import { useCallback, useEffect, useRef, useState } from "react"; export type AudioAnnouncementStatus = "locked" | "enabling" | "ready" | "error"; const AUDIO_BASE_PATH = "/audio/queue-voice"; const ANNOUNCEMENT_REPEAT_COUNT = 3; const GAP_AUDIO = `${AUDIO_BASE_PATH}/gap.wav`; const DIGIT_AUDIO: Record = { 零: `${AUDIO_BASE_PATH}/digit-0.wav`, 一: `${AUDIO_BASE_PATH}/digit-1.wav`, 二: `${AUDIO_BASE_PATH}/digit-2.wav`, 三: `${AUDIO_BASE_PATH}/digit-3.wav`, 四: `${AUDIO_BASE_PATH}/digit-4.wav`, 五: `${AUDIO_BASE_PATH}/digit-5.wav`, 六: `${AUDIO_BASE_PATH}/digit-6.wav`, 七: `${AUDIO_BASE_PATH}/digit-7.wav`, 八: `${AUDIO_BASE_PATH}/digit-8.wav`, 九: `${AUDIO_BASE_PATH}/digit-9.wav`, }; const PHRASE_AUDIO = [ ["前往入口", `${AUDIO_BASE_PATH}/entrance.wav`], ["请", `${AUDIO_BASE_PATH}/please.wav`], ["号", `${AUDIO_BASE_PATH}/number.wav`], ["至", `${AUDIO_BASE_PATH}/through.wav`], ] as const; function clipsForAnnouncement(announcement: string): string[] { const callStart = announcement.indexOf("请"); if (callStart < 0) return []; const callText = announcement.slice(callStart); const clips: string[] = []; for (let index = 0; index < callText.length;) { const phrase = PHRASE_AUDIO.find(([text]) => callText.startsWith(text, index)); if (phrase) { clips.push(phrase[1]); index += phrase[0].length; continue; } const digit = DIGIT_AUDIO[callText[index]]; if (digit) clips.push(digit); index += 1; } return clips.length ? [...clips, GAP_AUDIO] : []; } export function announcementAudioQueue(announcements: string[]): string[] { const queue: string[] = []; for (const announcement of announcements) { const clips = clipsForAnnouncement(announcement); for (let repeat = 0; repeat < ANNOUNCEMENT_REPEAT_COUNT; repeat += 1) { queue.push(...clips); } } return queue; } export function useAudioAnnouncements() { const audioRef = useRef(null); const playbackIdRef = useRef(0); const [status, setStatus] = useState("locked"); const playQueue = useCallback((sources: string[], onStarted?: () => void) => { const audio = audioRef.current; if (!audio || !sources.length) { setStatus("error"); return; } const playbackId = ++playbackIdRef.current; audio.pause(); audio.onended = null; audio.onerror = null; let cursor = 0; let started = false; const markStarted = () => { if (started || playbackIdRef.current !== playbackId) return; started = true; onStarted?.(); }; const fail = (error?: unknown) => { if (playbackIdRef.current !== playbackId) return; if (error instanceof DOMException && error.name === "AbortError") return; audio.onended = null; audio.onerror = null; setStatus("error"); }; const playNext = () => { if (playbackIdRef.current !== playbackId) return; if (cursor >= sources.length) { audio.onended = null; audio.onerror = null; return; } audio.src = sources[cursor]; cursor += 1; audio.currentTime = 0; audio.onended = playNext; audio.onerror = () => fail(); try { const result = audio.play(); if (result && typeof result.then === "function") { void result.then(markStarted).catch(fail); } else { markStarted(); } } catch (error) { fail(error); } }; playNext(); }, []); const enable = useCallback(() => { setStatus("enabling"); playQueue([`${AUDIO_BASE_PATH}/ready.wav`], () => setStatus("ready")); }, [playQueue]); const announce = useCallback((announcements: string[]) => { if (status !== "ready" || !announcements.length) return; const queue = announcementAudioQueue(announcements); if (queue.length) playQueue(queue); }, [playQueue, status]); useEffect(() => () => { playbackIdRef.current += 1; const audio = audioRef.current; if (!audio) return; audio.pause(); audio.onended = null; audio.onerror = null; }, []); return { announce, audioRef, enable, status }; }