Меню как у skillok.org, живая расшифровка и ответ ИИ, длинные записи (#13)
- Логотип убран. Шапка и меню как у skillok.org: кнопка ☰ открывает панель слева (Главная, Записать, Добавить аудиофайл, записи с поиском, Настройки), закрытие крестиком, кликом мимо, Esc и переходом; крошки «Skillok Meet / запись». Список записей с поиском — на главной. - Во время записи один общий уровень вместо «Вы» и «Собеседники». - Расшифровка по ходу: реплики приходят по порядку времени (кусок уходит, когда готовы все куски раньше), текст растёт на экране. - Ответ ИИ потоком (stream: true): конспект, задачи, ревью и свой запрос пишутся на глазах. - Длинные записи: дорожка не читается в память целиком — нарезка блоками по 10 с, каждый кусок читается с диска перед отправкой; чужой файл один раз перегоняется во временный WAV. Прогон 2 ч × 2 дорожки с подделкой распознавания: 1600 кусков, последняя реплика 01:59:55, пик памяти 25 МБ. - Говорящий в файле без дорожек — «Спикер 1». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
43a49bd
· сравнить с родителем
-
crates/audio/src/pcm.rs +106 −2
Показать разницуСкрыть разницу
3 3 //! своего ресемплера нет. 4 4 5 5 use std::fs::File; 6 −use std::io::{self, Seek, SeekFrom, Write}; 7 −use std::path::Path; 6 +use std::io::{self, Read, Seek, SeekFrom, Write}; 7 +use std::path::{Path, PathBuf}; 8 8 9 9 pub const SAMPLE_RATE: u32 = 16_000; 10 10 pub const SAMPLES_PER_MS: u64 = SAMPLE_RATE as u64 / 1000; 210 210 Err(bad("нет блока data")) 211 211 } 212 212 213 +/// Дорожка в нашем формате на диске, читаемая по кускам. Двухчасовая встреча — 214 +/// 230 МБ на дорожку: держать её в памяти целиком незачем, расшифровке 215 +/// одновременно нужны несколько кусков по полминуты. 216 +#[derive(Debug, Clone)] 217 +pub struct WavFile { 218 + path: PathBuf, 219 + data_start: u64, 220 + samples: usize, 221 +} 222 + 223 +impl WavFile { 224 + pub fn open(path: &Path) -> io::Result<Self> { 225 + let bad = |m: &str| io::Error::new(io::ErrorKind::InvalidData, m.to_string()); 226 + let mut file = File::open(path)?; 227 + let file_len = file.metadata()?.len(); 228 + let mut riff = [0u8; 12]; 229 + file.read_exact(&mut riff).map_err(|_| bad("не WAV"))?; 230 + if &riff[0..4] != b"RIFF" || &riff[8..12] != b"WAVE" { 231 + return Err(bad("не WAV")); 232 + } 233 + let mut pos = 12u64; 234 + let mut format_ok = false; 235 + while pos + 8 <= file_len { 236 + let mut head = [0u8; 8]; 237 + file.seek(SeekFrom::Start(pos))?; 238 + file.read_exact(&mut head)?; 239 + let size = u32::from_le_bytes(head[4..8].try_into().unwrap()) as u64; 240 + let body = pos + 8; 241 + if &head[0..4] == b"fmt " { 242 + let mut f = [0u8; 16]; 243 + file.read_exact(&mut f).map_err(|_| bad("короткий fmt"))?; 244 + let fmt = u16::from_le_bytes([f[0], f[1]]); 245 + let channels = u16::from_le_bytes([f[2], f[3]]); 246 + let rate = u32::from_le_bytes(f[4..8].try_into().unwrap()); 247 + let bits = u16::from_le_bytes([f[14], f[15]]); 248 + format_ok = fmt == 1 && channels == 1 && rate == SAMPLE_RATE && bits == 16; 249 + if !format_ok { 250 + return Err(bad("нужен WAV 16 кГц, моно, 16 бит")); 251 + } 252 + } else if &head[0..4] == b"data" { 253 + if !format_ok { 254 + return Err(bad("data раньше fmt")); 255 + } 256 + // Как у `decode_wav`: заголовок обгонять файл не может. 257 + let bytes = size.min(file_len - body); 258 + return Ok(Self { 259 + path: path.to_path_buf(), 260 + data_start: body, 261 + samples: (bytes / 2) as usize, 262 + }); 263 + } 264 + pos = body + size + (size & 1); 265 + } 266 + Err(bad("нет блока data")) 267 + } 268 + 269 + /// Сколько сэмплов в дорожке. 270 + pub fn len(&self) -> usize { 271 + self.samples 272 + } 273 + 274 + pub fn is_empty(&self) -> bool { 275 + self.samples == 0 276 + } 277 + 278 + /// `n` сэмплов с сэмпла `start`; у конца дорожки — сколько есть. 279 + pub fn read(&self, start: usize, n: usize) -> io::Result<Vec<i16>> { 280 + let n = n.min(self.samples.saturating_sub(start)); 281 + let mut file = File::open(&self.path)?; 282 + file.seek(SeekFrom::Start(self.data_start + start as u64 * 2))?; 283 + let mut bytes = vec![0u8; n * 2]; 284 + file.read_exact(&mut bytes)?; 285 + Ok(bytes 286 + .as_chunks::<2>() 287 + .0 288 + .iter() 289 + .map(|c| i16::from_le_bytes(*c)) 290 + .collect()) 291 + } 292 +} 293 + 213 294 /// Дорожка встречи на диске. Заголовок дописывается при каждом `flush`, поэтому 214 295 /// файл читается, даже если программа упала посреди записи. 215 296 pub struct WavWriter { 368 449 vec![0, 32767, -32767, 32767, -32767] 369 450 ); 370 451 } 452 + 453 + #[test] 454 + fn wav_file_reads_slices_from_disk() { 455 + let path = std::env::temp_dir().join(format!("meet-wavfile-{}.wav", std::process::id())); 456 + let samples: Vec<i16> = (0..50_000).map(|i| (i % 3000) as i16 - 1500).collect(); 457 + { 458 + let mut w = WavWriter::create(&path).unwrap(); 459 + w.write(&samples).unwrap(); 460 + } 461 + let f = WavFile::open(&path).unwrap(); 462 + assert_eq!(f.len(), samples.len()); 463 + assert_eq!(f.read(0, 10).unwrap(), samples[..10]); 464 + assert_eq!(f.read(12_345, 4_000).unwrap(), samples[12_345..16_345]); 465 + assert_eq!( 466 + f.read(49_990, 100).unwrap(), 467 + samples[49_990..], 468 + "у конца — сколько есть" 469 + ); 470 + assert!(f.read(60_000, 10).unwrap().is_empty()); 471 + std::fs::write(&path, b"RIFF....WAVEjunk").unwrap(); 472 + assert!(WavFile::open(&path).is_err()); 473 + let _ = std::fs::remove_file(&path); 474 + } 371 475 } -
index.html +16 −4
Показать разницуСкрыть разницу
9 9 <link rel="stylesheet" href="/src/styles/app.css"> 10 10 </head> 11 11 <body> 12 − <div class="shell"> 13 − <aside id="side" class="side" aria-label="Записи"></aside> 14 − <main id="app" class="main"></main> 15 − </div> 12 + <header class="hdr"> 13 + <div class="hdr-row"> 14 + <details class="drawer hdr-nav" name="shell-top"> 15 + <summary class="hdr-burger" title="Меню" aria-label="Меню"> 16 + <span class="hdr-burger-bar"></span> 17 + <span class="hdr-burger-bar"></span> 18 + <span class="hdr-burger-bar"></span> 19 + </summary> 20 + <div class="drawer-body" id="drawer"></div> 21 + </details> 22 + <nav class="hdr-crumbs" id="crumbs" aria-label="Где я"></nav> 23 + <span class="hdr-spacer"></span> 24 + <div class="hdr-right" id="hdr-right"></div> 25 + </div> 26 + </header> 27 + <main id="app" class="main"></main> 16 28 <p id="toast" class="toast" role="status" hidden></p> 17 29 <script type="module" src="/src/main.ts"></script> 18 30 </body> -
src-tauri/src/commands.rs +63 −25
Показать разницуСкрыть разницу
11 11 use tauri::{AppHandle, Emitter, State}; 12 12 use tokio_util::sync::CancellationToken; 13 13 14 +use meet_audio::pcm::{WavFile, WavWriter}; 14 15 use meet_audio::source::{Capture, CaptureError}; 15 16 16 17 use crate::ai::{self, Command}; 318 319 meeting_id: &'a str, 319 320 done: usize, 320 321 total: usize, 322 + /// Новые реплики по порядку времени, абзацами как в заметке. 323 + lines: Vec<String>, 321 324 } 322 325 323 −fn read_track(path: &Path) -> Result<Vec<i16>, String> { 324 − let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; 325 − // Свои дорожки записи читаются напрямую, чужие файлы — декодером. 326 − match meet_audio::pcm::decode_wav(&bytes) { 327 − Ok(samples) => Ok(samples), 328 − Err(_) => meet_audio::decode::decode_file(path).map_err(|e| e.to_string()), 326 +/// Дорожка для расшифровки, читаемая с диска по кускам. Свои дорожки записи — 327 +/// WAV нашего формата; чужой файл (mp3, m4a…) один раз перегоняется в такой 328 +/// WAV рядом, чтобы и двухчасовой файл не держать в памяти всю расшифровку. 329 +/// Второй путь — файл-времянка, его надо удалить после. 330 +fn open_track(path: &Path) -> Result<(WavFile, Option<PathBuf>), String> { 331 + if let Ok(f) = WavFile::open(path) { 332 + return Ok((f, None)); 329 333 } 334 + let samples = meet_audio::decode::decode_file(path).map_err(|e| e.to_string())?; 335 + let temp = path.with_extension("transcribe.wav"); 336 + let mut w = WavWriter::create(&temp).map_err(|e| format!("{}: {e}", temp.display()))?; 337 + w.write(&samples).map_err(|e| e.to_string())?; 338 + w.flush().map_err(|e| e.to_string())?; 339 + drop(samples); 340 + let f = WavFile::open(&temp).map_err(|e| e.to_string())?; 341 + Ok((f, Some(temp))) 330 342 } 331 343 332 344 #[tauri::command] 352 364 state.store().audio_path(&id, file).map_err(|e| e.to_string())?, 353 365 )); 354 366 } 355 − let tracks = tokio::task::spawn_blocking(move || { 367 + let opened = tokio::task::spawn_blocking(move || { 356 368 tracks 357 369 .into_iter() 358 − .map(|(s, p)| read_track(&p).map(|a| (s, a))) 370 + .map(|(s, p)| open_track(&p).map(|a| (s, a))) 359 371 .collect::<Result<Vec<_>, _>>() 360 372 }) 361 373 .await 362 374 .map_err(|e| e.to_string())??; 375 + let mut temps = Vec::new(); 376 + let tracks: Vec<(Speaker, Arc<dyn transcribe::Samples>)> = opened 377 + .into_iter() 378 + .map(|(speaker, (file, temp))| { 379 + temps.extend(temp); 380 + (speaker, Arc::new(file) as Arc<dyn transcribe::Samples>) 381 + }) 382 + .collect(); 383 + // Времянки удаляются при любом исходе, в том числе при отмене. 384 + let _cleanup = RemoveOnDrop(temps); 363 385 364 386 let stt = Arc::new(OpenRouterStt { 365 387 client: Client::new(key), 366 388 model: settings.stt_model.clone(), 367 389 language: settings.stt_language().map(str::to_string), 368 390 }); 369 − let outcome = transcribe::run( 370 − tracks, 371 − stt, 372 − transcribe::Config::default(), 373 − &job.token, 374 − |done, total| { 375 − let _ = app.emit( 376 − "meet://progress", 377 − Progress { 378 − meeting_id: &id, 379 − done, 380 − total, 381 − }, 382 − ); 383 − }, 384 − ) 391 + let outcome = transcribe::run(tracks, stt, transcribe::Config::default(), &job.token, |u| { 392 + let _ = app.emit( 393 + "meet://progress", 394 + Progress { 395 + meeting_id: &id, 396 + done: u.done, 397 + total: u.total, 398 + lines: u.fresh.iter().map(transcript::line).collect(), 399 + }, 400 + ); 401 + }) 385 402 .await 386 403 .map_err(|e| e.to_string())?; 387 404 if outcome.failed > 0 && outcome.failed == outcome.segments.len() { 409 426 }) 410 427 } 411 428 429 +struct RemoveOnDrop(Vec<PathBuf>); 430 + 431 +impl Drop for RemoveOnDrop { 432 + fn drop(&mut self) { 433 + for p in &self.0 { 434 + let _ = std::fs::remove_file(p); 435 + } 436 + } 437 +} 438 + 412 439 // ── Команды ИИ ────────────────────────────────────────────────────────────── 413 440 414 441 #[derive(Serialize)] 420 447 421 448 /// Получить результат: ответ ИИ ложится разделом на место прежнего с тем же 422 449 /// заголовком. Прежний ответ модель не видит — иначе она его пересказывает. 450 +#[derive(Serialize, Clone)] 451 +struct AnswerPiece<'a> { 452 + meeting_id: &'a str, 453 + heading: &'a str, 454 + /// Новый кусок ответа; интерфейс дописывает его к уже показанному. 455 + text: &'a str, 456 +} 457 + 423 458 #[tauri::command] 424 459 pub async fn run_command( 460 + app: AppHandle, 425 461 state: State<'_, AppState>, 426 462 id: String, 427 463 command: Command, 435 471 ai::prepare(&command, &crate::notes::remove(¬e, &heading)).map_err(|e| e.to_string())?; 436 472 let client = Client::new(key); 437 473 let answer = tokio::select! { 438 − answer = client.complete(&settings.ai_model, &prepared.system, &prepared.user) => answer.map_err(|e| e.to_string())?.0, 474 + answer = client.complete(&settings.ai_model, &prepared.system, &prepared.user, |text| { 475 + let _ = app.emit("meet://answer", AnswerPiece { meeting_id: &id, heading: &prepared.heading, text }); 476 + }) => answer.map_err(|e| e.to_string())?.0, 439 477 _ = job.token.cancelled() => return Err("Отменено".into()), 440 478 }; 441 479 let note = state -
src-tauri/src/openrouter.rs +200 −40
Показать разницуСкрыть разницу
120 120 } 121 121 122 122 /// Ответ модели простым текстом (Markdown): команда ИИ над заметкой. 123 − pub async fn complete(&self, model: &str, system: &str, user: &str) -> Result<(String, Usage), Error> { 123 + /// Ответ идёт потоком (`stream: true`), и `on_text` получает каждый новый 124 + /// кусок — человек видит, как пишется конспект, а не ждёт минуту пустой 125 + /// экран. Документация: https://openrouter.ai/docs/api/reference/streaming 126 + pub async fn complete( 127 + &self, 128 + model: &str, 129 + system: &str, 130 + user: &str, 131 + mut on_text: impl FnMut(&str), 132 + ) -> Result<(String, Usage), Error> { 124 133 let request = json!({ 125 134 "model": model, 126 135 "max_tokens": 16000, 136 + "stream": true, 137 + "usage": { "include": true }, 127 138 "messages": [ 128 139 { "role": "system", "content": system }, 129 140 { "role": "user", "content": user } 130 141 ] 131 142 }); 132 − let body = self 133 − .send( 134 − self.http 135 − .post(format!("{}/chat/completions", self.base)) 136 − .json(&request), 137 − ) 138 − .await?; 139 − let choice = &body["choices"][0]; 140 − let finish = choice["finish_reason"].as_str().unwrap_or(""); 141 − let text = choice["message"]["content"] 142 − .as_str() 143 − .unwrap_or("") 144 − .trim() 145 − .to_string(); 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())); 143 + let mut response = self 144 + .http 145 + .post(format!("{}/chat/completions", self.base)) 146 + .json(&request) 147 + .bearer_auth(&self.key) 148 + .header("X-Title", "Skillok Meet") 149 + .send() 150 + .await 151 + .map_err(|e| Error::Network(e.to_string()))?; 152 + let status = response.status().as_u16(); 153 + let is_stream = response 154 + .headers() 155 + .get(reqwest::header::CONTENT_TYPE) 156 + .and_then(|v| v.to_str().ok()) 157 + .is_some_and(|v| v.contains("text/event-stream")); 158 + if !(200..=299).contains(&status) || !is_stream { 159 + // Ошибка или ответ целиком, без потока, — разбирается как обычно. 160 + let text = response.text().await.map_err(|e| Error::Network(e.to_string()))?; 161 + let body = parse_reply(status, &text)?; 162 + let (text, usage) = answer_of(&body)?; 163 + on_text(&text); 164 + return Ok((text, usage)); 165 + } 166 + let mut sse = SseAnswer::default(); 167 + let mut pending = Vec::new(); 168 + while let Some(bytes) = response 169 + .chunk() 170 + .await 171 + .map_err(|e| Error::Network(e.to_string()))? 172 + { 173 + pending.extend_from_slice(&bytes); 174 + // Событие — строка `data: {...}`; строка может прийти по частям. 175 + while let Some(end) = pending.iter().position(|&b| b == b'\n') { 176 + let line: Vec<u8> = pending.drain(..=end).collect(); 177 + if let Some(delta) = sse.line(String::from_utf8_lossy(&line).trim())? { 178 + on_text(&delta); 179 + } 180 + } 153 181 } 154 − Ok((text, usage_of(&body))) 182 + sse.finish() 155 183 } 156 184 157 185 async fn send(&self, request: reqwest::RequestBuilder) -> Result<Value, Error> { 163 191 .map_err(|e| Error::Network(e.to_string()))?; 164 192 let status = response.status().as_u16(); 165 193 let text = response.text().await.map_err(|e| Error::Network(e.to_string()))?; 166 − match status { 167 − 200..=299 => serde_json::from_str(&text).map_err(|e| Error::Http { 194 + parse_reply(status, &text) 195 + } 196 +} 197 + 198 +fn parse_reply(status: u16, text: &str) -> Result<Value, Error> { 199 + match status { 200 + 200..=299 => serde_json::from_str(text).map_err(|e| Error::Http { 201 + status, 202 + message: format!("ответ не JSON: {e}"), 203 + }), 204 + 401 | 403 => Err(Error::Unauthorized), 205 + 402 => Err(Error::PaymentRequired), 206 + 429 => Err(Error::RateLimited), 207 + _ => { 208 + let message = serde_json::from_str::<Value>(text) 209 + .ok() 210 + .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) 211 + .unwrap_or_else(|| text.chars().take(300).collect()); 212 + Err(Error::Http { status, message }) 213 + } 214 + } 215 +} 216 + 217 +/// Ответ целиком: текст, если модель его закончила. 218 +fn answer_of(body: &Value) -> Result<(String, Usage), Error> { 219 + let choice = &body["choices"][0]; 220 + let finish = choice["finish_reason"].as_str().unwrap_or(""); 221 + let text = choice["message"]["content"] 222 + .as_str() 223 + .unwrap_or("") 224 + .trim() 225 + .to_string(); 226 + finished(finish, text, usage_of(body)) 227 +} 228 + 229 +fn finished(finish: &str, text: String, usage: Usage) -> Result<(String, Usage), Error> { 230 + if finish != "stop" || text.is_empty() { 231 + let why = if finish.is_empty() { 232 + "пустой ответ" 233 + } else { 234 + finish 235 + }; 236 + return Err(Error::Incomplete(why.to_string())); 237 + } 238 + Ok((text, usage)) 239 +} 240 + 241 +/// Сборка ответа из потока событий `data: {...}`. Строки `: ...` — сигнал 242 +/// «ещё работаю» от OpenRouter, их пропускаем. 243 +#[derive(Default)] 244 +struct SseAnswer { 245 + text: String, 246 + finish: String, 247 + usage: Usage, 248 +} 249 + 250 +impl SseAnswer { 251 + /// Разбирает строку потока; возвращает новый кусок текста, если он есть. 252 + fn line(&mut self, line: &str) -> Result<Option<String>, Error> { 253 + let Some(data) = line.strip_prefix("data:") else { 254 + return Ok(None); 255 + }; 256 + let data = data.trim(); 257 + if data.is_empty() || data == "[DONE]" { 258 + return Ok(None); 259 + } 260 + let Ok(event) = serde_json::from_str::<Value>(data) else { 261 + return Ok(None); 262 + }; 263 + if let Some(message) = event["error"]["message"].as_str() { 264 + // Ошибка посреди потока: статус уже 200, причина — в событии. 265 + let status = event["error"]["code"].as_u64().unwrap_or(500) as u16; 266 + return Err(Error::Http { 168 267 status, 169 − message: format!("ответ не JSON: {e}"), 170 − }), 171 − 401 | 403 => Err(Error::Unauthorized), 172 − 402 => Err(Error::PaymentRequired), 173 − 429 => Err(Error::RateLimited), 174 − _ => { 175 − let message = serde_json::from_str::<Value>(&text) 176 − .ok() 177 − .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) 178 − .unwrap_or_else(|| text.chars().take(300).collect()); 179 − Err(Error::Http { status, message }) 180 − } 268 + message: message.to_string(), 269 + }); 270 + } 271 + if event["usage"].is_object() { 272 + self.usage = usage_of(&event); 273 + } 274 + let choice = &event["choices"][0]; 275 + if let Some(f) = choice["finish_reason"].as_str() { 276 + self.finish = f.to_string(); 181 277 } 278 + let delta = choice["delta"]["content"].as_str().unwrap_or(""); 279 + if delta.is_empty() { 280 + return Ok(None); 281 + } 282 + self.text.push_str(delta); 283 + Ok(Some(delta.to_string())) 284 + } 285 + 286 + fn finish(self) -> Result<(String, Usage), Error> { 287 + finished(&self.finish, self.text.trim().to_string(), self.usage) 182 288 } 183 289 } 184 290 261 367 /// Заглушка одного HTTP-ответа. Возвращает адрес и канал, по которому 262 368 /// придёт полученный запрос целиком. 263 369 fn serve_once(status: u16, body: &str) -> (String, mpsc::Receiver<String>) { 370 + serve_once_as(status, "application/json", body) 371 + } 372 + 373 + fn serve_once_as(status: u16, content_type: &str, body: &str) -> (String, mpsc::Receiver<String>) { 374 + let content_type = content_type.to_string(); 264 375 let listener = TcpListener::bind("127.0.0.1:0").unwrap(); 265 376 let addr = listener.local_addr().unwrap(); 266 377 let body = body.to_string(); 292 403 } 293 404 tx.send(String::from_utf8_lossy(&buf).to_string()).unwrap(); 294 405 let reply = format!( 295 − "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", 406 + "HTTP/1.1 {status} X\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", 296 407 body.len() 297 408 ); 298 409 stream.write_all(reply.as_bytes()).unwrap(); 423 534 let reply = json!({ "choices": [{ "finish_reason": "stop", "message": { "content": " ## Задачи\n\n- [ ] Отчёт\n" } }] }); 424 535 let (base, rx) = serve_once(200, &reply.to_string()); 425 536 let (text, _) = Client::with_base(base, "k") Разница обрезана: показаны первые 225 строк. -
src-tauri/src/transcribe.rs +313 −51
Показать разницуСкрыть разницу
7 7 //! Куски без речи не отправляются: это экономит деньги, и Whisper не сочиняет 8 8 //! фраз на тишине. Неудачный кусок повторяется один раз; если и повтор не 9 9 //! прошёл, на его месте в расшифровке остаётся пометка, а не молчаливая дыра. 10 +//! 11 +//! Длинные записи: дорожка не читается в память целиком (два часа — 230 МБ на 12 +//! дорожку). Сначала она проходит блоками по 10 секунд и оставляет только 13 +//! границы кусков, потом каждый кусок читается с диска перед отправкой. 14 +//! 15 +//! Живой ход: готовые реплики отдаются наружу по порядку времени — кусок 16 +//! уходит к человеку, когда распознаны все куски раньше него, и текст растёт 17 +//! сверху вниз, а не вразнобой. 10 18 19 +use std::collections::BTreeMap; 11 20 use std::future::Future; 21 +use std::io; 12 22 use std::pin::Pin; 13 23 use std::sync::Arc; 14 24 use std::time::Duration; 15 25 16 −use meet_audio::chunker::{self, Chunk, Chunker}; 17 −use meet_audio::pcm::{self, samples_to_ms}; 26 +use meet_audio::chunker::{self, Chunker}; 27 +use meet_audio::pcm::{self, ms_to_samples, samples_to_ms, WavFile}; 18 28 use tokio::task::JoinSet; 19 29 use tokio_util::sync::CancellationToken; 20 30 28 38 fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>>; 29 39 } 30 40 41 +/// Звук дорожки: файл на диске в приложении, вектор в тестах. 42 +pub trait Samples: Send + Sync + 'static { 43 + fn len(&self) -> usize; 44 + fn read(&self, start: usize, n: usize) -> io::Result<Vec<i16>>; 45 + fn is_empty(&self) -> bool { 46 + self.len() == 0 47 + } 48 +} 49 + 50 +impl Samples for WavFile { 51 + fn len(&self) -> usize { 52 + WavFile::len(self) 53 + } 54 + fn read(&self, start: usize, n: usize) -> io::Result<Vec<i16>> { 55 + WavFile::read(self, start, n) 56 + } 57 +} 58 + 59 +impl Samples for Vec<i16> { 60 + fn len(&self) -> usize { 61 + self.as_slice().len() 62 + } 63 + fn read(&self, start: usize, n: usize) -> io::Result<Vec<i16>> { 64 + let start = start.min(self.as_slice().len()); 65 + let end = (start + n).min(self.as_slice().len()); 66 + Ok(self[start..end].to_vec()) 67 + } 68 +} 69 + 31 70 pub const UNRECOGNIZED: &str = "*(этот кусок не распознался — нажмите «Расшифровать» ещё раз)*"; 32 71 33 72 #[derive(Debug, Clone, Copy)] 57 96 pub last_error: Option<String>, 58 97 } 59 98 60 −/// Режет дорожку на куски по паузам. 61 −pub fn chunks_of(samples: &[i16], cfg: chunker::Config) -> Vec<Chunk> { 99 +/// Ход расшифровки: сколько кусков готово и какие реплики добавились. 100 +#[derive(Debug)] 101 +pub struct Update<'a> { 102 + pub done: usize, 103 + pub total: usize, 104 + /// Новые реплики по порядку времени; пусто, пока не готов кусок раньше. 105 + pub fresh: &'a [Segment], 106 +} 107 + 108 +/// Кусок для распознавания: где он в дорожке. Самого звука здесь нет. 109 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] 110 +pub struct Span { 111 + pub start_ms: u64, 112 + pub samples: usize, 113 +} 114 + 115 +/// Блок, которым дорожка проходит через нарезку. 116 +const BLOCK_MS: u64 = 10_000; 117 + 118 +/// Режет дорожку на куски по паузам, читая её блоками. 119 +pub fn spans_of(source: &dyn Samples, cfg: chunker::Config) -> io::Result<Vec<Span>> { 62 120 let mut c = Chunker::new(cfg); 63 − let mut out = c.push(samples_to_ms(samples.len()), samples); 64 − out.extend(c.flush()); 65 − out 121 + let block = ms_to_samples(BLOCK_MS); 122 + let mut spans = Vec::new(); 123 + let mut pos = 0; 124 + let span = |ch: chunker::Chunk| Span { 125 + start_ms: ch.start_ms, 126 + samples: ch.samples.len(), 127 + }; 128 + while pos < source.len() { 129 + let samples = source.read(pos, block)?; 130 + if samples.is_empty() { 131 + break; 132 + } 133 + pos += samples.len(); 134 + spans.extend(c.push(samples_to_ms(pos), &samples).into_iter().map(span)); 135 + } 136 + spans.extend(c.flush().into_iter().map(span)); 137 + Ok(spans) 66 138 } 67 139 68 140 /// Расшифровку отменил человек. 70 142 #[error("Расшифровка отменена")] 71 143 pub struct Cancelled; 72 144 73 −/// Расшифровывает дорожки. `progress(готово, всего)` зовётся после каждого куска. 145 +#[derive(Debug, thiserror::Error)] 146 +pub enum Error { 147 + #[error(transparent)] 148 + Cancelled(#[from] Cancelled), 149 + #[error("Не читается звук: {0}")] 150 + Read(#[from] io::Error), 151 +} 152 + 153 +/// Расшифровывает дорожки. `progress` зовётся в начале и после каждого куска. 74 154 /// По `cancel` неотправленные куски не отправляются, идущие обрываются. 75 155 pub async fn run( 76 − tracks: Vec<(Speaker, Vec<i16>)>, 156 + tracks: Vec<(Speaker, Arc<dyn Samples>)>, 77 157 stt: Arc<dyn Stt>, 78 158 cfg: Config, 79 159 cancel: &CancellationToken, 80 − progress: impl Fn(usize, usize), 81 −) -> Result<Outcome, Cancelled> { 82 − let jobs: Vec<(Speaker, Chunk)> = tracks 160 + progress: impl Fn(Update<'_>), 161 +) -> Result<Outcome, Error> { 162 + // Границы кусков — в отдельном потоке: это чтение всего звука с диска. 163 + let planned = tokio::task::spawn_blocking(move || { 164 + tracks 165 + .into_iter() 166 + .map(|(speaker, source)| { 167 + let spans = spans_of(source.as_ref(), cfg.chunker)?; 168 + Ok::<_, io::Error>((speaker, source, spans)) 169 + }) 170 + .collect::<Result<Vec<_>, _>>() 171 + }) 172 + .await 173 + .expect("нарезка упала")?; 174 + let mut jobs: Vec<(Speaker, Arc<dyn Samples>, Span)> = planned 83 175 .into_iter() 84 − .flat_map(|(speaker, samples)| { 85 − chunks_of(&samples, cfg.chunker) 176 + .flat_map(|(speaker, source, spans)| { 177 + spans 86 178 .into_iter() 87 − .map(move |c| (speaker.clone(), c)) 179 + .map(move |span| (speaker.clone(), source.clone(), span)) 88 180 }) 89 181 .collect(); 182 + // По времени, чтобы первыми распознавались и показывались ранние куски. 183 + jobs.sort_by_key(|(_, _, span)| span.start_ms); 90 184 let total = jobs.len(); 91 − progress(0, total); 185 + progress(Update { 186 + done: 0, 187 + total, 188 + fresh: &[], 189 + }); 92 190 93 191 let mut set = JoinSet::new(); 94 − let mut pending = jobs.into_iter(); 192 + let mut pending = jobs.into_iter().enumerate(); 95 193 let mut segments = Vec::new(); 194 + // Готовые куски, которые ждут, пока распознаются куски раньше них. 195 + let mut waiting: BTreeMap<usize, Vec<Segment>> = BTreeMap::new(); 196 + let mut next_out = 0; 96 197 let mut failed = 0; 97 198 let mut last_error = None; 98 199 let mut done = 0; 99 200 loop { 100 201 while set.len() < cfg.parallel.max(1) { 101 − let Some((speaker, chunk)) = pending.next() else { 202 + let Some((index, (speaker, source, span))) = pending.next() else { 102 203 break; 103 204 }; 104 205 let stt = stt.clone(); 105 206 let retry_after = cfg.retry_after; 106 207 set.spawn(async move { 107 − let wav = pcm::encode_wav(&chunk.samples); 108 − let mut result = stt.transcribe(wav.clone()).await; 109 − if result.is_err() { 110 − tokio::time::sleep(retry_after).await; 111 − result = stt.transcribe(wav).await; 112 − } 113 − (speaker, chunk.start_ms, result) 208 + let start = ms_to_samples(span.start_ms); 209 + let samples = tokio::task::spawn_blocking(move || source.read(start, span.samples)) 210 + .await 211 + .expect("чтение упало"); 212 + let result = match samples { 213 + Ok(samples) => { 214 + let wav = pcm::encode_wav(&samples); 215 + drop(samples); 216 + let mut result = stt.transcribe(wav.clone()).await; 217 + if result.is_err() { 218 + tokio::time::sleep(retry_after).await; 219 + result = stt.transcribe(wav).await; 220 + } 221 + result 222 + } 223 + Err(e) => Err(format!("не читается звук: {e}")), 224 + }; 225 + (index, speaker, span.start_ms, result) 114 226 }); 115 227 } 116 228 let joined = tokio::select! { 117 229 joined = set.join_next() => joined, 118 230 _ = cancel.cancelled() => { 119 231 set.abort_all(); 120 − return Err(Cancelled); 232 + return Err(Cancelled.into()); 121 233 } 122 234 }; 123 235 let Some(joined) = joined else { 124 236 break; Разница обрезана: показаны первые 225 строк. -
src-tauri/src/transcript.rs +22 −17
Показать разницуСкрыть разницу
1 1 //! Реплики расшифровки и то, как они ложатся в заметку. 2 2 //! 3 3 //! Говорящий — только дорожка звука: «Я» (микрофон), «Собеседники» (звук компьютера) 4 −//! или никто, если звук пришёл одним файлом. Узнавания людей по голосу нет и не 5 −//! будет: это биометрия (см. README, «Согласие на запись»). 4 +//! или «Спикер 1», если звук пришёл одним файлом и кто говорит, неизвестно. 5 +//! Узнавания людей по голосу нет: это биометрия (см. README, «Согласие на запись»). 6 6 7 7 use serde::{Deserialize, Serialize}; 8 8 16 16 } 17 17 18 18 impl Speaker { 19 − pub fn label(&self) -> Option<&'static str> { 19 + /// Подпись в расшифровке. Кто говорит в файле, неизвестно — «Спикер 1» 20 + /// (решение владельца 01.10.2026: не определился — нумеруем). 21 + pub fn label(&self) -> &'static str { 20 22 match self { 21 − Speaker::Me => Some("Я"), 22 − Speaker::Others => Some("Собеседники"), 23 − Speaker::Unknown => None, 23 + Speaker::Me => "Я", 24 + Speaker::Others => "Собеседники", 25 + Speaker::Unknown => "Спикер 1", 24 26 } 25 27 } 26 28 57 59 continue; 58 60 } 59 61 out.push_str("\n\n"); 60 − match s.speaker.label() { 61 − Some(who) => out.push_str(&format!( 62 − "**{} {}:** {}", 63 − format_timestamp(s.start_ms), 64 − who, 65 − s.text 66 − )), 67 − None => out.push_str(&format!("**{}** {}", format_timestamp(s.start_ms), s.text)), 68 − } 62 + out.push_str(&line(s)); 69 63 prev = Some(&s.speaker); 70 64 } 71 65 if segments.is_empty() { 75 69 out 76 70 } 77 71 72 +/// Реплика абзацем: «**00:03:21 Я:** текст». Так же её показывает интерфейс 73 +/// по ходу расшифровки. 74 +pub fn line(s: &Segment) -> String { 75 + format!( 76 + "**{} {}:** {}", 77 + format_timestamp(s.start_ms), 78 + s.speaker.label(), 79 + s.text 80 + ) 81 +} 82 + 78 83 pub fn format_timestamp(ms: u64) -> String { 79 84 let total = ms / 1000; 80 85 format!("{:02}:{:02}:{:02}", total / 3600, total / 60 % 60, total % 60) 110 115 } 111 116 112 117 #[test] 113 − fn single_file_has_no_speaker_names() { 118 + fn unknown_speaker_is_speaker_one() { 114 119 let md = to_markdown(&[ 115 120 seg(0, Speaker::Unknown, "Раз."), 116 121 seg(20_000, Speaker::Unknown, "Два."), 117 122 ]); 118 − assert_eq!(md, "## Расшифровка\n\n**00:00:00** Раз. Два.\n"); 123 + assert_eq!(md, "## Расшифровка\n\n**00:00:00 Спикер 1:** Раз. Два.\n"); 119 124 } 120 125 121 126 #[test] -
src/api.ts +5 −1
Показать разницуСкрыть разницу
118 118 119 119 export const events = { 120 120 recorder: (cb: (p: { meeting_id: string; event: RecorderEvent }) => void) => on("meet://recorder", cb), 121 − progress: (cb: (p: { meeting_id: string; done: number; total: number }) => void) => on("meet://progress", cb), 121 + /** Ход расшифровки: `lines` — новые реплики по порядку времени, абзацами как в заметке. */ 122 + progress: (cb: (p: { meeting_id: string; done: number; total: number; lines: string[] }) => void) => 123 + on("meet://progress", cb), 124 + /** Ответ ИИ пишется: `text` — новый кусок, его дописывают к показанному. */ 125 + answer: (cb: (p: { meeting_id: string; heading: string; text: string }) => void) => on("meet://answer", cb), 122 126 }; 123 127 124 128 export const files = { -
src/dev-mock.ts +14 −8
Показать разницуСкрыть разницу
181 181 const d = find(args.id); 182 182 if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках"; 183 183 if (!d.meeting.audio.length) throw "В этой встрече нет звука — запишите встречу или добавьте аудиофайл"; 184 − const total = 12; 184 + // Реплики приходят по ходу, как из ядра: по куску за раз, по порядку времени. 185 + const lines = TRANSCRIPT.replace(/^## .*\n+/, "").trim().split(/\n\s*\n/); 186 + const total = lines.length; 185 187 cancelled = false; 186 − for (let i = 0; i <= total; i++) { 188 + emit("meet://progress", { meeting_id: d.meeting.id, done: 0, total, lines: [] }); 189 + for (let i = 1; i <= total; i++) { 190 + await wait(350); 187 191 if (cancelled) throw "Расшифровка отменена"; 188 − emit("meet://progress", { meeting_id: d.meeting.id, done: i, total }); 189 − await wait(250); 192 + emit("meet://progress", { meeting_id: d.meeting.id, done: i, total, lines: [lines[i - 1]] }); 190 193 } 191 194 d.meeting.transcribed_at = new Date().toISOString(); 192 195 return { note: upsert(d, "Расшифровка", TRANSCRIPT), failed: 0, last_error: null } as T; 198 201 if (!d.note.trim()) throw "Заметка пустая — сначала расшифруйте аудио или напишите текст"; 199 202 if (c.kind === "custom" && !c.text.trim()) throw "Напишите, что сделать"; 200 203 cancelled = false; 201 − for (let i = 0; i < 8; i++) { 202 − await wait(200); 203 − if (cancelled) throw "Отменено"; 204 − } 205 204 const heading = c.kind === "custom" ? c.text.trim().replace(/^./, (x) => x.toUpperCase()) : { summary: "Конспект", tasks: "Задачи", review: "Ревью" }[c.kind]; 206 205 const block = 207 206 c.kind === "custom" 208 207 ? `## ${heading}\n\n(В просмотре без ядра ИИ не вызывается — здесь будет ответ на «${c.text.trim()}».)\n` 209 208 : ANSWERS[c.kind]; 209 + // Ответ пишется потоком: кусками по несколько знаков, как у модели. 210 + const answer = block.replace(/^## .*\n+/, ""); 211 + for (let i = 0; i < answer.length; i += 6) { 212 + await wait(25); 213 + if (cancelled) throw "Отменено"; 214 + emit("meet://answer", { meeting_id: d.meeting.id, heading, text: answer.slice(i, i + 6) }); 215 + } 210 216 return { note: upsert(d, heading, block), heading } as T; 211 217 } 212 218 case "recorder_status": -
src/main.ts +6 −5
Показать разницуСкрыть разницу
3 3 import { renderHome } from "./views/home"; 4 4 import { renderMeeting } from "./views/meeting"; 5 5 import { renderSettings } from "./views/settings"; 6 −import { refreshSide, startSide } from "./side"; 6 +import { refreshNav, setCrumb, startNav } from "./nav"; 7 7 import { errorText, files } from "./api"; 8 8 import { esc } from "./dom"; 9 9 import { Scope } from "./scope"; 17 17 scope = new Scope(); 18 18 const [section, id, tab] = location.hash.replace(/^#\/?/, "").split("/"); 19 19 main.scrollTop = 0; 20 − void refreshSide(); 20 + setCrumb(null); 21 + void refreshNav(); 21 22 try { 22 23 if (section === "m" && id) { 23 24 await renderMeeting(main, scope, decodeURIComponent(id), tab ? decodeURIComponent(tab) : null); 33 34 34 35 async function start(): Promise<void> { 35 36 await refreshRecording(); 36 − startSide(); 37 − // Запись началась, встала на паузу или закончилась — панель слева меняется. 38 − onChange(() => void refreshSide()); 37 + startNav(); 38 + // Запись началась, встала на паузу или закончилась — шапка и меню меняются. 39 + onChange(() => void refreshNav()); 39 40 // Аудиофайл, брошенный в окно с любого экрана, — новая запись. 40 41 await files.onDrop(async (paths) => { 41 42 for (const p of paths) await addAudio(p); -
src/nav.ts +236
Показать разницуСкрыть разницу
1 +// Шапка и меню — как на skillok.org: кнопка ☰ открывает панель слева поверх 2 +// экрана (разделы с иконками, список записей с поиском, «Настройки»), рядом — 3 +// крошки «Skillok Meet / где я». Разметка, классы и поведение панели сняты с 4 +// живого skillok.org (шапка `.hdr`, ящик `.drawer.hdr-nav`, меню `.mn`): 5 +// закрывается крестиком, кликом мимо, Esc и переходом на другой экран. 6 + 7 +import { api, elapsed, files, type Meeting } from "./api"; 8 +import { esc } from "./dom"; 9 +import { addAudio, startRecording, state } from "./state"; 10 + 11 +let meetings: Meeting[] = []; 12 +let captureSupported = true; 13 +/** Название открытой записи — для крошек. */ 14 +let crumb: string | null = null; 15 + 16 +const icon = (paths: string) => 17 + `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`; 18 + 19 +// Иконки — Lucide, как у skillok.org. 20 +const ICONS = { 21 + home: icon('<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), 22 + mic: icon('<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>'), 23 + file: icon('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>'), 24 + settings: icon('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>'), 25 + search: icon('<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>'), 26 + plus: icon('<path d="M5 12h14"/><path d="M12 5v14"/>'), 27 + record: icon('<rect x="4" y="4" width="16" height="16"/>'), 28 +}; 29 + 30 +function route(): { section: string; id: string | null } { 31 + const [section, id] = location.hash.replace(/^#\/?/, "").split("/"); 32 + return { section: section ?? "", id: section === "m" && id ? decodeURIComponent(id) : null }; 33 +} 34 + 35 +function link(href: string, label: string, ic: string, current: boolean, extra = ""): string { 36 + return `<a class="mn-link" href="${href}"${current ? ` aria-current="page"` : ""}> 37 + <span class="mn-icon">${ic}</span><span class="mn-label">${label}</span>${extra}</a>`; 38 +} 39 + 40 +function drawerBody(): string { 41 + const { section, id } = route(); 42 + const r = state.recording; 43 + const rows = meetings 44 + .map((m) => { 45 + const mark = m.recording 46 + ? `<span class="mn-count" data-strong>запись</span>` 47 + : m.audio.length && !m.transcribed_at 48 + ? `<span class="mn-dot" title="Не расшифровано"></span>` 49 + : ""; 50 + return `<a class="mn-link" data-rec-row href="#/m/${encodeURIComponent(m.id)}" title="${esc(m.title)}"${ 51 + m.id === id ? ` aria-current="page"` : "" 52 + }><span class="mn-label">${esc(m.title)}</span>${mark}</a>`; 53 + }) 54 + .join(""); 55 + const record = r 56 + ? link(`#/m/${encodeURIComponent(r.meeting_id)}`, r.paused ? "Запись на паузе" : "Идёт запись", ICONS.record, false, 57 + `<span class="mn-count" data-strong data-drawer-timer>${elapsed(r.started_at)}</span>`) 58 + : captureSupported 59 + ? `<button type="button" class="mn-link" data-nav-record><span class="mn-icon">${ICONS.mic}</span><span class="mn-label">Записать встречу</span></button>` 60 + : ""; 61 + return ` 62 + <div class="drawer-top"> 63 + <a class="drawer-home" href="#/">Skillok Meet</a> 64 + <button type="button" class="drawer-close" data-drawer-close aria-label="Закрыть меню">✕</button> 65 + </div> 66 + <aside class="mn"> 67 + <div class="mn-group"><nav class="mn-list" aria-label="Меню"> 68 + ${link("#/", "Главная", ICONS.home, section === "")} 69 + </nav></div> 70 + <div class="mn-rule"></div> 71 + <div class="mn-group"><nav class="mn-list" aria-label="Новая запись"> 72 + ${record} 73 + <button type="button" class="mn-link" data-nav-pick><span class="mn-icon">${ICONS.file}</span><span class="mn-label">Добавить аудиофайл</span></button> 74 + </nav></div> 75 + <div class="mn-rule"></div> 76 + <div class="mn-group drawer-repos"> 77 + <div class="mn-title drawer-group-row"> 78 + <span>Записи</span> 79 + <button type="button" class="drawer-icon" data-rec-search title="Найти запись…" aria-label="Найти запись…">${ICONS.search}</button> 80 + </div> 81 + <input class="drawer-filter" type="search" data-rec-filter placeholder="Найти запись…" aria-label="Найти запись…" hidden> 82 + ${rows || `<p class="drawer-empty">Записей пока нет.</p>`} 83 + </div> 84 + <div class="mn-rule"></div> 85 + <div class="mn-group"><nav class="mn-list" aria-label="Программа"> 86 + ${link("#/settings", "Настройки", ICONS.settings, section === "settings")} 87 + </nav></div> 88 + </aside>`; 89 +} 90 + 91 +function headerRight(): string { 92 + const r = state.recording; 93 + if (r) { 94 + return `<a class="hdr-live" href="#/m/${encodeURIComponent(r.meeting_id)}"> 95 + <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Запись"} 96 + <span class="mono-num" data-hdr-timer>${elapsed(r.started_at)}</span></a>`; 97 + } 98 + return captureSupported ? `<button class="ui-btn ui-btn-sm" data-nav-record>Записать</button>` : ""; 99 +} 100 + 101 +function crumbs(): string { 102 + const { section, id } = route(); 103 + const here = section === "settings" ? "Настройки" : id ? crumb ?? "" : ""; 104 + return `<a class="hdr-crumb${here ? "" : " hdr-crumb-strong"}" href="#/">Skillok Meet</a>${ 105 + here ? `<span class="hdr-slash">/</span><span class="hdr-crumb hdr-crumb-strong" aria-current="page">${esc(here)}</span>` : "" 106 + }`; 107 +} 108 + 109 +export function draw(): void { 110 + const body = document.getElementById("drawer"); 111 + const filter = body?.querySelector<HTMLInputElement>("[data-rec-filter]"); 112 + const query = filter && !filter.hidden ? filter.value : null; 113 + if (body) body.innerHTML = drawerBody(); 114 + document.getElementById("crumbs")!.innerHTML = crumbs(); 115 + document.getElementById("hdr-right")!.innerHTML = headerRight(); 116 + document.querySelectorAll("[data-nav-record]").forEach((b) => b.addEventListener("click", () => { 117 + closeAll(); 118 + void startRecording(); 119 + })); 120 + document.querySelector("[data-nav-pick]")?.addEventListener("click", async () => { 121 + closeAll(); 122 + await addAudio(await files.pickAudio()); 123 + }); 124 + // Поиск остаётся открытым с тем же текстом, если список перерисовался. 125 + if (query !== null) { 126 + const f = document.querySelector<HTMLInputElement>("[data-rec-filter]")!; 127 + f.hidden = false; 128 + f.value = query; 129 + applyFilter(query); 130 + } 131 +} 132 + 133 +function applyFilter(value: string): void { 134 + const q = value.trim().toLowerCase(); 135 + document.querySelectorAll<HTMLElement>("[data-rec-row]").forEach((row) => { 136 + row.hidden = q !== "" && !row.textContent!.toLowerCase().includes(q); 137 + }); 138 +} 139 + 140 +/** Название открытой записи для крошек; `null` — открыт не экран записи. */ 141 +export function setCrumb(title: string | null): void { 142 + crumb = title; 143 + document.getElementById("crumbs")!.innerHTML = crumbs(); 144 +} 145 + 146 +/** Перечитать записи и перерисовать шапку и меню: после записи, расшифровки, правки. */ 147 +export async function refreshNav(): Promise<void> { 148 + try { 149 + const [list, view] = await Promise.all([api.listMeetings(), api.getSettings()]); 150 + meetings = list; 151 + captureSupported = view.capture_supported; 152 + } catch { 153 + // Меню — не главное: список останется прежним, ошибку покажет экран. 154 + } 155 + draw(); 156 +} 157 + 158 +/** Список записей, который уже знает меню, — главной не надо читать его заново. */ 159 +export function knownMeetings(): Meeting[] { 160 + return meetings; 161 +} 162 + 163 +// ── Поведение ящика — копия скрипта skillok.org ──────────────────────────── 164 + 165 +const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; 166 + 167 +function close(drawer: HTMLDetailsElement): void { 168 + if (!drawer.open || drawer.classList.contains("is-closing")) return; 169 + if (reduce) { 170 + drawer.open = false; 171 + return; 172 + } 173 + drawer.classList.add("is-closing"); 174 + const body = drawer.querySelector(":scope > .drawer-body"); 175 + let done = false; 176 + const finish = () => { 177 + if (done) return; 178 + done = true; 179 + drawer.classList.remove("is-closing"); 180 + drawer.open = false; 181 + }; 182 + body?.addEventListener("animationend", finish, { once: true }); 183 + window.setTimeout(finish, 260); 184 +} 185 + 186 +function closeAll(): void { 187 + document.querySelectorAll<HTMLDetailsElement>(".drawer[open]").forEach(close); 188 +} 189 + 190 +export function startNav(): void { 191 + document.addEventListener("click", (event) => { 192 + const target = event.target as HTMLElement; 193 + const closer = target.closest("[data-drawer-close]"); 194 + if (closer) { 195 + const own = closer.closest("details"); 196 + if (own) close(own); 197 + return; 198 + } 199 + if (target.closest("[data-rec-search]")) { 200 + const f = document.querySelector<HTMLInputElement>("[data-rec-filter]"); 201 + if (f) { 202 + f.hidden = !f.hidden; 203 + if (!f.hidden) f.focus(); 204 + else applyFilter(""); 205 + } 206 + return; 207 + } 208 + const drawer = target.closest<HTMLDetailsElement>(".drawer"); 209 + if (!drawer) { 210 + closeAll(); 211 + return; 212 + } 213 + const summary = target.closest("summary"); 214 + if (summary && summary.parentNode === drawer && drawer.open) { 215 + event.preventDefault(); 216 + close(drawer); 217 + return; 218 + } 219 + if (target === drawer) close(drawer); 220 + }); 221 + document.addEventListener("input", (event) => { 222 + const t = event.target as HTMLElement; 223 + if (t.matches("[data-rec-filter]")) applyFilter((t as HTMLInputElement).value); 224 + }); 225 + document.addEventListener("keydown", (event) => { 226 + if (event.key === "Escape") closeAll(); 227 + }); 228 + // Переход на другой экран — ящик закрывается, как у ссылки на сайте. 229 + window.addEventListener("hashchange", closeAll); 230 + window.setInterval(() => { 231 + if (!state.recording) return; 232 + const t = elapsed(state.recording.started_at); 233 + document.querySelectorAll("[data-hdr-timer], [data-drawer-timer]").forEach((el) => (el.textContent = t)); 234 + }, 1000); 235 +} 236 + -
src/recording.ts +13 −8
Показать разницуСкрыть разницу
1 1 // Запись: панель с таймером, уровнями и кнопками — одна на главной и на 2 −// экране записи. Метка «идёт запись» с любого экрана — в боковой панели (side.ts). 2 +// экране записи. Метка «идёт запись» с любого экрана — в шапке (nav.ts). 3 3 4 4 import { api, elapsed, events } from "./api"; 5 5 import { esc } from "./dom"; 9 9 export function recordingPanel(warning: string): string { 10 10 const r = state.recording; 11 11 if (!r) return ""; 12 − const meter = (label: string, key: string) => ` 13 − <div class="meter"><span class="meter-label small">${label}</span> 14 − <span class="meter-bar"><span class="meter-fill" data-level="${key}"></span></span></div>`; 15 12 return ` 16 13 <div class="rec" data-rec> 17 14 <p class="rec-state"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${ 19 16 }</p> 20 17 ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся: ${esc(r.system_error)}</p>` : ""} 21 18 <p class="rec-timer mono-num" data-timer>${elapsed(r.started_at)}</p> 22 − <div class="meters">${meter("Вы", "me")}${r.system ? meter("Собеседники", "others") : ""}</div> 19 + <div class="meter" title="Громкость того, что пишется: ваш микрофон и звук компьютера вместе"> 20 + <span class="meter-bar"><span class="meter-fill" data-level></span></span> 21 + </div> 23 22 <div class="actions"> 24 23 <button class="ui-btn" data-rec-stop>Остановить</button> 25 24 <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button> 55 54 } 56 55 }); 57 56 root.querySelector("[data-rec-warning]")?.addEventListener("click", () => copy(warning, "Предупреждение скопировано — вставьте его в чат встречи")); 57 + // Один общий уровень: громче из дорожек за последние полсекунды. Человеку 58 + // важно одно — запись идёт и звук есть; делить его на «вы» и «собеседники» 59 + // незачем (решение владельца 01.10.2026). 60 + const last: Record<string, { dbfs: number; at: number }> = {}; 58 61 scope.on( 59 62 events.recorder(({ event }) => { 60 63 if (event.kind === "level") { 61 − const key = event.data.speaker.kind === "me" ? "me" : "others"; 62 − const fill = root.querySelector<HTMLElement>(`[data-level="${key}"]`); 63 − if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`; 64 + const now = performance.now(); 65 + last[event.data.speaker.kind] = { dbfs: event.data.dbfs, at: now }; 66 + const dbfs = Math.max(-90, ...Object.values(last).filter((l) => now - l.at < 500).map((l) => l.dbfs)); 67 + const fill = root.querySelector<HTMLElement>("[data-level]"); 68 + if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((dbfs + 60) / 55) * 100))}%`; 64 69 } else { 65 70 toast(`Дорожка «${event.data.speaker.kind === "me" ? "вы" : "собеседники"}» перестала писаться: ${event.data.error}`); 66 71 } -
src/side.ts −81
Показать разницуСкрыть разницу
1 −// Боковая панель, как библиотека у MacWhisper: «Записать» сверху, все записи 2 −// списком, «Настройки» внизу. Видна с любого экрана; пока идёт запись, вместо 3 −// кнопки — метка с таймером. 4 − 5 −import { api, elapsed, files, formatDate, formatDuration, meetingDuration, type Meeting } from "./api"; 6 −import { esc } from "./dom"; 7 −import { addAudio, startRecording, state } from "./state"; 8 − 9 −const side = () => document.getElementById("side")!; 10 −let meetings: Meeting[] = []; 11 −let captureSupported = true; 12 − 13 −function currentId(): string | null { 14 − const [section, id] = location.hash.replace(/^#\/?/, "").split("/"); 15 − return section === "m" && id ? decodeURIComponent(id) : null; 16 −} 17 − 18 −function item(m: Meeting, current: string | null): string { 19 − const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · "); 20 − const mark = m.recording 21 − ? `<span class="side-mark is-live"><span class="dot dot-live"></span>запись</span>` 22 − : m.audio.length && !m.transcribed_at 23 − ? `<span class="side-mark">не расшифровано</span>` 24 − : ""; 25 − return ` 26 − <a class="side-item" href="#/m/${encodeURIComponent(m.id)}"${m.id === current ? ` aria-current="page"` : ""}> 27 − <span class="side-title">${esc(m.title)}</span> 28 − <span class="side-meta">${esc(meta)}${mark}</span> 29 − </a>`; 30 −} 31 − 32 −function draw(): void { 33 − const r = state.recording; 34 − const current = currentId(); 35 − const top = r 36 − ? `<a class="side-live" href="#/m/${encodeURIComponent(r.meeting_id)}"> 37 − <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Идёт запись"} 38 − <span class="mono-num" data-side-timer>${elapsed(r.started_at)}</span> 39 − </a>` 40 − : captureSupported 41 − ? `<button class="ui-btn side-rec" data-side-record>Записать</button>` 42 − : ""; 43 − side().innerHTML = ` 44 − <a class="brand" href="#/"> 45 − <svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true"> 46 − <rect width="1024" height="1024" fill="var(--color-accent)"/> 47 − <path fill="var(--background)" d="M232 792V232h136l144 232 144-232h136v560H672V440L540 648h-56L352 440v352z"/> 48 − </svg> 49 − Skillok Meet 50 − </a> 51 − <div class="side-actions"> 52 − ${top} 53 − <button class="link-btn" data-side-pick>Добавить аудиофайл</button> 54 − </div> 55 − <p class="side-head">Записи${meetings.length ? ` <span class="ui-nav-count">${meetings.length}</span>` : ""}</p> 56 − <nav class="side-list"> 57 − ${meetings.length ? meetings.map((m) => item(m, current)).join("") : `<p class="side-empty">Здесь появятся ваши записи.</p>`} 58 − </nav> 59 − <a class="side-foot" href="#/settings"${location.hash.startsWith("#/settings") ? ` aria-current="page"` : ""}>Настройки</a>`; 60 − side().querySelector("[data-side-record]")?.addEventListener("click", startRecording); 61 − side().querySelector("[data-side-pick]")?.addEventListener("click", async () => addAudio(await files.pickAudio())); 62 −} 63 − 64 −/** Перечитать записи и перерисовать панель: после записи, расшифровки, правки. */ 65 −export async function refreshSide(): Promise<void> { 66 − try { 67 − const [list, view] = await Promise.all([api.listMeetings(), api.getSettings()]); 68 − meetings = list; 69 − captureSupported = view.capture_supported; 70 − } catch { 71 − // Панель — не главное: список останется прежним, ошибку покажет экран. 72 − } 73 − draw(); 74 −} 75 − 76 −export function startSide(): void { 77 − window.setInterval(() => { 78 − const t = document.querySelector("[data-side-timer]"); 79 − if (t && state.recording) t.textContent = elapsed(state.recording.started_at); 80 − }, 1000); 81 −} -
src/styles/app.css +138 −55
Показать разницуСкрыть разницу
1 −/* Раскладка как у MacWhisper: библиотека записей слева, открытая запись справа. 2 − Стиль — skillok.org: бумага, чернила, терракота, квадратные углы. Тема одна, светлая. */ 1 +/* Раскладка: шапка и меню как у skillok.org (кнопка ☰ открывает панель слева), 2 + под ней — экран. Правила шапки, ящика и меню сняты с живого skillok.org 3 + (`.hdr`, `.drawer.hdr-nav`, `.mn`) и пересчитаны на токены программы. 4 + Стиль — бумага, чернила, терракота, квадратные углы. Тема одна, светлая. */ 3 5 4 6 html, body { height: 100%; } 5 −.shell { display: grid; grid-template-columns: 248px 1fr; height: 100%; } 6 − 7 −/* ── Боковая панель ── */ 8 −.side { 9 − display: flex; flex-direction: column; min-height: 0; 10 − background: var(--color-bg-secondary); 11 − border-right: 1px solid var(--color-border); 12 −} 13 −.brand { 14 − display: flex; align-items: center; gap: 10px; flex: none; 15 − height: 52px; padding: 0 16px; 16 − color: var(--color-text-primary); font-weight: 700; font-size: var(--text-lg); 17 −} 18 −.brand:hover { text-decoration: none; } 19 −.brand-mark { width: 22px; height: 22px; } 20 −.side-actions { display: grid; gap: 6px; justify-items: start; padding: 4px 16px 16px; } 21 −.side-actions .side-rec { justify-self: stretch; min-height: 40px; } 22 −.side-live { 23 − display: flex; align-items: center; gap: 8px; justify-self: stretch; 24 − min-height: 40px; padding: 0 12px; 25 − font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text); 26 − border: 2px solid var(--color-accent); background: var(--color-bg-elevated); 27 −} 28 −.side-live:hover { text-decoration: none; background: var(--background); } 29 −.side-live .mono-num { margin-left: auto; } 30 −.side-head { 31 − display: flex; align-items: center; gap: 8px; padding: 8px 16px 6px; 32 − font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; 33 − color: var(--color-text-secondary); border-top: 1px solid var(--color-border); 34 −} 35 −.side-list { flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 8px; } 36 −.side-item { 37 − display: grid; gap: 2px; padding: 8px 16px; 38 − color: var(--color-text-primary); box-shadow: inset 2px 0 0 transparent; 39 −} 40 −.side-item:hover { background: var(--color-bg-hover); text-decoration: none; } 41 −.side-item[aria-current] { background: var(--color-bg-elevated); box-shadow: inset 2px 0 0 var(--color-accent); } 42 −.side-title { font-size: var(--text-md); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 43 −.side-meta { display: flex; flex-wrap: wrap; gap: 0 8px; font-size: var(--text-xs); color: var(--color-text-secondary); } 44 −.side-mark { display: inline-flex; align-items: center; gap: 6px; color: var(--color-accent-text); } 45 −.side-empty { padding: 8px 16px; font-size: var(--text-sm); color: var(--color-text-secondary); } 46 −.side-foot { 47 − flex: none; display: flex; align-items: center; height: 44px; padding: 0 16px; 48 − color: var(--color-text-primary); font-size: var(--text-md); 49 − border-top: 1px solid var(--color-border); 50 −} 51 −.side-foot:hover { background: var(--color-bg-hover); text-decoration: none; } 52 −.side-foot[aria-current] { box-shadow: inset 2px 0 0 var(--color-accent); background: var(--color-bg-elevated); } 53 − 54 −.main { min-width: 0; min-height: 0; overflow-y: auto; padding: 32px 32px 80px; background: var(--background); } 7 +body { display: flex; flex-direction: column; } 8 + 9 +/* ── Шапка ── */ 10 +.hdr { flex: none; position: relative; z-index: 100; background: var(--background); border-bottom: 1px solid var(--color-border); } 11 +.hdr-row { display: flex; align-items: center; height: 64px; padding: 0 16px; gap: 8px; } 12 +.hdr-spacer { flex: 1; } 13 +.hdr-crumbs { display: flex; align-items: center; min-width: 0; flex-shrink: 1; font-size: var(--text-md); } 14 +.hdr-slash { flex-shrink: 0; margin: 0 2px; color: var(--color-text-secondary); } 15 +.hdr-crumb { 16 + flex-shrink: 0; padding: 4px 8px; color: var(--color-text-primary); text-decoration: none; 17 + transition: background-color 0.12s; 18 +} 19 +a.hdr-crumb:hover { background: var(--color-bg-hover); text-decoration: none; } 20 +.hdr-crumb-strong { font-weight: 700; min-width: 0; flex-shrink: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 21 +.hdr-right { display: flex; align-items: center; gap: 8px; flex: none; } 22 +.hdr-live { 23 + display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px; 24 + font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text); border: 1px solid var(--color-accent); 25 +} 26 +.hdr-live:hover { background: var(--color-bg-hover); text-decoration: none; } 27 + 28 +/* Кнопка ☰ */ 29 +.drawer { position: relative; } 30 +.drawer > summary { list-style: none; cursor: pointer; } 31 +.drawer > summary::-webkit-details-marker { display: none; } 32 +.hdr-burger { 33 + display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; 34 + box-sizing: border-box; height: 32px; min-width: 32px; padding: 0; 35 + color: var(--color-text-secondary); border: 1px solid var(--color-border); 36 + transition: background-color 0.12s, color 0.12s; 37 +} 38 +.hdr-burger:hover { color: var(--color-text-primary); background: var(--color-bg-hover); } 39 +.hdr-burger-bar { display: block; width: 16px; height: 2px; background: currentColor; } 40 + 41 +/* Ящик слева поверх экрана */ 42 +.drawer.hdr-nav > .drawer-body { 43 + position: fixed; z-index: 130; top: 0; left: 0; bottom: 0; width: min(320px, 90vw); 44 + overflow-y: auto; padding: 16px; 45 + background: var(--background); color: var(--color-text-primary); 46 + box-shadow: rgba(10, 10, 10, 0.2) 0 8px 24px; 47 + font-size: var(--text-md); line-height: 20px; 48 +} 49 +.drawer.hdr-nav[open]::before { content: ""; position: fixed; inset: 0; z-index: 129; background: rgba(10, 10, 10, 0.5); } 50 +.drawer.hdr-nav[open] > .drawer-body { animation: drawer-slide 0.25s cubic-bezier(0.33, 1, 0.68, 1); } 51 +.drawer.hdr-nav[open]::before { animation: drawer-fade 0.25s ease-out; } 52 +.drawer.hdr-nav.is-closing > .drawer-body { animation: drawer-slide-out 0.2s cubic-bezier(0.32, 0, 0.67, 0) forwards; } 53 +.drawer.hdr-nav.is-closing::before { animation: drawer-fade-out 0.2s ease-in forwards; } 54 +@keyframes drawer-slide { from { transform: translateX(-100%); } to { transform: none; } } 55 +@keyframes drawer-slide-out { from { transform: none; } to { transform: translateX(-100%); } } 56 +@keyframes drawer-fade { from { opacity: 0; } to { opacity: 1; } } 57 +@keyframes drawer-fade-out { from { opacity: 1; } to { opacity: 0; } } 58 +@media (prefers-reduced-motion: reduce) { 59 + .drawer.hdr-nav[open] > .drawer-body, .drawer.hdr-nav[open]::before, 60 + .drawer.hdr-nav.is-closing > .drawer-body, .drawer.hdr-nav.is-closing::before { animation: none; } 61 +} 62 +.drawer-top { display: flex; align-items: center; justify-content: space-between; padding: 0 0 16px; } 63 +.drawer-home { padding: 0 14px; font-size: var(--text-lg); font-weight: 700; color: var(--color-text-primary); } 64 +.drawer-home:hover { text-decoration: none; } 65 +.drawer-close, .drawer-icon { 66 + display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; 67 + background: none; border: 0; color: var(--color-text-secondary); 68 + font-family: var(--font-ui); font-size: var(--text-xl); line-height: 1; cursor: pointer; 69 + transition: background-color 0.12s, color 0.12s; 70 +} 71 +.drawer-icon { width: 24px; height: 24px; } 72 +.drawer-close:hover, .drawer-icon:hover { color: var(--color-text-primary); background: var(--color-bg-hover); } 73 +.drawer-group-row { display: flex; align-items: center; justify-content: space-between; } 74 +.drawer-group-row > span { flex: 1; } 75 +.drawer-empty { margin: 0; padding: 4px 14px 6px; font-size: var(--text-xs); color: var(--color-text-secondary); } 76 +.drawer-filter { 77 + display: block; width: 100%; height: 32px; margin: 4px 0 8px; padding: 0 8px; 78 + font-family: var(--font-ui); font-size: var(--text-md); color: var(--color-text-primary); 79 + background: var(--background); border: 1px solid var(--color-border); outline: none; 80 +} 81 +.drawer-filter:focus { border-color: var(--color-accent); box-shadow: rgba(166, 83, 60, 0.25) 0 0 0 3px; } 82 +.drawer-filter[hidden] { display: none; } 83 + 84 +/* Меню */ 85 +.mn { width: 100%; } 86 +.mn-group + .mn-group { margin-top: 16px; } 87 +.mn-title { margin: 0 0 4px; padding: 0 14px; font-size: var(--text-xs); font-weight: 700; color: var(--color-text-secondary); } 88 +.mn-list { display: flex; flex-direction: column; } 89 +.mn-link { 90 + display: flex; align-items: center; gap: 10px; padding: 8px 14px; 91 + font-size: var(--text-sm); color: var(--color-text-primary); text-decoration: none; 92 + border-left: 3px solid transparent; transition: background 0.1s, color 0.1s; 93 +} 94 +.mn-link:hover { background: var(--color-bg-hover); text-decoration: none; } 95 +.mn-link[aria-current] { border-left-color: var(--color-accent); background: var(--color-bg-secondary); font-weight: 700; } 96 +.mn-link[hidden] { display: none; } 97 +button.mn-link { width: 100%; background: none; border-top: 0; border-right: 0; border-bottom: 0; font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; } 98 +.mn-icon { flex-shrink: 0; display: inline-flex; color: var(--color-text-secondary); } 99 +.mn-link[aria-current] .mn-icon { color: var(--color-accent); } 100 +.mn-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 101 +.mn-count { 102 + flex-shrink: 0; min-width: 18px; padding: 0 6px; line-height: 18px; text-align: center; 103 + font-size: var(--text-xs); color: var(--color-text-secondary); background: var(--color-bg-secondary); 104 +} 105 +.mn-count[data-strong] { color: var(--color-on-accent); background: var(--color-accent); font-weight: 700; } 106 +.mn-dot { flex-shrink: 0; width: 6px; height: 6px; background: var(--color-accent); } 107 +.mn-rule { height: 1px; margin: 8px 14px; background: var(--color-border); } 108 + 109 +.main { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 32px 32px 80px; background: var(--background); } 55 110 .home, .page { max-width: 720px; margin: 0 auto; display: grid; gap: 20px; } 56 111 .meeting { max-width: 860px; margin: 0 auto; display: grid; gap: 20px; } 57 112 103 158 .hero.drop { padding: 24px 32px; gap: 4px; border-style: dashed; border-color: var(--color-border-light); background: transparent; } 104 159 .hero.is-live { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); } 105 160 161 +/* ── Список записей на главной ── */ 162 +.records { display: grid; gap: 12px; } 163 +.records-head { display: flex; align-items: center; justify-content: space-between; } 164 +.records h2 { font-size: var(--text-lg); } 165 +.record-list { border: 1px solid var(--color-border); background: var(--color-bg-elevated); } 166 +.record { 167 + display: grid; grid-template-columns: 1fr auto; grid-template-areas: "title status" "meta status"; 168 + gap: 2px 16px; align-items: center; padding: 12px 16px; 169 + color: var(--color-text-primary); border-top: 1px solid var(--color-border); 170 +} 171 +.record:first-child { border-top: 0; } 172 +.record[hidden] { display: none; } 173 +.record:hover { background: var(--color-bg-hover); text-decoration: none; } 174 +.record-title { grid-area: title; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 175 +.record-meta { grid-area: meta; } 176 +.status { grid-area: status; font-size: var(--text-sm); color: var(--color-text-secondary); text-align: right; } 177 +.status-todo, .status-live { color: var(--color-accent-text); } 178 +.status-live { font-weight: 700; } 179 + 180 +/* ── Идёт работа: расшифровка растёт на глазах ── */ 181 +.live { display: grid; gap: 10px; } 182 +.live-head { display: flex; align-items: center; gap: 16px; } 183 +.live-title { flex: 1; display: flex; align-items: center; gap: 8px; font-size: var(--text-lg); font-weight: 700; } 184 +.live-title .spinner { margin-right: 4px; } 185 +.live-pct { color: var(--color-accent-text); } 186 +.live .pane { border-top: 1px solid var(--color-border); margin-top: 6px; } 187 +.live-wait { padding: 4px 0; } 188 + 106 189 /* ── Запись идёт ── */ 107 190 .rec { display: grid; gap: 14px; justify-items: start; width: 100%; } 108 191 .rec-state { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); } 109 192 .rec-timer { font-size: var(--text-6xl); font-weight: 700; line-height: 1; } 110 −.meters { display: grid; gap: 6px; width: min(420px, 100%); } 111 −.meter { display: grid; grid-template-columns: 100px 1fr; align-items: center; gap: 12px; } 193 +.meter { width: min(420px, 100%); } 194 +.meter .meter-bar { display: block; } 112 195 .meter-bar { height: 8px; background: var(--color-bg-secondary); overflow: hidden; } 113 196 .meter-fill { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.18s linear; } 114 197 158 241 .pane-empty > p { max-width: 60ch; } 159 242 .pane-empty.ask .input { width: 100%; max-width: 560px; } 160 243 .working { display: flex; align-items: center; font-size: var(--text-lg); } 244 +.pane-tools .working { font-size: var(--text-sm); } 161 245 .pane-edit { display: grid; gap: 12px; padding: 16px 20px; } 162 246 .section-editor { 163 247 width: 100%; min-height: 50vh; padding: 12px 14px; 222 306 .toast.is-error { background: var(--color-error); color: #fff; } 223 307 224 308 @media (max-width: 900px) { 225 − .shell { grid-template-columns: 208px 1fr; } 226 309 .main { padding: 24px 20px 64px; } 227 310 .bar { top: -24px; margin: -24px -20px 0; padding: 16px 20px 10px; } 228 311 .hero, .card { padding: 24px 20px; } -
src/views/home.ts +45 −3
Показать разницуСкрыть разницу
1 −// Главная: записать встречу или добавить файл. Список записей — в боковой панели. 1 +// Главная: записать встречу или добавить файл, ниже — все записи с поиском, 2 +// как список репозиториев на главной skillok.org. 2 3 3 −import { api, files, type SettingsView } from "../api"; 4 +import { api, files, formatDate, formatDuration, meetingDuration, type Meeting, type SettingsView } from "../api"; 5 +import { esc } from "../dom"; 4 6 import { bindRecording, recordingPanel } from "../recording"; 5 7 import type { Scope } from "../scope"; 6 8 import { addAudio, onChange, startRecording, state } from "../state"; 31 33 </section>`; 32 34 } 33 35 36 +function status(m: Meeting): string { 37 + if (m.recording) return `<span class="status status-live">Идёт запись</span>`; 38 + if (!m.audio.length) return `<span class="status">Нет звука</span>`; 39 + if (!m.transcribed_at) return `<span class="status status-todo">Не расшифровано</span>`; 40 + return `<span class="status">Расшифровано</span>`; 41 +} 42 + 43 +function list(meetings: Meeting[]): string { 44 + if (!meetings.length) return ""; 45 + return ` 46 + <section class="records"> 47 + <div class="records-head"><h2>Записи</h2></div> 48 + <input class="input records-filter" type="search" data-filter placeholder="Найти запись…" aria-label="Найти запись…"> 49 + <div class="record-list"> 50 + ${meetings 51 + .map((m) => { 52 + const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · "); 53 + return ` 54 + <a class="record" data-row href="#/m/${encodeURIComponent(m.id)}"> 55 + <span class="record-title">${esc(m.title)}</span> 56 + <span class="record-meta small muted">${esc(meta)}</span> 57 + ${status(m)} 58 + </a>`; 59 + }) 60 + .join("")} 61 + </div> 62 + <p class="small muted" data-nothing hidden>Ничего не нашлось.</p> 63 + </section>`; 64 +} 65 + 34 66 export async function renderHome(root: HTMLElement, scope: Scope): Promise<void> { 35 − const view = await api.getSettings(); 67 + const [view, meetings] = await Promise.all([api.getSettings(), api.listMeetings()]); 36 68 if (!scope.alive) return; 37 69 38 70 const draw = () => { 44 76 ? "" 45 77 : `<p class="notice">Чтобы расшифровывать записи, подключите ИИ — <a href="#/settings">это минута</a>.</p>` 46 78 } 79 + ${list(meetings)} 47 80 </div>`; 81 + root.querySelector<HTMLInputElement>("[data-filter]")?.addEventListener("input", (e) => { 82 + const q = (e.target as HTMLInputElement).value.trim().toLowerCase(); 83 + let shown = 0; 84 + root.querySelectorAll<HTMLElement>("[data-row]").forEach((row) => { 85 + row.hidden = q !== "" && !row.textContent!.toLowerCase().includes(q); 86 + if (!row.hidden) shown++; 87 + }); 88 + root.querySelector<HTMLElement>("[data-nothing]")!.hidden = shown > 0; 89 + }); 48 90 root.querySelector("[data-record]")?.addEventListener("click", startRecording); 49 91 root.querySelectorAll("[data-pick]").forEach((b) => b.addEventListener("click", async () => addAudio(await files.pickAudio()))); 50 92 // После «Остановить» переход к записи делает подписка ниже. -
src/views/meeting.ts +57 −23
Показать разницуСкрыть разницу
13 13 import { plain, render, sectionBody, sections, toggleTask, upsertSection } from "../markdown"; 14 14 import { bindRecording, recordingPanel } from "../recording"; 15 15 import type { Scope } from "../scope"; 16 −import { refreshSide } from "../side"; 16 +import { refreshNav, setCrumb } from "../nav"; 17 17 import { copy, onChange, state, toast } from "../state"; 18 18 19 19 const TRANSCRIPT = "Расшифровка"; 74 74 */ 75 75 function transcriptView(body: string): string { 76 76 const paras = body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean); 77 + const rows = transcriptRows(paras); 78 + return rows === null ? `<div class="md">${render(body)}</div>` : `<div class="tr">${rows}</div>`; 79 +} 80 + 81 +/** Реплики строками таблицы; `null` — если хоть одна не похожа на реплику. */ 82 +function transcriptRows(paras: string[]): string | null { 77 83 const rows = paras.map((p) => LINE.exec(p)); 78 − if (!rows.length || rows.some((r) => !r)) return `<div class="md">${render(body)}</div>`; 79 − return ` 80 − <div class="tr"> 81 − ${rows 82 − .map((r) => { 83 − const [, time, who, text] = r!; 84 − return ` 84 + if (!rows.length || rows.some((r) => !r)) return paras.length ? null : ""; 85 + return rows 86 + .map((r) => { 87 + const [, time, who, text] = r!; 88 + return ` 85 89 <div class="tr-row"> 86 90 <span class="tr-time mono-num">${esc(time)}</span> 87 91 <div class="tr-text"> 88 − ${who ? `<span class="tr-who${who === "Вы" ? " is-me" : ""}">${esc(who)}</span>` : ""} 92 + ${who ? `<span class="tr-who${who === "Я" ? " is-me" : ""}">${esc(who)}</span>` : ""} 89 93 <p>${esc(text)}</p> 90 94 </div> 91 95 </div>`; 92 − }) 93 − .join("")} 94 − </div>`; 96 + }) 97 + .join(""); 95 98 } 96 99 97 100 export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise<void> { 104 107 // Что сейчас идёт над записью: расшифровка или ответ ИИ (с какой вкладки). 105 108 let job: { kind: "transcribe"; done: number; total: number } | { kind: "command"; tab: string } | null = null; 106 109 let jobError: string | null = null; 110 + // Что уже пришло по ходу работы: реплики расшифровки и текст ответа ИИ. 111 + let liveLines: string[] = []; 112 + let liveAnswer = ""; 107 113 let tab = wantTab ?? TRANSCRIPT; 108 114 let editing = false; 109 115 134 140 if (job?.kind === "transcribe") { 135 141 const pct = job.total ? Math.round((job.done / job.total) * 100) : 0; 136 142 return ` 137 − <section class="card"> 138 − <h2>Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}</h2> 139 − <p class="big-pct mono-num" data-pct>${pct}%</p> 143 + <section class="live"> 144 + <div class="live-head"> 145 + <p class="live-title"><span class="spinner"></span>Расшифровываю${dur ? ` ${esc(dur)} звука` : ""} 146 + <span class="mono-num live-pct" data-pct>${pct}%</span></p> 147 + <button class="link-btn" data-cancel>Отменить</button> 148 + </div> 140 149 <div class="progress"><span style="width:${pct}%"></span></div> 141 − <p class="small muted" data-progress-note>${progressNote(job.done, job.total)}</p> 142 − <div class="actions"><button class="link-btn" data-cancel>Отменить</button></div> 150 + <p class="xs muted" data-progress-note>${progressNote(job.done, job.total)}</p> 151 + <div class="pane"><div class="pane-body"><div class="tr" data-live>${transcriptRows(liveLines) ?? ""}</div> 152 + <p class="live-wait small muted" data-live-wait ${liveLines.length ? "hidden" : ""}>Текст появится здесь, как только распознается первый кусок.</p></div></div> 143 153 </section>`; 144 154 } 145 155 if (!hasKey) { 180 190 if (busyHere) { 181 191 const preset = PRESETS.find((p) => p.heading === tab); 182 192 return ` 183 − <div class="pane-empty"> 184 − <p class="working"><span class="spinner"></span>${esc(preset?.working ?? "ИИ отвечает…")}</p> 193 + <div class="pane-tools"> 194 + <p class="working small"><span class="spinner"></span>${esc(preset?.working ?? "ИИ отвечает…")}</p> 195 + <span class="spacer"></span> 185 196 <button class="link-btn" data-cancel>Отменить</button> 186 − </div>`; 197 + </div> 198 + <div class="pane-body"><div class="md" data-answer>${liveAnswer ? render(liveAnswer) : `<p class="muted small">Читаю расшифровку…</p>`}</div></div>`; 187 199 } 188 200 189 201 if (tab === CUSTOM_TAB) { 270 282 </section>`; 271 283 272 284 const draw = () => { 285 + setCrumb(d.meeting.title); 273 286 const step = stepOf(d); 274 287 if (!tabs().includes(tab)) tab = TRANSCRIPT; 275 288 const meta = [formatDate(d.meeting.created_at), formatDuration(meetingDuration(d.meeting))].filter(Boolean).join(" · "); 305 318 if (job) return; 306 319 job = { kind: "transcribe", done: 0, total: 0 }; 307 320 jobError = null; 321 + liveLines = []; 308 322 draw(); 309 323 try { 310 324 const r = await api.transcribe(id); 311 325 d.note = r.note; 312 326 d.meeting = (await api.getMeeting(id)).meeting; 313 327 tab = TRANSCRIPT; 314 − void refreshSide(); 328 + void refreshNav(); 315 329 if (r.failed) toast(`Не распозналось кусков: ${r.failed}. ${r.last_error ?? ""}`); 316 330 } catch (e) { 317 331 jobError = errorText(e) === "Расшифровка отменена" ? null : errorText(e); 324 338 if (job) return; 325 339 job = { kind: "command", tab: forTab }; 326 340 jobError = null; 341 + liveAnswer = ""; 327 342 tab = forTab; 328 343 draw(); 329 344 try { 361 376 title.addEventListener("change", async () => { 362 377 try { 363 378 d.meeting = await api.renameMeeting(id, title.value); 364 − void refreshSide(); 379 + void refreshNav(); 365 380 } catch (e) { 366 381 title.value = d.meeting.title; 367 382 toast(e); 477 492 draw(); 478 493 479 494 scope.on( 480 − events.progress(({ meeting_id, done, total }) => { 495 + events.progress(({ meeting_id, done, total, lines }) => { 481 496 if (meeting_id !== id || job?.kind !== "transcribe") return; 482 497 job = { kind: "transcribe", done, total }; 498 + if (lines.length) { 499 + liveLines.push(...lines); 500 + root.querySelector("[data-live]")?.insertAdjacentHTML("beforeend", transcriptRows(lines) ?? ""); 501 + root.querySelector<HTMLElement>("[data-live-wait]")?.setAttribute("hidden", ""); 502 + } 483 503 const pct = total ? Math.round((done / total) * 100) : 0; 484 504 const bar = root.querySelector<HTMLElement>(".progress > span"); 485 505 const big = root.querySelector("[data-pct]"); 489 509 if (note) note.textContent = total ? progressNote(done, total) : "В записи не нашлось речи."; 490 510 }), 491 511 ); 512 + // Ответ ИИ пишется — показываем по ходу. Перерисовка не чаще кадра. 513 + let frame = 0; 514 + scope.on( 515 + events.answer(({ meeting_id, text }) => { 516 + if (meeting_id !== id || job?.kind !== "command") return; 517 + liveAnswer += text; 518 + if (frame) return; 519 + frame = requestAnimationFrame(() => { 520 + frame = 0; 521 + const el = root.querySelector("[data-answer]"); 522 + if (el) el.innerHTML = render(liveAnswer); 523 + }); 524 + }), 525 + ); 492 526 // Запись остановили из шапки или с главной — шаг меняется. 493 527 const off = onChange(async () => { 494 528 if (!scope.alive) return;