mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
fix(voice): serialize capture lifecycle
A capture start already awaiting permission could resume after teardown, and repeated destroy calls did not share completion. Order capture lifecycle operations behind one promise and build the native module declarations before clean-CI typechecking.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -74,7 +74,8 @@ export function createAudioEngine(
|
||||
const refs: {
|
||||
initialized: boolean;
|
||||
captureActive: boolean;
|
||||
captureStopPromise: Promise<void> | null;
|
||||
captureLifecycle: Promise<void>;
|
||||
destroyPromise: Promise<void> | 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<void> {
|
||||
if (refs.captureStopPromise) {
|
||||
await refs.captureStopPromise;
|
||||
return;
|
||||
}
|
||||
function runCaptureLifecycle(operation: () => Promise<void>): Promise<void> {
|
||||
const result = refs.captureLifecycle.then(operation, operation);
|
||||
refs.captureLifecycle = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function stopCaptureNow(): Promise<void> {
|
||||
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<number> {
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user