anc
Проверка звука до записи и предупреждение, если дорожка молчит
Встреча, которая молча не записалась, — худшее для программы: человек
узнаёт об этом после встречи.
- «Новая запись»: проверка звука — микрофон и звук компьютера открываются
без записи в файл (audio_check_start/stop), две живые полоски
«слышно / тишина». Устройство не открылось — причина и где разрешить
(на Mac — «Запись экрана и системного звука»).
- Во время записи: дорожка минуту тише −60 dBFS — событие silent,
сообщение и строка на панели записи; звук вернулся — heard, строка
уходит. На паузе тишина не считается; звук компьютера, который в
тишине не приходит вовсе, тоже ловится.
- Начало записи гасит проверку: устройства не заняты дважды.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
57e443b
1 октября 2026 г.
recording.ts
// Запись: панель с таймером, уровнями и кнопками — одна на главной и на
// экране записи. Метка «идёт запись» с любого экрана — в шапке (nav.ts).
import { api, elapsed, events } from "./api";
import { esc } from "./dom";
import type { Scope } from "./scope";
import { changed, refreshRecording, state, toast } from "./state";
type Track = "me" | "others";
/** Дорожки, которые сейчас давно молчат. Живёт дольше экрана: человек мог
* уйти в настройки, а предупреждение должно ждать его на панели записи. */
const silent = new Set();
const SILENT_TEXT: Record = {
me: "Минуту не слышно вашего микрофона. Он выключен — в системе или в Zoom — или выбран не тот.",
others:
"Минуту не слышно собеседников. Если они говорят, а звука нет — звук компьютера не пишется: на Mac разрешите Skillok Meet «Запись экрана и системного звука» в настройках конфиденциальности.",
};
/** Слушать тишину дорожек на всю программу: предупреждение — и сообщением, и на панели. */
export function startSilenceWatch(): void {
void events.recorder(({ meeting_id, event }) => {
if (!meeting_id || state.recording?.meeting_id !== meeting_id) return;
if (event.kind !== "silent" && event.kind !== "heard") return;
const track: Track = event.data.speaker.kind === "me" ? "me" : "others";
if (event.kind === "silent") {
silent.add(track);
toast(SILENT_TEXT[track]);
} else {
silent.delete(track);
}
changed();
});
}
/** Новая запись — старые предупреждения не в счёт. */
export function resetSilence(): void {
silent.clear();
}
export function recordingPanel(): string {
const r = state.recording;
if (!r) return "";
return `
${
r.paused ? "Запись на паузе" : r.system ? "Идёт запись: собеседники и вы" : "Идёт запись: только ваш микрофон"
}
${r.system_error ? `Голоса собеседников не пишутся: ${esc(r.system_error)}` : ""}
${[...silent].map((t) => `${esc(SILENT_TEXT[t])}`).join("")}
${elapsed(r.started_at)}
Остановить
${r.paused ? "Продолжить" : "Пауза"}
`;
}
/** Кнопки панели и живые уровни звука. `onStop` — после остановки. */
export function bindRecording(root: HTMLElement, scope: Scope, onStop: () => void): void {
root.querySelector("[data-rec-stop]")?.addEventListener("click", async (e) => {
const b = e.currentTarget as HTMLButtonElement;
b.disabled = true;
b.textContent = "Останавливаю…";
try {
await api.recorderStop();
await refreshRecording();
onStop();
} catch (err) {
b.disabled = false;
toast(err);
}
});
root.querySelector("[data-rec-pause]")?.addEventListener("click", async () => {
if (!state.recording) return;
try {
await api.recorderPause(!state.recording.paused);
state.recording.paused = !state.recording.paused;
changed();
} catch (err) {
toast(err);
}
});
// Один общий уровень: громче из дорожек за последние полсекунды. Человеку
// важно одно — запись идёт и звук есть; делить его на «вы» и «собеседники»
// незачем (решение владельца 01.10.2026).
const last: Record = {};
scope.on(
events.recorder(({ meeting_id, event }) => {
// Уровни проверки звука (пустой meeting_id) — не для панели записи.
if (!meeting_id) return;
if (event.kind === "level") {
const now = performance.now();
last[event.data.speaker.kind] = { dbfs: event.data.dbfs, at: now };
const dbfs = Math.max(-90, ...Object.values(last).filter((l) => now - l.at < 500).map((l) => l.dbfs));
const fill = root.querySelector("[data-level]");
if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((dbfs + 60) / 55) * 100))}%`;
} else if (event.kind === "track_failed") {
toast(`Дорожка «${event.data.speaker.kind === "me" ? "вы" : "собеседники"}» перестала писаться: ${event.data.error}`);
}
}),
);
scope.every(1000, () => {
const t = root.querySelector("[data-timer]");
if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
});
}