功能:改用前端中文语音合成播报

需求:新厂家设备采用浏览器 speechSynthesis 方式播报叫号。

实现:移除内联 WAV 与 audio 播放队列,两个公屏根据叫号文本调用中文语音合成并连续播报三次。

验证:前端 67 项测试、TypeScript 检查与生产构建通过。
This commit is contained in:
2026-08-10 14:56:42 +08:00
parent 08bce4cab1
commit 5ad8bfcd03
23 changed files with 122 additions and 204 deletions

Binary file not shown.

View File

@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { announcementAudioQueue } from "./useAudioAnnouncements";
const inlineAudio = (source: string) => source.startsWith("data:audio/wav;base64,");
describe("announcementAudioQueue", () => {
it("忽略项目名并把单号码播报素材重复三遍", () => {
const queue = announcementAudioQueue(["东门观光车,请零零零一六号,前往入口。"]);
const once = queue.slice(0, 9);
expect(once).toHaveLength(9);
expect(once.every(inlineAudio)).toBe(true);
expect(once[1]).toBe(once[2]);
expect(once[2]).toBe(once[3]);
expect(queue).toEqual([...once, ...once, ...once]);
});
it("支持连续号码范围", () => {
const queue = announcementAudioQueue(["请一七号至一九号,前往入口。"]);
expect(queue.slice(0, 9).every(inlineAudio)).toBe(true);
expect(queue[1]).not.toBe(queue[2]);
expect(queue[3]).not.toBe(queue[4]);
});
it("音频素材不使用独立 HTTP 路径", () => {
const queue = announcementAudioQueue(["请一号,前往入口。"]);
expect(queue).not.toContain("/audio/queue-voice/please.wav");
expect(queue.every(inlineAudio)).toBe(true);
});
});

View File

@@ -1,136 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import digit0Audio from "../assets/queue-voice/digit-0.wav?inline";
import digit1Audio from "../assets/queue-voice/digit-1.wav?inline";
import digit2Audio from "../assets/queue-voice/digit-2.wav?inline";
import digit3Audio from "../assets/queue-voice/digit-3.wav?inline";
import digit4Audio from "../assets/queue-voice/digit-4.wav?inline";
import digit5Audio from "../assets/queue-voice/digit-5.wav?inline";
import digit6Audio from "../assets/queue-voice/digit-6.wav?inline";
import digit7Audio from "../assets/queue-voice/digit-7.wav?inline";
import digit8Audio from "../assets/queue-voice/digit-8.wav?inline";
import digit9Audio from "../assets/queue-voice/digit-9.wav?inline";
import entranceAudio from "../assets/queue-voice/entrance.wav?inline";
import gapAudio from "../assets/queue-voice/gap.wav?inline";
import numberAudio from "../assets/queue-voice/number.wav?inline";
import pleaseAudio from "../assets/queue-voice/please.wav?inline";
import throughAudio from "../assets/queue-voice/through.wav?inline";
const ANNOUNCEMENT_REPEAT_COUNT = 3;
const GAP_AUDIO = gapAudio;
const DIGIT_AUDIO: Record<string, string> = {
: digit0Audio,
: digit1Audio,
: digit2Audio,
: digit3Audio,
: digit4Audio,
: digit5Audio,
: digit6Audio,
: digit7Audio,
: digit8Audio,
: digit9Audio,
};
const PHRASE_AUDIO = [
["前往入口", entranceAudio],
["请", pleaseAudio],
["号", numberAudio],
["至", throughAudio],
] 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 playQueue = useCallback((sources: string[]) => {
const audio = audioRef.current;
if (!audio || !sources.length) return;
const playbackId = ++playbackIdRef.current;
audio.pause();
audio.onended = null;
audio.onerror = null;
let cursor = 0;
const fail = () => {
if (playbackIdRef.current !== playbackId) return;
audio.onended = null;
audio.onerror = null;
};
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.catch(fail);
}
} catch {
fail();
}
};
playNext();
}, []);
const announce = useCallback((announcements: string[]) => {
if (!announcements.length) return;
const queue = announcementAudioQueue(announcements);
if (queue.length) playQueue(queue);
}, [playQueue]);
useEffect(() => () => {
playbackIdRef.current += 1;
const audio = audioRef.current;
if (!audio) return;
audio.pause();
audio.onended = null;
audio.onerror = null;
}, []);
return { announce, audioRef };
}

