skillok meeting

Запись всего звука, папка хранения, точнее распознавание

- Звук собеседников — весь звук компьютера, кроме самого 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>

anc · 30 сентября 2026 г. 96a28a9c42ff4eab2c832f9e7b418f3fdf45bc26
22 файла
  • 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);
  • Показать разницуСкрыть разницу
    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"));
  • Показать разницуСкрыть разницу
    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(&current)
    81 − .and_then(|p| procs.get(&p.parent).map(|parent| (p.parent, parent)))
    82 − {
    83 − Some((parent_pid, parent)) if parent.name == ZOOM_EXE => current = parent_pid,
    84 − _ => break,
    85 − }
    86 − }
    87 − current
    88 −}
    89 −
    90 −/// Процесс Zoom, у которого сейчас активная сессия вывода звука на любом
    91 −/// устройстве воспроизведения.
    92 −pub fn active_zoom_process() -> Option<u32> {
    93 − let _ = initialize_mta();
    94 − let procs = processes();
    95 − if !procs.values().any(|p| p.name == ZOOM_EXE) {
    96 − return None;
    97 − }
    98 − let enumerator = DeviceEnumerator::new().ok()?;
    99 − let devices = enumerator.get_device_collection(&Direction::Render).ok()?;
    100 − for device in &devices {
    101 − let Ok(device) = device else { continue };
    102 − let Ok(manager) = device.get_iaudiosessionmanager() else {
    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"] }
  • Показать разницуСкрыть разницу
    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);