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,73 @@
|
|
|
1
|
+
// Transcription output formats shared by the CLI and /v1/audio/transcriptions:
|
|
2
|
+
// OpenAI `json` / `verbose_json` / `text` / `srt` / `vtt`.
|
|
3
|
+
|
|
4
|
+
import type { WhisperSegment, WhisperTranscription } from "./whisper-transcribe";
|
|
5
|
+
|
|
6
|
+
export type TranscriptionFormat = "json" | "verbose_json" | "text" | "srt" | "vtt";
|
|
7
|
+
|
|
8
|
+
export const TRANSCRIPTION_FORMATS: readonly TranscriptionFormat[] =
|
|
9
|
+
["json", "verbose_json", "text", "srt", "vtt"];
|
|
10
|
+
|
|
11
|
+
function pad2(n: number): string {
|
|
12
|
+
return n < 10 ? `0${n}` : String(n);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** HH:MM:SS,mmm (srt) or HH:MM:SS.mmm (vtt). */
|
|
16
|
+
export function formatTimestamp(seconds: number, separator: "," | "." = ","): string {
|
|
17
|
+
let ms = Math.round(seconds * 1000);
|
|
18
|
+
const hours = Math.floor(ms / 3_600_000);
|
|
19
|
+
ms -= hours * 3_600_000;
|
|
20
|
+
const minutes = Math.floor(ms / 60_000);
|
|
21
|
+
ms -= minutes * 60_000;
|
|
22
|
+
const secs = Math.floor(ms / 1000);
|
|
23
|
+
ms -= secs * 1000;
|
|
24
|
+
return `${pad2(hours)}:${pad2(minutes)}:${pad2(secs)}${separator}${String(ms).padStart(3, "0")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toSrt(segments: WhisperSegment[]): string {
|
|
28
|
+
let out = "";
|
|
29
|
+
let n = 1;
|
|
30
|
+
for (const s of segments) {
|
|
31
|
+
if (!s.text.trim()) continue;
|
|
32
|
+
out += `${n++}\n${formatTimestamp(s.start, ",")} --> ${formatTimestamp(s.end, ",")}\n${s.text.trim()}\n\n`;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function toVtt(segments: WhisperSegment[]): string {
|
|
38
|
+
let out = "WEBVTT\n\n";
|
|
39
|
+
for (const s of segments) {
|
|
40
|
+
if (!s.text.trim()) continue;
|
|
41
|
+
out += `${formatTimestamp(s.start, ".")} --> ${formatTimestamp(s.end, ".")}\n${s.text.trim()}\n\n`;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** OpenAI verbose_json body (segments carry the openai-whisper fields). */
|
|
47
|
+
export function toVerboseJson(r: WhisperTranscription, durationSeconds: number, task: string): Record<string, unknown> {
|
|
48
|
+
return {
|
|
49
|
+
task,
|
|
50
|
+
language: r.language,
|
|
51
|
+
duration: durationSeconds,
|
|
52
|
+
text: r.text.trim(),
|
|
53
|
+
segments: r.segments.map((s) => ({
|
|
54
|
+
id: s.id, seek: s.seek, start: s.start, end: s.end, text: s.text, tokens: s.tokens,
|
|
55
|
+
temperature: s.temperature, avg_logprob: s.avgLogprob, compression_ratio: s.compressionRatio,
|
|
56
|
+
no_speech_prob: s.noSpeechProb,
|
|
57
|
+
...(s.words ? { words: s.words } : {}),
|
|
58
|
+
})),
|
|
59
|
+
...(r.segments.some((s) => s.words) ? { words: r.segments.flatMap((s) => s.words ?? []) } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatTranscription(
|
|
64
|
+
r: WhisperTranscription, format: TranscriptionFormat, durationSeconds: number, task: string,
|
|
65
|
+
): string {
|
|
66
|
+
switch (format) {
|
|
67
|
+
case "text": return r.text.trim() + "\n";
|
|
68
|
+
case "json": return JSON.stringify({ text: r.text.trim() }) + "\n";
|
|
69
|
+
case "verbose_json": return JSON.stringify(toVerboseJson(r, durationSeconds, task)) + "\n";
|
|
70
|
+
case "srt": return toSrt(r.segments);
|
|
71
|
+
case "vtt": return toVtt(r.segments);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Whisper log-mel spectrogram — an op-for-op port of mlx-whisper's
|
|
2
|
+
// audio.py (log_mel_spectrogram / stft / mel_filters), gated bit-exact
|
|
3
|
+
// against the oracle in tests/parity/whisper.test.ts.
|
|
4
|
+
//
|
|
5
|
+
// Pipeline (single 16 kHz mono float32 waveform):
|
|
6
|
+
// [optional zero pad of `padding` samples on the right]
|
|
7
|
+
// → reflect pad N_FFT/2 = 200 samples each side (done host-side; the
|
|
8
|
+
// values are a copy so the device graph is identical)
|
|
9
|
+
// → frames [T, 400] via as_strided (stride HOP = 160)
|
|
10
|
+
// → × periodic Hann window (np.hanning(401)[:-1], float32)
|
|
11
|
+
// → rfft n=400 → drop the LAST frame → |·|² (float32)
|
|
12
|
+
// → @ melᵀ [201, 128] (librosa slaney filters, float32 — built below)
|
|
13
|
+
// → log10(max(·, 1e-10)) → max(·, global_max − 8) → (· + 4) / 4
|
|
14
|
+
// Output: [T, n_mels] float32 where T = (len + 400 − 400 + 160) // 160 − 1
|
|
15
|
+
// (the oracle's `freqs[:-1]`), i.e. exactly len/160 frames for a multiple
|
|
16
|
+
// of the hop.
|
|
17
|
+
//
|
|
18
|
+
// The mel filter bank reproduces librosa.filters.mel(sr=16000, n_fft=400,
|
|
19
|
+
// n_mels, htk=False, norm="slaney") in float64 and rounds to float32 — the
|
|
20
|
+
// same values mlx-whisper ships pre-computed in assets/mel_filters.npz
|
|
21
|
+
// (checked bit-exact by the parity test against the oracle dump).
|
|
22
|
+
|
|
23
|
+
import { MlxArray } from "../mlx/array";
|
|
24
|
+
import { Dtype } from "../mlx/ffi";
|
|
25
|
+
import * as ops from "../mlx/ops";
|
|
26
|
+
|
|
27
|
+
export const WHISPER_SAMPLE_RATE = 16_000;
|
|
28
|
+
export const WHISPER_N_FFT = 400;
|
|
29
|
+
export const WHISPER_HOP = 160;
|
|
30
|
+
export const WHISPER_CHUNK_SECONDS = 30;
|
|
31
|
+
/** 480_000 samples per 30 s window. */
|
|
32
|
+
export const WHISPER_N_SAMPLES = WHISPER_CHUNK_SECONDS * WHISPER_SAMPLE_RATE;
|
|
33
|
+
/** 3000 mel frames per window. */
|
|
34
|
+
export const WHISPER_N_FRAMES = WHISPER_N_SAMPLES / WHISPER_HOP;
|
|
35
|
+
/** 100 mel frames per second. */
|
|
36
|
+
export const WHISPER_FRAMES_PER_SECOND = WHISPER_SAMPLE_RATE / WHISPER_HOP;
|
|
37
|
+
/** 50 encoder positions per second (conv2 stride 2). */
|
|
38
|
+
export const WHISPER_TOKENS_PER_SECOND = WHISPER_SAMPLE_RATE / (WHISPER_HOP * 2);
|
|
39
|
+
|
|
40
|
+
const dispose = (old: MlxArray, next: MlxArray): MlxArray => {
|
|
41
|
+
old.dispose();
|
|
42
|
+
return next;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// --- librosa slaney mel filter bank ----------------------------------------
|
|
46
|
+
const F_SP = 200.0 / 3;
|
|
47
|
+
const MIN_LOG_HZ = 1000.0;
|
|
48
|
+
const MIN_LOG_MEL = MIN_LOG_HZ / F_SP; // 15
|
|
49
|
+
const LOGSTEP = Math.log(6.4) / 27.0;
|
|
50
|
+
|
|
51
|
+
function hzToMel(f: number): number {
|
|
52
|
+
const m = f / F_SP;
|
|
53
|
+
return f >= MIN_LOG_HZ ? MIN_LOG_MEL + Math.log(f / MIN_LOG_HZ) / LOGSTEP : m;
|
|
54
|
+
}
|
|
55
|
+
function melToHz(m: number): number {
|
|
56
|
+
return m >= MIN_LOG_MEL ? MIN_LOG_HZ * Math.exp(LOGSTEP * (m - MIN_LOG_MEL)) : F_SP * m;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Row-major [nMels, 201] float32 — librosa.filters.mel semantics. */
|
|
60
|
+
export function whisperMelFilters(nMels: number): Float32Array {
|
|
61
|
+
const nBins = WHISPER_N_FFT / 2 + 1; // 201
|
|
62
|
+
const fmax = WHISPER_SAMPLE_RATE / 2;
|
|
63
|
+
// np.fft.rfftfreq(400, 1/16000) = arange(201) * 16000/400
|
|
64
|
+
// np.fft.rfftfreq: val = 1/(n·d) with d = 1/sr, then k·val — keep numpy's
|
|
65
|
+
// float64 rounding (1/16000 is inexact in binary) for bit-exact filters.
|
|
66
|
+
const val = 1.0 / (WHISPER_N_FFT * (1.0 / WHISPER_SAMPLE_RATE));
|
|
67
|
+
const fftFreqs = new Float64Array(nBins);
|
|
68
|
+
for (let k = 0; k < nBins; k++) fftFreqs[k] = k * val;
|
|
69
|
+
// mel_frequencies(n_mels + 2): np.linspace in mel space, mapped to Hz
|
|
70
|
+
const nPts = nMels + 2;
|
|
71
|
+
const melMin = hzToMel(0);
|
|
72
|
+
const melMax = hzToMel(fmax);
|
|
73
|
+
const melF = new Float64Array(nPts);
|
|
74
|
+
const step = (melMax - melMin) / (nPts - 1);
|
|
75
|
+
for (let i = 0; i < nPts; i++) melF[i] = melToHz(melMin + step * i);
|
|
76
|
+
const out = new Float32Array(nMels * nBins);
|
|
77
|
+
for (let i = 0; i < nMels; i++) {
|
|
78
|
+
const fd0 = melF[i + 1]! - melF[i]!;
|
|
79
|
+
const fd1 = melF[i + 2]! - melF[i + 1]!;
|
|
80
|
+
const enorm = 2.0 / (melF[i + 2]! - melF[i]!);
|
|
81
|
+
for (let k = 0; k < nBins; k++) {
|
|
82
|
+
const lower = -(melF[i]! - fftFreqs[k]!) / fd0;
|
|
83
|
+
const upper = (melF[i + 2]! - fftFreqs[k]!) / fd1;
|
|
84
|
+
// librosa stores the un-normalized weight into a float32 array, then
|
|
85
|
+
// `weights *= enorm[:, None]` (float64 multiply, float32 store).
|
|
86
|
+
const w = Math.fround(Math.max(0, Math.min(lower, upper)));
|
|
87
|
+
out[i * nBins + k] = Math.fround(w * enorm);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** np.hanning(N+1)[:-1] as float32 (periodic Hann). */
|
|
94
|
+
export function whisperHannWindow(): Float32Array {
|
|
95
|
+
const n = WHISPER_N_FFT;
|
|
96
|
+
const w = new Float32Array(n);
|
|
97
|
+
for (let i = 0; i < n; i++) w[i] = Math.fround(0.5 - 0.5 * Math.cos((2 * Math.PI * i) / n));
|
|
98
|
+
return w;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Device-resident constants for one mel configuration (built once per model). */
|
|
102
|
+
export class WhisperMelExtractor {
|
|
103
|
+
readonly nMels: number;
|
|
104
|
+
/** [201, nMels] float32 — filters transposed for `magnitudes @ filtersᵀ`. */
|
|
105
|
+
readonly #filtersT: MlxArray;
|
|
106
|
+
/** [400] float32. */
|
|
107
|
+
readonly #window: MlxArray;
|
|
108
|
+
|
|
109
|
+
constructor(nMels: number) {
|
|
110
|
+
this.nMels = nMels;
|
|
111
|
+
const f = whisperMelFilters(nMels);
|
|
112
|
+
const nBins = WHISPER_N_FFT / 2 + 1;
|
|
113
|
+
const t = new Float32Array(nBins * nMels);
|
|
114
|
+
for (let i = 0; i < nMels; i++)
|
|
115
|
+
for (let k = 0; k < nBins; k++) t[k * nMels + i] = f[i * nBins + k]!;
|
|
116
|
+
this.#filtersT = MlxArray.fromFloat32(t, [nBins, nMels]);
|
|
117
|
+
this.#window = MlxArray.fromFloat32(whisperHannWindow(), [WHISPER_N_FFT]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Number of mel frames the oracle produces for `numSamples` (+ padding). */
|
|
121
|
+
static frameCount(numSamples: number, padding = 0): number {
|
|
122
|
+
const len = numSamples + padding + WHISPER_N_FFT; // reflect-padded length
|
|
123
|
+
return Math.floor((len - WHISPER_N_FFT + WHISPER_HOP) / WHISPER_HOP) - 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** log_mel_spectrogram(audio, n_mels, padding) → [T, nMels] float32 (lazy). */
|
|
127
|
+
logMel(samples: Float32Array, padding = 0): MlxArray {
|
|
128
|
+
const half = WHISPER_N_FFT / 2;
|
|
129
|
+
const n = samples.length + padding;
|
|
130
|
+
if (samples.length <= half) throw new Error(`whisper mel: clip too short (${samples.length} samples)`);
|
|
131
|
+
// host-side: zero pad right, then reflect pad `half` on both sides
|
|
132
|
+
const padded = new Float32Array(n + 2 * half);
|
|
133
|
+
// prefix = x[1:half+1][::-1]
|
|
134
|
+
for (let i = 0; i < half; i++) padded[i] = samples[half - i] ?? 0;
|
|
135
|
+
padded.set(samples, half);
|
|
136
|
+
// (zero padding region already 0)
|
|
137
|
+
// suffix = x[-(half+1):-1][::-1] over the ZERO-PADDED signal
|
|
138
|
+
const src = (idx: number): number => (idx < samples.length ? samples[idx]! : 0);
|
|
139
|
+
for (let i = 0; i < half; i++) padded[half + n + i] = src(n - 2 - i);
|
|
140
|
+
const x = MlxArray.fromFloat32(padded, [padded.length]);
|
|
141
|
+
const t = Math.floor((padded.length - WHISPER_N_FFT + WHISPER_HOP) / WHISPER_HOP);
|
|
142
|
+
let frames = ops.asStrided(x, [t, WHISPER_N_FFT], [WHISPER_HOP, 1]);
|
|
143
|
+
frames = dispose(frames, ops.mul(frames, this.#window));
|
|
144
|
+
let spec = ops.rfft(frames, WHISPER_N_FFT, -1);
|
|
145
|
+
frames.dispose();
|
|
146
|
+
x.dispose();
|
|
147
|
+
spec = dispose(spec, spec.slice([0, 0], [t - 1, WHISPER_N_FFT / 2 + 1]));
|
|
148
|
+
spec = dispose(spec, ops.abs(spec));
|
|
149
|
+
spec = dispose(spec, ops.square(spec));
|
|
150
|
+
let mel = ops.matmul(spec, this.#filtersT);
|
|
151
|
+
spec.dispose();
|
|
152
|
+
const floor = ops.scalarLike(1e-10, mel);
|
|
153
|
+
mel = dispose(mel, ops.maximum(mel, floor));
|
|
154
|
+
floor.dispose();
|
|
155
|
+
mel = dispose(mel, ops.log10(mel));
|
|
156
|
+
const mx = ops.maxAll(mel);
|
|
157
|
+
const eight = ops.scalarLike(8.0, mel);
|
|
158
|
+
const lo = ops.sub(mx, eight);
|
|
159
|
+
mel = dispose(mel, ops.maximum(mel, lo));
|
|
160
|
+
mx.dispose();
|
|
161
|
+
eight.dispose();
|
|
162
|
+
lo.dispose();
|
|
163
|
+
const four = ops.scalarLike(4.0, mel);
|
|
164
|
+
mel = dispose(mel, ops.add(mel, four));
|
|
165
|
+
mel = dispose(mel, ops.div(mel, four));
|
|
166
|
+
four.dispose();
|
|
167
|
+
return mel;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
dispose(): void {
|
|
171
|
+
this.#filtersT.dispose();
|
|
172
|
+
this.#window.dispose();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** pad_or_trim along the frame axis to `length` frames (zeros on the right). */
|
|
177
|
+
export function padOrTrimFrames(mel: MlxArray, length: number, dtype: Dtype): MlxArray {
|
|
178
|
+
const [t, m] = mel.shape as [number, number];
|
|
179
|
+
let out: MlxArray;
|
|
180
|
+
if (t > length) out = mel.slice([0, 0], [length, m]);
|
|
181
|
+
else if (t < length) out = ops.pad(mel, [[0, length - t], [0, 0]]);
|
|
182
|
+
else out = ops.copyOf(mel);
|
|
183
|
+
if (out.dtype !== dtype) out = dispose(out, out.astype(dtype));
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Word-level timestamps — a port of mlx-whisper's timing.py: cross-attention
|
|
2
|
+
// weights of the checkpoint's `alignment_heads` over the text tokens →
|
|
3
|
+
// z-normalize per head → median filter (width 7) along time → mean over
|
|
4
|
+
// heads → DTW (numba dtw_cpu / backtrace, ported to plain JS) → word
|
|
5
|
+
// boundaries from the tokenizer's word split; then add_word_timestamps'
|
|
6
|
+
// duration heuristics and punctuation merging. Runs the FAITHFUL decoder
|
|
7
|
+
// once per window with `wantCrossQk` (the alignment pass of the reference).
|
|
8
|
+
|
|
9
|
+
import { MlxArray } from "../mlx/array";
|
|
10
|
+
import { Dtype } from "../mlx/ffi";
|
|
11
|
+
import * as ops from "../mlx/ops";
|
|
12
|
+
import { WhisperKvCache, type WhisperModel } from "../model/whisper";
|
|
13
|
+
import { WHISPER_HOP, WHISPER_SAMPLE_RATE, WHISPER_TOKENS_PER_SECOND } from "./whisper-mel";
|
|
14
|
+
import type { WhisperTokenizer } from "./whisper-tokenizer";
|
|
15
|
+
import type { WhisperSegment, WhisperWord } from "./whisper-transcribe";
|
|
16
|
+
|
|
17
|
+
export interface WordTiming {
|
|
18
|
+
word: string;
|
|
19
|
+
tokens: number[];
|
|
20
|
+
start: number;
|
|
21
|
+
end: number;
|
|
22
|
+
probability: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Reflect-padded median filter along the last axis (scipy.signal.medfilt semantics
|
|
26
|
+
* on the padded row; odd width). */
|
|
27
|
+
export function medianFilter(x: Float32Array, rows: number, cols: number, width: number): Float32Array {
|
|
28
|
+
const pad = Math.floor(width / 2);
|
|
29
|
+
if (cols <= pad) return x;
|
|
30
|
+
const out = new Float32Array(rows * cols);
|
|
31
|
+
const buf = new Float32Array(width);
|
|
32
|
+
for (let r = 0; r < rows; r++) {
|
|
33
|
+
const row = x.subarray(r * cols, (r + 1) * cols);
|
|
34
|
+
for (let c = 0; c < cols; c++) {
|
|
35
|
+
for (let k = 0; k < width; k++) {
|
|
36
|
+
let j = c - pad + k;
|
|
37
|
+
if (j < 0) j = -j; // reflect
|
|
38
|
+
else if (j >= cols) j = 2 * cols - j - 2;
|
|
39
|
+
buf[k] = row[j]!;
|
|
40
|
+
}
|
|
41
|
+
const sorted = buf.slice().sort();
|
|
42
|
+
out[r * cols + c] = sorted[pad]!;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** DTW over cost matrix x [N, M] (row-major); returns the aligned index
|
|
49
|
+
* pairs (text_indices, time_indices) like timing.dtw_cpu + backtrace. */
|
|
50
|
+
export function dtw(x: Float32Array, N: number, M: number): { text: Int32Array; time: Int32Array } {
|
|
51
|
+
const cost = new Float32Array((N + 1) * (M + 1)).fill(Infinity);
|
|
52
|
+
const trace = new Int8Array((N + 1) * (M + 1)).fill(-1);
|
|
53
|
+
const W = M + 1;
|
|
54
|
+
cost[0] = 0;
|
|
55
|
+
for (let j = 1; j <= M; j++) {
|
|
56
|
+
for (let i = 1; i <= N; i++) {
|
|
57
|
+
const c0 = cost[(i - 1) * W + (j - 1)]!;
|
|
58
|
+
const c1 = cost[(i - 1) * W + j]!;
|
|
59
|
+
const c2 = cost[i * W + (j - 1)]!;
|
|
60
|
+
let c: number;
|
|
61
|
+
let t: number;
|
|
62
|
+
if (c0 < c1 && c0 < c2) { c = c0; t = 0; }
|
|
63
|
+
else if (c1 < c0 && c1 < c2) { c = c1; t = 1; }
|
|
64
|
+
else { c = c2; t = 2; }
|
|
65
|
+
cost[i * W + j] = x[(i - 1) * M + (j - 1)]! + c;
|
|
66
|
+
trace[i * W + j] = t;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// backtrace
|
|
70
|
+
let i = N;
|
|
71
|
+
let j = M;
|
|
72
|
+
for (let jj = 0; jj <= M; jj++) trace[jj] = 2;
|
|
73
|
+
for (let ii = 0; ii <= N; ii++) trace[ii * W] = 1;
|
|
74
|
+
const text: number[] = [];
|
|
75
|
+
const time: number[] = [];
|
|
76
|
+
while (i > 0 || j > 0) {
|
|
77
|
+
text.push(i - 1);
|
|
78
|
+
time.push(j - 1);
|
|
79
|
+
const t = trace[i * W + j];
|
|
80
|
+
if (t === 0) { i--; j--; }
|
|
81
|
+
else if (t === 1) i--;
|
|
82
|
+
else if (t === 2) j--;
|
|
83
|
+
else throw new Error("Unexpected trace value");
|
|
84
|
+
}
|
|
85
|
+
text.reverse();
|
|
86
|
+
time.reverse();
|
|
87
|
+
return { text: Int32Array.from(text), time: Int32Array.from(time) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** find_alignment: word timings for `textTokens` within one 30 s window
|
|
91
|
+
* (`mel` [1, 3000, n_mels] model dtype, `numFrames` = content mel frames). */
|
|
92
|
+
export function findAlignment(
|
|
93
|
+
model: WhisperModel, tokenizer: WhisperTokenizer, language: string,
|
|
94
|
+
textTokens: number[], mel: MlxArray, numFrames: number,
|
|
95
|
+
medfiltWidth = 7, qkScale = 1.0,
|
|
96
|
+
): WordTiming[] {
|
|
97
|
+
if (textTokens.length === 0) return [];
|
|
98
|
+
const sot = tokenizer.sotSequence(language, "transcribe");
|
|
99
|
+
const tokens = [...sot, tokenizer.noTimestamps, ...textTokens, tokenizer.eot];
|
|
100
|
+
const feats = model.encode(mel);
|
|
101
|
+
const cache = new WhisperKvCache(model.dims.nTextLayer);
|
|
102
|
+
const tokArr = ops.fromInt32(tokens, [1, tokens.length]);
|
|
103
|
+
const { logits, crossQk } = model.decode(tokArr, feats, cache, { wantCrossQk: true });
|
|
104
|
+
tokArr.dispose();
|
|
105
|
+
feats.dispose();
|
|
106
|
+
cache.dispose();
|
|
107
|
+
const V = model.dims.nVocab;
|
|
108
|
+
const T = tokens.length;
|
|
109
|
+
// text-token probabilities from the logits predicting each text token
|
|
110
|
+
let sampled = logits.slice([0, sot.length, 0], [1, T - 2, tokenizer.eot]);
|
|
111
|
+
logits.dispose();
|
|
112
|
+
sampled = (() => { const r = ops.reshape(sampled, [T - 2 - sot.length, tokenizer.eot]); sampled.dispose(); return r; })();
|
|
113
|
+
const probs = ops.softmaxAxis(sampled, -1, true);
|
|
114
|
+
sampled.dispose();
|
|
115
|
+
const idx = ops.fromInt32(textTokens, [textTokens.length, 1]);
|
|
116
|
+
const picked = ops.takeAlongAxis(probs, idx, 1);
|
|
117
|
+
idx.dispose();
|
|
118
|
+
probs.dispose();
|
|
119
|
+
const textTokenProbs = picked.toFloat32();
|
|
120
|
+
picked.dispose();
|
|
121
|
+
// alignment heads: [heads, T, frames]
|
|
122
|
+
const heads = model.alignmentHeads;
|
|
123
|
+
const frames = Math.floor(numFrames / 2);
|
|
124
|
+
const stacked: MlxArray[] = [];
|
|
125
|
+
for (const [l, h] of heads) {
|
|
126
|
+
const qk = crossQk[l];
|
|
127
|
+
if (!qk) continue;
|
|
128
|
+
const w = qk.slice([0, h, 0, 0], [1, h + 1, T, frames]); // [1,1,T,frames]
|
|
129
|
+
stacked.push(ops.reshape(w, [T, frames]));
|
|
130
|
+
w.dispose();
|
|
131
|
+
}
|
|
132
|
+
for (const q of crossQk) q?.dispose();
|
|
133
|
+
let weights = ops.stackAxis(stacked, 0); // [heads, T, frames]
|
|
134
|
+
for (const a of stacked) a.dispose();
|
|
135
|
+
if (qkScale !== 1.0) weights = (() => { const s = ops.scalarLike(qkScale, weights); const r = ops.mul(weights, s); s.dispose(); weights.dispose(); return r; })();
|
|
136
|
+
weights = (() => { const r = ops.softmaxAxis(weights, -1, true); weights.dispose(); return r; })();
|
|
137
|
+
weights = (() => { const r = weights.astype(Dtype.float32); weights.dispose(); return r; })();
|
|
138
|
+
const mean = ops.meanAxis(weights, -2, true);
|
|
139
|
+
let std = ops.varAxis(weights, -2, true, 0);
|
|
140
|
+
std = (() => { const r = ops.sqrt(std); std.dispose(); return r; })();
|
|
141
|
+
let z = ops.sub(weights, mean);
|
|
142
|
+
z = (() => { const r = ops.div(z, std); z.dispose(); return r; })();
|
|
143
|
+
weights.dispose(); mean.dispose(); std.dispose();
|
|
144
|
+
const nHeads = stacked.length;
|
|
145
|
+
const zHost = z.toFloat32();
|
|
146
|
+
z.dispose();
|
|
147
|
+
const filtered = medianFilter(zHost, nHeads * T, frames, medfiltWidth);
|
|
148
|
+
// mean over heads → [T, frames], then rows sot.length … T-2
|
|
149
|
+
const rows = T - 1 - sot.length;
|
|
150
|
+
const matrix = new Float32Array(rows * frames);
|
|
151
|
+
for (let r = 0; r < rows; r++)
|
|
152
|
+
for (let f = 0; f < frames; f++) {
|
|
153
|
+
let acc = 0;
|
|
154
|
+
for (let h = 0; h < nHeads; h++) acc += filtered[(h * T + sot.length + r) * frames + f]!;
|
|
155
|
+
matrix[r * frames + f] = -(acc / nHeads);
|
|
156
|
+
}
|
|
157
|
+
const { text: textIndices, time: timeIndices } = dtw(matrix, rows, frames);
|
|
158
|
+
const { words, wordTokens } = tokenizer.splitToWordTokens([...textTokens, tokenizer.eot], language);
|
|
159
|
+
if (wordTokens.length <= 1) return [];
|
|
160
|
+
const boundaries = [0];
|
|
161
|
+
for (let i = 0; i < wordTokens.length - 1; i++) boundaries.push(boundaries[i]! + wordTokens[i]!.length);
|
|
162
|
+
const jumps: boolean[] = [true];
|
|
163
|
+
for (let i = 1; i < textIndices.length; i++) jumps.push(textIndices[i]! - textIndices[i - 1]! !== 0);
|
|
164
|
+
const jumpTimes: number[] = [];
|
|
165
|
+
for (let i = 0; i < timeIndices.length; i++) if (jumps[i]) jumpTimes.push(timeIndices[i]! / WHISPER_TOKENS_PER_SECOND);
|
|
166
|
+
const out: WordTiming[] = [];
|
|
167
|
+
for (let w = 0; w < words.length; w++) {
|
|
168
|
+
const a = boundaries[w]!;
|
|
169
|
+
const b = boundaries[w + 1] ?? a;
|
|
170
|
+
let p = 0;
|
|
171
|
+
for (let i = a; i < b; i++) p += textTokenProbs[i] ?? 0;
|
|
172
|
+
out.push({
|
|
173
|
+
word: words[w]!, tokens: wordTokens[w]!,
|
|
174
|
+
start: jumpTimes[a] ?? jumpTimes[jumpTimes.length - 1] ?? 0,
|
|
175
|
+
end: jumpTimes[b] ?? jumpTimes[jumpTimes.length - 1] ?? 0,
|
|
176
|
+
probability: b > a ? p / (b - a) : 0,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const SENTENCE_END = ".。!!??";
|
|
183
|
+
|
|
184
|
+
function mergePunctuations(alignment: WordTiming[], prepended: string, appended: string): void {
|
|
185
|
+
let i = alignment.length - 2;
|
|
186
|
+
let j = alignment.length - 1;
|
|
187
|
+
while (i >= 0) {
|
|
188
|
+
const previous = alignment[i]!;
|
|
189
|
+
const following = alignment[j]!;
|
|
190
|
+
if (previous.word.startsWith(" ") && prepended.includes(previous.word.trim())) {
|
|
191
|
+
following.word = previous.word + following.word;
|
|
192
|
+
following.tokens = [...previous.tokens, ...following.tokens];
|
|
193
|
+
previous.word = "";
|
|
194
|
+
previous.tokens = [];
|
|
195
|
+
} else j = i;
|
|
196
|
+
i--;
|
|
197
|
+
}
|
|
198
|
+
i = 0;
|
|
199
|
+
j = 1;
|
|
200
|
+
while (j < alignment.length) {
|
|
201
|
+
const previous = alignment[i]!;
|
|
202
|
+
const following = alignment[j]!;
|
|
203
|
+
if (!previous.word.endsWith(" ") && appended.includes(following.word)) {
|
|
204
|
+
previous.word = previous.word + following.word;
|
|
205
|
+
previous.tokens = [...previous.tokens, ...following.tokens];
|
|
206
|
+
following.word = "";
|
|
207
|
+
following.tokens = [];
|
|
208
|
+
} else i = j;
|
|
209
|
+
j++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** add_word_timestamps: annotates `segments` (one window) with words. */
|
|
214
|
+
export function addWordTimestamps(
|
|
215
|
+
segments: WhisperSegment[], model: WhisperModel, tokenizer: WhisperTokenizer, language: string,
|
|
216
|
+
mel: MlxArray, numFrames: number, lastSpeechTimestamp: number,
|
|
217
|
+
prependPunctuations = "\"'“¿([{-", appendPunctuations = "\"'.。,,!!??::”)]}、",
|
|
218
|
+
): number {
|
|
219
|
+
if (segments.length === 0) return lastSpeechTimestamp;
|
|
220
|
+
const perSegment = segments.map((s) => s.tokens.filter((t) => t < tokenizer.eot));
|
|
221
|
+
const textTokens = perSegment.flat();
|
|
222
|
+
const alignment = findAlignment(model, tokenizer, language, textTokens, mel, numFrames);
|
|
223
|
+
const durations = alignment.map((t) => t.end - t.start).filter((d) => d !== 0);
|
|
224
|
+
const sortedD = [...durations].sort((a, b) => a - b);
|
|
225
|
+
let medianDuration = sortedD.length ? (sortedD.length % 2 ? sortedD[(sortedD.length - 1) / 2]! : (sortedD[sortedD.length / 2 - 1]! + sortedD[sortedD.length / 2]!) / 2) : 0;
|
|
226
|
+
medianDuration = Math.min(0.7, medianDuration);
|
|
227
|
+
const maxDuration = medianDuration * 2;
|
|
228
|
+
if (durations.length > 0) {
|
|
229
|
+
for (let i = 1; i < alignment.length; i++) {
|
|
230
|
+
const a = alignment[i]!;
|
|
231
|
+
if (a.end - a.start > maxDuration) {
|
|
232
|
+
if (SENTENCE_END.includes(a.word)) a.end = a.start + maxDuration;
|
|
233
|
+
else if (SENTENCE_END.includes(alignment[i - 1]!.word)) a.start = a.end - maxDuration;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
mergePunctuations(alignment, prependPunctuations, appendPunctuations);
|
|
238
|
+
const timeOffset = (segments[0]!.seek * WHISPER_HOP) / WHISPER_SAMPLE_RATE;
|
|
239
|
+
let wordIndex = 0;
|
|
240
|
+
for (let si = 0; si < segments.length; si++) {
|
|
241
|
+
const segment = segments[si]!;
|
|
242
|
+
const tt = perSegment[si]!;
|
|
243
|
+
let saved = 0;
|
|
244
|
+
const words: WhisperWord[] = [];
|
|
245
|
+
while (wordIndex < alignment.length && saved < tt.length) {
|
|
246
|
+
const timing = alignment[wordIndex]!;
|
|
247
|
+
if (timing.word)
|
|
248
|
+
words.push({ word: timing.word, start: Math.round((timeOffset + timing.start) * 100) / 100, end: Math.round((timeOffset + timing.end) * 100) / 100, probability: timing.probability });
|
|
249
|
+
saved += timing.tokens.length;
|
|
250
|
+
wordIndex++;
|
|
251
|
+
}
|
|
252
|
+
if (words.length > 0) {
|
|
253
|
+
const w0 = words[0]!;
|
|
254
|
+
if (w0.end - lastSpeechTimestamp > medianDuration * 4 &&
|
|
255
|
+
(w0.end - w0.start > maxDuration || (words.length > 1 && words[1]!.end - w0.start > maxDuration * 2))) {
|
|
256
|
+
if (words.length > 1 && words[1]!.end - words[1]!.start > maxDuration) {
|
|
257
|
+
const boundary = Math.max(words[1]!.end / 2, words[1]!.end - maxDuration);
|
|
258
|
+
w0.end = words[1]!.start = boundary;
|
|
259
|
+
}
|
|
260
|
+
w0.start = Math.max(0, w0.end - maxDuration);
|
|
261
|
+
}
|
|
262
|
+
if (segment.start < w0.end && segment.start - 0.5 > w0.start)
|
|
263
|
+
w0.start = Math.max(0, Math.min(w0.end - medianDuration, segment.start));
|
|
264
|
+
else segment.start = w0.start;
|
|
265
|
+
const wl = words[words.length - 1]!;
|
|
266
|
+
if (segment.end > wl.start && segment.end + 0.5 < wl.end)
|
|
267
|
+
wl.end = Math.max(wl.start + medianDuration, segment.end);
|
|
268
|
+
else segment.end = wl.end;
|
|
269
|
+
lastSpeechTimestamp = segment.end;
|
|
270
|
+
}
|
|
271
|
+
segment.words = words;
|
|
272
|
+
}
|
|
273
|
+
return lastSpeechTimestamp;
|
|
274
|
+
}
|