Skip to content

fix(audio): WhatsApp PTT format mismatch — ElevenLabs MP3 sent as Ogg/Opus#5283

Closed
f-liva wants to merge 5 commits into
librefang:mainfrom
f-liva:pr/audio-mime-fix
Closed

fix(audio): WhatsApp PTT format mismatch — ElevenLabs MP3 sent as Ogg/Opus#5283
f-liva wants to merge 5 commits into
librefang:mainfrom
f-liva:pr/audio-mime-fix

Conversation

@f-liva

@f-liva f-liva commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • ElevenLabs synth now requests config-driven output_format (default opus_48000_32) so the API returns OGG/Opus directly instead of MP3.
  • wa-gateway sendAudio sniffs the actual buffer mime via magic bytes (OggS / ID3 / MPEG-sync / RIFF / fLaC / ftyp). Non-Opus buffers auto-downgrade ptt to false with a warn, instead of silently corrupting the message.

Root cause

ElevenLabs TTS returns MP3 by default. wa-gateway sendAudio hard-coded the mime as audio/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 on output_format default)
  • node --test packages/whatsapp-gateway/index.test.js — 8/8 detectAudioMime describe block pass
  • E2E test on production: voice note arrives on Beeper with waveform/player (isVoiceNote: true, mimeType: audio/ogg; codecs=opus), no "Unsupported content type"
  • Regression check: existing MP3-only callers still work via auto-downgrade path (verified by code review, no live MP3 callers in current codebase)

Files

File Change
crates/librefang-runtime/src/tts.rs URL ?output_format={cfg}, format derived from prefix
crates/librefang-types/src/config/types.rs New TtsElevenLabsConfig.output_format field (default opus_48000_32)
librefang.toml.example [tts.elevenlabs] section documenting output_format
packages/whatsapp-gateway/index.js detectAudioMime helper + magic-byte-based mime resolution in sendAudio
packages/whatsapp-gateway/index.test.js describe('detectAudioMime') with 8 cases

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same root cause as #5284:

  • synthesize_elevenlabs() (crates/librefang-runtime/src/tts.rs ~178) doesn't accept a format_override parameter and forces self.config.elevenlabs.output_format unconditionally. OpenAI and Google TTS in the same module DO accept the override. As written this:
    • breaks API-contract parity — format_override works for openai/google but is silently ignored for elevenlabs;
    • nullifies #5284's tool-call-arg path for the ElevenLabs provider specifically.
      Fix: add format_override: Option<&str> to the elevenlabs signature, use it in the request-build (after the config-default resolution).

Two nits:

  • detectAudioMime (packages/whatsapp-gateway/index.js ~3368) matches the OggS container magic and then asserts audio/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 detectAudioMime in isolation, but no integration test asserting synthesize_elevenlabs with default config produces a buffer that detectAudioMime reads as Opus. Worth a #[tokio::test] that mocks the ElevenLabs response with Opus bytes and asserts TtsResult.format == "opus".

The fix strategy (request Opus at source rather than transcode) is the right call.

@github-actions github-actions Bot added the needs-changes Changes requested by reviewer label May 20, 2026
@f-liva

f-liva commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review in 607f739c.

Blocker — synthesize_elevenlabs format_override (crates/librefang-runtime/src/tts.rs:178)
Added format_override: Option<&str> to the signature and the resolution chain
(format_override.unwrap_or(self.config.elevenlabs.output_format.as_str())) so
the same 3-tier precedence as synthesize_openai / synthesize_google applies.
Updated the single call site in synthesize (~line 85) to pass format_override
through. Note: PR #5284 landed a sibling commit (124b7e40) with the same
signature change on its own branch; it does not appear on this PR's branch and
the cherry-pick conflicted on unrelated tool_runner/media.rs history that
isn't on pr/audio-mime-fix, so I re-applied the targeted hunk locally with
the same shape — when both PRs merge the diff will be identical at the
synthesize_elevenlabs site.

Nit 1 — container-only OggS detection (packages/whatsapp-gateway/index.js:3402)
Added an inline comment above the OggS → audio/ogg; codecs=opus branch:
container magic is not codec proof, Ogg/Vorbis would false-positive but is not
a realistic input under the TTS layer's opus_48000_32 default and would fail
downstream anyway. The assertion is correct under the gateway's actual
operating envelope.

