mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-14 02:16:22 +00:00
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:
1 parent
fb0ef9f783
commit
ad537ee4a6
23 files changed
+1578
-10
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
Executable
+111
@@ -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")
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user