Files
paseo/packages/app/src/hooks/use-audio-player.web.ts
T

276 lines
8.2 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
export interface AudioPlayerOptions {
isDetecting?: () => boolean;
isSpeaking?: () => boolean;
}
/**
* Web audio player for server-provided audio chunks.
*
* Supports:
* - `audio/pcm` (assumed PCM16 LE, mono, default 24kHz unless `rate=` is present)
* - formats supported by `AudioContext.decodeAudioData` (e.g. mp3)
*/
export function useAudioPlayer(options?: AudioPlayerOptions) {
const [isPlayingState, setIsPlayingState] = useState(false);
type QueuedAudio = {
audioData: Blob;
resolve: (duration: number) => void;
reject: (error: Error) => void;
};
const audioContextRef = useRef<AudioContext | null>(null);
const queueRef = useRef<QueuedAudio[]>([]);
const suppressedQueueRef = useRef<QueuedAudio[]>([]);
const isProcessingQueueRef = useRef(false);
const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const activePlaybackRef = useRef<{
source: AudioBufferSourceNode;
resolve: (duration: number) => void;
reject: (error: Error) => void;
settled: boolean;
} | null>(null);
const decodeAudioData = async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
const maybePromise = context.decodeAudioData(buffer);
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
return maybePromise as Promise<AudioBuffer>;
}
return await new Promise<AudioBuffer>((resolve, reject) => {
context.decodeAudioData(buffer, resolve, reject);
});
};
const 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;
};
const pcm16LeToAudioBuffer = (context: AudioContext, bytes: Uint8Array, sampleRate: number): AudioBuffer => {
const sampleCount = Math.floor(bytes.length / 2);
const audioBuffer = context.createBuffer(1, sampleCount, sampleRate);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < sampleCount; i++) {
const lo = bytes[i * 2]!;
const hi = bytes[i * 2 + 1]!;
let value = (hi << 8) | lo;
if (value & 0x8000) {
value = value - 0x10000;
}
channel[i] = value / 0x8000;
}
return audioBuffer;
};
const ensureContext = async (): Promise<AudioContext> => {
if (audioContextRef.current) {
if (audioContextRef.current.state === "suspended") {
try {
await audioContextRef.current.resume();
} catch {
// Best effort. If this fails due to autoplay policies, a later user gesture
// (or explicit warmup call) can unlock it.
}
}
return audioContextRef.current;
}
const context = new AudioContext();
if (context.state === "suspended") {
try {
await context.resume();
} catch {
// See note above.
}
}
audioContextRef.current = context;
return context;
};
const shouldSuppressPlayback = (): boolean => {
return Boolean(options?.isDetecting?.()) || Boolean(options?.isSpeaking?.());
};
const startCheckingForClearFlags = (): void => {
if (checkIntervalRef.current !== null) {
return;
}
checkIntervalRef.current = setInterval(() => {
const isStillBlocked = shouldSuppressPlayback();
if (!isStillBlocked && suppressedQueueRef.current.length > 0) {
const suppressedItems = [...suppressedQueueRef.current];
suppressedQueueRef.current = [];
queueRef.current = [...suppressedItems, ...queueRef.current];
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
if (!isProcessingQueueRef.current) {
void processQueue();
}
} else if (!isStillBlocked && suppressedQueueRef.current.length === 0) {
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
}
}, 100);
};
const playAudio = async (audioData: Blob): Promise<number> => {
const context = await ensureContext();
const arrayBuffer = await audioData.arrayBuffer();
let audioBuffer: AudioBuffer;
const type = (audioData.type || "").toLowerCase();
if (type.startsWith("audio/pcm")) {
const sampleRate = parsePcmSampleRate(type) ?? 24000;
audioBuffer = pcm16LeToAudioBuffer(context, new Uint8Array(arrayBuffer), sampleRate);
} else {
audioBuffer = await decodeAudioData(context, arrayBuffer);
}
const durationSec = audioBuffer.duration;
const source = context.createBufferSource();
source.buffer = audioBuffer;
source.connect(context.destination);
return await new Promise<number>((resolve, reject) => {
activePlaybackRef.current = { source, resolve, reject, settled: false };
setIsPlayingState(true);
const settleOnce = (fn: () => void) => {
const active = activePlaybackRef.current;
if (!active || active.source !== source || active.settled) {
return;
}
active.settled = true;
activePlaybackRef.current = null;
setIsPlayingState(false);
fn();
};
source.onended = () => {
settleOnce(() => resolve(durationSec));
};
try {
source.start();
} catch (e) {
settleOnce(() => reject(e instanceof Error ? e : new Error(String(e))));
}
});
};
const processQueue = async (): Promise<void> => {
if (isProcessingQueueRef.current || queueRef.current.length === 0) {
return;
}
isProcessingQueueRef.current = true;
while (queueRef.current.length > 0) {
if (shouldSuppressPlayback()) {
suppressedQueueRef.current = [...queueRef.current, ...suppressedQueueRef.current];
queueRef.current = [];
startCheckingForClearFlags();
break;
}
const item = queueRef.current.shift()!;
try {
const duration = await playAudio(item.audioData);
item.resolve(duration);
} catch (error) {
item.reject(error as Error);
}
}
isProcessingQueueRef.current = false;
};
const play = async (audioData: Blob): Promise<number> => {
return await new Promise<number>((resolve, reject) => {
if (shouldSuppressPlayback()) {
suppressedQueueRef.current.push({ audioData, resolve, reject });
startCheckingForClearFlags();
return;
}
queueRef.current.push({ audioData, resolve, reject });
if (!isProcessingQueueRef.current) {
void processQueue();
}
});
};
const stop = (): void => {
// Stop currently playing audio (and reject its promise)
if (activePlaybackRef.current) {
const active = activePlaybackRef.current;
activePlaybackRef.current = null;
try {
active.source.onended = null;
active.source.stop();
} catch {
// ignore
}
if (!active.settled) {
active.settled = true;
active.reject(new Error("Playback stopped"));
}
}
setIsPlayingState(false);
while (queueRef.current.length > 0) {
queueRef.current.shift()!.reject(new Error("Playback stopped"));
}
while (suppressedQueueRef.current.length > 0) {
suppressedQueueRef.current.shift()!.reject(new Error("Playback stopped"));
}
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
isProcessingQueueRef.current = false;
};
const clearQueue = (): void => {
while (queueRef.current.length > 0) {
queueRef.current.shift()!.reject(new Error("Queue cleared"));
}
while (suppressedQueueRef.current.length > 0) {
suppressedQueueRef.current.shift()!.reject(new Error("Queue cleared"));
}
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
};
return useMemo(
() => ({
play,
stop,
isPlaying: () => isPlayingState,
clearQueue,
warmup: async () => {
await ensureContext();
},
}),
[isPlayingState]
);
}