feat(voice): bundled Vietnamese voice calls (master + player)

Speak the called number on master draw, "Chờ N" when a row is one
away, and "Kinh" on bingo. No runtime TTS API — clips are
pre-generated by `scripts/generate-audio.py` (free edge-tts) and
shipped as static MP3s under `static/audio/{voiceId}/`.

- src/lib/vietnamese-number.js + test (40 cases): tonal exceptions
  mười lăm / hai mươi mốt / hai mươi lăm
- src/lib/voice.js: lazy <audio> cache, token-based cancellation,
  cho+number sequencer, on-unmount cleanup
- src/lib/audio-manifest.js: re-exports static/audio/manifest.json
- scripts/generate-audio.py: discovers every vi-* edge-tts voice,
  writes 92 clips per voice + manifest.json
- static/audio/manifest.json: placeholder until user runs the script
- src/lib/settings-store: +voiceEnabledMaster/voiceEnabledPlayer/voice
  with per-key validators
- src/lib/SettingsButton: new "Âm thanh" fieldset (toggles + voice
  picker rendered from manifest)
- MasterPanel.handleDrawNext: playNumber(next) + cancel on new game
- PlayerBoard $effect: playWaiting/playBingo beside toast/popup;
  cancel on regenerate / clear

To materialize the MP3s on first install:
    pip install edge-tts
    python3 scripts/generate-audio.py

Tests: 98 pass (40 number + 31 settings + 27 game-logic).
This commit is contained in:
tiennm99 committed 2026-04-27 09:06:48 +07:00
1 parent fb0ef9f783
commit ad537ee4a6
23 files changed
+1578 -10

No files matched your search

