Skip to content

Commit 4d9be01

Browse files
authored
Merge pull request #1 from OMARVII/fix/flash-counting-off-by-one
fix: reliable flash detection at threshold frequencies
2 parents ad840ea + 5f0821c commit 4d9be01

2 files changed

Lines changed: 130 additions & 16 deletions

File tree

src/detector.js

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,16 @@ var NeuroShieldDetector = (function () {
2424
const TARGET_FPS = 15;
2525
const FRAME_INTERVAL_MS = Math.round(1000 / TARGET_FPS);
2626

27-
/** Sliding window duration for flash counting (ms). */
28-
const WINDOW_DURATION_MS = 1000;
27+
/**
28+
* Sliding window duration for flash counting (ms).
29+
* Extended by one frame interval beyond 1 second so the window reliably
30+
* captures transitions at the boundary. Without this, a flash whose
31+
* direction change falls right at the 1-second edge is missed ~40% of the
32+
* time, causing under-detection at the exact threshold frequency (e.g.
33+
* 3 Hz at medium sensitivity). The extra 67 ms has negligible impact on
34+
* false-positive rate (effective threshold shifts from 3.0 Hz to ~2.8 Hz).
35+
*/
36+
const WINDOW_DURATION_MS = 1000 + FRAME_INTERVAL_MS;
2937

3038
/**
3139
* WCAG general flash threshold.
@@ -329,7 +337,7 @@ var NeuroShieldDetector = (function () {
329337
return { isFlashing: false, flashCount: 0, isRedFlash: false, peakDelta: 0, severity: 'safe' };
330338
}
331339

332-
var transitions = 0;
340+
var directionChanges = 0;
333341
var redTransitions = 0;
334342
var peakDelta = 0;
335343
var lastDirection = 0; // +1 = getting brighter, -1 = getting darker
@@ -346,9 +354,11 @@ var NeuroShieldDetector = (function () {
346354
if (absDelta >= LUMINANCE_DELTA_THRESHOLD && darkerLum < DARK_STATE_THRESHOLD) {
347355
var direction = delta > 0 ? 1 : -1;
348356

349-
// Count opposing transitions (direction change)
350-
if (lastDirection !== 0 && direction !== lastDirection) {
351-
transitions++;
357+
// Count every significant direction change, including the first.
358+
// The first change starts a potential flash cycle; subsequent
359+
// opposing changes continue it. Two direction changes = one flash.
360+
if (lastDirection === 0 || direction !== lastDirection) {
361+
directionChanges++;
352362
}
353363
lastDirection = direction;
354364

@@ -360,10 +370,10 @@ var NeuroShieldDetector = (function () {
360370
}
361371
}
362372

363-
// Each flash = 1 full cycle (bright→dark→bright) = 2 opposing transitions
364-
// But we count individual opposing transitions, so flashCount ≈ transitions
365-
// Being conservative: any opposing transition pair counts
366-
var flashCount = Math.ceil(transitions / 2);
373+
// Each flash = 1 full cycle (bright→dark→bright) = 2 direction changes.
374+
// floor() is used because an incomplete cycle (single direction change)
375+
// does not constitute a full flash per WCAG 2.3.1.
376+
var flashCount = Math.floor(directionChanges / 2);
367377
var threshold = SENSITIVITY[this.sensitivity] || SENSITIVITY.medium;
368378
var isFlashing = flashCount >= threshold;
369379
var isRedFlash = redTransitions >= 1;

test/detector.test.js

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -361,14 +361,13 @@ describe('Flash detection (_analyzeWindow integration)', () => {
361361
});
362362

363363
it('does NOT trigger at medium for 2 flash cycles but DOES trigger at high', () => {
364-
// 2 flash cycles = 4 opposing transitions = ceil(4/2) = 2 flashes
364+
// 2 complete cycles: D→B→D→B→D (4 direction changes → floor(4/2)=2)
365365
const history = [
366366
{ timestamp: 0, luminance: 0.0, redRatio: 0 },
367-
{ timestamp: 100, luminance: 0.9, redRatio: 0 }, // up
368-
{ timestamp: 200, luminance: 0.0, redRatio: 0 }, // down (transition 1)
369-
{ timestamp: 300, luminance: 0.9, redRatio: 0 }, // up (transition 2)
370-
{ timestamp: 400, luminance: 0.0, redRatio: 0 }, // down (transition 3)
371-
{ timestamp: 500, luminance: 0.9, redRatio: 0 }, // up (transition 4)
367+
{ timestamp: 100, luminance: 0.9, redRatio: 0 }, // up (change 1)
368+
{ timestamp: 200, luminance: 0.0, redRatio: 0 }, // down (change 2)
369+
{ timestamp: 300, luminance: 0.9, redRatio: 0 }, // up (change 3)
370+
{ timestamp: 400, luminance: 0.0, redRatio: 0 }, // down (change 4)
372371
];
373372

374373
const medDet = createDetectorWithHistory('medium', history);
@@ -382,6 +381,34 @@ describe('Flash detection (_analyzeWindow integration)', () => {
382381
`high should trigger at ${highResult.flashCount} flashes`);
383382
});
384383

384+
it('triggers at maximum for a single complete flash cycle', () => {
385+
// 1 complete cycle: D→B→D (2 direction changes → floor(2/2)=1)
386+
const history = [
387+
{ timestamp: 0, luminance: 0.1, redRatio: 0 },
388+
{ timestamp: 67, luminance: 0.8, redRatio: 0 },
389+
{ timestamp: 134, luminance: 0.1, redRatio: 0 },
390+
];
391+
const det = createDetectorWithHistory('maximum', history);
392+
const result = det._analyzeWindow();
393+
assert.strictEqual(result.isFlashing, true,
394+
`maximum should trigger at ${result.flashCount} flash`);
395+
assert.strictEqual(result.flashCount, 1);
396+
});
397+
398+
it('does NOT trigger at maximum for a single direction change (half-cycle)', () => {
399+
// Half cycle: D→B only (1 direction change → floor(1/2)=0)
400+
const history = [
401+
{ timestamp: 0, luminance: 0.1, redRatio: 0 },
402+
{ timestamp: 67, luminance: 0.1, redRatio: 0 },
403+
{ timestamp: 134, luminance: 0.8, redRatio: 0 },
404+
];
405+
const det = createDetectorWithHistory('maximum', history);
406+
const result = det._analyzeWindow();
407+
assert.strictEqual(result.isFlashing, false,
408+
'a single direction change is not a complete flash');
409+
assert.strictEqual(result.flashCount, 0);
410+
});
411+
385412
it('ignores luminance changes below the 0.1 threshold', () => {
386413
const history = [];
387414
for (let i = 0; i < 20; i++) {
@@ -507,6 +534,83 @@ describe('Flash detection (_analyzeWindow integration)', () => {
507534
});
508535
});
509536

537+
describe('realistic 15fps simulation', () => {
538+
it('detects 3Hz flashing at medium when sampled at 15fps over full window', () => {
539+
// Simulate exactly what the real detector sees: 15fps sampling (67ms apart)
540+
// over the extended window (1067ms). A 3Hz signal alternates every ~167ms.
541+
const fps = 15;
542+
const interval = Math.round(1000 / fps); // 67ms
543+
const windowMs = 1000 + interval; // 1067ms
544+
const flashHz = 3;
545+
const halfPeriod = 1000 / (flashHz * 2); // ~167ms per half-cycle
546+
547+
const history = [];
548+
for (let t = 0; t <= windowMs; t += interval) {
549+
// Which half-cycle are we in? Even = dark, odd = bright.
550+
const phase = Math.floor(t / halfPeriod);
551+
history.push({
552+
timestamp: t,
553+
luminance: phase % 2 === 0 ? 0.05 : 0.85,
554+
redRatio: 0,
555+
});
556+
}
557+
558+
const det = createDetectorWithHistory('medium', history);
559+
const result = det._analyzeWindow();
560+
assert.strictEqual(result.isFlashing, true,
561+
`3Hz at medium should trigger (got ${result.flashCount} flashes from ${history.length} frames)`);
562+
assert.ok(result.flashCount >= 3,
563+
`expected >= 3 flashes, got ${result.flashCount}`);
564+
});
565+
566+
it('does NOT detect 2Hz flashing at medium (below 3Hz threshold)', () => {
567+
const fps = 15;
568+
const interval = Math.round(1000 / fps);
569+
const windowMs = 1000 + interval;
570+
const flashHz = 2;
571+
const halfPeriod = 1000 / (flashHz * 2);
572+
573+
const history = [];
574+
for (let t = 0; t <= windowMs; t += interval) {
575+
const phase = Math.floor(t / halfPeriod);
576+
history.push({
577+
timestamp: t,
578+
luminance: phase % 2 === 0 ? 0.05 : 0.85,
579+
redRatio: 0,
580+
});
581+
}
582+
583+
const det = createDetectorWithHistory('medium', history);
584+
const result = det._analyzeWindow();
585+
assert.strictEqual(result.isFlashing, false,
586+
`2Hz at medium should NOT trigger (got ${result.flashCount} flashes)`);
587+
});
588+
589+
it('detects 1Hz flashing at maximum sensitivity', () => {
590+
const fps = 15;
591+
const interval = Math.round(1000 / fps);
592+
const windowMs = 1000 + interval;
593+
const flashHz = 1;
594+
const halfPeriod = 1000 / (flashHz * 2);
595+
596+
const history = [];
597+
for (let t = 0; t <= windowMs; t += interval) {
598+
const phase = Math.floor(t / halfPeriod);
599+
history.push({
600+
timestamp: t,
601+
luminance: phase % 2 === 0 ? 0.05 : 0.85,
602+
redRatio: 0,
603+
});
604+
}
605+
606+
const det = createDetectorWithHistory('maximum', history);
607+
const result = det._analyzeWindow();
608+
assert.strictEqual(result.isFlashing, true,
609+
`1Hz at maximum should trigger (got ${result.flashCount} flashes)`);
610+
assert.strictEqual(result.flashCount, 1);
611+
});
612+
});
613+
510614
describe('FlashDetector class', () => {
511615
it('defaults to medium sensitivity', () => {
512616
const det = new FlashDetector({ tagName: 'VIDEO' }, {});

0 commit comments

Comments
 (0)