1#![forbid(unsafe_code)]
2
3mod error;
6pub mod resident;
7pub mod robot;
8pub mod style;
9pub mod synth;
10
11pub use error::{FttsError, FttsExitCode};
12pub use robot::{EventType, validate_event, validate_ndjson};
13
14use std::collections::BTreeMap;
15use std::ffi::OsString;
16use std::fs;
17use std::io::{self, Read, Write};
18use std::path::{Path, PathBuf};
19use std::process::ExitCode;
20use std::sync::OnceLock;
21
22#[cfg(test)]
23use clap::CommandFactory;
24use clap::{Parser, Subcommand, ValueEnum};
25use ftts_artifacts::census::{ExpectedTensor, WeightsManifest};
26use ftts_artifacts::converter::{
27 StreamingConversionPlan, TensorConversion, TensorStoragePolicy, convert_safetensors_streaming,
28};
29use ftts_artifacts::fttsq::{AccessClass, MappedFttsq};
30use ftts_artifacts::safetensors::Dtype;
31use ftts_core::{NormalizationMode, NormalizationOptions, SynthesisRequest};
32use ftts_kernels::mmap::MappedFile;
33use serde_json::{Value, json};
34
35const ROBOT_SCHEMA_VERSION: u8 = 1;
36const SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES: usize = 1_048_576;
37const MODEL_BASENAME: &str = "qwen3-tts-12hz-0.6b-base.fttsq";
38
39const PRESET_VOICES: &[(&str, &str, &[u8])] = &[
44 (
45 "aria",
46 "clear, warm, feminine",
47 include_bytes!("../presets/aria.spk"),
48 ),
49 (
50 "ember",
51 "the same character a few semitones deeper",
52 include_bytes!("../presets/ember.spk"),
53 ),
54 (
55 "james",
56 "natural, conversational, masculine",
57 include_bytes!("../presets/james.spk"),
58 ),
59 (
60 "matt",
61 "warm, easy, masculine — the out-of-box default",
62 include_bytes!("../presets/matt.spk"),
63 ),
64 (
65 "leo",
66 "relaxed, resonant, masculine",
67 include_bytes!("../presets/leo.spk"),
68 ),
69 (
70 "robert",
71 "steady, measured, masculine",
72 include_bytes!("../presets/robert.spk"),
73 ),
74 (
75 "judy",
76 "bright, articulate, feminine",
77 include_bytes!("../presets/judy.spk"),
78 ),
79];
80
81const DEFAULT_PRESET_VOICE: &str = "matt";
84
85fn materialize_preset_voice(name: &str) -> Option<Result<PathBuf, FttsError>> {
90 let (_, _, bytes) = PRESET_VOICES
91 .iter()
92 .find(|(preset, _, _)| *preset == name)?;
93 let staging_dir = match synth::private_staging_dir() {
94 Ok(dir) => dir,
95 Err(error) => {
96 return Some(Err(FttsError::Generic(format!(
97 "cannot create staging directory for preset voice {name}: {error}"
98 ))));
99 }
100 };
101 let path = staging_dir.join(format!("ftts-preset-{name}-{}.spk", std::process::id()));
102 Some(
103 fs::write(&path, bytes)
104 .map(|()| path.clone())
105 .map_err(|error| {
106 FttsError::Generic(format!(
107 "cannot materialize preset voice {name} at {}: {error}",
108 path.display()
109 ))
110 }),
111 )
112}
113
114fn preset_names() -> String {
115 PRESET_VOICES
116 .iter()
117 .map(|(name, _, _)| *name)
118 .collect::<Vec<_>>()
119 .join(", ")
120}
121const PINNED_MAIN_WEIGHTS_FILENAME: &str = "model.safetensors";
122const PINNED_MAIN_WEIGHTS_SHA256: &str =
123 "180b3b10eb1c9f1b4db7806d5475bae3071c0243c299d49926bab1da3b6946f6";
124const PINNED_MODEL_REVISION: &str = "5d83992436eae1d760afd27aff78a71d676296fc";
125const PINNED_MAIN_TENSOR_COUNT: usize = 478;
126const PINNED_TENSOR_INVENTORY: &str = include_str!("../pinned/TENSOR_INVENTORY.json");
131const PINNED_MODEL_CONFIG: &str = include_str!("../pinned/model_config.json");
132const APACHE_LICENSE: &str = include_str!("../pinned/QWEN_APACHE_LICENSE");
133const PINNED_MODEL_MANIFEST: &str = include_str!("../pinned/model_manifest.json");
137const DEFAULT_MODEL_CACHE_SUBDIR: &str = ".cache/franken_tts/model";
139const ENVIRONMENT_VARIABLES: [&str; 11] = [
140 "FTTS_MODEL_DIR",
141 "FTTS_DEFAULT_VOICE",
142 "FTTS_THREADS",
143 "FTTS_PROFILE",
144 "FTTS_PACKET_FRAMES",
145 "FTTS_MATH_MODE",
146 "FTTS_QUANT",
147 "FTTS_FORCE_ARCH",
148 "FTTS_NUMA",
149 "FTTS_MAX_FRAMES",
150 "FTTS_MEMORY_BUDGET_MB",
151];
152
153pub fn cli_main() -> ExitCode {
155 let cli = match Cli::try_parse() {
160 Ok(cli) => cli,
161 Err(error) => {
162 let exit_code = match error.kind() {
163 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
164 FttsExitCode::Success
165 }
166 _ => FttsExitCode::Usage,
167 };
168 let _ = error.print();
169 return exit_code.as_exit_code();
170 }
171 };
172
173 let mut stdin = io::stdin().lock();
174 let mut stdout = io::stdout().lock();
175 let mut stderr = io::stderr().lock();
176 match dispatch(cli, environment(), &mut stdin, &mut stdout, &mut stderr) {
177 Ok(()) => FttsExitCode::Success.as_exit_code(),
178 Err(error) => {
179 let _ = writeln!(stderr, "error: {error}");
180 error.exit_code().as_exit_code()
181 }
182 }
183}
184
185#[derive(Debug, Parser)]
186#[command(
187 name = "ftts",
188 version,
189 about = "Pure-Rust Qwen3-TTS command-line interface",
190 long_about = "FrankenTTS is stateless by default: synthesis history is never persisted. \
191 Use `ftts robot schema` for the versioned NDJSON contract.",
192 arg_required_else_help = true
193)]
194struct Cli {
195 #[arg(long, global = true, value_enum)]
197 profile: Option<ExecutionProfile>,
198
199 #[arg(long, global = true, value_enum)]
201 packet_frames: Option<PacketFrames>,
202
203 #[arg(long, global = true, value_enum)]
205 math_mode: Option<MathMode>,
206
207 #[arg(long, global = true, value_enum)]
209 voice_pack: Option<VoicePackProfile>,
210
211 #[arg(long, global = true, value_enum)]
213 normalize: Option<NormalizeMode>,
214
215 #[arg(long, global = true, value_name = "DIR")]
217 trace: Option<PathBuf>,
218
219 #[arg(long, global = true)]
221 seed: Option<u64>,
222
223 #[command(subcommand)]
224 command: Command,
225}
226
227#[derive(Debug, Subcommand)]
228enum Command {
229 Say(SayArgs),
231 #[command(name = "make-video")]
233 MakeVideo(MakeVideoArgs),
234 Enroll(EnrollArgs),
236 Voice(VoiceArgs),
238 Convert(ConvertArgs),
240 Pull(PullArgs),
242 Robot(RobotArgs),
244 Doctor(DoctorArgs),
246 #[command(hide = true, name = "resident-daemon")]
248 ResidentDaemon(ResidentDaemonArgs),
249}
250
251#[derive(Debug, clap::Args)]
252struct ResidentDaemonArgs {
253 #[arg(long, value_name = "PATH")]
255 bundle_root: PathBuf,
256}
257
258#[derive(Debug, clap::Args)]
259struct SayArgs {
260 #[arg(value_name = "TEXT")]
262 text: Option<String>,
263
264 #[arg(value_name = "OUTPUT", conflicts_with_all = ["stream", "output"])]
268 output_positional: Option<PathBuf>,
269
270 #[arg(long, value_name = "PATH", conflicts_with = "text")]
272 file: Option<PathBuf>,
273
274 #[arg(long, value_name = "PATH")]
276 model: Option<PathBuf>,
277
278 #[arg(long, value_name = "PATH|NAME")]
282 voice: Option<PathBuf>,
283
284 #[arg(short = 'o', long, value_name = "PATH", conflicts_with = "stream")]
286 output: Option<PathBuf>,
287
288 #[arg(long, value_enum)]
290 stream: Option<StreamMode>,
291
292 #[arg(long)]
294 check: bool,
295
296 #[arg(long)]
301 robot: bool,
302
303 #[arg(long)]
310 no_resident: bool,
311}
312
313#[derive(Debug, clap::Args)]
314struct MakeVideoArgs {
315 #[arg(value_name = "TEXT")]
317 text: Option<String>,
318
319 #[arg(value_name = "OUTPUT", conflicts_with = "output")]
322 output_positional: Option<PathBuf>,
323
324 #[arg(long, value_name = "PATH", conflicts_with = "text")]
326 file: Option<PathBuf>,
327
328 #[arg(long, value_name = "PATH")]
330 model: Option<PathBuf>,
331
332 #[arg(long, value_name = "PATH|NAME")]
335 voice: Option<PathBuf>,
336
337 #[arg(short = 'o', long, value_name = "PATH")]
339 output: Option<PathBuf>,
340
341 #[arg(long, value_name = "PATH", conflicts_with_all = ["text", "file"])]
343 audio: Option<PathBuf>,
344
345 #[arg(long, value_name = "NAME")]
347 label: Option<String>,
348
349 #[arg(long)]
351 no_resident: bool,
352}
353
354#[derive(Debug, clap::Args)]
355struct EnrollArgs {
356 #[arg(value_name = "REFERENCE_AUDIO")]
359 reference_audio: PathBuf,
360
361 #[arg(long, value_name = "PATH")]
363 model: Option<PathBuf>,
364
365 #[arg(short = 'o', long, value_name = "PATH", conflicts_with = "default")]
367 output: Option<PathBuf>,
368
369 #[arg(long, conflicts_with = "output")]
371 default: bool,
372
373 #[arg(long)]
375 force: bool,
376
377 #[arg(long)]
382 overwrite: bool,
383
384 #[arg(long)]
391 dereverb: bool,
392
393 #[arg(long, overrides_with = "no_denoise")]
402 denoise: bool,
403
404 #[arg(long, overrides_with = "denoise")]
406 no_denoise: bool,
407}
408
409#[derive(Debug, clap::Args)]
410struct VoiceArgs {
411 #[command(subcommand)]
412 command: VoiceCommand,
413}
414
415#[derive(Debug, Subcommand)]
416enum VoiceCommand {
417 Inspect { path: PathBuf },
419}
420
421#[derive(Debug, clap::Args)]
422struct ConvertArgs {
423 #[arg(value_name = "SOURCE")]
425 source: PathBuf,
426
427 #[arg(short = 'o', long, value_name = "PATH")]
429 output: PathBuf,
430}
431
432#[derive(Debug, clap::Args)]
433struct PullArgs {
434 #[arg(long, value_name = "PATH")]
436 model: Option<PathBuf>,
437
438 #[arg(long)]
440 force: bool,
441}
442
443#[derive(Debug, clap::Args)]
444struct RobotArgs {
445 #[command(subcommand)]
446 command: RobotCommand,
447}
448
449#[derive(Clone, Debug, Subcommand)]
450enum RobotCommand {
451 Schema,
453 Health,
455 Backends,
457 Selftest,
459}
460
461#[derive(Debug, clap::Args)]
462struct DoctorArgs {
463 #[arg(long)]
465 json: bool,
466}
467
468#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
469enum ExecutionProfile {
470 Interactive,
471 Balanced,
472 Throughput,
473 Strict,
474}
475
476impl ExecutionProfile {
477 const fn as_str(self) -> &'static str {
478 match self {
479 Self::Interactive => "interactive",
480 Self::Balanced => "balanced",
481 Self::Throughput => "throughput",
482 Self::Strict => "strict",
483 }
484 }
485}
486
487#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
488enum PacketFrames {
489 #[value(name = "1")]
490 One,
491 #[value(name = "2")]
492 Two,
493 #[value(name = "4")]
494 Four,
495 Auto,
496}
497
498impl PacketFrames {
499 const fn as_str(self) -> &'static str {
500 match self {
501 Self::One => "1",
502 Self::Two => "2",
503 Self::Four => "4",
504 Self::Auto => "auto",
505 }
506 }
507
508 const fn frames_per_packet(self) -> u8 {
514 match self {
515 Self::One => 1,
516 Self::Two => 2,
517 Self::Four | Self::Auto => 4,
518 }
519 }
520
521 const fn samples_per_packet(self) -> usize {
523 self.frames_per_packet() as usize * ftts_core::audio::SAMPLES_PER_FRAME
524 }
525
526 const fn default_for(profile: ExecutionProfile) -> Self {
527 match profile {
528 ExecutionProfile::Interactive => Self::One,
529 ExecutionProfile::Balanced => Self::Four,
530 ExecutionProfile::Throughput => Self::Auto,
531 ExecutionProfile::Strict => Self::Four,
532 }
533 }
534}
535
536#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
537enum MathMode {
538 Strict,
539 Fast,
540}
541
542impl MathMode {
543 const fn as_str(self) -> &'static str {
544 match self {
545 Self::Strict => "strict",
546 Self::Fast => "fast",
547 }
548 }
549}
550
551#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
552enum VoicePackProfile {
553 Portable,
554 Private,
555 Minimal,
556}
557
558impl VoicePackProfile {
559 const fn as_str(self) -> &'static str {
560 match self {
561 Self::Portable => "portable",
562 Self::Private => "private",
563 Self::Minimal => "minimal",
564 }
565 }
566}
567
568#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
569enum NormalizeMode {
570 Verbatim,
571 Conservative,
572 LocaleAware,
573}
574
575impl NormalizeMode {
576 const fn as_str(self) -> &'static str {
577 match self {
578 Self::Verbatim => "verbatim",
579 Self::Conservative => "conservative",
580 Self::LocaleAware => "locale-aware",
581 }
582 }
583}
584
585impl From<NormalizeMode> for NormalizationMode {
586 fn from(mode: NormalizeMode) -> Self {
587 match mode {
588 NormalizeMode::Verbatim => Self::Verbatim,
589 NormalizeMode::Conservative => Self::Conservative,
590 NormalizeMode::LocaleAware => Self::LocaleAware,
591 }
592 }
593}
594
595#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
596enum StreamMode {
597 Raw,
598}
599
600#[derive(Debug, Default)]
601struct Environment {
602 values: BTreeMap<&'static str, Option<OsString>>,
603 stage_budget_values: BTreeMap<OsString, OsString>,
604}
605
606impl Environment {
607 fn from_process() -> Self {
608 let values = ENVIRONMENT_VARIABLES
609 .into_iter()
610 .map(|name| (name, std::env::var_os(name)))
611 .collect();
612 let stage_budget_values = std::env::vars_os()
613 .filter(|(name, _)| {
614 name.to_str().is_some_and(|name| {
615 name.starts_with("FTTS_STAGE_BUDGET_") && name.ends_with("_MS")
616 })
617 })
618 .collect();
619 Self {
620 values,
621 stage_budget_values,
622 }
623 }
624
625 fn value(&self, name: &'static str) -> Option<&str> {
626 self.values.get(name)?.as_deref()?.to_str()
627 }
628
629 fn documented_values(&self) -> BTreeMap<String, Option<String>> {
630 let mut values = self
631 .values
632 .iter()
633 .map(|(name, value)| {
634 (
635 (*name).to_owned(),
636 value
637 .as_ref()
638 .map(|value| value.to_string_lossy().into_owned()),
639 )
640 })
641 .collect::<BTreeMap<_, _>>();
642 values.insert("FTTS_STAGE_BUDGET_*_MS".to_owned(), None);
643 values.extend(self.stage_budget_values.iter().map(|(name, value)| {
644 (
645 name.to_string_lossy().into_owned(),
646 Some(value.to_string_lossy().into_owned()),
647 )
648 }));
649 values
650 }
651}
652
653fn environment() -> &'static Environment {
654 static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
655 ENVIRONMENT.get_or_init(Environment::from_process)
656}
657
658#[derive(Debug)]
659struct EffectiveSettings {
660 profile: ExecutionProfile,
661 packet_frames: PacketFrames,
662 math_mode: MathMode,
663 voice_pack: VoicePackProfile,
664 normalize: NormalizeMode,
665}
666
667impl EffectiveSettings {
668 fn resolve(cli: &Cli, environment: &Environment) -> Result<Self, FttsError> {
669 let profile = cli
670 .profile
671 .or(parse_env_value(
672 environment.value("FTTS_PROFILE"),
673 "FTTS_PROFILE",
674 ExecutionProfile::value_variants(),
675 )?)
676 .unwrap_or(ExecutionProfile::Balanced);
677 let packet_frames = cli
678 .packet_frames
679 .or(parse_env_value(
680 environment.value("FTTS_PACKET_FRAMES"),
681 "FTTS_PACKET_FRAMES",
682 PacketFrames::value_variants(),
683 )?)
684 .unwrap_or_else(|| PacketFrames::default_for(profile));
685 let math_mode = cli
686 .math_mode
687 .or(parse_env_value(
688 environment.value("FTTS_MATH_MODE"),
689 "FTTS_MATH_MODE",
690 MathMode::value_variants(),
691 )?)
692 .unwrap_or(MathMode::Fast);
693 let voice_pack = cli.voice_pack.unwrap_or(VoicePackProfile::Portable);
694 let normalize = cli.normalize.unwrap_or(NormalizeMode::Verbatim);
695 Ok(Self {
696 profile,
697 packet_frames,
698 math_mode,
699 voice_pack,
700 normalize,
701 })
702 }
703
704 fn normalization_options(&self) -> NormalizationOptions {
705 NormalizationOptions {
706 mode: self.normalize.into(),
707 ..NormalizationOptions::default()
708 }
709 }
710}
711
712fn parse_env_value<T>(
713 value: Option<&str>,
714 name: &str,
715 variants: &'static [T],
716) -> Result<Option<T>, FttsError>
717where
718 T: ValueEnum + Copy,
719{
720 match value {
721 None => Ok(None),
722 Some(value) => T::from_str(value, true).map(Some).map_err(|_| {
723 let choices = variants
724 .iter()
725 .filter_map(|variant| variant.to_possible_value())
726 .map(|variant| variant.get_name().to_owned())
727 .collect::<Vec<_>>()
728 .join(", ");
729 FttsError::Usage(format!("invalid {name}={value:?}; use one of: {choices}"))
730 }),
731 }
732}
733
734fn dispatch(
735 cli: Cli,
736 environment: &Environment,
737 stdin: &mut dyn Read,
738 stdout: &mut dyn Write,
739 stderr: &mut dyn Write,
740) -> Result<(), FttsError> {
741 match &cli.command {
742 Command::Say(args) => run_say(&cli, args, environment, stdin, stdout, stderr),
743 Command::MakeVideo(args) => run_make_video(&cli, args, environment, stdin, stdout, stderr),
744 Command::Enroll(args) => run_enroll(args, environment, stdout),
745 Command::Voice(VoiceArgs {
746 command: VoiceCommand::Inspect { path },
747 }) => run_voice_inspect(path, stdout),
748 Command::Convert(args) => run_convert(&cli, args, environment, stdout, stderr),
749 Command::Pull(args) => run_pull(args, environment, stdout),
750 Command::Robot(args) => run_robot(args.command.clone(), environment, stdout),
751 Command::Doctor(args) => run_doctor(args, environment, stdout),
752 Command::ResidentDaemon(args) => resident::run_daemon(&args.bundle_root),
753 }
754}
755
756#[derive(Clone, Debug)]
758struct PinnedMainTensor {
759 name: String,
760 dtype: Dtype,
761 shape: Vec<usize>,
762 access_class: AccessClass,
763 storage: TensorStoragePolicy,
764}
765
766fn run_convert(
767 cli: &Cli,
768 args: &ConvertArgs,
769 environment: &Environment,
770 stdout: &mut dyn Write,
771 stderr: &mut dyn Write,
772) -> Result<(), FttsError> {
773 let run = robot::RunContext::generate();
774 let outcome = run_convert_events(cli, args, environment, &run, &mut |event| {
775 write_json_line(stdout, event)
776 });
777
778 if let Err(error) = &outcome {
779 let mut event = run.event(robot::EventType::RunError);
780 event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
781 event.insert("kind".to_owned(), json!(error.exit_code().description()));
782 event.insert("message".to_owned(), json!(error.to_string()));
783 event.insert("remediation".to_owned(), json!(error.remediation()));
784 event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
785 write_json_line(stderr, &Value::Object(event))?;
786 }
787
788 outcome
789}
790
791fn run_convert_events(
798 cli: &Cli,
799 args: &ConvertArgs,
800 environment: &Environment,
801 run: &robot::RunContext,
802 emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
803) -> Result<(), FttsError> {
804 let settings = EffectiveSettings::resolve(cli, environment)?;
805 let mut start = run.event(robot::EventType::RunStart);
806 start.insert("command".to_owned(), json!("convert"));
807 start.insert("profile".to_owned(), json!(settings.profile.as_str()));
808 start.insert(
809 "packet_frames".to_owned(),
810 json!(settings.packet_frames.as_str()),
811 );
812 start.insert("math_mode".to_owned(), json!(settings.math_mode.as_str()));
813 start.insert("stateless".to_owned(), json!(true));
814 start.insert("seed".to_owned(), json!(cli.seed));
815 start.insert("model".to_owned(), Value::Null);
816 start.insert("voice".to_owned(), Value::Null);
817 emit(&Value::Object(start))?;
818
819 let mut seq = 0_u64;
820 emit_stage(run, emit, "source_preflight", "begin", &mut seq)?;
821 let source = resolve_pinned_main_source(&args.source)?;
822 let mapping = MappedFile::open(&source).map_err(|error| {
823 FttsError::Input(format!(
824 "cannot memory-map pinned source checkpoint {}: {error}",
825 source.display()
826 ))
827 })?;
828 let (manifest, plan) = pinned_main_conversion_plan()?;
829 let staging = conversion_staging_path(&args.output)?;
830 emit_stage(run, emit, "source_preflight", "end", &mut seq)?;
831
832 emit_stage(run, emit, "convert", "begin", &mut seq)?;
833 let destination = std::fs::File::options()
834 .write(true)
835 .create_new(true)
836 .open(&staging)
837 .map_err(|error| {
838 FttsError::Input(format!(
839 "cannot create conversion staging artifact {}: {error}; the output path is never overwritten",
840 staging.display()
841 ))
842 })?;
843 let destination = convert_safetensors_streaming(
844 mapping.as_slice(),
845 &manifest,
846 &plan,
847 destination,
848 )
849 .map_err(|error| {
850 FttsError::ArtifactFormat(format!(
851 "conversion failed before publication: {error}; staging artifact retained at {}",
852 staging.display()
853 ))
854 })?;
855 destination.sync_all().map_err(|error| {
856 FttsError::ArtifactFormat(format!(
857 "cannot sync converted artifact at {}: {error}; staging artifact retained",
858 staging.display()
859 ))
860 })?;
861 drop(destination);
862 emit_stage(run, emit, "convert", "end", &mut seq)?;
863
864 emit_stage(run, emit, "verify", "begin", &mut seq)?;
865 let verified = MappedFttsq::open(&staging).map_err(|error| {
866 FttsError::ArtifactFormat(format!(
867 "converted staging artifact did not pass digest re-read: {error}; retained at {}",
868 staging.display()
869 ))
870 })?;
871 if verified.reader().source_sha256() != PINNED_MAIN_WEIGHTS_SHA256 {
872 return Err(FttsError::ArtifactFormat(format!(
873 "converted staging artifact recorded an unexpected source digest {}; retained at {}",
874 verified.reader().source_sha256(),
875 staging.display()
876 )));
877 }
878 drop(verified);
879 std::fs::rename(&staging, &args.output).map_err(|error| {
880 FttsError::ArtifactFormat(format!(
881 "converted artifact verified but could not be published from {} to {}: {error}; staging artifact retained",
882 staging.display(),
883 args.output.display()
884 ))
885 })?;
886 emit_stage(run, emit, "verify", "end", &mut seq)?;
887
888 let mut complete = run.event(robot::EventType::RunComplete);
889 complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
890 complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
891 complete.insert("frames".to_owned(), json!(0));
892 complete.insert("audio_bytes".to_owned(), json!(0));
893 emit(&Value::Object(complete))
894}
895
896fn resolve_pinned_main_source(source: &Path) -> Result<PathBuf, FttsError> {
897 let source = if source.is_dir() {
898 source.join(PINNED_MAIN_WEIGHTS_FILENAME)
899 } else {
900 source.to_owned()
901 };
902 if !source.is_file() {
903 return Err(FttsError::Input(format!(
904 "pinned main checkpoint {} does not exist or is not a file; pass model.safetensors or its containing directory",
905 source.display()
906 )));
907 }
908 if source.file_name().and_then(|name| name.to_str()) != Some(PINNED_MAIN_WEIGHTS_FILENAME) {
909 return Err(FttsError::Input(format!(
910 "this converter accepts the pinned main checkpoint named {PINNED_MAIN_WEIGHTS_FILENAME}, not {}",
911 source.display()
912 )));
913 }
914 Ok(source)
915}
916
917fn conversion_staging_path(output: &Path) -> Result<PathBuf, FttsError> {
918 if output.exists() {
919 return Err(FttsError::Input(format!(
920 "refusing to overwrite existing output {}; choose a new -o path",
921 output.display()
922 )));
923 }
924 let parent = output.parent().unwrap_or_else(|| Path::new("."));
925 let file_name = output
926 .file_name()
927 .and_then(|name| name.to_str())
928 .ok_or_else(|| {
929 FttsError::Usage("conversion output must name a file, not a directory".to_owned())
930 })?;
931 let nonce = std::time::SystemTime::now()
932 .duration_since(std::time::UNIX_EPOCH)
933 .map_err(|error| FttsError::Generic(format!("system clock is before UNIX_EPOCH: {error}")))?
934 .as_nanos();
935 let staging = parent.join(format!(
936 ".{file_name}.fttsq-converting-{}-{nonce}",
937 std::process::id()
938 ));
939 if staging.exists() {
940 return Err(FttsError::Input(format!(
941 "conversion staging path already exists {}; inspect or move it before retrying",
942 staging.display()
943 )));
944 }
945 Ok(staging)
946}
947
948fn pinned_main_conversion_plan() -> Result<(WeightsManifest, StreamingConversionPlan), FttsError> {
949 let specs = pinned_main_tensor_specs()?;
950 let manifest = WeightsManifest::from_expectations(
951 "Qwen/Qwen3-TTS-12Hz-0.6B-Base main checkpoint",
952 specs
953 .iter()
954 .map(|spec| ExpectedTensor::new(&spec.name, spec.shape.clone(), spec.dtype)),
955 );
956 let model_config = serde_json::from_str(PINNED_MODEL_CONFIG).map_err(|error| {
957 FttsError::Generic(format!(
958 "checked-in pinned model config is invalid JSON: {error}"
959 ))
960 })?;
961 let q8_count = specs
962 .iter()
963 .filter(|spec| spec.storage == TensorStoragePolicy::Q8PerOutputChannel)
964 .count();
965 let mut plan = StreamingConversionPlan::new(
966 "qwen3-tts-12hz-0.6b-base",
967 PINNED_MAIN_WEIGHTS_SHA256,
968 )
969 .license_notice(pinned_license_notice())
970 .model_config(model_config)
971 .quantization_manifest(json!({
972 "source": {
973 "repository": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
974 "revision": PINNED_MODEL_REVISION,
975 "file": PINNED_MAIN_WEIGHTS_FILENAME,
976 "sha256": PINNED_MAIN_WEIGHTS_SHA256,
977 },
978 "q8_recipe": "symmetric per-output-channel int8; zero_point=0; scale=max_abs(row)/127",
979 "q8_tensor_count": q8_count,
980 "verbatim_tensor_count": specs.len() - q8_count,
981 "q8_scope": "talker and residual-code-microdecoder attention/MLP projection matrices only",
982 "verbatim_scope": "norms, heads, embeddings, speaker path, and every tensor outside the reviewed Q8 projection set",
983 }));
984 for spec in specs {
985 let conversion = match spec.storage {
986 TensorStoragePolicy::Verbatim => {
987 TensorConversion::verbatim(&spec.name, &spec.name, spec.access_class)
988 }
989 TensorStoragePolicy::Q8PerOutputChannel => {
990 TensorConversion::q8_per_output_channel(&spec.name, &spec.name, spec.access_class)
991 }
992 };
993 plan = plan.tensor(conversion);
994 }
995 Ok((manifest, plan))
996}
997
998fn pinned_main_tensor_specs() -> Result<Vec<PinnedMainTensor>, FttsError> {
999 let inventory: Value = serde_json::from_str(PINNED_TENSOR_INVENTORY).map_err(|error| {
1000 FttsError::Generic(format!(
1001 "checked-in tensor inventory is invalid JSON: {error}"
1002 ))
1003 })?;
1004 if inventory.get("source_pin").and_then(Value::as_str)
1005 != Some(&format!(
1006 "Qwen/Qwen3-TTS-12Hz-0.6B-Base@{PINNED_MODEL_REVISION}"
1007 ))
1008 {
1009 return Err(FttsError::Generic(
1010 "checked-in tensor inventory does not name the pinned Qwen3-TTS revision".to_owned(),
1011 ));
1012 }
1013 let records = inventory
1014 .get("tensors")
1015 .and_then(Value::as_array)
1016 .ok_or_else(|| {
1017 FttsError::Generic("checked-in tensor inventory lacks tensors[]".to_owned())
1018 })?;
1019 let mut specs = Vec::new();
1020 for record in records {
1021 if record.get("source").and_then(Value::as_str) != Some(PINNED_MAIN_WEIGHTS_FILENAME) {
1022 continue;
1023 }
1024 let name = required_inventory_string(record, "name")?.to_owned();
1025 let dtype = match required_inventory_string(record, "dtype")? {
1026 "BF16" => Dtype::Bf16,
1027 "F32" => Dtype::F32,
1028 other => {
1029 return Err(FttsError::Generic(format!(
1030 "pinned main inventory has unsupported dtype {other:?} for {name}"
1031 )));
1032 }
1033 };
1034 let shape = record
1035 .get("shape")
1036 .and_then(Value::as_array)
1037 .ok_or_else(|| {
1038 FttsError::Generic(format!("pinned inventory tensor {name} lacks shape[]"))
1039 })?
1040 .iter()
1041 .map(|dimension| {
1042 dimension
1043 .as_u64()
1044 .and_then(|dimension| usize::try_from(dimension).ok())
1045 .ok_or_else(|| {
1046 FttsError::Generic(format!(
1047 "pinned inventory tensor {name} has a non-usize shape dimension"
1048 ))
1049 })
1050 })
1051 .collect::<Result<Vec<_>, _>>()?;
1052 let storage = if is_q8_projection(&name) {
1053 TensorStoragePolicy::Q8PerOutputChannel
1054 } else {
1055 TensorStoragePolicy::Verbatim
1056 };
1057 specs.push(PinnedMainTensor {
1058 access_class: main_access_class(&name)?,
1059 name,
1060 dtype,
1061 shape,
1062 storage,
1063 });
1064 }
1065 if specs.len() != PINNED_MAIN_TENSOR_COUNT {
1066 return Err(FttsError::Generic(format!(
1067 "pinned main inventory contains {} tensors, expected {PINNED_MAIN_TENSOR_COUNT}",
1068 specs.len()
1069 )));
1070 }
1071 Ok(specs)
1072}
1073
1074fn required_inventory_string<'a>(record: &'a Value, field: &str) -> Result<&'a str, FttsError> {
1075 record.get(field).and_then(Value::as_str).ok_or_else(|| {
1076 FttsError::Generic(format!(
1077 "checked-in tensor inventory record lacks string {field:?}"
1078 ))
1079 })
1080}
1081
1082fn is_q8_projection(name: &str) -> bool {
1083 (name.starts_with("talker.model.layers.")
1084 || name.starts_with("talker.code_predictor.model.layers."))
1085 && [
1086 ".self_attn.q_proj.weight",
1087 ".self_attn.k_proj.weight",
1088 ".self_attn.v_proj.weight",
1089 ".self_attn.o_proj.weight",
1090 ".mlp.gate_proj.weight",
1091 ".mlp.up_proj.weight",
1092 ".mlp.down_proj.weight",
1093 ]
1094 .iter()
1095 .any(|suffix| name.ends_with(suffix))
1096}
1097
1098fn main_access_class(name: &str) -> Result<AccessClass, FttsError> {
1099 if name == "talker.model.text_embedding.weight" {
1100 Ok(AccessClass::ColdTextEmbedding)
1101 } else if name.starts_with("speaker_encoder.") {
1102 Ok(AccessClass::EnrollmentSpeakerEncoder)
1103 } else if name.starts_with("talker.code_predictor.")
1104 || name == "talker.model.codec_embedding.weight"
1105 {
1106 Ok(AccessClass::HotRecurrentMicrodecoder)
1107 } else if name.starts_with("talker.model.")
1108 || name.starts_with("talker.codec_head.")
1109 || name.starts_with("talker.text_projection.")
1110 {
1111 Ok(AccessClass::HotRecurrentTalker)
1112 } else {
1113 Err(FttsError::Generic(format!(
1114 "pinned main tensor {name} has no reviewed access-class assignment"
1115 )))
1116 }
1117}
1118
1119fn pinned_license_notice() -> String {
1120 format!(
1121 "This artifact contains model weights derived from\n\
1122 Qwen3-TTS-12Hz-0.6B-Base (https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-Base)\n\
1123 and code derived from QwenLM/Qwen3-TTS (https://github.com/QwenLM/Qwen3-TTS).\n\n\
1124 Copyright 2026 Alibaba Cloud\n\n\
1125 Licensed under the Apache License, Version 2.0.\n\
1126 http://www.apache.org/licenses/LICENSE-2.0\n\n\
1127 CHANGES: the original bfloat16 weights were converted to franken_tts's\n\
1128 quantized .fttsq container. Tensors were requantized according to the\n\
1129 artifact's quantization manifest; protected tensors remain verbatim.\n\
1130 The model graph is re-implemented in Rust.\n\n\
1131 Apache License, Version 2.0:\n\n{APACHE_LICENSE}"
1132 )
1133}
1134
1135fn run_say(
1136 cli: &Cli,
1137 args: &SayArgs,
1138 environment: &Environment,
1139 stdin: &mut dyn Read,
1140 stdout: &mut dyn Write,
1141 stderr: &mut dyn Write,
1142) -> Result<(), FttsError> {
1143 let run = robot::RunContext::generate();
1144 let outcome = if args.stream == Some(StreamMode::Raw) {
1149 run_say_events(cli, args, environment, stdin, &run, stdout, &mut |event| {
1150 write_json_line(stderr, event)
1151 })
1152 } else if args.robot || !style::is_interactive() {
1153 let mut discard = io::sink();
1154 run_say_events(
1155 cli,
1156 args,
1157 environment,
1158 stdin,
1159 &run,
1160 &mut discard,
1161 &mut |event| write_json_line(stdout, event),
1162 )
1163 } else {
1164 let mut discard = io::sink();
1168 let destination = args
1169 .output
1170 .as_deref()
1171 .or(args.output_positional.as_deref())
1172 .map(|path| path.display().to_string());
1173 let mut presenter = style::SayPresenter::writing_to(destination);
1174 run_say_events(
1175 cli,
1176 args,
1177 environment,
1178 stdin,
1179 &run,
1180 &mut discard,
1181 &mut |event| {
1182 presenter
1183 .event(event, stdout)
1184 .map_err(|error| FttsError::Generic(format!("cannot write progress: {error}")))
1185 },
1186 )
1187 };
1188
1189 if let Err(error) = &outcome {
1190 let mut event = run.event(robot::EventType::RunError);
1194 event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
1195 event.insert("kind".to_owned(), json!(error.exit_code().description()));
1196 event.insert("message".to_owned(), json!(error.to_string()));
1197 event.insert("remediation".to_owned(), json!(error.remediation()));
1198 event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1199 write_json_line(stderr, &Value::Object(event))?;
1200 }
1201
1202 outcome
1203}
1204
1205fn run_make_video(
1211 cli: &Cli,
1212 args: &MakeVideoArgs,
1213 environment: &Environment,
1214 stdin: &mut dyn Read,
1215 stdout: &mut dyn Write,
1216 stderr: &mut dyn Write,
1217) -> Result<(), FttsError> {
1218 let output = args
1219 .output
1220 .clone()
1221 .or_else(|| args.output_positional.clone())
1222 .ok_or_else(|| {
1223 FttsError::Usage(
1224 "`ftts make-video` needs an output path (`ftts make-video \"text\" out.mp4`)"
1225 .to_owned(),
1226 )
1227 })?;
1228 let extension = output
1229 .extension()
1230 .and_then(|extension| extension.to_str())
1231 .map(str::to_ascii_lowercase);
1232 let is_mp4 = match extension.as_deref() {
1233 Some("mp4") => true,
1234 Some("y4m") => false,
1235 other => {
1236 return Err(FttsError::Usage(format!(
1237 "unsupported video extension `.{}`; use .mp4 (system encoder) or .y4m (native)",
1238 other.unwrap_or("<none>")
1239 )));
1240 }
1241 };
1242
1243 let capitalize = |raw: &str| {
1249 let mut chars = raw.chars();
1250 chars
1251 .next()
1252 .map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
1253 .unwrap_or_else(|| raw.to_owned())
1254 };
1255 let stem_of = |path: &Path| {
1256 path.file_stem()
1257 .and_then(|stem| stem.to_str())
1258 .map(str::to_owned)
1259 };
1260 let label = args.label.clone().unwrap_or_else(|| {
1261 if let Some(voice) = &args.voice {
1262 return stem_of(voice)
1263 .map(|stem| capitalize(&stem))
1264 .unwrap_or_else(|| "Voice".to_owned());
1265 }
1266 if let Some(audio) = &args.audio {
1267 return stem_of(audio)
1269 .map(|stem| capitalize(&stem))
1270 .unwrap_or_else(|| "Voice".to_owned());
1271 }
1272 if let Some(default_voice) = environment.value("FTTS_DEFAULT_VOICE")
1273 && let Some(stem) = stem_of(Path::new(default_voice))
1274 {
1275 return capitalize(&stem);
1276 }
1277 let enrolled_default = resolve_model(args.model.as_deref(), environment)
1278 .ok()
1279 .and_then(|model| synth::ModelBundle::resolve(Path::new(&model)).ok())
1280 .is_some_and(|bundle| bundle.root.join("default.spk").is_file());
1281 if enrolled_default {
1282 "My voice".to_owned()
1283 } else {
1284 "Matt".to_owned()
1285 }
1286 });
1287
1288 let staging: Option<PathBuf> = if args.audio.is_none() {
1293 if is_mp4 {
1294 let mut path = output.as_os_str().to_owned();
1295 path.push(".ftts-staging.wav");
1296 Some(PathBuf::from(path))
1297 } else {
1298 Some(output.with_extension("wav"))
1299 }
1300 } else {
1301 None
1302 };
1303 if let Some(staging_path) = &staging {
1304 let say_args = SayArgs {
1305 text: args.text.clone(),
1306 output_positional: None,
1307 file: args.file.clone(),
1308 model: args.model.clone(),
1309 voice: args.voice.clone(),
1310 output: Some(staging_path.clone()),
1311 stream: None,
1312 check: false,
1313 robot: false,
1314 no_resident: args.no_resident,
1315 };
1316 run_say(cli, &say_args, environment, stdin, stdout, stderr)?;
1317 }
1318 let audio_path = args
1319 .audio
1320 .clone()
1321 .or_else(|| staging.clone())
1322 .unwrap_or_default();
1323
1324 let interactive = style::is_interactive();
1325 if interactive {
1326 writeln!(stdout, "rendering video: {}", output.display())
1327 .map_err(|error| FttsError::Generic(format!("cannot write progress: {error}")))?;
1328 }
1329 let request = ftts_video::VideoRequest {
1330 audio: &audio_path,
1331 output: &output,
1332 voice_label: &label,
1333 };
1334 let mut last_percent = 0usize;
1335 let render_result = ftts_video::render(&request, &mut |progress| {
1336 if !interactive {
1337 return;
1338 }
1339 let percent = progress.frame * 100 / progress.total_frames;
1340 if percent >= last_percent + 10 || progress.frame == progress.total_frames {
1341 last_percent = percent;
1342 let _ = write!(
1343 stdout,
1344 "\r frame {}/{} ({percent}%)",
1345 progress.frame, progress.total_frames
1346 );
1347 let _ = stdout.flush();
1348 }
1349 });
1350 if interactive {
1351 let _ = writeln!(stdout);
1352 }
1353 render_result.map_err(FttsError::Generic)?;
1354
1355 if is_mp4 && let Some(staging_path) = &staging {
1359 let _ = fs::remove_file(staging_path);
1360 }
1361 if interactive {
1362 writeln!(stdout, "wrote {}", output.display())
1363 .map_err(|error| FttsError::Generic(format!("cannot write result: {error}")))?;
1364 }
1365 Ok(())
1366}
1367
1368fn emit_stage(
1370 run: &robot::RunContext,
1371 emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
1372 name: &str,
1373 state: &str,
1374 seq: &mut u64,
1375) -> Result<(), FttsError> {
1376 let mut event = run.event(robot::EventType::Stage);
1377 event.insert("name".to_owned(), json!(name));
1378 event.insert("seq".to_owned(), json!(*seq));
1379 event.insert("state".to_owned(), json!(state));
1380 event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1381 event.insert("budget_ms".to_owned(), Value::Null);
1382 *seq += 1;
1383 emit(&Value::Object(event))
1384}
1385
1386fn run_say_events(
1391 cli: &Cli,
1392 args: &SayArgs,
1393 environment: &Environment,
1394 stdin: &mut dyn Read,
1395 run: &robot::RunContext,
1396 raw_audio: &mut dyn Write,
1397 emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
1398) -> Result<(), FttsError> {
1399 let settings = EffectiveSettings::resolve(cli, environment)?;
1400
1401 let mut start = run.event(robot::EventType::RunStart);
1402 start.insert("command".to_owned(), json!("say"));
1403 start.insert("profile".to_owned(), json!(settings.profile.as_str()));
1404 start.insert(
1405 "packet_frames".to_owned(),
1406 json!(settings.packet_frames.as_str()),
1407 );
1408 start.insert("math_mode".to_owned(), json!(settings.math_mode.as_str()));
1409 start.insert("stateless".to_owned(), json!(true));
1410 start.insert("seed".to_owned(), json!(cli.seed));
1411 start.insert("model".to_owned(), json!(args.model.as_deref()));
1412 start.insert(
1413 "voice".to_owned(),
1414 json!(args.voice.as_ref().map(|path| path.display().to_string())),
1415 );
1416 emit(&Value::Object(start))?;
1417
1418 let mut seq = 0u64;
1419
1420 emit_stage(run, emit, "resolve", "begin", &mut seq)?;
1421 let text = read_text(args, stdin)?;
1422 let model = resolve_model(args.model.as_deref(), environment)?;
1423 let voice = resolve_requested_voice(args.voice.as_deref(), environment)?;
1424 emit_stage(run, emit, "resolve", "end", &mut seq)?;
1425
1426 let request = SynthesisRequest::new(text)
1427 .with_normalization_options(settings.normalization_options())
1428 .with_normalization_trace(cli.trace.is_some());
1429
1430 let mut prepared = run.event(robot::EventType::TextPrepared);
1434 prepared.insert("normalize".to_owned(), json!(settings.normalize.as_str()));
1435 prepared.insert(
1436 "unicode_version".to_owned(),
1437 json!(ftts_model_qwen::tokenizer::unicode_version()),
1438 );
1439 prepared.insert("char_count".to_owned(), json!(request.text.chars().count()));
1440 prepared.insert(
1441 "trace_requested".to_owned(),
1442 json!(request.trace_normalization),
1443 );
1444 emit(&Value::Object(prepared))?;
1445
1446 let requested_output: Option<PathBuf> = args
1448 .output
1449 .clone()
1450 .or_else(|| args.output_positional.clone());
1451 let output_plan = requested_output
1452 .as_deref()
1453 .map(OutputPlan::for_path)
1454 .transpose()?;
1455
1456 emit_stage(run, emit, "admission", "begin", &mut seq)?;
1457 let admission = admission_plan(&request.text, &settings)?;
1458 emit_stage(run, emit, "admission", "end", &mut seq)?;
1459
1460 if args.check {
1461 let event = json!({
1462 "schema_version": ROBOT_SCHEMA_VERSION,
1463 "event": "check_complete",
1464 "run_id": run.run_id(),
1465 "model": model,
1466 "voice": voice,
1467 "profile": settings.profile.as_str(),
1468 "packet_frames": settings.packet_frames.as_str(),
1469 "math_mode": settings.math_mode.as_str(),
1470 "voice_pack": settings.voice_pack.as_str(),
1471 "normalize": settings.normalize.as_str(),
1472 "normalization_trace_requested": request.trace_normalization,
1473 "seed": cli.seed,
1474 "trace": cli.trace.as_ref().map(|path| path.display().to_string()),
1475 "output": requested_output.as_ref().map(|path| path.display().to_string()),
1476 "admission": admission,
1477 });
1478 emit(&event)?;
1479 let mut complete = run.event(robot::EventType::RunComplete);
1480 complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
1481 complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1482 complete.insert("frames".to_owned(), json!(0));
1483 complete.insert("audio_bytes".to_owned(), json!(0));
1484 emit(&Value::Object(complete))?;
1485 return Ok(());
1486 }
1487
1488 let raw_stream = args.stream == Some(StreamMode::Raw);
1492 let mut audio = match (&output_plan, raw_stream) {
1493 (Some(plan), false) => AudioOutput::wav(&plan.wav_path)?,
1494 (None, true) => AudioOutput::raw(),
1495 (None, false) => {
1496 return Err(FttsError::Usage(
1497 "`ftts say` has nowhere to put the audio; add an output path (`ftts say \"text\" \
1498 out.wav`, or `-o PATH`) or `--stream raw` for PCM on stdout"
1499 .to_owned(),
1500 ));
1501 }
1502 (Some(_), true) => unreachable!("clap enforces the conflict"),
1504 };
1505
1506 emit_stage(run, emit, "load", "begin", &mut seq)?;
1508 let bundle = synth::ModelBundle::resolve(Path::new(&model))?;
1509 let voice_path = match voice.as_deref().map(PathBuf::from).or_else(|| {
1510 let candidate = bundle.root.join("default.spk");
1511 candidate.is_file().then_some(candidate)
1512 }) {
1513 Some(path) => path,
1514 None => materialize_preset_voice(DEFAULT_PRESET_VOICE)
1519 .expect("the default preset name is a member of PRESET_VOICES")?,
1520 };
1521 let mut say_denoise_report = None;
1527 let denoise_ephemeral = bundle.root.join(synth::DENOISE_ARTIFACT_RELPATH).is_file();
1528 let speaker = synth::speaker_from_voice(
1529 &bundle,
1530 &voice_path,
1531 synth::ReferenceCleanup {
1532 denoise: denoise_ephemeral.then_some(&mut say_denoise_report),
1533 dereverb: None,
1534 },
1535 )?;
1536 let use_resident = resident::enabled(args.no_resident);
1541 let loaded = if use_resident {
1542 None
1543 } else {
1544 Some(synth::LoadedModel::load(&bundle)?)
1545 };
1546 emit_stage(run, emit, "load", "end", &mut seq)?;
1547
1548 let observer = |_event: ftts_core::SynthesisEvent| {};
1555
1556 let seed = cli.seed.unwrap_or(0);
1560
1561 emit_stage(run, emit, "synthesis", "begin", &mut seq)?;
1562 let resident_audio = if use_resident {
1563 resident::try_synthesize(
1564 &bundle,
1565 &resident::WireRequest {
1566 text: &request.text,
1567 normalize: settings.normalize.as_str(),
1568 trace: request.trace_normalization,
1569 speaker: &speaker,
1570 seed,
1571 },
1572 )?
1573 } else {
1574 None
1575 };
1576 let audio_result = match resident_audio {
1577 Some(audio) => audio,
1578 None => {
1579 let loaded = match loaded {
1580 Some(loaded) => loaded,
1581 None => synth::LoadedModel::load(&bundle)?,
1583 };
1584 let engine = ftts_core::TtsEngine::from_process_environment()
1585 .map_err(|error| FttsError::Generic(format!("cannot start the engine: {error}")))?;
1586 let cancellation = ftts_core::CancellationToken::new();
1587 synth::synthesize(
1588 &loaded,
1589 &engine,
1590 &request,
1591 &speaker,
1592 seed,
1593 &cancellation,
1594 &observer,
1595 )?
1596 }
1597 };
1598 emit_stage(run, emit, "synthesis", "end", &mut seq)?;
1599
1600 let packet_samples = settings.packet_frames.samples_per_packet();
1602 let packet_frame_count = settings.packet_frames.frames_per_packet();
1603 emit_stage(run, emit, "output", "begin", &mut seq)?;
1604 for packet in audio_result.pcm.chunks(packet_samples) {
1605 let event = audio.write_packet(packet, raw_audio, run.run_id(), packet_frame_count)?;
1606 emit(&event)?;
1607 }
1608 let audio_bytes = audio.byte_offset();
1609 let samples = audio.finish()?;
1610 if let Some(plan) = &output_plan {
1611 plan.finalize()?;
1612 }
1613 emit_stage(run, emit, "output", "end", &mut seq)?;
1614
1615 let mut complete = run.event(robot::EventType::RunComplete);
1616 complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
1617 complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1618 complete.insert("frames".to_owned(), json!(audio_result.frames));
1619 complete.insert("audio_bytes".to_owned(), json!(audio_bytes));
1620 complete.insert("samples".to_owned(), json!(samples));
1621 complete.insert(
1622 "duration_ms".to_owned(),
1623 json!(samples * 1000 / u64::from(ftts_core::audio::SAMPLE_RATE_HZ)),
1624 );
1625 complete.insert(
1626 "prepared_token_count".to_owned(),
1627 json!(audio_result.prepared_token_count),
1628 );
1629 if let Some(ttfa) = audio_result.ttfa {
1630 complete.insert(
1631 "ttfa_ms".to_owned(),
1632 json!(u64::try_from(ttfa.as_millis()).unwrap_or(u64::MAX)),
1633 );
1634 }
1635 emit(&Value::Object(complete))?;
1636 Ok(())
1637}
1638
1639pub enum AudioSink {
1648 Wav(Box<ftts_core::audio::WavWriter<fs::File>>),
1651 RawPcm,
1653 None,
1655}
1656
1657pub struct AudioOutput {
1659 sink: AudioSink,
1660 byte_offset: u64,
1661 samples_written: u64,
1662}
1663
1664#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1666enum OutputFormat {
1667 Wav,
1669 M4a,
1671 Mp3,
1673 Flac,
1675}
1676
1677#[derive(Clone, Debug)]
1685struct OutputPlan {
1686 final_path: PathBuf,
1688 wav_path: PathBuf,
1690 format: OutputFormat,
1691}
1692
1693impl OutputPlan {
1694 fn for_path(path: &Path) -> Result<Self, FttsError> {
1695 let extension = path
1696 .extension()
1697 .and_then(|extension| extension.to_str())
1698 .map(str::to_ascii_lowercase);
1699 let format = match extension.as_deref() {
1700 Some("wav") | None => OutputFormat::Wav,
1701 Some("m4a" | "aac") => OutputFormat::M4a,
1702 Some("mp3") => OutputFormat::Mp3,
1703 Some("flac") => OutputFormat::Flac,
1704 Some(other) => {
1705 return Err(FttsError::Usage(format!(
1706 "unsupported output extension `.{other}`; use .wav (native), .m4a, .mp3, or \
1707 .flac (system encoder)"
1708 )));
1709 }
1710 };
1711 let wav_path = if format == OutputFormat::Wav {
1712 path.to_path_buf()
1713 } else {
1714 let mut staging = path.as_os_str().to_owned();
1715 staging.push(".ftts-staging.wav");
1716 PathBuf::from(staging)
1717 };
1718 Ok(Self {
1719 final_path: path.to_path_buf(),
1720 wav_path,
1721 format,
1722 })
1723 }
1724
1725 fn finalize(&self) -> Result<(), FttsError> {
1727 if self.format == OutputFormat::Wav {
1728 return Ok(());
1729 }
1730 let wav = self.wav_path.as_os_str();
1731 let target = self.final_path.as_os_str();
1732 let attempts: &[(&str, Vec<&std::ffi::OsStr>)] = &match self.format {
1734 OutputFormat::M4a => [
1735 (
1736 "afconvert",
1737 vec![
1738 "-f".as_ref(),
1739 "m4af".as_ref(),
1740 "-d".as_ref(),
1741 "aac".as_ref(),
1742 wav,
1743 target,
1744 ],
1745 ),
1746 (
1747 "ffmpeg",
1748 vec![
1749 "-y".as_ref(),
1750 "-loglevel".as_ref(),
1751 "error".as_ref(),
1752 "-i".as_ref(),
1753 wav,
1754 "-c:a".as_ref(),
1755 "aac".as_ref(),
1756 target,
1757 ],
1758 ),
1759 ],
1760 OutputFormat::Mp3 => [
1761 (
1762 "lame",
1763 vec!["--quiet".as_ref(), "-V2".as_ref(), wav, target],
1764 ),
1765 (
1766 "ffmpeg",
1767 vec![
1768 "-y".as_ref(),
1769 "-loglevel".as_ref(),
1770 "error".as_ref(),
1771 "-i".as_ref(),
1772 wav,
1773 "-codec:a".as_ref(),
1774 "libmp3lame".as_ref(),
1775 "-q:a".as_ref(),
1776 "2".as_ref(),
1777 target,
1778 ],
1779 ),
1780 ],
1781 OutputFormat::Flac => [
1782 (
1783 "flac",
1784 vec![
1785 "--totally-silent".as_ref(),
1786 "-f".as_ref(),
1787 "-o".as_ref(),
1788 target,
1789 wav,
1790 ],
1791 ),
1792 (
1793 "ffmpeg",
1794 vec![
1795 "-y".as_ref(),
1796 "-loglevel".as_ref(),
1797 "error".as_ref(),
1798 "-i".as_ref(),
1799 wav,
1800 "-c:a".as_ref(),
1801 "flac".as_ref(),
1802 target,
1803 ],
1804 ),
1805 ],
1806 OutputFormat::Wav => unreachable!("handled above"),
1807 };
1808
1809 let mut tried = Vec::new();
1810 for (tool, arguments) in attempts {
1811 match std::process::Command::new(tool).args(arguments).status() {
1814 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1815 tried.push(*tool);
1816 }
1817 Err(error) => {
1818 return Err(FttsError::Generic(format!(
1819 "audio encoder `{tool}` could not run: {error}; the synthesized WAV is \
1820 preserved at {}",
1821 self.wav_path.display()
1822 )));
1823 }
1824 Ok(status) if status.success() => {
1825 let _ = fs::remove_file(&self.wav_path);
1828 return Ok(());
1829 }
1830 Ok(status) => {
1831 return Err(FttsError::Generic(format!(
1832 "audio encoder `{tool}` exited with {status}; the synthesized WAV is \
1833 preserved at {}",
1834 self.wav_path.display()
1835 )));
1836 }
1837 }
1838 }
1839 Err(FttsError::Generic(format!(
1840 "no system audio encoder found for {} (tried: {}); install one or use a .wav output. \
1841 The synthesized WAV is preserved at {}",
1842 self.final_path.display(),
1843 tried.join(", "),
1844 self.wav_path.display()
1845 )))
1846 }
1847}
1848
1849impl AudioOutput {
1850 pub fn wav(path: &Path) -> Result<Self, FttsError> {
1856 let file = fs::File::create(path).map_err(|error| {
1857 FttsError::Generic(format!(
1858 "cannot create audio output {}: {error}",
1859 path.display()
1860 ))
1861 })?;
1862 let writer = ftts_core::audio::WavWriter::new(file, ftts_core::audio::SAMPLE_RATE_HZ)
1863 .map_err(|error| {
1864 FttsError::Generic(format!(
1865 "cannot write WAV header to {}: {error}",
1866 path.display()
1867 ))
1868 })?;
1869 Ok(Self {
1870 sink: AudioSink::Wav(Box::new(writer)),
1871 byte_offset: 0,
1872 samples_written: 0,
1873 })
1874 }
1875
1876 #[must_use]
1878 pub const fn raw() -> Self {
1879 Self {
1880 sink: AudioSink::RawPcm,
1881 byte_offset: 0,
1882 samples_written: 0,
1883 }
1884 }
1885
1886 #[must_use]
1888 pub const fn none() -> Self {
1889 Self {
1890 sink: AudioSink::None,
1891 byte_offset: 0,
1892 samples_written: 0,
1893 }
1894 }
1895
1896 #[must_use]
1898 pub const fn sink_name(&self) -> &'static str {
1899 match self.sink {
1900 AudioSink::Wav(_) => "file",
1901 AudioSink::RawPcm => "stdout",
1902 AudioSink::None => "none",
1903 }
1904 }
1905
1906 #[must_use]
1908 pub const fn byte_offset(&self) -> u64 {
1909 self.byte_offset
1910 }
1911
1912 pub fn write_packet(
1925 &mut self,
1926 pcm: &[f32],
1927 raw: &mut dyn Write,
1928 run_id: &str,
1929 frame_count: u8,
1930 ) -> Result<Value, FttsError> {
1931 let offset_before = self.byte_offset;
1932 let bytes = (pcm.len() * 2) as u64;
1933
1934 match &mut self.sink {
1935 AudioSink::Wav(writer) => writer.write_samples(pcm).map_err(|error| {
1936 FttsError::Generic(format!("cannot write audio samples: {error}"))
1937 })?,
1938 AudioSink::RawPcm => {
1939 let mut buffer = Vec::with_capacity(pcm.len() * 2);
1940 for sample in pcm {
1941 buffer
1942 .extend_from_slice(&ftts_core::audio::sample_to_i16(*sample).to_le_bytes());
1943 }
1944 raw.write_all(&buffer).map_err(|error| {
1945 FttsError::Generic(format!("cannot write raw PCM: {error}"))
1946 })?;
1947 }
1948 AudioSink::None => {}
1949 }
1950
1951 self.byte_offset += bytes;
1952 self.samples_written += pcm.len() as u64;
1953
1954 let mut event = robot::EventType::AudioChunk.event();
1955 event.insert("run_id".to_owned(), json!(run_id));
1956 event.insert("byte_offset".to_owned(), json!(offset_before));
1957 event.insert("bytes".to_owned(), json!(bytes));
1958 event.insert(
1959 "duration_ms".to_owned(),
1960 json!((pcm.len() as u64) * 1000 / u64::from(ftts_core::audio::SAMPLE_RATE_HZ.max(1))),
1961 );
1962 event.insert("packet_frames".to_owned(), json!(frame_count.to_string()));
1963 event.insert("sink".to_owned(), json!(self.sink_name()));
1964 Ok(Value::Object(event))
1965 }
1966
1967 pub fn finish(self) -> Result<u64, FttsError> {
1973 let samples = self.samples_written;
1974 if let AudioSink::Wav(writer) = self.sink {
1975 writer.finish().map_err(|error| {
1976 FttsError::Generic(format!("cannot finalize the WAV header: {error}"))
1977 })?;
1978 }
1979 Ok(samples)
1980 }
1981}
1982
1983fn read_text(args: &SayArgs, stdin: &mut dyn Read) -> Result<String, FttsError> {
1984 let text = match (&args.text, &args.file) {
1985 (Some(text), None) if text == "-" => read_utf8(stdin, "stdin")?,
1986 (Some(text), None) => text.clone(),
1987 (None, Some(path)) if path == Path::new("-") => read_utf8(stdin, "stdin")?,
1988 (None, Some(path)) => fs::read_to_string(path).map_err(|error| {
1989 FttsError::Input(format!(
1990 "cannot read text file {}: {error}; use `ftts say --file PATH --check --model PATH`",
1991 path.display()
1992 ))
1993 })?,
1994 (None, None) => {
1995 return Err(FttsError::Usage(
1996 "missing text; use `ftts say TEXT`, `ftts say --file PATH`, or `ftts say -`".to_owned(),
1997 ));
1998 }
1999 (Some(_), Some(_)) => unreachable!("clap enforces the conflict"),
2000 };
2001
2002 if text.trim().is_empty() {
2003 return Err(FttsError::Input(
2004 "text is empty; provide non-whitespace UTF-8 text to `ftts say`".to_owned(),
2005 ));
2006 }
2007 Ok(text)
2008}
2009
2010fn read_utf8(reader: &mut dyn Read, source: &str) -> Result<String, FttsError> {
2011 let mut bytes = Vec::new();
2012 reader.read_to_end(&mut bytes).map_err(|error| {
2013 FttsError::Input(format!(
2014 "cannot read {source}: {error}; retry with readable UTF-8 input"
2015 ))
2016 })?;
2017 String::from_utf8(bytes).map_err(|error| {
2018 FttsError::Input(format!(
2019 "{source} is not valid UTF-8: {error}; transcode it before `ftts say`"
2020 ))
2021 })
2022}
2023
2024fn resolve_model(explicit: Option<&Path>, environment: &Environment) -> Result<String, FttsError> {
2025 resolve_model_from(
2026 explicit,
2027 &model_search_paths(environment),
2028 default_pull_model_dir(environment).as_deref(),
2029 )
2030}
2031
2032fn resolve_model_from(
2037 explicit: Option<&Path>,
2038 searched: &[PathBuf],
2039 pull_dir: Option<&Path>,
2040) -> Result<String, FttsError> {
2041 if let Some(path) = explicit {
2042 if path.is_dir() {
2046 return Ok(path.display().to_string());
2047 }
2048 return resolve_existing_file(path, "model artifact")
2049 .map(|path| path.display().to_string());
2050 }
2051
2052 if let Some(path) = searched.iter().find(|path| path.is_file()) {
2053 return Ok(path.display().to_string());
2054 }
2055 if let Some(directory) = searched
2060 .iter()
2061 .filter_map(|path| path.parent())
2062 .find(|directory| directory.join("model.safetensors").is_file())
2063 {
2064 return Ok(directory.display().to_string());
2065 }
2066
2067 if let Some(directory) = pull_dir
2071 && directory.is_dir()
2072 && synth::ModelBundle::resolve(directory).is_ok()
2073 {
2074 return Ok(directory.display().to_string());
2075 }
2076
2077 let searched = searched
2078 .iter()
2079 .map(|path| path.display().to_string())
2080 .collect::<Vec<_>>()
2081 .join(", ");
2082 Err(FttsError::ModelNotFound(format!(
2083 "no model artifact was found; searched: [{searched}]; run `ftts pull` to fetch the model \
2084 (~2.0 GB), or pass --model PATH or set FTTS_MODEL_DIR"
2085 )))
2086}
2087
2088fn resolve_optional_file(path: Option<&Path>, label: &str) -> Result<Option<String>, FttsError> {
2089 path.map(|path| resolve_existing_file(path, label).map(|path| path.display().to_string()))
2090 .transpose()
2091}
2092
2093fn resolve_requested_voice(
2094 explicit: Option<&Path>,
2095 environment: &Environment,
2096) -> Result<Option<String>, FttsError> {
2097 if let Some(path) = explicit {
2098 if !path.exists()
2101 && let Some(name) = path.to_str()
2102 && let Some(materialized) = materialize_preset_voice(name)
2103 {
2104 return materialized.map(|path| Some(path.display().to_string()));
2105 }
2106 let looks_like_name =
2109 path.extension().is_none() && path.components().count() == 1 && !path.exists();
2110 return resolve_optional_file(Some(path), "voice source").map_err(|error| {
2111 if looks_like_name {
2112 FttsError::Input(format!(
2113 "{error}; built-in voice names are: {}",
2114 preset_names()
2115 ))
2116 } else {
2117 error
2118 }
2119 });
2120 }
2121 environment
2122 .value("FTTS_DEFAULT_VOICE")
2123 .map(Path::new)
2124 .map(|path| resolve_existing_file(path, "FTTS_DEFAULT_VOICE"))
2125 .transpose()
2126 .map(|path| path.map(|path| path.display().to_string()))
2127}
2128
2129fn run_enroll(
2130 args: &EnrollArgs,
2131 environment: &Environment,
2132 stdout: &mut dyn Write,
2133) -> Result<(), FttsError> {
2134 let _ = args.force;
2135 let model = resolve_model(args.model.as_deref(), environment)?;
2136 let bundle = synth::ModelBundle::resolve(Path::new(&model))?;
2137 let output = match (&args.output, args.default) {
2138 (Some(path), false) => path.clone(),
2139 (None, true) => bundle.root.join("default.spk"),
2140 (None, false) => {
2141 return Err(FttsError::Usage(
2142 "`ftts enroll` needs -o PATH or --default; enrollment never overwrites a voice source"
2143 .to_owned(),
2144 ));
2145 }
2146 (Some(_), true) => unreachable!("clap enforces the conflict"),
2147 };
2148 let mut denoise_report = None;
2149 let mut dereverb_report = None;
2150 let denoise = if args.no_denoise {
2154 false
2155 } else {
2156 args.denoise || bundle.root.join(synth::DENOISE_ARTIFACT_RELPATH).is_file()
2157 };
2158 let speaker = synth::speaker_from_voice(
2159 &bundle,
2160 &args.reference_audio,
2161 synth::ReferenceCleanup {
2162 denoise: denoise.then_some(&mut denoise_report),
2163 dereverb: args.dereverb.then_some(&mut dereverb_report),
2164 },
2165 )?;
2166 if let Some(report) = dereverb_report {
2169 style::ok(
2170 stdout,
2171 &format!(
2172 "dereverberated reference {}",
2173 style::detail(&format!(
2174 "RT60-equivalent {:.2} → {:.2} s",
2175 report.before_rt60_s, report.after_rt60_s
2176 )),
2177 ),
2178 )
2179 .map_err(|error| FttsError::Generic(format!("cannot write dereverb report: {error}")))?;
2180 }
2181 if let Some(report) = denoise_report {
2184 let moved = report.before_dbfs - report.after_dbfs;
2185 style::ok(
2186 stdout,
2187 &format!(
2188 "denoised reference {}",
2189 style::detail(&format!(
2190 "pause floor {:.1} → {:.1} dBFS ({moved:.1} dB quieter)",
2191 report.before_dbfs, report.after_dbfs
2192 )),
2193 ),
2194 )
2195 .map_err(|error| FttsError::Generic(format!("cannot write denoise report: {error}")))?;
2196 }
2197
2198 let backup = if output.exists() {
2203 let consented = if args.overwrite {
2204 true
2205 } else {
2206 style::warn(
2207 stdout,
2208 &format!(
2209 "{} already holds an enrolled voice",
2210 style::emphasis(&output.display().to_string())
2211 ),
2212 )
2213 .map_err(|error| {
2214 FttsError::Generic(format!("cannot write overwrite notice: {error}"))
2215 })?;
2216 match style::confirm(stdout, "Replace it?")
2217 .map_err(|error| FttsError::Generic(format!("cannot read a reply: {error}")))?
2218 {
2219 Some(reply) => reply,
2220 None => {
2221 return Err(FttsError::Input(format!(
2222 "{} already exists; pass --overwrite to replace it (the displaced voice is \
2223 kept as {}.bak)",
2224 output.display(),
2225 output.display()
2226 )));
2227 }
2228 }
2229 };
2230 if !consented {
2231 style::info(stdout, "left the existing voice in place")
2232 .map_err(|error| FttsError::Generic(format!("cannot write result: {error}")))?;
2233 return Ok(());
2234 }
2235 Some(synth::replace_speaker_vector(&output, &speaker)?)
2236 } else {
2237 synth::write_speaker_vector_new(&output, &speaker)?;
2238 None
2239 };
2240
2241 style::ok(
2242 stdout,
2243 &format!(
2244 "enrolled {} → {}",
2245 style::emphasis(&args.reference_audio.display().to_string()),
2246 style::emphasis(&output.display().to_string()),
2247 ),
2248 )
2249 .map_err(|error| FttsError::Generic(format!("cannot write enrollment result: {error}")))?;
2250 if let Some(backup) = backup {
2251 style::info(
2252 stdout,
2253 &format!(
2254 "previous voice kept at {}",
2255 style::emphasis(&backup.display().to_string())
2256 ),
2257 )
2258 .map_err(|error| FttsError::Generic(format!("cannot write backup notice: {error}")))?;
2259 }
2260 if args.default {
2261 style::info(
2262 stdout,
2263 &format!(
2264 "{} will use it when --voice is absent",
2265 style::emphasis("ftts say")
2266 ),
2267 )
2268 .map_err(|error| FttsError::Generic(format!("cannot write result: {error}")))?;
2269 }
2270 Ok(())
2271}
2272
2273#[derive(Clone, Debug)]
2275struct ModelManifestFile {
2276 asset: String,
2278 dest: String,
2280 sha256: String,
2282 bytes: u64,
2284}
2285
2286#[derive(Clone, Debug)]
2288struct ModelManifest {
2289 model_id: String,
2290 release_tag: String,
2291 repo: String,
2292 files: Vec<ModelManifestFile>,
2293}
2294
2295impl ModelManifest {
2296 fn embedded() -> Result<Self, FttsError> {
2299 Self::parse(PINNED_MODEL_MANIFEST)
2300 }
2301
2302 fn parse(text: &str) -> Result<Self, FttsError> {
2305 let value: Value = serde_json::from_str(text).map_err(|error| {
2306 FttsError::ArtifactFormat(format!("model manifest is not valid JSON: {error}"))
2307 })?;
2308 if value["schema_version"].as_u64() != Some(1) {
2309 return Err(FttsError::ArtifactFormat(format!(
2310 "model manifest schema_version {} is not the supported 1",
2311 value["schema_version"]
2312 )));
2313 }
2314 let model_id = manifest_string(&value, "model_id")?;
2315 let release_tag = manifest_string(&value, "release_tag")?;
2316 let repo = manifest_string(&value, "repo")?;
2317 let files = value["files"]
2318 .as_array()
2319 .filter(|files| !files.is_empty())
2320 .ok_or_else(|| {
2321 FttsError::ArtifactFormat("model manifest needs a non-empty files array".to_owned())
2322 })?
2323 .iter()
2324 .map(parse_manifest_file)
2325 .collect::<Result<Vec<_>, _>>()?;
2326 Ok(Self {
2327 model_id,
2328 release_tag,
2329 repo,
2330 files,
2331 })
2332 }
2333
2334 fn download_url(&self, file: &ModelManifestFile) -> String {
2336 format!(
2337 "https://github.com/{}/releases/download/{}/{}",
2338 self.repo, self.release_tag, file.asset
2339 )
2340 }
2341
2342 fn total_bytes(&self) -> u64 {
2343 self.files
2344 .iter()
2345 .fold(0, |sum, file| sum.saturating_add(file.bytes))
2346 }
2347}
2348
2349fn manifest_string(value: &Value, field: &str) -> Result<String, FttsError> {
2350 value[field]
2351 .as_str()
2352 .filter(|text| !text.is_empty())
2353 .map(str::to_owned)
2354 .ok_or_else(|| {
2355 FttsError::ArtifactFormat(format!(
2356 "model manifest field {field} must be a non-empty string"
2357 ))
2358 })
2359}
2360
2361fn parse_manifest_file(value: &Value) -> Result<ModelManifestFile, FttsError> {
2362 let asset = manifest_string(value, "asset")?;
2363 if asset.contains('/') || asset.contains('\\') {
2364 return Err(FttsError::ArtifactFormat(format!(
2365 "manifest asset {asset:?} must be a bare release-asset name"
2366 )));
2367 }
2368 let dest = manifest_string(value, "dest")?;
2369 validate_manifest_dest(&dest)?;
2370 let sha256 = manifest_string(value, "sha256")?;
2371 if !is_sha256_hex(&sha256) {
2372 return Err(FttsError::ArtifactFormat(format!(
2373 "manifest sha256 for {asset} must be 64 lowercase hex characters"
2374 )));
2375 }
2376 let bytes = value["bytes"]
2377 .as_u64()
2378 .filter(|bytes| *bytes > 0)
2379 .ok_or_else(|| {
2380 FttsError::ArtifactFormat(format!(
2381 "manifest bytes for {asset} must be a positive integer"
2382 ))
2383 })?;
2384 Ok(ModelManifestFile {
2385 asset,
2386 dest,
2387 sha256,
2388 bytes,
2389 })
2390}
2391
2392fn validate_manifest_dest(dest: &str) -> Result<(), FttsError> {
2395 let path = Path::new(dest);
2396 let traversal_free = path
2397 .components()
2398 .all(|component| matches!(component, std::path::Component::Normal(_)));
2399 if path.is_absolute() || dest.contains('\\') || !traversal_free {
2400 return Err(FttsError::ArtifactFormat(format!(
2401 "manifest dest {dest:?} must be a relative path with no traversal; it is joined under the model directory"
2402 )));
2403 }
2404 Ok(())
2405}
2406
2407fn is_sha256_hex(text: &str) -> bool {
2408 text.len() == 64
2409 && text
2410 .bytes()
2411 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
2412}
2413
2414fn default_pull_model_dir(environment: &Environment) -> Option<PathBuf> {
2417 if let Some(first) = environment
2418 .value("FTTS_MODEL_DIR")
2419 .and_then(|dirs| std::env::split_paths(dirs).next())
2420 .filter(|path| !path.as_os_str().is_empty())
2421 {
2422 return Some(first);
2423 }
2424 std::env::var_os("HOME").map(|home| PathBuf::from(home).join(DEFAULT_MODEL_CACHE_SUBDIR))
2425}
2426
2427#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2429enum PullDecision {
2430 Skip,
2432 Download,
2434}
2435
2436fn pull_decision(dest: &Path, file: &ModelManifestFile, force: bool) -> PullDecision {
2441 if force {
2442 return PullDecision::Download;
2443 }
2444 let Ok(metadata) = fs::metadata(dest) else {
2445 return PullDecision::Download;
2446 };
2447 if !metadata.is_file() || metadata.len() != file.bytes {
2448 return PullDecision::Download;
2449 }
2450 match ftts_artifacts::sha256::hex_digest_file(dest) {
2451 Ok(digest) if digest == file.sha256 => PullDecision::Skip,
2452 _ => PullDecision::Download,
2453 }
2454}
2455
2456fn download_with_curl(url: &str, staging: &Path, pinned_bytes: u64) -> Result<(), FttsError> {
2462 let outcome = std::process::Command::new("curl")
2467 .args([
2468 "-L",
2469 "--fail",
2470 "--retry",
2471 "3",
2472 "-sS",
2473 "--proto",
2474 "=https",
2475 "--proto-redir",
2476 "=https",
2477 "--connect-timeout",
2478 "30",
2479 "--speed-limit",
2480 "1024",
2481 "--speed-time",
2482 "60",
2483 "--max-filesize",
2484 ])
2485 .arg(pinned_bytes.to_string())
2486 .arg("-o")
2487 .arg(staging)
2488 .arg(url)
2489 .status();
2490 match outcome {
2491 Err(error) if error.kind() == io::ErrorKind::NotFound => Err(FttsError::Generic(
2492 "`ftts pull` downloads with the system `curl`, which was not found on PATH; \
2493 install curl and retry, or download the release assets by hand"
2494 .to_owned(),
2495 )),
2496 Err(error) => Err(FttsError::Generic(format!("cannot run curl: {error}"))),
2497 Ok(status) if status.success() => Ok(()),
2498 Ok(status) => Err(FttsError::Generic(format!(
2499 "curl failed downloading {url} ({status}); check network access and retry `ftts pull`"
2500 ))),
2501 }
2502}
2503
2504fn verify_pulled_file(path: &Path, file: &ModelManifestFile) -> Result<(), FttsError> {
2506 let metadata = fs::metadata(path).map_err(|error| {
2507 FttsError::Generic(format!(
2508 "cannot stat downloaded {}: {error}",
2509 path.display()
2510 ))
2511 })?;
2512 if metadata.len() != file.bytes {
2513 return Err(FttsError::ArtifactFormat(format!(
2514 "downloaded {} is {} bytes, expected {}; the incomplete download was discarded, retry `ftts pull`",
2515 file.asset,
2516 metadata.len(),
2517 file.bytes
2518 )));
2519 }
2520 let digest = ftts_artifacts::sha256::hex_digest_file(path).map_err(|error| {
2521 FttsError::Generic(format!(
2522 "cannot hash downloaded {}: {error}",
2523 path.display()
2524 ))
2525 })?;
2526 if digest != file.sha256 {
2527 return Err(FttsError::ArtifactFormat(format!(
2528 "downloaded {} carries sha256 {digest}, expected {}; the corrupt download was discarded, retry `ftts pull`",
2529 file.asset, file.sha256
2530 )));
2531 }
2532 Ok(())
2533}
2534
2535fn pull_staging_path(dest: &Path) -> PathBuf {
2537 let mut name = dest.file_name().map(OsString::from).unwrap_or_default();
2538 name.push(".part");
2539 dest.with_file_name(name)
2540}
2541
2542fn pull_one_file(
2549 manifest: &ModelManifest,
2550 file: &ModelManifestFile,
2551 dest: &Path,
2552) -> Result<(), FttsError> {
2553 if let Some(parent) = dest.parent() {
2554 fs::create_dir_all(parent).map_err(|error| {
2555 FttsError::Generic(format!(
2556 "cannot create model directory {}: {error}",
2557 parent.display()
2558 ))
2559 })?;
2560 }
2561 let staging = pull_staging_path(dest);
2562 match fs::symlink_metadata(&staging) {
2567 Ok(_) => fs::remove_file(&staging).map_err(|error| {
2568 FttsError::Generic(format!(
2569 "cannot clear stale staging file {}: {error}",
2570 staging.display()
2571 ))
2572 })?,
2573 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2574 Err(error) => {
2575 return Err(FttsError::Generic(format!(
2576 "cannot stat staging path {}: {error}",
2577 staging.display()
2578 )));
2579 }
2580 }
2581 let url = manifest.download_url(file);
2582 let outcome = download_with_curl(&url, &staging, file.bytes)
2583 .and_then(|()| verify_pulled_file(&staging, file));
2584 if let Err(error) = outcome {
2585 let _ = fs::remove_file(&staging);
2586 return Err(error);
2587 }
2588 fs::OpenOptions::new()
2594 .write(true)
2595 .open(&staging)
2596 .and_then(|file| file.sync_all())
2597 .map_err(|error| {
2598 FttsError::Generic(format!(
2599 "cannot fsync downloaded {}: {error}",
2600 staging.display()
2601 ))
2602 })?;
2603 fs::rename(&staging, dest).map_err(|error| {
2604 FttsError::Generic(format!(
2605 "downloaded {} verified but could not be published to {}: {error}",
2606 file.asset,
2607 dest.display()
2608 ))
2609 })
2610}
2611
2612fn run_pull(
2613 args: &PullArgs,
2614 environment: &Environment,
2615 stdout: &mut dyn Write,
2616) -> Result<(), FttsError> {
2617 let manifest = ModelManifest::embedded()?;
2618 let destination = match &args.model {
2619 Some(path) => path.clone(),
2620 None => default_pull_model_dir(environment).ok_or_else(|| {
2621 FttsError::Usage(
2622 "cannot choose a model directory: pass --model PATH, or set FTTS_MODEL_DIR or HOME"
2623 .to_owned(),
2624 )
2625 })?,
2626 };
2627 writeln!(
2628 stdout,
2629 "pulling {} ({} files, {} bytes) into {}",
2630 manifest.model_id,
2631 manifest.files.len(),
2632 manifest.total_bytes(),
2633 destination.display()
2634 )
2635 .map_err(output_error)?;
2636 for file in &manifest.files {
2637 let dest = destination.join(&file.dest);
2638 match pull_decision(&dest, file, args.force) {
2639 PullDecision::Skip => writeln!(
2640 stdout,
2641 "{} ({} bytes): already present, verified",
2642 file.dest, file.bytes
2643 )
2644 .map_err(output_error)?,
2645 PullDecision::Download => {
2646 writeln!(stdout, "{} ({} bytes): downloading", file.dest, file.bytes)
2647 .map_err(output_error)?;
2648 pull_one_file(&manifest, file, &dest)?;
2649 writeln!(stdout, "{} ({} bytes): verified", file.dest, file.bytes)
2650 .map_err(output_error)?;
2651 }
2652 }
2653 }
2654 writeln!(stdout, "model ready at {}", destination.display()).map_err(output_error)
2655}
2656
2657fn resolve_existing_file<'a>(path: &'a Path, label: &str) -> Result<&'a Path, FttsError> {
2658 if path.is_file() {
2659 Ok(path)
2660 } else {
2661 Err(FttsError::ModelNotFound(format!(
2662 "{label} {} does not exist or is not a file; use an existing PATH",
2663 path.display()
2664 )))
2665 }
2666}
2667
2668fn admission_plan(text: &str, settings: &EffectiveSettings) -> Result<Value, FttsError> {
2681 if text.len() > SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES {
2682 return Err(FttsError::BudgetTimeout(format!(
2683 "text is {} bytes, above the Phase-0 admission bound of {} bytes; split the document before retrying",
2684 text.len(),
2685 SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES
2686 )));
2687 }
2688
2689 let characters = text.chars().count();
2690 let estimated_prompt_tokens = u64::try_from(characters).unwrap_or(u64::MAX);
2694 let policy = ftts_core::process_engine_config().admission;
2697
2698 match policy.admit(estimated_prompt_tokens) {
2699 Ok(plan) => Ok(json!({
2700 "status": "accepted",
2701 "scope": "preflight on an ESTIMATED prompt length; the binding decision is the \
2702 engine's, taken after tokenization",
2703 "text_bytes": text.len(),
2704 "text_characters": characters,
2705 "estimated_prompt_tokens": estimated_prompt_tokens,
2706 "predicted_max_frames": plan.predicted_max_frames,
2707 "predicted_peak_bytes": plan.predicted_peak_bytes,
2708 "budget_bytes": plan.budget_bytes,
2709 "binding_constraint": plan.binding_constraint.as_str(),
2710 "packet_frames": settings.packet_frames.as_str(),
2711 "profile": settings.profile.as_str(),
2712 })),
2713 Err(rejection) => Err(FttsError::BudgetTimeout(rejection.to_string())),
2717 }
2718}
2719
2720fn run_voice_inspect(path: &Path, stdout: &mut dyn Write) -> Result<(), FttsError> {
2721 let path = resolve_existing_file(path, "voice pack")?;
2722 write_json_line(
2723 stdout,
2724 &json!({
2725 "schema_version": ROBOT_SCHEMA_VERSION,
2726 "event": "voice_inspect",
2727 "path": path.display().to_string(),
2728 "status": "header_inspection_pending_artifact_reader",
2729 }),
2730 )
2731}
2732
2733fn run_robot(
2734 command: RobotCommand,
2735 environment: &Environment,
2736 stdout: &mut dyn Write,
2737) -> Result<(), FttsError> {
2738 let event = match command {
2742 RobotCommand::Schema => robot::schema_document(robot::DOCUMENTED_ENVIRONMENT),
2743 RobotCommand::Health => {
2744 let searched = model_search_paths(environment);
2745 let found = searched.iter().find(|path| looks_like_model_artifact(path));
2746 let mut object = robot::EventType::Health.event();
2747 object.insert("status".to_owned(), json!("phase0_skeleton"));
2748 object.insert("model_loaded".to_owned(), json!(false));
2749 object.insert("model_present".to_owned(), json!(found.is_some()));
2752 object.insert(
2753 "model_path".to_owned(),
2754 json!(found.map(|path| path.display().to_string())),
2755 );
2756 object.insert(
2757 "model_dir".to_owned(),
2758 json!(environment.value("FTTS_MODEL_DIR")),
2759 );
2760 object.insert(
2763 "searched".to_owned(),
2764 json!(
2765 searched
2766 .iter()
2767 .map(|path| path.display().to_string())
2768 .collect::<Vec<_>>()
2769 ),
2770 );
2771 object.insert("stateless_default".to_owned(), json!(true));
2772 object.insert(
2773 "threads".to_owned(),
2774 json!(
2775 environment
2776 .value("FTTS_THREADS")
2777 .and_then(|value| value.parse::<u64>().ok())
2778 ),
2779 );
2780 object.insert(
2781 "recommended_command".to_owned(),
2782 json!("ftts say --check --model PATH TEXT"),
2783 );
2784 Value::Object(object)
2785 }
2786 RobotCommand::Backends => {
2787 let mut object = robot::EventType::Backends.event();
2788 object.insert(
2791 "available".to_owned(),
2792 json!(
2793 ftts_kernels::int8::Int8Tier::available()
2794 .iter()
2795 .map(|tier| tier.as_str())
2796 .collect::<Vec<_>>()
2797 ),
2798 );
2799 object.insert(
2800 "dispatched".to_owned(),
2801 json!(ftts_kernels::int8::Int8Tier::dispatch().as_str()),
2802 );
2803 object.insert("isa_features".to_owned(), json!(detected_isa_features()));
2804 let plan = ftts_kernels::int8::autotuned_plan();
2805 object.insert(
2806 "kernel_plan".to_owned(),
2807 json!({
2808 "version": 0,
2809 "decode_gemv": plan.decode_gemv.as_str(),
2810 "batch_gemm": plan.batch_gemm.as_str(),
2811 "persisted": false,
2812 }),
2813 );
2814 object.insert("pool_sizing".to_owned(), Value::Null);
2815 object.insert(
2816 "force_arch".to_owned(),
2817 json!(environment.value("FTTS_FORCE_ARCH")),
2818 );
2819 Value::Object(object)
2820 }
2821 RobotCommand::Selftest => {
2822 let report = ftts_kernels::selftest::run_selftest();
2827 let checks: Vec<Value> = report
2828 .checks
2829 .iter()
2830 .map(|check| {
2831 json!({
2832 "row": check.row.id,
2833 "scope": check.row.scope.as_str(),
2834 "census_tensor": check.row.census_tensor,
2835 "reduction_k": check.row.reduction_k,
2836 "tier": check.tier.as_str(),
2837 "contract": check.contract.as_str(),
2838 "dispatched": check.tier == report.dispatched,
2839 "accumulator_i32": check.accumulator_i32,
2840 "reference_i64": check.reference_i64,
2841 "passed": check.passed,
2842 })
2843 })
2844 .collect();
2845 let mut object = robot::EventType::Selftest.event();
2846 object.insert(
2847 "status".to_owned(),
2848 json!(if report.passed() { "passed" } else { "failed" }),
2849 );
2850 object.insert("reason".to_owned(), Value::Null);
2851 object.insert("checks".to_owned(), json!(checks));
2852 Value::Object(object)
2853 }
2854 };
2855 write_json_line(stdout, &event)
2856}
2857
2858fn model_search_paths(environment: &Environment) -> Vec<PathBuf> {
2864 let mut searched = environment
2865 .value("FTTS_MODEL_DIR")
2866 .map(std::env::split_paths)
2867 .map(|paths| {
2868 paths
2869 .map(|path| path.join(MODEL_BASENAME))
2870 .collect::<Vec<_>>()
2871 })
2872 .unwrap_or_default();
2873 if let Some(home) = std::env::var_os("HOME") {
2874 let home = PathBuf::from(home);
2875 searched.push(home.join(".cache/franken_tts/models").join(MODEL_BASENAME));
2876 searched.push(home.join(DEFAULT_MODEL_CACHE_SUBDIR).join(MODEL_BASENAME));
2880 }
2881 searched
2882}
2883
2884fn looks_like_model_artifact(path: &Path) -> bool {
2890 use std::io::Read as _;
2891
2892 let Ok(mut file) = fs::File::open(path) else {
2893 return false;
2894 };
2895 let mut magic = [0u8; 5];
2896 file.read_exact(&mut magic).is_ok() && &magic == b"FTTSQ"
2897}
2898
2899fn detected_isa_features() -> Vec<&'static str> {
2904 let mut features = Vec::new();
2905 #[cfg(target_arch = "aarch64")]
2906 {
2907 if std::arch::is_aarch64_feature_detected!("neon") {
2908 features.push("neon");
2909 }
2910 if std::arch::is_aarch64_feature_detected!("dotprod") {
2911 features.push("dotprod");
2912 }
2913 if std::arch::is_aarch64_feature_detected!("i8mm") {
2914 features.push("i8mm");
2915 }
2916 }
2917 #[cfg(target_arch = "x86_64")]
2918 {
2919 if std::arch::is_x86_feature_detected!("avx2") {
2920 features.push("avx2");
2921 }
2922 if std::arch::is_x86_feature_detected!("avxvnni") {
2923 features.push("avx-vnni");
2924 }
2925 if std::arch::is_x86_feature_detected!("avx512vnni") {
2926 features.push("avx512-vnni");
2927 }
2928 }
2929 features
2930}
2931
2932fn run_doctor(
2933 args: &DoctorArgs,
2934 environment: &Environment,
2935 stdout: &mut dyn Write,
2936) -> Result<(), FttsError> {
2937 let report = json!({
2938 "schema_version": ROBOT_SCHEMA_VERSION,
2939 "status": "phase0_skeleton",
2940 "stateless_default": true,
2941 "persistent_history": false,
2942 "environment": environment.documented_values(),
2943 "recommended_command": "ftts robot schema",
2944 });
2945 if args.json {
2946 write_json_line(stdout, &report)
2947 } else {
2948 writeln!(stdout, "FrankenTTS Phase-0 CLI skeleton")
2949 .and_then(|_| writeln!(stdout, "stateless default: yes"))
2950 .and_then(|_| writeln!(stdout, "model loaded: no"))
2951 .and_then(|_| writeln!(stdout, "next: ftts robot schema"))
2952 .map_err(output_error)
2953 }
2954}
2955
2956fn write_json_line(writer: &mut dyn Write, value: &Value) -> Result<(), FttsError> {
2957 serde_json::to_writer(&mut *writer, value)
2958 .map_err(|error| FttsError::Generic(format!("cannot serialize CLI JSON: {error}")))?;
2959 writer.write_all(b"\n").map_err(output_error)
2960}
2961
2962fn output_error(error: io::Error) -> FttsError {
2963 FttsError::Generic(format!("cannot write CLI output: {error}"))
2964}
2965
2966#[cfg(test)]
2967mod tests {
2968 use super::*;
2969 use std::io::Cursor;
2970
2971 #[test]
2972 fn preset_voices_are_valid_speaker_vectors() {
2973 assert!(
2974 PRESET_VOICES
2975 .iter()
2976 .any(|(name, _, _)| *name == DEFAULT_PRESET_VOICE),
2977 "the default preset must exist in the table"
2978 );
2979 for (name, character, bytes) in PRESET_VOICES {
2980 assert_eq!(
2981 bytes.len(),
2982 synth::SPEAKER_VECTOR_BYTES,
2983 "preset {name} must be exactly one 1,024-float x-vector"
2984 );
2985 assert!(
2986 !character.is_empty(),
2987 "preset {name} needs a character line"
2988 );
2989 for chunk in bytes.as_chunks::<4>().0 {
2990 let value = f32::from_le_bytes(*chunk);
2991 assert!(
2992 value.is_finite(),
2993 "preset {name} carries a non-finite value"
2994 );
2995 }
2996 }
2997 }
2998
2999 #[test]
3000 fn preset_names_resolve_and_unknown_names_do_not() {
3001 let environment = Environment {
3002 values: BTreeMap::new(),
3003 stage_budget_values: BTreeMap::new(),
3004 };
3005 let resolved = resolve_requested_voice(Some(Path::new("aria")), &environment)
3006 .expect("preset name resolves")
3007 .expect("preset yields a path");
3008 let bytes = fs::read(&resolved).expect("materialized preset readable");
3009 assert_eq!(bytes.len(), synth::SPEAKER_VECTOR_BYTES);
3010
3011 let error = resolve_requested_voice(Some(Path::new("no-such-voice")), &environment)
3012 .expect_err("unknown names are refused");
3013 assert!(
3014 error.to_string().contains("aria"),
3015 "the refusal must list the built-in names, got: {error}"
3016 );
3017 }
3018
3019 const CLAP_SURFACE_SNAPSHOT: &str = "commands=say,make-video,enroll,voice,convert,pull,robot,doctor,resident-daemon\nrobot=schema,health,backends,selftest\nsay=file,model,voice,output,stream,check,robot,no-resident\npull=model,force\nglobal=profile,packet-frames,math-mode,voice-pack,normalize,trace,seed\n";
3023
3024 #[test]
3025 fn clap_surface_matches_snapshot() {
3026 let command = Cli::command();
3027 let commands = command
3028 .get_subcommands()
3029 .map(|command| command.get_name())
3030 .collect::<Vec<_>>()
3031 .join(",");
3032 let robot = command
3033 .get_subcommands()
3034 .find(|command| command.get_name() == "robot")
3035 .expect("robot subcommand")
3036 .get_subcommands()
3037 .map(|command| command.get_name())
3038 .collect::<Vec<_>>()
3039 .join(",");
3040 let say = command
3041 .get_subcommands()
3042 .find(|command| command.get_name() == "say")
3043 .expect("say subcommand")
3044 .get_arguments()
3045 .filter_map(|argument| argument.get_long())
3046 .collect::<Vec<_>>()
3047 .join(",");
3048 let pull = command
3049 .get_subcommands()
3050 .find(|command| command.get_name() == "pull")
3051 .expect("pull subcommand")
3052 .get_arguments()
3053 .filter_map(|argument| argument.get_long())
3054 .collect::<Vec<_>>()
3055 .join(",");
3056 let global = command
3057 .get_arguments()
3058 .filter_map(|argument| argument.get_long())
3059 .filter(|argument| *argument != "help")
3060 .collect::<Vec<_>>()
3061 .join(",");
3062 let actual = format!(
3063 "commands={commands}\nrobot={robot}\nsay={say}\npull={pull}\nglobal={global}\n"
3064 );
3065 assert_eq!(actual, CLAP_SURFACE_SNAPSHOT);
3066 }
3067
3068 #[test]
3069 fn argument_file_and_stdin_text_are_identical() {
3070 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../README.md");
3071 let expected = fs::read_to_string(&root).expect("checked-in README");
3072 let from_argument = read_text(
3073 &SayArgs {
3074 output_positional: None,
3075 text: Some(expected.clone()),
3076 file: None,
3077 model: None,
3078 voice: None,
3079 output: None,
3080 stream: None,
3081 check: true,
3082 robot: false,
3083 no_resident: true,
3084 },
3085 &mut Cursor::new(Vec::<u8>::new()),
3086 )
3087 .expect("argument text");
3088 let from_file = read_text(
3089 &SayArgs {
3090 output_positional: None,
3091 text: None,
3092 file: Some(root),
3093 model: None,
3094 voice: None,
3095 output: None,
3096 stream: None,
3097 check: true,
3098 robot: false,
3099 no_resident: true,
3100 },
3101 &mut Cursor::new(Vec::<u8>::new()),
3102 )
3103 .expect("file text");
3104 let from_stdin = read_text(
3105 &SayArgs {
3106 output_positional: None,
3107 text: Some("-".to_owned()),
3108 file: None,
3109 model: None,
3110 voice: None,
3111 output: None,
3112 stream: None,
3113 check: true,
3114 robot: false,
3115 no_resident: true,
3116 },
3117 &mut Cursor::new(expected.as_bytes()),
3118 )
3119 .expect("stdin text");
3120
3121 assert_eq!(from_argument, from_file);
3122 assert_eq!(from_argument, from_stdin);
3123 }
3124
3125 #[test]
3126 fn check_plan_is_deterministic_and_marks_its_scope() {
3127 let settings = EffectiveSettings {
3128 profile: ExecutionProfile::Strict,
3129 packet_frames: PacketFrames::Four,
3130 math_mode: MathMode::Strict,
3131 voice_pack: VoicePackProfile::Portable,
3132 normalize: NormalizeMode::Conservative,
3133 };
3134 let first = admission_plan("hello", &settings).expect("admission plan");
3135 let second = admission_plan("hello", &settings).expect("admission plan");
3136 assert_eq!(first, second);
3137 assert_eq!(first["status"], "accepted");
3138 assert!(
3140 first["scope"]
3141 .as_str()
3142 .unwrap_or_default()
3143 .contains("ESTIMATED")
3144 );
3145 }
3146
3147 #[test]
3148 fn the_cli_preflight_and_the_engine_agree_on_the_same_request() {
3149 let settings = EffectiveSettings {
3153 profile: ExecutionProfile::Balanced,
3154 packet_frames: PacketFrames::Four,
3155 math_mode: MathMode::Strict,
3156 voice_pack: VoicePackProfile::Portable,
3157 normalize: NormalizeMode::Verbatim,
3158 };
3159 let text = "a moderately sized utterance for admission";
3160 let plan = admission_plan(text, &settings).expect("preflight admits");
3161
3162 let policy = ftts_core::process_engine_config().admission;
3163 let engine = policy
3164 .admit(text.chars().count() as u64)
3165 .expect("engine admits the same request");
3166
3167 assert_eq!(plan["predicted_peak_bytes"], engine.predicted_peak_bytes);
3168 assert_eq!(plan["predicted_max_frames"], engine.predicted_max_frames);
3169 assert_eq!(plan["budget_bytes"], engine.budget_bytes);
3170 assert_eq!(
3171 plan["binding_constraint"],
3172 engine.binding_constraint.as_str()
3173 );
3174 }
3175
3176 #[test]
3177 fn a_wav_sink_writes_a_playable_file_and_conforming_audio_chunk_events() {
3178 let dir = std::env::temp_dir().join(format!("ftts-wav-sink-{}", std::process::id()));
3179 std::fs::create_dir_all(&dir).expect("temp dir");
3180 let path = dir.join("out.wav");
3181
3182 let frame: Vec<f32> = (0..1_920)
3183 .map(|i| (i as f32 / 1_920.0 * std::f32::consts::TAU).sin() * 0.5)
3184 .collect();
3185 let mut sink = AudioOutput::wav(&path).expect("wav sink");
3186 let mut discard = Vec::new();
3187
3188 let first = sink
3189 .write_packet(&frame, &mut discard, "run-1", 1)
3190 .expect("packet 1");
3191 let second = sink
3192 .write_packet(&frame, &mut discard, "run-1", 1)
3193 .expect("packet 2");
3194
3195 assert!(robot::validate_event(&first).is_empty(), "{first:?}");
3197 assert!(robot::validate_event(&second).is_empty(), "{second:?}");
3198 assert_eq!(first["sink"], "file");
3199 assert_eq!(first["byte_offset"], 0);
3200 assert_eq!(first["bytes"], 1_920 * 2);
3201 assert_eq!(first["duration_ms"], 80, "1,920 samples at 24 kHz is 80 ms");
3202 assert_eq!(second["byte_offset"], 1_920 * 2);
3204
3205 let samples = sink.finish().expect("finish");
3206 assert_eq!(samples, 1_920 * 2);
3207
3208 let bytes = std::fs::read(&path).expect("read wav");
3210 assert_eq!(&bytes[0..4], b"RIFF");
3211 assert_eq!(&bytes[8..12], b"WAVE");
3212 let declared = u32::from_le_bytes(bytes[40..44].try_into().expect("data size"));
3213 assert_eq!(declared as usize, 1_920 * 2 * 2);
3214 assert_eq!(bytes.len(), 44 + 1_920 * 2 * 2);
3215 assert!(
3216 discard.is_empty(),
3217 "a file sink must not also emit raw PCM to the stream"
3218 );
3219 }
3220
3221 #[test]
3222 fn a_raw_sink_writes_pcm_to_the_stream_and_never_mixes_it_with_events() {
3223 let mut sink = AudioOutput::raw();
3226 let mut raw = Vec::new();
3227 let pcm = vec![0.5f32; 4];
3228 let event = sink
3229 .write_packet(&pcm, &mut raw, "run-1", 1)
3230 .expect("packet");
3231
3232 assert!(robot::validate_event(&event).is_empty(), "{event:?}");
3233 assert_eq!(event["sink"], "stdout");
3234 assert_eq!(raw.len(), 8, "four 16-bit samples");
3235 let first = i16::from_le_bytes([raw[0], raw[1]]);
3236 assert_eq!(first, ftts_core::audio::sample_to_i16(0.5));
3237 assert!(
3239 !raw.windows(2).any(|w| w == b"{\""),
3240 "raw PCM stream must never contain an event object"
3241 );
3242 }
3243
3244 #[test]
3245 fn a_none_sink_still_reports_conforming_events() {
3246 let mut sink = AudioOutput::none();
3247 let mut discard = Vec::new();
3248 let event = sink
3249 .write_packet(&[0.0f32; 960], &mut discard, "run-1", 2)
3250 .expect("packet");
3251 assert!(robot::validate_event(&event).is_empty(), "{event:?}");
3252 assert_eq!(event["sink"], "none");
3253 assert_eq!(event["packet_frames"], "2");
3254 assert!(discard.is_empty());
3255 assert_eq!(sink.finish().expect("finish"), 960);
3256 }
3257
3258 #[test]
3259 fn a_health_violation_renders_as_a_contract_conforming_robot_event() {
3260 let silent =
3263 ftts_core::HealthEvent::Violation(ftts_core::health::HealthViolation::OutputSilent {
3264 silent_millis: 1_500,
3265 });
3266 let event = robot::health_violation_event("run-1", silent, 42);
3267 assert!(
3268 robot::validate_event(&event).is_empty(),
3269 "{:?}",
3270 robot::validate_event(&event)
3271 );
3272 assert_eq!(event["event"], "health_violation");
3273 assert_eq!(event["violation"], "output_silent");
3274 assert_eq!(event["invalidates_output"], true);
3275 assert!(event["detail"].as_str().expect("detail").contains("1500"));
3276 assert!(event["remedy"].as_str().expect("remedy").len() > 40);
3277
3278 let demoted =
3281 ftts_core::HealthEvent::Violation(ftts_core::health::HealthViolation::KernelDemoted {
3282 from: ftts_core::health::KernelTier::Optimized("i8mm"),
3283 to: ftts_core::health::KernelTier::Scalar,
3284 });
3285 let event = robot::health_violation_event("run-1", demoted, 43);
3286 assert!(robot::validate_event(&event).is_empty());
3287 assert_eq!(event["invalidates_output"], false);
3288
3289 for event in [
3291 ftts_core::HealthEvent::BudgetExceeded,
3292 ftts_core::HealthEvent::Cancelled,
3293 ] {
3294 let rendered = robot::health_violation_event("run-1", event, 44);
3295 assert!(robot::validate_event(&rendered).is_empty());
3296 assert_eq!(rendered["invalidates_output"], true);
3297 }
3298 }
3299
3300 #[test]
3301 fn normalization_defaults_to_verbatim_conformance_mode() {
3302 let cli = Cli {
3303 profile: None,
3304 packet_frames: None,
3305 math_mode: None,
3306 voice_pack: None,
3307 normalize: None,
3308 trace: None,
3309 seed: None,
3310 command: Command::Robot(RobotArgs {
3311 command: RobotCommand::Health,
3312 }),
3313 };
3314 assert_eq!(
3315 EffectiveSettings::resolve(&cli, &Environment::default())
3316 .expect("default settings")
3317 .normalize,
3318 NormalizeMode::Verbatim
3319 );
3320 assert_eq!(
3321 EffectiveSettings::resolve(&cli, &Environment::default())
3322 .expect("default settings")
3323 .normalization_options(),
3324 NormalizationOptions::default(),
3325 "CLI defaults must use the same verbatim options as the library"
3326 );
3327 }
3328
3329 #[test]
3330 fn cli_normalization_modes_map_to_shared_engine_options() {
3331 for (cli_mode, engine_mode) in [
3332 (NormalizeMode::Verbatim, NormalizationMode::Verbatim),
3333 (NormalizeMode::Conservative, NormalizationMode::Conservative),
3334 (NormalizeMode::LocaleAware, NormalizationMode::LocaleAware),
3335 ] {
3336 let settings = EffectiveSettings {
3337 profile: ExecutionProfile::Balanced,
3338 packet_frames: PacketFrames::Four,
3339 math_mode: MathMode::Strict,
3340 voice_pack: VoicePackProfile::Portable,
3341 normalize: cli_mode,
3342 };
3343 assert_eq!(settings.normalization_options().mode, engine_mode);
3344 }
3345 }
3346
3347 #[test]
3348 fn pinned_main_conversion_plan_preserves_the_reviewed_q8_boundary() {
3349 let specs = pinned_main_tensor_specs().expect("checked-in main inventory parses");
3350 let (_manifest, _plan) =
3351 pinned_main_conversion_plan().expect("checked-in main conversion plan builds");
3352 assert_eq!(specs.len(), PINNED_MAIN_TENSOR_COUNT);
3353 assert_eq!(
3354 specs
3355 .iter()
3356 .filter(|spec| spec.storage == TensorStoragePolicy::Q8PerOutputChannel)
3357 .count(),
3358 231,
3359 "28 talker + 5 microdecoder layers times seven attention/MLP projections"
3360 );
3361
3362 let text_embedding = specs
3363 .iter()
3364 .find(|spec| spec.name == "talker.model.text_embedding.weight");
3365 assert!(
3366 text_embedding.is_some(),
3367 "pinned inventory must contain the text embedding"
3368 );
3369 if let Some(text_embedding) = text_embedding {
3370 assert_eq!(text_embedding.storage, TensorStoragePolicy::Verbatim);
3371 assert_eq!(text_embedding.access_class, AccessClass::ColdTextEmbedding);
3372 }
3373
3374 let talker_projection = specs
3375 .iter()
3376 .find(|spec| spec.name == "talker.model.layers.0.mlp.down_proj.weight");
3377 assert!(
3378 talker_projection.is_some(),
3379 "pinned inventory must contain the talker projection"
3380 );
3381 if let Some(talker_projection) = talker_projection {
3382 assert_eq!(
3383 talker_projection.storage,
3384 TensorStoragePolicy::Q8PerOutputChannel
3385 );
3386 assert_eq!(
3387 talker_projection.access_class,
3388 AccessClass::HotRecurrentTalker
3389 );
3390 }
3391
3392 let micro_projection = specs
3393 .iter()
3394 .find(|spec| spec.name == "talker.code_predictor.model.layers.0.mlp.down_proj.weight");
3395 assert!(
3396 micro_projection.is_some(),
3397 "pinned inventory must contain the microdecoder projection"
3398 );
3399 if let Some(micro_projection) = micro_projection {
3400 assert_eq!(
3401 micro_projection.storage,
3402 TensorStoragePolicy::Q8PerOutputChannel
3403 );
3404 assert_eq!(
3405 micro_projection.access_class,
3406 AccessClass::HotRecurrentMicrodecoder
3407 );
3408 }
3409
3410 let primary_embedding = specs
3411 .iter()
3412 .find(|spec| spec.name == "talker.model.codec_embedding.weight");
3413 assert!(
3414 primary_embedding.is_some(),
3415 "pinned inventory must contain the primary-code embedding"
3416 );
3417 if let Some(primary_embedding) = primary_embedding {
3418 assert_eq!(
3419 primary_embedding.access_class,
3420 AccessClass::HotRecurrentMicrodecoder,
3421 "the primary-code embedding feeds residual depth one every frame"
3422 );
3423 }
3424
3425 let primary_head = specs
3426 .iter()
3427 .find(|spec| spec.name == "talker.codec_head.weight");
3428 assert!(
3429 primary_head.is_some(),
3430 "pinned inventory must contain the primary-code head"
3431 );
3432 if let Some(primary_head) = primary_head {
3433 assert_eq!(primary_head.storage, TensorStoragePolicy::Verbatim);
3434 assert_eq!(primary_head.access_class, AccessClass::HotRecurrentTalker);
3435 }
3436
3437 let text_projection = specs
3438 .iter()
3439 .find(|spec| spec.name == "talker.text_projection.linear_fc1.weight");
3440 assert!(
3441 text_projection.is_some(),
3442 "pinned inventory must contain the text-projection MLP"
3443 );
3444 if let Some(text_projection) = text_projection {
3445 assert_eq!(text_projection.storage, TensorStoragePolicy::Verbatim);
3446 assert_eq!(
3447 text_projection.access_class,
3448 AccessClass::HotRecurrentTalker
3449 );
3450 }
3451
3452 let head = specs
3453 .iter()
3454 .find(|spec| spec.name == "talker.code_predictor.lm_head.0.weight");
3455 assert!(
3456 head.is_some(),
3457 "pinned inventory must contain the residual-code head"
3458 );
3459 if let Some(head) = head {
3460 assert_eq!(head.storage, TensorStoragePolicy::Verbatim);
3461 assert_eq!(head.access_class, AccessClass::HotRecurrentMicrodecoder);
3462 }
3463
3464 let speaker = specs
3465 .iter()
3466 .find(|spec| spec.name == "speaker_encoder.fc.weight");
3467 assert!(
3468 speaker.is_some(),
3469 "pinned inventory must contain the speaker encoder"
3470 );
3471 if let Some(speaker) = speaker {
3472 assert_eq!(speaker.storage, TensorStoragePolicy::Verbatim);
3473 assert_eq!(speaker.access_class, AccessClass::EnrollmentSpeakerEncoder);
3474 }
3475 }
3476
3477 #[test]
3478 fn conversion_notice_carries_changes_and_the_full_license() {
3479 let notice = pinned_license_notice();
3480 assert!(notice.contains("Copyright 2026 Alibaba Cloud"));
3481 assert!(notice.contains("CHANGES: the original bfloat16 weights were converted"));
3482 assert!(notice.contains("Apache License"));
3483 assert!(notice.contains("TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION"));
3484 }
3485
3486 #[test]
3487 fn convert_refusal_still_emits_a_versioned_robot_lifecycle() {
3488 let cli = Cli {
3489 profile: None,
3490 packet_frames: None,
3491 math_mode: None,
3492 voice_pack: None,
3493 normalize: None,
3494 trace: None,
3495 seed: None,
3496 command: Command::Robot(RobotArgs {
3497 command: RobotCommand::Health,
3498 }),
3499 };
3500 let args = ConvertArgs {
3501 source: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"),
3504 output: PathBuf::from("never-created.fttsq"),
3505 };
3506 let mut stdout = Vec::new();
3507 let mut stderr = Vec::new();
3508 let error = run_convert(
3509 &cli,
3510 &args,
3511 &Environment::default(),
3512 &mut stdout,
3513 &mut stderr,
3514 )
3515 .expect_err("the non-pinned source must be refused");
3516 assert_eq!(error.exit_code(), FttsExitCode::Input);
3517
3518 let stdout = String::from_utf8(stdout).expect("NDJSON stdout");
3519 let stderr = String::from_utf8(stderr).expect("NDJSON stderr");
3520 assert!(robot::validate_ndjson(&stdout).is_empty());
3521 assert!(robot::validate_ndjson(&stderr).is_empty());
3522 let stdout_events = stdout
3523 .lines()
3524 .map(|line| serde_json::from_str::<Value>(line).expect("JSON event"))
3525 .collect::<Vec<_>>();
3526 assert_eq!(stdout_events[0]["event"], "run_start");
3527 assert_eq!(stdout_events[1]["event"], "stage");
3528 assert_eq!(
3529 serde_json::from_str::<Value>(stderr.trim()).expect("run error")["event"],
3530 "run_error"
3531 );
3532 }
3533
3534 #[test]
3535 fn say_check_emits_a_versioned_admission_outcome() {
3536 let model = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
3537 let cli = Cli {
3538 profile: Some(ExecutionProfile::Balanced),
3539 packet_frames: Some(PacketFrames::Four),
3540 math_mode: Some(MathMode::Strict),
3541 voice_pack: Some(VoicePackProfile::Portable),
3542 normalize: Some(NormalizeMode::Conservative),
3543 trace: None,
3544 seed: Some(7),
3545 command: Command::Robot(RobotArgs {
3546 command: RobotCommand::Health,
3547 }),
3548 };
3549 let args = SayArgs {
3550 text: Some("checked text".to_owned()),
3551 output_positional: None,
3552 file: None,
3553 model: Some(model),
3554 voice: None,
3555 output: None,
3556 stream: None,
3557 check: true,
3558 robot: false,
3559 no_resident: true,
3560 };
3561 let mut stdin = Cursor::new(Vec::<u8>::new());
3562 let mut stdout = Vec::new();
3563 let mut stderr = Vec::new();
3564
3565 run_say(
3566 &cli,
3567 &args,
3568 &Environment::default(),
3569 &mut stdin,
3570 &mut stdout,
3571 &mut stderr,
3572 )
3573 .expect("check path");
3574
3575 assert!(stderr.is_empty());
3576 let text = String::from_utf8(stdout).expect("utf-8 events");
3577
3578 assert!(
3580 robot::validate_ndjson(&text).is_empty(),
3581 "emitted stream violates the contract: {:?}",
3582 robot::validate_ndjson(&text)
3583 );
3584
3585 let events: Vec<Value> = text
3586 .lines()
3587 .map(|line| serde_json::from_str(line).expect("one JSON object per line"))
3588 .collect();
3589 let names: Vec<&str> = events
3590 .iter()
3591 .map(|event| event["event"].as_str().expect("event name"))
3592 .collect();
3593 assert_eq!(
3594 names,
3595 vec![
3596 "run_start",
3597 "stage",
3598 "stage",
3599 "text_prepared",
3600 "stage",
3601 "stage",
3602 "check_complete",
3603 "run_complete",
3604 ],
3605 "the skeleton lifecycle must flow end-to-end on the empty pipeline"
3606 );
3607
3608 let run_id = events[0]["run_id"]
3611 .as_str()
3612 .expect("run_start carries run_id");
3613 assert!(!run_id.is_empty());
3614 assert!(events.iter().all(|event| event["run_id"] == run_id));
3615 assert!(
3616 events
3617 .iter()
3618 .all(|event| event["schema_version"] == ROBOT_SCHEMA_VERSION)
3619 );
3620
3621 let seqs: Vec<u64> = events
3623 .iter()
3624 .filter(|event| event["event"] == "stage")
3625 .map(|event| event["seq"].as_u64().expect("seq"))
3626 .collect();
3627 assert_eq!(seqs, vec![0, 1, 2, 3]);
3628
3629 let check = &events[6];
3630 assert_eq!(check["admission"]["status"], "accepted");
3634 assert!(
3635 check["admission"]["predicted_peak_bytes"].is_u64(),
3636 "the engine-backed plan reports a real predicted peak"
3637 );
3638 assert_eq!(check["normalization_trace_requested"], false);
3639
3640 let prepared = &events[3];
3642 assert_eq!(prepared["char_count"], "checked text".chars().count());
3643 assert!(prepared["unicode_version"].is_string());
3644 assert!(
3645 !text.contains("checked text"),
3646 "the event stream must not carry the user's text"
3647 );
3648
3649 assert_eq!(events[7]["exit_code"], 0);
3650 }
3651
3652 #[test]
3653 fn a_newline_inside_a_field_cannot_break_ndjson_framing() {
3654 let run = robot::RunContext::with_id("r-test");
3659 let error = FttsError::Generic("first\nsecond".to_owned());
3660 let mut event = run.event(robot::EventType::RunError);
3661 event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
3662 event.insert("kind".to_owned(), json!(error.exit_code().description()));
3663 event.insert("message".to_owned(), json!(error.to_string()));
3664 event.insert("remediation".to_owned(), json!(error.remediation()));
3665 event.insert("elapsed_ms".to_owned(), json!(0));
3666 let value = Value::Object(event);
3667
3668 let mut buffer = Vec::new();
3669 write_json_line(&mut buffer, &value).expect("serializes");
3670 let text = String::from_utf8(buffer).expect("utf-8");
3671
3672 assert_eq!(
3673 text.lines().count(),
3674 1,
3675 "framing broken by an embedded newline"
3676 );
3677 assert!(robot::validate_ndjson(&text).is_empty());
3678 let parsed: Value = serde_json::from_str(text.trim_end()).expect("still one object");
3679 assert!(
3680 parsed["message"].as_str().expect("message").contains('\n'),
3681 "the newline must survive as data, not be stripped"
3682 );
3683 }
3684
3685 #[test]
3686 fn pinned_copies_match_the_truth_pack_canonicals() {
3687 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
3691 for (canonical, embedded, name) in [
3692 (
3693 "docs/truth-pack/TENSOR_INVENTORY.json",
3694 PINNED_TENSOR_INVENTORY,
3695 "TENSOR_INVENTORY.json",
3696 ),
3697 (
3698 "docs/truth-pack/snapshots/hf/config.json",
3699 PINNED_MODEL_CONFIG,
3700 "model_config.json",
3701 ),
3702 (
3703 "docs/truth-pack/snapshots/gh/LICENSE",
3704 APACHE_LICENSE,
3705 "QWEN_APACHE_LICENSE",
3706 ),
3707 ] {
3708 match std::fs::read_to_string(root.join(canonical)) {
3709 Ok(bytes) => assert_eq!(
3710 bytes, embedded,
3711 "pinned/{name} drifted from {canonical}; re-copy it"
3712 ),
3713 Err(_) => eprintln!(
3714 "SKIP pinned-copy check for {name}: {canonical} absent (no repo checkout)"
3715 ),
3716 }
3717 }
3718 }
3719
3720 #[test]
3721 fn embedded_model_manifest_is_wellformed_and_agrees_with_the_converter_pin() {
3722 let manifest = ModelManifest::embedded().expect("embedded manifest parses");
3723 assert_eq!(manifest.model_id, "qwen3-tts-12hz-0.6b-base");
3724 assert_eq!(manifest.release_tag, "model-qwen3-tts-v1");
3725 assert_eq!(manifest.repo, "Dicklesworthstone/franken_tts");
3726 assert_eq!(manifest.files.len(), 8);
3727
3728 for file in &manifest.files {
3729 assert!(
3730 is_sha256_hex(&file.sha256),
3731 "{} carries a malformed digest",
3732 file.asset
3733 );
3734 assert!(file.bytes > 0, "{} has no pinned size", file.asset);
3735 let dest = Path::new(&file.dest);
3736 assert!(!dest.is_absolute(), "{} dest is absolute", file.asset);
3737 assert!(
3738 dest.components()
3739 .all(|component| matches!(component, std::path::Component::Normal(_))),
3740 "{} dest can traverse out of the model directory",
3741 file.asset
3742 );
3743 }
3744
3745 let main = manifest
3750 .files
3751 .iter()
3752 .find(|file| file.dest == MODEL_BASENAME)
3753 .expect("manifest carries the canonical artifact");
3754 assert_eq!(
3755 manifest.download_url(main),
3756 "https://github.com/Dicklesworthstone/franken_tts/releases/download/model-qwen3-tts-v1/qwen3-tts-12hz-0.6b-base.fttsq"
3757 );
3758 assert!(
3759 !manifest
3760 .files
3761 .iter()
3762 .any(|file| file.dest == PINNED_MAIN_WEIGHTS_FILENAME),
3763 "pull must not fetch the raw main checkpoint alongside the canonical artifact"
3764 );
3765
3766 let dests: Vec<&str> = manifest
3769 .files
3770 .iter()
3771 .map(|file| file.dest.as_str())
3772 .collect();
3773 for required in [
3774 MODEL_BASENAME,
3775 "speech_tokenizer/model.safetensors",
3776 "vocab.json",
3777 "merges.txt",
3778 "tokenizer_config.json",
3779 ] {
3780 assert!(dests.contains(&required), "manifest is missing {required}");
3781 }
3782 }
3783
3784 #[test]
3785 fn malformed_model_manifests_are_refused_with_the_field_named() {
3786 fn manifest_with(
3787 schema_version: u64,
3788 asset: &str,
3789 dest: &str,
3790 sha256: &str,
3791 bytes: u64,
3792 ) -> String {
3793 json!({
3794 "schema_version": schema_version,
3795 "model_id": "m",
3796 "release_tag": "t",
3797 "repo": "owner/repo",
3798 "files": [{"asset": asset, "dest": dest, "sha256": sha256, "bytes": bytes}],
3799 })
3800 .to_string()
3801 }
3802 let good_sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
3803
3804 ModelManifest::parse(&manifest_with(1, "a.bin", "a.bin", good_sha, 1))
3806 .expect("well-formed manifest parses");
3807
3808 for (label, text) in [
3809 (
3810 "unsupported schema_version",
3811 manifest_with(2, "a.bin", "a.bin", good_sha, 1),
3812 ),
3813 (
3814 "short sha256",
3815 manifest_with(1, "a.bin", "a.bin", "abc123", 1),
3816 ),
3817 (
3818 "uppercase sha256",
3819 manifest_with(1, "a.bin", "a.bin", &good_sha.to_uppercase(), 1),
3820 ),
3821 (
3822 "zero bytes",
3823 manifest_with(1, "a.bin", "a.bin", good_sha, 0),
3824 ),
3825 (
3826 "absolute dest",
3827 manifest_with(1, "a.bin", "/etc/passwd", good_sha, 1),
3828 ),
3829 (
3830 "traversal dest",
3831 manifest_with(1, "a.bin", "../escape.bin", good_sha, 1),
3832 ),
3833 (
3834 "asset with a path separator",
3835 manifest_with(1, "dir/a.bin", "a.bin", good_sha, 1),
3836 ),
3837 (
3838 "empty files array",
3839 json!({
3840 "schema_version": 1,
3841 "model_id": "m",
3842 "release_tag": "t",
3843 "repo": "owner/repo",
3844 "files": [],
3845 })
3846 .to_string(),
3847 ),
3848 ] {
3849 let error = ModelManifest::parse(&text)
3850 .expect_err(&format!("a manifest with {label} must be refused"));
3851 assert_eq!(error.exit_code(), FttsExitCode::ArtifactFormat, "{label}");
3852 }
3853 }
3854
3855 #[test]
3856 fn pull_skips_only_a_file_matching_both_pinned_size_and_digest() {
3857 let dir = std::env::temp_dir().join(format!("ftts-pull-decision-{}", std::process::id()));
3858 fs::create_dir_all(&dir).expect("temp dir");
3859 let payload = b"pinned payload";
3860 let file = ModelManifestFile {
3861 asset: "a.bin".to_owned(),
3862 dest: "a.bin".to_owned(),
3863 sha256: ftts_artifacts::sha256::hex_digest(payload),
3864 bytes: payload.len() as u64,
3865 };
3866 let dest = dir.join("a.bin");
3867
3868 let _ = fs::remove_file(&dest);
3869 assert_eq!(
3870 pull_decision(&dest, &file, false),
3871 PullDecision::Download,
3872 "absent file must download"
3873 );
3874
3875 fs::write(&dest, payload).expect("write verified payload");
3876 assert_eq!(
3877 pull_decision(&dest, &file, false),
3878 PullDecision::Skip,
3879 "matching size and digest must skip"
3880 );
3881 assert_eq!(
3882 pull_decision(&dest, &file, true),
3883 PullDecision::Download,
3884 "--force must re-download even a verified file"
3885 );
3886
3887 fs::write(&dest, b"pinned_payload").expect("write same-length corruption");
3888 assert_eq!(
3889 pull_decision(&dest, &file, false),
3890 PullDecision::Download,
3891 "a same-length corruption must be caught by the digest"
3892 );
3893
3894 fs::write(&dest, b"short").expect("write truncation");
3895 assert_eq!(
3896 pull_decision(&dest, &file, false),
3897 PullDecision::Download,
3898 "a truncated file must be caught by the size check"
3899 );
3900 }
3901
3902 #[test]
3903 fn model_resolution_prefers_explicit_then_searched_then_the_pull_directory() {
3904 let root = std::env::temp_dir().join(format!("ftts-resolve-order-{}", std::process::id()));
3905
3906 let bundle = root.join("bundle");
3909 for relative in [
3910 "model.safetensors",
3911 "speech_tokenizer/model.safetensors",
3912 "vocab.json",
3913 "merges.txt",
3914 "tokenizer_config.json",
3915 ] {
3916 let path = bundle.join(relative);
3917 fs::create_dir_all(path.parent().expect("bundle parent")).expect("bundle dirs");
3918 fs::write(&path, b"").expect("bundle file");
3919 }
3920 assert!(
3921 synth::ModelBundle::resolve(&bundle).is_ok(),
3922 "five empty files must satisfy the resolver's is_file checks"
3923 );
3924
3925 let searched_artifact = root.join("searched").join(MODEL_BASENAME);
3926 fs::create_dir_all(searched_artifact.parent().expect("searched parent"))
3927 .expect("searched dir");
3928 fs::write(&searched_artifact, b"").expect("searched artifact");
3929 let searched = vec![searched_artifact.clone()];
3930 let absent = vec![root.join("absent").join(MODEL_BASENAME)];
3931
3932 assert_eq!(
3934 resolve_model_from(Some(&bundle), &searched, Some(&bundle)).expect("explicit"),
3935 bundle.display().to_string()
3936 );
3937
3938 assert_eq!(
3940 resolve_model_from(None, &searched, Some(&bundle)).expect("searched"),
3941 searched_artifact.display().to_string()
3942 );
3943
3944 assert_eq!(
3946 resolve_model_from(None, &absent, Some(&bundle)).expect("pull fallback"),
3947 bundle.display().to_string()
3948 );
3949
3950 let incomplete = root.join("incomplete");
3952 fs::create_dir_all(&incomplete).expect("incomplete dir");
3953 let error = resolve_model_from(None, &absent, Some(&incomplete))
3954 .expect_err("an empty pull directory must not resolve");
3955 assert_eq!(error.exit_code(), FttsExitCode::ModelNotFound);
3956 assert!(error.to_string().contains("ftts pull"), "{error}");
3957 assert!(error.to_string().contains("2.0 GB"), "{error}");
3958 assert!(error.to_string().contains("FTTS_MODEL_DIR"), "{error}");
3959 }
3960
3961 #[test]
3962 fn the_pull_directory_default_prefers_the_env_override() {
3963 let mut environment = Environment::default();
3964 environment
3965 .values
3966 .insert("FTTS_MODEL_DIR", Some(OsString::from("/tmp/env-model-dir")));
3967 assert_eq!(
3968 default_pull_model_dir(&environment),
3969 Some(PathBuf::from("/tmp/env-model-dir"))
3970 );
3971
3972 if std::env::var_os("HOME").is_some() {
3975 let fallback = default_pull_model_dir(&Environment::default())
3976 .expect("HOME is set, so a default exists");
3977 assert!(
3978 fallback.ends_with(DEFAULT_MODEL_CACHE_SUBDIR),
3979 "{fallback:?}"
3980 );
3981 }
3982 }
3983}