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.pow
13
- import kotlin.math.abs
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
- // Configurable buffer size - can be changed from JS
34
- // Larger buffer = better low frequency detection, more latency
35
- // Smaller buffer = better high frequency detection, less latency
36
- private var bufferSize = 2048
37
- // Configurable algorithm - can be changed from JS
38
- private var algorithm = PitchEstimationAlgorithm.MPM
39
- private var algorithmName = "mpm"
40
-
41
- private var dispatcher: AudioDispatcher? = null
42
- private var processor: AudioProcessor? = null
43
- private var runner: Thread? = null
44
-
45
- private val handler = PitchDetectionHandler { res, e ->
46
- val pitchInHz = res.pitch
47
- val decibel = e.getdBSPL().toFloat()
48
- // Only process if above the level threshold
49
- if (decibel > levelThreshold) {
50
- // Note: Not logging every pitch detection to JS to avoid flooding
51
- // Use adb logcat for verbose pitch logs
52
- Log.v(TAG, "Pitch detected: ${pitchInHz}Hz at ${decibel}dB")
53
- process(pitchInHz, decibel)
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 fun prepare() {
58
- try {
59
- logStatus("debug", "prepare() called with bufferSize=$bufferSize, algorithm=$algorithmName")
60
- processor = PitchProcessor(algorithm, 22050f, bufferSize, handler)
61
- logStatus("debug", "PitchProcessor created successfully")
62
- dispatcher = AudioDispatcherFactory.fromDefaultMicrophone(22050, bufferSize, 0)
63
- logStatus("debug", "AudioDispatcher created successfully: $dispatcher")
64
- dispatcher?.addAudioProcessor(processor)
65
- logStatus("debug", "AudioProcessor added to dispatcher")
66
- } catch (e: Exception) {
67
- logStatus("error", "Error in prepare(): ${e.message}")
68
- throw e
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 process(pitchInHz: Float, decibel: Float) {
73
- if (pitchInHz <= 0 || pitchInHz.isNaN()) {
74
- return
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
- // Calculate MIDI note number (A4 = 440Hz = MIDI 69)
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
- val note = notes[noteIndex]
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 = offsetPercentage
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
- prepare()
154
- runner = Thread(dispatcher)
155
- logStatus("debug", "Thread created, starting...")
156
- runner?.start()
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", "Recording started successfully")
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
- dispatcher?.stop()
167
- runner?.interrupt()
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
- return isRecording
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-simple-note-pitch-detector",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "a simple react native library to detect the pitch of the input recording",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",