anc
Экран записи и проигрыватель: волна, источники, отметки, звук ↔ текст
Запись:
- Время — по записанному звуку, паузы не в счёт (позиция сессии записи,
ms в событиях уровня, position_ms в recorder_status).
- Живая «волна» последней минуты, «Микрофон / Звук компьютера —
слышно / тишина», объём записи; «Остановить и расшифровать».
- «★ Отметить момент»: отметка с подписью (Meeting.markers,
add_marker / set_markers); видна в расшифровке и на проигрывателе.
Проигрыватель (player.ts):
- Полоса внизу экрана записи: играть, ±15 с, скорость до 2×, «волна»
всей встречи (клик и протяжка — перемотка, отметки, текущее место),
«★» — отметить место. Две дорожки записи играют вместе и
подравниваются. Живёт дольше перерисовок экрана — звук не прерывается.
- Звук читается с диска протоколом asset: только из папки записей
(protocol-asset, scope на папку, media-src в CSP); «волна» считается
один раз и кэшируется в peaks.json.
- Время реплики и отметки — кнопка «слушать отсюда», звучащая реплика
подсвечивается; пробел — играть/пауза, ←/→ — 5 с.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b82e259
2 октября 2026 г.
recording.ts
// Запись: панель с таймером, уровнями и кнопками — одна на главной и на
// экране записи. Метка «идёт запись» с любого экрана — в шапке (nav.ts).
import { api, events, type Marker } from "./api";
import * as jobs from "./jobs";
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 === "level") {
onLevel(trackOf(event.data.speaker.kind), event.data.dbfs, event.data.ms);
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();
wave.length = 0;
positionMs = 0;
heardAt.me = heardAt.others = 0;
marks = [];
}
/** Последняя минута звука записи: громкость каждые 0,2 с — для живой «волны». */
const WAVE_POINTS = 300;
const wave: number[] = [];
/** Сколько звука записано, мс (паузы не в счёт) — по событиям дорожек. */
let positionMs = 0;
/** Когда дорожка в последний раз что-то слышала. */
const heardAt: Record = { me: 0, others: 0 };
/** Последние уровни дорожек — общая точка «волны» берёт громкую из них. */
const lastLevel: Record = { me: { dbfs: -90, at: 0 }, others: { dbfs: -90, at: 0 } };
/** Отметки текущей записи. */
let marks: Marker[] = [];
function trackOf(kind: string): Track {
return kind === "me" ? "me" : "others";
}
function onLevel(track: Track, dbfs: number, ms: number): void {
const now = performance.now();
lastLevel[track] = { dbfs, at: now };
if (dbfs > -45) heardAt[track] = now;
positionMs = Math.max(positionMs, ms);
// Точка «волны» — раз в 0,2 с, громкая из дорожек; события дорожек
// приходят парами, вторую пару в тот же тик не считаем.
if (track === "me" || !lastLevel.me.at || now - lastLevel.me.at > 300) {
const loud = Math.max(...Object.values(lastLevel).filter((l) => now - l.at < 500).map((l) => l.dbfs), -90);
wave.push(Math.max(0, Math.min(1, (loud + 60) / 55)));
if (wave.length > WAVE_POINTS) wave.splice(0, wave.length - WAVE_POINTS);
}
}
/** Отметки записи с ядра — после начала, после «Отметить момент». */
export function setRecordingMarks(list: Marker[]): void {
marks = list.slice();
}
/** Время записи «чч:мм:сс» по записанному звуку. */
export function clock(ms: number): string {
const t = Math.floor(ms / 1000);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(Math.floor(t / 3600))}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}`;
}
function sizeText(ms: number, tracks: number): string {
const mb = (ms * 32 * tracks) / 1_048_576;
return mb < 1 ? "меньше 1 МБ" : `${mb.toFixed(mb < 10 ? 1 : 0).replace(".", ",")} МБ`;
}
/** Панель записи — одна на главной и на экране записи: время, живая
* «волна», что слышно, отметки и кнопки. Всё, что надо видеть во время
* встречи, — без перехода куда-либо. */
export function recordingPanel(): string {
const r = state.recording;
if (!r) return "";
const pos = Math.max(positionMs, r.position_ms ?? 0);
const source = (t: Track, title: string) => {
const off = t === "others" && !r.system;
return `${title}
${off ? "не пишется" : "…"}`;
};
return `
${
r.paused ? "Пауза" : "Идёт запись"
}
${clock(pos)}
${source("me", "Микрофон")}
${source("others", "Звук компьютера")}
${sizeText(pos, r.system ? 2 : 1)}
${r.system_error ? `Голоса собеседников не пишутся: ${esc(r.system_error)}` : ""}
${[...silent].map((t) => `${esc(SILENT_TEXT[t])}`).join("")}
Остановить
Остановить и расшифровать
${r.paused ? "Продолжить" : "Пауза"}
★ Отметить момент
${
marks.length
? `${marks
.map(
(m, i) => `${clock(m.ms)}
`,
)
.join("")}`
: `Случилось важное — «★ Отметить момент»: место будет видно в расшифровке и на проигрывателе.`
}
`;
}
/** Рисует «волну» последней минуты: новые точки справа. */
function drawWave(canvas: HTMLCanvasElement): void {
const w = canvas.clientWidth;
if (!w) return;
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== Math.round(w * dpr)) canvas.width = Math.round(w * dpr);
const h = canvas.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, h);
const css = getComputedStyle(canvas);
const step = canvas.width / WAVE_POINTS;
const bar = Math.max(1, step * 0.6);
ctx.fillStyle = css.getPropertyValue("--color-border").trim() || "#ddd";
ctx.fillRect(0, h / 2, canvas.width, 1);
ctx.fillStyle = css.getPropertyValue("--color-accent").trim() || "#a6533c";
const start = WAVE_POINTS - wave.length;
wave.forEach((v, i) => {
const bh = Math.max(1, v * (h - 4));
ctx.fillRect((start + i) * step, (h - bh) / 2, bar, bh);
});
}
/** Кнопки панели, живая «волна», время и отметки. `onStop` — после остановки. */
export function bindRecording(root: HTMLElement, scope: Scope, onStop: () => void): void {
const stop = async (b: HTMLButtonElement, thenTranscribe: boolean) => {
const id = state.recording?.meeting_id;
root.querySelectorAll("[data-rec-stop], [data-rec-stop-transcribe]").forEach((x) => (x.disabled = true));
b.textContent = "Останавливаю…";
try {
await api.recorderStop();
await refreshRecording();
if (thenTranscribe && id) void jobs.transcribe(id);
onStop();
} catch (err) {
root.querySelectorAll("[data-rec-stop], [data-rec-stop-transcribe]").forEach((x) => (x.disabled = false));
toast(err);
}
};
root.querySelector("[data-rec-stop]")?.addEventListener("click", (e) => stop(e.currentTarget as HTMLButtonElement, false));
root
.querySelector("[data-rec-stop-transcribe]")
?.addEventListener("click", (e) => stop(e.currentTarget as HTMLButtonElement, true));
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);
}
});
root.querySelector("[data-rec-mark]")?.addEventListener("click", async () => {
const id = state.recording?.meeting_id;
if (!id) return;
try {
const m = await api.addMarker(id, null);
marks = m.markers;
toast(`Отмечено: ${clock(marks.length ? Math.max(...marks.map((k) => k.ms)) : 0)}`, "ok");
changed();
// Сразу можно подписать: фокус в поле новой отметки.
requestAnimationFrame(() => {
const last = [...document.querySelectorAll("[data-mark-text]")].pop();
last?.focus();
});
} catch (err) {
toast(err);
}
});
root.querySelectorAll("[data-mark-text]").forEach((input) =>
input.addEventListener("change", async () => {
const id = state.recording?.meeting_id;
if (!id) return;
marks[Number(input.dataset.markText)] = { ...marks[Number(input.dataset.markText)], text: input.value };
try {
marks = (await api.setMarkers(id, marks)).markers;
} catch (err) {
toast(err);
}
}),
);
// Живое: «волна», время, размер, что слышно — 5 раз в секунду.
const tick = () => {
const r = state.recording;
if (!r) return;
const canvas = root.querySelector("[data-wave]");
if (canvas) drawWave(canvas);
const pos = Math.max(positionMs, r.position_ms ?? 0);
const t = root.querySelector("[data-timer]");
if (t) t.textContent = clock(pos);
const size = root.querySelector("[data-size]");
if (size) size.textContent = sizeText(pos, r.system ? 2 : 1);
const now = performance.now();
for (const tr of ["me", "others"] as Track[]) {
const el = root.querySelector(`[data-src="${tr}"]`);
if (!el || el.classList.contains("is-off")) continue;
const ok = !r.paused && now - heardAt[tr] < 3_000;
el.classList.toggle("is-ok", ok);
el.querySelector("[data-src-state]")!.textContent = r.paused ? "пауза" : ok ? "слышно" : "тишина";
}
};
tick();
scope.every(200, tick);
scope.on(
events.recorder(({ meeting_id, event }) => {
if (!meeting_id || event.kind !== "track_failed") return;
toast(`Дорожка «${event.data.speaker.kind === "me" ? "микрофон" : "звук компьютера"}» перестала писаться: ${event.data.error}`);
}),
);
}