# 音频流水线 SOP — 子代理执行手册

> 适用于: 片段 B-E（片段A已完成，参数已验证）
> 前置条件: 话术模板已审核（Obsidian L555 富贵花开 开价话术模板 v3.md）

## 一、文件结构

```
products/LYS001/
├── audio_v3/
│   ├── {片段}_lock.wav          # 主播锁死段（处理后）
│   ├── {片段}_lock_raw.wav      # 主播锁死段（原始备份）
│   ├── {片段}_safe_1.wav        # 主播安全段1
│   ├── {片段}_safe_2.wav        # ...
│   ├── {片段}_safe_3.wav        # ...
│   ├── {片段}_tailhook.wav      # 尾钩
│   ├── assistant/
│   │   ├── cues.md              # 助播音频库索引
│   │   ├── room_ir_numpy.wav    # 卷积混响IR
│   │   ├── room_ambient_half.wav # 环境音
│   │   ├── g01.wav ... g08.wav  # 气口音频
│   │   ├── p01.wav ... p12.wav  # 帮腔音频
│   │   └── i01.wav ... i11.wav  # 互动催促音频
│   └── {片段}_lock_srt.json     # 转写时间戳
├── video/clips_v2/
│   ├── A_appearance_soft.mp4
│   ├── B_heel_craft.mp4
│   ├── C_leather_sole.mp4
│   ├── D_price_open.mp4
│   └── E_recap_restock.mp4
└── script_v3_kaijia.json        # 话术模板
```

## 二、时间预算（⚠️ 生成前必须算）

### 2.0 公式

```
音频总上限 = 视频时长 - 弹幕切口数 × 10s - 段间间隔 × 0.5s
```

弹幕切口数 = safe段数（每两段之间一个切口）

**⚠️ 视频切片必须留足缓冲！**
- 目标：音频+弹幕只占视频时长的 **70-80%**，留 20-30% 缓冲
- 切视频时按 `(音频预算 + 弹幕) / 0.75` 确定视频长度
- 源视频30分钟，每轮8分钟，整体画面占60%，往后延30-50s完全没问题
- 细节动作都在片段前半部分，延长的都是后面的整体画面，不影响对齐

### 2.0.1 各片段预算（视频已扩展）

| 片段 | 视频 | 弹幕切口 | 音频上限 | 缓冲 | lock预算 | safe+hook预算 |
|------|------|---------|---------|------|---------|-------------|
| A | 112s | 3个(30s) | **80s** | 30s(27%) | ~39s | ~41s |
| B | 137s | 2个(20s) | **80s** | 37s(27%) | ~37s | ~43s |
| C | 120s | 1个(10s) | **69s** | 41s(34%) | ~32s | ~37s |
| D | 130s | 2个(20s) | **100s** | 30s(23%) | 0(无lock) | ~100s |
| E | 120s | 1个(10s) | **84s** | 36s(30%) | 0(无lock) | ~84s |

### 2.0.2 视频切片清单（最新）

| 片段 | 源时间 | 时长 | 细节动作 |
|------|--------|------|---------|
| A | R1 00:00-01:52 | 112s | 36s按压对折 |
| B | R1 01:43-04:00 | 137s | 9s露跟, 23s编织 |
| C | R1 04:50-06:50 | 120s | 4s内里, 27s鞋底 |
| D | R2 08:30-10:40 | 130s | 无（纯整体） |
| E | R2 10:40-12:40 | 120s | 无（纯整体） |

### 2.0.2 lock段预算计算

lock段必须覆盖到**最后一个对齐点+3s缓冲**:
- 片段B: 最后对齐点23s(编织) → lock预算 ≈ 23+3 = 26s（处理前），处理后/1.1 ≈ **24s**
- 但 lock 还要包含对齐点之前的铺垫话术
- 所以 lock 预算 = 最后对齐点/1.1 + 10-15s铺垫 ≈ **35-40s**

### 2.0.3 字数估算

- Fish Audio 乐亦姝v3: **~5.7 字/秒**（原始速度）
- atempo=1.1 后: **~6.3 字/秒**
- 30s 音频 ≈ 190 字（处理后）
- 40s 音频 ≈ 250 字（处理后）

**⚠️ 字数只是粗估，必须生成后转写确认。但生成前先用字数检查是否明显超标。**

### 2.0.4 超标处理

如果话术字数超出预算:
1. **先砍 safe 段**（内容灵活，不影响对齐）
2. **再砍 lock 段非核心内容**（保留对齐关键词前后的必要铺垫）
3. **绝不砍 tailhook**（过渡必须有）
4. 砍完重新估算字数，确认在预算内再生成

