功能:改用前端中文语音合成播报
需求:新厂家设备采用浏览器 speechSynthesis 方式播报叫号。 实现:移除内联 WAV 与 audio 播放队列,两个公屏根据叫号文本调用中文语音合成并连续播报三次。 验证:前端 67 项测试、TypeScript 检查与生产构建通过。
This commit is contained in:
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.
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
43
web/src/hooks/useSpeechAnnouncements.test.ts
Normal file
43
web/src/hooks/useSpeechAnnouncements.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
33
web/src/hooks/useSpeechAnnouncements.ts
Normal file
33
web/src/hooks/useSpeechAnnouncements.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
const { pollingResource } = vi.hoisted(() => ({
|
||||
@@ -48,7 +48,6 @@ vi.mock("../hooks/usePollingResource", () => ({
|
||||
}));
|
||||
|
||||
import { DisplayPage, forecastRows } from "./DisplayPage";
|
||||
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
|
||||
|
||||
describe("DisplayPage", () => {
|
||||
beforeEach(() => {
|
||||
@@ -95,17 +94,22 @@ describe("DisplayPage", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("新批次出现时无需人工开启即可连续播报三次叫号内容", () => {
|
||||
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();
|
||||
});
|
||||
it("新批次出现时用前端中文语音连续播报三次叫号内容", () => {
|
||||
const speak = vi.fn();
|
||||
const cancel = vi.fn();
|
||||
class SpeechSynthesisUtteranceMock {
|
||||
lang = "";
|
||||
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 />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("audio")).toBeNull();
|
||||
|
||||
pollingResource.data.current_batch = {
|
||||
batch_number: 3,
|
||||
@@ -119,12 +123,14 @@ describe("DisplayPage", () => {
|
||||
rerender(<DisplayPage />);
|
||||
rerender(<DisplayPage />);
|
||||
|
||||
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(cancel).toHaveBeenCalledOnce();
|
||||
expect(speak).toHaveBeenCalledTimes(3);
|
||||
for (const [utterance] of speak.mock.calls) {
|
||||
expect(utterance).toMatchObject({
|
||||
text: "请零零零零五号至零零零零六号,前往入口。",
|
||||
lang: "zh-CN",
|
||||
rate: 0.9,
|
||||
});
|
||||
}
|
||||
expect(playedSources).toEqual(expectedQueue);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useRef } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { ProjectScreenTile } from "../components/ProjectScreenTile";
|
||||
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";
|
||||
|
||||
@@ -11,7 +11,7 @@ export { forecastRows } from "../lib/display";
|
||||
|
||||
export function DisplayPage() {
|
||||
const { token = "" } = useParams();
|
||||
const { announce, audioRef } = useAudioAnnouncements();
|
||||
const { announce } = useSpeechAnnouncements();
|
||||
const resource = usePollingResource((signal) => api.display(token, signal), {
|
||||
enabled: Boolean(token),
|
||||
intervalMs: 3_000,
|
||||
@@ -69,7 +69,6 @@ export function DisplayPage() {
|
||||
|
||||
return (
|
||||
<main className="single-display-screen">
|
||||
<audio ref={audioRef} preload="auto" aria-hidden="true" />
|
||||
{(resource.offline || stale || resource.error) ? (
|
||||
<div className="display-alert" role="status">
|
||||
<strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong>
|
||||
|
||||
@@ -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";
|
||||
|
||||
const { displayOverview, pollingResource } = vi.hoisted(() => ({
|
||||
@@ -38,7 +38,6 @@ vi.mock("../hooks/usePollingResource", () => ({
|
||||
}));
|
||||
|
||||
import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage";
|
||||
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
|
||||
|
||||
describe("PublicDisplayCenterPage", () => {
|
||||
beforeEach(() => {
|
||||
@@ -63,17 +62,22 @@ describe("PublicDisplayCenterPage", () => {
|
||||
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("新叫号批次出现时无需人工开启即可连续三次播报号码", () => {
|
||||
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();
|
||||
});
|
||||
it("新叫号批次出现时用前端中文语音连续三次播报号码", () => {
|
||||
const speak = vi.fn();
|
||||
const cancel = vi.fn();
|
||||
class SpeechSynthesisUtteranceMock {
|
||||
lang = "";
|
||||
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 />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("audio")).toBeNull();
|
||||
|
||||
pollingResource.data = {
|
||||
...pollingResource.data,
|
||||
@@ -94,12 +98,14 @@ describe("PublicDisplayCenterPage", () => {
|
||||
};
|
||||
rerender(<PublicDisplayCenterPage />);
|
||||
|
||||
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(cancel).toHaveBeenCalledOnce();
|
||||
expect(speak).toHaveBeenCalledTimes(3);
|
||||
for (const [utterance] of speak.mock.calls) {
|
||||
expect(utterance).toMatchObject({
|
||||
text: "请零零零一六号,前往入口。",
|
||||
lang: "zh-CN",
|
||||
rate: 0.9,
|
||||
});
|
||||
}
|
||||
expect(playedSources).toEqual(expectedQueue);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { api } from "../api";
|
||||
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
|
||||
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";
|
||||
@@ -12,7 +12,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
|
||||
}
|
||||
|
||||
export function PublicDisplayCenterPage() {
|
||||
const { announce, audioRef } = useAudioAnnouncements();
|
||||
const { announce } = useSpeechAnnouncements();
|
||||
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
|
||||
const data = resource.data;
|
||||
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
|
||||
@@ -37,7 +37,6 @@ export function PublicDisplayCenterPage() {
|
||||
return (
|
||||
<div className="app-shell app-shell--admin app-shell--no-primary-action">
|
||||
<a className="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<audio ref={audioRef} preload="auto" aria-hidden="true" />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user