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
|
|
|
|
// Типизированный сокет с переподключением.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Три канала — три типа. Направление зашито в тип: у наблюдателя нельзя вызвать
|
|
|
|
|
|
// send, у преподавателя нет входящих. Разделение прав архитектурное, а не
|
|
|
|
|
|
// дисциплинарное (docs/arch/CONTRACT.md#каналы) — и на фронте тоже.
|
|
|
|
|
|
|
|
|
|
|
|
import type {
|
|
|
|
|
|
InstructorToServer,
|
|
|
|
|
|
ServerToObserver,
|
2026-09-17 21:45:47 +03:00
|
|
|
|
ServerToStation,
|
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
|
|
|
|
ServerToTrainee,
|
2026-09-17 21:45:47 +03:00
|
|
|
|
StationToServer,
|
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
|
|
|
|
TraineeToServer,
|
|
|
|
|
|
} from "@/shared/types/generated";
|
|
|
|
|
|
|
|
|
|
|
|
export type ChannelStatus = "connecting" | "open" | "reconnecting" | "closed";
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
interface ChannelOptions<In, Out> {
|
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
|
|
|
|
onEvent?: (event: In) => void;
|
2026-09-17 21:13:41 +03:00
|
|
|
|
/** Бинарный кадр: звук звонящего, без обёртки JSON. */
|
|
|
|
|
|
onBinary?: (frame: ArrayBuffer) => void;
|
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
|
|
|
|
onStatus?: (status: ChannelStatus) => void;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
/** Ключ безопасного идемпотентного события, которое можно повторить после
|
|
|
|
|
|
* краткого разрыва. null запрещает буферизацию команды. */
|
|
|
|
|
|
queueKey?: (event: Out) => string | null;
|
2026-09-26 18:12:27 +03:00
|
|
|
|
/** Подтверждение, что серверный durable snapshot уже содержит команду. */
|
|
|
|
|
|
isAcknowledged?: (incoming: In, pending: Out) => boolean;
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const RECONNECT_MIN_MS = 500;
|
|
|
|
|
|
const RECONNECT_MAX_MS = 5_000;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
const BUFFER_MAX_AGE_MS = 35_000;
|
|
|
|
|
|
const BUFFER_MAX_ITEMS = 100;
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
interface Buffered<Out> { key: string; commandId: string; event: Out; at: number }
|
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
|
|
|
|
|
|
|
|
|
|
export class Channel<In extends { type: string }, Out extends { type: string }> {
|
|
|
|
|
|
private socket: WebSocket | null = null;
|
|
|
|
|
|
private attempt = 0;
|
|
|
|
|
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
private closedByUs = false;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
private buffered: Buffered<Out>[] = [];
|
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
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
private readonly path: string,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
private readonly options: ChannelOptions<In, Out> = {},
|
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
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
|
|
connect(): this {
|
|
|
|
|
|
this.closedByUs = false;
|
|
|
|
|
|
this.open("connecting");
|
|
|
|
|
|
return this;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private open(status: ChannelStatus): void {
|
|
|
|
|
|
this.options.onStatus?.(status);
|
|
|
|
|
|
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
|
|
|
|
const socket = new WebSocket(`${scheme}://${location.host}${this.path}`);
|
|
|
|
|
|
socket.binaryType = "arraybuffer";
|
|
|
|
|
|
|
|
|
|
|
|
socket.onopen = () => {
|
|
|
|
|
|
this.attempt = 0;
|
|
|
|
|
|
this.options.onStatus?.("open");
|
2026-09-24 01:10:49 +03:00
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const pending = this.buffered.filter((item) => now - item.at <= BUFFER_MAX_AGE_MS);
|
2026-09-26 18:12:27 +03:00
|
|
|
|
this.buffered = pending;
|
|
|
|
|
|
for (const item of pending) socket.send(this.encode(item));
|
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
|
|
|
|
};
|
|
|
|
|
|
socket.onmessage = (message) => {
|
2026-09-17 21:13:41 +03:00
|
|
|
|
if (typeof message.data !== "string") {
|
|
|
|
|
|
this.options.onBinary?.(message.data as ArrayBuffer);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-26 18:12:27 +03:00
|
|
|
|
const event = JSON.parse(message.data) as In;
|
|
|
|
|
|
if (event.type === "command.ack" && typeof (event as { command_id?: unknown }).command_id === "string") {
|
|
|
|
|
|
const commandId = (event as unknown as { command_id: string }).command_id;
|
|
|
|
|
|
this.buffered = this.buffered.filter((item) => item.commandId !== commandId);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.options.isAcknowledged) {
|
|
|
|
|
|
this.buffered = this.buffered.filter(
|
|
|
|
|
|
(item) => !this.options.isAcknowledged!(event, item.event),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.options.onEvent?.(event);
|
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
|
|
|
|
};
|
|
|
|
|
|
socket.onclose = () => {
|
|
|
|
|
|
this.socket = null;
|
|
|
|
|
|
if (this.closedByUs) {
|
|
|
|
|
|
this.options.onStatus?.("closed");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Экспоненциальная пауза: при падении сервера вкладки всего класса
|
|
|
|
|
|
// не должны долбить его переподключениями каждую миллисекунду.
|
|
|
|
|
|
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** this.attempt++);
|
|
|
|
|
|
this.options.onStatus?.("reconnecting");
|
|
|
|
|
|
this.timer = setTimeout(() => this.open("reconnecting"), delay);
|
|
|
|
|
|
};
|
|
|
|
|
|
this.socket = socket;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get isOpen(): boolean {
|
|
|
|
|
|
return this.socket?.readyState === WebSocket.OPEN;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
send(event: Out): boolean {
|
2026-09-26 18:12:27 +03:00
|
|
|
|
const key = this.options.queueKey?.(event) ?? null;
|
|
|
|
|
|
if (!key) {
|
|
|
|
|
|
if (!this.isOpen) return false;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
this.socket!.send(JSON.stringify(event));
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
const existing = this.buffered.find((item) => item.key === key);
|
2026-09-26 18:12:27 +03:00
|
|
|
|
let pending: Buffered<Out>;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if (existing) {
|
|
|
|
|
|
// Частые kio.patch объединяем: последнее значение каждого поля важнее,
|
|
|
|
|
|
// но правки разных полей не должны затереть друг друга.
|
|
|
|
|
|
if (existing.event.type === "kio.patch" && event.type === "kio.patch") {
|
|
|
|
|
|
const previousPatch = existing.event as unknown as { fields: Record<string, unknown> };
|
|
|
|
|
|
const nextPatch = event as unknown as { fields: Record<string, unknown> };
|
|
|
|
|
|
existing.event = {
|
|
|
|
|
|
...existing.event,
|
|
|
|
|
|
fields: { ...previousPatch.fields, ...nextPatch.fields },
|
|
|
|
|
|
} as Out;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
existing.event = event;
|
|
|
|
|
|
}
|
|
|
|
|
|
existing.at = Date.now();
|
2026-09-26 18:12:27 +03:00
|
|
|
|
pending = existing;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
} else {
|
2026-09-26 18:12:27 +03:00
|
|
|
|
pending = { key, commandId: crypto.randomUUID(), event, at: Date.now() };
|
|
|
|
|
|
this.buffered.push(pending);
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
|
|
|
|
|
|
}
|
2026-09-26 18:12:27 +03:00
|
|
|
|
// Keep a replay-safe command (for at most BUFFER_MAX_AGE_MS) until an event
|
|
|
|
|
|
// emitted after the server's durable checkpoint proves it was applied. A
|
|
|
|
|
|
// send() on an open socket is not itself an acknowledgement: the connection
|
|
|
|
|
|
// may die before the server receives it or the response reaches this browser.
|
|
|
|
|
|
if (this.isOpen) this.socket!.send(this.encode(pending));
|
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
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
private encode(item: Buffered<Out>): string {
|
|
|
|
|
|
return JSON.stringify({ ...item.event, _command_id: item.commandId });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
sendBinary(frame: ArrayBuffer): boolean {
|
|
|
|
|
|
if (!this.isOpen) return false;
|
|
|
|
|
|
this.socket!.send(frame);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
close(): void {
|
|
|
|
|
|
this.closedByUs = true;
|
|
|
|
|
|
if (this.timer) clearTimeout(this.timer);
|
|
|
|
|
|
this.socket?.close();
|
2026-09-24 01:10:49 +03:00
|
|
|
|
this.buffered = [];
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
export const callChannel = (
|
|
|
|
|
|
sessionId: string,
|
|
|
|
|
|
options?: ChannelOptions<ServerToTrainee, TraineeToServer>,
|
|
|
|
|
|
) => new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, {
|
|
|
|
|
|
...options,
|
|
|
|
|
|
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
isAcknowledged: (incoming, pending) => {
|
|
|
|
|
|
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|
|
|
|
|
|
|| incoming.source !== "operator") return false;
|
|
|
|
|
|
return Object.entries(pending.fields).every(([key, value]) =>
|
|
|
|
|
|
sameJsonValue(value, incoming.fields[key]),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-09-24 01:10:49 +03:00
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
|
|
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
|
2026-09-24 01:10:49 +03:00
|
|
|
|
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver, never>) =>
|
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
|
|
|
|
new Channel<ServerToObserver, never>(`/ws/observe/${sessionId}`, options);
|
|
|
|
|
|
|
2026-09-17 21:45:47 +03:00
|
|
|
|
/** Станция ДДС: только JSON, аудио здесь нет вообще. */
|
|
|
|
|
|
export const stationChannel = (
|
|
|
|
|
|
sessionId: string,
|
|
|
|
|
|
role: string,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
options?: ChannelOptions<ServerToStation, StationToServer>,
|
|
|
|
|
|
) => new Channel<ServerToStation, StationToServer>(`/ws/station/${sessionId}?role=${role}`, {
|
|
|
|
|
|
...options,
|
|
|
|
|
|
queueKey: (event) => {
|
|
|
|
|
|
if (event.type === "card.reply") return `card.reply:${event.card_id}`;
|
2026-09-26 18:12:27 +03:00
|
|
|
|
// Non-replacement station actions are each their own replayable command.
|
|
|
|
|
|
// The server deduplicates by this ID inside its persisted session snapshot.
|
|
|
|
|
|
return `command:${crypto.randomUUID()}`;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
},
|
2026-09-26 18:12:27 +03:00
|
|
|
|
isAcknowledged: (incoming, pending) => incoming.type === "station.state"
|
|
|
|
|
|
&& pending.type === "card.reply"
|
|
|
|
|
|
&& incoming.snapshot.card_id === pending.card_id
|
|
|
|
|
|
&& incoming.snapshot.reply_text === pending.text,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
});
|
2026-09-17 21:45:47 +03:00
|
|
|
|
|
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
|
|
|
|
/** Преподаватель: только передача, входящих в этом канале нет. */
|
|
|
|
|
|
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
|
|
|
|
|
|
new Channel<never, InstructorToServer>(`/ws/control/${sessionId}`, options);
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
function sameJsonValue(left: unknown, right: unknown): boolean {
|
|
|
|
|
|
if (Object.is(left, right)) return true;
|
|
|
|
|
|
if (Array.isArray(left) || Array.isArray(right)) {
|
|
|
|
|
|
return Array.isArray(left) && Array.isArray(right)
|
|
|
|
|
|
&& left.length === right.length
|
|
|
|
|
|
&& left.every((item, index) => sameJsonValue(item, right[index]));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (left && right && typeof left === "object" && typeof right === "object") {
|
|
|
|
|
|
const leftRecord = left as Record<string, unknown>;
|
|
|
|
|
|
const rightRecord = right as Record<string, unknown>;
|
|
|
|
|
|
const keys = Object.keys(leftRecord);
|
|
|
|
|
|
return keys.length === Object.keys(rightRecord).length
|
|
|
|
|
|
&& keys.every((key) => key in rightRecord
|
|
|
|
|
|
&& sameJsonValue(leftRecord[key], rightRecord[key]));
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|