skillok meeting

Экран записи и проигрыватель: волна, источники, отметки, звук ↔ текст

Запись: - Время — по записанному звуку, паузы не в счёт (позиция сессии записи, 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>

anc · 2 октября 2026 г. b82e2594557ceeb893aa31f42f70a3ed15c1add1
16 файлов
  • 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 ## Как это устроено
  • Показать разницуСкрыть разницу
    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" }
  • Показать разницуСкрыть разницу
    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 +}
  • Показать разницуСкрыть разницу
    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 () => {