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,
});
diff --git a/test/manual/echo-probe.mjs b/test/manual/echo-probe.mjs
new file mode 100644
index 00000000..ef087191
--- /dev/null
+++ b/test/manual/echo-probe.mjs
@@ -0,0 +1,216 @@
+/**
+ * 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 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,
+ 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));
+
+// 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);
+
+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 prom erlDb ref% mic% coupled');
+ console.log(' ------- ---- ---- ----- ---- ---- -------');
+});
+
+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.
+ 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)}` +
+ ` ${num(m.prominence, 2).padStart(4)}` +
+ ` ${num(m.erlDb).padStart(6)}` +
+ ` ${num(m.refActivePct, 0).padStart(4)}` +
+ ` ${num(m.micActivePct, 0).padStart(4)}` +
+ ` ${m.coupled ? 'yes' : 'no'}`
+ );
+});
+
+ipcMain.on('probe:done', (_event, summary) => {
+ 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(`prominence : median ${num(summary.prominenceMedian, 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.');
+ }
+ }
+ 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();
+});
+
+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..a6a6c407
--- /dev/null
+++ b/test/manual/echo-probe/renderer.js
@@ -0,0 +1,358 @@
+/**
+ * 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;
+
+// Peak height alone cannot tell coupling from noise, and this is the single most important thing
+// the probe has measured so far. The search takes the MAX over ~120 candidate lags, and the max of
+// many correlations is biased upward, so unrelated signals score far higher than intuition
+// suggests: measured 0.53 on pure silence and 0.57 on two independent bursty signals. A threshold
+// of 0.5 - which looks entirely reasonable written down - would call both of those "coupled".
+//
+// Getting that wrong has an asymmetric cost. A false "coupled" on a HEADPHONE user is what leads a
+// gate to start cutting a microphone that was never echoing anything.
+//
+// So the discriminator is peak PROMINENCE: how far the best lag stands above the typical lag. A
+// real echo puts a sharp peak on an otherwise flat correlation surface; unrelated signals produce
+// a surface that is uniformly mediocre, with a high maximum and no peak.
+//
+// CORR_MIN is kept alongside it as a cheap floor, not as the discriminator - on its own it is
+// exactly the threshold shown above to be useless. Both must pass.
+//
+// A starting threshold, to be re-derived from real runs rather than trusted. Synthetic signals
+// suggested a comfortable gap - 0.28 for an unrelated pair against 0.87-1.13 for a clean echo -
+// but a live run of this probe on a silent room reached 0.47, which leaves almost nothing between
+// the noise and the threshold. Both ends of the synthetic gap are optimistic: that echo is a
+// perfectly scaled copy and a real one scores lower, while that "unrelated" pair shares a burst
+// grid and so scores higher than truly unrelated audio.
+//
+// This is why the run-level verdict requires several coupled reports rather than one. A single
+// report crossing this line is exactly what a quiet room produces from time to time.
+//
+// The per-second output prints the raw numbers whatever this is set to, which is the point:
+// measure the real distribution first, then set it.
+const CORR_MIN = 0.5;
+const PROMINENCE_MIN = 0.5;
+
+const MIN_OVERLAP_FRAMES = 50; // 0.5 s
+const DISPLAY_MEDIA_TIMEOUT_MS = 20000;
+
+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.prominence = null;
+ this.erlDb = null;
+ this.samples = [];
+ this.frames = 0;
+ }
+
+ push(ref, mic) {
+ this.frames++;
+ this.refDb.push(toDb(meanSquare(ref)));
+ this.micDb.push(toDb(meanSquare(mic)));
+ if (this.refDb.length > HISTORY_FRAMES) this.refDb.shift();
+ if (this.micDb.length > HISTORY_FRAMES) this.micDb.shift();
+
+ // Paced on the wall clock, which is fine here because frames genuinely arrive at 100/s from a
+ // live capture. Worth knowing before this is copied into the gate: it makes the class
+ // untestable from synthetic input, since a test loop feeds thousands of frames in a few
+ // milliseconds and no interval ever elapses. A gate that needs unit tests should pace on a
+ // frame counter instead.
+ const now = performance.now();
+ if (now - this.lastXcorrAt >= XCORR_INTERVAL_MS) {
+ this.lastXcorrAt = now;
+ this.estimate();
+ }
+ }
+
+ estimate() {
+ const minLag = Math.round(MIN_LAG_MS / FRAME_MS);
+ const maxLag = Math.round(MAX_LAG_MS / FRAME_MS);
+
+ let bestLag = null;
+ let bestCorr = -2;
+ const all = [];
+ for (let lag = minLag; lag <= maxLag; lag++) {
+ const corr = correlateAt(this.refDb, this.micDb, lag);
+ if (corr === null) continue;
+ all.push(corr);
+ if (corr > bestCorr) {
+ bestCorr = corr;
+ bestLag = lag;
+ }
+ }
+ if (bestLag === null) return;
+
+ this.lag = bestLag;
+ this.correlation = bestCorr;
+ // Against the median rather than the mean: a true echo's peak is broad enough to span several
+ // lags, and those neighbours would drag a mean up with it and hide the very prominence being
+ // measured.
+ this.prominence = bestCorr - median(all);
+
+ // Only over frames with a live reference, or the ratio is two noise floors divided.
+ const ratios = [];
+ const lo = Math.max(0, bestLag);
+ const hi = Math.min(this.micDb.length, this.refDb.length + bestLag);
+ for (let f = lo; f < hi; f++) {
+ const refFrame = this.refDb[f - bestLag];
+ if (refFrame < REF_FLOOR_DBFS) continue;
+ ratios.push(this.micDb[f] - refFrame);
+ }
+ this.erlDb = median(ratios);
+
+ // All three conditions, and each rules out a different way of being wrong: a live reference
+ // (or the ratio is two noise floors divided), a peak worth having, and a peak that actually
+ // stands out from its neighbours rather than merely topping a flat surface.
+ if (this.isCoupled()) {
+ this.samples.push({
+ delayMs: bestLag * FRAME_MS,
+ correlation: bestCorr,
+ prominence: this.prominence,
+ erlDb: this.erlDb,
+ });
+ }
+ }
+
+ activePct(series) {
+ if (series.length === 0) return 0;
+ const active = series.filter((db) => db >= REF_FLOOR_DBFS).length;
+ return (100 * active) / series.length;
+ }
+
+ isCoupled() {
+ return (
+ this.correlation !== null &&
+ this.correlation >= CORR_MIN &&
+ this.prominence !== null &&
+ this.prominence >= PROMINENCE_MIN &&
+ this.erlDb !== null
+ );
+ }
+
+ snapshot() {
+ return {
+ delayMs: this.lag === null ? null : this.lag * FRAME_MS,
+ correlation: this.correlation,
+ prominence: this.prominence,
+ erlDb: this.erlDb,
+ refActivePct: this.activePct(this.refDb),
+ micActivePct: this.activePct(this.micDb),
+ coupled: this.isCoupled(),
+ // Reported so a stalled 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,
+ };
+ }
+
+ 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 proms = this.samples.map((s) => s.prominence);
+ const erls = this.samples.map((s) => s.erlDb).filter((v) => v !== null);
+ return {
+ samples: this.samples.length,
+ delayMsMedian: median(delays),
+ delayMsMin: Math.min(...delays),
+ delayMsMax: Math.max(...delays),
+ correlationMedian: median(corrs),
+ prominenceMedian: median(proms),
+ erlDbMedian: median(erls),
+ searchWindow: [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 {
+ // 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(() => {});
+ }
+ 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);