@audio/neural-separate 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 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
 
@@ -67,8 +68,9 @@ the float32 graph's.
67
68
  ## Weights
68
69
 
69
70
  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
71
+ (Size, below), and `mel-roformer` its float16 weights, `mel-roformer.fp16.onnx` (or `roformer.js`'s `mel-roformer.engine.bin`), hosted on Hugging Face
72
+ (`audiojs/scnet-large`, `audiojs/scnet`, `audiojs/mrx`, `audiojs/tiger-dnr`, `audiojs/mel-roformer`, each with its model
73
+ card and licence): without `weights`, `https://huggingface.co/<repo>/resolve/<revision>/<file>` at the
72
74
  revision `REVISIONS` pins, fetched once and checked against its SHA-256 (the browser keeps it in the Cache API, Node in
73
75
  the neural cache, below). Every model can also be exported from its original checkpoint:
74
76
 
@@ -88,6 +90,10 @@ python3 node_modules/@audio/neural-separate/scripts/export-mrx.py --verify
88
90
  pip install safetensors
89
91
  python3 node_modules/@audio/neural-separate/scripts/export-tiger.py --verify
90
92
 
93
+ pip install einops beartype rotary-embedding-torch librosa
94
+ python3 node_modules/@audio/neural-separate/scripts/export-roformer.py --verify --fp16 --engine
95
+ node node_modules/@audio/neural-separate/scripts/engine.mjs # roformer.js in Chromium against onnxruntime (playwright)
96
+
91
97
  python3 node_modules/@audio/neural-separate/scripts/compact.py --model tiger --verify # tiger.int8.onnx from tiger.onnx
92
98
  ```
93
99
 
@@ -97,6 +103,8 @@ They land in `$AUDIO_NEURAL_CACHE/<model>/`, default `~/.cache/audiojs/neural/<m
97
103
  - **`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
104
  - **`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
105
  - **`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.
106
+ - **`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.
107
+ - **`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
108
  - **`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
109
 
102
110
  ## Size
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.5.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,481 @@
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) to the ONNX contract @audio/neural-separate expects for modelType 'complex': the network between its STFT and
4
+ iSTFT, which JS computes, written for the GPU.
5
+
6
+ pip install torch onnx onnxruntime pyyaml einops beartype rotary-embedding-torch librosa
7
+ python3 export-roformer.py --model mel-roformer --verify
8
+
9
+ The model is Kimberley Jensen's vocals Mel-Band RoFormer (huggingface.co/KimberleyJSN/melbandroformer, MIT), with the
10
+ config ZFTurbo/Music-Source-Separation-Training (MIT) runs it by (configs/KimberleyJensen/
11
+ config_vocals_mel_band_roformer_kj.yaml) and that repository's models/bs_roformer code, both at a pinned commit.
12
+
13
+ One 8 s segment (352800 samples at 44.1 kHz, 801 frames) per run:
14
+ input mix_spec [1, 4, 1025, 801] STFT, complex as channels (L re, L im, R re, R im)
15
+ output stems_spec [1, 4, 1025, 801] the vocals, per channel, re and im
16
+ The STFT is the model's own: n_fft 2048, hop 441, Hann window of 2048, unnormalized, centered with reflect padding.
17
+
18
+ The graph computes what MelBandRoformer.forward computes between its STFT and iSTFT, rearranged so a GPU runs it in few
19
+ and large operations; none of it changes the function (--verify: against the PyTorch forward):
20
+ - the 60 bands' input projections and mask MLPs run as batched matrix products, bands of near widths grouped and
21
+ zero-padded to the widest of their group (10 groups, none wider than 1.3 times its narrowest), not 60
22
+ small products each;
23
+ - each RMSNorm's scale (√d·γ) is folded into the weights of the linear layer it feeds; the attention's query scale
24
+ into the query weights; its gates' projection joins the q, k, v projection;
25
+ - the rotary embedding's interleaved pairs become halves (the same permutation of q's and k's head features leaves
26
+ 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;
27
+ - the masks of overlapping mel bands are averaged by two gathers and a weight, not a scatter, the DC bin's weight 0
28
+ (zero_dc);
29
+ - no tensor passes 100 MB: each transformer runs on slices of its sequences, the mask MLP by group of bands
30
+ (onnxruntime's WebGPU Softmax went wrong past WebGPU's default 128 MiB storage binding).
31
+
32
+ Writes <out-dir>/mel-roformer.onnx (928 MB), and with --fp16 mel-roformer.fp16.onnx (float16 weights, float32 compute,
33
+ half the download). Products computed in float16 measured slower on WebGPU (3.2 against 2.6 s a segment): not offered.
34
+ Default out-dir: $AUDIO_NEURAL_CACHE/<model>, else ~/.cache/audiojs/neural/<model>. --verify compares the stems rebuilt
35
+ from onnxruntime's output with MelBandRoformer.forward on the same segment and writes the reference separation test.js
36
+ compares the JS pipeline against (scripts/reference.py, chunked as separate.js chunks: step 0.5, Kim's num_overlap 2).
37
+ Last run: torch 2.14, onnxruntime 1.31.
38
+ """
39
+ import argparse
40
+ import gc
41
+ import math
42
+ import os
43
+ import sys
44
+ import urllib.request
45
+ from pathlib import Path
46
+
47
+ REPO = "https://raw.githubusercontent.com/ZFTurbo/Music-Source-Separation-Training/84b1eac0887756b4f1a9d7a1ff49105939749ed2/"
48
+ MODELS = { # checkpoint URL, its SHA-256, config path in REPO
49
+ "mel-roformer": ("https://huggingface.co/KimberleyJSN/melbandroformer/resolve/ac9b0614ab3cd7f77219e18ba494dfd93956c348/MelBandRoformer.ckpt",
50
+ "87201f4d31afb5bc79993230fc49446918425574db48c01c405e44f365c7559e",
51
+ "configs/KimberleyJensen/config_vocals_mel_band_roformer_kj.yaml"),
52
+ }
53
+ SEGMENT = 352800
54
+ STEP = 0.5
55
+
56
+
57
+ def fetch(url, path):
58
+ if not path.exists():
59
+ print(f"fetching {url}")
60
+ urllib.request.urlretrieve(url, str(path) + ".part")
61
+ os.replace(str(path) + ".part", path)
62
+ return path
63
+
64
+
65
+ def load(src, name="mel-roformer"):
66
+ """MelBandRoformer with its weights: the model code, config and checkpoint fetched into `src` once."""
67
+ import hashlib
68
+ import torch
69
+ import yaml
70
+
71
+ pkg = src / "roformer_code" / "models" / "bs_roformer"
72
+ pkg.mkdir(parents=True, exist_ok=True)
73
+ (pkg.parent / "__init__.py").touch()
74
+ (pkg / "__init__.py").touch()
75
+ for f in ("mel_band_roformer.py", "attend.py"):
76
+ fetch(REPO + "models/bs_roformer/" + f, pkg / f)
77
+ sys.path.insert(0, str(src / "roformer_code"))
78
+ sys.dont_write_bytecode = True
79
+ from models.bs_roformer.mel_band_roformer import MelBandRoformer
80
+
81
+ class Loader(yaml.SafeLoader): pass # the config's resolutions are a !!python/tuple
82
+ Loader.add_constructor("tag:yaml.org,2002:python/tuple", lambda l, n: tuple(l.construct_sequence(n)))
83
+ url, sha, config = MODELS[name]
84
+ cfg = yaml.load(fetch(REPO + config, src / Path(config).name).read_text(), Loader=Loader)
85
+ ckpt = fetch(url, src / Path(url).name)
86
+ h = hashlib.sha256()
87
+ with open(ckpt, "rb") as f:
88
+ for b in iter(lambda: f.read(1 << 24), b""):
89
+ h.update(b)
90
+ if h.hexdigest() != sha:
91
+ raise SystemExit(f"{ckpt}: SHA-256 {h.hexdigest()}, expected {sha}")
92
+ model = MelBandRoformer(**cfg["model"])
93
+ state = torch.load(ckpt, map_location="cpu", weights_only=True)
94
+ model.load_state_dict(state.get("state_dict", state) if isinstance(state, dict) else state)
95
+ return model.eval(), cfg
96
+
97
+
98
+ def stft(model, x):
99
+ """The model's STFT: (B, C, L) -> (B, 2C, F, T), complex as channels."""
100
+ import torch
101
+
102
+ B, C, L = x.shape
103
+ z = torch.view_as_real(torch.stft(x.reshape(-1, L), **model.stft_kwargs, window=model.stft_window_fn(), return_complex=True))
104
+ return z.permute(0, 3, 1, 2).reshape(B, 2 * C, z.shape[1], z.shape[2])
105
+
106
+
107
+ def istft(model, y, length):
108
+ """(B, 2·S·C, F, T) -> (B, S, C, L)"""
109
+ import torch
110
+
111
+ B, _, Fr, T = y.shape
112
+ y = torch.view_as_complex(y.reshape(-1, 2, Fr, T).permute(0, 2, 3, 1).contiguous())
113
+ x = torch.istft(y, **model.stft_kwargs, window=model.stft_window_fn(), length=length)
114
+ return x.reshape(B, -1, model.audio_channels, length)
115
+
116
+
117
+ def groups(widths, slack=1.3):
118
+ """Contiguous runs of bands whose widest is at most `slack` times their narrowest: [(first, last + 1, widest)]"""
119
+ out, b0 = [], 0
120
+ for b in range(1, len(widths) + 1):
121
+ if b == len(widths) or max(widths[b0:b + 1]) > slack * min(widths[b0:b + 1]):
122
+ out.append((b0, b, max(widths[b0:b])))
123
+ b0 = b
124
+ return out
125
+
126
+
127
+ class Core:
128
+ """MelBandRoformer.forward between its STFT and its iSTFT, for the GPU (the module docstring says how)."""
129
+
130
+ @staticmethod
131
+ def build(model, T):
132
+ import torch
133
+ import torch.nn as nn
134
+ import torch.nn.functional as F
135
+
136
+ f64 = torch.float64
137
+ Fq = model.num_bands_per_freq.numel() # 1025
138
+ S = model.audio_channels
139
+ fi = model.freq_indices.tolist() # (f·S + s) per band's pair, bands in order
140
+ nf = model.num_freqs_per_band.tolist()
141
+ widths = [2 * n * S for n in nf] # a band's input: its (f, s) pairs, re and im
142
+ G = groups(widths)
143
+ lin = torch.matmul
144
+ # every tensor under WebGPU's default storage binding (128 MiB, maxStorageBufferBindingSize): onnxruntime
145
+ # 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
146
+ # at 123 MB), so a transformer runs on as many sequences at a time as keep its largest, the attention's
147
+ # scores (heads × n × n) or its feed-forward's hidden layer (n × 4·dim), under 100 MB
148
+ BUDGET = 100e6
149
+
150
+ def sliced(f, x):
151
+ per = max(f.biggest(x.shape[1]), 1)
152
+ size = max(1, int(BUDGET // per))
153
+ 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)
154
+
155
+ rot = lambda n, emb: emb(emb.get_seq_pos(n, device="cpu", dtype=torch.float32)) # (n, dh) interleaved
156
+
157
+ def rms(m):
158
+ return (m.gamma.detach().double() * m.scale)
159
+
160
+ class Mod(nn.Module):
161
+ def __init__(s):
162
+ super().__init__()
163
+ # the STFT as columns: E[t, (f·S + s)·2 + c], and a zero column past them (index Fq·S·2)
164
+ pos = [] # each band's input columns, in its order
165
+ o = 0
166
+ for n in nf:
167
+ pos.append([fi[k] * 2 + c for k in range(o, o + n * S) for c in range(2)])
168
+ o += n * S
169
+ zero = Fq * S * 2
170
+ bs = model.band_split.to_features
171
+ s.split_idx, s.split_w, s.split_b = [], nn.ParameterList(), nn.ParameterList()
172
+ for b0, b1, W in G:
173
+ idx = torch.full((b1 - b0, W), zero, dtype=torch.long)
174
+ w = torch.zeros(b1 - b0, W, bs[0][1].out_features, dtype=f64)
175
+ bias = torch.zeros(b1 - b0, 1, w.shape[2], dtype=f64)
176
+ for j, b in enumerate(range(b0, b1)):
177
+ norm, lin = bs[b][0], bs[b][1]
178
+ idx[j, :widths[b]] = torch.tensor(pos[b])
179
+ w[j, :widths[b]] = (lin.weight.detach().double() * rms(norm)[None]).T
180
+ bias[j, 0] = lin.bias.detach().double()
181
+ s.register_buffer(f"si{len(s.split_idx)}", idx)
182
+ s.split_idx.append(f"si{len(s.split_idx)}")
183
+ s.split_w.append(nn.Parameter(w.float(), requires_grad=False))
184
+ s.split_b.append(nn.Parameter(bias.float(), requires_grad=False))
185
+ # transformers: time then frequency, per depth
186
+ s.blocks = nn.ModuleList()
187
+ for time, freq in model.layers:
188
+ s.blocks.append(nn.ModuleList([Block(time, T), Block(freq, len(nf))]))
189
+ # mask estimator (one stem): its MLP's linear layers before the last batched over bands (their shapes
190
+ # alike), the last by group, then GLU
191
+ me = model.mask_estimators[0].to_freqs
192
+ lin = [[m for m in me[b][0] if isinstance(m, nn.Linear)] for b in range(len(nf))]
193
+ 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)])
194
+ 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)])
195
+ s.m2_w, s.m2_b = nn.ParameterList(), nn.ParameterList()
196
+ col, cols = 0, {}
197
+ for b0, b1, W in G:
198
+ H = lin[0][-1].in_features
199
+ w = torch.zeros(b1 - b0, H, 2 * W)
200
+ bias = torch.zeros(b1 - b0, 1, 2 * W)
201
+ for j, b in enumerate(range(b0, b1)):
202
+ l2 = lin[b][-1]
203
+ n = widths[b]
204
+ w[j, :, :n], w[j, :, W:W + n] = l2.weight.detach()[:n].T, l2.weight.detach()[n:].T
205
+ bias[j, 0, :n], bias[j, 0, W:W + n] = l2.bias.detach()[:n], l2.bias.detach()[n:]
206
+ for e in range(n):
207
+ cols.setdefault(pos[b][e], []).append(col + j * W + e)
208
+ s.m2_w.append(nn.Parameter(w, requires_grad=False))
209
+ s.m2_b.append(nn.Parameter(bias, requires_grad=False))
210
+ col += (b1 - b0) * W
211
+ # each output column's sources among the masks (at most two bands overlap), the zero column for none,
212
+ # and its weight: 1 over the bands that cover it, 0 at DC
213
+ i1 = torch.full((zero,), col, dtype=torch.long)
214
+ i2 = torch.full((zero,), col, dtype=torch.long)
215
+ wt = torch.zeros(zero)
216
+ for o, src in cols.items():
217
+ assert len(src) <= 2, "three bands over one frequency"
218
+ i1[o] = src[0]
219
+ i2[o] = src[-1] if len(src) == 2 else col
220
+ wt[o] = 0 if o // (S * 2) == 0 and model.zero_dc else 1 / len(src)
221
+ s.register_buffer("i1", i1)
222
+ s.register_buffer("i2", i2)
223
+ s.register_buffer("wt", wt)
224
+ s.Fq, s.S = Fq, S
225
+
226
+ def forward(s, mix_spec):
227
+ Fq, S = s.Fq, s.S
228
+ T_ = mix_spec.shape[3]
229
+ # (1, 2S, F, T) -> E (T, F·S·2): [t, (f·S + s)·2 + c]
230
+ E = mix_spec.reshape(S, 2, Fq, T_).permute(3, 2, 0, 1).reshape(T_, Fq * S * 2)
231
+ Ez = torch.cat([E, torch.zeros(T_, 1, dtype=E.dtype)], 1)
232
+ xs = []
233
+ for k, name in enumerate(s.split_idx):
234
+ idx = getattr(s, name) # (nb, W)
235
+ x = Ez[:, idx].permute(1, 0, 2) # (nb, T, W)
236
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12)
237
+ xs.append(lin(x, s.split_w[k]) + s.split_b[k])
238
+ x = torch.cat(xs, 0) # (B, T, D)
239
+ for time, freq in s.blocks:
240
+ x = sliced(time, x) # sequences over time, one per band
241
+ x = sliced(freq, x.transpose(0, 1)).transpose(0, 1) # over bands, one per frame
242
+ ms = []
243
+ for k, (lo, hi, W) in enumerate(G):
244
+ h = x[lo:hi]
245
+ for w, b in zip(s.mw, s.mb):
246
+ h = torch.tanh(lin(h, w[lo:hi]) + b[lo:hi]) # (nb, T, H)
247
+ y = lin(h, s.m2_w[k]) + s.m2_b[k] # (nb, T, 2W)
248
+ m = y[..., :W] * torch.sigmoid(y[..., W:]) # GLU
249
+ ms.append(m.permute(1, 0, 2).reshape(T_, (hi - lo) * W))
250
+ M = torch.cat(ms + [torch.zeros(T_, 1)], 1) # (T, columns + 1)
251
+ mask = (M[:, s.i1] + M[:, s.i2]) * s.wt # (T, F·S·2)
252
+ e = E.reshape(T_, Fq, S, 2)
253
+ mk = mask.reshape(T_, Fq, S, 2)
254
+ er, ei, mr, mi = e[..., 0], e[..., 1], mk[..., 0], mk[..., 1]
255
+ out = torch.stack([er * mr - ei * mi, er * mi + ei * mr], -1) # (T, F, S, 2)
256
+ return out.permute(2, 3, 1, 0).reshape(1, 2 * S, Fq, T_)
257
+
258
+ class Block(nn.Module):
259
+ """One Transformer of the model (its layers and output norm) over sequences of n: x (batch, n, D)."""
260
+
261
+ def __init__(s, tr, n):
262
+ super().__init__()
263
+ s.layers = nn.ModuleList()
264
+ for attn, ff in tr.layers:
265
+ s.layers.append(nn.ModuleList([Attn(attn, n), FF(ff)]))
266
+ s.out = nn.Parameter(rms(tr.norm).float(), requires_grad=False)
267
+
268
+ def biggest(s, n):
269
+ """bytes of the largest tensor one sequence of n makes"""
270
+ a, ff = s.layers[0]
271
+ return 4 * max(a.h * n * n, n * ff.w1.shape[1], n * a.qkvg.shape[1])
272
+
273
+ def forward(s, x):
274
+ for attn, ff in s.layers:
275
+ x = attn(x) + x
276
+ x = ff(x) + x
277
+ return x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12) * s.out
278
+
279
+ class Attn(nn.Module):
280
+ def __init__(s, a, n):
281
+ super().__init__()
282
+ h, dh = a.heads, a.to_qkv.out_features // (3 * a.heads)
283
+ g = rms(a.norm)[None]
284
+ wq, wk, wv = (a.to_qkv.weight.detach().double() * g).reshape(3, h, dh, -1)
285
+ # interleaved pairs -> halves, the same permutation of q's and k's features
286
+ perm = torch.cat([torch.arange(0, dh, 2), torch.arange(1, dh, 2)])
287
+ wq, wk = wq[:, perm] * a.scale, wk[:, perm]
288
+ wg = a.to_gates.weight.detach().double() * g
289
+ 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)
290
+ s.gb = nn.Parameter(a.to_gates.bias.detach().float(), requires_grad=False)
291
+ s.wo = nn.Parameter(a.to_out[0].weight.detach().T.float(), requires_grad=False)
292
+ freqs = rot(n, a.rotary_embed).double()[:, perm] # (n, dh)
293
+ s.register_buffer("cos", freqs.cos().float())
294
+ s.register_buffer("sin", freqs.sin().float())
295
+ R = torch.zeros(dh, dh) # x @ R: (-x[half:], x[:half])
296
+ R[torch.arange(dh // 2) + dh // 2, torch.arange(dh // 2)] = -1
297
+ R[torch.arange(dh // 2), torch.arange(dh // 2) + dh // 2] = 1
298
+ s.register_buffer("R", R)
299
+ s.h, s.dh = h, dh
300
+
301
+ def forward(s, x):
302
+ Bt, n, _ = x.shape
303
+ h, dh = s.h, s.dh
304
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12)
305
+ y = lin(x, s.qkvg)
306
+ q, k, v = (y[..., i * h * dh:(i + 1) * h * dh].reshape(Bt, n, h, dh).transpose(1, 2) for i in range(3))
307
+ gates = torch.sigmoid(y[..., 3 * h * dh:] + s.gb) # (Bt, n, h)
308
+ q = q * s.cos + torch.matmul(q, s.R) * s.sin
309
+ k = k * s.cos + torch.matmul(k, s.R) * s.sin
310
+ o = torch.matmul(torch.softmax(torch.matmul(q, k.transpose(-1, -2)), -1), v) # (Bt, h, n, dh)
311
+ o = o * gates.transpose(1, 2)[..., None]
312
+ return lin(o.transpose(1, 2).reshape(Bt, n, h * dh), s.wo)
313
+
314
+ class FF(nn.Module):
315
+ def __init__(s, ff):
316
+ super().__init__()
317
+ norm, l1, _, _, l2, _ = ff.net
318
+ s.w1 = nn.Parameter((l1.weight.detach().double() * rms(norm)[None]).T.float(), requires_grad=False)
319
+ s.b1 = nn.Parameter(l1.bias.detach().float(), requires_grad=False)
320
+ s.w2 = nn.Parameter(l2.weight.detach().T.float(), requires_grad=False)
321
+ s.b2 = nn.Parameter(l2.bias.detach().float(), requires_grad=False)
322
+
323
+ def forward(s, x):
324
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12)
325
+ return lin(F.gelu(lin(x, s.w1) + s.b1), s.w2) + s.b2
326
+
327
+ return Mod().eval()
328
+
329
+
330
+ def frames(model):
331
+ import torch
332
+
333
+ return stft(model, torch.zeros(1, model.audio_channels, SEGMENT)).shape[3]
334
+
335
+
336
+ def export(model, path, opset):
337
+ import torch
338
+
339
+ T = frames(model)
340
+ core = Core.build(model, T)
341
+ spec = stft(model, torch.randn(1, model.audio_channels, SEGMENT))
342
+ with torch.no_grad():
343
+ torch.onnx.export(core, (spec,), str(path), input_names=["mix_spec"], output_names=["stems_spec"],
344
+ opset_version=opset, dynamo=False)
345
+ 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 model.num_freqs_per_band.tolist()])}")
346
+ return core
347
+
348
+
349
+ def engine(model, out_dir, name):
350
+ """The GPU form's tensors for the package's own WebGPU engine (roformer.js): <name>.engine.bin, each tensor in turn
351
+ (weights float16, tables float32, indices int32), and <name>.engine.json naming them, their type, shape and offset,
352
+ with the band groups and the sizes the engine reads."""
353
+ import json
354
+ import numpy as np
355
+ import torch
356
+
357
+ T = frames(model)
358
+ core = Core.build(model, T)
359
+ tensors, blob, off = {}, bytearray(), 0
360
+ for n, t in list(core.named_parameters()) + list(core.named_buffers()):
361
+ a = t.detach().numpy()
362
+ 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"
363
+ b = a.astype(kind).tobytes()
364
+ pad = -len(blob) % 16
365
+ blob += bytes(pad)
366
+ tensors[n] = {"type": kind, "shape": list(a.shape), "offset": len(blob)}
367
+ blob += b
368
+ nf = model.num_freqs_per_band.tolist()
369
+ meta = {
370
+ "model": name, "frames": T, "freqs": core.Fq, "channels": core.S, "dim": model.layers[0][0].layers[0][0].to_qkv.in_features,
371
+ "heads": model.layers[0][0].layers[0][0].heads, "depth": len(model.layers), "bands": len(nf),
372
+ "groups": [list(g) for g in groups([2 * n * core.S for n in nf])], "widths": [2 * n * core.S for n in nf],
373
+ "tensors": tensors,
374
+ }
375
+ (out_dir / f"{name}.engine.bin").write_bytes(bytes(blob))
376
+ (out_dir / f"{name}.engine.json").write_text(json.dumps(meta))
377
+ print(f"wrote {out_dir / (name + '.engine.bin')} ({len(blob) / 1e6:.1f} MB) and its .json, {len(tensors)} tensors")
378
+
379
+
380
+ def fp16(path, out):
381
+ """float16 weights, float32 compute (scripts/compact.py --as fp16)."""
382
+ sys.path.insert(0, str(Path(__file__).parent))
383
+ import compact
384
+
385
+ compact.compact(path, out, "fp16")
386
+ print(f"wrote {out} ({out.stat().st_size / 1e6:.1f} MB)")
387
+
388
+
389
+ def session(path):
390
+ import onnxruntime as ort
391
+
392
+ o = ort.SessionOptions()
393
+ o.enable_cpu_mem_arena = False
394
+ o.enable_mem_pattern = False
395
+ return ort.InferenceSession(str(path), o, providers=["CPUExecutionProvider"])
396
+
397
+
398
+ def mixes():
399
+ import torch
400
+
401
+ t = torch.arange(SEGMENT) / 44100
402
+ tone = sum(torch.sin(2 * math.pi * f * t) / k for k, f in enumerate((110, 220, 440, 880, 1760), 1))
403
+ return (("noise", 0.1 * torch.randn(1, 2, SEGMENT)), ("tones", 0.2 * torch.stack([tone, 0.8 * tone])[None]))
404
+
405
+
406
+ def verify(model, run, label, tol):
407
+ """The vocals rebuilt from run(spec) against MelBandRoformer.forward: noise and tones."""
408
+ import numpy as np
409
+ import torch
410
+
411
+ for name, mix in mixes():
412
+ with torch.no_grad():
413
+ want = model(mix).numpy()
414
+ got = istft(model, torch.as_tensor(run(stft(model, mix))), SEGMENT)[:, 0].numpy()
415
+ want = want[..., :got.shape[-1]]
416
+ rel = np.abs(got - want).max() / max(np.abs(want).max(), 1e-12)
417
+ print(f" verify {label} {name}: max|diff|/max|y| = {rel:.2e} " + ("OK" if rel < tol else "FAIL"))
418
+ if rel >= tol:
419
+ raise SystemExit(f"{label}: verification failed ({rel:.2e} >= {tol})")
420
+
421
+
422
+ def reference(model, out_dir):
423
+ """The reference separation: MelBandRoformer.forward on segments as scripts/reference.py's chunked() cuts them
424
+ (step 0.5, no standardization: Kim's config normalizes nothing), over a 20 s mix with a noise floor."""
425
+ import torch
426
+
427
+ sys.path.insert(0, str(Path(__file__).parent))
428
+ import reference as ref
429
+
430
+ def run(seg):
431
+ with torch.no_grad():
432
+ return model(torch.from_numpy(seg)[None])[0][None].numpy() # (1 stem, C, L)
433
+
434
+ # under the mix a noise floor 60 dB down: each band normalizes its input, and a silent band's is its rounding
435
+ ref.write(out_dir, lambda mix: {"vocals": ref.chunked(mix, run, SEGMENT, STEP)[0]}, 20.0, 1e-3)
436
+
437
+
438
+ def main():
439
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
440
+ ap.add_argument("--model", default="mel-roformer", choices=list(MODELS))
441
+ ap.add_argument("--out-dir", default=None, help="default: $AUDIO_NEURAL_CACHE/<model> or ~/.cache/audiojs/neural/<model>")
442
+ ap.add_argument("--opset", type=int, default=17)
443
+ ap.add_argument("--fp16", action="store_true", help="also write <model>.fp16.onnx (float16 weights, float32 compute)")
444
+ ap.add_argument("--verify", action="store_true", help="compare with MelBandRoformer.forward; write the reference separation")
445
+ ap.add_argument("--engine", action="store_true", help="also write <model>.engine.bin/.json: the tensors for roformer.js, the package's WebGPU engine")
446
+ args = ap.parse_args()
447
+
448
+ cache = Path(os.environ.get("AUDIO_NEURAL_CACHE") or Path.home() / ".cache" / "audiojs" / "neural")
449
+ out_dir = Path(args.out_dir) if args.out_dir else cache / args.model
450
+ out_dir.mkdir(parents=True, exist_ok=True)
451
+ src = out_dir / "src"
452
+ src.mkdir(exist_ok=True)
453
+ try:
454
+ model, _ = load(src, args.model)
455
+ except ImportError as e:
456
+ raise SystemExit(f"required: pip install torch onnx onnxruntime pyyaml einops beartype rotary-embedding-torch librosa ({e})")
457
+ path = out_dir / f"{args.model}.onnx"
458
+ core = export(model, path, args.opset)
459
+ if args.verify:
460
+ import torch
461
+
462
+ with torch.no_grad():
463
+ verify(model, lambda spec: core(spec), "the GPU form in torch", 1e-4)
464
+ # onnxruntime against torch: on the tones 1.7e-4, where torch's own float32 stands 4.6e-3 from its float64
465
+ s = session(path)
466
+ verify(model, lambda spec: s.run(None, {"mix_spec": spec.numpy()})[0], path.name, 1e-3)
467
+ del s
468
+ gc.collect()
469
+ reference(model, out_dir)
470
+ if args.fp16:
471
+ fp16(path, out_dir / f"{args.model}.fp16.onnx")
472
+ if args.verify:
473
+ s = session(out_dir / f"{args.model}.fp16.onnx")
474
+ verify(model, lambda spec: s.run(None, {"mix_spec": spec.numpy()})[0], f"{args.model}.fp16.onnx", 2e-2)
475
+ if args.engine:
476
+ engine(model, out_dir, args.model)
477
+ print(f"done: {args.model} in {out_dir}")
478
+
479
+
480
+ if __name__ == "__main__":
481
+ 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,15 @@ 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
+
374
383
  // The compact files (scripts/compact.py: int8 weights, a few float16, float32 compute), each within 0.05 dB of its
