diff --git a/README.md b/README.md index c1b2b2c..5bf9c17 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,38 @@ > Use [Elementary Audio][elem] in your React Native app -This is alpha quality software. +[![Alpha](https://img.shields.io/badge/status-alpha-orange)](https://github.com/tamlyn/react-native-elementary/releases) [elem]: https://elementary.audio +## Project status + +Alpha. The API is subject to change. + +### Supported platforms + +- iOS +- Android + +### Supported features + +- Native Elementary Audio renderer via `useRenderer` hook +- Real-time `setProperty` for graph parameter updates +- iOS audio session configuration and management +- Configurable event polling (`el.snapshot`, `el.meter`, `el.scope`, `el.fft`) +- Audio resource loading via VFS + +### Known issues + +- Native node types (`el.metro`, `el.time`, `el.fft`, `el.convolve`) are not yet + supported (see [#4][i4]) +- Audio I/O may not update automatically when device connection changes; + engine restart on route change is handled, re-graph against new route is pending + (see [#15][i15]) + +[i4]: https://github.com/tamlyn/react-native-elementary/issues/4 +[i15]: https://github.com/tamlyn/react-native-elementary/issues/15 + ## Installation ```sh diff --git a/ios/Elementary.mm b/ios/Elementary.mm index 78e4b5e..2b94f68 100644 --- a/ios/Elementary.mm +++ b/ios/Elementary.mm @@ -456,17 +456,21 @@ - (void)disableAudioSessionManagement RCT_EXPORT_METHOD(getAudioInfo:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - if (![self initializeAudioEngineIfNeeded]) { - reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); - return; - } + // AVAudioEngine.outputNode must be accessed on the main thread to avoid + // an RPC timeout in AURemoteIO::Cleanup when called from a background queue. + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self initializeAudioEngineIfNeeded]) { + reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); + return; + } - AVAudioFormat *format = [self.audioEngine.outputNode outputFormatForBus:0]; - resolve(@{ - @"channels": @(format.channelCount), - @"sampleRate": @(format.sampleRate), - @"engineRunning": @(self.audioEngine.isRunning), - @"runtimeReady": @(self.runtime != nullptr), + AVAudioFormat *format = [self.audioEngine.outputNode outputFormatForBus:0]; + resolve(@{ + @"channels": @(format.channelCount), + @"sampleRate": @(format.sampleRate), + @"engineRunning": @(self.audioEngine.isRunning), + @"runtimeReady": @(self.runtime != nullptr), + }); }); } @@ -521,13 +525,17 @@ - (void)getSampleRate:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) #endif { - if (![self initializeAudioEngineIfNeeded]) { - reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); - return; - } + // AVAudioEngine.outputNode must be accessed on the main thread to avoid + // an RPC timeout in AURemoteIO::Cleanup when called from a background queue. + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self initializeAudioEngineIfNeeded]) { + reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); + return; + } - NSNumber *sampleRate = @([self.audioEngine.outputNode outputFormatForBus:0].sampleRate); - resolve(sampleRate); + NSNumber *sampleRate = @([self.audioEngine.outputNode outputFormatForBus:0].sampleRate); + resolve(sampleRate); + }); } #ifdef RCT_NEW_ARCH_ENABLED @@ -542,63 +550,68 @@ - (void)loadAudioResource:(NSString *)key rejecter:(RCTPromiseRejectBlock)reject) #endif { - if (![self initializeAudioEngineIfNeeded]) { - reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); - return; - } - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - if (self.runtime == nullptr) { - reject(@"E_RUNTIME_NOT_INITIALIZED", @"Audio runtime not initialized", nil); + // AVAudioEngine.outputNode must be accessed on the main thread to avoid + // an RPC timeout in AURemoteIO::Cleanup when called from a background queue. + // Perform engine initialization on main thread before dispatching heavy work. + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self initializeAudioEngineIfNeeded]) { + reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); return; } - std::string keyStr = [key UTF8String]; - std::string filePathStr = [filePath UTF8String]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + if (self.runtime == nullptr) { + reject(@"E_RUNTIME_NOT_INITIALIZED", @"Audio runtime not initialized", nil); + return; + } - elementary::AudioLoadResult result = elementary::AudioResourceLoader::loadFile(keyStr, filePathStr); + std::string keyStr = [key UTF8String]; + std::string filePathStr = [filePath UTF8String]; - if (!result.success) { - reject(@"E_LOAD_FAILED", [NSString stringWithUTF8String:result.error.c_str()], nil); - return; - } + elementary::AudioLoadResult result = elementary::AudioResourceLoader::loadFile(keyStr, filePathStr); - 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); - } + if (!result.success) { + reject(@"E_LOAD_FAILED", [NSString stringWithUTF8String:result.error.c_str()], nil); + return; + } - auto resource = std::make_unique( - channelPtrs.data(), - numChannels, - numSamples - ); - bool added; - { - std::lock_guard lock(self->_runtimeMutex); - added = self.runtime->addSharedResource(keyStr, std::move(resource)); - } + 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); + } - if (!added) { - reject(@"E_KEY_EXISTS", [NSString stringWithFormat:@"Resource with key '%@' already exists", key], nil); - return; - } + auto resource = std::make_unique( + channelPtrs.data(), + numChannels, + numSamples + ); + bool added; + { + std::lock_guard lock(self->_runtimeMutex); + added = self.runtime->addSharedResource(keyStr, std::move(resource)); + } - @synchronized(self.loadedResources) { - [self.loadedResources addObject:key]; - } + if (!added) { + reject(@"E_KEY_EXISTS", [NSString stringWithFormat:@"Resource with key '%@' already exists", key], nil); + return; + } - NSDictionary *info = @{ - @"key": key, - @"channels": @(result.info.channels), - @"sampleCount": @(result.info.sampleCount), - @"sampleRate": @(result.info.sampleRate), - @"durationMs": @(result.info.durationMs) - }; + @synchronized(self.loadedResources) { + [self.loadedResources addObject:key]; + } - resolve(info); + NSDictionary *info = @{ + @"key": key, + @"channels": @(result.info.channels), + @"sampleCount": @(result.info.sampleCount), + @"sampleRate": @(result.info.sampleRate), + @"durationMs": @(result.info.durationMs) + }; + + resolve(info); + }); }); } @@ -612,24 +625,28 @@ - (void)unloadAudioResource:(NSString *)key rejecter:(RCTPromiseRejectBlock)reject) #endif { - if (![self initializeAudioEngineIfNeeded] || self.runtime == nullptr) { - reject(@"E_RUNTIME_NOT_INITIALIZED", @"Audio runtime not initialized", nil); - return; - } + // AVAudioEngine.outputNode must be accessed on the main thread to avoid + // an RPC timeout in AURemoteIO::Cleanup when called from a background queue. + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self initializeAudioEngineIfNeeded] || self.runtime == nullptr) { + reject(@"E_RUNTIME_NOT_INITIALIZED", @"Audio runtime not initialized", nil); + return; + } - BOOL found = NO; - @synchronized(self.loadedResources) { - if ([self.loadedResources containsObject:key]) { - [self.loadedResources removeObject:key]; - found = YES; + BOOL found = NO; + @synchronized(self.loadedResources) { + if ([self.loadedResources containsObject:key]) { + [self.loadedResources removeObject:key]; + found = YES; + } } - } - if (found) { - self.runtime->pruneSharedResources(); - } + if (found) { + self.runtime->pruneSharedResources(); + } - resolve(@(found)); + resolve(@(found)); + }); } #ifdef RCT_NEW_ARCH_ENABLED