skillok meeting
1b62ba24a18c2b97ace8dd04ee8ccbcfafcb5b37
Перейти к файлу
Код
meeting cratesaudiosrcwin.rs
anc Этап 1: запись встречи в Zoom, живая расшифровка и разбор - crates/audio: захват звука процесса Zoom и микрофона (WASAPI, process loopback), формат 16 кГц моно, нарезка на куски по паузам с отсевом тишины, распознавание начала и конца встречи по звуковой сессии Zoom. - Ядро: запись двух дорожек → Whisper через OpenRouter → расшифровка, живой конспект раз в три минуты, по окончании — разбор сам; звук удаляется после разбора, если не включено хранение. - Контроллер: слежу за Zoom → прошу предупредить участников → пишу; запись вручную, пауза, стоп; прерванная запись помечается при запуске. - Трей, уведомления, крестик прячет окно, запуск вместе с Windows. - Интерфейс: главный экран с карточкой состояния (задача #1), живая расшифровка и конспект на экране встречи, новые настройки. - reqwest на системном TLS: под Windows без C-кода ring. - 67 тестов; код под Windows проверен компиляцией и clippy с Mac, на Windows не запускался. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 169ce64 30 сентября 2026 г.
win.rs
//! Windows: захват звука процесса Zoom, микрофона и поиск активной встречи.
//!
//! - Звук процесса — process loopback (Windows 10 20438+):
//!   https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/
//!   Обёртка — крейт `wasapi` (`AudioClient::new_application_loopback_client`).
//! - Сессии звука по процессам — IAudioSessionManager2:
//!   https://learn.microsoft.com/en-us/windows/win32/api/audiopolicy/nn-audiopolicy-iaudiosessionmanager2
//!
//! COM-объекты WASAPI не переносятся между потоками, поэтому каждый захват
//! живёт в своём потоке, а наружу отдаёт сэмплы через канал.

use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;

use wasapi::{
    initialize_mta, AudioClient, DeviceEnumerator, Direction, Role, SampleType, SessionState, StreamMode,
    WaveFormat,
};
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Diagnostics::ToolHelp::{
    CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
};

use crate::pcm::{self, SAMPLE_RATE};
use crate::source::{Capture, CaptureError};

const ZOOM_EXE: &str = "zoom.exe";

// ── Поиск встречи ───────────────────────────────────────────────────────────

struct ProcessInfo {
    name: String,
    parent: u32,
}

fn processes() -> HashMap<u32, ProcessInfo> {
    let mut out = HashMap::new();
    unsafe {
        let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
            return out;
        };
        let mut entry = PROCESSENTRY32W {
            dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
            ..Default::default()
        };
        if Process32FirstW(snap, &mut entry).is_ok() {
            loop {
                let len = entry
                    .szExeFile
                    .iter()
                    .position(|&c| c == 0)
                    .unwrap_or(entry.szExeFile.len());
                out.insert(
                    entry.th32ProcessID,
                    ProcessInfo {
                        name: String::from_utf16_lossy(&entry.szExeFile[..len]).to_lowercase(),
                        parent: entry.th32ParentProcessID,
                    },
                );
                if Process32NextW(snap, &mut entry).is_err() {
                    break;
                }
            }
        }
        let _ = CloseHandle(snap);
    }
    out
}

/// Корень дерева процессов Zoom: захват включает дочерние процессы, но цель
/// обязана быть родителем (так требует process loopback).
fn zoom_root(pid: u32, procs: &HashMap<u32, ProcessInfo>) -> u32 {
    let mut current = pid;
    for _ in 0..16 {
        match procs
            .get(&current)
            .and_then(|p| procs.get(&p.parent).map(|parent| (p.parent, parent)))
        {
            Some((parent_pid, parent)) if parent.name == ZOOM_EXE => current = parent_pid,
            _ => break,
        }
    }
    current
}