## 三、主播音频生成

### 3.1 从话术模板提取文本

每个片段按话术模板拆分为:
- **lock段**: 包含细节动作对齐点的完整段，一次TTS生成，不可拆
- **safe段**: lock之后的内容，按话题拆成2-3小段
- **tailhook**: "小助理切一下搭配..." 触发穿搭过渡

**拆分时必须带上时间预算**:
```
[lock段 预算Xs / 约N字]
话术文本...

[safe_1 预算Xs / 约N字]
话术文本...
```

⚠️ **不要擅自生成！话术文本+时间预算必须用户审核后才能调 Fish Audio。**

### 3.2 Fish Audio TTS 生成

```python
from fish_audio_sdk import Session, TTSRequest

session = Session("4f9d36d20d044d35b119b284751a90bb")
VOICE_HOST = "aa9c26ce0f9b4abeb8715b748ff50f96"  # 乐亦姝v3

req = TTSRequest(reference_id=VOICE_HOST, text="[情感标签] 话术文本...")
audio_data = b""
for chunk in session.tts(req):
    audio_data += chunk

with open("segment_raw.mp3", "wb") as f:
    f.write(audio_data)

# 转WAV
# ffmpeg -y -i segment_raw.mp3 -ar 44100 -ac 1 segment.wav
```

**关键规则:**
- 每段一次生成完整音频，**绝不分段拼接**（会错音）
- 情感标签用 Fish Audio S2 [方括号] 语法
- 标签密度约50-60字/个
- 详见 memory/reference_fish_audio_tags.md

### 3.3 主播音频后处理

```bash
ffmpeg -y -i segment_raw.wav \
    -af "atempo=1.1,aecho=0.8:0.75:15|30:0.2|0.1" \
    -ar 44100 segment.wav
```

参数:
- atempo=1.1 — 稍快，直播节奏
- aecho — 空间感

**处理前先备份原始文件为 `_raw.wav`**

## 四、音视频对齐

### 4.1 转写获取时间戳

```bash
# 1. 上传到 beacon
cp segment.wav ~/beacon/uploads/

# 2. 百炼 SenseVoice 转写
python3 -c "
import dashscope, json, time, urllib.request
from dashscope.audio.asr import Transcription
dashscope.api_key = 'sk-9d5bdf54579a425981669384368e26cf'

task = Transcription.async_call(
    model='sensevoice-v1',
    file_urls=['https://beacon.xin/api/uploads/segment.wav'],
    language_hints=['zh']
)
while True:
    result = Transcription.fetch(task)
    if result.output.task_status in ('SUCCEEDED', 'FAILED'): break
    time.sleep(3)

url = result.output.results[0]['transcription_url']
data = json.loads(urllib.request.urlopen(url).read())
# 保存
with open('segment_srt.json', 'w') as f:
    json.dump(data, f, ensure_ascii=False, indent=2)
"
```

### 4.2 对齐关键词

在 SRT 中找关键词时间（如"九十度"），与视频动作时间对比:
- 音频关键词在 Xs，视频动作在 Ys
- 差值 = Y - X
- 差值 > 0: 视频起始点后移（ffmpeg -ss 加大）
- 差值 < 0: 视频起始点前移（ffmpeg -ss 减小）
- ±2s 可接受，±3s 以上才调整

```bash
# 调整视频起始点
ffmpeg -y -ss {新起始秒} -to {结束秒} -i source.mp4 -c copy clip.mp4
```

**原则: 音频不动，调视频。TTS 贵，ffmpeg 免费。**

### 4.3 注意主播 atempo=1.1

主播音频已加速 1.1x，SRT 时间戳是**处理前**的。处理后时间 = 原时间 / 1.1。

## 五、助播音频

### 5.1 检测停顿点

```bash
# 用 -25dB 阈值检测主播音频（处理后的）的停顿
ffmpeg -i segment.wav -af "silencedetect=noise=-25dB:d=0.2" -f null - 2>&1 | grep "silence_"
```

### 5.2 选择助播 cue

规则:
- **50%密度**: 不是每个停顿都插，选一半
- **内容匹配**: 助播说的要跟主播刚讲的相关
- **不用重复词**: "对对对"太假
- **不用书面语**: "复购多""真的呢"不像说话
- lock段: 4条左右
- 每个safe段: 1条

从 `assistant/cues.md` 的音频库中选取合适的 cue。

### 5.3 助播后处理链（已有音频无需重新处理）

如需新增助播 cue:

