docs(phase-03): research ASR processing domain

This commit is contained in:
2026-03-13 19:05:28 +08:00
parent acb6e15621
commit 46bd809bf6
@@ -0,0 +1,478 @@
# Phase 3: Processing Skills - Research
**Researched:** 2026-03-13
**Domain:** ASR (Automatic Speech Recognition) 和字幕处理集成
**Confidence:** HIGH
## Summary
Phase 3 的核心目标是将现有的分散 ASR 工具整合为统一的管道步骤。关键发现:
1. **现有资产高度可复用**:`kb-whisper/whisper.py` 和 `kb-whisper-diarization/whisper_diarization.py` 已经实现了完整的功能(音频转换、ASR 识别、多格式输出),只需包装为管道步骤模式。
2. **决策已锁定**:OCR 明确排除,只关注 ASR/字幕处理。官方字幕优先,无字幕时使用 Whisper.cpp。HF_TOKEN 配置时自动启用说话者分离。
3. **架构模式清晰**:Phase 1/2 建立的 `StepInput`/`StepOutput` Schema、`WorkflowState` 状态管理、`retry_on_network_error` 重试机制可直接复用。
**Primary recommendation:** 创建 `ASRProcessor` 类,统一封装官方字幕下载和 Whisper.cpp 调用,复用现有代码逻辑,遵循 Phase 1/2 的管道模式。
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
#### ASR 引擎选择策略
- **优先级顺序**:B站官方字幕 → Whisper.cpp 本地识别
- 官方字幕质量最高且无计算成本,优先使用
- 无官方字幕时,默认使用 Whisper.cpp 进行语音识别
- FunASR 保留作为备选,不作为默认选项
#### 说话者分离
- HF_TOKEN 配置时自动启用带说话者分离的 Whisper(pyannote.audio)
- 无需显式参数控制,根据环境变量自动判断
- 说话者分离结果合并到标准输出格式中
#### 输出格式
- 保留全部格式:txt(纯文本)+ srt(字幕)+ json(结构化数据)
- 与现有 ASR 工具输出保持一致
- JSON 包含 segments(带时间戳的分段)和完整文本
#### OCR 处理
- **不集成 OCR 功能**
- Phase 3 专注于 ASR/字幕处理
- OCR 相关计划(03-02)移除
#### 工作流集成
- ASR 作为管道步骤执行,完成后记录到 workflow_state.json
- B站字幕下载集成到 ASR 步骤(统一入口:先尝试官方字幕,无则 ASR)
- 输出文件与内容放在同一目录,不创建子目录
#### Schema 复用
- 复用 Phase 1 定义的 StepInput/StepOutput 格式
- ASR 输出符合统一的 JSON Schema 规范
- 包含 success、tool、segments、text 等标准字段
### Claude's Discretion
- Whisper 模型默认选择(base/small/medium)
- 重试机制的具体参数(继承 Phase 1 的 tenacity 配置)
- 音频转换的临时文件处理策略
### Deferred Ideas (OUT OF SCOPE)
- OCR 视频帧文字识别 — 不需要,明确排除
- 进度可见性(实时显示 ASR 进度)— v2 需求
- 并行 ASR 处理 — v2 需求
</user_constraints>
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| whisper.cpp | v1.7.1+ | 本地语音识别引擎 | 零依赖、高性能、CPU 友好 |
| ffmpeg | 4.x+ | 音频格式转换 | 行业标准,支持所有格式 |
| pydantic | v2 | Schema 验证 | Phase 1 已采用,类型安全 |
| pydantic-settings | v2 | 配置管理 | Phase 1 已采用,环境变量加载 |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| pyannote.audio | 3.x | 说话者分离 | HF_TOKEN 配置时自动启用 |
| torch | 2.x | pyannote 依赖 | 说话者分离需要 |
| tenacity | 8.x | 重试机制 | 继承 Phase 1 配置 |
| aiohttp | 3.x | 异步 HTTP | B站字幕 API 调用 |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Whisper.cpp | FunASR | FunASR 中文效果好,但需要额外依赖(modelscope),作为备选方案 |
| Whisper.cpp | OpenAI Whisper API | API 需要付费且依赖网络,本地处理更可靠 |
**Installation:**
```bash
# Whisper.cpp(如果未安装)
git clone https://github.com/ggml-org/whisper.cpp.git ~/whisper.cpp
cd ~/whisper.cpp && make
./models/download-ggml-model.sh base
# Python 依赖
pip install pydantic pydantic-settings tenacity aiohttp
# 说话者分离(可选,需要 HF_TOKEN)
pip install pyannote.audio torch
```
## Architecture Patterns
### Recommended Project Structure
```
kb_pipeline/
├── processors/ # 处理器模块(Phase 3 新增)
│ ├── __init__.py
│ ├── base.py # BaseProcessor 基类
│ ├── asr.py # ASRProcessor 实现
│ └── subtitle.py # 字幕下载逻辑
├── schemas.py # 已有 Schema 定义
├── state.py # 已有状态管理
├── config.py # 已有配置管理
└── retry.py # 已有重试机制
```
### Pattern 1: ASRProcessor 统一入口
**What:** 将官方字幕下载和 Whisper.cpp 识别统一为单一处理器
**When to use:** 所有需要视频内容转文字的场景
**Example:**
```python
# Source: 基于 kb-whisper/whisper.py 和 kb-bilibili-content/bilibili_content.py
from kb_pipeline.schemas import StepInput, StepOutput
from kb_pipeline.state import WorkflowStateManager
from kb_pipeline.config import get_settings
class ASRInput(StepInput):
"""ASR 步骤输入"""
video_path: str # 视频文件路径
content_type: str = "video" # video/zvideo
class ASROutput(StepOutput):
"""ASR 步骤输出"""
tool: str # "subtitle" | "whisper" | "whisper-diarization"
has_subtitle: bool
output_files: Dict[str, str] # {txt, srt, json}
segments: List[Dict]
text: str
duration_seconds: float
class ASRProcessor:
"""ASR 处理器 - 统一字幕下载和语音识别"""
def __init__(self, state_manager: WorkflowStateManager = None):
self.settings = get_settings()
self.state_manager = state_manager
async def process(self, input_data: ASRInput) -> ASROutput:
"""执行 ASR 处理
策略:
1. 先尝试下载官方字幕
2. 无官方字幕则调用 Whisper.cpp
3. HF_TOKEN 配置时自动启用说话者分离
"""
# Step 1: 尝试官方字幕
if input_data.platform == "bilibili":
subtitle_result = await self._try_official_subtitle(input_data)
if subtitle_result:
return subtitle_result
# Step 2: Whisper.cpp 识别
return await self._run_whisper(input_data)
async def _try_official_subtitle(self, input_data: ASRInput) -> Optional[ASROutput]:
"""尝试下载 B站官方字幕"""
# 复用 kb-bilibili-content/bilibili_content.py 的 download_subtitle 逻辑
...
async def _run_whisper(self, input_data: ASRInput) -> ASROutput:
"""执行 Whisper.cpp 识别"""
# 检查 HF_TOKEN 决定是否启用说话者分离
hf_token = self.settings.hf_token.get_secret_value()
if hf_token:
return await self._run_whisper_with_diarization(input_data)
else:
return await self._run_whisper_basic(input_data)
```
### Pattern 2: 状态记录
**What:** ASR 完成后记录到 workflow_state.json
**When to use:** 每次 ASR 处理完成后
**Example:**
```python
# Source: Phase 1 状态管理模式
def record_asr_result(state_manager: WorkflowStateManager, result: ASROutput):
"""记录 ASR 结果到状态文件"""
if result.success:
state_manager.mark_step_completed(
"asr",
output_summary=f"tool={result.tool}, segments={len(result.segments)}"
)
else:
state_manager.mark_step_failed("asr", result.error_message)
```
### Anti-Patterns to Avoid
- **不要重新实现音频转换逻辑**:`kb-whisper/whisper.py` 的 `convert_to_wav()` 已经验证可用,直接复用
- **不要硬编码 Whisper 模型路径**:使用 `WHISPER_CPP_PATH` 环境变量
- **不要忽略临时文件清理**:`_audio.wav` 临时文件处理后必须删除
- **不要在没有官方字幕时报告失败**:返回 `has_subtitle=False` 并继续 Whisper 流程
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| 音频格式转换 | 自己写 ffmpeg 调用 | `kb-whisper/whisper.py:convert_to_wav()` | 已处理采样率检测、格式验证 |
| SRT 时间解析 | 正则表达式解析 | `kb-whisper/whisper.py:parse_srt_for_duration()` | 已验证的时间解析逻辑 |
| Whisper 模型检查 | 自己检查文件存在 | `kb-whisper/whisper.py:check_model()` | 已处理模型下载提示 |
| 说话者分离 | 自己实现分离逻辑 | `kb-whisper-diarization/whisper_diarization.py` | 已集成 pyannote.audio |
| B站字幕下载 | 自己调用 API | `kb-bilibili-content/bilibili_content.py:download_subtitle()` | 已处理 API 调用和 SRT 转换 |
**Key insight:** 现有代码已经过验证,核心逻辑直接复用,只需包装为管道模式。
## Common Pitfalls
### Pitfall 1: Whisper.cpp 路径未配置
**What goes wrong:** 运行时报错 "未找到 whisper.cpp"
**Why it happens:** `WHISPER_CPP_PATH` 环境变量未设置,默认路径也不存在
**How to avoid:**
```python
def get_whisper_cpp_path():
path = os.environ.get('WHISPER_CPP_PATH')
if path and os.path.exists(path):
return os.path.expanduser(path)
default = os.path.expanduser('~/whisper.cpp')
if os.path.exists(default):
return default
raise RuntimeError(
"未找到 whisper.cpp。请设置 WHISPER_CPP_PATH 或安装到 ~/whisper.cpp"
)
```
**Warning signs:** FileNotFoundError 在启动时
### Pitfall 2: HF_TOKEN 未配置时尝试说话者分离
**What goes wrong:** pyannote.audio 报错 "需要设置 HF_TOKEN 环境变量"
**Why it happens:** 说话者分离需要 HuggingFace 认证
**How to avoid:** 检查 HF_TOKEN 后再决定是否启用说话者分离
```python
hf_token = settings.hf_token.get_secret_value()
if not hf_token:
# 回退到普通 Whisper
return await self._run_whisper_basic(input_data)
```
**Warning signs:** RuntimeError 包含 "HF_TOKEN"
### Pitfall 3: 临时音频文件未清理
**What goes wrong:** 磁盘空间逐渐减少,`*_audio.wav` 文件堆积
**Why it happens:** ffmpeg 转换后的临时文件没有删除
**How to avoid:**
```python
# 处理完成后清理
if audio_path != input_file and audio_path.endswith('_audio.wav'):
try:
os.remove(audio_path)
except Exception:
pass # 静默失败,不阻塞主流程
```
**Warning signs:** 目录中存在多个 `*_audio.wav` 文件
### Pitfall 4: 超时设置不合理
**What goes wrong:** 长视频处理超时中断
**Why it happens:** Whisper 处理 1 小时视频可能需要 10+ 分钟
**How to avoid:** 设置合理超时(现有代码使用 3600 秒)
```python
result = subprocess.run(cmd, timeout=3600) # 1 小时超时
```
**Warning signs:** TimeoutExpired 异常
## Code Examples
### 官方字幕下载(复用现有代码)
```python
# Source: kb-bilibili-content/bilibili_content.py
async def download_subtitle(self, bvid: str) -> Dict[str, Any]:
"""下载字幕并转换为 SRT 格式"""
result = {"success": False, "has_subtitle": False}
# 获取 CID
api_url = f"https://api.bilibili.com/x/player/pagelist?bvid={bvid}"
async with aiohttp.ClientSession() as session:
async with session.get(api_url) as resp:
data = await resp.json()
pages = data.get('data', [])
if not pages:
result["success"] = True # 没有分P是正常的
return result
cid = pages[0].get('cid', '')
# 获取字幕列表
async with session.get(f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}") as resp:
sub_data = await resp.json()
subtitles = sub_data.get('data', {}).get('subtitle', {}).get('list', [])
if not subtitles:
result["success"] = True # 没有字幕是正常的
return result
# 下载并转换为 SRT
# ... (转换逻辑)
result["has_subtitle"] = True
result["success"] = True
return result
```
### Whisper.cpp 调用(复用现有代码)
```python
# Source: kb-whisper/whisper.py
def run_whisper(whisper_path, audio_path, model, output_dir, language='zh'):
"""运行 Whisper 语音识别"""
cmd = [
os.path.join(whisper_path, 'whisper-cli'),
'-m', os.path.join(whisper_path, 'models', f'ggml-{model}.bin'),
'-f', audio_path,
'-of', os.path.join(output_dir, output_basename),
'-l', language,
'--output-txt', '--output-srt', '--output-json',
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
return result
```
### 说话者分离(复用现有代码)
```python
# Source: kb-whisper-diarization/whisper_diarization.py
def run_diarization(audio_path, num_speakers=None):
"""运行 pyannote.audio 说话者分离"""
hf_token = os.environ.get('HF_TOKEN')
if not hf_token:
raise RuntimeError("需要设置 HF_TOKEN 环境变量")
from huggingface_hub import login
login(token=hf_token)
from pyannote.audio import Pipeline
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")
import torch
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
pipeline.to(device)
diarization = pipeline(audio_path, num_speakers=num_speakers)
# 解析结果
speakers = {}
for turn, _, speaker in diarization.itertracks(yield_label=True):
if speaker not in speakers:
speakers[speaker] = []
speakers[speaker].append({
"start": round(turn.start, 2),
"end": round(turn.end, 2)
})
return speakers
```
### 输出格式(标准 JSON)
```json
{
"success": true,
"tool": "whisper",
"model": "base",
"language": "zh",
"input_file": "video.mp4",
"output_files": {
"txt": "video.txt",
"srt": "video.srt",
"json": "video.json"
},
"duration_seconds": 120.5,
"segments": [
{
"id": 0,
"start": 0.0,
"end": 3.2,
"text": "这是第一段字幕",
"speaker": "SPEAKER_00"
}
],
"text": "完整文本内容..."
}
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| 手动调用 whisper.py | 管道步骤封装 | Phase 3 | 统一入口,状态可追踪 |
| 硬编码模型选择 | 配置化模型选择 | Phase 3 | 灵活调整精度/速度 |
| 无说话者分离 | HF_TOKEN 自动启用 | Phase 3 | 访谈/对话视频支持 |
**Deprecated/outdated:**
- `kb-workflow` 中的手动 Whisper 调用步骤:将被 `ASRProcessor` 统一封装
## Open Questions
1. **Whisper 默认模型选择**
- What we know: 现有代码默认使用 `base` 模型
- What's unclear: 是否需要根据视频时长自动选择模型(短视频用 tiny,长视频用 base)
- Recommendation: 保持 `base` 默认,Claude's Discretion 范围内可后续优化
2. **临时文件目录**
- What we know: 当前在视频同目录创建 `_audio.wav`
- What's unclear: 是否应该使用系统临时目录
- Recommendation: 保持现有行为(同目录),便于调试和断点续传
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest 8.x |
| Config file | pytest.ini |
| Quick run command | `pytest tests/test_processors/ -x -v` |
| Full suite command | `pytest tests/ -v` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| ASR-01 | 官方字幕下载成功 | unit | `pytest tests/test_processors/test_asr.py::test_official_subtitle -x` | ❌ Wave 0 |
| ASR-02 | 无字幕时调用 Whisper | unit | `pytest tests/test_processors/test_asr.py::test_whisper_fallback -x` | ❌ Wave 0 |
| ASR-03 | HF_TOKEN 启用说话者分离 | unit | `pytest tests/test_processors/test_asr.py::test_diarization_enabled -x` | ❌ Wave 0 |
| ASR-04 | 输出 txt/srt/json 三格式 | unit | `pytest tests/test_processors/test_asr.py::test_output_formats -x` | ❌ Wave 0 |
| ASR-05 | 状态记录到 workflow_state | unit | `pytest tests/test_processors/test_asr.py::test_state_recording -x` | ❌ Wave 0 |
| ASR-06 | 临时文件清理 | unit | `pytest tests/test_processors/test_asr.py::test_temp_file_cleanup -x` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `pytest tests/test_processors/test_asr.py -x -v`
- **Per wave merge:** `pytest tests/ -v`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `tests/test_processors/__init__.py` — 测试模块初始化
- [ ] `tests/test_processors/conftest.py` — 共享 fixtures(mock Whisper、mock B站 API)
- [ ] `tests/test_processors/test_asr.py` — ASR 处理器测试
- [ ] `tests/test_processors/test_subtitle.py` — 字幕下载测试
## Sources
### Primary (HIGH confidence)
- kb-whisper/whisper.py - 已验证的 Whisper.cpp 封装实现
- kb-whisper-diarization/whisper_diarization.py - 已验证的说话者分离实现
- kb-bilibili-content/bilibili_content.py - 已验证的 B站字幕下载实现
- kb_pipeline/schemas.py - Phase 1 定义的 Schema 体系
- kb_pipeline/state.py - Phase 1 定义的状态管理
- kb_pipeline/config.py - Phase 1 定义的配置管理
### Secondary (MEDIUM confidence)
- [whisper.cpp GitHub](https://github.com/ggml-org/whisper.cpp) - 官方仓库,确认安装方式
- [pyannote.audio GitHub](https://github.com/pyannote/pyannote-audio) - 官方仓库,确认 HF_TOKEN 配置
### Tertiary (LOW confidence)
- 无
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH - 基于现有代码和已验证的依赖
- Architecture: HIGH - 遵循 Phase 1/2 建立的模式
- Pitfalls: HIGH - 基于现有代码中的错误处理逻辑
**Research date:** 2026-03-13
**Valid until:** 30 days(稳定技术栈)