anc
Запись всего звука, папка хранения, точнее распознавание
- Звук собеседников — весь звук компьютера, кроме самого Skillok Meet:
Zoom, Google Meet, Телемост, что угодно. Windows — process loopback в
режиме исключения своего процесса, macOS — ScreenCaptureKit без фильтра
по приложению. Поиск Zoom и зависимость windows убраны.
- Если звук компьютера недоступен (на Mac — нет разрешения), запись идёт
с микрофона, а интерфейс говорит почему.
- Настройки: папка хранения записей (выбрать, открыть, вернуть по
умолчанию; записи переносятся целиком), точность распознавания —
по умолчанию полная whisper-large-v3.
- «Открыть папку с файлами» у записи; микрофон на Mac просит у CoreAudio
сразу 16 кГц.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96a28a9
30 сентября 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");
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,
};
details.unshift(d);
return d;
}
const other: Speaker = { kind: "unknown" };
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 total = 12;
cancelled = false;
for (let i = 0; i <= total; i++) {
if (cancelled) throw "Расшифровка отменена";
emit("meet://progress", { meeting_id: d.meeting.id, done: i, total });
await wait(250);
}
d.meeting.transcribed_at = new Date().toISOString();
return { note: upsert(d, "Расшифровка", TRANSCRIPT), failed: 0, last_error: null } 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;
for (let i = 0; i < 8; i++) {
await wait(200);
if (cancelled) throw "Отменено";
}
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];
return { note: upsert(d, heading, block), heading } 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 };
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}`;
}
}