@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,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Every DX7 voice in a folder of sysex files, deduplicated by the sound it makes and split by
|
|
3
|
+
// cartridge, for training and benchmarking the timbre encoder.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/voices.js [dir] dir: default ~/.cache/audiojs/data/dx7
|
|
6
|
+
//
|
|
7
|
+
// Reads every *.syx under dir (sources.json names the subfolders and their terms), writes
|
|
8
|
+
// dir/voices.json one row per distinct sound: name, cartridges it appears in, split, algorithm
|
|
9
|
+
// dir/patches.u8 155 bytes a row, the VCED voice as first seen
|
|
10
|
+
//
|
|
11
|
+
// Sound key: the 145 voice parameters (names excluded) with what the MSFA engine ignores zeroed
|
|
12
|
+
// (synth-dx7 README, "Not modeled"): amplitude modulation (AMS, AMD), oscillator key sync, the LFO
|
|
13
|
+
// when its pitch depth or sensitivity is 0, pitch-EG rates when its four levels are equal. Then
|
|
14
|
+
// sounds whose raw engine output is identical at four notes and velocities merge too (a silent
|
|
15
|
+
// modulator's envelope, say), and sounds that stay below -78 dBFS there are marked silent.
|
|
16
|
+
//
|
|
17
|
+
// Split by cartridge, never by voice: a cartridge's role comes from a hash of its file, and a
|
|
18
|
+
// sound in any training cartridge is a training sound, in any development cartridge a
|
|
19
|
+
// development one, so held-out sounds appear only in held-out cartridges. (Grouping cartridges
|
|
20
|
+
// that share a sound chains 3,931 of 4,115 into one group through compilations, so the hash is
|
|
21
|
+
// per cartridge.) Yamaha's factory and VRC cartridges always train: they are the catalog a user
|
|
22
|
+
// queries, and the held-out read must come from cartridges the encoder never saw. Near-copies
|
|
23
|
+
// are flagged: `near` is the fewest parameters in which a sound differs from any training sound.
|
|
24
|
+
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
import os from 'node:os'
|
|
29
|
+
import { parse, render } from '@audio/synth-dx7'
|
|
30
|
+
|
|
31
|
+
const dir = process.argv[2] ?? path.join(os.homedir(), '.cache/audiojs/data/dx7')
|
|
32
|
+
const ROLES = [['train', 75], ['dev', 5], ['heldout', 20]] // percent of cartridge groups
|
|
33
|
+
const ALWAYS_TRAIN = ['factory/', 'vrc/']
|
|
34
|
+
|
|
35
|
+
// Every DX7 voice message in a file: VMEM banks (format 9) and VCED voices (format 0) one by one,
|
|
36
|
+
// so a DX7II file's performance and supplement messages don't hide its voices. The byte count is
|
|
37
|
+
// not trusted: a tenth of circulating banks write 10 00 (hexadecimal 0x1000) where the manual's
|
|
38
|
+
// 7-bit count is 20 00; their 4096 data bytes are intact.
|
|
39
|
+
function voicesOf(bytes) {
|
|
40
|
+
if (bytes[0] !== 0xf0) try { return parse(bytes) } catch { return [] }
|
|
41
|
+
let out = []
|
|
42
|
+
for (let i = 0; i + 6 < bytes.length; i++) {
|
|
43
|
+
if (bytes[i] !== 0xf0 || bytes[i + 1] !== 0x43) continue
|
|
44
|
+
let format = bytes[i + 3], size = format === 9 ? 4096 : format === 0 ? 155 : 0
|
|
45
|
+
if (!size || i + 6 + size > bytes.length) continue
|
|
46
|
+
try { out.push(...parse(bytes.subarray(i + 6, i + 6 + size))) } catch {}
|
|
47
|
+
i += 6 + size - 1
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const soundKey = patch => createHash('sha1').update(canonical(patch)).digest('hex').slice(0, 16)
|
|
53
|
+
|
|
54
|
+
function canonical(patch) {
|
|
55
|
+
let p = Uint8Array.from(patch.subarray(0, 145))
|
|
56
|
+
for (let op = 0; op < 6; op++) p[op * 21 + 14] = 0 // AMS
|
|
57
|
+
p[140] = 0 // AMD
|
|
58
|
+
p[136] = 0 // oscillator key sync
|
|
59
|
+
if (p[139] === 0 || p[143] === 0) p.fill(0, 137, 144) // LFO inaudible: speed, delay, PMD, AMD, sync, wave, PMS
|
|
60
|
+
if (p[130] === p[131] && p[131] === p[132] && p[132] === p[133]) p.fill(0, 126, 130) // flat pitch EG: rates unused
|
|
61
|
+
return p
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function* files(root) {
|
|
65
|
+
for (let name of readdirSync(root).sort()) {
|
|
66
|
+
let f = path.join(root, name)
|
|
67
|
+
if (statSync(f).isDirectory()) yield* files(f)
|
|
68
|
+
else if (/\.syx$/i.test(name)) yield f
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const hash32 = s => parseInt(createHash('sha1').update(s).digest('hex').slice(0, 8), 16)
|
|
73
|
+
|
|
74
|
+
// Raw int32 output at four keys and velocities, 0.3 s held and 0.2 s released: equal hashes mean
|
|
75
|
+
// equal samples. Peak in int16 steps (2^13 of MSFA's 2^28 full scale is 2^-15 of it, one LSB).
|
|
76
|
+
const PROBES = [[48, 40], [60, 100], [72, 127], [84, 70]]
|
|
77
|
+
function fingerprint(patch) {
|
|
78
|
+
let h = createHash('sha1'), peak = 0
|
|
79
|
+
for (let [note, velocity] of PROBES) {
|
|
80
|
+
let y = render(patch, { note: note + patch[144] - 24, velocity, duration: 0.3, release: 0.2 })
|
|
81
|
+
h.update(new Uint8Array(y.buffer, y.byteOffset, y.byteLength))
|
|
82
|
+
for (let v of y) peak = Math.max(peak, Math.abs(v))
|
|
83
|
+
}
|
|
84
|
+
return { hash: h.digest('hex').slice(0, 16), peak: peak / 2 ** 13 }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// fewest differing parameters (sound keys' canonical bytes) from each of `rows` to any of `pool`
|
|
88
|
+
function nearest(rows, pool) {
|
|
89
|
+
return rows.map(a => {
|
|
90
|
+
let best = 145
|
|
91
|
+
for (let b of pool) {
|
|
92
|
+
let d = 0
|
|
93
|
+
for (let i = 0; i < 145 && d < best; i++) if (a[i] !== b[i]) d++
|
|
94
|
+
if (d < best) best = d
|
|
95
|
+
}
|
|
96
|
+
return best
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function main() {
|
|
101
|
+
let sources = JSON.parse(readFileSync(path.join(dir, 'sources.json'), 'utf8')).sources
|
|
102
|
+
let carts = [], byContent = new Map(), unreadable = []
|
|
103
|
+
for (let src of sources) for (let f of files(path.join(dir, src.dir))) {
|
|
104
|
+
let rel = path.relative(dir, f), voices = voicesOf(readFileSync(f))
|
|
105
|
+
if (!voices.length) { unreadable.push(rel); continue }
|
|
106
|
+
let content = createHash('sha1').update(Buffer.concat(voices.map(v => v.patch))).digest('hex')
|
|
107
|
+
let c = byContent.get(content)
|
|
108
|
+
if (c) { c.copies.push(rel); continue }
|
|
109
|
+
c = { file: rel, source: src.id, copies: [], voices }
|
|
110
|
+
byContent.set(content, c); carts.push(c)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// distinct sounds, and the cartridges holding each
|
|
114
|
+
let sounds = new Map()
|
|
115
|
+
carts.forEach((c, ci) => c.voices.forEach((v, vi) => {
|
|
116
|
+
let key = soundKey(v.patch), s = sounds.get(key)
|
|
117
|
+
if (!s) sounds.set(key, s = { key, name: v.name, patch: v.patch, at: [] })
|
|
118
|
+
if (!s.at.some(a => a[0] === ci)) s.at.push([ci, vi])
|
|
119
|
+
}))
|
|
120
|
+
|
|
121
|
+
// identical renders are one sound
|
|
122
|
+
let byRender = new Map(), silent = new Set()
|
|
123
|
+
for (let [key, s] of sounds) {
|
|
124
|
+
let { hash, peak } = fingerprint(s.patch)
|
|
125
|
+
if (peak < 4) silent.add(key)
|
|
126
|
+
let first = byRender.get(hash)
|
|
127
|
+
if (!first) { byRender.set(hash, s); continue }
|
|
128
|
+
for (let a of s.at) if (!first.at.some(b => b[0] === a[0])) first.at.push(a)
|
|
129
|
+
sounds.delete(key)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let role = carts.map(c => {
|
|
133
|
+
if (ALWAYS_TRAIN.some(p => c.file.startsWith(p))) return 'train'
|
|
134
|
+
let h = hash32(c.file) % 100
|
|
135
|
+
for (let acc = 0, k = 0; k < ROLES.length; k++) if (h < (acc += ROLES[k][1])) return ROLES[k][0]
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
let rows = [], bytes = []
|
|
139
|
+
for (let s of sounds.values()) {
|
|
140
|
+
let roles = new Set(s.at.map(([ci]) => role[ci]))
|
|
141
|
+
let split = roles.has('train') ? 'train' : roles.has('dev') ? 'dev' : 'heldout'
|
|
142
|
+
let at = s.at.find(([cj]) => role[cj] === split), [ci, vi] = at, c = carts[ci], v = c.voices[vi]
|
|
143
|
+
rows.push({ id: s.key, name: v.name, split, algorithm: v.patch[134] + 1, source: c.source, cartridge: c.file, index: vi,
|
|
144
|
+
also: s.at.filter(a => a !== at).map(([cj, vj]) => `${carts[cj].file}#${vj}`) })
|
|
145
|
+
bytes.push(v.patch)
|
|
146
|
+
}
|
|
147
|
+
let train = rows.map((r, i) => r.split === 'train' ? canonical(bytes[i]) : null).filter(Boolean)
|
|
148
|
+
let other = rows.map((r, i) => [r, i]).filter(([r]) => r.split !== 'train')
|
|
149
|
+
nearest(other.map(([, i]) => canonical(bytes[i])), train).forEach((d, k) => { other[k][0].near = d })
|
|
150
|
+
rows.forEach(r => { if (silent.has(r.id)) r.silent = true })
|
|
151
|
+
writeFileSync(path.join(dir, 'patches.u8'), Buffer.concat(bytes))
|
|
152
|
+
let count = r => rows.filter(x => x.split === r).length
|
|
153
|
+
let summary = {
|
|
154
|
+
files: carts.length + carts.reduce((n, c) => n + c.copies.length, 0) + unreadable.length, unreadable: unreadable.length,
|
|
155
|
+
cartridges: carts.length, voices: carts.reduce((n, c) => n + c.voices.length, 0), sounds: rows.length,
|
|
156
|
+
heldoutCartridges: role.filter(r => r === 'heldout').length,
|
|
157
|
+
train: count('train'), dev: count('dev'), heldout: count('heldout'), silent: silent.size,
|
|
158
|
+
heldoutNearCopies: rows.filter(r => r.split === 'heldout' && r.near <= 3).length,
|
|
159
|
+
}
|
|
160
|
+
writeFileSync(path.join(dir, 'voices.json'), JSON.stringify({ version: 1, created: new Date().toISOString().slice(0, 10), summary, voices: rows }))
|
|
161
|
+
console.log(JSON.stringify(summary))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// importable for soundKey: the key a catalog entry's `id` is, to find the voice in your own sysex
|
|
165
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The encoder in headless Chromium, on WebGPU and WASM, through this package, @audio/neural-runtime
|
|
3
|
+
// and onnxruntime-web: embeddings of scripts/export.py's parity fixtures against PyTorch's (largest
|
|
4
|
+
// difference, identical top-5).
|
|
5
|
+
//
|
|
6
|
+
// node scripts/webgpu.js [--chrome path] [--weights dir]
|
|
7
|
+
//
|
|
8
|
+
// Needs onnxruntime-web and playwright-core resolvable from here, and a Chromium with WebGPU
|
|
9
|
+
// (--chrome, else Playwright's own). weights: default ~/.cache/audiojs/neural.
|
|
10
|
+
import http from 'node:http'
|
|
11
|
+
import { readFile } from 'node:fs/promises'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
let o = {}
|
|
17
|
+
for (let a = process.argv.slice(2), i = 0; i < a.length; i += 2) o[a[i].replace(/^--/, '')] = a[i + 1]
|
|
18
|
+
const dirOf = spec => path.dirname(fileURLToPath(import.meta.resolve(spec)))
|
|
19
|
+
const roots = {
|
|
20
|
+
pkg: path.join(path.dirname(fileURLToPath(import.meta.url)), '..'),
|
|
21
|
+
runtime: dirOf('@audio/neural-runtime'), fft: dirOf('fourier-transform'), sinc: dirOf('@audio/resample-sinc'),
|
|
22
|
+
ort: path.join(dirOf('onnxruntime-web'), '..'), // dist/
|
|
23
|
+
cache: path.join(o.weights ?? process.env.AUDIO_NEURAL_CACHE ?? path.join(os.homedir(), '.cache/audiojs/neural'), 'timbre'),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PAGE = `<!doctype html><meta charset="utf-8"><title>neural-timbre</title>
|
|
27
|
+
<script type="importmap">{ "imports": {
|
|
28
|
+
"onnxruntime-web/webgpu": "/ort/dist/ort.webgpu.bundle.min.mjs", "onnxruntime-web": "/ort/dist/ort.wasm.bundle.min.mjs",
|
|
29
|
+
"@audio/neural-runtime": "/runtime/runtime.js", "@audio/resample-sinc": "/sinc/sinc.js", "fourier-transform": "/fft/index.js" } }</script>
|
|
30
|
+
<script type="module">
|
|
31
|
+
import { createEncoder, rank } from '/pkg/timbre.js'
|
|
32
|
+
let out = { adapter: !!(navigator.gpu && await navigator.gpu.requestAdapter()) }
|
|
33
|
+
let fx = await (await fetch('/cache/parity.json')).json(), { bands, frames } = fx.features, size = bands * frames
|
|
34
|
+
let raw = Uint8Array.from(atob(fx.patches), c => c.charCodeAt(0)), half = new Float16Array(raw.buffer)
|
|
35
|
+
let patches = Array.from({ length: fx.rows.length }, (_, i) => Float32Array.from(half.subarray(i * size, (i + 1) * size)))
|
|
36
|
+
for (let name of ['encoder.onnx', 'encoder.int8.onnx']) for (let backend of ['webgpu', 'wasm']) {
|
|
37
|
+
try {
|
|
38
|
+
let enc = await createEncoder({ model: new Uint8Array(await (await fetch('/cache/' + name)).arrayBuffer()), backend })
|
|
39
|
+
await enc.project(patches.slice(0, 1))
|
|
40
|
+
let t = performance.now(), e = await enc.project(patches), ms = performance.now() - t
|
|
41
|
+
enc.free()
|
|
42
|
+
let want = fx[name], max = 0
|
|
43
|
+
e.forEach((v, i) => v.forEach((x, d) => { max = Math.max(max, Math.abs(x - want.embeddings[i][d])) }))
|
|
44
|
+
let cat = { vectors: Float32Array.from(want.catalog.flat()), dimensions: want.catalog[0].length }
|
|
45
|
+
let same = e.filter((v, i) => rank(v, cat, 5).map(r => r.index).join() === want.top5[i].join()).length
|
|
46
|
+
out[name + ' ' + backend] = { maxDiff: +max.toExponential(2), top5: same + '/' + e.length, msPerNote: +(ms / e.length).toFixed(2) }
|
|
47
|
+
} catch (err) { out[name + ' ' + backend] = { error: String(err.message).slice(0, 300) } }
|
|
48
|
+
}
|
|
49
|
+
window.result = out; document.title = 'done'
|
|
50
|
+
</script>`
|
|
51
|
+
|
|
52
|
+
const TYPES = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.wasm': 'application/wasm' }
|
|
53
|
+
const server = http.createServer(async (req, res) => {
|
|
54
|
+
if (req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE) }
|
|
55
|
+
let [, root, ...rest] = decodeURIComponent(req.url.split('?')[0]).split('/')
|
|
56
|
+
try {
|
|
57
|
+
let file = path.join(roots[root], ...rest), body = await readFile(file)
|
|
58
|
+
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] ?? 'application/octet-stream' }); res.end(body)
|
|
59
|
+
} catch { res.writeHead(404); res.end() }
|
|
60
|
+
})
|
|
61
|
+
await new Promise(r => server.listen(0, r))
|
|
62
|
+
let { chromium } = await import('playwright-core')
|
|
63
|
+
let browser = await chromium.launch({ executablePath: o.chrome, headless: true, args: ['--enable-unsafe-webgpu'] })
|
|
64
|
+
try {
|
|
65
|
+
let page = await browser.newPage()
|
|
66
|
+
await page.goto(`http://localhost:${server.address().port}/`)
|
|
67
|
+
await page.waitForFunction(() => document.title === 'done', null, { timeout: 600000 })
|
|
68
|
+
console.log(JSON.stringify(await page.evaluate(() => window.result), null, 1))
|
|
69
|
+
} finally { await browser.close(); server.close() }
|
package/timbre.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Name the sound in a recording: a small encoder maps one note to a unit vector, and a catalog of
|
|
2
|
+
// reference vectors (one per preset, the mean over its rendered notes) is ranked by cosine. The
|
|
3
|
+
// encoder is trained once; catalogs are swappable, each bound to the encoder bytes that built it
|
|
4
|
+
// (gpu-font's split: the renderer labels, the network only encodes).
|
|
5
|
+
//
|
|
6
|
+
// The int8 encoder and two catalogs are hosted on Hugging Face, not in the package, so a source can
|
|
7
|
+
// come out at its owner's request (README, Catalogs); scripts/ rebuild them.
|
|
8
|
+
|
|
9
|
+
import { load, tensor, fetchModel } from '@audio/neural-runtime'
|
|
10
|
+
import { features, cqt, FEATURES } from './features.js'
|
|
11
|
+
|
|
12
|
+
export { features, cqt, FEATURES }
|
|
13
|
+
|
|
14
|
+
// The commit of audiojs/dx7-timbre this version reads, so a version always gets the files it was
|
|
15
|
+
// tested with.
|
|
16
|
+
export const REVISION = '2dd88e107183463c5a40609a92b0545d8857c635'
|
|
17
|
+
const REPO = 'audiojs/dx7-timbre'
|
|
18
|
+
const HOSTED = `https://huggingface.co/${REPO}/resolve/${REVISION}/`
|
|
19
|
+
|
|
20
|
+
// The hosted files, laid out as in the repository, each checked against its SHA-256 when read
|
|
21
|
+
const FILES = {
|
|
22
|
+
encoder: { file: 'encoder.int8.onnx', sha256: 'aaa9fae0337325fc9ae0f15c45f711073255c09264a31a9779da635ec21b8484' },
|
|
23
|
+
'dx7-factory': { file: 'catalogs/dx7-factory.json', sha256: '7ab9d48c1b90a4ec04af3aeb63737352d63f959db8b1459d6b238cea365a0900' },
|
|
24
|
+
'dx7-all': { file: 'catalogs/dx7-all.json', sha256: '0dd0fd646e8aef7e417151e8ae76b5c2bec7fde3cb531e01a3bc4e5b964e3671' },
|
|
25
|
+
}
|
|
26
|
+
export const MODEL = new URL(FILES.encoder.file, HOSTED)
|
|
27
|
+
export const catalogs = {
|
|
28
|
+
'dx7-factory': new URL(FILES['dx7-factory'].file, HOSTED),
|
|
29
|
+
'dx7-all': new URL(FILES['dx7-all'].file, HOSTED),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isNode = typeof process !== 'undefined' && !!process.versions?.node
|
|
33
|
+
// a URL scheme has two characters at least, so a Windows drive (C:\) stays a path
|
|
34
|
+
const isUrl = s => /^[a-z][a-z0-9+.-]+:/i.test(s)
|
|
35
|
+
|
|
36
|
+
// Web Crypto: global in browsers and Node 19+, node:crypto's webcrypto in Node 18
|
|
37
|
+
const subtle = async () => globalThis.crypto?.subtle ?? (await import('node:crypto')).webcrypto.subtle
|
|
38
|
+
export const sha256 = async bytes => Array.from(new Uint8Array(await (await subtle()).digest('SHA-256', bytes)), b => b.toString(16).padStart(2, '0')).join('')
|
|
39
|
+
const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v
|
|
40
|
+
export const featuresSha256 = (f = FEATURES) => sha256(new TextEncoder().encode(JSON.stringify(canonical(f))))
|
|
41
|
+
|
|
42
|
+
// Bytes of a model or catalog: bytes as given; a URL, or a path in Node, read through
|
|
43
|
+
// @audio/neural-runtime's fetchModel (file: URLs from disk).
|
|
44
|
+
async function bytesOf(src, opts) {
|
|
45
|
+
if (src instanceof Uint8Array) return src
|
|
46
|
+
if (src instanceof ArrayBuffer) return new Uint8Array(src)
|
|
47
|
+
let href = String(src)
|
|
48
|
+
if (isNode && !isUrl(href)) href = (await import('node:url')).pathToFileURL(href).href
|
|
49
|
+
return fetchModel(href, opts)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Base URL of the hosted files: opts.weights (a URL, or a directory in Node) holding a copy laid out
|
|
53
|
+
// as the repository, else the pinned revision
|
|
54
|
+
async function base(w) {
|
|
55
|
+
if (w == null) return HOSTED
|
|
56
|
+
w = String(w)
|
|
57
|
+
if (!isUrl(w)) {
|
|
58
|
+
if (!isNode) throw new Error(`neural-timbre: opts.weights must be a URL in the browser, got '${w}'`)
|
|
59
|
+
w = (await import('node:url')).pathToFileURL(w).href
|
|
60
|
+
}
|
|
61
|
+
return w.endsWith('/') ? w : w + '/'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A hosted file's bytes ('encoder', 'dx7-factory', 'dx7-all') from opts.weights or the pinned
|
|
65
|
+
// revision, refused unless of its SHA-256. A local copy is read in place; Node keeps a fetched one
|
|
66
|
+
// in the neural cache, dx7-timbre/<file>, written whole once checked and read while its SHA-256
|
|
67
|
+
// holds, so it loads offline after the first time; the browser keeps it in neural-runtime's Cache API.
|
|
68
|
+
async function hosted(key, opts) {
|
|
69
|
+
let { file, sha256: want } = FILES[key], url = await base(opts.weights) + file
|
|
70
|
+
let store = isNode && !url.startsWith('file:') ? await cached(file) : null, bytes = store?.read()
|
|
71
|
+
if (bytes && await sha256(bytes) === want) return bytes
|
|
72
|
+
try { bytes = await fetchModel(url, { progress: opts.progress, fetch: opts.fetch, cache: !store }) }
|
|
73
|
+
catch (e) { throw new Error(`neural-timbre: ${file} not read (${e.message}): opts.weights takes a URL or directory holding a copy of ${HOSTED}`) }
|
|
74
|
+
let hex = await sha256(bytes)
|
|
75
|
+
if (hex !== want) throw new Error(`neural-timbre: ${url} has SHA-256 ${hex}, expected ${want}: a partial or altered download${isNode ? '' : " (clear the 'audio-neural' cache)"}, or another build of the file`)
|
|
76
|
+
store?.write(bytes)
|
|
77
|
+
return bytes
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// <neural cache>/dx7-timbre/<file>: { read() → bytes or null, write(bytes) whole or not at all }
|
|
81
|
+
async function cached(file) {
|
|
82
|
+
let [fs, path, os] = await Promise.all([import('node:fs'), import('node:path'), import('node:os')])
|
|
83
|
+
let dest = path.join(process.env.AUDIO_NEURAL_CACHE || path.join(os.homedir(), '.cache', 'audiojs', 'neural'), REPO.split('/')[1], file)
|
|
84
|
+
return {
|
|
85
|
+
read() { try { let b = fs.readFileSync(dest); return new Uint8Array(b.buffer, b.byteOffset, b.length) } catch { return null } },
|
|
86
|
+
write(bytes) {
|
|
87
|
+
let part = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.part`
|
|
88
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
89
|
+
fs.writeFileSync(part, bytes)
|
|
90
|
+
fs.renameSync(part, dest)
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// createEncoder({ model, backend, weights }) → { embed(audio, { sampleRate }) → unit Float32Array,
|
|
96
|
+
// project(patches), binding: { encoderSha256, featuresSha256 }, free() }. model: URL, path or
|
|
97
|
+
// bytes, default the hosted encoder (MODEL), the graph the hosted catalogs are built with.
|
|
98
|
+
export async function createEncoder(opts = {}) {
|
|
99
|
+
let bytes = await (opts.model != null ? bytesOf(opts.model, opts) : hosted('encoder', opts))
|
|
100
|
+
let binding = { encoderSha256: await sha256(bytes), featuresSha256: await featuresSha256() }
|
|
101
|
+
let session = await load(bytes, { backend: opts.backend })
|
|
102
|
+
let input = session.inputs[0]?.name ?? 'features'
|
|
103
|
+
let output = session.outputs.some(o => o.name === 'embedding') ? 'embedding' : session.outputs[0]?.name
|
|
104
|
+
// prepared feature patches (bands × frames each) → unit embeddings
|
|
105
|
+
let project = async patches => {
|
|
106
|
+
let { bands, frames } = FEATURES, n = patches.length, x = new Float32Array(n * bands * frames)
|
|
107
|
+
patches.forEach((p, i) => x.set(p, i * bands * frames))
|
|
108
|
+
let e = (await session.run({ [input]: tensor(x, [n, 1, bands, frames]) }))[output].data, d = e.length / n
|
|
109
|
+
return Array.from({ length: n }, (_, i) => unit(e.subarray(i * d, (i + 1) * d)))
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
binding, project,
|
|
113
|
+
// one note (Float32Array, or channels to average) → its unit embedding
|
|
114
|
+
embed: async (audio, { sampleRate = FEATURES.sampleRate } = {}) => (await project([features(audio, { sampleRate })]))[0],
|
|
115
|
+
free: () => session.free(),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function unit(v) {
|
|
120
|
+
let s = 0
|
|
121
|
+
for (let x of v) s += x * x
|
|
122
|
+
let norm = Math.sqrt(s)
|
|
123
|
+
if (!(norm > 1e-12)) throw new Error('neural-timbre: zero embedding')
|
|
124
|
+
return Float32Array.from(v, x => x / norm)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Catalog vectors: one row per entry, `bits` a number with one scale per row (gpu-font's
|
|
128
|
+
// int8-base64 and int4-base64: signed values in a little-endian bit stream).
|
|
129
|
+
const BITS = { 'int8-base64': 8, 'int4-base64': 4 }
|
|
130
|
+
|
|
131
|
+
export function pack(values, bits) {
|
|
132
|
+
let bytes = new Uint8Array(Math.ceil(values.length * bits / 8)), mask = (1 << bits) - 1
|
|
133
|
+
for (let i = 0, bit = 0; i < values.length; i++, bit += bits) {
|
|
134
|
+
let word = (values[i] & mask) << (bit & 7)
|
|
135
|
+
bytes[bit >> 3] |= word
|
|
136
|
+
if (word > 255) bytes[(bit >> 3) + 1] |= word >> 8
|
|
137
|
+
}
|
|
138
|
+
return bytes
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function unpack(bytes, bits, count) {
|
|
142
|
+
let values = new Int8Array(count), mask = (1 << bits) - 1, sign = 1 << (bits - 1)
|
|
143
|
+
for (let i = 0, bit = 0; i < count; i++, bit += bits) {
|
|
144
|
+
let v = ((bytes[bit >> 3] | bytes[(bit >> 3) + 1] << 8) >> (bit & 7)) & mask
|
|
145
|
+
values[i] = v >= sign ? v - (1 << bits) : v
|
|
146
|
+
}
|
|
147
|
+
return values
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const toBase64 = bytes => { let s = ''; for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); return btoa(s) }
|
|
151
|
+
const fromBase64 = text => Uint8Array.from(atob(text), c => c.charCodeAt(0))
|
|
152
|
+
|
|
153
|
+
// catalog(entries, vectors, binding, info) → catalog JSON: entries are the metadata of each row
|
|
154
|
+
// ({ name, … }), vectors their unit embeddings, binding the encoder's.
|
|
155
|
+
export function catalog(entries, vectors, binding, { encoding = 'int8-base64', ...info } = {}) {
|
|
156
|
+
let bits = BITS[encoding], top = 2 ** (bits - 1) - 1, d = vectors[0]?.length
|
|
157
|
+
if (!bits) throw new Error(`neural-timbre: unknown encoding ${encoding}`)
|
|
158
|
+
if (entries.length !== vectors.length || !d) throw new Error('neural-timbre: one vector per entry')
|
|
159
|
+
let q = new Int8Array(vectors.length * d), scales = []
|
|
160
|
+
vectors.forEach((v, r) => {
|
|
161
|
+
let m = Math.max(...Array.from(v, Math.abs)) || 1, s = m / top
|
|
162
|
+
scales.push(+s.toPrecision(7))
|
|
163
|
+
for (let i = 0; i < d; i++) q[r * d + i] = Math.max(-top, Math.min(top, Math.round(v[i] / scales[r])))
|
|
164
|
+
})
|
|
165
|
+
return { version: 1, kind: 'timbre-catalog', ...binding, dimensions: d, ...info, entries,
|
|
166
|
+
vectors: { encoding, shape: [vectors.length, d], scales, data: toBase64(pack(q, bits)) } }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// readCatalog(json, binding) → { entries, vectors: Float32Array (rows unit), dimensions }; refuses a
|
|
170
|
+
// catalog built by another encoder or frontend.
|
|
171
|
+
export function readCatalog(data, binding) {
|
|
172
|
+
if (data?.kind !== 'timbre-catalog' || data.version !== 1) throw new Error('neural-timbre: not a timbre catalog')
|
|
173
|
+
if (binding && (data.encoderSha256 !== binding.encoderSha256 || data.featuresSha256 !== binding.featuresSha256))
|
|
174
|
+
throw new Error('neural-timbre: catalog was built with a different model: rebuild it with this one')
|
|
175
|
+
let { entries, dimensions: d, vectors: v } = data, bits = BITS[v?.encoding]
|
|
176
|
+
if (!bits || !Array.isArray(entries) || v.shape?.[0] !== entries.length || v.shape[1] !== d || v.scales?.length !== entries.length) throw new Error('neural-timbre: malformed catalog')
|
|
177
|
+
let bytes = fromBase64(v.data)
|
|
178
|
+
if (bytes.length !== Math.ceil(entries.length * d * bits / 8)) throw new Error('neural-timbre: truncated catalog vectors')
|
|
179
|
+
let q = unpack(bytes, bits, entries.length * d), out = new Float32Array(entries.length * d)
|
|
180
|
+
for (let r = 0; r < entries.length; r++) {
|
|
181
|
+
let s = 0
|
|
182
|
+
for (let i = 0; i < d; i++) { let x = q[r * d + i] * v.scales[r]; out[r * d + i] = x; s += x * x }
|
|
183
|
+
let n = Math.sqrt(s) || 1
|
|
184
|
+
for (let i = 0; i < d; i++) out[r * d + i] /= n
|
|
185
|
+
}
|
|
186
|
+
return { entries, vectors: out, dimensions: d }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// rank(embedding, catalog, top) → [{ index, score }] best first; ties by index
|
|
190
|
+
export function rank(e, { vectors, dimensions: d }, top = 10) {
|
|
191
|
+
let n = vectors.length / d, scores = new Float32Array(n)
|
|
192
|
+
for (let r = 0; r < n; r++) { let s = 0; for (let i = 0; i < d; i++) s += e[i] * vectors[r * d + i]; scores[r] = s }
|
|
193
|
+
let order = Array.from({ length: n }, (_, i) => i).sort((a, b) => scores[b] - scores[a] || a - b)
|
|
194
|
+
return order.slice(0, top).map(index => ({ index, score: scores[index] }))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A catalog's JSON: parsed JSON as given; a hosted catalog by name; a URL, or a path in Node.
|
|
198
|
+
async function catalogJson(c, opts) {
|
|
199
|
+
let text = bytes => JSON.parse(new TextDecoder().decode(bytes))
|
|
200
|
+
if (typeof c === 'string' && !/[/\\.:]/.test(c)) {
|
|
201
|
+
if (!Object.hasOwn(catalogs, c)) throw new Error(`neural-timbre: no catalog '${c}' is hosted (${Object.keys(catalogs).join(', ')}): pass a URL, a path or parsed JSON`)
|
|
202
|
+
return text(await hosted(c, opts))
|
|
203
|
+
}
|
|
204
|
+
return typeof c === 'string' || c instanceof URL ? text(await bytesOf(c, { cache: false })) : c
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// createMatcher(catalog, { model, backend, weights }) → { match(audio, { sampleRate, top }), catalog,
|
|
208
|
+
// encoder, free }. catalog: a hosted one by name ('dx7-factory', the default, or 'dx7-all'), a URL,
|
|
209
|
+
// a path in Node, or parsed JSON. It loads while the encoder does and must carry the encoder's binding.
|
|
210
|
+
export async function createMatcher(catalog = 'dx7-factory', opts = {}) {
|
|
211
|
+
let data = catalogJson(catalog, opts)
|
|
212
|
+
data.catch(() => {}) // awaited once the encoder is up
|
|
213
|
+
let encoder = await createEncoder(opts)
|
|
214
|
+
try {
|
|
215
|
+
let cat = readCatalog(await data, encoder.binding)
|
|
216
|
+
return {
|
|
217
|
+
catalog: cat, encoder,
|
|
218
|
+
// best first: { ...entry, score } with score the cosine, -1 to 1
|
|
219
|
+
async match(audio, { sampleRate, top = 5 } = {}) {
|
|
220
|
+
let e = await encoder.embed(audio, { sampleRate })
|
|
221
|
+
return rank(e, cat, top).map(({ index, score }) => ({ ...cat.entries[index], score }))
|
|
222
|
+
},
|
|
223
|
+
free: encoder.free,
|
|
224
|
+
}
|
|
225
|
+
} catch (e) { encoder.free(); throw e }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// identify(audio, { sampleRate, top, catalog, model, backend }) → matches; loads and frees a
|
|
229
|
+
// matcher per call.
|
|
230
|
+
export default async function identify(audio, opts = {}) {
|
|
231
|
+
let m = await createMatcher(opts.catalog, opts)
|
|
232
|
+
try { return await m.match(audio, opts) } finally { m.free() }
|
|
233
|
+
}
|