Проверка звука до записи и предупреждение, если дорожка молчит
Встреча, которая молча не записалась, — худшее для программы: человек узнаёт об этом после встречи. - «Новая запись»: проверка звука — микрофон и звук компьютера открываются без записи в файл (audio_check_start/stop), две живые полоски «слышно / тишина». Устройство не открылось — причина и где разрешить (на Mac — «Запись экрана и системного звука»). - Во время записи: дорожка минуту тише −60 dBFS — событие silent, сообщение и строка на панели записи; звук вернулся — heard, строка уходит. На паузе тишина не считается; звук компьютера, который в тишине не приходит вовсе, тоже ловится. - Начало записи гасит проверку: устройства не заняты дважды. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a730a5c
· сравнить с родителем
-
src-tauri/src/commands.rs +81 −1
Показать разницуСкрыть разницу
45 45 /// Модель голосов (WeSpeaker, ONNX) из ресурсов программы. Нет файла — 46 46 /// расшифровка идёт без разбора по голосам. 47 47 pub voices_model: PathBuf, 48 + /// Проверка звука на экране «Новая запись»: дорожки без файла, только уровни. 49 + pub check: Mutex<Option<Session>>, 48 50 } 49 51 50 52 impl AppState { 622 624 if slot.is_some() { 623 625 return Err("Запись уже идёт".into()); 624 626 } 627 + // Проверка звука держит те же устройства — гасим её до записи. 628 + stop_check(&state); 625 629 let meeting = state 626 630 .store() 627 631 .create(&title_now("Встреча"), Local::now()) 639 643 (Ok(capture), Ok(path)) => tracks.push(Track { 640 644 speaker, 641 645 capture, 642 − path, 646 + path: Some(path), 643 647 }), 644 648 (Err(e), _) => errors.push(e.to_string()), 645 649 (_, Err(e)) => errors.push(e), 688 692 Ok(meeting) 689 693 } 690 694 695 +// ── Проверка звука до записи ──────────────────────────────────────────────── 696 + 697 +#[derive(Serialize)] 698 +pub struct CheckView { 699 + /// Почему не открылся микрофон. 700 + mic_error: Option<String>, 701 + /// Почему не открылся звук компьютера — подсказать, где разрешить. 702 + system_error: Option<String>, 703 +} 704 + 705 +/// Открыть микрофон и звук компьютера без записи: интерфейс показывает две 706 +/// живые полоски, и человек видит, что всё слышно, до начала встречи. Уровни 707 +/// приходят тем же событием `meet://recorder` с пустым `meeting_id`. 708 +#[tauri::command] 709 +pub fn audio_check_start(app: AppHandle, state: State<'_, AppState>) -> Result<CheckView, String> { 710 + if !meet_audio::CAPTURE_SUPPORTED { 711 + return Err("На этой системе запись не поддерживается".into()); 712 + } 713 + if state 714 + .recording 715 + .lock() 716 + .unwrap_or_else(|p| p.into_inner()) 717 + .is_some() 718 + { 719 + return Err("Запись уже идёт".into()); 720 + } 721 + stop_check(&state); 722 + let mut tracks = Vec::new(); 723 + let mut view = CheckView { 724 + mic_error: None, 725 + system_error: None, 726 + }; 727 + match meet_audio::open_microphone() { 728 + Ok(capture) => tracks.push(Track { 729 + speaker: Speaker::Me, 730 + capture, 731 + path: None, 732 + }), 733 + Err(e) => view.mic_error = Some(e.to_string()), 734 + } 735 + match meet_audio::open_system_audio() { 736 + Ok(capture) => tracks.push(Track { 737 + speaker: Speaker::Others, 738 + capture, 739 + path: None, 740 + }), 741 + Err(e) => view.system_error = Some(e.to_string()), 742 + } 743 + if !tracks.is_empty() { 744 + let session = Session::start( 745 + tracks, 746 + Arc::new(move |e: recorder::Event| { 747 + let _ = app.emit( 748 + "meet://recorder", 749 + serde_json::json!({ "meeting_id": "", "event": e }), 750 + ); 751 + }), 752 + )?; 753 + *state.check.lock().unwrap_or_else(|p| p.into_inner()) = Some(session); 754 + } 755 + Ok(view) 756 +} 757 + 758 +#[tauri::command] 759 +pub fn audio_check_stop(state: State<'_, AppState>) { 760 + stop_check(&state); 761 +} 762 + 763 +fn stop_check(state: &AppState) { 764 + let check = state.check.lock().unwrap_or_else(|p| p.into_inner()).take(); 765 + if let Some(session) = check { 766 + // Потоки дорожек выходят за ~50 мс: ждать можно и здесь. 767 + session.stop(); 768 + } 769 +} 770 + 691 771 #[tauri::command] 692 772 pub fn recorder_pause(state: State<'_, AppState>, paused: bool) -> Result<(), String> { 693 773 match state.recording.lock().unwrap_or_else(|p| p.into_inner()).as_ref() { -
Показать разницуСкрыть разницу
54 54 recording: Mutex::new(None), 55 55 jobs: Mutex::new(HashMap::new()), 56 56 voices_model, 57 + check: Mutex::new(None), 57 58 }); 58 59 Ok(()) 59 60 }) 77 78 commands::regroup_speakers, 78 79 commands::run_command, 79 80 commands::recorder_status, 81 + commands::audio_check_start, 82 + commands::audio_check_stop, 80 83 commands::recorder_start, 81 84 commands::recorder_pause, 82 85 commands::recorder_stop, -
src-tauri/src/recorder.rs +106 −22
Показать разницуСкрыть разницу
4 4 //! 5 5 //! Заголовок WAV дописывается каждые несколько секунд, поэтому после сбоя 6 6 //! записанное читается. 7 +//! 8 +//! Встреча, которая молча не записалась, — худшее, что может случиться с 9 +//! программой: человек узнаёт об этом, когда уже поздно. Поэтому: 10 +//! - до записи — проверка звука: те же дорожки без файла, только уровни 11 +//! (`path: None`); 12 +//! - во время записи — если дорожка [`SILENCE_AFTER`] не слышит ничего, 13 +//! приходит [`Event::Silent`], а когда звук вернулся — [`Event::Heard`]. 7 14 8 15 use std::path::PathBuf; 9 16 use std::sync::atomic::{AtomicBool, Ordering}; 19 26 pub struct Track { 20 27 pub speaker: Speaker, 21 28 pub capture: Box<dyn Capture>, 22 − pub path: PathBuf, 29 + /// Куда писать; `None` — проверка звука, только уровни. 30 + pub path: Option<PathBuf>, 23 31 } 24 32 33 +/// Тише этого — «ничего не слышно»: и речь, и шум комнаты в живом микрофоне 34 +/// громче; выключенный микрофон и молчащий звук компьютера — тише. 35 +pub const SILENCE_DBFS: f32 = -60.0; 36 +/// Сколько молчания подряд — повод сказать человеку. 37 +pub const SILENCE_AFTER: Duration = Duration::from_secs(60); 38 + 25 39 #[derive(Debug, Clone, PartialEq, serde::Serialize)] 26 40 #[serde(tag = "kind", content = "data", rename_all = "snake_case")] 27 41 pub enum Event { 29 43 Level { speaker: Speaker, dbfs: f32 }, 30 44 /// Дорожка перестала писаться; остальные продолжают. 31 45 TrackFailed { speaker: Speaker, error: String }, 46 + /// Дорожка `seconds` секунд не слышит ничего (на паузе не считается). 47 + Silent { speaker: Speaker, seconds: u64 }, 48 + /// Звук на дорожке снова есть. 49 + Heard { speaker: Speaker }, 32 50 } 33 51 34 52 pub type Emit = Arc<dyn Fn(Event) + Send + Sync>; 49 67 50 68 impl Session { 51 69 pub fn start(tracks: Vec<Track>, emit: Emit) -> Result<Self, String> { 70 + Self::start_with(tracks, emit, SILENCE_AFTER) 71 + } 72 + 73 + /// То же, с другим порогом тишины — для тестов. 74 + pub fn start_with(tracks: Vec<Track>, emit: Emit, silence_after: Duration) -> Result<Self, String> { 52 75 let stop = Arc::new(AtomicBool::new(false)); 53 76 let paused = Arc::new(AtomicBool::new(false)); 54 77 let mut threads = Vec::new(); 55 78 for track in tracks { 56 − let writer = 57 − WavWriter::create(&track.path).map_err(|e| format!("{}: {e}", track.path.display()))?; 79 + let writer = match &track.path { 80 + Some(path) => Some(WavWriter::create(path).map_err(|e| format!("{}: {e}", path.display()))?), 81 + None => None, 82 + }; 58 83 let (stop, paused, emit) = (stop.clone(), paused.clone(), emit.clone()); 59 84 threads.push( 60 85 std::thread::Builder::new() 61 86 .name(format!("meet-track-{}", track.speaker.track_file())) 62 − .spawn(move || run_track(track, writer, stop, paused, emit)) 87 + .spawn(move || run_track(track, writer, stop, paused, emit, silence_after)) 63 88 .map_err(|e| e.to_string())?, 64 89 ); 65 90 } 92 117 93 118 fn run_track( 94 119 mut track: Track, 95 − mut writer: WavWriter, 120 + mut writer: Option<WavWriter>, 96 121 stop: Arc<AtomicBool>, 97 122 paused: Arc<AtomicBool>, 98 123 emit: Emit, 124 + silence_after: Duration, 99 125 ) -> Option<Written> { 100 126 let speaker = track.speaker.clone(); 101 127 let mut level_buf: Vec<i16> = Vec::new(); 102 128 let mut last_level = Instant::now(); 103 129 let mut last_flush = Instant::now(); 130 + // Когда дорожка в последний раз что-то слышала и сказали ли уже человеку. 131 + let mut last_heard = Instant::now(); 132 + let mut warned = false; 104 133 while !stop.load(Ordering::Relaxed) { 105 134 let samples = match track.capture.read() { 106 135 Ok(s) => s, 114 143 } 115 144 }; 116 145 // На паузе звук читается, но не пишется: иначе он копился бы в устройстве. 117 − if samples.is_empty() || paused.load(Ordering::Relaxed) { 146 + // И молчание на паузе — не повод для тревоги. 147 + if paused.load(Ordering::Relaxed) { 148 + last_heard = Instant::now(); 118 149 continue; 119 150 } 120 − if writer.write(&samples).is_err() { 121 − emit(Event::TrackFailed { 122 − speaker: speaker.clone(), 123 − error: "не удалось записать на диск".into(), 124 − }); 125 − break; 126 − } 127 − if last_flush.elapsed() >= Duration::from_secs(5) { 128 − let _ = writer.flush(); 129 − last_flush = Instant::now(); 151 + // Звук компьютера в тишине может не приходить вовсе: проверка тишины — 152 + // и на пустом чтении. 153 + if !samples.is_empty() { 154 + if let Some(w) = writer.as_mut() { 155 + if w.write(&samples).is_err() { 156 + emit(Event::TrackFailed { 157 + speaker: speaker.clone(), 158 + error: "не удалось записать на диск".into(), 159 + }); 160 + break; 161 + } 162 + if last_flush.elapsed() >= Duration::from_secs(5) { 163 + let _ = w.flush(); 164 + last_flush = Instant::now(); 165 + } 166 + } 167 + level_buf.extend_from_slice(&samples); 130 168 } 131 − level_buf.extend_from_slice(&samples); 132 169 if last_level.elapsed() >= Duration::from_millis(200) { 170 + let dbfs = if level_buf.is_empty() { 171 + -90.0 172 + } else { 173 + pcm::level_dbfs(&level_buf) 174 + }; 133 175 emit(Event::Level { 134 176 speaker: speaker.clone(), 135 − dbfs: pcm::level_dbfs(&level_buf), 177 + dbfs, 136 178 }); 179 + if dbfs > SILENCE_DBFS { 180 + last_heard = Instant::now(); 181 + if warned { 182 + warned = false; 183 + emit(Event::Heard { 184 + speaker: speaker.clone(), 185 + }); 186 + } 187 + } else if !warned && last_heard.elapsed() >= silence_after { 188 + warned = true; 189 + emit(Event::Silent { 190 + speaker: speaker.clone(), 191 + seconds: last_heard.elapsed().as_secs(), 192 + }); 193 + } 137 194 level_buf.clear(); 138 195 last_level = Instant::now(); 139 196 } 140 197 } 141 − let _ = writer.flush(); 142 198 emit(Event::Level { 143 199 speaker: speaker.clone(), 144 200 dbfs: -90.0, 145 201 }); 202 + let mut writer = writer?; 203 + let _ = writer.flush(); 146 204 Some(Written { 147 205 speaker, 148 206 duration_ms: writer.duration_ms(), 174 232 Track { 175 233 speaker: Speaker::Me, 176 234 capture: Box::new(Replay::new(vec![500; ms_to_samples(2_000)], 40.0)), 177 − path: me.clone(), 235 + path: Some(me.clone()), 178 236 }, 179 237 Track { 180 238 speaker: Speaker::Others, 181 239 capture: Box::new(Broken), 182 − path: dir.path().join("others.wav"), 240 + path: Some(dir.path().join("others.wav")), 183 241 }, 184 242 ], 185 243 Arc::new(move |e| sink.lock().unwrap().push(e)), 217 275 vec![Track { 218 276 speaker: Speaker::Me, 219 277 capture: Box::new(Replay::new(vec![500; ms_to_samples(60_000)], 1.0)), 220 − path: dir.path().join("me.wav"), 278 + path: Some(dir.path().join("me.wav")), 221 279 }], 222 280 Arc::new(|_| {}), 223 281 ) 226 284 std::thread::sleep(Duration::from_millis(300)); 227 285 assert_eq!(session.stop()[0].duration_ms, 0, "на паузе ничего не записано"); 228 286 } 287 + 288 + #[test] 289 + fn long_silence_is_reported_and_cleared_when_sound_returns() { 290 + // 0,6 с тишины, потом звук: при пороге 0,3 с — «не слышно», потом «слышно». 291 + let mut audio = vec![0i16; ms_to_samples(600)]; 292 + audio.extend(vec![3_000i16; ms_to_samples(600)]); 293 + let events = Arc::new(Mutex::new(Vec::new())); 294 + let sink = events.clone(); 295 + let session = Session::start_with( 296 + vec![Track { 297 + speaker: Speaker::Others, 298 + capture: Box::new(Replay::new(audio, 1.0)), 299 + path: None, 300 + }], 301 + Arc::new(move |e| sink.lock().unwrap().push(e)), 302 + Duration::from_millis(300), 303 + ) 304 + .unwrap(); 305 + std::thread::sleep(Duration::from_millis(1_400)); 306 + let written = session.stop(); 307 + assert!(written.is_empty(), "проверка звука ничего не пишет"); 308 + let events = events.lock().unwrap(); 309 + let silent = events.iter().position(|e| matches!(e, Event::Silent { .. })); 310 + let heard = events.iter().position(|e| matches!(e, Event::Heard { .. })); 311 + assert!(silent.is_some() && heard > silent, "{events:?}"); 312 + } 229 313 } -
src/api.ts +11 −1
Показать разницуСкрыть разницу
65 65 66 66 export type RecorderEvent = 67 67 | { kind: "level"; data: { speaker: Speaker; dbfs: number } } 68 − | { kind: "track_failed"; data: { speaker: Speaker; error: string } }; 68 + | { kind: "track_failed"; data: { speaker: Speaker; error: string } } 69 + /** Дорожка давно ничего не слышит (на паузе не считается). */ 70 + | { kind: "silent"; data: { speaker: Speaker; seconds: number } } 71 + /** Звук на дорожке снова есть. */ 72 + | { kind: "heard"; data: { speaker: Speaker } }; 73 + 74 +/** Проверка звука до записи: что не открылось и почему. */ 75 +export interface CheckView { mic_error: string | null; system_error: string | null } 69 76 70 77 type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>; 71 78 type Listen = <T>(event: string, cb: (payload: T) => void) => Promise<() => void>; 163 170 call<{ note: string; voices: number }>("regroup_speakers", { id, count }), 164 171 runCommand: (id: string, command: Command) => call<CommandResult>("run_command", { id, command }), 165 172 recorderStatus: () => call<RecordingView | null>("recorder_status"), 173 + /** Открыть микрофон и звук компьютера без записи — только уровни (`meeting_id` событий пустой). */ 174 + audioCheckStart: () => call<CheckView>("audio_check_start"), 175 + audioCheckStop: () => call<void>("audio_check_stop"), 166 176 recorderStart: () => call<Meeting>("recorder_start"), 167 177 recorderPause: (paused: boolean) => call<void>("recorder_pause", { paused }), 168 178 recorderStop: () => call<Meeting>("recorder_stop"), -
src/dev-mock.ts +23
Показать разницуСкрыть разницу
80 80 } 81 81 82 82 const other: Speaker = { kind: "unknown" }; 83 +let checkTimer = 0; 83 84 add("Планёрка команды", 60 * 26, `${TRANSCRIPT}\n${ANSWERS.tasks}`, [{ file: "Планёрка.m4a", speaker: other, duration_ms: 195_000 }], true); 84 85 add("Созвон с клиентом", 90, "", [{ file: "Созвон с клиентом.m4a", speaker: other, duration_ms: 2_760_000 }], false); 85 86 246 247 } 247 248 return { note: upsert(d, heading, block), heading } as T; 248 249 } 250 + case "audio_check_start": { 251 + if (!captureSupported) throw "На этой системе запись не поддерживается"; 252 + window.clearInterval(checkTimer); 253 + const began = Date.now(); 254 + // Микрофон «слышит» сразу, звук компьютера — через 3 секунды, как если 255 + // бы человек включил видео. 256 + checkTimer = window.setInterval(() => { 257 + const others = Date.now() - began > 3_000; 258 + for (const [speaker, on] of [[{ kind: "me" }, true], [{ kind: "others" }, others]] as [Speaker, boolean][]) { 259 + const dbfs = on ? -40 + Math.random() * 25 : -90; 260 + emit("meet://recorder", { meeting_id: "", event: { kind: "level", data: { speaker, dbfs } } }); 261 + } 262 + }, 200); 263 + return { mic_error: null, system_error: null } as T; 264 + } 265 + case "audio_check_stop": 266 + window.clearInterval(checkTimer); 267 + return undefined as T; 249 268 case "recorder_status": 250 269 return (recording && { ...recording, timer: undefined }) as T; 251 270 case "recorder_start": { 255 274 const m = add(`Встреча ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, 0, "", [], false); 256 275 m.meeting.recording = true; 257 276 const r = { meeting_id: m.meeting.id, started_at: d.toISOString(), paused: false, system: true, system_error: null, timer: 0 }; 277 + window.clearInterval(checkTimer); 278 + // Собеседники «молчат» с 6-й по 12-ю секунду — видно предупреждение о тишине. 279 + window.setTimeout(() => emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "silent", data: { speaker: { kind: "others" }, seconds: 60 } } }), 6_000); 280 + window.setTimeout(() => emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "heard", data: { speaker: { kind: "others" } } } }), 12_000); 258 281 r.timer = window.setInterval(() => { 259 282 for (const speaker of [{ kind: "me" }, { kind: "others" }] as Speaker[]) { 260 283 const dbfs = r.paused ? -90 : -60 + Math.random() * 45; -
src/main.ts +2
Показать разницуСкрыть разницу
5 5 import { renderNew } from "./views/new"; 6 6 import { renderSettings } from "./views/settings"; 7 7 import { startJobs } from "./jobs"; 8 +import { startSilenceWatch } from "./recording"; 8 9 import { refreshNav, setCrumb, startNav } from "./nav"; 9 10 import { errorText, files } from "./api"; 10 11 import { esc } from "./dom"; 40 41 await refreshRecording(); 41 42 startNav(); 42 43 startJobs(); 44 + startSilenceWatch(); 43 45 // Запись началась, встала на паузу или закончилась — шапка и меню меняются. 44 46 onChange(() => void refreshNav()); 45 47 // Аудиофайл, брошенный в окно с любого экрана, — новая запись. -
src/recording.ts +38 −2
Показать разницуСкрыть разницу
6 6 import type { Scope } from "./scope"; 7 7 import { changed, refreshRecording, state, toast } from "./state"; 8 8 9 +type Track = "me" | "others"; 10 + 11 +/** Дорожки, которые сейчас давно молчат. Живёт дольше экрана: человек мог 12 + * уйти в настройки, а предупреждение должно ждать его на панели записи. */ 13 +const silent = new Set<Track>(); 14 + 15 +const SILENT_TEXT: Record<Track, string> = { 16 + me: "Минуту не слышно вашего микрофона. Он выключен — в системе или в Zoom — или выбран не тот.", 17 + others: 18 + "Минуту не слышно собеседников. Если они говорят, а звука нет — звук компьютера не пишется: на Mac разрешите Skillok Meet «Запись экрана и системного звука» в настройках конфиденциальности.", 19 +}; 20 + 21 +/** Слушать тишину дорожек на всю программу: предупреждение — и сообщением, и на панели. */ 22 +export function startSilenceWatch(): void { 23 + void events.recorder(({ meeting_id, event }) => { 24 + if (!meeting_id || state.recording?.meeting_id !== meeting_id) return; 25 + if (event.kind !== "silent" && event.kind !== "heard") return; 26 + const track: Track = event.data.speaker.kind === "me" ? "me" : "others"; 27 + if (event.kind === "silent") { 28 + silent.add(track); 29 + toast(SILENT_TEXT[track]); 30 + } else { 31 + silent.delete(track); 32 + } 33 + changed(); 34 + }); 35 +} 36 + 37 +/** Новая запись — старые предупреждения не в счёт. */ 38 +export function resetSilence(): void { 39 + silent.clear(); 40 +} 41 + 9 42 export function recordingPanel(): string { 10 43 const r = state.recording; 11 44 if (!r) return ""; 15 48 r.paused ? "Запись на паузе" : r.system ? "Идёт запись: собеседники и вы" : "Идёт запись: только ваш микрофон" 16 49 }</p> 17 50 ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся: ${esc(r.system_error)}</p>` : ""} 51 + ${[...silent].map((t) => `<p class="rec-warn small" data-silent="${t}">${esc(SILENT_TEXT[t])}</p>`).join("")} 18 52 <p class="rec-timer mono-num" data-timer>${elapsed(r.started_at)}</p> 19 53 <div class="meter" title="Громкость того, что пишется: ваш микрофон и звук компьютера вместе"> 20 54 <span class="meter-bar"><span class="meter-fill" data-level></span></span> 56 90 // незачем (решение владельца 01.10.2026). 57 91 const last: Record<string, { dbfs: number; at: number }> = {}; 58 92 scope.on( 59 − events.recorder(({ event }) => { 93 + events.recorder(({ meeting_id, event }) => { 94 + // Уровни проверки звука (пустой meeting_id) — не для панели записи. 95 + if (!meeting_id) return; 60 96 if (event.kind === "level") { 61 97 const now = performance.now(); 62 98 last[event.data.speaker.kind] = { dbfs: event.data.dbfs, at: now }; 63 99 const dbfs = Math.max(-90, ...Object.values(last).filter((l) => now - l.at < 500).map((l) => l.dbfs)); 64 100 const fill = root.querySelector<HTMLElement>("[data-level]"); 65 101 if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((dbfs + 60) / 55) * 100))}%`; 66 − } else { 102 + } else if (event.kind === "track_failed") { 67 103 toast(`Дорожка «${event.data.speaker.kind === "me" ? "вы" : "собеседники"}» перестала писаться: ${event.data.error}`); 68 104 } 69 105 }), -
src/state.ts +1
Показать разницуСкрыть разницу
27 27 28 28 export async function startRecording(): Promise<void> { 29 29 try { 30 + (await import("./recording")).resetSilence(); 30 31 const m = await api.recorderStart(); 31 32 await refreshRecording(); 32 33 location.hash = `#/m/${encodeURIComponent(m.id)}`; -
Показать разницуСкрыть разницу
171 171 border-left: 2px solid var(--color-accent); background: var(--color-bg-secondary); color: var(--kv-body); 172 172 } 173 173 174 +.check { display: grid; gap: 10px; width: 100%; } 175 +.check-row { 176 + display: grid; grid-template-columns: 140px 1fr 90px; grid-template-areas: "title bar state" "hint hint hint"; 177 + align-items: center; gap: 4px 12px; 178 +} 179 +.check-title { grid-area: title; font-weight: 700; font-size: var(--text-sm); } 180 +.check-row .meter-bar { grid-area: bar; display: block; } 181 +.check-state { grid-area: state; font-size: var(--text-sm); color: var(--color-text-secondary); } 182 +.check-hint { grid-area: hint; } 183 +.check-row.is-ok .check-state { color: var(--color-accent-text); font-weight: 700; } 184 +.check-row.is-error .check-state, .check-row.is-error .check-hint { color: var(--color-error); } 185 + 174 186 /* ── Список записей на главной ── */ 175 187 .records { display: grid; gap: 12px; } 176 188 .records-head { display: flex; align-items: center; justify-content: space-between; } -
src/views/new.ts +69 −8
Показать разницуСкрыть разницу
1 1 // Экран «Новая запись»: «Записать встречу» ведёт сюда, а не запускает запись 2 −// сразу (решение владельца 01.10.2026). Здесь видно, что запишется, и совет 3 −// про наушники; запись начинается только кнопкой «Начать запись». 2 +// сразу (решение владельца 01.10.2026). Здесь проверка звука — две живые 3 +// полоски, микрофон и звук компьютера, — и совет про наушники; запись 4 +// начинается только кнопкой «Начать запись». 5 +// 6 +// Проверка — главная защита от встречи, которая молча не записалась: что 7 +// звука собеседников нет, человек видит до встречи, а не после. 4 8 5 −import { api } from "../api"; 9 +import { api, events, type CheckView } from "../api"; 10 +import { esc } from "../dom"; 6 11 import type { Scope } from "../scope"; 7 12 import { startRecording, state } from "../state"; 8 13 31 36 <section class="card prep"> 32 37 <h1>Новая запись</h1> 33 38 <div class="prep-block"> 34 − <p class="field-label">Запишется</p> 35 − <ul class="prep-list"> 36 − <li><span class="dot"></span>ваш микрофон</li> 37 − <li><span class="dot"></span>весь звук компьютера — голоса собеседников в Zoom, Google Meet, Телемосте и где угодно</li> 38 − </ul> 39 + <p class="field-label">Проверка звука</p> 40 + <div class="check" data-check> 41 + ${checkRow("me", "Микрофон", "скажите что-нибудь")} 42 + ${checkRow("others", "Звук компьютера", "включите любой звук — видео, музыку; на встрече это голоса собеседников")} 43 + </div> 39 44 </div> 40 45 <p class="muted">Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.</p> 41 46 ${ 46 51 <button class="ui-btn hero-btn" data-start>Начать запись</button> 47 52 </section> 48 53 </div>`; 54 + // Проверка звука: уровни приходят, пока открыт экран; уходим — устройства 55 + // закрываются. «Начать запись» ядро гасит проверку само. 56 + let check: CheckView | null = null; 57 + try { 58 + check = await api.audioCheckStart(); 59 + } catch (e) { 60 + check = { mic_error: String(e), system_error: String(e) }; 61 + } 62 + if (!scope.alive) { 63 + void api.audioCheckStop(); 64 + return; 65 + } 66 + showError(root, "me", check.mic_error); 67 + showError(root, "others", check.system_error); 68 + const heard: Record<string, number> = {}; 69 + scope.on( 70 + events.recorder(({ meeting_id, event }) => { 71 + if (meeting_id || event.kind !== "level") return; 72 + const key = event.data.speaker.kind === "me" ? "me" : "others"; 73 + const row = root.querySelector<HTMLElement>(`[data-check-row="${key}"]`); 74 + if (!row || row.dataset.error) return; 75 + const fill = row.querySelector<HTMLElement>(".meter-fill"); 76 + if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`; 77 + if (event.data.dbfs > -45) heard[key] = performance.now(); 78 + const ok = performance.now() - (heard[key] ?? -1e9) < 3_000; 79 + row.classList.toggle("is-ok", ok); 80 + row.querySelector("[data-check-state]")!.textContent = ok ? "слышно" : "тишина"; 81 + }), 82 + ); 83 + scope.on(Promise.resolve(() => void api.audioCheckStop())); 84 + 49 85 root.querySelector<HTMLButtonElement>("[data-start]")!.addEventListener("click", async (e) => { 50 86 const b = e.currentTarget as HTMLButtonElement; 51 87 b.disabled = true; 58 94 } 59 95 }); 60 96 } 97 + 98 +function checkRow(key: "me" | "others", title: string, hint: string): string { 99 + return ` 100 + <div class="check-row" data-check-row="${key}"> 101 + <span class="check-title">${title}</span> 102 + <span class="meter-bar"><span class="meter-fill"></span></span> 103 + <span class="check-state" data-check-state>тишина</span> 104 + <span class="check-hint xs muted" data-check-hint>${esc(hint)}</span> 105 + </div>`; 106 +} 107 + 108 +/** Устройство не открылось — вместо полоски причина и где разрешить. */ 109 +function showError(root: HTMLElement, key: "me" | "others", error: string | null): void { 110 + if (!error) return; 111 + const row = root.querySelector<HTMLElement>(`[data-check-row="${key}"]`); 112 + if (!row) return; 113 + row.dataset.error = "1"; 114 + row.classList.add("is-error"); 115 + row.querySelector("[data-check-state]")!.textContent = "не пишется"; 116 + const where = 117 + key === "others" 118 + ? " На Mac: Системные настройки → Конфиденциальность и безопасность → «Запись экрана и системного звука» → включите Skillok Meet и перезапустите программу." 119 + : " Проверьте, что микрофон подключён и программе разрешён доступ к нему."; 120 + row.querySelector("[data-check-hint]")!.textContent = error + where; 121 +}