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

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