Skip to content
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,24 @@ You can safely ignore Swift-specific files. Your respective package managers (Ca
| Apple | `"apple"` |
| W3C | `"w3c"` |
| Samsung Bixby | `"samsung-bixby"` or `"bixby"` |
| ElevenLabs | `"elevenlabs"` |
| ElevenLabs (pre-v3) | `"elevenlabs"` |
| ElevenLabs v3 | `"elevenlabs-v3"` |
| IBM Watson | `"ibm-watson"` or `"watson"` |

### ElevenLabs note

ElevenLabs does not parse SSML documents, so both ElevenLabs platforms emit
prompt markup rather than SSML, and the correct dialect depends on the model:

- `"elevenlabs"` — pre-v3 models (`eleven_multilingual_v2`, `eleven_flash_v2_5`,
`eleven_flash_v2`, `eleven_turbo_v2`): `<break time="x.xs"/>` pauses (max 3s)
and `<phoneme>` for IPA (flash/turbo, English only). No `<speak>` wrapper.
- `"elevenlabs-v3"` — `eleven_v3` / `eleven_v3_conversational`: bracketed
natural-language audio tags (`[whispers]`, `[excited]`, `[pause]`,
`[long pause]`, `[laughs]`), punctuation pauses, and native `"/IPA/"`.
Audio tags are model-interpreted direction — best-effort, voice-dependent —
and are not understood by pre-v3 models.

## API

All bindings expose the same core methods:
Expand Down
4 changes: 3 additions & 1 deletion bindings/dotnet/SpeechMarkdown.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,9 @@ public static class Platform
public const string Apple = "apple";
public const string W3c = "w3c";
public const string SamsungBixby = "samsung-bixby";
public const string ElevenLabs = "eleven-labs";
// Note: the core library parses "elevenlabs" (no hyphen).
public const string ElevenLabs = "elevenlabs";
public const string ElevenLabsV3 = "elevenlabs-v3";
public const string IbmWatson = "ibm-watson";
}
}
2 changes: 1 addition & 1 deletion bindings/nodejs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use speechmarkdown_rust::{Platform, SpeechMarkdownParser};
fn parse_platform(platform: &str) -> Result<Platform> {
Platform::from_platform_str(platform).ok_or_else(|| {
Error::from_reason(format!(
"unsupported platform: '{}'. Use one of: amazon-alexa, google-assistant, microsoft-azure, apple, w3c, samsung-bixby, elevenlabs, ibm-watson",
"unsupported platform: '{}'. Use one of: amazon-alexa, google-assistant, microsoft-azure, apple, w3c, samsung-bixby, elevenlabs, elevenlabs-v3, ibm-watson",
platform
))
})
Expand Down
2 changes: 1 addition & 1 deletion bindings/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use speechmarkdown_rust::{Platform, SpeechMarkdownParser};
fn parse_platform(platform: &str) -> PyResult<Platform> {
Platform::from_platform_str(platform).ok_or_else(|| {
pyo3::exceptions::PyValueError::new_err(format!(
"unsupported platform: '{}'. Use one of: amazon-alexa, google-assistant, microsoft-azure, apple, w3c, samsung-bixby, elevenlabs, ibm-watson",
"unsupported platform: '{}'. Use one of: amazon-alexa, google-assistant, microsoft-azure, apple, w3c, samsung-bixby, elevenlabs, elevenlabs-v3, ibm-watson",
platform
))
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ extern "C" {

// Convert SpeechMarkdown input to SSML for the given platform.
// Platforms: "amazon-alexa", "google-assistant", "microsoft-azure",
// "apple", "w3c", "samsung-bixby", "eleven-labs", "ibm-watson"
// "apple", "w3c", "samsung-bixby", "elevenlabs", "elevenlabs-v3", "ibm-watson"
// Returns: allocated string with SSML, or NULL on error.
// Caller must free with speechmarkdown_free().
const char* speechmarkdown_to_ssml(const char* input, const char* platform);
Expand Down
4 changes: 4 additions & 0 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ pub enum NodeType {
/// Mark tag
Mark,

/// Expressive audio tag [laugh], [sigh], [applause], etc.
/// Kept verbatim by formatters as a bracketed audio tag.
Expressive,

// Modifier types (for text modifiers and sections)
/// Emphasis modifier
Emphasis,
Expand Down
197 changes: 160 additions & 37 deletions src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub fn get_supported_ssml(platform: Platform) -> PlatformCapabilities {
Platform::W3c => w3c_capabilities(),
Platform::SamsungBixby => samsung_bixby_capabilities(),
Platform::ElevenLabs => elevenlabs_capabilities(),
Platform::ElevenLabsV3 => elevenlabs_v3_capabilities(),
Platform::IbmWatson => ibm_watson_capabilities(),
}
}
Expand Down Expand Up @@ -396,24 +397,127 @@ fn samsung_bixby_capabilities() -> PlatformCapabilities {
}

fn elevenlabs_capabilities() -> PlatformCapabilities {
// Pre-v3 prompt markup (eleven_multilingual_v2 / flash_v2_5 /
// flash_v2 / turbo_v2): no SSML document, but two inline tags are
// parsed. Everything else degrades to plain text.
PlatformCapabilities {
platform: "elevenlabs".into(),
ssml_elements: vec![
break_element(),
prosody_element(),
audio_element(),
phoneme_element(),
mark_element(),
say_as_element("characters", "characters"),
say_as_element("number", "number"),
say_as_element("date", "date"),
say_as_element("time", "time"),
SsmlCapability {
element: "break".into(),
description: "Insert a pause (pre-v3 models only; max 3s; \
eleven_v3 does not parse break tags)"
.into(),
attributes: vec!["time".into()],
speech_markdown_syntax: vec![
"[2s]".into(),
"[500ms]".into(),
"[break:strong]".into(),
],
example: "Hello [2s] world".into(),
},
SsmlCapability {
element: "phoneme".into(),
description: "Custom pronunciation (eleven_flash_v2 / \
eleven_turbo_v2 only, English only)"
.into(),
attributes: vec!["alphabet".into(), "ph".into()],
speech_markdown_syntax: vec![
"(text)[ipa:\"pɪkəloʊ\"]".into(),
"(text)/pɪkəloʊ/".into(),
],
example: "(piccolo)/pɪkəloʊ/".into(),
},
],
unsupported: vec![
"emphasis".into(),
"voice".into(),
"lang".into(),
"sub".into(),
"emphasis (no equivalent; degrades to text)".into(),
"prosody (rate maps to the API voice_settings.speed; use the \
elevenlabs-v3 dialect for tag-based control)"
.into(),
"say-as (text normalization is built in)".into(),
"sub (use pronunciation dictionaries or phonetic spelling)".into(),
"audio (no equivalent)".into(),
"mark (no equivalent)".into(),
"voice (switch via the API voice_id parameter)".into(),
"lang (no equivalent)".into(),
"audio tags (eleven_v3 only)".into(),
"amazon:effect".into(),
"amazon:emotion".into(),
"amazon:domain".into(),
"mstts:express-as".into(),
"google:style".into(),
],
}
}

fn elevenlabs_v3_capabilities() -> PlatformCapabilities {
// Eleven v3 audio-tag dialect: no SSML at all. Bracketed
// natural-language tags are prompts interpreted by the model —
// best-effort, voice-dependent, and directional from their insertion
// point (no span semantics).
PlatformCapabilities {
platform: "elevenlabs-v3".into(),
ssml_elements: vec![
SsmlCapability {
element: "[audio tag]".into(),
description: "Natural-language performance direction in \
brackets; open-ended (emotions, delivery, \
reactions, sound effects, accents)"
.into(),
attributes: vec![],
speech_markdown_syntax: vec![
"[laugh]".into(),
"#[excited] text".into(),
"(text)[whisper]".into(),
],
example: "[whispers] It's a secret".into(),
},
SsmlCapability {
element: "[pause] family".into(),
description: "Pauses via [short pause] / [pause] / [long \
pause] or ellipses; approximate — no exact \
durations on v3"
.into(),
attributes: vec![],
speech_markdown_syntax: vec![
"[500ms]".into(),
"[2s]".into(),
"[break:strong]".into(),
],
example: "Hello [2s] world".into(),
},
SsmlCapability {
element: "inline IPA".into(),
description: "Native IPA wrapped in quotes and slashes".into(),
attributes: vec![],
speech_markdown_syntax: vec!["(speech)/spitʃ/".into()],
example: "(speech)/spitʃ/".into(),
},
SsmlCapability {
element: "emphasis tags".into(),
description: "Emphasis via [emphasized] / [stress on next \
word] / [understated] (capitalization also \
works but mutates the text)"
.into(),
attributes: vec![],
speech_markdown_syntax: vec!["++word++".into(), "+word+".into()],
example: "++important++".into(),
},
],
unsupported: vec![
"break (eleven_v3 does not parse SSML break tags)".into(),
"phoneme (use inline IPA)".into(),
"prosody (rate maps to the API voice_settings.speed; \
tempo/volume map to [rushed]/[drawn out]/[softly]/[loudly] tags)"
.into(),
"say-as (text normalization is built in)".into(),
"sub (alias is spoken instead of the text)".into(),
"audio (no equivalent)".into(),
"mark (no equivalent)".into(),
"voice (switch via the API voice_id parameter or Text to \
Dialogue)"
.into(),
"lang (no equivalent)".into(),
"amazon:effect".into(),
"amazon:emotion".into(),
"amazon:domain".into(),
Expand Down Expand Up @@ -466,24 +570,64 @@ mod tests {
Platform::W3c,
Platform::SamsungBixby,
Platform::ElevenLabs,
Platform::ElevenLabsV3,
Platform::IbmWatson,
] {
let caps = get_supported_ssml(platform);
assert!(!caps.ssml_elements.is_empty(), "{:?} has no elements", platform);
assert!(
!caps.ssml_elements.is_empty(),
"{:?} has no elements",
platform
);
assert!(!caps.platform.is_empty());
}
}

#[test]
fn test_all_platforms_have_break() {
for platform in [
Platform::AmazonAlexa,
Platform::GoogleAssistant,
Platform::MicrosoftAzure,
Platform::Apple,
Platform::W3c,
Platform::SamsungBixby,
Platform::ElevenLabs,
Platform::IbmWatson,
] {
let caps = get_supported_ssml(platform);
assert!(
caps.ssml_elements.iter().any(|e| e.element == "break"),
"{:?} missing break",
platform
);
}

// Eleven v3 does not parse <break> at all; pauses are audio tags.
let v3 = get_supported_ssml(Platform::ElevenLabsV3);
assert!(v3
.ssml_elements
.iter()
.any(|e| e.element == "[pause] family"));
assert!(v3.unsupported.iter().any(|u| u.starts_with("break")));
}

#[test]
fn test_alexa_has_emotion() {
let caps = get_supported_ssml(Platform::AmazonAlexa);
assert!(caps.ssml_elements.iter().any(|e| e.element == "amazon:emotion"));
assert!(caps
.ssml_elements
.iter()
.any(|e| e.element == "amazon:emotion"));
}

#[test]
fn test_azure_has_express_as() {
let caps = get_supported_ssml(Platform::MicrosoftAzure);
assert!(caps.ssml_elements.iter().any(|e| e.element == "mstts:express-as"));
assert!(caps
.ssml_elements
.iter()
.any(|e| e.element == "mstts:express-as"));
}

#[test]
Expand All @@ -506,25 +650,4 @@ mod tests {
let deserialized: PlatformCapabilities = serde_json::from_str(&json).unwrap();
assert_eq!(caps, deserialized);
}

#[test]
fn test_all_platforms_have_break() {
for platform in [
Platform::AmazonAlexa,
Platform::GoogleAssistant,
Platform::MicrosoftAzure,
Platform::Apple,
Platform::W3c,
Platform::SamsungBixby,
Platform::ElevenLabs,
Platform::IbmWatson,
] {
let caps = get_supported_ssml(platform);
assert!(
caps.ssml_elements.iter().any(|e| e.element == "break"),
"{:?} missing break",
platform
);
}
}
}
15 changes: 15 additions & 0 deletions src/formatters/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ pub enum Platform {
W3c,
SamsungBixby,
ElevenLabs,
/// Eleven v3 audio-tag dialect (eleven_v3 / eleven_v3_conversational):
/// bracketed natural-language tags instead of SSML.
ElevenLabsV3,
IbmWatson,
}

Expand All @@ -26,6 +29,7 @@ impl Platform {
"w3c" => Some(Platform::W3c),
"samsung-bixby" | "bixby" => Some(Platform::SamsungBixby),
"elevenlabs" => Some(Platform::ElevenLabs),
"elevenlabs-v3" | "elevenlabs_v3" | "eleven-v3" => Some(Platform::ElevenLabsV3),
"ibm-watson" | "watson" => Some(Platform::IbmWatson),
_ => None,
}
Expand All @@ -41,6 +45,7 @@ impl Platform {
Platform::W3c => "w3c",
Platform::SamsungBixby => "samsung-bixby",
Platform::ElevenLabs => "elevenlabs",
Platform::ElevenLabsV3 => "elevenlabs-v3",
Platform::IbmWatson => "ibm-watson",
}
}
Expand Down Expand Up @@ -105,6 +110,16 @@ pub fn create_formatter(platform: Platform, options: FormatterOptions) -> Box<dy
// Use the base SSML formatter for W3C standard
Box::new(super::ssml::SsmlFormatterBase::new(options))
}
Platform::ElevenLabs => {
// Pre-v3 prompt markup: <break> tags (and flash-only <phoneme>),
// no SSML document, no escaping.
Box::new(super::elevenlabs::ElevenLabsFormatter::new(options))
}
Platform::ElevenLabsV3 => {
// Eleven v3 audio-tag dialect: no SSML at all — bracketed
// natural-language tags, punctuation pauses, native slash IPA.
Box::new(super::elevenlabs_v3::ElevenLabsV3Formatter::new(options))
}
_ => Box::new(super::TextFormatter::new()),
}
}
Loading
Loading