anc
Этап 1: запись встречи в Zoom, живая расшифровка и разбор
- crates/audio: захват звука процесса Zoom и микрофона (WASAPI, process
loopback), формат 16 кГц моно, нарезка на куски по паузам с отсевом
тишины, распознавание начала и конца встречи по звуковой сессии Zoom.
- Ядро: запись двух дорожек → Whisper через OpenRouter → расшифровка,
живой конспект раз в три минуты, по окончании — разбор сам; звук
удаляется после разбора, если не включено хранение.
- Контроллер: слежу за Zoom → прошу предупредить участников → пишу;
запись вручную, пауза, стоп; прерванная запись помечается при запуске.
- Трей, уведомления, крестик прячет окно, запуск вместе с Windows.
- Интерфейс: главный экран с карточкой состояния (задача #1), живая
расшифровка и конспект на экране встречи, новые настройки.
- reqwest на системном TLS: под Windows без C-кода ring.
- 67 тестов; код под Windows проверен компиляцией и clippy с Mac,
на Windows не запускался.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169ce64
30 сентября 2026 г.
dev-mock.ts
// Заглушка ядра для просмотра интерфейса в обычном браузере (`npm run dev`,
// `npm run build:mock`). В сборку приложения не попадает (см. api.ts).
//
// Изображает и запись: «встреча» идёт по репликам планёрки из fixtures, с
// уровнями звука и живым конспектом. Модель здесь не вызывается — разбор
// заготовлен руками. Для проверки экранов есть ручки в консоли:
// __meetMock.meetingStarted() — «началась встреча в Zoom»
// __meetMock.noKey() — ключ удалён
import planerka from "../fixtures/transcripts/planerka.txt?raw";
import klient from "../fixtures/transcripts/klient.txt?raw";
import type {
Analysis, KeyInfo, Meeting, MeetingDetail, RecorderStatus, Segment, Settings, SettingsView, Speaker,
} from "./api";
function parse(text: string): Segment[] {
const out: Segment[] = [];
for (const raw of text.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = /^\[(\d{1,2}):(\d{2}):(\d{2})\]\s*([^:]+):\s*(.*)$/.exec(line);
if (m) {
const ms = ((+m[1] * 60 + +m[2]) * 60 + +m[3]) * 1000;
out.push({ start_ms: ms, speaker: { kind: "named", name: m[4].trim() }, text: m[5] });
} else if (out.length) {
out[out.length - 1].text += " " + line;
} else {
throw "строка без метки времени перед первой репликой";
}
}
if (!out.length) throw "в тексте нет ни одной реплики";
return out;
}
const planerkaAnalysis: Analysis = {
title: "Планёрка: релиз, отчёт инвестору, отпуск",
summary:
"Релиз мобильной версии сдвинули с четверга на вторник из-за падения экрана оплаты на Android 12. Отчёт для инвестора уйдёт в четверг, когда Марат выгрузит партнёрские данные. Переезд на новый биллинг вынесли на отдельную встречу. Дежурства на время отпуска Дениса закрыты только на первую неделю.",
review: {
achieved: ["Решили судьбу релиза и назначили ответственного за блокер", "Согласовали срок отчёта для инвестора"],
open: ["Кто дежурит во вторую неделю отпуска Дениса", "Затронет ли переезд на новый биллинг партнёрские выгрузки"],
off_track: ["Спор о новом биллинге посреди пункта про отчёт"],
verdict: "Встреча короткая и полезная; спорные темы стоит сразу выносить в отдельный список, не открывая обсуждение.",
},
decisions: [
{ text: "Релиз переносится с четверга на следующий вторник", source_segment: 4 },
{ text: "Биллинг обсуждаем на отдельной встрече", source_segment: 17 },
],
tasks: [
{ text: "Починить падение экрана оплаты на Android 12", owner: "Денис", due: "пятница", quote: "Денис, падение на Android 12 — на тебе, срок — пятница.", source_segment: 7 },
{ text: "Выгрузить партнёрские данные для отчёта", owner: "Марат", due: "до среды", quote: "Партнёрские данные у меня, выгружу до среды.", source_segment: 10 },
{ text: "Отправить отчёт инвестору", owner: "Света", due: "в четверг", quote: "тогда отчёт отправлю в четверг.", source_segment: 11 },
{ text: "Собрать встречу по новому биллингу", owner: "Марат", due: "на следующей неделе", quote: "Соберу, на следующей неделе.", source_segment: 18 },
],
open_questions: ["Кто прикрывает дежурства во вторую неделю отпуска Дениса?"],
};
// ── Шина событий ────────────────────────────────────────────────────────────
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 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, segments: Segment[], minutesAgo: number, analysis: Analysis | null, state: Meeting["state"] = "done"): MeetingDetail {
const created = new Date(now - minutesAgo * 60000);
const meeting: Meeting = {
id: makeId(created),
title,
created_at: created.toISOString(),
source: state === "done" && minutesAgo > 60 ? "import" : "recording",
duration_ms: segments.length ? segments[segments.length - 1].start_ms : 0,
segments: segments.length,
analysis: analysis && {
model: "anthropic/claude-opus-5.5",
at: created.toISOString(),
usage: { prompt_tokens: 2140, completion_tokens: 810, cost: 0.0248 },
tasks: analysis.tasks.length,
},
state,
};
const d: MeetingDetail = { meeting, transcript: { segments }, analysis, live_summary: null };
details.unshift(d);
return d;
}
add("Планёрка команды", parse(planerka), 60 * 26, planerkaAnalysis);
add("Встреча с клиентом: сайт", parse(klient), 90, null);
let settings: Settings = {
live_model: "anthropic/claude-haiku-4.5",
analysis_model: "anthropic/claude-opus-5.5",
stt_model: "openai/whisper-large-v3-turbo",
warning_text:
"Коллеги, я записываю встречу, чтобы сделать расшифровку и список задач. Запись остаётся у меня и никуда не публикуется. Если кто-то против — скажите, я выключу.",
always_warn_myself: false,
language: "ru",
keep_audio: false,
launch_at_login: true,
};
let key: string | null = "sk-or-v1…demo";
const info: KeyInfo = { label: "просмотр без ядра", limit_remaining: 4.21, usage: 0.79, is_free_tier: false };
// ── Имитация записи ─────────────────────────────────────────────────────────
let status: RecorderStatus = { kind: "watching" };
let timers: number[] = [];
function setStatus(s: RecorderStatus): void {
status = s;
emit("meet://status", s);
}
function startRecording(zoom: boolean): Meeting {
const d = new Date();
const d0 = add(`${zoom ? "Встреча в Zoom," : "Запись,"} ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, [], 0, null, "recording");
const id = d0.meeting.id;
setStatus({ kind: "recording", meeting_id: id, started_at: d.toISOString(), paused: false, zoom });
const script = parse(planerka);
let i = 0;
timers.push(window.setInterval(() => {
if (status.kind !== "recording" || status.paused || i >= script.length) return;
const src = script[i++];
const speaker: Speaker = i % 3 === 1 ? { kind: "me" } : { kind: "others" };
const seg: Segment = { start_ms: Date.now() - Date.parse(status.started_at), speaker, text: src.text };
d0.transcript.segments.push(seg);
d0.meeting.segments++;
d0.meeting.duration_ms = seg.start_ms;
emit("meet://recorder", { meeting_id: id, event: { kind: "segments", data: [seg] } });
if (i === 6) {
d0.live_summary = "- Обсуждают релиз мобильной версии\n- Договорились: релиз переносится на вторник\n- Задача: Денис чинит оплату на Android 12 до пятницы";
emit("meet://recorder", { meeting_id: id, event: { kind: "live_summary", data: d0.live_summary } });
}
}, 1800));
timers.push(window.setInterval(() => {
if (status.kind !== "recording") return;
const quiet = status.paused;
for (const speaker of [{ kind: "me" }, ...(zoom ? [{ kind: "others" }] : [])] as Speaker[]) {
const dbfs = quiet ? -90 : -60 + Math.random() * 45;
emit("meet://recorder", { meeting_id: id, event: { kind: "level", data: { speaker, dbfs } } });
}
}, 200));
return d0.meeting;
}
function stopRecording(): void {
if (status.kind !== "recording") throw "Запись не идёт";
const id = status.meeting_id;
timers.forEach(clearInterval);
timers = [];
const d = details.find((x) => x.meeting.id === id)!;
setStatus({ kind: "finishing", meeting_id: id, step: "transcribing" });
setTimeout(() => {
d.meeting.state = "done";
emit("meet://meeting-changed", id);
if (!d.transcript.segments.length) {
setStatus({ kind: "watching" });
return;
}
setStatus({ kind: "finishing", meeting_id: id, step: "analyzing" });
setTimeout(() => {
d.analysis = { ...planerkaAnalysis, decisions: [], tasks: planerkaAnalysis.tasks.map((t) => ({ ...t, source_segment: 0 })) };
d.meeting.analysis = { model: settings.analysis_model, at: new Date().toISOString(), usage: { prompt_tokens: 900, completion_tokens: 600, cost: 0.0156 }, tasks: 4 };
emit("meet://meeting-changed", id);
setStatus({ kind: "watching" });
}, 2500);
}, 1200);
}
(window as unknown as { __meetMock: object }).__meetMock = {
meetingStarted: () => status.kind === "watching" && setStatus({ kind: "awaiting_consent" }),
meetingEnded: () => (status.kind === "awaiting_consent" ? setStatus({ kind: "watching" }) : status.kind === "recording" && stopRecording()),
noKey: () => { key = null; setStatus(status); },
unsupported: () => setStatus({ kind: "unsupported" }),
};
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function invoke<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
await wait(80);
const find = (id: unknown) => {
const d = details.find((x) => x.meeting.id === id);
if (!d) throw `встреча «${id}» не найдена`;
return d;
};
switch (cmd) {
case "get_settings":
return {
settings, openrouter_key: key, capture_supported: status.kind !== "unsupported",
meetings_dir: "(просмотр без ядра — на диск ничего не пишется)",
} 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 "import_meeting": {
const title = String(args.title).trim();
if (!title) throw "название встречи не может быть пустым";
let segs: Segment[];
try {
segs = parse(String(args.text));
} catch (e) {
throw `расшифровка не разбирается: ${e}`;
}
return add(title, segs, 0, null).meeting as T;
}
case "analyze_meeting":
find(args.id);
await wait(600);
throw "В просмотре без ядра разбор не запускается — откройте приложение через `npm run tauri dev`";
case "recorder_status":
return status as T;
case "recorder_consent":
if (status.kind !== "awaiting_consent") throw "Сейчас нечего подтверждать";
if (args.record) startRecording(true);
else setStatus({ kind: "watching" });
return undefined as T;
case "recorder_start":
if (!key) throw "Сначала подключите ИИ: добавьте ключ OpenRouter в настройках";
if (status.kind !== "watching" && status.kind !== "awaiting_consent") throw "Запись уже идёт";
return startRecording(status.kind === "awaiting_consent") as T;
case "recorder_pause":
if (status.kind !== "recording") throw "Запись не идёт";
setStatus({ ...status, paused: Boolean(args.paused) });
return undefined as T;
case "recorder_stop":
stopRecording();
return undefined as T;
default:
throw `нет команды ${cmd}`;
}
}