/// Процесс Zoom, у которого сейчас активная сессия вывода звука на любом
/// устройстве воспроизведения.
pub fn active_zoom_process() -> Option<u32> {
    let _ = initialize_mta();
    let procs = processes();
    if !procs.values().any(|p| p.name == ZOOM_EXE) {
        return None;
    }
    let enumerator = DeviceEnumerator::new().ok()?;
    let devices = enumerator.get_device_collection(&Direction::Render).ok()?;
    for device in &devices {
        let Ok(device) = device else { continue };
        let Ok(manager) = device.get_iaudiosessionmanager() else {
            continue;
        };
        let Ok(sessions) = manager.get_audiosessionenumerator() else {
            continue;
        };
        let count = sessions.get_count().unwrap_or(0);
        for i in 0..count {
            let Ok(session) = sessions.get_session(i) else {
                continue;
            };
            if !matches!(session.get_state(), Ok(SessionState::Active)) {
                continue;
            }
            let Ok(pid) = session.get_process_id() else {
                continue;
            };
            if procs.get(&pid).is_some_and(|p| p.name == ZOOM_EXE) {
                return Some(zoom_root(pid, &procs));
            }
        }
    }
    None
}

// ── Захват ──────────────────────────────────────────────────────────────────

/// Поток захвата и канал сэмплов из него.
struct Worker {
    rx: Receiver<Result<Vec<i16>, String>>,
    stop: Arc<AtomicBool>,
    thread: Option<JoinHandle<()>>,
}

impl Worker {
    fn spawn(open: impl FnOnce() -> Result<AudioClient, String> + Send + 'static) -> Result<Self, String> {
        let (tx, rx) = mpsc::channel();
        let (ready_tx, ready_rx) = mpsc::channel();
        let stop = Arc::new(AtomicBool::new(false));
        let stop_flag = stop.clone();
        let thread = std::thread::Builder::new()
            .name("meet-capture".into())
            .spawn(move || {
                let _ = initialize_mta();
                let started = open().and_then(|mut client| start(&mut client).map(|fmt| (client, fmt)));
                let (client, format) = match started {
                    Ok(v) => {
                        let _ = ready_tx.send(Ok(()));
                        v
                    }
                    Err(e) => {
                        let _ = ready_tx.send(Err(e));
                        return;
                    }
                };
                if let Err(e) = pump(&client, format, &tx, &stop_flag) {
                    let _ = tx.send(Err(e));
                }
                let _ = client.stop_stream();
            })
            .map_err(|e| e.to_string())?;
        ready_rx
            .recv()
            .map_err(|_| "поток захвата не запустился".to_string())??;
        Ok(Self {
            rx,
            stop,
            thread: Some(thread),
        })
    }

    fn read(&mut self) -> Result<Vec<i16>, String> {
        let mut out = Vec::new();
        match self.rx.recv_timeout(Duration::from_millis(50)) {
            Ok(block) => out.extend(block?),
            Err(RecvTimeoutError::Timeout) => return Ok(out),
            Err(RecvTimeoutError::Disconnected) => return Err("поток захвата остановился".into()),
        }
        while let Ok(block) = self.rx.try_recv() {
            out.extend(block?);
        }
        Ok(out)
    }
}

impl Drop for Worker {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);
        if let Some(t) = self.thread.take() {
            let _ = t.join();
        }
    }
}

/// Формат, который получился у клиента.
#[derive(Clone, Copy)]
enum Format {
    /// Наш формат: WASAPI сам привёл к 16 кГц моно.
    Native,
    /// Запасной: float, как попросили, с частотой и числом каналов.
    Float { rate: u32, channels: usize },
}

