Skip to content

Offer Eleven v3 alongside the v2 models, each with its own settings - #148

Merged
NeoVand merged 1 commit into
mainfrom
feat/elevenlabs-v3
Aug 27, 2026
Merged

Offer Eleven v3 alongside the v2 models, each with its own settings#148
NeoVand merged 1 commit into
mainfrom
feat/elevenlabs-v3

Conversation

@NeoVand

@NeoVand NeoVand commented Aug 27, 2026

Copy link
Copy Markdown
Owner

What changed

Eleven v3 is now selectable. It was deliberately excluded from the catalog because it could not return character alignment, and read-along highlighting depends on it. That is no longer true — probing the live API shows both eleven_v3 and eleven_v3_conversational return alignment from /with-timestamps at pcm_24000.

The picker in Settings → Voice now groups two generations. Nothing was removed: Flash v2.5, Turbo v2.5, and Multilingual v2 all stay, and Flash v2.5 remains the default — half the credits, and steadiest on the ~280-character passages the segmenter produces. Switching to v3 is one click.

Each model carries its own settings, persisted per model id in the new elevenlabs-model-options setting. You can leave v3 on Creative while Multilingual keeps speaker boost off; the two don't interfere.

Only the knobs a model actually honours are rendered. The support matrix comes from GET /v1/models plus measurement, not from the docs:

Model Stability Similarity Style Speed Speaker boost
Eleven v3 3 named points
Eleven v3 Conversational 3 named points yes
Flash v2.5 / Turbo v2.5 0–1 yes yes
Multilingual v2 0–1 yes yes yes yes

Notable findings behind that table, all measured against the live API rather than read off documentation:

  • v3 accepts speed and ignores it. 0.7 and 1.2 both produced identical 2.88 s audio, where Flash tracked them exactly (4.04 s vs 2.32 s). Exposing a speed slider for v3 would have been a lie, so it is off.
  • v3 reports can_use_style: false, and plain v3 reports can_use_speaker_boost: false.
  • v3 rejects previous_text/next_text with unsupported_model, so the usual long-form stitching trick is unavailable there.
  • v3 caps at 5,000 characters per request against Flash's 40,000.

Why the cache is safe

This is the part most worth a careful look. Existing users must not silently re-generate (and re-pay for) audio they already have.

  • While options are untouched, the request sends no voice_settings at all — byte-for-byte what the code did before this PR, which means the voice's own saved settings still apply.
  • In that same untouched state elevenLabsRevision() returns the bare model id, so every pre-existing AudioVariantMeta still matches and coverage resolves exactly as before.
  • Move any knob and the revision gains a sorted #k=v,… suffix. That flows through speechVariant().revision into variantSignature(), matchesCurrentVariant(), and cacheKey(), so only the tuned variant re-synthesizes.
  • The suffix is cache-only. The wire always receives the bare modelId; sending eleven_v3#stability=1 would be a 400.

Setting a knob back to its default collapses the revision to the bare id again and the original cached audio is reused.

Other fixes in this PR

  • Character-limit guard. A passage over the model's per-request cap now throws a readable error before the request goes out instead of burning a round trip on a 422.
  • $state.snapshot when persisting. Nested $state proxies cannot be structured-cloned into IndexedDB. The first version of this code threw DataCloneError inside a floating promise: the UI updated correctly and nothing persisted, visible only after a reload. Caught by driving the live preview — types and tests were green.

Verification

  • npm run lint
  • npm run check — 1838 files, 0 errors, 0 warnings
  • npm run test:coverage — 464 unit tests, thresholds met
  • npm run build
  • npm run test:e2e — 44 passed
  • Live journey: imported a document, played it on Eleven v3 with Robust stability, confirmed word-level highlighting tracked correctly across passages. Switched to Creative and observed the previously-cached passage re-synthesize with the live request body { model_id: "eleven_v3", voice_settings: { stability: 0 } } — bare model id on the wire, only the field v3 honours.
  • Per-model isolation checked against IndexedDB: eleven_v3 held stability: 0 while eleven_multilingual_v2 held speakerBoost: false, simultaneously, surviving reload.

New tests cover stability snapping, clamping, unsupported-knob suppression, the untouched-sends-nothing invariant, per-model request bodies, revision stability, and the character-limit guard.

Review checklist

  • Privacy and network behavior are unchanged or explicitly explained. Same endpoint, same key handling, same direct browser-to-ElevenLabs call. The only request-body change is an optional voice_settings object, sent only when the user has tuned something.
  • Storage/migration and model-license implications are covered. One additive setting (elevenlabs-model-options), absent-reads-as-{}, no migration needed. No stored audio is invalidated by this PR alone. No new model licenses.
  • Keyboard, screen-reader, contrast, and reduced-motion behavior are covered. Sliders are native input[type=range] inside <label>; the v3 stability picker is a role="group" of buttons with aria-pressed, matching the existing model picker; the checkbox row keeps the 44px touch target. Colors reuse the existing settings tokens, so light/dark and contrast are inherited. No new animation.
  • Visual changes include desktop screenshots. Not attachable from the CLI — the new panel sits under the model picker in Settings → Voice and reuses the existing .engine-model card styling, so it reads as part of that section rather than as a new surface.

🤖 Generated with Claude Code

…ings

Eleven v3 was deliberately absent from the catalog because it could not
return character alignment, and word highlighting depends on it. Probing
the live API shows that is no longer true: both eleven_v3 and
eleven_v3_conversational return alignment from /with-timestamps at
pcm_24000, so they can join the picker without losing read-along.

The two generations are grouped in Settings and every v2 model stays.
Flash v2.5 remains the default — half the credits, and steadiest on the
short passages the segmenter produces.

Each model now carries its own synthesis knobs, persisted per model id in
the elevenlabs-model-options setting, and only the ones that model
actually honours are shown. The support matrix comes from GET /v1/models
plus measurement, not the docs: v3 accepts `speed` and ignores it
(identical audio at 0.7 and 1.2, where Flash tracked both exactly), and
reports can_use_style false, so v3 exposes stability alone — the three
named points ElevenLabs documents. v3 Conversational adds speaker boost;
Multilingual v2 exposes all five.

Cached audio is preserved. Untouched options send no voice_settings at
all, which is exactly what the code did before, and elevenLabsRevision()
returns the bare model id so existing cache entries still match. Moving
any knob appends a sorted suffix to the revision, which flows into the
audio cache key and re-synthesizes that variant. The wire always receives
the bare model id.

Also guards a request longer than the model's limit (v3 caps at 5,000
characters against Flash's 40,000) before spending credits, and persists
the options with $state.snapshot — nested $state proxies cannot be
structured-cloned into IndexedDB, and the resulting DataCloneError
rejected silently while the UI looked correct until a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NeoVand
NeoVand merged commit 2a6780b into main Aug 27, 2026
5 checks passed
@NeoVand
NeoVand deleted the feat/elevenlabs-v3 branch August 27, 2026 03:36
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