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>
578 lines
23 KiB
Rust
578 lines
23 KiB
Rust
//! 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);
|
||
}
|
||
}
|