View File

@@ -0,0 +1,43 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSpeechAnnouncements } from "./useSpeechAnnouncements";
class SpeechSynthesisUtteranceMock {
lang = "";
rate = 1;
constructor(public text: string) {}
}
describe("useSpeechAnnouncements", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("把叫号文本用中文语音连续播报三次", () => {
const speak = vi.fn();
const cancel = vi.fn();
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
const { result } = renderHook(() => useSpeechAnnouncements());
act(() => result.current.announce(["请零零零一六号,前往入口。"]));
expect(cancel).toHaveBeenCalledOnce();
expect(speak).toHaveBeenCalledTimes(3);
for (const [utterance] of speak.mock.calls) {
expect(utterance).toMatchObject({
text: "请零零零一六号,前往入口。",
lang: "zh-CN",
rate: 0.9,
});
}
});
it("浏览器不支持语音合成时安全跳过", () => {
const { result } = renderHook(() => useSpeechAnnouncements());
expect(() => result.current.announce(["请一号,前往入口。"])).not.toThrow();
});
});

View File

@@ -0,0 +1,33 @@
import { useCallback } from "react";
const ANNOUNCEMENT_REPEAT_COUNT = 3;
function speechSupported(): boolean {
return typeof window !== "undefined"
&& typeof window.speechSynthesis?.speak === "function"
&& 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 announce = useCallback((announcements: string[]) => {
if (!speechSupported() || !announcements.length) 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) {
synthesis.speak(createUtterance(announcement));
}
}
}, []);
return { announce };
}

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { pollingResource } = vi.hoisted(() => ({ const { pollingResource } = vi.hoisted(() => ({
@@ -48,7 +48,6 @@ vi.mock("../hooks/usePollingResource", () => ({
})); }));
import { DisplayPage, forecastRows } from "./DisplayPage"; import { DisplayPage, forecastRows } from "./DisplayPage";
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
describe("DisplayPage", () => { describe("DisplayPage", () => {
beforeEach(() => { beforeEach(() => {
@@ -95,17 +94,22 @@ describe("DisplayPage", () => {
]); ]);
}); });
it("新批次出现时无需人工开启即可连续播报三次叫号内容", () => { it("新批次出现时用前端中文语音连续播报三次叫号内容", () => {
const playedSources: string[] = []; const speak = vi.fn();
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); const cancel = vi.fn();
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) { class SpeechSynthesisUtteranceMock {
playedSources.push(this.getAttribute("src") ?? ""); lang = "";
return Promise.resolve(); rate = 1;
});
constructor(public text: string) {}
}
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
const { container, rerender } = render(<DisplayPage />); const { container, rerender } = render(<DisplayPage />);
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument(); expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
expect(container.querySelector("audio")).toBeNull();
pollingResource.data.current_batch = { pollingResource.data.current_batch = {
batch_number: 3, batch_number: 3,
@@ -119,12 +123,14 @@ describe("DisplayPage", () => {
rerender(<DisplayPage />); rerender(<DisplayPage />);
rerender(<DisplayPage />); rerender(<DisplayPage />);
const expectedQueue = announcementAudioQueue(["请零零零零五号至零零零零六号,前往入口。"]).slice(); expect(cancel).toHaveBeenCalledOnce();
const audio = container.querySelector("audio"); expect(speak).toHaveBeenCalledTimes(3);
expect(audio).not.toBeNull(); for (const [utterance] of speak.mock.calls) {
for (let index = 1; index < expectedQueue.length; index += 1) { expect(utterance).toMatchObject({
fireEvent.ended(audio!); text: "请零零零零五号至零零零零六号,前往入口。",
lang: "zh-CN",
rate: 0.9,
});
} }
expect(playedSources).toEqual(expectedQueue);
}); });
}); });

View File

@@ -2,8 +2,8 @@ import { useEffect, useRef } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { ProjectScreenTile } from "../components/ProjectScreenTile"; import { ProjectScreenTile } from "../components/ProjectScreenTile";
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
import { usePollingResource } from "../hooks/usePollingResource"; import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, formatDateTime, isTimestampStale } from "../lib/format"; import { formatCallAnnouncement, formatDateTime, isTimestampStale } from "../lib/format";
import type { AdminProjectDto } from "../types"; import type { AdminProjectDto } from "../types";
@@ -11,7 +11,7 @@ export { forecastRows } from "../lib/display";
export function DisplayPage() { export function DisplayPage() {
const { token = "" } = useParams(); const { token = "" } = useParams();
const { announce, audioRef } = useAudioAnnouncements(); const { announce } = useSpeechAnnouncements();
const resource = usePollingResource((signal) => api.display(token, signal), { const resource = usePollingResource((signal) => api.display(token, signal), {
enabled: Boolean(token), enabled: Boolean(token),
intervalMs: 3_000, intervalMs: 3_000,
@@ -69,7 +69,6 @@ export function DisplayPage() {
return ( return (
<main className="single-display-screen"> <main className="single-display-screen">
<audio ref={audioRef} preload="auto" aria-hidden="true" />
{(resource.offline || stale || resource.error) ? ( {(resource.offline || stale || resource.error) ? (
<div className="display-alert" role="status"> <div className="display-alert" role="status">
<strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong> <strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong>

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { displayOverview, pollingResource } = vi.hoisted(() => ({ const { displayOverview, pollingResource } = vi.hoisted(() => ({
@@ -38,7 +38,6 @@ vi.mock("../hooks/usePollingResource", () => ({
})); }));
import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage"; import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage";
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
describe("PublicDisplayCenterPage", () => { describe("PublicDisplayCenterPage", () => {
beforeEach(() => { beforeEach(() => {
@@ -63,17 +62,22 @@ describe("PublicDisplayCenterPage", () => {
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
}); });
it("新叫号批次出现时无需人工开启即可连续三次播报号码", () => { it("新叫号批次出现时用前端中文语音连续三次播报号码", () => {
const playedSources: string[] = []; const speak = vi.fn();
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); const cancel = vi.fn();
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) { class SpeechSynthesisUtteranceMock {
playedSources.push(this.getAttribute("src") ?? ""); lang = "";
return Promise.resolve(); rate = 1;
});
constructor(public text: string) {}
}
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
const { container, rerender } = render(<PublicDisplayCenterPage />); const { container, rerender } = render(<PublicDisplayCenterPage />);
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument(); expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
expect(container.querySelector("audio")).toBeNull();
pollingResource.data = { pollingResource.data = {
...pollingResource.data, ...pollingResource.data,
@@ -94,12 +98,14 @@ describe("PublicDisplayCenterPage", () => {
}; };
rerender(<PublicDisplayCenterPage />); rerender(<PublicDisplayCenterPage />);
const expectedQueue = announcementAudioQueue(["请零零零一六号,前往入口。"]).slice(); expect(cancel).toHaveBeenCalledOnce();
const audio = container.querySelector("audio"); expect(speak).toHaveBeenCalledTimes(3);
expect(audio).not.toBeNull(); for (const [utterance] of speak.mock.calls) {
for (let index = 1; index < expectedQueue.length; index += 1) { expect(utterance).toMatchObject({
fireEvent.ended(audio!); text: "请零零零一六号,前往入口。",
lang: "zh-CN",
rate: 0.9,
});
} }
expect(playedSources).toEqual(expectedQueue);
}); });
}); });

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { api } from "../api"; import { api } from "../api";
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback"; import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
import { usePollingResource } from "../hooks/usePollingResource"; import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, isTimestampStale } from "../lib/format"; import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
import type { CallBatchDto } from "../types"; import type { CallBatchDto } from "../types";
import { DisplayCenter } from "./AdminPage"; import { DisplayCenter } from "./AdminPage";
@@ -12,7 +12,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
} }
export function PublicDisplayCenterPage() { export function PublicDisplayCenterPage() {
const { announce, audioRef } = useAudioAnnouncements(); const { announce } = useSpeechAnnouncements();
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 }); const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
const data = resource.data; const data = resource.data;
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000); const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
@@ -37,7 +37,6 @@ export function PublicDisplayCenterPage() {
return ( return (
<div className="app-shell app-shell--admin app-shell--no-primary-action"> <div className="app-shell app-shell--admin app-shell--no-primary-action">
<a className="skip-link" href="#main-content"></a> <a className="skip-link" href="#main-content"></a>
<audio ref={audioRef} preload="auto" aria-hidden="true" />
<header className="app-header"> <header className="app-header">
<div className="brand-lockup" aria-label="景区排队叫号系统"> <div className="brand-lockup" aria-label="景区排队叫号系统">
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span> <span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>