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

+152
View File
@@ -0,0 +1,152 @@
//! Vorhoeren (Wiedergabe) und Mikrofonaufnahme ueber rodio.
use crate::media;
use rodio::microphone::MicrophoneBuilder;
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
#[derive(Default)]
pub struct Player {
// beide muessen leben, solange etwas spielt
sink: Option<MixerDeviceSink>,
current: Option<rodio::Player>,
error: Option<String>,
}
impl Player {
pub fn play(&mut self, path: &Path) {
self.stop();
if let Err(e) = self.try_play(path) {
self.error = Some(format!("Wiedergabe nicht möglich: {e}"));
}
}
fn try_play(&mut self, path: &Path) -> Result<(), String> {
if self.sink.is_none() {
let mut sink = DeviceSinkBuilder::open_default_sink().map_err(|e| e.to_string())?;
sink.log_on_drop(false);
self.sink = Some(sink);
}
let sink = self.sink.as_ref().expect("sink");
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let player = rodio::Player::connect_new(sink.mixer());
player.append(Decoder::try_from(file).map_err(|e| e.to_string())?);
self.current = Some(player);
Ok(())
}
pub fn stop(&mut self) {
if let Some(p) = self.current.take() {
p.stop();
}
}
pub fn is_playing(&self) -> bool {
self.current.as_ref().is_some_and(|p| !p.empty())
}
pub fn take_error(&mut self) -> Option<String> {
self.error.take()
}
}
/// Laufende Mikrofonaufnahme. Das Mikrofon puffert nur ~100 ms, deshalb liest ein
/// eigener Thread laufend mit und sammelt die Samples hier ein.
pub struct Recording {
stop: Arc<AtomicBool>,
level: Arc<AtomicU32>,
samples: Arc<Mutex<Vec<f32>>>,
sample_rate: u32,
channels: u16,
started: Instant,
}
impl Recording {
pub fn start() -> Result<Self, String> {
let stop = Arc::new(AtomicBool::new(false));
let level = Arc::new(AtomicU32::new(0));
let samples = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let (stop_t, level_t, samples_t) = (stop.clone(), level.clone(), samples.clone());
std::thread::spawn(move || {
let opened = MicrophoneBuilder::new()
.default_device()
.and_then(|b| b.default_config())
.map_err(|e| e.to_string())
.and_then(|b| b.open_stream().map_err(|e| e.to_string()))
.map_err(|e| format!("Mikrofon nicht verfügbar: {e}"));
let mut mic = match opened {
Ok(m) => m,
Err(e) => {
let _ = tx.send(Err(e));
return;
}
};
let cfg = *mic.config();
let (sr, ch) = (cfg.sample_rate.get(), cfg.channel_count.get());
let _ = tx.send(Ok((sr, ch)));
let block = (sr as usize * ch as usize / 20).max(1); // 50 ms
let mut buf = Vec::with_capacity(block);
// next() blockiert, bis Samples da sind; Stopp wird beim naechsten Sample bemerkt
while !stop_t.load(Ordering::Relaxed) {
let Some(s) = mic.next() else { break };
buf.push(s);
if buf.len() >= block {
let rms = (buf.iter().map(|s| s * s).sum::<f32>() / buf.len() as f32).sqrt();
level_t.store(rms.to_bits(), Ordering::Relaxed);
samples_t.lock().expect("samples").append(&mut buf);
}
}
samples_t.lock().expect("samples").append(&mut buf);
});
let (sample_rate, channels) = rx.recv_timeout(Duration::from_secs(5)).map_err(|_| "Mikrofon antwortet nicht.".to_string())??;
Ok(Recording { stop, level, samples, sample_rate, channels, started: Instant::now() })
}
pub fn elapsed(&self) -> f32 {
self.started.elapsed().as_secs_f32()
}
/// Pegel fuer die Anzeige, 0..1 ueber -60..0 dBFS
pub fn level(&self) -> f32 {
let rms = f32::from_bits(self.level.load(Ordering::Relaxed));
((20.0 * (rms + 1e-9).log10() + 60.0) / 60.0).clamp(0.0, 1.0)
}
/// Aufnahme beenden und als Mono-WAV speichern.
pub fn stop(self, file: &Path) -> Result<(), String> {
self.stop.store(true, Ordering::Relaxed);
std::thread::sleep(Duration::from_millis(100)); // letzten Block einsammeln lassen
let interleaved = std::mem::take(&mut *self.samples.lock().map_err(|e| e.to_string())?);
let ch = self.channels.max(1) as usize;
let mono: Vec<f32> = interleaved.chunks(ch).map(|c| c.iter().sum::<f32>() / ch as f32).collect();
if media::seconds(mono.len(), self.sample_rate) < 1.0 {
return Err("Aufnahme ist leer oder zu kurz – Mikrofon prüfen.".into());
}
media::write_wav(file, &mono, self.sample_rate)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Echte Hardware: cargo test -- --ignored audio
#[test]
#[ignore]
fn audio_geraete() {
let mut sink = DeviceSinkBuilder::open_default_sink().expect("Ausgabegerät");
sink.log_on_drop(false);
let rec = Recording::start().expect("Mikrofon");
std::thread::sleep(Duration::from_millis(1500));
println!("Mikrofon: {} Hz, {} Kanal/Kanäle, Pegel {:.2}", rec.sample_rate, rec.channels, rec.level());
let file = std::env::temp_dir().join("tts-studio-mic-test.wav");
rec.stop(&file).expect("Aufnahme");
let (s, sr) = media::read_wav_bytes(&std::fs::read(&file).unwrap()).unwrap();
println!("aufgenommen: {:.2} s", media::seconds(s.len(), sr));
std::fs::remove_file(&file).unwrap();
}
}