fix(tts): decode the raw PCM Gemini returns - #48
Open
Joilence wants to merge 7 commits into
Open
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.
Joilence
force-pushed
the
pr/gemini-pcm
branch
from
August 25, 2026 22:06
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: