diff --git a/src/main.ts b/src/main.ts index 5e896c6c..5a5f159f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import WebSocket from 'ws'; import type { FetchRequest, FetchResponse } from './types'; import * as telemetry from './telemetry'; import * as smtc from './smtc'; +import { isMicInUse } from './mic-in-use'; // Ensure the renderer's Media Session API (used to surface "now playing" to // Windows System Media Transport Controls / macOS Now Playing) is wired up. @@ -49,7 +50,7 @@ smtc.init((action) => { wsSend({ namespace: 'playback', groupId, command: 'play' }, { allowTvPauseRestore: true, deviceFeedback: 'NONE' }).catch(() => {}); break; case 'pause': - wsSend({ namespace: 'playback', groupId, command: 'pause' }, { allowTvPauseRestore: true, deviceFeedback: 'NONE' }).catch(() => {}); + void handleSmtcPause(); break; case 'next': wsSend({ namespace: 'playback', groupId, command: 'skipToNextTrack' }, {}).catch(() => {}); @@ -60,6 +61,39 @@ smtc.init((action) => { } }, { displayName: 'True Tunes', appPath: app.getAppPath(), isPackaged: app.isPackaged }); +/** How long after an SMTC pause we keep watching for a call starting. */ +const CALL_PAUSE_GRACE_MS = 4000; +const CALL_PAUSE_POLL_MS = 500; + +/** + * Teams (and other call apps) pause every SMTC media session when you join a + * call. That's right for local audio, but here it would pause the office + * speakers for everyone. The pause event doesn't say who sent it, so we use + * "another app has the mic open" as the call signal: if it already is, the + * pause is ignored; if it opens within a few seconds of us pausing, we resume. + */ +async function handleSmtcPause(): Promise { + if (await isMicInUse()) { + console.log('[smtc] ignoring pause: microphone in use (call in progress)'); + telemetry.event('SmtcPauseIgnored', { reason: 'mic-in-use' }); + return; + } + const sendPlayback = (command: 'play' | 'pause') => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + wsSend({ namespace: 'playback', groupId: config.groupId, command }, { allowTvPauseRestore: true, deviceFeedback: 'NONE' }).catch(() => {}); + }; + sendPlayback('pause'); + for (let waited = 0; waited < CALL_PAUSE_GRACE_MS; waited += CALL_PAUSE_POLL_MS) { + await new Promise((r) => setTimeout(r, CALL_PAUSE_POLL_MS)); + if (await isMicInUse()) { + console.log('[smtc] call started right after pause, resuming playback'); + telemetry.event('SmtcPauseIgnored', { reason: 'mic-opened-after' }); + sendPlayback('play'); + return; + } + } +} + // ─── Config ────────────────────────────────────────────────────────────────── /** Music service credentials (persisted to userData/config.json) plus diff --git a/src/mic-in-use.test.ts b/src/mic-in-use.test.ts new file mode 100644 index 00000000..91011b06 --- /dev/null +++ b/src/mic-in-use.test.ts @@ -0,0 +1,45 @@ +// Run: npm run test:main +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseMicUsage } from './mic-in-use.ts'; + +const ROOT = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone'; + +const SAMPLE = [ + ROOT, + ' Value REG_SZ Allow', + '', + `${ROOT}\\Microsoft.Copilot_8wekyb3d8bbwe`, + ' Value REG_SZ Prompt', + ' LastSetTime REG_QWORD 0x1db9a44d9072d01', + '', + `${ROOT}\\MSTeams_8wekyb3d8bbwe`, + ' Value REG_SZ Allow', + ' LastUsedTimeStart REG_QWORD 0x1dd3ac0038f1caf', + ' LastUsedTimeStop REG_QWORD 0x0', + '', + `${ROOT}\\NonPackaged`, + '', + `${ROOT}\\NonPackaged\\C:#Program Files#Discord#Discord.exe`, + ' LastUsedTimeStart REG_QWORD 0x1dd3ac0038f1caf', + ' LastUsedTimeStop REG_QWORD 0x1dd3ac0038f2000', + '', +].join('\r\n'); + +test('reports apps with an open mic (start set, stop zero)', () => { + assert.deepEqual(parseMicUsage(SAMPLE), [`${ROOT}\\MSTeams_8wekyb3d8bbwe`]); +}); + +test('finished sessions and never-used apps are not in use', () => { + const idle = SAMPLE.replace('LastUsedTimeStop REG_QWORD 0x0', 'LastUsedTimeStop REG_QWORD 0x1dd3ac0038f2000'); + assert.deepEqual(parseMicUsage(idle), []); +}); + +test('in-use entry as the last key in the output is still reported', () => { + const out = `${ROOT}\\NonPackaged\\C:#zoom.exe\r\n LastUsedTimeStart REG_QWORD 0x1\r\n LastUsedTimeStop REG_QWORD 0x0`; + assert.deepEqual(parseMicUsage(out), [`${ROOT}\\NonPackaged\\C:#zoom.exe`]); +}); + +test('empty output', () => { + assert.deepEqual(parseMicUsage(''), []); +}); diff --git a/src/mic-in-use.ts b/src/mic-in-use.ts new file mode 100644 index 00000000..62280c23 --- /dev/null +++ b/src/mic-in-use.ts @@ -0,0 +1,52 @@ +// Windows-only "is another app using the microphone right now?" check, used to +// tell a call app's automatic SMTC pause (Teams pauses every media session +// when you join a call) apart from a deliberate media-key / flyout pause. +// +// Windows records per-app microphone usage under the CapabilityAccessManager +// consent store (the same data behind the taskbar's "app is using your +// microphone" indicator). An app is currently using the mic when its key has +// a non-zero LastUsedTimeStart and LastUsedTimeStop == 0. Packaged apps (e.g. +// new Teams, MSTeams_8wekyb3d8bbwe) are direct subkeys; Win32 apps live under +// NonPackaged. Complete no-op (always false) off Windows or if `reg` fails. + +import { execFile } from 'child_process'; + +const MIC_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone'; + +/** Parse `reg query /s` output into the list of keys currently using the mic. */ +export function parseMicUsage(output: string): string[] { + const active: string[] = []; + let key: string | null = null; + let start = 0n; + let stop: bigint | null = null; + const flush = () => { + if (key && start !== 0n && stop === 0n) active.push(key); + }; + for (const raw of output.split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith('HKEY_')) { + flush(); + key = line; + start = 0n; + stop = null; + continue; + } + const m = /^(LastUsedTimeStart|LastUsedTimeStop)\s+REG_QWORD\s+(0x[0-9a-fA-F]+)$/.exec(line); + if (!m) continue; + if (m[1] === 'LastUsedTimeStart') start = BigInt(m[2]); + else stop = BigInt(m[2]); + } + flush(); + return active; +} + +/** Resolves true if any app currently has the microphone open. Never rejects. */ +export function isMicInUse(): Promise { + if (process.platform !== 'win32') return Promise.resolve(false); + return new Promise((resolve) => { + execFile('reg', ['query', MIC_KEY, '/s'], { windowsHide: true, timeout: 2000 }, (err, stdout) => { + if (err) return resolve(false); + resolve(parseMicUsage(stdout).length > 0); + }); + }); +}