lct-hack/frontend/src/shared/audio/playback.ts
Ivan Gerasimov 750ded2991 lct-09: звук звонящего на фронте, перебивание, аудио-фон
Очередь AudioBufferSourceNode, а не <audio>: перебивание требует
оборвать звук мгновенно и выбросить очередь. Планирование встык по
currentTime с упреждением 50 мс — иначе между чанками щелчок.

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

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

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

Путь через браузер с живым микрофоном не проверен: шаги записаны
в карточке.
2026-09-17 21:13:41 +03:00

71 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Воспроизведение голоса звонящего.
//
// Очередь `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;
}
}