Initial commit: English Wonder World

給 Jay 的小朋友英文學習網頁,包含 Units 1-4、端午節特輯、
數字單元。含 Gemini TTS 預錄音檔與 LINE Seed 字型。
This commit is contained in:
Timmy
2026-04-20 18:07:31 +08:00
commit 7f053d2b27
149 changed files with 3557 additions and 0 deletions

146
generate_audio.py Normal file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
generate_audio.py — 用 OpenAI TTS 把 english_units.html 裡所有英文句子跑成 mp3。
用法:
export OPENAI_API_KEY=你的key
python3 generate_audio.py [--force] [--voice shimmer] [--speed 0.9]
不需要任何套件(純 Python 標準函式庫),不需要 ffmpegOpenAI 直接回 mp3
聲音alloy / ash / ballad / coral / echo / fable / nova / onyx / sage / shimmer / verse
模型gpt-4o-mini-tts預設支援 instructions/ tts-1-hd / tts-1
"""
import argparse
import hashlib
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent
HTML = ROOT / "english_units.html"
AUDIO_DIR = ROOT / "audio"
OPENAI_TTS_URL = "https://api.openai.com/v1/audio/speech"
INSTRUCTIONS = (
"Speak in a warm, playful, encouraging tone like a cheerful older sister reading a "
"picture book to a young child. Clear American English, slightly slower than normal, "
"full of warmth and excitement. Sound friendly and approachable, not stiff. "
"Pronounce every word clearly."
)
def extract_texts(html_text: str):
pattern = re.compile(r'en:\s*"((?:[^"\\]|\\.)*)"')
clean, seen = [], set()
for m in pattern.findall(html_text):
t = m.replace('\\"', '"').replace("\\'", "'")
t = re.sub(r'<[^>]+>', '', t).strip()
if t and t not in seen:
seen.add(t)
clean.append(t)
return clean
def hash_text(text: str) -> str:
return hashlib.sha1(text.encode('utf-8')).hexdigest()[:16]
def tts(api_key: str, text: str, voice: str, model: str,
fmt: str = 'mp3', speed: float = 0.9) -> bytes:
payload = {
"model": model,
"input": text,
"voice": voice,
"response_format": fmt,
"speed": speed,
}
if model.startswith("gpt-4o"):
payload["instructions"] = INSTRUCTIONS
req = urllib.request.Request(
OPENAI_TTS_URL,
data=json.dumps(payload).encode('utf-8'),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=90) as r:
return r.read()
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--force', action='store_true', help='重新產生已存在的檔案')
ap.add_argument('--voice', default='nova',
help='alloy/ash/ballad/coral/echo/fable/nova/onyx/sage/shimmer/verse')
ap.add_argument('--speed', type=float, default=0.92,
help='0.254.0<1 變慢(預設 0.92')
ap.add_argument('--model', default='gpt-4o-mini-tts',
choices=['gpt-4o-mini-tts', 'tts-1-hd', 'tts-1'])
ap.add_argument('--format', default='mp3',
choices=['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'])
ap.add_argument('--sleep', type=float, default=0.2)
args = ap.parse_args()
api_key = os.getenv('OPENAI_API_KEY')
if not api_key:
print('需要 OPENAI_API_KEY', file=sys.stderr)
print(' 去 https://platform.openai.com/api-keys 拿', file=sys.stderr)
sys.exit(1)
AUDIO_DIR.mkdir(exist_ok=True)
texts = extract_texts(HTML.read_text(encoding='utf-8'))
print(f'找到 {len(texts)} 句不重複英文')
print(f'模型 {args.model} 聲音 {args.voice} 速度 {args.speed}x 格式 {args.format}')
print()
manifest = {}
made = skipped = failed = 0
for i, text in enumerate(texts, 1):
h = hash_text(text)
out = AUDIO_DIR / f'{h}.{args.format}'
label = text if len(text) <= 55 else text[:52] + '...'
if out.exists() and not args.force:
manifest[text] = out.name
skipped += 1
print(f'[{i:3}/{len(texts)}] skip {label}')
continue
print(f'[{i:3}/{len(texts)}] gen {label}')
try:
audio = tts(api_key, text, args.voice, args.model, args.format, args.speed)
out.write_bytes(audio)
manifest[text] = out.name
made += 1
except urllib.error.HTTPError as e:
failed += 1
body = e.read().decode('utf-8', errors='ignore')[:240]
print(f' HTTP {e.code}: {body}')
except Exception as e:
failed += 1
print(f' FAIL: {e}')
time.sleep(args.sleep)
(AUDIO_DIR / 'manifest.json').write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8')
(AUDIO_DIR / 'manifest.js').write_text(
'window.__audioManifest = ' + json.dumps(manifest, ensure_ascii=False) + ';\n',
encoding='utf-8',
)
print()
print(f'完成:新產生 {made}、跳過 {skipped}、失敗 {failed}')
print(f'檔案在 {AUDIO_DIR}/')
if __name__ == '__main__':
main()