skillok meeting
169ce64e1705fd64e48ee357a816bbdb90bab01d
Перейти к файлу
Код
meeting cratesaudiosrcpcm.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 г.
pcm.rs
//! Формат звука во всей программе: 16 кГц, моно, 16 бит. Столько ждёт
//! Whisper, и в таком виде WASAPI отдаёт звук сам (автоконвертация), так что
//! своего ресемплера нет.

use std::fs::File;
use std::io::{self, Seek, SeekFrom, Write};
use std::path::Path;

pub const SAMPLE_RATE: u32 = 16_000;
pub const SAMPLES_PER_MS: u64 = SAMPLE_RATE as u64 / 1000;

pub fn ms_to_samples(ms: u64) -> usize {
    (ms * SAMPLES_PER_MS) as usize
}

pub fn samples_to_ms(samples: usize) -> u64 {
    samples as u64 / SAMPLES_PER_MS
}

/// Уровень куска в dBFS: 0 — предел, тишина — около −90.
pub fn level_dbfs(samples: &[i16]) -> f32 {
    if samples.is_empty() {
        return -90.0;
    }
    let sum: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum();
    let rms = (sum / samples.len() as f64).sqrt();
    if rms < 1.0 {
        return -90.0;
    }
    (20.0 * (rms / i16::MAX as f64).log10()) as f32
}

/// Float-сэмплы (−1..1) в i16 с насыщением.
pub fn f32_to_i16(samples: &[f32]) -> Vec<i16> {
    samples
        .iter()
        .map(|&s| (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16)
        .collect()
}

/// Приводит float-звук с `channels` каналами и частотой `rate` к нашему формату.
/// Каналы усредняются. Кратная частота (48, 32 кГц) понижается усреднением
/// соседних сэмплов — это заодно простой фильтр от наложения частот; для
/// речи его хватает. Некратная — линейной интерполяцией.
pub fn to_native(samples: &[f32], channels: usize, rate: u32) -> Vec<i16> {
    let channels = channels.max(1);
    let mono: Vec<f32> = samples
        .chunks_exact(channels)
        .map(|f| f.iter().sum::<f32>() / channels as f32)
        .collect();
    let out: Vec<f32> = if rate == SAMPLE_RATE {
        mono
    } else if rate.is_multiple_of(SAMPLE_RATE) {
        let k = (rate / SAMPLE_RATE) as usize;
        mono.chunks_exact(k)
            .map(|c| c.iter().sum::<f32>() / k as f32)
            .collect()
    } else {
        let step = rate as f64 / SAMPLE_RATE as f64;
        let n = (mono.len() as f64 / step) as usize;
        (0..n)
            .map(|i| {
                let x = i as f64 * step;
                let j = x as usize;
                let t = (x - j as f64) as f32;
                let a = mono[j];
                let b = *mono.get(j + 1).unwrap_or(&a);
                a + (b - a) * t
            })
            .collect()
    };
    f32_to_i16(&out)
}

fn header(data_bytes: u32) -> [u8; 44] {
    let mut h = [0u8; 44];
    h[0..4].copy_from_slice(b"RIFF");
    h[4..8].copy_from_slice(&(36 + data_bytes).to_le_bytes());
    h[8..12].copy_from_slice(b"WAVE");
    h[12..16].copy_from_slice(b"fmt ");
    h[16..20].copy_from_slice(&16u32.to_le_bytes());
    h[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM
    h[22..24].copy_from_slice(&1u16.to_le_bytes()); // моно
    h[24..28].copy_from_slice(&SAMPLE_RATE.to_le_bytes());
    h[28..32].copy_from_slice(&(SAMPLE_RATE * 2).to_le_bytes());
    h[32..34].copy_from_slice(&2u16.to_le_bytes());
    h[34..36].copy_from_slice(&16u16.to_le_bytes());
    h[36..40].copy_from_slice(b"data");
    h[40..44].copy_from_slice(&data_bytes.to_le_bytes());
    h
}

/// WAV целиком в памяти — для отправки куска на распознавание.
pub fn encode_wav(samples: &[i16]) -> Vec<u8> {
    let data_bytes = (samples.len() * 2) as u32;
    let mut out = Vec::with_capacity(44 + samples.len() * 2);
    out.extend_from_slice(&header(data_bytes));
    for s in samples {
        out.extend_from_slice(&s.to_le_bytes());
    }
    out
}

/// Разбирает WAV в нашем формате (16 кГц, моно, 16 бит). Другой формат —
/// ошибка: чужие файлы программа не читает, это для своих дорожек и тестов.
pub fn decode_wav(bytes: &[u8]) -> io::Result<Vec<i16>> {
    let bad = |m: &str| io::Error::new(io::ErrorKind::InvalidData, m.to_string());
    if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
        return Err(bad("не WAV"));
    }
    let mut pos = 12;
    let mut format_ok = false;
    while pos + 8 <= bytes.len() {
        let id = &bytes[pos..pos + 4];
        let size = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()) as usize;
        let body = pos + 8;
        let end = (body + size).min(bytes.len());
        if id == b"fmt " {
            let f = &bytes[body..end];
            if f.len() < 16 {
                return Err(bad("короткий fmt"));
            }
            let fmt = u16::from_le_bytes([f[0], f[1]]);
            let channels = u16::from_le_bytes([f[2], f[3]]);
            let rate = u32::from_le_bytes(f[4..8].try_into().unwrap());
            let bits = u16::from_le_bytes([f[14], f[15]]);
            format_ok = fmt == 1 && channels == 1 && rate == SAMPLE_RATE && bits == 16;
            if !format_ok {
                return Err(bad("нужен WAV 16 кГц, моно, 16 бит"));
            }
        } else if id == b"data" {
            if !format_ok {
                return Err(bad("data раньше fmt"));
            }
            return Ok(bytes[body..end]
                .as_chunks::<2>()
                .0
                .iter()
                .map(|c| i16::from_le_bytes(*c))
                .collect());
        }
        pos = body + size + (size & 1);
    }
    Err(bad("нет блока data"))
}

