功能:内置公屏叫号音频并仅播号码

需求:屏行天下等设备缺少中文语音服务,speechSynthesis 无法播报;公屏不再播项目名。

实现:内置中文数字和提示语 WAV 素材,统一通过普通 audio 元素顺序组合播放,每次叫号重复三遍,并保留首次点击解锁。

验证:前端 65 项测试通过,生产构建通过,真实浏览器完成 WAV 解码播放且无媒体错误。
This commit is contained in:
2026-07-31 23:11:39 +08:00
parent c2a5281534
commit 6d911271e6
25 changed files with 267 additions and 168 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,37 @@
import type { RefObject } from "react";
import type { AudioAnnouncementStatus } from "../hooks/useAudioAnnouncements";
interface AudioControlProps {
audioRef: RefObject<HTMLAudioElement | null>;
status: AudioAnnouncementStatus;
onEnable: () => void;
}
export function AudioControl({ audioRef, status, onEnable }: AudioControlProps) {
return (
<>
<audio ref={audioRef} preload="auto" aria-hidden="true" />
{status === "ready" ? (
<aside className="speech-control speech-control--ready" role="status">
<strong></strong>
<span></span>
</aside>
) : (
<aside className={`speech-control${status === "error" ? " speech-control--error" : ""}`} role="status">
<div>
<strong>{status === "error" ? "叫号声音开启失败" : "叫号声音尚未开启"}</strong>
<span>{status === "error" ? "请检查设备媒体音量后重试" : "请点击一次,之后将自动播报"}</span>
</div>
<button
className="button button--primary"
type="button"
disabled={status === "enabling"}
onClick={onEnable}
>
{status === "enabling" ? "正在开启叫号声音" : status === "error" ? "重新开启叫号声音" : "开启叫号声音"}
</button>
</aside>
)}
</>
);
}

View File

@@ -1,38 +0,0 @@
import type { SpeechAnnouncementStatus } from "../hooks/useSpeechAnnouncements";
interface SpeechControlProps {
status: SpeechAnnouncementStatus;
onEnable: () => void;
}
export function SpeechControl({ status, onEnable }: SpeechControlProps) {
if (status === "ready") {
return (
<aside className="speech-control speech-control--ready" role="status">
<strong></strong>
<span></span>
</aside>
);
}
const unsupported = status === "unsupported";
const failed = status === "error";
return (
<aside className={`speech-control${failed || unsupported ? " speech-control--error" : ""}`} role="status">
<div>
<strong>{unsupported ? "当前浏览器不支持叫号声音" : failed ? "叫号声音开启失败" : "叫号声音尚未开启"}</strong>
<span>{unsupported || failed ? "请确认系统已安装中文语音服务" : "Chrome 需要先点击一次,之后才允许自动播报"}</span>
</div>
{!unsupported ? (
<button
className="button button--primary"
type="button"
disabled={status === "enabling"}
onClick={onEnable}
>
{status === "enabling" ? "正在开启叫号声音" : failed ? "重新开启叫号声音" : "开启叫号声音"}
</button>
) : null}
</aside>
);
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { announcementAudioQueue } from "./useAudioAnnouncements";
describe("announcementAudioQueue", () => {
it("忽略项目名并把单号码播报素材重复三遍", () => {
const once = [
"/audio/queue-voice/please.wav",
"/audio/queue-voice/digit-0.wav",
"/audio/queue-voice/digit-0.wav",
"/audio/queue-voice/digit-0.wav",
"/audio/queue-voice/digit-1.wav",
"/audio/queue-voice/digit-6.wav",
"/audio/queue-voice/number.wav",
"/audio/queue-voice/entrance.wav",
"/audio/queue-voice/gap.wav",
];
expect(announcementAudioQueue(["东门观光车,请零零零一六号,前往入口。"]))
.toEqual([...once, ...once, ...once]);
});
it("支持连续号码范围", () => {
const queue = announcementAudioQueue(["请一七号至一九号,前往入口。"]);
expect(queue.slice(0, 9)).toEqual([
"/audio/queue-voice/please.wav",
"/audio/queue-voice/digit-1.wav",
"/audio/queue-voice/digit-7.wav",
"/audio/queue-voice/number.wav",
"/audio/queue-voice/through.wav",
"/audio/queue-voice/digit-1.wav",
"/audio/queue-voice/digit-9.wav",
"/audio/queue-voice/number.wav",
"/audio/queue-voice/entrance.wav",
]);
});
});

View 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 };
}

