frontend/engine/align.js (24688 bytes)
1 /* The alignment engine, in the browser. 2 * 3 * A transcript with times and a book without them go in; subtitles worded by 4 * the book and timed by the transcript come out. 5 * 6 * This is a port of SubPlz's aligner (ats.align, subplz.align.shift_align), 7 * kept statement-for-statement where it is a port, quirks included: the tests 8 * in tests/engine run it against the reference implementation's real output, 9 * stage by stage, and it is only trustworthy while it reproduces that exactly. 10 * 11 * What is not a port is anchoredAlign. The reference aligns one chapter at a 12 * time in a full dynamic-programming table and needs gigabytes to do it. This 13 * aligns the whole book at once by anchoring on stretches unique to both texts 14 * and solving exactly only between anchors - seconds and megabytes, which is 15 * what makes doing it in a browser tab possible at all. 16 * 17 * Offsets are code points throughout, never UTF-16 units, so a slice cannot 18 * split a surrogate pair however the text was cut. 19 */ 20 21 export const toCodePoints = (s) => Int32Array.from(s, (ch) => ch.codePointAt(0)); 22 23 export function fromCodePoints(cps, from = 0, to = cps.length) { 24 if (to <= from) return ''; 25 let out = ''; 26 for (let i = from; i < to; i += 8192) { 27 out += String.fromCodePoint(...cps.subarray(i, Math.min(to, i + 8192))); 28 } 29 return out; 30 } 31 32 /* ------------------------------------------------------------------ language */ 33 34 const JA = (() => { 35 const map = new Map(); 36 // Katakana to hiragana: the recogniser picks between them at random. 37 for (let i = 0; i < 0x56; i++) map.set(0x30a1 + i, 0x3041 + i); 38 // Kanji numerals to digits. The digit string is shorter and wraps, which is 39 // how both 十 and 拾 come out as 1. 40 const kansuu = toCodePoints('一二三四五六七八九十〇零壱弐参肆伍陸漆捌玖拾'); 41 const arabic = toCodePoints('123456789100'); 42 kansuu.forEach((cp, i) => map.set(cp, arabic[i % arabic.length])); 43 // ASCII to full width, so "A" in one text meets "A" in the other. 44 for (let cp = 0x21; cp < 0x7f; cp++) map.set(cp, cp + 0xfee0); 45 // Particles written one way and pronounced another. 46 for (const [a, b] of [['は', 'わ'], ['あ', 'わ'], ['お', 'を'], ['へ', 'え']]) { 47 map.set(a.codePointAt(0), b.codePointAt(0)); 48 } 49 return map; 50 })(); 51 52 // Everything that is not a letter or digit goes, except 。 when it leads a run. 53 const JA_NOISE = /(?![。])[\p{C}\p{M}\p{P}\p{S}\p{Z}\sー々ゝ]+/gu; 54 // Collapse repeats: long vowels and stutters are where the two texts disagree. 55 const JA_REPEATS = /(.)(?=\1+)/gu; 56 57 /** Only Japanese has rules of its own; everything else is case-folded. */ 58 export function language(code) { 59 const japanese = code === 'ja'; 60 const translate = (s) => { 61 if (!japanese) return s.toLowerCase(); 62 let out = ''; 63 for (const ch of s) { 64 const cp = ch.codePointAt(0); 65 out += JA.has(cp) ? String.fromCodePoint(JA.get(cp)) : ch; 66 } 67 return out.toLowerCase(); 68 }; 69 const clean = japanese 70 ? (s) => translate(s).replace(JA_NOISE, '').replace(JA_REPEATS, '') 71 : translate; 72 return { code, translate, clean }; 73 } 74 75 /* --------------------------------------------------------------------- gotoh */ 76 77 // The reference's scores, times ten so ties are exact. 78 const MATCH = 10, MISMATCH = -6, OPEN = -8, EXTEND = -5; 79 const NEG = -(2 ** 29); 80 81 export const cells = (n, m) => (n + 1) * (m + 1); 82 83 /** Exact global alignment with affine gaps. Returns {score, t, q}: a path of breakpoints. */ 84 export function gotoh(target, query) { 85 const n = target.length, m = query.length, width = m + 1; 86 const trace = new Uint8Array(cells(n, m)); 87 let pm = new Int32Array(width).fill(NEG), px = new Int32Array(width).fill(NEG), 88 py = new Int32Array(width).fill(NEG); 89 let cm = new Int32Array(width), cx = new Int32Array(width), cy = new Int32Array(width); 90 91 pm[0] = 0; 92 for (let j = 1; j <= m; j++) { py[j] = OPEN + (j - 1) * EXTEND; trace[j] = j === 1 ? 0 : 32; } 93 94 for (let i = 1; i <= n; i++) { 95 const row = i * width, t = target[i - 1]; 96 cm[0] = NEG; cy[0] = NEG; cx[0] = OPEN + (i - 1) * EXTEND; 97 trace[row] = i === 1 ? 0 : 4; 98 for (let j = 1; j <= m; j++) { 99 let bits = 0, best = pm[j - 1]; 100 if (px[j - 1] > best) { best = px[j - 1]; bits = 1; } 101 if (py[j - 1] > best) { best = py[j - 1]; bits = 2; } 102 cm[j] = best + (t === query[j - 1] ? MATCH : MISMATCH); 103 104 let bx = pm[j] + OPEN, xb = 0; 105 if (px[j] + EXTEND > bx) { bx = px[j] + EXTEND; xb = 4; } 106 if (py[j] + OPEN > bx) { bx = py[j] + OPEN; xb = 8; } 107 cx[j] = bx; 108 109 let by = cm[j - 1] + OPEN, yb = 0; 110 if (cx[j - 1] + OPEN > by) { by = cx[j - 1] + OPEN; yb = 16; } 111 if (cy[j - 1] + EXTEND > by) { by = cy[j - 1] + EXTEND; yb = 32; } 112 cy[j] = by; 113 114 trace[row + j] = bits | xb | yb; 115 } 116 [pm, cm] = [cm, pm]; [px, cx] = [cx, px]; [py, cy] = [cy, py]; 117 } 118 119 let state = 0, score = pm[m]; 120 if (px[m] > score) { score = px[m]; state = 1; } 121 if (py[m] > score) { score = py[m]; state = 2; } 122 123 const ts = [n], qs = [m]; 124 let i = n, j = m, last = -1; 125 while (i > 0 || j > 0) { 126 if (last !== -1 && last !== state) { ts.push(i); qs.push(j); } 127 last = state; 128 const bits = trace[i * width + j]; 129 if (state === 0) { state = bits & 3; i--; j--; } 130 else if (state === 1) { state = (bits >> 2) & 3; i--; } 131 else { state = (bits >> 4) & 3; j--; } 132 } 133 if (ts[ts.length - 1] !== 0 || qs[qs.length - 1] !== 0) { ts.push(0); qs.push(0); } 134 return { score, t: ts.reverse(), q: qs.reverse() }; 135 } 136 137 export function pathScore(target, query, path) { 138 let total = 0; 139 for (let k = 1; k < path.t.length; k++) { 140 const dt = path.t[k] - path.t[k - 1], dq = path.q[k] - path.q[k - 1]; 141 if (dt > 0 && dq > 0) { 142 for (let d = 0; d < dt; d++) { 143 total += target[path.t[k - 1] + d] === query[path.q[k - 1] + d] ? MATCH : MISMATCH; 144 } 145 } else if (dt > 0 || dq > 0) total += OPEN + (Math.max(dt, dq) - 1) * EXTEND; 146 } 147 return total; 148 } 149 150 /* ------------------------------------------------------------------ anchored */ 151 152 // FNV-1a over code points, folded to 53 bits so it is an exact JS number. 153 function gramHash(seq, at, k) { 154 let lo = 0x811c9dc5, hi = 0x1000193; 155 for (let d = 0; d < k; d++) { 156 const c = seq[at + d]; 157 lo = Math.imul(lo ^ c, 0x01000193) >>> 0; 158 hi = Math.imul(hi ^ (c >>> 3), 0x27d4eb2f) >>> 0; 159 } 160 return (hi & 0x1fffff) * 4294967296 + lo; 161 } 162 163 /** k-gram -> its position, or -2 when it occurs more than once. */ 164 function uniqueGrams(seq, from, to, k) { 165 const map = new Map(); 166 for (let i = from; i <= to - k; i++) { 167 const h = gramHash(seq, i, k); 168 map.set(h, map.has(h) ? -2 : i); 169 } 170 return map; 171 } 172 173 function anchors(target, query, t0, t1, q0, q1, k) { 174 if (t1 - t0 < k || q1 - q0 < k) return []; 175 const inTarget = uniqueGrams(target, t0, t1, k); 176 const inQuery = uniqueGrams(query, q0, q1, k); 177 const runs = []; 178 let current = null; 179 for (let qi = q0; qi <= q1 - k; qi++) { 180 const h = gramHash(query, qi, k); 181 const ti = inQuery.get(h) === qi ? inTarget.get(h) : undefined; 182 let same = ti !== undefined && ti >= 0; 183 for (let d = 0; same && d < k; d++) same = target[ti + d] === query[qi + d]; 184 if (!same) { current = null; continue; } 185 if (current && ti === current.t + (qi - current.q)) current.len = qi - current.q + k; 186 else runs.push(current = { t: ti, q: qi, len: k }); 187 } 188 return runs; 189 } 190 191 /** Longest chain of runs in order on both sides (patience sort), overlaps trimmed. */ 192 function chainOf(runs) { 193 if (!runs.length) return runs; 194 const byT = runs.slice().sort((a, b) => a.t - b.t); 195 const tails = [], prev = new Int32Array(byT.length).fill(-1); 196 byT.forEach((r, i) => { 197 let lo = 0, hi = tails.length; 198 while (lo < hi) { const mid = (lo + hi) >>> 1; if (byT[tails[mid]].q < r.q) lo = mid + 1; else hi = mid; } 199 if (lo > 0) prev[i] = tails[lo - 1]; 200 tails[lo] = i; 201 }); 202 const picked = []; 203 for (let at = tails[tails.length - 1]; at >= 0; at = prev[at]) picked.push(byT[at]); 204 picked.reverse(); 205 206 const out = []; 207 let endT = 0, endQ = 0; 208 for (const r of picked) { 209 const cut = Math.max(endT - r.t, endQ - r.q, 0); 210 if (cut >= r.len) continue; 211 r.t += cut; r.q += cut; r.len -= cut; 212 out.push(r); 213 endT = r.t + r.len; endQ = r.q + r.len; 214 } 215 return out; 216 } 217 218 /** Global alignment of inputs too big for one table. Same shape of result as gotoh. */ 219 export function anchoredAlign(target, query, { exactCells = 4_000_000, firstK = 14, lastK = 5 } = {}) { 220 const ts = [0], qs = [0]; 221 let lastKind = 0; 222 const moveTo = (t, q) => { 223 const pt = ts[ts.length - 1], pq = qs[qs.length - 1]; 224 if (t === pt && q === pq) return; 225 const dt = t - pt, dq = q - pq; 226 if (dt > 0 && dq > 0 && dt !== dq) { // a diagonal and a gap in one step 227 const d = Math.min(dt, dq); 228 moveTo(pt + d, pq + d); moveTo(t, q); 229 return; 230 } 231 const kind = dt > 0 && dq > 0 ? 1 : dt > 0 ? 2 : 3; 232 if (kind === lastKind && ts.length > 1) { ts[ts.length - 1] = t; qs[qs.length - 1] = q; } 233 else { ts.push(t); qs.push(q); } 234 lastKind = kind; 235 }; 236 237 // An explicit stack: a book is thousands of ranges deep in places. 238 const work = [[0, target.length, 0, query.length, firstK]]; 239 while (work.length) { 240 const job = work.pop(); 241 if (job.length === 2) { moveTo(job[0], job[1]); continue; } 242 const [t0, t1, q0, q1, k] = job; 243 const n = t1 - t0, m = q1 - q0; 244 if (n === 0 || m === 0) { moveTo(t1, q1); continue; } 245 if (cells(n, m) <= exactCells) { 246 const sub = gotoh(target.subarray(t0, t1), query.subarray(q0, q1)); 247 for (let i = 0; i < sub.t.length; i++) moveTo(t0 + sub.t[i], q0 + sub.q[i]); 248 continue; 249 } 250 251 let kk = k, chain = []; 252 for (; kk >= lastK; kk -= 3) { 253 chain = chainOf(anchors(target, query, t0, t1, q0, q1, kk)); 254 if (chain.length) break; 255 } 256 const next = []; 257 if (!chain.length) { 258 // Two long stretches with nothing in common: halve both and carry on. 259 const tm = t0 + (n >> 1), qm = q0 + (m >> 1); 260 next.push([t0, tm, q0, qm, lastK], [tm, t1, qm, q1, lastK]); 261 } else { 262 let ct = t0, cq = q0; 263 for (const r of chain) { 264 next.push([ct, r.t, cq, r.q, kk], [r.t + r.len, r.q + r.len]); 265 ct = r.t + r.len; cq = r.q + r.len; 266 } 267 next.push([ct, t1, cq, q1, kk]); 268 } 269 for (let i = next.length - 1; i >= 0; i--) work.push(next[i]); 270 } 271 moveTo(target.length, query.length); 272 const path = { t: ts, q: qs }; 273 path.score = pathScore(target, query, path); 274 return path; 275 } 276 277 /* ----------------------------------------------------------------- align_sub */ 278 279 const FULL_STOP = '。'.codePointAt(0); 280 281 function distinctExceptFullStop(cps, start, end) { 282 const seen = new Set(); 283 for (let k = start; k < Math.min(end, cps.length); k++) if (cps[k] !== FULL_STOP) seen.add(cps[k]); 284 return seen.size; 285 } 286 287 /** Which piece of which paragraph each transcript segment covers. Spans are [start, end, sub]. */ 288 export function alignSub(path, text, subs, thing = 2) { 289 let line = 0, sub = 0, toff = 0, off = 0; 290 const pos = [0, 0], p = [0, 0], gaps = [0, 0]; 291 const segments = [[]]; 292 const last = () => segments[segments.length - 1]; 293 294 for (let i = 0; i < path.t.length; i++) { 295 const c0 = path.t[i], c1 = path.q[i]; 296 const isGap = c0 - p[0] === 0 || c1 - p[1] === 0; 297 298 while (sub < subs.length && pos[1] + subs[sub].length <= c1) { 299 if (line >= text.length) return segments.slice(0, text.length); 300 const subLen = subs[sub].length; 301 pos[1] += subLen; 302 if (isGap) { gaps[0] += Math.max(pos[1] - p[1], 0); gaps[1] += Math.max(pos[0] - p[0], 0); } 303 304 const diff = subLen + gaps[1] - gaps[0]; 305 if (diff > Math.floor(subLen / 4)) { 306 let target = toff + diff + off; 307 off = 0; 308 while (line < text.length && target >= text[line].length) { 309 const start = toff, end = text[line].length; 310 if (end - start !== 0) { 311 const prev = last(); 312 if (end - start < thing || distinctExceptFullStop(text[line], start, end) < thing) { 313 if (prev.length) prev[prev.length - 1][1] = end; 314 else prev.push([start, end, sub]); 315 } else prev.push([start, end, sub]); 316 } 317 segments.push([]); 318 pos[0] += end - start; 319 target -= text[line].length; 320 line++; 321 toff = 0; 322 } 323 pos[0] += target - toff; 324 last().push([toff, target, sub]); 325 toff = target; 326 } else { 327 const prev = last(); 328 if (toff >= Math.floor(text[line].length / 2) && prev.length) { 329 prev[prev.length - 1][1] += diff; 330 toff += diff; 331 } else off += diff; 332 } 333 334 sub++; 335 gaps[0] = 0; gaps[1] = 0; 336 p[0] = Math.max(pos[0], p[0]); p[1] = Math.max(pos[1], p[1]); 337 } 338 339 if (isGap) { gaps[0] += c1 - p[1]; gaps[1] += c0 - p[0]; } 340 p[0] = c0; p[1] = c1; 341 } 342 return segments.slice(0, text.length); 343 } 344 345 /** Spans were computed on cleaned text; map them back onto the original. */ 346 export function fix(lang, original, edited, segments) { 347 segments.forEach((spans, l) => { 348 const o = toCodePoints(lang.translate(original[l])), e = edited[l]; 349 const m = new Int32Array(e.length + 1).fill(-1); 350 let ei = 0; 351 for (let oi = 0; oi < o.length; oi++) { 352 if (ei < e.length && o[oi] === e[ei]) { m[ei] = oi; ei++; } 353 } 354 m[ei] = o.length; 355 m[0] = 0; 356 let lastSet = 0; 357 for (let i = 0; i < e.length; i++) { if (m[i] !== -1) lastSet = i; else m[i] = m[lastSet]; } 358 if (m[e.length] === -1) m[e.length] = o.length; 359 const clamp = (v) => Math.min(Math.max(v, 0), e.length); 360 for (const f of spans) { f[0] = m[clamp(f[0])]; f[1] = m[clamp(f[1])]; } 361 }); 362 } 363 364 /** Python indexing: negatives count from the end; out of range is "no character". */ 365 const at = (t, i) => { const k = i < 0 ? i + t.length : i; return k >= 0 && k < t.length ? t[k] : -1; }; 366 367 /** Nudge span ends so punctuation stays with the words it belongs to. */ 368 export function fixPunc(text, segments, prepend, append, nopend) { 369 segments.forEach((s, l) => { 370 if (!s.length) return; 371 const t = toCodePoints(text[l]); 372 for (let k = 0; k < s.length; k++) { 373 const p = s[k], f = k + 1 < s.length ? s[k + 1] : s[k]; 374 const connected = f[0] === p[1]; 375 for (let loop = 0; loop <= 20; loop++) { 376 if (p[1] < t.length && append.has(at(t, p[1]))) p[1] += 1; 377 else if (prepend.has(at(t, p[1] - 1))) p[1] -= 1; 378 else if ((p[1] > 0 && nopend.has(at(t, p[1] - 1))) || 379 (p[1] < t.length && nopend.has(at(t, p[1]))) || 380 (p[1] < t.length - 1 && nopend.has(at(t, p[1] + 1)))) { 381 let start = p[1] - 1, end = p[1]; 382 if (p[1] < t.length - 1) { 383 const here = at(t, p[1]); 384 if ((nopend.has(at(t, p[1] + 1)) && 0x4e00 > here) || here > 0x9faf) end += 1; 385 } 386 while (start > 0 && nopend.has(at(t, start))) start -= 1; 387 while (end < t.length - 1 && nopend.has(at(t, end))) end += 1; 388 389 if (prepend.has(at(t, start))) { if (p[1] === start) break; p[1] = start; } 390 else if (append.has(at(t, start))) { if (p[1] === start + 1) break; p[1] = start + 1; } 391 else if (end < t.length && prepend.has(at(t, end))) { if (p[1] === end) break; p[1] = end; } 392 else if (end < t.length && append.has(at(t, end))) { if (p[1] === end + 1) break; p[1] = end + 1; } 393 else break; 394 } else break; 395 } 396 if (connected) f[0] = p[1]; 397 } 398 }); 399 } 400 401 /* ---------------------------------------------------------------------- subs */ 402 403 export const START_PUNC = '『「((《⦅[{"\'“¿' + '\'“"¿([{-『「(〈《〔【{[⦅<<‘“〝※'; 404 export const END_PUNC = '\'"・.。!!??::”>>⦆)]}』」)〉》〕】}]’〟/\~〜~;;─―–-➡'; 405 export const OTHER_PUNC = '* ,,、…'; 406 export const NOPEND = 'うぁぃぅぇぉっゃゅょゎゕゖァィゥェォヵㇰヶㇱㇲッㇳㇴㇵㇶㇷㇷ゚ㇸㇹㇺャュョㇻㇼㇽㇾㇿヮ… '; 407 export const UNMATCHED = '*'; 408 409 const cpSet = (s) => new Set(toCodePoints(s)); 410 export const PREPEND_SET = cpSet(START_PUNC); 411 export const APPEND_SET = cpSet(END_PUNC + OTHER_PUNC); 412 export const NOPEND_SET = cpSet(NOPEND); 413 414 const PUNCT = new Set(START_PUNC + END_PUNC + OTHER_PUNC); 415 const START = new Set(START_PUNC), END = new Set(END_PUNC); 416 417 function pySlice(cps, a, b) { 418 const n = cps.length, fixIndex = (v) => Math.min(Math.max(v < 0 ? v + n : v, 0), n); 419 return fromCodePoints(cps, fixIndex(a), fixIndex(b)); 420 } 421 422 /** One cue per transcript segment: timed by the recogniser, worded by the book. */ 423 export function toSubs(text, subs, alignment, offset = 0) { 424 const flat = []; 425 alignment.forEach((spans, line) => spans.forEach((span) => flat.push({ span, line }))); 426 const lines = text.map(toCodePoints); 427 const out = []; 428 let start = 0, end = 0; 429 subs.forEach((s, si) => { 430 while (end < flat.length && flat[end].span[2] === si) end++; 431 let body = ''; 432 for (let k = start; k < end; k++) body += pySlice(lines[flat[k].line], flat[k].span[0], flat[k].span[1]); 433 out.push({ text: body.trim() ? body : UNMATCHED + s.text, start: s.start + offset, end: s.end + offset }); 434 start = end; 435 }); 436 return out; 437 } 438 439 const punctIndices = (s) => { const r = []; for (let i = 0; i < s.length; i++) if (PUNCT.has(s[i])) r.push(i); return r; }; 440 const countNonPunct = (s) => { let n = 0; for (let i = 0; i < s.length; i++) if (!PUNCT.has(s[i])) n++; return n; }; 441 const doubleComma = (a, b) => a[a.length - 1] === '、' && b[b.length - 1] === '、'; 442 443 /** Move a stranded character or two across a cue boundary. Cues are mutated, as in the original. */ 444 export function shiftAlign(segments) { 445 const fresh = []; 446 let startIndex = 0; // read by the second pass too, as in the original 447 segments.forEach((segment, i) => { 448 let text = segment.text; 449 const idx = punctIndices(text); 450 if (!idx.length) { fresh.push(segment); return; } 451 startIndex = idx[0]; 452 const nonPunc = countNonPunct(text.slice(0, startIndex)); 453 if (nonPunc === 0 || countNonPunct(text.slice(startIndex + 1)) === 0) { fresh.push(segment); return; } 454 const prev = fresh[fresh.length - 1]; 455 if (nonPunc <= 2 && i > 0 && fresh.length && !END.has(prev.text[prev.text.length - 1]) && 456 !doubleComma(prev.text, text.slice(0, startIndex + 1))) { 457 prev.text += text.slice(0, startIndex + 1); 458 text = text.slice(startIndex + 1); 459 } 460 fresh.push({ text, start: segment.start, end: segment.end }); 461 }); 462 463 const final = []; 464 for (let i = 0; i < fresh.length; i++) { 465 const segment = fresh[i]; 466 let text = segment.text; 467 const idx = punctIndices(text); 468 if (idx.length) { 469 const lastIndex = idx[idx.length - 1], tail = text.slice(lastIndex); 470 if (countNonPunct(tail) === 0 || tail.length === text.length) { final.push(segment); continue; } 471 if (countNonPunct(tail) <= 2 && i + 1 < fresh.length && !END.has(fresh[i + 1].text[0]) && 472 !doubleComma(fresh[i + 1].text.slice(0, 1), text.slice(0, startIndex + 1))) { 473 // The original reaches into the *input* list here. Kept: the fixtures depend on it. 474 const next = segments[i + 1]; 475 next.text = text.slice(lastIndex + 1) + next.text; 476 text = text.slice(0, lastIndex + 1); 477 final.push({ text, start: segment.start, end: segment.end }); 478 fresh[i + 1] = next; 479 continue; 480 } 481 } 482 final.push({ text, start: segment.start, end: segment.end }); 483 } 484 485 if (final.length >= 2) { 486 const stray = /(」「(.{1,2})、$|」「(.{1})、$)/u; 487 const adjusted = []; 488 final.forEach((segment, i) => { 489 const m = stray.exec(segment.text); 490 if (m && i < final.length - 1) { 491 final[i + 1].text = m[0] + final[i + 1].text; 492 segment.text = segment.text.slice(0, m.index); 493 } 494 if (segment.text && END.has(segment.text[0]) && i > 0) { 495 adjusted[adjusted.length - 1].text += segment.text[0]; 496 segment.text = segment.text.slice(1); 497 } 498 if (segment.text && START.has(segment.text[segment.text.length - 1]) && i < final.length - 1) { 499 final[i + 1].text = segment.text[segment.text.length - 1] + final[i + 1].text; 500 segment.text = segment.text.slice(0, -1); 501 } 502 adjusted.push(segment); 503 }); 504 } 505 return final.map((c) => ({ text: c.text.trim(), start: c.start, end: c.end })); 506 } 507 508 /* ------------------------------------------------------------------ pipeline */ 509 510 const EXACT_CELL_LIMIT = 64_000_000; 511 512 function concat(parts) { 513 const out = new Int32Array(parts.reduce((n, p) => n + p.length, 0)); 514 let o = 0; 515 for (const p of parts) { out.set(p, o); o += p.length; } 516 return out; 517 } 518 519 export function align(transcript, paragraphs, lang, { anchoredOnly = false, exactCells } = {}) { 520 if (!transcript.length) return []; 521 const subsClean = transcript.map((s) => toCodePoints(lang.clean(s.text))); 522 const textClean = paragraphs.map((p) => toCodePoints(lang.clean(p))); 523 const query = concat(subsClean), target = concat(textClean); 524 if (!target.length || !query.length) { 525 return transcript.map((s) => ({ text: UNMATCHED + s.text, start: s.start, end: s.end })); 526 } 527 const path = !anchoredOnly && cells(target.length, query.length) <= EXACT_CELL_LIMIT 528 ? gotoh(target, query) : anchoredAlign(target, query, exactCells ? { exactCells } : {}); 529 const spans = alignSub(path, textClean, subsClean); 530 fix(lang, paragraphs, textClean, spans); 531 fixPunc(paragraphs, spans, PREPEND_SET, APPEND_SET, NOPEND_SET); 532 return shiftAlign(toSubs(paragraphs, transcript, spans)); 533 } 534 535 // A paragraph counts as heard when this share of it agrees with the transcript... 536 const HEARD = 0.20; 537 // ...and unheard ones are dropped only in runs this long: one mangled heading 538 // was still read aloud; a page of nothing was not. 539 const MIN_UNREAD_RUN = 240; 540 541 /** The paragraphs that were actually narrated: front matter, notes and the like removed. */ 542 export function narratedOnly(transcript, paragraphs, lang) { 543 const textClean = paragraphs.map((p) => toCodePoints(lang.clean(p))); 544 const target = concat(textClean); 545 const query = concat(transcript.map((s) => toCodePoints(lang.clean(s.text)))); 546 if (!target.length || !query.length) return paragraphs; 547 548 const path = anchoredAlign(target, query); 549 const agreed = new Uint8Array(target.length); 550 for (let k = 1; k < path.t.length; k++) { 551 const t0 = path.t[k - 1], q0 = path.q[k - 1], dt = path.t[k] - t0; 552 if (dt > 0 && path.q[k] - q0 > 0) { 553 for (let d = 0; d < dt; d++) agreed[t0 + d] = target[t0 + d] === query[q0 + d] ? 1 : 0; 554 } 555 } 556 557 let o = 0; 558 const heard = textClean.map((cps) => { 559 let hits = 0; 560 for (let i = o; i < o + cps.length; i++) hits += agreed[i]; 561 o += cps.length; 562 return cps.length === 0 || hits >= HEARD * cps.length; 563 }); 564 565 const keep = paragraphs.map(() => true); 566 for (let i = 0; i < paragraphs.length;) { 567 if (heard[i]) { i++; continue; } 568 let j = i, chars = 0; 569 while (j < paragraphs.length && (!heard[j] || textClean[j].length === 0)) { chars += textClean[j].length; j++; } 570 if (chars >= MIN_UNREAD_RUN) for (let k = i; k < j; k++) keep[k] = false; 571 i = j; 572 } 573 const out = paragraphs.filter((_, k) => keep[k]); 574 return out.length ? out : paragraphs; 575 } 576 577 /** A whole book at once. No chapter matching: the alignment itself says what was read. */ 578 export function alignBook(transcript, paragraphs, lang) { 579 const narrated = narratedOnly(transcript, paragraphs, lang); 580 const cues = align(transcript, narrated, lang); 581 const matched = cues.filter((c) => !c.text.startsWith(UNMATCHED)).length; 582 return { 583 cues, 584 paragraphsUsed: narrated.length, 585 paragraphsDropped: paragraphs.length - narrated.length, 586 matchRate: cues.length ? matched / cues.length : 0, 587 }; 588 } 589 590 /* ----------------------------------------------------------------------- srt */ 591 592 export function stamp(seconds) { 593 const ms = Math.round(Math.max(seconds, 0) * 1000); 594 const pad = (v, n = 2) => String(v).padStart(n, '0'); 595 return `${pad(Math.floor(ms / 3600000))}:${pad(Math.floor(ms / 60000) % 60)}:${pad(Math.floor(ms / 1000) % 60)},${pad(ms % 1000, 3)}`; 596 } 597 598 /** One line of text per cue, always: Hoshi Reader reads exactly the third line of each block. */ 599 export function writeSrt(cues) { 600 let out = '', n = 0; 601 for (const cue of cues) { 602 const text = cue.text.replace(/\s*[\r\n]+\s*/g, ' ').trim(); 603 if (!text) continue; 604 out += `${++n}\n${stamp(cue.start)} --> ${stamp(cue.end)}\n${text}\n\n`; 605 } 606 return out; 607 }