Description
An audit of lyric-romanizer × OpenKara (against the 2026-08-12 dist/ and lyric-romanizer@0.3.0) found that almost all romanization cost is in this repo's packaging and call pattern, not in the library's orchestration. The library's lazy import() and whole-array pin are already the right shape; OpenKara currently fights both.
This issue is the OpenKara half. The library half is implemented in thedavidweng/lyric-romanizer as 0.3.1 (additive: warmup, lyric-romanizer/dict, Vite-worker docs). Bump after that release is published.
Measured cost (OpenKara dist/, 2026-08-12)
| Asset | Raw | gzip | Role |
|---|---|---|---|
dist/dict/* (kuromoji) | 17.0 MB | 17.0 MB | Japanese morphological dict, copied at build |
assets/romanize.worker-*.js | 1.11 MB | 537 KB | Worker inlined every engine |
assets/index-*.js (contains pinyin-pro) | 1.57 MB | 489 KB | Main bundle; alphabet-index.ts statically imports pinyin-pro |
leftover async chunks (to-jyutping 379 KB, transliteration 178 KB, sanscript 109 KB, …) | ~0.8 MB | ~0.4 MB | Main-thread import("lyric-romanizer") fallback, unused when the worker exists |
The worker file has zero dynamic import(). Vite rewrote every import('pinyin-pro') / import('[@sglkc](/sglkc)/kuroshiro') / … into Promise.resolve().then(...). Toggling romanization on a Chinese song still parses Japanese, Cantonese, Indic, and the fallback transliterator.
pinyin-pro is on disk twice: once in the 1.1 MB worker, once in the main bundle because src/lib/alphabet-index.ts does import { pinyin } from "pinyin-pro" solely to take the first letter of the first Han grapheme.
Root causes
1. Vite worker IIFE eats lazy loading (biggest JS win)
// src/workers/romanize.worker.ts
new Worker(new URL("../workers/romanize.worker.ts", import.meta.url), { type: "module" })
vite.config.ts does not set worker.format. Vite production default is 'iife'. IIFE workers cannot code-split, so lyric-romanizer's per-engine import() is inlined. The main-thread fallback does split — which is why those extra ~800 KB chunks exist at all.
Fix:
// vite.config.ts
worker: { format: "es" }
After this, a Chinese song should load pinyin-pro only; Jyutping / kuroshiro / sanscript stay on disk as separate chunks until first use.
If the Tauri webview always has Worker, the main-thread getRomanizer() in src/lib/lyrics-romanizer.ts (and the engine chunks it pulls) is dead weight. Keep a tiny fallback or drop it; do not let it retain the engine graph on the main entry.
2. Pinned-language path calls romanizeLines once per line
// src/lib/romanize-options.ts — language is a known SongLanguage
return Promise.all(
lines.map(async (line) => {
if (isLatinScript([line])) return line;
try {
const r = await romanizer.romanizeLines([line], options);
return r.lines[0] ?? line;
} catch {
return line;
}
}),
);
This predates lyric-romanizer@0.3.0. The library already:
- returns pure-Latin lines unchanged under a pinned script (latin guard; ADR-0002)
- isolates engine failure per line via
RomanizeResult.fallbacks(does not throw) - only throws
UnsupportedRomanizationErrorwhen the script has no engine — and everySongLanguagemaps to a local engine
So for a pinned language this should be one call:
const r = await romanizer.romanizeLines(lines, OPTIONS_BY_LANGUAGE[language]);
return lines.map((line, i) => r.lines[i] ?? line);
The unknown-language branch (romanizeLines(lines) with no options) is already correct. Tests in src/lib/romanize-options.test.ts currently assert the per-line loop (toHaveBeenCalledTimes(2)); update them to assert a single batched call.
isLatinScript is also stacked three times: main thread before creating the worker (keep — English songs must not spin a worker), worker entry, and every pinned line (drop the last two once batched).
OpenKara currently ignores fallbacks. After batching, a failed engine line is a readable transliteration plus fallbacks[i] === true, not the original CJK. That is an improvement; do not swallow it back to the original unless product wants that.
3. Kuromoji dict copy is a leaked library contract
vite.config.ts kuromojiDictPlugin hard-codes:
- package
[@sglkc](/sglkc)/kuromoji(also a direct devDependency, can drift fromkuroshiro-analyzer-kuromoji) src/kuromoji.js→../dictlayout- the twelve
*.dat.gznames
lyric-romanizer@0.3.1 owns that knowledge:
import {
KUROMOJI_DICT_FILES,
resolveKuromojiDictDir,
} from "lyric-romanizer/dict"; // Node / build-time only — not from the worker
const src = resolveKuromojiDictDir();
for (const file of KUROMOJI_DICT_FILES) {
copyFileSync(join(src, file), join(dest, file));
}
Then delete the [@sglkc](/sglkc)/kuromoji devDependency. Keep japaneseDictPath: "/dict/". public/dict/ is already gitignored.
Optional follow-up, once 0.3.1 is bumped: idle romanizer.warmup("japanese") so the first Japanese overlay does not pay dict parse on the click. Romanizer now requires warmup — src/lib/romanize-options.test.ts / src/test-setup.ts fakes must add a no-op.
4. pinyin-pro on the main thread is the wrong tool
src/lib/alphabet-index.ts needs A–Z of the first Han grapheme. Shipping the full pinyin dictionary in the main 1.5 MB chunk is disproportionate. Replace with a small first-letter table (or a tiny dedicated helper). After that, pinyin-pro lives only in the (split) worker Chinese chunk. Pin can drop from package.json dependencies once nothing static-imports it (lyric-romanizer already depends on it).
Acceptance criteria
-
vite.config.tssetsworker: { format: "es" }. - Production
romanize.worker-*.jsis the orchestrator only (low tens of KB, not ~1.1 MB) and contains real dynamicimport()s for engines. - First romanize of a Mandarin song does not load kuroshiro / to-jyutping / sanscript chunks (Network/coverage or a bundle analyzer snapshot).
- Pinned
SongLanguagegoes through oneromanizeLines(lines, options)call; tests updated. - Dict plugin uses
lyric-romanizer/dict;[@sglkc](/sglkc)/kuromojiremoved from OpenKaradevDependencies. -
pinyin-prois no longer a direct runtime import from the main-thread graph (alphabet-indexincluded). -
lyric-romanizerbumped to^0.3.1(or whatever version publishes these APIs). -
pnpm vitest run+pnpm buildgreen; worker still serves/dict/for Japanese.
Proposed order
worker.format: 'es'(no library bump needed; largest JS win).- Batch pinned
romanizeLines+ test updates. - Bump
lyric-romanizer→ switch dict plugin to./dict; add no-opwarmupon fakes; optional idlewarmup('japanese'). - Remove main-thread
pinyin-profrom alphabet index. - Drop or isolate the main-thread romanizer fallback so its engine chunks are not always on disk.
Out of scope (do not mix in)
- Rust Japanese engine via
createRomanizer({ engines: { japanese } }). That is the only way to get the 17 MB dict out of the webview. Real, but a separate issue (lindera + kana→romaji, inject through the existing ADR-0003 seam). Do not start that here. - Changing
romanizeLineswhole-array pin (library ADR-0002; Han block still cannot tell kanji-only lines from hanzi). - External scripts (Arabic, Hebrew, …). OpenKara has no
SongLanguagefor them; swallowingUnsupportedRomanizationError/ returning the original line is fine. - Engine subpath exports on the library (
lyric-romanizer/engines/chinese). Not needed once the worker actually code-splits.
Files
| File | Change |
|---|---|
vite.config.ts | worker.format: 'es'; dict plugin → lyric-romanizer/dict |
src/workers/romanize.worker.ts | keep dynamic import("lyric-romanizer"); optional warmup |
src/lib/lyrics-romanizer.ts | same; consider dropping main-thread engine fallback |
src/lib/romanize-options.ts | one batched call when language is pinned |
src/lib/romanize-options.test.ts | expect one call, not N |
src/lib/alphabet-index.ts | drop pinyin-pro |
src/test-setup.ts, src/lib/lyrics-romanizer.test.ts | add warmup no-op after 0.3.1 |
package.json / pnpm-workspace.yaml catalog | bump lyric-romanizer; remove [@sglkc](/sglkc)/kuromoji devDep and direct pinyin-pro if unused |
Additional context
- Library ADRs that constrain this work: 0001 outputs are a contract, 0002 whole-array pin, 0003 injectable engines / zero-API default, 0006 dict is a third Node-only packaging seam (0.3.1).
- OpenKara already does the right first-order things: detector subpath for
isLatinScript, dynamic import of the heavy entry, worker off the UI thread, local/dict/instead of jsDelivr. createRomanizer({ engines })andrequiresExternalRomanizationstay unused; that is OK until a Rust Japanese engine or an external-script adapter exists.- Do not import
lyric-romanizer/dictfrom the worker or any browser module. It usesnode:module.