diff --git a/CLAUDE.md b/CLAUDE.md index 8a4e0eea..c41663e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,9 @@ Handler registration lives in [src/main/ipc/](src/main/ipc/) - one file per doma ### Transcription and Suggestion Flow -[src/main/services/transcript.service.ts](src/main/services/transcript.service.ts) is the central orchestrator. `transcriptService.ingest(channel, type, text)` merges both audio channels, deduplicates overlapping segments, and decides whether a final `Other` transcript is worth answering - with a `LIVE_SUGGESTION_GAP_MS` guard that suppresses the call if Self spoke recently. +[src/main/services/transcript.service.ts](src/main/services/transcript.service.ts) is the central orchestrator. `transcriptService.ingest(channel, type, text)` merges both audio channels and decides whether a final `Other` transcript is worth answering - with a `LIVE_SUGGESTION_GAP_MS` guard that suppresses the call if Self spoke recently. + +**Nothing deduplicates.** `mergeAdjacentTranscripts` concatenates consecutive blocks from the *same* speaker that fall within `TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS`, and no code anywhere compares the two channels against each other. This line claimed the opposite for a while, which is worth naming because the gap it papered over is the whole of #111: on speakers the microphone re-captures the interviewer, the same words arrive on `ch_0` and `ch_1`, and both are kept. See Headphones below. - `ch_0` = `Speaker.Other` (interviewer, captured via loopback audio) - `ch_1` = `Speaker.Self` (candidate, captured via microphone) @@ -262,6 +264,24 @@ visible; the damaging half is not. The echo lands as a recent `Self` final, so suppresses the live suggestion **for the question that was just asked**, with no error anywhere. See #111 for the measurements and the longer-term suppression work. +How much of the interviewer the microphone actually re-captures is a property of the machine, not +something to reason about, and no constant in a future gate should be picked before it is measured. +`test/manual/echo-probe.mjs` runs both captures through one worklet and reports the signed +arrival-order delay, the correlation peak at that lag and the echo return loss, once a second - run +by hand (`pnpm exec electron test/manual/echo-probe.mjs`), deliberately not in `test/run.mjs`, since +it needs a desktop session, real speakers, and a person to play audio into them. `--no-aec`, +`--no-ns` and `--no-agc` drive the A/B on the processing flags below. + +Those flags are stated rather than defaulted. Every `getUserMedia` in the app opens through +`micConstraints()` in +[live-transcription.service.ts](src/renderer/services/live-transcription.service.ts), which writes +out `echoCancellation`, `noiseSuppression` and `autoGainControl`. Chromium already defaults all +three to `true`, so this changes nothing today; the point is that they stop moving on their own +under a version bump, and that there is one place to flip them from once the probe says which way +they should go. `test/mic-constraints.test.mjs` fails on any capture that opens its own way instead, +which is not hypothetical - two of them have already been added, one duplicating the flags and one +opening with `audio: true`, and neither produced a conflict, a type error or a lint warning. + [headphone-notice-dialog.tsx](src/renderer/components/custom/headphone-notice-dialog.tsx) is shown before every session until the user silences it, and it says what actually goes wrong rather than recommending headphones for "best results" - the cost of ignoring it is answers that never appear. diff --git a/src/renderer/components/custom/settings/microphone-field.tsx b/src/renderer/components/custom/settings/microphone-field.tsx index 9da6a123..5962d49c 100644 --- a/src/renderer/components/custom/settings/microphone-field.tsx +++ b/src/renderer/components/custom/settings/microphone-field.tsx @@ -15,7 +15,7 @@ import { useAppState } from '@/hooks/use-app-state'; import { useAudioInputDevices } from '@/hooks/use-audio-devices'; import { useAudioInputDevice } from '@/hooks/use-audio-input-device'; import { useConfigStore } from '@/hooks/use-config-store'; -import { resolveMicDeviceId } from '@/services/live-transcription.service'; +import { micConstraints, resolveMicDeviceId } from '@/services/live-transcription.service'; import { RunningState } from '@/types/app-state'; /** @@ -91,8 +91,11 @@ export function MicrophoneField() { setTestStarting(true); try { const deviceId = await resolveMicDeviceId(deviceName); + // The same constraints a session opens with, so the level shown here is measured through + // the same processing chain the session will use. Opened as `true`, the test stream could + // run different gain and noise handling than the capture it is meant to predict. const stream = await navigator.mediaDevices.getUserMedia({ - audio: deviceId ? { deviceId: { exact: deviceId } } : true, + audio: micConstraints(deviceId), }); setTestStream(stream); } catch (e) { diff --git a/src/renderer/services/live-transcription.service.ts b/src/renderer/services/live-transcription.service.ts index 99b5dd3a..0378ede2 100644 --- a/src/renderer/services/live-transcription.service.ts +++ b/src/renderer/services/live-transcription.service.ts @@ -25,6 +25,43 @@ function buildStreamingUrl(language: Language): string { return `${STREAMING_URL}?language=${encodeURIComponent(language)}`; } +/** + * Whether the microphone track runs Chromium's automatic gain control. + * + * Kept as a named constant rather than inlined because it is the flag most likely to move. AGC is + * the largest source of coupling-gain instability when the candidate is on speakers: it raises + * gain through quiet passages, which amplifies re-captured interviewer audio at exactly the moment + * an echo gate is trying to measure how much of it there is. The opposite pull is ASR accuracy for + * a quiet candidate. Measure with `test/manual/echo-probe.mjs` before changing it. + */ +const MIC_AUTO_GAIN_CONTROL = true; + +/** + * The constraints every microphone capture in the app opens with. + * + * The three processing flags are stated rather than left out. Chromium's defaults for an + * unspecified flag are already `true` for all three, so writing them changes nothing today - the + * point is that it stops changing on its own when Chromium's defaults move under a version bump, + * and that there is one place to flip them when the echo probe says which way they should go. + * + * An absent `deviceId` is the "system default microphone" case, and is deliberately expressed as + * an object with no `deviceId` key rather than as `audio: true` - `true` would drop the flags with + * it and put that user back on whatever Chromium currently defaults to. + * + * Exported because "one place" only holds if every caller uses it. The mock service and the + * settings microphone test open their own streams, and a second copy of these flags is the same + * drift this exists to stop - with the extra sting that the level the test meter shows would be + * measured through different processing than the session it is meant to predict. + */ +export function micConstraints(deviceId: string | null): MediaTrackConstraints { + return { + ...(deviceId ? { deviceId: { exact: deviceId } } : {}), + echoCancellation: true, + noiseSuppression: true, + autoGainControl: MIC_AUTO_GAIN_CONTROL, + }; +} + // Inline AudioWorklet processor (runs off the main thread) const AUDIO_WORKLET_CODE = ` class AudioSenderWorklet extends AudioWorkletProcessor { @@ -487,7 +524,7 @@ class LiveTranscriptionService { const micDeviceId = await resolveMicDeviceId(audioInputDeviceName); this.micStream = await navigator.mediaDevices.getUserMedia({ - audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true, + audio: micConstraints(micDeviceId), video: false, }); @@ -566,7 +603,7 @@ class LiveTranscriptionService { const deviceId = await resolveMicDeviceId(deviceName); const nextStream = await navigator.mediaDevices.getUserMedia({ - audio: deviceId ? { deviceId: { exact: deviceId } } : true, + audio: micConstraints(deviceId), video: false, }); diff --git a/src/renderer/services/mock-transcription.service.ts b/src/renderer/services/mock-transcription.service.ts index c2b507be..a8643ac4 100644 --- a/src/renderer/services/mock-transcription.service.ts +++ b/src/renderer/services/mock-transcription.service.ts @@ -1,7 +1,7 @@ import { getElectron } from '@/lib/utils'; import { Language } from '@/types/language'; -import { AudioWsStream, resolveMicDeviceId } from './live-transcription.service'; +import { AudioWsStream, micConstraints, resolveMicDeviceId } from './live-transcription.service'; /** * Microphone-only capture for a mock interview. @@ -34,14 +34,7 @@ class MockTranscriptionService { const micDeviceId = await resolveMicDeviceId(audioInputDeviceName); this.micStream = await navigator.mediaDevices.getUserMedia({ - audio: micDeviceId - ? { - deviceId: { exact: micDeviceId }, - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - } - : { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + audio: micConstraints(micDeviceId), video: false, }); diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs new file mode 100644 index 00000000..a6870c4c --- /dev/null +++ b/test/manual/echo-probe.mjs @@ -0,0 +1,450 @@ +/** + * Manual measurement of how much of the interviewer's audio the microphone re-captures. + * + * When the candidate listens on speakers, the mic picks the interviewer up too, so the same words + * arrive on both channels. `transcript.service.ts` attributes speaker purely by channel name, so + * the echo is filed as the candidate - and a recent `Self` final is exactly what + * `skipDueToRecentSelf` suppresses live suggestions on. The suppression is silent, which is what + * makes it worth measuring rather than reasoning about. + * + * Nothing here gates or fixes anything. It reports three numbers, and the constants of any gate + * built later have to be sized from them rather than guessed: + * + * delayMs arrival-order difference between the two channels, WITH ITS SIGN. Chromium's + * getDisplayMedia loopback path carries its own latency, and if it is the slower + * of the two, the reference arrives *after* the mic's echo of it. A gate that + * searched only 0..MAX would find no peak on precisely the machines that need it. + * correlation peak height at that lag - what separates speakers from headphones. + * erlDb how far below the reference the echo sits. Also the score for the A/B below. + * + * Not in `test/run.mjs`: it needs a desktop session, real speakers, and a person to play audio + * into them. CI runs headless Linux. + * + * cd client + * pnpm exec electron test/manual/echo-probe.mjs + * pnpm exec electron test/manual/echo-probe.mjs --seconds=60 --device="Microphone (Realtek)" + * + * The A/B the constraints work exists for - compare `erlDb` between: + * + * pnpm exec electron test/manual/echo-probe.mjs --no-aec + * pnpm exec electron test/manual/echo-probe.mjs --no-agc + * pnpm exec electron test/manual/echo-probe.mjs --no-ns + * + * Establish the spread of the UNCHANGED configuration first, by running the default several times + * over, and treat any difference smaller than that spread as no difference at all. This is not + * pedantry: on the first machine measured, two 16 s runs of the same configuration came back 2.8 dB + * apart while the aec on/off pair differed by 4.3 dB. Two runs cannot separate those. Use the full + * default duration or longer, and repeat, before writing a number down. + * + * If the summary says the peak sits at the edge of the search window, it names the flag to widen + * it with. The window is `--min-lag=` / `--max-lag=`, in ms, and it is signed: + * + * pnpm exec electron test/manual/echo-probe.mjs --min-lag=-1000 + * + * Play a recorded interview through the speakers at a normal listening volume for the whole run, + * and stay quiet - near-end speech is what poisons an ERL estimate. + * + * If `electron --version` prints a Node version rather than an Electron one, `ELECTRON_RUN_AS_NODE` + * is set in your shell; clear it first. + */ +import { app, BrowserWindow, ipcMain } from 'electron'; +import loopbackPkg from 'electron-audio-loopback'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +const args = process.argv.slice(2); + +const FLAGS = ['--no-aec', '--no-ns', '--no-agc']; +const VALUES = ['seconds', 'device', 'min-lag', 'max-lag']; + +// The lag search window, in ms, and deliberately WIDER than the window any gate is expected to +// ship with (-300..+600). The probe's job includes finding out whether the real value lands near +// an edge, and a search that stops exactly where the proposed window stops cannot tell "the peak +// is at the edge" from "the window is too small". +// +// Overridable because the first machine actually measured put its peak at -400, the floor of this +// default, which is the probe reporting that the window is too small - and the summary's answer to +// that is "widen it and re-run". That should not mean editing renderer.js. +const DEFAULT_MIN_LAG_MS = -400; +const DEFAULT_MAX_LAG_MS = 800; + +// Rejected rather than ignored, because the whole point of the flags is the A/B: a mistyped +// `--noaec` that is silently dropped runs with echo cancellation ON and reports a perfectly +// plausible number for the configuration you were trying to rule out. +const unknown = args.filter( + (a) => !FLAGS.includes(a) && !VALUES.some((name) => a.startsWith(`--${name}=`)) +); +if (unknown.length > 0) { + console.error(`Unknown argument(s): ${unknown.join(' ')}`); + console.error(`Expected: ${FLAGS.join(' ')} ${VALUES.map((v) => `--${v}=...`).join(' ')}`); + process.exit(2); +} + +const flag = (name) => args.includes(name); +const value = (name, fallback) => { + const hit = args.find((a) => a.startsWith(`--${name}=`)); + return hit === undefined ? fallback : hit.slice(name.length + 3); +}; + +const seconds = Number(value('seconds', 45)); +if (!Number.isFinite(seconds) || seconds <= 0) { + // Left unchecked this reaches setTimeout as NaN, which fires immediately - so the run ends + // before it starts and reports "no correlated frames", which reads like a headphone result. + console.error(`--seconds must be a positive number, got "${value('seconds', '')}"`); + process.exit(2); +} + +/** A lag bound in ms, or its default. Rejected rather than coerced, for the `--seconds` reason. */ +const lagMs = (name, fallback) => { + const raw = value(name, null); + if (raw === null) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + console.error(`--${name} must be a number of milliseconds, got "${raw}"`); + process.exit(2); + } + return parsed; +}; + +const minLagMs = lagMs('min-lag', DEFAULT_MIN_LAG_MS); +const maxLagMs = lagMs('max-lag', DEFAULT_MAX_LAG_MS); +if (minLagMs >= maxLagMs) { + // Inverted or empty, the lag loop runs zero times, no estimate is ever produced, and the run + // reports no correlated frames - which reads exactly like a headphone result. + console.error(`--min-lag must be below --max-lag, got ${minLagMs} and ${maxLagMs}`); + process.exit(2); +} + +const options = { + seconds, + minLagMs, + maxLagMs, + device: value('device', ''), + echoCancellation: !flag('--no-aec'), + noiseSuppression: !flag('--no-ns'), + autoGainControl: !flag('--no-agc'), +}; + +// Must run before the app is ready: it appends a Chromium feature switch as well as registering +// the two IPC handlers, and the switch is only read at startup. +loopbackPkg.initMain(); + +const num = (v, digits = 1) => (v === null || v === undefined ? ' --' : v.toFixed(digits)); + +/** + * Quit with a real exit status. + * + * `process.exitCode = 1` followed by `app.quit()` does not survive: Electron ends the process + * through its own path and the status comes out 0, so every failure here reported success to the + * shell. Verified - a `--device` that does not exist printed its error and exited 0. `app.exit` + * is the one that carries the code. + * + * The writes above it are `console.error`, which is synchronous to a TTY and to a pipe on the + * platforms this runs on, so the message is out before the process goes. + */ +const quitWith = (code) => app.exit(code); + +// Counted, not latched. A single coupled report out of forty is noise, not a speaker setup, and +// the whole reason prominence exists is that spurious single-report verdicts are reachable. A +// boolean here would let one of them decide the headline finding for the entire run. +let coupledReports = 0; +let totalReports = 0; +let lastFrames = 0; +let stalled = false; +let deadTracks = false; + +// The loudest the reference ever got, as a percentage of frames above the floor. A run where this +// stays at zero played nothing for the microphone to re-capture, so it measured nothing about +// coupling - and that is a different answer from "headphones", which the summary used to offer as +// an equal possibility rather than ruling it out with the column it already had. +let peakRefActivePct = 0; +const REF_ACTIVE_MIN_PCT = 5; + +// Whether the run reached an end of its own - a summary or a reported failure. Closing the window +// is an ordinary thing to do to a window, and without this it ends the process quietly at exit 0, +// which is indistinguishable from a run that completed and says nothing about the missing summary. +let finished = false; + +ipcMain.handle('probe:options', () => options); + +ipcMain.on('probe:ready', (_event, info) => { + console.log(`\nmicrophone : ${info.micLabel}`); + console.log( + ` requested: aec=${options.echoCancellation} ns=${options.noiseSuppression} agc=${options.autoGainControl}` + ); + console.log( + ` applied : aec=${info.micSettings.echoCancellation} ns=${info.micSettings.noiseSuppression} agc=${info.micSettings.autoGainControl}` + ); + // The A/B is scored by running with one flag off and comparing erlDb, which measures nothing if + // the platform quietly declined to turn it off: two runs of the same configuration, reported as + // a comparison. These constraints are advisory, so Chromium is free to ignore them and say so + // only in getSettings(). This is the same failure as the mistyped `--noaec` the argument parser + // rejects above, one layer down and not the operator's fault, so it is worth as much noise. + const FLAG_KEYS = [ + ['aec', 'echoCancellation'], + ['ns', 'noiseSuppression'], + ['agc', 'autoGainControl'], + ]; + const ignored = FLAG_KEYS.filter( + ([, key]) => info.micSettings[key] !== undefined && info.micSettings[key] !== options[key] + ); + const unreported = FLAG_KEYS.filter(([, key]) => info.micSettings[key] === undefined); + if (ignored.length > 0) { + const names = ignored.map(([short]) => short).join(' and '); + console.log( + `\nWARNING: this device did not apply ${names} as requested. An A/B that differs only in\n` + + 'that flag is then comparing two runs of the same configuration. Score erlDb on another\n' + + 'device, or drop that flag from the comparison.' + ); + } + if (unreported.length > 0) { + const names = unreported.map(([short]) => short).join(', '); + console.log( + `\nNote: this device does not report ${names} back, so whether the request was honoured\n` + + 'cannot be confirmed from here.' + ); + } + + console.log(`loopback : ${info.loopbackTracks} audio track(s)`); + if (info.loopbackTracks === 0) { + // Said here rather than left to be inferred from an empty ref% column forty lines later. + // With no reference there is nothing to correlate against, so the run can only report "no + // coupling" - the headphone answer, for a reason that has nothing to do with headphones. + console.log( + '\nWARNING: the loopback capture carries no audio track, so there is no reference to\n' + + 'correlate against and every result below will read as "no coupling". Check that system\n' + + 'audio capture is permitted and re-run.' + ); + } + console.log( + `\nPlay interviewer audio through the speakers for ${options.seconds}s. Stay quiet.\n` + ); + console.log(' delayMs corr prom erlDb ref% mic% coupled'); + console.log(' ------- ---- ---- ----- ---- ---- -------'); +}); + +ipcMain.on('probe:metrics', (_event, m) => { + // Both health checks run *before* the report is counted. A report the probe is about to refuse + // to print is not evidence either way, and `coupled` on such a report is the verdict of an + // estimate that ran against audio which is no longer arriving - counting it would let a dead + // capture vote on the run's headline finding, which is the one thing these counters exist to + // stop. + // + // No new frames means the graph itself is not running: a suspended AudioContext, or a closed + // one. Every column below would then be a stale reading of a dead graph, which is worse than no + // reading at all because it looks like data. + // + // It does NOT catch an unplugged microphone. The worklet is pulled by the destination for the + // life of the context and zero-pads a missing input by design, so frames keep arriving at 100/s + // after a track dies, with the columns quietly decaying toward the noise floor. That case is + // what `deadTracks` covers. + const advanced = m.frames - lastFrames; + lastFrames = m.frames; + if (advanced === 0) { + if (m.frames === 0) { + // Before the first frame, not after the last one. The graph has not started yet, which is + // an ordinary first second - flagging it as a stall would put a "re-run this" warning on + // the summary of a run that then went perfectly. + console.log(' -- waiting for the first audio frame --'); + return; + } + stalled = true; + console.log(' -- no audio frames received since the last report (capture stalled) --'); + return; + } + + if (m.deadTracks.length > 0) { + deadTracks = true; + console.log(` -- ${m.deadTracks.join(' and ')} stopped delivering audio --`); + return; + } + + totalReports++; + if (m.coupled) coupledReports++; + if (m.refActivePct > peakRefActivePct) peakRefActivePct = m.refActivePct; + + console.log( + ` ${String(m.delayMs === null ? '--' : m.delayMs).padStart(7)}` + + ` ${num(m.correlation, 2).padStart(4)}` + + ` ${num(m.prominence, 2).padStart(4)}` + + ` ${num(m.erlDb).padStart(6)}` + + ` ${num(m.refActivePct, 0).padStart(4)}` + + ` ${num(m.micActivePct, 0).padStart(4)}` + + ` ${m.coupled ? 'yes' : 'no'}` + ); +}); + +ipcMain.on('probe:done', (_event, summary) => { + finished = true; + console.log('\n=== summary ==='); + if (!summary.samples) { + // States the fact and stops. Reading it is the verdict's job, which has the ref% history and + // the report counts to do it with. This used to offer "a headphone setup (the good case)" as + // one of two possibilities, and it now sits directly above a verdict that can tell which - + // and sometimes above one saying nothing was measured at all, which it would contradict. + console.log('accepted estimates : 0 (no estimate passed the coupling test)'); + console.log(`search window : ${summary.searchWindow[0]}..${summary.searchWindow[1]} ms`); + } else { + console.log(`accepted estimates : ${summary.samples}`); + console.log( + `delayMs : median ${summary.delayMsMedian}, range ${summary.delayMsMin}..${summary.delayMsMax}` + ); + console.log(`correlation : median ${num(summary.correlationMedian, 2)}`); + console.log(`prominence : median ${num(summary.prominenceMedian, 2)}`); + console.log(`erlDb : median ${num(summary.erlDbMedian)}`); + // Said because the two disagree on purpose and it reads as an error otherwise. The table + // above samples whatever the latest estimate was, once a second; these medians cover only the + // estimates that passed the coupling test, of which there are two per printed row. So they + // are drawn from a different and better population, and will read higher than any single row. + console.log(' (medians over accepted estimates only, which run twice per'); + console.log(' printed row - so they read higher than the table above)'); + console.log(`search window : ${summary.searchWindow[0]}..${summary.searchWindow[1]} ms`); + + const [lo, hi] = summary.searchWindow; + const atFloor = summary.delayMsMedian <= lo + 50; + if (atFloor || summary.delayMsMedian >= hi - 50) { + console.log( + '\nWARNING: the peak sits at the edge of the search window, so the true delay may' + ); + // Names the flag and the value to re-run with, rather than a constant to go and edit. This + // warning is not exotic: it fired on the first machine measured. The suggestion is clamped + // to what the correlator can actually search, so it can never name a window the probe would + // then reject - and when there is no room left to widen, it says that instead. + const span = hi - lo; + const target = atFloor + ? Math.max(-summary.usableLagMs, Math.round(lo - span / 2)) + : Math.min(summary.usableLagMs, Math.round(hi + span / 2)); + if (atFloor ? target < lo : target > hi) { + const flag = atFloor ? `--min-lag=${target}` : `--max-lag=${target}`; + console.log(`lie outside it. Re-run with ${flag} before treating this number as`); + console.log('the real one.'); + } else { + console.log( + `lie outside it - but the window already spans the ${summary.usableLagMs} ms the` + ); + console.log('correlator can search, so resolving it needs a longer history rather than a'); + console.log('wider window. Treat this delay as a lower bound.'); + } + } + if (summary.delayMsMedian < 0) { + console.log('\nNote: the delay is NEGATIVE - the loopback reference arrives after the mic'); + console.log('echo it explains. Any gate on this machine has to search signed lags and delay'); + console.log('the mic to keep its decisions causal.'); + } + } + const pct = totalReports > 0 ? Math.round((100 * coupledReports) / totalReports) : 0; + console.log(''); + console.log(`coupled reports : ${coupledReports}/${totalReports} (${pct}%)`); + // Nothing was measured at all: every report was discarded as stalled or dead, or the run was + // too short to produce one. Reaching the "no coupling" branch here would be the worst version + // of the failure this whole summary is built to avoid - a confident headphone verdict from a + // probe that never took a single valid reading. + if (totalReports === 0) { + console.log('verdict : NOTHING MEASURED - not one valid report in the whole run.'); + console.log(' Every report was discarded (see any warning below), or the'); + console.log(' run was shorter than the one-second report interval.'); + } + // The reference was silent throughout, so nothing was ever played for the microphone to + // re-capture. That is not evidence of headphones, and the old wording offered the two as equal + // readings of the same result while the ref% column had already told them apart. A run measures + // coupling only if something was coupling-capable in the first place. + else if (peakRefActivePct < REF_ACTIVE_MIN_PCT) { + console.log('verdict : NOTHING PLAYED - the loopback reference stayed silent for'); + console.log(' the whole run (ref% never rose), so there was nothing for'); + console.log(' the microphone to re-capture. This says nothing either way'); + console.log(' about coupling. Start the audio first, then re-run.'); + } + // The two counters measure different things and can disagree: estimates run twice a second, + // reports are sampled once a second, so intermittent coupling can be accepted into `samples` + // without a single report tick ever landing on it. "No coupling" therefore has to clear both, + // or the summary prints a confident headphone verdict directly underneath a non-zero count of + // accepted coupled estimates. + else if (coupledReports === 0 && !summary.samples) { + console.log('verdict : no coupling (headphones - audio was playing and the mic did'); + console.log(' not pick it up)'); + } else if (coupledReports >= 3 && pct >= 20) { + console.log('verdict : coupled (speakers)'); + } else { + console.log('verdict : INCONCLUSIVE - too few coupled reports to call it either'); + // The remedy has to match the reason. "Play audio for the whole run" is the right advice only + // when the reference was patchy; told to someone whose ref% sat at 80 all run it is simply + // wrong, and it sends them to re-run the thing they already did correctly. A reference that + // was solid throughout means the coupling itself is marginal on this machine, which is a + // finding rather than a mistake. + if (peakRefActivePct >= 50) { + console.log(' way, though the reference was playing throughout. The'); + console.log(' coupling is marginal here rather than absent: re-run to'); + console.log( + ' see whether it is stable, and record it as marginal if so.' + ); + } else { + console.log(' way, and the reference was only intermittently active.'); + console.log(' Re-run with audio playing for the whole duration.'); + } + } + if (stalled) { + console.log(''); + console.log('WARNING: the capture stalled during this run, so the numbers above cover'); + console.log('less audio than the requested duration. Re-run before recording them.'); + } + if (deadTracks) { + console.log(''); + console.log('WARNING: a capture track ended mid-run - a device was unplugged, or the screen'); + console.log('share was stopped from the sharing bar. Reports after that point were discarded,'); + console.log('so this run covers less audio than requested. Re-run before recording it.'); + } + app.quit(); +}); + +ipcMain.on('probe:error', (_event, failure) => { + finished = true; + // The stack is omitted for the failures the renderer marks as the operator's to fix - a + // mistyped `--device`, a loopback that was never permitted. Their message is the whole answer, + // and for the device case it is a list of names to copy, which a stack trace only buries. + console.error('\nprobe failed:\n' + (failure.stack || failure.message)); + quitWith(1); +}); + +app + .whenReady() + .then(async () => { + const win = new BrowserWindow({ + width: 520, + height: 200, + title: 'Echo probe', + webPreferences: { + // A local, hand-run diagnostic that has to reach ipcRenderer from a plain script tag. The + // shipped app does the opposite - see navigation-guard.ts - and nothing here loads remote + // content. + nodeIntegration: true, + contextIsolation: false, + backgroundThrottling: false, + }, + }); + + await win.loadFile(path.join(HERE, 'echo-probe', 'index.html')); + }) + // Without this a failed load rejects into nothing: the window stays up showing "starting...", + // no report ever arrives, and the probe waits for a run that will not begin. + .catch((error) => { + finished = true; + console.error('\nprobe failed to start:\n' + (error && error.stack ? error.stack : error)); + quitWith(1); + }); + +app.on('window-all-closed', () => { + // Reached two ways: after `probe:done` or `probe:error` asked the app to quit, which is the + // ordinary end, and by the operator closing the window mid-run. Only the second one needs + // saying - it produces no summary at all, and silently exiting 0 would leave a half-run looking + // like a clean one in a scrollback that no longer shows where it stopped. + if (!finished) { + console.error('\nThe probe window was closed before the run finished, so there is no summary'); + console.error('and the reports above cover only part of the requested duration. Re-run and'); + console.error('let it reach its own end, or pass a shorter --seconds.'); + quitWith(1); + return; + } + app.quit(); +}); diff --git a/test/manual/echo-probe/index.html b/test/manual/echo-probe/index.html new file mode 100644 index 00000000..8e68efd6 --- /dev/null +++ b/test/manual/echo-probe/index.html @@ -0,0 +1,25 @@ + + + + + Echo probe + + + +
starting...
+ + + diff --git a/test/manual/echo-probe/renderer.js b/test/manual/echo-probe/renderer.js new file mode 100644 index 00000000..4b70d233 --- /dev/null +++ b/test/manual/echo-probe/renderer.js @@ -0,0 +1,479 @@ +/** + * Measures the coupling between the loopback reference and the microphone. Measures only - there + * is deliberately no gating here, because this runs *before* the gate exists and is what its + * constants get sized from. + * + * Three numbers come out of it, per machine: + * + * delayMs how far the mic's copy of the interviewer trails the loopback's, and crucially + * its SIGN. The acoustic path is always mic-after-speaker, but what is measured + * here is arrival order at the worklet, and Chromium's getDisplayMedia loopback + * path carries its own latency. If it is the slower of the two, the reference + * arrives after the echo it explains and the lag is negative - which a one-sided + * 0..MAX search would miss entirely, on exactly the setup the gate exists for. + * correlation peak height of the normalised cross-correlation at that lag. This is what + * separates a speaker setup from headphones, and what CORR_MIN gets set from. + * erlDb echo return loss: how far below the reference the mic's copy sits. This is the + * residual echo level, so it is also the number the echoCancellation and + * autoGainControl A/B is scored on. + */ +const { ipcRenderer } = require('electron'); + +const FRAME_MS = 10; +const HISTORY_FRAMES = 400; // 4 s +const XCORR_INTERVAL_MS = 500; +const REPORT_INTERVAL_MS = 1000; + +// The search window's defaults and the reasoning behind them live with the `--min-lag`/`--max-lag` +// flags in echo-probe.mjs, which is also where they are validated. They arrive here on `options` +// because the first real machine measured put its peak at the floor of the default window, and the +// summary's answer to that is "widen it and re-run" - which should not mean editing this file. + +// Frames quieter than this carry no reference to correlate against, and including them drags +// every estimate toward the noise floor. +const REF_FLOOR_DBFS = -55; + +// Peak height alone cannot tell coupling from noise, and this is the single most important thing +// the probe has measured so far. The search takes the MAX over ~120 candidate lags, and the max of +// many correlations is biased upward, so unrelated signals score far higher than intuition +// suggests: measured 0.53 on pure silence and 0.57 on two independent bursty signals. A threshold +// of 0.5 - which looks entirely reasonable written down - would call both of those "coupled". +// +// Getting that wrong has an asymmetric cost. A false "coupled" on a HEADPHONE user is what leads a +// gate to start cutting a microphone that was never echoing anything. +// +// So the discriminator is peak PROMINENCE: how far the best lag stands above the typical lag. A +// real echo puts a sharp peak on an otherwise flat correlation surface; unrelated signals produce +// a surface that is uniformly mediocre, with a high maximum and no peak. +// +// CORR_MIN is kept alongside it as a cheap floor, not as the discriminator - on its own it is +// exactly the threshold shown above to be useless. Both must pass. +// +// A starting threshold, to be re-derived from real runs rather than trusted. Synthetic signals +// suggested a comfortable gap - 0.28 for an unrelated pair against 0.87-1.13 for a clean echo - +// but a live run of this probe on a silent room reached 0.47, which leaves almost nothing between +// the noise and the threshold. Both ends of the synthetic gap are optimistic: that echo is a +// perfectly scaled copy and a real one scores lower, while that "unrelated" pair shares a burst +// grid and so scores higher than truly unrelated audio. +// +// This is why the run-level verdict requires several coupled reports rather than one. A single +// report crossing this line is exactly what a quiet room produces from time to time. +// +// The per-second output prints the raw numbers whatever this is set to, which is the point: +// measure the real distribution first, then set it. +const CORR_MIN = 0.5; +const PROMINENCE_MIN = 0.5; + +// The mirror of the reference floor on the microphone side, and not a calibration: an estimate can +// only be describing re-captured audio if the microphone recorded any. Found by running the probe +// with the reference playing, which accepted a "coupled" estimate at correlation 0.62 and +// prominence 0.61 with mic% at 0 and an ERL of -56 dB. That is not an echo 56 dB down, it is the +// correlator finding structure in a noise floor, at a lag pinned to the edge of the search window. +// +// The thresholds above cannot catch this on their own - the surface really does have a sharp peak. +// And a false "coupled" on a HEADPHONE user is the expensive direction, which is the whole reason +// prominence exists, so the cheapest physical precondition is required outright rather than left +// to a correlation score. Real coupling on the same machine ran mic% 24-45, so this rejects the +// impossible case without touching the measurement. +const MIC_ACTIVE_MIN_PCT = 5; + +const MIN_OVERLAP_FRAMES = 50; // 0.5 s +const DISPLAY_MEDIA_TIMEOUT_MS = 20000; + +const status = (text) => { + document.getElementById('status').textContent = text; +}; + +/** + * A failure the person running the probe can fix, as opposed to one that needs the code read. + * + * The distinction is only there to decide whether a stack trace is printed. The likeliest failure + * by far is a mistyped `--device`, whose message is a list of the device names that do exist - + * and burying that list under ten frames of Electron internals is the difference between an error + * that answers itself and one that has to be squinted at. + */ +class ProbeError extends Error {} + +const toDb = (power) => 10 * Math.log10(power + 1e-12); + +function meanSquare(frame) { + let sum = 0; + for (let i = 0; i < frame.length; i++) sum += frame[i] * frame[i]; + return sum / (frame.length || 1); +} + +function median(values) { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +/** + * Pearson correlation of the two log-energy envelopes with the reference shifted by `lag` frames. + * + * Envelopes rather than the waveforms themselves: the echo path filters the signal heavily, so + * sample-level correlation collapses while the energy contour survives. Positive `lag` means the + * mic trails the reference. + * + * A known bias, recorded rather than corrected because correcting it would move every number the + * gate is about to be sized from, on judgement rather than on data. The overlap shrinks as |lag| + * grows - 400 frames at lag 0 against 320 at +80 - so correlations at the edges of the search are + * estimated from ~20% less audio and are correspondingly noisier. The max over lags therefore + * leans very slightly outward, on the order of 0.02. Small against the 0.47-0.57 the noise floor + * has actually measured, and it cannot reach the summary's "peak sits at the edge" warning, which + * only prints for runs that already have accepted coupled estimates. Worth knowing before these + * numbers are used to pick a window: equalising the overlap across lags is the fix, and it costs + * the widest lag's worth of frames at every lag. + */ +function correlateAt(refDb, micDb, lag) { + const lo = Math.max(0, lag); + const hi = Math.min(micDb.length, refDb.length + lag); + const n = hi - lo; + if (n < MIN_OVERLAP_FRAMES) return null; + + let sumRef = 0; + let sumMic = 0; + for (let f = lo; f < hi; f++) { + sumRef += refDb[f - lag]; + sumMic += micDb[f]; + } + const meanRef = sumRef / n; + const meanMic = sumMic / n; + + let num = 0; + let devRef = 0; + let devMic = 0; + for (let f = lo; f < hi; f++) { + const dr = refDb[f - lag] - meanRef; + const dm = micDb[f] - meanMic; + num += dr * dm; + devRef += dr * dr; + devMic += dm * dm; + } + if (devRef <= 0 || devMic <= 0) return null; + return num / Math.sqrt(devRef * devMic); +} + +class CouplingMeter { + constructor(minLagMs, maxLagMs) { + this.minLagMs = minLagMs; + this.maxLagMs = maxLagMs; + this.refDb = []; + this.micDb = []; + this.lastXcorrAt = 0; + this.lag = null; + this.correlation = null; + this.prominence = null; + this.erlDb = null; + this.samples = []; + this.frames = 0; + } + + push(ref, mic) { + this.frames++; + this.refDb.push(toDb(meanSquare(ref))); + this.micDb.push(toDb(meanSquare(mic))); + if (this.refDb.length > HISTORY_FRAMES) this.refDb.shift(); + if (this.micDb.length > HISTORY_FRAMES) this.micDb.shift(); + + // Paced on the wall clock, which is fine here because frames genuinely arrive at 100/s from a + // live capture. Worth knowing before this is copied into the gate: it makes the class + // untestable from synthetic input, since a test loop feeds thousands of frames in a few + // milliseconds and no interval ever elapses. A gate that needs unit tests should pace on a + // frame counter instead. + const now = performance.now(); + if (now - this.lastXcorrAt >= XCORR_INTERVAL_MS) { + this.lastXcorrAt = now; + this.estimate(); + } + } + + estimate() { + const minLag = Math.round(this.minLagMs / FRAME_MS); + const maxLag = Math.round(this.maxLagMs / FRAME_MS); + + let bestLag = null; + let bestCorr = -2; + const all = []; + for (let lag = minLag; lag <= maxLag; lag++) { + const corr = correlateAt(this.refDb, this.micDb, lag); + if (corr === null) continue; + all.push(corr); + if (corr > bestCorr) { + bestCorr = corr; + bestLag = lag; + } + } + if (bestLag === null) return; + + this.lag = bestLag; + this.correlation = bestCorr; + // Against the median rather than the mean: a true echo's peak is broad enough to span several + // lags, and those neighbours would drag a mean up with it and hide the very prominence being + // measured. + this.prominence = bestCorr - median(all); + + // Only over frames with a live reference, or the ratio is two noise floors divided. + const ratios = []; + const lo = Math.max(0, bestLag); + const hi = Math.min(this.micDb.length, this.refDb.length + bestLag); + for (let f = lo; f < hi; f++) { + const refFrame = this.refDb[f - bestLag]; + if (refFrame < REF_FLOOR_DBFS) continue; + ratios.push(this.micDb[f] - refFrame); + } + this.erlDb = median(ratios); + + // All three conditions, and each rules out a different way of being wrong: a live reference + // (or the ratio is two noise floors divided), a peak worth having, and a peak that actually + // stands out from its neighbours rather than merely topping a flat surface. + if (this.isCoupled()) { + this.samples.push({ + delayMs: bestLag * FRAME_MS, + correlation: bestCorr, + prominence: this.prominence, + erlDb: this.erlDb, + }); + } + } + + activePct(series) { + if (series.length === 0) return 0; + const active = series.filter((db) => db >= REF_FLOOR_DBFS).length; + return (100 * active) / series.length; + } + + isCoupled() { + return ( + this.correlation !== null && + this.correlation >= CORR_MIN && + this.prominence !== null && + this.prominence >= PROMINENCE_MIN && + this.erlDb !== null && + // The microphone has to have heard something. See MIC_ACTIVE_MIN_PCT. + this.activePct(this.micDb) >= MIC_ACTIVE_MIN_PCT + ); + } + + snapshot() { + return { + delayMs: this.lag === null ? null : this.lag * FRAME_MS, + correlation: this.correlation, + prominence: this.prominence, + erlDb: this.erlDb, + refActivePct: this.activePct(this.refDb), + micActivePct: this.activePct(this.micDb), + coupled: this.isCoupled(), + // Reported so a stalled *graph* is visible. Nothing else here would show it: push() stops + // being called, the report timer keeps firing, and the same numbers print every second + // looking exactly like a steady measurement. + // + // This covers a suspended or closed AudioContext, and nothing else. It cannot see a dead + // capture: the worklet is pulled by the destination for the life of the context and + // zero-pads a missing input on purpose, so frames keep arriving after a track ends. See + // `deadTrackNames` in main() for that half. + frames: this.frames, + }; + } + + summary() { + if (this.samples.length === 0) + return { samples: 0, searchWindow: [this.minLagMs, this.maxLagMs] }; + const delays = this.samples.map((s) => s.delayMs); + const corrs = this.samples.map((s) => s.correlation); + const proms = this.samples.map((s) => s.prominence); + const erls = this.samples.map((s) => s.erlDb).filter((v) => v !== null); + return { + samples: this.samples.length, + delayMsMedian: median(delays), + delayMsMin: Math.min(...delays), + delayMsMax: Math.max(...delays), + correlationMedian: median(corrs), + prominenceMedian: median(proms), + erlDbMedian: median(erls), + searchWindow: [this.minLagMs, this.maxLagMs], + }; + } +} + +async function resolveMicDeviceId(deviceName) { + if (!deviceName) return null; + const devices = await navigator.mediaDevices.enumerateDevices(); + const match = devices.find((d) => d.kind === 'audioinput' && d.label === deviceName); + return match ? match.deviceId : null; +} + +async function main() { + const options = await ipcRenderer.invoke('probe:options'); + + // Checked here rather than in the CLI because this is where the numbers it depends on live, and + // checked at all because `--min-lag`/`--max-lag` are settable. A lag further from zero than the + // history can cover leaves `correlateAt` below its minimum overlap at every candidate, so it + // returns null for all of them, no estimate is ever produced, and the summary reports "no + // correlated frames" - the headphone answer, from a window that was simply too wide to search. + const usableLagMs = (HISTORY_FRAMES - MIN_OVERLAP_FRAMES) * FRAME_MS; + const widest = Math.max(Math.abs(options.minLagMs), Math.abs(options.maxLagMs)); + if (widest > usableLagMs) { + throw new ProbeError( + `The search window has to stay within +/-${usableLagMs} ms, and this one reaches ` + + `${widest} ms. The correlator holds ${HISTORY_FRAMES * FRAME_MS} ms of history and needs ` + + `${MIN_OVERLAP_FRAMES * FRAME_MS} ms of overlap at every lag it tests, so a wider window ` + + `produces no estimate at all rather than a wider search.` + ); + } + + status('acquiring microphone...'); + // enumerateDevices only fills in labels once a capture has been granted, so an unconstrained + // open comes first and is released immediately. + const priming = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + priming.getTracks().forEach((t) => t.stop()); + + const deviceId = await resolveMicDeviceId(options.device); + if (options.device && !deviceId) { + // Listed rather than just refused. `--device` matches the OS label exactly, and those labels + // are long, parenthesised and easy to get subtly wrong, so the names are the whole answer to + // the error - and they have already been enumerated by this point. + const labels = (await navigator.mediaDevices.enumerateDevices()) + .filter((d) => d.kind === 'audioinput') + .map((d) => ` ${d.label || '(unlabelled)'}`); + throw new ProbeError( + `No audio input device named "${options.device}". Available:\n${labels.join('\n')}` + ); + } + + const micStream = await navigator.mediaDevices.getUserMedia({ + audio: { + ...(deviceId ? { deviceId: { exact: deviceId } } : {}), + echoCancellation: options.echoCancellation, + noiseSuppression: options.noiseSuppression, + autoGainControl: options.autoGainControl, + }, + video: false, + }); + + status('acquiring loopback...'); + await ipcRenderer.invoke('enable-loopback-audio'); + let displayStream; + try { + // Bounded the same way live-transcription.service.ts bounds it. Unbounded, a loopback that + // never resolves leaves the probe sitting silently with no output and nothing to read. + displayStream = await Promise.race([ + navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }), + new Promise((_, reject) => + setTimeout( + () => + reject( + new ProbeError( + `Loopback capture did not start within ${DISPLAY_MEDIA_TIMEOUT_MS / 1000}s. ` + + 'Check that this machine permits system audio capture and re-run.' + ) + ), + DISPLAY_MEDIA_TIMEOUT_MS + ) + ), + ]); + } finally { + await ipcRenderer.invoke('disable-loopback-audio').catch(() => {}); + } + displayStream.getVideoTracks().forEach((track) => { + track.stop(); + displayStream.removeTrack(track); + }); + + const micTrack = micStream.getAudioTracks()[0]; + ipcRenderer.send('probe:ready', { + micLabel: micTrack ? micTrack.label : '(none)', + micSettings: micTrack ? micTrack.getSettings() : {}, + loopbackTracks: displayStream.getAudioTracks().length, + }); + + const ctx = new AudioContext(); + // Chromium can hand back a suspended context. Nothing then reaches the worklet, no frame is + // ever produced, and the run prints a full table of blanks before summarising a machine it + // never listened to as "no coupling" - which is the headphone verdict. + if (ctx.state === 'suspended') await ctx.resume(); + await ctx.audioWorklet.addModule('worklet.js'); + + const node = new AudioWorkletNode(ctx, 'echo-probe', { + numberOfInputs: 2, + numberOfOutputs: 1, + }); + + const refSource = ctx.createMediaStreamSource(displayStream); + const micSource = ctx.createMediaStreamSource(micStream); + refSource.connect(node, 0, 0); + micSource.connect(node, 0, 1); + + // Same silent sink the app uses: the graph needs a path to the destination to be pulled, and + // nothing here may reach the speakers - that would feed back into the very signal being measured. + const sink = ctx.createGain(); + sink.gain.value = 0; + node.connect(sink); + sink.connect(ctx.destination); + + const meter = new CouplingMeter(options.minLagMs, options.maxLagMs); + node.port.onmessage = (event) => meter.push(event.data.ref, event.data.mic); + + status('measuring - play interviewer audio through the speakers now'); + + /** + * Which captures have died, if any. + * + * The frame counter cannot answer this. The worklet is pulled by the destination for the life + * of the context and zero-pads a missing input by design, so frames keep arriving at 100/s + * after a track ends - the columns just decay quietly toward the noise floor while still + * looking like a measurement, which is the exact failure the frame counter was added to catch. + * + * `readyState === 'ended'` is the signal, and `muted` deliberately is not: an ended track is a + * device that is gone or a screen share the user stopped, while `muted` toggles on ordinary + * silence on some platforms and would discard most of a legitimately quiet run. + */ + const deadTrackNames = () => { + const dead = []; + const ended = (stream) => { + const tracks = stream.getAudioTracks(); + return tracks.length > 0 && tracks.every((t) => t.readyState === 'ended'); + }; + if (ended(micStream)) dead.push('microphone'); + if (ended(displayStream)) dead.push('loopback'); + return dead; + }; + + // The run asks the person to do something - play audio, stay quiet - for a fixed stretch, and + // the console table is the only thing that moves. A count of the seconds left is what tells + // them whether they can stop, without counting printed rows to work it out. + const startedAt = performance.now(); + const reportTimer = setInterval(() => { + const left = Math.max(0, Math.ceil(options.seconds - (performance.now() - startedAt) / 1000)); + status(`measuring - play interviewer audio through the speakers (${left}s left)`); + ipcRenderer.send('probe:metrics', { + ...meter.snapshot(), + deadTracks: deadTrackNames(), + sampleRate: ctx.sampleRate, + }); + }, REPORT_INTERVAL_MS); + + setTimeout(() => { + clearInterval(reportTimer); + status('done - the summary is in the console'); + // usableLagMs travels with the summary so the "widen the window" advice cannot name a value + // this file would then refuse. The limit is a property of the correlator, so it is sent from + // where it is derived rather than restated in the CLI. + ipcRenderer.send('probe:done', { ...meter.summary(), usableLagMs }); + micStream.getTracks().forEach((t) => t.stop()); + displayStream.getTracks().forEach((t) => t.stop()); + ctx.close(); + }, options.seconds * 1000); +} + +main().catch((error) => { + const message = String(error && error.message ? error.message : error); + status('failed: ' + message); + ipcRenderer.send('probe:error', { + message, + // Suppressed for a ProbeError, whose message is already the whole answer. Kept for everything + // else, where the probe has hit something it did not anticipate and the frames are the point. + stack: error instanceof ProbeError ? null : String(error && error.stack ? error.stack : error), + }); +}); diff --git a/test/manual/echo-probe/worklet.js b/test/manual/echo-probe/worklet.js new file mode 100644 index 00000000..4af6c770 --- /dev/null +++ b/test/manual/echo-probe/worklet.js @@ -0,0 +1,65 @@ +/** + * Hands both capture channels up to the main thread, frame-aligned. + * + * Two jobs beyond what the app's own worklet does today, and both are the reason this exists: + * it reads *every* channel of each input rather than only channel 0 (a stereo loopback otherwise + * loses its right channel, which is half the reference signal), and it batches to 10 ms frames so + * the two streams arrive as matched pairs the correlator can index directly. + * + * A missing input is zero-padded rather than skipped. Dropping the frame instead would let the + * two channels drift apart in frame count, and every delay estimate downstream is measured in + * frames. + */ +class EchoProbeWorklet extends AudioWorkletProcessor { + constructor() { + super(); + this.frameSize = Math.round(sampleRate * 0.01); + this.ref = new Float32Array(this.frameSize); + this.mic = new Float32Array(this.frameSize); + this.filled = 0; + } + + static sampleAt(input, index) { + if (!input || input.length === 0) return 0; + let sum = 0; + let channels = 0; + for (let c = 0; c < input.length; c++) { + const channel = input[c]; + if (!channel || channel.length === 0) continue; + sum += channel[index] || 0; + channels++; + } + return channels > 0 ? sum / channels : 0; + } + + static quantumLength(inputs) { + for (const input of inputs) { + if (input && input.length > 0 && input[0] && input[0].length > 0) return input[0].length; + } + return 128; + } + + process(inputs) { + const refIn = inputs[0]; + const micIn = inputs[1]; + const n = EchoProbeWorklet.quantumLength(inputs); + + for (let i = 0; i < n; i++) { + this.ref[this.filled] = EchoProbeWorklet.sampleAt(refIn, i); + this.mic[this.filled] = EchoProbeWorklet.sampleAt(micIn, i); + this.filled++; + + if (this.filled === this.frameSize) { + this.port.postMessage({ + ref: new Float32Array(this.ref), + mic: new Float32Array(this.mic), + }); + this.filled = 0; + } + } + + return true; + } +} + +registerProcessor('echo-probe', EchoProbeWorklet); diff --git a/test/mic-constraints.test.mjs b/test/mic-constraints.test.mjs new file mode 100644 index 00000000..2cbc8169 --- /dev/null +++ b/test/mic-constraints.test.mjs @@ -0,0 +1,110 @@ +/** + * Every microphone capture in the app must open through `micConstraints`. + * + * The three processing flags - `echoCancellation`, `noiseSuppression`, `autoGainControl` - are + * stated rather than left to Chromium's defaults, so that they stop moving on their own under a + * version bump and so that the echo work has one place to flip them from once the probe says + * which way they should go. That only holds while every caller actually uses it. + * + * This is pinned rather than trusted because it has already been broken once, in the ordinary + * way: while the echo branch was open, `main` grew two more captures. `mock-transcription.service` + * inlined its own copy of the three flags, and the settings microphone test opened with + * `audio: true`, which drops them entirely. Both merged clean - there is no conflict, no type + * error and no lint warning in adding a second spelling of a constraint object, which is exactly + * why a checker has to be the thing that notices. + * + * The `audio: true` case is the one with a user-visible edge: it is the mic *test* meter, so the + * level shown while choosing a device would be measured through different processing than the + * session that level is meant to predict. + * + * Only `src/` is scanned. `test/manual/echo-probe.mjs` opens its own capture with the flags + * varied on purpose - driving that A/B is the probe's whole job - so it must not be caught here. + * + * Source-level, for the same reason `audio-device-switch.test.mjs` is: renderer code, and the + * renderer has no runtime harness in this directory. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { codeOnly, createChecker, methodBody, readSource } from './helpers.mjs'; + +const SRC = fileURLToPath(new URL('../src', import.meta.url)); +const SERVICE = new URL('../src/renderer/services/live-transcription.service.ts', import.meta.url); + +/** Every .ts/.tsx file under `dir`, recursively. */ +function sourceFiles(dir) { + const found = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) found.push(...sourceFiles(full)); + else if (/\.tsx?$/.test(entry.name)) found.push(full); + } + return found; +} + +export async function run() { + const { check, failures } = createChecker('mic-constraints'); + + // Enough of the call to cover the options object's `audio` key without running into whatever + // follows the call itself. + const CALL_WINDOW = 200; + + const callSites = []; + for (const file of sourceFiles(SRC)) { + const code = codeOnly(readSource(pathToFileURL(file))); + for (const match of code.matchAll(/getUserMedia\(/g)) { + callSites.push({ + file: path.relative(SRC, file).replace(/\\/g, '/'), + text: code.slice(match.index, match.index + CALL_WINDOW), + }); + } + } + + // Without this the rest of the file passes vacuously the day someone renames the call or moves + // capture behind a wrapper - a green check meaning "found nothing to look at". + check('there are microphone captures to check', callSites.length > 0); + + const inlined = callSites.filter((site) => !site.text.includes('audio: micConstraints(')); + check( + `every capture opens through micConstraints${inlined.length ? ` (not: ${inlined.map((s) => s.file).join(', ')})` : ''}`, + inlined.length === 0 + ); + + // Called out separately from the check above because it is the specific regression that reached + // main, and because `true` fails differently: it does not merely duplicate the flags, it drops + // them and hands that capture back to whatever Chromium currently defaults to. + const bareTrue = callSites.filter((site) => /audio:\s*true/.test(site.text)); + check( + `no capture falls back to \`audio: true\`${bareTrue.length ? ` (not: ${bareTrue.map((s) => s.file).join(', ')})` : ''}`, + bareTrue.length === 0 + ); + + const service = codeOnly(readSource(SERVICE)); + + check( + 'micConstraints is exported for the other capture sites to use', + /export function micConstraints\(/.test(service) + ); + check('it is defined once', (service.match(/function micConstraints\(/g) || []).length === 1); + + // Scoped to the function's own braces. The inline worklet source further down this file ends + // its `process()` with `return true`, so a check that searched the rest of the file would fail + // on a perfectly correct implementation. + const constraints = methodBody(service, 'export function micConstraints('); + check('micConstraints has a body to read', constraints.length > 0); + + // The point of the helper is that the flags are written down, not that a helper exists. + for (const flag of ['echoCancellation', 'noiseSuppression', 'autoGainControl']) { + check(`micConstraints states ${flag}`, constraints.includes(`${flag}:`)); + } + + // The no-device case has to stay an object. Returning `true` for it would put every user on the + // system default microphone back on Chromium's defaults, silently, and only for them. + check( + 'the default-device case keeps the flags rather than returning `true`', + !/return true/.test(constraints) + ); + + return failures; +} diff --git a/test/run.mjs b/test/run.mjs index 9ef316ff..cd9636fe 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -48,6 +48,7 @@ for (const module of [ './mock-session-scroll.test.mjs', './speech-chunks.test.mjs', './audio-device-switch.test.mjs', + './mic-constraints.test.mjs', './language-switch.test.mjs', './rtl-rendering.test.mjs', './interviewer-turn.test.mjs',