Skip to content

Commit 8c31b64

Browse files
txbrownclaude
andcommitted
feat: add VFS audio resource loading and upgrade to Elementary v4
- Add loadAudioResource/unloadAudioResource APIs for loading audio files - Use miniaudio for cross-platform audio decoding (WAV, MP3, FLAC, etc.) - Upgrade Elementary submodule and @elemaudio/core to v4.0.3 - Update JS Renderer for Elementary v4 API (sendMessage-only constructor) - Fix audio buffer zeroing to prevent noise when no graph is active - Add AudioBufferResource for proper multi-channel sample support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9305335 commit 8c31b64

29 files changed

Lines changed: 1014 additions & 111 deletions

.claude/settings.local.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(git -C /Users/ricardoabreu/Development/oss/react-native-elementary-main log --oneline)",
5+
"Bash(gh issue view:*)",
6+
"Bash(git submodule:*)",
7+
"WebSearch",
8+
"WebFetch(domain:www.elementary.audio)",
9+
"WebFetch(domain:raw.githubusercontent.com)",
10+
"Bash(yarn install:*)",
11+
"Bash(pod install)",
12+
"Bash(npx pod-install)",
13+
"Bash(yarn build:ios:*)",
14+
"Bash(sudo xcode-select:*)",
15+
"Bash(xcodebuild:*)",
16+
"Bash(DEVELOPER_DIR=/Applications/Xcode-16.4.0.app/Contents/Developer xcodebuild:*)",
17+
"Bash(yarn build:android:*)",
18+
"Bash(yarn android)",
19+
"Bash(adb shell:*)",
20+
"Bash(adb push:*)"
21+
]
22+
}
23+
}

AGENTS.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# AGENTS.md
2+
3+
This file provides guidance to AI coding agents when working with code in this repository.
4+
5+
## Project Overview
6+
7+
**react-native-elementary** is a React Native library that bridges Elementary Audio with React Native, enabling audio synthesis and processing. It uses C++ audio processing (via miniAudio and Elementary Runtime) with platform-specific wrappers for iOS (Objective-C++/AVAudioEngine) and Android (Kotlin/JNI).
8+
9+
## Common Commands
10+
11+
```bash
12+
# Install dependencies
13+
yarn
14+
15+
# Start Metro bundler for example app
16+
yarn example start
17+
18+
# Run example app
19+
yarn example ios
20+
yarn example android
21+
22+
# Type checking
23+
yarn typecheck
24+
25+
# Linting (uses ESLint + Prettier)
26+
yarn lint
27+
yarn lint --fix
28+
29+
# Run tests
30+
yarn test
31+
32+
# Build library
33+
yarn prepack
34+
35+
# Setup project (install deps + pods)
36+
yarn bootstrap
37+
```
38+
39+
## Architecture
40+
41+
### Code Flow
42+
```
43+
JavaScript (useRenderer hook)
44+
45+
NativeElementary.ts (TurboModule spec)
46+
47+
┌─────────────────────────────────────┐
48+
│ iOS: Elementary.mm (Obj-C++) │
49+
│ Android: ElementaryModule.kt │
50+
└─────────────────────────────────────┘
51+
52+
C++ Layer (cpp/audioengine.cpp)
53+
54+
Elementary Runtime + miniAudio
55+
```
56+
57+
### Key Source Files
58+
- `src/index.tsx` - Public API: `getSampleRate()`, `NativeRenderer`, `useRenderer()`
59+
- `src/NativeElementary.ts` - TurboModule interface definition
60+
- `ios/Elementary.mm` - iOS native implementation using AVAudioEngine
61+
- `android/src/main/java/com/elementary/ElementaryModule.kt` - Android native module
62+
- `android/cpp-adapter.cpp` - JNI bridge to C++ layer
63+
- `cpp/audioengine.cpp` - Cross-platform audio engine using miniAudio
64+
65+
### New vs Old Architecture
66+
The library supports both React Native architectures:
67+
- **New Architecture**: TurboModules with JSI (controlled by `RCT_NEW_ARCH_ENABLED`)
68+
- **Old Architecture**: Standard bridge
69+
- Source sets in `android/src/newarch/` and `android/src/oldarch/` handle this
70+
71+
### Native Development
72+
- **iOS**: Open `example/ios/ElementaryExample.xcworkspace`, sources at `Pods > Development Pods > react-native-elementary`
73+
- **Android**: Open `example/android` in Android Studio, sources at `react-native-elementary` module
74+
75+
## Conventions
76+
77+
- **Package Manager**: Use `yarn` (not npm)
78+
- **Commit Messages**: Conventional Commits format (`fix:`, `feat:`, `refactor:`, `docs:`, `test:`, `chore:`)
79+
- **Code Style**: Single quotes, 2-space indent, ES5 trailing commas (enforced by Prettier)
80+
- **Node**: Requires >= 16.0.0

