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