skillok meeting
d0d9d7d1b42763f9297927180d851d27dda50dcd
Перейти к файлу
Код
meeting srcdev-mock.ts
anc Разбор по голосам: «Спикер 1», «Спикер 2»… на компьютере, без сети - crates/voices: отпечаток голоса реплики (WeSpeaker ResNet34 через sherpa-onnx, статические сборки под Windows и macOS) и разбиение снизу вверх со средней связью. Из отпечатков вычитается средний по записи: без этого у подкаста любые две реплики были похожи на 0,5–0,8. - После распознавания реплики «Собеседников» и файла раскладываются по голосам; микрофон — «Я». Отпечатки и реплики — в voices.json рядом с записью: «Говорящих: N» пересобирает разбиение без новой расшифровки. - Ход работы: этап «Разделяю по голосам» с процентом; по ходу распознавания у реплик разбираемых дорожек ещё нет имени. - Исправлено: файл без смены говорящего ложился одним абзацем 00:00:00 (подкаст 1 ч 52 мин — 170 КБ одной строкой). Абзац теперь — реплики одного говорящего без пауз больше 1,5 с и не длиннее ~600 знаков. У реплики появился конец (из ответа Whisper или до следующей). - Модель (26 МБ) не в git: scripts/fetch-models.sh с проверкой SHA-256, в сборку — ресурсом. Windows — со статической средой C (+crt-static, явная libucrt): установщику не нужен VC++ Redistributable; crate-type ядра — только rlib (cdylib нужен лишь мобильным платформам). - Проверено на подкасте Kuji 230 (1 ч 52 мин, трое в студии): трое, 48 % / 36 % / 15 % речи, отпечатки 1184 реплик за ~3 мин. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> d0d9d7d 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");

/** Расшифровка с говорящими «Спикер 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" };
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 "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}`;
  }
}