Fix Gemini TTS, and make the playground play the answer and remember its config - #184
Merged
Conversation
gemini-3.1-flash-tts-preview rejects a conversation with "Multiturn chat is not enabled for this model" and an audio part with "Audio input modality is not enabled for this model", so a stateful session broke on its second turn as soon as the first spoken answer was recorded. The client now synthesizes the newest message and leaves the rest of the history out of the request. The same model also rejects a system instruction, a thinking level, a thinking summary, and tool declarations, each with its own 400, so the TTS request is built from the speech settings and max_tokens alone and returns early. The temperature, fast_mode, and prompt_caching checks move above that branch so every model still gets them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBwEdjegVGtfY2yJKwhZ77
A TTS answer arrives as dozens of 1920-byte PCM chunks. concat_uni_events_to_uni_message merges consecutive inline_data items that share an audio/ mime type, so the history and the tracer hold one item per utterance instead of one per chunk. The playground collects the chunks of a response behind a line that counts up the received duration and renders a single player once the stream ends; an interrupted stream keeps the audio that had arrived. Chunks are joined as bytes before the WAV header is written, and the sample rate and channel count come from the mime type parameters rather than a hardcoded 24 kHz. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBwEdjegVGtfY2yJKwhZ77
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBwEdjegVGtfY2yJKwhZ77
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBwEdjegVGtfY2yJKwhZ77
The playground writes the configuration panel to localStorage on every edit and restores it on load, so a reload no longer drops the model, the API key, the base URL, the extra headers, the thinking and tool settings, the system prompt, the tools, or the trace id. Text fields are stored as typed so an unfinished JSON edit survives, and a model the dropdown does not list comes back as a custom entry with its client type. A finished audio clip now plays once by itself; a stream stopped from the Stop button stays quiet, and a browser that blocks autoplay leaves the player ready to press. The token footer is appended to the message card rather than re-parsed into it, which would have torn out the playing element. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBwEdjegVGtfY2yJKwhZ77
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.
The playground could not run
gemini-3.1-flash-tts-preview: the second message of asession always failed, panel state left over from another model failed the first one, and
a single answer rendered as dozens of 40 ms audio players. Two playground papercuts asked
for in review are fixed along with it: a reload no longer wipes the config panel, and a
finished clip plays itself.
What the live API actually allows
Probed against
gemini-3.1-flash-tts-previewon 2026-08-20; each of these came back400 INVALID_ARGUMENT:Multiturn chat is not enabled for this modelAudio input modality is not enabled for this modelsystem_instructionDeveloper instruction is not enabled for this modelthinking_config.thinking_levelThinking level is not supported for this model.thinking_config.include_thoughtsThinking is not enabled for this modeltoolsFunction calling is not enabled for this modelmax_output_tokens,response_modalities, andspeech_configwere accepted. The docs sayonly "TTS models can only receive text inputs"; the multi-turn and developer-instruction
limits are not in them, which is why the playground hit them and the client did not expect
them.
What changed
Gemini3_7Clientsynthesizes the newest message and drops the rest of the conversationfor a TTS model. The stateful helpers record the answer's audio, and replaying that audio
is what broke the next turn.
max_tokensalone, returning early;temperature,fast_mode, andprompt_cachingvalidation moves above that branch soevery model still gets it.
concat_uni_events_to_uni_message/concatUniEventsToUniMessagemerge consecutiveinline_dataitems sharing anaudio/mime type, so one utterance is one history itemand one tracer entry.
🔊 Receiving audio... 2.3sline and renders one player when the stream ends; an interrupted stream keeps what had
arrived. Chunks are joined as bytes before the WAV header is written, and the sample rate
and channel count are read from the mime type parameters instead of a hardcoded 24 kHz.
Also in this change:
and a browser that blocks autoplay leaves the player ready to press. The token footer is
appended to the message card instead of re-parsed into it, which would have torn out the
playing element.
localStorageunderagenthub.playground.configonevery edit and restored on load, so a reload keeps the model, client type, API key, base
URL, extra headers, thinking and tool settings, system prompt, tools, and trace id. Text
fields are stored as typed, so an unfinished JSON edit survives; a model the dropdown does
not list — one from List models, or a custom id — comes back as a custom entry with its
client type. The API key is part of what is stored, so it stays in that browser profile
until the field is cleared.
Alternatives weighed
after the first answer, and there is nothing the caller could do except clear the session
before every message. The vendor treats a TTS call as one synthesis request, so the client
sends one.
streaming_response_statefulbroken for TTS in both SDKs.about the turn count, not the modality.
URL cannot be appended to, so each chunk would re-encode the whole clip (~190 rebuilds for
a 7-second answer). Left the placeholder counting up instead.
autoplayon the element instead of callingplay(). The attribute fires again everytime the card is re-parsed, which is the opposite of playing once.
Verification
the two clips back with
gemini-3.7-flashreturns each turn's own text, so the trimmedhistory does not leak into the audio.
WAV, no console errors, and Stop mid-synthesis leaves one playable partial clip plus the
Interrupted marker.
combobox labels, an unlisted model restored as a custom entry with its client type, and no
focus stolen into the custom id field on load.
with the token footer rendered beside it.
<audio>element in the tracer instead of one per chunk.pytest -k tts(4 passed),pytest -k gemini-3.7-flash(11 passed),jest -t gemini-3.1-flash-tts-preview(14 passed),jest -t gemini-3.7-flash(20 passed).ruff check/ruff format --check,npm run lint,npm run build, and the offlinesuites in both languages are green.
Not covered
The multi-turn TTS path has no E2E assertion. Covering it means adding a second turn to the
shared
test_tts_generation_single_speaker/ its TS counterpart, which the dev skill asksfor explicit approval before touching.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UBwEdjegVGtfY2yJKwhZ77