skillok meeting
169ce64e1705fd64e48ee357a816bbdb90bab01d
Перейти к файлу
Код
meeting src-taurisrccontroller.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 г.
793 строки 28.2 КБ
controller.rs
//! Что программа делает сама: следит за Zoom, спрашивает отметку о
//! предупреждении участников, пишет встречу, по окончании дописывает
//! расшифровку и запускает разбор.
//!
//! ```text
//!             встреча началась            «Предупредил, записывать»
//!  Watching ─────────────────► AwaitingConsent ─────────────────► Recording
//!     ▲  │ (или сразу, если «предупреждаю всегда сам»)                │
//!     │  └──────────── «Записать вручную» ───────────────────────────►│
//!     │                                   встреча кончилась / «Стоп»  ▼
//!     └──────────────────────────────── Finishing (расшифровка → разбор)
//! ```
//!
//! Контроллер не знает про Tauri: окно, уведомления и события интерфейса —
//! через [`Outlet`], источники звука — через [`Sources`]. Поэтому переходы
//! проверяются тестами на любой машине.

use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use chrono::{DateTime, Local};
use meet_audio::detector::{self, Detector};
use serde::Serialize;

use crate::meetings::{Meeting, Store};
use crate::openrouter::{Client, SttSegment};
use crate::recorder::{self, BoxFuture, Session, Track, Transcriber};
use crate::settings::{Secrets, Settings, OPENROUTER_KEY};
use crate::transcript::Transcript;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FinishStep {
    /// Дописываем расшифровку последних кусков.
    Transcribing,
    /// Модель пишет разбор.
    Analyzing,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Status {
    /// На этой машине звук Zoom не записать (не Windows).
    Unsupported,
    /// Жду встречу в Zoom.
    Watching,
    /// Встреча началась, ждём отметку «участники предупреждены».
    AwaitingConsent,
    Recording {
        meeting_id: String,
        started_at: DateTime<Local>,
        paused: bool,
        /// Пишется звук Zoom (а не только микрофон).
        zoom: bool,
    },
    Finishing {
        meeting_id: String,
        step: FinishStep,
    },
}

/// Всё, что видит пользователь.
pub trait Outlet: Send + Sync + 'static {
    fn status(&self, status: &Status);
    fn recorder_event(&self, meeting_id: &str, event: &recorder::Event);
    /// Встреча изменилась на диске (дописана, разобрана).
    fn meeting_changed(&self, meeting_id: &str);
    fn notify(&self, title: &str, body: &str);
    fn show_window(&self);
}

/// Источники звука этой машины.
pub trait Sources: Send + Sync + 'static {
    fn supported(&self) -> bool;
    /// Процесс Zoom с активным звуком, если он есть.
    fn active_zoom(&self) -> Option<u32>;
    /// Дорожки записи: микрофон и, если есть `zoom_pid`, звук Zoom.
    fn open(&self, zoom_pid: Option<u32>) -> Result<Vec<Track>, String>;
}

/// Распознавание и конспект по ключу и настройкам.
pub type MakeAi = Arc<dyn Fn(&str, &Settings) -> Arc<dyn Transcriber> + Send + Sync>;
/// Итоговый разбор встречи: (id, настройки, ключ).
pub type Analyze =
    Arc<dyn Fn(String, Settings, String) -> BoxFuture<'static, Result<(), String>> + Send + Sync>;

pub struct Deps {
    pub store: Arc<Store>,
    pub settings_path: PathBuf,
    pub secrets: Arc<dyn Secrets>,
    pub outlet: Arc<dyn Outlet>,
    pub sources: Arc<dyn Sources>,
    pub rt: tokio::runtime::Handle,
    /// Распознавание и конспект по ключу; в тестах — подделка.
    pub make_ai: MakeAi,
    /// Итоговый разбор; в тестах — подделка.
    pub analyze: Analyze,
    pub recorder: recorder::Config,
}

struct Inner {
    status: Status,
    session: Option<Session>,
    detector: Detector,
    /// Процесс Zoom текущей встречи.
    zoom_pid: Option<u32>,
    /// Предупредили, что без ключа записи не будет, — один раз за встречу.
    warned_no_key: bool,
}

