Skip to content
Open
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
24 changes: 24 additions & 0 deletions .claude/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,30 @@ together in the `ContextPromise` resolve task (CallInvoker), after driver work
still reads the prior value until settlement (needed when `resume()` then `suspend()` are issued
back-to-back).

**Context `statechange` event:** `BaseAudioContext::dispatchStateChange(state)` fires
`AudioEvent::STATE_CHANGE` (payload `{state: "..."}`), deduped against the last dispatched state so
the two paths that both reach RUNNING (implicit `tryStartDriver` from a source start, then the
`resume()` promise) emit once. Call it *after* the `jsiPromise->resolve(...)` enqueue in the
`ContextPromiseResolver` factory lambdas — the registry worker can only enqueue its own
`invokeAsync` later, so the event's JS task always lands behind the resolve task and its
microtasks (spec's promise-then-statechange order). Carry the state by value: `getState()` may
still report SUSPENDED until the driver settles. On the TS side the event handler is
notification-only — it must NOT write the `state` attribute; a rapid `resume()+suspend()` settles
both operations before either event arrives, and a handler write would roll the attribute back to
the stale event's value. The attribute is fully native: `BaseAudioContext::publishedState_` is read
by the JSI `state` getter and written by `setPublishedState()` **inside each promise's own
resolution lambda** (the callback passed to `jsiPromise->resolve`, which runs on the JS thread in
that promise's resolve task) — never from the worker/render thread directly, where a later
operation's write can land before an earlier operation's continuations read. The statechange event
must use `EventCaller::dispatchOnJSQueue` (registry `dispatchEventOnJSQueue`, straight
`invokeAsync`), NOT the worker-queue `dispatch`: FIFO placement right behind its own resolve task
makes the handler observe each intermediate state instead of only the final one. Do not dispatch
statechange from mid-lifecycle bodies (`tryStartDriver`) — that enqueues the event before the
resolve task, so the handler reads the pre-transition state; the resolver's dispatch covers the
implicit-start path because TS always issues `resume()` for it. Verified with WPT
`suspend-after-construct` (5/0) and a probe asserting `resume.then→running, event→running,
suspend.then→suspended, event→suspended`.

---

## Common Mistakes
Expand Down
4 changes: 4 additions & 0 deletions packages/audiodocs/docs/core/audio-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,7 @@ Resumes a previously suspended audio context.

#### Returns `Promise<undefined>`.

## Events

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.

27 changes: 27 additions & 0 deletions packages/audiodocs/docs/core/base-audio-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,33 @@ const buffer = await this.audioContext.decodeAudioData(data, 4800, 2, false);
```
</details>

## Events

### `onstatechange`

Sets (or removes, when `null` is assigned) a callback fired whenever the context's [`state`](./base-audio-context.mdx#properties) changes to a different value.

The event is dispatched by the native audio engine once a transition is **acknowledged** — after the promise of the operation that caused it ([`resume`](./audio-context.mdx#resume), [`suspend`](./audio-context.mdx#suspend), [`close`](./audio-context.mdx#close), [`OfflineAudioContext.startRendering`](./offline-audio-context.mdx#startrendering)) has resolved. Code awaiting that promise always observes the new `state` value first; the `statechange` callback runs in a later task.

It also fires for transitions no method call requested, e.g. when the context starts running implicitly because a source node's `start()` engaged the audio driver.

The callback receives an event object:

| Field | Type | Description |
| :---: | :---: | :---- |
| `type` | `string` | Always `'statechange'`. |
| `target` | [`BaseAudioContext`](./base-audio-context.mdx) | The context whose state changed. |

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

audioContext.onstatechange = (event) => {
console.log(`state is now ${event.target.state}`);
};

await audioContext.resume(); // logs "state is now running" shortly after
```

## Remarks

#### `currentTime`
Expand Down
28 changes: 28 additions & 0 deletions packages/audiodocs/docs/core/offline-audio-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,31 @@ Resume time progression in audio context when it has been suspended.
Starts rendering the audio, taking into account the current connections and the current scheduled changes.

#### Returns `Promise<AudioBuffer>`.

## Events

Inherits [`onstatechange`](./base-audio-context.mdx#onstatechange) from [`BaseAudioContext`](./base-audio-context.mdx#events). It fires when rendering starts (`running`), when a scheduled [`suspend`](./offline-audio-context.mdx#suspend) point is reached (`suspended`), after [`resume`](./offline-audio-context.mdx#resume) (`running`), and when rendering finishes (`closed`).

### `oncomplete`

Sets (or removes, when `null` is assigned) a callback fired when rendering has finished. It is dispatched after the `closed` [`statechange`](./base-audio-context.mdx#onstatechange), so the two events always arrive in spec order. The callback receives an event object:

| Field | Type | Description |
| :---: | :---: | :---- |
| `type` | `string` | Always `'complete'`. |
| `target` | [`OfflineAudioContext`](./offline-audio-context.mdx) | The context that finished rendering. |
| `renderedBuffer` | [`AudioBuffer`](../sources/audio-buffer.mdx) | The rendered audio. |

```tsx
const offlineContext = new OfflineAudioContext({
numberOfChannels: 2,
length: 44100,
sampleRate: 44100,
});

offlineContext.oncomplete = (event) => {
console.log(`rendered ${event.renderedBuffer.duration}s of audio`);
};

await offlineContext.startRendering();
```
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ enum class AudioEvent {
BUFFER_ENDED,
RECORDER_ERROR,
BUFFERING_STATE_CHANGE,
STATE_CHANGE,
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ BaseAudioContextHostObject::BaseAudioContextHostObject(
JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, destination),
JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, listener),
JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, sampleRate),
JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, currentTime));
JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, currentTime),
JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, state));

addSetters(JSI_EXPORT_PROPERTY_SETTER(BaseAudioContextHostObject, onstatechange));

addFunctions(
JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createRecorderAdapter),
Expand All @@ -74,7 +77,19 @@ BaseAudioContextHostObject::BaseAudioContextHostObject(
// "key function" for the audio classes - this allow for RTTI to work
// properly across dynamic library boundaries (i.e. dynamic_cast that is used by
// isHostObject method), android specific issue
BaseAudioContextHostObject::~BaseAudioContextHostObject() = default;
BaseAudioContextHostObject::~BaseAudioContextHostObject() {
// The C++ context can outlive this HostObject (lifecycle promises hold it);
// never let it fire statechange into a GC'd JSI function.
context_->assignOnStateChangeCallbackId(0);
}

JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, state) {
return jsi::String::createFromUtf8(runtime, contextStateToString(context_->getPublishedState()));
}

JSI_PROPERTY_SETTER_IMPL(BaseAudioContextHostObject, onstatechange) {
context_->assignOnStateChangeCallbackId(std::stoull(value.getString(runtime).utf8(runtime)));
}

JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, destination) {
return jsi::Object::createFromHostObject(runtime, destination_);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class BaseAudioContextHostObject : public HostObject {
~BaseAudioContextHostObject() override;

JSI_PROPERTY_GETTER_DECL(destination);
JSI_PROPERTY_GETTER_DECL(state);
JSI_PROPERTY_SETTER_DECL(onstatechange);

JSI_PROPERTY_GETTER_DECL(listener);
JSI_PROPERTY_GETTER_DECL(sampleRate);
JSI_PROPERTY_GETTER_DECL(currentTime);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ JSI_HOST_FUNCTION_IMPL(OfflineAudioContextHostObject, suspend) {
}

JSI_HOST_FUNCTION_IMPL(OfflineAudioContextHostObject, startRendering) {
// No promise acknowledges this first transition; rendering counts as
// running from the moment it is requested, synchronously with the call.
context_->setPublishedState(ContextState::RUNNING);
return promiseVendor_->createPromise([this](Promise &&promise) {
auto resultPromise = OfflineAudioContextResultPromise::makeOfflineAudioContextResultResolver(
std::move(promise), context_);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ AudioEvent audioEventFromString(const std::string &event) {
return AudioEvent::RECORDER_ERROR;
if (event == "bufferingStateChanged")
return AudioEvent::BUFFERING_STATE_CHANGE;
if (event == "stateChange")
return AudioEvent::STATE_CHANGE;

throw std::invalid_argument("Unknown audio event: " + event);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@
#include <string>

namespace audioapi::js_enum_parser {

std::string overSampleTypeToString(OverSampleType type);
OverSampleType overSampleTypeFromString(const std::string &type);
std::string oscillatorTypeToString(OscillatorType type);
OscillatorType oscillatorTypeFromString(const std::string &type);
std::string filterTypeToString(BiquadFilterType type);
BiquadFilterType filterTypeFromString(const std::string &type);
AudioEvent audioEventFromString(const std::string &event);
std::string contextStateToString(ContextState state);
std::string channelCountModeToString(ChannelCountMode mode);
ChannelCountMode channelCountModeFromString(const std::string &mode);
std::string channelInterpretationToString(ChannelInterpretation interpretation);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ bool AudioContext::tryStartDriver() {

if (audioPlayer_->start()) {
isInitialized_.store(true, std::memory_order_release);
// The driver also starts implicitly, from the first
// `AudioScheduledSourceNode::start()`. Publish RUNNING here so the visible state
// Publish RUNNING here so the visible state
// never reports SUSPENDED while the graph is actually rendering; `resume()`
// reaches the same state through its promise task.
// reaches the same state through its promise task (its dispatch then
// dedupes against this one).
setState(ContextState::RUNNING);
Comment on lines -73 to 77

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.

Actually it does not. It only flips the atomic state flag. (?)

return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ BaseAudioContext::BaseAudioContext(
: state_(ContextState::SUSPENDED),
sampleRate_(sampleRate),
audioEventHandlerRegistry_(audioEventHandlerRegistry),
stateChangeEvent_(audioEventHandlerRegistry),
pendingPromisesOffloader_(
std::make_unique<task_offloader::TaskOffloader<
ContextPromiseTask,
Expand Down Expand Up @@ -64,6 +65,21 @@ double BaseAudioContext::getCurrentTime() const {
return static_cast<double>(getCurrentSampleFrame()) / getSampleRate();
}

void BaseAudioContext::assignOnStateChangeCallbackId(uint64_t callbackId) {
stateChangeEvent_.assignCallbackId(callbackId);
}

void BaseAudioContext::dispatchStateChange(ContextState state) {
if (lastDispatchedState_.exchange(state, std::memory_order_acq_rel) == state) {
return;
}

// FIFO with the promise resolution the caller just enqueued, so the event
// fires between this transition's continuations and the next transition's —
// the handler observes each state, not just the final one.
stateChangeEvent_.dispatch(StringPayload{.name = "state", .reason = contextStateToString(state)});
}

void BaseAudioContext::setState(ContextState state) {
state_.store(state, std::memory_order_release);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <audioapi/core/utils/graph/Graph.h>
#include <audioapi/events/AudioEvent.h>
#include <audioapi/events/DeferredEventQueue.hpp>
#include <audioapi/events/EventCaller.hpp>
#include <audioapi/utils/AudioBuffer.hpp>
#include <audioapi/utils/CrossThreadEventScheduler.hpp>
#include <audioapi/utils/TaskOffloader.hpp>
Expand Down Expand Up @@ -47,6 +48,30 @@ class BaseAudioContext : public std::enable_shared_from_this<BaseAudioContext> {

void setState(ContextState state);

/// The value behind the JS `state` attribute. Read from the JSI getter on
/// the JS thread.
[[nodiscard]] ContextState getPublishedState() const {
return publishedState_.load(std::memory_order_acquire);
}

/// Publishes an acknowledged transition to the JS-visible state. JS thread
/// only, and for promise-driven transitions only from the operation's own
/// resolution continuation (the TS `publishedState` setter): CallInvokers
/// may batch queued resolve tasks ahead of the first microtask checkpoint,
/// so any earlier write point lets a rapid resume()+suspend() pair publish
/// both states before either continuation reads. Native-originated
/// transitions with no acknowledging promise (planned `interrupted`) write
/// here from a CallInvoker task instead.
void setPublishedState(ContextState state) {
publishedState_.store(state, std::memory_order_release);
}

/// JS thread. Wires the `statechange` listener registered by the TS context.
void assignOnStateChangeCallbackId(uint64_t callbackId);

/// Fires `statechange` for an acknowledged transition to @p state.
void dispatchStateChange(ContextState state);

[[nodiscard]] std::shared_ptr<PeriodicWave> createPeriodicWave(
const std::vector<std::complex<float>> &complexData,
bool disableNormalization,
Expand Down Expand Up @@ -159,6 +184,12 @@ class BaseAudioContext : public std::enable_shared_from_this<BaseAudioContext> {
std::atomic<float> sampleRate_;
std::shared_ptr<IAudioEventHandlerRegistry> audioEventHandlerRegistry_;

EventCaller<AudioEvent::STATE_CHANGE> stateChangeEvent_;
/// Ledger backing dispatchStateChange()'s dedupe; contexts start suspended.
std::atomic<ContextState> lastDispatchedState_{ContextState::SUSPENDED};
/// Backs the JS `state` attribute; written only via setPublishedState().
std::atomic<ContextState> publishedState_{ContextState::SUSPENDED};

std::shared_ptr<PeriodicWave> cachedSineWave_ = nullptr;
std::shared_ptr<PeriodicWave> cachedSquareWave_ = nullptr;
std::shared_ptr<PeriodicWave> cachedSawtoothWave_ = nullptr;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,12 @@ void OfflineAudioContext::startRendering(
renderingStarted_ = true;
resultPromise_ = promise;
auto runningStatePromise = std::make_shared<ContextPromiseResolver<void>>(
[self = shared_from_this()]() { self->setState(ContextState::RUNNING); },
[self = shared_from_this()]() {
self->setState(ContextState::RUNNING);
// startRendering has no acknowledging promise for this transition;
// the render thread actually starting is the acknowledgment.
self->dispatchStateChange(ContextState::RUNNING);
},
[](const std::string &) {});
renderAudio(runningStatePromise);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,18 @@ namespace audioapi {

enum class ContextState : std::uint8_t { SUSPENDED, RUNNING, CLOSED };

/// The Web Audio API `AudioContextState` string for @p state, as carried by
/// the `statechange` event payload and the JS `state` attribute.
inline const char *contextStateToString(ContextState state) {
switch (state) {
case ContextState::SUSPENDED:
return "suspended";
case ContextState::RUNNING:
return "running";
case ContextState::CLOSED:
return "closed";
}
return "suspended";
}

} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ enum class AudioEvent : uint8_t {
BUFFER_ENDED,
RECORDER_ERROR,
BUFFERING_STATE_CHANGE,
STATE_CHANGE,
};
} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ 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);
AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::STATE_CHANGE, StringPayload);

#undef AUDIOAPI_DEFINE_EVENT_PAYLOAD

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ std::shared_ptr<ContextPromiseResolver<void>> ContextPromiseResolver<T>::makeCon
// Spec: update the state attribute in the same follow-up task that
// resolves the lifecycle promise (before statechange reactions).
audioContext->setState(nextState);
jsiPromise->resolve([](jsi::Runtime &runtime) { return jsi::Value::undefined(); });
// Publishes the JS-visible state on the JS thread
jsiPromise->resolve([audioContext, nextState](jsi::Runtime &runtime) {
audioContext->setPublishedState(nextState);
return jsi::Value::undefined();
});
audioContext->dispatchStateChange(nextState);
},
[jsiPromise](const std::string &message) { jsiPromise->reject(message); });
}
Expand All @@ -44,9 +49,14 @@ ContextPromiseResolver<T>::makeOfflineAudioContextResultResolver(
// resolves the startRendering promise (before statechange reactions).
audioContext->setState(ContextState::CLOSED);
auto audioBufferHostObject = std::make_shared<AudioBufferHostObject>(audioBuffer);
jsiPromise->resolve([audioBufferHostObject](jsi::Runtime &runtime) {
// Published in the resolution task, as above.
jsiPromise->resolve([audioContext, audioBufferHostObject](jsi::Runtime &runtime) {
audioContext->setPublishedState(ContextState::CLOSED);
return jsi::Object::createFromHostObject(runtime, audioBufferHostObject);
});
// Behind the resolve enqueue, as above; the TS layer orders the
// `complete` event after this statechange lands.
audioContext->dispatchStateChange(ContextState::CLOSED);
},
[jsiPromise](const std::string &message) { jsiPromise->reject(message); });
}
Expand Down
Loading
Loading