Stop Android audio interruptions crashing voice mode (#1941)
* fix(android): handle denied voice audio focus Android can deny voice-mode audio focus while another system audio owner, such as an incoming call, is active. Treat that as an interruption so resume does not crash the app and JS stops voice mode coherently. * fix(voice): keep interruption state consistent Make native interruption handling payload-aware so iOS resume events do not stop the shared JS runtime, avoid ending voice mode for duck-only Android focus changes, and roll host voice mode back if capture fails after startup enabled it. * fix(voice): avoid duplicate interruption handling Keep exactly one Android audio-focus request active at a time and leave JS capture state untouched for non-terminal interruption events. * fix(android): abandon blocked audio focus requests When Android denies or blocks voice audio focus, abandon the pending focus request so delayed focus gain cannot arrive after voice mode has already stopped. * fix(voice): handle interrupted dictation capture Stop Android resume from replaying after recording restart fails, and propagate native blocked interruptions into dictation so users do not remain in a stale recording state.
This commit is contained in:
@@ -125,6 +125,11 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
onVolumeLevel: (level) => {
|
||||
runtime?.handleCaptureVolume(level);
|
||||
},
|
||||
onInterruption: () => {
|
||||
void runtime?.stopVoice().catch((error) => {
|
||||
console.error("[VoiceEngine] Failed to stop after audio interruption:", error);
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("[VoiceEngine] Capture error:", error);
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
|
||||
const onPcmSegmentRef = useRef(config.onPcmSegment);
|
||||
const onErrorRef = useRef(config.onError);
|
||||
const onInterruptionRef = useRef(config.onInterruption);
|
||||
const [volume, setVolume] = useState(0);
|
||||
const engineRef = useRef<ReturnType<typeof createAudioEngine> | null>(null);
|
||||
|
||||
@@ -30,6 +31,9 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
onError: (error) => {
|
||||
onErrorRef.current?.(error);
|
||||
},
|
||||
onInterruption: () => {
|
||||
onInterruptionRef.current?.();
|
||||
},
|
||||
});
|
||||
return engineRef.current;
|
||||
}, []);
|
||||
@@ -37,7 +41,8 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
useEffect(() => {
|
||||
onPcmSegmentRef.current = config.onPcmSegment;
|
||||
onErrorRef.current = config.onError;
|
||||
}, [config.onPcmSegment, config.onError]);
|
||||
onInterruptionRef.current = config.onInterruption;
|
||||
}, [config.onPcmSegment, config.onError, config.onInterruption]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const engine = getOrCreateEngine();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface DictationAudioSourceConfig {
|
||||
onPcmSegment: (pcm16Base64: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onInterruption?: () => void;
|
||||
}
|
||||
|
||||
export interface DictationAudioSource {
|
||||
|
||||
@@ -184,19 +184,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
});
|
||||
}, [client]);
|
||||
|
||||
const audio = useDictationAudioSource({
|
||||
onPcmSegment: (audioData) => {
|
||||
senderRef.current?.enqueueSegment(audioData);
|
||||
},
|
||||
onError: (err) => {
|
||||
onErrorRef.current?.(err);
|
||||
},
|
||||
});
|
||||
const audioStopRef = useRef(audio.stop);
|
||||
useEffect(() => {
|
||||
audioStopRef.current = audio.stop;
|
||||
}, [audio.stop]);
|
||||
|
||||
const handleStreamingTranscriptionSuccess = useCallback(
|
||||
(text: string, requestId: string) => {
|
||||
setIsProcessing(false);
|
||||
@@ -220,6 +207,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
(failure: unknown) => {
|
||||
const normalized = toError(failure);
|
||||
const failureId = generateMessageId();
|
||||
stopDurationTracking();
|
||||
setIsProcessing(false);
|
||||
isProcessingRef.current = false;
|
||||
isRecordingRef.current = false;
|
||||
@@ -234,9 +222,30 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
reportError(normalized, "Failed to complete dictation");
|
||||
},
|
||||
[reportError],
|
||||
[reportError, stopDurationTracking],
|
||||
);
|
||||
|
||||
const audio = useDictationAudioSource({
|
||||
onPcmSegment: (audioData) => {
|
||||
senderRef.current?.enqueueSegment(audioData);
|
||||
},
|
||||
onError: (err) => {
|
||||
onErrorRef.current?.(err);
|
||||
},
|
||||
onInterruption: () => {
|
||||
try {
|
||||
senderRef.current?.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
handleDictationFailure(new Error("Dictation was interrupted by another audio source."));
|
||||
},
|
||||
});
|
||||
const audioStopRef = useRef(audio.stop);
|
||||
useEffect(() => {
|
||||
audioStopRef.current = audio.stop;
|
||||
}, [audio.stop]);
|
||||
|
||||
const startDictation = useCallback(async () => {
|
||||
if (
|
||||
actionGateRef.current.starting ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface AudioEngineCallbacks {
|
||||
onCaptureData(pcm: Uint8Array): void;
|
||||
onVolumeLevel(level: number): void;
|
||||
onInterruption?(): void;
|
||||
onError?(error: Error): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,21 @@ export function createAudioEngine(
|
||||
callbacks.onVolumeLevel(level);
|
||||
},
|
||||
);
|
||||
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onAudioInterruption",
|
||||
(event: { data: string }) => {
|
||||
if (event.data !== "blocked") {
|
||||
return;
|
||||
}
|
||||
const wasCaptureActive = refs.captureActive;
|
||||
refs.captureActive = false;
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
if (wasCaptureActive) {
|
||||
callbacks.onInterruption?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function ensureInitialized(): Promise<void> {
|
||||
if (refs.initialized) {
|
||||
@@ -234,6 +249,7 @@ export function createAudioEngine(
|
||||
}
|
||||
microphoneSubscription.remove();
|
||||
volumeSubscription.remove();
|
||||
interruptionSubscription.remove();
|
||||
},
|
||||
|
||||
async startCapture() {
|
||||
@@ -244,7 +260,12 @@ export function createAudioEngine(
|
||||
try {
|
||||
await ensureMicrophonePermission();
|
||||
await ensureInitialized();
|
||||
native.toggleRecording(true);
|
||||
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));
|
||||
|
||||
@@ -423,6 +423,22 @@ describe("voice runtime", () => {
|
||||
expect(runtime.shouldPlayVoiceAudio("server-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("rolls daemon voice mode back when capture fails after enabling it", async () => {
|
||||
const adapter = createSessionAdapter();
|
||||
const engine = createAudioEngineMock();
|
||||
vi.mocked(engine.startCapture).mockRejectedValue(new Error("audio focus unavailable"));
|
||||
const { runtime } = createRuntime({ engine });
|
||||
runtime.registerSession(adapter);
|
||||
|
||||
await expect(runtime.startVoice("server-1", "agent-1")).rejects.toThrow(
|
||||
"audio focus unavailable",
|
||||
);
|
||||
|
||||
expect(adapter.setVoiceMode).toHaveBeenNthCalledWith(1, true, "agent-1");
|
||||
expect(adapter.setVoiceMode).toHaveBeenNthCalledWith(2, false);
|
||||
expect(runtime.getSnapshot().phase).toBe("disabled");
|
||||
});
|
||||
|
||||
it("returns an explicit not-ready error when the adapter is missing", async () => {
|
||||
const { runtime } = createRuntime();
|
||||
await expect(runtime.startVoice("server-1", "agent-1")).rejects.toThrow(
|
||||
|
||||
@@ -730,6 +730,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
const previousServerId = state.snapshot.activeServerId;
|
||||
const previousAgentId = state.snapshot.activeAgentId;
|
||||
const generation = state.generation + 1;
|
||||
let enabledCurrentVoiceMode = false;
|
||||
state.generation = generation;
|
||||
state.transportReady = false;
|
||||
patchSnapshot((prev) => ({
|
||||
@@ -759,6 +760,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
|
||||
await deps.engine.initialize();
|
||||
await session.adapter.setVoiceMode(true, agentId);
|
||||
enabledCurrentVoiceMode = true;
|
||||
await deps.engine.startCapture();
|
||||
if (state.generation !== generation) {
|
||||
return;
|
||||
@@ -776,6 +778,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
||||
isMuted: deps.engine.isMuted(),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (enabledCurrentVoiceMode) {
|
||||
await session.adapter.setVoiceMode(false).catch(() => undefined);
|
||||
}
|
||||
await performLocalStop();
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user