react-native-simple-note-pitch-detector 0.7.0 → 0.7.2
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.
|
@@ -2,15 +2,19 @@ package expo.modules.simplepitchdetector
|
|
|
2
2
|
|
|
3
3
|
import android.util.Log
|
|
4
4
|
import be.tarsos.dsp.AudioDispatcher
|
|
5
|
+
import be.tarsos.dsp.AudioEvent
|
|
5
6
|
import be.tarsos.dsp.AudioProcessor
|
|
6
7
|
import be.tarsos.dsp.io.android.AudioDispatcherFactory
|
|
7
8
|
import be.tarsos.dsp.pitch.PitchDetectionHandler
|
|
9
|
+
import be.tarsos.dsp.pitch.PitchDetectionResult
|
|
8
10
|
import be.tarsos.dsp.pitch.PitchProcessor
|
|
9
11
|
import be.tarsos.dsp.pitch.PitchProcessor.PitchEstimationAlgorithm
|
|
12
|
+
import be.tarsos.dsp.util.fft.FFT
|
|
10
13
|
import kotlin.math.log2
|
|
11
14
|
import kotlin.math.round
|
|
12
|
-
import kotlin.math.
|
|
13
|
-
import kotlin.math.
|
|
15
|
+
import kotlin.math.ln
|
|
16
|
+
import kotlin.math.cos
|
|
17
|
+
import kotlin.math.PI
|
|
14
18
|
|
|
15
19
|
private const val TAG = "PitchAnalyzer"
|
|
16
20
|
|
|
@@ -22,6 +26,14 @@ data class PitchData(
|
|
|
22
26
|
val offset: Float
|
|
23
27
|
)
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Dual-dispatcher pitch detector:
|
|
31
|
+
* - HPS (Harmonic Product Spectrum, h=2,3,4,5) on its own AudioDispatcher for low notes (<200Hz)
|
|
32
|
+
* - MPM (McLeod Pitch Method) on a separate AudioDispatcher for mid/high notes (>=200Hz)
|
|
33
|
+
*
|
|
34
|
+
* Two completely separate microphone streams to avoid any shared state or buffer corruption.
|
|
35
|
+
* Previous experiments proved that ANY combination of HPS+MPM in the same dispatcher degrades HPS.
|
|
36
|
+
*/
|
|
25
37
|
class PitchAnalyzer {
|
|
26
38
|
|
|
27
39
|
private val notes = arrayOf("C","C#","D","D#","E","F","F#","G","G#","A","A#","B")
|
|
@@ -30,74 +42,223 @@ class PitchAnalyzer {
|
|
|
30
42
|
private var onStatus: ((String, String) -> Unit)? = null
|
|
31
43
|
private var isRecording = false
|
|
32
44
|
private var levelThreshold = -30f
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
private var
|
|
39
|
-
private var
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
private var
|
|
43
|
-
private var
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
private var sampleRate = 44100
|
|
46
|
+
private var bufferSize = 8192
|
|
47
|
+
private var algorithmName = "hps"
|
|
48
|
+
|
|
49
|
+
// HPS dispatcher (low notes)
|
|
50
|
+
private var hpsDispatcher: AudioDispatcher? = null
|
|
51
|
+
private var hpsThread: Thread? = null
|
|
52
|
+
|
|
53
|
+
// MPM dispatcher (mid/high notes)
|
|
54
|
+
private var mpmDispatcher: AudioDispatcher? = null
|
|
55
|
+
private var mpmThread: Thread? = null
|
|
56
|
+
|
|
57
|
+
// HPS pre-allocated buffers
|
|
58
|
+
private var fftSize = 0
|
|
59
|
+
private var freqResolution = 0f
|
|
60
|
+
private var hpsFft: FFT? = null
|
|
61
|
+
private var fftBuffer: FloatArray? = null
|
|
62
|
+
private var magnitudes: FloatArray? = null
|
|
63
|
+
private var hpsProduct: FloatArray? = null
|
|
64
|
+
private var hannWindow: FloatArray? = null
|
|
65
|
+
|
|
66
|
+
// Adaptive noise gate: tracks background noise floor per-dispatcher
|
|
67
|
+
// A note must be this many dB above the noise floor to be emitted
|
|
68
|
+
private val noiseGateMarginDb = 10f
|
|
69
|
+
// Exponential moving average decay for noise floor (0.01 = slow adaptation)
|
|
70
|
+
private val noiseFloorAlpha = 0.01f
|
|
71
|
+
@Volatile private var hpsNoiseFloor = -60f
|
|
72
|
+
@Volatile private var yinNoiseFloor = -60f
|
|
73
|
+
|
|
74
|
+
// HPS searches low range only: A0 (27.5Hz) to ~G3 (196Hz)
|
|
75
|
+
private val hpsMinHz = 26f
|
|
76
|
+
private val hpsMaxHz = 200f
|
|
77
|
+
|
|
78
|
+
// MPM crossover: only emit MPM results >= this frequency
|
|
79
|
+
private val mpmMinHz = 200f
|
|
80
|
+
// MPM max: reject garbage above piano range (C8 = 4186Hz)
|
|
81
|
+
private val mpmMaxHz = 4200f
|
|
82
|
+
|
|
83
|
+
private fun updateNoiseFloor(currentFloor: Float, dB: Float): Float {
|
|
84
|
+
// Only update noise floor when signal is quiet (close to current floor)
|
|
85
|
+
// This prevents played notes from raising the floor
|
|
86
|
+
return if (dB < currentFloor + noiseGateMarginDb) {
|
|
87
|
+
currentFloor + noiseFloorAlpha * (dB - currentFloor)
|
|
88
|
+
} else {
|
|
89
|
+
currentFloor
|
|
54
90
|
}
|
|
55
91
|
}
|
|
56
92
|
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
93
|
+
private val hpsProcessor = object : AudioProcessor {
|
|
94
|
+
override fun process(audioEvent: AudioEvent): Boolean {
|
|
95
|
+
val buffer = audioEvent.floatBuffer
|
|
96
|
+
val dB = audioEvent.getdBSPL().toFloat()
|
|
97
|
+
|
|
98
|
+
// Update noise floor estimate
|
|
99
|
+
hpsNoiseFloor = updateNoiseFloor(hpsNoiseFloor, dB)
|
|
100
|
+
|
|
101
|
+
// Adaptive gate: only process if dB is above noise floor + margin
|
|
102
|
+
if (dB > hpsNoiseFloor + noiseGateMarginDb) {
|
|
103
|
+
try {
|
|
104
|
+
val pitch = detectPitchHPS(buffer)
|
|
105
|
+
if (pitch > 0f) {
|
|
106
|
+
emitPitch(pitch, dB)
|
|
107
|
+
}
|
|
108
|
+
} catch (ex: Exception) {
|
|
109
|
+
Log.e(TAG, "HPS failed: ${ex.message}")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override fun processingFinished() {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private fun detectPitchHPS(buffer: FloatArray): Float {
|
|
119
|
+
val fft = hpsFft ?: return -1f
|
|
120
|
+
val fftBuf = fftBuffer ?: return -1f
|
|
121
|
+
val mags = magnitudes ?: return -1f
|
|
122
|
+
val hps = hpsProduct ?: return -1f
|
|
123
|
+
val window = hannWindow ?: return -1f
|
|
124
|
+
|
|
125
|
+
// Window and zero-pad
|
|
126
|
+
for (i in buffer.indices) {
|
|
127
|
+
fftBuf[i] = buffer[i] * window[i]
|
|
128
|
+
}
|
|
129
|
+
for (i in buffer.size until fftSize) {
|
|
130
|
+
fftBuf[i] = 0f
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// FFT
|
|
134
|
+
fft.forwardTransform(fftBuf)
|
|
135
|
+
fft.modulus(fftBuf, mags)
|
|
136
|
+
|
|
137
|
+
// HPS: multiply magnitudes at harmonics 2, 3, 4, 5
|
|
138
|
+
val hpsSize = mags.size / 5
|
|
139
|
+
for (i in 0 until hpsSize) {
|
|
140
|
+
hps[i] = mags[2 * i] * mags[3 * i] * mags[4 * i] * mags[5 * i]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Search for peak in low range only
|
|
144
|
+
val minBin = (hpsMinHz / freqResolution).toInt().coerceAtLeast(1)
|
|
145
|
+
val maxBin = (hpsMaxHz / freqResolution).toInt().coerceAtMost(hpsSize - 1)
|
|
146
|
+
|
|
147
|
+
var peakBin = minBin
|
|
148
|
+
var peakVal = hps[minBin]
|
|
149
|
+
for (i in minBin + 1..maxBin) {
|
|
150
|
+
if (hps[i] > peakVal) { peakBin = i; peakVal = hps[i] }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// SNR check
|
|
154
|
+
val snrStart = (peakBin - 200).coerceAtLeast(minBin)
|
|
155
|
+
val snrEnd = (peakBin + 200).coerceAtMost(maxBin)
|
|
156
|
+
var sum = 0.0
|
|
157
|
+
for (i in snrStart..snrEnd) { sum += hps[i].toDouble() }
|
|
158
|
+
val mean = sum / (snrEnd - snrStart + 1)
|
|
159
|
+
val snr = if (mean > 0.0) peakVal.toDouble() / mean else 0.0
|
|
160
|
+
if (snr < 12.0) return -1f
|
|
161
|
+
|
|
162
|
+
return interpolatePeak(hps, peakBin, minBin, maxBin) * freqResolution
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private fun interpolatePeak(spectrum: FloatArray, peakBin: Int, minBin: Int, maxBin: Int): Float {
|
|
166
|
+
if (peakBin <= minBin || peakBin >= maxBin) return peakBin.toFloat()
|
|
167
|
+
val prev = spectrum[peakBin - 1].coerceAtLeast(1e-30f)
|
|
168
|
+
val curr = spectrum[peakBin].coerceAtLeast(1e-30f)
|
|
169
|
+
val next = spectrum[peakBin + 1].coerceAtLeast(1e-30f)
|
|
170
|
+
val logPrev = ln(prev)
|
|
171
|
+
val logCurr = ln(curr)
|
|
172
|
+
val logNext = ln(next)
|
|
173
|
+
val denom = logPrev - 2f * logCurr + logNext
|
|
174
|
+
return if (denom != 0f) {
|
|
175
|
+
peakBin + 0.5f * (logPrev - logNext) / denom
|
|
176
|
+
} else {
|
|
177
|
+
peakBin.toFloat()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private fun prepareHPS() {
|
|
182
|
+
val overlap = (bufferSize * 3) / 4
|
|
183
|
+
logStatus("debug", "prepareHPS() sampleRate=$sampleRate, bufferSize=$bufferSize, overlap=$overlap")
|
|
184
|
+
|
|
185
|
+
// 4x zero-padding for frequency resolution
|
|
186
|
+
fftSize = bufferSize * 4
|
|
187
|
+
freqResolution = sampleRate.toFloat() / fftSize
|
|
188
|
+
hpsFft = FFT(fftSize)
|
|
189
|
+
fftBuffer = FloatArray(fftSize)
|
|
190
|
+
magnitudes = FloatArray(fftSize / 2)
|
|
191
|
+
hpsProduct = FloatArray(fftSize / (2 * 5))
|
|
192
|
+
hannWindow = FloatArray(bufferSize) { i ->
|
|
193
|
+
(0.5 * (1.0 - cos(2.0 * PI * i / (bufferSize - 1)))).toFloat()
|
|
69
194
|
}
|
|
195
|
+
|
|
196
|
+
val maxHpsHz = (fftSize / (2 * 5)) * freqResolution
|
|
197
|
+
logStatus("debug", "HPS: fftSize=$fftSize, freqRes=${freqResolution}Hz/bin, search=${hpsMinHz}-${hpsMaxHz}Hz, maxHpsHz=$maxHpsHz")
|
|
198
|
+
|
|
199
|
+
hpsDispatcher = AudioDispatcherFactory.fromDefaultMicrophone(sampleRate, bufferSize, overlap)
|
|
200
|
+
hpsDispatcher?.addAudioProcessor(hpsProcessor)
|
|
201
|
+
|
|
202
|
+
logStatus("debug", "HPS dispatcher prepared (low notes <${hpsMaxHz}Hz)")
|
|
70
203
|
}
|
|
71
204
|
|
|
72
|
-
private fun
|
|
73
|
-
|
|
74
|
-
|
|
205
|
+
private fun prepareMPM() {
|
|
206
|
+
val mpmBufferSize = 2048
|
|
207
|
+
val mpmOverlap = (mpmBufferSize * 3) / 4
|
|
208
|
+
logStatus("debug", "prepareMPM() sampleRate=$sampleRate, bufferSize=$mpmBufferSize, overlap=$mpmOverlap")
|
|
209
|
+
|
|
210
|
+
val pitchHandler = PitchDetectionHandler { result: PitchDetectionResult, event: AudioEvent ->
|
|
211
|
+
val pitch = result.pitch
|
|
212
|
+
val dB = event.getdBSPL().toFloat()
|
|
213
|
+
|
|
214
|
+
// Update YIN noise floor
|
|
215
|
+
yinNoiseFloor = updateNoiseFloor(yinNoiseFloor, dB)
|
|
216
|
+
|
|
217
|
+
if (pitch > 0 && pitch >= mpmMinHz && pitch <= mpmMaxHz && result.isPitched
|
|
218
|
+
&& dB > yinNoiseFloor + noiseGateMarginDb) {
|
|
219
|
+
emitPitch(pitch, dB)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Use YIN instead of MPM — McLeodPitchMethod.peakPicking throws AssertionError
|
|
224
|
+
// when two AudioRecord instances compete for the microphone
|
|
225
|
+
val yinPitchProcessor = PitchProcessor(PitchEstimationAlgorithm.YIN, sampleRate.toFloat(), mpmBufferSize, pitchHandler)
|
|
226
|
+
val safeYinProcessor = object : AudioProcessor {
|
|
227
|
+
override fun process(audioEvent: AudioEvent): Boolean {
|
|
228
|
+
try {
|
|
229
|
+
return yinPitchProcessor.process(audioEvent)
|
|
230
|
+
} catch (e: Exception) {
|
|
231
|
+
Log.w(TAG, "YIN error (ignored): ${e.message}")
|
|
232
|
+
return true
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
override fun processingFinished() {
|
|
236
|
+
yinPitchProcessor.processingFinished()
|
|
237
|
+
}
|
|
75
238
|
}
|
|
76
239
|
|
|
77
|
-
|
|
240
|
+
mpmDispatcher = AudioDispatcherFactory.fromDefaultMicrophone(sampleRate, mpmBufferSize, mpmOverlap)
|
|
241
|
+
mpmDispatcher?.addAudioProcessor(safeYinProcessor)
|
|
242
|
+
|
|
243
|
+
logStatus("debug", "YIN dispatcher prepared (mid/high notes ${mpmMinHz}-${mpmMaxHz}Hz)")
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private fun emitPitch(pitchInHz: Float, decibel: Float) {
|
|
247
|
+
if (pitchInHz <= 0 || pitchInHz.isNaN()) return
|
|
248
|
+
|
|
78
249
|
val midiNote = 12 * log2(pitchInHz / 440f) + 69
|
|
79
250
|
val roundedMidiNote = round(midiNote).toInt()
|
|
80
|
-
|
|
81
|
-
// Calculate note index (0-11) and octave
|
|
82
251
|
val noteIndex = ((roundedMidiNote % 12) + 12) % 12
|
|
83
252
|
val octave = (roundedMidiNote / 12) - 1
|
|
84
|
-
|
|
85
|
-
// Calculate offset from perfect pitch (in cents, then convert to percentage)
|
|
86
|
-
// 100 cents = 1 semitone, so we express as percentage of a semitone
|
|
87
253
|
val centsOff = (midiNote - roundedMidiNote) * 100
|
|
88
|
-
val offsetPercentage = centsOff // Already in a reasonable range (-50 to +50)
|
|
89
254
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
val pitchData = PitchData(
|
|
93
|
-
note = note,
|
|
255
|
+
onPitchDetected(PitchData(
|
|
256
|
+
note = notes[noteIndex],
|
|
94
257
|
octave = octave,
|
|
95
258
|
frequency = pitchInHz,
|
|
96
259
|
amplitude = decibel,
|
|
97
|
-
offset =
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
onPitchDetected(pitchData)
|
|
260
|
+
offset = centsOff
|
|
261
|
+
))
|
|
101
262
|
}
|
|
102
263
|
|
|
103
264
|
fun setOnPitchDetectedListener(listener: (PitchData) -> Unit) {
|
|
@@ -122,53 +283,63 @@ class PitchAnalyzer {
|
|
|
122
283
|
this.levelThreshold = threshold
|
|
123
284
|
}
|
|
124
285
|
|
|
286
|
+
fun setSampleRate(rate: Int) {
|
|
287
|
+
this.sampleRate = rate
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fun getSampleRate(): Int = this.sampleRate
|
|
291
|
+
|
|
125
292
|
fun setBufferSize(size: Int) {
|
|
126
293
|
this.bufferSize = size
|
|
127
294
|
}
|
|
128
295
|
|
|
129
|
-
fun getBufferSize(): Int
|
|
130
|
-
return this.bufferSize
|
|
131
|
-
}
|
|
296
|
+
fun getBufferSize(): Int = this.bufferSize
|
|
132
297
|
|
|
133
298
|
fun setAlgorithm(name: String) {
|
|
134
299
|
this.algorithmName = name.lowercase()
|
|
135
|
-
this.algorithm = when (this.algorithmName) {
|
|
136
|
-
"yin" -> PitchEstimationAlgorithm.YIN
|
|
137
|
-
"fft_yin" -> PitchEstimationAlgorithm.FFT_YIN
|
|
138
|
-
"mpm" -> PitchEstimationAlgorithm.MPM
|
|
139
|
-
"fft_pitch" -> PitchEstimationAlgorithm.FFT_PITCH
|
|
140
|
-
"dynamic_wavelet" -> PitchEstimationAlgorithm.DYNAMIC_WAVELET
|
|
141
|
-
"amdf" -> PitchEstimationAlgorithm.AMDF
|
|
142
|
-
else -> PitchEstimationAlgorithm.FFT_YIN // Default
|
|
143
|
-
}
|
|
144
300
|
}
|
|
145
301
|
|
|
146
|
-
fun getAlgorithm(): String
|
|
147
|
-
return this.algorithmName
|
|
148
|
-
}
|
|
302
|
+
fun getAlgorithm(): String = this.algorithmName
|
|
149
303
|
|
|
150
304
|
fun start() {
|
|
151
305
|
try {
|
|
152
|
-
logStatus("debug", "start() called")
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
306
|
+
logStatus("debug", "start() called — dual dispatcher mode (HPS + MPM)")
|
|
307
|
+
|
|
308
|
+
// Start HPS dispatcher first
|
|
309
|
+
prepareHPS()
|
|
310
|
+
hpsThread = Thread(hpsDispatcher)
|
|
311
|
+
hpsThread?.name = "HPS-Thread"
|
|
312
|
+
hpsThread?.start()
|
|
313
|
+
logStatus("debug", "HPS thread started")
|
|
314
|
+
|
|
315
|
+
// Start MPM dispatcher second
|
|
316
|
+
prepareMPM()
|
|
317
|
+
mpmThread = Thread(mpmDispatcher)
|
|
318
|
+
mpmThread?.name = "MPM-Thread"
|
|
319
|
+
mpmThread?.start()
|
|
320
|
+
logStatus("debug", "MPM thread started")
|
|
321
|
+
|
|
157
322
|
isRecording = true
|
|
158
|
-
logStatus("debug", "
|
|
323
|
+
logStatus("debug", "Dual dispatcher recording started successfully")
|
|
159
324
|
} catch (e: Exception) {
|
|
160
325
|
logStatus("error", "Error in start(): ${e.message}")
|
|
326
|
+
// Clean up whatever was started
|
|
327
|
+
stop()
|
|
161
328
|
isRecording = false
|
|
162
329
|
}
|
|
163
330
|
}
|
|
164
331
|
|
|
165
332
|
fun stop() {
|
|
166
|
-
|
|
167
|
-
|
|
333
|
+
hpsDispatcher?.stop()
|
|
334
|
+
mpmDispatcher?.stop()
|
|
335
|
+
hpsThread?.interrupt()
|
|
336
|
+
mpmThread?.interrupt()
|
|
337
|
+
hpsDispatcher = null
|
|
338
|
+
mpmDispatcher = null
|
|
339
|
+
hpsThread = null
|
|
340
|
+
mpmThread = null
|
|
168
341
|
isRecording = false
|
|
169
342
|
}
|
|
170
343
|
|
|
171
|
-
fun isRecording(): Boolean
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
}
|
|
344
|
+
fun isRecording(): Boolean = isRecording
|
|
345
|
+
}
|
|
@@ -58,6 +58,14 @@ class ReactNativeSimpleNotePitchDetectorModule : Module() {
|
|
|
58
58
|
pitchAnalyzer.setLevelThreshold(threshold.toFloat())
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
Function("setSampleRate") { rate: Int ->
|
|
62
|
+
pitchAnalyzer.setSampleRate(rate)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Function("getSampleRate") {
|
|
66
|
+
pitchAnalyzer.getSampleRate()
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
// Allow JS to configure the buffer size
|
|
62
70
|
// Must be called before start() to take effect
|
|
63
71
|
// Common values: 1024 (better for high frequencies), 2048 (balanced), 4096 (better for low frequencies)
|
package/package.json
CHANGED