1use crate::error::FttsError;
27use ftts_core::{
28 CancellationToken, EngineError, FrameGenerator, GenerationError, NormalizationOptions,
29 NormalizationTrace, PreparedText, SynthesisObserver, SynthesisRequest, TextPreparationError,
30 TextPreparer, TtsEngine,
31};
32use ftts_model_qwen::checkpoint::{
33 CODEC_LANGUAGE_ENGLISH_ID, CheckpointError, CodecCheckpoint, TALKER_HIDDEN, TalkerCheckpoint,
34};
35use ftts_model_qwen::generate::{QwenGenerator, QwenGeneratorConfig};
36use ftts_model_qwen::microdecoder::MicrodecoderConfig;
37use ftts_model_qwen::prompt::{CloneMode, PromptMode};
38use ftts_model_qwen::sampler::SamplingMode;
39use ftts_model_qwen::speaker::{
40 Encoder as SpeakerEncoder, SPEAKER_SAMPLE_RATE_HZ, log_mel_from_24khz_pcm,
41};
42use ftts_model_qwen::talker::TalkerConfig;
43use ftts_model_qwen::tokenizer::{QwenTokenizer, TokenizerFiles};
44use std::fs;
45use std::fs::OpenOptions;
46use std::path::{Path, PathBuf};
47use symphonia::core::audio::SampleBuffer;
48use symphonia::core::codecs::DecoderOptions;
49use symphonia::core::errors::Error as SymphoniaError;
50use symphonia::core::formats::FormatOptions;
51use symphonia::core::io::MediaSourceStream;
52use symphonia::core::meta::MetadataOptions;
53use symphonia::core::probe::Hint;
54use symphonia::default::{get_codecs, get_probe};
55
56pub const SPEAKER_VECTOR_BYTES: usize = TALKER_HIDDEN * 4;
58
59const CANONICAL_MODEL_BASENAME: &str = "qwen3-tts-12hz-0.6b-base.fttsq";
60
61fn checkpoint_error(error: CheckpointError) -> FttsError {
62 FttsError::ArtifactFormat(error.to_string())
63}
64
65#[derive(Clone, Debug)]
67pub struct ModelBundle {
68 pub root: PathBuf,
70 pub main: PathBuf,
73 pub canonical_main: Option<PathBuf>,
75 pub codec: PathBuf,
77}
78
79impl ModelBundle {
80 pub fn resolve(model: &Path) -> Result<Self, FttsError> {
88 let root = if model.is_dir() {
89 model.to_path_buf()
90 } else {
91 model
92 .parent()
93 .ok_or_else(|| {
94 FttsError::ModelNotFound(format!(
95 "model path {} has no parent directory",
96 model.display()
97 ))
98 })?
99 .to_path_buf()
100 };
101 let canonical_main = if model.is_dir() {
102 let canonical = root.join(CANONICAL_MODEL_BASENAME);
103 if canonical.is_file() {
104 Some(canonical)
105 } else {
106 None
107 }
108 } else if model.extension().and_then(|extension| extension.to_str()) == Some("fttsq") {
109 Some(model.to_path_buf())
110 } else {
111 None
112 };
113 let main = root.join("model.safetensors");
114 let codec = root.join("speech_tokenizer/model.safetensors");
115 let (main_label, main_path) = match canonical_main.as_ref() {
116 Some(path) => ("canonical talker artifact", path),
117 None => ("talker checkpoint", &main),
118 };
119 for (label, path) in [
120 (main_label, main_path),
121 ("codec checkpoint", &codec),
122 ("tokenizer vocabulary", &root.join("vocab.json")),
123 ("tokenizer merges", &root.join("merges.txt")),
124 ("tokenizer config", &root.join("tokenizer_config.json")),
125 ] {
126 if !path.is_file() {
127 return Err(FttsError::ModelNotFound(format!(
128 "{label} is missing at {}; `ftts say` needs a complete model directory \
129 ({CANONICAL_MODEL_BASENAME} or model.safetensors, \
130 speech_tokenizer/model.safetensors, \
131 vocab.json, merges.txt, tokenizer_config.json)",
132 path.display()
133 )));
134 }
135 }
136 Ok(Self {
137 root,
138 main,
139 canonical_main,
140 codec,
141 })
142 }
143}
144
145pub struct LoadedModel {
147 talker: TalkerCheckpoint,
148 codec: CodecCheckpoint,
149 tokenizer: QwenTokenizer,
150 artifact: Option<std::sync::Arc<ftts_artifacts::fttsq::MappedFttsq>>,
155}
156
157impl LoadedModel {
158 pub fn load(bundle: &ModelBundle) -> Result<Self, FttsError> {
164 let read = |name: &str| -> Result<String, FttsError> {
165 let path = bundle.root.join(name);
166 fs::read_to_string(&path).map_err(|error| {
167 FttsError::ArtifactFormat(format!("cannot read {}: {error}", path.display()))
168 })
169 };
170 let vocab = read("vocab.json")?;
171 let merges = read("merges.txt")?;
172 let config = read("tokenizer_config.json")?;
173
174 let (talker, codec, tokenizer) = std::thread::scope(|scope| {
178 let codec = scope.spawn(|| CodecCheckpoint::load(&bundle.codec));
179 let tokenizer = scope.spawn(|| {
180 QwenTokenizer::from_files_using_environment(TokenizerFiles {
181 vocab_json: &vocab,
182 merges_txt: &merges,
183 tokenizer_config_json: &config,
184 })
185 });
186 let talker = match bundle.canonical_main.as_deref() {
187 Some(path) => TalkerCheckpoint::load_fttsq_elided(
191 path,
192 ftts_model_qwen::generate::hot_elision_from_environment(),
193 ),
194 None => TalkerCheckpoint::load(&bundle.main),
195 };
196 (
197 talker,
198 codec.join().expect("codec loader panicked"),
199 tokenizer.join().expect("tokenizer builder panicked"),
200 )
201 });
202 let tokenizer = tokenizer
203 .map_err(|error| FttsError::ArtifactFormat(format!("tokenizer unusable: {error}")))?;
204
205 let talker = talker.map_err(checkpoint_error)?;
206 let artifact = talker.artifact().cloned();
209 Ok(Self {
210 talker,
211 codec: codec.map_err(checkpoint_error)?,
212 tokenizer,
213 artifact,
214 })
215 }
216}
217
218pub fn read_speaker_vector(path: &Path) -> Result<Vec<f32>, FttsError> {
228 let bytes = fs::read(path).map_err(|error| {
229 FttsError::Input(format!(
230 "cannot read speaker vector {}: {error}",
231 path.display()
232 ))
233 })?;
234 if bytes.len() != SPEAKER_VECTOR_BYTES {
235 return Err(FttsError::Input(format!(
236 "speaker vector {} is {} bytes; `ftts say --voice` expects exactly {} \
237 ({TALKER_HIDDEN} little-endian f32)",
238 path.display(),
239 bytes.len(),
240 SPEAKER_VECTOR_BYTES
241 )));
242 }
243 let vector: Vec<f32> = bytes
244 .as_chunks::<4>()
245 .0
246 .iter()
247 .map(|quad| f32::from_le_bytes(*quad))
248 .collect();
249 if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
250 return Err(FttsError::Input(format!(
251 "speaker vector {} holds a non-finite value at index {index}; it would poison every \
252 prefill position it is summed into",
253 path.display()
254 )));
255 }
256 Ok(vector)
257}
258
259#[derive(Clone, Copy, Debug)]
262pub struct DenoiseReport {
263 pub before_dbfs: f32,
265 pub after_dbfs: f32,
267}
268
269#[derive(Default)]
274pub struct ReferenceCleanup<'a> {
275 pub denoise: Option<&'a mut Option<DenoiseReport>>,
277 pub dereverb: Option<&'a mut Option<DereverbReport>>,
279}
280
281pub fn speaker_from_voice(
292 bundle: &ModelBundle,
293 path: &Path,
294 cleanup: ReferenceCleanup<'_>,
295) -> Result<Vec<f32>, FttsError> {
296 let bytes = fs::read(path).map_err(|error| {
297 FttsError::Input(format!(
298 "cannot read voice source {}: {error}",
299 path.display()
300 ))
301 })?;
302 let looks_like_audio = bytes.len() >= 12
306 && (bytes.starts_with(b"RIFF")
307 || bytes.starts_with(b"fLaC")
308 || bytes.starts_with(b"ID3")
309 || bytes.starts_with(b"OggS")
310 || &bytes[4..8] == b"ftyp");
311 let looks_like_image = bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
319 || bytes.starts_with(&[0xFF, 0xD8, 0xFF]);
320 if looks_like_image {
321 let (_, vector) = crate::card::decode_card(&bytes)?;
322 return Ok(vector);
323 }
324 if bytes.len() == SPEAKER_VECTOR_BYTES && !looks_like_audio {
325 return decode_speaker_vector(path, &bytes);
326 }
327 let pcm = decode_reference_audio_any(path)?;
328 speaker_from_reference_pcm(bundle, pcm, cleanup)
329}
330
331pub fn speaker_from_reference_pcm(
342 bundle: &ModelBundle,
343 pcm: Vec<f32>,
344 cleanup: ReferenceCleanup<'_>,
345) -> Result<Vec<f32>, FttsError> {
346 let ReferenceCleanup { denoise, dereverb } = cleanup;
347 let pcm = match dereverb {
348 Some(report) => {
349 let before = reverb_time_s(&pcm);
350 let dried = dereverb_reference(&pcm);
351 let after = reverb_time_s(&dried);
352 if let (Some(before), Some(after)) = (before, after) {
353 *report = Some(DereverbReport {
354 before_rt60_s: before,
355 after_rt60_s: after,
356 });
357 }
358 dried
359 }
360 None => pcm,
361 };
362 let pcm = match denoise {
363 Some(report) => {
364 let before = pause_floor_dbfs(&pcm);
365 let cleaned = match neural_denoise_reference(bundle, &pcm)? {
366 Some(cleaned) => cleaned,
367 None => denoise_reference(&pcm),
368 };
369 *report = Some(DenoiseReport {
370 before_dbfs: before,
371 after_dbfs: pause_floor_dbfs(&cleaned),
372 });
373 cleaned
374 }
375 None => pcm,
376 };
377 let mel = log_mel_from_24khz_pcm(&pcm)
378 .map_err(|error| FttsError::Input(format!("cannot extract speaker features: {error}")))?;
379 let encoder = match bundle.canonical_main.as_deref() {
380 Some(artifact) => SpeakerEncoder::load_fttsq(artifact),
381 None => SpeakerEncoder::load(&bundle.main),
382 }
383 .map_err(checkpoint_error)?;
384 let vector = encoder.encode(&mel.values, mel.frames);
385 if vector.iter().all(|value| value.is_finite()) {
386 Ok(vector)
387 } else {
388 Err(FttsError::Input(
389 "speaker encoder produced a non-finite x-vector; refusing to condition synthesis"
390 .to_owned(),
391 ))
392 }
393}
394
395pub fn write_speaker_vector_new(path: &Path, vector: &[f32]) -> Result<(), FttsError> {
397 if vector.len() != TALKER_HIDDEN {
398 return Err(FttsError::Input(format!(
399 "cannot write {}-wide speaker vector; expected {TALKER_HIDDEN}",
400 vector.len()
401 )));
402 }
403 if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
404 return Err(FttsError::Input(format!(
405 "cannot write speaker vector with a non-finite value at index {index}"
406 )));
407 }
408 let mut bytes = Vec::with_capacity(SPEAKER_VECTOR_BYTES);
409 for value in vector {
410 bytes.extend_from_slice(&value.to_le_bytes());
411 }
412 use std::io::Write;
413 let mut file = OpenOptions::new()
414 .write(true)
415 .create_new(true)
416 .open(path)
417 .map_err(|error| {
418 FttsError::Input(format!(
419 "cannot create enrolled voice {} without overwriting an existing file: {error}",
420 path.display()
421 ))
422 })?;
423 file.write_all(&bytes).map_err(|error| {
424 FttsError::Input(format!(
425 "cannot write enrolled voice {}: {error}",
426 path.display()
427 ))
428 })
429}
430
431pub fn replace_speaker_vector(path: &Path, vector: &[f32]) -> Result<PathBuf, FttsError> {
441 let backup = path.with_extension("spk.bak");
442 fs::copy(path, &backup).map_err(|error| {
443 FttsError::Input(format!(
444 "cannot back up the existing voice {} to {}: {error}",
445 path.display(),
446 backup.display()
447 ))
448 })?;
449 let staging = path.with_extension("spk.incoming");
452 if staging.exists() {
453 fs::remove_file(&staging).map_err(|error| {
454 FttsError::Input(format!(
455 "cannot clear the stale staging file {}: {error}",
456 staging.display()
457 ))
458 })?;
459 }
460 write_speaker_vector_new(&staging, vector)?;
461 fs::rename(&staging, path).map_err(|error| {
462 FttsError::Input(format!(
463 "cannot replace {} with the new voice: {error}",
464 path.display()
465 ))
466 })?;
467 Ok(backup)
468}
469
470fn decode_speaker_vector(path: &Path, bytes: &[u8]) -> Result<Vec<f32>, FttsError> {
471 let vector: Vec<f32> = bytes
472 .as_chunks::<4>()
473 .0
474 .iter()
475 .map(|quad| f32::from_le_bytes(*quad))
476 .collect();
477 if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
478 return Err(FttsError::Input(format!(
479 "speaker vector {} holds a non-finite value at index {index}; it would poison every \
480 prefill position it is summed into",
481 path.display()
482 )));
483 }
484 Ok(vector)
485}
486
487const SYSTEM_DECODED_EXTENSIONS: [&str; 6] = ["m4a", "mp3", "aac", "mp4", "ogg", "opus"];
491
492pub(crate) fn private_staging_dir() -> std::io::Result<PathBuf> {
502 #[allow(deprecated)] let base = std::env::home_dir()
504 .map(|home| home.join(".cache/franken_tts"))
505 .unwrap_or_else(std::env::temp_dir);
506 let dir = base.join("staging");
507 fs::create_dir_all(&dir)?;
508 #[cfg(unix)]
509 {
510 use std::os::unix::fs::PermissionsExt;
511 fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
512 }
513 Ok(dir)
514}
515
516fn decode_reference_audio_any(path: &Path) -> Result<Vec<f32>, FttsError> {
522 let extension = path
523 .extension()
524 .and_then(|extension| extension.to_str())
525 .map(str::to_ascii_lowercase);
526 let needs_system_decoder = extension
527 .as_deref()
528 .is_some_and(|extension| SYSTEM_DECODED_EXTENSIONS.contains(&extension));
529 if !needs_system_decoder {
530 return decode_reference_audio(path);
531 }
532
533 let staging_dir = private_staging_dir()
534 .map_err(|error| FttsError::Generic(format!("cannot create staging directory: {error}")))?;
535 let staging = staging_dir.join(format!(
536 "ftts-enroll-{}-{}.wav",
537 std::process::id(),
538 path.file_stem()
539 .and_then(|stem| stem.to_str())
540 .unwrap_or("reference")
541 ));
542 let attempts: &[(&str, Vec<&std::ffi::OsStr>)] = &[
543 (
548 "afconvert",
549 vec![
550 "-f".as_ref(),
551 "WAVE".as_ref(),
552 "-d".as_ref(),
553 "LEI16@24000".as_ref(),
554 "-c".as_ref(),
555 "1".as_ref(),
556 path.as_os_str(),
557 staging.as_os_str(),
558 ],
559 ),
560 (
561 "ffmpeg",
562 vec![
563 "-y".as_ref(),
564 "-loglevel".as_ref(),
565 "error".as_ref(),
566 "-i".as_ref(),
567 path.as_os_str(),
568 "-acodec".as_ref(),
569 "pcm_s16le".as_ref(),
570 "-ar".as_ref(),
571 "24000".as_ref(),
572 "-ac".as_ref(),
573 "1".as_ref(),
574 staging.as_os_str(),
575 ],
576 ),
577 ];
578 let mut ran = false;
579 for (tool, arguments) in attempts {
580 match std::process::Command::new(tool).args(arguments).status() {
581 Ok(status) if status.success() => {
582 ran = true;
583 break;
584 }
585 Ok(status) => {
586 let _ = fs::remove_file(&staging);
587 return Err(FttsError::Input(format!(
588 "{tool} failed decoding reference audio {} (exit {status})",
589 path.display()
590 )));
591 }
592 Err(_) => continue, }
594 }
595 if !ran {
596 return Err(FttsError::Input(format!(
597 "reference audio {} is a compressed container and no system decoder was found; \
598 install afconvert (macOS) or ffmpeg, or supply WAV/FLAC",
599 path.display()
600 )));
601 }
602 let decoded = decode_reference_audio(&staging);
603 let _ = fs::remove_file(&staging);
604 decoded
605}
606
607fn decode_reference_audio(path: &Path) -> Result<Vec<f32>, FttsError> {
608 let file = fs::File::open(path).map_err(|error| {
609 FttsError::Input(format!(
610 "cannot open reference audio {}: {error}",
611 path.display()
612 ))
613 })?;
614 let mut hint = Hint::new();
615 if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) {
616 hint.with_extension(extension);
617 }
618 let stream = MediaSourceStream::new(Box::new(file), Default::default());
619 let probed = get_probe()
620 .format(
621 &hint,
622 stream,
623 &FormatOptions::default(),
624 &MetadataOptions::default(),
625 )
626 .map_err(|error| {
627 FttsError::Input(format!(
628 "cannot identify reference audio {}: {error}",
629 path.display()
630 ))
631 })?;
632 let mut format = probed.format;
633 let track = format.default_track().ok_or_else(|| {
634 FttsError::Input(format!(
635 "reference audio {} has no default audio track",
636 path.display()
637 ))
638 })?;
639 let track_id = track.id;
640 let mut decoder = get_codecs()
641 .make(&track.codec_params, &DecoderOptions::default())
642 .map_err(|error| {
643 FttsError::Input(format!(
644 "cannot decode reference audio {}: {error}",
645 path.display()
646 ))
647 })?;
648 let mut sample_rate = None;
649 let mut mono = Vec::new();
650 loop {
651 let packet = match format.next_packet() {
652 Ok(packet) => packet,
653 Err(SymphoniaError::IoError(error))
654 if error.kind() == std::io::ErrorKind::UnexpectedEof =>
655 {
656 break;
657 }
658 Err(error) => {
659 return Err(FttsError::Input(format!(
660 "cannot read reference audio {}: {error}",
661 path.display()
662 )));
663 }
664 };
665 if packet.track_id() != track_id {
666 continue;
667 }
668 let decoded = decoder.decode(&packet).map_err(|error| {
669 FttsError::Input(format!(
670 "cannot decode reference audio {}: {error}",
671 path.display()
672 ))
673 })?;
674 let spec = *decoded.spec();
675 match sample_rate {
676 Some(rate) if rate != spec.rate => {
677 return Err(FttsError::Input(format!(
678 "reference audio {} changed sample rate mid-stream ({rate} to {} Hz)",
679 path.display(),
680 spec.rate
681 )));
682 }
683 None => sample_rate = Some(spec.rate),
684 Some(_) => {}
685 }
686 let channels = spec.channels.count();
687 let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
688 samples.copy_interleaved_ref(decoded);
689 for frame in samples.samples().chunks_exact(channels) {
690 mono.push(frame.iter().sum::<f32>() / channels as f32);
691 }
692 }
693 let rate = sample_rate.ok_or_else(|| {
694 FttsError::Input(format!(
695 "reference audio {} contains no decodable samples",
696 path.display()
697 ))
698 })?;
699 if mono.is_empty() {
700 return Err(FttsError::Input(format!(
701 "reference audio {} contains no PCM samples",
702 path.display()
703 )));
704 }
705 let pcm = resample_to_speaker_rate(mono, rate);
706 if pcm.is_empty() {
710 return Err(FttsError::Input(format!(
711 "reference audio {} is too short to resample from {rate} Hz to \
712 {SPEAKER_SAMPLE_RATE_HZ} Hz; supply a longer recording",
713 path.display()
714 )));
715 }
716 Ok(pcm)
717}
718
719fn resample_to_speaker_rate(mono: Vec<f32>, from_rate: u32) -> Vec<f32> {
733 if from_rate == SPEAKER_SAMPLE_RATE_HZ {
734 return mono;
735 }
736 resample_lanczos(&mono, from_rate, SPEAKER_SAMPLE_RATE_HZ)
737}
738
739fn resample_lanczos(mono: &[f32], from_rate: u32, to_rate: u32) -> Vec<f32> {
741 if from_rate == to_rate {
742 return mono.to_vec();
743 }
744 const LOBES: f64 = 6.0;
749 let ratio = f64::from(to_rate) / f64::from(from_rate);
750 let cutoff = ratio.min(1.0);
751 let half = (LOBES / cutoff).ceil() as isize;
752 let out_len = ((mono.len() as f64) * ratio).round() as usize;
753
754 let mut out = Vec::with_capacity(out_len);
755 for index in 0..out_len {
756 let center = index as f64 / ratio;
757 let first = center.floor() as isize - half + 1;
758 let mut acc = 0.0_f64;
759 let mut norm = 0.0_f64;
760 for tap in first..first + 2 * half {
761 if tap < 0 {
762 continue;
763 }
764 let Some(sample) = mono.get(tap as usize) else {
765 break;
766 };
767 let weight = lanczos_tap(center - tap as f64, cutoff, LOBES);
768 acc += weight * f64::from(*sample);
769 norm += weight;
770 }
771 out.push(if norm.abs() > 1e-12 {
772 (acc / norm) as f32
773 } else {
774 0.0
775 });
776 }
777 out
778}
779
780const DENOISE_FRAME: usize = 512;
783
784const DENOISE_HOP: usize = DENOISE_FRAME / 4;
787
788const DD_ALPHA: f32 = 0.92;
791
792const OMLSA_GAIN_FLOOR: f32 = 0.017_782_79;
800
801const NOISE_BLOCK_FRAMES: usize = 256;
804
805const SPEECH_ABSENCE_PRIOR: f32 = 0.6;
808
809#[allow(clippy::excessive_precision)]
816const NOISE_QUANTILE_BIAS: f32 = 9.491_221; const NOISE_INIT_QUANTILE: f32 = 0.1;
821
822pub const DENOISE_ARTIFACT_RELPATH: &str = "denoise/fastenhancer-s-48k.safetensors";
871
872fn neural_denoise_reference(
888 bundle: &ModelBundle,
889 pcm24k: &[f32],
890) -> Result<Option<Vec<f32>>, FttsError> {
891 let path = bundle.root.join(DENOISE_ARTIFACT_RELPATH);
892 if !path.is_file() {
893 return Ok(None);
894 }
895 if std::env::var("FTTS_DENOISE_ENGINE").is_ok_and(|v| v.eq_ignore_ascii_case("omlsa")) {
896 return Ok(None);
897 }
898 let enhancer = ftts_artifacts::enhance_loader::open_enhancer(&path).map_err(|error| {
899 FttsError::ArtifactFormat(format!(
900 "denoiser artifact {} is unreadable ({error}); re-fetch it with `ftts pull --force`",
901 path.display()
902 ))
903 })?;
904 Ok(Some(enhancer.enhance_24k(pcm24k)))
905}
906
907pub fn denoise_pcm_24k(bundle: &ModelBundle, pcm: &[f32]) -> Result<Option<Vec<f32>>, FttsError> {
918 neural_denoise_reference(bundle, pcm)
919}
920
921fn denoise_reference(pcm: &[f32]) -> Vec<f32> {
922 const DENOISE_MIN_FRAMES: usize = 32;
926 if pcm.len() < DENOISE_FRAME + (DENOISE_MIN_FRAMES - 1) * DENOISE_HOP {
927 return pcm.to_vec();
928 }
929 let mut planner = rustfft::FftPlanner::<f32>::new();
930 let forward = planner.plan_fft_forward(DENOISE_FRAME);
931 let inverse = planner.plan_fft_inverse(DENOISE_FRAME);
932
933 let window: Vec<f32> = (0..DENOISE_FRAME)
934 .map(|n| {
935 let phase = std::f32::consts::TAU * n as f32 / DENOISE_FRAME as f32;
936 0.5 - 0.5 * phase.cos()
937 })
938 .collect();
939
940 let bins = DENOISE_FRAME / 2 + 1;
941 let starts: Vec<usize> = (0..=pcm.len() - DENOISE_FRAME)
942 .step_by(DENOISE_HOP)
943 .collect();
944
945 let mut powers: Vec<Vec<f32>> = Vec::with_capacity(starts.len());
948 let mut scratch: Vec<rustfft::num_complex::Complex<f32>> =
949 vec![rustfft::num_complex::Complex::new(0.0, 0.0); DENOISE_FRAME];
950 for &start in &starts {
951 for (slot, n) in scratch.iter_mut().zip(0..DENOISE_FRAME) {
952 *slot = rustfft::num_complex::Complex::new(pcm[start + n] * window[n], 0.0);
953 }
954 forward.process(&mut scratch);
955 powers.push((0..bins).map(|bin| scratch[bin].norm_sqr()).collect());
956 }
957
958 let blocks = powers.len().div_ceil(NOISE_BLOCK_FRAMES);
973 let mut noise = vec![f32::INFINITY; bins];
974 let mut column: Vec<f32> = Vec::with_capacity(NOISE_BLOCK_FRAMES);
975 for block in 0..blocks {
976 let span = block * NOISE_BLOCK_FRAMES..((block + 1) * NOISE_BLOCK_FRAMES).min(powers.len());
977 for (bin, slot) in noise.iter_mut().enumerate() {
978 column.clear();
979 column.extend(powers[span.clone()].iter().map(|frame| frame[bin]));
980 column.sort_by(f32::total_cmp);
981 let rank = ((column.len() as f32 - 1.0) * NOISE_INIT_QUANTILE).round() as usize;
982 *slot = slot.min(column[rank].max(1e-12) * NOISE_QUANTILE_BIAS);
983 }
984 }
985
986 let mut prev_gain = vec![1.0_f32; bins];
988 let mut prev_gamma = vec![1.0_f32; bins];
989
990 let mut out = vec![0.0_f32; pcm.len()];
991 let mut weight = vec![0.0_f32; pcm.len()];
992
993 for (index, &start) in starts.iter().enumerate() {
994 let mut frame: Vec<rustfft::num_complex::Complex<f32>> = (0..DENOISE_FRAME)
995 .map(|n| rustfft::num_complex::Complex::new(pcm[start + n] * window[n], 0.0))
996 .collect();
997 forward.process(&mut frame);
998 let power = &powers[index];
999
1000 for bin in 0..bins {
1001 let gamma = (power[bin] / noise[bin]).min(1e6);
1002 let xi = (DD_ALPHA * prev_gain[bin].powi(2) * prev_gamma[bin]
1003 + (1.0 - DD_ALPHA) * (gamma - 1.0).max(0.0))
1004 .max(1e-6);
1005
1006 let nu = (xi / (1.0 + xi)) * gamma;
1007 let lsa =
1008 ((xi / (1.0 + xi)) * (0.5 * exponential_integral_e1(nu)).exp()).clamp(0.0, 1.0);
1009
1010 let odds = SPEECH_ABSENCE_PRIOR / (1.0 - SPEECH_ABSENCE_PRIOR);
1014 let presence = 1.0 / (1.0 + odds * (1.0 + xi) * (-nu).exp());
1015 let presence = presence.clamp(0.0, 1.0);
1016
1017 let gain = (lsa.max(OMLSA_GAIN_FLOOR).powf(presence)
1018 * OMLSA_GAIN_FLOOR.powf(1.0 - presence))
1019 .clamp(OMLSA_GAIN_FLOOR, 1.0);
1020
1021 prev_gain[bin] = gain;
1022 prev_gamma[bin] = gamma;
1023
1024 frame[bin] *= gain;
1025 let mirror = DENOISE_FRAME - bin;
1026 if mirror != bin && mirror < DENOISE_FRAME {
1029 frame[mirror] *= gain;
1030 }
1031 }
1032
1033 inverse.process(&mut frame);
1034 let scale = 1.0 / DENOISE_FRAME as f32;
1035 for n in 0..DENOISE_FRAME {
1036 out[start + n] += frame[n].re * scale * window[n];
1037 weight[start + n] += window[n] * window[n];
1038 }
1039 }
1040
1041 const WOLA_MIN_WEIGHT: f32 = 0.15;
1047 for (sample, energy) in out.iter_mut().zip(weight.iter()) {
1048 if *energy > WOLA_MIN_WEIGHT {
1049 *sample /= *energy;
1050 }
1051 }
1052 let covered =
1055 starts.first().copied().unwrap_or(0)..starts.last().map_or(0, |last| last + DENOISE_FRAME);
1056 for (index, sample) in out.iter_mut().enumerate() {
1057 if !covered.contains(&index) || weight[index] <= WOLA_MIN_WEIGHT {
1058 *sample = pcm[index];
1059 }
1060 }
1061 out
1062}
1063
1064fn exponential_integral_e1(x: f32) -> f32 {
1071 if x <= 0.0 {
1072 return 0.0;
1075 }
1076 let x = f64::from(x);
1077 let value = if x < 1.0 {
1078 const A: [f64; 6] = [
1080 -0.577_215_664_9,
1081 0.999_991_93,
1082 -0.249_910_55,
1083 0.055_199_68,
1084 -0.009_760_04,
1085 0.001_078_57,
1086 ];
1087 let mut acc = 0.0;
1088 for (power, coefficient) in A.iter().enumerate() {
1089 acc += coefficient * x.powi(power as i32);
1090 }
1091 acc - x.ln()
1092 } else {
1093 const A: [f64; 4] = [8.573_328_74, 18.059_016_97, 8.634_760_89, 0.267_773_734];
1095 const B: [f64; 4] = [9.573_322_34, 25.632_956_15, 21.099_653_08, 3.958_496_93];
1096 let numerator = x.powi(4) + A[0] * x.powi(3) + A[1] * x * x + A[2] * x + A[3];
1097 let denominator = x.powi(4) + B[0] * x.powi(3) + B[1] * x * x + B[2] * x + B[3];
1098 (numerator / denominator) / (x * x.exp())
1099 };
1100 value as f32
1101}
1102
1103const DEREVERB_FRAME: usize = 1024;
1112const DEREVERB_HOP: usize = 256;
1113
1114const DEREVERB_TAPS: usize = 40;
1116
1117const DEREVERB_DELAY: usize = 2;
1121
1122const DEREVERB_ITERATIONS: usize = 3;
1126
1127const DEREVERB_LOADING: f64 = 1e-4;
1130
1131#[derive(Clone, Copy, Debug)]
1133pub struct DereverbReport {
1134 pub before_rt60_s: f32,
1136 pub after_rt60_s: f32,
1138}
1139
1140fn dereverb_reference(pcm: &[f32]) -> Vec<f32> {
1172 if pcm.len() < DEREVERB_FRAME * 4 {
1173 return pcm.to_vec();
1174 }
1175 let mut planner = rustfft::FftPlanner::<f32>::new();
1176 let forward = planner.plan_fft_forward(DEREVERB_FRAME);
1177 let inverse = planner.plan_fft_inverse(DEREVERB_FRAME);
1178
1179 let window: Vec<f32> = (0..DEREVERB_FRAME)
1180 .map(|n| {
1181 let phase = std::f32::consts::TAU * n as f32 / DEREVERB_FRAME as f32;
1182 0.5 - 0.5 * phase.cos()
1183 })
1184 .collect();
1185
1186 let bins = DEREVERB_FRAME / 2 + 1;
1187 let starts: Vec<usize> = (0..=pcm.len() - DEREVERB_FRAME)
1188 .step_by(DEREVERB_HOP)
1189 .collect();
1190 let frames = starts.len();
1191 if frames <= DEREVERB_DELAY + DEREVERB_TAPS + 2 {
1192 return pcm.to_vec();
1193 }
1194
1195 let mut observed: Vec<Vec<Complex64>> = Vec::with_capacity(frames);
1197 let mut scratch: Vec<rustfft::num_complex::Complex<f32>> =
1198 vec![rustfft::num_complex::Complex::new(0.0, 0.0); DEREVERB_FRAME];
1199 for &start in &starts {
1200 for (slot, n) in scratch.iter_mut().zip(0..DEREVERB_FRAME) {
1201 *slot = rustfft::num_complex::Complex::new(pcm[start + n] * window[n], 0.0);
1202 }
1203 forward.process(&mut scratch);
1204 observed.push(
1205 scratch[..bins]
1206 .iter()
1207 .map(|value| Complex64::new(f64::from(value.re), f64::from(value.im)))
1208 .collect(),
1209 );
1210 }
1211
1212 let mut desired = observed.clone();
1213 for _ in 0..DEREVERB_ITERATIONS {
1214 for bin in 0..bins {
1215 let mut power: Vec<f64> = (0..frames).map(|t| desired[t][bin].norm_sqr()).collect();
1218 let mean = power.iter().sum::<f64>() / frames as f64;
1219 let floor = (mean * 1e-6).max(1e-12);
1220 for value in &mut power {
1221 *value = value.max(floor);
1222 }
1223
1224 let taps = DEREVERB_TAPS;
1225 let mut covariance = vec![Complex64::new(0.0, 0.0); taps * taps];
1226 let mut cross = vec![Complex64::new(0.0, 0.0); taps];
1227 for t in (DEREVERB_DELAY + taps)..frames {
1228 let weight = 1.0 / power[t];
1229 let regressor: Vec<Complex64> = (0..taps)
1231 .map(|lag| observed[t - DEREVERB_DELAY - lag][bin])
1232 .collect();
1233 for row in 0..taps {
1234 let scaled = regressor[row] * weight;
1235 for column in row..taps {
1236 covariance[row * taps + column] += scaled * regressor[column].conj();
1237 }
1238 cross[row] += scaled * observed[t][bin].conj();
1239 }
1240 }
1241 for row in 0..taps {
1243 for column in 0..row {
1244 covariance[row * taps + column] = covariance[column * taps + row].conj();
1245 }
1246 }
1247 let trace: f64 = (0..taps).map(|i| covariance[i * taps + i].re).sum();
1248 if trace <= 0.0 {
1249 continue;
1250 }
1251 let loading = trace / taps as f64 * DEREVERB_LOADING;
1252 for i in 0..taps {
1253 covariance[i * taps + i] += Complex64::new(loading, 0.0);
1254 }
1255
1256 let Some(filter) = solve_complex_system(&mut covariance, &mut cross, taps) else {
1257 continue;
1258 };
1259 for t in 0..frames {
1260 if t < DEREVERB_DELAY + taps {
1261 desired[t][bin] = observed[t][bin];
1262 continue;
1263 }
1264 let mut tail = Complex64::new(0.0, 0.0);
1265 for (lag, coefficient) in filter.iter().enumerate() {
1266 tail += coefficient.conj() * observed[t - DEREVERB_DELAY - lag][bin];
1267 }
1268 desired[t][bin] = observed[t][bin] - tail;
1269 }
1270 }
1271 }
1272
1273 let mut out = vec![0.0_f32; pcm.len()];
1276 let mut weight = vec![0.0_f32; pcm.len()];
1277 for (index, &start) in starts.iter().enumerate() {
1278 let mut frame = vec![rustfft::num_complex::Complex::new(0.0_f32, 0.0); DEREVERB_FRAME];
1279 for bin in 0..bins {
1280 let value = desired[index][bin];
1281 #[allow(clippy::cast_possible_truncation)]
1282 let value = rustfft::num_complex::Complex::new(value.re as f32, value.im as f32);
1283 frame[bin] = value;
1284 let mirror = DEREVERB_FRAME - bin;
1285 if mirror != bin && mirror < DEREVERB_FRAME {
1286 frame[mirror] = value.conj();
1287 }
1288 }
1289 inverse.process(&mut frame);
1290 let scale = 1.0 / DEREVERB_FRAME as f32;
1291 for n in 0..DEREVERB_FRAME {
1292 out[start + n] += frame[n].re * scale * window[n];
1293 weight[start + n] += window[n] * window[n];
1294 }
1295 }
1296 for (sample, energy) in out.iter_mut().zip(weight.iter()) {
1297 if *energy > 1e-6 {
1298 *sample /= *energy;
1299 }
1300 }
1301 let covered =
1302 starts.first().copied().unwrap_or(0)..starts.last().map_or(0, |last| last + DEREVERB_FRAME);
1303 for (index, sample) in out.iter_mut().enumerate() {
1304 if !covered.contains(&index) || weight[index] <= 1e-6 {
1305 *sample = pcm[index];
1306 }
1307 }
1308 out
1309}
1310
1311type Complex64 = rustfft::num_complex::Complex<f64>;
1316
1317fn solve_complex_system(
1322 a: &mut [Complex64],
1323 b: &mut [Complex64],
1324 n: usize,
1325) -> Option<Vec<Complex64>> {
1326 for column in 0..n {
1327 let (pivot, magnitude) = (column..n).fold((column, 0.0_f64), |best, row| {
1328 let candidate = a[row * n + column].norm_sqr();
1329 if candidate > best.1 {
1330 (row, candidate)
1331 } else {
1332 best
1333 }
1334 });
1335 if magnitude <= f64::MIN_POSITIVE {
1336 return None;
1337 }
1338 if pivot != column {
1339 for k in 0..n {
1340 a.swap(pivot * n + k, column * n + k);
1341 }
1342 b.swap(pivot, column);
1343 }
1344 let diagonal = a[column * n + column];
1345 for row in (column + 1)..n {
1346 let factor = a[row * n + column] / diagonal;
1347 if factor == Complex64::new(0.0, 0.0) {
1348 continue;
1349 }
1350 for k in column..n {
1351 let value = a[column * n + k] * factor;
1352 a[row * n + k] -= value;
1353 }
1354 let value = b[column] * factor;
1355 b[row] -= value;
1356 }
1357 }
1358 let mut solution = vec![Complex64::new(0.0, 0.0); n];
1359 for row in (0..n).rev() {
1360 let mut accumulator = b[row];
1361 for k in (row + 1)..n {
1362 accumulator -= a[row * n + k] * solution[k];
1363 }
1364 solution[row] = accumulator / a[row * n + row];
1365 }
1366 Some(solution)
1367}
1368
1369fn reverb_time_s(pcm: &[f32]) -> Option<f32> {
1376 let hop = (SPEAKER_SAMPLE_RATE_HZ as usize) / 100; if pcm.len() < hop * 32 {
1378 return None;
1379 }
1380 let envelope: Vec<f32> = pcm
1381 .chunks_exact(hop)
1382 .map(|chunk| {
1383 let energy = chunk.iter().map(|s| s * s).sum::<f32>() / chunk.len() as f32;
1384 10.0 * (energy + 1e-9).log10()
1385 })
1386 .collect();
1387 let peak = envelope.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1388 let span = 15_usize; let mut slopes: Vec<f32> = Vec::new();
1390 for index in 1..envelope.len().saturating_sub(span) {
1391 if envelope[index] < peak - 25.0 || envelope[index] <= envelope[index - 1] {
1392 continue;
1393 }
1394 let drop = envelope[index] - envelope[index + span - 1];
1395 if drop < 6.0 {
1396 continue;
1397 }
1398 slopes.push(drop / (span as f32 * 0.01));
1399 }
1400 if slopes.is_empty() {
1401 return None;
1402 }
1403 slopes.sort_by(f32::total_cmp);
1404 let median = slopes[slopes.len() / 2];
1405 (median > 0.0).then(|| 60.0 / median)
1406}
1407
1408fn pause_floor_dbfs(pcm: &[f32]) -> f32 {
1413 let span = (SPEAKER_SAMPLE_RATE_HZ as usize) / 20;
1414 if pcm.len() < span {
1415 return f32::NEG_INFINITY;
1416 }
1417 let mut windows: Vec<f32> = pcm
1418 .chunks_exact(span)
1419 .map(|chunk| {
1420 (chunk
1421 .iter()
1422 .map(|s| f64::from(*s) * f64::from(*s))
1423 .sum::<f64>()
1424 / chunk.len() as f64)
1425 .sqrt() as f32
1426 })
1427 .filter(|rms| *rms > 0.0)
1428 .collect();
1429 if windows.is_empty() {
1430 return f32::NEG_INFINITY;
1431 }
1432 windows.sort_by(f32::total_cmp);
1433 let keep = (windows.len() / 10).max(1);
1434 let mean = windows[..keep].iter().sum::<f32>() / keep as f32;
1435 20.0 * mean.log10()
1436}
1437
1438fn lanczos_tap(offset: f64, cutoff: f64, lobes: f64) -> f64 {
1440 let scaled = cutoff * offset;
1441 if scaled.abs() >= lobes {
1442 return 0.0;
1443 }
1444 sinc(scaled) * sinc(scaled / lobes)
1445}
1446
1447fn sinc(x: f64) -> f64 {
1449 if x.abs() < 1e-12 {
1450 return 1.0;
1451 }
1452 let scaled = std::f64::consts::PI * x;
1453 scaled.sin() / scaled
1454}
1455
1456struct PreparedPassThrough {
1458 prepared: PreparedText,
1459}
1460
1461impl TextPreparer for PreparedPassThrough {
1462 fn prepare(
1463 &self,
1464 _text: &str,
1465 _options: &NormalizationOptions,
1466 ) -> Result<PreparedText, TextPreparationError> {
1467 Ok(PreparedText::new(
1468 self.prepared.token_ids.clone(),
1469 NormalizationTrace {
1470 mode: self.prepared.normalization_trace.mode,
1471 unicode_version: self.prepared.normalization_trace.unicode_version.clone(),
1472 changes: self.prepared.normalization_trace.changes.clone(),
1473 },
1474 ))
1475 }
1476}
1477
1478pub struct SynthesizedAudio {
1480 pub frames: u64,
1482 pub prepared_token_count: usize,
1484 pub pcm: Vec<f32>,
1486 pub ttfa: Option<std::time::Duration>,
1491}
1492
1493#[allow(clippy::too_many_arguments)]
1500pub fn synthesize(
1501 model: &LoadedModel,
1502 engine: &TtsEngine,
1503 request: &SynthesisRequest,
1504 speaker: &[f32],
1505 seed: u64,
1506 cancellation: &CancellationToken,
1507 observer: &dyn SynthesisObserver,
1508) -> Result<SynthesizedAudio, FttsError> {
1509 let prepared_raw = model
1511 .tokenizer
1512 .prepare(&request.text, &request.normalization_options)
1513 .map_err(|error| FttsError::Input(format!("text preparation failed: {error}")))?;
1514 let wrapped = TalkerCheckpoint::wrap_target_ids(&prepared_raw.token_ids);
1515 let prepared = PreparedText::new(wrapped.clone(), prepared_raw.normalization_trace);
1516
1517 let ids = TalkerCheckpoint::utterance_text_ids(&wrapped);
1519 let table = model
1520 .talker
1521 .gather_text_rows(&ids)
1522 .map_err(checkpoint_error)?;
1523
1524 let header = model
1526 .talker
1527 .xvector_header(&table, speaker, CODEC_LANGUAGE_ENGLISH_ID)
1528 .map_err(checkpoint_error)?;
1529 let tts_eos = model.talker.tts_eos(&table);
1530
1531 let talker_layers = model.talker.talker_layer_weights();
1533 let micro_layers = model.talker.microdecoder_layer_weights();
1534 let residual = model.talker.residual_embedding_slices();
1535 let heads = model.talker.microdecoder_head_slices();
1536 let micro_residual = &residual[..residual.len() - 1];
1539
1540 let mut generator = QwenGenerator::new_with_artifact(
1541 QwenGeneratorConfig {
1542 talker_config: TalkerConfig::default(),
1543 talker_weights: model.talker.talker_weights(&talker_layers),
1544 text: model.talker.text_weights(&table),
1545 feedback: model.talker.feedback_tables(&residual),
1546 microdecoder_config: MicrodecoderConfig::default(),
1547 microdecoder_weights: model.talker.microdecoder_weights(
1548 µ_layers,
1549 micro_residual,
1550 &heads,
1551 ),
1552 prompt_mode: PromptMode {
1553 clone_mode: CloneMode::XVector,
1554 non_streaming_mode: false,
1555 },
1556 header,
1557 tts_eos,
1558 reference: None,
1559 sampling_mode: SamplingMode::Production,
1570 seed,
1571 },
1572 model.artifact.as_deref(),
1573 );
1574
1575 let preparer = PreparedPassThrough { prepared };
1584 let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<ftts_core::CodeFrame>(256);
1585 let codec = &model.codec;
1586 let synthesis_started = std::time::Instant::now();
1587 let (result, pcm, ttfa) = std::thread::scope(
1588 |scope| -> Result<
1589 (
1590 ftts_core::SynthesisResult,
1591 Vec<f32>,
1592 Option<std::time::Duration>,
1593 ),
1594 FttsError,
1595 > {
1596 let worker = scope.spawn(
1597 move || -> Result<(Vec<f32>, Option<std::time::Duration>), FttsError> {
1598 ftts_kernels::team::bypass_team_on_this_thread();
1601 const PACKET_FRAMES: usize = 4;
1602 let mut state = codec.stream_state();
1603 let mut pcm = Vec::new();
1604 let mut packet_pcm = Vec::new();
1607 let mut packet: Vec<i32> = Vec::with_capacity(16 * PACKET_FRAMES);
1608 let mut packet_frames = 0_usize;
1609 let mut first_audio_at: Option<std::time::Duration> = None;
1610 while let Ok(frame) = frame_rx.recv() {
1611 if frame.codes.len() != 16 {
1612 return Err(FttsError::Generic(format!(
1613 "generated frame carries {} codes, expected 16",
1614 frame.codes.len()
1615 )));
1616 }
1617 for code in &frame.codes {
1618 packet.push(i32::try_from(*code).map_err(|_| {
1619 FttsError::Generic(format!(
1620 "generated code {code} does not fit the codec's i32"
1621 ))
1622 })?);
1623 }
1624 packet_frames += 1;
1625 if packet_frames == PACKET_FRAMES {
1626 codec
1627 .stream_push(&mut state, &packet, packet_frames, &mut packet_pcm)
1628 .map_err(checkpoint_error)?;
1629 pcm.extend_from_slice(&packet_pcm);
1630 first_audio_at.get_or_insert_with(|| synthesis_started.elapsed());
1631 packet.clear();
1632 packet_frames = 0;
1633 }
1634 }
1635 if packet_frames > 0 {
1636 codec
1637 .stream_push(&mut state, &packet, packet_frames, &mut packet_pcm)
1638 .map_err(checkpoint_error)?;
1639 pcm.extend_from_slice(&packet_pcm);
1640 first_audio_at.get_or_insert_with(|| synthesis_started.elapsed());
1641 }
1642 Ok((pcm, first_audio_at))
1643 },
1644 );
1645
1646 let mut tee = TeeGenerator {
1647 inner: &mut generator,
1648 frames: frame_tx,
1649 };
1650 let result = engine
1651 .synthesize(
1652 request.clone(),
1653 &preparer,
1654 &mut tee as &mut dyn FrameGenerator,
1655 cancellation,
1656 observer,
1657 )
1658 .map_err(engine_error);
1659 drop(tee); let pcm = worker.join().expect("codec worker must not panic");
1661 let result = result?;
1665 let (pcm, ttfa) = pcm?;
1666 Ok((result, pcm, ttfa))
1667 },
1668 )?;
1669
1670 if result.code_frames.is_empty() {
1671 return Err(FttsError::Generic(
1672 "the talker stopped before emitting a frame; there is no audio to write. This is a \
1673 model or prompt problem, not an output problem — check the speaker vector and the \
1674 text"
1675 .to_owned(),
1676 ));
1677 }
1678
1679 Ok(SynthesizedAudio {
1680 frames: result.generated_frames,
1681 prepared_token_count: result.prepared_token_count,
1682 pcm,
1683 ttfa,
1684 })
1685}
1686
1687struct TeeGenerator<'a> {
1693 inner: &'a mut dyn FrameGenerator,
1694 frames: std::sync::mpsc::SyncSender<ftts_core::CodeFrame>,
1695}
1696
1697impl FrameGenerator for TeeGenerator<'_> {
1698 fn begin_utterance(&mut self, prepared: &PreparedText) -> Result<(), GenerationError> {
1699 self.inner.begin_utterance(prepared)
1700 }
1701
1702 fn next_frame(&mut self) -> Result<Option<ftts_core::CodeFrame>, GenerationError> {
1703 let frame = self.inner.next_frame()?;
1704 if let Some(frame) = &frame
1705 && self.frames.send(frame.clone()).is_err()
1706 {
1707 return Err(GenerationError::new(
1708 "the codec worker stopped accepting frames; its error follows at join",
1709 ));
1710 }
1711 Ok(frame)
1712 }
1713}
1714
1715fn engine_error(error: EngineError) -> FttsError {
1717 match error {
1718 EngineError::BudgetExceeded(_) => FttsError::BudgetTimeout(error.to_string()),
1719 EngineError::ResourceAdmission(_) => FttsError::BudgetTimeout(error.to_string()),
1720 EngineError::TextPreparation(_) => FttsError::Input(error.to_string()),
1721 other => FttsError::Generic(other.to_string()),
1722 }
1723}
1724
1725#[must_use]
1727pub fn generation_error(message: &str) -> GenerationError {
1728 GenerationError::new(message)
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use super::*;
1734
1735 #[test]
1738 fn audio_at_the_pinned_rate_is_returned_bit_for_bit() {
1739 let pcm: Vec<f32> = (0..4_096)
1740 .map(|n| (n as f32 * 0.017).sin() * 0.4 + (n as f32 * 0.31).sin() * 0.05)
1741 .collect();
1742 let out = resample_to_speaker_rate(pcm.clone(), SPEAKER_SAMPLE_RATE_HZ);
1743 assert_eq!(out.len(), pcm.len());
1744 for (index, (a, b)) in out.iter().zip(pcm.iter()).enumerate() {
1745 assert!(
1746 a.to_bits() == b.to_bits(),
1747 "sample {index} was altered at the pinned rate"
1748 );
1749 }
1750 }
1751
1752 #[test]
1760 fn the_exponential_integral_matches_its_series_expansion() {
1761 for (x, expected) in [
1763 (0.1_f32, 1.822_923_9_f32),
1764 (0.5, 0.559_773_6),
1765 (0.9, 0.260_183_94),
1766 ] {
1767 let actual = exponential_integral_e1(x);
1768 let relative = ((actual - expected) / expected).abs();
1769 assert!(
1770 relative < 1e-6,
1771 "E1({x}) = {actual} but the series gives {expected} (relative {relative:e})"
1772 );
1773 }
1774
1775 let below = exponential_integral_e1(0.999_9);
1777 let above = exponential_integral_e1(1.000_1);
1778 assert!(
1779 below > above && (below - above).abs() < 1e-4,
1780 "the series and rational branches disagree across x = 1: {below} vs {above}"
1781 );
1782 assert_eq!(
1783 exponential_integral_e1(0.0),
1784 0.0,
1785 "a non-positive argument must not produce NaN"
1786 );
1787 }
1788
1789 #[test]
1793 fn denoise_lowers_the_floor_between_bursts_without_eating_the_signal() {
1794 const TONE_HZ: f64 = 700.0;
1795 let samples = SPEAKER_SAMPLE_RATE_HZ as usize * 2;
1796 let mut state = 0x2545_F491_4F6C_DD1D_u64;
1798 let mut noise = || {
1799 state ^= state << 13;
1800 state ^= state >> 7;
1801 state ^= state << 17;
1802 ((state >> 40) as f32 / 16_777_216.0) - 0.5
1803 };
1804
1805 let clean: Vec<f32> = (0..samples)
1807 .map(|n| {
1808 let t = n as f64 / f64::from(SPEAKER_SAMPLE_RATE_HZ);
1809 let speaking = (n / (SPEAKER_SAMPLE_RATE_HZ as usize / 2)).is_multiple_of(2);
1810 if speaking {
1811 (std::f64::consts::TAU * TONE_HZ * t).sin() as f32 * 0.35
1812 } else {
1813 0.0
1814 }
1815 })
1816 .collect();
1817 let noisy: Vec<f32> = clean.iter().map(|s| s + noise() * 0.1).collect();
1821
1822 let cleaned = denoise_reference(&noisy);
1823 assert_eq!(cleaned.len(), noisy.len(), "denoise must preserve length");
1824 assert!(
1825 cleaned.iter().all(|s| s.is_finite()),
1826 "denoise produced a non-finite sample"
1827 );
1828
1829 let before = pause_floor_dbfs(&noisy);
1830 let after = pause_floor_dbfs(&cleaned);
1831 assert!(
1832 after < before - 3.0,
1833 "expected the pause floor to drop by >3 dB, got {before:.1} -> {after:.1} dBFS"
1834 );
1835
1836 let span = SPEAKER_SAMPLE_RATE_HZ as usize / 4;
1838 let peak_rms = |pcm: &[f32]| {
1839 pcm.chunks_exact(span)
1840 .map(|c| (c.iter().map(|s| s * s).sum::<f32>() / c.len() as f32).sqrt())
1841 .fold(0.0_f32, f32::max)
1842 };
1843 let kept = peak_rms(&cleaned) / peak_rms(&noisy);
1844 assert!(
1845 kept > 0.7,
1846 "denoise removed too much of the signal: peak RMS kept {kept:.3} of the original"
1847 );
1848 }
1849
1850 #[test]
1858 fn denoise_keeps_a_reference_that_opens_on_speech() {
1859 let rate = SPEAKER_SAMPLE_RATE_HZ as usize;
1860 let mut state = 0x9E37_79B9_7F4A_7C15_u64;
1861 let mut hiss = || {
1862 state ^= state << 13;
1863 state ^= state >> 7;
1864 state ^= state << 17;
1865 ((state >> 40) as f32 / 16_777_216.0) - 0.5
1866 };
1867 let burst = rate / 4;
1868 let noisy: Vec<f32> = (0..rate * 3)
1869 .map(|n| {
1870 let t = n as f64 / rate as f64;
1871 let index = n / burst;
1872 let voice = if index.is_multiple_of(2) {
1873 let vibrato = 1.0 + 0.03 * (std::f64::consts::TAU * 5.5 * t).sin();
1876 let phase = (n % burst) as f32 / burst as f32;
1877 let envelope = (std::f32::consts::PI * phase).sin();
1878 let f0 = 140.0 * vibrato * (1.0 + 0.15 * (index / 2) as f64);
1879 (1..=10)
1880 .map(|h| {
1881 let a = 0.3 / h as f32;
1882 (std::f64::consts::TAU * f0 * h as f64 * t).sin() as f32 * a
1883 })
1884 .sum::<f32>()
1885 * envelope
1886 } else {
1887 0.0
1888 };
1889 voice + hiss() * 0.02
1890 })
1891 .collect();
1892
1893 let cleaned = denoise_reference(&noisy);
1894 let rms = |pcm: &[f32]| (pcm.iter().map(|s| s * s).sum::<f32>() / pcm.len() as f32).sqrt();
1895 let kept = rms(&cleaned[..burst]) / rms(&noisy[..burst]);
1896 assert!(
1897 kept > 0.7,
1898 "the opening burst kept only {kept:.3} of its energy; the noise floor is being \
1899 seeded from speech the estimator has not yet learned to exclude"
1900 );
1901 }
1902
1903 #[test]
1913 fn denoise_does_not_preferentially_zap_high_frequencies() {
1914 let rate = SPEAKER_SAMPLE_RATE_HZ as usize;
1915 let mut state = 0xDEAD_BEEF_1234_5678_u64;
1916 let mut hiss = || {
1917 state ^= state << 13;
1918 state ^= state >> 7;
1919 state ^= state << 17;
1920 ((state >> 40) as f32 / 16_777_216.0) - 0.5
1921 };
1922 let probes: [f64; 5] = [300.0, 1_200.0, 3_000.0, 6_000.0, 9_000.0];
1925 let burst = rate / 4;
1926 let noisy: Vec<f32> = (0..rate * 3)
1927 .map(|n| {
1928 let t = n as f64 / rate as f64;
1929 let voice = if (n / burst).is_multiple_of(2) {
1930 let phase = (n % burst) as f32 / burst as f32;
1931 let envelope = (std::f32::consts::PI * phase).sin();
1932 probes
1933 .iter()
1934 .map(|hz| (std::f64::consts::TAU * hz * t).sin() as f32 * 0.12)
1935 .sum::<f32>()
1936 * envelope
1937 } else {
1938 0.0
1939 };
1940 voice + hiss() * 0.02
1941 })
1942 .collect();
1943
1944 let cleaned = denoise_reference(&noisy);
1945
1946 let span = burst / 2..burst;
1949 let energy_at = |pcm: &[f32], hz: f64| -> f32 {
1950 let (mut re, mut im) = (0.0_f64, 0.0_f64);
1951 for (offset, sample) in pcm[span.clone()].iter().enumerate() {
1952 let t = (span.start + offset) as f64 / rate as f64;
1953 let angle = std::f64::consts::TAU * hz * t;
1954 re += f64::from(*sample) * angle.cos();
1955 im += f64::from(*sample) * angle.sin();
1956 }
1957 (re.hypot(im) / span.len() as f64) as f32
1958 };
1959
1960 let retention: Vec<f32> = probes
1961 .iter()
1962 .map(|hz| energy_at(&cleaned, *hz) / energy_at(&noisy, *hz).max(1e-9))
1963 .collect();
1964 let low = retention[0];
1965 for (hz, kept) in probes.iter().zip(retention.iter()) {
1966 assert!(
1967 *kept > 0.5,
1968 "{hz} Hz retained only {kept:.3}; the denoiser is eating the band, not the noise \
1969 (all bands: {retention:?})"
1970 );
1971 assert!(
1972 *kept > low * 0.6,
1973 "{hz} Hz retained {kept:.3} against {low:.3} at 300 Hz — high frequencies are \
1974 being attenuated preferentially, which is how sibilance and speaker identity go \
1975 (all bands: {retention:?})"
1976 );
1977 }
1978 }
1979
1980 #[test]
1984 fn a_clip_shorter_than_its_downsample_ratio_resamples_to_nothing() {
1985 let out = resample_to_speaker_rate(vec![0.25], 192_000);
1986 assert!(
1987 out.is_empty(),
1988 "one sample at 192 kHz is less than half an output sample at \
1989 {SPEAKER_SAMPLE_RATE_HZ} Hz, so it cannot produce one"
1990 );
1991 }
1992
1993 #[test]
1997 fn a_48k_tone_resamples_to_24k_with_its_shape_intact() {
1998 const SOURCE_HZ: u32 = 48_000;
1999 const TONE_HZ: f64 = 1_000.0;
2000 let samples = SOURCE_HZ as usize; let pcm: Vec<f32> = (0..samples)
2002 .map(|n| {
2003 let t = n as f64 / f64::from(SOURCE_HZ);
2004 (std::f64::consts::TAU * TONE_HZ * t).sin() as f32
2005 })
2006 .collect();
2007
2008 let out = resample_to_speaker_rate(pcm, SOURCE_HZ);
2009
2010 let expected_len = SPEAKER_SAMPLE_RATE_HZ as usize;
2011 assert!(
2012 out.len().abs_diff(expected_len) <= 1,
2013 "expected ~{expected_len} samples at {SPEAKER_SAMPLE_RATE_HZ} Hz, got {}",
2014 out.len()
2015 );
2016
2017 let skip = 64;
2020 let interior = out.len() - skip;
2021 let mut worst = 0.0_f32;
2022 for (index, sample) in out.iter().enumerate().take(interior).skip(skip) {
2023 let t = index as f64 / f64::from(SPEAKER_SAMPLE_RATE_HZ);
2024 let ideal = (std::f64::consts::TAU * TONE_HZ * t).sin() as f32;
2025 worst = worst.max((sample - ideal).abs());
2026 }
2027 assert!(
2028 worst < 0.02,
2029 "resampled tone drifted from the analytic reference by {worst}"
2030 );
2031 }
2032
2033 #[test]
2034 fn a_short_speaker_vector_is_refused_rather_than_padded() {
2035 let dir = std::env::temp_dir().join("ftts-synth-tests");
2036 fs::create_dir_all(&dir).expect("temp dir");
2037 let path = dir.join("short.spk");
2038 fs::write(&path, vec![0u8; 64]).expect("write");
2039 let error = read_speaker_vector(&path).expect_err("a short vector must be refused");
2040 let message = error.to_string();
2041 assert!(message.contains("64 bytes"), "{message}");
2042 assert!(message.contains("4096"), "{message}");
2043 }
2044
2045 #[test]
2046 fn a_non_finite_speaker_vector_is_refused() {
2047 let dir = std::env::temp_dir().join("ftts-synth-tests");
2048 fs::create_dir_all(&dir).expect("temp dir");
2049 let path = dir.join("nan.spk");
2050 let mut bytes = vec![0u8; SPEAKER_VECTOR_BYTES];
2051 bytes[0..4].copy_from_slice(&f32::NAN.to_le_bytes());
2052 fs::write(&path, &bytes).expect("write");
2053 let error = read_speaker_vector(&path).expect_err("NaN must be refused");
2054 assert!(error.to_string().contains("index 0"), "{error}");
2055 }
2056
2057 #[test]
2058 fn a_well_formed_speaker_vector_reads_back_exactly() {
2059 let dir = std::env::temp_dir().join("ftts-synth-tests");
2060 fs::create_dir_all(&dir).expect("temp dir");
2061 let path = dir.join("good.spk");
2062 let expected: Vec<f32> = (0..TALKER_HIDDEN).map(|i| i as f32 * 0.001).collect();
2063 let mut bytes = Vec::with_capacity(SPEAKER_VECTOR_BYTES);
2064 for value in &expected {
2065 bytes.extend_from_slice(&value.to_le_bytes());
2066 }
2067 fs::write(&path, &bytes).expect("write");
2068 assert_eq!(read_speaker_vector(&path).expect("read"), expected);
2069 }
2070
2071 #[test]
2072 fn enrollment_writer_refuses_overwrite_and_preserves_the_vector() {
2073 let path = std::env::temp_dir().join(format!(
2074 "ftts-enroll-{}-{}.spk",
2075 std::process::id(),
2076 std::time::SystemTime::now()
2077 .duration_since(std::time::UNIX_EPOCH)
2078 .expect("clock")
2079 .as_nanos()
2080 ));
2081 let expected: Vec<f32> = (0..TALKER_HIDDEN)
2082 .map(|index| index as f32 * 0.125)
2083 .collect();
2084 write_speaker_vector_new(&path, &expected).expect("initial enrollment write");
2085 assert_eq!(
2086 read_speaker_vector(&path).expect("read enrolled vector"),
2087 expected
2088 );
2089 let error = write_speaker_vector_new(&path, &[0.0; TALKER_HIDDEN])
2090 .expect_err("an enrollment must never replace an existing voice");
2091 assert!(error.to_string().contains("without overwriting"), "{error}");
2092 }
2093
2094 #[test]
2095 fn wav_reference_decodes_to_mono_24khz_pcm() {
2096 let path = std::env::temp_dir().join(format!(
2097 "ftts-reference-{}-{}.wav",
2098 std::process::id(),
2099 std::time::SystemTime::now()
2100 .duration_since(std::time::UNIX_EPOCH)
2101 .expect("clock")
2102 .as_nanos()
2103 ));
2104 let pcm: Vec<f32> = (0..1_920)
2105 .map(|index| (index as f32 / 1_920.0 * std::f32::consts::TAU).sin() * 0.25)
2106 .collect();
2107 fs::write(
2108 &path,
2109 ftts_core::audio::encode_wav(&pcm, SPEAKER_SAMPLE_RATE_HZ),
2110 )
2111 .expect("write reference WAV");
2112 let decoded = decode_reference_audio(&path).expect("decode reference WAV");
2113 assert_eq!(decoded.len(), pcm.len());
2114 assert!(decoded.iter().all(|sample| sample.is_finite()));
2115 }
2116
2117 #[test]
2118 fn a_bundle_names_the_file_that_is_actually_missing() {
2119 let dir = std::env::temp_dir().join("ftts-bundle-tests-empty");
2122 fs::create_dir_all(&dir).expect("temp dir");
2123 let error = ModelBundle::resolve(&dir).expect_err("an empty directory is not a bundle");
2124 assert!(error.to_string().contains("model.safetensors"), "{error}");
2125 }
2126
2127 #[test]
2128 fn a_complete_bundle_prefers_its_canonical_artifact_for_synthesis() {
2129 let nonce = std::time::SystemTime::now()
2130 .duration_since(std::time::UNIX_EPOCH)
2131 .expect("clock after epoch")
2132 .as_nanos();
2133 let dir = std::env::temp_dir().join(format!(
2134 "ftts-bundle-canonical-{}-{nonce}",
2135 std::process::id()
2136 ));
2137 fs::create_dir_all(dir.join("speech_tokenizer")).expect("create bundle sidecar directory");
2138 for name in [
2139 CANONICAL_MODEL_BASENAME,
2140 "speech_tokenizer/model.safetensors",
2141 "vocab.json",
2142 "merges.txt",
2143 "tokenizer_config.json",
2144 ] {
2145 fs::write(dir.join(name), []).expect("write bundle fixture sidecar");
2146 }
2147
2148 let expected_artifact = dir.join(CANONICAL_MODEL_BASENAME);
2149 let bundle = ModelBundle::resolve(&dir).expect("complete canonical bundle resolves");
2150 assert_eq!(
2151 bundle.canonical_main.as_deref(),
2152 Some(expected_artifact.as_path())
2153 );
2154 assert!(
2155 !bundle.main.exists(),
2156 "canonical synthesis must not require the raw main checkpoint"
2157 );
2158
2159 let explicit = ModelBundle::resolve(&expected_artifact)
2160 .expect("an explicit canonical artifact resolves against its sidecars");
2161 assert_eq!(
2162 explicit.canonical_main.as_deref(),
2163 Some(expected_artifact.as_path())
2164 );
2165 }
2166}