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 }