diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69689e9c13..46b9dcf45b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,9 @@ jobs: - name: Build server stack run: npm run build:server + - name: Build native app dependency + run: npm run build --workspace=@getpaseo/expo-two-way-audio + - name: Typecheck all packages run: npm run typecheck diff --git a/packages/app/src/voice/audio-engine.native.test.ts b/packages/app/src/voice/audio-engine.native.test.ts index 9b19396fa6..fb04492e11 100644 --- a/packages/app/src/voice/audio-engine.native.test.ts +++ b/packages/app/src/voice/audio-engine.native.test.ts @@ -105,6 +105,60 @@ describe("native audio capture", () => { expect(nativeAudio.tearDown).toHaveBeenCalledOnce(); }); + it("does not finish an in-flight capture start after destruction", async () => { + let resolvePermission!: (permission: { granted: boolean }) => void; + nativeAudio.getMicrophonePermissionsAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePermission = resolve; + }), + ); + const engine = createAudioEngine({ + onCaptureData: vi.fn(), + onVolumeLevel: vi.fn(), + }); + + const started = engine.startCapture(); + await Promise.resolve(); + const destroyed = engine.destroy(); + resolvePermission({ granted: true }); + + await expect(started).rejects.toThrow("Audio engine has been destroyed"); + await destroyed; + + expect(nativeAudio.initialize).not.toHaveBeenCalled(); + expect(nativeAudio.toggleRecording).not.toHaveBeenCalled(); + }); + + it("makes concurrent destruction wait for the same capture drain", async () => { + const engine = createAudioEngine({ + onCaptureData: vi.fn(), + onVolumeLevel: vi.fn(), + }); + + await engine.startCapture(); + let firstFinished = false; + let secondFinished = false; + const first = engine.destroy().then(() => { + firstFinished = true; + return undefined; + }); + const second = engine.destroy().then(() => { + secondFinished = true; + return undefined; + }); + await Promise.resolve(); + + expect(firstFinished).toBe(false); + expect(secondFinished).toBe(false); + expect(nativeAudio.stopRecording).toHaveBeenCalledOnce(); + + nativeAudio.resolveStop(); + await Promise.all([first, second]); + + expect(nativeAudio.tearDown).toHaveBeenCalledOnce(); + }); + it("does not restart capture when destruction overtakes a pending stop", async () => { const engine = createAudioEngine({ onCaptureData: vi.fn(), @@ -115,7 +169,9 @@ describe("native audio capture", () => { const stopped = engine.stopCapture(); const restarted = engine.startCapture(); const destroyed = engine.destroy(); + await Promise.resolve(); + expect(nativeAudio.stopRecording).toHaveBeenCalledOnce(); nativeAudio.resolveStop(); await stopped; await expect(restarted).rejects.toThrow("Audio engine has been destroyed"); diff --git a/packages/app/src/voice/audio-engine.native.ts b/packages/app/src/voice/audio-engine.native.ts index 40440d06ae..20294ab91d 100644 --- a/packages/app/src/voice/audio-engine.native.ts +++ b/packages/app/src/voice/audio-engine.native.ts @@ -74,7 +74,8 @@ export function createAudioEngine( const refs: { initialized: boolean; captureActive: boolean; - captureStopPromise: Promise | null; + captureLifecycle: Promise; + destroyPromise: Promise | null; muted: boolean; queue: QueuedAudio[]; processingQueue: boolean; @@ -88,7 +89,8 @@ export function createAudioEngine( } = { initialized: false, captureActive: false, - captureStopPromise: null, + captureLifecycle: Promise.resolve(), + destroyPromise: null, muted: false, queue: [], processingQueue: false, @@ -180,29 +182,22 @@ export function createAudioEngine( } } - async function stopCapture(): Promise { - if (refs.captureStopPromise) { - await refs.captureStopPromise; - return; - } + function runCaptureLifecycle(operation: () => Promise): Promise { + const result = refs.captureLifecycle.then(operation, operation); + refs.captureLifecycle = result.catch(() => undefined); + return result; + } + + async function stopCaptureNow(): Promise { if (!refs.captureActive) { return; } - const stopping = (async () => { - await native.stopRecording(); - refs.captureActive = false; - refs.muted = false; - callbacks.onVolumeLevel(0); - releaseSessionIfIdle(); - })(); - - refs.captureStopPromise = stopping; - try { - await stopping; - } finally { - refs.captureStopPromise = null; - } + await native.stopRecording(); + refs.captureActive = false; + refs.muted = false; + callbacks.onVolumeLevel(0); + releaseSessionIfIdle(); } async function playAudio(audio: AudioPlaybackSource): Promise { @@ -273,56 +268,70 @@ export function createAudioEngine( await ensureInitialized(); }, - async destroy() { - if (refs.destroyed) { - return; + destroy() { + if (refs.destroyPromise) { + return refs.destroyPromise; } refs.destroyed = true; - await stopCapture(); - this.stop(); - this.clearQueue(); - clearPlaybackTimeout(); - refs.muted = false; - callbacks.onVolumeLevel(0); - if (refs.initialized) { - native.tearDown(); - refs.initialized = false; - } - microphoneSubscription.remove(); - volumeSubscription.remove(); - interruptionSubscription.remove(); - }, - - async startCapture() { - if (refs.captureStopPromise) { - await refs.captureStopPromise; - } - if (refs.destroyed) { - throw new Error("Audio engine has been destroyed"); - } - if (refs.captureActive) { - return; - } - - try { - await ensureMicrophonePermission(); - await ensureInitialized(); - const isRecording = native.toggleRecording(true); - if (!isRecording) { - throw new Error( - "Microphone capture could not start because Android audio focus is unavailable.", - ); + refs.destroyPromise = runCaptureLifecycle(async () => { + await stopCaptureNow(); + this.stop(); + this.clearQueue(); + clearPlaybackTimeout(); + refs.muted = false; + callbacks.onVolumeLevel(0); + if (refs.initialized) { + native.tearDown(); + refs.initialized = false; } - refs.captureActive = true; - } catch (error) { - const wrapped = error instanceof Error ? error : new Error(String(error)); - callbacks.onError?.(wrapped); - throw wrapped; - } + microphoneSubscription.remove(); + volumeSubscription.remove(); + interruptionSubscription.remove(); + }); + return refs.destroyPromise; }, - async stopCapture() { - await stopCapture(); + startCapture() { + if (refs.destroyed) { + return Promise.reject(new Error("Audio engine has been destroyed")); + } + + return runCaptureLifecycle(async () => { + if (refs.destroyed) { + throw new Error("Audio engine has been destroyed"); + } + if (refs.captureActive) { + return; + } + + try { + await ensureMicrophonePermission(); + if (refs.destroyed) { + throw new Error("Audio engine has been destroyed"); + } + await ensureInitialized(); + if (refs.destroyed) { + throw new Error("Audio engine has been destroyed"); + } + const isRecording = native.toggleRecording(true); + if (!isRecording) { + throw new Error( + "Microphone capture could not start because Android audio focus is unavailable.", + ); + } + refs.captureActive = true; + } catch (error) { + const wrapped = error instanceof Error ? error : new Error(String(error)); + if (!refs.destroyed) { + callbacks.onError?.(wrapped); + } + throw wrapped; + } + }); + }, + + stopCapture() { + return runCaptureLifecycle(stopCaptureNow); }, toggleMute() {