Nit 2 — integration test (crates/librefang-runtime/src/tts.rs:402-501)
Added 3 unit tests in the tts::tests module:

  • test_three_tier_resolution_elevenlabs_format — pins
    override > config > built-in precedence so a future refactor of the
    resolution call site can't silently restore the "config ignored" bug.
  • test_three_tier_resolution_elevenlabs_voice — same shape for voice_id.
  • test_default_elevenlabs_format_label_is_opus — the requested contract
    test: with the default ElevenLabs config, the prefix-derivation block
    must yield TtsResult.format == "opus" so the wa-gateway sniffer accepts
    the buffer. Also exercises mp3_* / pcm_* / ulaw_* prefixes to lock
    the derivation algorithm.

I did not add a full wiremock-based HTTP round-trip because
synthesize_elevenlabs hardcodes https://api.elevenlabs.io/... (no base-URL
injection); pointing it at a mock server would require a real refactor of the
client construction. The 3 unit tests above cover the same regression
surfaces — the precedence chain and the format-label derivation contract —
that an HTTP-roundtrip test would. Happy to layer a base-URL-injection
refactor + wiremock test in a follow-up if you'd prefer it now rather than
deferred.

Verification

  • cargo check -p librefang-runtime --lib — clean.
  • cargo test -p librefang-runtime --lib tts — 11/11 pass (8 pre-existing + 3 new).
  • cargo clippy -p librefang-runtime --lib -- -D warnings — clean.
  • cargo fmt -p librefang-runtime — no diff.
  • node --test --test-name-pattern detectAudioMime packages/whatsapp-gateway/index.test.js — 8/8 pass.
  • Full node --test packages/whatsapp-gateway/index.test.js — 140/142 pass; the 2 failures (EB-02 forward_dispatch log + dispatch_self_test, §A owner_notify channel) reproduce on pr/audio-mime-fix before my changes too, so they're pre-existing and unrelated to this fix.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Schema baseline drift (OpenAPI Drift job, fail). TtsElevenLabsConfig derives schemars::JsonSchema (crates/librefang-types/src/config/types.rs:1283), so adding the new output_format field (line 1298) changes the config JSON schema and therefore xtask/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 gen
    

    then commit the updated xtask/baselines/config.sha256 (and any openapi.json / sdk/ deltas it produces).

  2. Formatting (Quality job, fail on cargo 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 on 4bc691f while main is now at 9db55b6, and main has since reformatted those files. A rebase on current origin/main should clear it.

  3. 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 houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.sha256 still pins 4f12fa2a07651bb76ce35378a3cb4126b964e4b953c74c52871d20e7ba49c37e 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 (configlibrefang.toml.examplextask/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) and comms_send_rejects_oversize_message (network_routes_integration.rs:430). Both files are byte-identical to origin/main on this branch (git diff origin/main HEAD is empty for them), and they're unrelated to the audio diff. The branch merged main at 5875b13d, which is one commit behind origin/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-197 now appends ?output_format={output_format} (default opus_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-257 derives the format label from the requested prefix (opus_48000_32"opus"), so the label tracks the real container.
  • index.js detectAudioMime sniffs magic bytes and only keeps ptt: true when 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:711 skips convert_to_ogg_opus when format is already opus, so requesting Opus at the source avoids a redundant transcode. Coherent.

Minor nits (in-scope, please address)

  1. File extension .opus vs .oggtool_runner/media.rs:750 builds the filename as tts_{ts}.{format}, so an ElevenLabs opus_48000_32 result is saved as *.opus. ElevenLabs returns an OGG container (the buffer starts with OggS, which is exactly what detectAudioMime keys on), so the conventional extension is .ogg. .opus is 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 the opus prefix to an ogg extension, or document the choice.
  2. Ogg/Vorbis false-positive is acknowledged in the detectAudioMime comment (index.js). Asserting codecs=opus on any OggS buffer 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.

f-liva pushed a commit to f-liva/librefang that referenced this pull request May 22, 2026
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).
@f-liva
f-liva force-pushed the pr/audio-mime-fix branch from a3d202a to e04fd8e Compare May 22, 2026 18:22
@f-liva

