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