147 lines
4.8 KiB
Python
147 lines
4.8 KiB
Python
#!/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()
|