@audio/neural-timbre 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,180 @@
1
+ // Augmentation for training views and benchmark queries, all in audiojs: chorus, EQ, reverb,
2
+ // another voice or drums underneath, noise, compression, gain and a low-bitrate codec, applied in
3
+ // that order to a mono 44.1 kHz note. Every choice is drawn from a seeded generator, so a view or a
4
+ // benchmark query is reproducible from its seed.
5
+ import chorus from '@audio/effect-chorus'
6
+ import eq from '@audio/eq-parametric'
7
+ import freeverb from '@audio/reverb-freeverb'
8
+ import dattorro from '@audio/reverb-dattorro'
9
+ import compressor from '@audio/dynamics-compressor'
10
+ import encodeMp3 from '@audio/encode-mp3'
11
+ import decodeMp3 from '@audio/decode-mp3'
12
+ import encodeOpus from '@audio/encode-opus'
13
+ import decodeOpus from '@audio/decode-opus'
14
+ import { membrane, metal, noiseDrum } from '@audio/synth-drum'
15
+ import resample from '@audio/resample-sinc'
16
+
17
+ export const FS = 44100
18
+
19
+ // mulberry32 (Tommy Ettinger): 32-bit state, full period, adequate for choosing augmentations
20
+ export function rng(seed) {
21
+ let a = seed >>> 0
22
+ let next = () => {
23
+ a = (a + 0x6d2b79f5) >>> 0
24
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
25
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
26
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
27
+ }
28
+ next.uniform = (lo, hi) => lo + (hi - lo) * next()
29
+ next.int = (lo, hi) => lo + Math.floor(next() * (hi - lo + 1)) // inclusive
30
+ next.pick = list => list[Math.floor(next() * list.length)]
31
+ next.log = (lo, hi) => lo * (hi / lo) ** next()
32
+ return next
33
+ }
34
+
35
+ // A training chain: a third of views stay clean, the rest take each stage by its own chance.
36
+ // `other` draws the note under a mix: { voice, note, velocity, hold } of another voice.
37
+ export function chain(r, other) {
38
+ if (r() < 1 / 3) return {}
39
+ let c = {}
40
+ if (r() < 0.25) c.chorus = { rate: r.uniform(0.2, 2), depth: r.uniform(0.1, 0.6), delay: r.uniform(0.008, 0.03), voices: r.int(2, 4) }
41
+ if (r() < 0.45) c.eq = eqBands(r)
42
+ if (r() < 0.35) c.reverb = r() < 0.75
43
+ ? { kind: 'freeverb', room: r.uniform(0.3, 0.95), damp: r.uniform(0.1, 0.7), mix: r.uniform(0.1, 0.5) }
44
+ : { kind: 'dattorro', decay: r.uniform(0.3, 0.9), damping: r.uniform(0.1, 0.7), mix: r.uniform(0.1, 0.4) }
45
+ if (r() < 0.25) c.mix = r() < 0.7 ? { ...other(r), snr: r.uniform(0, 20) } : { drums: r.int(0, 2 ** 31), snr: r.uniform(0, 20) }
46
+ if (r() < 0.25) c.noise = { color: r.pick(['white', 'pink']), snr: r.uniform(5, 35), seed: r.int(0, 2 ** 31) }
47
+ if (r() < 0.25) c.compressor = { threshold: r.uniform(-36, -10), ratio: r.uniform(2, 10), attack: r.uniform(1, 30), release: r.uniform(50, 300) }
48
+ c.gain = r.uniform(-24, 0)
49
+ if (r() < 0.15) c.codec = r() < 0.7 ? { kind: 'mp3', bitrate: r.pick([32, 48, 64]) } : { kind: 'opus', bitrate: r.pick([12, 16, 24]) }
50
+ return c
51
+ }
52
+
53
+ const eqBands = r => [
54
+ { type: 'lowshelf', fc: r.uniform(80, 300), gain: r.uniform(-9, 9) },
55
+ { fc: r.log(300, 5000), Q: r.uniform(0.5, 2), gain: r.uniform(-9, 9) },
56
+ { type: 'highshelf', fc: r.uniform(3000, 8000), gain: r.uniform(-12, 6) },
57
+ ]
58
+
59
+ // Benchmark conditions, one stage each at a fixed setting, plus the training chain drawn from the
60
+ // query's own seed. Frozen with the benchmark: changing one changes every figure that cites it.
61
+ export const CONDITIONS = {
62
+ clean: () => ({}),
63
+ reverb: () => ({ reverb: { kind: 'freeverb', room: 0.8, damp: 0.4, mix: 0.35 } }),
64
+ eq: () => ({ eq: [{ type: 'lowshelf', fc: 150, gain: 6 }, { fc: 1500, Q: 1, gain: -6 }, { type: 'highshelf', fc: 5000, gain: -9 }] }),
65
+ compressor: () => ({ compressor: { threshold: -30, ratio: 8, attack: 5, release: 100 } }),
66
+ chorus: () => ({ chorus: { rate: 0.8, depth: 0.4, delay: 0.015, voices: 3 } }),
67
+ gain: () => ({ gain: -30 }),
68
+ mp3: () => ({ codec: { kind: 'mp3', bitrate: 32 } }),
69
+ opus: () => ({ codec: { kind: 'opus', bitrate: 12 } }),
70
+ noise20: r => ({ noise: { color: 'pink', snr: 20, seed: r.int(0, 2 ** 31) } }),
71
+ noise10: r => ({ noise: { color: 'pink', snr: 10, seed: r.int(0, 2 ** 31) } }),
72
+ mix10: (r, other) => ({ mix: { ...other(r), snr: 10 } }),
73
+ mix0: (r, other) => ({ mix: { ...other(r), snr: 0 } }),
74
+ drums10: r => ({ mix: { drums: r.int(0, 2 ** 31), snr: 10 } }),
75
+ chain: (r, other) => { let c = {}; while (!Object.keys(c).length || (Object.keys(c).length === 1 && 'gain' in c)) c = chain(r, other); return c },
76
+ }
77
+
78
+ const rms = x => { let s = 0; for (let i = 0; i < x.length; i++) s += x[i] * x[i]; return Math.sqrt(s / Math.max(1, x.length)) }
79
+ const db2lin = db => 10 ** (db / 20)
80
+
81
+ // Mix `y` into `x` (in place) at `snr` dB below x's RMS over x's length.
82
+ function addAt(x, y, snr) {
83
+ let gx = rms(x), gy = rms(y)
84
+ if (!(gy > 0) || !(gx > 0)) return x
85
+ let k = gx / gy * db2lin(-snr)
86
+ for (let i = 0; i < x.length && i < y.length; i++) x[i] += k * y[i]
87
+ return x
88
+ }
89
+
90
+ function noise({ color, seed }, n) {
91
+ let r = rng(seed), out = new Float32Array(n)
92
+ // pink: Paul Kellet's refined filter of white noise (musicdsp.org #76), -3 dB/octave within 0.05 dB above 9 Hz at 44.1 kHz
93
+ let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0
94
+ for (let i = 0; i < n; i++) {
95
+ let w = r() * 2 - 1
96
+ if (color === 'white') { out[i] = w; continue }
97
+ b0 = 0.99886 * b0 + w * 0.0555179; b1 = 0.99332 * b1 + w * 0.0750759; b2 = 0.969 * b2 + w * 0.153852
98
+ b3 = 0.8665 * b3 + w * 0.3104856; b4 = 0.55 * b4 + w * 0.5329522; b5 = -0.7616 * b5 - w * 0.016898
99
+ out[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362; b6 = w * 0.115926
100
+ }
101
+ return out
102
+ }
103
+
104
+ // A bar of drums at 120 BPM from a seed: kick, snare or hat and cymbal hits on eighth notes.
105
+ function drums(seed, n) {
106
+ let r = rng(seed), out = new Float32Array(n), step = Math.round(FS * 0.25)
107
+ for (let at = Math.round(r() * step); at < n; at += step) {
108
+ let hit = r.pick(['kick', 'kick', 'snare', 'hat', 'cymbal', 'rest'])
109
+ if (hit === 'rest') continue
110
+ let s = hit === 'kick' ? membrane(r.uniform(45, 70), { duration: 0.4, fs: FS })
111
+ : hit === 'cymbal' ? metal(r.uniform(180, 260), { duration: 0.6, fs: FS, amp: 0.3 })
112
+ : noiseDrum({ duration: hit === 'hat' ? 0.06 : 0.2, fs: FS, seed: r.int(1, 1e6), amp: hit === 'hat' ? 0.3 : 0.7 })
113
+ for (let i = 0; i < s.length && at + i < n; i++) out[at + i] += s[i]
114
+ }
115
+ return out
116
+ }
117
+
118
+ const concat = parts => { let out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)), o = 0; for (let p of parts) { out.set(p, o); o += p.length } return out }
119
+
120
+ // Halfband lowpass for 2x rate changes: 63-tap Blackman-windowed sinc cut at a quarter of the
121
+ // rate (Oppenheim & Schafer, window method), every other tap but the centre zero.
122
+ const HALF = (() => {
123
+ let n = 63, m = 31, h = new Float64Array(n)
124
+ for (let i = 0; i < n; i++) {
125
+ let k = i - m, w = 0.42 - 0.5 * Math.cos(2 * Math.PI * i / (n - 1)) + 0.08 * Math.cos(4 * Math.PI * i / (n - 1))
126
+ h[i] = (k === 0 ? 0.5 : Math.sin(Math.PI * k / 2) / (Math.PI * k)) * w
127
+ }
128
+ return h
129
+ })()
130
+ function decimate2(x) {
131
+ let out = new Float32Array(x.length >> 1), m = HALF.length >> 1
132
+ for (let o = 0; o < out.length; o++) {
133
+ let c = 2 * o, s = HALF[m] * x[c]
134
+ for (let k = 1; k <= m; k += 2) s += HALF[m + k] * ((x[c - k] ?? 0) + (x[c + k] ?? 0))
135
+ out[o] = s
136
+ }
137
+ return out
138
+ }
139
+ function interpolate2(y, n) {
140
+ let out = new Float32Array(n), m = HALF.length >> 1
141
+ for (let i = 0; i < n; i++) {
142
+ if (i % 2 === 0) { out[i] = y[i >> 1] ?? 0; continue } // the centre tap is 0.5, doubled by the zero-stuffing gain
143
+ let s = 0
144
+ for (let k = 1; k <= m; k += 2) s += HALF[m + k] * ((y[(i - k) >> 1] ?? 0) + (y[(i + k) >> 1] ?? 0))
145
+ out[i] = 2 * s
146
+ }
147
+ return out
148
+ }
149
+
150
+ // MP3 runs as MPEG-2 layer III at 22.05 kHz, the rate LAME picks itself for these bitrates at
151
+ // 44.1 kHz input; Opus codes at 48 kHz and comes back through the resampler.
152
+ async function codec(x, { kind, bitrate }) {
153
+ let mp3 = kind === 'mp3', rate = mp3 ? FS / 2 : FS
154
+ let enc = mp3 ? await encodeMp3({ sampleRate: rate, channels: 1, bitrate }) : await encodeOpus({ sampleRate: rate, channels: 1, bitrate })
155
+ let bytes = concat([enc.encode([mp3 ? decimate2(x) : x]), enc.flush()]); enc.free?.()
156
+ let { channelData, sampleRate } = mp3 ? await decodeMp3(bytes) : await decodeOpus(bytes)
157
+ let y = channelData[0]
158
+ if (sampleRate === FS / 2) return interpolate2(y, 2 * y.length)
159
+ return sampleRate === FS ? y : resample(y, { from: sampleRate, to: FS })
160
+ }
161
+
162
+ // apply(x, spec, render) → Float32Array: x is the dry note (not modified); render(mix) returns
163
+ // the other voice's note for a mix stage.
164
+ export async function apply(x, spec, render) {
165
+ let y = Float32Array.from(x)
166
+ let peak = 0
167
+ for (let i = 0; i < y.length; i++) peak = Math.max(peak, Math.abs(y[i]))
168
+ if (peak > 0) for (let i = 0; i < y.length; i++) y[i] *= 0.5 / peak // a nominal level before level-dependent stages
169
+ if (spec.chorus) y = chorus(y, { ...spec.chorus, fs: FS })
170
+ if (spec.eq) y = Float32Array.from(eq(Float64Array.from(y), { bands: spec.eq.map(b => ({ ...b })), fs: FS }))
171
+ if (spec.reverb) y = spec.reverb.kind === 'dattorro'
172
+ ? dattorro(y, { decay: spec.reverb.decay, damping: spec.reverb.damping, mix: spec.reverb.mix, fs: FS })
173
+ : freeverb(y, { room: spec.reverb.room, damp: spec.reverb.damp, mix: spec.reverb.mix, fs: FS })
174
+ if (spec.mix) addAt(y, spec.mix.drums != null ? drums(spec.mix.drums, y.length) : render(spec.mix), spec.mix.snr)
175
+ if (spec.noise) addAt(y, noise(spec.noise, y.length), spec.noise.snr)
176
+ if (spec.compressor) y = compressor(y, { ...spec.compressor })
177
+ if (spec.gain) { let g = db2lin(spec.gain); for (let i = 0; i < y.length; i++) y[i] *= g }
178
+ if (spec.codec) y = await codec(y, spec.codec)
179
+ return y
180
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // The frozen benchmark (scripts/freeze.js), read through the shipped path: the exported encoder
3
+ // in @audio/neural-runtime and catalogs as scripts/catalogs.py writes them.
4
+ //
5
+ // node scripts/bench.js [--model file] [--catalogs dir] [--out file]
6
+ //
7
+ // Queries: every audible held-out voice, one note at a note and velocity no reference uses, under
8
+ // each condition of augment.js (features computed once by scripts/factory.js heldout, float16).
9
+ // Each is ranked against the held-out catalog (2,925 voices) and against every voice (dx7-all),
10
+ // with figures apart for novel voices (4 or more parameters from any training voice) and near
11
+ // copies; the algorithm head is scored on the same queries.
12
+ import { readFileSync, writeFileSync, openSync, readSync, closeSync, existsSync } from 'node:fs'
13
+ import path from 'node:path'
14
+ import os from 'node:os'
15
+ import { readCatalog, unit, sha256, featuresSha256 } from '../timbre.js'
16
+ import { load, tensor } from '@audio/neural-runtime'
17
+ import { pins } from './freeze.js'
18
+
19
+ const DATA = process.env.TIMBRE_DATA ?? path.join(os.homedir(), '.cache/audiojs/data/dx7')
20
+ const CACHE = path.join(process.env.AUDIO_NEURAL_CACHE ?? path.join(os.homedir(), '.cache/audiojs/neural'), 'timbre')
21
+
22
+ let o = {}
23
+ for (let a = process.argv.slice(2), i = 0; i < a.length; i += 2) o[a[i].replace(/^--/, '')] = a[i + 1]
24
+ let model = o.model ?? path.join(CACHE, 'encoder.int8.onnx'), dir = o.catalogs ?? path.join(CACHE, 'catalogs')
25
+
26
+ let frozen = JSON.parse(readFileSync(path.join(DATA, 'bench.json'), 'utf8')), now = pins()
27
+ for (let [k, v] of Object.entries(frozen.pins)) if (now[k] !== v) throw new Error(`bench: ${k} changed since the benchmark froze (${frozen.frozen})`)
28
+
29
+ let meta = JSON.parse(readFileSync(path.join(DATA, 'features/heldout.json'), 'utf8'))
30
+ let f = meta.features.cqt, size = f.bands * f.frames, rows = meta.rows.map(r => Object.fromEntries(meta.keys.map((k, i) => [k, r[i]])))
31
+ let stats = new Float32Array(readFileSync(path.join(DATA, 'features/heldout.stats.f32')).buffer)
32
+ let voices = JSON.parse(readFileSync(path.join(DATA, 'voices.json'), 'utf8')).voices
33
+ let fd = openSync(path.join(DATA, 'features/heldout.cqt.f16'), 'r')
34
+ let read = i => { let b = new Uint8Array(size * 2); readSync(fd, b, 0, b.length, i * size * 2); return Float32Array.from(new Float16Array(b.buffer)) }
35
+
36
+ let bytes = new Uint8Array(readFileSync(model)), binding = { encoderSha256: await sha256(bytes), featuresSha256: await featuresSha256() }
37
+ let session = await load(bytes)
38
+ // dx7-heldout is required; dx7-all joins when its references exist
39
+ let files = Object.fromEntries(['heldout', 'all'].filter(n => n === 'heldout' || existsSync(path.join(dir, `dx7-${n}.json`))).map(n => [n, JSON.parse(readFileSync(path.join(dir, `dx7-${n}.json`), 'utf8'))]))
40
+ let catalogs = Object.fromEntries(Object.entries(files).map(([n, json]) => [n, readCatalog(json, binding)]))
41
+ let position = Object.fromEntries(Object.entries(catalogs).map(([n, c]) => [n, new Map(c.entries.map((e, i) => [e.id, i]))]))
42
+
43
+ // rank of the true entry: entries scoring higher, ties broken by index as rank() does
44
+ function rankOf(e, { vectors, dimensions: d }, truth) {
45
+ let n = vectors.length / d, t = 0
46
+ for (let i = 0; i < d; i++) t += e[i] * vectors[truth * d + i]
47
+ let k = 0
48
+ for (let r = 0; r < n; r++) {
49
+ let v = 0
50
+ for (let i = 0; i < d; i++) v += e[i] * vectors[r * d + i]
51
+ if (v > t || (v === t && r < truth)) k++
52
+ }
53
+ return k
54
+ }
55
+
56
+ let queries = rows.map((r, i) => [r, i]).filter(([r, i]) => r.kind === 'query' && stats[2 * i] >= 2 ** -13)
57
+ let results = {}, t0 = performance.now(), B = 64
58
+ for (let b = 0; b < queries.length; b += B) {
59
+ let batch = queries.slice(b, b + B), x = new Float32Array(batch.length * size)
60
+ batch.forEach(([, i], k) => x.set(read(i), k * size))
61
+ let out = await session.run({ features: tensor(x, [batch.length, 1, f.bands, f.frames]) })
62
+ let e = out.embedding.data, d = e.length / batch.length, alg = out.algorithm.data
63
+ batch.forEach(([r], k) => {
64
+ let v = voices[r.voice], group = v.near >= 4 ? 'novel' : 'near', emb = unit(e.subarray(k * d, (k + 1) * d))
65
+ let a = Array.from(alg.subarray(k * 32, k * 32 + 32)), guess = a.indexOf(Math.max(...a)) + 1
66
+ for (let [n, c] of Object.entries(catalogs)) {
67
+ let rank = rankOf(emb, c, position[n].get(v.id))
68
+ for (let g of [group, 'all']) {
69
+ let s = results[`${n}|${r.cond}|${g}`] ??= { n: 0, top1: 0, top5: 0, algorithm: 0 }
70
+ s.n++; s.top1 += rank < 1; s.top5 += rank < 5; s.algorithm += guess === v.algorithm
71
+ }
72
+ }
73
+ })
74
+ if ((b / B) % 100 === 0) console.log(`${b + batch.length}/${queries.length} queries, ${((performance.now() - t0) / 1000).toFixed(0)} s`)
75
+ }
76
+ closeSync(fd); session.free()
77
+
78
+ let table = Object.entries(results).map(([key, s]) => { let [catalog, cond, group] = key.split('|'); return { catalog, cond, group, n: s.n, top1: s.top1 / s.n, top5: s.top5 / s.n, algorithm: s.algorithm / s.n } })
79
+ let out = o.out ?? path.join(CACHE, 'bench.json')
80
+ writeFileSync(out, JSON.stringify({ model: path.basename(model), encoderSha256: binding.encoderSha256, frozen: frozen.frozen,
81
+ catalogs: Object.fromEntries(Object.entries(files).map(([n, json]) => [n, { voices: json.entries.length, encoding: json.vectors.encoding }])),
82
+ seconds: Math.round((performance.now() - t0) / 1000), results: table }, null, 1))
83
+ let pct = x => (100 * x).toFixed(1)
84
+ console.log(`\n| condition | held-out top-1 | top-5 | novel top-1 | novel top-5 | all voices top-1 | all top-5 | algorithm |\n|---|---:|---:|---:|---:|---:|---:|---:|`)
85
+ for (let cond of frozen.conditions) {
86
+ let g = (c, grp) => table.find(t => t.catalog === c && t.cond === cond && t.group === grp)
87
+ let h = g('heldout', 'all'), nv = g('heldout', 'novel'), a = g('all', 'all'), p = x => x ? pct(x) : '-'
88
+ if (h) console.log(`| ${cond} | ${pct(h.top1)} | ${pct(h.top5)} | ${pct(nv.top1)} | ${pct(nv.top5)} | ${p(a?.top1)} | ${p(a?.top5)} | ${pct(h.algorithm)} |`)
89
+ }
90
+ console.log(`\n${out}`)
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // Build a DX7 catalog: every chosen voice rendered at the reference notes (scripts/recipe.js refs),
3
+ // each note embedded by the exported encoder through @audio/neural-runtime, the mean of the audible
4
+ // ones kept as the voice's vector (a note below 4 int16 steps, which some key scalings give, is
5
+ // skipped, as scripts/catalogs.py skips it). Names, cartridges and vectors only: no voice data
6
+ // leaves the sysex.
7
+ //
8
+ // node scripts/catalog.js <voices> [--model file] [--out file] [--encoding int8-base64|int4-base64]
9
+ //
10
+ // voices factory (Yamaha ROM1A-4B and VRC-101-112), heldout, dev, train or all (every audible voice)
11
+ // model default ~/.cache/audiojs/neural/timbre/encoder.int8.onnx
12
+ // out default ~/.cache/audiojs/neural/timbre/catalogs/dx7-<voices>.json
13
+ //
14
+ // Four worker threads render and prepare notes; the main thread runs the encoder in batches.
15
+ import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
16
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
17
+ import path from 'node:path'
18
+ import os from 'node:os'
19
+ import { cqt, onset } from '../features.js'
20
+ import { refs, note } from './recipe.js'
21
+
22
+ const DATA = process.env.TIMBRE_DATA ?? path.join(os.homedir(), '.cache/audiojs/data/dx7')
23
+ const CACHE = path.join(process.env.AUDIO_NEURAL_CACHE ?? path.join(os.homedir(), '.cache/audiojs/neural'), 'timbre')
24
+ const WORKERS = 4, CHUNK = 16
25
+
26
+ const SOURCES = { factory: v => v.source === 'yamaha-factory' || v.source === 'yamaha-vrc' }
27
+ const pick = which => SOURCES[which] ?? (which === 'all' ? () => true : v => v.split === which)
28
+
29
+ // 'factory/rom1a.syx' → 'ROM1A'; other cartridges keep their path under the source folder
30
+ const cartridge = file => file.startsWith('factory/') || file.startsWith('vrc/') ? path.basename(file, path.extname(file)).toUpperCase() : file.replace(/^[^/]+\//, '')
31
+
32
+ async function main() {
33
+ let [which, ...rest] = process.argv.slice(2), o = {}
34
+ for (let i = 0; i < rest.length; i += 2) o[rest[i].replace(/^--/, '')] = rest[i + 1]
35
+ let { createEncoder, catalog } = await import('../timbre.js')
36
+ let voices = JSON.parse(readFileSync(path.join(DATA, 'voices.json'), 'utf8')).voices
37
+ let chosen = voices.map((v, i) => [v, i]).filter(([v]) => !v.silent && pick(which)(v))
38
+ let encoder = await createEncoder({ model: o.model ?? path.join(CACHE, 'encoder.int8.onnx') })
39
+ let vectors = new Array(chosen.length), t0 = performance.now(), done = 0
40
+ let todo = Array.from({ length: Math.ceil(chosen.length / CHUNK) }, (_, c) => c)
41
+ let pending = Promise.resolve()
42
+ await Promise.all(Array.from({ length: Math.min(WORKERS, todo.length) }, () => new Promise((resolve, reject) => {
43
+ let w = new Worker(new URL(import.meta.url), { workerData: { data: DATA } })
44
+ let next = () => todo.length ? (c => w.postMessage({ c, voices: chosen.slice(c * CHUNK, (c + 1) * CHUNK).map(([, i]) => i) }))(todo.shift()) : w.postMessage(null)
45
+ w.on('message', ({ c, patches, owners }) => {
46
+ // each voice's audible reference notes → one mean vector; runs are serialized on the session
47
+ pending = pending.then(async () => {
48
+ let e = patches.length ? await encoder.project(patches) : []
49
+ e.forEach((v, j) => {
50
+ let k = c * CHUNK + owners[j], mean = vectors[k] ??= new Float32Array(v.length)
51
+ for (let d = 0; d < v.length; d++) mean[d] += v[d]
52
+ })
53
+ done += Math.min(CHUNK, chosen.length - c * CHUNK)
54
+ if (done % 1024 < CHUNK) console.log(`${done}/${chosen.length} voices, ${(done / ((performance.now() - t0) / 1000)).toFixed(1)}/s`)
55
+ })
56
+ next()
57
+ })
58
+ w.on('error', reject); w.on('exit', resolve); next()
59
+ })))
60
+ await pending
61
+ let { unit } = await import('../timbre.js')
62
+ let kept = chosen.map((_, k) => k).filter(k => vectors[k])
63
+ let entries = kept.map(k => chosen[k][0]).map(v => ({ name: v.name, cartridge: cartridge(v.cartridge), index: v.index + 1, source: v.source, algorithm: v.algorithm, id: v.id }))
64
+ let json = catalog(entries, kept.map(k => unit(vectors[k])), encoder.binding, {
65
+ encoding: o.encoding ?? 'int8-base64',
66
+ name: `DX7 ${which}`, synth: 'Yamaha DX7 (@audio/synth-dx7, MSFA)',
67
+ references: `MIDI notes ${refs(0).map(r => r.note).filter((n, i, a) => a.indexOf(n) === i).join(', ')} at velocities 64 and 112, held 1 s, 44.1 kHz`,
68
+ sources: 'names, cartridges and vectors only; each source and its stated terms: the @audio/neural-timbre README, Catalogs',
69
+ })
70
+ let out = o.out ?? path.join(CACHE, 'catalogs', `dx7-${which}.json`)
71
+ mkdirSync(path.dirname(out), { recursive: true })
72
+ writeFileSync(out, JSON.stringify(json))
73
+ encoder.free()
74
+ console.log(`${out}: ${entries.length} voices in ${((performance.now() - t0) / 60000).toFixed(1)} min`)
75
+ }
76
+
77
+ function worker() {
78
+ let patches = new Uint8Array(readFileSync(path.join(workerData.data, 'patches.u8')))
79
+ parentPort.on('message', msg => {
80
+ if (!msg) return parentPort.close()
81
+ let out = [], owners = []
82
+ msg.voices.forEach((v, k) => {
83
+ for (let r of refs(v)) {
84
+ let x = note(patches.subarray(v * 155, v * 155 + 155), r), peak = 0
85
+ for (let s of x) peak = Math.max(peak, Math.abs(s))
86
+ if (peak < 2 ** -13) continue
87
+ out.push(cqt(x.subarray(onset(x)))); owners.push(k)
88
+ }
89
+ })
90
+ parentPort.postMessage({ c: msg.c, patches: out, owners }, out.map(p => p.buffer))
91
+ })
92
+ }
93
+
94
+ if (isMainThread) main().catch(e => { console.error(e); process.exit(1) })
95
+ else worker()
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """Build the DX7 catalogs from stored reference features, on MPS: the bulk path.
3
+
4
+ python scripts/catalogs.py --run NAME [--model encoder.int8.onnx] [--encoding int8-base64] [--dir DIR] [--only factory,all]
5
+
6
+ Reads the reference rows of the refs (training voices), dev and heldout feature sets that
7
+ scripts/factory.js wrote, embeds them with the run's encoder (weights rounded to int8 per row when
8
+ --model is the int8 graph, as scripts/export.py rounds them), averages each voice's audible
9
+ references (a note below 4 int16 steps, which some key scalings give, is skipped) and writes ~/.cache/audiojs/neural/timbre/catalogs/dx7-{factory,dev,heldout,all}.json in the format
10
+ timbre.js catalog() writes and readCatalog() checks, bound to --model's SHA-256 and the features'.
11
+ scripts/catalog.js is the same builder through @audio/neural-runtime, rendering on the fly, for
12
+ small catalogs; on dx7-factory the two agree (README, Catalogs). The package ships dx7-factory and
13
+ dx7-all at int4: --encoding int4-base64 --dir catalogs --only factory,all.
14
+ """
15
+ import argparse, base64, hashlib, json, os, sys
16
+ from pathlib import Path
17
+ import numpy as np
18
+ import torch
19
+ from torch.nn import functional as F
20
+
21
+ sys.path.insert(0, str(Path(__file__).parent))
22
+ from train import Store, DATA, voices as load_voices # noqa: E402
23
+ from export import load, round_int8, OUT # noqa: E402
24
+
25
+ FACTORY = ('yamaha-factory', 'yamaha-vrc')
26
+
27
+
28
+ def cartridge(f):
29
+ """'factory/rom1a.syx' -> 'ROM1A'; other cartridges keep their path under the source folder, as scripts/catalog.js."""
30
+ if f.startswith('factory/') or f.startswith('vrc/'): return Path(f).stem.upper()
31
+ return f.split('/', 1)[1]
32
+
33
+
34
+ def pack(values, bits):
35
+ """Signed integers `bits` apiece in a little-endian bit stream, as timbre.js pack()."""
36
+ v = np.asarray(values, np.int64).ravel() & ((1 << bits) - 1)
37
+ return np.packbits(((v[:, None] >> np.arange(bits)) & 1).astype(np.uint8).ravel(), bitorder='little').tobytes()
38
+
39
+
40
+ def quantize(vectors, bits=8):
41
+ """Per-row scale max|v| / (2^(bits-1) - 1), values rounded half away from zero as Math.round does for these magnitudes."""
42
+ top = 2 ** (bits - 1) - 1
43
+ scales = np.maximum(np.abs(vectors).max(1), 1e-30) / top
44
+ scales = np.array([float(f'{s:.7g}') for s in scales]) # toPrecision(7), as catalog() stores them
45
+ q = np.floor(vectors / scales[:, None] + 0.5).astype(np.int64)
46
+ return q, scales
47
+
48
+
49
+ def write(path, entries, vectors, binding, info, encoding='int8-base64'):
50
+ bits = {'int8-base64': 8, 'int4-base64': 4}[encoding]
51
+ q, scales = quantize(vectors, bits)
52
+ data = {'version': 1, 'kind': 'timbre-catalog', **binding, 'dimensions': vectors.shape[1], **info, 'entries': entries,
53
+ 'vectors': {'encoding': encoding, 'shape': list(vectors.shape), 'scales': scales.tolist(), 'data': base64.b64encode(pack(q, bits)).decode()}}
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ path.write_text(json.dumps(data, separators=(',', ':')))
56
+ print(f'{path}: {len(entries)} voices, {path.stat().st_size / 1e6:.2f} MB')
57
+
58
+
59
+ @torch.no_grad()
60
+ def main():
61
+ ap = argparse.ArgumentParser()
62
+ ap.add_argument('--run', required=True)
63
+ ap.add_argument('--model', default='encoder.int8.onnx', help='the graph the catalogs bind to, in --out')
64
+ ap.add_argument('--out', default=str(OUT))
65
+ ap.add_argument('--encoding', default='int8-base64')
66
+ ap.add_argument('--dir', help='where the catalogs go, default <out>/catalogs')
67
+ ap.add_argument('--only', default='factory,dev,heldout,all', help='the catalogs to write')
68
+ a = ap.parse_args()
69
+ torch.set_num_threads(4)
70
+ device = 'mps'
71
+ torch.mps.set_per_process_memory_fraction(min(1.0, 3e9 / torch.mps.recommended_max_memory()))
72
+ out = Path(a.out)
73
+ graph, ck = load(a.run)
74
+ if 'int8' in a.model: round_int8(graph)
75
+ graph = graph.to(device).eval()
76
+ feats = ck['features']
77
+ canonical = json.dumps(feats, sort_keys=True, separators=(',', ':')) # JSON.stringify of timbre.js's canonical()
78
+ binding = {'encoderSha256': hashlib.sha256((out / a.model).read_bytes()).hexdigest(), 'featuresSha256': hashlib.sha256(canonical.encode()).hexdigest()}
79
+ meta = load_voices()
80
+ vec, loaded = {}, set()
81
+ for name, split in (('refs', 'train'), ('dev', 'dev'), ('heldout', 'heldout')):
82
+ if not (DATA / f'features/{name}.json').exists(): print(f'{name}: no feature set, its catalogs are skipped'); continue
83
+ loaded.add(split)
84
+ store = Store(name, ck['kind'])
85
+ rows = [i for i, r in enumerate(store.rows) if r['kind'] == 'ref' and store.audible[i]]
86
+ owners = np.array([store.rows[i]['voice'] for i in rows])
87
+ for b in range(0, len(rows), 256):
88
+ x = torch.from_numpy(store.read(rows[b:b + 256])).to(device).float()[:, None]
89
+ e, _ = graph(x)
90
+ e = e.cpu().numpy()
91
+ for o, v in zip(owners[b:b + 256], e): vec.setdefault(int(o), []).append(v)
92
+ print(f'{name}: {len(rows)} references', flush=True)
93
+ ids = sorted(vec)
94
+ print(f'{len(ids)} voices; {sum(len(v) < 10 for v in vec.values())} with fewer than ten audible references', flush=True)
95
+ mean = F.normalize(torch.tensor(np.stack([np.mean(vec[i], 0) for i in ids])), dim=1).numpy()
96
+ row = {v: k for k, v in enumerate(ids)}
97
+ info = lambda which: {'name': f'DX7 {which}', 'synth': 'Yamaha DX7 (@audio/synth-dx7, MSFA)', 'references': 'MIDI notes 36, 48, 60, 72, 84 at velocities 64 and 112, held 1 s, 44.1 kHz',
98
+ 'sources': 'names, cartridges and vectors only; each source and its stated terms: the @audio/neural-timbre README, Catalogs'}
99
+ entry = lambda i: {'name': meta[i]['name'], 'cartridge': cartridge(meta[i]['cartridge']), 'index': meta[i]['index'] + 1, 'source': meta[i]['source'], 'algorithm': meta[i]['algorithm'], 'id': meta[i]['id']}
100
+ for which, keep in (('factory', lambda v: v['source'] in FACTORY), ('dev', lambda v: v['split'] == 'dev'), ('heldout', lambda v: v['split'] == 'heldout'), ('all', lambda v: True)):
101
+ if which not in a.only.split(','): continue
102
+ chosen = [i for i in ids if keep(meta[i])]
103
+ if not {v['split'] for v in meta if keep(v)} <= loaded: print(f'dx7-{which}: references missing, skipped'); continue
104
+ write(Path(a.dir or out / 'catalogs') / f'dx7-{which}.json', [entry(i) for i in chosen], mean[[row[i] for i in chosen]], binding, info(which), a.encoding)
105
+
106
+
107
+ if __name__ == '__main__':
108
+ main()
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Export a trained encoder to ONNX for @audio/neural-runtime, with parity fixtures.
3
+
4
+ python scripts/export.py --run NAME [--out ~/.cache/audiojs/neural/timbre]
5
+
6
+ Writes to --out:
7
+ encoder.onnx float32: features [N, 1, bands, frames] -> embedding [N, dims] (unit),
8
+ algorithm [N, 32] (logits of the DX7 algorithm head)
9
+ encoder.int8.onnx the same graph, every conv and linear weight rounded per output row to int8
10
+ (gpu-font's per-row weight rounding) and restored by DequantizeLinear
11
+ parity.json fixture patches from the development set (float16, base64), their PyTorch
12
+ embeddings for each graph, and the top-5 of each against a PyTorch catalog of
13
+ development voices: test.js checks ONNX in JS against them
14
+ Batch norm is folded into the convolutions first, so both graphs compute what training evaluated.
15
+ """
16
+ import argparse, base64, json, os, sys
17
+ from pathlib import Path
18
+ import numpy as np
19
+ import torch
20
+ from torch import nn
21
+ from torch.nn import functional as F
22
+
23
+ sys.path.insert(0, str(Path(__file__).parent))
24
+ from train import Encoder, Store, RUNS, DATA # noqa: E402
25
+
26
+ OUT = Path(os.environ.get('AUDIO_NEURAL_CACHE', Path.home() / '.cache/audiojs/neural')) / 'timbre'
27
+
28
+
29
+ class Graph(nn.Module):
30
+ """The deployed function: unit embedding and algorithm logits."""
31
+ def __init__(self, encoder, heads):
32
+ super().__init__(); self.encoder, self.heads = encoder, heads
33
+
34
+ def forward(self, features):
35
+ e = self.encoder(features)
36
+ return e, self.heads(e) * 10
37
+
38
+
39
+ def fold(model):
40
+ """Conv + BatchNorm -> one conv; Linear + BatchNorm1d -> one linear. Eval-mode identities."""
41
+ model = model.eval()
42
+ seq, out, i = list(model.features), [], 0
43
+ while i < len(seq):
44
+ m = seq[i]
45
+ if isinstance(m, nn.Conv2d) and i + 1 < len(seq) and isinstance(seq[i + 1], nn.BatchNorm2d):
46
+ out.append(nn.utils.fuse_conv_bn_eval(m, seq[i + 1])); i += 2
47
+ else: out.append(m); i += 1
48
+ model.features = nn.Sequential(*out)
49
+ lin, bn, *rest = list(model.head)
50
+ model.head = nn.Sequential(nn.utils.fuse_linear_bn_eval(lin, bn), *rest)
51
+ return model
52
+
53
+
54
+ def load(run):
55
+ ck = torch.load(RUNS / run / 'best.pt', map_location='cpu', weights_only=False)
56
+ c = ck['config']
57
+ enc = Encoder(c['bands'], c['dims'], tuple(c['widths']), c['reduce'], c['hidden']); enc.load_state_dict(ck['state'])
58
+ heads = nn.Linear(c['dims'], 32); heads.load_state_dict(ck['heads'])
59
+ return Graph(fold(enc), heads.eval()).eval(), ck
60
+
61
+
62
+ @torch.no_grad()
63
+ def round_int8(graph):
64
+ """Every conv and linear weight rounded per output row to int8 in place: the PyTorch twin of quantize()."""
65
+ for m in graph.modules():
66
+ if isinstance(m, (nn.Conv2d, nn.Linear)):
67
+ w = m.weight.data; rows = w.reshape(w.shape[0], -1); s = rows.abs().amax(1).clamp(min=1e-12) / 127
68
+ m.weight.data = (torch.round(rows / s[:, None]).clamp(-127, 127) * s[:, None]).reshape(w.shape)
69
+ return graph
70
+
71
+
72
+ def quantize(path, out):
73
+ """Every float initializer of rank >= 2 feeding a Conv, Gemm or MatMul becomes int8 rows (axis 0 for
74
+ Conv and transB Gemm, the output axis for MatMul) plus a per-row scale, dequantized in-graph."""
75
+ import onnx
76
+ from onnx import helper, numpy_helper
77
+ m = onnx.load(path); g = m.graph
78
+ inits = {i.name: i for i in g.initializer}
79
+ users = {}
80
+ for n in g.node:
81
+ for k, name in enumerate(n.input): users.setdefault(name, []).append((n, k))
82
+ for name, init in list(inits.items()):
83
+ w = numpy_helper.to_array(init)
84
+ if w.ndim < 2 or w.dtype != np.float32: continue
85
+ uses = users.get(name, [])
86
+ if not uses or any(n.op_type not in ('Conv', 'Gemm', 'MatMul') for n, _ in uses): continue
87
+ n0 = uses[0][0]
88
+ axis = 1 if n0.op_type == 'MatMul' or (n0.op_type == 'Gemm' and not any(a.name == 'transB' and a.i for a in n0.attribute)) else 0
89
+ moved = np.moveaxis(w, axis, 0); rows = moved.reshape(moved.shape[0], -1)
90
+ scale = np.maximum(np.abs(rows).max(1), 1e-12) / 127
91
+ q = np.clip(np.round(rows / scale[:, None]), -127, 127).astype(np.int8).reshape(moved.shape)
92
+ q = np.moveaxis(q, 0, axis)
93
+ g.initializer.remove(init)
94
+ g.initializer.extend([numpy_helper.from_array(q, name + '_q'), numpy_helper.from_array(scale.astype(np.float32), name + '_scale'),
95
+ numpy_helper.from_array(np.zeros_like(scale, np.int8), name + '_zero')])
96
+ g.node.insert(0, helper.make_node('DequantizeLinear', [name + '_q', name + '_scale', name + '_zero'], [name], axis=axis))
97
+ onnx.checker.check_model(m)
98
+ onnx.save(m, out)
99
+
100
+
101
+ def main():
102
+ ap = argparse.ArgumentParser()
103
+ ap.add_argument('--run', required=True)
104
+ ap.add_argument('--out', default=str(OUT))
105
+ ap.add_argument('--fixtures', type=int, default=32)
106
+ a = ap.parse_args()
107
+ out = Path(a.out); out.mkdir(parents=True, exist_ok=True)
108
+ graph, ck = load(a.run)
109
+ bands, frames = ck['features']['bands'], ck['features']['frames']
110
+ x = torch.zeros(2, 1, bands, frames)
111
+ onnx_path = out / 'encoder.onnx'
112
+ torch.onnx.export(graph, (x,), str(onnx_path), input_names=['features'], output_names=['embedding', 'algorithm'],
113
+ dynamic_axes={'features': {0: 'n'}, 'embedding': {0: 'n'}, 'algorithm': {0: 'n'}}, opset_version=17, dynamo=False)
114
+ import onnx
115
+ m = onnx.load(onnx_path)
116
+ for k, v in {'features': json.dumps(ck['features'], sort_keys=True), 'run': a.run, 'step': str(ck['step'])}.items():
117
+ m.metadata_props.append(onnx.StringStringEntryProto(key=k, value=v))
118
+ onnx.save(m, onnx_path)
119
+ quantize(str(onnx_path), str(out / 'encoder.int8.onnx'))
120
+
121
+ # fixtures: queries and references of development voices, and a catalog of those voices
122
+ dev = Store('dev', ck['kind']); rows = dev.rows
123
+ voices = sorted({r['voice'] for r in rows})[:64]
124
+ refs = [i for i, r in enumerate(rows) if r['kind'] == 'ref' and r['voice'] in voices and dev.audible[i]]
125
+ queries = [i for i, r in enumerate(rows) if r['kind'] == 'query' and r['voice'] in voices and dev.audible[i]][:a.fixtures]
126
+ import onnxruntime as ort
127
+ fixture = {'features': ck['features'], 'rows': queries, 'voices': voices, 'patches': base64.b64encode(dev.read(queries).tobytes()).decode()}
128
+ with torch.no_grad():
129
+ for name in ('encoder.onnx', 'encoder.int8.onnx'):
130
+ # the int8 graph's PyTorch twin: the same weights rounded per row
131
+ g = graph if name == 'encoder.onnx' else round_int8(load(a.run)[0])
132
+ run = lambda rows: torch.cat([g(torch.from_numpy(dev.read(rows[b:b + 64])).float()[:, None])[0] for b in range(0, len(rows), 64)])
133
+ qe, re_ = run(queries), run(refs)
134
+ owners = torch.tensor([voices.index(rows[i]['voice']) for i in refs])
135
+ cat = F.normalize(torch.zeros(len(voices), re_.shape[1]).index_add_(0, owners, re_), dim=1)
136
+ top5 = (qe @ cat.T).argsort(1, descending=True)[:, :5]
137
+ sess = ort.InferenceSession(str(out / name), providers=['CPUExecutionProvider'])
138
+ oe = sess.run(['embedding'], {'features': dev.read(queries).astype(np.float32)[:, None]})[0]
139
+ print(f'{name}: {(out / name).stat().st_size / 1e6:.2f} MB, python onnxruntime vs torch max |diff| {np.abs(oe - qe.numpy()).max():.2e}')
140
+ fixture[name] = {'embeddings': qe.numpy().round(7).tolist(), 'catalog': cat.numpy().round(7).tolist(), 'top5': top5.tolist()}
141
+ (out / 'parity.json').write_text(json.dumps(fixture))
142
+ print('wrote', onnx_path, out / 'encoder.int8.onnx', out / 'parity.json')
143
+
144
+
145
+ if __name__ == '__main__':
146
+ main()