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