anc
Проверка звука до записи и предупреждение, если дорожка молчит
Встреча, которая молча не записалась, — худшее для программы: человек
узнаёт об этом после встречи.
- «Новая запись»: проверка звука — микрофон и звук компьютера открываются
без записи в файл (audio_check_start/stop), две живые полоски
«слышно / тишина». Устройство не открылось — причина и где разрешить
(на Mac — «Запись экрана и системного звука»).
- Во время записи: дорожка минуту тише −60 dBFS — событие silent,
сообщение и строка на панели записи; звук вернулся — heard, строка
уходит. На паузе тишина не считается; звук компьютера, который в
тишине не приходит вовсе, тоже ловится.
- Начало записи гасит проверку: устройства не заняты дважды.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
57e443b
1 октября 2026 г.
dev-mock.ts
// Заглушка ядра для просмотра интерфейса в обычном браузере (`npm run dev`,
// `npm run build:mock`). В сборку приложения не попадает (см. api.ts).
// ИИ здесь не вызывается: расшифровка и ответы заготовлены руками.
import planerka from "../fixtures/transcripts/planerka.txt?raw";
import { sections, upsertSection } from "./markdown";
import type { Command, KeyInfo, Meeting, MeetingDetail, RecordingView, Settings, SettingsView, Speaker } from "./api";
// ── Шина событий ────────────────────────────────────────────────────────────
const handlers = new Map<string, Set<(p: unknown) => void>>();
function emit(event: string, payload: unknown): void {
handlers.get(event)?.forEach((h) => h(payload));
}
export async function listen<T>(event: string, cb: (payload: T) => void): Promise<() => void> {
const set = handlers.get(event) ?? new Set();
handlers.set(event, set);
const h = cb as (p: unknown) => void;
set.add(h);
return () => set.delete(h);
}
// ── Данные ──────────────────────────────────────────────────────────────────
/** Расшифровка планёрки в том виде, в каком ядро кладёт её в заметку. */
const TRANSCRIPT =
"## Расшифровка\n\n" +
planerka
.trim()
.split("\n")
.map((l) => l.replace(/^\[(\d\d:\d\d:\d\d)\] ([^:]+): /, "**$1 $2:** "))
.join("\n\n") +
"\n";
const ANSWERS: Record<string, string> = {
summary:
"## Конспект\n\nРелиз мобильной версии сдвинули с четверга на вторник из-за падения экрана оплаты на Android 12. Отчёт для инвестора уйдёт в четверг, когда Марат выгрузит партнёрские данные. Переезд на новый биллинг вынесли на отдельную встречу.\n\n### Решения\n\n- Релиз переносится на следующий вторник (00:00:55)\n- Биллинг — отдельной встречей (00:02:40)\n",
tasks:
"## Задачи\n\n- [ ] Починить падение оплаты на Android 12 — Денис, пятница (00:01:08)\n- [ ] Выгрузить партнёрские данные — Марат, до среды (00:01:34)\n- [ ] Отправить отчёт инвестору — Света, четверг (00:01:40)\n- [ ] Собрать встречу по биллингу — Марат, на следующей неделе (00:02:45)\n\n### Открытые вопросы\n\n- Кто дежурит во вторую неделю отпуска Дениса?\n",
review:
"## Ревью\n\nВстреча короткая и по делу: по релизу и отчёту есть решения и ответственные. В сторону ушли на биллинге — спор начался посреди пункта про отчёт. Открытым остался вопрос дежурств.\n\nСтоила своего времени; спорные темы лучше сразу выносить в список на потом.\n",
};
const now = Date.now();
const details: MeetingDetail[] = [];
const p2 = (n: number) => String(n).padStart(2, "0");
/** Расшифровка с говорящими «Спикер N», как после разбора по голосам в ядре.
* В заглушке голоса — имена из заготовки: `count` сводит их к стольким спикерам. */
function byVoices(count: number | null): { md: string; voices: number } {
const names: string[] = [];
const md = TRANSCRIPT.replace(/^\*\*(\d\d:\d\d:\d\d) ([^*:]+):\*\*/gm, (_, t: string, who: string) => {
if (!names.includes(who)) names.push(who);
const i = names.indexOf(who);
return `**${t} Спикер ${count ? (i % count) + 1 : i + 1}:**`;
});
return { md, voices: count ? Math.min(count, names.length) : names.length };
}
function makeId(d: Date): string {
return `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
}
function add(title: string, minutesAgo: number, note: string, audio: Meeting["audio"], transcribed: boolean): MeetingDetail {
const created = new Date(now - minutesAgo * 60000);
let id = makeId(created);
while (details.some((d) => d.meeting.id === id)) id += "-2";
const d: MeetingDetail = {
meeting: {
id, title, created_at: created.toISOString(), updated_at: created.toISOString(), audio,
recording: false, transcribed_at: transcribed ? created.toISOString() : null,
},
note,
voices: transcribed,
};
details.unshift(d);
return d;
}
const other: Speaker = { kind: "unknown" };
let checkTimer = 0;
add("Планёрка команды", 60 * 26, `${TRANSCRIPT}\n${ANSWERS.tasks}`, [{ file: "Планёрка.m4a", speaker: other, duration_ms: 195_000 }], true);
add("Созвон с клиентом", 90, "", [{ file: "Созвон с клиентом.m4a", speaker: other, duration_ms: 2_760_000 }], false);
let settings: Settings = {
ai_model: "anthropic/claude-opus-5.5",
stt_model: "openai/whisper-large-v3",
language: "ru",
storage_dir: "",
warning_text:
"Коллеги, я записываю встречу, чтобы сделать расшифровку и список задач. Запись остаётся у меня и никуда не публикуется. Если кто-то против — скажите, я выключу.",
};
let key: string | null = "sk-or-v1…demo";
let captureSupported = true;
const info: KeyInfo = { label: "просмотр без ядра", limit_remaining: 4.21, usage: 0.79, is_free_tier: false };
let recording: (RecordingView & { timer: number }) | null = null;
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
function find(id: unknown): MeetingDetail {
const d = details.find((x) => x.meeting.id === id);
if (!d) throw `встреча «${id}» не найдена`;
return d;
}
function upsert(d: MeetingDetail, heading: string, block: string): string {
d.note = upsertSection(d.note, heading, block);
return d.note;
}
let cancelled = false;
(window as unknown as { __meetMock: object }).__meetMock = {
noKey: () => (key = null),
unsupported: () => (captureSupported = false),
};
export async function pickFolder(): Promise<string | null> {
return "/Users/demo/Documents/Записи встреч";
}
export async function pickAudio(): Promise<string | null> {
return "/Users/demo/Загрузки/Интервью с кандидатом.m4a";
}
export async function onFileDrop(): Promise<() => void> {
return () => undefined;
}
export async function invoke<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
await wait(60);
switch (cmd) {
case "get_settings":
return {
settings, openrouter_key: key, capture_supported: captureSupported,
meetings_dir: settings.storage_dir || "~/Library/Application Support/org.skillok.meet/meetings",
default_meetings_dir: "~/Library/Application Support/org.skillok.meet/meetings",
} satisfies SettingsView as T;
case "save_settings":
settings = args.settings as Settings;
return undefined as T;
case "set_openrouter_key": {
const k = String(args.key).trim();
if (!k) throw "Вставьте ключ OpenRouter";
key = k.length > 12 ? `${k.slice(0, 8)}…${k.slice(-4)}` : "•".repeat(k.length);
return info as T;
}
case "check_openrouter_key":
if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках";
return info as T;
case "delete_openrouter_key":
key = null;
return undefined as T;
case "list_meetings":
return details.map((d) => d.meeting) as T;
case "get_meeting":
return structuredClone(find(args.id)) as T;
case "save_note":
find(args.id).note = String(args.text);
return find(args.id).meeting as T;
case "rename_meeting": {
const t = String(args.title).trim();
if (!t) throw "название не может быть пустым";
find(args.id).meeting.title = t;
return find(args.id).meeting as T;
}
case "remove_section": {
const d = find(args.id);
const rest = sections(d.note).filter((x) => x.heading !== args.heading);
d.note = rest.length ? rest.map((x) => x.source.trim()).join("\n\n") + "\n" : "";
return d.note as T;
}
case "set_storage_dir":
if (recording) throw "Сначала остановите запись";
settings.storage_dir = (args.path as string | null) ?? "";
return (settings.storage_dir || "~/Library/Application Support/org.skillok.meet/meetings") as T;
case "open_folder":
return undefined as T;
case "cancel_job":
cancelled = true;
return undefined as T;
case "delete_meeting":
details.splice(details.indexOf(find(args.id)), 1);
return undefined as T;
case "add_audio": {
const path = String(args.path);
const name = path.split("/").pop()!;
await wait(400);
return add(name.replace(/\.[^.]+$/, ""), 0, "", [{ file: name, speaker: other, duration_ms: 1_380_000 }], false).meeting as T;
}
case "transcribe_meeting": {
const d = find(args.id);
if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках";
if (!d.meeting.audio.length) throw "В этой встрече нет звука — запишите встречу или добавьте аудиофайл";
// Реплики приходят по ходу, как из ядра: по куску за раз, по порядку времени.
// У файла имени говорящего по ходу нет — оно появится после разбора по голосам.
const lines = TRANSCRIPT.replace(/^## .*\n+/, "")
.trim()
.split(/\n\s*\n/)
.map((l) => l.replace(/^\*\*(\d\d:\d\d:\d\d) [^*:]+:\*\*/, "**$1**"));
const total = lines.length;
cancelled = false;
emit("meet://progress", { meeting_id: d.meeting.id, stage: "speech", done: 0, total, lines: [] });
for (let i = 1; i <= total; i++) {
await wait(300);
if (cancelled) throw "Расшифровка отменена";
emit("meet://progress", { meeting_id: d.meeting.id, stage: "speech", done: i, total, lines: [lines[i - 1]] });
}
for (let i = 0; i <= total; i += 4) {
await wait(200);
if (cancelled) throw "Расшифровка отменена";
emit("meet://progress", { meeting_id: d.meeting.id, stage: "voices", done: Math.min(i, total), total, lines: [] });
}
d.meeting.transcribed_at = new Date().toISOString();
d.voices = true;
const v = byVoices(null);
return { note: upsert(d, "Расшифровка", v.md), failed: 0, last_error: null, voices: v.voices, voices_error: null } as T;
}
case "regroup_speakers": {
const d = find(args.id);
if (!d.voices) throw "Для этой записи нет данных о голосах — расшифруйте её заново";
await wait(300);
const v = byVoices((args.count as number | null) ?? null);
return { note: upsert(d, "Расшифровка", v.md), voices: v.voices } as T;
}
case "run_command": {
const d = find(args.id);
const c = args.command as Command;
if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках";
if (!d.note.trim()) throw "Заметка пустая — сначала расшифруйте аудио или напишите текст";
if (c.kind === "custom" && !c.text.trim()) throw "Напишите, что сделать";
cancelled = false;
const heading = c.kind === "custom" ? c.text.trim().replace(/^./, (x) => x.toUpperCase()) : { summary: "Конспект", tasks: "Задачи", review: "Ревью" }[c.kind];
const block =
c.kind === "custom"
? `## ${heading}\n\n(В просмотре без ядра ИИ не вызывается — здесь будет ответ на «${c.text.trim()}».)\n`
: ANSWERS[c.kind];
// Ответ пишется потоком: кусками по несколько знаков, как у модели.
const answer = block.replace(/^## .*\n+/, "");
for (let i = 0; i < answer.length; i += 6) {
await wait(25);
if (cancelled) throw "Отменено";
emit("meet://answer", { meeting_id: d.meeting.id, heading, text: answer.slice(i, i + 6) });
}
return { note: upsert(d, heading, block), heading } as T;
}
case "audio_check_start": {
if (!captureSupported) throw "На этой системе запись не поддерживается";
window.clearInterval(checkTimer);
const began = Date.now();
// Микрофон «слышит» сразу, звук компьютера — через 3 секунды, как если
// бы человек включил видео.
checkTimer = window.setInterval(() => {
const others = Date.now() - began > 3_000;
for (const [speaker, on] of [[{ kind: "me" }, true], [{ kind: "others" }, others]] as [Speaker, boolean][]) {
const dbfs = on ? -40 + Math.random() * 25 : -90;
emit("meet://recorder", { meeting_id: "", event: { kind: "level", data: { speaker, dbfs } } });
}
}, 200);
return { mic_error: null, system_error: null } as T;
}
case "audio_check_stop":
window.clearInterval(checkTimer);
return undefined as T;
case "recorder_status":
return (recording && { ...recording, timer: undefined }) as T;
case "recorder_start": {
if (!captureSupported) throw "На этой системе запись не поддерживается — можно добавить готовый аудиофайл.";
if (recording) throw "Запись уже идёт";
const d = new Date();
const m = add(`Встреча ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, 0, "", [], false);
m.meeting.recording = true;
const r = { meeting_id: m.meeting.id, started_at: d.toISOString(), paused: false, system: true, system_error: null, timer: 0 };
window.clearInterval(checkTimer);
// Собеседники «молчат» с 6-й по 12-ю секунду — видно предупреждение о тишине.
window.setTimeout(() => emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "silent", data: { speaker: { kind: "others" }, seconds: 60 } } }), 6_000);
window.setTimeout(() => emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "heard", data: { speaker: { kind: "others" } } } }), 12_000);
r.timer = window.setInterval(() => {
for (const speaker of [{ kind: "me" }, { kind: "others" }] as Speaker[]) {
const dbfs = r.paused ? -90 : -60 + Math.random() * 45;
emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "level", data: { speaker, dbfs } } });
}
}, 200);
recording = r;
return m.meeting as T;
}
case "recorder_pause":
if (!recording) throw "Запись не идёт";
recording.paused = Boolean(args.paused);
return undefined as T;
case "recorder_stop": {
if (!recording) throw "Запись не идёт";
clearInterval(recording.timer);
const d = find(recording.meeting_id);
const ms = Date.now() - Date.parse(recording.started_at);
d.meeting.recording = false;
d.meeting.audio = [
{ file: "me.wav", speaker: { kind: "me" }, duration_ms: ms },
{ file: "others.wav", speaker: { kind: "others" }, duration_ms: ms },
];
recording = null;
await wait(300);
return d.meeting as T;
}
default:
throw `нет команды ${cmd}`;
}
}