Files
paseo/packages/app/src/audio/audio-engine.native.ts
Mohamed Boudra 42806cdbf9 Let plugins play audio on every client (#5976)
* feat(plugins): play audio files on every client

* fix(audio): preserve voice output and isolate waiting cues

* fix(plugins): normalize audio MIME parameters

* refactor(audio): share PCM conversion and keep plugin playback file-only
2026-10-03 16:56:59 +01:00

225 lines
6.5 KiB
TypeScript

import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
import { File, Paths } from "expo-file-system";
import { createPlaybackQueue } from "./playback";
import { playFile } from "./file-playback";
import { playPcm16 } from "./pcm";
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
const refs: {
initialized: boolean;
captureActive: boolean;
muted: boolean;
destroyed: boolean;
} = {
initialized: false,
captureActive: false,
muted: false,
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 || playback.isPlaying()) {
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.",
);
}
}
let nextFileId = 0;
async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise<number> {
const bytes = new Uint8Array(await audio.arrayBuffer());
if (signal.aborted) throw new Error("Playback stopped");
if (audio.type.startsWith("audio/pcm")) {
await ensureInitialized();
return playPcm16(bytes, audio.type, signal, native);
}
// Capture owns its audio session while active. File playback alone must not
// initialize the microphone or the native two-way engine.
if (!refs.captureActive) {
await setAudioModeAsync({
playsInSilentMode: true,
allowsRecording: false,
interruptionMode: "duckOthers",
interruptionModeAndroid: "duckOthers",
});
}
if (signal.aborted) throw new Error("Playback stopped");
// AVPlayer needs a file extension to recognize local encoded audio on iOS.
const extension =
{
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/wave": "wav",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/mp4": "m4a",
"audio/aac": "aac",
"audio/ogg": "ogg",
"audio/flac": "flac",
}[audio.type.split(";")[0].trim()] ?? "audio";
const file = new File(Paths.cache, `paseo-audio-${Date.now()}-${nextFileId++}.${extension}`);
try {
file.write(bytes);
const player = createAudioPlayer(file.uri, {
updateInterval: 100,
keepAudioSessionActive: refs.captureActive,
});
return await playFile(player, signal);
} finally {
if (file.exists) file.delete();
}
}
const playback = createPlaybackQueue(playAudio, releaseSessionIfIdle);
return {
async initialize() {
await ensureInitialized();
},
async destroy() {
if (refs.destroyed) {
return;
}
refs.destroyed = true;
playback.destroy();
if (refs.captureActive) {
native.toggleRecording(false);
refs.captureActive = false;
}
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;
},
play: playback.play,
stop: playback.stop,
clearQueue: playback.clearQueue,
isPlaying: playback.isPlaying,
};
}