* 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.
330 lines
8.6 KiB
TypeScript
330 lines
8.6 KiB
TypeScript
import type {
|
|
AudioEngine,
|
|
AudioEngineCallbacks,
|
|
AudioPlaybackSource,
|
|
} from "@/voice/audio-engine-types";
|
|
|
|
interface QueuedAudio {
|
|
audio: AudioPlaybackSource;
|
|
resolve: (duration: number) => void;
|
|
reject: (error: Error) => void;
|
|
}
|
|
|
|
interface AudioEngineTraceOptions {
|
|
traceLabel?: string;
|
|
}
|
|
|
|
function parsePcmSampleRate(mimeType: string): number | null {
|
|
const match = /rate=(\d+)/i.exec(mimeType);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const rate = Number(match[1]);
|
|
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
|
}
|
|
|
|
function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array {
|
|
if (fromRate === toRate) {
|
|
return pcm;
|
|
}
|
|
|
|
const inputSamples = Math.floor(pcm.length / 2);
|
|
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
|
|
const out = new Uint8Array(outputSamples * 2);
|
|
const ratio = fromRate / toRate;
|
|
|
|
const readInt16 = (sampleIndex: number): number => {
|
|
const i = sampleIndex * 2;
|
|
if (i + 1 >= pcm.length) {
|
|
return 0;
|
|
}
|
|
const lo = pcm[i];
|
|
const hi = pcm[i + 1];
|
|
let value = (hi << 8) | lo;
|
|
if (value & 0x8000) {
|
|
value = value - 0x10000;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const writeInt16 = (sampleIndex: number, value: number): void => {
|
|
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
|
|
const i = sampleIndex * 2;
|
|
out[i] = clamped & 0xff;
|
|
out[i + 1] = (clamped >> 8) & 0xff;
|
|
};
|
|
|
|
for (let i = 0; i < outputSamples; i++) {
|
|
const srcPos = i * ratio;
|
|
const i0 = Math.floor(srcPos);
|
|
const frac = srcPos - i0;
|
|
const s0 = readInt16(i0);
|
|
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
|
|
writeInt16(i, s0 + (s1 - s0) * frac);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
export function createAudioEngine(
|
|
callbacks: AudioEngineCallbacks,
|
|
_options?: AudioEngineTraceOptions,
|
|
): AudioEngine {
|
|
const native = require("@getpaseo/expo-two-way-audio");
|
|
|
|
const refs: {
|
|
initialized: boolean;
|
|
captureActive: boolean;
|
|
muted: boolean;
|
|
queue: QueuedAudio[];
|
|
processingQueue: boolean;
|
|
playbackTimeout: ReturnType<typeof setTimeout> | null;
|
|
activePlayback: {
|
|
resolve: (duration: number) => void;
|
|
reject: (error: Error) => void;
|
|
settled: boolean;
|
|
} | null;
|
|
destroyed: boolean;
|
|
} = {
|
|
initialized: false,
|
|
captureActive: false,
|
|
muted: false,
|
|
queue: [],
|
|
processingQueue: false,
|
|
playbackTimeout: null,
|
|
activePlayback: null,
|
|
destroyed: false,
|
|
};
|
|
|
|
const microphoneSubscription = native.addExpoTwoWayAudioEventListener(
|
|
"onMicrophoneData",
|
|
(event: { data: Uint8Array }) => {
|
|
if (!refs.captureActive || refs.muted) {
|
|
return;
|
|
}
|
|
const pcm = event.data;
|
|
callbacks.onCaptureData(pcm);
|
|
},
|
|
);
|
|
const volumeSubscription = native.addExpoTwoWayAudioEventListener(
|
|
"onInputVolumeLevelData",
|
|
(event: { data: number }) => {
|
|
if (!refs.captureActive) {
|
|
return;
|
|
}
|
|
const level = refs.muted ? 0 : event.data;
|
|
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) {
|
|
return;
|
|
}
|
|
const success = await native.initialize();
|
|
if (!success) {
|
|
throw new Error("expo-two-way-audio: native initialize() returned false");
|
|
}
|
|
refs.initialized = true;
|
|
}
|
|
|
|
async function ensureMicrophonePermission(): Promise<void> {
|
|
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
|
|
if (!permission?.granted) {
|
|
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
|
|
}
|
|
if (!permission?.granted) {
|
|
throw new Error(
|
|
"Microphone permission is required to capture audio. Please enable microphone access in system settings.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function clearPlaybackTimeout(): void {
|
|
if (refs.playbackTimeout) {
|
|
clearTimeout(refs.playbackTimeout);
|
|
refs.playbackTimeout = null;
|
|
}
|
|
}
|
|
|
|
async function playAudio(audio: AudioPlaybackSource): Promise<number> {
|
|
await ensureInitialized();
|
|
|
|
return await new Promise<number>((resolve, reject) => {
|
|
refs.activePlayback = { resolve, reject, settled: false };
|
|
|
|
audio
|
|
.arrayBuffer()
|
|
.then((arrayBuffer) => {
|
|
const pcm = new Uint8Array(arrayBuffer);
|
|
const inputRate = parsePcmSampleRate(audio.type || "") ?? 24000;
|
|
|
|
// Native AudioEngine expects 16kHz PCM16
|
|
const pcm16k = resamplePcm16(pcm, inputRate, 16000);
|
|
const durationSec = pcm16k.length / 2 / 16000;
|
|
|
|
native.resumePlayback();
|
|
native.playPCMData(pcm16k);
|
|
|
|
clearPlaybackTimeout();
|
|
refs.playbackTimeout = setTimeout(() => {
|
|
clearPlaybackTimeout();
|
|
const active = refs.activePlayback;
|
|
if (!active || active.settled) {
|
|
return;
|
|
}
|
|
active.settled = true;
|
|
refs.activePlayback = null;
|
|
resolve(durationSec);
|
|
}, durationSec * 1000);
|
|
return undefined;
|
|
})
|
|
.catch((error: unknown) => {
|
|
clearPlaybackTimeout();
|
|
const active = refs.activePlayback;
|
|
if (active && !active.settled) {
|
|
active.settled = true;
|
|
refs.activePlayback = null;
|
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function processQueue(): Promise<void> {
|
|
if (refs.processingQueue || refs.queue.length === 0) {
|
|
return;
|
|
}
|
|
|
|
refs.processingQueue = true;
|
|
while (refs.queue.length > 0) {
|
|
const item = refs.queue.shift()!;
|
|
try {
|
|
const duration = await playAudio(item.audio);
|
|
item.resolve(duration);
|
|
} catch (error) {
|
|
item.reject(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
}
|
|
refs.processingQueue = false;
|
|
}
|
|
|
|
return {
|
|
async initialize() {
|
|
await ensureInitialized();
|
|
},
|
|
|
|
async destroy() {
|
|
if (refs.destroyed) {
|
|
return;
|
|
}
|
|
refs.destroyed = true;
|
|
this.stop();
|
|
this.clearQueue();
|
|
if (refs.captureActive) {
|
|
native.toggleRecording(false);
|
|
refs.captureActive = false;
|
|
}
|
|
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.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.captureActive = true;
|
|
} catch (error) {
|
|
const wrapped = error instanceof Error ? error : new Error(String(error));
|
|
callbacks.onError?.(wrapped);
|
|
throw wrapped;
|
|
}
|
|
},
|
|
|
|
async stopCapture() {
|
|
if (refs.captureActive) {
|
|
native.toggleRecording(false);
|
|
}
|
|
refs.captureActive = false;
|
|
refs.muted = false;
|
|
callbacks.onVolumeLevel(0);
|
|
},
|
|
|
|
toggleMute() {
|
|
refs.muted = !refs.muted;
|
|
if (refs.muted) {
|
|
callbacks.onVolumeLevel(0);
|
|
}
|
|
return refs.muted;
|
|
},
|
|
|
|
isMuted() {
|
|
return refs.muted;
|
|
},
|
|
|
|
async play(audio: AudioPlaybackSource) {
|
|
return await new Promise<number>((resolve, reject) => {
|
|
refs.queue.push({ audio, resolve, reject });
|
|
if (!refs.processingQueue) {
|
|
void processQueue();
|
|
}
|
|
});
|
|
},
|
|
|
|
stop() {
|
|
native.stopPlayback();
|
|
clearPlaybackTimeout();
|
|
const active = refs.activePlayback;
|
|
refs.activePlayback = null;
|
|
if (active && !active.settled) {
|
|
active.settled = true;
|
|
active.reject(new Error("Playback stopped"));
|
|
}
|
|
},
|
|
|
|
clearQueue() {
|
|
while (refs.queue.length > 0) {
|
|
refs.queue.shift()!.reject(new Error("Playback stopped"));
|
|
}
|
|
refs.processingQueue = false;
|
|
},
|
|
|
|
isPlaying() {
|
|
return refs.activePlayback !== null;
|
|
},
|
|
};
|
|
}
|