use core::{num::NonZeroU32, time::Duration};
use std::path::Path;
use mediatime::TimeRange;
use ort::session::{RunOptions, Session};
use smol_str::format_smolstr;
use crate::{
core::AlignmentResult,
runner::{
RunnerError,
aligner::{
core::{
AlignerCore, AlignerCoreLoadError, capture_vocab_size, detect_blank_token_id,
detect_unk_token_id, detect_vocab_uppercase_only, load_tokenizer_with_compat,
validate_word_delimiter_present,
},
emissions_api::{SpanError, SpeechCoverage, SpeechSpans},
normalizer::DynTextNormalizer,
},
},
time::SAMPLE_RATE_HZ,
types::{AlignmentError, AlignmentFailure, Lang, WorkFailure, WorkerHangTimeout},
};
fn lift_core_load_error(err: AlignerCoreLoadError) -> RunnerError {
RunnerError::AlignerLoad {
message: err.message().clone(),
}
}
fn spans_from_sub_segments(
sub_segments: &[TimeRange],
language: &Lang,
) -> Result<SpeechSpans, WorkFailure> {
SpeechSpans::from_time_ranges(sub_segments).map_err(|e| match e {
SpanError::Timebase { num, den, .. } => {
WorkFailure::Alignment(AlignmentError::ModelInference(AlignmentFailure::new(
format_smolstr!(
"Aligner::align expects sub_segments in chunk-local 1/{} timebase, \
got {}/{}; caller passed sub_segments in the wrong timebase \
(samples will not match audio if we proceed).",
SAMPLE_RATE_HZ,
num,
den,
),
language.clone(),
)))
}
other => WorkFailure::Alignment(AlignmentError::ModelInference(AlignmentFailure::new(
format_smolstr!("invalid sub_segment: {other}"),
language.clone(),
))),
})
}
const DEFAULT_HOP_SAMPLES: NonZeroU32 = match NonZeroU32::new(320) {
Some(v) => v,
None => unreachable!(),
};
const fn nonzero_hop(value: u32) -> NonZeroU32 {
assert!(value > 0, "hop_samples must be > 0");
match NonZeroU32::new(value) {
Some(v) => v,
None => unreachable!(),
}
}
pub struct Aligner {
session: Session,
core: AlignerCore,
}
impl Aligner {
pub fn from_paths(
language: Lang,
model_path: &Path,
tokenizer_path: &Path,
normalizer: DynTextNormalizer,
) -> Result<Self, RunnerError> {
let session = Session::builder()
.map_err(|e| RunnerError::AlignerLoad {
message: format_smolstr!("Session::builder failed: {e}"),
})?
.commit_from_file(model_path)
.map_err(|e| RunnerError::AlignerLoad {
message: format_smolstr!("commit_from_file({}) failed: {e}", model_path.display()),
})?;
let tokenizer = load_tokenizer_with_compat(tokenizer_path).map_err(lift_core_load_error)?;
let blank_token_id =
detect_blank_token_id(&tokenizer).ok_or_else(|| RunnerError::AlignerLoad {
message: format_smolstr!(
"tokenizer has no <pad> / [PAD] entry; cannot determine CTC blank token"
),
})?;
let unk_token_id = detect_unk_token_id(&tokenizer);
let vocab_uppercase_only = detect_vocab_uppercase_only(&tokenizer);
validate_word_delimiter_present(&tokenizer, normalizer.use_word_delimiter())
.map_err(lift_core_load_error)?;
let tokenizer_vocab_size =
capture_vocab_size(&tokenizer).ok_or_else(|| RunnerError::AlignerLoad {
message: format_smolstr!(
"tokenizer reports a zero-size vocab; a CTC vocabulary must contain at least \
the blank token"
),
})?;
Ok(Self {
session,
core: AlignerCore::from_parts(
tokenizer,
language,
normalizer,
DEFAULT_HOP_SAMPLES,
blank_token_id,
unk_token_id,
vocab_uppercase_only,
tokenizer_vocab_size,
SpeechCoverage::DEFAULT,
crate::runner::aligner::algorithm::compose::DEFAULT_MAX_INTRA_SILENT_RUN,
),
})
}
pub const fn language(&self) -> &Lang {
self.core.language()
}
pub fn detect_oov(
&self,
text: &str,
) -> Result<Vec<crate::core::OovEvent>, crate::types::WorkFailure> {
self.core.detect_oov(text)
}
pub const fn sample_rate(&self) -> u32 {
SAMPLE_RATE_HZ
}
pub const fn hop_samples(&self) -> u32 {
self.core.hop_samples().get()
}
pub const fn blank_token_id(&self) -> u32 {
self.core.blank_token_id()
}
pub const fn set_hop_samples(&mut self, value: u32) {
self.core.set_hop_samples(nonzero_hop(value));
}
pub const fn with_hop_samples(mut self, value: u32) -> Self {
self.core.set_hop_samples(nonzero_hop(value));
self
}
pub const fn min_speech_coverage(&self) -> f32 {
self.core.min_speech_coverage().get()
}
pub const fn set_min_speech_coverage(&mut self, value: f32) {
self
.core
.set_min_speech_coverage(SpeechCoverage::clamped(value));
}
pub const fn with_min_speech_coverage(mut self, value: f32) -> Self {
self
.core
.set_min_speech_coverage(SpeechCoverage::clamped(value));
self
}
pub const fn max_intra_silent_run(&self) -> Duration {
self.core.max_intra_silent_run()
}
pub const fn set_max_intra_silent_run(&mut self, value: Duration) {
self.core.set_max_intra_silent_run(value);
}
pub const fn with_max_intra_silent_run(mut self, value: Duration) -> Self {
self.core.set_max_intra_silent_run(value);
self
}
pub fn align_chunk<F>(
&mut self,
samples: &[f32],
sub_segments: &[TimeRange],
text: &str,
chunk_first_sample_in_stream: u64,
samples_to_output_range: F,
) -> Result<AlignmentResult, WorkFailure>
where
F: Fn(u64, u64) -> TimeRange,
{
let abort_flag = core::sync::atomic::AtomicBool::new(false);
let run_options = RunOptions::new().map_err(|e| {
WorkFailure::Alignment(AlignmentError::ModelInference(AlignmentFailure::new(
format_smolstr!("RunOptions::new failed: {e:?}"),
self.core.language().clone(),
)))
})?;
let oov_events = self.detect_oov(text)?;
let oov_decisions = crate::core::default_oov_decisions(&oov_events);
let expected = self.core.language().clone();
self.align(
samples,
sub_segments,
text,
chunk_first_sample_in_stream,
samples_to_output_range,
&abort_flag,
&run_options,
&oov_decisions,
&expected,
)
}
#[allow(
clippy::too_many_arguments,
reason = "7 args carry independent semantic inputs (audio, \
sub_segments, text, chunk anchor, timebase bridge, \
abort flag, run options); each comes from a different \
upstream pass"
)]
pub fn align_chunk_with_abort<F>(
&mut self,
samples: &[f32],
sub_segments: &[TimeRange],
text: &str,
chunk_first_sample_in_stream: u64,
samples_to_output_range: F,
abort_flag: &core::sync::atomic::AtomicBool,
run_options: &RunOptions,
oov_decisions: &[crate::core::ResolvedOov],
) -> Result<AlignmentResult, WorkFailure>
where
F: Fn(u64, u64) -> TimeRange,
{
let expected = self.core.language().clone();
self.align(
samples,
sub_segments,
text,
chunk_first_sample_in_stream,
samples_to_output_range,
abort_flag,
run_options,
oov_decisions,
&expected,
)
}
#[allow(
clippy::too_many_arguments,
reason = "8 args, each carrying an independent semantic input \
(audio buffer, sub-segments, transcript, chunk anchor, \
timebase bridge closure, abort flag, run options); \
clustering them into a struct adds indirection without \
clarity gain since callers already pass them positionally"
)]
pub(crate) fn align<F>(
&mut self,
samples: &[f32],
sub_segments: &[TimeRange],
text: &str,
chunk_first_sample_in_stream: u64,
samples_to_output_range: F,
abort_flag: &core::sync::atomic::AtomicBool,
run_options: &RunOptions,
oov_decisions: &[crate::core::ResolvedOov],
expected_decision_language: &Lang,
) -> Result<AlignmentResult, WorkFailure>
where
F: Fn(u64, u64) -> TimeRange,
{
use crate::runner::aligner::algorithm::encode::encode_log_softmax;
let speech = spans_from_sub_segments(sub_segments, self.core.language())?;
let prepared = self.core.prepare(
samples,
&speech,
text,
oov_decisions,
expected_decision_language,
abort_flag,
)?;
if prepared.is_trivial() {
return Ok(AlignmentResult::new(Vec::new()));
}
let log_probs = encode_log_softmax(
&mut self.session,
prepared.encoder_input(),
run_options,
self.core.language(),
)
.map_err(|e| classify_encode_abort(abort_flag, e))?;
self.core.finish(
prepared,
&log_probs,
chunk_first_sample_in_stream,
samples_to_output_range,
abort_flag,
)
}
}
fn classify_encode_abort(
abort_flag: &core::sync::atomic::AtomicBool,
err: WorkFailure,
) -> WorkFailure {
use core::sync::atomic::Ordering;
if abort_flag.load(Ordering::Relaxed) {
WorkFailure::WorkerHang(WorkerHangTimeout::new(
crate::types::WorkerKind::Alignment,
core::time::Duration::ZERO,
))
} else {
err
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runner::aligner::test_fixtures::fixture_or_panic;
#[test]
fn classify_encode_abort_promotes_to_timeout_when_aborted() {
use core::sync::atomic::AtomicBool;
let aborted = AtomicBool::new(true);
let original = WorkFailure::Alignment(AlignmentError::ModelInference(AlignmentFailure::new(
"ort terminate".into(),
Lang::En,
)));
let classified = classify_encode_abort(&aborted, original);
match classified {
WorkFailure::WorkerHang(ref t) if t.kind() == crate::types::WorkerKind::Alignment => {}
other => {
panic!("aborted-encode error must surface as WorkerHangTimeout(Alignment); got {other:?}",)
}
}
}
#[test]
fn classify_encode_abort_passes_through_when_not_aborted() {
use core::sync::atomic::AtomicBool;
let not_aborted = AtomicBool::new(false);
let original = WorkFailure::Alignment(AlignmentError::ModelInference(AlignmentFailure::new(
"ort genuine error".into(),
Lang::En,
)));
let classified = classify_encode_abort(¬_aborted, original);
match classified {
WorkFailure::Alignment(AlignmentError::ModelInference(_)) => {}
other => panic!("non-aborted encode error must pass through unchanged; got {other:?}"),
}
}
fn analysis_tb() -> mediatime::Timebase {
mediatime::Timebase::new(1, core::num::NonZeroU32::new(SAMPLE_RATE_HZ).unwrap())
}
#[test]
fn build_speech_mask_errors_on_non_analysis_timebase() {
let ms_tb = mediatime::Timebase::new(1, core::num::NonZeroU32::new(1000).unwrap());
let segs = [TimeRange::new(0, 100, ms_tb)];
let err = spans_from_sub_segments(&segs, &Lang::En).expect_err("must error");
match err {
WorkFailure::Alignment(AlignmentError::ModelInference(payload)) => {
let message = payload.message();
assert!(
message.contains("chunk-local 1/16000 timebase"),
"error message must cite the contract; got: {message}"
);
assert!(
message.contains("1/1000"),
"error message must cite the offending timebase; got: {message}"
);
}
other => panic!("expected ModelInference, got {other:?}"),
}
}
#[test]
fn build_speech_mask_errors_on_output_timebase() {
let out_tb = mediatime::Timebase::new(1, core::num::NonZeroU32::new(48_000).unwrap());
let segs = [TimeRange::new(0, 1000, out_tb)];
let err = spans_from_sub_segments(&segs, &Lang::En).expect_err("must error");
assert!(matches!(err, WorkFailure::Alignment(_)));
}
#[test]
fn spans_from_sub_segments_accepts_the_analysis_timebase() {
let segs = [TimeRange::new(2, 5, analysis_tb())];
let spans = spans_from_sub_segments(&segs, &Lang::En).expect("1/16000 is the contract");
assert_eq!(spans.as_slice().len(), 1);
assert_eq!(spans.as_slice()[0].start(), 2);
assert_eq!(spans.as_slice()[0].end(), 5);
}
#[test]
fn lift_core_load_error_preserves_variant_and_message() {
let core_err = AlignerCoreLoadError::new(
"tokenizer is missing the `|` word-delimiter token, but the language's normaliser \
declared `use_word_delimiter = true`."
.into(),
);
let expected = core_err.message().clone();
let RunnerError::AlignerLoad { message } = lift_core_load_error(core_err) else {
panic!("core load failures must surface as RunnerError::AlignerLoad");
};
assert_eq!(
message, expected,
"the diagnostic must cross the front-end boundary verbatim"
);
}
#[test]
fn aligner_is_send_not_sync() {
fn assert_send<T: Send>() {}
assert_send::<Aligner>();
}
#[test]
#[cfg_attr(
not(asry_w2v_en),
ignore = "needs the English wav2vec2 fixture: ASRY_FETCH_W2V=en cargo test --features alignment"
)]
fn empty_normalised_text_returns_empty_alignment_result() {
use core::sync::atomic::AtomicBool;
use mediatime::{TimeRange, Timebase};
use crate::runner::aligner::{normalizers::EnglishNormalizer, test_fixtures::english_aligner};
let mut aligner = english_aligner(Box::new(EnglishNormalizer::new()));
let samples = vec![0.0_f32; 16_000];
let sub_segments: Vec<TimeRange> = Vec::new();
let abort = AtomicBool::new(false);
let run_options = ort::session::RunOptions::new().expect("RunOptions::new");
let result = aligner
.align(
&samples,
&sub_segments,
"!!!...",
0,
|start, end| {
TimeRange::new(
start as i64,
end as i64,
Timebase::new(1, core::num::NonZeroU32::new(16_000).unwrap()),
)
},
&abort,
&run_options,
&[],
&Lang::En,
)
.expect("EmptyText must short-circuit to Ok, not propagate as AlignmentFailed");
assert!(
result.words().is_empty(),
"empty normalisation must yield zero words; got {:?}",
result.words()
);
}
#[test]
#[cfg_attr(
not(asry_w2v_en),
ignore = "needs the English wav2vec2 fixture: ASRY_FETCH_W2V=en cargo test --features alignment"
)]
fn sub_400_sample_chunk_surfaces_no_alignment_path() {
use core::sync::atomic::AtomicBool;
use mediatime::{TimeRange, Timebase};
use crate::runner::aligner::{normalizers::EnglishNormalizer, test_fixtures::english_aligner};
let mut aligner = english_aligner(Box::new(EnglishNormalizer::new()));
let samples = vec![0.0_f32; 200];
let sub_segments: Vec<TimeRange> = Vec::new();
let abort = AtomicBool::new(false);
let run_options = ort::session::RunOptions::new().expect("RunOptions::new");
let err = aligner
.align(
&samples,
&sub_segments,
"hello world",
0,
|start, end| {
TimeRange::new(
start as i64,
end as i64,
Timebase::new(1, core::num::NonZeroU32::new(16_000).unwrap()),
)
},
&abort,
&run_options,
&[],
&Lang::En,
)
.expect_err(
"a 1-frame trellis cannot admit an 11-token CTC path; the aligner must say so, not \
manufacture an Ok(empty) that the pool could not distinguish from a real zero-word \
alignment",
);
let WorkFailure::Alignment(AlignmentError::NoAlignmentPath(failure)) = err else {
panic!(
"a too-short chunk must surface as the RECOVERABLE `NoAlignmentPath` — that is what \
`alignment_pool` keys the ASR-text-preserving recovery off. Any other variant is \
classified fatal and would destroy the transcript. Got: {err:?}"
);
};
let message = failure.message();
assert!(
message.contains("audio too short"),
"the failure must name its cause; got: {message}"
);
assert!(
message.contains("T=1 frames"),
"400 padded samples must yield exactly one frame; got: {message}"
);
assert!(
message.contains("11 chars"),
"`hello world` must tokenise to 11 chars (10 letters + `|`); got: {message}"
);
assert_eq!(
*failure.language(),
Lang::En,
"the failure must carry the chunk's language for the pool's telemetry"
);
}
#[test]
#[cfg_attr(
not(asry_w2v_ja),
ignore = "needs the Japanese wav2vec2 fixture: ASRY_FETCH_W2V=ja cargo test --features alignment"
)]
fn japanese_aligner_loads_and_short_circuits_on_empty_text() {
use core::sync::atomic::AtomicBool;
use mediatime::{TimeRange, Timebase};
use crate::runner::aligner::default_normalizer_for;
let model_path = fixture_or_panic(option_env!("ASRY_W2V_JA_MODEL"), "ASRY_W2V_JA_MODEL", "ja");
let tokenizer_path = fixture_or_panic(
option_env!("ASRY_W2V_JA_TOKENIZER"),
"ASRY_W2V_JA_TOKENIZER",
"ja",
);
let normalizer = default_normalizer_for(&Lang::Ja).expect("Ja normalizer must exist");
let mut aligner = Aligner::from_paths(
Lang::Ja,
Path::new(model_path),
Path::new(tokenizer_path),
normalizer,
)
.expect("Aligner::from_paths(Ja)");
let samples = vec![0.0_f32; 16_000];
let sub_segments: Vec<TimeRange> = Vec::new();
let abort = AtomicBool::new(false);
let run_options = ort::session::RunOptions::new().expect("RunOptions::new");
let result = aligner
.align(
&samples,
&sub_segments,
"!!!...",
0,
|start, end| {
TimeRange::new(
start as i64,
end as i64,
Timebase::new(1, core::num::NonZeroU32::new(16_000).unwrap()),
)
},
&abort,
&run_options,
&[],
&Lang::Ja,
)
.expect("Ja aligner empty-text must short-circuit Ok");
assert!(result.words().is_empty());
}
#[test]
#[cfg_attr(
not(asry_w2v_zh),
ignore = "needs the Chinese wav2vec2 fixture: ASRY_FETCH_W2V=zh cargo test --features alignment"
)]
fn chinese_aligner_loads_and_short_circuits_on_empty_text() {
use core::sync::atomic::AtomicBool;
use mediatime::{TimeRange, Timebase};
use crate::runner::aligner::default_normalizer_for;
let model_path = fixture_or_panic(option_env!("ASRY_W2V_ZH_MODEL"), "ASRY_W2V_ZH_MODEL", "zh");
let tokenizer_path = fixture_or_panic(
option_env!("ASRY_W2V_ZH_TOKENIZER"),
"ASRY_W2V_ZH_TOKENIZER",
"zh",
);
let normalizer = default_normalizer_for(&Lang::Zh).expect("Zh normalizer must exist");
let mut aligner = Aligner::from_paths(
Lang::Zh,
Path::new(model_path),
Path::new(tokenizer_path),
normalizer,
)
.expect("Aligner::from_paths(Zh)");
let samples = vec![0.0_f32; 16_000];
let sub_segments: Vec<TimeRange> = Vec::new();
let abort = AtomicBool::new(false);
let run_options = ort::session::RunOptions::new().expect("RunOptions::new");
let result = aligner
.align(
&samples,
&sub_segments,
"!!!...",
0,
|start, end| {
TimeRange::new(
start as i64,
end as i64,
Timebase::new(1, core::num::NonZeroU32::new(16_000).unwrap()),
)
},
&abort,
&run_options,
&[],
&Lang::Zh,
)
.expect("Zh aligner empty-text must short-circuit Ok");
assert!(result.words().is_empty());
}
#[test]
#[cfg_attr(
not(asry_w2v_ko),
ignore = "needs the Korean wav2vec2 fixture: ASRY_FETCH_W2V=ko cargo test --features alignment"
)]
fn korean_aligner_loads_and_short_circuits_on_empty_text() {
use core::sync::atomic::AtomicBool;
use mediatime::{TimeRange, Timebase};
use crate::runner::aligner::default_normalizer_for;
let model_path = fixture_or_panic(option_env!("ASRY_W2V_KO_MODEL"), "ASRY_W2V_KO_MODEL", "ko");
let tokenizer_path = fixture_or_panic(
option_env!("ASRY_W2V_KO_TOKENIZER"),
"ASRY_W2V_KO_TOKENIZER",
"ko",
);
let normalizer = default_normalizer_for(&Lang::Ko).expect("Ko normalizer must exist");
let mut aligner = Aligner::from_paths(
Lang::Ko,
Path::new(model_path),
Path::new(tokenizer_path),
normalizer,
)
.expect("Aligner::from_paths(Ko)");
let samples = vec![0.0_f32; 16_000];
let sub_segments: Vec<TimeRange> = Vec::new();
let abort = AtomicBool::new(false);
let run_options = ort::session::RunOptions::new().expect("RunOptions::new");
let result = aligner
.align(
&samples,
&sub_segments,
"!!!...",
0,
|start, end| {
TimeRange::new(
start as i64,
end as i64,
Timebase::new(1, core::num::NonZeroU32::new(16_000).unwrap()),
)
},
&abort,
&run_options,
&[],
&Lang::Ko,
)
.expect("Ko aligner empty-text must short-circuit Ok");
assert!(result.words().is_empty());
}
fn smoke_latin_aligner(
lang: Lang,
fetch_code: &str,
model_env: Option<&'static str>,
model_var: &str,
tokenizer_env: Option<&'static str>,
tokenizer_var: &str,
) {
use core::sync::atomic::AtomicBool;
use mediatime::{TimeRange, Timebase};
use crate::runner::aligner::default_normalizer_for;
let model_path = fixture_or_panic(model_env, model_var, fetch_code);
let tokenizer_path = fixture_or_panic(tokenizer_env, tokenizer_var, fetch_code);
let normalizer = default_normalizer_for(&lang).expect("Latin lang must resolve a normalizer");
let mut aligner = Aligner::from_paths(
lang.clone(),
Path::new(model_path),
Path::new(tokenizer_path),
normalizer,
)
.expect("Aligner::from_paths(Latin)");
let samples = vec![0.0_f32; 16_000];
let sub_segments: Vec<TimeRange> = Vec::new();
let abort = AtomicBool::new(false);
let run_options = ort::session::RunOptions::new().expect("RunOptions::new");
let result = aligner
.align(
&samples,
&sub_segments,
"!!!...",
0,
|start, end| {
TimeRange::new(
start as i64,
end as i64,
Timebase::new(1, core::num::NonZeroU32::new(16_000).unwrap()),
)
},
&abort,
&run_options,
&[],
&lang,
)
.expect("Latin aligner empty-text must short-circuit Ok");
assert!(
result.words().is_empty(),
"{lang:?} aligner empty-text must yield zero words"
);
}
#[test]
#[cfg_attr(
not(asry_w2v_es),
ignore = "needs the Spanish wav2vec2 fixture: ASRY_FETCH_W2V=es cargo test --features alignment"
)]
fn spanish_aligner_loads_and_short_circuits_on_empty_text() {
smoke_latin_aligner(
Lang::Es,
"es",
option_env!("ASRY_W2V_ES_MODEL"),
"ASRY_W2V_ES_MODEL",
option_env!("ASRY_W2V_ES_TOKENIZER"),
"ASRY_W2V_ES_TOKENIZER",
);
}
#[test]
#[cfg_attr(
not(asry_w2v_fr),
ignore = "needs the French wav2vec2 fixture: ASRY_FETCH_W2V=fr cargo test --features alignment"
)]
fn french_aligner_loads_and_short_circuits_on_empty_text() {
smoke_latin_aligner(
Lang::Fr,
"fr",
option_env!("ASRY_W2V_FR_MODEL"),
"ASRY_W2V_FR_MODEL",
option_env!("ASRY_W2V_FR_TOKENIZER"),
"ASRY_W2V_FR_TOKENIZER",
);
}
#[test]
#[cfg_attr(
not(asry_w2v_de),
ignore = "needs the German wav2vec2 fixture: ASRY_FETCH_W2V=de cargo test --features alignment"
)]
fn german_aligner_loads_and_short_circuits_on_empty_text() {
smoke_latin_aligner(
Lang::De,
"de",
option_env!("ASRY_W2V_DE_MODEL"),
"ASRY_W2V_DE_MODEL",
option_env!("ASRY_W2V_DE_TOKENIZER"),
"ASRY_W2V_DE_TOKENIZER",
);
}
#[test]
#[cfg_attr(
not(asry_w2v_it),
ignore = "needs the Italian wav2vec2 fixture: ASRY_FETCH_W2V=it cargo test --features alignment"
)]
fn italian_aligner_loads_and_short_circuits_on_empty_text() {
smoke_latin_aligner(
Lang::It,
"it",
option_env!("ASRY_W2V_IT_MODEL"),
"ASRY_W2V_IT_MODEL",
option_env!("ASRY_W2V_IT_TOKENIZER"),
"ASRY_W2V_IT_TOKENIZER",
);
}
#[test]
#[cfg_attr(
not(asry_w2v_pt),
ignore = "needs the Portuguese wav2vec2 fixture: ASRY_FETCH_W2V=pt cargo test --features alignment"
)]
fn portuguese_aligner_loads_and_short_circuits_on_empty_text() {
smoke_latin_aligner(
Lang::Pt,
"pt",
option_env!("ASRY_W2V_PT_MODEL"),
"ASRY_W2V_PT_MODEL",
option_env!("ASRY_W2V_PT_TOKENIZER"),
"ASRY_W2V_PT_TOKENIZER",
);
}
}