use super::*;
use core::num::NonZeroU32;
use asry::{
emissions::{
EmissionsFailure, EncoderOutput, EnglishNormalizer, OovEvent, OovKind, default_oov_policy,
wildcard_all_policy,
},
time::ANALYSIS_TIMEBASE,
};
use crate::audio::align::error::TokenizationError;
use crate::audio::align::acoustic::{
AcousticGeometry, Granularity, LetterCase, OutputKind, Tokenization, WordDelimiter,
};
fn contract(blank: u32, geometry: AcousticGeometry) -> AcousticContract {
AcousticContract::new(
blank,
geometry,
Tokenization::new(
WordDelimiter::Pipe,
LetterCase::Upper,
Granularity::Character,
&[],
),
OutputKind::LogProbabilities,
)
}
fn normalizer() -> DynTextNormalizer {
Box::new(EnglishNormalizer::new())
}
fn bundled_seam() -> EmissionsAligner {
build_seam(
Lang::En,
&Vocabulary::bundled(),
&AcousticContract::BASE960H,
normalizer(),
&AlignerOptions::new(),
)
.expect("bundled tokenizer + explicit blank id builds")
}
fn position(event: &OovEvent) -> (OovKind, usize, usize, Lang) {
(
event.kind().clone(),
event.char_index(),
event.word_index(),
event.language().clone(),
)
}
fn positions(events: &[OovEvent]) -> Vec<(OovKind, usize, usize, Lang)> {
events.iter().map(position).collect()
}
fn refusal_positions(
events: &[crate::audio::align::error::RefusedOov],
) -> Vec<(OovKind, usize, usize, Lang)> {
events
.iter()
.map(|event| {
(
event.kind().clone(),
event.char_index(),
event.word_index(),
event.language().clone(),
)
})
.collect()
}
fn clock() -> OutputClock {
OutputClock::new(0, ANALYSIS_TIMEBASE, 0).expect("clock")
}
#[test]
fn options_new_matches_documented_defaults() {
let o = AlignerOptions::new();
assert_eq!(o.min_speech_coverage(), DEFAULT_MIN_SPEECH_COVERAGE);
assert_eq!(o.min_speech_coverage(), 0.5);
assert_eq!(o.max_intra_silent_run(), DEFAULT_MAX_INTRA_SILENT_RUN);
assert_eq!(o.compute(), DEFAULT_ENCODER_COMPUTE);
assert_eq!(o.compute(), ComputeUnits::CpuOnly);
}
#[test]
fn options_compute_overrides() {
let o = AlignerOptions::new().with_compute(ComputeUnits::CpuAndGpu);
assert_eq!(o.compute(), ComputeUnits::CpuAndGpu);
let mut o = AlignerOptions::new();
o.set_compute(ComputeUnits::CpuAndNeuralEngine);
assert_eq!(o.compute(), ComputeUnits::CpuAndNeuralEngine);
}
#[test]
fn options_default_matches_new() {
assert_eq!(AlignerOptions::default(), AlignerOptions::new());
}
#[test]
fn options_with_builders_override() {
let o = AlignerOptions::new()
.with_min_speech_coverage(0.75)
.with_max_intra_silent_run(Duration::from_millis(120));
assert_eq!(o.min_speech_coverage(), 0.75);
assert_eq!(o.max_intra_silent_run(), Duration::from_millis(120));
}
#[test]
fn options_set_in_place() {
let mut o = AlignerOptions::new();
o.set_min_speech_coverage(0.25);
o.set_max_intra_silent_run(Duration::from_millis(40));
assert_eq!(o.min_speech_coverage(), 0.25);
assert_eq!(o.max_intra_silent_run(), Duration::from_millis(40));
}
#[test]
fn aligner_options_display_pins_the_composed_spelling() {
assert_eq!(
AlignerOptions::new().to_string(),
"min_speech_coverage=0.5,max_intra_silent_run=80ms,compute=cpu_only"
);
let built = AlignerOptions::new()
.with_min_speech_coverage(0.75)
.with_max_intra_silent_run(Duration::from_millis(120))
.with_compute(ComputeUnits::CpuAndGpu);
assert_eq!(
built.to_string(),
"min_speech_coverage=0.75,max_intra_silent_run=120ms,compute=cpu_and_gpu"
);
}
#[cfg(feature = "serde")]
#[test]
fn options_serde_missing_fields_default() {
let o: AlignerOptions = serde_json::from_str("{}").unwrap();
assert_eq!(o, AlignerOptions::new());
}
#[cfg(feature = "serde")]
#[test]
fn options_serde_partial_fills_defaults() {
let o: AlignerOptions = serde_json::from_str(r#"{"min_speech_coverage":0.7}"#).unwrap();
assert_eq!(o.min_speech_coverage(), 0.7);
assert_eq!(o.max_intra_silent_run(), DEFAULT_MAX_INTRA_SILENT_RUN);
assert_eq!(o.compute(), DEFAULT_ENCODER_COMPUTE);
}
#[cfg(feature = "serde")]
#[test]
fn options_serde_round_trips() {
let o = AlignerOptions::new()
.with_max_intra_silent_run(Duration::from_millis(120))
.with_compute(ComputeUnits::CpuAndGpu);
let json = serde_json::to_string(&o).unwrap();
assert!(json.contains("cpu_and_gpu"), "round-tripped json: {json}");
let back: AlignerOptions = serde_json::from_str(&json).unwrap();
assert_eq!(o, back);
}
#[test]
fn build_seam_wires_the_staged_blank_and_vocab_29() {
let seam = build_seam(
Lang::En,
&Vocabulary::bundled(),
&AcousticContract::BASE960H,
normalizer(),
&AlignerOptions::new(),
)
.expect("bundled tokenizer + explicit blank id builds");
assert_eq!(seam.blank_token_id(), AcousticContract::BASE960H.blank());
assert_eq!(seam.blank_token_id(), 0);
assert_eq!(
seam.vocab_size().get(),
crate::audio::align::vocab::VOCAB_SIZE
);
}
#[test]
fn build_seam_threads_options_into_the_seam() {
let options = AlignerOptions::new().with_max_intra_silent_run(Duration::from_millis(120));
let seam = build_seam(
Lang::En,
&Vocabulary::bundled(),
&AcousticContract::BASE960H,
normalizer(),
&options,
)
.expect("builds");
assert_eq!(seam.max_intra_silent_run(), options.max_intra_silent_run());
}
fn geometry(receptive_field: u32, stride: u32) -> AcousticGeometry {
AcousticGeometry::new(
16_000,
NonZeroU32::new(receptive_field).expect("nonzero"),
NonZeroU32::new(stride).expect("nonzero"),
)
.expect("a geometry asry's seam times")
}
#[test]
fn seam_stride_is_the_contract_stride() {
for (contract, stride) in [
(AcousticContract::BASE960H, 320),
(contract(0, geometry(640, 320)), 320),
(contract(0, geometry(480, 480)), 480),
] {
let seam = build_seam(
Lang::En,
&Vocabulary::bundled(),
&contract,
normalizer(),
&AlignerOptions::new(),
)
.expect("builds");
assert_eq!(seam.hop_samples(), contract.geometry().stride());
assert_eq!(
seam.hop_samples().get(),
stride,
"the seam's hop must equal the contract's stride (the stride that times the words, via T)"
);
}
}
#[test]
fn a_character_the_bundled_table_cannot_spell_is_an_oov_event() {
let detection = bundled_seam()
.detect_oov("Café AT&T b4d")
.expect("a character the vocabulary cannot spell is an event, never an error");
assert_eq!(
positions(detection.events()),
[
(OovKind::Symbol('é'), 3, 0, Lang::En),
(OovKind::Symbol('&'), 7, 1, Lang::En),
(OovKind::Symbol('4'), 11, 2, Lang::En),
]
);
}
#[test]
fn a_seam_reads_back_the_contracts_statements() {
let staged = bundled_seam();
assert_eq!(staged.word_delimiter(), "|");
assert_eq!(staged.letter_case(), asry::emissions::LetterCase::Upper);
assert_eq!(staged.receptive_field_samples().get(), 400);
assert_eq!(staged.hop_samples().get(), 320);
assert_eq!(staged.blank_token_id(), AcousticContract::BASE960H.blank());
let vocabulary = table(&["<pad>", "-", "a", "b", "你"]);
let contract = AcousticContract::new(
1,
geometry(640, 480),
Tokenization::new(
WordDelimiter::Absent,
LetterCase::AsWritten,
Granularity::Character,
&["<pad>"],
),
OutputKind::Logits,
);
assert_eq!(
check_tokenization(1, contract.tokenization(), &vocabulary, false),
Ok(()),
"the contract is one the table and a non-delimiting normalizer agree with"
);
let seam = build_seam(
Lang::Zh,
&vocabulary,
&contract,
Box::new(asry::emissions::ChineseNormalizer::new()),
&AlignerOptions::new(),
)
.expect("builds");
assert_eq!(
seam.word_delimiter(),
"",
"the contract states no delimiter"
);
assert_eq!(seam.letter_case(), asry::emissions::LetterCase::AsWritten);
assert_eq!(seam.receptive_field_samples().get(), 640);
assert_eq!(seam.hop_samples().get(), 480);
assert_eq!(seam.blank_token_id(), 1);
}
#[test]
fn asry_pads_a_short_chunk_to_the_contracts_receptive_field() {
let abort = AtomicBool::new(false);
for (contract, cases) in [
(
AcousticContract::BASE960H,
[(100usize, 400usize), (400, 400), (500, 500)],
),
(
contract(0, geometry(640, 320)),
[(100, 640), (640, 640), (700, 700)],
),
] {
let seam = build_seam(
Lang::En,
&Vocabulary::bundled(),
&contract,
normalizer(),
&AlignerOptions::new(),
)
.expect("builds");
for (real, padded) in cases {
let resolution = seam
.detect_oov("A")
.expect("detect_oov")
.decide(wildcard_all_policy);
let prepared = seam
.prepare(
&vec![0.1f32; real],
&SpeechSpans::all_speech(),
"A",
resolution,
clock(),
&abort,
)
.expect("prepare");
assert!(!prepared.is_trivial(), "`A` is alignable");
assert_eq!(
prepared.encoder_input().len(),
padded,
"{real} samples under {contract:?}"
);
assert_eq!(prepared.real_samples(), real);
}
}
}
fn aligned_words(
seam: &EmissionsAligner,
text: &str,
samples: usize,
frames: usize,
) -> Result<Vec<String>, EmissionsError> {
let abort = AtomicBool::new(false);
let resolution = seam.detect_oov(text)?.decide(wildcard_all_policy);
let prepared = seam.prepare(
&vec![0.1f32; samples],
&SpeechSpans::all_speech(),
text,
resolution,
clock(),
&abort,
)?;
let vocab = seam.vocab_size();
let uniform = -(vocab.get() as f32).ln();
let emissions = prepared.encode_with(|_| {
Ok::<_, EmissionsError>(EncoderOutput::LogProbs {
frames,
vocab,
data: vec![uniform; frames * vocab.get()],
})
})?;
Ok(
seam
.finish(prepared, emissions, &abort)?
.words()
.iter()
.map(|word| word.text().to_owned())
.collect(),
)
}
#[test]
fn the_staged_seam_spells_no_character_onto_a_reserved_column() {
let seam = bundled_seam();
let (second, short) = ((16_000, 49), (3_280, 10));
let pipe = seam.detect_oov("A|B").expect("detect_oov");
assert_eq!(
positions(pipe.events()),
[(OovKind::Symbol('|'), 1, 0, Lang::En)],
"the `|` inside a word is no delimiter"
);
assert_eq!(
aligned_words(&seam, "A|B", second.0, second.1).expect("aligns"),
["A|B"]
);
let hyphen = seam.detect_oov("well-known").expect("detect_oov");
assert!(hyphen.events().is_empty(), "{:?}", hyphen.events());
assert_eq!(
aligned_words(&seam, "well-known", short.0, short.1)
.expect("nine tokens and the blank between `ll` in ten frames"),
["well-known"]
);
let lexical_hyphen = build_seam(
Lang::En,
&table(&["<pad>", "|", "-", "W", "E", "L", "K", "N", "O", "A", "B"]),
&contract(0, AcousticGeometry::WAV2VEC2),
normalizer(),
&AlignerOptions::new(),
)
.expect("builds");
assert!(
matches!(
aligned_words(&lexical_hyphen, "well-known", short.0, short.1),
Err(EmissionsError::NoAlignmentPath(_))
),
"a `-` the table spells as an ordinary class is a tenth token"
);
assert_eq!(
aligned_words(&lexical_hyphen, "well-known", second.0, second.1).expect("aligns"),
["well-known"]
);
}
#[test]
fn a_space_delimited_table_builds_a_seam_split_at_the_space() {
let delimiter = WordDelimiter::from_token(" ").expect("the space is a word delimiter");
let vocabulary = table(&["<pad>", " ", "A", "B"]);
let contract = AcousticContract::new(
0,
AcousticGeometry::WAV2VEC2,
Tokenization::new(delimiter, LetterCase::Upper, Granularity::Character, &[]),
OutputKind::Logits,
);
assert_eq!(
check_tokenization(0, contract.tokenization(), &vocabulary, true),
Ok(())
);
let seam = build_seam(
Lang::En,
&vocabulary,
&contract,
normalizer(),
&AlignerOptions::new(),
)
.expect("builds");
assert_eq!(seam.word_delimiter(), " ");
let detection = seam.detect_oov("A B").expect("detect_oov");
assert!(detection.events().is_empty(), "{:?}", detection.events());
assert_eq!(
aligned_words(&seam, "A B", 1_040, 3).expect("three tokens in three frames"),
["A", "B"]
);
assert!(
matches!(
aligned_words(&seam, "A B", 720, 2),
Err(EmissionsError::NoAlignmentPath(_))
),
"the separator is a token of its own"
);
}
#[test]
fn a_declared_one_character_special_is_never_spelled() {
let vocabulary = table(&["<pad>", "|", "A", "B", "#"]);
let declared = AcousticContract::new(
0,
AcousticGeometry::WAV2VEC2,
Tokenization::new(
WordDelimiter::Pipe,
LetterCase::Upper,
Granularity::Character,
&["#"],
),
OutputKind::Logits,
);
let seam = |contract: &AcousticContract| {
build_seam(
Lang::En,
&vocabulary,
contract,
normalizer(),
&AlignerOptions::new(),
)
.expect("builds")
};
let special = seam(&declared).detect_oov("A#B").expect("detect_oov");
assert_eq!(
positions(special.events()),
[(OovKind::Symbol('#'), 1, 0, Lang::En)]
);
let ordinary = seam(&contract(0, AcousticGeometry::WAV2VEC2))
.detect_oov("A#B")
.expect("detect_oov");
assert!(
ordinary.events().is_empty(),
"an undeclared `#` the table spells is a token: {:?}",
ordinary.events()
);
}
fn contract_declaring(
delimiter: WordDelimiter,
specials: &'static [&'static str],
) -> AcousticContract {
AcousticContract::new(
0,
AcousticGeometry::WAV2VEC2,
Tokenization::new(
delimiter,
LetterCase::Upper,
Granularity::Character,
specials,
),
OutputKind::Logits,
)
}
fn reserved_and_declared(
language: Lang,
vocabulary: &Vocabulary,
contract: &AcousticContract,
normalizer: DynTextNormalizer,
) -> (BTreeSet<usize>, BTreeSet<usize>) {
assert_eq!(
check_tokenization(
contract.blank(),
contract.tokenization(),
vocabulary,
normalizer.use_word_delimiter()
),
Ok(()),
"the door accepts {contract:?}"
);
let seam = build_seam(
language,
vocabulary,
contract,
normalizer,
&AlignerOptions::new(),
)
.expect("builds");
let reserved =
seam_reserved(&seam, &vocabulary.tokenizer_json(contract)).expect("the document parses");
let declared = vocabulary.non_lexical(contract.blank(), contract.tokenization());
(reserved, declared)
}
#[test]
fn the_seam_reserves_exactly_the_contracts_non_lexical_set() {
let english = || -> DynTextNormalizer { normalizer() };
let unsegmented = || -> DynTextNormalizer { Box::new(asry::emissions::ChineseNormalizer::new()) };
let cases = [
(
Lang::En,
Vocabulary::bundled(),
AcousticContract::BASE960H,
english(),
BTreeSet::from([0, 1]),
),
(
Lang::En,
table(&["<pad>", "|", "A", "B", "#"]),
contract_declaring(WordDelimiter::Pipe, &["#"]),
english(),
BTreeSet::from([0, 1, 4]),
),
(
Lang::En,
table(&["", "|", "A", "B"]),
contract_declaring(WordDelimiter::Pipe, &[""]),
english(),
BTreeSet::from([0, 1]),
),
(
Lang::Zh,
table(&["<pad>", "A", "B", ""]),
contract_declaring(WordDelimiter::Absent, &[""]),
unsegmented(),
BTreeSet::from([0, 3]),
),
];
for (language, vocabulary, contract, normalizer, expected) in cases {
let (reserved, declared) = reserved_and_declared(language, &vocabulary, &contract, normalizer);
assert_eq!(declared, expected, "{contract:?}");
assert_eq!(reserved, declared, "{contract:?}");
}
}
#[test]
fn a_seam_that_reserves_other_columns_than_declared_is_refused_by_name() {
let refusal = |vocabulary: &Vocabulary, contract: &AcousticContract| match build_seam(
Lang::En,
vocabulary,
contract,
normalizer(),
&AlignerOptions::new(),
) {
Err(AlignerError::ReservedSetMismatch(mismatch)) => {
(mismatch.declared().to_vec(), mismatch.reserved().to_vec())
}
Err(other) => panic!("{contract:?}: refused for another reason: {other}"),
Ok(_) => panic!("{contract:?}: the seam was built"),
};
assert_eq!(
refusal(
&table(&["<pad>", "|", "A", "B", ""]),
&contract_declaring(WordDelimiter::Pipe, &[""])
),
(vec![0, 1, 4], vec![0, 1]),
"the declared empty special is dropped by the parse"
);
let hf = Vocabulary::from_json(HF_BASE960H_TABLE).expect("the table reads");
assert_eq!(
refusal(&hf, &contract_declaring(WordDelimiter::Pipe, &[])),
(vec![0, 4], vec![0, 3, 4]),
"the declared unknown token is reserved whatever the contract calls it"
);
}
#[test]
fn the_door_refuses_an_empty_named_special_before_the_model_loads() {
let refused = Aligner::from_paths_with_vocabulary(
Lang::En,
Path::new("/nonexistent/model.mlmodelc"),
&table(&["<pad>", "|", "A", "B", ""]),
&contract_declaring(WordDelimiter::Pipe, &[""]),
normalizer(),
AlignerOptions::new(),
)
.err()
.expect("refused");
assert_eq!(
refused,
AlignerError::Tokenization(TokenizationError::EmptySpecial(4))
);
}
#[test]
fn bundled_tokenizer_has_no_autodetectable_blank() {
let result =
EmissionsAligner::builder(Lang::En, crate::audio::align::vocab::tokenizer_json_bytes())
.normalizer(normalizer())
.build();
assert!(
matches!(result, Err(EmissionsError::Config(_))),
"auto-detect must fail without an explicit blank id"
);
}
#[test]
fn effective_options_reports_the_seams_clamped_coverage_not_the_requested_value() {
for (requested, effective) in [(2.0_f32, 1.0_f32), (-0.25, 0.0)] {
let options = AlignerOptions::new().with_min_speech_coverage(requested);
let seam = build_seam(
Lang::En,
&Vocabulary::bundled(),
&AcousticContract::BASE960H,
normalizer(),
&options,
)
.expect("builds");
let eff = effective_options(&seam, &options);
assert_eq!(
eff.min_speech_coverage(),
effective,
"requested {requested} must report as {effective}"
);
assert_eq!(eff.min_speech_coverage(), seam.min_speech_coverage().get());
}
let options = AlignerOptions::new().with_min_speech_coverage(f32::NAN);
let seam = build_seam(
Lang::En,
&Vocabulary::bundled(),
&AcousticContract::BASE960H,
normalizer(),
&options,
)
.expect("builds");
let eff = effective_options(&seam, &options);
assert!(
!eff.min_speech_coverage().is_nan(),
"NaN must not survive into effective options"
);
assert_eq!(eff.min_speech_coverage(), DEFAULT_MIN_SPEECH_COVERAGE);
assert_eq!(eff.min_speech_coverage(), seam.min_speech_coverage().get());
}
#[test]
fn effective_options_passes_through_the_uncoerced_fields() {
let options = AlignerOptions::new()
.with_max_intra_silent_run(Duration::from_millis(120))
.with_compute(ComputeUnits::CpuAndGpu)
.with_min_speech_coverage(2.0);
let seam = build_seam(
Lang::En,
&Vocabulary::bundled(),
&AcousticContract::BASE960H,
normalizer(),
&options,
)
.expect("builds");
let eff = effective_options(&seam, &options);
assert_eq!(eff.max_intra_silent_run(), Duration::from_millis(120));
assert_eq!(eff.compute(), ComputeUnits::CpuAndGpu);
assert_eq!(eff.min_speech_coverage(), 1.0); }
#[test]
#[ignore = "requires local alignkit models (ALIGNKIT_TEST_MODELS)"]
fn aligner_options_reports_effective_coverage_after_construction() {
let aligner = Aligner::from_paths_with(
Lang::En,
&models_dir().join("base960h_aligner.mlmodelc"),
normalizer(),
AlignerOptions::new().with_min_speech_coverage(2.0),
)
.expect("load base960h_aligner.mlmodelc (set ALIGNKIT_TEST_MODELS)");
assert_eq!(
aligner.options().min_speech_coverage(),
1.0,
"options() must report the seam's clamped coverage, not the requested 2.0"
);
}
fn models_dir() -> std::path::PathBuf {
std::env::var_os("ALIGNKIT_TEST_MODELS").map_or_else(
|| crate::tests::models_root().join("alignkit"),
std::path::PathBuf::from,
)
}
fn failure(message: &str) -> EmissionsFailure {
EmissionsFailure::new(message.into())
}
fn decided(text: &str, policy: impl FnMut(&OovEvent) -> OovDecision) -> OovResolution {
bundled_seam()
.detect_oov(text)
.expect("detect_oov")
.decide(policy)
}
#[test]
fn seam_error_names_a_refusal_by_every_refused_position() {
let resolution = decided("Café AT&T b4d", |event| match event.char() {
Some('é') => OovDecision::Wildcard,
_ => OovDecision::FailClosed,
});
let err = seam_error(
EmissionsError::SemanticOutOfVocab(failure("OOV '&' resolved as FailClosed")),
&refused_positions(&resolution),
);
let AlignError::Refused(refusal) = err else {
panic!("a fail-closed refusal must be AlignError::Refused, got {err:?}");
};
assert_eq!(
refusal_positions(refusal.events()),
[
(OovKind::Symbol('&'), 7, 1, Lang::En),
(OovKind::Symbol('4'), 11, 2, Lang::En),
]
);
assert_eq!(refusal.to_string(), "'&' (word 1), '4' (word 2)");
}
#[test]
fn seam_error_names_a_chunk_with_no_alignment_path() {
let resolution = decided("AT&T", |_| OovDecision::FailClosed);
let refused = refused_positions(&resolution);
assert_eq!(refused.len(), 1, "the `&` is refused");
let err = seam_error(
EmissionsError::NoAlignmentPath(failure("no finite path")),
&refused,
);
let AlignError::NoAlignmentPath(diagnostic) = err else {
panic!("a lattice with no path must be AlignError::NoAlignmentPath, got {err:?}");
};
assert_eq!(diagnostic.message(), "no finite path");
}
#[test]
fn seam_error_never_names_a_refusal_with_no_refused_position() {
let resolution = decided("b4d", wildcard_all_policy);
assert_eq!(resolution.resolved().len(), 1, "the `4` is decided");
let refused = refused_positions(&resolution);
assert!(refused.is_empty(), "a wildcard refuses nothing");
assert!(matches!(
seam_error(
EmissionsError::SemanticOutOfVocab(failure("fail-closed OOV")),
&refused
),
AlignError::Alignment(EmissionsError::SemanticOutOfVocab(_))
));
}
#[test]
fn seam_error_passes_every_other_failure_through() {
assert!(matches!(
seam_error(EmissionsError::Config(failure("blank id >= V")), &[]),
AlignError::Alignment(EmissionsError::Config(_))
));
assert!(matches!(
seam_error(EmissionsError::Aborted(failure("aborted")), &[]),
AlignError::Alignment(EmissionsError::Aborted(_))
));
assert!(matches!(
seam_error(
EmissionsError::Tokenization(failure("stale decisions")),
&[]
),
AlignError::Alignment(EmissionsError::Tokenization(_))
));
}
#[test]
fn a_fail_closed_decision_is_a_named_refusal_through_the_seam() {
let seam = bundled_seam();
let text = "Café AT&T b4d";
let samples = vec![0.0f32; 16_000];
let abort = AtomicBool::new(false);
let resolution = seam
.detect_oov(text)
.expect("detect_oov")
.decide(default_oov_policy);
let refused = refused_positions(&resolution);
let err = seam
.prepare(
&samples,
&SpeechSpans::all_speech(),
text,
resolution,
clock(),
&abort,
)
.err()
.map(|err| seam_error(err, &refused))
.expect("the default policy fails closed on `&`");
let AlignError::Refused(refusal) = err else {
panic!("expected the named refusal, got {err:?}");
};
assert_eq!(
refusal_positions(refusal.events()),
[(OovKind::Symbol('&'), 7, 1, Lang::En)]
);
let wildcards = seam
.detect_oov(text)
.expect("detect_oov")
.decide(wildcard_all_policy);
let prepared = seam
.prepare(
&samples,
&SpeechSpans::all_speech(),
text,
wildcards,
clock(),
&abort,
)
.expect("a character the policy wildcards is aligned around, not refused");
assert!(!prepared.is_trivial());
}
#[test]
fn an_empty_chunk_with_tokens_has_no_alignment_path() {
let seam = bundled_seam();
let abort = AtomicBool::new(false);
let prepare = |text: &'static str| {
let resolution = seam
.detect_oov(text)
.expect("detect_oov")
.decide(wildcard_all_policy);
seam
.prepare(
&[],
&SpeechSpans::all_speech(),
text,
resolution,
clock(),
&abort,
)
.expect("prepare an empty chunk")
};
let prepared = prepare("test");
assert!(!prepared.is_trivial());
assert_eq!(prepared.real_samples(), 0);
assert!(matches!(
check_audio(&prepared),
Err(AlignError::NoAlignmentPath(_))
));
assert!(check_audio(&prepare(" ... ")).is_ok(), "nothing to align");
}
fn width(width: usize) -> NonZeroUsize {
NonZeroUsize::new(width).expect("nonzero")
}
#[test]
fn check_vocabulary_width_refuses_a_table_of_another_width_by_name() {
assert_eq!(check_vocabulary_width(width(29), width(29)), Ok(()));
for (vocabulary, model) in [(30, 29), (28, 29), (29, 32)] {
let Err(AlignerError::VocabularyMismatch(mismatch)) =
check_vocabulary_width(width(vocabulary), width(model))
else {
panic!("a {vocabulary}-entry table on a {model}-class head must be refused by name");
};
assert_eq!(
(mismatch.vocabulary(), mismatch.model()),
(vocabulary, model)
);
}
}
fn bundled_table_as_json() -> Vec<u8> {
let asset: serde_json::Value =
serde_json::from_slice(crate::audio::align::vocab::tokenizer_json_bytes())
.expect("the bundled asset is JSON");
serde_json::to_vec(&asset["model"]["vocab"]).expect("a table serializes")
}
#[test]
fn a_table_read_as_json_builds_the_bundled_seam() {
let read = Vocabulary::from_json(&bundled_table_as_json()).expect("the table reads");
let options = AlignerOptions::new();
let bundled = build_seam(
Lang::En,
&Vocabulary::bundled(),
&AcousticContract::BASE960H,
normalizer(),
&options,
)
.expect("builds");
let own = build_seam(
Lang::En,
&read,
&AcousticContract::BASE960H,
normalizer(),
&options,
)
.expect("builds");
assert_eq!(own.vocab_size(), bundled.vocab_size());
assert_eq!(own.blank_token_id(), bundled.blank_token_id());
let samples = vec![0.0f32; 16_000];
let abort = AtomicBool::new(false);
for text in [
"And so my fellow Americans, ask not.",
"Café AT&T b4d",
"don't stop U.S.A",
" ... !! ",
"1000",
] {
let events =
|seam: &EmissionsAligner| positions(seam.detect_oov(text).expect("detect_oov").events());
assert_eq!(events(&own), events(&bundled), "{text:?}");
let prepare = |seam: &EmissionsAligner| {
let resolution = seam
.detect_oov(text)
.expect("detect_oov")
.decide(wildcard_all_policy);
seam
.prepare(
&samples,
&SpeechSpans::all_speech(),
text,
resolution,
clock(),
&abort,
)
.map(|prepared| (prepared.is_trivial(), prepared.encoder_input().to_vec()))
};
assert_eq!(prepare(&own), prepare(&bundled), "{text:?}");
}
}
const HF_BASE960H_TABLE: &[u8] = br#"{"<pad>": 0, "<s>": 1, "</s>": 2, "<unk>": 3, "|": 4,
"E": 5, "T": 6, "A": 7, "O": 8, "N": 9, "I": 10, "H": 11, "S": 12, "R": 13, "D": 14, "L": 15,
"U": 16, "M": 17, "W": 18, "C": 19, "F": 20, "G": 21, "Y": 22, "P": 23, "B": 24, "V": 25,
"K": 26, "'": 27, "X": 28, "J": 29, "Q": 30, "Z": 31}"#;
#[test]
fn a_table_of_another_width_builds_a_seam_of_that_width() {
let vocabulary = Vocabulary::from_json(HF_BASE960H_TABLE).expect("the table reads");
let contract = contract_declaring(WordDelimiter::Pipe, &["<s>", "</s>", "<unk>"]);
assert_eq!(
check_tokenization(0, contract.tokenization(), &vocabulary, true),
Ok(())
);
let seam = build_seam(
Lang::En,
&vocabulary,
&contract,
normalizer(),
&AlignerOptions::new(),
)
.expect("a 32-class table builds its seam");
assert_eq!(seam.vocab_size().get(), 32);
assert_eq!(seam.blank_token_id(), 0);
assert_eq!(
positions(seam.detect_oov("b4d").expect("detect_oov").events()),
[(OovKind::Symbol('4'), 1, 0, Lang::En)]
);
}
#[test]
fn an_explicit_blank_outside_the_table_is_refused_by_name() {
let entries = width(29);
for blank in [0u32, 1, 28] {
assert_eq!(check_blank(blank, entries), Ok(()), "id {blank}");
}
for blank in [29u32, 30, u32::MAX] {
let Err(AlignerError::BlankOutOfVocabulary(refused)) = check_blank(blank, entries) else {
panic!("id {blank} is no id of a 29-entry table");
};
assert_eq!((refused.blank(), refused.entries()), (blank, 29));
}
}
#[test]
fn an_ambiguous_table_binds_exactly_the_contracts_blank() {
let table = br#"{"<blank>": 0, "<pad>": 1, "|": 2, "A": 3, "B": 4, "C": 5}"#;
let vocabulary = Vocabulary::from_json(table).expect("the table reads");
let document = vocabulary.tokenizer_json(&contract(0, AcousticGeometry::WAV2VEC2));
let guessed = EmissionsAligner::builder(Lang::En, &document)
.normalizer(normalizer())
.build()
.expect("asry's auto-detect builds a seam");
assert_eq!(
guessed.blank_token_id(),
1,
"a guess by name takes `<pad>`, the wrong column when the blank is `<blank>`"
);
for blank in [0u32, 1] {
let contract = contract(blank, AcousticGeometry::WAV2VEC2);
let seam = build_seam(
Lang::En,
&vocabulary,
&contract,
normalizer(),
&AlignerOptions::new(),
)
.expect("builds");
assert_eq!(seam.blank_token_id(), blank);
}
}
fn table(tokens: &[&str]) -> Vocabulary {
let entries: Vec<String> = tokens
.iter()
.enumerate()
.map(|(id, token)| format!("{}: {id}", serde_json::to_string(token).expect("a token")))
.collect();
Vocabulary::from_json(format!("{{{}}}", entries.join(", ")).as_bytes()).expect("a table")
}
#[test]
fn the_door_refuses_a_contradicted_tokenization_before_the_model_loads() {
let absent = Path::new("/nonexistent/model.mlmodelc");
let load = |vocabulary: &Vocabulary, contract: &AcousticContract| {
Aligner::from_paths_with_vocabulary(
Lang::En,
absent,
vocabulary,
contract,
normalizer(),
AlignerOptions::new(),
)
.err()
.expect("refused")
};
let staged = contract(0, AcousticGeometry::WAV2VEC2);
let as_written = AcousticContract::new(
0,
AcousticGeometry::WAV2VEC2,
Tokenization::new(
WordDelimiter::Pipe,
LetterCase::AsWritten,
Granularity::Character,
&[],
),
OutputKind::LogProbabilities,
);
let spaced = table(&["<pad>", " ", "|", "A", "B"]);
assert_eq!(
load(&spaced, &staged),
AlignerError::Tokenization(TokenizationError::WhitespaceToken(" ".to_owned()))
);
let mixed = table(&["<pad>", "|", "A", "B", "b"]);
assert_eq!(
load(&mixed, &staged),
AlignerError::Tokenization(TokenizationError::UpperWithLowercase('b'))
);
let plain = table(&["<pad>", "|", "A", "B"]);
assert_eq!(
load(&plain, &as_written),
AlignerError::Tokenization(TokenizationError::ProjectedAsWritten)
);
assert!(matches!(load(&plain, &staged), AlignerError::Load(_)));
assert!(matches!(load(&mixed, &as_written), AlignerError::Load(_)));
}
#[test]
fn a_mixed_case_table_with_a_reserved_a_loads_as_written() {
let as_written = |specials: &'static [&'static str]| {
AcousticContract::new(
0,
AcousticGeometry::WAV2VEC2,
Tokenization::new(
WordDelimiter::Pipe,
LetterCase::AsWritten,
Granularity::Character,
specials,
),
OutputKind::LogProbabilities,
)
};
for (vocabulary, contract) in [
(table(&["a", "|", "A", "B", "b"]), as_written(&[])),
(
table(&["<pad>", "|", "B", "a", "b", "A"]),
as_written(&["A"]),
),
] {
let result = Aligner::from_paths_with_vocabulary(
Lang::En,
Path::new("/nonexistent/model.mlmodelc"),
&vocabulary,
&contract,
normalizer(),
AlignerOptions::new(),
);
assert!(
matches!(result, Err(AlignerError::Load(_))),
"{contract:?}: only the absent model refuses the load"
);
}
}
#[test]
fn a_blank_spelled_as_a_space_passes_the_doors_tokenization_check() {
let spaced_blank = table(&[" ", "|", "A", "B"]);
let result = Aligner::from_paths_with_vocabulary(
Lang::En,
Path::new("/nonexistent/model.mlmodelc"),
&spaced_blank,
&contract(0, AcousticGeometry::WAV2VEC2),
normalizer(),
AlignerOptions::new(),
);
assert!(
matches!(result, Err(AlignerError::Load(_))),
"the table and the contract agree; only the absent model refuses the load"
);
}
#[test]
fn a_blank_spelled_a_passes_the_doors_case_check() {
let lettered_blank = table(&["A", "|", "B", "C"]);
let staged = contract(0, AcousticGeometry::WAV2VEC2);
let result = Aligner::from_paths_with_vocabulary(
Lang::En,
Path::new("/nonexistent/model.mlmodelc"),
&lettered_blank,
&staged,
normalizer(),
AlignerOptions::new(),
);
assert!(
matches!(result, Err(AlignerError::Load(_))),
"the table and the contract agree; only the absent model refuses the load"
);
let seam = build_seam(
Lang::En,
&lettered_blank,
&staged,
normalizer(),
&AlignerOptions::new(),
)
.expect("builds");
assert_eq!(
positions(seam.detect_oov("cab").expect("detect_oov").events()),
[(OovKind::Symbol('a'), 1, 0, Lang::En)]
);
}
fn staged_aligner() -> Aligner {
Aligner::from_paths(
Lang::En,
&models_dir().join("base960h_aligner.mlmodelc"),
normalizer(),
)
.expect("load base960h_aligner.mlmodelc (set ALIGNKIT_TEST_MODELS)")
}
fn jfk() -> Vec<f32> {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/whisper/fixtures/audio/jfk.wav");
let mut reader = hound::WavReader::open(&path)
.unwrap_or_else(|e| panic!("open the jfk.wav fixture at {path:?}: {e}"));
assert_eq!(reader.spec().sample_rate, 16_000, "fixture must be 16 kHz");
reader
.samples::<i16>()
.map(|s| f32::from(s.expect("valid sample")) / 32_768.0)
.collect()
}
fn wildcarded(aligner: &Aligner, text: &str) -> OovResolution {
aligner
.detect_oov(text)
.expect("detect_oov")
.decide(wildcard_all_policy)
}
#[test]
#[ignore = "requires local alignkit models (ALIGNKIT_TEST_MODELS)"]
fn the_composition_keeps_only_real_frames() {
let aligner = staged_aligner();
let samples = &jfk()[80_000..80_200];
let abort = AtomicBool::new(false);
let prepared = aligner
.inner
.prepare(
samples,
&SpeechSpans::all_speech(),
"test",
wildcarded(&aligner, "test"),
clock(),
&abort,
)
.expect("prepare 200 real samples with alignable text");
assert!(!prepared.is_trivial());
assert_eq!(prepared.encoder_input().len(), 400, "asry pads to 400");
let output = aligner
.encoder
.emissions(EncoderInput::from_prepared(&prepared))
.expect("emissions on the prepared chunk");
assert_eq!(crate::audio::align::encode::output_shape(&output).0, 1);
}
#[test]
#[ignore = "requires local alignkit models (ALIGNKIT_TEST_MODELS)"]
fn the_composition_of_641_samples_of_abc_has_no_alignment_path() {
let aligner = staged_aligner();
let samples = &jfk()[80_000..80_641];
let text = "ABC";
assert!(
aligner
.detect_oov(text)
.expect("detect_oov")
.events()
.is_empty()
);
let abort = AtomicBool::new(false);
let prepared = aligner
.inner
.prepare(
samples,
&SpeechSpans::all_speech(),
text,
wildcarded(&aligner, text),
clock(),
&abort,
)
.expect("prepare 641 samples of ABC");
let emissions = prepared
.encode_with(|_| {
aligner
.encoder
.emissions(EncoderInput::from_prepared(&prepared))
})
.expect("emissions on the 641-sample chunk");
assert_eq!(emissions.frames(), 1);
let err = aligner
.inner
.finish(prepared, emissions, &abort)
.expect_err("one frame cannot carry three distinct tokens");
assert!(matches!(err, EmissionsError::NoAlignmentPath(_)), "{err:?}");
}
#[test]
#[ignore = "requires local alignkit models (ALIGNKIT_TEST_MODELS)"]
fn align_chunk_names_an_empty_chunk_with_tokens() {
let aligner = staged_aligner();
let abort = AtomicBool::new(false);
let err = aligner
.align_chunk(
&[],
&[],
"test",
clock(),
&abort,
wildcarded(&aligner, "test"),
)
.expect_err("no frame can carry a token");
assert!(matches!(err, AlignError::NoAlignmentPath(_)), "{err:?}");
}
#[test]
#[ignore = "requires local alignkit models (ALIGNKIT_TEST_MODELS)"]
fn align_chunk_is_the_composition_under_a_partial_vad_mask() {
let aligner = staged_aligner();
let samples = jfk();
let sub_segments = [
TimeRange::new(0, 84_000, asry::time::ANALYSIS_TIMEBASE),
TimeRange::new(120_000, 176_000, asry::time::ANALYSIS_TIMEBASE),
];
let text = "And so my fellow Americans ask not what your country can do for you, ask what you \
can do for your country.";
let decided = || {
aligner
.detect_oov(text)
.expect("oov")
.decide(default_oov_policy)
};
let abort = AtomicBool::new(false);
let left = aligner
.align_chunk(&samples, &sub_segments, text, clock(), &abort, decided())
.expect("align_chunk")
.words()
.to_vec();
let speech = SpeechSpans::from_time_ranges(&sub_segments).expect("speech spans");
let prepared = aligner
.inner
.prepare(&samples, &speech, text, decided(), clock(), &abort)
.expect("prepare");
let emissions = prepared
.encode_with(|_| {
aligner
.encoder
.emissions(EncoderInput::from_prepared(&prepared))
})
.expect("emissions");
let right = aligner
.inner
.finish(prepared, emissions, &abort)
.expect("finish")
.words()
.to_vec();
assert!(!right.is_empty(), "the composition must produce words");
assert_eq!(left.len(), right.len());
for (l, r) in left.iter().zip(&right) {
assert_eq!(l.text(), r.text());
assert_eq!(
(l.range().start_pts(), l.range().end_pts()),
(r.range().start_pts(), r.range().end_pts()),
"word `{}`",
l.text()
);
assert_eq!(
l.score().to_bits(),
r.score().to_bits(),
"word `{}`",
l.text()
);
}
}
#[cfg(feature = "tracing")]
mod tracing_spans {
use core::cell::RefCell;
use std::sync::{
Once,
atomic::{AtomicU64, Ordering},
};
use super::*;
thread_local! {
static CAPTURED: RefCell<Option<Capture>> = const { RefCell::new(None) };
}
#[derive(Debug)]
struct CapturedSpan {
id: u64,
name: &'static str,
fields: Vec<&'static str>,
values: Vec<(&'static str, String)>,
parent: Option<u64>,
}
struct Values<'a>(&'a mut Vec<(&'static str, String)>);
impl tracing::field::Visit for Values<'_> {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn core::fmt::Debug) {
self.0.push((field.name(), format!("{value:?}")));
}
}
#[derive(Debug, Default)]
struct Capture {
spans: Vec<CapturedSpan>,
entered: Vec<u64>,
}
struct CaptureSpans {
next_id: AtomicU64,
}
impl tracing::Subscriber for CaptureSpans {
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
CAPTURED.with(|captured| {
if let Some(capture) = captured.borrow_mut().as_mut() {
let parent = capture.entered.last().copied();
let fields = span
.metadata()
.fields()
.iter()
.map(|field| field.name())
.collect();
let mut values = Vec::new();
span.record(&mut Values(&mut values));
capture.spans.push(CapturedSpan {
id,
name: span.metadata().name(),
fields,
values,
parent,
});
}
});
tracing::span::Id::from_u64(id)
}
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
fn event(&self, _event: &tracing::Event<'_>) {}
fn enter(&self, span: &tracing::span::Id) {
CAPTURED.with(|captured| {
if let Some(capture) = captured.borrow_mut().as_mut() {
capture.entered.push(span.into_u64());
}
});
}
fn exit(&self, span: &tracing::span::Id) {
CAPTURED.with(|captured| {
if let Some(capture) = captured.borrow_mut().as_mut() {
if let Some(pos) = capture
.entered
.iter()
.rposition(|&id| id == span.into_u64())
{
capture.entered.remove(pos);
}
}
});
}
}
fn spans_opened_by(body: impl FnOnce()) -> Vec<CapturedSpan> {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
tracing::subscriber::set_global_default(CaptureSpans {
next_id: AtomicU64::new(1),
})
.expect("no other subscriber may claim the global default in this test binary");
});
CAPTURED.with(|captured| *captured.borrow_mut() = Some(Capture::default()));
body();
CAPTURED.with(|captured| {
captured
.borrow_mut()
.take()
.expect("capture was armed above")
.spans
})
}
fn count(spans: &[CapturedSpan], name: &str) -> usize {
spans.iter().filter(|span| span.name == name).count()
}
fn first<'a>(spans: &'a [CapturedSpan], name: &str) -> &'a CapturedSpan {
spans
.iter()
.find(|span| span.name == name)
.unwrap_or_else(|| panic!("no `{name}` span was captured; got {spans:?}"))
}
fn parent_name<'a>(spans: &'a [CapturedSpan], span: &CapturedSpan) -> Option<&'a str> {
let parent = span.parent?;
spans
.iter()
.find(|candidate| candidate.id == parent)
.map(|candidate| candidate.name)
}
fn value<'a>(span: &'a CapturedSpan, field: &str) -> Option<&'a str> {
span
.values
.iter()
.find(|(name, _)| *name == field)
.map(|(_, value)| value.as_str())
}
fn assert_no_bare_language(spans: &[CapturedSpan]) {
for span in spans {
assert!(
!span.fields.contains(&"language"),
"`{}` carries a bare `language` field; got {:?}",
span.name,
span.fields
);
}
}
fn assert_has_fields(span: &CapturedSpan, expected: &[&str]) {
for field in expected {
assert!(
span.fields.contains(field),
"`{}` span must carry the documented `{field}` field; got {:?}",
span.name,
span.fields
);
}
}
#[test]
fn load_emits_a_span_even_when_the_model_is_missing() {
let spans = spans_opened_by(|| {
let result = Aligner::from_paths_with(
Lang::En,
Path::new("/nonexistent/base960h_aligner.mlmodelc"),
normalizer(),
AlignerOptions::new(),
);
assert!(
matches!(result, Err(AlignerError::Load(_))),
"the point of this path is that it fails; a load that succeeded would prove nothing \
about the span"
);
});
assert!(
count(&spans, "alignkit.aligner.load") >= 1,
"`--features tracing` must emit a load span; got {spans:?}"
);
assert!(
count(&spans, "alignkit.encoder.load") >= 1,
"the CoreML load must be its own nested span (it is where the wall-clock hides — 308 s on \
a cold ANE placement); got {spans:?}"
);
assert_has_fields(
first(&spans, "alignkit.aligner.load"),
&["aligner_language", "model_path", "compute"],
);
assert_eq!(
value(first(&spans, "alignkit.aligner.load"), "aligner_language"),
Some("En")
);
assert_no_bare_language(&spans);
assert_has_fields(first(&spans, "alignkit.encoder.load"), &["path", "compute"]);
assert_eq!(
parent_name(&spans, first(&spans, "alignkit.encoder.load")),
Some("alignkit.aligner.load"),
"`alignkit.encoder.load` must nest inside `alignkit.aligner.load`; got {spans:?}"
);
}
#[test]
#[ignore = "requires local alignkit models (ALIGNKIT_TEST_MODELS)"]
fn every_align_chunk_call_opens_exactly_one_span() {
let samples = load_jfk_wav();
let text = "And so my fellow Americans ask not what your country can do for you, ask what \
you can do for your country.";
let spans = spans_opened_by(|| {
let aligner = Aligner::from_paths(
Lang::En,
&models_dir().join("base960h_aligner.mlmodelc"),
normalizer(),
)
.expect("load base960h_aligner.mlmodelc (set ALIGNKIT_TEST_MODELS)");
let abort = AtomicBool::new(false);
for _ in 0..2 {
let resolution = aligner
.detect_oov(text)
.expect("detect_oov")
.decide(default_oov_policy);
let clock = OutputClock::new(0, asry::time::ANALYSIS_TIMEBASE, 0).expect("clock");
let result = aligner
.align_chunk(&samples, &[], text, clock, &abort, resolution)
.expect("align_chunk on the shipping default");
assert!(!result.words().is_empty(), "jfk.wav must align to words");
}
});
assert_eq!(
count(&spans, "alignkit.align_chunk"),
2,
"one span per align_chunk call, no more and no fewer; got {spans:?}"
);
assert_eq!(
count(&spans, "alignkit.encoder.emissions"),
2,
"exactly one CoreML predict span per chunk, two over two calls; got {spans:?}"
);
assert!(
count(&spans, "alignkit.aligner.load") >= 1,
"load must still be spanned on the success path; got {spans:?}"
);
assert_has_fields(
first(&spans, "alignkit.align_chunk"),
&[
"aligner_language",
"samples",
"sub_segments",
"text_bytes",
"oov_decisions",
],
);
assert_has_fields(
first(&spans, "alignkit.encoder.emissions"),
&["encoder_input", "real_samples", "compute"],
);
for span in spans
.iter()
.filter(|span| span.name == "alignkit.encoder.emissions")
{
assert_eq!(
parent_name(&spans, span),
Some("alignkit.align_chunk"),
"each `alignkit.encoder.emissions` must nest inside `alignkit.align_chunk`; got {spans:?}"
);
}
}
#[test]
fn a_registry_request_traces_its_requested_language_and_route() {
use crate::audio::align::registry::AlignmentSetBuilder;
let spans = spans_opened_by(|| {
let set = AlignmentSetBuilder::new().build();
let resolution = set
.detect_oov("anything", &Lang::Ko)
.expect("a miss detects nothing")
.decide(|event| event.default_decision());
let clock = OutputClock::new(0, asry::time::ANALYSIS_TIMEBASE, 0).expect("clock");
let abort = AtomicBool::new(false);
let alignment = set
.align_chunk(&Lang::Ko, &[], &[], "anything", clock, &abort, resolution)
.expect("a SkipChunk miss is not an error");
assert!(alignment.words().is_empty());
});
let request = first(&spans, "alignkit.registry.align_chunk");
assert_eq!(value(request, "requested_language"), Some("Ko"));
assert_eq!(value(request, "route"), Some("Miss(SkipChunk)"));
assert_no_bare_language(&spans);
}
#[test]
#[ignore = "requires local alignkit models (ALIGNKIT_TEST_MODELS)"]
fn a_fallback_request_traces_the_requested_and_the_aligner_language() {
use crate::audio::align::registry::{AlignerKey, AlignmentSetBuilder};
let samples = load_jfk_wav();
let text = "And so my fellow Americans ask not what your country can do for you, ask what \
you can do for your country.";
let spans = spans_opened_by(|| {
let english = Aligner::from_paths(
Lang::En,
&models_dir().join("base960h_aligner.mlmodelc"),
normalizer(),
)
.expect("load base960h_aligner.mlmodelc (set ALIGNKIT_TEST_MODELS)");
let set = AlignmentSetBuilder::new()
.register(AlignerKey::Any, english)
.build();
let resolution = set
.detect_oov(text, &Lang::Ko)
.expect("detect_oov")
.decide(|event| event.default_decision());
let clock = OutputClock::new(0, asry::time::ANALYSIS_TIMEBASE, 0).expect("clock");
let abort = AtomicBool::new(false);
let alignment = set
.align_chunk(&Lang::Ko, &samples, &[], text, clock, &abort, resolution)
.expect("the English fallback aligns the chunk");
assert!(!alignment.words().is_empty(), "jfk.wav must align to words");
});
let request = first(&spans, "alignkit.registry.align_chunk");
assert_eq!(
value(request, "requested_language"),
Some("Ko"),
"{spans:?}"
);
assert_eq!(
value(request, "route"),
Some("AnyFallback(En)"),
"{spans:?}"
);
let chunk = first(&spans, "alignkit.align_chunk");
assert_eq!(value(chunk, "aligner_language"), Some("En"), "{spans:?}");
assert_eq!(
parent_name(&spans, chunk),
Some("alignkit.registry.align_chunk"),
"the aligner's span nests inside the request's; got {spans:?}"
);
assert_no_bare_language(&spans);
}
fn models_dir() -> std::path::PathBuf {
std::env::var_os("ALIGNKIT_TEST_MODELS").map_or_else(
|| crate::tests::models_root().join("alignkit"),
std::path::PathBuf::from,
)
}
fn load_jfk_wav() -> Vec<f32> {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/whisper/fixtures/audio/jfk.wav");
let mut reader = hound::WavReader::open(&path)
.unwrap_or_else(|e| panic!("open the jfk.wav fixture at {path:?}: {e}"));
assert_eq!(reader.spec().sample_rate, 16_000, "fixture must be 16 kHz");
reader
.samples::<i16>()
.map(|s| f32::from(s.expect("valid sample")) / 32_768.0)
.collect()
}
}