android/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ set(CMAKE_CXX_STANDARD 17) # Updated to C++17
77
add_library(react-native-elementary SHARED
88
../cpp/react-native-elementary.cpp
99
../cpp/audioengine.cpp
10+
../cpp/AudioResourceLoader.cpp
1011
cpp-adapter.cpp
1112
)
1213

android/cpp-adapter.cpp

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Java_com_elementary_ElementaryModule_nativeApplyInstructions(JNIEnv *env, jclass
2626

2727

2828
env->ReleaseStringUTFChars(instructions, instrCStr);
29-
29+
3030
auto jsonInstructions = elem::js::parseJSON(instrStr);
3131

3232
audioEngine->getRuntime().applyInstructions(jsonInstructions);
@@ -38,3 +38,78 @@ JNIEXPORT jint JNICALL
3838
Java_com_elementary_ElementaryModule_nativeGetSampleRate(JNIEnv *env, jclass type) {
3939
return audioEngine.get() ? audioEngine->getSampleRate() : 0;
4040
}
41+
42+
extern "C"
43+
JNIEXPORT jobject JNICALL
44+
Java_com_elementary_ElementaryModule_nativeLoadAudioResource(JNIEnv *env, jclass type, jstring key, jstring filePath) {
45+
if (!audioEngine) {
46+
return nullptr;
47+
}
48+
49+
const char *keyCStr = env->GetStringUTFChars(key, nullptr);
50+
const char *filePathCStr = env->GetStringUTFChars(filePath, nullptr);
51+
52+
if (!keyCStr || !filePathCStr) {
53+
if (keyCStr) env->ReleaseStringUTFChars(key, keyCStr);
54+
if (filePathCStr) env->ReleaseStringUTFChars(filePath, filePathCStr);
55+
return nullptr;
56+
}
57+
58+
std::string keyStr(keyCStr);
59+
std::string filePathStr(filePathCStr);
60+
61+
env->ReleaseStringUTFChars(key, keyCStr);
62+
env->ReleaseStringUTFChars(filePath, filePathCStr);
63+
64+
// Load the audio resource
65+
elementary::AudioLoadResult result = audioEngine->loadAudioResource(keyStr, filePathStr);
66+
67+
// Find the AudioResourceInfo class
68+
jclass infoClass = env->FindClass("com/elementary/AudioResourceInfo");
69+
if (!infoClass) {
70+
return nullptr;
71+
}
72+
73+
// Get the constructor
74+
jmethodID constructor = env->GetMethodID(infoClass, "<init>", "(ZLjava/lang/String;Ljava/lang/String;IJID)V");
75+
if (!constructor) {
76+
return nullptr;
77+
}
78+
79+
// Create the result object
80+
jstring jKey = env->NewStringUTF(result.info.key.c_str());
81+
jstring jError = env->NewStringUTF(result.error.c_str());
82+
83+
jobject infoObj = env->NewObject(
84+
infoClass,
85+
constructor,
86+
static_cast<jboolean>(result.success),
87+
jError,
88+
jKey,
89+
static_cast<jint>(result.info.channels),
90+
static_cast<jlong>(result.info.sampleCount),
91+
static_cast<jint>(result.info.sampleRate),
92+
static_cast<jdouble>(result.info.durationMs)
93+
);
94+
95+
return infoObj;
96+
}
97+
98+
extern "C"
99+
JNIEXPORT jboolean JNICALL
100+
Java_com_elementary_ElementaryModule_nativeUnloadAudioResource(JNIEnv *env, jclass type, jstring key) {
101+
if (!audioEngine) {
102+
return JNI_FALSE;
103+
}
104+
105+
const char *keyCStr = env->GetStringUTFChars(key, nullptr);
106+
if (!keyCStr) {
107+
return JNI_FALSE;
108+
}
109+
110+
std::string keyStr(keyCStr);
111+
env->ReleaseStringUTFChars(key, keyCStr);
112+
113+
bool result = audioEngine->unloadAudioResource(keyStr);
114+
return result ? JNI_TRUE : JNI_FALSE;
115+
}

android/src/main/java/com/elementary/ElementaryModule.kt

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,24 @@ import com.facebook.react.bridge.ReactApplicationContext
44
import com.facebook.react.bridge.ReactContextBaseJavaModule
55
import com.facebook.react.bridge.ReactMethod
66
import com.facebook.react.bridge.Promise
7+
import com.facebook.react.bridge.Arguments
78
import com.facebook.react.bridge.WritableMap
89
import com.facebook.react.modules.core.DeviceEventManagerModule
10+
import java.io.File
11+
import java.io.FileOutputStream
12+
13+
/**
14+
* Data class for audio resource information returned from native code
15+
*/
16+
data class AudioResourceInfo(
17+
val success: Boolean,
18+
val error: String,
19+
val key: String,
20+
val channels: Int,
21+
val sampleCount: Long,
22+
val sampleRate: Int,
23+
val durationMs: Double
24+
)
925

1026
class ElementaryModule(reactContext: ReactApplicationContext) :
1127
ReactContextBaseJavaModule(reactContext) {
@@ -34,6 +50,70 @@ class ElementaryModule(reactContext: ReactApplicationContext) :
3450
// No-op
3551
}
3652

53+
@ReactMethod
54+
fun loadAudioResource(key: String, filePath: String, promise: Promise) {
55+
Thread {
56+
try {
57+
val result = nativeLoadAudioResource(key, filePath)
58+
if (result == null) {
59+
promise.reject("E_NATIVE_ERROR", "Native audio engine not initialized")
60+
return@Thread
61+
}
62+
63+
if (!result.success) {
64+
promise.reject("E_LOAD_FAILED", result.error)
65+
return@Thread
66+
}
67+
68+
val info = Arguments.createMap().apply {
69+
putString("key", result.key)
70+
putInt("channels", result.channels)
71+
putDouble("sampleCount", result.sampleCount.toDouble())
72+
putInt("sampleRate", result.sampleRate)
73+
putDouble("durationMs", result.durationMs)
74+
}
75+
promise.resolve(info)
76+
} catch (e: Exception) {
77+
promise.reject("E_LOAD_FAILED", e.message, e)
78+
}
79+
}.start()
80+
}
81+
82+
@ReactMethod
83+
fun unloadAudioResource(key: String, promise: Promise) {
84+
try {
85+
val result = nativeUnloadAudioResource(key)
86+
promise.resolve(result)
87+
} catch (e: Exception) {
88+
promise.reject("E_UNLOAD_FAILED", e.message, e)
89+
}
90+
}
91+
92+
@ReactMethod
93+
fun getDocumentsDirectory(promise: Promise) {
94+
val documentsDir = reactApplicationContext.filesDir.absolutePath
95+
promise.resolve(documentsDir)
96+
}
97+
98+
@ReactMethod
99+
fun copyAssetToFiles(assetPath: String, destPath: String, promise: Promise) {
100+
Thread {
101+
try {
102+
val destFile = File(destPath)
103+
destFile.parentFile?.mkdirs()
104+
105+
reactApplicationContext.assets.open(assetPath).use { input ->
106+
FileOutputStream(destFile).use { output ->
107+
input.copyTo(output)
108+
}
109+
}
110+
promise.resolve(destPath)
111+
} catch (e: Exception) {
112+
promise.reject("E_COPY_FAILED", e.message, e)
113+
}
114+
}.start()
115+
}
116+
37117
// Helper to emit events
38118
private fun sendEvent(eventName: String, params: WritableMap?) {
39119
reactApplicationContext
@@ -54,7 +134,9 @@ class ElementaryModule(reactContext: ReactApplicationContext) :
54134
nativeStartAudioEngine();
55135
}
56136

57-
external fun nativeGetSampleRate(): Int;
58-
external fun nativeApplyInstructions(message: String);
59-
external fun nativeStartAudioEngine();
137+
external fun nativeGetSampleRate(): Int
138+
external fun nativeApplyInstructions(message: String)
139+
external fun nativeStartAudioEngine()
140+
external fun nativeLoadAudioResource(key: String, filePath: String): AudioResourceInfo?
141+
external fun nativeUnloadAudioResource(key: String): Boolean
60142
}

