fix(capture): derive audio PTS from sample count (kill 2.5s leading silence)

The persistent ~2.5s of leading silence was the master aresample=async=1 PADDING
the audio to reconcile a PTS-origin mismatch: video PTS starts at frame 0
(-framerate), but -use_wallclock_as_timestamps stamped the first audio chunk at
its wall-clock arrival time (~2.5s after the ffmpeg graph opened). aresample
filled the gap with silence.

Drop wallclock: audio PTS now comes from the 48kHz sample count starting at 0 —
the same origin as video frame 0 — so the streams align with no pad. The bridge
already hands live audio (backlog flushed on attach), so no rate reference is
needed from wallclock.
This commit is contained in:
Zac Gaetano 2026-06-04 05:01:05 +00:00
parent e9e883d06e
commit 55a72af905
3 changed files with 116 additions and 47 deletions

View file

@ -720,10 +720,15 @@ class CaptureManager {
'-video_size', fcSize,
'-framerate', fcFps,
'-i', 'pipe:0',
// Audio FIFO → ffmpeg input 1. The deltacast bridge writes a 2ch s16le
// 48kHz stream paced by the SDI slot clock (same clock as the video),
// so wallclock timestamps + master aresample=async=1 keep A/V locked.
'-use_wallclock_as_timestamps', '1',
// Audio FIFO → ffmpeg input 1. The bridge flushes its slot backlog to
// the live edge on reader-attach, so the FIFO delivers live audio in
// lockstep with the video. We DERIVE audio PTS from the s16le sample
// count starting at 0 — the SAME origin as the video's frame-0 PTS —
// rather than from wall-clock arrival. Wall-clock stamped the first
// audio chunk at a time offset from video frame 0, and the master
// aresample then PADDED ~2.5s of leading silence to align them. With
// sample-count PTS both streams share one origin → no pad, no leading
// silence, length locked.
'-thread_queue_size', '512',
'-f', 's16le',
'-ar', '48000',

View file

@ -616,7 +616,6 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc');
const [bitrate, setBitrate] = React.useState((recorder.recording_video_bitrate || '25').replace(/M$/i, ''));
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
const [audioCh, setAudioCh] = React.useState(recorder.recording_audio_channels || 2);
const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || '');
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
@ -634,7 +633,6 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
label: label.trim() || null,
recording_codec: effCodec,
growing_enabled: growing,
recording_audio_channels: Number(audioCh),
project_id: projectId || null,
};
if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M';
@ -680,41 +678,59 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
</div>
</div>
{/* Recording mode — clean segmented control instead of a tickbox. */}
<div className="field">
<label className="field-label">
Video codec{growing && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--warn, #d9a441)' }}>· LOCKED BY GROWING</span>}
</label>
<select className="field-input" value={growing ? 'h264_growing' : codec}
onChange={e => setCodec(e.target.value)} disabled={growing || isRec}
style={{ appearance: 'auto', opacity: growing ? 0.6 : 1 }}>
{growing && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>}
<option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
</select>
<label className="field-label">Recording mode</label>
<div className="rec-mode-seg" role="tablist">
<button type="button" role="tab"
className={'rec-mode-opt' + (!growing ? ' active' : '')}
disabled={isRec} onClick={() => setGrowing(false)}>
<Icon name="video" size={14} />
<div className="rec-mode-txt">
<span className="rec-mode-name">Standard</span>
<span className="rec-mode-desc">GPU master library</span>
</div>
</button>
<button type="button" role="tab"
className={'rec-mode-opt' + (growing ? ' active' : '')}
disabled={isRec} onClick={() => setGrowing(true)}>
<Icon name="edit" size={14} />
<div className="rec-mode-txt">
<span className="rec-mode-name">Growing</span>
<span className="rec-mode-desc">Edit while recording</span>
</div>
</button>
</div>
<div className="rec-mode-hint">
{growing
? 'Writes a growing XDCAM HD422 MXF (OP1a) to the SMB share so editors can cut the clip live in Premiere.'
: 'Encodes a GPU master (HEVC/H.264) streamed straight to the library on stop.'}
</div>
</div>
{showBitrate && (
<div className="field">
<label className="field-label">Target bitrate (Mbps)</label>
<input className="field-input" type="number" min="1" max="400" step="1"
value={bitrate} disabled={isRec}
onChange={e => setBitrate(e.target.value)} />
{/* Codec + bitrate only apply to Standard mode (growing is fixed XDCAM). */}
{!growing && (
<div className="rec-cfg-grid">
<div className="field">
<label className="field-label">Video codec</label>
<select className="field-input" value={codec}
onChange={e => setCodec(e.target.value)} disabled={isRec}
style={{ appearance: 'auto' }}>
<option value="hevc_nvenc">HEVC (NVENC, GPU)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
</select>
</div>
{showBitrate && (
<div className="field">
<label className="field-label">Bitrate (Mbps)</label>
<input className="field-input" type="number" min="1" max="400" step="1"
value={bitrate} disabled={isRec}
onChange={e => setBitrate(e.target.value)} />
</div>
)}
</div>
)}
<div className="field">
<label className="field-label">Audio channels</label>
<select className="field-input" value={audioCh} disabled={isRec}
onChange={e => setAudioCh(e.target.value)} style={{ appearance: 'auto' }}>
<option value={2}>2 stereo (first SDI pair)</option>
<option value={8}>8 first 4 SDI pairs</option>
<option value={16}>16 all 4 SDI groups</option>
</select>
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
SDI embeds up to 16 channels; the master keeps the first N (discrete, no downmix).
</div>
</div>
<div className="field">
<label className="field-label">Default project</label>
<select className="field-input" value={projectId} disabled={isRec}
@ -724,17 +740,6 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
</select>
</div>
<label className="field" style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: isRec ? 'default' : 'pointer' }}>
<input type="checkbox" checked={growing} disabled={isRec}
onChange={e => setGrowing(e.target.checked)} />
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>Growing-file (edit-while-record)</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
Writes a growing XDCAM HD422 MXF to the SMB share so editors can cut it live.
</div>
</div>
</label>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
@ -805,6 +810,10 @@ function Recorders({ navigate, onNew }) {
// Per-recorder config editor (codec / growing / label). Null = closed.
const [configRecorder, setConfigRecorder] = React.useState(null);
// Bump when the cluster snapshot updates so the node-grouping re-derives
// online/offline state without waiting for the recorder list to change.
const [nodesTick, setNodesTick] = React.useState(0);
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
@ -818,6 +827,18 @@ function Recorders({ navigate, onNew }) {
if (err && err.message && err.message.includes('Unauthenticated')) return;
window.DF_LOG.warn('[recorders] poll error:', err?.message);
});
// ALSO refresh the cluster-node snapshot the recorder page groups by node
// and shows each node's online/offline state via ZAMPP_DATA.NODES. Without
// this the snapshot goes stale while idling here (nodes wrongly show offline
// even though they're heartbeating). Best-effort; failure leaves last-known.
window.ZAMPP_API.fetch('/cluster')
.then(nodes => {
if (Array.isArray(nodes)) {
window.ZAMPP_DATA.NODES = nodes;
setNodesTick(t => t + 1);
}
})
.catch(() => {});
}, []);
React.useEffect(() => {
@ -857,7 +878,7 @@ function Recorders({ navigate, onNew }) {
}
out.sort((a, b) => a.meta.hostname.localeCompare(b.meta.hostname));
return out;
}, [recorders]);
}, [recorders, nodesTick]);
return (
<div className="page">

View file

@ -1126,3 +1126,46 @@ button.btn.primary:active {
.recorder-take-project,
.recorder-take-clip { width: auto; flex: 1; min-width: 110px; }
}
/* ── Recorder config modal — recording-mode segmented control + grid ───────── */
.rec-mode-seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.rec-mode-opt {
display: flex;
align-items: center;
gap: 9px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--bg-2, rgba(255,255,255,0.02));
color: var(--text-2);
cursor: pointer;
text-align: left;
transition: border-color .12s ease, background .12s ease, color .12s ease;
}
.rec-mode-opt:hover:not(:disabled) { border-color: var(--accent, #4a9eff); }
.rec-mode-opt.active {
border-color: var(--accent, #4a9eff);
background: var(--accent-soft, rgba(74,158,255,0.12));
color: var(--text-1);
}
.rec-mode-opt:disabled { opacity: .55; cursor: default; }
.rec-mode-opt .icon { flex-shrink: 0; opacity: .85; }
.rec-mode-txt { display: flex; flex-direction: column; line-height: 1.25; }
.rec-mode-name { font-size: 13px; font-weight: 600; }
.rec-mode-desc { font-size: 10.5px; color: var(--text-3); }
.rec-mode-hint {
margin-top: 8px;
font-size: 11px;
line-height: 1.5;
color: var(--text-3);
}
.rec-cfg-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.rec-cfg-grid .field:only-child { grid-column: 1 / -1; }