react-native-simple-note-pitch-detector 0.7.0 → 0.7.1

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,200 @@ 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
+ // HPS searches low range only: A0 (27.5Hz) to ~G3 (196Hz)
67
+ private val hpsMinHz = 26f
68
+ private val hpsMaxHz = 200f
69
+
70
+ // MPM crossover: only emit MPM results >= this frequency
71
+ private val mpmMinHz = 200f
72
+ // MPM max: reject garbage above piano range (C8 = 4186Hz)
73
+ private val mpmMaxHz = 4200f
74
+
75
+ private val hpsProcessor = object : AudioProcessor {
76
+ override fun process(audioEvent: AudioEvent): Boolean {
77
+ val buffer = audioEvent.floatBuffer
78
+ val dB = audioEvent.getdBSPL().toFloat()
79
+
80
+ if (dB > levelThreshold) {
81
+ try {
82
+ val pitch = detectPitchHPS(buffer)
83
+ if (pitch > 0f) {
84
+ emitPitch(pitch, dB)
85
+ }
86
+ } catch (ex: Exception) {
87
+ Log.e(TAG, "HPS failed: ${ex.message}")
88
+ }
89
+ }
90
+ return true
54
91
  }
92
+
93
+ override fun processingFinished() {}
55
94
  }
56
95
 
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
96
+ private fun detectPitchHPS(buffer: FloatArray): Float {
97
+ val fft = hpsFft ?: return -1f
98
+ val fftBuf = fftBuffer ?: return -1f
99
+ val mags = magnitudes ?: return -1f
100
+ val hps = hpsProduct ?: return -1f
101
+ val window = hannWindow ?: return -1f
102
+
103
+ // Window and zero-pad
104
+ for (i in buffer.indices) {
105
+ fftBuf[i] = buffer[i] * window[i]
106
+ }
107
+ for (i in buffer.size until fftSize) {
108
+ fftBuf[i] = 0f
109
+ }
110
+
111
+ // FFT
112
+ fft.forwardTransform(fftBuf)
113
+ fft.modulus(fftBuf, mags)
114
+
115
+ // HPS: multiply magnitudes at harmonics 2, 3, 4, 5
116
+ val hpsSize = mags.size / 5
117
+ for (i in 0 until hpsSize) {
118
+ hps[i] = mags[2 * i] * mags[3 * i] * mags[4 * i] * mags[5 * i]
119
+ }
120
+
121
+ // Search for peak in low range only
122
+ val minBin = (hpsMinHz / freqResolution).toInt().coerceAtLeast(1)
123
+ val maxBin = (hpsMaxHz / freqResolution).toInt().coerceAtMost(hpsSize - 1)
124
+
125
+ var peakBin = minBin
126
+ var peakVal = hps[minBin]
127
+ for (i in minBin + 1..maxBin) {
128
+ if (hps[i] > peakVal) { peakBin = i; peakVal = hps[i] }
129
+ }
130
+
131
+ // SNR check
132
+ val snrStart = (peakBin - 200).coerceAtLeast(minBin)
133
+ val snrEnd = (peakBin + 200).coerceAtMost(maxBin)
134
+ var sum = 0.0
135
+ for (i in snrStart..snrEnd) { sum += hps[i].toDouble() }
136
+ val mean = sum / (snrEnd - snrStart + 1)
137
+ val snr = if (mean > 0.0) peakVal.toDouble() / mean else 0.0
138
+ if (snr < 12.0) return -1f
139
+
140
+ return interpolatePeak(hps, peakBin, minBin, maxBin) * freqResolution
141
+ }
142
+
143
+ private fun interpolatePeak(spectrum: FloatArray, peakBin: Int, minBin: Int, maxBin: Int): Float {
144
+ if (peakBin <= minBin || peakBin >= maxBin) return peakBin.toFloat()
145
+ val prev = spectrum[peakBin - 1].coerceAtLeast(1e-30f)
146
+ val curr = spectrum[peakBin].coerceAtLeast(1e-30f)
147
+ val next = spectrum[peakBin + 1].coerceAtLeast(1e-30f)
148
+ val logPrev = ln(prev)
149
+ val logCurr = ln(curr)
150
+ val logNext = ln(next)
151
+ val denom = logPrev - 2f * logCurr + logNext
152
+ return if (denom != 0f) {
153
+ peakBin + 0.5f * (logPrev - logNext) / denom
154
+ } else {
155
+ peakBin.toFloat()
156
+ }
157
+ }
158
+
159
+ private fun prepareHPS() {
160
+ val overlap = (bufferSize * 3) / 4
161
+ logStatus("debug", "prepareHPS() sampleRate=$sampleRate, bufferSize=$bufferSize, overlap=$overlap")
162
+
163
+ // 4x zero-padding for frequency resolution
164
+ fftSize = bufferSize * 4
165
+ freqResolution = sampleRate.toFloat() / fftSize
166
+ hpsFft = FFT(fftSize)
167
+ fftBuffer = FloatArray(fftSize)
168
+ magnitudes = FloatArray(fftSize / 2)
169
+ hpsProduct = FloatArray(fftSize / (2 * 5))
170
+ hannWindow = FloatArray(bufferSize) { i ->
171
+ (0.5 * (1.0 - cos(2.0 * PI * i / (bufferSize - 1)))).toFloat()
69
172
  }
173
+
174
+ val maxHpsHz = (fftSize / (2 * 5)) * freqResolution
175
+ logStatus("debug", "HPS: fftSize=$fftSize, freqRes=${freqResolution}Hz/bin, search=${hpsMinHz}-${hpsMaxHz}Hz, maxHpsHz=$maxHpsHz")
176
+
177
+ hpsDispatcher = AudioDispatcherFactory.fromDefaultMicrophone(sampleRate, bufferSize, overlap)
178
+ hpsDispatcher?.addAudioProcessor(hpsProcessor)
179
+
180
+ logStatus("debug", "HPS dispatcher prepared (low notes <${hpsMaxHz}Hz)")
70
181
  }
