skillok meeting
ad47f897f99f8450c300100150f8c07c6061d85a
Перейти к файлу
Код
meeting cratesaudiosrcpcm.rs
anc Ядро: встреча = заметка Markdown, всё по кнопке - Убрана автоматика: слежение за Zoom, окно согласия, трей, уведомления, автозапуск, живая расшифровка, разбор по JSON-схеме. - Встреча — note.md и дорожки звука; правка, дописывание, переименование, удаление. - «Добавить аудио»: чтение mp3, m4a, wav, aiff, flac, ogg (symphonia), потоковое приведение к 16 кГц моно. - «Расшифровать»: нарезка по паузам с обрезкой тишины в начале куска, до 4 кусков сразу, повтор, пометка нераспознанного, блок «Расшифровка». - Команды ИИ над заметкой: «Конспект», «Задачи», «Ревью», свой запрос; промпты файлами в prompts/. - «Записать» по кнопке: дорожки Zoom и микрофона в WAV, пауза, стоп. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> ad47f89 30 сентября 2026 г.
371 строка 13.4 КБ
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)
}

/// Потоковое приведение к нашему формату: звук подаётся кусками любой длины,
/// результат — тот же, что у [`to_native`] на всём звуке сразу. Нужен, чтобы
/// читать длинные файлы, не держа их в памяти целиком во float.
pub struct Resampler {
    channels: usize,
    rate: u32,
    /// Кратное понижение: сумма и число накопленных сэмплов неполной группы.
    acc: f32,
    acc_n: usize,
    /// Некратное: позиция следующего выходного сэмпла во входных и прошлый сэмпл.
    pos: f64,
    index: u64,
    prev: Option<f32>,
}

impl Resampler {
    pub fn new(channels: usize, rate: u32) -> Self {
        Self {
            channels: channels.max(1),
            rate,
            acc: 0.0,
            acc_n: 0,
            pos: 0.0,
            index: 0,
            prev: None,
        }
    }

    pub fn push(&mut self, interleaved: &[f32]) -> Vec<i16> {
        let c = self.channels;
        let mono = interleaved
            .chunks_exact(c)
            .map(|f| f.iter().sum::<f32>() / c as f32);
        let mut out = Vec::new();
        if self.rate == SAMPLE_RATE {
            out.extend(mono);
        } else if self.rate.is_multiple_of(SAMPLE_RATE) {
            let k = (self.rate / SAMPLE_RATE) as usize;
            for s in mono {
                self.acc += s;
                self.acc_n += 1;
                if self.acc_n == k {
                    out.push(self.acc / k as f32);
                    self.acc = 0.0;
                    self.acc_n = 0;
                }
            }
        } else {
            let step = self.rate as f64 / SAMPLE_RATE as f64;
            for s in mono {
                // Входной сэмпл номер `index`; выдаём все выходные с позицией в [index-1, index).
                if let Some(a) = self.prev {
                    while self.pos < self.index as f64 {
                        let t = (self.pos - (self.index - 1) as f64) as f32;
                        out.push(a + (s - a) * t);
                        self.pos += step;
                    }
                }
                self.prev = Some(s);
                self.index += 1;
            }
        }
        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 streaming_resampler_matches_whole_signal() {
        for (rate, channels) in [(48_000u32, 2usize), (44_100, 2), (16_000, 1), (22_050, 1)] {
            let n = rate as usize; // секунда звука
            let signal: Vec<f32> = (0..n * channels)
                .map(|i| ((i / channels) as f32 / rate as f32 * std::f32::consts::TAU * 220.0).sin() * 0.5)
                .collect();
            let whole = to_native(&signal, channels, rate);
            let mut r = Resampler::new(channels, rate);
            let mut streamed = Vec::new();
            // Куски неровной длины, кратные числу каналов, — как пакеты декодера.
            for part in signal.chunks(1151 * channels) {
                streamed.extend(r.push(part));
            }
            assert!(
                (streamed.len() as i64 - whole.len() as i64).abs() <= 1,
                "{rate}/{channels}: {} против {}",
                streamed.len(),
                whole.len()
            );
            let worst = streamed
                .iter()
                .zip(&whole)
                .map(|(a, b)| (*a as i32 - *b as i32).abs())
                .max()
                .unwrap();
            assert!(worst <= 2, "{rate}/{channels}: расхождение {worst}");
        }
    }

    #[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]
        );
    }
}