```python
# 1. 生成 numpy IR（如果 room_ir_numpy.wav 不存在）
import numpy as np, wave, struct
sr = 44100
t = np.arange(int(sr * 0.4)) / sr
noise = np.random.randn(len(t)) * np.exp(-t * 12)
noise[0] = 1.0
noise = noise / np.max(np.abs(noise)) * 0.8
with wave.open('room_ir_numpy.wav', 'w') as w:
    w.setnchannels(1); w.setsampwidth(2); w.setframerate(sr)
    for s in noise:
        w.writeframes(struct.pack('h', int(s * 32767)))
```

```bash
# 2. Fish Audio TTS 生成
# voice_id = "2e00f6baafa44840adb4963b92238bc4" (助播15915)

# 3. 后处理
ffmpeg -y -i cue_raw.mp3 -i room_ir_numpy.wav \
    -filter_complex "
        [0]aresample=44100,atempo=1.4,apad=pad_dur=1.0,lowpass=f=4000[sped];
        [sped][1]afir=dry=7:wet=3[reverbed];
        [reverbed]loudnorm=I=-27:TP=-1:LRA=7,
        pan=stereo|c0=0.4*c0|c1=0.6*c0[out]
    " -map "[out]" -ar 44100 cue.wav
```

参数:
| 参数 | 值 | 作用 |
|------|---|------|
| atempo | 1.4 | 帮腔干脆 |
| lowpass | 4000Hz | 模拟远处拾音 |
| afir | dry=7:wet=3 + numpy IR | 空间感 |
| loudnorm | -27 LUFS | 比主播低~10dB |
| pan | L0.4/R0.6 | 偏右 |
| apad | 1.0s | 混响衰减空间 |

## 六、环境音

已生成 `assistant/room_ambient_half.wav`（120s），全程播放。

如需重新生成:
```bash
ffmpeg -y -f lavfi -i "sine=frequency=100:duration=120:sample_rate=44100" \
    -f lavfi -i "anoisesrc=d=120:c=pink:a=0.06:s=42" \
    -filter_complex "
        [0]volume=0.06,lowpass=f=200[hum];
        [1]highpass=f=200,lowpass=f=3000,volume=0.8[noise];
        [hum][noise]amix=inputs=2:duration=first,
        volume=0.5,pan=stereo|c0=0.8*c0|c1=c0[out]
    " -map "[out]" -ar 44100 room_ambient_half.wav
```

## 七、OBS 播放测试

```python
import obsws_python as obs
import subprocess, time, threading

cl = obs.ReqClient(host='localhost', port=4455, password='2qpqoeYNLKZJpf4W')

def play_at(filepath, delay):
    time.sleep(delay)
    subprocess.run(["ffplay", "-nodisp", "-autoexit", "-i", filepath],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

# 1. 视频静音+重启到开头
cl.send("SetInputVolume", {"inputName": "视频_X", "inputVolumeMul": 0.0})
cl.send("TriggerMediaInputAction", {"inputName": "视频_X",
    "mediaAction": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART"})
time.sleep(0.3)
cl.send("TriggerMediaInputAction", {"inputName": "视频_X",
    "mediaAction": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE"})
time.sleep(0.2)
cl.send("SetMediaInputCursor", {"inputName": "视频_X", "mediaCursor": 0})

# 2. 启动环境音
ambient = subprocess.Popen(["ffplay", "-nodisp", "-autoexit", "-i",
    "assistant/room_ambient_half.wav"], ...)

# 3. 启动助播线程（在停顿时间点播放）
threads = [threading.Thread(target=play_at, args=(cue_path, delay)) for ...]
for t in threads: t.start()

# 4. 同时开始视频+主播音频
cl.send("TriggerMediaInputAction", {"inputName": "视频_X",
    "mediaAction": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY"})
subprocess.run(["ffplay", "-nodisp", "-autoexit", "-i", "主播音频.wav"], ...)

# 5. 等待完成，暂停视频
```

## 八、各片段细节动作对齐点

| 片段 | 动作 | 视频内秒数 | 话术关键词 |
|------|------|-----------|-----------|
| A | 按压对折 | 36s | "九十度" |
| B | 翻转露跟 | 9s | "看鞋跟" |
| B | 编织特写 | 23s | "手工编织" |
| C | 露内里 | 4s | "看里面" |
| C | 翻转鞋底 | 27s | "看鞋底" |
| D | 无 | — | 纯整体，话术自由 |
| E | 无 | — | 纯整体，话术自由 |

## 九、执行清单（每个片段）

