skillok meeting

Этап 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>

anc · 30 сентября 2026 г. 169ce64e1705fd64e48ee357a816bbdb90bab01d
32 файла
  • Показать разницуСкрыть разницу
    48 48 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
    49 49
    50 50 [[package]]
    51 +name = "async-broadcast"
    52 +version = "0.7.2"
    53 +source = "registry+https://github.com/rust-lang/crates.io-index"
    54 +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
    55 +dependencies = [
    56 + "event-listener",
    57 + "event-listener-strategy",
    58 + "futures-core",
    59 + "pin-project-lite",
    60 +]
    61 +
    62 +[[package]]
    63 +name = "async-channel"
    64 +version = "2.5.0"
    65 +source = "registry+https://github.com/rust-lang/crates.io-index"
    66 +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
    67 +dependencies = [
    68 + "concurrent-queue",
    69 + "event-listener-strategy",
    70 + "futures-core",
    71 + "pin-project-lite",
    72 +]
    73 +
    74 +[[package]]
    75 +name = "async-executor"
    76 +version = "1.14.0"
    77 +source = "registry+https://github.com/rust-lang/crates.io-index"
    78 +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
    79 +dependencies = [
    80 + "async-task",
    81 + "concurrent-queue",
    82 + "fastrand",
    83 + "futures-lite",
    84 + "pin-project-lite",
    85 + "slab",
    86 +]
    87 +
    88 +[[package]]
    89 +name = "async-io"
    90 +version = "2.6.0"
    91 +source = "registry+https://github.com/rust-lang/crates.io-index"
    92 +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
    93 +dependencies = [
    94 + "autocfg",
    95 + "cfg-if",
    96 + "concurrent-queue",
    97 + "futures-io",
    98 + "futures-lite",
    99 + "parking",
    100 + "polling",
    101 + "rustix",
    102 + "slab",
    103 + "windows-sys 0.61.2",
    104 +]
    105 +
    106 +[[package]]
    107 +name = "async-lock"
    108 +version = "3.4.2"
    109 +source = "registry+https://github.com/rust-lang/crates.io-index"
    110 +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
    111 +dependencies = [
    112 + "event-listener",
    113 + "event-listener-strategy",
    114 + "pin-project-lite",
    115 +]
    116 +
    117 +[[package]]
    118 +name = "async-process"
    119 +version = "2.5.0"
    120 +source = "registry+https://github.com/rust-lang/crates.io-index"
    121 +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
    122 +dependencies = [
    123 + "async-channel",
    124 + "async-io",
    125 + "async-lock",
    126 + "async-signal",
    127 + "async-task",
    128 + "blocking",
    129 + "cfg-if",
    130 + "event-listener",
    131 + "futures-lite",
    132 + "rustix",
    133 +]
    134 +
    135 +[[package]]
    136 +name = "async-recursion"
    137 +version = "1.1.1"
    138 +source = "registry+https://github.com/rust-lang/crates.io-index"
    139 +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
    140 +dependencies = [
    Разница обрезана: показаны первые 93 строк.
  • Cargo.toml +3
    Показать разницуСкрыть разницу
    1 +[workspace]
    2 +resolver = "2"
    3 +members = ["src-tauri", "crates/audio"]
  • README.md +43 −13
    Показать разницуСкрыть разницу
    32 32
    33 33 ```
    34 34 Zoom.exe ──звук──┐
    35 − ├─► захват (WASAPI) ─► куски по ~30 с ─► OpenRouter: Whisper ─► расшифровка
    35 + ├─► захват (WASAPI) ─► куски 6–28 с ──► OpenRouter: Whisper ─► расшифровка
    36 36 микрофон ────────┘ │
    37 37 ▼
    38 38 OpenRouter: Claude ◄── конспект / разбор
    48 48 См. [Application loopback sample](https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/)
    49 49 и [ActivateAudioInterfaceAsync](https://learn.microsoft.com/en-us/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-activateaudiointerfaceasync).
    50 50 Нужна Windows 10 сборки 20438 или новее.
    51 −- Начало встречи определяем по появлению звуковой сессии `Zoom.exe` и окна встречи.
    52 − Конец — по их исчезновению.
    51 +- Встреча идёт, пока у `Zoom.exe` есть активная звуковая сессия вывода: во время встречи Zoom
    52 + держит поток открытым, даже когда все молчат. Началом считаем 4 секунды такой сессии
    53 + (звук уведомления — не встреча), концом — 20 секунд без неё (переподключение не рвёт запись).
    53 54 - API Zoom, аккаунт разработчика и одобрение в Marketplace **не нужны**. Работает на любой
    54 55 встрече и на любом плане Zoom, в том числе бесплатном и на чужой встрече.
    55 56
    60 61 `openai/whisper-large-v3`, `openai/whisper-1`.
    61 62 См. [документацию OpenRouter STT](https://openrouter.ai/docs/guides/overview/multimodal/stt).
    62 63 - Эндпоинт принимает файл целиком, потокового режима нет. Поэтому звук режем на куски
    63 − по ~30 секунд по паузам в речи. Расшифровка отстаёт от живой речи на длину куска
    64 + от 6 до 28 секунд по паузам в речи, куски без речи не отправляем. Расшифровка отстаёт от живой речи на длину куска
    64 65 плюс время ответа.
    65 66 - Дорожки «я» и «собеседники» распознаём раздельно. Так реплики подписаны без узнавания
    66 67 людей по голосу.
    199 200 npm run tauri dev # приложение целиком: ядро + интерфейс
    200 201 npm run dev # только интерфейс в браузере, http://localhost:1420, с заглушкой ядра
    201 202 npm run typecheck # проверка типов интерфейса
    202 −cd src-tauri && cargo test
    203 +cargo test --workspace # тесты ядра и звука
    203 204 ```
    204 205
    206 +Запись Zoom работает только в Windows. Проверить цепочку «звук → куски → распознавание →
    207 +расшифровка» на другой машине можно файлом: `MEET_DEV_REPLAY=путь.wav npm run tauri dev`
    208 +(WAV 16 кГц, моно, 16 бит) — он играет роль звука собеседников, «Записать вручную» его запускает.
    209 +Нужен настоящий ключ OpenRouter: распознавание платное.
    210 +
    211 +В браузере заглушка изображает и запись. Ручки в консоли:
    212 +`__meetMock.meetingStarted()`, `__meetMock.meetingEnded()`, `__meetMock.noKey()`, `__meetMock.unsupported()`.
    213 +
    214 +Код под Windows проверяется с Mac так (нужен `rustup target add x86_64-pc-windows-msvc`):
    215 +
    216 +```
    217 +cargo clippy -p meet-audio --target x86_64-pc-windows-msvc --all-targets
    218 +```
    219 +
    220 +Всё приложение под Windows с Mac не собрать: встраиванию ресурсов Windows нужен `llvm-rc`.
    221 +Собирать установщик — на Windows: `npm run tauri build`.
    222 +
    205 223 Раскладка:
    206 224
    207 225 ```
    208 226 src/ интерфейс: HTML/CSS/TS, стиль skillok.org (src/styles/tokens.css)
    209 227 src/dev-mock.ts заглушка ядра — только для `npm run dev` в браузере
    228 +crates/audio/ звук, без Tauri
    229 + pcm.rs формат 16 кГц моно 16 бит, WAV, уровень
    230 + chunker.rs нарезка на куски по паузам, отсев тишины
    231 + detector.rs когда встреча в Zoom началась и кончилась
    232 + source.rs источник звука; Replay — звук из памяти для тестов
    233 + win.rs Windows: захват процесса Zoom и микрофона, поиск сессии Zoom
    210 234 src-tauri/src/ ядро на Rust
    211 235 transcript.rs реплики, импорт текста, Markdown расшифровки
    212 236 analysis.rs схема разбора, проверка ответа модели, summary.md
    213 − openrouter.rs клиент OpenRouter: проверка ключа, разбор
    237 + openrouter.rs клиент OpenRouter: ключ, распознавание, живой конспект, разбор
    238 + recorder.rs запись встречи: дорожки → куски → распознавание → расшифровка
    239 + controller.rs что программа делает сама: следит за Zoom, согласие, запись, разбор
    240 + shell.rs окно, трей, уведомления, автозапуск
    214 241 settings.rs настройки; ключ — в хранилище паролей ОС (keyring)
    215 242 meetings.rs встречи на диске, папка на встречу
    216 243 commands.rs команды для интерфейса
    217 −prompts/analysis.md системный промпт разбора
    244 +prompts/ системные промпты разбора и живого конспекта
    218 245 fixtures/transcripts проверочные расшифровки
    219 246 ```
    220 247
    236 263
    237 264 ### Этап 1. MVP
    238 265
    239 −- [ ] Захват звука `Zoom.exe` и микрофона на две дорожки.
    240 −- [ ] Автостарт и автостоп по встрече в Zoom, значок в трее.
    241 −- [ ] Предупреждение участникам и отметка «предупреждены».
    242 −- [ ] Куски по паузам → Whisper через OpenRouter → живая расшифровка.
    243 −- [ ] Живой конспект и итоговый разбор через Claude.
    244 −- [ ] Сохранение встречи на диск, экспорт в Markdown.
    266 +- [x] Захват звука `Zoom.exe` и микрофона на две дорожки.
    267 +- [x] Автостарт и автостоп по встрече в Zoom, значок в трее.
    268 +- [x] Предупреждение участникам и отметка «предупреждены».
    269 +- [x] Куски по паузам → Whisper через OpenRouter → живая расшифровка.
    270 +- [x] Живой конспект и итоговый разбор через Claude.
    271 +- [x] Сохранение встречи на диск, экспорт в Markdown.
    272 +- [ ] **Проверить на Windows с настоящей встречей в Zoom** — код под Windows только
    273 + скомпилирован с Mac, ни разу не запускался.
    274 +- [ ] Распознать заново из сохранённого звука, если кусок не распознался или разбор упал.
    Разница обрезана: показаны первые 96 строк.
  • Показать разницуСкрыть разницу
    1 +[package]
    2 +name = "meet-audio"
    3 +version = "0.1.0"
    4 +description = "Захват звука встречи: процесс Zoom и микрофон (WASAPI), нарезка на куски по паузам"
    5 +edition = "2021"
    6 +rust-version = "1.90"
    7 +
    8 +[dependencies]
    9 +thiserror = "2"
    10 +
    11 +[target.'cfg(windows)'.dependencies]
    12 +wasapi = "0.24"
    13 +windows = { version = "0.62", features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp"] }
  • Показать разницуСкрыть разницу
    1 +//! Нарезка дорожки на куски для распознавания.
    2 +//!
    3 +//! Эндпоинт OpenRouter принимает файл целиком, поэтому живая расшифровка — это
    4 +//! куски по нескольку секунд. Резать надо по паузе, а не по таймеру: слово,
    5 +//! разрезанное пополам, Whisper распознаёт неверно в обоих кусках.
    6 +//!
    7 +//! Правила:
    8 +//! - кусок не короче `min_ms` и режется на первой паузе не короче `pause_ms`;
    9 +//! - дольше `max_ms` кусок не растёт — режется в самом тихом месте последних секунд;
    10 +//! - кусок, где речи меньше `min_speech_ms`, выбрасывается: тишина стоит денег,
    11 +//! а на тишине Whisper сочиняет фразы («Продолжение следует…»);
    12 +//! - время у куска — от начала записи, дыры в потоке (устройство молчало)
    13 +//! заполняются тишиной, чтобы время реплик не уезжало.
    14 +//!
    15 +//! Речь от тишины отличается по громкости кадра 20 мс относительно шумового
    16 +//! фона, который подстраивается сам: вентилятор ноутбука — не речь.
    17 +
    18 +use crate::pcm::{ms_to_samples, samples_to_ms};
    19 +
    20 +const FRAME_MS: u64 = 20;
    21 +
    22 +#[derive(Debug, Clone, Copy, PartialEq)]
    23 +pub struct Config {
    24 + pub min_ms: u64,
    25 + pub max_ms: u64,
    26 + pub pause_ms: u64,
    27 + pub min_speech_ms: u64,
    28 + /// Кадр — речь, если громче фона во столько раз (по RMS).
    29 + pub over_floor: f32,
    30 + /// И не тише этого RMS в единицах i16 (≈ −47 dBFS).
    31 + pub min_rms: f32,
    32 +}
    33 +
    34 +impl Default for Config {
    35 + fn default() -> Self {
    36 + Self {
    37 + min_ms: 6_000,
    38 + max_ms: 28_000,
    39 + pause_ms: 700,
    40 + min_speech_ms: 400,
    41 + over_floor: 3.0,
    42 + min_rms: 150.0,
    43 + }
    44 + }
    45 +}
    46 +
    47 +#[derive(Debug, Clone, PartialEq)]
    48 +pub struct Chunk {
    49 + /// Начало куска от начала записи, мс.
    50 + pub start_ms: u64,
    51 + pub samples: Vec<i16>,
    52 + /// Сколько в куске речи, мс.
    53 + pub speech_ms: u64,
    54 +}
    55 +
    56 +impl Chunk {
    57 + pub fn duration_ms(&self) -> u64 {
    58 + samples_to_ms(self.samples.len())
    59 + }
    60 +}
    61 +
    62 +pub struct Chunker {
    63 + cfg: Config,
    64 + /// Время первого сэмпла в `buf`, мс от начала записи.
    65 + buf_start_ms: u64,
    66 + buf: Vec<i16>,
    67 + /// Разметка `buf` по кадрам: речь или нет.
    68 + frames: Vec<bool>,
    69 + /// Хвост, не набравший целого кадра.
    70 + partial: usize,
    71 + noise_floor: f32,
    72 + /// Сколько тишины подряд в конце `buf`, мс.
    73 + trailing_silence_ms: u64,
    74 +}
    75 +
    76 +impl Chunker {
    77 + pub fn new(cfg: Config) -> Self {
    78 + Self {
    79 + cfg,
    80 + buf_start_ms: 0,
    81 + buf: Vec::new(),
    82 + frames: Vec::new(),
    83 + partial: 0,
    84 + noise_floor: 300.0,
    85 + trailing_silence_ms: 0,
    86 + }
    87 + }
    88 +
    89 + /// Позиция конца принятого звука, мс от начала записи.
    90 + pub fn position_ms(&self) -> u64 {
    91 + self.buf_start_ms + samples_to_ms(self.buf.len())
    92 + }
    93 +
    94 + /// Принимает звук, который закончился в момент `end_ms`. Если между прошлым
    95 + /// концом и началом этого звука дыра больше 200 мс — она заполняется
    96 + /// тишиной. Возвращает готовые куски (обычно ни одного).
    97 + pub fn push(&mut self, end_ms: u64, samples: &[i16]) -> Vec<Chunk> {
    98 + let start_ms = end_ms.saturating_sub(samples_to_ms(samples.len()));
    99 + let pos = self.position_ms();
    Разница обрезана: показаны первые 99 строк.
  • Показать разницуСкрыть разницу
    1 +//! Когда встреча в Zoom началась и когда закончилась.
    2 +//!
    3 +//! Признак встречи — у процесса Zoom есть активная звуковая сессия вывода:
    4 +//! во время встречи Zoom держит поток воспроизведения открытым, даже когда все
    5 +//! молчат. Вне встречи потока нет (или он короткий — звук уведомления).
    6 +//!
    7 +//! Сам опрос системы — в `windows::zoom`; здесь только решение по его
    8 +//! результатам, чтобы его можно было проверить тестами на любой машине:
    9 +//! - встреча началась, если сессия активна дольше `start_after_ms` — отсекаем
    10 +//! звук входящего сообщения и тестовый звонок динамика;
    11 +//! - встреча закончилась, если сессии нет дольше `end_after_ms` — переподключение
    12 +//! и смена динамика в настройках Zoom не рвут запись;
    13 +//! - процесс Zoom сменился (перезапуск) — это конец прежней встречи.
    14 +
    15 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    16 +pub struct Config {
    17 + pub start_after_ms: u64,
    18 + pub end_after_ms: u64,
    19 +}
    20 +
    21 +impl Default for Config {
    22 + fn default() -> Self {
    23 + Self {
    24 + start_after_ms: 4_000,
    25 + end_after_ms: 20_000,
    26 + }
    27 + }
    28 +}
    29 +
    30 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    31 +pub enum Event {
    32 + /// Встреча началась; `pid` — корневой процесс Zoom, его звук и пишем.
    33 + Started {
    34 + pid: u32,
    35 + },
    36 + Ended,
    37 +}
    38 +
    39 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    40 +enum State {
    41 + Idle,
    42 + /// Сессия активна с момента `since`, но ещё мало.
    43 + Starting {
    44 + pid: u32,
    45 + since: u64,
    46 + },
    47 + InMeeting {
    48 + pid: u32,
    49 + last_seen: u64,
    50 + },
    51 +}
    52 +
    53 +pub struct Detector {
    54 + cfg: Config,
    55 + state: State,
    56 +}
    57 +
    58 +impl Detector {
    59 + pub fn new(cfg: Config) -> Self {
    60 + Self {
    61 + cfg,
    62 + state: State::Idle,
    63 + }
    64 + }
    65 +
    66 + pub fn in_meeting(&self) -> Option<u32> {
    67 + match self.state {
    68 + State::InMeeting { pid, .. } => Some(pid),
    69 + _ => None,
    70 + }
    71 + }
    72 +
    73 + /// Очередной опрос в момент `now_ms`: `active` — процесс Zoom с активной
    74 + /// сессией вывода, если он есть.
    75 + pub fn poll(&mut self, now_ms: u64, active: Option<u32>) -> Option<Event> {
    76 + match (self.state, active) {
    77 + (State::Idle, Some(pid)) => {
    78 + self.state = State::Starting { pid, since: now_ms };
    79 + self.check_start(now_ms)
    80 + }
    81 + (State::Idle, None) => None,
    82 + (State::Starting { pid, .. }, Some(p)) if p == pid => self.check_start(now_ms),
    83 + (State::Starting { .. }, Some(p)) => {
    84 + self.state = State::Starting {
    85 + pid: p,
    86 + since: now_ms,
    87 + };
    88 + None
    89 + }
    90 + (State::Starting { .. }, None) => {
    91 + self.state = State::Idle;
    92 + None
    93 + }
    94 + (State::InMeeting { pid, .. }, Some(p)) if p == pid => {
    95 + self.state = State::InMeeting {
    96 + pid,
    97 + last_seen: now_ms,
    98 + };
    99 + None
    Разница обрезана: показаны первые 99 строк.
  • Показать разницуСкрыть разницу
    1 +//! Звук встречи: захват, формат, нарезка на куски и распознавание начала
    2 +//! встречи в Zoom. Всё, что не зависит от Windows, проверяется тестами на любой
    3 +//! машине; сам захват — в модуле [`win`] и собирается только под Windows.
    4 +
    5 +pub mod chunker;
    6 +pub mod detector;
    7 +pub mod pcm;
    8 +pub mod source;
    9 +
    10 +#[cfg(windows)]
    11 +pub mod win;
    12 +
    13 +/// Микрофон и звук процесса Zoom. Вне Windows — ошибка «только в Windows».
    14 +pub fn open_microphone() -> Result<Box<dyn source::Capture>, source::CaptureError> {
    15 + #[cfg(windows)]
    16 + {
    17 + Ok(Box::new(win::Microphone::open()?))
    18 + }
    19 + #[cfg(not(windows))]
    20 + {
    21 + Err(source::CaptureError::Unsupported)
    22 + }
    23 +}
    24 +
    25 +pub fn open_process_loopback(_pid: u32) -> Result<Box<dyn source::Capture>, source::CaptureError> {
    26 + #[cfg(windows)]
    27 + {
    28 + Ok(Box::new(win::ProcessLoopback::open(_pid)?))
    29 + }
    30 + #[cfg(not(windows))]
    31 + {
    32 + Err(source::CaptureError::Unsupported)
    33 + }
    34 +}
    35 +
    36 +/// Процесс Zoom с активной сессией вывода звука, если он есть. Вне Windows — `None`.
    37 +pub fn active_zoom_process() -> Option<u32> {
    38 + #[cfg(windows)]
    39 + {
    40 + win::active_zoom_process()
    41 + }
    42 + #[cfg(not(windows))]
    43 + {
    44 + None
    45 + }
    46 +}
    47 +
    48 +/// Запись встреч возможна на этой машине.
    49 +pub const CAPTURE_SUPPORTED: bool = cfg!(windows);
  • Показать разницуСкрыть разницу
    1 +//! Формат звука во всей программе: 16 кГц, моно, 16 бит. Столько ждёт
    2 +//! Whisper, и в таком виде WASAPI отдаёт звук сам (автоконвертация), так что
    3 +//! своего ресемплера нет.
    4 +
    5 +use std::fs::File;
    6 +use std::io::{self, Seek, SeekFrom, Write};
    7 +use std::path::Path;
    8 +
    9 +pub const SAMPLE_RATE: u32 = 16_000;
    10 +pub const SAMPLES_PER_MS: u64 = SAMPLE_RATE as u64 / 1000;
    11 +
    12 +pub fn ms_to_samples(ms: u64) -> usize {
    13 + (ms * SAMPLES_PER_MS) as usize
    14 +}
    15 +
    16 +pub fn samples_to_ms(samples: usize) -> u64 {
    17 + samples as u64 / SAMPLES_PER_MS
    18 +}
    19 +
    20 +/// Уровень куска в dBFS: 0 — предел, тишина — около −90.
    21 +pub fn level_dbfs(samples: &[i16]) -> f32 {
    22 + if samples.is_empty() {
    23 + return -90.0;
    24 + }
    25 + let sum: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum();
    26 + let rms = (sum / samples.len() as f64).sqrt();
    27 + if rms < 1.0 {
    28 + return -90.0;
    29 + }
    30 + (20.0 * (rms / i16::MAX as f64).log10()) as f32
    31 +}
    32 +
    33 +/// Float-сэмплы (−1..1) в i16 с насыщением.
    34 +pub fn f32_to_i16(samples: &[f32]) -> Vec<i16> {
    35 + samples
    36 + .iter()
    37 + .map(|&s| (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16)
    38 + .collect()
    39 +}
    40 +
    41 +/// Приводит float-звук с `channels` каналами и частотой `rate` к нашему формату.
    42 +/// Каналы усредняются. Кратная частота (48, 32 кГц) понижается усреднением
    43 +/// соседних сэмплов — это заодно простой фильтр от наложения частот; для
    44 +/// речи его хватает. Некратная — линейной интерполяцией.
    45 +pub fn to_native(samples: &[f32], channels: usize, rate: u32) -> Vec<i16> {
    46 + let channels = channels.max(1);
    47 + let mono: Vec<f32> = samples
    48 + .chunks_exact(channels)
    49 + .map(|f| f.iter().sum::<f32>() / channels as f32)
    50 + .collect();
    51 + let out: Vec<f32> = if rate == SAMPLE_RATE {
    52 + mono
    53 + } else if rate.is_multiple_of(SAMPLE_RATE) {
    54 + let k = (rate / SAMPLE_RATE) as usize;
    55 + mono.chunks_exact(k)
    56 + .map(|c| c.iter().sum::<f32>() / k as f32)
    57 + .collect()
    58 + } else {
    59 + let step = rate as f64 / SAMPLE_RATE as f64;
    60 + let n = (mono.len() as f64 / step) as usize;
    61 + (0..n)
    62 + .map(|i| {
    63 + let x = i as f64 * step;
    64 + let j = x as usize;
    65 + let t = (x - j as f64) as f32;
    66 + let a = mono[j];
    67 + let b = *mono.get(j + 1).unwrap_or(&a);
    68 + a + (b - a) * t
    69 + })
    70 + .collect()
    71 + };
    72 + f32_to_i16(&out)
    73 +}
    74 +
    75 +fn header(data_bytes: u32) -> [u8; 44] {
    76 + let mut h = [0u8; 44];
    77 + h[0..4].copy_from_slice(b"RIFF");
    78 + h[4..8].copy_from_slice(&(36 + data_bytes).to_le_bytes());
    79 + h[8..12].copy_from_slice(b"WAVE");
    80 + h[12..16].copy_from_slice(b"fmt ");
    81 + h[16..20].copy_from_slice(&16u32.to_le_bytes());
    82 + h[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM
    83 + h[22..24].copy_from_slice(&1u16.to_le_bytes()); // моно
    84 + h[24..28].copy_from_slice(&SAMPLE_RATE.to_le_bytes());
    85 + h[28..32].copy_from_slice(&(SAMPLE_RATE * 2).to_le_bytes());
    86 + h[32..34].copy_from_slice(&2u16.to_le_bytes());
    87 + h[34..36].copy_from_slice(&16u16.to_le_bytes());
    88 + h[36..40].copy_from_slice(b"data");
    89 + h[40..44].copy_from_slice(&data_bytes.to_le_bytes());
    90 + h
    91 +}
    92 +
    93 +/// WAV целиком в памяти — для отправки куска на распознавание.
    94 +pub fn encode_wav(samples: &[i16]) -> Vec<u8> {
    95 + let data_bytes = (samples.len() * 2) as u32;
    96 + let mut out = Vec::with_capacity(44 + samples.len() * 2);
    97 + out.extend_from_slice(&header(data_bytes));
    98 + for s in samples {
    99 + out.extend_from_slice(&s.to_le_bytes());
    100 + }
    101 + out
    Разница обрезана: показаны первые 101 строк.
  • Показать разницуСкрыть разницу
    1 +//! Источник звука: дорожка «я» (микрофон) или «собеседники» (звук Zoom).
    2 +//!
    3 +//! Источник отдаёт то, что накопилось с прошлого вызова, в формате
    4 +//! [`crate::pcm`]. Время звуку ставит тот, кто читает: конец блока — момент
    5 +//! чтения. Источник может молчать (захват процесса не шлёт ничего, пока Zoom
    6 +//! тих) — это не ошибка, дыру заполнит [`crate::chunker::Chunker`].
    7 +
    8 +use std::time::{Duration, Instant};
    9 +
    10 +use crate::pcm::SAMPLES_PER_MS;
    11 +
    12 +#[derive(Debug, thiserror::Error)]
    13 +pub enum CaptureError {
    14 + #[error("Звук Zoom недоступен: {0}")]
    15 + Loopback(String),
    16 + #[error("Микрофон недоступен: {0}")]
    17 + Microphone(String),
    18 + #[error("Запись встреч из Zoom работает только в Windows")]
    19 + Unsupported,
    20 + #[error("Источник звука закончился")]
    21 + Ended,
    22 +}
    23 +
    24 +pub trait Capture: Send {
    25 + /// Ждёт не дольше ~50 мс и отдаёт накопленные сэмплы (может — ни одного).
    26 + fn read(&mut self) -> Result<Vec<i16>, CaptureError>;
    27 +}
    28 +
    29 +/// Звук из памяти в реальном времени (со множителем скорости). Для тестов и
    30 +/// для проверки цепочки на машине без Windows.
    31 +pub struct Replay {
    32 + samples: Vec<i16>,
    33 + pos: usize,
    34 + started: Option<Instant>,
    35 + speed: f64,
    36 +}
    37 +
    38 +impl Replay {
    39 + pub fn new(samples: Vec<i16>, speed: f64) -> Self {
    40 + Self {
    41 + samples,
    42 + pos: 0,
    43 + started: None,
    44 + speed,
    45 + }
    46 + }
    47 +}
    48 +
    49 +impl Capture for Replay {
    50 + fn read(&mut self) -> Result<Vec<i16>, CaptureError> {
    51 + if self.pos >= self.samples.len() {
    52 + return Err(CaptureError::Ended);
    53 + }
    54 + let started = *self.started.get_or_insert_with(Instant::now);
    55 + std::thread::sleep(Duration::from_millis(20));
    56 + let due = (started.elapsed().as_secs_f64() * 1000.0 * self.speed) as u64 * SAMPLES_PER_MS;
    57 + let end = (due as usize).clamp(self.pos, self.samples.len());
    58 + let out = self.samples[self.pos..end].to_vec();
    59 + self.pos = end;
    60 + Ok(out)
    61 + }
    62 +}
    63 +
    64 +#[cfg(test)]
    65 +mod tests {
    66 + use super::*;
    67 + use crate::pcm::ms_to_samples;
    68 +
    69 + #[test]
    70 + fn replay_delivers_everything_then_ends() {
    71 + let mut r = Replay::new(vec![1; ms_to_samples(300)], 10.0);
    72 + let mut got = 0;
    73 + loop {
    74 + match r.read() {
    75 + Ok(s) => got += s.len(),
    76 + Err(CaptureError::Ended) => break,
    77 + Err(e) => panic!("{e}"),
    78 + }
    79 + }
    80 + assert_eq!(got, ms_to_samples(300));
    81 + }
    82 +}
  • Показать разницуСкрыть разницу
    1 +//! Windows: захват звука процесса Zoom, микрофона и поиск активной встречи.
    2 +//!
    3 +//! - Звук процесса — process loopback (Windows 10 20438+):
    4 +//! https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/
    5 +//! Обёртка — крейт `wasapi` (`AudioClient::new_application_loopback_client`).
    6 +//! - Сессии звука по процессам — IAudioSessionManager2:
    7 +//! https://learn.microsoft.com/en-us/windows/win32/api/audiopolicy/nn-audiopolicy-iaudiosessionmanager2
    8 +//!
    9 +//! COM-объекты WASAPI не переносятся между потоками, поэтому каждый захват
    10 +//! живёт в своём потоке, а наружу отдаёт сэмплы через канал.
    11 +
    12 +use std::collections::{HashMap, VecDeque};
    13 +use std::sync::atomic::{AtomicBool, Ordering};
    14 +use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
    15 +use std::sync::Arc;
    16 +use std::thread::JoinHandle;
    17 +use std::time::Duration;
    18 +
    19 +use wasapi::{
    20 + initialize_mta, AudioClient, DeviceEnumerator, Direction, Role, SampleType, SessionState, StreamMode,
    21 + WaveFormat,
    22 +};
    23 +use windows::Win32::Foundation::CloseHandle;
    24 +use windows::Win32::System::Diagnostics::ToolHelp::{
    25 + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
    26 +};
    27 +
    28 +use crate::pcm::{self, SAMPLE_RATE};
    29 +use crate::source::{Capture, CaptureError};
    30 +
    31 +const ZOOM_EXE: &str = "zoom.exe";
    32 +
    33 +// ── Поиск встречи ───────────────────────────────────────────────────────────
    34 +
    35 +struct ProcessInfo {
    36 + name: String,
    37 + parent: u32,
    38 +}
    39 +
    40 +fn processes() -> HashMap<u32, ProcessInfo> {
    41 + let mut out = HashMap::new();
    42 + unsafe {
    43 + let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
    44 + return out;
    45 + };
    46 + let mut entry = PROCESSENTRY32W {
    47 + dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
    48 + ..Default::default()
    49 + };
    50 + if Process32FirstW(snap, &mut entry).is_ok() {
    51 + loop {
    52 + let len = entry
    53 + .szExeFile
    54 + .iter()
    55 + .position(|&c| c == 0)
    56 + .unwrap_or(entry.szExeFile.len());
    57 + out.insert(
    58 + entry.th32ProcessID,
    59 + ProcessInfo {
    60 + name: String::from_utf16_lossy(&entry.szExeFile[..len]).to_lowercase(),
    61 + parent: entry.th32ParentProcessID,
    62 + },
    63 + );
    64 + if Process32NextW(snap, &mut entry).is_err() {
    65 + break;
    66 + }
    67 + }
    68 + }
    69 + let _ = CloseHandle(snap);
    70 + }
    71 + out
    72 +}
    73 +
    74 +/// Корень дерева процессов Zoom: захват включает дочерние процессы, но цель
    75 +/// обязана быть родителем (так требует process loopback).
    76 +fn zoom_root(pid: u32, procs: &HashMap<u32, ProcessInfo>) -> u32 {
    77 + let mut current = pid;
    78 + for _ in 0..16 {
    79 + match procs
    80 + .get(&current)
    81 + .and_then(|p| procs.get(&p.parent).map(|parent| (p.parent, parent)))
    82 + {
    83 + Some((parent_pid, parent)) if parent.name == ZOOM_EXE => current = parent_pid,
    84 + _ => break,
    85 + }
    86 + }
    87 + current
    88 +}
    89 +
    90 +/// Процесс Zoom, у которого сейчас активная сессия вывода звука на любом
    91 +/// устройстве воспроизведения.
    92 +pub fn active_zoom_process() -> Option<u32> {
    93 + let _ = initialize_mta();
    94 + let procs = processes();
    95 + if !procs.values().any(|p| p.name == ZOOM_EXE) {
    96 + return None;
    97 + }
    98 + let enumerator = DeviceEnumerator::new().ok()?;
    99 + let devices = enumerator.get_device_collection(&Direction::Render).ok()?;
    100 + for device in &devices {
    101 + let Ok(device) = device else { continue };
    102 + let Ok(manager) = device.get_iaudiosessionmanager() else {
    Разница обрезана: показаны первые 102 строк.
  • index.html +2 −2
    Показать разницуСкрыть разницу
    10 10 </head>
    11 11 <body>
    12 12 <header class="header">
    13 − <a class="brand" href="#/meetings">
    13 + <a class="brand" href="#/">
    14 14 <svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true">
    15 15 <rect width="1024" height="1024" fill="var(--color-accent)"/>
    16 16 <path fill="var(--background)" d="M232 792V232h136l144 232 144-232h136v560H672V440L540 648h-56L352 440v352z"/>
    18 18 Skillok Meet
    19 19 </a>
    20 20 <nav class="header-nav" aria-label="Разделы">
    21 − <a href="#/meetings" data-nav="meetings">Встречи</a>
    21 + <a href="#/" data-nav="home">Встречи</a>
    22 22 <a href="#/settings" data-nav="settings">Настройки</a>
    23 23 </nav>
    24 24 </header>
  • Показать разницуСкрыть разницу
    1 +Ты ведёшь короткий живой конспект встречи, которая идёт прямо сейчас. Человек бросает на него взгляд между репликами, поэтому конспект должен читаться за десять секунд.
    2 +
    3 +Расшифровка автоматическая, реплики пронумерованы: `#N [время] Говорящий: текст`. «Я» — владелец записи, «Собеседники» — остальные участники. Встречаются ошибки распознавания — не цитируй их, пересказывай смысл.
    4 +
    5 +Ответь простым текстом, без заголовков, в таком виде:
    6 +
    7 +- 2–4 пункта: о чём говорят и к чему пришли;
    8 +- если прозвучали договорённости или поручения — отдельные пункты, начинающиеся с «Договорились:» или «Задача:».
    9 +
    10 +Не больше 80 слов. Не выдумывай того, чего не было. Если говорить пока не о чем — ответь одной строкой «Пока идёт разговор без итогов».
  • Показать разницуСкрыть разницу
    13 13 tauri-build = { version = "2.7", features = [] }
    14 14
    15 15 [dependencies]
    16 −tauri = { version = "2.12", features = [] }
    16 +tauri = { version = "2.12", features = ["tray-icon", "image-png"] }
    17 +tauri-plugin-notification = "2"
    18 +tauri-plugin-autostart = "2"
    19 +meet-audio = { path = "../crates/audio" }
    20 +base64 = "0.22"
    21 +tokio = { version = "1", features = ["rt", "sync", "time", "macros"] }
    17 22 serde = { version = "1", features = ["derive"] }
    18 23 serde_json = "1"
    19 24 thiserror = "2"
    20 −reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
    25 +reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls"] }
    21 26 keyring = { version = "3.6", features = ["apple-native", "windows-native"] }
    22 27 chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
    23 28
    24 29 [dev-dependencies]
    25 30 tempfile = "3"
    26 −tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
    31 +tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] }
  • Показать разницуСкрыть разницу
    2 2 //! пользователю как есть, поэтому все ошибки модулей написаны по-русски.
    3 3
    4 4 use std::path::PathBuf;
    5 +use std::sync::Arc;
    5 6
    6 7 use chrono::Local;
    7 8 use serde::Serialize;
    8 9 use tauri::State;
    9 10
    11 +use crate::controller::{Controller, Status};
    10 12 use crate::meetings::{Meeting, MeetingDetail, Store};
    11 13 use crate::openrouter::{Client, KeyInfo};
    12 14 use crate::settings::{mask_key, Secrets, Settings, OPENROUTER_KEY};
    13 15
    14 16 pub struct AppState {
    15 − settings_path: PathBuf,
    16 − store: Store,
    17 − secrets: Box<dyn Secrets>,
    17 + pub settings_path: PathBuf,
    18 + pub store: Arc<Store>,
    19 + pub secrets: Arc<dyn Secrets>,
    20 + pub controller: Arc<Controller>,
    18 21 }
    19 22
    20 23 impl AppState {
    21 − pub fn new(settings_path: PathBuf, store: Store, secrets: Box<dyn Secrets>) -> Self {
    22 − Self {
    23 − settings_path,
    24 − store,
    25 − secrets,
    26 − }
    27 − }
    28 −
    29 24 fn settings(&self) -> Result<Settings, String> {
    30 25 Settings::load(&self.settings_path).map_err(|e| e.to_string())
    31 26 }
    32 27
    33 − fn client(&self) -> Result<Client, String> {
    34 − match self.secrets.get(OPENROUTER_KEY).map_err(|e| e.to_string())? {
    35 − Some(key) => Ok(Client::new(key)),
    36 − None => Err("Ключ OpenRouter не задан — добавьте его в настройках".into()),
    37 − }
    28 + fn key(&self) -> Result<String, String> {
    29 + self.secrets
    30 + .get(OPENROUTER_KEY)
    31 + .map_err(|e| e.to_string())?
    32 + .ok_or_else(|| "Ключ OpenRouter не задан — добавьте его в настройках".into())
    38 33 }
    39 34 }
    40 35
    36 +/// Итоговый разбор встречи и запись его на диск. Один путь и для кнопки
    37 +/// «Разобрать», и для разбора, который запускается сам после записи.
    38 +pub async fn analyze(store: &Store, settings: &Settings, key: &str, id: &str) -> Result<Meeting, String> {
    39 + let detail = store.get(id).map_err(|e| e.to_string())?;
    40 + if detail.transcript.segments.is_empty() {
    41 + return Err("В расшифровке нет ни одной реплики".into());
    42 + }
    43 + let model = &settings.analysis_model;
    44 + let (analysis, usage) = Client::new(key)
    45 + .analyze(model, &detail.meeting.title, &detail.transcript)
    46 + .await
    47 + .map_err(|e| e.to_string())?;
    48 + store
    49 + .save_analysis(id, &analysis, model, usage, Local::now())
    50 + .map_err(|e| e.to_string())
    51 +}
    52 +
    41 53 #[derive(Serialize)]
    42 54 pub struct SettingsView {
    43 55 settings: Settings,
    44 56 /// Ключ с закрытой серединой; `None` — ключа нет.
    45 57 openrouter_key: Option<String>,
    46 58 meetings_dir: String,
    59 + /// Эта машина умеет записывать Zoom.
    60 + capture_supported: bool,
    47 61 }
    48 62
    49 63 #[tauri::command]
    56 70 .map_err(|e| e.to_string())?
    57 71 .map(|k| mask_key(&k)),
    58 72 meetings_dir: state.store.root().display().to_string(),
    73 + capture_supported: state.controller.status() != Status::Unsupported,
    59 74 })
    60 75 }
    61 76
    62 77 #[tauri::command]
    63 −pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Result<(), String> {
    64 − settings.save(&state.settings_path).map_err(|e| e.to_string())
    78 +pub fn save_settings(
    79 + app: tauri::AppHandle,
    80 + state: State<'_, AppState>,
    81 + settings: Settings,
    82 +) -> Result<(), String> {
    83 + settings.save(&state.settings_path).map_err(|e| e.to_string())?;
    84 + crate::shell::apply_autostart(&app, settings.launch_at_login);
    85 + Ok(())
    65 86 }
    66 87
    67 88 /// Ключ сохраняется, только если OpenRouter его принял: нерабочий ключ в
    85 106
    86 107 #[tauri::command]
    87 108 pub async fn check_openrouter_key(state: State<'_, AppState>) -> Result<KeyInfo, String> {
    88 − let client = state.client()?;
    89 − client.key_info().await.map_err(|e| e.to_string())
    109 + Client::new(state.key()?)
    110 + .key_info()
    111 + .await
    112 + .map_err(|e| e.to_string())
    90 113 }
    91 114
    92 115 #[tauri::command]
    114 137
    115 138 #[tauri::command]
    116 139 pub async fn analyze_meeting(state: State<'_, AppState>, id: String) -> Result<Meeting, String> {
    117 − let detail = state.store.get(&id).map_err(|e| e.to_string())?;
    118 − let model = state.settings()?.analysis_model;
    119 − let client = state.client()?;
    120 − let (analysis, usage) = client
    121 − .analyze(&model, &detail.meeting.title, &detail.transcript)
    122 − .await
    123 − .map_err(|e| e.to_string())?;
    124 − state
    125 − .store
    Разница обрезана: показаны первые 123 строк.
  • Показать разницуСкрыть разницу
    1 +//! Что программа делает сама: следит за Zoom, спрашивает отметку о
    2 +//! предупреждении участников, пишет встречу, по окончании дописывает
    3 +//! расшифровку и запускает разбор.
    4 +//!
    5 +//! ```text
    6 +//! встреча началась «Предупредил, записывать»
    7 +//! Watching ─────────────────► AwaitingConsent ─────────────────► Recording
    8 +//! ▲ │ (или сразу, если «предупреждаю всегда сам») │
    9 +//! │ └──────────── «Записать вручную» ───────────────────────────►│
    10 +//! │ встреча кончилась / «Стоп» ▼
    11 +//! └──────────────────────────────── Finishing (расшифровка → разбор)
    12 +//! ```
    13 +//!
    14 +//! Контроллер не знает про Tauri: окно, уведомления и события интерфейса —
    15 +//! через [`Outlet`], источники звука — через [`Sources`]. Поэтому переходы
    16 +//! проверяются тестами на любой машине.
    17 +
    18 +use std::path::PathBuf;
    19 +use std::sync::{Arc, Mutex};
    20 +use std::time::{Duration, Instant};
    21 +
    22 +use chrono::{DateTime, Local};
    23 +use meet_audio::detector::{self, Detector};
    24 +use serde::Serialize;
    25 +
    26 +use crate::meetings::{Meeting, Store};
    27 +use crate::openrouter::{Client, SttSegment};
    28 +use crate::recorder::{self, BoxFuture, Session, Track, Transcriber};
    29 +use crate::settings::{Secrets, Settings, OPENROUTER_KEY};
    30 +use crate::transcript::Transcript;
    31 +
    32 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
    33 +#[serde(rename_all = "snake_case")]
    34 +pub enum FinishStep {
    35 + /// Дописываем расшифровку последних кусков.
    36 + Transcribing,
    37 + /// Модель пишет разбор.
    38 + Analyzing,
    39 +}
    40 +
    41 +#[derive(Debug, Clone, PartialEq, Serialize)]
    42 +#[serde(tag = "kind", rename_all = "snake_case")]
    43 +pub enum Status {
    44 + /// На этой машине звук Zoom не записать (не Windows).
    45 + Unsupported,
    46 + /// Жду встречу в Zoom.
    47 + Watching,
    48 + /// Встреча началась, ждём отметку «участники предупреждены».
    49 + AwaitingConsent,
    50 + Recording {
    51 + meeting_id: String,
    52 + started_at: DateTime<Local>,
    53 + paused: bool,
    54 + /// Пишется звук Zoom (а не только микрофон).
    55 + zoom: bool,
    56 + },
    57 + Finishing {
    58 + meeting_id: String,
    59 + step: FinishStep,
    60 + },
    61 +}
    62 +
    63 +/// Всё, что видит пользователь.
    64 +pub trait Outlet: Send + Sync + 'static {
    65 + fn status(&self, status: &Status);
    66 + fn recorder_event(&self, meeting_id: &str, event: &recorder::Event);
    67 + /// Встреча изменилась на диске (дописана, разобрана).
    68 + fn meeting_changed(&self, meeting_id: &str);
    69 + fn notify(&self, title: &str, body: &str);
    70 + fn show_window(&self);
    71 +}
    72 +
    73 +/// Источники звука этой машины.
    74 +pub trait Sources: Send + Sync + 'static {
    75 + fn supported(&self) -> bool;
    76 + /// Процесс Zoom с активным звуком, если он есть.
    77 + fn active_zoom(&self) -> Option<u32>;
    78 + /// Дорожки записи: микрофон и, если есть `zoom_pid`, звук Zoom.
    79 + fn open(&self, zoom_pid: Option<u32>) -> Result<Vec<Track>, String>;
    80 +}
    81 +
    82 +/// Распознавание и конспект по ключу и настройкам.
    83 +pub type MakeAi = Arc<dyn Fn(&str, &Settings) -> Arc<dyn Transcriber> + Send + Sync>;
    84 +/// Итоговый разбор встречи: (id, настройки, ключ).
    85 +pub type Analyze =
    86 + Arc<dyn Fn(String, Settings, String) -> BoxFuture<'static, Result<(), String>> + Send + Sync>;
    87 +
    88 +pub struct Deps {
    89 + pub store: Arc<Store>,
    90 + pub settings_path: PathBuf,
    91 + pub secrets: Arc<dyn Secrets>,
    92 + pub outlet: Arc<dyn Outlet>,
    93 + pub sources: Arc<dyn Sources>,
    94 + pub rt: tokio::runtime::Handle,
    95 + /// Распознавание и конспект по ключу; в тестах — подделка.
    96 + pub make_ai: MakeAi,
    97 + /// Итоговый разбор; в тестах — подделка.
    98 + pub analyze: Analyze,
    99 + pub recorder: recorder::Config,
    100 +}
    101 +
    102 +struct Inner {
    103 + status: Status,
    104 + session: Option<Session>,
    105 + detector: Detector,
    106 + /// Процесс Zoom текущей встречи.
    107 + zoom_pid: Option<u32>,
    108 + /// Предупредили, что без ключа записи не будет, — один раз за встречу.
    109 + warned_no_key: bool,
    110 +}
    111 +
    112 +pub struct Controller {
    113 + deps: Deps,
    114 + clock: Instant,
    115 + inner: Mutex<Inner>,
    116 +}
    117 +
    118 +impl Controller {
    119 + pub fn new(deps: Deps) -> Arc<Self> {
    120 + let status = if deps.sources.supported() {
    121 + Status::Watching
    122 + } else {
    123 + Status::Unsupported
    Разница обрезана: показаны первые 123 строк.
  • src-tauri/src/lib.rs +67 −6
    Показать разницуСкрыть разницу
    1 1 //! Skillok Meet — ядро приложения. Интерфейс зовёт команды отсюда через
    2 −//! `invoke`; всё, что трогает диск, сеть и ключи, живёт в Rust.
    2 +//! `invoke`; всё, что трогает диск, сеть, звук и ключи, живёт в Rust.
    3 3
    4 4 pub mod analysis;
    5 +pub mod controller;
    5 6 pub mod meetings;
    6 7 pub mod openrouter;
    8 +pub mod recorder;
    7 9 pub mod settings;
    8 10 pub mod transcript;
    9 11
    10 12 mod commands;
    13 +mod shell;
    14 +
    15 +use std::sync::Arc;
    11 16
    12 17 use tauri::Manager;
    13 18
    19 +use controller::{Controller, Deps, OpenRouterAi, SystemSources};
    20 +use settings::Settings;
    21 +
    14 22 pub fn run() {
    15 23 tauri::Builder::default()
    24 + .plugin(tauri_plugin_notification::init())
    25 + .plugin(tauri_plugin_autostart::init(
    26 + tauri_plugin_autostart::MacosLauncher::LaunchAgent,
    27 + Some(vec!["--hidden"]),
    28 + ))
    16 29 .setup(|app| {
    30 + let handle = app.handle().clone();
    17 31 let config_dir = app.path().app_config_dir()?;
    18 32 let data_dir = app.path().app_data_dir()?;
    19 − app.manage(commands::AppState::new(
    20 − settings::settings_path(&config_dir),
    21 − meetings::Store::new(data_dir.join("meetings")),
    22 − Box::new(settings::Keyring),
    23 − ));
    33 + let settings_path = settings::settings_path(&config_dir);
    34 + let store = Arc::new(meetings::Store::new(data_dir.join("meetings")));
    35 + // Запись, оборванная выключением компьютера, — «прервана», а не «идёт».
    36 + let _ = store.recover_interrupted();
    37 + let secrets: Arc<dyn settings::Secrets> = Arc::new(settings::Keyring);
    38 +
    39 + let analyze_store = store.clone();
    40 + let controller = Controller::new(Deps {
    41 + store: store.clone(),
    42 + settings_path: settings_path.clone(),
    43 + secrets: secrets.clone(),
    44 + outlet: Arc::new(shell::TauriOutlet { app: handle.clone() }),
    45 + sources: Arc::new(SystemSources::from_env()),
    46 + rt: tauri::async_runtime::handle().inner().clone(),
    47 + make_ai: Arc::new(|key, settings| Arc::new(OpenRouterAi::new(key, settings))),
    48 + analyze: Arc::new(move |id, settings, key| {
    49 + let store = analyze_store.clone();
    50 + Box::pin(async move { commands::analyze(&store, &settings, &key, &id).await.map(|_| ()) })
    51 + }),
    52 + recorder: recorder::Config::default(),
    53 + });
    54 + controller.spawn_watch();
    55 +
    56 + app.manage(commands::AppState {
    57 + settings_path: settings_path.clone(),
    58 + store,
    59 + secrets,
    60 + controller,
    61 + });
    62 + shell::build_tray(&handle)?;
    63 + let launch = Settings::load(&settings_path)
    64 + .map(|s| s.launch_at_login)
    65 + .unwrap_or(true);
    66 + shell::apply_autostart(&handle, launch);
    67 +
    68 + // Запуск вместе с Windows — сразу в трей, без окна.
    69 + if std::env::args().any(|a| a == "--hidden") {
    70 + if let Some(w) = app.get_webview_window("main") {
    71 + let _ = w.hide();
    72 + }
    73 + }
    24 74 Ok(())
    25 75 })
    76 + .on_window_event(|window, event| {
    77 + if let tauri::WindowEvent::CloseRequested { api, .. } = event {
    78 + api.prevent_close();
    79 + let _ = window.hide();
    80 + }
    81 + })
    26 82 .invoke_handler(tauri::generate_handler![
    27 83 commands::get_settings,
    28 84 commands::save_settings,
    33 89 commands::get_meeting,
    34 90 commands::import_meeting,
    35 91 commands::analyze_meeting,
    92 + commands::recorder_status,
    93 + commands::recorder_consent,
    94 + commands::recorder_start,
    95 + commands::recorder_pause,
    96 + commands::recorder_stop,
    36 97 ])
    37 98 .run(tauri::generate_context!())
    38 99 .expect("Skillok Meet не запустился");
  • Показать разницуСкрыть разницу
    7 7 //! transcript.md — расшифровка для людей
    8 8 //! analysis.json — разбор (после «Разобрать»)
    9 9 //! summary.md — разбор для людей; это его сохраним в Skillok на этапе 2
    10 +//! live.md — живой конспект, пока идёт запись
    11 +//! audio/me.wav, audio/others.wav — дорожки записи; удаляются после разбора,
    12 +//! если в настройках не сказано хранить звук
    10 13 //! ```
    14 +//!
    15 +//! Запись меняет встречу много раз в минуту из разных задач (реплики, живой
    16 +//! конспект, разбор), поэтому каждое изменение «прочитал — поменял — записал»
    17 +//! идёт под одним замком: иначе две задачи затирали бы друг друга.
    11 18
    12 19 use std::path::{Path, PathBuf};
    20 +use std::sync::Mutex;
    13 21
    14 22 use chrono::{DateTime, Local};
    15 23 use serde::{Deserialize, Serialize};
    16 24
    17 25 use crate::analysis::Analysis;
    18 26 use crate::openrouter::Usage;
    19 −use crate::transcript::{ParseError, Transcript};
    27 +use crate::transcript::{ParseError, Segment, Speaker, Transcript};
    20 28
    21 29 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    22 30 #[serde(rename_all = "snake_case")]
    23 31 pub enum Source {
    24 32 /// Текст расшифровки, который пользователь вставил сам.
    25 33 Import,
    26 − /// Запись Zoom (этап 1).
    34 + /// Запись Zoom или микрофона.
    35 + Recording,
    36 +}
    37 +
    38 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
    39 +#[serde(rename_all = "snake_case")]
    40 +pub enum MeetingState {
    41 + /// Идёт запись: реплики ещё прибывают.
    27 42 Recording,
    43 + #[default]
    44 + Done,
    45 + /// Программа закрылась посреди записи; что успело — сохранено.
    46 + Interrupted,
    28 47 }
    29 48
    30 49 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    36 55 pub duration_ms: u64,
    37 56 pub segments: usize,
    38 57 pub analysis: Option<AnalysisMeta>,
    58 + #[serde(default)]
    59 + pub state: MeetingState,
    39 60 }
    40 61
    41 62 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    51 72 pub meeting: Meeting,
    52 73 pub transcript: Transcript,
    53 74 pub analysis: Option<Analysis>,
    75 + pub live_summary: Option<String>,
    54 76 }
    55 77
    56 78 #[derive(Debug, thiserror::Error)]
    69 91
    70 92 pub struct Store {
    71 93 root: PathBuf,
    94 + write: Mutex<()>,
    72 95 }
    73 96
    74 97 impl Store {
    75 98 pub fn new(root: impl Into<PathBuf>) -> Self {
    76 − Self { root: root.into() }
    99 + Self {
    100 + root: root.into(),
    101 + write: Mutex::new(()),
    102 + }
    103 + }
    104 +
    105 + fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
    106 + self.write.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
    77 107 }
    78 108
    79 109 pub fn root(&self) -> &Path {
    111 141 } else {
    112 142 None
    113 143 };
    144 + let live_summary = std::fs::read_to_string(dir.join("live.md")).ok();
    114 145 Ok(MeetingDetail {
    115 146 meeting,
    116 147 transcript,
    117 148 analysis,
    149 + live_summary,
    118 150 })
    119 151 }
    120 152
    125 157 return Err(Error::BlankTitle);
    126 158 }
    127 159 let transcript = Transcript::parse(text)?;
    160 + let _guard = self.lock();
    128 161 let id = self.free_id(now)?;
    129 162 let dir = self.root.join(&id);
    130 163 std::fs::create_dir_all(&dir)?;
    136 169 duration_ms: transcript.duration_ms(),
    137 170 segments: transcript.segments.len(),
    138 171 analysis: None,
    172 + state: MeetingState::Done,
    139 173 };
    140 174 write_json(&dir.join("transcript.json"), &transcript)?;
    141 175 std::fs::write(dir.join("transcript.md"), transcript.to_markdown(title))?;
    152 186 usage: Usage,
    153 187 now: DateTime<Local>,
    154 188 ) -> Result<Meeting, Error> {
    189 + let _guard = self.lock();
    155 190 let dir = self.dir(id)?;
    156 191 let mut meeting: Meeting = read_json(&dir.join("meeting.json"))?;
    157 192 let transcript: Transcript = read_json(&dir.join("transcript.json"))?;
    167 202 Ok(meeting)
    168 203 }
    169 204
    205 + /// Новая встреча под запись: пустая расшифровка, состояние «идёт запись».
    206 + pub fn create_recording(&self, title: &str, now: DateTime<Local>) -> Result<Meeting, Error> {
    207 + let _guard = self.lock();
    208 + let id = self.free_id(now)?;
    209 + let dir = self.root.join(&id);
    210 + std::fs::create_dir_all(dir.join("audio"))?;
    211 + let meeting = Meeting {
    212 + id,
    Разница обрезана: показаны первые 124 строк.
  • Показать разницуСкрыть разницу
    1 1 //! Клиент OpenRouter: проверка ключа и разбор встречи.
    2 2 //!
    3 −//! Один ключ пользователя на всё: на этапе 1 через него же пойдёт расшифровка
    4 −//! (`/audio/transcriptions`). Документация:
    3 +//! Один ключ пользователя на всё: разбор, живой конспект и распознавание речи.
    4 +//! Документация:
    5 5 //! - ключ: `GET /api/v1/key` — https://openrouter.ai/docs/api/reference/limits
    6 +//! - речь: `POST /api/v1/audio/transcriptions` —
    7 +//! https://openrouter.ai/docs/guides/overview/multimodal/stt
    6 8 //! - строгий JSON: `response_format.json_schema` —
    7 9 //! https://openrouter.ai/docs/guides/features/structured-outputs
    8 10
    11 13 use serde::{Deserialize, Serialize};
    12 14 use serde_json::{json, Value};
    13 15
    16 +use base64::Engine;
    17 +
    14 18 use crate::analysis::{self, Analysis, InvalidAnalysis};
    15 19 use crate::transcript::Transcript;
    16 20
    21 +/// Промпт живого конспекта по ходу встречи.
    22 +pub const LIVE_PROMPT: &str = include_str!("../../prompts/live.md");
    23 +
    17 24 pub const DEFAULT_BASE: &str = "https://openrouter.ai/api/v1";
    18 25
    19 26 #[derive(Debug, thiserror::Error)]
    130 137 }
    131 138 let content = choice["message"]["content"].as_str().unwrap_or("");
    132 139 let analysis = Analysis::from_model_json(content, transcript)?;
    133 − let usage = Usage {
    134 − prompt_tokens: body["usage"]["prompt_tokens"].as_u64().unwrap_or(0),
    135 − completion_tokens: body["usage"]["completion_tokens"].as_u64().unwrap_or(0),
    136 − cost: body["usage"]["cost"].as_f64(),
    137 − };
    138 − Ok((analysis, usage))
    140 + Ok((analysis, usage_of(&body)))
    141 + }
    142 +
    143 + /// Распознаёт кусок звука (WAV). Время реплик — от начала куска.
    144 + pub async fn transcribe(
    145 + &self,
    146 + model: &str,
    147 + wav: &[u8],
    148 + language: Option<&str>,
    149 + ) -> Result<Transcription, Error> {
    150 + let mut request = json!({
    151 + "model": model,
    152 + "input_audio": {
    153 + "data": base64::engine::general_purpose::STANDARD.encode(wav),
    154 + "format": "wav"
    155 + },
    156 + "temperature": 0,
    157 + "response_format": "verbose_json",
    158 + "timestamp_granularities": ["segment"]
    159 + });
    160 + if let Some(lang) = language {
    161 + request["language"] = json!(lang);
    162 + }
    163 + let body = self
    164 + .send(
    165 + self.http
    166 + .post(format!("{}/audio/transcriptions", self.base))
    167 + .json(&request),
    168 + )
    169 + .await?;
    170 + Ok(Transcription::from_response(&body))
    171 + }
    172 +
    173 + /// Короткий живой конспект по расшифровке, накопленной к этому моменту.
    174 + pub async fn live_summary(&self, model: &str, transcript: &Transcript) -> Result<(String, Usage), Error> {
    175 + let request = json!({
    176 + "model": model,
    177 + "max_tokens": 1200,
    178 + "messages": [
    179 + { "role": "system", "content": LIVE_PROMPT },
    180 + { "role": "user", "content": transcript.to_prompt_text() }
    181 + ]
    182 + });
    183 + let body = self
    184 + .send(
    185 + self.http
    186 + .post(format!("{}/chat/completions", self.base))
    187 + .json(&request),
    188 + )
    189 + .await?;
    190 + let text = body["choices"][0]["message"]["content"]
    191 + .as_str()
    192 + .unwrap_or("")
    193 + .trim()
    194 + .to_string();
    195 + if text.is_empty() {
    196 + return Err(Error::Incomplete("пустой ответ".into()));
    197 + }
    198 + Ok((text, usage_of(&body)))
    139 199 }
    140 200
    141 201 async fn send(&self, request: reqwest::RequestBuilder) -> Result<Value, Error> {
    166 226 }
    167 227 }
    168 228
    229 +fn usage_of(body: &Value) -> Usage {
    230 + Usage {
    231 + prompt_tokens: body["usage"]["prompt_tokens"].as_u64().unwrap_or(0),
    232 + completion_tokens: body["usage"]["completion_tokens"].as_u64().unwrap_or(0),
    233 + cost: body["usage"]["cost"].as_f64(),
    234 + }
    235 +}
    236 +
    237 +/// Реплика из распознавания; время — от начала куска.
    238 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    239 +pub struct SttSegment {
    240 + pub start_ms: u64,
    241 + pub text: String,
    242 +}
    243 +
    244 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
    245 +pub struct Transcription {
    246 + pub segments: Vec<SttSegment>,
    247 + pub cost: Option<f64>,
    248 +}
    249 +
    250 +impl Transcription {
    251 + /// Сегменты с временем, если модель их дала; иначе весь текст одной
    252 + /// репликой с начала куска. Пустые и выдуманные на тишине фразы выброшены.
    Разница обрезана: показаны первые 124 строк.
  • Показать разницуСкрыть разницу
    1 +//! Запись одной встречи: дорожки звука → куски по паузам → распознавание →
    2 +//! реплики в расшифровке, и раз в несколько минут — живой конспект.
    3 +//!
    4 +//! ```text
    5 +//! поток «я» ─┐ ┌─ store.append_segments
    6 +//! (микрофон) ├─ куски ─► распознавание ─┤
    7 +//! поток «собеседники» ┘ (канал) (одна задача) └─ живой конспект
    8 +//! (звук Zoom)
    9 +//! ```
    10 +//!
    11 +//! Каждая дорожка читается своим потоком: захват блокирующий, а медленная
    12 +//! сеть не должна терять звук. Распознавание — одна асинхронная задача, куски
    13 +//! идут по очереди: так реплики одной дорожки не обгоняют друг друга.
    14 +//! Звук каждой дорожки пишется на диск сразу — после сбоя его можно
    15 +//! распознать заново.
    16 +//!
    17 +//! Модуль не знает про Tauri и OpenRouter: распознавание — через
    18 +//! [`Transcriber`], события — через колбэк. Так его проверяют тесты.
    19 +
    20 +use std::future::Future;
    21 +use std::pin::Pin;
    22 +use std::sync::atomic::{AtomicBool, Ordering};
    23 +use std::sync::Arc;
    24 +use std::thread::JoinHandle;
    25 +use std::time::{Duration, Instant};
    26 +
    27 +use meet_audio::chunker::{self, Chunk, Chunker};
    28 +use meet_audio::pcm::{self, WavWriter};
    29 +use meet_audio::source::{Capture, CaptureError};
    30 +use tokio::sync::mpsc;
    31 +
    32 +use crate::meetings::Store;
    33 +use crate::openrouter::SttSegment;
    34 +use crate::transcript::{Segment, Speaker, Transcript};
    35 +
    36 +pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
    37 +
    38 +/// Распознавание и живой конспект. В приложении — OpenRouter, в тестах — подделка.
    39 +pub trait Transcriber: Send + Sync + 'static {
    40 + fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>>;
    41 + fn live_summary(&self, transcript: Transcript) -> BoxFuture<'_, Result<String, String>>;
    42 +}
    43 +
    44 +#[derive(Debug, Clone, PartialEq, serde::Serialize)]
    45 +#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
    46 +pub enum Event {
    47 + /// Новые реплики уже в расшифровке.
    48 + Segments(Vec<Segment>),
    49 + /// Уровень дорожки, dBFS, несколько раз в секунду.
    50 + Level {
    51 + speaker: Speaker,
    52 + dbfs: f32,
    53 + },
    54 + LiveSummary(String),
    55 + /// Дорожка перестала писаться; остальные продолжают.
    56 + TrackFailed {
    57 + speaker: Speaker,
    58 + error: String,
    59 + },
    60 + /// Кусок не распознался и после повтора; звук остался на диске.
    61 + SttFailed(String),
    62 +}
    63 +
    64 +pub struct Track {
    65 + pub speaker: Speaker,
    66 + pub capture: Box<dyn Capture>,
    67 +}
    68 +
    69 +#[derive(Debug, Clone, Copy)]
    70 +pub struct Config {
    71 + pub chunker: chunker::Config,
    72 + /// Как часто обновлять живой конспект.
    73 + pub live_every: Duration,
    74 + /// Пауза перед повтором неудачного распознавания.
    75 + pub retry_after: Duration,
    76 +}
    77 +
    78 +impl Default for Config {
    79 + fn default() -> Self {
    80 + Self {
    81 + chunker: chunker::Config::default(),
    82 + live_every: Duration::from_secs(180),
    83 + retry_after: Duration::from_secs(3),
    84 + }
    85 + }
    86 +}
    87 +
    88 +type Emit = Arc<dyn Fn(Event) + Send + Sync>;
    89 +
    90 +pub struct Session {
    91 + pub meeting_id: String,
    92 + stop: Arc<AtomicBool>,
    93 + paused: Arc<AtomicBool>,
    94 + threads: Vec<JoinHandle<u64>>,
    95 + worker: tokio::task::JoinHandle<()>,
    96 +}
    97 +
    98 +impl Session {
    99 + pub fn start(
    100 + meeting_id: String,
    101 + store: Arc<Store>,
    102 + tracks: Vec<Track>,
    103 + ai: Arc<dyn Transcriber>,
    104 + emit: Emit,
    105 + cfg: Config,
    106 + rt: &tokio::runtime::Handle,
    107 + ) -> Self {
    108 + let stop = Arc::new(AtomicBool::new(false));
    109 + let paused = Arc::new(AtomicBool::new(false));
    110 + let started = Instant::now();
    111 + let (tx, rx) = mpsc::unbounded_channel();
    112 + let threads = tracks
    113 + .into_iter()
    114 + .map(|track| {
    115 + let wav_path = store.audio_path(&meeting_id, &track.speaker).ok();
    116 + let ctx = TrackCtx {
    117 + stop: stop.clone(),
    118 + paused: paused.clone(),
    119 + started,
    120 + tx: tx.clone(),
    121 + emit: emit.clone(),
    122 + chunker: cfg.chunker,
    123 + };
    124 + std::thread::Builder::new()
    Разница обрезана: показаны первые 124 строк.
  • Показать разницуСкрыть разницу
    27 27 pub warning_text: String,
    28 28 /// «Предупреждаю всегда сам» — не спрашивать отметку перед каждой записью.
    29 29 pub always_warn_myself: bool,
    30 + /// Язык встреч для распознавания: код ISO 639-1 или пусто — определять
    31 + /// самому. Короткие куски Whisper определяет плохо, поэтому по умолчанию «ru».
    32 + pub language: String,
    33 + /// Хранить звук после разбора. По умолчанию нет: звук — самое личное.
    34 + pub keep_audio: bool,
    35 + /// Запускаться вместе с Windows — иначе встречу, начатую до запуска
    36 + /// программы, некому записать.
    37 + pub launch_at_login: bool,
    30 38 }
    31 39
    32 40 impl Default for Settings {
    37 45 stt_model: "openai/whisper-large-v3-turbo".into(),
    38 46 warning_text: DEFAULT_WARNING.into(),
    39 47 always_warn_myself: false,
    48 + language: "ru".into(),
    49 + keep_audio: false,
    50 + launch_at_login: true,
    40 51 }
    41 52 }
    42 53 }
    51 62 Keyring(String),
    52 63 #[error("поле «{0}» не может быть пустым")]
    53 64 Blank(&'static str),
    65 + #[error("язык «{0}» — нужен двухбуквенный код вроде ru или en, или пусто")]
    66 + BadLanguage(String),
    54 67 }
    55 68
    56 69 impl Settings {
    70 + /// Язык для распознавания: `None` — определять самому.
    71 + pub fn stt_language(&self) -> Option<&str> {
    72 + let lang = self.language.trim();
    73 + (!lang.is_empty()).then_some(lang)
    74 + }
    75 +
    57 76 /// Нет файла — значения по умолчанию. Файл есть, но битый — ошибка, а не
    58 77 /// молчаливый сброс: пользователь должен узнать, что его настройки пропали.
    59 78 pub fn load(path: &Path) -> Result<Self, Error> {
    86 105 return Err(Error::Blank(name));
    87 106 }
    88 107 }
    108 + let lang = self.language.trim();
    109 + if !(lang.is_empty() || (lang.len() == 2 && lang.chars().all(|c| c.is_ascii_lowercase()))) {
    110 + return Err(Error::BadLanguage(self.language.clone()));
    111 + }
    89 112 Ok(())
    90 113 }
    91 114 }
    212 235 }
    213 236
    214 237 #[test]
    238 + fn language_is_a_two_letter_code_or_empty() {
    239 + let ok = Settings {
    240 + language: "en".into(),
    241 + ..Settings::default()
    242 + };
    243 + assert!(ok.validate().is_ok());
    244 + assert_eq!(ok.stt_language(), Some("en"));
    245 + let auto = Settings {
    246 + language: "".into(),
    247 + ..Settings::default()
    248 + };
    249 + assert_eq!(auto.stt_language(), None);
    250 + let bad = Settings {
    251 + language: "russian".into(),
    252 + ..Settings::default()
    253 + };
    254 + assert!(matches!(bad.validate(), Err(Error::BadLanguage(_))));
    255 + }
    256 +
    257 + #[test]
    215 258 fn memory_secrets_and_mask() {
    216 259 let m = Memory::default();
    217 260 assert_eq!(m.get(OPENROUTER_KEY).unwrap(), None);
  • Показать разницуСкрыть разницу
    1 +//! Оболочка Tauri: окно, трей, уведомления, автозапуск и события интерфейса.
    2 +//!
    3 +//! Программа живёт в трее: крестик прячет окно, а не выключает её — иначе
    4 +//! закрыл окно и встреча не записалась. Выключить — «Выйти» в меню трея.
    5 +
    6 +use tauri::menu::{Menu, MenuItem};
    7 +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
    8 +use tauri::{AppHandle, Emitter, Manager};
    9 +use tauri_plugin_notification::NotificationExt;
    10 +
    11 +use crate::controller::{FinishStep, Outlet, Status};
    12 +use crate::recorder;
    13 +
    14 +const TRAY_ID: &str = "main";
    15 +
    16 +pub struct TauriOutlet {
    17 + pub app: AppHandle,
    18 +}
    19 +
    20 +impl Outlet for TauriOutlet {
    21 + fn status(&self, status: &Status) {
    22 + let _ = self.app.emit("meet://status", status);
    23 + if let Some(tray) = self.app.tray_by_id(TRAY_ID) {
    24 + let _ = tray.set_tooltip(Some(tooltip(status)));
    25 + }
    26 + }
    27 +
    28 + fn recorder_event(&self, meeting_id: &str, event: &recorder::Event) {
    29 + let _ = self.app.emit(
    30 + "meet://recorder",
    31 + serde_json::json!({ "meeting_id": meeting_id, "event": event }),
    32 + );
    33 + }
    34 +
    35 + fn meeting_changed(&self, meeting_id: &str) {
    36 + let _ = self.app.emit("meet://meeting-changed", meeting_id);
    37 + }
    38 +
    39 + fn notify(&self, title: &str, body: &str) {
    40 + let _ = self.app.notification().builder().title(title).body(body).show();
    41 + }
    42 +
    43 + fn show_window(&self) {
    44 + show_main(&self.app);
    45 + }
    46 +}
    47 +
    48 +fn tooltip(status: &Status) -> &'static str {
    49 + match status {
    50 + Status::Unsupported => "Skillok Meet",
    51 + Status::Watching => "Skillok Meet — жду встречу в Zoom",
    52 + Status::AwaitingConsent => "Skillok Meet — предупредите участников",
    53 + Status::Recording { paused: true, .. } => "Skillok Meet — запись на паузе",
    54 + Status::Recording { .. } => "Skillok Meet — идёт запись",
    55 + Status::Finishing {
    56 + step: FinishStep::Transcribing,
    57 + ..
    58 + } => "Skillok Meet — дописываю расшифровку",
    59 + Status::Finishing {
    60 + step: FinishStep::Analyzing,
    61 + ..
    62 + } => "Skillok Meet — разбираю встречу",
    63 + }
    64 +}
    65 +
    66 +pub fn show_main(app: &AppHandle) {
    67 + if let Some(w) = app.get_webview_window("main") {
    68 + let _ = w.show();
    69 + let _ = w.unminimize();
    70 + let _ = w.set_focus();
    71 + }
    72 +}
    73 +
    74 +pub fn build_tray(app: &AppHandle) -> tauri::Result<()> {
    75 + let open = MenuItem::with_id(app, "open", "Открыть Skillok Meet", true, None::<&str>)?;
    76 + let quit = MenuItem::with_id(app, "quit", "Выйти", true, None::<&str>)?;
    77 + let menu = Menu::with_items(app, &[&open, &quit])?;
    78 + let mut tray = TrayIconBuilder::with_id(TRAY_ID)
    79 + .tooltip("Skillok Meet")
    80 + .menu(&menu)
    81 + .show_menu_on_left_click(false)
    82 + .on_menu_event(|app, event| match event.id.as_ref() {
    83 + "open" => show_main(app),
    84 + "quit" => app.exit(0),
    85 + _ => {}
    86 + })
    87 + .on_tray_icon_event(|tray, event| {
    88 + if let TrayIconEvent::Click {
    89 + button: MouseButton::Left,
    90 + button_state: MouseButtonState::Up,
    91 + ..
    92 + } = event
    93 + {
    94 + show_main(tray.app_handle());
    95 + }
    96 + });
    97 + if let Some(icon) = app.default_window_icon() {
    98 + tray = tray.icon(icon.clone());
    99 + }
    100 + tray.build(app)?;
    101 + Ok(())
    102 +}
    103 +
    104 +/// Запуск вместе с Windows. Вне Windows не трогаем: на Mac идёт разработка,
    105 +/// и регистрировать там автозапуск отладочной сборки незачем.
    106 +pub fn apply_autostart(_app: &AppHandle, _enabled: bool) {
    107 + #[cfg(windows)]
    108 + {
    109 + use tauri_plugin_autostart::ManagerExt;
    110 + let manager = _app.autolaunch();
    111 + let _ = if _enabled {
    112 + manager.enable()
    113 + } else {
    114 + manager.disable()
    115 + };
    116 + }
    117 +}
  • src/api.ts +70 −12
    Показать разницуСкрыть разницу
    10 10
    11 11 export interface AnalysisMeta { model: string; at: string; usage: Usage; tasks: number }
    12 12
    13 +export type MeetingState = "recording" | "done" | "interrupted";
    14 +
    13 15 export interface Meeting {
    14 16 id: string;
    15 17 title: string;
    18 20 duration_ms: number;
    19 21 segments: number;
    20 22 analysis: AnalysisMeta | null;
    23 + state: MeetingState;
    21 24 }
    22 25
    23 26 export interface Analysis {
    29 32 open_questions: string[];
    30 33 }
    31 34
    32 −export interface MeetingDetail { meeting: Meeting; transcript: Transcript; analysis: Analysis | null }
    35 +export interface MeetingDetail {
    36 + meeting: Meeting;
    37 + transcript: Transcript;
    38 + analysis: Analysis | null;
    39 + live_summary: string | null;
    40 +}
    33 41
    34 42 export interface Settings {
    35 43 live_model: string;
    37 45 stt_model: string;
    38 46 warning_text: string;
    39 47 always_warn_myself: boolean;
    48 + language: string;
    49 + keep_audio: boolean;
    50 + launch_at_login: boolean;
    51 +}
    52 +
    53 +export interface SettingsView {
    54 + settings: Settings;
    55 + openrouter_key: string | null;
    56 + meetings_dir: string;
    57 + capture_supported: boolean;
    40 58 }
    41 59
    42 −export interface SettingsView { settings: Settings; openrouter_key: string | null; meetings_dir: string }
    60 +/** Состояние записи — `Status` из src-tauri/src/controller.rs. */
    61 +export type RecorderStatus =
    62 + | { kind: "unsupported" }
    63 + | { kind: "watching" }
    64 + | { kind: "awaiting_consent" }
    65 + | { kind: "recording"; meeting_id: string; started_at: string; paused: boolean; zoom: boolean }
    66 + | { kind: "finishing"; meeting_id: string; step: "transcribing" | "analyzing" };
    67 +
    68 +/** Событие записи — `Event` из src-tauri/src/recorder.rs. */
    69 +export type RecorderEvent =
    70 + | { kind: "segments"; data: Segment[] }
    71 + | { kind: "level"; data: { speaker: Speaker; dbfs: number } }
    72 + | { kind: "live_summary"; data: string }
    73 + | { kind: "track_failed"; data: { speaker: Speaker; error: string } }
    74 + | { kind: "stt_failed"; data: string };
    43 75
    44 76 export interface KeyInfo { label: string | null; limit_remaining: number | null; usage: number | null; is_free_tier: boolean }
    45 77
    46 78 type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
    79 +type Listen = <T>(event: string, cb: (payload: T) => void) => Promise<() => void>;
    47 80
    48 −let invokeImpl: Invoke | null = null;
    81 +interface Core { invoke: Invoke; listen: Listen }
    49 82
    50 −async function call<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
    51 − if (!invokeImpl) {
    83 +let core: Promise<Core> | null = null;
    84 +
    85 +function getCore(): Promise<Core> {
    86 + core ??= (async (): Promise<Core> => {
    52 87 if ("__TAURI_INTERNALS__" in window) {
    53 − invokeImpl = (await import("@tauri-apps/api/core")).invoke;
    54 − } else if (import.meta.env.DEV || import.meta.env.VITE_MOCK === "1") {
    88 + const { invoke } = await import("@tauri-apps/api/core");
    89 + const { listen } = await import("@tauri-apps/api/event");
    90 + return { invoke, listen: (event, cb) => listen(event, (e) => cb(e.payload as never)) };
    91 + }
    92 + if (import.meta.env.DEV || import.meta.env.VITE_MOCK === "1") {
    55 93 // Только для просмотра интерфейса в обычном браузере: `npm run dev`
    56 94 // или сборка `npm run build:mock`. В сборку приложения не попадает.
    57 − invokeImpl = (await import("./dev-mock")).invoke;
    58 − } else {
    59 − throw new Error("Skillok Meet запущен без ядра");
    95 + return import("./dev-mock");
    60 96 }
    61 − }
    62 − return invokeImpl<T>(cmd, args);
    97 + throw new Error("Skillok Meet запущен без ядра");
    98 + })();
    99 + return core;
    63 100 }
    64 101
    102 +async function call<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
    103 + return (await getCore()).invoke<T>(cmd, args);
    104 +}
    105 +
    106 +/** Подписка на событие ядра; возвращает отписку. */
    107 +export async function on<T>(event: string, cb: (payload: T) => void): Promise<() => void> {
    108 + return (await getCore()).listen<T>(event, cb);
    109 +}
    110 +
    111 +export const events = {
    112 + status: (cb: (s: RecorderStatus) => void) => on<RecorderStatus>("meet://status", cb),
    113 + recorder: (cb: (p: { meeting_id: string; event: RecorderEvent }) => void) => on("meet://recorder", cb),
    114 + meetingChanged: (cb: (id: string) => void) => on<string>("meet://meeting-changed", cb),
    115 +};
    116 +
    65 117 export const api = {
    66 118 getSettings: () => call<SettingsView>("get_settings"),
    67 119 saveSettings: (settings: Settings) => call<void>("save_settings", { settings }),
    72 124 getMeeting: (id: string) => call<MeetingDetail>("get_meeting", { id }),
    73 125 importMeeting: (title: string, text: string) => call<Meeting>("import_meeting", { title, text }),
    74 126 analyzeMeeting: (id: string) => call<Meeting>("analyze_meeting", { id }),
    127 + recorderStatus: () => call<RecorderStatus>("recorder_status"),
    128 + recorderConsent: (record: boolean) => call<void>("recorder_consent", { record }),
    129 + recorderStart: () => call<Meeting>("recorder_start"),
    130 + recorderPause: (paused: boolean) => call<void>("recorder_pause", { paused }),
    131 + recorderStop: () => call<void>("recorder_stop"),
    75 132 };
    76 133
    77 134 export function speakerLabel(s: Speaker): string {
    89 146 }
    90 147
    91 148 export function formatDuration(ms: number): string {
    149 + if (ms < 60000) return "меньше минуты";
    92 150 const min = Math.round(ms / 60000);
    Разница обрезана: показаны первые 130 строк.
  • src/dev-mock.ts +147 −22
    Показать разницуСкрыть разницу
    1 −// Заглушка ядра для `npm run dev` в обычном браузере: показать интерфейс без
    2 −// Tauri. В сборку приложения не попадает (см. api.ts). Разбор здесь не
    3 −// вызывает модель — у первой встречи он заготовлен руками.
    1 +// Заглушка ядра для просмотра интерфейса в обычном браузере (`npm run dev`,
    2 +// `npm run build:mock`). В сборку приложения не попадает (см. api.ts).
    3 +//
    4 +// Изображает и запись: «встреча» идёт по репликам планёрки из fixtures, с
    5 +// уровнями звука и живым конспектом. Модель здесь не вызывается — разбор
    6 +// заготовлен руками. Для проверки экранов есть ручки в консоли:
    7 +// __meetMock.meetingStarted() — «началась встреча в Zoom»
    8 +// __meetMock.noKey() — ключ удалён
    4 9
    5 10 import planerka from "../fixtures/transcripts/planerka.txt?raw";
    6 11 import klient from "../fixtures/transcripts/klient.txt?raw";
    7 −import type { Analysis, Meeting, MeetingDetail, Segment, Settings, SettingsView, KeyInfo } from "./api";
    12 +import type {
    13 + Analysis, KeyInfo, Meeting, MeetingDetail, RecorderStatus, Segment, Settings, SettingsView, Speaker,
    14 +} from "./api";
    8 15
    9 16 function parse(text: string): Segment[] {
    10 17 const out: Segment[] = [];
    48 55 open_questions: ["Кто прикрывает дежурства во вторую неделю отпуска Дениса?"],
    49 56 };
    50 57
    58 +// ── Шина событий ────────────────────────────────────────────────────────────
    59 +
    60 +const handlers = new Map<string, Set<(p: unknown) => void>>();
    61 +
    62 +function emit(event: string, payload: unknown): void {
    63 + handlers.get(event)?.forEach((h) => h(payload));
    64 +}
    65 +
    66 +export async function listen<T>(event: string, cb: (payload: T) => void): Promise<() => void> {
    67 + const set = handlers.get(event) ?? new Set();
    68 + handlers.set(event, set);
    69 + const h = cb as (p: unknown) => void;
    70 + set.add(h);
    71 + return () => set.delete(h);
    72 +}
    73 +
    74 +// ── Данные ──────────────────────────────────────────────────────────────────
    75 +
    51 76 const now = Date.now();
    52 77 const details: MeetingDetail[] = [];
    78 +const p2 = (n: number) => String(n).padStart(2, "0");
    53 79
    54 −function add(title: string, text: string, minutesAgo: number, analysis: Analysis | null): MeetingDetail {
    55 − const segments = parse(text);
    80 +function makeId(d: Date): string {
    81 + return `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
    82 +}
    83 +
    84 +function add(title: string, segments: Segment[], minutesAgo: number, analysis: Analysis | null, state: Meeting["state"] = "done"): MeetingDetail {
    56 85 const created = new Date(now - minutesAgo * 60000);
    57 − const p = (n: number) => String(n).padStart(2, "0");
    58 − const id = `${created.getFullYear()}${p(created.getMonth() + 1)}${p(created.getDate())}-${p(created.getHours())}${p(created.getMinutes())}${p(created.getSeconds())}`;
    59 86 const meeting: Meeting = {
    60 − id,
    87 + id: makeId(created),
    61 88 title,
    62 89 created_at: created.toISOString(),
    63 − source: "import",
    64 − duration_ms: segments[segments.length - 1].start_ms,
    90 + source: state === "done" && minutesAgo > 60 ? "import" : "recording",
    91 + duration_ms: segments.length ? segments[segments.length - 1].start_ms : 0,
    65 92 segments: segments.length,
    66 93 analysis: analysis && {
    67 94 model: "anthropic/claude-opus-5.5",
    69 96 usage: { prompt_tokens: 2140, completion_tokens: 810, cost: 0.0248 },
    70 97 tasks: analysis.tasks.length,
    71 98 },
    99 + state,
    72 100 };
    73 − const d = { meeting, transcript: { segments }, analysis };
    101 + const d: MeetingDetail = { meeting, transcript: { segments }, analysis, live_summary: null };
    74 102 details.unshift(d);
    75 103 return d;
    76 104 }
    77 105
    78 −add("Планёрка команды", planerka, 60 * 26, planerkaAnalysis);
    79 −add("Встреча с клиентом: сайт", klient, 90, null);
    106 +add("Планёрка команды", parse(planerka), 60 * 26, planerkaAnalysis);
    107 +add("Встреча с клиентом: сайт", parse(klient), 90, null);
    80 108
    81 109 let settings: Settings = {
    82 110 live_model: "anthropic/claude-haiku-4.5",
    85 113 warning_text:
    86 114 "Коллеги, я записываю встречу, чтобы сделать расшифровку и список задач. Запись остаётся у меня и никуда не публикуется. Если кто-то против — скажите, я выключу.",
    87 115 always_warn_myself: false,
    116 + language: "ru",
    117 + keep_audio: false,
    118 + launch_at_login: true,
    119 +};
    120 +let key: string | null = "sk-or-v1…demo";
    121 +const info: KeyInfo = { label: "просмотр без ядра", limit_remaining: 4.21, usage: 0.79, is_free_tier: false };
    122 +
    123 +// ── Имитация записи ─────────────────────────────────────────────────────────
    124 +
    125 +let status: RecorderStatus = { kind: "watching" };
    126 +let timers: number[] = [];
    127 +
    128 +function setStatus(s: RecorderStatus): void {
    129 + status = s;
    130 + emit("meet://status", s);
    131 +}
    132 +
    133 +function startRecording(zoom: boolean): Meeting {
    134 + const d = new Date();
    135 + const d0 = add(`${zoom ? "Встреча в Zoom," : "Запись,"} ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, [], 0, null, "recording");
    136 + const id = d0.meeting.id;
    137 + setStatus({ kind: "recording", meeting_id: id, started_at: d.toISOString(), paused: false, zoom });
    138 + const script = parse(planerka);
    139 + let i = 0;
    140 + timers.push(window.setInterval(() => {
    141 + if (status.kind !== "recording" || status.paused || i >= script.length) return;
    142 + const src = script[i++];
    143 + const speaker: Speaker = i % 3 === 1 ? { kind: "me" } : { kind: "others" };
    144 + const seg: Segment = { start_ms: Date.now() - Date.parse(status.started_at), speaker, text: src.text };
    145 + d0.transcript.segments.push(seg);
    146 + d0.meeting.segments++;
    147 + d0.meeting.duration_ms = seg.start_ms;
    148 + emit("meet://recorder", { meeting_id: id, event: { kind: "segments", data: [seg] } });
    149 + if (i === 6) {
    150 + d0.live_summary = "- Обсуждают релиз мобильной версии\n- Договорились: релиз переносится на вторник\n- Задача: Денис чинит оплату на Android 12 до пятницы";
    151 + emit("meet://recorder", { meeting_id: id, event: { kind: "live_summary", data: d0.live_summary } });
    152 + }
    153 + }, 1800));
    154 + timers.push(window.setInterval(() => {
    155 + if (status.kind !== "recording") return;
    156 + const quiet = status.paused;
    Разница обрезана: показаны первые 130 строк.
  • src/dom.ts +8
    Показать разницуСкрыть разницу
    31 31 button.textContent = before;
    32 32 }
    33 33 }
    34 +
    35 +/** Русское число: plural(3, "задача", "задачи", "задач") → «задачи». */
    36 +export function plural(n: number, one: string, few: string, many: string): string {
    37 + const d = n % 10, h = n % 100;
    38 + if (d === 1 && h !== 11) return one;
    39 + if (d >= 2 && d <= 4 && (h < 12 || h > 14)) return few;
    40 + return many;
    41 +}
  • src/main.ts +9 −5
    Показать разницуСкрыть разницу
    1 −// Маршрутизация по хешу: #/meetings, #/meeting/<id>/<вкладка>[/<реплика>], #/settings.
    1 +// Маршрутизация по хешу: #/ (главная), #/meeting/<id>/<вкладка>[/<реплика>], #/settings.
    2 2
    3 −import { renderMeetings } from "./views/meetings";
    3 +import { renderHome } from "./views/home";
    4 4 import { renderMeeting, type Tab } from "./views/meeting";
    5 5 import { renderSettings } from "./views/settings";
    6 6 import { errorText } from "./api";
    7 7 import { esc } from "./dom";
    8 +import { Scope } from "./scope";
    8 9
    9 10 const app = document.getElementById("app")!;
    11 +let scope = new Scope();
    10 12
    11 13 async function route(): Promise<void> {
    14 + scope.dispose();
    15 + scope = new Scope();
    12 16 const [section, id, tab, target] = location.hash.replace(/^#\/?/, "").split("/");
    13 17 for (const a of document.querySelectorAll<HTMLAnchorElement>("[data-nav]")) {
    14 − const current = a.dataset.nav === (section === "meeting" ? "meetings" : section || "meetings");
    18 + const current = a.dataset.nav === (section === "settings" ? "settings" : "home");
    15 19 if (current) a.setAttribute("aria-current", "page");
    16 20 else a.removeAttribute("aria-current");
    17 21 }
    18 22 try {
    19 23 if (section === "meeting" && id) {
    20 − await renderMeeting(app, decodeURIComponent(id), (tab as Tab) || "transcript", target ? Number(target) : null);
    24 + await renderMeeting(app, scope, decodeURIComponent(id), (tab as Tab) || "transcript", target ? Number(target) : null);
    21 25 } else if (section === "settings") {
    22 26 await renderSettings(app);
    23 27 } else {
    24 − await renderMeetings(app);
    28 + await renderHome(app, scope);
    25 29 }
    26 30 } catch (e) {
    27 31 app.innerHTML = `<p class="error">${esc(errorText(e))}</p>`;
  • src/scope.ts +32
    Показать разницуСкрыть разницу
    1 +// Область экрана: подписки и таймеры, которые снимаются при уходе с него.
    2 +
    3 +export class Scope {
    4 + private disposers: (() => void)[] = [];
    5 + private closed = false;
    6 +
    7 + /** Подписка на событие ядра: `on(events.status(cb))`. */
    8 + on(unsubscribe: Promise<() => void>): void {
    9 + unsubscribe.then((off) => (this.closed ? off() : this.disposers.push(off)));
    10 + }
    11 +
    12 + every(ms: number, fn: () => void): void {
    13 + const id = window.setInterval(fn, ms);
    14 + this.disposers.push(() => clearInterval(id));
    15 + }
    16 +
    17 + /** Вложенная область: снимается вместе с этой или раньше — сама. */
    18 + child(): Scope {
    19 + const c = new Scope();
    20 + this.disposers.push(() => c.dispose());
    21 + return c;
    22 + }
    23 +
    24 + get alive(): boolean {
    25 + return !this.closed;
    26 + }
    27 +
    28 + dispose(): void {
    29 + this.closed = true;
    30 + this.disposers.splice(0).forEach((off) => off());
    31 + }
    32 +}
  • Показать разницуСкрыть разницу
    55 55 .meeting-row { flex-wrap: wrap; }
    56 56 .meeting-row .when, .meeting-row .status { width: auto; text-align: left; }
    57 57 }
    58 +
    59 +/* ── Карточка состояния (главный экран, задача #1) ── */
    60 +.status-card {
    61 + display: grid; gap: 12px;
    62 + padding: 24px;
    63 + border: 1px solid var(--color-border);
    64 + background: var(--color-bg-elevated);
    65 +}
    66 +.status-card.is-attention, .status-card.is-recording { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); }
    67 +.status-kicker { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); }
    68 +.status-title { font-size: var(--text-3xl); }
    69 +.status-text { max-width: 60ch; color: var(--kv-body); }
    70 +.mono-num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
    71 +
    72 +/* Индикатор — квадрат: круг в продукте только у аватаров. */
    73 +.dot { display: inline-block; width: 8px; height: 8px; background: var(--color-text-secondary); }
    74 +.dot-accent { background: var(--color-accent); }
    75 +.dot-live { background: var(--color-accent); animation: blink 1.4s steps(2, jump-none) infinite; }
    76 +.dot-live.is-paused { animation: none; background: var(--color-text-secondary); }
    77 +@keyframes blink { 50% { opacity: 0.25; } }
    78 +@media (prefers-reduced-motion: reduce) { .dot-live { animation: none; } }
    79 +
    80 +.warning-text {
    81 + margin: 0; padding: 12px 16px; max-width: 72ch;
    82 + border-left: 2px solid var(--color-accent);
    83 + background: var(--color-bg-secondary);
    84 +}
    85 +
    86 +.meters { display: grid; gap: 6px; max-width: 420px; }
    87 +.meter { display: grid; grid-template-columns: 110px 1fr; align-items: center; gap: 12px; }
    88 +.meter-bar { height: 8px; background: var(--color-bg-secondary); overflow: hidden; }
    89 +.meter-fill { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.18s linear; }
    90 +
    91 +.live-lines { display: grid; gap: 4px; max-width: 72ch; font-size: var(--text-sm); min-height: 60px; }
    92 +.live-lines .who { font-weight: 700; }
    93 +
    94 +/* Второстепенное действие — ссылкой, а не второй кнопкой (одна контурная кнопка на экран). */
    95 +.link-btn {
    96 + padding: 0 4px; min-height: 32px;
    97 + font: inherit; font-size: var(--text-sm);
    98 + color: var(--color-accent-text); background: none; border: 0; cursor: pointer;
    99 + text-decoration: underline; text-underline-offset: 3px;
    100 +}
    101 +.link-btn:hover { color: var(--color-text-primary); }
    102 +.link-btn[disabled] { opacity: 0.5; cursor: default; }
    103 +a.ui-btn { text-decoration: none; }
    104 +a.ui-btn:hover { text-decoration: none; }
    105 +
    106 +.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
    107 +
    108 +/* Расшифровка: свои реплики отмечены полосой слева. */
    109 +.segment.is-me { box-shadow: inset 2px 0 0 var(--color-border-light); }
    110 +.segment.is-me.is-target { box-shadow: inset 2px 0 0 var(--color-accent); }
    111 +
    112 +.live-summary .box-head { align-items: baseline; }
    113 +.pre { white-space: pre-wrap; }
    114 +select.input { appearance: auto; }
  • Показать разницуСкрыть разницу
    1 +// Главный экран: что программа делает прямо сейчас (задача #1) и список встреч.
    2 +//
    3 +// Карточка состояния отвечает на один вопрос — «что происходит и что будет
    4 +// дальше». В каждом состоянии одно главное действие.
    5 +
    6 +import {
    7 + api, errorText, events, formatDate, formatDuration, speakerLabel,
    8 + type Meeting, type RecorderStatus, type Segment, type SettingsView, type Speaker,
    9 +} from "../api";
    10 +import { $, busy, esc, plural } from "../dom";
    11 +import type { Scope } from "../scope";
    12 +
    13 +function elapsed(since: string): string {
    14 + const t = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
    15 + const p = (n: number) => String(n).padStart(2, "0");
    16 + return t >= 3600 ? `${Math.floor(t / 3600)}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}` : `${p(Math.floor(t / 60))}:${p(t % 60)}`;
    17 +}
    18 +
    19 +function meter(label: string, key: string): string {
    20 + return `
    21 + <div class="meter">
    22 + <span class="meter-label small">${esc(label)}</span>
    23 + <span class="meter-bar"><span class="meter-fill" data-level="${key}"></span></span>
    24 + </div>`;
    25 +}
    26 +
    27 +function card(status: RecorderStatus, view: SettingsView): string {
    28 + const hasKey = Boolean(view.openrouter_key);
    29 + if (status.kind === "unsupported") {
    30 + return `
    31 + <section class="status-card">
    32 + <h2 class="status-title">Запись встреч работает в Windows</h2>
    33 + <p class="status-text">На этом компьютере можно разобрать готовую расшифровку: вставьте её, и ИИ сделает конспект и задачи.</p>
    34 + <div class="actions"><button class="ui-btn" data-open-import>Импортировать расшифровку</button></div>
    35 + </section>`;
    36 + }
    37 + if (!hasKey && (status.kind === "watching" || status.kind === "awaiting_consent")) {
    38 + return `
    39 + <section class="status-card">
    40 + <h2 class="status-title">Подключите ИИ, чтобы записывать встречи</h2>
    41 + <p class="status-text">Расшифровка и разбор идут через ваш ключ OpenRouter. Без него встречу не записать.</p>
    42 + <div class="actions"><a class="ui-btn" href="#/settings">Подключить</a></div>
    43 + </section>`;
    44 + }
    45 + switch (status.kind) {
    46 + case "watching":
    47 + return `
    48 + <section class="status-card">
    49 + <p class="status-kicker"><span class="dot"></span>Слежу за Zoom</p>
    50 + <h2 class="status-title">Жду встречу</h2>
    51 + <p class="status-text">${
    52 + view.settings.always_warn_myself
    53 + ? "Как только начнётся встреча в Zoom, запись включится сама."
    54 + : "Как только начнётся встреча в Zoom, я попрошу предупредить участников и начну запись."
    55 + }</p>
    56 + <div class="actions"><button class="link-btn" data-start>Записать вручную</button></div>
    57 + </section>`;
    58 + case "awaiting_consent":
    59 + return `
    60 + <section class="status-card is-attention">
    61 + <p class="status-kicker"><span class="dot dot-accent"></span>Началась встреча в Zoom</p>
    62 + <h2 class="status-title">Предупредите участников о записи</h2>
    63 + <blockquote class="warning-text" data-warning>${esc(view.settings.warning_text)}</blockquote>
    64 + <div class="actions">
    65 + <button class="ui-btn" data-consent="yes">Предупредил, записывать</button>
    66 + <button class="link-btn" data-copy>Скопировать текст</button>
    67 + <button class="link-btn" data-consent="no">Не записывать</button>
    68 + </div>
    69 + </section>`;
    70 + case "recording":
    71 + return `
    72 + <section class="status-card is-recording">
    73 + <p class="status-kicker"><span class="dot dot-live${status.paused ? " is-paused" : ""}"></span>${
    74 + status.paused ? "Запись на паузе" : status.zoom ? "Записываю встречу в Zoom" : "Записываю с микрофона"
    75 + }</p>
    76 + <h2 class="status-title mono-num" data-timer>${elapsed(status.started_at)}</h2>
    77 + <div class="meters">
    78 + ${meter("Я", "me")}
    79 + ${status.zoom ? meter("Собеседники", "others") : ""}
    80 + </div>
    81 + <div class="live-lines" data-live><p class="muted small">Реплики появятся здесь через несколько секунд после начала разговора.</p></div>
    82 + <div class="actions">
    83 + <button class="ui-btn" data-stop>Остановить</button>
    84 + <button class="link-btn" data-pause>${status.paused ? "Продолжить" : "Пауза"}</button>
    85 + <a class="link-btn" href="#/meeting/${encodeURIComponent(status.meeting_id)}">Открыть расшифровку</a>
    86 + </div>
    87 + </section>`;
    88 + case "finishing":
    89 + return `
    90 + <section class="status-card">
    91 + <p class="status-kicker"><span class="dot dot-accent"></span>Запись остановлена</p>
    92 + <h2 class="status-title">${status.step === "transcribing" ? "Дописываю расшифровку…" : "Разбираю встречу…"}</h2>
    93 + <p class="status-text">${
    94 + status.step === "transcribing"
    95 + ? "Распознаю последние реплики. Потом ИИ сделает конспект и задачи."
    96 + : "ИИ пишет конспект, решения и задачи. Обычно это меньше минуты — пришлю уведомление."
    97 + }</p>
    98 + <div class="actions"><a class="link-btn" href="#/meeting/${encodeURIComponent(status.meeting_id)}">Открыть встречу</a></div>
    99 + </section>`;
    100 + }
    101 +}
    102 +
    103 +function status(m: Meeting): string {
    104 + if (m.state === "recording") return `<span class="label label-accent">идёт запись</span>`;
    105 + if (m.state === "interrupted" && !m.analysis) return `<span class="label">прервана</span>`;
    106 + if (!m.analysis) return `<span class="label">не разобрана</span>`;
    107 + const n = m.analysis.tasks;
    108 + return `<span class="label label-accent">${n} ${plural(n, "задача", "задачи", "задач")}</span>`;
    109 +}
    110 +
    111 +function list(meetings: Meeting[]): string {
    112 + if (!meetings.length) {
    113 + return `<p class="empty">Здесь появятся записанные встречи.</p>`;
    114 + }
    115 + return meetings
    116 + .map(
    117 + (m) => `
    118 + <a class="row meeting-row" href="#/meeting/${encodeURIComponent(m.id)}">
    119 + <span class="title">${esc(m.title)}</span>
    120 + <span class="when small muted">${esc(formatDate(m.created_at))} · ${esc(formatDuration(m.duration_ms))}</span>
    121 + <span class="status">${status(m)}</span>
    122 + </a>`,
    123 + )
    124 + .join("");
    125 +}
    126 +
    127 +function importForm(): string {
    128 + return `
    129 + <form class="box" data-import hidden>
    130 + <div class="box-head"><h2>Импорт расшифровки</h2></div>
    131 + <div class="box-body">
    132 + <div class="field">
    133 + <label for="imp-title">Название</label>
    134 + <input id="imp-title" class="input" name="title" autocomplete="off" required>
    135 + </div>
    136 + <div class="field">
    137 + <label for="imp-text">Расшифровка</label>
    138 + <textarea id="imp-text" class="textarea mono" name="text" rows="10" required
    139 + placeholder="[00:00:05] Анна: Начнём с отчёта."></textarea>
    140 + <p class="field-hint">Каждая реплика — с новой строки: [чч:мм:сс] Имя: текст.</p>
    141 + </div>
    142 + <p class="error" data-import-error hidden></p>
    143 + <div class="actions">
    144 + <button class="ui-btn" type="submit">Создать встречу</button>
    145 + <button class="link-btn" type="button" data-close-import>Отмена</button>
    146 + </div>
    147 + </div>
    148 + </form>`;
    149 +}
    150 +
    151 +function speakerKey(s: Speaker): string {
    152 + return s.kind === "me" ? "me" : "others";
    153 +}
    154 +
    155 +export async function renderHome(root: HTMLElement, scope: Scope): Promise<void> {
    156 + const [recorder, initialView, meetings] = await Promise.all([api.recorderStatus(), api.getSettings(), api.listMeetings()]);
    157 + if (!scope.alive) return;
    158 + let view = initialView;
    159 +
    160 + root.innerHTML = `
    161 + <div data-card>${card(recorder, view)}</div>
    162 + <p class="error" data-error hidden></p>
    163 + ${importForm()}
    164 + <section class="section">
    165 + <div class="section-head">
    166 + <h2>Встречи</h2>
    167 + <button class="link-btn" data-open-import>Импортировать расшифровку</button>
    168 + </div>
    169 + <div class="box" data-list>${list(meetings)}</div>
    170 + </section>`;
    171 +
    172 + const error = $(root, "[data-error]");
    173 + const fail = (e: unknown) => {
    174 + error.textContent = errorText(e);
    175 + error.hidden = false;
    176 + };
    177 + let current = recorder;
    178 + const live: Segment[] = [];
    179 +
    180 + const bindCard = () => {
    181 + const box = $(root, "[data-card]");
    182 + box.querySelector<HTMLButtonElement>("[data-start]")?.addEventListener("click", (e) =>
    183 + busy(e.currentTarget as HTMLButtonElement, "Запускаю…", () => api.recorderStart()).catch(fail),
    184 + );
    185 + box.querySelectorAll<HTMLButtonElement>("[data-consent]").forEach((b) =>
    186 + b.addEventListener("click", () => api.recorderConsent(b.dataset.consent === "yes").catch(fail)),
    187 + );
    188 + box.querySelector<HTMLButtonElement>("[data-copy]")?.addEventListener("click", async (e) => {
    189 + const b = e.currentTarget as HTMLButtonElement;
    190 + await navigator.clipboard.writeText(view.settings.warning_text).catch(() => undefined);
    191 + b.textContent = "Скопировано";
    192 + });
    193 + box.querySelector<HTMLButtonElement>("[data-stop]")?.addEventListener("click", (e) =>
    194 + busy(e.currentTarget as HTMLButtonElement, "Останавливаю…", () => api.recorderStop()).catch(fail),
    195 + );
    196 + box.querySelector<HTMLButtonElement>("[data-pause]")?.addEventListener("click", () =>
    197 + api.recorderPause(current.kind === "recording" && !current.paused).catch(fail),
    198 + );
    199 + box.querySelector("[data-open-import]")?.addEventListener("click", openImport);
    200 + renderLive();
    201 + };
    202 +
    203 + const renderLive = () => {
    204 + const el = root.querySelector("[data-live]");
    205 + if (!el || !live.length) return;
    206 + el.innerHTML = live
    207 + .slice(-3)
    208 + .map((s) => `<p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p>`)
    209 + .join("");
    210 + };
    211 +
    212 + const refreshList = async () => {
    213 + const fresh = await api.listMeetings();
    214 + if (scope.alive) $(root, "[data-list]").innerHTML = list(fresh);
    215 + };
    216 +
    217 + // Импорт
    218 + const form = $(root, "[data-import]") as HTMLFormElement;
    219 + const importError = $(root, "[data-import-error]");
    220 + function openImport() {
    221 + form.hidden = false;
    222 + $(form, "#imp-title").focus();
    223 + }
    224 + root.querySelectorAll("[data-open-import]").forEach((b) => b.addEventListener("click", openImport));
    225 + $(root, "[data-close-import]").addEventListener("click", () => {
    226 + form.hidden = true;
    Разница обрезана: показаны первые 226 строк.
  • src/views/meeting.ts +92 −20
    Показать разницуСкрыть разницу
    1 1 // Экран «Встреча»: вкладки «Расшифровка», «Конспект», «Задачи» и запуск разбора.
    2 +// Пока идёт запись, расшифровка и живой конспект обновляются по событиям ядра.
    2 3
    3 −import { api, errorText, formatDate, formatDuration, formatTime, speakerLabel, type Analysis, type MeetingDetail } from "../api";
    4 +import {
    5 + api, errorText, events, formatDate, formatDuration, formatTime, speakerLabel,
    6 + type Analysis, type MeetingDetail, type Segment,
    7 +} from "../api";
    4 8 import { $, busy, esc, list } from "../dom";
    9 +import type { Scope } from "../scope";
    5 10
    6 11 export type Tab = "transcript" | "summary" | "tasks";
    7 12
    11 16 return `<a class="jump" href="#/meeting/${encodeURIComponent(d.meeting.id)}/transcript/${index}">${formatTime(seg.start_ms)}</a>`;
    12 17 }
    13 18
    14 −function transcriptTab(d: MeetingDetail, target: number | null): string {
    15 − return `<section class="box">${d.transcript.segments
    16 − .map(
    17 − (s, i) => `
    18 − <div class="segment${i === target ? " is-target" : ""}" id="seg-${i}">
    19 +function segmentRow(s: Segment, i: number, target: number | null): string {
    20 + return `
    21 + <div class="segment${i === target ? " is-target" : ""}${s.speaker.kind === "me" ? " is-me" : ""}" id="seg-${i}">
    19 22 <span class="at mono">${formatTime(s.start_ms)}</span>
    20 23 <p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p>
    21 − </div>`,
    22 − )
    23 − .join("")}</section>`;
    24 + </div>`;
    25 +}
    26 +
    27 +function transcriptTab(d: MeetingDetail, target: number | null): string {
    28 + if (!d.transcript.segments.length) {
    29 + return `<section class="box" data-segments><p class="empty">${
    30 + d.meeting.state === "recording"
    31 + ? "Реплики появятся здесь через несколько секунд после начала разговора."
    32 + : "В записи не прозвучало ни одной реплики."
    33 + }</p></section>`;
    34 + }
    35 + return `<section class="box" data-segments>${d.transcript.segments.map((s, i) => segmentRow(s, i, target)).join("")}</section>`;
    24 36 }
    25 37
    26 −function notAnalyzed(): string {
    27 − return `<section class="box"><p class="empty">Разбора ещё нет.</p></section>`;
    38 +function liveSummary(d: MeetingDetail): string {
    39 + if (!d.live_summary) return "";
    40 + return `
    41 + <section class="box live-summary">
    42 + <div class="box-head"><h2>Живой конспект</h2><span class="xs muted">обновляется каждые несколько минут</span></div>
    43 + <div class="box-body"><p class="pre">${esc(d.live_summary)}</p></div>
    44 + </section>`;
    45 +}
    46 +
    47 +function notAnalyzed(d: MeetingDetail): string {
    48 + const why =
    49 + d.meeting.state === "recording"
    50 + ? "Разбор появится сам, когда встреча закончится."
    51 + : "Разбора ещё нет. Нажмите «Разобрать» — ИИ сделает конспект, решения и задачи.";
    52 + return `${liveSummary(d)}<section class="box"><p class="empty">${why}</p></section>`;
    28 53 }
    29 54
    30 55 function summaryTab(d: MeetingDetail, a: Analysis): string {
    69 94 return `<p class="xs muted">Разбор: ${esc(a.model)} · ${esc(formatDate(a.at))} · ${a.usage.prompt_tokens + a.usage.completion_tokens} токенов${price}</p>`;
    70 95 }
    71 96
    72 −export async function renderMeeting(root: HTMLElement, id: string, tab: Tab, target: number | null = null): Promise<void> {
    97 +export async function renderMeeting(
    98 + root: HTMLElement,
    99 + parent: Scope,
    100 + id: string,
    101 + tab: Tab,
    102 + target: number | null = null,
    103 +): Promise<void> {
    104 + const scope = parent.child();
    73 105 const d = await api.getMeeting(id);
    106 + if (!scope.alive) return;
    107 + const rerender = (t: Tab = tab, at: number | null = null) => {
    108 + scope.dispose();
    109 + return renderMeeting(root, parent, id, t, at);
    110 + };
    74 111 const a = d.analysis;
    112 + const recording = d.meeting.state === "recording";
    75 113 const base = `#/meeting/${encodeURIComponent(id)}`;
    76 114 const tabLink = (key: Tab, label: string, count?: number) =>
    77 115 `<a href="${base}/${key}"${key === tab ? ` aria-current="page"` : ""}>${label}${
    79 117 }</a>`;
    80 118
    81 119 const body =
    82 − tab === "transcript" ? transcriptTab(d, target) : !a ? notAnalyzed() : tab === "summary" ? summaryTab(d, a) : tasksTab(d, a);
    120 + tab === "transcript" ? transcriptTab(d, target) : !a ? notAnalyzed(d) : tab === "summary" ? summaryTab(d, a) : tasksTab(d, a);
    121 + const stateLabel = recording
    122 + ? ` <span class="label label-accent">идёт запись</span>`
    123 + : d.meeting.state === "interrupted"
    124 + ? ` <span class="label" title="Программа закрылась посреди записи; всё, что успело распознаться, сохранено">прервана</span>`
    125 + : "";
    83 126
    84 127 root.innerHTML = `
    85 128 <div class="page-head">
    86 129 <div>
    87 130 <h1>${esc(d.meeting.title)}</h1>
    88 − <p class="meta small muted">${esc(formatDate(d.meeting.created_at))} · ${esc(formatDuration(d.meeting.duration_ms))} · ${d.meeting.segments} реплик</p>
    131 + <p class="meta small muted">${esc(formatDate(d.meeting.created_at))} · ${esc(formatDuration(d.meeting.duration_ms))} · <span data-count>${d.meeting.segments}</span> реплик${stateLabel}</p>
    89 132 </div>
    90 133 <div class="actions">
    91 − <button class="ui-btn" data-analyze>${a ? "Разобрать заново" : "Разобрать"}</button>
    134 + ${recording ? "" : `<button class="ui-btn" data-analyze>${a ? "Разобрать заново" : "Разобрать"}</button>`}
    92 135 </div>
    93 136 </div>
    94 137 <p class="error" data-error hidden></p>
    102 145
    103 146 if (target !== null) document.getElementById(`seg-${target}`)?.scrollIntoView({ block: "center" });
    104 147
    105 − const button = $(root, "[data-analyze]") as HTMLButtonElement;
    106 148 const error = $(root, "[data-error]");
    107 − button.addEventListener("click", async () => {
    149 + root.querySelector<HTMLButtonElement>("[data-analyze]")?.addEventListener("click", async (event) => {
    108 150 error.hidden = true;
    109 151 try {
    110 − await busy(button, "Разбираю…", () => api.analyzeMeeting(id));
    111 − location.hash = `${base}/summary`;
    112 − if (tab === "summary") await renderMeeting(root, id, "summary");
    152 + await busy(event.currentTarget as HTMLButtonElement, "Разбираю…", () => api.analyzeMeeting(id));
    153 + if (tab === "summary") await rerender("summary");
    154 + else location.hash = `${base}/summary`;
    113 155 } catch (e) {
    114 156 error.textContent = errorText(e);
    115 157 error.hidden = false;
    116 158 }
    117 159 });
    160 +
    161 + // Живая расшифровка: новые реплики дописываются на место по времени, лента
    162 + // едет вниз сама, пока человек не прокрутил вверх.
    163 + const segments = d.transcript.segments;
    164 + scope.on(
    165 + events.recorder(({ meeting_id, event }) => {
    166 + if (meeting_id !== id) return;
    167 + if (event.kind === "segments") {
    168 + segments.push(...event.data);
    169 + segments.sort((x, y) => x.start_ms - y.start_ms);
    170 + const count = root.querySelector("[data-count]");
    171 + if (count) count.textContent = String(segments.length);
    172 + const box = root.querySelector<HTMLElement>("[data-segments]");
    173 + if (tab !== "transcript" || !box) return;
    174 + const atBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
    175 + box.innerHTML = segments.map((s, i) => segmentRow(s, i, null)).join("");
    176 + if (atBottom) window.scrollTo({ top: document.body.scrollHeight });
    177 + } else if (event.kind === "live_summary" && tab !== "transcript" && !a) {
    178 + rerender();
    179 + } else if (event.kind === "stt_failed") {
    180 + error.textContent = `Кусок записи не распознался: ${event.data}. Звук сохранён.`;
    181 + error.hidden = false;
    182 + }
    183 + }),
    184 + );
    185 + scope.on(
    186 + events.meetingChanged((changed) => {
    187 + if (changed === id) rerender(tab, target);
    188 + }),
    189 + );
    118 190 }
  • Показать разницуСкрыть разницу
    1 −// Экран «Встречи»: список и импорт расшифровки.
    2 −
    3 −import { api, errorText, formatDate, formatDuration, type Meeting } from "../api";
    4 −import { $, busy, esc } from "../dom";
    5 −
    6 −function status(m: Meeting): string {
    7 − if (!m.analysis) return `<span class="label">не разобрана</span>`;
    8 − const n = m.analysis.tasks;
    9 − return `<span class="label label-accent">разобрана · ${n} ${plural(n, "задача", "задачи", "задач")}</span>`;
    10 −}
    11 −
    12 −export function plural(n: number, one: string, few: string, many: string): string {
    13 − const d = n % 10, h = n % 100;
    14 − if (d === 1 && h !== 11) return one;
    15 − if (d >= 2 && d <= 4 && (h < 12 || h > 14)) return few;
    16 − return many;
    17 −}
    18 −
    19 −export async function renderMeetings(root: HTMLElement): Promise<void> {
    20 − const meetings = await api.listMeetings();
    21 − root.innerHTML = `
    22 − <div class="page-head">
    23 − <h1>Встречи</h1>
    24 − <div class="actions"><button class="ui-btn" data-open-import>Импорт расшифровки</button></div>
    25 − </div>
    26 −
    27 − <form class="box" data-import hidden>
    28 − <div class="box-head"><h2>Импорт расшифровки</h2></div>
    29 − <div class="box-body">
    30 − <div class="field">
    31 − <label for="imp-title">Название</label>
    32 − <input id="imp-title" class="input" name="title" autocomplete="off" required>
    33 − </div>
    34 − <div class="field">
    35 − <label for="imp-text">Расшифровка</label>
    36 − <textarea id="imp-text" class="textarea mono" name="text" rows="10" required
    37 − placeholder="[00:00:05] Анна: Начнём с отчёта."></textarea>
    38 − <p class="field-hint">Каждая реплика — с новой строки: [чч:мм:сс] Имя: текст.</p>
    39 − </div>
    40 − <p class="error" data-import-error hidden></p>
    41 − <div class="actions">
    42 − <button class="ui-btn" type="submit">Создать встречу</button>
    43 − <button class="ui-btn" type="button" data-close-import>Отмена</button>
    44 − </div>
    45 − </div>
    46 − </form>
    47 −
    48 − <section class="box">
    49 − ${
    50 − meetings.length
    51 − ? meetings
    52 − .map(
    53 − (m) => `
    54 − <a class="row meeting-row" href="#/meeting/${encodeURIComponent(m.id)}">
    55 − <span class="title">${esc(m.title)}</span>
    56 − <span class="when small muted">${esc(formatDate(m.created_at))} · ${esc(formatDuration(m.duration_ms))}</span>
    57 − <span class="status">${status(m)}</span>
    58 − </a>`,
    59 − )
    60 − .join("")
    61 − : `<p class="empty">Встреч пока нет.</p>`
    62 − }
    63 − </section>`;
    64 −
    65 − const form = $(root, "[data-import]") as HTMLFormElement;
    66 − const error = $(root, "[data-import-error]");
    67 − $(root, "[data-open-import]").addEventListener("click", () => {
    68 − form.hidden = false;
    69 − $(form, "#imp-title").focus();
    70 − });
    71 − $(root, "[data-close-import]").addEventListener("click", () => {
    72 − form.hidden = true;
    73 − error.hidden = true;
    74 − });
    75 − form.addEventListener("submit", async (event) => {
    76 − event.preventDefault();
    77 − const data = new FormData(form);
    78 − const submit = $(form, "[type=submit]") as HTMLButtonElement;
    79 − try {
    80 − const m = await busy(submit, "Создаю…", () => api.importMeeting(String(data.get("title")), String(data.get("text"))));
    81 − location.hash = `#/meeting/${encodeURIComponent(m.id)}`;
    82 − } catch (e) {
    83 − error.textContent = errorText(e);
    84 − error.hidden = false;
    85 − }
    86 − });
    87 −}
  • src/views/settings.ts +25 −1
    Показать разницуСкрыть разницу
    43 43 ${keyBox(view)}
    44 44
    45 45 <form class="box" data-settings>
    46 − <div class="box-head"><h2>Модели и запись</h2></div>
    46 + <div class="box-head"><h2>Запись и ИИ</h2></div>
    47 47 <div class="box-body">
    48 48 <div class="field">
    49 49 <label for="m-analysis">Модель итогового разбора</label>
    68 68 <span>Я всегда предупреждаю участников сам — не спрашивать перед каждой записью</span>
    69 69 </label>
    70 70 <div class="field">
    71 + <label for="lang">Язык встреч</label>
    72 + <select id="lang" class="input" name="language">
    73 + ${[["ru", "Русский"], ["en", "Английский"], ["", "Определять самому"]]
    74 + .map(([v, t]) => `<option value="${v}"${s.language === v ? " selected" : ""}>${t}</option>`)
    75 + .join("")}
    76 + </select>
    77 + <p class="field-hint">Короткие куски речи распознаются точнее, когда язык задан заранее.</p>
    78 + </div>
    79 + <label class="check">
    80 + <input type="checkbox" name="keep_audio"${s.keep_audio ? " checked" : ""}>
    81 + <span>Хранить звук встреч после разбора. Без этой отметки звук удаляется, остаются текст и разбор.</span>
    82 + </label>
    83 + ${
    84 + view.capture_supported
    85 + ? `<label class="check">
    86 + <input type="checkbox" name="launch_at_login"${s.launch_at_login ? " checked" : ""}>
    87 + <span>Запускать вместе с Windows — иначе встречу, начатую до запуска программы, некому записать</span>
    88 + </label>`
    89 + : ""
    90 + }
    91 + <div class="field">
    71 92 <span class="field-label">Папка встреч</span>
    72 93 <code>${esc(view.meetings_dir)}</code>
    73 94 </div>
    126 147 stt_model: String(data.get("stt_model")).trim(),
    127 148 warning_text: String(data.get("warning_text")).trim(),
    128 149 always_warn_myself: data.get("always_warn_myself") === "on",
    150 + language: String(data.get("language") ?? ""),
    151 + keep_audio: data.get("keep_audio") === "on",
    152 + launch_at_login: view.capture_supported ? data.get("launch_at_login") === "on" : s.launch_at_login,
    129 153 }),
    130 154 );
    131 155 show(status, "Сохранено", true);