Запись всего звука, папка хранения, точнее распознавание
- Звук собеседников — весь звук компьютера, кроме самого Skillok Meet: Zoom, Google Meet, Телемост, что угодно. Windows — process loopback в режиме исключения своего процесса, macOS — ScreenCaptureKit без фильтра по приложению. Поиск Zoom и зависимость windows убраны. - Если звук компьютера недоступен (на Mac — нет разрешения), запись идёт с микрофона, а интерфейс говорит почему. - Настройки: папка хранения записей (выбрать, открыть, вернуть по умолчанию; записи переносятся целиком), точность распознавания — по умолчанию полная whisper-large-v3. - «Открыть папку с файлами» у записи; микрофон на Mac просит у CoreAudio сразу 16 кГц. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1b62ba2
· сравнить с родителем
-
Cargo.lock +457 −1
Показать разницуСкрыть разницу
95 95 checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" 96 96 97 97 [[package]] 98 +name = "async-broadcast" 99 +version = "0.7.2" 100 +source = "registry+https://github.com/rust-lang/crates.io-index" 101 +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" 102 +dependencies = [ 103 + "event-listener", 104 + "event-listener-strategy", 105 + "futures-core", 106 + "pin-project-lite", 107 +] 108 + 109 +[[package]] 110 +name = "async-channel" 111 +version = "2.5.0" 112 +source = "registry+https://github.com/rust-lang/crates.io-index" 113 +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" 114 +dependencies = [ 115 + "concurrent-queue", 116 + "event-listener-strategy", 117 + "futures-core", 118 + "pin-project-lite", 119 +] 120 + 121 +[[package]] 122 +name = "async-executor" 123 +version = "1.14.0" 124 +source = "registry+https://github.com/rust-lang/crates.io-index" 125 +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" 126 +dependencies = [ 127 + "async-task", 128 + "concurrent-queue", 129 + "fastrand", 130 + "futures-lite", 131 + "pin-project-lite", 132 + "slab", 133 +] 134 + 135 +[[package]] 136 +name = "async-io" 137 +version = "2.6.0" 138 +source = "registry+https://github.com/rust-lang/crates.io-index" 139 +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" 140 +dependencies = [ 141 + "autocfg", 142 + "cfg-if", 143 + "concurrent-queue", 144 + "futures-io", 145 + "futures-lite", 146 + "parking", 147 + "polling", 148 + "rustix", 149 + "slab", 150 + "windows-sys 0.61.2", 151 +] 152 + 153 +[[package]] 154 +name = "async-lock" 155 +version = "3.4.2" 156 +source = "registry+https://github.com/rust-lang/crates.io-index" 157 +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" 158 +dependencies = [ 159 + "event-listener", 160 + "event-listener-strategy", 161 + "pin-project-lite", 162 +] 163 + 164 +[[package]] 165 +name = "async-process" 166 +version = "2.5.0" 167 +source = "registry+https://github.com/rust-lang/crates.io-index" 168 +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" 169 +dependencies = [ 170 + "async-channel", 171 + "async-io", 172 + "async-lock", 173 + "async-signal", 174 + "async-task", 175 + "blocking", 176 + "cfg-if", 177 + "event-listener", 178 + "futures-lite", 179 + "rustix", 180 +] 181 + 182 +[[package]] 183 +name = "async-recursion" 184 +version = "1.1.1" 185 +source = "registry+https://github.com/rust-lang/crates.io-index" 186 +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" 187 +dependencies = [ 188 + "proc-macro2", 189 + "quote", 190 + "syn 2.0.119", 191 +] 192 + 193 +[[package]] 194 +name = "async-signal" 195 +version = "0.2.14" 196 +source = "registry+https://github.com/rust-lang/crates.io-index" 197 +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" 198 +dependencies = [ 199 + "async-io", 200 + "async-lock", 201 + "atomic-waker", 202 + "cfg-if", 203 + "futures-core", 204 + "futures-io", 205 + "rustix", 206 + "signal-hook-registry", 207 + "slab", 208 + "windows-sys 0.61.2", 209 +] 210 + 211 +[[package]] 212 +name = "async-task" 213 +version = "4.7.1" 214 +source = "registry+https://github.com/rust-lang/crates.io-index" 215 +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" 216 + 217 +[[package]] 218 +name = "async-trait" 219 +version = "0.1.92" 220 +source = "registry+https://github.com/rust-lang/crates.io-index" 221 +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" 222 +dependencies = [ 223 + "proc-macro2", 224 + "quote", 225 + "syn 3.0.6", 226 +] 227 + 228 +[[package]] 98 229 name = "atk" 99 230 version = "0.18.2" Разница обрезана: показаны первые 136 строк. -
README.md +30 −31
Показать разницуСкрыть разницу
21 21 текущий: 22 22 23 23 1. **Аудио.** 24 − - «Записать» — пишется звук Zoom и микрофон, пока не нажмёте «Остановить»; есть пауза 25 − и кнопка «Скопировать предупреждение для чата». Zoom нужно запустить до нажатия, 26 − иначе запишется только микрофон. 24 + - «Записать» — пишется весь звук компьютера (собеседники в Zoom, Google Meet, Телемосте — 25 + где угодно) и микрофон, пока не нажмёте «Остановить»; есть пауза и кнопка 26 + «Скопировать предупреждение для чата». Лучше в наушниках: иначе голоса собеседников 27 + попадут и в микрофон. 27 28 - Готовый файл — перетащить в окно или «выберите файл»: mp3, m4a, wav, aiff, flac, ogg. 28 29 2. **Расшифровка** — кнопка «Расшифровать». Виден ход работы, можно «Отменить». 29 30 3. **Результат** — вкладки: 42 43 ## Как это устроено 43 44 44 45 ``` 45 − «Записать»: Zoom.exe ──┐ 46 − микрофон ──┴─► audio/others.wav, audio/me.wav 46 + «Записать»: звук компьютера ─┐ 47 + микрофон ─────┴─► audio/others.wav, audio/me.wav 47 48 «Добавить аудио»: файл ──────────► audio/<файл> 48 49 49 50 «Расшифровать»: дорожки ─► 16 кГц моно ─► куски 6–28 с по паузам ─► OpenRouter: Whisper 54 55 Команда ИИ: note.md ─► OpenRouter: Claude ─► note.md: ## Задачи / ## Конспект / … 55 56 ``` 56 57 57 −### Запись звука Zoom (Windows) 58 − 59 −- Звук берём **только от процесса Zoom** (process loopback) и отдельно с микрофона. 60 − Системные звуки и музыка в запись не попадают. 61 − См. [Application loopback sample](https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/) 62 − и [ActivateAudioInterfaceAsync](https://learn.microsoft.com/en-us/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-activateaudiointerfaceasync). 63 − Нужна Windows 10 сборки 20438 или новее. 64 −- Какой процесс писать, определяется в момент нажатия «Записать»: `Zoom.exe` с открытым 65 − потоком звука, то есть идущей встречей. 66 −- API Zoom, аккаунт разработчика и одобрение в Marketplace **не нужны**. Работает на 67 − любой встрече и на любом плане Zoom. 68 − 69 −### Запись звука Zoom (macOS 13+) 70 − 71 −- Звук Zoom — ScreenCaptureKit с фильтром по приложению `us.zoom.xos`: в запись попадает 72 − только то, что выводит Zoom, сразу в 16 кГц моно 73 − ([SCStreamConfiguration.capturesAudio](https://developer.apple.com/documentation/screencapturekit/scstreamconfiguration/capturesaudio)). 74 − Крейт — `screencapturekit`. 75 −- Микрофон — CoreAudio через `cpal`. 76 −- При первой записи macOS спросит два разрешения: «Микрофон» и «Запись экрана и системного 77 − звука». После второго программу нужно перезапустить — так устроена macOS. Без него 78 − запишется только микрофон. 79 −- Для сборки нужны Command Line Tools (Swift): ScreenCaptureKit подключается Swift-мостом. 58 +### Запись звука 59 + 60 +Пишутся две дорожки: **весь звук компьютера**, кроме самого Skillok Meet (голоса 61 +собеседников в любом сервисе встреч — Zoom, Google Meet, Телемост, Teams), и **микрофон**. 62 +Звук — WAV без сжатия, 16 кГц моно: ровно то, что использует распознавание. Большая 63 +частота точность не поднимает; поднимают полная модель Whisper (по умолчанию), наушники и 64 +заданный язык. Вместе со звуком компьютера в запись попадёт и всё прочее, что звучит во 65 +время встречи (уведомления, музыка); куски без речи при расшифровке отсеиваются. 66 + 67 +- **Windows 10 (сборка 20438+) и 11** — process loopback в режиме «все процессы, кроме 68 + нашего» ([Application loopback sample](https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/)), 69 + микрофон — WASAPI. 70 +- **macOS 13+** — ScreenCaptureKit, весь звук экрана без звука своего приложения 71 + ([SCStreamConfiguration.capturesAudio](https://developer.apple.com/documentation/screencapturekit/scstreamconfiguration/capturesaudio)), 72 + микрофон — CoreAudio через `cpal`. При первой записи macOS спросит «Микрофон» и «Запись 73 + экрана и системного звука»; после второго программу нужно перезапустить. Без него 74 + запишется только микрофон — программа так и скажет. 75 +- API Zoom и других сервисов **не нужны**. 76 + 77 +Папка записей выбирается в «Настройках»; при смене все записи переносятся в новую. 80 78 81 79 ### Расшифровка — Whisper через OpenRouter 82 80 83 81 - `POST https://openrouter.ai/api/v1/audio/transcriptions`, тот же ключ, что для текстовых 84 − моделей. По умолчанию `openai/whisper-large-v3-turbo`. 82 + моделей. По умолчанию `openai/whisper-large-v3` — точнее turbo; в настройках можно 83 + выбрать быстрее и дешевле. 85 84 См. [документацию OpenRouter STT](https://openrouter.ai/docs/guides/overview/multimodal/stt). 86 85 - Файл любой длины режется на куски по паузам: у эндпоинта предел 25 МБ и около минуты 87 86 обработки на запрос. Куски без речи не отправляются — это дешевле, и Whisper не 191 190 decode.rs чтение mp3, m4a, wav, aiff, flac, ogg (symphonia) 192 191 chunker.rs нарезка на куски по паузам, отсев тишины 193 192 source.rs источник звука; Replay — звук из памяти для тестов 194 − win.rs Windows: захват процесса Zoom и микрофона, поиск Zoom 195 − mac.rs macOS: звук Zoom (ScreenCaptureKit) и микрофон (cpal) 193 + win.rs Windows: звук компьютера (process loopback) и микрофон 194 + mac.rs macOS: звук компьютера (ScreenCaptureKit) и микрофон (cpal) 196 195 src-tauri/src/ ядро на Rust 197 196 meetings.rs встречи на диске: meeting.json, note.md, audio/ 198 197 notes.rs разделы заметки: поставить на место, убрать -
Показать разницуСкрыть разницу
15 15 16 16 [target.'cfg(windows)'.dependencies] 17 17 wasapi = "0.24" 18 −windows = { version = "0.62", features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp"] } -
Показать разницуСкрыть разницу
327 327 fn gaps_in_the_stream_keep_time_right() { 328 328 let mut c = Chunker::new(Config::default()); 329 329 let (end, _) = feed(&mut c, 0, &tone(3_000)); 330 − // Устройство молчало 10 секунд (так делает захват процесса, когда Zoom тих). 330 + // Устройство молчало 10 секунд (так делает захват звука, когда все молчат). 331 331 let (_, chunks) = feed(&mut c, end + 10_000, &tone(2_000)); 332 332 assert_eq!(chunks.len(), 1, "длинная дыра закрывает кусок"); 333 333 assert_eq!(chunks[0].start_ms, 0); -
crates/audio/src/lib.rs +5 −15
Показать разницуСкрыть разницу
24 24 Err(CaptureError::Unsupported) 25 25 } 26 26 27 −/// Звук процесса Zoom (и его дочерних процессов). 28 −pub fn open_zoom_audio(_pid: u32) -> Result<Box<dyn Capture>, CaptureError> { 27 +/// Весь звук, который выводит компьютер, кроме самого Skillok Meet: голоса 28 +/// собеседников в любом сервисе встреч. 29 +pub fn open_system_audio() -> Result<Box<dyn Capture>, CaptureError> { 29 30 #[cfg(windows)] 30 − return Ok(Box::new(win::ProcessLoopback::open(_pid)?)); 31 + return Ok(Box::new(win::SystemAudio::open()?)); 31 32 #[cfg(target_os = "macos")] 32 − return Ok(Box::new(mac::ZoomAudio::open(_pid)?)); 33 + return Ok(Box::new(mac::SystemAudio::open()?)); 33 34 #[cfg(not(any(windows, target_os = "macos")))] 34 35 Err(CaptureError::Unsupported) 35 36 } 36 37 37 −/// Процесс Zoom, звук которого писать. В Windows — Zoom с открытым потоком 38 −/// звука (идёт встреча); на Mac — запущенное приложение Zoom. Нет — `None`. 39 −pub fn zoom_process() -> Option<u32> { 40 − #[cfg(windows)] 41 − return win::active_zoom_process(); 42 − #[cfg(target_os = "macos")] 43 − return mac::zoom_process(); 44 − #[cfg(not(any(windows, target_os = "macos")))] 45 − None 46 −} 47 − 48 38 /// Запись встреч возможна на этой машине. 49 39 pub const CAPTURE_SUPPORTED: bool = cfg!(any(windows, target_os = "macos")); -
crates/audio/src/mac.rs +26 −33
Показать разницуСкрыть разницу
1 −//! macOS: звук приложения Zoom и микрофон. 1 +//! macOS: звук компьютера и микрофон. 2 2 //! 3 −//! - Звук Zoom — ScreenCaptureKit с фильтром по приложению: в запись попадает 4 −//! только то, что выводит Zoom. Нужно разрешение «Запись экрана и системного 5 −//! звука» (Системные настройки → Конфиденциальность и безопасность). 3 +//! - Звук компьютера — ScreenCaptureKit: всё, что выводят программы, кроме самого 4 +//! Skillok Meet. Так пишутся собеседники в Zoom, Google Meet, Телемосте — где 5 +//! угодно. Нужно разрешение «Запись экрана и системного звука» (Системные 6 +//! настройки → Конфиденциальность и безопасность). 6 7 //! https://developer.apple.com/documentation/screencapturekit/scstreamconfiguration/capturesaudio 7 8 //! - Микрофон — CoreAudio через `cpal`. Нужно разрешение «Микрофон». 8 9 //! 19 20 use crate::pcm::{self, Resampler, SAMPLE_RATE}; 20 21 use crate::source::{Capture, CaptureError}; 21 22 22 −/// Идентификатор приложения Zoom для macOS. 23 −const ZOOM_BUNDLE: &str = "us.zoom.xos"; 24 − 25 23 fn drain(rx: &Receiver<Vec<i16>>) -> Result<Vec<i16>, ()> { 26 24 let mut out = Vec::new(); 27 25 match rx.recv_timeout(Duration::from_millis(50)) { 35 33 Ok(out) 36 34 } 37 35 38 −// ── Zoom ──────────────────────────────────────────────────────────────────── 39 − 40 −/// Процесс Zoom, если приложение запущено. Без разрешения на запись экрана 41 −/// список приложений недоступен — тогда `None`, и запишется только микрофон. 42 −pub fn zoom_process() -> Option<u32> { 43 − let content = SCShareableContent::get().ok()?; 44 − content 45 − .applications() 46 − .iter() 47 − .find(|a| a.bundle_identifier() == ZOOM_BUNDLE) 48 − .map(|a| a.process_id() as u32) 49 −} 36 +// ── Звук компьютера ───────────────────────────────────────────────────────── 50 37 51 38 struct AudioOut { 52 39 tx: Sender<Vec<i16>>, 73 60 } 74 61 } 75 62 76 −/// Звук приложения Zoom. 77 −pub struct ZoomAudio { 63 +/// Весь звук, который выводит компьютер, кроме самого Skillok Meet. 64 +pub struct SystemAudio { 78 65 stream: SCStream, 79 66 rx: Receiver<Vec<i16>>, 80 67 } 81 68 82 −impl ZoomAudio { 83 − pub fn open(pid: u32) -> Result<Self, CaptureError> { 69 +impl SystemAudio { 70 + pub fn open() -> Result<Self, CaptureError> { 84 71 let err = |e: &dyn std::fmt::Display| { 85 72 CaptureError::Loopback(format!( 86 73 "{e}. Разрешите запись звука: Системные настройки → Конфиденциальность и безопасность → \ 88 75 )) 89 76 }; 90 77 let content = SCShareableContent::get().map_err(|e| err(&e))?; 91 − let apps = content.applications(); 92 − let zoom = apps 93 − .iter() 94 − .find(|a| a.process_id() as u32 == pid) 95 − .ok_or_else(|| err(&"Zoom закрыли"))?; 96 78 let displays = content.displays(); 97 79 let display = displays.first().ok_or_else(|| err(&"нет экрана"))?; 80 + // Фильтр по экрану без исключений: звук берётся от всех программ; 81 + // свой звук исключает настройка потока ниже. 98 82 let filter = SCContentFilter::create() 99 83 .with_display(display) 100 − .with_including_applications(&[zoom], &[]) 84 + .with_excluding_windows(&[]) 101 85 .build() 102 86 .map_err(|e| err(&e))?; 103 87 // Видео не нужно, но поток без него не бывает: самый маленький кадр раз в секунду. 119 103 } 120 104 } 121 105 122 −impl Capture for ZoomAudio { 106 +impl Capture for SystemAudio { 123 107 fn read(&mut self) -> Result<Vec<i16>, CaptureError> { 124 − drain(&self.rx).map_err(|_| CaptureError::Loopback("поток звука Zoom остановился".into())) 108 + drain(&self.rx).map_err(|_| CaptureError::Loopback("поток звука компьютера остановился".into())) 125 109 } 126 110 } 127 111 128 −impl Drop for ZoomAudio { 112 +impl Drop for SystemAudio { 129 113 fn drop(&mut self) { 130 114 let _ = self.stream.stop_capture(); 131 115 } 153 137 let device = cpal::default_host() 154 138 .default_input_device() 155 139 .ok_or("микрофон не найден")?; 156 − let config = device.default_input_config().map_err(|e| e.to_string())?; 140 + // Сначала просим 16 кГц у самой системы: CoreAudio приводит частоту 141 + // качественнее простого усреднения. Не выйдет — берём родную частоту. 142 + let native = device.supported_input_configs().ok().and_then(|mut all| { 143 + all.find(|c| c.sample_format() == cpal::SampleFormat::F32) 144 + .and_then(|c| c.try_with_sample_rate(SAMPLE_RATE)) 145 + }); 146 + let config = match native { 147 + Some(c) => c, 148 + None => device.default_input_config().map_err(|e| e.to_string())?, 149 + }; 157 150 let channels = config.channels() as usize; 158 151 let rate = config.sample_rate(); 159 152 let mut resampler = Resampler::new(channels, rate); -
Показать разницуСкрыть разницу
1 −//! Источник звука: дорожка «я» (микрофон) или «собеседники» (звук Zoom). 1 +//! Источник звука: дорожка «я» (микрофон) или «собеседники» (звук компьютера). 2 2 //! 3 3 //! Источник отдаёт то, что накопилось с прошлого вызова, в формате 4 4 //! [`crate::pcm`]. Время звуку ставит тот, кто читает: конец блока — момент 5 −//! чтения. Источник может молчать (захват процесса не шлёт ничего, пока Zoom 6 −//! тих) — это не ошибка, дыру заполнит [`crate::chunker::Chunker`]. 5 +//! чтения. Источник может молчать (захват звука не шлёт ничего, пока 6 +//! все молчат) — это не ошибка, дыру заполнит [`crate::chunker::Chunker`]. 7 7 8 8 use std::time::{Duration, Instant}; 9 9 11 11 12 12 #[derive(Debug, thiserror::Error)] 13 13 pub enum CaptureError { 14 − #[error("Звук Zoom недоступен: {0}")] 14 + #[error("Звук компьютера недоступен: {0}")] 15 15 Loopback(String), 16 16 #[error("Микрофон недоступен: {0}")] 17 17 Microphone(String), -
crates/audio/src/win.rs +17 −116
Показать разницуСкрыть разницу
1 −//! Windows: захват звука процесса Zoom, микрофона и поиск активной встречи. 1 +//! Windows: звук компьютера и микрофон. 2 2 //! 3 −//! - Звук процесса — process loopback (Windows 10 20438+): 3 +//! - Звук компьютера — process loopback в режиме исключения (Windows 10 20438+): 4 +//! всё, что выводят программы, кроме самого Skillok Meet. Так пишутся 5 +//! собеседники в Zoom, Google Meet, Телемосте — где угодно. 4 6 //! 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 7 +//! Обёртка — крейт `wasapi` (`AudioClient::new_application_loopback_client`, 8 +//! `include_tree = false` — «все, кроме этого дерева процессов»). 8 9 //! 9 10 //! COM-объекты WASAPI не переносятся между потоками, поэтому каждый захват 10 11 //! живёт в своём потоке, а наружу отдаёт сэмплы через канал. 11 12 12 −use std::collections::{HashMap, VecDeque}; 13 +use std::collections::VecDeque; 13 14 use std::sync::atomic::{AtomicBool, Ordering}; 14 15 use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; 15 16 use std::sync::Arc; 17 18 use std::time::Duration; 18 19 19 20 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, 21 + initialize_mta, AudioClient, DeviceEnumerator, Direction, Role, SampleType, StreamMode, WaveFormat, 26 22 }; 27 23 28 24 use crate::pcm::{self, SAMPLE_RATE}; 29 25 use crate::source::{Capture, CaptureError}; 30 26 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 { 103 − continue; 104 − }; 105 − let Ok(sessions) = manager.get_audiosessionenumerator() else { 106 − continue; 107 − }; 108 − let count = sessions.get_count().unwrap_or(0); 109 − for i in 0..count { 110 − let Ok(session) = sessions.get_session(i) else { 111 − continue; 112 − }; 113 − if !matches!(session.get_state(), Ok(SessionState::Active)) { 114 − continue; 115 − } 116 − let Ok(pid) = session.get_process_id() else { 117 − continue; 118 − }; 119 − if procs.get(&pid).is_some_and(|p| p.name == ZOOM_EXE) { 120 − return Some(zoom_root(pid, &procs)); 121 − } 122 − } 123 − } 124 − None 125 −} 126 − 127 27 // ── Захват ────────────────────────────────────────────────────────────────── 128 28 129 29 /// Поток захвата и канал сэмплов из него. 237 137 client.start_stream().map_err(|e| e.to_string())?; 238 138 let mut raw: VecDeque<u8> = VecDeque::new(); 239 139 while !stop.load(Ordering::Relaxed) { 240 − // Захват процесса не сигналит, пока Zoom молчит: таймаут — не ошибка. 140 + // Захват звука не сигналит, пока все молчат: таймаут — не ошибка. 241 141 let _ = event.wait_for_event(100); 242 142 loop { 243 143 let frames = capture 285 185 Ok(()) 286 186 } 287 187 288 −/// Звук процесса Zoom и всех его дочерних процессов. 289 −pub struct ProcessLoopback(Worker); 188 +/// Весь звук компьютера, кроме нашего процесса. 189 +pub struct SystemAudio(Worker); 290 190 291 −impl ProcessLoopback { 292 − pub fn open(pid: u32) -> Result<Self, CaptureError> { 191 +impl SystemAudio { 192 + pub fn open() -> Result<Self, CaptureError> { 193 + let pid = std::process::id(); 293 194 Worker::spawn(move || { 294 − AudioClient::new_application_loopback_client(pid, true).map_err(|e| e.to_string()) 195 + AudioClient::new_application_loopback_client(pid, false).map_err(|e| e.to_string()) 295 196 }) 296 197 .map(Self) 297 198 .map_err(CaptureError::Loopback) 298 199 } 299 200 } 300 201 301 −impl Capture for ProcessLoopback { 202 +impl Capture for SystemAudio { 302 203 fn read(&mut self) -> Result<Vec<i16>, CaptureError> { 303 204 self.0.read().map_err(CaptureError::Loopback) 304 205 } 305 206 } 306 207 Разница обрезана: показаны первые 173 строк. -
Показать разницуСкрыть разницу
15 15 [dependencies] 16 16 tauri = { version = "2.12", features = [] } 17 17 tauri-plugin-dialog = "2" 18 +tauri-plugin-opener = "2" 18 19 meet-audio = { path = "../crates/audio" } 19 20 base64 = "0.22" 20 21 tokio = { version = "1", features = ["rt", "sync", "time", "macros"] } -
src-tauri/src/commands.rs +94 −42
Показать разницуСкрыть разницу
4 4 5 5 use std::collections::HashMap; 6 6 use std::path::{Path, PathBuf}; 7 −use std::sync::{Arc, Mutex}; 7 +use std::sync::{Arc, Mutex, RwLock}; 8 8 9 9 use chrono::{DateTime, Local}; 10 10 use serde::Serialize; 24 24 pub struct Recording { 25 25 meeting_id: String, 26 26 started_at: DateTime<Local>, 27 − zoom: bool, 27 + system_error: Option<String>, 28 28 session: Session, 29 29 } 30 30 31 31 pub struct AppState { 32 32 pub settings_path: PathBuf, 33 − pub store: Arc<Store>, 33 + /// Хранилище в папке из настроек; меняется, когда человек выбирает другую. 34 + pub store: RwLock<Arc<Store>>, 35 + /// Папка записей по умолчанию — в данных программы. 36 + pub default_meetings_dir: PathBuf, 34 37 pub secrets: Arc<dyn Secrets>, 35 38 pub recording: Mutex<Option<Recording>>, 36 39 /// Работа, которая сейчас идёт над встречей (расшифровка или ответ ИИ), 39 42 } 40 43 41 44 impl AppState { 45 + fn store(&self) -> Arc<Store> { 46 + self.store.read().unwrap_or_else(|p| p.into_inner()).clone() 47 + } 48 + 42 49 fn settings(&self) -> Result<Settings, String> { 43 50 Settings::load(&self.settings_path).map_err(|e| e.to_string()) 44 51 } 103 110 /// Ключ с закрытой серединой; `None` — ключа нет. 104 111 openrouter_key: Option<String>, 105 112 meetings_dir: String, 106 − /// Эта машина умеет записывать звук (Windows). 113 + default_meetings_dir: String, 114 + /// Эта машина умеет записывать звук (Windows, macOS). 107 115 capture_supported: bool, 108 116 } 109 117 116 124 .get(OPENROUTER_KEY) 117 125 .map_err(|e| e.to_string())? 118 126 .map(|k| mask_key(&k)), 119 − meetings_dir: state.store.root().display().to_string(), 127 + meetings_dir: state.store().root().display().to_string(), 128 + default_meetings_dir: state.default_meetings_dir.display().to_string(), 120 129 capture_supported: meet_audio::CAPTURE_SUPPORTED, 121 130 }) 122 131 } 161 170 162 171 #[tauri::command] 163 172 pub fn list_meetings(state: State<'_, AppState>) -> Result<Vec<Meeting>, String> { 164 − state.store.list().map_err(|e| e.to_string()) 173 + state.store().list().map_err(|e| e.to_string()) 165 174 } 166 175 167 176 #[tauri::command] 168 177 pub fn get_meeting(state: State<'_, AppState>, id: String) -> Result<MeetingDetail, String> { 169 − state.store.get(&id).map_err(|e| e.to_string()) 178 + state.store().get(&id).map_err(|e| e.to_string()) 170 179 } 171 180 172 181 #[tauri::command] 173 182 pub fn save_note(state: State<'_, AppState>, id: String, text: String) -> Result<Meeting, String> { 174 − state.store.save_note(&id, &text).map_err(|e| e.to_string()) 183 + state.store().save_note(&id, &text).map_err(|e| e.to_string()) 175 184 } 176 185 177 186 /// Убрать результат — раздел заметки с этим заголовком. 178 187 #[tauri::command] 179 188 pub fn remove_section(state: State<'_, AppState>, id: String, heading: String) -> Result<String, String> { 180 189 state 181 − .store 190 + .store() 182 191 .remove_section(&id, &heading) 183 192 .map_err(|e| e.to_string()) 184 193 } 185 194 195 +/// Новая папка для записей: все записи переносятся туда, дальше пишутся туда 196 +/// же. `None` — вернуть папку по умолчанию. 197 +#[tauri::command] 198 +pub fn set_storage_dir(state: State<'_, AppState>, path: Option<String>) -> Result<String, String> { 199 + if state 200 + .recording 201 + .lock() 202 + .unwrap_or_else(|p| p.into_inner()) 203 + .is_some() 204 + { 205 + return Err("Сначала остановите запись".into()); 206 + } 207 + if !state.jobs.lock().unwrap_or_else(|p| p.into_inner()).is_empty() { 208 + return Err("Дождитесь, пока закончится расшифровка или ответ ИИ".into()); 209 + } 210 + let target = match path.as_deref().map(str::trim) { 211 + Some(p) if !p.is_empty() => PathBuf::from(p), 212 + _ => state.default_meetings_dir.clone(), 213 + }; 214 + let mut settings = state.settings()?; 215 + let mut slot = state.store.write().unwrap_or_else(|p| p.into_inner()); 216 + slot.move_all_to(&target).map_err(|e| e.to_string())?; 217 + settings.storage_dir = if target == state.default_meetings_dir { 218 + String::new() 219 + } else { 220 + target.display().to_string() 221 + }; 222 + settings.save(&state.settings_path).map_err(|e| e.to_string())?; 223 + *slot = Arc::new(Store::new(&target)); 224 + Ok(target.display().to_string()) 225 +} 226 + 227 +/// Открыть папку с записями или с одной записью в Finder / Проводнике. 228 +#[tauri::command] 229 +pub fn open_folder(state: State<'_, AppState>, id: Option<String>) -> Result<(), String> { 230 + let store = state.store(); 231 + let path = match id { 232 + Some(id) => store.folder(&id).map_err(|e| e.to_string())?, 233 + None => store.root().to_path_buf(), 234 + }; 235 + std::fs::create_dir_all(&path).map_err(|e| e.to_string())?; 236 + tauri_plugin_opener::open_path(&path, None::<&str>).map_err(|e| e.to_string()) 237 +} 238 + 186 239 /// «Отменить» — расшифровку или ответ ИИ, что сейчас идёт над встречей. 187 240 #[tauri::command] 188 241 pub fn cancel_job(state: State<'_, AppState>, id: String) { 193 246 194 247 #[tauri::command] 195 248 pub fn rename_meeting(state: State<'_, AppState>, id: String, title: String) -> Result<Meeting, String> { 196 − state.store.rename(&id, &title).map_err(|e| e.to_string()) 249 + state.store().rename(&id, &title).map_err(|e| e.to_string()) 197 250 } 198 251 199 252 #[tauri::command] 202 255 return Err("Сначала остановите запись".into()); 203 256 } 204 257 let _job = state.start_job(&id)?; 205 − state.store.delete(&id).map_err(|e| e.to_string()) 258 + state.store().delete(&id).map_err(|e| e.to_string()) 206 259 } 207 260 208 261 /// Новая встреча из аудиофайла. Файл сразу читается целиком: так сразу видно, 224 277 .unwrap_or("Аудио") 225 278 .to_string(); 226 279 let meeting = state 227 − .store 280 + .store() 228 281 .create(&title, Local::now()) 229 282 .map_err(|e| e.to_string())?; 230 283 state 231 − .store 284 + .store() 232 285 .attach_audio_file(&meeting.id, &source, duration_ms) 233 286 .map_err(|e| e.to_string()) 234 287 } 288 341 let key = state.key()?; 289 342 let job = state.start_job(&id)?; 290 343 let settings = state.settings()?; 291 − let meeting = state.store.get(&id).map_err(|e| e.to_string())?.meeting; 344 + let meeting = state.store().get(&id).map_err(|e| e.to_string())?.meeting; 292 345 if meeting.audio.is_empty() { 293 346 return Err("В этой встрече нет звука — запишите встречу или добавьте аудиофайл".into()); 294 347 } 296 349 for AudioTrack { file, speaker, .. } in &meeting.audio { 297 350 tracks.push(( 298 351 speaker.clone(), 299 − state.store.audio_path(&id, file).map_err(|e| e.to_string())?, 352 + state.store().audio_path(&id, file).map_err(|e| e.to_string())?, 300 353 )); 301 354 } 302 355 let tracks = tokio::task::spawn_blocking(move || { 338 391 .unwrap_or_else(|| "распознавание не удалось".into())); 339 392 } 340 393 let note = state 341 − .store 394 + .store() 342 395 .upsert_section( 343 396 &id, 344 397 transcript::HEADING, 346 399 ) 347 400 .map_err(|e| e.to_string())?; 348 401 state Разница обрезана: показаны первые 186 строк. -
src-tauri/src/lib.rs +18 −4
Показать разницуСкрыть разницу
14 14 mod commands; 15 15 16 16 use std::collections::HashMap; 17 −use std::sync::{Arc, Mutex}; 17 +use std::path::PathBuf; 18 +use std::sync::{Arc, Mutex, RwLock}; 18 19 19 20 use tauri::Manager; 20 21 24 25 .setup(|app| { 25 26 let config_dir = app.path().app_config_dir()?; 26 27 let data_dir = app.path().app_data_dir()?; 27 − let store = Arc::new(meetings::Store::new(data_dir.join("meetings"))); 28 + let settings_path = settings::settings_path(&config_dir); 29 + let default_meetings_dir = data_dir.join("meetings"); 30 + let chosen = settings::Settings::load(&settings_path) 31 + .map(|s| s.storage_dir) 32 + .unwrap_or_default(); 33 + let root = if chosen.trim().is_empty() { 34 + default_meetings_dir.clone() 35 + } else { 36 + PathBuf::from(chosen) 37 + }; 38 + let store = Arc::new(meetings::Store::new(root)); 28 39 // Запись, оборванная выключением компьютера, перестаёт считаться идущей. 29 40 let _ = store.recover_interrupted(); 30 41 app.manage(commands::AppState { 31 − settings_path: settings::settings_path(&config_dir), 32 − store, 42 + settings_path, 43 + store: RwLock::new(store), 44 + default_meetings_dir, 33 45 secrets: Arc::new(settings::Keyring), 34 46 recording: Mutex::new(None), 35 47 jobs: Mutex::new(HashMap::new()), 47 59 commands::save_note, 48 60 commands::remove_section, 49 61 commands::cancel_job, 62 + commands::set_storage_dir, 63 + commands::open_folder, 50 64 commands::rename_meeting, 51 65 commands::delete_meeting, 52 66 commands::add_audio, -
Показать разницуСкрыть разницу
135 135 Ok(meeting) 136 136 } 137 137 138 + /// Папка встречи: заметка и звук. 139 + pub fn folder(&self, id: &str) -> Result<PathBuf, Error> { 140 + self.dir(id) 141 + } 142 + 138 143 /// Путь для дорожки внутри встречи. 139 144 pub fn audio_path(&self, id: &str, file: &str) -> Result<PathBuf, Error> { 140 145 let safe = !file.is_empty() && !file.contains(['/', '\\']) && file != "." && file != ".."; 224 229 Ok(()) 225 230 } 226 231 232 + /// Переносит все записи в другую папку — когда человек выбрал новую папку 233 + /// хранения. Запись переносится целиком или не переносится: при переносе 234 + /// на другой диск папка сначала копируется, и только потом удаляется 235 + /// старая. Возвращает число перенесённых записей. 236 + pub fn move_all_to(&self, target: &Path) -> Result<usize, Error> { 237 + let _guard = self.lock(); 238 + std::fs::create_dir_all(target)?; 239 + if same_dir(&self.root, target) { 240 + return Ok(0); 241 + } 242 + let ids: Vec<String> = match std::fs::read_dir(&self.root) { 243 + Ok(entries) => entries 244 + .flatten() 245 + .filter(|e| e.path().join("meeting.json").is_file()) 246 + .filter_map(|e| e.file_name().to_str().map(str::to_string)) 247 + .collect(), 248 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), 249 + Err(e) => return Err(e.into()), 250 + }; 251 + if let Some(clash) = ids.iter().find(|id| target.join(id).exists()) { 252 + return Err(Error::Io(std::io::Error::new( 253 + std::io::ErrorKind::AlreadyExists, 254 + format!("в новой папке уже есть запись «{clash}» — выберите пустую папку"), 255 + ))); 256 + } 257 + for id in &ids { 258 + let from = self.root.join(id); 259 + let to = target.join(id); 260 + if std::fs::rename(&from, &to).is_err() { 261 + copy_dir(&from, &to)?; 262 + std::fs::remove_dir_all(&from)?; 263 + } 264 + } 265 + Ok(ids.len()) 266 + } 267 + 227 268 /// Запись, оборванная выключением компьютера, перестаёт считаться идущей; 228 269 /// дорожки на диске остаются — их можно расшифровать. 229 270 pub fn recover_interrupted(&self) -> Result<Vec<String>, Error> { 271 312 } 272 313 } 273 314 315 +fn same_dir(a: &Path, b: &Path) -> bool { 316 + match (a.canonicalize(), b.canonicalize()) { 317 + (Ok(a), Ok(b)) => a == b, 318 + _ => a == b, 319 + } 320 +} 321 + 322 +fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { 323 + std::fs::create_dir_all(to)?; 324 + for entry in std::fs::read_dir(from)? { 325 + let entry = entry?; 326 + let target = to.join(entry.file_name()); 327 + if entry.file_type()?.is_dir() { 328 + copy_dir(&entry.path(), &target)?; 329 + } else { 330 + std::fs::copy(entry.path(), &target)?; 331 + } 332 + } 333 + Ok(()) 334 +} 335 + 274 336 /// Имя файла без символов, которые Windows не разрешает. 275 337 fn sanitize_file_name(name: &str) -> String { 276 338 let clean: String = name 392 454 } 393 455 394 456 #[test] 457 + fn meetings_move_to_a_new_folder() { 458 + let dir = tempfile::tempdir().unwrap(); 459 + let old = Store::new(dir.path().join("old")); 460 + let a = old.create("Раз", at(10, 0)).unwrap(); 461 + let b = old.create("Два", at(11, 0)).unwrap(); 462 + std::fs::write(old.audio_path(&a.id, "me.wav").unwrap(), b"sound").unwrap(); 463 + old.upsert_section(&b.id, "Задачи", "## Задачи\n\n- [ ] x") 464 + .unwrap(); 465 + 466 + let target = dir.path().join("Записи встреч"); 467 + assert_eq!(old.move_all_to(&target).unwrap(), 2); 468 + let new = Store::new(&target); 469 + assert_eq!(new.list().unwrap().len(), 2); 470 + assert_eq!( 471 + std::fs::read(new.audio_path(&a.id, "me.wav").unwrap()).unwrap(), 472 + b"sound" 473 + ); 474 + assert!(new.get(&b.id).unwrap().note.contains("- [ ] x")); 475 + assert!( 476 + old.list().unwrap().is_empty(), 477 + "в старой папке ничего не осталось" 478 + ); 479 + assert_eq!( 480 + new.move_all_to(&target).unwrap(), 481 + 0, 482 + "та же папка — ничего не делаем" 483 + ); 484 + } 485 + 486 + #[test] 487 + fn move_refuses_to_overwrite() { 488 + let dir = tempfile::tempdir().unwrap(); 489 + let old = Store::new(dir.path().join("old")); 490 + let a = old.create("Раз", at(10, 0)).unwrap(); 491 + let target = dir.path().join("new"); 492 + std::fs::create_dir_all(target.join(&a.id)).unwrap(); 493 + assert!(old.move_all_to(&target).is_err()); 494 + assert_eq!(old.list().unwrap().len(), 1, "ничего не тронуто"); 495 + } 496 + 497 + #[test] 395 498 fn interrupted_recording_is_released_on_start() { 396 499 let dir = tempfile::tempdir().unwrap(); 397 500 let store = Store::new(dir.path()); -
Показать разницуСкрыть разницу
1 1 //! Запись по кнопке: каждая дорожка («я» — микрофон, «собеседники» — звук 2 −//! Zoom) пишется своим потоком в свой WAV. Больше ничего — расшифровка 2 +//! компьютера) пишется своим потоком в свой WAV. Больше ничего — расшифровка 3 3 //! запускается потом, когда человек попросит. 4 4 //! 5 5 //! Заголовок WAV дописывается каждые несколько секунд, поэтому после сбоя -
Показать разницуСкрыть разницу
27 27 pub language: String, 28 28 /// Текст, которым человек предупреждает участников перед записью. 29 29 pub warning_text: String, 30 + /// Папка, где лежат записи: звук, расшифровки, результаты. Пусто — папка 31 + /// данных программы. 32 + pub storage_dir: String, 30 33 } 31 34 32 35 impl Default for Settings { 33 36 fn default() -> Self { 34 37 Self { 35 38 ai_model: "anthropic/claude-opus-5.5".into(), 36 − stt_model: "openai/whisper-large-v3-turbo".into(), 39 + // Полная модель, а не turbo: точнее на русском и на плохом звуке. 40 + stt_model: "openai/whisper-large-v3".into(), 37 41 language: "ru".into(), 38 42 warning_text: DEFAULT_WARNING.into(), 43 + storage_dir: String::new(), 39 44 } 40 45 } 41 46 } -
Показать разницуСкрыть разницу
1 1 //! Реплики расшифровки и то, как они ложатся в заметку. 2 2 //! 3 −//! Говорящий — только дорожка звука: «Я» (микрофон), «Собеседники» (звук Zoom) 3 +//! Говорящий — только дорожка звука: «Я» (микрофон), «Собеседники» (звук компьютера) 4 4 //! или никто, если звук пришёл одним файлом. Узнавания людей по голосу нет и не 5 5 //! будет: это биометрия (см. README, «Согласие на запись»). 6 6 -
src/api.ts +19 −1
Показать разницуСкрыть разницу
22 22 stt_model: string; 23 23 language: string; 24 24 warning_text: string; 25 + storage_dir: string; 25 26 } 26 27 27 28 export interface SettingsView { 28 29 settings: Settings; 29 30 openrouter_key: string | null; 30 31 meetings_dir: string; 32 + default_meetings_dir: string; 31 33 capture_supported: boolean; 32 34 } 33 35 39 41 | { kind: "review" } 40 42 | { kind: "custom"; text: string }; 41 43 42 −export interface RecordingView { meeting_id: string; started_at: string; paused: boolean; zoom: boolean } 44 +export interface RecordingView { 45 + meeting_id: string; 46 + started_at: string; 47 + paused: boolean; 48 + /** Пишется звук компьютера (собеседники). */ 49 + system: boolean; 50 + /** Почему звук компьютера не пишется. */ 51 + system_error: string | null; 52 +} 43 53 44 54 export interface TranscribeResult { note: string; failed: number; last_error: string | null } 45 55 56 66 invoke: Invoke; 57 67 listen: Listen; 58 68 pickAudio: () => Promise<string | null>; 69 + pickFolder: () => Promise<string | null>; 59 70 onFileDrop: (cb: (paths: string[]) => void) => Promise<() => void>; 60 71 } 61 72 77 88 const picked = await open({ multiple: false, filters: [{ name: "Аудио", extensions: AUDIO_EXTENSIONS }] }); 78 89 return typeof picked === "string" ? picked : null; 79 90 }, 91 + pickFolder: async () => { 92 + const picked = await open({ directory: true, multiple: false }); 93 + return typeof picked === "string" ? picked : null; 94 + }, 80 95 onFileDrop: (cb) => 81 96 getCurrentWebview().onDragDropEvent((e) => { 82 97 if (e.payload.type === "drop") cb(e.payload.paths); 108 123 109 124 export const files = { 110 125 pickAudio: async () => (await getCore()).pickAudio(), 126 + pickFolder: async () => (await getCore()).pickFolder(), 111 127 onDrop: async (cb: (paths: string[]) => void) => (await getCore()).onFileDrop(cb), 112 128 isAudio: (path: string) => AUDIO_EXTENSIONS.includes(path.split(".").pop()?.toLowerCase() ?? ""), 113 129 }; 123 139 saveNote: (id: string, text: string) => call<Meeting>("save_note", { id, text }), 124 140 removeSection: (id: string, heading: string) => call<string>("remove_section", { id, heading }), 125 141 cancelJob: (id: string) => call<void>("cancel_job", { id }), 142 + setStorageDir: (path: string | null) => call<string>("set_storage_dir", { path }), 143 + openFolder: (id: string | null) => call<void>("open_folder", { id }), 126 144 renameMeeting: (id: string, title: string) => call<Meeting>("rename_meeting", { id, title }), 127 145 deleteMeeting: (id: string) => call<void>("delete_meeting", { id }), 128 146 addAudio: (path: string) => call<Meeting>("add_audio", { path }), -
src/dev-mock.ts +19 −4
Показать разницуСкрыть разницу
72 72 73 73 let settings: Settings = { 74 74 ai_model: "anthropic/claude-opus-5.5", 75 − stt_model: "openai/whisper-large-v3-turbo", 75 + stt_model: "openai/whisper-large-v3", 76 76 language: "ru", 77 + storage_dir: "", 77 78 warning_text: 78 79 "Коллеги, я записываю встречу, чтобы сделать расшифровку и список задач. Запись остаётся у меня и никуда не публикуется. Если кто-то против — скажите, я выключу.", 79 80 }; 103 104 unsupported: () => (captureSupported = false), 104 105 }; 105 106 107 +export async function pickFolder(): Promise<string | null> { 108 + return "/Users/demo/Documents/Записи встреч"; 109 +} 110 + 106 111 export async function pickAudio(): Promise<string | null> { 107 112 return "/Users/demo/Загрузки/Интервью с кандидатом.m4a"; 108 113 } 115 120 await wait(60); 116 121 switch (cmd) { 117 122 case "get_settings": 118 − return { settings, openrouter_key: key, capture_supported: captureSupported, meetings_dir: "(просмотр без ядра)" } satisfies SettingsView as T; 123 + return { 124 + settings, openrouter_key: key, capture_supported: captureSupported, 125 + meetings_dir: settings.storage_dir || "~/Library/Application Support/org.skillok.meet/meetings", 126 + default_meetings_dir: "~/Library/Application Support/org.skillok.meet/meetings", 127 + } satisfies SettingsView as T; 119 128 case "save_settings": 120 129 settings = args.settings as Settings; 121 130 return undefined as T; 150 159 d.note = rest.length ? rest.map((x) => x.source.trim()).join("\n\n") + "\n" : ""; 151 160 return d.note as T; 152 161 } 162 + case "set_storage_dir": 163 + if (recording) throw "Сначала остановите запись"; 164 + settings.storage_dir = (args.path as string | null) ?? ""; 165 + return (settings.storage_dir || "~/Library/Application Support/org.skillok.meet/meetings") as T; 166 + case "open_folder": 167 + return undefined as T; 153 168 case "cancel_job": 154 169 cancelled = true; 155 170 return undefined as T; 200 215 if (!captureSupported) throw "На этой системе запись не поддерживается — можно добавить готовый аудиофайл."; 201 216 if (recording) throw "Запись уже идёт"; 202 217 const d = new Date(); 203 − const m = add(`Встреча в Zoom, ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, 0, "", [], false); 218 + const m = add(`Встреча ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, 0, "", [], false); 204 219 m.meeting.recording = true; 205 − const r = { meeting_id: m.meeting.id, started_at: d.toISOString(), paused: false, zoom: true, timer: 0 }; 220 + const r = { meeting_id: m.meeting.id, started_at: d.toISOString(), paused: false, system: true, system_error: null, timer: 0 }; 206 221 r.timer = window.setInterval(() => { 207 222 for (const speaker of [{ kind: "me" }, { kind: "others" }] as Speaker[]) { 208 223 const dbfs = r.paused ? -90 : -60 + Math.random() * 45; -
src/recording.ts +4 −3
Показать разницуСкрыть разницу
15 15 return ` 16 16 <div class="rec" data-rec> 17 17 <p class="rec-state"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${ 18 − r.paused ? "Запись на паузе" : r.zoom ? "Идёт запись: Zoom и микрофон" : "Идёт запись: микрофон (Zoom не найден)" 18 + r.paused ? "Запись на паузе" : r.system ? "Идёт запись: собеседники и вы" : "Идёт запись: только ваш микрофон" 19 19 }</p> 20 + ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся: ${esc(r.system_error)}</p>` : ""} 20 21 <p class="rec-timer mono-num" data-timer>${elapsed(r.started_at)}</p> 21 − <div class="meters">${meter("Вы", "me")}${r.zoom ? meter("Собеседники", "others") : ""}</div> 22 + <div class="meters">${meter("Вы", "me")}${r.system ? meter("Собеседники", "others") : ""}</div> 22 23 <div class="actions"> 23 24 <button class="ui-btn" data-rec-stop>Остановить</button> 24 25 <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button> 53 54 toast(err); 54 55 } 55 56 }); 56 − root.querySelector("[data-rec-warning]")?.addEventListener("click", () => copy(warning, "Предупреждение скопировано — вставьте его в чат Zoom")); 57 + root.querySelector("[data-rec-warning]")?.addEventListener("click", () => copy(warning, "Предупреждение скопировано — вставьте его в чат встречи")); 57 58 scope.on( 58 59 events.recorder(({ event }) => { 59 60 if (event.kind === "level") { -
src/styles/app.css +3 −1
Показать разницуСкрыть разницу
168 168 } 169 169 .section-editor:focus { outline: 2px solid var(--color-accent); outline-offset: -1px; } 170 170 171 −.meeting-foot { padding-top: 8px; } 171 +.meeting-foot { padding-top: 8px; display: flex; gap: 20px; flex-wrap: wrap; } 172 +.path { display: block; padding: 8px 10px; background: var(--color-bg-secondary); word-break: break-all; } 173 +.rec-warn { color: var(--color-error); max-width: 60ch; } 172 174 173 175 /* Markdown результата */ 174 176 .md { display: grid; gap: 12px; max-width: 72ch; } -
src/views/home.ts +2 −2
Показать разницуСкрыть разницу
24 24 return ` 25 25 <section class="hero"> 26 26 <h1>Запишите встречу</h1> 27 − <p class="hero-text">Запустите встречу в Zoom и нажмите «Записать» — запишутся собеседники и вы. Потом расшифруете и получите конспект и задачи.</p> 27 + <p class="hero-text">Начните встречу — в Zoom, Google Meet, Телемосте, где угодно — и нажмите «Записать». Запишутся собеседники и вы. Потом расшифруете и получите конспект и задачи.</p> 28 28 <button class="ui-btn hero-btn" data-record>Записать</button> 29 − <p class="small muted">Уже есть запись? Перетащите аудиофайл в окно или ${pick}.</p> 29 + <p class="small muted">Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.<br>Уже есть запись? Перетащите аудиофайл в окно или ${pick}.</p> 30 30 </section>`; 31 31 } 32 32 -
Показать разницуСкрыть разницу
253 253 ${stepper(step)} 254 254 ${step === "audio" ? audioStep() : step === "transcribe" ? transcribeStep() : resultStep()} 255 255 <footer class="meeting-foot"> 256 + <button class="link-btn" data-open-folder>Открыть папку с файлами</button> 256 257 <button class="link-btn link-danger" data-delete ${d.meeting.recording ? "hidden" : ""}>Удалить запись</button> 257 258 </footer> 258 259 </article>`; 417 418 }); 418 419 }); 419 420 421 + root.querySelector("[data-open-folder]")?.addEventListener("click", () => api.openFolder(id).catch(toast)); 422 + 420 423 root.querySelector("[data-delete]")?.addEventListener("click", async () => { 421 424 if (!confirm(`Удалить «${d.meeting.title}» вместе со звуком и результатами? Это не отменить.`)) return; 422 425 try { -
src/views/settings.ts +61 −13
Показать разницуСкрыть разницу
1 1 // Экран «Настройки»: ключ OpenRouter, модели, язык, предупреждение участникам. 2 2 3 −import { api, errorText, type KeyInfo, type SettingsView } from "../api"; 3 +import { api, errorText, files, type KeyInfo, type SettingsView } from "../api"; 4 + 5 +const STT_MODELS: [string, string][] = [ 6 + ["openai/whisper-large-v3", "Точнее — полная модель Whisper"], 7 + ["openai/whisper-large-v3-turbo", "Быстрее и дешевле — Whisper turbo"], 8 +]; 9 + 10 +function sttOptions(current: string): string { 11 + const list = STT_MODELS.some(([id]) => id === current) ? STT_MODELS : [...STT_MODELS, [current, `Другая: ${current}`] as [string, string]]; 12 + return list.map(([id, label]) => `<option value="${esc(id)}"${id === current ? " selected" : ""}>${esc(label)}</option>`).join(""); 13 +} 4 14 import { $, busy, esc } from "../dom"; 5 15 6 16 function keyInfoText(info: KeyInfo): string { 59 69 <div class="field"> 60 70 <label for="warn">Текст предупреждения участникам</label> 61 71 <textarea id="warn" class="textarea" name="warning_text" rows="3" required>${esc(s.warning_text)}</textarea> 62 − <p class="field-hint">Во время записи его можно скопировать одной кнопкой и отправить в чат Zoom. Записывать разговор без ведома участников нельзя.</p> 72 + <p class="field-hint">Во время записи его можно скопировать одной кнопкой и отправить в чат встречи. Записывать разговор без ведома участников нельзя.</p> 73 + </div> 74 + <div class="field"> 75 + <label for="m-stt">Точность распознавания</label> 76 + <select id="m-stt" class="input" name="stt_model"> 77 + ${sttOptions(s.stt_model)} 78 + </select> 79 + <p class="field-hint">Звук пишется без сжатия, 16 кГц — ровно столько, сколько использует распознавание; больше частота точность не поднимает. Точность поднимают полная модель, наушники и заданный язык.</p> 63 80 </div> 64 81 <details class="advanced"> 65 − <summary class="small">Модели</summary> 82 + <summary class="small">Модель ИИ для результатов</summary> 66 83 <div class="field"> 67 − <label for="m-ai">Модель для команд ИИ</label> 84 + <label for="m-ai">Модель OpenRouter для конспекта, задач и своих запросов</label> 68 85 <input id="m-ai" class="input" name="ai_model" value="${esc(s.ai_model)}" required spellcheck="false"> 69 86 </div> 70 − <div class="field"> 71 − <label for="m-stt">Модель распознавания речи</label> 72 − <input id="m-stt" class="input" name="stt_model" value="${esc(s.stt_model)}" required spellcheck="false"> 73 − <p class="field-hint">Имена — как в каталоге OpenRouter.</p> 74 − </div> 75 87 </details> 76 − <div class="field"> 77 − <span class="field-label">Папка встреч</span> 78 − <code>${esc(view.meetings_dir)}</code> 79 − </div> 80 88 <p data-settings-status hidden></p> 81 89 <div class="actions"><button class="ui-btn" type="submit">Сохранить</button></div> 82 90 </div> 83 91 </form> 92 + 93 + <section class="box"> 94 + <div class="box-head"><h2>Где хранить записи</h2></div> 95 + <div class="box-body"> 96 + <p class="small">Здесь лежат звук (WAV), расшифровки и результаты — по папке на запись.</p> 97 + <code class="path" data-storage>${esc(view.meetings_dir)}</code> 98 + <div class="actions"> 99 + <button class="link-btn" data-pick-folder>Выбрать другую папку…</button> 100 + <button class="link-btn" data-open-folder>Открыть папку</button> 101 + ${ 102 + view.meetings_dir !== view.default_meetings_dir 103 + ? `<button class="link-btn" data-default-folder>Вернуть папку по умолчанию</button>` 104 + : "" 105 + } 106 + </div> 107 + <p class="small muted">При смене папки все записи переносятся в новую.</p> 108 + <p data-storage-status hidden></p> 109 + </div> 110 + </section> 84 111 </div>`; 85 112 86 113 const keyStatus = $(root, "[data-key-status]"); 120 147 } 121 148 }); 122 149 150 + const storageStatus = $(root, "[data-storage-status]"); 151 + const moveTo = async (path: string | null) => { 152 + try { 153 + storageStatus.hidden = true; 154 + await api.setStorageDir(path); 155 + await renderSettings(root); 156 + show($(root, "[data-storage-status]"), "Папка изменена, записи перенесены", true); 157 + } catch (e) { 158 + show(storageStatus, errorText(e), false); 159 + } 160 + }; 161 + root.querySelector("[data-pick-folder]")?.addEventListener("click", async () => { 162 + const path = await files.pickFolder(); 163 + if (path) await moveTo(path); 164 + }); 165 + root.querySelector("[data-default-folder]")?.addEventListener("click", () => moveTo(null)); 166 + root.querySelector("[data-open-folder]")?.addEventListener("click", () => 167 + api.openFolder(null).catch((e) => show(storageStatus, errorText(e), false)), 168 + ); 169 + 123 170 const form = $(root, "[data-settings]") as HTMLFormElement; 124 171 form.addEventListener("submit", async (event) => { 125 172 event.preventDefault(); 132 179 stt_model: String(data.get("stt_model")).trim(), 133 180 language: String(data.get("language") ?? ""), 134 181 warning_text: String(data.get("warning_text")).trim(), 182 + storage_dir: s.storage_dir, 135 183 }), 136 184 ); 137 185 show(status, "Сохранено", true);