skillok meeting
169ce64e1705fd64e48ee357a816bbdb90bab01d
Перейти к файлу
Код
meeting cratesaudiosrcdetector.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 г.
detector.rs
//! Когда встреча в Zoom началась и когда закончилась.
//!
//! Признак встречи — у процесса Zoom есть активная звуковая сессия вывода:
//! во время встречи Zoom держит поток воспроизведения открытым, даже когда все
//! молчат. Вне встречи потока нет (или он короткий — звук уведомления).
//!
//! Сам опрос системы — в `windows::zoom`; здесь только решение по его
//! результатам, чтобы его можно было проверить тестами на любой машине:
//! - встреча началась, если сессия активна дольше `start_after_ms` — отсекаем
//!   звук входящего сообщения и тестовый звонок динамика;
//! - встреча закончилась, если сессии нет дольше `end_after_ms` — переподключение
//!   и смена динамика в настройках Zoom не рвут запись;
//! - процесс Zoom сменился (перезапуск) — это конец прежней встречи.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Config {
    pub start_after_ms: u64,
    pub end_after_ms: u64,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            start_after_ms: 4_000,
            end_after_ms: 20_000,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
    /// Встреча началась; `pid` — корневой процесс Zoom, его звук и пишем.
    Started {
        pid: u32,
    },
    Ended,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
    Idle,
    /// Сессия активна с момента `since`, но ещё мало.
    Starting {
        pid: u32,
        since: u64,
    },
    InMeeting {
        pid: u32,
        last_seen: u64,
    },
}

pub struct Detector {
    cfg: Config,
    state: State,
}

impl Detector {
    pub fn new(cfg: Config) -> Self {
        Self {
            cfg,
            state: State::Idle,
        }
    }

    pub fn in_meeting(&self) -> Option<u32> {
        match self.state {
            State::InMeeting { pid, .. } => Some(pid),
            _ => None,
        }
    }

    /// Очередной опрос в момент `now_ms`: `active` — процесс Zoom с активной
    /// сессией вывода, если он есть.
    pub fn poll(&mut self, now_ms: u64, active: Option<u32>) -> Option<Event> {
        match (self.state, active) {
            (State::Idle, Some(pid)) => {
                self.state = State::Starting { pid, since: now_ms };
                self.check_start(now_ms)
            }
            (State::Idle, None) => None,
            (State::Starting { pid, .. }, Some(p)) if p == pid => self.check_start(now_ms),
            (State::Starting { .. }, Some(p)) => {
                self.state = State::Starting {
                    pid: p,
                    since: now_ms,
                };
                None
            }
            (State::Starting { .. }, None) => {
                self.state = State::Idle;
                None
            }
            (State::InMeeting { pid, .. }, Some(p)) if p == pid => {
                self.state = State::InMeeting {
                    pid,
                    last_seen: now_ms,
                };
                None
            }
            (State::InMeeting { .. }, Some(p)) => {
                self.state = State::Starting {
                    pid: p,
                    since: now_ms,
                };
                Some(Event::Ended)
            }
            (State::InMeeting { last_seen, .. }, None) => {
                if now_ms.saturating_sub(last_seen) >= self.cfg.end_after_ms {
                    self.state = State::Idle;
                    Some(Event::Ended)
                } else {
                    None
                }
            }
        }
    }

    fn check_start(&mut self, now_ms: u64) -> Option<Event> {
        if let State::Starting { pid, since } = self.state {
            if now_ms.saturating_sub(since) >= self.cfg.start_after_ms {
                self.state = State::InMeeting {
                    pid,
                    last_seen: now_ms,
                };
                return Some(Event::Started { pid });
            }
        }
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Прогон по секундам: `active[i]` — что видно на секунде i.
    fn run(active: &[Option<u32>]) -> Vec<(u64, Event)> {
        let mut d = Detector::new(Config::default());
        active
            .iter()
            .enumerate()
            .filter_map(|(i, a)| d.poll(i as u64 * 1000, *a).map(|e| (i as u64, e)))
            .collect()
    }

    #[test]
    fn meeting_starts_after_a_few_seconds_and_ends_after_silence() {
        let mut timeline = vec![None; 3];
        timeline.extend(vec![Some(7); 60]);
        timeline.extend(vec![None; 25]);
        assert_eq!(
            run(&timeline),
            vec![(7, Event::Started { pid: 7 }), (82, Event::Ended)]
        );
    }

    #[test]
    fn notification_sound_is_not_a_meeting() {
        let mut timeline = vec![Some(7); 2];
        timeline.extend(vec![None; 10]);
        timeline.extend(vec![Some(7); 3]);
        timeline.extend(vec![None; 10]);
        assert!(run(&timeline).is_empty());
    }

    #[test]
    fn short_dropout_does_not_end_the_meeting() {
        let mut timeline = vec![Some(7); 10];
        timeline.extend(vec![None; 15]);
        timeline.extend(vec![Some(7); 10]);
        assert_eq!(run(&timeline), vec![(4, Event::Started { pid: 7 })]);
    }

    #[test]
    fn zoom_restart_ends_the_old_meeting() {
        let mut timeline = vec![Some(7); 10];
        timeline.extend(vec![Some(9); 10]);
        assert_eq!(
            run(&timeline),
            vec![
                (4, Event::Started { pid: 7 }),
                (10, Event::Ended),
                (14, Event::Started { pid: 9 })
            ]
        );
    }
}