1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
//! `impl Engine` methods — split out of the former god-file.
use super::*;
impl Engine {
pub fn is_int8(&self) -> bool {
self.int8
}
/// The recognition head ([`ModelVariant`]) detected on disk at load time.
/// Lets callers decide the default punctuation policy (`auto`).
pub fn variant(&self) -> ModelVariant {
self.variant
}
/// Attach an optional punctuation / casing restorer, consuming and
/// returning `self` (builder style). Pass `None` for pass-through. When set,
/// the restorer post-processes the final text of file transcription
/// ([`Engine::transcribe_file`] / [`Engine::transcribe_bytes_shared`]) and
/// of finalized streaming segments ([`Engine::process_chunk`] /
/// [`Engine::flush_state`]).
pub fn with_punctuator(mut self, punctuator: Option<crate::punctuation::Punctuator>) -> Self {
self.punctuator = punctuator;
self
}
/// Whether a punctuation restorer is attached.
pub fn has_punctuator(&self) -> bool {
self.punctuator.is_some()
}
/// Reject a [`TranscribeOverrides`] that turns a knob on without the backing
/// resource loaded. Call this *before* checking out a session so the request
/// fails fast (the REST layer maps the error to a `409`). Turning a knob off
/// (`Some(false)`) and ITN in either direction are always valid — ITN is pure
/// code with no model to load.
///
/// Hotword DoS limits are validated separately via
/// [`Engine::validate_hotwords`] so [`TranscribeOverrides`] stays `Copy` (semver).
///
/// # Errors
///
/// - [`OverrideError::VadNotLoaded`] when `vad = Some(true)` but no VAD is
/// attached.
/// - [`OverrideError::PunctuationNotAvailable`] when `punctuation = Some(true)`
/// but no punctuator is attached.
pub fn validate_overrides(&self, o: &TranscribeOverrides) -> Result<(), OverrideError> {
if o.vad == Some(true) && self.vad.is_none() {
return Err(OverrideError::VadNotLoaded);
}
if o.punctuation == Some(true) && self.punctuator.is_none() {
return Err(OverrideError::PunctuationNotAvailable);
}
Ok(())
}
/// Reject a [`HotwordOverride`] that exceeds DoS limits. Call before checkout
/// so oversized requests fail fast (REST maps to HTTP 400).
///
/// # Errors
///
/// - [`HotwordError::TooManyHotwords`] when more than
/// [`MAX_HOTWORDS_PER_REQUEST`] phrases are supplied.
/// - [`HotwordError::PhraseTooLong`] when any phrase exceeds
/// [`MAX_HOTWORD_PHRASE_CHARS`] Unicode scalar values.
pub fn validate_hotwords(&self, hw: &HotwordOverride) -> Result<(), HotwordError> {
if hw.phrases.len() > MAX_HOTWORDS_PER_REQUEST {
return Err(HotwordError::TooManyHotwords);
}
for phrase in &hw.phrases {
if phrase.chars().count() > MAX_HOTWORD_PHRASE_CHARS {
return Err(HotwordError::PhraseTooLong);
}
}
Ok(())
}
/// Build a temporary per-request [`bias::Biaser`] from a
/// [`HotwordOverride`]. Empty phrases force biasing off (`None`).
/// Unrepresentable phrases are dropped by [`Biaser::from_phrases`]; if none
/// survive, returns `None` (decode continues without biasing).
pub(crate) fn build_request_biaser(&self, hw: &HotwordOverride) -> Option<bias::Biaser> {
if hw.phrases.is_empty() {
return None;
}
let boost = hw.boost.unwrap_or(DEFAULT_HOTWORDS_BOOST);
let pairs: Vec<(String, f32)> = hw.phrases.iter().cloned().map(|p| (p, 1.0)).collect();
bias::Biaser::from_phrases(&self.tokenizer, &pairs, boost)
}
/// Effective hotword biaser for one decode call.
///
/// - `hotwords == None` → engine boot biaser (may itself be `None`)
/// - `hotwords == Some(_)` → temporary request biaser (may be `None` when
/// phrases are empty / unrepresentable — deliberately *not* the boot biaser)
///
/// `request_biaser` must be the result of [`build_request_biaser`] (or
/// `None` when `hotwords` is absent); it is only borrowed, never moved.
pub(crate) fn select_biaser<'a>(
&'a self,
hotwords: Option<&HotwordOverride>,
request_biaser: &'a Option<bias::Biaser>,
) -> Option<&'a bias::Biaser> {
match hotwords {
None => self.biaser.as_ref(),
Some(_) => request_biaser.as_ref(),
}
}
/// Apply ITN then punctuation to joined text. Shared by the streaming
/// finalizer and the file-transcription result builder so the order and
/// guards stay identical.
pub(crate) fn apply_text_postprocess(
&self,
text: String,
itn: bool,
punctuation: bool,
) -> String {
let text = if itn {
crate::itn::apply_itn(&text)
} else {
text
};
match &self.punctuator {
Some(p) if punctuation => p.restore(&text),
_ => text,
}
}
/// Enable or disable inverse text normalization (Russian number-words →
/// digits) on file-transcription output and finalized streaming segments,
/// consuming and returning `self` (builder style). When enabled, ITN runs
/// *before* the punctuation pass so the restorer cases the
/// already-digitized text.
pub fn with_itn(mut self, enabled: bool) -> Self {
self.itn = enabled;
self
}
/// Whether inverse text normalization is enabled.
pub fn has_itn(&self) -> bool {
self.itn
}
/// Attach a contextual hotword biaser built from `(phrase, weight)` pairs
/// and an additive `boost`, consuming and returning `self` (builder style).
/// Each phrase is tokenized with the engine's own `Tokenizer`, so biasing
/// adapts to whichever recognition head is loaded.
///
/// When `phrases` is empty, `boost <= 0`, or no phrase is representable in
/// the active vocab, the biaser resolves to `None` and the decode path stays
/// byte-for-byte unchanged. Replaces any previously attached biaser.
pub fn with_hotwords(mut self, phrases: &[(String, f32)], boost: f32) -> Self {
self.biaser = if phrases.is_empty() {
None
} else {
bias::Biaser::from_phrases(&self.tokenizer, phrases, boost)
};
if let Some(b) = &self.biaser {
tracing::info!(
"Hotword biasing enabled ({} phrase(s), boost {boost})",
b.phrase_count()
);
}
self
}
/// Whether a hotword biaser is attached (biasing active).
pub fn has_hotwords(&self) -> bool {
self.biaser.is_some()
}
/// Attach an optional Silero VAD plus its config, consuming and returning
/// `self` (builder style). Pass `None` for no VAD (the default): file
/// transcription then decodes the whole buffer and streaming endpointing is
/// byte-for-byte unchanged. When set, file transcription skips silence and
/// streaming endpointing is owned by VAD-detected trailing silence (the
/// decoder's blank-run heuristic is ignored).
pub fn with_vad(
mut self,
vad: Option<crate::vad::SileroVad>,
config: crate::vad::VadConfig,
) -> Self {
self.vad = vad;
self.vad_config = config;
if self.vad.is_some() {
tracing::info!(
"VAD enabled (threshold {}, min_silence {}ms)",
self.vad_config.threshold,
self.vad_config.min_silence_ms
);
}
self
}
/// Whether a VAD is attached (silence skipping / VAD endpointing active).
pub fn has_vad(&self) -> bool {
self.vad.is_some()
}
/// Boot-time VAD config (threshold, min silence, …). Useful when recreating
/// a per-session endpointer after `configure.min_silence_ms`.
pub fn vad_config(&self) -> &crate::vad::VadConfig {
&self.vad_config
}
/// Boot-time streaming endpoint mode for new sessions.
pub fn endpoint_mode(&self) -> EndpointMode {
self.endpoint_mode
}
/// Set the default streaming utterance-end policy for new sessions.
pub fn with_endpoint_mode(mut self, mode: EndpointMode) -> Self {
self.endpoint_mode = mode;
self
}
/// Size of the BPE vocabulary the loaded tokenizer covers. Exposed so the
/// REST `/v1/models` handler can report the real value instead of a
/// hardcoded literal that would drift if the upstream model rev changes.
pub fn vocab_size(&self) -> usize {
self.tokenizer.vocab_size()
}
}