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 г.
home.ts
// Главный экран: что программа делает прямо сейчас (задача #1) и список встреч.
//
// Карточка состояния отвечает на один вопрос — «что происходит и что будет
// дальше». В каждом состоянии одно главное действие.
import {
api, errorText, events, formatDate, formatDuration, speakerLabel,
type Meeting, type RecorderStatus, type Segment, type SettingsView, type Speaker,
} from "../api";
import { $, busy, esc, plural } from "../dom";
import type { Scope } from "../scope";
function elapsed(since: string): string {
const t = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
const p = (n: number) => String(n).padStart(2, "0");
return t >= 3600 ? `${Math.floor(t / 3600)}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}` : `${p(Math.floor(t / 60))}:${p(t % 60)}`;
}
function meter(label: string, key: string): string {
return `
${esc(label)}
`;
}
function card(status: RecorderStatus, view: SettingsView): string {
const hasKey = Boolean(view.openrouter_key);
if (status.kind === "unsupported") {
return `
Запись встреч работает в Windows
На этом компьютере можно разобрать готовую расшифровку: вставьте её, и ИИ сделает конспект и задачи.
Импортировать расшифровку
`;
}
if (!hasKey && (status.kind === "watching" || status.kind === "awaiting_consent")) {
return `
Подключите ИИ, чтобы записывать встречи
Расшифровка и разбор идут через ваш ключ OpenRouter. Без него встречу не записать.
Подключить
`;
}
switch (status.kind) {
case "watching":
return `
Слежу за Zoom
Жду встречу
${
view.settings.always_warn_myself
? "Как только начнётся встреча в Zoom, запись включится сама."
: "Как только начнётся встреча в Zoom, я попрошу предупредить участников и начну запись."
}
Записать вручную
`;
case "awaiting_consent":
return `
Началась встреча в Zoom
Предупредите участников о записи
${esc(view.settings.warning_text)}
Предупредил, записывать
Скопировать текст
Не записывать
`;
case "recording":
return `
${
status.paused ? "Запись на паузе" : status.zoom ? "Записываю встречу в Zoom" : "Записываю с микрофона"
}
${elapsed(status.started_at)}
${meter("Я", "me")}
${status.zoom ? meter("Собеседники", "others") : ""}
Реплики появятся здесь через несколько секунд после начала разговора.
Остановить
${status.paused ? "Продолжить" : "Пауза"}
Открыть расшифровку
`;
case "finishing":
return `
Запись остановлена
${status.step === "transcribing" ? "Дописываю расшифровку…" : "Разбираю встречу…"}
${
status.step === "transcribing"
? "Распознаю последние реплики. Потом ИИ сделает конспект и задачи."
: "ИИ пишет конспект, решения и задачи. Обычно это меньше минуты — пришлю уведомление."
}
Открыть встречу
`;
}
}
function status(m: Meeting): string {
if (m.state === "recording") return `идёт запись`;
if (m.state === "interrupted" && !m.analysis) return `прервана`;
if (!m.analysis) return `не разобрана`;
const n = m.analysis.tasks;
return `${n} ${plural(n, "задача", "задачи", "задач")}`;
}
function list(meetings: Meeting[]): string {
if (!meetings.length) {
return `Здесь появятся записанные встречи.`;
}
return meetings
.map(
(m) => `
${esc(m.title)}
${esc(formatDate(m.created_at))} · ${esc(formatDuration(m.duration_ms))}
${status(m)}
`,
)
.join("");
}
function importForm(): string {
return `
Импорт расшифровки
Название
Расшифровка
Каждая реплика — с новой строки: [чч:мм:сс] Имя: текст.
Создать встречу
Отмена
`;
}
function speakerKey(s: Speaker): string {
return s.kind === "me" ? "me" : "others";
}
export async function renderHome(root: HTMLElement, scope: Scope): Promise {
const [recorder, initialView, meetings] = await Promise.all([api.recorderStatus(), api.getSettings(), api.listMeetings()]);
if (!scope.alive) return;
let view = initialView;
root.innerHTML = `
${card(recorder, view)}
${importForm()}
Встречи
Импортировать расшифровку
${list(meetings)}
`;
const error = $(root, "[data-error]");
const fail = (e: unknown) => {
error.textContent = errorText(e);
error.hidden = false;
};
let current = recorder;
const live: Segment[] = [];
const bindCard = () => {
const box = $(root, "[data-card]");
box.querySelector("[data-start]")?.addEventListener("click", (e) =>
busy(e.currentTarget as HTMLButtonElement, "Запускаю…", () => api.recorderStart()).catch(fail),
);
box.querySelectorAll("[data-consent]").forEach((b) =>
b.addEventListener("click", () => api.recorderConsent(b.dataset.consent === "yes").catch(fail)),
);
box.querySelector("[data-copy]")?.addEventListener("click", async (e) => {
const b = e.currentTarget as HTMLButtonElement;
await navigator.clipboard.writeText(view.settings.warning_text).catch(() => undefined);
b.textContent = "Скопировано";
});
box.querySelector("[data-stop]")?.addEventListener("click", (e) =>
busy(e.currentTarget as HTMLButtonElement, "Останавливаю…", () => api.recorderStop()).catch(fail),
);
box.querySelector("[data-pause]")?.addEventListener("click", () =>
api.recorderPause(current.kind === "recording" && !current.paused).catch(fail),
);
box.querySelector("[data-open-import]")?.addEventListener("click", openImport);
renderLive();
};
const renderLive = () => {
const el = root.querySelector("[data-live]");
if (!el || !live.length) return;
el.innerHTML = live
.slice(-3)
.map((s) => `${esc(speakerLabel(s.speaker))}: ${esc(s.text)}`)
.join("");
};
const refreshList = async () => {
const fresh = await api.listMeetings();
if (scope.alive) $(root, "[data-list]").innerHTML = list(fresh);
};
// Импорт
const form = $(root, "[data-import]") as HTMLFormElement;
const importError = $(root, "[data-import-error]");
function openImport() {
form.hidden = false;
$(form, "#imp-title").focus();
}
root.querySelectorAll("[data-open-import]").forEach((b) => b.addEventListener("click", openImport));
$(root, "[data-close-import]").addEventListener("click", () => {
form.hidden = true;
importError.hidden = true;
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
try {
const m = await busy($(form, "[type=submit]") as HTMLButtonElement, "Создаю…", () =>
api.importMeeting(String(data.get("title")), String(data.get("text"))),
);
location.hash = `#/meeting/${encodeURIComponent(m.id)}`;
} catch (e) {
importError.textContent = errorText(e);
importError.hidden = false;
}
});
bindCard();
scope.on(
events.status(async (s) => {
const changedMeeting = s.kind !== current.kind;
current = s;
if (s.kind !== "recording") live.length = 0;
// Ключ и «предупреждаю сам» могли поменяться, пока экран открыт.
view = await api.getSettings();
if (!scope.alive) return;
error.hidden = true;
$(root, "[data-card]").innerHTML = card(s, view);
bindCard();
if (changedMeeting) await refreshList();
}),
);
scope.on(
events.recorder(({ event }) => {
if (event.kind === "segments") {
live.push(...event.data);
renderLive();
} else if (event.kind === "level") {
const fill = root.querySelector(`[data-level="${speakerKey(event.data.speaker)}"]`);
if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`;
} else if (event.kind === "track_failed") {
fail(event.data.error);
}
}),
);
scope.on(events.meetingChanged(() => refreshList()));
scope.every(1000, () => {
const t = root.querySelector("[data-timer]");
if (t && current.kind === "recording") t.textContent = elapsed(current.started_at);
});
}