TTS Studio: Rust-GUI fuer Ansagen mit Qwen3-TTS

Modell- und Stimmenauswahl, neue Klonstimme per Datei oder Mikrofon
(automatisch aufbereitet, an Satzpause gekuerzt, per Whisper transkribiert),
Texteingabe mit Pausen, Tempo, Satz-/Kommapausen mit Raumklang,
Hintergrundmusik, Ziel Asterisk (WAV/G.722) oder WhatsApp (MP3),
Vorhoeren und Speichern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Renato PfeilerandClaude Opus 5.5 committed 2026-10-23 15:52:47 +02:00
1 parent 264a6239e1
commit d12897a928
11 files changed
+7048

No files matched your search

+564
View File
@@ -0,0 +1,564 @@
//! 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 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,
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,
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,
};
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.label(RichText::new("Zahlen am besten ausschreiben („acht Uhr dreißig“)").weak().small());
});
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);
}
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);
}
}