skillok meeting
393887285dfc8accae94edba2c7c575b098e705e
Перейти к файлу
Код
meeting src-taurisrcrecorder.rs
anc Запись всего звука, папка хранения, точнее распознавание - Звук собеседников — весь звук компьютера, кроме самого Skillok Meet: Zoom, Google Meet, Телемост, что угодно. Windows — process loopback в режиме исключения своего процесса, macOS — ScreenCaptureKit без фильтра по приложению. Поиск Zoom и зависимость windows убраны. - Если звук компьютера недоступен (на Mac — нет разрешения), запись идёт с микрофона, а интерфейс говорит почему. - Настройки: папка хранения записей (выбрать, открыть, вернуть по умолчанию; записи переносятся целиком), точность распознавания — по умолчанию полная whisper-large-v3. - «Открыть папку с файлами» у записи; микрофон на Mac просит у CoreAudio сразу 16 кГц. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 96a28a9 30 сентября 2026 г.
recorder.rs
//! Запись по кнопке: каждая дорожка («я» — микрофон, «собеседники» — звук
//! компьютера) пишется своим потоком в свой WAV. Больше ничего — расшифровка
//! запускается потом, когда человек попросит.
//!
//! Заголовок WAV дописывается каждые несколько секунд, поэтому после сбоя
//! записанное читается.

use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};

use meet_audio::pcm::{self, WavWriter};
use meet_audio::source::{Capture, CaptureError};

use crate::transcript::Speaker;

pub struct Track {
    pub speaker: Speaker,
    pub capture: Box<dyn Capture>,
    pub path: PathBuf,
}

#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum Event {
    /// Уровень дорожки, dBFS, несколько раз в секунду.
    Level { speaker: Speaker, dbfs: f32 },
    /// Дорожка перестала писаться; остальные продолжают.
    TrackFailed { speaker: Speaker, error: String },
}

pub type Emit = Arc<dyn Fn(Event) + Send + Sync>;

/// Итог дорожки: сколько записано.
#[derive(Debug, Clone, PartialEq)]
pub struct Written {
    pub speaker: Speaker,
    pub duration_ms: u64,
}

pub struct Session {
    stop: Arc<AtomicBool>,
    paused: Arc<AtomicBool>,
    threads: Vec<JoinHandle<Option<Written>>>,
    pub started: Instant,
}

impl Session {
    pub fn start(tracks: Vec<Track>, emit: Emit) -> Result<Self, String> {
        let stop = Arc::new(AtomicBool::new(false));
        let paused = Arc::new(AtomicBool::new(false));
        let mut threads = Vec::new();
        for track in tracks {
            let writer =
                WavWriter::create(&track.path).map_err(|e| format!("{}: {e}", track.path.display()))?;
            let (stop, paused, emit) = (stop.clone(), paused.clone(), emit.clone());
            threads.push(
                std::thread::Builder::new()
                    .name(format!("meet-track-{}", track.speaker.track_file()))
                    .spawn(move || run_track(track, writer, stop, paused, emit))
                    .map_err(|e| e.to_string())?,
            );
        }
        Ok(Self {
            stop,
            paused,
            threads,
            started: Instant::now(),
        })
    }

    pub fn set_paused(&self, paused: bool) {
        self.paused.store(paused, Ordering::Relaxed);
    }

    pub fn is_paused(&self) -> bool {
        self.paused.load(Ordering::Relaxed)
    }

    /// Останавливает запись и ждёт, пока дорожки допишутся. Блокирует — звать
    /// из фонового потока.
    pub fn stop(self) -> Vec<Written> {
        self.stop.store(true, Ordering::Relaxed);
        self.threads
            .into_iter()
            .filter_map(|t| t.join().ok().flatten())
            .collect()
    }
}

