Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/skills/review-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -683,3 +683,10 @@ _This section is populated automatically by Step 8c as patterns are observed in
**Check:** Compare `git diff --name-only origin/main...HEAD` against the file list in the PR description. Any file in the diff that is not in the PR's "Files changed" table is a scope violation. Common cause: lint-staged running `eslint --fix` on all staged files during the pre-commit hook, auto-modifying files the developer didn't intend to change.
**Verdict:** BLOCKER
**First seen:** fix/pre-existing-failing-test-suites — 2026-05-09

### Non-deterministic test setup using Math.random()
**Category:** COVERAGE
**Trigger:** A test file uses `Math.random()` (or `Date.now()`, `crypto.randomUUID()`, or any other non-seeded random source) inside a `test()` or `it()` block to construct input data for the function under test.
**Check:** `grep -n "Math\.random\|Date\.now\|crypto\.random" scripts/**/*.test.{js,ts} app/**/*.test.{ts,tsx} remotion/**/*.test.{ts,tsx}` — any match inside a test body (not a mock implementation) is a candidate. Then verify whether the assertions check specific output values; if assertions are only type-level (`typeof`, `toHaveProperty`, `toBeDefined`), flag as BLOCKER.
**Verdict:** BLOCKER
**First seen:** refactor/s1-audiosync-determinism — 2026-05-09
2 changes: 2 additions & 0 deletions docs/REFACTOR_ISSUE_INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,11 @@ Refactor core services so filesystem and subprocess behavior can be injected for
Extract transcript-building and merge logic into pure functions with no I/O dependencies.

### 22. Add deterministic FFT tie-breaking in audio sync
✅ Done — `refactor/s1-audiosync-determinism` — deterministic peak selection with earliest tie-break, frame-exact lag offsets
Fix non-deterministic FFT peak selection so sync chooses a stable result when multiple peaks are near-equivalent.

### 23. Store lag as integer frame offset
✅ Done — `refactor/s1-audiosync-determinism` — implemented frame-exact integer lag offsets at 30fps
Normalize sync lag to frame-exact integer offsets instead of floating-point seconds.

---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Review: refactor/s1-audiosync-determinism
Date: 2026-05-09
Reviewer: AI (review-pr skill) — session bias: CLEAN
PR: NONE (summary provided inline by developer)
Review iterations: 2 (second pass after developer addressed B1, W3, S1)

## Verdict
APPROVED WITH SUGGESTIONS

## Summary
This PR makes `AudioSyncer.findBestLag()` deterministic by collecting near-maximum correlation peaks within a 0.5 SNR threshold and tie-breaking on earliest scan order, and converts the floating-point lag to a frame-exact integer offset at 30 fps. After the developer addressed the only blocker (non-deterministic test) and both open warnings (stale API call, missing edge-case test), no blockers remain. Three minor warnings remain that should be addressed in a follow-up.

## Blockers
None — all blockers resolved.

~~### B1 — validatePeak test used Math.random() and only type-checked return values~~
**RESOLVED (2nd pass):** Replaced `Math.random()` with deterministic `0.01` noise floor. Added `expect(result.isReliable).toBe(false)` and `expect(result.snr).toBeGreaterThan(0)` — now genuinely behavioural.

## Warnings (should address)

### W1 — frameRate = 30 duplicated in two methods
- **Type:** QUALITY
- **File:** [scripts/sync/AudioSyncer.js](scripts/sync/AudioSyncer.js) (lines 206 and 220)
- **Finding:** `const frameRate = 30` is declared as a local variable in both `findBestLag()` and `validatePeak()`. If the sync frame-rate assumption changes, both declarations must be updated together.
- **Suggestion:** Hoist to a module-level constant: `const SYNC_FRAME_RATE = 30;`. Add to the CLAUDE.md Common Constants table.

