Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
69 changes: 61 additions & 8 deletions android/src/main/java/com/elementary/ElementaryModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -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()})")
}
Expand Down
15 changes: 15 additions & 0 deletions android/src/newarch/com/elementary/ElementaryTurboModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
6 changes: 5 additions & 1 deletion ios/Elementary.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
63 changes: 57 additions & 6 deletions ios/Elementary.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Comment thread
txbrown marked this conversation as resolved.
#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<NSString *> *)supportedEvents
Expand Down
26 changes: 18 additions & 8 deletions src/NativeElementary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,6 @@ export type AudioResourceInfo = {

export interface Spec extends TurboModule {
getSampleRate(): Promise<number>;
activateAudioSession(): Promise<boolean>;
deactivateAudioSession(): Promise<boolean>;
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)
Expand All @@ -36,6 +28,24 @@ export interface Spec extends TurboModule {
// Path helpers
getDocumentsDirectory(): Promise<string>;
getBundlePath(): Promise<string>;

// 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<boolean>;
stopEventPolling(): Promise<boolean>;
configureEventPolling(intervalMs: number): Promise<boolean>;

// iOS audio session (no-ops on Android)
activateAudioSession(): Promise<boolean>;
deactivateAudioSession(): Promise<boolean>;
configureAudioSession(
category: string,
mode: string,
options: string[]
): void;
disableAudioSessionManagement(): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('Elementary');
43 changes: 43 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,49 @@ export function getBundlePath(): Promise<string> {
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<boolean> {
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<boolean> {
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<boolean> {
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
Expand Down
Loading