f-liva commented May 22, 2026

Copy link
Copy Markdown
Contributor Author

Regenerated xtask/baselines/config.sha256 via cargo xtask schema-check gen and rebased onto current origin/main (stale-base fmt drift + the two known main-flakes should clear with the rebased CI).

The new hash matches the librefang.toml.example content (with the [tts.elevenlabs] block this PR added). No openapi.json / SDK changes — only the one baseline.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot added the has-conflicts PR has merge conflicts that need resolution label May 23, 2026
@houko

houko commented May 23, 2026

Copy link
Copy Markdown
Contributor

Status update after the merge-of-main in 3e7faa7 (post-dates my last review). My standing CHANGES_REQUESTED (the #5283/#5284 duplication blocker) still stands — please pick one path before this can merge. Two new mechanical facts since the review:

1. The PR is now DIRTY (merge conflict), so it can't merge regardless. 3e7faa7 is a merge of main at 34c44b6, which predates ed81e69b (#5230). Since then origin/main gained #5230's compileGroupTriggerRegex test block, which lands at the same insertion point in packages/whatsapp-gateway/index.test.js as your new detectAudioMime block. Conflict is at index.test.js ~L1547–1684 — purely two independent describe() suites colliding; resolution is to keep both. A git rebase origin/main (rather than another merge) clears it.

2. The remaining red check is inherited from main, not yours. Quality fails on clippy in crates/librefang-api/src/routes/registry.rs:287, validation.rs:468–471, webchat.rs:277 — all three files are byte-identical between this branch and origin/main (git diff origin/main HEAD is empty for them), so a rebase carries the fix once main is green; nothing for you to change here. Everything else is now green (OpenAPI Drift, all Ubuntu shards, Unit, Security), confirming the regenerated config.sha256 is correct.

The audio change itself remains correct on review: output_format plumbing is complete (config/types.rs:1294–1314 struct field + Default + doc), synthesize_elevenlabs threads format_override and derives the label from the prefix (tts.rs:179–264), and the gateway sniffer downgrades non-Opus PTT. The only thing gating merge is the duplication decision + the rebase.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 indentation
  • crates/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=opus breaks the Beeper/mautrix-whatsapp bridge.
  • detectAudioMime magic-byte table in index.js is 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 a console.warn is a safe fallback rather than a silent corrupt.
  • TtsElevenLabsConfig.output_format field is properly added to the struct, Default impl, and has #[serde(default)] on the parent struct — consistent with the CLAUDE.md convention for config fields.
  • The split('_').next() prefix derivation correctly maps opus_48000_32 → "opus", mp3_44100_128 → "mp3", etc. for TtsResult.format.
  • Tests pin the 3-tier resolution chain and the format-label derivation; the JS test suite covers all 8 detectAudioMime cases.

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 houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 fresh unnecessary_literal_unwrap hits on the test scaffolding you added:

    • crates/librefang-runtime/src/tts.rs:420 Some("pcm_16000") + unwrap_or(...)
    • crates/librefang-runtime/src/tts.rs:425,434 None: Option<&str> + unwrap_or(...) (x2)
    • crates/librefang-runtime/src/tts.rs:448 Some("arg_voice") + unwrap_or(...)
    • crates/librefang-runtime/src/tts.rs:453,459 None: 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-None unwrap_or chain at compile time so the assertion no longer exercises the real call-site logic. Easiest fixes:

    1. Wrap the constructed Option through a #[inline(never)] fn black_box(x: Option<&str>) -> Option<&str> { x } (or std::hint::black_box) to defeat the lint and the const-fold simultaneously.
    2. Extract the 3-tier resolution from synthesize_elevenlabs into 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).
  • CONFLICTING against mainpackages/whatsapp-gateway/index.test.js collides with the trailing describe block from main (visible via git merge-tree). Please rebase onto current origin/main; the only real conflict is at the bottom of the test file and will resolve by keeping both describe blocks. The xtask/baselines/config.sha256 line will also need regeneration after rebase.

