skillok meeting

Проверка звука до записи и предупреждение, если дорожка молчит

Встреча, которая молча не записалась, — худшее для программы: человек узнаёт об этом после встречи. - «Новая запись»: проверка звука — микрофон и звук компьютера открываются без записи в файл (audio_check_start/stop), две живые полоски «слышно / тишина». Устройство не открылось — причина и где разрешить (на Mac — «Запись экрана и системного звука»). - Во время записи: дорожка минуту тише −60 dBFS — событие silent, сообщение и строка на панели записи; звук вернулся — heard, строка уходит. На паузе тишина не считается; звук компьютера, который в тишине не приходит вовсе, тоже ловится. - Начало записи гасит проверку: устройства не заняты дважды. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

anc · 1 октября 2026 г. 57e443b222306e63eaf81d54fae10fa0a4f4ea1a
10 файлов
  • Показать разницуСкрыть разницу
    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,
  • Показать разницуСкрыть разницу
    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"),
  • Показать разницуСкрыть разницу
    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 +}