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
65
frontend/src/pages/trainee/Call.tsx
Normal file
65
frontend/src/pages/trainee/Call.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// АРМ курсанта: приём вызова и разговор голосом.
|
||||
// Карточка КИО — lct-10, таймеры — lct-11.
|
||||
|
||||
import { useCall } from "@/features/call/useCall";
|
||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
connecting: "подключение",
|
||||
open: "на связи",
|
||||
reconnecting: "связь потеряна, переподключение",
|
||||
closed: "отключено",
|
||||
};
|
||||
|
||||
export function Call() {
|
||||
const sessionId = sessionIdFromUrl();
|
||||
const call = useCall(sessionId);
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>АРМ курсанта</h1>
|
||||
<p className="warn">В адресе нет номера занятия: откройте ссылку вида /trainee?session=…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>АРМ оператора 112</h1>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
<tr><th>Канал</th><td className={call.status === "open" ? "" : "warn"}>{STATUS_LABEL[call.status]}</td></tr>
|
||||
<tr><th>Вызов</th><td>{call.incoming ? `${call.incoming.caller_number} · сценарий ${call.incoming.scenario_id} · режим ${call.incoming.mode}` : "ожидание"}</td></tr>
|
||||
<tr><th>Гарнитура</th><td>{call.micOn ? "микрофон включён" : "выключен"}</td></tr>
|
||||
<tr><th>Звонящий</th><td>{call.callerSpeaking ? "говорит" : "молчит"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
{call.phase === "incoming" && !call.micOn && (
|
||||
<button type="button" onClick={() => void call.answer()}>Ответить</button>
|
||||
)}
|
||||
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}{" "}
|
||||
{call.micOn && call.incoming?.mode !== "exam" && (
|
||||
<button type="button" onClick={call.hint}>Подсказка</button>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{call.error && <p className="violated">{call.error}</p>}
|
||||
|
||||
<h2>Разговор</h2>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{call.lines.map((line, index) => (
|
||||
<tr key={index}>
|
||||
<th>{line.speaker === "caller" ? "звонящий" : "оператор"}</th>
|
||||
<td className={line.partial ? "warn" : ""}>{line.text}</td>
|
||||
</tr>
|
||||
))}
|
||||
{call.lines.length === 0 && <tr><td colSpan={2}>—</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ChannelStatus, string> = {
|
||||
connecting: "подключение",
|
||||
open: "на связи",
|
||||
reconnecting: "связь потеряна, переподключение",
|
||||
closed: "отключено",
|
||||
};
|
||||
|
||||
export function MicCheck() {
|
||||
const sessionId = sessionIdFromUrl();
|
||||
const [status, setStatus] = useState<ChannelStatus>("connecting");
|
||||
const [events, setEvents] = useState<ServerToTrainee[]>([]);
|
||||
const [framesSent, setFramesSent] = useState(0);
|
||||
const [micRate, setMicRate] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
||||
const capture = useRef<Capture | null>(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 (
|
||||
<main className="page">
|
||||
<h1>АРМ курсанта</h1>
|
||||
<p className="warn">В адресе нет номера занятия: откройте ссылку вида /trainee?session=…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>АРМ курсанта — проверка звука</h1>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
<tr><th>Занятие</th><td><code>{sessionId}</code></td></tr>
|
||||
<tr><th>Канал</th><td className={status === "open" ? "" : "warn"}>{STATUS_LABEL[status]}</td></tr>
|
||||
<tr><th>Микрофон</th><td>{micRate ? `включён, ${micRate} Гц → 16000 Гц` : "выключен"}</td></tr>
|
||||
<tr><th>Кадров отправлено</th><td>{framesSent} ({(framesSent * 0.02).toFixed(1)} с звука)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
<button type="button" onClick={toggleMic} disabled={status !== "open"}>
|
||||
{micRate ? "Выключить микрофон" : "Включить микрофон"}
|
||||
</button>
|
||||
</p>
|
||||
{error && <p className="violated">Ошибка микрофона: {error}</p>}
|
||||
<h2>События сервера</h2>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{events.map((event, index) => (
|
||||
<tr key={index}><th>{event.type}</th><td><code>{JSON.stringify(event).slice(0, 140)}</code></td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue