Knowing what was said is only half the job. OxiWhisper 0.1.2 also answers who said it.
Today we released OxiWhisper 0.1.2 — a feature release that adds a full speaker-diarization pipeline, first-class word-level timestamps, a translation task, and temperature-fallback decoding to the Pure Rust Whisper engine.
The ground rule has not moved: OxiWhisper is still a Pure Rust Whisper inference engine with no C, no C++, and no Python anywhere in the build, compiling to a single static binary — or to WebAssembly — that runs Whisper inference end to end. The incumbents have not moved either. whisper.cpp is still a vendored C++ tree you cross-compile per target. Python + PyTorch still drags in libtorch and an interpreter. And if you wanted speaker labels on top of either one, the standard answer was pyannote.audio — a second Python/PyTorch dependency bolted onto your transcription stack just to know who was talking. 0.1.2 folds that second pipeline into the same Rust crate instead.
Why OxiWhisper 0.1.2
If 0.1.1 was about meeting the formats and hardware people actually have, 0.1.2 is about closing the gap between “Whisper transcribes text” and what a real transcription product needs: who is talking, where each word actually sits in time, and what to do when a decode attempt goes sideways. All three used to mean reaching outside Rust. Not anymore.
| Component | Status | Tests |
|---|---|---|
| Core inference (encoder/decoder) | Stable | 579 passing |
| Quantized inference (Q4_0/Q5_0/Q8_0) | Stable | 40+ |
| SIMD kernels (AVX2/NEON/WASM) | Stable | 15+ |
| Streaming API | Stable | 8+ |
| Word timestamps (monotonic peak) | Stable | 15+ |
| ONNX model loading | Stable | 28+ |
| Speaker diarization (VAD, clustering, RTTM/DER) | Stable* | 99+ |
* The pipeline — VAD, embedding, clustering, resegmentation, RTTM/DER export — is feature-complete and stable. Producing production-accurate speaker labels requires supplying an external speaker-embedding model (e.g. ECAPA-TDNN via ONNX); the built-in baseline embedder is intentionally low-accuracy and meant for tests and no-model demos only. More on this in Tips.
- 23,516 lines of Rust across 27 modules (up from 17,101 lines / 24 modules in 0.1.1), 579 tests in total (up from 399), and 12 examples, including a new
diarizeCLI. - A full speaker diarization pipeline landed behind a new
diarizationfeature: energy VAD, uniform sub-segmentation, per-window speaker embedding, cosine-affinity clustering (agglomerative or spectral, with automatic speaker-count estimation), resegmentation, and fusion with word timestamps. - Word-level timestamps graduate from an internal routine to a first-class API —
transcribe_words(). - A translation task and temperature-fallback decoding bring OxiWhisper to OpenAI parity on two behaviors it previously lacked.
- A beam-search bug that could silently return an empty transcription for audible speech is fixed, and malformed GGUF files are now rejected cleanly instead of panicking or attempting an unbounded allocation.
Technical Deep Dive
The core pipeline is unchanged — it is still the real Whisper encoder-decoder transformer:
Audio (WAV/f32) -> Mel Spectrogram (OxiFFT) -> Encoder (Conv + Transformer)
-> Decoder (Autoregressive + KV Cache + Beam Search) -> Tokenizer -> Text
0.1.2 adds a second pipeline alongside it, and deepens the first one at several points.
Diarization is a new stage built on top of the decoder’s output rather than inside it: energy-based VAD finds speech regions, those get sliced into uniform sub-segments, each sub-segment is embedded by a SpeakerEmbedder, cosine-affinity clustering (agglomerative or spectral, both with speaker-count estimation) groups the embeddings, resegmentation cleans up the boundaries, and the result fuses with word timestamps into a SpeakerTranscript. Two embedders ship: WhisperEncoderEmbedder, which mean-pools the audio encoder’s own features and is explicitly a low-accuracy baseline — Whisper’s encoder is trained to be speaker-invariant, not speaker-discriminative — and EcapaOnnx, which loads an external ECAPA-TDNN / x-vector checkpoint through oxionnx for real accuracy. The clustering and the symmetric eigensolver behind spectral clustering are hand-written inline in diarize — the feature pulls in no ndarray, no nalgebra, and deliberately no scirs2-cluster / scirs2-linalg.
Decoding got denser too. transcribe_words() wires the existing, fully-tested dtw.rs (align_tokens_dp_dtw, build_word_segments) into the public API as WordTimedTranscript / WordSegment, working with the greedy and temperature-sampling paths (beam_width > 1 currently returns ConfigError). A new Task enum (Transcribe / Translate) drives the <|translate|> (50358) token for any-language-to-English decoding. fallback_temperatures plus logprob_threshold implement OpenAI-style retry: a low-confidence or degenerate decode gets re-attempted at the next temperature in the schedule, a no-op by default. no_speech_prob is now captured from the raw prefill logits before any suppression across all three samplers, and ApplyTimestampRules — OpenAI’s timestamp-token forcing logic — runs automatically whenever timestamps are requested.
Audio ingestion moved to the Symphonia 0.6 API: decode_with_symphonia now uses GenericAudioBufferRef::copy_to_vec_interleaved in place of SampleBuffer, CodecParameters::Audio / AudioDecoderOptions in place of the old CODEC_TYPE_NULL / DecoderOptions, and Probe::probe() / make_audio_decoder() in place of their predecessors, with next_packet() now handling Ok(None) cleanly at end-of-stream — zero API change for callers of load_audio().
Robustness closed two real bugs. In decode_beam, a stop-token candidate seeded as a zero-token “done” beam could out-score every genuine hypothesis once scores were length-normalized, silently returning empty text for audible input; seeding now over-samples the top-k candidates and filters stop tokens before seeding. And in src/gguf/parse.rs / src/gguf/spec.rs, tensor sizes are now computed with checked arithmetic and validated against the actual file length before allocation, so a truncated or adversarially-crafted .gguf file returns OxiWhisperError::InvalidModel instead of panicking or attempting a multi-gigabyte allocation.
Getting Started
cargo add oxiwhisper
use oxiwhisper::{WhisperModel, TranscribeOptions};
use std::path::Path;
fn main() -> Result<(), oxiwhisper::OxiWhisperError> {
let model = WhisperModel::from_file(Path::new("ggml-tiny.bin"))?;
let audio = oxiwhisper::audio::load_wav(Path::new("audio.wav"))?;
let text = model.transcribe(&audio, &TranscribeOptions::default())?;
println!("{text}");
Ok(())
}
That is still the whole basic flow — load a GGML or GGUF checkpoint, read a WAV, transcribe. Diarization, word timestamps, and translation are opt-in from here; see Tips below.
What’s New in 0.1.2
- Speaker diarization (new
diarizationfeature, off by default):WhisperModel::diarize/diarize_with_embedderanswer “who spoke when,”transcribe_with_speakers/transcribe_with_speakers_using_embedderanswer “who spoke what.” NIST RTTM export (write_rttm,rttm_string),[SPEAKER_k]-labeled transcripts, and DER/JER evaluation against a reference RTTM with Hungarian speaker mapping.examples/diarize.rsships as a ready-to-run CLI. - Word-level timestamps as a public API —
transcribe_words(),WordTimedTranscript,WordSegment; opt in viaTranscribeOptions::word_timestamps(defaultfalse, zero overhead when off). - Translation task — set
TranscribeOptions::tasktoTask::Translateto decode to English;Task::Transcriberemains the default, unchanged for existing callers. - Temperature fallback decoding —
fallback_temperaturesandlogprob_thresholdonTranscribeOptions. - Progress callbacks —
transcribe_long_with_progress,transcribe_long_segmented_with_progress, andtranscribe_long_with_vad_with_progressacceptFnMut(chunk_index: usize, total: usize); the original methods delegate through a no-op closure and keep their old signatures. no_speech_threshold(default0.6) andsuppress_blank(defaulttrue) onTranscribeOptions— both on by default now, a behavior change from 0.1.1; setsuppress_blank: falseif your code relied on the old defaults.- Changed: migrated to the Symphonia 0.6 decoding API (
src/audio.rs) — no caller-visible change. - Fixed: the beam-search empty-transcription bug in
decode_beam(src/beam_search.rs). - Fixed: malformed/truncated GGUF files now fail cleanly with
OxiWhisperError::InvalidModelinstead of panicking or over-allocating (src/gguf/parse.rs,src/gguf/spec.rs).
Tips
- Enable
diarization, but bring your own embedder for real accuracy.WhisperModel::diarizeandtranscribe_with_speakerswork with zero extra downloads, but they default toWhisperEncoderEmbedder— a baseline that mean-pools features out of Whisper’s own (speaker-invariant) encoder, so it clusters only weakly by speaker. It exists for tests and no-model demos, not production. For real speaker discrimination, build withdiarization,onnx, load an external ECAPA-TDNN / x-vector ONNX checkpoint withEcapaOnnx::from_path, and pass it todiarize_with_embedder/transcribe_with_speakers_using_embedderinstead. - Try the CLI first.
cargo run --example diarize --features diarization -- <model> <audio> --speakers 2gets you a labeled transcript with no code; add--embedder ecapa:<path-to-onnx-model>(with--features diarization,onnx) for the real embedder,--clustering ahc|spectralto pick the clustering strategy, and--rttmfor NIST RTTM output. - Reach for
transcribe_words()when you need per-word timing, not just per-segment. It works with greedy and temperature-sampled decoding today;beam_width > 1currently returnsConfigError, so drop beam search for word-timed runs. - Set
task: Task::TranslateonTranscribeOptionsfor any-language-to-English output — it is a one-field change, andTask::Transcribestays the default so nothing else in your code needs to move. - Give shaky decodes a second attempt. Populate
fallback_temperatures(e.g.&[0.2, 0.4, 0.6, 0.8, 1.0]) andlogprob_thresholdonTranscribeOptions; OxiWhisper retries at the next temperature whenever a decode looks degenerate, OpenAI-style. Leave the slice empty — the default — to keep the old single-pass behavior. - Double-check
suppress_blankafter upgrading from 0.1.1. It is nowtrueby default, matching OpenAI’sSuppressBlank; if your code depended on selecting the leading-space token or EOT at step 0, set it back tofalseexplicitly.
This is the foundation
OxiWhisper’s place in the COOLJAPAN stack has not moved — it is still the speech-recognition layer, anchored by OxiFFT for the mel front end. What is new this release is how far oxionnx reaches: it used to be only the optional path for loading ONNX Whisper checkpoints, and now it also carries real speaker embeddings into the diarization pipeline via EcapaOnnx. Diarization’s clustering and eigensolver deliberately do not reach for SciRS2’s scirs2-cluster / scirs2-linalg — they are hand-written inline, keeping the feature’s dependency footprint as small as its accuracy claims are honest. SciRS2 and NumRS2 remain the numerical bedrock elsewhere in the ecosystem, and VoiRS stays the natural companion on the text-to-speech side — between the two, a Pure Rust project can now find out who said what and say something back, with no foreign-language runtime anywhere in the loop.
Repository: https://github.com/cool-japan/oxiwhisper
Star the repo if speaker-attributed, word-timestamped Pure Rust transcription — without a second Python toolchain bolted on — is what you have been waiting for.
Pure Rust speech recognition is here — fast, safe, and sovereign.
— KitaSan at COOLJAPAN OÜ July 11, 2026