@audio/neural-separate 0.4.0 → 0.6.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 CHANGED
@@ -28,6 +28,7 @@ In the browser `@audio/neural-runtime` runs `onnxruntime-web` instead (`device:
28
28
  | `'htdemucs_ft'` | four fine-tuned HTDemucs, one per source | 4 × 174 MB | research only | 8.67 · 9.45 · 9.68 · 5.58 | vocals only: 1.1 · not measured · not measured |
29
29
  | `'scnet-large'` | SCNet-large: band-split convolutions around dual-path LSTMs, on the complex spectrogram | 45 MB (export 169 MB) | MIT | 11.00 · 10.27 · 8.21 · 6.87 | 2.1 · 7.8 · 5.0 |
30
30
  | `'scnet'` | SCNet: the same at half the width | 13 MB (export 43 MB) | MIT | 9.88 · 9.43 · 8.35 · 6.15 | 0.70 · 1.7 · 0.91 |
31
+ | `'mel-roformer'` | Mel-Band RoFormer (Kimberley Jensen's vocals model): axial transformers over time and mel bands, on the complex spectrogram; written for the GPU | 464 MB, float16 weights (export 928 MB) | MIT | vocals only: 12.08 | 1.4 · not measured · 0.56 (Node's WebGPU; the export 0.44) |
31
32
 
32
33
  SDR as measured below (Verification), identical to the Python originals'. The previews are 7 s excerpts, so these numbers sit apart from full-track results in scale, not in order: the Demucs README reports overall SDR 9.0 for fine-tuned HT Demucs against 5.3 for Open-Unmix on the MUSDB18-HQ test set.
33
34
 
@@ -62,13 +63,22 @@ together (0.9 GB of it the browser idle): wasm `scnet` 3.0 GB, `scnet-large` 4.6
62
63
  on wasm 9 and 0.3 minutes. Float16 compute does not pay: `tiger`'s on WebGPU is 13 % faster and its music 4.7 dB from
63
64
  the float32 graph's.
64
65
 
66
+ A voice's room (de-reverb):
67
+
68
+ | `model` | Architecture | Files | Weights | Targets | Real-time factor: Node CPU · WebGPU |
69
+ |---|---|---|---|---|---|
70
+ | `'dereverb-room'` | anvuew's room de-reverb: a mono BS-RoFormer (axial transformers over time and 63 contiguous bands of the complex spectrogram, dim 128, depth 16, 29.4 M parameters), trained on voices in rooms simulated with pyroomacoustics | 60 MB, float16 weights (export 122 MB) | GPL-3.0 | `dry`: the voice with its room off, early reflections and tail; the residual is the room | 2.3 · 0.65 (Node's WebGPU) |
71
+
72
+ Each channel heard apart, 8.7 s segments every 4.35 s as `demix()` runs it (`num_overlap: 2`), nothing normalized. Measured through [`audio`](https://github.com/audiojs/audio)'s `dereverb()` (`bench/rx/dereverb.mjs`, test): VoiceBank test utterances in 19 MIT IR Survey rooms (Traer & McDermott, PNAS 2016; T60 0.38–1.85 s), each response split at direct + 50 ms, PESQ against the dry take 1.39 → 2.65, STOI 0.77 → 0.94, DNSMOS OVRL 2.19 → 2.98, the late tail 15.6 dB down; iZotope RX 12 De-reverb tuned 1.45, its Dialogue Isolate with the reverb off 1.79. It takes the early reflections too: the output stands 5.2 dB under the direct sound and its first 50 ms, where the voice is 10 dB over the tail. Music is room to it (MUSDB18 previews, GuitarSet, sung tones and four pieces lose 6 dB in their worst octave as they are, and 9 dB of themselves in a room); `audio` passes music by a speech/music guard. The real-time factors are of 20 s of stereo on a busy 14-core M4 Max (load averages over 100), so upper bounds; the float16 file stands 74 dB from the PyTorch forward through this package's pipeline, the float32 export 123 dB.
73
+
65
74
  `targets` picks a subset: `targets: ['vocals']` runs only the vocals graph of `umxhq` (against the residual, see Algorithm: 2.4× faster, vocals SDR 6.50 instead of 6.75) and of `htdemucs_ft`, and skips the other sources' iSTFT for `htdemucs`. Presets resample to their 44.1 kHz and back.
66
75
 
67
76
  ## Weights
68
77
 
69
78
  Nothing ships in this package. `scnet-large`, `scnet`, `mrx` and `tiger` read a compact file each, `<model>.int8.onnx`
70
- (Size, below), hosted on Hugging Face (`audiojs/scnet-large`, `audiojs/scnet`, `audiojs/mrx`, `audiojs/tiger-dnr`, each
71
- with its model card and licence): without `weights`, `https://huggingface.co/<repo>/resolve/<revision>/<file>` at the
79
+ (Size, below), and `mel-roformer` its float16 weights, `mel-roformer.fp16.onnx` (or `roformer.js`'s `mel-roformer.engine.bin`), hosted on Hugging Face
80
+ (`audiojs/scnet-large`, `audiojs/scnet`, `audiojs/mrx`, `audiojs/tiger-dnr`, `audiojs/mel-roformer`, each with its model
81
+ card and licence): without `weights`, `https://huggingface.co/<repo>/resolve/<revision>/<file>` at the
72
82
  revision `REVISIONS` pins, fetched once and checked against its SHA-256 (the browser keeps it in the Cache API, Node in
73
83
  the neural cache, below). Every model can also be exported from its original checkpoint:
74
84
 
@@ -88,6 +98,10 @@ python3 node_modules/@audio/neural-separate/scripts/export-mrx.py --verify
88
98
  pip install safetensors
89
99
  python3 node_modules/@audio/neural-separate/scripts/export-tiger.py --verify
90
100
 
101
+ pip install einops beartype rotary-embedding-torch librosa
102
+ python3 node_modules/@audio/neural-separate/scripts/export-roformer.py --verify --fp16 --engine
103
+ node node_modules/@audio/neural-separate/scripts/engine.mjs # roformer.js in Chromium against onnxruntime (playwright)
104
+
91
105
  python3 node_modules/@audio/neural-separate/scripts/compact.py --model tiger --verify # tiger.int8.onnx from tiger.onnx
92
106
  ```
93
107
 
@@ -97,6 +111,8 @@ They land in `$AUDIO_NEURAL_CACHE/<model>/`, default `~/.cache/audiojs/neural/<m
97
111
  - **`export-scnet.py`** fetches SCNet's code from ZFTurbo/Music-Source-Separation-Training at a pinned commit and the author's MUSDB18-HQ checkpoint from that repository's releases, and exports the network between its STFT and iSTFT (the same split as demucs.onnx's): `mix_spec [1, 4, 2049, 476]` in, `stems_spec [1, 16, 2049, 476]` out, one 11 s segment. Two changes to the graph, neither to its function: the rFFT and irFFT over time in its separation network become products with cosine and sine matrices (torch 2.14 exports no `fft_rfft`), and each `GroupNorm(1, C)` reduces its mean and variance one axis at a time: exported whole, onnxruntime summed a 10-million-value group in float32 and the stems came out 52 to 55 dB from PyTorch's, against 110 dB now (PyTorch's own float32 against float64: 110 dB). `--fp16` also writes float16 weights.
