diff --git a/web/src/assets/queue-voice/digit-0.wav b/web/src/assets/queue-voice/digit-0.wav deleted file mode 100644 index ed8b19a..0000000 Binary files a/web/src/assets/queue-voice/digit-0.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/digit-1.wav b/web/src/assets/queue-voice/digit-1.wav deleted file mode 100644 index 0f84aa5..0000000 Binary files a/web/src/assets/queue-voice/digit-1.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/digit-2.wav b/web/src/assets/queue-voice/digit-2.wav deleted file mode 100644 index 9d144bb..0000000 Binary files a/web/src/assets/queue-voice/digit-2.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/digit-3.wav b/web/src/assets/queue-voice/digit-3.wav deleted file mode 100644 index f199f32..0000000 Binary files a/web/src/assets/queue-voice/digit-3.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/digit-4.wav b/web/src/assets/queue-voice/digit-4.wav deleted file mode 100644 index 7968ef4..0000000 Binary files a/web/src/assets/queue-voice/digit-4.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/digit-5.wav b/web/src/assets/queue-voice/digit-5.wav deleted file mode 100644 index bfc4797..0000000 Binary files a/web/src/assets/queue-voice/digit-5.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/digit-6.wav b/web/src/assets/queue-voice/digit-6.wav deleted file mode 100644 index 0d4b986..0000000 Binary files a/web/src/assets/queue-voice/digit-6.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/digit-7.wav b/web/src/assets/queue-voice/digit-7.wav deleted file mode 100644 index 26b7bdd..0000000 Binary files a/web/src/assets/queue-voice/digit-7.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/digit-8.wav b/web/src/assets/queue-voice/digit-8.wav deleted file mode 100644 index acaa325..0000000 Binary files a/web/src/assets/queue-voice/digit-8.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/digit-9.wav b/web/src/assets/queue-voice/digit-9.wav deleted file mode 100644 index 3a1b0e1..0000000 Binary files a/web/src/assets/queue-voice/digit-9.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/entrance.wav b/web/src/assets/queue-voice/entrance.wav deleted file mode 100644 index 6052326..0000000 Binary files a/web/src/assets/queue-voice/entrance.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/gap.wav b/web/src/assets/queue-voice/gap.wav deleted file mode 100644 index 1d8f74a..0000000 Binary files a/web/src/assets/queue-voice/gap.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/number.wav b/web/src/assets/queue-voice/number.wav deleted file mode 100644 index 86ec51f..0000000 Binary files a/web/src/assets/queue-voice/number.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/please.wav b/web/src/assets/queue-voice/please.wav deleted file mode 100644 index 8547cdb..0000000 Binary files a/web/src/assets/queue-voice/please.wav and /dev/null differ diff --git a/web/src/assets/queue-voice/through.wav b/web/src/assets/queue-voice/through.wav deleted file mode 100644 index 92f9775..0000000 Binary files a/web/src/assets/queue-voice/through.wav and /dev/null differ diff --git a/web/src/hooks/useAudioAnnouncements.test.ts b/web/src/hooks/useAudioAnnouncements.test.ts deleted file mode 100644 index 2b3f89c..0000000 --- a/web/src/hooks/useAudioAnnouncements.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/web/src/hooks/useAudioAnnouncements.ts b/web/src/hooks/useAudioAnnouncements.ts deleted file mode 100644 index 5a90445..0000000 --- a/web/src/hooks/useAudioAnnouncements.ts +++ /dev/null @@ -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 = { - 零: 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(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 }; -} diff --git a/web/src/hooks/useSpeechAnnouncements.test.ts b/web/src/hooks/useSpeechAnnouncements.test.ts new file mode 100644 index 0000000..ce31a0f --- /dev/null +++ b/web/src/hooks/useSpeechAnnouncements.test.ts @@ -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(); + }); +}); diff --git a/web/src/hooks/useSpeechAnnouncements.ts b/web/src/hooks/useSpeechAnnouncements.ts new file mode 100644 index 0000000..474eab9 --- /dev/null +++ b/web/src/hooks/useSpeechAnnouncements.ts @@ -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 }; +} diff --git a/web/src/pages/DisplayPage.test.tsx b/web/src/pages/DisplayPage.test.tsx index 8f61aaa..539db6c 100644 --- a/web/src/pages/DisplayPage.test.tsx +++ b/web/src/pages/DisplayPage.test.tsx @@ -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(); 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(); rerender(); - 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); }); }); diff --git a/web/src/pages/DisplayPage.tsx b/web/src/pages/DisplayPage.tsx index 88aff6c..d61b76e 100644 --- a/web/src/pages/DisplayPage.tsx +++ b/web/src/pages/DisplayPage.tsx @@ -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 (
-