View File

@@ -1,63 +0,0 @@
import { useCallback, useState } from "react";
export type SpeechAnnouncementStatus = "locked" | "enabling" | "ready" | "error" | "unsupported";
const ANNOUNCEMENT_REPEAT_COUNT = 3;
function speechSupported(): boolean {
return typeof window !== "undefined"
&& "speechSynthesis" in window
&& typeof SpeechSynthesisUtterance !== "undefined";
}
function createUtterance(text: string): SpeechSynthesisUtterance {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "zh-CN";
utterance.rate = 0.9;
return utterance;
}
export function useSpeechAnnouncements() {
const [status, setStatus] = useState<SpeechAnnouncementStatus>(() => (
speechSupported() ? "locked" : "unsupported"
));
const enable = useCallback(() => {
if (!speechSupported()) {
setStatus("unsupported");
return;
}
const synthesis = window.speechSynthesis;
const utterance = createUtterance("叫号声音已开启。");
utterance.onstart = () => setStatus("ready");
utterance.onend = () => setStatus("ready");
utterance.onerror = (event) => {
if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error");
};
setStatus("enabling");
synthesis.cancel();
if (synthesis.paused) synthesis.resume();
synthesis.speak(utterance);
}, []);
const announce = useCallback((announcements: string[]) => {
if (status !== "ready" || !announcements.length || !speechSupported()) return;
const synthesis = window.speechSynthesis;
synthesis.cancel();
if (synthesis.paused) synthesis.resume();
for (const announcement of announcements) {
for (let repeat = 0; repeat < ANNOUNCEMENT_REPEAT_COUNT; repeat += 1) {
const utterance = createUtterance(announcement);
utterance.onerror = (event) => {
if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error");
};
synthesis.speak(utterance);
}
}
}, [status]);
return { announce, enable, status };
}

View File

