TTS Studio: Zahlen, Daten und Telefonnummern ausschreiben

Neues Modul normalize.rs wandelt vor dem Sprechen Datum (1.10. -> ersten
Oktober, mit Jänner), Uhrzeiten und Bereiche, Telefonnummern (ziffernweise,
Gruppen mit Punkt getrennt), Euro-Beträge, Ordnungszahlen und gängige
Abkürzungen in Worte. In der GUI abschaltbar, mit Vorschau des gesprochenen
Textes. Hörproben der Telefonnummer-Varianten unter qwen3_samples/telefon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
renatoandClaude Opus 5.5 committed 2026-10-23 22:50:24 +02:00
1 parent d12897a928
commit b3b38f1fe1
9 files changed
+464 -4

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+39
View File
@@ -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",
+1
View File
@@ -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"] }
+14 -1
View File
@@ -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<String>,
status: Status,
result: Option<Rendered>,
@@ -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| {
+379
View File
@@ -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<Regex> = 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>) -> 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<String> = 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<Vec<(Regex, &str, bool)>> = 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");
}
}
+31 -3
View File
@@ -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<Part> {
parts
}
/// Text mit Pausen zerlegen und optional Zahlen usw. ausschreiben.
pub fn prepare(text: &str, normalize: bool) -> Vec<Part> {
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::<Vec<_>>()
.join(" ")
}
fn break_ms(tag: &str) -> Option<u32> {
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<Rendered, String> {
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"));