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:
parent
e9e883d06e
commit
55a72af905
3 changed files with 116 additions and 47 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue