mlx-bun 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -0,0 +1,239 @@
1
+ // Whisper tokenizer — mlx-whisper's tokenizer.py semantics over the stock
2
+ // HF tokenizer.json (openai/whisper-large-v3-turbo ships the multilingual
3
+ // tiktoken vocab in HF form; ids are identical, checked in
4
+ // tests/unit/whisper-tokenizer.test.ts against the oracle constants).
5
+ //
6
+ // Special-token layout (multilingual v3, n_vocab 51866): 50256 BPE ranks +
7
+ // "<|endoftext|>"(50257) "<|startoftranscript|>"(50258) + 100 language
8
+ // tokens + "<|translate|>" "<|transcribe|>" "<|startoflm|>" "<|startofprev|>"
9
+ // "<|nospeech|>" "<|notimestamps|>" + 1501 timestamp tokens "<|0.00|>" …
10
+ // "<|30.00|>". The mlx-community/whisper-* repos ship no tokenizer, so the
11
+ // loader resolves the openai/whisper-* tokenizer files (see
12
+ // resolveWhisperTokenizerDir).
13
+
14
+ import { existsSync, readdirSync } from "node:fs";
15
+ import { loadTokenizer, type LoadedTokenizer } from "../tokenizer";
16
+
17
+ export const WHISPER_LANGUAGES: Record<string, string> = {
18
+ en: "english", zh: "chinese", de: "german", es: "spanish", ru: "russian", ko: "korean",
19
+ fr: "french", ja: "japanese", pt: "portuguese", tr: "turkish", pl: "polish", ca: "catalan",
20
+ nl: "dutch", ar: "arabic", sv: "swedish", it: "italian", id: "indonesian", hi: "hindi",
21
+ fi: "finnish", vi: "vietnamese", he: "hebrew", uk: "ukrainian", el: "greek", ms: "malay",
22
+ cs: "czech", ro: "romanian", da: "danish", hu: "hungarian", ta: "tamil", no: "norwegian",
23
+ th: "thai", ur: "urdu", hr: "croatian", bg: "bulgarian", lt: "lithuanian", la: "latin",
24
+ mi: "maori", ml: "malayalam", cy: "welsh", sk: "slovak", te: "telugu", fa: "persian",
25
+ lv: "latvian", bn: "bengali", sr: "serbian", az: "azerbaijani", sl: "slovenian", kn: "kannada",
26
+ et: "estonian", mk: "macedonian", br: "breton", eu: "basque", is: "icelandic", hy: "armenian",
27
+ ne: "nepali", mn: "mongolian", bs: "bosnian", kk: "kazakh", sq: "albanian", sw: "swahili",
28
+ gl: "galician", mr: "marathi", pa: "punjabi", si: "sinhala", km: "khmer", sn: "shona",
29
+ yo: "yoruba", so: "somali", af: "afrikaans", oc: "occitan", ka: "georgian", be: "belarusian",
30
+ tg: "tajik", sd: "sindhi", gu: "gujarati", am: "amharic", yi: "yiddish", lo: "lao",
31
+ uz: "uzbek", fo: "faroese", ht: "haitian creole", ps: "pashto", tk: "turkmen", nn: "nynorsk",
32
+ mt: "maltese", sa: "sanskrit", lb: "luxembourgish", my: "myanmar", bo: "tibetan", tl: "tagalog",
33
+ mg: "malagasy", as: "assamese", tt: "tatar", haw: "hawaiian", ln: "lingala", ha: "hausa",
34
+ ba: "bashkir", jw: "javanese", su: "sundanese", yue: "cantonese",
35
+ };
36
+
37
+ const TO_LANGUAGE_CODE: Record<string, string> = {
38
+ ...Object.fromEntries(Object.entries(WHISPER_LANGUAGES).map(([c, n]) => [n, c])),
39
+ burmese: "my", valencian: "ca", flemish: "nl", haitian: "ht", letzeburgesch: "lb",
40
+ pushto: "ps", panjabi: "pa", moldavian: "ro", moldovan: "ro", sinhalese: "si", castilian: "es",
41
+ mandarin: "zh",
42
+ };
43
+
44
+ /** "English" / "en" → "en"; throws on unknown. */
45
+ export function normalizeWhisperLanguage(language: string): string {
46
+ const l = language.toLowerCase();
47
+ if (l in WHISPER_LANGUAGES) return l;
48
+ const code = TO_LANGUAGE_CODE[l];
49
+ if (code) return code;
50
+ throw new Error(`Unsupported language: ${language}`);
51
+ }
52
+
53
+ export type WhisperTask = "transcribe" | "translate";
54
+
55
+ const ASCII_PUNCTUATION = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
56
+
57
+ export class WhisperTokenizer {
58
+ readonly eot: number;
59
+ readonly sot: number;
60
+ readonly translate: number;
61
+ readonly transcribe: number;
62
+ readonly sotLm: number;
63
+ readonly sotPrev: number;
64
+ readonly noSpeech: number;
65
+ readonly noTimestamps: number;
66
+ readonly timestampBegin: number;
67
+ readonly numLanguages: number;
68
+ /** Language token ids in id order (sot+1 …). */
69
+ readonly allLanguageTokens: number[];
70
+ readonly allLanguageCodes: string[];
71
+ readonly #tok: LoadedTokenizer;
72
+ readonly #encodeCache = new Map<string, number[]>();
73
+
74
+ constructor(tok: LoadedTokenizer, numLanguages: number) {
75
+ this.#tok = tok;
76
+ this.numLanguages = numLanguages;
77
+ const one = (text: string): number => {
78
+ const ids = tok.encode(text, false);
79
+ if (ids.length !== 1) throw new Error(`whisper tokenizer: ${text} is not a single token`);
80
+ return ids[0]!;
81
+ };
82
+ this.eot = one("<|endoftext|>");
83
+ this.sot = one("<|startoftranscript|>");
84
+ this.translate = one("<|translate|>");
85
+ this.transcribe = one("<|transcribe|>");
86
+ this.sotLm = one("<|startoflm|>");
87
+ this.sotPrev = one("<|startofprev|>");
88
+ this.noSpeech = one("<|nospeech|>");
89
+ this.noTimestamps = one("<|notimestamps|>");
90
+ this.timestampBegin = one("<|0.00|>");
91
+ // language tokens: the oracle's order is by (insertion) special-token
92
+ // order == ascending id, capped at num_languages
93
+ const codes = Object.keys(WHISPER_LANGUAGES).slice(0, numLanguages);
94
+ this.allLanguageTokens = codes.map((c) => one(`<|${c}|>`));
95
+ this.allLanguageCodes = codes;
96
+ }
97
+
98
+ encode(text: string): number[] {
99
+ let ids = this.#encodeCache.get(text);
100
+ if (!ids) {
101
+ ids = this.#tok.encode(text, false);
102
+ if (this.#encodeCache.size < 4096) this.#encodeCache.set(text, ids);
103
+ }
104
+ return ids;
105
+ }
106
+
107
+ /** decode() drops timestamp tokens (ids ≥ timestampBegin) like the oracle. */
108
+ decode(ids: number[]): string {
109
+ return this.#tok.decode(ids.filter((t) => t < this.timestampBegin), false);
110
+ }
111
+
112
+ /** Text tokens only (id < eot). */
113
+ decodeText(ids: number[]): string {
114
+ return this.#tok.decode(ids.filter((t) => t < this.eot), false);
115
+ }
116
+
117
+ decodeWithTimestamps(ids: number[]): string {
118
+ return this.#tok.decode(ids, false);
119
+ }
120
+
121
+ languageToken(language: string): number {
122
+ const i = this.allLanguageCodes.indexOf(language);
123
+ if (i < 0) throw new Error(`Language ${language} not found in tokenizer.`);
124
+ return this.allLanguageTokens[i]!;
125
+ }
126
+
127
+ /** [sot, <|lang|>, <|task|>] */
128
+ sotSequence(language: string | null, task: WhisperTask): number[] {
129
+ const seq = [this.sot];
130
+ if (language !== null) seq.push(this.languageToken(language));
131
+ seq.push(task === "transcribe" ? this.transcribe : this.translate);
132
+ return seq;
133
+ }
134
+
135
+ /** tokenizer.non_speech_tokens — punctuation/symbol tokens suppressed at
136
+ * every step (mlx-whisper tokenizer.py). */
137
+ get nonSpeechTokens(): number[] {
138
+ const symbols = "\"#()*+/:;<=>@[\\]^_`{|}~「」『』".split("");
139
+ symbols.push(...["<<", ">>", "<<<", ">>>", "--", "---", "-(", "-[", "('", "(\"", "((", "))", "(((", ")))", "[[", "]]", "{{", "}}", "♪♪", "♪♪♪"]);
140
+ const miscellaneous = new Set("♩♪♫♬♭♮♯".split(""));
141
+ const result = new Set<number>([this.encode(" -")[0]!, this.encode(" '")[0]!]);
142
+ for (const symbol of [...symbols, ...miscellaneous]) {
143
+ for (const tokens of [this.encode(symbol), this.encode(" " + symbol)]) {
144
+ if (tokens.length === 1 || miscellaneous.has(symbol)) result.add(tokens[0]!);
145
+ }
146
+ }
147
+ return [...result].sort((a, b) => a - b);
148
+ }
149
+
150
+ /** Split text tokens into words (split_to_word_tokens): unicode-boundary
151
+ * split for CJK/Thai/Lao/Myanmar, space split otherwise. */
152
+ splitToWordTokens(tokens: number[], language: string): { words: string[]; wordTokens: number[][] } {
153
+ if (["zh", "ja", "th", "lo", "my", "yue"].includes(language)) return this.#splitOnUnicode(tokens);
154
+ return this.#splitOnSpaces(tokens);
155
+ }
156
+
157
+ #splitOnUnicode(tokens: number[]): { words: string[]; wordTokens: number[][] } {
158
+ const decodedFull = this.decodeWithTimestamps(tokens);
159
+ const replacement = "�";
160
+ const words: string[] = [];
161
+ const wordTokens: number[][] = [];
162
+ let current: number[] = [];
163
+ let unicodeOffset = 0;
164
+ for (const token of tokens) {
165
+ current.push(token);
166
+ const decoded = this.decodeWithTimestamps(current);
167
+ const idx = decoded.indexOf(replacement);
168
+ if (idx < 0 || decodedFull[unicodeOffset + idx] === replacement) {
169
+ words.push(decoded);
170
+ wordTokens.push(current);
171
+ current = [];
172
+ unicodeOffset += decoded.length;
173
+ }
174
+ }
175
+ return { words, wordTokens };
176
+ }
177
+
178
+ #splitOnSpaces(tokens: number[]): { words: string[]; wordTokens: number[][] } {
179
+ const sub = this.#splitOnUnicode(tokens);
180
+ const words: string[] = [];
181
+ const wordTokens: number[][] = [];
182
+ for (let i = 0; i < sub.words.length; i++) {
183
+ const subword = sub.words[i]!;
184
+ const subwordTokens = sub.wordTokens[i]!;
185
+ const special = subwordTokens[0]! >= this.eot;
186
+ const withSpace = subword.startsWith(" ");
187
+ // python: `subword.strip() in string.punctuation` — a SUBSTRING test.
188
+ const punctuation = ASCII_PUNCTUATION.includes(subword.trim());
189
+ if (special || withSpace || punctuation || words.length === 0) {
190
+ words.push(subword);
191
+ wordTokens.push(subwordTokens);
192
+ } else {
193
+ words[words.length - 1] += subword;
194
+ wordTokens[wordTokens.length - 1]!.push(...subwordTokens);
195
+ }
196
+ }
197
+ return { words, wordTokens };
198
+ }
199
+ }
200
+
201
+ /** Tokenizer directory for a Whisper checkpoint: the model dir itself when
202
+ * it ships tokenizer.json, else the matching openai/whisper-* HF snapshot
203
+ * (multilingual v3 vocab → large-v3-turbo; v2 → large-v2; english-only →
204
+ * base.en). Returns null when nothing is on disk. */
205
+ export function resolveWhisperTokenizerDir(modelDir: string, nVocab: number): string | null {
206
+ if (existsSync(`${modelDir}/tokenizer.json`) && existsSync(`${modelDir}/tokenizer_config.json`))
207
+ return modelDir;
208
+ const home = process.env.HOME ?? "";
209
+ const hub = `${home}/.cache/huggingface/hub`;
210
+ for (const repo of whisperTokenizerRepos(nVocab)) {
211
+ const base = `${hub}/models--${repo.replace("/", "--")}/snapshots`;
212
+ try {
213
+ for (const snap of readdirSync(base)) {
214
+ const dir = `${base}/${snap}`;
215
+ if (existsSync(`${dir}/tokenizer.json`) && existsSync(`${dir}/tokenizer_config.json`)) return dir;
216
+ }
217
+ } catch { /* not downloaded */ }
218
+ }
219
+ return null;
220
+ }
221
+
222
+ /** Candidate HF repos whose tokenizer matches an n_vocab. */
223
+ export function whisperTokenizerRepos(nVocab: number): string[] {
224
+ if (nVocab === 51866) return ["openai/whisper-large-v3-turbo", "openai/whisper-large-v3"];
225
+ if (nVocab === 51865) return ["openai/whisper-large-v2", "openai/whisper-small", "openai/whisper-tiny"];
226
+ return ["openai/whisper-tiny.en", "openai/whisper-base.en", "openai/whisper-small.en", "openai/whisper-medium.en"];
227
+ }
228
+
229
+ export async function loadWhisperTokenizer(modelDir: string, nVocab: number): Promise<WhisperTokenizer> {
230
+ const dir = resolveWhisperTokenizerDir(modelDir, nVocab);
231
+ if (!dir)
232
+ throw new Error(
233
+ `no Whisper tokenizer for ${modelDir}: place tokenizer.json + tokenizer_config.json ` +
234
+ `beside the weights or download ${whisperTokenizerRepos(nVocab)[0]} (tokenizer files only)`,
235
+ );
236
+ const isMultilingual = nVocab >= 51865;
237
+ const numLanguages = nVocab - 51765 - (isMultilingual ? 1 : 0);
238
+ return new WhisperTokenizer(await loadTokenizer(dir), numLanguages);
239
+ }
@@ -0,0 +1,410 @@
1
+ // Whisper long-form transcription — a port of mlx-whisper's transcribe.py
2
+ // seek loop (30 s windows, temperature fallback, no-speech skipping,
3
+ // timestamp-driven seeking, previous-text conditioning), restructured as a
4
+ // RESUMABLE run: `feed()` appends audio and transcribes every window that
5
+ // is already complete (≥ 30 s past the seek point); `finish()` drains the
6
+ // remainder. Batch transcription is the same run fed once, so the oracle
7
+ // parity gate covers both. Streaming caveat: a window fed early sees a
8
+ // log-mel normalized against the audio received so far (the 8-decade clamp
9
+ // is relative to the clip maximum), which can differ from the batch result
10
+ // in near-silent bins. Word timestamps live in whisper-timing.ts.
11
+
12
+ import { MlxArray } from "../mlx/array";
13
+ import { Dtype } from "../mlx/ffi";
14
+ import * as ops from "../mlx/ops";
15
+ import type { WhisperModel } from "../model/whisper";
16
+ import {
17
+ WhisperDecodingTask, type WhisperDecodingOptions, type WhisperDecodingResult,
18
+ } from "./whisper-decode";
19
+ import {
20
+ WHISPER_FRAMES_PER_SECOND, WHISPER_HOP, WHISPER_N_FRAMES, WHISPER_N_SAMPLES,
21
+ WHISPER_SAMPLE_RATE, WhisperMelExtractor, padOrTrimFrames,
22
+ } from "./whisper-mel";
23
+ import { addWordTimestamps } from "./whisper-timing";
24
+ import { normalizeWhisperLanguage, type WhisperTokenizer } from "./whisper-tokenizer";
25
+
26
+ export interface WhisperSegment {
27
+ id: number;
28
+ seek: number;
29
+ start: number;
30
+ end: number;
31
+ text: string;
32
+ tokens: number[];
33
+ temperature: number;
34
+ avgLogprob: number;
35
+ compressionRatio: number;
36
+ noSpeechProb: number;
37
+ words?: WhisperWord[];
38
+ }
39
+
40
+ export interface WhisperWord {
41
+ word: string;
42
+ start: number;
43
+ end: number;
44
+ probability: number;
45
+ }
46
+
47
+ export interface WhisperTranscribeOptions extends WhisperDecodingOptions {
48
+ /** Single temperature or the fallback ladder (oracle default: 0, .2 … 1). */
49
+ temperatures?: number[];
50
+ compressionRatioThreshold?: number | null;
51
+ logprobThreshold?: number | null;
52
+ noSpeechThreshold?: number | null;
53
+ conditionOnPreviousText?: boolean;
54
+ initialPrompt?: string | null;
55
+ /** [start, end, start, end, …] seconds; last end defaults to the clip end.
56
+ * Batch only: a run with clips never transcribes eagerly. */
57
+ clipTimestamps?: number[];
58
+ /** Word-level timestamps via cross-attention DTW (whisper-timing.ts);
59
+ * one extra faithful decoder pass per window. */
60
+ wordTimestamps?: boolean;
61
+ /** With wordTimestamps: skip silent gaps longer than this many seconds
62
+ * around a likely hallucination (transcribe.py hallucination_silence_threshold). */
63
+ hallucinationSilenceThreshold?: number | null;
64
+ /** Progress callback: seeked frames out of content frames. */
65
+ onProgress?: (done: number, total: number) => void;
66
+ /** Called with each finalized segment as the loop emits it. */
67
+ onSegment?: (segment: WhisperSegment) => void;
68
+ /** Checked between windows; an aborted signal throws "transcription cancelled". */
69
+ signal?: AbortSignal;
70
+ }
71
+
72
+ export interface WhisperTranscription {
73
+ text: string;
74
+ segments: WhisperSegment[];
75
+ language: string;
76
+ /** Detected-language probabilities when language was auto-detected. */
77
+ languageProbs?: Record<string, number>;
78
+ }
79
+
80
+ const DEFAULT_TEMPERATURES = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0];
81
+
82
+ export class WhisperTranscriber {
83
+ readonly mel: WhisperMelExtractor;
84
+ constructor(readonly model: WhisperModel, readonly tokenizer: WhisperTokenizer) {
85
+ this.mel = new WhisperMelExtractor(model.dims.nMels);
86
+ }
87
+
88
+ /** Transcribe a complete 16 kHz mono float32 waveform. */
89
+ async transcribe(samples: Float32Array, options: WhisperTranscribeOptions = {}): Promise<WhisperTranscription> {
90
+ const run = new WhisperStreamingRun(this, options);
91
+ await run.feed(samples);
92
+ return run.finish();
93
+ }
94
+
95
+ /** Start a resumable run (see WhisperStreamingRun). */
96
+ start(options: WhisperTranscribeOptions = {}): WhisperStreamingRun {
97
+ return new WhisperStreamingRun(this, options);
98
+ }
99
+
100
+ dispose(): void {
101
+ this.mel.dispose();
102
+ }
103
+ }
104
+
105
+ /** A transcription in progress over audio that is still arriving. */
106
+ export class WhisperStreamingRun {
107
+ readonly #tr: WhisperTranscriber;
108
+ readonly #o: WhisperTranscribeOptions;
109
+ #buf = new Float32Array(WHISPER_SAMPLE_RATE * 60);
110
+ #len = 0;
111
+ #seek = 0;
112
+ #language: string | null;
113
+ #languageProbs: Record<string, number> | undefined;
114
+ #allTokens: number[] = [];
115
+ #allSegments: WhisperSegment[] = [];
116
+ #promptResetSince = 0;
117
+ #initialPromptTokens: number[];
118
+ #lastSpeechTimestamp = 0;
119
+ #finished = false;
120
+ readonly #temperatures: number[];
121
+ readonly #crThreshold: number | null;
122
+ readonly #lpThreshold: number | null;
123
+ readonly #nsThreshold: number | null;
124
+ readonly #condition: boolean;
125
+
126
+ constructor(transcriber: WhisperTranscriber, options: WhisperTranscribeOptions) {
127
+ this.#tr = transcriber;
128
+ this.#o = options;
129
+ this.#language = options.language == null ? null : normalizeWhisperLanguage(options.language);
130
+ this.#temperatures = options.temperatures ?? (options.temperature !== undefined ? [options.temperature] : DEFAULT_TEMPERATURES);
131
+ this.#crThreshold = options.compressionRatioThreshold === undefined ? 2.4 : options.compressionRatioThreshold;
132
+ this.#lpThreshold = options.logprobThreshold === undefined ? -1.0 : options.logprobThreshold;
133
+ this.#nsThreshold = options.noSpeechThreshold === undefined ? 0.6 : options.noSpeechThreshold;
134
+ this.#condition = options.conditionOnPreviousText ?? true;
135
+ this.#initialPromptTokens = options.initialPrompt != null ? transcriber.tokenizer.encode(" " + options.initialPrompt.trim()) : [];
136
+ this.#allTokens.push(...this.#initialPromptTokens);
137
+ }
138
+
139
+ /** Samples received so far. */
140
+ get sampleCount(): number {
141
+ return this.#len;
142
+ }
143
+
144
+ /** Segments finalized so far. */
145
+ get segments(): readonly WhisperSegment[] {
146
+ return this.#allSegments;
147
+ }
148
+
149
+ /** Append audio WITHOUT transcribing yet (e.g. no speech detected so far). */
150
+ feedSilent(samples: Float32Array): void {
151
+ if (this.#finished) throw new Error("transcription run already finished");
152
+ if (this.#len + samples.length > this.#buf.length) {
153
+ const next = new Float32Array(Math.max(this.#buf.length * 2, this.#len + samples.length));
154
+ next.set(this.#buf.subarray(0, this.#len));
155
+ this.#buf = next;
156
+ }
157
+ this.#buf.set(samples, this.#len);
158
+ this.#len += samples.length;
159
+ }
160
+
161
+ /** Append audio; transcribes every complete 30 s window. Returns the
162
+ * segments finalized by this call. */
163
+ async feed(samples: Float32Array): Promise<WhisperSegment[]> {
164
+ this.feedSilent(samples);
165
+ if (this.#o.clipTimestamps) return [];
166
+ const before = this.#allSegments.length;
167
+ await this.#drain(false);
168
+ return this.#allSegments.slice(before);
169
+ }
170
+
171
+ /** Transcribe the remaining audio and return the whole transcription. */
172
+ async finish(): Promise<WhisperTranscription> {
173
+ if (this.#finished) throw new Error("transcription run already finished");
174
+ this.#finished = true;
175
+ await this.#drain(true);
176
+ const t = this.#tr.tokenizer;
177
+ return {
178
+ text: t.decodeText(this.#allTokens.slice(this.#initialPromptTokens.length)),
179
+ segments: this.#allSegments,
180
+ language: this.#language ?? "en",
181
+ languageProbs: this.#languageProbs,
182
+ };
183
+ }
184
+
185
+ async #drain(final: boolean): Promise<void> {
186
+ const { model, tokenizer: t } = this.#tr;
187
+ const samples = this.#buf.subarray(0, this.#len);
188
+ if (samples.length <= 200) return; // below one mel frame of real audio
189
+ const mel = this.#tr.mel.logMel(samples, WHISPER_N_SAMPLES);
190
+ try {
191
+ const contentFrames = mel.shape[0]! - WHISPER_N_FRAMES;
192
+ // language detection on the first window (batch: same as the oracle)
193
+ if (this.#language === null) {
194
+ if (!final && contentFrames - this.#seek < WHISPER_N_FRAMES) return;
195
+ if (!model.isMultilingual) this.#language = "en";
196
+ else {
197
+ const seg = padOrTrimFrames(mel, WHISPER_N_FRAMES, model.dtype);
198
+ const seg3 = reshape3(seg);
199
+ const probe = new WhisperDecodingTask(model, t, { ...this.#o, language: null });
200
+ const feats = probe.encode(seg3);
201
+ const det = probe.detectLanguage(feats);
202
+ feats.dispose(); seg3.dispose(); seg.dispose();
203
+ this.#language = det.code;
204
+ this.#languageProbs = det.probs;
205
+ }
206
+ }
207
+ // clip windows: batch only
208
+ let clips: [number, number][];
209
+ if (this.#o.clipTimestamps) {
210
+ const pts = this.#o.clipTimestamps.map((ts) => Math.round(ts * WHISPER_FRAMES_PER_SECOND));
211
+ if (pts.length === 0) pts.push(0);
212
+ if (pts.length % 2 === 1) pts.push(contentFrames);
213
+ else pts[pts.length - 1] = Math.min(contentFrames, pts[pts.length - 1]!);
214
+ clips = [];
215
+ for (let i = 0; i < pts.length; i += 2) clips.push([pts[i]!, pts[i + 1]!]);
216
+ this.#seek = clips[0]![0];
217
+ } else clips = [[0, contentFrames]];
218
+ for (const [, clipEnd] of clips) {
219
+ while (this.#seek < clipEnd) {
220
+ // during feed, only windows with 30 s of audio past `seek` are final
221
+ if (!final && contentFrames - this.#seek < WHISPER_N_FRAMES) return;
222
+ if (this.#o.signal?.aborted) throw new Error("transcription cancelled");
223
+ if (this.#allSegments.length > 0) await Bun.sleep(0);
224
+ await this.#window(mel, contentFrames, clipEnd);
225
+ this.#o.onProgress?.(Math.min(contentFrames, this.#seek), final ? contentFrames : Math.max(contentFrames, this.#seek));
226
+ }
227
+ }
228
+ } finally {
229
+ mel.dispose();
230
+ }
231
+ }
232
+
233
+ #decodeWithFallback(segment: MlxArray, prompt: number[]): WhisperDecodingResult {
234
+ const { model, tokenizer: t } = this.#tr;
235
+ let result: WhisperDecodingResult | null = null;
236
+ for (const temp of this.#temperatures) {
237
+ const opts: WhisperDecodingOptions = { ...this.#o, language: this.#language, task: this.#o.task ?? "transcribe", prompt, temperature: temp };
238
+ if (temp > 0) { opts.beamSize = null; opts.patience = null; }
239
+ else opts.bestOf = null;
240
+ result = new WhisperDecodingTask(model, t, opts).run(segment);
241
+ let needsFallback = false;
242
+ if (this.#crThreshold !== null && result.compressionRatio > this.#crThreshold) needsFallback = true;
243
+ if (this.#lpThreshold !== null && result.avgLogprob < this.#lpThreshold) needsFallback = true;
244
+ if (this.#nsThreshold !== null && result.noSpeechProb > this.#nsThreshold) needsFallback = false;
245
+ if (!needsFallback) break;
246
+ }
247
+ return result!;
248
+ }
249
+
250
+ /** One window at `#seek` (transcribe.py loop body). */
251
+ async #window(mel: MlxArray, contentFrames: number, clipEnd: number): Promise<void> {
252
+ const { model, tokenizer: t } = this.#tr;
253
+ const o = this.#o;
254
+ const seek = this.#seek;
255
+ const timeOffset = (seek * WHISPER_HOP) / WHISPER_SAMPLE_RATE;
256
+ const segmentSize = Math.min(WHISPER_N_FRAMES, contentFrames - seek, clipEnd - seek);
257
+ const segmentDuration = (segmentSize * WHISPER_HOP) / WHISPER_SAMPLE_RATE;
258
+ const melSeg = mel.slice([seek, 0], [seek + segmentSize, model.dims.nMels]);
259
+ const padded = padOrTrimFrames(melSeg, WHISPER_N_FRAMES, model.dtype);
260
+ melSeg.dispose();
261
+ const seg3 = reshape3(padded);
262
+ padded.dispose();
263
+ const prompt = this.#allTokens.slice(this.#promptResetSince);
264
+ let result: WhisperDecodingResult;
265
+ try {
266
+ result = this.#decodeWithFallback(seg3, prompt);
267
+ } catch (e) {
268
+ seg3.dispose();
269
+ throw e;
270
+ }
271
+ const tokens = result.tokens;
272
+ if (this.#nsThreshold !== null) {
273
+ let shouldSkip = result.noSpeechProb > this.#nsThreshold;
274
+ if (this.#lpThreshold !== null && result.avgLogprob > this.#lpThreshold) shouldSkip = false;
275
+ if (shouldSkip) {
276
+ seg3.dispose();
277
+ this.#seek += segmentSize;
278
+ return;
279
+ }
280
+ }
281
+ const previousSeek = seek;
282
+ const current: WhisperSegment[] = [];
283
+ const newSegment = (start: number, end: number, toks: number[]): WhisperSegment => ({
284
+ id: 0, seek, start, end,
285
+ text: t.decodeText(toks), tokens: toks,
286
+ temperature: result.temperature, avgLogprob: result.avgLogprob,
287
+ compressionRatio: result.compressionRatio, noSpeechProb: result.noSpeechProb,
288
+ });
289
+ const isTs = tokens.map((x) => x >= t.timestampBegin);
290
+ const singleTimestampEnding = tokens.length >= 2 && !isTs[tokens.length - 2] && isTs[tokens.length - 1];
291
+ const consecutive: number[] = [];
292
+ for (let i = 0; i + 1 < tokens.length; i++) if (isTs[i] && isTs[i + 1]) consecutive.push(i + 1);
293
+ const inputStride = WHISPER_N_FRAMES / model.dims.nAudioCtx; // 2
294
+ const timePrecision = (inputStride * WHISPER_HOP) / WHISPER_SAMPLE_RATE; // 0.02
295
+ if (consecutive.length > 0) {
296
+ const slices = [...consecutive];
297
+ if (singleTimestampEnding) slices.push(tokens.length);
298
+ let lastSlice = 0;
299
+ for (const cur of slices) {
300
+ const sliced = tokens.slice(lastSlice, cur);
301
+ const startPos = sliced[0]! - t.timestampBegin;
302
+ const endPos = sliced[sliced.length - 1]! - t.timestampBegin;
303
+ current.push(newSegment(timeOffset + startPos * timePrecision, timeOffset + endPos * timePrecision, sliced));
304
+ lastSlice = cur;
305
+ }
306
+ if (singleTimestampEnding) this.#seek += segmentSize;
307
+ else {
308
+ const lastPos = tokens[lastSlice - 1]! - t.timestampBegin;
309
+ this.#seek += lastPos * inputStride;
310
+ }
311
+ } else {
312
+ let duration = segmentDuration;
313
+ const timestamps = tokens.filter((x) => x >= t.timestampBegin);
314
+ if (timestamps.length > 0 && timestamps[timestamps.length - 1] !== t.timestampBegin) {
315
+ const lastPos = timestamps[timestamps.length - 1]! - t.timestampBegin;
316
+ duration = lastPos * timePrecision;
317
+ }
318
+ current.push(newSegment(timeOffset, timeOffset + duration, tokens));
319
+ this.#seek += segmentSize;
320
+ }
321
+
322
+ if (o.wordTimestamps) {
323
+ this.#lastSpeechTimestamp = addWordTimestamps(current, model, t, this.#language!, seg3, segmentSize, this.#lastSpeechTimestamp);
324
+ if (!singleTimestampEnding) {
325
+ const lastWordEnd = lastWordEndOf(current);
326
+ if (lastWordEnd !== null && lastWordEnd > timeOffset) this.#seek = Math.round(lastWordEnd * WHISPER_FRAMES_PER_SECOND);
327
+ }
328
+ const threshold = o.hallucinationSilenceThreshold ?? null;
329
+ if (threshold !== null) {
330
+ const windowEndTime = ((previousSeek + WHISPER_N_FRAMES) * WHISPER_HOP) / WHISPER_SAMPLE_RATE;
331
+ if (!singleTimestampEnding) {
332
+ const lastWordEnd = lastWordEndOf(current);
333
+ if (lastWordEnd !== null && lastWordEnd > timeOffset) {
334
+ const remaining = windowEndTime - lastWordEnd;
335
+ this.#seek = remaining > threshold ? Math.round(lastWordEnd * WHISPER_FRAMES_PER_SECOND) : previousSeek + segmentSize;
336
+ }
337
+ }
338
+ const first = current.find((s) => s.words && s.words.length);
339
+ if (first && isSegmentAnomaly(first)) {
340
+ const gap = first.start - timeOffset;
341
+ if (gap > threshold) { this.#seek = previousSeek + Math.round(gap * WHISPER_FRAMES_PER_SECOND); seg3.dispose(); return; }
342
+ }
343
+ let halLastEnd = this.#lastSpeechTimestamp;
344
+ for (let si = 0; si < current.length; si++) {
345
+ const segment = current[si]!;
346
+ if (!segment.words?.length) continue;
347
+ if (isSegmentAnomaly(segment)) {
348
+ const nextSeg = current.slice(si + 1).find((s) => s.words && s.words.length);
349
+ const halNextStart = nextSeg ? nextSeg.words![0]!.start : timeOffset + segmentDuration;
350
+ const silenceBefore = segment.start - halLastEnd > threshold || segment.start < threshold || segment.start - timeOffset < 2.0;
351
+ const silenceAfter = halNextStart - segment.end > threshold || isSegmentAnomaly(nextSeg) || windowEndTime - segment.end < 2.0;
352
+ if (silenceBefore && silenceAfter) {
353
+ this.#seek = Math.round(Math.max(timeOffset + 1, segment.start) * WHISPER_FRAMES_PER_SECOND);
354
+ if ((contentFrames * WHISPER_HOP) / WHISPER_SAMPLE_RATE - segment.end < threshold) this.#seek = contentFrames;
355
+ current.splice(si);
356
+ break;
357
+ }
358
+ }
359
+ halLastEnd = segment.end;
360
+ }
361
+ }
362
+ const lastWordEnd = lastWordEndOf(current);
363
+ if (lastWordEnd !== null) this.#lastSpeechTimestamp = lastWordEnd;
364
+ }
365
+ seg3.dispose();
366
+ for (const s of current) {
367
+ if (s.start === s.end || s.text.trim() === "") { s.text = ""; s.tokens = []; s.words = []; }
368
+ }
369
+ for (const s of current) {
370
+ s.id = this.#allSegments.length;
371
+ this.#allSegments.push(s);
372
+ o.onSegment?.(s);
373
+ }
374
+ for (const s of current) this.#allTokens.push(...s.tokens);
375
+ if (!this.#condition || result.temperature > 0.5) this.#promptResetSince = this.#allTokens.length;
376
+ }
377
+ }
378
+
379
+ /** [T, M] → [1, T, M]. */
380
+ function reshape3(a: MlxArray): MlxArray {
381
+ const [T, M] = a.shape as [number, number];
382
+ return ops.reshape(a, [1, T, M]);
383
+ }
384
+ export { Dtype };
385
+
386
+ const PUNCTUATION = "\"'“¿([{-\"'.。,,!!??::”)]}、";
387
+
388
+ function lastWordEndOf(segments: WhisperSegment[]): number | null {
389
+ for (let i = segments.length - 1; i >= 0; i--) {
390
+ const w = segments[i]!.words;
391
+ if (w && w.length) return w[w.length - 1]!.end;
392
+ }
393
+ return segments.length ? segments[segments.length - 1]!.end : null;
394
+ }
395
+
396
+ function wordAnomalyScore(word: WhisperWord): number {
397
+ const duration = word.end - word.start;
398
+ let score = 0;
399
+ if (word.probability < 0.15) score += 1;
400
+ if (duration < 0.133) score += (0.133 - duration) * 15;
401
+ if (duration > 2.0) score += duration - 2.0;
402
+ return score;
403
+ }
404
+
405
+ function isSegmentAnomaly(segment: WhisperSegment | undefined): boolean {
406
+ if (!segment || !segment.words?.length) return false;
407
+ const words = segment.words.filter((w) => !PUNCTUATION.includes(w.word)).slice(0, 8);
408
+ const score = words.reduce((a, w) => a + wordAnomalyScore(w), 0);
409
+ return score >= 3 || score + 0.01 >= words.length;
410
+ }