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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
//! Rust port of WhisperKit (speech-to-text on CoreML).
//!
//! Pure-Rust port of Argmax's WhisperKit pipeline on top of the `coremlit`
//! CoreML runtime layer. Unlike `coremlit`'s flat re-exports, this crate
//! exposes its modules publicly per the spec's module map (constants,
//! errors, options, tokenizer, model, audio, decode, transcribe, ...);
//! later plans fill the map in task by task.
//!
//! macOS only. Swift source of truth: `argmax-oss-swift`.
//!
//! # Module map
//!
//! - [`constants`] — sample rate, window sizes, the language table.
//! - [`options`] — [`DecodingOptions`](options::DecodingOptions) (per-run
//! knobs), [`Options`](options::Options) (construction config),
//! [`ComputeOptions`](options::ComputeOptions).
//! - [`error`] — per-domain error types composing into
//! [`TranscribeError`](error::TranscribeError).
//! - [`result`] — transcription value types and the temperature-fallback
//! decision ([`needs_fallback`](result::needs_fallback)), plus the
//! [`writer`](result::writer) submodule's transcript writers:
//! [`SrtWriter`](result::writer::SrtWriter) and
//! [`VttWriter`](result::writer::VttWriter) always, and — behind the
//! `serde` feature — `JsonWriter`, all behind the shared
//! [`ResultWriter`](result::writer::ResultWriter) trait.
//! - [`tokenizer`] — the Whisper tokenizer facade and special tokens.
//! - [`model`] — model-lifecycle vocabulary (states, variants, folder
//! detection, device support) and
//! [`ModelManager`](model::manager::ModelManager), the coalesced
//! load/prewarm/unload orchestrator over
//! [`LoadedModels`](model::manager::LoadedModels).
//! - [`audio`] — sans-I/O DSP over 16 kHz mono PCM: pad/trim, energy,
//! VAD, long-form chunking.
//! - [`backend`] — [`InferenceBackend`](backend::InferenceBackend) trait
//! (mel/encode/decode-step seam), [`ModelDims`](backend::ModelDims),
//! [`AlignmentView`](backend::AlignmentView) (the borrowed
//! cross-attention alignment slice word-timestamp code reads), the real
//! [`CoreMlBackend`](backend::coreml::CoreMlBackend) driving the three
//! loaded CoreML models, and the scripted
//! [`MockBackend`](backend::mock::MockBackend) test double used for
//! hermetic pipeline tests.
//! - [`text`] — zlib compression-ratio repetition signal
//! ([`text::compression_ratio_of_tokens`]) and Whisper string
//! normalization/trimming ([`text::normalized`],
//! [`text::trim_special_token_chars`]), consumed by the decode loop's
//! fallback checks and language post-processing.
//! - [`decode`] — autoregressive decoding: the per-window loop
//! ([`decode::decode_text`]) and one-shot language detection
//! ([`decode::detect_language`]) driven against an
//! [`InferenceBackend`](backend::InferenceBackend), the prefill-prompt
//! assembly that feeds both ([`decode::prefill_tokens`]), the per-step
//! [`LogitsFilter`](decode::filter::LogitsFilter) chain the loop runs
//! against each step's raw logits, and the
//! [`GreedyTokenSampler`](decode::sampler::GreedyTokenSampler) that
//! picks the next token from what the chain leaves unmasked.
//! - [`segment`] — how a decoded window becomes
//! [`TranscriptionSegment`](result::TranscriptionSegment)s and the next
//! seek offset
//! ([`find_seek_point_and_segments`](segment::find_seek_point_and_segments)),
//! plus the word-timestamp math
//! [`transcribe::TranscribeTask::run`] wires into the pipeline behind
//! [`DecodingOptions::word_timestamps`](options::DecodingOptions::word_timestamps):
//! [`dynamic_time_warping`](segment::dynamic_time_warping) over a
//! decoded-token x audio-frame alignment matrix,
//! [`find_alignment`](segment::find_alignment),
//! [`merge_punctuations`](segment::merge_punctuations), the word-duration
//! heuristics
//! ([`calculate_word_duration_constraints`](segment::calculate_word_duration_constraints),
//! [`truncate_long_words_at_sentence_boundaries`](segment::truncate_long_words_at_sentence_boundaries)),
//! and the orchestrating
//! [`add_word_timestamps`](segment::add_word_timestamps).
//! - [`transcribe`] — [`WhisperKit`](transcribe::WhisperKit), the public
//! pipeline entry point (`transcribe`/`transcribe_all`/
//! `detect_language`), composing
//! [`TranscribeTask`](transcribe::TranscribeTask) — the seek/window loop
//! and temperature-fallback ladder that drives the decode stack over a
//! full audio buffer — and, for VAD-chunked audio, folding per-chunk
//! results together via
//! [`merge_transcription_results_with_options`](result::merge_transcription_results_with_options),
//! which is equally the entry point for folding a `transcribe_all` batch
//! by hand ([`merge_transcription_results`](result::merge_transcription_results)
//! is its options-blind, exactly-Swift sibling).
//! - [`stream`] — push-based streaming vocabulary (spec §5.3 `stream`
//! row): [`StreamState`](stream::StreamState) (Swift's
//! `AudioStreamTranscriber.State`),
//! [`AudioStreamOptions`](stream::AudioStreamOptions),
//! [`StreamUpdate`](stream::StreamUpdate), and the early-stop gate
//! [`should_stop_early`](stream::should_stop_early) (Swift's static
//! `shouldStopEarly`), driving the push-based state machine
//! [`AudioStreamTranscriber`](stream::AudioStreamTranscriber): push new
//! samples through
//! [`push_samples`](stream::AudioStreamTranscriber::push_samples) as
//! they arrive and read [`StreamState`](stream::StreamState) back for
//! the session's live transcript. [`stream::agreement`] adds the
//! LocalAgreement-2 confirmation engine
//! [`LocalAgreement`](stream::agreement::LocalAgreement) and its
//! simulated-stream driver
//! [`LocalAgreementTranscriber`](stream::agreement::LocalAgreementTranscriber).
//! [`WhisperKit`](transcribe::WhisperKit) builds either streamer from an
//! already-constructed pipeline
//! ([`audio_stream_transcriber`](transcribe::WhisperKit::audio_stream_transcriber)/
//! [`local_agreement_transcriber`](transcribe::WhisperKit::local_agreement_transcriber)).
//! - [`task_facts`] — [`TaskFacts`](task_facts::TaskFacts), the single record
//! of the decode-time facts a run *controls* (RNG draw, observed language,
//! early-stop truncation, worker coordinates, allocated id span), with the
//! one associative [merge law](task_facts::TaskFacts::merge) every merge
//! entry point calls. Carried on every
//! [`TranscriptionResult`](result::TranscriptionResult) and embedded in
//! [`Provenance`](provenance::Provenance).
//! - [`log`] — leveled logging with a replacing callback.
//!
//! # Reproducibility and provenance
//!
//! The same audio through the same model can produce different text,
//! tokens, and segments when decode options drift — coremlit issue #9's
//! round-1–4 validation found exact Rust/Swift parity under one pinned
//! configuration and observable divergence when single knobs moved (e.g.
//! VAD-chunked with prefill was parity-pass; the same run without prefill
//! was not). Consumers that index, snapshot, or regression-test
//! transcripts should therefore set the decode policy **explicitly**
//! rather than relying on defaults, and record the full configuration
//! alongside every stored transcript or benchmark artifact:
//!
//! - model folder identity and revision (e.g. the Hugging Face repo id +
//! revision the `.mlmodelc` folder came from — this crate loads local
//! folders and does not know their provenance;
//! [`Options::model_folder`](options::Options::model_folder) is only
//! the path)
//! - tokenizer identity and revision (same caveat;
//! [`Options::tokenizer_folder`](options::Options::tokenizer_folder))
//! - compute units, per stage
//! ([`ComputeOptions`](options::ComputeOptions))
//! - chunking strategy
//! ([`DecodingOptions::chunking_strategy`](options::DecodingOptions::chunking_strategy))
//! - language, when known
//! ([`DecodingOptions::language`](options::DecodingOptions::language) —
//! set it explicitly instead of leaving auto-detection to pick one)
//! - prefill
//! ([`DecodingOptions::use_prefill_prompt`](options::DecodingOptions::use_prefill_prompt))
//! - special-token skipping
//! ([`DecodingOptions::skip_special_tokens`](options::DecodingOptions::skip_special_tokens))
//! - word timestamps
//! ([`DecodingOptions::word_timestamps`](options::DecodingOptions::word_timestamps))
//! - VAD strategy (the detector driving VAD chunking —
//! [`EnergyVad`](audio::vad::EnergyVad) by default, swappable via
//! [`WhisperKit::set_vad_detector`](transcribe::WhisperKit::set_vad_detector);
//! Silero VAD is available opt-in behind the `vadkit` feature as
//! `silero_vad::SileroVad`)
//!
//! [`Provenance`](provenance::Provenance) assembles that record for you
//! (coremlit issue #14). Of the items above, the **library-known** ones are
//! captured by
//! [`Provenance::from_options`](provenance::Provenance::from_options) —
//! which **embeds the entire resolved
//! [`DecodingOptions`](options::DecodingOptions)**
//! ([`Provenance::decoding`](provenance::Provenance::decoding)) rather than
//! copying a chosen handful of its knobs into flat fields of its own,
//! alongside the [`ComputeOptions`](options::ComputeOptions) and the
//! *effective* decode temperature.
//!
//! "Wholesale" is load-bearing and not a figure of speech. A curated
//! projection is a list somebody must remember to extend, and this one had
//! already drifted to 11 of 30 knobs — silently dropping, among others,
//! `drop_blank_audio` and `word_grouping`, both of which visibly change the
//! transcript, so two runs differing only in them left byte-identical
//! records. Embedding the struct makes the record complete **by
//! construction**: an option added tomorrow is recorded with no change to
//! the provenance code, and `provenance`'s mutation table — whose coverage
//! is derived from [`DecodingOptions`](options::DecodingOptions)' own field
//! set, not hand-written — fails the suite if one ever is not.
//! `alignment_gather` (whisper #41) is a third such transcript-changing knob,
//! captured with no change to the provenance code.
//!
//! The remaining three are **consumer-supplied**: settable `Option` fields,
//! left `None` and never guessed, because this crate genuinely cannot
//! observe them.
//!
//! - Model identity and tokenizer identity — it loads bare local folders,
//! so it never sees a repo id or a revision.
//! - The **VAD detector**
//! ([`Provenance::vad_detector`](provenance::Provenance::vad_detector)).
//! This one is not an oversight: the pipeline holds the detector as a
//! `Box<dyn `[`VoiceActivityDetector`](audio::vad::VoiceActivityDetector)`>`
//! ([`WhisperKit::vad_detector`](transcribe::WhisperKit::vad_detector)),
//! and a trait object carries no identity to read — nor does it live in
//! either options struct, so
//! [`from_options`](provenance::Provenance::from_options) could not
//! reach it even if it had a name. The record therefore says *whether*
//! VAD chunking ran
//! ([`chunking_strategy`](options::DecodingOptions::chunking_strategy)),
//! never *which* detector drove it. Since a swapped detector moves the
//! chunk boundaries — and the boundaries move the transcript — a
//! consumer that calls
//! [`set_vad_detector`](transcribe::WhisperKit::set_vad_detector) should
//! name it here, or two runs that differ only in detector leave
//! byte-identical records with no trace of what made their text differ.
//!
//! Under the `serde` feature the whole record serializes (unset
//! consumer-supplied fields omitted, so they can never masquerade as a
//! known `null`), as do [`DecodingOptions`](options::DecodingOptions),
//! [`ComputeOptions`](options::ComputeOptions), and
//! [`Options`](options::Options) individually.
//!
//! Provenance-adjacent behaviors to plan for:
//!
//! - **Compute units affect output.** The same model/audio/options can
//! yield different transcripts across `cpuOnly`/`cpuAndGPU`/
//! `cpuAndNeuralEngine`/`all` — CoreML backend numeric drift, not a bug
//! in this port (Rust and Swift match each other when the unit
//! matches). Never compare outputs across compute units as if
//! equivalent: fix one unit for regression baselines, or keep a
//! separate baseline per unit.
//! - **Silence decodes to a marker, and this crate drops it by default.**
//! Confirmed under the validated configuration coremlit issue #9 pinned
//! (tiny model, matched VAD/prefill options, 5 s of digital silence):
//! the window decodes to exactly
//! [`BLANK_AUDIO_MARKER`](constants::BLANK_AUDIO_MARKER) — see that
//! constant's doc for the general, model/config-dependent shape of this
//! behavior. Rather than leave every product layer to post-filter that,
//! [`DecodingOptions::drop_blank_audio`](options::DecodingOptions::drop_blank_audio)
//! defaults `true` and removes such segments after decoding, so pure
//! silence yields an empty result (coremlit issue #14). This is the one
//! default in this crate that deliberately diverges from Swift, which
//! emits the marker; set the option `false` for exact Swift parity.
//! - **Sampling above temperature 0 is non-reproducible by default, on
//! both runtimes — set [`DecodingOptions::seed`](options::DecodingOptions::seed) to make it
//! reproducible on this side.** Whenever the fallback ladder retries at
//! `temperature > 0`, upstream Swift draws from an unseeded RNG
//! (`Float.random`, `TokenSampler.swift:169`), and with
//! [`DecodingOptions::seed`](options::DecodingOptions::seed) left `None` (the default) this port's
//! sampler matches that non-determinism
//! ([`GreedyTokenSampler::new`](decode::sampler::GreedyTokenSampler::new)
//! seeds from the OS (`StdRng::from_rng` fed the OS-seeded
//! `rand::rng()` thread RNG, so its full internal state is OS-derived,
//! not just a 64-bit value) — two identical unseeded
//! runs that fall back can legitimately differ, by design, exactly like
//! Swift, and this is the byte-unchanged default path. Setting
//! [`DecodingOptions::seed`](options::DecodingOptions::seed) to `Some(_)` makes the full
//! [`WhisperKit::transcribe`](transcribe::WhisperKit::transcribe)/
//! [`transcribe_all`](transcribe::WhisperKit::transcribe_all) pipeline
//! **bit-reproducible across runs, with the fallback ladder still fully
//! enabled**: [`transcribe::TranscribeTask`] derives an independent
//! per-(worker, window, attempt) sub-seed from the base seed
//! ([`decode::sampler::derive_attempt_seed`] — see its doc for the exact
//! mixing function and why one seed can't just be reused verbatim
//! everywhere), so the same audio/options/seed always samples the same
//! tokens end to end, while different windows and different fallback
//! attempts still draw distinct, uncorrelated streams. A seed makes
//! **this port's own** output reproducible; it does not, and cannot,
//! make that output match Swift's draws — Swift has no seed knob at
//! all, and its sampling stays unseeded regardless of anything set on
//! this side. Direct callers of [`decode::decode_text`]/
//! [`decode::detect_language`] (this crate's own tests included) sit one
//! layer below the pipeline and supply their own sampler; they can call
//! [`GreedyTokenSampler::with_seed`](decode::sampler::GreedyTokenSampler::with_seed)
//! directly (optionally through
//! [`decode::sampler::derive_attempt_seed`] too, to replicate the
//! pipeline's own seed schedule) for the same reproducibility at that
//! layer. Either way, seeded or not, record the effective temperature
//! (the initial value plus any fallback increments actually taken) in
//! provenance — already carried per window by
//! [`TranscriptionSegment::temperature`](result::TranscriptionSegment::temperature)/
//! [`DecodingResult::temperature`](result::DecodingResult::temperature) —
//! since it marks exactly which outputs carry sampling randomness.
//! - **Swift CLI comparison pitfall: `--language` forces prefill on.**
//! The upstream Swift CLI resolves
//! `usePrefillPrompt: arguments.usePrefillPrompt || arguments.language
//! != nil || task == .translate` (`TranscribeCLIUtils.swift:56`), so
//! passing `--language` to it silently turns prefill ON regardless of
//! the flag under test. A parity or benchmark harness that sets
//! `--language` while intending a no-prefill configuration is
//! comparing mismatched configurations — pin the language through
//! [`DecodingOptions::language`](options::DecodingOptions::language)
//! on this side and double-check what the Swift side actually resolved
//! before attributing any divergence to the ports.
//!
//! # Examples
//!
//! ```no_run
//! use coremlit::audio::whisper::options::{DecodingOptions, Options};
//! use coremlit::audio::whisper::transcribe::WhisperKit;
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let options = Options::new(
//! "Models/whisperkit-coreml/openai_whisper-tiny",
//! "Models/tokenizers/whisper-tiny",
//! );
//! let kit = WhisperKit::new(&options)?;
//! let audio: Vec<f32> = vec![0.0; 16_000]; // 1 s of 16 kHz mono PCM
//! let result = kit.transcribe(&audio, &DecodingOptions::new())?;
//! println!("{}", result.text());
//! # Ok(())
//! # }
//! ```
//!
//! Fallback-ladder decisions are pure functions over
//! [`DecodingOptions`](options::DecodingOptions)/
//! [`DecodingResult`](result::DecodingResult), independently testable
//! without a loaded model — a window whose compression ratio crosses the
//! threshold asks for a retry at the next temperature:
//!
//! ```
//! use coremlit::audio::whisper::options::{ChunkingStrategy, DecodingOptions};
//! use coremlit::audio::whisper::result::{DecodingResult, FallbackReason, needs_fallback};
//!
//! let options = DecodingOptions::new()
//! .with_temperature(0.2)
//! .with_chunking_strategy(ChunkingStrategy::Vad);
//! assert_eq!(options.temperature(), 0.2);
//!
//! // The first-token flag comes from the decode loop (see
//! // `result::needs_fallback`).
//! let repetitive = DecodingResult::new()
//! .with_avg_logprob(-0.4)
//! .with_no_speech_prob(0.1)
//! .with_compression_ratio(3.4);
//! assert_eq!(
//! needs_fallback(false, &repetitive, &options),
//! Some(FallbackReason::CompressionRatioThreshold),
//! );
//! ```
//!
//! # Streaming example
//!
//! ```no_run
//! use coremlit::audio::whisper::options::{DecodingOptions, Options};
//! use coremlit::audio::whisper::transcribe::WhisperKit;
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let options = Options::new(
//! "Models/whisperkit-coreml/openai_whisper-tiny",
//! "Models/tokenizers/whisper-tiny",
//! );
//! let kit = WhisperKit::new(&options)?;
//! let mut streamer = kit.audio_stream_transcriber(DecodingOptions::new());
//! loop {
//! let samples: Vec<f32> = vec![0.0; 16_000]; // 1 s of 16 kHz mono from the caller's source
//! let update = streamer.push_samples(&samples)?;
//! if update.is_transcribed() {
//! for segment in streamer.state().confirmed_segments_slice() {
//! println!("confirmed: {}", segment.text());
//! }
//! break;
//! }
//! }
//! # Ok(())
//! # }
//! ```
/// The one rule every door here that joins a caller's string to a directory
/// asks first: is that string ONE plain path component? (#114, #120.)
pub
/// Silero VAD (`vadkit`-backed) as an opt-in [`VoiceActivityDetector`], behind
/// the `vadkit` feature — the energy VAD stays the default.
///
/// [`VoiceActivityDetector`]: audio::vad::VoiceActivityDetector