Files
english-wonder-world/generate_audio.py
Timmy 7f053d2b27 Initial commit: English Wonder World
給 Jay 的小朋友英文學習網頁,包含 Units 1-4、端午節特輯、
數字單元。含 Gemini TTS 預錄音檔與 LINE Seed 字型。
2026-04-20 18:07:31 +08:00

147 lines
4.8 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()