skillok meeting
955f445c6196281d40574259b51317a618aa5376
Перейти к файлу
Код
meeting src-taurisrcplayback.rs
anc Экран записи и проигрыватель: волна, источники, отметки, звук ↔ текст Запись: - Время — по записанному звуку, паузы не в счёт (позиция сессии записи, ms в событиях уровня, position_ms в recorder_status). - Живая «волна» последней минуты, «Микрофон / Звук компьютера — слышно / тишина», объём записи; «Остановить и расшифровать». - «★ Отметить момент»: отметка с подписью (Meeting.markers, add_marker / set_markers); видна в расшифровке и на проигрывателе. Проигрыватель (player.ts): - Полоса внизу экрана записи: играть, ±15 с, скорость до 2×, «волна» всей встречи (клик и протяжка — перемотка, отметки, текущее место), «★» — отметить место. Две дорожки записи играют вместе и подравниваются. Живёт дольше перерисовок экрана — звук не прерывается. - Звук читается с диска протоколом asset: только из папки записей (protocol-asset, scope на папку, media-src в CSP); «волна» считается один раз и кэшируется в peaks.json. - Время реплики и отметки — кнопка «слушать отсюда», звучащая реплика подсвечивается; пробел — играть/пауза, ←/→ — 5 с. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> b82e259 2 октября 2026 г.
playback.rs
//! Проигрыватель: что играть и «волна» всей встречи для прокрутки.
//!
//! Звук отдаётся интерфейсу самими файлами записи (протокол `asset:` Tauri):
//! у записи с двумя дорожками — оба WAV, интерфейс играет их вместе. «Волна»
//! — громкость по равным отрезкам всей записи: по ней видно, где говорили,
//! и по ней перематывают. Считается один раз и кладётся рядом
//! (`peaks.json`); двухчасовой mp3 декодируется секунды, WAV читается кусками.

use std::path::{Path, PathBuf};

use meet_audio::pcm::WavFile;
use serde::{Deserialize, Serialize};

/// Сколько отрезков в «волне»: шире окна программы не бывает.
pub const BUCKETS: usize = 1_600;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Peaks {
    /// Для какой длины записи посчитано — запись поменялась, считаем заново.
    pub duration_ms: u64,
    /// Громкость отрезков, 0…1, по самой громкой дорожке.
    pub peaks: Vec<f32>,
}

/// Громкость отрезков одной дорожки: максимум модуля сэмпла, 0…1.
fn track_peaks(path: &Path, total_samples: usize) -> std::io::Result<Vec<f32>> {
    let per = total_samples.div_ceil(BUCKETS).max(1);
    let mut out = vec![0f32; BUCKETS];
    let mut feed = |offset: usize, samples: &[i16]| {
        for (i, s) in samples.iter().enumerate() {
            let b = ((offset + i) / per).min(BUCKETS - 1);
            let v = (*s as f32).abs() / 32_768.0;
            if v > out[b] {
                out[b] = v;
            }
        }
    };
    match WavFile::open(path) {
        Ok(f) => {
            let block = 160_000;
            let mut pos = 0;
            while pos < f.len() {
                let part = f.read(pos, block)?;
                if part.is_empty() {
                    break;
                }
                feed(pos, &part);
                pos += part.len();
            }
        }
        Err(_) => {
            let samples = meet_audio::decode::decode_file(path)
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
            feed(0, &samples);
        }
    }
    Ok(out)
}

/// «Волна» записи: по всем дорожкам, из кэша, если он про ту же длину.
pub fn peaks(dir: &Path, tracks: &[PathBuf], duration_ms: u64) -> std::io::Result<Peaks> {
    let cache = dir.join("peaks.json");
    if let Ok(text) = std::fs::read_to_string(&cache) {
        if let Ok(p) = serde_json::from_str::<Peaks>(&text) {
            if p.duration_ms == duration_ms && p.peaks.len() == BUCKETS {
                return Ok(p);
            }
        }
    }
    let total = meet_audio::pcm::ms_to_samples(duration_ms).max(1);
    let mut merged = vec![0f32; BUCKETS];
    for t in tracks {
        for (m, v) in merged.iter_mut().zip(track_peaks(t, total)?) {
            *m = m.max(v);
        }
    }
    let p = Peaks {
        duration_ms,
        peaks: merged,
    };
    let _ = std::fs::write(&cache, serde_json::to_vec(&p).unwrap_or_default());
    Ok(p)
}

#[cfg(test)]
mod tests {
    use super::*;
    use meet_audio::pcm::{ms_to_samples, WavWriter};

    #[test]
    fn peaks_show_where_the_sound_is_and_are_cached() {
        let dir = tempfile::tempdir().unwrap();
        let wav = dir.path().join("me.wav");
        {
            let mut w = WavWriter::create(&wav).unwrap();
            w.write(&vec![0; ms_to_samples(5_000)]).unwrap();
            w.write(&vec![16_384; ms_to_samples(5_000)]).unwrap();
        }
        let p = peaks(dir.path(), std::slice::from_ref(&wav), 10_000).unwrap();
        assert_eq!(p.peaks.len(), BUCKETS);
        assert!(
            p.peaks[..BUCKETS / 2 - 1].iter().all(|v| *v == 0.0),
            "первая половина — тишина"
        );
        assert!(
            (p.peaks[BUCKETS - 1] - 0.5).abs() < 0.01,
            "вторая — половина громкости"
        );
        // Кэш: файл звука пропал — «волна» всё равно отдаётся.
        std::fs::remove_file(&wav).unwrap();
        assert_eq!(peaks(dir.path(), &[wav], 10_000).unwrap(), p);
    }
}