@audio/neural-timbre 0.0.0-stage → 0.1.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 +236 -2
- package/features.js +98 -0
- package/index.d.ts +144 -0
- package/package.json +59 -4
- package/scripts/augment.js +180 -0
- package/scripts/bench.js +90 -0
- package/scripts/catalog.js +95 -0
- package/scripts/catalogs.py +108 -0
- package/scripts/export.py +146 -0
- package/scripts/factory.js +125 -0
- package/scripts/freeze.js +46 -0
- package/scripts/logmel.js +45 -0
- package/scripts/rebuild.js +96 -0
- package/scripts/recipe.js +68 -0
- package/scripts/train.py +252 -0
- package/scripts/voices.js +165 -0
- package/scripts/webgpu.js +69 -0
- package/timbre.js +233 -0
|
@@ -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
|
+
}
|
package/scripts/bench.js
ADDED
|
@@ -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()
|