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