Recently Written · git

subplz-web

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

Log | Files | Refs


tests/engine/align.test.mjs (5527 bytes)

1 /* The browser's alignment engine against the reference implementation's real
2  * output, stage by stage. Fixtures come from SubPlz itself, run on real
3  * Whisper-tiny transcripts of a public-domain book (Aozora Bunko), with every
4  * intermediate stage recorded.
5  *
6  *   node --test tests/engine
7  */
8 import test from 'node:test';
9 import assert from 'node:assert/strict';
10 import { readdirSync, readFileSync, existsSync } from 'node:fs';
11 import { fileURLToPath } from 'node:url';
12 import path from 'node:path';
13 
14 import * as E from '../../frontend/engine/align.js';
15 
16 const here = path.dirname(fileURLToPath(import.meta.url));
17 const load = (dir) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.json')).sort() : [])
18   .map((f) => JSON.parse(readFileSync(path.join(dir, f), 'utf8')));
19 const cases = load(path.join(here, 'golden'));
20 assert.ok(cases.length > 0, 'no golden fixtures');
21 
22 const copy = (spans) => spans.map((line) => line.map((s) => s.slice()));
23 const cps = (list) => list.map(E.toCodePoints);
24 const joined = (list) => E.toCodePoints(list.join(''));
25 const pathOf = (c) => ({ t: c.coords[0], q: c.coords[1] });
26 const texts = (cues) => cues.map((c) => c.text);
27 
28 for (const c of cases) {
29   const lang = E.language(c.language);
30 
31   test(`${c.name}: cleaning`, () => {
32     assert.deepEqual(c.transcript.map((s) => lang.clean(s.text)), c.transcript_clean);
33     assert.deepEqual(c.paragraphs.map((p) => lang.clean(p)), c.paragraphs_clean);
34   });
35 
36   test(`${c.name}: alignSub`, () => {
37     assert.deepEqual(E.alignSub(pathOf(c), cps(c.paragraphs_clean), cps(c.transcript_clean)), c.after_align_sub);
38   });
39 
40   test(`${c.name}: fix`, () => {
41     const spans = copy(c.after_align_sub);
42     E.fix(lang, c.paragraphs, cps(c.paragraphs_clean), spans);
43     assert.deepEqual(spans, c.after_fix);
44   });
45 
46   test(`${c.name}: fixPunc`, () => {
47     const spans = copy(c.after_fix);
48     E.fixPunc(c.paragraphs, spans, E.PREPEND_SET, E.APPEND_SET, E.NOPEND_SET);
49     assert.deepEqual(spans, c.after_fix_punc);
50   });
51 
52   test(`${c.name}: toSubs`, () => {
53     assert.deepEqual(E.toSubs(c.paragraphs, c.transcript, c.after_fix_punc), c.cues_raw);
54   });
55 
56   test(`${c.name}: shiftAlign`, () => {
57     assert.deepEqual(E.shiftAlign(c.cues_raw.map((x) => ({ ...x }))), c.cues);
58   });
59 
60   test(`${c.name}: gotoh finds the optimal score`, () => {
61     const target = joined(c.paragraphs_clean), query = joined(c.transcript_clean);
62     const got = E.gotoh(target, query);
63     assert.equal(got.score, E.pathScore(target, query, got));
64     assert.equal(got.score, Math.round(c.score * 10));
65   });
66 
67   test(`${c.name}: anchored alignment is as good as exact`, () => {
68     const target = joined(c.paragraphs_clean), query = joined(c.transcript_clean);
69     const exact = Math.round(c.score * 10);
70     for (const [exactCells, floor] of [[4_000_000, 0.999], [40_000, 0.98], [2_500, 0.97]]) {
71       const got = E.anchoredAlign(target, query, { exactCells });
72       assert.equal(got.t.at(-1), target.length);
73       assert.equal(got.q.at(-1), query.length);
74       assert.ok(got.score / exact >= floor, `${got.score} vs ${exact} at ${exactCells}`);
75     }
76   });
77 
78   test(`${c.name}: whole pipeline, exact and anchored`, () => {
79     assert.deepEqual(texts(E.align(c.transcript, c.paragraphs, lang)), texts(c.cues));
80     const anchored = E.align(c.transcript, c.paragraphs, lang, { anchoredOnly: true, exactCells: 40_000 });
81     const same = anchored.filter((cue, i) => cue.text === c.cues[i].text).length;
82     assert.ok(same / c.cues.length >= 0.95, `only ${same}/${c.cues.length} cues match`);
83   });
84 }
85 
86 test('text that was never narrated is left out', () => {
87   const c = cases.find((x) => x.name === 'neko_003_006');
88   const other = cases.find((x) => x.name === 'neko_137_140').paragraphs;
89   const lang = E.language(c.language);
90   const front = other.slice(0, 6), insert = other.slice(40, 52), back = other.slice(-8);
91   const mid = c.paragraphs.length >> 1;
92   const clean = E.alignBook(c.transcript, c.paragraphs, lang);
93   const padded = [...front, ...c.paragraphs.slice(0, mid), ...insert, ...c.paragraphs.slice(mid), ...back];
94   const got = E.alignBook(c.transcript, padded, lang);
95   assert.equal(got.paragraphsUsed, clean.paragraphsUsed);
96   assert.deepEqual(texts(got.cues), texts(clean.cues));
97   assert.ok(got.matchRate > 0.95);
98 });
99 
100 test('srt', () => {
101   assert.equal(E.stamp(59.9996), '00:01:00,000');
102   assert.equal(E.stamp(12 * 3600 + 34 * 60 + 56.789), '12:34:56,789');
103   assert.equal(
104     E.writeSrt([{ text: 'first\nline  \r\n wrapped', start: 0, end: 1.5 }, { text: '  ', start: 1.5, end: 2 },
105       { text: 'second', start: 2, end: 3.25 }]),
106     '1\n00:00:00,000 --> 00:00:01,500\nfirst line wrapped\n\n2\n00:00:02,000 --> 00:00:03,250\nsecond\n\n');
107 });
108 
109 // Local only: a whole audiobook in one pass. See subread-android/tools/make_fullbook.py.
110 for (const book of load(path.join(here, 'golden-local'))) {
111   test('a whole book aligns in one go', () => {
112     const started = performance.now();
113     const got = E.alignBook(book.transcript, book.paragraphs, E.language(book.language));
114     const seconds = (performance.now() - started) / 1000;
115     const longest = Math.max(...got.cues.map((x) => x.text.length));
116     console.log(`${got.cues.length} cues in ${seconds.toFixed(1)}s, ${(got.matchRate * 100).toFixed(1)}% matched, ` +
117       `${got.paragraphsDropped} paragraphs dropped, longest cue ${longest}`);
118     assert.equal(got.cues.length, book.transcript.length);
119     assert.ok(got.matchRate > 0.95 && longest < 400 && seconds < 120);
120   });
121 }