skillok meeting

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>

anc · 30 сентября 2026 г. 1b62ba24a18c2b97ace8dd04ee8ccbcfafcb5b37
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 +}
  • Показать разницуСкрыть разницу
    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);
  • Показать разницуСкрыть разницу
    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>`;