Packages
Native Elixir bindings for whisper.cpp. Accepts 16 kHz mono f32 PCM and runs on CPU, CUDA, ROCm (hipBLAS), Metal, Vulkan, or CoreML.
Current section
Files
Jump to
Current section
Files
native/whisper_cpp_native/src/transcribe.rs
//! Run-one-transcription glue between the NIF entry point and
//! `whisper-rs`. Owns the decoding strategy, parameter setting, and
//! segment/word collection.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use crate::errors::{ErrorContext as _, inference_error, invalid_request};
use crate::vad::{self, VadOutcome};
use rustler::{Encoder, LocalPid, OwnedEnv};
use whisper_rs::{FullParams, SamplingStrategy, WhisperState};
/// Per-call decoding request decoded from the Elixir-side `TranscribeOpts`.
pub(crate) struct TranscribeRequest {
pub(crate) language: Option<String>,
pub(crate) translate: bool,
pub(crate) initial_prompt: Option<String>,
pub(crate) word_timestamps: bool,
pub(crate) beam_size: Option<u32>,
pub(crate) best_of: Option<u32>,
pub(crate) temperature: Option<f32>,
pub(crate) n_threads: Option<u32>,
pub(crate) n_max_text_ctx: Option<u32>,
pub(crate) offset_ms: Option<u32>,
pub(crate) duration_ms: Option<u32>,
pub(crate) no_speech_thold: Option<f32>,
pub(crate) logprob_thold: Option<f32>,
pub(crate) suppress_blank: Option<bool>,
pub(crate) suppress_non_speech_tokens: Option<bool>,
pub(crate) single_segment: Option<bool>,
pub(crate) print_progress: bool,
pub(crate) vad_model_path: Option<String>,
pub(crate) vad_threshold: Option<f32>,
pub(crate) vad_min_speech_ms: Option<u32>,
pub(crate) vad_min_silence_ms: Option<u32>,
pub(crate) vad_speech_pad_ms: Option<u32>,
}
pub(crate) struct WordResult {
pub(crate) text: String,
pub(crate) start: f32,
pub(crate) end: f32,
pub(crate) probability: f32,
}
pub(crate) struct SegmentResult {
pub(crate) text: String,
pub(crate) start: f32,
pub(crate) end: f32,
pub(crate) no_speech_prob: f32,
pub(crate) avg_logprob: f32,
pub(crate) tokens: Vec<u32>,
pub(crate) words: Option<Vec<WordResult>>,
}
pub(crate) struct TranscriptionResult {
pub(crate) language: String,
pub(crate) duration_s: f32,
pub(crate) segments: Vec<SegmentResult>,
}
/// Saturating cast for `u32` count-like values handed to whisper-rs
/// APIs that use `i32`. Realistic values (thread counts, beam sizes,
/// millisecond offsets) never overflow.
#[inline]
pub(crate) fn u32_to_i32(n: u32) -> i32 {
i32::try_from(n).unwrap_or(i32::MAX)
}
/// Resolve the caller's language request against whisper.cpp's language
/// table and the loaded model. `Ok(None)` means auto-detect.
///
/// English-only checkpoints never auto-detect: whisper.cpp's detection
/// pass scores language tokens the model was not trained with, so `nil`
/// and `"auto"` resolve to `"en"` and any other language is rejected.
fn resolve_language(requested: Option<&str>, multilingual: bool) -> anyhow::Result<Option<String>> {
match requested {
None | Some("auto") => {
if multilingual {
Ok(None)
} else {
Ok(Some("en".to_owned()))
}
}
Some(lang) => {
// Check NUL before `get_lang_id`: whisper-rs builds a CString
// from the value and panics on embedded NUL bytes.
if lang.contains('\0') || whisper_rs::get_lang_id(lang).is_none() {
return Err(invalid_request(format!(
"unknown language {lang:?}; pass an ISO 639-1 code whisper.cpp \
supports (e.g. \"de\"), a full language name (\"german\"), or \"auto\""
)));
}
if !multilingual && lang != "en" && lang != "english" {
return Err(invalid_request(format!(
"model is English-only; language {lang:?} is unavailable \
(use \"en\", \"auto\", or omit the option)"
)));
}
Ok(Some(lang.to_owned()))
}
}
}
/// Build a `FullParams` from the request. Sampling strategy is beam-search
/// when `beam_size > 1`, otherwise greedy. `language: None` selects
/// whisper.cpp's auto-detection.
fn build_params<'a>(req: &'a TranscribeRequest, language: Option<&'a str>) -> FullParams<'a, 'a> {
// whisper.cpp's default: 5 sampling candidates on the
// temperature-fallback passes, in both greedy and beam mode.
let best_of = u32_to_i32(req.best_of.unwrap_or(5));
let strategy = match req.beam_size {
Some(n) if n > 1 => SamplingStrategy::BeamSearch {
beam_size: u32_to_i32(n),
patience: -1.0,
},
_ => SamplingStrategy::Greedy { best_of },
};
let mut params = FullParams::new(strategy);
// The constructor only writes the chosen strategy's struct, but
// whisper.cpp reads greedy.best_of for fallback passes regardless.
params.set_greedy_best_of(best_of);
if let Some(t) = req.n_threads {
params.set_n_threads(u32_to_i32(t));
}
if let Some(c) = req.n_max_text_ctx {
params.set_n_max_text_ctx(u32_to_i32(c));
}
// With VAD active the window is cut from the original buffer before
// detection (see `transcribe_one`); whisper must then see the whole
// filtered buffer, not a window of it.
if req.vad_model_path.is_none() {
if let Some(o) = req.offset_ms {
params.set_offset_ms(u32_to_i32(o));
}
if let Some(d) = req.duration_ms {
params.set_duration_ms(u32_to_i32(d));
}
}
if let Some(t) = req.temperature {
params.set_temperature(t);
}
if let Some(v) = req.no_speech_thold {
params.set_no_speech_thold(v);
}
if let Some(v) = req.logprob_thold {
params.set_logprob_thold(v);
}
if let Some(b) = req.suppress_blank {
params.set_suppress_blank(b);
}
if let Some(b) = req.suppress_non_speech_tokens {
params.set_suppress_nst(b);
}
if let Some(b) = req.single_segment {
params.set_single_segment(b);
}
if req.translate {
params.set_translate(true);
}
// `None` clears whisper.cpp's "en" default and turns on auto-detection.
params.set_language(language);
if let Some(ref prompt) = req.initial_prompt {
params.set_initial_prompt(prompt);
}
params.set_token_timestamps(req.word_timestamps);
params.set_print_progress(req.print_progress);
params.set_print_realtime(false);
params.set_print_special(false);
params.set_print_timestamps(false);
params
}
/// Wire optional cooperative-cancellation and progress callbacks onto
/// the `FullParams`. Both hooks are no-ops when the caller omits them.
///
/// Progress messages cannot be sent directly from the callback because
/// it fires on the dirty-CPU scheduler thread where
/// `OwnedEnv::send_and_clear` panics. A dedicated sender thread owns
/// the `OwnedEnv` and reads percentages off an `mpsc` channel; the
/// callback only forwards new values. The closure owns the channel's
/// only `Sender`, and the vendored whisper-rs frees the abort and
/// progress closures it installs when `FullParams` drops (inside
/// `state.full`), so the channel closes there and the thread exits.
fn install_callbacks(
params: &mut FullParams<'_, '_>,
abort_flag: Option<Arc<AtomicBool>>,
progress_pid: Option<LocalPid>,
) {
if let Some(flag) = abort_flag {
params.set_abort_callback_safe(move || flag.load(Ordering::SeqCst));
}
let Some(pid) = progress_pid else { return };
let (tx, rx) = mpsc::channel::<i32>();
std::thread::spawn(move || {
while let Ok(pct) = rx.recv() {
let mut owned = OwnedEnv::new();
let _ = owned.send_and_clear(&pid, |env| {
let tag =
rustler::Atom::from_str(env, "whisper_progress").expect("atom name is valid");
(tag, pct).encode(env)
});
}
});
let mut last: i32 = -1;
params.set_progress_callback_safe(move |pct: i32| {
// whisper.cpp can report past 100 on its last chunk; the
// documented contract is 0..=100.
let pct = pct.clamp(0, 100);
if pct == last {
return;
}
last = pct;
// Receiver thread has exited if this errors; nothing to do.
let _ = tx.send(pct);
});
}
/// Transcribe a single PCM buffer. The context mutex is held only long
/// enough to call `create_state()`; `WhisperState` then carries its own
/// `Arc<WhisperInnerContext>`, so parallel transcribes on one loaded
/// model do not serialise.
pub(crate) fn transcribe_one(
model: &crate::WhisperResource,
samples: Vec<f32>,
request: &TranscribeRequest,
abort_flag: Option<Arc<AtomicBool>>,
progress_pid: Option<LocalPid>,
) -> anyhow::Result<TranscriptionResult> {
let token_eot = model.token_eot;
let language = resolve_language(request.language.as_deref(), model.multilingual)?;
if request.translate && !model.multilingual {
return Err(invalid_request(
"model is English-only; translate has nothing to translate from",
));
}
// whisper-rs's `set_initial_prompt` panics on embedded NUL bytes
// (CString conversion); reject them as a caller error instead.
if let Some(ref prompt) = request.initial_prompt
&& prompt.contains('\0')
{
return Err(invalid_request("initial_prompt must not contain NUL bytes"));
}
// VAD runs before any whisper state exists: silence-only audio
// returns the documented empty transcription without paying for an
// encoder pass. `:offset_ms`/`:duration_ms` are applied here, on
// the original timeline - whisper.cpp would otherwise window the
// silence-stripped buffer instead of the audio the caller meant.
let vad_outcome = match request.vad_model_path {
Some(ref path) => {
let (win_start, win_end) =
vad::window(request.offset_ms, request.duration_ms, samples.len());
Some(vad::filter_speech(
path,
request,
&samples[win_start..win_end],
win_start,
)?)
}
None => None,
};
#[allow(clippy::cast_precision_loss)]
let duration_s = samples.len() as f32 / 16_000.0_f32;
// With VAD, whisper only sees the filtered copy: free the original
// buffer now instead of holding both through the whole inference.
let (inference_samples, vad_ranges) = match vad_outcome {
Some(VadOutcome::NoSpeech) => {
// No detection ran; report the requested language or none.
return Ok(TranscriptionResult {
language: language.unwrap_or_default(),
duration_s,
segments: Vec::new(),
});
}
Some(VadOutcome::Speech { filtered, ranges }) => {
drop(samples);
(filtered, Some(ranges))
}
None => (samples, None),
};
// whisper.cpp polls the abort callback only inside `full()`; honour
// a flag raised during the VAD pass before paying for the encoder.
if abort_flag
.as_ref()
.is_some_and(|f| f.load(Ordering::SeqCst))
{
return Ok(TranscriptionResult {
language: language.unwrap_or_default(),
duration_s,
segments: Vec::new(),
});
}
let mut state: WhisperState = {
let ctx_guard = model.ctx.lock();
ctx_guard
.as_ref()
.expect("whisper context is only taken in Drop")
.create_state()
.inference_error_ctx("failed to create whisper state")?
};
let mut params = build_params(request, language.as_deref());
let abort_flag_check = abort_flag.clone();
install_callbacks(&mut params, abort_flag, progress_pid);
if let Err(e) = state.full(params, &inference_samples) {
let aborted = abort_flag_check.is_some_and(|f| f.load(Ordering::SeqCst));
if !aborted {
return Err(inference_error(format!("whisper.cpp full() failed: {e}")));
}
// Abort was requested by the caller: fall through and return the
// segments produced before cancellation as a partial result.
}
let n_segments = usize::try_from(state.full_n_segments()).unwrap_or(0);
let mut segments = Vec::with_capacity(n_segments);
for seg in state.as_iter() {
segments.push(extract_segment(&seg, request.word_timestamps, token_eot)?);
}
if let Some(ref ranges) = vad_ranges {
for seg in &mut segments {
seg.start = vad::remap_seconds(ranges, seg.start);
seg.end = vad::remap_seconds(ranges, seg.end);
if let Some(ref mut words) = seg.words {
for word in words {
word.start = vad::remap_seconds(ranges, word.start);
word.end = vad::remap_seconds(ranges, word.end);
}
}
}
}
// With no decoded segments there was no detection: echo the request
// (or "" when auto-detect found nothing to detect) instead of the
// state's lang_id default, which reads as a fabricated "en".
let language = if segments.is_empty() {
language.unwrap_or_default()
} else {
let id = state.full_lang_id_from_state();
whisper_rs::get_lang_str(id)
.map(str::to_owned)
.or(language)
.unwrap_or_default()
};
Ok(TranscriptionResult {
language,
duration_s,
segments,
})
}
fn extract_segment(
seg: &whisper_rs::WhisperSegment<'_>,
word_timestamps: bool,
token_eot: u32,
) -> anyhow::Result<SegmentResult> {
let text = seg
.to_str_lossy()
.map(std::borrow::Cow::into_owned)
.inference_error_ctx("failed to read segment text")?;
let start = cs_to_s(seg.start_timestamp());
let end = cs_to_s(seg.end_timestamp());
let no_speech_prob = seg.no_speech_probability();
let n_tokens = seg.n_tokens();
let token_cap = usize::try_from(n_tokens).unwrap_or(0);
let mut tokens = Vec::with_capacity(token_cap);
let mut total_logprob = 0.0_f32;
let mut counted: u32 = 0;
let mut word_tokens: Option<Vec<WordToken>> = if word_timestamps {
Some(Vec::new())
} else {
None
};
for t in 0..n_tokens {
let Some(tok) = seg.get_token(t) else {
continue;
};
let data = tok.token_data();
let id = data.id;
// Keep only text tokens: `id < token_eot` is the text/non-text boundary.
if let Ok(u) = u32::try_from(id)
&& u < token_eot
{
tokens.push(u);
}
total_logprob += data.plog;
counted += 1;
if let Some(ref mut buf) = word_tokens {
buf.push(WordToken {
bytes: tok.to_bytes().map(<[u8]>::to_vec).unwrap_or_default(),
t0: data.t0,
t1: data.t1,
p: data.p,
});
}
}
let words_acc = word_tokens.map(assemble_words);
#[allow(clippy::cast_precision_loss)]
let avg_logprob = if counted > 0 {
total_logprob / counted as f32
} else {
0.0
};
Ok(SegmentResult {
text,
start,
end,
no_speech_prob,
avg_logprob,
tokens,
words: words_acc,
})
}
/// One decoded token as fed to word assembly: raw text bytes plus
/// whisper.cpp's token-level timing and probability.
struct WordToken {
bytes: Vec<u8>,
t0: i64,
t1: i64,
p: f32,
}
/// Group decoded tokens into words. Token bytes are accumulated raw and
/// converted to UTF-8 once per finished word: Whisper's BPE regularly
/// splits a multibyte character across two tokens, so converting each
/// token on its own corrupts it (e.g. "schön" becomes "sch\u{FFFD}\u{FFFD}n").
fn assemble_words(tokens: Vec<WordToken>) -> Vec<WordResult> {
struct WordAcc {
bytes: Vec<u8>,
start: f32,
end: f32,
probability: f32,
}
impl WordAcc {
fn finish(self) -> WordResult {
WordResult {
text: String::from_utf8_lossy(&self.bytes).trim_start().to_owned(),
start: self.start,
end: self.end,
probability: self.probability,
}
}
}
let mut words = Vec::new();
let mut current: Option<WordAcc> = None;
for tok in tokens {
// Skip whisper.cpp special tokens for the word stream - they
// carry no acoustic word content.
if tok.bytes.starts_with(b"[_") || tok.bytes.starts_with(b"<|") {
continue;
}
// A leading space byte cannot be a fragment of a split
// codepoint: UTF-8 continuation bytes are always >= 0x80.
let starts_new_word = tok.bytes.first() == Some(&b' ') || current.is_none();
if starts_new_word {
if let Some(acc) = current.take() {
words.push(acc.finish());
}
current = Some(WordAcc {
start: cs_to_s(tok.t0),
end: cs_to_s(tok.t1),
probability: tok.p,
bytes: tok.bytes,
});
} else if let Some(ref mut acc) = current {
acc.bytes.extend_from_slice(&tok.bytes);
acc.end = cs_to_s(tok.t1);
// Worst-token probability, matching faster-whisper.
acc.probability = acc.probability.min(tok.p);
}
}
if let Some(acc) = current.take() {
words.push(acc.finish());
}
words
}
/// whisper.cpp reports timestamps in centiseconds (10 ms units).
#[inline]
fn cs_to_s(cs: i64) -> f32 {
#[allow(clippy::cast_precision_loss)]
let cs_f = cs as f32;
cs_f / 100.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::kind_from_chain;
#[test]
fn resolve_language_accepts_codes_and_full_names() {
assert_eq!(
resolve_language(Some("de"), true).unwrap(),
Some("de".to_owned())
);
assert_eq!(
resolve_language(Some("german"), true).unwrap(),
Some("german".to_owned())
);
assert_eq!(
resolve_language(Some("en"), false).unwrap(),
Some("en".to_owned())
);
}
#[test]
fn resolve_language_maps_nil_and_auto_per_model() {
assert_eq!(resolve_language(None, true).unwrap(), None);
assert_eq!(resolve_language(Some("auto"), true).unwrap(), None);
assert_eq!(
resolve_language(None, false).unwrap(),
Some("en".to_owned())
);
assert_eq!(
resolve_language(Some("auto"), false).unwrap(),
Some("en".to_owned())
);
}
#[test]
fn resolve_language_rejects_unknown_codes() {
for bad in ["de-CH", "gsw", "klingon", " ", "en\0", "\0", "de\0ch"] {
let err = resolve_language(Some(bad), true).unwrap_err();
assert_eq!(kind_from_chain(&err), Some("invalid_request"), "{bad:?}");
}
}
#[test]
fn resolve_language_rejects_non_english_on_english_only_models() {
let err = resolve_language(Some("de"), false).unwrap_err();
assert_eq!(kind_from_chain(&err), Some("invalid_request"));
}
fn word_token(bytes: &[u8], t0: i64, t1: i64, p: f32) -> WordToken {
WordToken {
bytes: bytes.to_vec(),
t0,
t1,
p,
}
}
#[test]
fn assemble_words_reassembles_codepoints_split_across_tokens() {
// "schön" with the "ö" (0xC3 0xB6) split across two BPE tokens.
let words = assemble_words(vec![
word_token(b" sch\xC3", 0, 10, 0.9),
word_token(b"\xB6n", 10, 20, 0.8),
]);
assert_eq!(words.len(), 1);
assert_eq!(words[0].text, "schön");
assert!((words[0].start - 0.0).abs() < f32::EPSILON);
assert!((words[0].end - 0.2).abs() < f32::EPSILON);
assert!((words[0].probability - 0.8).abs() < f32::EPSILON);
}
#[test]
fn assemble_words_splits_on_leading_space() {
let words = assemble_words(vec![
word_token(b" ask", 0, 10, 0.9),
word_token(b" not", 10, 20, 0.7),
]);
let texts: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
assert_eq!(texts, ["ask", "not"]);
}
#[test]
fn assemble_words_skips_special_tokens() {
let words = assemble_words(vec![
word_token(b"<|endoftext|>", 0, 0, 1.0),
word_token(b"[_TT_50]", 0, 0, 1.0),
word_token(b" hi", 0, 10, 0.9),
]);
assert_eq!(words.len(), 1);
assert_eq!(words[0].text, "hi");
}
}