skillok meeting
778ade180d60c9f7692423132486bd257954c517
Перейти к файлу
Код
meeting srcapi.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 г.
api.ts
// Типы и вызовы команд ядра (src-tauri/src/commands.rs). Поля совпадают с
// тем, что отдаёт serde на стороне Rust.

export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "unknown" } | { kind: "voice"; n: number };

export interface AudioTrack { file: string; speaker: Speaker; duration_ms: number }

export interface Meeting {
  id: string;
  title: string;
  created_at: string;
  updated_at: string;
  audio: AudioTrack[];
  recording: boolean;
  transcribed_at: string | null;
}

/** `voices` — есть реплики с отпечатками голосов: разбиение по говорящим пересобирается без новой расшифровки. */
export interface MeetingDetail { meeting: Meeting; note: string; voices: boolean }

export interface Settings {
  ai_model: string;
  stt_model: string;
  language: string;
  warning_text: string;
  storage_dir: string;
}

export interface SettingsView {
  settings: Settings;
  openrouter_key: string | null;
  meetings_dir: string;
  default_meetings_dir: string;
  capture_supported: boolean;
}

export interface KeyInfo { label: string | null; limit_remaining: number | null; usage: number | null; is_free_tier: boolean }

export type Command =
  | { kind: "summary" }
  | { kind: "tasks" }
  | { kind: "review" }
  | { kind: "custom"; text: string };

export interface RecordingView {
  meeting_id: string;
  started_at: string;
  paused: boolean;
  /** Пишется звук компьютера (собеседники). */
  system: boolean;
  /** Почему звук компьютера не пишется. */
  system_error: string | null;
}

/** `voices` — сколько говорящих нашлось по голосам (0 — разбора не было), `voices_error` — почему не было. */
export interface TranscribeResult {
  note: string;
  failed: number;
  last_error: string | null;
  voices: number;
  voices_error: string | null;
}

export interface CommandResult { note: string; heading: string }

export type RecorderEvent =
  | { kind: "level"; data: { speaker: Speaker; dbfs: number } }
  | { kind: "track_failed"; data: { speaker: Speaker; error: string } };

type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
type Listen = <T>(event: string, cb: (payload: T) => void) => Promise<() => void>;

interface Core {
  invoke: Invoke;
  listen: Listen;
  pickAudio: () => Promise<string | null>;
  pickFolder: () => Promise<string | null>;
  onFileDrop: (cb: (paths: string[]) => void) => Promise<() => void>;
}

const AUDIO_EXTENSIONS = ["mp3", "m4a", "wav", "aiff", "aif", "flac", "ogg", "aac", "mp4", "mkv", "caf"];

let core: Promise<Core> | null = null;

function getCore(): Promise<Core> {
  core ??= (async (): Promise<Core> => {
    if ("__TAURI_INTERNALS__" in window) {
      const { invoke } = await import("@tauri-apps/api/core");
      const { listen } = await import("@tauri-apps/api/event");
      const { getCurrentWebview } = await import("@tauri-apps/api/webview");
      const { open } = await import("@tauri-apps/plugin-dialog");
      return {
        invoke,
        listen: (event, cb) => listen(event, (e) => cb(e.payload as never)),
        pickAudio: async () => {
          const picked = await open({ multiple: false, filters: [{ name: "Аудио", extensions: AUDIO_EXTENSIONS }] });
          return typeof picked === "string" ? picked : null;
        },
        pickFolder: async () => {
          const picked = await open({ directory: true, multiple: false });
          return typeof picked === "string" ? picked : null;
        },
        onFileDrop: (cb) =>
          getCurrentWebview().onDragDropEvent((e) => {
            if (e.payload.type === "drop") cb(e.payload.paths);
          }),
      };
    }
    if (import.meta.env.DEV || import.meta.env.VITE_MOCK === "1") {
      // Только для просмотра интерфейса в обычном браузере: `npm run dev`
      // или сборка `npm run build:mock`. В сборку приложения не попадает.
      return import("./dev-mock");
    }
    throw new Error("Skillok Meet запущен без ядра");
  })();
  return core;
}