+18 -2
View File
@@ -2,8 +2,9 @@
Bàn số của trò chơi "Lô tô" — SvelteKit app.
Two routes: `/` for players, `/master` for the host (quản trò) — calls
numbers, shows a tracking board, and has its own player card to play along.
Single page (`/`). The host enables "Chế độ quản trò" in settings to
reveal the master panel inline below their player card; called numbers
get spoken aloud in Vietnamese using bundled MP3 clips (no runtime TTS).
See `docs/` for architecture, code standards, and deployment.
@@ -36,3 +37,18 @@ npm run build:gh # /loto basePath, for tiennm99.github.io/loto manual expor
Static export to `build/`. Deployed to Cloudflare Pages from `main`
(set up via the CF dashboard — see `docs/deployment-guide.md`).
## Regenerating audio
Vietnamese voice clips live in `static/audio/{voiceId}/`. Generated
once with [edge-tts](https://github.com/rany2/edge-tts) (free, no API
key) — runtime never calls TTS. To regenerate (e.g., to add a new voice
that Microsoft ships, or to change wording):
```bash
pip install edge-tts
python3 scripts/generate-audio.py
```
The script auto-discovers every `vi-*` voice and writes a manifest the
app reads on next reload.
+11 -4
View File
@@ -12,7 +12,7 @@
| File | Purpose |
|------|---------|
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid rendered as 3 stacked 3×9 mini-cards: Tân Tân / An khang thịnh vượng / Tân Tân tốt nhất). Tall (3:5 on mobile; wider on sm+) cells with condensed bold black numbers (`tan-tan-num` font stack), white number cells, purple empty cells by default. Handles crossed state, bingo popup, "Chờ X" toast. Two header actions: "Tạo bảng mới" (regenerate grid) and "Xoá đánh dấu" (clear all marks, keep grid — only shown when a grid exists). Accepts `storagePrefix` prop for multi-card isolation. Empty cells use `--empty-cell-bg` CSS var from settings store. |
| `src/lib/SettingsButton.svelte` | Gear icon + modal. 4 fieldsets: Giao diện (theme auto/light/dark), Chế độ quản trò (master mode toggle), Tự động xổ (auto-call + speed 1–10s), Màu ô trống (10 Excel color swatches). Reset-to-default button. Mounted on `/`. |
| `src/lib/SettingsButton.svelte` | Gear icon + modal. 5 fieldsets: Giao diện (theme auto/light/dark), Chế độ quản trò (master mode toggle), Tự động xổ (auto-call + speed 1–10s), Âm thanh (voice toggles for master + player + voice picker from manifest), Màu ô trống (10 Excel color swatches). Reset-to-default button. Mounted on `/`. |
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, "Số vừa xổ" hero token, "Thứ tự đã xổ" history list, 11×9 last-digit-aligned tracking grid (with circular tokens + draw-order overlay). No host's own player card (the player already has one above). "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.masterMode === true`. |
| `src/lib/PageFooter.svelte` | Footer with tagline ("Made by miti99 with ❤️ SVG icon") + link. Mounted on `/`. |
@@ -20,7 +20,10 @@
| File | Purpose |
|------|---------|
| `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns, soft "no 3 consecutive filled cols per row" via rejection sampling), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
| `src/lib/settings-store.svelte.js` | Reactive global UI settings via Svelte 5 runes. Stores 5 keys: `theme` (enum: "auto" / "light" / "dark"), `masterMode` (bool), `autoCallEnabled` (bool), `autoCallSpeed` (1–10), `emptyCellColor` (hex). Persisted to localStorage `loto_settings`. Pushes values to CSS vars and `<html class="dark">` on `:root`. Per-key validators preserve old data. |
| `src/lib/vietnamese-number.js` | `numberToVietnamese(n)` — pure utility mapping 0..90 to spoken Vietnamese, with tonal exceptions (15 → "mười lăm", 21 → "hai mươi mốt", 25 → "hai mươi lăm"). Out-of-range falls back to `String(n)`. |
| `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `<audio>` cache, cancel-then-play, token-based cancel ensures stale promises can't resume after a new event. Reads active voice from `settings.voice`; URLs go through `import { base } from "$app/paths"` for basePath safety. |
| `src/lib/audio-manifest.js` | Re-exports `static/audio/manifest.json` as `VOICES` (array) + `VOICE_IDS` (Set) + `DEFAULT_VOICE`. Manifest is generated by `scripts/generate-audio.py`. |
| `src/lib/settings-store.svelte.js` | Reactive global UI settings via Svelte 5 runes. Stores 8 keys: `theme` (enum: "auto" / "light" / "dark"), `masterMode` (bool), `autoCallEnabled` (bool), `autoCallSpeed` (1–10), `emptyCellColor` (hex), `voiceEnabledMaster` (bool), `voiceEnabledPlayer` (bool), `voice` (string id matching audio manifest). Persisted to localStorage `loto_settings`. Pushes values to CSS vars and `<html class="dark">` on `:root`. Per-key validators preserve old data. |
### Styling
| File | Purpose |
@@ -30,8 +33,9 @@
### Tests
| File | Purpose |
|------|---------|
| `src/lib/game-logic.test.js` | 26 unit tests: generateGrid shape (9×9, 5 per row/col, no duplicates), column ranges & ascending sort, row completion, waiting number detection, persistence (saveGrid/loadGrid/saveCrossedState/loadCrossedState with validators). |
| `src/lib/settings-store.test.js` | 27 unit tests: defaults, loadSettings (restore 5 keys from localStorage, apply CSS vars, toggle dark class, handle empty/corrupt), saveSettings, resetSettings, theme toggle (auto → OS pref detection), master mode, auto-call + speed, color validation. |
| `src/lib/game-logic.test.js` | 27 unit tests: generateGrid shape (9×9, 5 per row/col, no duplicates), column ranges & ascending sort, no-3-consecutive soft constraint, row completion, waiting number detection, persistence (saveGrid/loadGrid/saveCrossedState/loadCrossedState with validators). |
| `src/lib/settings-store.test.js` | 31 unit tests: defaults (incl. voice keys), loadSettings (restore 8 keys, apply CSS vars, toggle dark class, handle empty/corrupt), saveSettings, resetSettings, theme toggle (auto → OS pref detection), master mode, auto-call + speed, color validation, voice round-trip + invalid-id fallback. |
| `src/lib/vietnamese-number.test.js` | 40 unit tests: ones (0–9), teens (10–19 incl. mười lăm), 20–90 incl. mốt and lăm exceptions, out-of-range fall-through. |
### Configuration
| File | Purpose |
@@ -44,6 +48,9 @@
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
| `.env.example` | codeserver profile vars (CODESERVER_HOST, CODESERVER_PORT). |
| `static/_redirects` | Cloudflare Pages: `/* / 301` — every unknown path 301-redirects to homepage. Static assets are served first so this only fires on misses. |
| `static/audio/{voiceId}/*.mp3` | Pre-generated Vietnamese voice clips (1–90 + cho + kinh per voice). Generated once by `scripts/generate-audio.py` (edge-tts). Runtime never calls TTS. |
| `static/audio/manifest.json` | Voice list `{ id, edgeName, label, gender }[]`. Written by the generator, imported by `audio-manifest.js`. |
| `scripts/generate-audio.py` | One-shot Python script: discovers every `vi-*` edge-tts voice, generates 92 clips per voice, writes manifest. Run after voice/wording changes. |
## Key Data Structures
-3
View File
@@ -6,9 +6,6 @@ now", see `docs/project-overview-pdr.md` and `docs/codebase-summary.md`.
## Idea Phase
### Sound Effects on Bingo
Play celebratory chime or "Kinh!" voice snippet when row completes. Could use Web Audio API or `<audio>` tag. **Status**: Idea (no demand yet)
### Undo Last Cell
Allow player to undo the most recent cross/uncross action. Requires change history or state snapshot. **Status**: Idea (low priority)
+7 -1
View File
@@ -35,10 +35,12 @@ That format is intentionally out of scope.
a master board with circular tokens (pink ring for 1–49, green for 50–90,
cream fill on called, dim on uncalled, red ring + scale on the most
recent draw), and may also play their own card.
- **Settings**: gear icon in headers opens a modal with 4 fieldsets:
- **Settings**: gear icon in headers opens a modal with 5 fieldsets:
- Giao diện: theme switcher (auto / light / dark; auto mirrors OS pref)
- Chế độ quản trò: toggle to show MasterPanel on `/`
- Tự động xổ: enable auto-call; speed slider (1–10 seconds)
- Âm thanh: voice toggles for master + player + Vietnamese voice picker
(every `vi-*` edge-tts voice; default Hoài Mỹ)
- Màu ô trống: color picker + 10 Excel preset swatches
Persisted to `loto_settings`.
@@ -70,6 +72,7 @@ That format is intentionally out of scope.
- **Runtime**: Svelte 5 runes ($state, $derived, $effect, $props)
- **Styling**: Tailwind CSS 4 (utility-first, animations)
- **Persistence**: localStorage (no backend)
- **Audio**: pre-generated MP3 clips bundled under `static/audio/{voiceId}/`. Built once by `scripts/generate-audio.py` using free `edge-tts` (Microsoft Neural). Runtime plays via plain HTML5 Audio — no TTS API at runtime.
- **Deploy**: Cloudflare Pages (root domain), GitHub Pages fallback (`/loto`)
- **Dev Profile**: code-server compatible via `/absproxy/{port}` basePath + HMR proxy config
@@ -94,6 +97,9 @@ State is entirely client-side. Each card / panel instance uses a unique localSto
- [x] Player can click cells to toggle crossed state.
- [x] Player can clear all marks on the current card without regenerating it (confirm prompt when marks exist).
- [x] No row has 3 consecutive filled columns (soft constraint, rejection-sampled).
- [x] Master speaks the called number aloud in Vietnamese (bundled MP3, no runtime TTS API).
- [x] Player hears "Chờ N" when a row is one away and "Kinh" on bingo.
- [x] Voice picker in settings — switch among every Vietnamese edge-tts voice.
- [x] Bingo popup triggers when row is complete, shows row number and "Kinh!" message.
- [x] Player may keep marking after a Kinh — no game-end lock.
- [x] Toast notifications show "Chờ X" before bingo (one number remaining).
@@ -0,0 +1,92 @@
# Phase 1 — Vietnamese number words utility
## Context
- [plan.md](plan.md). No dependencies; self-contained pure module.
## Overview
- Priority: P0 (everything else depends on it)
- Status: TODO
- Effort: ~15 min
## Goal
Pure function `numberToVietnamese(n)` mapping `1..90` to spoken Vietnamese,
honoring the tonal exceptions that show up in lô tô numbers.
## Tonal rules (the parts that matter)
| Position | Rule | Example |
|---|---|---|
| Standalone unit | "không" / "một" / ... / "chín" | `5 = năm` |
| 10 alone | "mười" | `10 = mười` |
| 11–19, units 1–9 | "mười X" | `12 = mười hai` |
| 11–19, unit = 5 | **"mười lăm"** (not "năm") | `15 = mười lăm` |
| 20–90 tens place | "X mươi" (not "mười") | `30 = ba mươi` |
| 21+ unit = 1 | **"…mốt"** (not "một") | `21 = hai mươi mốt` |
| 21+ unit = 5 | **"…lăm"** (not "năm") | `25 = hai mươi lăm` |
| Tens 0 | drop unit | `40 = bốn mươi` |
(`tư` for 4 in unit position is regional — we stay with `bốn` for
consistency.)
## Files
| File | Change |
|---|---|
| `src/lib/vietnamese-number.js` | NEW — exports `numberToVietnamese(n)` |
| `src/lib/vietnamese-number.test.js` | NEW — covers edge cases below |
## Implementation sketch
```js
const ONES = [
"không", "một", "hai", "ba", "bốn",
"năm", "sáu", "bảy", "tám", "chín",
];
/** @param {number} n integer 0..90 */
export function numberToVietnamese(n) {
if (!Number.isInteger(n) || n < 0 || n > 90) return String(n);
if (n < 10) return ONES[n];
if (n === 10) return "mười";
if (n < 20) {
const u = n - 10;
return u === 5 ? "mười lăm" : `mười ${ONES[u]}`;
}
const t = Math.floor(n / 10);
const u = n % 10;
const tens = `${ONES[t]} mươi`;
if (u === 0) return tens;
if (u === 1) return `${tens} mốt`;
if (u === 5) return `${tens} lăm`;
return `${tens} ${ONES[u]}`;
}
```
## Tests (must cover)
| n | Expected |
|---|---|
| 1 | "một" |
| 5 | "năm" |
| 10 | "mười" |
| 11 | "mười một" |
| 15 | "mười lăm" |
| 19 | "mười chín" |
| 20 | "hai mươi" |
| 21 | "hai mươi mốt" |
| 25 | "hai mươi lăm" |
| 45 | "bốn mươi lăm" |
| 81 | "tám mươi mốt" |
| 90 | "chín mươi" |
Plus: out-of-range fall-through (`91 → "91"`, `0.5 → "0.5"`, `-1 → "-1"`).
## Success criteria
- All ~12 table cases pass.
- `numberToVietnamese` is pure (no side effects, idempotent, no DOM).
- Test file slots into existing Vitest setup with no config changes.
## Next
- Phase 2 imports it from the voice module.
@@ -0,0 +1,204 @@
# Phase 2 — Audio generation script (build-time, all Vietnamese voices)
## Context
- [plan.md](plan.md). Output: per-voice MP3 sets committed under
`static/audio/{voiceId}/`, ready for Phase 3's playback module.
## Overview
- Priority: P0
- Status: TODO
- Effort: ~30 min (incl. ~5-10 min generation runtime)
- Run frequency: once per voice change (rare).
## What gets generated
```
static/audio/
├── hoai-my/ (vi-VN-HoaiMyNeural — female)
│ ├── 1.mp3 "một"
│ ├── 2.mp3 "hai"
│ ├── ... (90)
│ ├── 90.mp3 "chín mươi"
│ ├── cho.mp3 "Chờ"
│ └── kinh.mp3 "Kinh"
├── nam-minh/ (vi-VN-NamMinhNeural — male)
│ ├── 1.mp3
│ ├── ... (same 92 names)
│ └── kinh.mp3
└── manifest.json (voice list, generated)
```
92 clips × N voices. As of writing, edge-tts ships 2 Vietnamese voices
(female + male) — total ~184 files, ~2.2 MB. Script auto-discovers
voices via `edge-tts.list_voices()`, so new voices added by Microsoft
get picked up on the next regen.
## Voice ID mapping
| edge-tts voice | Folder ID | Display label |
|---|---|---|
| `vi-VN-HoaiMyNeural` | `hoai-my` | "Hoài Mỹ (nữ)" |
| `vi-VN-NamMinhNeural` | `nam-minh` | "Nam Minh (nam)" |
| (future `vi-VN-XxxNeural`) | `xxx` | "Xxx" — script slug-ifies |
Folder ID = lowercase + kebab-case + drop locale prefix + drop
"Neural" suffix. Conversion is deterministic; script writes the
manifest so the JS side never has to mirror Python.
## Tooling
| Tool | Why |
|---|---|
| Python 3 | already on dev machine |
| `edge-tts` (`pip install edge-tts`) | free, no API key, MS Neural quality |
User installs `edge-tts` once on the dev machine; project doesn't
add Python deps to `package.json` (build-time only).
## File: `scripts/generate-audio.py`
```python
#!/usr/bin/env python3
"""Generate Vietnamese audio clips (1-90 + Chờ + Kinh) for every
edge-tts Vietnamese voice. Output committed to static/audio/{voiceId}/
and shipped with the app — runtime never calls TTS."""
import asyncio, json, os, re, sys
OUT_ROOT = os.path.join(os.path.dirname(__file__), "..", "static", "audio")
ONES = ["không", "một", "hai", "ba", "bốn",
"năm", "sáu", "bảy", "tám", "chín"]
def number_to_vietnamese(n: int) -> str:
if n < 10: return ONES[n]
if n == 10: return "mười"
if n < 20:
u = n - 10
return "mười lăm" if u == 5 else f"mười {ONES[u]}"
t, u = divmod(n, 10)
tens = f"{ONES[t]} mươi"
if u == 0: return tens
if u == 1: return f"{tens} mốt"
if u == 5: return f"{tens} lăm"
return f"{tens} {ONES[u]}"
def voice_id(short_name: str) -> str:
"""vi-VN-HoaiMyNeural -> hoai-my"""
name = short_name.split("-")[-1] # HoaiMyNeural
name = re.sub(r"Neural$", "", name) # HoaiMy
name = re.sub(r"(?<!^)(?=[A-Z])", "-", name) # Hoai-My
return name.lower() # hoai-my
async def synth(text: str, voice: str, out: str):
import edge_tts
await edge_tts.Communicate(text, voice).save(out)
print(f" {out} ← \"{text}\"")
async def main():
import edge_tts
all_voices = await edge_tts.list_voices()
vi_voices = [v for v in all_voices if v["Locale"].startswith("vi-")]
if not vi_voices:
sys.exit("No Vietnamese voices found in edge-tts.")
manifest = {"voices": []}
for v in vi_voices:
vid = voice_id(v["ShortName"])
out_dir = os.path.join(OUT_ROOT, vid)
os.makedirs(out_dir, exist_ok=True)
print(f"\n→ {v['ShortName']} → static/audio/{vid}/")
tasks = []
for n in range(1, 91):
tasks.append(synth(number_to_vietnamese(n),
v["ShortName"],
os.path.join(out_dir, f"{n}.mp3")))
tasks.append(synth("Chờ", v["ShortName"], os.path.join(out_dir, "cho.mp3")))
tasks.append(synth("Kinh", v["ShortName"], os.path.join(out_dir, "kinh.mp3")))
await asyncio.gather(*tasks)
# Display label: gender + given name
gender_vi = "nữ" if v["Gender"].lower() == "female" else "nam"
given = re.sub(r"(?<!^)(?=[A-Z])", " ", re.sub(r"Neural$", "",
v["ShortName"].split("-")[-1])).strip()
manifest["voices"].append({
"id": vid,
"edgeName": v["ShortName"],
"label": f"{given} ({gender_vi})",
"gender": v["Gender"].lower(),
})
with open(os.path.join(OUT_ROOT, "manifest.json"), "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
print(f"\nWrote manifest with {len(manifest['voices'])} voice(s).")
if __name__ == "__main__":
try:
asyncio.run(main())
except ImportError:
sys.exit("Install dep first: pip install edge-tts")
```
## How to run
```bash
pip install edge-tts # one-time, dev-machine only
python3 scripts/generate-audio.py
git add static/audio/
git commit -m "chore(audio): regenerate Vietnamese clips"
```
## manifest.json — the JS side reads this
```json
{
"voices": [
{ "id": "hoai-my", "edgeName": "vi-VN-HoaiMyNeural", "label": "Hoai My (nữ)", "gender": "female" },
{ "id": "nam-minh", "edgeName": "vi-VN-NamMinhNeural", "label": "Nam Minh (nam)", "gender": "male" }
]
}
```
Phase 4's settings UI fetches `manifest.json` once (or imports it via
Vite's `?json` if we want it baked into the bundle) to populate the
voice picker. Adding a new voice = re-run the script, ship the new
folder + manifest, no code change required.
## Repository policy / README addendum
```md
### Regenerating audio
Vietnamese voice clips live in `static/audio/{voiceId}/`. To regenerate
(e.g., to add a new edge-tts voice or change wording):
pip install edge-tts
python3 scripts/generate-audio.py
```
## Edge cases
| Case | Handling |
|---|---|
| Network fails mid-generation | `asyncio.gather` raises; rerun (idempotent) |
| edge-tts upstream removes a voice | Committed MP3s still play; only regen blocks. Manifest stays accurate to what's on disk. |
| New Vietnamese voice arrives | Auto-included in next regen; Phase 4 UI picks it up via manifest |
| Voice name collision after slug-ify | Defensive: script aborts with message if two `id`s collide |
## Success criteria
- `python3 scripts/generate-audio.py` writes 92 MP3s per Vietnamese
voice into `static/audio/{voiceId}/` plus `manifest.json`.
- Each `45.mp3` says "bốn mươi lăm" in the corresponding voice.
- `cho.mp3` + `45.mp3` in sequence sounds like "Chờ bốn mươi lăm".
- Repo grows by ~2.2 MB (acceptable).
## Risks
| Risk | Mitigation |
|---|---|
| edge-tts endpoint changes break regen later | MP3s already committed; runtime unaffected |
| Manifest drifts from disk (manual edit) | Script always rewrites manifest; treat as generated artifact |
| Bandwidth — 184 files lazy-loaded | Browser caches; player only ever uses one voice's 92 files at a time |
## Next
- Phase 3 reads `manifest.json` + active-voice setting to build URLs.
@@ -0,0 +1,171 @@
# Phase 3 — Audio playback module
## Context
- [plan.md](plan.md). Depends on Phase 2 (committed MP3s).
- Single module both Master and Player import from.
## Overview
- Priority: P0
- Status: TODO
- Effort: ~25 min
## Goal
Three exports both call sites use:
```
playNumber(n) // master: plays {voice}/{n}.mp3
playWaiting(n) // player: plays {voice}/cho.mp3 then {voice}/{n}.mp3
playBingo() // player: plays {voice}/kinh.mp3
cancelPlayback() // pauses any in-flight audio (used on new game / new card)
```
Active voice resolved at call time from `settings.voice`. URLs go
through `import { base } from "$app/paths"` for basePath safety.
## Files
| File | Change |
|---|---|
| `src/lib/voice.js` | NEW — playback functions, `<audio>` cache, sequencer |
## Implementation sketch
```js
import { base } from "$app/paths";
import { settings } from "$lib/settings-store.svelte.js";
/** @type {Map<string, HTMLAudioElement>} */
const cache = new Map();
/** @type {HTMLAudioElement | null} */
let activePrimary = null; // currently-playing or chained chain leader
/** @param {string} url */
function getAudio(url) {
let a = cache.get(url);
if (!a) {
a = new Audio(url);
a.preload = "auto";
cache.set(url, a);
}
return a;
}
function clipUrl(name) {
return `${base}/audio/${settings.voice}/${name}.mp3`;
}
export function cancelPlayback() {
if (activePrimary) {
activePrimary.onended = null;
activePrimary.pause();
activePrimary.currentTime = 0;
activePrimary = null;
}
}
/**
* Play a single clip; resolves when it ends (or errors / is canceled).
* @param {string} url
*/
function playClip(url) {
return new Promise((resolve) => {
const a = getAudio(url);
a.currentTime = 0;
activePrimary = a;
const done = () => {
a.onended = null;
a.onerror = null;
if (activePrimary === a) activePrimary = null;
resolve();
};
a.onended = done;
a.onerror = done;
a.play().catch(done); // autoplay blocked, etc.
});
}
/** @param {number} n */
export function playNumber(n) {
cancelPlayback();
void playClip(clipUrl(String(n)));
}
/** @param {number} n */
export function playWaiting(n) {
cancelPlayback();
// Sequence: cho → number. Re-check cancelPlayback between clips so
// a fast user click can interrupt mid-sequence.
(async () => {
const cho = getAudio(clipUrl("cho"));
const num = getAudio(clipUrl(String(n)));
activePrimary = cho;
await playClip(clipUrl("cho"));
if (activePrimary !== null && activePrimary !== num) return; // canceled
await playClip(clipUrl(String(n)));
})();
}
export function playBingo() {
cancelPlayback();
void playClip(clipUrl("kinh"));
}
```
## Manifest loading
Phase 4 imports `manifest.json` directly via Vite's `?json` query so
it lands in the JS bundle (no fetch needed):
```js
// In settings-store.svelte.js or SettingsButton.svelte
import manifest from "../../static/audio/manifest.json";
```
If `static/audio/manifest.json` doesn't exist yet (script not run on
fresh checkout), import fails at build time. Phase 7 mentions this in
the README.
Alternative if static-import is awkward: copy the manifest into `src/lib/`
during the script run. Defer that micro-optimization.
## Edge cases
| Case | Behavior |
|---|---|
| Voice clip 404 (e.g., user upgraded but didn't pull MP3s) | `audio.onerror` fires, promise resolves silently — game keeps working |
| User changes voice mid-game | Next call uses new URL; cache holds both voices' clips. Memory cost: each Audio node ~few KB; acceptable. |
| Rapid manual draws or auto-call at 1s | Each call hits `cancelPlayback` first; only the latest plays |
| iOS Safari autoplay block | All call sites trigger from a click handler (Tạo bảng / Xổ số / cell click) — autoplay policy satisfied |
| `playWaiting` interrupted mid-sequence | Activity flag check between clips bails out; prevents stale tail playing on top of a newer event |
| Browser tab backgrounded | Browser pauses Audio elements automatically; nothing for us to do |
## Tests
Skip unit tests for this module — it's a thin wrapper around the
DOM Audio element. Phase 1 covers the only branchy logic
(`numberToVietnamese`). Manual smoke test in browser is the validation.
If we ever want to test it: stub `Audio` with a mock that records
`play()` / `pause()` calls. Defer until there's a real bug to chase.
## Success criteria
- `import { playNumber, playWaiting, playBingo } from "$lib/voice.js"`
works in components.
- `playNumber(45)` plays the chosen voice's "bốn mươi lăm" clip.
- `playWaiting(45)` plays "Chờ" then "bốn mươi lăm" with no audible
gap > 200 ms.
- Rapid calls cancel prior playback (no overlap, no backlog).
- Module no-ops cleanly if a clip is missing (logs only).
## Risks
| Risk | Mitigation |
|---|---|
| Cache grows unbounded across voices | At most 92 × N voices ≈ 184 nodes after full warmup. Bounded; acceptable. |
| `base` not resolved at module-init time on SSR | We're SSR-disabled (`ssr: false` in `+layout.js`); `base` is always set when these functions run |
## Next
- Phase 4 wires settings + voice picker.
@@ -0,0 +1,139 @@
# Phase 4 — Settings: 3 keys + voice picker UI
## Context
- [plan.md](plan.md). Adds 3 keys to `settings-store` and a new
"Âm thanh" fieldset with toggles + a voice list.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~25 min
## New settings keys
| Key | Type | Default | Effect |
|---|---|---|---|
| `voiceEnabledMaster` | boolean | `true` | Speak called number when master draws |
| `voiceEnabledPlayer` | boolean | `true` | Speak "Chờ N" / "Kinh" on player events |
| `voice` | string (voiceId) | first manifest entry | Which voice to play |
All persisted under existing `loto_settings` blob, same merge-and-validate
pattern as the other keys.
## Manifest source
```js
// In settings-store.svelte.js (or a small lib/audio-manifest.js):
import manifest from "../../static/audio/manifest.json";
export const VOICES = manifest.voices;
// e.g. [{ id: "hoai-my", edgeName: "...", label: "Hoai My (nữ)", gender: "female" }, ...]
```
Vite imports JSON natively — manifest is part of the JS bundle, no
fetch needed at runtime.
## Files
| File | Change |
|---|---|
| `src/lib/audio-manifest.js` (NEW, optional) | Re-exports manifest as `VOICES` so settings-store and SettingsButton share the source |
| `src/lib/settings-store.svelte.js` | Add 3 keys to `DEFAULTS`, validate in `loadSettings` (boolean for two, string-in-VOICES-ids for `voice`) |
| `src/lib/settings-store.test.js` | 4-5 tests: defaults, persistence round-trip, corruption fallback, invalid voiceId fallback |
| `src/lib/SettingsButton.svelte` | NEW "Âm thanh" fieldset: 2 toggles + voice radio/pill list |
## settings-store.svelte.js — pattern
```js
import { VOICES } from "$lib/audio-manifest.js";
const VOICE_IDS = new Set(VOICES.map((v) => v.id));
const DEFAULT_VOICE = VOICES[0]?.id ?? "hoai-my"; // graceful fallback
const DEFAULTS = {
// ...existing 5 keys...
voiceEnabledMaster: true,
voiceEnabledPlayer: true,
voice: DEFAULT_VOICE,
};
// In loadSettings(), per-key validate:
// voiceEnabledMaster: typeof v === "boolean" → keep, else default
// voiceEnabledPlayer: typeof v === "boolean" → keep, else default
// voice: VOICE_IDS.has(v) → keep, else default
```
No CSS variables, no `<html>` class — pure values read by other modules.
## SettingsButton.svelte — UI fieldset
Place between "Tự động xổ" and "Màu ô trống" (logical grouping with
feedback features).
```svelte
<fieldset class="mb-5">
<legend class="text-sm font-semibold ...">Âm thanh</legend>
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
Đọc số bằng tiếng Việt
</p>
<button onclick={() => toggleVoiceMaster()} class="...pill...">
Quản trò: {settings.voiceEnabledMaster ? "Bật" : "Tắt"}
</button>
<button onclick={() => toggleVoicePlayer()} class="...pill mt-2...">
Người chơi (Chờ / Kinh): {settings.voiceEnabledPlayer ? "Bật" : "Tắt"}
</button>
<div class="mt-3">
<p class="text-xs text-slate-500 dark:text-slate-400 mb-1">Giọng đọc</p>
<div class="grid grid-cols-2 gap-2">
{#each VOICES as v}
<button
onclick={() => saveSettings({ voice: v.id })}
class="px-3 py-2 rounded-lg border-2 text-sm
{settings.voice === v.id
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30'
: 'border-slate-300 dark:border-slate-600'}"
>
{v.label}
</button>
{/each}
</div>
</div>
</fieldset>
```
Optional polish: tiny "▶" preview button next to each voice that calls
`playNumber(45)` so users can hear before committing. Skip in v1; add
if asked.
## Tests
In `settings-store.test.js`:
1. Defaults: `voiceEnabledMaster === true`, `voiceEnabledPlayer === true`,
`voice === DEFAULT_VOICE` (first manifest entry).
2. Round-trip: `saveSettings({ voice: "nam-minh" })` persists and reloads.
3. Corrupt boolean → falls back to default for that key, others survive.
4. Invalid voice id (`"made-up"`) → falls back to default voice.
5. Empty manifest case (theoretical — if somehow no voices) → default voice
string still set without crashing other settings.
## Success criteria
- Toggling either pill flips the boolean immediately (rune-reactive).
- Picking a voice updates `settings.voice` and next call uses new clips.
- Page reload preserves all 3 settings.
- Vitest still green; settings-store tests grow by 4-5.
## Risks
| Risk | Mitigation |
|---|---|
| Manifest absent on fresh checkout (Phase 2 not run) | Build fails fast at import — README tells contributors to run the script first |
| Manifest renamed a voice id; saved id no longer valid | `loadSettings` falls back to default voice (covered by test #4) |
| User picks a voice but its clips are missing on disk | Phase 3's `audio.onerror` keeps app functional; UI still works (silent) |
## Next
- Phase 5 reads `voiceEnabledMaster` to gate master speech.
- Phase 6 reads `voiceEnabledPlayer` to gate player speech.
@@ -0,0 +1,76 @@
# Phase 5 — Master: play called number
## Context
- [plan.md](plan.md). Depends on Phases 2–4.
- Single hook point: `handleDrawNext()` in `MasterPanel.svelte:119`.
Both manual click ("Xổ số") and auto-call interval funnel through it.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~5 min
## Files
| File | Change |
|---|---|
| `src/lib/MasterPanel.svelte` | Import `playNumber`, `cancelPlayback`. Call `playNumber(next)` after state update; `cancelPlayback()` on new game. |
## Diff (illustrative)
```diff
+ import { playNumber, cancelPlayback } from "$lib/voice.js";
// ...
function handleNewGame() {
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
+ cancelPlayback();
autoRunning = false;
state = createFreshState();
lastCalled = null;
}
function handleDrawNext() {
if (!state || state.remaining.length === 0) return;
const next = state.remaining[0];
state = {
called: [...state.called, next],
remaining: state.remaining.slice(1),
};
lastCalled = next;
+ if (settings.voiceEnabledMaster) playNumber(next);
}
```
## Why one hook covers both manual + auto
The auto-call `$effect` (line ~96 in current file) calls
`handleDrawNext()` directly. No duplicate hook needed. ✓
## Edge cases
| Case | Behavior |
|---|---|
| User mashes "Xổ số" rapidly | `playNumber` cancels prior playback (Phase 3) — only the latest plays. ✓ |
| Auto-call at 1s with ~1.5s clips | Same — cancel-then-play. Truncation is the cost of fast mode; users adjust speed if needed. ✓ |
| Toggle `voiceEnabledMaster` off mid-game | Next draw stays silent. Already-playing clip finishes (acceptable) or `cancelPlayback()` if we want immediate silence — KISS, skip. |
| `state.remaining.length === 0` | Early-return guards; `playNumber` never called. ✓ |
| Voice changed mid-game | Next draw uses new voice's clip. ✓ |
## Success criteria
- Click "Xổ số" with `voiceEnabledMaster=true` → audible Vietnamese
number in chosen voice.
- Auto-call announces every drawn number.
- Toggle off → next draws silent.
- "Ván mới" → any pending playback stops.
## Risks
| Risk | Mitigation |
|---|---|
| Clip 404 (corrupt static dir) | Phase 3's `audio.onerror` no-ops silently |
| iOS Safari autoplay block | First "Xổ số" click satisfies the user-gesture requirement |
## Next
- Phase 6 mirrors this in PlayerBoard.
@@ -0,0 +1,104 @@
# Phase 6 — Player: play "Chờ N" and "Kinh"
## Context
- [plan.md](plan.md). Depends on Phases 2–4.
- Hook into the existing waiting/celebrated `$effect` block in
`PlayerBoard.svelte` (lines 83–108). Sound is a sibling output to
the toast and bingo popup, gated by the existing `notifiedWaitingRows`
/ `celebratedRows` sets to dedupe.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~10 min
## Files
| File | Change |
|---|---|
| `src/lib/PlayerBoard.svelte` | Import `playWaiting`, `playBingo`, `cancelPlayback`. Call beside existing toast + popup triggers. Cancel on `handleGenerate` / `handleClear`. |
## Diff (illustrative)
```diff
+ import { playWaiting, playBingo, cancelPlayback } from "$lib/voice.js";
+ import { settings } from "$lib/settings-store.svelte.js";
// ...
$effect(() => {
if (!grid || crossed.length === 0) return;
// Pass 1: at most one bingo popup per render
for (let i = 0; i < grid.length; i++) {
if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
celebratedRows.add(i);
notifiedWaitingRows.add(i);
congratsRow = i + 1;
showCongrats = true;
+ if (settings.voiceEnabledPlayer) playBingo();
break;
}
}
// Pass 2: update waiting state for every non-celebrated row
for (let i = 0; i < grid.length; i++) {
if (celebratedRows.has(i)) continue;
const waitNum = getWaitingNumber(grid, crossed, i);
if (waitNum !== null && !notifiedWaitingRows.has(i)) {
notifiedWaitingRows.add(i);
showToast(`Chờ ${waitNum}`);
+ if (settings.voiceEnabledPlayer) playWaiting(waitNum);
} else if (waitNum === null && notifiedWaitingRows.has(i)) {
notifiedWaitingRows.delete(i);
}
}
});
function handleGenerate() {
if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return;
+ cancelPlayback();
// ...existing...
}
function handleClear() {
if (!grid) return;
const hasMarks = crossed.some((row) => row.some(Boolean));
if (hasMarks && !confirm("Bạn có muốn xoá tất cả đánh dấu không?")) return;
+ cancelPlayback();
// ...existing...
}
```
## De-duplication is already there
The existing `notifiedWaitingRows` and `celebratedRows` sets gate the
toast + popup; the audio call hangs on the same gates so we never
play twice for the same row. ✓
## Edge cases
| Case | Behavior |
|---|---|
| Multiple rows enter "waiting" same render | Each calls `playWaiting`, but cancel-then-play (Phase 3) means only the LAST plays. Acceptable; queueing is a future tune-up. |
| Bingo + waiting on same render | Impossible — `if (celebratedRows.has(i)) continue` short-circuits the waiting check. ✓ |
| Player crosses fast, toggles waiting on/off | `notifiedWaitingRows.delete(i)` lets re-firing happen next entry into waiting. ✓ |
| Player uncrosses winning cell | No "un-bingo" sound. Acceptable — the popup already requires explicit dismiss. |
| Master + player speech race | Latest call wins (cancel-then-play). Player events are low-frequency, so this is rarely user-visible. |
## Success criteria
- Cross 4 of 5 cells in a row → "Chờ {N}" plays.
- Cross the 5th → bingo popup + "Kinh" plays.
- "Tạo bảng mới" / "Xoá đánh dấu" stop any pending playback.
- `voiceEnabledPlayer=false` → silent on both events; toast and popup
still appear.
## Risks
| Risk | Mitigation |
|---|---|
| Master and player speech overlap (e.g., player marks a cell during master draw) | Cancel-then-play documented behavior; latest event wins |
| iOS Safari first-event no-play (no prior gesture) | First waiting state requires 4 prior cell clicks → gesture already satisfied. ✓ |
## Next
- Phase 7 docs sync.
@@ -0,0 +1,92 @@
# Phase 7 — Tests pass + docs sync
## Context
- [plan.md](plan.md). Final phase. No new features — verification +
doc drift cleanup.
## Overview
- Priority: P2
- Status: TODO
- Effort: ~15 min
## Verification
```bash
npm test
npm run build
```
Expected:
- 54 (current) + ~12 (Phase 1 number tests) + ~5 (Phase 4 settings
tests) ≈ 71 tests passing.
- Build clean. `static/audio/` carried into `build/audio/` by
adapter-static automatically.
Manual quick-check after build:
```bash
ls build/audio/hoai-my/ | head # confirm clips bundled
```
## Manual smoke test (browser)
| Step | Expected |
|---|---|
| Fresh load `/`, click "Tạo bảng mới" | grid renders, no audio |
| Cross 4 cells in a row | "Chờ {N}" plays (chosen voice) |
| Cross the 5th | "Kinh" plays + bingo popup |
| Open Settings → toggle "Người chơi" off → cross to bingo | popup, no audio |
| Toggle master mode on, click "Xổ số" | called number plays |
| Settings → switch voice (Hoài Mỹ → Nam Minh) → click "Xổ số" | new voice plays |
| Auto-call at 1s | each draw plays, no backlog |
| Click "Ván mới" mid-clip | playback stops |
| Hard refresh, settings persisted | voice selection survives |
| DevTools → Network tab on first draw | one MP3 fetch from `/audio/{voice}/{n}.mp3`; subsequent draws use cache |
## Docs to sync
| File | Change |
|---|---|
| `docs/codebase-summary.md` | Add `vietnamese-number.js`, `voice.js`, `audio-manifest.js` rows. Update `SettingsButton.svelte` row to mention 5 fieldsets (was 4) + voice picker. Update `settings-store.svelte.js` description: 8 keys (was 5). New "Static Assets" subsection mentioning `static/audio/{voiceId}/`. |
| `docs/project-overview-pdr.md` | Settings section: add "Âm thanh" fieldset (master toggle, player toggle, voice picker). Add acceptance items for voice-on-number, voice-on-Chờ/Kinh, voice picker. Tech Stack: note "Free build-time TTS via edge-tts". |
| `docs/development-roadmap.md` | Drop "Sound Effects on Bingo" idea entry — done in better form (TTS, configurable voice). |
| `docs/deployment-guide.md` | Brief note: regenerating audio = `pip install edge-tts && python3 scripts/generate-audio.py`; no impact on CF Pages build (MP3s are committed). |
| `README.md` | "Regenerating audio" subsection (one short paragraph). |
Skip changes to `system-architecture.md` and `code-standards.md` —
voice is a leaf module with no architectural surface.
## Roadmap
The roadmap currently lists "Sound Effects on Bingo" as an Idea. After
this lands, **delete that section** — it's done in the better form
(neural TTS, configurable voice).
## Cross-language `numberToVietnamese` divergence guard
`scripts/generate-audio.py` and `src/lib/vietnamese-number.js` both
implement the same Vietnamese number rules. They have to stay in sync.
Mitigations:
1. JS unit tests (Phase 1) cover all tonal exceptions; if JS drifts,
tests catch it.
2. Manual: after editing either file, re-read both side by side.
3. (Future) auto-check via a small CI script that calls Python +
loads the JS via Node and compares 1..90. Skip in v1.
## Out of scope (intentionally)
- `voice.test.js` — Phase 3 explained why (DOM Audio mock churn not
worth it; the only branchy logic is `numberToVietnamese`, already
covered by Phase 1 tests).
- Component tests for SettingsButton voice toggles + voice picker —
manual smoke test plus settings-store unit tests cover it.
- E2E tests for audio playback (requires Playwright + audio capture).
## Success criteria
- All tests pass.
- `npm run build` clean. `build/audio/` populated.
- Manual smoke checklist all green.
- Docs updated to reflect new files, new settings keys, new voice
picker.
- No stale "Sound Effects" idea in roadmap.
@@ -0,0 +1,116 @@
---
slug: vietnamese-voice-calls
created: 2026-04-27
updated: 2026-04-27
status: completed
completedAt: 2026-04-27
mode: fast
blockedBy: []
blocks: []
---
# Vietnamese voice calls — bundled MP3 clips, no runtime APIs
Speak the called number aloud (master), and "Chờ {N}" / "Kinh!"
(player) when those events fire. **Free + offline + zero runtime API
calls** — pre-generate Vietnamese audio once during dev (using the
free `edge-tts` Microsoft neural voice) and ship the MP3s as static
assets bundled with the app.
## Why this approach (vs Web Speech API)
User wants the audio bundled in the project — no runtime API or browser
TTS dependency.
| Concern | Web Speech API | Bundled MP3 (this plan) |
|---|---|---|
| Runtime cost | free | free |
| Voice quality | OS-dependent (Linux Chrome often has no `vi`) | uniform, neural, Vietnamese |
| Bundle weight | 0 KB | ~2.2 MB (92 clips × ~12 KB × 2 voices, lazy-loaded) |
| Offline | yes | yes |
| Privacy | TTS may upload text to OS service | fully self-hosted |
| Build complexity | none | one-time `python` script |
Trade-off accepted: ~1.1 MB added to repo + initial page weight, in
exchange for consistent, high-quality Vietnamese audio everywhere.
## Decisions (locked)
- **TTS engine for generation**: `edge-tts` (Python). Free, no API key,
Microsoft Neural Voice quality.
- **All Vietnamese voices generated**: script auto-discovers every
`vi-*` voice in edge-tts and produces a separate clip set per voice
under `static/audio/{voiceId}/`. As of writing: `vi-VN-HoaiMyNeural`
(female) and `vi-VN-NamMinhNeural` (male) — script picks up new
voices automatically on regen.
- **Active voice configurable in settings**: new `voice` key (string,
matches a voice's `id` from `manifest.json`). Default = first voice
in manifest (`hoai-my`). User picks in the "Âm thanh" fieldset.
- **Runtime playback**: HTML5 `<audio>` via plain `new Audio(url)`. No
Web Audio API. Files lazy-loaded on first request; browser caches
after that.
- **Asset layout**: `static/audio/{voiceId}/{1..90}.mp3` +
`static/audio/{voiceId}/cho.mp3` + `static/audio/{voiceId}/kinh.mp3`,
plus `static/audio/manifest.json` (voice list). 92 files per voice.
- **"Chờ N" composition**: play `cho.mp3` then `{N}.mp3` in sequence
via `audio.onended`. Saves 90 files vs pre-generating "Chờ 1.mp3"
through "Chờ 90.mp3".
- **Cancel-then-play**: each request stops any in-flight audio first.
Auto-call at 1s with ~1.5s clips → without cancel we'd grow a
backlog. Latest number is what matters.
- **basePath aware**: URLs go through `import { base } from "$app/paths"`
so the same code works on `loto.miti99.com`, `tiennm99.github.io/loto`,
and code-server `/absproxy/{port}`.
- **Three settings keys**:
- `voiceEnabledMaster: boolean` (default `true`) — speak called number
- `voiceEnabledPlayer: boolean` (default `true`) — speak "Chờ N" / "Kinh"
- `voice: string` (default first manifest entry) — which voice to use
- **No volume slider, no rate/pitch tuning** — device volume + a curated
voice list. KISS.
- **Hook points**:
- Master: inside `handleDrawNext()` (covers manual click + auto-call)
- Player: inside the existing waiting/celebrated `$effect` in
`PlayerBoard.svelte` (uses existing dedupe sets)
## Phases
| # | Phase | File |
|---|---|---|
| 1 | Vietnamese number utility (1–90) — provides TTS prompts + tests | DONE | [phase-01-vietnamese-number-utility.md](phase-01-vietnamese-number-utility.md) |
| 2 | Audio generation script (all `vi-*` voices via edge-tts) → commit `static/audio/{voiceId}/*.mp3` + `manifest.json` | DONE (script + placeholder manifest; user runs script to materialize MP3s) | [phase-02-audio-generation-script.md](phase-02-audio-generation-script.md) |
| 3 | `voice.js` playback module — `<audio>` lazy cache, sequencer, cancel, voice-aware URL builder | DONE | [phase-03-audio-playback-module.md](phase-03-audio-playback-module.md) |
| 4 | Settings: 3 keys (master/player toggles + voice picker) + "Âm thanh" fieldset + tests | DONE | [phase-04-settings-integration.md](phase-04-settings-integration.md) |
| 5 | MasterPanel: announce drawn number in `handleDrawNext()` | DONE | [phase-05-master-integration.md](phase-05-master-integration.md) |
| 6 | PlayerBoard: announce "Chờ N" / "Kinh" in waiting/bingo $effect | DONE | [phase-06-player-integration.md](phase-06-player-integration.md) |
| 7 | Tests pass + docs sync | DONE (98/98 tests; reviewer fixes applied) | [phase-07-tests-and-docs.md](phase-07-tests-and-docs.md) |
## Files Touched / Created
| File | Phase |
|---|---|
| `src/lib/vietnamese-number.js` (NEW) | 1 |
| `src/lib/vietnamese-number.test.js` (NEW) | 1 |
| `scripts/generate-audio.py` (NEW) | 2 |
| `scripts/README.md` (NEW, optional) | 2 |
| `static/audio/*.mp3` (NEW, 92 files, committed) | 2 |
| `src/lib/voice.js` (NEW) | 3 |
| `src/lib/settings-store.svelte.js` | 4 |
| `src/lib/settings-store.test.js` | 4 |
| `src/lib/SettingsButton.svelte` | 4 |
| `src/lib/MasterPanel.svelte` | 5 |
| `src/lib/PlayerBoard.svelte` | 6 |
| `docs/codebase-summary.md` | 7 |
| `docs/project-overview-pdr.md` | 7 |
| `docs/development-roadmap.md` | 7 (drop stale "Sound Effects" idea) |
| `.gitattributes` (optional, for LFS-free MP3 handling) | 2 |
| `README.md` (mention audio regen) | 2 |
## Out of Scope
- Audio sprite / single-file packing (HTTP/2 makes 92 small fetches fine)
- Volume slider, voice picker, alt voices, male voice, child voice
- Pre-rendered "Chờ 1..90" combo clips (sequence at runtime)
- Sound effects on bingo (chimes / confetti audio)
- Service Worker pre-caching (PWA territory; future plan)
- Lô tô fairground rhythm / chant style — flat reading only
- Runtime TTS APIs (edge-tts is build-time only; no runtime API)
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Generate Vietnamese audio clips (1-90 + Chờ + Kinh) for every edge-tts
Vietnamese voice. Output written to static/audio/{voiceId}/ and shipped
with the app — runtime never calls TTS.
Run once on a dev machine:
pip install edge-tts
python3 scripts/generate-audio.py
"""
import asyncio
import json
import os
import re
import sys
OUT_ROOT = os.path.join(os.path.dirname(__file__), "..", "static", "audio")
ONES = [
"không", "một", "hai", "ba", "bốn",
"năm", "sáu", "bảy", "tám", "chín",
]
def number_to_vietnamese(n: int) -> str:
"""Mirror of src/lib/vietnamese-number.js for build-time use."""
if n < 10:
return ONES[n]
if n == 10:
return "mười"
if n < 20:
u = n - 10
return "mười lăm" if u == 5 else f"mười {ONES[u]}"
t, u = divmod(n, 10)
tens = f"{ONES[t]} mươi"
if u == 0:
return tens
if u == 1:
return f"{tens} mốt"
if u == 5:
return f"{tens} lăm"
return f"{tens} {ONES[u]}"
def voice_id(short_name: str) -> str:
"""vi-VN-HoaiMyNeural -> hoai-my"""
name = short_name.split("-")[-1] # HoaiMyNeural
name = re.sub(r"Neural$", "", name) # HoaiMy
name = re.sub(r"(?<!^)(?=[A-Z])", "-", name) # Hoai-My
return name.lower() # hoai-my
def display_label(short_name: str, gender: str) -> str:
given = re.sub(r"Neural$", "", short_name.split("-")[-1])
given = re.sub(r"(?<!^)(?=[A-Z])", " ", given).strip()
gender_vi = "nữ" if gender.lower() == "female" else "nam"
return f"{given} ({gender_vi})"
async def synth(text: str, voice: str, out: str) -> None:
import edge_tts
await edge_tts.Communicate(text, voice).save(out)
print(f" {out} ← \"{text}\"")
async def main() -> None:
import edge_tts
all_voices = await edge_tts.list_voices()
vi_voices = [v for v in all_voices if v["Locale"].startswith("vi-")]
if not vi_voices:
sys.exit("No Vietnamese voices found in edge-tts.")
seen_ids: set[str] = set()
manifest = {"voices": []}
for v in vi_voices:
vid = voice_id(v["ShortName"])
if vid in seen_ids:
sys.exit(f"Voice id collision: {vid} (from {v['ShortName']})")
seen_ids.add(vid)
out_dir = os.path.join(OUT_ROOT, vid)
os.makedirs(out_dir, exist_ok=True)
print(f"\n→ {v['ShortName']} → static/audio/{vid}/")
tasks = [
synth(number_to_vietnamese(n), v["ShortName"], os.path.join(out_dir, f"{n}.mp3"))
for n in range(1, 91)
]
tasks.append(synth("Chờ", v["ShortName"], os.path.join(out_dir, "cho.mp3")))
tasks.append(synth("Kinh", v["ShortName"], os.path.join(out_dir, "kinh.mp3")))
await asyncio.gather(*tasks)
manifest["voices"].append({
"id": vid,
"edgeName": v["ShortName"],
"label": display_label(v["ShortName"], v["Gender"]),
"gender": v["Gender"].lower(),
})
manifest_path = os.path.join(OUT_ROOT, "manifest.json")
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
print(f"\nWrote manifest with {len(manifest['voices'])} voice(s) → {manifest_path}")
if __name__ == "__main__":
try:
asyncio.run(main())
except ImportError:
sys.exit("Install dep first: pip install edge-tts")
+7
View File
@@ -64,6 +64,7 @@
<script>
import { settings } from "$lib/settings-store.svelte.js";
import { cancelPlayback, playNumber } from "$lib/voice.js";
let state = $state(
/** @type {{called: number[], remaining: number[]} | null} */ (null),
@@ -109,8 +110,13 @@
return () => clearInterval(id);
});
// Stop any in-flight clip when the component unmounts (e.g. master mode
// toggled off mid-clip) so audio doesn't outlive the panel.
$effect(() => () => cancelPlayback());
function handleNewGame() {
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
cancelPlayback();
autoRunning = false;
state = createFreshState();
lastCalled = null;
@@ -124,6 +130,7 @@
remaining: state.remaining.slice(1),
};
lastCalled = next;
if (settings.voiceEnabledMaster) playNumber(next);
}
function toggleAuto() {
+9
View File
@@ -8,6 +8,8 @@
saveCrossedState,
saveGrid,
} from "$lib/game-logic.js";
import { settings } from "$lib/settings-store.svelte.js";
import { cancelPlayback, playBingo, playWaiting } from "$lib/voice.js";
/**
* @typedef {Object} Props
@@ -90,6 +92,7 @@
notifiedWaitingRows.add(i);
congratsRow = i + 1;
showCongrats = true;
if (settings.voiceEnabledPlayer) playBingo();
break;
}
}
@@ -101,14 +104,19 @@
if (waitNum !== null && !notifiedWaitingRows.has(i)) {
notifiedWaitingRows.add(i);
showToast(`Chờ ${waitNum}`);
if (settings.voiceEnabledPlayer) playWaiting(waitNum);
} else if (waitNum === null && notifiedWaitingRows.has(i)) {
notifiedWaitingRows.delete(i);
}
}
});
// Stop any in-flight clip on unmount so audio doesn't outlive the board.
$effect(() => () => cancelPlayback());
function handleGenerate() {
if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return;
cancelPlayback();
const newGrid = generateGrid();
const newCrossed = newGrid.map((row) => row.map(() => false));
grid = newGrid;
@@ -124,6 +132,7 @@
if (!grid) return;
const hasMarks = crossed.some((row) => row.some(Boolean));
if (hasMarks && !confirm("Bạn có muốn xoá tất cả đánh dấu không?")) return;
cancelPlayback();
crossed = grid.map((row) => row.map(() => false));
celebratedRows.clear();
notifiedWaitingRows.clear();
+75
View File
@@ -1,4 +1,5 @@
<script>
import { VOICES } from "$lib/audio-manifest.js";
import {
resetSettings,
saveSettings,
@@ -66,6 +67,22 @@
}
}
function toggleVoiceMaster() {
settings.voiceEnabledMaster = !settings.voiceEnabledMaster;
saveSettings();
}
function toggleVoicePlayer() {
settings.voiceEnabledPlayer = !settings.voiceEnabledPlayer;
saveSettings();
}
/** @param {string} id */
function pickVoice(id) {
settings.voice = id;
saveSettings();
}
function close() {
open = false;
}
@@ -232,6 +249,64 @@
</fieldset>
{/if}
<!-- Voice / sound -->
<fieldset class="mb-5">
<legend
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
>
Âm thanh
</legend>
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
Đọc số bằng tiếng Việt
</p>
<button
type="button"
aria-pressed={settings.voiceEnabledMaster}
onclick={toggleVoiceMaster}
class="w-full px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all mb-2
{settings.voiceEnabledMaster
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400'}"
>
Quản trò: {settings.voiceEnabledMaster ? "Bật" : "Tắt"}
</button>
<button
type="button"
aria-pressed={settings.voiceEnabledPlayer}
onclick={toggleVoicePlayer}
class="w-full px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all
{settings.voiceEnabledPlayer
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400'}"
>
Người chơi (Chờ / Kinh): {settings.voiceEnabledPlayer ? "Bật" : "Tắt"}
</button>
{#if VOICES.length > 1}
<p class="text-xs text-slate-500 dark:text-slate-400 mt-3 mb-1">
Giọng đọc
</p>
<div class="grid grid-cols-2 gap-2">
{#each VOICES as v (v.id)}
{@const selected = settings.voice === v.id}
<button
type="button"
aria-pressed={selected}
onclick={() => pickVoice(v.id)}
class="px-3 py-2 rounded-lg border-2 text-sm transition-all
{selected
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-500'}"
>
{v.label}
</button>
{/each}
</div>
{/if}
</fieldset>
<!-- Empty cell color -->
<fieldset class="mb-5">
<legend
+23
View File
@@ -0,0 +1,23 @@
/**
* Voice manifest produced by `scripts/generate-audio.py`. Imported as a
* static asset so Vite bakes it into the bundle — no runtime fetch.
* @module lib/audio-manifest
*/
import manifest from "../../static/audio/manifest.json";
/**
* @typedef {Object} VoiceEntry
* @property {string} id — folder slug (e.g. "hoai-my")
* @property {string} edgeName — original edge-tts ShortName
* @property {string} label — display label
* @property {string} gender
*/
/** @type {VoiceEntry[]} */
export const VOICES = manifest.voices;
/** Set of valid voice ids for fast membership checks. */
export const VOICE_IDS = new Set(VOICES.map((v) => v.id));
/** First entry is the default — the script writes them in edge-tts order. */
export const DEFAULT_VOICE = VOICES[0]?.id ?? "hoai-my";
+23
View File
@@ -6,6 +6,8 @@
* @module lib/settings-store
*/
import { DEFAULT_VOICE, VOICE_IDS } from "$lib/audio-manifest.js";
const STORAGE_KEY = "loto_settings";
const HEX6 = /^#[0-9a-fA-F]{6}$/;
const VALID_THEMES = /** @type {const} */ (["auto", "light", "dark"]);
@@ -21,6 +23,12 @@ export const DEFAULT_SETTINGS = Object.freeze({
autoCallEnabled: false,
/** Auto-call interval, seconds per number. Integer 1..10. */
autoCallSpeed: 5,
/** Speak the called number aloud when master draws. */
voiceEnabledMaster: true,
/** Speak "Chờ N" / "Kinh" on player events. */
voiceEnabledPlayer: true,
/** Active voice id; matches an entry in audio manifest. */
voice: DEFAULT_VOICE,
});
export const settings = $state({ ...DEFAULT_SETTINGS });
@@ -45,6 +53,10 @@ function validSpeed(v) {
? v
: null;
}
/** @param {unknown} v */
function validVoiceId(v) {
return typeof v === "string" && VOICE_IDS.has(v) ? v : null;
}
/* ------------------------------------------------------------------ */
/* DOM apply */
@@ -111,6 +123,14 @@ export function loadSettings() {
validBool(parsed.autoCallEnabled) ?? DEFAULT_SETTINGS.autoCallEnabled;
settings.autoCallSpeed =
validSpeed(parsed.autoCallSpeed) ?? DEFAULT_SETTINGS.autoCallSpeed;
settings.voiceEnabledMaster =
validBool(parsed.voiceEnabledMaster) ??
DEFAULT_SETTINGS.voiceEnabledMaster;
settings.voiceEnabledPlayer =
validBool(parsed.voiceEnabledPlayer) ??
DEFAULT_SETTINGS.voiceEnabledPlayer;
settings.voice =
validVoiceId(parsed.voice) ?? DEFAULT_SETTINGS.voice;
}
} catch {
/* private mode / quota / corrupt JSON — fall back to defaults */
@@ -133,5 +153,8 @@ export function resetSettings() {
settings.masterMode = DEFAULT_SETTINGS.masterMode;
settings.autoCallEnabled = DEFAULT_SETTINGS.autoCallEnabled;
settings.autoCallSpeed = DEFAULT_SETTINGS.autoCallSpeed;
settings.voiceEnabledMaster = DEFAULT_SETTINGS.voiceEnabledMaster;
settings.voiceEnabledPlayer = DEFAULT_SETTINGS.voiceEnabledPlayer;
settings.voice = DEFAULT_SETTINGS.voice;
saveSettings();
}
+59
View File
@@ -48,6 +48,9 @@ beforeEach(() => {
"masterMode",
"autoCallEnabled",
"autoCallSpeed",
"voiceEnabledMaster",
"voiceEnabledPlayer",
"voice",
])) {
/** @type {any} */ (settings)[k] = /** @type {any} */ (DEFAULT_SETTINGS)[k];
}
@@ -74,6 +77,56 @@ describe("settings-store — defaults", () => {
expect(DEFAULT_SETTINGS.autoCallEnabled).toBe(false);
expect(DEFAULT_SETTINGS.autoCallSpeed).toBe(5);
});
it("voice toggles default ON; voice id picks first manifest entry", () => {
expect(DEFAULT_SETTINGS.voiceEnabledMaster).toBe(true);
expect(DEFAULT_SETTINGS.voiceEnabledPlayer).toBe(true);
expect(typeof DEFAULT_SETTINGS.voice).toBe("string");
expect(DEFAULT_SETTINGS.voice.length).toBeGreaterThan(0);
});
it("voice round-trips through localStorage and survives reload", () => {
settings.voice = "nam-minh";
settings.voiceEnabledMaster = false;
saveSettings();
settings.voice = "hoai-my";
settings.voiceEnabledMaster = true;
loadSettings();
expect(settings.voice).toBe("nam-minh");
expect(settings.voiceEnabledMaster).toBe(false);
});
it("invalid voice id falls back to default; other settings survive", () => {
localStorage.setItem(
KEY,
JSON.stringify({
...DEFAULT_SETTINGS,
voice: "made-up-voice-id",
autoCallSpeed: 7,
}),
);
loadSettings();
expect(settings.voice).toBe(DEFAULT_SETTINGS.voice);
expect(settings.autoCallSpeed).toBe(7);
});
it("non-boolean voiceEnabled* falls back to default", () => {
localStorage.setItem(
KEY,
JSON.stringify({
...DEFAULT_SETTINGS,
voiceEnabledMaster: "yes",
voiceEnabledPlayer: 1,
}),
);
loadSettings();
expect(settings.voiceEnabledMaster).toBe(
DEFAULT_SETTINGS.voiceEnabledMaster,
);
expect(settings.voiceEnabledPlayer).toBe(
DEFAULT_SETTINGS.voiceEnabledPlayer,
);
});
});
describe("settings-store — loadSettings (color)", () => {
@@ -188,6 +241,9 @@ describe("settings-store — saveSettings", () => {
settings.masterMode = true;
settings.autoCallEnabled = true;
settings.autoCallSpeed = 7;
settings.voiceEnabledMaster = false;
settings.voiceEnabledPlayer = false;
settings.voice = "nam-minh";
saveSettings();
const raw = localStorage.getItem(KEY);
expect(raw).not.toBeNull();
@@ -197,6 +253,9 @@ describe("settings-store — saveSettings", () => {
masterMode: true,
autoCallEnabled: true,
autoCallSpeed: 7,
voiceEnabledMaster: false,
voiceEnabledPlayer: false,
voice: "nam-minh",
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Vietnamese number-words for lô tô calls. Pure, no DOM, no I/O.
* @module lib/vietnamese-number
*/
const ONES = [
"không",
"một",
"hai",
"ba",
"bốn",
"năm",
"sáu",
"bảy",
"tám",
"chín",
];
/**
* Convert an integer 0..90 to its spoken Vietnamese form, honoring the
* tonal exceptions that show up in lô tô (15 = "mười lăm", 21 = "hai mươi
* mốt", 25 = "hai mươi lăm", etc.). Out-of-range values fall back to a
* plain `String(n)` so the caller never has to guard against undefined.
* @param {number} n
* @returns {string}
*/
export function numberToVietnamese(n) {
if (!Number.isInteger(n) || n < 0 || n > 90) return String(n);
if (n < 10) return ONES[n];
if (n === 10) return "mười";
if (n < 20) {
const u = n - 10;
return u === 5 ? "mười lăm" : `mười ${ONES[u]}`;
}
const t = Math.floor(n / 10);
const u = n % 10;
const tens = `${ONES[t]} mươi`;
if (u === 0) return tens;
if (u === 1) return `${tens} mốt`;
if (u === 5) return `${tens} lăm`;
return `${tens} ${ONES[u]}`;
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { numberToVietnamese } from "./vietnamese-number.js";
describe("numberToVietnamese — units 0..9", () => {
it.each([
[0, "không"],
[1, "một"],
[2, "hai"],
[3, "ba"],
[4, "bốn"],
[5, "năm"],
[6, "sáu"],
[7, "bảy"],
[8, "tám"],
[9, "chín"],
])("%i → %s", (n, expected) => {
expect(numberToVietnamese(n)).toBe(expected);
});
});
describe("numberToVietnamese — teens 10..19 (mười …, with mười lăm exception)", () => {
it.each([
[10, "mười"],
[11, "mười một"],
[12, "mười hai"],
[13, "mười ba"],
[14, "mười bốn"],
[15, "mười lăm"],
[16, "mười sáu"],
[17, "mười bảy"],
[18, "mười tám"],
[19, "mười chín"],
])("%i → %s", (n, expected) => {
expect(numberToVietnamese(n)).toBe(expected);
});
});
describe("numberToVietnamese — 20..90 (mốt and lăm exceptions, ten-only forms)", () => {
it.each([
[20, "hai mươi"],
[21, "hai mươi mốt"],
[22, "hai mươi hai"],
[25, "hai mươi lăm"],
[29, "hai mươi chín"],
[30, "ba mươi"],
[31, "ba mươi mốt"],
[40, "bốn mươi"],
[45, "bốn mươi lăm"],
[55, "năm mươi lăm"],
[61, "sáu mươi mốt"],
[70, "bảy mươi"],
[81, "tám mươi mốt"],
[85, "tám mươi lăm"],
[90, "chín mươi"],
])("%i → %s", (n, expected) => {
expect(numberToVietnamese(n)).toBe(expected);
});
});
describe("numberToVietnamese — out of range falls back to String(n)", () => {
it.each([
[-1, "-1"],
[91, "91"],
[100, "100"],
[0.5, "0.5"],
[Number.NaN, "NaN"],
])("%p → %p", (n, expected) => {
expect(numberToVietnamese(/** @type {number} */ (n))).toBe(expected);
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Bundled-MP3 playback for Vietnamese number calls. No runtime TTS, no
* API. Clips live under `static/audio/{voiceId}/{1..90,cho,kinh}.mp3`
* and are picked by `settings.voice`.
* @module lib/voice
*/
import { base } from "$app/paths";
import { settings } from "$lib/settings-store.svelte.js";
/** @type {Map<string, HTMLAudioElement>} */
const cache = new Map();
/** @type {HTMLAudioElement | null} */
let activeClip = null;
/** @type {symbol | null} */
let activeToken = null;
/** @type {(() => void) | null} — resolver of the in-flight playClip promise */
let activeResolver = null;
function isBrowser() {
return typeof window !== "undefined" && typeof Audio !== "undefined";
}
/** @param {string} url */
function getAudio(url) {
let a = cache.get(url);
if (!a) {
a = new Audio(url);
a.preload = "auto";
cache.set(url, a);
}
return a;
}
/** @param {string} name — clip basename without extension */
function clipUrl(name) {
return `${base}/audio/${settings.voice}/${name}.mp3`;
}
export function cancelPlayback() {
if (!isBrowser()) return;
if (activeClip) {
activeClip.onended = null;
activeClip.onerror = null;
activeClip.pause();
activeClip.currentTime = 0;
}
activeClip = null;
activeToken = null;
// Resolve any pending playClip promise so async chains exit cleanly
// (the next await will see the token mismatch and short-circuit).
if (activeResolver) {
const r = activeResolver;
activeResolver = null;
r();
}
}
/**
* Play one clip; resolve when it ends, errors, or is canceled.
* @param {string} url
* @param {symbol} token — caller's session marker; mismatch = canceled
*/
function playClip(url, token) {
return new Promise((resolve) => {
if (!isBrowser() || activeToken !== token) return resolve();
const a = getAudio(url);
a.currentTime = 0;
activeClip = a;
activeResolver = resolve;
const done = () => {
a.onended = null;
a.onerror = null;
if (activeClip === a) activeClip = null;
if (activeResolver === resolve) activeResolver = null;
resolve();
};
a.onended = done;
a.onerror = done;
a.play().catch(done);
});
}
/** @param {number} n */
export function playNumber(n) {
if (!isBrowser()) return;
cancelPlayback();
const token = Symbol("playNumber");
activeToken = token;
void playClip(clipUrl(String(n)), token);
}
/** @param {number} n */
export function playWaiting(n) {
if (!isBrowser()) return;
cancelPlayback();
const token = Symbol("playWaiting");
activeToken = token;
(async () => {
await playClip(clipUrl("cho"), token);
await playClip(clipUrl(String(n)), token);
})();
}
export function playBingo() {
if (!isBrowser()) return;
cancelPlayback();
const token = Symbol("playBingo");
activeToken = token;
void playClip(clipUrl("kinh"), token);
}
+16
View File
@@ -0,0 +1,16 @@
{
"voices": [
{
"id": "hoai-my",
"edgeName": "vi-VN-HoaiMyNeural",
"label": "Hoai My (nữ)",
"gender": "female"
},
{
"id": "nam-minh",
"edgeName": "vi-VN-NamMinhNeural",
"label": "Nam Minh (nam)",
"gender": "male"
}
]
}