From d3e520e3b16b31961447a90eccdfe7035a1e88a8 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 4 Jun 2026 13:21:30 +0000 Subject: [PATCH] fix(capture+gui): kill audio-drift regression + fix elapsed/signal status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A/V REGRESSION (no audio + start stutter): capture-manager.js dropped the -use_wallclock_as_timestamps 1 flag on the audio FIFO input (re-added by d6b0b3a). Wallclock stamped audio by arrival time while video is CFR frame-count, so audio ran 3-18% longer and master aresample padded seconds of LEADING SILENCE → silent head, late video start, apparent 'no audio'. Removing it restores the sample-count PTS baseline (8e5405c/55a72af): audio shares the SDI clock domain, no drift, no pad. GUI BUG A (elapsed showed 1hr+ on standby/just-started): frontend seeded elapsed from recorder.started_at = the standby CONTAINER boot time (hours old). Now seeds ONLY from the sidecar session duration (liveStatus.duration when live.recording), shows nothing when idle. Backend /status now returns session-scoped duration + recording flag, not container uptime. GUI BUG B (false 'stopped' signal on idle ports): backend inferred signal from container Running state (running->receiving, down->stopped) — so idle standby ports with down sidecars showed red 'stopped'. Now signal comes from the sidecar session (live.recording); standby = neutral 'idle', never a false 'stopped'/'receiving'. --- services/capture/src/capture-manager.js | 16 ++++++++---- services/mam-api/src/routes/recorders.js | 24 +++++++++++++---- services/web-ui/public/screens-ingest.jsx | 32 +++++++++++++---------- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 1f022a7..528d2d1 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -732,11 +732,17 @@ class CaptureManager { '-video_size', fcSize, '-framerate', fcFps, '-i', 'pipe:0', - // Audio FIFO → ffmpeg input 1. Wallclock timestamps + master - // aresample=async=1 is the proven-clean A/V config; both inputs must - // start from the live edge (see the fc_pipe + audio flush at record - // start) so aresample has minimal correction to do. - '-use_wallclock_as_timestamps', '1', + // Audio FIFO → ffmpeg input 1. + // + // Do NOT use -use_wallclock_as_timestamps here. The bridge feeds raw + // s16le at a steady 48000 samples/s off the SAME SDI clock as video, + // so letting ffmpeg derive audio PTS from the sample count keeps audio + // and video in one clock domain (no drift). Wallclock stamps audio by + // arrival wall-time instead — when the HEVC encoder dips under realtime + // the audio ends up 3–18% LONGER than the frame-count video, and the + // master aresample=async=1 then pads seconds of LEADING SILENCE to + // "align" them → the silent-head + start-stutter + apparent "no audio" + // regression (reverts commit d6b0b3a; restores 8e5405c/55a72af). '-thread_queue_size', '512', '-f', 's16le', '-ar', '48000', diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 65073e4..8caa1ea 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -1270,13 +1270,27 @@ router.get('/:id/status', async (req, res, next) => { } catch (_) { /* not ready yet */ } } - if (isRunning) signal = 'receiving'; - if (!isRunning) signal = 'stopped'; - if (live && live.signal) { signal = live.signal; signalKnown = true; } + // Recording state and signal come from the capture sidecar's session, NOT + // from whether its standby CONTAINER happens to be running. A running + // standby container is NOT "recording" and its signal is NOT "stopped" — + // it's idle. Only when live.recording is true do we surface the real + // session signal/duration; otherwise the row is idle with no elapsed. + const isRecording = !!(live && live.recording); + if (isRecording) { + signal = live.signal || 'connecting'; + signalKnown = true; + } else { + signal = 'idle'; + signalKnown = false; + } + const sessionDuration = isRecording && live.duration != null ? live.duration : 0; res.json({ - status: isRunning ? 'recording' : 'stopped', - duration, + // recording = sidecar is actively capturing a session; standby container + // up but idle reports its own status (not 'recording'). + status: isRecording ? 'recording' : (isRunning ? recorder.status : 'stopped'), + recording: isRecording, + duration: sessionDuration, containerId: recorder.container_id, signal, signalKnown, diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 4d3ddc0..fd210a5 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -987,19 +987,19 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn return () => clearInterval(id); }, [isRec, recorder.id]); - // Tick elapsed every second while recording. Seed from liveStatus.duration - // (authoritative from the capture container) when available; fall back to - // wall-clock diff from recorder.started_at so the counter never freezes. + // Tick elapsed every second while recording. Seed ONLY from the capture + // sidecar's session duration (liveStatus.duration) — never from + // recorder.started_at, which is the standby CONTAINER's boot time (hours old) + // and made standby/just-started rows show bogus 1hr+ elapsed. Until the first + // /status poll lands we show 0 rather than guessing from a stale field. const [elapsedSecs, setElapsedSecs] = React.useState(0); React.useEffect(() => { if (!isRec) { setElapsedSecs(0); return; } - const base = () => { - if (liveStatus && liveStatus.duration != null) return liveStatus.duration; - if (recorder.started_at) return Math.floor((Date.now() - new Date(recorder.started_at).getTime()) / 1000); - return 0; - }; + const base = (liveStatus && liveStatus.recording && liveStatus.duration != null) + ? liveStatus.duration + : 0; // Snap to latest authoritative value immediately, then tick from there. - const anchor = { at: Date.now(), secs: base() }; + const anchor = { at: Date.now(), secs: base }; setElapsedSecs(anchor.secs); const id = setInterval(() => { setElapsedSecs(anchor.secs + Math.floor((Date.now() - anchor.at) / 1000)); @@ -1007,7 +1007,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn return () => clearInterval(id); // Re-anchor whenever liveStatus.duration arrives from the poll. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isRec, liveStatus && liveStatus.duration, recorder.started_at]); + }, [isRec, liveStatus && liveStatus.recording, liveStatus && liveStatus.duration]); const displayElapsed = React.useMemo(() => { if (!isRec) return '·'; @@ -1017,12 +1017,16 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn String(d % 60).padStart(2, '0'); }, [isRec, elapsedSecs]); - const displaySignal = liveStatus - ? (liveStatus.signal || '·') - : (isRec ? 'connecting…' : '·'); + // Signal is only meaningful while recording. A standby recorder isn't + // "stopped" (that red state falsely implied lost signal on idle ports) — it's + // simply idle, so show a neutral dot. Only trust liveStatus.signal when the + // sidecar reports it's actually recording. + const displaySignal = (isRec && liveStatus && liveStatus.recording) + ? (liveStatus.signal || 'connecting…') + : (isRec ? 'connecting…' : 'idle'); const signalColor = displaySignal === 'receiving' ? 'var(--success)' - : displaySignal === 'stopped' ? 'var(--danger)' + : (displaySignal === 'lost' || displaySignal === 'error') ? 'var(--danger)' : 'var(--text-3)'; const toggle = () => {