Skip to content
Merged
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
16 changes: 15 additions & 1 deletion apps/common-app/src/examples/AudioTag/AudioTag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ const AudioTag: React.FC = () => {
const handleVolumeEvent = useCallback((volume: number) => {
// console.log('onVolumeChange', volume);
}, []);
const handleWaiting = useCallback(() => {
// console.log('onWaiting');
}, []);
const handlePlaying = useCallback(() => {
// console.log('onPlaying');
}, []);

const audioTagElement = useMemo(
() => (
Expand All @@ -113,6 +119,8 @@ const AudioTag: React.FC = () => {
onPlay={handlePlay}
onPause={handlePause}
onVolumeChange={handleVolumeEvent}
onWaiting={handleWaiting}
onPlaying={handlePlaying}
/>
),
[
Expand All @@ -122,8 +130,10 @@ const AudioTag: React.FC = () => {
handleLoadStart,
handlePause,
handlePlay,
handlePlaying,
handlePositionChange,
handleVolumeEvent,
handleWaiting,
]
);

Expand Down Expand Up @@ -156,7 +166,11 @@ const AudioTag: React.FC = () => {
</View>
<Spacer.Vertical size={12} />
<Button
title={!mediaElementRoute ? 'Route via MediaElement node' : 'Route without MediaElement node'}
title={
!mediaElementRoute
? 'Route via MediaElement node'
: 'Route without MediaElement node'
}
onPress={handleMediaElementRouteChange}
width={screenWidth * 0.8}
/>
Expand Down
17 changes: 17 additions & 0 deletions packages/audiodocs/docs/sources/audio-tag.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Only **required** field is a `source`. Callbacks default to no-ops if omitted.
| `onPlay` | `() => void` | no-op | After `play()`. |
| `onPause` | `() => void` | no-op | After `pause()`. |
| `onVolumeChange` | `(volume: number) => void` | no-op | When effective volume changes. |
| `onWaiting` | `() => void` | no-op | Playback stalled waiting on decoded data (network/decoder). Mirrors the HTML `waiting` event — not fired for a deliberate pause or seek. |
| `onPlaying` | `() => void` | no-op | Playback resumed after a stall reported by `onWaiting`. Mirrors the HTML `playing` event — not fired for the initial `play()`, see `onPlay`. |

### `AudioSource`

Expand Down Expand Up @@ -135,6 +137,21 @@ type AudioComponentContextType = {

Useful for creating your custom UI component. Must be used under `<Audio>`. Throws an `Error` if used outside the provider.

### `playbackState`

`'idle' | 'playing' | 'paused' | 'buffering'`.

`'buffering'` is a stall **during** playback, not a resting state: the user's play intent is still in effect, and playback resumes on its own once data arrives. A play/pause control must therefore treat it like `'playing'` — comparing against `'playing'` alone flips the button back to a play affordance mid-track. Use the exported `isPlaybackActive` helper instead of a direct comparison:

```tsx
import { isPlaybackActive } from 'react-native-audio-api';

const { playbackState, play, pause } = useAudioTagContext();
const isPlaying = isPlaybackActive(playbackState); // true while buffering too
```

The built-in `AudioControls` additionally runs an indeterminate sweep across the progress track while `playbackState === 'buffering'`.

```tsx
import React from 'react';
import { Button } from 'react-native';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ enum class AudioEvent {
POSITION_CHANGED,
BUFFER_ENDED,
RECORDER_ERROR,
BUFFERING_STATE_CHANGE,
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ AudioFileSourceNodeHostObject::AudioFileSourceNodeHostObject(
JSI_EXPORT_PROPERTY_GETTER(AudioFileSourceNodeHostObject, routedThroughMediaElement));
addSetters(
JSI_EXPORT_PROPERTY_SETTER(AudioFileSourceNodeHostObject, onPositionChanged),
JSI_EXPORT_PROPERTY_SETTER(AudioFileSourceNodeHostObject, onBufferingStateChanged),
JSI_EXPORT_PROPERTY_SETTER(AudioFileSourceNodeHostObject, volume),
JSI_EXPORT_PROPERTY_SETTER(AudioFileSourceNodeHostObject, playbackRate),
JSI_EXPORT_PROPERTY_SETTER(AudioFileSourceNodeHostObject, preservesPitch),
Expand All @@ -51,6 +52,7 @@ AudioFileSourceNodeHostObject::AudioFileSourceNodeHostObject(

AudioFileSourceNodeHostObject::~AudioFileSourceNodeHostObject() {
audioFileSourceNode_->assignOnPositionChangedCallbackId(0);
audioFileSourceNode_->assignOnBufferingStateChangeCallbackId(0);
}

JSI_PROPERTY_GETTER_IMPL(AudioFileSourceNodeHostObject, volume) {
Expand Down Expand Up @@ -146,4 +148,9 @@ JSI_PROPERTY_SETTER_IMPL(AudioFileSourceNodeHostObject, onPositionChanged) {
std::stoull(value.getString(runtime).utf8(runtime)));
}

JSI_PROPERTY_SETTER_IMPL(AudioFileSourceNodeHostObject, onBufferingStateChanged) {
audioFileSourceNode_->assignOnBufferingStateChangeCallbackId(
std::stoull(value.getString(runtime).utf8(runtime)));
}

} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class AudioFileSourceNodeHostObject : public AudioScheduledSourceNodeHostObject
JSI_PROPERTY_SETTER_DECL(preservesPitch);
JSI_PROPERTY_SETTER_DECL(loop);
JSI_PROPERTY_SETTER_DECL(onPositionChanged);
JSI_PROPERTY_SETTER_DECL(onBufferingStateChanged);

JSI_HOST_FUNCTION_DECL(pause);
JSI_HOST_FUNCTION_DECL(seekToStart);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ AudioEvent audioEventFromString(const std::string &event) {
return AudioEvent::BUFFER_ENDED;
if (event == "recorderError")
return AudioEvent::RECORDER_ERROR;
if (event == "bufferingStateChanged")
return AudioEvent::BUFFERING_STATE_CHANGE;

throw std::invalid_argument("Unknown audio event: " + event);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ AudioFileSourceNode::AudioFileSourceNode(
positionChanged_(
context->getAudioEventHandlerRegistry(),
static_cast<int>(context->getSampleRate() * ON_POSITION_CHANGED_INTERVAL),
true) {
true),
bufferingStateDispatcher_(
context->getAudioEventHandlerRegistry(),
static_cast<int>(context->getSampleRate() * ON_BUFFERING_STATE_DEBOUNCE_INTERVAL)) {
decoderState_->playbackRate.store(options.playbackRate, std::memory_order_release);
decoderState_->preservesPitch.store(options.preservesPitch, std::memory_order_release);

Expand Down Expand Up @@ -85,6 +88,10 @@ void AudioFileSourceNode::assignOnPositionChangedCallbackId(uint64_t callbackId)
positionChanged_.assignCallbackId(callbackId);
}

void AudioFileSourceNode::assignOnBufferingStateChangeCallbackId(uint64_t callbackId) {
bufferingStateDispatcher_.assignCallbackId(callbackId);
}

bool AudioFileSourceNode::initDecoder(
const std::shared_ptr<BaseAudioContext> &context,
AudioFileSourceOptions &options) {
Expand Down Expand Up @@ -434,13 +441,17 @@ bool AudioFileSourceNode::isCurrentMediaElementSource(uint64_t bindingId) const

void AudioFileSourceNode::pause() {
filePaused_ = true;
// A deliberate pause is not a stall — don't leave a stale "buffering" state
// observed by JS once processDecodedOutput() stops being called below.
bufferingStateDispatcher_.advance(/* hasData */ true, 0);
}

void AudioFileSourceNode::disable() {
stopDaemonThread();
filePaused_ = false;
endOfStreamStopPending_ = false;
endOfStreamDrainPending_ = false;
bufferingStateDispatcher_.advance(/* hasData */ true, 0);

AudioScheduledSourceNode::disable();
}
Expand Down Expand Up @@ -721,10 +732,13 @@ void AudioFileSourceNode::processDecodedOutput(
const bool hasFreshChunk = needsFreshDecoderChunk && readNextFrameChunk(incoming);

if (!hasFreshChunk && pendingDecoderChunk_.size == 0) {
bufferingStateDispatcher_.advance(/* hasData */ false, framesToProcess);
processingBuffer->zero();
return;
}

bufferingStateDispatcher_.advance(/* hasData */ true, framesToProcess);

if (hasFreshChunk && incoming.state == StreamState::END_OF_STREAM) {
currentTime_.store(duration_, std::memory_order_release);
sendOnPositionChangedEvent(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <audioapi/decoding/backends/AudioDecoderBackend.h>
#include <audioapi/dsp/WsolaTimeStretcher.h>
#include <audioapi/types/NodeOptions.h>
#include <audioapi/utils/events/BufferingStateDispatcher.h>
#include <audioapi/utils/events/PositionChangedDispatcher.h>
#include <cstddef>
#include <thread>
Expand All @@ -25,6 +26,9 @@ class MediaElementAudioSourceNode;

inline constexpr auto ON_POSITION_CHANGED_INTERVAL = 0.25f;

/// @brief Debounce interval for BufferingStateDispatcher — see its header.
inline constexpr auto ON_BUFFERING_STATE_DEBOUNCE_INTERVAL = 0.15f;

/// @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.
class AudioFileSourceNode : public AudioScheduledSourceNode {
Expand Down Expand Up @@ -122,6 +126,17 @@ class AudioFileSourceNode : public AudioScheduledSourceNode {

void assignOnPositionChangedCallbackId(uint64_t callbackId);

/// @brief Registers the JS callback for buffering-state-change events.
/// Pass 0 to unregister.
void assignOnBufferingStateChangeCallbackId(uint64_t callbackId);

/// @brief True while the render thread has been unable to obtain a decoded
/// frame for longer than @ref ON_BUFFERING_STATE_DEBOUNCE_INTERVAL. Not
/// exposed to JS (event-driven there); kept for tests.
[[nodiscard]] bool isBuffering() const {
return bufferingStateDispatcher_.isBuffering();
}

protected:
void processNode(int framesToProcess) override;

Expand Down Expand Up @@ -178,6 +193,10 @@ class AudioFileSourceNode : public AudioScheduledSourceNode {

PositionChangedDispatcher positionChanged_;

/// @brief Owns the buffering-state debounce/dispatch logic; see
/// BufferingStateDispatcher.h for the policy.
BufferingStateDispatcher bufferingStateDispatcher_;

/// @brief Sets up SPSC channels, constructs the SeekDecoderDaemon, and initialises metadata from the opened decoder.
/// @return false if the source could not be opened; caller must not set isInitialized_.
[[nodiscard]] bool initDecoder(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ enum class AudioEvent : uint8_t {
POSITION_CHANGED,
BUFFER_ENDED,
RECORDER_ERROR,
BUFFERING_STATE_CHANGE,
};
} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ struct DoubleValuePayload {
}
};

struct BoolValuePayload {
bool value;

facebook::jsi::Object toJsiObject(facebook::jsi::Runtime &rt) const {
facebook::jsi::Object obj(rt);
obj.setProperty(rt, "value", value);
return obj;
}
};

struct InterruptionPayload {
std::string type;
bool shouldResume;
Expand Down Expand Up @@ -90,6 +100,7 @@ struct AudioReadyPayload {
using AudioEventPayload = std::variant<
EmptyPayload,
DoubleValuePayload,
BoolValuePayload,
InterruptionPayload,
StringPayload,
BufferEndedPayload,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::AUDIO_READY, AudioReadyPayload);
AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::POSITION_CHANGED, DoubleValuePayload);
AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::BUFFER_ENDED, BufferEndedPayload);
AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::RECORDER_ERROR, StringPayload);
AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::BUFFERING_STATE_CHANGE, BoolValuePayload);

#undef AUDIOAPI_DEFINE_EVENT_PAYLOAD

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#include <audioapi/utils/events/BufferingStateDispatcher.h>

#include <memory>

namespace audioapi {

BufferingStateDispatcher::BufferingStateDispatcher(
const std::shared_ptr<IAudioEventHandlerRegistry> &audioEventHandlerRegistry,
int startThresholdFrames)
: bufferingStateChangeEvent_(audioEventHandlerRegistry),
startThresholdFrames_(startThresholdFrames) {}

void BufferingStateDispatcher::assignCallbackId(uint64_t callbackId) noexcept {
bufferingStateChangeEvent_.assignCallbackId(callbackId);
}

uint64_t BufferingStateDispatcher::getCallbackId() const noexcept {
return bufferingStateChangeEvent_.getCallbackId();
}

bool BufferingStateDispatcher::hasCallback() const noexcept {
return bufferingStateChangeEvent_.hasCallback();
}

bool BufferingStateDispatcher::isBuffering() const noexcept {
return isBuffering_.load(std::memory_order_acquire);
}

void BufferingStateDispatcher::advance(bool hasData, int framesToProcess) {
if (!bufferingStateChangeEvent_.hasCallback()) {
return;
}

if (hasData) {
starvedFrames_ = 0;
if (isBuffering_.exchange(false, std::memory_order_acq_rel)) {
bufferingStateChangeEvent_.dispatchFromAudioThread(BoolValuePayload{.value = false});
}
return;
}

starvedFrames_ += framesToProcess;
if (!isBuffering_.load(std::memory_order_acquire) && starvedFrames_ >= startThresholdFrames_) {
isBuffering_.store(true, std::memory_order_release);
bufferingStateChangeEvent_.dispatchFromAudioThread(BoolValuePayload{.value = true});
}
}

} // namespace audioapi
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#pragma once

#include <audioapi/events/EventCaller.hpp>

#include <atomic>
#include <cstdint>
#include <memory>

namespace audioapi {

/// @brief Debounces render-thread frame starvation into a buffering-state
/// event. A single starved render quantum is normal decode-ahead jitter, not
/// a real stall — only sustained starvation past @p startThresholdFrames is
/// reported. Recovery is reported immediately, with no symmetric debounce.
class BufferingStateDispatcher {
public:
BufferingStateDispatcher(
const std::shared_ptr<IAudioEventHandlerRegistry> &audioEventHandlerRegistry,
int startThresholdFrames);

void assignCallbackId(uint64_t callbackId) noexcept;
[[nodiscard]] uint64_t getCallbackId() const noexcept;
[[nodiscard]] bool hasCallback() const noexcept;

/// @brief True once sustained starvation has been reported and no
/// recovery has fired yet.
[[nodiscard]] bool isBuffering() const noexcept;

/// @brief Call once per render quantum. @p hasData is true when the
/// quantum had a decoded chunk available (fresh or previously stashed) to
/// play; false when the decoder daemon has nothing ready yet.
/// @note Audio thread only.
void advance(bool hasData, int framesToProcess);

private:
EventCaller<AudioEvent::BUFFERING_STATE_CHANGE> bufferingStateChangeEvent_;
std::atomic<bool> isBuffering_{false};
int starvedFrames_ = 0;
const int startThresholdFrames_;
};

} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ constexpr uint64_t POSITION_CALLBACK_ID = 19;
static_assert(EventPayloadFor<AudioEvent::ENDED, EmptyPayload>);
static_assert(EventPayloadFor<AudioEvent::POSITION_CHANGED, DoubleValuePayload>);
static_assert(EventPayloadFor<AudioEvent::RECORDER_ERROR, StringPayload>);
static_assert(EventPayloadFor<AudioEvent::BUFFERING_STATE_CHANGE, BoolValuePayload>);
static_assert(!EventPayloadFor<AudioEvent::ENDED, StringPayload>);
static_assert(!EventPayloadFor<AudioEvent::RECORDER_ERROR, EmptyPayload>);
static_assert(!EventPayloadFor<AudioEvent::BUFFERING_STATE_CHANGE, EmptyPayload>);
} // namespace

TEST(EventCallerTest, AssignAndGetCallbackId) {
Expand Down
Loading
Loading