98
112
  - **`export-mrx.py`** fetches MRX's code and MERL's checkpoint (`default_mrx_pre_trained_weights.pth`, trained with the SNR loss so its stems keep the mixture's level; the three others are scale-invariant or trained on loudness- or EQ-adapted stems, and their stems come out at another level: on Divide and Remaster v3 their SNR is negative where their SI-SDR is up to 1 dB higher) from merlresearch/cocktail-fork-separation at a pinned commit, and exports the network between its STFTs and iSTFTs: each channel's three magnitude spectrograms `mag_1024 [B, 513, T]`, `mag_2048 [B, 1025, T]`, `mag_8192 [B, 4097, T]` in (scaled 1/√n, torch.stft's `normalized=True`), the masks `mask_<n> [B, 3, F, T]` out, music, speech and sfx; B and T free.
99
113
  - **`export-tiger.py`** fetches TIGER's model code (JusperLee/TIGER, `look2hear/models/tiger_dnr.py` and the two layer modules it reads) at a pinned commit and its Divide and Remaster weights (huggingface.co/JusperLee/TIGER-DnR) at a pinned revision, and exports TIGERDNR's three models between their STFT and iSTFT as one graph, each its kept source (the first's dialogue, the second's effects, the third's music): `mix_spec [1, 2, 1025, 1034]` (one channel's STFT, n 2048, hop 512, Hann, unnormalized) in, `stems_spec [1, 6, 1025, 1034]` (dialogue, music, effects, re and im) out, one 12 s segment. Three changes to the graph, none to its function: `F.adaptive_avg_pool1d`, which the exporter takes only for output sizes dividing the input's, becomes a cumulative sum read at torch's windows; UConvBlock's global sum starts at its first term, not at a zeros tensor exported as a constant the size of the features; each `GroupNorm(1, C)` reduces axis by axis, as SCNet's. ONNX Runtime runs the graph in 12 s segments at about twice real time on a CPU: three models of 15,000 small operators each, every sample in three segments.
114
+ - **`export-roformer.py`** fetches Mel-Band RoFormer's code and Kim's config from ZFTurbo/Music-Source-Separation-Training at a pinned commit and Kim's checkpoint (huggingface.co/KimberleyJSN/melbandroformer, MIT) at a pinned revision, checked by its SHA-256, and exports the network between its STFT and iSTFT: `mix_spec [1, 4, 1025, 801]` in, `stems_spec [1, 4, 1025, 801]` (the vocals) out, one 8 s segment every 4 s. It is written for the GPU, its function unchanged (`--verify`: 1e-5 to 2e-4 of the peak from MelBandRoformer.forward, whose own float32 stands 5e-3 from its float64 on tones): the 60 bands' projections and mask MLPs as batched products, bands of near widths grouped and zero-padded; each RMSNorm's scale folded into the weights it feeds, the attention's scale into the queries, its gates' projection joined to q, k, v; the rotary embedding's interleaved pairs turned to halves (the same permutation of q's and k's features), so it is two products with cos and sin tables and one with a fixed matrix; the overlapping mel bands' masks averaged by two gathers, not a scatter. 826 operators where an export of BS-RoFormer as written has about 3,000, and SCNet's LSTMs none: on Node's WebGPU (Metal, M4 Max) 2.4 s a segment against 9 s on its CPU; SCNet-large's LSTMs take 19 of its 26 s a segment there. And no tensor passes 100 MB, each transformer run on slices of its sequences: onnxruntime 1.30's WebGPU Softmax over a tensor past WebGPU's default 128 MiB storage binding came out wrong one run in two (36 dB from the CPU's at 307 MB, every run right at 123 MB), and the stems with it. `--fp16`: float16 weights, float32 compute, half the download, 3.0 s a segment and 48 dB from the float32 file's stems.
115
+ - **`roformer.js`**: in a browser whose GPU offers subgroup matrices (`chromium-experimental-subgroup-matrix`: Chromium on Metal), `mel-roformer` runs on kernels of its own instead of onnxruntime, from `mel-roformer.engine.bin` and `.json` (`export-roformer.py --engine`: the same tensors, hosted beside the ONNX file; a browser downloads one or the other). Matrix products and attention on the GPU's matrix units, float32: band split, norms, the q, k, v projection with the rotation and gates, attention in two passes over the keys (the rows' maxima, then the output in registers), feed-forward with GELU, the mask MLP with tanh and GLU, the masks averaged and applied; 163 dispatches a segment, recorded once; kept on the GPU a minute after a separation, so the next starts at once (its weights and 2 GB of buffers made in about a second the first time). Against onnxruntime's CPU on the same weights 101.5 dB (`scripts/engine.mjs`); in Chromium on an M4 Max under the same load as onnxruntime-web's WebGPU there, 1.5 to 1.8 s a segment against 2.6 to 3.3; GPU time by kernel: the time attention a third, the matrix products half (about 3 TFLOPS each, against onnxruntime's 1.8 for its float32 products and 0.5 to 0.75 for its attention). Elsewhere (Node, Firefox, Safari, a GPU without them) onnxruntime runs the ONNX file as before.
100
116
  - **`export-htdemucs.py`** follows [sevagh/demucs.onnx](https://github.com/sevagh/demucs.onnx): the STFT and iSTFT, which ONNX export cannot carry, move out of the graph. Rather than a vendored copy of `htdemucs.py`, it runs upstream's own `forward` with its four transform methods swapped on the instance. One graph per 7.8 s segment: `mix [1, 2, 343980]` and `mix_spec [1, 4, 2048, 336]` (complex as channels) in, `stems_spec [1, 4, 4, 2048, 336]` and `stems_wave [1, 4, 2, 343980]` out; a source is the iSTFT of the first plus the second.
101
117
 
102
118
  ## Size
@@ -279,6 +295,7 @@ Open-source stem separation has these lineages; this package runs SCNet, Open-Un
279
295
  | **SCNet** (`scnet`, `scnet-large`; starrytong) | MIT | MIT: "I confirm that the released SCNet and SCNet-large pretrained weights are distributed under the MIT License, consistent with the source code. You are welcome to redistribute the original checkpoints and format-converted versions, including ONNX exports, as part of your MIT-licensed tool, with appropriate attribution." (the author in [starrytong/SCNet#35](https://github.com/starrytong/SCNet/issues/35), 2026) | trained on MUSDB18-HQ; hosted with its configs by [ZFTurbo/Music-Source-Separation-Training](https://github.com/ZFTurbo/Music-Source-Separation-Training) (MIT), whose own MUSDB18-HQ checkpoints (SCNet XL, BS-RoFormer) carry its author's MIT statement only for some files ([#254](https://github.com/ZFTurbo/Music-Source-Separation-Training/issues/254)) |
280
296
  | **MRX** (MERL; `mrx`) | MIT | MIT: the repository's [`.reuse/dep5`](https://github.com/merlresearch/cocktail-fork-separation/blob/main/.reuse/dep5) names the four checkpoints, "Copyright: 2023 Mitsubishi Electric Research Laboratories (MERL) / License: MIT" | trained on Divide and Remaster v2, whose music and effects come from FMA and FSD50K clips each under its own licence, some non-commercial (the DnR v3 paper's account) |
281
297
  | **TIGER** (Tsinghua; `tiger`) | MIT (repository LICENSE; its README badge says Apache 2.0) | Apache 2.0: the [model card](https://huggingface.co/JusperLee/TIGER-DnR) states `license: apache-2.0` | trained on Divide and Remaster v1 (652 test mixtures in its paper), sourced as v2 is |
298
+ | **anvuew's de-reverb** (`dereverb-room`) | the architecture: BS-RoFormer (Phil Wang's [BS-RoFormer](https://github.com/lucidrains/BS-RoFormer), MIT, in ZFTurbo's Music-Source-Separation-Training) | **GPL-3.0** ([model card](https://huggingface.co/anvuew/dereverb_room)): the converted copy is hosted under the same licence, fetched at run time, never bundled | trained on convolutional reverbs from pyroomacoustics, the dry voices not named |
282
299
  | **Bandit v2** (DnR v3) | Apache 2.0 | **CC BY-SA 4.0** ([Zenodo](https://zenodo.org/records/12701995)): a converted copy is an adaptation under the same terms | not shipped; BandIt v1's DnR checkpoints are CC BY-NC 4.0 ([Zenodo](https://zenodo.org/records/10160698)) |
283
300
  | **Spleeter** (Deezer) | MIT | **undocumented** | the README licenses only "the code of Spleeter"; the pretrained weights' license is an open, unresolved question ([deezer/spleeter#898](https://github.com/deezer/spleeter/issues/898)) — do not assume MIT |
284
301
 
package/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export type AudioInput = Float32Array[] | { channelData: Float32Array[]; sampleR
12
12
  export type ModelSpec = string | Uint8Array
13
13
 
14
14
  /** Presets whose files the export scripts write: <weights>/<name>/<target>.onnx or <weights>/<name>/<name>.onnx */
15
- export type ModelName = 'umxhq' | 'htdemucs' | 'htdemucs_ft' | 'scnet-large' | 'scnet' | 'mrx' | 'tiger'
15
+ export type ModelName = 'umxhq' | 'htdemucs' | 'htdemucs_ft' | 'scnet-large' | 'scnet' | 'mrx' | 'tiger' | 'mel-roformer'
16
16
 
17
17
  /** One graph whose output stacks several sources on its S axis */
18
18
  export type MultiGraph = { url: ModelSpec; targets: string[] }
@@ -74,7 +74,7 @@ export interface ModelPreset {
74
74
  export const models: Record<ModelName, ModelPreset>
75
75
 
76
76
  /** The hosted revision (commit) of each compact preset's repository: https://huggingface.co/<repo>/resolve/<revision>/<file> is read without opts.weights in the browser, and in Node when the cache holds neither the file nor its export. Empty: not hosted. */
77
- export const REVISIONS: Record<'scnet-large' | 'scnet' | 'mrx' | 'tiger', string>
77
+ export const REVISIONS: Record<'scnet-large' | 'scnet' | 'mrx' | 'tiger' | 'mel-roformer', string>
78
78
 
79
79
  /** A neural-runtime-shaped session — enough of it to drive separate() with a test double. */
80
80
  export interface Session {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audio/neural-separate",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Source separation (stems): a song's by SCNet (MIT weights), Hybrid Transformer Demucs, Open-Unmix with multichannel Wiener refinement; a soundtrack's dialogue, music and effects by MRX (MIT weights) and TIGER (Apache-2.0); model-agnostic ONNX",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -15,18 +15,21 @@
15
15
  },
16
16
  "files": [
17
17
  "separate.js",
18
+ "roformer.js",
18
19
  "index.d.ts",
19
20
  "scripts/export-openunmix.py",
20
21
  "scripts/export-htdemucs.py",
21
22
  "scripts/export-scnet.py",
22
23
  "scripts/export-mrx.py",
23
24
  "scripts/export-tiger.py",
25
+ "scripts/export-roformer.py",
26
+ "scripts/engine.mjs",
24
27
  "scripts/reference.py",
25
28
  "scripts/compact.py",
26
29
  "NOTICE"
27
30
  ],
28
31
  "dependencies": {
29
- "@audio/neural-runtime": "^0.1.0",
32
+ "@audio/neural-runtime": "^0.1.1",
30
33
  "@audio/resample-sinc": "^1.1.2",
31
34
  "fourier-transform": "^2.4.1"
32
35
  },
package/roformer.js ADDED
@@ -0,0 +1,396 @@
1
+ // Mel-Band RoFormer on WebGPU, kernels of its own: the GPU form scripts/export-roformer.py writes (--engine), one 8 s
2
+ // segment a run, what its ONNX graph computes (47.8 dB from onnxruntime's CPU on the float32 graph: the float16 weights'
3
+ // rounding, as the float16 ONNX file's). Matrix products and attention on the GPU's matrix units through
4
+ // chromium_experimental_subgroup_matrix (Apple's simdgroup matrices: Chromium on Metal), float32 throughout: under the same
5
+ // load, 1.6 s a segment where onnxruntime-web's WebGPU takes 2.6 to 3.3 (an M4 Max). Attention in two passes over the
6
+ // keys (the rows' maxima, then the output in registers), never past 128 MiB a dispatch needs no binding beyond it.
7
+ // let rf = await engine(meta, bytes) // null where the GPU lacks subgroup matrices
8
+ // let { stems_spec } = await rf.run({ mix_spec }) // as an onnxruntime session: [1, 4, F, T] in and out
9
+ const TILE = 32
10
+
11
+ /** A device with what the kernels need, or null */
12
+ export async function gpu() {
13
+ let adapter = await globalThis.navigator?.gpu?.requestAdapter({ powerPreference: 'high-performance' }).catch(() => null)
14
+ if (!adapter || adapter.info?.isFallbackAdapter || !['subgroups', 'chromium-experimental-subgroup-matrix'].every(f => adapter.features.has(f))) return null
15
+ let cfg = [...(adapter.info.subgroupMatrixConfigs ?? [])]
16
+ if (!cfg.some(c => c.componentType === 'f32' && c.M === 8 && c.N === 8 && c.K === 8)) return null
17
+ return adapter.requestDevice({
18
+ requiredFeatures: ['subgroups', 'chromium-experimental-subgroup-matrix'],
19
+ requiredLimits: { maxComputeWorkgroupStorageSize: 32768, maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize, maxBufferSize: adapter.limits.maxBufferSize },
20
+ }).catch(() => null)
21
+ }
22
+
23
+ /** The engine as an onnxruntime-shaped session ({ inputs, outputs, run(feeds), free() }), or null without such a GPU */
24
+ export async function engine(meta, bytes, device) {
25
+ device ??= await gpu()
26
+ if (!device) return null
27
+ let rf = await roformer(device, meta, bytes), { freqs: F, frames: T } = meta
28
+ return {
29
+ inputs: [{ name: 'mix_spec', dims: [1, 4, F, T], type: 'float32' }], outputs: [{ name: 'stems_spec', dims: [1, 4, F, T], type: 'float32' }],
30
+ backend: 'roformer',
31
+ async run(feeds) { return { stems_spec: { data: await rf.run(feeds.mix_spec.data), dims: [1, 4, F, T], type: 'float32' } } },
32
+ free() { device.destroy() },
33
+ }
34
+ }
35
+
36
+ export default async function roformer(device, meta, bytes) {
37
+ const { frames: T, freqs: F, channels: S, dim: D, heads: H, depth, bands: B, groups } = meta
38
+ const DH = 64, FF = 1536, MH = 1536, Tp = Math.ceil(T / TILE) * TILE, QKV = 3 * H * DH, QKVG = Math.ceil((QKV + H) / TILE) * TILE
39
+ const COLS = F * S * 2, LT = Math.ceil(T / 16) * 16, LF = Math.ceil(B / 16) * 16
40
+ const pad16 = w => Math.ceil(w / 16) * 16
41
+ const G = groups.map(([b0, b1, W]) => ({ b0, b1, n: b1 - b0, W, Wp: pad16(W) }))
42
+
43
+ // ---------------------------------------------------------------- tensors
44
+ const view = n => {
45
+ const t = meta.tensors[n], len = t.shape.reduce((a, b) => a * b, 1)
46
+ return t.type === 'float16' ? new Float16Array(bytes.buffer, bytes.byteOffset + t.offset, len)
47
+ : t.type === 'int32' ? new Int32Array(bytes.buffer, bytes.byteOffset + t.offset, len)
48
+ : new Float32Array(bytes.buffer, bytes.byteOffset + t.offset, len)
49
+ }
50
+ const usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC
51
+ const buf = (n, type = 'f32') => device.createBuffer({ size: Math.max(16, n * 4), usage })
52
+ const upload = data => { const b = device.createBuffer({ size: Math.max(16, data.byteLength), usage, mappedAtCreation: true }); new data.constructor(b.getMappedRange()).set(data); b.unmap(); return b }
53
+ const f32 = a => a instanceof Float32Array ? a : Float32Array.from(a)
54
+ // a weight [batch, K, N] to [batch, Kp, Np], zeros past (rows, cols)
55
+ const padded = (a, batch, K, N, Kp, Np, place = (k, n) => k * Np + n) => {
56
+ const o = new Float32Array(batch * Kp * Np)
57
+ for (let b = 0; b < batch; b++) for (let k = 0; k < K; k++) for (let n = 0; n < N; n++) o[b * Kp * Np + place(k, n)] = a[(b * K + k) * N + n]
58
+ return o
59
+ }
60
+
61
+ const W = { split: [], splitB: [], splitIdx: [], m2: [], m2B: [] }
62
+ for (const [g, gr] of G.entries()) {
63
+ W.split.push(upload(padded(view(`split_w.${g}`), gr.n, gr.W, D, gr.Wp, D)))
64
+ W.splitB.push(upload(f32(view(`split_b.${g}`))))
65
+ const idx = view(`si${g}`), pi = new Int32Array(gr.n * gr.Wp).fill(COLS)
66
+ for (let j = 0; j < gr.n; j++) for (let e = 0; e < gr.W; e++) pi[j * gr.Wp + e] = idx[j * gr.W + e]
67
+ W.splitIdx.push(upload(pi))
68
+ // the last mask layer: its value half to [0, W), its gate half to [Wp, Wp + W)
69
+ const w = view(`m2_w.${g}`), b = view(`m2_b.${g}`), Wp = gr.Wp, wo = new Float32Array(gr.n * MH * 2 * Wp), bo = new Float32Array(gr.n * 2 * Wp)
70
+ for (let j = 0; j < gr.n; j++) {
71
+ for (let k = 0; k < MH; k++) for (let e = 0; e < gr.W; e++) {
72
+ wo[(j * MH + k) * 2 * Wp + e] = w[(j * MH + k) * 2 * gr.W + e]
73
+ wo[(j * MH + k) * 2 * Wp + Wp + e] = w[(j * MH + k) * 2 * gr.W + gr.W + e]
74
+ }
75
+ for (let e = 0; e < gr.W; e++) { bo[j * 2 * Wp + e] = b[j * 2 * gr.W + e]; bo[j * 2 * Wp + Wp + e] = b[j * 2 * gr.W + gr.W + e] }
76
+ }
77
+ W.m2.push(upload(wo)); W.m2B.push(upload(bo))
78
+ }
79
+ W.mw = [0, 1].map(i => upload(f32(view(`mw.${i}`)))); W.mb = [0, 1].map(i => upload(f32(view(`mb.${i}`))))
80
+ W.blocks = []
81
+ for (let d = 0; d < depth; d++) for (let k = 0; k < 2; k++) {
82
+ const p = `blocks.${d}.${k}.`, a = p + 'layers.0.0.', f = p + 'layers.0.1.'
83
+ W.blocks.push({
84
+ out: upload(f32(view(p + 'out'))),
85
+ qkvg: upload(padded(view(a + 'qkvg'), 1, D, QKV + H, D, QKVG)), gb: upload(f32(view(a + 'gb'))), wo: upload(f32(view(a + 'wo'))),
86
+ w1: upload(f32(view(f + 'w1'))), b1: upload(f32(view(f + 'b1'))), w2: upload(f32(view(f + 'w2'))), b2: upload(f32(view(f + 'b2'))),
87
+ })
88
+ }
89
+ const rope = k => { const c = view(`blocks.0.${k}.layers.0.0.cos`), s = view(`blocks.0.${k}.layers.0.0.sin`); return [upload(f32(c)), upload(f32(s))] }
90
+ const [cosT, sinT] = rope(0), [cosF, sinF] = rope(1)
91
+ // the masks' sources: per output column, two (base, stride: the group's Wp) into the GLU outputs, and its weight
92
+ const i1 = view('i1'), i2 = view('i2'), wt = view('wt'), gofs = [], cols = []
93
+ let mOff = 0, cOff = 0
94
+ for (const gr of G) { gofs.push(mOff); cols.push(cOff); mOff += gr.n * Tp * gr.Wp; cOff += gr.n * gr.W }
95
+ const src = c => {
96
+ if (c >= cOff) return [0, 0, 0]
97
+ let g = cols.findLastIndex(o => o <= c), gr = G[g], r = c - cols[g], j = Math.floor(r / gr.W), e = r % gr.W
98
+ return [gofs[g] + j * Tp * gr.Wp + e, gr.Wp, 1]
99
+ }
100
+ const map = new Uint32Array(COLS * 8) // per column: base1, stride1, on1, base2, stride2, on2, -, -
101
+ for (let o = 0; o < COLS; o++) map.set([...src(i1[o]), ...src(i2[o])], o * 8)
102
+ const maskMap = upload(map)
103
+ const maskWt = upload(Float32Array.from({ length: COLS }, (_, o) => wt[o]))
104
+
105
+ // ---------------------------------------------------------------- activations
106
+ const E = buf(Tp * (COLS + 1)), X = buf(B * Tp * D), XN = buf(B * Tp * D), Y = buf(B * Tp * QKVG)
107
+ const att = Math.max(B * H * LT, T * H * LF) * DH
108
+ const Qb = buf(att), Kb = buf(att), Vb = buf(att), Ob = buf(att), Gt = buf(B * Tp * H), Z = buf(B * Tp * H * DH)
109
+ const FFH = buf(B * Tp * FF), H2 = buf(B * Tp * MH), Mg = buf(mOff), OUT = buf(4 * F * T)
110
+ const splitA = G.map(gr => buf(gr.n * Tp * gr.Wp)), glu = G.map(gr => buf(gr.n * Tp * 2 * gr.Wp)), scratch = buf(4)
111
+
112
+ // ---------------------------------------------------------------- kernels
113
+ const pipes = new Map()
114
+ const pipe = (key, code) => pipes.get(key) ?? pipes.set(key, device.createComputePipeline({ layout: 'auto', compute: { module: device.createShaderModule({ code }), entryPoint: 'main' } })).get(key)
115
+ const uni = (...v) => { const b = device.createBuffer({ size: Math.ceil(v.length / 4) * 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); device.queue.writeBuffer(b, 0, new Uint32Array(v.map(x => typeof x === 'number' && !Number.isInteger(x) ? new Uint32Array(new Float32Array([x]).buffer)[0] : x))); return b }
116
+ const steps = [] // [pipeline, bindings, [x, y, z]]
117
+ const step = (p, bindings, grid) => steps.push([p, device.createBindGroup({ layout: p.getBindGroupLayout(0), entries: bindings.map((b, i) => ({ binding: i, resource: { buffer: b } })) }), grid])
118
+
119
+ const HEAD = `enable subgroups;\nenable chromium_experimental_subgroup_matrix;\n`
120
+ // C[b] (M×N, row stride ldc, at c0 + b·sc) = A[b] (M×K, at a0 + b·sa) · Bm[b] (K×N, at b0 + b·sb), then the epilogue:
121
+ // ep: '' as is; 'b' + bias (at bias0 + b·sbias); then 'g' gelu or 't' tanh; 'r' C += it (a residual)
122
+ const MM = ep => pipe('mm' + ep, HEAD + `
123
+ @group(0) @binding(0) var<storage, read> A: array<f32>;
124
+ @group(0) @binding(1) var<storage, read> Bm: array<f32>;
125
+ @group(0) @binding(2) var<storage, read_write> C: array<f32>;
126
+ @group(0) @binding(3) var<storage, read> bias: array<f32>;
127
+ struct P { M: u32, N: u32, K: u32, a0: u32, sa: u32, b0: u32, sb: u32, c0: u32, sc: u32, ldc: u32, bias0: u32, sbias: u32 }
128
+ @group(0) @binding(4) var<uniform> p: P;
129
+ var<workgroup> tile: array<f32, 1024>;
130
+ @compute @workgroup_size(32)
131
+ fn main(@builtin(workgroup_id) wg: vec3u, @builtin(subgroup_invocation_id) lane: u32) {
132
+ _ = bias[0]; // bound whether read or not: the layout is the module's own
133
+ let r0 = wg.y * 32u; let n0 = wg.x * 32u; let b = wg.z;
134
+ let ab = p.a0 + b * p.sa + r0 * p.K; let bb = p.b0 + b * p.sb + n0;
135
+ ${[0, 1, 2, 3].map(i => [0, 1, 2, 3].map(j => `var c${i}${j}: subgroup_matrix_result<f32, 8, 8>;`).join(' ')).join('\n ')}
136
+ for (var k = 0u; k < p.K; k += 8u) {
137
+ ${[0, 1, 2, 3].map(i => `let a${i} = subgroupMatrixLoad<subgroup_matrix_left<f32, 8, 8>>(&A, ab + ${i * 8}u * p.K + k, false, p.K);`).join('\n ')}
138
+ ${[0, 1, 2, 3].map(j => `let b${j} = subgroupMatrixLoad<subgroup_matrix_right<f32, 8, 8>>(&Bm, bb + k * p.N + ${j * 8}u, false, p.N);`).join('\n ')}
139
+ ${[0, 1, 2, 3].map(i => [0, 1, 2, 3].map(j => `c${i}${j} = subgroupMatrixMultiplyAccumulate(a${i}, b${j}, c${i}${j});`).join(' ')).join('\n ')}
140
+ }
141
+ ${[0, 1, 2, 3].map(i => [0, 1, 2, 3].map(j => `subgroupMatrixStore(&tile, ${i * 8 * 32 + j * 8}u, c${i}${j}, false, 32u);`).join(' ')).join('\n ')}
142
+ workgroupBarrier();
143
+ for (var e = lane; e < 1024u; e += 32u) {
144
+ let r = r0 + e / 32u; let n = n0 + e % 32u;
145
+ if (r < p.M && n < p.N) {
146
+ var v = tile[e];
147
+ ${ep.includes('b') ? 'v += bias[p.bias0 + b * p.sbias + n];' : ''}
148
+ ${ep.includes('g') ? 'v = 0.5 * v * (1.0 + erf_(v * 0.7071067811865476));' : ep.includes('t') ? 'v = tanh(v);' : ''}
149
+ let o = p.c0 + b * p.sc + r * p.ldc + n;
150
+ ${ep.includes('r') ? 'C[o] += v;' : 'C[o] = v;'}
151
+ }
152
+ }
153
+ }
154
+ fn erf_(x: f32) -> f32 { // Abramowitz & Stegun 7.1.26 is 1.5e-7; GELU's own float32 erf is as close
155
+ let t = 1.0 / (1.0 + 0.3275911 * abs(x));
156
+ let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * exp(-x * x);
157
+ return select(-y, y, x >= 0.0);
158
+ }`)
159
+ const mm = (ep, A, Bm, C, bias, { M, N, K, batch = 1, a0 = 0, sa = 0, b0 = 0, sb = 0, c0 = 0, sc = 0, ldc = N, bias0 = 0, sbias = 0 }) =>
160
+ step(MM(ep), [A, Bm, C, bias ?? maskWt, uni(M, N, K, a0, sa, b0, sb, c0, sc, ldc, bias0, sbias)], [N / 32, M / 32, batch])
161
+
162
+ // rows of n: y = x / max(|x|, 1e-12), or in place, scaled by g (a vector): x = x / max(|x|, 1e-12) · g
163
+ const NORM = scaled => pipe('norm' + scaled, HEAD + `
164
+ @group(0) @binding(0) var<storage, read_write> x: array<f32>;
165
+ @group(0) @binding(1) var<storage, read_write> y: array<f32>;
166
+ @group(0) @binding(2) var<storage, read> g: array<f32>;
167
+ struct P { rows: u32, n: u32 }
168
+ @group(0) @binding(3) var<uniform> p: P;
169
+ @compute @workgroup_size(32)
170
+ fn main(@builtin(workgroup_id) wg: vec3u, @builtin(subgroup_invocation_id) lane: u32) {
171
+ _ = g[0]; _ = y[0];
172
+ let r = wg.x + wg.y * 65535u;
173
+ if (r >= p.rows) { return; }
174
+ var s = 0.0;
175
+ for (var i = lane; i < p.n; i += 32u) { let v = x[r * p.n + i]; s += v * v; }
176
+ let k = 1.0 / max(sqrt(subgroupAdd(s)), 1e-12);
177
+ for (var i = lane; i < p.n; i += 32u) { ${scaled ? 'x[r * p.n + i] = x[r * p.n + i] * k * g[i];' : 'y[r * p.n + i] = x[r * p.n + i] * k;'} }
178
+ }`)
179
+ const grid1 = n => [Math.min(n, 65535), Math.ceil(n / 65535), 1]
180
+ const norm = (x, y, rows, n, g) => step(NORM(!!g), [x, g ? scratch : y, g ?? maskWt, uni(rows, n)], grid1(rows))
181
+
182
+ // a group's band inputs: band j's columns of E at frame t (none past T), normalized: A[j, t, e]
183
+ const SPLIT = pipe('split', HEAD + `
184
+ @group(0) @binding(0) var<storage, read> E: array<f32>;
185
+ @group(0) @binding(1) var<storage, read> idx: array<i32>;
186
+ @group(0) @binding(2) var<storage, read_write> A: array<f32>;
187
+ struct P { Wp: u32, Tp: u32, T: u32, cols: u32 }
188
+ @group(0) @binding(3) var<uniform> p: P;
189
+ @compute @workgroup_size(32)
190
+ fn main(@builtin(workgroup_id) wg: vec3u, @builtin(subgroup_invocation_id) lane: u32) {
191
+ let t = wg.x; let j = wg.y;
192
+ var s = 0.0;
193
+ for (var e = lane; e < p.Wp; e += 32u) { let v = select(0.0, E[t * (p.cols + 1u) + u32(idx[j * p.Wp + e])], t < p.T); s += v * v; }
194
+ let k = 1.0 / max(sqrt(subgroupAdd(s)), 1e-12);
195
+ for (var e = lane; e < p.Wp; e += 32u) {
196
+ let v = select(0.0, E[t * (p.cols + 1u) + u32(idx[j * p.Wp + e])], t < p.T);
197
+ A[(j * p.Tp + t) * p.Wp + e] = v * k;
198
+ }
199
+ }`)
200
+
201
+ // q, k, v of each head to sequences, the rotation on q and k, the gates: token r = band·Tp + t; over time a sequence is
202
+ // a band, its positions frames (n T, padded to L); over frequency a frame, its positions bands (n B, padded to L)
203
+ const ROPE = pipe('rope', HEAD + `
204
+ @group(0) @binding(0) var<storage, read> Yq: array<f32>;
205
+ @group(0) @binding(1) var<storage, read_write> Q: array<f32>;
206
+ @group(0) @binding(2) var<storage, read_write> K: array<f32>;
207
+ @group(0) @binding(3) var<storage, read_write> V: array<f32>;
208
+ @group(0) @binding(4) var<storage, read_write> Gt: array<f32>;
209
+ @group(0) @binding(5) var<storage, read> cs: array<f32>;
210
+ @group(0) @binding(6) var<storage, read> sn: array<f32>;
211
+ @group(0) @binding(7) var<storage, read> gb: array<f32>;
212
+ struct P { time: u32, Tp: u32, T: u32, L: u32, ld: u32 }
213
+ @group(0) @binding(8) var<uniform> p: P;
214
+ @compute @workgroup_size(64)
215
+ fn main(@builtin(workgroup_id) wg: vec3u, @builtin(local_invocation_id) li: vec3u) {
216
+ let t = wg.x; let band = wg.y; let h = wg.z; let d = li.x;
217
+ if (t >= p.T) { return; }
218
+ let r = band * p.Tp + t;
219
+ var seq = band; var pos = t;
220
+ if (p.time == 0u) { seq = t; pos = band; }
221
+ let o = ((seq * ${H}u + h) * p.L + pos) * 64u + d;
222
+ let y = r * p.ld + h * 64u;
223
+ let rot = select(Yq[y + d - 32u], -Yq[y + d + 32u], d < 32u);
224
+ let rk = select(Yq[y + ${H * DH}u + d - 32u], -Yq[y + ${H * DH}u + d + 32u], d < 32u);
225
+ Q[o] = Yq[y + d] * cs[pos * 64u + d] + rot * sn[pos * 64u + d];
226
+ K[o] = Yq[y + ${H * DH}u + d] * cs[pos * 64u + d] + rk * sn[pos * 64u + d];
227
+ V[o] = Yq[y + ${2 * H * DH}u + d];
228
+ if (d == 0u) { Gt[r * ${H}u + h] = 1.0 / (1.0 + exp(-(Yq[r * p.ld + ${QKV}u + h] + gb[h]))); }
229
+ }`)
230
+ // and back: Z[r, h·64 + d] = O · gate
231
+ const MERGE = pipe('merge', HEAD + `
232
+ @group(0) @binding(0) var<storage, read> O: array<f32>;
233
+ @group(0) @binding(1) var<storage, read> Gt: array<f32>;
234
+ @group(0) @binding(2) var<storage, read_write> Zb: array<f32>;
235
+ struct P { time: u32, Tp: u32, T: u32, L: u32 }
236
+ @group(0) @binding(3) var<uniform> p: P;
237
+ @compute @workgroup_size(64)
238
+ fn main(@builtin(workgroup_id) wg: vec3u, @builtin(local_invocation_id) li: vec3u) {
239
+ let t = wg.x; let band = wg.y; let h = wg.z; let d = li.x;
240
+ let r = band * p.Tp + t;
241
+ if (t >= p.T) { Zb[r * ${H * DH}u + h * 64u + d] = 0.0; return; }
242
+ var seq = band; var pos = t;
243
+ if (p.time == 0u) { seq = t; pos = band; }
244
+ Zb[r * ${H * DH}u + h * 64u + d] = O[((seq * ${H}u + h) * p.L + pos) * 64u + d] * Gt[r * ${H}u + h];
245
+ }`)
246
+ // attention in two passes over the keys, 32 a block, 16 query rows a subgroup: the rows' maxima first (Q Kᵀ alone),
247
+ // then exp(S − max) and the output accumulated in registers, no rescaling; keys past n masked
248
+ const BR = 16, BC = 32, SG = 4, I2 = [0, 1], J4 = [0, 1, 2, 3], D8 = [...Array(8).keys()]
249
+ const scores = `${I2.map(i => J4.map(j => `var s${i}_${j}: subgroup_matrix_result<f32, 8, 8>;`).join(' ')).join('\n ')}
250
+ for (var k = 0u; k < 8u; k++) {
251
+ ${I2.map(i => `let q${i} = subgroupMatrixLoad<subgroup_matrix_left<f32, 8, 8>>(&Q, base + (r0 + ${i * 8}u) * 64u + k * 8u, false, 64u);`).join('\n ')}
252
+ ${J4.map(j => `let k${j} = subgroupMatrixLoad<subgroup_matrix_right<f32, 8, 8>>(&K, base + (c0 + ${j * 8}u) * 64u + k * 8u, true, 64u);`).join('\n ')}
253
+ ${I2.map(i => J4.map(j => `s${i}_${j} = subgroupMatrixMultiplyAccumulate(q${i}, k${j}, s${i}_${j});`).join(' ')).join('\n ')}
254
+ }
255
+ ${I2.map(i => J4.map(j => `subgroupMatrixStore(&S[sg], ${i * 8 * BC + j * 8}u, s${i}_${j}, false, ${BC}u);`).join(' ')).join('\n ')}
256
+ workgroupBarrier();`
257
+ const ATT = pipe('att', HEAD + `
258
+ @group(0) @binding(0) var<storage, read> Q: array<f32>;
259
+ @group(0) @binding(1) var<storage, read> K: array<f32>;
260
+ @group(0) @binding(2) var<storage, read> V: array<f32>;
261
+ @group(0) @binding(3) var<storage, read_write> O: array<f32>;
262
+ struct P { n: u32, np: u32 }
263
+ @group(0) @binding(4) var<uniform> p: P;
264
+ var<workgroup> S: array<array<f32, ${BR * BC}>, ${SG}>;
265
+ var<workgroup> Ow: array<array<f32, ${BR * 64}>, ${SG}>;
266
+ @compute @workgroup_size(${32 * SG})
267
+ fn main(@builtin(workgroup_id) wg: vec3u, @builtin(subgroup_id) sg: u32, @builtin(subgroup_invocation_id) lane: u32) {
268
+ let b = wg.y;
269
+ let r0 = (wg.x * ${SG}u + sg) * ${BR}u;
270
+ let base = b * p.np * 64u;
271
+ let row = lane / 2u; let half = (lane % 2u) * ${BC / 2}u; // two lanes a row, 16 columns each
272
+ var mx = -1e30;
273
+ for (var c0 = 0u; c0 < p.n; c0 += ${BC}u) {
274
+ ${scores}
275
+ for (var j = 0u; j < ${BC / 2}u; j++) { if (c0 + half + j < p.n) { mx = max(mx, S[sg][row * ${BC}u + half + j]); } }
276
+ workgroupBarrier();
277
+ }
278
+ mx = max(mx, subgroupShuffleXor(mx, 1u));
279
+ var l = 0.0;
280
+ ${I2.map(i => D8.map(t => `var o${i}_${t}: subgroup_matrix_result<f32, 8, 8>;`).join(' ')).join('\n ')}
281
+ for (var c0 = 0u; c0 < p.n; c0 += ${BC}u) {
282
+ ${scores}
283
+ for (var j = 0u; j < ${BC / 2}u; j++) {
284
+ let at = row * ${BC}u + half + j;
285
+ let e = select(0.0, exp(S[sg][at] - mx), c0 + half + j < p.n);
286
+ S[sg][at] = e; l += e;
287
+ }
288
+ workgroupBarrier();
289
+ ${I2.map(i => J4.map(j => `let p${i}_${j} = subgroupMatrixLoad<subgroup_matrix_left<f32, 8, 8>>(&S[sg], ${i * 8 * BC + j * 8}u, false, ${BC}u);`).join(' ')).join('\n ')}
290
+ ${D8.map(t => J4.map(j => `let v${j}_${t} = subgroupMatrixLoad<subgroup_matrix_right<f32, 8, 8>>(&V, base + (c0 + ${j * 8}u) * 64u + ${t * 8}u, false, 64u);`).join(' ') + '\n ' + I2.map(i => J4.map(j => `o${i}_${t} = subgroupMatrixMultiplyAccumulate(p${i}_${j}, v${j}_${t}, o${i}_${t});`).join(' ')).join(' ')).join('\n ')}
291
+ workgroupBarrier();
292
+ }
293
+ l += subgroupShuffleXor(l, 1u);
294
+ ${I2.map(i => D8.map(t => `subgroupMatrixStore(&Ow[sg], ${i * 8 * 64 + t * 8}u, o${i}_${t}, false, 64u);`).join(' ')).join('\n ')}
295
+ workgroupBarrier();
296
+ // lane pairs hold their row's sum; each lane writes half its row
297
+ for (var d = 0u; d < 32u; d++) {
298
+ let r = r0 + row;
299
+ if (r < p.n) { O[base + r * 64u + half * 2u + d] = Ow[sg][row * 64u + half * 2u + d] / l; }
300
+ }
301
+ }`)
302
+ // GLU: value half by the sigmoid of the gate half
303
+ const GLU = pipe('glu', HEAD + `
304
+ @group(0) @binding(0) var<storage, read> Yg: array<f32>;
305
+ @group(0) @binding(1) var<storage, read_write> Mo: array<f32>;
306
+ struct P { rows: u32, Wp: u32, at: u32 }
307
+ @group(0) @binding(2) var<uniform> p: P;
308
+ @compute @workgroup_size(64)
309
+ fn main(@builtin(global_invocation_id) gi: vec3u) {
310
+ let i = gi.x + gi.y * 65535u * 64u;
311
+ if (i >= p.rows * p.Wp) { return; }
312
+ let r = i / p.Wp; let e = i % p.Wp;
313
+ Mo[p.at + i] = Yg[r * 2u * p.Wp + e] / (1.0 + exp(-Yg[r * 2u * p.Wp + p.Wp + e]));
314
+ }`)
315
+ // the masks averaged over the bands that cover a column, times the spectrum: out planes [s·2 + c][f][t]
316
+ const MASK = pipe('mask', HEAD + `
317
+ @group(0) @binding(0) var<storage, read> Mo: array<f32>;
318
+ @group(0) @binding(1) var<storage, read> map: array<u32>;
319
+ @group(0) @binding(2) var<storage, read> wt: array<f32>;
320
+ @group(0) @binding(3) var<storage, read> E: array<f32>;
321
+ @group(0) @binding(4) var<storage, read_write> Out: array<f32>;
322
+ struct P { T: u32, F: u32, cols: u32 }
323
+ @group(0) @binding(5) var<uniform> p: P;
324
+ fn mask(t: u32, o: u32) -> f32 {
325
+ let a = map[o * 8u]; let sa = map[o * 8u + 1u]; let on1 = map[o * 8u + 2u];
326
+ let b = map[o * 8u + 3u]; let sb = map[o * 8u + 4u]; let on2 = map[o * 8u + 5u];
327
+ var v = 0.0;
328
+ if (on1 == 1u) { v += Mo[a + t * sa]; }
329
+ if (on2 == 1u) { v += Mo[b + t * sb]; }
330
+ return v * wt[o];
331
+ }
332
+ @compute @workgroup_size(64)
333
+ fn main(@builtin(global_invocation_id) gi: vec3u) {
334
+ let t = gi.x; let fs = gi.y; // fs = f·S + s
335
+ if (t >= p.T) { return; }
336
+ let f = fs / ${S}u; let s = fs % ${S}u;
337
+ let o = fs * 2u;
338
+ let er = E[t * (p.cols + 1u) + o]; let ei = E[t * (p.cols + 1u) + o + 1u];
339
+ let mr = mask(t, o); let mi = mask(t, o + 1u);
340
+ Out[((s * 2u) * p.F + f) * p.T + t] = er * mr - ei * mi;
341
+ Out[((s * 2u + 1u) * p.F + f) * p.T + t] = er * mi + ei * mr;
342
+ }`)
343
+
344
+ // ---------------------------------------------------------------- the run, recorded once
345
+ for (const [g, gr] of G.entries()) {
346
+ step(SPLIT, [E, W.splitIdx[g], splitA[g], uni(gr.Wp, Tp, T, COLS)], [Tp, gr.n, 1])
347
+ mm('b', splitA[g], W.split[g], X, W.splitB[g], { M: Tp, N: D, K: gr.Wp, batch: gr.n, sa: Tp * gr.Wp, sb: gr.Wp * D, c0: gr.b0 * Tp * D, sc: Tp * D, sbias: D })
348
+ }
349
+ for (const [i, w] of W.blocks.entries()) {
350
+ const time = i % 2 === 0, L = time ? LT : LF, n = time ? T : B, seqs = time ? B : T
351
+ norm(X, XN, B * Tp, D)
352
+ mm('', XN, w.qkvg, Y, null, { M: B * Tp, N: QKVG, K: D })
353
+ step(ROPE, [Y, Qb, Kb, Vb, Gt, time ? cosT : cosF, time ? sinT : sinF, w.gb, uni(time ? 1 : 0, Tp, T, L, QKVG)], [Tp, B, H])
354
+ step(ATT, [Qb, Kb, Vb, Ob, uni(n, L)], [Math.ceil(L / (BR * SG)), seqs * H, 1])
355
+ step(MERGE, [Ob, Gt, Z, uni(time ? 1 : 0, Tp, T, L)], [Tp, B, H])
356
+ mm('r', Z, w.wo, X, null, { M: B * Tp, N: D, K: H * DH })
357
+ norm(X, XN, B * Tp, D)
358
+ mm('bg', XN, w.w1, FFH, w.b1, { M: B * Tp, N: FF, K: D })
359
+ mm('br', FFH, w.w2, X, w.b2, { M: B * Tp, N: D, K: FF })
360
+ norm(X, X, B * Tp, D, w.out)
361
+ }
362
+ mm('bt', X, W.mw[0], FFH, W.mb[0], { M: Tp, N: MH, K: D, batch: B, sa: Tp * D, sb: D * MH, sc: Tp * MH, sbias: MH })
363
+ mm('bt', FFH, W.mw[1], H2, W.mb[1], { M: Tp, N: MH, K: MH, batch: B, sa: Tp * MH, sb: MH * MH, sc: Tp * MH, sbias: MH })
364
+ for (const [g, gr] of G.entries()) {
365
+ mm('b', H2, W.m2[g], glu[g], W.m2B[g], { M: Tp, N: 2 * gr.Wp, K: MH, batch: gr.n, a0: gr.b0 * Tp * MH, sa: Tp * MH, sb: MH * 2 * gr.Wp, sc: Tp * 2 * gr.Wp, sbias: 2 * gr.Wp })
366
+ const n = gr.n * Tp * gr.Wp
367
+ step(GLU, [glu[g], Mg, uni(gr.n * Tp, gr.Wp, gofs[g])], [Math.min(Math.ceil(n / 64), 65535), Math.ceil(n / 64 / 65535), 1])
368
+ }
369
+ step(MASK, [Mg, maskMap, maskWt, E, OUT, uni(T, F, COLS)], [Math.ceil(T / 64), F * S, 1])
370
+ const zero = device.createCommandEncoder()
371
+ for (const b of [Qb, Kb, Vb]) zero.clearBuffer(b)
372
+ device.queue.submit([zero.finish()])
373
+
374
+ const stage = new Float32Array(Tp * (COLS + 1)), read = device.createBuffer({ size: 4 * F * T * 4, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ })
375
+ return {
376
+ steps: steps.length,
377
+ async run(spec) {
378
+ // planes [s·2 + c][f][t] → E[t][(f·S + s)·2 + c], a zero column past them, rows past T zero
379
+ stage.fill(0)
380
+ for (let s = 0; s < S; s++) for (let c = 0; c < 2; c++) for (let f = 0; f < F; f++) {
381
+ const plane = ((s * 2 + c) * F + f) * T, col = (f * S + s) * 2 + c
382
+ for (let t = 0; t < T; t++) stage[t * (COLS + 1) + col] = spec[plane + t]
383
+ }
384
+ device.queue.writeBuffer(E, 0, stage)
385
+ const e = device.createCommandEncoder(), pass = e.beginComputePass()
386
+ for (const [p, bg, grid] of steps) { pass.setPipeline(p); pass.setBindGroup(0, bg); pass.dispatchWorkgroups(...grid) }
387
+ pass.end()
388
+ e.copyBufferToBuffer(OUT, 0, read, 0, 4 * F * T * 4)
389
+ device.queue.submit([e.finish()])
390
+ await read.mapAsync(GPUMapMode.READ)
391
+ const out = new Float32Array(read.getMappedRange().slice(0))
392
+ read.unmap()
393
+ return out
394
+ },
395
+ }
396
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // roformer.js, the package's own WebGPU engine for Mel-Band RoFormer, against onnxruntime on the same weights: Chromium on
3
+ // the GPU (playwright's 'chromium' channel: Metal here) runs the engine on one segment of scripts/reference.py's mix,
4
+ // onnxruntime-node's CPU the float16 ONNX file on the same spectrum; prints how far apart they are and the engine's time.
5
+ //
6
+ // node scripts/engine.mjs (needs playwright, onnxruntime-node, and in the neural cache, mel-roformer/:
7
+ // export-roformer.py --verify --engine, compact.py --model mel-roformer --as fp16)
8
+ import { createServer } from 'node:http'
9
+ import { readFileSync } from 'node:fs'
10
+ import path from 'node:path'
11
+ import os from 'node:os'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { chromium } from 'playwright'
14
+ import * as ort from 'onnxruntime-node'
15
+ import { stft } from '../separate.js'
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url)), CACHE = path.join(process.env.AUDIO_NEURAL_CACHE || path.join(os.homedir(), '.cache', 'audiojs', 'neural'), 'mel-roformer')
18
+ const F = 1025, T = 801, L = 352800, f32 = f => { let b = readFileSync(f); return new Float32Array(b.buffer, b.byteOffset, b.byteLength / 4) }
19
+
20
+ // one segment's spectrum, as separate.js's complexSegment makes it: [L re, L im, R re, R im][f][t]
21
+ const mix = f32(path.join(CACHE, 'test.f32')), N = mix.length / 2, spec = new Float32Array(4 * F * T)
22
+ for (let c = 0; c < 2; c++) {
23
+ let { re, im } = stft(Float64Array.from(mix.subarray(c * N, c * N + L)), { n: 2048, hop: 441 })
24
+ for (let f = 0; f < F; f++) for (let t = 0; t < T; t++) { spec[(2 * c * F + f) * T + t] = re[t][f]; spec[((2 * c + 1) * F + f) * T + t] = im[t][f] }
25
+ }
26
+ let cpu = await ort.InferenceSession.create(path.join(CACHE, 'mel-roformer.fp16.onnx'))
27
+ let want = (await cpu.run({ mix_spec: new ort.Tensor('float32', spec, [1, 4, F, T]) })).stems_spec.data
28
+
29
+ const files = { '/roformer.js': path.join(HERE, '..', 'roformer.js'), '/meta': path.join(CACHE, 'mel-roformer.engine.json'), '/bin': path.join(CACHE, 'mel-roformer.engine.bin') }
30
+ const server = createServer((q, r) => {
31
+ if (q.url === '/') return r.writeHead(200, { 'content-type': 'text/html' }).end('<!doctype html><title>engine</title>')
32
+ if (q.url === '/spec') return r.writeHead(200).end(Buffer.from(spec.buffer))
33
+ if (!files[q.url]) return r.writeHead(404).end()
34
+ r.writeHead(200, { 'content-type': q.url.endsWith('.js') ? 'text/javascript' : 'application/octet-stream' }).end(readFileSync(files[q.url]))
35
+ })
36
+ await new Promise(r => server.listen(0, '127.0.0.1', r))
37
+ const browser = await chromium.launch({ channel: 'chromium', args: ['--enable-unsafe-webgpu'] }), page = await browser.newPage()
38
+ await page.goto(`http://127.0.0.1:${server.address().port}/`)
39
+ const got = await page.evaluate(async () => {
40
+ const { engine } = await import('/roformer.js')
41
+ const meta = await (await fetch('/meta')).json(), bytes = new Uint8Array(await (await fetch('/bin')).arrayBuffer())
42
+ let t = performance.now()
43
+ const s = await engine(meta, bytes)
44
+ if (!s) return { none: true }
45
+ const ready = performance.now() - t, spec = new Float32Array(await (await fetch('/spec')).arrayBuffer()), times = []
46
+ let out
47
+ for (let k = 0; k < 4; k++) { t = performance.now(); out = (await s.run({ mix_spec: { data: spec } })).stems_spec.data; times.push(performance.now() - t) }
48
+ return { ready, times, out: [...out] }
49
+ })
50
+ await browser.close(); server.close()
51
+ if (got.none) console.log('no GPU with subgroup matrices here: the engine stands aside for onnxruntime')
52
+ else {
53
+ let e = 0, p = 0
54
+ for (let i = 0; i < want.length; i++) { e += (got.out[i] - want[i]) ** 2; p += want[i] ** 2 }
55
+ console.log(`engine ready in ${(got.ready / 1000).toFixed(1)} s; a segment ${got.times.map(t => t.toFixed(0)).join(', ')} ms; ` +
56
+ `${(10 * Math.log10(p / e)).toFixed(1)} dB from onnxruntime's CPU on the same weights`)
57
+ }
58
+ process.exit(0)
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env python3
2
+ """Export Mel-Band RoFormer (Wang, Lu, Won, Chen, Song, "Mel-Band RoFormer for Music Source Separation", ISMIR LBD
3
+ 2023) and Band-Split RoFormer (Lu, Wang, Kong, Hung, "Music Source Separation with Band-Split RoPE Transformer", ICASSP
4
+ 2024) to the ONNX contract @audio/neural-separate expects for modelType 'complex': the network between its STFT and
5
+ iSTFT, which JS computes, written for the GPU.
6
+
7
+ pip install torch onnx onnxruntime pyyaml einops beartype rotary-embedding-torch librosa
8
+ python3 export-roformer.py --model mel-roformer --verify
9
+ python3 export-roformer.py --model dereverb-room --verify --fp16
10
+
11
+ The models, each run by ZFTurbo/Music-Source-Separation-Training's (MIT) models/bs_roformer code at a pinned commit:
12
+ mel-roformer Kimberley Jensen's vocals Mel-Band RoFormer (huggingface.co/KimberleyJSN/melbandroformer, MIT), with
13
+ the config that repository runs it by (configs/KimberleyJensen/config_vocals_mel_band_roformer_kj.yaml)
14
+ dereverb-room anvuew's room de-reverb, a mono BS-RoFormer (huggingface.co/anvuew/dereverb_room, GPL-3.0): a voice
15
+ in a room in, the voice dry out; trained on voices in rooms pyroomacoustics simulated (its card); its
16
+ own config, dim 128, depth 16, 63 bands, 29.4 M parameters
17
+ Each checkpoint and config at a pinned revision, the checkpoint checked by its SHA-256. What follows is told of
18
+ mel-roformer; dereverb-room is one channel, 8.7 s segments (384000 samples, hop 512, 751 frames), its bands contiguous
19
+ (each frequency in one band, so its masks are taken as they come) and one RMSNorm after its transformers, not one each.
20
+
21
+ One 8 s segment (352800 samples at 44.1 kHz, 801 frames) per run:
22
+ input mix_spec [1, 4, 1025, 801] STFT, complex as channels (L re, L im, R re, R im)
23
+ output stems_spec [1, 4, 1025, 801] the vocals, per channel, re and im
24
+ The STFT is the model's own: n_fft 2048, hop 441, Hann window of 2048, unnormalized, centered with reflect padding.
25
+
26
+ The graph computes what MelBandRoformer.forward computes between its STFT and iSTFT, rearranged so a GPU runs it in few
27
+ and large operations; none of it changes the function (--verify: against the PyTorch forward):
28
+ - the 60 bands' input projections and mask MLPs run as batched matrix products, bands of near widths grouped and
29
+ zero-padded to the widest of their group (10 groups, none wider than 1.3 times its narrowest), not 60
30
+ small products each;
31
+ - each RMSNorm's scale (√d·γ) is folded into the weights of the linear layer it feeds; the attention's query scale
32
+ into the query weights; its gates' projection joins the q, k, v projection;
33
+ - the rotary embedding's interleaved pairs become halves (the same permutation of q's and k's head features leaves
34
+ q·k as it was), so the rotation is two products with tables of cos and sin and one with a fixed 64×64 matrix;
35
+ - the masks of overlapping mel bands are averaged by two gathers and a weight, not a scatter, the DC bin's weight 0
36
+ (zero_dc);
37
+ - no tensor passes 100 MB: each transformer runs on slices of its sequences, the mask MLP by group of bands
38
+ (onnxruntime's WebGPU Softmax went wrong past WebGPU's default 128 MiB storage binding).
39
+
40
+ Writes <out-dir>/mel-roformer.onnx (928 MB), and with --fp16 mel-roformer.fp16.onnx (float16 weights, float32 compute,
41
+ half the download). Products computed in float16 measured slower on WebGPU (3.2 against 2.6 s a segment): not offered.
42
+ Default out-dir: $AUDIO_NEURAL_CACHE/<model>, else ~/.cache/audiojs/neural/<model>. --verify compares the stems rebuilt
43
+ from onnxruntime's output with MelBandRoformer.forward on the same segment and writes the reference separation test.js
44
+ compares the JS pipeline against (scripts/reference.py, chunked as separate.js chunks: step 0.5, Kim's num_overlap 2).
45
+ Last run: torch 2.14, onnxruntime 1.31.
46
+ """
47
+ import argparse
48
+ import gc
49
+ import math
50
+ import os
51
+ import sys
52
+ import urllib.request
53
+ from pathlib import Path
54
+
55
+ REPO = "https://raw.githubusercontent.com/ZFTurbo/Music-Source-Separation-Training/84b1eac0887756b4f1a9d7a1ff49105939749ed2/"
56
+ MODELS = { # checkpoint URL, its SHA-256, config (a path in REPO or a URL), class, segment (samples), the stem's name
57
+ "mel-roformer": ("https://huggingface.co/KimberleyJSN/melbandroformer/resolve/ac9b0614ab3cd7f77219e18ba494dfd93956c348/MelBandRoformer.ckpt",
58
+ "87201f4d31afb5bc79993230fc49446918425574db48c01c405e44f365c7559e",
59
+ "configs/KimberleyJensen/config_vocals_mel_band_roformer_kj.yaml", "MelBandRoformer", 352800, "vocals"),
60
+ "dereverb-room": ("https://huggingface.co/anvuew/dereverb_room/resolve/0b85f5b80b7f779b2dfe80f33a1b35b38af9376d/dereverb_room_anvuew_sdr_13.7432.ckpt",
61
+ "2edec521f09e26341c1923dc82c8c52dbc86478b42b9999f679535743c970cb3",
62
+ "https://huggingface.co/anvuew/dereverb_room/resolve/0b85f5b80b7f779b2dfe80f33a1b35b38af9376d/dereverb_room_anvuew.yaml",
63
+ "BSRoformer", 384000, "dry"),
64
+ }
65
+ SEGMENT = 352800 # the model's, set by main()
66
+ STEP = 0.5
67
+
68
+
69
+ def fetch(url, path):
70
+ if not path.exists():
71
+ print(f"fetching {url}")
72
+ urllib.request.urlretrieve(url, str(path) + ".part")
73
+ os.replace(str(path) + ".part", path)
74
+ return path
75
+
76
+
77
+ def load(src, name="mel-roformer"):
78
+ """The model with its weights: the model code, config and checkpoint fetched into `src` once."""
79
+ import hashlib
80
+ import torch
81
+ import yaml
82
+
83
+ pkg = src / "roformer_code" / "models" / "bs_roformer"
84
+ pkg.mkdir(parents=True, exist_ok=True)
85
+ (pkg.parent / "__init__.py").touch()
86
+ (pkg / "__init__.py").touch()
87
+ for f in ("mel_band_roformer.py", "bs_roformer.py", "attend.py"):
88
+ fetch(REPO + "models/bs_roformer/" + f, pkg / f)
89
+ sys.path.insert(0, str(src / "roformer_code"))
90
+ sys.dont_write_bytecode = True
91
+ from models.bs_roformer.mel_band_roformer import MelBandRoformer
92
+ from models.bs_roformer.bs_roformer import BSRoformer
93
+
94
+ class Loader(yaml.SafeLoader): pass # the config's resolutions are a !!python/tuple
95
+ Loader.add_constructor("tag:yaml.org,2002:python/tuple", lambda l, n: tuple(l.construct_sequence(n)))
96
+ url, sha, config, cls, _, _ = MODELS[name]
97
+ cfg = yaml.load(fetch(config if "://" in config else REPO + config, src / Path(config).name).read_text(), Loader=Loader)
98
+ ckpt = fetch(url, src / Path(url).name)
99
+ h = hashlib.sha256()
100
+ with open(ckpt, "rb") as f:
101
+ for b in iter(lambda: f.read(1 << 24), b""):
102
+ h.update(b)
103
+ if h.hexdigest() != sha:
104
+ raise SystemExit(f"{ckpt}: SHA-256 {h.hexdigest()}, expected {sha}")
105
+ model = {"MelBandRoformer": MelBandRoformer, "BSRoformer": BSRoformer}[cls](**cfg["model"])
106
+ state = torch.load(ckpt, map_location="cpu", weights_only=True)
107
+ model.load_state_dict(state.get("state_dict", state) if isinstance(state, dict) else state)
108
+ return model.eval(), cfg
109
+
110
+
111
+ def stft(model, x):
112
+ """The model's STFT: (B, C, L) -> (B, 2C, F, T), complex as channels."""
113
+ import torch
114
+
115
+ B, C, L = x.shape
116
+ z = torch.view_as_real(torch.stft(x.reshape(-1, L), **model.stft_kwargs, window=model.stft_window_fn(), return_complex=True))
117
+ return z.permute(0, 3, 1, 2).reshape(B, 2 * C, z.shape[1], z.shape[2])
118
+
119
+
120
+ def istft(model, y, length):
121
+ """(B, 2·S·C, F, T) -> (B, S, C, L)"""
122
+ import torch
123
+
124
+ B, _, Fr, T = y.shape
125
+ y = torch.view_as_complex(y.reshape(-1, 2, Fr, T).permute(0, 2, 3, 1).contiguous())
126
+ x = torch.istft(y, **model.stft_kwargs, window=model.stft_window_fn(), length=length)
127
+ return x.reshape(B, -1, model.audio_channels, length)
128
+
129
+
130
+ def bands(model):
131
+ """(frequencies, (f·S + s) of each band's pairs, bands in order, frequencies per band): Mel-Band RoFormer's
132
+ overlapping mel bands, BS-RoFormer's contiguous ones"""
133
+ S = model.audio_channels
134
+ if hasattr(model, "freq_indices"):
135
+ return model.num_bands_per_freq.numel(), model.freq_indices.tolist(), model.num_freqs_per_band.tolist()
136
+ nf = [d // (2 * S) for d in model.band_split.dim_inputs]
137
+ return sum(nf), list(range(sum(nf) * S)), nf
138
+
139
+
140
+ def groups(widths, slack=1.3):
141
+ """Contiguous runs of bands whose widest is at most `slack` times their narrowest: [(first, last + 1, widest)]"""
142
+ out, b0 = [], 0
143
+ for b in range(1, len(widths) + 1):
144
+ if b == len(widths) or max(widths[b0:b + 1]) > slack * min(widths[b0:b + 1]):
145
+ out.append((b0, b, max(widths[b0:b])))
146
+ b0 = b
147
+ return out
148
+
149
+
150
+ class Core:
151
+ """The model's forward between its STFT and its iSTFT, for the GPU (the module docstring says how)."""
152
+
153
+ @staticmethod
154
+ def build(model, T):
155
+ import torch
156
+ import torch.nn as nn
157
+ import torch.nn.functional as F
158
+
159
+ f64 = torch.float64
160
+ S = model.audio_channels
161
+ Fq, fi, nf = bands(model)
162
+ widths = [2 * n * S for n in nf] # a band's input: its (f, s) pairs, re and im
163
+ G = groups(widths)
164
+ lin = torch.matmul
165
+ # every tensor under WebGPU's default storage binding (128 MiB, maxStorageBufferBindingSize): onnxruntime
166
+ # 1.30's WebGPU Softmax over a larger one came out wrong one run in two (36 dB from the CPU's at 307 MB, right
167
+ # at 123 MB), so a transformer runs on as many sequences at a time as keep its largest, the attention's
168
+ # scores (heads × n × n) or its feed-forward's hidden layer (n × 4·dim), under 100 MB
169
+ BUDGET = 100e6
170
+
171
+ def sliced(f, x):
172
+ per = max(f.biggest(x.shape[1]), 1)
173
+ size = max(1, int(BUDGET // per))
174
+ return torch.cat([f(x[i:i + size]) for i in range(0, x.shape[0], size)]) if size < x.shape[0] else f(x)
175
+
176
+ rot = lambda n, emb: emb(emb.get_seq_pos(n, device="cpu", dtype=torch.float32)) # (n, dh) interleaved
177
+
178
+ def rms(m):
179
+ return (m.gamma.detach().double() * m.scale)
180
+
181
+ class Mod(nn.Module):
182
+ def __init__(s):
183
+ super().__init__()
184
+ # the STFT as columns: E[t, (f·S + s)·2 + c], and a zero column past them (index Fq·S·2)
185
+ pos = [] # each band's input columns, in its order
186
+ o = 0
187
+ for n in nf:
188
+ pos.append([fi[k] * 2 + c for k in range(o, o + n * S) for c in range(2)])
189
+ o += n * S
190
+ zero = Fq * S * 2
191
+ bs = model.band_split.to_features
192
+ s.split_idx, s.split_w, s.split_b = [], nn.ParameterList(), nn.ParameterList()
193
+ for b0, b1, W in G:
194
+ idx = torch.full((b1 - b0, W), zero, dtype=torch.long)
195
+ w = torch.zeros(b1 - b0, W, bs[0][1].out_features, dtype=f64)
196
+ bias = torch.zeros(b1 - b0, 1, w.shape[2], dtype=f64)
197
+ for j, b in enumerate(range(b0, b1)):
198
+ norm, lin = bs[b][0], bs[b][1]
199
+ idx[j, :widths[b]] = torch.tensor(pos[b])
200
+ w[j, :widths[b]] = (lin.weight.detach().double() * rms(norm)[None]).T
201
+ bias[j, 0] = lin.bias.detach().double()
202
+ s.register_buffer(f"si{len(s.split_idx)}", idx)
203
+ s.split_idx.append(f"si{len(s.split_idx)}")
204
+ s.split_w.append(nn.Parameter(w.float(), requires_grad=False))
205
+ s.split_b.append(nn.Parameter(bias.float(), requires_grad=False))
206
+ # transformers: time then frequency, per depth
207
+ s.blocks = nn.ModuleList()
208
+ for time, freq in model.layers:
209
+ s.blocks.append(nn.ModuleList([Block(time, T), Block(freq, len(nf))]))
210
+ # BS-RoFormer's one norm after the transformers (Mel-Band RoFormer's end each transformer)
211
+ fn = getattr(model, "final_norm", None)
212
+ s.final = nn.Parameter(rms(fn).float(), requires_grad=False) if fn is not None else None
213
+ # mask estimator (one stem): its MLP's linear layers before the last batched over bands (their shapes
214
+ # alike), the last by group, then GLU
215
+ me = model.mask_estimators[0].to_freqs
216
+ lin = [[m for m in me[b][0] if isinstance(m, nn.Linear)] for b in range(len(nf))]
217
+ s.mw = nn.ParameterList([nn.Parameter(torch.stack([l[i].weight.detach().T for l in lin]), requires_grad=False) for i in range(len(lin[0]) - 1)])
218
+ s.mb = nn.ParameterList([nn.Parameter(torch.stack([l[i].bias.detach() for l in lin])[:, None], requires_grad=False) for i in range(len(lin[0]) - 1)])
219
+ s.m2_w, s.m2_b = nn.ParameterList(), nn.ParameterList()
220
+ col, cols = 0, {}
221
+ for b0, b1, W in G:
222
+ H = lin[0][-1].in_features
223
+ w = torch.zeros(b1 - b0, H, 2 * W)
224
+ bias = torch.zeros(b1 - b0, 1, 2 * W)
225
+ for j, b in enumerate(range(b0, b1)):
226
+ l2 = lin[b][-1]
227
+ n = widths[b]
228
+ w[j, :, :n], w[j, :, W:W + n] = l2.weight.detach()[:n].T, l2.weight.detach()[n:].T
229
+ bias[j, 0, :n], bias[j, 0, W:W + n] = l2.bias.detach()[:n], l2.bias.detach()[n:]
230
+ for e in range(n):
231
+ cols.setdefault(pos[b][e], []).append(col + j * W + e)
232
+ s.m2_w.append(nn.Parameter(w, requires_grad=False))
233
+ s.m2_b.append(nn.Parameter(bias, requires_grad=False))
234
+ col += (b1 - b0) * W
235
+ # each output column's sources among the masks (at most two bands overlap), the zero column for none,
236
+ # and its weight: 1 over the bands that cover it, 0 at DC
237
+ i1 = torch.full((zero,), col, dtype=torch.long)
238
+ i2 = torch.full((zero,), col, dtype=torch.long)
239
+ wt = torch.zeros(zero)
240
+ for o, src in cols.items():
241
+ assert len(src) <= 2, "three bands over one frequency"
242
+ i1[o] = src[0]
243
+ i2[o] = src[-1] if len(src) == 2 else col
244
+ wt[o] = 0 if o // (S * 2) == 0 and model.zero_dc else 1 / len(src)
245
+ s.register_buffer("i1", i1)
246
+ s.register_buffer("i2", i2)
247
+ s.register_buffer("wt", wt)
248
+ s.Fq, s.S = Fq, S
249
+
250
+ def forward(s, mix_spec):
251
+ Fq, S = s.Fq, s.S
252
+ T_ = mix_spec.shape[3]
253
+ # (1, 2S, F, T) -> E (T, F·S·2): [t, (f·S + s)·2 + c]
254
+ E = mix_spec.reshape(S, 2, Fq, T_).permute(3, 2, 0, 1).reshape(T_, Fq * S * 2)
255
+ Ez = torch.cat([E, torch.zeros(T_, 1, dtype=E.dtype)], 1)
256
+ xs = []
257
+ for k, name in enumerate(s.split_idx):
258
+ idx = getattr(s, name) # (nb, W)
259
+ x = Ez[:, idx].permute(1, 0, 2) # (nb, T, W)
260
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12)
261
+ xs.append(lin(x, s.split_w[k]) + s.split_b[k])
262
+ x = torch.cat(xs, 0) # (B, T, D)
263
+ for time, freq in s.blocks:
264
+ x = sliced(time, x) # sequences over time, one per band
265
+ x = sliced(freq, x.transpose(0, 1)).transpose(0, 1) # over bands, one per frame
266
+ if s.final is not None:
267
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12) * s.final
268
+ ms = []
269
+ for k, (lo, hi, W) in enumerate(G):
270
+ h = x[lo:hi]
271
+ for w, b in zip(s.mw, s.mb):
272
+ h = torch.tanh(lin(h, w[lo:hi]) + b[lo:hi]) # (nb, T, H)
273
+ y = lin(h, s.m2_w[k]) + s.m2_b[k] # (nb, T, 2W)
274
+ m = y[..., :W] * torch.sigmoid(y[..., W:]) # GLU
275
+ ms.append(m.permute(1, 0, 2).reshape(T_, (hi - lo) * W))
276
+ M = torch.cat(ms + [torch.zeros(T_, 1)], 1) # (T, columns + 1)
277
+ mask = (M[:, s.i1] + M[:, s.i2]) * s.wt # (T, F·S·2)
278
+ e = E.reshape(T_, Fq, S, 2)
279
+ mk = mask.reshape(T_, Fq, S, 2)
280
+ er, ei, mr, mi = e[..., 0], e[..., 1], mk[..., 0], mk[..., 1]
281
+ out = torch.stack([er * mr - ei * mi, er * mi + ei * mr], -1) # (T, F, S, 2)
282
+ return out.permute(2, 3, 1, 0).reshape(1, 2 * S, Fq, T_)
283
+
284
+ class Block(nn.Module):
285
+ """One Transformer of the model (its layers and output norm) over sequences of n: x (batch, n, D)."""
286
+
287
+ def __init__(s, tr, n):
288
+ super().__init__()
289
+ s.layers = nn.ModuleList()
290
+ for attn, ff in tr.layers:
291
+ s.layers.append(nn.ModuleList([Attn(attn, n), FF(ff)]))
292
+ s.out = None if isinstance(tr.norm, nn.Identity) else nn.Parameter(rms(tr.norm).float(), requires_grad=False)
293
+
294
+ def biggest(s, n):
295
+ """bytes of the largest tensor one sequence of n makes"""
296
+ a, ff = s.layers[0]
297
+ return 4 * max(a.h * n * n, n * ff.w1.shape[1], n * a.qkvg.shape[1])
298
+
299
+ def forward(s, x):
300
+ for attn, ff in s.layers:
301
+ x = attn(x) + x
302
+ x = ff(x) + x
303
+ return x if s.out is None else x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12) * s.out
304
+
305
+ class Attn(nn.Module):
306
+ def __init__(s, a, n):
307
+ super().__init__()
308
+ h, dh = a.heads, a.to_qkv.out_features // (3 * a.heads)
309
+ g = rms(a.norm)[None]
310
+ wq, wk, wv = (a.to_qkv.weight.detach().double() * g).reshape(3, h, dh, -1)
311
+ # interleaved pairs -> halves, the same permutation of q's and k's features
312
+ perm = torch.cat([torch.arange(0, dh, 2), torch.arange(1, dh, 2)])
313
+ wq, wk = wq[:, perm] * a.scale, wk[:, perm]
314
+ wg = a.to_gates.weight.detach().double() * g
315
+ s.qkvg = nn.Parameter(torch.cat([wq.reshape(h * dh, -1), wk.reshape(h * dh, -1), wv.reshape(h * dh, -1), wg]).T.float(), requires_grad=False)
316
+ s.gb = nn.Parameter(a.to_gates.bias.detach().float(), requires_grad=False)
317
+ s.wo = nn.Parameter(a.to_out[0].weight.detach().T.float(), requires_grad=False)
318
+ freqs = rot(n, a.rotary_embed).double()[:, perm] # (n, dh)
319
+ s.register_buffer("cos", freqs.cos().float())
320
+ s.register_buffer("sin", freqs.sin().float())
321
+ R = torch.zeros(dh, dh) # x @ R: (-x[half:], x[:half])
322
+ R[torch.arange(dh // 2) + dh // 2, torch.arange(dh // 2)] = -1
323
+ R[torch.arange(dh // 2), torch.arange(dh // 2) + dh // 2] = 1
324
+ s.register_buffer("R", R)
325
+ s.h, s.dh = h, dh
326
+
327
+ def forward(s, x):
328
+ Bt, n, _ = x.shape
329
+ h, dh = s.h, s.dh
330
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12)
331
+ y = lin(x, s.qkvg)
332
+ q, k, v = (y[..., i * h * dh:(i + 1) * h * dh].reshape(Bt, n, h, dh).transpose(1, 2) for i in range(3))
333
+ gates = torch.sigmoid(y[..., 3 * h * dh:] + s.gb) # (Bt, n, h)
334
+ q = q * s.cos + torch.matmul(q, s.R) * s.sin
335
+ k = k * s.cos + torch.matmul(k, s.R) * s.sin
336
+ o = torch.matmul(torch.softmax(torch.matmul(q, k.transpose(-1, -2)), -1), v) # (Bt, h, n, dh)
337
+ o = o * gates.transpose(1, 2)[..., None]
338
+ return lin(o.transpose(1, 2).reshape(Bt, n, h * dh), s.wo)
339
+
340
+ class FF(nn.Module):
341
+ def __init__(s, ff):
342
+ super().__init__()
343
+ norm, l1, _, _, l2, _ = ff.net
344
+ s.w1 = nn.Parameter((l1.weight.detach().double() * rms(norm)[None]).T.float(), requires_grad=False)
345
+ s.b1 = nn.Parameter(l1.bias.detach().float(), requires_grad=False)
346
+ s.w2 = nn.Parameter(l2.weight.detach().T.float(), requires_grad=False)
347
+ s.b2 = nn.Parameter(l2.bias.detach().float(), requires_grad=False)
348
+
349
+ def forward(s, x):
350
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12)
351
+ return lin(F.gelu(lin(x, s.w1) + s.b1), s.w2) + s.b2
352
+
353
+ return Mod().eval()
354
+
355
+
356
+ def frames(model):
357
+ import torch
358
+
359
+ return stft(model, torch.zeros(1, model.audio_channels, SEGMENT)).shape[3]
360
+
361
+
362
+ def export(model, path, opset):
363
+ import torch
364
+
365
+ T = frames(model)
366
+ core = Core.build(model, T)
367
+ spec = stft(model, torch.randn(1, model.audio_channels, SEGMENT))
368
+ with torch.no_grad():
369
+ torch.onnx.export(core, (spec,), str(path), input_names=["mix_spec"], output_names=["stems_spec"],
370
+ opset_version=opset, dynamo=False)
371
+ print(f"wrote {path} ({path.stat().st_size / 1e6:.1f} MB), segment {SEGMENT} samples, spec {list(spec.shape)}, groups {groups([2 * n * model.audio_channels for n in bands(model)[2]])}")
372
+ return core
373
+
374
+
375
+ def engine(model, out_dir, name):
376
+ """The GPU form's tensors for the package's own WebGPU engine (roformer.js): <name>.engine.bin, each tensor in turn
377
+ (weights float16, tables float32, indices int32), and <name>.engine.json naming them, their type, shape and offset,
378
+ with the band groups and the sizes the engine reads."""
379
+ import json
380
+ import numpy as np
381
+ import torch
382
+
383
+ T = frames(model)
384
+ core = Core.build(model, T)
385
+ tensors, blob, off = {}, bytearray(), 0
386
+ for n, t in list(core.named_parameters()) + list(core.named_buffers()):
387
+ a = t.detach().numpy()
388
+ kind = "int32" if a.dtype.kind in "iu" else "float16" if n.rsplit(".", 1)[-1] in ("qkvg", "wo", "w1", "w2") or n.startswith(("split_w.", "mw.", "m2_w.")) else "float32"
389
+ b = a.astype(kind).tobytes()
390
+ pad = -len(blob) % 16
391
+ blob += bytes(pad)
392
+ tensors[n] = {"type": kind, "shape": list(a.shape), "offset": len(blob)}
393
+ blob += b
394
+ nf = bands(model)[2]
395
+ meta = {
396
+ "model": name, "frames": T, "freqs": core.Fq, "channels": core.S, "dim": model.layers[0][0].layers[0][0].to_qkv.in_features,
397
+ "heads": model.layers[0][0].layers[0][0].heads, "depth": len(model.layers), "bands": len(nf),
398
+ "groups": [list(g) for g in groups([2 * n * core.S for n in nf])], "widths": [2 * n * core.S for n in nf],
399
+ "tensors": tensors,
400
+ }
401
+ (out_dir / f"{name}.engine.bin").write_bytes(bytes(blob))
402
+ (out_dir / f"{name}.engine.json").write_text(json.dumps(meta))
403
+ print(f"wrote {out_dir / (name + '.engine.bin')} ({len(blob) / 1e6:.1f} MB) and its .json, {len(tensors)} tensors")
404
+
405
+
406
+ def fp16(path, out):
407
+ """float16 weights, float32 compute (scripts/compact.py --as fp16)."""
408
+ sys.path.insert(0, str(Path(__file__).parent))
409
+ import compact
410
+
411
+ compact.compact(path, out, "fp16")
412
+ print(f"wrote {out} ({out.stat().st_size / 1e6:.1f} MB)")
413
+
414
+
415
+ def session(path):
416
+ import onnxruntime as ort
417
+
418
+ o = ort.SessionOptions()
419
+ o.enable_cpu_mem_arena = False
420
+ o.enable_mem_pattern = False
421
+ return ort.InferenceSession(str(path), o, providers=["CPUExecutionProvider"])
422
+
423
+
424
+ def mixes(C):
425
+ import torch
426
+
427
+ t = torch.arange(SEGMENT) / 44100
428
+ tone = sum(torch.sin(2 * math.pi * f * t) / k for k, f in enumerate((110, 220, 440, 880, 1760), 1))
429
+ return (("noise", 0.1 * torch.randn(1, C, SEGMENT)), ("tones", 0.2 * torch.stack([tone, 0.8 * tone][:C])[None]))
430
+
431
+
432
+ def verify(model, run, label, tol):
433
+ """The stem rebuilt from run(spec) against the model's forward: noise and tones."""
434
+ import numpy as np
435
+ import torch
436
+
437
+ for name, mix in mixes(model.audio_channels):
438
+ with torch.no_grad():
439
+ want = model(mix).numpy()
440
+ got = istft(model, torch.as_tensor(run(stft(model, mix))), SEGMENT)[:, 0].numpy()
441
+ want = want[..., :got.shape[-1]]
442
+ rel = np.abs(got - want).max() / max(np.abs(want).max(), 1e-12)
443
+ print(f" verify {label} {name}: max|diff|/max|y| = {rel:.2e} " + ("OK" if rel < tol else "FAIL"))
444
+ if rel >= tol:
445
+ raise SystemExit(f"{label}: verification failed ({rel:.2e} >= {tol})")
446
+
447
+
448
+ def reference(model, out_dir, stem):
449
+ """The reference separation: the model's forward on segments as scripts/reference.py's chunked() cuts them
450
+ (step 0.5, no standardization: Kim's config normalizes nothing, nor anvuew's), over a 20 s mix with a noise floor;
451
+ a mono model's channel by channel, as separate.js runs a mono preset."""
452
+ import torch
453
+
454
+ import numpy as np
455
+
456
+ sys.path.insert(0, str(Path(__file__).parent))
457
+ import reference as ref
458
+
459
+ def run(seg):
460
+ with torch.no_grad():
461
+ y = model(torch.from_numpy(seg)[None]) # (1, C, L), BS-RoFormer's (1, 1 stem, C, L)
462
+ return (y[0] if y.ndim == 4 else y[0][None]).numpy() # (1 stem, C, L)
463
+
464
+ # under the mix a noise floor 60 dB down: each band normalizes its input, and a silent band's is its rounding
465
+ if model.audio_channels == 1:
466
+ each = lambda mix: np.concatenate([ref.chunked(mix[c:c + 1], run, SEGMENT, STEP)[0] for c in range(len(mix))])
467
+ else:
468
+ each = lambda mix: ref.chunked(mix, run, SEGMENT, STEP)[0]
469
+ ref.write(out_dir, lambda mix: {stem: each(mix)}, 20.0, 1e-3)
470
+
471
+
472
+ def main():
473
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
474
+ ap.add_argument("--model", default="mel-roformer", choices=list(MODELS))
475
+ ap.add_argument("--out-dir", default=None, help="default: $AUDIO_NEURAL_CACHE/<model> or ~/.cache/audiojs/neural/<model>")
476
+ ap.add_argument("--opset", type=int, default=17)
477
+ ap.add_argument("--fp16", action="store_true", help="also write <model>.fp16.onnx (float16 weights, float32 compute)")
478
+ ap.add_argument("--verify", action="store_true", help="compare with the model's forward; write the reference separation")
479
+ ap.add_argument("--engine", action="store_true", help="also write <model>.engine.bin/.json: the tensors for roformer.js, the package's WebGPU engine")
480
+ args = ap.parse_args()
481
+ if args.engine and MODELS[args.model][3] != "MelBandRoformer":
482
+ raise SystemExit("--engine: roformer.js runs Mel-Band RoFormer alone")
483
+ global SEGMENT
484
+ SEGMENT = MODELS[args.model][4]
485
+
486
+ cache = Path(os.environ.get("AUDIO_NEURAL_CACHE") or Path.home() / ".cache" / "audiojs" / "neural")
487
+ out_dir = Path(args.out_dir) if args.out_dir else cache / args.model
488
+ out_dir.mkdir(parents=True, exist_ok=True)
489
+ src = out_dir / "src"
490
+ src.mkdir(exist_ok=True)
491
+ try:
492
+ model, _ = load(src, args.model)
493
+ except ImportError as e:
494
+ raise SystemExit(f"required: pip install torch onnx onnxruntime pyyaml einops beartype rotary-embedding-torch librosa ({e})")
495
+ path = out_dir / f"{args.model}.onnx"
496
+ core = export(model, path, args.opset)
497
+ if args.verify:
498
+ import torch
499
+
500
+ with torch.no_grad():
501
+ verify(model, lambda spec: core(spec), "the GPU form in torch", 1e-4)
502
+ # onnxruntime against torch: on the tones 1.7e-4, where torch's own float32 stands 4.6e-3 from its float64
503
+ s = session(path)
504
+ verify(model, lambda spec: s.run(None, {"mix_spec": spec.numpy()})[0], path.name, 1e-3)
505
+ del s
506
+ gc.collect()
507
+ reference(model, out_dir, MODELS[args.model][5])
508
+ if args.fp16:
509
+ fp16(path, out_dir / f"{args.model}.fp16.onnx")
510
+ if args.verify:
511
+ s = session(out_dir / f"{args.model}.fp16.onnx")
512
+ verify(model, lambda spec: s.run(None, {"mix_spec": spec.numpy()})[0], f"{args.model}.fp16.onnx", 2e-2)
513
+ if args.engine:
514
+ engine(model, out_dir, args.model)
515
+ print(f"done: {args.model} in {out_dir}")
516
+
517
+
518
+ if __name__ == "__main__":
519
+ sys.exit(main())
@@ -64,9 +64,12 @@ def chunked(mix, run, L, step=0.25):
64
64
  return out[..., border:border + N] if wide else out
65
65
 
66
66
 
67
- def write(out_dir, separate, seconds=9.0):
68
- """separate(mix (2, N) float32) -> { target: (2, N) array }"""
67
+ def write(out_dir, separate, seconds=9.0, floor=0.0):
68
+ """separate(mix (2, N) float32) -> { target: (2, N) array }. `floor`: white noise of that RMS under the mix, so no
69
+ band of it is silent (a model that normalizes each band's input turns a silent band's rounding into its direction)"""
69
70
  mix = test_mix(seconds)
71
+ if floor:
72
+ mix = (mix + floor * np.random.RandomState(1).randn(*mix.shape)).astype(np.float32)
70
73
  mix.tofile(out_dir / "test.f32")
71
74
  for name, x in separate(mix).items():
72
75
  np.asarray(x, dtype=np.float32).tofile(out_dir / f"test.{name}.f32")
package/separate.js CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { fft, ifft } from 'fourier-transform'
29
29
  import resampleSinc from '@audio/resample-sinc'
30
- import { load as neuralLoad, tensor, fetchModel } from '@audio/neural-runtime'
30
+ import { load as neuralLoad, tensor, fetchModel, fetchJson, backends } from '@audio/neural-runtime'
31
31
 
32
32
  const PI2 = Math.PI * 2
33
33
  const isNode = typeof process !== 'undefined' && !!process.versions?.node
@@ -371,6 +371,24 @@ const TIGER = {
371
371
  standardize: false, mono: true, script: 'export-tiger.py',
372
372
  }
373
373
 
374
+ // Mel-Band RoFormer's STFT (Hann of 2048, hop 441, unnormalized), 8 s segments of 801 frames every 4 s, as
375
+ // Music-Source-Separation-Training's demix() runs Kim's vocals model (num_overlap 2); nothing normalized
376
+ const MEL = {
377
+ modelType: 'complex', sampleRate: 44100, targets: ['vocals'], sources: ['vocals'], n: 2048, hop: 441, window: 'hann',
378
+ normalized: false, segment: 352800, frames: 801, step: 0.5, standardize: false, gpu: true, script: 'export-roformer.py',
379
+ // roformer.js's tensors, for a browser whose GPU has subgroup matrices: the engine runs in onnxruntime's place
380
+ engine: { meta: 'mel-roformer.engine.json', bin: 'mel-roformer.engine.bin', sha256: '140fedcd9d03a8f918b235688c69f4dfdd86b08f9d430277659c095688a026ff' },
381
+ }
382
+
383
+ // anvuew's room de-reverb (a mono BS-RoFormer, GPL-3.0 weights) as scripts/export-roformer.py exports it: its STFT
384
+ // (Hann of 2048, hop 512, unnormalized), 8.7 s segments of 751 frames every 4.35 s, as Music-Source-Separation-Training's
385
+ // demix() runs it (num_overlap 2); each channel apart, nothing normalized. Its one stem is the voice dry; the residual
386
+ // is the room
387
+ const ROOM = {
388
+ modelType: 'complex', sampleRate: 44100, targets: ['dry'], sources: ['dry'], n: 2048, hop: 512, window: 'hann',
389
+ normalized: false, segment: 384000, frames: 751, step: 0.5, standardize: false, mono: true, gpu: true, script: 'export-roformer.py',
390
+ }
391
+
374
392
  // The compact files (scripts/compact.py: int8 weights, a few float16, float32 compute), each within 0.05 dB of its
375
393
  // export's median SDR or SNR per stem (README, Size); their Hugging Face repositories, and SHA-256 checked when fetched
376
394
  const FILES = {
@@ -378,6 +396,10 @@ const FILES = {
378
396
  scnet: { file: 'scnet.int8.onnx', repo: 'audiojs/scnet', sha256: '98228931494151762a1c4ab1ec7899a894b1f81fd4a509921a9d2f9bacc50845' },
379
397
  mrx: { file: 'mrx.int8.onnx', repo: 'audiojs/mrx', sha256: 'd876c92d224f5d8cd2207d528b23369da895a6de71fb24132416fcb16f9f8424' },
380
398
  tiger: { file: 'tiger.int8.onnx', repo: 'audiojs/tiger-dnr', sha256: '2189859ac7e89cf4d9d284c6f393e737aaa2b3233ed7ee424ad622012bce7bca' },
399
+ // float16 weights, float32 compute: half the download; int8 (259 MB) ran slower on WebGPU and 10 dB further from float32
400
+ 'mel-roformer': { file: 'mel-roformer.fp16.onnx', repo: 'audiojs/mel-roformer', sha256: 'adcbbb826f12453fbb86652b503242d565a9c81cdef1bca2fe0e54752eba91ef' },
401
+ // float16 weights, float32 compute: 60 MB, its output 49 dB from the export's
402
+ 'dereverb-room': { file: 'dereverb-room.fp16.onnx', repo: 'audiojs/dereverb-room', sha256: '99797e086a71d1cb57d50f3b5be93dcb7c89c49d1537c60487a488a24aaee9ee' },
381
403
  }
382
404
 
383
405
  // The hosted revision of each compact file's repository (a commit), its URL https://huggingface.co/<repo>/resolve/<revision>/
@@ -388,6 +410,8 @@ export const REVISIONS = {
388
410
  scnet: 'e71bcdb42bce1d62b7fae25ba90fcbcf90f73f46',
389
411
  mrx: 'dedc823a0bea2ed085d19e8b7f32589d35169ee8',
390
412
  tiger: '1c959ab7ff8794dc5663b104ba301687ab95d2ab',
413
+ 'mel-roformer': 'c1aba02c00a0bee852d6754fe6ede8164f90a4b3',
414
+ 'dereverb-room': 'b4c2d14da9f2199099656195b06fd8253fd16e1d',
391
415
  }
392
416
 
393
417
  const hosted = name => models[name].repo && REVISIONS[name] ? `https://huggingface.co/${models[name].repo}/resolve/${REVISIONS[name]}/${models[name].file}` : null
@@ -406,6 +430,11 @@ export const models = {
406
430
  mrx: { ...MRX, ...FILES.mrx },
407
431
  // TIGER (JusperLee/TIGER, Apache-2.0 weights): the same three stems, three band-split models, about 50 times MRX's time
408
432
  tiger: { ...TIGER, ...FILES.tiger },
433
+ // Mel-Band RoFormer (Kimberley Jensen's vocals model, MIT weights) as scripts/export-roformer.py exports it for the GPU
434
+ 'mel-roformer': { ...MEL, ...FILES['mel-roformer'] },
435
+ // anvuew's room de-reverb (BS-RoFormer, GPL-3.0 weights) as scripts/export-roformer.py exports it: a voice in a room
436
+ // in, the voice dry out
437
+ 'dereverb-room': { ...ROOM, ...FILES['dereverb-room'] },
409
438
  }
410
439
 
411
440
  async function cacheDir() {
@@ -452,6 +481,38 @@ async function local(graphs, name, opts) {
452
481
  `, or pass opts.weights: a URL or directory holding ${name}/<file>.onnx`)
453
482
  }
454
483
 
484
+ // A preset's own WebGPU engine (roformer.js) as a session, its tensors from `weights` or the hosted revision, or null
485
+ // where the GPU has no subgroup matrices (onnxruntime then): in the browser. Kept after a separation for KEEP ms, so the
486
+ // next one starts at once (its 466 MB of weights and 2 GB of buffers on the GPU, made in about a second), then let go
487
+ const KEEP = 60_000
488
+ let kept = null
489
+ function engineSession(opts, p) {
490
+ let name = opts.model, key = `${name}:${opts.weights ?? ''}:${p.engine.sha256}`
491
+ if (kept?.key === key) { clearTimeout(kept.timer); return kept.session }
492
+ kept?.session.then(s => s?.drop())
493
+ let mine = { key, timer: null }
494
+ // free() after a separation lets go KEEP ms later, unless the next one has taken it
495
+ mine.session = makeEngine(opts, p, name).then(s => s && {
496
+ ...s, drop: s.free,
497
+ free() { clearTimeout(mine.timer); mine.timer = setTimeout(() => { if (kept === mine) kept = null; s.free() }, KEEP) },
498
+ })
499
+ return (kept = mine).session
500
+ }
501
+ async function makeEngine(opts, p, name) {
502
+ let { engine, gpu } = await import('./roformer.js'), device = await gpu()
503
+ if (!device) return null
504
+ try {
505
+ let base = opts.weights != null ? `${await weightsBase(opts, name)}${name}/` : `https://huggingface.co/${p.repo}/resolve/${REVISIONS[name]}/`
506
+ let [meta, bytes] = await Promise.all([fetchJson(base + p.engine.meta), checked(base + p.engine.bin, p.engine.sha256)])
507
+ return await engine(meta, bytes, device)
508
+ } catch (e) {
509
+ // its files not there (weights without them), or the GPU refusing its buffers: onnxruntime, as without it
510
+ device.destroy()
511
+ console.warn(`neural-separate: ${name}'s own engine stands aside (${e.message})`)
512
+ return null
513
+ }
514
+ }
515
+
455
516
  // The hosted compact file into the cache, checked against its SHA-256, written whole or not at all → its file URL
456
517
  async function fetched(name, dest) {
457
518
  let [fs, path, { pathToFileURL }] = await Promise.all([import('node:fs'), import('node:path'), import('node:url')])
@@ -503,12 +564,18 @@ async function resolveModel(opts) {
503
564
  // No pooled arena: one htdemucs segment peaks at 2.7 GB RSS in onnxruntime-node instead of 3.2 GB.
504
565
  const sessionOptions = { enableCpuMemArena: false, enableMemPattern: false }
505
566
 
506
- // A graph's session; a fetched file with a SHA-256 checked against it first
507
- function loader(opts) {
567
+ // A graph's session; a fetched file with a SHA-256 checked against it first. A preset written for the GPU (`gpu`) runs
568
+ // on WebGPU where there is one (onnxruntime-web's in a browser, onnxruntime-node's in Node) unless `device` says
569
+ // otherwise: SCNet's LSTMs and the others' many small operators run no faster there
570
+ function loader(opts, preset) {
571
+ let device = opts.device ?? (preset?.gpu ? backends().then(b => b.includes('webgpu') ? 'webgpu' : undefined) : undefined)
572
+ let own = !opts.device && !opts.session && preset?.engine && !isNode ? engineSession(opts, preset) : null
508
573
  return async g => {
509
574
  if (opts.session) return opts.session(g.spec, opts)
575
+ let s = own && await own
576
+ if (s) return s
510
577
  let spec = g.sha256 && !g.spec.startsWith('file:') ? await checked(g.spec, g.sha256) : g.spec
511
- return neuralLoad(spec, { backend: opts.device, sessionOptions })
578
+ return neuralLoad(spec, { backend: await device, sessionOptions })
512
579
  }
513
580
  }
514
581
 
@@ -959,7 +1026,7 @@ export default async function separate(audio, opts = {}) {
959
1026
  if (!channelData[0].length) return { stems: Object.fromEntries(targets.map(t => [t, empty()])), sampleRate: rate, residual: empty() }
960
1027
  let targetRate = opts.targetRate ?? modelRate ?? rate
961
1028
  let proc = targetRate !== rate ? channelData.map(c => resampleSinc(c, { from: rate, to: targetRate })) : channelData
962
- let load = loader(opts)
1029
+ let load = loader(opts, preset)
963
1030
  // the input at the loudness the model trained at, its stems scaled back (a silent input as it is)
964
1031
  let level = preset?.loudness == null ? -Infinity : lufs(proc, targetRate), gain = Number.isFinite(level) ? 10 ** ((preset.loudness - level) / 20) : 1
965
1032
  if (gain !== 1) proc = proc.map(c => c.map(v => v * gain))