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
|
|
@ -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 = () => {
|
||||
|
|
|
|||
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