diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 00f2135..0bccb34 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -7,6 +7,7 @@ set(CMAKE_CXX_STANDARD 17) # Updated to C++17 add_library(react-native-elementary SHARED ../cpp/react-native-elementary.cpp ../cpp/audioengine.cpp + ../cpp/AudioResourceLoader.cpp cpp-adapter.cpp ) diff --git a/android/cpp-adapter.cpp b/android/cpp-adapter.cpp index dad0b02..3c6b6f8 100644 --- a/android/cpp-adapter.cpp +++ b/android/cpp-adapter.cpp @@ -26,7 +26,7 @@ Java_com_elementary_ElementaryModule_nativeApplyInstructions(JNIEnv *env, jclass env->ReleaseStringUTFChars(instructions, instrCStr); - + auto jsonInstructions = elem::js::parseJSON(instrStr); audioEngine->getRuntime().applyInstructions(jsonInstructions); @@ -38,3 +38,78 @@ JNIEXPORT jint JNICALL Java_com_elementary_ElementaryModule_nativeGetSampleRate(JNIEnv *env, jclass type) { return audioEngine.get() ? audioEngine->getSampleRate() : 0; } + +extern "C" +JNIEXPORT jobject JNICALL +Java_com_elementary_ElementaryModule_nativeLoadAudioResource(JNIEnv *env, jclass type, jstring key, jstring filePath) { + if (!audioEngine) { + return nullptr; + } + + const char *keyCStr = env->GetStringUTFChars(key, nullptr); + const char *filePathCStr = env->GetStringUTFChars(filePath, nullptr); + + if (!keyCStr || !filePathCStr) { + if (keyCStr) env->ReleaseStringUTFChars(key, keyCStr); + if (filePathCStr) env->ReleaseStringUTFChars(filePath, filePathCStr); + return nullptr; + } + + std::string keyStr(keyCStr); + std::string filePathStr(filePathCStr); + + env->ReleaseStringUTFChars(key, keyCStr); + env->ReleaseStringUTFChars(filePath, filePathCStr); + + // Load the audio resource + elementary::AudioLoadResult result = audioEngine->loadAudioResource(keyStr, filePathStr); + + // Find the AudioResourceInfo class + jclass infoClass = env->FindClass("com/elementary/AudioResourceInfo"); + if (!infoClass) { + return nullptr; + } + + // Get the constructor + jmethodID constructor = env->GetMethodID(infoClass, "", "(ZLjava/lang/String;Ljava/lang/String;IJID)V"); + if (!constructor) { + return nullptr; + } + + // Create the result object + jstring jKey = env->NewStringUTF(result.info.key.c_str()); + jstring jError = env->NewStringUTF(result.error.c_str()); + + jobject infoObj = env->NewObject( + infoClass, + constructor, + static_cast(result.success), + jError, + jKey, + static_cast(result.info.channels), + static_cast(result.info.sampleCount), + static_cast(result.info.sampleRate), + static_cast(result.info.durationMs) + ); + + return infoObj; +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_com_elementary_ElementaryModule_nativeUnloadAudioResource(JNIEnv *env, jclass type, jstring key) { + if (!audioEngine) { + return JNI_FALSE; + } + + const char *keyCStr = env->GetStringUTFChars(key, nullptr); + if (!keyCStr) { + return JNI_FALSE; + } + + std::string keyStr(keyCStr); + env->ReleaseStringUTFChars(key, keyCStr); + + bool result = audioEngine->unloadAudioResource(keyStr); + return result ? JNI_TRUE : JNI_FALSE; +} diff --git a/android/src/main/java/com/elementary/ElementaryModule.kt b/android/src/main/java/com/elementary/ElementaryModule.kt index 0728ca4..d1e6b38 100644 --- a/android/src/main/java/com/elementary/ElementaryModule.kt +++ b/android/src/main/java/com/elementary/ElementaryModule.kt @@ -4,9 +4,23 @@ 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.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule +/** + * Data class for audio resource information returned from native code + */ +data class AudioResourceInfo( + val success: Boolean, + val error: String, + val key: String, + val channels: Int, + val sampleCount: Long, + val sampleRate: Int, + val durationMs: Double +) + class ElementaryModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { @@ -34,6 +48,51 @@ class ElementaryModule(reactContext: ReactApplicationContext) : // No-op } + @ReactMethod + fun loadAudioResource(key: String, filePath: String, promise: Promise) { + Thread { + try { + val result = nativeLoadAudioResource(key, filePath) + if (result == null) { + promise.reject("E_NATIVE_ERROR", "Native audio engine not initialized") + return@Thread + } + + if (!result.success) { + promise.reject("E_LOAD_FAILED", result.error) + return@Thread + } + + val info = Arguments.createMap().apply { + putString("key", result.key) + putInt("channels", result.channels) + putDouble("sampleCount", result.sampleCount.toDouble()) + putInt("sampleRate", result.sampleRate) + putDouble("durationMs", result.durationMs) + } + promise.resolve(info) + } catch (e: Exception) { + promise.reject("E_LOAD_FAILED", e.message, e) + } + }.start() + } + + @ReactMethod + fun unloadAudioResource(key: String, promise: Promise) { + try { + val result = nativeUnloadAudioResource(key) + promise.resolve(result) + } catch (e: Exception) { + promise.reject("E_UNLOAD_FAILED", e.message, e) + } + } + + @ReactMethod + fun getDocumentsDirectory(promise: Promise) { + val documentsDir = reactApplicationContext.filesDir.absolutePath + promise.resolve(documentsDir) + } + // Helper to emit events private fun sendEvent(eventName: String, params: WritableMap?) { reactApplicationContext @@ -54,7 +113,9 @@ class ElementaryModule(reactContext: ReactApplicationContext) : nativeStartAudioEngine(); } - external fun nativeGetSampleRate(): Int; - external fun nativeApplyInstructions(message: String); - external fun nativeStartAudioEngine(); + external fun nativeGetSampleRate(): Int + external fun nativeApplyInstructions(message: String) + external fun nativeStartAudioEngine() + external fun nativeLoadAudioResource(key: String, filePath: String): AudioResourceInfo? + external fun nativeUnloadAudioResource(key: String): Boolean } diff --git a/android/src/newarch/com/elementary/ElementaryTurboModule.java b/android/src/newarch/com/elementary/ElementaryTurboModule.java index 696f40b..e77a4ba 100644 --- a/android/src/newarch/com/elementary/ElementaryTurboModule.java +++ b/android/src/newarch/com/elementary/ElementaryTurboModule.java @@ -32,4 +32,19 @@ public void addListener(String eventName) { public void removeListeners(double count) { module.removeListeners(count); } -} \ No newline at end of file + + @Override + public void loadAudioResource(String key, String filePath, Promise promise) { + module.loadAudioResource(key, filePath, promise); + } + + @Override + public void unloadAudioResource(String key, Promise promise) { + module.unloadAudioResource(key, promise); + } + + @Override + public void getDocumentsDirectory(Promise promise) { + module.getDocumentsDirectory(promise); + } +} diff --git a/cpp/AudioResourceLoader.cpp b/cpp/AudioResourceLoader.cpp new file mode 100644 index 0000000..07f8ec5 --- /dev/null +++ b/cpp/AudioResourceLoader.cpp @@ -0,0 +1,61 @@ +#include "AudioResourceLoader.h" +#include "miniaudio.h" + +namespace elementary { + + AudioLoadResult AudioResourceLoader::loadFile(const std::string& key, const std::string& filePath) { + AudioLoadResult result; + result.success = false; + result.info.key = key; + + ma_decoder decoder; + ma_decoder_config config = ma_decoder_config_init(ma_format_f32, 0, 0); + + ma_result initResult = ma_decoder_init_file(filePath.c_str(), &config, &decoder); + if (initResult != MA_SUCCESS) { + result.error = "Failed to open audio file: " + filePath; + return result; + } + + ma_uint64 totalFrames; + ma_result lengthResult = ma_decoder_get_length_in_pcm_frames(&decoder, &totalFrames); + if (lengthResult != MA_SUCCESS) { + result.error = "Failed to get audio file length"; + ma_decoder_uninit(&decoder); + return result; + } + + uint32_t channels = decoder.outputChannels; + uint32_t sampleRate = decoder.outputSampleRate; + + std::vector interleavedData(totalFrames * channels); + ma_uint64 framesRead; + ma_result readResult = ma_decoder_read_pcm_frames(&decoder, interleavedData.data(), totalFrames, &framesRead); + + ma_decoder_uninit(&decoder); + + if (readResult != MA_SUCCESS && readResult != MA_AT_END) { + result.error = "Failed to read audio data"; + return result; + } + + interleavedData.resize(framesRead * channels); + + // Deinterleave into separate channel data + result.data.resize(framesRead * channels); + for (uint32_t ch = 0; ch < channels; ++ch) { + for (ma_uint64 frame = 0; frame < framesRead; ++frame) { + result.data[ch * framesRead + frame] = interleavedData[frame * channels + ch]; + } + } + + result.info.channels = channels; + result.info.sampleCount = static_cast(framesRead); + result.info.sampleRate = sampleRate; + result.info.durationMs = (static_cast(framesRead) / static_cast(sampleRate)) * 1000.0; + + result.success = true; + return result; + } + +} // namespace elementary diff --git a/cpp/AudioResourceLoader.h b/cpp/AudioResourceLoader.h new file mode 100644 index 0000000..614fb69 --- /dev/null +++ b/cpp/AudioResourceLoader.h @@ -0,0 +1,51 @@ +#ifndef AUDIORESOURCELOADER_H +#define AUDIORESOURCELOADER_H + +#include +#include +#include +#include + +namespace elementary { + + /** + * Metadata about a loaded audio resource + */ + struct AudioResourceInfo { + std::string key; + uint32_t channels; + uint64_t sampleCount; // samples per channel + uint32_t sampleRate; + double durationMs; + }; + + /** + * Result of loading an audio resource + */ + struct AudioLoadResult { + bool success; + std::string error; + AudioResourceInfo info; + std::vector data; // Deinterleaved: all ch0 samples, then all ch1 samples, etc. + }; + + /** + * Audio Resource Loader using miniaudio + * + * Loads audio files (WAV, MP3, FLAC, etc.) and converts to deinterleaved float32 format + * suitable for Elementary Audio's Virtual File System. + */ + class AudioResourceLoader { + public: + /** + * Load an audio file from disk + * @param key - Unique identifier for this resource + * @param filePath - Absolute path to the audio file + * @return AudioLoadResult containing the audio data and metadata, or error info + */ + static AudioLoadResult loadFile(const std::string& key, const std::string& filePath); + }; + +} // namespace elementary + +#endif // AUDIORESOURCELOADER_H diff --git a/cpp/audioengine.cpp b/cpp/audioengine.cpp index 78069a8..2901d46 100644 --- a/cpp/audioengine.cpp +++ b/cpp/audioengine.cpp @@ -1,4 +1,5 @@ #include "audioengine.h" +#include "vendor/elementary/runtime/elem/AudioBufferResource.h" #define MINIAUDIO_IMPLEMENTATION #include "miniaudio.h" @@ -21,6 +22,61 @@ namespace elementary { return device.sampleRate; } + AudioLoadResult AudioEngine::loadAudioResource(const std::string& key, const std::string& filePath) { + AudioLoadResult result = AudioResourceLoader::loadFile(key, filePath); + + if (!result.success) { + return result; + } + + size_t numChannels = result.info.channels; + size_t numSamples = result.info.sampleCount; + std::vector channelPtrs(numChannels); + for (size_t ch = 0; ch < numChannels; ++ch) { + channelPtrs[ch] = result.data.data() + (ch * numSamples); + } + + auto resource = std::make_unique( + channelPtrs.data(), + numChannels, + numSamples + ); + bool added = proxy->runtime.addSharedResource(key, std::move(resource)); + + if (!added) { + result.success = false; + result.error = "Resource with key '" + key + "' already exists"; + return result; + } + + { + std::lock_guard lock(resourceMutex); + loadedResources.insert(key); + } + + return result; + } + + bool AudioEngine::unloadAudioResource(const std::string& key) { + bool found = false; + + // Only hold the lock while modifying loadedResources + { + std::lock_guard lock(resourceMutex); + auto it = loadedResources.find(key); + if (it != loadedResources.end()) { + loadedResources.erase(it); + found = true; + } + } + + if (found) { + proxy->runtime.pruneSharedResources(); + } + + return found; + } + void AudioEngine::initializeDevice() { proxy = std::make_unique(44100.0, 1024); diff --git a/cpp/audioengine.h b/cpp/audioengine.h index d1a2dfc..31d4ae7 100644 --- a/cpp/audioengine.h +++ b/cpp/audioengine.h @@ -2,7 +2,10 @@ #define AUDIOENGINE_H #include "../cpp/vendor/elementary/runtime/elem/Runtime.h" +#include "AudioResourceLoader.h" #include "miniaudio.h" +#include +#include namespace elementary { struct DeviceProxy { @@ -10,7 +13,7 @@ namespace elementary { std::vector scratchData; DeviceProxy(double sampleRate, size_t blockSize) - : scratchData(2 * blockSize), runtime(sampleRate, blockSize) {} + : runtime(sampleRate, blockSize), scratchData(2 * blockSize) {} void process(float* outputData, size_t numChannels, size_t numFrames) { if (scratchData.size() < (numChannels * numFrames)) @@ -44,6 +47,10 @@ namespace elementary { elem::Runtime& getRuntime(); int getSampleRate(); + // VFS / Audio Resource methods + AudioLoadResult loadAudioResource(const std::string& key, const std::string& filePath); + bool unloadAudioResource(const std::string& key); + private: void initializeDevice(); static void audioCallback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount); @@ -52,6 +59,10 @@ namespace elementary { ma_device_config deviceConfig; ma_device device; bool deviceInitialized; + + // Track loaded resources for unloading + std::mutex resourceMutex; + std::unordered_set loadedResources; }; } diff --git a/cpp/vendor/elementary b/cpp/vendor/elementary index c16e089..9fe1d26 160000 --- a/cpp/vendor/elementary +++ b/cpp/vendor/elementary @@ -1 +1 @@ -Subproject commit c16e0897d1fad9816512265020166cea5c602acb +Subproject commit 9fe1d2665ce2e8d317749a32b0a202e7b131635f diff --git a/example/App.tsx b/example/App.tsx index d74bfb7..fe90917 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,28 +1,194 @@ import * as React from 'react'; +import { useState, useRef, useEffect } from 'react'; -import { StyleSheet, View, Text, Button } from 'react-native'; -import { useRenderer } from 'react-native-elementary'; +import { StyleSheet, View, Text, Button, Platform } from 'react-native'; +import { + useRenderer, + loadAudioResource, + unloadAudioResource, + AudioResourceInfo, +} from 'react-native-elementary'; +import RNFS from 'react-native-fs'; import { el } from '@elemaudio/core'; +const SAMPLE_FILES = ['kick.wav', 'snare.wav', 'hihat.wav'] as const; + +const getSamplePath = async (filename: string): Promise => { + if (Platform.OS === 'android') { + const dest = `${RNFS.DocumentDirectoryPath}/${filename}`; + const exists = await RNFS.exists(dest); + if (!exists) { + await RNFS.copyFileAssets(`samples/${filename}`, dest); + } + return dest; + } + return `${RNFS.MainBundlePath}/samples/${filename}`; +}; + export default function App() { const { core } = useRenderer(); + const [loadedSamples, setLoadedSamples] = useState< + Record + >({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const triggerRef = useRef>({ + kick: 0, + snare: 0, + hihat: 0, + }); + const lastPlayTime = useRef>({}); + + const loadSamples = async () => { + setLoading(true); + setError(null); + + try { + const loaded: Record = {}; + + for (const file of SAMPLE_FILES) { + const key = file.replace('.wav', ''); + const path = await getSamplePath(file); + const info = await loadAudioResource(key, path); + loaded[key] = info; + } + + setLoadedSamples(loaded); + } catch (e) { + console.error('Failed to load samples:', e); + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + return () => { + Object.keys(loadedSamples).forEach((key) => { + unloadAudioResource(key).catch(console.error); + }); + }; + }, [loadedSamples]); + + const playSample = (key: string) => { + if (!core || !loadedSamples[key]) return; + + const now = Date.now(); + const lastTime = lastPlayTime.current[key] || 0; + if (now - lastTime < 100) return; + lastPlayTime.current[key] = now; + + triggerRef.current[key] = (triggerRef.current[key] || 0) + 1; + + const sample = el.sample( + { path: key, mode: 'trigger', key: `${key}-sample` }, + el.const({ key: `${key}-trig`, value: triggerRef.current[key] }), + 1 + ); + + core.render(sample, sample); + }; + + const playBeat = () => { + if (!core || Object.keys(loadedSamples).length === 0) return; + + const bpm = 126; + const beatFreq = bpm / 60; + + const kick = el.mul( + el.sample({ path: 'kick', mode: 'trigger' }, el.train(beatFreq), 1), + 0.9 + ); + const snare = el.mul( + el.sample({ path: 'snare', mode: 'trigger' }, el.train(beatFreq / 2), 1), + 0.6 + ); + const hihat = el.mul( + el.sample({ path: 'hihat', mode: 'trigger' }, el.train(beatFreq * 2), 1), + 0.35 + ); + + const mix = el.add(kick, el.add(snare, hihat)); + core.render(mix, mix); + }; + + const stop = () => { + if (core) { + core.render(); + } + }; if (!core) { return ( - Initialising audio... + Initialising audio... ); } return ( - Audio engine initialised -