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 35578d00fe
commit 69edd30529
23 files changed
+1578 -10

No files matched your search

+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")