Files
tts/tts-studio/src/main.rs
T
renatoandClaude Opus 5.5 b3b38f1fe1 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>
2026-10-23 22:50:24 +02:00

578 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TTS Studio: Ansagen mit Qwen3-TTS (DGX) erzeugen – Stimme waehlen oder klonen,
//! Text eingeben, optional Hintergrundmusik, fuer Asterisk oder WhatsApp speichern.
mod audio;
mod config;
mod http;
mod media;
mod normalize;
mod synth;
mod voices;
use config::Config;
use eframe::egui::{self, Color32, RichText};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use synth::{Job, Music, Rendered, Target};
use voices::{Prepared, Voice};
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("TTS Studio")
.with_app_id("tts-studio")
.with_inner_size([780.0, 760.0])
.with_min_inner_size([600.0, 560.0]),
..Default::default()
};
eframe::run_native("TTS Studio", options, Box::new(|cc| Ok(Box::new(App::new(&cc.egui_ctx)))))
}
/// Nachrichten der Hintergrund-Threads an die Oberflaeche
enum Msg {
Progress(String),
Rendered(Result<Rendered, String>),
Server { url: String, online: bool },
Prepared(Result<Prepared, String>),
}
enum Status {
None,
Info(String),
Error(String),
}
/// Fenster "Neue Stimme anlegen"
#[derive(Default)]
struct NewVoice {
name: String,
recording: Option<audio::Recording>,
busy: bool,
prepared: Option<Prepared>,
transcript: String,
error: Option<String>,
}
struct App {
cfg: Config,
model_idx: usize,
voices: Vec<Voice>,
voice_idx: usize,
text: String,
cursor: Option<usize>,
music_on: bool,
music_file: PathBuf,
music_volume: f32,
intro_secs: f32,
target: Target,
speed: f32,
sentence_pause: f32,
comma_pause: f32,
normalize: bool,
busy: Option<String>,
status: Status,
result: Option<Rendered>,
servers: Vec<(String, String, Option<bool>)>, // Bezeichnung, URL, online?
reading_text: String,
new_voice: Option<NewVoice>,
player: audio::Player,
work: PathBuf,
tx: Sender<Msg>,
rx: Receiver<Msg>,
ctx: egui::Context,
}
impl App {
fn new(ctx: &egui::Context) -> Self {
let (cfg, config_error) = config::load();
let (tx, rx) = channel();
let work = std::env::temp_dir().join(format!("tts-studio-{}", std::process::id()));
let _ = std::fs::create_dir_all(&work);
let reading_text = std::fs::read_to_string(&cfg.reading_text_file).unwrap_or_default().trim().to_string();
let mut app = App {
music_file: cfg.music_file.clone(),
cfg,
model_idx: 0,
voices: Vec::new(),
voice_idx: 0,
text: String::new(),
cursor: None,
music_on: false,
music_volume: 0.15,
intro_secs: 2.0,
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),
result: None,
servers: Vec::new(),
reading_text,
new_voice: None,
player: audio::Player::default(),
work,
tx,
rx,
ctx: ctx.clone(),
};
app.select_model(0);
app
}
fn model(&self) -> &config::Model {
&self.cfg.models[self.model_idx]
}
fn select_model(&mut self, idx: usize) {
self.model_idx = idx;
self.reload_voices(None);
self.check_servers();
}
/// Klonstimmen + feste Stimmen des Modells; `select` waehlt eine Stimme nach Namen aus.
fn reload_voices(&mut self, select: Option<&str>) {
let previous = self.voices.get(self.voice_idx).map(Voice::label);
let model = self.model().clone();
self.voices = Vec::new();
if model.clone_url.is_some() {
self.voices.extend(voices::list_clones(&self.cfg.voice_dir));
}
if model.voice_url.is_some() {
self.voices.extend(model.voices.iter().cloned().map(Voice::Preset));
}
let wanted = select.map(String::from).or(previous);
self.voice_idx = wanted.and_then(|w| self.voices.iter().position(|v| v.label() == w || matches!(v, Voice::Clone { name, .. } if *name == w))).unwrap_or(0);
}
fn check_servers(&mut self) {
let model = self.model().clone();
self.servers = [("Klonstimmen", model.clone_url), ("Feste Stimmen", model.voice_url)]
.into_iter()
.filter_map(|(label, url)| url.map(|u| (label.to_string(), u, None)))
.collect();
for (_, url, _) in &self.servers {
let (tx, ctx, url) = (self.tx.clone(), self.ctx.clone(), url.clone());
std::thread::spawn(move || {
let online = http::reachable(&config::models_url(&url));
let _ = tx.send(Msg::Server { url, online });
ctx.request_repaint();
});
}
}
fn poll(&mut self) {
while let Ok(msg) = self.rx.try_recv() {
match msg {
Msg::Progress(p) => self.busy = Some(p),
Msg::Rendered(r) => {
self.busy = None;
match r {
Ok(r) => {
self.status = Status::Info(format!("Fertig: {:.1} s ({}). Jetzt anhören oder speichern.", r.secs, r.target.label()));
self.player.play(&r.preview);
self.result = Some(r);
}
Err(e) => self.status = Status::Error(e),
}
}
Msg::Server { url, online } => {
for s in self.servers.iter_mut().filter(|s| s.1 == url) {
s.2 = Some(online);
}
}
Msg::Prepared(r) => {
if let Some(nv) = &mut self.new_voice {
nv.busy = false;
match r {
Ok(p) => {
nv.transcript = p.transcript.clone();
nv.prepared = Some(p);
}
Err(e) => nv.error = Some(e),
}
}
}
}
}
}
fn generate(&mut self) {
let Some(voice) = self.voices.get(self.voice_idx).cloned() else {
self.status = Status::Error("Keine Stimme verfügbar.".into());
return;
};
let job = Job {
model: self.model().clone(),
voice,
text: self.text.clone(),
music: self.music_on.then(|| Music { file: self.music_file.clone(), volume: self.music_volume, intro_secs: self.intro_secs }),
target: self.target,
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());
self.status = Status::None;
let (tx, ctx, work) = (self.tx.clone(), self.ctx.clone(), self.work.clone());
std::thread::spawn(move || {
let progress = |m: String| {
let _ = tx.send(Msg::Progress(m));
ctx.request_repaint();
};
let r = synth::render(&job, &work, &progress);
let _ = tx.send(Msg::Rendered(r));
ctx.request_repaint();
});
}
fn save_result(&mut self) {
let Some(r) = &self.result else { return };
let ext = r.target.extension();
let voice = self.voices.get(self.voice_idx).map(|v| match v {
Voice::Clone { name, .. } | Voice::Preset(name) => name.clone(),
});
let suggested = format!("ansage_{}.{ext}", voice.unwrap_or_default());
let Some(mut path) = rfd::FileDialog::new()
.set_title("Ansage speichern")
.set_directory(&self.cfg.output_dir)
.set_file_name(&suggested)
.add_filter(r.target.label(), &[ext])
.save_file()
else {
return;
};
if path.extension().and_then(|e| e.to_str()) != Some(ext) {
path.set_extension(ext);
}
self.status = match std::fs::copy(&r.output, &path) {
Ok(_) => {
if let Some(dir) = path.parent() {
self.cfg.output_dir = dir.to_path_buf();
}
Status::Info(format!("Gespeichert: {}", path.display()))
}
Err(e) => Status::Error(format!("Speichern fehlgeschlagen: {e}")),
};
}
fn insert_pause(&mut self, tag: &str) {
let chars: Vec<char> = self.text.chars().collect();
let at = self.cursor.unwrap_or(chars.len()).min(chars.len());
let before: String = chars[..at].iter().collect();
let after: String = chars[at..].iter().collect();
let pad_l = if before.is_empty() || before.ends_with(' ') { "" } else { " " };
let pad_r = if after.starts_with(' ') { "" } else { " " };
self.text = format!("{before}{pad_l}{tag}{pad_r}{after}");
self.cursor = Some(at + pad_l.len() + tag.chars().count() + pad_r.len());
}
fn main_ui(&mut self, ui: &mut egui::Ui) {
let busy = self.busy.is_some();
egui::Grid::new("form").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| {
ui.label("Modell");
ui.horizontal(|ui| {
let mut idx = self.model_idx;
egui::ComboBox::from_id_salt("model").width(260.0).selected_text(&self.model().name).show_ui(ui, |ui| {
for (i, m) in self.cfg.models.iter().enumerate() {
ui.selectable_value(&mut idx, i, &m.name);
}
});
if idx != self.model_idx {
self.select_model(idx);
}
for (label, _, online) in &self.servers {
let (color, tip) = match online {
None => (Color32::GRAY, "wird geprüft"),
Some(true) => (Color32::from_rgb(40, 160, 70), "erreichbar"),
Some(false) => (Color32::from_rgb(200, 50, 50), "nicht erreichbar – Server auf der DGX starten"),
};
ui.label(RichText::new(format!("● {label}")).color(color)).on_hover_text(tip);
}
if ui.small_button("🔄").on_hover_text("Server erneut prüfen").clicked() {
self.check_servers();
}
});
ui.end_row();
ui.label("Stimme");
ui.horizontal(|ui| {
let selected = self.voices.get(self.voice_idx).map(Voice::label).unwrap_or_else(|| "– keine –".into());
egui::ComboBox::from_id_salt("voice").width(260.0).selected_text(selected).show_ui(ui, |ui| {
for (i, v) in self.voices.iter().enumerate() {
ui.selectable_value(&mut self.voice_idx, i, v.label());
}
});
if let Some(Voice::Clone { audio, .. }) = self.voices.get(self.voice_idx)
&& ui.button("▶ Vorlage").on_hover_text("Aufnahme anhören, aus der die Stimme geklont wird").clicked() {
self.player.play(audio);
}
if ui.button("+ Neue Stimme …").clicked() && self.new_voice.is_none() {
self.new_voice = Some(NewVoice::default());
}
});
ui.end_row();
ui.label("Musik");
ui.horizontal(|ui| {
ui.checkbox(&mut self.music_on, "Hintergrundmusik");
ui.add_enabled_ui(self.music_on, |ui| {
let name = self.music_file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if ui.button(format!("📂 {name}")).on_hover_text(self.music_file.display().to_string()).clicked()
&& let Some(p) = rfd::FileDialog::new().add_filter("Audio", &["wav", "mp3", "ogg", "flac", "m4a"]).pick_file() {
self.music_file = p;
}
ui.add(egui::Slider::new(&mut self.music_volume, 0.02..=0.5).text("Lautstärke").fixed_decimals(2));
ui.add(egui::Slider::new(&mut self.intro_secs, 0.0..=5.0).text("s Vorlauf").fixed_decimals(1));
});
});
ui.end_row();
ui.label("Sprechweise");
ui.horizontal(|ui| {
ui.add(egui::Slider::new(&mut self.speed, 0.75..=1.25).step_by(0.05).fixed_decimals(2).suffix(" ×").text("Tempo"))
.on_hover_text("Tonhöhe bleibt gleich; Pausen sind davon nicht betroffen");
ui.add(egui::Slider::new(&mut self.sentence_pause, 0.0..=2.0).step_by(0.1).fixed_decimals(1).suffix(" s").text("nach Satzende"))
.on_hover_text("Pause nach . ! ? – 0 = Pausen wie vom Modell");
ui.add(egui::Slider::new(&mut self.comma_pause, 0.0..=1.0).step_by(0.1).fixed_decimals(1).suffix(" s").text("nach Komma"))
.on_hover_text("Pause nach , ; : – teilt den Satz dort, das Modell betont das Teilstück dann wie ein Satzende. 0 = aus");
});
ui.end_row();
ui.label("Ziel");
ui.horizontal(|ui| {
for t in Target::ALL {
ui.radio_value(&mut self.target, t, t.label());
}
});
ui.end_row();
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(RichText::new("Text").strong());
ui.add_space(8.0);
for (label, tag) in [("+ Pause 0,5 s", "<break time=\"500ms\"/>"), ("+ Pause 1 s", "<break time=\"1s\"/>")] {
if ui.small_button(label).on_hover_text("an der Cursorposition einfügen").clicked() {
self.insert_pause(tag);
}
}
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)
.desired_width(f32::INFINITY)
.hint_text("Text der Ansage …")
.show(ui);
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| {
let ctrl_enter = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter));
let can = !busy && !self.text.trim().is_empty() && !self.voices.is_empty();
if ui.add_enabled(can, egui::Button::new(RichText::new("🔊 Erzeugen").strong()).min_size([130.0, 30.0].into())).on_hover_text("Strg+Enter").clicked() || (can && ctrl_enter) {
self.generate();
}
if let Some(p) = &self.busy {
ui.spinner();
ui.label(p);
}
});
if let Some(r) = &self.result {
ui.add_space(6.0);
let (secs, label, preview) = (r.secs, r.target.label(), r.preview.clone());
ui.group(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("Ergebnis: {secs:.1} s · {label}")).strong());
if self.player.is_playing() {
if ui.button("⏹ Stopp").clicked() {
self.player.stop();
}
} else if ui.button("▶ Abspielen").clicked() {
self.player.play(&preview);
}
if ui.button("💾 Speichern unter …").clicked() {
self.save_result();
}
});
});
}
ui.add_space(6.0);
match &self.status {
Status::None => {}
Status::Info(m) => {
ui.label(m);
}
Status::Error(m) => {
ui.colored_label(Color32::from_rgb(200, 50, 50), m);
}
}
if let Some(e) = self.player.take_error() {
self.status = Status::Error(e);
}
}
fn new_voice_window(&mut self, ctx: &egui::Context) {
let Some(mut nv) = self.new_voice.take() else { return };
let mut open = true;
let mut saved: Option<Voice> = None;
egui::Window::new("Neue Stimme anlegen").open(&mut open).collapsible(false).default_width(600.0).show(ctx, |ui| {
ui.label(
"Aufnahme von ca. 15–25 s in ruhiger Umgebung. So sprechen, wie die Ansagen klingen sollen – \
das Modell übernimmt Tonfall und Tempo. Längere Aufnahmen werden automatisch in einer Sprechpause gekürzt.",
);
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Name");
ui.add(egui::TextEdit::singleline(&mut nv.name).hint_text("z. B. martina").desired_width(200.0));
});
ui.add_space(6.0);
let idle = !nv.busy && nv.recording.is_none();
ui.horizontal(|ui| {
if ui.add_enabled(idle, egui::Button::new("📂 Datei importieren …")).clicked()
&& let Some(p) = rfd::FileDialog::new().add_filter("Audio", &["wav", "mp3", "m4a", "ogg", "opus", "flac", "aac", "g722"]).pick_file() {
self.start_prepare(&mut nv, p);
}
ui.label("oder");
match &nv.recording {
None => {
if ui.add_enabled(idle, egui::Button::new("⏺ Mit Mikrofon aufnehmen")).clicked() {
self.player.stop();
match audio::Recording::start() {
Ok(r) => {
nv.recording = Some(r);
nv.error = None;
}
Err(e) => nv.error = Some(e),
}
}
}
Some(rec) => {
let secs = rec.elapsed();
let stop = ui.button(RichText::new("⏹ Aufnahme beenden").color(Color32::from_rgb(200, 50, 50))).clicked();
ui.label(format!("{:02}:{:02}", secs as u32 / 60, secs as u32 % 60));
ui.add(egui::ProgressBar::new(rec.level()).desired_width(140.0)).on_hover_text("Pegel");
ctx.request_repaint_after(std::time::Duration::from_millis(50));
if stop {
let rec = nv.recording.take().expect("recording");
let file = self.work.join("mikrofon.wav");
match rec.stop(&file) {
Ok(()) => self.start_prepare(&mut nv, file),
Err(e) => nv.error = Some(e),
}
}
}
}
});
if nv.recording.is_some() && !self.reading_text.is_empty() {
ui.add_space(6.0);
ui.label(RichText::new("Bitte vorlesen:").strong());
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(RichText::new(&self.reading_text).size(16.0));
});
}
if nv.busy {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Bereite Aufnahme auf und transkribiere …");
});
}
if let Some(p) = &nv.prepared {
ui.add_space(6.0);
ui.separator();
ui.horizontal(|ui| {
let info = if p.shortened {
format!("Vorlage: {:.1} s (gekürzt von {:.1} s)", p.secs, p.original_secs)
} else {
format!("Vorlage: {:.1} s", p.secs)
};
ui.label(RichText::new(info).strong());
if ui.button("▶ Anhören").clicked() {
self.player.play(&p.wav);
}
if self.player.is_playing() && ui.button("⏹").clicked() {
self.player.stop();
}
});
ui.label("Transkript – muss genau dem Gesprochenen entsprechen (bitte anhören und korrigieren):");
ui.add(egui::TextEdit::multiline(&mut nv.transcript).desired_rows(5).desired_width(f32::INFINITY));
ui.add_space(4.0);
if ui.add_enabled(!nv.name.trim().is_empty(), egui::Button::new(RichText::new("💾 Stimme speichern").strong())).clicked() {
match voices::save(&self.cfg.voice_dir, &nv.name, &p.wav, &nv.transcript) {
Ok(v) => saved = Some(v),
Err(e) => nv.error = Some(e),
}
}
}
if let Some(e) = &nv.error {
ui.colored_label(Color32::from_rgb(200, 50, 50), e);
}
});
if let Some(Voice::Clone { name, .. }) = saved {
self.reload_voices(Some(&name));
self.status = Status::Info(format!("Stimme \"{name}\" angelegt und ausgewählt."));
} else if open {
self.new_voice = Some(nv);
}
}
fn start_prepare(&self, nv: &mut NewVoice, file: PathBuf) {
nv.busy = true;
nv.error = None;
nv.prepared = None;
if nv.name.trim().is_empty() {
nv.name = file.file_stem().map(|s| voices::sanitize_name(&s.to_string_lossy())).filter(|n| n != "mikrofon").unwrap_or_default();
}
let (tx, ctx, cfg, work) = (self.tx.clone(), self.ctx.clone(), self.cfg.clone(), self.work.clone());
let max = self.model().max_ref_seconds;
std::thread::spawn(move || {
let r = voices::prepare(&file, &cfg, max, &work);
let _ = tx.send(Msg::Prepared(r));
ctx.request_repaint();
});
}
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.poll();
if self.player.is_playing() {
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| self.main_ui(ui));
});
let ctx = ui.ctx().clone();
self.new_voice_window(&ctx);
}
fn on_exit(&mut self) {
let _ = std::fs::remove_dir_all(&self.work);
}
}