From 81e24621985c13befa35597d52f4dec08844f467 Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 18 Aug 2026 17:35:05 +0200 Subject: [PATCH 1/5] refactor: split the context state to render and control threads --- .../HostObjects/BaseAudioContextHostObject.cpp | 6 ------ .../HostObjects/BaseAudioContextHostObject.h | 1 - .../audioapi/HostObjects/utils/JsEnumParser.cpp | 13 ------------- .../common/cpp/audioapi/core/AudioContext.cpp | 4 ++-- .../src/core/AudioContext.ts | 16 ++++++++++++++++ .../src/core/AudioScheduledSourceNode.ts | 1 + .../src/core/BaseAudioContext.ts | 11 ++++++++++- .../src/core/OfflineAudioContext.ts | 12 ++++-------- .../react-native-audio-api/src/jsi-interfaces.ts | 2 -- 9 files changed, 33 insertions(+), 33 deletions(-) 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 e66d8feaf..fadced0ca 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 @@ -47,7 +47,6 @@ BaseAudioContextHostObject::BaseAudioContextHostObject( addGetters( JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, destination), JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, listener), - JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, state), JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, sampleRate), JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, currentTime)); @@ -85,11 +84,6 @@ JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, listener) { return jsi::Object::createFromHostObject(runtime, listener_); } -JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, state) { - return jsi::String::createFromUtf8( - runtime, js_enum_parser::contextStateToString(context_->getState())); -} - JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, sampleRate) { return {context_->getSampleRate()}; } 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 5457ec121..d79425db0 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 @@ -27,7 +27,6 @@ class BaseAudioContextHostObject : public HostObject { JSI_PROPERTY_GETTER_DECL(destination); JSI_PROPERTY_GETTER_DECL(listener); - JSI_PROPERTY_GETTER_DECL(state); JSI_PROPERTY_GETTER_DECL(sampleRate); JSI_PROPERTY_GETTER_DECL(currentTime); 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 bcfa85d5b..ece4c803f 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 @@ -152,19 +152,6 @@ AudioEvent audioEventFromString(const std::string &event) { throw std::invalid_argument("Unknown audio event: " + event); } -std::string contextStateToString(ContextState state) { - switch (state) { - case ContextState::SUSPENDED: - return "suspended"; - case ContextState::RUNNING: - return "running"; - case ContextState::CLOSED: - return "closed"; - default: - throw std::invalid_argument("Unknown context state"); - } -} - std::string channelCountModeToString(ChannelCountMode mode) { switch (mode) { case ChannelCountMode::MAX: 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 fed66eb8a..252520412 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 @@ -129,7 +129,7 @@ bool AudioContext::resume(const std::shared_ptr> &p } if (result) { - // Visible RUNNING is applied in the promise resolve task (CallInvoker). + // Visible RUNNING is applied inside the resolver ContextPromiseResolver::resolve(promise); } else { ContextPromiseResolver::reject(promise, "Failed to resume audio context."); @@ -155,7 +155,7 @@ bool AudioContext::suspend(const std::shared_ptr> & processAudioEvents(); } - // Visible SUSPENDED is applied in the promise resolve task (CallInvoker). + // Visible SUSPENDED is applied inside the resolver ContextPromiseResolver::resolve(promise); return true; } diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index 8bf61ba42..e1e124bee 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -1,3 +1,4 @@ +import { InvalidStateError } from '../errors'; import { assertSupportedSampleRate } from '../utils/validation'; import { AudioTagHandle } from '../Audio/types'; import { IAudioContext } from '../jsi-interfaces'; @@ -28,14 +29,29 @@ export default class AudioContext extends BaseAudioContext { } async close(): Promise { + if (this.contextState === 'closed') { + throw new InvalidStateError('Cannot close a closed audio context.'); + } + + this.contextState = 'closed'; return (this.context as IAudioContext).close(); } async resume(): Promise { + if (this.contextState === 'closed') { + throw new InvalidStateError('Cannot resume a closed audio context.'); + } + + this.contextState = 'running'; return (this.context as IAudioContext).resume(); } async suspend(): Promise { + if (this.contextState === 'closed') { + throw new InvalidStateError('Cannot suspend a closed audio context.'); + } + + this.contextState = 'suspended'; return (this.context as IAudioContext).suspend(); } diff --git a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts index 61f4bb243..45c27dce7 100644 --- a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts @@ -25,6 +25,7 @@ export default class AudioScheduledSourceNode extends AudioNode { this.hasBeenStarted = true; (this.node as IAudioScheduledSourceNode).start(when); + this.context.markRunningOnSourceStart(); } public stop(when: number = 0): void { diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index d47251a11..b4146b0ed 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -38,12 +38,21 @@ export default class BaseAudioContext { this.sampleRate = context.sampleRate; } + protected contextState: ContextState = 'suspended'; + public get currentTime(): number { return this.context.currentTime; } public get state(): ContextState { - return this.context.state; + return this.contextState; + } + + /* @internal Called by AudioScheduledSourceNode.start(). */ + public markRunningOnSourceStart(): void { + if (this.contextState === 'suspended') { + this.contextState = 'running'; + } } public async decodeAudioData( diff --git a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts index 9d922774f..223b44282 100644 --- a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts +++ b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts @@ -6,7 +6,6 @@ import AudioBuffer from './AudioBuffer'; import BaseAudioContext from './BaseAudioContext'; export default class OfflineAudioContext extends BaseAudioContext { - private isSuspended: boolean; private isRendering: boolean; private duration: number; @@ -41,7 +40,6 @@ export default class OfflineAudioContext extends BaseAudioContext { throw new NotSupportedError('Invalid constructor arguments'); } - this.isSuspended = false; this.isRendering = false; } @@ -52,14 +50,13 @@ export default class OfflineAudioContext extends BaseAudioContext { ); } - if (!this.isSuspended) { + if (!(this.contextState === 'suspended')) { throw new InvalidStateError( 'Cannot resume an OfflineAudioContext that is not suspended' ); } - this.isSuspended = false; - + this.contextState = 'running'; return (this.context as IOfflineAudioContext).resume(); } @@ -80,8 +77,7 @@ export default class OfflineAudioContext extends BaseAudioContext { ); } - this.isSuspended = true; - + this.contextState = 'suspended'; return (this.context as IOfflineAudioContext).suspend(suspendTime); } @@ -91,7 +87,7 @@ export default class OfflineAudioContext extends BaseAudioContext { } this.isRendering = true; - + this.contextState = 'running'; const audioBuffer = await ( this.context as IOfflineAudioContext ).startRendering(); diff --git a/packages/react-native-audio-api/src/jsi-interfaces.ts b/packages/react-native-audio-api/src/jsi-interfaces.ts index 6fb1e6d05..78ea40f8f 100644 --- a/packages/react-native-audio-api/src/jsi-interfaces.ts +++ b/packages/react-native-audio-api/src/jsi-interfaces.ts @@ -14,7 +14,6 @@ import type { ChannelSplitterOptions, ConstantSourceOptions, ConvolverOptions, - ContextState, DelayOptions, FileInfo, GainOptions, @@ -40,7 +39,6 @@ export interface IOscillatorOptions extends Omit< export interface IBaseAudioContext { readonly destination: IAudioDestinationNode; readonly listener: IAudioListener; - readonly state: ContextState; readonly sampleRate: number; readonly currentTime: number; readonly decoder: IAudioDecoder; From 48188d90c383b88b8cfc2ba95863a6f1e29fe683 Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 18 Aug 2026 18:05:39 +0200 Subject: [PATCH 2/5] fix: correct suspend time handling --- .../react-native-audio-api/src/core/AudioContext.ts | 11 +++++++++++ .../src/core/BaseAudioContext.ts | 13 +++++++------ .../src/core/OfflineAudioContext.ts | 5 ++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index e1e124bee..b4c5386d0 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -55,6 +55,17 @@ export default class AudioContext extends BaseAudioContext { return (this.context as IAudioContext).suspend(); } + /** + * @internal Called by AudioScheduledSourceNode.start(). The native driver + * can start implicitly from the first scheduled source, with no promise to + * carry the transition, so this publishes the state that follows. + */ + public override markRunningOnSourceStart(): void { + if (this.contextState === 'suspended') { + this.contextState = 'running'; + } + } + createMediaElementSource( mediaElement: AudioTagHandle ): MediaElementAudioSourceNode { diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index b4146b0ed..ec2a32d86 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -48,12 +48,13 @@ export default class BaseAudioContext { return this.contextState; } - /* @internal Called by AudioScheduledSourceNode.start(). */ - public markRunningOnSourceStart(): void { - if (this.contextState === 'suspended') { - this.contextState = 'running'; - } - } + /** + * @internal Called by AudioScheduledSourceNode.start(). No-op here: only + * AudioContext overrides it, since only AudioContext's native driver can + * start implicitly from a source's start() call. OfflineAudioContext only + * starts rendering from an explicit startRendering() call. + */ + public markRunningOnSourceStart(): void {} public async decodeAudioData( input: DecodeDataInput, diff --git a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts index 223b44282..0167ff83f 100644 --- a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts +++ b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts @@ -77,8 +77,11 @@ export default class OfflineAudioContext extends BaseAudioContext { ); } + const result = await (this.context as IOfflineAudioContext).suspend( + suspendTime + ); this.contextState = 'suspended'; - return (this.context as IOfflineAudioContext).suspend(suspendTime); + return result; } async startRendering(): Promise { From e3fff995fdd7372fb38ef31ccfc28d5458dbfd61 Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 18 Aug 2026 18:35:28 +0200 Subject: [PATCH 3/5] fix: closed after offline context buffer --- packages/react-native-audio-api/src/core/OfflineAudioContext.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts index 0167ff83f..97a75ac11 100644 --- a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts +++ b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts @@ -94,6 +94,7 @@ export default class OfflineAudioContext extends BaseAudioContext { const audioBuffer = await ( this.context as IOfflineAudioContext ).startRendering(); + this.contextState = 'closed'; return new AudioBuffer(audioBuffer); } From 61fa326dc6cc7ad6b272267089df6ebe5b200642 Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 19 Aug 2026 13:37:04 +0200 Subject: [PATCH 4/5] refactor: resuming context in js --- .../audioapi/core/sources/AudioScheduledSourceNode.cpp | 8 -------- packages/react-native-audio-api/src/core/AudioContext.ts | 1 + 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp index 327c51c8c..e31f2a215 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp @@ -25,14 +25,6 @@ AudioScheduledSourceNode::AudioScheduledSourceNode( onEndedEvent_(context->getAudioEventHandlerRegistry()) {} void AudioScheduledSourceNode::start(double when) { -#if !RN_AUDIO_API_TEST - if (std::shared_ptr context = context_.lock()) { - if (auto *audioContext = dynamic_cast(context.get())) { - audioContext->start(); - } - } -#endif // RN_AUDIO_API_TEST - playbackState_ = PlaybackState::SCHEDULED; startTime_ = when; } diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index b4c5386d0..1418c5989 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -63,6 +63,7 @@ export default class AudioContext extends BaseAudioContext { public override markRunningOnSourceStart(): void { if (this.contextState === 'suspended') { this.contextState = 'running'; + (this.context as IAudioContext).resume(); } } From 54deb9e286d459f935d0cd1f58fb7f295f0739ca Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 19 Aug 2026 15:40:35 +0200 Subject: [PATCH 5/5] refactor: rename and one more constraint --- .../src/core/AudioContext.ts | 16 ++++++++-------- .../src/core/BaseAudioContext.ts | 4 ++-- .../src/core/OfflineAudioContext.ts | 14 +++++++++----- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index 1418c5989..8941e656c 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -29,29 +29,29 @@ export default class AudioContext extends BaseAudioContext { } async close(): Promise { - if (this.contextState === 'closed') { + if (this._state === 'closed') { throw new InvalidStateError('Cannot close a closed audio context.'); } - this.contextState = 'closed'; + this._state = 'closed'; return (this.context as IAudioContext).close(); } async resume(): Promise { - if (this.contextState === 'closed') { + if (this._state === 'closed') { throw new InvalidStateError('Cannot resume a closed audio context.'); } - this.contextState = 'running'; + this._state = 'running'; return (this.context as IAudioContext).resume(); } async suspend(): Promise { - if (this.contextState === 'closed') { + if (this._state === 'closed') { throw new InvalidStateError('Cannot suspend a closed audio context.'); } - this.contextState = 'suspended'; + this._state = 'suspended'; return (this.context as IAudioContext).suspend(); } @@ -61,8 +61,8 @@ export default class AudioContext extends BaseAudioContext { * carry the transition, so this publishes the state that follows. */ public override markRunningOnSourceStart(): void { - if (this.contextState === 'suspended') { - this.contextState = 'running'; + if (this._state === 'suspended') { + this._state = 'running'; (this.context as IAudioContext).resume(); } } diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index ec2a32d86..aa20c53ef 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -38,14 +38,14 @@ export default class BaseAudioContext { this.sampleRate = context.sampleRate; } - protected contextState: ContextState = 'suspended'; + protected _state: ContextState = 'suspended'; public get currentTime(): number { return this.context.currentTime; } public get state(): ContextState { - return this.contextState; + return this._state; } /** diff --git a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts index 97a75ac11..d715a84d7 100644 --- a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts +++ b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts @@ -50,13 +50,13 @@ export default class OfflineAudioContext extends BaseAudioContext { ); } - if (!(this.contextState === 'suspended')) { + if (!(this._state === 'suspended')) { throw new InvalidStateError( 'Cannot resume an OfflineAudioContext that is not suspended' ); } - this.contextState = 'running'; + this._state = 'running'; return (this.context as IOfflineAudioContext).resume(); } @@ -77,10 +77,14 @@ export default class OfflineAudioContext extends BaseAudioContext { ); } + if (this._state === 'closed') { + throw new InvalidStateError('the rendering is already finished'); + } + const result = await (this.context as IOfflineAudioContext).suspend( suspendTime ); - this.contextState = 'suspended'; + this._state = 'suspended'; return result; } @@ -90,11 +94,11 @@ export default class OfflineAudioContext extends BaseAudioContext { } this.isRendering = true; - this.contextState = 'running'; + this._state = 'running'; const audioBuffer = await ( this.context as IOfflineAudioContext ).startRendering(); - this.contextState = 'closed'; + this._state = 'closed'; return new AudioBuffer(audioBuffer); }