@@ -48,6 +48,7 @@ vi.mock("../hooks/usePollingResource", () => ({
}));
import { DisplayPage, forecastRows } from "./DisplayPage";
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
describe("DisplayPage", () => {
beforeEach(() => {
@@ -94,33 +95,22 @@ describe("DisplayPage", () => {
]);
});
it("新批次出现时连续播报三次叫号内容", () => {
const speak = vi.fn();
const cancel = vi.fn();
class SpeechSynthesisUtteranceMock {
text: string;
lang = "";
rate = 1;
onstart: (() => void) | null = null;
onend: (() => void) | null = null;
onerror: ((event: { error: string }) => void) | null = null;
constructor(text: string) {
this.text = text;
}
}
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
const { rerender } = render(<DisplayPage />);
it("新批次出现时用内置音频连续播报三次叫号内容", async () => {
const playedSources: string[] = [];
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
playedSources.push(this.getAttribute("src") ?? "");
return Promise.resolve();
});
const { container, rerender } = render(<DisplayPage />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
act(() => {
speak.mock.calls[0][0].onstart?.();
speak.mock.calls[0][0].onerror?.({ error: "interrupted" });
await Promise.resolve();
});
expect(screen.getByText("叫号声音已开启")).toBeVisible();
speak.mockClear();
cancel.mockClear();
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
playedSources.length = 0;
pollingResource.data.current_batch = {
batch_number: 3,
@@ -134,14 +124,12 @@ describe("DisplayPage", () => {
rerender(<DisplayPage />);
rerender(<DisplayPage />);
expect(cancel).toHaveBeenCalledOnce();
expect(speak).toHaveBeenCalledTimes(3);
for (const [utterance] of speak.mock.calls) {
expect(utterance).toMatchObject({
text: "请零零零零五号至零零零零六号,前往入口。",
lang: "zh-CN",
rate: 0.9,
});
const expectedQueue = announcementAudioQueue(["请零零零零五号至零零零零六号,前往入口。"]).slice();
const audio = container.querySelector("audio");
expect(audio).not.toBeNull();
for (let index = 1; index < expectedQueue.length; index += 1) {
fireEvent.ended(audio!);
}
expect(playedSources).toEqual(expectedQueue);
});
});

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { api } from "../api";
import { AudioControl } from "../components/AudioControl";
import { ProjectScreenTile } from "../components/ProjectScreenTile";
import { SpeechControl } from "../components/SpeechControl";
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, formatDateTime, isTimestampStale } from "../lib/format";
import type { AdminProjectDto } from "../types";
@@ -12,7 +12,7 @@ export { forecastRows } from "../lib/display";
export function DisplayPage() {
const { token = "" } = useParams();
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements();
const resource = usePollingResource((signal) => api.display(token, signal), {
enabled: Boolean(token),
intervalMs: 3_000,
@@ -70,7 +70,7 @@ export function DisplayPage() {
return (
<main className="single-display-screen">
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} />
{(resource.offline || stale || resource.error) ? (
<div className="display-alert" role="status">
<strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong>

View File

@@ -38,6 +38,7 @@ vi.mock("../hooks/usePollingResource", () => ({
}));
import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage";
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
describe("PublicDisplayCenterPage", () => {
beforeEach(() => {
@@ -62,28 +63,22 @@ describe("PublicDisplayCenterPage", () => {
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
});
it("新叫号批次出现时连续三次播报项目和号码", () => {
const speak = vi.fn();
class SpeechSynthesisUtteranceMock {
text: string;
lang = "";
rate = 1;
onstart: (() => void) | null = null;
onend: (() => void) | null = null;
onerror: ((event: { error: string }) => void) | null = null;
constructor(text: string) {
this.text = text;
}
}
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel: vi.fn(), paused: false, resume: vi.fn() });
const { rerender } = render(<PublicDisplayCenterPage />);
it("新叫号批次出现时只用内置音频连续三次播报号码", async () => {
const playedSources: string[] = [];
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
playedSources.push(this.getAttribute("src") ?? "");
return Promise.resolve();
});
const { container, rerender } = render(<PublicDisplayCenterPage />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
act(() => speak.mock.calls[0][0].onstart?.());
await Promise.resolve();
});
expect(screen.getByText("叫号声音已开启")).toBeVisible();
speak.mockClear();
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
playedSources.length = 0;
pollingResource.data = {
...pollingResource.data,
@@ -104,12 +99,12 @@ describe("PublicDisplayCenterPage", () => {
};
rerender(<PublicDisplayCenterPage />);
expect(speak).toHaveBeenCalledTimes(3);
for (const [utterance] of speak.mock.calls) {
expect(utterance).toMatchObject({
text: "东门观光车,请零零零一六号,前往入口。",
lang: "zh-CN",
});
const expectedQueue = announcementAudioQueue(["请零零零一六号,前往入口。"]).slice();
const audio = container.querySelector("audio");
expect(audio).not.toBeNull();
for (let index = 1; index < expectedQueue.length; index += 1) {
fireEvent.ended(audio!);
}
expect(playedSources).toEqual(expectedQueue);
});
});

View File

@@ -1,9 +1,9 @@
import { useEffect, useRef } from "react";
import { api } from "../api";
import { AudioControl } from "../components/AudioControl";
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { SpeechControl } from "../components/SpeechControl";
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
import type { CallBatchDto } from "../types";
import { DisplayCenter } from "./AdminPage";
@@ -13,7 +13,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
}
export function PublicDisplayCenterPage() {
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements();
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
const data = resource.data;
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
@@ -29,7 +29,7 @@ export function PublicDisplayCenterPage() {
nextCalls.set(project.id, key);
if (!previousCalls?.has(project.id) || !key || previousCalls.get(project.id) === key) continue;
const announcement = formatCallAnnouncement(batch);
if (announcement) announcements.push(`${project.name}${announcement}`);
if (announcement) announcements.push(announcement);
}
lastCallsRef.current = nextCalls;
announce(announcements);
@@ -38,7 +38,7 @@ export function PublicDisplayCenterPage() {
return (
<div className="app-shell app-shell--admin app-shell--no-primary-action">
<a className="skip-link" href="#main-content"></a>
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} />
<header className="app-header">
<div className="brand-lockup" aria-label="景区排队叫号系统">
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>