169ce64e1705fd64e48ee357a816bbdb90bab01d
Ветки
Перейти к файлуt
A
anc
Этап 1: запись встречи в Zoom, живая расшифровка и разбор
- crates/audio: захват звука процесса Zoom и микрофона (WASAPI, process
loopback), формат 16 кГц моно, нарезка на куски по паузам с отсевом
тишины, распознавание начала и конца встречи по звуковой сессии Zoom.
- Ядро: запись двух дорожек → Whisper через OpenRouter → расшифровка,
живой конспект раз в три минуты, по окончании — разбор сам; звук
удаляется после разбора, если не включено хранение.
- Контроллер: слежу за Zoom → прошу предупредить участников → пишу;
запись вручную, пауза, стоп; прерванная запись помечается при запуске.
- Трей, уведомления, крестик прячет окно, запуск вместе с Windows.
- Интерфейс: главный экран с карточкой состояния (задача #1), живая
расшифровка и конспект на экране встречи, новые настройки.
- reqwest на системном TLS: под Windows без C-кода ring.
- 67 тестов; код под Windows проверен компиляцией и clippy с Mac,
на Windows не запускался.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169ce64
30 сентября 2026 г.
meetings.rs
//! Встречи на диске пользователя. Каждая — своя папка:
//!
//! ```text
//! <корень>/20260930-154300/
//! meeting.json — название, когда, откуда, модель и цена разбора
//! transcript.json — реплики
//! transcript.md — расшифровка для людей
//! analysis.json — разбор (после «Разобрать»)
//! summary.md — разбор для людей; это его сохраним в Skillok на этапе 2
//! live.md — живой конспект, пока идёт запись
//! audio/me.wav, audio/others.wav — дорожки записи; удаляются после разбора,
//! если в настройках не сказано хранить звук
//! ```
//!
//! Запись меняет встречу много раз в минуту из разных задач (реплики, живой
//! конспект, разбор), поэтому каждое изменение «прочитал — поменял — записал»
//! идёт под одним замком: иначе две задачи затирали бы друг друга.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use crate::analysis::Analysis;
use crate::openrouter::Usage;
use crate::transcript::{ParseError, Segment, Speaker, Transcript};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Source {
/// Текст расшифровки, который пользователь вставил сам.
Import,
/// Запись Zoom или микрофона.
Recording,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MeetingState {
/// Идёт запись: реплики ещё прибывают.
Recording,
#[default]
Done,
/// Программа закрылась посреди записи; что успело — сохранено.
Interrupted,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Meeting {
pub id: String,
pub title: String,
pub created_at: DateTime<Local>,
pub source: Source,
pub duration_ms: u64,
pub segments: usize,
pub analysis: Option<AnalysisMeta>,
#[serde(default)]
pub state: MeetingState,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalysisMeta {
pub model: String,
pub at: DateTime<Local>,
pub usage: Usage,
pub tasks: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct MeetingDetail {
pub meeting: Meeting,
pub transcript: Transcript,
pub analysis: Option<Analysis>,
pub live_summary: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("встреча «{0}» не найдена")]
NotFound(String),
#[error("название встречи не может быть пустым")]
BlankTitle,
#[error("расшифровка не разбирается: {0}")]
Parse(#[from] ParseError),
#[error("файл встречи: {0}")]
Io(#[from] std::io::Error),
#[error("файл встречи повреждён: {0}")]
Corrupt(#[from] serde_json::Error),
}
pub struct Store {
root: PathBuf,
write: Mutex<()>,
}
impl Store {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
write: Mutex::new(()),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
self.write.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn root(&self) -> &Path {
&self.root
}
/// Все встречи, свежие сверху. Папка с повреждённым `meeting.json`
/// пропускается, а не роняет весь список.
pub fn list(&self) -> Result<Vec<Meeting>, Error> {
let entries = match std::fs::read_dir(&self.root) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut out = Vec::new();
for entry in entries.flatten() {
let file = entry.path().join("meeting.json");
if let Ok(text) = std::fs::read_to_string(&file) {
if let Ok(m) = serde_json::from_str::<Meeting>(&text) {
out.push(m);
}
}
}
out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
Ok(out)
}
pub fn get(&self, id: &str) -> Result<MeetingDetail, Error> {
let dir = self.dir(id)?;
let meeting: Meeting = read_json(&dir.join("meeting.json"))?;
let transcript: Transcript = read_json(&dir.join("transcript.json"))?;
let analysis_path = dir.join("analysis.json");
let analysis = if analysis_path.exists() {
Some(read_json(&analysis_path)?)
} else {
None
};
let live_summary = std::fs::read_to_string(dir.join("live.md")).ok();
Ok(MeetingDetail {
meeting,
transcript,
analysis,
live_summary,
})
}
/// Новая встреча из вставленного текста расшифровки.
pub fn import(&self, title: &str, text: &str, now: DateTime<Local>) -> Result<Meeting, Error> {
let title = title.trim();
if title.is_empty() {
return Err(Error::BlankTitle);
}
let transcript = Transcript::parse(text)?;
let _guard = self.lock();
let id = self.free_id(now)?;
let dir = self.root.join(&id);
std::fs::create_dir_all(&dir)?;
let meeting = Meeting {
id,
title: title.to_string(),
created_at: now,
source: Source::Import,
duration_ms: transcript.duration_ms(),
segments: transcript.segments.len(),
analysis: None,
state: MeetingState::Done,
};
write_json(&dir.join("transcript.json"), &transcript)?;
std::fs::write(dir.join("transcript.md"), transcript.to_markdown(title))?;
// meeting.json пишется последним: пока его нет, список встречу не видит.
write_json(&dir.join("meeting.json"), &meeting)?;
Ok(meeting)
}
pub fn save_analysis(
&self,
id: &str,
analysis: &Analysis,
model: &str,
usage: Usage,
now: DateTime<Local>,
) -> Result<Meeting, Error> {
let _guard = self.lock();
let dir = self.dir(id)?;
let mut meeting: Meeting = read_json(&dir.join("meeting.json"))?;
let transcript: Transcript = read_json(&dir.join("transcript.json"))?;
write_json(&dir.join("analysis.json"), analysis)?;
std::fs::write(dir.join("summary.md"), analysis.to_markdown(&transcript))?;
meeting.analysis = Some(AnalysisMeta {
model: model.to_string(),
at: now,
usage,
tasks: analysis.tasks.len(),
});
write_json(&dir.join("meeting.json"), &meeting)?;
Ok(meeting)
}
/// Новая встреча под запись: пустая расшифровка, состояние «идёт запись».
pub fn create_recording(&self, title: &str, now: DateTime<Local>) -> Result<Meeting, Error> {
let _guard = self.lock();
let id = self.free_id(now)?;
let dir = self.root.join(&id);
std::fs::create_dir_all(dir.join("audio"))?;
let meeting = Meeting {
id,
title: title.to_string(),
created_at: now,
source: Source::Recording,
duration_ms: 0,
segments: 0,
analysis: None,
state: MeetingState::Recording,
};
let empty = Transcript::default();
write_json(&dir.join("transcript.json"), &empty)?;
std::fs::write(dir.join("transcript.md"), empty.to_markdown(title))?;
write_json(&dir.join("meeting.json"), &meeting)?;
Ok(meeting)
}
/// Дописывает реплики. Реплики двух дорожек приходят не по порядку
/// (кусок «собеседников» распознаётся дольше), поэтому расшифровка
/// каждый раз упорядочивается по времени.
pub fn append_segments(&self, id: &str, new: &[Segment]) -> Result<Meeting, Error> {
let _guard = self.lock();
let dir = self.dir(id)?;
let mut meeting: Meeting = read_json(&dir.join("meeting.json"))?;
let mut transcript: Transcript = read_json(&dir.join("transcript.json"))?;
transcript.segments.extend_from_slice(new);
transcript.segments.sort_by_key(|s| s.start_ms);
write_json(&dir.join("transcript.json"), &transcript)?;
std::fs::write(dir.join("transcript.md"), transcript.to_markdown(&meeting.title))?;
meeting.segments = transcript.segments.len();
meeting.duration_ms = meeting.duration_ms.max(transcript.duration_ms());
write_json(&dir.join("meeting.json"), &meeting)?;
Ok(meeting)
}
pub fn save_live_summary(&self, id: &str, text: &str) -> Result<(), Error> {
let _guard = self.lock();
let dir = self.dir(id)?;
std::fs::write(dir.join("live.md"), text)?;
Ok(())
}
/// Запись закончилась: настоящая длительность — по звуку, а не по последней реплике.
pub fn finish_recording(&self, id: &str, duration_ms: u64) -> Result<Meeting, Error> {
let _guard = self.lock();
let dir = self.dir(id)?;
let mut meeting: Meeting = read_json(&dir.join("meeting.json"))?;
meeting.duration_ms = meeting.duration_ms.max(duration_ms);
meeting.state = MeetingState::Done;
write_json(&dir.join("meeting.json"), &meeting)?;
Ok(meeting)
}
/// Встречи, оставшиеся «в записи» после падения или выключения
/// компьютера, помечаются прерванными. Зовётся при запуске программы.
pub fn recover_interrupted(&self) -> Result<Vec<String>, Error> {
let _guard = self.lock();
let mut ids = Vec::new();
for m in self.list()? {
if m.state == MeetingState::Recording {
let mut m = m;
m.state = MeetingState::Interrupted;
write_json(&self.root.join(&m.id).join("meeting.json"), &m)?;
ids.push(m.id);
}
}
Ok(ids)
}
/// Файл дорожки записи.
pub fn audio_path(&self, id: &str, speaker: &Speaker) -> Result<PathBuf, Error> {
let dir = self.dir(id)?.join("audio");
std::fs::create_dir_all(&dir)?;
let name = match speaker {
Speaker::Me => "me",
Speaker::Others => "others",
Speaker::Named(_) => "named",
};
Ok(dir.join(format!("{name}.wav")))
}
pub fn delete_audio(&self, id: &str) -> Result<(), Error> {
let dir = self.dir(id)?.join("audio");
match std::fs::remove_dir_all(&dir) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
}
}
/// Папка встречи. Идентификатор приходит из интерфейса, поэтому в путь
/// попадают только цифры и дефис — `..` и слэши сюда не пролезут.
fn dir(&self, id: &str) -> Result<PathBuf, Error> {
let valid = !id.is_empty() && id.len() <= 32 && id.chars().all(|c| c.is_ascii_digit() || c == '-');
let dir = self.root.join(id);
if valid && dir.join("meeting.json").is_file() {
Ok(dir)
} else {
Err(Error::NotFound(id.to_string()))
}
}
fn free_id(&self, now: DateTime<Local>) -> Result<String, Error> {
let base = now.format("%Y%m%d-%H%M%S").to_string();
let mut id = base.clone();
let mut n = 1;
while self.root.join(&id).exists() {
n += 1;
id = format!("{base}-{n}");
}
Ok(id)
}
}
fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, Error> {
Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), Error> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(value)?)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::Review;
use chrono::TimeZone;
fn at(h: u32, m: u32) -> DateTime<Local> {
Local.with_ymd_and_hms(2026, 9, 30, h, m, 0).unwrap()
}
const TEXT: &str = "[00:00:01] Анна: Отчёт к пятнице.\n[00:02:05] Борис: Возьму.";
#[test]
fn import_list_get() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path().join("meetings"));
assert!(store.list().unwrap().is_empty(), "нет папки — пустой список");
let a = store.import("Планёрка", TEXT, at(10, 0)).unwrap();
let b = store.import("Созвон", TEXT, at(11, 0)).unwrap();
assert_eq!(a.id, "20260930-100000");
assert_eq!(a.duration_ms, 125_000);
assert_eq!(a.segments, 2);
let ids: Vec<_> = store.list().unwrap().into_iter().map(|m| m.id).collect();
assert_eq!(ids, vec![b.id.clone(), a.id.clone()], "свежие сверху");
let detail = store.get(&a.id).unwrap();
assert_eq!(detail.transcript.segments.len(), 2);
assert!(detail.analysis.is_none());
let md = std::fs::read_to_string(store.root().join(&a.id).join("transcript.md")).unwrap();
assert!(md.contains("`00:02:05` **Борис:** Возьму."), "{md}");
}
#[test]
fn same_second_gets_a_new_id() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
let a = store.import("Раз", TEXT, at(10, 0)).unwrap();
let b = store.import("Два", TEXT, at(10, 0)).unwrap();
assert_eq!(a.id, "20260930-100000");
assert_eq!(b.id, "20260930-100000-2");
}
#[test]
fn bad_input_creates_nothing() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
assert!(matches!(
store.import(" ", TEXT, at(10, 0)),
Err(Error::BlankTitle)
));
assert!(matches!(
store.import("Т", "без меток", at(10, 0)),
Err(Error::Parse(_))
));
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[test]
fn ids_cannot_escape_the_root() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path().join("m"));
std::fs::create_dir_all(dir.path().join("secret")).unwrap();
std::fs::write(dir.path().join("secret/meeting.json"), "{}").unwrap();
for id in ["../secret", "..", "", "a/b", "20260930-100000"] {
assert!(matches!(store.get(id), Err(Error::NotFound(_))), "{id}");
}
}
#[test]
fn broken_meeting_is_skipped_in_list() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
store.import("Целая", TEXT, at(10, 0)).unwrap();
std::fs::create_dir_all(dir.path().join("20260930-090000")).unwrap();
std::fs::write(dir.path().join("20260930-090000/meeting.json"), "{битый").unwrap();
assert_eq!(store.list().unwrap().len(), 1);
}
#[test]
fn recording_lifecycle() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
let m = store.create_recording("Встреча в Zoom", at(10, 0)).unwrap();
assert_eq!(m.state, MeetingState::Recording);
assert!(store.get(&m.id).unwrap().transcript.segments.is_empty());
let seg = |ms, speaker: Speaker, text: &str| Segment {
start_ms: ms,
speaker,
text: text.into(),
};
store
.append_segments(&m.id, &[seg(9_000, Speaker::Others, "Слышно?")])
.unwrap();
let after = store
.append_segments(&m.id, &[seg(2_000, Speaker::Me, "Всем привет")])
.unwrap();
assert_eq!(after.segments, 2);
let t = store.get(&m.id).unwrap().transcript;
assert_eq!(
t.segments[0].text, "Всем привет",
"реплики упорядочены по времени"
);
store.save_live_summary(&m.id, "- Здороваются").unwrap();
assert_eq!(
store.get(&m.id).unwrap().live_summary.as_deref(),
Some("- Здороваются")
);
let wav = store.audio_path(&m.id, &Speaker::Me).unwrap();
std::fs::write(&wav, b"x").unwrap();
let done = store.finish_recording(&m.id, 65_000).unwrap();
assert_eq!(done.state, MeetingState::Done);
assert_eq!(done.duration_ms, 65_000);
store.delete_audio(&m.id).unwrap();
assert!(!wav.exists());
store.delete_audio(&m.id).unwrap();
}
#[test]
fn unfinished_recording_is_marked_interrupted_on_start() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
let rec = store.create_recording("Оборвалась", at(10, 0)).unwrap();
let imported = store.import("Целая", TEXT, at(11, 0)).unwrap();
assert_eq!(store.recover_interrupted().unwrap(), vec![rec.id.clone()]);
assert_eq!(
store.get(&rec.id).unwrap().meeting.state,
MeetingState::Interrupted
);
assert_eq!(store.get(&imported.id).unwrap().meeting.state, MeetingState::Done);
}
#[test]
fn old_meeting_json_without_state_reads_as_done() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
let m = store.import("Старая", TEXT, at(10, 0)).unwrap();
let path = dir.path().join(&m.id).join("meeting.json");
let mut v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
v.as_object_mut().unwrap().remove("state");
std::fs::write(&path, v.to_string()).unwrap();
assert_eq!(store.get(&m.id).unwrap().meeting.state, MeetingState::Done);
}
#[test]
fn save_analysis_writes_summary_and_meta() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
let m = store.import("Планёрка", TEXT, at(10, 0)).unwrap();
let analysis = Analysis {
title: "Отчёт".into(),
summary: "Договорились.".into(),
review: Review {
achieved: vec![],
open: vec![],
off_track: vec![],
verdict: "Ок.".into(),
},
decisions: vec![],
tasks: vec![],
open_questions: vec![],
};
let usage = Usage {
prompt_tokens: 10,
completion_tokens: 5,
cost: Some(0.01),
};
let saved = store
.save_analysis(&m.id, &analysis, "anthropic/claude-opus-5.5", usage, at(10, 5))
.unwrap();
assert_eq!(
saved.analysis.as_ref().unwrap().model,
"anthropic/claude-opus-5.5"
);
let detail = store.get(&m.id).unwrap();
assert_eq!(detail.analysis, Some(analysis));
assert_eq!(detail.meeting.analysis.unwrap().usage.cost, Some(0.01));
assert!(store.root().join(&m.id).join("summary.md").is_file());
}
}