mlx-bun 0.4.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.
- package/README.md +57 -32
- package/docs/README.md +55 -42
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
- package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
- package/docs/design/batching.md +220 -7
- package/docs/design/decode-speed-program.md +121 -66
- package/docs/design/generic-model-support.md +116 -0
- package/docs/design/kv-cache.md +380 -211
- package/docs/design/speculative-decoding.md +165 -26
- package/docs/design/turboquant.md +20 -7
- package/docs/design/unified-engine-frontier-plan.md +120 -13
- package/docs/planning/PRODUCT_ROADMAP.md +5 -6
- package/docs/planning/public-readiness-handoff.md +244 -0
- package/docs/planning/public-readiness.md +149 -0
- package/docs/reference/benchmarks.md +3463 -3489
- package/docs/reference/cli.md +62 -1
- package/docs/reference/distribution.md +29 -4
- package/docs/reference/environment.md +104 -8
- package/docs/reference/library-api.md +18 -4
- package/docs/reference/memory.md +1 -1
- package/docs/reference/models.md +17 -5
- package/docs/reference/server-api.md +181 -11
- package/docs/reference/server-config.md +232 -105
- package/package.json +2 -2
- package/src/audio/audiotoolbox.ts +112 -0
- package/src/audio/conformer.ts +8 -0
- package/src/audio/mic-capture.ts +98 -0
- package/src/audio/silero-vad.ts +379 -0
- package/src/audio/whisper-decode.ts +774 -0
- package/src/audio/whisper-fast.ts +732 -0
- package/src/audio/whisper-format.ts +73 -0
- package/src/audio/whisper-mel.ts +185 -0
- package/src/audio/whisper-timing.ts +274 -0
- package/src/audio/whisper-tokenizer.ts +239 -0
- package/src/audio/whisper-transcribe.ts +410 -0
- package/src/backends/mlx/autoregressive.ts +22 -1
- package/src/backends/mlx/batch-group.ts +111 -27
- package/src/backends/mlx/cache-layout.ts +3 -3
- package/src/backends/mlx/continuation-persistence.ts +5 -4
- package/src/backends/mlx/encoder-cache.ts +25 -0
- package/src/backends/mlx/fill-append.ts +21 -8
- package/src/backends/mlx/fill-group.ts +497 -0
- package/src/backends/mlx/gateway-binding.ts +56 -12
- package/src/backends/mlx/kv-maintenance.ts +30 -2
- package/src/backends/mlx/mixed-iteration.ts +62 -0
- package/src/backends/mlx/model-prompt.ts +46 -32
- package/src/backends/mlx/model-serving.ts +4 -3
- package/src/backends/mlx/paged-cache-codec.ts +27 -0
- package/src/backends/mlx/prefill-cohort.ts +15 -4
- package/src/backends/mlx/prefill-policy.ts +26 -0
- package/src/backends/mlx/prefill-rows.ts +57 -13
- package/src/backends/mlx/prompt-input.ts +29 -0
- package/src/backends/mlx/qwen-prompt-input.ts +40 -0
- package/src/backends/mlx/request-state-policy.ts +18 -5
- package/src/backends/mlx/rollback.ts +6 -3
- package/src/backends/mlx/serial-executor.ts +16 -7
- package/src/backends/mlx/speculative-group.ts +105 -26
- package/src/backends/mlx/speculative.ts +24 -7
- package/src/backends/mlx/target-layout-capability.ts +1 -1
- package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
- package/src/cli.ts +322 -0
- package/src/contracts/execution.ts +10 -0
- package/src/contracts/object-cache.ts +8 -0
- package/src/contracts/pi-provider.ts +1 -1
- package/src/contracts/pi.ts +3 -1
- package/src/contracts/prefix-cache.ts +13 -3
- package/src/contracts/scheduling.ts +9 -0
- package/src/engine/execution-plan.ts +23 -12
- package/src/engine/execution-tasks.ts +44 -0
- package/src/engine/namespaced-cache.ts +13 -0
- package/src/engine/scheduler.ts +22 -2
- package/src/fill/fill-session.ts +14 -3
- package/src/generate.ts +28 -22
- package/src/grammar.ts +13 -0
- package/src/inference/prefill.ts +6 -0
- package/src/kv-scheme.ts +22 -0
- package/src/kv-store.ts +115 -79
- package/src/lab/README.md +5 -1
- package/src/lab/paged-kv/paged-attention.ts +117 -0
- package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
- package/src/lab/paged-kv/paged-kv.ts +108 -42
- package/src/md-modules.d.ts +5 -0
- package/src/mlx/array.ts +37 -0
- package/src/mlx/ffi.ts +19 -0
- package/src/mlx/normalized-argmax.ts +116 -0
- package/src/mlx/ops.ts +97 -10
- package/src/model/batched-turboquant-kv.ts +6 -5
- package/src/model/delayed-quantized-kv.ts +3 -3
- package/src/model/delayed-rotating-quantized-kv.ts +1 -1
- package/src/model/delayed-turboquant-kv.ts +6 -4
- package/src/model/factory.ts +26 -0
- package/src/model/fingerprint.ts +12 -0
- package/src/model/full-prefill-row.ts +1 -1
- package/src/model/gemma4-base.ts +25 -6
- package/src/model/gemma4.ts +82 -2
- package/src/model/kv-attention-view.ts +4 -1
- package/src/model/profile.ts +29 -4
- package/src/model/quantized-append-attention.ts +31 -0
- package/src/model/qwen3-mrope.ts +7 -9
- package/src/model/qwen3_5.ts +99 -37
- package/src/model/support.ts +8 -0
- package/src/model/token-groups.ts +66 -0
- package/src/model/trellis-linear.ts +2 -2
- package/src/model/turboquant-codec.ts +6 -0
- package/src/model/whisper.ts +435 -0
- package/src/native/mic_capture.swift +92 -0
- package/src/native-pack.ts +6 -5
- package/src/pi-web.ts +9 -1
- package/src/prompt-cache.ts +265 -43
- package/src/runtime-config.ts +1 -5
- package/src/safetensors.ts +9 -2
- package/src/sampler.ts +5 -4
- package/src/serve/audio-routes.ts +323 -0
- package/src/serve/chat-request.ts +2 -0
- package/src/serve/chat-stage.ts +17 -12
- package/src/serve/completion-executor.ts +9 -1
- package/src/serve/discovery-routes.ts +17 -1
- package/src/serve/generated-token-history.ts +57 -0
- package/src/serve/generation-gateway.ts +52 -11
- package/src/serve/model-binding.ts +7 -1
- package/src/serve/preparation.ts +3 -3
- package/src/serve/prompt-response-trace.ts +14 -0
- package/src/serve/request-plan.ts +2 -0
- package/src/serve/request-prep.ts +38 -21
- package/src/serve/transcription-server.ts +64 -0
- package/src/serve/transcription-service.ts +502 -0
- package/src/server.ts +132 -164
- package/src/spec/assistant-rows.ts +1 -0
- package/src/spec/deepspec-rows.ts +1 -1
- package/src/spec/dflash-source.ts +1 -1
- package/src/spec/glm52-mtp-source.ts +1 -0
- package/src/spec/ngram-source.ts +27 -5
- package/src/spec/projected-draft-rows.ts +2 -2
- package/src/spec/qwen-mtp-rows.ts +11 -0
- package/src/spec/qwen-mtp-source.ts +3 -0
- package/src/spec/serve-loop.ts +5 -3
- package/src/spec/source.ts +14 -1
- package/src/spec/two-model-rows.ts +1 -0
- package/src/spec/two-model.ts +2 -2
- package/src/ssd-cache.ts +76 -17
- package/src/ssd-durability.ts +20 -22
- package/src/storage/host-buffer.ts +24 -0
- package/src/storage/kv-writer.ts +83 -0
- package/src/storage/kv-writer.worker.js +264 -0
- package/src/storage/retention-policy.ts +37 -0
- package/src/tiered-prompt-cache.ts +45 -0
- package/src/vision/embedder.ts +8 -0
- package/src/vision/prompt.ts +101 -65
- package/src/vision/qwen3vl-prompt.ts +101 -64
- package/src/vision/qwen3vl-tower.ts +11 -0
- package/src/vision/siglip.ts +8 -0
- package/src/web/app.html +12 -0
- package/src/web/app.js +160 -0
- package/src/web/src/chat.ts +4 -1
- package/src/web/src/composer.ts +3 -0
- package/src/web/src/voice.ts +141 -0
- package/src/web/sw.js +1 -1
- /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// Transcription service: owns a Whisper model's residency and runs
|
|
2
|
+
// transcriptions one at a time.
|
|
3
|
+
//
|
|
4
|
+
// Residency (the sotto ask — fast to page in, not always resident): the
|
|
5
|
+
// model loads on the first request and is DISPOSED right after each take
|
|
6
|
+
// by default (`idleUnloadSec` > 0 keeps it for that long; `resident` pins it). Weights are mmap'd safetensors, so
|
|
7
|
+
// a reload after unload reads from the OS page cache (~0.25 s for the 1.6 GB
|
|
8
|
+
// fp16 large-v3-turbo on an M1 Max, measured 2026-09-15) — the GPU memory
|
|
9
|
+
// is what an idle server gives back. Timings are reported per response.
|
|
10
|
+
//
|
|
11
|
+
// Concurrency: requests queue FIFO here; inside the full server every run is
|
|
12
|
+
// additionally wrapped in the generation gateway's exclusive lock (`exclusive`)
|
|
13
|
+
// so decoding never overlaps chat generation on the GPU.
|
|
14
|
+
|
|
15
|
+
import { decodeBytesWithAudioToolbox } from "../audio/audiotoolbox";
|
|
16
|
+
import { decodeWav, resampleTo16k } from "../audio/decode";
|
|
17
|
+
import { isRiffWave } from "../audio/transcode";
|
|
18
|
+
import { loadWhisperTokenizer, type WhisperTokenizer } from "../audio/whisper-tokenizer";
|
|
19
|
+
import {
|
|
20
|
+
WhisperTranscriber, type WhisperSegment, type WhisperStreamingRun, type WhisperTranscription,
|
|
21
|
+
} from "../audio/whisper-transcribe";
|
|
22
|
+
import { SileroVad as SileroVadClass, type VadStreamState } from "../audio/silero-vad";
|
|
23
|
+
import { clearCache } from "../mlx/ffi";
|
|
24
|
+
import { openWhisperModel } from "../model/factory";
|
|
25
|
+
import { SileroVad, type SpeechTimestampOptions, type VadSegment } from "../audio/silero-vad";
|
|
26
|
+
import type { WhisperModel } from "../model/whisper";
|
|
27
|
+
|
|
28
|
+
export interface TranscriptionParams {
|
|
29
|
+
task?: "transcribe" | "translate";
|
|
30
|
+
/** ISO code / name; null or "auto" detects. */
|
|
31
|
+
language?: string | null;
|
|
32
|
+
prompt?: string | null;
|
|
33
|
+
/** Recognition hints fitted into the prompt-token budget (sotto). */
|
|
34
|
+
vocabulary?: string[];
|
|
35
|
+
temperature?: number | null;
|
|
36
|
+
beamSize?: number | null;
|
|
37
|
+
conditionOnPreviousText?: boolean;
|
|
38
|
+
noSpeechThreshold?: number | null;
|
|
39
|
+
withoutTimestamps?: boolean;
|
|
40
|
+
/** Silero VAD gate: when no speech is detected the transcript is empty and
|
|
41
|
+
* Whisper never runs. `trim` crops the clip to the detected speech span
|
|
42
|
+
* (plus padding) before Whisper. */
|
|
43
|
+
vad?: { threshold?: number; minSpeechMs?: number; trim?: boolean } | null;
|
|
44
|
+
/** Word-level timestamps (verbose_json `words`). */
|
|
45
|
+
wordTimestamps?: boolean;
|
|
46
|
+
/** Force the faithful oracle graph (default: the fast path). */
|
|
47
|
+
faithful?: boolean;
|
|
48
|
+
/** Lab: encoder context truncation (positions, ≤ 1500). */
|
|
49
|
+
audioCtx?: number | null;
|
|
50
|
+
onSegment?: (segment: WhisperSegment) => void;
|
|
51
|
+
onProgress?: (done: number, total: number) => void;
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface VocabularyUsage {
|
|
56
|
+
included: string[];
|
|
57
|
+
omitted: string[];
|
|
58
|
+
token_count: number;
|
|
59
|
+
token_budget: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TranscriptionOutcome {
|
|
63
|
+
result: WhisperTranscription;
|
|
64
|
+
durationSeconds: number;
|
|
65
|
+
modelId: string;
|
|
66
|
+
timings: { load_ms: number; transcribe_ms: number; total_ms: number; vad_ms?: number };
|
|
67
|
+
vocabulary?: VocabularyUsage;
|
|
68
|
+
vad?: { speech: boolean; segments: { start: number; end: number }[]; trimmed?: { start: number; end: number } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TranscriptionServiceOptions {
|
|
72
|
+
modelDir: string;
|
|
73
|
+
modelId: string;
|
|
74
|
+
/** Seconds idle before the model is disposed. Default 0 = release right
|
|
75
|
+
* after each take, so the chat model gets the memory back before it
|
|
76
|
+
* prefills the transcript (a cold take costs ~150 ms more than a warm one
|
|
77
|
+
* on the M1 Max: 40 ms page-in + graph re-trace). */
|
|
78
|
+
idleUnloadSec?: number;
|
|
79
|
+
/** Never dispose (sotto-style always-resident); overrides idleUnloadSec. */
|
|
80
|
+
resident?: boolean;
|
|
81
|
+
/** Wraps each run (the full server passes gateway.runExclusive). */
|
|
82
|
+
exclusive?: <T>(fn: () => Promise<T>, signal?: AbortSignal) => Promise<T>;
|
|
83
|
+
/** Explicit Silero ggml path (default: the HF cache copy of ggml-org/whisper-vad). */
|
|
84
|
+
vadModelPath?: string | null;
|
|
85
|
+
log?: (line: string) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface Loaded {
|
|
89
|
+
model: WhisperModel;
|
|
90
|
+
tokenizer: WhisperTokenizer;
|
|
91
|
+
transcriber: WhisperTranscriber;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class TranscriptionService {
|
|
95
|
+
readonly modelDir: string;
|
|
96
|
+
readonly modelId: string;
|
|
97
|
+
readonly idleUnloadSec: number;
|
|
98
|
+
readonly resident_: boolean;
|
|
99
|
+
#loaded: Loaded | null = null;
|
|
100
|
+
#loading: Promise<Loaded> | null = null;
|
|
101
|
+
#idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
102
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
103
|
+
#active = 0;
|
|
104
|
+
#loads = 0;
|
|
105
|
+
#unloads = 0;
|
|
106
|
+
#requests = 0;
|
|
107
|
+
#lastLoadMs = 0;
|
|
108
|
+
readonly #exclusive: <T>(fn: () => Promise<T>, signal?: AbortSignal) => Promise<T>;
|
|
109
|
+
readonly #log: (line: string) => void;
|
|
110
|
+
readonly #vadModelPath: string | null;
|
|
111
|
+
#vad: SileroVad | null = null;
|
|
112
|
+
|
|
113
|
+
constructor(opts: TranscriptionServiceOptions) {
|
|
114
|
+
this.modelDir = opts.modelDir;
|
|
115
|
+
this.modelId = opts.modelId;
|
|
116
|
+
this.idleUnloadSec = Math.max(0, opts.idleUnloadSec ?? 0);
|
|
117
|
+
this.resident_ = opts.resident ?? false;
|
|
118
|
+
this.#exclusive = opts.exclusive ?? ((fn) => fn());
|
|
119
|
+
this.#log = opts.log ?? ((line) => console.log(line));
|
|
120
|
+
this.#vadModelPath = opts.vadModelPath ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The Silero gate, loaded on first use (≈1 MB, stays resident). */
|
|
124
|
+
vad(): SileroVad {
|
|
125
|
+
return this.#vad ??= SileroVad.load(this.#vadModelPath);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get resident(): boolean {
|
|
129
|
+
return this.#loaded !== null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get stats(): { resident: boolean; loads: number; unloads: number; requests: number; last_load_ms: number; idle_unload_sec: number | null } {
|
|
133
|
+
return {
|
|
134
|
+
resident: this.resident, loads: this.#loads, unloads: this.#unloads, requests: this.#requests,
|
|
135
|
+
last_load_ms: this.#lastLoadMs, idle_unload_sec: this.resident_ ? null : this.idleUnloadSec,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Load (or reuse) the model; returns the load time for this call. */
|
|
140
|
+
async ensureLoaded(): Promise<{ loadMs: number; loaded: Loaded }> {
|
|
141
|
+
if (this.#loaded) return { loadMs: 0, loaded: this.#loaded };
|
|
142
|
+
if (!this.#loading) {
|
|
143
|
+
this.#loading = (async () => {
|
|
144
|
+
const t0 = performance.now();
|
|
145
|
+
const { model } = await openWhisperModel(this.modelDir);
|
|
146
|
+
try {
|
|
147
|
+
const tokenizer = await loadWhisperTokenizer(this.modelDir, model.dims.nVocab);
|
|
148
|
+
const transcriber = new WhisperTranscriber(model, tokenizer);
|
|
149
|
+
this.#loaded = { model, tokenizer, transcriber };
|
|
150
|
+
this.#loads++;
|
|
151
|
+
this.#lastLoadMs = performance.now() - t0;
|
|
152
|
+
this.#log(`[transcription] ${this.modelId} loaded in ${this.#lastLoadMs.toFixed(0)} ms`);
|
|
153
|
+
return this.#loaded;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
model.dispose();
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
})().finally(() => { this.#loading = null; });
|
|
159
|
+
}
|
|
160
|
+
const t0 = performance.now();
|
|
161
|
+
const loaded = await this.#loading;
|
|
162
|
+
return { loadMs: performance.now() - t0, loaded };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Dispose the model (GPU memory returns; the next request reloads). */
|
|
166
|
+
unload(): boolean {
|
|
167
|
+
if (this.#idleTimer) { clearTimeout(this.#idleTimer); this.#idleTimer = null; }
|
|
168
|
+
if (!this.#loaded || this.#active > 0) return false;
|
|
169
|
+
this.#loaded.transcriber.dispose();
|
|
170
|
+
this.#loaded.model.dispose();
|
|
171
|
+
this.#loaded = null;
|
|
172
|
+
this.#unloads++;
|
|
173
|
+
// Disposing frees the arrays but MLX's allocator keeps the buffers in
|
|
174
|
+
// its cache (measured 2.7 GB after unload); hand them back to the OS.
|
|
175
|
+
clearCache();
|
|
176
|
+
this.#log(`[transcription] ${this.modelId} unloaded (idle)`);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#armIdleTimer(): void {
|
|
181
|
+
if (this.#idleTimer) clearTimeout(this.#idleTimer);
|
|
182
|
+
this.#idleTimer = null;
|
|
183
|
+
if (this.resident_) return;
|
|
184
|
+
if (this.idleUnloadSec <= 0) { if (this.#active === 0 && this.#sessions.size === 0) this.unload(); return; }
|
|
185
|
+
this.#idleTimer = setTimeout(() => { this.#idleTimer = null; this.unload(); }, this.idleUnloadSec * 1000);
|
|
186
|
+
this.#idleTimer.unref?.();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Decode to 16 kHz mono float32: WAV through the exact PCM parser
|
|
190
|
+
* (oracle scaling), everything else in-process via AudioToolbox
|
|
191
|
+
* (3.7 ms for an 11 s mp3 vs 72 ms through the afconvert subprocess). */
|
|
192
|
+
static async decodeAudio(bytes: Uint8Array): Promise<Float32Array> {
|
|
193
|
+
if (isRiffWave(bytes)) {
|
|
194
|
+
const wav = decodeWav(bytes);
|
|
195
|
+
return resampleTo16k(wav.samples, wav.sampleRate);
|
|
196
|
+
}
|
|
197
|
+
return decodeBytesWithAudioToolbox(bytes);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Fit vocabulary terms into the prompt budget (n_text_ctx/2 − 1 tokens),
|
|
201
|
+
* ", "-joined in order, whole terms only — sotto's helper semantics. */
|
|
202
|
+
static fitVocabulary(tokenizer: WhisperTokenizer, budget: number, base: string, terms: string[]): { prompt: string; usage: VocabularyUsage } {
|
|
203
|
+
const included: string[] = [];
|
|
204
|
+
const omitted: string[] = [];
|
|
205
|
+
let prompt = base.trim();
|
|
206
|
+
let tokenCount = tokenizer.encode(" " + prompt).length;
|
|
207
|
+
if (!prompt) tokenCount = 0;
|
|
208
|
+
for (const term of terms) {
|
|
209
|
+
const candidate = prompt ? `${prompt}, ${term}` : term;
|
|
210
|
+
const n = tokenizer.encode(" " + candidate.trim()).length;
|
|
211
|
+
if (n > budget) { omitted.push(term); continue; }
|
|
212
|
+
included.push(term);
|
|
213
|
+
prompt = candidate;
|
|
214
|
+
tokenCount = n;
|
|
215
|
+
}
|
|
216
|
+
return { prompt, usage: { included, omitted, token_count: tokenCount, token_budget: budget } };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async transcribe(audio: Uint8Array | Float32Array, params: TranscriptionParams = {}): Promise<TranscriptionOutcome> {
|
|
220
|
+
const run = async (): Promise<TranscriptionOutcome> => {
|
|
221
|
+
const t0 = performance.now();
|
|
222
|
+
this.#requests++;
|
|
223
|
+
if (this.#idleTimer) { clearTimeout(this.#idleTimer); this.#idleTimer = null; }
|
|
224
|
+
this.#active++;
|
|
225
|
+
try {
|
|
226
|
+
let samples = audio instanceof Float32Array ? audio : await TranscriptionService.decodeAudio(audio);
|
|
227
|
+
if (samples.length < 1600) throw new TranscriptionError("audio is shorter than 0.1 s", 400);
|
|
228
|
+
let vadInfo: TranscriptionOutcome["vad"];
|
|
229
|
+
let vadMs = 0;
|
|
230
|
+
if (params.vad) {
|
|
231
|
+
const tv = performance.now();
|
|
232
|
+
const vadOpts: SpeechTimestampOptions = { threshold: params.vad.threshold ?? 0.5, minSpeechDurationMs: params.vad.minSpeechMs ?? 250 };
|
|
233
|
+
const { segments } = this.vad().detect(samples, vadOpts);
|
|
234
|
+
vadMs = performance.now() - tv;
|
|
235
|
+
vadInfo = { speech: segments.length > 0, segments: segments.map((s: VadSegment) => ({ start: s.start / 16_000, end: s.end / 16_000 })) };
|
|
236
|
+
if (segments.length === 0) {
|
|
237
|
+
return {
|
|
238
|
+
result: { text: "", segments: [], language: params.language && params.language !== "auto" ? params.language : "en" },
|
|
239
|
+
durationSeconds: samples.length / 16_000, modelId: this.modelId,
|
|
240
|
+
timings: { load_ms: 0, transcribe_ms: 0, total_ms: performance.now() - t0, vad_ms: vadMs }, vad: vadInfo,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (params.vad.trim) {
|
|
244
|
+
const pad = 16_000 * 0.5;
|
|
245
|
+
const start = Math.max(0, Math.floor(segments[0]!.start - pad));
|
|
246
|
+
const end = Math.min(samples.length, Math.ceil(segments[segments.length - 1]!.end + pad));
|
|
247
|
+
samples = samples.subarray(start, end);
|
|
248
|
+
vadInfo.trimmed = { start: start / 16_000, end: end / 16_000 };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const { loadMs, loaded } = await this.ensureLoaded();
|
|
252
|
+
const budget = Math.floor(loaded.model.dims.nTextCtx / 2) - 1;
|
|
253
|
+
let prompt = params.prompt ?? null;
|
|
254
|
+
let vocabulary: VocabularyUsage | undefined;
|
|
255
|
+
if (params.vocabulary && params.vocabulary.length) {
|
|
256
|
+
const fit = TranscriptionService.fitVocabulary(loaded.tokenizer, budget, prompt ?? "", params.vocabulary);
|
|
257
|
+
prompt = fit.prompt || null;
|
|
258
|
+
vocabulary = fit.usage;
|
|
259
|
+
}
|
|
260
|
+
const language = params.language == null || params.language === "auto" ? null : params.language;
|
|
261
|
+
const t1 = performance.now();
|
|
262
|
+
const result = await this.#exclusive(() => loaded.transcriber.transcribe(samples, {
|
|
263
|
+
task: params.task ?? "transcribe",
|
|
264
|
+
language,
|
|
265
|
+
initialPrompt: prompt,
|
|
266
|
+
beamSize: params.beamSize ?? null,
|
|
267
|
+
...(params.temperature != null ? { temperature: params.temperature } : {}),
|
|
268
|
+
conditionOnPreviousText: params.conditionOnPreviousText ?? true,
|
|
269
|
+
...(params.noSpeechThreshold !== undefined ? { noSpeechThreshold: params.noSpeechThreshold } : {}),
|
|
270
|
+
withoutTimestamps: params.withoutTimestamps ?? false,
|
|
271
|
+
wordTimestamps: params.wordTimestamps ?? false,
|
|
272
|
+
fast: !params.faithful,
|
|
273
|
+
audioCtx: params.audioCtx ?? null,
|
|
274
|
+
onSegment: params.onSegment,
|
|
275
|
+
onProgress: params.onProgress,
|
|
276
|
+
signal: params.signal,
|
|
277
|
+
}), params.signal);
|
|
278
|
+
const t2 = performance.now();
|
|
279
|
+
if (vadInfo?.trimmed) {
|
|
280
|
+
const off = vadInfo.trimmed.start;
|
|
281
|
+
for (const seg of result.segments) {
|
|
282
|
+
seg.start += off; seg.end += off;
|
|
283
|
+
for (const w of seg.words ?? []) { w.start += off; w.end += off; }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
result,
|
|
288
|
+
durationSeconds: samples.length / 16_000,
|
|
289
|
+
modelId: this.modelId,
|
|
290
|
+
timings: { load_ms: loadMs, transcribe_ms: t2 - t1, total_ms: t2 - t0, ...(params.vad ? { vad_ms: vadMs } : {}) },
|
|
291
|
+
vocabulary,
|
|
292
|
+
vad: vadInfo,
|
|
293
|
+
};
|
|
294
|
+
} finally {
|
|
295
|
+
this.#active--;
|
|
296
|
+
this.#armIdleTimer();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
const next = this.#queue.then(run, run);
|
|
300
|
+
this.#queue = next.catch(() => undefined);
|
|
301
|
+
return next;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- streaming sessions -----------------------------------------------------
|
|
305
|
+
// Audio arrives while it is being recorded; every completed 30 s window
|
|
306
|
+
// is transcribed under the exclusive lock as soon as it exists, so the
|
|
307
|
+
// latency at finish() is one window regardless of take length.
|
|
308
|
+
|
|
309
|
+
readonly #sessions = new Map<string, TranscriptionSession>();
|
|
310
|
+
|
|
311
|
+
get sessionCount(): number {
|
|
312
|
+
return this.#sessions.size;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async createSession(params: TranscriptionParams = {}): Promise<TranscriptionSession> {
|
|
316
|
+
if (this.#sessions.size >= 64) throw new TranscriptionError("too many open transcription sessions", 429);
|
|
317
|
+
const { loaded } = await this.ensureLoaded();
|
|
318
|
+
if (this.#idleTimer) { clearTimeout(this.#idleTimer); this.#idleTimer = null; }
|
|
319
|
+
const id = crypto.randomUUID();
|
|
320
|
+
const budget = Math.floor(loaded.model.dims.nTextCtx / 2) - 1;
|
|
321
|
+
let prompt = params.prompt ?? null;
|
|
322
|
+
let vocabulary: VocabularyUsage | undefined;
|
|
323
|
+
if (params.vocabulary?.length) {
|
|
324
|
+
const fit = TranscriptionService.fitVocabulary(loaded.tokenizer, budget, prompt ?? "", params.vocabulary);
|
|
325
|
+
prompt = fit.prompt || null;
|
|
326
|
+
vocabulary = fit.usage;
|
|
327
|
+
}
|
|
328
|
+
const language = params.language == null || params.language === "auto" ? null : params.language;
|
|
329
|
+
const run = loaded.transcriber.start({
|
|
330
|
+
task: params.task ?? "transcribe", language, initialPrompt: prompt,
|
|
331
|
+
beamSize: params.beamSize ?? null,
|
|
332
|
+
...(params.temperature != null ? { temperature: params.temperature } : {}),
|
|
333
|
+
conditionOnPreviousText: params.conditionOnPreviousText ?? true,
|
|
334
|
+
...(params.noSpeechThreshold !== undefined ? { noSpeechThreshold: params.noSpeechThreshold } : {}),
|
|
335
|
+
withoutTimestamps: params.withoutTimestamps ?? false,
|
|
336
|
+
wordTimestamps: params.wordTimestamps ?? false,
|
|
337
|
+
fast: !params.faithful,
|
|
338
|
+
});
|
|
339
|
+
const session = new TranscriptionSession(id, this, run, params, vocabulary, params.vad ? this.vad() : null);
|
|
340
|
+
this.#sessions.set(id, session);
|
|
341
|
+
this.#active++;
|
|
342
|
+
return session;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
session(id: string): TranscriptionSession | null {
|
|
346
|
+
return this.#sessions.get(id) ?? null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** @internal called by the session when it closes. */
|
|
350
|
+
releaseSession(id: string): void {
|
|
351
|
+
if (this.#sessions.delete(id)) {
|
|
352
|
+
this.#active--;
|
|
353
|
+
this.#requests++;
|
|
354
|
+
this.#armIdleTimer();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @internal window work runs under the host's exclusive lock, FIFO with requests. */
|
|
359
|
+
runExclusiveQueued<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
360
|
+
const next = this.#queue.then(() => this.#exclusive(fn, signal), () => this.#exclusive(fn, signal));
|
|
361
|
+
this.#queue = next.catch(() => undefined);
|
|
362
|
+
return next;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
dispose(): void {
|
|
366
|
+
for (const s of this.#sessions.values()) s.close();
|
|
367
|
+
if (this.#idleTimer) clearTimeout(this.#idleTimer);
|
|
368
|
+
this.#idleTimer = null;
|
|
369
|
+
if (this.#loaded) {
|
|
370
|
+
this.#loaded.transcriber.dispose();
|
|
371
|
+
this.#loaded.model.dispose();
|
|
372
|
+
this.#loaded = null;
|
|
373
|
+
}
|
|
374
|
+
this.#vad?.dispose();
|
|
375
|
+
this.#vad = null;
|
|
376
|
+
clearCache();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export class TranscriptionError extends Error {
|
|
381
|
+
constructor(message: string, readonly status: number) {
|
|
382
|
+
super(message);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Registry lookup for the default Whisper checkpoint (first downloaded
|
|
387
|
+
* `whisper` record) — null when none is on disk. */
|
|
388
|
+
export async function defaultWhisperRecord(): Promise<{ path: string; repoId: string } | null> {
|
|
389
|
+
const { Registry } = await import("../registry");
|
|
390
|
+
const reg = new Registry();
|
|
391
|
+
try {
|
|
392
|
+
if (reg.list().length === 0) await reg.scan();
|
|
393
|
+
const hit = reg.listCanonical().find((r) => r.modelType === "whisper");
|
|
394
|
+
return hit ? { path: hit.path, repoId: hit.repoId } : null;
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
} finally {
|
|
398
|
+
reg.close();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** One live dictation: append PCM, windows transcribe eagerly, finish. */
|
|
403
|
+
export class TranscriptionSession {
|
|
404
|
+
readonly createdAt = Date.now();
|
|
405
|
+
#samples = 0;
|
|
406
|
+
#closed = false;
|
|
407
|
+
#speech = false;
|
|
408
|
+
#vadState: VadStreamState | null;
|
|
409
|
+
#vadProbs: number[] = [];
|
|
410
|
+
#pendingSamples: Float32Array[] = [];
|
|
411
|
+
#pendingLen = 0;
|
|
412
|
+
#feeding: Promise<void> = Promise.resolve();
|
|
413
|
+
#feedError: Error | null = null;
|
|
414
|
+
readonly #vad: SileroVadClass | null;
|
|
415
|
+
|
|
416
|
+
constructor(
|
|
417
|
+
readonly id: string, readonly service: TranscriptionService, readonly run: WhisperStreamingRun,
|
|
418
|
+
readonly params: TranscriptionParams, readonly vocabulary: VocabularyUsage | undefined, vad: SileroVadClass | null,
|
|
419
|
+
) {
|
|
420
|
+
this.#vad = vad;
|
|
421
|
+
this.#vadState = vad ? SileroVadClass.streamState() : null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
get samples(): number { return this.#samples; }
|
|
425
|
+
get durationSeconds(): number { return this.#samples / 16_000; }
|
|
426
|
+
get closed(): boolean { return this.#closed; }
|
|
427
|
+
get speechDetected(): boolean { return this.#speech; }
|
|
428
|
+
|
|
429
|
+
/** Append 16 kHz mono float32 PCM. Returns the segments finalized so far. */
|
|
430
|
+
append(pcm: Float32Array): Promise<{ segments: WhisperSegment[]; speech: boolean }> {
|
|
431
|
+
if (this.#closed) throw new TranscriptionError("session is closed", 409);
|
|
432
|
+
this.#samples += pcm.length;
|
|
433
|
+
// streaming VAD in whole 512-sample chunks; the remainder waits
|
|
434
|
+
if (this.#vad && this.#vadState) {
|
|
435
|
+
this.#pendingSamples.push(pcm);
|
|
436
|
+
this.#pendingLen += pcm.length;
|
|
437
|
+
const whole = this.#pendingLen - (this.#pendingLen % 512);
|
|
438
|
+
if (whole > 0) {
|
|
439
|
+
const buf = new Float32Array(this.#pendingLen);
|
|
440
|
+
let o = 0;
|
|
441
|
+
for (const p of this.#pendingSamples) { buf.set(p, o); o += p.length; }
|
|
442
|
+
const probs = this.#vad.probsStreaming(buf.subarray(0, whole), this.#vadState);
|
|
443
|
+
for (const p of probs) { this.#vadProbs.push(p); if (p >= (this.params.vad?.threshold ?? 0.5)) this.#speech = true; }
|
|
444
|
+
const rest = buf.subarray(whole);
|
|
445
|
+
this.#pendingSamples = rest.length ? [rest.slice()] : [];
|
|
446
|
+
this.#pendingLen = rest.length;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// serialize feeds; window work takes the exclusive lock
|
|
450
|
+
const work = async () => {
|
|
451
|
+
if (this.#vad && !this.#speech) { await this.run.feedSilent(pcm); return; }
|
|
452
|
+
await this.service.runExclusiveQueued(() => this.run.feed(pcm));
|
|
453
|
+
};
|
|
454
|
+
this.#feeding = this.#feeding.then(work, work).catch((e) => { this.#feedError = e as Error; });
|
|
455
|
+
return this.#feeding.then(() => {
|
|
456
|
+
if (this.#feedError) throw this.#feedError;
|
|
457
|
+
return { segments: [...this.run.segments], speech: this.#speech };
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Transcribe the remainder and close. */
|
|
462
|
+
async finish(): Promise<TranscriptionOutcome> {
|
|
463
|
+
if (this.#closed) throw new TranscriptionError("session is closed", 409);
|
|
464
|
+
const t0 = performance.now();
|
|
465
|
+
await this.#feeding;
|
|
466
|
+
if (this.#feedError) { this.close(); throw this.#feedError; }
|
|
467
|
+
try {
|
|
468
|
+
let vad: TranscriptionOutcome["vad"];
|
|
469
|
+
if (this.#vad && this.#vadState) {
|
|
470
|
+
if (this.#pendingLen > 0) {
|
|
471
|
+
const buf = new Float32Array(this.#pendingLen);
|
|
472
|
+
let o = 0;
|
|
473
|
+
for (const p of this.#pendingSamples) { buf.set(p, o); o += p.length; }
|
|
474
|
+
for (const p of this.#vad.probsStreaming(buf, this.#vadState)) { this.#vadProbs.push(p); if (p >= (this.params.vad?.threshold ?? 0.5)) this.#speech = true; }
|
|
475
|
+
}
|
|
476
|
+
const segs = SileroVadClass.speechTimestamps(Float32Array.from(this.#vadProbs), this.#samples, { threshold: this.params.vad?.threshold ?? 0.5, minSpeechDurationMs: this.params.vad?.minSpeechMs ?? 250 });
|
|
477
|
+
vad = { speech: segs.length > 0, segments: segs.map((x) => ({ start: x.start / 16_000, end: x.end / 16_000 })) };
|
|
478
|
+
if (segs.length === 0) {
|
|
479
|
+
return {
|
|
480
|
+
result: { text: "", segments: [], language: this.params.language && this.params.language !== "auto" ? this.params.language : "en" },
|
|
481
|
+
durationSeconds: this.durationSeconds, modelId: this.service.modelId,
|
|
482
|
+
timings: { load_ms: 0, transcribe_ms: 0, total_ms: performance.now() - t0 }, vad, vocabulary: this.vocabulary,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const result = await this.service.runExclusiveQueued(() => this.run.finish());
|
|
487
|
+
const t1 = performance.now();
|
|
488
|
+
return {
|
|
489
|
+
result, durationSeconds: this.durationSeconds, modelId: this.service.modelId,
|
|
490
|
+
timings: { load_ms: 0, transcribe_ms: t1 - t0, total_ms: t1 - t0 }, vocabulary: this.vocabulary, vad,
|
|
491
|
+
};
|
|
492
|
+
} finally {
|
|
493
|
+
this.close();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
close(): void {
|
|
498
|
+
if (this.#closed) return;
|
|
499
|
+
this.#closed = true;
|
|
500
|
+
this.service.releaseSession(this.id);
|
|
501
|
+
}
|
|
502
|
+
}
|