pub struct Controller {
    deps: Deps,
    clock: Instant,
    inner: Mutex<Inner>,
}

impl Controller {
    pub fn new(deps: Deps) -> Arc<Self> {
        let status = if deps.sources.supported() {
            Status::Watching
        } else {
            Status::Unsupported
        };
        Arc::new(Self {
            deps,
            clock: Instant::now(),
            inner: Mutex::new(Inner {
                status,
                session: None,
                detector: Detector::new(detector::Config::default()),
                zoom_pid: None,
                warned_no_key: false,
            }),
        })
    }

    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
        self.inner.lock().unwrap_or_else(|p| p.into_inner())
    }

    pub fn status(&self) -> Status {
        self.lock().status.clone()
    }

    fn set_status(&self, inner: &mut Inner, status: Status) {
        inner.status = status;
        self.deps.outlet.status(&inner.status);
    }

    fn settings(&self) -> Settings {
        Settings::load(&self.deps.settings_path).unwrap_or_default()
    }

    fn key(&self) -> Option<String> {
        self.deps.secrets.get(OPENROUTER_KEY).ok().flatten()
    }

    /// Опрос Zoom раз в секунду в своём потоке.
    pub fn spawn_watch(self: &Arc<Self>) {
        if !self.deps.sources.supported() {
            return;
        }
        let this = self.clone();
        std::thread::Builder::new()
            .name("meet-watch".into())
            .spawn(move || loop {
                this.tick();
                std::thread::sleep(Duration::from_secs(1));
            })
            .expect("поток наблюдения");
    }

    /// Один опрос: что с Zoom сейчас.
    pub fn tick(self: &Arc<Self>) {
        self.tick_at(self.clock.elapsed().as_millis() as u64);
    }

    /// Опрос в момент `now_ms` — тесты подают время сами, не ожидая секунд.
    pub fn tick_at(self: &Arc<Self>, now_ms: u64) {
        let active = self.deps.sources.active_zoom();
        let event = self.lock().detector.poll(now_ms, active);
        match event {
            Some(detector::Event::Started { pid }) => self.meeting_started(pid),
            Some(detector::Event::Ended) => self.meeting_ended(),
            None => {}
        }
    }

    fn meeting_started(self: &Arc<Self>, pid: u32) {
        let mut inner = self.lock();
        inner.zoom_pid = Some(pid);
        if inner.status != Status::Watching {
            return;
        }
        if self.key().is_none() {
            if !inner.warned_no_key {
                inner.warned_no_key = true;
                self.deps.outlet.notify(
                    "Началась встреча в Zoom",
                    "Записать не получится: не подключён ИИ. Добавьте ключ OpenRouter в настройках.",
                );
            }
            return;
        }
        if self.settings().always_warn_myself {
            drop(inner);
            if let Err(e) = self.start(true) {
                self.deps.outlet.notify("Запись не началась", &e);
            }
            return;
        }
        self.set_status(&mut inner, Status::AwaitingConsent);
        self.deps.outlet.notify(
            "Началась встреча в Zoom",
            "Предупредите участников о записи и нажмите «Записывать».",
        );
        self.deps.outlet.show_window();
    }

    fn meeting_ended(self: &Arc<Self>) {
        let mut inner = self.lock();
        inner.zoom_pid = None;
        inner.warned_no_key = false;
        match inner.status {
            Status::AwaitingConsent => self.set_status(&mut inner, Status::Watching),
            Status::Recording { zoom: true, .. } => {
                drop(inner);
                let this = self.clone();
                self.deps.rt.spawn(async move {
                    let _ = this.stop().await;
                });
            }
            _ => {}
        }
    }

    /// Ответ на «Предупредите участников».
    pub fn consent(self: &Arc<Self>, record: bool) -> Result<(), String> {
        let mut inner = self.lock();
        if inner.status != Status::AwaitingConsent {
            return Err("Сейчас нечего подтверждать".into());
        }
        if !record {
            self.set_status(&mut inner, Status::Watching);
            return Ok(());
        }
        drop(inner);
        self.start(true).map(|_| ())
    }

    /// «Записать вручную»: встреча не в Zoom или Zoom не распознался.
    pub fn start_manual(self: &Arc<Self>) -> Result<Meeting, String> {
        self.start(false)
    }

    fn start(self: &Arc<Self>, auto: bool) -> Result<Meeting, String> {
        let mut inner = self.lock();
        if !matches!(inner.status, Status::Watching | Status::AwaitingConsent) {
            return Err("Запись уже идёт".into());
        }
        if !self.deps.sources.supported() {
            return Err("Запись встреч работает только в Windows".into());
        }
        let key = self
            .key()
            .ok_or("Сначала подключите ИИ: добавьте ключ OpenRouter в настройках")?;
        let settings = self.settings();
        let zoom_pid = inner.zoom_pid.or_else(|| self.deps.sources.active_zoom());
        let tracks = self.deps.sources.open(zoom_pid)?;
        let zoom = zoom_pid.is_some();
        let now = Local::now();
        let title = format!(
            "{} {}",
            if zoom {
                "Встреча в Zoom,"
            } else {
                "Запись,"
            },
            now.format("%d.%m, %H:%M")
        );
        let meeting = self
            .deps
            .store
            .create_recording(&title, now)
            .map_err(|e| e.to_string())?;
        let ai = (self.deps.make_ai)(&key, &settings);
        let outlet = self.deps.outlet.clone();
        let id = meeting.id.clone();
        let session = Session::start(
            meeting.id.clone(),
            self.deps.store.clone(),
            tracks,
            ai,
            Arc::new(move |e| outlet.recorder_event(&id, &e)),
            self.deps.recorder,
            &self.deps.rt,
        );
        inner.session = Some(session);
        self.set_status(
            &mut inner,
            Status::Recording {
                meeting_id: meeting.id.clone(),
                started_at: now,
                paused: false,
                zoom,
            },
        );
        if auto {
            self.deps.outlet.notify(
                "Записываю встречу",
                "Расшифровка появится в Skillok Meet по ходу встречи.",
            );
        }
        Ok(meeting)
    }

    pub fn pause(&self, paused: bool) -> Result<(), String> {
        let mut inner = self.lock();
        let Status::Recording {
            meeting_id,
            started_at,
            zoom,
            ..
        } = inner.status.clone()
        else {
            return Err("Запись не идёт".into());
        };
        if let Some(s) = inner.session.as_ref() {
            s.set_paused(paused);
        }
        self.set_status(
            &mut inner,
            Status::Recording {
                meeting_id,
                started_at,
                paused,
                zoom,
            },
        );
        Ok(())
    }

    /// Остановить запись, дописать расшифровку и запустить разбор.
    pub async fn stop(self: &Arc<Self>) -> Result<(), String> {
        let (session, meeting_id) = {
            let mut inner = self.lock();
            let Status::Recording { meeting_id, .. } = inner.status.clone() else {
                return Err("Запись не идёт".into());
            };
            let session = inner.session.take();
            self.set_status(
                &mut inner,
                Status::Finishing {
                    meeting_id: meeting_id.clone(),
                    step: FinishStep::Transcribing,
                },
            );
            (session, meeting_id)
        };
        let duration = match session {
            Some(s) => s.stop().await,
            None => 0,
        };
        let store = &self.deps.store;
        let outlet = &self.deps.outlet;
        let finished = store.finish_recording(&meeting_id, duration);
        outlet.meeting_changed(&meeting_id);
        let title = finished.map(|m| m.title).unwrap_or_default();
        let has_speech = store
            .get(&meeting_id)
            .map(|d| !d.transcript.segments.is_empty())
            .unwrap_or(false);

        let settings = self.settings();
        match (has_speech, self.key()) {
            (false, _) => outlet.notify("Запись остановлена", "Речи не было — разбирать нечего."),
            (true, None) => outlet.notify("Запись остановлена", "Разбор не запущен: не подключён ИИ."),
            (true, Some(key)) => {
                {
                    let mut inner = self.lock();
                    self.set_status(
                        &mut inner,
                        Status::Finishing {
                            meeting_id: meeting_id.clone(),
                            step: FinishStep::Analyzing,
                        },
                    );
                }
                match (self.deps.analyze)(meeting_id.clone(), settings.clone(), key).await {
                    Ok(()) => {
                        if !settings.keep_audio {
                            let _ = store.delete_audio(&meeting_id);
                        }
                        outlet.meeting_changed(&meeting_id);
                        outlet.notify("Разбор встречи готов", &title);
                    }
                    Err(e) => outlet.notify(
                        "Разбор не получился",
                        &format!("{e}. Звук сохранён, можно разобрать заново."),
                    ),
                }
            }
        }
        let mut inner = self.lock();
        self.set_status(&mut inner, Status::Watching);
        Ok(())
    }
}

// ── Настоящие зависимости ───────────────────────────────────────────────────

/// Распознавание и живой конспект через OpenRouter.
pub struct OpenRouterAi {
    client: Client,
    stt_model: String,
    live_model: String,
    language: Option<String>,
}

impl OpenRouterAi {
    pub fn new(key: &str, settings: &Settings) -> Self {
        Self {
            client: Client::new(key),
            stt_model: settings.stt_model.clone(),
            live_model: settings.live_model.clone(),
            language: settings.stt_language().map(str::to_string),
        }
    }
}

impl Transcriber for OpenRouterAi {
    fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>> {
        Box::pin(async move {
            self.client
                .transcribe(&self.stt_model, &wav, self.language.as_deref())
                .await
                .map(|t| t.segments)
                .map_err(|e| e.to_string())
        })
    }

    fn live_summary(&self, transcript: Transcript) -> BoxFuture<'_, Result<String, String>> {
        Box::pin(async move {
            self.client
                .live_summary(&self.live_model, &transcript)
                .await
                .map(|(t, _)| t)
                .map_err(|e| e.to_string())
        })
    }
}

/// Звук этой машины: WASAPI в Windows. Вне Windows — только проба цепочки из
/// файла: `MEET_DEV_REPLAY=путь.wav` (16 кГц, моно) играет его как «собеседников».
pub struct SystemSources {
    dev_replay: Option<PathBuf>,
}

