lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить нечего и нельзя; у преподавателя нет входящих. Разделение прав архитектурное и на фронте тоже. Пока соединения нет, события не копятся: истина на сервере, после переподключения придёт актуальное состояние. Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц, помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера выключены: гарнитура эхо не даёт, а обработка портит речь для STT. Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр не того размера отбрасывается с одним предупреждением, а не 50 в секунду. Логирование приложения не было настроено: uvicorn настраивает только свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда в логе не оказалось строки о принятых кадрах. make lesson запускает занятие и печатает ссылки на экраны — пульта преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем. Путь из браузера с живым микрофоном не проверен: нужен человек, шаги записаны в карточке.
This commit is contained in:
parent
f01f2dab76
commit
f24031b5b2
21 changed files with 594 additions and 4 deletions
46
frontend/src/shared/audio/capture.ts
Normal file
46
frontend/src/shared/audio/capture.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Захват микрофона: getUserMedia → AudioWorklet → кадры PCM16 16 кГц по 20 мс.
|
||||
|
||||
import workletUrl from "./worklet.ts?worker&url";
|
||||
|
||||
export interface Capture {
|
||||
readonly sampleRate: number;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startCapture(onFrame: (frame: ArrayBuffer) => void): Promise<Capture> {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
// Оператор работает в гарнитуре: микрофон не слышит динамик, и эхоподавление
|
||||
// не нужно. А шумоподавление и автоусиление браузера портят речь для
|
||||
// распознавания сильнее, чем помогают (docs/arch/STACK.md).
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
},
|
||||
});
|
||||
|
||||
const context = new AudioContext();
|
||||
await context.audioWorklet.addModule(workletUrl);
|
||||
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const processor = new AudioWorkletNode(context, "pcm-capture");
|
||||
processor.port.onmessage = (message: MessageEvent<ArrayBuffer>) => onFrame(message.data);
|
||||
|
||||
// Узел должен быть частью графа, иначе браузер может не вызывать его обработку.
|
||||
// Нулевое усиление — чтобы оператор не слышал в гарнитуре сам себя.
|
||||
const silence = context.createGain();
|
||||
silence.gain.value = 0;
|
||||
source.connect(processor).connect(silence).connect(context.destination);
|
||||
|
||||
return {
|
||||
sampleRate: context.sampleRate,
|
||||
async stop() {
|
||||
processor.port.onmessage = null;
|
||||
source.disconnect();
|
||||
processor.disconnect();
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
await context.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
59
frontend/src/shared/audio/resample.ts
Normal file
59
frontend/src/shared/audio/resample.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Нарезка звука микрофона в кадры 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;
|
||||
}
|
||||
}
|
||||
18
frontend/src/shared/audio/worklet-env.d.ts
vendored
Normal file
18
frontend/src/shared/audio/worklet-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Глобальная область AudioWorklet. В lib.dom её нет: там описан главный поток.
|
||||
|
||||
declare const sampleRate: number;
|
||||
|
||||
declare abstract class AudioWorkletProcessor {
|
||||
readonly port: MessagePort;
|
||||
constructor(options?: { processorOptions?: unknown });
|
||||
abstract process(
|
||||
inputs: Float32Array[][],
|
||||
outputs: Float32Array[][],
|
||||
parameters: Record<string, Float32Array>,
|
||||
): boolean;
|
||||
}
|
||||
|
||||
declare function registerProcessor(
|
||||
name: string,
|
||||
processorCtor: new (options?: { processorOptions?: unknown }) => AudioWorkletProcessor,
|
||||
): void;
|
||||
21
frontend/src/shared/audio/worklet.ts
Normal file
21
frontend/src/shared/audio/worklet.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Процессор захвата: звук микрофона → кадры PCM16 16 кГц по 20 мс.
|
||||
// Работает в отдельном аудиопотоке браузера — главный поток с React не тормозит звук.
|
||||
|
||||
import { FRAME_SAMPLES, Pcm16Framer, TARGET_RATE } from "./resample";
|
||||
|
||||
class PcmCaptureProcessor extends AudioWorkletProcessor {
|
||||
private readonly framer = new Pcm16Framer(sampleRate, TARGET_RATE, FRAME_SAMPLES);
|
||||
|
||||
process(inputs: Float32Array[][]): boolean {
|
||||
const channel = inputs[0]?.[0];
|
||||
if (channel) {
|
||||
for (const frame of this.framer.push(channel)) {
|
||||
// Буфер передаётся, а не копируется: 50 кадров в секунду без лишних аллокаций.
|
||||
this.port.postMessage(frame.buffer, [frame.buffer]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("pcm-capture", PcmCaptureProcessor);
|
||||
Loading…
Reference in a new issue