fn run_track(
    mut track: Track,
    mut writer: WavWriter,
    stop: Arc<AtomicBool>,
    paused: Arc<AtomicBool>,
    emit: Emit,
) -> Option<Written> {
    let speaker = track.speaker.clone();
    let mut level_buf: Vec<i16> = Vec::new();
    let mut last_level = Instant::now();
    let mut last_flush = Instant::now();
    while !stop.load(Ordering::Relaxed) {
        let samples = match track.capture.read() {
            Ok(s) => s,
            Err(CaptureError::Ended) => break,
            Err(e) => {
                emit(Event::TrackFailed {
                    speaker: speaker.clone(),
                    error: e.to_string(),
                });
                break;
            }
        };
        // На паузе звук читается, но не пишется: иначе он копился бы в устройстве.
        if samples.is_empty() || paused.load(Ordering::Relaxed) {
            continue;
        }
        if writer.write(&samples).is_err() {
            emit(Event::TrackFailed {
                speaker: speaker.clone(),
                error: "не удалось записать на диск".into(),
            });
            break;
        }
        if last_flush.elapsed() >= Duration::from_secs(5) {
            let _ = writer.flush();
            last_flush = Instant::now();
        }
        level_buf.extend_from_slice(&samples);
        if last_level.elapsed() >= Duration::from_millis(200) {
            emit(Event::Level {
                speaker: speaker.clone(),
                dbfs: pcm::level_dbfs(&level_buf),
            });
            level_buf.clear();
            last_level = Instant::now();
        }
    }
    let _ = writer.flush();
    emit(Event::Level {
        speaker: speaker.clone(),
        dbfs: -90.0,
    });
    Some(Written {
        speaker,
        duration_ms: writer.duration_ms(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use meet_audio::pcm::{decode_wav, ms_to_samples};
    use meet_audio::source::Replay;
    use std::sync::Mutex;

    struct Broken;
    impl Capture for Broken {
        fn read(&mut self) -> Result<Vec<i16>, CaptureError> {
            Err(CaptureError::Loopback("устройство пропало".into()))
        }
    }

    #[test]
    fn tracks_are_written_to_their_files() {
        let dir = tempfile::tempdir().unwrap();
        let events = Arc::new(Mutex::new(Vec::new()));
        let sink = events.clone();
        let me = dir.path().join("me.wav");
        let session = Session::start(
            vec![
                Track {
                    speaker: Speaker::Me,
                    capture: Box::new(Replay::new(vec![500; ms_to_samples(2_000)], 40.0)),
                    path: me.clone(),
                },
                Track {
                    speaker: Speaker::Others,
                    capture: Box::new(Broken),
                    path: dir.path().join("others.wav"),
                },
            ],
            Arc::new(move |e| sink.lock().unwrap().push(e)),
        )
        .unwrap();
        std::thread::sleep(Duration::from_millis(400));
        let written = session.stop();
        let me_written = written.iter().find(|w| w.speaker == Speaker::Me).unwrap();
        assert_eq!(me_written.duration_ms, 2_000);
        assert_eq!(
            decode_wav(&std::fs::read(&me).unwrap()).unwrap().len(),
            ms_to_samples(2_000)
        );
        let events = events.lock().unwrap();
        assert!(events.iter().any(|e| matches!(
            e,
            Event::TrackFailed {
                speaker: Speaker::Others,
                ..
            }
        )));
        assert!(events.iter().any(|e| matches!(
            e,
            Event::Level {
                speaker: Speaker::Me,
                ..
            }
        )));
    }

    #[test]
    fn paused_audio_is_not_written() {
        let dir = tempfile::tempdir().unwrap();
        let session = Session::start(
            vec![Track {
                speaker: Speaker::Me,
                capture: Box::new(Replay::new(vec![500; ms_to_samples(60_000)], 1.0)),
                path: dir.path().join("me.wav"),
            }],
            Arc::new(|_| {}),
        )
        .unwrap();
        session.set_paused(true);
        std::thread::sleep(Duration::from_millis(300));
        assert_eq!(session.stop()[0].duration_ms, 0, "на паузе ничего не записано");
    }
}