Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f15c40a
fix(AudioPlayer): modify AudioPlayer::resume, expand AudioPlayer::onE…
basiav Aug 24, 2026
88e6167
fix(AudioPlayer): update onErrorAfterClose, delete stream-rebuilding …
basiav Aug 26, 2026
194e3bc
fix(AudioPlayer): update onErrorAfterClose, remove rebuilding the str…
basiav Aug 26, 2026
4c39721
fix(AudioPlayer): update onErrorAfterClose, remove rebuilding the str…
basiav Aug 26, 2026
995dca8
fix: added onerror event handler to AudioContext
basiav Sep 14, 2026
890fa56
chore: deleted logs
basiav Sep 14, 2026
e991d8f
fix: add reentrancy guard and deleted isFailed_
basiav Sep 14, 2026
d603c6e
chore: delete log from AudioContext
basiav Sep 14, 2026
317121e
Merge branch 'main' into fix/oboe-stream-dies
basiav Sep 14, 2026
d024c14
fix: add EventCaller to AudioContext to handle onerror
basiav Sep 16, 2026
d43fedc
docs: mention onerror in docs
basiav Sep 16, 2026
e59f04c
fix: rebuild once only for ErrorDisconnected, for other errors onStre…
basiav Sep 16, 2026
c1bd209
Update packages/audiodocs/docs/core/audio-context.mdx
basiav Sep 16, 2026
69eefaa
docs: update info about onerror
basiav Sep 16, 2026
f6587cf
fix: fix in AudioPlayer
basiav Sep 16, 2026
7728102
fix: added error listening and onerror dispatch on ios
basiav Sep 17, 2026
1486226
fix: deleted CommonPlayer interface method notifyStreamFailed
basiav Sep 17, 2026
1fa64a2
chore: delete leftover of notifyStreamFailed
basiav Sep 17, 2026
dd087e1
refactor: shared player state in common class
mdydek Sep 28, 2026
3517327
fix: final look
mdydek Sep 28, 2026
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
4 changes: 3 additions & 1 deletion .claude/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ Control-plane synchronization uses two layers — both are non-recursive `std::m

On Android, `AudioPlayer::onErrorAfterClose` also takes `driverMutex_` because Oboe error callbacks bypass `AudioContext`.

**Live `AudioContext` render quiescence:** `currentRenders_` on `AudioContext` is incremented at the start of each platform I/O callback (`IOSAudioPlayer::deliverOutputBuffers` / `AudioPlayer::onAudioReady`) via a reference passed in `initialize()`, and decremented when the callback returns (RAII scope). `suspend()` and `close()` call `waitForRenderQuiescence()` (under `driverMutex_`) before `processAudioEvents()` / `cleanup()`. Platform drivers share the `CommonPlayer` abstract base (`common/cpp/audioapi/core/CommonPlayer.h`).
**Live `AudioContext` render quiescence:** `currentRenders_` on `AudioContext` is incremented at the start of each platform I/O callback (`IOSAudioPlayer::deliverOutputBuffers` / `AudioPlayer::onAudioReady`) via a reference passed in `initialize()`, and decremented when the callback returns (RAII scope). `suspend()` and `close()` call `waitForRenderQuiescence()` (under `driverMutex_`) before `processAudioEvents()` / `cleanup()`. `NodeAudioPlayer` (WPT harness) receives the same reference but never opens a render scope.

**Player base class:** all three drivers (`AudioPlayer` on Android, `IOSAudioPlayer`, `NodeAudioPlayer` in `wpt_tests/src/`) derive from `CommonPlayer` (`common/cpp/audioapi/core/CommonPlayer.h`). It owns the shared driver state as protected members: `renderAudio_`, `renderBuffer_` (one render quantum, allocated once in the constructor), `sampleRate_`, `channelCount_`, `currentRenders_`, `context_`, `driverMutex_`, and `isRunning_`. It also takes one constructor signature for every platform, so `AudioContext::initialize()` builds a single `PlatformAudioPlayer` alias. Render through `renderNormalizedQuantum()`, which keeps the output peak-limiting in the player so offline renders are never limited. `AudioPlayerTests.mm` (fabric-example XCTest) hand-copies `IOSAudioPlayer`'s member layout to reach its private members, so any change to `IOSAudioPlayer`'s data members must also be made there.

**Graph producer self-drain:** `Graph::enableProducerSelfDrain()` makes producer threads drain the event channels themselves after each enqueue; `disableProducerSelfDrain()` hands consumption back to the audio/render thread. Both flush the channels internally (no separate `processEvents()` call needed) and serialize with in-flight drains via `selfDrainMutex_`, because two producers can drain concurrently: the JS thread (mutations) and the GC finalizer thread (`removeNode`, which self-drains after its Channel B orphan send — otherwise a finalizer burst with no consumer fills the bounded channel and blocks forever, e.g. at process exit). Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend); disable *before* starting the audio/render consumer; re-enable if start/resume fails.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,8 @@ - (NSString *)attachSourceNodeToAudioEngine {
return [self.audioEngine
attachSourceNodeWithRenderBlock:[self testSourceRenderBlock]
sampleRate:44100
channelCount:2];
channelCount:2
onOutputRecoveryFailed:nil];
}

- (void)testCleanupDestroysInternalEngineAndResetsStateAndDeactivatesSession {
Expand Down Expand Up @@ -370,7 +371,8 @@ - (void)testAttachSourceNodeStoresAndConnectsSource {
NSString *sourceNodeId = [self.audioEngine
attachSourceNodeWithRenderBlock:[self testSourceRenderBlock]
sampleRate:44100
channelCount:2];
channelCount:2
onOutputRecoveryFailed:nil];
AVAudioSourceNode *sourceNode = self.audioEngine.sourceNodes[sourceNodeId];
AVAudioFormat *format = self.audioEngine.sourceFormats[sourceNodeId];

Expand Down
22 changes: 13 additions & 9 deletions apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <cstddef>
#include <functional>
#include <memory>
#include <mutex>
#include <utility>
#include <vector>

Expand All @@ -25,7 +26,9 @@
const std::function<void(DSPAudioBuffer *, int)> &renderAudio,
float sampleRate,
int channelCount,
std::atomic<uint32_t> &currentRenders);
std::atomic<uint32_t> &currentRenders,
std::weak_ptr<AudioContext> context,
std::mutex *driverMutex);
~IOSAudioPlayer() override;

bool start() override;
Expand All @@ -40,13 +43,7 @@
[[nodiscard]] double getOutputLatency() const override;

protected:
std::shared_ptr<DSPAudioBuffer> audioBuffer_;
NativeAudioPlayer *audioPlayer_;
float sampleRate_;
std::function<void(DSPAudioBuffer *, int)> renderAudio_;
std::atomic<uint32_t> &currentRenders_;
int channelCount_;
std::atomic<bool> isRunning_;
std::atomic<bool> flushOverflowNextPull_;
int pendingSavedCount_;
DSPAudioBuffer pendingSaved_;
Expand Down Expand Up @@ -159,6 +156,7 @@ - (void)stopIfPossible
- (NSString *)attachSourceNodeWithRenderBlock:(AVAudioSourceNodeRenderBlock)renderBlock
sampleRate:(float)sampleRate
channelCount:(AVAudioChannelCount)channelCount
onOutputRecoveryFailed:(OnOutputRecoveryFailedBlock)onOutputRecoveryFailed
{
self.attachSourceNodeCallCount += 1;
self.lastAttachedRenderBlock = renderBlock;
Expand Down Expand Up @@ -235,7 +233,13 @@ - (void)cleanup
float sampleRate,
int channelCount)
: currentRendersStorage_(0),
IOSAudioPlayer(renderAudio, sampleRate, channelCount, currentRendersStorage_) {}
IOSAudioPlayer(
renderAudio,
sampleRate,
channelCount,
currentRendersStorage_,
std::weak_ptr<AudioContext>{},
nullptr) {}

NativeAudioPlayer *replaceAudioPlayer(NativeAudioPlayer *audioPlayer) {
NativeAudioPlayer *previous = audioPlayer_;
Expand All @@ -248,7 +252,7 @@ - (void)cleanup
}

std::shared_ptr<DSPAudioBuffer> getAudioBuffer() const {
return audioBuffer_;
return renderBuffer_;
}

void setRunning(bool isRunning) {
Expand Down
18 changes: 18 additions & 0 deletions packages/audiodocs/docs/core/audio-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,21 @@ Resumes a previously suspended audio context.

Inherits [`onstatechange`](./base-audio-context.mdx#onstatechange) from [`BaseAudioContext`](./base-audio-context.mdx#events); `close`, `suspend` and `resume` each fire it once their returned promise has resolved.

### `onerror`

Sets (or removes, when `null` is assigned) a callback fired when the live output stream fails natively and cannot keep rendering — for example after the audio device disconnects or the OS audio service dies while the context is running.


Typical recovery is to call [`resume`](./audio-context.mdx#resume) from the handler (or recreate the context). A short delay before `resume` gives the OS audio server time to recover from some errors, e.g. hardware errors, and prevents rapid retry loops.

```tsx
const audioContext = new AudioContext();

