Skip to main content

ftts_cli/
lib.rs

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