Skip to main content

ftts_cli/
synth.rs

1//! The `ftts say` synthesis path: text in, 24 kHz PCM out.
2//!
3//! This module is where the CLI stops describing the pipeline and runs it. It resolves a
4//! checkpoint bundle, hydrates the talker and the codec, tokenizes and wraps the text, derives the
5//! prompt header, drives [`TtsEngine::synthesize`] over the real [`QwenGenerator`], and hands the
6//! generated codes to the codec decoder. What comes back is `f32` samples; the WAV writing lives
7//! in `ftts-core::audio` and the sink policy in [`crate::AudioOutput`].
8//!
9//! # Why the text is prepared before the engine runs
10//!
11//! [`TtsEngine::synthesize`] owns text preparation, and normally that is where tokenization
12//! happens. Here it happens once, up front, and the engine is handed a preparer that returns that
13//! exact result. The reason is the cold text embedding: it is `[151936, 2048]`, and materializing
14//! it whole to serve a fifteen-token utterance would cost 1.24 GB. The gather needs the token ids,
15//! the generator needs the gathered table, and the generator must exist before `synthesize` is
16//! called — so the ids have to be known first. The engine still receives, verbatim, the
17//! `PreparedText` a fresh call would have produced; nothing is skipped, only ordered.
18//!
19//! # Speaker conditioning is derived, never invented
20//!
21//! An x-vector prompt conditions on a 1,024-wide speaker embedding. A voice source may be either
22//! a precomputed raw vector (1,024 little-endian `f32`, 4,096 bytes) or reference audio decoded
23//! through the pinned 24 kHz log-mel front end and ECAPA encoder. Neither path accepts a
24//! fabricated vector.
25
26use 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
56/// Bytes in a speaker-vector file: 1,024 little-endian `f32`.
57pub 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/// The model resources `ftts say` needs, located relative to one model path.
66#[derive(Clone, Debug)]
67pub struct ModelBundle {
68    /// Directory holding the artifact sidecars and tokenizer files.
69    pub root: PathBuf,
70    /// The raw main checkpoint, retained for enrollment-only components that have not yet gained
71    /// a canonical-artifact accessor.
72    pub main: PathBuf,
73    /// The portable main-weight artifact selected for synthesis, when present.
74    pub canonical_main: Option<PathBuf>,
75    /// The codec decoder checkpoint.
76    pub codec: PathBuf,
77}
78
79impl ModelBundle {
80    /// Resolve a bundle from `--model`, which may name the directory, a `.fttsq`, or
81    /// `model.safetensors`.
82    ///
83    /// # Errors
84    ///
85    /// [`FttsError::ModelNotFound`] naming the exact missing file, so a partial download is
86    /// diagnosable without guessing which of the four is absent.
87    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
145/// Every weight and table one `say` needs, hydrated once.
146pub struct LoadedModel {
147    talker: TalkerCheckpoint,
148    codec: CodecCheckpoint,
149    tokenizer: QwenTokenizer,
150    /// The checkpoint's own digest-verified mapping of the canonical artifact, shared so the
151    /// int8 route hydrates its Q8 tables from it (proven byte-identical to requantizing the
152    /// widened f32 copies, scales included). Never re-opened: every `MappedFttsq::open`
153    /// re-verifies the whole artifact's digests, ~1.3 GB of hashing.
154    artifact: Option<std::sync::Arc<ftts_artifacts::fttsq::MappedFttsq>>,
155}
156
157impl LoadedModel {
158    /// Hydrate the bundle. This reads gigabytes and is the slow step of a cold run.
159    ///
160    /// # Errors
161    ///
162    /// If any checkpoint or tokenizer file is unreadable or not the pinned model.
163    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        // The three heavyweight hydrations are independent, so the codec checkpoint and the
175        // tokenizer build overlap the talker load instead of queueing behind it. Each result is
176        // computed exactly as it was serially; only wall time changes.
177        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                // The elision mirrors the generator's own hydration decision for this process:
188                // stacks that will run artifact-native int8 skip their dead f32 projections
189                // (~2.5 GB of widening nobody reads).
190                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        // Shared, not re-opened: a second MappedFttsq::open would re-verify the whole artifact's
207        // digests (~1.3 GB of hashing) for a mapping the checkpoint already carries.
208        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
218/// Read a precomputed 1,024-wide speaker vector.
219///
220/// See the module docs on why this is a raw vector rather than a `.ftvoice` pack.
221///
222/// # Errors
223///
224/// [`FttsError::Input`] when the file is unreadable or is not exactly
225/// [`SPEAKER_VECTOR_BYTES`] bytes — a truncated vector would otherwise be padded with silence and
226/// change the voice in a way only listening could detect.
227pub 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 an x-vector from an enrolled raw vector or a real reference recording.
260/// What a `--denoise` enrollment measured, so the CLI can report the effect rather than assert it.
261#[derive(Clone, Copy, Debug)]
262pub struct DenoiseReport {
263    /// Pause floor of the decoded reference, before denoising.
264    pub before_dbfs: f32,
265    /// Pause floor after denoising.
266    pub after_dbfs: f32,
267}
268
269/// Which reference-cleanup stages to run, and where each reports what it measured.
270///
271/// Both default to off. Every stage changes the enrolled identity, so none of them is applied on
272/// a user's behalf — a lever that can alter who the clone sounds like is opted into by name.
273#[derive(Default)]
274pub struct ReferenceCleanup<'a> {
275    /// Spectral-subtract the stationary noise floor.
276    pub denoise: Option<&'a mut Option<DenoiseReport>>,
277    /// Remove late reverberation.
278    pub dereverb: Option<&'a mut Option<DereverbReport>>,
279}
280
281/// Derive a speaker vector from a voice source: a raw x-vector file, or reference audio.
282///
283/// Passing `Some` for a cleanup slot opts the reference into that stage and fills the slot with
284/// what it measured. Dereverberation runs first: it is a linear operation on the observed signal,
285/// so applying it before the noise floor is estimated keeps that estimate from being fitted to a
286/// signal the next stage is about to change.
287///
288/// # Errors
289///
290/// When the source cannot be read, decoded, resampled, or encoded into a finite x-vector.
291pub 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    // Size alone must not decide: a perfectly plausible 4,096-byte audio clip would
303    // otherwise reinterpret as garbage floats and enroll a nonsense voice without any
304    // error. A recognizable audio container magic wins over the size sniff.
305    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    // A voice-card image (PNG or JPEG) IS a voice: decode it directly, no explicit
312    // `ftts card import` step needed. Card decoding never touches the model bundle.
313    // Checked BEFORE the raw-vector size branch for the same reason the audio sniff
314    // exists: a 4,096-byte image would otherwise reinterpret as garbage floats.
315    // The JPEG sniff is three bytes (SOI plus the next marker's FF), not two: every
316    // real JPEG has it, and a bare FF D8 matches one legitimate .spk in 65,536 —
317    // which would then FAIL to load through the image path instead of speaking.
318    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
331/// Derive a speaker vector from already-decoded mono 24 kHz reference PCM.
332///
333/// The PCM-level half of [`speaker_from_voice`], split out so hosts that capture audio
334/// themselves (the iOS app through `ftts-ffi`) run exactly the enrollment pipeline the
335/// CLI runs — same cleanup stages, same encoder, same finiteness refusal — instead of a
336/// parallel one that quietly skips the denoiser.
337///
338/// # Errors
339///
340/// When feature extraction or encoding fails, or the encoder yields a non-finite vector.
341pub 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
395/// Write a raw x-vector without replacing an existing enrollment result.
396pub 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
431/// Replaces an existing enrolled voice, keeping the displaced one alongside it.
432///
433/// Enrollment is cheap to redo but a voice is not always cheap to re-record, and the reference a
434/// `.spk` came from may be long gone. The previous vector is copied to `<path>.bak` before the new
435/// one lands, so a mistaken overwrite is one `mv` away from undone rather than unrecoverable.
436///
437/// # Errors
438///
439/// When the vector is malformed, the backup cannot be written, or the file cannot be replaced.
440pub 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    // Write the replacement to a sibling first, then rename over the target: a crash mid-write
450    // must not leave a half-written vector where a valid voice used to be.
451    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
487/// Container formats the embedded decoder does not read; these route through a system decoder,
488/// mirroring how output encoding shells out — synthesis and enrollment themselves never depend
489/// on one.
490const SYSTEM_DECODED_EXTENSIONS: [&str; 6] = ["m4a", "mp3", "aac", "mp4", "ogg", "opus"];
491
492/// A user-owned staging directory for temporary artifacts (transcoded references,
493/// materialized preset voices).
494///
495/// This deliberately avoids the shared system temp dir: on Linux, `/tmp` is world-writable,
496/// the staging names here are predictable (pid + stem), and the files are written by
497/// external decoders that happily follow a pre-planted symlink — so another local user
498/// could truncate an arbitrary victim-writable file, or swap content between our write and
499/// read. A directory under the user's own cache root closes the whole class. Falls back to
500/// the system temp dir only when no home directory exists at all.
501pub(crate) fn private_staging_dir() -> std::io::Result<PathBuf> {
502    #[allow(deprecated)] // un-deprecated in current Rust; the lint fires on older stables
503    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
516/// Decodes reference audio of any supported container to mono f32 PCM.
517///
518/// WAV and FLAC decode through the embedded pure-Rust path. Compressed containers (m4a, mp3, …)
519/// are first transcoded to a temporary WAV by the first system decoder found — `afconvert` on
520/// macOS, then `ffmpeg` — with a clear error naming both tools when neither exists.
521fn 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        // Both decoders are told to resample to the speaker encoder's pinned 24 kHz mono here
544        // rather than leaving the source rate intact: phone and Mac voice memos default to
545        // 44.1/48 kHz, and a transcode that preserves them would only move the failure to the
546        // enrollment rate check (frankentts-gra).
547        (
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, // tool not installed; try the next one
593        }
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    // Downsampling shortens the signal, and a clip of a few samples at a high source rate can
707    // round to nothing. The mel front end would then see an empty slice, so the emptiness check
708    // has to be made against the PCM actually handed on, not only against what was decoded.
709    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
719/// Resamples decoded mono PCM to the speaker encoder's pinned rate.
720///
721/// Compressed references already arrive at 24 kHz because the system decoder is told to convert
722/// (`frankentts-gra`), but a `.wav` or `.flac` is read directly and can be any rate — 44.1 and 48
723/// kHz being what every phone, Mac voice memo, and DAW export actually produces. Refusing those
724/// pushed the identical resample onto the user as an `ffmpeg` incantation, so it happens here.
725///
726/// Audio already at the pinned rate is returned untouched, so this cannot perturb any existing
727/// enrollment: it only turns a former hard error into a working path.
728///
729/// Windowed-sinc (Lanczos-3) with the kernel cutoff clamped to the lower of the two rates, which
730/// is what suppresses aliasing on the common downsampling direction. Taps are normalized by their
731/// own sum so DC gain stays 1 even where the window runs off the ends of the signal.
732fn 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
739/// The Lanczos-6 core, rate-agnostic: also carries the denoiser's 24 <-> 48 kHz round trip.
740fn 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    // Six lobes, not three: with the cutoff at the output Nyquist a 3-lobe kernel's transition
745    // band sits inside the passband — measured -2.2 dB at 10 kHz for 48->24 kHz and only ~18 dB
746    // of alias rejection, right where the speaker encoder reads sibilance. Six lobes halves the
747    // transition width and pushes rejection past 40 dB for double the (still trivial) tap count.
748    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
780/// STFT window for reference denoising: 512 samples is ~21 ms at the pinned 24 kHz, long enough
781/// to resolve a noise floor between words and short enough not to smear plosives.
782const DENOISE_FRAME: usize = 512;
783
784/// Three-quarter overlap. Hann at hop `N/4` overlap-adds smoothly, which is what keeps gain
785/// changes from becoming audible frame edges.
786const DENOISE_HOP: usize = DENOISE_FRAME / 4;
787
788/// Decision-directed smoothing for the a priori SNR. Ephraim and Malah's 0.98 is calmer but lags
789/// onsets; 0.92 tracks a voice that starts and stops mid-recording.
790const DD_ALPHA: f32 = 0.92;
791
792/// Floor gain, −35 dB. OM-LSA never gates a bin fully closed: leaving a quiet, *stationary* bed
793/// is what stops residual noise from flickering into musical tones.
794///
795/// This is the right knob for "deeper pauses", and the only safe one — it applies where the
796/// presence probability has already decided a bin is noise, so lowering it buys silence between
797/// words without touching a bin the estimator thinks holds voice. Reaching for a more aggressive
798/// *noise estimate* instead is what damages speech.
799const OMLSA_GAIN_FLOOR: f32 = 0.017_782_79;
800
801/// Frames per block over which each bin's noise floor is estimated. ~1.4 s at this hop: long
802/// enough that speech is sparse within a block, short enough to follow room tone that drifts.
803const NOISE_BLOCK_FRAMES: usize = 256;
804
805/// Prior probability that a bin holds no speech, used in the likelihood ratio. Slightly above a
806/// half so that ambiguous bins lean toward suppression rather than passing noise through.
807const SPEECH_ABSENCE_PRIOR: f32 = 0.6;
808
809/// Bias compensation for reading a low quantile of an exponentially distributed power as its
810/// mean: for `Exp(mu)` the q-quantile is `-mu ln(1-q)`, so the 10th percentile UNDERSTATES the
811/// mean 9.49x. Without this factor, pure-pause frames measure a posteriori SNRs of ~7-10 against
812/// the uncorrected floor, the presence estimator reads them as speech, and the gain never
813/// approaches the floor (measured: 2.5 dB of pause reduction instead of ~20). This is the same
814/// role IMCRA's B_min plays for its minimum statistic, recomputed for the quantile used here.
815#[allow(clippy::excessive_precision)]
816const NOISE_QUANTILE_BIAS: f32 = 9.491_221; // 1 / -ln(1 - NOISE_INIT_QUANTILE)
817
818/// Quantile of each bin's power, across the whole recording, taken as its initial noise floor.
819/// Speech is sparse in time, so a low quantile of a bin is the room rather than the voice.
820const NOISE_INIT_QUANTILE: f32 = 0.1;
821
822/// Single-channel speech enhancement: MMSE-LSA gains, decision-directed SNR, OM-LSA presence
823/// weighting, over a noise floor initialised offline and then tracked recursively.
824///
825/// Enrollment noise is not cosmetic: it is encoded into the x-vector and then reproduced in every
826/// utterance the cloned voice speaks (measured — cleaning a real 53 s reference dropped the
827/// synthesized output's pause floor by 19.5 dB). This removes the stationary part of it.
828///
829/// **Why this and not spectral subtraction.** Subtracting an estimated noise magnitude minimises
830/// squared error in the *spectrum*, which is the wrong objective for something a listener judges
831/// and a speaker encoder reads: it punches holes in low-SNR bins, producing musical noise, and it
832/// removes real signal along with the noise (measured here at 33% of peak burst energy before
833/// this replaced it). Three pieces fix that, and they compose:
834///
835/// 1. **MMSE-LSA** (Ephraim & Malah 1985) estimates the *log* amplitude, matching how loudness is
836///    perceived, and yields the gain `ξ/(1+ξ) · exp(½·E₁(ν))`. The exponential-integral term is
837///    what makes it gentle where the a posteriori SNR is uncertain instead of gating hard.
838/// 2. **Decision-directed a priori SNR** (same paper) smooths ξ across frames using the previous
839///    frame's own estimate. This is the specific mechanism that suppresses musical noise: isolated
840///    noise peaks never get a confident ξ, so they are never sharply attenuated *or* passed.
841/// 3. **OM-LSA** (Cohen & Berdugo 2001) blends that gain toward the floor by the speech-presence
842///    probability, `G = G_LSA^p · G_min^(1−p)`, so bins that are probably noise settle to a
843///    constant bed rather than being tracked.
844///
845/// **Why the noise floor is initialised offline rather than by minimum statistics.** IMCRA's
846/// online minimum tracking exists because a streaming denoiser cannot see the future. Enrollment
847/// can: the file is already on disk, so a low quantile of each bin over the whole recording is a
848/// better starting floor than any causal estimator's, with none of the convergence transient.
849/// An earlier revision here did run minimum statistics, and it is instructive why that was
850/// removed rather than debugged: seeded from frame 0 of a reference that opens on speech, the
851/// refined minimum locked above the speech level, which drove the presence probability to zero,
852/// which unfroze the noise update, which let the noise estimate absorb the voice — a positive
853/// feedback that left ~10% of every burst after the first. Speech presence here instead comes
854/// from the likelihood ratio in ξ and ν, which is self-correcting: it cannot conclude "no speech"
855/// about a bin whose own a priori SNR is high.
856///
857/// Nonstationary noise is still tracked, by the recursive average that the presence probability
858/// gates — the offline quantile only sets where that average starts.
859///
860/// This is the state of the art among methods that need no trained weights. Neural enhancers
861/// (DeepFilterNet and friends) do beat it, at the cost of shipping and running another model —
862/// which is not a trade this CLI should make silently for an enrollment preprocessing step.
863///
864/// Deliberately conservative even so: the speaker encoder reads breath, sibilance, and room as
865/// part of identity, so this stays opt-in (`--denoise`) per the project's doctrine that a lever
866/// which can damage speaker identity ships behind a named switch until blind listening clears it.
867///
868/// Phase is preserved untouched; only per-bin magnitude is scaled.
869/// Where `ftts pull` lands the neural denoiser, relative to the model root.
870pub const DENOISE_ARTIFACT_RELPATH: &str = "denoise/fastenhancer-s-48k.safetensors";
871
872/// The FastEnhancer denoise path: 24 kHz reference up to the model's native 48 kHz,
873/// through the ported network, back down to 24 kHz.
874///
875/// Returns `Ok(None)` when the neural route is unavailable (artifact not pulled) or the
876/// user forced the classic engine with `FTTS_DENOISE_ENGINE=omlsa` — the caller falls back
877/// to the spectral-subtraction reference. A *present but malformed* artifact is an error,
878/// not a silent fallback: repairing it is one `ftts pull --force` away, and quietly handing
879/// the user a different denoiser than the one they pulled would misreport what cleaned
880/// their reference.
881///
882/// The network runs at 48 kHz because that is what its checkpoint was trained on (with
883/// low-pass augmentation down to 24 kHz content, so band-limited input is in-distribution).
884/// The round trip uses the same Lanczos-6 resampler enrollment already trusts, and the
885/// input is zero-padded to the model's hop grid so the STFT round trip returns every
886/// sample, then trimmed back to the original length.
887fn 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
907/// Denoise arbitrary mono 24 kHz PCM through the pulled neural denoiser, if present.
908///
909/// `Ok(None)` when the neural route is unavailable — the caller keeps the original.
910/// Split out for hosts (the iOS app through `ftts-ffi`) that clean synthesized OUTPUT;
911/// enrollment keeps its own opt-in path through [`ReferenceCleanup`].
912///
913/// # Errors
914///
915/// When the artifact is present but unreadable — a broken denoiser is repaired, not
916/// silently swapped for a different one.
917pub 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    // A floor estimated from a handful of frames is just the clip's own spectrum; below ~a
923    // quarter second there is nothing honest to subtract, so the clip passes through untouched
924    // (a single-frame "estimate" measured as flattening the whole clip toward the gain floor).
925    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    // Pass 1: every frame's power spectrum. Only the magnitudes are kept; retaining the complex
946    // frames would save the second FFT at 8× the memory, which a several-minute reference feels.
947    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    // Noise floor per bin: the MINIMUM over blocks of a low within-block quantile.
959    //
960    // There is deliberately no feedback here. A recursive noise average has to be gated by a
961    // speech-presence estimate, which in turn divides by the noise — and any error in that loop
962    // compounds: one speech frame admitted into the floor raises it, which lowers the presence
963    // estimate, which admits more speech. Both earlier revisions of this function died that way.
964    // Reading the whole file at once removes the loop rather than tuning it.
965    //
966    // The min-over-blocks reduction is load-bearing: a bare per-block quantile assumes speech is
967    // sparse WITHIN every 1.4 s block, and a bin that stays voiced across one whole block (a held
968    // vowel, a low harmonic mid-sentence) would have its own signal adopted as that block's floor
969    // and be gated to the floor gain — measured at ~28 dB of deletion on a sustained tone. Taking
970    // the minimum across blocks only requires the bin to be quiet somewhere in the recording,
971    // which is what "noise floor" actually means.
972    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    // Decision-directed state: last frame's gain and a posteriori SNR, per bin.
987    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            // Speech-presence probability by the likelihood ratio (Ephraim & Malah's signal
1011            // presence uncertainty). High ξ with a matching ν drives this to 1, so a bin that is
1012            // plainly speech can never be talked into being noise.
1013            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            // DC (bin 0) has no mirror, and Nyquist (bin N/2) *is* its own mirror — scaling it
1027            // through this branch as well would apply `gain` twice there.
1028            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    // A sample under-covered by the window stack cannot be normalized honestly: near the edges
1042    // out[n] is dominated by circular-convolution leakage from the rest of the frame, and
1043    // dividing that by a window energy as small as ~2e-6 manufactures a spike (measured 1.5x
1044    // input peak with a bare non-zero guard). Anything below a tenth of the steady-state COLA
1045    // sum (1.5 for periodic Hann at hop N/4) keeps the original PCM instead.
1046    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    // The head and tail lie outside any fully-stacked window and keep their original samples
1053    // rather than a partially-normalized reconstruction.
1054    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
1064/// The exponential integral `E₁(x) = ∫ₓ^∞ e^{−t}/t dt`, for `x > 0`.
1065///
1066/// This is the term that makes MMSE-LSA gentle rather than gating: it grows without bound as the
1067/// a priori SNR falls, so the log-amplitude estimate backs off smoothly instead of snapping shut.
1068/// Abramowitz & Stegun 5.1.53 below 1 and 5.1.56 above it; both are accurate to ~2e-7, far inside
1069/// what a spectral gain needs.
1070fn exponential_integral_e1(x: f32) -> f32 {
1071    if x <= 0.0 {
1072        // ν ≤ 0 cannot arise from a non-negative SNR, but a denormal would otherwise return NaN
1073        // and poison the frame.
1074        return 0.0;
1075    }
1076    let x = f64::from(x);
1077    let value = if x < 1.0 {
1078        // A&S 5.1.53: E₁(x) + ln x = polynomial in x.
1079        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        // A&S 5.1.56: x·e^x·E₁(x) = rational in x.
1094        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
1103/// Dereverberation runs its own STFT, deliberately coarser in time than the denoiser's.
1104///
1105/// The two want opposite things. Denoising wants short frames so a gain change lands inside a
1106/// phoneme; prediction wants each frame to cover enough of the room's tail that a tractable
1107/// number of taps can span it. At a 5.3 ms hop, a 24-tap filter reaches 128 ms — against an
1108/// 810 ms reverb that removed 0.01 s of RT60, i.e. nothing (measured). A 10.7 ms hop with 40 taps
1109/// reaches ~427 ms, which is the fraction of the tail single-channel prediction can model without
1110/// the covariance becoming both enormous and ill-conditioned.
1111const DEREVERB_FRAME: usize = 1024;
1112const DEREVERB_HOP: usize = 256;
1113
1114/// Prediction taps: ~427 ms of tail at [`DEREVERB_HOP`].
1115const DEREVERB_TAPS: usize = 40;
1116
1117/// Frames skipped before prediction starts, so the direct sound and its early reflections are
1118/// never predictable from the regressor and therefore never subtracted. This delay is the whole
1119/// reason WPE dereverberates instead of just whitening the voice.
1120const DEREVERB_DELAY: usize = 2;
1121
1122/// Alternations between "estimate the speech variance" and "re-fit the filter". The variance
1123/// estimate is what makes the fit ignore loud speech frames and key on the tail; two passes are
1124/// enough to converge in practice, three leaves margin.
1125const DEREVERB_ITERATIONS: usize = 3;
1126
1127/// Diagonal loading on the covariance, relative to its own trace. Silent bins are rank-deficient
1128/// and would otherwise produce an arbitrary filter that injects noise instead of removing tail.
1129const DEREVERB_LOADING: f64 = 1e-4;
1130
1131/// What a `--dereverb` enrollment measured, so the CLI reports the effect rather than asserting it.
1132#[derive(Clone, Copy, Debug)]
1133pub struct DereverbReport {
1134    /// Reverberation time equivalent of the reference, before dereverberation.
1135    pub before_rt60_s: f32,
1136    /// The same measure afterwards.
1137    pub after_rt60_s: f32,
1138}
1139
1140/// Blind single-channel dereverberation by Weighted Prediction Error (Nakatani et al., 2010).
1141///
1142/// # Why this is a separate lever from `--denoise`
1143///
1144/// Reverb is *convolutive*: the microphone hears the voice convolved with the room's impulse
1145/// response. Denoising subtracts an *additive* stationary floor. The two do not overlap at all,
1146/// which is why running the denoiser on a reverberant reference moves the noise floor by 0.0 dB
1147/// and leaves the wetness untouched — measured, on exactly the recording that prompted this.
1148///
1149/// # Why it matters for enrollment specifically
1150///
1151/// The speaker encoder cannot separate voice from room, so a wet reference enrolls the room as
1152/// part of the speaker's identity and every utterance the clone speaks is rendered in that room
1153/// (measured: a 0.81 s reference produced a 0.79 s clone; a 0.66 s reference produced 0.68 s).
1154/// Drying the reference is therefore not cosmetic — it changes who the model thinks it is
1155/// imitating.
1156///
1157/// # The method
1158///
1159/// Late reverberation at frame `t` is, by construction, a linear function of the *past* of the
1160/// same signal: it is what earlier sound has decayed into. So per frequency bin, fit a linear
1161/// predictor from frames `t-D-L+1 ..= t-D` and subtract what it predicts. The delay `D` is what
1162/// protects the direct path: the speech itself is not predictable at that lag, the room's tail is.
1163///
1164/// The weighting is the "WPE" part and the reason it beats plain linear prediction. Each frame is
1165/// divided by the current estimate of the speech power there, so loud vowels — where the residual
1166/// is dominated by speech, not tail — stop dominating the fit. Estimating that power needs the
1167/// dereverberated signal, which needs the filter, so the two alternate for a few iterations.
1168///
1169/// Only late reverberation is removed. Early reflections arrive inside the protected delay by
1170/// design, so a very close, very live room is improved less than a distant one.
1171fn 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    // Observed spectra, kept complex: prediction needs phase, unlike the magnitude-only denoiser.
1196    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            // Speech power per frame, from the current estimate. The floor keeps a silent frame
1216            // from receiving unbounded weight and hijacking the fit.
1217            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                // Regressor: the observed signal at increasing lag past the protected delay.
1230                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            // Hermitian: fill the lower triangle from the upper one that was accumulated.
1242            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    // Overlap-add the dereverberated spectra back, mirroring the conjugate half so the inverse
1274    // transform yields a real signal.
1275    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
1311/// Double-precision complex scalar for the normal equations.
1312///
1313/// The covariance is accumulated over thousands of frames and then inverted; doing that in f32
1314/// loses conditioning on quiet bins, which is where a bad filter does the most audible damage.
1315type Complex64 = rustfft::num_complex::Complex<f64>;
1316
1317/// Solves `a x = b` by Gaussian elimination with partial pivoting, consuming both.
1318///
1319/// Returns `None` when the system is singular to working precision, which the caller treats as
1320/// "leave this bin alone" rather than as a failure — a bin with no energy has no tail to remove.
1321fn 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
1369/// Reverberation time equivalent, in seconds, from the decay following speech offsets.
1370///
1371/// Reverb leaves no trace in a noise floor — what it does is stretch the energy envelope after
1372/// every stop. Measuring the median decay slope across offsets is what separates "the room rings"
1373/// from "the microphone hisses", two problems whose fixes have nothing in common. Returns `None`
1374/// when the audio has no clear offsets to measure.
1375fn reverb_time_s(pcm: &[f32]) -> Option<f32> {
1376    let hop = (SPEAKER_SAMPLE_RATE_HZ as usize) / 100; // 10 ms
1377    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; // 150 ms of decay
1389    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
1408/// Root-mean-square of the quietest decile of 50 ms windows, in dBFS.
1409///
1410/// Whole-signal RMS hides pause noise behind the speech that dominates it, so enrollment
1411/// diagnostics report the floor between words — the part a denoise actually moves.
1412fn 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
1438/// One Lanczos tap: a sinc lowpass at `cutoff`, windowed by a wider sinc over `lobes`.
1439fn 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
1447/// Normalized sinc, `sin(pi x) / (pi x)`, with the removable singularity at zero filled in.
1448fn 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
1456/// Hands the engine a `PreparedText` that was computed before the weights were borrowed.
1457struct 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
1478/// A completed synthesis: the codes the talker produced and the audio they decode to.
1479pub struct SynthesizedAudio {
1480    /// Codec frames generated before the stop.
1481    pub frames: u64,
1482    /// Token ids that entered the model path, including the assistant wrapper.
1483    pub prepared_token_count: usize,
1484    /// Mono 24 kHz samples in `[-1, 1]`.
1485    pub pcm: Vec<f32>,
1486    /// Time from synthesis start (prompt work + prefill + first frames) to the first decoded
1487    /// packet of PCM existing. `None` when the run produced no audio. Time-to-first-audio and
1488    /// real-time factor are different products (doctrine: report them separately); this is the
1489    /// TTFA half, excluding model load, which the `load` stage event already bounds.
1490    pub ttfa: Option<std::time::Duration>,
1491}
1492
1493/// Run one utterance end to end: text, codes, PCM.
1494///
1495/// # Errors
1496///
1497/// Engine refusals (admission, budget, cancellation) and model refusals are mapped to their CLI
1498/// exit classes; a zero-frame generation is reported rather than written out as an empty file.
1499#[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    // 1. Text, once — see the module docs on ordering.
1510    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    // 2. The cold-embedding rows this utterance can reach, and nothing else.
1518    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    // 3. The prompt header, derived from checkpoint tensors and the caller's speaker vector.
1525    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    // 4. Borrowed weights for the generator.
1532    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    // The microdecoder's internal tables cover depths 2..=15: the first fourteen of the same
1537    // fifteen-table set the talker feedback path uses.
1538    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                &micro_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            // The PRODUCT samples, exactly as the pinned upstream runtime does
1560            // (generation_config.json: do_sample=true, T=0.9, top_k=50, repetition_penalty=1.05,
1561            // subtalker likewise); canonical greedy remains the conformance decoder only. The p7r
1562            // forensics that certified this path: our talker draw stack matched torch's choices
1563            // code-for-code for seven straight frames from the same prefill, the silence defect was
1564            // the subtalker being forced greedy under a sampled talker (a measured silence
1565            // attractor the reference reproduces in that mismatched configuration), and with the
1566            // subtalker sampling per depth the engine's utterance envelope matches the reference's
1567            // sampled runs (peak frame RMS 0.086 with trailing silence). Determinism scope: build +
1568            // ISA + sampler version + seed, 16 draws per frame.
1569            sampling_mode: SamplingMode::Production,
1570            seed,
1571        },
1572        model.artifact.as_deref(),
1573    );
1574
1575    // 5. The engine owns admission, the budget, cancellation, and the frame loop — and the
1576    // codec decodes IN PARALLEL with it: a tee on the generator feeds every produced frame
1577    // through a bounded channel to a scoped codec worker driving the streaming decoder.
1578    // Streamed output is bit-identical to offline decode under every packet schedule (the
1579    // standing streaming==batch gate), so this overlap changes wall time and nothing else.
1580    // Deadlock shape: the worker only ever blocks on `recv` (it always drains), and the
1581    // generator only ever blocks on `send` when the worker is more than 256 frames behind —
1582    // bounded, and covered by the engine's rolling frame budget if the worker wedges.
1583    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                    // Overlap for real: this thread's int8 ops run serially on a spare core
1599                    // instead of contending for the generator's worker team.
1600                    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                    // `stream_push` REPLACES its output buffer with one packet's samples (see the
1605                    // streaming==offline test), so packets decode into a scratch and append here.
1606                    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); // closes the channel; the worker drains the tail packet and exits
1660            let pcm = worker.join().expect("codec worker must not panic");
1661            // The engine's error wins the report: when generation fails, the worker usually
1662            // fails too (starved or fed a partial stream), and its complaint would bury the
1663            // actual cause.
1664            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
1687/// Forwards a generator's frames unchanged while teeing each one to the codec worker.
1688///
1689/// A send only fails when the worker has already died with its own error; surfacing a
1690/// generation error here aborts the engine loop early, and the worker's real failure is
1691/// reported at join.
1692struct 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
1715/// Map an engine refusal onto the CLI's exit-code contract.
1716fn 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/// A model-side failure, for callers that need the engine's own error type.
1726#[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    /// Audio already at the pinned rate must come back untouched — the resample path is additive
1736    /// and may not perturb any enrollment that worked before it existed.
1737    #[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    /// `E₁` sets the MMSE-LSA gain at every bin of every frame, so a mistyped coefficient would
1753    /// quietly bias the whole denoiser instead of failing. References computed from the
1754    /// convergent series `−γ − ln x + Σ (−1)^{k+1} x^k /(k·k!)`, a different algorithm from the
1755    /// rational fits under test.
1756    ///
1757    /// The tolerance is tight on purpose: a single-digit slip in the fifth-order coefficient
1758    /// perturbs `E₁(0.9)` by ~7.6e-7, which a looser bound would wave through.
1759    #[test]
1760    fn the_exponential_integral_matches_its_series_expansion() {
1761        // (x, E₁(x)) — the series branch, x < 1.
1762        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        // E₁ is positive and strictly decreasing; the two branches must agree where they meet.
1776        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    /// The denoiser has to do both halves of its job: drop the noise floor between bursts, and
1790    /// leave the signal itself standing. A filter that achieves the first by attenuating
1791    /// everything would pass a floor-only check while destroying the voice it was meant to clean.
1792    #[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        // Deterministic hiss, so the assertion cannot flake on a lucky seed.
1797        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        // Half-second bursts of tone alternating with silence, all of it under hiss.
1806        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        // Hiss at ~-26 dBFS against a 0.35 tone (~17 dB SNR): the audible-voice-memo regime
1818        // this lever exists for. (An earlier revision's generator bug made the "hiss" 4000x
1819        // louder than the signal, and the assertions below were calibrated against artifacts.)
1820        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        // Energy inside a burst must survive. Compare the loudest quarter-second of each.
1837        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    /// A reference that opens on speech, with no leading room tone to learn from, must keep that
1851    /// opening — most voice memos start the moment recording does.
1852    ///
1853    /// The probe is deliberately voice-*like*: a harmonic stack with vibrato and an amplitude
1854    /// envelope. That matters, because a steady unvarying partial is spectrally what a hum is,
1855    /// and suppressing it is correct behaviour rather than a bug — an earlier version of this
1856    /// test used a bare sine and was measuring the denoiser doing its job.
1857    #[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                    // Vibrato and a syllable envelope keep the partials moving, which is what
1874                    // distinguishes a voice from a tone to any minimum/quantile noise estimator.
1875                    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    /// Denoising must not buy a quiet floor by dulling the voice.
1904    ///
1905    /// High frequencies are where this fails first and where it matters most: broadband hiss
1906    /// overlaps sibilance almost exactly, so a suppressor tuned by overall SNR happily trades
1907    /// away 4–10 kHz — and the speaker encoder reads sibilance as identity, so that trade shows
1908    /// up as a duller *and less recognizable* clone rather than merely a duller one.
1909    ///
1910    /// The probe is broadband speech-like content: every band must survive comparably, so the
1911    /// assertion is on the spread across bands, not on any single band's absolute retention.
1912    #[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        // Equal-amplitude tones spanning the band, gated into syllable-like bursts so the
1923        // estimator treats them as speech rather than as hum.
1924        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        // Per-probe retention, measured by projecting each band onto its own tone (a one-bin
1947        // Goertzel-style correlation) inside a burst.
1948        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    /// A clip too short to survive its own downsample must round to nothing here rather than
1981    /// reaching the mel front end as an empty slice — which is why `decode_reference_audio`
1982    /// re-checks emptiness against the resampled PCM instead of only the decoded PCM.
1983    #[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    /// A tone that survives the resample proves the kernel is a real lowpass and not a decimator:
1994    /// 48 kHz is the rate every phone and Mac voice memo records at, and a 1 kHz tone sits well
1995    /// inside the 12 kHz band that survives the trip to 24 kHz.
1996    #[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; // one second
2001        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        // Compare against the tone sampled directly at the target rate, ignoring the window's
2018        // run-up at each end where the kernel is truncated by the signal boundary.
2019        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        // An agent that gets "model not found" for a directory holding three of four files cannot
2120        // act on it; the message must name the one that is absent.
2121        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}