🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

fix(audio): stop the echo/feedback loop and keep voice ahead of video - #40

Merged
ralyodio merged 1 commit into
masterfrom
worktree-audio-quality-fix
Aug 9, 2026
Merged

fix(audio): stop the echo/feedback loop and keep voice ahead of video#40
ralyodio merged 1 commit into
masterfrom
worktree-audio-quality-fix

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Calls echoed for both parties and sounded choppy. Four separate causes, spread across every client.

The feedback loop (desktop) — the big one

CapturePreview.tsx published the recording mixer's output to viewers. That mix contains every viewer's own audio, so each viewer got their own voice returned to them and the loop closed through the host. With two viewers, B also heard A twice — once relayed directly, once inside the host mix.

The host now publishes its bare microphone. The mixer stays what it always should have been: a recording-only artifact. RTMP broadcast keeps the full mix, which is correct — it's one-way to an outside audience, so nothing comes back into the call.

The host heard everyone twice

Each remote track was played by an <audio> element in the WebRTC hook and routed to the speakers again through the AudioContext (playback=true), offset by the graph's buffer. Sounds exactly like an echo.

Nobody ever asked for echo cancellation

Every mic capture across desktop/web/mobile was a bare getUserMedia({ audio: true }). They now share one constraint set from @pairux/shared-types, so the clients can't drift into the state where one end cancels and the other doesn't.

Mobile gets a deliberately narrower set: on Android react-native-webrtc forwards every key to createAudioSource as a mandatory native constraint, so it carries only the flags libwebrtc actually recognises — sampleRate/channelCount aren't audio-source constraints there and would only widen the blast radius.

Video was starving audio (the choppiness)

Screen video was marked priority: 'high' and networkPriority: 'high' at up to 8 Mbps, while audio senders got no encoding params at all — so the bandwidth allocator served 4K screen video first. Audio now outranks video, and Opus runs with in-band FEC + DTX so a lost packet is a duller syllable rather than a gap.

Priorities are re-applied once each connection reaches connected, since some stacks report no encodings on a sender until negotiation completes, which would make the addTrack-time call a silent no-op.

Verification

  • tsc --noEmit clean on desktop, web, and mobile
  • ESLint: 0 errors (3 warnings, all pre-existing on main)
  • Tests: 517 desktop + 583 web + 67 mobile + 61 shared-types, all passing
  • 17 new tests cover the Opus SDP munging (FEC/DTX/mono, CRLF and bare-LF handling, Opus-free SDP left untouched) and sender prioritisation (including the no-encodings and setParameters-failure paths)

Not covered — needs a device

Mobile has no audio-session setup at all (no InCallManager equivalent), so iOS/Android may not enter voice-communication mode. The constraint change is the part I could verify; wiring up an audio session needs a native dep and a prebuild, so I left it out rather than ship it blind. See the "remaining" note in the task thread.

🤖 Generated with Claude Code

Calls echoed for both parties and sounded choppy. There were four
separate causes, spread across every client.

The feedback loop was the big one. On desktop the host published the
*recording mixer's* output to viewers, and that mix contains every
viewer's own audio — so each viewer got their own voice returned to
them and the loop closed through the host. Viewers with a second peer
in the call also heard that peer twice: once relayed directly, once
inside the host mix. The host publishes its bare microphone now; the
mixer stays what it always should have been, a recording-only artifact.
RTMP broadcast keeps the full mix, which is correct — it is one-way to
an outside audience, so nothing comes back.

The host also heard every viewer twice. Each remote track was played by
an <audio> element in the WebRTC hook *and* routed to the speakers again
through the AudioContext, offset by the graph's buffer.

No client ever asked for echo cancellation — every microphone was a bare
getUserMedia({ audio: true }). They now share one set of constraints, so
desktop, web and mobile cannot drift into the state where one end
cancels and the other does not. Mobile gets a narrower set: on Android
every key is forwarded to createAudioSource as a *mandatory* native
constraint, so it carries only the flags libwebrtc actually recognises.

Finally, screen video was marked high priority in both the encoder queue
and on the wire while audio was left unranked, so a share allowed up to
8 Mbps won the bandwidth allocator and left the voice stream to stutter.
Audio now outranks video, and Opus runs with in-band FEC so a lost
packet is a duller syllable rather than a gap. Priorities are re-applied
once a connection is established, since some stacks report no encodings
on a sender until negotiation completes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

159 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 48 | LOW: 106

Severity Rule Location
HIGH secret-generic-credential apps/livekit/fly.toml:12
HIGH manifest-typosquat apps/mobile/package.json:43
HIGH secret-generic-credential apps/turn/fly.toml:11
HIGH secret-generic-credential docs/API.md:648
HIGH secret-generic-credential docs/API.md:654
MEDIUM insecure-temp-file apps/desktop/src/main/streaming/index.test.ts:64
MEDIUM sql-template-interpolation apps/desktop/src/renderer/components/capture/CameraBubble.tsx:129
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:370
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:393
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:402
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:438
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:48
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:73
MEDIUM js-unescaped-html-sink apps/web/src/app/c/[handle]/page.tsx:191
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:165
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:128
MEDIUM js-unescaped-html-sink apps/web/src/app/live/page.tsx:145
MEDIUM js-unescaped-html-sink apps/web/src/app/page.tsx:122
MEDIUM js-unescaped-html-sink apps/web/src/app/pricing/page.tsx:284
MEDIUM js-open-redirect apps/web/src/app/pricing/UpgradeButton.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/u/[username]/page.tsx:282
MEDIUM manifest-install-lifecycle-script package.json:54
MEDIUM sql-template-interpolation packages/ai-core/src/prompts.ts:36
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:57
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:72
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:88
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:108
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:128
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:151
MEDIUM sql-template-interpolation scripts/lib/package-managers/apt.ts:55
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:154
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:160
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:208
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:313
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/aur.ts:310
MEDIUM sql-template-interpolation scripts/lib/package-managers/aur.ts:341
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/aur.ts:341
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:264
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:288
MEDIUM sql-template-interpolation scripts/lib/package-managers/gentoo.ts:274
MEDIUM sql-template-interpolation scripts/lib/package-managers/homebrew.ts:152
MEDIUM sql-template-interpolation scripts/lib/package-managers/nix.ts:225
MEDIUM sql-template-interpolation scripts/lib/package-managers/rpm.ts:55
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:201
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:261
MEDIUM sql-template-interpolation scripts/lib/package-managers/scoop.ts:145
MEDIUM js-shell-exec-interpolation scripts/release.mjs:145
MEDIUM js-shell-exec-interpolation scripts/release.mjs:146
MEDIUM js-shell-exec-interpolation scripts/release.mjs:147
MEDIUM sql-template-interpolation scripts/supabase-update-emails.ts:101

…and 109 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 9, 2026 11:48
@ralyodio
ralyodio merged commit 499c3bb into master Aug 9, 2026
13 checks passed
@ralyodio
ralyodio deleted the worktree-audio-quality-fix branch August 9, 2026 11:48
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.

1 participant