Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


commit 800c908ff5bf197e17df56128dc3a13da7ee68cd
truex <truex@equwal.com>
2026-09-24 12:21:59 -0700

Copy the SubPlz browser engine from subplz-web

align.js and align.worker.js are verbatim. asr.js, media.js and job.js
take the paragraphs as a string list and load transformers.js, the
model and the ffmpeg core from a configurable asset base URL. The
ffmpeg wrapper comes from the @ffmpeg/ffmpeg package, because its worker
must be same-origin. The .d.ts files give strict TypeScript the used
signatures. The Vite dev proxy makes /vendor of subread.space
same-origin for the web build.

 .prettierignore            |   1 +
 package-lock.json          |  22 ++
 package.json               |   1 +
 src/engine/align.d.ts      |  31 +++
 src/engine/align.js        | 608 +++++++++++++++++++++++++++++++++++++++++++++
 src/engine/align.worker.js |  12 +
 src/engine/asr.js          |  89 +++++++
 src/engine/job.d.ts        |  47 ++++
 src/engine/job.js          | 159 ++++++++++++
 src/engine/media.js        |  99 ++++++++
 test/engine.test.ts        |  31 +++
 vite.config.ts             |  30 +++
 12 files changed, 1130 insertions(+)
diff --git a/.prettierignore b/.prettierignore
index 4818b89..09c3bc1 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,3 +2,4 @@ dist/
 package-lock.json
 fixtures/*.srt
 android/
+src/engine/*.js
diff --git a/package-lock.json b/package-lock.json
index e71d0dc..49d7615 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
       "dependencies": {
         "@capacitor/android": "^8.5.2",
         "@capacitor/core": "^8.5.2",
+        "@ffmpeg/ffmpeg": "^0.12.15",
         "fast-diff": "^1.3.0",
         "fflate": "^0.8.3",
         "idb": "^8.0.3",
@@ -555,6 +556,27 @@
         "node": ">=18"
       }
     },
+    "node_modules/@ffmpeg/ffmpeg": {
+      "version": "0.12.15",
+      "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
+      "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==",
+      "license": "MIT",
+      "dependencies": {
+        "@ffmpeg/types": "^0.12.4"
+      },
+      "engines": {
+        "node": ">=18.x"
+      }
+    },
+    "node_modules/@ffmpeg/types": {
+      "version": "0.12.4",
+      "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz",
+      "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.x"
+      }
+    },
     "node_modules/@img/colour": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
diff --git a/package.json b/package.json
index defdbf0..1032c42 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
   "dependencies": {
     "@capacitor/android": "^8.5.2",
     "@capacitor/core": "^8.5.2",
+    "@ffmpeg/ffmpeg": "^0.12.15",
     "fast-diff": "^1.3.0",
     "fflate": "^0.8.3",
     "idb": "^8.0.3",
diff --git a/src/engine/align.d.ts b/src/engine/align.d.ts
new file mode 100644
index 0000000..2d2ec02
--- /dev/null
+++ b/src/engine/align.d.ts
@@ -0,0 +1,31 @@
+// Types for the parts of align.js that the app uses.
+
+/** One timed piece of text: a transcript segment or a subtitle cue. */
+export interface Segment {
+  text: string;
+  start: number;
+  end: number;
+}
+
+export interface Lang {
+  code: string;
+  translate(s: string): string;
+  clean(s: string): string;
+}
+
+export interface BookAlignment {
+  cues: Segment[];
+  paragraphsUsed: number;
+  paragraphsDropped: number;
+  /** Share of cues whose text comes from the book, in [0, 1]. */
+  matchRate: number;
+}
+
+export function language(code: string): Lang;
+export function alignBook(
+  transcript: readonly Segment[],
+  paragraphs: readonly string[],
+  lang: Lang,
+): BookAlignment;
+export function writeSrt(cues: readonly Segment[]): string;
+export const UNMATCHED: string;
diff --git a/src/engine/align.js b/src/engine/align.js
new file mode 100644
index 0000000..796d9b8
--- /dev/null
+++ b/src/engine/align.js
@@ -0,0 +1,608 @@
+/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author). */
+/* The alignment engine, in the browser.
+ *
+ * A transcript with times and a book without them go in; subtitles worded by
+ * the book and timed by the transcript come out.
+ *
+ * This is a port of SubPlz's aligner (ats.align, subplz.align.shift_align),
+ * kept statement-for-statement where it is a port, quirks included: the tests
+ * in tests/engine run it against the reference implementation's real output,
+ * stage by stage, and it is only trustworthy while it reproduces that exactly.
+ *
+ * What is not a port is anchoredAlign. The reference aligns one chapter at a
+ * time in a full dynamic-programming table and needs gigabytes to do it. This
+ * aligns the whole book at once by anchoring on stretches unique to both texts
+ * and solving exactly only between anchors - seconds and megabytes, which is
+ * what makes doing it in a browser tab possible at all.
+ *
+ * Offsets are code points throughout, never UTF-16 units, so a slice cannot
+ * split a surrogate pair however the text was cut.
+ */
+
+export const toCodePoints = (s) => Int32Array.from(s, (ch) => ch.codePointAt(0));
+
+export function fromCodePoints(cps, from = 0, to = cps.length) {
+  if (to <= from) return '';
+  let out = '';
+  for (let i = from; i < to; i += 8192) {
+    out += String.fromCodePoint(...cps.subarray(i, Math.min(to, i + 8192)));
+  }
+  return out;
+}
+
+/* ------------------------------------------------------------------ language */
+
+const JA = (() => {
+  const map = new Map();
+  // Katakana to hiragana: the recogniser picks between them at random.
+  for (let i = 0; i < 0x56; i++) map.set(0x30a1 + i, 0x3041 + i);
+  // Kanji numerals to digits. The digit string is shorter and wraps, which is
+  // how both 十 and 拾 come out as 1.
+  const kansuu = toCodePoints('一二三四五六七八九十〇零壱弐参肆伍陸漆捌玖拾');
+  const arabic = toCodePoints('123456789100');
+  kansuu.forEach((cp, i) => map.set(cp, arabic[i % arabic.length]));
+  // ASCII to full width, so "A" in one text meets "A" in the other.
+  for (let cp = 0x21; cp < 0x7f; cp++) map.set(cp, cp + 0xfee0);
+  // Particles written one way and pronounced another.
+  for (const [a, b] of [['は', 'わ'], ['あ', 'わ'], ['お', 'を'], ['へ', 'え']]) {
+    map.set(a.codePointAt(0), b.codePointAt(0));
+  }
+  return map;
+})();
+
+// Everything that is not a letter or digit goes, except 。 when it leads a run.
+const JA_NOISE = /(?![。])[\p{C}\p{M}\p{P}\p{S}\p{Z}\sー々ゝ]+/gu;
+// Collapse repeats: long vowels and stutters are where the two texts disagree.
+const JA_REPEATS = /(.)(?=\1+)/gu;
+
+/** Only Japanese has rules of its own; everything else is case-folded. */
+export function language(code) {
+  const japanese = code === 'ja';
+  const translate = (s) => {
+    if (!japanese) return s.toLowerCase();
+    let out = '';
+    for (const ch of s) {
+      const cp = ch.codePointAt(0);
+      out += JA.has(cp) ? String.fromCodePoint(JA.get(cp)) : ch;
+    }
+    return out.toLowerCase();
+  };
+  const clean = japanese
+    ? (s) => translate(s).replace(JA_NOISE, '').replace(JA_REPEATS, '')
+    : translate;
+  return { code, translate, clean };
+}
+
+/* --------------------------------------------------------------------- gotoh */
+
+// The reference's scores, times ten so ties are exact.
+const MATCH = 10, MISMATCH = -6, OPEN = -8, EXTEND = -5;
+const NEG = -(2 ** 29);
+
+export const cells = (n, m) => (n + 1) * (m + 1);
+
+/** Exact global alignment with affine gaps. Returns {score, t, q}: a path of breakpoints. */
+export function gotoh(target, query) {
+  const n = target.length, m = query.length, width = m + 1;
+  const trace = new Uint8Array(cells(n, m));
+  let pm = new Int32Array(width).fill(NEG), px = new Int32Array(width).fill(NEG),
+      py = new Int32Array(width).fill(NEG);
+  let cm = new Int32Array(width), cx = new Int32Array(width), cy = new Int32Array(width);
+
+  pm[0] = 0;
+  for (let j = 1; j <= m; j++) { py[j] = OPEN + (j - 1) * EXTEND; trace[j] = j === 1 ? 0 : 32; }
+
+  for (let i = 1; i <= n; i++) {
+    const row = i * width, t = target[i - 1];
+    cm[0] = NEG; cy[0] = NEG; cx[0] = OPEN + (i - 1) * EXTEND;
+    trace[row] = i === 1 ? 0 : 4;
+    for (let j = 1; j <= m; j++) {
+      let bits = 0, best = pm[j - 1];
+      if (px[j - 1] > best) { best = px[j - 1]; bits = 1; }
+      if (py[j - 1] > best) { best = py[j - 1]; bits = 2; }
+      cm[j] = best + (t === query[j - 1] ? MATCH : MISMATCH);
+
+      let bx = pm[j] + OPEN, xb = 0;
+      if (px[j] + EXTEND > bx) { bx = px[j] + EXTEND; xb = 4; }
+      if (py[j] + OPEN > bx) { bx = py[j] + OPEN; xb = 8; }
+      cx[j] = bx;
+
+      let by = cm[j - 1] + OPEN, yb = 0;
+      if (cx[j - 1] + OPEN > by) { by = cx[j - 1] + OPEN; yb = 16; }
+      if (cy[j - 1] + EXTEND > by) { by = cy[j - 1] + EXTEND; yb = 32; }
+      cy[j] = by;
+
+      trace[row + j] = bits | xb | yb;
+    }
+    [pm, cm] = [cm, pm]; [px, cx] = [cx, px]; [py, cy] = [cy, py];
+  }
+
+  let state = 0, score = pm[m];
+  if (px[m] > score) { score = px[m]; state = 1; }
+  if (py[m] > score) { score = py[m]; state = 2; }
+
+  const ts = [n], qs = [m];
+  let i = n, j = m, last = -1;
+  while (i > 0 || j > 0) {
+    if (last !== -1 && last !== state) { ts.push(i); qs.push(j); }
+    last = state;
+    const bits = trace[i * width + j];
+    if (state === 0) { state = bits & 3; i--; j--; }
+    else if (state === 1) { state = (bits >> 2) & 3; i--; }
+    else { state = (bits >> 4) & 3; j--; }
+  }
+  if (ts[ts.length - 1] !== 0 || qs[qs.length - 1] !== 0) { ts.push(0); qs.push(0); }
+  return { score, t: ts.reverse(), q: qs.reverse() };
+}
+
+export function pathScore(target, query, path) {
+  let total = 0;
+  for (let k = 1; k < path.t.length; k++) {
+    const dt = path.t[k] - path.t[k - 1], dq = path.q[k] - path.q[k - 1];
+    if (dt > 0 && dq > 0) {
+      for (let d = 0; d < dt; d++) {
+        total += target[path.t[k - 1] + d] === query[path.q[k - 1] + d] ? MATCH : MISMATCH;
+      }
+    } else if (dt > 0 || dq > 0) total += OPEN + (Math.max(dt, dq) - 1) * EXTEND;
+  }
+  return total;
+}
+
+/* ------------------------------------------------------------------ anchored */
+
+// FNV-1a over code points, folded to 53 bits so it is an exact JS number.
+function gramHash(seq, at, k) {
+  let lo = 0x811c9dc5, hi = 0x1000193;
+  for (let d = 0; d < k; d++) {
+    const c = seq[at + d];
+    lo = Math.imul(lo ^ c, 0x01000193) >>> 0;
+    hi = Math.imul(hi ^ (c >>> 3), 0x27d4eb2f) >>> 0;
+  }
+  return (hi & 0x1fffff) * 4294967296 + lo;
+}
+
+/** k-gram -> its position, or -2 when it occurs more than once. */
+function uniqueGrams(seq, from, to, k) {
+  const map = new Map();
+  for (let i = from; i <= to - k; i++) {
+    const h = gramHash(seq, i, k);
+    map.set(h, map.has(h) ? -2 : i);
+  }
+  return map;
+}
+
+function anchors(target, query, t0, t1, q0, q1, k) {
+  if (t1 - t0 < k || q1 - q0 < k) return [];
+  const inTarget = uniqueGrams(target, t0, t1, k);
+  const inQuery = uniqueGrams(query, q0, q1, k);
+  const runs = [];
+  let current = null;
+  for (let qi = q0; qi <= q1 - k; qi++) {
+    const h = gramHash(query, qi, k);
+    const ti = inQuery.get(h) === qi ? inTarget.get(h) : undefined;
+    let same = ti !== undefined && ti >= 0;
+    for (let d = 0; same && d < k; d++) same = target[ti + d] === query[qi + d];
+    if (!same) { current = null; continue; }
+    if (current && ti === current.t + (qi - current.q)) current.len = qi - current.q + k;
+    else runs.push(current = { t: ti, q: qi, len: k });
+  }
+  return runs;
+}
+
+/** Longest chain of runs in order on both sides (patience sort), overlaps trimmed. */
+function chainOf(runs) {
+  if (!runs.length) return runs;
+  const byT = runs.slice().sort((a, b) => a.t - b.t);
+  const tails = [], prev = new Int32Array(byT.length).fill(-1);
+  byT.forEach((r, i) => {
+    let lo = 0, hi = tails.length;
+    while (lo < hi) { const mid = (lo + hi) >>> 1; if (byT[tails[mid]].q < r.q) lo = mid + 1; else hi = mid; }
+    if (lo > 0) prev[i] = tails[lo - 1];
+    tails[lo] = i;
+  });
+  const picked = [];
+  for (let at = tails[tails.length - 1]; at >= 0; at = prev[at]) picked.push(byT[at]);
+  picked.reverse();
+
+  const out = [];
+  let endT = 0, endQ = 0;
+  for (const r of picked) {
+    const cut = Math.max(endT - r.t, endQ - r.q, 0);
+    if (cut >= r.len) continue;
+    r.t += cut; r.q += cut; r.len -= cut;
+    out.push(r);
+    endT = r.t + r.len; endQ = r.q + r.len;
+  }
+  return out;
+}
+
+/** Global alignment of inputs too big for one table. Same shape of result as gotoh. */
+export function anchoredAlign(target, query, { exactCells = 4_000_000, firstK = 14, lastK = 5 } = {}) {
+  const ts = [0], qs = [0];
+  let lastKind = 0;
+  const moveTo = (t, q) => {
+    const pt = ts[ts.length - 1], pq = qs[qs.length - 1];
+    if (t === pt && q === pq) return;
+    const dt = t - pt, dq = q - pq;
+    if (dt > 0 && dq > 0 && dt !== dq) {   // a diagonal and a gap in one step
+      const d = Math.min(dt, dq);
+      moveTo(pt + d, pq + d); moveTo(t, q);
+      return;
+    }
+    const kind = dt > 0 && dq > 0 ? 1 : dt > 0 ? 2 : 3;
+    if (kind === lastKind && ts.length > 1) { ts[ts.length - 1] = t; qs[qs.length - 1] = q; }
+    else { ts.push(t); qs.push(q); }
+    lastKind = kind;
+  };
+
+  // An explicit stack: a book is thousands of ranges deep in places.
+  const work = [[0, target.length, 0, query.length, firstK]];
+  while (work.length) {
+    const job = work.pop();
+    if (job.length === 2) { moveTo(job[0], job[1]); continue; }
+    const [t0, t1, q0, q1, k] = job;
+    const n = t1 - t0, m = q1 - q0;
+    if (n === 0 || m === 0) { moveTo(t1, q1); continue; }
+    if (cells(n, m) <= exactCells) {
+      const sub = gotoh(target.subarray(t0, t1), query.subarray(q0, q1));
+      for (let i = 0; i < sub.t.length; i++) moveTo(t0 + sub.t[i], q0 + sub.q[i]);
+      continue;
+    }
+
+    let kk = k, chain = [];
+    for (; kk >= lastK; kk -= 3) {
+      chain = chainOf(anchors(target, query, t0, t1, q0, q1, kk));
+      if (chain.length) break;
+    }
+    const next = [];
+    if (!chain.length) {
+      // Two long stretches with nothing in common: halve both and carry on.
+      const tm = t0 + (n >> 1), qm = q0 + (m >> 1);
+      next.push([t0, tm, q0, qm, lastK], [tm, t1, qm, q1, lastK]);
+    } else {
+      let ct = t0, cq = q0;
+      for (const r of chain) {
+        next.push([ct, r.t, cq, r.q, kk], [r.t + r.len, r.q + r.len]);
+        ct = r.t + r.len; cq = r.q + r.len;
+      }
+      next.push([ct, t1, cq, q1, kk]);
+    }
+    for (let i = next.length - 1; i >= 0; i--) work.push(next[i]);
+  }
+  moveTo(target.length, query.length);
+  const path = { t: ts, q: qs };
+  path.score = pathScore(target, query, path);
+  return path;
+}
+
+/* ----------------------------------------------------------------- align_sub */
+
+const FULL_STOP = '。'.codePointAt(0);
+
+function distinctExceptFullStop(cps, start, end) {
+  const seen = new Set();
+  for (let k = start; k < Math.min(end, cps.length); k++) if (cps[k] !== FULL_STOP) seen.add(cps[k]);
+  return seen.size;
+}
+
+/** Which piece of which paragraph each transcript segment covers. Spans are [start, end, sub]. */
+export function alignSub(path, text, subs, thing = 2) {
+  let line = 0, sub = 0, toff = 0, off = 0;
+  const pos = [0, 0], p = [0, 0], gaps = [0, 0];
+  const segments = [[]];
+  const last = () => segments[segments.length - 1];
+
+  for (let i = 0; i < path.t.length; i++) {
+    const c0 = path.t[i], c1 = path.q[i];
+    const isGap = c0 - p[0] === 0 || c1 - p[1] === 0;
+
+    while (sub < subs.length && pos[1] + subs[sub].length <= c1) {
+      if (line >= text.length) return segments.slice(0, text.length);
+      const subLen = subs[sub].length;
+      pos[1] += subLen;
+      if (isGap) { gaps[0] += Math.max(pos[1] - p[1], 0); gaps[1] += Math.max(pos[0] - p[0], 0); }
+
+      const diff = subLen + gaps[1] - gaps[0];
+      if (diff > Math.floor(subLen / 4)) {
+        let target = toff + diff + off;
+        off = 0;
+        while (line < text.length && target >= text[line].length) {
+          const start = toff, end = text[line].length;
+          if (end - start !== 0) {
+            const prev = last();
+            if (end - start < thing || distinctExceptFullStop(text[line], start, end) < thing) {
+              if (prev.length) prev[prev.length - 1][1] = end;
+              else prev.push([start, end, sub]);
+            } else prev.push([start, end, sub]);
+          }
+          segments.push([]);
+          pos[0] += end - start;
+          target -= text[line].length;
+          line++;
+          toff = 0;
+        }
+        pos[0] += target - toff;
+        last().push([toff, target, sub]);
+        toff = target;
+      } else {
+        const prev = last();
+        if (toff >= Math.floor(text[line].length / 2) && prev.length) {
+          prev[prev.length - 1][1] += diff;
+          toff += diff;
+        } else off += diff;
+      }
+
+      sub++;
+      gaps[0] = 0; gaps[1] = 0;
+      p[0] = Math.max(pos[0], p[0]); p[1] = Math.max(pos[1], p[1]);
+    }
+
+    if (isGap) { gaps[0] += c1 - p[1]; gaps[1] += c0 - p[0]; }
+    p[0] = c0; p[1] = c1;
+  }
+  return segments.slice(0, text.length);
+}
+
+/** Spans were computed on cleaned text; map them back onto the original. */
+export function fix(lang, original, edited, segments) {
+  segments.forEach((spans, l) => {
+    const o = toCodePoints(lang.translate(original[l])), e = edited[l];
+    const m = new Int32Array(e.length + 1).fill(-1);
+    let ei = 0;
+    for (let oi = 0; oi < o.length; oi++) {
+      if (ei < e.length && o[oi] === e[ei]) { m[ei] = oi; ei++; }
+    }
+    m[ei] = o.length;
+    m[0] = 0;
+    let lastSet = 0;
+    for (let i = 0; i < e.length; i++) { if (m[i] !== -1) lastSet = i; else m[i] = m[lastSet]; }
+    if (m[e.length] === -1) m[e.length] = o.length;
+    const clamp = (v) => Math.min(Math.max(v, 0), e.length);
+    for (const f of spans) { f[0] = m[clamp(f[0])]; f[1] = m[clamp(f[1])]; }
+  });
+}
+
+/** Python indexing: negatives count from the end; out of range is "no character". */
+const at = (t, i) => { const k = i < 0 ? i + t.length : i; return k >= 0 && k < t.length ? t[k] : -1; };
+
+/** Nudge span ends so punctuation stays with the words it belongs to. */
+export function fixPunc(text, segments, prepend, append, nopend) {
+  segments.forEach((s, l) => {
+    if (!s.length) return;
+    const t = toCodePoints(text[l]);
+    for (let k = 0; k < s.length; k++) {
+      const p = s[k], f = k + 1 < s.length ? s[k + 1] : s[k];
+      const connected = f[0] === p[1];
+      for (let loop = 0; loop <= 20; loop++) {
+        if (p[1] < t.length && append.has(at(t, p[1]))) p[1] += 1;
+        else if (prepend.has(at(t, p[1] - 1))) p[1] -= 1;
+        else if ((p[1] > 0 && nopend.has(at(t, p[1] - 1))) ||
+                 (p[1] < t.length && nopend.has(at(t, p[1]))) ||
+                 (p[1] < t.length - 1 && nopend.has(at(t, p[1] + 1)))) {
+          let start = p[1] - 1, end = p[1];
+          if (p[1] < t.length - 1) {
+            const here = at(t, p[1]);
+            if ((nopend.has(at(t, p[1] + 1)) && 0x4e00 > here) || here > 0x9faf) end += 1;
+          }
+          while (start > 0 && nopend.has(at(t, start))) start -= 1;
+          while (end < t.length - 1 && nopend.has(at(t, end))) end += 1;
+
+          if (prepend.has(at(t, start))) { if (p[1] === start) break; p[1] = start; }
+          else if (append.has(at(t, start))) { if (p[1] === start + 1) break; p[1] = start + 1; }
+          else if (end < t.length && prepend.has(at(t, end))) { if (p[1] === end) break; p[1] = end; }
+          else if (end < t.length && append.has(at(t, end))) { if (p[1] === end + 1) break; p[1] = end + 1; }
+          else break;
+        } else break;
+      }
+      if (connected) f[0] = p[1];
+    }
+  });
+}
+
+/* ---------------------------------------------------------------------- subs */
+
+export const START_PUNC = '『「((《⦅[{"\'“¿' + '\'“"¿([{-『「(〈《〔【{[⦅<<‘“〝※';
+export const END_PUNC = '\'"・.。!!??::”>>⦆)]}』」)〉》〕】}]’〟/\~〜~;;─―–-➡';
+export const OTHER_PUNC = '* ,,、…';
+export const NOPEND = 'うぁぃぅぇぉっゃゅょゎゕゖァィゥェォヵㇰヶㇱㇲッㇳㇴㇵㇶㇷㇷ゚ㇸㇹㇺャュョㇻㇼㇽㇾㇿヮ…  ';
+export const UNMATCHED = '*';
+
+const cpSet = (s) => new Set(toCodePoints(s));
+export const PREPEND_SET = cpSet(START_PUNC);
+export const APPEND_SET = cpSet(END_PUNC + OTHER_PUNC);
+export const NOPEND_SET = cpSet(NOPEND);
+
+const PUNCT = new Set(START_PUNC + END_PUNC + OTHER_PUNC);
+const START = new Set(START_PUNC), END = new Set(END_PUNC);
+
+function pySlice(cps, a, b) {
+  const n = cps.length, fixIndex = (v) => Math.min(Math.max(v < 0 ? v + n : v, 0), n);
+  return fromCodePoints(cps, fixIndex(a), fixIndex(b));
+}
+
+/** One cue per transcript segment: timed by the recogniser, worded by the book. */
+export function toSubs(text, subs, alignment, offset = 0) {
+  const flat = [];
+  alignment.forEach((spans, line) => spans.forEach((span) => flat.push({ span, line })));
+  const lines = text.map(toCodePoints);
+  const out = [];
+  let start = 0, end = 0;
+  subs.forEach((s, si) => {
+    while (end < flat.length && flat[end].span[2] === si) end++;
+    let body = '';
+    for (let k = start; k < end; k++) body += pySlice(lines[flat[k].line], flat[k].span[0], flat[k].span[1]);
+    out.push({ text: body.trim() ? body : UNMATCHED + s.text, start: s.start + offset, end: s.end + offset });
+    start = end;
+  });
+  return out;
+}
+
+const punctIndices = (s) => { const r = []; for (let i = 0; i < s.length; i++) if (PUNCT.has(s[i])) r.push(i); return r; };
+const countNonPunct = (s) => { let n = 0; for (let i = 0; i < s.length; i++) if (!PUNCT.has(s[i])) n++; return n; };
+const doubleComma = (a, b) => a[a.length - 1] === '、' && b[b.length - 1] === '、';
+
+/** Move a stranded character or two across a cue boundary. Cues are mutated, as in the original. */
+export function shiftAlign(segments) {
+  const fresh = [];
+  let startIndex = 0;   // read by the second pass too, as in the original
+  segments.forEach((segment, i) => {
+    let text = segment.text;
+    const idx = punctIndices(text);
+    if (!idx.length) { fresh.push(segment); return; }
+    startIndex = idx[0];
+    const nonPunc = countNonPunct(text.slice(0, startIndex));
+    if (nonPunc === 0 || countNonPunct(text.slice(startIndex + 1)) === 0) { fresh.push(segment); return; }
+    const prev = fresh[fresh.length - 1];
+    if (nonPunc <= 2 && i > 0 && fresh.length && !END.has(prev.text[prev.text.length - 1]) &&
+        !doubleComma(prev.text, text.slice(0, startIndex + 1))) {
+      prev.text += text.slice(0, startIndex + 1);
+      text = text.slice(startIndex + 1);
+    }
+    fresh.push({ text, start: segment.start, end: segment.end });
+  });
+
+  const final = [];
+  for (let i = 0; i < fresh.length; i++) {
+    const segment = fresh[i];
+    let text = segment.text;
+    const idx = punctIndices(text);
+    if (idx.length) {
+      const lastIndex = idx[idx.length - 1], tail = text.slice(lastIndex);
+      if (countNonPunct(tail) === 0 || tail.length === text.length) { final.push(segment); continue; }
+      if (countNonPunct(tail) <= 2 && i + 1 < fresh.length && !END.has(fresh[i + 1].text[0]) &&
+          !doubleComma(fresh[i + 1].text.slice(0, 1), text.slice(0, startIndex + 1))) {
+        // The original reaches into the *input* list here. Kept: the fixtures depend on it.
+        const next = segments[i + 1];
+        next.text = text.slice(lastIndex + 1) + next.text;
+        text = text.slice(0, lastIndex + 1);
+        final.push({ text, start: segment.start, end: segment.end });
+        fresh[i + 1] = next;
+        continue;
+      }
+    }
+    final.push({ text, start: segment.start, end: segment.end });
+  }
+
+  if (final.length >= 2) {
+    const stray = /(」「(.{1,2})、$|」「(.{1})、$)/u;
+    const adjusted = [];
+    final.forEach((segment, i) => {
+      const m = stray.exec(segment.text);
+      if (m && i < final.length - 1) {
+        final[i + 1].text = m[0] + final[i + 1].text;
+        segment.text = segment.text.slice(0, m.index);
+      }
+      if (segment.text && END.has(segment.text[0]) && i > 0) {
+        adjusted[adjusted.length - 1].text += segment.text[0];
+        segment.text = segment.text.slice(1);
+      }
+      if (segment.text && START.has(segment.text[segment.text.length - 1]) && i < final.length - 1) {
+        final[i + 1].text = segment.text[segment.text.length - 1] + final[i + 1].text;
+        segment.text = segment.text.slice(0, -1);
+      }
+      adjusted.push(segment);
+    });
+  }
+  return final.map((c) => ({ text: c.text.trim(), start: c.start, end: c.end }));
+}
+
+/* ------------------------------------------------------------------ pipeline */
+
+const EXACT_CELL_LIMIT = 64_000_000;
+
+function concat(parts) {
+  const out = new Int32Array(parts.reduce((n, p) => n + p.length, 0));
+  let o = 0;
+  for (const p of parts) { out.set(p, o); o += p.length; }
+  return out;
+}
+
+export function align(transcript, paragraphs, lang, { anchoredOnly = false, exactCells } = {}) {
+  if (!transcript.length) return [];
+  const subsClean = transcript.map((s) => toCodePoints(lang.clean(s.text)));
+  const textClean = paragraphs.map((p) => toCodePoints(lang.clean(p)));
+  const query = concat(subsClean), target = concat(textClean);
+  if (!target.length || !query.length) {
+    return transcript.map((s) => ({ text: UNMATCHED + s.text, start: s.start, end: s.end }));
+  }
+  const path = !anchoredOnly && cells(target.length, query.length) <= EXACT_CELL_LIMIT
+    ? gotoh(target, query) : anchoredAlign(target, query, exactCells ? { exactCells } : {});
+  const spans = alignSub(path, textClean, subsClean);
+  fix(lang, paragraphs, textClean, spans);
+  fixPunc(paragraphs, spans, PREPEND_SET, APPEND_SET, NOPEND_SET);
+  return shiftAlign(toSubs(paragraphs, transcript, spans));
+}
+
+// A paragraph counts as heard when this share of it agrees with the transcript...
+const HEARD = 0.20;
+// ...and unheard ones are dropped only in runs this long: one mangled heading
+// was still read aloud; a page of nothing was not.
+const MIN_UNREAD_RUN = 240;
+
+/** The paragraphs that were actually narrated: front matter, notes and the like removed. */
+export function narratedOnly(transcript, paragraphs, lang) {
+  const textClean = paragraphs.map((p) => toCodePoints(lang.clean(p)));
+  const target = concat(textClean);
+  const query = concat(transcript.map((s) => toCodePoints(lang.clean(s.text))));
+  if (!target.length || !query.length) return paragraphs;
+
+  const path = anchoredAlign(target, query);
+  const agreed = new Uint8Array(target.length);
+  for (let k = 1; k < path.t.length; k++) {
+    const t0 = path.t[k - 1], q0 = path.q[k - 1], dt = path.t[k] - t0;
+    if (dt > 0 && path.q[k] - q0 > 0) {
+      for (let d = 0; d < dt; d++) agreed[t0 + d] = target[t0 + d] === query[q0 + d] ? 1 : 0;
+    }
+  }
+
+  let o = 0;
+  const heard = textClean.map((cps) => {
+    let hits = 0;
+    for (let i = o; i < o + cps.length; i++) hits += agreed[i];
+    o += cps.length;
+    return cps.length === 0 || hits >= HEARD * cps.length;
+  });
+
+  const keep = paragraphs.map(() => true);
+  for (let i = 0; i < paragraphs.length;) {
+    if (heard[i]) { i++; continue; }
+    let j = i, chars = 0;
+    while (j < paragraphs.length && (!heard[j] || textClean[j].length === 0)) { chars += textClean[j].length; j++; }
+    if (chars >= MIN_UNREAD_RUN) for (let k = i; k < j; k++) keep[k] = false;
+    i = j;
+  }
+  const out = paragraphs.filter((_, k) => keep[k]);
+  return out.length ? out : paragraphs;
+}
+
+/** A whole book at once. No chapter matching: the alignment itself says what was read. */
+export function alignBook(transcript, paragraphs, lang) {
+  const narrated = narratedOnly(transcript, paragraphs, lang);
+  const cues = align(transcript, narrated, lang);
+  const matched = cues.filter((c) => !c.text.startsWith(UNMATCHED)).length;
+  return {
+    cues,
+    paragraphsUsed: narrated.length,
+    paragraphsDropped: paragraphs.length - narrated.length,
+    matchRate: cues.length ? matched / cues.length : 0,
+  };
+}
+
+/* ----------------------------------------------------------------------- srt */
+
+export function stamp(seconds) {
+  const ms = Math.round(Math.max(seconds, 0) * 1000);
+  const pad = (v, n = 2) => String(v).padStart(n, '0');
+  return `${pad(Math.floor(ms / 3600000))}:${pad(Math.floor(ms / 60000) % 60)}:${pad(Math.floor(ms / 1000) % 60)},${pad(ms % 1000, 3)}`;
+}
+
+/** One line of text per cue, always: Hoshi Reader reads exactly the third line of each block. */
+export function writeSrt(cues) {
+  let out = '', n = 0;
+  for (const cue of cues) {
+    const text = cue.text.replace(/\s*[\r\n]+\s*/g, ' ').trim();
+    if (!text) continue;
+    out += `${++n}\n${stamp(cue.start)} --> ${stamp(cue.end)}\n${text}\n\n`;
+  }
+  return out;
+}
diff --git a/src/engine/align.worker.js b/src/engine/align.worker.js
new file mode 100644
index 0000000..cf1e1a7
--- /dev/null
+++ b/src/engine/align.worker.js
@@ -0,0 +1,12 @@
+/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author). */
+/* Alignment off the main thread: a long book is a few seconds of solid work. */
+import { alignBook, language, writeSrt } from './align.js';
+
+self.onmessage = ({ data }) => {
+  try {
+    const result = alignBook(data.transcript, data.paragraphs, language(data.language));
+    self.postMessage({ ok: true, ...result, srt: writeSrt(result.cues) });
+  } catch (e) {
+    self.postMessage({ ok: false, error: String(e?.message || e) });
+  }
+};
diff --git a/src/engine/asr.js b/src/engine/asr.js
new file mode 100644
index 0000000..bc6eb3b
--- /dev/null
+++ b/src/engine/asr.js
@@ -0,0 +1,89 @@
+/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author).
+ * Adapted: transformers.js and the model load from a configurable asset
+ * base URL instead of this origin's /vendor/.
+ */
+
+/* Speech recognition in the browser: Whisper-tiny on the ONNX runtime, through
+ * transformers.js. WebGPU where the browser has it (many times faster than real
+ * time on an ordinary laptop), WebAssembly threads where it does not.
+ *
+ * The transcript only has to be good enough to line up against a book we
+ * already have the words of, which is why the smallest model will do.
+ */
+
+const MODEL = 'whisper-tiny';
+
+/** transformers.js, loaded once from `assets` (a URL that ends with "/"). */
+let loaded = null;
+
+async function transformers(assets) {
+  loaded ??= import(/* @vite-ignore */ `${assets}transformers/transformers.min.js`).then((m) => {
+    const { env } = m;
+    env.backends.onnx.wasm.wasmPaths = `${assets}transformers/`;
+    // The model weights live under <assets>/models/whisper-tiny/.
+    env.remoteHost = `${assets}models/`;
+    env.remotePathTemplate = '{model}/';
+    env.allowRemoteModels = true;
+    env.allowLocalModels = false;
+    return m;
+  });
+  return loaded;
+}
+
+export async function hasWebGpu() {
+  try { return !!(navigator.gpu && await navigator.gpu.requestAdapter()); } catch { return false; }
+}
+
+export class Recogniser {
+  #pipe;
+  device;
+
+  /** @param onProgress ({file, loaded, total}) while the ~120 MB of weights download (cached after). */
+  static async open(assets, onProgress) {
+    const { pipeline } = await transformers(assets);
+    const r = new Recogniser();
+    r.device = (await hasWebGpu()) ? 'webgpu' : 'wasm';
+    const options = (device) => ({
+      device,
+      // Full-precision encoder: quantising it is what ruins accuracy. The
+      // decoder tolerates 4-bit well and is where the time goes.
+      dtype: device === 'webgpu'
+        ? { encoder_model: 'fp32', decoder_model_merged: 'q4' }
+        : { encoder_model: 'fp32', decoder_model_merged: 'q8' },
+      progress_callback: (p) => { if (p.status === 'progress') onProgress?.(p); },
+    });
+    try {
+      r.#pipe = await pipeline('automatic-speech-recognition', MODEL, options(r.device));
+    } catch (e) {
+      if (r.device !== 'webgpu') throw e;
+      // A GPU that is listed but cannot run the model: fall back rather than fail.
+      r.device = 'wasm';
+      r.#pipe = await pipeline('automatic-speech-recognition', MODEL, options('wasm'));
+    }
+    return r;
+  }
+
+  /**
+   * @param pcm 16 kHz mono Float32Array
+   * @param language a Whisper code ("ja", "ru", ...), or null to let it decide
+   * @returns [{text, start, end}] with times offset by `offset` seconds
+   */
+  async transcribe(pcm, language, offset = 0) {
+    const out = await this.#pipe(pcm, {
+      return_timestamps: true,
+      chunk_length_s: 30,
+      stride_length_s: 5,
+      task: 'transcribe',
+      ...(language ? { language } : {}),
+    });
+    const segments = [];
+    for (const c of out.chunks ?? []) {
+      const text = (c.text ?? '').trim();
+      const [s, e] = c.timestamp ?? [];
+      if (!text || s == null) continue;
+      // The last segment of a buffer can come back without an end.
+      segments.push({ text, start: offset + s, end: offset + (e ?? pcm.length / 16000) });
+    }
+    return segments;
+  }
+}
diff --git a/src/engine/job.d.ts b/src/engine/job.d.ts
new file mode 100644
index 0000000..9de17d8
--- /dev/null
+++ b/src/engine/job.d.ts
@@ -0,0 +1,47 @@
+// Types for the parts of job.js that the app uses.
+
+import type { BookAlignment } from './align.js';
+
+export interface JobStatus {
+  phase: 'preparing' | 'transcribing' | 'aligning' | 'done';
+  detail: string;
+  /** Progress in [0, 1]. */
+  fraction: number;
+  /** Seconds left, once the speed is known. */
+  eta?: number | null;
+  /** Audio seconds transcribed per wall-clock second. */
+  speed?: number | null;
+  indeterminate?: boolean;
+}
+
+export interface JobResult extends BookAlignment {
+  srt: string;
+  srtName: string;
+  stem: string;
+  duration: number;
+  segments: number;
+}
+
+export interface JobOptions {
+  audio: File[];
+  paragraphs: string[];
+  /** Whisper code, such as "ja". */
+  language: string;
+  /** Asset base URL. Must end with "/". */
+  assets: string;
+  onStatus?: (status: JobStatus) => void;
+}
+
+export class Cancelled extends Error {}
+
+export class Job {
+  constructor(options: JobOptions);
+  /** "webgpu" or "wasm", set once the model is open. */
+  device?: string;
+  result: JobResult | null;
+  run(): Promise<JobResult>;
+  cancel(): void;
+  close(): void;
+}
+
+export function clock(seconds: number): string;
diff --git a/src/engine/job.js b/src/engine/job.js
new file mode 100644
index 0000000..fe744c2
--- /dev/null
+++ b/src/engine/job.js
@@ -0,0 +1,159 @@
+/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author).
+ * Adapted: the paragraphs come in as a string list (the OCR lines), so the
+ * book reader is gone; the video and read-along epub outputs are gone; the
+ * engine assets load from a configurable base URL.
+ */
+
+/* One conversion, start to finish, in this tab.
+ *
+ *   audio --ffmpeg--> two-minute chunks --whisper--> rough transcript
+ *   OCR lines ------> paragraphs
+ *   transcript + paragraphs --align--> subtitles (.srt)
+ *
+ * Nothing leaves the machine. The transcript is saved to IndexedDB after every
+ * chunk, because transcribing a book takes hours and tabs get closed: opening
+ * the same files again carries on from the last chunk.
+ */
+import { Media, quietestPoint, SAMPLE_RATE } from './media.js';
+import { Recogniser } from './asr.js';
+
+const CHUNK_SECONDS = 120;
+
+export class Cancelled extends Error { constructor() { super('Stopped.'); } }
+
+/* ------------------------------------------------------------ saved progress */
+
+const db = () => new Promise((resolve, reject) => {
+  const open = indexedDB.open('subread', 1);
+  open.onupgradeneeded = () => open.result.createObjectStore('transcripts');
+  open.onsuccess = () => resolve(open.result);
+  open.onerror = () => reject(open.error);
+});
+
+async function store(mode, fn) {
+  const d = await db();
+  return new Promise((resolve, reject) => {
+    const tx = d.transaction('transcripts', mode);
+    const req = fn(tx.objectStore('transcripts'));
+    tx.oncomplete = () => resolve(req?.result);
+    tx.onerror = () => reject(tx.error);
+  });
+}
+
+/** The same files picked again resume, whatever route they were picked by. */
+const keyFor = (files, language) => files.map((f) => `${f.name}|${f.size}`).join('//') + `#${language}`;
+
+export async function savedProgress(files, language) {
+  try { return (await store('readonly', (s) => s.get(keyFor(files, language)))) ?? null; } catch { return null; }
+}
+
+/* ------------------------------------------------------------------- the job */
+
+export class Job {
+  #media = null;
+  #paths = [];
+  #cancelled = false;
+
+  constructor({ audio, paragraphs, language, assets, onStatus }) {
+    this.audio = audio;            // File[], in playback order
+    this.paragraphs = paragraphs;  // string[], the book text lines
+    this.language = language;      // Whisper code; always explicit, see asr.js
+    this.assets = assets;          // URL that ends with "/", see asr.js and media.js
+    this.onStatus = onStatus ?? (() => {});
+    this.result = null;
+  }
+
+  cancel() { this.#cancelled = true; }
+  #check() { if (this.#cancelled) throw new Cancelled(); }
+  #status(phase, detail, fraction, extra = {}) { this.onStatus({ phase, detail, fraction, ...extra }); }
+
+  async run() {
+    if (!this.paragraphs.length) throw new Error('The book has no text. Run OCR first.');
+
+    this.#status('preparing', 'Starting the audio decoder', 0);
+    this.#media = await Media.open(this.assets);
+    const parts = [];
+    for (const [i, file] of this.audio.entries()) {
+      const path = await this.#media.mount(file, `/in${i}`);
+      parts.push({ path, ...(await this.#media.probe(path)) });
+      this.#paths.push(path);
+    }
+    const total = parts.reduce((n, p) => n + p.duration, 0);
+
+    const transcript = await this.#transcribe(parts, total);
+    this.#check();
+
+    this.#status('aligning', 'Matching the book to the narration', 0.97);
+    const aligned = await alignInWorker(transcript, this.paragraphs, this.language);
+    const stem = this.audio[0].name.replace(/\.[^.]+$/, '');
+    this.result = {
+      ...aligned, stem, duration: total, parts,
+      srtName: `${stem}.${this.language}.srt`,
+      segments: transcript.length,
+    };
+    this.#status('done', 'Done', 1);
+    return this.result;
+  }
+
+  async #transcribe(parts, total) {
+    const key = keyFor(this.audio, this.language);
+    const saved = (await savedProgress(this.audio, this.language)) ?? { doneUntil: 0, segments: [], complete: false };
+    if (saved.complete) return saved.segments;
+
+    this.#status('preparing', 'Loading the speech model', 0, { indeterminate: true });
+    const asr = await Recogniser.open(this.assets, (p) => {
+      if (p.total) this.#status('preparing', 'Downloading the speech model (once)', p.loaded / p.total * 0.02);
+    });
+    this.device = asr.device;
+
+    const resumedAt = saved.doneUntil, started = performance.now();
+    let base = 0;   // where the current part starts on the whole book's clock
+    for (const part of parts) {
+      let pos = Math.max(0, saved.doneUntil - base);
+      while (pos < part.duration - 0.05) {
+        this.#check();
+        let pcm = await this.#media.pcm(part.path, pos, CHUNK_SECONDS);
+        if (!pcm.length) break;
+        const last = pos + pcm.length / SAMPLE_RATE >= part.duration - 0.5;
+        // Cut where the narrator pauses, so no word straddles two chunks.
+        if (!last) pcm = pcm.subarray(0, quietestPoint(pcm));
+
+        const at = base + pos;
+        saved.segments.push(...await asr.transcribe(pcm, this.language, at));
+        pos += pcm.length / SAMPLE_RATE;
+        saved.doneUntil = base + pos;
+        await store('readwrite', (s) => s.put(saved, key)).catch(() => {});
+
+        const elapsed = (performance.now() - started) / 1000;
+        const speed = (saved.doneUntil - resumedAt) / elapsed;
+        this.#status('transcribing', `Listening: ${clock(saved.doneUntil)} of ${clock(total)}`,
+          0.02 + 0.94 * saved.doneUntil / total,
+          { eta: elapsed > 10 ? (total - saved.doneUntil) / speed : null, speed: elapsed > 10 ? speed : null });
+      }
+      base += part.duration;
+    }
+    saved.complete = true;
+    await store('readwrite', (s) => s.put(saved, key)).catch(() => {});
+    return saved.segments;
+  }
+
+  close() { this.#media?.close(); }
+}
+
+function alignInWorker(transcript, paragraphs, language) {
+  return new Promise((resolve, reject) => {
+    const worker = new Worker(new URL('./align.worker.js', import.meta.url), { type: 'module' });
+    worker.onmessage = ({ data }) => {
+      worker.terminate();
+      if (data.ok) resolve(data); else reject(new Error(data.error));
+    };
+    worker.onerror = (e) => { worker.terminate(); reject(new Error(e.message || 'The aligner crashed.')); };
+    worker.postMessage({ transcript, paragraphs, language });
+  });
+}
+
+export function clock(seconds) {
+  const s = Math.max(0, Math.floor(seconds)), pad = (v) => String(v).padStart(2, '0');
+  return s >= 3600 ? `${Math.floor(s / 3600)}:${pad(Math.floor(s / 60) % 60)}:${pad(s % 60)}`
+    : `${Math.floor(s / 60)}:${pad(s % 60)}`;
+}
diff --git a/src/engine/media.js b/src/engine/media.js
new file mode 100644
index 0000000..d000fea
--- /dev/null
+++ b/src/engine/media.js
@@ -0,0 +1,99 @@
+/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author).
+ * Adapted: the ffmpeg wrapper comes from the @ffmpeg/ffmpeg package (its
+ * worker must be same-origin), and the 32 MB core loads from a configurable
+ * asset base URL.
+ */
+
+/* Reading and writing media in the browser, with ffmpeg compiled to WebAssembly.
+ *
+ * The visitor's file is never copied anywhere: it is mounted read-only into
+ * ffmpeg's filesystem (WORKERFS), which reads straight from the File on disk.
+ * That is what makes a 600 MB audiobook workable - the only audio ever held in
+ * memory is the couple of minutes being transcribed.
+ *
+ * ffmpeg runs in its own worker; everything here is async message-passing.
+ */
+import { FFmpeg } from '@ffmpeg/ffmpeg';
+
+const RATE = 16000;
+
+export class Media {
+  #ffmpeg = new FFmpeg();
+  #log = [];
+  #mounted = [];
+
+  /** Start ffmpeg with the single-thread core at `<assets>ffmpeg/ffmpeg-core.js`. */
+  static async open(assets, onLog) {
+    const m = new Media();
+    m.#ffmpeg.on('log', ({ message }) => {
+      m.#log.push(message);
+      if (m.#log.length > 400) m.#log.shift();
+      onLog?.(message);
+    });
+    await m.#ffmpeg.load({ coreURL: `${assets}ffmpeg/ffmpeg-core.js` });
+    return m;
+  }
+
+  /** Make `file` readable by ffmpeg at the returned path, without copying it. */
+  async mount(file, dir) {
+    await this.#ffmpeg.createDir(dir).catch(() => {});
+    await this.#ffmpeg.mount('WORKERFS', { files: [file] }, dir);
+    this.#mounted.push(dir);
+    return `${dir}/${file.name}`;
+  }
+
+  /** Duration in seconds and chapter marks, read from ffmpeg's own banner. */
+  async probe(path) {
+    this.#log.length = 0;
+    await this.#ffmpeg.exec(['-hide_banner', '-i', path]);   // "fails": no output given. Expected.
+    const text = this.#log.join('\n');
+    const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(text);
+    if (!d) throw new Error('This file does not look like audio ffmpeg can read.');
+    const audio = /Stream #\d+:\d+.*?: Audio: ([a-z0-9_]+)/i.exec(text);
+    return {
+      duration: (+d[1]) * 3600 + (+d[2]) * 60 + (+d[3]),
+      codec: audio ? audio[1].toLowerCase() : null,
+      chapters: [...text.matchAll(/Chapter #\d+:\d+: start ([\d.]+), end ([\d.]+)/g)]
+        .map((c) => ({ start: +c[1], end: +c[2] })),
+    };
+  }
+
+  /** `seconds` of audio from `start`, as the 16 kHz mono float PCM the speech model takes. */
+  async pcm(path, start, seconds) {
+    const out = 'chunk.f32';
+    const code = await this.#ffmpeg.exec([
+      '-hide_banner', '-v', 'error',
+      // Tolerate damaged files: drop what cannot be decoded, keep going.
+      '-err_detect', 'ignore_err', '-fflags', '+discardcorrupt',
+      '-ss', String(start), '-t', String(seconds), '-i', path,
+      '-map', '0:a:0', '-vn', '-sn', '-ac', '1', '-ar', String(RATE), '-f', 'f32le', out,
+    ]);
+    if (code !== 0) throw new Error(`Could not decode the audio at ${Math.round(start)}s.`);
+    const bytes = await this.#ffmpeg.readFile(out);
+    await this.#ffmpeg.deleteFile(out);
+    return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
+  }
+
+  close() { this.#ffmpeg.terminate(); }
+}
+
+/**
+ * Where to cut a chunk: the quietest quarter second in its last few seconds,
+ * so that no word is split across two transcriptions.
+ * Returns a sample index into `pcm`.
+ */
+export function quietestPoint(pcm, searchSeconds = 12, windowSeconds = 0.25) {
+  const window = Math.floor(windowSeconds * RATE);
+  const from = Math.max(0, pcm.length - searchSeconds * RATE);
+  if (pcm.length - from <= window * 2) return pcm.length;
+  let energy = 0;
+  for (let i = from; i < from + window; i++) energy += Math.abs(pcm[i]);
+  let best = energy, bestAt = from;
+  for (let i = from; i + window < pcm.length; i++) {
+    energy += Math.abs(pcm[i + window]) - Math.abs(pcm[i]);
+    if (energy < best) { best = energy; bestAt = i + 1; }
+  }
+  return bestAt + (window >> 1);
+}
+
+export const SAMPLE_RATE = RATE;
diff --git a/test/engine.test.ts b/test/engine.test.ts
new file mode 100644
index 0000000..7c11b00
--- /dev/null
+++ b/test/engine.test.ts
@@ -0,0 +1,31 @@
+// One smoke test for the copied engine: it is tested in its own repository.
+import { describe, expect, it } from 'vitest';
+import { alignBook, language, writeSrt } from '../src/engine/align.js';
+import { parseSubtitles } from '../src/subtitles';
+
+describe('engine align.js', () => {
+  it('aligns a tiny transcript to three paragraphs and writes SRT the parser reads', () => {
+    const paragraphs = [
+      '朝の光が丘の上に昇った。',
+      '村の人々は目を覚ました。',
+      'パン屋は店を開けた。',
+    ];
+    // A rough transcript: no punctuation, one recognition slip.
+    const transcript = [
+      { text: '朝の光が丘の上に昇った', start: 0, end: 2.5 },
+      { text: '村の人々は目を覚ました', start: 2.5, end: 5 },
+      { text: 'パン屋は店をあけた', start: 5, end: 7.5 },
+    ];
+    const result = alignBook(transcript, paragraphs, language('ja'));
+    expect(result.cues).toHaveLength(3);
+    expect(result.matchRate).toBe(1);
+    expect(result.cues.map((c) => c.text)).toEqual(paragraphs);
+
+    const cues = parseSubtitles(writeSrt(result.cues));
+    expect(cues).toEqual([
+      { start: 0, end: 2.5, text: paragraphs[0] },
+      { start: 2.5, end: 5, text: paragraphs[1] },
+      { start: 5, end: 7.5, text: paragraphs[2] },
+    ]);
+  });
+});
diff --git a/vite.config.ts b/vite.config.ts
index 26f2679..94d4615 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,37 @@
 import { defineConfig } from 'vite';
 
+/**
+ * The dev proxy makes the subread.space API and its engine assets
+ * same-origin in the browser, so the app uses the same relative paths on
+ * the web as it does in the Android app (where native HTTP takes over).
+ */
+const SUBREAD = 'https://subread.space';
+
 export default defineConfig({
   build: {
     target: 'es2022',
   },
+  worker: {
+    format: 'es',
+  },
+  optimizeDeps: {
+    // Pre-bundling would break the worker URL inside the ffmpeg wrapper.
+    exclude: ['@ffmpeg/ffmpeg'],
+  },
+  server: {
+    proxy: {
+      '/api': {
+        target: SUBREAD,
+        changeOrigin: true,
+        cookieDomainRewrite: 'localhost',
+        // The SRT download redirects to a presigned URL. Follow it here, so
+        // the browser sees one same-origin answer.
+        followRedirects: true,
+      },
+      '/vendor': {
+        target: SUBREAD,
+        changeOrigin: true,
+      },
+    },
+  },
 });