Ядро: встреча = заметка Markdown, всё по кнопке
- Убрана автоматика: слежение за Zoom, окно согласия, трей, уведомления, автозапуск, живая расшифровка, разбор по JSON-схеме. - Встреча — note.md и дорожки звука; правка, дописывание, переименование, удаление. - «Добавить аудио»: чтение mp3, m4a, wav, aiff, flac, ogg (symphonia), потоковое приведение к 16 кГц моно. - «Расшифровать»: нарезка по паузам с обрезкой тишины в начале куска, до 4 кусков сразу, повтор, пометка нераспознанного, блок «Расшифровка». - Команды ИИ над заметкой: «Конспект», «Задачи», «Ревью», свой запрос; промпты файлами в prompts/. - «Записать» по кнопке: дорожки Zoom и микрофона в WAV, пауза, стоп. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169ce64
· сравнить с родителем
-
Cargo.lock +289 −610
Показать разницуСкрыть разницу
48 48 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 49 49 50 50 [[package]] 51 −name = "async-broadcast" 52 −version = "0.7.2" 51 +name = "arrayvec" 52 +version = "0.7.8" 53 53 source = "registry+https://github.com/rust-lang/crates.io-index" 54 −checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" 55 −dependencies = [ 56 − "event-listener", 57 − "event-listener-strategy", 58 − "futures-core", 59 − "pin-project-lite", 60 −] 61 − 62 −[[package]] 63 −name = "async-channel" 64 −version = "2.5.0" 65 −source = "registry+https://github.com/rust-lang/crates.io-index" 66 −checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" 67 −dependencies = [ 68 − "concurrent-queue", 69 − "event-listener-strategy", 70 − "futures-core", 71 − "pin-project-lite", 72 −] 73 − 74 −[[package]] 75 −name = "async-executor" 76 −version = "1.14.0" 77 −source = "registry+https://github.com/rust-lang/crates.io-index" 78 −checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" 79 −dependencies = [ 80 − "async-task", 81 − "concurrent-queue", 82 − "fastrand", 83 − "futures-lite", 84 − "pin-project-lite", 85 − "slab", 86 −] 87 − 88 −[[package]] 89 −name = "async-io" 90 −version = "2.6.0" 91 −source = "registry+https://github.com/rust-lang/crates.io-index" 92 −checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" 93 −dependencies = [ 94 − "autocfg", 95 − "cfg-if", 96 − "concurrent-queue", 97 − "futures-io", 98 − "futures-lite", 99 − "parking", 100 − "polling", 101 − "rustix", 102 − "slab", 103 − "windows-sys 0.61.2", 104 −] 105 − 106 −[[package]] 107 −name = "async-lock" 108 −version = "3.4.2" 109 −source = "registry+https://github.com/rust-lang/crates.io-index" 110 −checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" 111 −dependencies = [ 112 − "event-listener", 113 − "event-listener-strategy", 114 − "pin-project-lite", 115 −] 116 − 117 −[[package]] 118 −name = "async-process" 119 −version = "2.5.0" 120 −source = "registry+https://github.com/rust-lang/crates.io-index" 121 −checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" 122 −dependencies = [ 123 − "async-channel", 124 − "async-io", 125 − "async-lock", 126 − "async-signal", 127 − "async-task", 128 − "blocking", 129 − "cfg-if", 130 − "event-listener", 131 − "futures-lite", 132 − "rustix", 133 −] 134 − 135 −[[package]] 136 −name = "async-recursion" 137 −version = "1.1.1" 138 −source = "registry+https://github.com/rust-lang/crates.io-index" 139 −checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" 140 −dependencies = [ 141 − "proc-macro2", 142 − "quote", 143 − "syn 2.0.119", 144 −] 145 − 146 −[[package]] 147 −name = "async-signal" 148 −version = "0.2.14" 149 −source = "registry+https://github.com/rust-lang/crates.io-index" 150 −checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" 151 −dependencies = [ 152 − "async-io", Разница обрезана: показаны первые 107 строк. -
Показать разницуСкрыть разницу
7 7 8 8 [dependencies] 9 9 thiserror = "2" 10 +symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "isomp4", "alac", "flac", "ogg", "vorbis", "wav", "pcm", "mkv", "adpcm", "aiff", "caf"] } 10 11 11 12 [target.'cfg(windows)'.dependencies] 12 13 wasapi = "0.24" -
Показать разницуСкрыть разницу
1 +//! Проба декодера: `cargo run -p meet-audio --example decode -- файл.m4a`. 2 +//! Печатает длительность и сколько в файле речи по нарезчику. 3 + 4 +use meet_audio::chunker::{Chunker, Config}; 5 +use meet_audio::decode::decode_file; 6 +use meet_audio::pcm::samples_to_ms; 7 + 8 +fn main() { 9 + for path in std::env::args().skip(1) { 10 + match decode_file(std::path::Path::new(&path)) { 11 + Ok(samples) => { 12 + let mut c = Chunker::new(Config::default()); 13 + let mut chunks = c.push(samples_to_ms(samples.len()), &samples); 14 + chunks.extend(c.flush()); 15 + let speech: u64 = chunks.iter().map(|c| c.speech_ms).sum(); 16 + println!( 17 + "{path}: {} мс, кусков {}, речи {} мс", 18 + samples_to_ms(samples.len()), 19 + chunks.len(), 20 + speech 21 + ); 22 + } 23 + Err(e) => println!("{path}: {e}"), 24 + } 25 + } 26 +} -
crates/audio/src/chunker.rs +21 −8
Показать разницуСкрыть разницу
18 18 use crate::pcm::{ms_to_samples, samples_to_ms}; 19 19 20 20 const FRAME_MS: u64 = 20; 21 +/// Сколько кадров тишины оставить перед речью в начале куска (200 мс). 22 +const LEAD_KEEP_FRAMES: usize = 10; 21 23 22 24 #[derive(Debug, Clone, Copy, PartialEq)] 23 25 pub struct Config { 184 186 self.buf_start_ms += samples_to_ms(samples.len()); 185 187 self.trailing_silence_ms = self.frames.iter().rev().take_while(|s| !**s).count() as u64 * FRAME_MS; 186 188 let speech_ms = flags.iter().filter(|s| **s).count() as u64 * FRAME_MS; 187 − (speech_ms >= self.cfg.min_speech_ms && !samples.is_empty()).then_some(Chunk { 188 − start_ms, 189 − samples, 189 + // Тишина в начале куска срезается, 200 мс перед речью остаются: время 190 + // реплик от распознавания отсчитывается от начала куска и так точнее, 191 + // а отправлять надо меньше. 192 + let lead = flags 193 + .iter() 194 + .position(|s| *s) 195 + .unwrap_or(flags.len()) 196 + .saturating_sub(LEAD_KEEP_FRAMES); 197 + let skip = (lead * frame).min(samples.len()); 198 + (speech_ms >= self.cfg.min_speech_ms && samples.len() > skip).then(|| Chunk { 199 + start_ms: start_ms + samples_to_ms(skip), 200 + samples: samples[skip..].to_vec(), 190 201 speech_ms, 191 202 }) 192 203 } 246 257 let (_, chunks) = feed(&mut c, 0, &audio); 247 258 assert_eq!(chunks.len(), 1, "режется в паузе после 7,5 с"); 248 259 let first = &chunks[0]; 249 − assert_eq!(first.start_ms, 0); 260 + assert_eq!( 261 + first.start_ms, 300, 262 + "тишина в начале срезана, 200 мс перед речью оставлены" 263 + ); 250 264 assert!( 251 − (7_500..=8_200).contains(&first.duration_ms()), 265 + (7_200..=7_900).contains(&first.duration_ms()), 252 266 "{}", 253 267 first.duration_ms() 254 268 ); 257 271 let rest = c.flush(); 258 272 assert_eq!(rest.len(), 1); 259 273 assert_eq!( 260 − rest[0].start_ms, 261 − first.duration_ms(), 262 − "второй кусок продолжает первый без дыры" 274 + rest[0].start_ms, 8_300, 275 + "второй кусок — с начала речи минус 200 мс" 263 276 ); 264 277 assert!(rest[0].speech_ms >= 2_800); 265 278 } -
Показать разницуСкрыть разницу
1 +//! Чтение аудиофайла любого распространённого формата (mp3, m4a/aac, wav, aiff, 2 +//! flac, ogg, opus в mkv/webm не поддерживается) в наш формат 16 кГц моно. 3 +//! Декодер — `symphonia`, чистый Rust: одинаково работает в Windows и на Mac. 4 + 5 +use std::fs::File; 6 +use std::path::Path; 7 + 8 +use symphonia::core::audio::SampleBuffer; 9 +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; 10 +use symphonia::core::errors::Error as SymphoniaError; 11 +use symphonia::core::formats::FormatOptions; 12 +use symphonia::core::io::MediaSourceStream; 13 +use symphonia::core::meta::MetadataOptions; 14 +use symphonia::core::probe::Hint; 15 + 16 +use crate::pcm::Resampler; 17 + 18 +#[derive(Debug, thiserror::Error)] 19 +pub enum DecodeError { 20 + #[error("файл не открывается: {0}")] 21 + Io(#[from] std::io::Error), 22 + #[error("формат не поддерживается — подойдут mp3, m4a, wav, aiff, flac, ogg")] 23 + Unsupported, 24 + #[error("в файле нет звуковой дорожки")] 25 + NoTrack, 26 + #[error("файл повреждён: {0}")] 27 + Broken(String), 28 +} 29 + 30 +/// Весь звук файла в формате [`crate::pcm`]. 31 +pub fn decode_file(path: &Path) -> Result<Vec<i16>, DecodeError> { 32 + let file = File::open(path)?; 33 + let stream = MediaSourceStream::new(Box::new(file), Default::default()); 34 + let mut hint = Hint::new(); 35 + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { 36 + hint.with_extension(ext); 37 + } 38 + let probed = symphonia::default::get_probe() 39 + .format( 40 + &hint, 41 + stream, 42 + &FormatOptions::default(), 43 + &MetadataOptions::default(), 44 + ) 45 + .map_err(|_| DecodeError::Unsupported)?; 46 + let mut format = probed.format; 47 + let track = format 48 + .tracks() 49 + .iter() 50 + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) 51 + .ok_or(DecodeError::NoTrack)?; 52 + let track_id = track.id; 53 + let mut decoder = symphonia::default::get_codecs() 54 + .make(&track.codec_params, &DecoderOptions::default()) 55 + .map_err(|_| DecodeError::Unsupported)?; 56 + 57 + let mut out = Vec::new(); 58 + let mut resampler: Option<Resampler> = None; 59 + let mut buffer: Option<SampleBuffer<f32>> = None; 60 + loop { 61 + let packet = match format.next_packet() { 62 + Ok(p) => p, 63 + Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, 64 + Err(SymphoniaError::ResetRequired) => break, 65 + Err(e) => return Err(DecodeError::Broken(e.to_string())), 66 + }; 67 + if packet.track_id() != track_id { 68 + continue; 69 + } 70 + let decoded = match decoder.decode(&packet) { 71 + Ok(d) => d, 72 + // Битый пакет в середине — пропускаем, как делают плееры. 73 + Err(SymphoniaError::DecodeError(_)) => continue, 74 + Err(e) => return Err(DecodeError::Broken(e.to_string())), 75 + }; 76 + let spec = *decoded.spec(); 77 + let r = resampler.get_or_insert_with(|| Resampler::new(spec.channels.count(), spec.rate)); 78 + let b = match buffer.as_mut() { 79 + Some(b) if b.capacity() >= decoded.capacity() * spec.channels.count() => b, 80 + _ => buffer.insert(SampleBuffer::new(decoded.capacity() as u64, spec)), 81 + }; 82 + b.copy_interleaved_ref(decoded); 83 + out.extend(r.push(b.samples())); 84 + } 85 + Ok(out) 86 +} 87 + 88 +#[cfg(test)] 89 +mod tests { 90 + use super::*; 91 + use crate::pcm::{encode_wav, ms_to_samples}; 92 + 93 + #[test] 94 + fn reads_our_own_wav() { 95 + let dir = std::env::temp_dir().join(format!("meet-decode-{}", std::process::id())); 96 + std::fs::create_dir_all(&dir).unwrap(); 97 + let path = dir.join("t.wav"); 98 + let samples: Vec<i16> = (0..ms_to_samples(500)) 99 + .map(|i| ((i % 64) as i16 - 32) * 300) 100 + .collect(); 101 + std::fs::write(&path, encode_wav(&samples)).unwrap(); 102 + let back = decode_file(&path).unwrap(); 103 + assert_eq!(back.len(), samples.len()); 104 + let worst = back 105 + .iter() 106 + .zip(&samples) 107 + .map(|(a, b)| (*a as i32 - *b as i32).abs()) 108 + .max() 109 + .unwrap(); 110 + assert!(worst <= 1, "{worst}"); 111 + std::fs::remove_dir_all(dir).unwrap(); 112 + } 113 + 114 + #[test] 115 + fn stereo_44k_wav_is_converted() { 116 + // WAV 44,1 кГц стерео, 16 бит — как у диктофона. Разница обрезана: показаны первые 116 строк. -
Показать разницуСкрыть разницу
1 −//! Когда встреча в Zoom началась и когда закончилась. 2 −//! 3 −//! Признак встречи — у процесса Zoom есть активная звуковая сессия вывода: 4 −//! во время встречи Zoom держит поток воспроизведения открытым, даже когда все 5 −//! молчат. Вне встречи потока нет (или он короткий — звук уведомления). 6 −//! 7 −//! Сам опрос системы — в `windows::zoom`; здесь только решение по его 8 −//! результатам, чтобы его можно было проверить тестами на любой машине: 9 −//! - встреча началась, если сессия активна дольше `start_after_ms` — отсекаем 10 −//! звук входящего сообщения и тестовый звонок динамика; 11 −//! - встреча закончилась, если сессии нет дольше `end_after_ms` — переподключение 12 −//! и смена динамика в настройках Zoom не рвут запись; 13 −//! - процесс Zoom сменился (перезапуск) — это конец прежней встречи. 14 − 15 −#[derive(Debug, Clone, Copy, PartialEq, Eq)] 16 −pub struct Config { 17 − pub start_after_ms: u64, 18 − pub end_after_ms: u64, 19 −} 20 − 21 −impl Default for Config { 22 − fn default() -> Self { 23 − Self { 24 − start_after_ms: 4_000, 25 − end_after_ms: 20_000, 26 − } 27 − } 28 −} 29 − 30 −#[derive(Debug, Clone, Copy, PartialEq, Eq)] 31 −pub enum Event { 32 − /// Встреча началась; `pid` — корневой процесс Zoom, его звук и пишем. 33 − Started { 34 − pid: u32, 35 − }, 36 − Ended, 37 −} 38 − 39 −#[derive(Debug, Clone, Copy, PartialEq, Eq)] 40 −enum State { 41 − Idle, 42 − /// Сессия активна с момента `since`, но ещё мало. 43 − Starting { 44 − pid: u32, 45 − since: u64, 46 − }, 47 − InMeeting { 48 − pid: u32, 49 − last_seen: u64, 50 − }, 51 −} 52 − 53 −pub struct Detector { 54 − cfg: Config, 55 − state: State, 56 −} 57 − 58 −impl Detector { 59 − pub fn new(cfg: Config) -> Self { 60 − Self { 61 − cfg, 62 − state: State::Idle, 63 − } 64 − } 65 − 66 − pub fn in_meeting(&self) -> Option<u32> { 67 − match self.state { 68 − State::InMeeting { pid, .. } => Some(pid), 69 − _ => None, 70 − } 71 − } 72 − 73 − /// Очередной опрос в момент `now_ms`: `active` — процесс Zoom с активной 74 − /// сессией вывода, если он есть. 75 − pub fn poll(&mut self, now_ms: u64, active: Option<u32>) -> Option<Event> { 76 − match (self.state, active) { 77 − (State::Idle, Some(pid)) => { 78 − self.state = State::Starting { pid, since: now_ms }; 79 − self.check_start(now_ms) 80 − } 81 − (State::Idle, None) => None, 82 − (State::Starting { pid, .. }, Some(p)) if p == pid => self.check_start(now_ms), 83 − (State::Starting { .. }, Some(p)) => { 84 − self.state = State::Starting { 85 − pid: p, 86 − since: now_ms, 87 − }; 88 − None 89 − } 90 − (State::Starting { .. }, None) => { 91 − self.state = State::Idle; 92 − None 93 − } 94 − (State::InMeeting { pid, .. }, Some(p)) if p == pid => { 95 − self.state = State::InMeeting { 96 − pid, 97 − last_seen: now_ms, 98 − }; 99 − None 100 − } 101 − (State::InMeeting { .. }, Some(p)) => { 102 − self.state = State::Starting { 103 − pid: p, 104 − since: now_ms, 105 − }; 106 − Some(Event::Ended) 107 − } 108 − (State::InMeeting { last_seen, .. }, None) => { 109 − if now_ms.saturating_sub(last_seen) >= self.cfg.end_after_ms { 110 − self.state = State::Idle; 111 − Some(Event::Ended) 112 − } else { 113 − None 114 − } 115 − } 116 − } Разница обрезана: показаны первые 116 строк. -
crates/audio/src/lib.rs +5 −4
Показать разницуСкрыть разницу
1 −//! Звук встречи: захват, формат, нарезка на куски и распознавание начала 2 −//! встречи в Zoom. Всё, что не зависит от Windows, проверяется тестами на любой 1 +//! Звук встречи: захват, чтение файлов, формат и нарезка на куски для 2 +//! распознавания. Всё, что не зависит от Windows, проверяется тестами на любой 3 3 //! машине; сам захват — в модуле [`win`] и собирается только под Windows. 4 4 5 5 pub mod chunker; 6 −pub mod detector; 6 +pub mod decode; 7 7 pub mod pcm; 8 8 pub mod source; 9 9 33 33 } 34 34 } 35 35 36 −/// Процесс Zoom с активной сессией вывода звука, если он есть. Вне Windows — `None`. 36 +/// Процесс Zoom, который сейчас выводит звук (идёт встреча), если он есть. 37 +/// Вне Windows — `None`. 37 38 pub fn active_zoom_process() -> Option<u32> { 38 39 #[cfg(windows)] 39 40 { -
Показать разницуСкрыть разницу
72 72 f32_to_i16(&out) 73 73 } 74 74 75 +/// Потоковое приведение к нашему формату: звук подаётся кусками любой длины, 76 +/// результат — тот же, что у [`to_native`] на всём звуке сразу. Нужен, чтобы 77 +/// читать длинные файлы, не держа их в памяти целиком во float. 78 +pub struct Resampler { 79 + channels: usize, 80 + rate: u32, 81 + /// Кратное понижение: сумма и число накопленных сэмплов неполной группы. 82 + acc: f32, 83 + acc_n: usize, 84 + /// Некратное: позиция следующего выходного сэмпла во входных и прошлый сэмпл. 85 + pos: f64, 86 + index: u64, 87 + prev: Option<f32>, 88 +} 89 + 90 +impl Resampler { 91 + pub fn new(channels: usize, rate: u32) -> Self { 92 + Self { 93 + channels: channels.max(1), 94 + rate, 95 + acc: 0.0, 96 + acc_n: 0, 97 + pos: 0.0, 98 + index: 0, 99 + prev: None, 100 + } 101 + } 102 + 103 + pub fn push(&mut self, interleaved: &[f32]) -> Vec<i16> { 104 + let c = self.channels; 105 + let mono = interleaved 106 + .chunks_exact(c) 107 + .map(|f| f.iter().sum::<f32>() / c as f32); 108 + let mut out = Vec::new(); 109 + if self.rate == SAMPLE_RATE { 110 + out.extend(mono); 111 + } else if self.rate.is_multiple_of(SAMPLE_RATE) { 112 + let k = (self.rate / SAMPLE_RATE) as usize; 113 + for s in mono { 114 + self.acc += s; 115 + self.acc_n += 1; 116 + if self.acc_n == k { 117 + out.push(self.acc / k as f32); 118 + self.acc = 0.0; 119 + self.acc_n = 0; 120 + } 121 + } 122 + } else { 123 + let step = self.rate as f64 / SAMPLE_RATE as f64; 124 + for s in mono { 125 + // Входной сэмпл номер `index`; выдаём все выходные с позицией в [index-1, index). 126 + if let Some(a) = self.prev { 127 + while self.pos < self.index as f64 { 128 + let t = (self.pos - (self.index - 1) as f64) as f32; 129 + out.push(a + (s - a) * t); 130 + self.pos += step; 131 + } 132 + } 133 + self.prev = Some(s); 134 + self.index += 1; 135 + } 136 + } 137 + f32_to_i16(&out) 138 + } 139 +} 140 + 75 141 fn header(data_bytes: u32) -> [u8; 44] { 76 142 let mut h = [0u8; 44]; 77 143 h[0..4].copy_from_slice(b"RIFF"); 266 332 } 267 333 268 334 #[test] 335 + fn streaming_resampler_matches_whole_signal() { 336 + for (rate, channels) in [(48_000u32, 2usize), (44_100, 2), (16_000, 1), (22_050, 1)] { 337 + let n = rate as usize; // секунда звука 338 + let signal: Vec<f32> = (0..n * channels) 339 + .map(|i| ((i / channels) as f32 / rate as f32 * std::f32::consts::TAU * 220.0).sin() * 0.5) 340 + .collect(); 341 + let whole = to_native(&signal, channels, rate); 342 + let mut r = Resampler::new(channels, rate); 343 + let mut streamed = Vec::new(); 344 + // Куски неровной длины, кратные числу каналов, — как пакеты декодера. 345 + for part in signal.chunks(1151 * channels) { 346 + streamed.extend(r.push(part)); 347 + } 348 + assert!( 349 + (streamed.len() as i64 - whole.len() as i64).abs() <= 1, 350 + "{rate}/{channels}: {} против {}", 351 + streamed.len(), 352 + whole.len() 353 + ); 354 + let worst = streamed 355 + .iter() 356 + .zip(&whole) 357 + .map(|(a, b)| (*a as i32 - *b as i32).abs()) 358 + .max() 359 + .unwrap(); 360 + assert!(worst <= 2, "{rate}/{channels}: расхождение {worst}"); 361 + } 362 + } 363 + 364 + #[test] 269 365 fn float_conversion_saturates() { 270 366 assert_eq!( 271 367 f32_to_i16(&[0.0, 1.0, -1.0, 2.0, -2.0]), -
Показать разницуСкрыть разницу
1 −Ты разбираешь расшифровку рабочей встречи для человека, который на ней был. Он откроет разбор после встречи, чтобы вспомнить главное, увидеть, чем встреча закончилась, и не потерять договорённости. Разбор может уйти в его репозиторий и в трекер задач, поэтому он должен быть точным: лучше пропустить сомнительное, чем выдумать. 2 − 3 −Расшифровка сделана автоматически. Каждая реплика пронумерована: `#N [время] Говорящий: текст`. В ней бывают ошибки распознавания, оборванные фразы и повторы. Если говорящий подписан «Я», это владелец записи; «Собеседники» — все остальные участники одной дорожкой, без разделения по людям. 4 − 5 −Что заполнить: 6 − 7 −- `title` — тема встречи в нескольких словах, как её назвал бы участник. 8 −- `summary` — о чём была встреча и чем закончилась, 3–6 предложений. 9 −- `review` — честный взгляд со стороны: 10 − - `achieved` — чего встреча добилась; 11 − - `open` — что осталось нерешённым или отложено; 12 − - `off_track` — где обсуждение ушло в сторону или ходило по кругу; пустой список, если такого не было; 13 − - `verdict` — одно предложение: стоила ли встреча своего времени и что стоит сделать иначе в следующий раз. 14 −- `decisions` — только то, о чём договорились явно. Предложение, которое никто не принял, — не решение. 15 −- `tasks` — действия, которые кто-то взял на себя или которые кому-то поручили: 16 − - `owner` — имя, как оно звучит в расшифровке; `null`, если исполнитель не прозвучал. «Я» — владелец записи, так и пиши «Я»; 17 − - `due` — срок словами из расшифровки («до пятницы», «к 15 октября»); `null`, если срока не было. Не пересчитывай в даты; 18 − - `quote` — короткая дословная цитата, на которой основана задача. 19 −- `open_questions` — вопросы, которые прозвучали и остались без ответа, или которые надо поднять на следующей встрече. 20 − 21 −У каждого решения и задачи `source_segment` — номер реплики, где это прозвучало (число N из `#N`). 22 − 23 −Пиши на языке встречи, коротко и конкретно, без вводных слов и оценок участников как людей. Если встреча пустая или расшифровка нечитаема, скажи об этом в `summary` и оставь списки пустыми. -
prompts/base.md +10
Показать разницуСкрыть разницу
1 +Ты помогаешь человеку работать с заметкой о встрече. Заметка — Markdown: в ней может быть его собственный текст, расшифровка встречи и ответы на прошлые просьбы. Он просит тебя о чём-то одном; твой ответ будет вставлен в конец заметки под заголовком, который программа поставит сама. 2 + 3 +Расшифровка сделана автоматически: в ней бывают ошибки распознавания, оборванные фразы и повторы. Реплики помечены временем `**чч:мм:сс Говорящий:**`; «Я» — владелец заметки, «Собеседники» — остальные участники одной дорожкой, без разделения по людям. Если говорящих нет, запись шла одним файлом. 4 + 5 +Правила ответа: 6 + 7 +- Markdown, без заголовка первого и второго уровня (заголовок поставит программа); подзаголовки — `###`. 8 +- Пиши на языке встречи, коротко и конкретно, без вводных фраз вроде «Вот конспект». 9 +- Опирайся только на заметку. Чего в ней нет — не выдумывай; если данных не хватает, так и скажи одной строкой. 10 +- Ссылаясь на место в разговоре, ставь время из расшифровки: `(00:12:40)`. -
prompts/live.md −10
Показать разницуСкрыть разницу
1 −Ты ведёшь короткий живой конспект встречи, которая идёт прямо сейчас. Человек бросает на него взгляд между репликами, поэтому конспект должен читаться за десять секунд. 2 − 3 −Расшифровка автоматическая, реплики пронумерованы: `#N [время] Говорящий: текст`. «Я» — владелец записи, «Собеседники» — остальные участники. Встречаются ошибки распознавания — не цитируй их, пересказывай смысл. 4 − 5 −Ответь простым текстом, без заголовков, в таком виде: 6 − 7 −- 2–4 пункта: о чём говорят и к чему пришли; 8 −- если прозвучали договорённости или поручения — отдельные пункты, начинающиеся с «Договорились:» или «Задача:». 9 − 10 −Не больше 80 слов. Не выдумывай того, чего не было. Если говорить пока не о чем — ответь одной строкой «Пока идёт разговор без итогов». -
Показать разницуСкрыть разницу
1 +Сделай честное ревью встречи со стороны: чего добились, что осталось открытым, где обсуждение ушло в сторону или ходило по кругу. В конце — одно предложение: стоила ли встреча своего времени и что стоит сделать иначе в следующий раз. Оценивай ход встречи, а не людей. -
Показать разницуСкрыть разницу
1 +Сделай конспект встречи: о чём говорили и чем закончилось — 3–6 предложений, затем список принятых решений. Решение — только то, о чём договорились явно; предложение, которое никто не принял, — не решение. Если решений не было, так и напиши. -
Показать разницуСкрыть разницу
1 +Выпиши задачи — действия, которые кто-то взял на себя или которые кому-то поручили. Каждая задача — пункт списка с галочкой в таком виде: 2 + 3 +- [ ] Что сделать — исполнитель, срок (00:12:40) 4 + 5 +Исполнитель и срок — словами из разговора; если не прозвучали, пиши «исполнитель не назван» и не указывай срок. Сроки не пересчитывай в даты. После списка — «Открытые вопросы»: что осталось без ответа или надо поднять в следующий раз. Если задач не было, так и напиши. -
src-tauri/Cargo.toml +2 −3
Показать разницуСкрыть разницу
13 13 tauri-build = { version = "2.7", features = [] } 14 14 15 15 [dependencies] 16 −tauri = { version = "2.12", features = ["tray-icon", "image-png"] } 17 −tauri-plugin-notification = "2" 18 −tauri-plugin-autostart = "2" 16 +tauri = { version = "2.12", features = [] } 17 +tauri-plugin-dialog = "2" 19 18 meet-audio = { path = "../crates/audio" } 20 19 base64 = "0.22" 21 20 tokio = { version = "1", features = ["rt", "sync", "time", "macros"] } -
Показать разницуСкрыть разницу
1 1 { 2 2 "$schema": "../gen/schemas/desktop-schema.json", 3 3 "identifier": "default", 4 − "description": "Главное окно: только собственные команды приложения", 4 + "description": "Главное окно: команды приложения и выбор файла", 5 5 "windows": ["main"], 6 − "permissions": ["core:default"] 6 + "permissions": ["core:default", "dialog:allow-open"] 7 7 } -
src-tauri/src/ai.rs +145
Показать разницуСкрыть разницу
1 +//! Команды ИИ над заметкой: готовые («Конспект», «Задачи», «Ревью») и свой 2 +//! запрос человека. Модель получает заметку целиком — с правками человека, 3 +//! расшифровкой и прошлыми ответами — и отвечает Markdown, который программа 4 +//! дописывает в конец заметки под заголовком команды. 5 + 6 +use serde::Deserialize; 7 + 8 +const BASE: &str = include_str!("../../prompts/base.md"); 9 +const SUMMARY: &str = include_str!("../../prompts/summary.md"); 10 +const TASKS: &str = include_str!("../../prompts/tasks.md"); 11 +const REVIEW: &str = include_str!("../../prompts/review.md"); 12 + 13 +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 14 +#[serde(tag = "kind", content = "text", rename_all = "snake_case")] 15 +pub enum Command { 16 + Summary, 17 + Tasks, 18 + Review, 19 + /// Свой запрос: «напиши письмо клиенту по итогам». 20 + Custom(String), 21 +} 22 + 23 +#[derive(Debug, thiserror::Error, PartialEq, Eq)] 24 +pub enum Error { 25 + #[error("Напишите, что сделать")] 26 + EmptyRequest, 27 + #[error("Заметка пустая — сначала расшифруйте аудио или напишите текст")] 28 + EmptyNote, 29 +} 30 + 31 +/// Что отправить модели и под каким заголовком вставить ответ. 32 +#[derive(Debug, Clone, PartialEq, Eq)] 33 +pub struct Prepared { 34 + pub heading: String, 35 + pub system: String, 36 + pub user: String, 37 +} 38 + 39 +pub fn prepare(command: &Command, note: &str) -> Result<Prepared, Error> { 40 + if note.trim().is_empty() { 41 + return Err(Error::EmptyNote); 42 + } 43 + let (heading, task) = match command { 44 + Command::Summary => ("Конспект".to_string(), SUMMARY.trim().to_string()), 45 + Command::Tasks => ("Задачи".to_string(), TASKS.trim().to_string()), 46 + Command::Review => ("Ревью".to_string(), REVIEW.trim().to_string()), 47 + Command::Custom(request) => { 48 + let request = request.trim(); 49 + if request.is_empty() { 50 + return Err(Error::EmptyRequest); 51 + } 52 + (heading_for(request), format!("Просьба человека: {request}")) 53 + } 54 + }; 55 + Ok(Prepared { 56 + heading, 57 + system: format!("{}\n\n## Что сделать сейчас\n\n{task}", BASE.trim()), 58 + user: format!("<заметка>\n{}\n</заметка>", note.trim()), 59 + }) 60 +} 61 + 62 +/// Заголовок для своего запроса: сам запрос, укороченный до строки. 63 +fn heading_for(request: &str) -> String { 64 + let line = request.lines().next().unwrap_or(request).trim(); 65 + let mut chars = line.chars(); 66 + let short: String = chars.by_ref().take(60).collect(); 67 + let mut h = if chars.next().is_some() { 68 + format!("{}…", short.trim_end()) 69 + } else { 70 + short 71 + }; 72 + if let Some(first) = h.chars().next() { 73 + h = first.to_uppercase().collect::<String>() + &h[first.len_utf8()..]; 74 + } 75 + h 76 +} 77 + 78 +/// Блок для вставки в заметку. Заголовки второго уровня в ответе модели 79 +/// опускаются на ступень, чтобы не спорить с заголовком команды. 80 +pub fn to_block(heading: &str, answer: &str) -> String { 81 + let body: Vec<String> = answer 82 + .trim() 83 + .lines() 84 + .map(|l| { 85 + if l.starts_with("## ") || l.starts_with("# ") { 86 + format!("### {}", l.trim_start_matches('#').trim()) 87 + } else { 88 + l.to_string() 89 + } 90 + }) 91 + .collect(); 92 + format!("## {heading}\n\n{}\n", body.join("\n")) 93 +} 94 + 95 +#[cfg(test)] 96 +mod tests { 97 + use super::*; 98 + 99 + #[test] 100 + fn ready_commands_have_headings_and_task_text() { 101 + let p = prepare(&Command::Tasks, "## Расшифровка\n\nтекст").unwrap(); 102 + assert_eq!(p.heading, "Задачи"); 103 + assert!(p.system.starts_with("Ты помогаешь человеку работать с заметкой")); 104 + assert!(p.system.contains("- [ ] Что сделать")); 105 + assert_eq!(p.user, "<заметка>\n## Расшифровка\n\nтекст\n</заметка>"); 106 + assert_eq!(prepare(&Command::Summary, "x").unwrap().heading, "Конспект"); 107 + assert_eq!(prepare(&Command::Review, "x").unwrap().heading, "Ревью"); 108 + } 109 + 110 + #[test] 111 + fn custom_request_becomes_heading_and_task() { 112 + let p = prepare(&Command::Custom(" напиши письмо клиенту по итогам ".into()), "x").unwrap(); 113 + assert_eq!(p.heading, "Напиши письмо клиенту по итогам"); 114 + assert!(p 115 + .system 116 + .ends_with("Просьба человека: напиши письмо клиенту по итогам")); 117 + let long = "а".repeat(100); 118 + assert_eq!(heading_for(&long).chars().count(), 61, "60 знаков и многоточие"); 119 + } 120 + 121 + #[test] 122 + fn empty_input_is_refused() { 123 + assert_eq!( 124 + prepare(&Command::Custom(" ".into()), "x"), 125 + Err(Error::EmptyRequest) 126 + ); 127 + assert_eq!(prepare(&Command::Summary, " \n "), Err(Error::EmptyNote)); 128 + } 129 + 130 + #[test] 131 + fn answer_headings_are_demoted() { 132 + assert_eq!( 133 + to_block("Конспект", "# Конспект\n\nТекст.\n## Решения\n- Одно\n### Детали"), 134 + "## Конспект\n\n### Конспект\n\nТекст.\n### Решения\n- Одно\n### Детали\n" 135 + ); 136 + } 137 + 138 + #[test] 139 + fn command_comes_from_the_interface_as_json() { 140 + let c: Command = serde_json::from_str(r#"{"kind":"custom","text":"выпиши цифры"}"#).unwrap(); 141 + assert_eq!(c, Command::Custom("выпиши цифры".into())); 142 + let c: Command = serde_json::from_str(r#"{"kind":"summary"}"#).unwrap(); 143 + assert_eq!(c, Command::Summary); 144 + } 145 +} -
Показать разницуСкрыть разницу
1 −//! Разбор встречи: что модель обязана вернуть, как её об этом просить и как 2 −//! собрать из ответа `summary.md`. 3 −//! 4 −//! Модель отвечает JSON строго по [`schema`] (structured outputs OpenRouter, 5 −//! `strict: true`). Схема и структура [`Analysis`] описывают одно и то же, и 6 −//! тест `schema_matches_struct` держит их вместе: поле, добавленное в одно 7 −//! место и забытое в другом, падает тестом, а не молча пропадает из разбора. 8 − 9 −use serde::{Deserialize, Serialize}; 10 −use serde_json::{json, Value}; 11 − 12 −use crate::transcript::{format_timestamp, Transcript}; 13 − 14 −/// Системный промпт разбора. Лежит файлом, чтобы его правили без Rust. 15 −pub const SYSTEM_PROMPT: &str = include_str!("../../prompts/analysis.md"); 16 − 17 −#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 18 −#[serde(deny_unknown_fields)] 19 −pub struct Analysis { 20 − pub title: String, 21 − pub summary: String, 22 − pub review: Review, 23 − pub decisions: Vec<Decision>, 24 − pub tasks: Vec<Task>, 25 − pub open_questions: Vec<String>, 26 −} 27 − 28 −#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 29 −#[serde(deny_unknown_fields)] 30 −pub struct Review { 31 − pub achieved: Vec<String>, 32 − pub open: Vec<String>, 33 − pub off_track: Vec<String>, 34 − pub verdict: String, 35 −} 36 − 37 −#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 38 −#[serde(deny_unknown_fields)] 39 −pub struct Decision { 40 − pub text: String, 41 − pub source_segment: usize, 42 −} 43 − 44 −#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 45 −#[serde(deny_unknown_fields)] 46 −pub struct Task { 47 − pub text: String, 48 − pub owner: Option<String>, 49 − pub due: Option<String>, 50 − pub quote: String, 51 − pub source_segment: usize, 52 −} 53 − 54 −#[derive(Debug, thiserror::Error, PartialEq, Eq)] 55 −pub enum InvalidAnalysis { 56 − #[error("ответ модели не совпал со схемой: {0}")] 57 − Shape(String), 58 − #[error("{what} ссылается на реплику #{index}, а в расшифровке их {len}")] 59 − SegmentOutOfRange { 60 − what: &'static str, 61 − index: usize, 62 − len: usize, 63 − }, 64 − #[error("пустое поле «{0}»")] 65 − Blank(&'static str), 66 −} 67 − 68 −/// JSON Schema ответа. Только то, что понимают строгие режимы провайдеров: 69 −/// без `minimum`, `format` и прочих ограничений — их проверяет [`Analysis::validate`]. 70 −pub fn schema() -> Value { 71 − let string = json!({ "type": "string" }); 72 − let strings = json!({ "type": "array", "items": { "type": "string" } }); 73 − let nullable = json!({ "anyOf": [{ "type": "string" }, { "type": "null" }] }); 74 − let segment = json!({ "type": "integer", "description": "Номер реплики N из #N" }); 75 − json!({ 76 − "type": "object", 77 − "additionalProperties": false, 78 − "required": ["title", "summary", "review", "decisions", "tasks", "open_questions"], 79 − "properties": { 80 − "title": string, 81 − "summary": string, 82 − "review": { 83 − "type": "object", 84 − "additionalProperties": false, 85 − "required": ["achieved", "open", "off_track", "verdict"], 86 − "properties": { 87 − "achieved": strings, 88 − "open": strings, 89 − "off_track": strings, 90 − "verdict": string 91 − } 92 − }, 93 − "decisions": { 94 − "type": "array", 95 − "items": { 96 − "type": "object", 97 − "additionalProperties": false, 98 − "required": ["text", "source_segment"], 99 − "properties": { "text": string, "source_segment": segment } 100 − } 101 − }, 102 − "tasks": { 103 − "type": "array", 104 − "items": { 105 − "type": "object", 106 − "additionalProperties": false, 107 − "required": ["text", "owner", "due", "quote", "source_segment"], 108 − "properties": { 109 − "text": string, 110 − "owner": nullable, 111 − "due": nullable, 112 − "quote": string, 113 − "source_segment": segment 114 − } 115 − } 116 − }, 117 − "open_questions": strings 118 − } 119 − }) 120 −} 121 − 122 −/// Сообщение пользователя для модели: название, если оно уже есть, и 123 −/// пронумерованная расшифровка. 124 −pub fn user_message(title: &str, transcript: &Transcript) -> String { 125 − format!( 126 − "Встреча: {title}\nДлительность: {}\n\nРасшифровка:\n{}", 127 − format_timestamp(transcript.duration_ms()), 128 − transcript.to_prompt_text() 129 − ) 130 −} 131 − 132 −impl Analysis { 133 − /// Разбирает ответ модели и проверяет то, чего схема выразить не может. 134 − pub fn from_model_json(raw: &str, transcript: &Transcript) -> Result<Self, InvalidAnalysis> { 135 − let analysis: Analysis = 136 − serde_json::from_str(raw).map_err(|e| InvalidAnalysis::Shape(e.to_string()))?; 137 − analysis.validate(transcript)?; 138 − Ok(analysis) 139 − } 140 − 141 − pub fn validate(&self, transcript: &Transcript) -> Result<(), InvalidAnalysis> { 142 − if self.title.trim().is_empty() { 143 − return Err(InvalidAnalysis::Blank("title")); 144 − } 145 − if self.summary.trim().is_empty() { 146 − return Err(InvalidAnalysis::Blank("summary")); 147 − } 148 − let len = transcript.segments.len(); 149 − let check = |what, index: usize| { 150 − if index < len { 151 − Ok(()) 152 − } else { 153 − Err(InvalidAnalysis::SegmentOutOfRange { what, index, len }) 154 − } 155 − }; 156 − for d in &self.decisions { 157 − check("решение", d.source_segment)?; 158 − } 159 − for t in &self.tasks { 160 − check("задача", t.source_segment)?; 161 − } 162 − Ok(()) 163 − } 164 − 165 − /// `summary.md`: конспект, ревью, решения, задачи, вопросы. Время у 166 − /// решений и задач берётся из реплики, на которую сослалась модель. 167 − pub fn to_markdown(&self, transcript: &Transcript) -> String { 168 − let at = |i: usize| { 169 − transcript 170 − .segments 171 − .get(i) 172 − .map(|s| format!(" `{}`", format_timestamp(s.start_ms))) 173 − .unwrap_or_default() 174 − }; 175 − let mut out = format!("# {}\n\n{}\n\n## Ревью\n\n", self.title, self.summary); 176 − push_list(&mut out, "Чего добились", &self.review.achieved); 177 − push_list(&mut out, "Осталось открытым", &self.review.open); 178 − push_list(&mut out, "Где ушли в сторону", &self.review.off_track); 179 − out.push_str(&format!("**Итог:** {}\n\n", self.review.verdict)); 180 − 181 − out.push_str("## Решения\n\n"); 182 − if self.decisions.is_empty() { 183 − out.push_str("Явных решений не было.\n\n"); 184 − } else { 185 − for d in &self.decisions { 186 − out.push_str(&format!("- {}{}\n", d.text, at(d.source_segment))); 187 − } 188 − out.push('\n'); 189 − } 190 − 191 − out.push_str("## Задачи\n\n"); 192 − if self.tasks.is_empty() { 193 − out.push_str("Задач не прозвучало.\n\n"); 194 − } else { 195 − for t in &self.tasks { 196 − let owner = t.owner.as_deref().unwrap_or("исполнитель не назван"); 197 − let due = t.due.as_deref().map(|d| format!(", {d}")).unwrap_or_default(); 198 − out.push_str(&format!( 199 − "- [ ] {} — {owner}{due}{}\n > {}\n", 200 − t.text, 201 − at(t.source_segment), 202 − t.quote Разница обрезана: показаны первые 202 строк. -
src-tauri/src/commands.rs +375 −61
Показать разницуСкрыть разницу
1 −//! Команды для интерфейса. Ошибка уходит строкой — её текст показывается 2 −//! пользователю как есть, поэтому все ошибки модулей написаны по-русски. 1 +//! Команды для интерфейса. Каждая — одно действие человека; сама программа 2 +//! ничего не запускает. Ошибка уходит строкой и показывается как есть, поэтому 3 +//! ошибки модулей написаны по-русски. 3 4 4 −use std::path::PathBuf; 5 −use std::sync::Arc; 5 +use std::collections::HashSet; 6 +use std::path::{Path, PathBuf}; 7 +use std::sync::{Arc, Mutex}; 6 8 7 −use chrono::Local; 9 +use chrono::{DateTime, Local}; 8 10 use serde::Serialize; 9 −use tauri::State; 11 +use tauri::{AppHandle, Emitter, State}; 10 12 11 −use crate::controller::{Controller, Status}; 12 −use crate::meetings::{Meeting, MeetingDetail, Store}; 13 −use crate::openrouter::{Client, KeyInfo}; 13 +use meet_audio::source::{Capture, CaptureError}; 14 + 15 +use crate::ai::{self, Command}; 16 +use crate::meetings::{AudioTrack, Meeting, MeetingDetail, Store}; 17 +use crate::openrouter::{Client, KeyInfo, SttSegment}; 18 +use crate::recorder::{self, Session, Track}; 14 19 use crate::settings::{mask_key, Secrets, Settings, OPENROUTER_KEY}; 20 +use crate::transcribe::{self, BoxFuture, Stt}; 21 +use crate::transcript::{self, Speaker}; 22 + 23 +pub struct Recording { 24 + meeting_id: String, 25 + started_at: DateTime<Local>, 26 + zoom: bool, 27 + session: Session, 28 +} 15 29 16 30 pub struct AppState { 17 31 pub settings_path: PathBuf, 18 32 pub store: Arc<Store>, 19 33 pub secrets: Arc<dyn Secrets>, 20 − pub controller: Arc<Controller>, 34 + pub recording: Mutex<Option<Recording>>, 35 + /// Встречи, которые сейчас расшифровываются или обрабатываются ИИ. 36 + pub busy: Mutex<HashSet<String>>, 21 37 } 22 38 23 39 impl AppState { 29 45 self.secrets 30 46 .get(OPENROUTER_KEY) 31 47 .map_err(|e| e.to_string())? 32 − .ok_or_else(|| "Ключ OpenRouter не задан — добавьте его в настройках".into()) 48 + .ok_or_else(|| "Не подключён ИИ: добавьте ключ OpenRouter в настройках".into()) 49 + } 50 + 51 + /// Отмечает встречу занятой на время работы; вторая такая же работа — отказ. 52 + fn claim(&self, id: &str) -> Result<BusyGuard<'_>, String> { 53 + let mut busy = self.busy.lock().unwrap_or_else(|p| p.into_inner()); 54 + if !busy.insert(id.to_string()) { 55 + return Err("Со встречей уже идёт работа — дождитесь её".into()); 56 + } 57 + Ok(BusyGuard { 58 + state: self, 59 + id: id.to_string(), 60 + }) 61 + } 62 + 63 + fn is_recording(&self, id: &str) -> bool { 64 + self.recording 65 + .lock() 66 + .unwrap_or_else(|p| p.into_inner()) 67 + .as_ref() 68 + .is_some_and(|r| r.meeting_id == id) 33 69 } 34 70 } 35 71 36 −/// Итоговый разбор встречи и запись его на диск. Один путь и для кнопки 37 −/// «Разобрать», и для разбора, который запускается сам после записи. 38 −pub async fn analyze(store: &Store, settings: &Settings, key: &str, id: &str) -> Result<Meeting, String> { 39 − let detail = store.get(id).map_err(|e| e.to_string())?; 40 − if detail.transcript.segments.is_empty() { 41 − return Err("В расшифровке нет ни одной реплики".into()); 72 +struct BusyGuard<'a> { 73 + state: &'a AppState, 74 + id: String, 75 +} 76 + 77 +impl Drop for BusyGuard<'_> { 78 + fn drop(&mut self) { 79 + self.state 80 + .busy 81 + .lock() 82 + .unwrap_or_else(|p| p.into_inner()) 83 + .remove(&self.id); 42 84 } 43 − let model = &settings.analysis_model; 44 − let (analysis, usage) = Client::new(key) 45 − .analyze(model, &detail.meeting.title, &detail.transcript) 46 − .await 47 − .map_err(|e| e.to_string())?; 48 − store 49 − .save_analysis(id, &analysis, model, usage, Local::now()) 50 − .map_err(|e| e.to_string()) 51 85 } 52 86 87 +fn title_now(prefix: &str) -> String { 88 + format!("{prefix} {}", Local::now().format("%d.%m, %H:%M")) 89 +} 90 + 91 +// ── Настройки и ключ ──────────────────────────────────────────────────────── 92 + 53 93 #[derive(Serialize)] 54 94 pub struct SettingsView { 55 95 settings: Settings, 56 96 /// Ключ с закрытой серединой; `None` — ключа нет. 57 97 openrouter_key: Option<String>, 58 98 meetings_dir: String, 59 − /// Эта машина умеет записывать Zoom. 99 + /// Эта машина умеет записывать звук (Windows). 60 100 capture_supported: bool, 61 101 } 62 102 70 110 .map_err(|e| e.to_string())? 71 111 .map(|k| mask_key(&k)), 72 112 meetings_dir: state.store.root().display().to_string(), 73 − capture_supported: state.controller.status() != Status::Unsupported, 113 + capture_supported: meet_audio::CAPTURE_SUPPORTED, 74 114 }) 75 115 } 76 116 77 117 #[tauri::command] 78 −pub fn save_settings( 79 − app: tauri::AppHandle, 80 − state: State<'_, AppState>, 81 − settings: Settings, 82 −) -> Result<(), String> { 83 − settings.save(&state.settings_path).map_err(|e| e.to_string())?; 84 − crate::shell::apply_autostart(&app, settings.launch_at_login); 85 − Ok(()) 118 +pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Result<(), String> { 119 + settings.save(&state.settings_path).map_err(|e| e.to_string()) 86 120 } 87 121 88 −/// Ключ сохраняется, только если OpenRouter его принял: нерабочий ключ в 89 −/// хранилище — это ошибка, которую пользователь увидит лишь на первой встрече. 122 +/// Ключ сохраняется, только если OpenRouter его принял. 90 123 #[tauri::command] 91 124 pub async fn set_openrouter_key(state: State<'_, AppState>, key: String) -> Result<KeyInfo, String> { 92 125 let key = key.trim().to_string(); 117 150 state.secrets.delete(OPENROUTER_KEY).map_err(|e| e.to_string()) 118 151 } 119 152 153 +// ── Встречи и заметки ─────────────────────────────────────────────────────── 154 + 120 155 #[tauri::command] 121 156 pub fn list_meetings(state: State<'_, AppState>) -> Result<Vec<Meeting>, String> { 122 157 state.store.list().map_err(|e| e.to_string()) 128 163 } 129 164 130 165 #[tauri::command] 131 −pub fn import_meeting(state: State<'_, AppState>, title: String, text: String) -> Result<Meeting, String> { 166 +pub fn create_meeting(state: State<'_, AppState>) -> Result<Meeting, String> { 132 167 state 133 168 .store 134 − .import(&title, &text, Local::now()) 169 + .create(&title_now("Заметка"), Local::now()) 135 170 .map_err(|e| e.to_string()) 136 171 } 137 172 138 173 #[tauri::command] 139 −pub async fn analyze_meeting(state: State<'_, AppState>, id: String) -> Result<Meeting, String> { 140 − let settings = state.settings()?; 141 − let key = state.key()?; 142 − analyze(&state.store, &settings, &key, &id).await 174 +pub fn save_note(state: State<'_, AppState>, id: String, text: String) -> Result<Meeting, String> { 175 + state.store.save_note(&id, &text).map_err(|e| e.to_string()) 143 176 } 144 177 145 −// ── Запись ────────────────────────────────────────────────────────────────── 178 +#[tauri::command] 179 +pub fn rename_meeting(state: State<'_, AppState>, id: String, title: String) -> Result<Meeting, String> { 180 + state.store.rename(&id, &title).map_err(|e| e.to_string()) 181 +} 146 182 147 183 #[tauri::command] 148 −pub fn recorder_status(state: State<'_, AppState>) -> Status { 149 − state.controller.status() 184 +pub fn delete_meeting(state: State<'_, AppState>, id: String) -> Result<(), String> { 185 + if state.is_recording(&id) { 186 + return Err("Сначала остановите запись".into()); 187 + } 188 + let _busy = state.claim(&id)?; 189 + state.store.delete(&id).map_err(|e| e.to_string()) 150 190 } 151 191 192 +/// Новая встреча из аудиофайла. Файл сразу читается целиком: так сразу видно, 193 +/// что формат подходит, и известна длительность. 152 194 #[tauri::command] 153 −pub fn recorder_consent(state: State<'_, AppState>, record: bool) -> Result<(), String> { 154 − state.controller.consent(record) 195 +pub async fn add_audio(state: State<'_, AppState>, path: String) -> Result<Meeting, String> { Разница обрезана: показаны первые 202 строк. -
Показать разницуСкрыть разницу
1 −//! Что программа делает сама: следит за Zoom, спрашивает отметку о 2 −//! предупреждении участников, пишет встречу, по окончании дописывает 3 −//! расшифровку и запускает разбор. 4 −//! 5 −//! ```text 6 −//! встреча началась «Предупредил, записывать» 7 −//! Watching ─────────────────► AwaitingConsent ─────────────────► Recording 8 −//! ▲ │ (или сразу, если «предупреждаю всегда сам») │ 9 −//! │ └──────────── «Записать вручную» ───────────────────────────►│ 10 −//! │ встреча кончилась / «Стоп» ▼ 11 −//! └──────────────────────────────── Finishing (расшифровка → разбор) 12 −//! ``` 13 −//! 14 −//! Контроллер не знает про Tauri: окно, уведомления и события интерфейса — 15 −//! через [`Outlet`], источники звука — через [`Sources`]. Поэтому переходы 16 −//! проверяются тестами на любой машине. 17 − 18 −use std::path::PathBuf; 19 −use std::sync::{Arc, Mutex}; 20 −use std::time::{Duration, Instant}; 21 − 22 −use chrono::{DateTime, Local}; 23 −use meet_audio::detector::{self, Detector}; 24 −use serde::Serialize; 25 − 26 −use crate::meetings::{Meeting, Store}; 27 −use crate::openrouter::{Client, SttSegment}; 28 −use crate::recorder::{self, BoxFuture, Session, Track, Transcriber}; 29 −use crate::settings::{Secrets, Settings, OPENROUTER_KEY}; 30 −use crate::transcript::Transcript; 31 − 32 −#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] 33 −#[serde(rename_all = "snake_case")] 34 −pub enum FinishStep { 35 − /// Дописываем расшифровку последних кусков. 36 − Transcribing, 37 − /// Модель пишет разбор. 38 − Analyzing, 39 −} 40 − 41 −#[derive(Debug, Clone, PartialEq, Serialize)] 42 −#[serde(tag = "kind", rename_all = "snake_case")] 43 −pub enum Status { 44 − /// На этой машине звук Zoom не записать (не Windows). 45 − Unsupported, 46 − /// Жду встречу в Zoom. 47 − Watching, 48 − /// Встреча началась, ждём отметку «участники предупреждены». 49 − AwaitingConsent, 50 − Recording { 51 − meeting_id: String, 52 − started_at: DateTime<Local>, 53 − paused: bool, 54 − /// Пишется звук Zoom (а не только микрофон). 55 − zoom: bool, 56 − }, 57 − Finishing { 58 − meeting_id: String, 59 − step: FinishStep, 60 − }, 61 −} 62 − 63 −/// Всё, что видит пользователь. 64 −pub trait Outlet: Send + Sync + 'static { 65 − fn status(&self, status: &Status); 66 − fn recorder_event(&self, meeting_id: &str, event: &recorder::Event); 67 − /// Встреча изменилась на диске (дописана, разобрана). 68 − fn meeting_changed(&self, meeting_id: &str); 69 − fn notify(&self, title: &str, body: &str); 70 − fn show_window(&self); 71 −} 72 − 73 −/// Источники звука этой машины. 74 −pub trait Sources: Send + Sync + 'static { 75 − fn supported(&self) -> bool; 76 − /// Процесс Zoom с активным звуком, если он есть. 77 − fn active_zoom(&self) -> Option<u32>; 78 − /// Дорожки записи: микрофон и, если есть `zoom_pid`, звук Zoom. 79 − fn open(&self, zoom_pid: Option<u32>) -> Result<Vec<Track>, String>; 80 −} 81 − 82 −/// Распознавание и конспект по ключу и настройкам. 83 −pub type MakeAi = Arc<dyn Fn(&str, &Settings) -> Arc<dyn Transcriber> + Send + Sync>; 84 −/// Итоговый разбор встречи: (id, настройки, ключ). 85 −pub type Analyze = 86 − Arc<dyn Fn(String, Settings, String) -> BoxFuture<'static, Result<(), String>> + Send + Sync>; 87 − 88 −pub struct Deps { 89 − pub store: Arc<Store>, 90 − pub settings_path: PathBuf, 91 − pub secrets: Arc<dyn Secrets>, 92 − pub outlet: Arc<dyn Outlet>, 93 − pub sources: Arc<dyn Sources>, 94 − pub rt: tokio::runtime::Handle, 95 − /// Распознавание и конспект по ключу; в тестах — подделка. 96 − pub make_ai: MakeAi, 97 − /// Итоговый разбор; в тестах — подделка. 98 − pub analyze: Analyze, 99 − pub recorder: recorder::Config, 100 −} 101 − 102 −struct Inner { 103 − status: Status, 104 − session: Option<Session>, 105 − detector: Detector, 106 − /// Процесс Zoom текущей встречи. 107 − zoom_pid: Option<u32>, 108 − /// Предупредили, что без ключа записи не будет, — один раз за встречу. 109 − warned_no_key: bool, 110 −} 111 − 112 −pub struct Controller { 113 − deps: Deps, 114 − clock: Instant, 115 − inner: Mutex<Inner>, 116 −} 117 − 118 −impl Controller { 119 − pub fn new(deps: Deps) -> Arc<Self> { 120 − let status = if deps.sources.supported() { 121 − Status::Watching 122 − } else { 123 − Status::Unsupported 124 − }; 125 − Arc::new(Self { 126 − deps, 127 − clock: Instant::now(), 128 − inner: Mutex::new(Inner { 129 − status, 130 − session: None, 131 − detector: Detector::new(detector::Config::default()), 132 − zoom_pid: None, 133 − warned_no_key: false, 134 − }), 135 − }) 136 − } 137 − 138 − fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { 139 − self.inner.lock().unwrap_or_else(|p| p.into_inner()) 140 − } 141 − 142 − pub fn status(&self) -> Status { 143 − self.lock().status.clone() 144 − } 145 − 146 − fn set_status(&self, inner: &mut Inner, status: Status) { 147 − inner.status = status; 148 − self.deps.outlet.status(&inner.status); 149 − } 150 − 151 − fn settings(&self) -> Settings { 152 − Settings::load(&self.deps.settings_path).unwrap_or_default() 153 − } 154 − 155 − fn key(&self) -> Option<String> { 156 − self.deps.secrets.get(OPENROUTER_KEY).ok().flatten() 157 − } 158 − 159 − /// Опрос Zoom раз в секунду в своём потоке. 160 − pub fn spawn_watch(self: &Arc<Self>) { 161 − if !self.deps.sources.supported() { 162 − return; 163 − } 164 − let this = self.clone(); 165 − std::thread::Builder::new() 166 − .name("meet-watch".into()) 167 − .spawn(move || loop { 168 − this.tick(); 169 − std::thread::sleep(Duration::from_secs(1)); 170 − }) 171 − .expect("поток наблюдения"); 172 − } 173 − 174 − /// Один опрос: что с Zoom сейчас. 175 − pub fn tick(self: &Arc<Self>) { 176 − self.tick_at(self.clock.elapsed().as_millis() as u64); 177 − } 178 − 179 − /// Опрос в момент `now_ms` — тесты подают время сами, не ожидая секунд. 180 − pub fn tick_at(self: &Arc<Self>, now_ms: u64) { 181 − let active = self.deps.sources.active_zoom(); 182 − let event = self.lock().detector.poll(now_ms, active); 183 − match event { 184 − Some(detector::Event::Started { pid }) => self.meeting_started(pid), 185 − Some(detector::Event::Ended) => self.meeting_ended(), 186 − None => {} 187 − } 188 − } 189 − 190 − fn meeting_started(self: &Arc<Self>, pid: u32) { 191 − let mut inner = self.lock(); 192 − inner.zoom_pid = Some(pid); 193 − if inner.status != Status::Watching { 194 − return; 195 − } 196 − if self.key().is_none() { 197 − if !inner.warned_no_key { 198 − inner.warned_no_key = true; 199 − self.deps.outlet.notify( 200 − "Началась встреча в Zoom", 201 − "Записать не получится: не подключён ИИ. Добавьте ключ OpenRouter в настройках.", 202 − ); Разница обрезана: показаны первые 202 строк. -
src-tauri/src/lib.rs +18 −58
Показать разницуСкрыть разницу
1 1 //! Skillok Meet — ядро приложения. Интерфейс зовёт команды отсюда через 2 2 //! `invoke`; всё, что трогает диск, сеть, звук и ключи, живёт в Rust. 3 +//! Программа ничего не делает сама — только по действию человека. 3 4 4 −pub mod analysis; 5 −pub mod controller; 5 +pub mod ai; 6 6 pub mod meetings; 7 7 pub mod openrouter; 8 8 pub mod recorder; 9 9 pub mod settings; 10 +pub mod transcribe; 10 11 pub mod transcript; 11 12 12 13 mod commands; 13 −mod shell; 14 14 15 −use std::sync::Arc; 15 +use std::collections::HashSet; 16 +use std::sync::{Arc, Mutex}; 16 17 17 18 use tauri::Manager; 18 19 19 −use controller::{Controller, Deps, OpenRouterAi, SystemSources}; 20 −use settings::Settings; 21 − 22 20 pub fn run() { 23 21 tauri::Builder::default() 24 − .plugin(tauri_plugin_notification::init()) 25 − .plugin(tauri_plugin_autostart::init( 26 − tauri_plugin_autostart::MacosLauncher::LaunchAgent, 27 − Some(vec!["--hidden"]), 28 − )) 22 + .plugin(tauri_plugin_dialog::init()) 29 23 .setup(|app| { 30 − let handle = app.handle().clone(); 31 24 let config_dir = app.path().app_config_dir()?; 32 25 let data_dir = app.path().app_data_dir()?; 33 − let settings_path = settings::settings_path(&config_dir); 34 26 let store = Arc::new(meetings::Store::new(data_dir.join("meetings"))); 35 − // Запись, оборванная выключением компьютера, — «прервана», а не «идёт». 27 + // Запись, оборванная выключением компьютера, перестаёт считаться идущей. 36 28 let _ = store.recover_interrupted(); 37 − let secrets: Arc<dyn settings::Secrets> = Arc::new(settings::Keyring); 38 − 39 − let analyze_store = store.clone(); 40 − let controller = Controller::new(Deps { 41 − store: store.clone(), 42 − settings_path: settings_path.clone(), 43 − secrets: secrets.clone(), 44 − outlet: Arc::new(shell::TauriOutlet { app: handle.clone() }), 45 − sources: Arc::new(SystemSources::from_env()), 46 − rt: tauri::async_runtime::handle().inner().clone(), 47 − make_ai: Arc::new(|key, settings| Arc::new(OpenRouterAi::new(key, settings))), 48 − analyze: Arc::new(move |id, settings, key| { 49 − let store = analyze_store.clone(); 50 − Box::pin(async move { commands::analyze(&store, &settings, &key, &id).await.map(|_| ()) }) 51 − }), 52 − recorder: recorder::Config::default(), 53 − }); 54 − controller.spawn_watch(); 55 − 56 29 app.manage(commands::AppState { 57 − settings_path: settings_path.clone(), 30 + settings_path: settings::settings_path(&config_dir), 58 31 store, 59 − secrets, 60 − controller, 32 + secrets: Arc::new(settings::Keyring), 33 + recording: Mutex::new(None), 34 + busy: Mutex::new(HashSet::new()), 61 35 }); 62 − shell::build_tray(&handle)?; 63 − let launch = Settings::load(&settings_path) 64 − .map(|s| s.launch_at_login) 65 − .unwrap_or(true); 66 − shell::apply_autostart(&handle, launch); 67 − 68 − // Запуск вместе с Windows — сразу в трей, без окна. 69 − if std::env::args().any(|a| a == "--hidden") { 70 − if let Some(w) = app.get_webview_window("main") { 71 − let _ = w.hide(); 72 − } 73 − } 74 36 Ok(()) 75 37 }) 76 − .on_window_event(|window, event| { 77 − if let tauri::WindowEvent::CloseRequested { api, .. } = event { 78 − api.prevent_close(); 79 − let _ = window.hide(); 80 − } 81 − }) 82 38 .invoke_handler(tauri::generate_handler![ 83 39 commands::get_settings, 84 40 commands::save_settings, 87 43 commands::delete_openrouter_key, 88 44 commands::list_meetings, 89 45 commands::get_meeting, 90 − commands::import_meeting, 91 − commands::analyze_meeting, 46 + commands::create_meeting, 47 + commands::save_note, 48 + commands::rename_meeting, 49 + commands::delete_meeting, 50 + commands::add_audio, 51 + commands::transcribe_meeting, 52 + commands::run_command, 92 53 commands::recorder_status, 93 − commands::recorder_consent, 94 54 commands::recorder_start, 95 55 commands::recorder_pause, 96 56 commands::recorder_stop, -
src-tauri/src/meetings.rs +211 −321
Показать разницуСкрыть разницу
1 −//! Встречи на диске пользователя. Каждая — своя папка: 1 +//! Встречи на диске. Встреча — это заметка в Markdown и, если есть, звук: 2 2 //! 3 3 //! ```text 4 4 //! <корень>/20260930-154300/ 5 −//! meeting.json — название, когда, откуда, модель и цена разбора 6 −//! transcript.json — реплики 7 −//! transcript.md — расшифровка для людей 8 −//! analysis.json — разбор (после «Разобрать») 9 −//! summary.md — разбор для людей; это его сохраним в Skillok на этапе 2 10 −//! live.md — живой конспект, пока идёт запись 11 −//! audio/me.wav, audio/others.wav — дорожки записи; удаляются после разбора, 12 −//! если в настройках не сказано хранить звук 5 +//! meeting.json — название, даты, дорожки звука, когда расшифрована 6 +//! note.md — заметка: её пишет человек, в неё же ложатся расшифровка 7 +//! и ответы ИИ; единственный источник текста 8 +//! audio/ — дорожки: me.wav и others.wav из записи или исходный файл 13 9 //! ``` 14 10 //! 15 −//! Запись меняет встречу много раз в минуту из разных задач (реплики, живой 16 −//! конспект, разбор), поэтому каждое изменение «прочитал — поменял — записал» 17 −//! идёт под одним замком: иначе две задачи затирали бы друг друга. 11 +//! Программа ничего не делает сама: каждое изменение — по действию человека 12 +//! (записать, добавить аудио, расшифровать, команда ИИ, правка заметки). 13 +//! Изменения «прочитал — поменял — записал» идут под одним замком: расшифровка 14 +//! дописывается в заметку, пока человек может её править. 18 15 19 16 use std::path::{Path, PathBuf}; 20 17 use std::sync::Mutex; 22 19 use chrono::{DateTime, Local}; 23 20 use serde::{Deserialize, Serialize}; 24 21 25 −use crate::analysis::Analysis; 26 −use crate::openrouter::Usage; 27 −use crate::transcript::{ParseError, Segment, Speaker, Transcript}; 28 − 29 −#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 30 −#[serde(rename_all = "snake_case")] 31 −pub enum Source { 32 − /// Текст расшифровки, который пользователь вставил сам. 33 − Import, 34 − /// Запись Zoom или микрофона. 35 − Recording, 36 −} 22 +use crate::transcript::Speaker; 37 23 38 −#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] 39 −#[serde(rename_all = "snake_case")] 40 −pub enum MeetingState { 41 − /// Идёт запись: реплики ещё прибывают. 42 − Recording, 43 − #[default] 44 − Done, 45 − /// Программа закрылась посреди записи; что успело — сохранено. 46 − Interrupted, 24 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 25 +pub struct AudioTrack { 26 + /// Имя файла в папке `audio/`. 27 + pub file: String, 28 + pub speaker: Speaker, 29 + pub duration_ms: u64, 47 30 } 48 31 49 32 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 51 34 pub id: String, 52 35 pub title: String, 53 36 pub created_at: DateTime<Local>, 54 − pub source: Source, 55 − pub duration_ms: u64, 56 − pub segments: usize, 57 − pub analysis: Option<AnalysisMeta>, 37 + pub updated_at: DateTime<Local>, 38 + pub audio: Vec<AudioTrack>, 39 + /// Идёт запись: дорожки ещё пишутся. 58 40 #[serde(default)] 59 − pub state: MeetingState, 41 + pub recording: bool, 42 + pub transcribed_at: Option<DateTime<Local>>, 60 43 } 61 44 62 −#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 63 −pub struct AnalysisMeta { 64 − pub model: String, 65 − pub at: DateTime<Local>, 66 − pub usage: Usage, 67 − pub tasks: usize, 45 +impl Meeting { 46 + pub fn duration_ms(&self) -> u64 { 47 + self.audio.iter().map(|a| a.duration_ms).max().unwrap_or(0) 48 + } 68 49 } 69 50 70 51 #[derive(Debug, Clone, PartialEq, Serialize)] 71 52 pub struct MeetingDetail { 72 53 pub meeting: Meeting, 73 − pub transcript: Transcript, 74 − pub analysis: Option<Analysis>, 75 − pub live_summary: Option<String>, 54 + pub note: String, 76 55 } 77 56 78 57 #[derive(Debug, thiserror::Error)] 79 58 pub enum Error { 80 59 #[error("встреча «{0}» не найдена")] 81 60 NotFound(String), 82 − #[error("название встречи не может быть пустым")] 61 + #[error("название не может быть пустым")] 83 62 BlankTitle, 84 − #[error("расшифровка не разбирается: {0}")] 85 − Parse(#[from] ParseError), 86 63 #[error("файл встречи: {0}")] 87 64 Io(#[from] std::io::Error), 88 65 #[error("файл встречи повреждён: {0}")] 102 79 } 103 80 } 104 81 105 − fn lock(&self) -> std::sync::MutexGuard<'_, ()> { 106 − self.write.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) 107 − } 108 − 109 82 pub fn root(&self) -> &Path { 110 83 &self.root 111 84 } 112 85 113 − /// Все встречи, свежие сверху. Папка с повреждённым `meeting.json` 114 − /// пропускается, а не роняет весь список. 86 + fn lock(&self) -> std::sync::MutexGuard<'_, ()> { 87 + self.write.lock().unwrap_or_else(|p| p.into_inner()) 88 + } 89 + 90 + /// Все встречи, свежие сверху. Повреждённая папка пропускается. 115 91 pub fn list(&self) -> Result<Vec<Meeting>, Error> { 116 92 let entries = match std::fs::read_dir(&self.root) { 117 93 Ok(e) => e, 118 94 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), 119 95 Err(e) => return Err(e.into()), 120 96 }; 121 − let mut out = Vec::new(); 122 − for entry in entries.flatten() { 123 − let file = entry.path().join("meeting.json"); 124 − if let Ok(text) = std::fs::read_to_string(&file) { 125 − if let Ok(m) = serde_json::from_str::<Meeting>(&text) { 126 − out.push(m); 127 − } 128 − } 129 − } 97 + let mut out: Vec<Meeting> = entries 98 + .flatten() 99 + .filter_map(|e| std::fs::read_to_string(e.path().join("meeting.json")).ok()) 100 + .filter_map(|t| serde_json::from_str(&t).ok()) 101 + .collect(); 130 102 out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id))); 131 103 Ok(out) 132 104 } 133 105 134 106 pub fn get(&self, id: &str) -> Result<MeetingDetail, Error> { 135 107 let dir = self.dir(id)?; 136 − let meeting: Meeting = read_json(&dir.join("meeting.json"))?; 137 − let transcript: Transcript = read_json(&dir.join("transcript.json"))?; 138 − let analysis_path = dir.join("analysis.json"); 139 − let analysis = if analysis_path.exists() { 140 − Some(read_json(&analysis_path)?) 141 − } else { 142 − None 143 − }; 144 − let live_summary = std::fs::read_to_string(dir.join("live.md")).ok(); 145 108 Ok(MeetingDetail { 146 − meeting, 147 − transcript, 148 − analysis, 149 − live_summary, 109 + meeting: read_json(&dir.join("meeting.json"))?, 110 + note: std::fs::read_to_string(dir.join("note.md")).unwrap_or_default(), 150 111 }) 151 112 } 152 113 153 − /// Новая встреча из вставленного текста расшифровки. 154 − pub fn import(&self, title: &str, text: &str, now: DateTime<Local>) -> Result<Meeting, Error> { 114 + /// Новая встреча: пустая заметка, звука нет. 115 + pub fn create(&self, title: &str, now: DateTime<Local>) -> Result<Meeting, Error> { 155 116 let title = title.trim(); 156 117 if title.is_empty() { 157 118 return Err(Error::BlankTitle); 158 119 } 159 − let transcript = Transcript::parse(text)?; 160 120 let _guard = self.lock(); 161 − let id = self.free_id(now)?; 121 + let id = self.free_id(now); 162 122 let dir = self.root.join(&id); 163 − std::fs::create_dir_all(&dir)?; 123 + std::fs::create_dir_all(dir.join("audio"))?; 164 124 let meeting = Meeting { 165 125 id, 166 126 title: title.to_string(), 167 127 created_at: now, 168 − source: Source::Import, 169 − duration_ms: transcript.duration_ms(), 170 − segments: transcript.segments.len(), 171 − analysis: None, 172 − state: MeetingState::Done, 128 + updated_at: now, 129 + audio: Vec::new(), 130 + recording: false, 131 + transcribed_at: None, 173 132 }; 174 − write_json(&dir.join("transcript.json"), &transcript)?; 175 − std::fs::write(dir.join("transcript.md"), transcript.to_markdown(title))?; 176 − // meeting.json пишется последним: пока его нет, список встречу не видит. 133 + std::fs::write(dir.join("note.md"), "")?; 134 + // meeting.json последним: пока его нет, список встречу не видит. 177 135 write_json(&dir.join("meeting.json"), &meeting)?; 178 136 Ok(meeting) 179 137 } 180 138 181 − pub fn save_analysis( 139 + /// Путь для дорожки внутри встречи. Разница обрезана: показаны первые 216 строк. -
src-tauri/src/openrouter.rs +27 −138
Показать разницуСкрыть разницу
1 1 //! Клиент OpenRouter: проверка ключа и разбор встречи. 2 2 //! 3 −//! Один ключ пользователя на всё: разбор, живой конспект и распознавание речи. 3 +//! Один ключ пользователя на всё: распознавание речи и команды ИИ над заметкой. 4 4 //! Документация: 5 5 //! - ключ: `GET /api/v1/key` — https://openrouter.ai/docs/api/reference/limits 6 6 //! - речь: `POST /api/v1/audio/transcriptions` — 15 15 16 16 use base64::Engine; 17 17 18 −use crate::analysis::{self, Analysis, InvalidAnalysis}; 19 −use crate::transcript::Transcript; 20 − 21 −/// Промпт живого конспекта по ходу встречи. 22 −pub const LIVE_PROMPT: &str = include_str!("../../prompts/live.md"); 23 − 24 18 pub const DEFAULT_BASE: &str = "https://openrouter.ai/api/v1"; 25 19 26 20 #[derive(Debug, thiserror::Error)] 37 31 Network(String), 38 32 #[error("Модель не закончила ответ ({0}) — попробуйте ещё раз или другую модель")] 39 33 Incomplete(String), 40 − #[error("Модель вернула негодный разбор: {0}")] 41 − BadAnswer(#[from] InvalidAnalysis), 42 34 } 43 35 44 36 /// Что показать в настройках после проверки ключа. 97 89 }) 98 90 } 99 91 100 − /// Итоговый разбор встречи моделью `model` строго по [`analysis::schema`]. 101 − pub async fn analyze( 102 − &self, 103 − model: &str, 104 − title: &str, 105 − transcript: &Transcript, 106 − ) -> Result<(Analysis, Usage), Error> { 107 − let request = json!({ 108 − "model": model, 109 − "max_tokens": 16000, 110 − "messages": [ 111 − { "role": "system", "content": analysis::SYSTEM_PROMPT }, 112 − { "role": "user", "content": analysis::user_message(title, transcript) } 113 − ], 114 − "response_format": { 115 − "type": "json_schema", 116 − "json_schema": { "name": "meeting_analysis", "strict": true, "schema": analysis::schema() } 117 − }, 118 − // Только провайдеры, которые действительно держат строгую схему. 119 − "provider": { "require_parameters": true } 120 − }); 121 − let body = self 122 − .send( 123 − self.http 124 − .post(format!("{}/chat/completions", self.base)) 125 − .json(&request), 126 − ) 127 − .await?; 128 − 129 − let choice = &body["choices"][0]; 130 − let finish = choice["finish_reason"].as_str().unwrap_or(""); 131 − if finish != "stop" { 132 − return Err(Error::Incomplete(if finish.is_empty() { 133 − "нет ответа".into() 134 − } else { 135 − finish.into() 136 − })); 137 − } 138 − let content = choice["message"]["content"].as_str().unwrap_or(""); 139 − let analysis = Analysis::from_model_json(content, transcript)?; 140 − Ok((analysis, usage_of(&body))) 141 − } 142 − 143 92 /// Распознаёт кусок звука (WAV). Время реплик — от начала куска. 144 93 pub async fn transcribe( 145 94 &self, 170 119 Ok(Transcription::from_response(&body)) 171 120 } 172 121 173 − /// Короткий живой конспект по расшифровке, накопленной к этому моменту. 174 − pub async fn live_summary(&self, model: &str, transcript: &Transcript) -> Result<(String, Usage), Error> { 122 + /// Ответ модели простым текстом (Markdown): команда ИИ над заметкой. 123 + pub async fn complete(&self, model: &str, system: &str, user: &str) -> Result<(String, Usage), Error> { 175 124 let request = json!({ 176 125 "model": model, 177 − "max_tokens": 1200, 126 + "max_tokens": 16000, 178 127 "messages": [ 179 − { "role": "system", "content": LIVE_PROMPT }, 180 − { "role": "user", "content": transcript.to_prompt_text() } 128 + { "role": "system", "content": system }, 129 + { "role": "user", "content": user } 181 130 ] 182 131 }); 183 132 let body = self 187 136 .json(&request), 188 137 ) 189 138 .await?; 190 − let text = body["choices"][0]["message"]["content"] 139 + let choice = &body["choices"][0]; 140 + let finish = choice["finish_reason"].as_str().unwrap_or(""); 141 + let text = choice["message"]["content"] 191 142 .as_str() 192 143 .unwrap_or("") 193 144 .trim() 194 145 .to_string(); 195 − if text.is_empty() { 196 − return Err(Error::Incomplete("пустой ответ".into())); 146 + if finish != "stop" || text.is_empty() { 147 + let why = if finish.is_empty() { 148 + "пустой ответ" 149 + } else { 150 + finish 151 + }; 152 + return Err(Error::Incomplete(why.to_string())); 197 153 } 198 154 Ok((text, usage_of(&body))) 199 155 } 344 300 (format!("http://{addr}"), rx) 345 301 } 346 302 347 − fn transcript() -> Transcript { 348 − Transcript::parse("[00:00:01] Анна: Отчёт к пятнице.\n[00:00:05] Борис: Возьму.").unwrap() 349 − } 350 − 351 303 #[tokio::test] 352 304 async fn key_info_reads_data_and_sends_bearer() { 353 305 let (base, rx) = serve_once( 386 338 } 387 339 388 340 #[tokio::test] 389 − async fn analyze_sends_strict_schema_and_parses_answer() { 390 − let content = json!({ 391 − "title": "Отчёт", "summary": "Договорились.", 392 − "review": { "achieved": [], "open": [], "off_track": [], "verdict": "Ок." }, 393 − "decisions": [], "open_questions": [], 394 − "tasks": [{ "text": "Отчёт", "owner": "Борис", "due": "к пятнице", "quote": "Возьму.", "source_segment": 1 }] 395 − }); 396 − let reply = json!({ 397 − "choices": [{ "finish_reason": "stop", "message": { "content": content.to_string() } }], 398 − "usage": { "prompt_tokens": 1200, "completion_tokens": 300, "cost": 0.0108 } 399 − }); 400 − let (base, rx) = serve_once(200, &reply.to_string()); 401 − let (a, usage) = Client::with_base(base, "k") 402 − .analyze("anthropic/claude-opus-5.5", "Планёрка", &transcript()) 403 − .await 404 − .unwrap(); 405 − assert_eq!(a.tasks[0].owner.as_deref(), Some("Борис")); 406 − assert_eq!( 407 − usage, 408 − Usage { 409 − prompt_tokens: 1200, 410 − completion_tokens: 300, 411 − cost: Some(0.0108) 412 − } 413 − ); 414 − 415 − let req = rx.recv().unwrap(); 416 − assert!(req.starts_with("POST /chat/completions "), "{req}"); 417 − let sent: Value = serde_json::from_str(req.split("\r\n\r\n").nth(1).unwrap()).unwrap(); 418 − assert_eq!(sent["model"], "anthropic/claude-opus-5.5"); 419 − assert_eq!(sent["response_format"]["json_schema"]["strict"], true); 420 − assert_eq!( 421 − sent["response_format"]["json_schema"]["schema"], 422 − analysis::schema() 423 − ); 424 − assert_eq!(sent["provider"]["require_parameters"], true); 425 − assert!(sent["messages"][1]["content"] 426 − .as_str() 427 − .unwrap() 428 − .contains("#1 [00:00:05] Борис: Возьму.")); 429 − } 430 − 431 − #[tokio::test] 432 341 async fn transcribe_sends_base64_wav_and_reads_segments() { 433 342 let reply = json!({ 434 343 "text": "Привет. Продолжение следует...", 510 419 } 511 420 512 421 #[tokio::test] 513 − async fn live_summary_returns_plain_text() { 514 − let reply = json!({ "choices": [{ "finish_reason": "stop", "message": { "content": " - Обсуждают отчёт\n" } }] }); 422 + async fn complete_returns_plain_text() { 423 + let reply = json!({ "choices": [{ "finish_reason": "stop", "message": { "content": " ## Задачи\n\n- [ ] Отчёт\n" } }] }); 515 424 let (base, rx) = serve_once(200, &reply.to_string()); 516 425 let (text, _) = Client::with_base(base, "k") 517 − .live_summary("anthropic/claude-haiku-4.5", &transcript()) 426 + .complete("anthropic/claude-opus-5.5", "система", "заметка") 518 427 .await 519 428 .unwrap(); 520 − assert_eq!(text, "- Обсуждают отчёт"); 429 + assert_eq!(text, "## Задачи\n\n- [ ] Отчёт"); 521 430 let req = rx.recv().unwrap(); 431 + assert!(req.starts_with("POST /chat/completions "), "{req}"); 522 432 let sent: Value = serde_json::from_str(req.split("\r\n\r\n").nth(1).unwrap()).unwrap(); 523 − assert_eq!(sent["messages"][0]["content"], LIVE_PROMPT); 524 − assert!(sent.get("response_format").is_none()); 433 + assert_eq!(sent["model"], "anthropic/claude-opus-5.5"); 434 + assert_eq!(sent["messages"][0]["content"], "система"); 435 + assert_eq!(sent["messages"][1]["content"], "заметка"); 525 436 } 526 437 527 438 #[tokio::test] 528 − async fn truncated_answer_is_not_parsed() { 439 + async fn truncated_answer_is_an_error() { 529 440 let reply = 530 − json!({ "choices": [{ "finish_reason": "length", "message": { "content": "{\"title\":" } }] }); 441 + json!({ "choices": [{ "finish_reason": "length", "message": { "content": "Начало ответа" } }] }); 531 442 let (base, _rx) = serve_once(200, &reply.to_string()); 532 443 let err = Client::with_base(base, "k") 533 − .analyze("m", "t", &transcript()) 444 + .complete("m", "s", "u") 534 445 .await 535 446 .unwrap_err(); 536 447 assert!( 538 449 "{err:?}" Разница обрезана: показаны первые 216 строк. -
src-tauri/src/recorder.rs +124 −347
Показать разницуСкрыть разницу
1 −//! Запись одной встречи: дорожки звука → куски по паузам → распознавание → 2 −//! реплики в расшифровке, и раз в несколько минут — живой конспект. 1 +//! Запись по кнопке: каждая дорожка («я» — микрофон, «собеседники» — звук 2 +//! Zoom) пишется своим потоком в свой WAV. Больше ничего — расшифровка 3 +//! запускается потом, когда человек попросит. 3 4 //! 4 −//! ```text 5 −//! поток «я» ─┐ ┌─ store.append_segments 6 −//! (микрофон) ├─ куски ─► распознавание ─┤ 7 −//! поток «собеседники» ┘ (канал) (одна задача) └─ живой конспект 8 −//! (звук Zoom) 9 −//! ``` 10 −//! 11 −//! Каждая дорожка читается своим потоком: захват блокирующий, а медленная 12 −//! сеть не должна терять звук. Распознавание — одна асинхронная задача, куски 13 −//! идут по очереди: так реплики одной дорожки не обгоняют друг друга. 14 −//! Звук каждой дорожки пишется на диск сразу — после сбоя его можно 15 −//! распознать заново. 16 −//! 17 −//! Модуль не знает про Tauri и OpenRouter: распознавание — через 18 −//! [`Transcriber`], события — через колбэк. Так его проверяют тесты. 5 +//! Заголовок WAV дописывается каждые несколько секунд, поэтому после сбоя 6 +//! записанное читается. 19 7 20 −use std::future::Future; 21 −use std::pin::Pin; 8 +use std::path::PathBuf; 22 9 use std::sync::atomic::{AtomicBool, Ordering}; 23 10 use std::sync::Arc; 24 11 use std::thread::JoinHandle; 25 12 use std::time::{Duration, Instant}; 26 13 27 −use meet_audio::chunker::{self, Chunk, Chunker}; 28 14 use meet_audio::pcm::{self, WavWriter}; 29 15 use meet_audio::source::{Capture, CaptureError}; 30 −use tokio::sync::mpsc; 31 − 32 −use crate::meetings::Store; 33 −use crate::openrouter::SttSegment; 34 −use crate::transcript::{Segment, Speaker, Transcript}; 35 16 36 −pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; 17 +use crate::transcript::Speaker; 37 18 38 −/// Распознавание и живой конспект. В приложении — OpenRouter, в тестах — подделка. 39 −pub trait Transcriber: Send + Sync + 'static { 40 − fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>>; 41 − fn live_summary(&self, transcript: Transcript) -> BoxFuture<'_, Result<String, String>>; 19 +pub struct Track { 20 + pub speaker: Speaker, 21 + pub capture: Box<dyn Capture>, 22 + pub path: PathBuf, 42 23 } 43 24 44 25 #[derive(Debug, Clone, PartialEq, serde::Serialize)] 45 26 #[serde(tag = "kind", content = "data", rename_all = "snake_case")] 46 27 pub enum Event { 47 − /// Новые реплики уже в расшифровке. 48 − Segments(Vec<Segment>), 49 28 /// Уровень дорожки, dBFS, несколько раз в секунду. 50 − Level { 51 − speaker: Speaker, 52 − dbfs: f32, 53 − }, 54 − LiveSummary(String), 29 + Level { speaker: Speaker, dbfs: f32 }, 55 30 /// Дорожка перестала писаться; остальные продолжают. 56 − TrackFailed { 57 − speaker: Speaker, 58 − error: String, 59 − }, 60 − /// Кусок не распознался и после повтора; звук остался на диске. 61 − SttFailed(String), 31 + TrackFailed { speaker: Speaker, error: String }, 62 32 } 63 33 64 −pub struct Track { 65 − pub speaker: Speaker, 66 − pub capture: Box<dyn Capture>, 67 −} 68 − 69 −#[derive(Debug, Clone, Copy)] 70 −pub struct Config { 71 − pub chunker: chunker::Config, 72 − /// Как часто обновлять живой конспект. 73 − pub live_every: Duration, 74 − /// Пауза перед повтором неудачного распознавания. 75 − pub retry_after: Duration, 76 −} 34 +pub type Emit = Arc<dyn Fn(Event) + Send + Sync>; 77 35 78 −impl Default for Config { 79 − fn default() -> Self { 80 − Self { 81 − chunker: chunker::Config::default(), 82 − live_every: Duration::from_secs(180), 83 − retry_after: Duration::from_secs(3), 84 − } 85 − } 36 +/// Итог дорожки: сколько записано. 37 +#[derive(Debug, Clone, PartialEq)] 38 +pub struct Written { 39 + pub speaker: Speaker, 40 + pub duration_ms: u64, 86 41 } 87 42 88 −type Emit = Arc<dyn Fn(Event) + Send + Sync>; 89 − 90 43 pub struct Session { 91 − pub meeting_id: String, 92 44 stop: Arc<AtomicBool>, 93 45 paused: Arc<AtomicBool>, 94 − threads: Vec<JoinHandle<u64>>, 95 − worker: tokio::task::JoinHandle<()>, 46 + threads: Vec<JoinHandle<Option<Written>>>, 47 + pub started: Instant, 96 48 } 97 49 98 50 impl Session { 99 − pub fn start( 100 − meeting_id: String, 101 − store: Arc<Store>, 102 − tracks: Vec<Track>, 103 − ai: Arc<dyn Transcriber>, 104 − emit: Emit, 105 − cfg: Config, 106 − rt: &tokio::runtime::Handle, 107 − ) -> Self { 51 + pub fn start(tracks: Vec<Track>, emit: Emit) -> Result<Self, String> { 108 52 let stop = Arc::new(AtomicBool::new(false)); 109 53 let paused = Arc::new(AtomicBool::new(false)); 110 − let started = Instant::now(); 111 − let (tx, rx) = mpsc::unbounded_channel(); 112 − let threads = tracks 113 − .into_iter() 114 − .map(|track| { 115 − let wav_path = store.audio_path(&meeting_id, &track.speaker).ok(); 116 − let ctx = TrackCtx { 117 − stop: stop.clone(), 118 − paused: paused.clone(), 119 − started, 120 − tx: tx.clone(), 121 − emit: emit.clone(), 122 − chunker: cfg.chunker, 123 − }; 54 + let mut threads = Vec::new(); 55 + for track in tracks { 56 + let writer = 57 + WavWriter::create(&track.path).map_err(|e| format!("{}: {e}", track.path.display()))?; 58 + let (stop, paused, emit) = (stop.clone(), paused.clone(), emit.clone()); 59 + threads.push( 124 60 std::thread::Builder::new() 125 − .name(format!("meet-track-{}", track.speaker.label())) 126 − .spawn(move || run_track(track, wav_path, ctx)) 127 − .expect("поток дорожки") 128 − }) 129 − .collect(); 130 − drop(tx); 131 − let worker = rt.spawn(run_worker(meeting_id.clone(), store, rx, ai, emit, cfg)); 132 − Self { 133 − meeting_id, 61 + .name(format!("meet-track-{}", track.speaker.track_file())) 62 + .spawn(move || run_track(track, writer, stop, paused, emit)) 63 + .map_err(|e| e.to_string())?, 64 + ); 65 + } 66 + Ok(Self { 134 67 stop, 135 68 paused, 136 69 threads, 137 − worker, 138 − } 70 + started: Instant::now(), 71 + }) 139 72 } 140 73 141 74 pub fn set_paused(&self, paused: bool) { 146 79 self.paused.load(Ordering::Relaxed) 147 80 } 148 81 149 − /// Останавливает дорожки, дожидается распознавания всех кусков и 150 − /// возвращает длительность записи, мс. 151 − pub async fn stop(self) -> u64 { 82 + /// Останавливает запись и ждёт, пока дорожки допишутся. Блокирует — звать 83 + /// из фонового потока. 84 + pub fn stop(self) -> Vec<Written> { 152 85 self.stop.store(true, Ordering::Relaxed); 153 − let threads = self.threads; 154 − let duration = tokio::task::spawn_blocking(move || { 155 − threads 156 − .into_iter() 157 − .filter_map(|t| t.join().ok()) 158 − .max() 159 − .unwrap_or(0) 160 − }) 161 − .await 162 − .unwrap_or(0); 163 − let _ = self.worker.await; 164 − duration 86 + self.threads 87 + .into_iter() 88 + .filter_map(|t| t.join().ok().flatten()) 89 + .collect() 165 90 } 166 91 } 167 92 168 −struct TrackCtx { 93 +fn run_track( 94 + mut track: Track, 95 + mut writer: WavWriter, 169 96 stop: Arc<AtomicBool>, 170 97 paused: Arc<AtomicBool>, 171 − started: Instant, 172 − tx: mpsc::UnboundedSender<(Speaker, Chunk)>, 173 98 emit: Emit, 174 − chunker: chunker::Config, Разница обрезана: показаны первые 216 строк. -
src-tauri/src/settings.rs +22 −30
Показать разницуСкрыть разницу
17 17 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 18 18 #[serde(default)] 19 19 pub struct Settings { 20 − /// Живой конспект по ходу встречи (этап 1). 21 − pub live_model: String, 22 − /// Итоговый разбор после встречи. 23 − pub analysis_model: String, 24 − /// Распознавание речи (этап 1). 20 + /// Команды ИИ над заметкой: конспект, задачи, ревью, свой запрос. 21 + #[serde(alias = "analysis_model")] 22 + pub ai_model: String, 23 + /// Распознавание речи. 25 24 pub stt_model: String, 26 − /// Текст, который пользователь отправляет участникам перед записью. 27 − pub warning_text: String, 28 − /// «Предупреждаю всегда сам» — не спрашивать отметку перед каждой записью. 29 − pub always_warn_myself: bool, 30 25 /// Язык встреч для распознавания: код ISO 639-1 или пусто — определять 31 26 /// самому. Короткие куски Whisper определяет плохо, поэтому по умолчанию «ru». 32 27 pub language: String, 33 − /// Хранить звук после разбора. По умолчанию нет: звук — самое личное. 34 − pub keep_audio: bool, 35 − /// Запускаться вместе с Windows — иначе встречу, начатую до запуска 36 − /// программы, некому записать. 37 − pub launch_at_login: bool, 28 + /// Текст, которым человек предупреждает участников перед записью. 29 + pub warning_text: String, 38 30 } 39 31 40 32 impl Default for Settings { 41 33 fn default() -> Self { 42 34 Self { 43 − live_model: "anthropic/claude-haiku-4.5".into(), 44 − analysis_model: "anthropic/claude-opus-5.5".into(), 35 + ai_model: "anthropic/claude-opus-5.5".into(), 45 36 stt_model: "openai/whisper-large-v3-turbo".into(), 46 − warning_text: DEFAULT_WARNING.into(), 47 − always_warn_myself: false, 48 37 language: "ru".into(), 49 − keep_audio: false, 50 − launch_at_login: true, 38 + warning_text: DEFAULT_WARNING.into(), 51 39 } 52 40 } 53 41 } 96 84 97 85 pub fn validate(&self) -> Result<(), Error> { 98 86 for (name, value) in [ 99 − ("модель конспекта", &self.live_model), 100 − ("модель разбора", &self.analysis_model), 87 + ("модель ИИ", &self.ai_model), 101 88 ("модель распознавания", &self.stt_model), 102 89 ("текст предупреждения", &self.warning_text), 103 90 ] { 194 181 assert_eq!(Settings::load(&path).unwrap(), Settings::default()); 195 182 196 183 let s = Settings { 197 − analysis_model: "anthropic/claude-sonnet-5.5".into(), 198 − always_warn_myself: true, 184 + ai_model: "anthropic/claude-sonnet-5.5".into(), 185 + language: "en".into(), 199 186 ..Settings::default() 200 187 }; 201 188 s.save(&path).unwrap(); 204 191 } 205 192 206 193 #[test] 207 − fn old_file_without_new_fields_keeps_defaults_for_them() { 194 + fn old_file_is_read_with_defaults_and_old_names() { 208 195 let dir = tempfile::tempdir().unwrap(); 209 196 let path = settings_path(dir.path()); 210 − std::fs::write(&path, r#"{"analysis_model":"x/y"}"#).unwrap(); 197 + // Файл этапа 1: прежнее имя модели и поля, которых больше нет. 198 + std::fs::write( 199 + &path, 200 + r#"{"analysis_model":"x/y","live_model":"z","keep_audio":true}"#, 201 + ) 202 + .unwrap(); 211 203 let s = Settings::load(&path).unwrap(); 212 − assert_eq!(s.analysis_model, "x/y"); 213 − assert_eq!(s.live_model, Settings::default().live_model); 204 + assert_eq!(s.ai_model, "x/y"); 205 + assert_eq!(s.stt_model, Settings::default().stt_model); 214 206 } 215 207 216 208 #[test] 225 217 fn blank_model_is_refused() { 226 218 let dir = tempfile::tempdir().unwrap(); 227 219 let s = Settings { 228 − analysis_model: " ".into(), 220 + ai_model: " ".into(), 229 221 ..Settings::default() 230 222 }; 231 223 assert!(matches!( 232 224 s.save(&settings_path(dir.path())), 233 − Err(Error::Blank("модель разбора")) 225 + Err(Error::Blank("модель ИИ")) 234 226 )); 235 227 } 236 228 -
Показать разницуСкрыть разницу
1 −//! Оболочка Tauri: окно, трей, уведомления, автозапуск и события интерфейса. 2 −//! 3 −//! Программа живёт в трее: крестик прячет окно, а не выключает её — иначе 4 −//! закрыл окно и встреча не записалась. Выключить — «Выйти» в меню трея. 5 − 6 −use tauri::menu::{Menu, MenuItem}; 7 −use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; 8 −use tauri::{AppHandle, Emitter, Manager}; 9 −use tauri_plugin_notification::NotificationExt; 10 − 11 −use crate::controller::{FinishStep, Outlet, Status}; 12 −use crate::recorder; 13 − 14 −const TRAY_ID: &str = "main"; 15 − 16 −pub struct TauriOutlet { 17 − pub app: AppHandle, 18 −} 19 − 20 −impl Outlet for TauriOutlet { 21 − fn status(&self, status: &Status) { 22 − let _ = self.app.emit("meet://status", status); 23 − if let Some(tray) = self.app.tray_by_id(TRAY_ID) { 24 − let _ = tray.set_tooltip(Some(tooltip(status))); 25 − } 26 − } 27 − 28 − fn recorder_event(&self, meeting_id: &str, event: &recorder::Event) { 29 − let _ = self.app.emit( 30 − "meet://recorder", 31 − serde_json::json!({ "meeting_id": meeting_id, "event": event }), 32 − ); 33 − } 34 − 35 − fn meeting_changed(&self, meeting_id: &str) { 36 − let _ = self.app.emit("meet://meeting-changed", meeting_id); 37 − } 38 − 39 − fn notify(&self, title: &str, body: &str) { 40 − let _ = self.app.notification().builder().title(title).body(body).show(); 41 − } 42 − 43 − fn show_window(&self) { 44 − show_main(&self.app); 45 − } 46 −} 47 − 48 −fn tooltip(status: &Status) -> &'static str { 49 − match status { 50 − Status::Unsupported => "Skillok Meet", 51 − Status::Watching => "Skillok Meet — жду встречу в Zoom", 52 − Status::AwaitingConsent => "Skillok Meet — предупредите участников", 53 − Status::Recording { paused: true, .. } => "Skillok Meet — запись на паузе", 54 − Status::Recording { .. } => "Skillok Meet — идёт запись", 55 − Status::Finishing { 56 − step: FinishStep::Transcribing, 57 − .. 58 − } => "Skillok Meet — дописываю расшифровку", 59 − Status::Finishing { 60 − step: FinishStep::Analyzing, 61 − .. 62 − } => "Skillok Meet — разбираю встречу", 63 − } 64 −} 65 − 66 −pub fn show_main(app: &AppHandle) { 67 − if let Some(w) = app.get_webview_window("main") { 68 − let _ = w.show(); 69 − let _ = w.unminimize(); 70 − let _ = w.set_focus(); 71 − } 72 −} 73 − 74 −pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { 75 − let open = MenuItem::with_id(app, "open", "Открыть Skillok Meet", true, None::<&str>)?; 76 − let quit = MenuItem::with_id(app, "quit", "Выйти", true, None::<&str>)?; 77 − let menu = Menu::with_items(app, &[&open, &quit])?; 78 − let mut tray = TrayIconBuilder::with_id(TRAY_ID) 79 − .tooltip("Skillok Meet") 80 − .menu(&menu) 81 − .show_menu_on_left_click(false) 82 − .on_menu_event(|app, event| match event.id.as_ref() { 83 − "open" => show_main(app), 84 − "quit" => app.exit(0), 85 − _ => {} 86 − }) 87 − .on_tray_icon_event(|tray, event| { 88 − if let TrayIconEvent::Click { 89 − button: MouseButton::Left, 90 − button_state: MouseButtonState::Up, 91 − .. 92 − } = event 93 − { 94 − show_main(tray.app_handle()); 95 − } 96 − }); 97 − if let Some(icon) = app.default_window_icon() { 98 − tray = tray.icon(icon.clone()); 99 − } 100 − tray.build(app)?; 101 − Ok(()) 102 −} 103 − 104 −/// Запуск вместе с Windows. Вне Windows не трогаем: на Mac идёт разработка, 105 −/// и регистрировать там автозапуск отладочной сборки незачем. 106 −pub fn apply_autostart(_app: &AppHandle, _enabled: bool) { 107 − #[cfg(windows)] 108 − { 109 − use tauri_plugin_autostart::ManagerExt; 110 − let manager = _app.autolaunch(); 111 − let _ = if _enabled { 112 − manager.enable() 113 − } else { 114 − manager.disable() 115 − }; 116 − } 117 −} -
Показать разницуСкрыть разницу
1 +//! Расшифровка звука встречи по кнопке «Расшифровать». 2 +//! 3 +//! ```text 4 +//! дорожки ─► куски по паузам ─► распознавание (до 4 кусков сразу) ─► реплики по времени 5 +//! ``` 6 +//! 7 +//! Куски без речи не отправляются: это экономит деньги, и Whisper не сочиняет 8 +//! фраз на тишине. Неудачный кусок повторяется один раз; если и повтор не 9 +//! прошёл, на его месте в расшифровке остаётся пометка, а не молчаливая дыра. 10 + 11 +use std::future::Future; 12 +use std::pin::Pin; 13 +use std::sync::Arc; 14 +use std::time::Duration; 15 + 16 +use meet_audio::chunker::{self, Chunk, Chunker}; 17 +use meet_audio::pcm::{self, samples_to_ms}; 18 +use tokio::task::JoinSet; 19 + 20 +use crate::openrouter::SttSegment; 21 +use crate::transcript::{Segment, Speaker}; 22 + 23 +pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; 24 + 25 +/// Распознавание одного куска WAV. В приложении — OpenRouter, в тестах — подделка. 26 +pub trait Stt: Send + Sync + 'static { 27 + fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>>; 28 +} 29 + 30 +pub const UNRECOGNIZED: &str = "*(этот кусок не распознался — нажмите «Расшифровать» ещё раз)*"; 31 + 32 +#[derive(Debug, Clone, Copy)] 33 +pub struct Config { 34 + pub chunker: chunker::Config, 35 + /// Сколько кусков распознаётся одновременно. 36 + pub parallel: usize, 37 + pub retry_after: Duration, 38 +} 39 + 40 +impl Default for Config { 41 + fn default() -> Self { 42 + Self { 43 + chunker: chunker::Config::default(), 44 + parallel: 4, 45 + retry_after: Duration::from_secs(3), 46 + } 47 + } 48 +} 49 + 50 +#[derive(Debug, Clone, PartialEq)] 51 +pub struct Outcome { 52 + pub segments: Vec<Segment>, 53 + /// Сколько кусков не распозналось. 54 + pub failed: usize, 55 + /// Последняя ошибка распознавания, чтобы показать человеку причину. 56 + pub last_error: Option<String>, 57 +} 58 + 59 +/// Режет дорожку на куски по паузам. 60 +pub fn chunks_of(samples: &[i16], cfg: chunker::Config) -> Vec<Chunk> { 61 + let mut c = Chunker::new(cfg); 62 + let mut out = c.push(samples_to_ms(samples.len()), samples); 63 + out.extend(c.flush()); 64 + out 65 +} 66 + 67 +/// Расшифровывает дорожки. `progress(готово, всего)` зовётся после каждого куска. 68 +pub async fn run( 69 + tracks: Vec<(Speaker, Vec<i16>)>, 70 + stt: Arc<dyn Stt>, 71 + cfg: Config, 72 + progress: impl Fn(usize, usize), 73 +) -> Outcome { 74 + let jobs: Vec<(Speaker, Chunk)> = tracks 75 + .into_iter() 76 + .flat_map(|(speaker, samples)| { 77 + chunks_of(&samples, cfg.chunker) 78 + .into_iter() 79 + .map(move |c| (speaker.clone(), c)) 80 + }) 81 + .collect(); 82 + let total = jobs.len(); 83 + progress(0, total); 84 + 85 + let mut set = JoinSet::new(); 86 + let mut pending = jobs.into_iter(); 87 + let mut segments = Vec::new(); 88 + let mut failed = 0; 89 + let mut last_error = None; 90 + let mut done = 0; 91 + loop { 92 + while set.len() < cfg.parallel.max(1) { 93 + let Some((speaker, chunk)) = pending.next() else { 94 + break; 95 + }; 96 + let stt = stt.clone(); 97 + let retry_after = cfg.retry_after; 98 + set.spawn(async move { 99 + let wav = pcm::encode_wav(&chunk.samples); 100 + let mut result = stt.transcribe(wav.clone()).await; 101 + if result.is_err() { 102 + tokio::time::sleep(retry_after).await; 103 + result = stt.transcribe(wav).await; 104 + } 105 + (speaker, chunk.start_ms, result) 106 + }); 107 + } 108 + let Some(joined) = set.join_next().await else { 109 + break; 110 + }; 111 + let (speaker, start_ms, result) = joined.expect("задача распознавания упала"); 112 + match result { 113 + Ok(list) => segments.extend(list.into_iter().map(|s| Segment { 114 + start_ms: start_ms + s.start_ms, 115 + speaker: speaker.clone(), 116 + text: s.text, 117 + })), 118 + Err(e) => { 119 + failed += 1; 120 + last_error = Some(e); 121 + segments.push(Segment { 122 + start_ms, 123 + speaker, 124 + text: UNRECOGNIZED.into(), 125 + }); 126 + } 127 + } 128 + done += 1; 129 + progress(done, total); 130 + } 131 + segments.sort_by_key(|s| s.start_ms); 132 + Outcome { 133 + segments, 134 + failed, 135 + last_error, 136 + } 137 +} 138 + 139 +#[cfg(test)] 140 +mod tests { 141 + use super::*; 142 + use meet_audio::pcm::{ms_to_samples, SAMPLE_RATE}; 143 + use std::sync::atomic::{AtomicUsize, Ordering}; 144 + use std::sync::Mutex; 145 + 146 + fn speech(ms: u64) -> Vec<i16> { 147 + (0..ms_to_samples(ms)) 148 + .map(|i| ((i as f32 / SAMPLE_RATE as f32 * std::f32::consts::TAU * 250.0).sin() * 9000.0) as i16) 149 + .collect() 150 + } 151 + 152 + fn silence(ms: u64) -> Vec<i16> { 153 + vec![0; ms_to_samples(ms)] 154 + } 155 + 156 + /// Подделка: реплика «N мс» на кусок; `fail` — сколько первых вызовов упадут. 157 + struct Fake { 158 + calls: AtomicUsize, 159 + fail: usize, 160 + in_flight: AtomicUsize, 161 + max_in_flight: AtomicUsize, 162 + } 163 + 164 + impl Fake { 165 + fn new(fail: usize) -> Arc<Self> { 166 + Arc::new(Self { 167 + calls: AtomicUsize::new(0), 168 + fail, 169 + in_flight: AtomicUsize::new(0), 170 + max_in_flight: AtomicUsize::new(0), 171 + }) 172 + } 173 + } 174 + 175 + impl Stt for Fake { 176 + fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>> { 177 + Box::pin(async move { 178 + let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; 179 + self.max_in_flight.fetch_max(now, Ordering::SeqCst); 180 + tokio::time::sleep(Duration::from_millis(20)).await; 181 + self.in_flight.fetch_sub(1, Ordering::SeqCst); 182 + let n = self.calls.fetch_add(1, Ordering::SeqCst); 183 + if n < self.fail { 184 + return Err("сеть".into()); 185 + } 186 + let ms = samples_to_ms(pcm::decode_wav(&wav).unwrap().len()); 187 + Ok(vec![SttSegment { 188 + start_ms: 200, 189 + text: format!("{ms} мс"), 190 + }]) 191 + }) 192 + } 193 + } 194 + 195 + fn cfg() -> Config { 196 + Config { 197 + retry_after: Duration::from_millis(5), 198 + ..Config::default() 199 + } 200 + } 201 + 202 + #[tokio::test] 203 + async fn two_tracks_become_one_timeline() { 204 + let mut me = speech(7_000); 205 + me.extend(silence(1_500)); 206 + me.extend(speech(3_000)); 207 + let mut others = silence(2_000); 208 + others.extend(speech(8_000)); 209 + let log = Mutex::new(Vec::new()); 210 + let out = run( 211 + vec![(Speaker::Me, me), (Speaker::Others, others)], 212 + Fake::new(0), 213 + cfg(), 214 + |d, t| log.lock().unwrap().push((d, t)), 215 + ) 216 + .await; 217 + assert_eq!(out.failed, 0); 218 + assert_eq!(out.segments.len(), 3, "{:#?}", out.segments); 219 + let starts: Vec<u64> = out.segments.iter().map(|s| s.start_ms).collect(); 220 + assert!(starts.windows(2).all(|w| w[0] <= w[1]), "по времени: {starts:?}"); 221 + assert!(out 222 + .segments 223 + .iter() 224 + .any(|s| s.speaker == Speaker::Others && s.start_ms >= 2_000)); 225 + assert_eq!(log.lock().unwrap().first(), Some(&(0, 3))); 226 + assert_eq!(log.lock().unwrap().last(), Some(&(3, 3))); 227 + } 228 + 229 + #[tokio::test] 230 + async fn chunks_run_in_parallel_but_not_more_than_allowed() { 231 + let long = speech(28_000 * 10); 232 + let fake = Fake::new(0); 233 + let out = run( 234 + vec![(Speaker::Unknown, long)], 235 + fake.clone(), 236 + Config { parallel: 3, ..cfg() }, 237 + |_, _| {}, 238 + ) 239 + .await; 240 + assert_eq!(out.segments.len(), 10); 241 + let max = fake.max_in_flight.load(Ordering::SeqCst); 242 + assert!((2..=3).contains(&max), "одновременно было {max}"); 243 + } 244 + 245 + #[tokio::test] 246 + async fn failed_chunk_is_retried_then_marked() { 247 + let one = speech(3_000); 248 + let out = run( 249 + vec![(Speaker::Unknown, one.clone())], 250 + Fake::new(1), 251 + cfg(), 252 + |_, _| {}, 253 + ) 254 + .await; 255 + assert_eq!(out.failed, 0, "одна ошибка — повтор прошёл"); 256 + let out = run(vec![(Speaker::Unknown, one)], Fake::new(2), cfg(), |_, _| {}).await; 257 + assert_eq!(out.failed, 1); 258 + assert_eq!(out.segments[0].text, UNRECOGNIZED); 259 + assert_eq!(out.last_error.as_deref(), Some("сеть")); 260 + } 261 + 262 + #[tokio::test] 263 + async fn silence_costs_nothing() { 264 + let fake = Fake::new(0); 265 + let out = run( 266 + vec![(Speaker::Others, silence(60_000))], 267 + fake.clone(), 268 + cfg(), 269 + |_, _| {}, 270 + ) 271 + .await; 272 + assert!(out.segments.is_empty()); 273 + assert_eq!(fake.calls.load(Ordering::SeqCst), 0); 274 + } 275 +} -
src-tauri/src/transcript.rs +72 −220
Показать разницуСкрыть разницу
1 −//! Расшифровка встречи: реплики с временем и говорящим. 1 +//! Реплики расшифровки и то, как они ложатся в заметку. 2 2 //! 3 −//! На этапе 1 реплики будут приходить из распознавания двух дорожек: «я» 4 −//! (микрофон) и «собеседники» (звук Zoom). Пока их даёт импорт текста — тот же 5 −//! формат, которым написаны проверочные расшифровки в `fixtures/transcripts/`: 6 −//! 7 −//! ```text 8 −//! [00:01:23] Анна: текст реплики 9 −//! продолжение той же реплики на следующей строке 10 −//! ``` 11 −//! 12 −//! Имя говорящего — только то, что написано в тексте. Узнавания людей по голосу 13 −//! нет и не будет: это биометрия (см. README, «Согласие на запись»). 3 +//! Говорящий — только дорожка звука: «Я» (микрофон), «Собеседники» (звук Zoom) 4 +//! или никто, если звук пришёл одним файлом. Узнавания людей по голосу нет и не 5 +//! будет: это биометрия (см. README, «Согласие на запись»). 14 6 15 7 use serde::{Deserialize, Serialize}; 16 8 17 −/// Кто говорит. `Me` и `Others` — две дорожки захвата; `Named` — имя из 18 −/// импортированного текста. 19 9 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 20 −#[serde(tag = "kind", content = "name", rename_all = "snake_case")] 10 +#[serde(tag = "kind", rename_all = "snake_case")] 21 11 pub enum Speaker { 22 12 Me, 23 13 Others, 24 − Named(String), 14 + /// Звук одним файлом: кто говорит, неизвестно. 15 + Unknown, 25 16 } 26 17 27 18 impl Speaker { 28 − pub fn label(&self) -> &str { 19 + pub fn label(&self) -> Option<&'static str> { 20 + match self { 21 + Speaker::Me => Some("Я"), 22 + Speaker::Others => Some("Собеседники"), 23 + Speaker::Unknown => None, 24 + } 25 + } 26 + 27 + /// Имя файла дорожки записи. 28 + pub fn track_file(&self) -> &'static str { 29 29 match self { 30 − Speaker::Me => "Я", 31 − Speaker::Others => "Собеседники", 32 − Speaker::Named(name) => name, 30 + Speaker::Me => "me.wav", 31 + Speaker::Others => "others.wav", 32 + Speaker::Unknown => "audio.wav", 33 33 } 34 34 } 35 35 } 36 36 37 37 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 38 38 pub struct Segment { 39 − /// Начало реплики от начала встречи, миллисекунды. 39 + /// Начало реплики от начала записи, мс. 40 40 pub start_ms: u64, 41 41 pub speaker: Speaker, 42 42 pub text: String, 43 43 } 44 44 45 −#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] 46 −pub struct Transcript { 47 − pub segments: Vec<Segment>, 48 −} 49 − 50 −#[derive(Debug, thiserror::Error, PartialEq, Eq)] 51 −pub enum ParseError { 52 − #[error("строка {line}: нет метки времени вида [чч:мм:сс] перед первой репликой")] 53 − TextBeforeFirstSegment { line: usize }, 54 − #[error("строка {line}: метка времени «{stamp}» не разбирается")] 55 − BadTimestamp { line: usize, stamp: String }, 56 − #[error("строка {line}: после метки времени нет «Имя:»")] 57 − NoSpeaker { line: usize }, 58 − #[error("строка {line}: время идёт назад")] 59 − TimeGoesBack { line: usize }, 60 − #[error("в тексте нет ни одной реплики")] 61 − Empty, 62 −} 63 − 64 −impl Transcript { 65 − /// Разбирает текст формата `[чч:мм:сс] Имя: реплика`. Строки без метки 66 − /// продолжают предыдущую реплику; пустые строки пропускаются. 67 − pub fn parse(text: &str) -> Result<Self, ParseError> { 68 − let mut segments: Vec<Segment> = Vec::new(); 69 − for (index, raw) in text.lines().enumerate() { 70 − let line = index + 1; 71 − let trimmed = raw.trim(); 72 − if trimmed.is_empty() { 73 − continue; 74 − } 75 − let Some(rest) = trimmed.strip_prefix('[') else { 76 − let Some(last) = segments.last_mut() else { 77 − return Err(ParseError::TextBeforeFirstSegment { line }); 78 − }; 79 − last.text.push(' '); 80 − last.text.push_str(trimmed); 81 − continue; 82 − }; 83 − let Some((stamp, rest)) = rest.split_once(']') else { 84 − return Err(ParseError::BadTimestamp { 85 − line, 86 − stamp: rest.to_string(), 87 − }); 88 − }; 89 − let start_ms = parse_timestamp(stamp).ok_or_else(|| ParseError::BadTimestamp { 90 − line, 91 − stamp: stamp.to_string(), 92 − })?; 93 − let Some((name, said)) = rest.split_once(':') else { 94 − return Err(ParseError::NoSpeaker { line }); 95 − }; 96 − let name = name.trim(); 97 − if name.is_empty() { 98 − return Err(ParseError::NoSpeaker { line }); 99 − } 100 − if segments.last().is_some_and(|prev| prev.start_ms > start_ms) { 101 − return Err(ParseError::TimeGoesBack { line }); 102 − } 103 − segments.push(Segment { 104 − start_ms, 105 − speaker: Speaker::Named(name.to_string()), 106 − text: said.trim().to_string(), 107 − }); 45 +/// Блок «Расшифровка» для заметки: реплика — абзац с временем. Подряд идущие 46 +/// реплики одного говорящего склеиваются в абзац — так читается легче. 47 +pub fn to_markdown(segments: &[Segment]) -> String { 48 + let mut out = String::from("## Расшифровка"); 49 + let mut prev: Option<&Speaker> = None; 50 + for s in segments { 51 + if prev == Some(&s.speaker) { 52 + out.push(' '); 53 + out.push_str(&s.text); 54 + continue; 108 55 } 109 − if segments.is_empty() { 110 − return Err(ParseError::Empty); 111 − } 112 − Ok(Transcript { segments }) 113 − } 114 − 115 − /// Длительность — по началу последней реплики. Конца реплики у импорта 116 − /// нет; у записи он появится на этапе 1. 117 − pub fn duration_ms(&self) -> u64 { 118 − self.segments.last().map_or(0, |s| s.start_ms) 119 − } 120 − 121 − /// Текст для модели: каждая реплика с номером, чтобы разбор мог сослаться 122 − /// на место, где прозвучала задача (`source_segment`). 123 − pub fn to_prompt_text(&self) -> String { 124 − let mut out = String::new(); 125 − for (i, s) in self.segments.iter().enumerate() { 126 − out.push_str(&format!( 127 − "#{i} [{}] {}: {}\n", 128 − format_timestamp(s.start_ms), 129 − s.speaker.label(), 130 − s.text 131 − )); 132 − } 133 − out 134 − } 135 − 136 − /// Markdown для файла `transcript.md`. 137 − pub fn to_markdown(&self, title: &str) -> String { 138 − let mut out = format!("# {title} — расшифровка\n\n"); 139 − for s in &self.segments { 140 − out.push_str(&format!( 141 − "`{}` **{}:** {}\n\n", 56 + out.push_str("\n\n"); 57 + match s.speaker.label() { 58 + Some(who) => out.push_str(&format!( 59 + "**{} {}:** {}", 142 60 format_timestamp(s.start_ms), 143 − s.speaker.label(), 61 + who, 144 62 s.text 145 − )); 63 + )), 64 + None => out.push_str(&format!("**{}** {}", format_timestamp(s.start_ms), s.text)), 146 65 } 147 − out 66 + prev = Some(&s.speaker); 148 67 } 149 −} 150 − 151 −/// `чч:мм:сс` или `мм:сс` → миллисекунды. 152 −pub fn parse_timestamp(stamp: &str) -> Option<u64> { 153 − let parts: Vec<&str> = stamp.trim().split(':').collect(); 154 − let nums: Vec<u64> = parts 155 − .iter() 156 − .map(|p| { 157 − if p.len() == 2 || p.len() == 1 { 158 − p.parse().ok() 159 − } else { 160 − None 161 − } 162 − }) 163 − .collect::<Option<_>>()?; 164 − let (h, m, s) = match nums.as_slice() { 165 − [m, s] => (0, *m, *s), 166 − [h, m, s] => (*h, *m, *s), 167 − _ => return None, 168 − }; 169 − if m >= 60 || s >= 60 { 170 − return None; 68 + if segments.is_empty() { 69 + out.push_str("\n\nРечи в записи не нашлось."); 171 70 } 172 − Some(((h * 60 + m) * 60 + s) * 1000) 71 + out.push('\n'); 72 + out 173 73 } 174 74 175 75 pub fn format_timestamp(ms: u64) -> String { 181 81 mod tests { 182 82 use super::*; 183 83 184 − #[test] 185 − fn parses_segments_and_continuations() { 186 − let t = Transcript::parse( 187 − "[00:00:05] Анна: Начнём.\n\n[00:01:10] Борис: Отчёт готов,\nно без графиков.\n", 188 − ) 189 − .unwrap(); 190 − assert_eq!(t.segments.len(), 2); 191 − assert_eq!(t.segments[1].start_ms, 70_000); 192 − assert_eq!(t.segments[1].speaker, Speaker::Named("Борис".into())); 193 − assert_eq!(t.segments[1].text, "Отчёт готов, но без графиков."); 194 − assert_eq!(t.duration_ms(), 70_000); 195 − } 196 − 197 − #[test] 198 − fn colon_inside_the_text_stays_in_the_text() { 199 − let t = Transcript::parse("[00:10] Анна: Срок: пятница").unwrap(); 200 − assert_eq!(t.segments[0].start_ms, 10_000); 201 − assert_eq!(t.segments[0].text, "Срок: пятница"); 84 + fn seg(ms: u64, speaker: Speaker, text: &str) -> Segment { 85 + Segment { 86 + start_ms: ms, 87 + speaker, 88 + text: text.into(), 89 + } 202 90 } 203 91 204 92 #[test] 205 − fn rejects_bad_input_with_line_numbers() { 206 − assert_eq!( 207 − Transcript::parse("привет\n[00:00:01] А: б"), 208 − Err(ParseError::TextBeforeFirstSegment { line: 1 }) 209 − ); 93 + fn markdown_merges_runs_of_one_speaker() { 94 + let md = to_markdown(&[ 95 + seg(1_000, Speaker::Me, "Привет."), 96 + seg(4_000, Speaker::Me, "Начнём?"), 97 + seg(9_000, Speaker::Others, "Да."), 98 + seg(3_723_000, Speaker::Me, "Спасибо."), 99 + ]); 210 100 assert_eq!( 211 − Transcript::parse("[00:99:00] А: б"), 212 − Err(ParseError::BadTimestamp { 213 − line: 1, 214 − stamp: "00:99:00".into() 215 − }) 101 + md, 102 + "## Расшифровка\n\n\ 103 + **00:00:01 Я:** Привет. Начнём?\n\n\ 104 + **00:00:09 Собеседники:** Да.\n\n\ 105 + **01:02:03 Я:** Спасибо.\n" 216 106 ); 217 − assert_eq!( 218 − Transcript::parse("[00:00:01] без двоеточия"), 219 − Err(ParseError::NoSpeaker { line: 1 }) 220 − ); 221 − assert_eq!( 222 − Transcript::parse("[00:00:05] А: б\n[00:00:01] В: г"), 223 − Err(ParseError::TimeGoesBack { line: 2 }) 224 − ); 225 − assert_eq!(Transcript::parse(" \n"), Err(ParseError::Empty)); 226 107 } 227 108 228 109 #[test] 229 − fn timestamps_round_trip() { 230 − for ms in [0, 59_000, 3_600_000, 5_025_000] { 231 − assert_eq!(parse_timestamp(&format_timestamp(ms)), Some(ms)); 232 − } 233 − assert_eq!(parse_timestamp("1:2:3:4"), None); 234 − assert_eq!(parse_timestamp("abc"), None); 110 + fn single_file_has_no_speaker_names() { 111 + let md = to_markdown(&[ 112 + seg(0, Speaker::Unknown, "Раз."), 113 + seg(20_000, Speaker::Unknown, "Два."), 114 + ]); 115 + assert_eq!(md, "## Расшифровка\n\n**00:00:00** Раз. Два.\n"); 235 116 } 236 117 237 118 #[test] 238 − fn prompt_text_numbers_segments() { 239 − let t = Transcript::parse("[00:00:01] А: раз\n[00:00:02] Б: два").unwrap(); 240 − assert_eq!(t.to_prompt_text(), "#0 [00:00:01] А: раз\n#1 [00:00:02] Б: два\n"); 241 − } 242 − 243 − /// Проверочные расшифровки из `fixtures/transcripts/` обязаны разбираться: 244 − /// на них проверяется промпт разбора. 245 − #[test] 246 − fn fixtures_parse() { 247 − for (name, text, segments) in [ 248 − ( 249 − "planerka", 250 − include_str!("../../fixtures/transcripts/planerka.txt"), 251 − 24, 252 − ), 253 − ( 254 − "klient", 255 − include_str!("../../fixtures/transcripts/klient.txt"), 256 − 15, 257 − ), 258 − ( 259 − "pustaya", 260 − include_str!("../../fixtures/transcripts/pustaya.txt"), 261 − 6, 262 − ), 263 − ] { 264 − let t = Transcript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}")); 265 − assert_eq!(t.segments.len(), segments, "{name}"); 266 − } 119 + fn empty_transcript_says_so() { 120 + assert_eq!(to_markdown(&[]), "## Расшифровка\n\nРечи в записи не нашлось.\n"); 267 121 } 268 122 269 123 #[test] 270 − fn capture_speakers_have_fixed_labels() { 271 − assert_eq!(Speaker::Me.label(), "Я"); 272 − assert_eq!(Speaker::Others.label(), "Собеседники"); 273 − let json = serde_json::to_string(&Speaker::Named("Анна".into())).unwrap(); 274 − assert_eq!(json, r#"{"kind":"named","name":"Анна"}"#); 124 + fn speaker_serializes_for_the_interface() { 125 + assert_eq!(serde_json::to_string(&Speaker::Me).unwrap(), r#"{"kind":"me"}"#); 126 + assert_eq!(Speaker::Others.track_file(), "others.wav"); 275 127 } 276 128 }