71
182
 
72
- private fun process(pitchInHz: Float, decibel: Float) {
73
- if (pitchInHz <= 0 || pitchInHz.isNaN()) {
74
- return
183
+ private fun prepareMPM() {
184
+ val mpmBufferSize = 2048
185
+ val mpmOverlap = (mpmBufferSize * 3) / 4
186
+ logStatus("debug", "prepareMPM() sampleRate=$sampleRate, bufferSize=$mpmBufferSize, overlap=$mpmOverlap")
187
+
188
+ val pitchHandler = PitchDetectionHandler { result: PitchDetectionResult, event: AudioEvent ->
189
+ val pitch = result.pitch
190
+ if (pitch > 0 && pitch >= mpmMinHz && pitch <= mpmMaxHz && result.isPitched) {
191
+ val dB = event.getdBSPL().toFloat()
192
+ emitPitch(pitch, dB)
193
+ }
75
194
  }
76
195
 
77
- // Calculate MIDI note number (A4 = 440Hz = MIDI 69)
196
+ // Use YIN instead of MPM McLeodPitchMethod.peakPicking throws AssertionError
197
+ // when two AudioRecord instances compete for the microphone
198
+ val yinPitchProcessor = PitchProcessor(PitchEstimationAlgorithm.YIN, sampleRate.toFloat(), mpmBufferSize, pitchHandler)
199
+ val safeYinProcessor = object : AudioProcessor {
200
+ override fun process(audioEvent: AudioEvent): Boolean {
201
+ try {
202
+ return yinPitchProcessor.process(audioEvent)
203
+ } catch (e: Exception) {
204
+ Log.w(TAG, "YIN error (ignored): ${e.message}")
205
+ return true
206
+ }
207
+ }
208
+ override fun processingFinished() {
209
+ yinPitchProcessor.processingFinished()
210
+ }
211
+ }
212
+
213
+ mpmDispatcher = AudioDispatcherFactory.fromDefaultMicrophone(sampleRate, mpmBufferSize, mpmOverlap)
214
+ mpmDispatcher?.addAudioProcessor(safeYinProcessor)
215
+
216
+ logStatus("debug", "YIN dispatcher prepared (mid/high notes ${mpmMinHz}-${mpmMaxHz}Hz)")
217
+ }
218
+
219
+ private fun emitPitch(pitchInHz: Float, decibel: Float) {
220
+ if (pitchInHz <= 0 || pitchInHz.isNaN()) return
221
+
78
222
  val midiNote = 12 * log2(pitchInHz / 440f) + 69
79
223
  val roundedMidiNote = round(midiNote).toInt()
80
-
81
- // Calculate note index (0-11) and octave
82
224
  val noteIndex = ((roundedMidiNote % 12) + 12) % 12
83
225
  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
226
  val centsOff = (midiNote - roundedMidiNote) * 100
88
- val offsetPercentage = centsOff // Already in a reasonable range (-50 to +50)
89
227
 
90
- val note = notes[noteIndex]
228
+ // Pre-filter: JS discards offset > 25% anyway, so skip detections > 40%
229
+ // to reduce noise in the JS majority voting window
230
+ if (kotlin.math.abs(centsOff) > 40f) return
91
231
 
92
- val pitchData = PitchData(
93
- note = note,
232
+ onPitchDetected(PitchData(
233
+ note = notes[noteIndex],
94
234
  octave = octave,
95
235
  frequency = pitchInHz,
96
236
  amplitude = decibel,
97
- offset = offsetPercentage
98
- )
99
-
100
- onPitchDetected(pitchData)
237
+ offset = centsOff
238
+ ))
101
239
  }
102
240
 
103
241
  fun setOnPitchDetectedListener(listener: (PitchData) -> Unit) {
@@ -122,53 +260,63 @@ class PitchAnalyzer {
122
260
  this.levelThreshold = threshold
123
261
  }
124
262
 
263
+ fun setSampleRate(rate: Int) {
264
+ this.sampleRate = rate
265
+ }
266
+
267
+ fun getSampleRate(): Int = this.sampleRate
268
+
125
269
  fun setBufferSize(size: Int) {
126
270
  this.bufferSize = size
127
271
  }
128
272
 
129
- fun getBufferSize(): Int {
130
- return this.bufferSize
131
- }
273
+ fun getBufferSize(): Int = this.bufferSize
132
274
 
133
275
  fun setAlgorithm(name: String) {
134
276
  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
277
  }
145
278
 
146
- fun getAlgorithm(): String {
147
- return this.algorithmName
148
- }
279
+ fun getAlgorithm(): String = this.algorithmName
149
280
 
150
281
  fun start() {
151
282
  try {
152
- logStatus("debug", "start() called")
153
- prepare()
154
- runner = Thread(dispatcher)
155
- logStatus("debug", "Thread created, starting...")
156
- runner?.start()
283
+ logStatus("debug", "start() called — dual dispatcher mode (HPS + MPM)")
284
+
285
+ // Start HPS dispatcher first
286
+ prepareHPS()
287
+ hpsThread = Thread(hpsDispatcher)
288
+ hpsThread?.name = "HPS-Thread"
289
+ hpsThread?.start()
290
+ logStatus("debug", "HPS thread started")
291
+
292
+ // Start MPM dispatcher second
293
+ prepareMPM()
294
+ mpmThread = Thread(mpmDispatcher)
295
+ mpmThread?.name = "MPM-Thread"
296
+ mpmThread?.start()
297
+ logStatus("debug", "MPM thread started")
298
+
157
299
  isRecording = true
158
- logStatus("debug", "Recording started successfully")
300
+ logStatus("debug", "Dual dispatcher recording started successfully")
159
301
  } catch (e: Exception) {
160
302
  logStatus("error", "Error in start(): ${e.message}")
303
+ // Clean up whatever was started
304
+ stop()
161
305
  isRecording = false
162
306
  }
163
307
  }
164
308
 
165
309
  fun stop() {
166
- dispatcher?.stop()
167
- runner?.interrupt()
310
+ hpsDispatcher?.stop()
311
+ mpmDispatcher?.stop()
312
+ hpsThread?.interrupt()
313
+ mpmThread?.interrupt()
314
+ hpsDispatcher = null
315
+ mpmDispatcher = null
316
+ hpsThread = null
317
+ mpmThread = null
168
318
  isRecording = false
169
319
  }
170
320
 
171
- fun isRecording(): Boolean {
172
- return isRecording
173
- }
174
- }
321
+ fun isRecording(): Boolean = isRecording
322
+ }
@@ -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.1",
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",