skillok meeting
57e443b222306e63eaf81d54fae10fa0a4f4ea1a
Перейти к файлу
Код
meeting cratesaudiosrcpcm.rs
anc Меню как у skillok.org, живая расшифровка и ответ ИИ, длинные записи (#13) - Логотип убран. Шапка и меню как у skillok.org: кнопка ☰ открывает панель слева (Главная, Записать, Добавить аудиофайл, записи с поиском, Настройки), закрытие крестиком, кликом мимо, Esc и переходом; крошки «Skillok Meet / запись». Список записей с поиском — на главной. - Во время записи один общий уровень вместо «Вы» и «Собеседники». - Расшифровка по ходу: реплики приходят по порядку времени (кусок уходит, когда готовы все куски раньше), текст растёт на экране. - Ответ ИИ потоком (stream: true): конспект, задачи, ревью и свой запрос пишутся на глазах. - Длинные записи: дорожка не читается в память целиком — нарезка блоками по 10 с, каждый кусок читается с диска перед отправкой; чужой файл один раз перегоняется во временный WAV. Прогон 2 ч × 2 дорожки с подделкой распознавания: 1600 кусков, последняя реплика 01:59:55, пик памяти 25 МБ. - Говорящий в файле без дорожек — «Спикер 1». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 3938872 30 сентября 2026 г.
pcm.rs
//! Формат звука во всей программе: 16 кГц, моно, 16 бит. Столько ждёт
//! Whisper, и в таком виде WASAPI отдаёт звук сам (автоконвертация), так что
//! своего ресемплера нет.

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

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"))
}

/// Дорожка в нашем формате на диске, читаемая по кускам. Двухчасовая встреча —
/// 230 МБ на дорожку: держать её в памяти целиком незачем, расшифровке
/// одновременно нужны несколько кусков по полминуты.
#[derive(Debug, Clone)]
pub struct WavFile {
    path: PathBuf,
    data_start: u64,
    samples: usize,
}

impl WavFile {
    pub fn open(path: &Path) -> io::Result<Self> {
        let bad = |m: &str| io::Error::new(io::ErrorKind::InvalidData, m.to_string());
        let mut file = File::open(path)?;
        let file_len = file.metadata()?.len();
        let mut riff = [0u8; 12];
        file.read_exact(&mut riff).map_err(|_| bad("не WAV"))?;
        if &riff[0..4] != b"RIFF" || &riff[8..12] != b"WAVE" {
            return Err(bad("не WAV"));
        }
        let mut pos = 12u64;
        let mut format_ok = false;
        while pos + 8 <= file_len {
            let mut head = [0u8; 8];
            file.seek(SeekFrom::Start(pos))?;
            file.read_exact(&mut head)?;
            let size = u32::from_le_bytes(head[4..8].try_into().unwrap()) as u64;
            let body = pos + 8;
            if &head[0..4] == b"fmt " {
                let mut f = [0u8; 16];
                file.read_exact(&mut f).map_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 &head[0..4] == b"data" {
                if !format_ok {
                    return Err(bad("data раньше fmt"));
                }
                // Как у `decode_wav`: заголовок обгонять файл не может.
                let bytes = size.min(file_len - body);
                return Ok(Self {
                    path: path.to_path_buf(),
                    data_start: body,
                    samples: (bytes / 2) as usize,
                });
            }
            pos = body + size + (size & 1);
        }
        Err(bad("нет блока data"))
    }

    /// Сколько сэмплов в дорожке.
    pub fn len(&self) -> usize {
        self.samples
    }

    pub fn is_empty(&self) -> bool {
        self.samples == 0
    }

    /// `n` сэмплов с сэмпла `start`; у конца дорожки — сколько есть.
    pub fn read(&self, start: usize, n: usize) -> io::Result<Vec<i16>> {
        let n = n.min(self.samples.saturating_sub(start));
        let mut file = File::open(&self.path)?;
        file.seek(SeekFrom::Start(self.data_start + start as u64 * 2))?;
        let mut bytes = vec![0u8; n * 2];
        file.read_exact(&mut bytes)?;
        Ok(bytes
            .as_chunks::<2>()
            .0
            .iter()
            .map(|c| i16::from_le_bytes(*c))
            .collect())
    }
}

/// Дорожка встречи на диске. Заголовок дописывается при каждом `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]
        );
    }

    #[test]
    fn wav_file_reads_slices_from_disk() {
        let path = std::env::temp_dir().join(format!("meet-wavfile-{}.wav", std::process::id()));
        let samples: Vec<i16> = (0..50_000).map(|i| (i % 3000) as i16 - 1500).collect();
        {
            let mut w = WavWriter::create(&path).unwrap();
            w.write(&samples).unwrap();
        }
        let f = WavFile::open(&path).unwrap();
        assert_eq!(f.len(), samples.len());
        assert_eq!(f.read(0, 10).unwrap(), samples[..10]);
        assert_eq!(f.read(12_345, 4_000).unwrap(), samples[12_345..16_345]);
        assert_eq!(
            f.read(49_990, 100).unwrap(),
            samples[49_990..],
            "у конца — сколько есть"
        );
        assert!(f.read(60_000, 10).unwrap().is_empty());
        std::fs::write(&path, b"RIFF....WAVEjunk").unwrap();
        assert!(WavFile::open(&path).is_err());
        let _ = std::fs::remove_file(&path);
    }
}