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: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rust-tts-wrapper"
version = "0.3.19"
version = "0.3.20"
edition = "2021"
license = "MIT"
description = "Cross-platform TTS wrapper with C API — mirrors js-tts-wrapper / SwiftTTSWrapper"
Expand Down Expand Up @@ -61,7 +61,7 @@ serde_json = { version = "1", optional = true }
# symbols an older prebuilt lib lacks → LNK2019). Bump both together.
sherpa-onnx = { version = "=1.13.5", optional = true }
base64 = { version = "0.22", optional = true }
speechmarkdown-rust = { version = "0.4.12", optional = true }
speechmarkdown-rust = { version = "0.4.13", optional = true }
anyhow = "1"
tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"], optional = true }
uuid = { version = "1.23.2", features = ["v4"], optional = true }
Expand Down
24 changes: 24 additions & 0 deletions examples/edge-style.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! Live check: `SpeechMarkdown` style sections through Edge (mstts:express-as
//! is zero-audio on the free endpoint, so the Edge path strips the tags and
//! keeps the text).
use rust_tts_wrapper::factory::create_engine;
fn main() {
let engine = create_engine("edge", "{}").expect("edge engine");
for text in ["Plain control.", "#[angry] I am angry!"] {
let mut bytes = 0usize;
engine
.speak(
text,
Some("en-GB-SoniaNeural"),
1.0,
1.0,
1.0,
Some(&mut |c: &[u8]| bytes += c.len()),
None,
)
.unwrap_or_else(|e| panic!("{text}: {e}"));
println!("{text:?}: {bytes} bytes");
assert!(bytes > 0, "{text}: no audio");
}
println!("PASS");
}
56 changes: 39 additions & 17 deletions src/cloud_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1193,25 +1193,30 @@ fn normalize_ssml_envelope(ssml: &str, voice: &str) -> String {
format!("{lead}{open}{}", &trimmed[tag_end + 1..])
}

/// Remove unsupported position-marking elements from an SSML document
/// for Azure/Edge.
/// Remove elements the target endpoint silently refuses, from an SSML
/// document bound for Azure/Edge.
///
/// Neither service supports the W3C SSML `<mark>` element — an utterance
/// containing one synthesises **zero audio**, again with no error from
/// the service. Azure proper documents its own `<bookmark mark=…>`
/// Both services lack the W3C SSML `<mark>` element — an utterance
/// containing one synthesises **zero audio**, with no error from 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.
/// `<bookmark>` and on `<mstts:express-as …>` style sections too**
/// (verified live), so in Edge mode (`is_edge`) those tags are dropped
/// as well. `<mark>`/`<bookmark>` are empty elements, and for the
/// paired `mstts:express-as` wrapper only the tags are removed — the
/// spoken content inside survives. Consumers that need 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, strip_bookmark: bool) -> String {
fn strip_unsupported_marks(ssml: &str, is_edge: 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 {
let has_edge_extras = is_edge
&& (ssml.contains("<bookmark")
|| ssml.contains("</bookmark")
|| ssml.contains("<mstts:express-as")
|| ssml.contains("</mstts:express-as"));
if !has_marks && !has_edge_extras {
return ssml.to_string();
}
let mut out = String::with_capacity(ssml.len());
Expand All @@ -1225,8 +1230,11 @@ fn strip_unsupported_marks(ssml: &str, strip_bookmark: bool) -> String {
};
let drop = name_done(after, "<mark")
|| name_done(after, "</mark")
|| (strip_bookmark
&& (name_done(after, "<bookmark") || name_done(after, "</bookmark")));
|| (is_edge
&& (name_done(after, "<bookmark")
|| name_done(after, "</bookmark")
|| name_done(after, "<mstts:express-as")
|| name_done(after, "</mstts:express-as")));
if drop {
if let Some(end) = after.find('>') {
out.push_str(&rest[..pos]);
Expand Down Expand Up @@ -3618,6 +3626,20 @@ mod tests {
);
}

#[test]
fn test_express_as_kept_for_azure_tags_dropped_for_edge() {
// SpeechMarkdown #[style] sections become mstts:express-as wrappers.
let styled = "<mstts:express-as style=\"angry\">I am angry!</mstts:express-as>";
assert_eq!(strip_unsupported_marks(styled, false), styled);
// Edge zero-audios on express-as; the tags go, the spoken text stays.
assert_eq!(strip_unsupported_marks(styled, true), "I am angry!");
// Sibling mstts elements are not touched.
assert_eq!(
strip_unsupported_marks("<mstts:backgroundaudio src='x'/>", true),
"<mstts:backgroundaudio src='x'/>"
);
}

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

#[test]
Expand Down
Loading