audioContext.onerror = () => {
console.error('Audio output failed');
setTimeout(() => {
void audioContext.resume();
}, 2000);
};
```

Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,17 @@
#include <audioapi/core/utils/Constants.h>
#include <audioapi/core/utils/CurrentRenderScope.h>
#include <audioapi/utils/AudioArray.hpp>
#include <audioapi/utils/Macros.h>

#include <jni.h>

#include <algorithm>
#include <cstddef>
#include <memory>
#include <mutex>

namespace audioapi {

AudioPlayer::AudioPlayer(
const std::function<void(DSPAudioBuffer *, int)> &renderAudio,
float sampleRate,
int channelCount,
std::mutex *driverMutex,
const std::shared_ptr<AudioContext> &context,
std::atomic<uint32_t> &currentRenders)
: renderAudio_(renderAudio),
currentRenders_(currentRenders),
sampleRate_(sampleRate),
channelCount_(channelCount),
isRunning_(false),
driverMutex_(driverMutex),
context_(context) {}

bool AudioPlayer::openAudioStream() {
std::scoped_lock lock(streamMutex_);
AudioStreamBuilder builder;
Expand All @@ -50,17 +37,20 @@ bool AudioPlayer::openAudioStream() {
return false;
}

buffer_ = std::make_shared<DSPAudioBuffer>(RENDER_QUANTUM_SIZE, channelCount_, sampleRate_);
isInitialized_.store(true, std::memory_order_release);
return true;
}

bool AudioPlayer::rebuildStream() {
cleanup();
return openAudioStream();
}

bool AudioPlayer::start() {
std::scoped_lock lock(streamMutex_);
if (!isInitialized_.load(std::memory_order_acquire)) {
if (!openAudioStream()) {
return false;
}

if ((!isInitialized_.load(std::memory_order_acquire)) && (!openAudioStream())) {
return false;
}

if (mStream_ != nullptr) {
Expand All @@ -87,6 +77,11 @@ bool AudioPlayer::resume() {
return true;
}

// The stream may have been dropped by onErrorAfterClose while suspended.
if ((!isInitialized_.load(std::memory_order_acquire)) && (!openAudioStream())) {
return false;
}

if (mStream_ != nullptr) {
auto result = mStream_->requestStart() == oboe::Result::OK;
isRunning_.store(result, std::memory_order_release);
Expand Down Expand Up @@ -139,34 +134,66 @@ AudioPlayer::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numF
auto framesToProcess = std::min(numFrames - processedFrames, RENDER_QUANTUM_SIZE);

if (isRunning_.load(std::memory_order_acquire)) {
renderAudio_(buffer_.get(), framesToProcess);
// Peak-normalize the rendered quantum before it reaches the hardware.
// This limiting lives in the player (not the destination node) so
// offline renders stay spec-accurate.
buffer_->normalize();
renderNormalizedQuantum(framesToProcess);
} else {
buffer_->zero();
renderBuffer_->zero();
}

float *destination = buffer + (processedFrames * channelCount_);
float *destination = buffer + (static_cast<ptrdiff_t>(processedFrames * channelCount_));

buffer_->interleaveTo(destination, framesToProcess);
renderBuffer_->interleaveTo(destination, framesToProcess);
processedFrames += framesToProcess;
}

return DataCallbackResult::Continue;
}

namespace {
struct ReentrancyGuard {
DELETE_COPY_AND_MOVE(ReentrancyGuard);
explicit ReentrancyGuard(bool *f) : flag(f) {
*flag = true;
}
~ReentrancyGuard() {
*flag = false;
}

private:
bool *flag;
};
} // namespace

void AudioPlayer::onErrorAfterClose(oboe::AudioStream *stream, oboe::Result error) {
if (error != oboe::Result::ErrorDisconnected || driverMutex_ == nullptr) {
if (driverMutex_ == nullptr) {
return;
}

switch (error) {
case oboe::Result::ErrorDisconnected:
case oboe::Result::ErrorTimeout:
case oboe::Result::ErrorInternal:
case oboe::Result::ErrorNoService:
break;
default:
return;
}

// Reentrancy guard - prevent recursive calls to onErrorAfterClose.
static thread_local bool isInsideOnError = false;
if (isInsideOnError) {
return;
}
ReentrancyGuard guard(&isInsideOnError);

auto context = context_.lock();
if (context == nullptr) {
return;
}

// Serialize with start()/resume()/suspend()/close() on the JS / promise-pool threads.
std::scoped_lock lock(*driverMutex_, streamMutex_);

auto context = context_.lock();
if (context == nullptr || context->isClosed()) {
if (context->isClosed()) {
return;
}

Expand All @@ -176,10 +203,30 @@ void AudioPlayer::onErrorAfterClose(oboe::AudioStream *stream, oboe::Result erro
return;
}

cleanup();
if (openAudioStream()) {
resume();
// Check if the stream was expected to be running when the error occurred
const bool wasRunning = isRunning_.load(std::memory_order_acquire);

// Best effort rebuild; a suspended context keeps the rebuilt stream paused until resume().
if (error == oboe::Result::ErrorDisconnected && rebuildStream()) {
if (!wasRunning) {
return;
}
if (mStream_->requestStart() == oboe::Result::OK) {
isRunning_.store(true, std::memory_order_release);
return;
}
}

isRunning_.store(false, std::memory_order_release);

if (!wasRunning) {
// Nothing was playing, so there is no failure to report: drop the dead stream
// and let resume() open a new one.
cleanup();
return;
}

context->onStreamFail();
}

double AudioPlayer::getBaseLatency() const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,21 @@
#include <atomic>
#include <cassert>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>

#include <audioapi/core/CommonPlayer.h>
#include <audioapi/utils/AudioBuffer.hpp>

namespace audioapi {

using namespace oboe;

class AudioContext;

class AudioPlayer : public CommonPlayer,
public AudioStreamDataCallback,
public AudioStreamErrorCallback,
public std::enable_shared_from_this<AudioPlayer> {
public:
AudioPlayer(
const std::function<void(DSPAudioBuffer *, int)> &renderAudio,
float sampleRate,
int channelCount,
std::mutex *driverMutex,
const std::shared_ptr<AudioContext> &context,
std::atomic<uint32_t> &currentRenders);
using CommonPlayer::CommonPlayer;

~AudioPlayer() override {
cleanup();
Expand All @@ -54,21 +44,14 @@ class AudioPlayer : public CommonPlayer,
void onErrorAfterClose(AudioStream *audioStream, Result error) override;

private:
std::function<void(DSPAudioBuffer *, int)> renderAudio_;
std::atomic<uint32_t> &currentRenders_;
std::shared_ptr<AudioStream> mStream_;
mutable std::recursive_mutex streamMutex_;
std::shared_ptr<DSPAudioBuffer> buffer_;
std::atomic<bool> isInitialized_{false};
float sampleRate_;
int channelCount_;
std::atomic<bool> isRunning_;
/// Updated on the audio thread from each Oboe callback `numFrames`.
std::atomic<int32_t> lastCallbackFrameCount_{0};
std::mutex *driverMutex_;
std::weak_ptr<AudioContext> context_;

bool openAudioStream();
bool rebuildStream();
};

} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ enum class AudioEvent {
RECORDER_ERROR,
BUFFERING_STATE_CHANGE,
STATE_CHANGE,
CONTEXT_ERROR,
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,18 @@ AudioContextHostObject::AudioContextHostObject(
callInvoker) {
addGetters(JSI_EXPORT_PROPERTY_GETTER(AudioContextHostObject, outputLatency));
addGetters(JSI_EXPORT_PROPERTY_GETTER(AudioContextHostObject, baseLatency));
addSetters(JSI_EXPORT_PROPERTY_SETTER(AudioContextHostObject, onerror));
addFunctions(
JSI_EXPORT_FUNCTION(AudioContextHostObject, close),
JSI_EXPORT_FUNCTION(AudioContextHostObject, resume),
JSI_EXPORT_FUNCTION(AudioContextHostObject, suspend),
JSI_EXPORT_FUNCTION(AudioContextHostObject, createMediaElementSource));
}

AudioContextHostObject::~AudioContextHostObject() {
std::static_pointer_cast<AudioContext>(context_)->assignOnErrorCallbackId(0);
}

JSI_HOST_FUNCTION_IMPL(AudioContextHostObject, close) {
return promiseVendor_->createPromise([this](Promise &&promise) {
auto contextPromise = ContextPromiseResolver<void>::makeContextPromiseResolver(
Expand Down Expand Up @@ -78,4 +83,9 @@ JSI_HOST_FUNCTION_IMPL(AudioContextHostObject, createMediaElementSource) {
return object;
}

JSI_PROPERTY_SETTER_IMPL(AudioContextHostObject, onerror) {
auto audioContext = std::static_pointer_cast<AudioContext>(context_);
audioContext->assignOnErrorCallbackId(std::stoull(value.getString(runtime).utf8(runtime)));
}

Comment on lines +86 to +90

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even if it looked simple, it can introduce some errors, such as datarace when swaping the function mid oboe's onError, reference cycle due to strong ptr and that it can be destroyed on different thread. Utilize existing AudioEventHandlerRegistry and compare how events are done using it and implement the onerror this way.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

corrected

} // namespace audioapi
Loading
Loading