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 }