skillok meeting
e75bd240fcc7fc458270d9e00688eec8cce33cd9
Перейти к файлу
Код
meeting cratesaudiosrcchunker.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 г.
chunker.rs
//! Нарезка дорожки на куски для распознавания.
//!
//! Эндпоинт OpenRouter принимает файл целиком, поэтому живая расшифровка — это
//! куски по нескольку секунд. Резать надо по паузе, а не по таймеру: слово,
//! разрезанное пополам, Whisper распознаёт неверно в обоих кусках.
//!
//! Правила:
//! - кусок не короче `min_ms` и режется на первой паузе не короче `pause_ms`;
//! - дольше `max_ms` кусок не растёт — режется в самом тихом месте последних секунд;
//! - кусок, где речи меньше `min_speech_ms`, выбрасывается: тишина стоит денег,
//!   а на тишине Whisper сочиняет фразы («Продолжение следует…»);
//! - время у куска — от начала записи, дыры в потоке (устройство молчало)
//!   заполняются тишиной, чтобы время реплик не уезжало.
//!
//! Речь от тишины отличается по громкости кадра 20 мс относительно шумового
//! фона, который подстраивается сам: вентилятор ноутбука — не речь.

use crate::pcm::{ms_to_samples, samples_to_ms};

const FRAME_MS: u64 = 20;
/// Сколько кадров тишины оставить перед речью в начале куска (200 мс).
const LEAD_KEEP_FRAMES: usize = 10;

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Config {
    pub min_ms: u64,
    pub max_ms: u64,
    pub pause_ms: u64,
    pub min_speech_ms: u64,
    /// Кадр — речь, если громче фона во столько раз (по RMS).
    pub over_floor: f32,
    /// И не тише этого RMS в единицах i16 (≈ −47 dBFS).
    pub min_rms: f32,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            min_ms: 6_000,
            max_ms: 28_000,
            pause_ms: 700,
            min_speech_ms: 400,
            over_floor: 3.0,
            min_rms: 150.0,
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct Chunk {
    /// Начало куска от начала записи, мс.
    pub start_ms: u64,
    pub samples: Vec<i16>,
    /// Сколько в куске речи, мс.
    pub speech_ms: u64,
}

impl Chunk {
    pub fn duration_ms(&self) -> u64 {
        samples_to_ms(self.samples.len())
    }
}

pub struct Chunker {
    cfg: Config,
    /// Время первого сэмпла в `buf`, мс от начала записи.
    buf_start_ms: u64,
    buf: Vec<i16>,
    /// Разметка `buf` по кадрам: речь или нет.
    frames: Vec<bool>,
    /// Хвост, не набравший целого кадра.
    partial: usize,
    noise_floor: f32,
    /// Сколько тишины подряд в конце `buf`, мс.
    trailing_silence_ms: u64,
}

impl Chunker {
    pub fn new(cfg: Config) -> Self {
        Self {
            cfg,
            buf_start_ms: 0,
            buf: Vec::new(),
            frames: Vec::new(),
            partial: 0,
            noise_floor: 300.0,
            trailing_silence_ms: 0,
        }
    }

    /// Позиция конца принятого звука, мс от начала записи.
    pub fn position_ms(&self) -> u64 {
        self.buf_start_ms + samples_to_ms(self.buf.len())
    }

    /// Принимает звук, который закончился в момент `end_ms`. Если между прошлым
    /// концом и началом этого звука дыра больше 200 мс — она заполняется
    /// тишиной. Возвращает готовые куски (обычно ни одного).
    pub fn push(&mut self, end_ms: u64, samples: &[i16]) -> Vec<Chunk> {
        let start_ms = end_ms.saturating_sub(samples_to_ms(samples.len()));
        let pos = self.position_ms();
        let mut out = Vec::new();
        if start_ms > pos + 200 {
            // Длинную дыру не храним нулями: закрываем текущий кусок и сдвигаем начало.
            let gap = start_ms - pos;
            if gap > self.cfg.pause_ms {
                out.extend(self.cut_all());
                self.buf_start_ms = start_ms;
                self.trailing_silence_ms = 0;
            } else {
                out.extend(self.append(&vec![0; ms_to_samples(gap)]));
            }
        }
        out.extend(self.append(samples));
        out
    }

    /// Остаток по окончании записи.
    pub fn flush(&mut self) -> Vec<Chunk> {
        self.cut_all()
    }

    fn append(&mut self, samples: &[i16]) -> Vec<Chunk> {
        let mut out = Vec::new();
        let frame = ms_to_samples(FRAME_MS);
        self.buf.extend_from_slice(samples);
        self.partial += samples.len();
        while self.partial >= frame {
            let end = self.buf.len() - (self.partial - frame);
            let is_speech = self.classify_at(end - frame, end);
            self.frames.push(is_speech);
            self.partial -= frame;
            self.trailing_silence_ms = if is_speech {
                0
            } else {
                self.trailing_silence_ms + FRAME_MS
            };
            if let Some(chunk) = self.maybe_cut() {
                out.push(chunk);
            }
        }
        out
    }

    fn classify_at(&mut self, from: usize, to: usize) -> bool {
        let frame = &self.buf[from..to];
        let rms = (frame.iter().map(|&s| (s as f32) * (s as f32)).sum::<f32>() / frame.len() as f32).sqrt();
        let speech = rms > self.noise_floor * self.cfg.over_floor && rms > self.cfg.min_rms;
        // Фон: быстро опускается к тихим кадрам, медленно поднимается к громким.
        // Иначе длинная речь «подняла бы» фон и перестала считаться речью.
        if rms < self.noise_floor {
            self.noise_floor = self.noise_floor * 0.9 + rms * 0.1;
        } else if !speech {
            self.noise_floor = self.noise_floor * 0.995 + rms * 0.005;
        }
        self.noise_floor = self.noise_floor.max(20.0);
        speech
    }

    fn maybe_cut(&mut self) -> Option<Chunk> {
        let dur = self.frames.len() as u64 * FRAME_MS;
        if dur >= self.cfg.min_ms && self.trailing_silence_ms >= self.cfg.pause_ms {
            // Режем посередине паузы: хвост тишины уходит в этот кусок наполовину.
            let keep_frames = (self.trailing_silence_ms / FRAME_MS / 2) as usize;
            return self.cut_at(self.frames.len() - keep_frames);
        }
        if dur >= self.cfg.max_ms {
            // Паузы не было — ищем самое тихое место в последних 5 секундах.
            let look = (5_000 / FRAME_MS) as usize;
            let from = self.frames.len().saturating_sub(look);
            let at = (from..self.frames.len())
                .rev()
                .find(|&i| !self.frames[i])
                .unwrap_or(self.frames.len());
            return self.cut_at(at.max(1));
        }
        None
    }

    fn cut_at(&mut self, frame_index: usize) -> Option<Chunk> {
        let frame = ms_to_samples(FRAME_MS);
        let n = (frame_index * frame).min(self.buf.len());
        let samples: Vec<i16> = self.buf.drain(..n).collect();
        let flags: Vec<bool> = self.frames.drain(..frame_index.min(self.frames.len())).collect();
        let start_ms = self.buf_start_ms;
        self.buf_start_ms += samples_to_ms(samples.len());
        self.trailing_silence_ms = self.frames.iter().rev().take_while(|s| !**s).count() as u64 * FRAME_MS;
        let speech_ms = flags.iter().filter(|s| **s).count() as u64 * FRAME_MS;
        // Тишина в начале куска срезается, 200 мс перед речью остаются: время
        // реплик от распознавания отсчитывается от начала куска и так точнее,
        // а отправлять надо меньше.
        let lead = flags
            .iter()
            .position(|s| *s)
            .unwrap_or(flags.len())
            .saturating_sub(LEAD_KEEP_FRAMES);
        let skip = (lead * frame).min(samples.len());
        (speech_ms >= self.cfg.min_speech_ms && samples.len() > skip).then(|| Chunk {
            start_ms: start_ms + samples_to_ms(skip),
            samples: samples[skip..].to_vec(),
            speech_ms,
        })
    }

    fn cut_all(&mut self) -> Vec<Chunk> {
        let frame = ms_to_samples(FRAME_MS);
        // Неполный кадр в конце тоже уходит в кусок.
        let frames = self.frames.len() + usize::from(self.partial > 0);
        if self.partial > 0 {
            self.frames.push(false);
            self.buf.resize(frames * frame, 0);
            self.partial = 0;
        }
        let chunk = self.cut_at(frames);
        self.trailing_silence_ms = 0;
        chunk.into_iter().collect()
    }
}

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

    fn tone(ms: u64) -> Vec<i16> {
        (0..ms_to_samples(ms))
            .map(|i| {
                ((i as f32 / SAMPLE_RATE as f32 * 2.0 * std::f32::consts::PI * 300.0).sin() * 8000.0) as i16
            })
            .collect()
    }

    fn hiss(ms: u64) -> Vec<i16> {
        // Тихий «фон»: ±40 — примерно −58 dBFS.
        (0..ms_to_samples(ms))
            .map(|i| if i % 2 == 0 { 40 } else { -40 })
            .collect()
    }

    /// Подаёт звук блоками по 10 мс, как настоящий захват.
    fn feed(c: &mut Chunker, mut at_ms: u64, audio: &[i16]) -> (u64, Vec<Chunk>) {
        let mut out = Vec::new();
        for block in audio.chunks(ms_to_samples(10)) {
            at_ms += samples_to_ms(block.len());
            out.extend(c.push(at_ms, block));
        }
        (at_ms, out)
    }

    #[test]
    fn cuts_at_the_first_pause_after_min_length() {
        let mut c = Chunker::new(Config::default());
        let mut audio = hiss(500);
        audio.extend(tone(7_000));
        audio.extend(hiss(1_000));
        audio.extend(tone(3_000));
        let (_, chunks) = feed(&mut c, 0, &audio);
        assert_eq!(chunks.len(), 1, "режется в паузе после 7,5 с");
        let first = &chunks[0];
        assert_eq!(
            first.start_ms, 300,
            "тишина в начале срезана, 200 мс перед речью оставлены"
        );
        assert!(
            (7_200..=7_900).contains(&first.duration_ms()),
            "{}",
            first.duration_ms()
        );
        assert!(first.speech_ms >= 6_800, "{}", first.speech_ms);

        let rest = c.flush();
        assert_eq!(rest.len(), 1);
        assert_eq!(
            rest[0].start_ms, 8_300,
            "второй кусок — с начала речи минус 200 мс"
        );
        assert!(rest[0].speech_ms >= 2_800);
    }

    #[test]
    fn short_pauses_inside_min_length_do_not_cut() {
        let mut c = Chunker::new(Config::default());
        // Паузы по 0,9 с на 1,5 и 3,9 с — раньше min_ms; конец на речи.
        let mut audio = Vec::new();
        for _ in 0..2 {
            audio.extend(tone(1_500));
            audio.extend(hiss(900));
        }
        audio.extend(tone(1_500));
        let (_, chunks) = feed(&mut c, 0, &audio);
        assert!(chunks.is_empty(), "меньше min_ms — не режем");
        assert_eq!(c.flush().len(), 1);
    }

    #[test]
    fn long_speech_without_pause_is_cut_at_max() {
        let mut c = Chunker::new(Config::default());
        let (_, chunks) = feed(&mut c, 0, &tone(65_000));
        assert_eq!(chunks.len(), 2);
        for ch in &chunks {
            assert!(ch.duration_ms() <= 28_000, "{}", ch.duration_ms());
        }
        assert_eq!(chunks[1].start_ms, chunks[0].duration_ms());
    }

    #[test]
    fn silence_is_never_sent() {
        let mut c = Chunker::new(Config::default());
        let (_, chunks) = feed(&mut c, 0, &hiss(60_000));
        assert!(chunks.is_empty());
        assert!(c.flush().is_empty());
    }

    #[test]
    fn steady_noise_becomes_the_floor_not_speech() {
        // Шум погромче (≈ −35 dBFS), но ровный: фон подстраивается, речью не считается.
        let noise: Vec<i16> = (0..ms_to_samples(40_000))
            .map(|i| if i % 2 == 0 { 600 } else { -600 })
            .collect();
        let mut c = Chunker::new(Config::default());
        let (_, chunks) = feed(&mut c, 0, &noise);
        let speech: u64 = chunks.iter().chain(c.flush().iter()).map(|c| c.speech_ms).sum();
        assert!(speech < 1_000, "ровный шум распознан как речь: {speech} мс");
    }

    #[test]
    fn gaps_in_the_stream_keep_time_right() {
        let mut c = Chunker::new(Config::default());
        let (end, _) = feed(&mut c, 0, &tone(3_000));
        // Устройство молчало 10 секунд (так делает захват процесса, когда Zoom тих).
        let (_, chunks) = feed(&mut c, end + 10_000, &tone(2_000));
        assert_eq!(chunks.len(), 1, "длинная дыра закрывает кусок");
        assert_eq!(chunks[0].start_ms, 0);
        let rest = c.flush();
        assert_eq!(rest[0].start_ms, 13_000, "время после дыры — настоящее");
    }

    #[test]
    fn small_gaps_are_filled_with_silence() {
        let mut c = Chunker::new(Config::default());
        let (end, _) = feed(&mut c, 0, &tone(2_000));
        feed(&mut c, end + 300, &tone(1_000));
        assert_eq!(c.position_ms(), 3_300);
    }
}