feat(moq-audio): cancel the speaker's echo out of the microphone - #2538
feat(moq-audio): cancel the speaker's echo out of the microphone#2538kixelated wants to merge 1 commit into
Conversation
moq-audio can now capture and play, which means it can also hear itself: anyone on a laptop without a headset sends the whole call back to it. This is the second of the two blockers #2282 names, and the reason the playback half had to land first. `playback::Engine::canceller` taps the post-mix signal and `capture::Config::aec` subtracts it from the microphone, so the buffers leaving capture are already clean and `encode::publish_capture` needs no changes. The work runs in the microphone callback on 10 ms frames, which is the ordering the echo model needs and keeps it off both audio callbacks at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 2 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (8)
✨ Finishing Touches✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary
moq-audio can now both capture and play, which means it can also hear itself: on any laptop without a headset the microphone picks up the speaker and sends the whole call back. That is the second of the two blockers #2282 names, and the half of #2478 that #2529 left open.
playback::Engine::cancellertaps the post-mix signal andcapture::Config::aecsubtracts it from the microphone, so buffers leaving capture are already clean andencode::publish_captureis untouched. Two things you get from an open output device now:sink()to play,canceller()to stop hearing it.The engine, not iroh-live's API, is what carried over. Where the shape differs:
AudioSink/AudioSourcetraits or firewheel nodes;Configin, an ownedCancellerout,Dropremoves the tap.fixed-resamplechannel, not a raw ring. It resamples the device's rate to a fixed 48 kHz, so switching to a 44.1 kHz output doesn't invalidate an adaptive filter that spent seconds converging, and it costs nothing on the 48 kHz devices that are the norm. Theirs pinned the whole engine to one internal rate to get the same property.split_at_mutper channel count rather than collecting aVec<&[f32]>per 10 ms frame. On the output side the tap owns a heap ring, so a replaced or cleared one leaves by the same retire channel a removed sink uses; that channel now carries aRetiredenum instead of a bare sink entry.Root cause of a deadlock found while writing this
The tap registration is the mirror of a sink's, and the driver rebuilds it inside its own lock. Holding a strong handle on the canceller there means the last one can drop there, and its
Dropderegisters, which re-enters that same lock.Statesits behind its ownArcso the driver only ever holds aWeakon the canceller itself and never upgrades it.Dependency
One new crate, optional:
sonora, a pure-Rust port of WebRTC's audio processing (AEC3, noise suppression, AGC2). BSD-3-Clause like the upstream it was ported from, already ondeny.toml's allow list, and it adds no C++ toolchain, which was the whole reason #2478 asked whether a pure-Rust AEC existed. No duplicate versions enter the tree; no git dependency, per that issue's prerequisite.The maturity check that issue asked for turned up a real caveat. 0.1.0 is the only release and it panics when the adaptive filter shrinks (sonora#14) — reported in the wild after a 700 s call on a MacBook Air, i.e. something a long call on real hardware does reach. The one-line fix is upstream and merged (#15) but unreleased, and a release request is open and unanswered (#24). This workspace builds
panic = "abort", so there is nothing to catch in-process.That is why
aecis a non-default feature and why nothing else in the repo turns it on: enabling it today is opting into a known upstream panic on long calls. The code is unaffected by the fix, so acargo updateto 0.1.1 is the whole remedy. Worth deciding before merge whether to hold this until 0.1.1 exists, ping upstream, or land it opt-in as-is. The caveat and the tracking links are recorded next to the dependency inCargo.toml.Public API changes
All additive, all behind the new non-default
aecfeature. Nothing renamed, removed, or signature-changed, so this targetsmain.moq_audio::aec(new module)aec::Config—#[non_exhaustive],Default, withnoise_suppression: boolandauto_gain: bool(both on, matching what a browser gives a call).aec::Canceller—Clone + Debug, withset_enabled(bool)/enabled() -> bool. Everything else on it ispub(crate).moq_audio::playback::Engine::canceller(aec::Config) -> aec::Canceller.moq_audio::capture::Config::aec: Option<aec::Canceller>— a new field on a#[non_exhaustive]struct, so callers already build it viadefault().playback::{Shared, BUS_CHANNELS}becamepub(crate)re-exports for theaecmodule; neither is public surface.Cross-Package Sync
moq-audiohas no row in the table and nodoc/page.moq-ffiand wrapper exposure is explicitly out of scope in #2478; nothing in the FFI surface changed.rs/CLAUDE.md's crate map is updated.Test plan
cargo nextest run -p moq-audio --all-features— 74 passed. Clippy-D warningsacross--all-features,--no-default-features,--features capture, and--features playback, plus the workspace--all-features/--no-default-featuresgates;cargo doc -D warnings,fmt,sort,shear, anddeny checkall clean.New tests, all hardware-free:
One
#[ignore]d device round trip (taps_a_real_output_device) confirms a real output device reaches the tap; run it withcargo test -p moq-audio --features aec -- --ignored. Whether the microphone then stops hearing the speaker is an acoustic question about the room, so it is a manual check, and CI compiles this feature without running it.Closes #2478.
(Written by Claude Opus 5)