//! 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), Server { url: String, online: bool }, Prepared(Result), } enum Status { None, Info(String), Error(String), } /// Fenster "Neue Stimme anlegen" #[derive(Default)] struct NewVoice { name: String, recording: Option, busy: bool, prepared: Option, transcript: String, error: Option, } struct App { cfg: Config, model_idx: usize, voices: Vec, voice_idx: usize, text: String, cursor: Option, 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, status: Status, result: Option, servers: Vec<(String, String, Option)>, // Bezeichnung, URL, online? reading_text: String, new_voice: Option, player: audio::Player, work: PathBuf, tx: Sender, rx: Receiver, 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 = 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", ""), ("+ Pause 1 s", "")] { 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 = 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); } }