note-listener 1.1.2 → 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.
- package/README.md +126 -38
- package/package.json +1 -2
- 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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
###
|
|
38
|
+
### 1. List available audio inputs (optional)
|
|
28
39
|
|
|
29
40
|
```js
|
|
30
|
-
import { listAudioInputs
|
|
41
|
+
import { listAudioInputs } from "note-listener"
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
59
|
+
deviceId: inputs[0].deviceId,
|
|
38
60
|
minEnergy: 0.03,
|
|
39
|
-
onNote: (note,
|
|
40
|
-
console.log(
|
|
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
|
-
//
|
|
47
|
-
// listener.stop()
|
|
107
|
+
// Later...
|
|
108
|
+
// listener.stop()
|
|
48
109
|
```
|
|
49
110
|
|
|
50
|
-
### Vue 3
|
|
111
|
+
### Vue 3
|
|
51
112
|
|
|
52
113
|
```vue
|
|
53
114
|
<template>
|
|
54
115
|
<div>
|
|
55
|
-
<
|
|
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
|
|
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
|
-
|
|
159
|
+
function stop() {
|
|
98
160
|
if (listener) listener.stop()
|
|
99
|
-
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
onBeforeUnmount(stop)
|
|
100
164
|
</script>
|
|
101
165
|
```
|
|
102
166
|
|
|
103
|
-
##
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
package/src/index.js
CHANGED
|
@@ -1,168 +1,385 @@
|
|
|
1
1
|
import { PitchDetector } from "pitchy";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
141
|
+
|
|
142
|
+
return results;
|
|
40
143
|
}
|
|
41
144
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
187
|
+
|
|
188
|
+
if (energy < onThreshold && band.lastNote === null) {
|
|
189
|
+
band.lastEnergy = energy;
|
|
190
|
+
return null;
|
|
69
191
|
}
|
|
70
192
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
244
|
+
return all;
|
|
245
|
+
}
|
|
90
246
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
247
|
+
/* ─────────────────────────────────────────────
|
|
248
|
+
PUBLIC API
|
|
249
|
+
───────────────────────────────────────────── */
|
|
94
250
|
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
293
|
+
/* -- AudioContext --------------------------------------------------- */
|
|
294
|
+
const context = new ACtx();
|
|
100
295
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
313
|
+
if (context.state === "suspended") await context.resume();
|
|
105
314
|
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
321
|
+
const source = context.createMediaStreamSource(stream);
|
|
322
|
+
const worklet = new AudioWorkletNode(context, "pitch-processor");
|
|
112
323
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
128
|
-
|
|
330
|
+
/* -- Highpass filter ------------------------------------------------ */
|
|
331
|
+
const hpf = context.createBiquadFilter();
|
|
332
|
+
hpf.type = "highpass";
|
|
333
|
+
hpf.frequency.value = 70;
|
|
129
334
|
|
|
130
|
-
|
|
335
|
+
source.connect(hpf);
|
|
336
|
+
hpf.connect(analyser);
|
|
337
|
+
hpf.connect(worklet); // worklet gets filtered signal too
|
|
131
338
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
339
|
+
/* -- Band detectors ------------------------------------------------- */
|
|
340
|
+
const bands = createBandDetectors(clarityThreshold, smoothing);
|
|
341
|
+
const activeNotes = new Set(); // currently sounding notes (for onset tracking)
|
|
135
342
|
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
350
|
+
// --- FFT peak detections ---
|
|
351
|
+
analyser.getFloatFrequencyData(fftBuf);
|
|
352
|
+
const fftNotes = fftPeakNotes(fftBuf, context.sampleRate, { minFreq, maxFreq });
|
|
141
353
|
|
|
142
|
-
|
|
354
|
+
// --- Merge & deduplicate ---
|
|
355
|
+
const detected = mergeNotes(bandNotes, fftNotes, activeNotes);
|
|
143
356
|
|
|
144
|
-
|
|
145
|
-
lastNote = smoothedNote;
|
|
146
|
-
onNote(smoothedNote, freq, clarity);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
357
|
+
if (detected.length === 0) return;
|
|
150
358
|
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
}
|