aqua-feel sync: audio-visual onset lag vs perceptual windows #32

Merged
aqua merged 4 commits from aqua/feel-sync into main 2026-11-10 23:42:59 +00:00
10 changed files with 727 additions and 1 deletions

View file

@ -9,6 +9,8 @@ The WAVs and the gallery exist so a person can press play.
To look at a finished audio file (wav/ogg/flac: spectrogram, onsets at 1 ms, level envelope, click/clip/double-hit/overlap/abrupt-end detectors, two-file compare) rather than a rendered patch, use `../aqua-ear`.
To check that sound and picture of a capture line up (video or WAV plus a frame-diff track), use `node feel.mjs sync`: see `SYNC.md`.
## Run
```

103
tools/aqua-feel/SYNC.md Normal file
View file

@ -0,0 +1,103 @@
# aqua-feel sync
Checks audio-visual synchrony of a capture: finds audio onsets and visual onsets, pairs them, reports the lag per event in ms and
compares it with published detectability windows. Standalone: Node 18+, no npm dependencies (`feel.mjs` imports `sync.mjs`, which
imports one FFT from `metrics.js`). ffmpeg (no ffprobe) is needed only to read a video file directly.
**The metric is not the verdict.** It compares two onset lists extracted from signals. A human ear and eye still decide whether a
capture feels in sync. Nothing here heard or saw anything.
## Run
```
# WAV + a per-frame track (no ffmpeg needed)
node feel.mjs sync --wav capture.wav --track frames.csv [--events hits.txt] [--fps 60] [--kind luma|flux]
# a video with one audio and one video stream (needs ffmpeg on PATH, or FFMPEG=/path/to/ffmpeg)
node feel.mjs sync --video capture.mp4 [--events hits.txt] [--kind luma|flux]
# options: --profile default|bt1359|ebu-r37|atsc-is191 --lead-ms N --lag-ms N --pair-window-ms N (default 200)
# --min-gap-ms N --allow-unpaired --json out.json --no-gate
node sync.test.mjs # 37 checks on synthetic fixtures; 46 with FFMPEG=/path/to/ffmpeg (also builds clips with known stream offsets and runs a real browser capture)
node sync-fixture.mjs DIR 80 60 # write a fixture (audio 80 ms late, 60 fps) to try the CLI on
```
Exit 0 when no pair is detectably outside the window; 1 when any pair is outside, an event or onset is unpaired or has no partner, a pairing is ambiguous,
or nothing paired; 2 on a tool error (bad option, unreadable or malformed input). `--no-gate` always exits 0. `--allow-unpaired` stops stray
onsets (background hits that have no counterpart) from failing the check.
**Inputs.** `frames.csv`: one row per video frame, `t,value` in seconds (a header line is skipped), or one value per line with `--fps`.
`value` is mean frame luminance (`--kind luma`, default: a flash or cut is a positive step) or a frame-difference magnitude
(`--kind flux`, motion flux: a start of movement is a rise). `hits.txt`: seconds, optionally `,label` per line (`#` comments), or JSON
`[0.5, {"t":1.3,"label":"hit"}]`. With a video, luminance and flux are computed on a 64x36 gray downscale; stream `start_time`
offsets are applied so both clocks are the container's.
**Lag convention.** `lag = audio onset - visual onset`. Positive means the sound comes after the picture (audio late); negative means
the sound comes first (audio early). With `--events`, each logged event is matched to the nearest audio and nearest visual onset
within the pairing window (200 ms), and `a-ev` / `v-ev` give each side's offset from the logged time, so you can tell which side is late. Without an
event log, audio onsets pair with visual onsets by proximity (within the pairing window, one-to-one, nearest first). **A pair is `ambiguous-pairing` and fails the check when
another onset of the same kind lies closer than twice the pairing window** (400 ms by default). Nearest-onset pairing is right only if the true lag is under half
the spacing of the onsets, and the tool cannot know the lag, so it takes the pairing window as the largest lag you consider plausible. The cost is real: material
faster than 2.5 hits per second cannot pass at the default, however good the sync. Narrow `--pair-window-ms` if the lag is known to be small (`50` clears 100 ms
spacing), or space the events out. A lag beyond the window reads as `no-audio-onset`, or, when the previous hit's sound lands inside the window, as that hit's (wrong sign, e.g.
+250 ms at 0.45 s spacing reads -200 ms); it fails either way, but do not trust the sign of a lag larger than half the hit spacing.
Parsers refuse input they cannot read (timecodes, a third column, empty cells, non-increasing times) rather than guess.
## How onsets are found
- **Audio:** log-magnitude spectral flux (1024-sample window, 128-sample hop, 60 Hz and up), half-wave rectified; peaks over
median + 8 robust sigmas, at least 50 ms apart; each is then refined on the samples to the first one that rises above both 10% of the local peak and
2.5 times the steady bed (the median block peak of the 85 ms before, after removing the median as DC). A bed louder than a tenth of the hit still defeats it.
- **Visual:** the positive step (luma) or the value (flux) of the track; peaks over median + 8 robust sigmas and at least 10% of the
track's range, at least 100 ms apart (`--min-gap-ms` changes it, for both trains; flashes closer than that merge into one onset and the events beyond the first go unpaired); the onset is the first frame of the rise reaching 50% of the peak.
## Verdict
Per pair, against a window of how far audio may lead (`lead`) or trail (`lag`) the picture before the offset is detectable:
| profile | lead ms | lag ms | source |
|---|---|---|---|
| `default` | 25 | 100 | the conservative range this tool was asked for; it is not one standard: it sits between the ATSC and ITU figures. Treat as a tripwire |
| `bt1359` | 45 | 125 | ITU-R BT.1359-1 (1998), "Relative timing of sound and vision for broadcasting": detectability threshold +45 / -125 ms (acceptability +90 / -185) |
| `ebu-r37` | 40 | 60 | EBU R37 (2007) limit for emission, sound before / after picture. Figure taken from a secondary summary, the EBU text was not read |
| `atsc-is191` | 15 | 45 | ATSC IS-191 (2003) limit at the DTV encoder input. Secondary summary; the same document gives +30 / -90 ms end to end. The document itself was not read |
Verdict words: `inside-window` (the whole truth interval, below, lies inside the window); `audio-late-detectable` / `audio-early-detectable` (the whole interval lies outside);
`borderline` (the interval straddles a window edge: not shown to be outside, so it does not fail the gate, and the report says "NOT DETECTABLY OUTSIDE" rather than "WITHIN");
`ambiguous-pairing`; `no-audio-onset` / `no-visual-onset` / `no-onsets` (the logged event had no onset within the pairing window, so a sound further than 200 ms from its event
reads as absent). `pass` means no pair is detectably outside, ambiguous or missing, and nothing is unpaired (unless `--allow-unpaired`).
Sources: [ITU-R BT.1359-1](https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.1359-1-199811-I!!PDF-E.pdf) (the PDF text was
fragmentary in the search; figures confirmed against secondary summaries), and the TV Technology articles
[AV synchronization, how bad is bad](https://www.tvtechnology.com/opinions/av-synchronization-how-bad-is-bad) and
[ATSC recommends AV sync rules](https://tvtechnology.com/opinions/atsc-recommends-av-sync-rules). Windows depend on content (speech
lip-sync is more sensitive than a game impact) and on the viewer; they are population thresholds, not a property of your capture.
## Uncertainty
A visual onset can only land on a frame, and a frame is stamped when its picture started to be displayed. A change at time tau first shows in the first frame
stamped at or after tau, so the measured visual time is late by 0 to one frame period, and the measured lag (audio minus visual) is **biased low** by that much. The audio
onset is good to about half a hop (1.4 ms). Each pair therefore reports `lagLowMs = lag - 1.4` and `lagHighMs = lag + frame period + 1.4`: the truth lies in that interval
(16.7 + 2.7 ms wide at 60 fps; 33.3 + 2.7 ms at 30 fps). A video is resampled to constant frame rate, which snaps each frame to the nearest source frame, so for a video the interval is half a period wider on each side (`[lag - period/2 - hop/2, lag + 1.5 period + hop/2]`), and the verdict is read off the interval. This holds for events on and off the frame grid in the fixtures
(`sync.test.mjs`; the video clips there are built with frame stamps at or after the change, the model's assumption). The display's own latency (the time from stamp to light) is not in it.
## What the metric cannot tell you
- **Whether it feels right.** Two onsets agreeing says the energy rose at the same time, not that the sound belongs to the picture.
A person must watch and listen.
- **That the pairing is the right pairing.** Onsets are matched by nearness. A sound that belongs to an earlier or later visual
event can be paired with the wrong one; with an event log, the log is trusted, with none, nothing checks the pairing but you.
- **Which onset the viewer attends to.** A swell, a reverb tail, a ramped attack or a low-frequency thump has a perceptual onset
later than the first sample above the threshold; a flash with a fade reads late the same way. A decaying visual (afterimage, a bloom
that outlives the event) is not modelled.
- **Anything about the chain you did not capture.** If the capture is of the app's output, display latency, speaker/Bluetooth
latency and a player's own sync are not in it. A capture can be in sync and the playback chain still be 150 ms off.
- **Variable frame rate.** A video is resampled by presentation time to constant frame rate (ffmpeg `fps` filter), so a VFR browser capture works, but the frame period
used in the interval is the resampled one, not the source's real gaps. A CSV track keeps its own timestamps.
- **Stereo is mixed to mono**, so an antiphase stereo track cancels to silence and reads as no onsets.
- **Content that is not onset-shaped.** Continuous music against continuous motion has no onsets to compare; the tool returns
few or none, and silence in either track is reported as no pass, not as sync.
- **Measurements from downscaled luminance.** A flash that is local (a small icon) moves the mean luminance little and may fall
below the 10%-of-range floor; use a cropped frame-diff track in that case.
- **Known stream offsets.** `sync.test.mjs` builds clips with ffmpeg whose video or audio stream starts late (`-itsoffset`, stream copy) and checks that the known lag lies in every pair's interval, so the container start-time bookkeeping has a ground truth; the clips are synthetic flashes and tones.
- **A pulsing bed.** The bed estimate is a median of block peaks and cannot follow dynamics: a 4 Hz tremolo bed at 10% depth under the hits pulled some lags off by up to 28 ms in testing. Beds louder than a tenth of the hit and noise gates that cut the sound to digital silence (the noise resuming reads as an onset) can also give a wrong onset that passes when the true lag is well inside the window.
- **One real clip.** `fixtures/browser-capture.webm` is a headless-Chromium MediaRecorder capture (WebAudio beeps and canvas flashes scheduled at the same instants, vp8 + opus, recipe in `fixtures/record-browser-capture.cjs`). It is a test that the video path runs and pairs, not a validation of the lag it reports: nothing independent says what the true lag of that clip is. No Playground UI or Perlines clip has been run.
- **The tuned constants.** The 8-sigma threshold, window sizes, 200 ms pairing window, 10% floors and 2.5x bed factor were set against synthetic
fixtures (`sync-fixture.mjs`: tone-burst hits against 4-frame flashes, known offset, with noise, tone and DC beds) and one browser clip. They have not been tuned on game or music content.

View file

@ -5,6 +5,7 @@
// Usage: node feel.mjs run [--out DIR] [--only SUBSTR] [--no-gate] render, measure, write report + gallery, gate
// node feel.mjs gate REPORT.json [THRESHOLDS.json] [--partial] re-gate a report (--partial: a subset; skips id-rule and waiver bookkeeping)
// node feel.mjs compare BEFORE.json AFTER.json [--only SUBSTR] before/after table
// node feel.mjs sync --wav A.wav --track T.csv [--events E] | --video V [--fps N] [--kind luma|flux] [--profile P] [--lead-ms N --lag-ms N] [--pair-window-ms N] [--min-gap-ms N] [--allow-unpaired] [--json OUT] A/V synchrony (SYNC.md)
// node feel.mjs correlate TRIAL_EXPORT.json ... feel metrics vs listening-trial ratings
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
@ -53,7 +54,32 @@ async function run() {
process.exit(!noGate && !report.gate.pass ? 1 : 0);
}
async function sync() {
const { syncAnalyze, readWav, readTrack, readEvents, readVideo, formatSync, PROFILES } = await import("./sync.mjs");
try {
const val = (n) => { const i = args.indexOf(n); if (i < 0) return undefined; if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new Error(`${n} needs a value`); return args.splice(i, 2)[1]; };
const num = (n) => { const x = val(n); if (x === undefined) return undefined; if (!Number.isFinite(Number(x)) || x.trim() === "") throw new Error(`${n} needs a number, got "${x}"`); return Number(x); };
const wav = val("--wav"), trk = val("--track"), vid = val("--video"), evs = val("--events"), fps = num("--fps"), json = val("--json");
const kind = val("--kind") ?? "luma", profile = val("--profile") ?? "default", lead = num("--lead-ms"), lag = num("--lag-ms"), pw = num("--pair-window-ms"), gap = num("--min-gap-ms");
const noGate = bool("--no-gate"), allowUnpaired = bool("--allow-unpaired");
if (args.length) throw new Error(`unknown argument ${args[0]}`);
if (!["luma", "flux"].includes(kind)) throw new Error(`--kind is luma or flux, got "${kind}"`);
if (!PROFILES[profile]) throw new Error(`--profile is one of ${Object.keys(PROFILES).join(", ")}, got "${profile}"`);
let audio, sampleRate, audioStartS = 0, visual, visualResampled = false;
if (vid) { const v = readVideo(vid); audio = v.audio.samples; sampleRate = v.audio.sampleRate; audioStartS = v.audio.startS; visual = kind === "flux" ? v.flux : v.luma; visualResampled = true; }
else if (wav && trk) { const w = readWav(readFileSync(wav)); audio = w.samples; sampleRate = w.sampleRate; visual = readTrack(readFileSync(trk, "utf8"), fps); }
else throw new Error("give --video FILE, or --wav FILE with --track FILE");
const r = syncAnalyze({ audio, sampleRate, audioStartS, visual, visualKind: kind, profile, events: evs ? readEvents(readFileSync(evs, "utf8")) : undefined,
leadMs: lead, lagMs: lag, pairWindowMs: pw ?? 200, minGapMs: gap, allowUnpaired, visualResampled });
r.caveat = "Nothing here was heard or seen: two onset lists compared. The metric is not the verdict; see SYNC.md.";
console.log(formatSync(r));
if (json) writeFileSync(json, JSON.stringify(r, null, 1));
process.exit(!noGate && !r.summary.pass ? 1 : 0);
} catch (e) { console.error(`sync: ${e.message}`); process.exit(2); }
}
const cmds = {
sync,
run,
gate() {
const partial = bool("--partial"), report = readJson(args[0]), g = gate(report, readJson(args[1] ?? join(here, "thresholds.json")), { partial });

View file

@ -0,0 +1,7 @@
1,hit0
1.9,hit1
2.8,hit2
3.4,hit3
4.5,hit4
5.2,hit5
6.3,hit6

Binary file not shown.

View file

@ -0,0 +1,38 @@
// Recipe for fixtures/browser-capture.webm (needs playwright-core and a chromium headless shell; edit the executablePath). Records a real browser A/V clip: WebAudio beeps + canvas flashes at the same scheduled instants, muxed by MediaRecorder. Not the Playground UI.
const { chromium } = require('playwright-core');
const fs = require('fs');
(async () => {
// CHROME=/path/to/chrome-headless-shell node record-browser-capture.cjs (writes clip.webm and events.txt in the cwd)
const browser = await chromium.launch({ executablePath: process.env.CHROME, args: ['--autoplay-policy=no-user-gesture-required'] });
const pg = await (await browser.newContext({ viewport: { width: 320, height: 180 } })).newPage();
await pg.setContent('<canvas id=c width=320 height=180></canvas>');
const res = await pg.evaluate(async () => {
const c = document.getElementById('c'), g = c.getContext('2d'), ctx = new AudioContext(), dest = ctx.createMediaStreamDestination();
await ctx.resume();
const stream = new MediaStream([...c.captureStream(30).getVideoTracks(), ...dest.stream.getAudioTracks()]);
const rec = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp8,opus' }), chunks = [];
rec.ondataavailable = e => chunks.push(e.data);
const hits = [1.0, 1.9, 2.8, 3.4, 4.5, 5.2, 6.3]; // seconds after recording start (intended instants)
let flashes = [], t0, on = 0;
const draw = (now) => {
const t = (now - t0) / 1000; let lit = false;
for (const h of hits) if (t >= h && t < h + 0.12) lit = true;
g.fillStyle = lit ? '#fff' : `rgb(${20 + (Math.random() * 3) | 0},20,20)`; g.fillRect(0, 0, 320, 180);
if (lit && !on) flashes.push(t); on = lit ? 1 : 0;
if (t < 8) requestAnimationFrame(draw); else rec.stop();
};
const done = new Promise(r => rec.onstop = r);
rec.start(100);
t0 = performance.now(); const a0 = ctx.currentTime;
for (const h of hits) { const o = ctx.createOscillator(), gn = ctx.createGain(); o.frequency.value = 880; gn.gain.setValueAtTime(0, a0 + h); gn.gain.linearRampToValueAtTime(0.5, a0 + h + 0.002); gn.gain.exponentialRampToValueAtTime(0.001, a0 + h + 0.15); o.connect(gn); gn.connect(dest); o.start(a0 + h); o.stop(a0 + h + 0.2); }
requestAnimationFrame(draw);
await done;
const blob = new Blob(chunks, { type: 'video/webm' }), buf = new Uint8Array(await blob.arrayBuffer());
let s = ''; for (let i = 0; i < buf.length; i += 8192) s += String.fromCharCode(...buf.subarray(i, i + 8192));
return { b64: btoa(s), hits, flashes, baseLatency: ctx.baseLatency, outputLatency: ctx.outputLatency };
});
fs.writeFileSync('clip.webm', Buffer.from(res.b64, 'base64'));
fs.writeFileSync('events.txt', res.hits.map((h, i) => `${h},hit${i}`).join('\n') + '\n');
console.log(JSON.stringify({ hits: res.hits, flashes: res.flashes, base: res.baseLatency, out: res.outputLatency, bytes: fs.statSync('clip.webm').size }));
await browser.close();
})();

View file

@ -3,5 +3,5 @@
"private": true,
"type": "module",
"description": "Headless feel meter for AquaSynth: renders every shipped patch and Ding offline and measures them. Measures; does not listen.",
"scripts": { "test": "node test.mjs", "feel": "node feel.mjs", "shelf": "node shelf.mjs build", "shelf:check": "node shelf.mjs check" }
"scripts": { "test": "node test.mjs && node sync.test.mjs", "feel": "node feel.mjs", "shelf": "node shelf.mjs build", "shelf:check": "node shelf.mjs check" }
}

View file

@ -0,0 +1,39 @@
// Synthetic A/V fixture with a known offset: event times, a mono WAV of tone-burst "hits", and a per-frame luminance track of flashes.
// The audio is the visual delayed by offsetMs (positive = audio late), so syncAnalyze must recover lag = offsetMs. Nothing here is a real capture.
// CLI: node sync-fixture.mjs OUTDIR [offsetMs=80] [fps=60] writes OUTDIR/{fixture.wav,track.csv,events.txt,truth.json}
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { wavFloat32 } from "./render.js";
export const FIXTURE_EVENTS_S = [0.5, 1.3, 2.2, 3.4, 4.1, 5.0];
// bed: steady tones [[hz, amp], ...] under the hits; dc: constant offset; gain: hit amplitude. Events need not sit on frame boundaries.
export function makeFixture({ offsetMs = 80, fps = 60, sampleRate = 48000, seconds = 6, noise = 0.003, events = FIXTURE_EVENTS_S, seed = 7, bed = [], dc = 0, gain = 0.6 } = {}) {
let s = seed >>> 0; const rand = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 2 ** 32 - 0.5; };
const audio = new Float32Array(Math.round(sampleRate * seconds));
for (let i = 0; i < audio.length; i++) { audio[i] = dc + noise * 2 * rand(); for (const [hz, amp] of bed) audio[i] += amp * Math.sin((2 * Math.PI * hz * i) / sampleRate); }
for (const e of events) { // 40 ms decaying 880 Hz burst with a 1 ms ramp: a hit with a sharp but not instantaneous attack
const start = Math.round((e + offsetMs / 1000) * sampleRate);
for (let j = 0; j < 0.04 * sampleRate && start + j < audio.length; j++) {
const t = j / sampleRate; audio[start + j] += gain * Math.min(1, t / 0.001) * Math.exp(-t / 0.012) * Math.sin(2 * Math.PI * 880 * t);
}
}
const n = Math.round(fps * seconds), t = new Float64Array(n), v = new Float64Array(n);
for (let f = 0; f < n; f++) { // frame f is on screen from f/fps; a flash is bright for 4 frames starting at the first frame at or after the event
t[f] = f / fps; v[f] = 0.1 + 0.01 * 2 * rand();
for (const e of events) { const f0 = Math.ceil(e * fps - 1e-9); if (f >= f0 && f < f0 + 4) v[f] = 0.9; }
}
return { audio, sampleRate, visual: { t, v }, events: events.map((e, i) => ({ t: e, label: `hit${i}` })), offsetMs, fps };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const [dir, off = "80", fps = "60"] = process.argv.slice(2);
if (!dir) { console.error("usage: node sync-fixture.mjs OUTDIR [offsetMs=80] [fps=60]"); process.exit(2); }
const fx = makeFixture({ offsetMs: Number(off), fps: Number(fps) }); mkdirSync(dir, { recursive: true });
const wav = wavFloat32(fx.audio); wav.writeUInt32LE(fx.sampleRate, 24); wav.writeUInt32LE(fx.sampleRate * 4, 28); // wavFloat32 writes 48 kHz; same rate here
writeFileSync(join(dir, "fixture.wav"), wav);
writeFileSync(join(dir, "track.csv"), "t,luma\n" + Array.from(fx.visual.t, (x, i) => `${x.toFixed(6)},${fx.visual.v[i].toFixed(5)}`).join("\n") + "\n");
writeFileSync(join(dir, "events.txt"), fx.events.map((e) => `${e.t},${e.label}`).join("\n") + "\n");
writeFileSync(join(dir, "truth.json"), JSON.stringify({ offsetMs: fx.offsetMs, fps: fx.fps, note: "audio = visual delayed by offsetMs; synthetic" }, null, 1));
console.log(`wrote fixture to ${dir} (audio ${fx.offsetMs} ms late)`);
}

273
tools/aqua-feel/sync.mjs Normal file
View file

@ -0,0 +1,273 @@
// aqua-feel sync: audio-visual synchrony check. Zero npm dependencies (node >= 18); ffmpeg/ffprobe only if you hand it a video.
// NOTHING HERE HEARS OR SEES ANYTHING. It compares two onset lists extracted from signals; see SYNC.md for what that cannot tell you.
// Library: syncAnalyze({ audio, sampleRate, visual:{t,v}, events, profile }) -> report. CLI wiring is in feel.mjs (`node feel.mjs sync ...`).
import { readFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { fft } from "./metrics.js";
// Lag convention: lag = audio onset time - visual onset time. Positive = audio LATE (after the picture); negative = audio EARLY.
// Windows are [leadMs, lagMs]: audio may lead the picture by up to leadMs and trail it by up to lagMs before the offset is "detectable".
// Sources: see SYNC.md. 'default' is the requested conservative profile; the rest are the published ones, secondary-source figures noted there.
export const PROFILES = {
default: { leadMs: 25, lagMs: 100, note: "conservative; between ATSC IS-191 and ITU-R BT.1359-1" },
"bt1359": { leadMs: 45, lagMs: 125, note: "ITU-R BT.1359-1 (1998) detectability threshold" },
"ebu-r37": { leadMs: 40, lagMs: 60, note: "EBU R37 (2007) emission limit, per secondary source" },
"atsc-is191": { leadMs: 15, lagMs: 45, note: "ATSC IS-191 limit at the encoder input, per secondary source" },
};
// ---------- input readers ----------
export function readWav(buf) {
if (buf.toString("latin1", 0, 4) !== "RIFF" || buf.toString("latin1", 8, 12) !== "WAVE") throw new Error("not a RIFF/WAVE file");
let fmt, data;
for (let p = 12; p + 8 <= buf.length;) {
const id = buf.toString("latin1", p, p + 4), sz = buf.readUInt32LE(p + 4), body = p + 8;
if (id === "fmt ") fmt = { tag: buf.readUInt16LE(body), ch: buf.readUInt16LE(body + 2), sr: buf.readUInt32LE(body + 4), bits: buf.readUInt16LE(body + 14) };
if (id === "data") { data = buf.subarray(body, Math.min(buf.length, body + sz)); break; }
p = body + sz + (sz & 1);
}
if (!fmt || !data) throw new Error("WAV has no fmt or data chunk");
if (fmt.tag === 0xfffe) fmt.tag = buf.readUInt16LE(buf.indexOf("fmt ") + 8 + 24); // WAVE_FORMAT_EXTENSIBLE: sub-format tag
const bps = fmt.bits / 8, frames = Math.floor(data.length / (bps * fmt.ch)), out = new Float32Array(frames);
const get = fmt.tag === 3 && fmt.bits === 32 ? (o) => data.readFloatLE(o) : fmt.tag === 3 && fmt.bits === 64 ? (o) => data.readDoubleLE(o)
: fmt.tag === 1 && fmt.bits === 16 ? (o) => data.readInt16LE(o) / 32768 : fmt.tag === 1 && fmt.bits === 24 ? (o) => data.readIntLE(o, 3) / 8388608
: fmt.tag === 1 && fmt.bits === 32 ? (o) => data.readInt32LE(o) / 2147483648 : null;
if (!get) throw new Error(`unsupported WAV format tag ${fmt.tag} / ${fmt.bits} bit`);
for (let i = 0; i < frames; i++) { let s = 0; for (let c = 0; c < fmt.ch; c++) s += get((i * fmt.ch + c) * bps); out[i] = s / fmt.ch; } // mono mix
return { samples: out, sampleRate: fmt.sr, channels: fmt.ch };
}
const NUM = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/, num = (x) => (typeof x === "number" ? x : typeof x === "string" && NUM.test(x.trim()) ? Number(x) : NaN);
// A header is a first data line whose first field does not start like a number; a first field that does (a timecode, a hex number) is an error.
const isHeader = (line, first) => first && !/^[+-]?\.?\d/.test(line);
// Frame-diff track: lines of "t,value" (seconds, strictly increasing) or a single "value" column with fps given. Only the first line may be a header.
// Anything else that does not parse is an error: a silently dropped or shifted row would give a plausible wrong lag.
export function readTrack(text, fps) {
const t = [], v = [];
let first = true, ncols = 0;
text.replace(/^/, "").split(/\r\n|\r|\n/).forEach((raw, ln) => {
const line = raw.trim().replace(/[\s,;]+$/, ""); if (!line) return;
const wasFirst = first; first = false;
const tok = line.split(/[\s,;]+/), f = tok.map(num);
if (f.some((x) => !Number.isFinite(x))) { if (isHeader(line, wasFirst)) return; throw new Error(`track line ${ln + 1} is not numeric: ${raw}`); }
if (f.length > 2) throw new Error(`track line ${ln + 1} has ${f.length} columns; expected "t,value" or one value per line: ${raw}`);
if (ncols && f.length !== ncols) throw new Error(`track line ${ln + 1} has ${f.length} column(s), earlier lines had ${ncols} (an empty cell?): ${raw}`);
ncols = f.length;
if (f.length === 2) { if (t.length && f[0] <= t[t.length - 1]) throw new Error(`track line ${ln + 1}: time ${f[0]} does not increase`); t.push(f[0]); v.push(f[1]); }
else { if (!fps) throw new Error("single-column track needs --fps"); t.push(v.length / fps); v.push(f[0]); }
});
if (v.length < 3) throw new Error("track has fewer than 3 frames");
return { t: Float64Array.from(t), v: Float64Array.from(v) };
}
// Events: JSON array of numbers or {t,label}, or text lines "t[,label]" in seconds ('#' comments; the first line may be a header). Anything else is an error.
export function readEvents(text) {
const s = text.replace(/^/, "").trim(), fin = (x, where) => { if (!Number.isFinite(x)) throw new Error(`event ${where} has no numeric time`); return x; };
let out;
if (s.startsWith("[") || s.startsWith("{")) {
const j = JSON.parse(s), a = Array.isArray(j) ? j : j.events;
if (!Array.isArray(a)) throw new Error("events JSON must be an array or {events:[...]}");
out = a.map((e, i) => {
if (typeof e === "number") return { t: fin(e, i), label: `e${i}` };
if (!e || typeof e !== "object") return fin(NaN, i);
if (e.label !== undefined && typeof e.label !== "string") throw new Error(`event ${i} label must be a string`);
return { t: fin(num(e.t), i), label: e.label ?? `e${i}` };
});
} else {
out = []; let first = true;
s.split(/\r\n|\r|\n/).forEach((raw, ln) => {
const l = raw.trim(); if (!l || l.startsWith("#")) return;
const wasFirst = first; first = false;
const m = l.match(/^([^,;\s]+)(?:[,;\s]+(.*))?$/), t = num(m[1]);
if (!Number.isFinite(t)) { if (isHeader(l, wasFirst)) return; throw new Error(`events line ${ln + 1} does not start with a time in seconds: ${raw}`); }
out.push({ t, label: m[2]?.trim() || `e${out.length}` });
});
}
if (!out.length) throw new Error("the events log has no events");
return out;
}
// Video via ffmpeg: audio as mono f32 at 48 kHz, video as 64x36 gray frames; per-frame mean luminance and mean |frame diff| (motion flux).
// Stream start_time offsets are applied so both clocks are the container's clock; video is resampled to constant frame rate by pts.
export function readVideo(path, { ffmpeg = process.env.FFMPEG ?? "ffmpeg", targetFps } = {}) {
// ffmpeg only (no ffprobe: static builds ship without it). `ffmpeg -i` lists the streams; the first decoded frame of each stream says when it starts.
const run = (a) => { const r = spawnSync(ffmpeg, a, { encoding: "utf8", maxBuffer: 1 << 26 }); if (r.error?.code === "ENOENT") throw new Error("ffmpeg is not on PATH (set FFMPEG=/path/to/ffmpeg); give --wav and --track instead"); return String(r.stderr ?? ""); };
const lines = run(["-hide_banner", "-i", path]).split("\n"), find = (kind) => lines.find((l) => new RegExp(`Stream #\\d+:\\d+.*: ${kind}:`).test(l));
const vl = find("Video"), al = find("Audio");
if (!vl || !al) throw new Error("file needs one video and one audio stream");
const idx = (l) => Number(l.match(/Stream #\d+:(\d+)/)[1]), fpsm = vl.match(/(\d+(?:\.\d+)?) fps/) ?? vl.match(/(\d+(?:\.\d+)?) tbr/);
const fps = targetFps ?? Number(fpsm?.[1] ?? 30), W = 64, H = 36, vi = idx(vl), ai = idx(al);
// Time of a stream's first frame on the container clock (ffmpeg shifts every stream by the earliest start, so these are relative to that).
const first = (flags) => { const m = run(["-hide_banner", "-i", path, ...flags, "-f", "null", "-"]).match(/pts_time:\s*(-?\d+(?:\.\d+)?)/); if (!m) throw new Error("could not read a stream start time from ffmpeg"); return Number(m[1]); };
const _vStart = first(["-map", `0:${vi}`, "-an", "-frames:v", "1", "-vf", "showinfo"]), aStart = first(["-map", `0:${ai}`, "-vn", "-frames:a", "1", "-af", "ashowinfo"]);
const out = (a) => execFileSync(ffmpeg, ["-v", "error", "-i", path, ...a, "-"], { maxBuffer: 1 << 30 });
const audioRaw = out(["-map", `0:${ai}`, "-vn", "-ac", "1", "-ar", "48000", "-f", "f32le"]);
// Resample to constant frame rate by presentation time (a variable-frame-rate source, e.g. a browser capture, repeats frames). ffmpeg pads the cfr
// video from the container start (the earliest stream start), so frame f is at f/fps whatever the video's own start_time: the video start is
// already in the padding and is not added again (adding it was a 2x-offset bug); the audio, written unpadded, carries its start as startS.
const videoRaw = out(["-map", `0:${vi}`, "-an", "-vf", `fps=${fps},scale=${W}:${H}:flags=area,format=gray`, "-f", "rawvideo"]);
const frames = Math.floor(videoRaw.length / (W * H)), t = new Float64Array(frames), lum = new Float64Array(frames), flux = new Float64Array(frames);
for (let f = 0; f < frames; f++) {
let s = 0, df = 0; for (let i = 0; i < W * H; i++) { const p = videoRaw[f * W * H + i]; s += p; if (f) df += Math.abs(p - videoRaw[(f - 1) * W * H + i]); }
t[f] = f / fps; lum[f] = s / (W * H) / 255; flux[f] = f ? df / (W * H) / 255 : 0;
}
const a = new Float32Array(audioRaw.buffer.slice(audioRaw.byteOffset, audioRaw.byteOffset + audioRaw.length - (audioRaw.length % 4)));
return { audio: { samples: a, sampleRate: 48000, startS: aStart }, luma: { t, v: lum }, flux: { t, v: flux }, fps, resampled: true, videoStartS: _vStart };
}
// ---------- onset detection ----------
const median = (a) => { const s = Float64Array.from(a).sort(); const n = s.length; return n ? (n % 2 ? s[(n - 1) / 2] : (s[n / 2 - 1] + s[n / 2]) / 2) : 0; };
// Pick onsets from a nonnegative novelty curve nov[i] at times tt[i]. A peak is a local maximum above max(floor, med + k*1.4826*MAD) and
// at least minGapS from the previous kept peak (the larger wins). The onset is the first sample of the rise that reaches riseFrac of the peak.
function pickOnsets(nov, tt, { k, floor, minGapS, riseFrac }) {
const med = median(nov), mad = median(nov.map((x) => Math.abs(x - med))), thr = Math.max(floor, med + k * 1.4826 * mad);
const peaks = [];
for (let i = 0; i < nov.length; i++) {
if (nov[i] < thr || (i && nov[i] < nov[i - 1]) || (i + 1 < nov.length && nov[i] <= nov[i + 1])) continue;
const last = peaks[peaks.length - 1];
if (last && tt[i] - tt[last] < minGapS) { if (nov[i] > nov[last]) peaks[peaks.length - 1] = i; continue; }
peaks.push(i);
}
return {
threshold: thr,
onsets: peaks.map((p) => {
let i = p; while (i > 0 && nov[i - 1] >= riseFrac * nov[p] && tt[p] - tt[i - 1] < 0.1) i--;
return { t: tt[i], peakT: tt[p], strength: nov[p] };
}),
};
}
// Audio: log-magnitude spectral flux, 1024 window / 128 hop (2.7 ms at 48 kHz), half-wave rectified, summed over 60 Hz..Nyquist.
export function audioOnsets(x, sr, { startS = 0, k = 8, minGapS = 0.05 } = {}) {
const N = 1024, HOP = 128, win = Float64Array.from({ length: N }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / N));
const lo = Math.ceil((60 * N) / sr), hi = N / 2, frames = Math.max(0, Math.floor((x.length - N) / HOP) + 1);
const flux = new Float64Array(frames), tt = new Float64Array(frames), prev = new Float64Array(hi + 1), re = new Float64Array(N), im = new Float64Array(N);
for (let f = 0; f < frames; f++) {
for (let i = 0; i < N; i++) { re[i] = x[f * HOP + i] * win[i]; im[i] = 0; }
fft(re, im);
let s = 0;
for (let b = lo; b <= hi; b++) { const m = Math.log1p(1e3 * Math.hypot(re[b], im[b]) / N); if (f) s += Math.max(0, m - prev[b]); prev[b] = m; }
flux[f] = s; tt[f] = startS + (f * HOP + N / 2) / sr; // a frame is stamped at its centre
}
// The window centre sits half a window after the first audible sample's frame start; the rise-start pick lands within one window of the true onset.
const r = pickOnsets(flux, tt, { k, floor: 1e-3 * hi, minGapS, riseFrac: 0.25 });
// The flux frame is only good to a window; refine each onset on the samples. DC and the steady bed are estimated from the stretch before the
// search window (block peaks of |x - median|, their median, so a previous hit's tail in one block does not count); the onset is the first sample
// whose deviation exceeds max(10% of the local peak, 2.5x the bed). Search: from one window before the rise start (the window centre can sit up to
// N/2 early of a click inside it) to one window after the flux peak (over a loud bed the rise start can sit far before the real onset). If nothing clears the bar the flux time stands.
for (const o of r.onsets) {
const c = Math.round((o.t - startS) * sr), cp = Math.round((o.peakT - startS) * sr), a0 = Math.max(0, c - N), a1 = Math.min(x.length, cp + N), b0 = Math.max(0, a0 - 8 * 512), blocks = [];
const dc = median(Array.from(x.subarray(b0, a1)));
for (let q = b0; q + 512 <= a0; q += 512) { let m = 0; for (let i = q; i < q + 512; i++) m = Math.max(m, Math.abs(x[i] - dc)); blocks.push(m); }
const bed = blocks.length ? median(blocks) : 0;
let pk = 0; for (let i = a0; i < a1; i++) pk = Math.max(pk, Math.abs(x[i] - dc));
const bar = Math.max(0.1 * pk, 2.5 * bed);
for (let i = a0; i < a1; i++) if (Math.abs(x[i] - dc) >= bar && bar < pk) { o.t = startS + i / sr; break; }
}
// Two flux peaks can refine to the same sample onset (a step in the bed plus the hit): keep one per refined time within the minimum gap.
r.onsets = r.onsets.reduce((acc, o) => { const l = acc[acc.length - 1]; if (l && o.t - l.t < minGapS) { if (o.strength > l.strength) acc[acc.length - 1] = { ...o, t: Math.min(o.t, l.t) }; } else acc.push(o); return acc; }, []);
r.resolutionMs = (1000 * HOP) / sr; r.windowMs = (1000 * N) / sr;
return r;
}
// Visual: onset = frame where the track rises. Luminance uses the positive frame-to-frame step; motion flux is already a change measure.
export function visualOnsets(track, { kind = "luma", k = 8, minGapS = 0.1 } = {}) {
const { t, v } = track, nov = new Float64Array(v.length);
for (let i = 1; i < v.length; i++) nov[i] = kind === "luma" ? Math.max(0, v[i] - v[i - 1]) : Math.max(0, v[i]);
const range = Math.max(...v) - Math.min(...v);
const r = pickOnsets(nov, t, { k, floor: 0.1 * range + 1e-12, minGapS, riseFrac: 0.5 });
const dts = []; for (let i = 1; i < t.length; i++) dts.push(t[i] - t[i - 1]);
r.framePeriodMs = 1000 * median(dts);
return r;
}
// ---------- pairing and verdict ----------
// One-to-one greedy matching of onsets to anchors (events, or audio onsets) by distance, within windowS. Returns Map anchorIndex -> onsetIndex.
function match(anchors, onsets, windowS) {
const edges = [];
anchors.forEach((a, i) => onsets.forEach((o, j) => { const d = Math.abs(o.t - a); if (d <= windowS) edges.push([d, i, j]); }));
edges.sort((x, y) => x[0] - y[0]);
const ai = new Map(), oj = new Set();
for (const [, i, j] of edges) if (!ai.has(i) && !oj.has(j)) { ai.set(i, j); oj.add(j); }
return ai;
}
// Nearest-onset pairing is right only if the true lag is under half the spacing of that train's onsets. The caller declares the largest lag it
// considers plausible (the pairing window); a pair whose neighbouring onset lies closer than twice that cannot be told from a swap.
const crowded = (list, o, windowS) => list.some((x) => x !== o && Math.abs(x.t - o.t) < 2 * windowS);
// Frame quantisation: a visual change at time tau first shows in the frame stamped at or after tau, so visual - tau is in [0, framePeriod) and the
// measured lag (audio - visual) is biased LOW by up to a frame period. The truth therefore lies in [lag - audioHop/2, lag + framePeriod + audioHop/2];
// a pair is judged on that interval, not on a point.
export function syncAnalyze({ audio, sampleRate, audioStartS = 0, visual, visualKind = "luma", events, profile = "default", leadMs, lagMs, pairWindowMs = 200, minGapMs, allowUnpaired = false, visualResampled = false }) {
if (!PROFILES[profile]) throw new Error(`unknown profile ${profile}`);
const prof = { ...PROFILES[profile] }, custom = leadMs !== undefined || lagMs !== undefined;
if (leadMs !== undefined) prof.leadMs = leadMs;
if (lagMs !== undefined) prof.lagMs = lagMs;
if (custom) { prof.note = `profile ${profile} with the window overridden on the command line`; }
for (const [n, x] of [["leadMs", prof.leadMs], ["lagMs", prof.lagMs], ["pairWindowMs", pairWindowMs]]) if (!Number.isFinite(x) || x < 0 || (n === "pairWindowMs" && x === 0)) throw new Error(`${n} must be a ${n === "pairWindowMs" ? "positive" : "non-negative"} number`);
const gap = minGapMs === undefined ? {} : { minGapS: minGapMs / 1000 };
const a = audioOnsets(audio, sampleRate, { startS: audioStartS, ...gap }), v = visualOnsets(visual, { kind: visualKind, ...gap });
// A video resampled to constant frame rate by `fps` snaps each frame to the nearest source frame: half a period more slack on each side.
const hopMs = a.resolutionMs, periodMs = v.framePeriodMs, win = pairWindowMs / 1000, snapMs = visualResampled ? periodMs / 2 : 0;
const classify = (lag) => {
const lo = lag - hopMs / 2 - snapMs, hi = lag + periodMs + hopMs / 2 + snapMs; // truth interval, ms
if (lo >= -prof.leadMs && hi <= prof.lagMs) return "inside-window";
if (lo > prof.lagMs) return "audio-late-detectable";
if (hi < -prof.leadMs) return "audio-early-detectable";
return "borderline";
};
const pairs = [], usedA = new Set(), usedV = new Set();
const row = (label, eventS, ao, vo, ambiguous) => {
const r = { label, eventS, audioS: ao?.t ?? null, visualS: vo?.t ?? null, audioVsEventMs: ao && eventS !== null ? (ao.t - eventS) * 1e3 : null, visualVsEventMs: vo && eventS !== null ? (vo.t - eventS) * 1e3 : null, lagMs: ao && vo ? (ao.t - vo.t) * 1e3 : null };
if (r.lagMs !== null) { r.lagLowMs = r.lagMs - hopMs / 2 - snapMs; r.lagHighMs = r.lagMs + periodMs + hopMs / 2 + snapMs; }
r.verdict = r.lagMs === null ? (ao ? "no-visual-onset" : vo ? "no-audio-onset" : "no-onsets") : ambiguous ? "ambiguous-pairing" : classify(r.lagMs);
return r;
};
if (events) {
const et = events.map((e) => e.t), ma = match(et, a.onsets, win), mv = match(et, v.onsets, win);
events.forEach((e, i) => {
const ao = ma.has(i) ? a.onsets[ma.get(i)] : undefined, vo = mv.has(i) ? v.onsets[mv.get(i)] : undefined;
if (ao) usedA.add(ma.get(i)); if (vo) usedV.add(mv.get(i));
pairs.push(row(e.label, e.t, ao, vo, (ao && crowded(a.onsets, ao, win)) || (vo && crowded(v.onsets, vo, win))));
});
} else {
const m = match(a.onsets.map((o) => o.t), v.onsets, win);
a.onsets.forEach((ao, i) => {
if (!m.has(i)) return;
const vo = v.onsets[m.get(i)]; usedA.add(i); usedV.add(m.get(i));
pairs.push(row(`a${i}`, null, ao, vo, crowded(a.onsets, ao, win) || crowded(v.onsets, vo, win)));
});
}
const lags = pairs.map((p) => p.lagMs).filter((x) => x !== null);
const count = (f) => pairs.filter(f).length;
const summary = {
pairs: lags.length, audioOnsets: a.onsets.length, visualOnsets: v.onsets.length,
unpairedAudio: a.onsets.length - usedA.size, unpairedVisual: v.onsets.length - usedV.size,
medianLagMs: lags.length ? median(lags) : null, minLagMs: lags.length ? Math.min(...lags) : null, maxLagMs: lags.length ? Math.max(...lags) : null,
// Spread of the lags: a constant offset is a latency (fixable by a delay), a wide spread is jitter (not fixable by one number).
spreadMs: lags.length ? Math.max(...lags) - Math.min(...lags) : null,
outsideWindow: count((p) => /detectable$/.test(p.verdict)), borderline: count((p) => p.verdict === "borderline"), ambiguous: count((p) => p.verdict === "ambiguous-pairing"),
missing: count((p) => p.lagMs === null),
};
const unpaired = summary.unpairedAudio + summary.unpairedVisual;
summary.pass = summary.pairs > 0 && summary.outsideWindow === 0 && summary.missing === 0 && summary.ambiguous === 0 && (allowUnpaired || unpaired === 0);
return {
schema: "aqua-feel.sync.v1", lagConvention: "lagMs = audio onset - visual onset; positive = audio late, negative = audio early. Truth lies in [lagLowMs, lagHighMs] (visual onsets are quantised up to the next frame)",
profile: { name: custom ? `${profile}+override` : profile, ...prof }, allowUnpaired, pairWindowMs, visualResampled, visualKind, audioResolutionMs: hopMs, framePeriodMs: periodMs,
audioOnsets: a.onsets, visualOnsets: v.onsets, pairs, summary,
};
}
export function formatSync(r) {
const f = (x, d = 1) => (x === null || x === undefined ? " - " : x.toFixed(d).padStart(7));
const L = [`profile ${r.profile.name}: audio may lead by ${r.profile.leadMs} ms, lag by ${r.profile.lagMs} ms (${r.profile.note})`,
`lag = audio - visual (+ = audio late). The visual onset is quantised up to the next frame (period ${r.framePeriodMs.toFixed(1)} ms), so the true lag lies in [lag - ${(r.audioResolutionMs / 2 + (r.visualResampled ? r.framePeriodMs / 2 : 0)).toFixed(1)}, lag + ${(r.framePeriodMs * (r.visualResampled ? 1.5 : 1) + r.audioResolutionMs / 2).toFixed(1)}] ms${r.visualResampled ? " (video resampled to constant frame rate)" : ""}`,
"", "label event s audio s visual s a-ev ms v-ev ms lag ms verdict"];
for (const p of r.pairs) L.push(`${String(p.label).padEnd(10).slice(0, 10)} ${f(p.eventS, 3)} ${f(p.audioS, 3)} ${f(p.visualS, 3)} ${f(p.audioVsEventMs)} ${f(p.visualVsEventMs)} ${f(p.lagMs)} ${p.verdict}`);
const s = r.summary, rng = s.minLagMs === null ? "-" : `${s.minLagMs.toFixed(1)}..${s.maxLagMs.toFixed(1)}`;
L.push("", `${s.pairs} paired, ${s.unpairedAudio} audio and ${s.unpairedVisual} visual onsets unpaired${r.allowUnpaired ? " (allowed)" : " (fails the check unless --allow-unpaired)"}; median lag ${s.medianLagMs === null ? "-" : s.medianLagMs.toFixed(1)} ms, range ${rng} ms (spread ${s.spreadMs === null ? "-" : s.spreadMs.toFixed(1)} ms)`,
`${s.outsideWindow} outside the window, ${s.borderline} borderline, ${s.ambiguous} ambiguous (neighbouring onset closer than ${2 * r.pairWindowMs} ms), ${s.missing} without both onsets. ${s.pass ? (s.borderline ? `NOT DETECTABLY OUTSIDE THE WINDOW (${s.borderline} borderline: the interval straddles the edge)` : "WITHIN WINDOW") : "NOT WITHIN WINDOW"} by this metric.`,
"A metric over two onset lists, not a verdict: a person still has to watch and listen (SYNC.md).");
return L.join("\n");
}

View file

@ -0,0 +1,238 @@
// Checks of `aqua-feel sync` on synthetic fixtures with known offsets. No real capture is used. Usage: node sync.test.mjs
import assert from "node:assert/strict";
import { formatSync, syncAnalyze, readWav, readTrack, readEvents, audioOnsets, visualOnsets, PROFILES } from "./sync.mjs";
import { readFileSync } from "node:fs";
import { wavFloat32 } from "./render.js";
import { makeFixture, FIXTURE_EVENTS_S } from "./sync-fixture.mjs";
let n = 0;
const t = (name, fn) => { fn(); n++; console.log(`ok ${name}`); };
const run = (o, extra = {}) => { const fx = makeFixture(o); return { fx, r: syncAnalyze({ audio: fx.audio, sampleRate: fx.sampleRate, visual: fx.visual, events: fx.events, ...extra }) }; };
// The truth must lie in the interval each pair reports ([lagLowMs, lagHighMs]); the interval is the claim, a point estimate is not.
const inInterval = (p, truth) => p.lagLowMs - 1e-9 <= truth && truth <= p.lagHighMs + 1e-9;
const OFFGRID = [0.5042, 1.3117, 2.2209, 3.4033, 4.1161, 5.0087]; // events between frame boundaries at 30 and 60 fps
for (const fps of [30, 60]) for (const off of [-60, -20, 0, 40, 80, 150, 190]) {
t(`${fps} fps, ${off} ms offset: the truth is inside every pair's interval, events on and off the frame grid`, () => {
for (const events of [FIXTURE_EVENTS_S, OFFGRID]) {
const { r } = run({ offsetMs: off, fps, events });
assert.equal(r.pairs.length, events.length);
for (const p of r.pairs) assert.ok(inInterval(p, off), `${p.label}: [${p.lagLowMs}, ${p.lagHighMs}] vs ${off}`);
assert.equal(r.summary.unpairedVisual, 0, "no spurious visual onsets"); assert.equal(r.summary.unpairedAudio, 0, "no spurious audio onsets");
}
});
}
t("the interval is no wider than one frame period plus one audio hop (it is a measurement, not a shrug)", () => {
const { r } = run({ offsetMs: 80, fps: 60 }), p = r.pairs[0];
assert.ok(p.lagHighMs - p.lagLowMs <= r.framePeriodMs + r.audioResolutionMs + 1e-9);
});
t("verdict classes follow the default window (lead 25, lag 100) judged on the interval", () => {
const v = (off, o = {}) => run({ offsetMs: off, ...o }).r;
assert.equal(v(0).summary.pass, true);
assert.equal(v(60).summary.pass, true, "60 ms late is inside the 100 ms lag window");
assert.equal(v(150).pairs[0].verdict, "audio-late-detectable");
assert.equal(v(-60).pairs[0].verdict, "audio-early-detectable");
assert.equal(v(95).pairs[0].verdict, "borderline", "the interval straddles the 100 ms edge");
assert.equal(v(95).summary.pass, true, "borderline is not detectably outside, so it does not fail the gate");
assert.match(formatSync(v(95)), /borderline/, "but the report says so");
assert.match(formatSync(v(0)), /WITHIN WINDOW/); assert.doesNotMatch(formatSync(v(95)), /WITHIN WINDOW/);
assert.equal(v(85, { fps: 120 }).pairs[0].verdict, "inside-window", "a finer frame clock narrows the interval");
assert.equal(v(85, { fps: 30 }).pairs[0].verdict, "borderline", "a coarser one widens it");
});
t("the profile changes the verdict, not the measurement", () => {
const a = run({ offsetMs: 70 }, { profile: "default" }).r, b = run({ offsetMs: 70 }, { profile: "ebu-r37" }).r;
assert.equal(a.pairs[0].lagMs, b.pairs[0].lagMs);
assert.equal(a.summary.pass, true); assert.equal(b.summary.pass, false);
assert.match(run({ offsetMs: 70 }, { lagMs: 200 }).r.profile.note, /overridden/, "an overridden window is not reported as the default profile");
assert.ok(PROFILES.default.leadMs === 25 && PROFILES.default.lagMs === 100);
});
t("without an events log, onsets pair by proximity and the offset is still recovered", () => {
const fx = makeFixture({ offsetMs: 60 }), r = syncAnalyze({ audio: fx.audio, sampleRate: fx.sampleRate, visual: fx.visual });
assert.equal(r.pairs.length, FIXTURE_EVENTS_S.length);
for (const p of r.pairs) assert.ok(inInterval(p, 60));
});
t("jitter shows as spread, not as a median: alternate events 0 and 90 ms late", () => {
const fx = makeFixture({ offsetMs: 0 }), clean = makeFixture({ offsetMs: 90 }), a = Float32Array.from(fx.audio);
const s0 = Math.round(FIXTURE_EVENTS_S[1] * 48000) - 100, s1 = Math.round(FIXTURE_EVENTS_S[1] * 48000) + 4800;
for (let i = s0; i < s1; i++) a[i] = clean.audio[i]; // event 1 comes from the 90 ms-late render
const r = syncAnalyze({ audio: a, sampleRate: 48000, visual: fx.visual, events: fx.events });
assert.ok(r.summary.spreadMs > 80 && r.summary.spreadMs < 100, `spread ${r.summary.spreadMs}`);
});
t("an event with no sound is reported, not paired to a neighbour", () => {
const fx = makeFixture({ offsetMs: 0 }), a = Float32Array.from(fx.audio);
for (let i = Math.round(2.0 * 48000); i < Math.round(2.6 * 48000); i++) a[i] = 0; // silence the 2.2 s hit
const r = syncAnalyze({ audio: a, sampleRate: 48000, visual: fx.visual, events: fx.events });
const p = r.pairs.find((x) => x.eventS === 2.2);
assert.equal(p.verdict, "no-audio-onset"); assert.equal(p.lagMs, null); assert.equal(r.summary.pass, false);
});
t("an offset past the 200 ms pairing window is reported as missing, not as a longer lag", () => {
const { r } = run({ offsetMs: 250 });
assert.equal(r.pairs[0].verdict, "no-audio-onset"); assert.equal(r.summary.pass, false);
});
t("sparse events (0.7 s or more apart) pair without ambiguity at a +80 ms lag; the same lag at 0.3 s spacing is ambiguous", () => {
assert.equal(run({ offsetMs: 80 }).r.summary.ambiguous, 0);
const ev = [0.5, 0.8, 1.1, 1.4, 1.7];
const { r } = run({ offsetMs: 80, events: ev });
assert.equal(r.summary.ambiguous, ev.length); assert.equal(r.summary.pass, false);
});
// ---- regressions from the Soul pass on PR #32 ----
t("a noise bed, a steady-tone bed or a DC offset under the hits does not move the audio onset (refinement is relative to the bed)", () => {
for (const o of [{ noise: 0.03 }, { bed: [[220, 0.05], [330, 0.03]] }, { dc: 0.25 }, { gain: 0.03, noise: 0.003 }, { dc: -0.3, noise: 0.02 }]) {
const { r } = run({ offsetMs: 50, ...o });
assert.equal(r.pairs.length, 6, JSON.stringify(o));
for (const p of r.pairs) assert.ok(inInterval(p, 50), `${JSON.stringify(o)} ${p.label}: ${p.lagMs}`);
}
});
t("dense hits: events 120 ms apart with a +80 ms lag are not silently paired to the previous hit", () => {
const ev = [0.5, 0.62, 0.74, 0.86, 0.98, 1.1, 1.22];
const { r } = run({ offsetMs: 80, events: ev });
// Either the pairing is right, or it is flagged; it is never a confident wrong lag with a pass.
for (const p of r.pairs) assert.ok(inInterval(p, 80) || p.verdict === "ambiguous-pairing" || p.lagMs === null, `${p.label}: ${p.lagMs} ${p.verdict}`);
assert.equal(r.summary.pass, false, "overlapping candidates inside the pairing window cannot pass");
assert.ok(r.summary.ambiguous > 0);
});
t("an audio onset is never reused by two events", () => {
const { r } = run({ offsetMs: 200, events: [0.5, 0.58, 0.66, 0.74] });
const used = r.pairs.map((p) => p.audioS).filter((x) => x !== null);
assert.equal(new Set(used).size, used.length);
});
t("a narrower --pair-window-ms resolves dense hits that the default calls ambiguous", () => {
const ev = [0.5, 0.62, 0.74, 0.86, 0.98];
const { r } = run({ offsetMs: 30, events: ev }, { pairWindowMs: 50 });
for (const p of r.pairs) assert.ok(inInterval(p, 30), `${p.label}: ${p.lagMs}`);
assert.equal(r.summary.ambiguous, 0);
});
t("flashes closer than the visual minimum gap merge, and then an event goes unpaired rather than getting another flash's lag", () => {
const { r } = run({ offsetMs: 0, events: [0.5, 0.56, 0.62, 1.5] });
assert.equal(r.summary.pass, false);
for (const p of r.pairs) assert.ok(p.lagMs === null || inInterval(p, 0) || p.verdict === "ambiguous-pairing", `${p.label}: ${p.lagMs} ${p.verdict}`);
});
t("a stray sound or flash fails the check unless --allow-unpaired is given", () => {
const fx = makeFixture({ offsetMs: 0 }), a = Float32Array.from(fx.audio), click = makeFixture({ offsetMs: 0, events: [2.8] }).audio;
for (let i = 134000; i < 137500; i++) a[i] += click[i];
const run2 = (o) => syncAnalyze({ audio: a, sampleRate: 48000, visual: fx.visual, events: fx.events, ...o });
assert.equal(run2({}).summary.unpairedAudio, 1); assert.equal(run2({}).summary.pass, false);
assert.equal(run2({ allowUnpaired: true }).summary.pass, true);
});
t("bad options fail loudly: NaN window, unknown profile", () => {
const fx = makeFixture({});
assert.throws(() => syncAnalyze({ audio: fx.audio, sampleRate: 48000, visual: fx.visual, lagMs: NaN }), /non-negative/);
assert.throws(() => syncAnalyze({ audio: fx.audio, sampleRate: 48000, visual: fx.visual, profile: "nope" }), /unknown profile/);
});
t("parsers refuse input that would give a plausible wrong answer", () => {
assert.throws(() => readEvents("0:00.5\n0:01.2\n"), /does not start with a time/);
assert.throws(() => readEvents("time,label\nfoo,bar\n"), /line 2/);
assert.deepEqual(readEvents("time,label\n0.5,a\n1.5,b, loud\n").map((e) => e.label), ["a", "b, loud"], "a header on line 1 is skipped; commas stay in the label");
assert.throws(() => readEvents("[0.5, null]"), /no numeric time/);
assert.throws(() => readEvents('[{"time":2}]'), /no numeric time/);
assert.throws(() => readEvents("# only a comment\n"), /no events/);
assert.throws(() => readTrack("0,0,0.1\n1,0.1,0.2\n2,0.2,0.3\n"), /3 columns/);
assert.throws(() => readTrack("0.0,0.1\n0.0167,\n0.0333,0.3\n"), /earlier lines had 2/);
assert.throws(() => readTrack("0,0.1\n0.5,0.2\n0.4,0.3\n"), /does not increase/);
assert.throws(() => readEvents('[{"t":null}]'), /no numeric time/); assert.throws(() => readEvents('[{"t":""},1]'), /no numeric time/);
assert.throws(() => readEvents('[{"t":true}]'), /no numeric time/); assert.throws(() => readEvents("0x10\n"), /does not start with a time/);
assert.throws(() => readEvents('[{"t":1,"label":{"a":1}}]'), /label must be a string/);
assert.throws(() => readEvents("0:01.5\n"), /does not start with a time/);
});
t("valid layouts are accepted: header after blank or comment lines, semicolons, trailing commas, CRLF, BOM", () => {
assert.equal(readTrack("\n\nt,luma\r\n0,0.1,\r\n0.1;0.2\r\n0.2 0.3\r\n").v.length, 3);
assert.equal(readTrack("\uFEFF0,0.1\n0.1,0.2\n0.2,0.3\n").v.length, 3);
assert.equal(readEvents("0.5,a\r1.0,b\r").length, 2, "CR-only line ends");
assert.equal(readTrack("t0,luma_0to1\n0,0.1\n0.1,0.2\n0.2,0.3\n").v.length, 3, "a header may contain digits");
assert.deepEqual(readEvents("# list\ntime (s);label\n0.5;kick\n1.0;snare, loud\n").map((e) => [e.t, e.label]), [[0.5, "kick"], [1, "snare, loud"]]);
});
t("zero-width windows are expressible: --lag-ms 0 makes any positive lag outside", () => {
assert.equal(run({ offsetMs: 40 }, { lagMs: 0 }).r.summary.pass, false);
});
t("silence and a flat track yield no onsets and no pass", () => {
const r = syncAnalyze({ audio: new Float32Array(48000 * 2), sampleRate: 48000, visual: { t: Float64Array.from({ length: 120 }, (_, i) => i / 60), v: new Float64Array(120).fill(0.5) } });
assert.equal(r.summary.audioOnsets, 0); assert.equal(r.summary.visualOnsets, 0); assert.equal(r.summary.pass, false);
});
t("a sine-bed with a swell rather than a click still gives one audio onset near its start", () => {
const sr = 48000, x = new Float32Array(sr * 3);
for (let i = Math.round(1 * sr); i < x.length; i++) { const tt = (i - sr) / sr; x[i] = 0.5 * Math.min(1, tt / 0.01) * Math.sin(2 * Math.PI * 440 * tt); }
const o = audioOnsets(x, sr).onsets;
assert.equal(o.length, 1); assert.ok(Math.abs(o[0].t - 1.0) < 0.012, `${o[0].t}`);
});
t("motion-flux track: a step in flux is an onset at its frame", () => {
const tt = Float64Array.from({ length: 90 }, (_, i) => i / 30), v = new Float64Array(90); v[30] = 0.4; v[31] = 0.3;
const o = visualOnsets({ t: tt, v }, { kind: "flux" }).onsets;
assert.equal(o.length, 1); assert.ok(Math.abs(o[0].t - 1.0) < 1e-9);
});
t("readWav reads 16-bit PCM and 32-bit float, mixes stereo to mono, and rejects non-WAV", () => {
const f = wavFloat32(Float32Array.from([0, 0.5, -0.5])), w = readWav(f);
assert.equal(w.sampleRate, 48000); assert.deepEqual(Array.from(w.samples), [0, 0.5, -0.5]);
const b = Buffer.alloc(44 + 8); b.write("RIFF", 0); b.writeUInt32LE(44, 4); b.write("WAVEfmt ", 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(2, 22);
b.writeUInt32LE(44100, 24); b.writeUInt32LE(176400, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(16, 34); b.write("data", 36); b.writeUInt32LE(8, 40);
b.writeInt16LE(16384, 44); b.writeInt16LE(-16384, 46); b.writeInt16LE(8192, 48); b.writeInt16LE(8192, 50);
const s = readWav(b); assert.equal(s.sampleRate, 44100); assert.deepEqual(Array.from(s.samples), [0, 0.25]);
assert.throws(() => readWav(Buffer.from("not a wav file at all")));
});
t("readTrack takes header + t,value rows or a single column with fps; readEvents takes lines or JSON", () => {
const a = readTrack("t,luma\n0,0.1\n0.1,0.2\n0.2,0.3\n"); assert.deepEqual(Array.from(a.t), [0, 0.1, 0.2]);
const b = readTrack("0.1\n0.2\n0.3\n", 10); assert.ok(Math.abs(b.t[2] - 0.2) < 1e-12);
assert.throws(() => readTrack("0.1\n0.2\n0.3\n"), /fps/);
assert.deepEqual(readEvents("# hits\n0.5,kick\n1.25\n").map((e) => e.t), [0.5, 1.25]);
assert.deepEqual(readEvents('[{"t":2,"label":"x"},3]').map((e) => e.label), ["x", "e1"]);
});
// The video path needs ffmpeg (FFMPEG=/path or on PATH); skipped, and said so, when there is none.
{
const { readVideo } = await import("./sync.mjs"), { execFileSync } = await import("node:child_process");
const ff = process.env.FFMPEG ?? "ffmpeg";
let have = true; try { execFileSync(ff, ["-version"], { stdio: "ignore" }); } catch { have = false; }
if (!have) console.log("skip video path: no ffmpeg (set FFMPEG)");
if (have) for (const [vOff, aOff, vfps] of [[0, 0, 60], [0.06, 0, 60], [0, 0.08, 60], [0.25, 0.1, 60], [0.5, 0.5, 60], [0.1, 0, 15], [0.1, 0, 24], [0, 0.1, 15]]) {
const { mkdtempSync, writeFileSync } = await import("node:fs"), { tmpdir } = await import("node:os"), { join } = await import("node:path");
const dir = mkdtempSync(join(tmpdir(), "sync-clip-")), fx = makeFixture({ offsetMs: 0, fps: 60, seconds: 4, events: [0.5, 1.4, 2.3, 3.2] });
const wavP = join(dir, "a.wav"), rawP = join(dir, "v.gray"), outP = join(dir, "clip.mkv");
writeFileSync(wavP, wavFloat32(fx.audio));
writeFileSync(rawP, Buffer.concat(Array.from(fx.visual.v, (x) => Buffer.alloc(64 * 36, Math.round(255 * x)))));
// encode each stream alone, then mux with -itsoffset and stream copy: that is what puts a start_time on the stream in the file
const vP = join(dir, "v.mkv"), aP = join(dir, "a.mka");
execFileSync(ff, ["-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "gray", "-s", "64x36", "-r", "60", "-i", rawP, "-vf", `fps=${vfps}:round=up`, "-c:v", "ffv1", vP]);
execFileSync(ff, ["-v", "error", "-y", "-i", wavP, "-c:a", "pcm_s16le", aP]);
execFileSync(ff, ["-v", "error", "-y", "-itsoffset", String(vOff), "-i", vP, "-itsoffset", String(aOff), "-i", aP, "-map", "0:v", "-map", "1:a", "-c", "copy", outP]);
const v = readVideo(outP, { ffmpeg: ff }), truth = (aOff - vOff) * 1000; // audio = visual shifted by (aOff - vOff)
// events are on the container clock, which ffmpeg shifts so the earliest stream starts at 0
const r = syncAnalyze({ audio: v.audio.samples, sampleRate: v.audio.sampleRate, audioStartS: v.audio.startS, visual: v.luma, events: fx.events.map((e) => ({ t: e.t + vOff - Math.min(vOff, aOff), label: e.label })), visualResampled: v.resampled });
n++; console.log(`ok ffmpeg clip, video (${vfps} fps) starts ${vOff * 1000} ms, audio ${aOff * 1000} ms: true lag ${truth} ms inside every interval`);
for (const p of r.pairs) assert.ok(p.lagLowMs - 1e-6 <= truth && truth <= p.lagHighMs + 1e-6, `video@${vOff}/${vfps}fps audio@${aOff}: [${p.lagLowMs}, ${p.lagHighMs}] vs ${truth}`);
assert.equal(r.pairs.length, 4);
}
if (have) t("a real browser capture (VFR vp8+opus webm): every logged hit pairs, and the lag is a constant (spread under one frame)", () => {
const v = readVideo(new URL("./fixtures/browser-capture.webm", import.meta.url).pathname, { ffmpeg: ff });
const ev = readEvents(readFileSync(new URL("./fixtures/browser-capture.events.txt", import.meta.url), "utf8"));
const r = syncAnalyze({ audio: v.audio.samples, sampleRate: v.audio.sampleRate, audioStartS: v.audio.startS, visual: v.luma, events: ev });
assert.equal(r.pairs.length, 7); assert.equal(r.summary.missing, 0);
assert.ok(r.summary.spreadMs < 1000 / v.fps, `spread ${r.summary.spreadMs}`);
assert.equal(r.summary.unpairedAudio + r.summary.unpairedVisual, 0);
for (const p of r.pairs) assert.ok(p.visualVsEventMs >= 0 && p.visualVsEventMs <= 1000 / v.fps, `flash ${p.visualVsEventMs} ms off its logged instant`);
});
}
console.log(`${n} sync checks passed. Fixtures only (synthetic, plus one browser capture when ffmpeg is present); nothing was heard or seen.`);