macOS: запись звука Zoom и микрофона
- Звук Zoom — ScreenCaptureKit с фильтром по приложению us.zoom.xos, сразу 16 кГц моно; микрофон — CoreAudio через cpal. - Info.plist с объяснениями для запросов доступа (микрофон, системный звук), minimumSystemVersion 13.0, сборка .app. - Swift-мост ScreenCaptureKit при одних Command Line Tools: путь к библиотекам совместимости Swift и rpath /usr/lib/swift в build.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Родитель:
e75bd24
· сравнить с родителем
13 файлов
-
Cargo.lock +235 −5
Показать разницуСкрыть разницу
33 33 ] 34 34 35 35 [[package]] 36 +name = "alsa" 37 +version = "0.11.0" 38 +source = "registry+https://github.com/rust-lang/crates.io-index" 39 +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" 40 +dependencies = [ 41 + "alsa-sys", 42 + "bitflags 2.13.2", 43 + "cfg-if", 44 + "libc", 45 +] 46 + 47 +[[package]] 48 +name = "alsa-sys" 49 +version = "0.4.0" 50 +source = "registry+https://github.com/rust-lang/crates.io-index" 51 +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" 52 +dependencies = [ 53 + "libc", 54 + "pkg-config", 55 +] 56 + 57 +[[package]] 36 58 name = "android_system_properties" 37 59 version = "0.1.6" 38 60 source = "registry+https://github.com/rust-lang/crates.io-index" 48 70 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 49 71 50 72 [[package]] 73 +name = "apple-cf" 74 +version = "0.11.0" 75 +source = "registry+https://github.com/rust-lang/crates.io-index" 76 +checksum = "acc8e8f378f5bbd99f5850a95e55cbfd20e14db63d1eee16a060528cf960876e" 77 +dependencies = [ 78 + "doom-fish-utils", 79 +] 80 + 81 +[[package]] 82 +name = "apple-metal" 83 +version = "0.10.0" 84 +source = "registry+https://github.com/rust-lang/crates.io-index" 85 +checksum = "00a23f6df783ca6a2def6201afc6bd54c1d41454e35552309166432155b544fc" 86 +dependencies = [ 87 + "doom-fish-utils", 88 + "libc", 89 +] 90 + 91 +[[package]] 51 92 name = "arrayvec" 52 93 version = "0.7.8" 53 94 source = "registry+https://github.com/rust-lang/crates.io-index" 411 452 checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" 412 453 413 454 [[package]] 455 +name = "coreaudio-rs" 456 +version = "0.14.2" 457 +source = "registry+https://github.com/rust-lang/crates.io-index" 458 +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" 459 +dependencies = [ 460 + "bitflags 2.13.2", 461 + "libc", 462 + "objc2-audio-toolbox", 463 + "objc2-core-audio", 464 + "objc2-core-audio-types", 465 + "objc2-core-foundation", 466 +] 467 + 468 +[[package]] 469 +name = "cpal" 470 +version = "0.18.2" 471 +source = "registry+https://github.com/rust-lang/crates.io-index" 472 +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" 473 +dependencies = [ 474 + "alsa", 475 + "block2", 476 + "coreaudio-rs", 477 + "dasp_sample", 478 + "jni 0.22.4", 479 + "js-sys", 480 + "libc", 481 + "mach2", 482 + "ndk", 483 + "ndk-context", 484 + "num-derive", 485 + "num-traits", 486 + "objc2", 487 + "objc2-audio-toolbox", 488 + "objc2-avf-audio", 489 + "objc2-core-audio", 490 + "objc2-core-audio-types", 491 + "objc2-core-foundation", 492 + "objc2-foundation", 493 + "web-sys", 494 + "windows", 495 + "windows-core", 496 +] 497 + 498 +[[package]] 414 499 name = "cpufeatures" 415 500 version = "0.2.17" 416 501 source = "registry+https://github.com/rust-lang/crates.io-index" 438 523 ] 439 524 440 525 [[package]] 526 +name = "crossbeam-queue" 527 +version = "0.3.14" 528 +source = "registry+https://github.com/rust-lang/crates.io-index" 529 +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" 530 +dependencies = [ 531 + "crossbeam-utils", 532 +] 533 + 534 +[[package]] 441 535 name = "crossbeam-utils" 442 536 version = "0.8.23" 443 537 source = "registry+https://github.com/rust-lang/crates.io-index" 517 611 ] 518 612 519 613 [[package]] 614 +name = "dasp_sample" 615 +version = "0.11.0" 616 +source = "registry+https://github.com/rust-lang/crates.io-index" 617 +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" 618 + 619 +[[package]] 520 620 name = "dbus" 521 621 version = "0.9.12" 522 622 source = "registry+https://github.com/rust-lang/crates.io-index" 681 781 ] 682 782 683 783 [[package]] 784 +name = "doom-fish-utils" 785 +version = "0.4.1" 786 +source = "registry+https://github.com/rust-lang/crates.io-index" 787 +checksum = "32707dcbfc8b3fd80d134a6d9e63f4f73ab6eece78192f0f213a192f08900c79" 788 +dependencies = [ 789 + "crossbeam-queue", 790 + "futures-util", 791 +] 792 + 793 +[[package]] 684 794 name = "dpi" 685 795 version = "0.1.2" 686 796 source = "registry+https://github.com/rust-lang/crates.io-index" 1660 1770 ] 1661 1771 1662 1772 [[package]] 1773 +name = "jni" 1774 +version = "0.22.4" 1775 +source = "registry+https://github.com/rust-lang/crates.io-index" 1776 +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" 1777 +dependencies = [ 1778 + "cfg-if", 1779 + "combine", 1780 + "jni-macros", 1781 + "jni-sys 0.4.1", 1782 + "log", 1783 + "simd_cesu8", 1784 + "thiserror 2.0.21", 1785 + "walkdir", 1786 + "windows-link", 1787 +] 1788 + 1789 +[[package]] 1790 +name = "jni-macros" 1791 +version = "0.22.4" 1792 +source = "registry+https://github.com/rust-lang/crates.io-index" 1793 +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" 1794 +dependencies = [ 1795 + "proc-macro2", 1796 + "quote", 1797 + "rustc_version", 1798 + "simd_cesu8", 1799 + "syn 2.0.119", 1800 +] 1801 + 1802 +[[package]] 1663 1803 name = "jni-sys" 1664 1804 version = "0.3.1" 1665 1805 source = "registry+https://github.com/rust-lang/crates.io-index" 1836 1976 checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" 1837 1977 1838 1978 [[package]] 1979 +name = "mach2" 1980 +version = "0.6.0" 1981 +source = "registry+https://github.com/rust-lang/crates.io-index" 1982 +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" 1983 + 1984 +[[package]] 1839 1985 name = "markup5ever" 1840 1986 version = "0.39.0" 1841 1987 source = "registry+https://github.com/rust-lang/crates.io-index" 1850 1996 name = "meet-audio" 1851 1997 version = "0.1.0" 1852 1998 dependencies = [ 1999 + "cpal", 2000 + "screencapturekit", 1853 2001 "symphonia", 1854 2002 "thiserror 2.0.21", 1855 2003 "wasapi", 1995 2143 checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" 1996 2144 1997 2145 [[package]] 2146 +name = "num-derive" 2147 +version = "0.4.2" 2148 +source = "registry+https://github.com/rust-lang/crates.io-index" 2149 +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" 2150 +dependencies = [ 2151 + "proc-macro2", 2152 + "quote", 2153 + "syn 2.0.119", 2154 +] 2155 + 2156 +[[package]] 1998 2157 name = "num-integer" 1999 2158 version = "0.1.47" 2000 2159 source = "registry+https://github.com/rust-lang/crates.io-index" 2059 2218 ] 2060 2219 2061 2220 [[package]] 2221 +name = "objc2-audio-toolbox" 2222 +version = "0.3.2" 2223 +source = "registry+https://github.com/rust-lang/crates.io-index" 2224 +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" 2225 +dependencies = [ 2226 + "bitflags 2.13.2", 2227 + "libc", 2228 + "objc2", Разница обрезана: показаны первые 230 строк. -
README.md +24 −4
Показать разницуСкрыть разницу
2 2 3 3 Рабочее название. Продукт Skillok. 4 4 5 −Программа для Windows. Один путь: **записать → расшифровать → получить результат.** 5 +Программа для Windows и macOS. Один путь: **записать → расшифровать → получить результат.** 6 6 Звук встречи записывается по кнопке или добавляется готовым файлом, по кнопке 7 7 расшифровывается, а дальше ИИ делает конспект, задачи, ревью или то, что попросите. 8 8 Всё можно скопировать и поправить. 66 66 - API Zoom, аккаунт разработчика и одобрение в Marketplace **не нужны**. Работает на 67 67 любой встрече и на любом плане Zoom. 68 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-мостом. 80 + 69 81 ### Расшифровка — Whisper через OpenRouter 70 82 71 83 - `POST https://openrouter.ai/api/v1/audio/transcriptions`, тот же ключ, что для текстовых 146 158 cargo test --workspace # тесты ядра и звука 147 159 ``` 148 160 149 −Запись работает только в Windows. Добавление аудио, расшифровка и команды ИИ работают 150 −везде — на Mac их можно проверять целиком (нужен ключ OpenRouter: распознавание и ИИ 151 −платные). 161 +Запись работает в Windows и macOS; добавление аудио, расшифровка и команды ИИ — везде 162 +(нужен ключ OpenRouter: распознавание и ИИ платные). 163 + 164 +Собрать приложение для Mac — разрешения macOS выдаются самому приложению, а не терминалу: 165 + 166 +``` 167 +npx tauri build --bundles app 168 +open "src-tauri/target/release/bundle/macos/Skillok Meet.app" 169 +``` 152 170 153 171 Проба декодера и нарезки на любом файле: 154 172 174 192 chunker.rs нарезка на куски по паузам, отсев тишины 175 193 source.rs источник звука; Replay — звук из памяти для тестов 176 194 win.rs Windows: захват процесса Zoom и микрофона, поиск Zoom 195 + mac.rs macOS: звук Zoom (ScreenCaptureKit) и микрофон (cpal) 177 196 src-tauri/src/ ядро на Rust 178 197 meetings.rs встречи на диске: meeting.json, note.md, audio/ 179 198 notes.rs разделы заметки: поставить на место, убрать 213 232 ### Дальше 214 233 215 234 - [ ] **Проверить на Windows с настоящей встречей в Zoom** — запись ни разу не запускалась. 235 +- [ ] Проверить запись на Mac с настоящей встречей в Zoom. 216 236 - [ ] Прогнать команды ИИ на проверочных расшифровках настоящей моделью, поправить промпты. 217 237 - [ ] Прослушивание звука в заметке и переход по времени реплики к месту в записи. 218 238 - [ ] Добавить аудио к уже существующей встрече. -
Показать разницуСкрыть разницу
9 9 thiserror = "2" 10 10 symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "isomp4", "alac", "flac", "ogg", "vorbis", "wav", "pcm", "mkv", "adpcm", "aiff", "caf"] } 11 11 12 +[target.'cfg(target_os = "macos")'.dependencies] 13 +screencapturekit = "11" 14 +cpal = "0.18" 15 + 12 16 [target.'cfg(windows)'.dependencies] 13 17 wasapi = "0.24" 14 18 windows = { version = "0.62", features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp"] } -
Показать разницуСкрыть разницу
1 +fn main() { 2 + swift_runtime_search_path(); 3 + // Swift-мост грузит системные библиотеки Swift (libswift_Concurrency и др.) 4 + // через @rpath — в macOS они лежат в /usr/lib/swift. Тестам и примерам 5 + // этого крейта; приложение добавляет тот же путь в своём build.rs. 6 + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { 7 + println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift"); 8 + } 9 +} 10 + 11 +/// ScreenCaptureKit подключается через Swift-мост, которому нужны 12 +/// библиотеки совместимости Swift (`libswiftCompatibility56.a` и др.). 13 +/// Крейт ищет их только в тулчейне Xcode; при одних Command Line Tools они 14 +/// лежат в `<xcode-select -p>/usr/lib/swift/macosx` — добавляем этот путь. 15 +fn swift_runtime_search_path() { 16 + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { 17 + return; 18 + } 19 + let Ok(out) = std::process::Command::new("xcode-select").arg("-p").output() else { 20 + return; 21 + }; 22 + let developer = String::from_utf8_lossy(&out.stdout).trim().to_string(); 23 + for dir in [ 24 + format!("{developer}/usr/lib/swift/macosx"), 25 + format!("{developer}/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx"), 26 + ] { 27 + if std::path::Path::new(&dir).is_dir() { 28 + println!("cargo:rustc-link-search=native={dir}"); 29 + } 30 + } 31 +} -
crates/audio/src/lib.rs +29 −30
Показать разницуСкрыть разницу
1 1 //! Звук встречи: захват, чтение файлов, формат и нарезка на куски для 2 −//! распознавания. Всё, что не зависит от Windows, проверяется тестами на любой 3 −//! машине; сам захват — в модуле [`win`] и собирается только под Windows. 2 +//! распознавания. Всё, что не зависит от системы, проверяется тестами на любой 3 +//! машине; сам захват — в модулях [`win`] (Windows) и [`mac`] (macOS). 4 4 5 5 pub mod chunker; 6 6 pub mod decode; 7 7 pub mod pcm; 8 8 pub mod source; 9 9 10 +#[cfg(target_os = "macos")] 11 +pub mod mac; 10 12 #[cfg(windows)] 11 13 pub mod win; 12 14 13 −/// Микрофон и звук процесса Zoom. Вне Windows — ошибка «только в Windows». 14 −pub fn open_microphone() -> Result<Box<dyn source::Capture>, source::CaptureError> { 15 +use source::{Capture, CaptureError}; 16 + 17 +/// Микрофон по умолчанию. 18 +pub fn open_microphone() -> Result<Box<dyn Capture>, CaptureError> { 15 19 #[cfg(windows)] 16 − { 17 − Ok(Box::new(win::Microphone::open()?)) 18 − } 19 − #[cfg(not(windows))] 20 − { 21 − Err(source::CaptureError::Unsupported) 22 − } 20 + return Ok(Box::new(win::Microphone::open()?)); 21 + #[cfg(target_os = "macos")] 22 + return Ok(Box::new(mac::Microphone::open()?)); 23 + #[cfg(not(any(windows, target_os = "macos")))] 24 + Err(CaptureError::Unsupported) 23 25 } 24 26 25 −pub fn open_process_loopback(_pid: u32) -> Result<Box<dyn source::Capture>, source::CaptureError> { 27 +/// Звук процесса Zoom (и его дочерних процессов). 28 +pub fn open_zoom_audio(_pid: u32) -> Result<Box<dyn Capture>, CaptureError> { 26 29 #[cfg(windows)] 27 − { 28 − Ok(Box::new(win::ProcessLoopback::open(_pid)?)) 29 − } 30 − #[cfg(not(windows))] 31 − { 32 − Err(source::CaptureError::Unsupported) 33 − } 30 + return Ok(Box::new(win::ProcessLoopback::open(_pid)?)); 31 + #[cfg(target_os = "macos")] 32 + return Ok(Box::new(mac::ZoomAudio::open(_pid)?)); 33 + #[cfg(not(any(windows, target_os = "macos")))] 34 + Err(CaptureError::Unsupported) 34 35 } 35 36 36 −/// Процесс Zoom, который сейчас выводит звук (идёт встреча), если он есть. 37 −/// Вне Windows — `None`. 38 −pub fn active_zoom_process() -> Option<u32> { 37 +/// Процесс Zoom, звук которого писать. В Windows — Zoom с открытым потоком 38 +/// звука (идёт встреча); на Mac — запущенное приложение Zoom. Нет — `None`. 39 +pub fn zoom_process() -> Option<u32> { 39 40 #[cfg(windows)] 40 − { 41 − win::active_zoom_process() 42 − } 43 − #[cfg(not(windows))] 44 − { 45 − None 46 − } 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 47 46 } 48 47 49 48 /// Запись встреч возможна на этой машине. 50 −pub const CAPTURE_SUPPORTED: bool = cfg!(windows); 49 +pub const CAPTURE_SUPPORTED: bool = cfg!(any(windows, target_os = "macos")); -
Показать разницуСкрыть разницу
1 +//! macOS: звук приложения Zoom и микрофон. 2 +//! 3 +//! - Звук Zoom — ScreenCaptureKit с фильтром по приложению: в запись попадает 4 +//! только то, что выводит Zoom. Нужно разрешение «Запись экрана и системного 5 +//! звука» (Системные настройки → Конфиденциальность и безопасность). 6 +//! https://developer.apple.com/documentation/screencapturekit/scstreamconfiguration/capturesaudio 7 +//! - Микрофон — CoreAudio через `cpal`. Нужно разрешение «Микрофон». 8 +//! 9 +//! Потоки CoreAudio и ScreenCaptureKit живут в своих потоках системы; наружу 10 +//! сэмплы идут через канал, как в Windows. 11 + 12 +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; 13 +use std::time::Duration; 14 + 15 +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; 16 +use screencapturekit::cm::CMTime; 17 +use screencapturekit::prelude::*; 18 + 19 +use crate::pcm::{self, Resampler, SAMPLE_RATE}; 20 +use crate::source::{Capture, CaptureError}; 21 + 22 +/// Идентификатор приложения Zoom для macOS. 23 +const ZOOM_BUNDLE: &str = "us.zoom.xos"; 24 + 25 +fn drain(rx: &Receiver<Vec<i16>>) -> Result<Vec<i16>, ()> { 26 + let mut out = Vec::new(); 27 + match rx.recv_timeout(Duration::from_millis(50)) { 28 + Ok(block) => out.extend(block), 29 + Err(RecvTimeoutError::Timeout) => return Ok(out), 30 + Err(RecvTimeoutError::Disconnected) => return Err(()), 31 + } 32 + while let Ok(block) = rx.try_recv() { 33 + out.extend(block); 34 + } 35 + Ok(out) 36 +} 37 + 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 +} 50 + 51 +struct AudioOut { 52 + tx: Sender<Vec<i16>>, 53 +} 54 + 55 +impl SCStreamOutputTrait for AudioOut { 56 + fn did_output_sample_buffer(&self, sample: CMSampleBuffer, of_type: SCStreamOutputType) { 57 + if !matches!(of_type, SCStreamOutputType::Audio) { 58 + return; 59 + } 60 + let Ok(list) = sample.audio_buffer_list() else { 61 + return; 62 + }; 63 + // Поток настроен на 16 кГц моно float: один буфер, сэмплы f32 подряд. 64 + let Some(buffer) = list.get(0) else { return }; 65 + let floats: Vec<f32> = buffer 66 + .data() 67 + .as_chunks::<4>() 68 + .0 69 + .iter() 70 + .map(|c| f32::from_le_bytes(*c)) 71 + .collect(); 72 + let _ = self.tx.send(pcm::f32_to_i16(&floats)); 73 + } 74 +} 75 + 76 +/// Звук приложения Zoom. 77 +pub struct ZoomAudio { 78 + stream: SCStream, 79 + rx: Receiver<Vec<i16>>, 80 +} 81 + 82 +impl ZoomAudio { 83 + pub fn open(pid: u32) -> Result<Self, CaptureError> { 84 + let err = |e: &dyn std::fmt::Display| { 85 + CaptureError::Loopback(format!( 86 + "{e}. Разрешите запись звука: Системные настройки → Конфиденциальность и безопасность → \ 87 + Запись экрана и системного звука" 88 + )) 89 + }; 90 + 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 + let displays = content.displays(); 97 + let display = displays.first().ok_or_else(|| err(&"нет экрана"))?; 98 + let filter = SCContentFilter::create() 99 + .with_display(display) 100 + .with_including_applications(&[zoom], &[]) 101 + .build() 102 + .map_err(|e| err(&e))?; 103 + // Видео не нужно, но поток без него не бывает: самый маленький кадр раз в секунду. 104 + let config = SCStreamConfiguration::new() 105 + .with_width(2) 106 + .with_height(2) 107 + .with_minimum_frame_interval(&CMTime::new(1, 1)) 108 + .with_captures_audio(true) 109 + .with_excludes_current_process_audio(true) 110 + .with_sample_rate(SAMPLE_RATE as i32) 111 + .with_channel_count(1); 112 + let (tx, rx) = mpsc::channel(); 113 + let mut stream = SCStream::new(&filter, &config).map_err(|e| err(&e))?; 114 + stream 115 + .add_output_handler(AudioOut { tx }, SCStreamOutputType::Audio) 116 + .map_err(|e| err(&e))?; 117 + stream.start_capture().map_err(|e| err(&e))?; 118 + Ok(Self { stream, rx }) 119 + } 120 +} 121 + 122 +impl Capture for ZoomAudio { 123 + fn read(&mut self) -> Result<Vec<i16>, CaptureError> { 124 + drain(&self.rx).map_err(|_| CaptureError::Loopback("поток звука Zoom остановился".into())) 125 + } 126 +} 127 + 128 +impl Drop for ZoomAudio { 129 + fn drop(&mut self) { 130 + let _ = self.stream.stop_capture(); 131 + } 132 +} 133 + 134 +// ── Микрофон ──────────────────────────────────────────────────────────────── 135 + 136 +/// Микрофон по умолчанию. Поток `cpal` нельзя передавать между потоками, 137 +/// поэтому он живёт в своём потоке до сигнала остановки. 138 +pub struct Microphone { 139 + rx: Receiver<Vec<i16>>, 140 + stop: Option<Sender<()>>, 141 + thread: Option<std::thread::JoinHandle<()>>, 142 +} 143 + 144 +impl Microphone { 145 + pub fn open() -> Result<Self, CaptureError> { 146 + let (tx, rx) = mpsc::channel::<Vec<i16>>(); 147 + let (stop_tx, stop_rx) = mpsc::channel::<()>(); 148 + let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>(); 149 + let thread = std::thread::Builder::new() 150 + .name("meet-mic".into()) 151 + .spawn(move || { 152 + let started = (|| -> Result<cpal::Stream, String> { 153 + let device = cpal::default_host() 154 + .default_input_device() 155 + .ok_or("микрофон не найден")?; 156 + let config = device.default_input_config().map_err(|e| e.to_string())?; 157 + let channels = config.channels() as usize; 158 + let rate = config.sample_rate(); 159 + let mut resampler = Resampler::new(channels, rate); 160 + let stream = match config.sample_format() { 161 + cpal::SampleFormat::F32 => device.build_input_stream( 162 + config.into(), 163 + move |data: &[f32], _: &_| { 164 + let _ = tx.send(resampler.push(data)); 165 + }, 166 + |e| eprintln!("микрофон: {e}"), 167 + None, 168 + ), 169 + cpal::SampleFormat::I16 => device.build_input_stream( 170 + config.into(), 171 + move |data: &[i16], _: &_| { 172 + let floats: Vec<f32> = 173 + data.iter().map(|&s| s as f32 / i16::MAX as f32).collect(); 174 + let _ = tx.send(resampler.push(&floats)); 175 + }, 176 + |e| eprintln!("микрофон: {e}"), 177 + None, 178 + ), 179 + other => return Err(format!("формат {other:?} не поддерживается")), 180 + } 181 + .map_err(|e| e.to_string())?; 182 + stream.play().map_err(|e| e.to_string())?; 183 + Ok(stream) 184 + })(); 185 + match started { 186 + Ok(stream) => { 187 + let _ = ready_tx.send(Ok(())); 188 + let _ = stop_rx.recv(); 189 + drop(stream); 190 + } 191 + Err(e) => { 192 + let _ = ready_tx.send(Err(e)); 193 + } 194 + } 195 + }) 196 + .map_err(|e| CaptureError::Microphone(e.to_string()))?; 197 + ready_rx 198 + .recv() 199 + .map_err(|_| CaptureError::Microphone("поток микрофона не запустился".into()))? 200 + .map_err(|e| { 201 + CaptureError::Microphone(format!( 202 + "{e}. Проверьте доступ: Системные настройки → Конфиденциальность и безопасность → Микрофон" 203 + )) 204 + })?; 205 + Ok(Self { 206 + rx, 207 + stop: Some(stop_tx), 208 + thread: Some(thread), 209 + }) 210 + } 211 +} 212 + 213 +impl Capture for Microphone { 214 + fn read(&mut self) -> Result<Vec<i16>, CaptureError> { 215 + drain(&self.rx).map_err(|_| CaptureError::Microphone("поток микрофона остановился".into())) 216 + } 217 +} 218 + 219 +impl Drop for Microphone { 220 + fn drop(&mut self) { 221 + drop(self.stop.take()); 222 + if let Some(t) = self.thread.take() { 223 + let _ = t.join(); 224 + } 225 + } 226 +} -
Показать разницуСкрыть разницу
15 15 Loopback(String), 16 16 #[error("Микрофон недоступен: {0}")] 17 17 Microphone(String), 18 − #[error("Запись встреч из Zoom работает только в Windows")] 18 + #[error("Запись встреч работает только в Windows и macOS")] 19 19 Unsupported, 20 20 #[error("Источник звука закончился")] 21 21 Ended, -
Показать разницуСкрыть разницу
1 +<?xml version="1.0" encoding="UTF-8"?> 2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 +<plist version="1.0"> 4 +<dict> 5 + <key>NSMicrophoneUsageDescription</key> 6 + <string>Skillok Meet записывает ваш голос на встрече, чтобы потом расшифровать запись.</string> 7 + <key>NSAudioCaptureUsageDescription</key> 8 + <string>Skillok Meet записывает звук Zoom — голоса собеседников, — чтобы потом расшифровать запись.</string> 9 +</dict> 10 +</plist> -
Показать разницуСкрыть разницу
1 1 fn main() { 2 + // Запись звука на Mac идёт через Swift-мост ScreenCaptureKit; он грузит 3 + // системные библиотеки Swift через @rpath, а в macOS они в /usr/lib/swift. 4 + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { 5 + println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift"); 6 + } 2 7 tauri_build::build() 3 8 } -
Показать разницуСкрыть разницу
425 425 #[tauri::command] 426 426 pub fn recorder_start(app: AppHandle, state: State<'_, AppState>) -> Result<Meeting, String> { 427 427 if !meet_audio::CAPTURE_SUPPORTED { 428 − return Err("Запись работает только в Windows. Здесь можно добавить готовый аудиофайл.".into()); 428 + return Err("На этой системе запись не поддерживается — можно добавить готовый аудиофайл.".into()); 429 429 } 430 430 let mut slot = state.recording.lock().unwrap_or_else(|p| p.into_inner()); 431 431 if slot.is_some() { 432 432 return Err("Запись уже идёт".into()); 433 433 } 434 − let zoom_pid = meet_audio::active_zoom_process(); 434 + let zoom_pid = meet_audio::zoom_process(); 435 435 let meeting = state 436 436 .store 437 437 .create( 464 464 }; 465 465 add(Speaker::Me, meet_audio::open_microphone()); 466 466 if let Some(pid) = zoom_pid { 467 − add(Speaker::Others, meet_audio::open_process_loopback(pid)); 467 + add(Speaker::Others, meet_audio::open_zoom_audio(pid)); 468 468 } 469 469 if tracks.is_empty() { 470 470 let _ = state.store.delete(&id); -
src-tauri/tauri.conf.json +13 −2
Показать разницуСкрыть разницу
25 25 }, 26 26 "bundle": { 27 27 "active": true, 28 − "targets": ["nsis"], 29 − "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico", "icons/icon.png"] 28 + "targets": [ 29 + "nsis", 30 + "app" 31 + ], 32 + "icon": [ 33 + "icons/32x32.png", 34 + "icons/128x128.png", 35 + "icons/icon.ico", 36 + "icons/icon.png" 37 + ], 38 + "macOS": { 39 + "minimumSystemVersion": "13.0" 40 + } 30 41 } 31 42 } -
src/dev-mock.ts +1 −1
Показать разницуСкрыть разницу
197 197 case "recorder_status": 198 198 return (recording && { ...recording, timer: undefined }) as T; 199 199 case "recorder_start": { 200 − if (!captureSupported) throw "Запись работает только в Windows. Здесь можно добавить готовый аудиофайл."; 200 + if (!captureSupported) throw "На этой системе запись не поддерживается — можно добавить готовый аудиофайл."; 201 201 if (recording) throw "Запись уже идёт"; 202 202 const d = new Date(); 203 203 const m = add(`Встреча в Zoom, ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, 0, "", [], false); -
src/views/home.ts +1 −1
Показать разницуСкрыть разницу
16 16 return ` 17 17 <section class="hero"> 18 18 <h1>Добавьте запись встречи</h1> 19 − <p class="hero-text">Запись звука работает в Windows. Здесь — готовые файлы: mp3, m4a, wav и другие.</p> 19 + <p class="hero-text">На этой системе запись не поддерживается. Добавьте готовый файл: mp3, m4a, wav и другие.</p> 20 20 <button class="ui-btn" data-pick>Выбрать аудиофайл</button> 21 21 <p class="small muted">Или перетащите файл в окно.</p> 22 22 </section>`;