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