lct-hack/frontend/src/shared/audio/resample.ts
Ivan Gerasimov f24031b5b2 lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.

Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.

Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.

Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.

make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.

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

59 lines
2.4 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.

// Нарезка звука микрофона в кадры PCM16 16 кГц по 20 мс — формат из контракта
// (docs/arch/CONTRACT.md#аудио-по-websocket). Ресемплинг делает фронт, а не сервер:
// так с микрофона не летит 48 кГц лишнего трафика, и сервер получает ровно то,
// что ждёт GigaAM.
//
// Работает потоком: AudioWorklet отдаёт звук кусками по 128 сэмплов, и граница
// кадра почти никогда не совпадает с границей куска.
export const TARGET_RATE = 16_000;
export const FRAME_SAMPLES = 320; // 20 мс при 16 кГц = 640 байт PCM16
export class Pcm16Framer {
private readonly ratio: number;
private sum = 0;
private count = 0;
private consumed = 0;
private nextBoundary: number;
private frame: Int16Array;
private filled = 0;
constructor(
inputRate: number,
outputRate = TARGET_RATE,
private readonly frameSamples = FRAME_SAMPLES,
) {
if (inputRate < outputRate) {
throw new Error(`частота микрофона ${inputRate} ниже целевой ${outputRate}`);
}
this.ratio = inputRate / outputRate;
this.nextBoundary = this.ratio;
this.frame = new Int16Array(frameSamples);
}
// Усреднение входных сэмплов на интервале одного выходного. Это грубый
// фильтр нижних частот, но без него всё, что выше 8 кГц, наложилось бы
// на речь при прореживании 48 → 16 кГц и испортило распознавание.
push(input: Float32Array): Int16Array[] {
const frames: Int16Array[] = [];
for (let i = 0; i < input.length; i++) {
this.sum += input[i];
this.count += 1;
this.consumed += 1;
if (this.consumed < this.nextBoundary) continue;
const sample = Math.max(-1, Math.min(1, this.sum / this.count));
this.frame[this.filled++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
this.sum = 0;
this.count = 0;
this.nextBoundary += this.ratio;
if (this.filled === this.frameSamples) {
frames.push(this.frame);
this.frame = new Int16Array(this.frameSamples);
this.filled = 0;
}
}
return frames;
}
}