- [ ] **算时间预算**（视频时长 - 弹幕切口 - 段间隔）
- [ ] 从话术模板提取文本 + 标注预算字数
- [ ] 检查字数是否超标，超标先砍再提交
- [ ] **展示话术+时间预算，等用户审核** ⚠️
- [ ] Fish Audio TTS 生成各段音频
- [ ] 主播后处理（atempo=1.1 + aecho）
- [ ] **验证总时长 ≤ 音频上限**，超了必须砍话术重来
- [ ] 上传 beacon → SenseVoice 转写 lock 段 SRT
- [ ] 检查关键词时间 vs 视频动作时间（±2s 可接受）
- [ ] 如需调整 → 调视频起始点（不重新生成音频）
- [ ] 检测所有段停顿点（-25dB/0.2s）
- [ ] 从助播库选 cue + 确定时间点（50%密度，lock ~4条，safe各1条）
- [ ] OBS 完整播放测试（视频+主播+助播+环境音）
- [ ] 用户确认 ✅

## 十、常见问题与踩坑

### 10.1 音频超出视频时长
**原因**: 话术太长，没算时间预算
**解决**: 生成前按 2.0 公式算预算，字数粗估检查。生成后验证总时长。

### 10.2 对齐点偏差 >3s
**原因**: TTS 语速波动（5.5-6.0字/秒不固定）
**解决**: 音频不动，调视频起始点。`ffmpeg -ss {新秒数} -to {结束} -i source.mp4 -c copy clip.mp4`

### 10.3 助播音频听起来不自然
**已确认的规则**:
- 不用单字 cue（"对"太假），最少2字（"对的""是的"）
- 不用重复词（"对对对"假）
- 不用书面语（"复购多""真的呢"不像说话）
- 不用笑声（"嘿嘿"不像直播间）
- 50%密度，不是每个停顿都插

### 10.4 助播混响太重/太模糊
**已确认参数**: numpy IR（指数衰减，首样本=1.0）+ dry=7:wet=3
**不要用**: ffmpeg anoisesrc 生成的粉噪 IR（金属感）
**不要用**: 合成 IR + 极小 wet（感知不到变化）

### 10.5 主播音频没有空间感
**必须加**: aecho=0.8:0.75:15|30:0.2|0.1
**必须加**: atempo=1.1

### 10.6 OBS 视频有原声
**必须静音**: `cl.send("SetInputVolume", {"inputName": "视频_X", "inputVolumeMul": 0.0})`

### 10.7 音频和视频不要合成 MP4
音频和视频永远分开播放。合成了就没法插弹幕、没法动态调整。
测试用 OBS 播视频 + ffplay 播音频。

### 10.8 助播 cue 播放有"播放感"
**原因**: 每个文件的底噪突然出现/消失
**解决**: 全程铺环境音（room_ambient_half.wav），助播 cue 加 apad=1.0s 让混响自然衰减

### 10.9 SRT 时间戳与处理后音频不一致
**原因**: 转写的是原始音频，但播放的是 atempo=1.1 处理后的
**解决**: 处理后时间 = 原始时间 / 1.1。停顿检测必须对处理后的文件做。

### 10.10 片段D/E 没有 lock 段
D/E 纯整体画面，没有细节动作对齐点。全段都是 safe 区，可以随时插弹幕。
话术直接拆成 safe_1 + safe_2 + ... + tailhook。

## 十一、片段A 已确认效果（参考基准）

| 指标 | 值 |
|------|---|
| lock 时长 | 38.8s（处理后） |
| safe×3 + tailhook | 42.0s |
| 弹幕切口 | 3个 × ~8s |
| 助播密度 | 7条/段（50%） |
| 总音频 | ~82s |
| 视频时长 | 112s |
| **缓冲** | **~30s (27%)** |

**片段A效果好的原因**:
1. 话术量控制在预算内
2. lock 段只覆盖到对折动作(36s)+3s缓冲
3. safe 段按话题拆成3小段，每段10-14s
4. 助播不过密，只在关键停顿插入
5. 环境音持续铺底
6. **视频缓冲充足（27%），弹幕和意外都有空间**

## 十二、API 密钥

| 服务 | Key |
|------|-----|
| Fish Audio | 4f9d36d20d044d35b119b284751a90bb |
| Fish Audio 主播音色 | aa9c26ce0f9b4abeb8715b748ff50f96 (乐亦姝v3) |
| Fish Audio 助播音色 | 2e00f6baafa44840adb4963b92238bc4 (助播15915) |
| 百炼 | sk-9d5bdf54579a425981669384368e26cf |
| OBS WebSocket | localhost:4455 / 2qpqoeYNLKZJpf4W |
| Beacon | cp file ~/beacon/uploads/ → beacon.xin/api/uploads/ |
