Skip to content

Suppress speaker echo on the microphone channel - #113

Draft
alpha5611331 wants to merge 5 commits into
mainfrom
feat/echo-suppression
Draft

Suppress speaker echo on the microphone channel#113
alpha5611331 wants to merge 5 commits into
mainfrom
feat/echo-suppression

Conversation

@alpha5611331

@alpha5611331 alpha5611331 commented Aug 28, 2026

Copy link
Copy Markdown
Member

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 getUserMedia call sites in live-transcription.service.ts passed only a deviceId, so echoCancellation, noiseSuppression and autoGainControl ran 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 deviceId rather than audio: true, which would have dropped the flags along with it. The flags are plain (advisory) values, not exact, so there is no new OverconstrainedError path.

2. A manual probe for the coupling

test/manual/echo-probe.mjs drives a real Electron session and reports, once a second:

    delayMs   corr   prom    erlDb   ref%   mic%   coupled
    -------   ----   ----    -----   ----   ----   -------
        120   0.87   0.71    -18.4     71     74   yes

It also drives the A/B the constraints work exists for:

pnpm exec electron test/manual/echo-probe.mjs --no-aec
pnpm exec electron test/manual/echo-probe.mjs --no-agc

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. It is not packaged either - build.files ships only electron-dist, dist and package.json, so the probe's nodeIntegration never 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 getDisplayMedia loopback 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_MIN of 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 INCONCLUSIVE rather 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 --seconds now exit 2. A non-numeric --seconds previously reached setTimeout as NaN, ending the run before it started, which reads like a headphone result.

getDisplayMedia was unbounded, so a loopback that never resolves left the probe silent with no output. Bounded at 20s, the same as live-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.ts needs unit tests, so it should pace on a frame counter instead.

Still to come on this branch

  • Measure - A/B echoCancellation and autoGainControl on 2-3 machines; record delayMs (with sign), correlation, prominence and erlDb.
  • Layer 1 - one AudioContext, a two-input worklet, and a delay-aligned energy gate on ch_1 (audio-capture-graph.ts + a dependency-free echo-gate.ts), plus the audio-device-switch.test.mjs rewrite.
  • Layer 2 - cross-channel transcript dedup in ingest(), gated on time overlap (echo-dedup.ts).
  • Docs - hero docs and a documentation-dialog.tsx entry on speaker vs headphone capture; fix the stale "deduplicates overlapping segments" claim at CLAUDE.md:63.

Verification

  • pnpm lint, tsc -b, pnpm test:main all green.
  • Probe run end to end on Windows 11 repeatedly: acquires both streams, reports the applied constraints back, runs the correlator, prints the summary. With nothing playing it correctly reports no correlated frames, 0/N (0%) coupled, and points at the ref% column.
  • Argument guards exercised (--bogus, --seconds=abc, --seconds=0).
  • Correlator verified against synthetic signals of known delay, driving the real file rather than a copy. That harness is not committed - the equivalent coverage belongs in test/echo-gate.test.mjs once the gate exists and is testable, which is what the pacing note above is about.
  • Not done: the actual A/B across machines. It needs a person playing a recorded interview through speakers and cannot run from CI.

Supersedes #112, which GitHub closed when the branch was renamed.

alpha5611331 and others added 3 commits August 27, 2026 20:54
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>
@gitar-bot

gitar-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

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
mchl7852 marked this pull request as draft August 28, 2026 01:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Speaker echo on the mic channel silently suppresses live suggestions

1 participant