diff --git a/frontend/public/ambience/README.md b/frontend/public/ambience/README.md new file mode 100644 index 0000000..dcf43c7 --- /dev/null +++ b/frontend/public/ambience/README.md @@ -0,0 +1,17 @@ +# Аудио-фон происшествия + +Сюда кладутся зацикленные записи, которые звучат под голосом звонящего: треск огня, +детский плач, шум улицы, гудки, сирена. Имя файла указывается в сценарии: + +```yaml +background: + loop: fire_crackle.mp3 + gain_db: -18 +``` + +Требования: зацикливается без щелчка на стыке, моно или стерео, mp3 или ogg, +10–30 секунд. Гейн задаётся сценарием и должен быть заметно ниже голоса — фон +даёт ощущение места, но не мешает разбирать речь. + +Файла нет — занятие идёт без фона: в консоли браузера останется предупреждение, +звонок не прервётся. diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 0a16d13..8c23ce9 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -1,13 +1,13 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; -import { MicCheck } from "@/pages/trainee/MicCheck"; +import { Call } from "@/pages/trainee/Call"; import { Stub } from "@/shared/ui/Stub"; // Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md). // session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой. export const router = createBrowserRouter([ { path: "/", element: }, - { path: "/trainee", element: }, + { path: "/trainee", element: }, { path: "/instructor", element: }, { path: "/wall", element: }, { path: "/dds", element: }, diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts new file mode 100644 index 0000000..ff0364d --- /dev/null +++ b/frontend/src/features/call/useCall.ts @@ -0,0 +1,141 @@ +// Звонок глазами курсанта: канал, микрофон, голос звонящего, перебивание. +// +// Фронт не хранит состояние сессии как истину: всё, что здесь лежит, — это то, +// что прислал сервер (docs/arch/CONTRACT.md). + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { type ChannelStatus, callChannel } from "@/shared/api/ws"; +import { Ambience } from "@/shared/audio/ambience"; +import { type Capture, startCapture } from "@/shared/audio/capture"; +import { EnergyGate } from "@/shared/audio/levels"; +import { CALLER_RATE, Playback } from "@/shared/audio/playback"; +import type { CallIncoming, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated"; + +export interface Line { + speaker: "caller" | "operator"; + text: string; + partial?: boolean; +} + +export type CallPhase = "waiting" | "incoming" | "talking" | "ended"; + +export function useCall(sessionId: string | null) { + const [status, setStatus] = useState("connecting"); + const [phase, setPhase] = useState("waiting"); + const [incoming, setIncoming] = useState(null); + const [lines, setLines] = useState([]); + const [timers, setTimers] = useState([]); + const [callerSpeaking, setCallerSpeaking] = useState(false); + const [micOn, setMicOn] = useState(false); + const [error, setError] = useState(null); + + const channel = useRef | null>(null); + const capture = useRef(null); + const audio = useRef<{ context: AudioContext; playback: Playback; ambience: Ambience } | null>(null); + const gate = useRef(new EnergyGate()); + + const append = useCallback((line: Line) => { + setLines((prev) => { + const withoutPartial = prev.filter((item) => !item.partial); + return [...withoutPartial, line].slice(-50); + }); + }, []); + + useEffect(() => { + if (!sessionId) return; + const ch = callChannel(sessionId, { + onStatus: setStatus, + onBinary: (frame) => audio.current?.playback.enqueue(frame), + onEvent: (event: ServerToTrainee) => { + switch (event.type) { + case "call.incoming": + setIncoming(event); + setPhase("incoming"); + break; + case "call.started": + setPhase("talking"); + break; + case "stt.partial": + if (event.text) append({ speaker: "operator", text: event.text, partial: true }); + break; + case "stt.final": + append({ speaker: "operator", text: event.text }); + break; + case "caller.utterance": + append({ speaker: "caller", text: event.text }); + break; + case "tts.begin": + setCallerSpeaking(true); + break; + case "tts.end": + setCallerSpeaking(false); + break; + case "tts.cancel": + // Сервер подтвердил перебивание: выбросить всё, что не доиграло. + audio.current?.playback.flush(); + setCallerSpeaking(false); + break; + case "bg.start": + void audio.current?.ambience.start(event.loop, event.gain_db); + break; + case "bg.stop": + audio.current?.ambience.stop(); + break; + case "timer.tick": + setTimers(event.timers); + break; + case "call.ended": + setPhase("ended"); + audio.current?.ambience.stop(); + break; + case "error": + setError(event.message); + break; + } + }, + }).connect(); + channel.current = ch; + return () => { + ch.close(); + void capture.current?.stop(); + audio.current?.ambience.stop(); + void audio.current?.context.close(); + audio.current = null; + }; + }, [sessionId, append]); + + const answer = useCallback(async () => { + try { + setError(null); + // Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека. + const context = new AudioContext({ sampleRate: CALLER_RATE }); + audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) }; + + capture.current = await startCapture((frame) => { + const playback = audio.current?.playback; + // Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`. + if (gate.current.push(frame) && playback?.speaking) { + playback.flush(); + setCallerSpeaking(false); + } + channel.current?.sendBinary(frame); + }); + setMicOn(true); + channel.current?.send({ type: "call.answer" }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + const hangup = useCallback(() => { + channel.current?.send({ type: "call.hangup" }); + void capture.current?.stop(); + capture.current = null; + setMicOn(false); + }, []); + + const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []); + + return { status, phase, incoming, lines, timers, callerSpeaking, micOn, error, answer, hangup, hint }; +} diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx new file mode 100644 index 0000000..4375e1f --- /dev/null +++ b/frontend/src/pages/trainee/Call.tsx @@ -0,0 +1,65 @@ +// АРМ курсанта: приём вызова и разговор голосом. +// Карточка КИО — lct-10, таймеры — lct-11. + +import { useCall } from "@/features/call/useCall"; +import { sessionIdFromUrl } from "@/shared/api/session"; + +const STATUS_LABEL: Record = { + connecting: "подключение", + open: "на связи", + reconnecting: "связь потеряна, переподключение", + closed: "отключено", +}; + +export function Call() { + const sessionId = sessionIdFromUrl(); + const call = useCall(sessionId); + + if (!sessionId) { + return ( +
+

