Skip to main content

ftts_cli/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Shared, stateless command-line dispatch for both FrankenTTS binaries.
4
5mod error;
6pub mod resident;
7pub mod robot;
8pub mod style;
9pub mod synth;
10
11pub use error::{FttsError, FttsExitCode};
12pub use robot::{EventType, validate_event, validate_ndjson};
13
14use std::collections::BTreeMap;
15use std::ffi::OsString;
16use std::fs;
17use std::io::{self, Read, Write};
18use std::path::{Path, PathBuf};
19use std::process::ExitCode;
20use std::sync::OnceLock;
21
22#[cfg(test)]
23use clap::CommandFactory;
24use clap::{Parser, Subcommand, ValueEnum};
25use ftts_artifacts::census::{ExpectedTensor, WeightsManifest};
26use ftts_artifacts::converter::{
27    StreamingConversionPlan, TensorConversion, TensorStoragePolicy, convert_safetensors_streaming,
28};
29use ftts_artifacts::fttsq::{AccessClass, MappedFttsq};
30use ftts_artifacts::safetensors::Dtype;
31use ftts_core::{NormalizationMode, NormalizationOptions, SynthesisRequest};
32use ftts_kernels::mmap::MappedFile;
33use serde_json::{Value, json};
34
35const ROBOT_SCHEMA_VERSION: u8 = 1;
36const SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES: usize = 1_048_576;
37const MODEL_BASENAME: &str = "qwen3-tts-12hz-0.6b-base.fttsq";
38
39/// Built-in voices: name, one-line character, and the enrolled 1,024-float x-vector.
40///
41/// "matt" is the out-of-box default when no enrollment exists. Enrolling a real reference
42/// always takes precedence over any of them.
43const PRESET_VOICES: &[(&str, &str, &[u8])] = &[
44    (
45        "aria",
46        "clear, warm, feminine",
47        include_bytes!("../presets/aria.spk"),
48    ),
49    (
50        "ember",
51        "the same character a few semitones deeper",
52        include_bytes!("../presets/ember.spk"),
53    ),
54    (
55        "james",
56        "natural, conversational, masculine",
57        include_bytes!("../presets/james.spk"),
58    ),
59    (
60        "matt",
61        "warm, easy, masculine — the out-of-box default",
62        include_bytes!("../presets/matt.spk"),
63    ),
64    (
65        "leo",
66        "relaxed, resonant, masculine",
67        include_bytes!("../presets/leo.spk"),
68    ),
69    (
70        "robert",
71        "steady, measured, masculine",
72        include_bytes!("../presets/robert.spk"),
73    ),
74    (
75        "judy",
76        "bright, articulate, feminine",
77        include_bytes!("../presets/judy.spk"),
78    ),
79];
80
81/// The preset used when `--voice`, `FTTS_DEFAULT_VOICE`, and MODEL_DIR/default.spk are all
82/// absent, so a fresh install speaks out of the box.
83const DEFAULT_PRESET_VOICE: &str = "matt";
84
85/// Names a preset resolves to a temp-materialized `.spk` path the existing voice loaders read.
86///
87/// Only fires when the value is NOT an existing file, so a file named like a preset still wins.
88/// The file is rewritten unconditionally: 4 KB per run is cheaper than trusting stale content.
89fn materialize_preset_voice(name: &str) -> Option<Result<PathBuf, FttsError>> {
90    let (_, _, bytes) = PRESET_VOICES
91        .iter()
92        .find(|(preset, _, _)| *preset == name)?;
93    let staging_dir = match synth::private_staging_dir() {
94        Ok(dir) => dir,
95        Err(error) => {
96            return Some(Err(FttsError::Generic(format!(
97                "cannot create staging directory for preset voice {name}: {error}"
98            ))));
99        }
100    };
101    let path = staging_dir.join(format!("ftts-preset-{name}-{}.spk", std::process::id()));
102    Some(
103        fs::write(&path, bytes)
104            .map(|()| path.clone())
105            .map_err(|error| {
106                FttsError::Generic(format!(
107                    "cannot materialize preset voice {name} at {}: {error}",
108                    path.display()
109                ))
110            }),
111    )
112}
113
114fn preset_names() -> String {
115    PRESET_VOICES
116        .iter()
117        .map(|(name, _, _)| *name)
118        .collect::<Vec<_>>()
119        .join(", ")
120}
121const PINNED_MAIN_WEIGHTS_FILENAME: &str = "model.safetensors";
122const PINNED_MAIN_WEIGHTS_SHA256: &str =
123    "180b3b10eb1c9f1b4db7806d5475bae3071c0243c299d49926bab1da3b6946f6";
124const PINNED_MODEL_REVISION: &str = "5d83992436eae1d760afd27aff78a71d676296fc";
125const PINNED_MAIN_TENSOR_COUNT: usize = 478;
126//  Crate-local pinned copies (`pinned/`): `cargo package` cannot ship files outside the crate
127//  root, and these three are compile-time product surfaces (the artifact census, the model-dir
128//  pin assertion, and the Apache attribution the binary prints). A unit test asserts each copy is
129//  byte-identical to the truth-pack canonical whenever the truth pack is present.
130const PINNED_TENSOR_INVENTORY: &str = include_str!("../pinned/TENSOR_INVENTORY.json");
131const PINNED_MODEL_CONFIG: &str = include_str!("../pinned/model_config.json");
132const APACHE_LICENSE: &str = include_str!("../pinned/QWEN_APACHE_LICENSE");
133//  The `ftts pull` download contract: which release assets make a complete model directory, and
134//  the exact digest each must carry. Embedded so a shipped binary can fetch and verify the model
135//  with no network-served manifest to trust.
136const PINNED_MODEL_MANIFEST: &str = include_str!("../pinned/model_manifest.json");
137/// Subdirectory of `$HOME/.cache` that `ftts pull` fills and model resolution falls back to.
138const DEFAULT_MODEL_CACHE_SUBDIR: &str = ".cache/franken_tts/model";
139const ENVIRONMENT_VARIABLES: [&str; 11] = [
140    "FTTS_MODEL_DIR",
141    "FTTS_DEFAULT_VOICE",
142    "FTTS_THREADS",
143    "FTTS_PROFILE",
144    "FTTS_PACKET_FRAMES",
145    "FTTS_MATH_MODE",
146    "FTTS_QUANT",
147    "FTTS_FORCE_ARCH",
148    "FTTS_NUMA",
149    "FTTS_MAX_FRAMES",
150    "FTTS_MEMORY_BUDGET_MB",
151];
152
153/// Runs the shared `ftts` / `franken_tts` command-line interface.
154pub fn cli_main() -> ExitCode {
155    // The optimized route is the DEFAULT everywhere (library-level: see
156    // `ftts_kernels::route`). `FTTS_INT8=0` selects the f32 reference route end to end;
157    // DISC-003 records the decision and the evidence.
158
159    let cli = match Cli::try_parse() {
160        Ok(cli) => cli,
161        Err(error) => {
162            let exit_code = match error.kind() {
163                clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
164                    FttsExitCode::Success
165                }
166                _ => FttsExitCode::Usage,
167            };
168            let _ = error.print();
169            return exit_code.as_exit_code();
170        }
171    };
172
173    let mut stdin = io::stdin().lock();
174    let mut stdout = io::stdout().lock();
175    let mut stderr = io::stderr().lock();
176    match dispatch(cli, environment(), &mut stdin, &mut stdout, &mut stderr) {
177        Ok(()) => FttsExitCode::Success.as_exit_code(),
178        Err(error) => {
179            let _ = writeln!(stderr, "error: {error}");
180            error.exit_code().as_exit_code()
181        }
182    }
183}
184
185#[derive(Debug, Parser)]
186#[command(
187    name = "ftts",
188    version,
189    about = "Pure-Rust Qwen3-TTS command-line interface",
190    long_about = "FrankenTTS is stateless by default: synthesis history is never persisted. \
191                  Use `ftts robot schema` for the versioned NDJSON contract.",
192    arg_required_else_help = true
193)]
194struct Cli {
195    /// Execution profile. The default is balanced unless FTTS_PROFILE overrides it.
196    #[arg(long, global = true, value_enum)]
197    profile: Option<ExecutionProfile>,
198
199    /// Codec packet size in frames. The default is profile-dependent.
200    #[arg(long, global = true, value_enum)]
201    packet_frames: Option<PacketFrames>,
202
203    /// Math contract used by this invocation.
204    #[arg(long, global = true, value_enum)]
205    math_mode: Option<MathMode>,
206
207    /// Voice-pack serialization profile used by enrollment.
208    #[arg(long, global = true, value_enum)]
209    voice_pack: Option<VoicePackProfile>,
210
211    /// Text-normalization policy.
212    #[arg(long, global = true, value_enum)]
213    normalize: Option<NormalizeMode>,
214
215    /// Request a structured trace from synthesis without default-persisting sensitive text.
216    #[arg(long, global = true, value_name = "DIR")]
217    trace: Option<PathBuf>,
218
219    /// Reproducibility seed for a future sampler.
220    #[arg(long, global = true)]
221    seed: Option<u64>,
222
223    #[command(subcommand)]
224    command: Command,
225}
226
227#[derive(Debug, Subcommand)]
228enum Command {
229    /// Validate or synthesize text with an optional voice pack.
230    Say(SayArgs),
231    /// Synthesize text and render a share-ready branded video of it.
232    #[command(name = "make-video")]
233    MakeVideo(MakeVideoArgs),
234    /// Build a consent-bearing voice pack from reference audio.
235    Enroll(EnrollArgs),
236    /// Inspect a portable voice pack.
237    Voice(VoiceArgs),
238    /// Convert pinned source weights into a portable .fttsq artifact.
239    Convert(ConvertArgs),
240    /// Download and verify the pinned model files into the model directory.
241    Pull(PullArgs),
242    /// Emit versioned, line-oriented robot contract data.
243    Robot(RobotArgs),
244    /// Report local configuration and readiness without inference.
245    Doctor(DoctorArgs),
246    /// Internal: run the resident engine daemon. Spawned by `ftts say`; not for direct use.
247    #[command(hide = true, name = "resident-daemon")]
248    ResidentDaemon(ResidentDaemonArgs),
249}
250
251#[derive(Debug, clap::Args)]
252struct ResidentDaemonArgs {
253    /// Model directory this daemon serves.
254    #[arg(long, value_name = "PATH")]
255    bundle_root: PathBuf,
256}
257
258#[derive(Debug, clap::Args)]
259struct SayArgs {
260    /// Text to synthesize. Use `-` to read UTF-8 text from stdin.
261    #[arg(value_name = "TEXT")]
262    text: Option<String>,
263
264    /// Output file (same as -o). Format follows the extension: .wav is written natively;
265    /// .m4a, .mp3, and .flac are encoded from the native WAV by the first available system
266    /// encoder (afconvert, ffmpeg, lame, flac).
267    #[arg(value_name = "OUTPUT", conflicts_with_all = ["stream", "output"])]
268    output_positional: Option<PathBuf>,
269
270    /// Read UTF-8 text from PATH. Use `-` for stdin.
271    #[arg(long, value_name = "PATH", conflicts_with = "text")]
272    file: Option<PathBuf>,
273
274    /// Explicit .fttsq model artifact. No network lookup is performed.
275    #[arg(long, value_name = "PATH")]
276    model: Option<PathBuf>,
277
278    /// Voice source: a .spk vector, reference audio, or a built-in voice name
279    /// (matt, james, leo, robert, judy, aria, ember).
280    /// Default: MODEL_DIR/default.spk when enrolled, else the built-in "matt".
281    #[arg(long, value_name = "PATH|NAME")]
282    voice: Option<PathBuf>,
283
284    /// Write WAV output here. Mutually exclusive with raw stdout streaming.
285    #[arg(short = 'o', long, value_name = "PATH", conflicts_with = "stream")]
286    output: Option<PathBuf>,
287
288    /// Stream raw PCM on stdout; robot events then use stderr.
289    #[arg(long, value_enum)]
290    stream: Option<StreamMode>,
291
292    /// Parse inputs and run the conservative admission preflight without synthesis.
293    #[arg(long)]
294    check: bool,
295
296    /// Emit the NDJSON event stream even when stdout is a terminal.
297    ///
298    /// Piped, redirected and CI runs already get NDJSON — nothing but a terminal gets the human
299    /// view — so this exists for a person who wants to watch the machine contract directly.
300    #[arg(long)]
301    robot: bool,
302
303    /// Load the model in this process instead of using the resident engine.
304    ///
305    /// By default `ftts say` keeps the loaded model in a background process so the next
306    /// invocation starts without the multi-second load, unloading itself after ten idle
307    /// minutes (FTTS_RESIDENT_IDLE_SECS overrides). This flag, or FTTS_NO_RESIDENT=1,
308    /// opts a run out; results are identical either way.
309    #[arg(long)]
310    no_resident: bool,
311}
312
313#[derive(Debug, clap::Args)]
314struct MakeVideoArgs {
315    /// Text to synthesize. Use `-` to read UTF-8 text from stdin.
316    #[arg(value_name = "TEXT")]
317    text: Option<String>,
318
319    /// Output video. `.mp4` uses the first available system encoder (ffmpeg);
320    /// `.y4m` renders natively with a `.wav` sibling and needs no encoder.
321    #[arg(value_name = "OUTPUT", conflicts_with = "output")]
322    output_positional: Option<PathBuf>,
323
324    /// Read UTF-8 text from PATH. Use `-` for stdin.
325    #[arg(long, value_name = "PATH", conflicts_with = "text")]
326    file: Option<PathBuf>,
327
328    /// Explicit .fttsq model artifact. No network lookup is performed.
329    #[arg(long, value_name = "PATH")]
330    model: Option<PathBuf>,
331
332    /// Voice source: a .spk vector, reference audio, or a built-in voice name
333    /// (matt, james, leo, robert, judy, aria, ember).
334    #[arg(long, value_name = "PATH|NAME")]
335    voice: Option<PathBuf>,
336
337    /// Write the video here. Same as the positional OUTPUT.
338    #[arg(short = 'o', long, value_name = "PATH")]
339    output: Option<PathBuf>,
340
341    /// Skip synthesis and render this existing PCM WAV instead.
342    #[arg(long, value_name = "PATH", conflicts_with_all = ["text", "file"])]
343    audio: Option<PathBuf>,
344
345    /// Voice name shown on the video. Defaults to the voice's name.
346    #[arg(long, value_name = "NAME")]
347    label: Option<String>,
348
349    /// Load the model in this process instead of using the resident engine.
350    #[arg(long)]
351    no_resident: bool,
352}
353
354#[derive(Debug, clap::Args)]
355struct EnrollArgs {
356    /// Reference audio: WAV/FLAC decode natively; m4a/mp3/aac/ogg/opus route through the first
357    /// system decoder found (afconvert on macOS, ffmpeg).
358    #[arg(value_name = "REFERENCE_AUDIO")]
359    reference_audio: PathBuf,
360
361    /// Explicit model directory or .fttsq model artifact. No network lookup is performed.
362    #[arg(long, value_name = "PATH")]
363    model: Option<PathBuf>,
364
365    /// Write the enrolled raw 1,024-wide x-vector here. Refuses to overwrite an existing file.
366    #[arg(short = 'o', long, value_name = "PATH", conflicts_with = "default")]
367    output: Option<PathBuf>,
368
369    /// Write MODEL_DIR/default.spk, which `ftts say` uses when `--voice` is absent.
370    #[arg(long, conflicts_with = "output")]
371    default: bool,
372
373    /// Explicitly proceed after an enrollment-quality warning where safe.
374    #[arg(long)]
375    force: bool,
376
377    /// Replace an existing voice at the destination without asking.
378    ///
379    /// Interactive runs are asked to confirm instead; this is how a script or an agent gives that
380    /// consent up front. The displaced voice is copied to `<name>.spk.bak` either way.
381    #[arg(long)]
382    overwrite: bool,
383
384    /// Remove late reverberation from the reference before enrolling.
385    ///
386    /// A room is convolutive, so `--denoise` cannot touch it; this is the lever for a reference
387    /// that sounds "wet". It matters because the speaker encoder cannot separate voice from room,
388    /// so a reverberant reference enrolls the room as part of the speaker and every utterance the
389    /// clone speaks is rendered in it. Off by default: it changes the enrolled identity.
390    #[arg(long)]
391    dereverb: bool,
392
393    /// Clean stationary noise from the reference before enrolling.
394    ///
395    /// This is the default whenever the neural denoiser's weights are present (`ftts pull`
396    /// fetches them; measured on a static-hiss reference, the cleaned enrollment lands
397    /// closer to a clean-source enrollment than the raw recording does). Passing the flag
398    /// explicitly additionally engages the classic no-weights spectral subtraction when the
399    /// weights are absent, where the automatic path would skip cleanup rather than swap in
400    /// a different engine unannounced.
401    #[arg(long, overrides_with = "no_denoise")]
402    denoise: bool,
403
404    /// Enroll the recording exactly as given, with no noise cleanup.
405    #[arg(long, overrides_with = "denoise")]
406    no_denoise: bool,
407}
408
409#[derive(Debug, clap::Args)]
410struct VoiceArgs {
411    #[command(subcommand)]
412    command: VoiceCommand,
413}
414
415#[derive(Debug, Subcommand)]
416enum VoiceCommand {
417    /// Inspect a .ftvoice header without synthesizing.
418    Inspect { path: PathBuf },
419}
420
421#[derive(Debug, clap::Args)]
422struct ConvertArgs {
423    /// Pinned source-weight directory or file.
424    #[arg(value_name = "SOURCE")]
425    source: PathBuf,
426
427    /// Destination .fttsq path. Refuses to overwrite an existing artifact.
428    #[arg(short = 'o', long, value_name = "PATH")]
429    output: PathBuf,
430}
431
432#[derive(Debug, clap::Args)]
433struct PullArgs {
434    /// Destination model directory. Defaults to FTTS_MODEL_DIR, then ~/.cache/franken_tts/model.
435    #[arg(long, value_name = "PATH")]
436    model: Option<PathBuf>,
437
438    /// Re-download every file even when it is already present and verified.
439    #[arg(long)]
440    force: bool,
441}
442
443#[derive(Debug, clap::Args)]
444struct RobotArgs {
445    #[command(subcommand)]
446    command: RobotCommand,
447}
448
449#[derive(Clone, Debug, Subcommand)]
450enum RobotCommand {
451    /// Print the versioned NDJSON event schema.
452    Schema,
453    /// Print a versioned machine-readable readiness event.
454    Health,
455    /// Print available backend routes without probing model weights.
456    Backends,
457    /// Print the self-test state; no unavailable kernel is reported as passing.
458    Selftest,
459}
460
461#[derive(Debug, clap::Args)]
462struct DoctorArgs {
463    /// Emit one JSON object on stdout instead of a human-readable report.
464    #[arg(long)]
465    json: bool,
466}
467
468#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
469enum ExecutionProfile {
470    Interactive,
471    Balanced,
472    Throughput,
473    Strict,
474}
475
476impl ExecutionProfile {
477    const fn as_str(self) -> &'static str {
478        match self {
479            Self::Interactive => "interactive",
480            Self::Balanced => "balanced",
481            Self::Throughput => "throughput",
482            Self::Strict => "strict",
483        }
484    }
485}
486
487#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
488enum PacketFrames {
489    #[value(name = "1")]
490    One,
491    #[value(name = "2")]
492    Two,
493    #[value(name = "4")]
494    Four,
495    Auto,
496}
497
498impl PacketFrames {
499    const fn as_str(self) -> &'static str {
500        match self {
501            Self::One => "1",
502            Self::Two => "2",
503            Self::Four => "4",
504            Self::Auto => "auto",
505        }
506    }
507
508    /// Codec frames carried by one PCM packet.
509    ///
510    /// `auto` resolves to 4 here. The autotuner that will choose it per machine is
511    /// `frankentts-k-packet-tuning-28u`; until it exists, `auto` means "the balanced default"
512    /// rather than a number this call site invented on the spot.
513    const fn frames_per_packet(self) -> u8 {
514        match self {
515            Self::One => 1,
516            Self::Two => 2,
517            Self::Four | Self::Auto => 4,
518        }
519    }
520
521    /// Samples in one packet: frames times the codec's 1,920 samples per 80 ms frame.
522    const fn samples_per_packet(self) -> usize {
523        self.frames_per_packet() as usize * ftts_core::audio::SAMPLES_PER_FRAME
524    }
525
526    const fn default_for(profile: ExecutionProfile) -> Self {
527        match profile {
528            ExecutionProfile::Interactive => Self::One,
529            ExecutionProfile::Balanced => Self::Four,
530            ExecutionProfile::Throughput => Self::Auto,
531            ExecutionProfile::Strict => Self::Four,
532        }
533    }
534}
535
536#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
537enum MathMode {
538    Strict,
539    Fast,
540}
541
542impl MathMode {
543    const fn as_str(self) -> &'static str {
544        match self {
545            Self::Strict => "strict",
546            Self::Fast => "fast",
547        }
548    }
549}
550
551#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
552enum VoicePackProfile {
553    Portable,
554    Private,
555    Minimal,
556}
557
558impl VoicePackProfile {
559    const fn as_str(self) -> &'static str {
560        match self {
561            Self::Portable => "portable",
562            Self::Private => "private",
563            Self::Minimal => "minimal",
564        }
565    }
566}
567
568#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
569enum NormalizeMode {
570    Verbatim,
571    Conservative,
572    LocaleAware,
573}
574
575impl NormalizeMode {
576    const fn as_str(self) -> &'static str {
577        match self {
578            Self::Verbatim => "verbatim",
579            Self::Conservative => "conservative",
580            Self::LocaleAware => "locale-aware",
581        }
582    }
583}
584
585impl From<NormalizeMode> for NormalizationMode {
586    fn from(mode: NormalizeMode) -> Self {
587        match mode {
588            NormalizeMode::Verbatim => Self::Verbatim,
589            NormalizeMode::Conservative => Self::Conservative,
590            NormalizeMode::LocaleAware => Self::LocaleAware,
591        }
592    }
593}
594
595#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
596enum StreamMode {
597    Raw,
598}
599
600#[derive(Debug, Default)]
601struct Environment {
602    values: BTreeMap<&'static str, Option<OsString>>,
603    stage_budget_values: BTreeMap<OsString, OsString>,
604}
605
606impl Environment {
607    fn from_process() -> Self {
608        let values = ENVIRONMENT_VARIABLES
609            .into_iter()
610            .map(|name| (name, std::env::var_os(name)))
611            .collect();
612        let stage_budget_values = std::env::vars_os()
613            .filter(|(name, _)| {
614                name.to_str().is_some_and(|name| {
615                    name.starts_with("FTTS_STAGE_BUDGET_") && name.ends_with("_MS")
616                })
617            })
618            .collect();
619        Self {
620            values,
621            stage_budget_values,
622        }
623    }
624
625    fn value(&self, name: &'static str) -> Option<&str> {
626        self.values.get(name)?.as_deref()?.to_str()
627    }
628
629    fn documented_values(&self) -> BTreeMap<String, Option<String>> {
630        let mut values = self
631            .values
632            .iter()
633            .map(|(name, value)| {
634                (
635                    (*name).to_owned(),
636                    value
637                        .as_ref()
638                        .map(|value| value.to_string_lossy().into_owned()),
639                )
640            })
641            .collect::<BTreeMap<_, _>>();
642        values.insert("FTTS_STAGE_BUDGET_*_MS".to_owned(), None);
643        values.extend(self.stage_budget_values.iter().map(|(name, value)| {
644            (
645                name.to_string_lossy().into_owned(),
646                Some(value.to_string_lossy().into_owned()),
647            )
648        }));
649        values
650    }
651}
652
653fn environment() -> &'static Environment {
654    static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
655    ENVIRONMENT.get_or_init(Environment::from_process)
656}
657
658#[derive(Debug)]
659struct EffectiveSettings {
660    profile: ExecutionProfile,
661    packet_frames: PacketFrames,
662    math_mode: MathMode,
663    voice_pack: VoicePackProfile,
664    normalize: NormalizeMode,
665}
666
667impl EffectiveSettings {
668    fn resolve(cli: &Cli, environment: &Environment) -> Result<Self, FttsError> {
669        let profile = cli
670            .profile
671            .or(parse_env_value(
672                environment.value("FTTS_PROFILE"),
673                "FTTS_PROFILE",
674                ExecutionProfile::value_variants(),
675            )?)
676            .unwrap_or(ExecutionProfile::Balanced);
677        let packet_frames = cli
678            .packet_frames
679            .or(parse_env_value(
680                environment.value("FTTS_PACKET_FRAMES"),
681                "FTTS_PACKET_FRAMES",
682                PacketFrames::value_variants(),
683            )?)
684            .unwrap_or_else(|| PacketFrames::default_for(profile));
685        let math_mode = cli
686            .math_mode
687            .or(parse_env_value(
688                environment.value("FTTS_MATH_MODE"),
689                "FTTS_MATH_MODE",
690                MathMode::value_variants(),
691            )?)
692            .unwrap_or(MathMode::Fast);
693        let voice_pack = cli.voice_pack.unwrap_or(VoicePackProfile::Portable);
694        let normalize = cli.normalize.unwrap_or(NormalizeMode::Verbatim);
695        Ok(Self {
696            profile,
697            packet_frames,
698            math_mode,
699            voice_pack,
700            normalize,
701        })
702    }
703
704    fn normalization_options(&self) -> NormalizationOptions {
705        NormalizationOptions {
706            mode: self.normalize.into(),
707            ..NormalizationOptions::default()
708        }
709    }
710}
711
712fn parse_env_value<T>(
713    value: Option<&str>,
714    name: &str,
715    variants: &'static [T],
716) -> Result<Option<T>, FttsError>
717where
718    T: ValueEnum + Copy,
719{
720    match value {
721        None => Ok(None),
722        Some(value) => T::from_str(value, true).map(Some).map_err(|_| {
723            let choices = variants
724                .iter()
725                .filter_map(|variant| variant.to_possible_value())
726                .map(|variant| variant.get_name().to_owned())
727                .collect::<Vec<_>>()
728                .join(", ");
729            FttsError::Usage(format!("invalid {name}={value:?}; use one of: {choices}"))
730        }),
731    }
732}
733
734fn dispatch(
735    cli: Cli,
736    environment: &Environment,
737    stdin: &mut dyn Read,
738    stdout: &mut dyn Write,
739    stderr: &mut dyn Write,
740) -> Result<(), FttsError> {
741    match &cli.command {
742        Command::Say(args) => run_say(&cli, args, environment, stdin, stdout, stderr),
743        Command::MakeVideo(args) => run_make_video(&cli, args, environment, stdin, stdout, stderr),
744        Command::Enroll(args) => run_enroll(args, environment, stdout),
745        Command::Voice(VoiceArgs {
746            command: VoiceCommand::Inspect { path },
747        }) => run_voice_inspect(path, stdout),
748        Command::Convert(args) => run_convert(&cli, args, environment, stdout, stderr),
749        Command::Pull(args) => run_pull(args, environment, stdout),
750        Command::Robot(args) => run_robot(args.command.clone(), environment, stdout),
751        Command::Doctor(args) => run_doctor(args, environment, stdout),
752        Command::ResidentDaemon(args) => resident::run_daemon(&args.bundle_root),
753    }
754}
755
756/// A source tensor pinned by the truth-pack inventory and its reviewed storage policy.
757#[derive(Clone, Debug)]
758struct PinnedMainTensor {
759    name: String,
760    dtype: Dtype,
761    shape: Vec<usize>,
762    access_class: AccessClass,
763    storage: TensorStoragePolicy,
764}
765
766fn run_convert(
767    cli: &Cli,
768    args: &ConvertArgs,
769    environment: &Environment,
770    stdout: &mut dyn Write,
771    stderr: &mut dyn Write,
772) -> Result<(), FttsError> {
773    let run = robot::RunContext::generate();
774    let outcome = run_convert_events(cli, args, environment, &run, &mut |event| {
775        write_json_line(stdout, event)
776    });
777
778    if let Err(error) = &outcome {
779        let mut event = run.event(robot::EventType::RunError);
780        event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
781        event.insert("kind".to_owned(), json!(error.exit_code().description()));
782        event.insert("message".to_owned(), json!(error.to_string()));
783        event.insert("remediation".to_owned(), json!(error.remediation()));
784        event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
785        write_json_line(stderr, &Value::Object(event))?;
786    }
787
788    outcome
789}
790
791/// Converts the pinned main checkpoint and emits the normal run lifecycle receipt.
792///
793/// The source mapping owns no writable state. The destination is first created under a unique
794/// sibling name with `create_new`, then atomically renamed only after the streaming writer, an
795/// `fsync`, and a digest-validating mapped re-read all succeed. We deliberately leave a failed
796/// staging file in place for diagnosis rather than deleting data behind the caller's back.
797fn run_convert_events(
798    cli: &Cli,
799    args: &ConvertArgs,
800    environment: &Environment,
801    run: &robot::RunContext,
802    emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
803) -> Result<(), FttsError> {
804    let settings = EffectiveSettings::resolve(cli, environment)?;
805    let mut start = run.event(robot::EventType::RunStart);
806    start.insert("command".to_owned(), json!("convert"));
807    start.insert("profile".to_owned(), json!(settings.profile.as_str()));
808    start.insert(
809        "packet_frames".to_owned(),
810        json!(settings.packet_frames.as_str()),
811    );
812    start.insert("math_mode".to_owned(), json!(settings.math_mode.as_str()));
813    start.insert("stateless".to_owned(), json!(true));
814    start.insert("seed".to_owned(), json!(cli.seed));
815    start.insert("model".to_owned(), Value::Null);
816    start.insert("voice".to_owned(), Value::Null);
817    emit(&Value::Object(start))?;
818
819    let mut seq = 0_u64;
820    emit_stage(run, emit, "source_preflight", "begin", &mut seq)?;
821    let source = resolve_pinned_main_source(&args.source)?;
822    let mapping = MappedFile::open(&source).map_err(|error| {
823        FttsError::Input(format!(
824            "cannot memory-map pinned source checkpoint {}: {error}",
825            source.display()
826        ))
827    })?;
828    let (manifest, plan) = pinned_main_conversion_plan()?;
829    let staging = conversion_staging_path(&args.output)?;
830    emit_stage(run, emit, "source_preflight", "end", &mut seq)?;
831
832    emit_stage(run, emit, "convert", "begin", &mut seq)?;
833    let destination = std::fs::File::options()
834        .write(true)
835        .create_new(true)
836        .open(&staging)
837        .map_err(|error| {
838            FttsError::Input(format!(
839                "cannot create conversion staging artifact {}: {error}; the output path is never overwritten",
840                staging.display()
841            ))
842        })?;
843    let destination = convert_safetensors_streaming(
844        mapping.as_slice(),
845        &manifest,
846        &plan,
847        destination,
848    )
849    .map_err(|error| {
850        FttsError::ArtifactFormat(format!(
851            "conversion failed before publication: {error}; staging artifact retained at {}",
852            staging.display()
853        ))
854    })?;
855    destination.sync_all().map_err(|error| {
856        FttsError::ArtifactFormat(format!(
857            "cannot sync converted artifact at {}: {error}; staging artifact retained",
858            staging.display()
859        ))
860    })?;
861    drop(destination);
862    emit_stage(run, emit, "convert", "end", &mut seq)?;
863
864    emit_stage(run, emit, "verify", "begin", &mut seq)?;
865    let verified = MappedFttsq::open(&staging).map_err(|error| {
866        FttsError::ArtifactFormat(format!(
867            "converted staging artifact did not pass digest re-read: {error}; retained at {}",
868            staging.display()
869        ))
870    })?;
871    if verified.reader().source_sha256() != PINNED_MAIN_WEIGHTS_SHA256 {
872        return Err(FttsError::ArtifactFormat(format!(
873            "converted staging artifact recorded an unexpected source digest {}; retained at {}",
874            verified.reader().source_sha256(),
875            staging.display()
876        )));
877    }
878    drop(verified);
879    std::fs::rename(&staging, &args.output).map_err(|error| {
880        FttsError::ArtifactFormat(format!(
881            "converted artifact verified but could not be published from {} to {}: {error}; staging artifact retained",
882            staging.display(),
883            args.output.display()
884        ))
885    })?;
886    emit_stage(run, emit, "verify", "end", &mut seq)?;
887
888    let mut complete = run.event(robot::EventType::RunComplete);
889    complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
890    complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
891    complete.insert("frames".to_owned(), json!(0));
892    complete.insert("audio_bytes".to_owned(), json!(0));
893    emit(&Value::Object(complete))
894}
895
896fn resolve_pinned_main_source(source: &Path) -> Result<PathBuf, FttsError> {
897    let source = if source.is_dir() {
898        source.join(PINNED_MAIN_WEIGHTS_FILENAME)
899    } else {
900        source.to_owned()
901    };
902    if !source.is_file() {
903        return Err(FttsError::Input(format!(
904            "pinned main checkpoint {} does not exist or is not a file; pass model.safetensors or its containing directory",
905            source.display()
906        )));
907    }
908    if source.file_name().and_then(|name| name.to_str()) != Some(PINNED_MAIN_WEIGHTS_FILENAME) {
909        return Err(FttsError::Input(format!(
910            "this converter accepts the pinned main checkpoint named {PINNED_MAIN_WEIGHTS_FILENAME}, not {}",
911            source.display()
912        )));
913    }
914    Ok(source)
915}
916
917fn conversion_staging_path(output: &Path) -> Result<PathBuf, FttsError> {
918    if output.exists() {
919        return Err(FttsError::Input(format!(
920            "refusing to overwrite existing output {}; choose a new -o path",
921            output.display()
922        )));
923    }
924    let parent = output.parent().unwrap_or_else(|| Path::new("."));
925    let file_name = output
926        .file_name()
927        .and_then(|name| name.to_str())
928        .ok_or_else(|| {
929            FttsError::Usage("conversion output must name a file, not a directory".to_owned())
930        })?;
931    let nonce = std::time::SystemTime::now()
932        .duration_since(std::time::UNIX_EPOCH)
933        .map_err(|error| FttsError::Generic(format!("system clock is before UNIX_EPOCH: {error}")))?
934        .as_nanos();
935    let staging = parent.join(format!(
936        ".{file_name}.fttsq-converting-{}-{nonce}",
937        std::process::id()
938    ));
939    if staging.exists() {
940        return Err(FttsError::Input(format!(
941            "conversion staging path already exists {}; inspect or move it before retrying",
942            staging.display()
943        )));
944    }
945    Ok(staging)
946}
947
948fn pinned_main_conversion_plan() -> Result<(WeightsManifest, StreamingConversionPlan), FttsError> {
949    let specs = pinned_main_tensor_specs()?;
950    let manifest = WeightsManifest::from_expectations(
951        "Qwen/Qwen3-TTS-12Hz-0.6B-Base main checkpoint",
952        specs
953            .iter()
954            .map(|spec| ExpectedTensor::new(&spec.name, spec.shape.clone(), spec.dtype)),
955    );
956    let model_config = serde_json::from_str(PINNED_MODEL_CONFIG).map_err(|error| {
957        FttsError::Generic(format!(
958            "checked-in pinned model config is invalid JSON: {error}"
959        ))
960    })?;
961    let q8_count = specs
962        .iter()
963        .filter(|spec| spec.storage == TensorStoragePolicy::Q8PerOutputChannel)
964        .count();
965    let mut plan = StreamingConversionPlan::new(
966        "qwen3-tts-12hz-0.6b-base",
967        PINNED_MAIN_WEIGHTS_SHA256,
968    )
969    .license_notice(pinned_license_notice())
970    .model_config(model_config)
971    .quantization_manifest(json!({
972        "source": {
973            "repository": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
974            "revision": PINNED_MODEL_REVISION,
975            "file": PINNED_MAIN_WEIGHTS_FILENAME,
976            "sha256": PINNED_MAIN_WEIGHTS_SHA256,
977        },
978        "q8_recipe": "symmetric per-output-channel int8; zero_point=0; scale=max_abs(row)/127",
979        "q8_tensor_count": q8_count,
980        "verbatim_tensor_count": specs.len() - q8_count,
981        "q8_scope": "talker and residual-code-microdecoder attention/MLP projection matrices only",
982        "verbatim_scope": "norms, heads, embeddings, speaker path, and every tensor outside the reviewed Q8 projection set",
983    }));
984    for spec in specs {
985        let conversion = match spec.storage {
986            TensorStoragePolicy::Verbatim => {
987                TensorConversion::verbatim(&spec.name, &spec.name, spec.access_class)
988            }
989            TensorStoragePolicy::Q8PerOutputChannel => {
990                TensorConversion::q8_per_output_channel(&spec.name, &spec.name, spec.access_class)
991            }
992        };
993        plan = plan.tensor(conversion);
994    }
995    Ok((manifest, plan))
996}
997
998fn pinned_main_tensor_specs() -> Result<Vec<PinnedMainTensor>, FttsError> {
999    let inventory: Value = serde_json::from_str(PINNED_TENSOR_INVENTORY).map_err(|error| {
1000        FttsError::Generic(format!(
1001            "checked-in tensor inventory is invalid JSON: {error}"
1002        ))
1003    })?;
1004    if inventory.get("source_pin").and_then(Value::as_str)
1005        != Some(&format!(
1006            "Qwen/Qwen3-TTS-12Hz-0.6B-Base@{PINNED_MODEL_REVISION}"
1007        ))
1008    {
1009        return Err(FttsError::Generic(
1010            "checked-in tensor inventory does not name the pinned Qwen3-TTS revision".to_owned(),
1011        ));
1012    }
1013    let records = inventory
1014        .get("tensors")
1015        .and_then(Value::as_array)
1016        .ok_or_else(|| {
1017            FttsError::Generic("checked-in tensor inventory lacks tensors[]".to_owned())
1018        })?;
1019    let mut specs = Vec::new();
1020    for record in records {
1021        if record.get("source").and_then(Value::as_str) != Some(PINNED_MAIN_WEIGHTS_FILENAME) {
1022            continue;
1023        }
1024        let name = required_inventory_string(record, "name")?.to_owned();
1025        let dtype = match required_inventory_string(record, "dtype")? {
1026            "BF16" => Dtype::Bf16,
1027            "F32" => Dtype::F32,
1028            other => {
1029                return Err(FttsError::Generic(format!(
1030                    "pinned main inventory has unsupported dtype {other:?} for {name}"
1031                )));
1032            }
1033        };
1034        let shape = record
1035            .get("shape")
1036            .and_then(Value::as_array)
1037            .ok_or_else(|| {
1038                FttsError::Generic(format!("pinned inventory tensor {name} lacks shape[]"))
1039            })?
1040            .iter()
1041            .map(|dimension| {
1042                dimension
1043                    .as_u64()
1044                    .and_then(|dimension| usize::try_from(dimension).ok())
1045                    .ok_or_else(|| {
1046                        FttsError::Generic(format!(
1047                            "pinned inventory tensor {name} has a non-usize shape dimension"
1048                        ))
1049                    })
1050            })
1051            .collect::<Result<Vec<_>, _>>()?;
1052        let storage = if is_q8_projection(&name) {
1053            TensorStoragePolicy::Q8PerOutputChannel
1054        } else {
1055            TensorStoragePolicy::Verbatim
1056        };
1057        specs.push(PinnedMainTensor {
1058            access_class: main_access_class(&name)?,
1059            name,
1060            dtype,
1061            shape,
1062            storage,
1063        });
1064    }
1065    if specs.len() != PINNED_MAIN_TENSOR_COUNT {
1066        return Err(FttsError::Generic(format!(
1067            "pinned main inventory contains {} tensors, expected {PINNED_MAIN_TENSOR_COUNT}",
1068            specs.len()
1069        )));
1070    }
1071    Ok(specs)
1072}
1073
1074fn required_inventory_string<'a>(record: &'a Value, field: &str) -> Result<&'a str, FttsError> {
1075    record.get(field).and_then(Value::as_str).ok_or_else(|| {
1076        FttsError::Generic(format!(
1077            "checked-in tensor inventory record lacks string {field:?}"
1078        ))
1079    })
1080}
1081
1082fn is_q8_projection(name: &str) -> bool {
1083    (name.starts_with("talker.model.layers.")
1084        || name.starts_with("talker.code_predictor.model.layers."))
1085        && [
1086            ".self_attn.q_proj.weight",
1087            ".self_attn.k_proj.weight",
1088            ".self_attn.v_proj.weight",
1089            ".self_attn.o_proj.weight",
1090            ".mlp.gate_proj.weight",
1091            ".mlp.up_proj.weight",
1092            ".mlp.down_proj.weight",
1093        ]
1094        .iter()
1095        .any(|suffix| name.ends_with(suffix))
1096}
1097
1098fn main_access_class(name: &str) -> Result<AccessClass, FttsError> {
1099    if name == "talker.model.text_embedding.weight" {
1100        Ok(AccessClass::ColdTextEmbedding)
1101    } else if name.starts_with("speaker_encoder.") {
1102        Ok(AccessClass::EnrollmentSpeakerEncoder)
1103    } else if name.starts_with("talker.code_predictor.")
1104        || name == "talker.model.codec_embedding.weight"
1105    {
1106        Ok(AccessClass::HotRecurrentMicrodecoder)
1107    } else if name.starts_with("talker.model.")
1108        || name.starts_with("talker.codec_head.")
1109        || name.starts_with("talker.text_projection.")
1110    {
1111        Ok(AccessClass::HotRecurrentTalker)
1112    } else {
1113        Err(FttsError::Generic(format!(
1114            "pinned main tensor {name} has no reviewed access-class assignment"
1115        )))
1116    }
1117}
1118
1119fn pinned_license_notice() -> String {
1120    format!(
1121        "This artifact contains model weights derived from\n\
1122         Qwen3-TTS-12Hz-0.6B-Base (https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-Base)\n\
1123         and code derived from QwenLM/Qwen3-TTS (https://github.com/QwenLM/Qwen3-TTS).\n\n\
1124         Copyright 2026 Alibaba Cloud\n\n\
1125         Licensed under the Apache License, Version 2.0.\n\
1126         http://www.apache.org/licenses/LICENSE-2.0\n\n\
1127         CHANGES: the original bfloat16 weights were converted to franken_tts's\n\
1128         quantized .fttsq container. Tensors were requantized according to the\n\
1129         artifact's quantization manifest; protected tensors remain verbatim.\n\
1130         The model graph is re-implemented in Rust.\n\n\
1131         Apache License, Version 2.0:\n\n{APACHE_LICENSE}"
1132    )
1133}
1134
1135fn run_say(
1136    cli: &Cli,
1137    args: &SayArgs,
1138    environment: &Environment,
1139    stdin: &mut dyn Read,
1140    stdout: &mut dyn Write,
1141    stderr: &mut dyn Write,
1142) -> Result<(), FttsError> {
1143    let run = robot::RunContext::generate();
1144    // `--stream raw` puts PCM on stdout, so events move to stderr. One contract, chosen once here
1145    // so no later emission can pick the other stream and interleave NDJSON with audio bytes. The
1146    // two branches also settle the borrows: in raw mode audio owns `stdout` and events own
1147    // `stderr`; otherwise events own `stdout` and the raw sink is a discard that never runs.
1148    let outcome = if args.stream == Some(StreamMode::Raw) {
1149        run_say_events(cli, args, environment, stdin, &run, stdout, &mut |event| {
1150            write_json_line(stderr, event)
1151        })
1152    } else if args.robot || !style::is_interactive() {
1153        let mut discard = io::sink();
1154        run_say_events(
1155            cli,
1156            args,
1157            environment,
1158            stdin,
1159            &run,
1160            &mut discard,
1161            &mut |event| write_json_line(stdout, event),
1162        )
1163    } else {
1164        // A terminal gets the human view of the same lifecycle. The NDJSON contract is untouched:
1165        // it is what every pipe, file, CI job and agent still receives, because none of them is a
1166        // terminal. `--robot` forces it back on for a human debugging the stream itself.
1167        let mut discard = io::sink();
1168        let destination = args
1169            .output
1170            .as_deref()
1171            .or(args.output_positional.as_deref())
1172            .map(|path| path.display().to_string());
1173        let mut presenter = style::SayPresenter::writing_to(destination);
1174        run_say_events(
1175            cli,
1176            args,
1177            environment,
1178            stdin,
1179            &run,
1180            &mut discard,
1181            &mut |event| {
1182                presenter
1183                    .event(event, stdout)
1184                    .map_err(|error| FttsError::Generic(format!("cannot write progress: {error}")))
1185            },
1186        )
1187    };
1188
1189    if let Err(error) = &outcome {
1190        // The error is reported on the machine contract, not only as a human line: run_error is a
1191        // stderr event by definition (see the catalogue), so it goes to stderr on both stream
1192        // shapes.
1193        let mut event = run.event(robot::EventType::RunError);
1194        event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
1195        event.insert("kind".to_owned(), json!(error.exit_code().description()));
1196        event.insert("message".to_owned(), json!(error.to_string()));
1197        event.insert("remediation".to_owned(), json!(error.remediation()));
1198        event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1199        write_json_line(stderr, &Value::Object(event))?;
1200    }
1201
1202    outcome
1203}
1204
1205/// `ftts make-video`: synthesize (or take a WAV) and render the branded
1206/// share video. Frames, waveform, and text are pure Rust (`ftts-video`);
1207/// `.mp4` goes through the same first-available-system-encoder contract as
1208/// `ftts say`'s `.m4a` path, and `.y4m` + `.wav` is the native no-encoder
1209/// output.
1210fn run_make_video(
1211    cli: &Cli,
1212    args: &MakeVideoArgs,
1213    environment: &Environment,
1214    stdin: &mut dyn Read,
1215    stdout: &mut dyn Write,
1216    stderr: &mut dyn Write,
1217) -> Result<(), FttsError> {
1218    let output = args
1219        .output
1220        .clone()
1221        .or_else(|| args.output_positional.clone())
1222        .ok_or_else(|| {
1223            FttsError::Usage(
1224                "`ftts make-video` needs an output path (`ftts make-video \"text\" out.mp4`)"
1225                    .to_owned(),
1226            )
1227        })?;
1228    let extension = output
1229        .extension()
1230        .and_then(|extension| extension.to_str())
1231        .map(str::to_ascii_lowercase);
1232    let is_mp4 = match extension.as_deref() {
1233        Some("mp4") => true,
1234        Some("y4m") => false,
1235        other => {
1236            return Err(FttsError::Usage(format!(
1237                "unsupported video extension `.{}`; use .mp4 (system encoder) or .y4m (native)",
1238                other.unwrap_or("<none>")
1239            )));
1240        }
1241    };
1242
1243    // The voice pill needs a human name: an explicit --label wins, a preset
1244    // keeps its capitalized name, a custom voice shows its file stem. With
1245    // no voice given, the label follows the same default chain `say` uses
1246    // (FTTS_DEFAULT_VOICE, then an enrolled default.spk, then built-in matt)
1247    // rather than claiming "Matt" over someone's enrolled voice.
1248    let capitalize = |raw: &str| {
1249        let mut chars = raw.chars();
1250        chars
1251            .next()
1252            .map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
1253            .unwrap_or_else(|| raw.to_owned())
1254    };
1255    let stem_of = |path: &Path| {
1256        path.file_stem()
1257            .and_then(|stem| stem.to_str())
1258            .map(str::to_owned)
1259    };
1260    let label = args.label.clone().unwrap_or_else(|| {
1261        if let Some(voice) = &args.voice {
1262            return stem_of(voice)
1263                .map(|stem| capitalize(&stem))
1264                .unwrap_or_else(|| "Voice".to_owned());
1265        }
1266        if let Some(audio) = &args.audio {
1267            // Rendering someone's own recording: name it after the file.
1268            return stem_of(audio)
1269                .map(|stem| capitalize(&stem))
1270                .unwrap_or_else(|| "Voice".to_owned());
1271        }
1272        if let Some(default_voice) = environment.value("FTTS_DEFAULT_VOICE")
1273            && let Some(stem) = stem_of(Path::new(default_voice))
1274        {
1275            return capitalize(&stem);
1276        }
1277        let enrolled_default = resolve_model(args.model.as_deref(), environment)
1278            .ok()
1279            .and_then(|model| synth::ModelBundle::resolve(Path::new(&model)).ok())
1280            .is_some_and(|bundle| bundle.root.join("default.spk").is_file());
1281        if enrolled_default {
1282            "My voice".to_owned()
1283        } else {
1284            "Matt".to_owned()
1285        }
1286    });
1287
1288    // Audio: either supplied, or synthesized through the full `say` pipeline
1289    // (same events, presenter, and resident engine). An mp4 gets a staging
1290    // WAV that is consumed by the encoder; a y4m synthesizes straight into
1291    // its `.wav` sibling, which stays as the video's audio track.
1292    let staging: Option<PathBuf> = if args.audio.is_none() {
1293        if is_mp4 {
1294            let mut path = output.as_os_str().to_owned();
1295            path.push(".ftts-staging.wav");
1296            Some(PathBuf::from(path))
1297        } else {
1298            Some(output.with_extension("wav"))
1299        }
1300    } else {
1301        None
1302    };
1303    if let Some(staging_path) = &staging {
1304        let say_args = SayArgs {
1305            text: args.text.clone(),
1306            output_positional: None,
1307            file: args.file.clone(),
1308            model: args.model.clone(),
1309            voice: args.voice.clone(),
1310            output: Some(staging_path.clone()),
1311            stream: None,
1312            check: false,
1313            robot: false,
1314            no_resident: args.no_resident,
1315        };
1316        run_say(cli, &say_args, environment, stdin, stdout, stderr)?;
1317    }
1318    let audio_path = args
1319        .audio
1320        .clone()
1321        .or_else(|| staging.clone())
1322        .unwrap_or_default();
1323
1324    let interactive = style::is_interactive();
1325    if interactive {
1326        writeln!(stdout, "rendering video: {}", output.display())
1327            .map_err(|error| FttsError::Generic(format!("cannot write progress: {error}")))?;
1328    }
1329    let request = ftts_video::VideoRequest {
1330        audio: &audio_path,
1331        output: &output,
1332        voice_label: &label,
1333    };
1334    let mut last_percent = 0usize;
1335    let render_result = ftts_video::render(&request, &mut |progress| {
1336        if !interactive {
1337            return;
1338        }
1339        let percent = progress.frame * 100 / progress.total_frames;
1340        if percent >= last_percent + 10 || progress.frame == progress.total_frames {
1341            last_percent = percent;
1342            let _ = write!(
1343                stdout,
1344                "\r  frame {}/{} ({percent}%)",
1345                progress.frame, progress.total_frames
1346            );
1347            let _ = stdout.flush();
1348        }
1349    });
1350    if interactive {
1351        let _ = writeln!(stdout);
1352    }
1353    render_result.map_err(FttsError::Generic)?;
1354
1355    // The staging WAV is consumed into the mp4; remove it exactly as the
1356    // `.m4a` OutputPlan removes its staging file. The `.y4m` path keeps its
1357    // audio, renamed to the output's `.wav` sibling by the renderer.
1358    if is_mp4 && let Some(staging_path) = &staging {
1359        let _ = fs::remove_file(staging_path);
1360    }
1361    if interactive {
1362        writeln!(stdout, "wrote {}", output.display())
1363            .map_err(|error| FttsError::Generic(format!("cannot write result: {error}")))?;
1364    }
1365    Ok(())
1366}
1367
1368/// Emit one `stage` event and advance the run's stage counter.
1369fn emit_stage(
1370    run: &robot::RunContext,
1371    emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
1372    name: &str,
1373    state: &str,
1374    seq: &mut u64,
1375) -> Result<(), FttsError> {
1376    let mut event = run.event(robot::EventType::Stage);
1377    event.insert("name".to_owned(), json!(name));
1378    event.insert("seq".to_owned(), json!(*seq));
1379    event.insert("state".to_owned(), json!(state));
1380    event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1381    event.insert("budget_ms".to_owned(), Value::Null);
1382    *seq += 1;
1383    emit(&Value::Object(event))
1384}
1385
1386/// The `say` pipeline proper, emitting its lifecycle through `emit`.
1387///
1388/// Split out so the caller owns stream selection and the single `run_error` emission point: a
1389/// pipeline that emitted its own errors would have to know which stream it was on at every `?`.
1390fn run_say_events(
1391    cli: &Cli,
1392    args: &SayArgs,
1393    environment: &Environment,
1394    stdin: &mut dyn Read,
1395    run: &robot::RunContext,
1396    raw_audio: &mut dyn Write,
1397    emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
1398) -> Result<(), FttsError> {
1399    let settings = EffectiveSettings::resolve(cli, environment)?;
1400
1401    let mut start = run.event(robot::EventType::RunStart);
1402    start.insert("command".to_owned(), json!("say"));
1403    start.insert("profile".to_owned(), json!(settings.profile.as_str()));
1404    start.insert(
1405        "packet_frames".to_owned(),
1406        json!(settings.packet_frames.as_str()),
1407    );
1408    start.insert("math_mode".to_owned(), json!(settings.math_mode.as_str()));
1409    start.insert("stateless".to_owned(), json!(true));
1410    start.insert("seed".to_owned(), json!(cli.seed));
1411    start.insert("model".to_owned(), json!(args.model.as_deref()));
1412    start.insert(
1413        "voice".to_owned(),
1414        json!(args.voice.as_ref().map(|path| path.display().to_string())),
1415    );
1416    emit(&Value::Object(start))?;
1417
1418    let mut seq = 0u64;
1419
1420    emit_stage(run, emit, "resolve", "begin", &mut seq)?;
1421    let text = read_text(args, stdin)?;
1422    let model = resolve_model(args.model.as_deref(), environment)?;
1423    let voice = resolve_requested_voice(args.voice.as_deref(), environment)?;
1424    emit_stage(run, emit, "resolve", "end", &mut seq)?;
1425
1426    let request = SynthesisRequest::new(text)
1427        .with_normalization_options(settings.normalization_options())
1428        .with_normalization_trace(cli.trace.is_some());
1429
1430    // Privacy-safe by construction: shape and rule names only, never the text itself. The CLI
1431    // promises no persisted synthesis history, and an event stream an agent may log is exactly
1432    // where that promise would leak if this carried the input.
1433    let mut prepared = run.event(robot::EventType::TextPrepared);
1434    prepared.insert("normalize".to_owned(), json!(settings.normalize.as_str()));
1435    prepared.insert(
1436        "unicode_version".to_owned(),
1437        json!(ftts_model_qwen::tokenizer::unicode_version()),
1438    );
1439    prepared.insert("char_count".to_owned(), json!(request.text.chars().count()));
1440    prepared.insert(
1441        "trace_requested".to_owned(),
1442        json!(request.trace_normalization),
1443    );
1444    emit(&Value::Object(prepared))?;
1445
1446    // `-o PATH` and the positional OUTPUT are the same request; clap rejects supplying both.
1447    let requested_output: Option<PathBuf> = args
1448        .output
1449        .clone()
1450        .or_else(|| args.output_positional.clone());
1451    let output_plan = requested_output
1452        .as_deref()
1453        .map(OutputPlan::for_path)
1454        .transpose()?;
1455
1456    emit_stage(run, emit, "admission", "begin", &mut seq)?;
1457    let admission = admission_plan(&request.text, &settings)?;
1458    emit_stage(run, emit, "admission", "end", &mut seq)?;
1459
1460    if args.check {
1461        let event = json!({
1462            "schema_version": ROBOT_SCHEMA_VERSION,
1463            "event": "check_complete",
1464            "run_id": run.run_id(),
1465            "model": model,
1466            "voice": voice,
1467            "profile": settings.profile.as_str(),
1468            "packet_frames": settings.packet_frames.as_str(),
1469            "math_mode": settings.math_mode.as_str(),
1470            "voice_pack": settings.voice_pack.as_str(),
1471            "normalize": settings.normalize.as_str(),
1472            "normalization_trace_requested": request.trace_normalization,
1473            "seed": cli.seed,
1474            "trace": cli.trace.as_ref().map(|path| path.display().to_string()),
1475            "output": requested_output.as_ref().map(|path| path.display().to_string()),
1476            "admission": admission,
1477        });
1478        emit(&event)?;
1479        let mut complete = run.event(robot::EventType::RunComplete);
1480        complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
1481        complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1482        complete.insert("frames".to_owned(), json!(0));
1483        complete.insert("audio_bytes".to_owned(), json!(0));
1484        emit(&Value::Object(complete))?;
1485        return Ok(());
1486    }
1487
1488    // --- audio destination, decided before any model work ----------------------------------
1489    // A run that synthesizes for thirty seconds and then discovers it has nowhere to put the
1490    // result has wasted the user's time; the refusal belongs here, before the weights load.
1491    let raw_stream = args.stream == Some(StreamMode::Raw);
1492    let mut audio = match (&output_plan, raw_stream) {
1493        (Some(plan), false) => AudioOutput::wav(&plan.wav_path)?,
1494        (None, true) => AudioOutput::raw(),
1495        (None, false) => {
1496            return Err(FttsError::Usage(
1497                "`ftts say` has nowhere to put the audio; add an output path (`ftts say \"text\" \
1498                 out.wav`, or `-o PATH`) or `--stream raw` for PCM on stdout"
1499                    .to_owned(),
1500            ));
1501        }
1502        // clap declares every output form and `--stream` mutually exclusive.
1503        (Some(_), true) => unreachable!("clap enforces the conflict"),
1504    };
1505
1506    // --- model load ------------------------------------------------------------------------
1507    emit_stage(run, emit, "load", "begin", &mut seq)?;
1508    let bundle = synth::ModelBundle::resolve(Path::new(&model))?;
1509    let voice_path = match voice.as_deref().map(PathBuf::from).or_else(|| {
1510        let candidate = bundle.root.join("default.spk");
1511        candidate.is_file().then_some(candidate)
1512    }) {
1513        Some(path) => path,
1514        // Out-of-box: no --voice, no FTTS_DEFAULT_VOICE, no enrollment — speak with the built-in
1515        // default preset rather than refusing. The presets are real enrolled x-vectors taken from
1516        // speech, so they sit on the speaker encoder's manifold; any user enrollment or explicit
1517        // voice always outranks them.
1518        None => materialize_preset_voice(DEFAULT_PRESET_VOICE)
1519            .expect("the default preset name is a member of PRESET_VOICES")?,
1520    };
1521    // A `--voice` that names an audio file (not a .spk vector) computes an ephemeral
1522    // enrollment, and gets the same automatic denoise `ftts enroll` applies — otherwise the
1523    // one-off form of the exact same operation would sound worse than the saved form. The
1524    // report goes unread here: `say` has no enrollment console, and the .spk/preset paths
1525    // never enter the cleanup code.
1526    let mut say_denoise_report = None;
1527    let denoise_ephemeral = bundle.root.join(synth::DENOISE_ARTIFACT_RELPATH).is_file();
1528    let speaker = synth::speaker_from_voice(
1529        &bundle,
1530        &voice_path,
1531        synth::ReferenceCleanup {
1532            denoise: denoise_ephemeral.then_some(&mut say_denoise_report),
1533            dereverb: None,
1534        },
1535    )?;
1536    // With the resident engine (the default), the model stays loaded in a background
1537    // process and this invocation skips its own hydration; the daemon's load happens
1538    // inside the synthesis stage on its first request. Any resident-path unavailability
1539    // falls back to the classic in-process load below, never to a failure.
1540    let use_resident = resident::enabled(args.no_resident);
1541    let loaded = if use_resident {
1542        None
1543    } else {
1544        Some(synth::LoadedModel::load(&bundle)?)
1545    };
1546    emit_stage(run, emit, "load", "end", &mut seq)?;
1547
1548    // --- synthesis -------------------------------------------------------------------------
1549    // The engine's observer is not forwarded to the event stream here. Its events are lifecycle
1550    // facts the robot contract already carries as `stage` events, and per-frame progress cannot be
1551    // emitted from inside this call anyway: `emit` is borrowed for the duration. Progress becomes
1552    // observable per packet once synthesis returns, and genuinely incremental frame events belong
1553    // with the streaming decode path rather than being faked from a completed run.
1554    let observer = |_event: ftts_core::SynthesisEvent| {};
1555
1556    // Canonical greedy consumes no RNG state, so an absent `--seed` changes nothing today;
1557    // 0 is the documented default rather than a value picked per run, which would make a
1558    // future switch to the production sampler silently irreproducible.
1559    let seed = cli.seed.unwrap_or(0);
1560
1561    emit_stage(run, emit, "synthesis", "begin", &mut seq)?;
1562    let resident_audio = if use_resident {
1563        resident::try_synthesize(
1564            &bundle,
1565            &resident::WireRequest {
1566                text: &request.text,
1567                normalize: settings.normalize.as_str(),
1568                trace: request.trace_normalization,
1569                speaker: &speaker,
1570                seed,
1571            },
1572        )?
1573    } else {
1574        None
1575    };
1576    let audio_result = match resident_audio {
1577        Some(audio) => audio,
1578        None => {
1579            let loaded = match loaded {
1580                Some(loaded) => loaded,
1581                // The resident path was requested but no daemon could serve it.
1582                None => synth::LoadedModel::load(&bundle)?,
1583            };
1584            let engine = ftts_core::TtsEngine::from_process_environment()
1585                .map_err(|error| FttsError::Generic(format!("cannot start the engine: {error}")))?;
1586            let cancellation = ftts_core::CancellationToken::new();
1587            synth::synthesize(
1588                &loaded,
1589                &engine,
1590                &request,
1591                &speaker,
1592                seed,
1593                &cancellation,
1594                &observer,
1595            )?
1596        }
1597    };
1598    emit_stage(run, emit, "synthesis", "end", &mut seq)?;
1599
1600    // --- the output tail ---------------------------------------------------------------------
1601    let packet_samples = settings.packet_frames.samples_per_packet();
1602    let packet_frame_count = settings.packet_frames.frames_per_packet();
1603    emit_stage(run, emit, "output", "begin", &mut seq)?;
1604    for packet in audio_result.pcm.chunks(packet_samples) {
1605        let event = audio.write_packet(packet, raw_audio, run.run_id(), packet_frame_count)?;
1606        emit(&event)?;
1607    }
1608    let audio_bytes = audio.byte_offset();
1609    let samples = audio.finish()?;
1610    if let Some(plan) = &output_plan {
1611        plan.finalize()?;
1612    }
1613    emit_stage(run, emit, "output", "end", &mut seq)?;
1614
1615    let mut complete = run.event(robot::EventType::RunComplete);
1616    complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
1617    complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1618    complete.insert("frames".to_owned(), json!(audio_result.frames));
1619    complete.insert("audio_bytes".to_owned(), json!(audio_bytes));
1620    complete.insert("samples".to_owned(), json!(samples));
1621    complete.insert(
1622        "duration_ms".to_owned(),
1623        json!(samples * 1000 / u64::from(ftts_core::audio::SAMPLE_RATE_HZ)),
1624    );
1625    complete.insert(
1626        "prepared_token_count".to_owned(),
1627        json!(audio_result.prepared_token_count),
1628    );
1629    if let Some(ttfa) = audio_result.ttfa {
1630        complete.insert(
1631            "ttfa_ms".to_owned(),
1632            json!(u64::try_from(ttfa.as_millis()).unwrap_or(u64::MAX)),
1633        );
1634    }
1635    emit(&Value::Object(complete))?;
1636    Ok(())
1637}
1638
1639/// Where synthesised PCM goes, and the `audio_chunk` events that describe it.
1640///
1641/// The two destinations are mutually exclusive by contract (AGENTS.md agent ergonomics): either
1642/// events own stdout and audio goes to `-o PATH`, or `--stream raw` gives stdout to PCM and every
1643/// event goes to stderr. Raw bytes and NDJSON are never interleaved on one stream, so this type
1644/// owns the decision once instead of leaving it to each call site.
1645///
1646/// `audio_chunk` reports the bytes written, never the bytes themselves.
1647pub enum AudioSink {
1648    /// A WAV file. The header is finalised on [`AudioSink::finish`], so a run cut short still
1649    /// leaves a playable file describing the samples that landed.
1650    Wav(Box<ftts_core::audio::WavWriter<fs::File>>),
1651    /// Raw little-endian 16-bit PCM on a caller-supplied stream (`--stream raw`).
1652    RawPcm,
1653    /// `--check` and other non-synthesising paths.
1654    None,
1655}
1656
1657/// Accumulating state for the `audio_chunk` event stream.
1658pub struct AudioOutput {
1659    sink: AudioSink,
1660    byte_offset: u64,
1661    samples_written: u64,
1662}
1663
1664/// Audio container selected by the output path's extension.
1665#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1666enum OutputFormat {
1667    /// Written natively by the pure-Rust WAV writer.
1668    Wav,
1669    /// AAC in an MPEG-4 container, encoded by `afconvert` (macOS) or `ffmpeg`.
1670    M4a,
1671    /// MP3, encoded by `lame` or `ffmpeg`.
1672    Mp3,
1673    /// FLAC, encoded by `flac` or `ffmpeg`.
1674    Flac,
1675}
1676
1677/// Where the WAV bytes land and what happens to them after synthesis.
1678///
1679/// Synthesis always writes the pure-Rust WAV stream ("self-contained" covers everything up to
1680/// and including that file). For a compressed extension the WAV goes to a sibling staging file
1681/// and is then handed to the first available *system* encoder — an optional post-step, never a
1682/// runtime dependency of synthesis itself. No encoder found is a refusal with the tool list,
1683/// not a silent format switch.
1684#[derive(Clone, Debug)]
1685struct OutputPlan {
1686    /// The path the user asked for.
1687    final_path: PathBuf,
1688    /// Where the WAV sink writes; equals `final_path` for `.wav`.
1689    wav_path: PathBuf,
1690    format: OutputFormat,
1691}
1692
1693impl OutputPlan {
1694    fn for_path(path: &Path) -> Result<Self, FttsError> {
1695        let extension = path
1696            .extension()
1697            .and_then(|extension| extension.to_str())
1698            .map(str::to_ascii_lowercase);
1699        let format = match extension.as_deref() {
1700            Some("wav") | None => OutputFormat::Wav,
1701            Some("m4a" | "aac") => OutputFormat::M4a,
1702            Some("mp3") => OutputFormat::Mp3,
1703            Some("flac") => OutputFormat::Flac,
1704            Some(other) => {
1705                return Err(FttsError::Usage(format!(
1706                    "unsupported output extension `.{other}`; use .wav (native), .m4a, .mp3, or \
1707                     .flac (system encoder)"
1708                )));
1709            }
1710        };
1711        let wav_path = if format == OutputFormat::Wav {
1712            path.to_path_buf()
1713        } else {
1714            let mut staging = path.as_os_str().to_owned();
1715            staging.push(".ftts-staging.wav");
1716            PathBuf::from(staging)
1717        };
1718        Ok(Self {
1719            final_path: path.to_path_buf(),
1720            wav_path,
1721            format,
1722        })
1723    }
1724
1725    /// Encodes the staged WAV into the requested container and removes the staging file.
1726    fn finalize(&self) -> Result<(), FttsError> {
1727        if self.format == OutputFormat::Wav {
1728            return Ok(());
1729        }
1730        let wav = self.wav_path.as_os_str();
1731        let target = self.final_path.as_os_str();
1732        // (encoder, arguments) attempts in preference order; the first tool present decides.
1733        let attempts: &[(&str, Vec<&std::ffi::OsStr>)] = &match self.format {
1734            OutputFormat::M4a => [
1735                (
1736                    "afconvert",
1737                    vec![
1738                        "-f".as_ref(),
1739                        "m4af".as_ref(),
1740                        "-d".as_ref(),
1741                        "aac".as_ref(),
1742                        wav,
1743                        target,
1744                    ],
1745                ),
1746                (
1747                    "ffmpeg",
1748                    vec![
1749                        "-y".as_ref(),
1750                        "-loglevel".as_ref(),
1751                        "error".as_ref(),
1752                        "-i".as_ref(),
1753                        wav,
1754                        "-c:a".as_ref(),
1755                        "aac".as_ref(),
1756                        target,
1757                    ],
1758                ),
1759            ],
1760            OutputFormat::Mp3 => [
1761                (
1762                    "lame",
1763                    vec!["--quiet".as_ref(), "-V2".as_ref(), wav, target],
1764                ),
1765                (
1766                    "ffmpeg",
1767                    vec![
1768                        "-y".as_ref(),
1769                        "-loglevel".as_ref(),
1770                        "error".as_ref(),
1771                        "-i".as_ref(),
1772                        wav,
1773                        "-codec:a".as_ref(),
1774                        "libmp3lame".as_ref(),
1775                        "-q:a".as_ref(),
1776                        "2".as_ref(),
1777                        target,
1778                    ],
1779                ),
1780            ],
1781            OutputFormat::Flac => [
1782                (
1783                    "flac",
1784                    vec![
1785                        "--totally-silent".as_ref(),
1786                        "-f".as_ref(),
1787                        "-o".as_ref(),
1788                        target,
1789                        wav,
1790                    ],
1791                ),
1792                (
1793                    "ffmpeg",
1794                    vec![
1795                        "-y".as_ref(),
1796                        "-loglevel".as_ref(),
1797                        "error".as_ref(),
1798                        "-i".as_ref(),
1799                        wav,
1800                        "-c:a".as_ref(),
1801                        "flac".as_ref(),
1802                        target,
1803                    ],
1804                ),
1805            ],
1806            OutputFormat::Wav => unreachable!("handled above"),
1807        };
1808
1809        let mut tried = Vec::new();
1810        for (tool, arguments) in attempts {
1811            // `tool` is always one of the fixed string literals in `attempts` above — a
1812            // compile-time allowlist. User-controlled data (the two paths) enters only as argv.
1813            match std::process::Command::new(tool).args(arguments).status() {
1814                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1815                    tried.push(*tool);
1816                }
1817                Err(error) => {
1818                    return Err(FttsError::Generic(format!(
1819                        "audio encoder `{tool}` could not run: {error}; the synthesized WAV is \
1820                         preserved at {}",
1821                        self.wav_path.display()
1822                    )));
1823                }
1824                Ok(status) if status.success() => {
1825                    // The staging WAV is an intermediate this run created; the requested artifact
1826                    // now exists, so the intermediate is removed.
1827                    let _ = fs::remove_file(&self.wav_path);
1828                    return Ok(());
1829                }
1830                Ok(status) => {
1831                    return Err(FttsError::Generic(format!(
1832                        "audio encoder `{tool}` exited with {status}; the synthesized WAV is \
1833                         preserved at {}",
1834                        self.wav_path.display()
1835                    )));
1836                }
1837            }
1838        }
1839        Err(FttsError::Generic(format!(
1840            "no system audio encoder found for {} (tried: {}); install one or use a .wav output. \
1841             The synthesized WAV is preserved at {}",
1842            self.final_path.display(),
1843            tried.join(", "),
1844            self.wav_path.display()
1845        )))
1846    }
1847}
1848
1849impl AudioOutput {
1850    /// Open a WAV file sink.
1851    ///
1852    /// # Errors
1853    ///
1854    /// If the file cannot be created or the provisional header cannot be written.
1855    pub fn wav(path: &Path) -> Result<Self, FttsError> {
1856        let file = fs::File::create(path).map_err(|error| {
1857            FttsError::Generic(format!(
1858                "cannot create audio output {}: {error}",
1859                path.display()
1860            ))
1861        })?;
1862        let writer = ftts_core::audio::WavWriter::new(file, ftts_core::audio::SAMPLE_RATE_HZ)
1863            .map_err(|error| {
1864                FttsError::Generic(format!(
1865                    "cannot write WAV header to {}: {error}",
1866                    path.display()
1867                ))
1868            })?;
1869        Ok(Self {
1870            sink: AudioSink::Wav(Box::new(writer)),
1871            byte_offset: 0,
1872            samples_written: 0,
1873        })
1874    }
1875
1876    /// A raw-PCM sink; the caller supplies the stream on each write.
1877    #[must_use]
1878    pub const fn raw() -> Self {
1879        Self {
1880            sink: AudioSink::RawPcm,
1881            byte_offset: 0,
1882            samples_written: 0,
1883        }
1884    }
1885
1886    /// A sink that discards audio, for paths that synthesise nothing.
1887    #[must_use]
1888    pub const fn none() -> Self {
1889        Self {
1890            sink: AudioSink::None,
1891            byte_offset: 0,
1892            samples_written: 0,
1893        }
1894    }
1895
1896    /// The stable `sink` string reported in `audio_chunk`.
1897    #[must_use]
1898    pub const fn sink_name(&self) -> &'static str {
1899        match self.sink {
1900            AudioSink::Wav(_) => "file",
1901            AudioSink::RawPcm => "stdout",
1902            AudioSink::None => "none",
1903        }
1904    }
1905
1906    /// Bytes of audio emitted so far.
1907    #[must_use]
1908    pub const fn byte_offset(&self) -> u64 {
1909        self.byte_offset
1910    }
1911
1912    /// Write one packet and return its `audio_chunk` event.
1913    ///
1914    /// `raw` is where PCM goes under `--stream raw`; it is ignored by the other sinks. The event's
1915    /// `byte_offset` is the offset *before* this packet, so a consumer can seek with it.
1916    ///
1917    /// `duration_ms` is derived from the sample count rather than taken from a caller-supplied
1918    /// clock: it describes how much *audio* this packet holds, which is a property of the samples,
1919    /// not of how long the run took to produce them.
1920    ///
1921    /// # Errors
1922    ///
1923    /// If the sink rejects the write.
1924    pub fn write_packet(
1925        &mut self,
1926        pcm: &[f32],
1927        raw: &mut dyn Write,
1928        run_id: &str,
1929        frame_count: u8,
1930    ) -> Result<Value, FttsError> {
1931        let offset_before = self.byte_offset;
1932        let bytes = (pcm.len() * 2) as u64;
1933
1934        match &mut self.sink {
1935            AudioSink::Wav(writer) => writer.write_samples(pcm).map_err(|error| {
1936                FttsError::Generic(format!("cannot write audio samples: {error}"))
1937            })?,
1938            AudioSink::RawPcm => {
1939                let mut buffer = Vec::with_capacity(pcm.len() * 2);
1940                for sample in pcm {
1941                    buffer
1942                        .extend_from_slice(&ftts_core::audio::sample_to_i16(*sample).to_le_bytes());
1943                }
1944                raw.write_all(&buffer).map_err(|error| {
1945                    FttsError::Generic(format!("cannot write raw PCM: {error}"))
1946                })?;
1947            }
1948            AudioSink::None => {}
1949        }
1950
1951        self.byte_offset += bytes;
1952        self.samples_written += pcm.len() as u64;
1953
1954        let mut event = robot::EventType::AudioChunk.event();
1955        event.insert("run_id".to_owned(), json!(run_id));
1956        event.insert("byte_offset".to_owned(), json!(offset_before));
1957        event.insert("bytes".to_owned(), json!(bytes));
1958        event.insert(
1959            "duration_ms".to_owned(),
1960            json!((pcm.len() as u64) * 1000 / u64::from(ftts_core::audio::SAMPLE_RATE_HZ.max(1))),
1961        );
1962        event.insert("packet_frames".to_owned(), json!(frame_count.to_string()));
1963        event.insert("sink".to_owned(), json!(self.sink_name()));
1964        Ok(Value::Object(event))
1965    }
1966
1967    /// Finalise the sink, patching the WAV header to the real length.
1968    ///
1969    /// # Errors
1970    ///
1971    /// If the header cannot be rewritten.
1972    pub fn finish(self) -> Result<u64, FttsError> {
1973        let samples = self.samples_written;
1974        if let AudioSink::Wav(writer) = self.sink {
1975            writer.finish().map_err(|error| {
1976                FttsError::Generic(format!("cannot finalize the WAV header: {error}"))
1977            })?;
1978        }
1979        Ok(samples)
1980    }
1981}
1982
1983fn read_text(args: &SayArgs, stdin: &mut dyn Read) -> Result<String, FttsError> {
1984    let text = match (&args.text, &args.file) {
1985        (Some(text), None) if text == "-" => read_utf8(stdin, "stdin")?,
1986        (Some(text), None) => text.clone(),
1987        (None, Some(path)) if path == Path::new("-") => read_utf8(stdin, "stdin")?,
1988        (None, Some(path)) => fs::read_to_string(path).map_err(|error| {
1989            FttsError::Input(format!(
1990                "cannot read text file {}: {error}; use `ftts say --file PATH --check --model PATH`",
1991                path.display()
1992            ))
1993        })?,
1994        (None, None) => {
1995            return Err(FttsError::Usage(
1996                "missing text; use `ftts say TEXT`, `ftts say --file PATH`, or `ftts say -`".to_owned(),
1997            ));
1998        }
1999        (Some(_), Some(_)) => unreachable!("clap enforces the conflict"),
2000    };
2001
2002    if text.trim().is_empty() {
2003        return Err(FttsError::Input(
2004            "text is empty; provide non-whitespace UTF-8 text to `ftts say`".to_owned(),
2005        ));
2006    }
2007    Ok(text)
2008}
2009
2010fn read_utf8(reader: &mut dyn Read, source: &str) -> Result<String, FttsError> {
2011    let mut bytes = Vec::new();
2012    reader.read_to_end(&mut bytes).map_err(|error| {
2013        FttsError::Input(format!(
2014            "cannot read {source}: {error}; retry with readable UTF-8 input"
2015        ))
2016    })?;
2017    String::from_utf8(bytes).map_err(|error| {
2018        FttsError::Input(format!(
2019            "{source} is not valid UTF-8: {error}; transcode it before `ftts say`"
2020        ))
2021    })
2022}
2023
2024fn resolve_model(explicit: Option<&Path>, environment: &Environment) -> Result<String, FttsError> {
2025    resolve_model_from(
2026        explicit,
2027        &model_search_paths(environment),
2028        default_pull_model_dir(environment).as_deref(),
2029    )
2030}
2031
2032/// The full resolution order — `--model`, then the searched artifact paths (`FTTS_MODEL_DIR`,
2033/// then the home cache), then the `ftts pull` destination directory, accepted only when it holds
2034/// a complete bundle. Takes its inputs as data so tests can exercise the order against temp
2035/// directories without mutating process environment.
2036fn resolve_model_from(
2037    explicit: Option<&Path>,
2038    searched: &[PathBuf],
2039    pull_dir: Option<&Path>,
2040) -> Result<String, FttsError> {
2041    if let Some(path) = explicit {
2042        // A pinned checkpoint is a *directory* of five files, so `--model DIR` is the natural
2043        // thing to type and is accepted as such. `.fttsq` is a single file, and both forms reach
2044        // the same resolver rather than one being a special case documented somewhere else.
2045        if path.is_dir() {
2046            return Ok(path.display().to_string());
2047        }
2048        return resolve_existing_file(path, "model artifact")
2049            .map(|path| path.display().to_string());
2050    }
2051
2052    if let Some(path) = searched.iter().find(|path| path.is_file()) {
2053        return Ok(path.display().to_string());
2054    }
2055    // A directory named by FTTS_MODEL_DIR may itself BE the model: a pinned checkpoint snapshot
2056    // (`model.safetensors` + configs) or a directory holding the canonical artifact. `--model DIR`
2057    // already accepts that shape; the search path accepting it too is what lets a bare
2058    // `ftts say "text" out.wav` work after one exported variable.
2059    if let Some(directory) = searched
2060        .iter()
2061        .filter_map(|path| path.parent())
2062        .find(|directory| directory.join("model.safetensors").is_file())
2063    {
2064        return Ok(directory.display().to_string());
2065    }
2066
2067    // The `ftts pull` destination is a *bundle* directory (checkpoints + tokenizer files), not a
2068    // single artifact, so it counts only when the whole bundle is present — a half-finished pull
2069    // resolving here would fail later with a less actionable error than the one below.
2070    if let Some(directory) = pull_dir
2071        && directory.is_dir()
2072        && synth::ModelBundle::resolve(directory).is_ok()
2073    {
2074        return Ok(directory.display().to_string());
2075    }
2076
2077    let searched = searched
2078        .iter()
2079        .map(|path| path.display().to_string())
2080        .collect::<Vec<_>>()
2081        .join(", ");
2082    Err(FttsError::ModelNotFound(format!(
2083        "no model artifact was found; searched: [{searched}]; run `ftts pull` to fetch the model \
2084         (~2.0 GB), or pass --model PATH or set FTTS_MODEL_DIR"
2085    )))
2086}
2087
2088fn resolve_optional_file(path: Option<&Path>, label: &str) -> Result<Option<String>, FttsError> {
2089    path.map(|path| resolve_existing_file(path, label).map(|path| path.display().to_string()))
2090        .transpose()
2091}
2092
2093fn resolve_requested_voice(
2094    explicit: Option<&Path>,
2095    environment: &Environment,
2096) -> Result<Option<String>, FttsError> {
2097    if let Some(path) = explicit {
2098        // A bare preset name selects a built-in voice — but only when no such file exists, so
2099        // `--voice aria` in a directory containing a file named `aria` still means the file.
2100        if !path.exists()
2101            && let Some(name) = path.to_str()
2102            && let Some(materialized) = materialize_preset_voice(name)
2103        {
2104            return materialized.map(|path| Some(path.display().to_string()));
2105        }
2106        // A failed bare word was probably a preset-name attempt: name the built-ins in the
2107        // refusal so the user does not have to hunt the docs for the list.
2108        let looks_like_name =
2109            path.extension().is_none() && path.components().count() == 1 && !path.exists();
2110        return resolve_optional_file(Some(path), "voice source").map_err(|error| {
2111            if looks_like_name {
2112                FttsError::Input(format!(
2113                    "{error}; built-in voice names are: {}",
2114                    preset_names()
2115                ))
2116            } else {
2117                error
2118            }
2119        });
2120    }
2121    environment
2122        .value("FTTS_DEFAULT_VOICE")
2123        .map(Path::new)
2124        .map(|path| resolve_existing_file(path, "FTTS_DEFAULT_VOICE"))
2125        .transpose()
2126        .map(|path| path.map(|path| path.display().to_string()))
2127}
2128
2129fn run_enroll(
2130    args: &EnrollArgs,
2131    environment: &Environment,
2132    stdout: &mut dyn Write,
2133) -> Result<(), FttsError> {
2134    let _ = args.force;
2135    let model = resolve_model(args.model.as_deref(), environment)?;
2136    let bundle = synth::ModelBundle::resolve(Path::new(&model))?;
2137    let output = match (&args.output, args.default) {
2138        (Some(path), false) => path.clone(),
2139        (None, true) => bundle.root.join("default.spk"),
2140        (None, false) => {
2141            return Err(FttsError::Usage(
2142                "`ftts enroll` needs -o PATH or --default; enrollment never overwrites a voice source"
2143                    .to_owned(),
2144            ));
2145        }
2146        (Some(_), true) => unreachable!("clap enforces the conflict"),
2147    };
2148    let mut denoise_report = None;
2149    let mut dereverb_report = None;
2150    // Denoise resolution: --no-denoise wins, --denoise forces (including the classic engine
2151    // when the weights are absent), and the default is neural-when-pulled — never a silent
2152    // fallback to a different engine than the one the default advertises.
2153    let denoise = if args.no_denoise {
2154        false
2155    } else {
2156        args.denoise || bundle.root.join(synth::DENOISE_ARTIFACT_RELPATH).is_file()
2157    };
2158    let speaker = synth::speaker_from_voice(
2159        &bundle,
2160        &args.reference_audio,
2161        synth::ReferenceCleanup {
2162            denoise: denoise.then_some(&mut denoise_report),
2163            dereverb: args.dereverb.then_some(&mut dereverb_report),
2164        },
2165    )?;
2166    // Same reporting discipline as the denoise below: state what was measured. A reference whose
2167    // reverb time barely moves was not the problem, and a better recording beats more filtering.
2168    if let Some(report) = dereverb_report {
2169        style::ok(
2170            stdout,
2171            &format!(
2172                "dereverberated reference {}",
2173                style::detail(&format!(
2174                    "RT60-equivalent {:.2} → {:.2} s",
2175                    report.before_rt60_s, report.after_rt60_s
2176                )),
2177            ),
2178        )
2179        .map_err(|error| FttsError::Generic(format!("cannot write dereverb report: {error}")))?;
2180    }
2181    // Report what the denoise measured rather than asserting it helped: a reference whose floor
2182    // barely moves was not noisy, and the user should reach for a better recording instead.
2183    if let Some(report) = denoise_report {
2184        let moved = report.before_dbfs - report.after_dbfs;
2185        style::ok(
2186            stdout,
2187            &format!(
2188                "denoised reference {}",
2189                style::detail(&format!(
2190                    "pause floor {:.1} → {:.1} dBFS ({moved:.1} dB quieter)",
2191                    report.before_dbfs, report.after_dbfs
2192                )),
2193            ),
2194        )
2195        .map_err(|error| FttsError::Generic(format!("cannot write denoise report: {error}")))?;
2196    }
2197
2198    // Enrollment is cheap to redo; the recording behind an existing voice may not still exist. So
2199    // an occupied destination asks rather than refuses — but only when somebody is there to ask.
2200    // A pipe, a CI job, or an agent gets the explicit error it can act on instead of a prompt that
2201    // would hang forever, and says `--overwrite` when it means it.
2202    let backup = if output.exists() {
2203        let consented = if args.overwrite {
2204            true
2205        } else {
2206            style::warn(
2207                stdout,
2208                &format!(
2209                    "{} already holds an enrolled voice",
2210                    style::emphasis(&output.display().to_string())
2211                ),
2212            )
2213            .map_err(|error| {
2214                FttsError::Generic(format!("cannot write overwrite notice: {error}"))
2215            })?;
2216            match style::confirm(stdout, "Replace it?")
2217                .map_err(|error| FttsError::Generic(format!("cannot read a reply: {error}")))?
2218            {
2219                Some(reply) => reply,
2220                None => {
2221                    return Err(FttsError::Input(format!(
2222                        "{} already exists; pass --overwrite to replace it (the displaced voice is \
2223                         kept as {}.bak)",
2224                        output.display(),
2225                        output.display()
2226                    )));
2227                }
2228            }
2229        };
2230        if !consented {
2231            style::info(stdout, "left the existing voice in place")
2232                .map_err(|error| FttsError::Generic(format!("cannot write result: {error}")))?;
2233            return Ok(());
2234        }
2235        Some(synth::replace_speaker_vector(&output, &speaker)?)
2236    } else {
2237        synth::write_speaker_vector_new(&output, &speaker)?;
2238        None
2239    };
2240
2241    style::ok(
2242        stdout,
2243        &format!(
2244            "enrolled {} → {}",
2245            style::emphasis(&args.reference_audio.display().to_string()),
2246            style::emphasis(&output.display().to_string()),
2247        ),
2248    )
2249    .map_err(|error| FttsError::Generic(format!("cannot write enrollment result: {error}")))?;
2250    if let Some(backup) = backup {
2251        style::info(
2252            stdout,
2253            &format!(
2254                "previous voice kept at {}",
2255                style::emphasis(&backup.display().to_string())
2256            ),
2257        )
2258        .map_err(|error| FttsError::Generic(format!("cannot write backup notice: {error}")))?;
2259    }
2260    if args.default {
2261        style::info(
2262            stdout,
2263            &format!(
2264                "{} will use it when --voice is absent",
2265                style::emphasis("ftts say")
2266            ),
2267        )
2268        .map_err(|error| FttsError::Generic(format!("cannot write result: {error}")))?;
2269    }
2270    Ok(())
2271}
2272
2273/// One downloadable model file from the embedded manifest.
2274#[derive(Clone, Debug)]
2275struct ModelManifestFile {
2276    /// The bare release-asset name on the GitHub release.
2277    asset: String,
2278    /// Relative path under the model directory the asset lands at.
2279    dest: String,
2280    /// Pinned lowercase-hex SHA-256 the downloaded bytes must carry.
2281    sha256: String,
2282    /// Pinned exact size, checked before the (much more expensive) digest.
2283    bytes: u64,
2284}
2285
2286/// The embedded `ftts pull` download contract: release coordinates plus per-file pins.
2287#[derive(Clone, Debug)]
2288struct ModelManifest {
2289    model_id: String,
2290    release_tag: String,
2291    repo: String,
2292    files: Vec<ModelManifestFile>,
2293}
2294
2295impl ModelManifest {
2296    /// The compiled-in manifest. Parsing it can only fail if the checked-in copy is malformed,
2297    /// which the unit tests catch before a binary ships.
2298    fn embedded() -> Result<Self, FttsError> {
2299        Self::parse(PINNED_MODEL_MANIFEST)
2300    }
2301
2302    /// Parses and validates manifest text; every refusal names the offending field, because a
2303    /// manifest bug otherwise surfaces as a mystery mid-download.
2304    fn parse(text: &str) -> Result<Self, FttsError> {
2305        let value: Value = serde_json::from_str(text).map_err(|error| {
2306            FttsError::ArtifactFormat(format!("model manifest is not valid JSON: {error}"))
2307        })?;
2308        if value["schema_version"].as_u64() != Some(1) {
2309            return Err(FttsError::ArtifactFormat(format!(
2310                "model manifest schema_version {} is not the supported 1",
2311                value["schema_version"]
2312            )));
2313        }
2314        let model_id = manifest_string(&value, "model_id")?;
2315        let release_tag = manifest_string(&value, "release_tag")?;
2316        let repo = manifest_string(&value, "repo")?;
2317        let files = value["files"]
2318            .as_array()
2319            .filter(|files| !files.is_empty())
2320            .ok_or_else(|| {
2321                FttsError::ArtifactFormat("model manifest needs a non-empty files array".to_owned())
2322            })?
2323            .iter()
2324            .map(parse_manifest_file)
2325            .collect::<Result<Vec<_>, _>>()?;
2326        Ok(Self {
2327            model_id,
2328            release_tag,
2329            repo,
2330            files,
2331        })
2332    }
2333
2334    /// The release-asset URL for one file; the only endpoint `ftts pull` ever contacts.
2335    fn download_url(&self, file: &ModelManifestFile) -> String {
2336        format!(
2337            "https://github.com/{}/releases/download/{}/{}",
2338            self.repo, self.release_tag, file.asset
2339        )
2340    }
2341
2342    fn total_bytes(&self) -> u64 {
2343        self.files
2344            .iter()
2345            .fold(0, |sum, file| sum.saturating_add(file.bytes))
2346    }
2347}
2348
2349fn manifest_string(value: &Value, field: &str) -> Result<String, FttsError> {
2350    value[field]
2351        .as_str()
2352        .filter(|text| !text.is_empty())
2353        .map(str::to_owned)
2354        .ok_or_else(|| {
2355            FttsError::ArtifactFormat(format!(
2356                "model manifest field {field} must be a non-empty string"
2357            ))
2358        })
2359}
2360
2361fn parse_manifest_file(value: &Value) -> Result<ModelManifestFile, FttsError> {
2362    let asset = manifest_string(value, "asset")?;
2363    if asset.contains('/') || asset.contains('\\') {
2364        return Err(FttsError::ArtifactFormat(format!(
2365            "manifest asset {asset:?} must be a bare release-asset name"
2366        )));
2367    }
2368    let dest = manifest_string(value, "dest")?;
2369    validate_manifest_dest(&dest)?;
2370    let sha256 = manifest_string(value, "sha256")?;
2371    if !is_sha256_hex(&sha256) {
2372        return Err(FttsError::ArtifactFormat(format!(
2373            "manifest sha256 for {asset} must be 64 lowercase hex characters"
2374        )));
2375    }
2376    let bytes = value["bytes"]
2377        .as_u64()
2378        .filter(|bytes| *bytes > 0)
2379        .ok_or_else(|| {
2380            FttsError::ArtifactFormat(format!(
2381                "manifest bytes for {asset} must be a positive integer"
2382            ))
2383        })?;
2384    Ok(ModelManifestFile {
2385        asset,
2386        dest,
2387        sha256,
2388        bytes,
2389    })
2390}
2391
2392/// A manifest `dest` is joined under the model directory, so it must not be able to escape it:
2393/// no absolute paths, no `..`, no `.`, no backslash separators.
2394fn validate_manifest_dest(dest: &str) -> Result<(), FttsError> {
2395    let path = Path::new(dest);
2396    let traversal_free = path
2397        .components()
2398        .all(|component| matches!(component, std::path::Component::Normal(_)));
2399    if path.is_absolute() || dest.contains('\\') || !traversal_free {
2400        return Err(FttsError::ArtifactFormat(format!(
2401            "manifest dest {dest:?} must be a relative path with no traversal; it is joined under the model directory"
2402        )));
2403    }
2404    Ok(())
2405}
2406
2407fn is_sha256_hex(text: &str) -> bool {
2408    text.len() == 64
2409        && text
2410            .bytes()
2411            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
2412}
2413
2414/// The directory `ftts pull` fills when `--model` is absent, and the last resort model resolution
2415/// falls back to: the first `FTTS_MODEL_DIR` entry when set, else `$HOME/.cache/franken_tts/model`.
2416fn default_pull_model_dir(environment: &Environment) -> Option<PathBuf> {
2417    if let Some(first) = environment
2418        .value("FTTS_MODEL_DIR")
2419        .and_then(|dirs| std::env::split_paths(dirs).next())
2420        .filter(|path| !path.as_os_str().is_empty())
2421    {
2422        return Some(first);
2423    }
2424    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(DEFAULT_MODEL_CACHE_SUBDIR))
2425}
2426
2427/// Skip-versus-download for one manifest file, factored out so it is testable without a network.
2428#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2429enum PullDecision {
2430    /// The destination already carries the pinned size AND the pinned digest.
2431    Skip,
2432    /// Absent, wrong-sized, wrong-hashed, or `--force`.
2433    Download,
2434}
2435
2436/// `Skip` requires both pins to hold: size alone accepts a same-length corruption, and hashing
2437/// alone would digest gigabytes a length check could reject for free (which is why the cheap check
2438/// runs first). Any mismatch re-downloads rather than erroring — repairing a bad file is exactly
2439/// what `pull` is for.
2440fn pull_decision(dest: &Path, file: &ModelManifestFile, force: bool) -> PullDecision {
2441    if force {
2442        return PullDecision::Download;
2443    }
2444    let Ok(metadata) = fs::metadata(dest) else {
2445        return PullDecision::Download;
2446    };
2447    if !metadata.is_file() || metadata.len() != file.bytes {
2448        return PullDecision::Download;
2449    }
2450    match ftts_artifacts::sha256::hex_digest_file(dest) {
2451        Ok(digest) if digest == file.sha256 => PullDecision::Skip,
2452        _ => PullDecision::Download,
2453    }
2454}
2455
2456/// Downloads `url` to `staging` with the system `curl`.
2457///
2458/// Shelling out is deliberate: inference never touches the network, so the binary links no HTTP
2459/// stack. Only `pull` needs one, and `curl` is the same system-tool seam the audio encoders and
2460/// decoders already use.
2461fn download_with_curl(url: &str, staging: &Path, pinned_bytes: u64) -> Result<(), FttsError> {
2462    // Hardening beyond the happy path: HTTPS only through every redirect (a release URL should
2463    // never bounce through http), a connect timeout and a stall detector instead of hanging a
2464    // silent `-sS` transfer forever, and the pinned size as a hard transfer cap so a
2465    // misbehaving endpoint cannot fill the disk before the post-download size check runs.
2466    let outcome = std::process::Command::new("curl")
2467        .args([
2468            "-L",
2469            "--fail",
2470            "--retry",
2471            "3",
2472            "-sS",
2473            "--proto",
2474            "=https",
2475            "--proto-redir",
2476            "=https",
2477            "--connect-timeout",
2478            "30",
2479            "--speed-limit",
2480            "1024",
2481            "--speed-time",
2482            "60",
2483            "--max-filesize",
2484        ])
2485        .arg(pinned_bytes.to_string())
2486        .arg("-o")
2487        .arg(staging)
2488        .arg(url)
2489        .status();
2490    match outcome {
2491        Err(error) if error.kind() == io::ErrorKind::NotFound => Err(FttsError::Generic(
2492            "`ftts pull` downloads with the system `curl`, which was not found on PATH; \
2493             install curl and retry, or download the release assets by hand"
2494                .to_owned(),
2495        )),
2496        Err(error) => Err(FttsError::Generic(format!("cannot run curl: {error}"))),
2497        Ok(status) if status.success() => Ok(()),
2498        Ok(status) => Err(FttsError::Generic(format!(
2499            "curl failed downloading {url} ({status}); check network access and retry `ftts pull`"
2500        ))),
2501    }
2502}
2503
2504/// Size first, digest second, both against the embedded pins.
2505fn verify_pulled_file(path: &Path, file: &ModelManifestFile) -> Result<(), FttsError> {
2506    let metadata = fs::metadata(path).map_err(|error| {
2507        FttsError::Generic(format!(
2508            "cannot stat downloaded {}: {error}",
2509            path.display()
2510        ))
2511    })?;
2512    if metadata.len() != file.bytes {
2513        return Err(FttsError::ArtifactFormat(format!(
2514            "downloaded {} is {} bytes, expected {}; the incomplete download was discarded, retry `ftts pull`",
2515            file.asset,
2516            metadata.len(),
2517            file.bytes
2518        )));
2519    }
2520    let digest = ftts_artifacts::sha256::hex_digest_file(path).map_err(|error| {
2521        FttsError::Generic(format!(
2522            "cannot hash downloaded {}: {error}",
2523            path.display()
2524        ))
2525    })?;
2526    if digest != file.sha256 {
2527        return Err(FttsError::ArtifactFormat(format!(
2528            "downloaded {} carries sha256 {digest}, expected {}; the corrupt download was discarded, retry `ftts pull`",
2529            file.asset, file.sha256
2530        )));
2531    }
2532    Ok(())
2533}
2534
2535/// `<dest>.part`, in the destination directory so the final rename never crosses a filesystem.
2536fn pull_staging_path(dest: &Path) -> PathBuf {
2537    let mut name = dest.file_name().map(OsString::from).unwrap_or_default();
2538    name.push(".part");
2539    dest.with_file_name(name)
2540}
2541
2542/// Downloads one asset to `<dest>.part`, verifies it against the pins, and atomically publishes.
2543///
2544/// A staging file that fails verification is removed. This is the opposite of `convert`'s
2545/// retained staging, on purpose: a failed local conversion is diagnosable evidence, while a
2546/// corrupt download says nothing beyond "the network truncated it", and a multi-gigabyte corpse
2547/// in the cache directory helps no one.
2548fn pull_one_file(
2549    manifest: &ModelManifest,
2550    file: &ModelManifestFile,
2551    dest: &Path,
2552) -> Result<(), FttsError> {
2553    if let Some(parent) = dest.parent() {
2554        fs::create_dir_all(parent).map_err(|error| {
2555            FttsError::Generic(format!(
2556                "cannot create model directory {}: {error}",
2557                parent.display()
2558            ))
2559        })?;
2560    }
2561    let staging = pull_staging_path(dest);
2562    // The staging name is predictable, and `curl -o` opens it with a plain create-or-truncate
2563    // that follows symlinks. A pre-planted entry (stale crash debris, or a symlink in a shared
2564    // model directory) must be cleared first, checked via symlink_metadata so a link is seen as
2565    // itself rather than its target.
2566    match fs::symlink_metadata(&staging) {
2567        Ok(_) => fs::remove_file(&staging).map_err(|error| {
2568            FttsError::Generic(format!(
2569                "cannot clear stale staging file {}: {error}",
2570                staging.display()
2571            ))
2572        })?,
2573        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2574        Err(error) => {
2575            return Err(FttsError::Generic(format!(
2576                "cannot stat staging path {}: {error}",
2577                staging.display()
2578            )));
2579        }
2580    }
2581    let url = manifest.download_url(file);
2582    let outcome = download_with_curl(&url, &staging, file.bytes)
2583        .and_then(|()| verify_pulled_file(&staging, file));
2584    if let Err(error) = outcome {
2585        let _ = fs::remove_file(&staging);
2586        return Err(error);
2587    }
2588    // Durability before publish: rename orders the directory entry, not the data. Without the
2589    // fsync a crash can leave a truncated file at the verified name — and the tokenizer/codec
2590    // sidecars, unlike the .fttsq, carry no load-time digest to catch that. Same contract as
2591    // FttsqWriter::write_to_path. The handle must be writable: Windows refuses to flush a
2592    // read-only handle (ERROR_ACCESS_DENIED), while Unix fsync accepts one.
2593    fs::OpenOptions::new()
2594        .write(true)
2595        .open(&staging)
2596        .and_then(|file| file.sync_all())
2597        .map_err(|error| {
2598            FttsError::Generic(format!(
2599                "cannot fsync downloaded {}: {error}",
2600                staging.display()
2601            ))
2602        })?;
2603    fs::rename(&staging, dest).map_err(|error| {
2604        FttsError::Generic(format!(
2605            "downloaded {} verified but could not be published to {}: {error}",
2606            file.asset,
2607            dest.display()
2608        ))
2609    })
2610}
2611
2612fn run_pull(
2613    args: &PullArgs,
2614    environment: &Environment,
2615    stdout: &mut dyn Write,
2616) -> Result<(), FttsError> {
2617    let manifest = ModelManifest::embedded()?;
2618    let destination = match &args.model {
2619        Some(path) => path.clone(),
2620        None => default_pull_model_dir(environment).ok_or_else(|| {
2621            FttsError::Usage(
2622                "cannot choose a model directory: pass --model PATH, or set FTTS_MODEL_DIR or HOME"
2623                    .to_owned(),
2624            )
2625        })?,
2626    };
2627    writeln!(
2628        stdout,
2629        "pulling {} ({} files, {} bytes) into {}",
2630        manifest.model_id,
2631        manifest.files.len(),
2632        manifest.total_bytes(),
2633        destination.display()
2634    )
2635    .map_err(output_error)?;
2636    for file in &manifest.files {
2637        let dest = destination.join(&file.dest);
2638        match pull_decision(&dest, file, args.force) {
2639            PullDecision::Skip => writeln!(
2640                stdout,
2641                "{} ({} bytes): already present, verified",
2642                file.dest, file.bytes
2643            )
2644            .map_err(output_error)?,
2645            PullDecision::Download => {
2646                writeln!(stdout, "{} ({} bytes): downloading", file.dest, file.bytes)
2647                    .map_err(output_error)?;
2648                pull_one_file(&manifest, file, &dest)?;
2649                writeln!(stdout, "{} ({} bytes): verified", file.dest, file.bytes)
2650                    .map_err(output_error)?;
2651            }
2652        }
2653    }
2654    writeln!(stdout, "model ready at {}", destination.display()).map_err(output_error)
2655}
2656
2657fn resolve_existing_file<'a>(path: &'a Path, label: &str) -> Result<&'a Path, FttsError> {
2658    if path.is_file() {
2659        Ok(path)
2660    } else {
2661        Err(FttsError::ModelNotFound(format!(
2662            "{label} {} does not exist or is not a file; use an existing PATH",
2663            path.display()
2664        )))
2665    }
2666}
2667
2668/// Preflight admission for `say --check`, computed by the **engine**, not by the CLI.
2669///
2670/// This used to be a CLI-local heuristic whose own text said "model-specific KV and memory
2671/// admission is pending the V_REL engine". That engine now exists, so the preflight calls
2672/// [`ftts_core::admission`] directly. The point is not code reuse: it is that `--check` and the
2673/// synthesis that follows it must reach the *same* verdict for the same request. A preflight that
2674/// says yes and an engine that then says no is worse than no preflight, because the caller
2675/// budgeted on the first answer.
2676///
2677/// Prompt length is not knowable before tokenization, so `--check` reports the admission decision
2678/// for an *estimated* prompt length and labels it as such. The binding decision remains the
2679/// engine's, taken after real tokenization.
2680fn admission_plan(text: &str, settings: &EffectiveSettings) -> Result<Value, FttsError> {
2681    if text.len() > SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES {
2682        return Err(FttsError::BudgetTimeout(format!(
2683            "text is {} bytes, above the Phase-0 admission bound of {} bytes; split the document before retrying",
2684            text.len(),
2685            SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES
2686        )));
2687    }
2688
2689    let characters = text.chars().count();
2690    // A deliberately conservative stand-in until the tokenizer is on this path: over-estimating
2691    // the prompt can only make the preflight refuse something the engine would admit, which is the
2692    // safe direction. Under-estimating would promise capacity that is not there.
2693    let estimated_prompt_tokens = u64::try_from(characters).unwrap_or(u64::MAX);
2694    // The engine's own env-resolved policy (FTTS_MEMORY_BUDGET_MB / FTTS_MAX_FRAMES), not a copy
2695    // of it — a second parse of the same variables is a second thing to drift.
2696    let policy = ftts_core::process_engine_config().admission;
2697
2698    match policy.admit(estimated_prompt_tokens) {
2699        Ok(plan) => Ok(json!({
2700            "status": "accepted",
2701            "scope": "preflight on an ESTIMATED prompt length; the binding decision is the \
2702                      engine's, taken after tokenization",
2703            "text_bytes": text.len(),
2704            "text_characters": characters,
2705            "estimated_prompt_tokens": estimated_prompt_tokens,
2706            "predicted_max_frames": plan.predicted_max_frames,
2707            "predicted_peak_bytes": plan.predicted_peak_bytes,
2708            "budget_bytes": plan.budget_bytes,
2709            "binding_constraint": plan.binding_constraint.as_str(),
2710            "packet_frames": settings.packet_frames.as_str(),
2711            "profile": settings.profile.as_str(),
2712        })),
2713        // AdmissionRejection's Display already carries the shortfall, the binding constraint and
2714        // what to do about it, so it is passed through rather than re-summarised into something
2715        // less specific.
2716        Err(rejection) => Err(FttsError::BudgetTimeout(rejection.to_string())),
2717    }
2718}
2719
2720fn run_voice_inspect(path: &Path, stdout: &mut dyn Write) -> Result<(), FttsError> {
2721    let path = resolve_existing_file(path, "voice pack")?;
2722    write_json_line(
2723        stdout,
2724        &json!({
2725            "schema_version": ROBOT_SCHEMA_VERSION,
2726            "event": "voice_inspect",
2727            "path": path.display().to_string(),
2728            "status": "header_inspection_pending_artifact_reader",
2729        }),
2730    )
2731}
2732
2733fn run_robot(
2734    command: RobotCommand,
2735    environment: &Environment,
2736    stdout: &mut dyn Write,
2737) -> Result<(), FttsError> {
2738    // Every object below is built from `robot::EventType`, so the discriminator and
2739    // schema_version cannot be forgotten, and the frozen contract test in ftts-conformance
2740    // fails if any of these stops matching the catalogue.
2741    let event = match command {
2742        RobotCommand::Schema => robot::schema_document(robot::DOCUMENTED_ENVIRONMENT),
2743        RobotCommand::Health => {
2744            let searched = model_search_paths(environment);
2745            let found = searched.iter().find(|path| looks_like_model_artifact(path));
2746            let mut object = robot::EventType::Health.event();
2747            object.insert("status".to_owned(), json!("phase0_skeleton"));
2748            object.insert("model_loaded".to_owned(), json!(false));
2749            // Presence is a magic-bytes header sniff, never a tensor load: `robot health` must
2750            // stay cheap enough for an agent to call it on every invocation.
2751            object.insert("model_present".to_owned(), json!(found.is_some()));
2752            object.insert(
2753                "model_path".to_owned(),
2754                json!(found.map(|path| path.display().to_string())),
2755            );
2756            object.insert(
2757                "model_dir".to_owned(),
2758                json!(environment.value("FTTS_MODEL_DIR")),
2759            );
2760            // Every directory consulted, so a resolution failure is actionable rather than a
2761            // bare "not found".
2762            object.insert(
2763                "searched".to_owned(),
2764                json!(
2765                    searched
2766                        .iter()
2767                        .map(|path| path.display().to_string())
2768                        .collect::<Vec<_>>()
2769                ),
2770            );
2771            object.insert("stateless_default".to_owned(), json!(true));
2772            object.insert(
2773                "threads".to_owned(),
2774                json!(
2775                    environment
2776                        .value("FTTS_THREADS")
2777                        .and_then(|value| value.parse::<u64>().ok())
2778                ),
2779            );
2780            object.insert(
2781                "recommended_command".to_owned(),
2782                json!("ftts say --check --model PATH TEXT"),
2783            );
2784            Value::Object(object)
2785        }
2786        RobotCommand::Backends => {
2787            let mut object = robot::EventType::Backends.event();
2788            // Capability vs executed-route split: `available` is every tier this build can
2789            // certify on this CPU; `dispatched` is the one the int8 route would actually run.
2790            object.insert(
2791                "available".to_owned(),
2792                json!(
2793                    ftts_kernels::int8::Int8Tier::available()
2794                        .iter()
2795                        .map(|tier| tier.as_str())
2796                        .collect::<Vec<_>>()
2797                ),
2798            );
2799            object.insert(
2800                "dispatched".to_owned(),
2801                json!(ftts_kernels::int8::Int8Tier::dispatch().as_str()),
2802            );
2803            object.insert("isa_features".to_owned(), json!(detected_isa_features()));
2804            let plan = ftts_kernels::int8::autotuned_plan();
2805            object.insert(
2806                "kernel_plan".to_owned(),
2807                json!({
2808                    "version": 0,
2809                    "decode_gemv": plan.decode_gemv.as_str(),
2810                    "batch_gemm": plan.batch_gemm.as_str(),
2811                    "persisted": false,
2812                }),
2813            );
2814            object.insert("pool_sizing".to_owned(), Value::Null);
2815            object.insert(
2816                "force_arch".to_owned(),
2817                json!(environment.value("FTTS_FORCE_ARCH")),
2818            );
2819            Value::Object(object)
2820        }
2821        RobotCommand::Selftest => {
2822            // The permanent integer-kernel law, executed on the end user's silicon: every census
2823            // binding row through the real dot kernels on every dispatchable tier. The event's
2824            // top-level fields are pinned by the frozen v1 schema fixture (status/reason/checks);
2825            // per-row detail lives inside `checks`.
2826            let report = ftts_kernels::selftest::run_selftest();
2827            let checks: Vec<Value> = report
2828                .checks
2829                .iter()
2830                .map(|check| {
2831                    json!({
2832                        "row": check.row.id,
2833                        "scope": check.row.scope.as_str(),
2834                        "census_tensor": check.row.census_tensor,
2835                        "reduction_k": check.row.reduction_k,
2836                        "tier": check.tier.as_str(),
2837                        "contract": check.contract.as_str(),
2838                        "dispatched": check.tier == report.dispatched,
2839                        "accumulator_i32": check.accumulator_i32,
2840                        "reference_i64": check.reference_i64,
2841                        "passed": check.passed,
2842                    })
2843                })
2844                .collect();
2845            let mut object = robot::EventType::Selftest.event();
2846            object.insert(
2847                "status".to_owned(),
2848                json!(if report.passed() { "passed" } else { "failed" }),
2849            );
2850            object.insert("reason".to_owned(), Value::Null);
2851            object.insert("checks".to_owned(), json!(checks));
2852            Value::Object(object)
2853        }
2854    };
2855    write_json_line(stdout, &event)
2856}
2857
2858/// Every path the model resolver consults, in order.
2859///
2860/// Shared by `robot health` and the resolution error so the two can never disagree about what
2861/// was searched — a "not found" that lists different directories than `health` reports is worse
2862/// than no list at all.
2863fn model_search_paths(environment: &Environment) -> Vec<PathBuf> {
2864    let mut searched = environment
2865        .value("FTTS_MODEL_DIR")
2866        .map(std::env::split_paths)
2867        .map(|paths| {
2868            paths
2869                .map(|path| path.join(MODEL_BASENAME))
2870                .collect::<Vec<_>>()
2871        })
2872        .unwrap_or_default();
2873    if let Some(home) = std::env::var_os("HOME") {
2874        let home = PathBuf::from(home);
2875        searched.push(home.join(".cache/franken_tts/models").join(MODEL_BASENAME));
2876        // The `ftts pull` destination, listed after the legacy plural directory so existing
2877        // installs keep winning; it is also the bundle-directory fallback in `resolve_model_from`,
2878        // which is what lets a bare `ftts pull` then `ftts say` work with no env var at all.
2879        searched.push(home.join(DEFAULT_MODEL_CACHE_SUBDIR).join(MODEL_BASENAME));
2880    }
2881    searched
2882}
2883
2884/// A cheap header sniff: is there a plausible `.fttsq` artifact at this path?
2885///
2886/// Reads the magic bytes only. Deliberately never opens the tensor data — `robot health` is
2887/// meant to be callable on every agent invocation, and a multi-gigabyte read would make it a
2888/// thing agents avoid calling, which defeats the point.
2889fn looks_like_model_artifact(path: &Path) -> bool {
2890    use std::io::Read as _;
2891
2892    let Ok(mut file) = fs::File::open(path) else {
2893        return false;
2894    };
2895    let mut magic = [0u8; 5];
2896    file.read_exact(&mut magic).is_ok() && &magic == b"FTTSQ"
2897}
2898
2899/// ISA features detected at runtime, for `robot backends`.
2900///
2901/// Reported as a plain list so an agent can see what the dispatcher had available; the kernel
2902/// tiers themselves land with the Phase-3 engines.
2903fn detected_isa_features() -> Vec<&'static str> {
2904    let mut features = Vec::new();
2905    #[cfg(target_arch = "aarch64")]
2906    {
2907        if std::arch::is_aarch64_feature_detected!("neon") {
2908            features.push("neon");
2909        }
2910        if std::arch::is_aarch64_feature_detected!("dotprod") {
2911            features.push("dotprod");
2912        }
2913        if std::arch::is_aarch64_feature_detected!("i8mm") {
2914            features.push("i8mm");
2915        }
2916    }
2917    #[cfg(target_arch = "x86_64")]
2918    {
2919        if std::arch::is_x86_feature_detected!("avx2") {
2920            features.push("avx2");
2921        }
2922        if std::arch::is_x86_feature_detected!("avxvnni") {
2923            features.push("avx-vnni");
2924        }
2925        if std::arch::is_x86_feature_detected!("avx512vnni") {
2926            features.push("avx512-vnni");
2927        }
2928    }
2929    features
2930}
2931
2932fn run_doctor(
2933    args: &DoctorArgs,
2934    environment: &Environment,
2935    stdout: &mut dyn Write,
2936) -> Result<(), FttsError> {
2937    let report = json!({
2938        "schema_version": ROBOT_SCHEMA_VERSION,
2939        "status": "phase0_skeleton",
2940        "stateless_default": true,
2941        "persistent_history": false,
2942        "environment": environment.documented_values(),
2943        "recommended_command": "ftts robot schema",
2944    });
2945    if args.json {
2946        write_json_line(stdout, &report)
2947    } else {
2948        writeln!(stdout, "FrankenTTS Phase-0 CLI skeleton")
2949            .and_then(|_| writeln!(stdout, "stateless default: yes"))
2950            .and_then(|_| writeln!(stdout, "model loaded: no"))
2951            .and_then(|_| writeln!(stdout, "next: ftts robot schema"))
2952            .map_err(output_error)
2953    }
2954}
2955
2956fn write_json_line(writer: &mut dyn Write, value: &Value) -> Result<(), FttsError> {
2957    serde_json::to_writer(&mut *writer, value)
2958        .map_err(|error| FttsError::Generic(format!("cannot serialize CLI JSON: {error}")))?;
2959    writer.write_all(b"\n").map_err(output_error)
2960}
2961
2962fn output_error(error: io::Error) -> FttsError {
2963    FttsError::Generic(format!("cannot write CLI output: {error}"))
2964}
2965
2966#[cfg(test)]
2967mod tests {
2968    use super::*;
2969    use std::io::Cursor;
2970
2971    #[test]
2972    fn preset_voices_are_valid_speaker_vectors() {
2973        assert!(
2974            PRESET_VOICES
2975                .iter()
2976                .any(|(name, _, _)| *name == DEFAULT_PRESET_VOICE),
2977            "the default preset must exist in the table"
2978        );
2979        for (name, character, bytes) in PRESET_VOICES {
2980            assert_eq!(
2981                bytes.len(),
2982                synth::SPEAKER_VECTOR_BYTES,
2983                "preset {name} must be exactly one 1,024-float x-vector"
2984            );
2985            assert!(
2986                !character.is_empty(),
2987                "preset {name} needs a character line"
2988            );
2989            for chunk in bytes.as_chunks::<4>().0 {
2990                let value = f32::from_le_bytes(*chunk);
2991                assert!(
2992                    value.is_finite(),
2993                    "preset {name} carries a non-finite value"
2994                );
2995            }
2996        }
2997    }
2998
2999    #[test]
3000    fn preset_names_resolve_and_unknown_names_do_not() {
3001        let environment = Environment {
3002            values: BTreeMap::new(),
3003            stage_budget_values: BTreeMap::new(),
3004        };
3005        let resolved = resolve_requested_voice(Some(Path::new("aria")), &environment)
3006            .expect("preset name resolves")
3007            .expect("preset yields a path");
3008        let bytes = fs::read(&resolved).expect("materialized preset readable");
3009        assert_eq!(bytes.len(), synth::SPEAKER_VECTOR_BYTES);
3010
3011        let error = resolve_requested_voice(Some(Path::new("no-such-voice")), &environment)
3012            .expect_err("unknown names are refused");
3013        assert!(
3014            error.to_string().contains("aria"),
3015            "the refusal must list the built-in names, got: {error}"
3016        );
3017    }
3018
3019    // Updated 2026-08-10 for the `make-video` subcommand, which landed without re-baselining this
3020    // snapshot. The snapshot exists to make CLI-surface changes deliberate rather than accidental,
3021    // so it is re-baselined only alongside a real, intended command — never widened to stop failing.
3022    const CLAP_SURFACE_SNAPSHOT: &str = "commands=say,make-video,enroll,voice,convert,pull,robot,doctor,resident-daemon\nrobot=schema,health,backends,selftest\nsay=file,model,voice,output,stream,check,robot,no-resident\npull=model,force\nglobal=profile,packet-frames,math-mode,voice-pack,normalize,trace,seed\n";
3023
3024    #[test]
3025    fn clap_surface_matches_snapshot() {
3026        let command = Cli::command();
3027        let commands = command
3028            .get_subcommands()
3029            .map(|command| command.get_name())
3030            .collect::<Vec<_>>()
3031            .join(",");
3032        let robot = command
3033            .get_subcommands()
3034            .find(|command| command.get_name() == "robot")
3035            .expect("robot subcommand")
3036            .get_subcommands()
3037            .map(|command| command.get_name())
3038            .collect::<Vec<_>>()
3039            .join(",");
3040        let say = command
3041            .get_subcommands()
3042            .find(|command| command.get_name() == "say")
3043            .expect("say subcommand")
3044            .get_arguments()
3045            .filter_map(|argument| argument.get_long())
3046            .collect::<Vec<_>>()
3047            .join(",");
3048        let pull = command
3049            .get_subcommands()
3050            .find(|command| command.get_name() == "pull")
3051            .expect("pull subcommand")
3052            .get_arguments()
3053            .filter_map(|argument| argument.get_long())
3054            .collect::<Vec<_>>()
3055            .join(",");
3056        let global = command
3057            .get_arguments()
3058            .filter_map(|argument| argument.get_long())
3059            .filter(|argument| *argument != "help")
3060            .collect::<Vec<_>>()
3061            .join(",");
3062        let actual = format!(
3063            "commands={commands}\nrobot={robot}\nsay={say}\npull={pull}\nglobal={global}\n"
3064        );
3065        assert_eq!(actual, CLAP_SURFACE_SNAPSHOT);
3066    }
3067
3068    #[test]
3069    fn argument_file_and_stdin_text_are_identical() {
3070        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../README.md");
3071        let expected = fs::read_to_string(&root).expect("checked-in README");
3072        let from_argument = read_text(
3073            &SayArgs {
3074                output_positional: None,
3075                text: Some(expected.clone()),
3076                file: None,
3077                model: None,
3078                voice: None,
3079                output: None,
3080                stream: None,
3081                check: true,
3082                robot: false,
3083                no_resident: true,
3084            },
3085            &mut Cursor::new(Vec::<u8>::new()),
3086        )
3087        .expect("argument text");
3088        let from_file = read_text(
3089            &SayArgs {
3090                output_positional: None,
3091                text: None,
3092                file: Some(root),
3093                model: None,
3094                voice: None,
3095                output: None,
3096                stream: None,
3097                check: true,
3098                robot: false,
3099                no_resident: true,
3100            },
3101            &mut Cursor::new(Vec::<u8>::new()),
3102        )
3103        .expect("file text");
3104        let from_stdin = read_text(
3105            &SayArgs {
3106                output_positional: None,
3107                text: Some("-".to_owned()),
3108                file: None,
3109                model: None,
3110                voice: None,
3111                output: None,
3112                stream: None,
3113                check: true,
3114                robot: false,
3115                no_resident: true,
3116            },
3117            &mut Cursor::new(expected.as_bytes()),
3118        )
3119        .expect("stdin text");
3120
3121        assert_eq!(from_argument, from_file);
3122        assert_eq!(from_argument, from_stdin);
3123    }
3124
3125    #[test]
3126    fn check_plan_is_deterministic_and_marks_its_scope() {
3127        let settings = EffectiveSettings {
3128            profile: ExecutionProfile::Strict,
3129            packet_frames: PacketFrames::Four,
3130            math_mode: MathMode::Strict,
3131            voice_pack: VoicePackProfile::Portable,
3132            normalize: NormalizeMode::Conservative,
3133        };
3134        let first = admission_plan("hello", &settings).expect("admission plan");
3135        let second = admission_plan("hello", &settings).expect("admission plan");
3136        assert_eq!(first, second);
3137        assert_eq!(first["status"], "accepted");
3138        // The preflight is explicit that it estimates the prompt and that the engine decides.
3139        assert!(
3140            first["scope"]
3141                .as_str()
3142                .unwrap_or_default()
3143                .contains("ESTIMATED")
3144        );
3145    }
3146
3147    #[test]
3148    fn the_cli_preflight_and_the_engine_agree_on_the_same_request() {
3149        // The property that matters: `--check` saying yes and the engine then saying no is worse
3150        // than no preflight, because the caller budgeted on the first answer. Both must be the
3151        // same computation, not two implementations of the same rule.
3152        let settings = EffectiveSettings {
3153            profile: ExecutionProfile::Balanced,
3154            packet_frames: PacketFrames::Four,
3155            math_mode: MathMode::Strict,
3156            voice_pack: VoicePackProfile::Portable,
3157            normalize: NormalizeMode::Verbatim,
3158        };
3159        let text = "a moderately sized utterance for admission";
3160        let plan = admission_plan(text, &settings).expect("preflight admits");
3161
3162        let policy = ftts_core::process_engine_config().admission;
3163        let engine = policy
3164            .admit(text.chars().count() as u64)
3165            .expect("engine admits the same request");
3166
3167        assert_eq!(plan["predicted_peak_bytes"], engine.predicted_peak_bytes);
3168        assert_eq!(plan["predicted_max_frames"], engine.predicted_max_frames);
3169        assert_eq!(plan["budget_bytes"], engine.budget_bytes);
3170        assert_eq!(
3171            plan["binding_constraint"],
3172            engine.binding_constraint.as_str()
3173        );
3174    }
3175
3176    #[test]
3177    fn a_wav_sink_writes_a_playable_file_and_conforming_audio_chunk_events() {
3178        let dir = std::env::temp_dir().join(format!("ftts-wav-sink-{}", std::process::id()));
3179        std::fs::create_dir_all(&dir).expect("temp dir");
3180        let path = dir.join("out.wav");
3181
3182        let frame: Vec<f32> = (0..1_920)
3183            .map(|i| (i as f32 / 1_920.0 * std::f32::consts::TAU).sin() * 0.5)
3184            .collect();
3185        let mut sink = AudioOutput::wav(&path).expect("wav sink");
3186        let mut discard = Vec::new();
3187
3188        let first = sink
3189            .write_packet(&frame, &mut discard, "run-1", 1)
3190            .expect("packet 1");
3191        let second = sink
3192            .write_packet(&frame, &mut discard, "run-1", 1)
3193            .expect("packet 2");
3194
3195        // Every emitted object must satisfy the frozen robot contract, not merely look plausible.
3196        assert!(robot::validate_event(&first).is_empty(), "{first:?}");
3197        assert!(robot::validate_event(&second).is_empty(), "{second:?}");
3198        assert_eq!(first["sink"], "file");
3199        assert_eq!(first["byte_offset"], 0);
3200        assert_eq!(first["bytes"], 1_920 * 2);
3201        assert_eq!(first["duration_ms"], 80, "1,920 samples at 24 kHz is 80 ms");
3202        // The offset is cumulative, so a consumer can seek with it.
3203        assert_eq!(second["byte_offset"], 1_920 * 2);
3204
3205        let samples = sink.finish().expect("finish");
3206        assert_eq!(samples, 1_920 * 2);
3207
3208        // The file on disk must describe exactly what it holds.
3209        let bytes = std::fs::read(&path).expect("read wav");
3210        assert_eq!(&bytes[0..4], b"RIFF");
3211        assert_eq!(&bytes[8..12], b"WAVE");
3212        let declared = u32::from_le_bytes(bytes[40..44].try_into().expect("data size"));
3213        assert_eq!(declared as usize, 1_920 * 2 * 2);
3214        assert_eq!(bytes.len(), 44 + 1_920 * 2 * 2);
3215        assert!(
3216            discard.is_empty(),
3217            "a file sink must not also emit raw PCM to the stream"
3218        );
3219    }
3220
3221    #[test]
3222    fn a_raw_sink_writes_pcm_to_the_stream_and_never_mixes_it_with_events() {
3223        // The stream contract: under --stream raw, stdout carries PCM only. An event object landing
3224        // in the same buffer would corrupt both — the audio and the NDJSON.
3225        let mut sink = AudioOutput::raw();
3226        let mut raw = Vec::new();
3227        let pcm = vec![0.5f32; 4];
3228        let event = sink
3229            .write_packet(&pcm, &mut raw, "run-1", 1)
3230            .expect("packet");
3231
3232        assert!(robot::validate_event(&event).is_empty(), "{event:?}");
3233        assert_eq!(event["sink"], "stdout");
3234        assert_eq!(raw.len(), 8, "four 16-bit samples");
3235        let first = i16::from_le_bytes([raw[0], raw[1]]);
3236        assert_eq!(first, ftts_core::audio::sample_to_i16(0.5));
3237        // The PCM buffer must contain no JSON.
3238        assert!(
3239            !raw.windows(2).any(|w| w == b"{\""),
3240            "raw PCM stream must never contain an event object"
3241        );
3242    }
3243
3244    #[test]
3245    fn a_none_sink_still_reports_conforming_events() {
3246        let mut sink = AudioOutput::none();
3247        let mut discard = Vec::new();
3248        let event = sink
3249            .write_packet(&[0.0f32; 960], &mut discard, "run-1", 2)
3250            .expect("packet");
3251        assert!(robot::validate_event(&event).is_empty(), "{event:?}");
3252        assert_eq!(event["sink"], "none");
3253        assert_eq!(event["packet_frames"], "2");
3254        assert!(discard.is_empty());
3255        assert_eq!(sink.finish().expect("finish"), 960);
3256    }
3257
3258    #[test]
3259    fn a_health_violation_renders_as_a_contract_conforming_robot_event() {
3260        // The engine-to-wire seam: the violation's class, remedy and invalidates_output must
3261        // survive the crossing, and the result must satisfy the frozen robot contract.
3262        let silent =
3263            ftts_core::HealthEvent::Violation(ftts_core::health::HealthViolation::OutputSilent {
3264                silent_millis: 1_500,
3265            });
3266        let event = robot::health_violation_event("run-1", silent, 42);
3267        assert!(
3268            robot::validate_event(&event).is_empty(),
3269            "{:?}",
3270            robot::validate_event(&event)
3271        );
3272        assert_eq!(event["event"], "health_violation");
3273        assert_eq!(event["violation"], "output_silent");
3274        assert_eq!(event["invalidates_output"], true);
3275        assert!(event["detail"].as_str().expect("detail").contains("1500"));
3276        assert!(event["remedy"].as_str().expect("remedy").len() > 40);
3277
3278        // A kernel demotion is informational: the run stayed correct, just slower. If this were
3279        // reported as invalidating, an agent would discard good audio.
3280        let demoted =
3281            ftts_core::HealthEvent::Violation(ftts_core::health::HealthViolation::KernelDemoted {
3282                from: ftts_core::health::KernelTier::Optimized("i8mm"),
3283                to: ftts_core::health::KernelTier::Scalar,
3284            });
3285        let event = robot::health_violation_event("run-1", demoted, 43);
3286        assert!(robot::validate_event(&event).is_empty());
3287        assert_eq!(event["invalidates_output"], false);
3288
3289        // Budget and cancellation are health signals too, and both truncate the audio.
3290        for event in [
3291            ftts_core::HealthEvent::BudgetExceeded,
3292            ftts_core::HealthEvent::Cancelled,
3293        ] {
3294            let rendered = robot::health_violation_event("run-1", event, 44);
3295            assert!(robot::validate_event(&rendered).is_empty());
3296            assert_eq!(rendered["invalidates_output"], true);
3297        }
3298    }
3299
3300    #[test]
3301    fn normalization_defaults_to_verbatim_conformance_mode() {
3302        let cli = Cli {
3303            profile: None,
3304            packet_frames: None,
3305            math_mode: None,
3306            voice_pack: None,
3307            normalize: None,
3308            trace: None,
3309            seed: None,
3310            command: Command::Robot(RobotArgs {
3311                command: RobotCommand::Health,
3312            }),
3313        };
3314        assert_eq!(
3315            EffectiveSettings::resolve(&cli, &Environment::default())
3316                .expect("default settings")
3317                .normalize,
3318            NormalizeMode::Verbatim
3319        );
3320        assert_eq!(
3321            EffectiveSettings::resolve(&cli, &Environment::default())
3322                .expect("default settings")
3323                .normalization_options(),
3324            NormalizationOptions::default(),
3325            "CLI defaults must use the same verbatim options as the library"
3326        );
3327    }
3328
3329    #[test]
3330    fn cli_normalization_modes_map_to_shared_engine_options() {
3331        for (cli_mode, engine_mode) in [
3332            (NormalizeMode::Verbatim, NormalizationMode::Verbatim),
3333            (NormalizeMode::Conservative, NormalizationMode::Conservative),
3334            (NormalizeMode::LocaleAware, NormalizationMode::LocaleAware),
3335        ] {
3336            let settings = EffectiveSettings {
3337                profile: ExecutionProfile::Balanced,
3338                packet_frames: PacketFrames::Four,
3339                math_mode: MathMode::Strict,
3340                voice_pack: VoicePackProfile::Portable,
3341                normalize: cli_mode,
3342            };
3343            assert_eq!(settings.normalization_options().mode, engine_mode);
3344        }
3345    }
3346
3347    #[test]
3348    fn pinned_main_conversion_plan_preserves_the_reviewed_q8_boundary() {
3349        let specs = pinned_main_tensor_specs().expect("checked-in main inventory parses");
3350        let (_manifest, _plan) =
3351            pinned_main_conversion_plan().expect("checked-in main conversion plan builds");
3352        assert_eq!(specs.len(), PINNED_MAIN_TENSOR_COUNT);
3353        assert_eq!(
3354            specs
3355                .iter()
3356                .filter(|spec| spec.storage == TensorStoragePolicy::Q8PerOutputChannel)
3357                .count(),
3358            231,
3359            "28 talker + 5 microdecoder layers times seven attention/MLP projections"
3360        );
3361
3362        let text_embedding = specs
3363            .iter()
3364            .find(|spec| spec.name == "talker.model.text_embedding.weight");
3365        assert!(
3366            text_embedding.is_some(),
3367            "pinned inventory must contain the text embedding"
3368        );
3369        if let Some(text_embedding) = text_embedding {
3370            assert_eq!(text_embedding.storage, TensorStoragePolicy::Verbatim);
3371            assert_eq!(text_embedding.access_class, AccessClass::ColdTextEmbedding);
3372        }
3373
3374        let talker_projection = specs
3375            .iter()
3376            .find(|spec| spec.name == "talker.model.layers.0.mlp.down_proj.weight");
3377        assert!(
3378            talker_projection.is_some(),
3379            "pinned inventory must contain the talker projection"
3380        );
3381        if let Some(talker_projection) = talker_projection {
3382            assert_eq!(
3383                talker_projection.storage,
3384                TensorStoragePolicy::Q8PerOutputChannel
3385            );
3386            assert_eq!(
3387                talker_projection.access_class,
3388                AccessClass::HotRecurrentTalker
3389            );
3390        }
3391
3392        let micro_projection = specs
3393            .iter()
3394            .find(|spec| spec.name == "talker.code_predictor.model.layers.0.mlp.down_proj.weight");
3395        assert!(
3396            micro_projection.is_some(),
3397            "pinned inventory must contain the microdecoder projection"
3398        );
3399        if let Some(micro_projection) = micro_projection {
3400            assert_eq!(
3401                micro_projection.storage,
3402                TensorStoragePolicy::Q8PerOutputChannel
3403            );
3404            assert_eq!(
3405                micro_projection.access_class,
3406                AccessClass::HotRecurrentMicrodecoder
3407            );
3408        }
3409
3410        let primary_embedding = specs
3411            .iter()
3412            .find(|spec| spec.name == "talker.model.codec_embedding.weight");
3413        assert!(
3414            primary_embedding.is_some(),
3415            "pinned inventory must contain the primary-code embedding"
3416        );
3417        if let Some(primary_embedding) = primary_embedding {
3418            assert_eq!(
3419                primary_embedding.access_class,
3420                AccessClass::HotRecurrentMicrodecoder,
3421                "the primary-code embedding feeds residual depth one every frame"
3422            );
3423        }
3424
3425        let primary_head = specs
3426            .iter()
3427            .find(|spec| spec.name == "talker.codec_head.weight");
3428        assert!(
3429            primary_head.is_some(),
3430            "pinned inventory must contain the primary-code head"
3431        );
3432        if let Some(primary_head) = primary_head {
3433            assert_eq!(primary_head.storage, TensorStoragePolicy::Verbatim);
3434            assert_eq!(primary_head.access_class, AccessClass::HotRecurrentTalker);
3435        }
3436
3437        let text_projection = specs
3438            .iter()
3439            .find(|spec| spec.name == "talker.text_projection.linear_fc1.weight");
3440        assert!(
3441            text_projection.is_some(),
3442            "pinned inventory must contain the text-projection MLP"
3443        );
3444        if let Some(text_projection) = text_projection {
3445            assert_eq!(text_projection.storage, TensorStoragePolicy::Verbatim);
3446            assert_eq!(
3447                text_projection.access_class,
3448                AccessClass::HotRecurrentTalker
3449            );
3450        }
3451
3452        let head = specs
3453            .iter()
3454            .find(|spec| spec.name == "talker.code_predictor.lm_head.0.weight");
3455        assert!(
3456            head.is_some(),
3457            "pinned inventory must contain the residual-code head"
3458        );
3459        if let Some(head) = head {
3460            assert_eq!(head.storage, TensorStoragePolicy::Verbatim);
3461            assert_eq!(head.access_class, AccessClass::HotRecurrentMicrodecoder);
3462        }
3463
3464        let speaker = specs
3465            .iter()
3466            .find(|spec| spec.name == "speaker_encoder.fc.weight");
3467        assert!(
3468            speaker.is_some(),
3469            "pinned inventory must contain the speaker encoder"
3470        );
3471        if let Some(speaker) = speaker {
3472            assert_eq!(speaker.storage, TensorStoragePolicy::Verbatim);
3473            assert_eq!(speaker.access_class, AccessClass::EnrollmentSpeakerEncoder);
3474        }
3475    }
3476
3477    #[test]
3478    fn conversion_notice_carries_changes_and_the_full_license() {
3479        let notice = pinned_license_notice();
3480        assert!(notice.contains("Copyright 2026 Alibaba Cloud"));
3481        assert!(notice.contains("CHANGES: the original bfloat16 weights were converted"));
3482        assert!(notice.contains("Apache License"));
3483        assert!(notice.contains("TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION"));
3484    }
3485
3486    #[test]
3487    fn convert_refusal_still_emits_a_versioned_robot_lifecycle() {
3488        let cli = Cli {
3489            profile: None,
3490            packet_frames: None,
3491            math_mode: None,
3492            voice_pack: None,
3493            normalize: None,
3494            trace: None,
3495            seed: None,
3496            command: Command::Robot(RobotArgs {
3497                command: RobotCommand::Health,
3498            }),
3499        };
3500        let args = ConvertArgs {
3501            // A readable file with the wrong name reaches the explicit pinned-source refusal
3502            // before any destination is created.
3503            source: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"),
3504            output: PathBuf::from("never-created.fttsq"),
3505        };
3506        let mut stdout = Vec::new();
3507        let mut stderr = Vec::new();
3508        let error = run_convert(
3509            &cli,
3510            &args,
3511            &Environment::default(),
3512            &mut stdout,
3513            &mut stderr,
3514        )
3515        .expect_err("the non-pinned source must be refused");
3516        assert_eq!(error.exit_code(), FttsExitCode::Input);
3517
3518        let stdout = String::from_utf8(stdout).expect("NDJSON stdout");
3519        let stderr = String::from_utf8(stderr).expect("NDJSON stderr");
3520        assert!(robot::validate_ndjson(&stdout).is_empty());
3521        assert!(robot::validate_ndjson(&stderr).is_empty());
3522        let stdout_events = stdout
3523            .lines()
3524            .map(|line| serde_json::from_str::<Value>(line).expect("JSON event"))
3525            .collect::<Vec<_>>();
3526        assert_eq!(stdout_events[0]["event"], "run_start");
3527        assert_eq!(stdout_events[1]["event"], "stage");
3528        assert_eq!(
3529            serde_json::from_str::<Value>(stderr.trim()).expect("run error")["event"],
3530            "run_error"
3531        );
3532    }
3533
3534    #[test]
3535    fn say_check_emits_a_versioned_admission_outcome() {
3536        let model = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
3537        let cli = Cli {
3538            profile: Some(ExecutionProfile::Balanced),
3539            packet_frames: Some(PacketFrames::Four),
3540            math_mode: Some(MathMode::Strict),
3541            voice_pack: Some(VoicePackProfile::Portable),
3542            normalize: Some(NormalizeMode::Conservative),
3543            trace: None,
3544            seed: Some(7),
3545            command: Command::Robot(RobotArgs {
3546                command: RobotCommand::Health,
3547            }),
3548        };
3549        let args = SayArgs {
3550            text: Some("checked text".to_owned()),
3551            output_positional: None,
3552            file: None,
3553            model: Some(model),
3554            voice: None,
3555            output: None,
3556            stream: None,
3557            check: true,
3558            robot: false,
3559            no_resident: true,
3560        };
3561        let mut stdin = Cursor::new(Vec::<u8>::new());
3562        let mut stdout = Vec::new();
3563        let mut stderr = Vec::new();
3564
3565        run_say(
3566            &cli,
3567            &args,
3568            &Environment::default(),
3569            &mut stdin,
3570            &mut stdout,
3571            &mut stderr,
3572        )
3573        .expect("check path");
3574
3575        assert!(stderr.is_empty());
3576        let text = String::from_utf8(stdout).expect("utf-8 events");
3577
3578        // The whole emitted stream must conform, not just the event this test cares about.
3579        assert!(
3580            robot::validate_ndjson(&text).is_empty(),
3581            "emitted stream violates the contract: {:?}",
3582            robot::validate_ndjson(&text)
3583        );
3584
3585        let events: Vec<Value> = text
3586            .lines()
3587            .map(|line| serde_json::from_str(line).expect("one JSON object per line"))
3588            .collect();
3589        let names: Vec<&str> = events
3590            .iter()
3591            .map(|event| event["event"].as_str().expect("event name"))
3592            .collect();
3593        assert_eq!(
3594            names,
3595            vec![
3596                "run_start",
3597                "stage",
3598                "stage",
3599                "text_prepared",
3600                "stage",
3601                "stage",
3602                "check_complete",
3603                "run_complete",
3604            ],
3605            "the skeleton lifecycle must flow end-to-end on the empty pipeline"
3606        );
3607
3608        // Every event in a run repeats the same run_id, which is what lets an agent stitch a run
3609        // together across the two streams.
3610        let run_id = events[0]["run_id"]
3611            .as_str()
3612            .expect("run_start carries run_id");
3613        assert!(!run_id.is_empty());
3614        assert!(events.iter().all(|event| event["run_id"] == run_id));
3615        assert!(
3616            events
3617                .iter()
3618                .all(|event| event["schema_version"] == ROBOT_SCHEMA_VERSION)
3619        );
3620
3621        // Stage sequence numbers are dense and ordered, so a consumer can detect a dropped event.
3622        let seqs: Vec<u64> = events
3623            .iter()
3624            .filter(|event| event["event"] == "stage")
3625            .map(|event| event["seq"].as_u64().expect("seq"))
3626            .collect();
3627        assert_eq!(seqs, vec![0, 1, 2, 3]);
3628
3629        let check = &events[6];
3630        // "accepted", not "scaffold_accepted": the preflight is now the engine's own
3631        // ftts_core::admission computation rather than a CLI-local heuristic, so `--check` and the
3632        // synthesis that follows it cannot reach different verdicts for the same request.
3633        assert_eq!(check["admission"]["status"], "accepted");
3634        assert!(
3635            check["admission"]["predicted_peak_bytes"].is_u64(),
3636            "the engine-backed plan reports a real predicted peak"
3637        );
3638        assert_eq!(check["normalization_trace_requested"], false);
3639
3640        // text_prepared reports shape and provenance only; the input text must never appear.
3641        let prepared = &events[3];
3642        assert_eq!(prepared["char_count"], "checked text".chars().count());
3643        assert!(prepared["unicode_version"].is_string());
3644        assert!(
3645            !text.contains("checked text"),
3646            "the event stream must not carry the user's text"
3647        );
3648
3649        assert_eq!(events[7]["exit_code"], 0);
3650    }
3651
3652    #[test]
3653    fn a_newline_inside_a_field_cannot_break_ndjson_framing() {
3654        // The entire contract rests on one JSON object per line. serde_json escapes control
3655        // characters, so a message containing a newline stays one line and the newline survives as
3656        // data -- but nothing pinned that until now, and a hand-rolled serializer or a raw write
3657        // path would silently break every downstream parser.
3658        let run = robot::RunContext::with_id("r-test");
3659        let error = FttsError::Generic("first\nsecond".to_owned());
3660        let mut event = run.event(robot::EventType::RunError);
3661        event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
3662        event.insert("kind".to_owned(), json!(error.exit_code().description()));
3663        event.insert("message".to_owned(), json!(error.to_string()));
3664        event.insert("remediation".to_owned(), json!(error.remediation()));
3665        event.insert("elapsed_ms".to_owned(), json!(0));
3666        let value = Value::Object(event);
3667
3668        let mut buffer = Vec::new();
3669        write_json_line(&mut buffer, &value).expect("serializes");
3670        let text = String::from_utf8(buffer).expect("utf-8");
3671
3672        assert_eq!(
3673            text.lines().count(),
3674            1,
3675            "framing broken by an embedded newline"
3676        );
3677        assert!(robot::validate_ndjson(&text).is_empty());
3678        let parsed: Value = serde_json::from_str(text.trim_end()).expect("still one object");
3679        assert!(
3680            parsed["message"].as_str().expect("message").contains('\n'),
3681            "the newline must survive as data, not be stripped"
3682        );
3683    }
3684
3685    #[test]
3686    fn pinned_copies_match_the_truth_pack_canonicals() {
3687        //  `pinned/` exists because `cargo package` cannot ship the truth pack. The truth pack
3688        //  stays canonical; a drifted copy would embed a stale pin assertion or attribution in
3689        //  the shipped binary, so byte-identity is asserted whenever the repo checkout is present.
3690        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
3691        for (canonical, embedded, name) in [
3692            (
3693                "docs/truth-pack/TENSOR_INVENTORY.json",
3694                PINNED_TENSOR_INVENTORY,
3695                "TENSOR_INVENTORY.json",
3696            ),
3697            (
3698                "docs/truth-pack/snapshots/hf/config.json",
3699                PINNED_MODEL_CONFIG,
3700                "model_config.json",
3701            ),
3702            (
3703                "docs/truth-pack/snapshots/gh/LICENSE",
3704                APACHE_LICENSE,
3705                "QWEN_APACHE_LICENSE",
3706            ),
3707        ] {
3708            match std::fs::read_to_string(root.join(canonical)) {
3709                Ok(bytes) => assert_eq!(
3710                    bytes, embedded,
3711                    "pinned/{name} drifted from {canonical}; re-copy it"
3712                ),
3713                Err(_) => eprintln!(
3714                    "SKIP pinned-copy check for {name}: {canonical} absent (no repo checkout)"
3715                ),
3716            }
3717        }
3718    }
3719
3720    #[test]
3721    fn embedded_model_manifest_is_wellformed_and_agrees_with_the_converter_pin() {
3722        let manifest = ModelManifest::embedded().expect("embedded manifest parses");
3723        assert_eq!(manifest.model_id, "qwen3-tts-12hz-0.6b-base");
3724        assert_eq!(manifest.release_tag, "model-qwen3-tts-v1");
3725        assert_eq!(manifest.repo, "Dicklesworthstone/franken_tts");
3726        assert_eq!(manifest.files.len(), 8);
3727
3728        for file in &manifest.files {
3729            assert!(
3730                is_sha256_hex(&file.sha256),
3731                "{} carries a malformed digest",
3732                file.asset
3733            );
3734            assert!(file.bytes > 0, "{} has no pinned size", file.asset);
3735            let dest = Path::new(&file.dest);
3736            assert!(!dest.is_absolute(), "{} dest is absolute", file.asset);
3737            assert!(
3738                dest.components()
3739                    .all(|component| matches!(component, std::path::Component::Normal(_))),
3740                "{} dest can traverse out of the model directory",
3741                file.asset
3742            );
3743        }
3744
3745        // Since frankentts-zm5 the pull ships the canonical quantized artifact, not the raw main
3746        // checkpoint: enrollment and synthesis both hydrate from the .fttsq, so pulling the raw
3747        // 1.7 GB main would be pure waste. The artifact lands at the exact basename every model
3748        // search path probes for.
3749        let main = manifest
3750            .files
3751            .iter()
3752            .find(|file| file.dest == MODEL_BASENAME)
3753            .expect("manifest carries the canonical artifact");
3754        assert_eq!(
3755            manifest.download_url(main),
3756            "https://github.com/Dicklesworthstone/franken_tts/releases/download/model-qwen3-tts-v1/qwen3-tts-12hz-0.6b-base.fttsq"
3757        );
3758        assert!(
3759            !manifest
3760                .files
3761                .iter()
3762                .any(|file| file.dest == PINNED_MAIN_WEIGHTS_FILENAME),
3763            "pull must not fetch the raw main checkpoint alongside the canonical artifact"
3764        );
3765
3766        // Together the files are exactly what ModelBundle::resolve requires plus the two config
3767        // sidecars, so a completed pull always resolves.
3768        let dests: Vec<&str> = manifest
3769            .files
3770            .iter()
3771            .map(|file| file.dest.as_str())
3772            .collect();
3773        for required in [
3774            MODEL_BASENAME,
3775            "speech_tokenizer/model.safetensors",
3776            "vocab.json",
3777            "merges.txt",
3778            "tokenizer_config.json",
3779        ] {
3780            assert!(dests.contains(&required), "manifest is missing {required}");
3781        }
3782    }
3783
3784    #[test]
3785    fn malformed_model_manifests_are_refused_with_the_field_named() {
3786        fn manifest_with(
3787            schema_version: u64,
3788            asset: &str,
3789            dest: &str,
3790            sha256: &str,
3791            bytes: u64,
3792        ) -> String {
3793            json!({
3794                "schema_version": schema_version,
3795                "model_id": "m",
3796                "release_tag": "t",
3797                "repo": "owner/repo",
3798                "files": [{"asset": asset, "dest": dest, "sha256": sha256, "bytes": bytes}],
3799            })
3800            .to_string()
3801        }
3802        let good_sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
3803
3804        // The happy shape parses, so every refusal below is attributable to its one bad field.
3805        ModelManifest::parse(&manifest_with(1, "a.bin", "a.bin", good_sha, 1))
3806            .expect("well-formed manifest parses");
3807
3808        for (label, text) in [
3809            (
3810                "unsupported schema_version",
3811                manifest_with(2, "a.bin", "a.bin", good_sha, 1),
3812            ),
3813            (
3814                "short sha256",
3815                manifest_with(1, "a.bin", "a.bin", "abc123", 1),
3816            ),
3817            (
3818                "uppercase sha256",
3819                manifest_with(1, "a.bin", "a.bin", &good_sha.to_uppercase(), 1),
3820            ),
3821            (
3822                "zero bytes",
3823                manifest_with(1, "a.bin", "a.bin", good_sha, 0),
3824            ),
3825            (
3826                "absolute dest",
3827                manifest_with(1, "a.bin", "/etc/passwd", good_sha, 1),
3828            ),
3829            (
3830                "traversal dest",
3831                manifest_with(1, "a.bin", "../escape.bin", good_sha, 1),
3832            ),
3833            (
3834                "asset with a path separator",
3835                manifest_with(1, "dir/a.bin", "a.bin", good_sha, 1),
3836            ),
3837            (
3838                "empty files array",
3839                json!({
3840                    "schema_version": 1,
3841                    "model_id": "m",
3842                    "release_tag": "t",
3843                    "repo": "owner/repo",
3844                    "files": [],
3845                })
3846                .to_string(),
3847            ),
3848        ] {
3849            let error = ModelManifest::parse(&text)
3850                .expect_err(&format!("a manifest with {label} must be refused"));
3851            assert_eq!(error.exit_code(), FttsExitCode::ArtifactFormat, "{label}");
3852        }
3853    }
3854
3855    #[test]
3856    fn pull_skips_only_a_file_matching_both_pinned_size_and_digest() {
3857        let dir = std::env::temp_dir().join(format!("ftts-pull-decision-{}", std::process::id()));
3858        fs::create_dir_all(&dir).expect("temp dir");
3859        let payload = b"pinned payload";
3860        let file = ModelManifestFile {
3861            asset: "a.bin".to_owned(),
3862            dest: "a.bin".to_owned(),
3863            sha256: ftts_artifacts::sha256::hex_digest(payload),
3864            bytes: payload.len() as u64,
3865        };
3866        let dest = dir.join("a.bin");
3867
3868        let _ = fs::remove_file(&dest);
3869        assert_eq!(
3870            pull_decision(&dest, &file, false),
3871            PullDecision::Download,
3872            "absent file must download"
3873        );
3874
3875        fs::write(&dest, payload).expect("write verified payload");
3876        assert_eq!(
3877            pull_decision(&dest, &file, false),
3878            PullDecision::Skip,
3879            "matching size and digest must skip"
3880        );
3881        assert_eq!(
3882            pull_decision(&dest, &file, true),
3883            PullDecision::Download,
3884            "--force must re-download even a verified file"
3885        );
3886
3887        fs::write(&dest, b"pinned_payload").expect("write same-length corruption");
3888        assert_eq!(
3889            pull_decision(&dest, &file, false),
3890            PullDecision::Download,
3891            "a same-length corruption must be caught by the digest"
3892        );
3893
3894        fs::write(&dest, b"short").expect("write truncation");
3895        assert_eq!(
3896            pull_decision(&dest, &file, false),
3897            PullDecision::Download,
3898            "a truncated file must be caught by the size check"
3899        );
3900    }
3901
3902    #[test]
3903    fn model_resolution_prefers_explicit_then_searched_then_the_pull_directory() {
3904        let root = std::env::temp_dir().join(format!("ftts-resolve-order-{}", std::process::id()));
3905
3906        // A complete bundle directory: ModelBundle::resolve only asks `is_file`, so empty files
3907        // are a sufficient fake (and would fail loudly if the resolver ever started reading).
3908        let bundle = root.join("bundle");
3909        for relative in [
3910            "model.safetensors",
3911            "speech_tokenizer/model.safetensors",
3912            "vocab.json",
3913            "merges.txt",
3914            "tokenizer_config.json",
3915        ] {
3916            let path = bundle.join(relative);
3917            fs::create_dir_all(path.parent().expect("bundle parent")).expect("bundle dirs");
3918            fs::write(&path, b"").expect("bundle file");
3919        }
3920        assert!(
3921            synth::ModelBundle::resolve(&bundle).is_ok(),
3922            "five empty files must satisfy the resolver's is_file checks"
3923        );
3924
3925        let searched_artifact = root.join("searched").join(MODEL_BASENAME);
3926        fs::create_dir_all(searched_artifact.parent().expect("searched parent"))
3927            .expect("searched dir");
3928        fs::write(&searched_artifact, b"").expect("searched artifact");
3929        let searched = vec![searched_artifact.clone()];
3930        let absent = vec![root.join("absent").join(MODEL_BASENAME)];
3931
3932        // 1. `--model` outranks everything.
3933        assert_eq!(
3934            resolve_model_from(Some(&bundle), &searched, Some(&bundle)).expect("explicit"),
3935            bundle.display().to_string()
3936        );
3937
3938        // 2. A searched artifact outranks the pull directory.
3939        assert_eq!(
3940            resolve_model_from(None, &searched, Some(&bundle)).expect("searched"),
3941            searched_artifact.display().to_string()
3942        );
3943
3944        // 3. The pull directory resolves when nothing searched exists.
3945        assert_eq!(
3946            resolve_model_from(None, &absent, Some(&bundle)).expect("pull fallback"),
3947            bundle.display().to_string()
3948        );
3949
3950        // 4. An incomplete pull directory does not resolve, and the error teaches `ftts pull`.
3951        let incomplete = root.join("incomplete");
3952        fs::create_dir_all(&incomplete).expect("incomplete dir");
3953        let error = resolve_model_from(None, &absent, Some(&incomplete))
3954            .expect_err("an empty pull directory must not resolve");
3955        assert_eq!(error.exit_code(), FttsExitCode::ModelNotFound);
3956        assert!(error.to_string().contains("ftts pull"), "{error}");
3957        assert!(error.to_string().contains("2.0 GB"), "{error}");
3958        assert!(error.to_string().contains("FTTS_MODEL_DIR"), "{error}");
3959    }
3960
3961    #[test]
3962    fn the_pull_directory_default_prefers_the_env_override() {
3963        let mut environment = Environment::default();
3964        environment
3965            .values
3966            .insert("FTTS_MODEL_DIR", Some(OsString::from("/tmp/env-model-dir")));
3967        assert_eq!(
3968            default_pull_model_dir(&environment),
3969            Some(PathBuf::from("/tmp/env-model-dir"))
3970        );
3971
3972        // Without the override the default lives under $HOME; the exact suffix is the contract
3973        // `ftts pull` and model resolution share.
3974        if std::env::var_os("HOME").is_some() {
3975            let fallback = default_pull_model_dir(&Environment::default())
3976                .expect("HOME is set, so a default exists");
3977            assert!(
3978                fallback.ends_with(DEFAULT_MODEL_CACHE_SUBDIR),
3979                "{fallback:?}"
3980            );
3981        }
3982    }
3983}