From e43b865672fa8beb59a8c98610810967e119b991 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 27 Aug 2026 20:54:04 -0400 Subject: [PATCH 1/3] 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 310a98e..bb0d354 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 2/3] 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 0000000..86d1bb0 --- /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 0000000..8e68efd --- /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 0000000..764d2eb --- /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 0000000..4af6c77 --- /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 3/3] 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 86d1bb0..3fab535 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 764d2eb..4a52d26 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], };