Этап 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>
d93e31a
· сравнить с родителем
-
src-tauri/Cargo.lock → Cargo.lock +774 −202
Показать разницуСкрыть разницу
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(¤t) 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> -
prompts/live.md +10
Показать разницуСкрыть разницу
1 +Ты ведёшь короткий живой конспект встречи, которая идёт прямо сейчас. Человек бросает на него взгляд между репликами, поэтому конспект должен читаться за десять секунд. 2 + 3 +Расшифровка автоматическая, реплики пронумерованы: `#N [время] Говорящий: текст`. «Я» — владелец записи, «Собеседники» — остальные участники. Встречаются ошибки распознавания — не цитируй их, пересказывай смысл. 4 + 5 +Ответь простым текстом, без заголовков, в таком виде: 6 + 7 +- 2–4 пункта: о чём говорят и к чему пришли; 8 +- если прозвучали договорённости или поручения — отдельные пункты, начинающиеся с «Договорились:» или «Задача:». 9 + 10 +Не больше 80 слов. Не выдумывай того, чего не было. Если говорить пока не о чем — ответь одной строкой «Пока идёт разговор без итогов». -
src-tauri/Cargo.toml +8 −3
Показать разницуСкрыть разницу
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"] } -
src-tauri/src/commands.rs +82 −31
Показать разницуСкрыть разницу
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 не запустился"); -
src-tauri/src/meetings.rs +202 −3
Показать разницуСкрыть разницу
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 строк. -
src-tauri/src/openrouter.rs +233 −8
Показать разницуСкрыть разницу
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; } -
src/views/home.ts +277
Показать разницуСкрыть разницу
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);