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.
This commit is contained in:
OpenCode 2026-06-05 11:24:11 +00:00
parent feeab99a36
commit 641b033bf4
4 changed files with 43 additions and 3 deletions

View file

@ -330,6 +330,7 @@ router.post('/start', async (req, res) => {
growing_smb_password, growing_smb_password,
growing_smb_vers, growing_smb_vers,
growing_codec, growing_codec,
audio_offset_ms,
} = req.body; } = req.body;
if (!project_id || !clip_name) { 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_password) process.env.GROWING_SMB_PASSWORD = growing_smb_password;
if (growing_smb_vers) process.env.GROWING_SMB_VERS = growing_smb_vers; if (growing_smb_vers) process.env.GROWING_SMB_VERS = growing_smb_vers;
if (growing_codec) process.env.GROWING_CODEC = growing_codec; 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({ const session = await captureManager.start({
projectId: project_id, projectId: project_id,

View file

@ -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;

View file

@ -155,6 +155,7 @@ const RECORDER_FIELDS = [
'proxy_container', 'proxy_container',
'project_id', 'node_id', 'device_index', 'project_id', 'node_id', 'device_index',
'growing_enabled', 'growing_codec', 'label', 'growing_enabled', 'growing_codec', 'label',
'audio_offset_ms',
]; ];
function pickRecorderFields(body) { 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_BITRATE=${recorder.recording_audio_bitrate || ''}`,
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`, `RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`, `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 codec controls
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`, `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_password: growingInfra.growing_smb_password || '',
growing_smb_vers: growingInfra.growing_smb_vers || '3.0', growing_smb_vers: growingInfra.growing_smb_vers || '3.0',
growing_codec: ['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90', 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, { const captureRes = await fetch(captureStartUrl, {
method: 'POST', method: 'POST',

View file

@ -626,9 +626,13 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
const [growingCodec, setGrowingCodec] = React.useState( const [growingCodec, setGrowingCodec] = React.useState(
['vc3_220','vc3_90'].includes(recorder.growing_codec) ? recorder.growing_codec : 'vc3_90' ['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 [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || '');
const [saving, setSaving] = React.useState(false); // Per-recorder A/V alignment trim (ms). Positive delays audio (fixes audio-ahead).
const [err, setErr] = React.useState(null); 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'; const isRec = recorder.status === 'recording';
// Growing uses VC-3 with a codec-fixed bitrate (vc3_90 / vc3_220) never the // 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_enabled: growing,
growing_codec: growing ? growingCodec : undefined, growing_codec: growing ? growingCodec : undefined,
project_id: projectId || null, 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'; if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M';
@ -793,6 +798,19 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
</select> </select>
</div> </div>
<div className="field">
<label className="field-label">Audio offset (ms)</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input className="field-input" type="number" min="-1000" max="1000" step="1"
value={audioOffsetMs} disabled={isRec}
onChange={e => setAudioOffsetMs(e.target.value)}
style={{ width: 110 }} />
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)' }}>
+ delays audio (use if audio is ahead) · advances · 0 = none
</span>
</div>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>} {err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div> </div>
<div className="modal-foot"> <div className="modal-foot">
@ -838,6 +856,7 @@ function _normRecorder(r) {
framerate: r.recording_framerate || 'native', framerate: r.recording_framerate || 'native',
growing: r.growing_enabled === true, growing: r.growing_enabled === true,
growingCodec: (['vc3_220','vc3_90'].includes(r.growing_codec) ? r.growing_codec : 'vc3_90'), 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, nodeId: r.node_id || null,
node: r.node_id ? r.node_id.slice(0, 8) : 'primary', node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
deviceIndex: portIdx ?? null, deviceIndex: portIdx ?? null,