diff --git a/README.md b/README.md index 9ece5dd..c1b2b2c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,55 @@ disableAudioSessionManagement(); you need to explicitly control the iOS session lifecycle. These audio-session helpers are no-ops on Android. +## Event polling + +By default, `react-native-elementary` does **not** start polling for runtime events. This avoids unnecessary JS thread overhead for apps that only use `setProperty` for real-time updates and don't need `el.snapshot`, `el.meter`, or `el.scope` data. + +If your app needs snapshot/meter/scope events, opt in explicitly: + +```tsx +import { startEventPolling, stopEventPolling, configureEventPolling } from 'react-native-elementary'; + +// Start polling at default ~30Hz (33ms) +await startEventPolling(); + +// Or configure a different rate before starting: +await configureEventPolling(100); // 100ms ≈ 10Hz (drift correction only) +await startEventPolling(); + +// Stop polling when you don't need events anymore: +await stopEventPolling(); +``` + +### Listening for events + +Events are emitted on the `elementaryEvent` channel via `NativeEventEmitter`. Each callback receives a single event object with a `type` field and event-specific data: + +```tsx +import { NativeEventEmitter, NativeModules } from 'react-native'; + +const elementaryEmitter = new NativeEventEmitter(NativeModules.Elementary); + +const subscription = elementaryEmitter.addListener('elementaryEvent', (event) => { + // event = { type: 'snapshot', source: 'playhead', data: 1.25 } + // event = { type: 'meter', source: 'level', data: 0.75 } + // event = { type: 'scope', data: [...] } + console.log(event.type, event); +}); + +// Don't forget to remove on unmount: +subscription.remove(); +``` + +### Polling rates + +| Interval | Rate | Use case | +|----------|------|----------| +| 33ms | ~30Hz | Smooth metering, playhead UI | +| 100ms | ~10Hz | Drift correction only, minimal overhead | + +Values are clamped to 10–1000ms. + ## Contributing See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow. @@ -78,4 +127,4 @@ MIT --- -Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) +Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) \ No newline at end of file diff --git a/android/src/main/java/com/elementary/ElementaryModule.kt b/android/src/main/java/com/elementary/ElementaryModule.kt index eca5fdb..34b5f1a 100644 --- a/android/src/main/java/com/elementary/ElementaryModule.kt +++ b/android/src/main/java/com/elementary/ElementaryModule.kt @@ -38,6 +38,7 @@ class ElementaryModule(reactContext: ReactApplicationContext) : private var hasAudioFocus = false private var eventPollHandler: Handler? = null private var eventPollRunnable: Runnable? = null + private var eventPollIntervalMs: Long = 33 // Default ~30Hz private var listenerCount: Int = 0 private var hasEventListeners: Boolean = false @@ -125,6 +126,53 @@ class ElementaryModule(reactContext: ReactApplicationContext) : } } + /** + * Start polling for runtime events (el.snapshot, el.meter, el.scope, el.fft). + * Polling interval defaults to 33ms (~30Hz) — use configureEventPolling to change. + * Call stopEventPolling to halt polling and release native timer resources. + */ + @ReactMethod + fun startEventPolling(promise: Promise) { + if (eventPollHandler != null) { + promise.resolve(true) // Already running + return + } + startEventPolling() + promise.resolve(true) + } + + /** + * Stop polling for runtime events. Call startEventPolling to resume. + */ + @ReactMethod + fun stopEventPolling(promise: Promise) { + stopEventPolling() + promise.resolve(true) + } + + /** + * Configure the event polling interval in milliseconds. + * Must be called before startEventPolling, or polling will be restarted + * with the new interval. + * + * Typical values: + * - 33ms (~30Hz): smooth metering and playhead updates + * - 100ms (~10Hz): drift correction only, minimal JS thread overhead + * + * No-op if polling is not needed (consumers that don't use el.snapshot + * or el.meter can skip polling entirely by not calling startEventPolling). + */ + @ReactMethod + fun configureEventPolling(intervalMs: Double, promise: Promise) { + eventPollIntervalMs = intervalMs.toLong().coerceIn(10, 1000) + // If already running, restart with new interval + if (eventPollHandler != null) { + stopEventPolling() + startEventPolling() + } + promise.resolve(true) + } + @ReactMethod fun loadAudioResource(key: String, filePath: String, promise: Promise) { Thread { @@ -268,7 +316,7 @@ class ElementaryModule(reactContext: ReactApplicationContext) : } // Event polling: drain el.snapshot / el.meter / el.scope events from - // the Elementary C++ runtime and forward to JS at ~30Hz. + // the Elementary C++ runtime and forward to JS. // Mirrors the iOS dispatch_source_t timer in Elementary.mm. private fun startEventPolling() { if (eventPollHandler != null) return // Already running @@ -280,7 +328,7 @@ class ElementaryModule(reactContext: ReactApplicationContext) : if (!hasEventListeners) { // No JS listeners — skip polling to avoid unnecessary work. // Re-check next tick in case a listener is added. - eventPollHandler?.postDelayed(this, 33) + eventPollHandler?.postDelayed(this, eventPollIntervalMs) return } try { @@ -315,11 +363,11 @@ class ElementaryModule(reactContext: ReactApplicationContext) : } catch (e: Exception) { Log.d(TAG, "Event polling error: ${e.message}") } - eventPollHandler?.postDelayed(this, 33) // ~30Hz + eventPollHandler?.postDelayed(this, eventPollIntervalMs) } } eventPollHandler?.post(eventPollRunnable!!) - Log.d(TAG, "Event polling started at ~30Hz") + Log.d(TAG, "Event polling started at ~${1000.0 / eventPollIntervalMs}Hz (${eventPollIntervalMs}ms interval)") } private fun stopEventPolling() { @@ -345,10 +393,15 @@ class ElementaryModule(reactContext: ReactApplicationContext) : reactContext.addLifecycleEventListener(this) - // Start polling for runtime events (el.snapshot, el.meter, el.scope, el.fft). - // These nodes queue events on the audio thread; processQueuedEvents drains - // them on the main thread and we forward to JS via RCTDeviceEventEmitter. - startEventPolling() + // Event polling is NOT started automatically. + // Consumers that need el.snapshot / el.meter / el.scope events must + // explicitly call startEventPolling() (or configureEventPolling + + // startEventPolling) to opt in. This avoids unnecessary JS thread + // overhead for apps that only use setProperty for real-time updates + // and don't need periodic snapshot/meter/scope data. + // + // For backward compat, call startEventPolling() on mount if your + // app uses Elementary event listeners. Log.d(TAG, "Audio engine initialized (channels=${nativeGetNumChannels()}, sampleRate=${nativeGetSampleRate()})") } diff --git a/android/src/newarch/com/elementary/ElementaryTurboModule.java b/android/src/newarch/com/elementary/ElementaryTurboModule.java index e36534d..9296e7c 100644 --- a/android/src/newarch/com/elementary/ElementaryTurboModule.java +++ b/android/src/newarch/com/elementary/ElementaryTurboModule.java @@ -77,4 +77,19 @@ public void getBundlePath(Promise promise) { public void setProperty(double nodeHash, String key, double value) { module.setProperty(nodeHash, key, value); } + + @Override + public void startEventPolling(Promise promise) { + module.startEventPolling(promise); + } + + @Override + public void stopEventPolling(Promise promise) { + module.stopEventPolling(promise); + } + + @Override + public void configureEventPolling(double intervalMs, Promise promise) { + module.configureEventPolling(intervalMs, promise); + } } diff --git a/ios/Elementary.h b/ios/Elementary.h index e5b6d27..9e04543 100644 --- a/ios/Elementary.h +++ b/ios/Elementary.h @@ -40,9 +40,13 @@ @property(nonatomic, assign) AVAudioSessionCategoryOptions desiredAudioSessionOptions; /// Timer for processing queued runtime events (el.meter, el.snapshot, el.scope, el.fft). -/// Fires at ~30Hz on the main thread, drains the event queue and forwards to JS. +/// Started by startEventPolling (not auto-started — consumer must opt in). @property(nonatomic, strong) dispatch_source_t eventPollTimer; +/// Polling interval in milliseconds (default: 33ms ≈ 30Hz). +/// Set before startEventPolling, or call configureEventPolling to change at runtime. +@property(nonatomic, assign) NSUInteger eventPollIntervalMs; + /// Shared instance for native code to access the runtime (e.g. for real-time MIDI triggering) + (instancetype)sharedInstance; diff --git a/ios/Elementary.mm b/ios/Elementary.mm index b242470..78e4b5e 100644 --- a/ios/Elementary.mm +++ b/ios/Elementary.mm @@ -120,10 +120,11 @@ - (BOOL)initializeAudioEngineIfNeeded { name:AVAudioEngineConfigurationChangeNotification object:self.audioEngine]; - // Start polling for runtime events (el.snapshot, el.meter, el.scope, el.fft). - // These nodes queue events on the audio thread; processQueuedEvents drains - // them on the main thread and we forward to JS via RCTEventEmitter. - [self startEventPolling]; + // Event polling is NOT started automatically. + // Consumers that need el.snapshot / el.meter / el.scope events must + // explicitly call startEventPolling (or configureEventPolling + + // startEventPolling) to opt in. This avoids unnecessary JS thread + // overhead for apps that only use setProperty for real-time updates. self.audioEngineInitialized = YES; return YES; @@ -132,13 +133,14 @@ - (BOOL)initializeAudioEngineIfNeeded { - (void)startEventPolling { if (self.eventPollTimer) return; + NSUInteger intervalMs = self.eventPollIntervalMs ?: 33; // Default ~30Hz + dispatch_source_t timer = dispatch_source_create( DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()); - // ~30Hz (33ms interval) — enough for playhead UI, low overhead dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, 0), - 33 * NSEC_PER_MSEC, + intervalMs * NSEC_PER_MSEC, 5 * NSEC_PER_MSEC); __weak Elementary *weakSelf = self; @@ -655,6 +657,55 @@ - (void)getBundlePath:(RCTPromiseResolveBlock)resolve resolve(bundlePath); } +#pragma mark - Event Polling Control + +#ifdef RCT_NEW_ARCH_ENABLED +- (void)startEventPolling:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +#else +RCT_EXPORT_METHOD(startEventPolling:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +#endif +{ + [self startEventPolling]; + resolve(@YES); +} + +#ifdef RCT_NEW_ARCH_ENABLED +- (void)stopEventPolling:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +#else +RCT_EXPORT_METHOD(stopEventPolling:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +#endif +{ + if (self.eventPollTimer) { + dispatch_source_cancel(self.eventPollTimer); + self.eventPollTimer = nil; + } + resolve(@YES); +} + +#ifdef RCT_NEW_ARCH_ENABLED +- (void)configureEventPolling:(double)intervalMs + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +#else +RCT_EXPORT_METHOD(configureEventPolling:(double)intervalMs + resolve:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +#endif +{ + self.eventPollIntervalMs = (NSUInteger)fmax(10.0, fmin(1000.0, intervalMs)); + // If already running, restart with new interval + if (self.eventPollTimer) { + dispatch_source_cancel(self.eventPollTimer); + self.eventPollTimer = nil; + [self startEventPolling]; + } + resolve(@YES); +} + #pragma mark - RCTEventEmitter - (NSArray *)supportedEvents diff --git a/src/NativeElementary.ts b/src/NativeElementary.ts index a887596..146bf94 100644 --- a/src/NativeElementary.ts +++ b/src/NativeElementary.ts @@ -12,14 +12,6 @@ export type AudioResourceInfo = { export interface Spec extends TurboModule { getSampleRate(): Promise; - activateAudioSession(): Promise; - deactivateAudioSession(): Promise; - configureAudioSession( - category: string, - mode: string, - options: string[] - ): void; - disableAudioSessionManagement(): void; applyInstructions(message: string): void; // Real-time property updates (no graph re-render, audio-thread safe) @@ -36,6 +28,24 @@ export interface Spec extends TurboModule { // Path helpers getDocumentsDirectory(): Promise; getBundlePath(): Promise; + + // Event polling control + // Start/stop polling for el.snapshot, el.meter, el.scope, el.fft events. + // Polling is NOT started automatically — consumers must opt in. + // Use configureEventPolling to change the poll interval before starting. + startEventPolling(): Promise; + stopEventPolling(): Promise; + configureEventPolling(intervalMs: number): Promise; + + // iOS audio session (no-ops on Android) + activateAudioSession(): Promise; + deactivateAudioSession(): Promise; + configureAudioSession( + category: string, + mode: string, + options: string[] + ): void; + disableAudioSessionManagement(): void; } export default TurboModuleRegistry.getEnforcing('Elementary'); diff --git a/src/index.tsx b/src/index.tsx index 4d797d7..dd82852 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -124,6 +124,49 @@ export function getBundlePath(): Promise { return ElementaryModule.getBundlePath(); } +/** + * Start polling for Elementary runtime events (el.snapshot, el.meter, + * el.scope, el.fft). Events are delivered via the 'elementaryEvent' + * NativeEventEmitter. + * + * Polling is NOT started automatically — you must call this if your + * app needs snapshot/meter/scope events. Apps that only use setProperty + * for real-time updates can skip polling entirely for zero bridge overhead. + * + * Each event payload is a plain object with at least a `type` field + * (e.g. "snapshot", "meter", "scope") plus event-specific keys such as + * `source`, `data`, etc. + * + * Call stopEventPolling() to halt polling and release native timer resources. + */ +export function startEventPolling(): Promise { + return ElementaryModule.startEventPolling(); +} + +/** + * Stop polling for Elementary runtime events. + * Releases native timer resources (Android Handler / iOS dispatch_source_t). + * Call startEventPolling() to resume. + */ +export function stopEventPolling(): Promise { + return ElementaryModule.stopEventPolling(); +} + +/** + * Configure the event polling interval in milliseconds. + * Must be called before startEventPolling, or polling will be restarted + * with the new interval. + * + * Typical values: + * - 33ms (~30Hz): smooth metering and playhead updates + * - 100ms (~10Hz): drift correction only, minimal JS thread overhead + * + * Values are clamped to 10-1000ms. + */ +export function configureEventPolling(intervalMs: number): Promise { + return ElementaryModule.configureEventPolling(intervalMs); +} + /** * Update a property on a graph node without re-rendering the entire graph. * This operates directly on the audio thread — ideal for real-time MIDI