fix(tts): decode the raw PCM Gemini returns - #48
Conversation
Gemini's TTS models answer with `audio/L16;codec=pcm;rate=24000`: sample data and nothing else, no RIFF header and no magic bytes. convertToWav handed that straight to `ffmpeg -i pipe:0`, which has nothing to sniff and exits with "Invalid data found when processing input". The Gemini engine has never produced audio. convertToWav now takes an optional input format and declares it with `-f` ahead of the input. parseRawAudioMime reads the format and rate off the media type rather than assuming 24kHz, and refuses a media type it cannot read a rate from, because guessing does not fail: it pitches and stretches the voice at exit 0. Little-endian contradicts RFC 2586 section 3, which defines L16 as network byte order, and Google sends little-endian anyway. That is the one genuinely uncertain call here and getting it wrong is silent, so tests/tts/raw-pcm-roundtrip.test.ts decodes a synthesized sine through real ffmpeg and separates a correct decode from a byte-swapped one by peak amplitude. It runs under describeWithCapability, so a CI runner missing ffmpeg fails rather than skipping the one test that checks real bytes.
The default was `gemini-2.5-flash`, which is not a speech model. Asked for `responseModalities: ['AUDIO']` it answers 400 INVALID_ARGUMENT, "This model only supports text output", so `engines.gemini()` as README documents it never reached the conversion this branch fixes. Two independent reasons the Gemini voice produced nothing. `gemini-3.1-flash-tts-preview` is the current TTS model and what Google's own speech-generation sample uses. The `native-audio` models are not candidates: they expose only `bidiGenerateContent`, the Live API socket, while this engine calls `generateContent`. Every Gemini TTS model is preview, so this default will need revisiting when one reaches GA. Its responses also spell the media type differently, `audio/l16; rate=24000; channels=1` against 2.5's `audio/L16;codec=pcm;rate=24000`: lowercase, spaced, no codec, explicit channels. parseRawAudioMime already reads both to the same format, and a test now covers the second spelling so it stays that way.
The comments added on this branch ran longer than the code around them. Measured against their own neighbours: no file under src/tts/engines/ has a comment run over 4 lines, and src/tts/engine.ts's longest docblock is 12. The gemini.ts constructor note goes from 6 lines to 3, and the call-site note in generate() goes entirely, since it restated parseRawAudioMime's own docstring a few lines away. parseRawAudioMime's docstring drops to the file's 12-line ceiling, keeping the RFC 2586 contradiction and why it is deliberate. The raw-pcm-roundtrip header keeps only what is not already in engine.ts: that the sibling test stubs execFileSync and so cannot fail on a byte-order error. No behaviour change; 774 tests still pass.
2f00460 to
33ba415
Compare
What a guessed sample rate does, a clip a third shorter at 1.5x pitch with exit code 0, was written out at four sites: the throw in parseRawAudioMime and once in each of the three test files. The decision lives at the throw, so that copy stays whole and the three tests keep a one-line why instead. Also drops a pointer comment in raw-pcm-roundtrip that said only that the next line mattered, and a clause in the ffmpeg guard there that repeated the file header.
The docblock documented the null return but not the throw, so a caller reading it had no reason to expect an exception. gemini.ts calls it without a guard and lets that propagate out of GeminiEngine.generate, which a test already asserts, so throwing is part of the contract either way. The paragraph above it narrated the ffmpeg error instead of telling a caller when to reach for the function, and convertToWav repeated its own docblock in a comment sitting on top of the ternary that implements it.
The entry covered the decode fix but not the defect that hides it: a general model answers an AUDIO request with 400, so the PCM problem is never reached. It also gave one media type as if it were fixed, and the 3.1 models spell it differently. Drops the wrong-rate mechanism, which now lives in parseRawAudioMime's own docstring, leaving the entry shorter than before.
|
Thanks @Joilence — this is a thorough piece of work on a feature that was, as you found, broken in two independent places at once. The part I want to call out is the decision to throw when an L16 media type carries no readable rate, and the reasoning in that comment. Guessing 24000 for a 16000 stream doesn't fail — it returns a shorter clip at 1.5x pitch with exit code 0, and since Argo derives scene durations from clip length, every wait in the recording quietly shortens. That's the worst class of bug in this codebase and the comment names it exactly. Refusing is right. Verified rather than read:
One follow-up, not a change request. Also minor: Merging. Thanks again. |
Upstream merged shreyaskarnik#47 and shreyaskarnik#48, so dev's own drafts of the Gemini PCM work are superseded. All five tts files resolve to upstream: diffing the two sides showed every line unique to dev was something shreyaskarnik#48 had since fixed, namely the `gemini-2.5-flash` default that answers an AUDIO request with 400, the `describe.runIf` guard that skips silently instead of failing, and `expect(header.audioFormat).toBe(3)`, which ffmpeg 6 fails because it tags float32 as EXTENSIBLE. package.json takes upstream's `node scripts/copy-assets.mjs` over the shell one-liner, which upstream introduced alongside the `prepare` hook from shreyaskarnik#47: `prepare` now runs on the installer's machine, where `mkdir -p` and `cp` are not a given.
Why
Two things stopped the Gemini voice working.
The default model,
gemini-2.5-flash, is not a speech model: asked for audio it returns400: This model only supports text output. That is the documented call.Past that, the audio is raw PCM with no header.
gemini.tshands it toconvertToWav, which runsffmpeg -i pipe:0with no format flag, so ffmpeg has nothing to recognise and exits withInvalid data found when processing input.What
Default to
gemini-3.1-flash-tts-preview. Thenative-audiomodels speak only the Live API socket, and every Gemini TTS model is preview.convertToWavtakes an optional input format and declares it with-f; other engines are unchanged.parseRawAudioMimereads format and rate off the media type and refuses one it cannot read, since a wrong rate is silently wrong rather than an error. Little-endian contradicts RFC 2586, but it is what Google sends.Test
21 tests, 16 failing on
main. One decodes real audio through the ffmpeg CI installs.Live calls confirm both models return audio, both payloads fail on
main, and big-endian decodes to clipped noise where little-endian gives speech. 3.1 spells the media typeaudio/l16; rate=24000; channels=1, 2.5audio/L16;codec=pcm;rate=24000; a test covers both.Reproducing both failures
Steps 1 and 2 need only ffmpeg. Steps 3 and 4 also need
GEMINI_API_KEY,curlandjq; step 3 costs nothing because it errors before generating anything.Output here:
Step 1 is the decode bug with argo taken out of the picture: that is the argv
convertToWavbuilds onmaintoday, and ffmpeg has nothing to sniff. Step 4 is the same failure on bytes the newest TTS model actually returned, so the decode fix is needed whichever model you point the engine at.The failing tests need no key either, since the transport is mocked: