需求:屏行天下等设备缺少中文语音服务,speechSynthesis 无法播报;公屏不再播项目名。 实现:内置中文数字和提示语 WAV 素材,统一通过普通 audio 元素顺序组合播放,每次叫号重复三遍,并保留首次点击解锁。 验证:前端 65 项测试通过,生产构建通过,真实浏览器完成 WAV 解码播放且无媒体错误。
144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
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<string, string> = {
|
|
零: `${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<HTMLAudioElement | null>(null);
|
|
const playbackIdRef = useRef(0);
|
|
const [status, setStatus] = useState<AudioAnnouncementStatus>("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 };
|
|
}
|