skillok meeting
d0d9d7d1b42763f9297927180d851d27dda50dcd
Перейти к файлу
Код
meeting srcrecording.ts
anc Меню как у skillok.org, живая расшифровка и ответ ИИ, длинные записи (#13) - Логотип убран. Шапка и меню как у skillok.org: кнопка ☰ открывает панель слева (Главная, Записать, Добавить аудиофайл, записи с поиском, Настройки), закрытие крестиком, кликом мимо, Esc и переходом; крошки «Skillok Meet / запись». Список записей с поиском — на главной. - Во время записи один общий уровень вместо «Вы» и «Собеседники». - Расшифровка по ходу: реплики приходят по порядку времени (кусок уходит, когда готовы все куски раньше), текст растёт на экране. - Ответ ИИ потоком (stream: true): конспект, задачи, ревью и свой запрос пишутся на глазах. - Длинные записи: дорожка не читается в память целиком — нарезка блоками по 10 с, каждый кусок читается с диска перед отправкой; чужой файл один раз перегоняется во временный WAV. Прогон 2 ч × 2 дорожки с подделкой распознавания: 1600 кусков, последняя реплика 01:59:55, пик памяти 25 МБ. - Говорящий в файле без дорожек — «Спикер 1». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 3938872 30 сентября 2026 г.
recording.ts
// Запись: панель с таймером, уровнями и кнопками — одна на главной и на
// экране записи. Метка «идёт запись» с любого экрана — в шапке (nav.ts).

import { api, elapsed, events } from "./api";
import { esc } from "./dom";
import type { Scope } from "./scope";
import { changed, copy, refreshRecording, state, toast } from "./state";

export function recordingPanel(warning: string): string {
  const r = state.recording;
  if (!r) return "";
  return `
    <div class="rec" data-rec>
      <p class="rec-state"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${
        r.paused ? "Запись на паузе" : r.system ? "Идёт запись: собеседники и вы" : "Идёт запись: только ваш микрофон"
      }</p>
      ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся: ${esc(r.system_error)}</p>` : ""}
      <p class="rec-timer mono-num" data-timer>${elapsed(r.started_at)}</p>
      <div class="meter" title="Громкость того, что пишется: ваш микрофон и звук компьютера вместе">
        <span class="meter-bar"><span class="meter-fill" data-level></span></span>
      </div>
      <div class="actions">
        <button class="ui-btn" data-rec-stop>Остановить</button>
        <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button>
        <button class="link-btn" data-rec-warning>Скопировать предупреждение для чата</button>
      </div>
      <template data-warning>${esc(warning)}</template>
    </div>`;
}

/** Кнопки панели и живые уровни звука. `onStop` — после остановки. */
export function bindRecording(root: HTMLElement, scope: Scope, warning: string, onStop: () => void): void {
  root.querySelector<HTMLButtonElement>("[data-rec-stop]")?.addEventListener("click", async (e) => {
    const b = e.currentTarget as HTMLButtonElement;
    b.disabled = true;
    b.textContent = "Останавливаю…";
    try {
      await api.recorderStop();
      await refreshRecording();
      onStop();
    } catch (err) {
      b.disabled = false;
      toast(err);
    }
  });
  root.querySelector("[data-rec-pause]")?.addEventListener("click", async () => {
    if (!state.recording) return;
    try {
      await api.recorderPause(!state.recording.paused);
      state.recording.paused = !state.recording.paused;
      changed();
    } catch (err) {
      toast(err);
    }
  });
  root.querySelector("[data-rec-warning]")?.addEventListener("click", () => copy(warning, "Предупреждение скопировано — вставьте его в чат встречи"));
  // Один общий уровень: громче из дорожек за последние полсекунды. Человеку
  // важно одно — запись идёт и звук есть; делить его на «вы» и «собеседники»
  // незачем (решение владельца 01.10.2026).
  const last: Record<string, { dbfs: number; at: number }> = {};
  scope.on(
    events.recorder(({ event }) => {
      if (event.kind === "level") {
        const now = performance.now();
        last[event.data.speaker.kind] = { dbfs: event.data.dbfs, at: now };
        const dbfs = Math.max(-90, ...Object.values(last).filter((l) => now - l.at < 500).map((l) => l.dbfs));
        const fill = root.querySelector<HTMLElement>("[data-level]");
        if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((dbfs + 60) / 55) * 100))}%`;
      } else {
        toast(`Дорожка «${event.data.speaker.kind === "me" ? "вы" : "собеседники"}» перестала писаться: ${event.data.error}`);
      }
    }),
  );
  scope.every(1000, () => {
    const t = root.querySelector("[data-timer]");
    if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
  });
}