commit 44663928256d377d72cf0437c9329466677a6e59 truex <truex@equwal.com> 2026-09-26 13:36:32 -0700 Follow again after an alignment only when the cue moves align() applied the clock again, as a seek, after each alignment. While the reader reads pages in the background, an alignment runs every few seconds, so the page turned back to the cue again and again, also when the user had turned pages by hand. cueMoved in follower.ts now decides. It is true only when the new alignment changes the page of the cue of now (for example, the cue becomes matched) or the page that the cue borrows. A property test shows that the follow gives the same result whenever cueMoved is false.
README.md | 4 +++- src/follower.ts | 18 +++++++++++++++ src/main.ts | 19 +++++++++++---- test/follower.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md index 73d309d..13ae4de 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ A cue that the alignment did not find in the book has no page. In the two follow modes the page then follows the nearest matched cue before it, up to five cues back. The same cue does not turn the page twice: you can turn pages by hand while a long cue plays, and the page follows again at -the next cue. +the next cue. A new alignment after a page is read turns the page only +when it finds the cue of now for the first time or moves it to another +page. ### The top bar diff --git a/src/follower.ts b/src/follower.ts index 42e7e03..654ea0a 100644 --- a/src/follower.ts +++ b/src/follower.ts @@ -43,6 +43,24 @@ export function pageForCue(cuePages: readonly (number | null)[], cue: number): n return null; } +/** + * True when a new alignment moves the cue: its own page changed (for + * example, it became matched), or the page that stands for it changed. + * Only then must the follow act again after an alignment. Else a page + * that the user turned by hand stays. + */ +export function cueMoved( + before: readonly (number | null)[], + after: readonly (number | null)[], + cue: number, +): boolean { + if (cue < 0) return false; + return ( + (before[cue] ?? null) !== (after[cue] ?? null) || + pageForCue(before, cue) !== pageForCue(after, cue) + ); +} + /** * One step of the follow. * diff --git a/src/main.ts b/src/main.ts index 3f8ce7b..64f58eb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align'; import { bookText } from './book-text'; import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source'; -import { follow, type FollowMode } from './follower'; +import { cueMoved, follow, type FollowMode } from './follower'; import { lookupText, tokenAt } from './hit-test'; import { lineBoxes } from './line-boxes'; import { createOcr, type Ocr } from './ocr'; @@ -405,13 +405,18 @@ function scheduleAlign(): void { function align(): void { state.alignTimer = null; if (!state.aligner || state.cues.length === 0 || state.tokens.length === 0) return; + const before = state.cuePages; state.spans = state.aligner.spans(state.pages.map((p) => p?.tokens)); state.cuePages = state.spans.map((s) => (s.matched ? state.tokens[s.start]!.page : null)); const matched = state.cuePages.filter((p) => p !== null).length; setStatus(`${matched}/${state.cues.length} cues matched to the page text.`); renderCueList(); - // The cue of now may be matched now. Apply the clock state again. - if (state.clock) applyClock({ ...state.clock, seeked: true }); + // Follow again only when the alignment moved the cue of now, for + // example when it became matched. A page read in the background must not + // undo a page turn by hand. + if (state.clock && cueMoved(before, state.cuePages, clockCue(state.clock))) { + applyClock({ ...state.clock, seeked: true }); + } } // --- Cue list --- @@ -474,12 +479,16 @@ function showPlayerStatus(s: ClockState): void { } } +/** The cue at the position of a clock state, or -1. */ +function clockCue(s: ClockState): number { + return s.positionMs === null ? -1 : cueIndexAt(state.cues, s.positionMs / 1000); +} + /** One state of the clock: mark the cue of now and turn the page. */ function applyClock(s: ClockState): void { state.clock = s; showPlayerStatus(s); - if (s.positionMs === null) return; - const cue = cueIndexAt(state.cues, s.positionMs / 1000); + const cue = clockCue(s); // The last cue stays marked in the silence between two cues. if (cue < 0) return; const out = follow({ diff --git a/test/follower.test.ts b/test/follower.test.ts index c236d80..5006a85 100644 --- a/test/follower.test.ts +++ b/test/follower.test.ts @@ -1,6 +1,13 @@ import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; -import { follow, LOOKBACK, pageForCue, type FollowInput, type FollowMode } from '../src/follower'; +import { + cueMoved, + follow, + LOOKBACK, + pageForCue, + type FollowInput, + type FollowMode, +} from '../src/follower'; const pages: (number | null)[] = [0, 0, null, 1, 1, null, null, 2]; @@ -33,6 +40,61 @@ describe('pageForCue', () => { }); }); +describe('cueMoved', () => { + it('is false when the new alignment keeps the page of the cue', () => { + expect(cueMoved([0, 1, 1], [0, 1, 1], 2)).toBe(false); + }); + + it('is false when only other cues change, as when a page is read in the background', () => { + expect(cueMoved([0, 1, null, null], [0, 1, 2, 2], 1)).toBe(false); + }); + + it('is true when the cue becomes matched', () => { + expect(cueMoved([], [0, 1, 1], 1)).toBe(true); + expect(cueMoved([0, null, 1], [0, 1, 1], 1)).toBe(true); + }); + + it('is true when the cue moves to another page', () => { + expect(cueMoved([0, 1], [0, 2], 1)).toBe(true); + }); + + it('is true when an unmatched cue borrows another page', () => { + expect(cueMoved([0, null], [1, null], 1)).toBe(true); + }); + + it('is false between cues', () => { + expect(cueMoved([0], [1], -1)).toBe(false); + }); + + it('is true whenever the new alignment changes what the follow does', () => { + // Half of the cues are unmatched. The pages are few, so a change often + // gives the same page again. + const page = fc.option(fc.nat({ max: 3 }), { nil: null, freq: 2 }); + const step = fc.array(page, { minLength: 1, maxLength: 30 }).chain((before) => + fc.record({ + before: fc.constant(before), + cue: fc.integer({ min: -1, max: before.length - 1 }), + // Changes at the cue and in the cues that it can borrow a page from. + changes: fc.array(fc.tuple(fc.nat({ max: LOOKBACK + 1 }), page), { maxLength: 3 }), + mode: fc.constantFrom<FollowMode>('highlight', 'pages', 'off'), + currentPage: fc.nat({ max: 3 }), + previousCue: fc.integer({ min: -1, max: 30 }), + seeked: fc.boolean(), + }), + ); + fc.assert( + fc.property(step, ({ before, changes, ...i }) => { + const after = [...before]; + for (const [back, p] of changes) if (i.cue - back >= 0) after[i.cue - back] = p; + const old = follow({ ...i, cuePages: before }); + const now = follow({ ...i, cuePages: after }); + if (!cueMoved(before, after, i.cue)) expect(now).toEqual(old); + }), + { numRuns: 1000 }, + ); + }); +}); + describe('follow', () => { it('marks a matched cue and stays on its page', () => { expect(follow(input({ cue: 1 }))).toEqual({ highlightCue: 1, turnToPage: null });