lct-09: звук звонящего на фронте, перебивание, аудио-фон

Очередь AudioBufferSourceNode, а не <audio>: перебивание требует
оборвать звук мгновенно и выбросить очередь. Планирование встык по
currentTime с упреждением 50 мс — иначе между чанками щелчок.

Перебивание двухслойное: фронт гасит звук по громкости микрофона за два
кадра (40 мс), сервер подтверждает по VAD за 88–90 мс и присылает
tts.cancel. Сервер — авторитет, фронт — скорость.

Логика вынесена из WebAudio и проверена в Node: очередь планируется без
щелей и наложений, сброс останавливает все чанки, гейт не срабатывает на
шуме и ловит начало речи ровно один раз.

Нет файла фона — звонок идёт без фона с предупреждением в консоли, а не
падает: записи это контент, их кладёт методист.

Путь через браузер с живым микрофоном не проверен: шаги записаны
в карточке.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:13:41 +03:00
commit 750ded2991
9 changed files with 402 additions and 98 deletions

View file

@ -0,0 +1,17 @@
# Аудио-фон происшествия
Сюда кладутся зацикленные записи, которые звучат под голосом звонящего: треск огня,
детский плач, шум улицы, гудки, сирена. Имя файла указывается в сценарии:
```yaml
background:
loop: fire_crackle.mp3
gain_db: -18
```
Требования: зацикливается без щелчка на стыке, моно или стерео, mp3 или ogg,
10–30 секунд. Гейн задаётся сценарием и должен быть заметно ниже голоса — фон
даёт ощущение места, но не мешает разбирать речь.
Файла нет — занятие идёт без фона: в консоли браузера останется предупреждение,
звонок не прервётся.

View file

@ -1,13 +1,13 @@
import { createBrowserRouter, Navigate } from "react-router-dom"; 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"; import { Stub } from "@/shared/ui/Stub";
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md). // Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой. // session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/trainee" replace /> }, { path: "/", element: <Navigate to="/trainee" replace /> },
{ path: "/trainee", element: <MicCheck /> }, { path: "/trainee", element: <Call /> },
{ path: "/instructor", element: <Stub title="Пульт преподавателя" card="lct-17" /> }, { path: "/instructor", element: <Stub title="Пульт преподавателя" card="lct-17" /> },
{ path: "/wall", element: <Stub title="Внешний монитор" card="lct-16" /> }, { path: "/wall", element: <Stub title="Внешний монитор" card="lct-16" /> },
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> }, { path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },

View 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 };
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -15,6 +15,8 @@ export type ChannelStatus = "connecting" | "open" | "reconnecting" | "closed";
interface ChannelOptions<In> { interface ChannelOptions<In> {
onEvent?: (event: In) => void; onEvent?: (event: In) => void;
/** Бинарный кадр: звук звонящего, без обёртки JSON. */
onBinary?: (frame: ArrayBuffer) => void;
onStatus?: (status: ChannelStatus) => void; onStatus?: (status: ChannelStatus) => void;
} }
@ -49,7 +51,10 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
this.options.onStatus?.("open"); this.options.onStatus?.("open");
}; };
socket.onmessage = (message) => { 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); this.options.onEvent?.(JSON.parse(message.data) as In);
}; };
socket.onclose = () => { socket.onclose = () => {

View file

@ -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<void> {
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;
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,71 @@
// Воспроизведение голоса звонящего.
//
// Очередь `AudioBufferSourceNode`, а не `<audio>`: перебивание требует оборвать
// звук мгновенно и выбросить всё, что уже пришло, а `<audio>` так не умеет
// (docs/arch/CONTRACT.md).
export const CALLER_RATE = 24_000; // выход Silero
/** То, что нужно от AudioContext. Отдельным типом — чтобы очередь проверялась без браузера. */
export interface AudioSink {
readonly currentTime: number;
readonly destination: AudioNode;
createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer;
createBufferSource(): AudioBufferSourceNode;
}
//: Небольшое упреждение: чанки планируются чуть вперёд, иначе первый из них
//: обрывается на старте, а между соседними слышен щелчок.
const LEAD_S = 0.05;
export class Playback {
private sources = new Set<AudioBufferSourceNode>();
private nextStart = 0;
constructor(private readonly context: AudioSink) {}
/** Сколько секунд звука ещё не доиграло. */
get remaining(): number {
return Math.max(0, this.nextStart - this.context.currentTime);
}
get speaking(): boolean {
return this.remaining > 0;
}
/** Чанк PCM16 24 кГц в очередь. */
enqueue(pcm: ArrayBuffer): void {
const samples = new Int16Array(pcm);
if (samples.length === 0) return;
const buffer = this.context.createBuffer(1, samples.length, CALLER_RATE);
const channel = buffer.getChannelData(0);
for (let i = 0; i < samples.length; i++) channel[i] = samples[i] / 32768;
const source = this.context.createBufferSource();
source.buffer = buffer;
source.connect(this.context.destination);
const startAt = Math.max(this.context.currentTime + LEAD_S, this.nextStart);
source.start(startAt);
this.nextStart = startAt + buffer.duration;
this.sources.add(source);
source.onended = () => this.sources.delete(source);
}
/** Оборвать звук и выбросить очередь — реакция на перебивание. */
flush(): void {
for (const source of this.sources) {
source.onended = null;
try {
source.stop();
} catch {
// уже остановлен — ничего не делаем
}
source.disconnect();
}
this.sources.clear();
this.nextStart = 0;
}
}