diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 1e13b88b4..83a029480 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -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 diff --git a/packages/audiodocs/docs/core/audio-context.mdx b/packages/audiodocs/docs/core/audio-context.mdx index befc54774..45eaebeca 100644 --- a/packages/audiodocs/docs/core/audio-context.mdx +++ b/packages/audiodocs/docs/core/audio-context.mdx @@ -71,3 +71,7 @@ Resumes a previously suspended audio context. #### Returns `Promise`. +## 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. + diff --git a/packages/audiodocs/docs/core/base-audio-context.mdx b/packages/audiodocs/docs/core/base-audio-context.mdx index b92e87cd9..4821aae0f 100644 --- a/packages/audiodocs/docs/core/base-audio-context.mdx +++ b/packages/audiodocs/docs/core/base-audio-context.mdx @@ -227,6 +227,33 @@ const buffer = await this.audioContext.decodeAudioData(data, 4800, 2, false); ``` +## 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` diff --git a/packages/audiodocs/docs/core/offline-audio-context.mdx b/packages/audiodocs/docs/core/offline-audio-context.mdx index 62834e6fc..6c876f2ac 100644 --- a/packages/audiodocs/docs/core/offline-audio-context.mdx +++ b/packages/audiodocs/docs/core/offline-audio-context.mdx @@ -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`. + +## 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(); +``` diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt index 398a93c0f..324fac4e2 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt @@ -26,4 +26,5 @@ enum class AudioEvent { BUFFER_ENDED, RECORDER_ERROR, BUFFERING_STATE_CHANGE, + STATE_CHANGE, } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp index fadced0ca..53d91784c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp @@ -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), @@ -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_); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h index d79425db0..e3824d126 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h @@ -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); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/OfflineAudioContextHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/OfflineAudioContextHostObject.cpp index a00cbad9b..9838c326f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/OfflineAudioContextHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/OfflineAudioContextHostObject.cpp @@ -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_); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp index a2259b7db..aed6aaea5 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp @@ -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); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h index d9bb1969d..5735276c8 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h @@ -11,6 +11,7 @@ #include namespace audioapi::js_enum_parser { + std::string overSampleTypeToString(OverSampleType type); OverSampleType overSampleTypeFromString(const std::string &type); std::string oscillatorTypeToString(OscillatorType type); @@ -18,7 +19,6 @@ 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); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp index c7a038e48..4f4c90504 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp @@ -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); return true; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp index a845a8230..0ecc83a2b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp @@ -18,6 +18,7 @@ BaseAudioContext::BaseAudioContext( : state_(ContextState::SUSPENDED), sampleRate_(sampleRate), audioEventHandlerRegistry_(audioEventHandlerRegistry), + stateChangeEvent_(audioEventHandlerRegistry), pendingPromisesOffloader_( std::make_unique(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); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h index a6c4c17ba..ad2c216ec 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -47,6 +48,30 @@ class BaseAudioContext : public std::enable_shared_from_this { 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 createPeriodicWave( const std::vector> &complexData, bool disableNormalization, @@ -159,6 +184,12 @@ class BaseAudioContext : public std::enable_shared_from_this { std::atomic sampleRate_; std::shared_ptr audioEventHandlerRegistry_; + EventCaller stateChangeEvent_; + /// Ledger backing dispatchStateChange()'s dedupe; contexts start suspended. + std::atomic lastDispatchedState_{ContextState::SUSPENDED}; + /// Backs the JS `state` attribute; written only via setPublishedState(). + std::atomic publishedState_{ContextState::SUSPENDED}; + std::shared_ptr cachedSineWave_ = nullptr; std::shared_ptr cachedSquareWave_ = nullptr; std::shared_ptr cachedSawtoothWave_ = nullptr; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp index f75b104c3..b069d9ef7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp @@ -163,7 +163,12 @@ void OfflineAudioContext::startRendering( renderingStarted_ = true; resultPromise_ = promise; auto runningStatePromise = std::make_shared>( - [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); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/types/ContextState.h b/packages/react-native-audio-api/common/cpp/audioapi/core/types/ContextState.h index c40d7070f..acf3f9bad 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/types/ContextState.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/types/ContextState.h @@ -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 diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h index e11dc5262..beb125403 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h @@ -29,5 +29,6 @@ enum class AudioEvent : uint8_t { BUFFER_ENDED, RECORDER_ERROR, BUFFERING_STATE_CHANGE, + STATE_CHANGE, }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp index bf26da887..eb44b720c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp @@ -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 diff --git a/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp b/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp index e62275f24..0295f32db 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp @@ -25,7 +25,12 @@ std::shared_ptr> ContextPromiseResolver::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); }); } @@ -44,9 +49,14 @@ ContextPromiseResolver::makeOfflineAudioContextResultResolver( // resolves the startRendering promise (before statechange reactions). audioContext->setState(ContextState::CLOSED); auto audioBufferHostObject = std::make_shared(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); }); } diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index 8941e656c..5a8e52f05 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -33,8 +33,8 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot close a closed audio context.'); } - this._state = 'closed'; - return (this.context as IAudioContext).close(); + this.setControlState('closed'); + await (this.context as IAudioContext).close(); } async resume(): Promise { @@ -42,8 +42,8 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot resume a closed audio context.'); } - this._state = 'running'; - return (this.context as IAudioContext).resume(); + this.setControlState('running'); + await (this.context as IAudioContext).resume(); } async suspend(): Promise { @@ -51,8 +51,8 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot suspend a closed audio context.'); } - this._state = 'suspended'; - return (this.context as IAudioContext).suspend(); + this.setControlState('suspended'); + await (this.context as IAudioContext).suspend(); } /** @@ -62,8 +62,10 @@ export default class AudioContext extends BaseAudioContext { */ public override markRunningOnSourceStart(): void { if (this._state === 'suspended') { - this._state = 'running'; - (this.context as IAudioContext).resume(); + this.setControlState('running'); + (this.context as IAudioContext).resume().catch(() => { + // The driver refused to start; the attribute keeps reporting reality. + }); } } diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index aa20c53ef..b62b20eb7 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -1,4 +1,6 @@ import { InvalidStateError, NotSupportedError } from '../errors'; +import { AudioEventEmitter } from '../events'; +import { OnStateChangeEventType } from '../events/types'; import { IBaseAudioContext } from '../jsi-interfaces'; import { ContextState, @@ -25,6 +27,11 @@ import PeriodicWave from './PeriodicWave'; import StereoPannerNode from './StereoPannerNode'; import WaveShaperNode from './WaveShaperNode'; +export interface ContextStateChangeEvent { + type: 'statechange'; + target: BaseAudioContext; +} + export default class BaseAudioContext { readonly destination: AudioDestinationNode; readonly listener: AudioListener; @@ -36,16 +43,87 @@ export default class BaseAudioContext { this.destination = new AudioDestinationNode(this, context.destination); this.listener = new AudioListener(this, context.listener); this.sampleRate = context.sampleRate; + + // The native context owns statechange: it dispatches once per acknowledged + // transition, after settling the operation's promise, so the event always + // lands in a later task than the promise continuations. This subscription + // lives as long as the context; native transitions that no JS call + // requested (e.g. a future interrupted state) flow through the same path. + this.stateChangeSubscription = this.audioEventEmitter.addAudioEventListener( + 'stateChange', + (event: OnStateChangeEventType) => this.onNativeStateChange(event) + ); + this.context.onstatechange = this.stateChangeSubscription.subscriptionId; } + /** + * The spec's [[control thread state]]: written synchronously the moment an + * operation is accepted, so the NEXT call validates against what has already + * been requested (e.g. close() right after resume() must see 'running'). + * Never exposed — the `state` attribute reports acknowledged reality + * instead. + */ protected _state: ContextState = 'suspended'; + protected readonly audioEventEmitter = new AudioEventEmitter( + globalThis.AudioEventEmitter + ); + + private stateChangeSubscription: ReturnType< + AudioEventEmitter['addAudioEventListener'] + >; + + private onstatechangeCallback: + | ((event: ContextStateChangeEvent) => void) + | null = null; + + /** + * Web Audio API `statechange` event handler. Dispatched by the native context + * from a queued task after the `state` attribute changes — after the + * operation's promise resolution and all of its microtasks, matching the + * spec's media-element-task order. + */ + public get onstatechange(): + | ((event: ContextStateChangeEvent) => void) + | null { + return this.onstatechangeCallback; + } + + public set onstatechange( + callback: ((event: ContextStateChangeEvent) => void) | null + ) { + this.onstatechangeCallback = callback; + } + + /** + * Terminal handler for the native statechange dispatch. Deliberately does NOT + * write `state`: the attribute is published in the resolution task of the + * operation that caused the transition (spec order), and a rapid + * resume()+suspend() pair acknowledges both before either event lands — a + * write here would roll the attribute back to the stale event's value. When a + * native-originated state with no acknowledging promise arrives (the planned + * `interrupted`), its attribute update must be added here explicitly. + * Subclasses extend this to order dependent events (offline `complete`) after + * `statechange`. + */ + protected onNativeStateChange(_event: OnStateChangeEventType): void { + this.onstatechangeCallback?.({ type: 'statechange', target: this }); + } + + /** + * Record that a state transition has been requested ([[control thread + * state]]). + */ + protected setControlState(nextState: ContextState): void { + this._state = nextState; + } + public get currentTime(): number { return this.context.currentTime; } public get state(): ContextState { - return this._state; + return this.context.state as ContextState; } /** diff --git a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts index d715a84d7..eb35a82d1 100644 --- a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts +++ b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts @@ -1,13 +1,29 @@ import { InvalidStateError, NotSupportedError } from '../errors'; import { assertSupportedSampleRate } from '../utils/validation'; +import { OnStateChangeEventType } from '../events/types'; import { IOfflineAudioContext } from '../jsi-interfaces'; import { OfflineAudioContextOptions } from '../types'; import AudioBuffer from './AudioBuffer'; import BaseAudioContext from './BaseAudioContext'; +export interface OfflineAudioCompletionEvent { + type: 'complete'; + target: OfflineAudioContext; + renderedBuffer: AudioBuffer; +} + export default class OfflineAudioContext extends BaseAudioContext { private isRendering: boolean; private duration: number; + /** Set when startRendering() resolves; consumed by the `closed` statechange. */ + private pendingRenderedBuffer: AudioBuffer | null; + + /** + * Web Audio API `complete` event handler, dispatched when startRendering() + * finishes. Kept alongside the promise because plenty of code (and the WPT + * suite) never awaits the promise and relies on this event alone. + */ + public oncomplete: ((event: OfflineAudioCompletionEvent) => void) | null; constructor(options: OfflineAudioContextOptions); constructor(numberOfChannels: number, length: number, sampleRate: number); @@ -41,6 +57,8 @@ export default class OfflineAudioContext extends BaseAudioContext { } this.isRendering = false; + this.oncomplete = null; + this.pendingRenderedBuffer = null; } async resume(): Promise { @@ -56,8 +74,8 @@ export default class OfflineAudioContext extends BaseAudioContext { ); } - this._state = 'running'; - return (this.context as IOfflineAudioContext).resume(); + this.setControlState('running'); + await (this.context as IOfflineAudioContext).resume(); } async suspend(suspendTime: number): Promise { @@ -81,10 +99,12 @@ export default class OfflineAudioContext extends BaseAudioContext { throw new InvalidStateError('the rendering is already finished'); } + // The suspend promise resolves when rendering reaches the suspend point — + // the acknowledgment the spec publishes the state change on. const result = await (this.context as IOfflineAudioContext).suspend( suspendTime ); - this._state = 'suspended'; + this.setControlState('suspended'); return result; } @@ -94,12 +114,34 @@ export default class OfflineAudioContext extends BaseAudioContext { } this.isRendering = true; - this._state = 'running'; + this.setControlState('running'); const audioBuffer = await ( this.context as IOfflineAudioContext ).startRendering(); - this._state = 'closed'; + this.setControlState('closed'); + + const renderedBuffer = new AudioBuffer(audioBuffer); + // `complete` is fired by onNativeStateChange when the native `closed` + // statechange lands — a strictly later task than this continuation — so + // `statechange` always precedes `complete`, per the spec's ordering. + this.pendingRenderedBuffer = renderedBuffer; + + return renderedBuffer; + } + + protected override onNativeStateChange(event: OnStateChangeEventType): void { + super.onNativeStateChange(event); + + if (event.state !== 'closed' || this.pendingRenderedBuffer === null) { + return; + } - return new AudioBuffer(audioBuffer); + const renderedBuffer = this.pendingRenderedBuffer; + this.pendingRenderedBuffer = null; + this.oncomplete?.({ + type: 'complete', + target: this, + renderedBuffer, + }); } } diff --git a/packages/react-native-audio-api/src/events/types.ts b/packages/react-native-audio-api/src/events/types.ts index 34aff0d1a..94c620e46 100644 --- a/packages/react-native-audio-api/src/events/types.ts +++ b/packages/react-native-audio-api/src/events/types.ts @@ -1,5 +1,6 @@ import AudioBuffer from '../core/AudioBuffer'; import { NotificationEvents } from '../system'; +import { ContextState } from '../types'; export interface EventEmptyType {} @@ -36,6 +37,10 @@ export interface OnRecorderErrorEventType { message: string; } +export interface OnStateChangeEventType { + state: ContextState; +} + type SystemEvents = { volumeChange: EventTypeWithValue; interruption: OnInterruptionEventType; @@ -81,6 +86,7 @@ interface AudioAPIEvents { recorderError: OnRecorderErrorEventType; /** `value` is true while an `