Skip to content

Repository files navigation

Script Trainer

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).

What works today

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.

Run it

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 voices

Two 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.

Knowing which build you are running

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.

Flagging a bad recording, and fixing it

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 voice

Flags 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).

Architecture

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)

Look and feel

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.

Known limitations / next steps

  1. 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 in credits.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. SpeechSynthesis is only a fallback for a sound with no clip. Regenerate with scripts/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 a VOICEVOX:<character> credit if distributed publicly, and No.7 is non-commercial-only; commercial use needs prior approval from No.7製作委員会. 青山龍星 has no such restriction. See NOTICE. Everything audio-related is isolated behind src/engine/audio.ts.

  2. 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.

  3. Handwriting scoring is a beginner-tolerant heuristic (stroke count + per-stroke endpoints + direction). No shape/curvature model yet; strictness is an open design question.

  4. PWA icon is a single SVG. Fine for install; store-grade raster icons are a follow-up.

  5. 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.

  6. 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-min floors nasal consonants (default 0.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_length to ~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.

  7. 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.

  8. 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.

Attribution

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.

About

Mobile-first PWA for learning writing systems (Japanese kana first)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages