Экран записи и проигрыватель: волна, источники, отметки, звук ↔ текст
Запись: - Время — по записанному звуку, паузы не в счёт (позиция сессии записи, ms в событиях уровня, position_ms в recorder_status). - Живая «волна» последней минуты, «Микрофон / Звук компьютера — слышно / тишина», объём записи; «Остановить и расшифровать». - «★ Отметить момент»: отметка с подписью (Meeting.markers, add_marker / set_markers); видна в расшифровке и на проигрывателе. Проигрыватель (player.ts): - Полоса внизу экрана записи: играть, ±15 с, скорость до 2×, «волна» всей встречи (клик и протяжка — перемотка, отметки, текущее место), «★» — отметить место. Две дорожки записи играют вместе и подравниваются. Живёт дольше перерисовок экрана — звук не прерывается. - Звук читается с диска протоколом asset: только из папки записей (protocol-asset, scope на папку, media-src в CSP); «волна» считается один раз и кэшируется в peaks.json. - Время реплики и отметки — кнопка «слушать отсюда», звучащая реплика подсвечивается; пробел — играть/пауза, ←/→ — 5 с. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3699ac7
· сравнить с родителем
-
CHANGELOG.md +16
Показать разницуСкрыть разницу
1 1 # Изменения 2 2 3 +## 1.4 — не выпущена 4 + 5 +### Новое 6 + 7 +- **Экран записи — всё, что надо видеть во время встречи.** Время записанного звука 8 + (паузы не в счёт), живая «волна» последней минуты, «Микрофон — слышно / тишина», 9 + «Звук компьютера — слышно / тишина», сколько занимает запись. 10 +- **«★ Отметить момент»** во время записи — с подписью по желанию. Отметки видны в 11 + расшифровке (между репликами, по времени) и на проигрывателе; ненужную — «убрать». 12 +- **«Остановить и расшифровать»** — одной кнопкой. 13 +- **Проигрыватель записи** — полоса внизу экрана записи: играть и пауза, ±15 секунд, 14 + скорость до 2×, «волна» всей встречи — нажать или протянуть, чтобы перемотать, на ней 15 + отметки; «★» — отметить текущее место. У записи с двумя дорожками играют обе вместе. 16 +- **Звук ↔ текст.** Нажали на время реплики — звук с этого места; звучащая реплика 17 + подсвечивается. Пробел — играть и пауза, ←/→ — 5 секунд. 18 + 3 19 ## 1.3 — 01.10.2026 4 20 5 21 Главное: **встреча не потеряется молча** — звук проверяется до записи и во время неё, а ход -
Cargo.lock +7
Показать разницуСкрыть разницу
1771 1771 ] 1772 1772 1773 1773 [[package]] 1774 +name = "http-range" 1775 +version = "0.1.5" 1776 +source = "registry+https://github.com/rust-lang/crates.io-index" 1777 +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" 1778 + 1779 +[[package]] 1774 1780 name = "httparse" 1775 1781 version = "1.10.1" 1776 1782 source = "registry+https://github.com/rust-lang/crates.io-index" 4456 4462 "gtk", 4457 4463 "heck 0.5.0", 4458 4464 "http", 4465 + "http-range", 4459 4466 "jni 0.21.1", 4460 4467 "libc", 4461 4468 "log", -
README.md +12 −3
Показать разницуСкрыть разницу
51 51 полоски — микрофон и звук компьютера, — «слышно» или «тишина»; если устройство не 52 52 открылось, там же причина и где дать разрешение. Запись начинается только кнопкой 53 53 «Начать запись» — пишется весь звук компьютера (собеседники в Zoom, Google Meet, 54 − Телемосте — где угодно) и микрофон, пока не нажмёте «Остановить». Во время записи — 55 − таймер, общий уровень звука и пауза; если микрофон или собеседников минуту не слышно, 56 − программа скажет об этом. В шапке на любом экране — метка «Запись 12:34». 54 + Телемосте — где угодно) и микрофон, пока не нажмёте «Остановить». Во время записи на 55 + экране: время записанного звука (паузы не в счёт), живая «волна» последней минуты, 56 + «Микрофон — слышно / тишина», «Звук компьютера — слышно / тишина», сколько занимает 57 + запись, пауза, «Остановить и расшифровать» и **«★ Отметить момент»** — отметку можно 58 + подписать, она будет видна в расшифровке и на проигрывателе. Если микрофон или 59 + собеседников минуту не слышно, программа скажет об этом. В шапке на любом экране — 60 + метка «Запись 12:34». 57 61 - Готовый файл — перетащить в окно или «Добавить аудиофайл»: mp3, m4a, wav, aiff, flac, 58 62 ogg. 59 63 2. **Расшифровка** — кнопка «Расшифровать». Текст появляется на экране по ходу работы, 72 76 Сверху — «Копировать» (Markdown) и «как текст»; у вкладки — «Править», «Сделать 73 77 заново», «Удалить». Галочки задач отмечаются прямо в тексте. 74 78 79 + **Проигрыватель** — полоса внизу: играть и пауза, ±15 секунд, скорость до 2×, 80 + «волна» всей встречи (нажать или протянуть — перемотка; на ней отметки), «★» — 81 + отметить текущее место. Нажали на время реплики или отметки — звук с этого места, 82 + звучащая реплика подсвечивается. Пробел — играть и пауза, ←/→ — 5 секунд. 83 + 75 84 --- 76 85 77 86 ## Как это устроено -
src-tauri/Cargo.toml +1 −1
Показать разницуСкрыть разницу
15 15 tauri-build = { version = "2.7", features = [] } 16 16 17 17 [dependencies] 18 −tauri = { version = "2.12", features = [] } 18 +tauri = { version = "2.12", features = ["protocol-asset"] } 19 19 tauri-plugin-dialog = "2" 20 20 tauri-plugin-opener = "2" 21 21 meet-audio = { path = "../crates/audio" } -
src-tauri/src/commands.rs +107 −3
Показать разницуСкрыть разницу
8 8 9 9 use chrono::{DateTime, Local}; 10 10 use serde::Serialize; 11 −use tauri::{AppHandle, Emitter, State}; 11 +use tauri::{AppHandle, Emitter, Manager, State}; 12 12 use tokio_util::sync::CancellationToken; 13 13 14 14 use meet_audio::pcm::{WavFile, WavWriter}; 15 15 use meet_audio::source::{Capture, CaptureError}; 16 16 17 17 use crate::ai::{self, Command}; 18 −use crate::meetings::{AudioTrack, Meeting, MeetingDetail, Store}; 18 +use crate::meetings::{AudioTrack, Marker, Meeting, MeetingDetail, Store}; 19 19 use crate::openrouter::{Client, KeyInfo, SttSegment}; 20 20 use crate::recorder::{self, Session, Track}; 21 21 use crate::settings::{mask_key, Secrets, Settings, OPENROUTER_KEY}; 203 203 /// Новая папка для записей: все записи переносятся туда, дальше пишутся туда 204 204 /// же. `None` — вернуть папку по умолчанию. 205 205 #[tauri::command] 206 −pub fn set_storage_dir(state: State<'_, AppState>, path: Option<String>) -> Result<String, String> { 206 +pub fn set_storage_dir( 207 + app: AppHandle, 208 + state: State<'_, AppState>, 209 + path: Option<String>, 210 +) -> Result<String, String> { 207 211 if state 208 212 .recording 209 213 .lock() 229 233 }; 230 234 settings.save(&state.settings_path).map_err(|e| e.to_string())?; 231 235 *slot = Arc::new(Store::new(&target)); 236 + allow_playback(&app, &target); 232 237 Ok(target.display().to_string()) 233 238 } 234 239 595 600 system: bool, 596 601 /// Почему звук компьютера не пишется — чтобы подсказать, где разрешить. 597 602 system_error: Option<String>, 603 + /// Сколько звука записано, мс (паузы не в счёт). 604 + position_ms: u64, 598 605 } 599 606 600 607 #[tauri::command] 610 617 paused: r.session.is_paused(), 611 618 system: r.system_error.is_none(), 612 619 system_error: r.system_error.clone(), 620 + position_ms: r.session.position_ms(), 613 621 }) 614 622 } 615 623 624 +// ── Отметки и проигрыватель ───────────────────────────────────────────────── 625 + 626 +/// Отметить момент: во время записи — там, где запись сейчас (`ms` пусто), 627 +/// в проигрывателе — там, где звук (`ms` задан). 628 +#[tauri::command] 629 +pub fn add_marker( 630 + state: State<'_, AppState>, 631 + id: String, 632 + ms: Option<u64>, 633 + text: Option<String>, 634 +) -> Result<Meeting, String> { 635 + let ms = match ms { 636 + Some(ms) => ms, 637 + None => { 638 + let slot = state.recording.lock().unwrap_or_else(|p| p.into_inner()); 639 + match slot.as_ref() { 640 + Some(r) if r.meeting_id == id => r.session.position_ms(), 641 + _ => return Err("Запись не идёт — отметьте место в проигрывателе".into()), 642 + } 643 + } 644 + }; 645 + state 646 + .store() 647 + .add_marker( 648 + &id, 649 + Marker { 650 + ms, 651 + text: text.unwrap_or_default().trim().to_string(), 652 + }, 653 + ) 654 + .map_err(|e| e.to_string()) 655 +} 656 + 657 +#[tauri::command] 658 +pub fn set_markers(state: State<'_, AppState>, id: String, markers: Vec<Marker>) -> Result<Meeting, String> { 659 + state 660 + .store() 661 + .set_markers(&id, &markers) 662 + .map_err(|e| e.to_string()) 663 +} 664 + 665 +#[derive(Serialize)] 666 +pub struct PlayTrack { 667 + /// Полный путь к файлу; интерфейс превращает его в адрес `asset:`. 668 + path: String, 669 + speaker: Speaker, 670 +} 671 + 672 +#[derive(Serialize)] 673 +pub struct PlaybackView { 674 + tracks: Vec<PlayTrack>, 675 + duration_ms: u64, 676 + peaks: Vec<f32>, 677 +} 678 + 679 +/// Что играть и «волна» записи. Во время записи — нельзя: дорожки ещё пишутся. 680 +#[tauri::command] 681 +pub async fn playback(state: State<'_, AppState>, id: String) -> Result<PlaybackView, String> { 682 + if state.is_recording(&id) { 683 + return Err("Запись ещё идёт".into()); 684 + } 685 + let store = state.store(); 686 + let meeting = store.get(&id).map_err(|e| e.to_string())?.meeting; 687 + let dir = store.meeting_dir(&id).map_err(|e| e.to_string())?; 688 + let mut paths = Vec::new(); 689 + let mut tracks = Vec::new(); 690 + for t in &meeting.audio { 691 + let path = store.audio_path(&id, &t.file).map_err(|e| e.to_string())?; 692 + if path.is_file() { 693 + tracks.push(PlayTrack { 694 + path: path.display().to_string(), 695 + speaker: t.speaker.clone(), 696 + }); 697 + paths.push(path); 698 + } 699 + } 700 + if tracks.is_empty() { 701 + return Err("Файлов звука этой записи нет на диске".into()); 702 + } 703 + let duration_ms = meeting.duration_ms(); 704 + let peaks = tokio::task::spawn_blocking(move || crate::playback::peaks(&dir, &paths, duration_ms)) 705 + .await 706 + .map_err(|e| e.to_string())? 707 + .map_err(|e| e.to_string())?; 708 + Ok(PlaybackView { 709 + tracks, 710 + duration_ms, 711 + peaks: peaks.peaks, 712 + }) 713 +} 714 + 715 +/// Интерфейсу можно читать файлы звука только из папки записей. 716 +pub fn allow_playback(app: &AppHandle, dir: &Path) { 717 + let _ = app.asset_protocol_scope().allow_directory(dir, true); 718 +} 719 + 616 720 /// Начать запись: микрофон и весь звук компьютера — голоса собеседников в 617 721 /// любом сервисе встреч. 618 722 #[tauri::command] -
Показать разницуСкрыть разницу
6 6 pub mod meetings; 7 7 pub mod notes; 8 8 pub mod openrouter; 9 +pub mod playback; 9 10 pub mod recorder; 10 11 pub mod settings; 11 12 pub mod speakers; 43 44 } else { 44 45 PathBuf::from(chosen) 45 46 }; 47 + commands::allow_playback(app.handle(), &root); 46 48 let store = Arc::new(meetings::Store::new(root)); 47 49 // Запись, оборванная выключением компьютера, перестаёт считаться идущей. 48 50 let _ = store.recover_interrupted(); 76 78 commands::add_audio, 77 79 commands::transcribe_meeting, 78 80 commands::regroup_speakers, 81 + commands::add_marker, 82 + commands::set_markers, 83 + commands::playback, 79 84 commands::run_command, 80 85 commands::recorder_status, 81 86 commands::audio_check_start, -
Показать разницуСкрыть разницу
39 39 #[serde(default)] 40 40 pub recording: bool, 41 41 pub transcribed_at: Option<DateTime<Local>>, 42 + /// Отметки «важный момент» — кнопкой во время записи или в проигрывателе. 43 + #[serde(default)] 44 + pub markers: Vec<Marker>, 45 +} 46 + 47 +/// Отметка момента записи. 48 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 49 +pub struct Marker { 50 + /// Место в звуке от начала записи, мс (паузы не в счёт). 51 + pub ms: u64, 52 + /// Подпись; пусто — просто «Отметка». 53 + #[serde(default)] 54 + pub text: String, 42 55 } 43 56 44 57 impl Meeting { 142 155 audio: Vec::new(), 143 156 recording: false, 144 157 transcribed_at: None, 158 + markers: Vec::new(), 145 159 }; 146 160 std::fs::write(dir.join("note.md"), "")?; 147 161 // meeting.json последним: пока его нет, список встречу не видит. 224 238 Ok(note) 225 239 } 226 240 241 + /// Новая отметка; отметки держатся по времени. 242 + pub fn add_marker(&self, id: &str, marker: Marker) -> Result<Meeting, Error> { 243 + self.update(id, |m| { 244 + m.markers.push(marker.clone()); 245 + m.markers.sort_by_key(|k| k.ms); 246 + }) 247 + } 248 + 249 + /// Все отметки разом — правка подписи и удаление идут через интерфейс. 250 + pub fn set_markers(&self, id: &str, markers: &[Marker]) -> Result<Meeting, Error> { 251 + let mut sorted: Vec<Marker> = markers 252 + .iter() 253 + .map(|k| Marker { 254 + ms: k.ms, 255 + text: k.text.trim().chars().take(200).collect(), 256 + }) 257 + .collect(); 258 + sorted.sort_by_key(|k| k.ms); 259 + self.update(id, |m| m.markers = sorted.clone()) 260 + } 261 + 262 + /// Папка встречи на диске — для проигрывателя. 263 + pub fn meeting_dir(&self, id: &str) -> Result<PathBuf, Error> { 264 + self.dir(id) 265 + } 266 + 227 267 pub fn mark_transcribed(&self, id: &str, at: DateTime<Local>) -> Result<Meeting, Error> { 228 268 self.update(id, |m| m.transcribed_at = Some(at)) 229 269 } -
Показать разницуСкрыть разницу
1 +//! Проигрыватель: что играть и «волна» всей встречи для прокрутки. 2 +//! 3 +//! Звук отдаётся интерфейсу самими файлами записи (протокол `asset:` Tauri): 4 +//! у записи с двумя дорожками — оба WAV, интерфейс играет их вместе. «Волна» 5 +//! — громкость по равным отрезкам всей записи: по ней видно, где говорили, 6 +//! и по ней перематывают. Считается один раз и кладётся рядом 7 +//! (`peaks.json`); двухчасовой mp3 декодируется секунды, WAV читается кусками. 8 + 9 +use std::path::{Path, PathBuf}; 10 + 11 +use meet_audio::pcm::WavFile; 12 +use serde::{Deserialize, Serialize}; 13 + 14 +/// Сколько отрезков в «волне»: шире окна программы не бывает. 15 +pub const BUCKETS: usize = 1_600; 16 + 17 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 18 +pub struct Peaks { 19 + /// Для какой длины записи посчитано — запись поменялась, считаем заново. 20 + pub duration_ms: u64, 21 + /// Громкость отрезков, 0…1, по самой громкой дорожке. 22 + pub peaks: Vec<f32>, 23 +} 24 + 25 +/// Громкость отрезков одной дорожки: максимум модуля сэмпла, 0…1. 26 +fn track_peaks(path: &Path, total_samples: usize) -> std::io::Result<Vec<f32>> { 27 + let per = total_samples.div_ceil(BUCKETS).max(1); 28 + let mut out = vec![0f32; BUCKETS]; 29 + let mut feed = |offset: usize, samples: &[i16]| { 30 + for (i, s) in samples.iter().enumerate() { 31 + let b = ((offset + i) / per).min(BUCKETS - 1); 32 + let v = (*s as f32).abs() / 32_768.0; 33 + if v > out[b] { 34 + out[b] = v; 35 + } 36 + } 37 + }; 38 + match WavFile::open(path) { 39 + Ok(f) => { 40 + let block = 160_000; 41 + let mut pos = 0; 42 + while pos < f.len() { 43 + let part = f.read(pos, block)?; 44 + if part.is_empty() { 45 + break; 46 + } 47 + feed(pos, &part); 48 + pos += part.len(); 49 + } 50 + } 51 + Err(_) => { 52 + let samples = meet_audio::decode::decode_file(path) 53 + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; 54 + feed(0, &samples); 55 + } 56 + } 57 + Ok(out) 58 +} 59 + 60 +/// «Волна» записи: по всем дорожкам, из кэша, если он про ту же длину. 61 +pub fn peaks(dir: &Path, tracks: &[PathBuf], duration_ms: u64) -> std::io::Result<Peaks> { 62 + let cache = dir.join("peaks.json"); 63 + if let Ok(text) = std::fs::read_to_string(&cache) { 64 + if let Ok(p) = serde_json::from_str::<Peaks>(&text) { 65 + if p.duration_ms == duration_ms && p.peaks.len() == BUCKETS { 66 + return Ok(p); 67 + } 68 + } 69 + } 70 + let total = meet_audio::pcm::ms_to_samples(duration_ms).max(1); 71 + let mut merged = vec![0f32; BUCKETS]; 72 + for t in tracks { 73 + for (m, v) in merged.iter_mut().zip(track_peaks(t, total)?) { 74 + *m = m.max(v); 75 + } 76 + } 77 + let p = Peaks { 78 + duration_ms, 79 + peaks: merged, 80 + }; 81 + let _ = std::fs::write(&cache, serde_json::to_vec(&p).unwrap_or_default()); 82 + Ok(p) 83 +} 84 + 85 +#[cfg(test)] 86 +mod tests { 87 + use super::*; 88 + use meet_audio::pcm::{ms_to_samples, WavWriter}; 89 + 90 + #[test] 91 + fn peaks_show_where_the_sound_is_and_are_cached() { 92 + let dir = tempfile::tempdir().unwrap(); 93 + let wav = dir.path().join("me.wav"); 94 + { 95 + let mut w = WavWriter::create(&wav).unwrap(); 96 + w.write(&vec![0; ms_to_samples(5_000)]).unwrap(); 97 + w.write(&vec![16_384; ms_to_samples(5_000)]).unwrap(); 98 + } 99 + let p = peaks(dir.path(), std::slice::from_ref(&wav), 10_000).unwrap(); 100 + assert_eq!(p.peaks.len(), BUCKETS); 101 + assert!( 102 + p.peaks[..BUCKETS / 2 - 1].iter().all(|v| *v == 0.0), 103 + "первая половина — тишина" 104 + ); 105 + assert!( 106 + (p.peaks[BUCKETS - 1] - 0.5).abs() < 0.01, 107 + "вторая — половина громкости" 108 + ); 109 + // Кэш: файл звука пропал — «волна» всё равно отдаётся. 110 + std::fs::remove_file(&wav).unwrap(); 111 + assert_eq!(peaks(dir.path(), &[wav], 10_000).unwrap(), p); 112 + } 113 +} -
src-tauri/src/recorder.rs +21 −5
Показать разницуСкрыть разницу
13 13 //! приходит [`Event::Silent`], а когда звук вернулся — [`Event::Heard`]. 14 14 15 15 use std::path::PathBuf; 16 −use std::sync::atomic::{AtomicBool, Ordering}; 16 +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; 17 17 use std::sync::Arc; 18 18 use std::thread::JoinHandle; 19 19 use std::time::{Duration, Instant}; 39 39 #[derive(Debug, Clone, PartialEq, serde::Serialize)] 40 40 #[serde(tag = "kind", content = "data", rename_all = "snake_case")] 41 41 pub enum Event { 42 − /// Уровень дорожки, dBFS, несколько раз в секунду. 43 − Level { speaker: Speaker, dbfs: f32 }, 42 + /// Уровень дорожки, dBFS, несколько раз в секунду; `ms` — сколько звука 43 + /// дорожка уже записала (паузы не в счёт). 44 + Level { speaker: Speaker, dbfs: f32, ms: u64 }, 44 45 /// Дорожка перестала писаться; остальные продолжают. 45 46 TrackFailed { speaker: Speaker, error: String }, 46 47 /// Дорожка `seconds` секунд не слышит ничего (на паузе не считается). 61 62 pub struct Session { 62 63 stop: Arc<AtomicBool>, 63 64 paused: Arc<AtomicBool>, 65 + /// Сколько звука записано, мс — по самой длинной дорожке. 66 + position: Arc<AtomicU64>, 64 67 threads: Vec<JoinHandle<Option<Written>>>, 65 68 pub started: Instant, 66 69 } 74 77 pub fn start_with(tracks: Vec<Track>, emit: Emit, silence_after: Duration) -> Result<Self, String> { 75 78 let stop = Arc::new(AtomicBool::new(false)); 76 79 let paused = Arc::new(AtomicBool::new(false)); 80 + let position = Arc::new(AtomicU64::new(0)); 77 81 let mut threads = Vec::new(); 78 82 for track in tracks { 79 83 let writer = match &track.path { 80 84 Some(path) => Some(WavWriter::create(path).map_err(|e| format!("{}: {e}", path.display()))?), 81 85 None => None, 82 86 }; 83 − let (stop, paused, emit) = (stop.clone(), paused.clone(), emit.clone()); 87 + let (stop, paused, emit, pos) = (stop.clone(), paused.clone(), emit.clone(), position.clone()); 84 88 threads.push( 85 89 std::thread::Builder::new() 86 90 .name(format!("meet-track-{}", track.speaker.track_file())) 87 − .spawn(move || run_track(track, writer, stop, paused, emit, silence_after)) 91 + .spawn(move || run_track(track, writer, stop, paused, emit, silence_after, pos)) 88 92 .map_err(|e| e.to_string())?, 89 93 ); 90 94 } 91 95 Ok(Self { 92 96 stop, 93 97 paused, 98 + position, 94 99 threads, 95 100 started: Instant::now(), 96 101 }) 104 109 self.paused.load(Ordering::Relaxed) 105 110 } 106 111 112 + /// Сколько звука записано, мс (паузы не в счёт): место для отметки. 113 + pub fn position_ms(&self) -> u64 { 114 + self.position.load(Ordering::Relaxed) 115 + } 116 + 107 117 /// Останавливает запись и ждёт, пока дорожки допишутся. Блокирует — звать 108 118 /// из фонового потока. 109 119 pub fn stop(self) -> Vec<Written> { 122 132 paused: Arc<AtomicBool>, 123 133 emit: Emit, 124 134 silence_after: Duration, 135 + position: Arc<AtomicU64>, 125 136 ) -> Option<Written> { 126 137 let speaker = track.speaker.clone(); 127 138 let mut level_buf: Vec<i16> = Vec::new(); 172 183 } else { 173 184 pcm::level_dbfs(&level_buf) 174 185 }; 186 + let ms = writer.as_ref().map(WavWriter::duration_ms).unwrap_or(0); 187 + position.fetch_max(ms, Ordering::Relaxed); 175 188 emit(Event::Level { 176 189 speaker: speaker.clone(), 177 190 dbfs, 191 + ms, 178 192 }); 179 193 if dbfs > SILENCE_DBFS { 180 194 last_heard = Instant::now(); 198 212 emit(Event::Level { 199 213 speaker: speaker.clone(), 200 214 dbfs: -90.0, 215 + ms: writer.as_ref().map(WavWriter::duration_ms).unwrap_or(0), 201 216 }); 202 217 let mut writer = writer?; 203 218 let _ = writer.flush(); 282 297 .unwrap(); 283 298 session.set_paused(true); 284 299 std::thread::sleep(Duration::from_millis(300)); 300 + assert_eq!(session.position_ms(), 0); 285 301 assert_eq!(session.stop()[0].duration_ms, 0, "на паузе ничего не записано"); 286 302 } 287 303 -
Показать разницуСкрыть разницу
21 21 } 22 22 ], 23 23 "security": { 24 − "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:" 24 + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; media-src 'self' asset: http://asset.localhost", 25 + "assetProtocol": { 26 + "enable": true, 27 + "scope": [] 28 + } 25 29 } 26 30 }, 27 31 "bundle": { -
src/api.ts +26 −2
Показать разницуСкрыть разницу
13 13 audio: AudioTrack[]; 14 14 recording: boolean; 15 15 transcribed_at: string | null; 16 + /** Отметки «важный момент» — по времени. */ 17 + markers: Marker[]; 18 +} 19 + 20 +/** Отметка момента: место в звуке (мс, паузы не в счёт) и подпись. */ 21 +export interface Marker { ms: number; text: string } 22 + 23 +/** Что играть: файлы звука записи (у записи — две дорожки) и «волна» для прокрутки. */ 24 +export interface PlaybackView { 25 + tracks: { path: string; speaker: Speaker }[]; 26 + duration_ms: number; 27 + /** Громкость равных отрезков записи, 0…1. */ 28 + peaks: number[]; 16 29 } 17 30 18 31 /** `voices` — есть реплики с отпечатками голосов: разбиение по говорящим пересобирается без новой расшифровки. */ 50 63 system: boolean; 51 64 /** Почему звук компьютера не пишется. */ 52 65 system_error: string | null; 66 + /** Сколько звука записано, мс (паузы не в счёт). */ 67 + position_ms: number; 53 68 } 54 69 55 70 /** `voices` — сколько говорящих нашлось по голосам (0 — разбора не было), `voices_error` — почему не было. */ 64 79 export interface CommandResult { note: string; heading: string } 65 80 66 81 export type RecorderEvent = 67 − | { kind: "level"; data: { speaker: Speaker; dbfs: number } } 82 + /** Уровень дорожки; `ms` — сколько звука она уже записала (паузы не в счёт). */ 83 + | { kind: "level"; data: { speaker: Speaker; dbfs: number; ms: number } } 68 84 | { kind: "track_failed"; data: { speaker: Speaker; error: string } } 69 85 /** Дорожка давно ничего не слышит (на паузе не считается). */ 70 86 | { kind: "silent"; data: { speaker: Speaker; seconds: number } } 83 99 pickAudio: () => Promise<string | null>; 84 100 pickFolder: () => Promise<string | null>; 85 101 onFileDrop: (cb: (paths: string[]) => void) => Promise<() => void>; 102 + /** Адрес, по которому интерфейс читает файл с диска (протокол `asset:`). */ 103 + fileSrc: (path: string) => string; 86 104 } 87 105 88 106 const AUDIO_EXTENSIONS = ["mp3", "m4a", "wav", "aiff", "aif", "flac", "ogg", "aac", "mp4", "mkv", "caf"]; 92 110 function getCore(): Promise<Core> { 93 111 core ??= (async (): Promise<Core> => { 94 112 if ("__TAURI_INTERNALS__" in window) { 95 − const { invoke } = await import("@tauri-apps/api/core"); 113 + const { invoke, convertFileSrc } = await import("@tauri-apps/api/core"); 96 114 const { listen } = await import("@tauri-apps/api/event"); 97 115 const { getCurrentWebview } = await import("@tauri-apps/api/webview"); 98 116 const { open } = await import("@tauri-apps/plugin-dialog"); 111 129 getCurrentWebview().onDragDropEvent((e) => { 112 130 if (e.payload.type === "drop") cb(e.payload.paths); 113 131 }), 132 + fileSrc: (path) => convertFileSrc(path), 114 133 }; 115 134 } 116 135 if (import.meta.env.DEV || import.meta.env.VITE_MOCK === "1") { 146 165 pickFolder: async () => (await getCore()).pickFolder(), 147 166 onDrop: async (cb: (paths: string[]) => void) => (await getCore()).onFileDrop(cb), 148 167 isAudio: (path: string) => AUDIO_EXTENSIONS.includes(path.split(".").pop()?.toLowerCase() ?? ""), 168 + src: async (path: string) => (await getCore()).fileSrc(path), 149 169 }; 150 170 151 171 export const api = { 170 190 call<{ note: string; voices: number }>("regroup_speakers", { id, count }), 171 191 runCommand: (id: string, command: Command) => call<CommandResult>("run_command", { id, command }), 172 192 recorderStatus: () => call<RecordingView | null>("recorder_status"), 193 + /** Отметить момент: `ms` пусто — там, где сейчас запись. */ 194 + addMarker: (id: string, ms: number | null, text = "") => call<Meeting>("add_marker", { id, ms, text }), 195 + setMarkers: (id: string, markers: Marker[]) => call<Meeting>("set_markers", { id, markers }), 196 + playback: (id: string) => call<PlaybackView>("playback", { id }), 173 197 /** Открыть микрофон и звук компьютера без записи — только уровни (`meeting_id` событий пустой). */ 174 198 audioCheckStart: () => call<CheckView>("audio_check_start"), 175 199 audioCheckStop: () => call<void>("audio_check_stop"), -
src/dev-mock.ts +62 −4
Показать разницуСкрыть разницу
70 70 const d: MeetingDetail = { 71 71 meeting: { 72 72 id, title, created_at: created.toISOString(), updated_at: created.toISOString(), audio, 73 − recording: false, transcribed_at: transcribed ? created.toISOString() : null, 73 + recording: false, transcribed_at: transcribed ? created.toISOString() : null, markers: [], 74 74 }, 75 75 note, 76 76 voices: transcribed, 130 130 return () => undefined; 131 131 } 132 132 133 +/** В заглушке «путь» к звуку — уже готовый адрес (blob:). */ 134 +export function fileSrc(path: string): string { 135 + return path; 136 +} 137 + 138 +/** Звук для проигрывателя в браузере: 90 секунд — реплики тоном разной 139 + * высоты с паузами, чтобы было что слушать и видеть на «волне». */ 140 +let demoAudio: string | null = null; 141 +function demoTrack(): { url: string; peaks: number[]; ms: number } { 142 + const rate = 8_000; 143 + const seconds = 90; 144 + const n = rate * seconds; 145 + const data = new Int16Array(n); 146 + for (let i = 0; i < n; i++) { 147 + const t = i / rate; 148 + const phrase = Math.floor(t / 4); 149 + const on = t % 4 < 3.2; 150 + const f = [220, 330, 262, 392][phrase % 4]; 151 + data[i] = on ? Math.round(Math.sin(2 * Math.PI * f * t) * 6000 * (0.5 + 0.5 * Math.sin(t * 3))) : 0; 152 + } 153 + if (!demoAudio) { 154 + const buf = new ArrayBuffer(44 + n * 2); 155 + const v = new DataView(buf); 156 + const str = (o: number, x: string) => [...x].forEach((c, k) => v.setUint8(o + k, c.charCodeAt(0))); 157 + str(0, "RIFF"); v.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); 158 + v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); 159 + v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); 160 + str(36, "data"); v.setUint32(40, n * 2, true); 161 + new Int16Array(buf, 44).set(data); 162 + demoAudio = URL.createObjectURL(new Blob([buf], { type: "audio/wav" })); 163 + } 164 + const peaks = Array.from({ length: 1600 }, (_, b) => { 165 + const t = (b / 1600) * seconds; 166 + return t % 4 < 3.2 ? 0.25 + 0.2 * (0.5 + 0.5 * Math.sin(t * 3)) : 0; 167 + }); 168 + return { url: demoAudio, peaks, ms: seconds * 1000 }; 169 +} 170 + 133 171 export async function invoke<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> { 134 172 await wait(60); 135 173 switch (cmd) { 257 295 const others = Date.now() - began > 3_000; 258 296 for (const [speaker, on] of [[{ kind: "me" }, true], [{ kind: "others" }, others]] as [Speaker, boolean][]) { 259 297 const dbfs = on ? -40 + Math.random() * 25 : -90; 260 − emit("meet://recorder", { meeting_id: "", event: { kind: "level", data: { speaker, dbfs } } }); 298 + emit("meet://recorder", { meeting_id: "", event: { kind: "level", data: { speaker, dbfs, ms: 0 } } }); 261 299 } 262 300 }, 200); 263 301 return { mic_error: null, system_error: null } as T; 265 303 case "audio_check_stop": 266 304 window.clearInterval(checkTimer); 267 305 return undefined as T; 306 + case "add_marker": { 307 + const d = find(args.id); 308 + const ms = (args.ms as number | null) ?? (recording && recording.meeting_id === args.id ? recording.position_ms : null); 309 + if (ms == null) throw "Запись не идёт — отметьте место в проигрывателе"; 310 + d.meeting.markers = [...d.meeting.markers, { ms, text: String(args.text ?? "") }].sort((a, b) => a.ms - b.ms); 311 + return d.meeting as T; 312 + } 313 + case "set_markers": { 314 + const d = find(args.id); 315 + d.meeting.markers = (args.markers as Meeting["markers"]).slice().sort((a, b) => a.ms - b.ms); 316 + return d.meeting as T; 317 + } 318 + case "playback": { 319 + const d = find(args.id); 320 + if (d.meeting.recording) throw "Запись ещё идёт"; 321 + if (!d.meeting.audio.length) throw "Файлов звука этой записи нет на диске"; 322 + const t = demoTrack(); 323 + return { tracks: [{ path: t.url, speaker: { kind: "unknown" } }], duration_ms: t.ms, peaks: t.peaks } as T; 324 + } 268 325 case "recorder_status": 269 326 return (recording && { ...recording, timer: undefined }) as T; 270 327 case "recorder_start": { 273 330 const d = new Date(); 274 331 const m = add(`Встреча ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, 0, "", [], false); 275 332 m.meeting.recording = true; 276 − const r = { meeting_id: m.meeting.id, started_at: d.toISOString(), paused: false, system: true, system_error: null, timer: 0 }; 333 + const r = { meeting_id: m.meeting.id, started_at: d.toISOString(), paused: false, system: true, system_error: null, position_ms: 0, timer: 0 }; 277 334 window.clearInterval(checkTimer); 278 335 // Собеседники «молчат» с 6-й по 12-ю секунду — видно предупреждение о тишине. 279 336 window.setTimeout(() => emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "silent", data: { speaker: { kind: "others" }, seconds: 60 } } }), 6_000); 281 338 r.timer = window.setInterval(() => { 282 339 for (const speaker of [{ kind: "me" }, { kind: "others" }] as Speaker[]) { 283 340 const dbfs = r.paused ? -90 : -60 + Math.random() * 45; 284 − emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "level", data: { speaker, dbfs } } }); 341 + if (!r.paused && speaker.kind === "me") r.position_ms += 200; 342 + emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "level", data: { speaker, dbfs, ms: r.position_ms } } }); 285 343 } 286 344 }, 200); 287 345 recording = r; -
src/player.ts +201
Показать разницуСкрыть разницу
1 +// Проигрыватель записи — полоса внизу экрана записи, как у MacWhisper: 2 +// играть и пауза, ±15 секунд, скорость, «волна» всей встречи (клик или 3 +// протяжка — перемотка, на ней отметки и текущее место) и «★ отметить здесь». 4 +// 5 +// У записи две дорожки — микрофон и звук компьютера — отдельными файлами; 6 +// проигрыватель играет их вместе и подравнивает, если разошлись. Файлы 7 +// читаются с диска через протокол `asset:` — копий звука не делается. 8 + 9 +import { api, files, type Marker, type Meeting } from "./api"; 10 +import { clock } from "./recording"; 11 +import { toast } from "./state"; 12 + 13 +export interface Player { 14 + /** Корень полосы — экран вставляет его к себе после каждой перерисовки. */ 15 + el: HTMLElement; 16 + /** Перемотать на `ms`; `play` — сразу играть. */ 17 + seek(ms: number, play?: boolean): void; 18 + /** Где сейчас звук, мс. */ 19 + current(): number; 20 + /** Отметки поменялись — перерисовать их на «волне». */ 21 + setMarkers(markers: Marker[]): void; 22 + /** Звук сдвинулся — подписка; возвращает отписку. */ 23 + onTime(cb: (ms: number) => void): () => void; 24 + destroy(): void; 25 +} 26 + 27 +const RATES = [1, 1.25, 1.5, 2]; 28 +/** Дорожки разошлись больше чем на это — подравнять. */ 29 +const DRIFT_S = 0.12; 30 + 31 +export async function createPlayer(id: string, markers: Marker[], onMarked: (m: Meeting) => void): Promise<Player> { 32 + const view = await api.playback(id); 33 + const audios = await Promise.all( 34 + view.tracks.map(async (t) => { 35 + const a = new Audio(await files.src(t.path)); 36 + a.preload = "auto"; 37 + return a; 38 + }), 39 + ); 40 + const master = audios[0]; 41 + let marks = markers.slice(); 42 + const listeners = new Set<(ms: number) => void>(); 43 + const durationMs = () => (Number.isFinite(master.duration) && master.duration > 0 ? master.duration * 1000 : view.duration_ms); 44 + 45 + const el = document.createElement("div"); 46 + el.className = "player"; 47 + el.innerHTML = ` 48 + <button class="player-btn player-play" data-pl-play aria-label="Играть">▶</button> 49 + <button class="player-btn" data-pl-back title="Назад на 15 секунд">−15</button> 50 + <button class="player-btn" data-pl-fwd title="Вперёд на 15 секунд">+15</button> 51 + <span class="player-time mono-num" data-pl-time>${clock(0)} / ${clock(view.duration_ms)}</span> 52 + <canvas class="player-wave" data-pl-wave height="40" aria-label="Вся запись: нажмите или протяните, чтобы перемотать"></canvas> 53 + <select class="input player-rate" data-pl-rate aria-label="Скорость">${RATES.map((r) => `<option value="${r}">${String(r).replace(".", ",")}×</option>`).join("")}</select> 54 + <button class="player-btn player-mark" data-pl-mark title="Отметить это место">★</button>`; 55 + const canvas = el.querySelector<HTMLCanvasElement>("[data-pl-wave]")!; 56 + const playBtn = el.querySelector<HTMLButtonElement>("[data-pl-play]")!; 57 + 58 + const playing = () => !master.paused; 59 + const now = () => master.currentTime * 1000; 60 + 61 + const draw = () => { 62 + const w = canvas.clientWidth; 63 + if (!w) return; 64 + const dpr = window.devicePixelRatio || 1; 65 + if (canvas.width !== Math.round(w * dpr)) canvas.width = Math.round(w * dpr); 66 + const h = canvas.height; 67 + const ctx = canvas.getContext("2d"); 68 + if (!ctx) return; 69 + const css = getComputedStyle(el); 70 + const accent = css.getPropertyValue("--color-accent").trim() || "#a6533c"; 71 + const muted = css.getPropertyValue("--color-border-light").trim() || "#bbb"; 72 + const ink = css.getPropertyValue("--kv-ink").trim() || "#0a0a0a"; 73 + ctx.clearRect(0, 0, canvas.width, h); 74 + const total = durationMs(); 75 + const played = total ? now() / total : 0; 76 + const n = view.peaks.length; 77 + const step = canvas.width / n; 78 + const bar = Math.max(1, step * 0.7); 79 + for (let i = 0; i < n; i++) { 80 + // Корень — чтобы тихая речь тоже была видна. 81 + const v = Math.sqrt(view.peaks[i]); 82 + const bh = Math.max(1, v * (h - 6)); 83 + ctx.fillStyle = i / n <= played ? accent : muted; 84 + ctx.fillRect(i * step, (h - bh) / 2, bar, bh); 85 + } 86 + ctx.fillStyle = ink; 87 + for (const m of marks) { 88 + const x = total ? (m.ms / total) * canvas.width : 0; 89 + ctx.fillRect(x, 0, Math.max(2, dpr * 2), 6 * dpr); 90 + } 91 + ctx.fillRect(played * canvas.width, 0, Math.max(1, dpr), h); 92 + el.querySelector("[data-pl-time]")!.textContent = `${clock(now())} / ${clock(total)}`; 93 + playBtn.textContent = playing() ? "❚❚" : "▶"; 94 + playBtn.setAttribute("aria-label", playing() ? "Пауза" : "Играть"); 95 + }; 96 + 97 + const seek = (ms: number, play = false) => { 98 + const t = Math.max(0, Math.min(ms, durationMs())) / 1000; 99 + audios.forEach((a) => (a.currentTime = t)); 100 + if (play && !playing()) void start(); 101 + draw(); 102 + listeners.forEach((cb) => cb(t * 1000)); 103 + }; 104 + 105 + const start = async () => { 106 + try { 107 + await Promise.all(audios.map((a) => a.play())); 108 + } catch (e) { 109 + audios.forEach((a) => a.pause()); 110 + toast(`Звук не играет: ${e}`); 111 + } 112 + draw(); 113 + }; 114 + const pause = () => { 115 + audios.forEach((a) => a.pause()); 116 + draw(); 117 + }; 118 + const toggle = () => (playing() ? pause() : void start()); 119 + 120 + playBtn.addEventListener("click", toggle); 121 + el.querySelector("[data-pl-back]")!.addEventListener("click", () => seek(now() - 15_000)); 122 + el.querySelector("[data-pl-fwd]")!.addEventListener("click", () => seek(now() + 15_000)); 123 + el.querySelector<HTMLSelectElement>("[data-pl-rate]")!.addEventListener("change", (e) => { 124 + const rate = Number((e.target as HTMLSelectElement).value); 125 + audios.forEach((a) => (a.playbackRate = rate)); 126 + }); 127 + el.querySelector("[data-pl-mark]")!.addEventListener("click", async () => { 128 + try { 129 + const m = await api.addMarker(id, Math.round(now())); 130 + marks = m.markers; 131 + draw(); 132 + onMarked(m); 133 + toast(`Отмечено: ${clock(now())}`, "ok"); 134 + } catch (e) { 135 + toast(e); 136 + } 137 + }); 138 + 139 + // «Волна»: нажал — туда, протянул — следом. 140 + const toMs = (e: PointerEvent) => { 141 + const r = canvas.getBoundingClientRect(); 142 + return ((e.clientX - r.left) / r.width) * durationMs(); 143 + }; 144 + canvas.addEventListener("pointerdown", (e) => { 145 + canvas.setPointerCapture(e.pointerId); 146 + seek(toMs(e)); 147 + }); 148 + canvas.addEventListener("pointermove", (e) => { 149 + if (canvas.hasPointerCapture(e.pointerId)) seek(toMs(e)); 150 + }); 151 + 152 + // Время идёт: подравнять дорожки, перерисовать, сказать экрану. 153 + master.addEventListener("timeupdate", () => { 154 + for (const a of audios.slice(1)) { 155 + if (Math.abs(a.currentTime - master.currentTime) > DRIFT_S) a.currentTime = master.currentTime; 156 + } 157 + draw(); 158 + listeners.forEach((cb) => cb(now())); 159 + }); 160 + master.addEventListener("ended", pause); 161 + master.addEventListener("loadedmetadata", draw); 162 + 163 + // Пробел — играть и пауза, стрелки — ±5 с; не когда человек пишет текст. 164 + const onKey = (e: KeyboardEvent) => { 165 + const t = e.target as HTMLElement; 166 + if (t.closest("input, textarea, select, button, [contenteditable]") || !el.isConnected) return; 167 + if (e.key === " ") { 168 + e.preventDefault(); 169 + toggle(); 170 + } else if (e.key === "ArrowLeft") seek(now() - 5_000); 171 + else if (e.key === "ArrowRight") seek(now() + 5_000); 172 + }; 173 + document.addEventListener("keydown", onKey); 174 + const onResize = () => draw(); 175 + window.addEventListener("resize", onResize); 176 + requestAnimationFrame(draw); 177 + 178 + return { 179 + el, 180 + seek, 181 + current: now, 182 + setMarkers(list) { 183 + marks = list.slice(); 184 + draw(); 185 + }, 186 + onTime(cb) { 187 + listeners.add(cb); 188 + return () => listeners.delete(cb); 189 + }, 190 + destroy() { 191 + document.removeEventListener("keydown", onKey); 192 + window.removeEventListener("resize", onResize); 193 + audios.forEach((a) => { 194 + a.pause(); 195 + a.removeAttribute("src"); 196 + a.load(); 197 + }); 198 + listeners.clear(); 199 + }, 200 + }; 201 +} -
src/recording.ts +181 −35
Показать разницуСкрыть разницу
1 1 // Запись: панель с таймером, уровнями и кнопками — одна на главной и на 2 2 // экране записи. Метка «идёт запись» с любого экрана — в шапке (nav.ts). 3 3 4 −import { api, elapsed, events } from "./api"; 4 +import { api, events, type Marker } from "./api"; 5 +import * as jobs from "./jobs"; 5 6 import { esc } from "./dom"; 6 7 import type { Scope } from "./scope"; 7 8 import { changed, refreshRecording, state, toast } from "./state"; 22 23 export function startSilenceWatch(): void { 23 24 void events.recorder(({ meeting_id, event }) => { 24 25 if (!meeting_id || state.recording?.meeting_id !== meeting_id) return; 26 + if (event.kind === "level") { 27 + onLevel(trackOf(event.data.speaker.kind), event.data.dbfs, event.data.ms); 28 + return; 29 + } 25 30 if (event.kind !== "silent" && event.kind !== "heard") return; 26 31 const track: Track = event.data.speaker.kind === "me" ? "me" : "others"; 27 32 if (event.kind === "silent") { 34 39 }); 35 40 } 36 41 37 −/** Новая запись — старые предупреждения не в счёт. */ 42 +/** Новая запись — старые предупреждения, «волна» и отметки не в счёт. */ 38 43 export function resetSilence(): void { 39 44 silent.clear(); 45 + wave.length = 0; 46 + positionMs = 0; 47 + heardAt.me = heardAt.others = 0; 48 + marks = []; 49 +} 50 + 51 +/** Последняя минута звука записи: громкость каждые 0,2 с — для живой «волны». */ 52 +const WAVE_POINTS = 300; 53 +const wave: number[] = []; 54 +/** Сколько звука записано, мс (паузы не в счёт) — по событиям дорожек. */ 55 +let positionMs = 0; 56 +/** Когда дорожка в последний раз что-то слышала. */ 57 +const heardAt: Record<Track, number> = { me: 0, others: 0 }; 58 +/** Последние уровни дорожек — общая точка «волны» берёт громкую из них. */ 59 +const lastLevel: Record<Track, { dbfs: number; at: number }> = { me: { dbfs: -90, at: 0 }, others: { dbfs: -90, at: 0 } }; 60 +/** Отметки текущей записи. */ 61 +let marks: Marker[] = []; 62 + 63 +function trackOf(kind: string): Track { 64 + return kind === "me" ? "me" : "others"; 65 +} 66 + 67 +function onLevel(track: Track, dbfs: number, ms: number): void { 68 + const now = performance.now(); 69 + lastLevel[track] = { dbfs, at: now }; 70 + if (dbfs > -45) heardAt[track] = now; 71 + positionMs = Math.max(positionMs, ms); 72 + // Точка «волны» — раз в 0,2 с, громкая из дорожек; события дорожек 73 + // приходят парами, вторую пару в тот же тик не считаем. 74 + if (track === "me" || !lastLevel.me.at || now - lastLevel.me.at > 300) { 75 + const loud = Math.max(...Object.values(lastLevel).filter((l) => now - l.at < 500).map((l) => l.dbfs), -90); 76 + wave.push(Math.max(0, Math.min(1, (loud + 60) / 55))); 77 + if (wave.length > WAVE_POINTS) wave.splice(0, wave.length - WAVE_POINTS); 78 + } 79 +} 80 + 81 +/** Отметки записи с ядра — после начала, после «Отметить момент». */ 82 +export function setRecordingMarks(list: Marker[]): void { 83 + marks = list.slice(); 84 +} 85 + 86 +/** Время записи «чч:мм:сс» по записанному звуку. */ 87 +export function clock(ms: number): string { 88 + const t = Math.floor(ms / 1000); 89 + const p = (n: number) => String(n).padStart(2, "0"); 90 + return `${p(Math.floor(t / 3600))}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}`; 91 +} 92 + 93 +function sizeText(ms: number, tracks: number): string { 94 + const mb = (ms * 32 * tracks) / 1_048_576; 95 + return mb < 1 ? "меньше 1 МБ" : `${mb.toFixed(mb < 10 ? 1 : 0).replace(".", ",")} МБ`; 40 96 } 41 97 98 +/** Панель записи — одна на главной и на экране записи: время, живая 99 + * «волна», что слышно, отметки и кнопки. Всё, что надо видеть во время 100 + * встречи, — без перехода куда-либо. */ 42 101 export function recordingPanel(): string { 43 102 const r = state.recording; 44 103 if (!r) return ""; 104 + const pos = Math.max(positionMs, r.position_ms ?? 0); 105 + const source = (t: Track, title: string) => { 106 + const off = t === "others" && !r.system; 107 + return `<span class="src${off ? " is-off" : ""}" data-src="${t}"><span class="dot"></span>${title} 108 + <b data-src-state>${off ? "не пишется" : "…"}</b></span>`; 109 + }; 45 110 return ` 46 111 <div class="rec" data-rec> 47 − <p class="rec-state"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${ 48 − r.paused ? "Запись на паузе" : r.system ? "Идёт запись: собеседники и вы" : "Идёт запись: только ваш микрофон" 49 − }</p> 112 + <div class="rec-top"> 113 + <span class="rec-badge${r.paused ? " is-paused" : ""}"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${ 114 + r.paused ? "Пауза" : "Идёт запись" 115 + }</span> 116 + <span class="rec-timer mono-num" data-timer>${clock(pos)}</span> 117 + </div> 118 + <canvas class="rec-wave" data-wave height="72" aria-label="Звук последней минуты"></canvas> 119 + <div class="rec-sources"> 120 + ${source("me", "Микрофон")} 121 + ${source("others", "Звук компьютера")} 122 + <span class="rec-size xs muted" data-size>${sizeText(pos, r.system ? 2 : 1)}</span> 123 + </div> 50 124 ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся: ${esc(r.system_error)}</p>` : ""} 51 125 ${[...silent].map((t) => `<p class="rec-warn small" data-silent="${t}">${esc(SILENT_TEXT[t])}</p>`).join("")} 52 − <p class="rec-timer mono-num" data-timer>${elapsed(r.started_at)}</p> 53 − <div class="meter" title="Громкость того, что пишется: ваш микрофон и звук компьютера вместе"> 54 − <span class="meter-bar"><span class="meter-fill" data-level></span></span> 55 − </div> 56 − <div class="actions"> 126 + <div class="rec-actions"> 57 127 <button class="ui-btn" data-rec-stop>Остановить</button> 128 + <button class="ui-btn" data-rec-stop-transcribe>Остановить и расшифровать</button> 58 129 <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button> 130 + <span class="spacer"></span> 131 + <button class="ui-btn ui-btn-sm rec-mark-btn" data-rec-mark title="Отметить важный момент — он будет виден в расшифровке и на проигрывателе">★ Отметить момент</button> 59 132 </div> 133 + ${ 134 + marks.length 135 + ? `<ol class="rec-marks">${marks 136 + .map( 137 + (m, i) => `<li><span class="mono-num xs">${clock(m.ms)}</span> 138 + <input class="input" data-mark-text="${i}" value="${esc(m.text)}" placeholder="что было — необязательно" maxlength="200"></li>`, 139 + ) 140 + .join("")}</ol>` 141 + : `<p class="xs muted">Случилось важное — «★ Отметить момент»: место будет видно в расшифровке и на проигрывателе.</p>` 142 + } 60 143 </div>`; 61 144 } 62 145 63 −/** Кнопки панели и живые уровни звука. `onStop` — после остановки. */ 146 +/** Рисует «волну» последней минуты: новые точки справа. */ 147 +function drawWave(canvas: HTMLCanvasElement): void { 148 + const w = canvas.clientWidth; 149 + if (!w) return; 150 + const dpr = window.devicePixelRatio || 1; 151 + if (canvas.width !== Math.round(w * dpr)) canvas.width = Math.round(w * dpr); 152 + const h = canvas.height; 153 + const ctx = canvas.getContext("2d"); 154 + if (!ctx) return; 155 + ctx.clearRect(0, 0, canvas.width, h); 156 + const css = getComputedStyle(canvas); 157 + const step = canvas.width / WAVE_POINTS; 158 + const bar = Math.max(1, step * 0.6); 159 + ctx.fillStyle = css.getPropertyValue("--color-border").trim() || "#ddd"; 160 + ctx.fillRect(0, h / 2, canvas.width, 1); 161 + ctx.fillStyle = css.getPropertyValue("--color-accent").trim() || "#a6533c"; 162 + const start = WAVE_POINTS - wave.length; 163 + wave.forEach((v, i) => { 164 + const bh = Math.max(1, v * (h - 4)); 165 + ctx.fillRect((start + i) * step, (h - bh) / 2, bar, bh); 166 + }); 167 +} 168 + 169 +/** Кнопки панели, живая «волна», время и отметки. `onStop` — после остановки. */ 64 170 export function bindRecording(root: HTMLElement, scope: Scope, onStop: () => void): void { 65 − root.querySelector<HTMLButtonElement>("[data-rec-stop]")?.addEventListener("click", async (e) => { 66 − const b = e.currentTarget as HTMLButtonElement; 67 − b.disabled = true; 171 + const stop = async (b: HTMLButtonElement, thenTranscribe: boolean) => { 172 + const id = state.recording?.meeting_id; 173 + root.querySelectorAll<HTMLButtonElement>("[data-rec-stop], [data-rec-stop-transcribe]").forEach((x) => (x.disabled = true)); 68 174 b.textContent = "Останавливаю…"; 69 175 try { 70 176 await api.recorderStop(); 71 177 await refreshRecording(); 178 + if (thenTranscribe && id) void jobs.transcribe(id); 72 179 onStop(); 73 180 } catch (err) { 74 − b.disabled = false; 181 + root.querySelectorAll<HTMLButtonElement>("[data-rec-stop], [data-rec-stop-transcribe]").forEach((x) => (x.disabled = false)); 75 182 toast(err); 76 183 } 77 − }); 184 + }; 185 + root.querySelector<HTMLButtonElement>("[data-rec-stop]")?.addEventListener("click", (e) => stop(e.currentTarget as HTMLButtonElement, false)); 186 + root 187 + .querySelector<HTMLButtonElement>("[data-rec-stop-transcribe]") 188 + ?.addEventListener("click", (e) => stop(e.currentTarget as HTMLButtonElement, true)); 78 189 root.querySelector("[data-rec-pause]")?.addEventListener("click", async () => { 79 190 if (!state.recording) return; 80 191 try { 85 196 toast(err); 86 197 } 87 198 }); 88 − // Один общий уровень: громче из дорожек за последние полсекунды. Человеку 89 − // важно одно — запись идёт и звук есть; делить его на «вы» и «собеседники» 90 − // незачем (решение владельца 01.10.2026). 91 − const last: Record<string, { dbfs: number; at: number }> = {}; 92 − scope.on( 93 − events.recorder(({ meeting_id, event }) => { 94 − // Уровни проверки звука (пустой meeting_id) — не для панели записи. 95 − if (!meeting_id) return; 96 − if (event.kind === "level") { 97 − const now = performance.now(); 98 − last[event.data.speaker.kind] = { dbfs: event.data.dbfs, at: now }; 99 − const dbfs = Math.max(-90, ...Object.values(last).filter((l) => now - l.at < 500).map((l) => l.dbfs)); 100 − const fill = root.querySelector<HTMLElement>("[data-level]"); 101 − if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((dbfs + 60) / 55) * 100))}%`; 102 − } else if (event.kind === "track_failed") { 103 − toast(`Дорожка «${event.data.speaker.kind === "me" ? "вы" : "собеседники"}» перестала писаться: ${event.data.error}`); 199 + root.querySelector("[data-rec-mark]")?.addEventListener("click", async () => { 200 + const id = state.recording?.meeting_id; 201 + if (!id) return; 202 + try { 203 + const m = await api.addMarker(id, null); 204 + marks = m.markers; 205 + toast(`Отмечено: ${clock(marks.length ? Math.max(...marks.map((k) => k.ms)) : 0)}`, "ok"); 206 + changed(); 207 + // Сразу можно подписать: фокус в поле новой отметки. 208 + requestAnimationFrame(() => { 209 + const last = [...document.querySelectorAll<HTMLInputElement>("[data-mark-text]")].pop(); 210 + last?.focus(); 211 + }); 212 + } catch (err) { 213 + toast(err); 214 + } 215 + }); 216 + root.querySelectorAll<HTMLInputElement>("[data-mark-text]").forEach((input) => 217 + input.addEventListener("change", async () => { 218 + const id = state.recording?.meeting_id; 219 + if (!id) return; 220 + marks[Number(input.dataset.markText)] = { ...marks[Number(input.dataset.markText)], text: input.value }; 221 + try { 222 + marks = (await api.setMarkers(id, marks)).markers; 223 + } catch (err) { 224 + toast(err); 104 225 } 105 226 }), 106 227 ); 107 − scope.every(1000, () => { 228 + 229 + // Живое: «волна», время, размер, что слышно — 5 раз в секунду. 230 + const tick = () => { 231 + const r = state.recording; 232 + if (!r) return; 233 + const canvas = root.querySelector<HTMLCanvasElement>("[data-wave]"); 234 + if (canvas) drawWave(canvas); 235 + const pos = Math.max(positionMs, r.position_ms ?? 0); 108 236 const t = root.querySelector("[data-timer]"); 109 − if (t && state.recording) t.textContent = elapsed(state.recording.started_at); 110 − }); 237 + if (t) t.textContent = clock(pos); 238 + const size = root.querySelector("[data-size]"); 239 + if (size) size.textContent = sizeText(pos, r.system ? 2 : 1); 240 + const now = performance.now(); 241 + for (const tr of ["me", "others"] as Track[]) { 242 + const el = root.querySelector<HTMLElement>(`[data-src="${tr}"]`); 243 + if (!el || el.classList.contains("is-off")) continue; 244 + const ok = !r.paused && now - heardAt[tr] < 3_000; 245 + el.classList.toggle("is-ok", ok); 246 + el.querySelector("[data-src-state]")!.textContent = r.paused ? "пауза" : ok ? "слышно" : "тишина"; 247 + } 248 + }; 249 + tick(); 250 + scope.every(200, tick); 251 + scope.on( 252 + events.recorder(({ meeting_id, event }) => { 253 + if (!meeting_id || event.kind !== "track_failed") return; 254 + toast(`Дорожка «${event.data.speaker.kind === "me" ? "микрофон" : "звук компьютера"}» перестала писаться: ${event.data.error}`); 255 + }), 256 + ); 111 257 } -
src/styles/app.css +56 −3
Показать разницуСкрыть разницу
212 212 .live-wait { padding: 4px 0; } 213 213 214 214 /* ── Запись идёт ── */ 215 −.rec { display: grid; gap: 14px; justify-items: start; width: 100%; } 216 −.rec-state { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); } 215 +.rec { display: grid; gap: 16px; width: 100%; } 216 +.rec-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; } 217 +.rec-badge { 218 + display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px; 219 + font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; 220 + color: var(--color-accent-text); border: 1px solid var(--color-accent); 221 +} 222 +.rec-badge.is-paused { color: var(--color-text-secondary); border-color: var(--color-border-light); } 217 223 .rec-timer { font-size: var(--text-6xl); font-weight: 700; line-height: 1; } 224 +.rec-wave { display: block; width: 100%; height: 72px; background: var(--color-bg-secondary); } 225 +.rec-sources { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px; } 226 +.src { display: inline-flex; align-items: center; gap: 8px; font-size: var(--text-sm); } 227 +.src b { font-weight: 400; color: var(--color-text-secondary); } 228 +.src.is-ok .dot { background: var(--color-accent); } 229 +.src.is-ok b { color: var(--color-accent-text); font-weight: 700; } 230 +.src.is-off .dot { background: var(--color-error); } 231 +.src.is-off b { color: var(--color-error); } 232 +.rec-size { margin-left: auto; } 233 +.rec-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; } 234 +.rec-mark-btn { border-color: var(--color-accent); color: var(--color-accent-text); } 235 +.rec-marks { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; } 236 +.rec-marks li { display: grid; grid-template-columns: 72px 1fr; align-items: center; gap: 12px; } 237 +.rec-marks .input { padding: 4px 8px; font-size: var(--text-sm); } 218 238 .meter { width: min(420px, 100%); } 219 239 .meter .meter-bar { display: block; } 220 240 .meter-bar { height: 8px; background: var(--color-bg-secondary); overflow: hidden; } 285 305 .tr-row { display: grid; grid-template-columns: 72px 1fr; gap: 16px; padding: 10px 0; border-top: 1px solid var(--kv-line-2); } 286 306 .tr-row:first-child { border-top: 0; padding-top: 0; } 287 307 .tr-time { font-size: var(--text-xs); color: var(--color-text-secondary); padding-top: 3px; } 308 +button.tr-time { 309 + justify-self: start; align-self: start; height: fit-content; padding: 3px 0 0; margin: 0; 310 + font-family: var(--font-mono); background: none; border: 0; cursor: pointer; text-align: left; 311 +} 312 +button.tr-time:hover { color: var(--color-accent-text); text-decoration: underline; text-underline-offset: 3px; } 313 +.tr-row.is-playing { box-shadow: inset 2px 0 0 var(--color-accent); background: var(--color-bg-hover); } 314 +.tr-row.is-playing .tr-time { color: var(--color-accent-text); } 315 +.tr-mark .tr-who { color: var(--kv-ink); } 316 +.tr-mark p { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; } 288 317 .tr-text { display: grid; gap: 2px; } 289 318 .tr-who { font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text); } 290 319 .tr-who.is-me { color: var(--color-text-primary); } 300 329 301 330 /* Следующий шаг под расшифровкой */ 302 331 .next { 303 − position: sticky; bottom: -80px; z-index: 1; 304 332 display: flex; flex-wrap: wrap; align-items: center; gap: 8px; 305 333 padding: 12px 24px; border-top: 1px solid var(--color-border); background: var(--color-bg-secondary); 306 334 } 307 335 .next-label { margin-right: 8px; font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-secondary); } 308 336 337 +/* Проигрыватель — полоса внизу экрана записи */ 338 +.player { 339 + position: sticky; bottom: -80px; z-index: 3; 340 + display: flex; align-items: center; gap: 10px; 341 + max-width: 860px; margin: 20px auto 0; padding: 10px 14px; 342 + background: var(--color-bg-elevated); border: 1px solid var(--color-border); 343 + box-shadow: 0 -4px 16px rgba(10, 10, 10, 0.06); 344 +} 345 +.player-btn { 346 + flex: none; min-width: 34px; height: 32px; padding: 0 8px; 347 + font: inherit; font-size: var(--text-sm); color: var(--kv-ink); 348 + background: transparent; border: 1px solid var(--color-border-light); cursor: pointer; 349 +} 350 +.player-btn:hover { background: var(--color-bg-hover); } 351 +.player-play { min-width: 40px; border: 2px solid var(--kv-ink); font-weight: 700; } 352 +.player-mark { color: var(--color-accent-text); border-color: var(--color-accent); } 353 +.player-time { flex: none; font-size: var(--text-xs); color: var(--color-text-secondary); } 354 +.player-wave { flex: 1; min-width: 80px; height: 40px; cursor: pointer; touch-action: none; } 355 +.player-rate { flex: none; width: auto; padding: 4px 6px; font-size: var(--text-xs); } 356 + 309 357 /* Markdown результата */ 310 358 .md { display: grid; gap: 12px; max-width: 72ch; } 311 359 .md > * { margin: 0; } 338 386 } 339 387 .toast.is-error { background: var(--color-error); color: #fff; } 340 388 389 +@media (max-width: 700px) { 390 + .player { flex-wrap: wrap; } 391 + .player-wave { order: 10; flex-basis: 100%; } 392 +} 393 + 341 394 @media (max-width: 900px) { 342 395 .main { padding: 24px 20px 64px; } 343 396 .bar { top: -24px; margin: -24px -20px 0; padding: 16px 20px 10px; } -
src/views/meeting.ts +108 −10
Показать разницуСкрыть разницу
7 7 8 8 import { 9 9 api, formatDate, formatDuration, meetingDuration, 10 − type Command, type MeetingDetail, 10 + type Command, type Marker, type MeetingDetail, 11 11 } from "../api"; 12 12 import { $, esc } from "../dom"; 13 13 import { plain, renameSpeaker, render, sectionBody, sections, speakerNameProblem, toggleTask, upsertSection } from "../markdown"; 14 −import { bindRecording, recordingPanel } from "../recording"; 14 +import { bindRecording, clock, recordingPanel } from "../recording"; 15 15 import type { Scope } from "../scope"; 16 16 import * as jobs from "../jobs"; 17 +import { createPlayer, type Player } from "../player"; 17 18 import { refreshNav, setCrumb } from "../nav"; 18 19 import { copy, onChange, state, toast } from "../state"; 19 20 83 84 * Расшифровка — по репликам: время слева, кто говорит и текст справа, как у 84 85 * MacWhisper. Если человек переписал её по-своему — показываем как Markdown. 85 86 */ 86 −function transcriptView(body: string): string { 87 +function transcriptView(body: string, markers: Marker[] = []): string { 87 88 const paras = body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean); 88 − const rows = transcriptRows(paras, true); 89 + const rows = transcriptRows(paras, true, markers); 89 90 return rows === null ? `<div class="md">${render(body)}</div>` : `<div class="tr">${rows}</div>`; 90 91 } 91 92 93 +/** «00:03:21» → мс. */ 94 +function msOf(time: string): number { 95 + const [h, m, s] = time.split(":").map(Number); 96 + return ((h * 60 + m) * 60 + s) * 1000; 97 +} 98 + 99 +function markerRow(m: Marker, index: number): string { 100 + return ` 101 + <div class="tr-row tr-mark" data-row-ms="${m.ms}"> 102 + <button type="button" class="tr-time mono-num" data-seek-ms="${m.ms}" title="Слушать с этого места">${clock(m.ms)}</button> 103 + <div class="tr-text"> 104 + <span class="tr-who">★ Отметка</span> 105 + <p>${m.text ? esc(m.text) : `<span class="muted">без подписи</span>`} 106 + <button type="button" class="link-btn link-danger xs" data-mark-del="${index}" title="Убрать отметку">убрать</button></p> 107 + </div> 108 + </div>`; 109 +} 110 + 92 111 /** Реплики строками таблицы; `null` — если хоть одна не похожа на реплику. */ 93 −function transcriptRows(paras: string[], renamable = false): string | null { 112 +function transcriptRows(paras: string[], renamable = false, markers: Marker[] = []): string | null { 94 113 const rows = paras.map((p) => LINE.exec(p)); 95 114 if (!rows.length || rows.some((r) => !r)) return paras.length ? null : ""; 96 − return rows 115 + // Отметки встают между репликами по времени. 116 + const marks = markers.map((m, i) => ({ m, i })).sort((a, b) => a.m.ms - b.m.ms); 117 + let next = 0; 118 + const before = (ms: number) => { 119 + let out = ""; 120 + while (next < marks.length && marks[next].m.ms < ms) { 121 + out += markerRow(marks[next].m, marks[next].i); 122 + next++; 123 + } 124 + return out; 125 + }; 126 + const body = rows 97 127 .map((r) => { 98 128 const [, time, who, text] = r!; 99 − return ` 100 − <div class="tr-row"> 101 − <span class="tr-time mono-num">${esc(time)}</span> 129 + const ms = msOf(time); 130 + return `${before(ms)} 131 + <div class="tr-row" data-row-ms="${ms}"> 132 + <button type="button" class="tr-time mono-num" data-seek-ms="${ms}" title="Слушать с этого места">${esc(time)}</button> 102 133 <div class="tr-text"> 103 134 ${ 104 135 !who 112 143 </div>`; 113 144 }) 114 145 .join(""); 146 + return body + before(Number.POSITIVE_INFINITY); 115 147 } 116 148 117 149 export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise<void> { 294 326 } 295 327 </div> 296 328 ${errorLine} 297 − <div class="pane-body" data-view>${isTranscript ? transcriptView(body) : `<div class="md">${render(body)}</div>`}</div> 329 + <div class="pane-body" data-view>${isTranscript ? transcriptView(body, d.meeting.markers ?? []) : `<div class="md">${render(body)}</div>`}</div> 298 330 ${isTranscript ? nextStep() : ""}`; 299 331 }; 300 332 342 374 </footer> 343 375 </article>`; 344 376 bind(); 377 + // Проигрыватель живёт дольше перерисовок: его полоса возвращается на место, 378 + // звук не прерывается. 379 + ensurePlayer(); 380 + if (player) root.appendChild(player.el); 381 + highlight(player?.current() ?? -1, true); 382 + }; 383 + 384 + // ── Проигрыватель ───────────────────────────────────────────────────────── 385 + let player: Player | null = null; 386 + let playerLoading = false; 387 + let playing = -1; 388 + const ensurePlayer = () => { 389 + if (player || playerLoading || !d.meeting.audio.length || d.meeting.recording) return; 390 + playerLoading = true; 391 + createPlayer(id, d.meeting.markers ?? [], (m) => { 392 + d.meeting = m; 393 + draw(); 394 + }) 395 + .then((p) => { 396 + if (!scope.alive) { 397 + p.destroy(); 398 + return; 399 + } 400 + player = p; 401 + root.appendChild(p.el); 402 + p.onTime((ms) => highlight(ms)); 403 + }) 404 + .catch(() => { 405 + // Нет файлов звука (запись старой версии, папку чистили) — без проигрывателя. 406 + }) 407 + .finally(() => (playerLoading = false)); 408 + }; 409 + scope.on(Promise.resolve(() => player?.destroy())); 410 + 411 + /** Подсветить реплику, которая сейчас звучит. */ 412 + const highlight = (ms: number, force = false) => { 413 + const rows = [...root.querySelectorAll<HTMLElement>("[data-view] [data-row-ms]")]; 414 + let at = -1; 415 + for (let i = 0; i < rows.length && Number(rows[i].dataset.rowMs) <= ms; i++) at = i; 416 + if (at === playing && !force) return; 417 + playing = at; 418 + const started = (player?.current() ?? 0) > 0; 419 + rows.forEach((r, i) => r.classList.toggle("is-playing", started && i === at)); 345 420 }; 346 421 347 422 // ── Действия ────────────────────────────────────────────────────────────── 540 615 } 541 616 }); 542 617 618 + // Время реплики или отметки — слушать с этого места. 619 + root.querySelectorAll<HTMLElement>("[data-seek-ms]").forEach((b) => 620 + b.addEventListener("click", () => { 621 + if (!player) { 622 + toast("Проигрыватель ещё загружается"); 623 + return; 624 + } 625 + player.seek(Number(b.dataset.seekMs), true); 626 + }), 627 + ); 628 + root.querySelectorAll<HTMLElement>("[data-mark-del]").forEach((b) => 629 + b.addEventListener("click", async () => { 630 + const list = (d.meeting.markers ?? []).filter((_, i) => i !== Number(b.dataset.markDel)); 631 + try { 632 + d.meeting = await api.setMarkers(id, list); 633 + player?.setMarkers(d.meeting.markers); 634 + draw(); 635 + } catch (e) { 636 + toast(e); 637 + } 638 + }), 639 + ); 640 + 543 641 root.querySelector("[data-open-folder]")?.addEventListener("click", () => api.openFolder(id).catch(toast)); 544 642 545 643 root.querySelector("[data-delete]")?.addEventListener("click", async () => {