impl SystemSources {
    pub fn from_env() -> Self {
        Self {
            dev_replay: std::env::var_os("MEET_DEV_REPLAY").map(PathBuf::from),
        }
    }
}

impl Sources for SystemSources {
    fn supported(&self) -> bool {
        meet_audio::CAPTURE_SUPPORTED || self.dev_replay.is_some()
    }

    fn active_zoom(&self) -> Option<u32> {
        meet_audio::active_zoom_process()
    }

    fn open(&self, zoom_pid: Option<u32>) -> Result<Vec<Track>, String> {
        use crate::transcript::Speaker;
        if let Some(path) = &self.dev_replay {
            let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
            let samples = meet_audio::pcm::decode_wav(&bytes).map_err(|e| e.to_string())?;
            return Ok(vec![Track {
                speaker: Speaker::Others,
                capture: Box::new(meet_audio::source::Replay::new(samples, 1.0)),
            }]);
        }
        let mut tracks = Vec::new();
        let mut errors = Vec::new();
        match meet_audio::open_microphone() {
            Ok(c) => tracks.push(Track {
                speaker: Speaker::Me,
                capture: c,
            }),
            Err(e) => errors.push(e.to_string()),
        }
        if let Some(pid) = zoom_pid {
            match meet_audio::open_process_loopback(pid) {
                Ok(c) => tracks.push(Track {
                    speaker: Speaker::Others,
                    capture: c,
                }),
                Err(e) => errors.push(e.to_string()),
            }
        }
        if tracks.is_empty() {
            return Err(errors.join("; "));
        }
        Ok(tracks)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::settings::Memory;
    use crate::transcript::Speaker;
    use meet_audio::pcm::{ms_to_samples, SAMPLE_RATE};
    use meet_audio::source::Replay;
    use std::sync::atomic::{AtomicU32, Ordering};

    #[derive(Default)]
    struct Log(Mutex<Vec<String>>);

    impl Log {
        fn has(&self, needle: &str) -> bool {
            self.0.lock().unwrap().iter().any(|l| l.contains(needle))
        }
    }

    impl Outlet for Log {
        fn status(&self, s: &Status) {
            self.0
                .lock()
                .unwrap()
                .push(format!("status {}", serde_json::to_string(s).unwrap()));
        }
        fn recorder_event(&self, _: &str, e: &recorder::Event) {
            if let recorder::Event::Segments(s) = e {
                self.0.lock().unwrap().push(format!("segments {}", s.len()));
            }
        }
        fn meeting_changed(&self, id: &str) {
            self.0.lock().unwrap().push(format!("changed {id}"));
        }
        fn notify(&self, title: &str, body: &str) {
            self.0.lock().unwrap().push(format!("notify {title}: {body}"));
        }
        fn show_window(&self) {
            self.0.lock().unwrap().push("show".into());
        }
    }

    /// Zoom «активен», пока `zoom` не 0.
    struct FakeSources {
        zoom: AtomicU32,
    }

    impl Sources for FakeSources {
        fn supported(&self) -> bool {
            true
        }
        fn active_zoom(&self) -> Option<u32> {
            Some(self.zoom.load(Ordering::Relaxed)).filter(|p| *p != 0)
        }
        fn open(&self, zoom_pid: Option<u32>) -> Result<Vec<Track>, String> {
            let speech: Vec<i16> = (0..ms_to_samples(3_000))
                .map(|i| {
                    ((i as f32 / SAMPLE_RATE as f32 * std::f32::consts::TAU * 250.0).sin() * 9000.0) as i16
                })
                .collect();
            let speaker = if zoom_pid.is_some() {
                Speaker::Others
            } else {
                Speaker::Me
            };
            Ok(vec![Track {
                speaker,
                capture: Box::new(Replay::new(speech, 50.0)),
            }])
        }
    }

    struct EchoAi;
    impl Transcriber for EchoAi {
        fn transcribe(&self, _: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>> {
            Box::pin(async {
                Ok(vec![SttSegment {
                    start_ms: 0,
                    text: "реплика".into(),
                }])
            })
        }
        fn live_summary(&self, _: Transcript) -> BoxFuture<'_, Result<String, String>> {
            Box::pin(async { Ok("конспект".into()) })
        }
    }

    struct Setup {
        clock: std::sync::atomic::AtomicU64,
        ctl: Arc<Controller>,
        log: Arc<Log>,
        sources: Arc<FakeSources>,
        store: Arc<Store>,
        _dir: tempfile::TempDir,
    }

    fn setup(with_key: bool, always_warn: bool) -> Setup {
        let dir = tempfile::tempdir().unwrap();
        let settings_path = dir.path().join("settings.json");
        Settings {
            always_warn_myself: always_warn,
            ..Settings::default()
        }
        .save(&settings_path)
        .unwrap();
        let secrets = Arc::new(Memory::default());
        if with_key {
            secrets.set(OPENROUTER_KEY, "sk-test").unwrap();
        }
        let log = Arc::new(Log::default());
        let sources = Arc::new(FakeSources {
            zoom: AtomicU32::new(0),
        });
        let store = Arc::new(Store::new(dir.path().join("meetings")));
        let analyzed_store = store.clone();
        let ctl = Controller::new(Deps {
            store: store.clone(),
            settings_path,
            secrets,
            outlet: log.clone(),
            sources: sources.clone(),
            rt: tokio::runtime::Handle::current(),
            make_ai: Arc::new(|_, _| Arc::new(EchoAi)),
            analyze: Arc::new(move |id, _, _| {
                let store = analyzed_store.clone();
                Box::pin(async move {
                    store
                        .save_live_summary(&id, "разобрано")
                        .map_err(|e| e.to_string())
                })
            }),
            recorder: recorder::Config {
                live_every: Duration::from_secs(3600),
                ..recorder::Config::default()
            },
        });
        Setup {
            clock: std::sync::atomic::AtomicU64::new(0),
            ctl,
            log,
            sources,
            store,
            _dir: dir,
        }
    }

    /// Прогоняет опрос Zoom `seconds` раз по секунде условного времени.
    fn ticks(s: &Setup, seconds: u64) {
        for _ in 0..seconds {
            let now = s.clock.fetch_add(1000, Ordering::Relaxed) + 1000;
            s.ctl.tick_at(now);
        }
    }

    async fn wait_for(s: &Setup, pred: impl Fn(&Status) -> bool) {
        for _ in 0..200 {
            if pred(&s.ctl.status()) {
                return;
            }
            tokio::time::sleep(Duration::from_millis(20)).await;
        }
        panic!("не дождались состояния, сейчас {:?}", s.ctl.status());
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn meeting_asks_for_consent_records_and_analyzes() {
        let s = setup(true, false);
        s.sources.zoom.store(42, Ordering::Relaxed);
        ticks(&s, 6);
        assert_eq!(s.ctl.status(), Status::AwaitingConsent);
        assert!(s.log.has("notify Началась встреча в Zoom: Предупредите"));
        assert!(s.log.has("show"));

        s.ctl.consent(true).unwrap();
        let Status::Recording {
            meeting_id,
            zoom: true,
            ..
        } = s.ctl.status()
        else {
            panic!("{:?}", s.ctl.status())
        };
        tokio::time::sleep(Duration::from_millis(400)).await;

        // Zoom закрыли — запись кончается сама, разбор запускается.
        s.sources.zoom.store(0, Ordering::Relaxed);
        ticks(&s, 25);
        wait_for(&s, |st| *st == Status::Watching).await;
        let d = s.store.get(&meeting_id).unwrap();
        assert_eq!(d.meeting.state, crate::meetings::MeetingState::Done);
        assert_eq!(d.transcript.segments[0].speaker, Speaker::Others);
        assert_eq!(d.live_summary.as_deref(), Some("разобрано"), "разбор запущен");
        assert!(s.log.has("notify Разбор встречи готов"));
        assert!(
            !s.store.root().join(&meeting_id).join("audio").exists(),
            "звук удалён после разбора"
        );
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn declined_meeting_is_not_recorded() {
        let s = setup(true, false);
        s.sources.zoom.store(42, Ordering::Relaxed);
        ticks(&s, 6);
        s.ctl.consent(false).unwrap();
        assert_eq!(s.ctl.status(), Status::Watching);
        ticks(&s, 30);
        assert_eq!(
            s.ctl.status(),
            Status::Watching,
            "та же встреча второй раз не спрашивается"
        );
        assert!(s.store.list().unwrap().is_empty());
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn always_warn_starts_recording_at_once() {
        let s = setup(true, true);
        s.sources.zoom.store(42, Ordering::Relaxed);
        ticks(&s, 6);
        assert!(
            matches!(s.ctl.status(), Status::Recording { .. }),
            "{:?}",
            s.ctl.status()
        );
        assert!(s.log.has("notify Записываю встречу"));
        s.ctl.stop().await.unwrap();
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn without_key_nothing_is_recorded_and_user_is_told_once() {
        let s = setup(false, true);
        s.sources.zoom.store(42, Ordering::Relaxed);
        ticks(&s, 20);
        assert_eq!(s.ctl.status(), Status::Watching);
        assert_eq!(
            s.log
                .0
                .lock()
                .unwrap()
                .iter()
                .filter(|l| l.contains("не подключён ИИ"))
                .count(),
            1
        );
        assert!(s.ctl.start_manual().unwrap_err().contains("ключ OpenRouter"));
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn manual_recording_with_pause_and_stop() {
        let s = setup(true, false);
        let m = s.ctl.start_manual().unwrap();
        assert!(matches!(s.ctl.status(), Status::Recording { zoom: false, .. }));
        assert!(s.ctl.start_manual().is_err(), "вторая запись не начинается");
        s.ctl.pause(true).unwrap();
        assert!(matches!(s.ctl.status(), Status::Recording { paused: true, .. }));
        s.ctl.pause(false).unwrap();
        tokio::time::sleep(Duration::from_millis(400)).await;
        s.ctl.stop().await.unwrap();
        assert_eq!(s.ctl.status(), Status::Watching);
        assert_eq!(
            s.store.get(&m.id).unwrap().transcript.segments[0].speaker,
            Speaker::Me
        );
        assert!(
            s.ctl.stop().await.is_err(),
            "стоп без записи — ошибка, а не паника"
        );
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn status_serializes_for_the_interface() {
        let json = serde_json::to_value(Status::Finishing {
            meeting_id: "1".into(),
            step: FinishStep::Analyzing,
        })
        .unwrap();
        assert_eq!(
            json,
            serde_json::json!({ "kind": "finishing", "meeting_id": "1", "step": "analyzing" })
        );
    }
}