skillok meeting

Ядро: встреча = заметка 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>

anc · 30 сентября 2026 г. ad47f897f99f8450c300100150f8c07c6061d85a
28 файлов
  • 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 +}
  • Показать разницуСкрыть разницу
    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 строк.
  • Показать разницуСкрыть разницу
    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` и оставь списки пустыми.
  • Показать разницуСкрыть разницу
    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 +Исполнитель и срок — словами из разговора; если не прозвучали, пиши «исполнитель не назван» и не указывай срок. Сроки не пересчитывай в даты. После списка — «Открытые вопросы»: что осталось без ответа или надо поднять в следующий раз. Если задач не было, так и напиши.
  • Показать разницуСкрыть разницу
    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 }
  • Показать разницуСкрыть разницу
    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 строк.
  • Показать разницуСкрыть разницу
    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 строк.
  • Показать разницуСкрыть разницу
    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 строк.
  • Показать разницуСкрыть разницу
    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 +}
  • Показать разницуСкрыть разницу
    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 }