Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
T
tts-ranking
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
audio
tts-ranking
Commits
611dcc8e
Commit
611dcc8e
authored
Jul 22, 2026
by
程裕兵
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
doc:设计文档
parent
6442956f
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
418 additions
and
0 deletions
+418
-0
docs/superpowers/plans/2026-07-09-luna-tts-ranking.md
+189
-0
docs/superpowers/specs/2026-07-09-luna-tts-ranking-design.md
+229
-0
No files found.
docs/superpowers/plans/2026-07-09-luna-tts-ranking.md
0 → 100644
View file @
611dcc8e
# 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.
页面按钮点击投票正常
docs/superpowers/specs/2026-07-09-luna-tts-ranking-design.md
0 → 100644
View file @
611dcc8e
# 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(竞品版本) |
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment