Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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 }