// Звонок глазами курсанта: канал, микрофон, голос звонящего, перебивание. // // Фронт не хранит состояние сессии как истину: всё, что здесь лежит, — это то, // что прислал сервер (docs/arch/CONTRACT.md). import { useCallback, useEffect, useRef, useState } from "react"; import { useSessionReport } from "@/shared/api/http"; 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 CardState, applyPatch, applyState, edit, empty as emptyCard } from "@/features/kio-card/merge"; import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment"; import type { Outcome, CallIncoming, CardBriefing, DDSCode, ServerToTrainee, TimerSnapshot, } from "@/shared/types/generated"; /** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */ const PATCH_DEBOUNCE_MS = 300; export interface Line { speaker: "caller" | "operator"; text: string; source?: "local_llm" | "scenario"; 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 [briefing, setBriefing] = useState(null); const [cardSubmitted, setCardSubmitted] = useState(false); const [lines, setLines] = useState([]); const [timers, setTimers] = useState([]); const [callerSpeaking, setCallerSpeaking] = useState(false); const [micOn, setMicOn] = useState(false); const [card, setCard] = useState(emptyCard); const [checklist, setChecklist] = useState([]); const [selfAssessed, setSelfAssessed] = useState(false); const [scoreReady, setScoreReady] = useState(false); const { report, error: reportError } = useSessionReport(sessionId, scoreReady); const [error, setError] = useState(null); const [paused, setPaused] = useState(false); 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 outbox = useRef>({}); const patchTimer = useRef | null>(null); const textMode = useRef(false); const pausedRef = useRef(false); 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: (next) => { setStatus(next); }, onBinary: (frame) => { if (!pausedRef.current) audio.current?.playback.enqueue(frame); }, onEvent: (event: ServerToTrainee) => { if (event.type === "text.turn.accepted") { append({ speaker: "operator", text: event.text }); return; } if (event.type === "card.briefing") { const briefing = event; textMode.current = true; setBriefing(briefing); setIncoming(null); setCard(applyState(emptyCard, { ...briefing.card })); setCardSubmitted(false); setError(null); return; } 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, source: event.source }); 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 "kio.patch": setCard((prev) => applyPatch(prev, event.fields, event.source)); break; case "timer.tick": setTimers(event.timers); break; case "call.ended": setPhase("ended"); audio.current?.ambience.stop(); if (textMode.current) { setCardSubmitted(true); break; } // Чек-лист открывается только после звонка: во время него это // содержимое подсказок. void fetch(`/api/sessions/${sessionId}/checklist`) .then((response) => (response.ok ? response.json() : [])) .then(setChecklist) .catch(() => setChecklist([])); break; case "score.ready": setScoreReady(true); break; case "session.paused": pausedRef.current = event.paused; setPaused(event.paused); if (event.paused) { audio.current?.playback.flush(); setCallerSpeaking(false); if (patchTimer.current) clearTimeout(patchTimer.current); patchTimer.current = null; } else { // При переподключении сервер первым присылает состояние паузы. // Только после этого повторяем правки, ожидающие подтверждения. queueMicrotask(() => setCard((current) => { if (Object.keys(current.pending).length) { channel.current?.send({ type: "kio.patch", fields: current.pending }); } if (Object.keys(outbox.current).length) { channel.current?.send({ type: "kio.patch", fields: outbox.current }); outbox.current = {}; } return current; })); } 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 () => { if (pausedRef.current) return; try { setError(null); // Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека. const context = new AudioContext({ sampleRate: CALLER_RATE }); audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) }; const startedCapture = await startCapture((frame) => { if (pausedRef.current) return; const playback = audio.current?.playback; // Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`. if (gate.current.push(frame) && playback?.speaking) { playback.flush(); setCallerSpeaking(false); } channel.current?.sendBinary(frame); }); if (pausedRef.current) { await startedCapture.stop(); await context.close(); audio.current = null; return; } capture.current = startedCapture; setMicOn(true); channel.current?.send({ type: "call.answer" }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, []); const hangup = useCallback(() => { if (pausedRef.current) return; channel.current?.send({ type: "call.hangup" }); void capture.current?.stop(); capture.current = null; setMicOn(false); }, []); const hint = useCallback(() => { if (!pausedRef.current) channel.current?.send({ type: "hint.request" }); }, []); const patchKio = useCallback((path: string, value: unknown) => { if (pausedRef.current) return; setCard((prev) => edit(prev, path, value)); outbox.current[path] = value; if (patchTimer.current) clearTimeout(patchTimer.current); patchTimer.current = setTimeout(() => { const fields = outbox.current; outbox.current = {}; if (pausedRef.current) { outbox.current = { ...fields, ...outbox.current }; } else if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields }); }, PATCH_DEBOUNCE_MS); }, []); const dispatch = useCallback((service?: DDSCode) => { if (pausedRef.current) return; channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" }); }, []); const sendText = useCallback((text: string) => { if (pausedRef.current) return false; const clean = text.trim(); if (!clean || clean.length > 1000) return false; return Boolean(channel.current?.send({ type: "text.turn", text: clean })); }, []); const submitCard = useCallback(() => { if (pausedRef.current) return; if (patchTimer.current) clearTimeout(patchTimer.current); patchTimer.current = null; const fields = outbox.current; outbox.current = {}; if (Object.keys(fields).length && !channel.current?.send({ type: "kio.patch", fields })) { setError("Канал отключён: карточка не отправлена."); return; } if (channel.current?.send({ type: "card.submit" })) { setCardSubmitted(true); setError(null); } else { setError("Канал отключён: карточка не отправлена."); } }, []); /** Закрыть вызов не карточкой: справкой или передачей в другой регион. * Отдельное действие, а не «положил трубку»: система должна отличить * осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */ const resolve = useCallback((outcome: Outcome, comment: string) => { if (pausedRef.current) return; channel.current?.send({ type: "call.resolve", outcome, comment }); }, []); const submitSelfAssessment = useCallback((missed: string[], comment: string) => { if (pausedRef.current) return; channel.current?.send({ type: "self_assessment.submit", missed, comment }); setSelfAssessed(true); }, []); return { resolve, status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, paused, card, checklist, selfAssessed, scoreReady, report, reportError, answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText, }; }