Skip to content

feat(voice): ElevenLabs quality presets, defaulting to the house v3 narration setting - #76

Merged
jfrench9 merged 1 commit into
mainfrom
feature/tts-voice-presets
Sep 6, 2026
Merged

feat(voice): ElevenLabs quality presets, defaulting to the house v3 narration setting#76
jfrench9 merged 1 commit into
mainfrom
feature/tts-voice-presets

Conversation

@jfrench9

@jfrench9 jfrench9 commented Sep 6, 2026

Copy link
Copy Markdown
Member

Summary

The read-aloud sounded worse than the content-machine narrations because it was on different ElevenLabs settings: src/ai/tts.ts was 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 to eleven_v3 at mp3_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 — the VOICE_PRESETS registry, same shape as models.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.tssynthesizeSpeech(apiKey, text, voiceId, preset) sends the preset's model_id and voice_settings and adds the ?output_format= query parameter (previously absent, so the API's default bitrate applied). New chunkForTts(text, limit = 2500): a port of the content machine's chunk_text — paragraph boundaries first, sentence ends for an over-long paragraph. Module comment updated; the hard-coded MODEL_ID / VOICE_SETTINGS constants 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 newer speak() or stop() 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 from usePersistentVoicePreset.
  • usePersistentChoice.ts (new) — the generic "persisted pick from a fixed list" hook; usePersistentModel.ts is now a thin wrapper over it with its storage key unchanged (holon-viewer:model, so an existing model choice survives), and usePersistentVoicePreset.ts (new) is the second consumer (holon-viewer:voice-preset).

Keys drawer (src/chat/KeysDrawer.tsx)

  • A Quality selector (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 mocked fetch (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-components or 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-key header to api.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 in localStorage beside 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:all stages ran green: format:checklinttypechecktest (51 tests, 10 files) via the pre-commit hook on the commit, and npm run build separately in-session.
  • Loaded a report: not for this change — it does not touch rendering. In the preview build I exercised the shared usePersistentChoice hook through the Claude model selector: change → written to storage → survives a reload → restored.
  • Not verified by ear: this browser has no ElevenLabs key stored, so the two presets were not played. The request shape is pinned by unit tests; the audible comparison (Quality vs Fast) is the check that still needs a saved ElevenLabs key.

…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
@jfrench9
jfrench9 merged commit 41459b6 into main Sep 6, 2026
4 checks passed
@jfrench9
jfrench9 deleted the feature/tts-voice-presets branch September 6, 2026 06:42
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