@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,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 = globalThis.process?.release?.name === 'node' // Node, Bun, Deno: not a process polyfill (esm.sh's, a bundler's), whose versions.node is set
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
+ }