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}
151
152impl LoadedModel {
153    /// Hydrate the bundle. This reads gigabytes and is the slow step of a cold run.
154    ///
155    /// # Errors
156    ///
157    /// If any checkpoint or tokenizer file is unreadable or not the pinned model.
158    pub fn load(bundle: &ModelBundle) -> Result<Self, FttsError> {
159        let read = |name: &str| -> Result<String, FttsError> {
160            let path = bundle.root.join(name);
161            fs::read_to_string(&path).map_err(|error| {
162                FttsError::ArtifactFormat(format!("cannot read {}: {error}", path.display()))
163            })
164        };
165        let vocab = read("vocab.json")?;
166        let merges = read("merges.txt")?;
167        let config = read("tokenizer_config.json")?;
168        let tokenizer = QwenTokenizer::from_files_using_environment(TokenizerFiles {
169            vocab_json: &vocab,
170            merges_txt: &merges,
171            tokenizer_config_json: &config,
172        })
173        .map_err(|error| FttsError::ArtifactFormat(format!("tokenizer unusable: {error}")))?;
174
175        Ok(Self {
176            talker: match bundle.canonical_main.as_deref() {
177                Some(path) => TalkerCheckpoint::load_fttsq(path).map_err(checkpoint_error)?,
178                None => TalkerCheckpoint::load(&bundle.main).map_err(checkpoint_error)?,
179            },
180            codec: CodecCheckpoint::load(&bundle.codec).map_err(checkpoint_error)?,
181            tokenizer,
182        })
183    }
184}
185
186/// Read a precomputed 1,024-wide speaker vector.
187///
188/// See the module docs on why this is a raw vector rather than a `.ftvoice` pack.
189///
190/// # Errors
191///
192/// [`FttsError::Input`] when the file is unreadable or is not exactly
193/// [`SPEAKER_VECTOR_BYTES`] bytes — a truncated vector would otherwise be padded with silence and
194/// change the voice in a way only listening could detect.
195pub fn read_speaker_vector(path: &Path) -> Result<Vec<f32>, FttsError> {
196    let bytes = fs::read(path).map_err(|error| {
197        FttsError::Input(format!(
198            "cannot read speaker vector {}: {error}",
199            path.display()
200        ))
201    })?;
202    if bytes.len() != SPEAKER_VECTOR_BYTES {
203        return Err(FttsError::Input(format!(
204            "speaker vector {} is {} bytes; `ftts say --voice` expects exactly {} \
205             ({TALKER_HIDDEN} little-endian f32)",
206            path.display(),
207            bytes.len(),
208            SPEAKER_VECTOR_BYTES
209        )));
210    }
211    let vector: Vec<f32> = bytes
212        .as_chunks::<4>()
213        .0
214        .iter()
215        .map(|quad| f32::from_le_bytes(*quad))
216        .collect();
217    if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
218        return Err(FttsError::Input(format!(
219            "speaker vector {} holds a non-finite value at index {index}; it would poison every \
220             prefill position it is summed into",
221            path.display()
222        )));
223    }
224    Ok(vector)
225}
226
227/// Derive an x-vector from an enrolled raw vector or a real reference recording.
228pub fn speaker_from_voice(bundle: &ModelBundle, path: &Path) -> Result<Vec<f32>, FttsError> {
229    let bytes = fs::read(path).map_err(|error| {
230        FttsError::Input(format!(
231            "cannot read voice source {}: {error}",
232            path.display()
233        ))
234    })?;
235    if bytes.len() == SPEAKER_VECTOR_BYTES {
236        return decode_speaker_vector(path, &bytes);
237    }
238    let pcm = decode_reference_audio_any(path)?;
239    let mel = log_mel_from_24khz_pcm(&pcm)
240        .map_err(|error| FttsError::Input(format!("cannot extract speaker features: {error}")))?;
241    let encoder = match bundle.canonical_main.as_deref() {
242        Some(artifact) => SpeakerEncoder::load_fttsq(artifact),
243        None => SpeakerEncoder::load(&bundle.main),
244    }
245    .map_err(checkpoint_error)?;
246    let vector = encoder.encode(&mel.values, mel.frames);
247    if vector.iter().all(|value| value.is_finite()) {
248        Ok(vector)
249    } else {
250        Err(FttsError::Input(
251            "speaker encoder produced a non-finite x-vector; refusing to condition synthesis"
252                .to_owned(),
253        ))
254    }
255}
256
257/// Write a raw x-vector without replacing an existing enrollment result.
258pub fn write_speaker_vector_new(path: &Path, vector: &[f32]) -> Result<(), FttsError> {
259    if vector.len() != TALKER_HIDDEN {
260        return Err(FttsError::Input(format!(
261            "cannot write {}-wide speaker vector; expected {TALKER_HIDDEN}",
262            vector.len()
263        )));
264    }
265    if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
266        return Err(FttsError::Input(format!(
267            "cannot write speaker vector with a non-finite value at index {index}"
268        )));
269    }
270    let mut bytes = Vec::with_capacity(SPEAKER_VECTOR_BYTES);
271    for value in vector {
272        bytes.extend_from_slice(&value.to_le_bytes());
273    }
274    use std::io::Write;
275    let mut file = OpenOptions::new()
276        .write(true)
277        .create_new(true)
278        .open(path)
279        .map_err(|error| {
280            FttsError::Input(format!(
281                "cannot create enrolled voice {} without overwriting an existing file: {error}",
282                path.display()
283            ))
284        })?;
285    file.write_all(&bytes).map_err(|error| {
286        FttsError::Input(format!(
287            "cannot write enrolled voice {}: {error}",
288            path.display()
289        ))
290    })
291}
292
293fn decode_speaker_vector(path: &Path, bytes: &[u8]) -> Result<Vec<f32>, FttsError> {
294    let vector: Vec<f32> = bytes
295        .as_chunks::<4>()
296        .0
297        .iter()
298        .map(|quad| f32::from_le_bytes(*quad))
299        .collect();
300    if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
301        return Err(FttsError::Input(format!(
302            "speaker vector {} holds a non-finite value at index {index}; it would poison every \
303             prefill position it is summed into",
304            path.display()
305        )));
306    }
307    Ok(vector)
308}
309
310/// Container formats the embedded decoder does not read; these route through a system decoder,
311/// mirroring how output encoding shells out — synthesis and enrollment themselves never depend
312/// on one.
313const SYSTEM_DECODED_EXTENSIONS: [&str; 6] = ["m4a", "mp3", "aac", "mp4", "ogg", "opus"];
314
315/// Decodes reference audio of any supported container to mono f32 PCM.
316///
317/// WAV and FLAC decode through the embedded pure-Rust path. Compressed containers (m4a, mp3, …)
318/// are first transcoded to a temporary WAV by the first system decoder found — `afconvert` on
319/// macOS, then `ffmpeg` — with a clear error naming both tools when neither exists.
320fn decode_reference_audio_any(path: &Path) -> Result<Vec<f32>, FttsError> {
321    let extension = path
322        .extension()
323        .and_then(|extension| extension.to_str())
324        .map(str::to_ascii_lowercase);
325    let needs_system_decoder = extension
326        .as_deref()
327        .is_some_and(|extension| SYSTEM_DECODED_EXTENSIONS.contains(&extension));
328    if !needs_system_decoder {
329        return decode_reference_audio(path);
330    }
331
332    let staging = std::env::temp_dir().join(format!(
333        "ftts-enroll-{}-{}.wav",
334        std::process::id(),
335        path.file_stem()
336            .and_then(|stem| stem.to_str())
337            .unwrap_or("reference")
338    ));
339    let attempts: &[(&str, Vec<&std::ffi::OsStr>)] = &[
340        // Both decoders are told to resample to the speaker encoder's pinned 24 kHz mono here
341        // rather than leaving the source rate intact: phone and Mac voice memos default to
342        // 44.1/48 kHz, and a transcode that preserves them would only move the failure to the
343        // enrollment rate check (frankentts-gra).
344        (
345            "afconvert",
346            vec![
347                "-f".as_ref(),
348                "WAVE".as_ref(),
349                "-d".as_ref(),
350                "LEI16@24000".as_ref(),
351                "-c".as_ref(),
352                "1".as_ref(),
353                path.as_os_str(),
354                staging.as_os_str(),
355            ],
356        ),
357        (
358            "ffmpeg",
359            vec![
360                "-y".as_ref(),
361                "-loglevel".as_ref(),
362                "error".as_ref(),
363                "-i".as_ref(),
364                path.as_os_str(),
365                "-acodec".as_ref(),
366                "pcm_s16le".as_ref(),
367                "-ar".as_ref(),
368                "24000".as_ref(),
369                "-ac".as_ref(),
370                "1".as_ref(),
371                staging.as_os_str(),
372            ],
373        ),
374    ];
375    let mut ran = false;
376    for (tool, arguments) in attempts {
377        match std::process::Command::new(tool).args(arguments).status() {
378            Ok(status) if status.success() => {
379                ran = true;
380                break;
381            }
382            Ok(status) => {
383                let _ = fs::remove_file(&staging);
384                return Err(FttsError::Input(format!(
385                    "{tool} failed decoding reference audio {} (exit {status})",
386                    path.display()
387                )));
388            }
389            Err(_) => continue, // tool not installed; try the next one
390        }
391    }
392    if !ran {
393        return Err(FttsError::Input(format!(
394            "reference audio {} is a compressed container and no system decoder was found; \
395             install afconvert (macOS) or ffmpeg, or supply WAV/FLAC",
396            path.display()
397        )));
398    }
399    let decoded = decode_reference_audio(&staging);
400    let _ = fs::remove_file(&staging);
401    decoded
402}
403
404fn decode_reference_audio(path: &Path) -> Result<Vec<f32>, FttsError> {
405    let file = fs::File::open(path).map_err(|error| {
406        FttsError::Input(format!(
407            "cannot open reference audio {}: {error}",
408            path.display()
409        ))
410    })?;
411    let mut hint = Hint::new();
412    if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) {
413        hint.with_extension(extension);
414    }
415    let stream = MediaSourceStream::new(Box::new(file), Default::default());
416    let probed = get_probe()
417        .format(
418            &hint,
419            stream,
420            &FormatOptions::default(),
421            &MetadataOptions::default(),
422        )
423        .map_err(|error| {
424            FttsError::Input(format!(
425                "cannot identify reference audio {}: {error}",
426                path.display()
427            ))
428        })?;
429    let mut format = probed.format;
430    let track = format.default_track().ok_or_else(|| {
431        FttsError::Input(format!(
432            "reference audio {} has no default audio track",
433            path.display()
434        ))
435    })?;
436    let track_id = track.id;
437    let mut decoder = get_codecs()
438        .make(&track.codec_params, &DecoderOptions::default())
439        .map_err(|error| {
440            FttsError::Input(format!(
441                "cannot decode reference audio {}: {error}",
442                path.display()
443            ))
444        })?;
445    let mut sample_rate = None;
446    let mut mono = Vec::new();
447    loop {
448        let packet = match format.next_packet() {
449            Ok(packet) => packet,
450            Err(SymphoniaError::IoError(error))
451                if error.kind() == std::io::ErrorKind::UnexpectedEof =>
452            {
453                break;
454            }
455            Err(error) => {
456                return Err(FttsError::Input(format!(
457                    "cannot read reference audio {}: {error}",
458                    path.display()
459                )));
460            }
461        };
462        if packet.track_id() != track_id {
463            continue;
464        }
465        let decoded = decoder.decode(&packet).map_err(|error| {
466            FttsError::Input(format!(
467                "cannot decode reference audio {}: {error}",
468                path.display()
469            ))
470        })?;
471        let spec = *decoded.spec();
472        match sample_rate {
473            Some(rate) if rate != spec.rate => {
474                return Err(FttsError::Input(format!(
475                    "reference audio {} changed sample rate mid-stream ({rate} to {} Hz)",
476                    path.display(),
477                    spec.rate
478                )));
479            }
480            None => sample_rate = Some(spec.rate),
481            Some(_) => {}
482        }
483        let channels = spec.channels.count();
484        let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
485        samples.copy_interleaved_ref(decoded);
486        for frame in samples.samples().chunks_exact(channels) {
487            mono.push(frame.iter().sum::<f32>() / channels as f32);
488        }
489    }
490    let rate = sample_rate.ok_or_else(|| {
491        FttsError::Input(format!(
492            "reference audio {} contains no decodable samples",
493            path.display()
494        ))
495    })?;
496    if rate != SPEAKER_SAMPLE_RATE_HZ {
497        return Err(FttsError::Input(format!(
498            "reference audio {} is {rate} Hz; the pinned speaker encoder requires {SPEAKER_SAMPLE_RATE_HZ} Hz",
499            path.display()
500        )));
501    }
502    if mono.is_empty() {
503        return Err(FttsError::Input(format!(
504            "reference audio {} contains no PCM samples",
505            path.display()
506        )));
507    }
508    Ok(mono)
509}
510
511/// Hands the engine a `PreparedText` that was computed before the weights were borrowed.
512struct PreparedPassThrough {
513    prepared: PreparedText,
514}
515
516impl TextPreparer for PreparedPassThrough {
517    fn prepare(
518        &self,
519        _text: &str,
520        _options: &NormalizationOptions,
521    ) -> Result<PreparedText, TextPreparationError> {
522        Ok(PreparedText::new(
523            self.prepared.token_ids.clone(),
524            NormalizationTrace {
525                mode: self.prepared.normalization_trace.mode,
526                unicode_version: self.prepared.normalization_trace.unicode_version.clone(),
527                changes: self.prepared.normalization_trace.changes.clone(),
528            },
529        ))
530    }
531}
532
533/// A completed synthesis: the codes the talker produced and the audio they decode to.
534pub struct SynthesizedAudio {
535    /// Codec frames generated before the stop.
536    pub frames: u64,
537    /// Token ids that entered the model path, including the assistant wrapper.
538    pub prepared_token_count: usize,
539    /// Mono 24 kHz samples in `[-1, 1]`.
540    pub pcm: Vec<f32>,
541}
542
543/// Run one utterance end to end: text, codes, PCM.
544///
545/// # Errors
546///
547/// Engine refusals (admission, budget, cancellation) and model refusals are mapped to their CLI
548/// exit classes; a zero-frame generation is reported rather than written out as an empty file.
549#[allow(clippy::too_many_arguments)]
550pub fn synthesize(
551    model: &LoadedModel,
552    engine: &TtsEngine,
553    request: &SynthesisRequest,
554    speaker: &[f32],
555    seed: u64,
556    cancellation: &CancellationToken,
557    observer: &dyn SynthesisObserver,
558) -> Result<SynthesizedAudio, FttsError> {
559    // 1. Text, once — see the module docs on ordering.
560    let prepared_raw = model
561        .tokenizer
562        .prepare(&request.text, &request.normalization_options)
563        .map_err(|error| FttsError::Input(format!("text preparation failed: {error}")))?;
564    let wrapped = TalkerCheckpoint::wrap_target_ids(&prepared_raw.token_ids);
565    let prepared = PreparedText::new(wrapped.clone(), prepared_raw.normalization_trace);
566
567    // 2. The cold-embedding rows this utterance can reach, and nothing else.
568    let ids = TalkerCheckpoint::utterance_text_ids(&wrapped);
569    let table = model
570        .talker
571        .gather_text_rows(&ids)
572        .map_err(checkpoint_error)?;
573
574    // 3. The prompt header, derived from checkpoint tensors and the caller's speaker vector.
575    let header = model
576        .talker
577        .xvector_header(&table, speaker, CODEC_LANGUAGE_ENGLISH_ID)
578        .map_err(checkpoint_error)?;
579    let tts_eos = model.talker.tts_eos(&table);
580
581    // 4. Borrowed weights for the generator.
582    let talker_layers = model.talker.talker_layer_weights();
583    let micro_layers = model.talker.microdecoder_layer_weights();
584    let residual = model.talker.residual_embedding_slices();
585    let heads = model.talker.microdecoder_head_slices();
586    // The microdecoder's internal tables cover depths 2..=15: the first fourteen of the same
587    // fifteen-table set the talker feedback path uses.
588    let micro_residual = &residual[..residual.len() - 1];
589
590    let mut generator = QwenGenerator::new(QwenGeneratorConfig {
591        talker_config: TalkerConfig::default(),
592        talker_weights: model.talker.talker_weights(&talker_layers),
593        text: model.talker.text_weights(&table),
594        feedback: model.talker.feedback_tables(&residual),
595        microdecoder_config: MicrodecoderConfig::default(),
596        microdecoder_weights: model.talker.microdecoder_weights(
597            &micro_layers,
598            micro_residual,
599            &heads,
600        ),
601        prompt_mode: PromptMode {
602            clone_mode: CloneMode::XVector,
603            non_streaming_mode: false,
604        },
605        header,
606        tts_eos,
607        reference: None,
608        // The PRODUCT samples, exactly as the pinned upstream runtime does
609        // (generation_config.json: do_sample=true, T=0.9, top_k=50, repetition_penalty=1.05,
610        // subtalker likewise); canonical greedy remains the conformance decoder only. The p7r
611        // forensics that certified this path: our talker draw stack matched torch's choices
612        // code-for-code for seven straight frames from the same prefill, the silence defect was
613        // the subtalker being forced greedy under a sampled talker (a measured silence
614        // attractor the reference reproduces in that mismatched configuration), and with the
615        // subtalker sampling per depth the engine's utterance envelope matches the reference's
616        // sampled runs (peak frame RMS 0.086 with trailing silence). Determinism scope: build +
617        // ISA + sampler version + seed, 16 draws per frame.
618        sampling_mode: SamplingMode::Production,
619        seed,
620    });
621
622    // 5. The engine owns admission, the budget, cancellation, and the frame loop.
623    let preparer = PreparedPassThrough { prepared };
624    let result = engine
625        .synthesize(
626            request.clone(),
627            &preparer,
628            &mut generator as &mut dyn FrameGenerator,
629            cancellation,
630            observer,
631        )
632        .map_err(engine_error)?;
633
634    if result.code_frames.is_empty() {
635        return Err(FttsError::Generic(
636            "the talker stopped before emitting a frame; there is no audio to write. This is a \
637             model or prompt problem, not an output problem — check the speaker vector and the \
638             text"
639                .to_owned(),
640        ));
641    }
642
643    // 6. Codes to PCM. The codec wants frame-major `i32` groups.
644    let frames = result.code_frames.len();
645    let mut codes = Vec::with_capacity(frames * 16);
646    for frame in &result.code_frames {
647        if frame.codes.len() != 16 {
648            return Err(FttsError::Generic(format!(
649                "generated frame carries {} codes, expected 16",
650                frame.codes.len()
651            )));
652        }
653        for code in &frame.codes {
654            codes.push(i32::try_from(*code).map_err(|_| {
655                FttsError::Generic(format!(
656                    "generated code {code} does not fit the codec's i32"
657                ))
658            })?);
659        }
660    }
661    let pcm = model
662        .codec
663        .decode(&codes, frames)
664        .map_err(checkpoint_error)?;
665
666    Ok(SynthesizedAudio {
667        frames: result.generated_frames,
668        prepared_token_count: result.prepared_token_count,
669        pcm,
670    })
671}
672
673/// Map an engine refusal onto the CLI's exit-code contract.
674fn engine_error(error: EngineError) -> FttsError {
675    match error {
676        EngineError::BudgetExceeded(_) => FttsError::BudgetTimeout(error.to_string()),
677        EngineError::ResourceAdmission(_) => FttsError::BudgetTimeout(error.to_string()),
678        EngineError::TextPreparation(_) => FttsError::Input(error.to_string()),
679        other => FttsError::Generic(other.to_string()),
680    }
681}
682
683/// A model-side failure, for callers that need the engine's own error type.
684#[must_use]
685pub fn generation_error(message: &str) -> GenerationError {
686    GenerationError::new(message)
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn a_short_speaker_vector_is_refused_rather_than_padded() {
695        let dir = std::env::temp_dir().join("ftts-synth-tests");
696        fs::create_dir_all(&dir).expect("temp dir");
697        let path = dir.join("short.spk");
698        fs::write(&path, vec![0u8; 64]).expect("write");
699        let error = read_speaker_vector(&path).expect_err("a short vector must be refused");
700        let message = error.to_string();
701        assert!(message.contains("64 bytes"), "{message}");
702        assert!(message.contains("4096"), "{message}");
703    }
704
705    #[test]
706    fn a_non_finite_speaker_vector_is_refused() {
707        let dir = std::env::temp_dir().join("ftts-synth-tests");
708        fs::create_dir_all(&dir).expect("temp dir");
709        let path = dir.join("nan.spk");
710        let mut bytes = vec![0u8; SPEAKER_VECTOR_BYTES];
711        bytes[0..4].copy_from_slice(&f32::NAN.to_le_bytes());
712        fs::write(&path, &bytes).expect("write");
713        let error = read_speaker_vector(&path).expect_err("NaN must be refused");
714        assert!(error.to_string().contains("index 0"), "{error}");
715    }
716
717    #[test]
718    fn a_well_formed_speaker_vector_reads_back_exactly() {
719        let dir = std::env::temp_dir().join("ftts-synth-tests");
720        fs::create_dir_all(&dir).expect("temp dir");
721        let path = dir.join("good.spk");
722        let expected: Vec<f32> = (0..TALKER_HIDDEN).map(|i| i as f32 * 0.001).collect();
723        let mut bytes = Vec::with_capacity(SPEAKER_VECTOR_BYTES);
724        for value in &expected {
725            bytes.extend_from_slice(&value.to_le_bytes());
726        }
727        fs::write(&path, &bytes).expect("write");
728        assert_eq!(read_speaker_vector(&path).expect("read"), expected);
729    }
730
731    #[test]
732    fn enrollment_writer_refuses_overwrite_and_preserves_the_vector() {
733        let path = std::env::temp_dir().join(format!(
734            "ftts-enroll-{}-{}.spk",
735            std::process::id(),
736            std::time::SystemTime::now()
737                .duration_since(std::time::UNIX_EPOCH)
738                .expect("clock")
739                .as_nanos()
740        ));
741        let expected: Vec<f32> = (0..TALKER_HIDDEN)
742            .map(|index| index as f32 * 0.125)
743            .collect();
744        write_speaker_vector_new(&path, &expected).expect("initial enrollment write");
745        assert_eq!(
746            read_speaker_vector(&path).expect("read enrolled vector"),
747            expected
748        );
749        let error = write_speaker_vector_new(&path, &[0.0; TALKER_HIDDEN])
750            .expect_err("an enrollment must never replace an existing voice");
751        assert!(error.to_string().contains("without overwriting"), "{error}");
752    }
753
754    #[test]
755    fn wav_reference_decodes_to_mono_24khz_pcm() {
756        let path = std::env::temp_dir().join(format!(
757            "ftts-reference-{}-{}.wav",
758            std::process::id(),
759            std::time::SystemTime::now()
760                .duration_since(std::time::UNIX_EPOCH)
761                .expect("clock")
762                .as_nanos()
763        ));
764        let pcm: Vec<f32> = (0..1_920)
765            .map(|index| (index as f32 / 1_920.0 * std::f32::consts::TAU).sin() * 0.25)
766            .collect();
767        fs::write(
768            &path,
769            ftts_core::audio::encode_wav(&pcm, SPEAKER_SAMPLE_RATE_HZ),
770        )
771        .expect("write reference WAV");
772        let decoded = decode_reference_audio(&path).expect("decode reference WAV");
773        assert_eq!(decoded.len(), pcm.len());
774        assert!(decoded.iter().all(|sample| sample.is_finite()));
775    }
776
777    #[test]
778    fn a_bundle_names_the_file_that_is_actually_missing() {
779        // An agent that gets "model not found" for a directory holding three of four files cannot
780        // act on it; the message must name the one that is absent.
781        let dir = std::env::temp_dir().join("ftts-bundle-tests-empty");
782        fs::create_dir_all(&dir).expect("temp dir");
783        let error = ModelBundle::resolve(&dir).expect_err("an empty directory is not a bundle");
784        assert!(error.to_string().contains("model.safetensors"), "{error}");
785    }
786
787    #[test]
788    fn a_complete_bundle_prefers_its_canonical_artifact_for_synthesis() {
789        let nonce = std::time::SystemTime::now()
790            .duration_since(std::time::UNIX_EPOCH)
791            .expect("clock after epoch")
792            .as_nanos();
793        let dir = std::env::temp_dir().join(format!(
794            "ftts-bundle-canonical-{}-{nonce}",
795            std::process::id()
796        ));
797        fs::create_dir_all(dir.join("speech_tokenizer")).expect("create bundle sidecar directory");
798        for name in [
799            CANONICAL_MODEL_BASENAME,
800            "speech_tokenizer/model.safetensors",
801            "vocab.json",
802            "merges.txt",
803            "tokenizer_config.json",
804        ] {
805            fs::write(dir.join(name), []).expect("write bundle fixture sidecar");
806        }
807
808        let expected_artifact = dir.join(CANONICAL_MODEL_BASENAME);
809        let bundle = ModelBundle::resolve(&dir).expect("complete canonical bundle resolves");
810        assert_eq!(
811            bundle.canonical_main.as_deref(),
812            Some(expected_artifact.as_path())
813        );
814        assert!(
815            !bundle.main.exists(),
816            "canonical synthesis must not require the raw main checkpoint"
817        );
818
819        let explicit = ModelBundle::resolve(&expected_artifact)
820            .expect("an explicit canonical artifact resolves against its sidecars");
821        assert_eq!(
822            explicit.canonical_main.as_deref(),
823            Some(expected_artifact.as_path())
824        );
825    }
826}