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
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:
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
output/*.mp3
|
||||
output/.tmp/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,100 @@
|
||||
# 实验 6-6:Fish 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}")
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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):
|
||||
"""
|
||||
解析带控制标记的文本,返回片段列表。
|
||||
若传入 trace(list),会把「标记 -> 动作」的解析过程逐条记入,便于打印。
|
||||
"""
|
||||
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
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user