Skip to content
Draft
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
28 changes: 28 additions & 0 deletions android/cpp-adapter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ Java_com_elementary_ElementaryModule_nativeGetSampleRate(JNIEnv *env, jclass typ
return audioEngine.get() ? audioEngine->getSampleRate() : 0;
}

extern "C"
JNIEXPORT jint JNICALL
Java_com_elementary_ElementaryModule_nativeGetNumChannels(JNIEnv *env, jclass type) {
return audioEngine.get() ? audioEngine->getNumChannels() : 0;
}

extern "C"
JNIEXPORT jboolean JNICALL
Java_com_elementary_ElementaryModule_nativeIsDeviceRunning(JNIEnv *env, jclass type) {
return audioEngine.get() ? static_cast<jboolean>(audioEngine->isDeviceRunning()) : JNI_FALSE;
}

extern "C"
JNIEXPORT void JNICALL
Java_com_elementary_ElementaryModule_nativeStopDevice(JNIEnv *env, jclass type) {
if (audioEngine) {
audioEngine->stopDevice();
}
}

extern "C"
JNIEXPORT void JNICALL
Java_com_elementary_ElementaryModule_nativeStartDevice(JNIEnv *env, jclass type) {
if (audioEngine) {
audioEngine->startDevice();
}
}

extern "C"
JNIEXPORT jobject JNICALL
Java_com_elementary_ElementaryModule_nativeLoadAudioResource(JNIEnv *env, jclass type, jstring key, jstring filePath) {
Expand Down
160 changes: 157 additions & 3 deletions android/src/main/java/com/elementary/ElementaryModule.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
package com.elementary

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule

Expand All @@ -22,7 +32,48 @@ data class AudioResourceInfo(
)

class ElementaryModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
ReactContextBaseJavaModule(reactContext), LifecycleEventListener {

private val audioManager = reactContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus = false

private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Log.d(TAG, "Audio focus gained, restarting device")
hasAudioFocus = true
nativeStartDevice()
}
AudioManager.AUDIOFOCUS_LOSS -> {
Log.d(TAG, "Audio focus lost permanently, stopping device")
hasAudioFocus = false
nativeStopDevice()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.d(TAG, "Audio focus lost transiently, stopping device")
hasAudioFocus = false
nativeStopDevice()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// Could lower volume instead, but for an audio engine it's safer to stop
Log.d(TAG, "Audio focus lost (duck), stopping device")
hasAudioFocus = false
nativeStopDevice()
}
}
}

// Handle headphone disconnect (equivalent to iOS AVAudioEngineConfigurationChangeNotification)
private val noisyAudioReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) {
Log.d(TAG, "Audio becoming noisy (headphones disconnected), restarting device")
nativeStopDevice()
nativeStartDevice()
}
}
}

override fun getName(): String {
return NAME
Expand Down Expand Up @@ -93,6 +144,32 @@ class ElementaryModule(reactContext: ReactApplicationContext) :
promise.resolve(documentsDir)
}

@ReactMethod
fun getBundlePath(promise: Promise) {
val dataDir = reactApplicationContext.applicationInfo.dataDir
promise.resolve(dataDir)
}

@ReactMethod
fun setProperty(nodeHash: Double, key: String, value: Double) {
// Build a SET_PROPERTY instruction batch: [[3, nodeHash, key, value]]
// InstructionType::SET_PROPERTY = 3
val instruction = "[3,${nodeHash.toInt()},\"$key\",$value]"
val batch = "[$instruction]"
nativeApplyInstructions(batch)
}

@ReactMethod
fun getAudioInfo(promise: Promise) {
val info = Arguments.createMap().apply {
putInt("channels", nativeGetNumChannels())
putInt("sampleRate", nativeGetSampleRate())
putBoolean("engineRunning", nativeIsDeviceRunning())
putBoolean("runtimeReady", nativeGetSampleRate() > 0)
}
promise.resolve(info)
}

// Helper to emit events
private fun sendEvent(eventName: String, params: WritableMap?) {
reactApplicationContext
Expand All @@ -104,18 +181,95 @@ class ElementaryModule(reactContext: ReactApplicationContext) :
sendEvent("AudioPlaybackFinished", null)
}

private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener)
.build()
audioFocusRequest = focusRequest
val result = audioManager.requestAudioFocus(focusRequest)
hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} else {
@Suppress("DEPRECATION")
val result = audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
Log.d(TAG, "Audio focus requested, granted: $hasAudioFocus")
}

private fun abandonAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusChangeListener)
}
hasAudioFocus = false
}

// LifecycleEventListener
override fun onHostResume() {
if (!hasAudioFocus) {
Log.d(TAG, "Host resumed without audio focus, re-requesting")
requestAudioFocus()
}
if (hasAudioFocus && !nativeIsDeviceRunning()) {
Log.d(TAG, "Device not running, restarting")
nativeStartDevice()
Log.d(TAG, "Device running after start: ${nativeIsDeviceRunning()}")
}
}

override fun onHostPause() {}

override fun onHostDestroy() {
abandonAudioFocus()
try {
reactApplicationContext.unregisterReceiver(noisyAudioReceiver)
} catch (_: IllegalArgumentException) {
// Receiver was not registered
}
}

companion object {
const val NAME = "Elementary"
private const val TAG = "Elementary"
}

