Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,9 @@ fn elevenlabs_capabilities() -> PlatformCapabilities {
"mark (no equivalent)".into(),
"voice (switch via the API voice_id parameter)".into(),
"lang (no equivalent)".into(),
"audio tags (eleven_v3 only)".into(),
"audio tags (eleven_v3 only; pre-v3 models read them aloud — \
use the elevenlabs-v3 dialect)"
.into(),
"amazon:effect".into(),
"amazon:emotion".into(),
"amazon:domain".into(),
Expand Down
78 changes: 44 additions & 34 deletions src/formatters/elevenlabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,29 @@ impl ElevenLabsFormatter {
Some(value * scale)
}

/// Break time verbatim when within the documented 3s limit; clamped
/// to 3s beyond it (keeping the caller's unit formatting otherwise,
/// matching the shared corpus fixtures which use both "3s" and "250ms").
fn clamp_break_time(time: &str) -> String {
/// Normalize a break duration to ElevenLabs' documented seconds
/// format ("Break time should be described in seconds"), clamped to
/// the 3s limit: "250ms" → "0.25s", "10s" → "3s", "1.5s" unchanged.
fn normalize_break_time(time: &str) -> String {
match Self::parse_seconds(time) {
Some(secs) if secs > MAX_BREAK_SECONDS => format!("{MAX_BREAK_SECONDS}s"),
_ => time.to_string(),
Some(secs) => Self::format_seconds(secs.min(MAX_BREAK_SECONDS)),
None => time.to_string(),
}
}

/// Format seconds without trailing zeros ("2s", "0.25s", "1.2s").
/// Three decimals cover millisecond precision.
fn format_seconds(secs: f64) -> String {
let mut s = format!("{secs:.3}");
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
format!("{s}s")
}

fn map_strength_to_time(strength: &str) -> String {
let normalized = strength.trim().to_lowercase();
BREAK_STRENGTH_TO_DURATION
Expand Down Expand Up @@ -122,7 +135,7 @@ impl ElevenLabsFormatter {
}

NodeType::ShortBreak => {
let time = Self::clamp_break_time(Self::break_time_from_text(&node.text));
let time = Self::normalize_break_time(Self::break_time_from_text(&node.text));
out.push_str(&format!("<break time=\"{}\"/>", time));
}

Expand Down Expand Up @@ -184,12 +197,11 @@ impl ElevenLabsFormatter {
}

// No pre-v3 equivalent: drop rather than speak a URL or
// emit markup the model would read aloud.
NodeType::Audio | NodeType::Mark => {}

NodeType::Expressive => {
out.push_str(&format!("[{}]", node.text));
}
// emit markup the model would read aloud. Expressive audio
// tags ([laugh], …) are v3-exclusive — pre-v3 models read
// them aloud as literal text — so they are dropped here and
// emitted only by the elevenlabs-v3 dialect.
NodeType::Audio | NodeType::Mark | NodeType::Expressive => {}

// Modifier node types never appear standalone from the parser.
_ => {}
Expand Down Expand Up @@ -225,15 +237,15 @@ mod tests {
fn short_break_becomes_break_tag() {
assert_eq!(
to_elevenlabs("Sample [3s] speech [250ms] markdown"),
"Sample <break time=\"3s\"/> speech <break time=\"250ms\"/> markdown"
"Sample <break time=\"3s\"/> speech <break time=\"0.25s\"/> markdown"
);
}

#[test]
fn quoted_break_time_becomes_break_tag() {
assert_eq!(
to_elevenlabs("Sample [break:\"3s\"] speech [break:'250ms'] markdown"),
"Sample <break time=\"3s\"/> speech <break time=\"250ms\"/> markdown"
"Sample <break time=\"3s\"/> speech <break time=\"0.25s\"/> markdown"
);
}

Expand All @@ -253,9 +265,9 @@ mod tests {
}

#[test]
fn breaks_clamp_to_three_seconds() {
// Pre-v3 models accept at most 3s; longer values are rejected
// or destabilize the generation.
fn breaks_clamp_and_normalize_to_seconds() {
// Pre-v3 models accept at most 3s and the documented format is
// seconds ("Break time should be described in seconds").
assert_eq!(
to_elevenlabs("Wait [10s] now"),
"Wait <break time=\"3s\"/> now"
Expand All @@ -264,11 +276,17 @@ mod tests {
to_elevenlabs("Wait [3500ms] now"),
"Wait <break time=\"3s\"/> now"
);
// Values within the limit keep the caller's unit formatting
// (the corpus fixtures pin both "3s" and "250ms").
assert_eq!(
to_elevenlabs("Wait [250ms] now"),
"Wait <break time=\"250ms\"/> now"
"Wait <break time=\"0.25s\"/> now"
);
assert_eq!(
to_elevenlabs("Wait [1.50s] now"),
"Wait <break time=\"1.5s\"/> now"
);
assert_eq!(
to_elevenlabs("Wait [2s] now"),
"Wait <break time=\"2s\"/> now"
);
}

Expand All @@ -277,17 +295,7 @@ mod tests {
// Not valid durations: plain text passthrough (matches the
// speechmarkdown-js grammar, which only accepts \d+(\.\d+)?(s|ms)).
for word in [
"1.2.3s",
"1..5s",
"apps",
"infs",
"s",
"5.s",
".5s",
"0.s",
"-2s",
"+2s",
"1e3s",
"1.2.3s", "1..5s", "apps", "infs", "s", "5.s", ".5s", "0.s", "-2s", "+2s", "1e3s",
] {
let out = to_elevenlabs(&format!("x [{word}] y"));
assert_eq!(out, format!("x [{word}] y"), "word {word}");
Expand Down Expand Up @@ -342,10 +350,12 @@ mod tests {
}

#[test]
fn expressive_tags_pass_through() {
fn expressive_tags_are_dropped() {
// Audio tags are eleven_v3-exclusive; pre-v3 models read them
// aloud as literal text, so the pre-v3 dialect drops them.
assert_eq!(
to_elevenlabs("He [laugh] and then [applause] left"),
"He [laugh] and then [applause] left"
"He and then left"
);
}

Expand Down
21 changes: 21 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,27 @@ fn test_all_test_cases() {
}
}

// Test audio-tag output for ElevenLabs v3 (our own fixture
// set — the speechmarkdown-js reference has no v3 platform)
let elevenlabs_v3_file = test_dir.join(format!("{}.elevenlabs-v3.ssml", test_name));
if elevenlabs_v3_file.exists() {
let expected = fs::read_to_string(&elevenlabs_v3_file).unwrap_or_else(|_| {
panic!(
"Failed to read ElevenLabs v3 file: {:?}",
elevenlabs_v3_file
)
});

let result = SpeechMarkdownParser::to_ssml(&input, Platform::ElevenLabsV3);
if result.is_err() {
all_checks_passed = false;
} else if let Ok(actual) = result {
if actual.trim() != normalize_line_endings(expected.trim()) {
all_checks_passed = false;
}
}
}

all_checks_passed
}
Err(_e) => false,
Expand Down
2 changes: 1 addition & 1 deletion tests/test-data
Loading