mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
* Release the audio session when voice capture and playback go idle Opening Paseo permanently paused music playing in other apps. Once the mic had been used a single time, every subsequent foreground of the app killed the user's audio again until the app was force-quit. iOS AudioEngine.setupAudioSession() takes .playAndRecord/.voiceChat — a non-mixing category — and calls setActive(true), but the only setActive(false) was reachable from tearDown(). Nothing handed the session back after a normal dictation turn, so iOS re-asserted the last category the app set on every foreground. The iOS module had no app-lifecycle handling at all, while Android already had a lifecycle listener; that asymmetry is what made the platforms diverge. Android had the same leak in a different shape: AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE was requested per recording and never abandoned. Add releaseAudioSession() to both platforms and call it once capture stops and the playback queue drains. iOS pauses the engine, calls setActive(false, .notifyOthersOnDeactivation) — this is what tells the other app to resume — then drops to .ambient/.mixWithOthers so any implicit reactivation mixes instead of interrupting. The session is re-acquired lazily on toggleRecording(true)/playPCMData, so voice keeps working; resumeRecordingAndPlayer() re-activates before restarting the engine, which keeps post-interruption resume intact. Releasing is guarded on isRecording/isPlaying, and checkEngineIsRunning() now refuses to start the engine while the session is released, since starting it would silently re-take the session. The JS wrapper probes the native module before calling, because a JS bundle shipped over EAS Update can land on a binary predating this function. * Correct the iOS signing diagnosis in mobile-testing docs The "No Accounts" error does not mean no Apple ID is configured. The account can be registered in Xcode while its Xcode-Token keychain item is missing, which Xcode reports as No Accounts and then compounds by falling back to a stale cached profile — surfacing as a certificate mismatch that sends you chasing the wrong thing. Document the DVTDeveloperAccountManager log line that names the real cause, the two commands that check what is actually configured, and the build-unsigned-then-sign-manually stopgap. * Reacquire Android audio focus when playback restarts releaseAudioSession() abandons the AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE request and pauses the AudioTrack once capture and playback go idle, but nothing took either back. The next playback turn therefore ran with no focus held, so the assistant's response competed with whatever the user was listening to instead of interrupting it. Add acquireAudioSessionIfNeeded(), the mirror of the iOS activateAudioSessionIfNeeded() already called from playPCMData, and use it from both playPCMData() and resumePlayback(). A null audioFocusRequest is what "we released" looks like, so it doubles as the guard against re-requesting focus mid-turn. Reported by Greptile on #2866. * Make Android playback focus reacquisition non-fatal The previous commit dropped PCM data when focus reacquisition did not return AUDIOFOCUS_REQUEST_GRANTED. That is worse than playing unfocused: playPCMData is fire-and-forget from JS, and playAudio() settles on a duration timer rather than a native ack, so the runtime acknowledged and discarded a chunk that was never queued. It also broke delayed focus. The request is built with setAcceptsDelayedFocusGain(true), so AUDIOFOCUS_REQUEST_DELAYED is a normal outcome, but routing it through handleAudioFocusBlocked() abandoned the pending request and the later grant could never arrive. Request focus, then play regardless of the result, and leave the request outstanding so a delayed grant still lands via the listener. Reported by Greptile on #2866. * Track scheduled buffers so iOS actually releases after playback releaseAudioSession() guarded on !speechPlayer.isPlaying, but AVAudioPlayerNode.isPlaying stays true from play() until an explicit stop()/pause() — draining every scheduled buffer does not clear it. The normal completion path is processQueue() -> releaseSessionIfIdle(), and stopPlayback() is only reached from the explicit stop() API, so after the first spoken response the guard blocked every release and the session stayed active. The music-pausing bug returned in full for anyone using voice responses. Count buffers that are scheduled but not yet rendered, decrementing from the .dataPlayedBack completion handler, and guard on that instead. The handler runs on an AVAudioEngine-internal thread, so the count is behind a lock. Both stop() paths reset it, since stop() discards what is scheduled and a stale count would block every later release. Found by Codex review on #2866. * Stop re-taking Android audio focus on every foreground ExpoTwoWayAudioLifeCycleListener calls resumeRecordingAndPlayer() from onResume, and that requested AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE before looking at isRecordingBeforePause. Foregrounding the app while nothing was recording therefore took exclusive focus back and re-paused the user's music — the same "every foreground kills my music" symptom this change set exists to remove, just reached through the Android lifecycle instead of the iOS category. Return early unless capture or playback was actually live when we backgrounded. Found by Codex review on #2866.
351 lines
9.4 KiB
TypeScript
351 lines
9.4 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;
|
|
}
|
|
|
|
/**
|
|
* Release the OS audio session as soon as we are neither capturing nor playing.
|
|
* Holding it keeps the user's background music paused — on iOS the non-mixing
|
|
* `.playAndRecord` category survives backgrounding and is re-asserted on every
|
|
* foreground, so an unreleased session means their music never comes back.
|
|
*/
|
|
function releaseSessionIfIdle(): void {
|
|
if (!refs.initialized || refs.destroyed) {
|
|
return;
|
|
}
|
|
if (refs.captureActive || refs.activePlayback || refs.queue.length > 0) {
|
|
return;
|
|
}
|
|
// The wrapper no-ops on binaries whose native module predates this function.
|
|
native.releaseAudioSession();
|
|
}
|
|
|
|
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;
|
|
releaseSessionIfIdle();
|
|
}
|
|
|
|
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);
|
|
releaseSessionIfIdle();
|
|
},
|
|
|
|
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"));
|
|
}
|
|
releaseSessionIfIdle();
|
|
},
|
|
|
|
clearQueue() {
|
|
while (refs.queue.length > 0) {
|
|
refs.queue.shift()!.reject(new Error("Playback stopped"));
|
|
}
|
|
refs.processingQueue = false;
|
|
releaseSessionIfIdle();
|
|
},
|
|
|
|
isPlaying() {
|
|
return refs.activePlayback !== null;
|
|
},
|
|
};
|
|
}
|