Home

dev / openkara

publicthedavidweng/OpenKara· sync paused
Code Branches Pull requestsIssuesInsights
main
Home Code PRsIssues

#396 perf(lyrics): stop inlining every romanizer engine and use lyric-romanizer 0.3.1 hooks

closed

Opened by dev · yesterday

devopened this issueAuthor· yesterday

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)

AssetRawgzipRole
dist/dict/* (kuromoji)17.0 MB17.0 MBJapanese morphological dict, copied at build
assets/romanize.worker-*.js1.11 MB537 KBWorker inlined every engine
assets/index-*.js (contains pinyin-pro)1.57 MB489 KBMain 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 MBMain-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 UnsupportedRomanizationError when the script has no engine — and every SongLanguage maps 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 from kuroshiro-analyzer-kuromoji)
  • src/kuromoji.js → ../dict layout
  • the twelve *.dat.gz names

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.ts sets worker: { format: "es" }.
  • Production romanize.worker-*.js is the orchestrator only (low tens of KB, not ~1.1 MB) and contains real dynamic import()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 SongLanguage goes through one romanizeLines(lines, options) call; tests updated.
  • Dict plugin uses lyric-romanizer/dict; [@sglkc](/sglkc)/kuromoji removed from OpenKara devDependencies.
  • pinyin-pro is no longer a direct runtime import from the main-thread graph (alphabet-index included).
  • lyric-romanizer bumped to ^0.3.1 (or whatever version publishes these APIs).
  • pnpm vitest run + pnpm build green; worker still serves /dict/ for Japanese.

Proposed order

  1. worker.format: 'es' (no library bump needed; largest JS win).
  2. Batch pinned romanizeLines + test updates.
  3. Bump lyric-romanizer → switch dict plugin to ./dict; add no-op warmup on fakes; optional idle warmup('japanese').
  4. Remove main-thread pinyin-pro from alphabet index.
  5. 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 romanizeLines whole-array pin (library ADR-0002; Han block still cannot tell kanji-only lines from hanzi).
  • External scripts (Arabic, Hebrew, …). OpenKara has no SongLanguage for them; swallowing UnsupportedRomanizationError / 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

FileChange
vite.config.tsworker.format: 'es'; dict plugin → lyric-romanizer/dict
src/workers/romanize.worker.tskeep dynamic import("lyric-romanizer"); optional warmup
src/lib/lyrics-romanizer.tssame; consider dropping main-thread engine fallback
src/lib/romanize-options.tsone batched call when language is pinned
src/lib/romanize-options.test.tsexpect one call, not N
src/lib/alphabet-index.tsdrop pinyin-pro
src/test-setup.ts, src/lib/lyrics-romanizer.test.tsadd warmup no-op after 0.3.1
package.json / pnpm-workspace.yaml catalogbump 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 }) and requiresExternalRomanization stay unused; that is OK until a Rust Japanese engine or an external-script adapter exists.
  • Do not import lyric-romanizer/dict from the worker or any browser module. It uses node:module.
devcommented· 16 hours ago

Verified against main (2026-08-13). The four root causes match the current tree:

  • vite.config.ts has no worker.format, so the production worker is IIFE and inlines every engine.
  • Pinned SongLanguage still calls romanizeLines once per line.
  • The dict plugin hard-codes [@sglkc](/sglkc)/kuromoji + the twelve .dat.gz names.
  • src/lib/alphabet-index.ts statically imports pinyin-pro on the main thread.

lyric-romanizer@0.3.1 is on thedavidweng/lyric-romanizer main (warmup, lyric-romanizer/dict) but is not on npm yet (registry latest is still 0.3.0; npm publish stops on OTP).

Implementation is in progress on perf/lyrics-romanizer-worker-split. Local production build against the 0.3.1 tree already shows the worker split:

  • romanize.worker-*.js 885 B (was 1.11 MB), with a real import() of the orchestrator
  • orchestrator then import()s each engine (pinyin / jyutping / kuroshiro / sanscript / …)
  • main index-*.js no longer contains pinyin-pro or lyric-romanizer
  • /dict/ still copied (12 kuromoji files)

Blocked on publishing lyric-romanizer@0.3.1 so the lockfile / Flatpak node-sources can pin the registry tarball instead of file:../lyric-romanizer.

Sign in to comment.

Linked pull requests

No linked pull requests yet.