1 //! onde-wasm — Whisper speech-to-text compiled to WebAssembly.
2 //!
3 //! This crate exposes a single [`WhisperDecoder`] class to JavaScript via
4 //! `wasm-bindgen`. It is backed by `candle-transformers`' pure-Rust Whisper
5 //! implementation, which compiles cleanly to `wasm32-unknown-unknown` without
6 //! any C FFI or platform intrinsics.
7 //!
8 //! # JavaScript API
9 //!
10 //! ```js
11 //! import init, { WhisperDecoder } from "./onde_wasm.js";
12 //!
13 //! await init();
14 //!
15 //! // All four byte arrays are fetched from HuggingFace (see build.sh).
16 //! const decoder = new WhisperDecoder(
17 //! weightsBytes, // model.safetensors — or model-tiny-en-q80.gguf
18 //! tokenizerBytes, // tokenizer.json
19 //! melFiltersBytes, // mel_filters.safetensors
20 //! configBytes, // config.json
21 //! quantized, // bool — true for .gguf weights
22 //! isMultilingual, // bool — false for *.en models
23 //! timestamps, // bool — include per-segment timestamps
24 //! task, // "transcribe" | "translate" | null
25 //! language, // BCP-47 string | null (null = auto-detect)
26 //! );
27 //!
28 //! // wavBytes is a Uint8Array of a 16 kHz mono WAV file.
29 //! const json = decoder.decode(wavBytes);
30 //!
31 //! // json is a string with the shape:
32 //! // {
33 //! // "text": "Full transcript…",
34 //! // "segments": [
35 //! // { "start": 0.0, "end": 3.5, "text": " Hello world" },
36 //! // …
37 //! // ]
38 //! // }
39 //! const result = JSON.parse(json);
40 //! ```
41 //!
42 //! # Result JSON shape
43 //!
44 //! The JSON string returned by [`WhisperDecoder::decode`] mirrors the
45 //! `WhisperResult` / `WhisperSegment` types in `onde::whisper` so that the
46 //! same TypeScript interface can be reused across both the native Tauri app
47 //! and the browser connected-devices app.
48 //!
49 //! ```ts
50 //! interface OndeSegment { start: number; end: number; text: string; }
51 //! interface OndeResult { text: string; segments: OndeSegment[]; }
52 //! ```
53 //!
54 //! # Model assets
55 //!
56 //! | File | Source |
57 //! |---|---|
58 //! | `model.safetensors` | `openai/whisper-tiny` (or tiny.en, base, …) |
59 //! | `model-tiny-en-q80.gguf` | `lmz/candle-whisper` (quantized) |
60 //! | `tokenizer.json` | same HF repo |
61 //! | `config.json` | same HF repo |
62 //! | `mel_filters.safetensors` | `huggingface/candle-whisper` space assets |
63 //!
64 //! # Build
65 //!
66 //! ```sh
67 //! wasm-pack build --target web --out-dir pkg
68 //! ```
69 //!
70 //! See `onde/crates/onde-wasm/build.sh` for the full reproducible build
71 //! script with asset download instructions.
72
73 use anyhow::anyhow;
74 use candle_core::{safetensors::Load, Device, IndexOp, Tensor, D};
75 use candle_nn::{ops::softmax, VarBuilder};
76 use candle_transformers::models::whisper::{self as m, Config};
77 use rand::{distr::Distribution, rngs::StdRng, SeedableRng};
78 use serde::{Deserialize, Serialize};
79 use tokenizers::Tokenizer;
80 use wasm_bindgen::prelude::*;
81
82 // ---------------------------------------------------------------------------
83 // Panic hook
84 // ---------------------------------------------------------------------------
85
86 fn set_panic_hook() {
87 #[cfg(feature = "console_error_panic_hook")]
88 console_error_panic_hook::set_once();
89 }
90
91 // ---------------------------------------------------------------------------
92 // Browser console bridge
93 // ---------------------------------------------------------------------------
94
95 #[wasm_bindgen]
96 extern "C" {
97 #[wasm_bindgen(js_namespace = console, js_name = log)]
98 fn console_log(s: &str);
99
100 #[wasm_bindgen(js_namespace = console, js_name = warn)]
101 fn console_warn(s: &str);
102 }
103
104 macro_rules! clog {
105 ($($t:tt)*) => { console_log(&format!("[onde-wasm] {}", format_args!($($t)*))) }
106 }
107
108 macro_rules! cwarn {
109 ($($t:tt)*) => { console_warn(&format!("[onde-wasm] {}", format_args!($($t)*))) }
110 }
111
112 // ---------------------------------------------------------------------------
113 // Language table
114 //
115 // Copied verbatim from candle-wasm-examples/whisper/src/languages.rs at
116 // rev c3bb5bf. This constant is NOT re-exported by candle-transformers
117 // itself at that revision.
118 // ---------------------------------------------------------------------------
119
120 const LANGUAGES: [(&str, &str); 99] = [
121 ("en", "english"),
122 ("zh", "chinese"),
123 ("de", "german"),
124 ("es", "spanish"),
125 ("ru", "russian"),
126 ("ko", "korean"),
127 ("fr", "french"),
128 ("ja", "japanese"),
129 ("pt", "portuguese"),
130 ("tr", "turkish"),
131 ("pl", "polish"),
132 ("ca", "catalan"),
133 ("nl", "dutch"),
134 ("ar", "arabic"),
135 ("sv", "swedish"),
136 ("it", "italian"),
137 ("id", "indonesian"),
138 ("hi", "hindi"),
139 ("fi", "finnish"),
140 ("vi", "vietnamese"),
141 ("he", "hebrew"),
142 ("uk", "ukrainian"),
143 ("el", "greek"),
144 ("ms", "malay"),
145 ("cs", "czech"),
146 ("ro", "romanian"),
147 ("da", "danish"),
148 ("hu", "hungarian"),
149 ("ta", "tamil"),
150 ("no", "norwegian"),
151 ("th", "thai"),
152 ("ur", "urdu"),
153 ("hr", "croatian"),
154 ("bg", "bulgarian"),
155 ("lt", "lithuanian"),
156 ("la", "latin"),
157 ("mi", "maori"),
158 ("ml", "malayalam"),
159 ("cy", "welsh"),
160 ("sk", "slovak"),
161 ("te", "telugu"),
162 ("fa", "persian"),
163 ("lv", "latvian"),
164 ("bn", "bengali"),
165 ("sr", "serbian"),
166 ("az", "azerbaijani"),
167 ("sl", "slovenian"),
168 ("kn", "kannada"),
169 ("et", "estonian"),
170 ("mk", "macedonian"),
171 ("br", "breton"),
172 ("eu", "basque"),
173 ("is", "icelandic"),
174 ("hy", "armenian"),
175 ("ne", "nepali"),
176 ("mn", "mongolian"),
177 ("bs", "bosnian"),
178 ("kk", "kazakh"),
179 ("sq", "albanian"),
180 ("sw", "swahili"),
181 ("gl", "galician"),
182 ("mr", "marathi"),
183 ("pa", "punjabi"),
184 ("si", "sinhala"),
185 ("km", "khmer"),
186 ("sn", "shona"),
187 ("yo", "yoruba"),
188 ("so", "somali"),
189 ("af", "afrikaans"),
190 ("oc", "occitan"),
191 ("ka", "georgian"),
192 ("be", "belarusian"),
193 ("tg", "tajik"),
194 ("sd", "sindhi"),
195 ("gu", "gujarati"),
196 ("am", "amharic"),
197 ("yi", "yiddish"),
198 ("lo", "lao"),
199 ("uz", "uzbek"),
200 ("fo", "faroese"),
201 ("ht", "haitian creole"),
202 ("ps", "pashto"),
203 ("tk", "turkmen"),
204 ("nn", "nynorsk"),
205 ("mt", "maltese"),
206 ("sa", "sanskrit"),
207 ("lb", "luxembourgish"),
208 ("my", "myanmar"),
209 ("bo", "tibetan"),
210 ("tl", "tagalog"),
211 ("mg", "malagasy"),
212 ("as", "assamese"),
213 ("tt", "tatar"),
214 ("haw", "hawaiian"),
215 ("ln", "lingala"),
216 ("ha", "hausa"),
217 ("ba", "bashkir"),
218 ("jw", "javanese"),
219 ("su", "sundanese"),
220 ];
221
222 // ---------------------------------------------------------------------------
223 // Audio processing
224 //
225 // Ported from candle-wasm-examples/whisper/src/audio.rs (rev c3bb5bf).
226 // Pure-Rust FFT + mel-spectrogram — no C dependencies, WASM-safe.
227 // ---------------------------------------------------------------------------
228
229 trait Float: num_traits::Float + num_traits::FloatConst + num_traits::NumAssign {}
230 impl Float for f32 {}
231 impl Float for f64 {}
232
233 fn fft<T: Float>(inp: &[T]) -> Vec<T> {
234 let n = inp.len();
235 let zero = T::zero();
236 if n == 1 {
237 return vec![inp[0], zero];
238 }
239 if n % 2 == 1 {
240 return dft(inp);
241 }
242 let mut out = vec![zero; n * 2];
243 let mut even = Vec::with_capacity(n / 2);
244 let mut odd = Vec::with_capacity(n / 2);
245 for (i, &v) in inp.iter().enumerate() {
246 if i % 2 == 0 { even.push(v) } else { odd.push(v) }
247 }
248 let even_fft = fft(&even);
249 let odd_fft = fft(&odd);
250 let two_pi = T::PI() + T::PI();
251 let n_t = T::from(n).unwrap();
252 for k in 0..n / 2 {
253 let k_t = T::from(k).unwrap();
254 let theta = two_pi * k_t / n_t;
255 let re = theta.cos();
256 let im = -theta.sin();
257 let re_odd = odd_fft[2 * k];
258 let im_odd = odd_fft[2 * k + 1];
259 out[2 * k] = even_fft[2 * k] + re * re_odd - im * im_odd;
260 out[2 * k + 1] = even_fft[2 * k + 1] + re * im_odd + im * re_odd;
261 out[2 * (k + n / 2)] = even_fft[2 * k] - re * re_odd + im * im_odd;
262 out[2 * (k + n / 2) + 1] = even_fft[2 * k + 1] - re * im_odd - im * re_odd;
263 }
264 out
265 }
266
267 fn dft<T: Float>(inp: &[T]) -> Vec<T> {
268 let zero = T::zero();
269 let n = inp.len();
270 let two_pi = T::PI() + T::PI();
271 let mut out = Vec::with_capacity(2 * n);
272 let n_t = T::from(n).unwrap();
273 for k in 0..n {
274 let k_t = T::from(k).unwrap();
275 let mut re = zero;
276 let mut im = zero;
277 for (j, &v) in inp.iter().enumerate() {
278 let j_t = T::from(j).unwrap();
279 let angle = two_pi * k_t * j_t / n_t;
280 re += v * angle.cos();
281 im -= v * angle.sin();
282 }
283 out.push(re);
284 out.push(im);
285 }
286 out
287 }
288
289 #[allow(clippy::too_many_arguments)]
290 fn log_mel_spectrogram_w<T: Float>(
291 ith: usize,
292 hann: &[T],
293 samples: &[T],
294 filters: &[T],
295 fft_size: usize,
296 fft_step: usize,
297 speed_up: bool,
298 n_len: usize,
299 n_mel: usize,
300 n_threads: usize,
301 ) -> Vec<T> {
302 let n_fft = if speed_up { 1 + fft_size / 4 } else { 1 + fft_size / 2 };
303 let zero = T::zero();
304 let half = T::from(0.5).unwrap();
305 let mut fft_in = vec![zero; fft_size];
306 let mut mel = vec![zero; n_len * n_mel];
307 for i in (ith..n_len).step_by(n_threads) {
308 let offset = i * fft_step;
309 for j in 0..fft_size {
310 fft_in[j] = if offset + j < samples.len() {
311 hann[j] * samples[offset + j]
312 } else {
313 zero
314 };
315 }
316 let mut fft_out: Vec<T> = fft(&fft_in);
317 for j in 0..fft_size {
318 fft_out[j] = fft_out[2 * j] * fft_out[2 * j]
319 + fft_out[2 * j + 1] * fft_out[2 * j + 1];
320 }
321 for j in 1..fft_size / 2 {
322 let v = fft_out[fft_size - j];
323 fft_out[j] += v;
324 }
325 if speed_up {
326 for j in 0..n_fft {
327 fft_out[j] = half * (fft_out[2 * j] + fft_out[2 * j + 1]);
328 }
329 }
330 for j in 0..n_mel {
331 let mut sum = zero;
332 for k in 0..n_fft {
333 sum += fft_out[k] * filters[j * n_fft + k];
334 }
335 mel[j * n_len + i] = T::max(sum, T::from(1e-10).unwrap()).log10();
336 }
337 }
338 mel
339 }
340
341 fn log_mel_spectrogram_<T: Float>(
342 samples: &[T],
343 filters: &[T],
344 fft_size: usize,
345 fft_step: usize,
346 n_mel: usize,
347 speed_up: bool,
348 ) -> Vec<T> {
349 let zero = T::zero();
350 let two_pi = T::PI() + T::PI();
351 let half = T::from(0.5).unwrap();
352 let one = T::from(1.0).unwrap();
353 let four = T::from(4.0).unwrap();
354 let fft_size_t = T::from(fft_size).unwrap();
355 let hann: Vec<T> = (0..fft_size)
356 .map(|i| half * (one - ((two_pi * T::from(i).unwrap()) / fft_size_t).cos()))
357 .collect();
358 let n_len = samples.len() / fft_step;
359 let pad = 100 * m::CHUNK_LENGTH / 2;
360 let n_len = if n_len % pad != 0 {
361 (n_len / pad + 1) * pad
362 } else {
363 n_len
364 };
365 let n_len = n_len + pad;
366 let samples = {
367 let mut padded = samples.to_vec();
368 let to_add = n_len * fft_step - samples.len();
369 padded.extend(std::iter::repeat_n(zero, to_add));
370 padded
371 };
372 let mut mel = log_mel_spectrogram_w(
373 0, &hann, &samples, filters, fft_size, fft_step, speed_up, n_len, n_mel, 1,
374 );
375 let mmax = mel
376 .iter()
377 .max_by(|u, v| u.partial_cmp(v).unwrap_or(std::cmp::Ordering::Greater))
378 .copied()
379 .unwrap_or(zero)
380 - T::from(8).unwrap();
381 for m in mel.iter_mut() {
382 let v = T::max(*m, mmax);
383 *m = v / four + one;
384 }
385 mel
386 }
387
388 fn pcm_to_mel(cfg: &Config, samples: &[f32], filters: &[f32]) -> anyhow::Result<Vec<f32>> {
389 Ok(log_mel_spectrogram_(
390 samples,
391 filters,
392 m::N_FFT,
393 m::HOP_LENGTH,
394 cfg.num_mel_bins,
395 false,
396 ))
397 }
398
399 // ---------------------------------------------------------------------------
400 // Model enum — wraps normal (f32 safetensors) and quantized (GGUF) variants
401 // ---------------------------------------------------------------------------
402
403 enum Model {
404 Normal(m::model::Whisper),
405 Quantized(m::quantized_model::Whisper),
406 }
407
408 impl Model {
409 fn config(&self) -> &Config {
410 match self {
411 Self::Normal(m) => &m.config,
412 Self::Quantized(m) => &m.config,
413 }
414 }
415
416 fn encoder_forward(&mut self, x: &Tensor, flush: bool) -> candle_core::Result<Tensor> {
417 match self {
418 Self::Normal(m) => m.encoder.forward(x, flush),
419 Self::Quantized(m) => m.encoder.forward(x, flush),
420 }
421 }
422
423 fn decoder_forward(
424 &mut self,
425 x: &Tensor,
426 xa: &Tensor,
427 flush: bool,
428 ) -> candle_core::Result<Tensor> {
429 match self {
430 Self::Normal(m) => m.decoder.forward(x, xa, flush),
431 Self::Quantized(m) => m.decoder.forward(x, xa, flush),
432 }
433 }
434
435 fn decoder_final_linear(&self, x: &Tensor) -> candle_core::Result<Tensor> {
436 match self {
437 Self::Normal(m) => m.decoder.final_linear(x),
438 Self::Quantized(m) => m.decoder.final_linear(x),
439 }
440 }
441 }
442
443 // ---------------------------------------------------------------------------
444 // Token helpers
445 // ---------------------------------------------------------------------------
446
447 fn token_id(tokenizer: &Tokenizer, token: &str) -> anyhow::Result<u32> {
448 tokenizer
449 .token_to_id(token)
450 .ok_or_else(|| anyhow!("no token-id for {token}"))
451 }
452
453 fn detect_language(
454 model: &mut Model,
455 tokenizer: &Tokenizer,
456 mel: &Tensor,
457 ) -> anyhow::Result<u32> {
458 clog!("detecting language");
459 let (_bsize, _, seq_len) = mel.dims3()?;
460 let mel = mel.narrow(
461 2,
462 0,
463 usize::min(seq_len, model.config().max_source_positions),
464 )?;
465 let device = mel.device();
466 let language_token_ids = LANGUAGES
467 .iter()
468 .map(|(t, _)| token_id(tokenizer, &format!("<|{t}|>")))
469 .collect::<anyhow::Result<Vec<_>>>()?;
470 let sot = token_id(tokenizer, m::SOT_TOKEN)?;
471 let audio_features = model.encoder_forward(&mel, true)?;
472 let tokens = Tensor::new(&[[sot]], device)?;
473 let language_token_ids_t = Tensor::new(language_token_ids.as_slice(), device)?;
474 let ys = model.decoder_forward(&tokens, &audio_features, true)?;
475 let logits = model.decoder_final_linear(&ys.i(..1)?)?.i(0)?.i(0)?;
476 let logits = logits.index_select(&language_token_ids_t, 0)?;
477 let probs = softmax(&logits, D::Minus1)?.to_vec1::<f32>()?;
478 let mut ranked: Vec<_> = LANGUAGES.iter().zip(probs.iter()).collect();
479 ranked.sort_by(|(_, p1), (_, p2)| p2.total_cmp(p1));
480 let best_code = ranked[0].0 .0;
481 let token_str = format!("<|{best_code}|>");
482 let language_id = token_id(tokenizer, &token_str)?;
483 clog!("detected language: {best_code} ({token_str})");
484 Ok(language_id)
485 }
486
487 // ---------------------------------------------------------------------------
488 // Internal decoding state
489 // ---------------------------------------------------------------------------
490
491 #[derive(Clone)]
492 struct DecodingResult {
493 tokens: Vec<u32>,
494 text: String,
495 avg_logprob: f64,
496 no_speech_prob: f64,
497 compression_ratio: f64,
498 }
499
500 struct Decoder {
501 model: Model,
502 rng: StdRng,
503 tokenizer: Tokenizer,
504 mel_filters: Vec<f32>,
505 language: Option<String>,
506 is_multilingual: bool,
507 timestamps: bool,
508 task_tokens: Vec<u32>, // [task_token] or [task_token, no_timestamps_token]
509 suppress_tokens: Tensor,
510 sot_token: u32,
511 eot_token: u32,
512 no_speech_token: u32,
513 no_timestamps_token: u32,
514 }
515
516 impl Decoder {
517 #[allow(clippy::too_many_arguments)]
518 fn new(
519 model: Model,
520 tokenizer: Tokenizer,
521 mel_filters: Vec<f32>,
522 device: &Device,
523 task: Option<&str>,
524 language: Option<String>,
525 is_multilingual: bool,
526 timestamps: bool,
527 ) -> anyhow::Result<Self> {
528 let suppress_tokens_vec: Vec<f32> = (0..model.config().vocab_size as u32)
529 .map(|i| {
530 if model.config().suppress_tokens.contains(&i) {
531 f32::NEG_INFINITY
532 } else {
533 0f32
534 }
535 })
536 .collect();
537 let suppress_tokens = Tensor::new(suppress_tokens_vec.as_slice(), device)?;
538
539 let sot_token = token_id(&tokenizer, m::SOT_TOKEN)?;
540 let transcribe_token = token_id(&tokenizer, m::TRANSCRIBE_TOKEN)?;
541 let translate_token = token_id(&tokenizer, m::TRANSLATE_TOKEN)?;
542 let eot_token = token_id(&tokenizer, m::EOT_TOKEN)?;
543 let no_timestamps_token = token_id(&tokenizer, m::NO_TIMESTAMPS_TOKEN)?;
544 let no_speech_token = m::NO_SPEECH_TOKENS
545 .iter()
546 .find_map(|t| token_id(&tokenizer, t).ok())
547 .ok_or_else(|| anyhow!("no no-speech token found"))?;
548
549 let task_token = match task {
550 Some("translate") => translate_token,
551 _ => transcribe_token,
552 };
553
554 let mut task_tokens = vec![task_token];
555 if !timestamps {
556 task_tokens.push(no_timestamps_token);
557 }
558
559 Ok(Self {
560 model,
561 rng: StdRng::seed_from_u64(299_792_458),
562 tokenizer,
563 mel_filters,
564 language,
565 is_multilingual,
566 timestamps,
567 task_tokens,
568 suppress_tokens,
569 sot_token,
570 eot_token,
571 no_speech_token,
572 no_timestamps_token,
573 })
574 }
575
576 fn decode(&mut self, mel: &Tensor, temperature: f64) -> anyhow::Result<DecodingResult> {
577 let language_token = match (self.is_multilingual, &self.language) {
578 (true, None) => Some(detect_language(&mut self.model, &self.tokenizer, mel)?),
579 (false, None) => None,
580 (true, Some(lang)) => {
581 let tok = format!("<|{lang}|>");
582 Some(token_id(&self.tokenizer, &tok).map_err(|_| {
583 anyhow!("language '{lang}' is not supported by this model")
584 })?)
585 }
586 (false, Some(_)) => {
587 anyhow::bail!("language cannot be set for non-multilingual models")
588 }
589 };
590
591 let audio_features = self.model.encoder_forward(mel, true)?;
592 let sample_len = self.model.config().max_target_positions / 2;
593
594 // Build the initial prompt token sequence:
595 // [SOT, (language), task, (no_timestamps)]
596 let mut tokens = vec![self.sot_token];
597 if let Some(lang_tok) = language_token {
598 tokens.push(lang_tok);
599 }
600 tokens.extend_from_slice(&self.task_tokens);
601
602 let mut sum_logprob = 0f64;
603 let mut no_speech_prob = f64::NAN;
604
605 for i in 0..sample_len {
606 let tokens_t = Tensor::new(tokens.as_slice(), mel.device())?.unsqueeze(0)?;
607 let ys = self.model.decoder_forward(&tokens_t, &audio_features, i == 0)?;
608
609 // Extract no-speech probability from the first decoder step.
610 if i == 0 {
611 let logits = self.model.decoder_final_linear(&ys.i(..1)?)?.i(0)?.i(0)?;
612 no_speech_prob = softmax(&logits, 0)?
613 .i(self.no_speech_token as usize)?
614 .to_scalar::<f32>()? as f64;
615 }
616
617 let (_, seq_len, _) = ys.dims3()?;
618 let logits = self
619 .model
620 .decoder_final_linear(&ys.i((..1, seq_len - 1..))?)?
621 .i(0)?
622 .i(0)?;
623 let logits = logits.broadcast_add(&self.suppress_tokens)?;
624
625 let next_token = if temperature > 0.0 {
626 let prs = softmax(&(&logits / temperature)?, 0)?;
627 let logits_v: Vec<f32> = prs.to_vec1()?;
628 let distr = rand::distr::weighted::WeightedIndex::new(&logits_v)?;
629 distr.sample(&mut self.rng) as u32
630 } else {
631 let logits_v: Vec<f32> = logits.to_vec1()?;
632 logits_v
633 .iter()
634 .enumerate()
635 .max_by(|(_, a), (_, b)| a.total_cmp(b))
636 .map(|(i, _)| i as u32)
637 .unwrap_or(self.eot_token)
638 };
639
640 if next_token == self.eot_token
641 || tokens.len() > self.model.config().max_target_positions
642 {
643 break;
644 }
645
646 let prob = softmax(&logits, D::Minus1)?
647 .i(next_token as usize)?
648 .to_scalar::<f32>()? as f64;
649 sum_logprob += prob.ln();
650 tokens.push(next_token);
651 }
652
653 let text = self
654 .tokenizer
655 .decode(&tokens, true)
656 .map_err(|e| anyhow!("{e}"))?;
657 let avg_logprob = sum_logprob / tokens.len().max(1) as f64;
658
659 Ok(DecodingResult {
660 tokens,
661 text,
662 avg_logprob,
663 no_speech_prob,
664 compression_ratio: f64::NAN,
665 })
666 }
667
668 fn decode_with_fallback(&mut self, segment: &Tensor) -> anyhow::Result<DecodingResult> {
669 for (i, &t) in m::TEMPERATURES.iter().enumerate() {
670 let result = self.decode(segment, t);
671 if i == m::TEMPERATURES.len() - 1 {
672 return result;
673 }
674 match result {
675 Ok(dr) => {
676 let needs_fallback = dr.compression_ratio > m::COMPRESSION_RATIO_THRESHOLD
677 || dr.avg_logprob < m::LOGPROB_THRESHOLD;
678 if !needs_fallback || dr.no_speech_prob > m::NO_SPEECH_THRESHOLD {
679 return Ok(dr);
680 }
681 }
682 Err(e) => cwarn!("decode at temperature {t} failed: {e}"),
683 }
684 }
685 unreachable!()
686 }
687
688 fn run(&mut self, mel: &Tensor) -> anyhow::Result<Vec<Segment>> {
689 let (_, _, content_frames) = mel.dims3()?;
690 let mut seek = 0;
691 let mut segments = vec![];
692 while seek < content_frames {
693 let time_offset = (seek * m::HOP_LENGTH) as f64 / m::SAMPLE_RATE as f64;
694 let segment_size = usize::min(content_frames - seek, m::N_FRAMES);
695 let mel_segment = mel.narrow(2, seek, segment_size)?;
696 let segment_duration =
697 (segment_size * m::HOP_LENGTH) as f64 / m::SAMPLE_RATE as f64;
698 let dr = self.decode_with_fallback(&mel_segment)?;
699 seek += segment_size;
700 if dr.no_speech_prob > m::NO_SPEECH_THRESHOLD
701 && dr.avg_logprob < m::LOGPROB_THRESHOLD
702 {
703 clog!("skipping silent segment at seek={seek}");
704 continue;
705 }
706 clog!("segment [{:.1}s] {}", time_offset, dr.text.trim());
707 segments.push(Segment {
708 start: time_offset,
709 end: time_offset + segment_duration,
710 dr,
711 });
712 }
713 Ok(segments)
714 }
715 }
716
717 // ---------------------------------------------------------------------------
718 // Segment — intermediate result per 30-second chunk
719 // ---------------------------------------------------------------------------
720
721 struct Segment {
722 start: f64,
723 end: f64,
724 dr: DecodingResult,
725 }
726
727 // ---------------------------------------------------------------------------
728 // Public JSON output types
729 // ---------------------------------------------------------------------------
730
731 #[derive(Serialize, Deserialize)]
732 struct OndeSegment {
733 start: f64,
734 end: f64,
735 text: String,
736 }
737
738 #[derive(Serialize, Deserialize)]
739 struct OndeResult {
740 text: String,
741 segments: Vec<OndeSegment>,
742 }
743
744 // ---------------------------------------------------------------------------
745 // WhisperDecoder — the public wasm-bindgen export
746 // ---------------------------------------------------------------------------
747
748 /// Whisper decoder bound to a pre-loaded set of model weights.
749 ///
750 /// Construct once, then call `decode` for each audio clip.
751 #[wasm_bindgen]
752 pub struct WhisperDecoder {
753 decoder: Decoder,
754 }
755
756 #[wasm_bindgen]
757 impl WhisperDecoder {
758 /// Construct a new `WhisperDecoder` from raw byte buffers.
759 ///
760 /// All four byte slices must stay alive for the duration of this call;
761 /// they are consumed (weights are moved into the model, everything else
762 /// is copied into small owned structures) and the originals are no
763 /// longer needed once the constructor returns.
764 ///
765 /// # Arguments
766 ///
767 /// - `weights` — `model.safetensors` bytes, OR `.gguf` bytes when `quantized = true`
768 /// - `tokenizer` — `tokenizer.json` bytes
769 /// - `mel_filters` — `mel_filters.safetensors` bytes
770 /// - `config` — `config.json` bytes
771 /// - `quantized` — `true` when `weights` is a `.gguf` file
772 /// - `is_multilingual` — `false` for `*.en` models
773 /// - `timestamps` — include per-segment timestamps in the output
774 /// - `task` — `"transcribe"` | `"translate"` | `null`
775 /// - `language` — BCP-47 code (`"id"`, `"en"`, …) or `null` for auto-detect
776 #[wasm_bindgen(constructor)]
777 pub fn new(
778 weights: &[u8],
779 tokenizer: &[u8],
780 mel_filters: &[u8],
781 config: &[u8],
782 quantized: bool,
783 is_multilingual: bool,
784 timestamps: bool,
785 task: Option<String>,
786 language: Option<String>,
787 ) -> Result<WhisperDecoder, JsError> {
788 set_panic_hook();
789 Self::build(
790 weights,
791 tokenizer,
792 mel_filters,
793 config,
794 quantized,
795 is_multilingual,
796 timestamps,
797 task.as_deref(),
798 language,
799 )
800 .map_err(|e| JsError::new(&e.to_string()))
801 }
802
803 fn build(
804 weights: &[u8],
805 tokenizer_bytes: &[u8],
806 mel_filters_bytes: &[u8],
807 config_bytes: &[u8],
808 quantized: bool,
809 is_multilingual: bool,
810 timestamps: bool,
811 task: Option<&str>,
812 language: Option<String>,
813 ) -> anyhow::Result<WhisperDecoder> {
814 let device = Device::Cpu;
815
816 // Tokenizer
817 let tokenizer = Tokenizer::from_bytes(tokenizer_bytes)
818 .map_err(|e| anyhow!("tokenizer load failed: {e}"))?;
819 clog!("tokenizer loaded");
820
821 // Mel filters — shape (n_mels, n_fft/2+1) stored as "mel_80" tensor
822 let mel_st = safetensors::SafeTensors::deserialize(mel_filters_bytes)
823 .map_err(|e| anyhow!("mel_filters load failed: {e}"))?;
824 let mel_tensor = mel_st
825 .tensor("mel_80")
826 .map_err(|e| anyhow!("mel_80 tensor missing: {e}"))?
827 .load(&device)?;
828 let mel_filters = mel_tensor.flatten_all()?.to_vec1::<f32>()?;
829 clog!("mel filters loaded: {:?}", mel_tensor.shape());
830
831 // Config
832 let config: Config = serde_json::from_slice(config_bytes)
833 .map_err(|e| anyhow!("config.json parse failed: {e}"))?;
834
835 // Model weights
836 let model = if quantized {
837 let vb =
838 candle_transformers::quantized_var_builder::VarBuilder::from_gguf_buffer(
839 weights,
840 &device,
841 )?;
842 Model::Quantized(m::quantized_model::Whisper::load(&vb, config)?)
843 } else {
844 let vb = VarBuilder::from_buffered_safetensors(
845 weights.to_vec(),
846 m::DTYPE,
847 &device,
848 )?;
849 Model::Normal(m::model::Whisper::load(&vb, config)?)
850 };
851 clog!("model loaded (quantized={quantized})");
852
853 let decoder = Decoder::new(
854 model,
855 tokenizer,
856 mel_filters,
857 &device,
858 task,
859 language,
860 is_multilingual,
861 timestamps,
862 )?;
863
864 Ok(WhisperDecoder { decoder })
865 }
866
867 /// Decode a 16 kHz mono PCM WAV file.
868 ///
869 /// Returns a JSON string with shape `{ text, segments: [{start, end, text}] }`.
870 pub fn decode(&mut self, wav_bytes: &[u8]) -> Result<String, JsError> {
871 self.decode_inner(wav_bytes)
872 .map_err(|e| JsError::new(&e.to_string()))
873 }
874
875 fn decode_inner(&mut self, wav_bytes: &[u8]) -> anyhow::Result<String> {
876 let device = Device::Cpu;
877
878 // Decode WAV
879 let mut cursor = std::io::Cursor::new(wav_bytes);
880 let reader = hound::WavReader::new(&mut cursor)?;
881 let spec = reader.spec();
882 clog!("wav: {spec:?}");
883 if spec.sample_rate != m::SAMPLE_RATE as u32 {
884 anyhow::bail!(
885 "expected {}Hz WAV, got {}Hz — please resample before passing to decode()",
886 m::SAMPLE_RATE,
887 spec.sample_rate
888 );
889 }
890
891 // Read samples, take only the first channel if stereo
892 let raw: Vec<_> = reader.into_samples::<i16>().collect::<hound::Result<Vec<_>>>()?;
893 let mono: Vec<f32> = raw
894 .chunks(spec.channels as usize)
895 .map(|ch| ch[0] as f32 / 32768.0)
896 .collect();
897 clog!("pcm samples: {}", mono.len());
898
899 // Build mel spectrogram tensor (shape: [1, n_mels, n_frames])
900 let cfg = self.decoder.model.config();
901 let mel = pcm_to_mel(cfg, &mono, &self.decoder.mel_filters)?;
902 let mel_len = mel.len();
903 let n_mels = cfg.num_mel_bins;
904 let mel_t = Tensor::from_vec(mel, (1, n_mels, mel_len / n_mels), &device)?;
905 clog!("mel tensor: {:?}", mel_t.dims());
906
907 // Run the sliding-window decoder
908 let segments = self.decoder.run(&mel_t)?;
909
910 // Build output
911 let mut full_text = String::new();
912 let mut out_segments = Vec::with_capacity(segments.len());
913 for seg in &segments {
914 if !full_text.is_empty() && !seg.dr.text.starts_with(' ') {
915 full_text.push(' ');
916 }
917 full_text.push_str(seg.dr.text.trim());
918 out_segments.push(OndeSegment {
919 start: seg.start,
920 end: seg.end,
921 text: seg.dr.text.trim().to_string(),
922 });
923 }
924
925 let result = OndeResult {
926 text: full_text,
927 segments: out_segments,
928 };
929 Ok(serde_json::to_string(&result)?)
930 }
931 }