Skip to content

ElevenLabs support: expressive tags, pre-v3 prompt markup, v3 audio-tag dialect - #4

Merged
willwade merged 8 commits into
masterfrom
feat/elevenlabs-dialects
Sep 4, 2026
Merged

ElevenLabs support: expressive tags, pre-v3 prompt markup, v3 audio-tag dialect#4
willwade merged 8 commits into
masterfrom
feat/elevenlabs-dialects

Conversation

@willwade

@willwade willwade commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Real SpeechMarkdown support for ElevenLabs. Previously Platform::ElevenLabs fell through to the plain-text formatter: to_ssml(input, "elevenlabs") silently returned stripped text, and capabilities advertised elements ElevenLabs cannot parse (ElevenLabs has no SSML engine at all).

ElevenLabs markup is model-dependent, so this adds two dialects:

elevenlabs (pre-v3: multilingual_v2 / flash_v2_5 / flash_v2) elevenlabs-v3 (eleven_v3*)
Pauses <break time="x.xs"/> (≤3s) ... / [short pause] / [pause] / [long pause]
Pronunciation <phoneme> (flash_v2/turbo_v2 only, English only) native "/IPA/"
Emphasis degrades to text [emphasized] / [stress on next word] / [understated]
Direction degrades to text audio tags ([whispers], #[excited], …)

Cross-dialect emission is a correctness bug, not a degradation: v3 reads stray XML aloud; pre-v3 models read audio tags aloud (per ElevenLabs help center). Sources: help.elevenlabs.io articles 13416374683665 & 24352686926609, docs best-practices, v3-audiotags blog.

Changes

  • Parser: NodeType::Expressive for the speechmarkdown-js keyword list ([laugh], [sigh], [applause]…), kept verbatim by Text/SSML-base formatters; unknown bracketed content still passes through as plain text (preserving v3's open-ended natural-language tags)
  • elevenlabs.rs: pre-v3 formatter ported from the JS ElevenLabsFormatter — breaks (strength→duration table), IPA→<phoneme>, no <speak> wrapper, no escaping; matches the shared corpus .elevenlabs.ssml fixtures, which the corpus runner now enforces
  • elevenlabs_v3.rs: new Platform::ElevenLabsV3 ("elevenlabs-v3"), sections → prefix tags, sub speaks alias, audio/mark dropped
  • capabilities.rs: rewritten for both dialects to match documented reality (was: prosody/audio/mark/say-as falsely advertised)
  • Bug fixes: is_time_break accepted any token ending in "s" (plain-text [apps] became a break); dotnet binding's ElevenLabs constant was "eleven-labs" which never parsed
  • README documents both dialects and model requirements

Notes

  • v3 audio tags are model-interpreted prompts (best-effort, voice-dependent, no exact durations) — mappings are documented approximations, emphasis never CAPS-mutates user text
  • Consumed by AACTools/rust-tts-wrapper#91 (wrapper picks the dialect from the model)
  • After merge: bump to 0.4.14 and publish, then the wrapper can drop its git patch

Test plan

  • 209/209 shared-corpus cases (incl. 9 .elevenlabs.ssml) pass
  • 30+ new unit tests across parser/formatters/capabilities
  • cargo clippy --all-features clean
  • CI green

…ause])

Mirrors the speechmarkdown-js reference grammar: a fixed keyword list
parsed into a new NodeType::Expressive AST node. Text and SSML base
formatters keep the tag verbatim, matching the JS TextFormatter and
W3cSsmlFormatter behavior. Unknown bracketed content continues to pass
through as plain text, which preserves Eleven v3's open-ended
natural-language audio tags.
Ports the speechmarkdown-js ElevenLabsFormatter: <break time> for
pauses (strength mapped to approximate durations), <phoneme> for IPA
modifiers, expressive audio tags verbatim, everything else degrades to
plain text. No <speak> wrapper, no XML escaping — a bare prompt, as the
shared corpus .elevenlabs.ssml fixtures specify.

Also fixes a parser bug: is_time_break accepted any token ending in
's'/'ms', so plain-text words like [apps] were parsed as breaks; it now
requires a numeric body.

The corpus runner now enforces the .elevenlabs.ssml fixtures (9 cases).

Model notes: <break> works on multilingual_v2 / flash_v2_5 / flash_v2
(max 3s); <phoneme> only on flash_v2 / turbo_v2, English only;
eleven_v3 parses neither — it needs the audio-tag dialect (next).
New Platform::ElevenLabsV3 ("elevenlabs-v3") formatter for eleven_v3 /
eleven_v3_conversational, which parse no SSML at all. SpeechMarkdown
constructs map to Eleven's native direction mechanisms:

- breaks -> ... / [short pause] / [pause] / [long pause] (three
  approximate steps; v3 has no exact durations)
- emphasis -> [emphasized] / [stress on next word] / [understated]
  prefix tags (never CAPS-mutates user text)
- whisper/excited/disappointed and rate/volume modifiers ->
  [whispers]/[excited]/[rushed]/[drawn out]/[softly]/[loudly]
- IPA -> native " slash form; sub speaks the alias
- #[style] sections -> verbatim prefix tags (v3 tags are open-ended
  natural language); audio/mark dropped

capabilities.rs rewritten for both dialects to match the documented
per-model truth: pre-v3 = break (<=3s) + flash-only phoneme; v3 =
audio tags + inline IPA, everything else unsupported with pointers to
API-level alternatives (voice_id, voice_settings.speed).
The constant said "eleven-labs" but Platform::from_platform_str only
accepts "elevenlabs" — every dotnet ElevenLabs conversion failed as
unsupported. Also adds the ElevenLabsV3 ("elevenlabs-v3") constant.
…tolerance corpus gate

- pre-v3 formatter clamps <break> values above ElevenLabs' documented
  3s limit (in-limit values keep the caller's unit formatting, matching
  the corpus fixtures' 3s/250ms)
- is_time_break now rejects malformed numbers ([1.2.3s], [1..5s]) —
  at most one decimal point — closing the gap the previous fix left
- phoneme attribute values escape double quotes (tag integrity; the
  prompt body stays unescaped by design)
- corpus runner panics on ANY failure instead of a >50% threshold —
  the enforced fixture families are the regression net
- both ElevenLabs formatters honor FormatterOptions.preserve_empty_lines
  (was hardcoded true behind a wrong #[allow(dead_code)])
Loop-2 review caught leading/trailing-dot forms ([5.s], [.5s], [0.s])
still parsing as breaks. The dot must now be surrounded by digits,
matching the speechmarkdown-js grammar the code cites. Also drops two
unused use-super imports flagged by clippy --all-targets.
@willwade
willwade merged commit 400a162 into master Sep 4, 2026
4 checks passed
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