Commit 611dcc8e by 程裕兵

doc:设计文档

parent 6442956f
# Luna TTS 排行榜自动冲榜 Implementation Plan(v2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 重构投票决策引擎为"三库统一排名",替换旧七规则引擎。修策略 + 日志 + 扩展调优。
**Architecture:** Chrome MV3 extension ↔ WebSocket ↔ Python service (matcher unchanged, strategy simplified, server logging enhanced).
**Tech Stack:** Python 3, Resemblyzer, torch/torchaudio, websockets, scipy; Chrome Extension MV3 (vanilla JS).
---
## File Map(变更范围)
| File | Change |
|------|--------|
| `server/strategy.py` | **重写**——简化为三库统一排名 |
| `server/server.py` | 修改 SideResult 构建 + 重写日志函数 |
| `server/matcher.py` | 不变(已有全量分数返回) |
| `server/test_strategy.py` | **重写**——匹配新逻辑 |
| `extension/content.js` | 调优(超时、恢复逻辑) |
---
### Task 1: 重写 strategy.py
**Files:**
- Modify: `server/strategy.py`
将旧七规则引擎替换为"三库统一排名"。
```python
"""Voting decision engine — unified top-match across all three libraries."""
import random
from dataclasses import dataclass
from typing import Optional, Dict
@dataclass
class SideResult:
"""Match results for one audio (left or right)."""
label: str
top_library: str # "our" | "competitor" | "big_vendor"
top_speaker: str
top_score: float
# Per-library bests (for logging)
our_speaker: Optional[str]
our_score: float
competitor_speaker: Optional[str]
competitor_score: float
big_vendor_speaker: Optional[str]
big_vendor_score: float
# Full scoreboards
our_scores: Dict[str, float]
competitor_scores: Dict[str, float]
big_vendor_scores: Dict[str, float]
@dataclass
class Decision:
action: str # "vote_left" | "vote_right" | "skip"
reason: str
def decide(left: SideResult, right: SideResult, threshold: float = 0.80) -> Decision:
"""Unified top-match decision across all three libraries.
1. If one side's top match is ours AND >= threshold → vote that side
2. If both match ours → pick higher score
3. If neither is ours, one side is big_vendor AND >= threshold → vote that side
4. If both sides are big_vendor → random
5. Otherwise → skip
"""
left_is_ours = left.top_library == "our" and left.top_score >= threshold
right_is_ours = right.top_library == "our" and right.top_score >= threshold
left_is_bv = left.top_library == "big_vendor" and left.top_score >= threshold
right_is_bv = right.top_library == "big_vendor" and right.top_score >= threshold
# Both ours
if left_is_ours and right_is_ours:
side = left if left.top_score >= right.top_score else right
return Decision(
action=f"vote_{side.label}",
reason=f"两侧都是我方 ({left.top_speaker}({left.top_score:.4f}) vs {right.top_speaker}({right.top_score:.4f})) → 投{side.label}"
)
# One side ours
if left_is_ours:
return Decision(action="vote_left", reason=f"left 最高分为我方 {left.top_speaker}({left.top_score:.4f}) → vote")
if right_is_ours:
return Decision(action="vote_right", reason=f"right 最高分为我方 {right.top_speaker}({right.top_score:.4f}) → vote")
# Both big vendor
if left_is_bv and right_is_bv:
side = random.choice([left, right])
return Decision(
action=f"vote_{side.label}",
reason=f"两侧都是大厂 ({left.top_speaker}({left.top_score:.4f}) vs {right.top_speaker}({right.top_score:.4f})) → 随机投{side.label}"
)
# One side big vendor
if left_is_bv:
return Decision(action="vote_left", reason=f"left 最高分为大厂 {left.top_speaker}({left.top_score:.4f}) → vote")
if right_is_bv:
return Decision(action="vote_right", reason=f"right 最高分为大厂 {right.top_speaker}({right.top_score:.4f}) → vote")
# Neither
top_lib = left.top_library if left.top_score >= right.top_score else right.top_library
return Decision(action="skip", reason=f"最高分为{top_lib}库,非我方/大厂或 < {threshold} → skip")
```
### Task 2: 修改 server.py
**Files:**
- Modify: `server/server.py`
改动:
1. SideResult 构建时计算全库最高分(从三个库的 best 中取 max)
2. 日志函数重写——输出每个库中所有 speaker 的分数(降序排列,≥ 阈值标 ←)
日志输出格式:
```
───────────────────────────────────────────────────────
LEFT 音频
我方: Ella(0.7863)
Ella: 0.7863
Grace: 0.6211
Daniel: 0.4532
竞品: Lively Girl_us_female(0.8549)
Lively Girl_us_female: 0.8549 ←
Sophie_uk_female: 0.7233
...
大厂: Caroline_us_female(0.8592)
Caroline_us_female: 0.8592 ←
Victoria_uk_female: 0.7988
...
>>> left 最高: Caroline_us_female(0.8592) [big_vendor]
RIGHT 音频
...
决策: vote_left
原因: left 最高分为大厂 Caroline_us_female(0.8592) → vote
统计: 23 票 / 7 弃
───────────────────────────────────────────────────────
```
详细实现在 plan 中不重复(参见 spec 第七节),核心改动是 `_log_match_result` 签名和实现。
### Task 3: 更新 test_strategy.py
**Files:**
- Modify: `server/test_strategy.py`
重写全部测试:
- `test_our_top_wins` — 我方最高 → vote 该侧
- `test_both_ours_pick_higher` — 两侧我方 → 高分侧
- `test_both_ours_tie_goes_left` — 平分 → 左
- `test_big_vendor_top_wins` — 大厂最高 → vote 该侧
- `test_both_big_vendor` — 两侧大厂 → 随机(用 monkeypatch 验证)
- `test_competitor_top_skip` — 竞品最高 → skip
- `test_all_below_threshold_skip` — 全 < 0.80 → skip
- `test_threshold_exact_boundary` — 边界值 0.80
### Task 4: 调优 content.js
**Files:**
- Modify: `extension/content.js`
- 投票后等待 2s,尝试点击 Next/Skip 跳过结果页
- waitForAudios 超时改为 60s
- 错误时清空 URL 缓存再刷新
### Task 5: 全量回归测试
```bash
cd server && python3 -m pytest -v
```
预期所有 strategy + matcher 测试通过。
### Task 6: 冒烟测试
重启服务,打开打榜页面,确认:
1. 日志中所有 speaker 分数都展示(我方/竞品/大厂全量下降)
2. 决策符合新规则
3. 页面按钮点击投票正常
# Luna TTS 排行榜自动冲榜 — 设计文档(v3)
## 一、背景与目标
VUI Labs 在 Luna TTS 平台(artificialanalysis.ai/text-to-speech/arena)上需要自动化打榜。平台每次展示两段随机合成音频,用户选出匹配各自厂商的音频,选中的厂商得分。
**目标**:针对平台生成的随机音频,自动化识别匹配到我司对照音频并投票。
## 二、整体架构
Chrome 扩展(MV3)+ 本地 Python 服务:
```
Luna 页面 ──fetch拦截──→ Content Script ──→ Background Worker
WebSocket (localhost:8765)
Python 匹配服务
• 下载音频
• 提取声纹 → 余弦相似度
• 执行投票策略
← 投票决策 (left/right/skip)
Luna 页面 ←── Debugger API原生键盘 ── Content Script ←── Background Worker
```
### 组件职责
| 组件 | 职责 |
|------|------|
| Content Script | `document_start` 注入 fetch 拦截器;捕获音频 URL;CAPTCHA 检测与自动点击;Debugger API 原生键盘投票;状态管理与自动恢复 |
| Background Worker | WebSocket 客户端;消息转发;Debugger API 原生输入事件分发 |
| Python WebSocket 服务 | 下载音频;声纹匹配;三层投票策略决策;日志双写(终端+文件) |
| 声纹匹配引擎 | Resemblyzer VoiceEncoder;多片段取最高分;性别过滤;厂商/模型元数据 |
## 三、投票决策引擎
### 核心逻辑
左右两侧音频在三个对照库(我方/竞品/大厂)中做全量匹配,取**跨库最高分**作为该侧身份判定。决策按三层优先级:
| 优先级 | 条件 | 决策 |
|--------|------|------|
| **1. 我方** | 某侧最高分属于"我方"库 AND ≥ 0.80 | 投该侧 |
| | 两侧都是我方 | 投高分侧(平局投左) |
| **2. 大厂** | 某侧最高分属于"大厂"库 AND ≥ 0.80 | 投该侧 |
| | 两侧都是大厂 | 随机投一侧 |
| **3. 竞品兜底** | 两侧最高分都属于"竞品"库 AND ≥ 0.80 | 刷新页面(弃权) |
| | 一侧竞品,另一侧不是 | 投非竞品那一侧 |
| | 都不是竞品 | 随机投一侧 |
### 可配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| 匹配阈值 | 0.80 | 三个库统一阈值 |
| 每日票数上限 | 800 | 达到即停止 |
| 投票周期 | 10 分钟 | 每轮连续投票时长 |
| 休息时长 | 60-180 秒 | 随机区间 |
## 四、音频捕获
### 策略 1:fetch 拦截(主方案)
Content script 在 `document_start` 通过 `chrome.runtime.getURL('inject.js')` 注入外部脚本劫持 `window.fetch`,拦截 `.mp3/.wav/.ogg/.m4a/.flac` 请求,通过 `postMessage` 传回 URL。
### 策略 2:DOM 读取(兜底)
读取 `<audio>` 元素的 `currentSrc`/`src`
### 消费指针机制
`_consumedUrls` 追踪已处理的 URL 数量,避免投票后等待新音频时重复读取旧 URL。每次 `waitForAudios` 消费 2 个 URL 并递增指针。
### CSP 绕过
注入脚本通过 `web_accessible_resources` 声明,绕过页面的 Content Security Policy。
## 五、防检测策略
### 环境指纹
真实 Chrome 浏览器,天然无 `navigator.webdriver` 标记。
### 操作节奏
- 等待音频实际播放(min 3 秒 + `ended` 事件 / max 8 秒超时)
- 每投 10 分钟,休息 60-180 秒(随机)
### 行为多样性
- Debugger API(Chrome DevTools Protocol)发送浏览器级原生键盘事件(`isTrusted: true`
- 原生鼠标移动 + 随机延迟 + 点击,模拟真人操作节奏
### 异常降级
| 异常 | 处理 |
|------|------|
| CAPTCHA 弹出 | Debugger API 点击 Turnstile,每 3 秒重试,20 秒超时后刷新页面 |
| 音频 URL 未捕获 | 60 秒超时后刷新页面 |
| 投票未确认 | 等待 5 秒 + 重试一次键盘事件 + 再等 5 秒,仍失败则刷新 |
| 弃权刷新 | 前 9 次随机 1-3 秒后刷新,第 10 次冷却 10-20 秒(storage 跨页面计数) |
| 达到每日上限 | 自动停止 |
## 六、通信协议
WebSocket JSON(localhost:8765)。
### match_request → match_result
```json
// 请求
{"type": "match_request", "request_id": "uuid", "audio_left": "https://...", "audio_right": "https://..."}
// 响应
{
"type": "match_result", "decision": "vote_left|vote_right|skip",
"reason": "...",
"details": {
"left": {"our_match": ..., "competitor_match": ..., "big_vendor_match": ..., "top_match": {"speaker": "...", "score": 0.85, "library": "big_vendor"}},
"right": {...}
},
"stats": {"today_votes": 23, "today_skips": 7, "daily_limit": 800}
}
```
### 控制消息
| 消息 | 方向 | 用途 |
|------|------|------|
| `status` | 扩展→服务端 | 查询当日统计 |
| `grant_autoplay` | 扩展→后台 | Debugger API 原生点击获取用户激活 |
| `click_at` | 扩展→后台 | Debugger API 原生鼠标点击 |
| `dispatch_key` | 扩展→后台 | Debugger API 原生键盘事件 |
## 七、日志规范
每次匹配输出完整报告到终端 + `server/logs/match-YYYYMMDD.log`
```
───────────────────────────────────────────────────────
LEFT 音频
我方最佳: Ella(0.7863)
Ella: 0.7863
Grace: 0.6211
竞品最佳: Lively Girl_us_female(0.8549)
Lively Girl_us_female: 0.8549 ← [ElevenLabs / Eleven_v3]
Sophie_uk_female: 0.7233
大厂最佳: Caroline_us_female(0.8592)
Caroline_us_female: 0.8592 ← [Cartesia / Sonic_3.5]
>>> left 最高: Caroline_us_female(0.8592) [big_vendor]
RIGHT 音频
...
决策: vote_left
原因: left 最高分为大厂 Caroline_us_female(0.8592) → vote
统计: 23 票 / 7 弃
───────────────────────────────────────────────────────
```
注解:`←` = 达到阈值 0.80;`[厂商 / 模型]` = vendor/model 元数据。
## 八、声纹匹配技术栈
- **Resemblyzer VoiceEncoder**:256 维声纹向量,预训练于 VoxCeleb
- **torchaudio**:音频预处理、基频性别检测
- **余弦相似度**:声纹向量归一化后点积
- **多片段取最高分**:每个 speaker 可有多段音频,匹配时取所有片段最高分
- **性别过滤**:pitch 检测未知音频性别,只与同性别对照音频匹配
- **设备自动检测**:CUDA → MPS → CPU
- **厂商/模型元数据**:subdirectory symlinks 保留 `Vendor / Model` 信息,显示在日志中
## 九、项目文件结构
```
tts-ranking/
├── BUSINESS-LOGIC.md # 代码级业务逻辑文档
├── requirements.txt
├── README.md
├── server/
│ ├── config.py # 可调参数
│ ├── matcher.py # 声纹匹配引擎
│ ├── strategy.py # 投票决策引擎
│ ├── server.py # WebSocket 服务
│ ├── test_strategy.py # 策略单元测试 (20 cases)
│ ├── test_matcher.py # 匹配器单元测试 (15 cases)
│ ├── logs/ # 匹配日志
│ └── reference/
│ ├── standard/ # 我方 3 人(3 files)
│ ├── competitors/ # 竞品对照(19 个 Vendor__Model symlinks)
│ └── big_vendors/ # 大厂对照(4 个 Vendor__Model symlinks)
└── extension/
├── manifest.json
├── inject.js # fetch 拦截器(注入页面主世界)
├── background.js # WebSocket + Debugger API
├── content.js # 页面注入、音频捕获、投票、状态管理
└── popup/
├── popup.html
└── popup.js
```
## 十、大厂模型清单
| 厂商 | 模型 |
|------|------|
| SpeechifyAI | Simba 3.2 |
| Google | Gemini 3.1 Flash TTS |
| Cartesia | Sonic 3.5 |
| Alibaba | Fun-Realtime-TTS |
## 十一、竞品模型清单
| 厂商 | 模型数 |
|------|--------|
| Inworld | 3 |
| MiniMax | 4 |
| StepFun | 3 |
| async | 2 |
| Fish Audio | 2 |
| ElevenLabs | 1 |
| Smallest.ai | 1 |
| SpaceXAI | 1 |
| Microsoft | 1 |
| SpeechifyAI | Simba 3.0(竞品版本) |
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment