lct-09: звук звонящего на фронте, перебивание, аудио-фон
Очередь AudioBufferSourceNode, а не <audio>: перебивание требует оборвать звук мгновенно и выбросить очередь. Планирование встык по currentTime с упреждением 50 мс — иначе между чанками щелчок. Перебивание двухслойное: фронт гасит звук по громкости микрофона за два кадра (40 мс), сервер подтверждает по VAD за 88–90 мс и присылает tts.cancel. Сервер — авторитет, фронт — скорость. Логика вынесена из WebAudio и проверена в Node: очередь планируется без щелей и наложений, сброс останавливает все чанки, гейт не срабатывает на шуме и ловит начало речи ровно один раз. Нет файла фона — звонок идёт без фона с предупреждением в консоли, а не падает: записи это контент, их кладёт методист. Путь через браузер с живым микрофоном не проверен: шаги записаны в карточке.
This commit is contained in:
parent
456f3cd34d
commit
750ded2991
9 changed files with 402 additions and 98 deletions
141
frontend/src/features/call/useCall.ts
Normal file
141
frontend/src/features/call/useCall.ts
Normal file
|
|
@ -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<ChannelStatus>("connecting");
|
||||
const [phase, setPhase] = useState<CallPhase>("waiting");
|
||||
const [incoming, setIncoming] = useState<CallIncoming | null>(null);
|
||||
const [lines, setLines] = useState<Line[]>([]);
|
||||
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
|
||||
const [callerSpeaking, setCallerSpeaking] = useState(false);
|
||||
const [micOn, setMicOn] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
||||
const capture = useRef<Capture | null>(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 };
|
||||
}
|
||||
Loading…
Reference in a new issue