From 641b033bf47fe3eb4fb86f73989ddfe5e331de21 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Fri, 5 Jun 2026 11:24:11 +0000 Subject: [PATCH] feat: per-recorder audio_offset_ms dial for A/V alignment Adds a per-recorder audio offset (ms) control, applied as an ffmpeg -itsoffset on the audio input: positive delays audio (fixes audio-ahead), negative advances, 0 = none. Flows DB (migration 037) -> mam-api (RECORDER_FIELDS + env AUDIO_OFFSET_MS + start body) -> capture.js (sets process.env per session) -> capture-manager audioOffsetArgs() -> ffmpeg. UI: number field in the recorder config modal. Verified end-to-end (setting 120 -> -itsoffset 0.1200 on the live ffmpeg). Default 0, clamped +/-1000ms, non-destructive. Note: this is an interim trim control; the root-cause A/V fix (Deltacast JOINED single-slot embedded-audio extraction) is tracked separately. --- services/capture/src/routes/capture.js | 8 ++++++ .../migrations/037-recorder-audio-offset.sql | 7 ++++++ services/mam-api/src/routes/recorders.js | 6 +++++ services/web-ui/public/screens-ingest.jsx | 25 ++++++++++++++++--- 4 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 services/mam-api/src/db/migrations/037-recorder-audio-offset.sql diff --git a/services/capture/src/routes/capture.js b/services/capture/src/routes/capture.js index 958711c..7490c24 100644 --- a/services/capture/src/routes/capture.js +++ b/services/capture/src/routes/capture.js @@ -330,6 +330,7 @@ router.post('/start', async (req, res) => { growing_smb_password, growing_smb_vers, growing_codec, + audio_offset_ms, } = req.body; if (!project_id || !clip_name) { @@ -415,6 +416,13 @@ router.post('/start', async (req, res) => { if (growing_smb_password) process.env.GROWING_SMB_PASSWORD = growing_smb_password; if (growing_smb_vers) process.env.GROWING_SMB_VERS = growing_smb_vers; if (growing_codec) process.env.GROWING_CODEC = growing_codec; + // Per-recorder A/V trim. Always set (incl. 0) so a standby sidecar that + // carried a stale offset from a prior session is reset. capture-manager + // reads AUDIO_OFFSET_MS and applies it as -itsoffset on the audio input. + if (audio_offset_ms !== undefined && audio_offset_ms !== null) + process.env.AUDIO_OFFSET_MS = String(audio_offset_ms); + else + process.env.AUDIO_OFFSET_MS = '0'; const session = await captureManager.start({ projectId: project_id, diff --git a/services/mam-api/src/db/migrations/037-recorder-audio-offset.sql b/services/mam-api/src/db/migrations/037-recorder-audio-offset.sql new file mode 100644 index 0000000..db28503 --- /dev/null +++ b/services/mam-api/src/db/migrations/037-recorder-audio-offset.sql @@ -0,0 +1,7 @@ +-- 037-recorder-audio-offset.sql +-- Per-recorder A/V alignment trim. The capture pipeline applies this as an +-- ffmpeg -itsoffset on the audio input: a POSITIVE value delays audio (use when +-- audio is ahead of video), NEGATIVE advances it. Clamped to +/-1000 ms in the +-- API/capture layers. Default 0 = no shift. +ALTER TABLE recorders + ADD COLUMN IF NOT EXISTS audio_offset_ms integer NOT NULL DEFAULT 0; diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index c6c80d0..fa230c9 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -155,6 +155,7 @@ const RECORDER_FIELDS = [ 'proxy_container', 'project_id', 'node_id', 'device_index', 'growing_enabled', 'growing_codec', 'label', + 'audio_offset_ms', ]; function pickRecorderFields(body) { @@ -801,6 +802,10 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => { `RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`, `RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`, `RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`, + // Per-recorder A/V alignment trim. capture-manager applies this as an + // -itsoffset on the audio input (positive delays audio = fixes audio-ahead). + // Clamped to +/-1000ms. Default 0. + `AUDIO_OFFSET_MS=${Math.max(-1000, Math.min(1000, parseInt(recorder.audio_offset_ms, 10) || 0))}`, // Proxy codec controls `PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`, @@ -919,6 +924,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => { growing_smb_password: growingInfra.growing_smb_password || '', growing_smb_vers: growingInfra.growing_smb_vers || '3.0', growing_codec: ['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90', + audio_offset_ms: Math.max(-1000, Math.min(1000, parseInt(recorder.audio_offset_ms, 10) || 0)), }; const captureRes = await fetch(captureStartUrl, { method: 'POST', diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index fbb694a..7fe1d92 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -626,9 +626,13 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) { const [growingCodec, setGrowingCodec] = React.useState( ['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90' ); - const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || ''); - const [saving, setSaving] = React.useState(false); - const [err, setErr] = React.useState(null); + const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || ''); + // Per-recorder A/V alignment trim (ms). Positive delays audio (fixes audio-ahead). + const [audioOffsetMs, setAudioOffsetMs] = React.useState( + Number.isFinite(parseInt(recorder.audio_offset_ms, 10)) ? parseInt(recorder.audio_offset_ms, 10) : 0 + ); + const [saving, setSaving] = React.useState(false); + const [err, setErr] = React.useState(null); const isRec = recorder.status === 'recording'; // Growing uses VC-3 with a codec-fixed bitrate (vc3_90 / vc3_220) — never the @@ -647,6 +651,7 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) { growing_enabled: growing, growing_codec: growing ? growingCodec : undefined, project_id: projectId || null, + audio_offset_ms: Math.max(-1000, Math.min(1000, parseInt(audioOffsetMs, 10) || 0)), }; if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M'; @@ -793,6 +798,19 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) { +
+ +
+ setAudioOffsetMs(e.target.value)} + style={{ width: 110 }} /> + + + delays audio (use if audio is ahead) · − advances · 0 = none + +
+
+ {err &&
{err}
}
@@ -838,6 +856,7 @@ function _normRecorder(r) { framerate: r.recording_framerate || 'native', growing: r.growing_enabled === true, growingCodec: (['vc3_220','vc3_90'].includes(r.growing_codec) ? r.growing_codec : 'vc3_90'), + audio_offset_ms: Number.isFinite(parseInt(r.audio_offset_ms, 10)) ? parseInt(r.audio_offset_ms, 10) : 0, nodeId: r.node_id || null, node: r.node_id ? r.node_id.slice(0, 8) : 'primary', deviceIndex: portIdx ?? null,