Should fix

  • Parallel ElevenLabs driver still defaults to MP3crates/librefang-runtime-media/src/elevenlabs.rs:115 reads request.format.as_deref().unwrap_or("mp3_44100_128"). This driver is preferred over TtsEngine in the actual hot path: crates/librefang-runtime/src/tool_runner/media.rs:518-573 tries MediaDriverCache first and only falls back to TtsEngine when no media driver is configured. With media drivers configured (the common case for any deploy that's wired up crates/librefang-runtime-media), the runtime-media driver wins, hands MP3 bytes to the gateway, and the new sniffer correctly downgrades ptt=true to a regular audio attachment — the user-visible voice-note bubble silently doesn't appear. The fix is half-applied. Either (a) plumb the same opus_48000_32 default through ElevenLabsMediaDriver::synthesize_speech, or (b) honor tts_engine.tts_config().elevenlabs.output_format when building MediaTtsRequest in tool_text_to_speech around crates/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

  • detectAudioMime Ogg false-positive surface — your comment at packages/whatsapp-gateway/index.js:3452-3456 correctly flags that OggS magic 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: an OpusHead literal 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 — green
  • cargo clippy -p librefang-runtime --all-targets -- -D warnings — 6 errors (above)
  • cargo test -p librefang-runtime --lib tts — 11/11 pass
  • cargo test -p librefang-types --lib — pass
  • node --test packages/whatsapp-gateway/index.test.jsdetectAudioMime 8/8 pass (two unrelated pre-existing failures in forwardToLibreFang / 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.
@f-liva
f-liva force-pushed the pr/audio-mime-fix branch from 3e7faa7 to c6180d7 Compare May 25, 2026 09:01
@github-actions github-actions Bot added size/L 250-999 lines changed and removed size/M 50-249 lines changed has-conflicts PR has merge conflicts that need resolution labels May 25, 2026
@f-liva

f-liva commented May 25, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current upstream/main and addressed all review items in c6180d72.

Blocker 1 -- clippy unnecessary_literal_unwrap (6 errors)

Extracted the 3-tier resolution from synthesize_elevenlabs into a free function resolve_elevenlabs_format(override, cfg) -> &str and a companion elevenlabs_format_label(output_format) -> String. Both the production call site and the tests call the same functions -- no more mirrored logic that can silently drift. The voice-id resolution test uses std::hint::black_box to defeat the remaining literal-option lint. cargo clippy -p librefang-runtime --all-targets -- -D warnings is now clean.

Blocker 2 -- CONFLICTING against main

Branch reset to upstream/main and the single logical commit re-applied cleanly. The compileGroupTriggerRegex test block from main is preserved; the new detectAudioMime suite is appended after it.

Should fix -- ElevenLabs MediaDriver still defaults to MP3

Fixed in two places:

  1. crates/librefang-runtime-media/src/elevenlabs.rs:115 -- changed default from "mp3_44100_128" to "opus_48000_32".
  2. crates/librefang-runtime/src/tool_runner/media.rs -- when resolved_provider == Some("elevenlabs") and the tool call omits format, the config's tts_config().elevenlabs.output_format is injected into MediaTtsRequest.format. This ensures the primary MediaDriverCache hot path (which tool_text_to_speech prefers over TtsEngine) also produces Opus.

Nit -- OggS container-only detection comment

Already present from the prior revision at index.js:~3402 (the inline comment explaining that container magic is not codec proof, but Vorbis is not a realistic input under the TTS layer's opus_48000_32 default).

Schema baseline

xtask/baselines/config.sha256 regenerated via cargo xtask schema-check gen.

Verification performed:

  • cargo check --workspace --lib -- green
  • cargo clippy -p librefang-runtime --all-targets -- -D warnings -- clean
  • cargo clippy -p librefang-types --all-targets -- -D warnings -- clean
  • cargo clippy -p librefang-runtime-media --all-targets -- -D warnings -- clean
  • cargo test -p librefang-runtime --lib tts -- 11/11 pass
  • cargo test -p librefang-types --lib -- 846/846 pass
  • node --test packages/whatsapp-gateway/index.test.js -- detectAudioMime 9/9 pass, compileGroupTriggerRegex 6/6 pass (two unrelated pre-existing failures in forwardToLibreFang / dispatch-log suites)

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. crates/librefang-runtime/src/tts.rs:17-22 (resolve_elevenlabs_format) and the call into ElevenLabs at tts.rs:215-218format_override.unwrap_or(cfg.output_format.as_str()) is forwarded verbatim as ?output_format={…}. A caller passing the bare OpenAI/Google-shaped format: "mp3" produces an invalid ElevenLabs query (which expects the compound mp3_44100_128 etc.). Either reject non-compound format values 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.

@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed has-conflicts PR has merge conflicts that need resolution labels May 28, 2026
@houko

houko commented May 30, 2026

Copy link
Copy Markdown
Contributor

Reviewed — correct root-cause fix and the one to keep over #5284 (it's the strict superset). Flipping the ElevenLabs MediaDriver default mp3_44100_128opus_48000_32 (crates/librefang-runtime-media/src/elevenlabs.rs:115), deriving TtsResult.format from the actual output_format prefix instead of hard-coded "mp3", and the wa-gateway detectAudioMime magic-byte sniff (OggS/RIFF/fLaC/ID3/MPEG-sync/ftyp) that downgrades non-Opus buffers off PTT are all sound, with good coverage on both the Rust and JS sides. The "OggS ⇒ codecs=opus" assumption is documented and acceptable under the gateway envelope.

CI is green on the branch; it's just CONFLICTING/DIRTY — please rebase onto current main and it's ready.
(See also the note on #5284 — recommending that one be closed as superseded by this.)

# Conflicts:
#	crates/librefang-runtime/src/tts.rs
@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed has-conflicts PR has merge conflicts that need resolution labels May 31, 2026
@houko

houko commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Two issues.

1. opus_24000_32 is documented as a valid ElevenLabs output_format, but it isn't one. ElevenLabs Opus output is 48 kHz-only — the valid opus values are opus_48000_{32,64,96,128,192}; 24 kHz Opus doesn't exist and the API rejects it with HTTP 400.

crates/librefang-types/src/config/types.rs:1379   /// Other options: `mp3_44100_128`, `mp3_22050_32`, `opus_24000_32`,
librefang.toml.example:668                        #   opus_24000_32, pcm_16000, pcm_44100, ulaw_8000.

An operator who copies output_format = "opus_24000_32" from the example gets it passed straight into ?output_format=opus_24000_32 and TTS fails. The other listed values are all valid — only this one is bogus.
Fix: replace with a real value (e.g. opus_48000_64) in both spots, or drop the token. Optional: validate output_format against the known enum at config-load so a bad string fails fast.

2. On the PTT auto-downgrade path, the DB records [Voice message] for a message actually sent as a plain audio file. The wire payload correctly uses the corrected effectivePtt, but dbSaveMessage keys off the original ptt:

packages/whatsapp-gateway/index.js:3591   const effectivePtt = ptt && isOggOpus;
packages/whatsapp-gateway/index.js:3598   const audioMsg = { audio: buffer, mimetype: detectedMime, ptt: effectivePtt };
packages/whatsapp-gateway/index.js:3607   text: ptt ? '[Voice message]' : '[Audio]',

sendAudio(to, url, ptt=true) with an MP3 buffer → isOggOpus=falseeffectivePtt=false, so the message is sent as a regular audio attachment (correct), but line 3607 still sees ptt=true and persists [Voice message]. Since the table has no mime column, this text label is the only audio-type signal in the DB, and it's wrong on the downgrade path.
Fix: text: effectivePtt ? '[Voice message]' : '[Audio]',.

Reviewed at 8b84455.

@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed has-conflicts PR has merge conflicts that need resolution labels Jun 9, 2026
# Conflicts:
#	xtask/baselines/openapi.sha256
@github-actions github-actions Bot removed the has-conflicts PR has merge conflicts that need resolution label Jun 15, 2026
@houko

houko commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

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.
If it's still relevant, please rebase onto current main, address any outstanding review feedback, and reopen this PR (or open a fresh one) — it'll be picked up again.

Thanks for the contribution.

@houko

houko commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

The underlying bug is now tracked in #6116 (this PR is referenced there as the prior fix attempt). Re-verify against current main before re-implementing.

houko added a commit that referenced this pull request Jun 15, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Agent loop, LLM drivers, WASM sandbox area/sdk JavaScript and Python SDKs needs-changes Changes requested by reviewer size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants