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
30 changes: 30 additions & 0 deletions examples/edge-speechmarkdown.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! Live check: `SpeechMarkdown` through Edge, including [mark:] (whose Azure-
//! dialect output is <bookmark>, zero-audio on the free Edge endpoint unless
//! stripped) and emphasis. cargo run --no-default-features --features cloud
//! --example edge-speechmarkdown
use rust_tts_wrapper::factory::create_engine;

fn main() {
let engine = create_engine("edge", "{}").expect("edge engine");
for text in [
"Plain text reference.",
"This is (very)[emphasis:\"strong\"] emphasised.",
"A (mark)[mark:\"m1\"] in speech markdown.",
] {
let mut bytes = 0usize;
engine
.speak(
text,
Some("en-GB-SoniaNeural"),
1.0,
1.0,
1.0,
Some(&mut |chunk: &[u8]| bytes += chunk.len()),
None,
)
.unwrap_or_else(|e| panic!("{text}: {e}"));
println!("{text:?}: {bytes} bytes");
assert!(bytes > 0, "{text}: no audio");
}
println!("PASS");
}
73 changes: 54 additions & 19 deletions src/cloud_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1193,18 +1193,25 @@ fn normalize_ssml_envelope(ssml: &str, voice: &str) -> String {
format!("{lead}{open}{}", &trimmed[tag_end + 1..])
}

/// Remove `<mark>` elements from an SSML document for Azure/Edge.
/// Remove unsupported position-marking elements from an SSML document
/// for Azure/Edge.
///
/// Azure/Edge do not support the SSML `<mark>` element — an utterance
/// Neither service supports the W3C SSML `<mark>` element — an utterance
/// containing one synthesises **zero audio**, again with no error from
/// the service. `<mark>` is an empty element (it only names a position),
/// so dropping it changes no spoken content; consumers that need the
/// the service. Azure proper documents its own `<bookmark mark=…>`
/// replacement element, but the **free Edge endpoint zero-audios on
/// `<bookmark>` too** (verified live), so Edge strips both while Azure
/// keeps bookmarks. Both are empty elements (they only name a position),
/// so dropping them changes no spoken content; consumers that need the
/// positions should use word-boundary events. speech-dispatcher's
/// wrapper injects `<mark name="__spd_N"/>` around every pause, so
/// pass-through SSML from SSIP clients hits this constantly.
#[cfg(feature = "cloud")]
fn strip_unsupported_marks(ssml: &str) -> String {
if !ssml.contains("<mark") && !ssml.contains("</mark") {
fn strip_unsupported_marks(ssml: &str, strip_bookmark: bool) -> String {
let has_marks = ssml.contains("<mark") || ssml.contains("</mark");
let has_bookmarks =
strip_bookmark && (ssml.contains("<bookmark") || ssml.contains("</bookmark"));
if !has_marks && !has_bookmarks {
return ssml.to_string();
}
let mut out = String::with_capacity(ssml.len());
Expand All @@ -1216,7 +1223,11 @@ fn strip_unsupported_marks(ssml: &str) -> String {
s.strip_prefix(prefix)
.is_some_and(|tail| tail.starts_with([' ', '\t', '\r', '\n', '/', '>']))
};
if name_done(after, "<mark") || name_done(after, "</mark") {
let drop = name_done(after, "<mark")
|| name_done(after, "</mark")
|| (strip_bookmark
&& (name_done(after, "<bookmark") || name_done(after, "</bookmark")));
if drop {
if let Some(end) = after.find('>') {
out.push_str(&rest[..pos]);
rest = &after[end + 1..];
Expand Down Expand Up @@ -2003,11 +2014,16 @@ impl TtsEngine for CloudEngine {
// XML-escape the tags). If the SSML lacks a <voice> tag but the
// caller set one via tts_set_voice, inject it so the voice takes
// effect. The envelope is completed first (a bare <speak> is
// accepted but synthesises zero audio) and unsupported <mark>
// elements are dropped (same silent zero-audio failure).
// accepted but synthesises zero audio) and unsupported position
// elements are dropped (<mark> on both; <bookmark> — Azure's own
// documented element — on Edge only, where it also zeroes audio).
let is_edge = self.config.provider_id == "edge";
let ssml = if is_ssml {
inject_voice_if_missing(
&strip_unsupported_marks(&normalize_ssml_envelope(&text, &voice_to_use)),
&strip_unsupported_marks(
&normalize_ssml_envelope(&text, &voice_to_use),
is_edge,
),
&voice_to_use,
)
} else {
Expand Down Expand Up @@ -2270,10 +2286,11 @@ impl TtsEngine for CloudEngine {
// already SSML — send it directly (don't escape/wrap with
// build_azure_ssml). Inject voice if the SSML lacks a <voice>
// tag, after completing the envelope and dropping unsupported
// <mark> elements (both make Azure synthesise zero audio).
// <mark> elements (Azure accepts its documented <bookmark>
// here, so bookmarks are kept on this path).
let ssml = if is_ssml {
inject_voice_if_missing(
&strip_unsupported_marks(&normalize_ssml_envelope(&text, &voice_to_use)),
&strip_unsupported_marks(&normalize_ssml_envelope(&text, &voice_to_use), false),
&voice_to_use,
)
} else {
Expand Down Expand Up @@ -3555,34 +3572,52 @@ mod tests {
fn test_strip_marks_removes_self_closing_and_paired() {
// The exact shape speech-dispatcher wraps around pauses.
assert_eq!(
strip_unsupported_marks("A <mark name=\"__spd_0\"/> B"),
strip_unsupported_marks("A <mark name=\"__spd_0\"/> B", false),
"A B"
);
assert_eq!(
strip_unsupported_marks("A <mark name='x'></mark> B"),
strip_unsupported_marks("A <mark name='x'></mark> B", false),
"A B"
);
}

#[test]
fn test_strip_marks_leaves_similar_names_and_text_alone() {
assert_eq!(
strip_unsupported_marks("<market price='3'>"),
strip_unsupported_marks("<market price='3'>", false),
"<market price='3'>"
);
assert_eq!(strip_unsupported_marks("a < b"), "a < b");
assert_eq!(strip_unsupported_marks("<mark"), "<mark"); // unterminated
assert_eq!(strip_unsupported_marks("no marks here"), "no marks here");
assert_eq!(strip_unsupported_marks("a < b", false), "a < b");
assert_eq!(strip_unsupported_marks("<mark", false), "<mark"); // unterminated
assert_eq!(
strip_unsupported_marks("no marks here", false),
"no marks here"
);
}

#[test]
fn test_strip_marks_full_speechd_document() {
let ssml = "<speak>Hello <mark name=\"__spd_0\"/> world</speak>";
let stripped = strip_unsupported_marks(ssml);
let stripped = strip_unsupported_marks(ssml, false);
assert!(!stripped.contains("mark"));
assert_eq!(stripped, "<speak>Hello world</speak>");
}

#[test]
fn test_bookmark_kept_for_azure_stripped_for_edge() {
// Azure documents <bookmark mark=…> and accepts it …
assert_eq!(
strip_unsupported_marks("roses <bookmark mark='f1'/> and", false),
"roses <bookmark mark='f1'/> and"
);
// … but the free Edge endpoint synthesises zero audio for it, so
// the Edge path strips it too (verified live).
assert_eq!(
strip_unsupported_marks("roses <bookmark mark='f1'/> and", true),
"roses and"
);
}

// ===== inject_voice_if_missing =====

#[test]
Expand Down
5 changes: 4 additions & 1 deletion src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ pub fn preprocess_speech_markdown(text: &str, platform: &str) -> (String, bool)
}

let platform = match platform {
"azure" => Platform::MicrosoftAzure,
// Edge speaks the Azure SSML dialect (same Speech platform; it is
// not an Alexa-family endpoint) — its free endpoint just lacks a
// few elements, which the engine boundary strips.
"azure" | "edge" => Platform::MicrosoftAzure,
"google" => Platform::GoogleAssistant,
_ => Platform::AmazonAlexa,
};
Expand Down
Loading