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);