Skip to content

fix(tts): decode the raw PCM Gemini returns - #48

Open
Joilence wants to merge 7 commits into
shreyaskarnik:mainfrom
Joilence:pr/gemini-pcm
Open

fix(tts): decode the raw PCM Gemini returns#48
Joilence wants to merge 7 commits into
shreyaskarnik:mainfrom
Joilence:pr/gemini-pcm

Conversation

@Joilence

@Joilence Joilence commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Why

Two things stopped the Gemini voice working.

The default model, gemini-2.5-flash, is not a speech model: asked for audio it returns 400: This model only supports text output. That is the documented call.

Past that, the audio is raw PCM with no header. gemini.ts hands it to convertToWav, which runs ffmpeg -i pipe:0 with no format flag, so ffmpeg has nothing to recognise and exits with Invalid data found when processing input.

What

Default to gemini-3.1-flash-tts-preview. The native-audio models speak only the Live API socket, and every Gemini TTS model is preview.

convertToWav takes an optional input format and declares it with -f; other engines are unchanged.

parseRawAudioMime reads 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 type audio/l16; rate=24000; channels=1, 2.5 audio/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, curl and jq; step 3 costs nothing because it errors before generating anything.

#!/usr/bin/env bash
set -uo pipefail

TTS_MODEL=gemini-3.1-flash-tts-preview

# One second of headerless mono s16le at 24kHz: the shape Gemini's TTS models
# return, minus the RIFF header they do not send.
pcm() { ffmpeg -f lavfi -i sine=frequency=440:duration=1:sample_rate=24000 -f s16le - 2>/dev/null; }

# main's conversion: no format flag, so there is nothing for ffmpeg to sniff.
on_main() { ffmpeg -i pipe:0 -f wav -acodec pcm_f32le -ac 1 -ar 24000 pipe:1 2>&1 >/dev/null; }
fixed()   { ffmpeg -f s16le -ar 24000 -ac 1 -i pipe:0 -f wav -acodec pcm_f32le -ac 1 -ar 24000 pipe:1 2>/dev/null; }

say() { echo "   $*"; }

echo "1. main's conversion, on synthesized L16"
pcm | on_main | grep -i -m1 'invalid data' | sed 's/^/   /'

echo "2. same bytes, declaring the format"
say "$(pcm | fixed | wc -c | tr -d ' ') bytes of WAV"

if [ -z "${GEMINI_API_KEY:-}" ]; then
  echo "3-4. skipped, GEMINI_API_KEY not set"
  exit 0
fi

echo "3. main's default model, asked for audio"
curl -s -X POST "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" \
  -H "x-goog-api-key: $GEMINI_API_KEY" -H 'Content-Type: application/json' \
  -d '{"contents":[{"parts":[{"text":"hello"}]}],"generationConfig":{"responseModalities":["AUDIO"]}}' \
  | jq -r '.error | "   \(.code) \(.message)"'

echo "4. real audio from $TTS_MODEL, through main's conversion"
resp=$(curl -s -X POST "https://generativelanguage.googleapis.com/v1beta/models/$TTS_MODEL:generateContent" \
  -H "x-goog-api-key: $GEMINI_API_KEY" -H 'Content-Type: application/json' \
  -d '{"contents":[{"parts":[{"text":"Say: testing one two three."}]}],
       "generationConfig":{"responseModalities":["AUDIO"],
       "speechConfig":{"voiceConfig":{"prebuiltVoiceConfig":{"voiceName":"Kore"}}}}}')
part=$(jq -r '.candidates[0].content.parts[0].inlineData' <<<"$resp")
say "media type: $(jq -r .mimeType <<<"$part")"
jq -r .data <<<"$part" | base64 -d > /tmp/gemini31.pcm
say "$(wc -c < /tmp/gemini31.pcm | tr -d ' ') raw bytes returned"
on_main < /tmp/gemini31.pcm | grep -i -m1 'invalid data' | sed 's/^/   /'
say "with the flag: $(fixed < /tmp/gemini31.pcm | wc -c | tr -d ' ') bytes of WAV"
rm -f /tmp/gemini31.pcm

Output here:

1. main's conversion, on synthesized L16
   Error opening input: Invalid data found when processing input
2. same bytes, declaring the format
   96080 bytes of WAV
3. main's default model, asked for audio
   400 This model only supports text output.
4. real audio from gemini-3.1-flash-tts-preview, through main's conversion
   media type: audio/l16; rate=24000; channels=1
   186240 raw bytes returned
   Error opening input: Invalid data found when processing input
   with the flag: 372560 bytes of WAV

Step 1 is the decode bug with argo taken out of the picture: that is the argv convertToWav builds on main today, 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:

git checkout main
git checkout <this-branch> -- tests/tts/
npx vitest run tests/tts        # 16 failed | 84 passed (100)
git checkout HEAD -- tests/tts/ # restore

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant