skillok meeting
main
Перейти к файлу
Код
meeting src-taurisrctranscript.rs
anc Разбор по голосам: «Спикер 1», «Спикер 2»… на компьютере, без сети - crates/voices: отпечаток голоса реплики (WeSpeaker ResNet34 через sherpa-onnx, статические сборки под Windows и macOS) и разбиение снизу вверх со средней связью. Из отпечатков вычитается средний по записи: без этого у подкаста любые две реплики были похожи на 0,5–0,8. - После распознавания реплики «Собеседников» и файла раскладываются по голосам; микрофон — «Я». Отпечатки и реплики — в voices.json рядом с записью: «Говорящих: N» пересобирает разбиение без новой расшифровки. - Ход работы: этап «Разделяю по голосам» с процентом; по ходу распознавания у реплик разбираемых дорожек ещё нет имени. - Исправлено: файл без смены говорящего ложился одним абзацем 00:00:00 (подкаст 1 ч 52 мин — 170 КБ одной строкой). Абзац теперь — реплики одного говорящего без пауз больше 1,5 с и не длиннее ~600 знаков. У реплики появился конец (из ответа Whisper или до следующей). - Модель (26 МБ) не в git: scripts/fetch-models.sh с проверкой SHA-256, в сборку — ресурсом. Windows — со статической средой C (+crt-static, явная libucrt): установщику не нужен VC++ Redistributable; crate-type ядра — только rlib (cdylib нужен лишь мобильным платформам). - Проверено на подкасте Kuji 230 (1 ч 52 мин, трое в студии): трое, 48 % / 36 % / 15 % речи, отпечатки 1184 реплик за ~3 мин. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> d0d9d7d 30 сентября 2026 г.
201 строка 7.3 КБ
transcript.rs
//! Реплики расшифровки и то, как они ложатся в заметку.
//!
//! Говорящий сначала — дорожка звука: «Я» (микрофон), «Собеседники» (звук
//! компьютера) или файл целиком. Потом дорожки собеседников и файла
//! раскладываются по голосам (`speakers.rs`): «Спикер 1», «Спикер 2»… Людей
//! программа не узнаёт — только отличает один голос в записи от другого
//! (решение владельца 01.10.2026, см. README, «Согласие на запись»).

use std::borrow::Cow;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Speaker {
    Me,
    Others,
    /// Звук одним файлом: кто говорит, пока неизвестно.
    Unknown,
    /// Голос, найденный разбором по голосам: «Спикер {n}», с единицы.
    Voice {
        n: usize,
    },
}

impl Speaker {
    /// Подпись в расшифровке.
    pub fn label(&self) -> Cow<'static, str> {
        match self {
            Speaker::Me => "Я".into(),
            Speaker::Others => "Собеседники".into(),
            Speaker::Unknown => "Спикер 1".into(),
            Speaker::Voice { n } => format!("Спикер {n}").into(),
        }
    }

    /// Имя файла дорожки записи.
    pub fn track_file(&self) -> &'static str {
        match self {
            Speaker::Me => "me.wav",
            Speaker::Others => "others.wav",
            Speaker::Unknown | Speaker::Voice { .. } => "audio.wav",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Segment {
    /// Начало реплики от начала записи, мс.
    pub start_ms: u64,
    /// Конец реплики, мс.
    pub end_ms: u64,
    pub speaker: Speaker,
    pub text: String,
}

/// Заголовок раздела с расшифровкой в заметке.
pub const HEADING: &str = "Расшифровка";

/// Реплики одного говорящего склеиваются в абзац, только если между ними
/// пауза не больше этой…
const JOIN_GAP_MS: u64 = 1_500;
/// …и абзац ещё не длиннее этого. Иначе час одного голоса (лекция, файл
/// без разбора по голосам) ложился одним абзацем со временем 00:00:00.
const PARAGRAPH_CHARS: usize = 600;

/// Блок «Расшифровка» для заметки: абзац — реплика или несколько подряд
/// идущих реплик одного говорящего без пауз, со временем начала.
pub fn to_markdown(segments: &[Segment]) -> String {
    let mut out = String::from("## Расшифровка");
    let mut prev: Option<&Segment> = None;
    let mut paragraph = 0;
    for s in segments {
        if let Some(p) = prev {
            let joins = p.speaker == s.speaker
                && s.start_ms <= p.end_ms.max(p.start_ms) + JOIN_GAP_MS
                && paragraph < PARAGRAPH_CHARS;
            if joins {
                out.push(' ');
                out.push_str(&s.text);
                paragraph += s.text.chars().count() + 1;
                prev = Some(s);
                continue;
            }
        }
        out.push_str("\n\n");
        out.push_str(&line(s));
        paragraph = s.text.chars().count();
        prev = Some(s);
    }
    if segments.is_empty() {
        out.push_str("\n\nРечи в записи не нашлось.");
    }
    out.push('\n');
    out
}

/// Реплика абзацем: «**00:03:21 Я:** текст».
pub fn line(s: &Segment) -> String {
    format!(
        "**{} {}:** {}",
        format_timestamp(s.start_ms),
        s.speaker.label(),
        s.text
    )
}

/// Реплика по ходу расшифровки: у дорожек, которые потом разберутся по
/// голосам, имени ещё нет — только время и текст.
pub fn live_line(s: &Segment) -> String {
    match s.speaker {
        Speaker::Me => line(s),
        _ => format!("**{}** {}", format_timestamp(s.start_ms), s.text),
    }
}

pub fn format_timestamp(ms: u64) -> String {
    let total = ms / 1000;
    format!("{:02}:{:02}:{:02}", total / 3600, total / 60 % 60, total % 60)
}

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

    fn seg(ms: u64, speaker: Speaker, text: &str) -> Segment {
        Segment {
            start_ms: ms,
            end_ms: ms + 2_000,
            speaker,
            text: text.into(),
        }
    }

    #[test]
    fn markdown_merges_runs_of_one_speaker() {
        let md = to_markdown(&[
            seg(1_000, Speaker::Me, "Привет."),
            seg(3_500, Speaker::Me, "Начнём?"),
            seg(9_000, Speaker::Others, "Да."),
            seg(3_723_000, Speaker::Me, "Спасибо."),
        ]);
        assert_eq!(
            md,
            "## Расшифровка\n\n\
             **00:00:01 Я:** Привет. Начнём?\n\n\
             **00:00:09 Собеседники:** Да.\n\n\
             **01:02:03 Я:** Спасибо.\n"
        );
    }

    #[test]
    fn unknown_speaker_is_speaker_one_and_voices_are_numbered() {
        let md = to_markdown(&[
            seg(0, Speaker::Unknown, "Раз."),
            seg(2_500, Speaker::Voice { n: 2 }, "Два."),
        ]);
        assert_eq!(
            md,
            "## Расшифровка\n\n**00:00:00 Спикер 1:** Раз.\n\n**00:00:02 Спикер 2:** Два.\n"
        );
    }

    #[test]
    fn one_voice_for_an_hour_is_not_one_paragraph() {
        // Пауза больше 1,5 с — новый абзац.
        let md = to_markdown(&[
            seg(0, Speaker::Unknown, "Раз."),
            seg(20_000, Speaker::Unknown, "Два."),
        ]);
        assert_eq!(md.matches("**").count(), 4, "{md}");
        // Без пауз — абзац не длиннее ~600 знаков.
        let long = "слово ".repeat(40);
        let many: Vec<Segment> = (0..30)
            .map(|i| seg(i * 2_000, Speaker::Unknown, long.trim()))
            .collect();
        let md = to_markdown(&many);
        assert!(md.matches("**").count() / 2 >= 30 * 240 / 700, "{md}");
        assert!(md.split("\n\n").all(|p| p.chars().count() < 1_000));
    }

    #[test]
    fn live_line_has_no_name_until_voices_are_known() {
        assert_eq!(
            live_line(&seg(61_000, Speaker::Others, "Да.")),
            "**00:01:01** Да."
        );
        assert_eq!(live_line(&seg(61_000, Speaker::Me, "Да.")), "**00:01:01 Я:** Да.");
    }

    #[test]
    fn empty_transcript_says_so() {
        assert_eq!(to_markdown(&[]), "## Расшифровка\n\nРечи в записи не нашлось.\n");
    }

    #[test]
    fn speaker_serializes_for_the_interface() {
        assert_eq!(serde_json::to_string(&Speaker::Me).unwrap(), r#"{"kind":"me"}"#);
        assert_eq!(Speaker::Others.track_file(), "others.wav");
    }
}