skillok meeting
0fd9dec299001feea939bb2bf54891037292726b
Перейти к файлу
Код
meeting srcapi.ts
anc Экран записи и проигрыватель: волна, источники, отметки, звук ↔ текст Запись: - Время — по записанному звуку, паузы не в счёт (позиция сессии записи, ms в событиях уровня, position_ms в recorder_status). - Живая «волна» последней минуты, «Микрофон / Звук компьютера — слышно / тишина», объём записи; «Остановить и расшифровать». - «★ Отметить момент»: отметка с подписью (Meeting.markers, add_marker / set_markers); видна в расшифровке и на проигрывателе. Проигрыватель (player.ts): - Полоса внизу экрана записи: играть, ±15 с, скорость до 2×, «волна» всей встречи (клик и протяжка — перемотка, отметки, текущее место), «★» — отметить место. Две дорожки записи играют вместе и подравниваются. Живёт дольше перерисовок экрана — звук не прерывается. - Звук читается с диска протоколом asset: только из папки записей (protocol-asset, scope на папку, media-src в CSP); «волна» считается один раз и кэшируется в peaks.json. - Время реплики и отметки — кнопка «слушать отсюда», звучащая реплика подсвечивается; пробел — играть/пауза, ←/→ — 5 с. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> b82e259 2 октября 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;
  /** Отметки «важный момент» — по времени. */
  markers: Marker[];
}

/** Отметка момента: место в звуке (мс, паузы не в счёт) и подпись. */
export interface Marker { ms: number; text: string }

/** Что играть: файлы звука записи (у записи — две дорожки) и «волна» для прокрутки. */
export interface PlaybackView {
  tracks: { path: string; speaker: Speaker }[];
  duration_ms: number;
  /** Громкость равных отрезков записи, 0…1. */
  peaks: number[];
}

/** `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;
  /** Сколько звука записано, мс (паузы не в счёт). */
  position_ms: number;
}

/** `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 =
  /** Уровень дорожки; `ms` — сколько звука она уже записала (паузы не в счёт). */
  | { kind: "level"; data: { speaker: Speaker; dbfs: number; ms: number } }
  | { kind: "track_failed"; data: { speaker: Speaker; error: string } }
  /** Дорожка давно ничего не слышит (на паузе не считается). */
  | { kind: "silent"; data: { speaker: Speaker; seconds: number } }
  /** Звук на дорожке снова есть. */
  | { kind: "heard"; data: { speaker: Speaker } };

/** Проверка звука до записи: что не открылось и почему. */
export interface CheckView { mic_error: string | null; system_error: string | null }

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>;
  /** Адрес, по которому интерфейс читает файл с диска (протокол `asset:`). */
  fileSrc: (path: string) => string;
}

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, convertFileSrc } = 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);
          }),
        fileSrc: (path) => convertFileSrc(path),
      };
    }
    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() ?? ""),
  src: async (path: string) => (await getCore()).fileSrc(path),
};

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"),
  /** Отметить момент: `ms` пусто — там, где сейчас запись. */
  addMarker: (id: string, ms: number | null, text = "") => call<Meeting>("add_marker", { id, ms, text }),
  setMarkers: (id: string, markers: Marker[]) => call<Meeting>("set_markers", { id, markers }),
  playback: (id: string) => call<PlaybackView>("playback", { id }),
  /** Открыть микрофон и звук компьютера без записи — только уровни (`meeting_id` событий пустой). */
  audioCheckStart: () => call<CheckView>("audio_check_start"),
  audioCheckStop: () => call<void>("audio_check_stop"),
  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);
}