From 32bdd2fad3c1e683520890c5cbe769be25607603 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sat, 5 Sep 2026 14:36:23 +0700 Subject: [PATCH] fix: resume the audio context when a buffer source starts AudioBufferSourceNode.start() and AudioBufferQueueSourceNode.start() override AudioScheduledSourceNode.start() without calling markRunningOnSourceStart(), so an AudioContext driven only by a buffer source keeps reporting 'suspended' and its native driver is never resumed. Add the call to both overrides, matching the base class and AudioFileSourceNode.play(). --- .../src/core/AudioBufferQueueSourceNode.ts | 1 + .../src/core/AudioBufferSourceNode.ts | 1 + .../context-state-on-source-start.test.ts | 150 ++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 packages/react-native-audio-api/tests/context-state-on-source-start.test.ts diff --git a/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts index 0a3a7f957..760d33ae8 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts @@ -60,6 +60,7 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod this.state = AudioBufferQueueSourceState.PLAYING; (this.node as IAudioBufferQueueSourceNode).start(when, offset); + this.context.markRunningOnSourceStart(); } public override stop(when: number = 0): void { diff --git a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts index b5dbee150..e855e7a5b 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts @@ -112,6 +112,7 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { this.hasBeenStarted = true; (this.node as IAudioBufferSourceNode).start(when, offset, duration); + this.context.markRunningOnSourceStart(); } public get onLoopEnded(): ((event: EventEmptyType) => void) | undefined { diff --git a/packages/react-native-audio-api/tests/context-state-on-source-start.test.ts b/packages/react-native-audio-api/tests/context-state-on-source-start.test.ts new file mode 100644 index 000000000..cdf97bbd4 --- /dev/null +++ b/packages/react-native-audio-api/tests/context-state-on-source-start.test.ts @@ -0,0 +1,150 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +import type { IAudioParam, IBaseAudioContext } from '../src/jsi-interfaces'; + +type AudioContextExports = typeof import('../src/core/AudioContext'); + +jest.mock('react-native', () => ({ + Image: { resolveAssetSource: jest.fn() }, + Platform: { OS: 'ios' }, + TurboModuleRegistry: { + get: jest.fn(() => ({ + install: jest.fn(), + getDevicePreferredSampleRate: jest.fn(() => 48000), + })), + }, +})); + +const SAMPLE_RATE = 48000; + +const createNativeParam = (): IAudioParam => + ({ + value: 0, + defaultValue: 0, + minValue: -3.4028235e38, + maxValue: 3.4028235e38, + setValueAtTime: jest.fn(), + linearRampToValueAtTime: jest.fn(), + exponentialRampToValueAtTime: jest.fn(), + setTargetAtTime: jest.fn(), + setValueCurveAtTime: jest.fn(), + cancelScheduledValues: jest.fn(), + cancelAndHoldAtTime: jest.fn(), + checkCurveExclusion: jest.fn(() => ({ status: 'success' })), + }) as unknown as IAudioParam; + +const createNativeNode = (extra: Record = {}) => ({ + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers', + connect: jest.fn(), + disconnect: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + onEnded: '0', + ...extra, +}); + +const createNativeListener = () => ({ + positionX: createNativeParam(), + positionY: createNativeParam(), + positionZ: createNativeParam(), + forwardX: createNativeParam(), + forwardY: createNativeParam(), + forwardZ: createNativeParam(), + upX: createNativeParam(), + upY: createNativeParam(), + upZ: createNativeParam(), +}); + +const createNativeContext = () => { + const resume = jest.fn().mockResolvedValue(undefined); + + const context = { + sampleRate: SAMPLE_RATE, + currentTime: 0, + state: 'suspended', + baseLatency: 0, + outputLatency: 0, + destination: createNativeNode(), + listener: createNativeListener(), + resume, + suspend: jest.fn().mockResolvedValue(undefined), + close: jest.fn().mockResolvedValue(undefined), + createOscillator: jest.fn(() => + createNativeNode({ + frequency: createNativeParam(), + detune: createNativeParam(), + type: 'sine', + }) + ), + createBufferSource: jest.fn(() => + createNativeNode({ + detune: createNativeParam(), + playbackRate: createNativeParam(), + setBuffer: jest.fn(), + loop: false, + loopStart: 0, + loopEnd: 0, + loopSkip: false, + }) + ), + createBufferQueueSource: jest.fn(() => + createNativeNode({ + detune: createNativeParam(), + playbackRate: createNativeParam(), + enqueueBuffer: jest.fn(), + dequeueBuffer: jest.fn(), + clearBuffers: jest.fn(), + }) + ), + }; + + return { context: context as unknown as IBaseAudioContext, resume }; +}; + +const loadAudioContext = (): AudioContextExports['default'] => { + return (require('../src/core/AudioContext') as AudioContextExports).default; +}; + +const setUpContext = () => { + const { context: nativeContext, resume } = createNativeContext(); + globalThis.createAudioContext = jest.fn( + () => nativeContext + ) as unknown as typeof globalThis.createAudioContext; + + const AudioContext = loadAudioContext(); + return { context: new AudioContext({ sampleRate: SAMPLE_RATE }), resume }; +}; + +describe('starting a source publishes the running context state', () => { + it.each([ + [ + 'createOscillator', + (context: InstanceType) => + context.createOscillator(), + ], + [ + 'createBufferSource', + (context: InstanceType) => + context.createBufferSource(), + ], + [ + 'createBufferQueueSource', + (context: InstanceType) => + context.createBufferQueueSource(), + ], + ])('%s', (_name, createSource) => { + const { context, resume } = setUpContext(); + + expect(context.state).toBe('suspended'); + expect(resume).not.toHaveBeenCalled(); + + createSource(context).start(); + + expect(context.state).toBe('running'); + expect(resume).toHaveBeenCalledTimes(1); + }); +});