fix(audio): WhatsApp PTT format mismatch — ElevenLabs MP3 sent as Ogg/Opus#5283
fix(audio): WhatsApp PTT format mismatch — ElevenLabs MP3 sent as Ogg/Opus#5283f-liva wants to merge 5 commits into
Conversation
houko
left a comment
There was a problem hiding this comment.
Same root cause as #5284:
synthesize_elevenlabs()(crates/librefang-runtime/src/tts.rs~178) doesn't accept aformat_overrideparameter and forcesself.config.elevenlabs.output_formatunconditionally. OpenAI and Google TTS in the same module DO accept the override. As written this:- breaks API-contract parity —
format_overrideworks for openai/google but is silently ignored for elevenlabs; - nullifies #5284's tool-call-arg path for the ElevenLabs provider specifically.
Fix: addformat_override: Option<&str>to the elevenlabs signature, use it in the request-build (after the config-default resolution).
- breaks API-contract parity —
Two nits:
detectAudioMime(packages/whatsapp-gateway/index.js~3368) matches the OggS container magic and then assertsaudio/ogg; codecs=opus— but the container marker alone doesn't prove Opus codec; Ogg/Vorbis would false-positive. Probably acceptable for WhatsApp PTT (Vorbis would fail downstream anyway), but a one-line comment explaining "container-only detection assumes Opus per config context" would prevent confusion.- 8 unit tests for
detectAudioMimein isolation, but no integration test assertingsynthesize_elevenlabswith default config produces a buffer thatdetectAudioMimereads as Opus. Worth a#[tokio::test]that mocks the ElevenLabs response with Opus bytes and assertsTtsResult.format == "opus".
The fix strategy (request Opus at source rather than transcode) is the right call.
|
Addressed the review in Blocker — Nit 1 — container-only OggS detection (packages/whatsapp-gateway/index.js:3402) Nit 2 — integration test (crates/librefang-runtime/src/tts.rs:402-501)
I did not add a full Verification
|
houko
left a comment
There was a problem hiding this comment.
Thanks for the thorough fix — the root-cause analysis (MP3 bytes labelled audio/ogg; codecs=opus rejected by mautrix-whatsapp) is convincing, and the magic-byte sniffer + auto-downgrade in sendAudio is the right shape. The code itself reads correctly. Three CI blockers need resolving before this can merge, though:
-
Schema baseline drift (
OpenAPI Driftjob, fail).TtsElevenLabsConfigderivesschemars::JsonSchema(crates/librefang-types/src/config/types.rs:1283), so adding the newoutput_formatfield (line 1298) changes the config JSON schema and thereforextask/baselines/config.sha256. CI's auto-commit path can't run on a fork PR (IS_INTERNAL_PR: false), so this needs to be regenerated locally and committed:cargo xtask schema-check genthen commit the updated
xtask/baselines/config.sha256(and anyopenapi.json/sdk/deltas it produces). -
Formatting (
Qualityjob, fail oncargo xtask fmt). The reported diffs are all in files this PR doesn't touch (crates/librefang-cli/src/main.rs,crates/librefang-cli/src/tui/event.rs,crates/librefang-migrate/src/openclaw.rs,crates/librefang-types/src/config/types.rs:6542). That points at a stale base — the branch is on4bc691fwhilemainis now at9db55b6, andmainhas since reformatted those files. A rebase on currentorigin/mainshould clear it. -
Test / Windows (shard 1/2)fail. It ran ~45 min (vs ~12s for shard 2/2), so it looks like a timeout/flake rather than a logic failure, but please re-check after the rebase.
One non-blocking note on correctness, just to confirm intent: TtsResult.format is now derived from the output_format prefix (tts.rs:251-256) instead of the hard-coded "mp3". That value flows into tool_runner/media.rs:470 as the saved-file extension (music_{timestamp}.{format}), so the default now yields ….opus. That's the intended/correct behavior given the container, just flagging it as a downstream effect of the change.
Once the baseline is regenerated and the branch is rebased, this should be good to go.
houko
left a comment
There was a problem hiding this comment.
Review
The core fix is sound — this is a real source-side fix (request Opus from ElevenLabs), not a mislabel. Requesting changes for one genuine CI item plus two minor nits. The other red checks are stale-base.
Required: regenerate config.sha256 (real drift, not stale-base)
The OpenAPI Drift check is a genuine failure caused by this PR. The PR edits librefang.toml.example (adds the [tts.elevenlabs] block) but does not regenerate the schema baseline that hashes that file:
xtask/baselines/config.sha256still pins4f12fa2a07651bb76ce35378a3cb4126b964e4b953c74c52871d20e7ba49c37e librefang.toml.example- actual hash of the file in this branch is
a0df9a053023011bf36dacd7f5a179c59f39570fc873df7d8f2f27e384d81f9e
The baseline surface list is in crates/librefang-api/tests/config_schema_overlay.rs:47-51 (config → librefang.toml.example → xtask/baselines/config.sha256). Fix:
cargo xtask schema-check gen
then commit the updated xtask/baselines/config.sha256 (and any openapi.json / sdk/* the regen touches). This is in-scope for the PR since the PR is what changed the hashed file.
Stale-base (rebase clears these — not code defects)
- Test / Ubuntu (shard 1/4): fails on
every_kernel_config_struct_field_is_exposed_via_overlay(config_schema_overlay.rs:287) andcomms_send_rejects_oversize_message(network_routes_integration.rs:430). Both files are byte-identical toorigin/mainon this branch (git diff origin/main HEADis empty for them), and they're unrelated to the audio diff. The branch mergedmainat5875b13d, which is one commit behindorigin/main(9f38b30a). These are the known stale-base signatures. - Quality (rustfmt): every
Diff in …line is in a file NOT in this PR (triggers.rs,routes/agents.rs,routes/skills.rs,server.rs,agent_execution.rs, …). Whole-file fmt drift from the stale base, not the audio change.
Please rebase onto current origin/main so the test/fmt lanes clear, and regenerate the config baseline as above.
Logic — correct approach
tts.rs:194-197now appends?output_format={output_format}(defaultopus_48000_32), so ElevenLabs returns real OGG/Opus bytes. This is the right fix — it does not relabel MP3 as Ogg; it changes what the API actually emits.tts.rs:251-257derives theformatlabel from the requested prefix (opus_48000_32→"opus"), so the label tracks the real container.index.js detectAudioMimesniffs magic bytes and only keepsptt: truewhen the buffer is genuinely Ogg/Opus, downgrading to a plain attachment otherwise (sendAudio). Good defense-in-depth; no silent corruption.- Nice interaction with the existing ffmpeg path:
tool_runner/media.rs:711skipsconvert_to_ogg_opuswhenformatis alreadyopus, so requesting Opus at the source avoids a redundant transcode. Coherent.
Minor nits (in-scope, please address)
- File extension
.opusvs.ogg—tool_runner/media.rs:750builds the filename astts_{ts}.{format}, so an ElevenLabsopus_48000_32result is saved as*.opus. ElevenLabs returns an OGG container (the buffer starts withOggS, which is exactly whatdetectAudioMimekeys on), so the conventional extension is.ogg..opusis a tolerated Ogg/Opus extension and the gateway sniffs bytes (not extension), so this isn't a functional break — but the saved filename misrepresents the container. Consider mapping theopusprefix to anoggextension, or document the choice. - Ogg/Vorbis false-positive is acknowledged in the
detectAudioMimecomment (index.js). Assertingcodecs=opuson anyOggSbuffer is acceptable under the current TTS envelope, but if a non-Opus Ogg ever reaches this path it would be mislabeled. Fine to leave given the comment; flagging for visibility.
Once rebased + config.sha256 regenerated, this looks good to merge.
The previous baseline hash predates the `[tts.elevenlabs]` block added to `librefang.toml.example` by this PR. `cargo xtask schema-check gen` re-computes the hash to match the current example file; no other baseline changed. In-scope per houko librefang#5283 review (OpenAPI Drift check).
|
Regenerated The new hash matches the |
houko
left a comment
There was a problem hiding this comment.
The audio fix itself is correct — config field plumbing is complete (config/types.rs:1283-1320: struct-level #[serde(default)], all derives, output_format in the Default impl), synthesize_elevenlabs threads format_override and derives the label from the prefix (tts.rs:179-256), the gateway sniffer downgrades non-Opus PTT, and config.sha256 is regenerated.
Blocking on PR hygiene, not code: #5283 and #5284 contain duplicate copies of this entire change. git log origin/main.. on each branch shows both carry a fix(audio): … commit (38879423 here vs 0b0bb622 on #5284) and a plumb format_override commit, and #5284's diff is a strict superset of this one — the same tts.rs / config/types.rs / librefang.toml.example / index.js / config.sha256 hunks. Whichever merges first will conflict the other.
Please pick one path: either (a) close this and let #5284 (which subsumes it) carry the whole change, or (b) keep #5283 as the base and properly rebase #5284 onto this branch so #5284's diff narrows to just the media.rs tool_text_to_speech delta. Happy to re-review once the duplication is resolved.
Non-blocking nits (acknowledged, fine to leave): .opus extension on an OGG container (media.rs:743/776); the Ogg/Vorbis false-positive in detectAudioMime.
|
Status update after the merge-of-main in 1. The PR is now 2. The remaining red check is inherited from main, not yours. The audio change itself remains correct on review: |
houko
left a comment
There was a problem hiding this comment.
Rebase required + one type-contract bug in the format_override path.
1. Rebase needed (blocking)
The branch's merge base is 34c44b62 and origin/main is now 14 commits ahead of that point. The Quality CI run (job/77488129989) fails with six clippy errors, none of which are in any file this PR touches:
crates/librefang-api/src/routes/registry.rs:287— needless borrow on.strip_prefix(&home_dir)crates/librefang-api/src/validation.rs:468-471— doc list items without indentationcrates/librefang-api/src/webchat.rs:277— manual char comparison
These were all fixed in main by 72c1f60d (fix(api): clear clippy Quality lane) which landed after the PR's Merge branch 'main' into pr/audio-mime-fix commit (3e7faa7f). The merge only pulled up to 34c44b62. The fix: rebase (or merge again) onto current origin/main — the clippy errors are not yours to resolve, they are already resolved upstream, you just need to pick them up.
2. format_override / ElevenLabs output_format type-contract mismatch (correctness bug)
tts.rs:182-193: synthesize_elevenlabs receives format_override: Option<&str> and forwards it verbatim as the ElevenLabs ?output_format= query parameter, which expects compound values like opus_48000_32, mp3_44100_128, etc.
The call site at tool_runner/media.rs:581 passes the tool input's format field:
// tool_runner/media.rs:514, 581
let format = input["format"].as_str(); // user-supplied; e.g. "mp3", "opus"
let result = engine.synthesize(text, voice, format).await?;If a caller passes format: "mp3" (the valid OpenAI/Google label), the ElevenLabs path constructs ?output_format=mp3, which is not a recognised ElevenLabs format string — the API will reject it.
The happy path (no tool-level format override → config default opus_48000_32) is correct. But the override channel is broken for ElevenLabs: a bare label that is valid for OpenAI becomes an invalid compound key for ElevenLabs.
Suggested fix: In synthesize_elevenlabs, reject or remap a format_override that lacks the ElevenLabs compound structure (e.g. no _<rate> suffix), or document that format overrides are not forwarded to the ElevenLabs path and pass None unconditionally, letting only the new config field ([tts.elevenlabs] output_format) drive the choice.
Code review (what is correct)
- The root-cause diagnosis is accurate: ElevenLabs returns MP3 by default, and sending MP3 bytes labelled
audio/ogg; codecs=opusbreaks the Beeper/mautrix-whatsapp bridge. detectAudioMimemagic-byte table inindex.jsis correct: OggS prefix, ID3 header, 0xFF 0xEx MPEG sync, RIFF, fLaC, ftyp-at-offset-4. The comment acknowledging OggS ≠ Ogg/Opus but explaining why it is safe under this gateway's operating envelope is appropriate.- The auto-downgrade (
effectivePtt = ptt && isOggOpus) with aconsole.warnis a safe fallback rather than a silent corrupt. TtsElevenLabsConfig.output_formatfield is properly added to the struct,Defaultimpl, and has#[serde(default)]on the parent struct — consistent with the CLAUDE.md convention for config fields.- The
split('_').next()prefix derivation correctly mapsopus_48000_32 → "opus",mp3_44100_128 → "mp3", etc. forTtsResult.format. - Tests pin the 3-tier resolution chain and the format-label derivation; the JS test suite covers all 8
detectAudioMimecases.
Summary: The logic is sound on the no-override path that fixes the reported bug. Two things need addressing before merge: (1) rebase onto current main to pick up the upstream clippy fix that unblocks Quality CI, and (2) guard or document the format_override parameter in synthesize_elevenlabs so a bare label like "mp3" cannot silently produce an invalid ElevenLabs API call.
houko
left a comment
There was a problem hiding this comment.
Thanks for the careful root-cause writeup and the layered defense (TTS encoder fix plus gateway-side magic-byte sniffer) — the diff shape is exactly right. Two blockers and one substantive should-fix below; once cleared this is ready to merge.
Blocking
-
New tests violate
-D warnings— clippy fails the workspace lint with 6 freshunnecessary_literal_unwraphits on the test scaffolding you added:crates/librefang-runtime/src/tts.rs:420Some("pcm_16000")+unwrap_or(...)crates/librefang-runtime/src/tts.rs:425,434None: Option<&str>+unwrap_or(...)(x2)crates/librefang-runtime/src/tts.rs:448Some("arg_voice")+unwrap_or(...)crates/librefang-runtime/src/tts.rs:453,459None: Option<&str>+unwrap_or(...)(x2)
Reproduces locally with
cargo clippy -p librefang-runtime --all-targets -- -D warnings. The intent is good — pin the 3-tier resolution — but clippy folds the literal-Some/literal-Noneunwrap_orchain at compile time so the assertion no longer exercises the real call-site logic. Easiest fixes:- Wrap the constructed
Optionthrough a#[inline(never)] fn black_box(x: Option<&str>) -> Option<&str> { x }(orstd::hint::black_box) to defeat the lint and the const-fold simultaneously. - Extract the 3-tier resolution from
synthesize_elevenlabsinto a private free fn (resolve_format(override_: Option<&str>, cfg: &TtsElevenLabsConfig) -> &str) and have both the synth call and the tests use it. This is the better answer — the current tests "mirror the resolution logic directly" which is exactly the regression mode the comment warns against (someone changes the call-site, tests still pass).
-
CONFLICTINGagainst main —packages/whatsapp-gateway/index.test.jscollides with the trailingdescribeblock from main (visible viagit merge-tree). Please rebase onto currentorigin/main; the only real conflict is at the bottom of the test file and will resolve by keeping bothdescribeblocks. Thextask/baselines/config.sha256line will also need regeneration after rebase.
Should fix
- Parallel ElevenLabs driver still defaults to MP3 —
crates/librefang-runtime-media/src/elevenlabs.rs:115readsrequest.format.as_deref().unwrap_or("mp3_44100_128"). This driver is preferred overTtsEnginein the actual hot path:crates/librefang-runtime/src/tool_runner/media.rs:518-573triesMediaDriverCachefirst and only falls back toTtsEnginewhen no media driver is configured. With media drivers configured (the common case for any deploy that's wired upcrates/librefang-runtime-media), the runtime-media driver wins, hands MP3 bytes to the gateway, and the new sniffer correctly downgradesptt=trueto a regular audio attachment — the user-visible voice-note bubble silently doesn't appear. The fix is half-applied. Either (a) plumb the sameopus_48000_32default throughElevenLabsMediaDriver::synthesize_speech, or (b) honortts_engine.tts_config().elevenlabs.output_formatwhen buildingMediaTtsRequestintool_text_to_speecharoundcrates/librefang-runtime/src/tool_runner/media.rs:547. Worth doing here rather than punting to a follow-up — the PR title promises a fix for "WhatsApp PTT format mismatch", and the primary call path still triggers it.
Nits
detectAudioMimeOgg false-positive surface — your comment atpackages/whatsapp-gateway/index.js:3452-3456correctly flags thatOggSmagic proves the container but not the codec. Since the gateway now treats the codec assertion as load-bearing (PTT bubble vs file), it's worth ~5 lines to actually verify: anOpusHeadliteral appears at offset 28 of the first Ogg page for Opus streams, and at the same offset Vorbis streams carry\x01vorbis. Pinning that removes the future-regression risk the comment acknowledges.librefang.toml.example[tts.elevenlabs]block lives under a commented[tts]parent — the example file uses#to comment out the whole TTS section. Worth checking the line[tts.elevenlabs]is also# [tts.elevenlabs](currently# [tts.elevenlabs]per the diff, so this is fine — flagging for your own re-read after rebase regenerates the sha256 baseline).
Verification I ran:
cargo check --workspace --lib— greencargo clippy -p librefang-runtime --all-targets -- -D warnings— 6 errors (above)cargo test -p librefang-runtime --lib tts— 11/11 passcargo test -p librefang-types --lib— passnode --test packages/whatsapp-gateway/index.test.js—detectAudioMime8/8 pass (two unrelated pre-existing failures inforwardToLibreFang/ dispatch-log suites)
Please rebase onto latest main, fix the clippy errors (preferably via the extracted-resolver route), and address the runtime-media default. Happy to re-review once pushed.
…/Opus Root cause: ElevenLabs TTS defaulted to MP3 in both the TtsEngine fallback path and the primary MediaDriverCache path, but the gateway hard-coded `audio/ogg; codecs=opus` as the MIME type. Beeper/mautrix rejected the mislabelled buffer. Fix (source-side, not transcode): 1. Add `output_format` field to `TtsElevenLabsConfig` (default `opus_48000_32`) so ElevenLabs produces native Ogg/Opus. 2. Thread `format_override: Option<&str>` through `synthesize_elevenlabs` with 3-tier resolution (tool arg > config > built-in default), matching the existing OpenAI/Google parity. 3. Change `ElevenLabsMediaDriver` default from `mp3_44100_128` to `opus_48000_32` and plumb `tts_config().elevenlabs.output_format` into `MediaTtsRequest.format` when the tool call omits `format` — so the primary MediaDriverCache hot path also produces Opus. 4. Extract `resolve_elevenlabs_format` and `elevenlabs_format_label` as free functions; tests exercise the real resolution code path (not mirrored logic), fixing clippy `unnecessary_literal_unwrap`. 5. Gateway: add `detectAudioMime` magic-byte sniffer in `sendAudio` — PTT auto-downgrades to regular audio attachment when the buffer is not Ogg/Opus, as a defense-in-depth layer. 6. Regenerate `xtask/baselines/config.sha256` for the new `librefang.toml.example` `[tts.elevenlabs]` block.
|
Rebased onto current Blocker 1 -- clippy Extracted the 3-tier resolution from Blocker 2 -- CONFLICTING against main Branch reset to Should fix -- ElevenLabs MediaDriver still defaults to MP3 Fixed in two places:
Nit -- OggS container-only detection comment Already present from the prior revision at Schema baseline
Verification performed:
|
houko
left a comment
There was a problem hiding this comment.
Re-reviewed — the only commits since the prior review are merge-from-main syncs. The format_override type-contract bug at the heart of the prior review is still unfixed:
crates/librefang-runtime/src/tts.rs:17-22(resolve_elevenlabs_format) and the call into ElevenLabs attts.rs:215-218—format_override.unwrap_or(cfg.output_format.as_str())is forwarded verbatim as?output_format={…}. A caller passing the bare OpenAI/Google-shapedformat: "mp3"produces an invalid ElevenLabs query (which expects the compoundmp3_44100_128etc.). Either reject non-compoundformatvalues at the boundary (InvalidParameter), or drop the override channel for the ElevenLabs synthesizer and use only the config field. The runtime-media driver default + clippy + 3-tier resolver extraction are all good; only the type-contract mismatch remains.
Note: the PR is otherwise complete and ready to land once the override-vs-compound contract is resolved.
|
Reviewed — correct root-cause fix and the one to keep over #5284 (it's the strict superset). Flipping the ElevenLabs CI is green on the branch; it's just CONFLICTING/DIRTY — please rebase onto current |
# Conflicts: # crates/librefang-runtime/src/tts.rs
|
Two issues. 1. An operator who copies 2. On the PTT auto-downgrade path, the DB records
Reviewed at 8b84455. |
# Conflicts: # xtask/baselines/openapi.sha256
|
Closing as stale. This PR has gone roughly a month without author movement, is currently in a BLOCKED merge state, and isn't tied to any open tracked issue, so it's being closed to keep the review queue manageable. This is not a rejection of the underlying idea. Thanks for the contribution. |
|
The underlying bug is now tracked in #6116 (this PR is referenced there as the prior fix attempt). Re-verify against current |
…niff audio mime (#6118) ElevenLabs TTS returns MP3 by default, but the WhatsApp gateway hard-coded the outgoing mime as `audio/ogg; codecs=opus`, so the payload (MP3) did not match the declared container. Mobile WhatsApp tolerated it, but the mautrix-whatsapp/Beeper bridge rejected the message as "Unsupported content type in Web mode" — a silent drop with no voice bubble. Request Opus from ElevenLabs and stop mislabelling the buffer: - New `[tts.elevenlabs].output_format` config field (default `opus_48000_32`); the synth path now sends `?output_format=` with a 3-tier resolution (tool-arg override > config > built-in default) and labels `TtsResult.format` from the real format prefix. - The ElevenLabs media driver defaults to `opus_48000_32` too; the TTS tool injects the configured `output_format` on the media path when the call omits it. - The WhatsApp gateway gains a `detectAudioMime` magic-byte sniffer; `sendAudio` sends the detected mime and auto-downgrades PTT to a plain audio attachment (with a warn) for any non-Opus buffer instead of corrupting the message. Tests: tts 3-tier resolution + opus-label unit tests; 9 `detectAudioMime` gateway cases. Config baseline regenerated for the new example block. Recovers the fix from the stale-closed PR #5283 by @f-liva. Co-authored-by: Evan <[email protected]>
Summary
output_format(defaultopus_48000_32) so the API returns OGG/Opus directly instead of MP3.sendAudiosniffs the actual buffer mime via magic bytes (OggS / ID3 / MPEG-sync / RIFF / fLaC / ftyp). Non-Opus buffers auto-downgradeptttofalsewith a warn, instead of silently corrupting the message.Root cause
ElevenLabs TTS returns MP3 by default. wa-gateway
sendAudiohard-coded the mime asaudio/ogg; codecs=opus. Mobile WhatsApp tolerated the mismatch (its decoder sniffs the magic bytes and falls back to MP3), but the Beeper / mautrix-whatsapp bridge rejected the message as(Unsupported content type in Web mode)— no voice bubble, no audio file, silent drop.Test plan
cargo test -p librefang-runtime tts— 16/16 pass (new assert onoutput_formatdefault)node --test packages/whatsapp-gateway/index.test.js— 8/8detectAudioMimedescribe block passisVoiceNote: true,mimeType: audio/ogg; codecs=opus), no "Unsupported content type"Files
crates/librefang-runtime/src/tts.rs?output_format={cfg},formatderived from prefixcrates/librefang-types/src/config/types.rsTtsElevenLabsConfig.output_formatfield (defaultopus_48000_32)librefang.toml.example[tts.elevenlabs]section documentingoutput_formatpackages/whatsapp-gateway/index.jsdetectAudioMimehelper + magic-byte-based mime resolution insendAudiopackages/whatsapp-gateway/index.test.jsdescribe('detectAudioMime')with 8 cases