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:
1 parent
264a6239e1
commit
d12897a928
11 files changed
+7048
No files matched your search
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+5253
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "tts-studio"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.23"
|
||||
dirs = "7"
|
||||
eframe = "0.37"
|
||||
hound = "3.5"
|
||||
rfd = "0.17"
|
||||
rodio = "0.23"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1"
|
||||
ureq = { version = "3.5", features = ["json"] }
|
||||
@@ -0,0 +1,20 @@
|
||||
# Optionale Konfiguration fuer TTS Studio.
|
||||
# Nach ~/.config/tts-studio/config.toml kopieren und anpassen. Ohne Datei gelten
|
||||
# die Standardwerte unten (Pfade relativ zum Repo, Server auf der DGX).
|
||||
|
||||
voice_dir = "/home/renato/projects/tts/voice_clone" # Klonstimmen: <name>.wav + <name>.txt
|
||||
reading_text_file = "/home/renato/projects/tts/voice_clone/referenztext.txt" # Vorlesetext beim Aufnehmen
|
||||
music_file = "/home/renato/projects/tts/background.wav"
|
||||
output_dir = "/home/renato/projects/tts" # Startordner beim Speichern
|
||||
|
||||
whisper_url = "http://10.146.97.103:8020/v1/audio/transcriptions"
|
||||
whisper_model = "whisper-turbo-de"
|
||||
|
||||
# Modellauswahl: jeder Eintrag braucht einen OpenAI-kompatiblen /v1/audio/speech-Endpunkt.
|
||||
[[models]]
|
||||
name = "Qwen3-TTS 1.7B (DGX)"
|
||||
clone_url = "http://10.146.97.103:8012/v1/audio/speech" # Base-Modell, Klonstimmen (ref_audio)
|
||||
voice_url = "http://10.146.97.103:8011/v1/audio/speech" # CustomVoice-Modell, feste Stimmen
|
||||
voices = ["serena", "vivian", "ryan", "aiden", "uncle_fu"]
|
||||
language = "German"
|
||||
max_ref_seconds = 30.0 # laengste Vorlage, die der Server annimmt
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Einstellungen: eingebaute Defaults, optional ueberschrieben durch
|
||||
//! ~/.config/tts-studio/config.toml (siehe config.example.toml).
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DGX: &str = "http://10.146.97.103";
|
||||
|
||||
/// Repo-Wurzel (Elternordner von tts-studio/), dort liegen voice_clone/ und background.wav.
|
||||
fn repo_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(Path::to_path_buf).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// Klonstimmen: <name>.wav + <name>.txt (Transkript)
|
||||
pub voice_dir: PathBuf,
|
||||
/// Vorlesetext, der beim Aufnehmen einer neuen Stimme angezeigt wird
|
||||
pub reading_text_file: PathBuf,
|
||||
pub music_file: PathBuf,
|
||||
pub output_dir: PathBuf,
|
||||
/// Whisper (OpenAI-kompatibel) zum Transkribieren neuer Stimmen
|
||||
pub whisper_url: String,
|
||||
pub whisper_model: String,
|
||||
pub models: Vec<Model>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Model {
|
||||
pub name: String,
|
||||
/// /v1/audio/speech mit ref_audio + ref_text (Klonstimmen)
|
||||
pub clone_url: Option<String>,
|
||||
/// /v1/audio/speech mit voice=<name> (feste Stimmen)
|
||||
pub voice_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub voices: Vec<String>,
|
||||
#[serde(default = "default_language")]
|
||||
pub language: String,
|
||||
/// Laengste Vorlage, die der Server annimmt (Qwen3-TTS: 30 s)
|
||||
#[serde(default = "default_max_ref")]
|
||||
pub max_ref_seconds: f32,
|
||||
}
|
||||
|
||||
fn default_language() -> String {
|
||||
"German".into()
|
||||
}
|
||||
|
||||
fn default_max_ref() -> f32 {
|
||||
30.0
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let repo = repo_dir();
|
||||
Config {
|
||||
voice_dir: repo.join("voice_clone"),
|
||||
reading_text_file: repo.join("voice_clone/referenztext.txt"),
|
||||
music_file: repo.join("background.wav"),
|
||||
output_dir: repo.clone(),
|
||||
whisper_url: format!("{DGX}:8020/v1/audio/transcriptions"),
|
||||
whisper_model: "whisper-turbo-de".into(),
|
||||
models: vec![Model {
|
||||
name: "Qwen3-TTS 1.7B (DGX)".into(),
|
||||
clone_url: Some(format!("{DGX}:8012/v1/audio/speech")),
|
||||
voice_url: Some(format!("{DGX}:8011/v1/audio/speech")),
|
||||
voices: ["serena", "vivian", "ryan", "aiden", "uncle_fu"].map(String::from).to_vec(),
|
||||
language: default_language(),
|
||||
max_ref_seconds: default_max_ref(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("tts-studio/config.toml"))
|
||||
}
|
||||
|
||||
/// Laedt die Konfiguration; Fehler in der Datei werden gemeldet, nicht verschluckt.
|
||||
pub fn load() -> (Config, Option<String>) {
|
||||
let Some(path) = config_path().filter(|p| p.exists()) else {
|
||||
return (Config::default(), None);
|
||||
};
|
||||
match std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|s| toml::from_str(&s).map_err(|e| e.to_string())) {
|
||||
Ok(cfg) => (cfg, None),
|
||||
Err(e) => (Config::default(), Some(format!("{}: {e} – verwende Standardwerte", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Basis-URL eines Endpunkts fuer den Erreichbarkeits-Check (".../v1/audio/speech" -> ".../v1/models").
|
||||
pub fn models_url(endpoint: &str) -> String {
|
||||
match endpoint.find("/v1/") {
|
||||
Some(i) => format!("{}/v1/models", &endpoint[..i]),
|
||||
None => endpoint.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn beispielkonfiguration_ist_gueltig() {
|
||||
let cfg: super::Config = toml::from_str(include_str!("../config.example.toml")).unwrap();
|
||||
assert_eq!(cfg.models.len(), 1);
|
||||
assert_eq!(super::models_url(cfg.models[0].clone_url.as_deref().unwrap()), "http://10.146.97.103:8012/v1/models");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Aufrufe an die Server auf der DGX: Sprache erzeugen (Qwen3-TTS),
|
||||
//! transkribieren (Whisper), Erreichbarkeit pruefen.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
fn agent(timeout_secs: u64) -> ureq::Agent {
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(timeout_secs)))
|
||||
// Fehlerantworten selbst auswerten, der Server erklaert im Body, was nicht passt
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
fn net_err(url: &str, e: ureq::Error) -> String {
|
||||
format!("{url} nicht erreichbar: {e}")
|
||||
}
|
||||
|
||||
/// Liest die Antwort; bei Fehlerstatus wird die Meldung des Servers zurueckgegeben.
|
||||
fn read_body(url: &str, mut resp: ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, String> {
|
||||
let status = resp.status();
|
||||
let body = resp.body_mut().with_config().limit(100 * 1024 * 1024).read_to_vec().map_err(|e| net_err(url, e))?;
|
||||
if status.is_success() {
|
||||
return Ok(body);
|
||||
}
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
// vLLM: {"error": {"message": "..."}}
|
||||
let msg = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v["error"]["message"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| text.trim().to_string());
|
||||
Err(format!("Server meldet {status}: {msg}"))
|
||||
}
|
||||
|
||||
/// POST /v1/audio/speech -> WAV-Bytes. Erster Aufruf nach dem Serverstart dauert ~20 s.
|
||||
pub fn speech(url: &str, body: &serde_json::Value) -> Result<Vec<u8>, String> {
|
||||
let resp = agent(300).post(url).send_json(body).map_err(|e| net_err(url, e))?;
|
||||
read_body(url, resp)
|
||||
}
|
||||
|
||||
/// POST /v1/audio/transcriptions (multipart) -> erkannter Text.
|
||||
pub fn transcribe(url: &str, model: &str, wav: &[u8]) -> Result<String, String> {
|
||||
let boundary = format!("----tts-studio-{}", std::process::id());
|
||||
let mut body = Vec::with_capacity(wav.len() + 512);
|
||||
for (name, value) in [("model", model), ("language", "de"), ("response_format", "json")] {
|
||||
body.extend(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n").as_bytes());
|
||||
}
|
||||
body.extend(
|
||||
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"stimme.wav\"\r\nContent-Type: audio/wav\r\n\r\n")
|
||||
.as_bytes(),
|
||||
);
|
||||
body.extend(wav);
|
||||
body.extend(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||
|
||||
let resp = agent(180)
|
||||
.post(url)
|
||||
.header("Content-Type", format!("multipart/form-data; boundary={boundary}"))
|
||||
.send(&body[..])
|
||||
.map_err(|e| net_err(url, e))?;
|
||||
let json: serde_json::Value = serde_json::from_slice(&read_body(url, resp)?).map_err(|e| format!("Whisper-Antwort unlesbar: {e}"))?;
|
||||
json["text"].as_str().map(|t| t.trim().to_string()).ok_or_else(|| "Whisper hat keinen Text geliefert.".into())
|
||||
}
|
||||
|
||||
/// Kurzer Check, ob ein Server antwortet (GET .../v1/models).
|
||||
pub fn reachable(models_url: &str) -> bool {
|
||||
agent(3).get(models_url).call().is_ok_and(|r| r.status().is_success())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! ffmpeg-Aufrufe, WAV-Ein-/Ausgabe und einfache Signalanalyse
|
||||
//! (Stille abschneiden, Vorlagen in einer Sprechpause kuerzen).
|
||||
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
pub fn ffmpeg(args: &[&str]) -> Result<(), String> {
|
||||
let out = Command::new("ffmpeg")
|
||||
.args(["-y", "-v", "error"])
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("ffmpeg nicht startbar: {e}"))?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("ffmpeg: {}", String::from_utf8_lossy(&out.stderr).trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Beliebige Audiodatei -> mono f32 mit `sr`. `.g722` wird als rohes G.722 gelesen.
|
||||
pub fn decode(input: &Path, sr: u32, filter: Option<&str>) -> Result<Vec<f32>, String> {
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.args(["-v", "error"]);
|
||||
if is_g722(input) {
|
||||
cmd.args(["-f", "g722"]);
|
||||
}
|
||||
cmd.arg("-i").arg(input);
|
||||
if let Some(f) = filter {
|
||||
cmd.args(["-af", f]);
|
||||
}
|
||||
let sr = sr.to_string();
|
||||
cmd.args(["-ac", "1", "-ar", &sr, "-f", "f32le", "-"]);
|
||||
let out = cmd.output().map_err(|e| format!("ffmpeg nicht startbar: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("ffmpeg: {}", String::from_utf8_lossy(&out.stderr).trim()));
|
||||
}
|
||||
Ok(out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect())
|
||||
}
|
||||
|
||||
/// Mono-Samples aus dem Speicher auf eine andere Abtastrate bringen.
|
||||
pub fn resample(samples: &[f32], from: u32, to: u32) -> Result<Vec<f32>, String> {
|
||||
if from == to {
|
||||
return Ok(samples.to_vec());
|
||||
}
|
||||
pipe(samples, from, to, None)
|
||||
}
|
||||
|
||||
/// Tempo aendern, Tonhoehe bleibt (ffmpeg atempo, fuer Sprache gut geeignet).
|
||||
pub fn change_tempo(samples: &[f32], sr: u32, factor: f32) -> Result<Vec<f32>, String> {
|
||||
if (factor - 1.0).abs() < 0.005 {
|
||||
return Ok(samples.to_vec());
|
||||
}
|
||||
pipe(samples, sr, sr, Some(&format!("atempo={factor:.3}")))
|
||||
}
|
||||
|
||||
/// Samples aus dem Speicher durch ffmpeg schicken (Abtastrate und/oder Filter).
|
||||
fn pipe(samples: &[f32], from: u32, to: u32, filter: Option<&str>) -> Result<Vec<f32>, String> {
|
||||
let (from_s, to_s) = (from.to_string(), to.to_string());
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.args(["-v", "error", "-f", "f32le", "-ar", &from_s, "-ac", "1", "-i", "-"]);
|
||||
if let Some(f) = filter {
|
||||
cmd.args(["-af", f]);
|
||||
}
|
||||
let mut child = cmd
|
||||
.args(["-ac", "1", "-ar", &to_s, "-f", "f32le", "-"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("ffmpeg nicht startbar: {e}"))?;
|
||||
let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
let mut stdin = child.stdin.take().expect("stdin");
|
||||
// Schreiben in eigenem Thread, sonst Deadlock, wenn stdout voll laeuft
|
||||
let writer = std::thread::spawn(move || stdin.write_all(&bytes));
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
let _ = writer.join();
|
||||
if !out.status.success() {
|
||||
return Err(format!("ffmpeg: {}", String::from_utf8_lossy(&out.stderr).trim()));
|
||||
}
|
||||
Ok(out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect())
|
||||
}
|
||||
|
||||
pub fn is_g722(p: &Path) -> bool {
|
||||
p.extension().is_some_and(|e| e.eq_ignore_ascii_case("g722"))
|
||||
}
|
||||
|
||||
fn spec(sr: u32) -> hound::WavSpec {
|
||||
hound::WavSpec { channels: 1, sample_rate: sr, bits_per_sample: 16, sample_format: hound::SampleFormat::Int }
|
||||
}
|
||||
|
||||
fn to_i16(s: f32) -> i16 {
|
||||
(s.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16
|
||||
}
|
||||
|
||||
pub fn write_wav(path: &Path, samples: &[f32], sr: u32) -> Result<(), String> {
|
||||
let mut w = hound::WavWriter::create(path, spec(sr)).map_err(|e| e.to_string())?;
|
||||
for &s in samples {
|
||||
w.write_sample(to_i16(s)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
w.finalize().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn wav_bytes(samples: &[f32], sr: u32) -> Vec<u8> {
|
||||
let mut cur = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut w = hound::WavWriter::new(&mut cur, spec(sr)).expect("wav header");
|
||||
for &s in samples {
|
||||
w.write_sample(to_i16(s)).expect("wav sample");
|
||||
}
|
||||
w.finalize().expect("wav finalize");
|
||||
}
|
||||
cur.into_inner()
|
||||
}
|
||||
|
||||
/// WAV aus dem Speicher (Server-Antwort) -> mono f32 + Abtastrate.
|
||||
pub fn read_wav_bytes(bytes: &[u8]) -> Result<(Vec<f32>, u32), String> {
|
||||
let mut r = hound::WavReader::new(Cursor::new(bytes)).map_err(|e| format!("Antwort ist kein WAV: {e}"))?;
|
||||
let spec = r.spec();
|
||||
let interleaved: Vec<f32> = match spec.sample_format {
|
||||
hound::SampleFormat::Float => r.samples::<f32>().collect::<Result<_, _>>(),
|
||||
hound::SampleFormat::Int => {
|
||||
let scale = (1i64 << (spec.bits_per_sample - 1)) as f32;
|
||||
r.samples::<i32>().map(|s| s.map(|v| v as f32 / scale)).collect::<Result<_, _>>()
|
||||
}
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let ch = spec.channels.max(1) as usize;
|
||||
let mono = interleaved.chunks(ch).map(|c| c.iter().sum::<f32>() / ch as f32).collect();
|
||||
Ok((mono, spec.sample_rate))
|
||||
}
|
||||
|
||||
pub fn seconds(samples: usize, sr: u32) -> f32 {
|
||||
samples as f32 / sr as f32
|
||||
}
|
||||
|
||||
const FRAME_MS: usize = 20;
|
||||
|
||||
/// Kurzzeitpegel je 20-ms-Frame in dBFS.
|
||||
fn frame_db(x: &[f32], sr: u32) -> Vec<f32> {
|
||||
let n = (sr as usize * FRAME_MS / 1000).max(1);
|
||||
x.chunks(n)
|
||||
.map(|c| {
|
||||
let rms = (c.iter().map(|s| s * s).sum::<f32>() / c.len() as f32).sqrt();
|
||||
20.0 * (rms + 1e-9).log10()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn percentile(sorted: &[f32], p: f32) -> f32 {
|
||||
if sorted.is_empty() {
|
||||
return -90.0;
|
||||
}
|
||||
sorted[((sorted.len() - 1) as f32 * p).round() as usize]
|
||||
}
|
||||
|
||||
/// Schwelle zwischen Sprache und Pause: Grundrauschen (ohne digitale Stille) + 8 dB,
|
||||
/// aber mindestens 6 dB unter dem Median, damit leise Silben nicht als Pause zaehlen.
|
||||
fn silence_threshold(db: &[f32]) -> f32 {
|
||||
let mut audible: Vec<f32> = db.iter().copied().filter(|&d| d > -80.0).collect();
|
||||
audible.sort_by(f32::total_cmp);
|
||||
let floor = percentile(&audible, 0.2);
|
||||
let median = percentile(&audible, 0.5);
|
||||
(floor + 8.0).min(median - 6.0)
|
||||
}
|
||||
|
||||
/// Pausen als (Start, Ende) in Frames, mindestens `min_ms` lang.
|
||||
fn pauses(db: &[f32], thr: f32, min_ms: usize) -> Vec<(usize, usize)> {
|
||||
let min_frames = (min_ms / FRAME_MS).max(1);
|
||||
let mut out = Vec::new();
|
||||
let mut start = None;
|
||||
for (i, &d) in db.iter().chain(std::iter::once(&0.0)).enumerate() {
|
||||
match (d < thr, start) {
|
||||
(true, None) => start = Some(i),
|
||||
(false, Some(s)) => {
|
||||
if i - s >= min_frames {
|
||||
out.push((s, i));
|
||||
}
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Stille am Anfang und Ende eines erzeugten Abschnitts entfernen (60 ms Rest bleiben),
|
||||
/// damit die eingestellten Pausen exakt gelten.
|
||||
pub fn trim_silence(x: &[f32], sr: u32) -> Vec<f32> {
|
||||
let frame = sr as usize * FRAME_MS / 1000;
|
||||
let db = frame_db(x, sr);
|
||||
let Some(first) = db.iter().position(|&d| d > -45.0) else { return Vec::new() };
|
||||
let last = db.iter().rposition(|&d| d > -45.0).unwrap_or(first);
|
||||
let pad = 60 / FRAME_MS;
|
||||
let start = first.saturating_sub(pad) * frame;
|
||||
let end = ((last + 1 + pad) * frame).min(x.len());
|
||||
x[start.min(end)..end].to_vec()
|
||||
}
|
||||
|
||||
/// Raumklang einer Stimme: die leisesten Stellen zwischen den Woertern aller Abschnitte
|
||||
/// (ohne digitale Stille und ohne die Raender, die das Modell oft ganz stumm erzeugt).
|
||||
pub fn room_tone_pool(segments: &[Vec<f32>], sr: u32) -> Vec<f32> {
|
||||
let frame = sr as usize * FRAME_MS / 1000;
|
||||
let dbs: Vec<Vec<f32>> = segments.iter().map(|s| frame_db(s, sr)).collect();
|
||||
let mut audible: Vec<f32> = dbs.iter().flatten().copied().filter(|&d| d > -75.0).collect();
|
||||
audible.sort_by(f32::total_cmp);
|
||||
let thr = percentile(&audible, 0.08);
|
||||
let fade = sr as usize * 5 / 1000;
|
||||
let mut pool: Vec<f32> = Vec::new();
|
||||
for (s, db) in segments.iter().zip(&dbs) {
|
||||
let edge = 3; // Frames am Rand auslassen
|
||||
let mut i = edge;
|
||||
while i + edge < db.len() {
|
||||
if db[i] > -75.0 && db[i] <= thr {
|
||||
let start = i;
|
||||
while i + edge < db.len() && db[i] > -75.0 && db[i] <= thr + 1.0 {
|
||||
i += 1;
|
||||
}
|
||||
if i - start >= 2 {
|
||||
append_crossfaded(&mut pool, &s[start * frame..(i * frame).min(s.len())], fade);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
pool
|
||||
}
|
||||
|
||||
/// Haengt `piece` an `out` an, mit gleichleistungs-Ueberblendung (fuer Rauschen pegeltreu).
|
||||
fn append_crossfaded(out: &mut Vec<f32>, piece: &[f32], fade: usize) {
|
||||
let fade = fade.min(piece.len() / 2).min(out.len());
|
||||
let start = out.len() - fade;
|
||||
for (k, &p) in piece.iter().enumerate() {
|
||||
if k < fade {
|
||||
let t = (k as f32 + 0.5) / fade as f32 * std::f32::consts::FRAC_PI_2;
|
||||
out[start + k] = out[start + k] * t.cos() + p * t.sin();
|
||||
} else {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Durchgehender Grundpegel der Laenge `len` aus zufaelligen Koernern (80–200 ms) des Pools.
|
||||
/// Ohne brauchbaren Pool (< 60 ms) bleibt es still.
|
||||
pub fn room_tone_bed(pool: &[f32], len: usize, sr: u32) -> Vec<f32> {
|
||||
let ms = |m: usize| sr as usize * m / 1000;
|
||||
if pool.len() < ms(60) {
|
||||
return vec![0.0; len];
|
||||
}
|
||||
let mut rng: u64 = 0x9E37_79B9_7F4A_7C15; // fest: gleiche Eingabe -> gleiche Ausgabe
|
||||
let mut next = |n: usize| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 7;
|
||||
rng ^= rng << 17;
|
||||
(rng % n.max(1) as u64) as usize
|
||||
};
|
||||
let mut bed = Vec::with_capacity(len + ms(200));
|
||||
while bed.len() < len {
|
||||
let grain = (ms(80) + next(ms(120))).min(pool.len());
|
||||
let start = next(pool.len() - grain + 1);
|
||||
append_crossfaded(&mut bed, &pool[start..start + grain], ms(10));
|
||||
}
|
||||
bed.truncate(len);
|
||||
bed
|
||||
}
|
||||
|
||||
pub struct Trimmed {
|
||||
pub samples: Vec<f32>,
|
||||
pub original_secs: f32,
|
||||
pub shortened: bool,
|
||||
}
|
||||
|
||||
/// Stille am Anfang/Ende entfernen und auf `max_secs` kuerzen. Geschnitten wird in der
|
||||
/// letzten deutlichen Sprechpause vor der Grenze, damit kein Wort zerteilt wird.
|
||||
pub fn trim_reference(x: &[f32], sr: u32, max_secs: f32) -> Trimmed {
|
||||
let original_secs = seconds(x.len(), sr);
|
||||
let frame = sr as usize * FRAME_MS / 1000;
|
||||
let db = frame_db(x, sr);
|
||||
let thr = silence_threshold(&db);
|
||||
|
||||
let first = db.iter().position(|&d| d >= thr).unwrap_or(0);
|
||||
let last = db.iter().rposition(|&d| d >= thr).unwrap_or(db.len().saturating_sub(1));
|
||||
let pad_start = 150 / FRAME_MS;
|
||||
let pad_end = 250 / FRAME_MS;
|
||||
let start_f = first.saturating_sub(pad_start);
|
||||
let mut end_f = (last + 1 + pad_end).min(db.len());
|
||||
|
||||
// 1 s Reserve unter dem Server-Limit
|
||||
let limit_f = ((max_secs - 1.0).max(3.0) * 1000.0) as usize / FRAME_MS;
|
||||
let shortened = end_f - start_f > limit_f;
|
||||
if shortened {
|
||||
let window = start_f + limit_f;
|
||||
let candidates: Vec<(usize, usize)> =
|
||||
pauses(&db, thr, 150).into_iter().filter(|&(s, e)| s > start_f + limit_f / 4 && (s + e) / 2 <= window).collect();
|
||||
// bevorzugt eine Satzpause (>= 0,4 s) im hinteren Drittel, sonst die letzte Pause ueberhaupt
|
||||
let long = candidates.iter().rev().find(|&&(s, e)| e - s >= 400 / FRAME_MS && (s + e) / 2 >= start_f + limit_f * 2 / 3);
|
||||
end_f = match long.or(candidates.last()) {
|
||||
Some(&(s, e)) => (s + (e - s) / 2).min(s + pad_end),
|
||||
None => window,
|
||||
};
|
||||
}
|
||||
|
||||
let mut y = x[(start_f * frame).min(x.len())..(end_f * frame).min(x.len())].to_vec();
|
||||
let fade = (sr as usize * 15 / 1000).min(y.len() / 2);
|
||||
for i in 0..fade {
|
||||
let g = i as f32 / fade as f32;
|
||||
y[i] *= g;
|
||||
let j = y.len() - 1 - i;
|
||||
y[j] *= g;
|
||||
}
|
||||
Trimmed { samples: y, original_secs, shortened }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kuerzt_in_pause() {
|
||||
let sr = 16000;
|
||||
// 4 x (7 s "Sprache" + 0,6 s Pause) = 30,4 s
|
||||
let mut x = Vec::new();
|
||||
for _ in 0..4 {
|
||||
x.extend((0..sr * 7).map(|i| 0.3 * ((i as f32) * 0.05).sin()));
|
||||
x.extend((0..sr * 6 / 10).map(|_| 0.0005));
|
||||
}
|
||||
let t = trim_reference(&x, sr as u32, 30.0);
|
||||
let secs = seconds(t.samples.len(), sr as u32);
|
||||
assert!(t.shortened);
|
||||
assert!(secs <= 29.0, "{secs}");
|
||||
// Schnitt in der 3. Pause (nach 3 x 7,6 s = 22,8 s, Mitte der Pause ~22,5 s)
|
||||
assert!((21.5..23.5).contains(&secs), "{secs}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Text -> fertige Ansage: Pausen-Tags auswerten, Abschnitte beim Modell anfragen,
|
||||
//! zusammensetzen, optional Hintergrundmusik, ins Zielformat wandeln.
|
||||
|
||||
use crate::config::Model;
|
||||
use crate::voices::{REF_SR, Voice};
|
||||
use crate::{http, media};
|
||||
use base64::Engine;
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Target {
|
||||
AsteriskWav,
|
||||
AsteriskG722,
|
||||
WhatsApp,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub const ALL: [Target; 3] = [Target::AsteriskWav, Target::AsteriskG722, Target::WhatsApp];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Target::AsteriskWav => "Asterisk – WAV 8 kHz",
|
||||
Target::AsteriskG722 => "Asterisk – G.722",
|
||||
Target::WhatsApp => "WhatsApp – MP3",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Target::AsteriskWav => "wav",
|
||||
Target::AsteriskG722 => "g722",
|
||||
Target::WhatsApp => "mp3",
|
||||
}
|
||||
}
|
||||
|
||||
fn ffmpeg_out(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Target::AsteriskWav => &["-acodec", "pcm_s16le", "-ar", "8000", "-ac", "1"],
|
||||
Target::AsteriskG722 => &["-ac", "1", "-ar", "16000", "-c:a", "g722", "-f", "g722"],
|
||||
Target::WhatsApp => &["-ac", "1", "-ar", "44100", "-c:a", "libmp3lame", "-b:a", "96k"],
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate der Vorhoer-Datei: so klingt es beim Anrufer bzw. Empfaenger
|
||||
fn preview_rate(self) -> u32 {
|
||||
match self {
|
||||
Target::AsteriskWav => 8000,
|
||||
Target::AsteriskG722 => 16000,
|
||||
Target::WhatsApp => 44100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Music {
|
||||
pub file: PathBuf,
|
||||
pub volume: f32,
|
||||
pub intro_secs: f32,
|
||||
}
|
||||
|
||||
pub struct Job {
|
||||
pub model: Model,
|
||||
pub voice: Voice,
|
||||
pub text: String,
|
||||
pub music: Option<Music>,
|
||||
pub target: Target,
|
||||
/// Sprechtempo (1.0 = normal), Pausen bleiben davon unberuehrt
|
||||
pub speed: f32,
|
||||
/// Pause nach . ! ? (0 = Text nicht an Satzenden teilen)
|
||||
pub sentence_pause_ms: u32,
|
||||
/// Pause nach , ; : (0 = nicht teilen)
|
||||
pub comma_pause_ms: u32,
|
||||
}
|
||||
|
||||
/// Gleichzeitige Anfragen (Server verarbeitet bis zu 8 parallel)
|
||||
const PARALLEL: usize = 8;
|
||||
|
||||
pub struct Rendered {
|
||||
pub output: PathBuf,
|
||||
pub preview: PathBuf,
|
||||
pub secs: f32,
|
||||
pub target: Target,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Part {
|
||||
Text(String),
|
||||
Pause(u32),
|
||||
}
|
||||
|
||||
/// Text mit <break time="500ms"/> bzw. <break time="1s"/> in Abschnitte und Pausen zerlegen.
|
||||
/// Andere Tags (SSML aus Azure-Zeiten) werden zu Leerzeichen.
|
||||
pub fn parse_text(input: &str) -> Vec<Part> {
|
||||
fn flush(parts: &mut Vec<Part>, text: &mut String) {
|
||||
let t = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if !t.is_empty() {
|
||||
parts.push(Part::Text(t));
|
||||
}
|
||||
text.clear();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
let mut text = String::new();
|
||||
let mut rest = input;
|
||||
while let Some(lt) = rest.find('<') {
|
||||
text.push_str(&rest[..lt]);
|
||||
let Some(gt) = rest[lt..].find('>') else {
|
||||
rest = &rest[lt..]; // "<" ohne ">" bleibt normaler Text
|
||||
break;
|
||||
};
|
||||
let tag = &rest[lt + 1..lt + gt];
|
||||
rest = &rest[lt + gt + 1..];
|
||||
match break_ms(tag) {
|
||||
Some(ms) => {
|
||||
flush(&mut parts, &mut text);
|
||||
parts.push(Part::Pause(ms));
|
||||
}
|
||||
None => text.push(' '),
|
||||
}
|
||||
}
|
||||
text.push_str(rest);
|
||||
flush(&mut parts, &mut text);
|
||||
parts
|
||||
}
|
||||
|
||||
fn break_ms(tag: &str) -> Option<u32> {
|
||||
let t = tag.trim().trim_end_matches('/').trim().to_ascii_lowercase();
|
||||
let t = t.strip_prefix("break")?;
|
||||
let Some((_, after)) = t.split_once("time") else {
|
||||
return Some(500); // <break/> ohne Zeitangabe
|
||||
};
|
||||
let v = after.trim_start().strip_prefix('=')?.trim().trim_matches(|c| c == '"' || c == '\'');
|
||||
let split = v.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(v.len());
|
||||
let n: f32 = v[..split].parse().ok()?;
|
||||
let factor = match v[split..].trim() {
|
||||
"ms" | "" => 1.0,
|
||||
"s" => 1000.0,
|
||||
_ => return None,
|
||||
};
|
||||
Some((n * factor).round() as u32)
|
||||
}
|
||||
|
||||
/// Abkuerzungen, deren Punkt kein Satzende ist (klein, ohne Punkt)
|
||||
const ABBREVIATIONS: &[&str] = &[
|
||||
"dr", "prof", "mag", "dipl", "ing", "med", "univ", "nr", "str", "tel", "fax", "bzw", "ca", "usw", "etc", "inkl", "exkl", "evtl", "ggf",
|
||||
"vgl", "zb", "hr", "fr", "mo", "di", "mi", "do", "sa", "jan", "feb", "mär", "apr", "jun", "jul", "aug", "sep", "sept", "okt",
|
||||
"nov", "dez", "abt", "geb", "max", "min", "std", "bzgl", "lt", "u.a", "z.b", "d.h", "u.ä",
|
||||
];
|
||||
|
||||
/// Ist das Satzzeichen an Byte-Position `i` in `t` eine Grenze? Liefert true fuer Satzende.
|
||||
fn boundary_at(t: &str, i: usize, c: char) -> Option<bool> {
|
||||
let after = &t[i + c.len_utf8()..];
|
||||
// nur vor Leerraum: "8,30", "8:30", "www.x.at" bleiben ganz; Textende braucht keinen Schnitt
|
||||
if !after.starts_with(char::is_whitespace) || after.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
match c {
|
||||
'!' | '?' | '…' => Some(true),
|
||||
',' | ';' | ':' => Some(false),
|
||||
'.' => {
|
||||
let word = t[..i].rsplit(char::is_whitespace).next().unwrap_or("");
|
||||
let w = word.trim_start_matches(|c: char| !c.is_alphanumeric()).to_lowercase();
|
||||
let next = after.trim_start().chars().next();
|
||||
let abbreviation = w.chars().count() == 1 || ABBREVIATIONS.contains(&w.as_str());
|
||||
// "22. Dezember", "Dr. Ahmad", "z. B. morgen" sind kein Satzende
|
||||
let ordinal = !w.is_empty() && w.chars().all(|c| c.is_ascii_digit());
|
||||
(!abbreviation && !ordinal && !next.is_some_and(char::is_lowercase)).then_some(true)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_text(out: &mut Vec<Part>, t: &str) {
|
||||
if !t.trim().is_empty() {
|
||||
out.push(Part::Text(t.trim().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Textabschnitte an Satzenden (und optional Kommas) teilen und dort Pausen einsetzen.
|
||||
pub fn split_sentences(parts: Vec<Part>, sentence_ms: u32, comma_ms: u32) -> Vec<Part> {
|
||||
let mut out = Vec::new();
|
||||
for part in parts {
|
||||
let Part::Text(t) = part else {
|
||||
out.push(part);
|
||||
continue;
|
||||
};
|
||||
let mut start = 0;
|
||||
for (i, c) in t.char_indices() {
|
||||
let pause = match boundary_at(&t, i, c) {
|
||||
Some(true) if sentence_ms > 0 => sentence_ms,
|
||||
Some(false) if comma_ms > 0 => comma_ms,
|
||||
_ => continue,
|
||||
};
|
||||
let end = i + c.len_utf8();
|
||||
push_text(&mut out, &t[start..end]);
|
||||
out.push(Part::Pause(pause));
|
||||
start = end;
|
||||
}
|
||||
push_text(&mut out, &t[start..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Einen Abschnitt sprechen lassen: Stille an den Raendern weg, Tempo anpassen.
|
||||
fn speak(url: &str, request: &serde_json::Value, text: &str, job: &Job) -> Result<Vec<f32>, String> {
|
||||
let mut body = request.clone();
|
||||
body["input"] = json!(text);
|
||||
body["language"] = json!(job.model.language);
|
||||
body["response_format"] = json!("wav");
|
||||
body["sample_rate"] = json!(REF_SR);
|
||||
let (s, sr) = media::read_wav_bytes(&http::speech(url, &body)?)?;
|
||||
let s = media::trim_silence(&media::resample(&s, sr, REF_SR)?, REF_SR);
|
||||
media::change_tempo(&s, REF_SR, job.speed)
|
||||
}
|
||||
|
||||
/// Erzeugt die Ansage im Arbeitsordner `work`; `progress` meldet Zwischenstaende.
|
||||
pub fn render(job: &Job, work: &Path, progress: &dyn Fn(String)) -> Result<Rendered, String> {
|
||||
let parts = split_sentences(parse_text(&job.text), job.sentence_pause_ms, job.comma_pause_ms);
|
||||
let texts: Vec<&str> = parts.iter().filter_map(|p| if let Part::Text(t) = p { Some(t.as_str()) } else { None }).collect();
|
||||
if texts.is_empty() {
|
||||
return Err("Kein Text eingegeben.".into());
|
||||
}
|
||||
|
||||
let (url, request) = match &job.voice {
|
||||
Voice::Clone { audio, transcript, name } => {
|
||||
let url = job.model.clone_url.clone().ok_or("Dieses Modell unterstützt keine Klonstimmen.")?;
|
||||
let ref_samples = media::decode(audio, REF_SR, None)?;
|
||||
let ref_secs = media::seconds(ref_samples.len(), REF_SR);
|
||||
if ref_secs > job.model.max_ref_seconds {
|
||||
return Err(format!(
|
||||
"Vorlage von \"{name}\" ist {ref_secs:.1} s lang, erlaubt sind {:.0} s. Bitte über \"Neue Stimme\" neu anlegen (wird automatisch gekürzt).",
|
||||
job.model.max_ref_seconds
|
||||
));
|
||||
}
|
||||
let ref_text = std::fs::read_to_string(transcript).map_err(|e| format!("{}: {e}", transcript.display()))?;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(media::wav_bytes(&ref_samples, REF_SR));
|
||||
(url, json!({ "task_type": "Base", "ref_audio": format!("data:audio/wav;base64,{data}"), "ref_text": ref_text.trim() }))
|
||||
}
|
||||
Voice::Preset(name) => {
|
||||
let url = job.model.voice_url.clone().ok_or("Dieses Modell hat keine festen Stimmen.")?;
|
||||
(url, json!({ "voice": name }))
|
||||
}
|
||||
};
|
||||
|
||||
// Abschnitte parallel anfragen, Reihenfolge bleibt erhalten
|
||||
let mut spoken: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
|
||||
for chunk in texts.chunks(PARALLEL) {
|
||||
progress(format!("Spreche Abschnitt {}–{} von {} …", spoken.len() + 1, spoken.len() + chunk.len(), texts.len()));
|
||||
let results: Vec<Result<Vec<f32>, String>> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = chunk.iter().map(|t| s.spawn(|| speak(&url, &request, t, job))).collect();
|
||||
handles.into_iter().map(|h| h.join().unwrap_or_else(|_| Err("Abschnitt abgestürzt".into()))).collect()
|
||||
});
|
||||
for r in results {
|
||||
spoken.push(r?);
|
||||
}
|
||||
}
|
||||
let mut segments = spoken.iter();
|
||||
let mut speech: Vec<f32> = Vec::new();
|
||||
for part in &parts {
|
||||
match part {
|
||||
Part::Text(_) => speech.extend(segments.next().into_iter().flatten()),
|
||||
Part::Pause(ms) => speech.extend(std::iter::repeat_n(0.0, (REF_SR as u64 * *ms as u64 / 1000) as usize)),
|
||||
}
|
||||
}
|
||||
// kurzer Ausklang, sonst endet die Ansage hart nach dem letzten Wort
|
||||
speech.extend(std::iter::repeat_n(0.0, REF_SR as usize * 4 / 10));
|
||||
// Raumklang der Stimme durchgehend unterlegen: Pausen sind sonst digitale Stille,
|
||||
// was gegenueber dem Grundrauschen zwischen den Woertern auffaellt
|
||||
let bed = media::room_tone_bed(&media::room_tone_pool(&spoken, REF_SR), speech.len(), REF_SR);
|
||||
for (s, b) in speech.iter_mut().zip(bed) {
|
||||
*s += b;
|
||||
}
|
||||
|
||||
progress("Wandle ins Zielformat um …".into());
|
||||
// eindeutige Namen: eine laufende Wiedergabe der vorigen Datei stoert nicht
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
|
||||
let raw = work.join(format!("{stamp}_raw.wav"));
|
||||
let output = work.join(format!("{stamp}_ansage.{}", job.target.extension()));
|
||||
let preview = work.join(format!("{stamp}_vorhoeren.wav"));
|
||||
media::write_wav(&raw, &speech, REF_SR)?;
|
||||
|
||||
let raw_s = raw.to_string_lossy().into_owned();
|
||||
let mut args: Vec<String> = vec!["-i".into(), raw_s];
|
||||
if let Some(m) = &job.music {
|
||||
if !m.file.exists() {
|
||||
return Err(format!("Musikdatei nicht gefunden: {}", m.file.display()));
|
||||
}
|
||||
// Mix wie im bisherigen Azure-Skript, aber mit 2 s Nachlauf: der Fade-out am Ende
|
||||
// soll nur die Musik ausblenden, nicht die letzten Woerter
|
||||
let filter = format!(
|
||||
"[0:a]adelay={}:all=1,apad=pad_dur=2[sp];[1:a]volume={}[bg];\
|
||||
[sp][bg]amix=inputs=2:duration=first:dropout_transition=0,areverse,afade=t=in:d=2,areverse",
|
||||
(m.intro_secs * 1000.0).round() as u32,
|
||||
m.volume
|
||||
);
|
||||
args.extend(["-stream_loop".into(), "-1".into(), "-i".into(), m.file.to_string_lossy().into_owned(), "-filter_complex".into(), filter]);
|
||||
}
|
||||
args.extend(job.target.ffmpeg_out().iter().map(|s| s.to_string()));
|
||||
args.push(output.to_string_lossy().into_owned());
|
||||
media::ffmpeg(&args.iter().map(String::as_str).collect::<Vec<_>>())?;
|
||||
let _ = std::fs::remove_file(&raw);
|
||||
|
||||
let rate = job.target.preview_rate();
|
||||
let heard = media::decode(&output, rate, None)?;
|
||||
media::write_wav(&preview, &heard, rate)?;
|
||||
Ok(Rendered { output, preview, secs: media::seconds(heard.len(), rate), target: job.target })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pausen_tags() {
|
||||
assert_eq!(
|
||||
parse_text("Hallo <break time=\"500ms\"/> Welt <break time='1.5s' /> Ende"),
|
||||
vec![Part::Text("Hallo".into()), Part::Pause(500), Part::Text("Welt".into()), Part::Pause(1500), Part::Text("Ende".into())]
|
||||
);
|
||||
assert_eq!(parse_text("<speak>Grüß <prosody rate=\"slow\">Gott</prosody></speak>"), vec![Part::Text("Grüß Gott".into())]);
|
||||
assert_eq!(parse_text("A <break/> B"), vec![Part::Text("A".into()), Part::Pause(500), Part::Text("B".into())]);
|
||||
assert_eq!(parse_text(" <break time=\"1s\"/> "), vec![Part::Pause(1000)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dgx_tests {
|
||||
use super::*;
|
||||
use crate::{config::Config, voices};
|
||||
|
||||
/// Gegen die echten Server: cargo test -- --ignored --nocapture
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn ansage_mit_klonstimme_alle_ziele() {
|
||||
let cfg = Config::default();
|
||||
let voice = voices::list_clones(&cfg.voice_dir).into_iter().find(|v| v.label() == "martina").expect("martina");
|
||||
let work = std::env::temp_dir().join("tts-studio-test");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
for (target, music) in [(Target::AsteriskWav, false), (Target::AsteriskG722, true), (Target::WhatsApp, false)] {
|
||||
let job = Job {
|
||||
model: cfg.models[0].clone(),
|
||||
voice: voice.clone(),
|
||||
text: "Guten Tag. <break time=\"700ms\"/> Bitte haben Sie einen Moment Geduld.".into(),
|
||||
music: music.then(|| Music { file: cfg.music_file.clone(), volume: 0.15, intro_secs: 2.0 }),
|
||||
target,
|
||||
speed: 1.0,
|
||||
sentence_pause_ms: 800,
|
||||
comma_pause_ms: 0,
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = render(&job, &work, &|m| println!(" {m}")).unwrap();
|
||||
println!("{:?}: {:.1} s Audio in {:.1} s -> {}", target, r.secs, t0.elapsed().as_secs_f32(), r.output.display());
|
||||
assert!(r.secs > 2.0 && r.output.exists() && r.preview.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn neue_stimme_wird_gekuerzt_und_transkribiert() {
|
||||
let cfg = Config::default();
|
||||
let work = std::env::temp_dir().join("tts-studio-test");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let p = voices::prepare(&cfg.voice_dir.join("alex.wav"), &cfg, 30.0, &work).unwrap();
|
||||
println!("{:.1} s -> {:.1} s (gekuerzt: {})\n{}", p.original_secs, p.secs, p.shortened, p.transcript);
|
||||
assert!(p.shortened && p.secs <= 29.0 && p.secs > 15.0);
|
||||
assert!(p.transcript.contains("Nussbaumer"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod satz_tests {
|
||||
use super::*;
|
||||
|
||||
fn split(t: &str, s: u32, c: u32) -> Vec<Part> {
|
||||
split_sentences(parse_text(t), s, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satzenden() {
|
||||
let t = "Willkommen bei Dr. Ahmad. Wir haben vom 22. Dezember bis 6. Jänner zu, z. B. morgen. Rufen Sie an! Danke";
|
||||
assert_eq!(
|
||||
split(t, 800, 0),
|
||||
vec![
|
||||
Part::Text("Willkommen bei Dr. Ahmad.".into()),
|
||||
Part::Pause(800),
|
||||
Part::Text("Wir haben vom 22. Dezember bis 6. Jänner zu, z. B. morgen.".into()),
|
||||
Part::Pause(800),
|
||||
Part::Text("Rufen Sie an!".into()),
|
||||
Part::Pause(800),
|
||||
Part::Text("Danke".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kommas_und_uhrzeiten() {
|
||||
assert_eq!(
|
||||
split("Montag von 8:30 bis 17,30 Uhr, Dienstag geschlossen. Ende.", 0, 300),
|
||||
vec![Part::Text("Montag von 8:30 bis 17,30 Uhr,".into()), Part::Pause(300), Part::Text("Dienstag geschlossen. Ende.".into())]
|
||||
);
|
||||
// ohne Pausen bleibt alles ein Abschnitt; explizite Pausen bleiben erhalten
|
||||
assert_eq!(split("A. B. <break time=\"1s\"/> C.", 0, 0), vec![Part::Text("A. B.".into()), Part::Pause(1000), Part::Text("C.".into())]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tempo_tests {
|
||||
use super::*;
|
||||
use crate::{config::Config, voices};
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn tempo_und_satzpausen() {
|
||||
let cfg = Config::default();
|
||||
let voice = voices::list_clones(&cfg.voice_dir).into_iter().find(|v| v.label() == "martina").expect("martina");
|
||||
let work = std::env::temp_dir().join("tts-studio-tempo");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let text = "Herzlich willkommen in der Ordination von Doktor Ahmad und Doktor Nussbaumer, Gruppenpraxis für Psychiatrie und psychotherapeutische Medizin. \
|
||||
Leider rufen Sie außerhalb der Ordinationszeiten an. Unsere Ordinationszeiten sind Montag und Dienstag von acht Uhr dreißig bis siebzehn Uhr dreißig. \
|
||||
Mittwoch und Freitag von acht Uhr dreißig bis vierzehn Uhr. Vielen Dank!";
|
||||
for (speed, sentence, comma) in [(1.0, 0, 0), (1.0, 800, 0), (0.9, 800, 0), (1.0, 800, 300)] {
|
||||
let job = Job { model: cfg.models[0].clone(), voice: voice.clone(), text: text.into(), music: None, target: Target::WhatsApp, speed, sentence_pause_ms: sentence, comma_pause_ms: comma };
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = render(&job, &work, &|_| {}).unwrap();
|
||||
let name = work.join(format!("tempo{speed}_satz{sentence}_komma{comma}.wav"));
|
||||
std::fs::copy(&r.preview, &name).unwrap();
|
||||
println!("{:>4}x Satz {sentence:>3} ms Komma {comma:>3} ms: {:5.1} s Audio in {:4.1} s -> {}", speed, r.secs, t0.elapsed().as_secs_f32(), name.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Stimmen: Klonstimmen aus dem Stimmenordner und feste Stimmen des Modells,
|
||||
//! sowie das Anlegen neuer Klonstimmen (aufbereiten, kuerzen, transkribieren).
|
||||
|
||||
use crate::{config::Config, http, media};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Abtastrate der Vorlagen (= Ausgaberate von Qwen3-TTS)
|
||||
pub const REF_SR: u32 = 24000;
|
||||
const AUDIO_EXT: [&str; 6] = ["wav", "mp3", "m4a", "ogg", "flac", "opus"];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Voice {
|
||||
Clone { name: String, audio: PathBuf, transcript: PathBuf },
|
||||
Preset(String),
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Voice::Clone { name, .. } => name.clone(),
|
||||
Voice::Preset(name) => format!("{name} (feste Stimme)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alle Audiodateien mit gleichnamigem Transkript (.txt). "alex_ref.wav" erscheint als "alex".
|
||||
pub fn list_clones(dir: &Path) -> Vec<Voice> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut voices: Vec<Voice> = entries
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()).is_some_and(|e| AUDIO_EXT.contains(&e.to_ascii_lowercase().as_str())))
|
||||
.filter_map(|audio| {
|
||||
let transcript = audio.with_extension("txt");
|
||||
let stem = audio.file_stem()?.to_str()?;
|
||||
let name = stem.strip_suffix("_ref").unwrap_or(stem).to_string();
|
||||
transcript.exists().then_some(Voice::Clone { name, audio, transcript })
|
||||
})
|
||||
.collect();
|
||||
voices.sort_by_key(Voice::label);
|
||||
voices
|
||||
}
|
||||
|
||||
/// Dateiname aus frei eingegebenem Namen: klein, nur a-z 0-9 _ -, Umlaute ausgeschrieben.
|
||||
pub fn sanitize_name(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.trim().to_lowercase().chars() {
|
||||
match c {
|
||||
'a'..='z' | '0'..='9' | '_' | '-' => out.push(c),
|
||||
'ä' => out.push_str("ae"),
|
||||
'ö' => out.push_str("oe"),
|
||||
'ü' => out.push_str("ue"),
|
||||
'ß' => out.push_str("ss"),
|
||||
' ' | '.' => out.push('_'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct Prepared {
|
||||
pub wav: PathBuf,
|
||||
pub secs: f32,
|
||||
pub original_secs: f32,
|
||||
pub shortened: bool,
|
||||
pub transcript: String,
|
||||
}
|
||||
|
||||
/// Aufnahme -> Vorlage: Brummen raus (Hochpass 80 Hz), Pegel angleichen, Stille an den
|
||||
/// Raendern entfernen, auf das Server-Limit kuerzen, per Whisper transkribieren.
|
||||
/// Bewusst kein Entrauschen: Artefakte wuerden mitgeklont.
|
||||
pub fn prepare(input: &Path, cfg: &Config, max_secs: f32, work: &Path) -> Result<Prepared, String> {
|
||||
let x = media::decode(input, REF_SR, Some("highpass=f=80,loudnorm=I=-20:TP=-2:LRA=11"))?;
|
||||
if media::seconds(x.len(), REF_SR) < 3.0 {
|
||||
return Err("Aufnahme ist zu kurz (mindestens 3 Sekunden Sprache).".into());
|
||||
}
|
||||
let t = media::trim_reference(&x, REF_SR, max_secs);
|
||||
let wav = work.join("neue_stimme.wav");
|
||||
media::write_wav(&wav, &t.samples, REF_SR)?;
|
||||
let transcript = http::transcribe(&cfg.whisper_url, &cfg.whisper_model, &media::wav_bytes(&t.samples, REF_SR))?;
|
||||
Ok(Prepared {
|
||||
wav,
|
||||
secs: media::seconds(t.samples.len(), REF_SR),
|
||||
original_secs: t.original_secs,
|
||||
shortened: t.shortened,
|
||||
transcript,
|
||||
})
|
||||
}
|
||||
|
||||
/// Vorlage + Transkript in den Stimmenordner legen. Ueberschreibt nichts.
|
||||
pub fn save(dir: &Path, name: &str, prepared_wav: &Path, transcript: &str) -> Result<Voice, String> {
|
||||
let name = sanitize_name(name);
|
||||
if name.is_empty() {
|
||||
return Err("Bitte einen Namen eingeben.".into());
|
||||
}
|
||||
if transcript.trim().is_empty() {
|
||||
return Err("Das Transkript ist leer.".into());
|
||||
}
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
let audio = dir.join(format!("{name}.wav"));
|
||||
let txt = dir.join(format!("{name}.txt"));
|
||||
if audio.exists() || txt.exists() || dir.join(format!("{name}_ref.wav")).exists() {
|
||||
return Err(format!("Im Stimmenordner gibt es bereits \"{name}\" – bitte anderen Namen wählen."));
|
||||
}
|
||||
std::fs::copy(prepared_wav, &audio).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&txt, format!("{}\n", transcript.trim())).map_err(|e| e.to_string())?;
|
||||
Ok(Voice::Clone { name, audio, transcript: txt })
|
||||
}
|
||||
Reference in new issue
Block a user