Suppress speaker echo on the microphone channel - #113
Draft
alpha5611331 wants to merge 5 commits into
Draft
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This was referenced Aug 28, 2026
Review of the probe turned up four ways it could report a confident number that was not true. The run-level verdict latched on a single coupled report, so one spurious second decided the headline finding for the whole run. That matters more after a live run reached a prominence of 0.47 in a silent room, against a threshold of 0.5 - the synthetic separation of 0.28 was optimistic, and a quiet room crosses that line occasionally. The verdict now counts coupled reports and refuses to call a run that cannot show several, reporting the fraction either way and saying INCONCLUSIVE rather than guessing. A stalled capture was invisible. push() simply stops being called, the report timer keeps firing, and the same numbers print every second looking exactly like a steady measurement. Frames are now counted and a report with no new frames says so, with a warning in the summary. getDisplayMedia was unbounded, so a loopback that never resolves left the probe sitting silently with no output. Bounded at 20s, the same as live-transcription.service.ts bounds it. And CORR_MIN now says explicitly that it is a floor rather than the discriminator - on its own it is the threshold already shown to be useless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mchl7852
marked this pull request as draft
August 28, 2026 01:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #111.
The branch for the whole echo-suppression feature, landing in the order the work has to happen: the measurement first, because every constant in the gate is sized from it, and measuring after the fact means tuning twice.
Landed so far
1. Explicit microphone processing constraints
Both
getUserMediacall sites inlive-transcription.service.tspassed only adeviceId, soechoCancellation,noiseSuppressionandautoGainControlran on Chromium's implicit defaults.This changes no behaviour today - all three already default to
true. The point is that they stop moving on their own under a Chromium version bump, and that there is one place to flip them from once the probe says which way they should go. AGC is 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
deviceIdrather thanaudio: true, which would have dropped the flags along with it. The flags are plain (advisory) values, notexact, so there is no newOverconstrainedErrorpath.2. A manual probe for the coupling
test/manual/echo-probe.mjsdrives a real Electron session and reports, once a second:It also drives the A/B the constraints work exists for:
Manual, like
taskbar-probe.mjs: it needs a desktop session, real speakers and someone to play audio into them, so it stays out oftest/run.mjs. It is not packaged either -build.filesships onlyelectron-dist,distandpackage.json, so the probe'snodeIntegrationnever reaches a release.What building and reviewing the probe found
Every one of these is a case of the tool reporting a confident number that was not true, which is the only failure mode that matters in an instrument the rest of the feature is tuned against.
The correlator is right, including the sign. Injected delays of +120, +300, -150, -250 and 0 ms all come back exact to the frame, and the ERL matches the injected gain (0.1 -> -20.0 dB). The two-sided search is not theoretical: the acoustic path is always mic-after-speaker, but what is measured is arrival order at the worklet, and Chromium's
getDisplayMedialoopback path carries its own latency, so on some machines the reference arrives after the echo it explains.Peak height cannot tell coupling from noise. The search takes the max over ~120 candidate lags, and the max of many correlations is biased upward. Measured: 0.53 on pure silence, 0.57 on two independent bursty signals. A
CORR_MINof 0.5 - which looks perfectly reasonable written down, and is what the design started with - calls both of those coupled. That error runs in the dangerous direction: a false "coupled" on a headphone user is what leads a gate to cut a microphone that was never echoing anything.Replaced with peak prominence, the peak's height above the median lag. A real echo puts a sharp peak on an otherwise flat surface; unrelated signals produce a surface that is uniformly mediocre with a high maximum and no peak.
And the prominence threshold has less margin than the synthetic numbers implied. Synthetics gave 0.28 unrelated against 0.87-1.13 for echo. A live run in a silent room reached 0.47, against a threshold of 0.5. So the run-level verdict now requires several coupled reports rather than latching on the first - one spurious second was previously enough to decide the headline finding for an entire run - and reports the fraction either way, saying
INCONCLUSIVErather than guessing.A stalled capture was invisible. If frames stop arriving,
push()simply stops being called, the report timer keeps firing, and the same numbers print every second looking exactly like a steady measurement. Frames are counted now; a report with no new frames says so and the summary warns.A mistyped flag was silently ignored.
--noaec(a plausible slip for--no-aec) ran with echo cancellation ON and reported a credible number for the configuration you were trying to rule out - in the one tool whose entire job is scoring that A/B. Unknown arguments and a non-positive--secondsnow exit 2. A non-numeric--secondspreviously reachedsetTimeoutasNaN, ending the run before it started, which reads like a headphone result.getDisplayMediawas unbounded, so a loopback that never resolves left the probe silent with no output. Bounded at 20s, the same aslive-transcription.service.ts.A note for the gate, recorded in the code: the meter paces its cross-correlation on
performance.now(). That is fine for a live capture at 100 frames/s, but it makes the class untestable from synthetic input, because a test loop feeds thousands of frames in a few milliseconds and no interval ever elapses.echo-gate.tsneeds unit tests, so it should pace on a frame counter instead.Still to come on this branch
echoCancellationandautoGainControlon 2-3 machines; recorddelayMs(with sign),correlation,prominenceanderlDb.AudioContext, a two-input worklet, and a delay-aligned energy gate onch_1(audio-capture-graph.ts+ a dependency-freeecho-gate.ts), plus theaudio-device-switch.test.mjsrewrite.ingest(), gated on time overlap (echo-dedup.ts).herodocs and adocumentation-dialog.tsxentry on speaker vs headphone capture; fix the stale "deduplicates overlapping segments" claim atCLAUDE.md:63.Verification
pnpm lint,tsc -b,pnpm test:mainall green.0/N (0%)coupled, and points at theref%column.--bogus,--seconds=abc,--seconds=0).test/echo-gate.test.mjsonce the gate exists and is testable, which is what the pacing note above is about.Supersedes #112, which GitHub closed when the branch was renamed.