375
384
  // export's median SDR or SNR per stem (README, Size); their Hugging Face repositories, and SHA-256 checked when fetched
376
385
  const FILES = {
@@ -378,6 +387,8 @@ const FILES = {
378
387
  scnet: { file: 'scnet.int8.onnx', repo: 'audiojs/scnet', sha256: '98228931494151762a1c4ab1ec7899a894b1f81fd4a509921a9d2f9bacc50845' },
379
388
  mrx: { file: 'mrx.int8.onnx', repo: 'audiojs/mrx', sha256: 'd876c92d224f5d8cd2207d528b23369da895a6de71fb24132416fcb16f9f8424' },
380
389
  tiger: { file: 'tiger.int8.onnx', repo: 'audiojs/tiger-dnr', sha256: '2189859ac7e89cf4d9d284c6f393e737aaa2b3233ed7ee424ad622012bce7bca' },
390
+ // float16 weights, float32 compute: half the download; int8 (259 MB) ran slower on WebGPU and 10 dB further from float32
391
+ 'mel-roformer': { file: 'mel-roformer.fp16.onnx', repo: 'audiojs/mel-roformer', sha256: 'adcbbb826f12453fbb86652b503242d565a9c81cdef1bca2fe0e54752eba91ef' },
381
392
  }
382
393
 
383
394
  // The hosted revision of each compact file's repository (a commit), its URL https://huggingface.co/<repo>/resolve/<revision>/
@@ -388,6 +399,7 @@ export const REVISIONS = {
388
399
  scnet: 'e71bcdb42bce1d62b7fae25ba90fcbcf90f73f46',
389
400
  mrx: 'dedc823a0bea2ed085d19e8b7f32589d35169ee8',
390
401
  tiger: '1c959ab7ff8794dc5663b104ba301687ab95d2ab',
402
+ 'mel-roformer': 'c1aba02c00a0bee852d6754fe6ede8164f90a4b3',
391
403
  }
392
404
 
393
405
  const hosted = name => models[name].repo && REVISIONS[name] ? `https://huggingface.co/${models[name].repo}/resolve/${REVISIONS[name]}/${models[name].file}` : null
@@ -406,6 +418,8 @@ export const models = {
406
418
  mrx: { ...MRX, ...FILES.mrx },
407
419
  // TIGER (JusperLee/TIGER, Apache-2.0 weights): the same three stems, three band-split models, about 50 times MRX's time
408
420
  tiger: { ...TIGER, ...FILES.tiger },
421
+ // Mel-Band RoFormer (Kimberley Jensen's vocals model, MIT weights) as scripts/export-roformer.py exports it for the GPU
422
+ 'mel-roformer': { ...MEL, ...FILES['mel-roformer'] },
409
423
  }
410
424
 
411
425
  async function cacheDir() {
@@ -452,6 +466,38 @@ async function local(graphs, name, opts) {
452
466
  `, or pass opts.weights: a URL or directory holding ${name}/<file>.onnx`)
453
467
  }
454
468
 
469
+ // A preset's own WebGPU engine (roformer.js) as a session, its tensors from `weights` or the hosted revision, or null
470
+ // where the GPU has no subgroup matrices (onnxruntime then): in the browser. Kept after a separation for KEEP ms, so the
471
+ // 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
472
+ const KEEP = 60_000
473
+ let kept = null
474
+ function engineSession(opts, p) {
475
+ let name = opts.model, key = `${name}:${opts.weights ?? ''}:${p.engine.sha256}`
476
+ if (kept?.key === key) { clearTimeout(kept.timer); return kept.session }
477
+ kept?.session.then(s => s?.drop())
478
+ let mine = { key, timer: null }
479
+ // free() after a separation lets go KEEP ms later, unless the next one has taken it
480
+ mine.session = makeEngine(opts, p, name).then(s => s && {
481
+ ...s, drop: s.free,
482
+ free() { clearTimeout(mine.timer); mine.timer = setTimeout(() => { if (kept === mine) kept = null; s.free() }, KEEP) },
483
+ })
484
+ return (kept = mine).session
485
+ }
486
+ async function makeEngine(opts, p, name) {
487
+ let { engine, gpu } = await import('./roformer.js'), device = await gpu()
488
+ if (!device) return null
489
+ try {
490
+ let base = opts.weights != null ? `${await weightsBase(opts, name)}${name}/` : `https://huggingface.co/${p.repo}/resolve/${REVISIONS[name]}/`
491
+ let [meta, bytes] = await Promise.all([fetchJson(base + p.engine.meta), checked(base + p.engine.bin, p.engine.sha256)])
492
+ return await engine(meta, bytes, device)
493
+ } catch (e) {
494
+ // its files not there (weights without them), or the GPU refusing its buffers: onnxruntime, as without it
495
+ device.destroy()
496
+ console.warn(`neural-separate: ${name}'s own engine stands aside (${e.message})`)
497
+ return null
498
+ }
499
+ }
500
+
455
501
  // The hosted compact file into the cache, checked against its SHA-256, written whole or not at all → its file URL
456
502
  async function fetched(name, dest) {
457
503
  let [fs, path, { pathToFileURL }] = await Promise.all([import('node:fs'), import('node:path'), import('node:url')])
@@ -503,12 +549,18 @@ async function resolveModel(opts) {
503
549
  // No pooled arena: one htdemucs segment peaks at 2.7 GB RSS in onnxruntime-node instead of 3.2 GB.
504
550
  const sessionOptions = { enableCpuMemArena: false, enableMemPattern: false }
505
551
 
506
- // A graph's session; a fetched file with a SHA-256 checked against it first
507
- function loader(opts) {
552
+ // A graph's session; a fetched file with a SHA-256 checked against it first. A preset written for the GPU (`gpu`) runs
553
+ // on WebGPU where there is one (onnxruntime-web's in a browser, onnxruntime-node's in Node) unless `device` says
554
+ // otherwise: SCNet's LSTMs and the others' many small operators run no faster there
555
+ function loader(opts, preset) {
556
+ let device = opts.device ?? (preset?.gpu ? backends().then(b => b.includes('webgpu') ? 'webgpu' : undefined) : undefined)
557
+ let own = !opts.device && !opts.session && preset?.engine && !isNode ? engineSession(opts, preset) : null
508
558
  return async g => {
509
559
  if (opts.session) return opts.session(g.spec, opts)
560
+ let s = own && await own
561
+ if (s) return s
510
562
  let spec = g.sha256 && !g.spec.startsWith('file:') ? await checked(g.spec, g.sha256) : g.spec
511
- return neuralLoad(spec, { backend: opts.device, sessionOptions })
563
+ return neuralLoad(spec, { backend: await device, sessionOptions })
512
564
  }
513
565
  }
514
566
 
@@ -959,7 +1011,7 @@ export default async function separate(audio, opts = {}) {
959
1011
  if (!channelData[0].length) return { stems: Object.fromEntries(targets.map(t => [t, empty()])), sampleRate: rate, residual: empty() }
960
1012
  let targetRate = opts.targetRate ?? modelRate ?? rate
961
1013
  let proc = targetRate !== rate ? channelData.map(c => resampleSinc(c, { from: rate, to: targetRate })) : channelData
962
- let load = loader(opts)
1014
+ let load = loader(opts, preset)
963
1015
  // the input at the loudness the model trained at, its stems scaled back (a silent input as it is)
964
1016
  let level = preset?.loudness == null ? -Infinity : lufs(proc, targetRate), gain = Number.isFinite(level) ? 10 ** ((preset.loudness - level) / 20) : 1
965
1017
  if (gain !== 1) proc = proc.map(c => c.map(v => v * gain))