// Звонок глазами курсанта: канал, микрофон, голос звонящего, перебивание. // // Фронт не хранит состояние сессии как истину: всё, что здесь лежит, — это то, // что прислал сервер (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 }; }