diff --git a/apps/common-app/src/examples/AudioTag/AudioTag.tsx b/apps/common-app/src/examples/AudioTag/AudioTag.tsx index cea8d89df..495f232fb 100644 --- a/apps/common-app/src/examples/AudioTag/AudioTag.tsx +++ b/apps/common-app/src/examples/AudioTag/AudioTag.tsx @@ -9,7 +9,8 @@ import { AudioContext, BiquadFilterNode, MediaElementAudioSourceNode } from 'rea import { Button, Container, Slider, Spacer } from '../../components'; // const DEMO_AUDIO_URL = 'https://filesamples.com/samples/audio/m4a/sample4.m4a'; -const DEMO_AUDIO_URL = 'https://filesamples.com/samples/audio/mp3/sample4.mp3'; +const DEMO_AUDIO_URL = 'https://liveradio.timesa.pl/2980-1.aac/playlist.m3u8'; +// const DEMO_AUDIO_URL = 'https://filesamples.com/samples/audio/mp3/sample4.mp3'; const AudioTag: React.FC = () => { const { width: screenWidth } = useWindowDimensions(); diff --git a/apps/common-app/src/examples/Streaming/Streaming.tsx b/apps/common-app/src/examples/Streaming/Streaming.tsx index ab0393004..97c8de173 100644 --- a/apps/common-app/src/examples/Streaming/Streaming.tsx +++ b/apps/common-app/src/examples/Streaming/Streaming.tsx @@ -30,7 +30,9 @@ const Streaming: FC = () => { console.error('StreamerNode is already initialized'); return; } - streamerRef.current = aCtxRef.current.createStreamer('https://liveradio.timesa.pl/2980-1.aac/playlist.m3u8'); + streamerRef.current = aCtxRef.current.createStreamer( + 'https://liveradio.timesa.pl/2980-1.aac/playlist.m3u8' + ); streamerRef.current.connect(gainRef.current); gainRef.current.connect(aCtxRef.current.destination); diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index 74a47ec25..45288eaca 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2476,7 +2476,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b - hermes-engine: 91023181d4bc5948b457de5314623fbfe4f8604e + hermes-engine: 146211e12d60a1951d9eb0287be07211e86cf5d5 RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64 RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a @@ -2485,7 +2485,7 @@ SPEC CHECKSUMS: React: 1b1536b9099195944034e65b1830f463caaa8390 React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5 React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d - React-Core-prebuilt: a6d614de037caff7898424dfc22915ec792de921 + React-Core-prebuilt: ef40616103ee11f8c2517697c3aa4f48ce790549 React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6 React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74 @@ -2549,7 +2549,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2 ReactCodegen: d07ee3c8db75b43d1cbe479ae6affebf9925c733 ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360 - ReactNativeDependencies: 4d5ce2683b6d74f7c686bf90a88c7d381295cf3c + ReactNativeDependencies: 54189f1570b1308686cb21564e755e1daa77ea03 RNAudioAPI: 6668f71bdd9850005984acf39a3daef4935cec02 RNGestureHandler: 187c5c7936abf427bc4d22d6c3b1ac80ad1f63c0 RNReanimated: 64f4b3b33b48b19e0ba76a352571b52b1e931981 diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioFileSourceNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioFileSourceNodeHostObject.cpp index da00fb75b..4b32c20b6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioFileSourceNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioFileSourceNodeHostObject.cpp @@ -1,11 +1,11 @@ #include +#include #include #include #include #include #include -#include "audioapi/HostObjects/sources/AudioScheduledSourceNodeHostObject.h" namespace audioapi { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h index 0d83725a6..2f0f0df77 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -297,7 +298,7 @@ inline AudioFileSourceOptions parseAudioFileSourceOptions( auto loopValue = optionsObject.getProperty(runtime, "loop"); if (loopValue.isBool()) { - options.loop = static_cast(loopValue.getBool()); + options.loop = loopValue.getBool(); } auto volumeValue = optionsObject.getProperty(runtime, "volume"); @@ -309,7 +310,7 @@ inline AudioFileSourceOptions parseAudioFileSourceOptions( if (sourceValue.isString()) { options.filePath = sourceValue.asString(runtime).utf8(runtime); options.requiresFFmpeg = - audiodecoding::pathHasExtension(options.filePath, {".mp4", ".m4a", ".aac"}); + audiodecoding::pathHasExtension(options.filePath, {".mp4", ".m4a", ".aac", ".m3u8"}); } else if (sourceValue.isObject()) { auto sourceObj = sourceValue.asObject(runtime); if (sourceObj.isArrayBuffer(runtime)) { @@ -317,8 +318,8 @@ inline AudioFileSourceOptions parseAudioFileSourceOptions( auto *data = arrayBuffer.data(runtime); auto size = arrayBuffer.size(runtime); auto format = audiodecoding::detectAudioFormat(data, size); - options.requiresFFmpeg = - format == AudioFormat::MP4 || format == AudioFormat::M4A || format == AudioFormat::AAC; + options.requiresFFmpeg = format == AudioFormat::MP4 || format == AudioFormat::M4A || + format == AudioFormat::AAC || format == AudioFormat::M3U8; options.data = std::vector(data, data + size); } } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp index 5962765f7..98c5d974c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp @@ -9,9 +9,14 @@ #include +#include +#include #include +#include #include +#include #include +#include #if !RN_AUDIO_API_FFMPEG_DISABLED #include @@ -23,35 +28,52 @@ AudioFileSourceNode::AudioFileSourceNode( const std::shared_ptr &context, const AudioFileSourceOptions &options) : AudioScheduledSourceNode(context, options), + decoderState_(std::make_shared()), volume_(options.volume), - requiresFFmpeg_(options.requiresFFmpeg), loop_(options.loop), onPositionChangedInterval_( static_cast(context->getSampleRate() * ON_POSITION_CHANGED_INTERVAL)) { const bool useFilePath = !options.filePath.empty(); const bool useData = !options.data.empty(); - if (useFilePath || useData) { - auto state = std::make_shared(); - if (useData) { - state->memoryData = options.data; - } - if (useFilePath) { - state->filePath = options.filePath; - } - initDecoders(useFilePath, context, state); - } + // TODO: possibly check for file format, if hls and ffmpeg is not available, then fail to initialize + // ->> add to options parsing validation - if (decoderState_ == nullptr) { - assert(false && "cannot initialize decoder"); + if (!useFilePath && !useData) { + assert(false && "AudioFileSourceNode requires either a file path or memory data to initialize"); return; } - seekOffloader_ = std::make_unique>( - SEEK_OFFLOADER_WORKER_COUNT, [this](OffloadedSeekRequest req) { runOffloadedSeekTask(req); }); + // TODO: possibly move to an initialization function, and handle failure more gracefully + auto [frameSender, frameReceiver] = + channels::spsc::channel( + FRAME_SPSC_CHANNEL_CAPACITY); + frameReceiver_ = std::move(frameReceiver); + + auto [commandSender, commandReceiver] = channels::spsc:: + channel( + COMMAND_SPSC_CHANNEL_CAPACITY); + commandSender_ = std::move(commandSender); + + SeekDecoderDaemonOptions daemonOptions{ + .requiresFFmpeg = options.requiresFFmpeg, + .filePath = options.filePath, + .memoryData = options.data, + .contextSampleRate = context->getSampleRate(), + .loop = options.loop}; + + seekDecoderDaemon_ = std::make_unique( + daemonOptions, decoderState_, std::move(commandReceiver), std::move(frameSender)); + + // TODO: check if all this is needed or some may be accessed directly from the daemon thread + channelCount_ = decoderState_->channelCount; + sampleRate_ = decoderState_->sampleRate; + duration_ = decoderState_->duration; + + isInitialized_.store(true, std::memory_order_release); + + audioBuffer_ = std::make_shared( + static_cast(RENDER_QUANTUM_SIZE), channelCount_, context->getSampleRate()); isInitialized_.store(true, std::memory_order_release); } @@ -67,7 +89,7 @@ void AudioFileSourceNode::unregisterOnPositionChangedCallback(uint64_t callbackI void AudioFileSourceNode::sendOnPositionChangedEvent(int framesPlayed) { currentTime_.fetch_add(framesPlayed / sampleRate_); if (onPositionChangedCallbackId_ != 0 && - (onPositionChangedFlush_.load(std::memory_order_acquire) || + (decoderState_->onPositionChangedFlush.load(std::memory_order_acquire) || onPositionChangedTime_ > onPositionChangedInterval_)) { audioEventHandlerRegistry_->dispatchEvent( AudioEvent::POSITION_CHANGED, @@ -75,47 +97,12 @@ void AudioFileSourceNode::sendOnPositionChangedEvent(int framesPlayed) { DoubleValuePayload{.value = getCurrentTime()}); onPositionChangedTime_ = 0; - onPositionChangedFlush_.store(false, std::memory_order_release); + decoderState_->onPositionChangedFlush.store(false, std::memory_order_release); } onPositionChangedTime_ += framesPlayed; } -void AudioFileSourceNode::initDecoders( - bool useFilePath, - const std::shared_ptr &context, - const std::shared_ptr &state) { - decoding::DecoderResult openResult = Ok(None); - if (requiresFFmpeg_) { -#if !RN_AUDIO_API_FFMPEG_DISABLED - decoder_ = std::make_unique(); -#endif // RN_AUDIO_API_FFMPEG_DISABLED - } else { - decoder_ = std::make_unique(); - } - if (useFilePath) { - openResult = decoder_->openFile(static_cast(context->getSampleRate()), state->filePath); - } else { - openResult = decoder_->openMemory( - static_cast(context->getSampleRate()), - state->memoryData.data(), - state->memoryData.size()); - } - if (openResult.is_ok()) { - state->channels = decoder_->outputChannels(); - state->sampleRate = static_cast(decoder_->outputSampleRate()); - duration_ = static_cast(decoder_->getDurationInSeconds()); - } else { - decoder_->close(); - } - state->interleavedBuffer.resize(static_cast(RENDER_QUANTUM_SIZE) * state->channels); - decoderState_ = state; - channelCount_ = decoderState_->channels; - sampleRate_ = decoderState_->sampleRate; - audioBuffer_ = std::make_shared( - static_cast(RENDER_QUANTUM_SIZE), channelCount_, context->getSampleRate()); -} - void AudioFileSourceNode::connect(const std::shared_ptr &node) { if (isRoutedThroughMediaElement()) { return; @@ -133,6 +120,11 @@ void AudioFileSourceNode::start(double when) { AudioScheduledSourceNode::start(when); filePaused_ = false; + + if (seekDecoderDaemon_) { + seekDecoderThread_ = std::thread(std::move(*seekDecoderDaemon_)); + seekDecoderDaemon_.reset(); + } } void AudioFileSourceNode::bindMediaElementSource(uint64_t bindingId) { @@ -172,35 +164,20 @@ void AudioFileSourceNode::pause() { } void AudioFileSourceNode::disable() { - seekOffloader_.reset(); - filePaused_ = false; - if (decoder_ != nullptr) { - decoder_->close(); + decoderState_->isDaemonRunning.store(false, std::memory_order_release); + commandSender_.send( + SeekRequest{0}); // send a dummy command to unblock the daemon thread if it's waiting + if (seekDecoderThread_.joinable()) { + seekDecoderThread_.join(); } - AudioScheduledSourceNode::disable(); -} + filePaused_ = false; -size_t AudioFileSourceNode::readFrames(float *buf, size_t frameCount) { - if (pendingOffloadedSeeks_.load(std::memory_order_acquire) > 0) { - return 0; - } - return decoder_->readPcmFrames(buf, frameCount); + AudioScheduledSourceNode::disable(); } void AudioFileSourceNode::applyPlaybackStateAfterSuccessfulSeek(double seconds) { currentTime_.store(seconds, std::memory_order_release); - onPositionChangedFlush_.store(true, std::memory_order_release); -} - -void AudioFileSourceNode::runOffloadedSeekTask(OffloadedSeekRequest req) { - if (decoderState_ == nullptr) { - pendingOffloadedSeeks_.fetch_sub(1, std::memory_order_acq_rel); - return; - } - if (decoder_->seekToTime(req.seconds).is_ok()) { - applyPlaybackStateAfterSuccessfulSeek(req.seconds); - } - pendingOffloadedSeeks_.fetch_sub(1, std::memory_order_acq_rel); + decoderState_->onPositionChangedFlush.store(true, std::memory_order_release); } void AudioFileSourceNode::seekToTime(double seconds) { @@ -213,37 +190,40 @@ void AudioFileSourceNode::seekToTime(double seconds) { } else { seconds = std::max(0.0, seconds); } - pendingOffloadedSeeks_.fetch_add(1, std::memory_order_acq_rel); - seekOffloader_->getSender()->send(OffloadedSeekRequest{seconds}); + decoderState_->pendingOffloadedSeeks.fetch_add(1, std::memory_order_acq_rel); + commandSender_.send(SeekRequest{seconds}); } -size_t AudioFileSourceNode::handleEof( - const std::shared_ptr &processingBuffer, - size_t regionFrames, - size_t framesRead, - size_t destFrameOffset) { - if (!loop_) { - return framesRead; - } +// TODO: `DecoderData` may be initialized once and then reused as size is stable (128 samples, the render quantum size), +// to avoid dynamic memory allocations on the audio thread. +size_t AudioFileSourceNode::readInterleavedFrames( + const std::shared_ptr &destBuffer, + size_t framesToRead) { - if (!decoder_->seekToTime(0).is_ok()) { - return framesRead; - } + // TODO: if the decoder daemon shares frameReceiver_ directly, it can flush the queue when a seek happens, + // seekFlag will guard the audio thread from reading stale data during an active seek - const size_t toFill = regionFrames - framesRead; - if (toFill == 0) { - return framesRead; + // If a seek is active, continuously drain the pipe and return silence + if (decoderState_->pendingOffloadedSeeks.load(std::memory_order_acquire) > 0) { + DecoderData drop; + while (frameReceiver_.try_receive(drop) == ResponseStatus::SUCCESS) {} + return 0; } - auto &state = *decoderState_; - const size_t extra = readFrames(state.interleavedBuffer.data(), toFill); + // Read from the decoder daemon thread via the SPSC channel. + DecoderData incoming; + if (frameReceiver_.try_receive(incoming) == ResponseStatus::SUCCESS) { + size_t framesToCopy = std::min(framesToRead, incoming.size); + destBuffer->deinterleaveFrom(incoming.interleavedBuffer.data(), framesToCopy); + + if (volume_ != 1.0f && framesToCopy > 0) { + destBuffer->scale(volume_); + } - if (volume_ != 0.0f) { - processingBuffer->deinterleaveFrom(state.interleavedBuffer.data(), extra); - processingBuffer->scale(volume_); + return framesToCopy; } - return framesRead + extra; + return 0; } std::shared_ptr AudioFileSourceNode::processNode( @@ -259,7 +239,7 @@ std::shared_ptr AudioFileSourceNode::processNode( std::shared_ptr AudioFileSourceNode::processDecodedOutput( const std::shared_ptr &processingBuffer, int framesToProcess) { - if (decoderState_ == nullptr || decoder_ == nullptr || !decoder_->isOpen() || filePaused_) { + if (decoderState_ == nullptr || filePaused_) { processingBuffer->zero(); return processingBuffer; } @@ -270,11 +250,16 @@ std::shared_ptr AudioFileSourceNode::processDecodedOutput( return processingBuffer; } - if (pendingOffloadedSeeks_.load(std::memory_order_acquire) > 0) { + // TODO: either handle seek here on in reading frames, no need to duplicate the logic, + // remove pause as it is already handled above + + // Handle running sync gate blocks or user pause interactions instantly + if (decoderState_->pendingOffloadedSeeks.load(std::memory_order_acquire) > 0 || filePaused_) { processingBuffer->zero(); return processingBuffer; } + // Web Audio Timeline calculations size_t startOffset = 0; size_t offsetLength = 0; updatePlaybackInfo( @@ -290,31 +275,32 @@ std::shared_ptr AudioFileSourceNode::processDecodedOutput( return processingBuffer; } - auto &state = *decoderState_; + // Zero out optional leading sub-quantum scheduling padding space + if (startOffset > 0) { + processingBuffer->zero(0, startOffset); + } - size_t framesRead = readFrames(state.interleavedBuffer.data(), offsetLength); + // Consume, deinterleave, and copy chunks safely across the thread line + size_t framesRead = readInterleavedFrames(processingBuffer, offsetLength); sendOnPositionChangedEvent(static_cast(framesRead)); - if (volume_ != 0.0f && framesRead > 0) { - processingBuffer->deinterleaveFrom(state.interleavedBuffer.data(), framesRead); - processingBuffer->scale(volume_); - } + // TODO: make eof/eos more explicit + // Handle End of Stream / End of File boundaries if (framesRead < offsetLength) { - if (!loop_) { + if (decoderState_->isEof.load(std::memory_order_acquire) && + !decoderState_->loop.load(std::memory_order_acquire)) { + currentTime_.store(duration_, std::memory_order_release); - onPositionChangedFlush_.store(true, std::memory_order_release); + decoderState_->onPositionChangedFlush.store(true, std::memory_order_release); sendOnPositionChangedEvent(static_cast(offsetLength - framesRead)); + filePaused_ = true; playbackState_ = PlaybackState::STOP_SCHEDULED; - processingBuffer->zero(startOffset + framesRead, offsetLength - framesRead); - } else { - const size_t totalFilled = handleEof(processingBuffer, offsetLength, framesRead, startOffset); - onPositionChangedFlush_.store(true, std::memory_order_release); - currentTime_.store(0, std::memory_order_release); - sendOnPositionChangedEvent(static_cast(totalFilled)); - processingBuffer->zero(startOffset + totalFilled, offsetLength - totalFilled); } + + // Isolate hardware drivers completely from trailing garbage stack noise + processingBuffer->zero(startOffset + framesRead, offsetLength - framesRead); } if (isStopScheduled()) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.h index e2cf2a622..c776813f6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.h @@ -2,18 +2,18 @@ #include #include +#include #include +#include +#include #if !RN_AUDIO_API_FFMPEG_DISABLED #include #endif // RN_AUDIO_API_FFMPEG_DISABLED #include -#include #include #include #include -#include -#include using namespace audioapi::channels; @@ -22,24 +22,24 @@ namespace audioapi { struct AudioFileSourceOptions; class MediaElementAudioSourceNode; -/// @brief Target time for an offloaded seek task. -struct OffloadedSeekRequest { - double seconds = 0; - OffloadedSeekRequest() = default; - explicit OffloadedSeekRequest(double t) : seconds(t) {} -}; +inline constexpr auto FRAME_SPSC_OVERFLOW_STRATEGY = + audioapi::channels::spsc::OverflowStrategy::WAIT_ON_FULL; +inline constexpr auto FRAME_SPSC_WAIT_STRATEGY = + audioapi::channels::spsc::WaitStrategy::ATOMIC_WAIT; +inline constexpr auto FRAME_SPSC_CHANNEL_CAPACITY = 64; -/// @brief Decoder input and scratch buffer shared by the file source and media-element routing. -struct AudioFileDecoderState { - std::vector memoryData; - std::string filePath; - std::vector interleavedBuffer; - int channels = 0; - float sampleRate = 0; -}; +inline constexpr auto COMMAND_SPSC_OVERFLOW_STRATEGY = + audioapi::channels::spsc::OverflowStrategy::OVERWRITE_ON_FULL; +inline constexpr auto COMMAND_SPSC_WAIT_STRATEGY = + audioapi::channels::spsc::WaitStrategy::ATOMIC_WAIT; +inline constexpr auto COMMAND_SPSC_CHANNEL_CAPACITY = 16; + +inline constexpr auto ON_POSITION_CHANGED_INTERVAL = 0.25f; /// @brief Decodes a file or in-memory buffer and plays it as a scheduled source. /// @note When routed through MediaElementAudioSourceNode, this node outputs silence and the media node pulls decoded audio. +/// @note Seek commands are executed from the JS thread and delegated to @ref SeekDecoderDaemon, which performs the seek and decoding on a worker thread, +// then sends decoded frames back to the audio thread via SPSC channels. class AudioFileSourceNode : public AudioScheduledSourceNode { friend class MediaElementAudioSourceNode; @@ -118,45 +118,20 @@ class AudioFileSourceNode : public AudioScheduledSourceNode { int framesToProcess) override; private: + std::shared_ptr decoderState_; /// @brief Decodes and mixes samples for direct or media-element playback. /// @note Audio thread only. std::shared_ptr processDecodedOutput( const std::shared_ptr &processingBuffer, int framesToProcess); - /// @brief Opens the decoder from file path or memory and prepares scratch buffers. - void initDecoders( - bool useFilePath, - const std::shared_ptr &context, - const std::shared_ptr &state); - - std::shared_ptr decoderState_; - std::unique_ptr decoder_; - float volume_; - bool requiresFFmpeg_; bool filePaused_{false}; bool loop_{false}; double duration_{0}; double sampleRate_{0}; - - std::atomic activeMediaBindingId_{0}; std::atomic currentTime_{0}; - - static constexpr double ON_POSITION_CHANGED_INTERVAL = 0.25f; - static constexpr int SEEK_OFFLOADER_WORKER_COUNT = 16; - - /// @brief Reads up to @p frameCount interleaved PCM frames into @p buf. - /// @note Audio thread only. - size_t readFrames(float *buf, size_t frameCount); - - /// @brief Fills the remainder of a region after EOF, seeking to start when looping. - /// @note Audio thread only. - size_t handleEof( - const std::shared_ptr &processingBuffer, - size_t regionFrames, - size_t framesRead, - size_t destFrameOffset); + std::atomic activeMediaBindingId_{0}; /// @brief Dispatches position-changed events at the configured interval. /// @note Audio thread only. @@ -165,8 +140,14 @@ class AudioFileSourceNode : public AudioScheduledSourceNode { /// @brief Updates playback clock after a successful offloaded seek. void applyPlaybackStateAfterSuccessfulSeek(double seconds); - /// @brief Worker-thread seek implementation. - void runOffloadedSeekTask(OffloadedSeekRequest req); + /// @brief Reads decoded interleaved frames from the SPSC channel, deinterleaves them into @p destBuffer, and applies volume. + [[nodiscard]] size_t readInterleavedFrames( + const std::shared_ptr &destBuffer, + size_t framesToRead); + + /// @brief Daemon thread for decoding and seeking + std::unique_ptr seekDecoderDaemon_; + std::thread seekDecoderThread_; /// @brief Connects to the destination when leaving media routing while playback is active. /// @note Audio thread only. @@ -175,16 +156,14 @@ class AudioFileSourceNode : public AudioScheduledSourceNode { uint64_t onPositionChangedCallbackId_ = 0; int onPositionChangedInterval_; int onPositionChangedTime_ = 0; - std::atomic onPositionChangedFlush_{true}; - /// Pending offloaded seeks; while > 0 the audio thread must not read the decoder (outputs silence). - std::atomic pendingOffloadedSeeks_{0}; + /// @brief SPSC for JS -> Daemon thread communication (seek event) + channels::spsc::Sender + commandSender_; - std::unique_ptr> - seekOffloader_; + /// @brief SPSC for Daemon thread -> Audio thread communication (decoded frames) + channels::spsc::Receiver + frameReceiver_; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioFormat.h b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioFormat.h index 74fd4f9d1..f13a26c8d 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioFormat.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioFormat.h @@ -4,5 +4,5 @@ namespace audioapi { -enum class AudioFormat : uint8_t { UNKNOWN, WAV, OGG, FLAC, AAC, MP3, M4A, MP4, MOV }; +enum class AudioFormat : uint8_t { UNKNOWN, WAV, OGG, FLAC, AAC, MP3, M4A, MP4, MOV, M3U8 }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/decoding/SeekDecoderDaemon.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/decoding/SeekDecoderDaemon.cpp new file mode 100644 index 000000000..1c96dc5af --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/decoding/SeekDecoderDaemon.cpp @@ -0,0 +1,119 @@ +#include +#include +#include +namespace audioapi { + +SeekDecoderDaemon::SeekDecoderDaemon( + SeekDecoderDaemonOptions options, + std::shared_ptr sharedState, + channels::spsc::Receiver< + SeekRequest, + channels::spsc::OverflowStrategy::OVERWRITE_ON_FULL, + channels::spsc::WaitStrategy::ATOMIC_WAIT> commandReceiver, + channels::spsc::Sender< + DecoderData, + channels::spsc::OverflowStrategy::WAIT_ON_FULL, + channels::spsc::WaitStrategy::ATOMIC_WAIT> frameSender) + : sharedState_(std::move(sharedState)), + commandReceiver_(std::move(commandReceiver)), + frameSender_(std::move(frameSender)) { + if (options.requiresFFmpeg) { +#if !RN_AUDIO_API_FFMPEG_DISABLED + decoder_ = std::make_unique(); +#endif + } else { + decoder_ = std::make_unique(); + } + + decoding::DecoderResult openResult = Ok(None); + if (!options.filePath.empty()) { + openResult = decoder_->openFile(options.contextSampleRate, options.filePath); + } else if (!options.memoryData.empty()) { + openResult = decoder_->openMemory( + options.contextSampleRate, options.memoryData.data(), options.memoryData.size()); + } + + if (!openResult.is_ok()) { + decoder_->close(); + sharedState_->isDaemonRunning.store(false, std::memory_order_release); + return; + } + + sharedState_->channelCount.store(decoder_->outputChannels(), std::memory_order_release); + sharedState_->sampleRate.store( + static_cast(decoder_->outputSampleRate()), std::memory_order_release); + sharedState_->duration.store(decoder_->getDurationInSeconds(), std::memory_order_release); + sharedState_->isReady.store(true, std::memory_order_release); +} + +void SeekDecoderDaemon::operator()() { + const size_t chunkSize = RENDER_QUANTUM_SIZE; + const auto chCount = static_cast(sharedState_->channelCount); + + DecoderData localData; + localData.interleavedBuffer.resize(chunkSize * chCount); + bool hasPendingChunk = false; + + while (sharedState_->isDaemonRunning.load(std::memory_order_acquire)) { + SeekRequest seekReq; + bool seekHappened = false; + + while (commandReceiver_.try_receive(seekReq) == ResponseStatus::SUCCESS) { + if (decoder_ && decoder_->isOpen()) { + if (decoder_->seekToTime(seekReq.seconds).is_ok()) { + sharedState_->currentTime.store(seekReq.seconds, std::memory_order_release); + sharedState_->onPositionChangedFlush.store(true, std::memory_order_release); + } + } + seekHappened = true; + } + + if (seekHappened) { + hasPendingChunk = false; // Dump old timeline data + sharedState_->pendingOffloadedSeeks.fetch_sub(1, std::memory_order_release); + continue; + } + + if (!hasPendingChunk) { + if (!decoder_ || !decoder_->isOpen()) { + continue; + } + + size_t framesRead = decoder_->readPcmFrames(localData.interleavedBuffer.data(), chunkSize); + + if (framesRead == 0) { + if (sharedState_->loop.load(std::memory_order_acquire)) { + if (decoder_->seekToTime(0).is_ok()) { + sharedState_->currentTime.store(0.0, std::memory_order_release); + sharedState_->onPositionChangedFlush.store(true, std::memory_order_release); + continue; + } + } else { + sharedState_->isEof.store(true, std::memory_order_release); + continue; + } + } else { + // If we read actual frames, ensure the EOF flag remains false + sharedState_->isEof.store(false, std::memory_order_release); + } + + localData.size = framesRead; + hasPendingChunk = true; + } + + if (frameSender_.try_send(std::move(localData)) == ResponseStatus::SUCCESS) { + hasPendingChunk = false; + localData = DecoderData{}; + localData.interleavedBuffer.resize(chunkSize * chCount); + } else { + // Audio thread queue is packed. Yield current time slice. + std::this_thread::yield(); + } + } + + if (decoder_) { + decoder_->close(); + } +} + +} // namespace audioapi \ No newline at end of file diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/decoding/SeekDecoderDaemon.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/decoding/SeekDecoderDaemon.h new file mode 100644 index 000000000..9b7f0337b --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/decoding/SeekDecoderDaemon.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#if !RN_AUDIO_API_FFMPEG_DISABLED +#include +#endif // RN_AUDIO_API_FFMPEG_DISABLED +#include +#include +#include +#include +#include + +using namespace audioapi::channels::spsc; + +struct SeekDecoderDaemonOptions { + bool requiresFFmpeg; + // source + std::string filePath; + std::vector memoryData; + // playback + float contextSampleRate; + bool loop; +}; + +// TODO: check if all those fields are necessary or even used +struct AudioFileDecoderState { + // Lifecycle and Sync + std::atomic isDaemonRunning{true}; + std::atomic isReady{false}; // True once the decoder opens the file/URL + std::atomic pendingOffloadedSeeks{0}; + std::atomic isEof{false}; + + // Metadata + std::atomic channelCount{0}; + std::atomic sampleRate{0.0f}; + std::atomic duration{0.0}; + + std::atomic onPositionChangedFlush{false}; + + // Playback state + std::atomic currentTime{0.0}; + std::atomic loop{false}; +}; + +struct SeekRequest { + double seconds = 0; + SeekRequest() = default; + explicit SeekRequest(double t) : seconds(t) {} +}; + +struct DecoderData { + std::vector interleavedBuffer; + size_t size{}; +}; + +namespace audioapi { + +using CommandReceiver = + Receiver; +using FrameSender = Sender; + +/// @brief SeekDecoderDaemon is a dedicated thread worker that manages an audio decoder instance (FFmpeg or MiniAudio). +/// It listens for seek commands from the JS thread, performs seeks on the decoder, +/// decodes audio frames, and sends decoded planar audio data back to the audio thread via a lock-free SPSC channel. +class SeekDecoderDaemon { + public: + SeekDecoderDaemon( + SeekDecoderDaemonOptions options, + std::shared_ptr sharedState, + CommandReceiver commandReceiver, + FrameSender frameSender); + + /// @brief Main loop of the daemon thread. Listens for seek commands, + /// decodes audio frames, and sends decoded data back to the audio thread + /// via the frameSender SPSC channel. + void operator()(); + + private: + std::shared_ptr sharedState_; + std::unique_ptr decoder_; + CommandReceiver commandReceiver_; + FrameSender frameSender_; +}; + +} // namespace audioapi \ No newline at end of file diff --git a/packages/react-native-audio-api/common/cpp/audioapi/libs/decoding/IncrementalAudioDecoder.h b/packages/react-native-audio-api/common/cpp/audioapi/libs/decoding/IncrementalAudioDecoder.h index cf4550646..552f0f300 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/libs/decoding/IncrementalAudioDecoder.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/libs/decoding/IncrementalAudioDecoder.h @@ -1,8 +1,9 @@ #pragma once + +#include +#include #include #include -#include -#include namespace audioapi::decoding { using DecoderResult = Result; @@ -21,19 +22,15 @@ class IncrementalAudioDecoder { /// @param outputSampleRate The output sample rate. /// @param path The path to the file. /// @return Ok(None) on success or Err(message) on failure. - [[nodiscard]] virtual DecoderResult openFile( - int outputSampleRate, - const std::string &path) = 0; + [[nodiscard]] virtual DecoderResult openFile(int outputSampleRate, const std::string &path) = 0; /// @brief Opens a memory block for decoding. /// @param outputSampleRate The output sample rate. /// @param data The data to decode. /// @param size The size of the data. /// @return Ok(None) on success or Err(message) on failure. - [[nodiscard]] virtual DecoderResult openMemory( - int outputSampleRate, - const void *data, - size_t size) = 0; + [[nodiscard]] virtual DecoderResult + openMemory(int outputSampleRate, const void *data, size_t size) = 0; /// @brief Reads PCM frames from the decoder. /// @param outInterleaved The output buffer for the decoded frames. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.cpp b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.cpp index e66517043..6a411deb6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.cpp @@ -79,10 +79,8 @@ int findAudioStreamIndex(AVFormatContext *fmt_ctx) { return -1; } -decoding::DecoderResult openCodec( - AVFormatContext *fmt_ctx, - int &audio_stream_index, - AVCodecContext **out_codec) { +decoding::DecoderResult +openCodec(AVFormatContext *fmt_ctx, int &audio_stream_index, AVCodecContext **out_codec) { audio_stream_index = findAudioStreamIndex(fmt_ctx); if (audio_stream_index < 0) { return Err("FFmpegDecoder::openCodec failed: no audio stream found"); @@ -106,7 +104,8 @@ decoding::DecoderResult openCodec( const int openResult = avcodec_open2(ctx, codec, nullptr); if (openResult < 0) { avcodec_free_context(&ctx); - return Err("FFmpegDecoder::openCodec failed: avcodec_open2 failed: " + parseFFmpegError(openResult)); + return Err( + "FFmpegDecoder::openCodec failed: avcodec_open2 failed: " + parseFFmpegError(openResult)); } *out_codec = ctx; return Ok(None); @@ -214,8 +213,7 @@ decoding::DecoderResult FFmpegDecoder::openFile(int outputSampleRate, const std: return codecResult; } output_channels_ = codec_ctx_->ch_layout.nb_channels; - output_sample_rate_ = - (outputSampleRate > 0) ? outputSampleRate : codec_ctx_->sample_rate; + output_sample_rate_ = (outputSampleRate > 0) ? outputSampleRate : codec_ctx_->sample_rate; packet_ = av_packet_alloc(); frame_ = av_frame_alloc(); @@ -236,10 +234,8 @@ decoding::DecoderResult FFmpegDecoder::openFile(int outputSampleRate, const std: return Ok(None); } -decoding::DecoderResult FFmpegDecoder::openMemory( - int outputSampleRate, - const void *data, - size_t size) { +decoding::DecoderResult +FFmpegDecoder::openMemory(int outputSampleRate, const void *data, size_t size) { close(); if (data == nullptr || size == 0) { return Err("FFmpegDecoder::openMemory failed: input data is empty"); @@ -249,20 +245,19 @@ decoding::DecoderResult FFmpegDecoder::openMemory( mem_io_->size = size; mem_io_->pos = 0; - auto* io_buf = - static_cast(av_malloc(decoding::IncrementalAudioDecoder::CHUNK_SIZE)); + auto *io_buf = static_cast(av_malloc(decoding::IncrementalAudioDecoder::CHUNK_SIZE)); if (io_buf == nullptr) { close(); return Err("FFmpegDecoder::openMemory failed: av_malloc returned null"); } avio_ctx_ = avio_alloc_context( - io_buf, - static_cast(decoding::IncrementalAudioDecoder::CHUNK_SIZE), - 0, - mem_io_.get(), - read_packet, - nullptr, - seek_packet); + io_buf, + static_cast(decoding::IncrementalAudioDecoder::CHUNK_SIZE), + 0, + mem_io_.get(), + read_packet, + nullptr, + seek_packet); if (avio_ctx_ == nullptr) { av_free(io_buf); mem_io_.reset(); @@ -296,8 +291,7 @@ decoding::DecoderResult FFmpegDecoder::openMemory( return codecResult; } output_channels_ = codec_ctx_->ch_layout.nb_channels; - output_sample_rate_ = - (outputSampleRate > 0) ? outputSampleRate : codec_ctx_->sample_rate; + output_sample_rate_ = (outputSampleRate > 0) ? outputSampleRate : codec_ctx_->sample_rate; packet_ = av_packet_alloc(); frame_ = av_frame_alloc(); @@ -388,8 +382,7 @@ decoding::DecoderResult FFmpegDecoder::feedPipeline() { av_packet_unref(packet_); if (r < 0) { return Err( - "FFmpegDecoder::feedPipeline failed: avcodec_send_packet failed: " + - parseFFmpegError(r)); + "FFmpegDecoder::feedPipeline failed: avcodec_send_packet failed: " + parseFFmpegError(r)); } } } @@ -403,7 +396,9 @@ float FFmpegDecoder::getDurationInSeconds() const { return 0; } - auto validSeconds = [](double s) -> bool { return s > 0 && std::isfinite(s); }; + auto validSeconds = [](double s) -> bool { + return s > 0 && std::isfinite(s); + }; // Prefer per-stream duration (e.g. MP4 mdhd) — often exact vs container-level // guesses that trigger AAC “bitrate duration” warnings. @@ -457,8 +452,8 @@ decoding::DecoderResult FFmpegDecoder::seekToTime(double seconds) { avcodec_flush_buffers(codec_ctx_); leftover_.clear(); leftover_offset_ = 0; - total_output_frames_ = static_cast( - std::llround(seconds * static_cast(output_sample_rate_))); + total_output_frames_ = + static_cast(std::llround(seconds * static_cast(output_sample_rate_))); return Ok(None); } @@ -471,9 +466,8 @@ size_t FFmpegDecoder::readPcmFrames(float *outInterleaved, size_t frameCount) { while (delivered < frameCount) { size_t need = frameCount - delivered; - size_t available_samples = leftover_.size() > leftover_offset_ - ? leftover_.size() - leftover_offset_ - : 0; + size_t available_samples = + leftover_.size() > leftover_offset_ ? leftover_.size() - leftover_offset_ : 0; size_t leftover_frames = available_samples / ch; if (leftover_frames > 0) { size_t take = std::min(need, leftover_frames); @@ -496,10 +490,8 @@ size_t FFmpegDecoder::readPcmFrames(float *outInterleaved, size_t frameCount) { return delivered; } -static std::shared_ptr buildAudioBufferFromInterleaved( - std::vector &interleaved, - int channels, - int sample_rate) { +static std::shared_ptr +buildAudioBufferFromInterleaved(std::vector &interleaved, int channels, int sample_rate) { if (interleaved.empty() || channels <= 0) { return nullptr; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h index 4afbc4a83..e42797cde 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/FFmpegDecoding.h @@ -46,14 +46,11 @@ class FFmpegDecoder : public decoding::IncrementalAudioDecoder { ~FFmpegDecoder() override; DELETE_COPY_AND_MOVE(FFmpegDecoder); - [[nodiscard]] decoding::DecoderResult openFile( - int outputSampleRate, - const std::string &path) override; + [[nodiscard]] decoding::DecoderResult openFile(int outputSampleRate, const std::string &path) + override; - [[nodiscard]] decoding::DecoderResult openMemory( - int outputSampleRate, - const void *data, - size_t size) override; + [[nodiscard]] decoding::DecoderResult + openMemory(int outputSampleRate, const void *data, size_t size) override; [[nodiscard]] size_t readPcmFrames(float *outInterleaved, size_t frameCount) override; @@ -62,8 +59,12 @@ class FFmpegDecoder : public decoding::IncrementalAudioDecoder { [[nodiscard]] bool isOpen() const override { return fmt_ctx_ != nullptr && codec_ctx_ != nullptr; } - [[nodiscard]] int outputChannels() const override { return output_channels_; } - [[nodiscard]] int outputSampleRate() const override { return output_sample_rate_; } + [[nodiscard]] int outputChannels() const override { + return output_channels_; + } + [[nodiscard]] int outputSampleRate() const override { + return output_sample_rate_; + } [[nodiscard]] float getDurationInSeconds() const override; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/BoundedPriorityQueue.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/BoundedPriorityQueue.hpp index 63125bd9e..95412e5fe 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/BoundedPriorityQueue.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/BoundedPriorityQueue.hpp @@ -13,7 +13,7 @@ namespace audioapi { /// @brief A bounded priority queue with fixed capacity backed by a static pool allocator. /// Elements are kept in ascending sorted order (smallest element at front). /// All operations avoid heap allocation. Uses std::multiset under the hood -// to maintain sorted order and provide efficient insertion and removal. +/// to maintain sorted order and provide efficient insertion and removal. /// @tparam T The type of elements stored. Must be move-constructible. /// @tparam Capacity The maximum number of elements. /// @tparam Compare Comparator type. Defaults to std::less (smallest element at front). diff --git a/packages/react-native-audio-api/src/development/react/Audio/useAudioSourceLoader.ts b/packages/react-native-audio-api/src/development/react/Audio/useAudioSourceLoader.ts index 086ad67ef..777eb8cd0 100644 --- a/packages/react-native-audio-api/src/development/react/Audio/useAudioSourceLoader.ts +++ b/packages/react-native-audio-api/src/development/react/Audio/useAudioSourceLoader.ts @@ -135,7 +135,7 @@ export function useAudioSourceLoader({ const headers = getSourceHeaders(source); try { - if (path.startsWith('http')) { + if (path.startsWith('http') && !path.endsWith('.m3u8')) { const arrayBuffer = await fetch(path, { headers }).then((response) => response.arrayBuffer() );