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:
parent
feeab99a36
commit
641b033bf4
4 changed files with 43 additions and 3 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -627,6 +627,10 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
|
||||||
['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 || '');
|
||||||
|
// 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 [saving, setSaving] = React.useState(false);
|
||||||
const [err, setErr] = React.useState(null);
|
const [err, setErr] = React.useState(null);
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue