skillok meeting

Разбор по голосам: «Спикер 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>

anc · 30 сентября 2026 г. d0d9d7d1b42763f9297927180d851d27dda50dcd
26 файлов
  • Показать разницуСкрыть разницу
    1 +# Windows: C/C++-среда выполнения — статически. Движок голосов (sherpa-onnx и
    2 +# onnxruntime) собран с /MT; смешивать его с динамической средой Rust нельзя.
    3 +# Заодно программе не нужен Visual C++ Redistributable.
    4 +[target.x86_64-pc-windows-msvc]
    5 +rustflags = ["-C", "target-feature=+crt-static"]
  • .gitignore +3
    Показать разницуСкрыть разницу
    6 6 dist-mock/
    7 7 .claude/
    8 8 release/
    9 +models/*.onnx
    10 +models/*.part
    11 +downloads/*.app
  • CHANGELOG.md +26
    Показать разницуСкрыть разницу
    1 1 # Изменения
    2 2
    3 +## 1.2 — не выпущена
    4 +
    5 +### Новое
    6 +
    7 +- **Кто говорит — по голосам.** После расшифровки программа разделяет речь на
    8 + «Спикер 1», «Спикер 2»… — у аудиофайла целиком и у собеседников в записи (микрофон —
    9 + по-прежнему «Я»). Работает на компьютере, без сети и без оплаты: модель WeSpeaker через
    10 + sherpa-onnx. На подкасте 1 ч 52 мин с тремя людьми в студии нашлось трое; разбор занял
    11 + около 3,5 минуты сверх расшифровки. Людей программа не узнаёт — только отличает голоса
    12 + внутри записи; имена — переименованием.
    13 +- **«Говорящих: N»** над расшифровкой — пересобрать разбиение на нужное число людей за
    14 + секунду, без новой расшифровки.
    15 +
    16 +### Исправлено
    17 +
    18 +- Файл без разбора по голосам ложился в заметку **одним абзацем** со временем 00:00:00
    19 + (двухчасовой подкаст — 170 КБ одной строкой). Теперь абзац — реплики одного говорящего
    20 + без пауз, не длиннее ~600 знаков.
    21 +
    22 +### Для сборки
    23 +
    24 +- Модель голосов (26 МБ) не в git: `./scripts/fetch-models.sh` кладёт её в
    25 + `models/voices.onnx`, сборка берёт её ресурсом.
    26 +- Windows собирается со статической средой C/C++ (`+crt-static`): движок голосов собран
    27 + так же, и Visual C++ Redistributable программе не нужен.
    28 +
    3 29 ## 1.1 — 01.10.2026
    4 30
    5 31 Главное: **расшифровка и результаты видны по ходу работы**, длинные записи больше не
  • Cargo.lock +473 −16
    Показать разницуСкрыть разницу
    145 145 "futures-lite",
    146 146 "parking",
    147 147 "polling",
    148 − "rustix",
    148 + "rustix 1.1.5",
    149 149 "slab",
    150 150 "windows-sys 0.61.2",
    151 151 ]
    176 176 "cfg-if",
    177 177 "event-listener",
    178 178 "futures-lite",
    179 − "rustix",
    179 + "rustix 1.1.5",
    180 180 ]
    181 181
    182 182 [[package]]
    202 202 "cfg-if",
    203 203 "futures-core",
    204 204 "futures-io",
    205 − "rustix",
    205 + "rustix 1.1.5",
    206 206 "signal-hook-registry",
    207 207 "slab",
    208 208 "windows-sys 0.61.2",
    279 279 checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
    280 280
    281 281 [[package]]
    282 +name = "bindgen"
    283 +version = "0.69.5"
    284 +source = "registry+https://github.com/rust-lang/crates.io-index"
    285 +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
    286 +dependencies = [
    287 + "bitflags 2.13.2",
    288 + "cexpr",
    289 + "clang-sys",
    290 + "itertools",
    291 + "lazy_static",
    292 + "lazycell",
    293 + "log",
    294 + "prettyplease",
    295 + "proc-macro2",
    296 + "quote",
    297 + "regex",
    298 + "rustc-hash 1.1.0",
    299 + "shlex 1.3.0",
    300 + "syn 2.0.119",
    301 + "which",
    302 +]
    303 +
    304 +[[package]]
    282 305 name = "bit-set"
    283 306 version = "0.8.0"
    284 307 source = "registry+https://github.com/rust-lang/crates.io-index"
    397 420 ]
    398 421
    399 422 [[package]]
    423 +name = "bzip2"
    424 +version = "0.4.4"
    425 +source = "registry+https://github.com/rust-lang/crates.io-index"
    426 +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
    427 +dependencies = [
    428 + "bzip2-sys",
    429 + "libc",
    430 +]
    431 +
    432 +[[package]]
    433 +name = "bzip2-sys"
    434 +version = "0.1.13+1.0.8"
    435 +source = "registry+https://github.com/rust-lang/crates.io-index"
    436 +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
    437 +dependencies = [
    438 + "cc",
    439 + "pkg-config",
    440 +]
    441 +
    442 +[[package]]
    400 443 name = "cairo-rs"
    401 444 version = "0.18.5"
    402 445 source = "registry+https://github.com/rust-lang/crates.io-index"
    471 514 checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
    472 515 dependencies = [
    473 516 "find-msvc-tools",
    474 − "shlex",
    517 + "shlex 2.0.1",
    475 518 ]
    476 519
    477 520 [[package]]
    481 524 checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
    482 525
    483 526 [[package]]
    527 +name = "cexpr"
    528 +version = "0.6.0"
    529 +source = "registry+https://github.com/rust-lang/crates.io-index"
    530 +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
    531 +dependencies = [
    532 + "nom",
    533 +]
    534 +
    535 +[[package]]
    484 536 name = "cfb"
    485 537 version = "0.14.0"
    486 538 source = "registry+https://github.com/rust-lang/crates.io-index"
    520 572 ]
    521 573
    522 574 [[package]]
    575 +name = "clang-sys"
    576 +version = "1.9.1"
    577 +source = "registry+https://github.com/rust-lang/crates.io-index"
    578 +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
    579 +dependencies = [
    580 + "glob",
    581 + "libc",
    582 + "libloading 0.8.9",
    583 +]
    584 +
    585 +[[package]]
    586 +name = "cmake"
    587 +version = "0.1.58"
    588 +source = "registry+https://github.com/rust-lang/crates.io-index"
    589 +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
    590 +dependencies = [
    591 + "cc",
    592 +]
    593 +
    594 +[[package]]
    523 595 name = "combine"
    524 596 version = "4.6.8"
    525 597 source = "registry+https://github.com/rust-lang/crates.io-index"
    Разница обрезана: показаны первые 128 строк.
  • Cargo.toml +1 −1
    Показать разницуСкрыть разницу
    1 1 [workspace]
    2 2 resolver = "2"
    3 −members = ["src-tauri", "crates/audio"]
    3 +members = ["src-tauri", "crates/audio", "crates/voices"]
  • README.md +26 −7
    Показать разницуСкрыть разницу
    55 55 - Готовый файл — перетащить в окно или «Добавить аудиофайл»: mp3, m4a, wav, aiff, flac,
    56 56 ogg.
    57 57 2. **Расшифровка** — кнопка «Расшифровать». Текст появляется на экране по ходу работы,
    58 − сверху вниз, рядом — процент готовности; можно «Отменить». Часовые и двухчасовые записи
    59 − расшифровываются так же.
    58 + сверху вниз, рядом — процент готовности; можно «Отменить». Потом программа
    59 + **разделяет речь по голосам**: «Спикер 1», «Спикер 2»… — и у файла, и у собеседников
    60 + в записи (ваш микрофон — всегда «Я»). Если людей нашлось не столько, сколько было,
    61 + — «Говорящих: 3» над расшифровкой пересоберёт разбиение за секунду. Часовые и
    62 + двухчасовые записи расшифровываются так же.
    60 63 3. **Результат** — вкладки:
    61 − - «Расшифровка» — реплики с временем и говорящим: «Я» и «Собеседники» у записи,
    62 − «Спикер 1» у файла. **Имя говорящего можно переименовать**: нажмите на него, впишите
    63 − «Ольга» — имя сменится во всей записи, в конспекте и задачах;
    64 + - «Расшифровка» — реплики с временем и говорящим: «Я» и «Спикер 1, 2, 3…».
    65 + **Имя говорящего можно переименовать**: нажмите на него, впишите «Ольга» — имя
    66 + сменится во всей записи, в конспекте и задачах;
    64 67 - «Конспект», «Задачи», «Ревью» и «Свой запрос» («напиши письмо клиенту по итогам») —
    65 68 кнопками в полосе «Дальше» под расшифровкой. Ответ ИИ пишется на глазах.
    66 69
    80 83 (до 4 кусков сразу) │
    81 84 реплики по ходу — на экран ◄──────┤
    82 85 ▼
    83 − note.md: ## Расшифровка
    86 + реплики ─► отпечаток голоса каждой (WeSpeaker, на компьютере)
    87 + ─► группы ─► «Спикер 1», «Спикер 2»…
    88 + ▼
    89 + note.md: ## Расшифровка, voices.json
    84 90
    85 91 Команда ИИ: note.md ─► OpenRouter: Claude (потоком) ─► note.md: ## Задачи / ## Конспект / …
    86 92 ```
    148 154 - **РФ, 152-ФЗ.** Голос — персональные данные, тайная запись запрещена. Если голос
    149 155 используется для узнавания человека, это биометрия, и нужно письменное согласие
    150 156 ([Консультант](https://www.consultant.ru/law/podborki/zapis_telefonnogo_razgovora_personalnye_dannye/)).
    151 − Поэтому отпечатки голоса мы не строим: говорящий — только дорожка звука.
    157 +- **Отпечатки голоса (с версии 1.2).** Чтобы разделить расшифровку на «Спикер 1»,
    158 + «Спикер 2»…, программа строит отпечаток голоса каждой реплики — на этом компьютере,
    159 + без сети — и сравнивает реплики **одной записи** между собой. Людей она не узнаёт:
    160 + отпечатки ни с чем внешним не сравниваются, имена ставит сам человек переименованием.
    161 + Отпечатки лежат рядом с записью (`voices.json`) и удаляются вместе с ней. Решение
    162 + владельца 01.10.2026. Предупреждать участников о записи всё равно нужно; как это
    163 + соотносится с требованиями к биометрии — вопрос к юристу, программа его не решает.
    152 164 - **ЕС (GDPR)** требует уведомления. В ряде штатов США нужно согласие всех сторон.
    153 165
    154 166 ---
    181 193
    182 194 ```
    183 195 npm install
    196 +./scripts/fetch-models.sh # модель голосов (26 МБ) в models/voices.onnx — один раз
    184 197 npm run tauri dev # приложение целиком: ядро + интерфейс
    185 198 npm run dev # только интерфейс в браузере, http://localhost:1420, с заглушкой ядра
    186 199 npm run typecheck # проверка типов интерфейса
    197 210 open "src-tauri/target/release/bundle/macos/Skillok Meet.app"
    198 211 ```
    199 212
    213 +Разбор по голосам на своём файле (печатает, сколько говорящих при разных порогах):
    214 +
    215 +```
    216 +cargo run --release -p meet-voices --example voices -- файл.mp3 0 600 models/voices.onnx
    217 +```
    218 +
    200 219 Проба декодера и нарезки на любом файле:
    201 220
    202 221 ```
  • Показать разницуСкрыть разницу
    1 +[package]
    2 +name = "meet-voices"
    3 +version = "1.1.0"
    4 +description = "Кто говорит: отпечаток голоса для куска звука (sherpa-onnx) и разбиение реплик по говорящим"
    5 +edition = "2021"
    6 +rust-version = "1.90"
    7 +
    8 +[dependencies]
    9 +thiserror = "2"
    10 +# Готовые статические сборки sherpa-onnx и onnxruntime под Windows и macOS:
    11 +# отдельных DLL в установщике не нужно.
    12 +sherpa-rs-sys = { version = "0.6.8", default-features = false, features = ["download-binaries", "static"] }
    13 +
    14 +[dev-dependencies]
    15 +meet-audio = { path = "../audio" }
  • Показать разницуСкрыть разницу
    1 +fn main() {
    2 + // Примеры тянут meet-audio, а тот на macOS — Swift-мост ScreenCaptureKit.
    3 + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
    4 + println!("cargo:rustc-link-arg-examples=-Wl,-rpath,/usr/lib/swift");
    5 + }
    6 + // onnxruntime пишет события в журнал Windows (ETW) и читает реестр.
    7 + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
    8 + println!("cargo:rustc-link-lib=advapi32");
    9 + // Со статической средой C (+crt-static, см. .cargo/config.toml) Rust
    10 + // подключает libcmt, но не статическую UCRT, а onnxruntime берёт из неё
    11 + // strlen, round, pow и другие.
    12 + let features = std::env::var("CARGO_CFG_TARGET_FEATURE").unwrap_or_default();
    13 + if features.split(',').any(|f| f == "crt-static") {
    14 + println!("cargo:rustc-link-lib=libucrt");
    15 + }
    16 + }
    17 +}
  • Показать разницуСкрыть разницу
    1 +//! Подбор порога на настоящей записи:
    2 +//! `cargo run --release -p meet-voices --example voices -- <файл> <с какой секунды> <сколько секунд> <модель.onnx>`
    3 +//! Режет звук на реплики по паузам, снимает отпечатки и печатает разбиение
    4 +//! при нескольких порогах: сколько говорящих и чья доля речи.
    5 +
    6 +use meet_audio::chunker::{Chunker, Config};
    7 +use meet_audio::pcm::samples_to_ms;
    8 +use meet_voices::{assign, Count, Extractor};
    9 +use std::time::Instant;
    10 +
    11 +fn main() {
    12 + let a: Vec<String> = std::env::args().collect();
    13 + let pcm = meet_audio::decode::decode_file(std::path::Path::new(&a[1])).unwrap();
    14 + let from = (a[2].parse::<f32>().unwrap() * 16_000.0) as usize;
    15 + let to = (from + (a[3].parse::<f32>().unwrap() * 16_000.0) as usize).min(pcm.len());
    16 + let part = &pcm[from..to];
    17 + // Реплики — куски между паузами от 0,3 с, длиной 1–8 с.
    18 + let cfg = Config {
    19 + min_ms: 1_000,
    20 + max_ms: 8_000,
    21 + pause_ms: 300,
    22 + min_speech_ms: 300,
    23 + ..Config::default()
    24 + };
    25 + let mut c = Chunker::new(cfg);
    26 + let mut chunks = c.push(samples_to_ms(part.len()), part);
    27 + chunks.extend(c.flush());
    28 + let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
    29 + let mut ex = Extractor::open(std::path::Path::new(&a[4]), threads).unwrap();
    30 + let t = Instant::now();
    31 + let emb: Vec<Vec<f32>> = chunks.iter().map(|ch| ex.embed(&ch.samples)).collect();
    32 + let sec: Vec<f32> = chunks.iter().map(|ch| ch.duration_ms() as f32 / 1000.0).collect();
    33 + let talk: f32 = sec.iter().sum();
    34 + eprintln!(
    35 + "{} реплик, {talk:.0} с речи, отпечатки за {:.1} с ({threads} потоков)",
    36 + chunks.len(),
    37 + t.elapsed().as_secs_f32()
    38 + );
    39 + let norm: Vec<Vec<f32>> = emb
    40 + .iter()
    41 + .filter(|e| !e.is_empty())
    42 + .map(|e| {
    43 + let n = e.iter().map(|x| x * x).sum::<f32>().sqrt();
    44 + e.iter().map(|x| x / n).collect()
    45 + })
    46 + .collect();
    47 + let mut hist = [0usize; 10];
    48 + for i in 0..norm.len() {
    49 + for j in i + 1..norm.len() {
    50 + let s: f32 = norm[i].iter().zip(&norm[j]).map(|(a, b)| a * b).sum();
    51 + hist[((s.max(0.0) * 10.0) as usize).min(9)] += 1;
    52 + }
    53 + }
    54 + eprintln!("близость пар по десятым: {hist:?}");
    55 + let thrs: Vec<f32> = std::env::var("THR")
    56 + .map(|v| v.split(',').map(|x| x.parse().unwrap()).collect())
    57 + .unwrap_or(vec![0.0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35]);
    58 + for thr in thrs {
    59 + let l = assign(&emb, &sec, Count::Auto { threshold: thr });
    60 + let k = l.iter().max().map(|m| m + 1).unwrap_or(0);
    61 + let mut share = vec![0f32; k];
    62 + for (i, s) in l.iter().zip(&sec) {
    63 + share[*i] += s;
    64 + }
    65 + let share: Vec<String> = share
    66 + .iter()
    67 + .map(|s| format!("{:.0}%", s / talk * 100.0))
    68 + .collect();
    69 + eprintln!("порог {thr}: {k} говорящих {share:?}");
    70 + if a.get(5).map(|s| s.parse::<f32>().unwrap()) == Some(thr) {
    71 + for (ch, s) in chunks.iter().zip(&l) {
    72 + let t = from as u64 / 16 + ch.start_ms;
    73 + println!(
    74 + "{:02}:{:02}:{:02} {:>4.1}s S{s}",
    75 + t / 3_600_000,
    76 + t / 60_000 % 60,
    77 + t / 1000 % 60,
    78 + ch.duration_ms() as f32 / 1000.0
    79 + );
    80 + }
    81 + }
    82 + }
    83 +}
  • Показать разницуСкрыть разницу
    1 +//! Разбиение реплик по говорящим по отпечаткам голоса.
    2 +//!
    3 +//! Отпечаток — вектор, у одного голоса векторы смотрят в одну сторону
    4 +//! (косинусная близость высокая), у разных — в разные. Сколько людей в записи,
    5 +//! заранее неизвестно, поэтому разбиение — снизу вверх: каждая реплика сначала
    6 +//! сама по себе, потом сливаются две самые похожие группы, и так, пока похожих
    7 +//! не останется (или пока групп не станет столько, сколько сказал человек).
    8 +//!
    9 +//! Короткая реплика («Ага», «Да») даёт шумный отпечаток: в слиянии участвуют
    10 +//! только реплики от [`MIN_ANCHOR_S`] секунд, остальные потом просто
    11 +//! прикладываются к ближайшему говорящему.
    12 +
    13 +/// Сколько говорящих искать.
    14 +#[derive(Debug, Clone, Copy, PartialEq)]
    15 +pub enum Count {
    16 + /// Сколько получится: группы сливаются, пока средняя близость между ними не
    17 + /// ниже порога.
    18 + Auto { threshold: f32 },
    19 + /// Ровно столько (если реплик хватает).
    20 + Fixed(usize),
    21 +}
    22 +
    23 +/// Порог по умолчанию для модели WeSpeaker ResNet34 на отпечатках без
    24 +/// общего сдвига записи (см. `centered`). Подобран на подкасте с тремя
    25 +/// людьми в студии (`examples/voices.rs`): весь выпуск 1 ч 52 мин — трое
    26 +/// (49 %, 37 %, 14 % речи); при −0,05 двое сливаются, при 0,05 — пятеро.
    27 +pub const DEFAULT_THRESHOLD: f32 = 0.0;
    28 +
    29 +/// Реплики короче — не опора для разбиения, их отпечаток шумный.
    30 +pub const MIN_ANCHOR_S: f32 = 1.2;
    31 +
    32 +/// Больше опор не берём: слияние — квадрат от их числа по памяти и больше по
    33 +/// времени. Двухчасовая встреча — около 2000 реплик; 800 самых длинных
    34 +/// хватает, чтобы услышать каждого, кто говорил хоть минуту.
    35 +const MAX_ANCHORS: usize = 800;
    36 +
    37 +/// Говорящий, у которого меньше этой доли речи, в автоматическом режиме
    38 +/// растворяется в остальных: это чаще обрывок чужого голоса, смех, музыка.
    39 +const MIN_SHARE: f32 = 0.03;
    40 +
    41 +fn dot(a: &[f32], b: &[f32]) -> f32 {
    42 + a.iter().zip(b).map(|(x, y)| x * y).sum()
    43 +}
    44 +
    45 +fn normalized(mut v: Vec<f32>) -> Vec<f32> {
    46 + let n = dot(&v, &v).sqrt();
    47 + if n > 0.0 {
    48 + v.iter_mut().for_each(|x| *x /= n);
    49 + }
    50 + v
    51 +}
    52 +
    53 +/// Номер говорящего для каждой реплики: 0, 1, 2… по порядку первого
    54 +/// появления (реплики — по времени). `embeddings` — отпечатки, `seconds` —
    55 +/// длина реплик. Пустой отпечаток (реплика слишком короткая, чтобы его
    56 +/// снять) получает говорящего соседней реплики.
    57 +pub fn assign(embeddings: &[Vec<f32>], seconds: &[f32], count: Count) -> Vec<usize> {
    58 + assert_eq!(embeddings.len(), seconds.len());
    59 + let n = embeddings.len();
    60 + if n == 0 {
    61 + return Vec::new();
    62 + }
    63 + let emb = centered(embeddings, seconds);
    64 + let usable = |i: usize| !emb[i].is_empty();
    65 +
    66 + // Опоры: самые длинные реплики с отпечатком.
    67 + let mut anchors: Vec<usize> = (0..n)
    68 + .filter(|&i| usable(i) && seconds[i] >= MIN_ANCHOR_S)
    69 + .collect();
    70 + if anchors.is_empty() {
    71 + anchors = (0..n).filter(|&i| usable(i)).collect();
    72 + }
    73 + if anchors.is_empty() {
    74 + return vec![0; n];
    75 + }
    76 + anchors.sort_by(|&a, &b| seconds[b].total_cmp(&seconds[a]));
    77 + anchors.truncate(MAX_ANCHORS);
    78 +
    79 + let groups = merge(&emb, &anchors, count);
    80 + let mut centroids = centroids(&emb, seconds, &groups);
    81 + if let Count::Auto { .. } = count {
    82 + dissolve_small(&mut centroids, &emb, seconds, &anchors);
    83 + }
    84 +
    85 + // Каждую реплику — к ближайшему говорящему; два прохода уточнения, как в
    86 + // k-средних: центры пересчитываются по всем репликам, а не только опорам.
    87 + let mut labels = vec![usize::MAX; n];
    88 + for _ in 0..2 {
    89 + for i in (0..n).filter(|&i| usable(i)) {
    90 + labels[i] = nearest(&centroids, &emb[i]);
    91 + }
    92 + let groups: Vec<Vec<usize>> = (0..centroids.len())
    93 + .map(|k| (0..n).filter(|&i| labels[i] == k).collect())
    94 + .collect();
    95 + let fresh = centroids_of(&emb, seconds, &groups);
    96 + for (c, f) in centroids.iter_mut().zip(fresh) {
    97 + if let Some(f) = f {
    98 + *c = f;
    99 + }
    100 + }
    101 + }
    102 + for i in (0..n).filter(|&i| usable(i)) {
    103 + labels[i] = nearest(&centroids, &emb[i]);
    104 + }
    105 + fill_gaps(&mut labels);
    106 + renumber(&labels)
    107 +}
    108 +
    109 +/// Отпечатки без общего для всей записи: из каждого вычитается средний.
    110 +/// У записи есть своё «звучание» — комната, микрофон, сжатие, фоновая
    111 +/// музыка, — и оно одинаково сдвигает отпечатки всех говорящих: на подкасте
    112 +/// близость любых двух реплик была 0,5–0,8. Без общего сдвига остаются
    113 +/// различия голосов, и один порог годится для разных записей.
    114 +fn centered(embeddings: &[Vec<f32>], seconds: &[f32]) -> Vec<Vec<f32>> {
    115 + let unit: Vec<Vec<f32>> = embeddings.iter().map(|e| normalized(e.clone())).collect();
    116 + let Some(dim) = unit.iter().map(Vec::len).find(|&d| d > 0) else {
    117 + return unit;
    118 + };
    119 + let mut mean = vec![0f32; dim];
    120 + let mut total = 0f32;
    121 + for (e, s) in unit.iter().zip(seconds).filter(|(e, _)| e.len() == dim) {
    122 + let w = s.max(0.1);
    123 + mean.iter_mut().zip(e).for_each(|(m, x)| *m += x * w);
    124 + total += w;
    125 + }
    126 + mean.iter_mut().for_each(|m| *m /= total);
    127 + unit.into_iter()
    128 + .map(|e| {
    129 + if e.len() != dim {
    130 + return Vec::new();
    131 + }
    132 + normalized(e.iter().zip(&mean).map(|(x, m)| x - m).collect())
    133 + })
    134 + .collect()
    135 +}
    136 +
    137 +/// Слияние снизу вверх со средней связью: близость двух групп — средняя
    138 +/// близость их реплик.
    139 +fn merge(emb: &[Vec<f32>], anchors: &[usize], count: Count) -> Vec<Vec<usize>> {
    140 + let m = anchors.len();
    141 + let mut sim = vec![0f32; m * m];
    142 + for a in 0..m {
    143 + for b in a + 1..m {
    144 + let s = dot(&emb[anchors[a]], &emb[anchors[b]]);
    145 + sim[a * m + b] = s;
    146 + sim[b * m + a] = s;
    147 + }
    148 + }
    149 + let mut members: Vec<Vec<usize>> = anchors.iter().map(|&i| vec![i]).collect();
    150 + let mut alive = vec![true; m];
    151 + let mut left = m;
    152 + let target = match count {
    153 + Count::Fixed(k) => k.max(1),
    154 + Count::Auto { .. } => 1,
    155 + };
    Разница обрезана: показаны первые 155 строк.
  • Показать разницуСкрыть разницу
    1 +//! Отпечаток голоса: вектор модели WeSpeaker для куска звука 16 кГц.
    2 +
    3 +use std::ffi::CString;
    4 +use std::path::Path;
    5 +
    6 +#[derive(Debug, thiserror::Error)]
    7 +pub enum Error {
    8 + #[error("модель голосов не найдена: {0}")]
    9 + NoModel(String),
    10 + #[error("модель голосов не загрузилась: {0}")]
    11 + Load(String),
    12 +}
    13 +
    14 +/// Снимает отпечатки. Держит модель в памяти; создаётся на одну расшифровку.
    15 +pub struct Extractor {
    16 + raw: *const sherpa_rs_sys::SherpaOnnxSpeakerEmbeddingExtractor,
    17 + dim: usize,
    18 +}
    19 +
    20 +// Указатель используется из одного потока за раз: отпечатки снимаются
    21 +// последовательно в `spawn_blocking`, модель сама раскладывает работу по ядрам.
    22 +unsafe impl Send for Extractor {}
    23 +
    24 +impl Extractor {
    25 + pub fn open(model: &Path, threads: usize) -> Result<Self, Error> {
    26 + if !model.is_file() {
    27 + return Err(Error::NoModel(model.display().to_string()));
    28 + }
    29 + let path =
    30 + CString::new(model.to_string_lossy().as_bytes()).map_err(|e| Error::Load(e.to_string()))?;
    31 + let cpu = CString::new("cpu").unwrap();
    32 + let config = sherpa_rs_sys::SherpaOnnxSpeakerEmbeddingExtractorConfig {
    33 + model: path.as_ptr(),
    34 + num_threads: threads.clamp(1, 16) as i32,
    35 + debug: 0,
    36 + provider: cpu.as_ptr(),
    37 + };
    38 + let raw = unsafe { sherpa_rs_sys::SherpaOnnxCreateSpeakerEmbeddingExtractor(&config) };
    39 + if raw.is_null() {
    40 + return Err(Error::Load(model.display().to_string()));
    41 + }
    42 + let dim = unsafe { sherpa_rs_sys::SherpaOnnxSpeakerEmbeddingExtractorDim(raw) } as usize;
    43 + Ok(Self { raw, dim })
    44 + }
    45 +
    46 + /// Отпечаток куска (16 кГц, моно). Пусто, если звука слишком мало, чтобы
    47 + /// модель что-то сказала.
    48 + pub fn embed(&mut self, samples: &[i16]) -> Vec<f32> {
    49 + let x: Vec<f32> = samples.iter().map(|&s| s as f32 / 32768.0).collect();
    50 + unsafe {
    51 + let stream = sherpa_rs_sys::SherpaOnnxSpeakerEmbeddingExtractorCreateStream(self.raw);
    52 + sherpa_rs_sys::SherpaOnnxOnlineStreamAcceptWaveform(stream, 16_000, x.as_ptr(), x.len() as i32);
    53 + sherpa_rs_sys::SherpaOnnxOnlineStreamInputFinished(stream);
    54 + let out = if sherpa_rs_sys::SherpaOnnxSpeakerEmbeddingExtractorIsReady(self.raw, stream) != 0 {
    55 + let p = sherpa_rs_sys::SherpaOnnxSpeakerEmbeddingExtractorComputeEmbedding(self.raw, stream);
    56 + let v = std::slice::from_raw_parts(p, self.dim).to_vec();
    57 + sherpa_rs_sys::SherpaOnnxSpeakerEmbeddingExtractorDestroyEmbedding(p);
    58 + v
    59 + } else {
    60 + Vec::new()
    61 + };
    62 + sherpa_rs_sys::SherpaOnnxDestroyOnlineStream(stream);
    63 + out
    64 + }
    65 + }
    66 +}
    67 +
    68 +impl Drop for Extractor {
    69 + fn drop(&mut self) {
    70 + unsafe { sherpa_rs_sys::SherpaOnnxDestroySpeakerEmbeddingExtractor(self.raw) };
    71 + }
    72 +}
  • Показать разницуСкрыть разницу
    1 +//! Кто говорит. Для каждой реплики снимается отпечаток голоса моделью
    2 +//! WeSpeaker (через sherpa-onnx, на процессоре, без сети), потом реплики
    3 +//! раскладываются по говорящим: «Спикер 1», «Спикер 2»…
    4 +//!
    5 +//! Людей программа не узнаёт и отпечатки не хранит дольше, чем живёт запись:
    6 +//! они нужны только, чтобы отличить один голос в записи от другого.
    7 +
    8 +pub mod cluster;
    9 +mod extractor;
    10 +
    11 +pub use cluster::{assign, Count, DEFAULT_THRESHOLD};
    12 +pub use extractor::{Error, Extractor};
  • Показать разницуСкрыть разницу
    1 +#!/bin/sh
    2 +# Модель голосов для разбора «кто говорит» — WeSpeaker ResNet34 (VoxCeleb),
    3 +# сборка sherpa-onnx. Кладётся в models/voices.onnx и попадает в установщик
    4 +# ресурсом (src-tauri/tauri.conf.json → bundle.resources). В git её нет: 26 МБ.
    5 +# Лицензия модели — Apache-2.0 (https://github.com/wenet-e2e/wespeaker).
    6 +set -eu
    7 +cd "$(dirname "$0")/.."
    8 +url="https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx"
    9 +sha="e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012"
    10 +mkdir -p models
    11 +if [ -f models/voices.onnx ] && [ "$(shasum -a 256 models/voices.onnx | cut -d' ' -f1)" = "$sha" ]; then
    12 + echo "models/voices.onnx уже на месте"
    13 + exit 0
    14 +fi
    15 +curl -fL --retry 3 -o models/voices.onnx.part "$url"
    16 +got="$(shasum -a 256 models/voices.onnx.part | cut -d' ' -f1)"
    17 +if [ "$got" != "$sha" ]; then
    18 + rm -f models/voices.onnx.part
    19 + echo "контрольная сумма не совпала: $got" >&2
    20 + exit 1
    21 +fi
    22 +mv models/voices.onnx.part models/voices.onnx
    23 +echo "models/voices.onnx скачана"
  • Показать разницуСкрыть разницу
    7 7
    8 8 [lib]
    9 9 name = "skillok_meet_lib"
    10 −crate-type = ["staticlib", "cdylib", "rlib"]
    10 +# Только для настольных систем: staticlib и cdylib Tauri нужны для iOS и Android,
    11 +# а cdylib под Windows ещё и не компонуется со статической средой C (+crt-static).
    12 +crate-type = ["rlib"]
    11 13
    12 14 [build-dependencies]
    13 15 tauri-build = { version = "2.7", features = [] }
    17 19 tauri-plugin-dialog = "2"
    18 20 tauri-plugin-opener = "2"
    19 21 meet-audio = { path = "../crates/audio" }
    22 +meet-voices = { path = "../crates/voices" }
    20 23 base64 = "0.22"
    21 24 tokio = { version = "1", features = ["rt", "sync", "time", "macros"] }
    22 25 tokio-util = "0.7"
  • Показать разницуСкрыть разницу
    19 19 use crate::openrouter::{Client, KeyInfo, SttSegment};
    20 20 use crate::recorder::{self, Session, Track};
    21 21 use crate::settings::{mask_key, Secrets, Settings, OPENROUTER_KEY};
    22 +use crate::speakers;
    22 23 use crate::transcribe::{self, BoxFuture, Stt};
    23 24 use crate::transcript::{self, Speaker};
    25 +use meet_voices::{Count, DEFAULT_THRESHOLD};
    24 26
    25 27 pub struct Recording {
    26 28 meeting_id: String,
    40 42 /// Работа, которая сейчас идёт над встречей (расшифровка или ответ ИИ),
    41 43 /// и её отмена — по кнопке «Отменить».
    42 44 pub jobs: Mutex<HashMap<String, CancellationToken>>,
    45 + /// Модель голосов (WeSpeaker, ONNX) из ресурсов программы. Нет файла —
    46 + /// расшифровка идёт без разбора по голосам.
    47 + pub voices_model: PathBuf,
    43 48 }
    44 49
    45 50 impl AppState {
    312 317 note: String,
    313 318 failed: usize,
    314 319 last_error: Option<String>,
    320 + /// Сколько говорящих нашлось по голосам (0 — разбора не было).
    321 + voices: usize,
    322 + /// Почему разбора по голосам не было, если он должен был быть.
    323 + voices_error: Option<String>,
    315 324 }
    316 325
    317 326 #[derive(Serialize, Clone)]
    318 327 struct Progress<'a> {
    319 328 meeting_id: &'a str,
    329 + /// Что идёт: `speech` — распознавание, `voices` — разбор по голосам.
    330 + stage: &'static str,
    320 331 done: usize,
    321 332 total: usize,
    322 333 /// Новые реплики по порядку времени, абзацами как в заметке.
    388 399 model: settings.stt_model.clone(),
    389 400 language: settings.stt_language().map(str::to_string),
    390 401 });
    402 + let sources = tracks.clone();
    391 403 let outcome = transcribe::run(tracks, stt, transcribe::Config::default(), &job.token, |u| {
    392 404 let _ = app.emit(
    393 405 "meet://progress",
    394 406 Progress {
    395 407 meeting_id: &id,
    408 + stage: "speech",
    396 409 done: u.done,
    397 410 total: u.total,
    398 − lines: u.fresh.iter().map(transcript::line).collect(),
    411 + lines: u.fresh.iter().map(transcript::live_line).collect(),
    399 412 },
    400 413 );
    401 414 })
    407 420 .last_error
    408 421 .unwrap_or_else(|| "распознавание не удалось".into()));
    409 422 }
    423 +
    424 + // Разбор по голосам: «Собеседники» и файл — на «Спикер 1», «Спикер 2»…
    425 + // Не вышло (нет модели, не читается звук) — расшифровка всё равно
    426 + // сохраняется, со старыми подписями, а причина уходит человеку.
    427 + let mut segments = outcome.segments;
    428 + let mut voices_error = None;
    429 + if segments.iter().any(|s| speakers::diarized(&s.speaker)) {
    430 + let model = state.voices_model.clone();
    431 + let token = job.token.clone();
    432 + let emit = app.clone();
    433 + let emit_id = id.clone();
    434 + let input = segments.clone();
    435 + let result = tokio::task::spawn_blocking(move || {
    436 + let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
    437 + let mut extractor = meet_voices::Extractor::open(&model, threads).map_err(|e| e.to_string())?;
    438 + speakers::embed(&input, &sources, &mut extractor, &token, |done, total| {
    439 + let _ = emit.emit(
    440 + "meet://progress",
    441 + Progress {
    442 + meeting_id: &emit_id,
    443 + stage: "voices",
    444 + done,
    445 + total,
    446 + lines: Vec::new(),
    447 + },
    448 + );
    449 + })
    450 + .map_err(|e| e.to_string())
    451 + })
    452 + .await
    453 + .map_err(|e| e.to_string())?;
    454 + match result {
    455 + Ok(voices) => {
    456 + segments = speakers::label(
    457 + &voices,
    458 + Count::Auto {
    459 + threshold: DEFAULT_THRESHOLD,
    460 + },
    461 + );
    462 + if let Err(e) = state.store().save_voices(&id, &voices) {
    463 + voices_error = Some(e.to_string());
    464 + }
    465 + }
    466 + Err(e) if e == "Расшифровка отменена" => return Err(e),
    467 + Err(e) => voices_error = Some(e),
    468 + }
    469 + }
    410 470 let note = state
    411 471 .store()
    412 − .upsert_section(
    413 − &id,
    414 − transcript::HEADING,
    415 − &transcript::to_markdown(&outcome.segments),
    416 − )
    472 + .upsert_section(&id, transcript::HEADING, &transcript::to_markdown(&segments))
    417 473 .map_err(|e| e.to_string())?;
    418 474 state
    419 475 .store()
    423 479 note,
    424 480 failed: outcome.failed,
    425 481 last_error: outcome.last_error,
    482 + voices: speakers::voice_count(&segments),
    483 + voices_error,
    484 + })
    485 +}
    486 +
    487 +#[derive(Serialize)]
    488 +pub struct RegroupResult {
    489 + note: String,
    490 + voices: usize,
    491 +}
    492 +
    493 +/// Пересобрать разбиение по говорящим: `count` — сколько их (пусто — сколько
    494 +/// получится). Расшифровка раздела пересобирается из реплик: правки и
    495 +/// переименования в ней заменяются — интерфейс предупреждает.
    496 +#[tauri::command]
    497 +pub async fn regroup_speakers(
    498 + state: State<'_, AppState>,
    499 + id: String,
    500 + count: Option<usize>,
    501 +) -> Result<RegroupResult, String> {
    502 + let voices = state
    503 + .store()
    504 + .load_voices(&id)
    505 + .map_err(|_| "Для этой записи нет данных о голосах — расшифруйте её заново".to_string())?;
    506 + let count = match count {
    507 + Some(n) if n >= 1 => Count::Fixed(n),
    508 + _ => Count::Auto {
    509 + threshold: DEFAULT_THRESHOLD,
    510 + },
    511 + };
    512 + let segments = tokio::task::spawn_blocking(move || speakers::label(&voices, count))
    513 + .await
    514 + .map_err(|e| e.to_string())?;
    515 + let note = state
    516 + .store()
    517 + .upsert_section(&id, transcript::HEADING, &transcript::to_markdown(&segments))
    518 + .map_err(|e| e.to_string())?;
    519 + Ok(RegroupResult {
    520 + note,
    521 + voices: speakers::voice_count(&segments),
    426 522 })
    427 523 }
    428 524
  • Показать разницуСкрыть разницу
    8 8 pub mod openrouter;
    9 9 pub mod recorder;
    10 10 pub mod settings;
    11 +pub mod speakers;
    11 12 pub mod transcribe;
    12 13 pub mod transcript;
    13 14
    27 28 let data_dir = app.path().app_data_dir()?;
    28 29 let settings_path = settings::settings_path(&config_dir);
    29 30 let default_meetings_dir = data_dir.join("meetings");
    31 + // Модель голосов лежит в ресурсах программы (tauri.conf.json,
    32 + // bundle.resources); в `tauri dev` — там же, куда их копирует сборка.
    33 + let voices_model = app
    34 + .path()
    35 + .resource_dir()
    36 + .map(|d| d.join("models").join("voices.onnx"))
    37 + .unwrap_or_default();
    30 38 let chosen = settings::Settings::load(&settings_path)
    31 39 .map(|s| s.storage_dir)
    32 40 .unwrap_or_default();
    45 53 secrets: Arc::new(settings::Keyring),
    46 54 recording: Mutex::new(None),
    47 55 jobs: Mutex::new(HashMap::new()),
    56 + voices_model,
    48 57 });
    49 58 Ok(())
    50 59 })
    65 74 commands::delete_meeting,
    66 75 commands::add_audio,
    67 76 commands::transcribe_meeting,
    77 + commands::regroup_speakers,
    68 78 commands::run_command,
    69 79 commands::recorder_status,
    70 80 commands::recorder_start,
  • Показать разницуСкрыть разницу
    51 51 pub struct MeetingDetail {
    52 52 pub meeting: Meeting,
    53 53 pub note: String,
    54 + /// Есть реплики с отпечатками голосов: разбиение по говорящим можно
    55 + /// пересобрать без новой расшифровки.
    56 + pub voices: bool,
    54 57 }
    55 58
    56 59 #[derive(Debug, thiserror::Error)]
    107 110 Ok(MeetingDetail {
    108 111 meeting: read_json(&dir.join("meeting.json"))?,
    109 112 note: std::fs::read_to_string(dir.join("note.md")).unwrap_or_default(),
    113 + voices: dir.join(VOICES_FILE).is_file(),
    110 114 })
    111 115 }
    112 116
    117 + /// Реплики с отпечатками голосов (см. `speakers.rs`).
    118 + pub fn save_voices(&self, id: &str, voices: &crate::speakers::Voices) -> Result<(), Error> {
    119 + let dir = self.dir(id)?;
    120 + write_json(&dir.join(VOICES_FILE), voices)
    121 + }
    122 +
    123 + pub fn load_voices(&self, id: &str) -> Result<crate::speakers::Voices, Error> {
    124 + read_json(&self.dir(id)?.join(VOICES_FILE))
    125 + }
    126 +
    113 127 /// Новая встреча: пустая заметка, звука нет.
    114 128 pub fn create(&self, title: &str, now: DateTime<Local>) -> Result<Meeting, Error> {
    115 129 let title = title.trim();
    353 367 }
    354 368 }
    355 369
    370 +/// Реплики с отпечатками голосов — рядом с заметкой.
    371 +const VOICES_FILE: &str = "voices.json";
    372 +
    356 373 fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, Error> {
    357 374 Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
    358 375 }
  • Показать разницуСкрыть разницу
    300 300 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    301 301 pub struct SttSegment {
    302 302 pub start_ms: u64,
    303 + /// Конец реплики; 0 — модель его не дала.
    304 + pub end_ms: u64,
    303 305 pub text: String,
    304 306 }
    305 307
    319 321 list.iter()
    320 322 .map(|s| SttSegment {
    321 323 start_ms: (s["start"].as_f64().unwrap_or(0.0).max(0.0) * 1000.0) as u64,
    324 + end_ms: (s["end"].as_f64().unwrap_or(0.0).max(0.0) * 1000.0) as u64,
    322 325 text: s["text"].as_str().unwrap_or("").trim().to_string(),
    323 326 })
    324 327 .collect()
    326 329 .unwrap_or_default();
    327 330 if segments.is_empty() {
    328 331 let text = body["text"].as_str().unwrap_or("").trim().to_string();
    329 − segments.push(SttSegment { start_ms: 0, text });
    332 + segments.push(SttSegment {
    333 + start_ms: 0,
    334 + end_ms: 0,
    335 + text,
    336 + });
    330 337 }
    331 338 segments.retain(|s| !s.text.is_empty() && !is_hallucination(&s.text));
    332 339 Transcription {
    469 476 t.segments,
    470 477 vec![SttSegment {
    471 478 start_ms: 400,
    479 + end_ms: 1200,
    472 480 text: "Привет, коллеги.".into()
    473 481 }]
    474 482 );
    498 506 t.segments,
    499 507 vec![SttSegment {
    500 508 start_ms: 0,
    509 + end_ms: 0,
    501 510 text: "Да, слышу.".into()
    502 511 }]
    503 512 );
  • Показать разницуСкрыть разницу
    1 +//! Разбор по голосам после распознавания.
    2 +//!
    3 +//! ```text
    4 +//! реплики Whisper ─► отпечаток голоса каждой (кусок звука с диска) ─► группы ─► «Спикер 1», «Спикер 2»…
    5 +//! ```
    6 +//!
    7 +//! Разбираются дорожки, где может говорить больше одного человека: звук
    8 +//! компьютера (собеседники) и файл целиком. Микрофон — всегда «Я».
    9 +//!
    10 +//! Реплики с отпечатками лежат рядом с записью (`voices.json`): так разбиение
    11 +//! пересобирается на другое число говорящих за секунду, без новой расшифровки.
    12 +//! Отпечаток — не голос: из него звук не восстановить, он нужен только, чтобы
    13 +//! сравнить две реплики этой записи между собой.
    14 +
    15 +use std::io;
    16 +use std::sync::Arc;
    17 +
    18 +use base64::Engine;
    19 +use meet_audio::pcm::ms_to_samples;
    20 +use meet_voices::{Count, Extractor};
    21 +use serde::{Deserialize, Serialize};
    22 +use tokio_util::sync::CancellationToken;
    23 +
    24 +use crate::transcribe::{Samples, UNRECOGNIZED};
    25 +use crate::transcript::{Segment, Speaker};
    26 +
    27 +/// Короче — отпечаток не снимается: модели не хватает звука.
    28 +const MIN_EMBED_MS: u64 = 500;
    29 +/// Длиннее — берётся середина: хватает, чтобы узнать голос, и быстрее.
    30 +const MAX_EMBED_MS: u64 = 12_000;
    31 +
    32 +/// Реплики записи с отпечатками — то, из чего собирается разбиение.
    33 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    34 +pub struct Voices {
    35 + pub segments: Vec<Stored>,
    36 +}
    37 +
    38 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    39 +pub struct Stored {
    40 + pub start_ms: u64,
    41 + pub end_ms: u64,
    42 + /// Дорожка, с которой пришла реплика.
    43 + pub track: Speaker,
    44 + pub text: String,
    45 + /// Отпечаток (f32, little-endian) в base64; пусто — не снят.
    46 + #[serde(default)]
    47 + pub voice: String,
    48 +}
    49 +
    50 +/// Эту дорожку надо разбирать по голосам.
    51 +pub fn diarized(track: &Speaker) -> bool {
    52 + matches!(track, Speaker::Others | Speaker::Unknown)
    53 +}
    54 +
    55 +fn encode(v: &[f32]) -> String {
    56 + let bytes: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
    57 + base64::engine::general_purpose::STANDARD.encode(bytes)
    58 +}
    59 +
    60 +fn decode(s: &str) -> Vec<f32> {
    61 + base64::engine::general_purpose::STANDARD
    62 + .decode(s)
    63 + .map(|b| {
    64 + b.as_chunks::<4>()
    65 + .0
    66 + .iter()
    67 + .map(|c| f32::from_le_bytes(*c))
    68 + .collect()
    69 + })
    70 + .unwrap_or_default()
    71 +}
    72 +
    73 +/// Разбор отменил человек.
    74 +#[derive(Debug, thiserror::Error)]
    75 +pub enum Error {
    76 + #[error("Расшифровка отменена")]
    77 + Cancelled,
    78 + #[error("Не читается звук: {0}")]
    79 + Read(#[from] io::Error),
    80 +}
    81 +
    82 +/// Снимает отпечатки реплик разбираемых дорожек. Работает долго (минуты на
    83 +/// двухчасовую встречу) — зовётся из отдельного потока; `progress(готово,
    84 +/// всего)` — после каждой реплики.
    85 +pub fn embed(
    86 + segments: &[Segment],
    87 + sources: &[(Speaker, Arc<dyn Samples>)],
    88 + extractor: &mut Extractor,
    89 + cancel: &CancellationToken,
    90 + progress: impl Fn(usize, usize),
    91 +) -> Result<Voices, Error> {
    92 + let todo = segments
    93 + .iter()
    94 + .filter(|s| diarized(&s.speaker) && s.text != UNRECOGNIZED)
    95 + .count();
    96 + let mut done = 0;
    97 + progress(0, todo);
    98 + let mut out = Vec::with_capacity(segments.len());
    99 + for s in segments {
    100 + let mut voice = String::new();
    101 + if diarized(&s.speaker) && s.text != UNRECOGNIZED {
    102 + if cancel.is_cancelled() {
    103 + return Err(Error::Cancelled);
    104 + }
    105 + let len = s.end_ms.saturating_sub(s.start_ms);
    106 + if len >= MIN_EMBED_MS {
    107 + if let Some((_, source)) = sources.iter().find(|(t, _)| t == &s.speaker) {
    108 + let (from, len) = if len > MAX_EMBED_MS {
    109 + (s.start_ms + (len - MAX_EMBED_MS) / 2, MAX_EMBED_MS)
    110 + } else {
    111 + (s.start_ms, len)
    112 + };
    113 + let samples = source.read(ms_to_samples(from), ms_to_samples(len))?;
    114 + let v = extractor.embed(&samples);
    115 + if !v.is_empty() {
    116 + voice = encode(&v);
    117 + }
    118 + }
    119 + }
    120 + done += 1;
    121 + progress(done, todo);
    122 + }
    123 + out.push(Stored {
    124 + start_ms: s.start_ms,
    125 + end_ms: s.end_ms,
    126 + track: s.speaker.clone(),
    127 + text: s.text.clone(),
    128 + voice,
    129 + });
    130 + }
    131 + Ok(Voices { segments: out })
    132 +}
    133 +
    134 +/// Реплики с говорящими: разбираемые дорожки — «Спикер N» (N с единицы, по
    135 +/// порядку первого слова), микрофон — «Я».
    136 +pub fn label(voices: &Voices, count: Count) -> Vec<Segment> {
    137 + let picked: Vec<usize> = (0..voices.segments.len())
    138 + .filter(|&i| diarized(&voices.segments[i].track))
    139 + .collect();
    140 + let embeddings: Vec<Vec<f32>> = picked
    141 + .iter()
    142 + .map(|&i| decode(&voices.segments[i].voice))
    143 + .collect();
    144 + let seconds: Vec<f32> = picked
    145 + .iter()
    146 + .map(|&i| {
    147 + let s = &voices.segments[i];
    148 + s.end_ms.saturating_sub(s.start_ms) as f32 / 1000.0
    149 + })
    150 + .collect();
    151 + let labels = meet_voices::assign(&embeddings, &seconds, count);
    152 + let mut speaker: Vec<Speaker> = voices.segments.iter().map(|s| s.track.clone()).collect();
    153 + for (&i, n) in picked.iter().zip(labels) {
    154 + speaker[i] = Speaker::Voice { n: n + 1 };
    155 + }
    156 + voices
    157 + .segments
    158 + .iter()
    159 + .zip(speaker)
    160 + .map(|(s, speaker)| Segment {
    161 + start_ms: s.start_ms,
    162 + end_ms: s.end_ms,
    163 + speaker,
    164 + text: s.text.clone(),
    165 + })
    166 + .collect()
    167 +}
    168 +
    169 +/// Сколько разных «Спикеров» в разбиении.
    170 +pub fn voice_count(segments: &[Segment]) -> usize {
    171 + segments
    172 + .iter()
    173 + .filter_map(|s| match s.speaker {
    174 + Speaker::Voice { n } => Some(n),
    175 + _ => None,
    176 + })
    177 + .max()
    178 + .unwrap_or(0)
    179 +}
    180 +
    181 +#[cfg(test)]
    182 +mod tests {
    183 + use super::*;
    184 +
    185 + fn stored(ms: u64, track: Speaker, voice: &[f32]) -> Stored {
    186 + Stored {
    187 + start_ms: ms,
    188 + end_ms: ms + 3_000,
    189 + track,
    190 + text: format!("реплика {ms}"),
    191 + voice: if voice.is_empty() {
    192 + String::new()
    193 + } else {
    194 + encode(voice)
    195 + },
    196 + }
    197 + }
    198 +
    199 + #[test]
    200 + fn voice_survives_the_round_trip() {
    201 + let v = [0.25f32, -1.5, 3.0e-7];
    202 + assert_eq!(decode(&encode(&v)), v);
    203 + assert!(decode("не base64").is_empty());
    204 + }
    205 +
    206 + #[test]
    207 + fn my_track_stays_me_and_others_get_numbers() {
    208 + // Два голоса собеседников — два направления с общим сдвигом записи.
    209 + let a = [1.0, 0.1, 0.0, 0.5];
    210 + let b = [0.1, 1.0, 0.0, 0.5];
    211 + let voices = Voices {
    212 + segments: vec![
    213 + stored(0, Speaker::Me, &[]),
    214 + stored(3_000, Speaker::Others, &b),
    215 + stored(6_000, Speaker::Others, &a),
    216 + stored(9_000, Speaker::Others, &b),
    217 + stored(12_000, Speaker::Others, &[]),
    218 + stored(15_000, Speaker::Others, &a),
    219 + ],
    220 + };
    221 + let got: Vec<Speaker> = label(&voices, Count::Fixed(2))
    222 + .into_iter()
    223 + .map(|s| s.speaker)
    224 + .collect();
    225 + let v = |n| Speaker::Voice { n };
    226 + assert_eq!(got, [Speaker::Me, v(1), v(2), v(1), v(1), v(2)]);
    227 + let one = label(&voices, Count::Fixed(1));
    228 + assert_eq!(voice_count(&one), 1);
    229 + }
    230 +
    231 + #[test]
    232 + fn stored_voices_read_old_files_without_voice() {
    233 + let json = r#"{"segments":[{"start_ms":0,"end_ms":10,"track":{"kind":"unknown"},"text":"Да."}]}"#;
    234 + let v: Voices = serde_json::from_str(json).unwrap();
    235 + assert_eq!(v.segments[0].voice, "");
    236 + assert_eq!(
    237 + label(&v, Count::Auto { threshold: 0.0 })[0].speaker,
    238 + Speaker::Voice { n: 1 }
    239 + );
    240 + }
    241 +
    242 + /// Весь путь на настоящей записи, без OpenRouter: звук → реплики по паузам
    243 + /// (вместо Whisper) → отпечатки → разбиение → заметка. По запросу:
    244 + /// `MEET_VOICES_AUDIO=<файл> MEET_VOICES_MODEL=models/voices.onnx cargo test
    245 + /// --release -p skillok-meet real_recording -- --ignored --nocapture`.
    246 + #[test]
    247 + #[ignore]
    248 + fn real_recording() {
    249 + use meet_audio::chunker::{Chunker, Config};
    250 + use meet_audio::pcm::{samples_to_ms, WavFile, WavWriter};
    251 + let audio = std::env::var("MEET_VOICES_AUDIO").expect("MEET_VOICES_AUDIO");
    252 + let model = std::env::var("MEET_VOICES_MODEL").unwrap_or_else(|_| "../models/voices.onnx".into());
    253 + let dir = tempfile::tempdir().unwrap();
    254 + let wav = dir.path().join("track.wav");
    255 + {
    256 + let pcm = meet_audio::decode::decode_file(std::path::Path::new(&audio)).unwrap();
    257 + let mut w = WavWriter::create(&wav).unwrap();
    258 + w.write(&pcm).unwrap();
    259 + }
    260 + let file = WavFile::open(&wav).unwrap();
    261 + // Реплики, как их примерно режет Whisper: между паузами, 1–10 с.
    Разница обрезана: показаны первые 261 строк.
  • Показать разницуСкрыть разницу
    222 222 }
    223 223 Err(e) => Err(format!("не читается звук: {e}")),
    224 224 };
    225 − (index, speaker, span.start_ms, result)
    225 + (index, speaker, span, result)
    226 226 });
    227 227 }
    228 228 let joined = tokio::select! {
    235 235 let Some(joined) = joined else {
    236 236 break;
    237 237 };
    238 − let (index, speaker, start_ms, result) = joined.expect("задача распознавания упала");
    238 + let (index, speaker, span, result) = joined.expect("задача распознавания упала");
    239 + let start_ms = span.start_ms;
    240 + let chunk_ms = samples_to_ms(span.samples);
    239 241 let mut got: Vec<Segment> = match result {
    240 − Ok(list) => list
    241 − .into_iter()
    242 − .map(|s| Segment {
    243 − start_ms: start_ms + s.start_ms,
    244 − speaker: speaker.clone(),
    245 − text: s.text,
    246 − })
    247 − .collect(),
    242 + Ok(list) => {
    243 + // Конец реплики — из ответа модели; если его нет, до следующей
    244 + // реплики куска или до конца куска.
    245 + let starts: Vec<u64> = list.iter().map(|s| s.start_ms).collect();
    246 + list.into_iter()
    247 + .enumerate()
    248 + .map(|(i, s)| {
    249 + let next = starts.get(i + 1).copied().unwrap_or(chunk_ms);
    250 + let end = if s.end_ms > s.start_ms {
    251 + s.end_ms
    252 + } else {
    253 + next.max(s.start_ms)
    254 + };
    255 + Segment {
    256 + start_ms: start_ms + s.start_ms,
    257 + end_ms: start_ms + end.min(chunk_ms.max(s.start_ms)),
    258 + speaker: speaker.clone(),
    259 + text: s.text,
    260 + }
    261 + })
    262 + .collect()
    263 + }
    248 264 Err(e) => {
    249 265 failed += 1;
    250 266 last_error = Some(e);
    251 267 vec![Segment {
    252 268 start_ms,
    269 + end_ms: start_ms + chunk_ms,
    253 270 speaker,
    254 271 text: UNRECOGNIZED.into(),
    255 272 }]
    332 349 let ms = samples_to_ms(pcm::decode_wav(&wav).unwrap().len());
    333 350 Ok(vec![SttSegment {
    334 351 start_ms: 200,
    352 + end_ms: 0,
    335 353 text: format!("{ms} мс"),
    336 354 }])
    337 355 })
  • Показать разницуСкрыть разницу
    1 1 //! Реплики расшифровки и то, как они ложатся в заметку.
    2 2 //!
    3 −//! Говорящий — только дорожка звука: «Я» (микрофон), «Собеседники» (звук компьютера)
    4 −//! или «Спикер 1», если звук пришёл одним файлом и кто говорит, неизвестно.
    5 −//! Узнавания людей по голосу нет: это биометрия (см. README, «Согласие на запись»).
    3 +//! Говорящий сначала — дорожка звука: «Я» (микрофон), «Собеседники» (звук
    4 +//! компьютера) или файл целиком. Потом дорожки собеседников и файла
    5 +//! раскладываются по голосам (`speakers.rs`): «Спикер 1», «Спикер 2»… Людей
    6 +//! программа не узнаёт — только отличает один голос в записи от другого
    7 +//! (решение владельца 01.10.2026, см. README, «Согласие на запись»).
    8 +
    9 +use std::borrow::Cow;
    6 10
    7 11 use serde::{Deserialize, Serialize};
    8 12
    9 −#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    13 +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
    10 14 #[serde(tag = "kind", rename_all = "snake_case")]
    11 15 pub enum Speaker {
    12 16 Me,
    13 17 Others,
    14 − /// Звук одним файлом: кто говорит, неизвестно.
    18 + /// Звук одним файлом: кто говорит, пока неизвестно.
    15 19 Unknown,
    20 + /// Голос, найденный разбором по голосам: «Спикер {n}», с единицы.
    21 + Voice {
    22 + n: usize,
    23 + },
    16 24 }
    17 25
    18 26 impl Speaker {
    19 − /// Подпись в расшифровке. Кто говорит в файле, неизвестно — «Спикер 1»
    20 − /// (решение владельца 01.10.2026: не определился — нумеруем).
    21 − pub fn label(&self) -> &'static str {
    27 + /// Подпись в расшифровке.
    28 + pub fn label(&self) -> Cow<'static, str> {
    22 29 match self {
    23 − Speaker::Me => "Я",
    24 − Speaker::Others => "Собеседники",
    25 − Speaker::Unknown => "Спикер 1",
    30 + Speaker::Me => "Я".into(),
    31 + Speaker::Others => "Собеседники".into(),
    32 + Speaker::Unknown => "Спикер 1".into(),
    33 + Speaker::Voice { n } => format!("Спикер {n}").into(),
    26 34 }
    27 35 }
    28 36
    31 39 match self {
    32 40 Speaker::Me => "me.wav",
    33 41 Speaker::Others => "others.wav",
    34 − Speaker::Unknown => "audio.wav",
    42 + Speaker::Unknown | Speaker::Voice { .. } => "audio.wav",
    35 43 }
    36 44 }
    37 45 }
    40 48 pub struct Segment {
    41 49 /// Начало реплики от начала записи, мс.
    42 50 pub start_ms: u64,
    51 + /// Конец реплики, мс.
    52 + pub end_ms: u64,
    43 53 pub speaker: Speaker,
    44 54 pub text: String,
    45 55 }
    47 57 /// Заголовок раздела с расшифровкой в заметке.
    48 58 pub const HEADING: &str = "Расшифровка";
    49 59
    50 −/// Блок «Расшифровка» для заметки: реплика — абзац с временем. Подряд идущие
    51 −/// реплики одного говорящего склеиваются в абзац — так читается легче.
    60 +/// Реплики одного говорящего склеиваются в абзац, только если между ними
    61 +/// пауза не больше этой…
    62 +const JOIN_GAP_MS: u64 = 1_500;
    63 +/// …и абзац ещё не длиннее этого. Иначе час одного голоса (лекция, файл
    64 +/// без разбора по голосам) ложился одним абзацем со временем 00:00:00.
    65 +const PARAGRAPH_CHARS: usize = 600;
    66 +
    67 +/// Блок «Расшифровка» для заметки: абзац — реплика или несколько подряд
    68 +/// идущих реплик одного говорящего без пауз, со временем начала.
    52 69 pub fn to_markdown(segments: &[Segment]) -> String {
    53 70 let mut out = String::from("## Расшифровка");
    54 − let mut prev: Option<&Speaker> = None;
    71 + let mut prev: Option<&Segment> = None;
    72 + let mut paragraph = 0;
    55 73 for s in segments {
    56 − if prev == Some(&s.speaker) {
    57 − out.push(' ');
    58 − out.push_str(&s.text);
    59 − continue;
    74 + if let Some(p) = prev {
    75 + let joins = p.speaker == s.speaker
    76 + && s.start_ms <= p.end_ms.max(p.start_ms) + JOIN_GAP_MS
    77 + && paragraph < PARAGRAPH_CHARS;
    78 + if joins {
    79 + out.push(' ');
    80 + out.push_str(&s.text);
    81 + paragraph += s.text.chars().count() + 1;
    82 + prev = Some(s);
    83 + continue;
    84 + }
    60 85 }
    61 86 out.push_str("\n\n");
    62 87 out.push_str(&line(s));
    63 − prev = Some(&s.speaker);
    88 + paragraph = s.text.chars().count();
    89 + prev = Some(s);
    64 90 }
    65 91 if segments.is_empty() {
    66 92 out.push_str("\n\nРечи в записи не нашлось.");
    69 95 out
    70 96 }
    71 97
    72 −/// Реплика абзацем: «**00:03:21 Я:** текст». Так же её показывает интерфейс
    73 −/// по ходу расшифровки.
    98 +/// Реплика абзацем: «**00:03:21 Я:** текст».
    74 99 pub fn line(s: &Segment) -> String {
    75 100 format!(
    76 101 "**{} {}:** {}",
    80 105 )
    81 106 }
    82 107
    108 +/// Реплика по ходу расшифровки: у дорожек, которые потом разберутся по
    109 +/// голосам, имени ещё нет — только время и текст.
    110 +pub fn live_line(s: &Segment) -> String {
    111 + match s.speaker {
    112 + Speaker::Me => line(s),
    113 + _ => format!("**{}** {}", format_timestamp(s.start_ms), s.text),
    114 + }
    115 +}
    116 +
    83 117 pub fn format_timestamp(ms: u64) -> String {
    84 118 let total = ms / 1000;
    85 119 format!("{:02}:{:02}:{:02}", total / 3600, total / 60 % 60, total % 60)
    92 126 fn seg(ms: u64, speaker: Speaker, text: &str) -> Segment {
    93 127 Segment {
    94 128 start_ms: ms,
    129 + end_ms: ms + 2_000,
    95 130 speaker,
    96 131 text: text.into(),
    97 132 }
    101 136 fn markdown_merges_runs_of_one_speaker() {
    102 137 let md = to_markdown(&[
    103 138 seg(1_000, Speaker::Me, "Привет."),
    104 − seg(4_000, Speaker::Me, "Начнём?"),
    139 + seg(3_500, Speaker::Me, "Начнём?"),
    105 140 seg(9_000, Speaker::Others, "Да."),
    106 141 seg(3_723_000, Speaker::Me, "Спасибо."),
    107 142 ]);
    115 150 }
    116 151
    117 152 #[test]
    118 − fn unknown_speaker_is_speaker_one() {
    153 + fn unknown_speaker_is_speaker_one_and_voices_are_numbered() {
    154 + let md = to_markdown(&[
    155 + seg(0, Speaker::Unknown, "Раз."),
    156 + seg(2_500, Speaker::Voice { n: 2 }, "Два."),
    157 + ]);
    158 + assert_eq!(
    159 + md,
    160 + "## Расшифровка\n\n**00:00:00 Спикер 1:** Раз.\n\n**00:00:02 Спикер 2:** Два.\n"
    161 + );
    162 + }
    163 +
    164 + #[test]
    165 + fn one_voice_for_an_hour_is_not_one_paragraph() {
    166 + // Пауза больше 1,5 с — новый абзац.
    119 167 let md = to_markdown(&[
    120 168 seg(0, Speaker::Unknown, "Раз."),
    121 169 seg(20_000, Speaker::Unknown, "Два."),
    122 170 ]);
    123 − assert_eq!(md, "## Расшифровка\n\n**00:00:00 Спикер 1:** Раз. Два.\n");
    171 + assert_eq!(md.matches("**").count(), 4, "{md}");
    172 + // Без пауз — абзац не длиннее ~600 знаков.
    173 + let long = "слово ".repeat(40);
    174 + let many: Vec<Segment> = (0..30)
    175 + .map(|i| seg(i * 2_000, Speaker::Unknown, long.trim()))
    176 + .collect();
    177 + let md = to_markdown(&many);
    178 + assert!(md.matches("**").count() / 2 >= 30 * 240 / 700, "{md}");
    179 + assert!(md.split("\n\n").all(|p| p.chars().count() < 1_000));
    180 + }
    181 +
    182 + #[test]
    183 + fn live_line_has_no_name_until_voices_are_known() {
    184 + assert_eq!(
    185 + live_line(&seg(61_000, Speaker::Others, "Да.")),
    186 + "**00:01:01** Да."
    187 + );
    188 + assert_eq!(live_line(&seg(61_000, Speaker::Me, "Да.")), "**00:01:01 Я:** Да.");
    124 189 }
    125 190
    126 191 #[test]
  • Показать разницуСкрыть разницу
    30 30 "nsis",
    31 31 "app"
    32 32 ],
    33 + "resources": {
    34 + "../models/voices.onnx": "models/voices.onnx"
    35 + },
    33 36 "icon": [
    34 37 "icons/32x32.png",
    35 38 "icons/128x128.png",
  • src/api.ts +17 −5
    Показать разницуСкрыть разницу
    1 1 // Типы и вызовы команд ядра (src-tauri/src/commands.rs). Поля совпадают с
    2 2 // тем, что отдаёт serde на стороне Rust.
    3 3
    4 −export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "unknown" };
    4 +export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "unknown" } | { kind: "voice"; n: number };
    5 5
    6 6 export interface AudioTrack { file: string; speaker: Speaker; duration_ms: number }
    7 7
    15 15 transcribed_at: string | null;
    16 16 }
    17 17
    18 −export interface MeetingDetail { meeting: Meeting; note: string }
    18 +/** `voices` — есть реплики с отпечатками голосов: разбиение по говорящим пересобирается без новой расшифровки. */
    19 +export interface MeetingDetail { meeting: Meeting; note: string; voices: boolean }
    19 20
    20 21 export interface Settings {
    21 22 ai_model: string;
    51 52 system_error: string | null;
    52 53 }
    53 54
    54 −export interface TranscribeResult { note: string; failed: number; last_error: string | null }
    55 +/** `voices` — сколько говорящих нашлось по голосам (0 — разбора не было), `voices_error` — почему не было. */
    56 +export interface TranscribeResult {
    57 + note: string;
    58 + failed: number;
    59 + last_error: string | null;
    60 + voices: number;
    61 + voices_error: string | null;
    62 +}
    55 63
    56 64 export interface CommandResult { note: string; heading: string }
    57 65
    119 127 export const events = {
    120 128 recorder: (cb: (p: { meeting_id: string; event: RecorderEvent }) => void) => on("meet://recorder", cb),
    121 129 /** Ход расшифровки: `lines` — новые реплики по порядку времени, абзацами как в заметке. */
    122 − progress: (cb: (p: { meeting_id: string; done: number; total: number; lines: string[] }) => void) =>
    123 − on("meet://progress", cb),
    130 + progress: (
    131 + cb: (p: { meeting_id: string; stage: "speech" | "voices"; done: number; total: number; lines: string[] }) => void,
    132 + ) => on("meet://progress", cb),
    124 133 /** Ответ ИИ пишется: `text` — новый кусок, его дописывают к показанному. */
    125 134 answer: (cb: (p: { meeting_id: string; heading: string; text: string }) => void) => on("meet://answer", cb),
    126 135 };
    149 158 deleteMeeting: (id: string) => call<void>("delete_meeting", { id }),
    150 159 addAudio: (path: string) => call<Meeting>("add_audio", { path }),
    151 160 transcribe: (id: string) => call<TranscribeResult>("transcribe_meeting", { id }),
    161 + /** Пересобрать разбиение по говорящим: `count` — сколько их, `null` — сколько получится. */
    162 + regroupSpeakers: (id: string, count: number | null) =>
    163 + call<{ note: string; voices: number }>("regroup_speakers", { id, count }),
    152 164 runCommand: (id: string, command: Command) => call<CommandResult>("run_command", { id, command }),
    153 165 recorderStatus: () => call<RecordingView | null>("recorder_status"),
    154 166 recorderStart: () => call<Meeting>("recorder_start"),
  • src/dev-mock.ts +36 −5
    Показать разницуСкрыть разницу
    47 47 const details: MeetingDetail[] = [];
    48 48 const p2 = (n: number) => String(n).padStart(2, "0");
    49 49
    50 +/** Расшифровка с говорящими «Спикер N», как после разбора по голосам в ядре.
    51 + * В заглушке голоса — имена из заготовки: `count` сводит их к стольким спикерам. */
    52 +function byVoices(count: number | null): { md: string; voices: number } {
    53 + const names: string[] = [];
    54 + const md = TRANSCRIPT.replace(/^\*\*(\d\d:\d\d:\d\d) ([^*:]+):\*\*/gm, (_, t: string, who: string) => {
    55 + if (!names.includes(who)) names.push(who);
    56 + const i = names.indexOf(who);
    57 + return `**${t} Спикер ${count ? (i % count) + 1 : i + 1}:**`;
    58 + });
    59 + return { md, voices: count ? Math.min(count, names.length) : names.length };
    60 +}
    61 +
    50 62 function makeId(d: Date): string {
    51 63 return `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
    52 64 }
    61 73 recording: false, transcribed_at: transcribed ? created.toISOString() : null,
    62 74 },
    63 75 note,
    76 + voices: transcribed,
    64 77 };
    65 78 details.unshift(d);
    66 79 return d;
    182 195 if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках";
    183 196 if (!d.meeting.audio.length) throw "В этой встрече нет звука — запишите встречу или добавьте аудиофайл";
    184 197 // Реплики приходят по ходу, как из ядра: по куску за раз, по порядку времени.
    185 − const lines = TRANSCRIPT.replace(/^## .*\n+/, "").trim().split(/\n\s*\n/);
    198 + // У файла имени говорящего по ходу нет — оно появится после разбора по голосам.
    199 + const lines = TRANSCRIPT.replace(/^## .*\n+/, "")
    200 + .trim()
    201 + .split(/\n\s*\n/)
    202 + .map((l) => l.replace(/^\*\*(\d\d:\d\d:\d\d) [^*:]+:\*\*/, "**$1**"));
    186 203 const total = lines.length;
    187 204 cancelled = false;
    188 − emit("meet://progress", { meeting_id: d.meeting.id, done: 0, total, lines: [] });
    205 + emit("meet://progress", { meeting_id: d.meeting.id, stage: "speech", done: 0, total, lines: [] });
    189 206 for (let i = 1; i <= total; i++) {
    190 − await wait(350);
    207 + await wait(300);
    208 + if (cancelled) throw "Расшифровка отменена";
    209 + emit("meet://progress", { meeting_id: d.meeting.id, stage: "speech", done: i, total, lines: [lines[i - 1]] });
    210 + }
    211 + for (let i = 0; i <= total; i += 4) {
    212 + await wait(200);
    191 213 if (cancelled) throw "Расшифровка отменена";
    192 − emit("meet://progress", { meeting_id: d.meeting.id, done: i, total, lines: [lines[i - 1]] });
    214 + emit("meet://progress", { meeting_id: d.meeting.id, stage: "voices", done: Math.min(i, total), total, lines: [] });
    193 215 }
    194 216 d.meeting.transcribed_at = new Date().toISOString();
    195 − return { note: upsert(d, "Расшифровка", TRANSCRIPT), failed: 0, last_error: null } as T;
    217 + d.voices = true;
    218 + const v = byVoices(null);
    219 + return { note: upsert(d, "Расшифровка", v.md), failed: 0, last_error: null, voices: v.voices, voices_error: null } as T;
    220 + }
    221 + case "regroup_speakers": {
    222 + const d = find(args.id);
    223 + if (!d.voices) throw "Для этой записи нет данных о голосах — расшифруйте её заново";
    224 + await wait(300);
    225 + const v = byVoices((args.count as number | null) ?? null);
    226 + return { note: upsert(d, "Расшифровка", v.md), voices: v.voices } as T;
    196 227 }
    197 228 case "run_command": {
    198 229 const d = find(args.id);
  • Показать разницуСкрыть разницу
    276 276 .tr-text { display: grid; gap: 2px; }
    277 277 .tr-who { font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text); }
    278 278 .tr-who.is-me { color: var(--color-text-primary); }
    279 +.speakers-pick { display: inline-flex; align-items: center; gap: 6px; }
    280 +.speakers-pick .input { width: auto; padding: 2px 6px; font-size: var(--text-xs); }
    279 281 button.tr-who {
    280 282 justify-self: start; padding: 0; font: inherit; font-size: var(--text-sm); font-weight: 700;
    281 283 background: none; border: 0; cursor: pointer; text-align: left;
  • src/views/meeting.ts +60 −10
    Показать разницуСкрыть разницу
    61 61 return "result";
    62 62 }
    63 63
    64 −function progressNote(done: number, total: number): string {
    65 − return `${total ? `Готово ${done} из ${total} кусков` : "Готовлю звук…"}. Можно уйти с экрана — расшифровка продолжится.`;
    64 +type Stage = "speech" | "voices";
    65 +
    66 +const STAGE_TITLE: Record<Stage, string> = { speech: "Расшифровываю", voices: "Разделяю по голосам" };
    67 +
    68 +function progressNote(stage: Stage, done: number, total: number): string {
    69 + const what =
    70 + stage === "voices"
    71 + ? `Сравниваю голоса: ${done} из ${total} реплик`
    72 + : total
    73 + ? `Готово ${done} из ${total} кусков`
    74 + : "Готовлю звук…";
    75 + return `${what}. Можно уйти с экрана — расшифровка продолжится.`;
    66 76 }
    67 77
    68 78 /** Реплика расшифровки, как её пишет ядро: «**00:03:21 Вы:** текст» или «**00:03:21** текст». */
    111 121 const hasKey = Boolean(view.openrouter_key);
    112 122
    113 123 // Что сейчас идёт над записью: расшифровка или ответ ИИ (с какой вкладки).
    114 − let job: { kind: "transcribe"; done: number; total: number } | { kind: "command"; tab: string } | null = null;
    124 + let job: { kind: "transcribe"; stage: Stage; done: number; total: number } | { kind: "command"; tab: string } | null =
    125 + null;
    115 126 let jobError: string | null = null;
    116 127 // Что уже пришло по ходу работы: реплики расшифровки и текст ответа ИИ.
    117 128 let liveLines: string[] = [];
    148 159 return `
    149 160 <section class="live">
    150 161 <div class="live-head">
    151 − <p class="live-title"><span class="spinner"></span>Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}
    162 + <p class="live-title"><span class="spinner"></span><span data-stage-title>${STAGE_TITLE[job.stage]}${
    163 + job.stage === "speech" && dur ? ` ${esc(dur)} звука` : ""
    164 + }</span>
    152 165 <span class="mono-num live-pct" data-pct>${pct}%</span></p>
    153 166 <button class="link-btn" data-cancel>Отменить</button>
    154 167 </div>
    155 168 <div class="progress"><span style="width:${pct}%"></span></div>
    156 − <p class="xs muted" data-progress-note>${progressNote(job.done, job.total)}</p>
    169 + <p class="xs muted" data-progress-note>${progressNote(job.stage, job.done, job.total)}</p>
    157 170 <div class="pane"><div class="pane-body"><div class="tr" data-live>${transcriptRows(liveLines) ?? ""}</div>
    158 171 <p class="live-wait small muted" data-live-wait ${liveLines.length ? "hidden" : ""}>Текст появится здесь, как только распознается первый кусок.</p></div></div>
    159 172 </section>`;
    258 271 <div class="pane-tools">
    259 272 <button class="link-btn" data-edit>Править</button>
    260 273 ${isTranscript ? `<span class="xs muted">Имя говорящего — нажмите, чтобы переименовать</span>` : ""}
    274 + ${
    275 + isTranscript && d.voices
    276 + ? `<label class="xs muted speakers-pick">Говорящих
    277 + <select class="input" data-speakers ${job ? "disabled" : ""}>
    278 + <option value="">как нашлось</option>
    279 + ${[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `<option value="${n}">${n}</option>`).join("")}
    280 + </select></label>`
    281 + : ""
    282 + }
    261 283 <span class="spacer"></span>
    262 284 ${
    263 285 isTranscript
    323 345
    324 346 const transcribe = async () => {
    325 347 if (job) return;
    326 − job = { kind: "transcribe", done: 0, total: 0 };
    348 + job = { kind: "transcribe", stage: "speech", done: 0, total: 0 };
    327 349 jobError = null;
    328 350 liveLines = [];
    329 351 draw();
    330 352 try {
    331 353 const r = await api.transcribe(id);
    332 354 d.note = r.note;
    333 − d.meeting = (await api.getMeeting(id)).meeting;
    355 + const fresh = await api.getMeeting(id);
    356 + d.meeting = fresh.meeting;
    357 + d.voices = fresh.voices;
    334 358 tab = TRANSCRIPT;
    335 359 void refreshNav();
    336 360 if (r.failed) toast(`Не распозналось кусков: ${r.failed}. ${r.last_error ?? ""}`);
    361 + else if (r.voices_error) toast(`Расшифровка готова, но по голосам не разделилась: ${r.voices_error}`);
    362 + else if (r.voices > 1) toast(`Готово. Говорящих по голосам: ${r.voices}`, "ok");
    337 363 } catch (e) {
    338 364 jobError = errorText(e) === "Расшифровка отменена" ? null : errorText(e);
    339 365 }
    523 549 }),
    524 550 );
    525 551
    552 + // Сколько говорящих: разбиение пересобирается из сохранённых отпечатков,
    553 + // без новой расшифровки. Правки и имена в расшифровке заменятся.
    554 + root.querySelector<HTMLSelectElement>("[data-speakers]")?.addEventListener("change", async (e) => {
    555 + const select = e.currentTarget as HTMLSelectElement;
    556 + const count = select.value ? Number(select.value) : null;
    557 + const ask = `Разделить расшифровку на ${count ? `${count} говорящих` : "столько говорящих, сколько найдётся"}? Правки и переименования в расшифровке заменятся.`;
    558 + if (!confirm(ask)) {
    559 + select.value = "";
    560 + return;
    561 + }
    562 + try {
    563 + const r = await api.regroupSpeakers(id, count);
    564 + d.note = r.note;
    565 + draw();
    566 + toast(`Говорящих: ${r.voices}`, "ok");
    567 + } catch (err) {
    568 + toast(err);
    569 + }
    570 + });
    571 +
    526 572 root.querySelector("[data-open-folder]")?.addEventListener("click", () => api.openFolder(id).catch(toast));
    527 573
    528 574 root.querySelector("[data-delete]")?.addEventListener("click", async () => {
    539 585 draw();
    540 586
    541 587 scope.on(
    542 − events.progress(({ meeting_id, done, total, lines }) => {
    588 + events.progress(({ meeting_id, stage, done, total, lines }) => {
    543 589 if (meeting_id !== id || job?.kind !== "transcribe") return;
    544 − job = { kind: "transcribe", done, total };
    590 + if (stage !== job.stage) {
    591 + const title = root.querySelector("[data-stage-title]");
    592 + if (title) title.textContent = STAGE_TITLE[stage];
    593 + }
    594 + job = { kind: "transcribe", stage, done, total };
    545 595 if (lines.length) {
    546 596 liveLines.push(...lines);
    547 597 root.querySelector("[data-live]")?.insertAdjacentHTML("beforeend", transcriptRows(lines) ?? "");
    553 603 const note = root.querySelector("[data-progress-note]");
    554 604 if (bar) bar.style.width = `${pct}%`;
    555 605 if (big) big.textContent = `${pct}%`;
    556 − if (note) note.textContent = total ? progressNote(done, total) : "В записи не нашлось речи.";
    606 + if (note) note.textContent = total || stage === "voices" ? progressNote(stage, done, total) : "В записи не нашлось речи.";
    557 607 }),
    558 608 );
    559 609 // Ответ ИИ пишется — показываем по ходу. Перерисовка не чаще кадра.