mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 07:54:38 +00:00
* 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
225 lines
6.5 KiB
TypeScript
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,
|
|
};
|
|
}
|