note-listener 1.1.3 → 1.1.4
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/package.json +1 -1
- package/src/index.js +104 -53
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { PitchDetector } from "pitchy";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// AUDIO WORKLET PROCESSOR (inlined as a Blob)
|
|
4
|
+
// Runs on the audio thread, posts raw PCM frames
|
|
5
5
|
|
|
6
|
-
const WORKLET_CODE
|
|
6
|
+
const WORKLET_CODE = `
|
|
7
7
|
class PitchProcessorNode extends AudioWorkletProcessor {
|
|
8
8
|
constructor() {
|
|
9
9
|
super();
|
|
@@ -31,14 +31,27 @@ registerProcessor("pitch-processor", PitchProcessorNode);
|
|
|
31
31
|
CONSTANTS
|
|
32
32
|
───────────────────────────────────────────── */
|
|
33
33
|
const A4 = 440;
|
|
34
|
-
const NOTE_NAMES = [
|
|
34
|
+
const NOTE_NAMES = [
|
|
35
|
+
"C",
|
|
36
|
+
"C#",
|
|
37
|
+
"D",
|
|
38
|
+
"D#",
|
|
39
|
+
"E",
|
|
40
|
+
"F",
|
|
41
|
+
"F#",
|
|
42
|
+
"G",
|
|
43
|
+
"G#",
|
|
44
|
+
"A",
|
|
45
|
+
"A#",
|
|
46
|
+
"B",
|
|
47
|
+
];
|
|
35
48
|
const FRAME_SIZE = 2048;
|
|
36
49
|
|
|
37
50
|
// Guitar band splits: [label, lowHz, highHz]
|
|
38
51
|
const BANDS = [
|
|
39
|
-
["bass",
|
|
40
|
-
["mid",
|
|
41
|
-
["treble",500,
|
|
52
|
+
["bass", 82, 200],
|
|
53
|
+
["mid", 200, 500],
|
|
54
|
+
["treble", 500, 1319],
|
|
42
55
|
];
|
|
43
56
|
|
|
44
57
|
/* ─────────────────────────────────────────────
|
|
@@ -78,8 +91,13 @@ function rms(buf) {
|
|
|
78
91
|
function mostCommon(arr) {
|
|
79
92
|
const c = {};
|
|
80
93
|
arr.forEach((n) => (c[n] = (c[n] || 0) + 1));
|
|
81
|
-
let max = 0,
|
|
82
|
-
|
|
94
|
+
let max = 0,
|
|
95
|
+
winner = null;
|
|
96
|
+
for (const n in c)
|
|
97
|
+
if (c[n] > max) {
|
|
98
|
+
max = c[n];
|
|
99
|
+
winner = n;
|
|
100
|
+
}
|
|
83
101
|
return winner;
|
|
84
102
|
}
|
|
85
103
|
|
|
@@ -97,14 +115,18 @@ function mostCommon(arr) {
|
|
|
97
115
|
* @param {object} opts
|
|
98
116
|
* @returns {Array<{freq, note, cents}>}
|
|
99
117
|
*/
|
|
100
|
-
function fftPeakNotes(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
118
|
+
function fftPeakNotes(
|
|
119
|
+
freqData,
|
|
120
|
+
sampleRate,
|
|
121
|
+
{
|
|
122
|
+
minFreq = 82,
|
|
123
|
+
maxFreq = 1319,
|
|
124
|
+
noiseFloor = -60, // dBFS below which we ignore bins
|
|
125
|
+
peakDelta = 6, // dB above neighbours to count as peak
|
|
126
|
+
harmonics = 3, // how many harmonics to validate
|
|
127
|
+
harmonicGain = -18, // harmonic must be within XdB of fundamental
|
|
128
|
+
} = {},
|
|
129
|
+
) {
|
|
108
130
|
const binHz = sampleRate / (2 * (freqData.length - 1));
|
|
109
131
|
const results = [];
|
|
110
132
|
|
|
@@ -116,7 +138,8 @@ function fftPeakNotes(freqData, sampleRate, {
|
|
|
116
138
|
if (mag < noiseFloor) continue;
|
|
117
139
|
// local peak check
|
|
118
140
|
if (mag <= freqData[i - 1] || mag <= freqData[i + 1]) continue;
|
|
119
|
-
if (mag - freqData[i - 1] < peakDelta && mag - freqData[i + 1] < peakDelta)
|
|
141
|
+
if (mag - freqData[i - 1] < peakDelta && mag - freqData[i + 1] < peakDelta)
|
|
142
|
+
continue;
|
|
120
143
|
|
|
121
144
|
const freq = i * binHz;
|
|
122
145
|
|
|
@@ -152,12 +175,22 @@ function fftPeakNotes(freqData, sampleRate, {
|
|
|
152
175
|
*/
|
|
153
176
|
function createBandDetectors(clarityThreshold, bufferSize) {
|
|
154
177
|
return BANDS.map(([label, lo, hi]) => {
|
|
155
|
-
const detector
|
|
178
|
+
const detector = PitchDetector.forFloat32Array(FRAME_SIZE);
|
|
156
179
|
const noteBuffer = [];
|
|
157
|
-
let lastNote
|
|
158
|
-
let lastEnergy
|
|
180
|
+
let lastNote = null;
|
|
181
|
+
let lastEnergy = 0;
|
|
159
182
|
|
|
160
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
label,
|
|
185
|
+
lo,
|
|
186
|
+
hi,
|
|
187
|
+
detector,
|
|
188
|
+
noteBuffer,
|
|
189
|
+
lastNote,
|
|
190
|
+
lastEnergy,
|
|
191
|
+
bufferSize,
|
|
192
|
+
clarityThreshold,
|
|
193
|
+
};
|
|
161
194
|
});
|
|
162
195
|
}
|
|
163
196
|
|
|
@@ -176,11 +209,11 @@ function runBandDetector(band, frame, sampleRate, minEnergy, hysteresisRatio) {
|
|
|
176
209
|
const energy = rms(frame);
|
|
177
210
|
|
|
178
211
|
// Silence hysteresis: note-on at minEnergy, note-off at minEnergy * hysteresisRatio
|
|
179
|
-
const onThreshold
|
|
212
|
+
const onThreshold = minEnergy;
|
|
180
213
|
const offThreshold = minEnergy * hysteresisRatio;
|
|
181
214
|
|
|
182
215
|
if (energy < offThreshold) {
|
|
183
|
-
band.lastNote
|
|
216
|
+
band.lastNote = null;
|
|
184
217
|
band.lastEnergy = energy;
|
|
185
218
|
return null;
|
|
186
219
|
}
|
|
@@ -207,16 +240,16 @@ function runBandDetector(band, frame, sampleRate, minEnergy, hysteresisRatio) {
|
|
|
207
240
|
const isOnset = band.lastEnergy < onThreshold && energy >= onThreshold;
|
|
208
241
|
const noteChanged = smoothed !== band.lastNote;
|
|
209
242
|
|
|
210
|
-
band.lastNote
|
|
243
|
+
band.lastNote = smoothed;
|
|
211
244
|
band.lastEnergy = energy;
|
|
212
245
|
|
|
213
246
|
if (noteChanged || isOnset) {
|
|
214
247
|
return {
|
|
215
|
-
source:
|
|
216
|
-
note:
|
|
248
|
+
source: "band:" + band.label,
|
|
249
|
+
note: smoothed,
|
|
217
250
|
freq,
|
|
218
251
|
clarity,
|
|
219
|
-
cents:
|
|
252
|
+
cents: centDeviation(freq),
|
|
220
253
|
isOnset,
|
|
221
254
|
energy,
|
|
222
255
|
};
|
|
@@ -237,8 +270,9 @@ function mergeNotes(bandNotes, fftNotes, activeNotes, minEnergy) {
|
|
|
237
270
|
|
|
238
271
|
for (const fn of fftNotes) {
|
|
239
272
|
const midi = freqToMidi(fn.freq);
|
|
240
|
-
const dup
|
|
241
|
-
if (!dup)
|
|
273
|
+
const dup = all.some((n) => Math.abs(freqToMidi(n.freq) - midi) <= 1);
|
|
274
|
+
if (!dup)
|
|
275
|
+
all.push({ source: "fft", isOnset: !activeNotes.has(fn.note), ...fn });
|
|
242
276
|
}
|
|
243
277
|
|
|
244
278
|
return all;
|
|
@@ -279,14 +313,15 @@ export async function createPitchListener({
|
|
|
279
313
|
onNotes,
|
|
280
314
|
onOnset,
|
|
281
315
|
deviceId,
|
|
282
|
-
minEnergy
|
|
283
|
-
hysteresisRatio
|
|
316
|
+
minEnergy = 0.03,
|
|
317
|
+
hysteresisRatio = 0.6,
|
|
284
318
|
clarityThreshold = 0.88,
|
|
285
|
-
minFreq
|
|
286
|
-
maxFreq
|
|
287
|
-
smoothing
|
|
319
|
+
minFreq = 82,
|
|
320
|
+
maxFreq = 1319,
|
|
321
|
+
smoothing = 5,
|
|
288
322
|
} = {}) {
|
|
289
|
-
if (!navigator.mediaDevices?.getUserMedia)
|
|
323
|
+
if (!navigator.mediaDevices?.getUserMedia)
|
|
324
|
+
throw new Error("Browser does not support microphone access.");
|
|
290
325
|
const ACtx = window.AudioContext ?? window.webkitAudioContext;
|
|
291
326
|
if (!ACtx) throw new Error("Browser does not support Web Audio API.");
|
|
292
327
|
|
|
@@ -300,7 +335,7 @@ export async function createPitchListener({
|
|
|
300
335
|
audio: {
|
|
301
336
|
...(deviceId ? { deviceId: { exact: deviceId } } : {}),
|
|
302
337
|
echoCancellation: false,
|
|
303
|
-
autoGainControl:
|
|
338
|
+
autoGainControl: false,
|
|
304
339
|
noiseSuppression: false,
|
|
305
340
|
latency: 0,
|
|
306
341
|
},
|
|
@@ -313,43 +348,54 @@ export async function createPitchListener({
|
|
|
313
348
|
if (context.state === "suspended") await context.resume();
|
|
314
349
|
|
|
315
350
|
/* -- AudioWorklet --------------------------------------------------- */
|
|
316
|
-
const blob
|
|
351
|
+
const blob = new Blob([WORKLET_CODE], { type: "application/javascript" });
|
|
317
352
|
const blobUrl = URL.createObjectURL(blob);
|
|
318
353
|
await context.audioWorklet.addModule(blobUrl);
|
|
319
354
|
URL.revokeObjectURL(blobUrl);
|
|
320
355
|
|
|
321
|
-
const source
|
|
322
|
-
const worklet
|
|
356
|
+
const source = context.createMediaStreamSource(stream);
|
|
357
|
+
const worklet = new AudioWorkletNode(context, "pitch-processor");
|
|
323
358
|
|
|
324
359
|
/* -- AnalyserNode for FFT ------------------------------------------ */
|
|
325
|
-
const analyser
|
|
326
|
-
analyser.fftSize
|
|
360
|
+
const analyser = context.createAnalyser();
|
|
361
|
+
analyser.fftSize = 8192; // high resolution for FFT peak picker
|
|
327
362
|
analyser.smoothingTimeConstant = 0.5;
|
|
328
|
-
const fftBuf
|
|
363
|
+
const fftBuf = new Float32Array(analyser.frequencyBinCount);
|
|
329
364
|
|
|
330
365
|
/* -- Highpass filter ------------------------------------------------ */
|
|
331
|
-
const hpf
|
|
332
|
-
hpf.type
|
|
333
|
-
hpf.frequency.value
|
|
366
|
+
const hpf = context.createBiquadFilter();
|
|
367
|
+
hpf.type = "highpass";
|
|
368
|
+
hpf.frequency.value = 70;
|
|
334
369
|
|
|
335
370
|
source.connect(hpf);
|
|
336
371
|
hpf.connect(analyser);
|
|
337
|
-
hpf.connect(worklet);
|
|
372
|
+
hpf.connect(worklet); // worklet gets filtered signal too
|
|
338
373
|
|
|
339
374
|
/* -- Band detectors ------------------------------------------------- */
|
|
340
|
-
const bands
|
|
341
|
-
const activeNotes = new Set();
|
|
375
|
+
const bands = createBandDetectors(clarityThreshold, smoothing);
|
|
376
|
+
const activeNotes = new Set(); // currently sounding notes (for onset tracking)
|
|
342
377
|
|
|
343
378
|
/* -- Main processing handler --------------------------------------- */
|
|
344
379
|
worklet.port.onmessage = ({ data: frame }) => {
|
|
345
380
|
// --- Band-split detections ---
|
|
346
381
|
const bandNotes = bands
|
|
347
|
-
.map((band) =>
|
|
382
|
+
.map((band) =>
|
|
383
|
+
runBandDetector(
|
|
384
|
+
band,
|
|
385
|
+
frame,
|
|
386
|
+
context.sampleRate,
|
|
387
|
+
minEnergy,
|
|
388
|
+
hysteresisRatio,
|
|
389
|
+
),
|
|
390
|
+
)
|
|
348
391
|
.filter(Boolean);
|
|
349
392
|
|
|
350
393
|
// --- FFT peak detections ---
|
|
351
394
|
analyser.getFloatFrequencyData(fftBuf);
|
|
352
|
-
const fftNotes = fftPeakNotes(fftBuf, context.sampleRate, {
|
|
395
|
+
const fftNotes = fftPeakNotes(fftBuf, context.sampleRate, {
|
|
396
|
+
minFreq,
|
|
397
|
+
maxFreq,
|
|
398
|
+
});
|
|
353
399
|
|
|
354
400
|
// --- Merge & deduplicate ---
|
|
355
401
|
const detected = mergeNotes(bandNotes, fftNotes, activeNotes);
|
|
@@ -366,13 +412,18 @@ export async function createPitchListener({
|
|
|
366
412
|
if (onsets.length && onOnset) onOnset(onsets);
|
|
367
413
|
};
|
|
368
414
|
|
|
415
|
+
// a gain node with volume 0 — silent, but keeps the worklet alive
|
|
416
|
+
const silentGain = context.createGain();
|
|
417
|
+
silentGain.gain.value = 0;
|
|
418
|
+
silentGain.connect(context.destination);
|
|
419
|
+
|
|
369
420
|
let connected = false;
|
|
370
421
|
|
|
371
422
|
return {
|
|
372
423
|
start() {
|
|
373
424
|
if (connected) return;
|
|
374
425
|
connected = true;
|
|
375
|
-
worklet.connect(
|
|
426
|
+
worklet.connect(silentGain); // ← routes to silence instead of speakers
|
|
376
427
|
},
|
|
377
428
|
|
|
378
429
|
stop() {
|
|
@@ -382,4 +433,4 @@ export async function createPitchListener({
|
|
|
382
433
|
context.close().catch(() => {});
|
|
383
434
|
},
|
|
384
435
|
};
|
|
385
|
-
}
|
|
436
|
+
}
|