1#![forbid(unsafe_code)]
2
3mod error;
6pub mod robot;
7pub mod synth;
8
9pub use error::{FttsError, FttsExitCode};
10pub use robot::{EventType, validate_event, validate_ndjson};
11
12use std::collections::BTreeMap;
13use std::ffi::OsString;
14use std::fs;
15use std::io::{self, Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18use std::sync::OnceLock;
19
20#[cfg(test)]
21use clap::CommandFactory;
22use clap::{Parser, Subcommand, ValueEnum};
23use ftts_artifacts::census::{ExpectedTensor, WeightsManifest};
24use ftts_artifacts::converter::{
25 StreamingConversionPlan, TensorConversion, TensorStoragePolicy, convert_safetensors_streaming,
26};
27use ftts_artifacts::fttsq::{AccessClass, MappedFttsq};
28use ftts_artifacts::safetensors::Dtype;
29use ftts_core::{NormalizationMode, NormalizationOptions, SynthesisRequest};
30use ftts_kernels::mmap::MappedFile;
31use serde_json::{Value, json};
32
33const ROBOT_SCHEMA_VERSION: u8 = 1;
34const SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES: usize = 1_048_576;
35const MODEL_BASENAME: &str = "qwen3-tts-12hz-0.6b-base.fttsq";
36const PINNED_MAIN_WEIGHTS_FILENAME: &str = "model.safetensors";
37const PINNED_MAIN_WEIGHTS_SHA256: &str =
38 "180b3b10eb1c9f1b4db7806d5475bae3071c0243c299d49926bab1da3b6946f6";
39const PINNED_MODEL_REVISION: &str = "5d83992436eae1d760afd27aff78a71d676296fc";
40const PINNED_MAIN_TENSOR_COUNT: usize = 478;
41const PINNED_TENSOR_INVENTORY: &str = include_str!("../pinned/TENSOR_INVENTORY.json");
46const PINNED_MODEL_CONFIG: &str = include_str!("../pinned/model_config.json");
47const APACHE_LICENSE: &str = include_str!("../pinned/QWEN_APACHE_LICENSE");
48const PINNED_MODEL_MANIFEST: &str = include_str!("../pinned/model_manifest.json");
52const DEFAULT_MODEL_CACHE_SUBDIR: &str = ".cache/franken_tts/model";
54const ENVIRONMENT_VARIABLES: [&str; 11] = [
55 "FTTS_MODEL_DIR",
56 "FTTS_DEFAULT_VOICE",
57 "FTTS_THREADS",
58 "FTTS_PROFILE",
59 "FTTS_PACKET_FRAMES",
60 "FTTS_MATH_MODE",
61 "FTTS_QUANT",
62 "FTTS_FORCE_ARCH",
63 "FTTS_NUMA",
64 "FTTS_MAX_FRAMES",
65 "FTTS_MEMORY_BUDGET_MB",
66];
67
68pub fn cli_main() -> ExitCode {
70 let cli = match Cli::try_parse() {
75 Ok(cli) => cli,
76 Err(error) => {
77 let exit_code = match error.kind() {
78 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
79 FttsExitCode::Success
80 }
81 _ => FttsExitCode::Usage,
82 };
83 let _ = error.print();
84 return exit_code.as_exit_code();
85 }
86 };
87
88 let mut stdin = io::stdin().lock();
89 let mut stdout = io::stdout().lock();
90 let mut stderr = io::stderr().lock();
91 match dispatch(cli, environment(), &mut stdin, &mut stdout, &mut stderr) {
92 Ok(()) => FttsExitCode::Success.as_exit_code(),
93 Err(error) => {
94 let _ = writeln!(stderr, "error: {error}");
95 error.exit_code().as_exit_code()
96 }
97 }
98}
99
100#[derive(Debug, Parser)]
101#[command(
102 name = "ftts",
103 version,
104 about = "Pure-Rust Qwen3-TTS command-line interface",
105 long_about = "FrankenTTS is stateless by default: synthesis history is never persisted. \
106 Use `ftts robot schema` for the versioned NDJSON contract.",
107 arg_required_else_help = true
108)]
109struct Cli {
110 #[arg(long, global = true, value_enum)]
112 profile: Option<ExecutionProfile>,
113
114 #[arg(long, global = true, value_enum)]
116 packet_frames: Option<PacketFrames>,
117
118 #[arg(long, global = true, value_enum)]
120 math_mode: Option<MathMode>,
121
122 #[arg(long, global = true, value_enum)]
124 voice_pack: Option<VoicePackProfile>,
125
126 #[arg(long, global = true, value_enum)]
128 normalize: Option<NormalizeMode>,
129
130 #[arg(long, global = true, value_name = "DIR")]
132 trace: Option<PathBuf>,
133
134 #[arg(long, global = true)]
136 seed: Option<u64>,
137
138 #[command(subcommand)]
139 command: Command,
140}
141
142#[derive(Debug, Subcommand)]
143enum Command {
144 Say(SayArgs),
146 Enroll(EnrollArgs),
148 Voice(VoiceArgs),
150 Convert(ConvertArgs),
152 Pull(PullArgs),
154 Robot(RobotArgs),
156 Doctor(DoctorArgs),
158}
159
160#[derive(Debug, clap::Args)]
161struct SayArgs {
162 #[arg(value_name = "TEXT")]
164 text: Option<String>,
165
166 #[arg(value_name = "OUTPUT", conflicts_with_all = ["stream", "output"])]
170 output_positional: Option<PathBuf>,
171
172 #[arg(long, value_name = "PATH", conflicts_with = "text")]
174 file: Option<PathBuf>,
175
176 #[arg(long, value_name = "PATH")]
178 model: Option<PathBuf>,
179
180 #[arg(long, value_name = "PATH")]
182 voice: Option<PathBuf>,
183
184 #[arg(short = 'o', long, value_name = "PATH", conflicts_with = "stream")]
186 output: Option<PathBuf>,
187
188 #[arg(long, value_enum)]
190 stream: Option<StreamMode>,
191
192 #[arg(long)]
194 check: bool,
195}
196
197#[derive(Debug, clap::Args)]
198struct EnrollArgs {
199 #[arg(value_name = "REFERENCE_AUDIO")]
202 reference_audio: PathBuf,
203
204 #[arg(long, value_name = "PATH")]
206 model: Option<PathBuf>,
207
208 #[arg(short = 'o', long, value_name = "PATH", conflicts_with = "default")]
210 output: Option<PathBuf>,
211
212 #[arg(long, conflicts_with = "output")]
214 default: bool,
215
216 #[arg(long)]
218 force: bool,
219}
220
221#[derive(Debug, clap::Args)]
222struct VoiceArgs {
223 #[command(subcommand)]
224 command: VoiceCommand,
225}
226
227#[derive(Debug, Subcommand)]
228enum VoiceCommand {
229 Inspect { path: PathBuf },
231}
232
233#[derive(Debug, clap::Args)]
234struct ConvertArgs {
235 #[arg(value_name = "SOURCE")]
237 source: PathBuf,
238
239 #[arg(short = 'o', long, value_name = "PATH")]
241 output: PathBuf,
242}
243
244#[derive(Debug, clap::Args)]
245struct PullArgs {
246 #[arg(long, value_name = "PATH")]
248 model: Option<PathBuf>,
249
250 #[arg(long)]
252 force: bool,
253}
254
255#[derive(Debug, clap::Args)]
256struct RobotArgs {
257 #[command(subcommand)]
258 command: RobotCommand,
259}
260
261#[derive(Clone, Debug, Subcommand)]
262enum RobotCommand {
263 Schema,
265 Health,
267 Backends,
269 Selftest,
271}
272
273#[derive(Debug, clap::Args)]
274struct DoctorArgs {
275 #[arg(long)]
277 json: bool,
278}
279
280#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
281enum ExecutionProfile {
282 Interactive,
283 Balanced,
284 Throughput,
285 Strict,
286}
287
288impl ExecutionProfile {
289 const fn as_str(self) -> &'static str {
290 match self {
291 Self::Interactive => "interactive",
292 Self::Balanced => "balanced",
293 Self::Throughput => "throughput",
294 Self::Strict => "strict",
295 }
296 }
297}
298
299#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
300enum PacketFrames {
301 #[value(name = "1")]
302 One,
303 #[value(name = "2")]
304 Two,
305 #[value(name = "4")]
306 Four,
307 Auto,
308}
309
310impl PacketFrames {
311 const fn as_str(self) -> &'static str {
312 match self {
313 Self::One => "1",
314 Self::Two => "2",
315 Self::Four => "4",
316 Self::Auto => "auto",
317 }
318 }
319
320 const fn frames_per_packet(self) -> u8 {
326 match self {
327 Self::One => 1,
328 Self::Two => 2,
329 Self::Four | Self::Auto => 4,
330 }
331 }
332
333 const fn samples_per_packet(self) -> usize {
335 self.frames_per_packet() as usize * ftts_core::audio::SAMPLES_PER_FRAME
336 }
337
338 const fn default_for(profile: ExecutionProfile) -> Self {
339 match profile {
340 ExecutionProfile::Interactive => Self::One,
341 ExecutionProfile::Balanced => Self::Four,
342 ExecutionProfile::Throughput => Self::Auto,
343 ExecutionProfile::Strict => Self::Four,
344 }
345 }
346}
347
348#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
349enum MathMode {
350 Strict,
351 Fast,
352}
353
354impl MathMode {
355 const fn as_str(self) -> &'static str {
356 match self {
357 Self::Strict => "strict",
358 Self::Fast => "fast",
359 }
360 }
361}
362
363#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
364enum VoicePackProfile {
365 Portable,
366 Private,
367 Minimal,
368}
369
370impl VoicePackProfile {
371 const fn as_str(self) -> &'static str {
372 match self {
373 Self::Portable => "portable",
374 Self::Private => "private",
375 Self::Minimal => "minimal",
376 }
377 }
378}
379
380#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
381enum NormalizeMode {
382 Verbatim,
383 Conservative,
384 LocaleAware,
385}
386
387impl NormalizeMode {
388 const fn as_str(self) -> &'static str {
389 match self {
390 Self::Verbatim => "verbatim",
391 Self::Conservative => "conservative",
392 Self::LocaleAware => "locale-aware",
393 }
394 }
395}
396
397impl From<NormalizeMode> for NormalizationMode {
398 fn from(mode: NormalizeMode) -> Self {
399 match mode {
400 NormalizeMode::Verbatim => Self::Verbatim,
401 NormalizeMode::Conservative => Self::Conservative,
402 NormalizeMode::LocaleAware => Self::LocaleAware,
403 }
404 }
405}
406
407#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
408enum StreamMode {
409 Raw,
410}
411
412#[derive(Debug, Default)]
413struct Environment {
414 values: BTreeMap<&'static str, Option<OsString>>,
415 stage_budget_values: BTreeMap<OsString, OsString>,
416}
417
418impl Environment {
419 fn from_process() -> Self {
420 let values = ENVIRONMENT_VARIABLES
421 .into_iter()
422 .map(|name| (name, std::env::var_os(name)))
423 .collect();
424 let stage_budget_values = std::env::vars_os()
425 .filter(|(name, _)| {
426 name.to_str().is_some_and(|name| {
427 name.starts_with("FTTS_STAGE_BUDGET_") && name.ends_with("_MS")
428 })
429 })
430 .collect();
431 Self {
432 values,
433 stage_budget_values,
434 }
435 }
436
437 fn value(&self, name: &'static str) -> Option<&str> {
438 self.values.get(name)?.as_deref()?.to_str()
439 }
440
441 fn documented_values(&self) -> BTreeMap<String, Option<String>> {
442 let mut values = self
443 .values
444 .iter()
445 .map(|(name, value)| {
446 (
447 (*name).to_owned(),
448 value
449 .as_ref()
450 .map(|value| value.to_string_lossy().into_owned()),
451 )
452 })
453 .collect::<BTreeMap<_, _>>();
454 values.insert("FTTS_STAGE_BUDGET_*_MS".to_owned(), None);
455 values.extend(self.stage_budget_values.iter().map(|(name, value)| {
456 (
457 name.to_string_lossy().into_owned(),
458 Some(value.to_string_lossy().into_owned()),
459 )
460 }));
461 values
462 }
463}
464
465fn environment() -> &'static Environment {
466 static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
467 ENVIRONMENT.get_or_init(Environment::from_process)
468}
469
470#[derive(Debug)]
471struct EffectiveSettings {
472 profile: ExecutionProfile,
473 packet_frames: PacketFrames,
474 math_mode: MathMode,
475 voice_pack: VoicePackProfile,
476 normalize: NormalizeMode,
477}
478
479impl EffectiveSettings {
480 fn resolve(cli: &Cli, environment: &Environment) -> Result<Self, FttsError> {
481 let profile = cli
482 .profile
483 .or(parse_env_value(
484 environment.value("FTTS_PROFILE"),
485 "FTTS_PROFILE",
486 ExecutionProfile::value_variants(),
487 )?)
488 .unwrap_or(ExecutionProfile::Balanced);
489 let packet_frames = cli
490 .packet_frames
491 .or(parse_env_value(
492 environment.value("FTTS_PACKET_FRAMES"),
493 "FTTS_PACKET_FRAMES",
494 PacketFrames::value_variants(),
495 )?)
496 .unwrap_or_else(|| PacketFrames::default_for(profile));
497 let math_mode = cli
498 .math_mode
499 .or(parse_env_value(
500 environment.value("FTTS_MATH_MODE"),
501 "FTTS_MATH_MODE",
502 MathMode::value_variants(),
503 )?)
504 .unwrap_or(MathMode::Fast);
505 let voice_pack = cli.voice_pack.unwrap_or(VoicePackProfile::Portable);
506 let normalize = cli.normalize.unwrap_or(NormalizeMode::Verbatim);
507 Ok(Self {
508 profile,
509 packet_frames,
510 math_mode,
511 voice_pack,
512 normalize,
513 })
514 }
515
516 fn normalization_options(&self) -> NormalizationOptions {
517 NormalizationOptions {
518 mode: self.normalize.into(),
519 ..NormalizationOptions::default()
520 }
521 }
522}
523
524fn parse_env_value<T>(
525 value: Option<&str>,
526 name: &str,
527 variants: &'static [T],
528) -> Result<Option<T>, FttsError>
529where
530 T: ValueEnum + Copy,
531{
532 match value {
533 None => Ok(None),
534 Some(value) => T::from_str(value, true).map(Some).map_err(|_| {
535 let choices = variants
536 .iter()
537 .filter_map(|variant| variant.to_possible_value())
538 .map(|variant| variant.get_name().to_owned())
539 .collect::<Vec<_>>()
540 .join(", ");
541 FttsError::Usage(format!("invalid {name}={value:?}; use one of: {choices}"))
542 }),
543 }
544}
545
546fn dispatch(
547 cli: Cli,
548 environment: &Environment,
549 stdin: &mut dyn Read,
550 stdout: &mut dyn Write,
551 stderr: &mut dyn Write,
552) -> Result<(), FttsError> {
553 match &cli.command {
554 Command::Say(args) => run_say(&cli, args, environment, stdin, stdout, stderr),
555 Command::Enroll(args) => run_enroll(args, environment, stdout),
556 Command::Voice(VoiceArgs {
557 command: VoiceCommand::Inspect { path },
558 }) => run_voice_inspect(path, stdout),
559 Command::Convert(args) => run_convert(&cli, args, environment, stdout, stderr),
560 Command::Pull(args) => run_pull(args, environment, stdout),
561 Command::Robot(args) => run_robot(args.command.clone(), environment, stdout),
562 Command::Doctor(args) => run_doctor(args, environment, stdout),
563 }
564}
565
566#[derive(Clone, Debug)]
568struct PinnedMainTensor {
569 name: String,
570 dtype: Dtype,
571 shape: Vec<usize>,
572 access_class: AccessClass,
573 storage: TensorStoragePolicy,
574}
575
576fn run_convert(
577 cli: &Cli,
578 args: &ConvertArgs,
579 environment: &Environment,
580 stdout: &mut dyn Write,
581 stderr: &mut dyn Write,
582) -> Result<(), FttsError> {
583 let run = robot::RunContext::generate();
584 let outcome = run_convert_events(cli, args, environment, &run, &mut |event| {
585 write_json_line(stdout, event)
586 });
587
588 if let Err(error) = &outcome {
589 let mut event = run.event(robot::EventType::RunError);
590 event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
591 event.insert("kind".to_owned(), json!(error.exit_code().description()));
592 event.insert("message".to_owned(), json!(error.to_string()));
593 event.insert("remediation".to_owned(), json!(error.remediation()));
594 event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
595 write_json_line(stderr, &Value::Object(event))?;
596 }
597
598 outcome
599}
600
601fn run_convert_events(
608 cli: &Cli,
609 args: &ConvertArgs,
610 environment: &Environment,
611 run: &robot::RunContext,
612 emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
613) -> Result<(), FttsError> {
614 let settings = EffectiveSettings::resolve(cli, environment)?;
615 let mut start = run.event(robot::EventType::RunStart);
616 start.insert("command".to_owned(), json!("convert"));
617 start.insert("profile".to_owned(), json!(settings.profile.as_str()));
618 start.insert(
619 "packet_frames".to_owned(),
620 json!(settings.packet_frames.as_str()),
621 );
622 start.insert("math_mode".to_owned(), json!(settings.math_mode.as_str()));
623 start.insert("stateless".to_owned(), json!(true));
624 start.insert("seed".to_owned(), json!(cli.seed));
625 start.insert("model".to_owned(), Value::Null);
626 start.insert("voice".to_owned(), Value::Null);
627 emit(&Value::Object(start))?;
628
629 let mut seq = 0_u64;
630 emit_stage(run, emit, "source_preflight", "begin", &mut seq)?;
631 let source = resolve_pinned_main_source(&args.source)?;
632 let mapping = MappedFile::open(&source).map_err(|error| {
633 FttsError::Input(format!(
634 "cannot memory-map pinned source checkpoint {}: {error}",
635 source.display()
636 ))
637 })?;
638 let (manifest, plan) = pinned_main_conversion_plan()?;
639 let staging = conversion_staging_path(&args.output)?;
640 emit_stage(run, emit, "source_preflight", "end", &mut seq)?;
641
642 emit_stage(run, emit, "convert", "begin", &mut seq)?;
643 let destination = std::fs::File::options()
644 .write(true)
645 .create_new(true)
646 .open(&staging)
647 .map_err(|error| {
648 FttsError::Input(format!(
649 "cannot create conversion staging artifact {}: {error}; the output path is never overwritten",
650 staging.display()
651 ))
652 })?;
653 let destination = convert_safetensors_streaming(
654 mapping.as_slice(),
655 &manifest,
656 &plan,
657 destination,
658 )
659 .map_err(|error| {
660 FttsError::ArtifactFormat(format!(
661 "conversion failed before publication: {error}; staging artifact retained at {}",
662 staging.display()
663 ))
664 })?;
665 destination.sync_all().map_err(|error| {
666 FttsError::ArtifactFormat(format!(
667 "cannot sync converted artifact at {}: {error}; staging artifact retained",
668 staging.display()
669 ))
670 })?;
671 drop(destination);
672 emit_stage(run, emit, "convert", "end", &mut seq)?;
673
674 emit_stage(run, emit, "verify", "begin", &mut seq)?;
675 let verified = MappedFttsq::open(&staging).map_err(|error| {
676 FttsError::ArtifactFormat(format!(
677 "converted staging artifact did not pass digest re-read: {error}; retained at {}",
678 staging.display()
679 ))
680 })?;
681 if verified.reader().source_sha256() != PINNED_MAIN_WEIGHTS_SHA256 {
682 return Err(FttsError::ArtifactFormat(format!(
683 "converted staging artifact recorded an unexpected source digest {}; retained at {}",
684 verified.reader().source_sha256(),
685 staging.display()
686 )));
687 }
688 drop(verified);
689 std::fs::rename(&staging, &args.output).map_err(|error| {
690 FttsError::ArtifactFormat(format!(
691 "converted artifact verified but could not be published from {} to {}: {error}; staging artifact retained",
692 staging.display(),
693 args.output.display()
694 ))
695 })?;
696 emit_stage(run, emit, "verify", "end", &mut seq)?;
697
698 let mut complete = run.event(robot::EventType::RunComplete);
699 complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
700 complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
701 complete.insert("frames".to_owned(), json!(0));
702 complete.insert("audio_bytes".to_owned(), json!(0));
703 emit(&Value::Object(complete))
704}
705
706fn resolve_pinned_main_source(source: &Path) -> Result<PathBuf, FttsError> {
707 let source = if source.is_dir() {
708 source.join(PINNED_MAIN_WEIGHTS_FILENAME)
709 } else {
710 source.to_owned()
711 };
712 if !source.is_file() {
713 return Err(FttsError::Input(format!(
714 "pinned main checkpoint {} does not exist or is not a file; pass model.safetensors or its containing directory",
715 source.display()
716 )));
717 }
718 if source.file_name().and_then(|name| name.to_str()) != Some(PINNED_MAIN_WEIGHTS_FILENAME) {
719 return Err(FttsError::Input(format!(
720 "this converter accepts the pinned main checkpoint named {PINNED_MAIN_WEIGHTS_FILENAME}, not {}",
721 source.display()
722 )));
723 }
724 Ok(source)
725}
726
727fn conversion_staging_path(output: &Path) -> Result<PathBuf, FttsError> {
728 if output.exists() {
729 return Err(FttsError::Input(format!(
730 "refusing to overwrite existing output {}; choose a new -o path",
731 output.display()
732 )));
733 }
734 let parent = output.parent().unwrap_or_else(|| Path::new("."));
735 let file_name = output
736 .file_name()
737 .and_then(|name| name.to_str())
738 .ok_or_else(|| {
739 FttsError::Usage("conversion output must name a file, not a directory".to_owned())
740 })?;
741 let nonce = std::time::SystemTime::now()
742 .duration_since(std::time::UNIX_EPOCH)
743 .map_err(|error| FttsError::Generic(format!("system clock is before UNIX_EPOCH: {error}")))?
744 .as_nanos();
745 let staging = parent.join(format!(
746 ".{file_name}.fttsq-converting-{}-{nonce}",
747 std::process::id()
748 ));
749 if staging.exists() {
750 return Err(FttsError::Input(format!(
751 "conversion staging path already exists {}; inspect or move it before retrying",
752 staging.display()
753 )));
754 }
755 Ok(staging)
756}
757
758fn pinned_main_conversion_plan() -> Result<(WeightsManifest, StreamingConversionPlan), FttsError> {
759 let specs = pinned_main_tensor_specs()?;
760 let manifest = WeightsManifest::from_expectations(
761 "Qwen/Qwen3-TTS-12Hz-0.6B-Base main checkpoint",
762 specs
763 .iter()
764 .map(|spec| ExpectedTensor::new(&spec.name, spec.shape.clone(), spec.dtype)),
765 );
766 let model_config = serde_json::from_str(PINNED_MODEL_CONFIG).map_err(|error| {
767 FttsError::Generic(format!(
768 "checked-in pinned model config is invalid JSON: {error}"
769 ))
770 })?;
771 let q8_count = specs
772 .iter()
773 .filter(|spec| spec.storage == TensorStoragePolicy::Q8PerOutputChannel)
774 .count();
775 let mut plan = StreamingConversionPlan::new(
776 "qwen3-tts-12hz-0.6b-base",
777 PINNED_MAIN_WEIGHTS_SHA256,
778 )
779 .license_notice(pinned_license_notice())
780 .model_config(model_config)
781 .quantization_manifest(json!({
782 "source": {
783 "repository": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
784 "revision": PINNED_MODEL_REVISION,
785 "file": PINNED_MAIN_WEIGHTS_FILENAME,
786 "sha256": PINNED_MAIN_WEIGHTS_SHA256,
787 },
788 "q8_recipe": "symmetric per-output-channel int8; zero_point=0; scale=max_abs(row)/127",
789 "q8_tensor_count": q8_count,
790 "verbatim_tensor_count": specs.len() - q8_count,
791 "q8_scope": "talker and residual-code-microdecoder attention/MLP projection matrices only",
792 "verbatim_scope": "norms, heads, embeddings, speaker path, and every tensor outside the reviewed Q8 projection set",
793 }));
794 for spec in specs {
795 let conversion = match spec.storage {
796 TensorStoragePolicy::Verbatim => {
797 TensorConversion::verbatim(&spec.name, &spec.name, spec.access_class)
798 }
799 TensorStoragePolicy::Q8PerOutputChannel => {
800 TensorConversion::q8_per_output_channel(&spec.name, &spec.name, spec.access_class)
801 }
802 };
803 plan = plan.tensor(conversion);
804 }
805 Ok((manifest, plan))
806}
807
808fn pinned_main_tensor_specs() -> Result<Vec<PinnedMainTensor>, FttsError> {
809 let inventory: Value = serde_json::from_str(PINNED_TENSOR_INVENTORY).map_err(|error| {
810 FttsError::Generic(format!(
811 "checked-in tensor inventory is invalid JSON: {error}"
812 ))
813 })?;
814 if inventory.get("source_pin").and_then(Value::as_str)
815 != Some(&format!(
816 "Qwen/Qwen3-TTS-12Hz-0.6B-Base@{PINNED_MODEL_REVISION}"
817 ))
818 {
819 return Err(FttsError::Generic(
820 "checked-in tensor inventory does not name the pinned Qwen3-TTS revision".to_owned(),
821 ));
822 }
823 let records = inventory
824 .get("tensors")
825 .and_then(Value::as_array)
826 .ok_or_else(|| {
827 FttsError::Generic("checked-in tensor inventory lacks tensors[]".to_owned())
828 })?;
829 let mut specs = Vec::new();
830 for record in records {
831 if record.get("source").and_then(Value::as_str) != Some(PINNED_MAIN_WEIGHTS_FILENAME) {
832 continue;
833 }
834 let name = required_inventory_string(record, "name")?.to_owned();
835 let dtype = match required_inventory_string(record, "dtype")? {
836 "BF16" => Dtype::Bf16,
837 "F32" => Dtype::F32,
838 other => {
839 return Err(FttsError::Generic(format!(
840 "pinned main inventory has unsupported dtype {other:?} for {name}"
841 )));
842 }
843 };
844 let shape = record
845 .get("shape")
846 .and_then(Value::as_array)
847 .ok_or_else(|| {
848 FttsError::Generic(format!("pinned inventory tensor {name} lacks shape[]"))
849 })?
850 .iter()
851 .map(|dimension| {
852 dimension
853 .as_u64()
854 .and_then(|dimension| usize::try_from(dimension).ok())
855 .ok_or_else(|| {
856 FttsError::Generic(format!(
857 "pinned inventory tensor {name} has a non-usize shape dimension"
858 ))
859 })
860 })
861 .collect::<Result<Vec<_>, _>>()?;
862 let storage = if is_q8_projection(&name) {
863 TensorStoragePolicy::Q8PerOutputChannel
864 } else {
865 TensorStoragePolicy::Verbatim
866 };
867 specs.push(PinnedMainTensor {
868 access_class: main_access_class(&name)?,
869 name,
870 dtype,
871 shape,
872 storage,
873 });
874 }
875 if specs.len() != PINNED_MAIN_TENSOR_COUNT {
876 return Err(FttsError::Generic(format!(
877 "pinned main inventory contains {} tensors, expected {PINNED_MAIN_TENSOR_COUNT}",
878 specs.len()
879 )));
880 }
881 Ok(specs)
882}
883
884fn required_inventory_string<'a>(record: &'a Value, field: &str) -> Result<&'a str, FttsError> {
885 record.get(field).and_then(Value::as_str).ok_or_else(|| {
886 FttsError::Generic(format!(
887 "checked-in tensor inventory record lacks string {field:?}"
888 ))
889 })
890}
891
892fn is_q8_projection(name: &str) -> bool {
893 (name.starts_with("talker.model.layers.")
894 || name.starts_with("talker.code_predictor.model.layers."))
895 && [
896 ".self_attn.q_proj.weight",
897 ".self_attn.k_proj.weight",
898 ".self_attn.v_proj.weight",
899 ".self_attn.o_proj.weight",
900 ".mlp.gate_proj.weight",
901 ".mlp.up_proj.weight",
902 ".mlp.down_proj.weight",
903 ]
904 .iter()
905 .any(|suffix| name.ends_with(suffix))
906}
907
908fn main_access_class(name: &str) -> Result<AccessClass, FttsError> {
909 if name == "talker.model.text_embedding.weight" {
910 Ok(AccessClass::ColdTextEmbedding)
911 } else if name.starts_with("speaker_encoder.") {
912 Ok(AccessClass::EnrollmentSpeakerEncoder)
913 } else if name.starts_with("talker.code_predictor.")
914 || name == "talker.model.codec_embedding.weight"
915 {
916 Ok(AccessClass::HotRecurrentMicrodecoder)
917 } else if name.starts_with("talker.model.")
918 || name.starts_with("talker.codec_head.")
919 || name.starts_with("talker.text_projection.")
920 {
921 Ok(AccessClass::HotRecurrentTalker)
922 } else {
923 Err(FttsError::Generic(format!(
924 "pinned main tensor {name} has no reviewed access-class assignment"
925 )))
926 }
927}
928
929fn pinned_license_notice() -> String {
930 format!(
931 "This artifact contains model weights derived from\n\
932 Qwen3-TTS-12Hz-0.6B-Base (https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-Base)\n\
933 and code derived from QwenLM/Qwen3-TTS (https://github.com/QwenLM/Qwen3-TTS).\n\n\
934 Copyright 2026 Alibaba Cloud\n\n\
935 Licensed under the Apache License, Version 2.0.\n\
936 http://www.apache.org/licenses/LICENSE-2.0\n\n\
937 CHANGES: the original bfloat16 weights were converted to franken_tts's\n\
938 quantized .fttsq container. Tensors were requantized according to the\n\
939 artifact's quantization manifest; protected tensors remain verbatim.\n\
940 The model graph is re-implemented in Rust.\n\n\
941 Apache License, Version 2.0:\n\n{APACHE_LICENSE}"
942 )
943}
944
945fn run_say(
946 cli: &Cli,
947 args: &SayArgs,
948 environment: &Environment,
949 stdin: &mut dyn Read,
950 stdout: &mut dyn Write,
951 stderr: &mut dyn Write,
952) -> Result<(), FttsError> {
953 let run = robot::RunContext::generate();
954 let outcome = if args.stream == Some(StreamMode::Raw) {
959 run_say_events(cli, args, environment, stdin, &run, stdout, &mut |event| {
960 write_json_line(stderr, event)
961 })
962 } else {
963 let mut discard = io::sink();
964 run_say_events(
965 cli,
966 args,
967 environment,
968 stdin,
969 &run,
970 &mut discard,
971 &mut |event| write_json_line(stdout, event),
972 )
973 };
974
975 if let Err(error) = &outcome {
976 let mut event = run.event(robot::EventType::RunError);
980 event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
981 event.insert("kind".to_owned(), json!(error.exit_code().description()));
982 event.insert("message".to_owned(), json!(error.to_string()));
983 event.insert("remediation".to_owned(), json!(error.remediation()));
984 event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
985 write_json_line(stderr, &Value::Object(event))?;
986 }
987
988 outcome
989}
990
991fn emit_stage(
993 run: &robot::RunContext,
994 emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
995 name: &str,
996 state: &str,
997 seq: &mut u64,
998) -> Result<(), FttsError> {
999 let mut event = run.event(robot::EventType::Stage);
1000 event.insert("name".to_owned(), json!(name));
1001 event.insert("seq".to_owned(), json!(*seq));
1002 event.insert("state".to_owned(), json!(state));
1003 event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1004 event.insert("budget_ms".to_owned(), Value::Null);
1005 *seq += 1;
1006 emit(&Value::Object(event))
1007}
1008
1009fn run_say_events(
1014 cli: &Cli,
1015 args: &SayArgs,
1016 environment: &Environment,
1017 stdin: &mut dyn Read,
1018 run: &robot::RunContext,
1019 raw_audio: &mut dyn Write,
1020 emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
1021) -> Result<(), FttsError> {
1022 let settings = EffectiveSettings::resolve(cli, environment)?;
1023
1024 let mut start = run.event(robot::EventType::RunStart);
1025 start.insert("command".to_owned(), json!("say"));
1026 start.insert("profile".to_owned(), json!(settings.profile.as_str()));
1027 start.insert(
1028 "packet_frames".to_owned(),
1029 json!(settings.packet_frames.as_str()),
1030 );
1031 start.insert("math_mode".to_owned(), json!(settings.math_mode.as_str()));
1032 start.insert("stateless".to_owned(), json!(true));
1033 start.insert("seed".to_owned(), json!(cli.seed));
1034 start.insert("model".to_owned(), json!(args.model.as_deref()));
1035 start.insert(
1036 "voice".to_owned(),
1037 json!(args.voice.as_ref().map(|path| path.display().to_string())),
1038 );
1039 emit(&Value::Object(start))?;
1040
1041 let mut seq = 0u64;
1042
1043 emit_stage(run, emit, "resolve", "begin", &mut seq)?;
1044 let text = read_text(args, stdin)?;
1045 let model = resolve_model(args.model.as_deref(), environment)?;
1046 let voice = resolve_requested_voice(args.voice.as_deref(), environment)?;
1047 emit_stage(run, emit, "resolve", "end", &mut seq)?;
1048
1049 let request = SynthesisRequest::new(text)
1050 .with_normalization_options(settings.normalization_options())
1051 .with_normalization_trace(cli.trace.is_some());
1052
1053 let mut prepared = run.event(robot::EventType::TextPrepared);
1057 prepared.insert("normalize".to_owned(), json!(settings.normalize.as_str()));
1058 prepared.insert(
1059 "unicode_version".to_owned(),
1060 json!(ftts_model_qwen::tokenizer::unicode_version()),
1061 );
1062 prepared.insert("char_count".to_owned(), json!(request.text.chars().count()));
1063 prepared.insert(
1064 "trace_requested".to_owned(),
1065 json!(request.trace_normalization),
1066 );
1067 emit(&Value::Object(prepared))?;
1068
1069 let requested_output: Option<PathBuf> = args
1071 .output
1072 .clone()
1073 .or_else(|| args.output_positional.clone());
1074 let output_plan = requested_output
1075 .as_deref()
1076 .map(OutputPlan::for_path)
1077 .transpose()?;
1078
1079 emit_stage(run, emit, "admission", "begin", &mut seq)?;
1080 let admission = admission_plan(&request.text, &settings)?;
1081 emit_stage(run, emit, "admission", "end", &mut seq)?;
1082
1083 if args.check {
1084 let event = json!({
1085 "schema_version": ROBOT_SCHEMA_VERSION,
1086 "event": "check_complete",
1087 "run_id": run.run_id(),
1088 "model": model,
1089 "voice": voice,
1090 "profile": settings.profile.as_str(),
1091 "packet_frames": settings.packet_frames.as_str(),
1092 "math_mode": settings.math_mode.as_str(),
1093 "voice_pack": settings.voice_pack.as_str(),
1094 "normalize": settings.normalize.as_str(),
1095 "normalization_trace_requested": request.trace_normalization,
1096 "seed": cli.seed,
1097 "trace": cli.trace.as_ref().map(|path| path.display().to_string()),
1098 "output": requested_output.as_ref().map(|path| path.display().to_string()),
1099 "admission": admission,
1100 });
1101 emit(&event)?;
1102 let mut complete = run.event(robot::EventType::RunComplete);
1103 complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
1104 complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1105 complete.insert("frames".to_owned(), json!(0));
1106 complete.insert("audio_bytes".to_owned(), json!(0));
1107 emit(&Value::Object(complete))?;
1108 return Ok(());
1109 }
1110
1111 let raw_stream = args.stream == Some(StreamMode::Raw);
1115 let mut audio = match (&output_plan, raw_stream) {
1116 (Some(plan), false) => AudioOutput::wav(&plan.wav_path)?,
1117 (None, true) => AudioOutput::raw(),
1118 (None, false) => {
1119 return Err(FttsError::Usage(
1120 "`ftts say` has nowhere to put the audio; add an output path (`ftts say \"text\" \
1121 out.wav`, or `-o PATH`) or `--stream raw` for PCM on stdout"
1122 .to_owned(),
1123 ));
1124 }
1125 (Some(_), true) => unreachable!("clap enforces the conflict"),
1127 };
1128
1129 emit_stage(run, emit, "load", "begin", &mut seq)?;
1131 let bundle = synth::ModelBundle::resolve(Path::new(&model))?;
1132 let voice_path = voice
1133 .as_deref()
1134 .map(PathBuf::from)
1135 .or_else(|| {
1136 let candidate = bundle.root.join("default.spk");
1137 candidate.is_file().then_some(candidate)
1138 })
1139 .ok_or_else(|| {
1140 FttsError::Usage(
1141 "`ftts say` needs a real voice source; pass --voice PATH, set \
1142 FTTS_DEFAULT_VOICE=PATH, or run `ftts enroll REF.wav --model MODEL_DIR --default`. \
1143 Qwen3-TTS Base has no preset speaker, so a missing default is refused rather than \
1144 fabricated."
1145 .to_owned(),
1146 )
1147 })?;
1148 let speaker = synth::speaker_from_voice(&bundle, &voice_path)?;
1149 let loaded = synth::LoadedModel::load(&bundle)?;
1150 emit_stage(run, emit, "load", "end", &mut seq)?;
1151
1152 let observer = |_event: ftts_core::SynthesisEvent| {};
1159
1160 emit_stage(run, emit, "synthesis", "begin", &mut seq)?;
1161 let engine = ftts_core::TtsEngine::from_process_environment()
1162 .map_err(|error| FttsError::Generic(format!("cannot start the engine: {error}")))?;
1163 let cancellation = ftts_core::CancellationToken::new();
1164 let audio_result = synth::synthesize(
1165 &loaded,
1166 &engine,
1167 &request,
1168 &speaker,
1169 cli.seed.unwrap_or(0),
1173 &cancellation,
1174 &observer,
1175 )?;
1176 emit_stage(run, emit, "synthesis", "end", &mut seq)?;
1177
1178 let packet_samples = settings.packet_frames.samples_per_packet();
1180 let packet_frame_count = settings.packet_frames.frames_per_packet();
1181 emit_stage(run, emit, "output", "begin", &mut seq)?;
1182 for packet in audio_result.pcm.chunks(packet_samples) {
1183 let event = audio.write_packet(packet, raw_audio, run.run_id(), packet_frame_count)?;
1184 emit(&event)?;
1185 }
1186 let audio_bytes = audio.byte_offset();
1187 let samples = audio.finish()?;
1188 if let Some(plan) = &output_plan {
1189 plan.finalize()?;
1190 }
1191 emit_stage(run, emit, "output", "end", &mut seq)?;
1192
1193 let mut complete = run.event(robot::EventType::RunComplete);
1194 complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
1195 complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1196 complete.insert("frames".to_owned(), json!(audio_result.frames));
1197 complete.insert("audio_bytes".to_owned(), json!(audio_bytes));
1198 complete.insert("samples".to_owned(), json!(samples));
1199 complete.insert(
1200 "duration_ms".to_owned(),
1201 json!(samples * 1000 / u64::from(ftts_core::audio::SAMPLE_RATE_HZ)),
1202 );
1203 complete.insert(
1204 "prepared_token_count".to_owned(),
1205 json!(audio_result.prepared_token_count),
1206 );
1207 emit(&Value::Object(complete))?;
1208 Ok(())
1209}
1210
1211pub enum AudioSink {
1220 Wav(Box<ftts_core::audio::WavWriter<fs::File>>),
1223 RawPcm,
1225 None,
1227}
1228
1229pub struct AudioOutput {
1231 sink: AudioSink,
1232 byte_offset: u64,
1233 samples_written: u64,
1234}
1235
1236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1238enum OutputFormat {
1239 Wav,
1241 M4a,
1243 Mp3,
1245 Flac,
1247}
1248
1249#[derive(Clone, Debug)]
1257struct OutputPlan {
1258 final_path: PathBuf,
1260 wav_path: PathBuf,
1262 format: OutputFormat,
1263}
1264
1265impl OutputPlan {
1266 fn for_path(path: &Path) -> Result<Self, FttsError> {
1267 let extension = path
1268 .extension()
1269 .and_then(|extension| extension.to_str())
1270 .map(str::to_ascii_lowercase);
1271 let format = match extension.as_deref() {
1272 Some("wav") | None => OutputFormat::Wav,
1273 Some("m4a" | "aac") => OutputFormat::M4a,
1274 Some("mp3") => OutputFormat::Mp3,
1275 Some("flac") => OutputFormat::Flac,
1276 Some(other) => {
1277 return Err(FttsError::Usage(format!(
1278 "unsupported output extension `.{other}`; use .wav (native), .m4a, .mp3, or \
1279 .flac (system encoder)"
1280 )));
1281 }
1282 };
1283 let wav_path = if format == OutputFormat::Wav {
1284 path.to_path_buf()
1285 } else {
1286 let mut staging = path.as_os_str().to_owned();
1287 staging.push(".ftts-staging.wav");
1288 PathBuf::from(staging)
1289 };
1290 Ok(Self {
1291 final_path: path.to_path_buf(),
1292 wav_path,
1293 format,
1294 })
1295 }
1296
1297 fn finalize(&self) -> Result<(), FttsError> {
1299 if self.format == OutputFormat::Wav {
1300 return Ok(());
1301 }
1302 let wav = self.wav_path.as_os_str();
1303 let target = self.final_path.as_os_str();
1304 let attempts: &[(&str, Vec<&std::ffi::OsStr>)] = &match self.format {
1306 OutputFormat::M4a => [
1307 (
1308 "afconvert",
1309 vec![
1310 "-f".as_ref(),
1311 "m4af".as_ref(),
1312 "-d".as_ref(),
1313 "aac".as_ref(),
1314 wav,
1315 target,
1316 ],
1317 ),
1318 (
1319 "ffmpeg",
1320 vec![
1321 "-y".as_ref(),
1322 "-loglevel".as_ref(),
1323 "error".as_ref(),
1324 "-i".as_ref(),
1325 wav,
1326 "-c:a".as_ref(),
1327 "aac".as_ref(),
1328 target,
1329 ],
1330 ),
1331 ],
1332 OutputFormat::Mp3 => [
1333 (
1334 "lame",
1335 vec!["--quiet".as_ref(), "-V2".as_ref(), wav, target],
1336 ),
1337 (
1338 "ffmpeg",
1339 vec![
1340 "-y".as_ref(),
1341 "-loglevel".as_ref(),
1342 "error".as_ref(),
1343 "-i".as_ref(),
1344 wav,
1345 "-codec:a".as_ref(),
1346 "libmp3lame".as_ref(),
1347 "-q:a".as_ref(),
1348 "2".as_ref(),
1349 target,
1350 ],
1351 ),
1352 ],
1353 OutputFormat::Flac => [
1354 (
1355 "flac",
1356 vec![
1357 "--totally-silent".as_ref(),
1358 "-f".as_ref(),
1359 "-o".as_ref(),
1360 target,
1361 wav,
1362 ],
1363 ),
1364 (
1365 "ffmpeg",
1366 vec![
1367 "-y".as_ref(),
1368 "-loglevel".as_ref(),
1369 "error".as_ref(),
1370 "-i".as_ref(),
1371 wav,
1372 "-c:a".as_ref(),
1373 "flac".as_ref(),
1374 target,
1375 ],
1376 ),
1377 ],
1378 OutputFormat::Wav => unreachable!("handled above"),
1379 };
1380
1381 let mut tried = Vec::new();
1382 for (tool, arguments) in attempts {
1383 match std::process::Command::new(tool).args(arguments).status() {
1386 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1387 tried.push(*tool);
1388 }
1389 Err(error) => {
1390 return Err(FttsError::Generic(format!(
1391 "audio encoder `{tool}` could not run: {error}; the synthesized WAV is \
1392 preserved at {}",
1393 self.wav_path.display()
1394 )));
1395 }
1396 Ok(status) if status.success() => {
1397 let _ = fs::remove_file(&self.wav_path);
1400 return Ok(());
1401 }
1402 Ok(status) => {
1403 return Err(FttsError::Generic(format!(
1404 "audio encoder `{tool}` exited with {status}; the synthesized WAV is \
1405 preserved at {}",
1406 self.wav_path.display()
1407 )));
1408 }
1409 }
1410 }
1411 Err(FttsError::Generic(format!(
1412 "no system audio encoder found for {} (tried: {}); install one or use a .wav output. \
1413 The synthesized WAV is preserved at {}",
1414 self.final_path.display(),
1415 tried.join(", "),
1416 self.wav_path.display()
1417 )))
1418 }
1419}
1420
1421impl AudioOutput {
1422 pub fn wav(path: &Path) -> Result<Self, FttsError> {
1428 let file = fs::File::create(path).map_err(|error| {
1429 FttsError::Generic(format!(
1430 "cannot create audio output {}: {error}",
1431 path.display()
1432 ))
1433 })?;
1434 let writer = ftts_core::audio::WavWriter::new(file, ftts_core::audio::SAMPLE_RATE_HZ)
1435 .map_err(|error| {
1436 FttsError::Generic(format!(
1437 "cannot write WAV header to {}: {error}",
1438 path.display()
1439 ))
1440 })?;
1441 Ok(Self {
1442 sink: AudioSink::Wav(Box::new(writer)),
1443 byte_offset: 0,
1444 samples_written: 0,
1445 })
1446 }
1447
1448 #[must_use]
1450 pub const fn raw() -> Self {
1451 Self {
1452 sink: AudioSink::RawPcm,
1453 byte_offset: 0,
1454 samples_written: 0,
1455 }
1456 }
1457
1458 #[must_use]
1460 pub const fn none() -> Self {
1461 Self {
1462 sink: AudioSink::None,
1463 byte_offset: 0,
1464 samples_written: 0,
1465 }
1466 }
1467
1468 #[must_use]
1470 pub const fn sink_name(&self) -> &'static str {
1471 match self.sink {
1472 AudioSink::Wav(_) => "file",
1473 AudioSink::RawPcm => "stdout",
1474 AudioSink::None => "none",
1475 }
1476 }
1477
1478 #[must_use]
1480 pub const fn byte_offset(&self) -> u64 {
1481 self.byte_offset
1482 }
1483
1484 pub fn write_packet(
1497 &mut self,
1498 pcm: &[f32],
1499 raw: &mut dyn Write,
1500 run_id: &str,
1501 frame_count: u8,
1502 ) -> Result<Value, FttsError> {
1503 let offset_before = self.byte_offset;
1504 let bytes = (pcm.len() * 2) as u64;
1505
1506 match &mut self.sink {
1507 AudioSink::Wav(writer) => writer.write_samples(pcm).map_err(|error| {
1508 FttsError::Generic(format!("cannot write audio samples: {error}"))
1509 })?,
1510 AudioSink::RawPcm => {
1511 let mut buffer = Vec::with_capacity(pcm.len() * 2);
1512 for sample in pcm {
1513 buffer
1514 .extend_from_slice(&ftts_core::audio::sample_to_i16(*sample).to_le_bytes());
1515 }
1516 raw.write_all(&buffer).map_err(|error| {
1517 FttsError::Generic(format!("cannot write raw PCM: {error}"))
1518 })?;
1519 }
1520 AudioSink::None => {}
1521 }
1522
1523 self.byte_offset += bytes;
1524 self.samples_written += pcm.len() as u64;
1525
1526 let mut event = robot::EventType::AudioChunk.event();
1527 event.insert("run_id".to_owned(), json!(run_id));
1528 event.insert("byte_offset".to_owned(), json!(offset_before));
1529 event.insert("bytes".to_owned(), json!(bytes));
1530 event.insert(
1531 "duration_ms".to_owned(),
1532 json!((pcm.len() as u64) * 1000 / u64::from(ftts_core::audio::SAMPLE_RATE_HZ.max(1))),
1533 );
1534 event.insert("packet_frames".to_owned(), json!(frame_count.to_string()));
1535 event.insert("sink".to_owned(), json!(self.sink_name()));
1536 Ok(Value::Object(event))
1537 }
1538
1539 pub fn finish(self) -> Result<u64, FttsError> {
1545 let samples = self.samples_written;
1546 if let AudioSink::Wav(writer) = self.sink {
1547 writer.finish().map_err(|error| {
1548 FttsError::Generic(format!("cannot finalize the WAV header: {error}"))
1549 })?;
1550 }
1551 Ok(samples)
1552 }
1553}
1554
1555fn read_text(args: &SayArgs, stdin: &mut dyn Read) -> Result<String, FttsError> {
1556 let text = match (&args.text, &args.file) {
1557 (Some(text), None) if text == "-" => read_utf8(stdin, "stdin")?,
1558 (Some(text), None) => text.clone(),
1559 (None, Some(path)) if path == Path::new("-") => read_utf8(stdin, "stdin")?,
1560 (None, Some(path)) => fs::read_to_string(path).map_err(|error| {
1561 FttsError::Input(format!(
1562 "cannot read text file {}: {error}; use `ftts say --file PATH --check --model PATH`",
1563 path.display()
1564 ))
1565 })?,
1566 (None, None) => {
1567 return Err(FttsError::Usage(
1568 "missing text; use `ftts say TEXT`, `ftts say --file PATH`, or `ftts say -`".to_owned(),
1569 ));
1570 }
1571 (Some(_), Some(_)) => unreachable!("clap enforces the conflict"),
1572 };
1573
1574 if text.trim().is_empty() {
1575 return Err(FttsError::Input(
1576 "text is empty; provide non-whitespace UTF-8 text to `ftts say`".to_owned(),
1577 ));
1578 }
1579 Ok(text)
1580}
1581
1582fn read_utf8(reader: &mut dyn Read, source: &str) -> Result<String, FttsError> {
1583 let mut bytes = Vec::new();
1584 reader.read_to_end(&mut bytes).map_err(|error| {
1585 FttsError::Input(format!(
1586 "cannot read {source}: {error}; retry with readable UTF-8 input"
1587 ))
1588 })?;
1589 String::from_utf8(bytes).map_err(|error| {
1590 FttsError::Input(format!(
1591 "{source} is not valid UTF-8: {error}; transcode it before `ftts say`"
1592 ))
1593 })
1594}
1595
1596fn resolve_model(explicit: Option<&Path>, environment: &Environment) -> Result<String, FttsError> {
1597 resolve_model_from(
1598 explicit,
1599 &model_search_paths(environment),
1600 default_pull_model_dir(environment).as_deref(),
1601 )
1602}
1603
1604fn resolve_model_from(
1609 explicit: Option<&Path>,
1610 searched: &[PathBuf],
1611 pull_dir: Option<&Path>,
1612) -> Result<String, FttsError> {
1613 if let Some(path) = explicit {
1614 if path.is_dir() {
1618 return Ok(path.display().to_string());
1619 }
1620 return resolve_existing_file(path, "model artifact")
1621 .map(|path| path.display().to_string());
1622 }
1623
1624 if let Some(path) = searched.iter().find(|path| path.is_file()) {
1625 return Ok(path.display().to_string());
1626 }
1627 if let Some(directory) = searched
1632 .iter()
1633 .filter_map(|path| path.parent())
1634 .find(|directory| directory.join("model.safetensors").is_file())
1635 {
1636 return Ok(directory.display().to_string());
1637 }
1638
1639 if let Some(directory) = pull_dir
1643 && directory.is_dir()
1644 && synth::ModelBundle::resolve(directory).is_ok()
1645 {
1646 return Ok(directory.display().to_string());
1647 }
1648
1649 let searched = searched
1650 .iter()
1651 .map(|path| path.display().to_string())
1652 .collect::<Vec<_>>()
1653 .join(", ");
1654 Err(FttsError::ModelNotFound(format!(
1655 "no model artifact was found; searched: [{searched}]; run `ftts pull` to fetch the model \
1656 (~2.0 GB), or pass --model PATH or set FTTS_MODEL_DIR"
1657 )))
1658}
1659
1660fn resolve_optional_file(path: Option<&Path>, label: &str) -> Result<Option<String>, FttsError> {
1661 path.map(|path| resolve_existing_file(path, label).map(|path| path.display().to_string()))
1662 .transpose()
1663}
1664
1665fn resolve_requested_voice(
1666 explicit: Option<&Path>,
1667 environment: &Environment,
1668) -> Result<Option<String>, FttsError> {
1669 if let Some(path) = explicit {
1670 return resolve_optional_file(Some(path), "voice source");
1671 }
1672 environment
1673 .value("FTTS_DEFAULT_VOICE")
1674 .map(Path::new)
1675 .map(|path| resolve_existing_file(path, "FTTS_DEFAULT_VOICE"))
1676 .transpose()
1677 .map(|path| path.map(|path| path.display().to_string()))
1678}
1679
1680fn run_enroll(
1681 args: &EnrollArgs,
1682 environment: &Environment,
1683 stdout: &mut dyn Write,
1684) -> Result<(), FttsError> {
1685 let _ = args.force;
1686 let model = resolve_model(args.model.as_deref(), environment)?;
1687 let bundle = synth::ModelBundle::resolve(Path::new(&model))?;
1688 let output = match (&args.output, args.default) {
1689 (Some(path), false) => path.clone(),
1690 (None, true) => bundle.root.join("default.spk"),
1691 (None, false) => {
1692 return Err(FttsError::Usage(
1693 "`ftts enroll` needs -o PATH or --default; enrollment never overwrites a voice source"
1694 .to_owned(),
1695 ));
1696 }
1697 (Some(_), true) => unreachable!("clap enforces the conflict"),
1698 };
1699 let speaker = synth::speaker_from_voice(&bundle, &args.reference_audio)?;
1700 synth::write_speaker_vector_new(&output, &speaker)?;
1701 writeln!(
1702 stdout,
1703 "enrolled x-vector from {} to {}{}",
1704 args.reference_audio.display(),
1705 output.display(),
1706 if args.default {
1707 "; `ftts say` will use it when --voice is absent"
1708 } else {
1709 ""
1710 },
1711 )
1712 .map_err(|error| FttsError::Generic(format!("cannot write enrollment result: {error}")))
1713}
1714
1715#[derive(Clone, Debug)]
1717struct ModelManifestFile {
1718 asset: String,
1720 dest: String,
1722 sha256: String,
1724 bytes: u64,
1726}
1727
1728#[derive(Clone, Debug)]
1730struct ModelManifest {
1731 model_id: String,
1732 release_tag: String,
1733 repo: String,
1734 files: Vec<ModelManifestFile>,
1735}
1736
1737impl ModelManifest {
1738 fn embedded() -> Result<Self, FttsError> {
1741 Self::parse(PINNED_MODEL_MANIFEST)
1742 }
1743
1744 fn parse(text: &str) -> Result<Self, FttsError> {
1747 let value: Value = serde_json::from_str(text).map_err(|error| {
1748 FttsError::ArtifactFormat(format!("model manifest is not valid JSON: {error}"))
1749 })?;
1750 if value["schema_version"].as_u64() != Some(1) {
1751 return Err(FttsError::ArtifactFormat(format!(
1752 "model manifest schema_version {} is not the supported 1",
1753 value["schema_version"]
1754 )));
1755 }
1756 let model_id = manifest_string(&value, "model_id")?;
1757 let release_tag = manifest_string(&value, "release_tag")?;
1758 let repo = manifest_string(&value, "repo")?;
1759 let files = value["files"]
1760 .as_array()
1761 .filter(|files| !files.is_empty())
1762 .ok_or_else(|| {
1763 FttsError::ArtifactFormat("model manifest needs a non-empty files array".to_owned())
1764 })?
1765 .iter()
1766 .map(parse_manifest_file)
1767 .collect::<Result<Vec<_>, _>>()?;
1768 Ok(Self {
1769 model_id,
1770 release_tag,
1771 repo,
1772 files,
1773 })
1774 }
1775
1776 fn download_url(&self, file: &ModelManifestFile) -> String {
1778 format!(
1779 "https://github.com/{}/releases/download/{}/{}",
1780 self.repo, self.release_tag, file.asset
1781 )
1782 }
1783
1784 fn total_bytes(&self) -> u64 {
1785 self.files.iter().map(|file| file.bytes).sum()
1786 }
1787}
1788
1789fn manifest_string(value: &Value, field: &str) -> Result<String, FttsError> {
1790 value[field]
1791 .as_str()
1792 .filter(|text| !text.is_empty())
1793 .map(str::to_owned)
1794 .ok_or_else(|| {
1795 FttsError::ArtifactFormat(format!(
1796 "model manifest field {field} must be a non-empty string"
1797 ))
1798 })
1799}
1800
1801fn parse_manifest_file(value: &Value) -> Result<ModelManifestFile, FttsError> {
1802 let asset = manifest_string(value, "asset")?;
1803 if asset.contains('/') || asset.contains('\\') {
1804 return Err(FttsError::ArtifactFormat(format!(
1805 "manifest asset {asset:?} must be a bare release-asset name"
1806 )));
1807 }
1808 let dest = manifest_string(value, "dest")?;
1809 validate_manifest_dest(&dest)?;
1810 let sha256 = manifest_string(value, "sha256")?;
1811 if !is_sha256_hex(&sha256) {
1812 return Err(FttsError::ArtifactFormat(format!(
1813 "manifest sha256 for {asset} must be 64 lowercase hex characters"
1814 )));
1815 }
1816 let bytes = value["bytes"]
1817 .as_u64()
1818 .filter(|bytes| *bytes > 0)
1819 .ok_or_else(|| {
1820 FttsError::ArtifactFormat(format!(
1821 "manifest bytes for {asset} must be a positive integer"
1822 ))
1823 })?;
1824 Ok(ModelManifestFile {
1825 asset,
1826 dest,
1827 sha256,
1828 bytes,
1829 })
1830}
1831
1832fn validate_manifest_dest(dest: &str) -> Result<(), FttsError> {
1835 let path = Path::new(dest);
1836 let traversal_free = path
1837 .components()
1838 .all(|component| matches!(component, std::path::Component::Normal(_)));
1839 if path.is_absolute() || dest.contains('\\') || !traversal_free {
1840 return Err(FttsError::ArtifactFormat(format!(
1841 "manifest dest {dest:?} must be a relative path with no traversal; it is joined under the model directory"
1842 )));
1843 }
1844 Ok(())
1845}
1846
1847fn is_sha256_hex(text: &str) -> bool {
1848 text.len() == 64
1849 && text
1850 .bytes()
1851 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
1852}
1853
1854fn default_pull_model_dir(environment: &Environment) -> Option<PathBuf> {
1857 if let Some(first) = environment
1858 .value("FTTS_MODEL_DIR")
1859 .and_then(|dirs| std::env::split_paths(dirs).next())
1860 .filter(|path| !path.as_os_str().is_empty())
1861 {
1862 return Some(first);
1863 }
1864 std::env::var_os("HOME").map(|home| PathBuf::from(home).join(DEFAULT_MODEL_CACHE_SUBDIR))
1865}
1866
1867#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1869enum PullDecision {
1870 Skip,
1872 Download,
1874}
1875
1876fn pull_decision(dest: &Path, file: &ModelManifestFile, force: bool) -> PullDecision {
1881 if force {
1882 return PullDecision::Download;
1883 }
1884 let Ok(metadata) = fs::metadata(dest) else {
1885 return PullDecision::Download;
1886 };
1887 if !metadata.is_file() || metadata.len() != file.bytes {
1888 return PullDecision::Download;
1889 }
1890 match ftts_artifacts::sha256::hex_digest_file(dest) {
1891 Ok(digest) if digest == file.sha256 => PullDecision::Skip,
1892 _ => PullDecision::Download,
1893 }
1894}
1895
1896fn download_with_curl(url: &str, staging: &Path) -> Result<(), FttsError> {
1902 let outcome = std::process::Command::new("curl")
1903 .args(["-L", "--fail", "--retry", "3", "-sS", "-o"])
1904 .arg(staging)
1905 .arg(url)
1906 .status();
1907 match outcome {
1908 Err(error) if error.kind() == io::ErrorKind::NotFound => Err(FttsError::Generic(
1909 "`ftts pull` downloads with the system `curl`, which was not found on PATH; \
1910 install curl and retry, or download the release assets by hand"
1911 .to_owned(),
1912 )),
1913 Err(error) => Err(FttsError::Generic(format!("cannot run curl: {error}"))),
1914 Ok(status) if status.success() => Ok(()),
1915 Ok(status) => Err(FttsError::Generic(format!(
1916 "curl failed downloading {url} ({status}); check network access and retry `ftts pull`"
1917 ))),
1918 }
1919}
1920
1921fn verify_pulled_file(path: &Path, file: &ModelManifestFile) -> Result<(), FttsError> {
1923 let metadata = fs::metadata(path).map_err(|error| {
1924 FttsError::Generic(format!(
1925 "cannot stat downloaded {}: {error}",
1926 path.display()
1927 ))
1928 })?;
1929 if metadata.len() != file.bytes {
1930 return Err(FttsError::ArtifactFormat(format!(
1931 "downloaded {} is {} bytes, expected {}; the incomplete download was discarded, retry `ftts pull`",
1932 file.asset,
1933 metadata.len(),
1934 file.bytes
1935 )));
1936 }
1937 let digest = ftts_artifacts::sha256::hex_digest_file(path).map_err(|error| {
1938 FttsError::Generic(format!(
1939 "cannot hash downloaded {}: {error}",
1940 path.display()
1941 ))
1942 })?;
1943 if digest != file.sha256 {
1944 return Err(FttsError::ArtifactFormat(format!(
1945 "downloaded {} carries sha256 {digest}, expected {}; the corrupt download was discarded, retry `ftts pull`",
1946 file.asset, file.sha256
1947 )));
1948 }
1949 Ok(())
1950}
1951
1952fn pull_staging_path(dest: &Path) -> PathBuf {
1954 let mut name = dest.file_name().map(OsString::from).unwrap_or_default();
1955 name.push(".part");
1956 dest.with_file_name(name)
1957}
1958
1959fn pull_one_file(
1966 manifest: &ModelManifest,
1967 file: &ModelManifestFile,
1968 dest: &Path,
1969) -> Result<(), FttsError> {
1970 if let Some(parent) = dest.parent() {
1971 fs::create_dir_all(parent).map_err(|error| {
1972 FttsError::Generic(format!(
1973 "cannot create model directory {}: {error}",
1974 parent.display()
1975 ))
1976 })?;
1977 }
1978 let staging = pull_staging_path(dest);
1979 let url = manifest.download_url(file);
1980 let outcome =
1981 download_with_curl(&url, &staging).and_then(|()| verify_pulled_file(&staging, file));
1982 if let Err(error) = outcome {
1983 let _ = fs::remove_file(&staging);
1984 return Err(error);
1985 }
1986 fs::rename(&staging, dest).map_err(|error| {
1987 FttsError::Generic(format!(
1988 "downloaded {} verified but could not be published to {}: {error}",
1989 file.asset,
1990 dest.display()
1991 ))
1992 })
1993}
1994
1995fn run_pull(
1996 args: &PullArgs,
1997 environment: &Environment,
1998 stdout: &mut dyn Write,
1999) -> Result<(), FttsError> {
2000 let manifest = ModelManifest::embedded()?;
2001 let destination = match &args.model {
2002 Some(path) => path.clone(),
2003 None => default_pull_model_dir(environment).ok_or_else(|| {
2004 FttsError::Usage(
2005 "cannot choose a model directory: pass --model PATH, or set FTTS_MODEL_DIR or HOME"
2006 .to_owned(),
2007 )
2008 })?,
2009 };
2010 writeln!(
2011 stdout,
2012 "pulling {} ({} files, {} bytes) into {}",
2013 manifest.model_id,
2014 manifest.files.len(),
2015 manifest.total_bytes(),
2016 destination.display()
2017 )
2018 .map_err(output_error)?;
2019 for file in &manifest.files {
2020 let dest = destination.join(&file.dest);
2021 match pull_decision(&dest, file, args.force) {
2022 PullDecision::Skip => writeln!(
2023 stdout,
2024 "{} ({} bytes): already present, verified",
2025 file.dest, file.bytes
2026 )
2027 .map_err(output_error)?,
2028 PullDecision::Download => {
2029 writeln!(stdout, "{} ({} bytes): downloading", file.dest, file.bytes)
2030 .map_err(output_error)?;
2031 pull_one_file(&manifest, file, &dest)?;
2032 writeln!(stdout, "{} ({} bytes): verified", file.dest, file.bytes)
2033 .map_err(output_error)?;
2034 }
2035 }
2036 }
2037 writeln!(stdout, "model ready at {}", destination.display()).map_err(output_error)
2038}
2039
2040fn resolve_existing_file<'a>(path: &'a Path, label: &str) -> Result<&'a Path, FttsError> {
2041 if path.is_file() {
2042 Ok(path)
2043 } else {
2044 Err(FttsError::ModelNotFound(format!(
2045 "{label} {} does not exist or is not a file; use an existing PATH",
2046 path.display()
2047 )))
2048 }
2049}
2050
2051fn admission_plan(text: &str, settings: &EffectiveSettings) -> Result<Value, FttsError> {
2064 if text.len() > SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES {
2065 return Err(FttsError::BudgetTimeout(format!(
2066 "text is {} bytes, above the Phase-0 admission bound of {} bytes; split the document before retrying",
2067 text.len(),
2068 SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES
2069 )));
2070 }
2071
2072 let characters = text.chars().count();
2073 let estimated_prompt_tokens = u64::try_from(characters).unwrap_or(u64::MAX);
2077 let policy = ftts_core::process_engine_config().admission;
2080
2081 match policy.admit(estimated_prompt_tokens) {
2082 Ok(plan) => Ok(json!({
2083 "status": "accepted",
2084 "scope": "preflight on an ESTIMATED prompt length; the binding decision is the \
2085 engine's, taken after tokenization",
2086 "text_bytes": text.len(),
2087 "text_characters": characters,
2088 "estimated_prompt_tokens": estimated_prompt_tokens,
2089 "predicted_max_frames": plan.predicted_max_frames,
2090 "predicted_peak_bytes": plan.predicted_peak_bytes,
2091 "budget_bytes": plan.budget_bytes,
2092 "binding_constraint": plan.binding_constraint.as_str(),
2093 "packet_frames": settings.packet_frames.as_str(),
2094 "profile": settings.profile.as_str(),
2095 })),
2096 Err(rejection) => Err(FttsError::BudgetTimeout(rejection.to_string())),
2100 }
2101}
2102
2103fn run_voice_inspect(path: &Path, stdout: &mut dyn Write) -> Result<(), FttsError> {
2104 let path = resolve_existing_file(path, "voice pack")?;
2105 write_json_line(
2106 stdout,
2107 &json!({
2108 "schema_version": ROBOT_SCHEMA_VERSION,
2109 "event": "voice_inspect",
2110 "path": path.display().to_string(),
2111 "status": "header_inspection_pending_artifact_reader",
2112 }),
2113 )
2114}
2115
2116fn run_robot(
2117 command: RobotCommand,
2118 environment: &Environment,
2119 stdout: &mut dyn Write,
2120) -> Result<(), FttsError> {
2121 let event = match command {
2125 RobotCommand::Schema => robot::schema_document(robot::DOCUMENTED_ENVIRONMENT),
2126 RobotCommand::Health => {
2127 let searched = model_search_paths(environment);
2128 let found = searched.iter().find(|path| looks_like_model_artifact(path));
2129 let mut object = robot::EventType::Health.event();
2130 object.insert("status".to_owned(), json!("phase0_skeleton"));
2131 object.insert("model_loaded".to_owned(), json!(false));
2132 object.insert("model_present".to_owned(), json!(found.is_some()));
2135 object.insert(
2136 "model_path".to_owned(),
2137 json!(found.map(|path| path.display().to_string())),
2138 );
2139 object.insert(
2140 "model_dir".to_owned(),
2141 json!(environment.value("FTTS_MODEL_DIR")),
2142 );
2143 object.insert(
2146 "searched".to_owned(),
2147 json!(
2148 searched
2149 .iter()
2150 .map(|path| path.display().to_string())
2151 .collect::<Vec<_>>()
2152 ),
2153 );
2154 object.insert("stateless_default".to_owned(), json!(true));
2155 object.insert(
2156 "threads".to_owned(),
2157 json!(
2158 environment
2159 .value("FTTS_THREADS")
2160 .and_then(|value| value.parse::<u64>().ok())
2161 ),
2162 );
2163 object.insert(
2164 "recommended_command".to_owned(),
2165 json!("ftts say --check --model PATH TEXT"),
2166 );
2167 Value::Object(object)
2168 }
2169 RobotCommand::Backends => {
2170 let mut object = robot::EventType::Backends.event();
2171 object.insert(
2174 "available".to_owned(),
2175 json!(
2176 ftts_kernels::int8::Int8Tier::available()
2177 .iter()
2178 .map(|tier| tier.as_str())
2179 .collect::<Vec<_>>()
2180 ),
2181 );
2182 object.insert(
2183 "dispatched".to_owned(),
2184 json!(ftts_kernels::int8::Int8Tier::dispatch().as_str()),
2185 );
2186 object.insert("isa_features".to_owned(), json!(detected_isa_features()));
2187 let plan = ftts_kernels::int8::autotuned_plan();
2188 object.insert(
2189 "kernel_plan".to_owned(),
2190 json!({
2191 "version": 0,
2192 "decode_gemv": plan.decode_gemv.as_str(),
2193 "batch_gemm": plan.batch_gemm.as_str(),
2194 "persisted": false,
2195 }),
2196 );
2197 object.insert("pool_sizing".to_owned(), Value::Null);
2198 object.insert(
2199 "force_arch".to_owned(),
2200 json!(environment.value("FTTS_FORCE_ARCH")),
2201 );
2202 Value::Object(object)
2203 }
2204 RobotCommand::Selftest => {
2205 let report = ftts_kernels::selftest::run_selftest();
2210 let checks: Vec<Value> = report
2211 .checks
2212 .iter()
2213 .map(|check| {
2214 json!({
2215 "row": check.row.id,
2216 "scope": check.row.scope.as_str(),
2217 "census_tensor": check.row.census_tensor,
2218 "reduction_k": check.row.reduction_k,
2219 "tier": check.tier.as_str(),
2220 "contract": check.contract.as_str(),
2221 "dispatched": check.tier == report.dispatched,
2222 "accumulator_i32": check.accumulator_i32,
2223 "reference_i64": check.reference_i64,
2224 "passed": check.passed,
2225 })
2226 })
2227 .collect();
2228 let mut object = robot::EventType::Selftest.event();
2229 object.insert(
2230 "status".to_owned(),
2231 json!(if report.passed() { "passed" } else { "failed" }),
2232 );
2233 object.insert("reason".to_owned(), Value::Null);
2234 object.insert("checks".to_owned(), json!(checks));
2235 Value::Object(object)
2236 }
2237 };
2238 write_json_line(stdout, &event)
2239}
2240
2241fn model_search_paths(environment: &Environment) -> Vec<PathBuf> {
2247 let mut searched = environment
2248 .value("FTTS_MODEL_DIR")
2249 .map(std::env::split_paths)
2250 .map(|paths| {
2251 paths
2252 .map(|path| path.join(MODEL_BASENAME))
2253 .collect::<Vec<_>>()
2254 })
2255 .unwrap_or_default();
2256 if let Some(home) = std::env::var_os("HOME") {
2257 let home = PathBuf::from(home);
2258 searched.push(home.join(".cache/franken_tts/models").join(MODEL_BASENAME));
2259 searched.push(home.join(DEFAULT_MODEL_CACHE_SUBDIR).join(MODEL_BASENAME));
2263 }
2264 searched
2265}
2266
2267fn looks_like_model_artifact(path: &Path) -> bool {
2273 use std::io::Read as _;
2274
2275 let Ok(mut file) = fs::File::open(path) else {
2276 return false;
2277 };
2278 let mut magic = [0u8; 5];
2279 file.read_exact(&mut magic).is_ok() && &magic == b"FTTSQ"
2280}
2281
2282fn detected_isa_features() -> Vec<&'static str> {
2287 let mut features = Vec::new();
2288 #[cfg(target_arch = "aarch64")]
2289 {
2290 if std::arch::is_aarch64_feature_detected!("neon") {
2291 features.push("neon");
2292 }
2293 if std::arch::is_aarch64_feature_detected!("dotprod") {
2294 features.push("dotprod");
2295 }
2296 if std::arch::is_aarch64_feature_detected!("i8mm") {
2297 features.push("i8mm");
2298 }
2299 }
2300 #[cfg(target_arch = "x86_64")]
2301 {
2302 if std::arch::is_x86_feature_detected!("avx2") {
2303 features.push("avx2");
2304 }
2305 if std::arch::is_x86_feature_detected!("avxvnni") {
2306 features.push("avx-vnni");
2307 }
2308 if std::arch::is_x86_feature_detected!("avx512vnni") {
2309 features.push("avx512-vnni");
2310 }
2311 }
2312 features
2313}
2314
2315fn run_doctor(
2316 args: &DoctorArgs,
2317 environment: &Environment,
2318 stdout: &mut dyn Write,
2319) -> Result<(), FttsError> {
2320 let report = json!({
2321 "schema_version": ROBOT_SCHEMA_VERSION,
2322 "status": "phase0_skeleton",
2323 "stateless_default": true,
2324 "persistent_history": false,
2325 "environment": environment.documented_values(),
2326 "recommended_command": "ftts robot schema",
2327 });
2328 if args.json {
2329 write_json_line(stdout, &report)
2330 } else {
2331 writeln!(stdout, "FrankenTTS Phase-0 CLI skeleton")
2332 .and_then(|_| writeln!(stdout, "stateless default: yes"))
2333 .and_then(|_| writeln!(stdout, "model loaded: no"))
2334 .and_then(|_| writeln!(stdout, "next: ftts robot schema"))
2335 .map_err(output_error)
2336 }
2337}
2338
2339fn write_json_line(writer: &mut dyn Write, value: &Value) -> Result<(), FttsError> {
2340 serde_json::to_writer(&mut *writer, value)
2341 .map_err(|error| FttsError::Generic(format!("cannot serialize CLI JSON: {error}")))?;
2342 writer.write_all(b"\n").map_err(output_error)
2343}
2344
2345fn output_error(error: io::Error) -> FttsError {
2346 FttsError::Generic(format!("cannot write CLI output: {error}"))
2347}
2348
2349#[cfg(test)]
2350mod tests {
2351 use super::*;
2352 use std::io::Cursor;
2353
2354 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\npull=model,force\nglobal=profile,packet-frames,math-mode,voice-pack,normalize,trace,seed\n";
2355
2356 #[test]
2357 fn clap_surface_matches_snapshot() {
2358 let command = Cli::command();
2359 let commands = command
2360 .get_subcommands()
2361 .map(|command| command.get_name())
2362 .collect::<Vec<_>>()
2363 .join(",");
2364 let robot = command
2365 .get_subcommands()
2366 .find(|command| command.get_name() == "robot")
2367 .expect("robot subcommand")
2368 .get_subcommands()
2369 .map(|command| command.get_name())
2370 .collect::<Vec<_>>()
2371 .join(",");
2372 let say = command
2373 .get_subcommands()
2374 .find(|command| command.get_name() == "say")
2375 .expect("say subcommand")
2376 .get_arguments()
2377 .filter_map(|argument| argument.get_long())
2378 .collect::<Vec<_>>()
2379 .join(",");
2380 let pull = command
2381 .get_subcommands()
2382 .find(|command| command.get_name() == "pull")
2383 .expect("pull subcommand")
2384 .get_arguments()
2385 .filter_map(|argument| argument.get_long())
2386 .collect::<Vec<_>>()
2387 .join(",");
2388 let global = command
2389 .get_arguments()
2390 .filter_map(|argument| argument.get_long())
2391 .filter(|argument| *argument != "help")
2392 .collect::<Vec<_>>()
2393 .join(",");
2394 let actual = format!(
2395 "commands={commands}\nrobot={robot}\nsay={say}\npull={pull}\nglobal={global}\n"
2396 );
2397 assert_eq!(actual, CLAP_SURFACE_SNAPSHOT);
2398 }
2399
2400 #[test]
2401 fn argument_file_and_stdin_text_are_identical() {
2402 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../README.md");
2403 let expected = fs::read_to_string(&root).expect("checked-in README");
2404 let from_argument = read_text(
2405 &SayArgs {
2406 output_positional: None,
2407 text: Some(expected.clone()),
2408 file: None,
2409 model: None,
2410 voice: None,
2411 output: None,
2412 stream: None,
2413 check: true,
2414 },
2415 &mut Cursor::new(Vec::<u8>::new()),
2416 )
2417 .expect("argument text");
2418 let from_file = read_text(
2419 &SayArgs {
2420 output_positional: None,
2421 text: None,
2422 file: Some(root),
2423 model: None,
2424 voice: None,
2425 output: None,
2426 stream: None,
2427 check: true,
2428 },
2429 &mut Cursor::new(Vec::<u8>::new()),
2430 )
2431 .expect("file text");
2432 let from_stdin = read_text(
2433 &SayArgs {
2434 output_positional: None,
2435 text: Some("-".to_owned()),
2436 file: None,
2437 model: None,
2438 voice: None,
2439 output: None,
2440 stream: None,
2441 check: true,
2442 },
2443 &mut Cursor::new(expected.as_bytes()),
2444 )
2445 .expect("stdin text");
2446
2447 assert_eq!(from_argument, from_file);
2448 assert_eq!(from_argument, from_stdin);
2449 }
2450
2451 #[test]
2452 fn check_plan_is_deterministic_and_marks_its_scope() {
2453 let settings = EffectiveSettings {
2454 profile: ExecutionProfile::Strict,
2455 packet_frames: PacketFrames::Four,
2456 math_mode: MathMode::Strict,
2457 voice_pack: VoicePackProfile::Portable,
2458 normalize: NormalizeMode::Conservative,
2459 };
2460 let first = admission_plan("hello", &settings).expect("admission plan");
2461 let second = admission_plan("hello", &settings).expect("admission plan");
2462 assert_eq!(first, second);
2463 assert_eq!(first["status"], "accepted");
2464 assert!(
2466 first["scope"]
2467 .as_str()
2468 .unwrap_or_default()
2469 .contains("ESTIMATED")
2470 );
2471 }
2472
2473 #[test]
2474 fn the_cli_preflight_and_the_engine_agree_on_the_same_request() {
2475 let settings = EffectiveSettings {
2479 profile: ExecutionProfile::Balanced,
2480 packet_frames: PacketFrames::Four,
2481 math_mode: MathMode::Strict,
2482 voice_pack: VoicePackProfile::Portable,
2483 normalize: NormalizeMode::Verbatim,
2484 };
2485 let text = "a moderately sized utterance for admission";
2486 let plan = admission_plan(text, &settings).expect("preflight admits");
2487
2488 let policy = ftts_core::process_engine_config().admission;
2489 let engine = policy
2490 .admit(text.chars().count() as u64)
2491 .expect("engine admits the same request");
2492
2493 assert_eq!(plan["predicted_peak_bytes"], engine.predicted_peak_bytes);
2494 assert_eq!(plan["predicted_max_frames"], engine.predicted_max_frames);
2495 assert_eq!(plan["budget_bytes"], engine.budget_bytes);
2496 assert_eq!(
2497 plan["binding_constraint"],
2498 engine.binding_constraint.as_str()
2499 );
2500 }
2501
2502 #[test]
2503 fn a_wav_sink_writes_a_playable_file_and_conforming_audio_chunk_events() {
2504 let dir = std::env::temp_dir().join(format!("ftts-wav-sink-{}", std::process::id()));
2505 std::fs::create_dir_all(&dir).expect("temp dir");
2506 let path = dir.join("out.wav");
2507
2508 let frame: Vec<f32> = (0..1_920)
2509 .map(|i| (i as f32 / 1_920.0 * std::f32::consts::TAU).sin() * 0.5)
2510 .collect();
2511 let mut sink = AudioOutput::wav(&path).expect("wav sink");
2512 let mut discard = Vec::new();
2513
2514 let first = sink
2515 .write_packet(&frame, &mut discard, "run-1", 1)
2516 .expect("packet 1");
2517 let second = sink
2518 .write_packet(&frame, &mut discard, "run-1", 1)
2519 .expect("packet 2");
2520
2521 assert!(robot::validate_event(&first).is_empty(), "{first:?}");
2523 assert!(robot::validate_event(&second).is_empty(), "{second:?}");
2524 assert_eq!(first["sink"], "file");
2525 assert_eq!(first["byte_offset"], 0);
2526 assert_eq!(first["bytes"], 1_920 * 2);
2527 assert_eq!(first["duration_ms"], 80, "1,920 samples at 24 kHz is 80 ms");
2528 assert_eq!(second["byte_offset"], 1_920 * 2);
2530
2531 let samples = sink.finish().expect("finish");
2532 assert_eq!(samples, 1_920 * 2);
2533
2534 let bytes = std::fs::read(&path).expect("read wav");
2536 assert_eq!(&bytes[0..4], b"RIFF");
2537 assert_eq!(&bytes[8..12], b"WAVE");
2538 let declared = u32::from_le_bytes(bytes[40..44].try_into().expect("data size"));
2539 assert_eq!(declared as usize, 1_920 * 2 * 2);
2540 assert_eq!(bytes.len(), 44 + 1_920 * 2 * 2);
2541 assert!(
2542 discard.is_empty(),
2543 "a file sink must not also emit raw PCM to the stream"
2544 );
2545 }
2546
2547 #[test]
2548 fn a_raw_sink_writes_pcm_to_the_stream_and_never_mixes_it_with_events() {
2549 let mut sink = AudioOutput::raw();
2552 let mut raw = Vec::new();
2553 let pcm = vec![0.5f32; 4];
2554 let event = sink
2555 .write_packet(&pcm, &mut raw, "run-1", 1)
2556 .expect("packet");
2557
2558 assert!(robot::validate_event(&event).is_empty(), "{event:?}");
2559 assert_eq!(event["sink"], "stdout");
2560 assert_eq!(raw.len(), 8, "four 16-bit samples");
2561 let first = i16::from_le_bytes([raw[0], raw[1]]);
2562 assert_eq!(first, ftts_core::audio::sample_to_i16(0.5));
2563 assert!(
2565 !raw.windows(2).any(|w| w == b"{\""),
2566 "raw PCM stream must never contain an event object"
2567 );
2568 }
2569
2570 #[test]
2571 fn a_none_sink_still_reports_conforming_events() {
2572 let mut sink = AudioOutput::none();
2573 let mut discard = Vec::new();
2574 let event = sink
2575 .write_packet(&[0.0f32; 960], &mut discard, "run-1", 2)
2576 .expect("packet");
2577 assert!(robot::validate_event(&event).is_empty(), "{event:?}");
2578 assert_eq!(event["sink"], "none");
2579 assert_eq!(event["packet_frames"], "2");
2580 assert!(discard.is_empty());
2581 assert_eq!(sink.finish().expect("finish"), 960);
2582 }
2583
2584 #[test]
2585 fn a_health_violation_renders_as_a_contract_conforming_robot_event() {
2586 let silent =
2589 ftts_core::HealthEvent::Violation(ftts_core::health::HealthViolation::OutputSilent {
2590 silent_millis: 1_500,
2591 });
2592 let event = robot::health_violation_event("run-1", silent, 42);
2593 assert!(
2594 robot::validate_event(&event).is_empty(),
2595 "{:?}",
2596 robot::validate_event(&event)
2597 );
2598 assert_eq!(event["event"], "health_violation");
2599 assert_eq!(event["violation"], "output_silent");
2600 assert_eq!(event["invalidates_output"], true);
2601 assert!(event["detail"].as_str().expect("detail").contains("1500"));
2602 assert!(event["remedy"].as_str().expect("remedy").len() > 40);
2603
2604 let demoted =
2607 ftts_core::HealthEvent::Violation(ftts_core::health::HealthViolation::KernelDemoted {
2608 from: ftts_core::health::KernelTier::Optimized("i8mm"),
2609 to: ftts_core::health::KernelTier::Scalar,
2610 });
2611 let event = robot::health_violation_event("run-1", demoted, 43);
2612 assert!(robot::validate_event(&event).is_empty());
2613 assert_eq!(event["invalidates_output"], false);
2614
2615 for event in [
2617 ftts_core::HealthEvent::BudgetExceeded,
2618 ftts_core::HealthEvent::Cancelled,
2619 ] {
2620 let rendered = robot::health_violation_event("run-1", event, 44);
2621 assert!(robot::validate_event(&rendered).is_empty());
2622 assert_eq!(rendered["invalidates_output"], true);
2623 }
2624 }
2625
2626 #[test]
2627 fn normalization_defaults_to_verbatim_conformance_mode() {
2628 let cli = Cli {
2629 profile: None,
2630 packet_frames: None,
2631 math_mode: None,
2632 voice_pack: None,
2633 normalize: None,
2634 trace: None,
2635 seed: None,
2636 command: Command::Robot(RobotArgs {
2637 command: RobotCommand::Health,
2638 }),
2639 };
2640 assert_eq!(
2641 EffectiveSettings::resolve(&cli, &Environment::default())
2642 .expect("default settings")
2643 .normalize,
2644 NormalizeMode::Verbatim
2645 );
2646 assert_eq!(
2647 EffectiveSettings::resolve(&cli, &Environment::default())
2648 .expect("default settings")
2649 .normalization_options(),
2650 NormalizationOptions::default(),
2651 "CLI defaults must use the same verbatim options as the library"
2652 );
2653 }
2654
2655 #[test]
2656 fn cli_normalization_modes_map_to_shared_engine_options() {
2657 for (cli_mode, engine_mode) in [
2658 (NormalizeMode::Verbatim, NormalizationMode::Verbatim),
2659 (NormalizeMode::Conservative, NormalizationMode::Conservative),
2660 (NormalizeMode::LocaleAware, NormalizationMode::LocaleAware),
2661 ] {
2662 let settings = EffectiveSettings {
2663 profile: ExecutionProfile::Balanced,
2664 packet_frames: PacketFrames::Four,
2665 math_mode: MathMode::Strict,
2666 voice_pack: VoicePackProfile::Portable,
2667 normalize: cli_mode,
2668 };
2669 assert_eq!(settings.normalization_options().mode, engine_mode);
2670 }
2671 }
2672
2673 #[test]
2674 fn pinned_main_conversion_plan_preserves_the_reviewed_q8_boundary() {
2675 let specs = pinned_main_tensor_specs().expect("checked-in main inventory parses");
2676 let (_manifest, _plan) =
2677 pinned_main_conversion_plan().expect("checked-in main conversion plan builds");
2678 assert_eq!(specs.len(), PINNED_MAIN_TENSOR_COUNT);
2679 assert_eq!(
2680 specs
2681 .iter()
2682 .filter(|spec| spec.storage == TensorStoragePolicy::Q8PerOutputChannel)
2683 .count(),
2684 231,
2685 "28 talker + 5 microdecoder layers times seven attention/MLP projections"
2686 );
2687
2688 let text_embedding = specs
2689 .iter()
2690 .find(|spec| spec.name == "talker.model.text_embedding.weight");
2691 assert!(
2692 text_embedding.is_some(),
2693 "pinned inventory must contain the text embedding"
2694 );
2695 if let Some(text_embedding) = text_embedding {
2696 assert_eq!(text_embedding.storage, TensorStoragePolicy::Verbatim);
2697 assert_eq!(text_embedding.access_class, AccessClass::ColdTextEmbedding);
2698 }
2699
2700 let talker_projection = specs
2701 .iter()
2702 .find(|spec| spec.name == "talker.model.layers.0.mlp.down_proj.weight");
2703 assert!(
2704 talker_projection.is_some(),
2705 "pinned inventory must contain the talker projection"
2706 );
2707 if let Some(talker_projection) = talker_projection {
2708 assert_eq!(
2709 talker_projection.storage,
2710 TensorStoragePolicy::Q8PerOutputChannel
2711 );
2712 assert_eq!(
2713 talker_projection.access_class,
2714 AccessClass::HotRecurrentTalker
2715 );
2716 }
2717
2718 let micro_projection = specs
2719 .iter()
2720 .find(|spec| spec.name == "talker.code_predictor.model.layers.0.mlp.down_proj.weight");
2721 assert!(
2722 micro_projection.is_some(),
2723 "pinned inventory must contain the microdecoder projection"
2724 );
2725 if let Some(micro_projection) = micro_projection {
2726 assert_eq!(
2727 micro_projection.storage,
2728 TensorStoragePolicy::Q8PerOutputChannel
2729 );
2730 assert_eq!(
2731 micro_projection.access_class,
2732 AccessClass::HotRecurrentMicrodecoder
2733 );
2734 }
2735
2736 let primary_embedding = specs
2737 .iter()
2738 .find(|spec| spec.name == "talker.model.codec_embedding.weight");
2739 assert!(
2740 primary_embedding.is_some(),
2741 "pinned inventory must contain the primary-code embedding"
2742 );
2743 if let Some(primary_embedding) = primary_embedding {
2744 assert_eq!(
2745 primary_embedding.access_class,
2746 AccessClass::HotRecurrentMicrodecoder,
2747 "the primary-code embedding feeds residual depth one every frame"
2748 );
2749 }
2750
2751 let primary_head = specs
2752 .iter()
2753 .find(|spec| spec.name == "talker.codec_head.weight");
2754 assert!(
2755 primary_head.is_some(),
2756 "pinned inventory must contain the primary-code head"
2757 );
2758 if let Some(primary_head) = primary_head {
2759 assert_eq!(primary_head.storage, TensorStoragePolicy::Verbatim);
2760 assert_eq!(primary_head.access_class, AccessClass::HotRecurrentTalker);
2761 }
2762
2763 let text_projection = specs
2764 .iter()
2765 .find(|spec| spec.name == "talker.text_projection.linear_fc1.weight");
2766 assert!(
2767 text_projection.is_some(),
2768 "pinned inventory must contain the text-projection MLP"
2769 );
2770 if let Some(text_projection) = text_projection {
2771 assert_eq!(text_projection.storage, TensorStoragePolicy::Verbatim);
2772 assert_eq!(
2773 text_projection.access_class,
2774 AccessClass::HotRecurrentTalker
2775 );
2776 }
2777
2778 let head = specs
2779 .iter()
2780 .find(|spec| spec.name == "talker.code_predictor.lm_head.0.weight");
2781 assert!(
2782 head.is_some(),
2783 "pinned inventory must contain the residual-code head"
2784 );
2785 if let Some(head) = head {
2786 assert_eq!(head.storage, TensorStoragePolicy::Verbatim);
2787 assert_eq!(head.access_class, AccessClass::HotRecurrentMicrodecoder);
2788 }
2789
2790 let speaker = specs
2791 .iter()
2792 .find(|spec| spec.name == "speaker_encoder.fc.weight");
2793 assert!(
2794 speaker.is_some(),
2795 "pinned inventory must contain the speaker encoder"
2796 );
2797 if let Some(speaker) = speaker {
2798 assert_eq!(speaker.storage, TensorStoragePolicy::Verbatim);
2799 assert_eq!(speaker.access_class, AccessClass::EnrollmentSpeakerEncoder);
2800 }
2801 }
2802
2803 #[test]
2804 fn conversion_notice_carries_changes_and_the_full_license() {
2805 let notice = pinned_license_notice();
2806 assert!(notice.contains("Copyright 2026 Alibaba Cloud"));
2807 assert!(notice.contains("CHANGES: the original bfloat16 weights were converted"));
2808 assert!(notice.contains("Apache License"));
2809 assert!(notice.contains("TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION"));
2810 }
2811
2812 #[test]
2813 fn convert_refusal_still_emits_a_versioned_robot_lifecycle() {
2814 let cli = Cli {
2815 profile: None,
2816 packet_frames: None,
2817 math_mode: None,
2818 voice_pack: None,
2819 normalize: None,
2820 trace: None,
2821 seed: None,
2822 command: Command::Robot(RobotArgs {
2823 command: RobotCommand::Health,
2824 }),
2825 };
2826 let args = ConvertArgs {
2827 source: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"),
2830 output: PathBuf::from("never-created.fttsq"),
2831 };
2832 let mut stdout = Vec::new();
2833 let mut stderr = Vec::new();
2834 let error = run_convert(
2835 &cli,
2836 &args,
2837 &Environment::default(),
2838 &mut stdout,
2839 &mut stderr,
2840 )
2841 .expect_err("the non-pinned source must be refused");
2842 assert_eq!(error.exit_code(), FttsExitCode::Input);
2843
2844 let stdout = String::from_utf8(stdout).expect("NDJSON stdout");
2845 let stderr = String::from_utf8(stderr).expect("NDJSON stderr");
2846 assert!(robot::validate_ndjson(&stdout).is_empty());
2847 assert!(robot::validate_ndjson(&stderr).is_empty());
2848 let stdout_events = stdout
2849 .lines()
2850 .map(|line| serde_json::from_str::<Value>(line).expect("JSON event"))
2851 .collect::<Vec<_>>();
2852 assert_eq!(stdout_events[0]["event"], "run_start");
2853 assert_eq!(stdout_events[1]["event"], "stage");
2854 assert_eq!(
2855 serde_json::from_str::<Value>(stderr.trim()).expect("run error")["event"],
2856 "run_error"
2857 );
2858 }
2859
2860 #[test]
2861 fn say_check_emits_a_versioned_admission_outcome() {
2862 let model = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
2863 let cli = Cli {
2864 profile: Some(ExecutionProfile::Balanced),
2865 packet_frames: Some(PacketFrames::Four),
2866 math_mode: Some(MathMode::Strict),
2867 voice_pack: Some(VoicePackProfile::Portable),
2868 normalize: Some(NormalizeMode::Conservative),
2869 trace: None,
2870 seed: Some(7),
2871 command: Command::Robot(RobotArgs {
2872 command: RobotCommand::Health,
2873 }),
2874 };
2875 let args = SayArgs {
2876 text: Some("checked text".to_owned()),
2877 output_positional: None,
2878 file: None,
2879 model: Some(model),
2880 voice: None,
2881 output: None,
2882 stream: None,
2883 check: true,
2884 };
2885 let mut stdin = Cursor::new(Vec::<u8>::new());
2886 let mut stdout = Vec::new();
2887 let mut stderr = Vec::new();
2888
2889 run_say(
2890 &cli,
2891 &args,
2892 &Environment::default(),
2893 &mut stdin,
2894 &mut stdout,
2895 &mut stderr,
2896 )
2897 .expect("check path");
2898
2899 assert!(stderr.is_empty());
2900 let text = String::from_utf8(stdout).expect("utf-8 events");
2901
2902 assert!(
2904 robot::validate_ndjson(&text).is_empty(),
2905 "emitted stream violates the contract: {:?}",
2906 robot::validate_ndjson(&text)
2907 );
2908
2909 let events: Vec<Value> = text
2910 .lines()
2911 .map(|line| serde_json::from_str(line).expect("one JSON object per line"))
2912 .collect();
2913 let names: Vec<&str> = events
2914 .iter()
2915 .map(|event| event["event"].as_str().expect("event name"))
2916 .collect();
2917 assert_eq!(
2918 names,
2919 vec![
2920 "run_start",
2921 "stage",
2922 "stage",
2923 "text_prepared",
2924 "stage",
2925 "stage",
2926 "check_complete",
2927 "run_complete",
2928 ],
2929 "the skeleton lifecycle must flow end-to-end on the empty pipeline"
2930 );
2931
2932 let run_id = events[0]["run_id"]
2935 .as_str()
2936 .expect("run_start carries run_id");
2937 assert!(!run_id.is_empty());
2938 assert!(events.iter().all(|event| event["run_id"] == run_id));
2939 assert!(
2940 events
2941 .iter()
2942 .all(|event| event["schema_version"] == ROBOT_SCHEMA_VERSION)
2943 );
2944
2945 let seqs: Vec<u64> = events
2947 .iter()
2948 .filter(|event| event["event"] == "stage")
2949 .map(|event| event["seq"].as_u64().expect("seq"))
2950 .collect();
2951 assert_eq!(seqs, vec![0, 1, 2, 3]);
2952
2953 let check = &events[6];
2954 assert_eq!(check["admission"]["status"], "accepted");
2958 assert!(
2959 check["admission"]["predicted_peak_bytes"].is_u64(),
2960 "the engine-backed plan reports a real predicted peak"
2961 );
2962 assert_eq!(check["normalization_trace_requested"], false);
2963
2964 let prepared = &events[3];
2966 assert_eq!(prepared["char_count"], "checked text".chars().count());
2967 assert!(prepared["unicode_version"].is_string());
2968 assert!(
2969 !text.contains("checked text"),
2970 "the event stream must not carry the user's text"
2971 );
2972
2973 assert_eq!(events[7]["exit_code"], 0);
2974 }
2975
2976 #[test]
2977 fn a_newline_inside_a_field_cannot_break_ndjson_framing() {
2978 let run = robot::RunContext::with_id("r-test");
2983 let error = FttsError::Generic("first\nsecond".to_owned());
2984 let mut event = run.event(robot::EventType::RunError);
2985 event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
2986 event.insert("kind".to_owned(), json!(error.exit_code().description()));
2987 event.insert("message".to_owned(), json!(error.to_string()));
2988 event.insert("remediation".to_owned(), json!(error.remediation()));
2989 event.insert("elapsed_ms".to_owned(), json!(0));
2990 let value = Value::Object(event);
2991
2992 let mut buffer = Vec::new();
2993 write_json_line(&mut buffer, &value).expect("serializes");
2994 let text = String::from_utf8(buffer).expect("utf-8");
2995
2996 assert_eq!(
2997 text.lines().count(),
2998 1,
2999 "framing broken by an embedded newline"
3000 );
3001 assert!(robot::validate_ndjson(&text).is_empty());
3002 let parsed: Value = serde_json::from_str(text.trim_end()).expect("still one object");
3003 assert!(
3004 parsed["message"].as_str().expect("message").contains('\n'),
3005 "the newline must survive as data, not be stripped"
3006 );
3007 }
3008
3009 #[test]
3010 fn pinned_copies_match_the_truth_pack_canonicals() {
3011 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
3015 for (canonical, embedded, name) in [
3016 (
3017 "docs/truth-pack/TENSOR_INVENTORY.json",
3018 PINNED_TENSOR_INVENTORY,
3019 "TENSOR_INVENTORY.json",
3020 ),
3021 (
3022 "docs/truth-pack/snapshots/hf/config.json",
3023 PINNED_MODEL_CONFIG,
3024 "model_config.json",
3025 ),
3026 (
3027 "docs/truth-pack/snapshots/gh/LICENSE",
3028 APACHE_LICENSE,
3029 "QWEN_APACHE_LICENSE",
3030 ),
3031 ] {
3032 match std::fs::read_to_string(root.join(canonical)) {
3033 Ok(bytes) => assert_eq!(
3034 bytes, embedded,
3035 "pinned/{name} drifted from {canonical}; re-copy it"
3036 ),
3037 Err(_) => eprintln!(
3038 "SKIP pinned-copy check for {name}: {canonical} absent (no repo checkout)"
3039 ),
3040 }
3041 }
3042 }
3043
3044 #[test]
3045 fn embedded_model_manifest_is_wellformed_and_agrees_with_the_converter_pin() {
3046 let manifest = ModelManifest::embedded().expect("embedded manifest parses");
3047 assert_eq!(manifest.model_id, "qwen3-tts-12hz-0.6b-base");
3048 assert_eq!(manifest.release_tag, "model-qwen3-tts-v1");
3049 assert_eq!(manifest.repo, "Dicklesworthstone/franken_tts");
3050 assert_eq!(manifest.files.len(), 7);
3051
3052 for file in &manifest.files {
3053 assert!(
3054 is_sha256_hex(&file.sha256),
3055 "{} carries a malformed digest",
3056 file.asset
3057 );
3058 assert!(file.bytes > 0, "{} has no pinned size", file.asset);
3059 let dest = Path::new(&file.dest);
3060 assert!(!dest.is_absolute(), "{} dest is absolute", file.asset);
3061 assert!(
3062 dest.components()
3063 .all(|component| matches!(component, std::path::Component::Normal(_))),
3064 "{} dest can traverse out of the model directory",
3065 file.asset
3066 );
3067 }
3068
3069 let main = manifest
3074 .files
3075 .iter()
3076 .find(|file| file.dest == MODEL_BASENAME)
3077 .expect("manifest carries the canonical artifact");
3078 assert_eq!(
3079 manifest.download_url(main),
3080 "https://github.com/Dicklesworthstone/franken_tts/releases/download/model-qwen3-tts-v1/qwen3-tts-12hz-0.6b-base.fttsq"
3081 );
3082 assert!(
3083 !manifest
3084 .files
3085 .iter()
3086 .any(|file| file.dest == PINNED_MAIN_WEIGHTS_FILENAME),
3087 "pull must not fetch the raw main checkpoint alongside the canonical artifact"
3088 );
3089
3090 let dests: Vec<&str> = manifest
3093 .files
3094 .iter()
3095 .map(|file| file.dest.as_str())
3096 .collect();
3097 for required in [
3098 MODEL_BASENAME,
3099 "speech_tokenizer/model.safetensors",
3100 "vocab.json",
3101 "merges.txt",
3102 "tokenizer_config.json",
3103 ] {
3104 assert!(dests.contains(&required), "manifest is missing {required}");
3105 }
3106 }
3107
3108 #[test]
3109 fn malformed_model_manifests_are_refused_with_the_field_named() {
3110 fn manifest_with(
3111 schema_version: u64,
3112 asset: &str,
3113 dest: &str,
3114 sha256: &str,
3115 bytes: u64,
3116 ) -> String {
3117 json!({
3118 "schema_version": schema_version,
3119 "model_id": "m",
3120 "release_tag": "t",
3121 "repo": "owner/repo",
3122 "files": [{"asset": asset, "dest": dest, "sha256": sha256, "bytes": bytes}],
3123 })
3124 .to_string()
3125 }
3126 let good_sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
3127
3128 ModelManifest::parse(&manifest_with(1, "a.bin", "a.bin", good_sha, 1))
3130 .expect("well-formed manifest parses");
3131
3132 for (label, text) in [
3133 (
3134 "unsupported schema_version",
3135 manifest_with(2, "a.bin", "a.bin", good_sha, 1),
3136 ),
3137 (
3138 "short sha256",
3139 manifest_with(1, "a.bin", "a.bin", "abc123", 1),
3140 ),
3141 (
3142 "uppercase sha256",
3143 manifest_with(1, "a.bin", "a.bin", &good_sha.to_uppercase(), 1),
3144 ),
3145 (
3146 "zero bytes",
3147 manifest_with(1, "a.bin", "a.bin", good_sha, 0),
3148 ),
3149 (
3150 "absolute dest",
3151 manifest_with(1, "a.bin", "/etc/passwd", good_sha, 1),
3152 ),
3153 (
3154 "traversal dest",
3155 manifest_with(1, "a.bin", "../escape.bin", good_sha, 1),
3156 ),
3157 (
3158 "asset with a path separator",
3159 manifest_with(1, "dir/a.bin", "a.bin", good_sha, 1),
3160 ),
3161 (
3162 "empty files array",
3163 json!({
3164 "schema_version": 1,
3165 "model_id": "m",
3166 "release_tag": "t",
3167 "repo": "owner/repo",
3168 "files": [],
3169 })
3170 .to_string(),
3171 ),
3172 ] {
3173 let error = ModelManifest::parse(&text)
3174 .expect_err(&format!("a manifest with {label} must be refused"));
3175 assert_eq!(error.exit_code(), FttsExitCode::ArtifactFormat, "{label}");
3176 }
3177 }
3178
3179 #[test]
3180 fn pull_skips_only_a_file_matching_both_pinned_size_and_digest() {
3181 let dir = std::env::temp_dir().join(format!("ftts-pull-decision-{}", std::process::id()));
3182 fs::create_dir_all(&dir).expect("temp dir");
3183 let payload = b"pinned payload";
3184 let file = ModelManifestFile {
3185 asset: "a.bin".to_owned(),
3186 dest: "a.bin".to_owned(),
3187 sha256: ftts_artifacts::sha256::hex_digest(payload),
3188 bytes: payload.len() as u64,
3189 };
3190 let dest = dir.join("a.bin");
3191
3192 let _ = fs::remove_file(&dest);
3193 assert_eq!(
3194 pull_decision(&dest, &file, false),
3195 PullDecision::Download,
3196 "absent file must download"
3197 );
3198
3199 fs::write(&dest, payload).expect("write verified payload");
3200 assert_eq!(
3201 pull_decision(&dest, &file, false),
3202 PullDecision::Skip,
3203 "matching size and digest must skip"
3204 );
3205 assert_eq!(
3206 pull_decision(&dest, &file, true),
3207 PullDecision::Download,
3208 "--force must re-download even a verified file"
3209 );
3210
3211 fs::write(&dest, b"pinned_payload").expect("write same-length corruption");
3212 assert_eq!(
3213 pull_decision(&dest, &file, false),
3214 PullDecision::Download,
3215 "a same-length corruption must be caught by the digest"
3216 );
3217
3218 fs::write(&dest, b"short").expect("write truncation");
3219 assert_eq!(
3220 pull_decision(&dest, &file, false),
3221 PullDecision::Download,
3222 "a truncated file must be caught by the size check"
3223 );
3224 }
3225
3226 #[test]
3227 fn model_resolution_prefers_explicit_then_searched_then_the_pull_directory() {
3228 let root = std::env::temp_dir().join(format!("ftts-resolve-order-{}", std::process::id()));
3229
3230 let bundle = root.join("bundle");
3233 for relative in [
3234 "model.safetensors",
3235 "speech_tokenizer/model.safetensors",
3236 "vocab.json",
3237 "merges.txt",
3238 "tokenizer_config.json",
3239 ] {
3240 let path = bundle.join(relative);
3241 fs::create_dir_all(path.parent().expect("bundle parent")).expect("bundle dirs");
3242 fs::write(&path, b"").expect("bundle file");
3243 }
3244 assert!(
3245 synth::ModelBundle::resolve(&bundle).is_ok(),
3246 "five empty files must satisfy the resolver's is_file checks"
3247 );
3248
3249 let searched_artifact = root.join("searched").join(MODEL_BASENAME);
3250 fs::create_dir_all(searched_artifact.parent().expect("searched parent"))
3251 .expect("searched dir");
3252 fs::write(&searched_artifact, b"").expect("searched artifact");
3253 let searched = vec![searched_artifact.clone()];
3254 let absent = vec![root.join("absent").join(MODEL_BASENAME)];
3255
3256 assert_eq!(
3258 resolve_model_from(Some(&bundle), &searched, Some(&bundle)).expect("explicit"),
3259 bundle.display().to_string()
3260 );
3261
3262 assert_eq!(
3264 resolve_model_from(None, &searched, Some(&bundle)).expect("searched"),
3265 searched_artifact.display().to_string()
3266 );
3267
3268 assert_eq!(
3270 resolve_model_from(None, &absent, Some(&bundle)).expect("pull fallback"),
3271 bundle.display().to_string()
3272 );
3273
3274 let incomplete = root.join("incomplete");
3276 fs::create_dir_all(&incomplete).expect("incomplete dir");
3277 let error = resolve_model_from(None, &absent, Some(&incomplete))
3278 .expect_err("an empty pull directory must not resolve");
3279 assert_eq!(error.exit_code(), FttsExitCode::ModelNotFound);
3280 assert!(error.to_string().contains("ftts pull"), "{error}");
3281 assert!(error.to_string().contains("2.0 GB"), "{error}");
3282 assert!(error.to_string().contains("FTTS_MODEL_DIR"), "{error}");
3283 }
3284
3285 #[test]
3286 fn the_pull_directory_default_prefers_the_env_override() {
3287 let mut environment = Environment::default();
3288 environment
3289 .values
3290 .insert("FTTS_MODEL_DIR", Some(OsString::from("/tmp/env-model-dir")));
3291 assert_eq!(
3292 default_pull_model_dir(&environment),
3293 Some(PathBuf::from("/tmp/env-model-dir"))
3294 );
3295
3296 if std::env::var_os("HOME").is_some() {
3299 let fallback = default_pull_model_dir(&Environment::default())
3300 .expect("HOME is set, so a default exists");
3301 assert!(
3302 fallback.ends_with(DEFAULT_MODEL_CACHE_SUBDIR),
3303 "{fallback:?}"
3304 );
3305 }
3306 }
3307}