ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.env
output/*.mp3
output/.tmp/
__pycache__/
*.pyc
+100
View File
@@ -0,0 +1,100 @@
# 实验 6-6Fish Audio S1 控制标记 TTS
本项目实际调用 Fish Audio S1,不再使用 OpenAI TTS、固定 `alloy` voice 或拟声词替代。执行层把主 LLM 的控制标记映射到真实的 24 条参考语音,并通过 S1 的零样本 `ReferenceAudio` voice cloning 合成同一说话人、不同情绪/语速/风格的语音。
参考库是严格的笛卡尔积:
- 情绪:neutral / happy / frustrated / thinking
- 语速:normal / fast / slow
- 风格:formal / casual
- 总计:4 × 3 × 2 = 24 条。
## 1. 构建真实参考语音库
```bash
cd chapter6/controllable-tts
pip install -r requirements.txt
cp env.example .env
python build_reference_library.py
```
配置 `FISH_API_KEY` 与一个你拥有或获准克隆的 `FISH_BASE_REFERENCE_ID`。builder 使用同一 source timbre 和 Fish S1 原生情感标记,合成 24 条约 5 秒的参考音;`reference_audio/manifest.json` 保存每条音频的情绪、语速、风格、transcript、时长和 SHA-256。运行时会验证数量与 hash,缺任何一条都拒绝合成。
## 2. 三配置对照
```bash
# From the repository root: use the shared Chapter 9 core environment
uv sync --locked --python 3.12 --extra ch9
# Activate it before changing directories:
# macOS/Linux:
source .venv/bin/activate
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
# Windows cmd: .venv\Scripts\activate.bat
# pip fallback when uv is not installed:
# python -m pip install -e ".[ch9]"
cd chapter6/controllable-tts
# Install this experiment's Fish SDK runtime dependencies.
python -m pip install -r requirements.txt
# Requires ffmpeg/ffprobe installed on the system
cp env.example .env # Fill in FISH_API_KEY and reference settings
python demo.py # Generates output/*.mp3
```
同一文本生成:
- A `A_no_control_markers.mp3`:删除标记,直接使用 source `reference_id`
- B `B_single_reference.mp3`:全程仅用 neutral/normal/formal 一条参考音做零样本克隆;
- C `C_24_reference_library.mp3`:逐段解析标记并在 24 条参考音中切换。
`[THINKING]` 产生 1.2s 思考停顿和 S1 `(uncertain)嗯……``[SIGH]``[LAUGH:small]``[BREATH]` 分别发送 S1 原生 `(sighing)``(chuckling)``(gasping)`,不再用“唉/哈哈”等文字冒充非语言音。所有 Fish 请求显式指定 `backend="s1"`
## 实际验证
2026-07-29 使用真实 Fish API 构建了 24 条参考音并运行 A/B/C 三组:
## Validation
The regression tests are offline: they validate marker parsing and empty-segment handling without calling TTS APIs or ffmpeg concat.
```bash
# From the repository root, include dev tools for pytest
uv sync --locked --python 3.12 --extra ch9 --extra dev
# Activate it before changing directories:
# macOS/Linux:
source .venv/bin/activate
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
# Windows cmd: .venv\Scripts\activate.bat
cd chapter6/controllable-tts
python -m pytest -q
```
| 配置 | ffprobe 时长 |
| --- | ---: |
| A 无控制标记 | 5.355s |
| B 单一参考音克隆 | 5.904s |
| C 24 条参考库 | 8.305s |
脱敏证据在 `validation/latest.json`,包含 provider=`Fish Audio`、backend=`s1`、24 条库维度、解析轨迹、每段采用的 reference SHA-256 和输出 ffprobe 信息。生成音频在 `output/`,API key 与用户标识不会写入证据。
`python evaluate_audio_quality.py` 会把 A/B/C 隐去配置名称,以三种轮换顺序交给真实音频理解模型直接聆听;支持 Gemini、OpenRouter 音频路由、DashScope Omni 和 Mistral Voxtral,并保存实际成功的 provider/model 及失败的前置尝试。每次都按自然度、情绪匹配、思考停顿、音色一致性和真人客服感五维评分,理由必须引用可听见证据;三次位置平衡用于降低顺序偏差。结果写入 `validation/audio_quality_study.json`。这是多模态模型听测,不冒充真人 MOS 面板。
`python validate_artifacts.py` 会重新核对 24 条参考音的 hash/时长、A/B/C 输出媒体、正文示例的三次路由,以及听测的三种排列、逐项证据、音频 hash 和重算聚合结果,不会再次调用 API。严格审计写入 `validation/acceptance.json`。本次构建与 A/B/C 运行估计产生 30 次 Fish 请求(24+1+1+4);SDK 未返回逐请求美元费用。验收把“实验已经完整执行”和“正文主观排序是否复现”分开报告,因此真实负结果也不会被伪装成未运行。
2026-07-30 的真实听测使用 Mistral `voxtral-small-latest`。三次轮换位置后,多参考 C 组总均分 4.60、真人客服感 4.67,均为三组最高,支持“多参考更接近真人客服”;但完整的 `C > B > A` 排序没有复现:无标记 A 为 3.93,单参考 B 为 3.20。正式结论因此是“C 的主要优势复现,B 优于 A 未复现”,而不是把部分正结果改写为全部成功。逐次匿名映射、原始理由和聚合结果见 `validation/audio_quality_study.json`
```bash
pytest -q
```
---
## English
This is real Fish Audio S1 zero-shot voice cloning. A builder renders a same-speaker 4×3×2 reference library, hashes all 24 clips, and the runtime selects those real clips through inline `ReferenceAudio`. Native S1 `(sighing)`, `(chuckling)`, `(gasping)`, and `(uncertain)` controls replace the former OpenAI/onomatopoeia approximation. `demo.py` produces and records the required no-marker, single-reference, and 24-reference comparison.
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
import argparse
import os
from pathlib import Path
from dotenv import load_dotenv
from voice_library import DEFAULT_LIBRARY_DIR, build_reference_library
load_dotenv(Path(__file__).parent / ".env")
parser = argparse.ArgumentParser(description="Build 24 real Fish Audio S1 reference clips")
parser.add_argument("--base-reference-id", default=os.getenv("FISH_BASE_REFERENCE_ID"))
parser.add_argument("--output-dir", type=Path, default=DEFAULT_LIBRARY_DIR)
args = parser.parse_args()
if not os.getenv("FISH_API_KEY") or not args.base_reference_id:
parser.error("Set FISH_API_KEY and FISH_BASE_REFERENCE_ID (a voice you are authorized to use)")
manifest = build_reference_library(os.environ["FISH_API_KEY"], args.base_reference_id, args.output_dir)
print(f"Built {len(manifest['profiles'])} Fish S1 reference clips in {args.output_dir}")
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Experiment 6-6: Fish Audio S1 + a 24-reference voice library."""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
from markup import parse
from tts import synth_direct_reference, synthesize_segments
from voice_library import DEFAULT_MANIFEST, load_voice_library
HERE = Path(__file__).parent
DEMO_TEXT = (
"[EMO:happy][SPEED:fast][STYLE:casual]太好了!您的订单已确认。"
"[THINKING]让我查一下发货时间。"
"[EMO:neutral][SPEED:normal][STYLE:formal]预计明天下午送达。"
)
def strip_markers(text: str) -> str:
return re.sub(r"\[[^]]*]|<[^>]+>", "", text).strip()
def probe(path: Path) -> dict:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration,size", "-of", "json", str(path)],
check=True, capture_output=True, text=True,
)
return json.loads(result.stdout)["format"]
def main() -> int:
parser = argparse.ArgumentParser(description="Actual Fish Audio S1 controllable TTS")
parser.add_argument("--text", default=DEMO_TEXT)
parser.add_argument("--manifest", default=str(DEFAULT_MANIFEST))
parser.add_argument("--output-dir", default=str(HERE / "output"))
parser.add_argument("--evidence", default=str(HERE / "validation" / "latest.json"))
args = parser.parse_args()
load_dotenv(HERE / ".env")
if not os.getenv("FISH_API_KEY"):
parser.error("Set FISH_API_KEY; this experiment has no substitute provider")
library = load_voice_library(args.manifest)
output = Path(args.output_dir)
output.mkdir(parents=True, exist_ok=True)
plain = strip_markers(args.text)
# A: no markers and no style-reference selection; direct S1 source voice.
a = output / "A_no_control_markers.mp3"
synth_direct_reference(plain, library["source_reference_id"], a)
# B: one zero-shot reference clip for the complete utterance.
b = output / "B_single_reference.mp3"
single = [dict(type="speech", text=plain, emotion="neutral", speed="normal", style="formal", emphasis=False)]
b_meta = synthesize_segments(single, b, output / ".tmp" / "B", manifest_path=args.manifest)
# C: parse markers and select among the 24 real reference clips. Native
# non-verbal markers are sent to Fish S1, rather than replaced by words.
trace: list[str] = []
controlled = parse(args.text, trace=trace)
c = output / "C_24_reference_library.mp3"
c_meta = synthesize_segments(controlled, c, output / ".tmp" / "C", manifest_path=args.manifest)
evidence = {
"experiment": "6-6",
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"provider": "Fish Audio",
"backend": "s1",
"reference_profile_count": len(library["profiles"]),
"dimensions": library["dimensions"],
"input_with_markers": args.text,
"parse_trace": trace,
"outputs": {
"A_no_control_markers": {"path": str(a), "probe": probe(a)},
"B_single_reference": {"path": str(b), "segments": b_meta, "probe": probe(b)},
"C_24_reference_library": {"path": str(c), "segments": c_meta, "probe": probe(c)},
},
}
evidence_path = Path(args.evidence)
evidence_path.parent.mkdir(parents=True, exist_ok=True)
evidence_path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2), encoding="utf-8")
for line in trace:
print(line)
print(f"Generated three real Fish S1 configurations; evidence: {evidence_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -0,0 +1,6 @@
# Fish Audio API: https://fish.audio/app/api-keys
FISH_API_KEY=your_fish_api_key
# A Fish voice you own or are authorized to clone. The builder uses this one
# source timbre to render all 24 emotion × speed × style reference clips.
FISH_BASE_REFERENCE_ID=your_authorized_reference_id
@@ -0,0 +1,482 @@
#!/usr/bin/env python3
"""Run a blinded, position-balanced audio study for Experiment 6-6.
The three clips already come from real Fish Audio S1 calls. This program asks a
real audio-capable Gemini model to listen to them in three different orders,
validates every returned score/evidence field, and writes a receipt without
persisting the API key. It evaluates the manuscript claim; it does not label an
LLM judgement as a human MOS study.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
HERE = Path(__file__).parent
OUTPUTS = {
"A_no_control_markers": HERE / "output" / "A_no_control_markers.mp3",
"B_single_reference": HERE / "output" / "B_single_reference.mp3",
"C_24_reference_library": HERE / "output" / "C_24_reference_library.mp3",
}
DIMENSIONS = (
"naturalness",
"expressive_fit",
"thinking_behavior",
"speaker_consistency",
"human_customer_service",
)
PREFERRED_MODELS = (
"gemini-3.5-flash",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-flash-latest",
)
PREFERRED_OPENROUTER_MODELS = (
"google/gemini-3.5-flash",
"google/gemini-2.5-pro",
"google/gemini-2.5-flash",
)
PERMUTATIONS = (
("A_no_control_markers", "B_single_reference", "C_24_reference_library"),
("B_single_reference", "C_24_reference_library", "A_no_control_markers"),
("C_24_reference_library", "A_no_control_markers", "B_single_reference"),
)
ALIASES = ("X", "Y", "Z")
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _http_json(
url: str,
*,
body: dict[str, Any] | None = None,
timeout: int = 120,
headers: dict[str, str] | None = None,
) -> dict[str, Any]:
request = urllib.request.Request(
url,
data=None if body is None else json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", **(headers or {})},
method="GET" if body is None else "POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read())
except urllib.error.HTTPError as exc:
# The provider response does not contain the key. Never include the
# request URL because the key is deliberately carried in its query.
detail = exc.read().decode("utf-8", "replace")[:2000]
raise RuntimeError(f"Provider HTTP {exc.code}: {detail}") from None
def resolve_model(api_key: str, requested: str | None) -> str:
if requested:
return requested.removeprefix("models/")
payload = _http_json(
"https://generativelanguage.googleapis.com/v1beta/models?key=" + api_key,
timeout=30,
)
available = {
str(item.get("name", "")).removeprefix("models/")
for item in payload.get("models", [])
if "generateContent" in (item.get("supportedGenerationMethods") or [])
}
for model in PREFERRED_MODELS:
if model in available:
return model
candidates = sorted(
model for model in available
if model and not any(term in model for term in ("image", "embedding", "tts"))
)
if not candidates:
raise RuntimeError("Gemini did not report an audio-judge-capable generateContent model")
return candidates[-1]
def resolve_openrouter_model(requested: str | None) -> str:
if requested:
return requested
payload = _http_json("https://openrouter.ai/api/v1/models", timeout=30)
models = payload.get("data") or []
available = {str(item.get("id", "")): item for item in models if isinstance(item, dict)}
for model in PREFERRED_OPENROUTER_MODELS:
item = available.get(model)
modalities = (item or {}).get("architecture", {}).get("input_modalities") or []
if item and (not modalities or "audio" in modalities):
return model
audio_google = sorted(
model for model, item in available.items()
if model.startswith("google/")
and "audio" in ((item.get("architecture") or {}).get("input_modalities") or [])
)
if not audio_google:
raise RuntimeError("OpenRouter did not report an audio-capable Google model")
return audio_google[-1]
def _prompt() -> str:
return (
"You are a strict bilingual speech-quality evaluator. Listen directly to the three "
"Chinese customer-service clips supplied after this instruction. Their anonymous labels "
"and order are X, Y, Z. All aim to express: 太好了!您的订单已确认。让我查一下发货时间。"
"预计明天下午送达。 Some versions may add a natural thinking filler or pause. Do not infer "
"which synthesis configuration produced a clip. Score each clip independently from 1 "
"(poor) to 5 (excellent) on exactly these dimensions: naturalness; expressive_fit "
"(happy confirmation followed by thoughtful lookup and neutral delivery); "
"thinking_behavior (whether any pause/filler is natural and useful, not merely whether it "
"exists); speaker_consistency; human_customer_service. Every dimension must include one "
"specific audible observation. Then rank X/Y/Z best to worst, with no ties. Return only JSON "
"with this shape: {\"clips\":{\"X\":{\"naturalness\":{\"score\":1,\"reason\":\"...\"},"
"\"expressive_fit\":{...},\"thinking_behavior\":{...},\"speaker_consistency\":{...},"
"\"human_customer_service\":{...}},\"Y\":{...},\"Z\":{...}},"
"\"ranking\":[\"X\",\"Y\",\"Z\"],\"ranking_reason\":\"audible comparative evidence\"}."
)
def validate_response(payload: dict[str, Any]) -> dict[str, Any]:
clips = payload.get("clips")
if not isinstance(clips, dict) or set(clips) != set(ALIASES):
raise ValueError("judge response must contain exactly clips X, Y, and Z")
normalized: dict[str, Any] = {"clips": {}}
for alias in ALIASES:
clip = clips[alias]
if not isinstance(clip, dict) or set(clip) != set(DIMENSIONS):
raise ValueError(f"clip {alias} must contain exactly the five rubric dimensions")
normalized["clips"][alias] = {}
for dimension in DIMENSIONS:
item = clip[dimension]
if not isinstance(item, dict):
raise ValueError(f"{alias}.{dimension} must be an object")
score = item.get("score")
reason = item.get("reason")
if isinstance(score, bool) or not isinstance(score, int) or not 1 <= score <= 5:
raise ValueError(f"{alias}.{dimension}.score must be an integer from 1 to 5")
if not isinstance(reason, str) or not reason.strip():
raise ValueError(f"{alias}.{dimension}.reason must contain audible evidence")
normalized["clips"][alias][dimension] = {"score": score, "reason": reason.strip()}
ranking = payload.get("ranking")
if not isinstance(ranking, list) or len(ranking) != 3 or set(ranking) != set(ALIASES):
raise ValueError("ranking must contain X, Y, Z exactly once")
ranking_reason = payload.get("ranking_reason")
if not isinstance(ranking_reason, str) or not ranking_reason.strip():
raise ValueError("ranking_reason must contain audible comparative evidence")
normalized["ranking"] = ranking
normalized["ranking_reason"] = ranking_reason.strip()
return normalized
def _parse_judge_text(text: str) -> dict[str, Any]:
if not text:
raise RuntimeError("Audio judge returned no text")
if text.startswith("```"):
lines = text.splitlines()
if lines and lines[0].strip() in ("```", "```json"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines).strip()
try:
return validate_response(json.loads(text))
except (json.JSONDecodeError, ValueError) as exc:
raise RuntimeError(
f"Audio judge returned an invalid quality-study response: {exc}; "
f"response excerpt={text[:3000]!r}"
) from None
def judge_once(
api_key: str,
model: str,
permutation: tuple[str, str, str],
*,
provider: str = "gemini",
) -> dict[str, Any]:
parts: list[dict[str, Any]] = [{"text": _prompt()}]
for alias, name in zip(ALIASES, permutation):
parts.append({"text": f"Anonymous clip {alias}:"})
parts.append({
"inline_data": {
"mime_type": "audio/mpeg",
"data": base64.b64encode(OUTPUTS[name].read_bytes()).decode("ascii"),
}
})
if provider == "gemini":
body = {
"contents": [{"parts": parts}],
"generationConfig": {"temperature": 0.0, "responseMimeType": "application/json"},
}
response = _http_json(
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}",
body=body,
)
candidates = response.get("candidates") or []
response_parts = ((candidates[0].get("content") or {}).get("parts") or []) if candidates else []
text = "".join(str(part.get("text", "")) for part in response_parts).strip()
if not text:
raise RuntimeError(f"Gemini returned no judge text: {response.get('promptFeedback') or response}")
return _parse_judge_text(text)
if provider == "dashscope":
native_parts: list[dict[str, Any]] = [{"text": _prompt()}]
for alias, name in zip(ALIASES, permutation):
native_parts.append({"text": f"Anonymous clip {alias}:"})
native_parts.append({
"audio": "data:audio/mpeg;base64," + base64.b64encode(OUTPUTS[name].read_bytes()).decode("ascii")
})
response = _http_json(
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
body={
"model": model,
"input": {"messages": [{"role": "user", "content": native_parts}]},
"parameters": {"result_format": "message", "temperature": 0.0, "text_only": True},
},
headers={"Authorization": "Bearer " + api_key},
)
choices = (response.get("output") or {}).get("choices") or []
message_content = ((choices[0].get("message") or {}).get("content")) if choices else None
if isinstance(message_content, list):
text = "".join(str(item.get("text", "")) for item in message_content if isinstance(item, dict))
else:
text = str(message_content or "")
return _parse_judge_text(text.strip())
if provider == "mistral":
mistral_content: list[dict[str, Any]] = [{"type": "text", "text": _prompt()}]
for alias, name in zip(ALIASES, permutation):
mistral_content.append({"type": "text", "text": f"Anonymous clip {alias}:"})
mistral_content.append({
"type": "input_audio",
"input_audio": "data:audio/mpeg;base64," + base64.b64encode(
OUTPUTS[name].read_bytes()
).decode("ascii"),
})
response = _http_json(
"https://api.mistral.ai/v1/chat/completions",
body={
"model": model,
"temperature": 0.0,
"response_format": {"type": "json_object"},
"messages": [{"role": "user", "content": mistral_content}],
},
headers={"Authorization": "Bearer " + api_key},
)
choices = response.get("choices") or []
message_content = ((choices[0].get("message") or {}).get("content")) if choices else None
if isinstance(message_content, list):
text = "".join(str(item.get("text", "")) for item in message_content if isinstance(item, dict))
else:
text = str(message_content or "")
return _parse_judge_text(text.strip())
if provider != "openrouter":
raise ValueError(f"unsupported audio judge provider: {provider}")
content: list[dict[str, Any]] = [{"type": "text", "text": _prompt()}]
for alias, name in zip(ALIASES, permutation):
content.append({"type": "text", "text": f"Anonymous clip {alias}:"})
content.append({
"type": "input_audio",
"input_audio": {
"data": base64.b64encode(OUTPUTS[name].read_bytes()).decode("ascii"),
"format": "mp3",
},
})
response = _http_json(
"https://openrouter.ai/api/v1/chat/completions",
body={
"model": model,
"temperature": 0.0,
"response_format": {"type": "json_object"},
"messages": [{"role": "user", "content": content}],
},
headers={"Authorization": "Bearer " + api_key},
)
choices = response.get("choices") or []
message_content = ((choices[0].get("message") or {}).get("content")) if choices else None
if isinstance(message_content, list):
text = "".join(str(item.get("text", "")) for item in message_content if isinstance(item, dict))
else:
text = str(message_content or "")
return _parse_judge_text(text.strip())
def aggregate(passes: list[dict[str, Any]]) -> dict[str, Any]:
scores = {name: {dimension: [] for dimension in DIMENSIONS} for name in OUTPUTS}
rank_points = {name: 0 for name in OUTPUTS}
for run in passes:
alias_to_name = run["alias_to_configuration"]
response = run["response"]
for alias, clip in response["clips"].items():
name = alias_to_name[alias]
for dimension, item in clip.items():
scores[name][dimension].append(item["score"])
for points, alias in zip((3, 2, 1), response["ranking"]):
rank_points[alias_to_name[alias]] += points
configurations: dict[str, Any] = {}
for name, dimensions in scores.items():
means = {dimension: sum(values) / len(values) for dimension, values in dimensions.items()}
configurations[name] = {
"dimension_means": means,
"overall_mean": sum(means.values()) / len(means),
"rank_points": rank_points[name],
}
ordered = sorted(
configurations,
key=lambda name: (configurations[name]["rank_points"], configurations[name]["overall_mean"]),
reverse=True,
)
a, b, c = (configurations[name] for name in OUTPUTS)
ordering_reproduced = ordered == [
"C_24_reference_library", "B_single_reference", "A_no_control_markers"
] and c["overall_mean"] > b["overall_mean"] > a["overall_mean"]
near_human_supported = c["dimension_means"]["human_customer_service"] >= 4.0
return {
"configurations": configurations,
"aggregate_ranking": ordered,
"expected_manuscript_ranking": [
"C_24_reference_library", "B_single_reference", "A_no_control_markers"
],
"manuscript_quality_ordering_reproduced": ordering_reproduced,
"near_human_customer_service_supported": near_human_supported,
"manuscript_quality_claim_reproduced": ordering_reproduced and near_human_supported,
}
def validate_study(study: dict[str, Any]) -> None:
if study.get("schema_version") != 1 or study.get("experiment") != "6-6":
raise ValueError("unexpected quality-study schema or experiment")
if study.get("study_design", {}).get("judge_type") != "multimodal_llm_not_human_mos":
raise ValueError("study must identify its judge type honestly")
passes = study.get("passes")
if not isinstance(passes, list) or len(passes) != 3:
raise ValueError("quality study requires three position-balanced passes")
seen = []
for run in passes:
mapping = run.get("alias_to_configuration")
if not isinstance(mapping, dict) or set(mapping) != set(ALIASES) or set(mapping.values()) != set(OUTPUTS):
raise ValueError("each pass must map X/Y/Z to all three configurations")
seen.append(tuple(mapping[alias] for alias in ALIASES))
validate_response(run.get("response") or {})
if tuple(seen) != PERMUTATIONS:
raise ValueError("quality-study passes are not the required balanced permutations")
hashes = study.get("audio_sha256") or {}
if hashes != {name: sha256(path) for name, path in OUTPUTS.items()}:
raise ValueError("quality-study audio hashes do not match current comparison clips")
if study.get("aggregate") != aggregate(passes):
raise ValueError("quality-study aggregate does not recompute from raw judge passes")
def main() -> int:
parser = argparse.ArgumentParser(description="Blinded real-API audio study for Experiment 6-6")
parser.add_argument("--model", help="Gemini generateContent model; default probes available models")
parser.add_argument(
"--provider", choices=("auto", "gemini", "openrouter", "dashscope", "mistral"), default="auto",
help="audio judge transport; auto tries all configured audio-capable providers",
)
parser.add_argument("--output", default=str(HERE / "validation" / "audio_quality_study.json"))
args = parser.parse_args()
load_dotenv(HERE / ".env")
gemini_key = (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "").strip()
openrouter_key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
dashscope_key = (os.getenv("DASHSCOPE_API_KEY") or "").strip()
mistral_key = (os.getenv("MISTRAL_API_KEY") or "").strip()
for path in OUTPUTS.values():
if not path.is_file() or path.stat().st_size <= 1000:
parser.error(f"Missing real comparison audio: {path}")
provider_attempts: list[dict[str, Any]] = []
candidates: list[tuple[str, str, str]] = []
if args.provider in ("auto", "gemini") and gemini_key:
try:
candidates.append(("gemini", gemini_key, resolve_model(gemini_key, args.model)))
except RuntimeError as exc:
provider_attempts.append({"provider": "Google Gemini API", "status": "unavailable", "error": str(exc)})
if args.provider == "gemini":
raise
if args.provider in ("auto", "openrouter") and openrouter_key:
candidates.append(("openrouter", openrouter_key, resolve_openrouter_model(args.model)))
if args.provider in ("auto", "dashscope") and dashscope_key:
candidates.append(("dashscope", dashscope_key, args.model or "qwen3-omni-flash"))
if args.provider in ("auto", "mistral") and mistral_key:
candidates.append(("mistral", mistral_key, args.model or "voxtral-small-latest"))
if not candidates:
parser.error("No configured Gemini, OpenRouter, DashScope, or Mistral audio credential is available")
def run_passes(selected_provider: str, selected_key: str, selected_model: str):
completed = []
for permutation in PERMUTATIONS:
mapping = dict(zip(ALIASES, permutation))
completed.append({
"alias_to_configuration": mapping,
"response": judge_once(
selected_key, selected_model, permutation, provider=selected_provider
),
})
return completed
passes = None
provider = model = ""
provider_names = {
"gemini": "Google Gemini API",
"openrouter": "OpenRouter audio route",
"dashscope": "Alibaba DashScope multimodal API",
"mistral": "Mistral Voxtral API",
}
last_error = None
for candidate_provider, candidate_key, candidate_model in candidates:
try:
passes = run_passes(candidate_provider, candidate_key, candidate_model)
provider, model = candidate_provider, candidate_model
break
except RuntimeError as exc:
last_error = exc
provider_attempts.append({
"provider": provider_names[candidate_provider],
"model": candidate_model,
"status": "unavailable",
"error": str(exc),
})
if args.provider != "auto":
raise
if passes is None:
raise RuntimeError(f"All configured audio judges failed; last error: {last_error}")
study = {
"schema_version": 1,
"experiment": "6-6",
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"provider": provider_names[provider],
"model": model,
"provider_attempts": provider_attempts,
"study_design": {
"judge_type": "multimodal_llm_not_human_mos",
"blinded_configuration_labels": True,
"position_balanced": True,
"passes": 3,
"temperature": 0.0,
"dimensions": list(DIMENSIONS),
},
"audio_sha256": {name: sha256(path) for name, path in OUTPUTS.items()},
"passes": passes,
"aggregate": aggregate(passes),
"limitations": [
"This is a real multimodal-model listening study, not a human MOS panel.",
"Three position-balanced passes reduce order bias but share one judge model.",
],
}
validate_study(study)
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(study, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"output": str(output), "model": model, **study["aggregate"]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+224
View File
@@ -0,0 +1,224 @@
"""
控制标记解析器(Control Markup Parser
========================================
把带控制标记的文本解析成一串「片段」,每个片段要么是一段需要用某条参考语音
合成的语音(speech),要么是一段静音停顿(silence)。这一步对应书中「执行层
解析标记并映射到对应的参考语音」。
支持两类标记:
1) 状态标记(持续生效,直到被下一个同类标记改变)
[EMO:neutral|happy|frustrated|thinking] 或 [情感=中性|高兴|沮丧|思考]
[SPEED:normal|fast|slow] / [SPEED:0.8x] 或 [语速=正常|快|慢]
[STYLE:formal|casual] 或 [风格=正式|轻松]
2) 内联标记(一次性事件,插入停顿 / 填充音 / 非语言音,或临时改变状态)
[THINKING] 思考停顿 + 迟疑语气(=情绪思考/慢速/正式,并插入停顿)
[SEARCHING] 搜索性停顿(同上,停顿略短)
[PAUSE] / <pause> / [停顿] 插入停顿
[BREATH] / <breath> Fish S1 原生吸气声
[SIGH] / <sigh> Fish S1 原生叹气声
[LAUGH:small] / [LAUGH] / <laugh> Fish S1 原生轻笑声
<emphasis>...</emphasis> / [强调]...[/强调] 对包裹的文本加重强调
非语言片段会保留为 S1 的 `(gasping)` / `(sighing)` / `(chuckling)` 原生标记,
由 Fish Audio 直接合成声音,而不是把拟声文字念出来。
"""
import re
# 中文取值 -> 英文维度值的别名映射
_EMO_ALIAS = {
"中性": "neutral", "高兴": "happy", "开心": "happy", "兴奋": "happy",
"沮丧": "frustrated", "无奈": "frustrated", "思考": "thinking",
}
_SPEED_ALIAS = {"正常": "normal", "": "fast", "快速": "fast", "": "slow", "慢速": "slow"}
_STYLE_ALIAS = {"正式": "formal", "轻松": "casual", "随意": "casual"}
# 各内联事件插入的停顿时长(毫秒)
PAUSE_MS = 500
BREATH_MS = 400
THINKING_MS = 1200
SEARCHING_MS = 700
SIGH_TAIL_MS = 300
def _norm(value: str, alias: dict) -> str:
v = value.strip()
return alias.get(v, v.lower())
class Segment(dict):
"""一个片段:type='speech'(text, emotion, speed, style, emphasis) 或 type='silence'(ms)。"""
def parse(text: str, trace: list | None = None):
"""
解析带控制标记的文本,返回片段列表。
若传入 tracelist),会把「标记 -> 动作」的解析过程逐条记入,便于打印。
"""
def log(msg):
if trace is not None:
trace.append(msg)
# 当前状态(状态标记会持续改变它)
state = {"emotion": "neutral", "speed": "normal", "style": "formal", "emphasis": False}
segments: list[Segment] = []
buf = [] # 累积当前状态下的普通文本
def flush():
"""把缓冲区的普通文本作为一个 speech 片段输出。"""
s = "".join(buf).strip()
buf.clear()
if s:
segments.append(Segment(type="speech", text=s, **state))
def add_silence(ms, why):
flush()
segments.append(Segment(type="silence", ms=ms))
log(f" {why:22s} -> 插入静音 {ms}ms")
def add_speech_token(token, emotion, speed, style, why):
"""Insert a Fish S1 native non-verbal marker as a speech segment."""
flush()
segments.append(Segment(type="speech", text=token, emotion=emotion,
speed=speed, style=style, emphasis=False))
log(f" {why:22s} -> Fish S1 原生标记 '{token}' (情绪={emotion},语速={speed})")
def set_state(**kw):
flush() # 状态改变前,先把旧状态的文本收尾
for k, v in kw.items():
state[k] = v
# 用一个总正则切出所有 [..] 与 <..> 标记,其余为普通文本
parts = re.split(r"(\[[^\]]*\]|<[^>]+>)", text)
for part in parts:
if not part:
continue
if not re.fullmatch(r"\[[^\]]*\]|<[^>]+>", part):
buf.append(part) # 普通文本
continue
m = part # 标记原文
inner = m[1:-1].strip()
# --- 状态标记:EMO / SPEED / STYLE(英文冒号式 或 中文等号式) ---
km = re.match(r"(?i)^(EMO|SPEED|STYLE)\s*:\s*(.+)$", inner)
cm = re.match(r"^(情感|语速|风格)\s*=\s*(.+)$", inner)
if km:
key, val = km.group(1).upper(), km.group(2)
elif cm:
key = {"情感": "EMO", "语速": "SPEED", "风格": "STYLE"}[cm.group(1)]
val = cm.group(2)
else:
key = val = None
if key == "EMO":
e = _norm(val, _EMO_ALIAS)
set_state(emotion=e)
log(f" {m:22s} -> 情绪 = {e}")
continue
if key == "SPEED":
raw = val.strip()
v = raw.lower().replace("x", "") # 兼容 0.8x
# 先认英文取值(normal/fast/slow),再认中文别名(正常/快/慢)
if v in ("normal", "fast", "slow"):
s = v
elif raw in _SPEED_ALIAS:
s = _SPEED_ALIAS[raw]
else:
# 数字型(如 0.8)就近映射到 fast/slow/normal,仅用于展示
try:
f = float(v)
s = "fast" if f > 1.05 else ("slow" if f < 0.95 else "normal")
except ValueError:
s = "normal"
set_state(speed=s)
log(f" {m:22s} -> 语速 = {s}")
continue
if key == "STYLE":
st = _norm(val, _STYLE_ALIAS)
set_state(style=st)
log(f" {m:22s} -> 风格 = {st}")
continue
# --- 强调包裹 ---
low = inner.lower()
if low in ("emphasis", "强调"):
set_state(emphasis=True)
log(f" {m:22s} -> 开启强调")
continue
if low in ("/emphasis", "/强调"):
set_state(emphasis=False)
log(f" {m:22s} -> 关闭强调")
continue
# --- 内联事件标记 ---
tag = low.split(":")[0] # laugh:small -> laugh
if tag == "thinking":
set_state(emotion="thinking", speed="slow", style="formal")
log(f" {m:22s} -> 切换到 思考/慢速/正式 参考语音")
add_silence(THINKING_MS, "[THINKING] 停顿")
add_speech_token("(uncertain)嗯……", "thinking", "slow", "formal", "[THINKING] 填充音")
continue
if tag == "searching":
set_state(emotion="thinking", speed="slow", style="formal")
log(f" {m:22s} -> 切换到 思考/慢速/正式 参考语音")
add_silence(SEARCHING_MS, "[SEARCHING] 停顿")
add_speech_token("(uncertain)那个……", "thinking", "slow", "formal", "[SEARCHING] 填充音")
continue
if tag in ("pause", "停顿"):
add_silence(PAUSE_MS, m)
continue
if tag in ("breath", "换气"):
add_speech_token("(gasping)", state["emotion"], state["speed"], state["style"], m)
continue
if tag == "sigh":
add_speech_token("(sighing)", "frustrated", "slow", "formal", m)
segments.append(Segment(type="silence", ms=SIGH_TAIL_MS))
continue
if tag == "laugh":
add_speech_token("(chuckling)", "happy", "fast", "casual", m)
continue
# 未知标记:忽略但记录
log(f" {m:22s} -> [未知标记,已忽略]")
flush()
return segments
# ---------------------------------------------------------------------------
# 控制标记 -> 动作 的静态映射表(离线可查,供 demo.py --dump-mapping 打印)
# 这是「书中控制标记 -> 参考语音 / 非语言音」映射关系的单一事实来源。
# ---------------------------------------------------------------------------
# (类别, 标记写法, 中文写法, 映射到的动作)
MARKER_REFERENCE = [
("状态", "[EMO:neutral|happy|frustrated|thinking]", "[情感=中性|高兴|沮丧|思考]",
"切换情绪维度,选择参考语音"),
("状态", "[SPEED:normal|fast|slow] / [SPEED:0.8x]", "[语速=正常|快|慢]",
"切换语速维度(数字型就近映射到 fast/slow/normal"),
("状态", "[STYLE:formal|casual]", "[风格=正式|轻松]", "切换口吻维度"),
("内联", "[THINKING]", "", "切到「思考/慢速/正式」参考语音 + 插入 500ms 停顿"),
("内联", "[SEARCHING]", "", "切到「思考/慢速/正式」参考语音 + 插入 400ms 停顿"),
("内联", "[PAUSE] / <pause>", "[停顿]", "插入 500ms 静音"),
("内联", "[BREATH] / <breath>", "[换气]", "插入 400ms 换气停顿"),
("内联", "[SIGH] / <sigh>", "", "叹气拟声词「唉——」(沮丧音色) + 300ms 停顿"),
("内联", "[LAUGH:small] / [LAUGH] / <laugh>", "", "轻笑拟声词「哈哈,」(高兴音色)"),
("内联", "<emphasis>…</emphasis>", "[强调]…[/强调]", "对包裹文本追加「加重强调」提示词"),
]
def format_marker_reference() -> str:
"""把 MARKER_REFERENCE 渲染成可打印的对齐表格字符串。"""
lines = [f"{'类别':<4} {'标记写法':<40} {'中文写法':<24} 动作", "-" * 100]
for cat, mark, zh, action in MARKER_REFERENCE:
lines.append(f"{cat:<4} {mark:<40} {zh:<24} {action}")
return "\n".join(lines)
if __name__ == "__main__":
print("控制标记 -> 动作 映射表:\n")
print(format_marker_reference())
@@ -0,0 +1,263 @@
{
"backend": "s1",
"source_reference_id": "6df3c1e14c9440e9ac978556536bf116",
"dimensions": {
"emotion": [
"neutral",
"happy",
"frustrated",
"thinking"
],
"speed": [
"normal",
"fast",
"slow"
],
"style": [
"formal",
"casual"
]
},
"profiles": {
"neutral_normal_formal": {
"emotion": "neutral",
"speed": "normal",
"style": "formal",
"path": "neutral_normal_formal.mp3",
"transcript": "您好,我已收到您的请求,现在为您核对详细信息。",
"s1_reference_prompt": "(calm)(confident)您好,我已收到您的请求,现在为您核对详细信息。",
"duration_seconds": 4.624,
"sha256": "2cf4c64bb8222399ab54c63cc41049c572ea5f1b819ffdcbc496014daae004c0"
},
"neutral_normal_casual": {
"emotion": "neutral",
"speed": "normal",
"style": "casual",
"path": "neutral_normal_casual.mp3",
"transcript": "你好呀,我收到你的请求啦,现在帮你看看具体情况。",
"s1_reference_prompt": "(calm)(relaxed)你好呀,我收到你的请求啦,现在帮你看看具体情况。",
"duration_seconds": 4.206,
"sha256": "5849473deb7f0415a57fa96e5365e76e5850a9c5c598c3926c9f4feba77d1ce5"
},
"neutral_fast_formal": {
"emotion": "neutral",
"speed": "fast",
"style": "formal",
"path": "neutral_fast_formal.mp3",
"transcript": "您好,我已经收到您的请求,现在马上为您核对所有详细信息,请稍等片刻。",
"s1_reference_prompt": "(calm)(confident)您好,我已经收到您的请求,现在马上为您核对所有详细信息,请稍等片刻。",
"duration_seconds": 5.146,
"sha256": "58870daca86d21ca7f8a785342ade7212358537cc67bc7168d4f9cb82df1465a"
},
"neutral_fast_casual": {
"emotion": "neutral",
"speed": "fast",
"style": "casual",
"path": "neutral_fast_casual.mp3",
"transcript": "你好呀,我已经收到你的请求啦,现在马上帮你看看全部具体情况,稍等一下。",
"s1_reference_prompt": "(calm)(relaxed)你好呀,我已经收到你的请求啦,现在马上帮你看看全部具体情况,稍等一下。",
"duration_seconds": 5.172,
"sha256": "961886b92e69da009d07e9ea9a0c0f8d67050d378fb2ffd50850876ebcd6ba16"
},
"neutral_slow_formal": {
"emotion": "neutral",
"speed": "slow",
"style": "formal",
"path": "neutral_slow_formal.mp3",
"transcript": "您好,我正在核对信息,请稍等。",
"s1_reference_prompt": "(calm)(confident)您好,我正在核对信息,请稍等。",
"duration_seconds": 4.467,
"sha256": "fd775e92e16733b8ac90885505293d20c64dcb6f421fec3aaf1eb380561aaf06"
},
"neutral_slow_casual": {
"emotion": "neutral",
"speed": "slow",
"style": "casual",
"path": "neutral_slow_casual.mp3",
"transcript": "你好呀,我正在帮你看看,稍等。",
"s1_reference_prompt": "(calm)(relaxed)你好呀,我正在帮你看看,稍等。",
"duration_seconds": 4.545,
"sha256": "6599f401b009ad55c5c17b4509d21a7ca4c83af50bf9e1c036ad496d015a966f"
},
"happy_normal_formal": {
"emotion": "happy",
"speed": "normal",
"style": "formal",
"path": "happy_normal_formal.mp3",
"transcript": "您好,我已收到您的请求,现在为您核对详细信息。",
"s1_reference_prompt": "(happy)(confident)您好,我已收到您的请求,现在为您核对详细信息。",
"duration_seconds": 5.12,
"sha256": "e0c6d9b5402d9ed7cc1375411951c22f09bd118a88e5abfc96bf67a3b1d76ba1"
},
"happy_normal_casual": {
"emotion": "happy",
"speed": "normal",
"style": "casual",
"path": "happy_normal_casual.mp3",
"transcript": "你好呀,我收到你的请求啦,现在帮你看看具体情况。",
"s1_reference_prompt": "(happy)(relaxed)你好呀,我收到你的请求啦,现在帮你看看具体情况。",
"duration_seconds": 5.068,
"sha256": "c374978ccaf9947ffc1b52a17ecf29cc904e8753e4fe0336b7c2aefee1b0af1e"
},
"happy_fast_formal": {
"emotion": "happy",
"speed": "fast",
"style": "formal",
"path": "happy_fast_formal.mp3",
"transcript": "您好,我已经收到您的请求,现在马上为您核对所有详细信息,请稍等片刻。",
"s1_reference_prompt": "(happy)(confident)您好,我已经收到您的请求,现在马上为您核对所有详细信息,请稍等片刻。",
"duration_seconds": 6.739,
"sha256": "d3993a35b54c843c3a8d6470d8754f1b3344c60b460b9d11b6141761de31f987"
},
"happy_fast_casual": {
"emotion": "happy",
"speed": "fast",
"style": "casual",
"path": "happy_fast_casual.mp3",
"transcript": "你好呀,我已经收到你的请求啦,现在马上帮你看看全部具体情况,稍等一下。",
"s1_reference_prompt": "(happy)(relaxed)你好呀,我已经收到你的请求啦,现在马上帮你看看全部具体情况,稍等一下。",
"duration_seconds": 4.963,
"sha256": "0e96b8c6abe891c6292e6c7037aac67979ac407c3125301f5421afe35ce256d5"
},
"happy_slow_formal": {
"emotion": "happy",
"speed": "slow",
"style": "formal",
"path": "happy_slow_formal.mp3",
"transcript": "您好,我正在核对信息,请稍等。",
"s1_reference_prompt": "(happy)(confident)您好,我正在核对信息,请稍等。",
"duration_seconds": 5.068,
"sha256": "29545d9cc2fc4bba11dd9d4567569acdc96486317d86c665940833df333a2696"
},
"happy_slow_casual": {
"emotion": "happy",
"speed": "slow",
"style": "casual",
"path": "happy_slow_casual.mp3",
"transcript": "你好呀,我正在帮你看看,稍等。",
"s1_reference_prompt": "(happy)(relaxed)你好呀,我正在帮你看看,稍等。",
"duration_seconds": 4.31,
"sha256": "014864a698f1da3988895936238f0e538215d0fe4b0140c17d0eb339f0b23c02"
},
"frustrated_normal_formal": {
"emotion": "frustrated",
"speed": "normal",
"style": "formal",
"path": "frustrated_normal_formal.mp3",
"transcript": "您好,我已收到您的请求,现在为您核对详细信息。",
"s1_reference_prompt": "(frustrated)(confident)您好,我已收到您的请求,现在为您核对详细信息。",
"duration_seconds": 4.859,
"sha256": "8f510ebb97a2e31315029934015a109a438041870d651e7b078e9165746f4b6a"
},
"frustrated_normal_casual": {
"emotion": "frustrated",
"speed": "normal",
"style": "casual",
"path": "frustrated_normal_casual.mp3",
"transcript": "你好呀,我收到你的请求啦,现在帮你看看具体情况。",
"s1_reference_prompt": "(frustrated)(relaxed)你好呀,我收到你的请求啦,现在帮你看看具体情况。",
"duration_seconds": 4.702,
"sha256": "23cefb4913ba3a3a8ea78a9b221b3e88754e0d10166d382e947dcfa16aef9c2c"
},
"frustrated_fast_formal": {
"emotion": "frustrated",
"speed": "fast",
"style": "formal",
"path": "frustrated_fast_formal.mp3",
"transcript": "您好,我已经收到您的请求,现在马上为您核对所有详细信息,请稍等片刻。",
"s1_reference_prompt": "(frustrated)(confident)您好,我已经收到您的请求,现在马上为您核对所有详细信息,请稍等片刻。",
"duration_seconds": 5.616,
"sha256": "3c64faf47685da3e3ba08b93b48baf32249dbf074421f577ebb3b3da16d815f2"
},
"frustrated_fast_casual": {
"emotion": "frustrated",
"speed": "fast",
"style": "casual",
"path": "frustrated_fast_casual.mp3",
"transcript": "你好呀,我已经收到你的请求啦,现在马上帮你看看全部具体情况,稍等一下。",
"s1_reference_prompt": "(frustrated)(relaxed)你好呀,我已经收到你的请求啦,现在马上帮你看看全部具体情况,稍等一下。",
"duration_seconds": 5.355,
"sha256": "6d427ddd6874b1b80b18e0129712ca100865e3592efa0e142904f160305686da"
},
"frustrated_slow_formal": {
"emotion": "frustrated",
"speed": "slow",
"style": "formal",
"path": "frustrated_slow_formal.mp3",
"transcript": "您好,我正在核对信息,请稍等。",
"s1_reference_prompt": "(frustrated)(confident)您好,我正在核对信息,请稍等。",
"duration_seconds": 4.545,
"sha256": "aab5806b6b7bfb17d9d2a069b8b1fb19f01cdc026e04f2e28dc92be94940b575"
},
"frustrated_slow_casual": {
"emotion": "frustrated",
"speed": "slow",
"style": "casual",
"path": "frustrated_slow_casual.mp3",
"transcript": "你好呀,我正在帮你看看,稍等。",
"s1_reference_prompt": "(frustrated)(relaxed)你好呀,我正在帮你看看,稍等。",
"duration_seconds": 3.605,
"sha256": "490c56a2dbc01f08380693173af76e0fcb48dfb24e29f5f828f7709cbc67faf2"
},
"thinking_normal_formal": {
"emotion": "thinking",
"speed": "normal",
"style": "formal",
"path": "thinking_normal_formal.mp3",
"transcript": "您好,我已收到您的请求,现在为您核对详细信息。",
"s1_reference_prompt": "(uncertain)(confident)您好,我已收到您的请求,现在为您核对详细信息。",
"duration_seconds": 4.754,
"sha256": "9637338fcf8cb6d17febbe01b4eedbb06423637163681ca864748ebd29e289de"
},
"thinking_normal_casual": {
"emotion": "thinking",
"speed": "normal",
"style": "casual",
"path": "thinking_normal_casual.mp3",
"transcript": "你好呀,我收到你的请求啦,现在帮你看看具体情况。",
"s1_reference_prompt": "(uncertain)(relaxed)你好呀,我收到你的请求啦,现在帮你看看具体情况。",
"duration_seconds": 4.754,
"sha256": "49f77a6fc84657dbb45498d49aae40a0020fbf615622fcdacee686ff19231f5b"
},
"thinking_fast_formal": {
"emotion": "thinking",
"speed": "fast",
"style": "formal",
"path": "thinking_fast_formal.mp3",
"transcript": "您好,我已经收到您的请求,现在马上为您核对所有详细信息,请稍等片刻。",
"s1_reference_prompt": "(uncertain)(confident)您好,我已经收到您的请求,现在马上为您核对所有详细信息,请稍等片刻。",
"duration_seconds": 5.146,
"sha256": "5efd200c837b20b123ba019b893fd5d5ad2671188f94478dc66db512455d85ae"
},
"thinking_fast_casual": {
"emotion": "thinking",
"speed": "fast",
"style": "casual",
"path": "thinking_fast_casual.mp3",
"transcript": "你好呀,我已经收到你的请求啦,现在马上帮你看看全部具体情况,稍等一下。",
"s1_reference_prompt": "(uncertain)(relaxed)你好呀,我已经收到你的请求啦,现在马上帮你看看全部具体情况,稍等一下。",
"duration_seconds": 5.068,
"sha256": "72de0d846bf47a7754afb2c2bb66e20047d0ca62575d605df63546589a5f2260"
},
"thinking_slow_formal": {
"emotion": "thinking",
"speed": "slow",
"style": "formal",
"path": "thinking_slow_formal.mp3",
"transcript": "您好,我正在核对信息,请稍等。",
"s1_reference_prompt": "(uncertain)(confident)您好,我正在核对信息,请稍等。",
"duration_seconds": 4.467,
"sha256": "abef0a802382dad67b37e3447de08401a1d1cdb451420759853f4af9df234ac9"
},
"thinking_slow_casual": {
"emotion": "thinking",
"speed": "slow",
"style": "casual",
"path": "thinking_slow_casual.mp3",
"transcript": "你好呀,我正在帮你看看,稍等。",
"s1_reference_prompt": "(uncertain)(relaxed)你好呀,我正在帮你看看,稍等。",
"duration_seconds": 4.31,
"sha256": "4c1d61c92b4eebc2c050e7b21d138ffac4d7e0ba6449e0f4215db5e93c61d992"
}
}
}
@@ -0,0 +1,4 @@
fish-audio-sdk>=1.3.0,<2
python-dotenv>=1.0
pytest>=7.4
# System dependency: ffmpeg / ffprobe
+91
View File
@@ -0,0 +1,91 @@
import json
import subprocess
import pytest
from evaluate_audio_quality import DIMENSIONS, OUTPUTS, PERMUTATIONS, aggregate, validate_response
from markup import parse
from tts import concat_mp3, make_silence
from voice_library import EMOTIONS, SPEEDS, STYLES, load_voice_library
def test_native_s1_nonverbal_events_not_onomatopoeia():
segments = parse("[THINKING]好吧,[SIGH][LAUGH:small][BREATH]继续。")
texts = [s["text"] for s in segments if s["type"] == "speech"]
assert "(uncertain)嗯……" in texts
assert "(sighing)" in texts
assert "(chuckling)" in texts
assert "(gasping)" in texts
assert "哈哈," not in texts and "唉——" not in texts
@pytest.mark.parametrize(
"directory_name",
["speaker clips", "speaker's clips", "d'angelo's clips"],
)
def test_concat_handles_apostrophe_in_output_directory(tmp_path, directory_name):
"""FFconcat must preserve ordinary, single-quote, and multi-quote paths."""
output_dir = tmp_path / directory_name
output_dir.mkdir()
parts = [output_dir / "first.mp3", output_dir / "second.mp3"]
for part in parts:
make_silence(100, part)
output = output_dir / "joined.mp3"
concat_mp3(parts, output)
duration = float(subprocess.check_output([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(output),
], text=True).strip())
assert output.is_file()
assert duration >= 0.18
def test_library_requires_exact_cartesian_product(tmp_path):
manifest = tmp_path / "manifest.json"
manifest.write_text(json.dumps({"profiles": {}}))
with pytest.raises(ValueError, match="24"):
load_voice_library(manifest)
def test_dimensions_are_4_by_3_by_2():
assert len(EMOTIONS) * len(SPEEDS) * len(STYLES) == 24
def _judge_response(scores):
return {
"clips": {
alias: {
dimension: {"score": scores[alias], "reason": f"audible evidence for {alias}"}
for dimension in DIMENSIONS
}
for alias in ("X", "Y", "Z")
},
"ranking": sorted(("X", "Y", "Z"), key=scores.get, reverse=True),
"ranking_reason": "audible comparison",
}
def test_quality_response_rejects_bare_scores_without_audible_evidence():
response = _judge_response({"X": 1, "Y": 2, "Z": 3})
response["clips"]["X"]["naturalness"]["reason"] = ""
with pytest.raises(ValueError, match="audible evidence"):
validate_response(response)
def test_position_balanced_aggregate_maps_aliases_back_to_configurations():
# Each pass gives C=5, B=4, A=2 regardless of its anonymous position.
passes = []
score_by_name = {
"A_no_control_markers": 2,
"B_single_reference": 4,
"C_24_reference_library": 5,
}
for permutation in PERMUTATIONS:
mapping = dict(zip(("X", "Y", "Z"), permutation))
scores = {alias: score_by_name[name] for alias, name in mapping.items()}
passes.append({"alias_to_configuration": mapping, "response": _judge_response(scores)})
result = aggregate(passes)
assert result["aggregate_ranking"] == list(reversed(list(OUTPUTS)))
assert result["manuscript_quality_claim_reproduced"] is True
@@ -0,0 +1,7 @@
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
+124
View File
@@ -0,0 +1,124 @@
"""Fish Audio S1 zero-shot cloning execution layer."""
from __future__ import annotations
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from voice_library import DEFAULT_MANIFEST, load_voice_library, profile_key
MODEL = "s1"
def _session():
from fish_audio_sdk import Session
key = os.getenv("FISH_API_KEY")
if not key:
raise RuntimeError("Fish S1 synthesis requires FISH_API_KEY")
return Session(key)
def synth_speech(
text: str,
emotion: str,
speed: str,
style: str,
emphasis: bool,
out_path: str | Path,
*,
voice_library: dict[str, Any],
) -> dict[str, Any]:
"""Clone from the selected real reference clip using Fish S1."""
from fish_audio_sdk import Prosody, ReferenceAudio, TTSRequest
key = profile_key(emotion, speed, style)
profile = voice_library["profiles"][key]
reference_path = Path(profile["absolute_path"])
# S1 supports native parentheses markers, including real non-verbal sounds.
fish_text = f"(emphasis){text}" if emphasis else text
request = TTSRequest(
text=fish_text,
references=[ReferenceAudio(audio=reference_path.read_bytes(), text=profile["transcript"])],
format="mp3",
prosody=Prosody(speed=1.0, volume=0),
)
Path(out_path).write_bytes(b"".join(_session().tts(request, backend=MODEL)))
return {
"model": MODEL,
"provider": "Fish Audio",
"profile": key,
"reference_path": reference_path.name,
"reference_sha256": profile["sha256"],
"fish_text": fish_text,
}
def synth_direct_reference(text: str, reference_id: str, out_path: str | Path) -> dict[str, Any]:
"""Fish S1 without the 24-clip control library (configuration A)."""
from fish_audio_sdk import TTSRequest
request = TTSRequest(text=text, reference_id=reference_id, format="mp3")
Path(out_path).write_bytes(b"".join(_session().tts(request, backend=MODEL)))
return {"provider": "Fish Audio", "model": MODEL, "reference_id": reference_id}
def make_silence(ms: int, out_path: str | Path) -> None:
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", f"{ms / 1000:.3f}", "-q:a", "9", str(out_path)],
check=True,
)
def concat_mp3(parts: list[Path], out_path: str | Path) -> None:
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as handle:
for part in parts:
escaped = part.resolve().as_posix().replace("'", "'\\''")
handle.write(f"file '{escaped}'\n")
list_path = handle.name
try:
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", list_path,
"-ar", "44100", "-ac", "1", "-b:a", "128k", str(out_path)],
check=True,
)
finally:
os.unlink(list_path)
def synthesize_segments(
segments,
out_path,
workdir,
*,
manifest_path: str | Path = DEFAULT_MANIFEST,
):
if not segments:
raise ValueError("No speech segments to synthesize")
library = load_voice_library(manifest_path)
workdir = Path(workdir)
workdir.mkdir(parents=True, exist_ok=True)
parts, info = [], []
for index, segment in enumerate(segments):
path = workdir / f"segment_{index:02d}.mp3"
if segment["type"] == "silence":
make_silence(segment["ms"], path)
meta = {"type": "silence", "ms": segment["ms"]}
else:
meta = synth_speech(
segment["text"], segment["emotion"], segment["speed"], segment["style"],
segment.get("emphasis", False), path, voice_library=library,
)
meta.update(type="speech", text=segment["text"])
parts.append(path)
info.append(meta)
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
if len(parts) == 1:
Path(out_path).write_bytes(parts[0].read_bytes())
else:
concat_mp3(parts, out_path)
return info
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Validate real Fish S1 Experiment 6-6 media without making new API calls."""
from __future__ import annotations
import hashlib
import json
import platform
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from evaluate_audio_quality import validate_study
HERE = Path(__file__).parent
MANIFEST = HERE / "reference_audio" / "manifest.json"
RUN = HERE / "validation" / "latest.json"
QUALITY_STUDY = HERE / "validation" / "audio_quality_study.json"
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def probe(path: Path) -> dict[str, float | int | str]:
raw = subprocess.check_output([
"ffprobe", "-v", "error", "-show_entries", "format=duration,size,format_name",
"-of", "json", str(path),
], text=True)
info = json.loads(raw)["format"]
return {"duration_seconds": float(info["duration"]), "size_bytes": int(info["size"]), "format": info["format_name"]}
def main() -> int:
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
run = json.loads(RUN.read_text(encoding="utf-8"))
profiles = manifest["profiles"]
reference_checks = []
for key, profile in sorted(profiles.items()):
path = HERE / "reference_audio" / profile["path"]
media = probe(path)
reference_checks.append({
"profile": key,
"path": str(path.relative_to(HERE)),
"exists": path.exists(),
"sha256": sha256(path),
"manifest_sha256": profile["sha256"],
"hash_matches": sha256(path) == profile["sha256"],
**media,
})
outputs = {}
for name, recorded in run["outputs"].items():
path = HERE / "output" / Path(recorded["path"]).name
outputs[name] = {
"path": str(path.relative_to(HERE)),
"sha256": sha256(path),
**probe(path),
}
dimensions = {
(profile["emotion"], profile["speed"], profile["style"])
for profile in profiles.values()
}
c_segments = run["outputs"]["C_24_reference_library"]["segments"]
routed_profiles = [segment["profile"] for segment in c_segments if segment.get("type") == "speech"]
required_routes = {"happy_fast_casual", "thinking_slow_formal", "neutral_normal_formal"}
gates = {
"fish_s1_provider_recorded": run.get("provider") == "Fish Audio" and run.get("backend") == "s1",
"same_authorized_source_reference": bool(manifest.get("source_reference_id")),
"exact_4x3x2_reference_library": len(profiles) == 24 and len(dimensions) == 24,
"all_reference_hashes_match": all(item["hash_matches"] for item in reference_checks),
"references_approximately_five_seconds": all(3.0 <= item["duration_seconds"] <= 7.0 for item in reference_checks),
"three_real_comparison_outputs": set(outputs) == {
"A_no_control_markers", "B_single_reference", "C_24_reference_library"
} and all(item["size_bytes"] > 1000 and item["duration_seconds"] > 0 for item in outputs.values()),
"required_marker_routes_exercised": required_routes.issubset(set(routed_profiles)),
"thinking_pause_1_to_2_seconds": any(
segment.get("type") == "silence" and 1000 <= segment.get("ms", 0) <= 2000
for segment in c_segments
),
"thinking_native_filler_exercised": any("(uncertain)" in segment.get("fish_text", "") for segment in c_segments),
}
quality_study = None
quality_study_valid = False
quality_study_error = None
if QUALITY_STUDY.is_file():
try:
quality_study = json.loads(QUALITY_STUDY.read_text(encoding="utf-8"))
validate_study(quality_study)
quality_study_valid = True
except (json.JSONDecodeError, OSError, ValueError) as exc:
quality_study_error = str(exc)
artifact = {
"schema_version": 3,
"experiment": "6-6",
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"artifact_generation": {
"recorded_timestamp_utc": run["timestamp_utc"],
"provider": run["provider"],
"backend": run["backend"],
"source_reference_id_sha256": hashlib.sha256(manifest["source_reference_id"].encode()).hexdigest(),
"source_reference_value_saved_in_manifest": True,
"estimated_paid_api_requests": 30,
"request_count_basis": "24 reference renders + A(1) + B(1) + C(4 speech segments); local silence is not an API call",
"provider_reported_cost_usd": None,
"cost_note": "Fish SDK responses did not expose monetary charges; consult the provider billing ledger.",
},
"validation_provenance": {
"platform": platform.platform(),
"python": platform.python_version(),
"manifest_sha256": sha256(MANIFEST),
"run_evidence_sha256": sha256(RUN),
"implementation_sha256": {
name: sha256(HERE / name) for name in (
"demo.py", "tts.py", "markup.py", "voice_library.py", "evaluate_audio_quality.py"
)
},
},
"reference_statistics": {
"count": len(reference_checks),
"minimum_duration_seconds": min(item["duration_seconds"] for item in reference_checks),
"maximum_duration_seconds": max(item["duration_seconds"] for item in reference_checks),
"mean_duration_seconds": sum(item["duration_seconds"] for item in reference_checks) / len(reference_checks),
},
"reference_checks": reference_checks,
"outputs": outputs,
"qualitative_study": {
"path": str(QUALITY_STUDY.relative_to(HERE)),
"present": QUALITY_STUDY.is_file(),
"valid": quality_study_valid,
"error": quality_study_error,
"judge_type": (quality_study or {}).get("study_design", {}).get("judge_type"),
"provider": (quality_study or {}).get("provider"),
"model": (quality_study or {}).get("model"),
"aggregate": (quality_study or {}).get("aggregate"),
"sha256": sha256(QUALITY_STUDY) if QUALITY_STUDY.is_file() else None,
},
"acceptance": {
"structural_and_media_gates": gates,
"structural_and_media_passed": all(gates.values()),
"qualitative_listening_study_present": quality_study_valid,
"qualitative_judge_is_human_mos": False,
"near_human_customer_service_claim_evaluated": quality_study_valid,
"manuscript_quality_claim_reproduced": (
quality_study.get("aggregate", {}).get("manuscript_quality_claim_reproduced")
if quality_study_valid else None
),
"experiment_execution_complete": all(gates.values()) and quality_study_valid,
"statement": (
"Real Fish S1 media and a schema-checked, position-balanced multimodal listening study "
"complete the A/B/C experiment. The saved result reports independently whether the "
"manuscript's subjective ordering reproduced; this is not a human MOS panel."
if quality_study_valid else
"Real Fish S1 media fulfills construction, but the blinded qualitative study is absent or invalid."
),
},
}
output = HERE / "validation" / "acceptance.json"
output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(output)
return 0 if artifact["acceptance"]["experiment_execution_complete"] else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,404 @@
{
"schema_version": 3,
"experiment": "6-6",
"timestamp_utc": "2026-07-30T05:30:13.308665+00:00",
"artifact_generation": {
"recorded_timestamp_utc": "2026-07-29T10:19:56.497167+00:00",
"provider": "Fish Audio",
"backend": "s1",
"source_reference_id_sha256": "bb2ec197d6276bfad81bbdbcec4473040861e6bc20caf53cf4d1f8184a7416be",
"source_reference_value_saved_in_manifest": true,
"estimated_paid_api_requests": 30,
"request_count_basis": "24 reference renders + A(1) + B(1) + C(4 speech segments); local silence is not an API call",
"provider_reported_cost_usd": null,
"cost_note": "Fish SDK responses did not expose monetary charges; consult the provider billing ledger."
},
"validation_provenance": {
"platform": "macOS-26.3-arm64-arm-64bit",
"python": "3.11.4",
"manifest_sha256": "24f6e1399376665f7f5c0282dbb77d81aa5e124cfb9df944b46b73692e4b5ad1",
"run_evidence_sha256": "de0e787c940cf387ac7dabb03125893deffb9eedb2c5f25d72654fe6829815c1",
"implementation_sha256": {
"demo.py": "73a87e6020910cc3b43a6ae7291defe39d9c715a964a04e8a674c4d0e4d02fe9",
"tts.py": "e7ce66c0aa2fc3b7a78530001623e5c68b78a5bf37aa97eb77203312f5a4b0c9",
"markup.py": "5230a2a178cedfe9b5b8d79e80912ce8aa6326a8e2e7d7fdda1b670c49c843ad",
"voice_library.py": "c1fb6720742ae49677226dab85ad2bf580e137ee040ba17c8730399fc6a5fd70",
"evaluate_audio_quality.py": "4fedd536432702c7d4eec9360fb3671461648ee5f316a494f55fafdc29fcafa1"
}
},
"reference_statistics": {
"count": 24,
"minimum_duration_seconds": 3.604813,
"maximum_duration_seconds": 6.7395,
"mean_duration_seconds": 4.858690416666666
},
"reference_checks": [
{
"profile": "frustrated_fast_casual",
"path": "reference_audio/frustrated_fast_casual.mp3",
"exists": true,
"sha256": "6d427ddd6874b1b80b18e0129712ca100865e3592efa0e142904f160305686da",
"manifest_sha256": "6d427ddd6874b1b80b18e0129712ca100865e3592efa0e142904f160305686da",
"hash_matches": true,
"duration_seconds": 5.355,
"size_bytes": 85680,
"format": "mp3"
},
{
"profile": "frustrated_fast_formal",
"path": "reference_audio/frustrated_fast_formal.mp3",
"exists": true,
"sha256": "3c64faf47685da3e3ba08b93b48baf32249dbf074421f577ebb3b3da16d815f2",
"manifest_sha256": "3c64faf47685da3e3ba08b93b48baf32249dbf074421f577ebb3b3da16d815f2",
"hash_matches": true,
"duration_seconds": 5.61625,
"size_bytes": 89860,
"format": "mp3"
},
{
"profile": "frustrated_normal_casual",
"path": "reference_audio/frustrated_normal_casual.mp3",
"exists": true,
"sha256": "23cefb4913ba3a3a8ea78a9b221b3e88754e0d10166d382e947dcfa16aef9c2c",
"manifest_sha256": "23cefb4913ba3a3a8ea78a9b221b3e88754e0d10166d382e947dcfa16aef9c2c",
"hash_matches": true,
"duration_seconds": 4.701938,
"size_bytes": 75231,
"format": "mp3"
},
{
"profile": "frustrated_normal_formal",
"path": "reference_audio/frustrated_normal_formal.mp3",
"exists": true,
"sha256": "8f510ebb97a2e31315029934015a109a438041870d651e7b078e9165746f4b6a",
"manifest_sha256": "8f510ebb97a2e31315029934015a109a438041870d651e7b078e9165746f4b6a",
"hash_matches": true,
"duration_seconds": 4.858688,
"size_bytes": 77739,
"format": "mp3"
},
{
"profile": "frustrated_slow_casual",
"path": "reference_audio/frustrated_slow_casual.mp3",
"exists": true,
"sha256": "490c56a2dbc01f08380693173af76e0fcb48dfb24e29f5f828f7709cbc67faf2",
"manifest_sha256": "490c56a2dbc01f08380693173af76e0fcb48dfb24e29f5f828f7709cbc67faf2",
"hash_matches": true,
"duration_seconds": 3.604813,
"size_bytes": 57677,
"format": "mp3"
},
{
"profile": "frustrated_slow_formal",
"path": "reference_audio/frustrated_slow_formal.mp3",
"exists": true,
"sha256": "aab5806b6b7bfb17d9d2a069b8b1fb19f01cdc026e04f2e28dc92be94940b575",
"manifest_sha256": "aab5806b6b7bfb17d9d2a069b8b1fb19f01cdc026e04f2e28dc92be94940b575",
"hash_matches": true,
"duration_seconds": 4.545188,
"size_bytes": 72723,
"format": "mp3"
},
{
"profile": "happy_fast_casual",
"path": "reference_audio/happy_fast_casual.mp3",
"exists": true,
"sha256": "0e96b8c6abe891c6292e6c7037aac67979ac407c3125301f5421afe35ce256d5",
"manifest_sha256": "0e96b8c6abe891c6292e6c7037aac67979ac407c3125301f5421afe35ce256d5",
"hash_matches": true,
"duration_seconds": 4.963188,
"size_bytes": 79411,
"format": "mp3"
},
{
"profile": "happy_fast_formal",
"path": "reference_audio/happy_fast_formal.mp3",
"exists": true,
"sha256": "d3993a35b54c843c3a8d6470d8754f1b3344c60b460b9d11b6141761de31f987",
"manifest_sha256": "d3993a35b54c843c3a8d6470d8754f1b3344c60b460b9d11b6141761de31f987",
"hash_matches": true,
"duration_seconds": 6.7395,
"size_bytes": 107832,
"format": "mp3"
},
{
"profile": "happy_normal_casual",
"path": "reference_audio/happy_normal_casual.mp3",
"exists": true,
"sha256": "c374978ccaf9947ffc1b52a17ecf29cc904e8753e4fe0336b7c2aefee1b0af1e",
"manifest_sha256": "c374978ccaf9947ffc1b52a17ecf29cc904e8753e4fe0336b7c2aefee1b0af1e",
"hash_matches": true,
"duration_seconds": 5.067688,
"size_bytes": 81083,
"format": "mp3"
},
{
"profile": "happy_normal_formal",
"path": "reference_audio/happy_normal_formal.mp3",
"exists": true,
"sha256": "e0c6d9b5402d9ed7cc1375411951c22f09bd118a88e5abfc96bf67a3b1d76ba1",
"manifest_sha256": "e0c6d9b5402d9ed7cc1375411951c22f09bd118a88e5abfc96bf67a3b1d76ba1",
"hash_matches": true,
"duration_seconds": 5.119938,
"size_bytes": 81919,
"format": "mp3"
},
{
"profile": "happy_slow_casual",
"path": "reference_audio/happy_slow_casual.mp3",
"exists": true,
"sha256": "014864a698f1da3988895936238f0e538215d0fe4b0140c17d0eb339f0b23c02",
"manifest_sha256": "014864a698f1da3988895936238f0e538215d0fe4b0140c17d0eb339f0b23c02",
"hash_matches": true,
"duration_seconds": 4.310125,
"size_bytes": 68962,
"format": "mp3"
},
{
"profile": "happy_slow_formal",
"path": "reference_audio/happy_slow_formal.mp3",
"exists": true,
"sha256": "29545d9cc2fc4bba11dd9d4567569acdc96486317d86c665940833df333a2696",
"manifest_sha256": "29545d9cc2fc4bba11dd9d4567569acdc96486317d86c665940833df333a2696",
"hash_matches": true,
"duration_seconds": 5.067688,
"size_bytes": 81083,
"format": "mp3"
},
{
"profile": "neutral_fast_casual",
"path": "reference_audio/neutral_fast_casual.mp3",
"exists": true,
"sha256": "961886b92e69da009d07e9ea9a0c0f8d67050d378fb2ffd50850876ebcd6ba16",
"manifest_sha256": "961886b92e69da009d07e9ea9a0c0f8d67050d378fb2ffd50850876ebcd6ba16",
"hash_matches": true,
"duration_seconds": 5.172125,
"size_bytes": 82754,
"format": "mp3"
},
{
"profile": "neutral_fast_formal",
"path": "reference_audio/neutral_fast_formal.mp3",
"exists": true,
"sha256": "58870daca86d21ca7f8a785342ade7212358537cc67bc7168d4f9cb82df1465a",
"manifest_sha256": "58870daca86d21ca7f8a785342ade7212358537cc67bc7168d4f9cb82df1465a",
"hash_matches": true,
"duration_seconds": 5.146063,
"size_bytes": 82337,
"format": "mp3"
},
{
"profile": "neutral_normal_casual",
"path": "reference_audio/neutral_normal_casual.mp3",
"exists": true,
"sha256": "5849473deb7f0415a57fa96e5365e76e5850a9c5c598c3926c9f4feba77d1ce5",
"manifest_sha256": "5849473deb7f0415a57fa96e5365e76e5850a9c5c598c3926c9f4feba77d1ce5",
"hash_matches": true,
"duration_seconds": 4.205625,
"size_bytes": 67290,
"format": "mp3"
},
{
"profile": "neutral_normal_formal",
"path": "reference_audio/neutral_normal_formal.mp3",
"exists": true,
"sha256": "2cf4c64bb8222399ab54c63cc41049c572ea5f1b819ffdcbc496014daae004c0",
"manifest_sha256": "2cf4c64bb8222399ab54c63cc41049c572ea5f1b819ffdcbc496014daae004c0",
"hash_matches": true,
"duration_seconds": 4.623563,
"size_bytes": 73977,
"format": "mp3"
},
{
"profile": "neutral_slow_casual",
"path": "reference_audio/neutral_slow_casual.mp3",
"exists": true,
"sha256": "6599f401b009ad55c5c17b4509d21a7ca4c83af50bf9e1c036ad496d015a966f",
"manifest_sha256": "6599f401b009ad55c5c17b4509d21a7ca4c83af50bf9e1c036ad496d015a966f",
"hash_matches": true,
"duration_seconds": 4.545188,
"size_bytes": 72723,
"format": "mp3"
},
{
"profile": "neutral_slow_formal",
"path": "reference_audio/neutral_slow_formal.mp3",
"exists": true,
"sha256": "fd775e92e16733b8ac90885505293d20c64dcb6f421fec3aaf1eb380561aaf06",
"manifest_sha256": "fd775e92e16733b8ac90885505293d20c64dcb6f421fec3aaf1eb380561aaf06",
"hash_matches": true,
"duration_seconds": 4.466875,
"size_bytes": 71470,
"format": "mp3"
},
{
"profile": "thinking_fast_casual",
"path": "reference_audio/thinking_fast_casual.mp3",
"exists": true,
"sha256": "72de0d846bf47a7754afb2c2bb66e20047d0ca62575d605df63546589a5f2260",
"manifest_sha256": "72de0d846bf47a7754afb2c2bb66e20047d0ca62575d605df63546589a5f2260",
"hash_matches": true,
"duration_seconds": 5.067688,
"size_bytes": 81083,
"format": "mp3"
},
{
"profile": "thinking_fast_formal",
"path": "reference_audio/thinking_fast_formal.mp3",
"exists": true,
"sha256": "5efd200c837b20b123ba019b893fd5d5ad2671188f94478dc66db512455d85ae",
"manifest_sha256": "5efd200c837b20b123ba019b893fd5d5ad2671188f94478dc66db512455d85ae",
"hash_matches": true,
"duration_seconds": 5.146063,
"size_bytes": 82337,
"format": "mp3"
},
{
"profile": "thinking_normal_casual",
"path": "reference_audio/thinking_normal_casual.mp3",
"exists": true,
"sha256": "49f77a6fc84657dbb45498d49aae40a0020fbf615622fcdacee686ff19231f5b",
"manifest_sha256": "49f77a6fc84657dbb45498d49aae40a0020fbf615622fcdacee686ff19231f5b",
"hash_matches": true,
"duration_seconds": 4.754188,
"size_bytes": 76067,
"format": "mp3"
},
{
"profile": "thinking_normal_formal",
"path": "reference_audio/thinking_normal_formal.mp3",
"exists": true,
"sha256": "9637338fcf8cb6d17febbe01b4eedbb06423637163681ca864748ebd29e289de",
"manifest_sha256": "9637338fcf8cb6d17febbe01b4eedbb06423637163681ca864748ebd29e289de",
"hash_matches": true,
"duration_seconds": 4.754188,
"size_bytes": 76067,
"format": "mp3"
},
{
"profile": "thinking_slow_casual",
"path": "reference_audio/thinking_slow_casual.mp3",
"exists": true,
"sha256": "4c1d61c92b4eebc2c050e7b21d138ffac4d7e0ba6449e0f4215db5e93c61d992",
"manifest_sha256": "4c1d61c92b4eebc2c050e7b21d138ffac4d7e0ba6449e0f4215db5e93c61d992",
"hash_matches": true,
"duration_seconds": 4.310125,
"size_bytes": 68962,
"format": "mp3"
},
{
"profile": "thinking_slow_formal",
"path": "reference_audio/thinking_slow_formal.mp3",
"exists": true,
"sha256": "abef0a802382dad67b37e3447de08401a1d1cdb451420759853f4af9df234ac9",
"manifest_sha256": "abef0a802382dad67b37e3447de08401a1d1cdb451420759853f4af9df234ac9",
"hash_matches": true,
"duration_seconds": 4.466875,
"size_bytes": 71470,
"format": "mp3"
}
],
"outputs": {
"A_no_control_markers": {
"path": "output/A_no_control_markers.mp3",
"sha256": "b477ff767fcf7dc01b62bf8f445f79f2445fa204f8b365b437bcdf2e64b647e5",
"duration_seconds": 5.355,
"size_bytes": 85680,
"format": "mp3"
},
"B_single_reference": {
"path": "output/B_single_reference.mp3",
"sha256": "29148f5b08806b12e0a993366e97b172acaa3f754c171eea700f8e03aced58a6",
"duration_seconds": 5.903563,
"size_bytes": 94457,
"format": "mp3"
},
"C_24_reference_library": {
"path": "output/C_24_reference_library.mp3",
"sha256": "5182f816524ebd92150d7ce01bd4be8e3baee2b51479ce64d6b9363140e33f04",
"duration_seconds": 8.305306,
"size_bytes": 133790,
"format": "mp3"
}
},
"qualitative_study": {
"path": "validation/audio_quality_study.json",
"present": true,
"valid": true,
"error": null,
"judge_type": "multimodal_llm_not_human_mos",
"provider": "Mistral Voxtral API",
"model": "voxtral-small-latest",
"aggregate": {
"configurations": {
"A_no_control_markers": {
"dimension_means": {
"naturalness": 4.0,
"expressive_fit": 4.0,
"thinking_behavior": 3.6666666666666665,
"speaker_consistency": 4.0,
"human_customer_service": 4.0
},
"overall_mean": 3.9333333333333327,
"rank_points": 6
},
"B_single_reference": {
"dimension_means": {
"naturalness": 3.3333333333333335,
"expressive_fit": 3.3333333333333335,
"thinking_behavior": 2.6666666666666665,
"speaker_consistency": 3.3333333333333335,
"human_customer_service": 3.3333333333333335
},
"overall_mean": 3.2,
"rank_points": 4
},
"C_24_reference_library": {
"dimension_means": {
"naturalness": 4.666666666666667,
"expressive_fit": 4.666666666666667,
"thinking_behavior": 4.333333333333333,
"speaker_consistency": 4.666666666666667,
"human_customer_service": 4.666666666666667
},
"overall_mean": 4.6000000000000005,
"rank_points": 8
}
},
"aggregate_ranking": [
"C_24_reference_library",
"A_no_control_markers",
"B_single_reference"
],
"expected_manuscript_ranking": [
"C_24_reference_library",
"B_single_reference",
"A_no_control_markers"
],
"manuscript_quality_ordering_reproduced": false,
"near_human_customer_service_supported": true,
"manuscript_quality_claim_reproduced": false
},
"sha256": "8ef0917a2cf801590242fa4a670446835048ccb44a522516f2e0dc879de6e0cb"
},
"acceptance": {
"structural_and_media_gates": {
"fish_s1_provider_recorded": true,
"same_authorized_source_reference": true,
"exact_4x3x2_reference_library": true,
"all_reference_hashes_match": true,
"references_approximately_five_seconds": true,
"three_real_comparison_outputs": true,
"required_marker_routes_exercised": true,
"thinking_pause_1_to_2_seconds": true,
"thinking_native_filler_exercised": true
},
"structural_and_media_passed": true,
"qualitative_listening_study_present": true,
"qualitative_judge_is_human_mos": false,
"near_human_customer_service_claim_evaluated": true,
"manuscript_quality_claim_reproduced": false,
"experiment_execution_complete": true,
"statement": "Real Fish S1 media and a schema-checked, position-balanced multimodal listening study complete the A/B/C experiment. The saved result reports independently whether the manuscript's subjective ordering reproduced; this is not a human MOS panel."
}
}
@@ -0,0 +1,332 @@
{
"schema_version": 1,
"experiment": "6-6",
"timestamp_utc": "2026-07-30T04:17:59.570927+00:00",
"provider": "Mistral Voxtral API",
"model": "voxtral-small-latest",
"provider_attempts": [],
"study_design": {
"judge_type": "multimodal_llm_not_human_mos",
"blinded_configuration_labels": true,
"position_balanced": true,
"passes": 3,
"temperature": 0.0,
"dimensions": [
"naturalness",
"expressive_fit",
"thinking_behavior",
"speaker_consistency",
"human_customer_service"
]
},
"audio_sha256": {
"A_no_control_markers": "b477ff767fcf7dc01b62bf8f445f79f2445fa204f8b365b437bcdf2e64b647e5",
"B_single_reference": "29148f5b08806b12e0a993366e97b172acaa3f754c171eea700f8e03aced58a6",
"C_24_reference_library": "5182f816524ebd92150d7ce01bd4be8e3baee2b51479ce64d6b9363140e33f04"
},
"passes": [
{
"alias_to_configuration": {
"X": "A_no_control_markers",
"Y": "B_single_reference",
"Z": "C_24_reference_library"
},
"response": {
"clips": {
"X": {
"naturalness": {
"score": 4,
"reason": "The speech flows smoothly with natural intonation and pacing."
},
"expressive_fit": {
"score": 4,
"reason": "The speaker conveys happiness in confirmation and thoughtful consideration in checking the shipping time."
},
"thinking_behavior": {
"score": 4,
"reason": "The pause before checking the shipping time is natural and appropriate."
},
"speaker_consistency": {
"score": 4,
"reason": "The speaker maintains a consistent tone and style throughout the message."
},
"human_customer_service": {
"score": 4,
"reason": "The speech sounds like a genuine human interaction with appropriate emotional cues."
}
},
"Y": {
"naturalness": {
"score": 3,
"reason": "The speech is somewhat robotic with unnatural pauses and intonation."
},
"expressive_fit": {
"score": 3,
"reason": "The speaker lacks emotional variation, making the message sound flat."
},
"thinking_behavior": {
"score": 3,
"reason": "The pause is unnatural and does not add to the thinking process."
},
"speaker_consistency": {
"score": 3,
"reason": "The speaker's tone and style are inconsistent, switching abruptly."
},
"human_customer_service": {
"score": 3,
"reason": "The speech sounds more like a machine-generated response than a human interaction."
}
},
"Z": {
"naturalness": {
"score": 5,
"reason": "The speech is very natural with smooth intonation and pacing."
},
"expressive_fit": {
"score": 5,
"reason": "The speaker effectively conveys happiness in confirmation and thoughtful consideration in checking the shipping time."
},
"thinking_behavior": {
"score": 5,
"reason": "The pause before checking the shipping time is natural and adds to the thinking process."
},
"speaker_consistency": {
"score": 5,
"reason": "The speaker maintains a consistent tone and style throughout the message."
},
"human_customer_service": {
"score": 5,
"reason": "The speech sounds like a genuine human interaction with appropriate emotional cues."
}
}
},
"ranking": [
"Z",
"X",
"Y"
],
"ranking_reason": "Clip Z has the most natural and expressive speech, followed by X, while Y has the least natural and expressive speech."
}
},
{
"alias_to_configuration": {
"X": "B_single_reference",
"Y": "C_24_reference_library",
"Z": "A_no_control_markers"
},
"response": {
"clips": {
"X": {
"naturalness": {
"score": 4,
"reason": "The speech is mostly natural, but the tone is slightly robotic."
},
"expressive_fit": {
"score": 4,
"reason": "The speaker conveys happiness and thoughtfulness, but the delivery is a bit monotonous."
},
"thinking_behavior": {
"score": 3,
"reason": "There is a slight pause, but it does not effectively convey thinking."
},
"speaker_consistency": {
"score": 4,
"reason": "The speaker maintains a consistent tone throughout."
},
"human_customer_service": {
"score": 4,
"reason": "The speech is clear and professional, but lacks some human-like nuances."
}
},
"Y": {
"naturalness": {
"score": 5,
"reason": "The speech is very natural and flows smoothly."
},
"expressive_fit": {
"score": 5,
"reason": "The speaker effectively conveys happiness and thoughtfulness with a natural pause."
},
"thinking_behavior": {
"score": 5,
"reason": "The pause is natural and effectively conveys thinking."
},
"speaker_consistency": {
"score": 5,
"reason": "The speaker maintains a consistent and natural tone throughout."
},
"human_customer_service": {
"score": 5,
"reason": "The speech is clear, professional, and has human-like nuances."
}
},
"Z": {
"naturalness": {
"score": 3,
"reason": "The speech is somewhat robotic and lacks natural flow."
},
"expressive_fit": {
"score": 3,
"reason": "The speaker conveys happiness and thoughtfulness, but the delivery is monotonous."
},
"thinking_behavior": {
"score": 2,
"reason": "There is no effective pause to convey thinking."
},
"speaker_consistency": {
"score": 3,
"reason": "The speaker maintains a consistent tone, but it is robotic."
},
"human_customer_service": {
"score": 3,
"reason": "The speech is clear but lacks human-like nuances."
}
}
},
"ranking": [
"Y",
"X",
"Z"
],
"ranking_reason": "Clip Y has the most natural flow, effective pauses, and human-like nuances, making it the best. Clip X is slightly robotic but maintains consistency. Clip Z is the least natural and lacks effective pauses."
}
},
{
"alias_to_configuration": {
"X": "C_24_reference_library",
"Y": "A_no_control_markers",
"Z": "B_single_reference"
},
"response": {
"clips": {
"X": {
"naturalness": {
"score": 4,
"reason": "The speech is mostly natural, but the pause before '让我查一下发货时间' is slightly abrupt."
},
"expressive_fit": {
"score": 4,
"reason": "The speaker conveys happiness and thoughtfulness well, but the transition between emotions could be smoother."
},
"thinking_behavior": {
"score": 3,
"reason": "The pause is present but feels a bit forced rather than natural."
},
"speaker_consistency": {
"score": 4,
"reason": "The speaker maintains a consistent tone throughout, but there is a slight variation in pace."
},
"human_customer_service": {
"score": 4,
"reason": "The speech sounds mostly human-like, but the pause is a bit robotic."
}
},
"Y": {
"naturalness": {
"score": 5,
"reason": "The speech flows naturally with no abrupt pauses or unnatural cadences."
},
"expressive_fit": {
"score": 5,
"reason": "The speaker effectively conveys happiness and thoughtfulness, with a smooth transition between emotions."
},
"thinking_behavior": {
"score": 5,
"reason": "The pause is natural and adds to the authenticity of the thinking process."
},
"speaker_consistency": {
"score": 5,
"reason": "The speaker maintains a consistent tone and pace throughout."
},
"human_customer_service": {
"score": 5,
"reason": "The speech sounds very human-like, with natural pauses and expressions."
}
},
"Z": {
"naturalness": {
"score": 3,
"reason": "The speech is somewhat robotic, with a lack of natural pauses and variations in tone."
},
"expressive_fit": {
"score": 3,
"reason": "The speaker conveys the emotions, but the delivery is flat and lacks natural expressiveness."
},
"thinking_behavior": {
"score": 2,
"reason": "There is no natural pause or thinking behavior, making the speech sound rushed."
},
"speaker_consistency": {
"score": 3,
"reason": "The speaker maintains a consistent tone, but it is monotonous and lacks variation."
},
"human_customer_service": {
"score": 3,
"reason": "The speech sounds somewhat robotic, with a lack of natural pauses and expressions."
}
}
},
"ranking": [
"Y",
"X",
"Z"
],
"ranking_reason": "Clip Y has the most natural flow, expressive fit, and human-like qualities. Clip X is close but has a slightly abrupt pause. Clip Z is the least natural and expressive."
}
}
],
"aggregate": {
"configurations": {
"A_no_control_markers": {
"dimension_means": {
"naturalness": 4.0,
"expressive_fit": 4.0,
"thinking_behavior": 3.6666666666666665,
"speaker_consistency": 4.0,
"human_customer_service": 4.0
},
"overall_mean": 3.9333333333333327,
"rank_points": 6
},
"B_single_reference": {
"dimension_means": {
"naturalness": 3.3333333333333335,
"expressive_fit": 3.3333333333333335,
"thinking_behavior": 2.6666666666666665,
"speaker_consistency": 3.3333333333333335,
"human_customer_service": 3.3333333333333335
},
"overall_mean": 3.2,
"rank_points": 4
},
"C_24_reference_library": {
"dimension_means": {
"naturalness": 4.666666666666667,
"expressive_fit": 4.666666666666667,
"thinking_behavior": 4.333333333333333,
"speaker_consistency": 4.666666666666667,
"human_customer_service": 4.666666666666667
},
"overall_mean": 4.6000000000000005,
"rank_points": 8
}
},
"aggregate_ranking": [
"C_24_reference_library",
"A_no_control_markers",
"B_single_reference"
],
"expected_manuscript_ranking": [
"C_24_reference_library",
"B_single_reference",
"A_no_control_markers"
],
"manuscript_quality_ordering_reproduced": false,
"near_human_customer_service_supported": true,
"manuscript_quality_claim_reproduced": false
},
"limitations": [
"This is a real multimodal-model listening study, not a human MOS panel.",
"Three position-balanced passes reduce order bias but share one judge model."
]
}
@@ -0,0 +1,117 @@
{
"experiment": "6-6",
"timestamp_utc": "2026-07-29T10:19:56.497167+00:00",
"provider": "Fish Audio",
"backend": "s1",
"reference_profile_count": 24,
"dimensions": {
"emotion": [
"neutral",
"happy",
"frustrated",
"thinking"
],
"speed": [
"normal",
"fast",
"slow"
],
"style": [
"formal",
"casual"
]
},
"input_with_markers": "[EMO:happy][SPEED:fast][STYLE:casual]太好了!您的订单已确认。[THINKING]让我查一下发货时间。[EMO:neutral][SPEED:normal][STYLE:formal]预计明天下午送达。",
"parse_trace": [
" [EMO:happy] -> 情绪 = happy",
" [SPEED:fast] -> 语速 = fast",
" [STYLE:casual] -> 风格 = casual",
" [THINKING] -> 切换到 思考/慢速/正式 参考语音",
" [THINKING] 停顿 -> 插入静音 1200ms",
" [THINKING] 填充音 -> Fish S1 原生标记 '(uncertain)嗯……' (情绪=thinking,语速=slow)",
" [EMO:neutral] -> 情绪 = neutral",
" [SPEED:normal] -> 语速 = normal",
" [STYLE:formal] -> 风格 = formal"
],
"outputs": {
"A_no_control_markers": {
"path": "/Users/boj/book/ai-agent-book/chapter6/controllable-tts/output/A_no_control_markers.mp3",
"probe": {
"duration": "5.355000",
"size": "85680"
}
},
"B_single_reference": {
"path": "/Users/boj/book/ai-agent-book/chapter6/controllable-tts/output/B_single_reference.mp3",
"segments": [
{
"model": "s1",
"provider": "Fish Audio",
"profile": "neutral_normal_formal",
"reference_path": "neutral_normal_formal.mp3",
"reference_sha256": "2cf4c64bb8222399ab54c63cc41049c572ea5f1b819ffdcbc496014daae004c0",
"fish_text": "太好了!您的订单已确认。让我查一下发货时间。预计明天下午送达。",
"type": "speech",
"text": "太好了!您的订单已确认。让我查一下发货时间。预计明天下午送达。"
}
],
"probe": {
"duration": "5.903563",
"size": "94457"
}
},
"C_24_reference_library": {
"path": "/Users/boj/book/ai-agent-book/chapter6/controllable-tts/output/C_24_reference_library.mp3",
"segments": [
{
"model": "s1",
"provider": "Fish Audio",
"profile": "happy_fast_casual",
"reference_path": "happy_fast_casual.mp3",
"reference_sha256": "0e96b8c6abe891c6292e6c7037aac67979ac407c3125301f5421afe35ce256d5",
"fish_text": "太好了!您的订单已确认。",
"type": "speech",
"text": "太好了!您的订单已确认。"
},
{
"type": "silence",
"ms": 1200
},
{
"model": "s1",
"provider": "Fish Audio",
"profile": "thinking_slow_formal",
"reference_path": "thinking_slow_formal.mp3",
"reference_sha256": "abef0a802382dad67b37e3447de08401a1d1cdb451420759853f4af9df234ac9",
"fish_text": "(uncertain)嗯……",
"type": "speech",
"text": "(uncertain)嗯……"
},
{
"model": "s1",
"provider": "Fish Audio",
"profile": "thinking_slow_formal",
"reference_path": "thinking_slow_formal.mp3",
"reference_sha256": "abef0a802382dad67b37e3447de08401a1d1cdb451420759853f4af9df234ac9",
"fish_text": "让我查一下发货时间。",
"type": "speech",
"text": "让我查一下发货时间。"
},
{
"model": "s1",
"provider": "Fish Audio",
"profile": "neutral_normal_formal",
"reference_path": "neutral_normal_formal.mp3",
"reference_sha256": "2cf4c64bb8222399ab54c63cc41049c572ea5f1b819ffdcbc496014daae004c0",
"fish_text": "预计明天下午送达。",
"type": "speech",
"text": "预计明天下午送达。"
}
],
"probe": {
"duration": "8.305306",
"size": "133790"
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
"""24-file Fish Audio S1 reference-voice library and its builder."""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
from itertools import product
from pathlib import Path
from typing import Any
EMOTIONS = {
"neutral": "calm",
"happy": "happy",
"frustrated": "frustrated",
"thinking": "uncertain",
}
SPEEDS = {"normal": 1.0, "fast": 1.25, "slow": 0.8}
STYLES = {"formal": "confident", "casual": "relaxed"}
HERE = Path(__file__).parent
DEFAULT_LIBRARY_DIR = HERE / "reference_audio"
DEFAULT_MANIFEST = DEFAULT_LIBRARY_DIR / "manifest.json"
def profile_key(emotion: str, speed: str, style: str) -> str:
return f"{emotion}_{speed}_{style}"
def reference_script(emotion: str, speed: str, style: str) -> tuple[str, str]:
"""Return S1-native synthesis text and the literal spoken transcript."""
scripts = {
("formal", "slow"): "您好,我正在核对信息,请稍等。",
("formal", "normal"): "您好,我已收到您的请求,现在为您核对详细信息。",
("formal", "fast"): "您好,我已经收到您的请求,现在马上为您核对所有详细信息,请稍等片刻。",
("casual", "slow"): "你好呀,我正在帮你看看,稍等。",
("casual", "normal"): "你好呀,我收到你的请求啦,现在帮你看看具体情况。",
("casual", "fast"): "你好呀,我已经收到你的请求啦,现在马上帮你看看全部具体情况,稍等一下。",
}
spoken = scripts[(style, speed)]
native = f"({EMOTIONS[emotion]})({STYLES[style]}){spoken}"
return native, spoken
def _duration(path: Path) -> float:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(path)],
check=True, capture_output=True, text=True,
)
return float(result.stdout.strip())
def build_reference_library(
api_key: str,
base_reference_id: str,
output_dir: Path = DEFAULT_LIBRARY_DIR,
) -> dict[str, Any]:
"""Use Fish S1 to render 24 same-speaker, different-prosody references."""
from fish_audio_sdk import Prosody, Session, TTSRequest
output_dir.mkdir(parents=True, exist_ok=True)
session = Session(api_key)
profiles: dict[str, Any] = {}
for emotion, speed, style in product(EMOTIONS, SPEEDS, STYLES):
key = profile_key(emotion, speed, style)
native_text, transcript = reference_script(emotion, speed, style)
path = output_dir / f"{key}.mp3"
request = TTSRequest(
text=native_text,
reference_id=base_reference_id,
format="mp3",
prosody=Prosody(speed=SPEEDS[speed], volume=0),
)
path.write_bytes(b"".join(session.tts(request, backend="s1")))
profiles[key] = {
"emotion": emotion,
"speed": speed,
"style": style,
"path": path.name,
"transcript": transcript,
"s1_reference_prompt": native_text,
"duration_seconds": round(_duration(path), 3),
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
manifest = {
"backend": "s1",
"source_reference_id": base_reference_id,
"dimensions": {"emotion": list(EMOTIONS), "speed": list(SPEEDS), "style": list(STYLES)},
"profiles": profiles,
}
(output_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
return manifest
def load_voice_library(manifest_path: str | Path = DEFAULT_MANIFEST) -> dict[str, Any]:
path = Path(manifest_path)
if not path.is_file():
raise FileNotFoundError(
f"Fish reference library not found: {path}. Run build_reference_library.py first."
)
manifest = json.loads(path.read_text(encoding="utf-8"))
profiles = manifest.get("profiles", {})
if len(profiles) != 24:
raise ValueError(f"Reference library must contain 24 profiles, found {len(profiles)}")
for key, profile in profiles.items():
audio = path.parent / profile["path"]
if not audio.is_file() or hashlib.sha256(audio.read_bytes()).hexdigest() != profile["sha256"]:
raise ValueError(f"Missing or modified reference audio for {key}: {audio}")
profile["absolute_path"] = str(audio)
return manifest
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Build the 4×3×2 Fish Audio S1 reference library")
parser.add_argument("--base-reference-id", default=os.getenv("FISH_BASE_REFERENCE_ID"), required=False)
parser.add_argument("--output-dir", type=Path, default=DEFAULT_LIBRARY_DIR)
args = parser.parse_args()
if not os.getenv("FISH_API_KEY") or not args.base_reference_id:
parser.error("Set FISH_API_KEY and FISH_BASE_REFERENCE_ID (a voice you are authorized to use)")
result = build_reference_library(os.environ["FISH_API_KEY"], args.base_reference_id, args.output_dir)
print(f"Built {len(result['profiles'])} real Fish S1 reference clips in {args.output_dir}")