Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


commit f0bf9843d5890f808b28dc68732589c22c966f37
truex <truex@equwal.com>
2026-09-26 13:31:34 -0700

Keep one aligner for each subtitle file

The reader now makes the aligner when it loads the subtitles, and gives
it the tokens of each page. The aligner keeps the result of each page,
so an alignment after a new page costs the work of that page only.

The tokens of a new page now go into their place in the token list.
Before, the reader joined the tokens of all read pages again after each
page.

 src/main.ts | 16 +++++++++-------
 1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/src/main.ts b/src/main.ts
index 2aeb67f..0273d22 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,7 +1,7 @@
 // UI wiring. The logic lives in the pure modules: align.ts, follower.ts,
 // hit-test.ts, text-layer.ts, player-state.ts, play-clock.ts.
 
-import { alignCuesToTokens, type OcrToken, type TokenSpan } from './align';
+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';
@@ -83,6 +83,8 @@ const state = {
   /** The tokens of the pages read so far, in page order. */
   tokens: [] as OcrToken[],
   cues: [] as Cue[],
+  /** The aligner of the loaded subtitles. It keeps the result of each page. */
+  aligner: null as Aligner | null,
   spans: [] as TokenSpan[],
   /** The page of each cue, or null for an unmatched cue. */
   cuePages: [] as (number | null)[],
@@ -276,7 +278,9 @@ async function readBook(): Promise<void> {
       if (seq !== state.readSeq) return; // Another book or another setting took over.
       counts[entry.source]++;
       state.pages[page] = entry;
-      state.tokens = state.pages.flatMap((p) => p?.tokens ?? []);
+      // Put the tokens of the page at their place in page order.
+      const at = state.pages.slice(0, page).reduce((n, p) => n + (p?.tokens.length ?? 0), 0);
+      state.tokens.splice(at, 0, ...entry.tokens);
       setStatus(`Page ${page + 1}: ${entry.source === 'text' ? 'text layer' : 'OCR'}.`);
       ui.pagesStatus.textContent =
         `Pages: ${counts.text + counts.ocr}/${pdf.numPages} read ` +
@@ -308,6 +312,7 @@ ui.clearCache.addEventListener('click', async () => {
 /** Load subtitles from a file or from SubRead. `source` names where they came from. */
 function loadSubtitles(text: string, source: string): void {
   state.cues = parseSubtitles(text);
+  state.aligner = createAligner(state.cues.map((c) => c.text));
   state.srt = text;
   state.spans = [];
   state.cuePages = [];
@@ -394,11 +399,8 @@ function scheduleAlign(): void {
 /** Align the cues to the pages read so far. */
 function align(): void {
   state.alignTimer = null;
-  if (state.cues.length === 0 || state.tokens.length === 0) return;
-  state.spans = alignCuesToTokens(
-    state.cues.map((c) => c.text),
-    state.tokens,
-  );
+  if (!state.aligner || state.cues.length === 0 || state.tokens.length === 0) return;
+  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.`);