@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.
- 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,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
|
+
}
|
package/scripts/train.py
ADDED
|
@@ -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()
|