@audio/mir-chord 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.
Files changed (2) hide show
  1. package/chord.js +123 -0
  2. package/package.json +31 -0
package/chord.js ADDED
@@ -0,0 +1,123 @@
1
+ const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
2
+
3
+ /**
4
+ * 24 binary chord templates: C, C#, … B (major) then C, C#, … B (minor).
5
+ * Each template is a length-12 vector with 1 on chord tones, 0 elsewhere.
6
+ */
7
+ export const TEMPLATES = buildTemplates()
8
+
9
+ function buildTemplates() {
10
+ let T = []
11
+ // major: root, +4, +7
12
+ for (let r = 0; r < 12; r++) {
13
+ let t = new Float64Array(12)
14
+ t[r] = 1; t[(r + 4) % 12] = 1; t[(r + 7) % 12] = 1
15
+ T.push({ root: r, quality: 'maj', label: NOTE_NAMES[r], vec: t })
16
+ }
17
+ // minor: root, +3, +7
18
+ for (let r = 0; r < 12; r++) {
19
+ let t = new Float64Array(12)
20
+ t[r] = 1; t[(r + 3) % 12] = 1; t[(r + 7) % 12] = 1
21
+ T.push({ root: r, quality: 'min', label: NOTE_NAMES[r] + 'm', vec: t })
22
+ }
23
+ return T
24
+ }
25
+
26
+ /**
27
+ * Classify a single chroma frame as one of 24 major/minor triads
28
+ * via cosine similarity with binary templates (Fujishima, 1999).
29
+ *
30
+ * @param {Float64Array | Float32Array | number[]} chromaVec - length-12 chroma vector
31
+ * @param {{minConfidence?: number}} [params]
32
+ * @returns {{root: number, quality: 'maj'|'min'|'N', label: string, confidence: number}}
33
+ */
34
+ export default function chord(chromaVec, params) {
35
+ let minConf = params?.minConfidence ?? 0.3
36
+ let cNorm = l2norm(chromaVec)
37
+ if (cNorm === 0) return { root: -1, quality: 'N', label: 'N', confidence: 0 }
38
+
39
+ let best = null, bestSim = -Infinity
40
+ for (let t of TEMPLATES) {
41
+ let sim = cosine(chromaVec, t.vec, cNorm)
42
+ if (sim > bestSim) { bestSim = sim; best = t }
43
+ }
44
+ if (bestSim < minConf) return { root: -1, quality: 'N', label: 'N', confidence: bestSim }
45
+ return { root: best.root, quality: best.quality, label: best.label, confidence: bestSim }
46
+ }
47
+
48
+ /**
49
+ * Smooth a sequence of chroma frames into a chord sequence using Viterbi
50
+ * with a sticky self-transition prior. A simple stand-in for Mauch-style
51
+ * context models — works well in practice for short segments.
52
+ *
53
+ * @param {(Float64Array|Float32Array|number[])[]} frames - array of chroma vectors
54
+ * @param {{selfProb?: number}} [params]
55
+ * @returns {{root: number, quality: 'maj'|'min'|'N', label: string}[]}
56
+ */
57
+ export function smooth(frames, params) {
58
+ let selfProb = params?.selfProb ?? 0.5
59
+ let n = frames.length
60
+ if (n === 0) return []
61
+ let S = TEMPLATES.length // 24 states
62
+ let logSelf = Math.log(selfProb)
63
+ let logSwitch = Math.log((1 - selfProb) / (S - 1))
64
+
65
+ // observation log-likelihoods: cosine similarity mapped to log-prob
66
+ let logObs = (frame) => {
67
+ let norm = l2norm(frame)
68
+ let out = new Float64Array(S)
69
+ if (norm === 0) { out.fill(-1e9); return out }
70
+ for (let i = 0; i < S; i++) {
71
+ let sim = cosine(frame, TEMPLATES[i].vec, norm)
72
+ // map [-1,1] → log-prob; temperature 8 gives reasonably sharp distributions
73
+ out[i] = 8 * sim
74
+ }
75
+ return out
76
+ }
77
+
78
+ // Viterbi
79
+ let delta = logObs(frames[0])
80
+ let psi = new Array(n)
81
+ for (let t = 1; t < n; t++) {
82
+ let lo = logObs(frames[t])
83
+ let next = new Float64Array(S)
84
+ let back = new Int32Array(S)
85
+ for (let j = 0; j < S; j++) {
86
+ let bestVal = -Infinity, bestI = 0
87
+ for (let i = 0; i < S; i++) {
88
+ let v = delta[i] + (i === j ? logSelf : logSwitch)
89
+ if (v > bestVal) { bestVal = v; bestI = i }
90
+ }
91
+ next[j] = bestVal + lo[j]
92
+ back[j] = bestI
93
+ }
94
+ psi[t] = back
95
+ delta = next
96
+ }
97
+
98
+ // backtrace
99
+ let path = new Int32Array(n)
100
+ let last = 0, lastVal = -Infinity
101
+ for (let i = 0; i < S; i++) if (delta[i] > lastVal) { lastVal = delta[i]; last = i }
102
+ path[n - 1] = last
103
+ for (let t = n - 1; t > 0; t--) path[t - 1] = psi[t][path[t]]
104
+
105
+ return [...path].map(i => ({
106
+ root: TEMPLATES[i].root,
107
+ quality: TEMPLATES[i].quality,
108
+ label: TEMPLATES[i].label,
109
+ }))
110
+ }
111
+
112
+ function l2norm(v) {
113
+ let s = 0
114
+ for (let i = 0; i < 12; i++) s += v[i] * v[i]
115
+ return Math.sqrt(s)
116
+ }
117
+
118
+ function cosine(a, b, aNorm) {
119
+ let dot = 0, bNorm = 0
120
+ for (let i = 0; i < 12; i++) { dot += a[i] * b[i]; bNorm += b[i] * b[i] }
121
+ bNorm = Math.sqrt(bNorm)
122
+ return aNorm && bNorm ? dot / (aNorm * bNorm) : 0
123
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@audio/mir-chord",
3
+ "version": "1.0.0",
4
+ "description": "24 binary chord templates: C, C#, … B (major) then C, C#, … B (minor)",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "chord.js",
8
+ "exports": {
9
+ ".": "./chord.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "chord.js"
14
+ ],
15
+ "keywords": [
16
+ "audio",
17
+ "dsp",
18
+ "mir",
19
+ "chord",
20
+ "chord-detection",
21
+ "viterbi"
22
+ ],
23
+ "license": "MIT",
24
+ "author": "Dmitry Iv. <dfcreative@gmail.com>",
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
31
+ }