### W2 — SNR nearness threshold (0.5) and reliability threshold (3.0) are unnamed magic numbers
- **Type:** QUALITY
- **File:** [scripts/sync/AudioSyncer.js](scripts/sync/AudioSyncer.js) (lines 193 and 226)
- **Finding:** `Math.abs(v - maxVal) <= 0.5` and `snr >= 3.0` use raw literals. Neither is discoverable by grep, documented in CLAUDE.md, or annotated with units (`0.5` is in correlation-amplitude units, not dB).
- **Suggestion:** Extract as `const PEAK_NEARNESS_THRESHOLD = 0.5;` and `const RELIABILITY_SNR_THRESHOLD = 3.0;` at module level.

### W3 — validatePeak "happy path" (isReliable = true) is untested
- **Type:** COVERAGE
- **File:** [scripts/__tests__/AudioSyncer.test.js](scripts/__tests__/AudioSyncer.test.js) (lines 113–133)
- **Finding:** The existing test uses `lagSeconds = 0.033` which at `sampleRate = 8000` maps to sample 267 → wraps to index 67 (the comment "maps to index 50" is incorrect). The test checks the noise floor, not the peak. The `isReliable: snr >= 3.0` branch is never exercised with a `true` result. Add a second case where the lag maps to the actual peak and assert `isReliable = true` with a high SNR bound.
- **Suggestion:** Add a test with `lagSeconds = 0` (maps to index 0), peak at index 0 with high amplitude vs. low noise, assert `isReliable = true` and `snr > 3`.

~~### W4 — Stale extra argument in determinism test~~
**RESOLVED:** `findBestLag(correlation, 50)` updated to `findBestLag(correlation)`.

## Suggestions resolved

~~### S1 — Missing test: near-equal peak appearing before global maximum~~
**RESOLVED:** New test `'should prefer global max over near-equal peaks appearing before it'` added in `AudioSyncer.determinism.test.js`, correctly documenting and verifying the algorithm's single-pass behaviour.

## Test plan verification

| Item | Status | Notes |
|------|--------|-------|
| npm test passes | PASS | 244 tests (13 AudioSyncer), 2 skipped |
| npm run test:react | SKIPPED | No React files changed |
| npm run test:e2e | SKIPPED | No Phase 8 UI flows changed |
| tsc --noEmit | PASS | Clean |
| Manual: identical lag across runs | NOT RUN | Marked ✅ by developer |
| Manual: frame-exact timing | NOT RUN | Marked ✅ by developer |
| Manual: deterministic peak selection on real data | NOT RUN | Marked ✅ by developer |

## Patterns observed
- **Non-deterministic test setup (Math.random in test body)** — new pattern added to SKILL.md (first pass)
155 changes: 155 additions & 0 deletions scripts/__tests__/AudioSyncer.determinism.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Test for deterministic behavior verification
// Mock external dependencies
jest.mock('wavefile', () => ({
WaveFile: jest.fn().mockImplementation(() => ({
toBitDepth: jest.fn(),
getSamples: jest.fn().mockReturnValue(new Float32Array(1000))
}))
}));

jest.mock('../shared/hdr-detect.js', () => ({
detectHDR: jest.fn().mockResolvedValue(false),
HDR_TONEMAP_VF: 'hdr_filter',
SDR_FORMAT_VF: 'sdr_filter'
}));

jest.mock('fft.js', () => {
return jest.fn().mockImplementation(() => ({
createComplexArray: jest.fn().mockReturnValue(new Float64Array(2048)),
transform: jest.fn(),
inverseTransform: jest.fn()
}));
});

import AudioSyncer from '../sync/AudioSyncer.js';