АРМ курсанта

+

В адресе нет номера занятия: откройте ссылку вида /trainee?session=…

+
+ ); + } + + return ( +
+

АРМ оператора 112

+ + + + + + + +
Канал{STATUS_LABEL[call.status]}
Вызов{call.incoming ? `${call.incoming.caller_number} · сценарий ${call.incoming.scenario_id} · режим ${call.incoming.mode}` : "ожидание"}
Гарнитура{call.micOn ? "микрофон включён" : "выключен"}
Звонящий{call.callerSpeaking ? "говорит" : "молчит"}
+ +

+ {call.phase === "incoming" && !call.micOn && ( + + )} + {call.micOn && }{" "} + {call.micOn && call.incoming?.mode !== "exam" && ( + + )} +

+ + {call.error &&

{call.error}

} + +

Разговор

+ + + {call.lines.map((line, index) => ( + + + + + ))} + {call.lines.length === 0 && } + +
{line.speaker === "caller" ? "звонящий" : "оператор"}{line.text}
—
+
+ ); +} diff --git a/frontend/src/pages/trainee/MicCheck.tsx b/frontend/src/pages/trainee/MicCheck.tsx deleted file mode 100644 index d23aa80..0000000 --- a/frontend/src/pages/trainee/MicCheck.tsx +++ /dev/null @@ -1,95 +0,0 @@ -// Проверка сквозного пути звука до сервера — веха карточки lct-08. -// Полный АРМ курсанта собирается поверх этого в lct-09 и lct-10. - -import { useEffect, useRef, useState } from "react"; - -import { type Capture, startCapture } from "@/shared/audio/capture"; -import { type ChannelStatus, callChannel } from "@/shared/api/ws"; -import { sessionIdFromUrl } from "@/shared/api/session"; -import type { ServerToTrainee } from "@/shared/types/generated"; - -const STATUS_LABEL: Record = { - connecting: "подключение", - open: "на связи", - reconnecting: "связь потеряна, переподключение", - closed: "отключено", -}; - -export function MicCheck() { - const sessionId = sessionIdFromUrl(); - const [status, setStatus] = useState("connecting"); - const [events, setEvents] = useState([]); - const [framesSent, setFramesSent] = useState(0); - const [micRate, setMicRate] = useState(null); - const [error, setError] = useState(null); - const channel = useRef | null>(null); - const capture = useRef(null); - - useEffect(() => { - if (!sessionId) return; - const ch = callChannel(sessionId, { - onStatus: setStatus, - onEvent: (event) => setEvents((prev) => [event, ...prev].slice(0, 12)), - }).connect(); - channel.current = ch; - return () => { - ch.close(); - void capture.current?.stop(); - }; - }, [sessionId]); - - async function toggleMic() { - if (capture.current) { - await capture.current.stop(); - capture.current = null; - setMicRate(null); - return; - } - try { - setError(null); - capture.current = await startCapture((frame) => { - if (channel.current?.sendBinary(frame)) setFramesSent((n) => n + 1); - }); - setMicRate(capture.current.sampleRate); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - } - - if (!sessionId) { - return ( -
-

АРМ курсанта

-

В адресе нет номера занятия: откройте ссылку вида /trainee?session=…