android/src/newarch/com/elementary/ElementaryTurboModule.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,24 @@ public void addListener(String eventName) {
3232
public void removeListeners(double count) {
3333
module.removeListeners(count);
3434
}
35-
}
35+
36+
@Override
37+
public void loadAudioResource(String key, String filePath, Promise promise) {
38+
module.loadAudioResource(key, filePath, promise);
39+
}
40+
41+
@Override
42+
public void unloadAudioResource(String key, Promise promise) {
43+
module.unloadAudioResource(key, promise);
44+
}
45+
46+
@Override
47+
public void getDocumentsDirectory(Promise promise) {
48+
module.getDocumentsDirectory(promise);
49+
}
50+
51+
@Override
52+
public void copyAssetToFiles(String assetPath, String destPath, Promise promise) {
53+
module.copyAssetToFiles(assetPath, destPath, promise);
54+
}
55+
}

cpp/AudioResourceLoader.cpp

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#include "AudioResourceLoader.h"
2+
#include "miniaudio.h"
3+
4+
namespace elementary {
5+
6+
AudioLoadResult AudioResourceLoader::loadFile(const std::string& key, const std::string& filePath) {
7+
AudioLoadResult result;
8+
result.success = false;
9+
result.info.key = key;
10+
11+
ma_decoder decoder;
12+
ma_decoder_config config = ma_decoder_config_init(ma_format_f32, 0, 0);
13+
14+
ma_result initResult = ma_decoder_init_file(filePath.c_str(), &config, &decoder);
15+
if (initResult != MA_SUCCESS) {
16+
result.error = "Failed to open audio file: " + filePath;
17+
return result;
18+
}
19+
20+
ma_uint64 totalFrames;
21+
ma_result lengthResult = ma_decoder_get_length_in_pcm_frames(&decoder, &totalFrames);
22+
if (lengthResult != MA_SUCCESS) {
23+
result.error = "Failed to get audio file length";
24+
ma_decoder_uninit(&decoder);
25+
return result;
26+
}
27+
28+
uint32_t channels = decoder.outputChannels;
29+
uint32_t sampleRate = decoder.outputSampleRate;
30+
31+
std::vector<float> interleavedData(totalFrames * channels);
32+
ma_uint64 framesRead;
33+
ma_result readResult = ma_decoder_read_pcm_frames(&decoder, interleavedData.data(), totalFrames, &framesRead);
34+
35+
ma_decoder_uninit(&decoder);
36+
37+
if (readResult != MA_SUCCESS && readResult != MA_AT_END) {
38+
result.error = "Failed to read audio data";
39+
return result;
40+
}
41+
42+
interleavedData.resize(framesRead * channels);
43+
44+
// Deinterleave into separate channel data
45+
result.data.resize(framesRead * channels);
46+
for (uint32_t ch = 0; ch < channels; ++ch) {
47+
for (ma_uint64 frame = 0; frame < framesRead; ++frame) {
48+
result.data[ch * framesRead + frame] = interleavedData[frame * channels + ch];
49+
}
50+
}
51+
52+
result.info.channels = channels;
53+
result.info.sampleCount = static_cast<uint64_t>(framesRead);
54+
result.info.sampleRate = sampleRate;
55+
result.info.durationMs = (static_cast<double>(framesRead) / static_cast<double>(sampleRate)) * 1000.0;
56+
57+
result.success = true;
58+
return result;
59+
}
60+
61+
} // namespace elementary

0 commit comments

Comments
 (0)