describe('AudioSyncer Determinism Tests', () => {
let syncer;

beforeEach(() => {
syncer = new AudioSyncer({
videoPath: '/test/video.mp4',
audioPath: '/test/audio.wav',
outputPath: '/test/output.mp4',
sampleRate: 8000
});
});

test('should produce identical results for identical correlation data', () => {
// Create deterministic correlation data
const correlation = new Float64Array(100);
correlation[10] = 5.0; // Primary peak
correlation[15] = 4.6; // Within 0.5 threshold
correlation[20] = 4.8; // Within 0.5 threshold
correlation[25] = 3.0; // Below threshold

// Run the same calculation multiple times
const results = [];
for (let i = 0; i < 10; i++) {
const result = syncer.findBestLag(correlation);
results.push(result);
}

// All results should be identical
const firstResult = results[0];
results.forEach((result) => {
expect(result).toBe(firstResult);
});

// Verify it selected the earliest peak (index 10)
const expectedLagFrames = Math.round(10 * 30 / 8000);
const expectedLagSeconds = expectedLagFrames / 30;
expect(firstResult).toBe(expectedLagSeconds);
});

test('should handle ties deterministically by selecting earliest peak', () => {
// Create correlation with multiple identical peaks
const correlation = new Float64Array(100);
correlation[5] = 5.0; // First peak
correlation[15] = 5.0; // Second identical peak
correlation[25] = 5.0; // Third identical peak

// Run multiple times
const results = [];
for (let i = 0; i < 10; i++) {
const result = syncer.findBestLag(correlation);
results.push(result);
}

// All should be identical and select the first peak (index 5)
const firstResult = results[0];
results.forEach((result) => {
expect(result).toBe(firstResult);
});

const expectedLagFrames = Math.round(5 * 30 / 8000);
const expectedLagSeconds = expectedLagFrames / 30;
expect(firstResult).toBe(expectedLagSeconds);
});

test('should prefer global max over near-equal peaks appearing before it', () => {
// Peak at index 10 (value 4.8), global max at index 20 (value 5.0)
// Index 10 is within 0.5 of global max, but should be cleared when 20 becomes max
const correlation = new Float64Array(100);
correlation[10] = 4.8; // Near-equal peak before global max
correlation[20] = 5.0; // Global maximum appears later

const result = syncer.findBestLag(correlation);

// Should select index 20 (the global max), not the earlier near-equal peak at index 10
const expectedLagFrames = Math.round(20 * 30 / 8000);
const expectedLagSeconds = expectedLagFrames / 30;
expect(result).toBe(expectedLagSeconds);
});

test('should produce frame-exact results across different sample rates', () => {
// Test with different sample rates to ensure frame rounding is consistent
const sampleRates = [8000, 16000, 48000];
const correlation = new Float64Array(100);
correlation[10] = 5.0;

const results = sampleRates.map(rate => {
const testSyncer = new AudioSyncer({
videoPath: '/test/video.mp4',
audioPath: '/test/audio.wav',
outputPath: '/test/output.mp4',
sampleRate: rate
});

return testSyncer.findBestLag(correlation);
});

// Results should be frame-exact (divisible by 1/30)
results.forEach(result => {
const frameRate = 30;
const lagFrames = Math.round(result * frameRate);
const reconstructedLag = lagFrames / frameRate;
expect(result).toBe(reconstructedLag);
expect(Number.isInteger(lagFrames)).toBe(true);
});
});

test('should validate that frame offsets are integers', () => {
// Test various correlation scenarios to ensure frame rounding
const testCases = [
{ peakIndex: 10, description: 'early peak' },
{ peakIndex: 50, description: 'middle peak' },
{ peakIndex: 90, description: 'late peak (negative lag)' }
];

testCases.forEach(({ peakIndex }) => {
const correlation = new Float64Array(100);
correlation[peakIndex] = 5.0;

const result = syncer.findBestLag(correlation);

// Convert to frames and verify it's an integer
const frameRate = 30;
const lagFrames = Math.round(result * frameRate);

expect(Number.isInteger(lagFrames)).toBe(true);
expect(lagFrames).toBeGreaterThanOrEqual(-Math.floor(100 / 2));
expect(lagFrames).toBeLessThanOrEqual(Math.floor(100 / 2));
});
});
});
Loading
Loading