async function call<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
  return (await getCore()).invoke<T>(cmd, args);
}

export async function on<T>(event: string, cb: (payload: T) => void): Promise<() => void> {
  return (await getCore()).listen<T>(event, cb);
}

export const events = {
  recorder: (cb: (p: { meeting_id: string; event: RecorderEvent }) => void) => on("meet://recorder", cb),
  /** Ход расшифровки: `lines` — новые реплики по порядку времени, абзацами как в заметке. */
  progress: (
    cb: (p: { meeting_id: string; stage: "speech" | "voices"; done: number; total: number; lines: string[] }) => void,
  ) => on("meet://progress", cb),
  /** Ответ ИИ пишется: `text` — новый кусок, его дописывают к показанному. */
  answer: (cb: (p: { meeting_id: string; heading: string; text: string }) => void) => on("meet://answer", cb),
};

export const files = {
  pickAudio: async () => (await getCore()).pickAudio(),
  pickFolder: async () => (await getCore()).pickFolder(),
  onDrop: async (cb: (paths: string[]) => void) => (await getCore()).onFileDrop(cb),
  isAudio: (path: string) => AUDIO_EXTENSIONS.includes(path.split(".").pop()?.toLowerCase() ?? ""),
};

export const api = {
  getSettings: () => call<SettingsView>("get_settings"),
  saveSettings: (settings: Settings) => call<void>("save_settings", { settings }),
  setOpenrouterKey: (key: string) => call<KeyInfo>("set_openrouter_key", { key }),
  checkOpenrouterKey: () => call<KeyInfo>("check_openrouter_key"),
  deleteOpenrouterKey: () => call<void>("delete_openrouter_key"),
  listMeetings: () => call<Meeting[]>("list_meetings"),
  getMeeting: (id: string) => call<MeetingDetail>("get_meeting", { id }),
  saveNote: (id: string, text: string) => call<Meeting>("save_note", { id, text }),
  removeSection: (id: string, heading: string) => call<string>("remove_section", { id, heading }),
  cancelJob: (id: string) => call<void>("cancel_job", { id }),
  setStorageDir: (path: string | null) => call<string>("set_storage_dir", { path }),
  openFolder: (id: string | null) => call<void>("open_folder", { id }),
  renameMeeting: (id: string, title: string) => call<Meeting>("rename_meeting", { id, title }),
  deleteMeeting: (id: string) => call<void>("delete_meeting", { id }),
  addAudio: (path: string) => call<Meeting>("add_audio", { path }),
  transcribe: (id: string) => call<TranscribeResult>("transcribe_meeting", { id }),
  /** Пересобрать разбиение по говорящим: `count` — сколько их, `null` — сколько получится. */
  regroupSpeakers: (id: string, count: number | null) =>
    call<{ note: string; voices: number }>("regroup_speakers", { id, count }),
  runCommand: (id: string, command: Command) => call<CommandResult>("run_command", { id, command }),
  recorderStatus: () => call<RecordingView | null>("recorder_status"),
  recorderStart: () => call<Meeting>("recorder_start"),
  recorderPause: (paused: boolean) => call<void>("recorder_pause", { paused }),
  recorderStop: () => call<Meeting>("recorder_stop"),
};

export function formatDuration(ms: number): string {
  if (ms <= 0) return "";
  if (ms < 60000) return "меньше минуты";
  const min = Math.round(ms / 60000);
  if (min < 60) return `${min} мин`;
  return `${Math.floor(min / 60)} ч ${min % 60} мин`;
}

export function meetingDuration(m: Meeting): number {
  return Math.max(0, ...m.audio.map((a) => a.duration_ms));
}

export function formatDate(iso: string): string {
  return new Date(iso).toLocaleString("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}

export 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)}`;
}

export function errorText(e: unknown): string {
  return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
}