Initial commit: English Wonder World
給 Jay 的小朋友英文學習網頁,包含 Units 1-4、端午節特輯、 數字單元。含 Gemini TTS 預錄音檔與 LINE Seed 字型。
This commit is contained in:
146
generate_audio.py
Normal file
146
generate_audio.py
Normal 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 標準函式庫),不需要 ffmpeg(OpenAI 直接回 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.25–4.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()
|
||||
Reference in New Issue
Block a user