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


src/cue-pages.ts (4385 bytes)

1 // Where each cue is on the pages, and the page of the audio at a time. Pure.
2 //
3 // Most cues are on one page. A cue at a page break has text on two pages.
4 // The narrator reads the text in order, so the page follows the time: the
5 // first page for the share of the text of the cue on it, then the next page.
6 
7 import { normalizeText, type OcrToken, type TokenSpan } from './align';
8 
9 /** The part of a cue on one page. */
10 export interface PagePart {
11   page: number;
12   /** The share of the text of the cue on this page: more than 0, at most 1. The shares of a cue add up to 1. */
13   share: number;
14 }
15 
16 /** The parts of a cue in page order. Empty for an unmatched cue. */
17 export type CueParts = readonly PagePart[];
18 
19 /** A cue in time, in seconds. */
20 export interface CueTime {
21   start: number;
22   end: number;
23 }
24 
25 /** How many cues back an unmatched cue borrows the page of a matched one. */
26 export const LOOKBACK = 5;
27 
28 /**
29  * A seek to the start of a page lands this many seconds into its text, at
30  * most. A clock that rounds the time then does not show the page before.
31  */
32 export const SEEK_INTO = 0.05;
33 
34 /** The count of normalized characters of each token text that was seen. */
35 const weights = new Map<string, number>();
36 
37 /** The count of normalized characters of a token: 0 for punctuation, as in the alignment. */
38 function weight(text: string): number {
39   let n = weights.get(text);
40   if (n === undefined) {
41     n = normalizeText(text).length;
42     weights.set(text, n);
43   }
44   return n;
45 }
46 
47 /**
48  * The parts of each cue on the pages, from the spans of the alignment.
49  * The share of a page is the share of the normalized characters of the
50  * span on that page. The tokens must be in page order.
51  */
52 export function cueParts(tokens: readonly OcrToken[], spans: readonly TokenSpan[]): CueParts[] {
53   return spans.map((span) => {
54     if (!span.matched) return [];
55     const counts: { page: number; chars: number }[] = [];
56     let total = 0;
57     for (let t = span.start; t < span.end; t++) {
58       const token = tokens[t];
59       if (!token) continue;
60       const n = weight(token.text);
61       if (n === 0) continue;
62       const last = counts[counts.length - 1];
63       if (last && last.page === token.page) last.chars += n;
64       else counts.push({ page: token.page, chars: n });
65       total += n;
66     }
67     return counts.map((c) => ({ page: c.page, share: c.chars / total }));
68   });
69 }
70 
71 /** How far time `t` is into `cue`: 0 at its start, 1 at its end and after it. */
72 export function cueProgress(cue: CueTime, t: number): number {
73   const length = cue.end - cue.start;
74   if (length <= 0) return 1;
75   return Math.min(1, Math.max(0, (t - cue.start) / length));
76 }
77 
78 /**
79  * The page of the audio at `progress` into cue `cue`. For a matched cue it
80  * is the page of the part that holds the progress: the first page until
81  * the share of the text on it is read, then the next page. An unmatched
82  * cue borrows the last page of the nearest matched cue before it, within
83  * LOOKBACK cues. Null when there is none.
84  */
85 export function audioPage(
86   parts: readonly CueParts[],
87   cue: number,
88   progress: number,
89 ): number | null {
90   const own = parts[cue];
91   if (own && own.length > 0) {
92     let end = 0;
93     for (const part of own) {
94       end += part.share;
95       if (progress < end) return part.page;
96     }
97     return own[own.length - 1]!.page;
98   }
99   for (let i = cue - 1; i >= 0 && i >= cue - LOOKBACK; i--) {
100     const before = parts[i];
101     if (before && before.length > 0) return before[before.length - 1]!.page;
102   }
103   return null;
104 }
105 
106 /**
107  * The time where the text of `page` starts, in seconds: in the first cue
108  * with text on the page. When that cue starts on a page before, the time
109  * is inside the cue, after the share of its text on the pages before. The
110  * time is up to SEEK_INTO later, but inside the part of the page. Null
111  * when no cue has text on the page.
112  */
113 export function pageStartTime(
114   parts: readonly CueParts[],
115   cues: readonly CueTime[],
116   page: number,
117 ): number | null {
118   for (let i = 0; i < parts.length; i++) {
119     const cue = cues[i];
120     if (!cue) continue;
121     let before = 0;
122     for (const part of parts[i]!) {
123       if (part.page === page) {
124         const length = Math.max(0, cue.end - cue.start);
125         return cue.start + length * before + Math.min(SEEK_INTO, (length * part.share) / 2);
126       }
127       before += part.share;
128     }
129   }
130   return null;
131 }