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
17
frontend/public/ambience/README.md
Normal file
17
frontend/public/ambience/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Аудио-фон происшествия
|
||||||
|
|
||||||
|
Сюда кладутся зацикленные записи, которые звучат под голосом звонящего: треск огня,
|
||||||
|
детский плач, шум улицы, гудки, сирена. Имя файла указывается в сценарии:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
background:
|
||||||
|
loop: fire_crackle.mp3
|
||||||
|
gain_db: -18
|
||||||
|
```
|
||||||
|
|
||||||
|
Требования: зацикливается без щелчка на стыке, моно или стерео, mp3 или ogg,
|
||||||
|
10–30 секунд. Гейн задаётся сценарием и должен быть заметно ниже голоса — фон
|
||||||
|
даёт ощущение места, но не мешает разбирать речь.
|
||||||
|
|
||||||
|
Файла нет — занятие идёт без фона: в консоли браузера останется предупреждение,
|
||||||
|
звонок не прервётся.
|
||||||
|
|
@ -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" /> },
|
||||||
|
|
|
||||||
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 };
|
||||||
|
}
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
45
frontend/src/shared/audio/ambience.ts
Normal file
45
frontend/src/shared/audio/ambience.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
frontend/src/shared/audio/levels.ts
Normal file
55
frontend/src/shared/audio/levels.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
frontend/src/shared/audio/playback.ts
Normal file
71
frontend/src/shared/audio/playback.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue