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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +104 -53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "note-listener",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "simple pitch detection library for instruments using pitchy",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { PitchDetector } from "pitchy";
2
2
 
3
- // AUDIO WORKLET PROCESSOR (inlined as a Blob)
4
- // Runs on the audio thread, posts raw PCM frames
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 = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
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", 82, 200],
40
- ["mid", 200, 500],
41
- ["treble",500, 1319],
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, winner = null;
82
- for (const n in c) if (c[n] > max) { max = c[n]; winner = n; }
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(freqData, sampleRate, {
101
- minFreq = 82,
102
- maxFreq = 1319,
103
- noiseFloor = -60, // dBFS below which we ignore bins
104
- peakDelta = 6, // dB above neighbours to count as peak
105
- harmonics = 3, // how many harmonics to validate
106
- harmonicGain = -18, // harmonic must be within XdB of fundamental
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) continue;
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 = PitchDetector.forFloat32Array(FRAME_SIZE);
178
+ const detector = PitchDetector.forFloat32Array(FRAME_SIZE);
156
179
  const noteBuffer = [];
157
- let lastNote = null;
158
- let lastEnergy = 0;
180
+ let lastNote = null;
181
+ let lastEnergy = 0;
159
182
 
160
- return { label, lo, hi, detector, noteBuffer, lastNote, lastEnergy, bufferSize, clarityThreshold };
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 = minEnergy;
212
+ const onThreshold = minEnergy;
180
213
  const offThreshold = minEnergy * hysteresisRatio;
181
214
 
182
215
  if (energy < offThreshold) {
183
- band.lastNote = null;
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 = smoothed;
243
+ band.lastNote = smoothed;
211
244
  band.lastEnergy = energy;
212
245
 
213
246
  if (noteChanged || isOnset) {
214
247
  return {
215
- source: "band:" + band.label,
216
- note: smoothed,
248
+ source: "band:" + band.label,
249
+ note: smoothed,
217
250
  freq,
218
251
  clarity,
219
- cents: centDeviation(freq),
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 = all.some((n) => Math.abs(freqToMidi(n.freq) - midi) <= 1);
241
- if (!dup) all.push({ source: "fft", isOnset: !activeNotes.has(fn.note), ...fn });
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 = 0.03,
283
- hysteresisRatio = 0.6,
316
+ minEnergy = 0.03,
317
+ hysteresisRatio = 0.6,
284
318
  clarityThreshold = 0.88,
285
- minFreq = 82,
286
- maxFreq = 1319,
287
- smoothing = 5,
319
+ minFreq = 82,
320
+ maxFreq = 1319,
321
+ smoothing = 5,
288
322
  } = {}) {
289
- if (!navigator.mediaDevices?.getUserMedia) throw new Error("Browser does not support microphone access.");
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: false,
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 = new Blob([WORKLET_CODE], { type: "application/javascript" });
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 = context.createMediaStreamSource(stream);
322
- const worklet = new AudioWorkletNode(context, "pitch-processor");
356
+ const source = context.createMediaStreamSource(stream);
357
+ const worklet = new AudioWorkletNode(context, "pitch-processor");
323
358
 
324
359
  /* -- AnalyserNode for FFT ------------------------------------------ */
325
- const analyser = context.createAnalyser();
326
- analyser.fftSize = 8192; // high resolution for FFT peak picker
360
+ const analyser = context.createAnalyser();
361
+ analyser.fftSize = 8192; // high resolution for FFT peak picker
327
362
  analyser.smoothingTimeConstant = 0.5;
328
- const fftBuf = new Float32Array(analyser.frequencyBinCount);
363
+ const fftBuf = new Float32Array(analyser.frequencyBinCount);
329
364
 
330
365
  /* -- Highpass filter ------------------------------------------------ */
331
- const hpf = context.createBiquadFilter();
332
- hpf.type = "highpass";
333
- hpf.frequency.value = 70;
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); // worklet gets filtered signal too
372
+ hpf.connect(worklet); // worklet gets filtered signal too
338
373
 
339
374
  /* -- Band detectors ------------------------------------------------- */
340
- const bands = createBandDetectors(clarityThreshold, smoothing);
341
- const activeNotes = new Set(); // currently sounding notes (for onset tracking)
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) => runBandDetector(band, frame, context.sampleRate, minEnergy, hysteresisRatio))
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, { minFreq, maxFreq });
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(context.destination); // worklet needs to be in the graph to run
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
+ }