Files
ai-agent-book/chapter7/tts-quality-eval/demo.py
T
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

419 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
"""实验 7-6:全自动 TTS 质量评估流水线 —— 一条命令跑通。
python demo.py # 默认 4 个 OpenAI 配置 x 4 条语料
python demo.py --providers openai,minimax # 跨服务商横向对比
python demo.py --text '一段话' # 自定义文本
python demo.py --gemini # 评审改用多模态模型直接听两段音频
python demo.py --quick # 只用前 2 条语料,快速冒烟
python demo.py --list-providers # 离线:查看 provider 及配置状态
python demo.py --dump-rubric # 离线:查看 Rubric 维度定义
流程:多 provider TTS 合成 -> ffprobe 时长 -> Whisper 回译 -> CER/字准确率
-> LLM/多模态音频 Rubric 打分 -> 打印逐条明细 + 配置对比汇总表。
幂等:音频写入 output/ 并复用(除非 --fresh)。完整参数见 `python demo.py --help`。
"""
import argparse
import hashlib
import json
import os
import sys
import time
import traceback
from datetime import datetime, timezone
from pathlib import Path
from statistics import mean
import config
import pipeline
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
DEFAULT_REFERENCE_AUDIO = str(
Path(__file__).resolve().parents[2]
/ "chapter9"
/ "controllable-tts"
/ "reference_audio"
/ "neutral_normal_formal.mp3"
)
def load_env():
"""极简 .env 加载(不引第三方依赖)。"""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
if os.path.exists(path):
for line in open(path, encoding="utf-8"):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
def audio_path(cfg_name: str, sample_id: str) -> str:
return os.path.join(OUT_DIR, f"{cfg_name}__{sample_id}.mp3")
def evaluate_one(
cfg,
sample,
use_gemini: bool,
fresh: bool,
judge_model: str = None,
reference_audio: str = "",
with_asr: bool = False,
) -> dict:
"""对单个 (配置, 语料) 跑完整链路。任一步失败返回 error 记录,不抛出。"""
rec = {"config": cfg.name, "sample": sample.id, "challenge": sample.challenge,
"provider": getattr(cfg, "provider", "openai"), "ok": False, "error": None}
path = audio_path(cfg.name, sample.id)
stage = "synthesis"
try:
# 1) 合成(幂等:已存在且非 fresh 则复用)
if fresh or not os.path.exists(path) or os.path.getsize(path) == 0:
pipeline.synthesize(cfg, sample.text, path)
# Preserve successful synthesis evidence even if a later ASR/judge
# stage fails. This keeps provider progress auditable without
# incorrectly marking the end-to-end cell complete.
rec.update({
"audio_path": os.path.relpath(path, OUT_DIR),
"audio_sha256": pipeline.sha256_file(path),
"audio_bytes": os.path.getsize(path),
})
# 2) 时长
stage = "duration_probe"
dur = pipeline.probe_duration(path)
# 3) Optional objective ASR. Direct-audio Gemini judging does not
# require OpenAI/Whisper and therefore keeps Fish-only runs possible.
stage = "optional_asr"
hyp = pipeline.transcribe(path) if (with_asr or not use_gemini) else None
er = pipeline.char_error_rate(sample.text, hyp) if hyp is not None else None
# 4) Rubric: manuscript acceptance requires direct audio plus a real
# reference clip, not a transcript-only inference.
stage = "multimodal_judge" if use_gemini else "text_judge"
if use_gemini:
rub = pipeline.judge_gemini_audio(
sample.text, sample.emotion, path, reference_audio
)
else:
rub = pipeline.judge_rubric(
sample.text, sample.emotion, hyp or "", dur, er.cer if er else 0.0,
model=judge_model,
)
rec.update({
"ok": True,
"duration": dur,
"hypothesis": hyp,
"cer": er.cer if er else None,
"asr_accuracy": er.accuracy if er else None,
"speed": (len(pipeline.normalize(sample.text)) / dur) if dur else 0.0,
"scores": rub.scores, "reasons": rub.reasons,
"judge_model": rub.judge_model,
"evidence_mode": rub.evidence_mode,
"judge_provider_attempts": rub.provider_attempts,
})
except Exception as e: # 单条失败不影响整表
rec["failed_stage"] = stage
rec["error"] = f"{type(e).__name__}: {e}"
attempts = getattr(e, "provider_attempts", None)
if attempts:
rec["judge_provider_attempts"] = attempts
return rec
def fmt(x, nd=2):
return f"{x:.{nd}f}" if isinstance(x, (int, float)) else str(x)
def print_detail(rec, sample_text):
head = f"[{rec['config']} | {rec['sample']}] {rec['challenge']}"
if not rec["ok"]:
print(f" {head}\n !! 失败: {rec['error']}")
return
print(f" {head}")
print(f" 原文 : {sample_text}")
if rec.get("hypothesis") is not None:
print(f" 回译 : {rec['hypothesis']}")
objective = ""
if rec.get("cer") is not None:
objective = (
f" CER: {fmt(rec['cer'],3)}"
f" ASR字准确率: {fmt(rec['asr_accuracy']*100,1)}%"
)
print(f" 时长 : {fmt(rec['duration'])}s 语速: {fmt(rec['speed'])} 字/秒{objective}")
s, r = rec["scores"], rec["reasons"]
for dim in pipeline.RUBRIC_DIMENSIONS:
print(f" {dim:<4}: {s.get(dim,'-')}/5 {r.get(dim,'')}")
def summarize(records):
"""按配置聚合:平均 CER、平均字准确率、各 Rubric 维度均分、成功数。"""
by_cfg = {}
for rec in records:
by_cfg.setdefault(rec["config"], []).append(rec)
rows = []
for cfg_name, recs in by_cfg.items():
ok = [r for r in recs if r["ok"]]
row = {"config": cfg_name, "n_ok": len(ok), "n": len(recs)}
if ok:
objective = [r for r in ok if r.get("cer") is not None]
row["cer"] = mean(r["cer"] for r in objective) if objective else None
row["asr_accuracy"] = (
mean(r["asr_accuracy"] for r in objective) if objective else None
)
for dim in pipeline.RUBRIC_DIMENSIONS:
row[dim] = mean(r["scores"].get(dim, 0) for r in ok)
rows.append(row)
# 按整体分降序、CER 升序排序
rows.sort(
key=lambda x: (
-mean(x.get(dim, 0) for dim in pipeline.RUBRIC_DIMENSIONS),
x.get("cer") if x.get("cer") is not None else 1,
)
)
return rows
def print_table(rows):
cols = list(pipeline.RUBRIC_DIMENSIONS)
header = (f"{'配置':<22}{'成功':>6}{'ASR准确率':>11}{'CER':>8}"
+ "".join(f"{c:>9}" for c in cols))
print(header)
print("-" * 74)
for r in rows:
ok_str = f"{r['n_ok']}/{r['n']}"
if not r.get("n_ok"):
print(f"{r['config']:<22}{ok_str:>6} (全部失败)")
continue
acc = f"{r['asr_accuracy']*100:.1f}%" if r.get("asr_accuracy") is not None else "n/a"
cer = f"{r['cer']:.3f}" if r.get("cer") is not None else "n/a"
line = f"{r['config']:<22}{ok_str:>6}{acc:>11}{cer:>8}"
line += "".join(f"{r.get(c,0):>9.2f}" for c in cols)
print(line)
def print_providers():
"""离线打印所有可用 TTS provider 及其配置状态(无需任何 API key)。"""
print("可用 TTS provider(书中:OpenAI / ElevenLabs / Fish Audio / Minimax / 豆包):\n")
for key, p in config.PROVIDERS.items():
state = "已配置" if p.configured() else "未配置"
env = " + ".join(p.env)
print(f" [{key}] {p.label} ({state};需 {env})")
print(f" {p.note}")
print("\n用 --providers openai,minimax 选择跨服务商横向对比(默认仅 OpenAI)。")
print("非 OpenAI provider 需各自的 key(见 env.example);缺 key 时该行记为失败,不中断整表。")
def print_rubric():
"""离线打印 Rubric 维度定义(无需任何 API key)。"""
print("TTS 质量评估 Rubric1-5 分,5 最好):\n")
for dim in pipeline.RUBRIC_DIMENSIONS:
print(f" {dim}{pipeline.RUBRIC_DESCRIPTIONS.get(dim, '')}")
print("\n默认(Whisper 回译 + LLM)评审基于「转写文本 + 时长 + 语速 + CER」保守打分;")
print("--gemini 同时提供合成音频与参考语音,覆盖正文全部四个维度。")
def main():
global OUT_DIR
ap = argparse.ArgumentParser(
description="全自动 TTS 质量评估流水线(实验 7-6):多 provider 合成 + 多模态 LLM Rubric 评审",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="示例:\n"
" python demo.py 默认 4 个 OpenAI 配置 × 4 条语料\n"
" python demo.py --providers openai,minimax 跨服务商横向对比\n"
" python demo.py --text '今天天气不错' --gemini 自定义文本 + Gemini 多模态评审\n"
" python demo.py --list-providers 离线查看 provider 及配置状态\n"
" python demo.py --dump-rubric 离线查看 Rubric 维度定义",
)
ap.add_argument("--text", metavar="文本",
help="用一段自定义文本替换测试语料库(只评这一句)")
ap.add_argument("--providers", metavar="列表",
help="逗号分隔的 provideropenai,elevenlabs,fishaudio,minimax,doubao),"
"每个取代表性配置做横向对比;默认仅 OpenAI 的多配置")
ap.add_argument("--judge-model", metavar="模型", dest="judge_model",
help=f"覆盖 LLM 评审模型(默认 {config.JUDGE_MODEL});--gemini 时不生效")
ap.add_argument("--output", metavar="目录",
help=f"输出目录(音频 + results.json),默认 {OUT_DIR}")
ap.add_argument("--extra", action="store_true", help="额外加入 gpt-4o-mini-tts 配置")
ap.add_argument(
"--gemini",
action="store_true",
help="用 Gemini/OpenRouter/Voxtral 多模态路线直接听两段音频评审",
)
ap.add_argument(
"--reference-audio",
default=DEFAULT_REFERENCE_AUDIO,
help="Gemini 音色一致性对照的真实参考语音(默认复用第 9 章固定证据)",
)
ap.add_argument(
"--with-asr",
action="store_true",
help="Gemini 直听之外再运行 Whisper/CER;需要可用 OPENAI_API_KEY",
)
ap.add_argument("--quick", action="store_true", help="只用前 2 条语料快速冒烟")
ap.add_argument("--limit", type=int, default=0, help="只用前 N 条语料(0 = 全部)")
ap.add_argument("--fresh", action="store_true", help="忽略已有音频,全部重新合成")
ap.add_argument("--list-providers", action="store_true", dest="list_providers",
help="离线打印所有 TTS provider 及配置状态后退出(无需 key)")
ap.add_argument("--dump-rubric", action="store_true", dest="dump_rubric",
help="离线打印 Rubric 维度定义后退出(无需 key")
args = ap.parse_args()
load_env()
# 离线路径:不联网、不需要任何 key,打印后直接退出。
if args.list_providers:
print_providers()
return
if args.dump_rubric:
print_rubric()
return
if args.output:
OUT_DIR = os.path.abspath(args.output)
os.makedirs(OUT_DIR, exist_ok=True)
if not args.gemini and not os.environ.get("OPENAI_API_KEY", "").strip():
print("错误:缺少 OPENAI_API_KEY(回译/默认评审需要)。请 export 或写入 .env 后重试。",
file=sys.stderr)
sys.exit(1)
if (args.gemini
and not os.environ.get("GEMINI_API_KEY", "").strip()
and not os.environ.get("OPENROUTER_API_KEY", "").strip()
and not os.environ.get("MISTRAL_API_KEY", "").strip()):
print(
"错误:--gemini 需要 GEMINI_API_KEY、OPENROUTER_API_KEY 或 MISTRAL_API_KEY。",
file=sys.stderr,
)
sys.exit(1)
if args.gemini and not os.path.isfile(args.reference_audio):
print(f"错误:参考语音不存在:{args.reference_audio}", file=sys.stderr)
sys.exit(1)
# 选择待对比的配置:--providers 优先(跨服务商),否则默认 OpenAI 多配置。
if args.providers:
configs = []
for key in [p.strip() for p in args.providers.split(",") if p.strip()]:
if key not in config.PROVIDER_CONFIGS:
print(f"错误:未知 provider {key!r}。可用:{', '.join(config.PROVIDER_CONFIGS)}",
file=sys.stderr)
sys.exit(1)
configs.append(config.PROVIDER_CONFIGS[key])
else:
configs = list(config.TTS_CONFIGS)
if args.extra:
configs += config.EXTRA_CONFIGS
if args.text:
corpus = [config.Sample(id="custom", text=args.text,
challenge="自定义文本", emotion="中性")]
else:
corpus = config.CORPUS[:2] if args.quick else config.CORPUS
if args.limit:
if args.limit < 0:
print("错误:--limit 不能为负数。", file=sys.stderr)
sys.exit(1)
corpus = corpus[:args.limit]
judge_model = args.judge_model or config.JUDGE_MODEL
mode = ("多模态直接音频评审" if args.gemini
else f"Whisper 回译 + LLM Rubric{judge_model}")
providers_used = sorted({getattr(c, "provider", "openai") for c in configs})
print("=" * 72)
print(f"实验 7-6:全自动 TTS 质量评估流水线")
print(f"评审模式:{mode}")
print(f"参与 provider{', '.join(providers_used)}")
print(f"配置数:{len(configs)} 语料数:{len(corpus)} "
f"共 {len(configs)*len(corpus)} 条待评估")
print("=" * 72)
records = []
t0 = time.time()
for cfg in configs:
print(f"\n### 配置 {cfg.name} (provider={getattr(cfg,'provider','openai')}, "
f"model={cfg.model}, voice={cfg.voice}, speed={cfg.speed})")
for sample in corpus:
rec = evaluate_one(
cfg,
sample,
args.gemini,
args.fresh,
judge_model=None if args.gemini else args.judge_model,
reference_audio=args.reference_audio,
with_asr=args.with_asr,
)
print_detail(rec, sample.text)
records.append(rec)
rows = summarize(records)
print("\n" + "=" * 72)
print("配置对比汇总(按四维宏平均分降序)")
print("=" * 72)
print_table(rows)
ok = sum(1 for r in records if r["ok"])
print(f"\n完成:{ok}/{len(records)} 条成功,耗时 {time.time()-t0:.1f}s。")
# 落盘结构化结果,便于二次分析
out_json = os.path.join(OUT_DIR, "results.json")
expected = len(configs) * len(corpus)
exact_dims = set(pipeline.RUBRIC_DIMENSIONS)
complete_records = [
r for r in records
if r.get("ok")
and r.get("evidence_mode") == "direct-audio-with-reference"
and set(r.get("scores", {})) == exact_dims
and all(1 <= int(v) <= 5 for v in r.get("scores", {}).values())
]
reference_sha = (
pipeline.sha256_file(args.reference_audio)
if args.gemini and os.path.isfile(args.reference_audio)
else None
)
payload = {
"schema_version": "2.0",
"experiment": "7-6",
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
"command_scope": {
"providers": providers_used,
"configurations": [
{
"name": c.name,
"provider": c.provider,
"model": c.model,
"voice": c.voice,
"speed": c.speed,
}
for c in configs
],
"corpus_ids": [s.id for s in corpus],
"expected_records": expected,
"direct_audio_judge": args.gemini,
"optional_asr_enabled": args.with_asr,
},
"reference_audio": {
"path": os.path.relpath(args.reference_audio, OUT_DIR) if args.gemini else None,
"sha256": reference_sha,
},
"rubric_dimensions": pipeline.RUBRIC_DIMENSIONS,
"completion": {
"successful_records": ok,
"direct_audio_four_dimension_records": len(complete_records),
"expected_records": expected,
"all_cells_complete": len(complete_records) == expected,
"multi_provider": len(providers_used) >= 2,
"manuscript_core_complete": (
len(complete_records) == expected
and len(providers_used) >= 2
and bool(reference_sha)
),
},
"records": records,
"summary": rows,
}
with open(out_json, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"明细结果已写入 {out_json}")
if __name__ == "__main__":
main()