1use crate::error::FttsError;
27use ftts_core::{
28 CancellationToken, EngineError, FrameGenerator, GenerationError, NormalizationOptions,
29 NormalizationTrace, PreparedText, SynthesisObserver, SynthesisRequest, TextPreparationError,
30 TextPreparer, TtsEngine,
31};
32use ftts_model_qwen::checkpoint::{
33 CODEC_LANGUAGE_ENGLISH_ID, CheckpointError, CodecCheckpoint, TALKER_HIDDEN, TalkerCheckpoint,
34};
35use ftts_model_qwen::generate::{QwenGenerator, QwenGeneratorConfig};
36use ftts_model_qwen::microdecoder::MicrodecoderConfig;
37use ftts_model_qwen::prompt::{CloneMode, PromptMode};
38use ftts_model_qwen::sampler::SamplingMode;
39use ftts_model_qwen::speaker::{
40 Encoder as SpeakerEncoder, SPEAKER_SAMPLE_RATE_HZ, log_mel_from_24khz_pcm,
41};
42use ftts_model_qwen::talker::TalkerConfig;
43use ftts_model_qwen::tokenizer::{QwenTokenizer, TokenizerFiles};
44use std::fs;
45use std::fs::OpenOptions;
46use std::path::{Path, PathBuf};
47use symphonia::core::audio::SampleBuffer;
48use symphonia::core::codecs::DecoderOptions;
49use symphonia::core::errors::Error as SymphoniaError;
50use symphonia::core::formats::FormatOptions;
51use symphonia::core::io::MediaSourceStream;
52use symphonia::core::meta::MetadataOptions;
53use symphonia::core::probe::Hint;
54use symphonia::default::{get_codecs, get_probe};
55
56pub const SPEAKER_VECTOR_BYTES: usize = TALKER_HIDDEN * 4;
58
59const CANONICAL_MODEL_BASENAME: &str = "qwen3-tts-12hz-0.6b-base.fttsq";
60
61fn checkpoint_error(error: CheckpointError) -> FttsError {
62 FttsError::ArtifactFormat(error.to_string())
63}
64
65#[derive(Clone, Debug)]
67pub struct ModelBundle {
68 pub root: PathBuf,
70 pub main: PathBuf,
73 pub canonical_main: Option<PathBuf>,
75 pub codec: PathBuf,
77}
78
79impl ModelBundle {
80 pub fn resolve(model: &Path) -> Result<Self, FttsError> {
88 let root = if model.is_dir() {
89 model.to_path_buf()
90 } else {
91 model
92 .parent()
93 .ok_or_else(|| {
94 FttsError::ModelNotFound(format!(
95 "model path {} has no parent directory",
96 model.display()
97 ))
98 })?
99 .to_path_buf()
100 };
101 let canonical_main = if model.is_dir() {
102 let canonical = root.join(CANONICAL_MODEL_BASENAME);
103 if canonical.is_file() {
104 Some(canonical)
105 } else {
106 None
107 }
108 } else if model.extension().and_then(|extension| extension.to_str()) == Some("fttsq") {
109 Some(model.to_path_buf())
110 } else {
111 None
112 };
113 let main = root.join("model.safetensors");
114 let codec = root.join("speech_tokenizer/model.safetensors");
115 let (main_label, main_path) = match canonical_main.as_ref() {
116 Some(path) => ("canonical talker artifact", path),
117 None => ("talker checkpoint", &main),
118 };
119 for (label, path) in [
120 (main_label, main_path),
121 ("codec checkpoint", &codec),
122 ("tokenizer vocabulary", &root.join("vocab.json")),
123 ("tokenizer merges", &root.join("merges.txt")),
124 ("tokenizer config", &root.join("tokenizer_config.json")),
125 ] {
126 if !path.is_file() {
127 return Err(FttsError::ModelNotFound(format!(
128 "{label} is missing at {}; `ftts say` needs a complete model directory \
129 ({CANONICAL_MODEL_BASENAME} or model.safetensors, \
130 speech_tokenizer/model.safetensors, \
131 vocab.json, merges.txt, tokenizer_config.json)",
132 path.display()
133 )));
134 }
135 }
136 Ok(Self {
137 root,
138 main,
139 canonical_main,
140 codec,
141 })
142 }
143}
144
145pub struct LoadedModel {
147 talker: TalkerCheckpoint,
148 codec: CodecCheckpoint,
149 tokenizer: QwenTokenizer,
150}
151
152impl LoadedModel {
153 pub fn load(bundle: &ModelBundle) -> Result<Self, FttsError> {
159 let read = |name: &str| -> Result<String, FttsError> {
160 let path = bundle.root.join(name);
161 fs::read_to_string(&path).map_err(|error| {
162 FttsError::ArtifactFormat(format!("cannot read {}: {error}", path.display()))
163 })
164 };
165 let vocab = read("vocab.json")?;
166 let merges = read("merges.txt")?;
167 let config = read("tokenizer_config.json")?;
168 let tokenizer = QwenTokenizer::from_files_using_environment(TokenizerFiles {
169 vocab_json: &vocab,
170 merges_txt: &merges,
171 tokenizer_config_json: &config,
172 })
173 .map_err(|error| FttsError::ArtifactFormat(format!("tokenizer unusable: {error}")))?;
174
175 Ok(Self {
176 talker: match bundle.canonical_main.as_deref() {
177 Some(path) => TalkerCheckpoint::load_fttsq(path).map_err(checkpoint_error)?,
178 None => TalkerCheckpoint::load(&bundle.main).map_err(checkpoint_error)?,
179 },
180 codec: CodecCheckpoint::load(&bundle.codec).map_err(checkpoint_error)?,
181 tokenizer,
182 })
183 }
184}
185
186pub fn read_speaker_vector(path: &Path) -> Result<Vec<f32>, FttsError> {
196 let bytes = fs::read(path).map_err(|error| {
197 FttsError::Input(format!(
198 "cannot read speaker vector {}: {error}",
199 path.display()
200 ))
201 })?;
202 if bytes.len() != SPEAKER_VECTOR_BYTES {
203 return Err(FttsError::Input(format!(
204 "speaker vector {} is {} bytes; `ftts say --voice` expects exactly {} \
205 ({TALKER_HIDDEN} little-endian f32)",
206 path.display(),
207 bytes.len(),
208 SPEAKER_VECTOR_BYTES
209 )));
210 }
211 let vector: Vec<f32> = bytes
212 .as_chunks::<4>()
213 .0
214 .iter()
215 .map(|quad| f32::from_le_bytes(*quad))
216 .collect();
217 if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
218 return Err(FttsError::Input(format!(
219 "speaker vector {} holds a non-finite value at index {index}; it would poison every \
220 prefill position it is summed into",
221 path.display()
222 )));
223 }
224 Ok(vector)
225}
226
227pub fn speaker_from_voice(bundle: &ModelBundle, path: &Path) -> Result<Vec<f32>, FttsError> {
229 let bytes = fs::read(path).map_err(|error| {
230 FttsError::Input(format!(
231 "cannot read voice source {}: {error}",
232 path.display()
233 ))
234 })?;
235 if bytes.len() == SPEAKER_VECTOR_BYTES {
236 return decode_speaker_vector(path, &bytes);
237 }
238 let pcm = decode_reference_audio_any(path)?;
239 let mel = log_mel_from_24khz_pcm(&pcm)
240 .map_err(|error| FttsError::Input(format!("cannot extract speaker features: {error}")))?;
241 let encoder = match bundle.canonical_main.as_deref() {
242 Some(artifact) => SpeakerEncoder::load_fttsq(artifact),
243 None => SpeakerEncoder::load(&bundle.main),
244 }
245 .map_err(checkpoint_error)?;
246 let vector = encoder.encode(&mel.values, mel.frames);
247 if vector.iter().all(|value| value.is_finite()) {
248 Ok(vector)
249 } else {
250 Err(FttsError::Input(
251 "speaker encoder produced a non-finite x-vector; refusing to condition synthesis"
252 .to_owned(),
253 ))
254 }
255}
256
257pub fn write_speaker_vector_new(path: &Path, vector: &[f32]) -> Result<(), FttsError> {
259 if vector.len() != TALKER_HIDDEN {
260 return Err(FttsError::Input(format!(
261 "cannot write {}-wide speaker vector; expected {TALKER_HIDDEN}",
262 vector.len()
263 )));
264 }
265 if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
266 return Err(FttsError::Input(format!(
267 "cannot write speaker vector with a non-finite value at index {index}"
268 )));
269 }
270 let mut bytes = Vec::with_capacity(SPEAKER_VECTOR_BYTES);
271 for value in vector {
272 bytes.extend_from_slice(&value.to_le_bytes());
273 }
274 use std::io::Write;
275 let mut file = OpenOptions::new()
276 .write(true)
277 .create_new(true)
278 .open(path)
279 .map_err(|error| {
280 FttsError::Input(format!(
281 "cannot create enrolled voice {} without overwriting an existing file: {error}",
282 path.display()
283 ))
284 })?;
285 file.write_all(&bytes).map_err(|error| {
286 FttsError::Input(format!(
287 "cannot write enrolled voice {}: {error}",
288 path.display()
289 ))
290 })
291}
292
293fn decode_speaker_vector(path: &Path, bytes: &[u8]) -> Result<Vec<f32>, FttsError> {
294 let vector: Vec<f32> = bytes
295 .as_chunks::<4>()
296 .0
297 .iter()
298 .map(|quad| f32::from_le_bytes(*quad))
299 .collect();
300 if let Some(index) = vector.iter().position(|value| !value.is_finite()) {
301 return Err(FttsError::Input(format!(
302 "speaker vector {} holds a non-finite value at index {index}; it would poison every \
303 prefill position it is summed into",
304 path.display()
305 )));
306 }
307 Ok(vector)
308}
309
310const SYSTEM_DECODED_EXTENSIONS: [&str; 6] = ["m4a", "mp3", "aac", "mp4", "ogg", "opus"];
314
315fn decode_reference_audio_any(path: &Path) -> Result<Vec<f32>, FttsError> {
321 let extension = path
322 .extension()
323 .and_then(|extension| extension.to_str())
324 .map(str::to_ascii_lowercase);
325 let needs_system_decoder = extension
326 .as_deref()
327 .is_some_and(|extension| SYSTEM_DECODED_EXTENSIONS.contains(&extension));
328 if !needs_system_decoder {
329 return decode_reference_audio(path);
330 }
331
332 let staging = std::env::temp_dir().join(format!(
333 "ftts-enroll-{}-{}.wav",
334 std::process::id(),
335 path.file_stem()
336 .and_then(|stem| stem.to_str())
337 .unwrap_or("reference")
338 ));
339 let attempts: &[(&str, Vec<&std::ffi::OsStr>)] = &[
340 (
345 "afconvert",
346 vec![
347 "-f".as_ref(),
348 "WAVE".as_ref(),
349 "-d".as_ref(),
350 "LEI16@24000".as_ref(),
351 "-c".as_ref(),
352 "1".as_ref(),
353 path.as_os_str(),
354 staging.as_os_str(),
355 ],
356 ),
357 (
358 "ffmpeg",
359 vec![
360 "-y".as_ref(),
361 "-loglevel".as_ref(),
362 "error".as_ref(),
363 "-i".as_ref(),
364 path.as_os_str(),
365 "-acodec".as_ref(),
366 "pcm_s16le".as_ref(),
367 "-ar".as_ref(),
368 "24000".as_ref(),
369 "-ac".as_ref(),
370 "1".as_ref(),
371 staging.as_os_str(),
372 ],
373 ),
374 ];
375 let mut ran = false;
376 for (tool, arguments) in attempts {
377 match std::process::Command::new(tool).args(arguments).status() {
378 Ok(status) if status.success() => {
379 ran = true;
380 break;
381 }
382 Ok(status) => {
383 let _ = fs::remove_file(&staging);
384 return Err(FttsError::Input(format!(
385 "{tool} failed decoding reference audio {} (exit {status})",
386 path.display()
387 )));
388 }
389 Err(_) => continue, }
391 }
392 if !ran {
393 return Err(FttsError::Input(format!(
394 "reference audio {} is a compressed container and no system decoder was found; \
395 install afconvert (macOS) or ffmpeg, or supply WAV/FLAC",
396 path.display()
397 )));
398 }
399 let decoded = decode_reference_audio(&staging);
400 let _ = fs::remove_file(&staging);
401 decoded
402}
403
404fn decode_reference_audio(path: &Path) -> Result<Vec<f32>, FttsError> {
405 let file = fs::File::open(path).map_err(|error| {
406 FttsError::Input(format!(
407 "cannot open reference audio {}: {error}",
408 path.display()
409 ))
410 })?;
411 let mut hint = Hint::new();
412 if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) {
413 hint.with_extension(extension);
414 }
415 let stream = MediaSourceStream::new(Box::new(file), Default::default());
416 let probed = get_probe()
417 .format(
418 &hint,
419 stream,
420 &FormatOptions::default(),
421 &MetadataOptions::default(),
422 )
423 .map_err(|error| {
424 FttsError::Input(format!(
425 "cannot identify reference audio {}: {error}",
426 path.display()
427 ))
428 })?;
429 let mut format = probed.format;
430 let track = format.default_track().ok_or_else(|| {
431 FttsError::Input(format!(
432 "reference audio {} has no default audio track",
433 path.display()
434 ))
435 })?;
436 let track_id = track.id;
437 let mut decoder = get_codecs()
438 .make(&track.codec_params, &DecoderOptions::default())
439 .map_err(|error| {
440 FttsError::Input(format!(
441 "cannot decode reference audio {}: {error}",
442 path.display()
443 ))
444 })?;
445 let mut sample_rate = None;
446 let mut mono = Vec::new();
447 loop {
448 let packet = match format.next_packet() {
449 Ok(packet) => packet,
450 Err(SymphoniaError::IoError(error))
451 if error.kind() == std::io::ErrorKind::UnexpectedEof =>
452 {
453 break;
454 }
455 Err(error) => {
456 return Err(FttsError::Input(format!(
457 "cannot read reference audio {}: {error}",
458 path.display()
459 )));
460 }
461 };
462 if packet.track_id() != track_id {
463 continue;
464 }
465 let decoded = decoder.decode(&packet).map_err(|error| {
466 FttsError::Input(format!(
467 "cannot decode reference audio {}: {error}",
468 path.display()
469 ))
470 })?;
471 let spec = *decoded.spec();
472 match sample_rate {
473 Some(rate) if rate != spec.rate => {
474 return Err(FttsError::Input(format!(
475 "reference audio {} changed sample rate mid-stream ({rate} to {} Hz)",
476 path.display(),
477 spec.rate
478 )));
479 }
480 None => sample_rate = Some(spec.rate),
481 Some(_) => {}
482 }
483 let channels = spec.channels.count();
484 let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
485 samples.copy_interleaved_ref(decoded);
486 for frame in samples.samples().chunks_exact(channels) {
487 mono.push(frame.iter().sum::<f32>() / channels as f32);
488 }
489 }
490 let rate = sample_rate.ok_or_else(|| {
491 FttsError::Input(format!(
492 "reference audio {} contains no decodable samples",
493 path.display()
494 ))
495 })?;
496 if rate != SPEAKER_SAMPLE_RATE_HZ {
497 return Err(FttsError::Input(format!(
498 "reference audio {} is {rate} Hz; the pinned speaker encoder requires {SPEAKER_SAMPLE_RATE_HZ} Hz",
499 path.display()
500 )));
501 }
502 if mono.is_empty() {
503 return Err(FttsError::Input(format!(
504 "reference audio {} contains no PCM samples",
505 path.display()
506 )));
507 }
508 Ok(mono)
509}
510
511struct PreparedPassThrough {
513 prepared: PreparedText,
514}
515
516impl TextPreparer for PreparedPassThrough {
517 fn prepare(
518 &self,
519 _text: &str,
520 _options: &NormalizationOptions,
521 ) -> Result<PreparedText, TextPreparationError> {
522 Ok(PreparedText::new(
523 self.prepared.token_ids.clone(),
524 NormalizationTrace {
525 mode: self.prepared.normalization_trace.mode,
526 unicode_version: self.prepared.normalization_trace.unicode_version.clone(),
527 changes: self.prepared.normalization_trace.changes.clone(),
528 },
529 ))
530 }
531}
532
533pub struct SynthesizedAudio {
535 pub frames: u64,
537 pub prepared_token_count: usize,
539 pub pcm: Vec<f32>,
541}
542
543#[allow(clippy::too_many_arguments)]
550pub fn synthesize(
551 model: &LoadedModel,
552 engine: &TtsEngine,
553 request: &SynthesisRequest,
554 speaker: &[f32],
555 seed: u64,
556 cancellation: &CancellationToken,
557 observer: &dyn SynthesisObserver,
558) -> Result<SynthesizedAudio, FttsError> {
559 let prepared_raw = model
561 .tokenizer
562 .prepare(&request.text, &request.normalization_options)
563 .map_err(|error| FttsError::Input(format!("text preparation failed: {error}")))?;
564 let wrapped = TalkerCheckpoint::wrap_target_ids(&prepared_raw.token_ids);
565 let prepared = PreparedText::new(wrapped.clone(), prepared_raw.normalization_trace);
566
567 let ids = TalkerCheckpoint::utterance_text_ids(&wrapped);
569 let table = model
570 .talker
571 .gather_text_rows(&ids)
572 .map_err(checkpoint_error)?;
573
574 let header = model
576 .talker
577 .xvector_header(&table, speaker, CODEC_LANGUAGE_ENGLISH_ID)
578 .map_err(checkpoint_error)?;
579 let tts_eos = model.talker.tts_eos(&table);
580
581 let talker_layers = model.talker.talker_layer_weights();
583 let micro_layers = model.talker.microdecoder_layer_weights();
584 let residual = model.talker.residual_embedding_slices();
585 let heads = model.talker.microdecoder_head_slices();
586 let micro_residual = &residual[..residual.len() - 1];
589
590 let mut generator = QwenGenerator::new(QwenGeneratorConfig {
591 talker_config: TalkerConfig::default(),
592 talker_weights: model.talker.talker_weights(&talker_layers),
593 text: model.talker.text_weights(&table),
594 feedback: model.talker.feedback_tables(&residual),
595 microdecoder_config: MicrodecoderConfig::default(),
596 microdecoder_weights: model.talker.microdecoder_weights(
597 µ_layers,
598 micro_residual,
599 &heads,
600 ),
601 prompt_mode: PromptMode {
602 clone_mode: CloneMode::XVector,
603 non_streaming_mode: false,
604 },
605 header,
606 tts_eos,
607 reference: None,
608 sampling_mode: SamplingMode::Production,
619 seed,
620 });
621
622 let preparer = PreparedPassThrough { prepared };
624 let result = engine
625 .synthesize(
626 request.clone(),
627 &preparer,
628 &mut generator as &mut dyn FrameGenerator,
629 cancellation,
630 observer,
631 )
632 .map_err(engine_error)?;
633
634 if result.code_frames.is_empty() {
635 return Err(FttsError::Generic(
636 "the talker stopped before emitting a frame; there is no audio to write. This is a \
637 model or prompt problem, not an output problem — check the speaker vector and the \
638 text"
639 .to_owned(),
640 ));
641 }
642
643 let frames = result.code_frames.len();
645 let mut codes = Vec::with_capacity(frames * 16);
646 for frame in &result.code_frames {
647 if frame.codes.len() != 16 {
648 return Err(FttsError::Generic(format!(
649 "generated frame carries {} codes, expected 16",
650 frame.codes.len()
651 )));
652 }
653 for code in &frame.codes {
654 codes.push(i32::try_from(*code).map_err(|_| {
655 FttsError::Generic(format!(
656 "generated code {code} does not fit the codec's i32"
657 ))
658 })?);
659 }
660 }
661 let pcm = model
662 .codec
663 .decode(&codes, frames)
664 .map_err(checkpoint_error)?;
665
666 Ok(SynthesizedAudio {
667 frames: result.generated_frames,
668 prepared_token_count: result.prepared_token_count,
669 pcm,
670 })
671}
672
673fn engine_error(error: EngineError) -> FttsError {
675 match error {
676 EngineError::BudgetExceeded(_) => FttsError::BudgetTimeout(error.to_string()),
677 EngineError::ResourceAdmission(_) => FttsError::BudgetTimeout(error.to_string()),
678 EngineError::TextPreparation(_) => FttsError::Input(error.to_string()),
679 other => FttsError::Generic(other.to_string()),
680 }
681}
682
683#[must_use]
685pub fn generation_error(message: &str) -> GenerationError {
686 GenerationError::new(message)
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn a_short_speaker_vector_is_refused_rather_than_padded() {
695 let dir = std::env::temp_dir().join("ftts-synth-tests");
696 fs::create_dir_all(&dir).expect("temp dir");
697 let path = dir.join("short.spk");
698 fs::write(&path, vec![0u8; 64]).expect("write");
699 let error = read_speaker_vector(&path).expect_err("a short vector must be refused");
700 let message = error.to_string();
701 assert!(message.contains("64 bytes"), "{message}");
702 assert!(message.contains("4096"), "{message}");
703 }
704
705 #[test]
706 fn a_non_finite_speaker_vector_is_refused() {
707 let dir = std::env::temp_dir().join("ftts-synth-tests");
708 fs::create_dir_all(&dir).expect("temp dir");
709 let path = dir.join("nan.spk");
710 let mut bytes = vec![0u8; SPEAKER_VECTOR_BYTES];
711 bytes[0..4].copy_from_slice(&f32::NAN.to_le_bytes());
712 fs::write(&path, &bytes).expect("write");
713 let error = read_speaker_vector(&path).expect_err("NaN must be refused");
714 assert!(error.to_string().contains("index 0"), "{error}");
715 }
716
717 #[test]
718 fn a_well_formed_speaker_vector_reads_back_exactly() {
719 let dir = std::env::temp_dir().join("ftts-synth-tests");
720 fs::create_dir_all(&dir).expect("temp dir");
721 let path = dir.join("good.spk");
722 let expected: Vec<f32> = (0..TALKER_HIDDEN).map(|i| i as f32 * 0.001).collect();
723 let mut bytes = Vec::with_capacity(SPEAKER_VECTOR_BYTES);
724 for value in &expected {
725 bytes.extend_from_slice(&value.to_le_bytes());
726 }
727 fs::write(&path, &bytes).expect("write");
728 assert_eq!(read_speaker_vector(&path).expect("read"), expected);
729 }
730
731 #[test]
732 fn enrollment_writer_refuses_overwrite_and_preserves_the_vector() {
733 let path = std::env::temp_dir().join(format!(
734 "ftts-enroll-{}-{}.spk",
735 std::process::id(),
736 std::time::SystemTime::now()
737 .duration_since(std::time::UNIX_EPOCH)
738 .expect("clock")
739 .as_nanos()
740 ));
741 let expected: Vec<f32> = (0..TALKER_HIDDEN)
742 .map(|index| index as f32 * 0.125)
743 .collect();
744 write_speaker_vector_new(&path, &expected).expect("initial enrollment write");
745 assert_eq!(
746 read_speaker_vector(&path).expect("read enrolled vector"),
747 expected
748 );
749 let error = write_speaker_vector_new(&path, &[0.0; TALKER_HIDDEN])
750 .expect_err("an enrollment must never replace an existing voice");
751 assert!(error.to_string().contains("without overwriting"), "{error}");
752 }
753
754 #[test]
755 fn wav_reference_decodes_to_mono_24khz_pcm() {
756 let path = std::env::temp_dir().join(format!(
757 "ftts-reference-{}-{}.wav",
758 std::process::id(),
759 std::time::SystemTime::now()
760 .duration_since(std::time::UNIX_EPOCH)
761 .expect("clock")
762 .as_nanos()
763 ));
764 let pcm: Vec<f32> = (0..1_920)
765 .map(|index| (index as f32 / 1_920.0 * std::f32::consts::TAU).sin() * 0.25)
766 .collect();
767 fs::write(
768 &path,
769 ftts_core::audio::encode_wav(&pcm, SPEAKER_SAMPLE_RATE_HZ),
770 )
771 .expect("write reference WAV");
772 let decoded = decode_reference_audio(&path).expect("decode reference WAV");
773 assert_eq!(decoded.len(), pcm.len());
774 assert!(decoded.iter().all(|sample| sample.is_finite()));
775 }
776
777 #[test]
778 fn a_bundle_names_the_file_that_is_actually_missing() {
779 let dir = std::env::temp_dir().join("ftts-bundle-tests-empty");
782 fs::create_dir_all(&dir).expect("temp dir");
783 let error = ModelBundle::resolve(&dir).expect_err("an empty directory is not a bundle");
784 assert!(error.to_string().contains("model.safetensors"), "{error}");
785 }
786
787 #[test]
788 fn a_complete_bundle_prefers_its_canonical_artifact_for_synthesis() {
789 let nonce = std::time::SystemTime::now()
790 .duration_since(std::time::UNIX_EPOCH)
791 .expect("clock after epoch")
792 .as_nanos();
793 let dir = std::env::temp_dir().join(format!(
794 "ftts-bundle-canonical-{}-{nonce}",
795 std::process::id()
796 ));
797 fs::create_dir_all(dir.join("speech_tokenizer")).expect("create bundle sidecar directory");
798 for name in [
799 CANONICAL_MODEL_BASENAME,
800 "speech_tokenizer/model.safetensors",
801 "vocab.json",
802 "merges.txt",
803 "tokenizer_config.json",
804 ] {
805 fs::write(dir.join(name), []).expect("write bundle fixture sidecar");
806 }
807
808 let expected_artifact = dir.join(CANONICAL_MODEL_BASENAME);
809 let bundle = ModelBundle::resolve(&dir).expect("complete canonical bundle resolves");
810 assert_eq!(
811 bundle.canonical_main.as_deref(),
812 Some(expected_artifact.as_path())
813 );
814 assert!(
815 !bundle.main.exists(),
816 "canonical synthesis must not require the raw main checkpoint"
817 );
818
819 let explicit = ModelBundle::resolve(&expected_artifact)
820 .expect("an explicit canonical artifact resolves against its sidecars");
821 assert_eq!(
822 explicit.canonical_main.as_deref(),
823 Some(expected_artifact.as_path())
824 );
825 }
826}