@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,125 @@
1
+ #!/usr/bin/env node
2
+ // Feature sets for training and the benchmark: every row of a set (scripts/recipe.js) rendered by
3
+ // synth-dx7, augmented by scripts/augment.js and prepared by the package's own frontend, stored
4
+ // as float16 [rows, bands, frames] on disk for memory mapping.
5
+ //
6
+ // node scripts/factory.js <set> [--name NAME] [--limit N] [--views N] [--kinds logmel,cqt] [--workers N] [--data dir]
7
+ //
8
+ // set train (views of training voices), dev (references and clean + chain queries of
9
+ // development voices), heldout (references and every benchmark condition), refs
10
+ // (references of training voices, for the catalogs scripts/catalogs.py builds)
11
+ // writes <data>/features/<name>.json (rows and feature settings; name defaults to the set),
12
+ // <name>.<kind>.f16 and <name>.stats.f32 (the dry note's peak and RMS a row); chunks done
13
+ // so far are kept in <name>.done, so a stopped run resumes
14
+ //
15
+ // Needs @audio/synth-dx7 and the augmentation packages resolvable from here (npm link or a local
16
+ // node_modules); it runs at most --workers threads (default 4).
17
+ import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
18
+ import { readFileSync, writeFileSync, openSync, writeSync, closeSync, existsSync, mkdirSync, ftruncateSync } from 'node:fs'
19
+ import path from 'node:path'
20
+ import os from 'node:os'
21
+ import { FEATURES, cqt, onset } from '../features.js'
22
+ import { LOGMEL, logmel } from './logmel.js'
23
+ import { views, refs, queries, specOf, note, mixNote, seedOf } from './recipe.js'
24
+ import { apply, CONDITIONS } from './augment.js'
25
+
26
+ const KINDS = { cqt: [FEATURES, cqt], logmel: [LOGMEL, logmel] } // logmel: the feature pilot only
27
+ const CHUNK = 64
28
+
29
+ function args(argv) {
30
+ let o = { set: argv[0], limit: Infinity, views: 8, kinds: ['cqt'], workers: 4, data: path.join(os.homedir(), '.cache/audiojs/data/dx7') }
31
+ for (let i = 1; i < argv.length; i += 2) {
32
+ let [k, v] = [argv[i].replace(/^--/, ''), argv[i + 1]]
33
+ o[k] = k === 'kinds' ? v.split(',') : k === 'data' || k === 'name' ? v : Number(v)
34
+ }
35
+ return o
36
+ }
37
+
38
+ function rowsOf(set, voices, o) {
39
+ let pick = split => voices.map((v, i) => [v, i]).filter(([v]) => v.split === split && !v.silent)
40
+ if (set === 'train') {
41
+ // a fixed order by id hash, so --limit takes the same voices every time
42
+ let train = pick('train').sort((a, b) => seedOf(a[0].id) - seedOf(b[0].id)).slice(0, o.limit)
43
+ return { rows: train.flatMap(([v, i]) => views(i, v.id, o.views)), pool: 'train' }
44
+ }
45
+ if (set === 'refs') return { rows: pick('train').slice(0, o.limit).flatMap(([, i]) => refs(i)), pool: 'train' }
46
+ let split = set === 'dev' ? 'dev' : 'heldout', conds = set === 'dev' ? ['clean', 'chain'] : Object.keys(CONDITIONS)
47
+ let list = pick(split).slice(0, o.limit)
48
+ return { rows: list.flatMap(([v, i]) => [...refs(i), ...queries(i, v.id, conds)]), pool: split }
49
+ }
50
+
51
+ async function main() {
52
+ let o = args(process.argv.slice(2))
53
+ if (!['train', 'dev', 'heldout', 'refs'].includes(o.set)) throw new Error('set: train, dev, heldout or refs')
54
+ let voices = JSON.parse(readFileSync(path.join(o.data, 'voices.json'), 'utf8')).voices
55
+ let { rows, pool } = rowsOf(o.set, voices, o)
56
+ let out = path.join(o.data, 'features'); mkdirSync(out, { recursive: true })
57
+ let base = path.join(out, o.name ?? o.set), meta = base + '.json'
58
+ let settings = Object.fromEntries(o.kinds.map(k => [k, KINDS[k][0]]))
59
+ let keys = ['voice', 'kind', 'note', 'velocity', 'hold', 'pre', 'cond', 'seed']
60
+ // full precision: a row re-renders exactly from its JSON
61
+ let table = { version: 1, set: o.set, pool, keys, features: settings, rows: rows.map(r => keys.map(k => r[k])) }
62
+ if (existsSync(meta)) {
63
+ let old = JSON.parse(readFileSync(meta, 'utf8'))
64
+ if (JSON.stringify(old.rows) !== JSON.stringify(table.rows) || JSON.stringify(old.features) !== JSON.stringify(settings)) throw new Error(`${meta} holds another set: remove it and its .f16/.done to rebuild`)
65
+ table = old
66
+ } else writeFileSync(meta, JSON.stringify(table))
67
+ let files = [...o.kinds.map(k => [`${base}.${k}.f16`, KINDS[k][0].bands * KINDS[k][0].frames * 2]), [base + '.stats.f32', 8]]
68
+ for (let [f, size] of files) if (!existsSync(f)) { let fd = openSync(f, 'w'); ftruncateSync(fd, rows.length * size); closeSync(fd) }
69
+ let doneFile = base + '.done', done = existsSync(doneFile) ? new Uint8Array(readFileSync(doneFile)) : new Uint8Array(Math.ceil(rows.length / CHUNK))
70
+ let todo = [...done.keys()].filter(c => !done[c])
71
+ console.log(`${o.name ?? o.set}: ${rows.length} rows, ${todo.length} of ${done.length} chunks to do, ${o.workers} workers, ${o.kinds}`)
72
+ let t0 = performance.now(), finished = 0
73
+ await Promise.all(Array.from({ length: Math.min(o.workers, todo.length) }, () => new Promise((resolve, reject) => {
74
+ let w = new Worker(new URL(import.meta.url), { workerData: { data: o.data, base, kinds: o.kinds, pool } })
75
+ let next = () => {
76
+ if (!todo.length) return w.postMessage(null)
77
+ let c = todo.shift()
78
+ w.postMessage({ chunk: c, rows: rows.slice(c * CHUNK, (c + 1) * CHUNK), start: c * CHUNK })
79
+ }
80
+ w.on('message', ({ chunk }) => {
81
+ done[chunk] = 1; finished++
82
+ if (finished % 20 === 0) {
83
+ writeFileSync(doneFile, done)
84
+ let rate = finished * CHUNK / ((performance.now() - t0) / 1000)
85
+ console.log(`${finished}/${done.length} chunks, ${rate.toFixed(0)} rows/s, ${((todo.length * CHUNK) / rate / 60).toFixed(1)} min left`)
86
+ }
87
+ next()
88
+ })
89
+ w.on('error', reject)
90
+ w.on('exit', resolve)
91
+ next()
92
+ })))
93
+ writeFileSync(doneFile, done)
94
+ let stats = new Float32Array(readFileSync(base + '.stats.f32').buffer), silent = 0
95
+ for (let i = 0; i < rows.length; i++) if (stats[2 * i] < 2 ** -13) silent++
96
+ console.log(`done in ${((performance.now() - t0) / 60000).toFixed(1)} min; ${silent} silent rows`)
97
+ }
98
+
99
+ async function worker() {
100
+ let { data, base, kinds, pool: split } = workerData
101
+ let patches = new Uint8Array(readFileSync(path.join(data, 'patches.u8')))
102
+ let voices = JSON.parse(readFileSync(path.join(data, 'voices.json'), 'utf8')).voices
103
+ let pool = voices.map((v, i) => [v, i]).filter(([v]) => v.split === split).map(([, i]) => i)
104
+ let patch = i => patches.subarray(i * 155, i * 155 + 155)
105
+ let fds = kinds.map(k => openSync(`${base}.${k}.f16`, 'r+')), statsFd = openSync(base + '.stats.f32', 'r+')
106
+ parentPort.on('message', async msg => {
107
+ if (!msg) { [...fds, statsFd].forEach(closeSync); return parentPort.close() }
108
+ for (let [j, row] of msg.rows.entries()) {
109
+ let dry = note(patch(row.voice), row), peak = 0, sum = 0
110
+ for (let x of dry) { peak = Math.max(peak, Math.abs(x)); sum += x * x }
111
+ let spec = specOf(row, pool)
112
+ let y = await apply(dry, spec, m => mixNote(patch(m.voice), m, row.pre))
113
+ let x = y.subarray(onset(y))
114
+ kinds.forEach((k, ki) => {
115
+ let [f, fn] = KINDS[k], v = new Float16Array(fn(x, f)), size = f.bands * f.frames * 2
116
+ writeSync(fds[ki], new Uint8Array(v.buffer), 0, size, (msg.start + j) * size)
117
+ })
118
+ writeSync(statsFd, new Uint8Array(Float32Array.of(peak, Math.sqrt(sum / dry.length)).buffer), 0, 8, (msg.start + j) * 8)
119
+ }
120
+ parentPort.postMessage({ chunk: msg.chunk })
121
+ })
122
+ }
123
+
124
+ if (isMainThread) main().catch(e => { console.error(e); process.exit(1) })
125
+ else worker()
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // Freeze the benchmark before any training result: pin the files that define it (the voice split,
3
+ // the render and augmentation recipe, the frontend) by SHA-256 in <data>/bench.json. scripts/bench.js
4
+ // refuses to report against a changed pin. Refuses to overwrite an existing manifest; a pin moves
5
+ // only with --repin FILE --reason TEXT, recorded in the manifest's `repins`.
6
+ //
7
+ // node scripts/freeze.js [--data dir]
8
+ // node scripts/freeze.js --repin features.js --reason '...'
9
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
10
+ import { createHash } from 'node:crypto'
11
+ import path from 'node:path'
12
+ import os from 'node:os'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { CONDITIONS } from './augment.js'
15
+ import { REF_NOTES, REF_VELOCITIES, REF_HOLD } from './recipe.js'
16
+
17
+ const arg = k => { let i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : undefined }
18
+ const data = arg('--data') ?? path.join(os.homedir(), '.cache/audiojs/data/dx7')
19
+ const here = path.dirname(fileURLToPath(import.meta.url))
20
+ const sha = f => createHash('sha256').update(readFileSync(f)).digest('hex')
21
+ export const PINS = { 'voices.json': path.join(data, 'voices.json'), 'patches.u8': path.join(data, 'patches.u8'),
22
+ 'scripts/recipe.js': path.join(here, 'recipe.js'), 'scripts/augment.js': path.join(here, 'augment.js'), 'features.js': path.join(here, '../features.js') }
23
+ export const pins = () => Object.fromEntries(Object.entries(PINS).map(([k, f]) => [k, sha(f)]))
24
+
25
+ if (process.argv[1] === fileURLToPath(import.meta.url) && arg('--repin')) {
26
+ let out = path.join(data, 'bench.json'), bench = JSON.parse(readFileSync(out, 'utf8')), file = arg('--repin'), reason = arg('--reason')
27
+ if (!PINS[file] || !reason) throw new Error('--repin one of ' + Object.keys(PINS) + ' with --reason')
28
+ let now = sha(PINS[file])
29
+ ;(bench.repins ??= []).push({ date: new Date().toISOString(), file, from: bench.pins[file], to: now, reason })
30
+ bench.pins[file] = now
31
+ writeFileSync(out, JSON.stringify(bench, null, 1))
32
+ console.log('repinned', file)
33
+ } else if (process.argv[1] === fileURLToPath(import.meta.url)) {
34
+ let out = path.join(data, 'bench.json')
35
+ if (existsSync(out)) throw new Error(`${out} exists: the benchmark is frozen`)
36
+ let voices = JSON.parse(readFileSync(PINS['voices.json'], 'utf8')).voices, held = voices.filter(v => v.split === 'heldout' && !v.silent)
37
+ writeFileSync(out, JSON.stringify({
38
+ version: 1, frozen: new Date().toISOString(),
39
+ what: 'Held-out DX7 cartridges: every audible held-out voice is one catalog entry (references: C2-C6 at velocities 64 and 112, held 1 s) and one query note at a note and velocity no reference uses, under each condition. Near copies: held-out voices within 3 parameters of a training voice, reported apart.',
40
+ voices: held.length, novel: held.filter(v => v.near >= 4).length, nearCopies: held.filter(v => v.near <= 3).length,
41
+ references: { notes: REF_NOTES, velocities: REF_VELOCITIES, hold: REF_HOLD }, conditions: Object.keys(CONDITIONS),
42
+ metrics: ['top-1 and top-5 against the held-out catalog', 'top-1 and top-5 against the catalog of every voice', 'rebuild: specLoss of the retrieved preset before and after seeded match, against blind match at the same budget'],
43
+ pins: pins(),
44
+ }, null, 1))
45
+ console.log('froze', out)
46
+ }
@@ -0,0 +1,45 @@
1
+ // Log-mel spectrogram for the feature pilot (README, Features): the frontend the constant-Q one was
2
+ // measured against, on the same framing, level normalization and frame count. As librosa computes
3
+ // it: Slaney mel scale and area normalization (librosa.filters.mel), power spectrum of a
4
+ // periodic-Hann STFT without centering (librosa.stft(center=False)), dB re the loudest cell with an
5
+ // 80 dB floor (librosa.power_to_db(ref=np.max, top_db=80)); it matched librosa 1.0.0 to 3e-6 dB.
6
+ import { fft } from 'fourier-transform'
7
+
8
+ export const LOGMEL = Object.freeze({ kind: 'logmel', sampleRate: 44100, n: 2048, hop: 512, bands: 96, fmin: 20, fmax: 11025, frames: 128, top: 80 })
9
+
10
+ const hann = n => Float64Array.from({ length: n }, (_, i) => 0.5 - 0.5 * Math.cos(2 * Math.PI * i / n))
11
+
12
+ // Slaney's Auditory Toolbox mel scale, librosa.hz_to_mel(htk=False): linear below 1 kHz, log above.
13
+ const F_SP = 200 / 3, MIN_LOG_HZ = 1000, MIN_LOG_MEL = MIN_LOG_HZ / F_SP, LOGSTEP = Math.log(6.4) / 27
14
+ const hzToMel = f => f < MIN_LOG_HZ ? f / F_SP : MIN_LOG_MEL + Math.log(f / MIN_LOG_HZ) / LOGSTEP
15
+ const melToHz = m => m < MIN_LOG_MEL ? m * F_SP : MIN_LOG_HZ * Math.exp(LOGSTEP * (m - MIN_LOG_MEL))
16
+
17
+ // Triangular filters on the FFT bin frequencies, each scaled to unit area (norm='slaney').
18
+ function melBank({ sampleRate: fs, n, bands, fmin, fmax }) {
19
+ let lo = hzToMel(fmin), hi = hzToMel(fmax)
20
+ let edges = Float64Array.from({ length: bands + 2 }, (_, i) => melToHz(lo + (hi - lo) * i / (bands + 1)))
21
+ return Array.from({ length: bands }, (_, b) => {
22
+ let [l, c, r] = [edges[b], edges[b + 1], edges[b + 2]], norm = 2 / (r - l)
23
+ return Float64Array.from({ length: n / 2 + 1 }, (_, k) => { let f = k * fs / n; return Math.max(0, Math.min((f - l) / (c - l), (r - f) / (r - c))) * norm })
24
+ })
25
+ }
26
+
27
+ let bank = null, window = null
28
+ export function logmel(x, f = LOGMEL) {
29
+ let { n, hop, bands, frames, top } = f, frame = new Float64Array(n)
30
+ bank ??= melBank(f); window ??= hann(n)
31
+ let out = new Float32Array(bands * frames), max = 0
32
+ for (let t = 0; t < frames; t++) {
33
+ for (let i = 0; i < n; i++) frame[i] = (x[t * hop + i] || 0) * window[i]
34
+ let [re, im] = fft(frame)
35
+ for (let b = 0; b < bands; b++) {
36
+ let w = bank[b], s = 0
37
+ for (let k = 0; k <= n / 2; k++) if (w[k]) s += w[k] * (re[k] * re[k] + im[k] * im[k])
38
+ out[b * frames + t] = s
39
+ if (s > max) max = s
40
+ }
41
+ }
42
+ let ref = Math.max(max, 1e-10)
43
+ for (let i = 0; i < out.length; i++) { let db = 10 * Math.log10(Math.max(out[i], 1e-10) / ref); out[i] = Math.max(db, -top) / (top / 2) + 1 }
44
+ return out
45
+ }
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // The rebuild half of the benchmark: for held-out queries, the spectral distance (neural-synth's
3
+ // specLoss) between the query note and the best retrieved preset played at the same note, before
4
+ // and after seeded refinement with @audio/neural-synth's match, against blind match at the same
5
+ // budget. The query is the clean held-out note; retrieval ranks it against dx7-heldout.
6
+ //
7
+ // node scripts/rebuild.js [--n 64] [--budget 1000] [--model file] [--out file] [--misses file]
8
+ //
9
+ // misses a previous run's output: rerun only its retrieval misses (at another budget, say)
10
+ //
11
+ // match searches the 145 sound parameters of the VCED voice (MAX bounds; the 10 name bytes, which
12
+ // MAX also bounds, cannot change the sound and stay fixed), rounding each candidate to integers.
13
+ // Each CMA-ES start is evaluated before it steps (cmaes.js), so a seeded result is never worse than
14
+ // the retrieved preset (`kept`, the better of the two, equals `seeded`). `random` is another
15
+ // held-out voice at the same note: the scale of a wrong answer.
16
+ import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
17
+ import { readFileSync, writeFileSync, openSync, readSync, closeSync } from 'node:fs'
18
+ import path from 'node:path'
19
+ import os from 'node:os'
20
+ import { createEncoder, readCatalog, rank } from '../timbre.js'
21
+ import { note, seedOf } from './recipe.js'
22
+
23
+ const DATA = process.env.TIMBRE_DATA ?? path.join(os.homedir(), '.cache/audiojs/data/dx7')
24
+ const CACHE = path.join(process.env.AUDIO_NEURAL_CACHE ?? path.join(os.homedir(), '.cache/audiojs/neural'), 'timbre')
25
+ const WORKERS = 4
26
+
27
+ async function main() {
28
+ let o = { n: 64, budget: 1000 }
29
+ for (let a = process.argv.slice(2), i = 0; i < a.length; i += 2) o[a[i].replace(/^--/, '')] = isNaN(a[i + 1]) ? a[i + 1] : Number(a[i + 1])
30
+ let meta = JSON.parse(readFileSync(path.join(DATA, 'features/heldout.json'), 'utf8'))
31
+ let f = meta.features.cqt, size = f.bands * f.frames
32
+ let rows = meta.rows.map(r => Object.fromEntries(meta.keys.map((k, i) => [k, r[i]])))
33
+ let stats = new Float32Array(readFileSync(path.join(DATA, 'features/heldout.stats.f32')).buffer)
34
+ let voices = JSON.parse(readFileSync(path.join(DATA, 'voices.json'), 'utf8')).voices
35
+ // a fixed sample: clean queries ordered by a hash of their voice id
36
+ let picked = rows.map((r, i) => [r, i]).filter(([r, i]) => r.kind === 'query' && r.cond === 'clean' && stats[2 * i] >= 2 ** -13)
37
+ .sort((a, b) => seedOf(voices[a[0].voice].id, 'rebuild') - seedOf(voices[b[0].voice].id, 'rebuild')).slice(0, o.n)
38
+ if (o.misses) {
39
+ let miss = new Set(JSON.parse(readFileSync(o.misses, 'utf8')).results.filter(r => !r.hit).map(r => r.voice))
40
+ picked = picked.filter(([r]) => miss.has(voices[r.voice].id))
41
+ }
42
+ let fd = openSync(path.join(DATA, 'features/heldout.cqt.f16'), 'r')
43
+ 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)) }
44
+ let encoder = await createEncoder({ model: o.model ?? path.join(CACHE, 'encoder.int8.onnx') }) // the graph the catalogs bind
45
+ let cat = readCatalog(JSON.parse(readFileSync(path.join(CACHE, 'catalogs/dx7-heldout.json'), 'utf8')), encoder.binding)
46
+ let byId = new Map(voices.map((v, i) => [v.id, i]))
47
+ let emb = await encoder.project(picked.map(([, i]) => read(i)))
48
+ closeSync(fd); encoder.free()
49
+ let pool = voices.map((v, i) => [v, i]).filter(([v]) => v.split === 'heldout' && !v.silent).map(([, i]) => i)
50
+ let jobs = picked.map(([r], k) => {
51
+ let [best] = rank(emb[k], cat, 1), other = r.voice
52
+ for (let s = 0; other === r.voice; s++) other = pool[seedOf(voices[r.voice].id, 'random', s) % pool.length]
53
+ return { row: r, truth: r.voice, retrieved: byId.get(cat.entries[best.index].id), random: other, score: best.score }
54
+ })
55
+ let results = [], t0 = performance.now()
56
+ await Promise.all(Array.from({ length: WORKERS }, () => new Promise((resolve, reject) => {
57
+ let w = new Worker(new URL(import.meta.url), { workerData: { data: DATA, budget: o.budget } })
58
+ let next = () => jobs.length ? w.postMessage(jobs.shift()) : w.postMessage(null)
59
+ w.on('message', r => { results.push(r); console.log(`${results.length}/${picked.length}: ${r.name} ${r.hit ? 'hit' : 'miss'} random ${r.random.toFixed(3)} retrieved ${r.before.toFixed(3)} seeded ${r.seeded.toFixed(3)} blind ${r.blind.toFixed(3)} (${((performance.now() - t0) / 60000).toFixed(1)} min)`); next() })
60
+ w.on('error', reject); w.on('exit', resolve); next()
61
+ })))
62
+ let summary = group => {
63
+ let s = results.filter(group), m = k => s.reduce((a, r) => a + r[k], 0) / s.length
64
+ if (!s.length) return { n: 0 }
65
+ let med = k => { let v = s.map(r => r[k]).sort((a, b) => a - b); return v[v.length >> 1] }
66
+ let both = k => ({ mean: m(k), median: med(k) })
67
+ return { n: s.length, random: both('random'), retrieved: both('before'), seeded: both('seeded'), kept: both('kept'), blind: both('blind'),
68
+ seededBeatsBlind: s.filter(r => r.seeded < r.blind).length / s.length, seededBeatsRetrieved: s.filter(r => r.seeded < r.before).length / s.length,
69
+ blindBeatsRetrieved: s.filter(r => r.blind < r.before).length / s.length }
70
+ }
71
+ let out = { budget: o.budget, n: results.length, all: summary(() => true), misses: summary(r => !r.hit), hits: summary(r => r.hit), results }
72
+ writeFileSync(o.out ?? path.join(CACHE, 'rebuild.json'), JSON.stringify(out, null, 1))
73
+ console.log(JSON.stringify({ all: out.all, misses: out.misses }, null, 1))
74
+ }
75
+
76
+ async function worker() {
77
+ let { default: match, specLoss } = await import('@audio/neural-synth')
78
+ let { MAX } = await import('@audio/synth-dx7')
79
+ let patches = new Uint8Array(readFileSync(path.join(workerData.data, 'patches.u8')))
80
+ let voices = JSON.parse(readFileSync(path.join(workerData.data, 'voices.json'), 'utf8')).voices
81
+ let patch = i => patches.subarray(i * 155, i * 155 + 155)
82
+ let bounds = Array.from(MAX.subarray(0, 145), m => [0, m])
83
+ parentPort.on('message', job => {
84
+ if (!job) return parentPort.close()
85
+ let spec = { ...job.row, pre: 0 }, target = note(patch(job.truth), spec), seed = patch(job.retrieved)
86
+ let render = p => { let q = Uint8Array.from(seed); for (let i = 0; i < 145; i++) q[i] = Math.min(MAX[i], Math.max(0, Math.round(p[i]))); return note(q, spec) }
87
+ let before = specLoss(target, note(seed, spec)), random = specLoss(target, note(patch(job.random), spec))
88
+ let seeded = match(render, target, { bounds, seeds: [Array.from(seed.subarray(0, 145))], budget: workerData.budget })
89
+ let blind = match(render, target, { bounds, budget: workerData.budget })
90
+ parentPort.postMessage({ voice: voices[job.truth].id, name: voices[job.truth].name, retrieved: voices[job.retrieved].name, hit: job.truth === job.retrieved,
91
+ score: job.score, note: spec.note, velocity: spec.velocity, random, before, seeded: seeded.loss, kept: Math.min(seeded.loss, before), blind: blind.loss, evals: [seeded.evals, blind.evals] })
92
+ })
93
+ }
94
+
95
+ if (isMainThread) main().catch(e => { console.error(e); process.exit(1) })
96
+ else worker()
@@ -0,0 +1,68 @@
1
+ // What gets rendered: every row of a feature set as { voice, kind, note, velocity, hold, pre, cond,
2
+ // seed }, drawn from seeds derived from the voice id, so a set is the same wherever it is built.
3
+ //
4
+ // view training view: any note 33-99, velocity 20-127, held or staccato, a training chain
5
+ // ref catalog reference: C2, C3, C4, C5, C6 (MIDI 36-84) at velocities 64 and 112, held 1 s, clean
6
+ // query benchmark query: a note and velocity no reference uses (MIDI 38-94, velocity 30-127),
7
+ // held or staccato, under one condition of augment.js CONDITIONS
8
+ import { createHash } from 'node:crypto'
9
+ import dx7 from '@audio/synth-dx7'
10
+ import { rng, chain, CONDITIONS, FS } from './augment.js'
11
+
12
+ export const REF_NOTES = [36, 48, 60, 72, 84], REF_VELOCITIES = [64, 112], REF_HOLD = 1
13
+ export const LENGTH = 1.6 // seconds rendered after the pre-roll: the 1.52 s a view spans, plus codec delay
14
+ export const seedOf = (...parts) => parseInt(createHash('sha1').update(parts.join(':')).digest('hex').slice(0, 8), 16)
15
+
16
+ const hold = r => r() < 0.35 ? r.uniform(0.06, 0.4) : r.uniform(0.4, 2.5)
17
+ const queryHold = r => r() < 0.5 ? r.uniform(0.08, 0.3) : r.uniform(0.6, 2)
18
+
19
+ // another voice of the same split under a mix: pool is a list of voice rows, self excluded
20
+ const other = (pool, self) => r => {
21
+ let v = self
22
+ while (v === self) v = r.pick(pool)
23
+ return { voice: v, note: r.int(40, 84), velocity: r.int(60, 120), hold: r.uniform(0.3, 1.5), offset: r.uniform(-0.5, 0.5) }
24
+ }
25
+
26
+ export function views(voice, id, count) {
27
+ return Array.from({ length: count }, (_, i) => {
28
+ let seed = seedOf(id, 'view', i), r = rng(seed)
29
+ return { voice, kind: 'view', note: r.int(33, 99), velocity: r.int(20, 127), hold: hold(r), pre: r.uniform(0, 0.03), cond: 'chain', seed }
30
+ })
31
+ }
32
+
33
+ export const refs = voice => REF_NOTES.flatMap(note => REF_VELOCITIES.map(velocity => ({ voice, kind: 'ref', note, velocity, hold: REF_HOLD, pre: 0, cond: 'clean', seed: 0 })))
34
+
35
+ // One query draw per voice (note, velocity, hold, pre-roll), repeated under each condition so the
36
+ // conditions compare on the same notes.
37
+ export function queries(voice, id, conds) {
38
+ let r = rng(seedOf(id, 'query')), note, velocity
39
+ do note = r.int(38, 94); while (REF_NOTES.includes(note))
40
+ do velocity = r.int(30, 127); while (REF_VELOCITIES.includes(velocity))
41
+ let base = { voice, kind: 'query', note, velocity, hold: queryHold(r), pre: r.uniform(0, 0.05) }
42
+ return conds.map(cond => ({ ...base, cond, seed: seedOf(id, 'query', cond) }))
43
+ }
44
+
45
+ // The augmentation a row carries, from its seed (a view's note was drawn from the same seed, so
46
+ // its chain takes a derived one).
47
+ export function specOf(row, pool) {
48
+ let r = rng(row.kind === 'view' ? seedOf(row.seed, 'chain') : row.seed), o = other(pool, row.voice)
49
+ if (row.cond === 'chain' && row.kind === 'view') return chain(r, o)
50
+ return CONDITIONS[row.cond](r, o)
51
+ }
52
+
53
+ // The dry note: pre-roll of silence, then `LENGTH` seconds with the key up after `hold`.
54
+ export function note(patch, { note, velocity, hold, pre = 0 }) {
55
+ let freq = 440 * 2 ** ((note - 69) / 12), held = Math.min(hold, LENGTH)
56
+ let y = dx7(freq, { patch, velocity: velocity / 127, duration: held, release: LENGTH - held, fs: FS })
57
+ let x = new Float32Array(Math.round(pre * FS) + y.length)
58
+ x.set(y, x.length - y.length)
59
+ return x
60
+ }
61
+
62
+ // the other voice's note under a mix, shifted by its offset against the target's onset
63
+ export function mixNote(patch, m, pre) {
64
+ let y = note(patch, { ...m, pre: 0 }), shift = Math.round((pre + m.offset) * FS)
65
+ let x = new Float32Array(Math.round(pre * FS) + Math.round(LENGTH * FS))
66
+ for (let i = 0; i < x.length; i++) { let j = i - shift; if (j >= 0 && j < y.length) x[i] = y[j] }
67
+ return x
68
+ }
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """Train the timbre encoder on DX7 voices: supervised contrastive, on MPS.
3
+
4
+ python scripts/train.py --run NAME [--kind cqt] [--steps 30000] [--limit VOICES]
5
+
6
+ Reads the feature sets scripts/factory.js writes (<data>/features/{train,dev}.json and
7
+ .<kind>.f16) and writes ~/.cache/audiojs/neural/timbre/runs/NAME/{best.pt,progress.json}.
8
+
9
+ Each step holds 12 voices, each with one of its 8 nearest training voices by parameters (the
10
+ near-copies retrieval must tell apart, gpu-font's pairing of each face with a near design), 4
11
+ views apiece. Loss: supervised contrastive over the batch's views (Khosla et al., NeurIPS 2020,
12
+ L_out, temperature 0.1), a margin softmax of each view against a proxy per training voice
13
+ (CosFace: Wang et al., CVPR 2018; scale 30 and margin 0.15 as gpu-font's style encoder), and the
14
+ voice's algorithm (1-32) read linearly from the unit embedding (weight 0.2), the DX7's operator
15
+ topology. The checkpoint kept is the best development top-1, averaged over clean and augmented
16
+ queries against a catalog of development voices.
17
+ """
18
+ import argparse, json, math, os, threading, queue, time
19
+ from pathlib import Path
20
+ import numpy as np
21
+ import torch
22
+ from torch import nn
23
+ from torch.nn import functional as F
24
+
25
+ DATA = Path(os.environ.get('TIMBRE_DATA', Path.home() / '.cache/audiojs/data/dx7'))
26
+ RUNS = Path(os.environ.get('AUDIO_NEURAL_CACHE', Path.home() / '.cache/audiojs/neural')) / 'timbre/runs'
27
+ TEMPERATURE, SCALE, MARGIN, HEAD = 0.1, 30.0, 0.15, 0.2
28
+ SILENT = 2 ** -13 # dry peak below 4 int16 steps: nothing to recognize
29
+
30
+
31
+ class Encoder(nn.Module):
32
+ """Log-feature patch [B, 1, bands, frames] -> unit embedding [B, dims]. Four conv stages with 2x2
33
+ max pooling, a 1x1 conv down to `reduce` channels, then mean and max over time of each
34
+ (channel, band) cell and two linear layers. Frequency keeps its position to the end: brightness
35
+ is identity, not noise."""
36
+ def __init__(self, bands=96, dims=128, widths=(32, 64, 128, 256), reduce=96, hidden=512):
37
+ super().__init__()
38
+ layers, c = [], 1
39
+ for i, w in enumerate(widths):
40
+ for _ in range(1 if i == 0 else 2):
41
+ layers += [nn.Conv2d(c, w, 3, padding=1, bias=False), nn.BatchNorm2d(w), nn.ReLU(inplace=True)]
42
+ c = w
43
+ layers.append(nn.MaxPool2d(2))
44
+ layers += [nn.Conv2d(c, reduce, 1, bias=False), nn.BatchNorm2d(reduce), nn.ReLU(inplace=True)]
45
+ self.features = nn.Sequential(*layers)
46
+ cells = reduce * (bands // 2 ** len(widths))
47
+ self.head = nn.Sequential(nn.Linear(2 * cells, hidden), nn.BatchNorm1d(hidden), nn.ReLU(inplace=True), nn.Linear(hidden, dims))
48
+ self.config = {'bands': bands, 'dims': dims, 'widths': list(widths), 'reduce': reduce, 'hidden': hidden}
49
+
50
+ def forward(self, x):
51
+ h = self.features(x) # [B, C, F', T']
52
+ h = h.flatten(1, 2) # [B, C*F', T']
53
+ h = torch.cat([h.mean(2), h.amax(2)], 1) # time pooled
54
+ return F.normalize(self.head(h), dim=1)
55
+
56
+
57
+ class Store:
58
+ """A feature set on disk, read row by row with pread: the float16 file stays out of memory."""
59
+ def __init__(self, name, kind):
60
+ meta = json.loads((DATA / f'features/{name}.json').read_text())
61
+ f = meta['features'][kind]
62
+ self.shape, self.size = (f['bands'], f['frames']), f['bands'] * f['frames'] * 2
63
+ self.rows = [dict(zip(meta['keys'], r)) for r in meta['rows']]
64
+ self.fd = os.open(DATA / f'features/{name}.{kind}.f16', os.O_RDONLY)
65
+ stats = np.fromfile(DATA / f'features/{name}.stats.f32', np.float32).reshape(-1, 2)
66
+ self.audible = stats[:, 0] >= SILENT
67
+
68
+ def read(self, index):
69
+ out = np.empty((len(index), *self.shape), np.float16)
70
+ for k, i in enumerate(index):
71
+ out[k] = np.frombuffer(os.pread(self.fd, self.size, int(i) * self.size), np.float16).reshape(self.shape)
72
+ return out
73
+
74
+
75
+ def voices():
76
+ return json.loads((DATA / 'voices.json').read_text())['voices']
77
+
78
+
79
+ def canonical(patches):
80
+ """The sound-key bytes of scripts/voices.js: engine-ignored parameters zeroed."""
81
+ p = patches[:, :145].copy()
82
+ for op in range(6): p[:, op * 21 + 14] = 0
83
+ p[:, 140] = 0; p[:, 136] = 0
84
+ lfo = (p[:, 139] == 0) | (p[:, 143] == 0); p[lfo, 137:144] = 0
85
+ flat = (p[:, 130] == p[:, 131]) & (p[:, 131] == p[:, 132]) & (p[:, 132] == p[:, 133]); p[flat, 126:130] = 0
86
+ return p
87
+
88
+
89
+ def neighbours(ids, k=8, device='mps'):
90
+ """The k nearest training voices of each by differing parameters, computed on the GPU in blocks."""
91
+ patches = np.fromfile(DATA / 'patches.u8', np.uint8).reshape(-1, 155)
92
+ p = torch.from_numpy(canonical(patches[ids]).astype(np.int16)).to(device)
93
+ out = []
94
+ for i in range(0, len(p), 64):
95
+ d = (p[i:i + 64, None, :] != p[None, :, :]).sum(2).float()
96
+ d[torch.arange(len(d)), torch.arange(i, i + len(d))] = 1e9
97
+ out.append(d.topk(k, largest=False).indices.cpu())
98
+ return torch.cat(out).numpy()
99
+
100
+
101
+ class Batches:
102
+ """Background reader: `anchors` voices a step, each with one parameter neighbour, `views` views each."""
103
+ def __init__(self, store, voice_rows, neigh, anchors, views, seed):
104
+ self.store, self.voice_rows, self.neigh, self.anchors, self.views = store, voice_rows, neigh, anchors, views
105
+ self.rng = np.random.default_rng(seed); self.q = queue.Queue(maxsize=6); self.stop = False
106
+ self.thread = threading.Thread(target=self.run, daemon=True); self.thread.start()
107
+
108
+ def one(self):
109
+ n = len(self.voice_rows); chosen = []
110
+ seen = set()
111
+ for a in self.rng.permutation(n):
112
+ if len(chosen) >= 2 * self.anchors: break
113
+ b = int(self.rng.choice(self.neigh[a]))
114
+ if a in seen or b in seen: continue
115
+ chosen += [a, b]; seen |= {a, b}
116
+ rows, labels = [], []
117
+ for v in chosen:
118
+ r = self.voice_rows[v]
119
+ rows += list(self.rng.choice(r, self.views, replace=len(r) < self.views)); labels += [v] * self.views
120
+ return torch.from_numpy(self.store.read(rows)), torch.tensor(labels)
121
+
122
+ def run(self):
123
+ while not self.stop: self.q.put(self.one())
124
+
125
+ def __next__(self): return self.q.get()
126
+
127
+
128
+ def supcon(z, labels, t=TEMPERATURE):
129
+ logits = (z @ z.T) / t
130
+ self_mask = torch.eye(len(z), dtype=torch.bool, device=z.device)
131
+ logits = logits.masked_fill(self_mask, -1e4)
132
+ log_prob = logits - torch.logsumexp(logits, 1, keepdim=True)
133
+ pos = (labels[:, None] == labels[None, :]) & ~self_mask
134
+ return (-(log_prob * pos).sum(1) / pos.sum(1).clamp(min=1)).mean()
135
+
136
+
137
+ @torch.no_grad()
138
+ def embed(model, store, rows, device, batch=128):
139
+ model.eval(); out = []
140
+ for i in range(0, len(rows), batch):
141
+ x = torch.from_numpy(store.read(rows[i:i + batch])).to(device).float()[:, None]
142
+ out.append(model(x).float().cpu())
143
+ return torch.cat(out)
144
+
145
+
146
+ @torch.no_grad()
147
+ def evaluate(model, store, device):
148
+ """Catalog: each development voice's mean reference embedding. Queries: one clean and one
149
+ augmented note per voice at a note and velocity no reference uses."""
150
+ rows = store.rows; ok = store.audible
151
+ refs = [i for i, r in enumerate(rows) if r['kind'] == 'ref' and ok[i]]
152
+ e = embed(model, store, refs, device); owners = np.array([rows[i]['voice'] for i in refs])
153
+ ids = np.unique(owners); pos = {v: k for k, v in enumerate(ids)}
154
+ cat = torch.zeros(len(ids), e.shape[1]).index_add_(0, torch.tensor([pos[o] for o in owners]), e)
155
+ cat = F.normalize(cat, dim=1)
156
+ report = {}
157
+ for cond in ('clean', 'chain'):
158
+ q = [i for i, r in enumerate(rows) if r['kind'] == 'query' and r['cond'] == cond and ok[i] and r['voice'] in pos]
159
+ qe = embed(model, store, q, device); truth = torch.tensor([pos[rows[i]['voice']] for i in q])
160
+ rank = (qe @ cat.T).argsort(1, descending=True)
161
+ hit = rank == truth[:, None]
162
+ report[cond] = {'top1': hit[:, :1].any(1).float().mean().item(), 'top5': hit[:, :5].any(1).float().mean().item(), 'n': len(q)}
163
+ report['catalog'] = len(ids)
164
+ report['select'] = (report['clean']['top1'] + report['chain']['top1']) / 2
165
+ return report
166
+
167
+
168
+ def main():
169
+ ap = argparse.ArgumentParser()
170
+ ap.add_argument('--run', required=True)
171
+ ap.add_argument('--kind', default='cqt')
172
+ ap.add_argument('--train', default='train', help='feature set to train on (scripts/factory.js --name)')
173
+ ap.add_argument('--steps', type=int, default=30000)
174
+ ap.add_argument('--limit', type=int, default=0, help='train on the first N training voices of the set')
175
+ ap.add_argument('--anchors', type=int, default=12, help='voices a step, each with a parameter neighbour: 12 x 2 x 4 views = 96, what 3 GB of MPS memory holds with room')
176
+ ap.add_argument('--views', type=int, default=4)
177
+ ap.add_argument('--lr', type=float, default=2e-3)
178
+ ap.add_argument('--proxy', type=float, default=1.0, help='weight of the proxy loss, 0 for supervised contrastive alone')
179
+ ap.add_argument('--check', type=int, default=2500)
180
+ ap.add_argument('--seed', type=int, default=1)
181
+ a = ap.parse_args()
182
+
183
+ torch.set_num_threads(4); torch.manual_seed(a.seed)
184
+ device = 'mps'
185
+ torch.mps.set_per_process_memory_fraction(min(1.0, 3e9 / torch.mps.recommended_max_memory()))
186
+ out = RUNS / a.run; out.mkdir(parents=True, exist_ok=True)
187
+
188
+ train, dev = Store(a.train, a.kind), Store('dev', a.kind)
189
+ meta = voices()
190
+ by_voice = {}
191
+ for i, r in enumerate(train.rows):
192
+ if train.audible[i]: by_voice.setdefault(r['voice'], []).append(i)
193
+ ids = sorted(v for v, rs in by_voice.items() if len(rs) >= 2)
194
+ if a.limit: ids = ids[:a.limit]
195
+ voice_rows = [np.array(by_voice[v]) for v in ids]
196
+ algorithm = torch.tensor([meta[v]['algorithm'] - 1 for v in ids])
197
+ neigh = neighbours(np.array(ids), device=device)
198
+ print(f'{len(ids)} training voices, {sum(map(len, voice_rows))} views; dev {len(dev.rows)} rows', flush=True)
199
+
200
+ bands = train.shape[0]
201
+ model = Encoder(bands).to(device)
202
+ heads = nn.Linear(128, 32).to(device)
203
+ proxies = nn.Parameter(torch.randn(len(ids), 128, device=device) * 0.01)
204
+ params = sum(p.numel() for p in model.parameters())
205
+ print(f'encoder {params} parameters', flush=True)
206
+ opt = torch.optim.AdamW([{'params': model.parameters(), 'lr': a.lr, 'weight_decay': 1e-4},
207
+ {'params': heads.parameters(), 'lr': a.lr, 'weight_decay': 1e-4},
208
+ {'params': [proxies], 'lr': 10 * a.lr, 'weight_decay': 0}])
209
+ sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1, (s + 1) / 500) * (0.02 + 0.98 * 0.5 * (1 + math.cos(math.pi * min(s, a.steps) / a.steps))))
210
+ batches = Batches(train, voice_rows, neigh, a.anchors, a.views, a.seed)
211
+ history, best, t0, running = [], -1, time.time(), {}
212
+
213
+ def check(step):
214
+ nonlocal best
215
+ torch.mps.empty_cache()
216
+ rep = evaluate(model, dev, device); rep['step'] = step; rep['seconds'] = round(time.time() - t0)
217
+ torch.mps.empty_cache()
218
+ history.append(rep)
219
+ if rep['select'] > best:
220
+ best = rep['select']
221
+ torch.save({'state': {k: v.cpu() for k, v in model.state_dict().items()}, 'config': model.config,
222
+ 'heads': {k: v.cpu() for k, v in heads.state_dict().items()}, 'step': step, 'kind': a.kind,
223
+ 'features': json.loads((DATA / f'features/{a.train}.json').read_text())['features'][a.kind]}, out / 'best.pt')
224
+ (out / 'progress.json').write_text(json.dumps({'args': vars(a), 'voices': len(ids), 'parameters': params, 'best': best, 'history': history}, indent=1))
225
+ print(f"check {step}: clean top1 {rep['clean']['top1']:.3f} top5 {rep['clean']['top5']:.3f} | chain top1 {rep['chain']['top1']:.3f} top5 {rep['chain']['top5']:.3f} "
226
+ f"({rep['catalog']} voices, {rep['seconds']}s, {torch.mps.driver_allocated_memory() / 2**30:.1f} GB)", flush=True)
227
+
228
+ try:
229
+ for step in range(1, a.steps + 1):
230
+ model.train(); heads.train()
231
+ x, labels = next(batches)
232
+ x = x.to(device, non_blocking=True).float()[:, None]; labels = labels.to(device)
233
+ with torch.autocast('mps', dtype=torch.bfloat16):
234
+ z = model(x).float()
235
+ con = supcon(z, labels)
236
+ cos = z @ F.normalize(proxies, dim=1).T
237
+ onehot = F.one_hot(labels, len(ids)).bool()
238
+ prox = F.cross_entropy(SCALE * (cos - MARGIN * onehot), labels) if a.proxy else z.sum() * 0
239
+ alg = F.cross_entropy(heads(z) * 10, algorithm.to(device)[labels])
240
+ loss = con + a.proxy * prox + HEAD * alg
241
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
242
+ for k, v in (('supcon', con), ('proxy', prox), ('algorithm', alg)): running[k] = running.get(k, 0) + v.detach()
243
+ if step % 250 == 0:
244
+ print(f'step {step}: ' + ' '.join(f'{k} {float(v) / 250:.3f}' for k, v in running.items()) + f' ({time.time() - t0:.0f}s)', flush=True); running = {}
245
+ torch.mps.empty_cache()
246
+ if step % a.check == 0 or step == a.steps: check(step)
247
+ finally:
248
+ batches.stop = True
249
+
250
+
251
+ if __name__ == '__main__':
252
+ main()