A mobile-first trainer for acquiring a writing system (not a whole language), starting with Japanese kana. It pairs two practice loops the spec treats as equally first-class:
- Adaptive review — a scheduler surfaces the memories going weak.
- Freestyle drill — a deliberately repeatable run over any set (whole syllabary, one script, one row, pairs…), regardless of due dates.
This repo covers the gojūon, dakuten/handakuten, and all small kana: 79 sounds across both hiragana and katakana (あ…ん, が…ぽ, ゃゅょ, ぁぃぅぇぉ / ア…ン, ガ…ポ, ャュョ, ァィゥェォ) — glyphs, real KanjiVG stroke data, and neural-TTS audio in two rotating voices. On top of that sits a katakana loanword layer: 75 small, recognisable gairaigo (コーヒー, タクシー, ミルク, テレビ, パン, バス, サッカー, ジュース, ロボット, ファイル, カフェ, パーティー…) — the payoff moment where the glyphs resolve into an everyday English word.
Project spec: projects:script-trainer on Outside Brain (Rikk's).
| Area | Status |
|---|---|
| Content pack (79 sounds × 2 scripts, paired forms) | ✅ real KanjiVG stroke data |
| Recognition prompt (see glyph → type romaji, variants accepted) | ✅ |
| Audio prompt (hear sound → pick glyph, with distractors) | ✅ VOICEVOX neural TTS, 2 voices rotated |
| Writing canvas (guide, tracing, stroke-order assessment) | ✅ geometric heuristic |
| Writing guidance fades as a character's own writing memory matures | ✅ full → outline → blank, from FSRS stability |
| Per-modality FSRS scheduler + graded reviews (Again/Hard/Good/Easy) + export/import | ✅ |
| Freestyle drill: set / gojūon row / weakest-N / modality / order / repeat laps | ✅ |
| Katakana loanwords (95 words: glyph→meaning, listen→glyph) | ✅ scheduled alongside kana |
| Confusable-pair drills (シ・ツ, ソ・ン, ね・れ・わ, …) | ✅ 7 curated groups; distractors prefer the real look-alike |
"Hear it" playback on the answer screen (any prompt type, or P) |
✅ |
| Immediate "practise missed" pass | ✅ |
| Offline-capable installable PWA | ✅ service worker + manifest |
| Build stamp + in-app update check | ✅ commit and build time on the home screen |
| Flag a bad audio clip in-session, hand the list off for regeneration | ✅ no server — local list, copy/export |
The three memories per character (read / listen / write) are scheduled separately — you can read あ fluently while still unable to write it, and the scheduler won't hide that.
The writing canvas uses that same per-modality signal for something else: how
much scaffolding to show. A fixed full stroke-order guide on every attempt
teaches you to trace, not to recall. guideTierFor() (src/engine/scheduler.ts)
turns the writing card's own FSRS stability into three tiers — full guide
while new or recently shaky, outline (shape but no stroke numbers) once
you've written it correctly at least once, blank canvas once it's
genuinely consolidated (≥14 days' stability) — with a manual "Show guide"
always available if you get stuck. No extra tracking: a lapse already lowers
stability through the normal FSRS math, so guidance falls back on its own.
npm install
npm run build:pack # regenerate the kana content pack from vendored KanjiVG (already committed)
node scripts/build-words-pack.mjs # regenerate the loanword pack (already committed)
npm run dev # dev server
npm run build # typecheck + production build (dist/) + PWA service worker
npm run preview # serve the production build
npm test # unit tests (scheduler, stroke matching, pack integrity)Regenerating audio needs a local VOICEVOX engine (docker run -p 50021:50021 voicevox/voicevox_engine:cpu-ubuntu22.04-latest), then:
node scripts/gen-voicevox.mjs # kana audio, both voices
node scripts/gen-voicevox-words.mjs # word audio, both voicesTwo knobs on the kana generator matter for how the sounds are heard: --speed
(VOICEVOX's global speedScale, default 0.60 — lower is slower) and
--nasal-min (a floor on nasal consonant duration, default 0.10). The word
generator takes --speed too (default 0.85). See limitation 6.
Deploy under a GitHub Pages subpath with BASE_PATH=/script-trainer/ npm run build.
An offline-capable PWA will happily keep serving a cached shell, so the home
screen shows the short commit and build time (vite.config.ts stamps them in via
define), with a Check for updates button beside it. The worker is
registered by src/ui/update.ts rather than by the plugin's injected script, so
a new build can announce itself; it is checked hourly and whenever the app
returns to the foreground, which on a phone is the moment that matters. The
worker uses clientsClaim (so a fresh page is controlled immediately, and the
skip-waiting handshake has a client to hand over) with skipWaiting: false, so
an update lands when asked for rather than underneath a half-answered card.
VOICEVOX output isn't perfectly consistent — occasionally a clip comes out
glitchy, too quiet, or otherwise off. Tap 🔊 to hear a sound or word, then 🚩
next to it (in review, a drill, or the answer screen) if it sounded wrong;
"Flagged audio" on the home screen lists everything flagged, lets you re-listen,
add a short note, and copy or export the list — there's no server, so that
copy/export is the hand-off. Both generator scripts take that list back via
--only, scoped to a voice with --speakers, so a fix is a top-up over just
the flagged clips rather than a full 79-sound (or 95-word) regeneration:
node scripts/gen-voicevox.mjs --speakers 30 --only ma,ka # kana, one voice
node scripts/gen-voicevox-words.mjs --speakers 9 --only coffee # words, one voiceFlags are stored per (sound-or-word id, voice) — kana clips are per sound, shared between hiragana and katakana, since it's the same audio file either way. See limitation 7 for why this stays a manual, human-in-the-loop hand-off rather than something automated end-to-end (limitation 8).
The learning engine is separate from writing-system content packs, so a later, structurally different script (Arabic is the spec's architectural counterexample) can reuse it.
scripts/build-kana-pack.mjs # KanjiVG SVG → kana content pack (provenance-preserving)
scripts/build-words-pack.mjs # curated loanword list → word content pack
scripts/gen-voicevox.mjs # VOICEVOX → per-sound kana audio (2 voices)
scripts/gen-voicevox-words.mjs # VOICEVOX → per-word audio (2 voices)
vendor/kanjivg/*.svg # vendored source stroke data (CC BY-SA 3.0)
src/content/kana.json # generated kana pack (build artifact, committed)
src/content/words.json # generated word pack (build artifact, committed)
src/engine/
model.ts # script-agnostic types: Sound, Character, Word, Modality, Item
pack.ts # pack loading + lookups (pairs, distractors, confusable groups, word lookups)
scheduler.ts # FSRS spaced repetition — per-item difficulty/stability (unit-tested)
store.ts # localStorage progress + review log; export/import backup; Leitner→FSRS migration
session.ts # review queue (adaptive) + freestyle queue (deliberate) — kana and words alike
strokeMatch.ts # geometric handwriting assessment (pure) + SVG path sampler
audio.ts # audio playback (VOICEVOX clips for kana + words, SpeechSynthesis fallback)
src/ui/ # framework-free views: home, session runner, writing canvas, credits
src/ui/logo.ts # the app mark (あ, drawn from the same KanjiVG data)
src/fonts/ # Zen Kaku Gothic New + Zen Old Mincho, Latin subsets (OFL)
The interface borrows one object: the genkō yōshi practice cell, a ruled square with a faint dotted crosshair. It appears at four scales — the logo, the prompt glyph, the answer buttons, the writing canvas — so every screen is recognisably the same sheet of paper. Ink ground, paper actions: the primary button is washi-coloured rather than accent-coloured, which keeps vermilion (朱) free to mean exactly one thing, the correcting brush — a wrong answer, a stroke number, the first stroke of あ in the logo. Latin text is set in the Zen superfamily, drawn as companions to Japanese type; kana always use the reader's own system Japanese face.
Words are trainable Items exactly like kana (kind: 'word', refId: 'word:<id>'),
so they're scheduled by the same FSRS engine and show up in Review alongside
kana — no separate progress system. They only get recognition (glyph→meaning)
and audio (listen→glyph) modalities; nobody is asked to handwrite a whole
loanword.
-
Audio is VOICEVOX neural TTS (two voices rotated). Each sound plays a natural neural voice, alternating a male (青山龍星, ノーマル) and female (No.7, アナウンス) speaker per prompt;
src/content/audio/{ryusei,no7}/, provenance and terms incredits.json. No.7's announcer style is chosen deliberately — a reading-aloud register suits a trainer, and it avoids the breathy timbre that makes some voices unpleasant on repeated isolated syllables.SpeechSynthesisis only a fallback for a sound with no clip. Regenerate withscripts/gen-voicevox.mjs(run the engine:docker run -p 50021:50021 voicevox/voicevox_engine:cpu-ubuntu22.04-latest). Licence — read before any commercial use: both voices need aVOICEVOX:<character>credit if distributed publicly, and No.7 is non-commercial-only; commercial use needs prior approval from No.7製作委員会. 青山龍星 has no such restriction. SeeNOTICE. Everything audio-related is isolated behindsrc/engine/audio.ts. -
Stroke data is
verification: "unverified-import". Imported verbatim from KanjiVG (never hand-invented), but each of the 158 characters still wants a human check against a reputable teaching source before leaving MVP. -
Handwriting scoring is a beginner-tolerant heuristic (stroke count + per-stroke endpoints + direction). No shape/curvature model yet; strictness is an open design question.
-
PWA icon is a single SVG. Fine for install; store-grade raster icons are a follow-up.
-
Content is the gojūon + dakuten/handakuten + all small kana, plus 95 loanwords and 7 hand-curated confusable-pair groups. The kana syllabary itself is essentially complete; more loanwords have no real ceiling.
-
Isolated-mora synthesis needs care. Two distinct problems, both fixed:
Nasals were under-rendered. VOICEVOX predicts consonant durations for connected speech, so a lone mora comes back with a very short onset — ま at 24ms against ば's 10ms for the voice originally used. /m/ and /b/ are both voiced bilabials, so the nasal murmur is the only cue separating them, and a 14ms difference is no cue at all. Measured on the old clips, four of five of that voice's m-syllables carried less pre-vowel nasal energy than the matching b-syllable — "m" sounded more like "b" than "b" did.
--nasal-minfloors nasal consonants (default0.10), taking the murmur to ~95ms; stops keep VOICEVOX's own timing. The floor came from a duration sweep, as spectral separation proved too noisy to tune against — retune by ear with the flag.Duration must not come from stretching the vowel. The generator used to overwrite each mora's
vowel_lengthto ~0.4s. The model has never seen a held half-second mora, so this pushed the vocoder off-distribution and produced wavering, breathy, artefact-ridden vowels — audibly unpleasant and, in a female voice, uncomfortably so.--speed(speedScale) is the in-distribution control and slows the utterance cleanly. Note the ceiling here is linguistic, not technical: vowel length is phonemic in Japanese (おばさん vs おばあさん), so genuinely lengthening an isolated あ would teach a distinction that isn't there. If the clips still feel too brief, repeat the playback rather than stretch the sound. -
The sokuon (っ/ッ) isn't a taught Character, by design — like the chōonpu, it has no phoneme of its own (it doubles the following consonant), so it's whitelisted as a spelling marker rather than given a fake standalone lesson. See the pack-integrity test in
pack.test.ts. -
Flagged-clip regeneration is human-in-the-loop, not automated end-to-end, deliberately. The obvious next step — auto-regenerate a flagged clip and verify it with speech recognition before accepting it — was considered and set aside for now: ASR is a poor fit for what a flag actually reports. Whisper-class models are trained on connected speech, and a single isolated mora is exactly the input they're weakest on, so a flagged clip could easily transcribe as "correct" while still sounding glitchy, quiet, or breathy — the actual complaints. Those are signal-level properties (clipping, near-zero loudness, a click or mid-clip silence gap, level well below the rest of the pack), which is closer to what
scripts/gen-voicevox.mjs's nasal-floor tuning already measured by hand (see limitation 6) than to anything a transcript comparison would catch. A DSP quality gate — reusing that approach, formalised into an automated check the generator scripts run after synthesis — is a plausible follow-up; full ASR verification isn't, for isolated morae specifically. Would want more signal on how often regeneration actually fixes a flagged clip on the first try before building either.
Stroke-order vector data is from KanjiVG (© 2009–2011 Ulrich Apel),
distributed under CC BY-SA 3.0. See https://kanjivg.tagaini.net and
NOTICE. Per the licence, derivative stroke data here is shared alike.