Skip to main content

ftts_cli/
lib.rs

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