功能:内置公屏叫号音频并仅播号码
需求:屏行天下等设备缺少中文语音服务,speechSynthesis 无法播报;公屏不再播项目名。 实现:内置中文数字和提示语 WAV 素材,统一通过普通 audio 元素顺序组合播放,每次叫号重复三遍,并保留首次点击解锁。 验证:前端 65 项测试通过,生产构建通过,真实浏览器完成 WAV 解码播放且无媒体错误。
This commit is contained in:
143
web/src/hooks/useAudioAnnouncements.ts
Normal file
143
web/src/hooks/useAudioAnnouncements.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user