@audio/mir-chroma 1.0.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/chroma.js +139 -0
- package/package.json +34 -0
package/chroma.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import rfft from 'fourier-transform'
|
|
2
|
+
|
|
3
|
+
const PITCH_MIN = 24 // MIDI C1
|
|
4
|
+
const PITCH_MAX = 96 // MIDI C7
|
|
5
|
+
const A4_MIDI = 69
|
|
6
|
+
const A4_HZ = 440
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Chroma (pitch-class profile) feature — a 12-D vector where each bin holds
|
|
10
|
+
* the energy attributed to one pitch class (C, C♯, …, B).
|
|
11
|
+
*
|
|
12
|
+
* Two variants:
|
|
13
|
+
*
|
|
14
|
+
* - **'pcp'** (default) — classical Fujishima (1999). Each spectral bin is
|
|
15
|
+
* mapped to its nearest pitch class and energies are accumulated.
|
|
16
|
+
* - **'nnls'** — Mauch & Dixon (2010) NNLS Chroma. Fits the observed spectrum
|
|
17
|
+
* as a nonnegative combination of synthetic pitch-tone profiles (fundamental
|
|
18
|
+
* plus geometrically decaying overtones). Much cleaner on polyphonic audio,
|
|
19
|
+
* suppresses octave and harmonic confusion.
|
|
20
|
+
*
|
|
21
|
+
* @param {Float32Array | Float64Array} data - window, length must be a power of 2
|
|
22
|
+
* @param {{fs?: number, method?: 'pcp'|'nnls', minFreq?: number, maxFreq?: number, harmonics?: number, iterations?: number}} [params]
|
|
23
|
+
* @returns {Float64Array} length-12 chroma vector, L1-normalized
|
|
24
|
+
*/
|
|
25
|
+
export default function chroma(data, params) {
|
|
26
|
+
let fs = params?.fs || 44100
|
|
27
|
+
let method = params?.method ?? 'pcp'
|
|
28
|
+
let minFreq = params?.minFreq ?? 65 // ~C2
|
|
29
|
+
let maxFreq = params?.maxFreq ?? 2093 // ~C7
|
|
30
|
+
let N = data.length
|
|
31
|
+
if ((N & (N - 1)) !== 0) throw new Error('chroma: window length must be a power of 2')
|
|
32
|
+
|
|
33
|
+
let spec = rfft(data)
|
|
34
|
+
let half = spec.length
|
|
35
|
+
let binHz = fs / N
|
|
36
|
+
|
|
37
|
+
if (method === 'pcp') return pcp(spec, half, binHz, minFreq, maxFreq)
|
|
38
|
+
if (method === 'nnls') return nnls(spec, half, binHz, params?.harmonics ?? 8, params?.iterations ?? 30)
|
|
39
|
+
throw new Error(`chroma: unknown method "${method}"`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pcp(spec, half, binHz, minFreq, maxFreq) {
|
|
43
|
+
let out = new Float64Array(12)
|
|
44
|
+
for (let b = 1; b < half; b++) {
|
|
45
|
+
let f = b * binHz
|
|
46
|
+
if (f < minFreq || f > maxFreq) continue
|
|
47
|
+
let midi = A4_MIDI + 12 * Math.log2(f / A4_HZ)
|
|
48
|
+
let pc = ((Math.round(midi) % 12) + 12) % 12
|
|
49
|
+
out[pc] += spec[b] * spec[b] // power
|
|
50
|
+
}
|
|
51
|
+
normalize(out)
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// cached pitch dictionary per (half, binHz, harmonics)
|
|
56
|
+
let dictCache = new Map()
|
|
57
|
+
function pitchDict(half, binHz, harmonics) {
|
|
58
|
+
let key = `${half}|${binHz}|${harmonics}`
|
|
59
|
+
let cached = dictCache.get(key)
|
|
60
|
+
if (cached) return cached
|
|
61
|
+
|
|
62
|
+
let P = PITCH_MAX - PITCH_MIN + 1
|
|
63
|
+
let D = new Array(P)
|
|
64
|
+
let sigma = 0.5 // Gaussian width in semitones
|
|
65
|
+
for (let p = 0; p < P; p++) {
|
|
66
|
+
let col = new Float64Array(half)
|
|
67
|
+
let f0 = A4_HZ * Math.pow(2, (PITCH_MIN + p - A4_MIDI) / 12)
|
|
68
|
+
for (let h = 1; h <= harmonics; h++) {
|
|
69
|
+
let fh = h * f0
|
|
70
|
+
if (fh >= (half - 1) * binHz) break
|
|
71
|
+
let w = 1 / h // 1/h amplitude decay
|
|
72
|
+
// Gaussian lobe in log-frequency centered at fh
|
|
73
|
+
let bc = fh / binHz
|
|
74
|
+
let bLo = Math.max(1, Math.floor(bc - 6))
|
|
75
|
+
let bHi = Math.min(half - 1, Math.ceil(bc + 6))
|
|
76
|
+
for (let b = bLo; b <= bHi; b++) {
|
|
77
|
+
let fBin = b * binHz
|
|
78
|
+
if (fBin <= 0) continue
|
|
79
|
+
let semis = 12 * Math.log2(fBin / fh)
|
|
80
|
+
col[b] += w * Math.exp(-0.5 * (semis / sigma) ** 2)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// normalize column
|
|
84
|
+
let colSum = 0
|
|
85
|
+
for (let b = 0; b < half; b++) colSum += col[b] * col[b]
|
|
86
|
+
if (colSum > 0) {
|
|
87
|
+
let s = 1 / Math.sqrt(colSum)
|
|
88
|
+
for (let b = 0; b < half; b++) col[b] *= s
|
|
89
|
+
}
|
|
90
|
+
D[p] = col
|
|
91
|
+
}
|
|
92
|
+
dictCache.set(key, D)
|
|
93
|
+
return D
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function nnls(spec, half, binHz, harmonics, iterations) {
|
|
97
|
+
let D = pitchDict(half, binHz, harmonics)
|
|
98
|
+
let P = D.length
|
|
99
|
+
|
|
100
|
+
// observation: √spectrum for better loudness scaling
|
|
101
|
+
let s = new Float64Array(half)
|
|
102
|
+
for (let i = 0; i < half; i++) s[i] = Math.sqrt(spec[i])
|
|
103
|
+
|
|
104
|
+
// multiplicative NMF update: a ← a · (Dᵀ s) / (Dᵀ D a + ε)
|
|
105
|
+
let a = new Float64Array(P).fill(1)
|
|
106
|
+
let eps = 1e-9
|
|
107
|
+
for (let it = 0; it < iterations; it++) {
|
|
108
|
+
// reconstruction r = D a
|
|
109
|
+
let r = new Float64Array(half)
|
|
110
|
+
for (let p = 0; p < P; p++) {
|
|
111
|
+
let ap = a[p]
|
|
112
|
+
if (ap === 0) continue
|
|
113
|
+
let col = D[p]
|
|
114
|
+
for (let b = 0; b < half; b++) r[b] += ap * col[b]
|
|
115
|
+
}
|
|
116
|
+
// update each a[p]
|
|
117
|
+
for (let p = 0; p < P; p++) {
|
|
118
|
+
let col = D[p]
|
|
119
|
+
let num = 0, den = 0
|
|
120
|
+
for (let b = 0; b < half; b++) {
|
|
121
|
+
num += col[b] * s[b]
|
|
122
|
+
den += col[b] * r[b]
|
|
123
|
+
}
|
|
124
|
+
a[p] = a[p] * num / (den + eps)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// fold into chroma: sum activations per pitch class
|
|
129
|
+
let out = new Float64Array(12)
|
|
130
|
+
for (let p = 0; p < P; p++) out[(PITCH_MIN + p) % 12] += a[p]
|
|
131
|
+
normalize(out)
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalize(v) {
|
|
136
|
+
let sum = 0
|
|
137
|
+
for (let x of v) sum += x
|
|
138
|
+
if (sum > 0) for (let i = 0; i < v.length; i++) v[i] /= sum
|
|
139
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/mir-chroma",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Chroma (pitch-class profile) feature — a 12-D vector where each bin holds",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "chroma.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./chroma.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"chroma.js"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"fourier-transform": "^2.2.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"audio",
|
|
20
|
+
"dsp",
|
|
21
|
+
"mir",
|
|
22
|
+
"chroma",
|
|
23
|
+
"pcp",
|
|
24
|
+
"nnls"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|