Разбор по голосам: «Спикер 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>
18b6b16
· сравнить с родителем
-
Показать разницуСкрыть разницу
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(¢roids, &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(¢roids, &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 скачана" -
src-tauri/Cargo.toml +4 −1
Показать разницуСкрыть разницу
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" -
src-tauri/src/commands.rs +102 −6
Показать разницуСкрыть разницу
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 } -
src-tauri/src/openrouter.rs +10 −1
Показать разницуСкрыть разницу
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 строк. -
src-tauri/src/transcribe.rs +28 −10
Показать разницуСкрыть разницу
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 }) -
src-tauri/src/transcript.rs +90 −25
Показать разницуСкрыть разницу
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 // Ответ ИИ пишется — показываем по ходу. Перерисовка не чаще кадра.