/// Дорожка встречи на диске. Заголовок дописывается при каждом `flush`, поэтому
/// файл читается, даже если программа упала посреди записи.
pub struct WavWriter {
    file: File,
    samples: u64,
}

impl WavWriter {
    pub fn create(path: &Path) -> io::Result<Self> {
        let mut file = File::create(path)?;
        file.write_all(&header(0))?;
        Ok(Self { file, samples: 0 })
    }

    pub fn write(&mut self, samples: &[i16]) -> io::Result<()> {
        let mut buf = Vec::with_capacity(samples.len() * 2);
        for s in samples {
            buf.extend_from_slice(&s.to_le_bytes());
        }
        self.file.write_all(&buf)?;
        self.samples += samples.len() as u64;
        Ok(())
    }

    pub fn flush(&mut self) -> io::Result<()> {
        let bytes = (self.samples * 2).min(u32::MAX as u64 - 36) as u32;
        self.file.seek(SeekFrom::Start(0))?;
        self.file.write_all(&header(bytes))?;
        self.file.seek(SeekFrom::End(0))?;
        self.file.flush()
    }

    pub fn duration_ms(&self) -> u64 {
        self.samples / SAMPLES_PER_MS
    }
}

impl Drop for WavWriter {
    fn drop(&mut self) {
        let _ = self.flush();
    }
}

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

    pub fn sine(ms: u64, amplitude: f32) -> Vec<i16> {
        (0..ms_to_samples(ms))
            .map(|i| {
                let t = i as f32 / SAMPLE_RATE as f32;
                (amplitude * (2.0 * std::f32::consts::PI * 440.0 * t).sin() * i16::MAX as f32) as i16
            })
            .collect()
    }

    #[test]
    fn wav_round_trip() {
        let s = sine(250, 0.5);
        let wav = encode_wav(&s);
        assert_eq!(wav.len(), 44 + s.len() * 2);
        assert_eq!(decode_wav(&wav).unwrap(), s);
    }

    #[test]
    fn foreign_wav_is_refused() {
        let mut wav = encode_wav(&[1, 2, 3]);
        wav[24..28].copy_from_slice(&44_100u32.to_le_bytes());
        assert!(decode_wav(&wav).is_err());
        assert!(decode_wav(b"not a wav at all").is_err());
    }

    #[test]
    fn writer_keeps_file_readable_after_each_flush() {
        let dir = std::env::temp_dir().join(format!("meet-wav-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("t.wav");
        let mut w = WavWriter::create(&path).unwrap();
        w.write(&sine(100, 0.3)).unwrap();
        w.flush().unwrap();
        assert_eq!(decode_wav(&std::fs::read(&path).unwrap()).unwrap().len(), 1600);
        w.write(&sine(100, 0.3)).unwrap();
        drop(w);
        assert_eq!(decode_wav(&std::fs::read(&path).unwrap()).unwrap().len(), 3200);
        std::fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn levels() {
        assert_eq!(level_dbfs(&[]), -90.0);
        assert_eq!(level_dbfs(&[0; 100]), -90.0);
        let loud = level_dbfs(&sine(100, 1.0));
        assert!(
            (loud + 3.0).abs() < 0.2,
            "синус полной амплитуды ≈ −3 dBFS, а не {loud}"
        );
        assert!(level_dbfs(&sine(100, 0.01)) < -40.0);
    }

    #[test]
    fn stereo_48k_becomes_mono_16k() {
        // 30 мс стерео 48 кГц: левый канал 0.5, правый 0.1.
        let stereo: Vec<f32> = (0..1440).flat_map(|_| [0.5f32, 0.1]).collect();
        let out = to_native(&stereo, 2, 48_000);
        assert_eq!(out.len(), 480);
        assert!(out
            .iter()
            .all(|&s| (s - (0.3 * i16::MAX as f32) as i16).abs() <= 1));
    }

    #[test]
    fn odd_rate_is_interpolated() {
        let mono = vec![0.25f32; 44_100];
        let out = to_native(&mono, 1, 44_100);
        assert_eq!(out.len(), 16_000);
        assert!(out
            .iter()
            .all(|&s| (s - (0.25 * i16::MAX as f32) as i16).abs() <= 1));
        assert_eq!(to_native(&[0.5, 0.5], 1, 16_000).len(), 2);
    }

    #[test]
    fn float_conversion_saturates() {
        assert_eq!(
            f32_to_i16(&[0.0, 1.0, -1.0, 2.0, -2.0]),
            vec![0, 32767, -32767, 32767, -32767]
        );
    }
}