note-listener 1.1.1 → 1.1.3

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 (3) hide show
  1. package/README.md +126 -38
  2. package/package.json +11 -3
  3. package/src/index.js +338 -121
package/README.md CHANGED
@@ -1,18 +1,18 @@
1
1
  # note-listener
2
2
 
3
- A lightweight JavaScript/TypeScript library for detecting pitch from any audio input. Works in the browser and supports selecting specific microphones or sound cards. Perfect for guitar practice, music apps, or live pitch detection.
3
+ A lightweight JavaScript/TypeScript library for real-time musical note detection from microphone audio in the browser.
4
+ Ideal for guitar practice, tuners, music apps, and live pitch visualization.
4
5
 
5
- ---
6
+ `note-listener` listens to audio input, detects the dominant pitch, and continuously emits the corresponding musical note.
6
7
 
7
8
  ## Features
8
9
 
9
- * Detect musical notes (C0–B8) in real-time.
10
- * Smooths out note jitter with a configurable buffer.
11
- * Supports multiple audio inputs and allows choosing a specific device.
12
- * Pure browser-based no backend required.
13
- * Minimal dependencies (`pitchy`).
14
-
15
- ---
10
+ - Real-time musical note detection (C0–B8)
11
+ - Works with any microphone or sound card
12
+ - Smooths jitter using internal buffering
13
+ - Configurable frequency range and clarity threshold
14
+ - Pure browser-based (Web Audio API)
15
+ - Minimal dependencies ([pitchy](https://github.com/ianprime0509/pitchy))
16
16
 
17
17
  ## Installation
18
18
 
@@ -22,37 +22,99 @@ npm install note-listener
22
22
  yarn add note-listener
23
23
  ```
24
24
 
25
+ ## How it works
26
+
27
+ This library is **event-based**, not return-based.
28
+
29
+ 1. You start listening to the microphone
30
+ 2. The library continuously detects notes
31
+ 3. Each detected note is sent to you via a callback
32
+ 4. Listening continues until you explicitly stop it
33
+
34
+ There is no "single return value" — notes are streamed over time.
35
+
25
36
  ## Usage
26
37
 
27
- ### Plain JavaScript
38
+ ### 1. List available audio inputs (optional)
28
39
 
29
40
  ```js
30
- import { listAudioInputs, createPitchListener } from "note-listener";
41
+ import { listAudioInputs } from "note-listener"
31
42
 
32
- // List all available audio inputs
33
- const inputs = await listAudioInputs();
34
- console.log(inputs); // [{ deviceId, label }, ...]
43
+ const inputs = await listAudioInputs()
44
+ console.log(inputs)
45
+ // [{ deviceId: "...", label: "Built-in Microphone" }, ...]
46
+ ```
47
+
48
+ > **Note:** Browsers require microphone permission before device labels appear:
49
+ > ```js
50
+ > await navigator.mediaDevices.getUserMedia({ audio: true })
51
+ > ```
52
+
53
+ ### 2. Create a pitch listener
54
+
55
+ ```js
56
+ import { createPitchListener } from "note-listener"
35
57
 
36
58
  const listener = await createPitchListener({
37
- deviceId: inputs[0].deviceId, // select a device
59
+ deviceId: inputs[0].deviceId,
38
60
  minEnergy: 0.03,
39
- onNote: (note, freq, clarity) => {
40
- console.log(`Detected note: ${note} (${freq.toFixed(2)} Hz), clarity: ${clarity.toFixed(2)}`);
61
+ onNote: (note, frequency, clarity) => {
62
+ console.log(
63
+ `${note} (${frequency.toFixed(2)} Hz, clarity ${clarity.toFixed(2)})`
64
+ )
65
+ },
66
+ })
67
+ ```
68
+
69
+ At this point, the microphone is **not yet active** and no audio is being analyzed. You are just configuring the listener.
70
+
71
+ ### 3. Start listening
72
+
73
+ ```js
74
+ listener.start()
75
+ ```
76
+
77
+ When `start()` is called, the browser activates the selected microphone, audio is analyzed in real time, and detected notes are emitted via `onNote`.
78
+
79
+ ### 4. Stop listening
80
+
81
+ ```js
82
+ listener.stop()
83
+ ```
84
+
85
+ This stops the microphone stream, disconnects audio nodes, and frees system resources. You should always call `stop()` when done.
86
+
87
+ ## Examples
88
+
89
+ ### Plain JavaScript
90
+
91
+ ```js
92
+ import { listAudioInputs, createPitchListener } from "note-listener"
93
+
94
+ await navigator.mediaDevices.getUserMedia({ audio: true })
95
+
96
+ const inputs = await listAudioInputs()
97
+
98
+ const listener = await createPitchListener({
99
+ deviceId: inputs[0].deviceId,
100
+ onNote(note, freq) {
101
+ console.log(`Current note: ${note} (${freq.toFixed(1)} Hz)`)
41
102
  },
42
- });
103
+ })
43
104
 
44
- listener.start();
105
+ listener.start()
45
106
 
46
- // Stop when needed
47
- // listener.stop();
107
+ // Later...
108
+ // listener.stop()
48
109
  ```
49
110
 
50
- ### Vue 3 Example
111
+ ### Vue 3
51
112
 
52
113
  ```vue
53
114
  <template>
54
115
  <div>
55
- <h2>Select audio input</h2>
116
+ <h3>Select audio input</h3>
117
+
56
118
  <select v-model="selectedId">
57
119
  <option disabled value="">-- choose input --</option>
58
120
  <option v-for="d in inputs" :key="d.deviceId" :value="d.deviceId">
@@ -60,7 +122,9 @@ listener.start();
60
122
  </option>
61
123
  </select>
62
124
 
63
- <button @click="start" :disabled="!selectedId">Start listening</button>
125
+ <button @click="start" :disabled="!selectedId">Start</button>
126
+ <button @click="stop">Stop</button>
127
+
64
128
  <p>Last note: {{ lastNote || "—" }}</p>
65
129
  </div>
66
130
  </template>
@@ -84,33 +148,57 @@ async function start() {
84
148
 
85
149
  listener = await createPitchListener({
86
150
  deviceId: selectedId.value,
87
- minEnergy: 0.03,
88
151
  onNote(note) {
89
152
  lastNote.value = note
90
- console.log("🎵", note)
91
153
  },
92
154
  })
93
155
 
94
156
  listener.start()
95
157
  }
96
158
 
97
- onBeforeUnmount(() => {
159
+ function stop() {
98
160
  if (listener) listener.stop()
99
- })
161
+ }
162
+
163
+ onBeforeUnmount(stop)
100
164
  </script>
101
165
  ```
102
166
 
103
- ## Options
167
+ ## API
168
+
169
+ ### `createPitchListener(options)`
170
+
171
+ | Option | Type | Default | Description |
172
+ | ------------------ | ---------- | ----------- | ---------------------------------------- |
173
+ | `deviceId` | `string` | `undefined` | Audio input device ID |
174
+ | `minEnergy` | `number` | `0.03` | Minimum RMS energy required |
175
+ | `clarityThreshold` | `number` | `0.88` | Minimum pitch clarity |
176
+ | `minFreq` | `number` | `82` | Minimum frequency (Hz) |
177
+ | `maxFreq` | `number` | `1319` | Maximum frequency (Hz) |
178
+ | `onNote` | `function` | required | Called when a note is detected |
179
+
180
+ ### `onNote` callback
181
+
182
+ ```ts
183
+ (note: string, frequency: number, clarity: number) => void
184
+ ```
185
+
186
+ Called continuously while audio is being analyzed. `note` is a string like `"A4"`, `frequency` is in Hz, and `clarity` is a value between 0 and 1 indicating pitch confidence.
187
+
188
+ ### `listAudioInputs()`
189
+
190
+ Returns a promise resolving to an array of available audio input devices:
191
+
192
+ ```ts
193
+ [{ deviceId: string, label: string }]
194
+ ```
195
+
196
+ ## Browser support
104
197
 
105
- | Option | Type | Default | Description |
106
- | ---------------- | -------- | --------- | ------------------------------------------------------------------- |
107
- | deviceId | string | undefined | Audio input device ID to use. |
108
- | minEnergy | number | 0.03 | Minimum RMS energy to consider a signal as valid. |
109
- | clarityThreshold | number | 0.88 | Minimum clarity from pitchy to register a note. |
110
- | minFreq | number | 82 | Minimum frequency to detect (Hz). |
111
- | maxFreq | number | 1319 | Maximum frequency to detect (Hz). |
112
- | onNote | function | required | Callback when a new note is detected: `(note, freq, clarity) => {}` |
198
+ - Works in modern Chromium, Firefox, and Safari
199
+ - Requires user interaction to initiate microphone access
200
+ - Not supported in Node.js or server-side environments
113
201
 
114
202
  ## License
115
203
 
116
- MIT © Donald Edwin
204
+ MIT © Donald Edwin
package/package.json CHANGED
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "note-listener",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "simple pitch detection library for instruments using pitchy",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
7
  "test": "echo \"Error: no test specified\" && exit 1"
8
8
  },
9
- "keywords": [],
9
+ "keywords": [
10
+ "pitch",
11
+ "note",
12
+ "music",
13
+ "guitar",
14
+ "audio",
15
+ "pitch-detection",
16
+ "real-time",
17
+ "browser"
18
+ ],
10
19
  "author": "",
11
20
  "license": "ISC",
12
21
  "type": "module",
@@ -14,4 +23,3 @@
14
23
  "pitchy": "^4.1.0"
15
24
  }
16
25
  }
17
-
package/src/index.js CHANGED
@@ -1,168 +1,385 @@
1
1
  import { PitchDetector } from "pitchy";
2
2
 
3
- export async function listAudioInputs() {
4
- const devices = await navigator.mediaDevices.enumerateDevices();
5
- return devices.filter((d) => d.kind === "audioinput");
3
+ // AUDIO WORKLET PROCESSOR (inlined as a Blob)
4
+ // Runs on the audio thread, posts raw PCM frames
5
+
6
+ const WORKLET_CODE =`
7
+ class PitchProcessorNode extends AudioWorkletProcessor {
8
+ constructor() {
9
+ super();
10
+ this._buf = [];
11
+ this._frameSize = 2048;
12
+ }
13
+
14
+ process(inputs) {
15
+ const ch = inputs[0]?.[0];
16
+ if (!ch) return true;
17
+
18
+ for (let i = 0; i < ch.length; i++) this._buf.push(ch[i]);
19
+
20
+ while (this._buf.length >= this._frameSize) {
21
+ const frame = new Float32Array(this._buf.splice(0, this._frameSize));
22
+ this.port.postMessage(frame, [frame.buffer]);
23
+ }
24
+ return true;
25
+ }
6
26
  }
27
+ registerProcessor("pitch-processor", PitchProcessorNode);
28
+ `;
29
+
30
+ /* ─────────────────────────────────────────────
31
+ CONSTANTS
32
+ ───────────────────────────────────────────── */
33
+ const A4 = 440;
34
+ const NOTE_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
35
+ const FRAME_SIZE = 2048;
7
36
 
37
+ // Guitar band splits: [label, lowHz, highHz]
38
+ const BANDS = [
39
+ ["bass", 82, 200],
40
+ ["mid", 200, 500],
41
+ ["treble",500, 1319],
42
+ ];
43
+
44
+ /* ─────────────────────────────────────────────
45
+ UTILITIES
46
+ ───────────────────────────────────────────── */
47
+ export function listAudioInputs() {
48
+ return navigator.mediaDevices
49
+ .enumerateDevices()
50
+ .then((d) => d.filter((x) => x.kind === "audioinput"));
51
+ }
52
+
53
+ function freqToMidi(freq) {
54
+ return Math.round(12 * Math.log2(freq / A4) + 69);
55
+ }
56
+
57
+ function midiToNote(midi) {
58
+ return NOTE_NAMES[midi % 12] + (Math.floor(midi / 12) - 1);
59
+ }
8
60
 
9
- /* ====== Utilities ====== */
10
61
  function freqToNote(freq) {
11
- const A4 = 440;
12
- const notes = [
13
- "C",
14
- "C#",
15
- "D",
16
- "D#",
17
- "E",
18
- "F",
19
- "F#",
20
- "G",
21
- "G#",
22
- "A",
23
- "A#",
24
- "B",
25
- ];
26
-
27
- const n = Math.round(12 * Math.log2(freq / A4) + 69);
28
- const name = notes[n % 12];
29
- const octave = Math.floor(n / 12) - 1;
30
-
31
- return name + octave;
62
+ return midiToNote(freqToMidi(freq));
32
63
  }
33
64
 
34
- function rms(buffer) {
35
- let sum = 0;
36
- for (let i = 0; i < buffer.length; i++) {
37
- sum += buffer[i] * buffer[i];
65
+ /** Cents sharp (+) or flat (-) relative to the nearest equal-tempered pitch */
66
+ function centDeviation(freq) {
67
+ const midi = 12 * Math.log2(freq / A4) + 69;
68
+ const nearest = Math.round(midi);
69
+ return Math.round((midi - nearest) * 100);
70
+ }
71
+
72
+ function rms(buf) {
73
+ let s = 0;
74
+ for (let i = 0; i < buf.length; i++) s += buf[i] * buf[i];
75
+ return Math.sqrt(s / buf.length);
76
+ }
77
+
78
+ function mostCommon(arr) {
79
+ const c = {};
80
+ 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; }
83
+ return winner;
84
+ }
85
+
86
+ /* ─────────────────────────────────────────────
87
+ FFT PEAK PICKING WITH HARMONIC VALIDATION
88
+ ───────────────────────────────────────────── */
89
+
90
+ /**
91
+ * Finds candidate fundamental frequencies in a magnitude spectrum
92
+ * by locating spectral peaks and validating that their harmonics
93
+ * (2f, 3f, 4f) also carry significant energy.
94
+ *
95
+ * @param {Float32Array} freqData - getFloatFrequencyData() output (dBFS)
96
+ * @param {number} sampleRate
97
+ * @param {object} opts
98
+ * @returns {Array<{freq, note, cents}>}
99
+ */
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
+ } = {}) {
108
+ const binHz = sampleRate / (2 * (freqData.length - 1));
109
+ const results = [];
110
+
111
+ const minBin = Math.ceil(minFreq / binHz);
112
+ const maxBin = Math.floor(maxFreq / binHz);
113
+
114
+ for (let i = minBin; i <= maxBin; i++) {
115
+ const mag = freqData[i];
116
+ if (mag < noiseFloor) continue;
117
+ // local peak check
118
+ if (mag <= freqData[i - 1] || mag <= freqData[i + 1]) continue;
119
+ if (mag - freqData[i - 1] < peakDelta && mag - freqData[i + 1] < peakDelta) continue;
120
+
121
+ const freq = i * binHz;
122
+
123
+ // harmonic validation
124
+ let harmonicScore = 0;
125
+ for (let h = 2; h <= harmonics + 1; h++) {
126
+ const hBin = Math.round((freq * h) / binHz);
127
+ if (hBin >= freqData.length) break;
128
+ if (freqData[hBin] > noiseFloor && freqData[hBin] >= mag + harmonicGain) {
129
+ harmonicScore++;
130
+ }
131
+ }
132
+ // require at least half of the harmonics to be present
133
+ if (harmonicScore < Math.floor(harmonics / 2)) continue;
134
+
135
+ results.push({
136
+ freq,
137
+ note: freqToNote(freq),
138
+ cents: centDeviation(freq),
139
+ });
38
140
  }
39
- return Math.sqrt(sum / buffer.length);
141
+
142
+ return results;
40
143
  }
41
144
 
42
- function getGuitar(deviceId) {
43
- return navigator.mediaDevices.getUserMedia({
44
- audio: {
45
- ...(deviceId ? { deviceId: { exact: deviceId } } : {}),
46
- echoCancellation: false,
47
- autoGainControl: false,
48
- noiseSuppression: false,
49
- latency: 0,
50
- },
145
+ /* ─────────────────────────────────────────────
146
+ BAND-SPLIT + PARALLEL PITCHY DETECTORS
147
+ ───────────────────────────────────────────── */
148
+
149
+ /**
150
+ * Runs the McLeod (pitchy) detector on each frequency band independently.
151
+ * Each band has its own smoothing buffer and lastNote for hysteresis.
152
+ */
153
+ function createBandDetectors(clarityThreshold, bufferSize) {
154
+ return BANDS.map(([label, lo, hi]) => {
155
+ const detector = PitchDetector.forFloat32Array(FRAME_SIZE);
156
+ const noteBuffer = [];
157
+ let lastNote = null;
158
+ let lastEnergy = 0;
159
+
160
+ return { label, lo, hi, detector, noteBuffer, lastNote, lastEnergy, bufferSize, clarityThreshold };
51
161
  });
52
162
  }
53
163
 
54
- /* ====== PUBLIC API ====== */
164
+ /**
165
+ * Applies a simple software bandpass via frequency masking on a copy of
166
+ * the time-domain buffer before running pitchy. (A proper approach would
167
+ * use biquad-filtered streams, but this avoids extra Web Audio nodes and
168
+ * works well enough given pitchy's built-in autocorrelation windowing.)
169
+ *
170
+ * We apply a rectangular window in the frequency domain then IFFT via
171
+ * the AnalyserNode — but since we only have time-domain data here, we do
172
+ * the simpler thing: trust that pitchy's autocorrelation naturally weights
173
+ * the dominant frequency, and use the [lo, hi] range to gate the result.
174
+ */
175
+ function runBandDetector(band, frame, sampleRate, minEnergy, hysteresisRatio) {
176
+ const energy = rms(frame);
55
177
 
56
- export async function createPitchListener({
57
- onNote,
58
- deviceId,
59
- minEnergy = 0.03,
60
- clarityThreshold = 0.88,
61
- minFreq = 82,
62
- maxFreq = 1319,
63
- } = {}) {
64
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
65
- throw new Error("Browser does not support microphone access.");
178
+ // Silence hysteresis: note-on at minEnergy, note-off at minEnergy * hysteresisRatio
179
+ const onThreshold = minEnergy;
180
+ const offThreshold = minEnergy * hysteresisRatio;
181
+
182
+ if (energy < offThreshold) {
183
+ band.lastNote = null;
184
+ band.lastEnergy = energy;
185
+ return null;
66
186
  }
67
- if (!window.AudioContext && !window.webkitAudioContext) {
68
- throw new Error("Browser does not support Web Audio API.");
187
+
188
+ if (energy < onThreshold && band.lastNote === null) {
189
+ band.lastEnergy = energy;
190
+ return null;
69
191
  }
70
192
 
71
- const context = new AudioContext();
72
- let guitar;
73
- try {
74
- guitar = await getGuitar(deviceId);
75
- } catch (e) {
76
- console.warn("Microphone access not granted:", e.message);
193
+ const [freq, clarity] = band.detector.findPitch(frame, sampleRate);
194
+
195
+ if (clarity < band.clarityThreshold || freq < band.lo || freq > band.hi) {
196
+ band.lastEnergy = energy;
197
+ return null;
198
+ }
199
+
200
+ const note = freqToNote(freq);
201
+ band.noteBuffer.push(note);
202
+ if (band.noteBuffer.length > band.bufferSize) band.noteBuffer.shift();
203
+
204
+ const smoothed = mostCommon(band.noteBuffer);
205
+
206
+ // Onset detection: did we just cross the energy threshold from below?
207
+ const isOnset = band.lastEnergy < onThreshold && energy >= onThreshold;
208
+ const noteChanged = smoothed !== band.lastNote;
209
+
210
+ band.lastNote = smoothed;
211
+ band.lastEnergy = energy;
212
+
213
+ if (noteChanged || isOnset) {
77
214
  return {
78
- start() {
79
- console.warn("Cannot start, no mic access.");
80
- },
81
- stop() {},
215
+ source: "band:" + band.label,
216
+ note: smoothed,
217
+ freq,
218
+ clarity,
219
+ cents: centDeviation(freq),
220
+ isOnset,
221
+ energy,
82
222
  };
83
223
  }
224
+ return null;
225
+ }
226
+
227
+ /* ─────────────────────────────────────────────
228
+ MERGE & DEDUPLICATE DETECTIONS
229
+ ───────────────────────────────────────────── */
230
+
231
+ /**
232
+ * Merges notes from band detectors and FFT peak picker.
233
+ * Deduplicates by MIDI pitch (within ±1 semitone).
234
+ */
235
+ function mergeNotes(bandNotes, fftNotes, activeNotes, minEnergy) {
236
+ const all = [...bandNotes];
84
237
 
85
- if (context.state === "suspended") {
86
- await context.resume();
238
+ for (const fn of fftNotes) {
239
+ 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 });
87
242
  }
88
243
 
89
- const source = context.createMediaStreamSource(guitar);
244
+ return all;
245
+ }
90
246
 
91
- const filter = context.createBiquadFilter();
92
- filter.type = "highpass";
93
- filter.frequency.value = 70;
247
+ /* ─────────────────────────────────────────────
248
+ PUBLIC API
249
+ ───────────────────────────────────────────── */
94
250
 
95
- const analyser = context.createAnalyser();
96
- analyser.fftSize = 2048;
251
+ /**
252
+ * Creates a polyphonic pitch listener.
253
+ *
254
+ * @param {object} opts
255
+ * @param {function} opts.onNotes - Called with an array of detected note objects
256
+ * @param {function} [opts.onOnset] - Called when a new note onset is detected
257
+ * @param {string} [opts.deviceId] - Specific audio input device
258
+ * @param {number} [opts.minEnergy] - RMS energy threshold for note-on (default 0.03)
259
+ * @param {number} [opts.hysteresisRatio] - note-off = minEnergy * ratio (default 0.6)
260
+ * @param {number} [opts.clarityThreshold]- pitchy clarity gate (default 0.88)
261
+ * @param {number} [opts.minFreq] - lowest frequency to track Hz (default 82)
262
+ * @param {number} [opts.maxFreq] - highest frequency to track Hz (default 1319)
263
+ * @param {number} [opts.smoothing] - band detector smoothing buffer len (default 5)
264
+ *
265
+ * Each note object passed to onNotes:
266
+ * {
267
+ * note: string, // e.g. "E2"
268
+ * freq: number, // Hz
269
+ * cents: number, // deviation from equal temperament (-50..+50)
270
+ * clarity: number, // pitchy confidence 0-1 (band notes only)
271
+ * isOnset: boolean, // true on first frame of a new note
272
+ * source: string, // "band:bass" | "band:mid" | "band:treble" | "fft"
273
+ * energy: number, // RMS energy of this frame
274
+ * }
275
+ *
276
+ * @returns {Promise<{start, stop}>}
277
+ */
278
+ export async function createPitchListener({
279
+ onNotes,
280
+ onOnset,
281
+ deviceId,
282
+ minEnergy = 0.03,
283
+ hysteresisRatio = 0.6,
284
+ clarityThreshold = 0.88,
285
+ minFreq = 82,
286
+ maxFreq = 1319,
287
+ smoothing = 5,
288
+ } = {}) {
289
+ if (!navigator.mediaDevices?.getUserMedia) throw new Error("Browser does not support microphone access.");
290
+ const ACtx = window.AudioContext ?? window.webkitAudioContext;
291
+ if (!ACtx) throw new Error("Browser does not support Web Audio API.");
97
292
 
98
- source.connect(filter);
99
- filter.connect(analyser);
293
+ /* -- AudioContext --------------------------------------------------- */
294
+ const context = new ACtx();
100
295
 
101
- const buffer = new Float32Array(analyser.fftSize);
102
- const detector = PitchDetector.forFloat32Array(analyser.fftSize);
296
+ /* -- Microphone stream --------------------------------------------- */
297
+ let stream;
298
+ try {
299
+ stream = await navigator.mediaDevices.getUserMedia({
300
+ audio: {
301
+ ...(deviceId ? { deviceId: { exact: deviceId } } : {}),
302
+ echoCancellation: false,
303
+ autoGainControl: false,
304
+ noiseSuppression: false,
305
+ latency: 0,
306
+ },
307
+ });
308
+ } catch (e) {
309
+ console.warn("Microphone access denied:", e.message);
310
+ return { start() {}, stop() {} };
311
+ }
103
312
 
104
- // variables for control and smoothing
313
+ if (context.state === "suspended") await context.resume();
105
314
 
106
- const noteBuffer = [];
107
- const bufferSize = 5;
108
- let lastNote = null;
109
- let running = false;
315
+ /* -- AudioWorklet --------------------------------------------------- */
316
+ const blob = new Blob([WORKLET_CODE], { type: "application/javascript" });
317
+ const blobUrl = URL.createObjectURL(blob);
318
+ await context.audioWorklet.addModule(blobUrl);
319
+ URL.revokeObjectURL(blobUrl);
110
320
 
111
- // helper function for smoothing
321
+ const source = context.createMediaStreamSource(stream);
322
+ const worklet = new AudioWorkletNode(context, "pitch-processor");
112
323
 
113
- function mostCommon(arr) {
114
- const counts = {};
115
- arr.forEach((n) => (counts[n] = (counts[n] || 0) + 1));
116
- let max = 0,
117
- winner = null;
118
- for (const n in counts) {
119
- if (counts[n] > max) {
120
- max = counts[n];
121
- winner = n;
122
- }
123
- }
124
- return winner;
125
- }
324
+ /* -- AnalyserNode for FFT ------------------------------------------ */
325
+ const analyser = context.createAnalyser();
326
+ analyser.fftSize = 8192; // high resolution for FFT peak picker
327
+ analyser.smoothingTimeConstant = 0.5;
328
+ const fftBuf = new Float32Array(analyser.frequencyBinCount);
126
329
 
127
- function read() {
128
- if (!running) return;
330
+ /* -- Highpass filter ------------------------------------------------ */
331
+ const hpf = context.createBiquadFilter();
332
+ hpf.type = "highpass";
333
+ hpf.frequency.value = 70;
129
334
 
130
- analyser.getFloatTimeDomainData(buffer);
335
+ source.connect(hpf);
336
+ hpf.connect(analyser);
337
+ hpf.connect(worklet); // worklet gets filtered signal too
131
338
 
132
- const energy = rms(buffer);
133
- if (energy >= minEnergy) {
134
- const [freq, clarity] = detector.findPitch(buffer, context.sampleRate);
339
+ /* -- Band detectors ------------------------------------------------- */
340
+ const bands = createBandDetectors(clarityThreshold, smoothing);
341
+ const activeNotes = new Set(); // currently sounding notes (for onset tracking)
135
342
 
136
- if (clarity >= clarityThreshold && freq >= minFreq && freq <= maxFreq) {
137
- const note = freqToNote(freq);
343
+ /* -- Main processing handler --------------------------------------- */
344
+ worklet.port.onmessage = ({ data: frame }) => {
345
+ // --- Band-split detections ---
346
+ const bandNotes = bands
347
+ .map((band) => runBandDetector(band, frame, context.sampleRate, minEnergy, hysteresisRatio))
348
+ .filter(Boolean);
138
349
 
139
- noteBuffer.push(note);
140
- if (noteBuffer.length > bufferSize) noteBuffer.shift();
350
+ // --- FFT peak detections ---
351
+ analyser.getFloatFrequencyData(fftBuf);
352
+ const fftNotes = fftPeakNotes(fftBuf, context.sampleRate, { minFreq, maxFreq });
141
353
 
142
- const smoothedNote = mostCommon(noteBuffer);
354
+ // --- Merge & deduplicate ---
355
+ const detected = mergeNotes(bandNotes, fftNotes, activeNotes);
143
356
 
144
- if (smoothedNote !== lastNote) {
145
- lastNote = smoothedNote;
146
- onNote(smoothedNote, freq, clarity);
147
- }
148
- }
149
- }
357
+ if (detected.length === 0) return;
150
358
 
151
- requestAnimationFrame(read);
152
- }
359
+ // Update active note set
360
+ detected.forEach(({ note }) => activeNotes.add(note));
361
+
362
+ // Fire callbacks
363
+ if (onNotes) onNotes(detected);
364
+
365
+ const onsets = detected.filter((n) => n.isOnset);
366
+ if (onsets.length && onOnset) onOnset(onsets);
367
+ };
368
+
369
+ let connected = false;
153
370
 
154
371
  return {
155
372
  start() {
156
- running = true;
157
- read();
373
+ if (connected) return;
374
+ connected = true;
375
+ worklet.connect(context.destination); // worklet needs to be in the graph to run
158
376
  },
377
+
159
378
  stop() {
160
- running = false;
161
- if (context && context.state !== "closed") {
162
- context
163
- .close()
164
- .catch((err) => console.warn("Error closing audio context:", err));
165
- }
379
+ connected = false;
380
+ worklet.disconnect();
381
+ stream.getTracks().forEach((t) => t.stop());
382
+ context.close().catch(() => {});
166
383
  },
167
384
  };
168
- }
385
+ }