Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(() => {});
Expand All @@ -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<void> {
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
Expand Down
45 changes: 45 additions & 0 deletions src/mic-in-use.test.ts
Original file line number Diff line number Diff line change
@@ -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(''), []);
});
52 changes: 52 additions & 0 deletions src/mic-in-use.ts
Original file line number Diff line number Diff line change
@@ -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 <MIC_KEY> /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<boolean> {
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);
});
});
}
Loading