1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
|
// MQ Extraction Algorithm
// McAulay-Quatieri sinusoidal analysis
// Extract partials from audio buffer
function extractPartials(params, stftCache) {
const {fftSize, threshold, sampleRate} = params;
const numFrames = stftCache.getNumFrames();
const frames = [];
for (let i = 0; i < numFrames; ++i) {
const cachedFrame = stftCache.getFrameAtIndex(i);
const squaredAmp = stftCache.getSquaredAmplitude(cachedFrame.time);
const peaks = detectPeaks(squaredAmp, fftSize, sampleRate, threshold);
frames.push({time: cachedFrame.time, peaks});
}
const partials = trackPartials(frames);
for (const partial of partials) {
partial.freqCurve = fitBezier(partial.times, partial.freqs);
partial.ampCurve = fitBezier(partial.times, partial.amps);
}
return {partials, frames};
}
// Detect spectral peaks via local maxima + parabolic interpolation
// squaredAmp: pre-computed re*re+im*im per bin
function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
const mag = new Float32Array(fftSize / 2);
for (let i = 0; i < fftSize / 2; ++i) {
mag[i] = 10 * Math.log10(Math.max(squaredAmp[i], 1e-20));
}
const peaks = [];
for (let i = 2; i < mag.length - 2; ++i) {
if (mag[i] > thresholdDB &&
mag[i] > mag[i-1] && mag[i] > mag[i-2] &&
mag[i] > mag[i+1] && mag[i] > mag[i+2]) {
// Parabolic interpolation for sub-bin accuracy
const alpha = mag[i-1];
const beta = mag[i];
const gamma = mag[i+1];
const p = 0.5 * (alpha - gamma) / (alpha - 2*beta + gamma);
const freq = (i + p) * sampleRate / fftSize;
const ampDB = beta - 0.25 * (alpha - gamma) * p;
peaks.push({freq, amp: Math.pow(10, ampDB / 20)});
}
}
return peaks;
}
// Track partials across frames (birth/death/continuation)
function trackPartials(frames) {
const partials = [];
const activePartials = [];
const candidates = []; // pre-birth
const trackingRatio = 0.05; // 5% frequency tolerance
const minTrackingHz = 20;
const birthPersistence = 3; // frames before partial is born
const deathAge = 5; // frames without match before death
const minLength = 10; // frames required to keep partial
for (const frame of frames) {
const matched = new Set();
// Continue active partials
for (const partial of activePartials) {
const lastFreq = partial.freqs[partial.freqs.length - 1];
const tol = Math.max(lastFreq * trackingRatio, minTrackingHz);
let bestIdx = -1, bestDist = Infinity;
for (let i = 0; i < frame.peaks.length; ++i) {
if (matched.has(i)) continue;
const dist = Math.abs(frame.peaks[i].freq - lastFreq);
if (dist < tol && dist < bestDist) { bestDist = dist; bestIdx = i; }
}
if (bestIdx >= 0) {
const pk = frame.peaks[bestIdx];
partial.times.push(frame.time);
partial.freqs.push(pk.freq);
partial.amps.push(pk.amp);
partial.age = 0;
matched.add(bestIdx);
} else {
partial.age++;
}
}
// Advance candidates
for (let i = candidates.length - 1; i >= 0; --i) {
const cand = candidates[i];
const lastFreq = cand.freqs[cand.freqs.length - 1];
const tol = Math.max(lastFreq * trackingRatio, minTrackingHz);
let bestIdx = -1, bestDist = Infinity;
for (let j = 0; j < frame.peaks.length; ++j) {
if (matched.has(j)) continue;
const dist = Math.abs(frame.peaks[j].freq - lastFreq);
if (dist < tol && dist < bestDist) { bestDist = dist; bestIdx = j; }
}
if (bestIdx >= 0) {
const pk = frame.peaks[bestIdx];
cand.times.push(frame.time);
cand.freqs.push(pk.freq);
cand.amps.push(pk.amp);
matched.add(bestIdx);
if (cand.times.length >= birthPersistence) {
activePartials.push(cand);
candidates.splice(i, 1);
}
} else {
candidates.splice(i, 1);
}
}
// Spawn candidates from unmatched peaks
for (let i = 0; i < frame.peaks.length; ++i) {
if (matched.has(i)) continue;
const pk = frame.peaks[i];
candidates.push({times: [frame.time], freqs: [pk.freq], amps: [pk.amp], age: 0});
}
// Kill aged-out partials
for (let i = activePartials.length - 1; i >= 0; --i) {
if (activePartials[i].age > deathAge) {
if (activePartials[i].times.length >= minLength) partials.push(activePartials[i]);
activePartials.splice(i, 1);
}
}
}
// Collect remaining active partials
for (const partial of activePartials) {
if (partial.times.length >= minLength) partials.push(partial);
}
return partials;
}
// Fit cubic bezier to trajectory using samples at ~1/3 and ~2/3 as control points
function fitBezier(times, values) {
const n = times.length - 1;
const t0 = times[0], v0 = values[0];
const t3 = times[n], v3 = values[n];
const dt = t3 - t0;
if (dt <= 0 || n === 0) {
return {t0, v0, t1: t0, v1: v0, t2: t3, v2: v3, t3, v3};
}
const v1 = values[Math.round(n / 3)];
const v2 = values[Math.round(2 * n / 3)];
return {t0, v0, t1: t0 + dt / 3, v1, t2: t0 + 2 * dt / 3, v2, t3, v3};
}
|