/// Просим 16 кГц моно 16 бит с автоконвертацией. Если устройство отказало —
/// float 48 кГц стерео и приводим сами.
fn start(client: &mut AudioClient) -> Result<Format, String> {
    let mode = StreamMode::EventsShared {
        autoconvert: true,
        buffer_duration_hns: 2_000_000,
    };
    let native = WaveFormat::new(16, 16, &SampleType::Int, SAMPLE_RATE as usize, 1, None);
    let format = match client.initialize_client(&native, &Direction::Capture, &mode) {
        Ok(()) => Format::Native,
        Err(first) => {
            let float = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
            client
                .initialize_client(&float, &Direction::Capture, &mode)
                .map_err(|second| format!("{first}; запасной формат: {second}"))?;
            Format::Float {
                rate: 48_000,
                channels: 2,
            }
        }
    };
    Ok(format)
}

fn pump(
    client: &AudioClient,
    format: Format,
    tx: &mpsc::Sender<Result<Vec<i16>, String>>,
    stop: &AtomicBool,
) -> Result<(), String> {
    let event = client.set_get_eventhandle().map_err(|e| e.to_string())?;
    let capture = client.get_audiocaptureclient().map_err(|e| e.to_string())?;
    client.start_stream().map_err(|e| e.to_string())?;
    let mut raw: VecDeque<u8> = VecDeque::new();
    while !stop.load(Ordering::Relaxed) {
        // Захват процесса не сигналит, пока Zoom молчит: таймаут — не ошибка.
        let _ = event.wait_for_event(100);
        loop {
            let frames = capture
                .get_next_packet_size()
                .map_err(|e| e.to_string())?
                .unwrap_or(0);
            if frames == 0 {
                break;
            }
            let before = raw.len();
            let info = capture
                .read_from_device_to_deque(&mut raw)
                .map_err(|e| e.to_string())?;
            if info.flags.silent {
                for b in raw.iter_mut().skip(before) {
                    *b = 0;
                }
            }
        }
        if raw.is_empty() {
            continue;
        }
        let bytes: Vec<u8> = raw.drain(..).collect();
        let samples = match format {
            Format::Native => bytes
                .as_chunks::<2>()
                .0
                .iter()
                .map(|c| i16::from_le_bytes(*c))
                .collect(),
            Format::Float { rate, channels } => {
                let floats: Vec<f32> = bytes
                    .as_chunks::<4>()
                    .0
                    .iter()
                    .map(|c| f32::from_le_bytes(*c))
                    .collect();
                pcm::to_native(&floats, channels, rate)
            }
        };
        if tx.send(Ok(samples)).is_err() {
            break;
        }
    }
    Ok(())
}

/// Звук процесса Zoom и всех его дочерних процессов.
pub struct ProcessLoopback(Worker);

impl ProcessLoopback {
    pub fn open(pid: u32) -> Result<Self, CaptureError> {
        Worker::spawn(move || {
            AudioClient::new_application_loopback_client(pid, true).map_err(|e| e.to_string())
        })
        .map(Self)
        .map_err(CaptureError::Loopback)
    }
}

impl Capture for ProcessLoopback {
    fn read(&mut self) -> Result<Vec<i16>, CaptureError> {
        self.0.read().map_err(CaptureError::Loopback)
    }
}

/// Микрофон по умолчанию для связи — тот же, что обычно выбран в Zoom.
pub struct Microphone(Worker);

impl Microphone {
    pub fn open() -> Result<Self, CaptureError> {
        Worker::spawn(|| {
            let enumerator = DeviceEnumerator::new().map_err(|e| e.to_string())?;
            let device = enumerator
                .get_default_device_for_role(&Direction::Capture, &Role::Communications)
                .or_else(|_| enumerator.get_default_device(&Direction::Capture))
                .map_err(|e| e.to_string())?;
            device.get_iaudioclient().map_err(|e| e.to_string())
        })
        .map(Self)
        .map_err(CaptureError::Microphone)
    }
}

impl Capture for Microphone {
    fn read(&mut self) -> Result<Vec<i16>, CaptureError> {
        self.0.read().map_err(CaptureError::Microphone)
    }
}