init {
System.loadLibrary("react-native-elementary");
nativeStartAudioEngine();
System.loadLibrary("react-native-elementary")
nativeStartAudioEngine()

// Request audio focus
requestAudioFocus()

// Register for headphone disconnect events
val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
reactContext.registerReceiver(noisyAudioReceiver, filter)

// Register lifecycle listener for cleanup
reactContext.addLifecycleEventListener(this)

Log.d(TAG, "Audio engine initialized (channels=${nativeGetNumChannels()}, sampleRate=${nativeGetSampleRate()})")
}

external fun nativeGetSampleRate(): Int
external fun nativeGetNumChannels(): Int
external fun nativeIsDeviceRunning(): Boolean
external fun nativeApplyInstructions(message: String)
external fun nativeStartAudioEngine()
external fun nativeStopDevice()
external fun nativeStartDevice()
external fun nativeLoadAudioResource(key: String, filePath: String): AudioResourceInfo?
external fun nativeUnloadAudioResource(key: String): Boolean
}
10 changes: 10 additions & 0 deletions android/src/newarch/com/elementary/ElementaryTurboModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,14 @@ public void unloadAudioResource(String key, Promise promise) {
public void getDocumentsDirectory(Promise promise) {
module.getDocumentsDirectory(promise);
}

@Override
public void getBundlePath(Promise promise) {
module.getBundlePath(promise);
}

@Override
public void setProperty(double nodeHash, String key, double value) {
module.setProperty(nodeHash, key, value);
}
}
36 changes: 36 additions & 0 deletions cpp/audioengine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,42 @@ namespace elementary {
return device.sampleRate;
}

int AudioEngine::getNumChannels() {
return deviceInitialized ? static_cast<int>(device.playback.channels) : 0;
}

bool AudioEngine::isDeviceRunning() {
if (!deviceInitialized) return false;
return ma_device_get_state(&device) == ma_device_state_started;
}

void AudioEngine::stopDevice() {
if (deviceInitialized) {
proxy->muted.store(true, std::memory_order_relaxed);
ma_device_stop(&device);
}
}

void AudioEngine::startDevice() {
if (!deviceInitialized) return;

proxy->muted.store(false, std::memory_order_relaxed);
ma_result result = ma_device_start(&device);

if (result != MA_SUCCESS) {
// Device start failed — reinitialize
ma_device_uninit(&device);
deviceInitialized = false;

deviceConfig.pUserData = proxy.get();
result = ma_device_init(nullptr, &deviceConfig, &device);
if (result == MA_SUCCESS) {
deviceInitialized = true;
ma_device_start(&device);
}
}
}

AudioLoadResult AudioEngine::loadAudioResource(const std::string& key, const std::string& filePath) {
AudioLoadResult result = AudioResourceLoader::loadFile(key, filePath);

Expand Down
27 changes: 23 additions & 4 deletions cpp/audioengine.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,31 @@
#include "../cpp/vendor/elementary/runtime/elem/Runtime.h"
#include "AudioResourceLoader.h"
#include "miniaudio.h"
#include <atomic>
#include <mutex>
#include <unordered_set>

namespace elementary {
struct DeviceProxy {
elem::Runtime<float> runtime;
std::vector<float> scratchData;
std::atomic<bool> muted{false};

DeviceProxy(double sampleRate, size_t blockSize)
: runtime(sampleRate, blockSize), scratchData(2 * blockSize) {}

void process(float* outputData, size_t numChannels, size_t numFrames) {
if (scratchData.size() < (numChannels * numFrames))
scratchData.resize(numChannels * numFrames);
if (muted.load(std::memory_order_relaxed)) {
std::memset(outputData, 0, numChannels * numFrames * sizeof(float));
return;
}
// Clamp to max supported channels (stereo) to prevent out-of-bounds
// access if the device reports more channels than we can handle
static constexpr size_t kMaxChannels = 2;
size_t processChannels = std::min(numChannels, kMaxChannels);

if (scratchData.size() < (processChannels * numFrames))
scratchData.resize(processChannels * numFrames);

auto* deinterleaved = scratchData.data();
std::array<float*, 2> ptrs {deinterleaved, deinterleaved + numFrames};
Expand All @@ -26,14 +37,18 @@ namespace elementary {
nullptr,
0,
ptrs.data(),
numChannels,
processChannels,
numFrames,
nullptr
);

for (size_t i = 0; i < numChannels; ++i) {
for (size_t j = 0; j < numFrames; ++j) {
outputData[i + numChannels * j] = deinterleaved[i * numFrames + j];
if (i < processChannels) {
outputData[i + numChannels * j] = deinterleaved[i * numFrames + j];
} else {
outputData[i + numChannels * j] = 0.0f;
}
}
}
}
Expand All @@ -46,6 +61,10 @@ namespace elementary {

elem::Runtime<float>& getRuntime();
int getSampleRate();
int getNumChannels();
bool isDeviceRunning();
void stopDevice();
void startDevice();

// VFS / Audio Resource methods
AudioLoadResult loadAudioResource(const std::string& key, const std::string& filePath);
Expand Down
3 changes: 3 additions & 0 deletions ios/Elementary.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@
@property(nonatomic, assign) std::shared_ptr<elem::Runtime<float>> runtime;
@property(nonatomic, strong) NSMutableSet<NSString *> *loadedResources;

/// Shared instance for native code to access the runtime (e.g. for real-time MIDI triggering)
+ (instancetype)sharedInstance;

@end
Loading
Loading