fix(capture+gui): kill audio-drift regression + fix elapsed/signal status
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'.
This commit is contained in:
parent
727bdaae80
commit
d3e520e3b1
3 changed files with 48 additions and 24 deletions
|
|
@ -732,11 +732,17 @@ class CaptureManager {
|
||||||
'-video_size', fcSize,
|
'-video_size', fcSize,
|
||||||
'-framerate', fcFps,
|
'-framerate', fcFps,
|
||||||
'-i', 'pipe:0',
|
'-i', 'pipe:0',
|
||||||
// Audio FIFO → ffmpeg input 1. Wallclock timestamps + master
|
// Audio FIFO → ffmpeg input 1.
|
||||||
// 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
|
// Do NOT use -use_wallclock_as_timestamps here. The bridge feeds raw
|
||||||
// start) so aresample has minimal correction to do.
|
// s16le at a steady 48000 samples/s off the SAME SDI clock as video,
|
||||||
'-use_wallclock_as_timestamps', '1',
|
// 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',
|
'-thread_queue_size', '512',
|
||||||
'-f', 's16le',
|
'-f', 's16le',
|
||||||
'-ar', '48000',
|
'-ar', '48000',
|
||||||
|
|
|
||||||
|
|
@ -1270,13 +1270,27 @@ router.get('/:id/status', async (req, res, next) => {
|
||||||
} catch (_) { /* not ready yet */ }
|
} catch (_) { /* not ready yet */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRunning) signal = 'receiving';
|
// Recording state and signal come from the capture sidecar's session, NOT
|
||||||
if (!isRunning) signal = 'stopped';
|
// from whether its standby CONTAINER happens to be running. A running
|
||||||
if (live && live.signal) { signal = live.signal; signalKnown = true; }
|
// 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({
|
res.json({
|
||||||
status: isRunning ? 'recording' : 'stopped',
|
// recording = sidecar is actively capturing a session; standby container
|
||||||
duration,
|
// up but idle reports its own status (not 'recording').
|
||||||
|
status: isRecording ? 'recording' : (isRunning ? recorder.status : 'stopped'),
|
||||||
|
recording: isRecording,
|
||||||
|
duration: sessionDuration,
|
||||||
containerId: recorder.container_id,
|
containerId: recorder.container_id,
|
||||||
signal,
|
signal,
|
||||||
signalKnown,
|
signalKnown,
|
||||||
|
|
|
||||||
|
|
@ -987,19 +987,19 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [isRec, recorder.id]);
|
}, [isRec, recorder.id]);
|
||||||
|
|
||||||
// Tick elapsed every second while recording. Seed from liveStatus.duration
|
// Tick elapsed every second while recording. Seed ONLY from the capture
|
||||||
// (authoritative from the capture container) when available; fall back to
|
// sidecar's session duration (liveStatus.duration) — never from
|
||||||
// wall-clock diff from recorder.started_at so the counter never freezes.
|
// 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);
|
const [elapsedSecs, setElapsedSecs] = React.useState(0);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isRec) { setElapsedSecs(0); return; }
|
if (!isRec) { setElapsedSecs(0); return; }
|
||||||
const base = () => {
|
const base = (liveStatus && liveStatus.recording && liveStatus.duration != null)
|
||||||
if (liveStatus && liveStatus.duration != null) return liveStatus.duration;
|
? liveStatus.duration
|
||||||
if (recorder.started_at) return Math.floor((Date.now() - new Date(recorder.started_at).getTime()) / 1000);
|
: 0;
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
// Snap to latest authoritative value immediately, then tick from there.
|
// 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);
|
setElapsedSecs(anchor.secs);
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
setElapsedSecs(anchor.secs + Math.floor((Date.now() - anchor.at) / 1000));
|
setElapsedSecs(anchor.secs + Math.floor((Date.now() - anchor.at) / 1000));
|
||||||
|
|
@ -1007,7 +1007,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
// Re-anchor whenever liveStatus.duration arrives from the poll.
|
// Re-anchor whenever liveStatus.duration arrives from the poll.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
const displayElapsed = React.useMemo(() => {
|
||||||
if (!isRec) return '·';
|
if (!isRec) return '·';
|
||||||
|
|
@ -1017,12 +1017,16 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
|
||||||
String(d % 60).padStart(2, '0');
|
String(d % 60).padStart(2, '0');
|
||||||
}, [isRec, elapsedSecs]);
|
}, [isRec, elapsedSecs]);
|
||||||
|
|
||||||
const displaySignal = liveStatus
|
// Signal is only meaningful while recording. A standby recorder isn't
|
||||||
? (liveStatus.signal || '·')
|
// "stopped" (that red state falsely implied lost signal on idle ports) — it's
|
||||||
: (isRec ? 'connecting…' : '·');
|
// 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)'
|
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
|
||||||
: displaySignal === 'stopped' ? 'var(--danger)'
|
: (displaySignal === 'lost' || displaySignal === 'error') ? 'var(--danger)'
|
||||||
: 'var(--text-3)';
|
: 'var(--text-3)';
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue