fix(translate): drive talk boundaries from Opus DTX and drop silence frames - #120
Conversation
…frames
With a live mic, `/translate` clients got `sending=true` but no `sending=false`:
the /v1/realtime/translations output-audio stream is continuous — ambient mic
noise keeps OpenAI emitting — so the output-audio-silence heuristic that ends a
talk never triggered, and the continuous comfort noise was forwarded to the
bridge as media. Confirmed live on stage (muting the mic was the only thing that
produced a stop). Neither the client's RFC 6464 audio level nor its VAD bit
distinguishes speech from ambient noise (measured: the V bit is set on 100% of
non-silent packets, including typing), so the discrimination has to come from
the audio itself.
Enable Opus DTX on the output encoder and use libopus's own VAD: OPUS_GET_IN_DTX
flags comfort-noise/silence frames. Voice frames (`!inDtx`) are forwarded and
drive the talk; DTX frames (silence, incl. the periodic CNG updates) are dropped
— not sent to the bridge — and don't extend the talk, so the silence timer ends
it on sustained DTX and the next voice frame starts a fresh one.
- opus_frame_encoder.c (WASM) and opus_addon.cc (native): OPUS_SET_DTX +
OPUS_GET_IN_DTX, with OPUS_SET_VBR(1) enforced when DTX is on (DTX no-ops under
CBR). Makefile exports the two new WASM symbols.
- encodeFrame now returns EncodedFrame { data, inDtx } across both backends,
facade, and interface; `dtx` added to OpusEncoderConfig (off by default).
- TranslatorConnection creates the encoder with dtx:true and skips forwarding /
timer-arming for DTX frames.
Talks are now bracketed by voice runs, so a pause longer than
TRANSLATION_TALK_SILENCE_TIMEOUT_MS splits a talk (tune to taste). ~180 ms of
libopus DTX hangover trails past speech.
Verified: DTX plumbing in both freshly-built backends (tone -> voice,
silence -> inDtx); new OpusEncoderDtx unit test (both backends); boundary unit
test (silence not forwarded, doesn't extend, stop fires, voice-resume = new
talk); real-OpenAI e2e on native and the WASM/Worker path (balanced start/stop,
media dropped ~2060 -> ~1350). Full suite 625 pass; typecheck + worker-safe clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve only) The new OpusEncoderDtx wasm case imported dist/opus-*.cjs, which the CI `test` job doesn't build (it builds only the native addon; the facade uses native there, like OpusRoundTrip). Gate each backend on its artifact existing so the wasm case is skipped where only native is built, and both run locally / wherever build:wasm has run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code ReviewOverviewThis PR fixes a real bug: the synthetic-source Code QualityStrengths:
Minor observations:
Potential IssuesNone critical. One worth surfacing:
Performance
Test CoverageGood. Coverage includes:
One gap worth noting:
SummaryThe fix is correct, well-scoped, and thoughtfully implemented. The DTX approach is better than a custom RMS gate — it reuses the codec's own VAD so the voice/silence boundary is the same signal the encoder itself relies on. The ~180 ms DTX hangover is an acceptable and documented trade-off. The breaking change to |
…DTX frames Per code review: make the "dropping DTX frames is transparent" guarantee explicit — after ~5 s of dropped DTX frames, the resuming voice frame's RTP timestamp jumps by ~the elapsed silence (continuous media-playout clock), not by a single frame, so the client hears real silence rather than a splice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #120: fix(translate): drive talk boundaries from Opus DTX and drop silence framesOverviewThis PR fixes a real production bug: on Positives
Issues / Suggestions1. In int in_dtx = 0;
opus_encoder_ctl(encoder_, OPUS_GET_IN_DTX(&in_dtx));When DTX is disabled, libopus always returns 0, so the result is correct — but there's a per-frame syscall/ctl overhead. For the current use case this is negligible, but a 2. Stale In if (encoded_bytes >= 0) {
int in_dtx = 0;
if (opus_encoder_ctl(ctx->encoder, OPUS_GET_IN_DTX(&in_dtx)) == OPUS_OK) {
ctx->last_in_dtx = in_dtx;
} else {
ctx->last_in_dtx = 0;
}
}
// last_in_dtx NOT reset when encoded_bytes < 0
return encoded_bytes;If 3. Dropped CNG frames and bridge decoder state By dropping DTX/comfort-noise frames entirely (including the periodic CNG updates libopus sends), the bridge's Opus decoder doesn't receive the comfort-noise refresh packets. This is acceptable for silence periods, but worth a CLAUDE.md note so future maintainers understand the tradeoff (bridge gets silence via packet-loss concealment rather than explicit CNG). The RtpTimestamper gap-insertion on voice resume correctly models the real elapsed silence, so the client-side experience should be fine. 4. In let mockInDtx = false;This is reset in 5. Inconsistent error handling between WASM and native on The native addon throws ( Questions
VerdictApprove with minor suggestions. The fix is correct, well-tested, and uses the right tool (libopus VAD) for the job. The issues above are minor — items 2 and 5 are the ones I'd most recommend addressing before merge (stale flag on encode failure and WASM not throwing on DTX-enable failure). Items 1, 3, and 4 are informational. 🤖 Generated with Claude Code |
…re — GitHub Actions incident) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… on encode error Review follow-ups (#120): - OpusEncoderWasm: throw (not warn) if OPUS_SET_DTX fails, matching the native addon. A silently-failed DTX enable would make inDtx never true → every frame forwarded → the voice/silence detection this PR adds silently regresses; fail loudly instead. - opus_frame_encoder.c: reset last_in_dtx=0 when opus_encode returns an error, so a stale value can't be read as a false DTX (belt-and-suspenders; the JS wrapper already throws on a negative return before reading it). - CLAUDE.md: note that dropping DTX frames also drops the periodic comfort-noise (CNG) refresh — the bridge decoder fills silence via PLC, and RtpTimestamper's gap on voice resume keeps the client hearing true silence, not a splice. Full suite 626 pass; typecheck + worker-safe clean; WASM rebuilt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #120: fix(translate): drive talk boundaries from Opus DTX and drop silence framesOverviewThis PR fixes a production bug where the synthetic-source Code Quality — Mostly excellentC layer ( The implementation is sound. A few observations:
TypeScript layer
One unconditional overhead: Potential Issues1. Transcript deltas re-arming the silence timer during sustained DTX The CLAUDE.md update says the silence timer is "armed by voice frames (and, per above, transcript deltas)". If a long transcript arrives during sustained DTX audio output (all silence frames dropped), the timer could be re-armed and delay the 2. First frame from OpenAI being a DTX frame If the very first audio delta from OpenAI happens to be classified as DTX (unlikely but possible on a cold start), Test Coverage — Good
One small nit: the RTP-gap test asserts Security / PerformanceNo concerns. The change tightens what's forwarded to the bridge (silence is dropped), reducing bandwidth. DTX is a well-understood Opus feature; using DocumentationCLAUDE.md is updated in the same commit and the new talk-boundary section is accurate and comprehensive. The note about CNG packets being dropped (bridge fills via PLC → true silence rather than a splice) is a valuable callout for future maintainers. SummarySolid fix. The design is elegant, the implementation is correct, the new tests cover the critical cases against real encoder binaries, and the documentation is thorough. The main actionable items are:
Everything else is either a non-issue or a deliberate trade-off well-explained in the code. |
…havior Review follow-ups (#120, 2nd pass): - opus_addon.cc: comment why the OPUS_GET_IN_DTX ctl return is intentionally unchecked (in_dtx pre-initialised to 0 → a failure reports voice, never a false silence), matching opus_frame_encoder.c's explicit fallback. - CLAUDE.md: note the symmetric edge of the hangover — a run whose first frame is classified DTX simply defers talk-start to the first voice frame. Comment/doc only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code ReviewSummary: This PR fixes a real bug (sending-indicator stuck on) with an elegant, codec-level solution. Using libopus's own VAD via Strengths
Observations / Minor Issues1. Error handling asymmetry between WASM and native for In const dtxRet = this.module._opus_frame_encoder_set_dtx(this.ctx, 1);
if (dtxRet < 0) throw new Error(`OpusEncoder: set_dtx(1) failed (${dtxRet})`);In 2. // TranslatorConnection.ts:430
dtx: true,DTX is always on for translation connections with no way to opt out per-connection. Given the PR description, this is intentional (the whole 3. Removal of -if (opusFrames.length > 0) {
- for (const frame of opusFrames) {
- this.sendAudioFrame(frame);
- }
+for (const frame of opusFrames) {
+ this.sendAudioFrame(frame.data, frame.inDtx);
}This is correct — iterating over an empty array is a no-op. Minor clean-up, no issue. 4. CNG drop acknowledged but bridge decoder behaviour is deployment-dependent CLAUDE.md correctly notes: "dropping DTX frames also drops libopus's periodic comfort-noise (CNG) refresh packets, so during silence the bridge's decoder gets no explicit comfort noise — it fills the gap via packet-loss concealment." PLC is standard and JVB implements it, but deployments using a different bridge/decoder that lacks PLC could hear artefacts. The documentation is clear, which is sufficient. 5. if (ts != null) { if (parsed.event === 'start') talkStartEvents++; else talkStopEvents++; }This compact form is fine for a debug script. One minor note: the 6. const voice = enc.encodeFrame(tone(15)); // 15 frames of 440 Hz15 frames = 300ms of tone. The test then checks SummaryThis is a well-engineered fix. The core change (DTX in |
Problem
On
/translatewith a live mic, clients receivedsending=truebut neversending=false— the synthetic-source "sending" indicator stuck on. The/v1/realtime/translationsoutput-audio stream is continuous: a live mic's ambient/room noise keeps OpenAI emitting audio, so the output-audio-silence heuristic that ends a talk never fired, and the continuous comfort noise was forwarded to the bridge as media. Confirmed live on stage — muting the mic was the only thing that produced a stop.Neither client-side signal distinguishes speech from ambient noise: the RFC 6464 audio level only marks hard mute, and the VAD bit, while reliably present (
ext_id=1), is set on 100% of non-silent packets (including typing/room noise) — it mirrors "not muted", not "voice". So the discrimination has to come from the audio itself.Fix
Enable Opus DTX on the output encoder and use libopus's own VAD via
OPUS_GET_IN_DTX:!inDtx) are forwarded to the bridge and drive the talk (start / keep-alive).So the silence timer ends the talk on sustained DTX and the next voice frame starts a fresh one. One mechanism satisfies both goals: DTX-driven start/stop and no silence packets to the bridge.
Changes
opus_frame_encoder.c(WASM) andnative/opus_addon.cc(native):OPUS_SET_DTX+OPUS_GET_IN_DTX, withOPUS_SET_VBR(1)enforced when DTX is enabled (DTX no-ops under CBR).Makefileexports the two new WASM symbols.encodeFramenow returnsEncodedFrame { data, inDtx }across both backends, the facade, andIOpusEncoder;dtx?added toOpusEncoderConfig(off by default).TranslatorConnection— creates the encoder withdtx: true;sendAudioFrameearly-returns on DTX frames (no forward, no timer arm). TheRtpTimestamperinserts the real gap when voice resumes, so contiguous RTP sequence numbers + jumping timestamps are preserved (standard silence-suppression).Notes / behavior changes
TRANSLATION_TALK_SILENCE_TIMEOUT_MS(default 350 ms) splits a talk (~10–11 talks for the 47 s sample vs. 3 before). Tune via that env var.Testing
inDtx=false, silence →inDtx=true), captured by the newOpusEncoderDtx.test.ts(runs against native and WASM).typecheck+typecheck:worker+check:worker-safeclean. (dist/+build/are gitignored; CI/Docker rebuild the encoders from the C changed here.)🤖 Generated with Claude Code