fix(deltacast-bridge): use group-0 sample count as authoritative audio length

Taking the MAX sample count across the 4 audio groups could emit more audio
frames per slot than group 0 (the SDI-clock reference), drifting the audio
stream slightly longer than video — heard as a ~1% pitch-up. Group 0 paces the
timeline exactly as the original 2ch path did; shorter groups are silence-padded
to its length, never extending it.
This commit is contained in:
Zac Gaetano 2026-06-04 04:01:25 +00:00
parent 095306d9cf
commit 51f939b1fe
2 changed files with 20 additions and 10 deletions

View file

@ -322,16 +322,17 @@ static void *audio_thread(void *arg) {
for (int g = 0; g < GROUPS; g++) for (int g = 0; g < GROUPS; g++)
ai.pAudioGroups[g].pAudioChannels[0].DataSize = (ULONG)gbuf_sz; ai.pAudioGroups[g].pAudioChannels[0].DataSize = (ULONG)gbuf_sz;
if (VHD_SlotExtractAudio(slot, &ai) == VHDERR_NOERROR) { if (VHD_SlotExtractAudio(slot, &ai) == VHDERR_NOERROR) {
/* Frames present = bytes from the most-populated group /* Group 0 is the AUTHORITATIVE sample count — it paces the
* divided by one stereo frame (4 bytes). Real SDI audio * audio timeline in lockstep with video (same SDI slot
* is sample-aligned across groups; using the max keeps * clock), exactly as the original 2ch path did. We must
* us robust if a quiet group returns fewer bytes. */ * emit EXACTLY group 0's frame count per slot; taking a
* max across groups would occasionally emit extra frames
* and make the audio stream drift LONGER than the video
* (heard as a slight pitch-up). Groups 1-3 are sampled at
* the same rate; any that return fewer bytes are padded
* with silence to group 0's length, never extending it. */
ULONG g0 = ai.pAudioGroups[0].pAudioChannels[0].DataSize; ULONG g0 = ai.pAudioGroups[0].pAudioChannels[0].DataSize;
size_t frames = (size_t)g0 / 4; /* 2ch * s16 = 4 bytes/frame */ size_t frames = (size_t)g0 / 4; /* 2ch * s16 = 4 bytes/frame */
for (int g = 1; g < GROUPS; g++) {
size_t gf = (size_t)ai.pAudioGroups[g].pAudioChannels[0].DataSize / 4;
if (gf > frames) frames = gf;
}
if (frames > 0) { if (frames > 0) {
size_t need = frames * FRAME_BYTES; size_t need = frames * FRAME_BYTES;
if (need > out_cap) { frames = out_cap / FRAME_BYTES; need = frames * FRAME_BYTES; } if (need > out_cap) { frames = out_cap / FRAME_BYTES; need = frames * FRAME_BYTES; }

View file

@ -276,6 +276,13 @@ async function reconcileRecordersForNode(node) {
} }
if (ports.length === 0) return; if (ports.length === 0) return;
// Default master codec for newly-discovered ports. SDI capture at 1080p59.94
// CANNOT be encoded in realtime on CPU (ProRes/x264 fall behind → dropped
// frames → short, fast-playing files). Nodes with an NVENC-capable GPU default
// to GPU HEVC; only GPU-less nodes fall back to CPU ProRes.
const hasGpu = Array.isArray(cap.gpus) && cap.gpus.length > 0;
const defaultCodec = hasGpu ? 'hevc_nvenc' : 'prores_hq';
for (const p of ports) { for (const p of ports) {
// INSERT … ON CONFLICT DO NOTHING: create-once. Never overwrite an existing // INSERT … ON CONFLICT DO NOTHING: create-once. Never overwrite an existing
// row (preserves label, enabled, codec config, status). source_config keeps // row (preserves label, enabled, codec config, status). source_config keeps
@ -285,8 +292,9 @@ async function reconcileRecordersForNode(node) {
: { device: p.device_index }; : { device: p.device_index };
await pool.query( await pool.query(
`INSERT INTO recorders `INSERT INTO recorders
(node_id, device_index, source_type, source_config, name, enabled, auto_provisioned) (node_id, device_index, source_type, source_config, name, enabled, auto_provisioned,
VALUES ($1, $2, $3::source_type, $4, $5, false, true) recording_codec, recording_container, recording_video_bitrate, recording_audio_channels)
VALUES ($1, $2, $3::source_type, $4, $5, false, true, $6, 'mov', '25M', 2)
ON CONFLICT (node_id, device_index) WHERE node_id IS NOT NULL AND device_index IS NOT NULL ON CONFLICT (node_id, device_index) WHERE node_id IS NOT NULL AND device_index IS NOT NULL
DO NOTHING`, DO NOTHING`,
[ [
@ -296,6 +304,7 @@ async function reconcileRecordersForNode(node) {
JSON.stringify(srcCfg), JSON.stringify(srcCfg),
// Deterministic hardware name; the operator can set a friendly `label`. // Deterministic hardware name; the operator can set a friendly `label`.
`${node.hostname}-${p.source_type === 'deltacast' ? 'dc' : 'bmd'}${p.device_index}`, `${node.hostname}-${p.source_type === 'deltacast' ? 'dc' : 'bmd'}${p.device_index}`,
defaultCodec,
] ]
); );
} }