diff --git a/qwen3_samples/telefon/a_gruppen_komma.wav b/qwen3_samples/telefon/a_gruppen_komma.wav new file mode 100644 index 0000000..514bf36 Binary files /dev/null and b/qwen3_samples/telefon/a_gruppen_komma.wav differ diff --git a/qwen3_samples/telefon/b_ziffern_komma.wav b/qwen3_samples/telefon/b_ziffern_komma.wav new file mode 100644 index 0000000..d44ee08 Binary files /dev/null and b/qwen3_samples/telefon/b_ziffern_komma.wav differ diff --git a/qwen3_samples/telefon/c_gruppen_pause.wav b/qwen3_samples/telefon/c_gruppen_pause.wav new file mode 100644 index 0000000..3f60426 Binary files /dev/null and b/qwen3_samples/telefon/c_gruppen_pause.wav differ diff --git a/qwen3_samples/telefon/d_ziffern_komma_pause.wav b/qwen3_samples/telefon/d_ziffern_komma_pause.wav new file mode 100644 index 0000000..32add17 Binary files /dev/null and b/qwen3_samples/telefon/d_ziffern_komma_pause.wav differ diff --git a/tts-studio/Cargo.lock b/tts-studio/Cargo.lock index 57e531a..56b7887 100644 --- a/tts-studio/Cargo.lock +++ b/tts-studio/Cargo.lock @@ -145,6 +145,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -3070,6 +3079,35 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3978,6 +4016,7 @@ dependencies = [ "dirs", "eframe", "hound", + "regex", "rfd", "rodio", "serde", diff --git a/tts-studio/Cargo.toml b/tts-studio/Cargo.toml index c201e02..eefb4a8 100644 --- a/tts-studio/Cargo.toml +++ b/tts-studio/Cargo.toml @@ -8,6 +8,7 @@ base64 = "0.23" dirs = "7" eframe = "0.37" hound = "3.5" +regex = "1.13.1" rfd = "0.17" rodio = "0.23" serde = { version = "1", features = ["derive"] } diff --git a/tts-studio/src/main.rs b/tts-studio/src/main.rs index 60e68e5..6b5df22 100644 --- a/tts-studio/src/main.rs +++ b/tts-studio/src/main.rs @@ -5,6 +5,7 @@ mod audio; mod config; mod http; mod media; +mod normalize; mod synth; mod voices; @@ -67,6 +68,7 @@ struct App { speed: f32, sentence_pause: f32, comma_pause: f32, + normalize: bool, busy: Option, status: Status, result: Option, @@ -101,6 +103,7 @@ impl App { speed: 1.0, sentence_pause: 0.8, comma_pause: 0.0, + normalize: true, target: Target::AsteriskWav, busy: None, status: config_error.map_or(Status::None, Status::Error), @@ -209,6 +212,7 @@ impl App { speed: self.speed, sentence_pause_ms: (self.sentence_pause * 1000.0).round() as u32, comma_pause_ms: (self.comma_pause * 1000.0).round() as u32, + normalize: self.normalize, }; self.player.stop(); self.busy = Some("Starte …".into()); @@ -356,7 +360,8 @@ impl App { self.insert_pause(tag); } } - ui.label(RichText::new("Zahlen am besten ausschreiben („acht Uhr dreißig“)").weak().small()); + ui.checkbox(&mut self.normalize, "Zahlen ausschreiben") + .on_hover_text("Datum (1.10.), Uhrzeit (8:30), Telefonnummern, Euro-Beträge, Ordnungszahlen und Abkürzungen (Dr., z. B.) in Worte fassen"); }); let editor = egui::TextEdit::multiline(&mut self.text) .desired_rows(9) @@ -366,6 +371,14 @@ impl App { if let Some(range) = editor.cursor_range { self.cursor = Some(range.primary.index.0); } + if self.normalize { + let spoken = synth::spoken_preview(&self.text, true); + if spoken != synth::spoken_preview(&self.text, false) { + egui::CollapsingHeader::new("So wird gesprochen").default_open(true).show(ui, |ui| { + ui.label(RichText::new(spoken).weak()); + }); + } + } ui.add_space(8.0); ui.horizontal(|ui| { diff --git a/tts-studio/src/normalize.rs b/tts-studio/src/normalize.rs new file mode 100644 index 0000000..a4f2875 --- /dev/null +++ b/tts-studio/src/normalize.rs @@ -0,0 +1,379 @@ +//! Text fuer die Sprachausgabe aufbereiten: Datum, Uhrzeit, Telefonnummern, Geldbetraege, +//! Ordnungszahlen und gaengige Abkuerzungen ausschreiben (oesterreichisch: "Jänner"). +//! Was nicht sicher erkannt wird, bleibt unveraendert. + +use regex::{Captures, Regex}; +use std::sync::LazyLock; + +macro_rules! re { + ($s:literal) => {{ + static RE: LazyLock = LazyLock::new(|| Regex::new($s).unwrap()); + &*RE + }}; +} + +const UNITS: [&str; 20] = [ + "null", "eins", "zwei", "drei", "vier", "fünf", "sechs", "sieben", "acht", "neun", "zehn", "elf", "zwölf", "dreizehn", "vierzehn", + "fünfzehn", "sechzehn", "siebzehn", "achtzehn", "neunzehn", +]; +const TENS: [&str; 10] = ["", "", "zwanzig", "dreißig", "vierzig", "fünfzig", "sechzig", "siebzig", "achtzig", "neunzig"]; +const MONTHS: [&str; 12] = + ["Jänner", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]; +const WEEKDAYS: [(&str, &str); 7] = [ + ("Mo", "Montag"), ("Di", "Dienstag"), ("Mi", "Mittwoch"), ("Do", "Donnerstag"), ("Fr", "Freitag"), ("Sa", "Samstag"), ("So", "Sonntag"), +]; +/// Woerter vor einer Ordnungszahl; danach Endung "-e" (der erste), sonst "-en" (am ersten) +const ORDINAL_E: &[&str] = &["der", "die", "das", "jede", "jeder", "jedes"]; +const ORDINAL_EN: &[&str] = &["am", "im", "vom", "zum", "beim", "ab", "bis", "seit", "den", "dem", "des", "jeden", "jedem"]; + +/// Grundzahl in Worten, z. B. 21 -> "einundzwanzig", 1 -> "eins" +pub fn cardinal(n: u64) -> String { + fn below100(n: u64) -> String { + match n { + 0..20 => UNITS[n as usize].into(), + _ if n % 10 == 0 => TENS[(n / 10) as usize].into(), + _ => format!("{}und{}", if n % 10 == 1 { "ein" } else { UNITS[(n % 10) as usize] }, TENS[(n / 10) as usize]), + } + } + // Teil < 1000 als Vorsilbe ("ein" statt "eins"), leer bei 0 + fn below1000(n: u64) -> String { + let (h, r) = (n / 100, n % 100); + let mut s = String::new(); + if h > 0 { + s += if h == 1 { "ein" } else { UNITS[h as usize] }; + s += "hundert"; + } + if r > 0 { + s += &below100(r); + } + s + } + fn prefix(n: u64) -> String { + let s = below1000(n); + s.strip_suffix("eins").map(|p| format!("{p}ein")).unwrap_or(s) + } + match n { + 0..20 => UNITS[n as usize].into(), + 20..1000 => below1000(n), + 1000..1_000_000 => format!("{}tausend{}", prefix(n / 1000), below1000(n % 1000)), + _ => { + let m = n / 1_000_000; + let millions = if m == 1 { "eine Million".into() } else { format!("{} Millionen", cardinal(m)) }; + if n % 1_000_000 == 0 { millions } else { format!("{millions} {}", cardinal(n % 1_000_000)) } + } + } +} + +/// Grundzahl vor einem Hauptwort: "ein Uhr", "ein Euro" +fn cardinal_attr(n: u64) -> String { + if n == 1 { "ein".into() } else { cardinal(n) } +} + +/// Ordnungszahl mit Endung, z. B. (1, "en") -> "ersten", (22, "e") -> "zweiundzwanzigste" +fn ordinal(n: u64, ending: &str) -> String { + let stem = match n { + 1 => "erst".into(), + 3 => "dritt".into(), + 7 => "siebt".into(), + 8 => "acht".into(), + 0..20 => format!("{}t", cardinal(n)), + _ => format!("{}st", cardinal(n)), + }; + format!("{stem}{ending}") +} + +fn year(y: u64) -> String { + if (1100..2000).contains(&y) { + let r = y % 100; + format!("{}hundert{}", cardinal(y / 100), if r == 0 { String::new() } else { cardinal(r) }) + } else { + cardinal(y) + } +} + +/// Endung der Ordnungszahl nach dem vorangehenden Wort +fn ordinal_ending(before: &str) -> &'static str { + if ORDINAL_E.contains(&prev_word(before).as_str()) { "e" } else { "en" } +} + +fn prev_word(before: &str) -> String { + let t = before.trim_end(); + let start = t.rfind(|c: char| !c.is_alphanumeric()).map_or(0, |i| i + t[i..].chars().next().map_or(1, char::len_utf8)); + t[start..].to_lowercase() +} + +/// Steht nach einem Punkt, den die Ersetzung verschluckt, ein neuer Satz? Dann Punkt behalten. +fn sentence_end(after: &str) -> &'static str { + let next = after.trim_start().chars().next(); + let ends = match next { + None => true, + Some(c) => after.starts_with(char::is_whitespace) && c.is_uppercase(), + }; + if ends { "." } else { "" } +} + +fn num(c: &Captures, i: usize) -> u64 { + c.get(i).map_or(0, |m| m.as_str().replace('.', "").parse().unwrap_or(0)) +} + +/// Alle Treffer von `re` ersetzen; `f` bekommt Treffer, Text davor und danach, None laesst den Treffer stehen. +fn replace(text: &str, re: &Regex, f: impl Fn(&Captures, &str, &str) -> Option) -> String { + let mut out = String::with_capacity(text.len()); + let mut last = 0; + for c in re.captures_iter(text) { + let m = c.get(0).unwrap(); + if let Some(r) = f(&c, &text[..m.start()], &text[m.end()..]) { + out.push_str(&text[last..m.start()]); + out.push_str(&r); + last = m.end(); + } + } + out.push_str(&text[last..]); + out +} + +/// Ziffern einzeln mit Komma, Gruppen mit Punkt getrennt – so spricht das Modell langsam und +/// deutlich: "0664 1234567" -> "null, sechs, sechs, vier. Eins, zwei, drei. Vier, fünf, sechs, sieben" +fn digits_spoken(groups: &[&str]) -> String { + let mut parts: Vec = Vec::new(); + for g in groups { + let d: Vec<&str> = g.chars().filter_map(|c| c.to_digit(10)).map(|d| UNITS[d as usize]).collect(); + // lange Bloecke in Dreiergruppen teilen, Rest von 1 an die letzte Gruppe haengen + let mut i = 0; + while i < d.len() { + let rest = d.len() - i; + let take = if rest <= 4 { rest } else { 3 }; + parts.push(d[i..i + take].join(", ")); + i += take; + } + } + for p in parts.iter_mut().skip(1) { + let mut c = p.chars(); + *p = c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default(); + } + parts.join(". ") +} + +fn phone(text: &str) -> String { + let t = replace(text, re!(r"(?:\+\d{1,3}|\b0\d+)(?:[ \t]*(?:\(0\)|/|-)?[ \t]*\d+)*"), |c, _, _| { + let s = c.get(0).unwrap().as_str().replace("(0)", " "); + let digits = s.chars().filter(char::is_ascii_digit).count(); + if digits < 6 { + return None; + } + let groups: Vec<&str> = s.split(|c: char| !c.is_ascii_digit()).filter(|g| !g.is_empty()).collect(); + let plus = if s.starts_with('+') { "plus " } else { "" }; + Some(format!("{plus}{}", digits_spoken(&groups))) + }); + // nach Stichwort auch kurze Nummern ziffernweise: "Durchwahl 23", "Taste 1" + replace( + &t, + re!(r"\b((?:Durchwahl|Klappe|Nebenstelle|Taste|Telefon|Telefonnummer|Rufnummer|Fax|Faxnummer|Handy|Handynummer|Mobil|Mobilnummer)\b[.:]?[ \t]*)(\d+(?:[ \t]*[/-]?[ \t]*\d+)*)"), + |c, _, _| { + let groups: Vec<&str> = c[2].split(|c: char| !c.is_ascii_digit()).filter(|g| !g.is_empty()).collect(); + Some(format!("{}{}", &c[1], digits_spoken(&groups))) + }, + ) +} + +fn abbreviations(text: &str) -> String { + // (Muster, Ersatz, kann Satzende sein) + static LIST: LazyLock> = LazyLock::new(|| { + [ + (r"\bz\.[ \t]?B\.", "zum Beispiel", false), + (r"\bd\.[ \t]?h\.", "das heißt", false), + (r"\bu\.[ \t]?a\.", "unter anderem", true), + (r"\bDipl\.-Ing\.", "Diplomingenieur", false), + (r"\bMag\.a\b", "Magistra", false), + (r"\bMag\.", "Magister", false), + (r"\bDr\.", "Doktor", false), + (r"\bDres\.", "Doktores", false), + (r"\bProf\.", "Professor", false), + (r"\bTel\.", "Telefon", false), + (r"\bNr\.", "Nummer", false), + (r"\bStr\.", "Straße", false), + (r"\bbzw\.", "beziehungsweise", false), + (r"\bca\.", "circa", false), + (r"\binkl\.", "inklusive", false), + (r"\bevtl\.", "eventuell", false), + (r"\bggf\.", "gegebenenfalls", false), + (r"\busw\.", "und so weiter", true), + (r"\betc\.", "et cetera", true), + ] + .into_iter() + .map(|(p, r, end)| (Regex::new(p).unwrap(), r, end)) + .collect() + }); + let mut t = text.to_string(); + for (re, word, can_end) in LIST.iter() { + t = replace(&t, re, |_, _, after| Some(format!("{word}{}", if *can_end { sentence_end(after) } else { "" }))); + } + t +} + +fn weekdays(text: &str) -> String { + let full = |abbr: &str| WEEKDAYS.iter().find(|(a, _)| *a == abbr).map_or("", |(_, f)| *f); + let t = replace(text, re!(r"\b(Mo|Di|Mi|Do|Fr|Sa|So)\.?[ \t]*[-–][ \t]*(Mo|Di|Mi|Do|Fr|Sa|So)\b\.?"), |c, _, _| { + Some(format!("{} bis {}", full(&c[1]), full(&c[2]))) + }); + // einzeln nur, wenn eindeutig ein Wochentag gemeint ist ("Mo, Mi: 8 bis 12"); "So" nur mit Punkt + replace(&t, re!(r"\b(?:(Mo|Di|Mi|Do|Fr|Sa)\.?|(So)\.)([ \t]*(?:[,:/&]|\d|und\b))"), |c, _, _| { + let abbr = c.get(1).or(c.get(2)).unwrap().as_str(); + Some(format!("{}{}", full(abbr), &c[3])) + }) +} + +fn dates(text: &str) -> String { + replace(text, re!(r"\b(\d{1,2})\.[ \t]?(\d{1,2})\.(\d{4}\b|\d{2}\b)?"), |c, before, after| { + let (d, m) = (num(c, 1), num(c, 2)); + if !(1..=31).contains(&d) || !(1..=12).contains(&m) { + return None; + } + let day = format!("{} {}", ordinal(d, ordinal_ending(before)), MONTHS[m as usize - 1]); + Some(match c.get(3) { + Some(y) => { + let y: u64 = y.as_str().parse().unwrap_or(0); + format!("{day} {}", year(if y < 100 { 2000 + y } else { y })) + } + None => format!("{day}{}", sentence_end(after)), + }) + }) +} + +fn time_words(h: u64, m: u64) -> String { + if m == 0 { format!("{} Uhr", cardinal_attr(h)) } else { format!("{} Uhr {}", cardinal_attr(h), cardinal(m)) } +} + +fn times(text: &str) -> String { + let t = replace(text, re!(r"\b([01]?\d|2[0-4]):([0-5]\d)\b([ \t]*Uhr\b)?"), |c, _, _| Some(time_words(num(c, 1), num(c, 2)))); + // 8.30 nur mit "Uhr", nach um/ab/von/bis oder vor "bis 17.30" + let t = replace(&t, re!(r"\b([01]?\d|2[0-4])\.([0-5]\d)\b([ \t]*Uhr\b)?"), |c, before, after| { + let clear = c.get(3).is_some() + || ["um", "ab", "von", "bis", "gegen"].contains(&prev_word(before).as_str()) + || re!(r"^[ \t]*bis[ \t]+\d{1,2}[.:]\d{2}").is_match(after); + clear.then(|| time_words(num(c, 1), num(c, 2))) + }); + replace(&t, re!(r"\b([01]?\d|2[0-4])[ \t]*Uhr\b"), |c, _, _| Some(format!("{} Uhr", cardinal_attr(num(c, 1))))) +} + +fn money_words(euro: u64, cents: Option<&str>) -> String { + let cents: u64 = cents.and_then(|c| c.parse().ok()).unwrap_or(0); + match (euro, cents) { + (0, c) if c > 0 => format!("{} Cent", cardinal(c)), + (e, 0) => format!("{} Euro", cardinal_attr(e)), + (e, c) => format!("{} Euro {}", cardinal_attr(e), cardinal(c)), + } +} + +fn money(text: &str) -> String { + let t = replace(text, re!(r"\b(\d{1,3}(?:\.\d{3})+|\d+)(?:,(\d{2}|-{1,2}))?[ \t]*(?:€|EUR\b|Euro\b)"), |c, _, _| { + Some(money_words(num(c, 1), c.get(2).map(|m| m.as_str()))) + }); + replace(&t, re!(r"(?:€|\bEUR)[ \t]*(\d{1,3}(?:\.\d{3})+|\d+)(?:,(\d{2}|-{1,2}))?"), |c, _, _| { + Some(money_words(num(c, 1), c.get(2).map(|m| m.as_str()))) + }) +} + +/// "am 22. Dezember", "im 2. Stock", "der 3. Mai 2026"; "Tür 14." bleibt eine Grundzahl +fn ordinals(text: &str) -> String { + replace(text, re!(r"\b(\d{1,3})\.([ \t]+)(\p{L}+)(?:([ \t]+)(\d{4})\b)?"), |c, before, _| { + let n = num(c, 1); + let month = MONTHS.contains(&&c[3]) || c[3] == *"Januar"; + if n == 0 || !(month || ORDINAL_E.contains(&prev_word(before).as_str()) || ORDINAL_EN.contains(&prev_word(before).as_str())) { + return None; + } + let mut s = format!("{}{}{}", ordinal(n, ordinal_ending(before)), &c[2], &c[3]); + if let (Some(sp), Some(y)) = (c.get(4), c.get(5)) { + s += sp.as_str(); + s += &if month { year(y.as_str().parse().unwrap_or(0)) } else { y.as_str().into() }; + } + Some(s) + }) +} + +/// Bereiche mit Bindestrich: "8-12 Uhr", "22.12.-6.1." -> "bis" +fn ranges(text: &str) -> String { + re!(r"(\d\.?)[ \t]*[-–][ \t]*(\d)").replace_all(text, "$1 bis $2").into_owned() +} + +/// Gesamter Ablauf; die Reihenfolge ist wichtig (Telefonnummern vor Bereichen, Daten vor Uhrzeiten). +pub fn normalize(text: &str) -> String { + let t = abbreviations(text); + let t = phone(&t); + let t = weekdays(&t); + let t = ranges(&t); + let t = money(&t); + let t = dates(&t); + let t = times(&t); + ordinals(&t) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zahlwoerter() { + let cases = [ + (0, "null"), (1, "eins"), (16, "sechzehn"), (21, "einundzwanzig"), (30, "dreißig"), (101, "einhunderteins"), + (999, "neunhundertneunundneunzig"), (1000, "eintausend"), (1001, "eintausendeins"), (21500, "einundzwanzigtausendfünfhundert"), + (2_000_000, "zwei Millionen"), + ]; + for (n, w) in cases { + assert_eq!(cardinal(n), w, "{n}"); + } + assert_eq!(year(1999), "neunzehnhundertneunundneunzig"); + assert_eq!(year(2026), "zweitausendsechsundzwanzig"); + assert_eq!(ordinal(1, "en"), "ersten"); + assert_eq!(ordinal(22, "e"), "zweiundzwanzigste"); + assert_eq!(ordinal(31, "en"), "einunddreißigsten"); + } + + #[test] + fn datum() { + assert_eq!(normalize("Wir haben am 1.10. geschlossen."), "Wir haben am ersten Oktober geschlossen."); + assert_eq!(normalize("Vom 22.12. bis 6.1. ist zu."), "Vom zweiundzwanzigsten Dezember bis sechsten Jänner ist zu."); + assert_eq!(normalize("Urlaub 22.12.-6.1."), "Urlaub zweiundzwanzigsten Dezember bis sechsten Jänner."); + assert_eq!(normalize("Ab 1.10.2026 neue Zeiten"), "Ab ersten Oktober zweitausendsechsundzwanzig neue Zeiten"); + assert_eq!(normalize("Der 3.5.26 ist frei"), "Der dritte Mai zweitausendsechsundzwanzig ist frei"); + // Punkt am Satzende bleibt erhalten + assert_eq!(normalize("Geschlossen bis 6.1. Danke!"), "Geschlossen bis sechsten Jänner. Danke!"); + assert_eq!(normalize("Geschlossen bis 6.1."), "Geschlossen bis sechsten Jänner."); + assert_eq!(normalize("am 22. Dezember 2026"), "am zweiundzwanzigsten Dezember zweitausendsechsundzwanzig"); + // keine Daten + assert_eq!(normalize("Version 3.45."), "Version 3.45."); + } + + #[test] + fn uhrzeit() { + assert_eq!(normalize("Montag von 8:30 bis 17:30 Uhr."), "Montag von acht Uhr dreißig bis siebzehn Uhr dreißig."); + assert_eq!(normalize("Mo-Fr 8.30 - 12.00 Uhr"), "Montag bis Freitag acht Uhr dreißig bis zwölf Uhr"); + assert_eq!(normalize("ab 13.30 erreichbar"), "ab dreizehn Uhr dreißig erreichbar"); + assert_eq!(normalize("um 1 Uhr und 8-12 Uhr"), "um ein Uhr und 8 bis zwölf Uhr"); + assert_eq!(normalize("Di, Do: 9:05"), "Dienstag, Donnerstag: neun Uhr fünf"); + assert_eq!(normalize("So erreichen Sie uns"), "So erreichen Sie uns"); + } + + #[test] + fn telefon() { + assert_eq!( + normalize("Rufen Sie 0664 1234567 an."), + "Rufen Sie null, sechs, sechs, vier. Eins, zwei, drei. Vier, fünf, sechs, sieben an." + ); + assert_eq!(normalize("+43 (0) 1 234 56 78"), "plus vier, drei. Eins. Zwei, drei, vier. Fünf, sechs. Sieben, acht"); + assert_eq!(normalize("Tel. 01/234 56 78"), "Telefon null, eins. Zwei, drei, vier. Fünf, sechs. Sieben, acht"); + assert_eq!(normalize("0732-123456"), "null, sieben, drei, zwei. Eins, zwei, drei. Vier, fünf, sechs"); + assert_eq!(normalize("Durchwahl 23 oder Taste 1"), "Durchwahl zwei, drei oder Taste eins"); + // kurze Zahlen mit 0 sind keine Telefonnummern + assert_eq!(normalize("um 08:00"), "um acht Uhr"); + } + + #[test] + fn geld_ordnungszahlen_abkuerzungen() { + assert_eq!(normalize("Das kostet 12,50 €."), "Das kostet zwölf Euro fünfzig."); + assert_eq!(normalize("€ 1.200,- und 0,80 EUR und 1 Euro"), "eintausendzweihundert Euro und achtzig Cent und ein Euro"); + assert_eq!(normalize("Im 2. Stock, Tür 14."), "Im zweiten Stock, Tür 14."); + assert_eq!(normalize("Dr. Ahmad, z. B. morgen usw. Danke"), "Doktor Ahmad, zum Beispiel morgen und so weiter. Danke"); + assert_eq!(normalize("Mag.a Huber"), "Magistra Huber"); + } +} diff --git a/tts-studio/src/synth.rs b/tts-studio/src/synth.rs index 754c56e..2bcc733 100644 --- a/tts-studio/src/synth.rs +++ b/tts-studio/src/synth.rs @@ -3,7 +3,7 @@ use crate::config::Model; use crate::voices::{REF_SR, Voice}; -use crate::{http, media}; +use crate::{http, media, normalize}; use base64::Engine; use serde_json::json; use std::path::{Path, PathBuf}; @@ -70,6 +70,8 @@ pub struct Job { pub sentence_pause_ms: u32, /// Pause nach , ; : (0 = nicht teilen) pub comma_pause_ms: u32, + /// Datum, Uhrzeit, Telefonnummern usw. ausschreiben + pub normalize: bool, } /// Gleichzeitige Anfragen (Server verarbeitet bis zu 8 parallel) @@ -122,6 +124,31 @@ pub fn parse_text(input: &str) -> Vec { parts } +/// Text mit Pausen zerlegen und optional Zahlen usw. ausschreiben. +pub fn prepare(text: &str, normalize: bool) -> Vec { + let mut parts = parse_text(text); + if normalize { + for p in &mut parts { + if let Part::Text(t) = p { + *t = normalize::normalize(t); + } + } + } + parts +} + +/// So geht der Text ans Modell (Pausen als [0,5 s]) – fuer die Vorschau in der Oberflaeche. +pub fn spoken_preview(text: &str, normalize: bool) -> String { + prepare(text, normalize) + .iter() + .map(|p| match p { + Part::Text(t) => t.clone(), + Part::Pause(ms) => format!("[{} s]", format!("{:.1}", *ms as f32 / 1000.0).replace('.', ",")), + }) + .collect::>() + .join(" ") +} + fn break_ms(tag: &str) -> Option { let t = tag.trim().trim_end_matches('/').trim().to_ascii_lowercase(); let t = t.strip_prefix("break")?; @@ -214,7 +241,7 @@ fn speak(url: &str, request: &serde_json::Value, text: &str, job: &Job) -> Resul /// Erzeugt die Ansage im Arbeitsordner `work`; `progress` meldet Zwischenstaende. pub fn render(job: &Job, work: &Path, progress: &dyn Fn(String)) -> Result { - let parts = split_sentences(parse_text(&job.text), job.sentence_pause_ms, job.comma_pause_ms); + let parts = split_sentences(prepare(&job.text, job.normalize), job.sentence_pause_ms, job.comma_pause_ms); let texts: Vec<&str> = parts.iter().filter_map(|p| if let Part::Text(t) = p { Some(t.as_str()) } else { None }).collect(); if texts.is_empty() { return Err("Kein Text eingegeben.".into()); @@ -344,6 +371,7 @@ mod dgx_tests { speed: 1.0, sentence_pause_ms: 800, comma_pause_ms: 0, + normalize: true, }; let t0 = std::time::Instant::now(); let r = render(&job, &work, &|m| println!(" {m}")).unwrap(); @@ -417,7 +445,7 @@ mod tempo_tests { Leider rufen Sie außerhalb der Ordinationszeiten an. Unsere Ordinationszeiten sind Montag und Dienstag von acht Uhr dreißig bis siebzehn Uhr dreißig. \ Mittwoch und Freitag von acht Uhr dreißig bis vierzehn Uhr. Vielen Dank!"; for (speed, sentence, comma) in [(1.0, 0, 0), (1.0, 800, 0), (0.9, 800, 0), (1.0, 800, 300)] { - let job = Job { model: cfg.models[0].clone(), voice: voice.clone(), text: text.into(), music: None, target: Target::WhatsApp, speed, sentence_pause_ms: sentence, comma_pause_ms: comma }; + let job = Job { model: cfg.models[0].clone(), voice: voice.clone(), text: text.into(), music: None, target: Target::WhatsApp, speed, sentence_pause_ms: sentence, comma_pause_ms: comma, normalize: true }; let t0 = std::time::Instant::now(); let r = render(&job, &work, &|_| {}).unwrap(); let name = work.join(format!("tempo{speed}_satz{sentence}_komma{comma}.wav"));