#!/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()