feat(voice): ElevenLabs quality presets, defaulting to the house v3 narration setting - #76
Merged
Merged
Conversation
…arration setting The read-aloud was cloned from the content machine's old voiceover config — eleven_turbo_v2_5 at the API's default 128 kbps, stability 0.7, style 0.3 — and never followed it when it moved (2026-07-29) to eleven_v3 at mp3_44100_192, stability 0.5, style 0: chosen there by ear from an A/B, and for measured reasons (turbo inserted 2–3.5 s of dead air mid-segment and v3 does not; 192 kbps removes the compression mush that reads as slurring; style 0 keeps the read even). So the viewer sounded worse than the narrations. The model, bitrate and voice settings are now a VoicePreset (`ai/voices.ts`, the same shape as `models.ts`): "Quality — Eleven v3", the house setting, is the default; "Fast — Turbo v2.5", the previous viewer setting, stays offered because v3 is the quality model, not the latency model. The choice persists from a selector in the ElevenLabs card and rides the request as model_id, ?output_format and voice_settings. Because v3 takes longer per request, long answers are now chunked on paragraph / sentence boundaries (the content machine's chunk_text, 2500 chars) and pipelined: the next chunk synthesizes while the current one plays, so speech starts on the first chunk. stop() and a newer speak() abandon an in-flight pipeline. The model selector's hook is generalized into usePersistentChoice so the two persisted picks share one implementation; its storage key is unchanged. Claude-Session: https://claude.ai/code/session_01FxBDnfPLwUHtyv1TkaLuFt
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.
Summary
The read-aloud sounded worse than the content-machine narrations because it was on different ElevenLabs settings:
src/ai/tts.tswas cloned from the content machine's old voiceover config (eleven_turbo_v2_5, the API's default 128 kbps, stability 0.7, style 0.3) and never followed it when the pipeline moved toeleven_v3atmp3_44100_192, stability 0.5, style 0 — a change made there by ear from an A/B and for measured reasons (turbo inserted 2–3.5 s of dead air mid-segment where v3 does not; 192 kbps removes the compression mush that reads as slurring; style 0 keeps the read even). This makes the model, bitrate and voice settings a selectable preset, defaults to the house v3 setting, keeps Turbo as the fast option, and chunks long answers so the slower model starts speaking on the first chunk.Changes
Voice (
src/ai/,src/hooks/)voices.ts— theVOICE_PRESETSregistry, same shape asmodels.ts: Quality — Eleven v3 (eleven_v3,mp3_44100_192, stability 0.5 / similarity 0.8 / style 0 / speaker boost) as the default, and Fast — Turbo v2.5 (the previous viewer setting:eleven_turbo_v2_5,mp3_44100_128, 0.7 / 0.8 / 0.3).isKnownVoicePreset/voicePreset(id)resolve an unknown id to the default. The v3 preset keeps stability on one of the three values that model accepts (0, 0.5, 1).tts.ts—synthesizeSpeech(apiKey, text, voiceId, preset)sends the preset'smodel_idandvoice_settingsand adds the?output_format=query parameter (previously absent, so the API's default bitrate applied). NewchunkForTts(text, limit = 2500): a port of the content machine'schunk_text— paragraph boundaries first, sentence ends for an over-long paragraph. Module comment updated; the hard-codedMODEL_ID/VOICE_SETTINGSconstants are gone.useTts.ts— chunked, pipelined playback: the next chunk is synthesized while the current one plays, so a long answer on v3 starts on its first chunk instead of after the whole text. A newerspeak()orstop()bumps a generation counter and releases the pending clip, so an abandoned pipeline drops its remaining chunks without setting an error. Prefetch results are settled objects ({blob} | {error}) so a failed prefetch surfaces where it is consumed rather than as an unhandled rejection. Reads the preset fromusePersistentVoicePreset.usePersistentChoice.ts(new) — the generic "persisted pick from a fixed list" hook;usePersistentModel.tsis now a thin wrapper over it with its storage key unchanged (holon-viewer:model, so an existing model choice survives), andusePersistentVoicePreset.ts(new) is the second consumer (holon-viewer:voice-preset).Keys drawer (
src/chat/KeysDrawer.tsx)VoicePresetRow) inside the ElevenLabs card, above the Voice ID override, shown once an ElevenLabs key is saved — same pattern as the Claude model selector under the Anthropic key. Shows the selected preset's blurb.Docs / tests
README.md— the Voice bullet names the quality preset.test/voices.test.ts— registry invariants: default is the house v3/192k setting, Fast is the previous setting, v3 stability constraint, unique ids, unknown id → default.test/tts.test.ts— the request shape with a mockedfetch(URL with?output_format,xi-api-key,model_id,voice_settings, currency spoken as words), the 401 message, and the chunker (short text whole, paragraph splits, sentence fallback, every chunk within the limit).Rendering output is unchanged. Nothing in
@robosystems/report-componentsor the report path is touched.Key Handling
No new key storage, and no new destination. The ElevenLabs key still travels only in the
xi-api-keyheader toapi.elevenlabs.io(via the Vite proxy in dev), for the user's own read-aloud. What changes on that request: the model id, a bitrate query parameter, and the voice settings — all provider-side tuning, no additional data about the user or the report. A long answer now becomes several requests to the same endpoint instead of one. The preset choice is stored inlocalStoragebeside the model choice. File mode's offline guarantee is unaffected: opening a report makes no network call; voice was already opt-in on a saved ElevenLabs key.Testing
npm run test:allstages ran green:format:check→lint→typecheck→test(51 tests, 10 files) via the pre-commit hook on the commit, andnpm run buildseparately in-session.usePersistentChoicehook through the Claude model selector: change → written to storage → survives a reload → restored.