From e43b865672fa8beb59a8c98610810967e119b991 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 27 Aug 2026 20:54:04 -0400 Subject: [PATCH 01/15] State the microphone processing constraints explicitly Both getUserMedia call sites passed only a deviceId, so echoCancellation, noiseSuppression and autoGainControl ran on whatever Chromium currently defaults to. All three default to true today, so this changes no behaviour - it stops the behaviour changing on its own under a Chromium version bump, and gives the echo work one place to flip them from. AGC is split out as a named constant because it is the flag most likely to move: it raises gain through quiet passages, which amplifies re-captured interviewer audio on a speaker setup. The no-device case is an object with no deviceId rather than `audio: true`, which would have dropped the flags along with it. Co-Authored-By: Claude Opus 5 --- .../services/live-transcription.service.ts | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/renderer/services/live-transcription.service.ts b/src/renderer/services/live-transcription.service.ts index 310a98e0..bb0d354f 100644 --- a/src/renderer/services/live-transcription.service.ts +++ b/src/renderer/services/live-transcription.service.ts @@ -25,6 +25,38 @@ 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 this service 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. + */ +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 { @@ -471,7 +503,7 @@ class LiveTranscriptionService { const micDeviceId = await this.resolveMicDeviceId(audioInputDeviceName); this.micStream = await navigator.mediaDevices.getUserMedia({ - audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true, + audio: micConstraints(micDeviceId), video: false, }); @@ -550,7 +582,7 @@ class LiveTranscriptionService { const deviceId = await this.resolveMicDeviceId(deviceName); const nextStream = await navigator.mediaDevices.getUserMedia({ - audio: deviceId ? { deviceId: { exact: deviceId } } : true, + audio: micConstraints(deviceId), video: false, }); From 71529ee079adfe6ab023c346c87704c6fd8e21ae Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 27 Aug 2026 21:00:00 -0400 Subject: [PATCH 02/15] Add a manual probe for microphone/loopback echo coupling Measures how much of the interviewer's audio the microphone re-captures when the candidate is on speakers. Reports three numbers per machine: the signed arrival-order delay between the two channels, the correlation peak at that lag, and the echo return loss. The sign matters and is the reason the search window is two-sided. 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 - so on a machine where it is the slower of the two, the reference arrives after the echo it explains. The window searched is wider than any gate would ship with, so a peak sitting at the edge is distinguishable from a window that is too small; the summary warns when that happens. An estimate is only accepted while the reference is actually active. A silent run reached a correlation of 0.53 - two noise floors correlate - so a peak height alone cannot tell coupling from silence. Manual, like taskbar-probe.mjs: it needs a desktop session, real speakers and someone to play audio into them, so it stays out of test/run.mjs. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 157 ++++++++++++++++ test/manual/echo-probe/index.html | 25 +++ test/manual/echo-probe/renderer.js | 288 +++++++++++++++++++++++++++++ test/manual/echo-probe/worklet.js | 65 +++++++ 4 files changed, 535 insertions(+) create mode 100644 test/manual/echo-probe.mjs create mode 100644 test/manual/echo-probe/index.html create mode 100644 test/manual/echo-probe/renderer.js create mode 100644 test/manual/echo-probe/worklet.js diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs new file mode 100644 index 00000000..86d1bb08 --- /dev/null +++ b/test/manual/echo-probe.mjs @@ -0,0 +1,157 @@ +/** + * 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 - run each twice and compare `erlDb`: + * + * pnpm exec electron test/manual/echo-probe.mjs --no-aec + * pnpm exec electron test/manual/echo-probe.mjs --no-agc + * + * 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 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 options = { + seconds: Number(value('seconds', 45)), + 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)); + +let sawCoupling = 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}` + ); + console.log(`loopback : ${info.loopbackTracks} audio track(s)`); + console.log( + `\nPlay interviewer audio through the speakers for ${options.seconds}s. Stay quiet.\n` + ); + console.log(' delayMs corr erlDb ref% mic% coupled'); + console.log(' ------- ---- ----- ---- ---- -------'); +}); + +ipcMain.on('probe:metrics', (_event, m) => { + if (m.coupled) sawCoupling = true; + console.log( + ` ${String(m.delayMs === null ? '--' : m.delayMs).padStart(7)}` + + ` ${num(m.correlation, 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) => { + console.log('\n=== summary ==='); + if (!summary.samples) { + console.log('No correlated frames. Either this is a headphone setup (the good case), or no'); + console.log('audio was playing through the speakers during the run - check the ref% column.'); + 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(`erlDb : median ${num(summary.erlDbMedian)}`); + console.log(`search window : ${summary.searchWindow[0]}..${summary.searchWindow[1]} ms`); + + const [lo, hi] = summary.searchWindow; + if (summary.delayMsMedian <= lo + 50 || summary.delayMsMedian >= hi - 50) { + console.log( + '\nWARNING: the peak sits at the edge of the search window, so the true delay may' + ); + console.log('lie outside it. Widen MIN_LAG_MS/MAX_LAG_MS in renderer.js and re-run before'); + console.log('treating this number as the real one.'); + } + 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.'); + } + } + console.log( + `\ncoupling seen : ${sawCoupling ? 'yes (speakers)' : 'no (headphones, or silence)'}` + ); + app.quit(); +}); + +ipcMain.on('probe:error', (_event, message) => { + console.error('\nprobe failed:\n' + message); + process.exitCode = 1; + app.quit(); +}); + +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')); +}); + +app.on('window-all-closed', () => 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..764d2ebe --- /dev/null +++ b/test/manual/echo-probe/renderer.js @@ -0,0 +1,288 @@ +/** + * 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; + +// Deliberately WIDER than the window the gate is expected to ship with (-300..+600 ms). The +// probe's whole job is to find 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". +const MIN_LAG_MS = -400; +const MAX_LAG_MS = 800; + +// 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; + +// Below this the correlation is noise. Reported rather than enforced: the point of the run is to +// find out where the real threshold should sit. +const CORR_MIN = 0.5; + +const MIN_OVERLAP_FRAMES = 50; // 0.5 s + +const status = (text) => { + document.getElementById('status').textContent = text; +}; + +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. + */ +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() { + this.refDb = []; + this.micDb = []; + this.lastXcorrAt = 0; + this.lag = null; + this.correlation = null; + this.erlDb = null; + this.samples = []; + } + + push(ref, mic) { + 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(); + + const now = performance.now(); + if (now - this.lastXcorrAt >= XCORR_INTERVAL_MS) { + this.lastXcorrAt = now; + this.estimate(); + } + } + + estimate() { + const minLag = Math.round(MIN_LAG_MS / FRAME_MS); + const maxLag = Math.round(MAX_LAG_MS / FRAME_MS); + + let bestLag = null; + let bestCorr = -2; + for (let lag = minLag; lag <= maxLag; lag++) { + const corr = correlateAt(this.refDb, this.micDb, lag); + if (corr === null) continue; + if (corr > bestCorr) { + bestCorr = corr; + bestLag = lag; + } + } + if (bestLag === null) return; + + this.lag = bestLag; + this.correlation = bestCorr; + + // 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); + + // A live reference is required, not just a high peak. Two noise floors correlate: a silent + // run of this probe reached 0.53 with nothing playing at all, which is above the 0.5 that + // looked like a reasonable CORR_MIN. So the correlation alone cannot tell coupling from + // silence, and any gate built on this has to carry the same reference-active condition. + if (bestCorr >= CORR_MIN && this.erlDb !== null) { + this.samples.push({ delayMs: bestLag * FRAME_MS, correlation: bestCorr, 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; + } + + snapshot() { + return { + delayMs: this.lag === null ? null : this.lag * FRAME_MS, + correlation: this.correlation, + erlDb: this.erlDb, + refActivePct: this.activePct(this.refDb), + micActivePct: this.activePct(this.micDb), + coupled: this.correlation !== null && this.correlation >= CORR_MIN && this.erlDb !== null, + }; + } + + summary() { + if (this.samples.length === 0) return { samples: 0, searchWindow: [MIN_LAG_MS, MAX_LAG_MS] }; + const delays = this.samples.map((s) => s.delayMs); + const corrs = this.samples.map((s) => s.correlation); + 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), + erlDbMedian: median(erls), + searchWindow: [MIN_LAG_MS, MAX_LAG_MS], + }; + } +} + +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'); + + 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) { + throw new Error('No audio input device named "' + options.device + '"'); + } + + 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 { + displayStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }); + } 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(); + 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(); + node.port.onmessage = (event) => meter.push(event.data.ref, event.data.mic); + + status('measuring - play interviewer audio through the speakers now'); + + const reportTimer = setInterval(() => { + ipcRenderer.send('probe:metrics', { ...meter.snapshot(), sampleRate: ctx.sampleRate }); + }, REPORT_INTERVAL_MS); + + setTimeout(() => { + clearInterval(reportTimer); + ipcRenderer.send('probe:done', meter.summary()); + micStream.getTracks().forEach((t) => t.stop()); + displayStream.getTracks().forEach((t) => t.stop()); + ctx.close(); + }, options.seconds * 1000); +} + +main().catch((error) => { + status('failed: ' + error.message); + ipcRenderer.send('probe:error', 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); From 8cd30bf8d5fcabbab5d99a28f27b9ed8931f2a71 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 27 Aug 2026 21:16:32 -0400 Subject: [PATCH 03/15] Judge coupling by peak prominence, not peak height Verifying the correlator against synthetic signals turned up the more important defect. Delay recovery is exact, including the sign: +120, +300, -150, -250 and 0 ms all come back to the frame, and the ERL matches the injected gain. But the coupling verdict was wrong in the dangerous direction. 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: 0.53 on pure silence, 0.57 on two independent bursty signals. A CORR_MIN of 0.5 calls both of those coupled, and a false "coupled" on a headphone user is what would lead a gate to cut a microphone that was never echoing anything. Prominence - the peak's height above the median lag - separates them cleanly: 0.28 for the unrelated pair against 0.87-1.13 for a real echo. Both ends of that gap are optimistic, so the threshold is a starting point to be re-derived from real runs, and the raw numbers are printed every second regardless. Also: reject unknown arguments and a non-positive --seconds. A mistyped --noaec was silently ignored, which runs with echo cancellation ON and reports a plausible number for the configuration you were trying to rule out; a non-numeric --seconds reached setTimeout as NaN and ended the run before it started, which reads like a headphone result. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 32 +++++++++++++-- test/manual/echo-probe/renderer.js | 65 +++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index 86d1bb08..3fab5357 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -43,14 +43,38 @@ 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']; + +// 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); +} + const options = { - seconds: Number(value('seconds', 45)), + seconds, device: value('device', ''), echoCancellation: !flag('--no-aec'), noiseSuppression: !flag('--no-ns'), @@ -79,8 +103,8 @@ ipcMain.on('probe:ready', (_event, info) => { console.log( `\nPlay interviewer audio through the speakers for ${options.seconds}s. Stay quiet.\n` ); - console.log(' delayMs corr erlDb ref% mic% coupled'); - console.log(' ------- ---- ----- ---- ---- -------'); + console.log(' delayMs corr prom erlDb ref% mic% coupled'); + console.log(' ------- ---- ---- ----- ---- ---- -------'); }); ipcMain.on('probe:metrics', (_event, m) => { @@ -88,6 +112,7 @@ ipcMain.on('probe:metrics', (_event, m) => { 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)}` + @@ -107,6 +132,7 @@ ipcMain.on('probe:done', (_event, summary) => { `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)}`); console.log(`search window : ${summary.searchWindow[0]}..${summary.searchWindow[1]} ms`); diff --git a/test/manual/echo-probe/renderer.js b/test/manual/echo-probe/renderer.js index 764d2ebe..4a52d260 100644 --- a/test/manual/echo-probe/renderer.js +++ b/test/manual/echo-probe/renderer.js @@ -35,9 +35,27 @@ const MAX_LAG_MS = 800; // every estimate toward the noise floor. const REF_FLOOR_DBFS = -55; -// Below this the correlation is noise. Reported rather than enforced: the point of the run is to -// find out where the real threshold should sit. +// 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. +// +// A starting threshold, to be re-derived from real runs rather than trusted. Against synthetic +// signals an unrelated pair scored 0.28 and a clean echo 0.87-1.13, so 0.5 sits in the gap - but +// the synthetic echo is a perfectly scaled copy and a real one will score lower, while the +// synthetic "unrelated" pair shares a burst grid and so scores HIGHER than truly unrelated audio. +// Both ends of that gap are therefore optimistic. The per-second output prints the raw numbers +// regardless of this threshold, which is the point: measure the real distribution, then set it. const CORR_MIN = 0.5; +const PROMINENCE_MIN = 0.5; const MIN_OVERLAP_FRAMES = 50; // 0.5 s @@ -103,6 +121,7 @@ class CouplingMeter { this.lastXcorrAt = 0; this.lag = null; this.correlation = null; + this.prominence = null; this.erlDb = null; this.samples = []; } @@ -113,6 +132,11 @@ class CouplingMeter { 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; @@ -126,9 +150,11 @@ class CouplingMeter { 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; @@ -138,6 +164,10 @@ class CouplingMeter { 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 = []; @@ -150,12 +180,16 @@ class CouplingMeter { } this.erlDb = median(ratios); - // A live reference is required, not just a high peak. Two noise floors correlate: a silent - // run of this probe reached 0.53 with nothing playing at all, which is above the 0.5 that - // looked like a reasonable CORR_MIN. So the correlation alone cannot tell coupling from - // silence, and any gate built on this has to carry the same reference-active condition. - if (bestCorr >= CORR_MIN && this.erlDb !== null) { - this.samples.push({ delayMs: bestLag * FRAME_MS, correlation: bestCorr, erlDb: this.erlDb }); + // 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, + }); } } @@ -165,14 +199,25 @@ class CouplingMeter { return (100 * active) / series.length; } + isCoupled() { + return ( + this.correlation !== null && + this.correlation >= CORR_MIN && + this.prominence !== null && + this.prominence >= PROMINENCE_MIN && + this.erlDb !== null + ); + } + 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.correlation !== null && this.correlation >= CORR_MIN && this.erlDb !== null, + coupled: this.isCoupled(), }; } @@ -180,6 +225,7 @@ class CouplingMeter { if (this.samples.length === 0) return { samples: 0, searchWindow: [MIN_LAG_MS, MAX_LAG_MS] }; 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, @@ -187,6 +233,7 @@ class CouplingMeter { delayMsMin: Math.min(...delays), delayMsMax: Math.max(...delays), correlationMedian: median(corrs), + prominenceMedian: median(proms), erlDbMedian: median(erls), searchWindow: [MIN_LAG_MS, MAX_LAG_MS], }; From 5d864a0b0d2a51681dbff5781b8830488a87070e Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 27 Aug 2026 21:28:20 -0400 Subject: [PATCH 04/15] Make the probe's verdict survive a single spurious report Review of the probe turned up four ways it could report a confident number that was not true. The run-level verdict latched on a single coupled report, so one spurious second decided the headline finding for the whole run. That matters more after a live run reached a prominence of 0.47 in a silent room, against a threshold of 0.5 - the synthetic separation of 0.28 was optimistic, and a quiet room crosses that line occasionally. The verdict now counts coupled reports and refuses to call a run that cannot show several, reporting the fraction either way and saying INCONCLUSIVE rather than guessing. A stalled capture was invisible. push() simply stops being called, the report timer keeps firing, and the same numbers print every second looking exactly like a steady measurement. Frames are now counted and a report with no new frames says so, with a warning in the summary. getDisplayMedia was unbounded, so a loopback that never resolves left the probe sitting silently with no output. Bounded at 20s, the same as live-transcription.service.ts bounds it. And CORR_MIN now says explicitly that it is a floor rather than the discriminator - on its own it is the threshold already shown to be useless. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 43 ++++++++++++++++++++++++++---- test/manual/echo-probe/renderer.js | 37 ++++++++++++++++++++----- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index 3fab5357..ef087191 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -87,7 +87,13 @@ loopbackPkg.initMain(); const num = (v, digits = 1) => (v === null || v === undefined ? ' --' : v.toFixed(digits)); -let sawCoupling = false; +// 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; ipcMain.handle('probe:options', () => options); @@ -108,7 +114,20 @@ ipcMain.on('probe:ready', (_event, info) => { }); ipcMain.on('probe:metrics', (_event, m) => { - if (m.coupled) sawCoupling = true; + totalReports++; + if (m.coupled) coupledReports++; + + // No new frames since the last report means the capture has stopped feeding the graph - an + // unplugged device, or a suspended context. Every column below is then a stale reading of a + // dead stream, which is worse than no reading at all because it looks like data. + const advanced = m.frames - lastFrames; + lastFrames = m.frames; + if (advanced === 0) { + stalled = true; + console.log(' -- no audio frames received since the last report (capture stalled) --'); + return; + } + console.log( ` ${String(m.delayMs === null ? '--' : m.delayMs).padStart(7)}` + ` ${num(m.correlation, 2).padStart(4)}` + @@ -150,9 +169,23 @@ ipcMain.on('probe:done', (_event, summary) => { console.log('the mic to keep its decisions causal.'); } } - console.log( - `\ncoupling seen : ${sawCoupling ? 'yes (speakers)' : 'no (headphones, or silence)'}` - ); + const pct = totalReports > 0 ? Math.round((100 * coupledReports) / totalReports) : 0; + console.log(''); + console.log(`coupled reports : ${coupledReports}/${totalReports} (${pct}%)`); + if (coupledReports === 0) { + console.log('verdict : no coupling (headphones, or nothing played through them)'); + } else if (coupledReports >= 3 && pct >= 20) { + console.log('verdict : coupled (speakers)'); + } else { + console.log('verdict : INCONCLUSIVE - too few coupled reports to call it either'); + console.log(' way. 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.'); + console.log('audio than the requested duration. Re-run before recording them.'); + } app.quit(); }); diff --git a/test/manual/echo-probe/renderer.js b/test/manual/echo-probe/renderer.js index 4a52d260..a6a6c407 100644 --- a/test/manual/echo-probe/renderer.js +++ b/test/manual/echo-probe/renderer.js @@ -48,16 +48,26 @@ const REF_FLOOR_DBFS = -55; // 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. // -// A starting threshold, to be re-derived from real runs rather than trusted. Against synthetic -// signals an unrelated pair scored 0.28 and a clean echo 0.87-1.13, so 0.5 sits in the gap - but -// the synthetic echo is a perfectly scaled copy and a real one will score lower, while the -// synthetic "unrelated" pair shares a burst grid and so scores HIGHER than truly unrelated audio. -// Both ends of that gap are therefore optimistic. The per-second output prints the raw numbers -// regardless of this threshold, which is the point: measure the real distribution, then set it. +// 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; const MIN_OVERLAP_FRAMES = 50; // 0.5 s +const DISPLAY_MEDIA_TIMEOUT_MS = 20000; const status = (text) => { document.getElementById('status').textContent = text; @@ -124,9 +134,11 @@ class CouplingMeter { 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(); @@ -218,6 +230,10 @@ class CouplingMeter { refActivePct: this.activePct(this.refDb), micActivePct: this.activePct(this.micDb), coupled: this.isCoupled(), + // Reported so a stalled capture is visible. Nothing else here would show it: push() simply + // stops being called, the report timer keeps firing, and the same numbers print every + // second looking exactly like a steady measurement. + frames: this.frames, }; } @@ -275,7 +291,14 @@ async function main() { await ipcRenderer.invoke('enable-loopback-audio'); let displayStream; try { - displayStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }); + // 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 Error('Loopback capture timed out')), DISPLAY_MEDIA_TIMEOUT_MS) + ), + ]); } finally { await ipcRenderer.invoke('disable-loopback-audio').catch(() => {}); } From 013c32c23fd254939e5946dc8dca6151b0423afd Mon Sep 17 00:00:00 2001 From: alpha Date: Sun, 6 Sep 2026 23:00:55 -0400 Subject: [PATCH 05/15] Keep the probe's verdict honest, and the mic constraints in one place Review of the branch, plus the merge of main, turned up six ways the work reported something that was not true. The merge is the substantive one. main added two more microphone captures while this branch was open: mock-transcription.service.ts, which inlines its own copy of the three processing flags, and the settings microphone test, which opens with `audio: true` and so takes whatever Chromium defaults to. Both textually merged clean and both defeat the point of the change - "one place to flip them from" is not one place if three callers spell it out themselves. micConstraints is exported and used by all three. The test meter case has a second edge: opened as `true`, the level shown while picking a device is measured through different processing than the session it exists to predict. In the probe: The stall guard counted the report it was about to refuse to print. `coupled` on a stalled report is the verdict of an estimate that ran against audio which is no longer arriving, so a capture that died while coupled kept voting on the headline finding - the exact thing the counters were added to stop. Both health checks now run before the counters. The frame counter cannot see an unplugged microphone, though the comment claimed it. 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 while the columns decay quietly toward the noise floor. Ended tracks are reported separately and named. `muted` is deliberately not used - it toggles on ordinary silence on some platforms and would discard most of a legitimately quiet run. The first report latched `stalled` permanently. lastFrames starts at 0, so a report that lands before the graph produces its first frame looks exactly like a capture that died, and put a "re-run this" warning on the summary of a run that then went fine. Before the first frame is now distinguished from after the last one. A suspended AudioContext produced a silent run with no explanation: no frames, a table of blanks, and a summary reading "no coupling" - the headphone verdict, from a probe that never listened. Resumed if suspended. "No coupling" could print directly underneath a non-zero count of accepted coupled estimates. Estimates run twice a second and reports are sampled once, so intermittent coupling can enter `samples` without a report tick landing on it. The headphone verdict now has to clear both counters. Also: a loopback with no audio track is called out at startup rather than left to be inferred from an empty ref% column, `--device` lists the labels it did not match (an exact match against long parenthesised OS strings is the whole difficulty), `--no-ns` is documented alongside the other two flags, and the stall warning no longer prints a mangled duplicate of its own second line. Co-Authored-By: Claude Opus 5 --- .../custom/settings/microphone-field.tsx | 7 ++- .../services/live-transcription.service.ts | 9 ++- .../services/mock-transcription.service.ts | 11 +--- test/manual/echo-probe.mjs | 62 ++++++++++++++++--- test/manual/echo-probe/renderer.js | 54 ++++++++++++++-- 5 files changed, 117 insertions(+), 26 deletions(-) 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 22d1d2d6..0378ede2 100644 --- a/src/renderer/services/live-transcription.service.ts +++ b/src/renderer/services/live-transcription.service.ts @@ -37,7 +37,7 @@ function buildStreamingUrl(language: Language): string { const MIC_AUTO_GAIN_CONTROL = true; /** - * The constraints every microphone capture in this service opens with. + * 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 @@ -47,8 +47,13 @@ const MIC_AUTO_GAIN_CONTROL = true; * 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. */ -function micConstraints(deviceId: string | null): MediaTrackConstraints { +export function micConstraints(deviceId: string | null): MediaTrackConstraints { return { ...(deviceId ? { deviceId: { exact: deviceId } } : {}), echoCancellation: true, 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 index ef087191..bf04746a 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -28,6 +28,7 @@ * * 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 * * 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. @@ -94,6 +95,7 @@ let coupledReports = 0; let totalReports = 0; let lastFrames = 0; let stalled = false; +let deadTracks = false; ipcMain.handle('probe:options', () => options); @@ -106,6 +108,16 @@ ipcMain.on('probe:ready', (_event, info) => { ` applied : aec=${info.micSettings.echoCancellation} ns=${info.micSettings.noiseSuppression} agc=${info.micSettings.autoGainControl}` ); 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` ); @@ -114,20 +126,44 @@ ipcMain.on('probe:ready', (_event, info) => { }); ipcMain.on('probe:metrics', (_event, m) => { - totalReports++; - if (m.coupled) coupledReports++; - - // No new frames since the last report means the capture has stopped feeding the graph - an - // unplugged device, or a suspended context. Every column below is then a stale reading of a - // dead stream, which is worse than no reading at all because it looks like data. + // 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++; + console.log( ` ${String(m.delayMs === null ? '--' : m.delayMs).padStart(7)}` + ` ${num(m.correlation, 2).padStart(4)}` + @@ -172,7 +208,12 @@ ipcMain.on('probe:done', (_event, summary) => { const pct = totalReports > 0 ? Math.round((100 * coupledReports) / totalReports) : 0; console.log(''); console.log(`coupled reports : ${coupledReports}/${totalReports} (${pct}%)`); - if (coupledReports === 0) { + // 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. + if (coupledReports === 0 && !summary.samples) { console.log('verdict : no coupling (headphones, or nothing played through them)'); } else if (coupledReports >= 3 && pct >= 20) { console.log('verdict : coupled (speakers)'); @@ -184,7 +225,12 @@ ipcMain.on('probe:done', (_event, summary) => { 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.'); - console.log('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(); }); diff --git a/test/manual/echo-probe/renderer.js b/test/manual/echo-probe/renderer.js index a6a6c407..7944c56a 100644 --- a/test/manual/echo-probe/renderer.js +++ b/test/manual/echo-probe/renderer.js @@ -230,9 +230,14 @@ class CouplingMeter { refActivePct: this.activePct(this.refDb), micActivePct: this.activePct(this.micDb), coupled: this.isCoupled(), - // Reported so a stalled capture is visible. Nothing else here would show it: push() simply - // stops being called, the report timer keeps firing, and the same numbers print every - // second looking exactly like a steady measurement. + // 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, }; } @@ -274,7 +279,15 @@ async function main() { const deviceId = await resolveMicDeviceId(options.device); if (options.device && !deviceId) { - throw new Error('No audio input device named "' + options.device + '"'); + // 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 Error( + `No audio input device named "${options.device}". Available:\n${labels.join('\n')}` + ); } const micStream = await navigator.mediaDevices.getUserMedia({ @@ -315,6 +328,10 @@ async function main() { }); 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', { @@ -339,8 +356,35 @@ async function main() { 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; + }; + const reportTimer = setInterval(() => { - ipcRenderer.send('probe:metrics', { ...meter.snapshot(), sampleRate: ctx.sampleRate }); + ipcRenderer.send('probe:metrics', { + ...meter.snapshot(), + deadTracks: deadTrackNames(), + sampleRate: ctx.sampleRate, + }); }, REPORT_INTERVAL_MS); setTimeout(() => { From 77cf9256a127baec4db1566b05ed68a390f02b9f Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 00:41:39 -0400 Subject: [PATCH 06/15] Pin the constraints invariant, and make the probe legible to the person running it A test for the invariant the merge already broke once. `main` grew two more microphone captures while this branch was open, one with the three processing flags copied out and one opening with `audio: true`, and 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 why a checker has to be the thing that notices. `mic-constraints.test.mjs` walks src/ and fails on any capture that does not open through micConstraints, naming the file. It fails, verified, on exactly the regression that reached main. Only src/ is scanned: the probe varies those flags on purpose, which is its job. Then the probe, from the point of view of someone actually running it. Closing the window mid-run ended the process at exit 0 with no summary and nothing said - indistinguishable, in a scrollback, from a run that finished. It now says what happened and exits non-zero. A failed window load rejected into nothing: the window sat at "starting...", no report ever arrived, and the probe waited for a run that would not begin. The likeliest failure in this tool is a mistyped --device, whose message is a list of the device names that do exist - and it was printed under ten frames of Electron internals. Failures the operator can fix are marked, and print their message alone. The loopback timeout is one of them, and now says what to check rather than just that it timed out. The run asks a person to play audio and stay quiet for 45 seconds while the only thing moving is a console table. The window counts down the seconds left, and says when the summary is ready. Also recorded, not corrected: the correlation overlap shrinks as |lag| grows, so lags at the edges of the search are estimated from ~20% less audio and the peak leans very slightly outward. It is ~0.02 against a noise floor that has measured 0.47-0.57, and it cannot reach the "peak sits at the edge" warning, which only prints for runs that already have coupled estimates. Correcting it would move every number the gate is about to be sized from, on judgement rather than on data, so it is written down where whoever picks the window will read it. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 68 +++++++++++++----- test/manual/echo-probe/renderer.js | 50 +++++++++++-- test/mic-constraints.test.mjs | 110 +++++++++++++++++++++++++++++ test/run.mjs | 1 + 4 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 test/mic-constraints.test.mjs diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index bf04746a..53d70503 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -97,6 +97,11 @@ let lastFrames = 0; let stalled = false; let deadTracks = false; +// 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) => { @@ -176,6 +181,7 @@ ipcMain.on('probe:metrics', (_event, m) => { }); ipcMain.on('probe:done', (_event, summary) => { + finished = true; console.log('\n=== summary ==='); if (!summary.samples) { console.log('No correlated frames. Either this is a headphone setup (the good case), or no'); @@ -235,28 +241,54 @@ ipcMain.on('probe:done', (_event, summary) => { app.quit(); }); -ipcMain.on('probe:error', (_event, message) => { - console.error('\nprobe failed:\n' + message); +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)); process.exitCode = 1; app.quit(); }); -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, - }, +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)); + process.exitCode = 1; + app.quit(); }); - await win.loadFile(path.join(HERE, 'echo-probe', 'index.html')); +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.'); + process.exitCode = 1; + } + app.quit(); }); - -app.on('window-all-closed', () => app.quit()); diff --git a/test/manual/echo-probe/renderer.js b/test/manual/echo-probe/renderer.js index 7944c56a..cf70152e 100644 --- a/test/manual/echo-probe/renderer.js +++ b/test/manual/echo-probe/renderer.js @@ -73,6 +73,16 @@ 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) { @@ -94,6 +104,16 @@ function median(values) { * 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); @@ -285,7 +305,7 @@ async function main() { const labels = (await navigator.mediaDevices.enumerateDevices()) .filter((d) => d.kind === 'audioinput') .map((d) => ` ${d.label || '(unlabelled)'}`); - throw new Error( + throw new ProbeError( `No audio input device named "${options.device}". Available:\n${labels.join('\n')}` ); } @@ -309,7 +329,16 @@ async function main() { displayStream = await Promise.race([ navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }), new Promise((_, reject) => - setTimeout(() => reject(new Error('Loopback capture timed out')), DISPLAY_MEDIA_TIMEOUT_MS) + 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 { @@ -379,7 +408,13 @@ async function main() { 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(), @@ -389,6 +424,7 @@ async function main() { setTimeout(() => { clearInterval(reportTimer); + status('done - the summary is in the console'); ipcRenderer.send('probe:done', meter.summary()); micStream.getTracks().forEach((t) => t.stop()); displayStream.getTracks().forEach((t) => t.stop()); @@ -397,6 +433,12 @@ async function main() { } main().catch((error) => { - status('failed: ' + error.message); - ipcRenderer.send('probe:error', String(error && error.stack ? error.stack : 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/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', From 7edc6f58be20833e492ff6a8983f722c7d90dafb Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 00:44:57 -0400 Subject: [PATCH 07/15] Refuse a verdict on a run with no valid reports, and document the probe A run where every report was discarded - all stalled, all on a dead capture - or one too short to produce a single report reached the summary with totalReports at zero and printed "no coupling (headphones, or nothing played through them)". That is the worst version of the failure the rest of this summary is built to avoid: the headphone verdict, stated confidently, from a probe that took no valid reading at all. It now says so and names both causes. And the probe was undiscoverable. `taskbar-probe.mjs` is documented in the section it belongs to; this one was in no section at all, so the next person to wonder how much of the interviewer the microphone re-captures had no way to find the tool that answers it. The Headphones section now carries it, together with the constraints rule and the test that pins it. CLAUDE.md is edited by hand rather than through prettier: running prettier on it rewrites 29 unrelated emphasis markers, which is the churn the file's own Commands section warns about. This change is purely additive. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 18 ++++++++++++++++++ test/manual/echo-probe.mjs | 11 ++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8a4e0eea..21f9fce1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,6 +262,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/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index 53d70503..8f5c779a 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -214,12 +214,21 @@ ipcMain.on('probe:done', (_event, summary) => { 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 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. - if (coupledReports === 0 && !summary.samples) { + else if (coupledReports === 0 && !summary.samples) { console.log('verdict : no coupling (headphones, or nothing played through them)'); } else if (coupledReports >= 3 && pct >= 20) { console.log('verdict : coupled (speakers)'); From aa937c5de9d9d0db861679a25abac172a8a14454 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 00:46:27 -0400 Subject: [PATCH 08/15] Warn when the device ignores a flag the A/B is being scored on The probe rejects a mistyped `--noaec` because a silently dropped flag runs with echo cancellation ON and reports a plausible number for the configuration you were trying to rule out. The same thing happens one layer down and was not handled: these constraints are advisory, so Chromium is free to decline them and say so only in getSettings(). Requested and applied were both printed and any disagreement left for the reader to spot, in a tool whose entire job is scoring that A/B - and unlike the typo, this one is not the operator's fault. Now called out. A device that reports nothing back is said to report nothing, rather than being read as agreement. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index 8f5c779a..0e2b7f54 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -112,6 +112,36 @@ ipcMain.on('probe:ready', (_event, info) => { 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. From db998ea8bcdbe37a7dfbdfa43935dfb220d88abd Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 00:51:41 -0400 Subject: [PATCH 09/15] Stop reading a silent run as headphones, and make a failed run exit non-zero Both found by running the probe rather than reading it. The first is the same class as everything else in this summary. A run where the loopback reference never carried audio played nothing for the microphone to re-capture, so it measured nothing about coupling - and the verdict called that "no coupling (headphones, or nothing played through them)", offering the two as equal readings while the ref% column had already told them apart. A run only measures coupling if something was coupling-capable in the first place. The silent case now says so and says what to do; the headphone verdict now means what it says, audio played and the mic did not pick it up. The second is worse for being invisible. `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 the probe reported came back as success to the shell. A `--device` that does not exist printed its error, its list of real device names, and exited 0. Both the error paths and the window-closed-early path went through it. `app.exit` carries the code. Verified end to end on Windows 11: a run exits 0, a bad --device exits 1 with its message intact through a pipe, and the four argument guards still exit 2. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 43 ++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index 0e2b7f54..f8cadf14 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -88,6 +88,19 @@ 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. @@ -97,6 +110,13 @@ 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. @@ -198,6 +218,7 @@ ipcMain.on('probe:metrics', (_event, m) => { totalReports++; if (m.coupled) coupledReports++; + if (m.refActivePct > peakRefActivePct) peakRefActivePct = m.refActivePct; console.log( ` ${String(m.delayMs === null ? '--' : m.delayMs).padStart(7)}` + @@ -253,13 +274,24 @@ ipcMain.on('probe:done', (_event, summary) => { 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, or nothing played through them)'); + 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 { @@ -286,8 +318,7 @@ ipcMain.on('probe:error', (_event, failure) => { // 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)); - process.exitCode = 1; - app.quit(); + quitWith(1); }); app @@ -314,8 +345,7 @@ app .catch((error) => { finished = true; console.error('\nprobe failed to start:\n' + (error && error.stack ? error.stack : error)); - process.exitCode = 1; - app.quit(); + quitWith(1); }); app.on('window-all-closed', () => { @@ -327,7 +357,8 @@ app.on('window-all-closed', () => { 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.'); - process.exitCode = 1; + quitWith(1); + return; } app.quit(); }); From 24e13baadd190b58077ec5e10e7fe444c3624ee8 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 08:23:30 -0400 Subject: [PATCH 10/15] Reject a coupled verdict the microphone cannot support, and stop sending the reader to edit source Four findings, all from running the probe against real audio rather than reading it. The coupled path had never actually executed before this. **A false "coupled" with the microphone silent.** A run accepted an 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 cannot catch it on their own, because the surface really does have a sharp peak - and a false "coupled" on a HEADPHONE user is the expensive direction, which is the entire reason prominence exists. An estimate can only describe re-captured audio if the microphone recorded any, so that precondition is now required outright. Real coupling on the same machine runs mic% 24-45 against a floor of 5, and a re-run confirms the verdict still lands at coupled 8/16. **The search window needed a source edit.** The summary's answer to a peak at the edge was "widen MIN_LAG_MS/MAX_LAG_MS in renderer.js and re-run". That warning is not exotic: it fired on the first machine measured, whose peak sat at -400, the exact floor of the default. Completing a measurement should not mean editing a renderer file, so the window is `--min-lag=` / `--max-lag=`, validated like the rest, and the warning now names the flag and the value to re-run with. Re-run at --min-lag=-1200 the peak lands at -280 and the warning correctly stops firing. **The summary and the table describe different populations.** Medians cover accepted estimates only, which run twice per printed row, so the summary reported a median correlation of 0.67 when no printed row exceeded 0.53. Read side by side that looks like an error rather than a better-filtered number. Said explicitly now. **INCONCLUSIVE gave advice that did not fit.** It said "re-run with audio playing for the whole duration" to a run whose ref% sat between 72 and 85 the entire time - sending someone to redo the thing they had already done correctly. A reference that was solid throughout means the coupling is marginal on that machine, which is a finding, not a mistake, and the two cases now get their own remedy. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 75 ++++++++++++++++++++++++++++-- test/manual/echo-probe/renderer.js | 42 +++++++++++------ 2 files changed, 99 insertions(+), 18 deletions(-) diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index f8cadf14..6b0cb198 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -30,6 +30,11 @@ * pnpm exec electron test/manual/echo-probe.mjs --no-agc * pnpm exec electron test/manual/echo-probe.mjs --no-ns * + * 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. * @@ -46,7 +51,18 @@ 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']; +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 @@ -74,8 +90,31 @@ if (!Number.isFinite(seconds) || seconds <= 0) { 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'), @@ -246,15 +285,27 @@ ipcMain.on('probe:done', (_event, summary) => { 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; - if (summary.delayMsMedian <= lo + 50 || summary.delayMsMedian >= hi - 50) { + const atFloor = summary.delayMsMedian <= lo + 50; + if (atFloor || summary.delayMsMedian >= hi - 50) { + // Names the flag to re-run with, and the value, rather than a constant to go and edit. This + // warning is not exotic: it fired on the first machine measured. + const widened = atFloor + ? `--min-lag=${Math.round(lo - (hi - lo) / 2)}` + : `--max-lag=${Math.round(hi + (hi - lo) / 2)}`; console.log( '\nWARNING: the peak sits at the edge of the search window, so the true delay may' ); - console.log('lie outside it. Widen MIN_LAG_MS/MAX_LAG_MS in renderer.js and re-run before'); - console.log('treating this number as the real one.'); + console.log(`lie outside it. Re-run with ${widened} before treating this number as`); + console.log('the real one.'); } if (summary.delayMsMedian < 0) { console.log('\nNote: the delay is NEGATIVE - the loopback reference arrives after the mic'); @@ -296,7 +347,21 @@ ipcMain.on('probe:done', (_event, summary) => { console.log('verdict : coupled (speakers)'); } else { console.log('verdict : INCONCLUSIVE - too few coupled reports to call it either'); - console.log(' way. Re-run with audio playing for the whole duration.'); + // 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(''); diff --git a/test/manual/echo-probe/renderer.js b/test/manual/echo-probe/renderer.js index cf70152e..c574fe7d 100644 --- a/test/manual/echo-probe/renderer.js +++ b/test/manual/echo-probe/renderer.js @@ -24,12 +24,10 @@ const HISTORY_FRAMES = 400; // 4 s const XCORR_INTERVAL_MS = 500; const REPORT_INTERVAL_MS = 1000; -// Deliberately WIDER than the window the gate is expected to ship with (-300..+600 ms). The -// probe's whole job is to find 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". -const MIN_LAG_MS = -400; -const MAX_LAG_MS = 800; +// 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. @@ -66,6 +64,19 @@ const REF_FLOOR_DBFS = -55; 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; @@ -145,7 +156,9 @@ function correlateAt(refDb, micDb, lag) { } class CouplingMeter { - constructor() { + constructor(minLagMs, maxLagMs) { + this.minLagMs = minLagMs; + this.maxLagMs = maxLagMs; this.refDb = []; this.micDb = []; this.lastXcorrAt = 0; @@ -177,8 +190,8 @@ class CouplingMeter { } estimate() { - const minLag = Math.round(MIN_LAG_MS / FRAME_MS); - const maxLag = Math.round(MAX_LAG_MS / FRAME_MS); + const minLag = Math.round(this.minLagMs / FRAME_MS); + const maxLag = Math.round(this.maxLagMs / FRAME_MS); let bestLag = null; let bestCorr = -2; @@ -237,7 +250,9 @@ class CouplingMeter { this.correlation >= CORR_MIN && this.prominence !== null && this.prominence >= PROMINENCE_MIN && - this.erlDb !== null + this.erlDb !== null && + // The microphone has to have heard something. See MIC_ACTIVE_MIN_PCT. + this.activePct(this.micDb) >= MIC_ACTIVE_MIN_PCT ); } @@ -263,7 +278,8 @@ class CouplingMeter { } summary() { - if (this.samples.length === 0) return { samples: 0, searchWindow: [MIN_LAG_MS, MAX_LAG_MS] }; + 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); @@ -276,7 +292,7 @@ class CouplingMeter { correlationMedian: median(corrs), prominenceMedian: median(proms), erlDbMedian: median(erls), - searchWindow: [MIN_LAG_MS, MAX_LAG_MS], + searchWindow: [this.minLagMs, this.maxLagMs], }; } } @@ -380,7 +396,7 @@ async function main() { node.connect(sink); sink.connect(ctx.destination); - const meter = new CouplingMeter(); + 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'); From fae89cc33074894bf04fc097dd73d32cc5d34f2e Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 08:25:22 -0400 Subject: [PATCH 11/15] Say how to run the A/B so it cannot produce a confident wrong answer "Run each twice and compare erlDb" is not enough method, and the first machine measured shows why: two 16 s runs of the same unchanged configuration came back -36.0 and -38.8, a spread of 2.8 dB, while the aec on/off pair differed by 4.3 dB. Two runs cannot separate a real effect from that, and the instruction as written would have someone record a difference that is mostly noise - in the one number the constraints decision gets made on. The header now says to establish the unchanged configuration's spread first and to discard any difference smaller than it. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index 6b0cb198..8b290772 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -24,12 +24,18 @@ * 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 - run each twice and compare `erlDb`: + * 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: * From 3405ef277a7f6974d5f9ee77ebdad93a87b9e7c2 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 08:27:36 -0400 Subject: [PATCH 12/15] Reject a search window wider than the correlator can search Making --min-lag/--max-lag settable opened a new way to get the silent wrong answer this branch keeps closing. The correlator holds 4 s of history and needs 500 ms of overlap at every candidate lag, so a bound beyond +/-3500 ms leaves correlateAt below its minimum at every lag: 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 too wide to search rather than a mic that heard nothing. Checked in the renderer, where the history and overlap numbers are defined, and raised as a ProbeError so it prints the arithmetic and no stack. --min-lag=-5000 now explains itself and exits 1. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe/renderer.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/manual/echo-probe/renderer.js b/test/manual/echo-probe/renderer.js index c574fe7d..c3ad5220 100644 --- a/test/manual/echo-probe/renderer.js +++ b/test/manual/echo-probe/renderer.js @@ -307,6 +307,22 @@ async function resolveMicDeviceId(deviceName) { 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. From 220e7d10ee94e76a5daa08246f28c18d4a9a1c71 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 08:30:58 -0400 Subject: [PATCH 13/15] Stop the widen-the-window advice naming a window the probe would reject The edge warning suggests a wider search, and the previous commit taught the probe to refuse a window beyond what the correlator can search. On a window already near that limit the two disagreed: the summary would name a --min-lag the next run exits 1 on. The suggestion is clamped to the limit now, and when there is no room left to widen it says that instead - the delay is a lower bound at that point, and resolving it needs a longer history rather than a wider window. The limit travels with the summary from the renderer, where it is derived, rather than being restated here and left to drift. Checked across the five cases: peak at the floor and at the ceiling of the default window, peak at each end of a window already at the limit, and a peak mid-window that warns about nothing. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 26 +++++++++++++++++++------- test/manual/echo-probe/renderer.js | 5 ++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index 8b290772..1dc656e5 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -302,16 +302,28 @@ ipcMain.on('probe:done', (_event, summary) => { const [lo, hi] = summary.searchWindow; const atFloor = summary.delayMsMedian <= lo + 50; if (atFloor || summary.delayMsMedian >= hi - 50) { - // Names the flag to re-run with, and the value, rather than a constant to go and edit. This - // warning is not exotic: it fired on the first machine measured. - const widened = atFloor - ? `--min-lag=${Math.round(lo - (hi - lo) / 2)}` - : `--max-lag=${Math.round(hi + (hi - lo) / 2)}`; console.log( '\nWARNING: the peak sits at the edge of the search window, so the true delay may' ); - console.log(`lie outside it. Re-run with ${widened} before treating this number as`); - console.log('the real one.'); + // 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'); diff --git a/test/manual/echo-probe/renderer.js b/test/manual/echo-probe/renderer.js index c3ad5220..4b70d233 100644 --- a/test/manual/echo-probe/renderer.js +++ b/test/manual/echo-probe/renderer.js @@ -457,7 +457,10 @@ async function main() { setTimeout(() => { clearInterval(reportTimer); status('done - the summary is in the console'); - ipcRenderer.send('probe:done', meter.summary()); + // 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(); From 83ac3f90afd7ef9032e252ef3b1af238588f4cd7 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 08:32:58 -0400 Subject: [PATCH 14/15] Stop the zero-sample summary contradicting the verdict below it "No correlated frames. Either this is a headphone setup (the good case), or no audio was playing" was written when that line was the whole answer. It is not any more: the verdict beneath it now distinguishes NOTHING MEASURED, NOTHING PLAYED, no coupling and INCONCLUSIVE, using the ref% history and the report counts. So the line offered "the good case" as a live possibility directly above a verdict saying nothing had been measured at all - the same unearned confidence this branch has been removing everywhere else, in the one place that used to be entitled to it. It states the fact and stops, in the same label/value shape as the populated branch. Interpreting is the verdict's job. Co-Authored-By: Claude Opus 5 --- test/manual/echo-probe.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs index 1dc656e5..a6870c4c 100644 --- a/test/manual/echo-probe.mjs +++ b/test/manual/echo-probe.mjs @@ -280,9 +280,12 @@ ipcMain.on('probe:done', (_event, summary) => { finished = true; console.log('\n=== summary ==='); if (!summary.samples) { - console.log('No correlated frames. Either this is a headphone setup (the good case), or no'); - console.log('audio was playing through the speakers during the run - check the ref% column.'); - console.log(`search window: ${summary.searchWindow[0]}..${summary.searchWindow[1]} ms`); + // 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( From b6fad035c96b8e1389879b8662d33700279e5234 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 7 Sep 2026 08:36:08 -0400 Subject: [PATCH 15/15] Correct the claim that ingest deduplicates CLAUDE.md said `ingest()` "deduplicates overlapping segments". Nothing does. `mergeAdjacentTranscripts` concatenates consecutive blocks from the SAME speaker inside TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS, and no code anywhere compares the two channels against each other - grepped for it to be sure. Worth more than a wording fix, because the file already contradicted itself: the Headphones section a few hundred lines down says the transcript duplicates on speakers and that suppressing it is what #111 is for. A reader who took line 63 at face value would conclude the echo duplication was already handled, which is the exact opposite of the problem this branch exists to measure, and would have no reason to look further. Listed on this PR's own checklist and cheap to settle now that the code was being read anyway. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 21f9fce1..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)