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 = () => {