From e646b5ce311f798008912a650f6926ce4fec9d62 Mon Sep 17 00:00:00 2001 From: denizyesilirmak Date: Wed, 27 May 2026 12:49:22 +0300 Subject: [PATCH 1/4] Enhance pitch detection data handling and update confidence threshold - Increased confidenceThreshold_ from 0.60 to 0.75 in Pipeline.hpp for improved accuracy. - Added new properties to TunerBridge for tracking pitch detection metrics. - Updated getStatus method in TunerEngine to return additional pitch event data. - Defined new EngineStatus type in types.ts to include pitch event details. --- cpp/include/Pipeline.hpp | 2 +- ios/TunerBridge.mm | 45 ++++++++++++++++++++++++++++++++++++++-- src/TunerEngine.ts | 4 ++-- src/types.ts | 11 ++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/cpp/include/Pipeline.hpp b/cpp/include/Pipeline.hpp index 74e5468..eac5b43 100644 --- a/cpp/include/Pipeline.hpp +++ b/cpp/include/Pipeline.hpp @@ -38,7 +38,7 @@ class Pipeline { float sampleRate_; float noiseGateDb_ = -55.0f; - float confidenceThreshold_ = 0.60f; // lower than raw YIN default; SNR will tighten it + float confidenceThreshold_ = 0.75f; BiquadHpf hpf_; HannWindow window_; diff --git a/ios/TunerBridge.mm b/ios/TunerBridge.mm index ab0d35d..8203fc1 100644 --- a/ios/TunerBridge.mm +++ b/ios/TunerBridge.mm @@ -8,12 +8,32 @@ @implementation TunerBridge { IosAudioSource* _audioSource; std::unique_ptr _dispatcher; bool _isRunning; + uint64_t _seq; + bool _latestHasPitch; + float _latestFrequency; + float _latestConfidence; + float _latestRmsDb; + NSString* _latestNoteName; + int _latestOctave; + float _latestCents; + NSString* _latestNearestString; + float _latestStringDeviation; } - (instancetype)init { self = [super init]; if (self) { _isRunning = false; + _seq = 0; + _latestHasPitch = false; + _latestFrequency = 0; + _latestConfidence = 0; + _latestRmsDb = 0; + _latestNoteName = @""; + _latestOctave = 0; + _latestCents = 0; + _latestNearestString = @""; + _latestStringDeviation = 0; } return self; } @@ -34,6 +54,17 @@ - (void)buildDispatcherWithSampleRate:(float)sr frameSize:(int)fs overlapRatio:( __strong __typeof__(weakSelf) strongSelf = weakSelf; if (!strongSelf || !strongSelf.onPitch) return; + strongSelf->_seq++; + strongSelf->_latestHasPitch = r.hasPitch; + strongSelf->_latestFrequency = r.frequency; + strongSelf->_latestConfidence = r.confidence; + strongSelf->_latestRmsDb = r.rmsDb; + strongSelf->_latestNoteName = @(r.noteName.c_str()); + strongSelf->_latestOctave = r.octave; + strongSelf->_latestCents = r.cents; + strongSelf->_latestNearestString = @(r.nearestString.c_str()); + strongSelf->_latestStringDeviation = r.stringDeviation; + NSDictionary* event = @{ @"hasPitch": @(r.hasPitch), @"frequency": @(r.frequency), @@ -133,8 +164,18 @@ - (void)setTemperament:(NSString *)name { - (NSDictionary *)getStatus { return @{ - @"isRunning": @(_isRunning), - @"engineReady": @(_dispatcher != nullptr) + @"isRunning": @(_isRunning), + @"engineReady": @(_dispatcher != nullptr), + @"seq": @(_seq), + @"hasPitch": @(_latestHasPitch), + @"frequency": @(_latestFrequency), + @"confidence": @(_latestConfidence), + @"rmsDb": @(_latestRmsDb), + @"noteName": _latestNoteName, + @"octave": @(_latestOctave), + @"cents": @(_latestCents), + @"nearestString": _latestNearestString, + @"stringDeviation": @(_latestStringDeviation), }; } diff --git a/src/TunerEngine.ts b/src/TunerEngine.ts index 1018abe..9141568 100644 --- a/src/TunerEngine.ts +++ b/src/TunerEngine.ts @@ -71,7 +71,7 @@ class TunerEngine { } getStatus(): EngineStatus { - return NativeTunerEngine.getStatus() as EngineStatus; + return NativeTunerEngine.getStatus() as unknown as EngineStatus; } onPitch(callback: PitchCallback): Unsubscribe { @@ -85,7 +85,7 @@ class TunerEngine { const poll = () => { if (stopped) return; try { - const s = NativeTunerEngine.getStatus() as any; + const s = NativeTunerEngine.getStatus() as unknown as EngineStatus; if (s.seq !== lastSeq) { lastSeq = s.seq; callback({ diff --git a/src/types.ts b/src/types.ts index 3945fc2..7cb0d2a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,4 +112,15 @@ export type PitchEvent = { export type EngineStatus = { isRunning: boolean; engineReady: boolean; + /** Monotonic counter incremented on each pitch event. Use to detect new data when polling. */ + seq: number; + hasPitch: boolean; + frequency: number; + confidence: number; + rmsDb: number; + noteName: string; + octave: number; + cents: number; + nearestString: string; + stringDeviation: number; }; From f0d7d0e02c821b2426e13e0d9201de98d0711e47 Mon Sep 17 00:00:00 2001 From: denizyesilirmak Date: Wed, 27 May 2026 13:24:07 +0300 Subject: [PATCH 2/4] Implement mutex locking for thread safety in audio processing functions --- android/src/main/cpp/TunerEngineJni.cpp | 165 ++++++++++++++++-------- 1 file changed, 111 insertions(+), 54 deletions(-) diff --git a/android/src/main/cpp/TunerEngineJni.cpp b/android/src/main/cpp/TunerEngineJni.cpp index 3c2dfb3..0f4c8cc 100644 --- a/android/src/main/cpp/TunerEngineJni.cpp +++ b/android/src/main/cpp/TunerEngineJni.cpp @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include #include "AudioFrameDispatcher.hpp" #include "OboeAudioSource.h" @@ -11,16 +13,28 @@ #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) -// Globals — one engine per process (single-instance module assumption) +// Globals — one engine per process (single-instance module assumption). +// All access must hold gMutex, except: +// - gJvm: written once in JNI_OnLoad, then read-only +// - gModuleRef / gOnPitchMethod: written only while dispatcher worker is +// joined (so onPitchResult cannot be in-flight), therefore safe to read +// from the worker without the mutex +// - gDispatcherAtomic: read lock-free from the Oboe audio thread +static std::mutex gMutex; static std::unique_ptr gDispatcher; static std::shared_ptr gAudioSource; +// Atomic raw pointer used by the Oboe audio callback to push samples. +// Written under gMutex; read lock-free from the audio thread. +static std::atomic gDispatcherAtomic{nullptr}; -// JNI back-reference for pitch callbacks static JavaVM* gJvm = nullptr; -static jobject gModuleRef = nullptr; // strong global ref to TunerEngineModule kotlin object +static jobject gModuleRef = nullptr; static jmethodID gOnPitchMethod = nullptr; -// Called from C++ worker thread; marshals PitchResult → Java +// Called from AudioFrameDispatcher's worker thread. +// gModuleRef and gOnPitchMethod are stable here: they are only replaced after +// the worker is joined (see initDispatcherLocked), so they cannot change +// while this function is executing. static void onPitchResult(const PitchResult& r) { if (!gJvm || !gModuleRef || !gOnPitchMethod) return; @@ -39,29 +53,61 @@ static void onPitchResult(const PitchResult& r) { return; } - if (gModuleRef) { - jstring noteName = env->NewStringUTF(r.noteName.c_str()); - jstring nearestStr = env->NewStringUTF(r.nearestString.c_str()); - env->CallVoidMethod( - gModuleRef, - gOnPitchMethod, - static_cast(r.hasPitch), - static_cast(r.frequency), - static_cast(r.confidence), - static_cast(r.rmsDb), - noteName, - static_cast(r.octave), - static_cast(r.cents), - nearestStr, - static_cast(r.stringDeviation) - ); - env->DeleteLocalRef(noteName); - env->DeleteLocalRef(nearestStr); - } + jstring noteName = env->NewStringUTF(r.noteName.c_str()); + jstring nearestStr = env->NewStringUTF(r.nearestString.c_str()); + env->CallVoidMethod( + gModuleRef, gOnPitchMethod, + static_cast(r.hasPitch), + static_cast(r.frequency), + static_cast(r.confidence), + static_cast(r.rmsDb), + noteName, + static_cast(r.octave), + static_cast(r.cents), + nearestStr, + static_cast(r.stringDeviation) + ); + env->DeleteLocalRef(noteName); + env->DeleteLocalRef(nearestStr); - if (didAttach) { - gJvm->DetachCurrentThread(); + if (didAttach) gJvm->DetachCurrentThread(); +} + +// Rebuilds the dispatcher. Must be called with gMutex held. +// Correct teardown order: +// 1. Null the atomic → audio callback stops pushing immediately +// 2. Join old worker → onPitchResult() cannot be in-flight after this +// 3. Swap gModuleRef → safe because no callbacks can race us +// 4. Create new dispatcher +// 5. Restore atomic → audio callback resumes with new dispatcher +static void initDispatcherLocked( + JNIEnv* env, jobject thiz, + float sampleRate, int frameSize, float overlapRatio +) { + gDispatcherAtomic.store(nullptr, std::memory_order_release); + + if (gDispatcher) { + gDispatcher->stop(); + gDispatcher.reset(); } + + if (gModuleRef) env->DeleteGlobalRef(gModuleRef); + gModuleRef = env->NewGlobalRef(thiz); + + jclass cls = env->GetObjectClass(thiz); + gOnPitchMethod = env->GetMethodID(cls, "onPitchDetected", + "(ZFFFLjava/lang/String;IFLjava/lang/String;F)V"); + + gDispatcher = std::make_unique( + frameSize, sampleRate, onPitchResult, overlapRatio + ); + + // Let the audio callback push to the new dispatcher. + // The dispatcher worker is not started here; nativeStart() calls start(). + gDispatcherAtomic.store(gDispatcher.get(), std::memory_order_release); + + LOGI("nativeInit: sampleRate=%.0f frameSize=%d overlapRatio=%.2f", + sampleRate, frameSize, overlapRatio); } extern "C" { @@ -76,36 +122,26 @@ Java_com_tunerengine_TunerEngineModule_nativeInit( JNIEnv* env, jobject thiz, jfloat sampleRate, jint frameSize, jfloat overlapRatio ) { - // Store strong global ref to the Kotlin module object for callbacks - if (gModuleRef) env->DeleteGlobalRef(gModuleRef); - gModuleRef = env->NewGlobalRef(thiz); - - // Cache the callback method ID - jclass cls = env->GetObjectClass(thiz); - gOnPitchMethod = env->GetMethodID(cls, "onPitchDetected", - "(ZFFFLjava/lang/String;IFLjava/lang/String;F)V"); - - gDispatcher = std::make_unique( - static_cast(frameSize), - static_cast(sampleRate), - onPitchResult, - static_cast(overlapRatio) - ); - - LOGI("nativeInit: sampleRate=%.0f frameSize=%d overlapRatio=%.2f", (float)sampleRate, (int)frameSize, (float)overlapRatio); + std::lock_guard lock(gMutex); + initDispatcherLocked(env, thiz, sampleRate, frameSize, overlapRatio); } JNIEXPORT jboolean JNICALL Java_com_tunerengine_TunerEngineModule_nativeStart( JNIEnv* env, jobject thiz ) { + std::lock_guard lock(gMutex); + if (!gDispatcher) { - Java_com_tunerengine_TunerEngineModule_nativeInit(env, thiz, 48000.0f, 2048, 0.0f); + initDispatcherLocked(env, thiz, 48000.0f, 2048, 0.0f); } gAudioSource = std::make_shared( [](const float* samples, int count, float /*sr*/) { - if (gDispatcher) gDispatcher->push(samples, count); + // Lock-free read — safe because gDispatcherAtomic is atomic and the + // pointed-to object outlives the audio source (stopped before reset). + auto* d = gDispatcherAtomic.load(std::memory_order_acquire); + if (d) d->push(samples, count); } ); @@ -115,11 +151,8 @@ Java_com_tunerengine_TunerEngineModule_nativeStart( return JNI_FALSE; } - // Sync dispatcher to actual sample rate reported by Oboe - if (gDispatcher) { - gDispatcher->setSampleRate(gAudioSource->sampleRate()); - gDispatcher->start(); - } + gDispatcher->setSampleRate(gAudioSource->sampleRate()); + gDispatcher->start(); LOGI("Audio capture started"); return JNI_TRUE; @@ -129,16 +162,30 @@ JNIEXPORT void JNICALL Java_com_tunerengine_TunerEngineModule_nativeStop( JNIEnv* env, jobject /*thiz*/ ) { - if (gDispatcher) gDispatcher->stop(); + std::lock_guard lock(gMutex); + + // Stop audio callback before tearing down so the audio thread cannot + // slip a push() through while we destroy the dispatcher. + gDispatcherAtomic.store(nullptr, std::memory_order_release); + + // Join the Oboe audio thread first (no more pushes after this). if (gAudioSource) { gAudioSource->stop(); gAudioSource.reset(); } - // Release global ref to allow GC of the module object + + // Join the dispatcher worker thread (no more onPitchResult calls after this). + if (gDispatcher) { + gDispatcher->stop(); + gDispatcher.reset(); + } + + // Safe to release the module ref: both threads above are joined. if (gModuleRef) { env->DeleteGlobalRef(gModuleRef); gModuleRef = nullptr; } + LOGI("Audio capture stopped"); } @@ -150,7 +197,8 @@ Java_com_tunerengine_TunerEngineModule_nativeConfigure( jfloat minFrequency, jfloat maxFrequency, jfloat a4, jfloat overlapRatio ) { - Java_com_tunerengine_TunerEngineModule_nativeInit(env, thiz, sampleRate, frameSize, overlapRatio); + std::lock_guard lock(gMutex); + initDispatcherLocked(env, thiz, sampleRate, frameSize, overlapRatio); if (gDispatcher) { gDispatcher->setNoiseGateDb(noiseGateDb); gDispatcher->setConfidenceThreshold(confidenceThreshold); @@ -163,6 +211,7 @@ JNIEXPORT void JNICALL Java_com_tunerengine_TunerEngineModule_nativeSetA4( JNIEnv* /*env*/, jobject /*thiz*/, jfloat hz ) { + std::lock_guard lock(gMutex); if (gDispatcher) gDispatcher->setA4(hz); } @@ -170,16 +219,18 @@ JNIEXPORT void JNICALL Java_com_tunerengine_TunerEngineModule_nativeSetHpfCutoff( JNIEnv* /*env*/, jobject /*thiz*/, jfloat hz ) { - if (gDispatcher) gDispatcher->setHpfCutoff(static_cast(hz)); + std::lock_guard lock(gMutex); + if (gDispatcher) gDispatcher->setHpfCutoff(hz); } JNIEXPORT void JNICALL Java_com_tunerengine_TunerEngineModule_nativeSetPostProcessorConfig( JNIEnv* /*env*/, jobject /*thiz*/, jfloat emaAlpha, jint hysteresisFrames ) { + std::lock_guard lock(gMutex); if (gDispatcher) { PostProcessor::Config cfg; - cfg.emaAlpha = static_cast(emaAlpha); + cfg.emaAlpha = emaAlpha; cfg.hysteresisFrames = static_cast(hysteresisFrames); gDispatcher->setPostProcessorConfig(cfg); } @@ -189,6 +240,7 @@ JNIEXPORT void JNICALL Java_com_tunerengine_TunerEngineModule_nativeSetInstrument( JNIEnv* env, jobject /*thiz*/, jstring name ) { + std::lock_guard lock(gMutex); const char* nameChars = env->GetStringUTFChars(name, nullptr); if (gDispatcher && nameChars) gDispatcher->setInstrument(std::string(nameChars)); env->ReleaseStringUTFChars(name, nameChars); @@ -198,6 +250,7 @@ JNIEXPORT void JNICALL Java_com_tunerengine_TunerEngineModule_nativeSetTuning( JNIEnv* env, jobject /*thiz*/, jstring name ) { + std::lock_guard lock(gMutex); const char* nameChars = env->GetStringUTFChars(name, nullptr); if (gDispatcher && nameChars) gDispatcher->setTuning(std::string(nameChars)); env->ReleaseStringUTFChars(name, nameChars); @@ -207,6 +260,7 @@ JNIEXPORT jboolean JNICALL Java_com_tunerengine_TunerEngineModule_nativeIsRunning( JNIEnv* /*env*/, jobject /*thiz*/ ) { + std::lock_guard lock(gMutex); return static_cast(gAudioSource != nullptr); } @@ -214,6 +268,7 @@ JNIEXPORT void JNICALL Java_com_tunerengine_TunerEngineModule_nativeSetOnsetDetection( JNIEnv* /*env*/, jobject /*thiz*/, jboolean enabled ) { + std::lock_guard lock(gMutex); if (gDispatcher) gDispatcher->setOnsetDetectionEnabled(static_cast(enabled)); } @@ -221,6 +276,7 @@ JNIEXPORT void JNICALL Java_com_tunerengine_TunerEngineModule_nativeSetTemperament( JNIEnv* env, jobject /*thiz*/, jstring name ) { + std::lock_guard lock(gMutex); const char* nameChars = env->GetStringUTFChars(name, nullptr); if (gDispatcher && nameChars) gDispatcher->setTemperament(std::string(nameChars)); env->ReleaseStringUTFChars(name, nameChars); @@ -230,6 +286,7 @@ JNIEXPORT void JNICALL Java_com_tunerengine_TunerEngineModule_nativeSetAdaptiveFrameSize( JNIEnv* /*env*/, jobject /*thiz*/, jboolean enabled ) { + std::lock_guard lock(gMutex); if (gDispatcher) gDispatcher->setAdaptiveFrameSize(static_cast(enabled)); } From 2d0ae1498d65a72444d71524f6713a41daaff552 Mon Sep 17 00:00:00 2001 From: denizyesilirmak Date: Wed, 27 May 2026 13:24:17 +0300 Subject: [PATCH 3/4] Add JSI support and improve thread safety in audio processing - Integrate ReactAndroid and fbjni in CMakeLists.txt - Enhance OboeAudioSource with mutex for thread safety - Implement JSI direct callback for pitch detection in TunerEngine - Update TunerEngineModule to wire up JSI call invoker - Refactor useTuner to handle errors more gracefully --- android/src/main/cpp/CMakeLists.txt | 5 ++ android/src/main/cpp/OboeAudioSource.cpp | 15 ++-- android/src/main/cpp/OboeAudioSource.h | 2 + android/src/main/cpp/TunerEngineJni.cpp | 86 +++++++++++++++++-- .../java/com/tunerengine/TunerEngineModule.kt | 14 +++ src/TunerEngine.ts | 44 ++-------- src/useTuner.ts | 3 +- 7 files changed, 121 insertions(+), 48 deletions(-) diff --git a/android/src/main/cpp/CMakeLists.txt b/android/src/main/cpp/CMakeLists.txt index 919f9ee..754401e 100644 --- a/android/src/main/cpp/CMakeLists.txt +++ b/android/src/main/cpp/CMakeLists.txt @@ -12,6 +12,8 @@ add_subdirectory( ) find_package(oboe REQUIRED CONFIG) +find_package(ReactAndroid REQUIRED CONFIG) +find_package(fbjni REQUIRED CONFIG) add_library(tunerengine SHARED TunerEngineJni.cpp @@ -28,4 +30,7 @@ target_link_libraries(tunerengine oboe::oboe android log + ReactAndroid::jsi + ReactAndroid::reactnative + fbjni::fbjni ) diff --git a/android/src/main/cpp/OboeAudioSource.cpp b/android/src/main/cpp/OboeAudioSource.cpp index 30c2e4c..b465b7c 100644 --- a/android/src/main/cpp/OboeAudioSource.cpp +++ b/android/src/main/cpp/OboeAudioSource.cpp @@ -61,9 +61,12 @@ bool OboeAudioSource::start() { } void OboeAudioSource::stop() { - stopped_.store(true, std::memory_order_relaxed); - if (restartThread_.joinable()) { - restartThread_.join(); + // Use seq_cst so onErrorAfterClose() on another thread is guaranteed to + // see stopped_=true before we try to join the restart thread. + stopped_.store(true, std::memory_order_seq_cst); + { + std::lock_guard lock(restartMutex_); + if (restartThread_.joinable()) restartThread_.join(); } if (stream_) { stream_->requestStop(); @@ -90,8 +93,10 @@ oboe::DataCallbackResult OboeAudioSource::onAudioReady( void OboeAudioSource::onErrorAfterClose(oboe::AudioStream* /*stream*/, oboe::Result error) { LOGE("Oboe stream error after close: %s — attempting restart", oboe::convertToText(error)); - if (stopped_.load(std::memory_order_relaxed)) return; - // Restart on a separate thread to avoid deadlock; join in stop()/destructor + std::lock_guard lock(restartMutex_); + // seq_cst load pairs with the seq_cst store in stop() — guarantees we + // see stopped_=true if stop() ran before we acquired the mutex. + if (stopped_.load(std::memory_order_seq_cst)) return; if (restartThread_.joinable()) restartThread_.join(); restartThread_ = std::thread([this]() { if (!stopped_.load(std::memory_order_relaxed)) { diff --git a/android/src/main/cpp/OboeAudioSource.h b/android/src/main/cpp/OboeAudioSource.h index 0bdc746..ca2189c 100644 --- a/android/src/main/cpp/OboeAudioSource.h +++ b/android/src/main/cpp/OboeAudioSource.h @@ -4,6 +4,7 @@ #include #include #include +#include #include // Oboe-based microphone capture. @@ -37,5 +38,6 @@ class OboeAudioSource : public oboe::AudioStreamCallback, std::shared_ptr stream_; float sampleRate_{48000.0f}; std::atomic stopped_{false}; + std::mutex restartMutex_; std::thread restartThread_; }; diff --git a/android/src/main/cpp/TunerEngineJni.cpp b/android/src/main/cpp/TunerEngineJni.cpp index 0f4c8cc..2f3fa35 100644 --- a/android/src/main/cpp/TunerEngineJni.cpp +++ b/android/src/main/cpp/TunerEngineJni.cpp @@ -4,6 +4,10 @@ #include #include #include +#include +#include +#include +#include #include "AudioFrameDispatcher.hpp" #include "OboeAudioSource.h" #include "OnsetDetector.hpp" @@ -16,6 +20,7 @@ // Globals — one engine per process (single-instance module assumption). // All access must hold gMutex, except: // - gJvm: written once in JNI_OnLoad, then read-only +// - gJsInvoker: accessed via std::atomic_load/store (lock-free shared_ptr) // - gModuleRef / gOnPitchMethod: written only while dispatcher worker is // joined (so onPitchResult cannot be in-flight), therefore safe to read // from the worker without the mutex @@ -27,15 +32,68 @@ static std::shared_ptr gAudioSource; // Written under gMutex; read lock-free from the audio thread. static std::atomic gDispatcherAtomic{nullptr}; +// JSI call invoker — set once from initialize(), read from worker thread. +// std::atomic_load/store is used for lock-free, thread-safe shared_ptr access. +static std::shared_ptr gJsInvoker; + +// JNI back-reference for Kotlin-side pitch snapshot update (getStatus()). static JavaVM* gJvm = nullptr; static jobject gModuleRef = nullptr; static jmethodID gOnPitchMethod = nullptr; // Called from AudioFrameDispatcher's worker thread. -// gModuleRef and gOnPitchMethod are stable here: they are only replaced after -// the worker is joined (see initDispatcherLocked), so they cannot change -// while this function is executing. +// +// Two delivery paths run in order: +// 1. JSI (Bridgeless / New Arch): schedules a direct call into JS via +// __tunerEngineOnPitch, mirroring the iOS implementation. +// 2. JNI → Kotlin (always): updates the @Volatile snapshot fields so that +// getStatus() returns fresh data regardless of which path is active. +// +// gModuleRef and gOnPitchMethod are stable during execution: they are only +// replaced after the worker is joined (see initDispatcherLocked), so they +// cannot change while this function is executing. static void onPitchResult(const PitchResult& r) { + // ── Path 1: JSI direct call ────────────────────────────────────────────── + auto jsInvoker = std::atomic_load(&gJsInvoker); + if (jsInvoker) { + bool hasPitch = r.hasPitch; + double frequency = r.frequency; + double confidence = r.confidence; + double rmsDb = r.rmsDb; + std::string note = r.noteName; + int octave = r.octave; + double cents = r.cents; + std::string nearestStr = r.nearestString; + double stringDev = r.stringDeviation; + + jsInvoker->invokeAsync( + [hasPitch, frequency, confidence, rmsDb, + note, octave, cents, nearestStr, stringDev] + (facebook::jsi::Runtime& rt) { + auto cb = rt.global().getProperty(rt, "__tunerEngineOnPitch"); + if (!cb.isObject()) return; + auto fn = cb.asObject(rt); + if (!fn.isFunction(rt)) return; + + facebook::jsi::Object obj(rt); + obj.setProperty(rt, "hasPitch", facebook::jsi::Value(hasPitch)); + obj.setProperty(rt, "frequency", facebook::jsi::Value(frequency)); + obj.setProperty(rt, "confidence", facebook::jsi::Value(confidence)); + obj.setProperty(rt, "rmsDb", facebook::jsi::Value(rmsDb)); + obj.setProperty(rt, "noteName", + facebook::jsi::String::createFromUtf8(rt, note)); + obj.setProperty(rt, "octave", facebook::jsi::Value(octave)); + obj.setProperty(rt, "cents", facebook::jsi::Value(cents)); + obj.setProperty(rt, "nearestString", + facebook::jsi::String::createFromUtf8(rt, nearestStr)); + obj.setProperty(rt, "stringDeviation", facebook::jsi::Value(stringDev)); + + fn.asFunction(rt).call(rt, std::move(obj)); + } + ); + } + + // ── Path 2: JNI → Kotlin (snapshot update for getStatus()) ────────────── if (!gJvm || !gModuleRef || !gOnPitchMethod) return; JNIEnv* env = nullptr; @@ -102,8 +160,6 @@ static void initDispatcherLocked( frameSize, sampleRate, onPitchResult, overlapRatio ); - // Let the audio callback push to the new dispatcher. - // The dispatcher worker is not started here; nativeStart() calls start(). gDispatcherAtomic.store(gDispatcher.get(), std::memory_order_release); LOGI("nativeInit: sampleRate=%.0f frameSize=%d overlapRatio=%.2f", @@ -112,9 +168,25 @@ static void initDispatcherLocked( extern "C" { -JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* /*reserved*/) { +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { gJvm = vm; - return JNI_VERSION_1_6; + // Initialize fbjni so HybridClass internals (field IDs, type registration) + // are set up for this library. Safe to call from multiple libraries. + return facebook::jni::initialize(vm, []() {}); +} + +// Called from TunerEngineModule.initialize() once the JS context is ready. +// Stores the JSI CallInvoker for direct-to-JS event delivery (Bridgeless mode). +JNIEXPORT void JNICALL +Java_com_tunerengine_TunerEngineModule_nativeSetCallInvoker( + JNIEnv* /*env*/, jobject /*thiz*/, jobject callInvokerHolder +) { + if (!callInvokerHolder) return; + auto holder = jni::alias_ref{ + reinterpret_cast(callInvokerHolder) + }; + std::atomic_store(&gJsInvoker, holder->cthis()->getCallInvoker()); + LOGI("JSI CallInvoker registered — direct event delivery active"); } JNIEXPORT void JNICALL diff --git a/android/src/main/java/com/tunerengine/TunerEngineModule.kt b/android/src/main/java/com/tunerengine/TunerEngineModule.kt index 176d2a8..164988f 100644 --- a/android/src/main/java/com/tunerengine/TunerEngineModule.kt +++ b/android/src/main/java/com/tunerengine/TunerEngineModule.kt @@ -10,6 +10,7 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.modules.core.PermissionAwareActivity import com.facebook.react.modules.core.PermissionListener +import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder class TunerEngineModule(reactContext: ReactApplicationContext) : NativeTunerEngineSpec(reactContext) { @@ -184,7 +185,20 @@ class TunerEngineModule(reactContext: ReactApplicationContext) : pitchSequence++ } + override fun initialize() { + super.initialize() + // Wire up JSI direct-callback path for Bridgeless / New Arch. + // Falls back silently to the polling path if the invoker is unavailable. + try { + val holder = reactApplicationContext.getJSCallInvokerHolder() + if (holder != null) { + nativeSetCallInvoker(holder) + } + } catch (_: Throwable) {} + } + // JNI methods + private external fun nativeSetCallInvoker(callInvokerHolder: CallInvokerHolder) private external fun nativeInit(sampleRate: Float, frameSize: Int, overlapRatio: Float) private external fun nativeStart(): Boolean private external fun nativeStop() diff --git a/src/TunerEngine.ts b/src/TunerEngine.ts index 9141568..ecb6570 100644 --- a/src/TunerEngine.ts +++ b/src/TunerEngine.ts @@ -75,48 +75,22 @@ class TunerEngine { } onPitch(callback: PitchCallback): Unsubscribe { - if (Platform.OS === 'android') { - // Bridgeless mode on Android doesn't deliver RCTDeviceEventEmitter events. - // Poll getStatus() which includes latest pitch via requestAnimationFrame. - let lastSeq = -1; - let rafId: number; - let stopped = false; - - const poll = () => { - if (stopped) return; - try { - const s = NativeTunerEngine.getStatus() as unknown as EngineStatus; - if (s.seq !== lastSeq) { - lastSeq = s.seq; - callback({ - hasPitch: s.hasPitch, - frequency: s.frequency, - confidence: s.confidence, - rmsDb: s.rmsDb, - noteName: s.noteName, - octave: s.octave, - cents: s.cents, - nearestString: s.nearestString, - stringDeviation: s.stringDeviation, - } as PitchEvent); - } - } catch (_) {} - rafId = requestAnimationFrame(poll); - }; - rafId = requestAnimationFrame(poll); + // Primary path (both platforms): JSI direct callback via C++ invokeAsync. + // The native side calls __tunerEngineOnPitch directly on the JS thread, + // matching iOS latency on Android Bridgeless / New Arch. + (globalThis as any).__tunerEngineOnPitch = callback; + if (Platform.OS === 'ios') { + // Old-arch iOS fallback: DeviceEventEmitter fires when JSI global is absent. + const sub = DeviceEventEmitter.addListener('onPitch', callback); return () => { - stopped = true; - cancelAnimationFrame(rafId); + (globalThis as any).__tunerEngineOnPitch = undefined; + sub.remove(); }; } - // iOS: JSI direct callback via global + DeviceEventEmitter fallback - (globalThis as any).__tunerEngineOnPitch = callback; - const sub = DeviceEventEmitter.addListener('onPitch', callback); return () => { (globalThis as any).__tunerEngineOnPitch = undefined; - sub.remove(); }; } } diff --git a/src/useTuner.ts b/src/useTuner.ts index 731b00f..27ecf9f 100644 --- a/src/useTuner.ts +++ b/src/useTuner.ts @@ -51,8 +51,9 @@ export function useTuner(opts: UseTunerOptions = {}): UseTunerResult { await TunerEngine.start(); setIsRunning(true); } catch (e) { + unsubscribeRef.current?.(); + unsubscribeRef.current = null; setError(e instanceof Error ? e : new Error(String(e))); - setIsRunning(false); } }, []); From 34d6c473ad6c494cee1e3ca5288f22228ac6fc66 Mon Sep 17 00:00:00 2001 From: denizyesilirmak Date: Thu, 28 May 2026 19:15:11 +0300 Subject: [PATCH 4/4] Refactor audio processing and type definitions for improved performance and clarity --- android/src/main/cpp/TunerEngineJni.cpp | 7 +++- cpp/include/EnsembleSelector.hpp | 3 ++ cpp/src/EnsembleSelector.cpp | 38 ++++++++++------------ cpp/src/PostProcessor.cpp | 1 + ios/TunerBridge.mm | 5 +++ src/TunerEngine.ts | 13 ++++++++ src/types.ts | 43 +++++++++---------------- 7 files changed, 61 insertions(+), 49 deletions(-) diff --git a/android/src/main/cpp/TunerEngineJni.cpp b/android/src/main/cpp/TunerEngineJni.cpp index 2f3fa35..3eddb69 100644 --- a/android/src/main/cpp/TunerEngineJni.cpp +++ b/android/src/main/cpp/TunerEngineJni.cpp @@ -182,7 +182,7 @@ Java_com_tunerengine_TunerEngineModule_nativeSetCallInvoker( JNIEnv* /*env*/, jobject /*thiz*/, jobject callInvokerHolder ) { if (!callInvokerHolder) return; - auto holder = jni::alias_ref{ + auto holder = facebook::jni::alias_ref{ reinterpret_cast(callInvokerHolder) }; std::atomic_store(&gJsInvoker, holder->cthis()->getCallInvoker()); @@ -270,12 +270,17 @@ Java_com_tunerengine_TunerEngineModule_nativeConfigure( jfloat a4, jfloat overlapRatio ) { std::lock_guard lock(gMutex); + const bool wasRunning = (gAudioSource != nullptr); initDispatcherLocked(env, thiz, sampleRate, frameSize, overlapRatio); if (gDispatcher) { gDispatcher->setNoiseGateDb(noiseGateDb); gDispatcher->setConfidenceThreshold(confidenceThreshold); gDispatcher->setFrequencyRange(minFrequency, maxFrequency); gDispatcher->setA4(a4); + if (wasRunning) { + gDispatcher->setSampleRate(gAudioSource->sampleRate()); + gDispatcher->start(); + } } } diff --git a/cpp/include/EnsembleSelector.hpp b/cpp/include/EnsembleSelector.hpp index 91ce5f2..0a9624a 100644 --- a/cpp/include/EnsembleSelector.hpp +++ b/cpp/include/EnsembleSelector.hpp @@ -18,8 +18,11 @@ class EnsembleSelector : public IPitchDetector { void setThreshold(float threshold) override; private: + struct VoicedEntry { int idx; float freq; float conf; int votes; }; + std::vector> detectors_; std::vector resultsBuf_; + std::vector voicedBuf_; static bool withinSemitones(float f1, float f2, float tolerance = 1.0f); }; diff --git a/cpp/src/EnsembleSelector.cpp b/cpp/src/EnsembleSelector.cpp index b0dda40..0219a44 100644 --- a/cpp/src/EnsembleSelector.cpp +++ b/cpp/src/EnsembleSelector.cpp @@ -6,6 +6,7 @@ EnsembleSelector::EnsembleSelector(std::vector> : detectors_(std::move(detectors)) { resultsBuf_.resize(detectors_.size()); + voicedBuf_.reserve(detectors_.size()); } void EnsembleSelector::reset() { @@ -31,35 +32,30 @@ DetectorResult EnsembleSelector::detect(const float* frame, int n, float sampleR resultsBuf_[i] = detectors_[i]->detect(frame, n, sampleRate); } - // Work with a small stack-local view of voiced results to avoid heap allocation. - // Maximum 8 detectors is more than enough for any realistic ensemble. - struct Entry { int idx; float freq; float conf; int votes; }; - Entry voiced[8]; - int voicedCount = 0; - - for (int i = 0; i < static_cast(resultsBuf_.size()) && voicedCount < 8; ++i) { + voicedBuf_.clear(); + for (int i = 0; i < static_cast(resultsBuf_.size()); ++i) { const auto& r = resultsBuf_[i]; if (r.voiced && r.confidence > 0.0f) { - voiced[voicedCount++] = {i, r.frequency, r.confidence, 0}; + voicedBuf_.push_back({i, r.frequency, r.confidence, 0}); } } - if (voicedCount == 0) return DetectorResult{}; + if (voicedBuf_.empty()) return DetectorResult{}; // Tally agreement votes between voiced entries - for (int i = 0; i < voicedCount; ++i) { - for (int j = i + 1; j < voicedCount; ++j) { - if (withinSemitones(voiced[i].freq, voiced[j].freq)) { - ++voiced[i].votes; - ++voiced[j].votes; + for (int i = 0; i < static_cast(voicedBuf_.size()); ++i) { + for (int j = i + 1; j < static_cast(voicedBuf_.size()); ++j) { + if (withinSemitones(voicedBuf_[i].freq, voicedBuf_[j].freq)) { + ++voicedBuf_[i].votes; + ++voicedBuf_[j].votes; } } } // Pick winner: most votes first, then highest confidence - const Entry* best = &voiced[0]; - for (int i = 1; i < voicedCount; ++i) { - const Entry& c = voiced[i]; + const VoicedEntry* best = &voicedBuf_[0]; + for (int i = 1; i < static_cast(voicedBuf_.size()); ++i) { + const VoicedEntry& c = voicedBuf_[i]; if (c.votes > best->votes || (c.votes == best->votes && c.conf > best->conf)) { best = &c; @@ -71,10 +67,10 @@ DetectorResult EnsembleSelector::detect(const float* frame, int n, float sampleR float confSum = best->conf; int agreeing = 1; - for (int i = 0; i < voicedCount; ++i) { - if (&voiced[i] != best && withinSemitones(voiced[i].freq, best->freq)) { - freqSum += voiced[i].freq; - confSum += voiced[i].conf; + for (int i = 0; i < static_cast(voicedBuf_.size()); ++i) { + if (&voicedBuf_[i] != best && withinSemitones(voicedBuf_[i].freq, best->freq)) { + freqSum += voicedBuf_[i].freq; + confSum += voicedBuf_[i].conf; ++agreeing; } } diff --git a/cpp/src/PostProcessor.cpp b/cpp/src/PostProcessor.cpp index 1c521e3..30d83c3 100644 --- a/cpp/src/PostProcessor.cpp +++ b/cpp/src/PostProcessor.cpp @@ -25,6 +25,7 @@ void PostProcessor::setConfig(Config cfg) { cfg.emaAlpha = std::clamp(cfg.emaAlpha, 0.01f, 1.0f); cfg.hysteresisFrames = std::max(cfg.hysteresisFrames, 1); cfg_ = cfg; + reset(); } float PostProcessor::median5() const { diff --git a/ios/TunerBridge.mm b/ios/TunerBridge.mm index 8203fc1..4808f8c 100644 --- a/ios/TunerBridge.mm +++ b/ios/TunerBridge.mm @@ -44,6 +44,11 @@ - (void)configure:(NSDictionary *)opts { float overlapRatio = opts[@"overlapRatio"] ? [opts[@"overlapRatio"] floatValue] : 0.0f; [self buildDispatcherWithSampleRate:sampleRate frameSize:frameSize overlapRatio:overlapRatio opts:opts]; + + if (_isRunning && _dispatcher && _audioSource) { + _dispatcher->setSampleRate(_audioSource.sampleRate); + _dispatcher->start(); + } } - (void)buildDispatcherWithSampleRate:(float)sr frameSize:(int)fs overlapRatio:(float)overlap opts:(NSDictionary*)opts { diff --git a/src/TunerEngine.ts b/src/TunerEngine.ts index ecb6570..c91205c 100644 --- a/src/TunerEngine.ts +++ b/src/TunerEngine.ts @@ -1,5 +1,6 @@ import { Platform, DeviceEventEmitter } from 'react-native'; import NativeTunerEngine from './NativeTunerEngine'; +import { INSTRUMENTS, TEMPERAMENTS, TUNING_PRESETS } from './types'; import type { EngineStatus, Instrument, @@ -59,14 +60,26 @@ class TunerEngine { } setInstrument(name: Instrument): void { + if (__DEV__ && !(INSTRUMENTS as readonly string[]).includes(name)) { + console.warn(`[TunerEngine] Unknown instrument: "${name}"`); + return; + } NativeTunerEngine.setInstrument(name); } setTemperament(name: Temperament): void { + if (__DEV__ && !(TEMPERAMENTS as readonly string[]).includes(name)) { + console.warn(`[TunerEngine] Unknown temperament: "${name}"`); + return; + } NativeTunerEngine.setTemperament(name); } setTuning(name: TuningPreset | ''): void { + if (__DEV__ && name !== '' && !(TUNING_PRESETS as readonly string[]).includes(name)) { + console.warn(`[TunerEngine] Unknown tuning preset: "${name}"`); + return; + } NativeTunerEngine.setTuning(name); } diff --git a/src/types.ts b/src/types.ts index 7cb0d2a..850771c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,15 +1,11 @@ -export type Instrument = - | 'guitar' - | 'bass' - | 'violin' - | 'viola' - | 'cello' - | 'ukulele' - | 'mandolin' - | 'banjo' - | 'chromatic'; +export const INSTRUMENTS = [ + 'guitar', 'bass', 'violin', 'viola', 'cello', + 'ukulele', 'mandolin', 'banjo', 'chromatic', +] as const; +export type Instrument = typeof INSTRUMENTS[number]; -export type Temperament = 'equal' | 'just'; +export const TEMPERAMENTS = ['equal', 'just'] as const; +export type Temperament = typeof TEMPERAMENTS[number]; /** * Quality preset that maps to a frame size + overlap combination. @@ -78,22 +74,15 @@ export type TunerConfig = { quality?: QualityPreset; }; -export type TuningPreset = - | 'guitar_standard' - | 'guitar_eb_standard' - | 'guitar_d_standard' - | 'guitar_drop_d' - | 'guitar_drop_c' - | 'guitar_open_g' - | 'guitar_open_d' - | 'guitar_open_c' - | 'guitar_dadgad' - | 'bass_standard' - | 'bass_drop_d' - | 'violin_standard' - | 'viola_standard' - | 'cello_standard' - | 'ukulele_standard'; +export const TUNING_PRESETS = [ + 'guitar_standard', 'guitar_eb_standard', 'guitar_d_standard', + 'guitar_drop_d', 'guitar_drop_c', 'guitar_open_g', + 'guitar_open_d', 'guitar_open_c', 'guitar_dadgad', + 'bass_standard', 'bass_drop_d', + 'violin_standard', 'viola_standard', 'cello_standard', + 'ukulele_standard', +] as const; +export type TuningPreset = typeof TUNING_PRESETS[number]; export type PitchEvent = { hasPitch: boolean;