-
- ); - } - - return ( -
-

АРМ курсанта — проверка звука

- - - - - - - -
Занятие{sessionId}
Канал{STATUS_LABEL[status]}
Микрофон{micRate ? `включён, ${micRate} Гц → 16000 Гц` : "выключен"}
Кадров отправлено{framesSent} ({(framesSent * 0.02).toFixed(1)} с звука)
-

- -

- {error &&

Ошибка микрофона: {error}

} -

События сервера

- - - {events.map((event, index) => ( - - ))} - -
{event.type}{JSON.stringify(event).slice(0, 140)}
-
- ); -} diff --git a/frontend/src/shared/api/ws.ts b/frontend/src/shared/api/ws.ts index f40ac0c..c12e4c9 100644 --- a/frontend/src/shared/api/ws.ts +++ b/frontend/src/shared/api/ws.ts @@ -15,6 +15,8 @@ export type ChannelStatus = "connecting" | "open" | "reconnecting" | "closed"; interface ChannelOptions { onEvent?: (event: In) => void; + /** Бинарный кадр: звук звонящего, без обёртки JSON. */ + onBinary?: (frame: ArrayBuffer) => void; onStatus?: (status: ChannelStatus) => void; } @@ -49,7 +51,10 @@ export class Channel this.options.onStatus?.("open"); }; socket.onmessage = (message) => { - if (typeof message.data !== "string") return; // бинарь курсанту разбирает аудиослой (lct-09) + if (typeof message.data !== "string") { + this.options.onBinary?.(message.data as ArrayBuffer); + return; + } this.options.onEvent?.(JSON.parse(message.data) as In); }; socket.onclose = () => { diff --git a/frontend/src/shared/audio/ambience.ts b/frontend/src/shared/audio/ambience.ts new file mode 100644 index 0000000..762832c --- /dev/null +++ b/frontend/src/shared/audio/ambience.ts @@ -0,0 +1,45 @@ +// Аудио-фон происшествия: треск огня, детский плач, шум улицы. +// +// Файл лежит в сборке и крутится локально — трафика фон не создаёт +// (docs/arch/CONTRACT.md). Гейн заметно ниже голоса: фон даёт ощущение места, +// но не мешает разбирать речь. + +export class Ambience { + private source: AudioBufferSourceNode | null = null; + private gain: GainNode | null = null; + + constructor(private readonly context: AudioContext) {} + + async start(loop: string, gainDb: number): Promise { + this.stop(); + let buffer: AudioBuffer; + try { + const response = await fetch(`/ambience/${loop}`); + if (!response.ok) throw new Error(`${response.status}`); + buffer = await this.context.decodeAudioData(await response.arrayBuffer()); + } catch (error) { + // Записи фона — контент, его добавляет методист. Нет файла — занятие идёт + // без фона, а не падает посреди звонка. + console.warn(`фон ${loop} не загрузился, звонок идёт без него:`, error); + return; + } + + const gain = this.context.createGain(); + gain.gain.value = 10 ** (gainDb / 20); + const source = this.context.createBufferSource(); + source.buffer = buffer; + source.loop = true; + source.connect(gain).connect(this.context.destination); + source.start(); + this.source = source; + this.gain = gain; + } + + stop(): void { + this.source?.stop(); + this.source?.disconnect(); + this.gain?.disconnect(); + this.source = null; + this.gain = null; + } +} diff --git a/frontend/src/shared/audio/levels.ts b/frontend/src/shared/audio/levels.ts new file mode 100644 index 0000000..cbea863 --- /dev/null +++ b/frontend/src/shared/audio/levels.ts @@ -0,0 +1,55 @@ +// Энергетический гейт: локальный триггер перебивания. +// +// Перебивание двухслойное. Фронт гасит звук мгновенно по громкости микрофона, +// сервер подтверждает по VAD и присылает `tts.cancel` (docs/arch/STACK.md). +// Сервер — авторитет, фронт — скорость: на сервере распознавание речи занимает +// около 90 мс, здесь — два кадра, то есть 40. + +/** Порог речи в долях полной шкалы. −40 dBFS: дыхание и шум класса ниже. */ +const SPEECH_RMS = 0.01; + +/** Сколько кадров подряд должны быть громкими. Один кадр — это щелчок или стук. */ +const ATTACK_FRAMES = 2; + +/** Сколько тихих кадров считаем концом реплики — чтобы поймать следующее начало. */ +const RELEASE_FRAMES = 10; + +export class EnergyGate { + private loud = 0; + private quiet = 0; + private speaking = false; + + constructor( + private readonly threshold = SPEECH_RMS, + private readonly attack = ATTACK_FRAMES, + private readonly release = RELEASE_FRAMES, + ) {} + + /** `true` ровно один раз — в кадре, где речь началась. */ + push(frame: ArrayBuffer): boolean { + const samples = new Int16Array(frame); + let sum = 0; + for (let i = 0; i < samples.length; i++) { + const value = samples[i] / 32768; + sum += value * value; + } + const rms = Math.sqrt(sum / Math.max(1, samples.length)); + + if (rms >= this.threshold) { + this.quiet = 0; + this.loud += 1; + if (!this.speaking && this.loud >= this.attack) { + this.speaking = true; + return true; + } + return false; + } + + this.loud = 0; + if (this.speaking && ++this.quiet >= this.release) { + this.speaking = false; + this.quiet = 0; + } + return false; + } +} diff --git a/frontend/src/shared/audio/playback.ts b/frontend/src/shared/audio/playback.ts new file mode 100644 index 0000000..791e478 --- /dev/null +++ b/frontend/src/shared/audio/playback.ts @@ -0,0 +1,71 @@ +// Воспроизведение голоса звонящего. +// +// Очередь `AudioBufferSourceNode`, а не `