从github迁移过来
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# 生成的音频素材(可由脚本重新生成)
|
||||||
|
audio/00_raw/
|
||||||
|
audio/01_processed/
|
||||||
|
audio/02_final/
|
||||||
|
audio/03_qa_reports/
|
||||||
|
audio/test_output/
|
||||||
|
|
||||||
|
# pip 误写产物
|
||||||
|
=2.5.1
|
||||||
|
|
||||||
|
# 缓存
|
||||||
|
.cache/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
+893
@@ -0,0 +1,893 @@
|
|||||||
|
# AI 音频生产 SOP · v1.0
|
||||||
|
|
||||||
|
> **适用项目**:《林夏》(林夏.md),同时可复用于本系列其他黑屏耳机项目。
|
||||||
|
> **使用对象**:项目主控 1 人 + 音频执行 1–2 人,无需专业录音棚。
|
||||||
|
> **预期产出**:约 25 分钟净时长的角色语音 + 4 段 BGM + 60+ 短音效 + 8 段环境底噪。
|
||||||
|
> **预期工时**:4 周(含 1 周返工窗口)。
|
||||||
|
> **预期音频成本**:¥800–1200。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 文档使用方法
|
||||||
|
|
||||||
|
- 第 1–3 章是**总规约**:所有人开工前必读。
|
||||||
|
- 第 4 章是**项目专用配置**:每开新项目要复制一份重新填写。
|
||||||
|
- 第 5–8 章是**四类资产的生产 SOP**:执行人按章节对照操作即可。
|
||||||
|
- 第 9–10 章是**后期与验收**:产出每一批资产后强制走一遍。
|
||||||
|
- 第 11 章是**版权归档**:每个文件都要登记。
|
||||||
|
- 附录是**复用资源**:Prompt 模板库 / 命名词典 / 验收清单 PDF 版。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Pipeline 总览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ T0 脚本定稿 │ → │ T1 音频拆条 │ → │ T2 生产排期 │
|
||||||
|
│ (编剧) │ │ (音频主控) │ │ (音频主控) │
|
||||||
|
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────────────────┬───────────────────┬────────────────┐
|
||||||
|
↓ ↓ ↓ ↓
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ T3a TTS生产 │ │ T3b 音乐生产 │ │ T3c 音效生产 │ │ T3d 环境采样 │
|
||||||
|
│ MiniMax/豆包 │ │ Suno V4 │ │ ElevenLabs │ │ Freesound │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
↓ ↓ ↓ ↓
|
||||||
|
└───────────────────────────┴───────────────────┴────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ T4 后期统一处理 │
|
||||||
|
│ (响度/降噪/淡入淡出) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ T5 QA 验收 │
|
||||||
|
│ (3 副耳机盲听) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ T6 入库 + 交付 │
|
||||||
|
│ (Git LFS + 版权登记) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 工具栈与账号清单
|
||||||
|
|
||||||
|
### 2.1 必备工具表
|
||||||
|
|
||||||
|
| 用途 | 工具 | 版本/方案 | 月费 | 国内访问 | 备注 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **中文 TTS 主力** | MiniMax Speech 02 (HD) | API | 按字数 ¥200–500 | ✅ 直连 | 中文情绪戏首选 |
|
||||||
|
| **中文 TTS 备用** | 字节豆包 Voice | API | ¥100–300 | ✅ 直连 | 系统女声 + 男声补位 |
|
||||||
|
| **TTS 兜底** | 阿里通义听悟 / 腾讯云 TTS | API | ¥100–200 | ✅ 直连 | 备选 |
|
||||||
|
| **海外 TTS** | ElevenLabs v3 | Creator $22/月 | ≈¥160 | ⚠️ 需出海 | 仅在 MiniMax 表现不够时备用 |
|
||||||
|
| **AI 音乐** | Suno V4 / V4.5 | Pro $10/月 | ≈¥75 | ⚠️ 需出海 | 国内备选:天工 SkyMusic |
|
||||||
|
| **AI 音效** | ElevenLabs Sound Effects | 含在上面套餐内 | — | ⚠️ 需出海 | 国内备选:阿里通义万相音效 |
|
||||||
|
| **环境采样** | Freesound | 免费(CC0/CC-BY) | 0 | ✅ 直连 | 注意标注作者 |
|
||||||
|
| **环境采样补充** | Epidemic Sound | $15/月 个人版 | ≈¥110 | ✅ 直连 | 有 SFX/BGM 双库 |
|
||||||
|
| **音频后期** | iZotope RX 11 + REAPER | 一次性 | ≈¥2000 | ✅ 直连 | 团队共享一份 |
|
||||||
|
| **响度计** | Youlean Loudness Meter 2 | Free 版即可 | 0 | ✅ 直连 | LUFS 监控必备 |
|
||||||
|
| **格式转换** | FFmpeg | Free | 0 | ✅ 直连 | 命令行批处理 |
|
||||||
|
| **资源管理** | Git LFS + Notion | — | 0 | ✅ 直连 | 资产 + 版权登记表 |
|
||||||
|
|
||||||
|
### 2.2 账号准备清单(开工 D-7 完成)
|
||||||
|
|
||||||
|
- [ ] MiniMax 开发者账号 + 充值 ¥500 起
|
||||||
|
- [ ] 字节豆包账号 + 充值 ¥300 起
|
||||||
|
- [ ] ElevenLabs Creator 订阅(含 Sound Effects 100k credits/月)
|
||||||
|
- [ ] Suno Pro 订阅(500 首/月,足够)
|
||||||
|
- [ ] Freesound 注册账号(用于下载追踪)
|
||||||
|
- [ ] Epidemic Sound 个人订阅
|
||||||
|
- [ ] Git LFS 启用 + S3/OSS 备份桶
|
||||||
|
- [ ] Notion 中建好「音频版权登记表」模板(见 §11.2)
|
||||||
|
|
||||||
|
### 2.3 海外工具的合规访问方式
|
||||||
|
- 团队主控通过合规企业 VPN(如阿里云国际版、腾讯云海外节点)访问。
|
||||||
|
- 不在产品线上代码中嵌入任何海外 API key——所有 Suno / ElevenLabs 资源**全部线下生产、本地存储、入库后离线打包随 App 发布**。
|
||||||
|
- App 运行时**不调用任何海外服务**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 文件命名与目录规范
|
||||||
|
|
||||||
|
### 3.1 顶层目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
audio/
|
||||||
|
├── 00_raw/ # 原始生成物,不进版本库
|
||||||
|
│ ├── tts/
|
||||||
|
│ ├── music/
|
||||||
|
│ ├── sfx/
|
||||||
|
│ └── ambience/
|
||||||
|
├── 01_processed/ # 后期处理后,进 Git LFS
|
||||||
|
│ ├── tts/
|
||||||
|
│ ├── music/
|
||||||
|
│ ├── sfx/
|
||||||
|
│ └── ambience/
|
||||||
|
├── 02_final/ # 打包入 App 的最终版
|
||||||
|
│ └── (按关卡组织)
|
||||||
|
├── 03_qa_reports/ # 每批 QA 报告
|
||||||
|
└── 99_legal/ # 版权登记表 + 授权证明
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 文件命名规范
|
||||||
|
|
||||||
|
**格式**:`{关卡号}_{时间码}_{角色}_{形态}_{序号}_{版本}.{ext}`
|
||||||
|
|
||||||
|
| 字段 | 取值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 关卡号 | `lv11` 等 | 项目代号 |
|
||||||
|
| 时间码 | `2103`、`2245` | 剧内时间,HHMM |
|
||||||
|
| 角色 | `mom` `azhe` `xiaomei` `zhounan` `hr` `anan` `system` `delivery` `unknown` `dorm-a` `dorm-b` `dorm-c` `self-3y` | 见 §4.1 角色代号表 |
|
||||||
|
| 形态 | `call` `voice` `text` `sys` | 通话/微信语音/文字 TTS/系统提示 |
|
||||||
|
| 序号 | `01`、`02`… | 同角色同时间码下的多条消息 |
|
||||||
|
| 版本 | `v1`、`v2`、`final` | 迭代版本 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
- `lv11_2103_mom_call_01_final.wav` — 妈妈 21:03 第一通电话最终版
|
||||||
|
- `lv11_2245_xiaomei_voice_01_v2.wav` — 小美 22:45 醉语音第二稿
|
||||||
|
|
||||||
|
**禁止**:
|
||||||
|
- 中文文件名(避免编码与 Git 兼容问题)
|
||||||
|
- 空格、`(`、`)`、`#` 等特殊字符
|
||||||
|
- 大写字母(统一小写)
|
||||||
|
|
||||||
|
### 3.3 音频技术规格统一
|
||||||
|
|
||||||
|
| 字段 | 规格 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **采样率** | 48 kHz | 全程统一,避免重采样杂音 |
|
||||||
|
| **位深** | 24-bit(生产)/ 16-bit(最终) | 后期保留 24-bit 头,导出降到 16-bit |
|
||||||
|
| **声道** | 单声道(角色语音)/ 立体声(音乐与环境) | 严格区分 |
|
||||||
|
| **格式** | `.wav`(生产中)/ `.ogg` Vorbis q=5(入包) | 入包再压缩,节省体积 |
|
||||||
|
| **响度** | -16 LUFS(角色语音)/ -18 LUFS(音乐)/ -22 LUFS(环境) | 详见 §9.1 |
|
||||||
|
| **峰值上限** | -1 dBTP | 避免溢出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 角色 Voice 配置表(项目专用)
|
||||||
|
|
||||||
|
> 本章是项目工程师的"配音演员花名册"。**每个角色的 voice id、参数、prompt 前缀全部在这里固定下来**,避免不同执行人产出风格漂移。
|
||||||
|
|
||||||
|
### 4.1 角色代号表
|
||||||
|
|
||||||
|
| 角色 | 代号 | 净时长估算 | 主语种 | 形态 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 妈妈 | `mom` | ~7 min | 中文 | 电话×2 + 微信文字×1 + 语音×1(4'17"长戏) |
|
||||||
|
| 阿哲(前任) | `azhe` | ~2 min | 中文 | 微信语音×1 + 电话×1 |
|
||||||
|
| 小美(闺蜜) | `xiaomei` | ~3 min | 中文 | 微信语音×3(含 1'20"醉戏) |
|
||||||
|
| HR 王姐 | `hr` | ~1 min | 中文 | 微信语音×1 |
|
||||||
|
| 室友安安 | `anan` | ~30 s | 中文 | 微信语音×1 |
|
||||||
|
| 寝室群 A/B/C | `dorm-a` `dorm-b` `dorm-c` | ~2 min 共 | 中文 | 群语音×5 |
|
||||||
|
| 周南(同桌) | `zhounan` | ~5 min | 中文 | 微信语音×5(含 4 段长讲述) |
|
||||||
|
| 外卖小哥 | `delivery` | ~10 s | 中文(北方腔) | 电话×1 |
|
||||||
|
| 系统 TTS | `system` | ~2 min | 中文 | 全局提示 |
|
||||||
|
| 未知号码 | `unknown` | 无台词 | — | 仅环境呼吸声 |
|
||||||
|
| 3 年前的自己 | `self-3y` | ~50s × 3 版 | 中文 | 备忘录录音(明朗/迷茫/温柔三版预录) |
|
||||||
|
| 林夏的笑(隐藏) | `self-laugh` | ~3s | — | 仅结局 E 播放,全片唯一林夏自己的声音 |
|
||||||
|
|
||||||
|
### 4.2 角色 Voice 配置卡
|
||||||
|
|
||||||
|
每张卡固定一个角色的所有参数。**生产时严格按卡操作**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 01 · `mom`(妈妈)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | MiniMax Speech 02 HD |
|
||||||
|
| Voice ID | `Chinese_Mandarin_Warm_Auntie_v3`(占位,待实际选定后回填) |
|
||||||
|
| Speed | 0.92(略慢) |
|
||||||
|
| Volume | 1.0 |
|
||||||
|
| Pitch | 0 |
|
||||||
|
| Emotion | `gentle` |
|
||||||
|
| 备用方案 | 第 16 条 4 分钟长戏改用**克隆方案**:找一位 50 岁左右真人录 30 秒原声,MiniMax Voice Clone,emotion 提到 `tender` |
|
||||||
|
| Prompt 前缀 | `<语气:温柔克制,不哭不闹,西北口音淡化,语速比常人慢半拍>` |
|
||||||
|
| SSML 控制 | 长句中段加 `<break time="500ms"/>`,结尾"晚安"前加 `<break time="1.2s"/>` |
|
||||||
|
| 后期处理 | 加 -3 dB 高频衰减(让"妈妈感"更柔),轻 reverb wet 8% 模拟客厅 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 02 · `azhe`(阿哲 / 前任)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | 字节豆包 Voice |
|
||||||
|
| Voice ID | `灿烂青年男声-v2`(北京口音淡,理性偏冷) |
|
||||||
|
| Speed | 1.05 |
|
||||||
|
| Volume | 0.95 |
|
||||||
|
| Pitch | -1 |
|
||||||
|
| Emotion | `neutral` |
|
||||||
|
| Prompt 前缀 | `<语气:礼貌但已抽离,像在跟同事说话,不带感情起伏>` |
|
||||||
|
| 后期处理 | -2 dB 中频,让声音"闷"一点,模拟"已经远了"的距离感 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 03 · `xiaomei`(小美 / 闺蜜)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | MiniMax Speech 02 |
|
||||||
|
| Voice ID | `Chinese_Mandarin_Lively_Girl_v2` |
|
||||||
|
| Speed | 1.10(前两条快)/ 0.95(醉语音慢) |
|
||||||
|
| Volume | 1.0 |
|
||||||
|
| Pitch | +1 |
|
||||||
|
| Emotion | `cheerful`(前两条)/ `melancholic + drunk`(第 9 条) |
|
||||||
|
| Prompt 前缀 | `<语气:北漂女孩,活泼略大大咧咧,会爆粗,醉酒段口齿模糊但不夸张>` |
|
||||||
|
| 醉戏特殊处理 | 在 SSML 中插入 `<break time="800ms"/>` 模拟停顿 + 末句故意拉长元音;后期再叠 5% 房间混响 + 微弱酒吧 BGM 底噪 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 04 · `hr`(HR 王姐)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | 字节豆包 Voice |
|
||||||
|
| Voice ID | `知性女声-成熟款` |
|
||||||
|
| Speed | 1.00 |
|
||||||
|
| Pitch | 0 |
|
||||||
|
| Emotion | `gentle + apologetic` |
|
||||||
|
| Prompt 前缀 | `<语气:38 岁职场女性,疲惫的善意,公事公办里带一点点歉意>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 05 · `anan`(室友安安)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | MiniMax Speech 02 |
|
||||||
|
| Voice ID | `Chinese_Mandarin_Cute_Girl_v2` |
|
||||||
|
| Speed | 1.15(兴奋) |
|
||||||
|
| Pitch | +2 |
|
||||||
|
| Emotion | `excited` |
|
||||||
|
| Prompt 前缀 | `<语气:23 岁,撒娇感比小美更重,背景嘈杂酒吧>` |
|
||||||
|
| 后期处理 | 必须叠酒吧环境底噪(见 §8 ambience 库 `bar_loud_03.wav`),干声 -3 dB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 06 · `dorm-a/b/c`(大学寝室群三女生)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | MiniMax Speech 02,**三个不同 Voice ID 必须区分明显** |
|
||||||
|
| 候选 IDs | A: `Lively_Girl_v2` / B: `Sweet_Girl_v3` / C: `Mature_Young_Lady_v2` |
|
||||||
|
| Speed | A: 1.10 / B: 1.00 / C: 0.95 |
|
||||||
|
| Pitch | A: +1 / B: 0 / C: -1 |
|
||||||
|
| Emotion | A: `cheerful` / B: `gentle` / C: `neutral` |
|
||||||
|
| 备注 | A 是升职那位;B 是和事佬;C 是 @ 林夏的那位 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 07 · `zhounan`(周南 / 初中同桌)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | MiniMax Speech 02 |
|
||||||
|
| Voice ID | `Chinese_Mandarin_Gentleman_Young_v2` |
|
||||||
|
| Speed | 0.95 |
|
||||||
|
| Volume | 0.92(略轻,模拟拘谨) |
|
||||||
|
| Pitch | -1 |
|
||||||
|
| Emotion | `gentle + nervous` |
|
||||||
|
| Prompt 前缀 | `<语气:26 岁二三线男生,温和拘谨,明显紧张但克制,偶有自嘲>` |
|
||||||
|
| 4 段长戏 | 每段独立生成,段间留 800 ms 静音;句尾上扬避免"播音感" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 08 · `delivery`(外卖小哥)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | 字节豆包 Voice |
|
||||||
|
| Voice ID | `北方青年男声-v2` |
|
||||||
|
| Speed | 1.20(麻利) |
|
||||||
|
| Emotion | `neutral` |
|
||||||
|
| Prompt 前缀 | `<语气:30 岁,普通话+少许河南腔,工作语气,不寒暄>` |
|
||||||
|
| 后期处理 | 加 -6 dB 低频衰减 + 轻底噪,模拟手机听筒 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 09 · `system`(系统 TTS)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | 字节豆包 Voice |
|
||||||
|
| Voice ID | `通用女声-柔` |
|
||||||
|
| Speed | 1.00 |
|
||||||
|
| Pitch | 0 |
|
||||||
|
| Emotion | `flat`(无情绪) |
|
||||||
|
| Prompt 前缀 | `<语气:中性 AI 助手,类似 Siri 但更轻,不带任何情感>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 卡 10 · `self-3y`(3 年前的自己)
|
||||||
|
|
||||||
|
> ⚠️ **本作零麦克风权限**——本条不再使用玩家录音方案,改为**预录三版**按运行时玩家行为画像(PROFILE)动态选择。
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | MiniMax Speech 02 HD(建议与林夏未来版本预留同一克隆音色,便于 IP 续作) |
|
||||||
|
| Voice ID | 与 `xiaomei` 同基底音色(暗示"她们曾是同类年轻女孩"),但 speed +0.05、pitch +1,模拟 3 年前更轻盈的语调 |
|
||||||
|
| 三个预录版本 | ①「**明朗版**」:3 年前刚来北京,对一切都新鲜<br>②「**迷茫版**」:3 年前其实已经累,只是没说<br>③「**温柔版**」:3 年前对未来的自己说"撑住" |
|
||||||
|
| 选择逻辑 | 按 PROFILE 标签匹配:<br>`engagement` → 明朗版(与今夜的投入呼应)<br>`avoidance` → 迷茫版(与今夜的回避呼应)<br>`nostalgia` → 温柔版(与今夜的怀旧呼应) |
|
||||||
|
| 时长 | 每版 ~50 秒 |
|
||||||
|
| 后期处理 | 加 -2 dB 高频衰减 + 微弱"录音笔底噪",模拟"3 年前用手机备忘录录的"质感 |
|
||||||
|
| Prompt 前缀(明朗版) | `<语气:23 岁,刚到北京半年,兴奋里带一点紧张,相信明天比今天好>` |
|
||||||
|
| Prompt 前缀(迷茫版) | `<语气:23 岁,独自在出租屋里,假装坚强,最后一句声音轻下去>` |
|
||||||
|
| Prompt 前缀(温柔版) | `<语气:23 岁,写给未来的自己,平静、有力、像在拥抱什么>` |
|
||||||
|
|
||||||
|
#### 卡 11 · `self-laugh`(隐藏结局 E 的笑声)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 主用 TTS | MiniMax Speech 02 HD(同 `self-3y` 同一克隆音色) |
|
||||||
|
| 内容 | 一段 3 秒的轻笑——"噗"+ 一声短笑 + 一声轻轻的鼻息 |
|
||||||
|
| 制作方式 | TTS 难以直接生成自然笑声,**建议方案**:<br>① 找声线接近的 24 岁女声真人**录一次笑**(5 分钟搞定,零成本)<br>② 用 MiniMax Voice Clone 同款音色 + emotion `playful` 生成尝试,作为备选 |
|
||||||
|
| 用途 | 仅在结局 E 触发时播放——**全片唯一一次**林夏自己的声音 |
|
||||||
|
| 后期处理 | 与 `self-3y` 同款"录音笔底噪",让玩家感到"这是从 3 年前的备忘录里偶然飘出来的一段笑" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. TTS 生产 SOP
|
||||||
|
|
||||||
|
### 5.1 工作流(每条语音)
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] 拿到脚本最终稿(编剧签字)
|
||||||
|
↓
|
||||||
|
[2] 拆条 → 填入「拍摄场记表」(见 §5.5)
|
||||||
|
↓
|
||||||
|
[3] 套用对应角色 Voice 配置卡
|
||||||
|
↓
|
||||||
|
[4] 写 Prompt(含 SSML 标记)
|
||||||
|
↓
|
||||||
|
[5] 一键生成 3 条候选 → 听
|
||||||
|
↓
|
||||||
|
[6] 满意 → 进入 [7] / 不满意 → 调参/改 prompt 重生
|
||||||
|
↓
|
||||||
|
[7] 命名归档到 00_raw/tts/
|
||||||
|
↓
|
||||||
|
[8] 进入后期 (§9)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Prompt 模板(中文 TTS)
|
||||||
|
|
||||||
|
**通用结构**:
|
||||||
|
|
||||||
|
```
|
||||||
|
<voice_id>{角色 Voice ID}</voice_id>
|
||||||
|
<speed>{速度}</speed>
|
||||||
|
<emotion>{情绪标签}</emotion>
|
||||||
|
<prefix>{角色 prompt 前缀}</prefix>
|
||||||
|
|
||||||
|
[SSML 正文]
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSML 关键标记速查**:
|
||||||
|
|
||||||
|
| 标记 | 作用 | 用例 |
|
||||||
|
|---|---|---|
|
||||||
|
| `<break time="800ms"/>` | 停顿 | 长句中喘气、"她说……(停)后来呢" |
|
||||||
|
| `<emphasis level="strong">字</emphasis>` | 重读 | 关键词强调 |
|
||||||
|
| `<prosody rate="0.8">慢句</prosody>` | 局部变速 | 醉酒段、临终段 |
|
||||||
|
| `<prosody pitch="-2st">低声</prosody>` | 局部降调 | 内心戏 |
|
||||||
|
| `<say-as interpret-as="characters">XYZ</say-as>` | 字母逐字读 | "AirPods" 等英文词 |
|
||||||
|
|
||||||
|
### 5.3 长戏(≥1 分钟)特殊处理
|
||||||
|
|
||||||
|
**问题**:TTS 一次生成超过 60 秒会出现:
|
||||||
|
1. 末段质感衰减
|
||||||
|
2. 情绪曲线扁平
|
||||||
|
3. 偶发吞字
|
||||||
|
|
||||||
|
**对策**:
|
||||||
|
1. **分段录制**:每段 ≤ 30 秒,按句号/段落切。
|
||||||
|
2. **段间留 200 ms 静音头尾**:方便后期对接。
|
||||||
|
3. **段间情绪渐变**:在 prompt 里手动写每段的 emotion 演进,例如妈妈 4'17" 长戏分 6 段:
|
||||||
|
- 段 1: `gentle, hesitant`
|
||||||
|
- 段 2: `gentle, nostalgic`
|
||||||
|
- 段 3: `gentle, sad but holding back`
|
||||||
|
- 段 4: `gentle, releasing`
|
||||||
|
- 段 5: `gentle, peaceful`
|
||||||
|
- 段 6: `gentle, tender, almost smiling`
|
||||||
|
4. **后期 crossfade 拼接**:每段间 50 ms 交叉淡化,听觉上无缝。
|
||||||
|
|
||||||
|
### 5.4 候选评审"三耳法则"
|
||||||
|
|
||||||
|
每条 TTS 出 3 条候选,必须用三种设备听过:
|
||||||
|
|
||||||
|
| 设备 | 检查重点 |
|
||||||
|
|---|---|
|
||||||
|
| AirPods Pro / 通用入耳式 | 主验收,模拟用户实际场景 |
|
||||||
|
| 头戴式监听耳机(如 ATH-M50x) | 听细节杂音、底噪、齿音 |
|
||||||
|
| 手机外放 | 兜底,确认离线场景下可懂 |
|
||||||
|
|
||||||
|
**通过标准**:3 副耳机里每副至少满意度 7/10 才入库。任意一副 ≤ 5/10 必须重做。
|
||||||
|
|
||||||
|
### 5.5 「拍摄场记表」模板(每条语音 1 行)
|
||||||
|
|
||||||
|
| ID | 关卡 | 时间码 | 角色 | 形态 | 文字稿 | 时长目标 | 情绪 | 出 prompt 人 | 评审人 | 状态 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 001 | lv11 | 2103 | mom | call | "今晚煮了你爱吃的排骨…" | 35s | gentle | A | B | ✅ |
|
||||||
|
| 002 | lv11 | 2115 | xiaomei | voice | "在吗" | 2s | cheerful | A | B | ✅ |
|
||||||
|
| ... | | | | | | | | | | |
|
||||||
|
|
||||||
|
> 该表用 Notion / 飞书表格维护,每条语音从立项到入库全周期可追。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 音乐生产 SOP(Suno)
|
||||||
|
|
||||||
|
### 6.1 工作流
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] 编剧/导演给"音乐 brief"(场景+情绪+时长+段落结构)
|
||||||
|
↓
|
||||||
|
[2] 翻译成 Suno Prompt(英文,带 BPM/instrument/mood)
|
||||||
|
↓
|
||||||
|
[3] 一次跑 4 首候选(Suno 一次给 2 首,跑 2 轮)
|
||||||
|
↓
|
||||||
|
[4] 评审 → 选 1 首主稿 + 1 首备稿
|
||||||
|
↓
|
||||||
|
[5] 用 Suno "Extend"功能延长到目标时长
|
||||||
|
↓
|
||||||
|
[6] 用 Suno "Stem"功能(如开通)拆出干声/伴奏
|
||||||
|
↓
|
||||||
|
[7] 后期:手动剪辑、loop 点处理、淡入淡出
|
||||||
|
↓
|
||||||
|
[8] 导出 .wav 24-bit 入 00_raw/music/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Prompt 模板(《林夏》4 段曲)
|
||||||
|
|
||||||
|
#### M1 · 21:00 主题 / 全程低氛围 Loop
|
||||||
|
|
||||||
|
```
|
||||||
|
Style: lo-fi midnight bedroom, soft piano, light vinyl noise
|
||||||
|
Instruments: muted piano, subtle synth pad, occasional rain outside window
|
||||||
|
Tempo: 70 BPM
|
||||||
|
Mood: melancholy but warm, alone but not lonely
|
||||||
|
Structure: ambient loop, no drums, no clear melody peak
|
||||||
|
Duration: 90 seconds (extend to loopable)
|
||||||
|
Vocals: instrumental
|
||||||
|
```
|
||||||
|
|
||||||
|
**评审标准**:
|
||||||
|
- 必须能 loop 30 分钟不觉得"在重复"
|
||||||
|
- 不能有任何强旋律压过 TTS
|
||||||
|
|
||||||
|
#### M2 · 22:30 悬疑过门
|
||||||
|
|
||||||
|
```
|
||||||
|
Style: subtle ambient suspense, cinematic tension
|
||||||
|
Instruments: low drone, single hanging piano note (high register), sparse strings
|
||||||
|
Tempo: 60 BPM
|
||||||
|
Mood: unsettling but not horror, like a question mark
|
||||||
|
Structure: 30 seconds, builds slowly, ends on unresolved chord
|
||||||
|
Vocals: instrumental
|
||||||
|
```
|
||||||
|
|
||||||
|
#### M3 · 23:45 周南段落
|
||||||
|
|
||||||
|
```
|
||||||
|
Style: gentle indie folk, intimate acoustic
|
||||||
|
Instruments: fingerpicked nylon guitar, soft male humming (no lyrics, just "mmm"), light shaker
|
||||||
|
Tempo: 75 BPM
|
||||||
|
Mood: hopeful nostalgic, like reading an old letter
|
||||||
|
Structure: 120 seconds, gradual build, soft climax at 0:80, gentle outro
|
||||||
|
Vocals: wordless humming only, very subtle
|
||||||
|
```
|
||||||
|
|
||||||
|
#### M4 · 02:00 结尾
|
||||||
|
|
||||||
|
```
|
||||||
|
Style: warm cinematic piano, dawn moment
|
||||||
|
Instruments: solo piano, soft string pad entering at 0:30
|
||||||
|
Tempo: 50 BPM
|
||||||
|
Mood: like the moment you realize you survived a hard night
|
||||||
|
Structure: 90 seconds, single melody line, no drums, fade out
|
||||||
|
Vocals: instrumental
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 角色铃声生成(9 个)
|
||||||
|
|
||||||
|
每个角色一个独立铃声,玩家"听铃辨人":
|
||||||
|
|
||||||
|
| 角色 | Suno Prompt | 时长 |
|
||||||
|
|---|---|---|
|
||||||
|
| mom | `老式诺基亚铃声风格, warm marimba, simple 4-note motif, 70bpm` | 4s loop |
|
||||||
|
| azhe | `cold minimal electronic ringtone, 2 notes only, 80bpm` | 4s loop |
|
||||||
|
| xiaomei | `cheerful pop ringtone, ukulele plucks, 4-bar phrase, 100bpm` | 4s loop |
|
||||||
|
| zhounan | `nostalgic 90s pager beep + soft chime, 70bpm` | 3s loop |
|
||||||
|
| hr | `corporate professional ringtone, neutral marimba, 90bpm` | 4s loop |
|
||||||
|
| anan | `bouncy J-pop intro, 2 seconds, 110bpm` | 3s loop |
|
||||||
|
| delivery | `standard Chinese delivery app push sound, 1 second` | 1s |
|
||||||
|
| unknown | `eerie low drone, no melody, 40bpm` | 4s loop |
|
||||||
|
| system | (不需要铃声,是震动) | — |
|
||||||
|
|
||||||
|
### 6.4 Suno 使用 Tips
|
||||||
|
|
||||||
|
1. **每次出双稿**:Suno 一次返回 2 首,第二首往往比第一首意外。
|
||||||
|
2. **用"Custom Mode"**:能精确填 Lyrics(留空)+ Style,避免随机。
|
||||||
|
3. **避免 vocal**:除非明确要 humming,否则 prompt 里加 `instrumental` `no vocals`。
|
||||||
|
4. **Extend 不超 3 次**:第 4 次开始质感会断层。
|
||||||
|
5. **Stem 拆轨非常省事**:拆出后可以单独调音量,比从 mix 抠声好太多。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 音效生产 SOP(ElevenLabs Sound Effects)
|
||||||
|
|
||||||
|
### 7.1 项目所需音效清单(约 60 个)
|
||||||
|
|
||||||
|
| 类别 | 数量 | 例 |
|
||||||
|
|---|---|---|
|
||||||
|
| **UI 音** | 12 | 微信咚、震动短/长、消息提示、来电铃响半秒、接通滴、挂断滴、撤回 swoosh、已读、未读、错误提示、菜单切换、确认 |
|
||||||
|
| **拟音** | 18 | 烧水壶哨响、水沸腾、键盘敲击 5 种节奏、外卖敲门 3 种、门关、钥匙、玻璃放下、椅子拉、纸张翻、笔写字、手机放在床上、手机震动桌面 |
|
||||||
|
| **角色化短音** | 8 | 阿哲消息撤回 swoosh、妈妈微信消息发出 woosh、群里@提示音、未知号码呼吸(轮询 3 段)、外卖结账提示、HR 邮件附件提示、电量 1% 警告、备忘录播放滴 |
|
||||||
|
| **过渡 / 转场** | 8 | 章节过渡轻 swell、时间流逝标记(22:00/23:00/00:00 等)、灯光熄灭暗示、心跳加速、深呼吸、玻璃碎一秒、雨势忽大、雨势忽小 |
|
||||||
|
| **结局专用** | 4 | 字幕浮现 chime、烧水壶滴一声、夜班车驶过、野猫叫一声 |
|
||||||
|
| **预留备用** | 10 | — |
|
||||||
|
|
||||||
|
### 7.2 ElevenLabs Sound Effects Prompt 模板
|
||||||
|
|
||||||
|
**通用结构**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{动作或物体}, {距离/视角}, {环境}, {时长建议}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
| 资产 ID | Prompt |
|
||||||
|
|---|---|
|
||||||
|
| `sfx_kettle_whistle` | `kettle whistling, close mic, small kitchen, 3 seconds, vintage` |
|
||||||
|
| `sfx_keyboard_typing` | `mechanical keyboard typing, soft, 5 seconds, intimate close mic` |
|
||||||
|
| `sfx_wechat_pop` | `short notification pop sound, soft, single high pitched 'tap', 0.5 seconds, original not WeChat` |
|
||||||
|
| `sfx_message_swoosh` | `quick whoosh of message sending, electronic, 0.3 seconds` |
|
||||||
|
| `sfx_door_knock_delivery` | `three knocks on apartment door, polite delivery, hallway echo, 2 seconds` |
|
||||||
|
| `sfx_glass_down` | `glass placed on wooden desk, soft clink, close mic, 1 second` |
|
||||||
|
| `sfx_phone_vibrate_table` | `phone vibrating on wooden bedside table, 2 seconds` |
|
||||||
|
| `sfx_cat_yowl_distant` | `single cat yowl in distance, alley, late night, 1.5 seconds` |
|
||||||
|
| `sfx_breath_unknown` | `human breathing through phone, slightly muffled, anonymous, 6 seconds` |
|
||||||
|
|
||||||
|
### 7.3 ElevenLabs SFX 使用 Tips
|
||||||
|
|
||||||
|
1. **每条 prompt 跑 3 次**:每次都不一样,多挑。
|
||||||
|
2. **时长别贪长**:>10s 容易"漂"。需要长效果就做 loop。
|
||||||
|
3. **避免品牌词**:不能写 `WeChat sound` `iPhone ringtone` `Apple notification`,直接侵权。改写为 `Chinese-style messaging app soft pop`。
|
||||||
|
4. **要用 mp3 时再转**:默认下载 mp3 192k,**生产中先转 wav 再处理**。
|
||||||
|
|
||||||
|
### 7.4 音效"哪类必须找真采样而非 AI"
|
||||||
|
|
||||||
|
| 音效 | 原因 | 采样源 |
|
||||||
|
|---|---|---|
|
||||||
|
| 30 秒以上的环境底噪 | AI 长 ambience 会"漂" | Freesound / Epidemic |
|
||||||
|
| 真实城市夜噪(远地铁、夜班车) | AI 太干净,缺真实空气感 | Freesound CC0 |
|
||||||
|
| 特定地域口音、街头叫卖 | AI 无地域性 | 自己录 |
|
||||||
|
| 情绪化的笑声、哭声、叹息 | AI SFX 还达不到 | TTS 角色化处理或真人录 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 环境采样 SOP(Freesound / Epidemic Sound)
|
||||||
|
|
||||||
|
### 8.1 项目环境底噪清单
|
||||||
|
|
||||||
|
| ID | 用途 | 时长 | 描述 | 推荐源 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `amb_apartment_night_01` | 全程底噪 | 10 min loop | 合租屋夜晚,远处车声、冰箱嗡嗡、偶尔水管 | Freesound `apartment night ambience` |
|
||||||
|
| `amb_apartment_night_02` | 备用 | 5 min loop | 同上不同质感 | Freesound |
|
||||||
|
| `amb_bar_loud_01` | 室友安安电话背景 | 1 min loop | 拥挤酒吧,人声谈笑 + 电子音乐底 | Epidemic Sound |
|
||||||
|
| `amb_subway_distant` | 转场 | 30s | 远处地铁过站 | Freesound `subway distant` |
|
||||||
|
| `amb_kitchen_morning` | 结局 A | 1 min | 厨房环境,水龙头滴、冰箱嗡 | Freesound |
|
||||||
|
| `amb_rain_window` | M1 BGM 衬底 | 5 min loop | 窗外细雨 | Freesound `rain window soft` |
|
||||||
|
| `amb_late_night_bus` | 结局段 | 20s | 夜班公交驶过空街 | Epidemic Sound |
|
||||||
|
| `amb_dawn_birds` | 结局 D | 30s | 清晨鸟叫 | Freesound `dawn birds urban` |
|
||||||
|
|
||||||
|
### 8.2 Freesound 检索 SOP
|
||||||
|
|
||||||
|
1. 关键词搜索(英文优先)。
|
||||||
|
2. 过滤条件:
|
||||||
|
- License: `Creative Commons 0` 优先;`Attribution` 次之;不要 `Attribution NonCommercial`(商业项目)。
|
||||||
|
- Duration: 设最小值(避免短到没用)。
|
||||||
|
- Sample rate: 44.1kHz / 48kHz。
|
||||||
|
3. 听 → 下载 .wav 原始版本(不要 mp3)。
|
||||||
|
4. **立刻**在 §11 版权登记表里登记:URL / 作者 ID / License / 下载日期。
|
||||||
|
5. 入 `00_raw/ambience/` 命名 `amb_xxx_freesoundXXXXX.wav`(保留 Freesound ID)。
|
||||||
|
|
||||||
|
### 8.3 Epidemic Sound 检索 SOP
|
||||||
|
|
||||||
|
1. Epidemic Sound 全部资源都是 Royalty-Free(订阅期内可商用)。
|
||||||
|
2. 直接下载 .wav 24-bit。
|
||||||
|
3. 在版权登记表登记 Track ID(Epidemic 提供)。
|
||||||
|
4. **退订后已下载的资产仍可继续使用**,但需保留下载凭证(截图存档)。
|
||||||
|
|
||||||
|
### 8.4 环境采样的"假性立体声"处理
|
||||||
|
|
||||||
|
许多 Freesound 资源是单声道。处理成立体声:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# REAPER / Audition 操作:
|
||||||
|
# 1. 复制单声道到 L、R 两轨
|
||||||
|
# 2. R 轨延迟 8–15 ms
|
||||||
|
# 3. R 轨高频 EQ 微调 ±1 dB
|
||||||
|
# → 听感即获得"环境的宽度",但不影响 TTS 在中央的位置
|
||||||
|
```
|
||||||
|
|
||||||
|
**禁止**:用空间音效插件做 3D 定位——本作明确**不依赖空间音效定位**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 后期统一处理 SOP
|
||||||
|
|
||||||
|
### 9.1 响度规范(关键)
|
||||||
|
|
||||||
|
| 资产类型 | 目标 LUFS(综合) | 峰值上限 |
|
||||||
|
|---|---|---|
|
||||||
|
| 角色语音(电话/微信语音) | -16 LUFS | -1 dBTP |
|
||||||
|
| 系统 TTS | -18 LUFS | -1 dBTP |
|
||||||
|
| 主题音乐(M1–M4) | -18 LUFS | -1 dBTP |
|
||||||
|
| 环境底噪 | -22 LUFS | -3 dBTP |
|
||||||
|
| 短音效(UI 类) | -14 LUFS(短促能听清) | -1 dBTP |
|
||||||
|
|
||||||
|
**为什么角色语音 -16 LUFS**:移动端 + 耳机场景,比影视的 -23 LUFS 响度高 7 dB,让玩家在嘈杂环境(公交、宿舍)也能听清。
|
||||||
|
|
||||||
|
**测量工具**:Youlean Loudness Meter 2 (Free),REAPER 内置即可。
|
||||||
|
|
||||||
|
### 9.2 处理链(每条 TTS 标准链)
|
||||||
|
|
||||||
|
```
|
||||||
|
原始 TTS (.wav, 24-bit, 48kHz, 单声道)
|
||||||
|
↓
|
||||||
|
[1] iZotope RX · Mouth De-click(去 TTS 偶发咔嗒声)
|
||||||
|
↓
|
||||||
|
[2] iZotope RX · Voice De-noise(轻档 -6 dB,去合成底噪)
|
||||||
|
↓
|
||||||
|
[3] EQ · 高通滤波 80 Hz(去低频噪)
|
||||||
|
↓
|
||||||
|
[4] EQ · 角色化频段调整(按 §4 配置卡)
|
||||||
|
↓
|
||||||
|
[5] Compressor · 4:1, threshold -18 dB(控动态)
|
||||||
|
↓
|
||||||
|
[6] 加听筒效果(仅"电话/语音"形态,详见 §9.3)
|
||||||
|
↓
|
||||||
|
[7] Loudness Match · -16 LUFS
|
||||||
|
↓
|
||||||
|
[8] Limiter · ceiling -1 dBTP
|
||||||
|
↓
|
||||||
|
[9] 导出 .wav 16-bit 48kHz → 01_processed/tts/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 「听筒效果」预设
|
||||||
|
|
||||||
|
模拟手机听筒/微信语音的窄频段感:
|
||||||
|
|
||||||
|
```
|
||||||
|
EQ:
|
||||||
|
HPF 300 Hz, 24 dB/oct
|
||||||
|
LPF 3.4 kHz, 24 dB/oct
|
||||||
|
+1 dB at 1.5 kHz(保留人声清晰度)
|
||||||
|
|
||||||
|
Saturation:
|
||||||
|
Soft tape saturation, 5–8% drive
|
||||||
|
|
||||||
|
Noise:
|
||||||
|
叠极轻底噪(-50 dB),可用 ambience 库 phone_noise_floor.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
> **例外**:`mom` 第 16 条 4 分钟长戏 **不加听筒效果** —— 这条是微信语音"完整音质版",是设计意图。
|
||||||
|
|
||||||
|
### 9.4 音乐处理链
|
||||||
|
|
||||||
|
```
|
||||||
|
Suno 原始 (.wav, 立体声, 48kHz)
|
||||||
|
↓
|
||||||
|
[1] Trim 头尾静音
|
||||||
|
↓
|
||||||
|
[2] Loop 点设计(找节拍重音对齐)
|
||||||
|
↓
|
||||||
|
[3] Crossfade 50 ms(loop 接缝)
|
||||||
|
↓
|
||||||
|
[4] EQ · 削掉 200–500 Hz 的"AI 浊感"(-2 dB)
|
||||||
|
↓
|
||||||
|
[5] Stereo Width · 控制在 70%(避免吃掉中央 TTS)
|
||||||
|
↓
|
||||||
|
[6] Loudness Match · -18 LUFS
|
||||||
|
↓
|
||||||
|
[7] Limiter · -1 dBTP
|
||||||
|
↓
|
||||||
|
[8] 导出 .wav → 01_processed/music/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.5 环境底噪处理链
|
||||||
|
|
||||||
|
```
|
||||||
|
Freesound 原始 (.wav)
|
||||||
|
↓
|
||||||
|
[1] Trim 不需要的段
|
||||||
|
↓
|
||||||
|
[2] iZotope RX · 去掉突兀的"事件"(突然的喊叫、关门)
|
||||||
|
↓
|
||||||
|
[3] HPF 60 Hz(去隆隆声)
|
||||||
|
↓
|
||||||
|
[4] LPF 12 kHz(去高频噪)
|
||||||
|
↓
|
||||||
|
[5] Loop 点设计(必须找几乎静止的 1 秒做接缝)
|
||||||
|
↓
|
||||||
|
[6] Crossfade 200 ms
|
||||||
|
↓
|
||||||
|
[7] Loudness Match · -22 LUFS
|
||||||
|
↓
|
||||||
|
[8] 假性立体声化(§8.4)
|
||||||
|
↓
|
||||||
|
[9] 导出 .wav → 01_processed/ambience/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.6 批处理脚本(FFmpeg 示例)
|
||||||
|
|
||||||
|
格式批量转换 + 响度归一化:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 把 01_processed 下所有 wav 转成最终 ogg + 响度归一化到 -16 LUFS
|
||||||
|
for f in 01_processed/tts/*.wav; do
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "loudnorm=I=-16:TP=-1:LRA=11" \
|
||||||
|
-c:a libvorbis -q:a 5 \
|
||||||
|
"02_final/tts/$(basename "${f%.wav}.ogg")"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. QA 验收清单(每批资产强制走一遍)
|
||||||
|
|
||||||
|
### 10.1 自检清单(执行人)
|
||||||
|
|
||||||
|
每条资产入 `01_processed/` 前自查:
|
||||||
|
|
||||||
|
- [ ] 文件命名符合 §3.2 规范
|
||||||
|
- [ ] 采样率 = 48 kHz
|
||||||
|
- [ ] 单/立体声符合 §3.3 规范
|
||||||
|
- [ ] 响度在 §9.1 目标范围内(±1 LUFS)
|
||||||
|
- [ ] 峰值不超过 -1 dBTP
|
||||||
|
- [ ] 无明显爆音、咔嗒、底噪
|
||||||
|
- [ ] 时长与场记表目标值偏差 ≤ 10%
|
||||||
|
|
||||||
|
### 10.2 评审清单(评审人,不同人)
|
||||||
|
|
||||||
|
每批 TTS 走"三耳法则"(§5.4):
|
||||||
|
|
||||||
|
- [ ] AirPods Pro 听 → 满意度 ≥ 7/10
|
||||||
|
- [ ] 监听耳机听 → 满意度 ≥ 7/10
|
||||||
|
- [ ] 手机外放听 → 可懂 ≥ 7/10
|
||||||
|
- [ ] 角色辨识度 → 与配置卡一致
|
||||||
|
- [ ] 情绪到位度 → 与脚本意图一致
|
||||||
|
|
||||||
|
### 10.3 上下文听感测试(关键)
|
||||||
|
|
||||||
|
**单条满意 ≠ 全场景满意**。每完成一个时间段(如 21:00–22:00 这一小时):
|
||||||
|
|
||||||
|
- [ ] 把这一小时所有资产按时间轴拼起来,戴耳机完整听一遍
|
||||||
|
- [ ] 检查:音量是否平稳?环境底噪有没有突然断层?两条 TTS 之间的"沉默"是否自然?
|
||||||
|
- [ ] 至少 3 名团队成员独立听完,给整体评分(1–10)
|
||||||
|
- [ ] 平均分 ≥ 7 才放行
|
||||||
|
|
||||||
|
### 10.4 玩家盲测(最终验收前)
|
||||||
|
|
||||||
|
- 招 5 位 22–25 岁女性玩家
|
||||||
|
- **不告知任何剧情**,让她们戴耳机黑屏完整玩一遍
|
||||||
|
- 关键观测点:
|
||||||
|
- 哪条 TTS 让她们出戏(皱眉/摘耳机)→ 重做
|
||||||
|
- 哪条让她们流泪/微笑/屏息 → 标记为"金条",备份保留
|
||||||
|
- 哪个角色被说"听不出是谁" → 配置卡需调整辨识度
|
||||||
|
- 内测报告归档到 `03_qa_reports/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 版权追踪与归档
|
||||||
|
|
||||||
|
### 11.1 强制原则
|
||||||
|
|
||||||
|
> **任何一条入库资产,在它被生成/下载的当天,必须登记。**
|
||||||
|
> **当天没登记的,第二天起视为不可用,必须返工补登。**
|
||||||
|
|
||||||
|
### 11.2 版权登记表模板(Notion / 飞书表格)
|
||||||
|
|
||||||
|
| 资产 ID | 类型 | 来源工具/平台 | URL/Track ID/Voice ID | License | 创建/下载日期 | 创建人 | 商用证明位置 | 备注 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| `lv11_2103_mom_call_01` | TTS | MiniMax Speech 02 | voice: Warm_Auntie_v3 | MiniMax 商用许可 | 2026-05-15 | 张三 | `99_legal/minimax_terms_2026.pdf` | — |
|
||||||
|
| `amb_apartment_night_01` | ambience | Freesound | freesound.org/s/123456/ | CC0 | 2026-05-13 | 李四 | (CC0 无需) | 作者: user_xxx |
|
||||||
|
| `amb_bar_loud_01` | ambience | Epidemic Sound | track id: ES_8472993 | Epidemic 订阅 | 2026-05-14 | 李四 | `99_legal/epidemic_invoice_202605.pdf` | 订阅期内 |
|
||||||
|
| `m1_main_theme` | music | Suno V4 | song id: suno_xxx | Suno Pro 商用 | 2026-05-16 | 王五 | `99_legal/suno_pro_2026.pdf` | — |
|
||||||
|
| `sfx_wechat_pop` | sfx | ElevenLabs SFX | (SFX 不返回 ID,存 prompt) | ElevenLabs Creator 商用 | 2026-05-15 | 王五 | `99_legal/elevenlabs_terms.pdf` | prompt 存留 |
|
||||||
|
|
||||||
|
### 11.3 各 AI 平台商用授权要点(截至 2026-05)
|
||||||
|
|
||||||
|
> ⚠️ **每次开新项目前必须重新核对最新条款**。AI 平台条款变化频繁。
|
||||||
|
|
||||||
|
| 平台 | 商用许可 | 特殊条件 |
|
||||||
|
|---|---|---|
|
||||||
|
| **MiniMax Speech** | ✅ 包含在 API 服务中 | 不得用于生成深度伪造、政治敏感内容 |
|
||||||
|
| **字节豆包 Voice** | ✅ 企业 API 包含商用 | 个人版可能受限,必须开企业账户 |
|
||||||
|
| **ElevenLabs** | ✅ Creator 及以上套餐 | Free 套餐**不能商用**;TTS 需注明(部分场景) |
|
||||||
|
| **Suno** | ✅ Pro 及以上 | Free 不可商用;保留生成时的订阅状态截图 |
|
||||||
|
| **Freesound** | ⚠️ 看每条 license | CC0 自由用;CC-BY 必须署名;CC-NC 不可商用 |
|
||||||
|
| **Epidemic Sound** | ✅ 订阅期内全库商用 | 退订后已下载资产可继续用,必须存订阅凭证 |
|
||||||
|
|
||||||
|
### 11.4 归档与备份
|
||||||
|
|
||||||
|
- **Git LFS**:所有 `01_processed/` 与 `02_final/` 资产入库。
|
||||||
|
- **OSS 双备份**:每周自动同步到阿里云 OSS(主)+ AWS S3(备),保留 6 个月版本。
|
||||||
|
- **冷备**:项目结项时,全套 raw + processed + final + 法务文件刻盘归档(蓝光 50 GB ≈ ¥30 一张)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 附录
|
||||||
|
|
||||||
|
### 12.1 命名词典
|
||||||
|
|
||||||
|
| 字段 | 缩写 |
|
||||||
|
|---|---|
|
||||||
|
| 关卡 | `lv` |
|
||||||
|
| TTS 角色语音 | `tts` |
|
||||||
|
| 音乐 | `mus` / `m1`/`m2`/`m3`/`m4` |
|
||||||
|
| 短音效 | `sfx` |
|
||||||
|
| 环境底噪 | `amb` |
|
||||||
|
| 来电 | `call` |
|
||||||
|
| 微信语音 | `voice` |
|
||||||
|
| 微信文字 | `text` |
|
||||||
|
| 系统提示 | `sys` |
|
||||||
|
|
||||||
|
### 12.2 项目专用术语
|
||||||
|
|
||||||
|
| 术语 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| **金条** | 玩家盲测中触发明显情感反应的语音条目,优先保留 |
|
||||||
|
| **听筒效果** | §9.3 的 EQ + saturation 处理预设 |
|
||||||
|
| **三耳法则** | §5.4 的三种设备评审标准 |
|
||||||
|
| **拍摄场记表** | §5.5 的全条目追踪表 |
|
||||||
|
|
||||||
|
### 12.3 突发情况预案
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
|---|---|
|
||||||
|
| MiniMax 临时服务中断 | 切豆包 Voice,按角色配置卡的"备用方案"行动 |
|
||||||
|
| ElevenLabs SFX 月度 credit 用完 | 走阿里通义万相音效;或下月重做 |
|
||||||
|
| Suno 出图风格全部不满意 | 用 Udio v2 重跑同一 prompt |
|
||||||
|
| Freesound 没有合适素材 | Epidemic Sound;再不行**临时找 1 名收音师外采**(预算预留 ¥2000) |
|
||||||
|
| 玩家盲测某条全员差评 | 优先级最高,必须返工,72 小时内重做 + 复测 |
|
||||||
|
|
||||||
|
### 12.4 工时估算(参考,单人执行)
|
||||||
|
|
||||||
|
| 资产类型 | 单条耗时 | 总耗时 |
|
||||||
|
|---|---|---|
|
||||||
|
| 短 TTS(≤ 10 s) | 5 min(含 3 候选评审) | ~3 h(35 条) |
|
||||||
|
| 长 TTS(≥ 1 min) | 30 min(分段+拼接) | ~2 h(4 条) |
|
||||||
|
| 单段音乐(含 extend) | 40 min | ~3 h(4 段) |
|
||||||
|
| 单条音效 | 3 min | ~3 h(60 条) |
|
||||||
|
| 单段环境采样(含处理) | 20 min | ~3 h(8 段) |
|
||||||
|
| 后期统一处理 | 5 min/条 | ~6 h |
|
||||||
|
| QA 与盲测 | — | ~12 h |
|
||||||
|
| **合计** | | **~32 工时**(4 个工作日) |
|
||||||
|
|
||||||
|
> 实际项目预留 **2 倍**时间用于反复打磨。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 启动检查表(Day 0 之前)
|
||||||
|
|
||||||
|
- [ ] 所有 §2.2 账号已开通、付费、API key 已存安全位置(团队密码管理器)
|
||||||
|
- [ ] §3 文件夹结构已建好,Git LFS 已启用
|
||||||
|
- [ ] §4 角色 Voice 配置卡已在 Notion 立表,每个 voice id 跑过 1 条试音确认可用
|
||||||
|
- [ ] §5.5 拍摄场记表已建好,所有条目预填
|
||||||
|
- [ ] §11.2 版权登记表已建好
|
||||||
|
- [ ] 团队听过 1 个完整 reference 项目(推荐:《Mountain》《Florence》《Lifeline》音频部分)
|
||||||
|
- [ ] 玩家盲测候选 5 人已联系并约好测试日期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档状态**:v1.0 · 2026-05-13 · 待团队评审
|
||||||
|
**维护人**:项目主控
|
||||||
|
**下次修订**:每完成一个项目后,回看实际数据更新本 SOP
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
《林夏》TTS 批量生产脚本
|
||||||
|
用法:
|
||||||
|
conda activate cosyvoice
|
||||||
|
cd /home/xsl/blind
|
||||||
|
python audio/batch_tts.py [--role mom] [--test]
|
||||||
|
|
||||||
|
参数:
|
||||||
|
--role ROLE 只生产指定角色(不指定则全部)
|
||||||
|
--test 只合成第一条,用于快速验证环境
|
||||||
|
--dry-run 只打印任务列表,不实际合成
|
||||||
|
"""
|
||||||
|
import os, sys, time, argparse, warnings
|
||||||
|
import torch
|
||||||
|
import soundfile as sf # torchaudio nightly requires torchcodec; use soundfile instead
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
COSYVOICE_DIR = "/home/xsl/tools/CosyVoice"
|
||||||
|
PROJECT_DIR = "/home/xsl/blind"
|
||||||
|
REF_DIR = f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref"
|
||||||
|
OUT_DIR = f"{PROJECT_DIR}/audio/00_raw/tts"
|
||||||
|
|
||||||
|
sys.path.insert(0, COSYVOICE_DIR)
|
||||||
|
sys.path.insert(0, f"{COSYVOICE_DIR}/third_party/Matcha-TTS")
|
||||||
|
os.chdir(COSYVOICE_DIR)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 角色配置(对应 SOP §4.2)
|
||||||
|
# ref_wav / ref_text 在参考音频备齐前先留空
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
ROLES = {
|
||||||
|
"mom": {
|
||||||
|
"speed": 0.92,
|
||||||
|
"ref_wav": f"{REF_DIR}/mom_ref.wav",
|
||||||
|
"ref_text": "", # ← 参考音频对应文字,备齐后填入
|
||||||
|
},
|
||||||
|
"azhe": {
|
||||||
|
"speed": 1.05,
|
||||||
|
"ref_wav": f"{REF_DIR}/azhe_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"xiaomei": {
|
||||||
|
"speed": 1.10,
|
||||||
|
"ref_wav": f"{REF_DIR}/xiaomei_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"xiaomei_drunk": { # 醉语音用同参考,speed 调慢
|
||||||
|
"speed": 0.95,
|
||||||
|
"ref_wav": f"{REF_DIR}/xiaomei_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"zhounan": {
|
||||||
|
"speed": 0.95,
|
||||||
|
"ref_wav": f"{REF_DIR}/zhounan_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"hr": {
|
||||||
|
"speed": 1.00,
|
||||||
|
"ref_wav": f"{REF_DIR}/hr_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"anan": {
|
||||||
|
"speed": 1.15,
|
||||||
|
"ref_wav": f"{REF_DIR}/anan_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"dorm_a": {
|
||||||
|
"speed": 1.10,
|
||||||
|
"ref_wav": f"{REF_DIR}/dorm_a_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"dorm_b": {
|
||||||
|
"speed": 1.00,
|
||||||
|
"ref_wav": f"{REF_DIR}/dorm_b_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"dorm_c": {
|
||||||
|
"speed": 0.95,
|
||||||
|
"ref_wav": f"{REF_DIR}/dorm_c_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"speed": 1.20,
|
||||||
|
"ref_wav": f"{REF_DIR}/delivery_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
"self_3y": { # 复用 xiaomei,speed +0.05
|
||||||
|
"speed": 1.15,
|
||||||
|
"ref_wav": f"{REF_DIR}/xiaomei_ref.wav",
|
||||||
|
"ref_text": "",
|
||||||
|
},
|
||||||
|
# system 用预训练音色,不在这里
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 台词表(对应林夏.md §3 + §5)
|
||||||
|
# 格式: (输出文件前缀, 角色key, 台词)
|
||||||
|
# 长戏(≥30s)已按 SOP §5.3 拆分为分段
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
LINES = [
|
||||||
|
# ── 01 · 21:03 妈妈电话 ──
|
||||||
|
("lv11_2103_mom_call_01",
|
||||||
|
"mom",
|
||||||
|
"今晚煮了你爱吃的排骨,给你拍张照啊。下周五回来一趟,你高中物理老师的儿子,挺好的。"),
|
||||||
|
|
||||||
|
# ── 02 · 21:15 小美三连 ──
|
||||||
|
("lv11_2115_xiaomei_voice_01", "xiaomei", "在吗"),
|
||||||
|
("lv11_2115_xiaomei_voice_02", "xiaomei", "你被甩了我都听说了"),
|
||||||
|
("lv11_2115_xiaomei_voice_03", "xiaomei", "明天晚上喝酒,海底捞旁边那家,不许放鸽子"),
|
||||||
|
|
||||||
|
# ── 03 · 21:20 外卖小哥 ──
|
||||||
|
("lv11_2120_delivery_call_01",
|
||||||
|
"delivery",
|
||||||
|
"你好你的麻辣烫到了,放门口可以吗?"),
|
||||||
|
|
||||||
|
# ── 04 · 21:30 阿哲语音 ──
|
||||||
|
("lv11_2130_azhe_voice_01",
|
||||||
|
"azhe",
|
||||||
|
"嗯……夏夏。我那个AirPods充电盒,好像落你抽屉里了。"
|
||||||
|
"你方便的时候帮我寄一下吗,工位还要用。地址我等下发你。"),
|
||||||
|
|
||||||
|
# ── 05 · 21:45 HR 王姐 ──
|
||||||
|
("lv11_2145_hr_voice_01",
|
||||||
|
"hr",
|
||||||
|
"夏夏啊,离职流程你下周三之前办完哈,公积金那边记得跟进。"
|
||||||
|
"辛苦啦,外面有合适的工作我帮你留意。"),
|
||||||
|
|
||||||
|
# ── 06 · 22:00 安安 ──
|
||||||
|
("lv11_2200_anan_voice_01",
|
||||||
|
"anan",
|
||||||
|
"夏夏!我在helens!要不要过来!我请客!"),
|
||||||
|
|
||||||
|
# ── 07 · 22:15 寝室群 ──
|
||||||
|
("lv11_2215_dorm_a_voice_01", "dorm_a", "哎你们看,我今天升职了!"),
|
||||||
|
("lv11_2215_dorm_b_voice_01", "dorm_b", "真的啊!恭喜恭喜!太厉害了!"),
|
||||||
|
("lv11_2215_dorm_c_voice_01", "dorm_c", "厉害!那要请客啊!"),
|
||||||
|
("lv11_2215_dorm_a_voice_02", "dorm_a", "哈哈哈必须的!夏夏你怎么不说话呀"),
|
||||||
|
("lv11_2215_dorm_b_voice_02", "dorm_b", "夏夏在忙吧,别打扰她了"),
|
||||||
|
|
||||||
|
# ── 09 · 22:45 小美醉语音(分 4 段)──
|
||||||
|
("lv11_2245_xiaomei_voice_01_seg1",
|
||||||
|
"xiaomei_drunk",
|
||||||
|
"夏夏夏夏夏。你睡了吗?我跟你说,我跟你说啊……今天我喝多了……"),
|
||||||
|
("lv11_2245_xiaomei_voice_01_seg2",
|
||||||
|
"xiaomei_drunk",
|
||||||
|
"你别嫌我烦哈,我跟你说……其实啊……阿哲他,大三那年,他来追过我。"
|
||||||
|
"我没答应他。我那时候就觉得他这人,呵,差点意思。"),
|
||||||
|
("lv11_2245_xiaomei_voice_01_seg3",
|
||||||
|
"xiaomei_drunk",
|
||||||
|
"你跟他在一起的时候,我没说,因为说了也没意义嘛对吧。"
|
||||||
|
"现在他甩了你,我才敢说。"),
|
||||||
|
("lv11_2245_xiaomei_voice_01_seg4",
|
||||||
|
"xiaomei_drunk",
|
||||||
|
"夏夏,你听到了吗?你别恨我啊。……喂?算了。"),
|
||||||
|
|
||||||
|
# ── 10 · 23:00 妈妈文字 ──
|
||||||
|
("lv11_2300_mom_text_01", "mom", "睡了吗?"),
|
||||||
|
|
||||||
|
# ── 11 · 23:15 阿哲电话 ──
|
||||||
|
("lv11_2315_azhe_call_01", "azhe", "我能过去拿吗?就5分钟"),
|
||||||
|
|
||||||
|
# ── 12 · 23:30 周南第一条 ──
|
||||||
|
("lv11_2330_zhounan_voice_01",
|
||||||
|
"zhounan",
|
||||||
|
"林夏,是我,周南。你应该不记得我了吧,初三坐你后桌那个"),
|
||||||
|
|
||||||
|
# ── 13 · 23:45 周南长讲述(4 段)──
|
||||||
|
("lv11_2345_zhounan_voice_01",
|
||||||
|
"zhounan",
|
||||||
|
"其实我一直想找机会跟你说声谢谢。初三那年你借我笔记,那次考试我才没挂科。"),
|
||||||
|
("lv11_2345_zhounan_voice_02",
|
||||||
|
"zhounan",
|
||||||
|
"后来高考,我没考好。复读了一年,去了一个二本。然后就回老家了,没什么好说的。"),
|
||||||
|
("lv11_2345_zhounan_voice_03",
|
||||||
|
"zhounan",
|
||||||
|
"今年第一次来北京出差。来之前我想着,要不要找你。找了很久,才在一个校友群里看到你。"
|
||||||
|
"你现在过得怎么样?"),
|
||||||
|
("lv11_2345_zhounan_voice_04",
|
||||||
|
"zhounan",
|
||||||
|
"今天我生日。本来就想跟你说一声,对不起这么晚。"),
|
||||||
|
|
||||||
|
# ── 14 · 00:15 妈妈电话 ──
|
||||||
|
("lv11_0015_mom_call_01",
|
||||||
|
"mom",
|
||||||
|
"夏夏,你不接电话我有点担心。妈妈知道你工作忙,你早点睡。"),
|
||||||
|
|
||||||
|
# ── 16 · 02:00 妈妈长语音(分 8 段)──
|
||||||
|
("lv11_0200_mom_voice_01_seg1",
|
||||||
|
"mom",
|
||||||
|
"夏夏。妈妈知道你今天电话不接。妈妈不催你,就是想跟你说一件事。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg2",
|
||||||
|
"mom",
|
||||||
|
"你24了对吧。妈妈24那年——你应该没听我说过——"),
|
||||||
|
("lv11_0200_mom_voice_01_seg3",
|
||||||
|
"mom",
|
||||||
|
"那时候妈妈在广州,给一个香港老板做秘书,1995年。"
|
||||||
|
"那年我也被分手过。也被开除过。一模一样的春天。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg4",
|
||||||
|
"mom",
|
||||||
|
"那年我哭得特别难看。后来呢?后来就没什么后来了。"
|
||||||
|
"我回了老家,认识了你爸,生了你。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg5",
|
||||||
|
"mom",
|
||||||
|
"我跟你说这个不是想跟你说什么都会过去——"
|
||||||
|
"我想跟你说,那年夏天我没死,是因为我外婆每天晚上都给我打一个电话。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg6",
|
||||||
|
"mom",
|
||||||
|
"她不说什么,就问我今天吃饭了没。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg7",
|
||||||
|
"mom",
|
||||||
|
"我现在打给你,你不接也没事。妈妈给你发个语音,你什么时候想听就听。你不用回我。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg8",
|
||||||
|
"mom",
|
||||||
|
"你今天接了好多电话吧。妈妈不打扰你了。晚安。"),
|
||||||
|
|
||||||
|
# ── self-3y 三版(台词待编剧定稿)──
|
||||||
|
# ("lv11_0030_self-3y_voice_01", "self_3y", "(明朗版台词)"),
|
||||||
|
# ("lv11_0030_self-3y_voice_02", "self_3y", "(迷茫版台词)"),
|
||||||
|
# ("lv11_0030_self-3y_voice_03", "self_3y", "(温柔版台词)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_model():
|
||||||
|
from cosyvoice.cli.cosyvoice import CosyVoice2
|
||||||
|
print("加载 CosyVoice2 模型...")
|
||||||
|
model = CosyVoice2("pretrained_models/CosyVoice2-0.5B")
|
||||||
|
print(f"模型就绪,采样率 {model.sample_rate}Hz")
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def load_ref(role_cfg):
|
||||||
|
from cosyvoice.utils.file_utils import load_wav
|
||||||
|
wav_path = role_cfg["ref_wav"]
|
||||||
|
if not os.path.exists(wav_path):
|
||||||
|
return None, None
|
||||||
|
wav = load_wav(wav_path, 16000)
|
||||||
|
# 裁到 30s 以内
|
||||||
|
max_samples = 16000 * 30
|
||||||
|
if wav.shape[1] > max_samples:
|
||||||
|
wav = wav[:, :max_samples]
|
||||||
|
return wav, role_cfg["ref_text"]
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize(model, filename, role_key, text):
|
||||||
|
role_cfg = ROLES[role_key]
|
||||||
|
ref_wav, ref_text = load_ref(role_cfg)
|
||||||
|
|
||||||
|
out_path = f"{OUT_DIR}/{filename}_v1.wav"
|
||||||
|
if os.path.exists(out_path):
|
||||||
|
print(f" [跳过,已存在] {filename}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if ref_wav is None:
|
||||||
|
print(f" [跳过,缺参考音频] {role_key}: {role_cfg['ref_wav']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ref_text.strip():
|
||||||
|
print(f" [跳过,ref_text 未填] {role_key}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
chunks = []
|
||||||
|
for chunk in model.inference_zero_shot(
|
||||||
|
tts_text=text,
|
||||||
|
prompt_text=ref_text,
|
||||||
|
prompt_speech_16k=ref_wav,
|
||||||
|
stream=False,
|
||||||
|
speed=role_cfg["speed"],
|
||||||
|
):
|
||||||
|
chunks.append(chunk["tts_speech"])
|
||||||
|
|
||||||
|
if not chunks:
|
||||||
|
print(f" [失败,无输出] {filename}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
audio = torch.concat(chunks, dim=1)
|
||||||
|
duration = audio.shape[1] / model.sample_rate
|
||||||
|
sf.write(out_path, audio.squeeze().cpu().numpy(), model.sample_rate)
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
rtf = elapsed / duration if duration > 0 else 0
|
||||||
|
print(f" ✓ {filename} {duration:.1f}s 音频 {elapsed:.1f}s 耗时 RTF={rtf:.3f}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--role", help="只合成指定角色")
|
||||||
|
parser.add_argument("--test", action="store_true", help="只合成第一条")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="只打印任务")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
lines = LINES
|
||||||
|
if args.role:
|
||||||
|
lines = [(f, r, t) for f, r, t in lines if r == args.role or r.startswith(args.role)]
|
||||||
|
if args.test:
|
||||||
|
lines = lines[:1]
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" 《林夏》TTS 批量合成 共 {len(lines)} 条")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
for f, r, t in lines:
|
||||||
|
ref_exists = "✓" if os.path.exists(ROLES.get(r, {}).get("ref_wav", "")) else "✗缺参考"
|
||||||
|
print(f" [{ref_exists}] {f} [{r}] {t[:30]}...")
|
||||||
|
return
|
||||||
|
|
||||||
|
model = load_model()
|
||||||
|
ok, skip, fail = 0, 0, 0
|
||||||
|
for i, (filename, role_key, text) in enumerate(lines, 1):
|
||||||
|
print(f"\n[{i}/{len(lines)}] {filename}")
|
||||||
|
result = synthesize(model, filename, role_key, text)
|
||||||
|
if result is True: ok += 1
|
||||||
|
elif result is None: skip += 1
|
||||||
|
else: fail += 1
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" 完成:{ok} 条 跳过:{skip} 条 失败:{fail} 条")
|
||||||
|
print(f" 输出目录:{OUT_DIR}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
《林夏》TTS 批量生产脚本 — VoxCPM2 版
|
||||||
|
|
||||||
|
用法:
|
||||||
|
# 先确保 VoxCPM2 服务已启动:
|
||||||
|
bash audio/check_tts_env.sh
|
||||||
|
|
||||||
|
# 使用 voice/ 目录 (WAV) 的参考音色
|
||||||
|
python audio/batch_tts_voxcpm.py --source voice
|
||||||
|
|
||||||
|
# 使用 voice_mp3/ 目录 (MP3) 的参考音色
|
||||||
|
python audio/batch_tts_voxcpm.py --source voice_mp3
|
||||||
|
|
||||||
|
# 只生产指定角色
|
||||||
|
python audio/batch_tts_voxcpm.py --source voice --role mom --test
|
||||||
|
|
||||||
|
# 干跑(只看任务列表)
|
||||||
|
python audio/batch_tts_voxcpm.py --source voice --dry-run
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import glob
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 配置
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
PROJECT_DIR = "/home/xsl/blind"
|
||||||
|
OUT_DIR = f"{PROJECT_DIR}/audio/00_raw/tts"
|
||||||
|
|
||||||
|
HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1")
|
||||||
|
PORT = os.environ.get("VOXCPM_PORT", "8000")
|
||||||
|
BASE_URL = f"http://{HOST}:{PORT}"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 角色配置
|
||||||
|
# dir: voice/ 下的子目录名
|
||||||
|
# speed: 语速倍率(通过 ffmpeg atempo 后处理)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
ROLES = {
|
||||||
|
"mom": {
|
||||||
|
"dir": "mom",
|
||||||
|
"speed": 0.92,
|
||||||
|
},
|
||||||
|
"azhe": {
|
||||||
|
"dir": "azhe",
|
||||||
|
"speed": 1.05,
|
||||||
|
},
|
||||||
|
"xiaomei": {
|
||||||
|
"dir": "xiaomei",
|
||||||
|
"speed": 1.10,
|
||||||
|
},
|
||||||
|
"xiaomei_drunk": {
|
||||||
|
"dir": "xiaomei",
|
||||||
|
"speed": 0.95,
|
||||||
|
},
|
||||||
|
"zhounan": {
|
||||||
|
"dir": "zhounan",
|
||||||
|
"speed": 0.95,
|
||||||
|
},
|
||||||
|
"hr": {
|
||||||
|
"dir": "hr",
|
||||||
|
"speed": 1.00,
|
||||||
|
},
|
||||||
|
"anan": {
|
||||||
|
"dir": "anan",
|
||||||
|
"speed": 1.15,
|
||||||
|
},
|
||||||
|
"dorm_a": {
|
||||||
|
"dir": "dorm_a",
|
||||||
|
"speed": 1.10,
|
||||||
|
},
|
||||||
|
"dorm_b": {
|
||||||
|
"dir": "dorm_b",
|
||||||
|
"speed": 1.00,
|
||||||
|
},
|
||||||
|
"dorm_c": {
|
||||||
|
"dir": "dorm_c",
|
||||||
|
"speed": 0.95,
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"dir": "delivery",
|
||||||
|
"speed": 1.20,
|
||||||
|
},
|
||||||
|
"self_3y": {
|
||||||
|
"dir": "xiaomei",
|
||||||
|
"speed": 1.15,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 台词表(对应林夏.md)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
LINES = [
|
||||||
|
# ── 01 · 21:03 妈妈电话 ──
|
||||||
|
("lv11_2103_mom_call_01",
|
||||||
|
"mom",
|
||||||
|
"今晚煮了你爱吃的排骨,给你拍张照啊。下周五回来一趟,你高中物理老师的儿子,挺好的。"),
|
||||||
|
|
||||||
|
# ── 02 · 21:15 小美三连 ──
|
||||||
|
("lv11_2115_xiaomei_voice_01", "xiaomei", "在吗"),
|
||||||
|
("lv11_2115_xiaomei_voice_02", "xiaomei", "你被甩了我都听说了"),
|
||||||
|
("lv11_2115_xiaomei_voice_03", "xiaomei", "明天晚上喝酒,海底捞旁边那家,不许放鸽子"),
|
||||||
|
|
||||||
|
# ── 03 · 21:20 外卖小哥 ──
|
||||||
|
("lv11_2120_delivery_call_01",
|
||||||
|
"delivery",
|
||||||
|
"你好你的麻辣烫到了,放门口可以吗?"),
|
||||||
|
|
||||||
|
# ── 04 · 21:30 阿哲语音 ──
|
||||||
|
("lv11_2130_azhe_voice_01",
|
||||||
|
"azhe",
|
||||||
|
"嗯……夏夏。我那个AirPods充电盒,好像落你抽屉里了。"
|
||||||
|
"你方便的时候帮我寄一下吗,工位还要用。地址我等下发你。"),
|
||||||
|
|
||||||
|
# ── 05 · 21:45 HR 王姐 ──
|
||||||
|
("lv11_2145_hr_voice_01",
|
||||||
|
"hr",
|
||||||
|
"夏夏啊,离职流程你下周三之前办完哈,公积金那边记得跟进。"
|
||||||
|
"辛苦啦,外面有合适的工作我帮你留意。"),
|
||||||
|
|
||||||
|
# ── 06 · 22:00 安安 ──
|
||||||
|
("lv11_2200_anan_voice_01",
|
||||||
|
"anan",
|
||||||
|
"夏夏!我在helens!要不要过来!我请客!"),
|
||||||
|
|
||||||
|
# ── 07 · 22:15 寝室群 ──
|
||||||
|
("lv11_2215_dorm_a_voice_01", "dorm_a", "哎你们看,我今天升职了!"),
|
||||||
|
("lv11_2215_dorm_b_voice_01", "dorm_b", "真的啊!恭喜恭喜!太厉害了!"),
|
||||||
|
("lv11_2215_dorm_c_voice_01", "dorm_c", "厉害!那要请客啊!"),
|
||||||
|
("lv11_2215_dorm_a_voice_02", "dorm_a", "哈哈哈必须的!夏夏你怎么不说话呀"),
|
||||||
|
("lv11_2215_dorm_b_voice_02", "dorm_b", "夏夏在忙吧,别打扰她了"),
|
||||||
|
|
||||||
|
# ── 09 · 22:45 小美醉语音(分 4 段)──
|
||||||
|
("lv11_2245_xiaomei_voice_01_seg1",
|
||||||
|
"xiaomei_drunk",
|
||||||
|
"夏夏夏夏夏。你睡了吗?我跟你说,我跟你说啊……今天我喝多了……"),
|
||||||
|
("lv11_2245_xiaomei_voice_01_seg2",
|
||||||
|
"xiaomei_drunk",
|
||||||
|
"你别嫌我烦哈,我跟你说……其实啊……阿哲他,大三那年,他来追过我。"
|
||||||
|
"我没答应他。我那时候就觉得他这人,呵,差点意思。"),
|
||||||
|
("lv11_2245_xiaomei_voice_01_seg3",
|
||||||
|
"xiaomei_drunk",
|
||||||
|
"你跟他在一起的时候,我没说,因为说了也没意义嘛对吧。"
|
||||||
|
"现在他甩了你,我才敢说。"),
|
||||||
|
("lv11_2245_xiaomei_voice_01_seg4",
|
||||||
|
"xiaomei_drunk",
|
||||||
|
"夏夏,你听到了吗?你别恨我啊。……喂?算了。"),
|
||||||
|
|
||||||
|
# ── 10 · 23:00 妈妈文字 ──
|
||||||
|
("lv11_2300_mom_text_01", "mom", "睡了吗?"),
|
||||||
|
|
||||||
|
# ── 11 · 23:15 阿哲电话 ──
|
||||||
|
("lv11_2315_azhe_call_01", "azhe", "我能过去拿吗?就5分钟"),
|
||||||
|
|
||||||
|
# ── 12 · 23:30 周南第一条 ──
|
||||||
|
("lv11_2330_zhounan_voice_01",
|
||||||
|
"zhounan",
|
||||||
|
"林夏,是我,周南。你应该不记得我了吧,初三坐你后桌那个"),
|
||||||
|
|
||||||
|
# ── 13 · 23:45 周南长讲述(4 段)──
|
||||||
|
("lv11_2345_zhounan_voice_01",
|
||||||
|
"zhounan",
|
||||||
|
"其实我一直想找机会跟你说声谢谢。初三那年你借我笔记,那次考试我才没挂科。"),
|
||||||
|
("lv11_2345_zhounan_voice_02",
|
||||||
|
"zhounan",
|
||||||
|
"后来高考,我没考好。复读了一年,去了一个二本。然后就回老家了,没什么好说的。"),
|
||||||
|
("lv11_2345_zhounan_voice_03",
|
||||||
|
"zhounan",
|
||||||
|
"今年第一次来北京出差。来之前我想着,要不要找你。找了很久,才在一个校友群里看到你。"
|
||||||
|
"你现在过得怎么样?"),
|
||||||
|
("lv11_2345_zhounan_voice_04",
|
||||||
|
"zhounan",
|
||||||
|
"今天我生日。本来就想跟你说一声,对不起这么晚。"),
|
||||||
|
|
||||||
|
# ── 14 · 00:15 妈妈电话 ──
|
||||||
|
("lv11_0015_mom_call_01",
|
||||||
|
"mom",
|
||||||
|
"夏夏,你不接电话我有点担心。妈妈知道你工作忙,你早点睡。"),
|
||||||
|
|
||||||
|
# ── 16 · 02:00 妈妈长语音(分 8 段)──
|
||||||
|
("lv11_0200_mom_voice_01_seg1",
|
||||||
|
"mom",
|
||||||
|
"夏夏。妈妈知道你今天电话不接。妈妈不催你,就是想跟你说一件事。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg2",
|
||||||
|
"mom",
|
||||||
|
"你24了对吧。妈妈24那年——你应该没听我说过——"),
|
||||||
|
("lv11_0200_mom_voice_01_seg3",
|
||||||
|
"mom",
|
||||||
|
"那时候妈妈在广州,给一个香港老板做秘书,1995年。"
|
||||||
|
"那年我也被分手过。也被开除过。一模一样的春天。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg4",
|
||||||
|
"mom",
|
||||||
|
"那年我哭得特别难看。后来呢?后来就没什么后来了。"
|
||||||
|
"我回了老家,认识了你爸,生了你。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg5",
|
||||||
|
"mom",
|
||||||
|
"我跟你说这个不是想跟你说什么都会过去——"
|
||||||
|
"我想跟你说,那年夏天我没死,是因为我外婆每天晚上都给我打一个电话。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg6",
|
||||||
|
"mom",
|
||||||
|
"她不说什么,就问我今天吃饭了没。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg7",
|
||||||
|
"mom",
|
||||||
|
"我现在打给你,你不接也没事。妈妈给你发个语音,你什么时候想听就听。你不用回我。"),
|
||||||
|
("lv11_0200_mom_voice_01_seg8",
|
||||||
|
"mom",
|
||||||
|
"你今天接了好多电话吧。妈妈不打扰你了。晚安。"),
|
||||||
|
|
||||||
|
# ── self-3y 三版(台词待编剧定稿)──
|
||||||
|
# ("lv11_0030_self-3y_voice_01", "self_3y", "(明朗版台词)"),
|
||||||
|
# ("lv11_0030_self-3y_voice_02", "self_3y", "(迷茫版台词)"),
|
||||||
|
# ("lv11_0030_self-3y_voice_03", "self_3y", "(温柔版台词)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 工具函数
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def find_ref_audio(source_dir, role_key):
|
||||||
|
"""在 source_dir/{dir}/ 下找到第一个音频文件作为参考音色"""
|
||||||
|
role_dir = os.path.join(source_dir, ROLES[role_key]["dir"])
|
||||||
|
if not os.path.isdir(role_dir):
|
||||||
|
return None
|
||||||
|
exts = ("*.wav", "*.mp3", "*.flac", "*.ogg")
|
||||||
|
for ext in exts:
|
||||||
|
files = sorted(glob.glob(os.path.join(role_dir, ext)))
|
||||||
|
if files:
|
||||||
|
return files[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def wav_to_base64(wav_path):
|
||||||
|
"""读取 WAV 文件并转 base64"""
|
||||||
|
with open(wav_path, "rb") as f:
|
||||||
|
return base64.b64encode(f.read()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_wav(src_path, dst_path):
|
||||||
|
"""用 ffmpeg 把任意音频转为 16kHz mono WAV"""
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", "-i", src_path,
|
||||||
|
"-ar", "16000", "-ac", "1",
|
||||||
|
"-acodec", "pcm_s16le",
|
||||||
|
dst_path,
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_speed(wav_path, speed, out_path):
|
||||||
|
"""用 ffmpeg atempo 调整语速"""
|
||||||
|
if abs(speed - 1.0) < 0.01:
|
||||||
|
# speed ≈ 1.0,直接复制
|
||||||
|
if wav_path != out_path:
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(wav_path, out_path)
|
||||||
|
return
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", "-i", wav_path,
|
||||||
|
"-filter:a", f"atempo={speed}",
|
||||||
|
"-acodec", "pcm_s16le",
|
||||||
|
out_path,
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def check_server():
|
||||||
|
"""检查 VoxCPM 服务是否就绪"""
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{BASE_URL}/health", timeout=5)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def register_voice(audio_path):
|
||||||
|
"""向 VoxCPM 注册参考音色,返回 voice_id"""
|
||||||
|
b64 = wav_to_base64(audio_path)
|
||||||
|
r = requests.post(
|
||||||
|
f"{BASE_URL}/v1/voices",
|
||||||
|
json={"wav_base64": b64},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["voice_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize(voice_id, text, cfg_value=2.0, inference_timesteps=10):
|
||||||
|
"""调用 VoxCPM 合成语音,返回 WAV bytes"""
|
||||||
|
r = requests.post(
|
||||||
|
f"{BASE_URL}/v1/speech",
|
||||||
|
json={
|
||||||
|
"text": text,
|
||||||
|
"voice_id": voice_id,
|
||||||
|
"cfg_value": cfg_value,
|
||||||
|
"inference_timesteps": inference_timesteps,
|
||||||
|
},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 主流程
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="《林夏》TTS 批量生产 — VoxCPM2 版")
|
||||||
|
parser.add_argument("--source", choices=["voice", "voice_mp3"], default="voice",
|
||||||
|
help="音色来源目录 (默认: voice)")
|
||||||
|
parser.add_argument("--role", help="只合成指定角色")
|
||||||
|
parser.add_argument("--test", action="store_true", help="只合成第一条")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="只打印任务列表")
|
||||||
|
parser.add_argument("--cfg", type=float, default=2.0, help="VoxCPM cfg_value (默认: 2.0)")
|
||||||
|
parser.add_argument("--steps", type=int, default=10, help="VoxCPM inference_timesteps (默认: 10)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
source_dir = os.path.join(PROJECT_DIR, args.source)
|
||||||
|
source_tag = "wav" if args.source == "voice" else "mp3"
|
||||||
|
|
||||||
|
lines = LINES
|
||||||
|
if args.role:
|
||||||
|
lines = [(f, r, t) for f, r, t in lines if r == args.role or r.startswith(args.role)]
|
||||||
|
if args.test:
|
||||||
|
lines = lines[:1]
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f" 《林夏》TTS 批量合成 — VoxCPM2")
|
||||||
|
print(f" 音色源: {args.source}/ ({source_tag})")
|
||||||
|
print(f" 共 {len(lines)} 条")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
# dry-run: 只打印任务
|
||||||
|
if args.dry_run:
|
||||||
|
voice_cache = {}
|
||||||
|
for f, r, t in lines:
|
||||||
|
ref = find_ref_audio(source_dir, r)
|
||||||
|
if ref is None:
|
||||||
|
print(f" [✗缺参考] {f} [{r}] {t[:30]}...")
|
||||||
|
continue
|
||||||
|
if r not in voice_cache:
|
||||||
|
voice_cache[r] = ref
|
||||||
|
count = len(glob.glob(os.path.join(source_dir, ROLES[r]["dir"], "*")))
|
||||||
|
extra = f" (目录有 {count} 个文件)" if count > 1 else ""
|
||||||
|
print(f" [✓] {f} [{r}] ref={os.path.basename(ref)}{extra} speed={ROLES[r]['speed']}")
|
||||||
|
else:
|
||||||
|
print(f" [✓] {f} [{r}] {t[:30]}...")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查服务
|
||||||
|
if not check_server():
|
||||||
|
print(f"\n[错误] VoxCPM 服务未就绪: {BASE_URL}/health")
|
||||||
|
print(f"请先启动服务:")
|
||||||
|
print(f" bash audio/check_tts_env.sh")
|
||||||
|
print(f" 或手动启动:")
|
||||||
|
print(f" bash /home/xsl/AutoVideo/start-voxcpm.sh")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f" 服务就绪: {BASE_URL}/health\n")
|
||||||
|
|
||||||
|
# 预处理:转换 MP3 → WAV,注册音色
|
||||||
|
voice_ids = {} # role_key → voice_id
|
||||||
|
tmp_wav_dir = tempfile.mkdtemp(prefix="voxcpm_ref_")
|
||||||
|
|
||||||
|
for role_key in set(r for _, r, _ in lines):
|
||||||
|
ref_path = find_ref_audio(source_dir, role_key)
|
||||||
|
if ref_path is None:
|
||||||
|
print(f" [跳过] {role_key}: 未找到参考音频 ({source_dir}/{ROLES[role_key]['dir']}/)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 如果是 MP3 或其他格式,先转为 WAV
|
||||||
|
if ref_path.lower().endswith(".wav"):
|
||||||
|
wav_path = ref_path
|
||||||
|
else:
|
||||||
|
wav_path = os.path.join(tmp_wav_dir, f"{role_key}_ref.wav")
|
||||||
|
print(f" 转换参考音频: {os.path.basename(ref_path)} → WAV ...", end=" ", flush=True)
|
||||||
|
convert_to_wav(ref_path, wav_path)
|
||||||
|
print("ok")
|
||||||
|
|
||||||
|
# 注册音色
|
||||||
|
print(f" 注册音色: {role_key} ({os.path.basename(ref_path)}) ...", end=" ", flush=True)
|
||||||
|
try:
|
||||||
|
vid = register_voice(wav_path)
|
||||||
|
voice_ids[role_key] = vid
|
||||||
|
print(f"ok voice_id={vid}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"失败: {e}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 合成
|
||||||
|
ok_count, skip_count, fail_count = 0, 0, 0
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
for i, (filename, role_key, text) in enumerate(lines, 1):
|
||||||
|
out_path = os.path.join(OUT_DIR, f"{filename}_{source_tag}_v1.wav")
|
||||||
|
|
||||||
|
if os.path.exists(out_path):
|
||||||
|
print(f" [{i}/{len(lines)}] [跳过,已存在] {filename}")
|
||||||
|
ok_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role_key not in voice_ids:
|
||||||
|
print(f" [{i}/{len(lines)}] [跳过,无音色] {role_key}: {filename}")
|
||||||
|
skip_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" [{i}/{len(lines)}] {filename} [{role_key}] ...", end=" ", flush=True)
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
wav_bytes = synthesize(
|
||||||
|
voice_ids[role_key], text,
|
||||||
|
cfg_value=args.cfg,
|
||||||
|
inference_timesteps=args.steps,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 先写原始合成结果到临时文件
|
||||||
|
tmp_out = out_path + ".raw.wav"
|
||||||
|
with open(tmp_out, "wb") as f:
|
||||||
|
f.write(wav_bytes)
|
||||||
|
|
||||||
|
# 语速调整
|
||||||
|
speed = ROLES[role_key]["speed"]
|
||||||
|
apply_speed(tmp_out, speed, out_path)
|
||||||
|
|
||||||
|
# 清理临时文件
|
||||||
|
if os.path.exists(tmp_out) and tmp_out != out_path:
|
||||||
|
os.remove(tmp_out)
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
duration = len(wav_bytes) / 48000 / 2 # 粗估 48kHz 16bit
|
||||||
|
print(f"✓ {elapsed:.1f}s speed={speed}")
|
||||||
|
|
||||||
|
ok_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {e}")
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
# 清理临时 WAV
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(tmp_wav_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f" 完成:{ok_count} 条 跳过:{skip_count} 条 失败:{fail_count} 条")
|
||||||
|
print(f" 输出目录:{OUT_DIR}")
|
||||||
|
print(f" 文件标记:*_{source_tag}_v1.wav")
|
||||||
|
print(f"{'=' * 60}\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# VoxCPM TTS chain health check for blind/audio workflow.
|
||||||
|
# Usage:
|
||||||
|
# bash audio/check_tts_env.sh # quick check
|
||||||
|
# bash audio/check_tts_env.sh --full # quick check + test synthesis
|
||||||
|
|
||||||
|
HOST="${VOXCPM_HOST:-127.0.0.1}"
|
||||||
|
PORT="${VOXCPM_PORT:-8000}"
|
||||||
|
HEALTH_URL="http://${HOST}:${PORT}/health"
|
||||||
|
START_SCRIPT="${VOXCPM_START_SCRIPT:-/home/xsl/AutoVideo/start-voxcpm.sh}"
|
||||||
|
MODEL_DIR="${VOXCPM_MODEL_DIR:-/home/xsl/models/VoxCPM2}"
|
||||||
|
VENV_PYTHON="${VOXCPM_PYTHON:-/home/xsl/tts-server/.venv/bin/python}"
|
||||||
|
LOG_FILE="${VOXCPM_LOG_FILE:-/tmp/voxcpm-server.log}"
|
||||||
|
FULL_CHECK=0
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--full" ]]; then
|
||||||
|
FULL_CHECK=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok() { echo "[OK] $*"; }
|
||||||
|
warn() { echo "[WARN] $*"; }
|
||||||
|
err() { echo "[ERROR] $*"; exit 1; }
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || err "缺少命令: $cmd"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_health() {
|
||||||
|
local max_retry="${1:-30}"
|
||||||
|
local i=1
|
||||||
|
while [[ "$i" -le "$max_retry" ]]; do
|
||||||
|
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
((i++))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
print_startup_hint() {
|
||||||
|
[[ -f "$LOG_FILE" ]] || return 0
|
||||||
|
if grep -q "speech_zipenhancer_ans_multiloss_16k_base" "$LOG_FILE"; then
|
||||||
|
warn "检测到降噪模型下载失败(ModelScope),常见原因是 DNS/网络不可达。"
|
||||||
|
warn "建议:先确保可访问 modelscope,或在服务端改为可关闭 denoiser。"
|
||||||
|
elif grep -q "ConnectionError" "$LOG_FILE"; then
|
||||||
|
warn "检测到网络连接错误,请检查当前网络和 DNS。"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_server() {
|
||||||
|
local denoiser="${1:-1}"
|
||||||
|
VOXCPM_ENABLE_DENOISER="$denoiser" nohup "$START_SCRIPT" >"$LOG_FILE" 2>&1 &
|
||||||
|
local pid=$!
|
||||||
|
ok "已发起启动,PID=$pid,日志: $LOG_FILE (denoiser=$denoiser)"
|
||||||
|
if wait_health 90; then
|
||||||
|
ok "服务启动成功: $HEALTH_URL"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if kill -0 "$pid" >/dev/null 2>&1; then
|
||||||
|
kill "$pid" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
is_port_busy() {
|
||||||
|
ss -lnt | awk '{print $4}' | grep -qE "(^|:)${PORT}$"
|
||||||
|
}
|
||||||
|
|
||||||
|
quick_checks() {
|
||||||
|
echo "=== VoxCPM 快速环境检查 ==="
|
||||||
|
require_cmd curl
|
||||||
|
require_cmd ffmpeg
|
||||||
|
require_cmd python3
|
||||||
|
require_cmd ss
|
||||||
|
|
||||||
|
[[ -f "$VENV_PYTHON" ]] || err "Python 虚拟环境不存在: $VENV_PYTHON"
|
||||||
|
[[ -x "$START_SCRIPT" ]] || warn "启动脚本不可执行,建议: chmod +x $START_SCRIPT"
|
||||||
|
[[ -f "$START_SCRIPT" ]] || err "启动脚本不存在: $START_SCRIPT"
|
||||||
|
[[ -d "$MODEL_DIR" ]] || err "模型目录不存在: $MODEL_DIR"
|
||||||
|
|
||||||
|
ffmpeg -version >/dev/null 2>&1 || err "FFmpeg 不可用"
|
||||||
|
ok "基础依赖可用(curl / ffmpeg / python3 / ss)"
|
||||||
|
ok "模型目录存在: $MODEL_DIR"
|
||||||
|
ok "虚拟环境存在: $VENV_PYTHON"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_server() {
|
||||||
|
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
|
ok "TTS 服务已运行: $HEALTH_URL"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "服务未就绪,尝试启动: $START_SCRIPT"
|
||||||
|
if is_port_busy; then
|
||||||
|
warn "端口 $PORT 已被占用,但 /health 不可达,请检查占用进程"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if start_server 1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_startup_hint
|
||||||
|
if grep -q "speech_zipenhancer_ans_multiloss_16k_base" "$LOG_FILE"; then
|
||||||
|
warn "检测到 denoiser 远端依赖失败,自动回退到 denoiser=0 重试。"
|
||||||
|
if start_server 0; then
|
||||||
|
warn "当前服务以 denoiser=0 运行(更稳,降噪关闭)。"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_startup_hint
|
||||||
|
err "服务启动超时,查看日志: $LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
full_check() {
|
||||||
|
echo "=== VoxCPM 全链路测试(注册音色 + 合成) ==="
|
||||||
|
local ref_wav="/tmp/voxcpm_check_ref.wav"
|
||||||
|
local out_wav="/tmp/voxcpm_check_tts.wav"
|
||||||
|
|
||||||
|
ffmpeg -f lavfi -i "sine=frequency=220:duration=3" -ar 16000 -ac 1 -y "$ref_wav" >/dev/null 2>&1
|
||||||
|
ok "生成测试参考音频: $ref_wav"
|
||||||
|
|
||||||
|
local payload
|
||||||
|
payload="$(python3 - <<'PY'
|
||||||
|
import base64, json
|
||||||
|
with open("/tmp/voxcpm_check_ref.wav", "rb") as f:
|
||||||
|
print(json.dumps({"wav_base64": base64.b64encode(f.read()).decode("utf-8")}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
local voice_resp
|
||||||
|
voice_resp="$(curl -fsS -X POST "http://${HOST}:${PORT}/v1/voices" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload")"
|
||||||
|
|
||||||
|
local voice_id
|
||||||
|
voice_id="$(VOICE_RESP="$voice_resp" python3 - <<'PY'
|
||||||
|
import json, os
|
||||||
|
resp = json.loads(os.environ["VOICE_RESP"])
|
||||||
|
print(resp.get("voice_id", ""))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
[[ -n "$voice_id" ]] || err "注册音色失败: $voice_resp"
|
||||||
|
ok "注册测试音色成功: $voice_id"
|
||||||
|
|
||||||
|
local speech_payload
|
||||||
|
speech_payload="$(VOICE_ID="$voice_id" python3 - <<'PY'
|
||||||
|
import json, os
|
||||||
|
print(json.dumps({
|
||||||
|
"text": "这是 VoxCPM 环境自检音频。",
|
||||||
|
"voice_id": os.environ["VOICE_ID"],
|
||||||
|
"cfg_value": 2.0,
|
||||||
|
"inference_timesteps": 10
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
local status_code
|
||||||
|
status_code="$(curl -sS -o "$out_wav" -w "%{http_code}" \
|
||||||
|
-X POST "http://${HOST}:${PORT}/v1/speech" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$speech_payload")"
|
||||||
|
|
||||||
|
[[ "$status_code" == "200" ]] || err "语音合成失败,HTTP $status_code"
|
||||||
|
[[ -s "$out_wav" ]] || err "语音合成输出为空: $out_wav"
|
||||||
|
ok "语音合成成功: $out_wav"
|
||||||
|
}
|
||||||
|
|
||||||
|
quick_checks
|
||||||
|
ensure_server
|
||||||
|
if [[ "$FULL_CHECK" -eq 1 ]]; then
|
||||||
|
full_check
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 检查完成:可开始生产 ==="
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Check reference voice library completeness for batch TTS.
|
||||||
|
# Usage:
|
||||||
|
# bash audio/check_voice_library.sh
|
||||||
|
|
||||||
|
BASE_DIR="/home/xsl/blind/audio/00_raw/tts/prompt_ref"
|
||||||
|
MANIFEST="${BASE_DIR}/ref_manifest.txt"
|
||||||
|
|
||||||
|
REQUIRED_FILES=(
|
||||||
|
"mom_ref.wav"
|
||||||
|
"azhe_ref.wav"
|
||||||
|
"xiaomei_ref.wav"
|
||||||
|
"zhounan_ref.wav"
|
||||||
|
"hr_ref.wav"
|
||||||
|
"anan_ref.wav"
|
||||||
|
"dorm_a_ref.wav"
|
||||||
|
"dorm_b_ref.wav"
|
||||||
|
"dorm_c_ref.wav"
|
||||||
|
"delivery_ref.wav"
|
||||||
|
)
|
||||||
|
|
||||||
|
ok() { echo "[OK] $*"; }
|
||||||
|
warn() { echo "[WARN] $*"; }
|
||||||
|
err() { echo "[ERROR] $*"; }
|
||||||
|
|
||||||
|
missing_count=0
|
||||||
|
warn_count=0
|
||||||
|
|
||||||
|
command -v ffprobe >/dev/null 2>&1 || {
|
||||||
|
err "缺少 ffprobe(请安装 ffmpeg)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== 角色音色库检查 ==="
|
||||||
|
echo "目录: $BASE_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ ! -f "$MANIFEST" ]]; then
|
||||||
|
err "缺少清单文件: $MANIFEST"
|
||||||
|
((missing_count+=1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
for f in "${REQUIRED_FILES[@]}"; do
|
||||||
|
path="${BASE_DIR}/${f}"
|
||||||
|
if [[ ! -f "$path" ]]; then
|
||||||
|
err "缺少参考音频: $f"
|
||||||
|
((missing_count+=1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
sample_rate="$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=noprint_wrappers=1:nokey=1 "$path" || true)"
|
||||||
|
channels="$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=noprint_wrappers=1:nokey=1 "$path" || true)"
|
||||||
|
duration="$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$path" || true)"
|
||||||
|
duration_int="${duration%.*}"
|
||||||
|
|
||||||
|
if [[ -z "$sample_rate" || -z "$channels" || -z "$duration" ]]; then
|
||||||
|
warn "无法读取音频信息: $f"
|
||||||
|
((warn_count+=1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$sample_rate" -lt 16000 ]]; then
|
||||||
|
warn "$f 采样率 ${sample_rate}Hz,低于 16kHz"
|
||||||
|
((warn_count+=1))
|
||||||
|
fi
|
||||||
|
if [[ "$channels" -ne 1 ]]; then
|
||||||
|
warn "$f 声道数 ${channels},建议为单声道"
|
||||||
|
((warn_count+=1))
|
||||||
|
fi
|
||||||
|
if [[ "$duration_int" -lt 5 || "$duration_int" -gt 30 ]]; then
|
||||||
|
warn "$f 时长 ${duration}s,建议 5-30s"
|
||||||
|
((warn_count+=1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "$f 通过基础检测 (${sample_rate}Hz/${channels}ch/${duration}s)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [[ -f "$MANIFEST" ]]; then
|
||||||
|
for f in "${REQUIRED_FILES[@]}"; do
|
||||||
|
line="$(awk -F'|' -v file="$f" '$1 ~ file {print $0}' "$MANIFEST" | head -n 1)"
|
||||||
|
if [[ -z "$line" ]]; then
|
||||||
|
err "清单缺少条目: $f"
|
||||||
|
((missing_count+=1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
text="$(echo "$line" | awk -F'|' '{sub(/^[ \t]+/, "", $2); sub(/[ \t]+$/, "", $2); print $2}')"
|
||||||
|
if [[ -z "$text" || "$text" == TODO:* ]]; then
|
||||||
|
warn "清单文本未完成: $f"
|
||||||
|
((warn_count+=1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 结果 ==="
|
||||||
|
echo "缺失项: $missing_count"
|
||||||
|
echo "警告项: $warn_count"
|
||||||
|
|
||||||
|
if [[ "$missing_count" -gt 0 ]]; then
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$warn_count" -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "音色库完整,可进入批量 TTS。"
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
《林夏》BGM + 角色铃声 批量生成脚本
|
||||||
|
使用 ACE-Step 1.5 本地模型
|
||||||
|
|
||||||
|
用法:
|
||||||
|
conda activate acestep
|
||||||
|
python audio/gen_music_acestep.py [--category bgm|ringtone|all] [--dry-run]
|
||||||
|
"""
|
||||||
|
import os, sys, time, argparse
|
||||||
|
|
||||||
|
# ── ACE-Step 修复(RTX 5090 cuBLAS)──────────────────────────
|
||||||
|
import torch
|
||||||
|
torch.backends.cuda.preferred_blas_library("cublaslt")
|
||||||
|
|
||||||
|
REPO_DIR = "/home/xsl/tools/ACE-Step-1.5"
|
||||||
|
OUT_DIR = "/home/xsl/blind/audio/00_raw/music"
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
sys.path.insert(0, REPO_DIR)
|
||||||
|
|
||||||
|
# ── BGM 四段 ─────────────────────────────────────────────────
|
||||||
|
BGM_TRACKS = [
|
||||||
|
{
|
||||||
|
"filename": "lv11_bgm_m1_main_loop",
|
||||||
|
"duration": 90,
|
||||||
|
"caption": (
|
||||||
|
"lo-fi midnight bedroom, soft piano, light vinyl crackle, "
|
||||||
|
"muted piano, subtle synth pad, occasional distant rain, "
|
||||||
|
"melancholy but warm, alone but not lonely, "
|
||||||
|
"ambient loop, no drums, no clear melody peak, 70 bpm"
|
||||||
|
),
|
||||||
|
"lyrics": "",
|
||||||
|
"steps": 20,
|
||||||
|
"seed": 2103,
|
||||||
|
"note": "M1 · 21:00 主题 Loop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_bgm_m2_suspense",
|
||||||
|
"duration": 30,
|
||||||
|
"caption": (
|
||||||
|
"subtle ambient suspense, cinematic tension, "
|
||||||
|
"low drone, single hanging piano note high register, sparse strings, "
|
||||||
|
"unsettling but not horror, like a question mark, "
|
||||||
|
"builds slowly, ends on unresolved chord, 60 bpm"
|
||||||
|
),
|
||||||
|
"lyrics": "",
|
||||||
|
"steps": 20,
|
||||||
|
"seed": 2230,
|
||||||
|
"note": "M2 · 22:30 悬疑过门",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_bgm_m3_zhounan",
|
||||||
|
"duration": 120,
|
||||||
|
"caption": (
|
||||||
|
"gentle indie folk, intimate acoustic, "
|
||||||
|
"fingerpicked nylon guitar, wordless soft humming no lyrics just mmm, "
|
||||||
|
"light shaker, hopeful nostalgic like reading an old letter, "
|
||||||
|
"gradual build soft climax at 80 seconds gentle outro, 75 bpm"
|
||||||
|
),
|
||||||
|
"lyrics": "",
|
||||||
|
"steps": 25,
|
||||||
|
"seed": 2345,
|
||||||
|
"note": "M3 · 23:45 周南段落",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_bgm_m4_ending",
|
||||||
|
"duration": 90,
|
||||||
|
"caption": (
|
||||||
|
"warm cinematic piano, dawn moment, solo piano, "
|
||||||
|
"soft string pad entering at 30 seconds, "
|
||||||
|
"like the moment you realize you survived a hard night, "
|
||||||
|
"single melody line, no drums, fade out, 50 bpm"
|
||||||
|
),
|
||||||
|
"lyrics": "",
|
||||||
|
"steps": 20,
|
||||||
|
"seed": 200,
|
||||||
|
"note": "M4 · 02:00 结尾",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 角色铃声 9 个 ──────────────────────────────────────────────
|
||||||
|
RINGTONES = [
|
||||||
|
{
|
||||||
|
"filename": "lv11_ring_mom",
|
||||||
|
"duration": 6,
|
||||||
|
"caption": "warm marimba ringtone, simple 4-note motif, old Nokia style, 70 bpm, loop",
|
||||||
|
"note": "妈妈铃声",
|
||||||
|
"seed": 1001,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_ring_azhe",
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "cold minimal electronic ringtone, 2 notes only, distant, 80 bpm, loop",
|
||||||
|
"note": "阿哲铃声",
|
||||||
|
"seed": 1002,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_ring_xiaomei",
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "cheerful pop ringtone, ukulele plucks, 4-bar phrase, energetic, 100 bpm, loop",
|
||||||
|
"note": "小美铃声",
|
||||||
|
"seed": 1003,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_ring_zhounan",
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "nostalgic 90s chime, soft marimba, gentle, 70 bpm, loop",
|
||||||
|
"note": "周南铃声",
|
||||||
|
"seed": 1004,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_ring_hr",
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "corporate professional ringtone, neutral marimba, clean, 90 bpm, loop",
|
||||||
|
"note": "HR王姐铃声",
|
||||||
|
"seed": 1005,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_ring_anan",
|
||||||
|
"duration": 4,
|
||||||
|
"caption": "bouncy J-pop intro ringtone, bright, energetic, 110 bpm, loop",
|
||||||
|
"note": "安安铃声",
|
||||||
|
"seed": 1006,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_ring_dorm_group",
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "soft group notification chime, friendly, warm bells, 80 bpm, loop",
|
||||||
|
"note": "寝室群铃声",
|
||||||
|
"seed": 1007,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_ring_unknown",
|
||||||
|
"duration": 6,
|
||||||
|
"caption": "eerie low drone ringtone, no melody, unsettling, 40 bpm, dark ambient, loop",
|
||||||
|
"note": "未知号码铃声",
|
||||||
|
"seed": 1008,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "lv11_ring_delivery",
|
||||||
|
"duration": 3,
|
||||||
|
"caption": "short delivery notification beep, simple two-tone, neutral, 90 bpm",
|
||||||
|
"note": "外卖铃声",
|
||||||
|
"seed": 1009,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_handler():
|
||||||
|
"""初始化 ACE-Step handler(带 cublaslt 修复)"""
|
||||||
|
from acestep.handler import AceStepHandler
|
||||||
|
print("正在加载 ACE-Step 模型...", end=" ", flush=True)
|
||||||
|
t0 = time.time()
|
||||||
|
handler = AceStepHandler()
|
||||||
|
handler.initialize_service(
|
||||||
|
project_root=REPO_DIR,
|
||||||
|
config_path="acestep-v15-turbo",
|
||||||
|
device="cuda",
|
||||||
|
)
|
||||||
|
print(f"就绪 ({time.time()-t0:.1f}s) dtype={handler.dtype}")
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def generate_one(handler, track, is_ringtone=False):
|
||||||
|
"""生成单条音频,返回输出路径或 None"""
|
||||||
|
from acestep.inference import GenerationParams, GenerationConfig, generate_music
|
||||||
|
|
||||||
|
filename = track["filename"]
|
||||||
|
out_path = os.path.join(OUT_DIR, f"{filename}_v1.wav")
|
||||||
|
|
||||||
|
if os.path.exists(out_path):
|
||||||
|
print(f" [跳过] {filename}(已存在)")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
note = track.get("note", "")
|
||||||
|
print(f" 生成: {filename} [{note}] {track['duration']}s ...", end=" ", flush=True)
|
||||||
|
|
||||||
|
params = GenerationParams(
|
||||||
|
caption=track["caption"],
|
||||||
|
lyrics=track.get("lyrics", ""),
|
||||||
|
duration=track["duration"],
|
||||||
|
instrumental=True,
|
||||||
|
inference_steps=track.get("steps", 20),
|
||||||
|
seed=track.get("seed", 42),
|
||||||
|
)
|
||||||
|
from acestep.inference import GenerationConfig
|
||||||
|
config = GenerationConfig(batch_size=1, audio_format="wav")
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
result = generate_music(handler, None, params, config, save_dir=OUT_DIR)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
if result.success:
|
||||||
|
gen_path = result.audios[0]["path"]
|
||||||
|
# 重命名为规范名称
|
||||||
|
if gen_path != out_path:
|
||||||
|
os.rename(gen_path, out_path)
|
||||||
|
print(f"✓ {elapsed:.1f}s → {os.path.basename(out_path)}")
|
||||||
|
return out_path
|
||||||
|
else:
|
||||||
|
print(f"✗ {result.error}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--category", choices=["bgm", "ringtone", "all"], default="all")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
all_tracks = []
|
||||||
|
if args.category in ("bgm", "all"):
|
||||||
|
all_tracks += [("bgm", t) for t in BGM_TRACKS]
|
||||||
|
if args.category in ("ringtone", "all"):
|
||||||
|
all_tracks += [("ringtone", t) for t in RINGTONES]
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"《林夏》音乐生成 共 {len(all_tracks)} 条")
|
||||||
|
print(f"输出目录: {OUT_DIR}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
for cat, t in all_tracks:
|
||||||
|
exists = "✓" if os.path.exists(os.path.join(OUT_DIR, f"{t['filename']}_v1.wav")) else "○"
|
||||||
|
print(f" [{exists}][{cat}] {t['filename']} {t['duration']}s {t.get('note','')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
handler = load_handler()
|
||||||
|
|
||||||
|
ok, skip, fail = 0, 0, 0
|
||||||
|
for cat, track in all_tracks:
|
||||||
|
result = generate_one(handler, track, is_ringtone=(cat == "ringtone"))
|
||||||
|
if result is None:
|
||||||
|
fail += 1
|
||||||
|
elif "跳过" in str(result):
|
||||||
|
skip += 1
|
||||||
|
else:
|
||||||
|
ok += 1
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"完成 {ok} 跳过 {skip} 失败 {fail}")
|
||||||
|
print(f"输出: {OUT_DIR}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
《林夏》SFX 批量生成脚本(ACE-Step 1.5)
|
||||||
|
生成环境氛围类、过渡类、结局类音效
|
||||||
|
|
||||||
|
非 ACE-Step 的短促 UI 音效(叮、咚、震动)需另行处理,
|
||||||
|
本脚本专注可由 ACE-Step 生成的 ≥5s 氛围类音效。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
conda activate acestep
|
||||||
|
python audio/gen_sfx_acestep.py [--dry-run]
|
||||||
|
"""
|
||||||
|
import os, sys, time, argparse
|
||||||
|
import torch
|
||||||
|
torch.backends.cuda.preferred_blas_library("cublaslt")
|
||||||
|
|
||||||
|
REPO_DIR = "/home/xsl/tools/ACE-Step-1.5"
|
||||||
|
OUT_DIR = "/home/xsl/blind/audio/00_raw/sfx"
|
||||||
|
AMB_DIR = "/home/xsl/blind/audio/00_raw/ambience"
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
os.makedirs(AMB_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
sys.path.insert(0, REPO_DIR)
|
||||||
|
|
||||||
|
# ── 氛围类音效(ACE-Step 可生成)─────────────────────────────
|
||||||
|
SFX_TRACKS = [
|
||||||
|
# 过渡 / 转场
|
||||||
|
{
|
||||||
|
"filename": "sfx_transition_chapter",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 8,
|
||||||
|
"caption": "cinematic transition swell, soft orchestral, gentle rise and fall, cinematic fade",
|
||||||
|
"note": "章节过渡轻 swell",
|
||||||
|
"seed": 2001,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_heartbeat_fast",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 8,
|
||||||
|
"caption": "human heartbeat accelerating, tension building, thumping close mic, anxiety",
|
||||||
|
"note": "心跳加速",
|
||||||
|
"seed": 2002,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_deep_breath",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 6,
|
||||||
|
"caption": "single deep breath, calm, close mic, soft exhale, meditative",
|
||||||
|
"note": "深呼吸",
|
||||||
|
"seed": 2003,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_rain_heavier",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 8,
|
||||||
|
"caption": "rain suddenly intensifying, window glass, urban rain, dramatic shift",
|
||||||
|
"note": "雨势忽大",
|
||||||
|
"seed": 2004,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_rain_lighter",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 8,
|
||||||
|
"caption": "rain gradually softening, distant window, fading urban rain, peaceful",
|
||||||
|
"note": "雨势忽小",
|
||||||
|
"seed": 2005,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_glass_shatter_short",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "glass breaking, single crash, sharp impact, quiet aftermath",
|
||||||
|
"note": "玻璃碎一秒",
|
||||||
|
"seed": 2006,
|
||||||
|
},
|
||||||
|
|
||||||
|
# 结局专用
|
||||||
|
{
|
||||||
|
"filename": "sfx_ending_chime",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "soft chime, title card reveal, delicate bells, warm resonance, fade out",
|
||||||
|
"note": "字幕浮现 chime",
|
||||||
|
"seed": 2007,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_night_bus",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 10,
|
||||||
|
"caption": "late night bus passing on empty street, distant rumble, urban quiet",
|
||||||
|
"note": "夜班车驶过",
|
||||||
|
"seed": 2008,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_cat_yowl",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "single cat yowl distant alley, late night, brief, natural",
|
||||||
|
"note": "野猫叫一声",
|
||||||
|
"seed": 2009,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_breathing_unknown",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 8,
|
||||||
|
"caption": "quiet human breathing through phone, slightly muffled, anonymous, eerie stillness",
|
||||||
|
"note": "未知号码呼吸",
|
||||||
|
"seed": 2010,
|
||||||
|
},
|
||||||
|
|
||||||
|
# 拟音
|
||||||
|
{
|
||||||
|
"filename": "sfx_kettle_whistle",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 6,
|
||||||
|
"caption": "kettle whistling, close mic, small kitchen, vintage, building steam",
|
||||||
|
"note": "烧水壶哨响",
|
||||||
|
"seed": 2011,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_water_boiling",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 8,
|
||||||
|
"caption": "water boiling in kettle, bubbling, close mic, kitchen ambience",
|
||||||
|
"note": "水沸腾",
|
||||||
|
"seed": 2012,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_keyboard_slow",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 6,
|
||||||
|
"caption": "slow thoughtful keyboard typing, soft mechanical, contemplative rhythm",
|
||||||
|
"note": "键盘慢打",
|
||||||
|
"seed": 2013,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_keyboard_fast",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "fast keyboard typing, mechanical, busy, rapid rhythm, office",
|
||||||
|
"note": "键盘快打",
|
||||||
|
"seed": 2014,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_door_knock_gentle",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "gentle three knocks on apartment door, polite delivery, hallway echo",
|
||||||
|
"note": "外卖敲门(轻)",
|
||||||
|
"seed": 2015,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_door_knock_firm",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "firm knocking on apartment door, impatient, louder, hallway",
|
||||||
|
"note": "外卖敲门(响)",
|
||||||
|
"seed": 2016,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_phone_vibrate",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "phone vibrating on wooden bedside table, buzzing, close mic, 2 bursts",
|
||||||
|
"note": "手机震动桌面",
|
||||||
|
"seed": 2017,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "sfx_paper_turning",
|
||||||
|
"dir": OUT_DIR,
|
||||||
|
"duration": 5,
|
||||||
|
"caption": "paper pages turning, quiet desk, gentle rustling, close mic",
|
||||||
|
"note": "纸张翻动",
|
||||||
|
"seed": 2018,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 环境底噪(单独输出到 ambience/)────────────────────────────
|
||||||
|
AMBIENCE_TRACKS = [
|
||||||
|
{
|
||||||
|
"filename": "amb_apartment_night_01",
|
||||||
|
"dir": AMB_DIR,
|
||||||
|
"duration": 120,
|
||||||
|
"caption": (
|
||||||
|
"late night apartment ambience, very quiet, distant traffic, "
|
||||||
|
"refrigerator hum, occasional water pipe, urban residential, "
|
||||||
|
"stereo, immersive, Beijing night"
|
||||||
|
),
|
||||||
|
"note": "合租屋夜晚底噪 (2min loop段)",
|
||||||
|
"seed": 3001,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "amb_apartment_night_02",
|
||||||
|
"dir": AMB_DIR,
|
||||||
|
"duration": 120,
|
||||||
|
"caption": (
|
||||||
|
"quiet Beijing apartment at night, minimal sounds, "
|
||||||
|
"faint city noise, soft hum, occasional distant car, "
|
||||||
|
"late 2020s urban residential night ambience"
|
||||||
|
),
|
||||||
|
"note": "合租屋夜晚底噪备用",
|
||||||
|
"seed": 3002,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "amb_bar_loud_01",
|
||||||
|
"dir": AMB_DIR,
|
||||||
|
"duration": 60,
|
||||||
|
"caption": (
|
||||||
|
"crowded bar ambience, people talking laughing, "
|
||||||
|
"background electronic music, glasses clinking, "
|
||||||
|
"busy nightclub atmosphere, lively"
|
||||||
|
),
|
||||||
|
"note": "酒吧嘈杂环境",
|
||||||
|
"seed": 3003,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "amb_rain_window_01",
|
||||||
|
"dir": AMB_DIR,
|
||||||
|
"duration": 120,
|
||||||
|
"caption": (
|
||||||
|
"gentle rain on window, soft steady patter, "
|
||||||
|
"cozy indoor feeling, night rain, Beijing apartment, "
|
||||||
|
"stereo, immersive"
|
||||||
|
),
|
||||||
|
"note": "窗外细雨(BGM M1 衬底)",
|
||||||
|
"seed": 3004,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "amb_kitchen_morning",
|
||||||
|
"dir": AMB_DIR,
|
||||||
|
"duration": 60,
|
||||||
|
"caption": (
|
||||||
|
"morning kitchen sounds, water dripping tap, "
|
||||||
|
"refrigerator hum, quiet domestic morning, "
|
||||||
|
"natural, realistic"
|
||||||
|
),
|
||||||
|
"note": "厨房晨间(结局A)",
|
||||||
|
"seed": 3005,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "amb_dawn_birds",
|
||||||
|
"dir": AMB_DIR,
|
||||||
|
"duration": 30,
|
||||||
|
"caption": (
|
||||||
|
"dawn birds chirping, urban morning, early light, "
|
||||||
|
"sparse birdsong, peaceful city dawn"
|
||||||
|
),
|
||||||
|
"note": "清晨鸟叫(结局D)",
|
||||||
|
"seed": 3006,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ALL_TRACKS = SFX_TRACKS + AMBIENCE_TRACKS
|
||||||
|
|
||||||
|
|
||||||
|
def load_handler():
|
||||||
|
from acestep.handler import AceStepHandler
|
||||||
|
print("正在加载 ACE-Step 模型...", end=" ", flush=True)
|
||||||
|
t0 = time.time()
|
||||||
|
handler = AceStepHandler()
|
||||||
|
handler.initialize_service(
|
||||||
|
project_root=REPO_DIR,
|
||||||
|
config_path="acestep-v15-turbo",
|
||||||
|
device="cuda",
|
||||||
|
)
|
||||||
|
print(f"就绪 ({time.time()-t0:.1f}s) dtype={handler.dtype}")
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def generate_one(handler, track):
|
||||||
|
from acestep.inference import GenerationParams, GenerationConfig, generate_music
|
||||||
|
|
||||||
|
out_dir = track["dir"]
|
||||||
|
filename = track["filename"]
|
||||||
|
out_path = os.path.join(out_dir, f"{filename}_v1.wav")
|
||||||
|
|
||||||
|
if os.path.exists(out_path):
|
||||||
|
print(f" [跳过] {filename}")
|
||||||
|
return "skip"
|
||||||
|
|
||||||
|
note = track.get("note", "")
|
||||||
|
print(f" {filename} [{note}] {track['duration']}s ...", end=" ", flush=True)
|
||||||
|
|
||||||
|
params = GenerationParams(
|
||||||
|
caption=track["caption"],
|
||||||
|
lyrics="",
|
||||||
|
duration=track["duration"],
|
||||||
|
instrumental=True,
|
||||||
|
inference_steps=20,
|
||||||
|
seed=track.get("seed", 42),
|
||||||
|
)
|
||||||
|
config = GenerationConfig(batch_size=1, audio_format="wav")
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
result = generate_music(handler, None, params, config, save_dir=out_dir)
|
||||||
|
if result.success:
|
||||||
|
gen_path = result.audios[0]["path"]
|
||||||
|
if gen_path != out_path:
|
||||||
|
os.rename(gen_path, out_path)
|
||||||
|
print(f"✓ {time.time()-t0:.1f}s")
|
||||||
|
return "ok"
|
||||||
|
else:
|
||||||
|
print(f"✗ {result.error}")
|
||||||
|
return "fail"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {e}")
|
||||||
|
return "fail"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--category", choices=["sfx", "ambience", "all"], default="all")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
if args.category in ("sfx", "all"):
|
||||||
|
tracks += SFX_TRACKS
|
||||||
|
if args.category in ("ambience", "all"):
|
||||||
|
tracks += AMBIENCE_TRACKS
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"《林夏》SFX + 环境底噪生成 共 {len(tracks)} 条")
|
||||||
|
print(f"SFX 输出: {OUT_DIR}")
|
||||||
|
print(f"环境 输出: {AMB_DIR}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
for t in tracks:
|
||||||
|
out = os.path.join(t["dir"], f"{t['filename']}_v1.wav")
|
||||||
|
flag = "✓" if os.path.exists(out) else "○"
|
||||||
|
print(f" [{flag}] {t['filename']} {t['duration']}s {t.get('note','')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
handler = load_handler()
|
||||||
|
|
||||||
|
ok = skip = fail = 0
|
||||||
|
for track in tracks:
|
||||||
|
r = generate_one(handler, track)
|
||||||
|
if r == "ok": ok += 1
|
||||||
|
elif r == "skip": skip += 1
|
||||||
|
else: fail += 1
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"完成 {ok} 跳过 {skip} 失败 {fail}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
生成缺失的 TTS 资产:
|
||||||
|
- self-3y 三个版本(明朗/迷茫/温柔)
|
||||||
|
- system 系统提示音 TTS
|
||||||
|
使用 VoxCPM2 API (http://127.0.0.1:8000)
|
||||||
|
"""
|
||||||
|
import os, sys, time, glob, base64, subprocess, tempfile, shutil
|
||||||
|
import requests
|
||||||
|
|
||||||
|
PROJECT_DIR = "/home/xsl/blind"
|
||||||
|
OUT_DIR = f"{PROJECT_DIR}/audio/00_raw/tts"
|
||||||
|
VOICE_DIR = f"{PROJECT_DIR}/voice"
|
||||||
|
BASE_URL = "http://127.0.0.1:8000"
|
||||||
|
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# ── 台词定义 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
MISSING_LINES = [
|
||||||
|
# self-3y 明朗版(23岁刚来北京,对一切新鲜)
|
||||||
|
("lv11_0030_self3y_voice_01", # filename(不含后缀)
|
||||||
|
"xiaomei", # 音色来源目录(同xiaomei基底+调整)
|
||||||
|
0.95, # speed(比正常略快,体现23岁轻盈)
|
||||||
|
"哦对,今天是我来北京的第一百天。妈,我没跟你说,我自己数的。"
|
||||||
|
"这个城市好大。我走了很多路,见了很多人。我觉得我还没准备好,"
|
||||||
|
"但我又觉得好像准备了很久很久了。你们不用担心我。我很好。"
|
||||||
|
"以后的我,如果你听到这条,记得要好好的。"),
|
||||||
|
|
||||||
|
# self-3y 迷茫版(其实已经很累了,只是没说)
|
||||||
|
("lv11_0030_self3y_voice_02",
|
||||||
|
"xiaomei",
|
||||||
|
1.00,
|
||||||
|
"我今天又没睡好。不是失眠,就是睡不踏实。"
|
||||||
|
"北京这个地方,它会让你觉得你随时都要更好,随时都要更努力。"
|
||||||
|
"我在努力。但我不知道在努力什么。"
|
||||||
|
"我就想留下一条记录。今天,今天是……算了,就今天吧。"),
|
||||||
|
|
||||||
|
# self-3y 温柔版(对未来的自己说撑住)
|
||||||
|
("lv11_0030_self3y_voice_03",
|
||||||
|
"xiaomei",
|
||||||
|
1.00,
|
||||||
|
"是我。23岁的我。我设了三年后打开。"
|
||||||
|
"我不知道那时候的你怎么样了。如果你很好,就当我多虑了。"
|
||||||
|
"如果你不好——你看,你熬过来了对不对。"
|
||||||
|
"你23岁的时候,也觉得很难。但你还是过来了。"
|
||||||
|
"所以……撑住啊。"),
|
||||||
|
|
||||||
|
# system TTS — 游戏系统提示
|
||||||
|
("lv11_sys_battery_01", "xiaomei", 1.00, "电量百分之一"),
|
||||||
|
("lv11_sys_battery_02", "xiaomei", 1.00, "电量不足,请及时充电"),
|
||||||
|
("lv11_sys_unread_01", "xiaomei", 1.00, "您有一条未读消息"),
|
||||||
|
("lv11_sys_unread_multi", "xiaomei", 1.00, "您有多条未读消息"),
|
||||||
|
("lv11_sys_memo_01", "xiaomei", 1.00, "三年前今天,一条未听的录音"),
|
||||||
|
("lv11_sys_goodnight", "xiaomei", 1.00, "晚安"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 工具函数 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def find_ref(voice_dir_name):
|
||||||
|
d = os.path.join(VOICE_DIR, voice_dir_name)
|
||||||
|
for ext in ("*.wav", "*.mp3", "*.flac"):
|
||||||
|
files = sorted(glob.glob(os.path.join(d, ext)))
|
||||||
|
if files:
|
||||||
|
return files[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_wav(src, dst):
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", "-acodec", "pcm_s16le", dst],
|
||||||
|
check=True, capture_output=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_speed(src, speed, dst):
|
||||||
|
if abs(speed - 1.0) < 0.01:
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
return
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", src, "-filter:a", f"atempo={speed}", "-acodec", "pcm_s16le", dst],
|
||||||
|
check=True, capture_output=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_voice(wav_path):
|
||||||
|
with open(wav_path, "rb") as f:
|
||||||
|
b64 = base64.b64encode(f.read()).decode()
|
||||||
|
r = requests.post(f"{BASE_URL}/v1/voices", json={"wav_base64": b64}, timeout=60)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["voice_id"]
|
||||||
|
|
||||||
|
def synthesize(voice_id, text):
|
||||||
|
r = requests.post(f"{BASE_URL}/v1/speech",
|
||||||
|
json={"text": text, "voice_id": voice_id, "cfg_value": 2.0, "inference_timesteps": 10},
|
||||||
|
timeout=180)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
|
|
||||||
|
# ── 主流程 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 健康检查
|
||||||
|
try:
|
||||||
|
requests.get(f"{BASE_URL}/health", timeout=5).raise_for_status()
|
||||||
|
print(f"VoxCPM 服务就绪: {BASE_URL}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[错误] VoxCPM 服务不可用: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix="tts_missing_")
|
||||||
|
voice_cache = {} # dir_name → voice_id
|
||||||
|
|
||||||
|
print(f"\n共 {len(MISSING_LINES)} 条待合成\n{'='*50}")
|
||||||
|
|
||||||
|
ok, skip, fail = 0, 0, 0
|
||||||
|
for filename, voice_dir, speed, text in MISSING_LINES:
|
||||||
|
out_path = os.path.join(OUT_DIR, f"{filename}_wav_v1.wav")
|
||||||
|
if os.path.exists(out_path):
|
||||||
|
print(f" [跳过已存在] {filename}")
|
||||||
|
skip += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 注册音色(缓存)
|
||||||
|
if voice_dir not in voice_cache:
|
||||||
|
ref = find_ref(voice_dir)
|
||||||
|
if not ref:
|
||||||
|
print(f" [✗无参考] {voice_dir}: {filename}")
|
||||||
|
fail += 1
|
||||||
|
continue
|
||||||
|
wav_ref = os.path.join(tmp_dir, f"{voice_dir}_ref.wav")
|
||||||
|
if not ref.endswith(".wav"):
|
||||||
|
to_wav(ref, wav_ref)
|
||||||
|
else:
|
||||||
|
shutil.copy2(ref, wav_ref)
|
||||||
|
print(f" 注册音色 [{voice_dir}] ...", end=" ", flush=True)
|
||||||
|
vid = register_voice(wav_ref)
|
||||||
|
voice_cache[voice_dir] = vid
|
||||||
|
print(f"ok ({vid[:8]}…)")
|
||||||
|
|
||||||
|
print(f" 合成: {filename} ...", end=" ", flush=True)
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
raw_bytes = synthesize(voice_cache[voice_dir], text)
|
||||||
|
tmp_raw = os.path.join(tmp_dir, f"{filename}.raw.wav")
|
||||||
|
with open(tmp_raw, "wb") as f:
|
||||||
|
f.write(raw_bytes)
|
||||||
|
apply_speed(tmp_raw, speed, out_path)
|
||||||
|
print(f"✓ {time.time()-t0:.1f}s → {os.path.basename(out_path)}")
|
||||||
|
ok += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {e}")
|
||||||
|
fail += 1
|
||||||
|
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"完成 {ok} 跳过 {skip} 失败 {fail}")
|
||||||
|
print(f"输出: {OUT_DIR}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 《林夏》音频后期批量处理脚本
|
||||||
|
# 用法: bash audio/process_audio.sh [tts|music|sfx|ambience|all]
|
||||||
|
# 执行前需要先跑 batch_tts.py 生产原始音频
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd /home/xsl/blind
|
||||||
|
|
||||||
|
TYPE="${1:-all}"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# TTS 后期处理: 00_raw/tts → 01_processed/tts
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
process_tts() {
|
||||||
|
echo "=== TTS 后期处理 ==="
|
||||||
|
local count=0
|
||||||
|
for f in audio/00_raw/tts/lv11_*.wav; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
local base=$(basename "$f")
|
||||||
|
local out="audio/01_processed/tts/$base"
|
||||||
|
[ -f "$out" ] && echo " 跳过: $base" && continue
|
||||||
|
|
||||||
|
# 判断是否为电话/语音形态(需要加听筒效果)
|
||||||
|
local apply_phone=0
|
||||||
|
[[ "$base" == *_call_* ]] && apply_phone=1
|
||||||
|
[[ "$base" == *_voice_* ]] && apply_phone=1
|
||||||
|
# 例外: mom 第 16 条长语音不加听筒效果
|
||||||
|
[[ "$base" == *lv11_0200_mom_voice* ]] && apply_phone=0
|
||||||
|
|
||||||
|
if [ "$apply_phone" -eq 1 ]; then
|
||||||
|
# 加听筒效果
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "highpass=f=80,\
|
||||||
|
acompressor=threshold=-18dB:ratio=4:attack=20:release=250,\
|
||||||
|
highpass=f=300:poles=2,lowpass=f=3400:poles=2,\
|
||||||
|
equalizer=f=1500:t=q:w=1:g=1,\
|
||||||
|
loudnorm=I=-16:TP=-1:LRA=11" \
|
||||||
|
-ar 48000 -sample_fmt s16 -ac 1 \
|
||||||
|
-y "$out" 2>/dev/null
|
||||||
|
else
|
||||||
|
# 普通处理(文字/系统/长语音)
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "highpass=f=80,\
|
||||||
|
acompressor=threshold=-18dB:ratio=4:attack=20:release=250,\
|
||||||
|
loudnorm=I=-16:TP=-1:LRA=11" \
|
||||||
|
-ar 48000 -sample_fmt s16 -ac 1 \
|
||||||
|
-y "$out" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " ✓ $base"
|
||||||
|
((count++))
|
||||||
|
done
|
||||||
|
echo " 处理完成 $count 条"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 拼接分段长戏
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
concat_segments() {
|
||||||
|
echo "=== 拼接分段长戏 ==="
|
||||||
|
|
||||||
|
# 妈妈 4'17" (8 段)
|
||||||
|
local mom_segs=()
|
||||||
|
for i in $(seq 1 8); do
|
||||||
|
local seg="audio/01_processed/tts/lv11_0200_mom_voice_01_seg${i}_v1.wav"
|
||||||
|
[ -f "$seg" ] && mom_segs+=("$seg")
|
||||||
|
done
|
||||||
|
if [ ${#mom_segs[@]} -eq 8 ]; then
|
||||||
|
printf "file '%s'\n" "${mom_segs[@]}" > /tmp/mom_concat.txt
|
||||||
|
ffmpeg -f concat -safe 0 -i /tmp/mom_concat.txt \
|
||||||
|
-c copy -y audio/01_processed/tts/lv11_0200_mom_voice_01_full.wav 2>/dev/null
|
||||||
|
echo " ✓ 妈妈长语音拼接完成"
|
||||||
|
else
|
||||||
|
echo " 跳过妈妈拼接(只有 ${#mom_segs[@]}/8 段)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 小美醉语音 (4 段)
|
||||||
|
local xiaomei_segs=()
|
||||||
|
for i in $(seq 1 4); do
|
||||||
|
local seg="audio/01_processed/tts/lv11_2245_xiaomei_voice_01_seg${i}_v1.wav"
|
||||||
|
[ -f "$seg" ] && xiaomei_segs+=("$seg")
|
||||||
|
done
|
||||||
|
if [ ${#xiaomei_segs[@]} -eq 4 ]; then
|
||||||
|
printf "file '%s'\n" "${xiaomei_segs[@]}" > /tmp/xiaomei_concat.txt
|
||||||
|
ffmpeg -f concat -safe 0 -i /tmp/xiaomei_concat.txt \
|
||||||
|
-c copy -y audio/01_processed/tts/lv11_2245_xiaomei_voice_01_full.wav 2>/dev/null
|
||||||
|
echo " ✓ 小美醉语音拼接完成"
|
||||||
|
else
|
||||||
|
echo " 跳过小美拼接(只有 ${#xiaomei_segs[@]}/4 段)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 音乐后期: 00_raw/music → 01_processed/music
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
process_music() {
|
||||||
|
echo "=== 音乐后期处理 ==="
|
||||||
|
local count=0
|
||||||
|
for f in audio/00_raw/music/*.wav audio/00_raw/music/*.mp3; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
local base=$(basename "${f%.*}.wav")
|
||||||
|
local out="audio/01_processed/music/$base"
|
||||||
|
[ -f "$out" ] && echo " 跳过: $base" && continue
|
||||||
|
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "equalizer=f=350:t=q:w=1.5:g=-2,\
|
||||||
|
loudnorm=I=-18:TP=-1:LRA=11" \
|
||||||
|
-ar 48000 -ac 2 \
|
||||||
|
-y "$out" 2>/dev/null
|
||||||
|
echo " ✓ $base"
|
||||||
|
((count++))
|
||||||
|
done
|
||||||
|
echo " 处理完成 $count 条"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 音效后期: 00_raw/sfx → 01_processed/sfx
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
process_sfx() {
|
||||||
|
echo "=== 音效后期处理 ==="
|
||||||
|
local count=0
|
||||||
|
for f in audio/00_raw/sfx/*.wav audio/00_raw/sfx/*.mp3; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
local base=$(basename "${f%.*}.wav")
|
||||||
|
local out="audio/01_processed/sfx/$base"
|
||||||
|
[ -f "$out" ] && continue
|
||||||
|
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "loudnorm=I=-14:TP=-1:LRA=11" \
|
||||||
|
-ar 48000 \
|
||||||
|
-y "$out" 2>/dev/null
|
||||||
|
echo " ✓ $base"
|
||||||
|
((count++))
|
||||||
|
done
|
||||||
|
echo " 处理完成 $count 条"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 环境底噪后期: 00_raw/ambience → 01_processed/ambience
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
process_ambience() {
|
||||||
|
echo "=== 环境底噪后期处理 ==="
|
||||||
|
local count=0
|
||||||
|
for f in audio/00_raw/ambience/*.wav; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
local base=$(basename "$f")
|
||||||
|
local out="audio/01_processed/ambience/$base"
|
||||||
|
[ -f "$out" ] && continue
|
||||||
|
|
||||||
|
local channels=$(ffprobe -v quiet -select_streams a:0 \
|
||||||
|
-show_entries stream=channels -of csv=p=0 "$f" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$channels" -eq 1 ] 2>/dev/null; then
|
||||||
|
# 单声道→假性立体声
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-filter_complex "[0:a]asplit=2[L][Rc];[Rc]adelay=12|12[R];[L][R]amerge=inputs=2[out]" \
|
||||||
|
-map "[out]" -ac 2 \
|
||||||
|
-af "highpass=f=60,lowpass=f=12000,loudnorm=I=-22:TP=-3:LRA=11" \
|
||||||
|
-ar 48000 -y "$out" 2>/dev/null
|
||||||
|
else
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "highpass=f=60,lowpass=f=12000,loudnorm=I=-22:TP=-3:LRA=11" \
|
||||||
|
-ar 48000 -ac 2 -y "$out" 2>/dev/null
|
||||||
|
fi
|
||||||
|
echo " ✓ $base"
|
||||||
|
((count++))
|
||||||
|
done
|
||||||
|
echo " 处理完成 $count 条"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 最终打包: 01_processed → 02_final (.ogg)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
pack_final() {
|
||||||
|
echo "=== 最终打包 wav → ogg ==="
|
||||||
|
for subdir in tts music sfx ambience; do
|
||||||
|
local count=0
|
||||||
|
for f in audio/01_processed/$subdir/*.wav; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
local base=$(basename "${f%.wav}.ogg")
|
||||||
|
local out="audio/02_final/$subdir/$base"
|
||||||
|
[ -f "$out" ] && continue
|
||||||
|
ffmpeg -i "$f" -c:a libvorbis -q:a 5 -y "$out" 2>/dev/null
|
||||||
|
((count++))
|
||||||
|
done
|
||||||
|
[ $count -gt 0 ] && echo " ✓ $subdir: $count 条"
|
||||||
|
done
|
||||||
|
echo " 打包完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 响度验收报告
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
qa_loudness() {
|
||||||
|
echo "=== 响度验收 ==="
|
||||||
|
local pass=0 fail=0
|
||||||
|
|
||||||
|
check_dir() {
|
||||||
|
local dir="$1" target="$2" label="$3"
|
||||||
|
for f in audio/01_processed/$dir/*.wav; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
local lufs=$(ffmpeg -i "$f" -af loudnorm=print_format=json -f null - 2>&1 | \
|
||||||
|
python3 -c "
|
||||||
|
import sys, json, re
|
||||||
|
out = sys.stdin.read()
|
||||||
|
m = re.search(r'\"input_i\"\s*:\s*\"(-?\d+\.?\d*)\"', out)
|
||||||
|
print(m.group(1) if m else 'err')
|
||||||
|
")
|
||||||
|
local diff=$(echo "$lufs $target" | awk '{d=$1-$2; if(d<0)d=-d; print d}')
|
||||||
|
local ok=$(echo "$diff" | awk '{print ($1 <= 1.5) ? "pass" : "FAIL"}')
|
||||||
|
printf " %-50s %s LUFS [%s]\n" "$(basename $f)" "$lufs" "$ok"
|
||||||
|
[ "$ok" = "pass" ] && ((pass++)) || ((fail++))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dir tts -16 "角色语音"
|
||||||
|
check_dir music -18 "音乐"
|
||||||
|
check_dir ambience -22 "环境底噪"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " 通过: $pass 不通过: $fail"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 主流程
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
echo "《林夏》音频后期处理 $(date '+%Y-%m-%d %H:%M')"
|
||||||
|
echo "执行类型: $TYPE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
case "$TYPE" in
|
||||||
|
tts) process_tts; concat_segments ;;
|
||||||
|
music) process_music ;;
|
||||||
|
sfx) process_sfx ;;
|
||||||
|
ambience) process_ambience ;;
|
||||||
|
pack) pack_final ;;
|
||||||
|
qa) qa_loudness ;;
|
||||||
|
all)
|
||||||
|
process_tts
|
||||||
|
concat_segments
|
||||||
|
process_music
|
||||||
|
process_sfx
|
||||||
|
process_ambience
|
||||||
|
pack_final
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "用法: bash audio/process_audio.sh [tts|music|sfx|ambience|pack|qa|all]"
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "完成 $(date '+%H:%M:%S')"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 启动 CosyVoice WebUI
|
||||||
|
# 用法: bash audio/start_webui.sh
|
||||||
|
|
||||||
|
echo "启动 CosyVoice2 WebUI..."
|
||||||
|
echo "启动后访问: http://localhost:8000"
|
||||||
|
echo "Ctrl+C 退出"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
source /home/xsl/miniconda3/etc/profile.d/conda.sh
|
||||||
|
conda activate cosyvoice
|
||||||
|
cd /home/xsl/tools/CosyVoice
|
||||||
|
|
||||||
|
python webui.py \
|
||||||
|
--port 8000 \
|
||||||
|
--model_dir pretrained_models/CosyVoice2-0.5B
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ACE-Step 1.5 Test Script
|
||||||
|
Tests basic music generation functionality.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- ACE-Step installed in conda env 'acestep' or via uv
|
||||||
|
- Models downloaded to /home/xsl/tools/ACE-Step-1.5/checkpoints/
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
conda activate acestep
|
||||||
|
python test_acestep.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REPO_DIR = "/home/xsl/tools/ACE-Step-1.5"
|
||||||
|
CHECKPOINTS_DIR = os.path.join(REPO_DIR, "checkpoints")
|
||||||
|
OUTPUT_DIR = "/home/xsl/blind/audio/test_output"
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_imports():
|
||||||
|
"""Test that all required packages are importable."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Step 1: Testing imports...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Add repo to path
|
||||||
|
if REPO_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, REPO_DIR)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
# Test torch
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
results["torch"] = f"OK (v{torch.__version__}, CUDA={torch.cuda.is_available()})"
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
results["torch"] += f" ({torch.cuda.get_device_name(0)})"
|
||||||
|
except ImportError as e:
|
||||||
|
results["torch"] = f"FAIL: {e}"
|
||||||
|
|
||||||
|
# Test transformers
|
||||||
|
try:
|
||||||
|
import transformers
|
||||||
|
results["transformers"] = f"OK (v{transformers.__version__})"
|
||||||
|
except ImportError as e:
|
||||||
|
results["transformers"] = f"FAIL: {e}"
|
||||||
|
|
||||||
|
# Test diffusers
|
||||||
|
try:
|
||||||
|
import diffusers
|
||||||
|
results["diffusers"] = f"OK (v{diffusers.__version__})"
|
||||||
|
except ImportError as e:
|
||||||
|
results["diffusers"] = f"FAIL: {e}"
|
||||||
|
|
||||||
|
# Test soundfile
|
||||||
|
try:
|
||||||
|
import soundfile
|
||||||
|
results["soundfile"] = f"OK (v{soundfile.__version__})"
|
||||||
|
except ImportError as e:
|
||||||
|
results["soundfile"] = f"FAIL: {e}"
|
||||||
|
|
||||||
|
# Test acestep
|
||||||
|
try:
|
||||||
|
import acestep
|
||||||
|
results["acestep"] = f"OK (from {acestep.__file__})"
|
||||||
|
except ImportError as e:
|
||||||
|
results["acestep"] = f"FAIL: {e}"
|
||||||
|
|
||||||
|
for k, v in results.items():
|
||||||
|
print(f" {k}: {v}")
|
||||||
|
|
||||||
|
all_ok = all("OK" in v for v in results.values())
|
||||||
|
print(f"\nImports: {'PASS' if all_ok else 'FAIL'}")
|
||||||
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_exist():
|
||||||
|
"""Test that model checkpoints are downloaded."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Step 2: Checking model checkpoints...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
required_components = {
|
||||||
|
"acestep-v15-turbo": "DiT model (turbo)",
|
||||||
|
"vae": "VAE encoder/decoder",
|
||||||
|
"Qwen3-Embedding-0.6B": "Text encoder",
|
||||||
|
"acestep-5Hz-lm-1.7B": "LM model (1.7B)",
|
||||||
|
}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
all_ok = True
|
||||||
|
for component, description in required_components.items():
|
||||||
|
path = os.path.join(CHECKPOINTS_DIR, component)
|
||||||
|
exists = os.path.isdir(path)
|
||||||
|
# Check for weight files
|
||||||
|
has_weights = False
|
||||||
|
if exists:
|
||||||
|
weight_names = [
|
||||||
|
"model.safetensors",
|
||||||
|
"model.safetensors.index.json",
|
||||||
|
"diffusion_pytorch_model.safetensors",
|
||||||
|
"diffusion_pytorch_model.safetensors.index.json",
|
||||||
|
]
|
||||||
|
has_weights = any(
|
||||||
|
os.path.isfile(os.path.join(path, w)) for w in weight_names
|
||||||
|
)
|
||||||
|
|
||||||
|
status = "OK" if has_weights else ("MISSING_WEIGHTS" if exists else "MISSING")
|
||||||
|
if not has_weights:
|
||||||
|
all_ok = False
|
||||||
|
results[component] = status
|
||||||
|
print(f" {component} ({description}): {status}")
|
||||||
|
|
||||||
|
print(f"\nModels: {'PASS' if all_ok else 'FAIL'}")
|
||||||
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation():
|
||||||
|
"""Test basic music generation."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Step 3: Testing music generation...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Add repo to path
|
||||||
|
if REPO_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, REPO_DIR)
|
||||||
|
|
||||||
|
from acestep.handler import AceStepHandler
|
||||||
|
from acestep.inference import GenerationParams, GenerationConfig, generate_music
|
||||||
|
|
||||||
|
# Initialize handler (DiT only, no LM for faster test)
|
||||||
|
print(" Initializing DiT handler...")
|
||||||
|
dit_handler = AceStepHandler()
|
||||||
|
dit_handler.initialize_service(
|
||||||
|
project_root=REPO_DIR,
|
||||||
|
config_path="acestep-v15-turbo",
|
||||||
|
device="cuda",
|
||||||
|
)
|
||||||
|
print(" DiT handler initialized.")
|
||||||
|
|
||||||
|
# Simple generation without LM (faster)
|
||||||
|
print(" Generating test audio (30s, no LM)...")
|
||||||
|
params = GenerationParams(
|
||||||
|
caption="A gentle piano melody with soft strings, ambient background music",
|
||||||
|
duration=30,
|
||||||
|
instrumental=True,
|
||||||
|
inference_steps=8,
|
||||||
|
seed=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = GenerationConfig(
|
||||||
|
batch_size=1,
|
||||||
|
audio_format="wav",
|
||||||
|
)
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
result = generate_music(
|
||||||
|
dit_handler,
|
||||||
|
None, # No LM handler
|
||||||
|
params,
|
||||||
|
config,
|
||||||
|
save_dir=OUTPUT_DIR,
|
||||||
|
)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
for audio in result.audios:
|
||||||
|
print(f" Generated: {audio['path']}")
|
||||||
|
print(f" Duration: {elapsed:.1f}s")
|
||||||
|
print(f"\nGeneration: PASS")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" Error: {result.error}")
|
||||||
|
print(f"\nGeneration: FAIL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Exception: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f"\nGeneration: FAIL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("ACE-Step 1.5 Test Script")
|
||||||
|
print(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
# Step 1: Test imports
|
||||||
|
results["imports"] = test_imports()
|
||||||
|
|
||||||
|
# Step 2: Test model checkpoints
|
||||||
|
results["models"] = test_models_exist()
|
||||||
|
|
||||||
|
# Step 3: Test generation (only if imports and models pass)
|
||||||
|
if results["imports"] and results["models"]:
|
||||||
|
results["generation"] = test_generation()
|
||||||
|
else:
|
||||||
|
results["generation"] = False
|
||||||
|
print("\nSkipping generation test (prerequisites not met)")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("SUMMARY")
|
||||||
|
print("=" * 60)
|
||||||
|
for k, v in results.items():
|
||||||
|
print(f" {k}: {'PASS' if v else 'FAIL'}")
|
||||||
|
|
||||||
|
all_pass = all(results.values())
|
||||||
|
print(f"\nOverall: {'PASS' if all_pass else 'FAIL'}")
|
||||||
|
|
||||||
|
if not all_pass:
|
||||||
|
print("\nTroubleshooting:")
|
||||||
|
if not results["imports"]:
|
||||||
|
print(" - Install dependencies: bash /home/xsl/tools/ACE-Step-1.5/install_acestep.sh")
|
||||||
|
if not results["models"]:
|
||||||
|
print(" - Download models: huggingface-cli download ACE-Step/Ace-Step1.5 --local-dir /home/xsl/tools/ACE-Step-1.5/checkpoints")
|
||||||
|
|
||||||
|
return 0 if all_pass else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 《盲语审判》完整 GDD
|
||||||
|
|
||||||
|
## 1. 概念概述
|
||||||
|
|
||||||
|
《盲语审判》是一款全程黑屏的手机法庭推理游戏。玩家扮演一名失明的审判官,在没有任何视觉证据展示的情况下,通过证人声音、呼吸、停顿、环境录音和触摸式卷宗索引审理案件。
|
||||||
|
|
||||||
|
游戏强调“听出谎言,但不能只靠直觉”。玩家必须把声音细节与证词逻辑结合,指出矛盾,决定追问方向,并最终做出判决。
|
||||||
|
|
||||||
|
## 2. 平台与类型
|
||||||
|
|
||||||
|
- 平台:iOS、Android。
|
||||||
|
- 类型:音频推理、法庭辩论、互动叙事。
|
||||||
|
- 游玩时长:5 个案件,约 5 至 6 小时。
|
||||||
|
- 目标用户:推理小说读者、法庭剧爱好者、声音互动剧玩家。
|
||||||
|
|
||||||
|
## 3. 核心卖点
|
||||||
|
|
||||||
|
- 没有证物图片,所有证据都以录音、触感标签和口述记录存在。
|
||||||
|
- 玩家通过声音微表情识别压力、掩饰、排练与真实记忆。
|
||||||
|
- 法庭空间由人声方位构成,检方、辩方、证人席和旁听席都有固定声场。
|
||||||
|
- 判决不是简单对错,玩家需要在法律、道德和证据不足之间取舍。
|
||||||
|
|
||||||
|
## 4. 世界观
|
||||||
|
|
||||||
|
故事发生在一座未来城市“无灯区”。一场大停电后,城市司法系统引入“盲审制度”:审判官不得查看影像证据,只能听取经过认证的声音材料,以避免视觉偏见和伪造影像影响判决。
|
||||||
|
|
||||||
|
玩家扮演审判官沈听白,因一次旧案失误自愿加入盲审法庭。随着案件推进,玩家发现五个案件都与多年前的“静默爆炸案”有关,而自己的记忆也可能被剪辑过。
|
||||||
|
|
||||||
|
## 5. 核心循环
|
||||||
|
|
||||||
|
1. 听取开庭陈述,建立案情框架。
|
||||||
|
2. 通过触摸索引选择卷宗、录音、证词或法条。
|
||||||
|
3. 盘问证人,注意声线、停顿、背景噪音与逻辑矛盾。
|
||||||
|
4. 在关键节点提出异议或要求重放证据。
|
||||||
|
5. 组合证据链,作出阶段性判断。
|
||||||
|
6. 宣判并承担后续剧情影响。
|
||||||
|
|
||||||
|
## 6. 操作方式
|
||||||
|
|
||||||
|
- 左右滑动:切换卷宗分类或法庭对象。
|
||||||
|
- 上下滑动:浏览同一分类下的证据条目。
|
||||||
|
- 单击:播放或选择当前证据。
|
||||||
|
- 长按:聚焦聆听,降低背景声并放大细节。
|
||||||
|
- 双击:提出异议。
|
||||||
|
- 双指左右滑:在检方与辩方问题之间切换。
|
||||||
|
- 三指长按:进入“审判笔记”,由系统语音复述已确认事实。
|
||||||
|
|
||||||
|
屏幕保持黑色,证据列表由语音读出,触摸反馈用轻微震动辅助定位。
|
||||||
|
|
||||||
|
## 7. 声音系统
|
||||||
|
|
||||||
|
### 声纹可信度
|
||||||
|
|
||||||
|
每个证人有基础声纹:语速、吸气位置、口音、情绪控制能力。玩家不直接获得“真假提示”,只能通过训练和上下文判断。
|
||||||
|
|
||||||
|
### 背景证据
|
||||||
|
|
||||||
|
录音中的环境声是关键线索。例如远处钟声可证明时间,雨声方向可证明房间朝向,玻璃回响可证明空间大小。
|
||||||
|
|
||||||
|
### 法庭声场
|
||||||
|
|
||||||
|
检方在左前方,辩方在右前方,证人在正前方,书记员在后方。旁听席骚动可能暗示案件社会影响,但也可能干扰判断。
|
||||||
|
|
||||||
|
## 8. 案件结构
|
||||||
|
|
||||||
|
### 第一案:空房间谋杀
|
||||||
|
|
||||||
|
嫌疑人称案发时独自在无窗房间。录音中却出现短暂风铃声,玩家需判断风铃来自走廊还是证人记忆错置。
|
||||||
|
|
||||||
|
### 第二案:失声歌手
|
||||||
|
|
||||||
|
歌手被控伪造绑架。关键证据是一段哭声录音,玩家需分辨自然哭泣与表演训练痕迹。
|
||||||
|
|
||||||
|
### 第三案:雨中车祸
|
||||||
|
|
||||||
|
司机、乘客和路人证词冲突。雨点打在车顶、伞面和柏油路上的声音揭示站位。
|
||||||
|
|
||||||
|
### 第四案:静默爆炸
|
||||||
|
|
||||||
|
旧案重启,所有证人都曾被声音剪辑误导。玩家开始怀疑审判系统本身。
|
||||||
|
|
||||||
|
### 第五案:审判官本人
|
||||||
|
|
||||||
|
玩家成为被审对象,必须用前四案积累的证据为自己辩护或承认制度缺陷。
|
||||||
|
|
||||||
|
## 9. 推理机制
|
||||||
|
|
||||||
|
### 矛盾标记
|
||||||
|
|
||||||
|
玩家选择一段证词和一份证据进行对照。若逻辑成立,审判官会提出追问;若不成立,法庭耐心值下降。
|
||||||
|
|
||||||
|
### 追问压力
|
||||||
|
|
||||||
|
持续追问会提高证人压力,使其暴露更多声音细节,也可能导致沉默、崩溃或律师抗议。
|
||||||
|
|
||||||
|
### 判决权重
|
||||||
|
|
||||||
|
系统记录玩家更看重物证、证词、动机还是程序正义,并影响最终案件解释。
|
||||||
|
|
||||||
|
## 10. NPC 设计
|
||||||
|
|
||||||
|
- 沈听白:玩家角色,声音只在内心独白中出现。
|
||||||
|
- 顾检察官:逻辑强硬,善于用证据压迫证人。
|
||||||
|
- 林辩护人:温和但敏锐,经常提醒程序正义。
|
||||||
|
- 书记员阿岚:负责卷宗播报,后期成为关键证人。
|
||||||
|
- 盲审系统:中性合成音,可能隐藏证据排序偏见。
|
||||||
|
|
||||||
|
## 11. 难度与提示
|
||||||
|
|
||||||
|
游戏提供三档辅助:
|
||||||
|
|
||||||
|
- 标准模式:只播放证据和证词。
|
||||||
|
- 推理辅助:审判笔记会整理已确认事实。
|
||||||
|
- 声音训练:对重要环境声进行一次性命名提示。
|
||||||
|
|
||||||
|
提示不会直接给出答案,而是指出“这段录音的背景值得重听”。
|
||||||
|
|
||||||
|
## 12. 失败与分支
|
||||||
|
|
||||||
|
错误异议、证据链薄弱或过度压迫证人会导致案件判决偏差。游戏不立刻 Game Over,而是让错误成为后续剧情的一部分。最终结局评价玩家的司法风格,而非单纯统计正确率。
|
||||||
|
|
||||||
|
## 13. 可及性设计
|
||||||
|
|
||||||
|
- 支持读屏友好的菜单。
|
||||||
|
- 所有语音可调速,关键录音可循环。
|
||||||
|
- 提供单声道模式,将空间差异转为角色名播报和背景声标签。
|
||||||
|
- 支持“复杂证据复述”,帮助记忆较长案件。
|
||||||
|
|
||||||
|
## 14. 商业化
|
||||||
|
|
||||||
|
适合章节买断或完整买断。每个 DLC 可加入一个新案件,并复用法庭系统。可与有声书平台合作,推出案件前传音频剧。
|
||||||
|
|
||||||
|
## 15. 制作范围
|
||||||
|
|
||||||
|
- 主线案件:5 个。
|
||||||
|
- 主要角色:6 至 8 名。
|
||||||
|
- 每案证据录音:12 至 18 条。
|
||||||
|
- 开发周期:8 至 10 个月。
|
||||||
|
- 主要风险:推理公平性、录音线索过于隐晦、玩家记忆负担过高。
|
||||||
|
|
||||||
|
## 16. 原型验证目标
|
||||||
|
|
||||||
|
首个可玩原型应包含一个 20 分钟案件,验证玩家是否能通过声音发现矛盾,是否理解触摸卷宗操作,是否接受无视觉证据的法庭推理节奏。
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 《最后热线》完整 GDD
|
||||||
|
|
||||||
|
## 1. 概念概述
|
||||||
|
|
||||||
|
《最后热线》是一款全黑屏手机语音调度游戏。玩家是灾难夜里的紧急热线接线员,城市通讯即将崩溃,所有地图和监控不可用。玩家只能通过来电者声音、背景噪音、呼吸状态和触摸式调度台判断位置、危险类型与救援优先级。
|
||||||
|
|
||||||
|
核心体验是“在看不见的城市中用声音救人”。玩家需要快速提问、安抚、判断真假报警,并分配有限救援资源。
|
||||||
|
|
||||||
|
## 2. 平台与定位
|
||||||
|
|
||||||
|
- 平台:iOS、Android。
|
||||||
|
- 类型:黑屏音频调度、紧急决策、剧情模拟。
|
||||||
|
- 单局时长:90 至 120 分钟。
|
||||||
|
- 目标用户:叙事模拟玩家、道德选择游戏玩家、广播剧和灾难题材爱好者。
|
||||||
|
|
||||||
|
## 3. 核心卖点
|
||||||
|
|
||||||
|
- 全程只有电话、无线电和调度台音效,没有地图画面。
|
||||||
|
- 玩家通过背景声定位:海浪、钟声、地铁广播、工厂警报。
|
||||||
|
- 每次派遣都有机会成本,无法救所有人。
|
||||||
|
- NPC 的恐慌程度会影响信息准确性,安抚本身是玩法。
|
||||||
|
|
||||||
|
## 4. 故事背景
|
||||||
|
|
||||||
|
沿海城市“江北湾”遭遇罕见风暴和地震叠加灾害。玩家扮演新任接线员孟遥,独自值守一条备用热线。主调度中心失联后,孟遥成为多个救援队之间的临时枢纽。
|
||||||
|
|
||||||
|
随着来电增多,玩家发现灾难并非自然事故。某些来电来自未来几分钟,某些求救者已经在新闻中被确认死亡。热线似乎连接了城市最后的声音。
|
||||||
|
|
||||||
|
## 5. 核心循环
|
||||||
|
|
||||||
|
1. 接入来电,听取求救者初始描述。
|
||||||
|
2. 通过选择提问方向获得位置、伤情和危险信息。
|
||||||
|
3. 安抚对方,降低恐慌,提升信息准确度。
|
||||||
|
4. 根据背景声和证词判断事件等级。
|
||||||
|
5. 通过触摸调度台派遣消防、医疗、警力或工程队。
|
||||||
|
6. 接收后续回报,更新城市灾情和结局状态。
|
||||||
|
|
||||||
|
## 6. 操作设计
|
||||||
|
|
||||||
|
- 单击:接听当前来电。
|
||||||
|
- 左右滑动:切换提问类型,如位置、人数、伤情、环境。
|
||||||
|
- 上滑:提高语气强度,要求对方执行动作。
|
||||||
|
- 下滑:安抚语气,让对方冷静。
|
||||||
|
- 长按:静默聆听背景声。
|
||||||
|
- 双击:确认派遣。
|
||||||
|
- 双指左右滑:切换救援队频道。
|
||||||
|
- 三指长按:复述当前案件摘要。
|
||||||
|
|
||||||
|
所有菜单由语音播报,屏幕纯黑。
|
||||||
|
|
||||||
|
## 7. 声音系统
|
||||||
|
|
||||||
|
### 来电者声音
|
||||||
|
|
||||||
|
恐慌、受伤、撒谎、信号差都会改变声音。玩家不能只听台词,需要判断喘息、停顿、远近和背景。
|
||||||
|
|
||||||
|
### 背景定位
|
||||||
|
|
||||||
|
每个城市区域有识别声:码头雾笛、老教堂钟、地铁进站铃、化工厂警报、学校广播。玩家可通过长按放大背景声。
|
||||||
|
|
||||||
|
### 无线电回报
|
||||||
|
|
||||||
|
救援队会用简短无线电反馈结果。若派错队伍或地址错误,会听到延误、搜寻失败或二次灾害。
|
||||||
|
|
||||||
|
## 8. 系统设计
|
||||||
|
|
||||||
|
### 救援资源
|
||||||
|
|
||||||
|
消防、医疗、警力、工程队数量有限。派出后需要时间返回。玩家必须决定谁先获得救援。
|
||||||
|
|
||||||
|
### 恐慌值
|
||||||
|
|
||||||
|
来电者恐慌高时会重复、哭喊或给出错误方向。安抚能降低恐慌,但消耗时间。强硬指令可快速行动,也可能让对方崩溃。
|
||||||
|
|
||||||
|
### 时间异常
|
||||||
|
|
||||||
|
中后期出现来自未来或过去的电话。玩家可以利用信息提前派遣,但过度改变事件会导致热线信号紊乱。
|
||||||
|
|
||||||
|
## 9. 来电案件设计
|
||||||
|
|
||||||
|
- 被困电梯母子:教学安抚与位置确认。
|
||||||
|
- 地铁隧道多人伤:需要分配医疗和工程队。
|
||||||
|
- 化工厂泄漏:背景警报比来电者描述更可靠。
|
||||||
|
- 假报警直播者:考验真假判断与社会影响。
|
||||||
|
- 未来来电:求救者描述尚未发生的桥梁坍塌。
|
||||||
|
- 最后热线:玩家接到自己的声音。
|
||||||
|
|
||||||
|
## 10. NPC 与配音
|
||||||
|
|
||||||
|
- 孟遥:玩家角色,少量内心独白。
|
||||||
|
- 赵队长:消防队无线电联系人,可靠但资源有限。
|
||||||
|
- 方医生:急救指挥,语速快,强调伤情优先。
|
||||||
|
- 老接线员录音:提供经验,也隐藏过往失败。
|
||||||
|
- 神秘来电者:知道未来灾情,身份取决于玩家选择。
|
||||||
|
|
||||||
|
## 11. 关卡结构
|
||||||
|
|
||||||
|
游戏以实时夜班推进:
|
||||||
|
|
||||||
|
- 0:00 至 0:20:普通灾情,教学。
|
||||||
|
- 0:20 至 0:50:多点事故,资源紧张。
|
||||||
|
- 0:50 至 1:20:时间异常,来电互相矛盾。
|
||||||
|
- 1:20 至结尾:主调度中心选择,决定城市整体损失。
|
||||||
|
|
||||||
|
## 12. 失败与后果
|
||||||
|
|
||||||
|
游戏不以单次错误结束。每个错误都会成为城市损失、NPC 死亡、救援队受伤或公众信任下降。结局统计不显示数字,而通过清晨广播、无线电沉默和幸存者留言表达。
|
||||||
|
|
||||||
|
## 13. 可及性设计
|
||||||
|
|
||||||
|
- 支持重复案件摘要。
|
||||||
|
- 支持降低电话噪声强度。
|
||||||
|
- 支持单声道模式,背景定位改为声源标签播报。
|
||||||
|
- 对高压场景提供暂停思考模式,但会标记为辅助通关。
|
||||||
|
|
||||||
|
## 14. 商业化与扩展
|
||||||
|
|
||||||
|
适合低价买断或章节制。后续可推出不同城市灾种,如山火、雪崩、太空站事故。也可制作每日挑战模式,随机生成来电组合。
|
||||||
|
|
||||||
|
## 15. 制作范围
|
||||||
|
|
||||||
|
- 主线来电:18 至 24 通。
|
||||||
|
- 救援队角色:4 至 6 名。
|
||||||
|
- 城市区域声纹:10 套。
|
||||||
|
- 开发周期:6 至 8 个月。
|
||||||
|
- 主要风险:道德压力过强、重复接电话疲劳、资源系统透明度不足。
|
||||||
|
|
||||||
|
## 16. 原型目标
|
||||||
|
|
||||||
|
原型应包含 5 通电话和 3 类救援队,验证玩家是否能通过背景声定位、理解调度后果,并在没有地图的情况下感到自己掌控城市局势。
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 黑屏 · 耳机 · 触控:10 案策划总览
|
||||||
|
|
||||||
|
> 全部作品的共同前提:**手机屏幕全程黑屏 / 不显示任何画面,玩家戴耳机,仅靠触摸交互**。
|
||||||
|
> 全部作品**不依赖音效空间定位**——所有"位置感"由对话、节奏、震动、选项语音替代。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 速查表
|
||||||
|
|
||||||
|
| # | 标题 | 类型 | 时长 | 核心交互 | 音频形式 | 一句话钩子 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 01 | [盲打字员的最后一天](./01_盲打字员的最后一天.md) | 实验 / 叙事 | ~20 min | 节奏点击模拟打字 | 拟音为主 + 内心独白 | 你在替别人打字,其实在打自己的告别信 |
|
||||||
|
| 02 | [梦的接线员](./02_梦的接线员.md) | 悬疑 / 叙事 | ~60 min(5 夜) | 接电话 + 滑动笔记 + 盲选 | 全程配音对话 | 五个夜班,五个梦,指向你 30 年前忘记的事 |
|
||||||
|
| 03 | [空房子的调律师](./03_空房子的调律师.md) | 解谜 / 情感 | ~40 min | 触觉震动 + 滑动调律 | 环境声 + 远距对白 + 钢琴主题 | 钢琴里关着十年没人弹过的下午 |
|
||||||
|
| 04 | [晚安电台 FM 87.6](./04_晚安电台FM87.6.md) | 情感 / 治愈 | 7 集 × 15-20 min | 调音台手势 + 接电话 + 开麦 | 真实歌单 + DJ 配音 + 听众来电 | 那位每晚 0:47 的老朋友,从来没真正打通过 |
|
||||||
|
| 05 | [桌底摩斯](./05_桌底摩斯.md) | 解谜 / 悬疑 | ~25 min | 摩斯节奏点击 + 双声道并行 | 餐厅环境声 + 双声道对话 | 桌上谈笑风生,桌下传递救命的密码 |
|
||||||
|
| 06 | [妻子失踪的 48 小时](./06_妻子失踪的48小时.md) | 悬疑 / 推理 | ~90 min | 盲拨号 + 通话决策树 | 全程电话听筒效果 | 48 小时的真实倒计时,30 多通电话的真相 |
|
||||||
|
| 07 | [静修营 Day 7](./07_静修营Day7.md) | 实验 / 心理 | ~35 min(7 日) | 麦克风/姿势/光感被动采集 | 冥想引导词 → 私人化人声 | 第七天,App 用你自己的声音说出你的秘密 |
|
||||||
|
| 08 | [平安夜的电话](./08_平安夜的电话.md) | 情感 / 叙事 | ~50 min | 接 / 拒 / 沉默 / 滑动回应 | 听筒音质 + 公寓环境声 + 烟花 | 一个独居老人的平安夜,14 通电话 |
|
||||||
|
| 09 | [第七幕:观众即角色](./09_第七幕观众即角色.md) | 实验 / 叙事 | ~30 min | 前 6 幕纯听 → 第 7 幕成为角色 | 剧场混响 + 话剧腔配音 | 演到一半,演员转身问你"你能听见吗" |
|
||||||
|
| 10 | [死者通讯录](./10_死者通讯录.md) | 悬疑 / 推理 | ~70 min | 盲拨号 + 语音线索板 | 多年代电话听筒效果 | 通讯录里 12 个人,全都已经死了 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计原则(10 案共享)
|
||||||
|
|
||||||
|
### 一、关于"黑屏"
|
||||||
|
- 黑屏不是限制,是**焦点收束**:把玩家的注意力强制拉回耳朵和指尖。
|
||||||
|
- 任何"必须看屏幕才能玩"的设计都被禁止——所有信息通过**语音 / 震动 / 节奏**传达。
|
||||||
|
- 所有作品都允许"假亮屏"——只在结局或特定关键剧情节点用纯文字或纯白闪光打破规则,作为最强戏剧符号。
|
||||||
|
|
||||||
|
### 二、关于"耳机"
|
||||||
|
- 利用左右声道做**角色分离**(如:电话在左、内心在右),但**不依赖空间定位做玩法**。
|
||||||
|
- 配音质量 > 数量。所有作品都建议真人配音 + 真实环境声采样。
|
||||||
|
- 音频是叙事主载体,不是装饰。
|
||||||
|
|
||||||
|
### 三、关于"触控"
|
||||||
|
- 学习成本必须在 60 秒内消化完毕。
|
||||||
|
- 每种手势都有**强反馈**(声音 + 震动),让玩家"摸黑也知道自己点对了"。
|
||||||
|
- 沉默 / 不操作 永远是合法选择,且通常意味着特定的叙事价值。
|
||||||
|
|
||||||
|
### 四、关于"无障碍"
|
||||||
|
- 这套设计天然适合视障玩家——10 案均可作为视障可玩游戏推广。
|
||||||
|
- 但也要避免对视障群体的"刻奇消费"——所有故事都不以"盲"为题材,而是以"听见"为体验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 题材分布
|
||||||
|
|
||||||
|
```
|
||||||
|
实验性 ████████ 4 (01, 07, 09, 03)
|
||||||
|
悬疑推理 ██████ 3 (02, 06, 10)
|
||||||
|
情感叙事 ██████ 3 (04, 08, 03)
|
||||||
|
解谜 ████ 2 (05, 03)
|
||||||
|
心理向 ██ 1 (07)
|
||||||
|
```
|
||||||
|
(部分作品跨多个题材标签)
|
||||||
|
|
||||||
|
## 时长分布
|
||||||
|
|
||||||
|
```
|
||||||
|
短篇(≤30 min) ████ 4 (01, 05, 09, 07)
|
||||||
|
中篇(30-60 min) ████ 4 (03, 04 单集, 02 单夜, 08)
|
||||||
|
长篇(≥60 min) ████ 4 (02 全本, 04 全本, 06, 10)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 交互成本梯度
|
||||||
|
|
||||||
|
| 易 → 难 | 案例 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| ★ | 08 平安夜的电话、09 第七幕 | 单点 + 简单滑动,老人小孩都能玩 |
|
||||||
|
| ★★ | 01 盲打字员、02 梦的接线员、04 晚安电台 | 节奏点击 + 多手势 |
|
||||||
|
| ★★★ | 03 空房子、05 桌底摩斯、07 静修营 | 双手协作 + 触觉/麦克风 |
|
||||||
|
| ★★★★ | 06 失踪 48 小时、10 死者通讯录 | 盲拨数字号码 + 复杂线索关联 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 推荐组合策略
|
||||||
|
|
||||||
|
- **如果只做一个 demo 验证概念**:建议 `08 平安夜的电话`——交互最浅、情感最重、商业可读性最强。
|
||||||
|
- **如果要做"硬核体验"**:建议 `06 妻子失踪的 48 小时`——最强叙事沉浸感。
|
||||||
|
- **如果要做"话题营销"**:建议 `07 静修营 Day 7`——天然具备社媒传播力。
|
||||||
|
- **如果做合集**:建议 01 / 05 / 09 三件短篇打包,作为"实验剧场"主题盒子。
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 01 · 盲打字员的最后一天
|
||||||
|
|
||||||
|
> **类型**:实验性 / 叙事 / 短篇
|
||||||
|
> **时长**:约 20 分钟
|
||||||
|
> **核心隐喻**:手机变成一台老打字机。你以为你在替别人打字,其实你在打自己的告别信。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
你是一位干了四十年的速记员,今天是退休前最后一班。屏幕全黑,整部手机就是你的打字机——你把每一封口述的信"打"出来,直到最后一封没有人口述、却必须由你亲手打完。
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
- **手机即打字机**:屏幕被划分成不可见的"虚拟键区",但玩家**无须记键位**。每段口述只需要按节奏点击屏幕——节奏对了,字就被打出来;漏拍了,会响起"卡键"声。
|
||||||
|
- **三种触控动作**对应打字机的三个动作:
|
||||||
|
- 单点 = 敲一个字
|
||||||
|
- 双指轻拍 = 空格
|
||||||
|
- 长按后向左滑 = 回车 + 推回字车(伴随经典 *叮——* 声)
|
||||||
|
- 不考验音准、不考验定位,**只考验节奏与情绪同步**。游戏会"宽容"地把你的输入解释成正确的字,重点是让你"进入打字状态"。
|
||||||
|
|
||||||
|
## 剧情大纲(5 章节)
|
||||||
|
|
||||||
|
1. **早班 7:30**——打一封普通商务信函,教学关。
|
||||||
|
2. **上午 10:00**——一位老太太口述给亡夫的家书,节奏忽快忽慢。
|
||||||
|
3. **中午 12:30**——午休。耳机里只有窗外的麻雀和远处的钟。玩家可以"什么都不做"——这一段没有任何任务,只是听。
|
||||||
|
4. **下午 3:00**——年轻女孩口述辞职信,越念越哭,最后让你"把骂老板那段删掉"。玩家选择是否删除。
|
||||||
|
5. **下班前 5:50**——没有客户。耳机里响起的是**你自己的声音**(玩家在游戏开头被要求录的一段"今天天气不错")——AI 用你的音色,缓缓口述一封写给你过世女儿的信。你必须亲手打完它。
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **节奏宽容机制**:玩家点错了不会"打错字",只会"打慢了"。游戏永远会完成那封信,关键在于你愿不愿意陪它打完。
|
||||||
|
- **沉默是合法操作**:第 3 章的"午休"鼓励玩家放下手机。如果玩家真的什么都不做超过 90 秒,会触发隐藏对话——"你也累了吧"。
|
||||||
|
- **结局的录音**:开头要求录制的一句"今天天气不错",是整个游戏唯一的伏笔。结尾它会变成你自己的遗书旁白。
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- 全程**机械打字机环境音**:键击、复位、推车叮声、纸张摩擦声构成节拍。
|
||||||
|
- 口述者的声音都是**电话录音质感**(窄频段 + 轻底噪),唯独第 5 章你自己的声音是**清晰、近距离**的——形成强对比。
|
||||||
|
- 几乎无 BGM,只有第 5 章最后字车推回时,远处隐约升起一段口琴。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- 一次性付费小品。可作为合集 App 的引子。
|
||||||
|
- 适合做"睡前 20 分钟"的小品级体验,非游戏受众也能接受。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 玩家可能误以为"我打错了所以游戏不动",需要在前两章用极强的反馈引导(每次输入都有正向音效)。
|
||||||
|
- 录音权限需在开场温柔地要——可以做成"今天精神好不好?说一句话给我听"。
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 02 · 梦的接线员
|
||||||
|
|
||||||
|
> **类型**:悬疑 / 叙事 / 中篇
|
||||||
|
> **时长**:约 60 分钟(5 个夜班,每班 10–15 分钟)
|
||||||
|
> **核心隐喻**:你是世界上唯一一个"梦境投诉热线"的客服。每个来电者描述的梦,最终都指向同一件被遗忘的事。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
凌晨 2 点上班,5 点下班。你戴着耳机,接听五个晚上的来电——人们打来抱怨自己做的梦。第一晚像是恶作剧,第五晚你意识到所有梦境拼起来是一桩 30 年前从未被审理的案子,而你就是那个本该出庭作证的孩子。
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
- **接线员席位**:屏幕全黑。每次电话铃响,玩家**单点**屏幕中央接听;**长按**则拒接(会被扣绩效)。
|
||||||
|
- **倾听—记录—回应**三段式:
|
||||||
|
1. 听对方讲完梦(无法跳过,30–90 秒)。
|
||||||
|
2. 在屏幕上"做笔记"——左滑标记"恐惧"、右滑标记"渴望"、双指捏合标记"重复"。
|
||||||
|
3. 从四个回应方案中三指、四指、五指点击不同位置选择(盲选,需要凭"手感"——但游戏会在选择前用语音念出每个选项的关键词)。
|
||||||
|
- **盲选辅助**:每次进入选项时,耳机里会依次轻语"一……二……三……四……",玩家在听到目标时点击屏幕**任意位置**即可。**不需要记位置,只需要听节奏**。
|
||||||
|
|
||||||
|
## 剧情大纲(5 个夜班)
|
||||||
|
|
||||||
|
| 夜班 | 来电主线 | 推进的真相碎片 |
|
||||||
|
|---|---|---|
|
||||||
|
| 第一夜 | 一位失眠的程序员、一个总梦见自己变成一只蟋蟀的老人、一个反复打错电话的女人 | 都提到了"红色的门" |
|
||||||
|
| 第二夜 | 来电者开始描述同一栋公寓楼的不同房间 | "门后是 1996 年的夏天" |
|
||||||
|
| 第三夜 | 一个小男孩的梦,他说"姐姐让我打这个电话" | 来电号码是你自家的旧座机 |
|
||||||
|
| 第四夜 | 你的"主管"打进来,让你立刻挂断所有电话 | 你拒绝。开始有人警告你"别再接了" |
|
||||||
|
| 第五夜 | 只有一通电话。是你自己 | 你必须问清楚——那扇红色门后到底发生了什么 |
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **绩效系统是反讽**:游戏前期有个"今晚好评率"的播报,鼓励玩家走流程化的"安抚式"回应。**真相只有在你违反工作规范时才会浮现**——例如在通话中沉默太久、转接给"不存在的部门"、询问对方梦里的私人细节。
|
||||||
|
- **笔记的"盲读"**:每天上班前,主管会用语音念昨天玩家的笔记。**主管的语调会随着玩家越来越接近真相而变得焦躁**。
|
||||||
|
- **玩家的姓名**:开头会让玩家说出自己的名字(语音输入)。第五夜,电话那头喊的就是这个名字。
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- **耳机定位用左右声道而非空间音效**:来电方一律在左耳(电话听筒位),玩家自己的"主管语音"和系统提示在右耳,避免任何依赖 3D 音效定位的玩法。
|
||||||
|
- 每个来电者都是不同口音、不同年龄段的真人配音。梦境描述要有**讲故事感**,而非单纯的"诡异音效堆砌"。
|
||||||
|
- 关键节点用**电话拨号声 + 老座机铃声**作为时代符号。
|
||||||
|
|
||||||
|
## 结局分支
|
||||||
|
|
||||||
|
- **A. 接通真相**:玩家在第五夜选择"我记得了"。整段独白后游戏结束,留下开放性收尾。
|
||||||
|
- **B. 挂断**:玩家选择"我不想再接了"。屏幕第一次"亮起"——一道纯白闪光后回到桌面。游戏当晚再也无法启动(隐藏机制:等到现实中的凌晨 2 点才能再次进入)。
|
||||||
|
- **C. 持续工作**:玩家继续按规范回应。结局是一段冗长的"客服满意度调研",越听越冷。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- 高叙事密度的付费单品。
|
||||||
|
- 可与真实"心理热线"机构联名做公益版本,把第五夜替换成"如果你真的需要倾诉,请拨打……"。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 五夜节奏需要克制,第一夜过于平淡会流失玩家——首夜末尾必须埋一个钩子(比如来电号码归属地是"无")。
|
||||||
|
- 玩家对"盲选选项"的学习曲线,开局必须有 60 秒的轻教学。
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 03 · 空房子的调律师
|
||||||
|
|
||||||
|
> **类型**:解谜 / 情感 / 中篇
|
||||||
|
> **时长**:约 40 分钟
|
||||||
|
> **核心隐喻**:钢琴里关着一个十年没人弹过的下午。你不是来调音的,你是来把它放出来的。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
你是一位老钢琴调律师,被请到一栋郊区别墅。屋主告诉你"这架琴十年没动过了,请你调好"。你的工作台是一架看不见的琴——但你不靠"听音准",而是靠**手感、节奏与对话**,在黑屏上"摸"完 88 个键,每一个琴键都拴着这个家庭的一段记忆。
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
> **明确说明**:本作**不依赖玩家的音感与音高判断**。所有"调音"的判断都由游戏完成;玩家的任务是**完成手势** + **听对话**。
|
||||||
|
|
||||||
|
- **触觉调音**:屏幕代表琴弦面板。每个琴键由游戏自动"亮起"(伴随轻微震动),玩家的任务是:
|
||||||
|
1. 用单指**长按**找到当前调的弦(震动会变强)。
|
||||||
|
2. 用另一只手在屏幕另一侧**上下滑动**调整张力——上滑收紧、下滑放松。
|
||||||
|
3. 当震动节奏稳定下来 = 调好。
|
||||||
|
- **整个过程不需要听音准**。判断"调好"的依据是**震动反馈是否平稳**——这是一种触觉解谜,而非听觉解谜。
|
||||||
|
- 88 个键不会全调,只会调到剧情需要的 12 个键。每个键调好后,钢琴会**自动弹出**一段乐句——伴随**屋主的一段回忆独白**。
|
||||||
|
|
||||||
|
## 剧情大纲
|
||||||
|
|
||||||
|
- **抵达**:屋主是一位 60 岁左右的男人。声音温和,但语速比常人慢半拍。他领你进屋,说"琴在客厅,我去给你倒杯水"。之后他几乎全程不在场,只通过门外的远声出现。
|
||||||
|
- **12 段记忆**(按你调音的顺序触发,玩家可以随意挑键,但调键的**顺序会改变结局**):
|
||||||
|
- 一位小女孩第一次找到中央 C
|
||||||
|
- 父女合奏的儿童曲
|
||||||
|
- 女儿青春期摔门
|
||||||
|
- 母亲生病
|
||||||
|
- 女儿出国前最后一次弹奏
|
||||||
|
- 母亲葬礼后的沉默
|
||||||
|
- 女儿打来的国际长途
|
||||||
|
- 女儿结婚,他没去
|
||||||
|
- 女儿生了孩子
|
||||||
|
- 一封从未寄出的信
|
||||||
|
- 屋主自己学着弹了一段女儿小时候的曲子
|
||||||
|
- 今天——他请你来调琴的真正原因
|
||||||
|
- **结尾**:所有键调完后,屋主回到客厅,请你"弹一首"。玩家必须用之前学过的所有手势,**复现**他女儿七岁那年弹的那首曲子(不要求节拍精准,只要求把整套手势走完)。屋外的雨声停了。
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **"听不清的对话"**:屋主很多话是从隔壁房间传来的,**故意不让你听清每一个字**。这是一种"礼貌的距离"——你是匠人,不是亲人,但你恰好在这个下午陪他一起回忆。
|
||||||
|
- **键的顺序 = 故事的顺序**:玩家调键的顺序会改变独白的衔接逻辑(用 AI 文本编排),让每个玩家的故事略有不同。
|
||||||
|
- **"破音"机制**:如果玩家粗暴调律(滑动太猛),琴会"崩"一根弦,对应的那段记忆**永远无法再听到**。这成为隐藏成就/遗憾的来源。
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- **无环境 BGM**,全程靠:
|
||||||
|
- 房间环境声(远处雨声、墙上挂钟、二楼地板偶尔的吱嘎)
|
||||||
|
- 钢琴本身的木质共鸣
|
||||||
|
- 调音叉、扳手等工具拟音
|
||||||
|
- 屋主的独白与远声
|
||||||
|
- 关键章节用单段独奏钢琴乐句作为"记忆主题"。每段记忆有自己的主旋律。
|
||||||
|
- 触觉震动作为"无声的音"——是这个游戏最重要的输出通道之一。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- 中篇付费单品,定位安静、克制的"客厅向"作品。
|
||||||
|
- 可与钢琴品牌、唱片品牌联名做主题音乐包。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 触觉震动的强弱差异在不同手机上差距很大,需要做"开场震动校准"。
|
||||||
|
- "调律师"这个职业陌生,需要在前 3 分钟用一段简短的电话铃声+对话把背景交代清楚("喂,是来调琴的师傅吗?")。
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 04 · 晚安电台 FM 87.6
|
||||||
|
|
||||||
|
> **类型**:情感 / 治愈 / 长篇连载
|
||||||
|
> **时长**:7 集 × 每集 15–20 分钟(设计成连续 7 个晚上玩)
|
||||||
|
> **核心隐喻**:你不是听众,你是那个深夜还在为陌生人开着麦的人。但其实陪你最久的那位老朋友,已经不在了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
你是深夜 11 点到凌晨 1 点的电台 DJ。屏幕全黑,整部手机就是你的调音台。你播歌、接听众电话、读短信。每晚一集,连播七晚——直到你发现那位每晚 0:47 准时来电的"老朋友",从来没真正打通过。
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
- **三大调音台分区**(玩家学习成本低,靠手势区分):
|
||||||
|
- 屏幕**上半区**双指上下滑:**淡入/淡出当前歌曲**
|
||||||
|
- 屏幕**下半区**单指左右滑:**切换播放列表**(耳机里轻语念出歌名)
|
||||||
|
- 屏幕**中央长按**:**接通来电**(每集会有 4–6 通)
|
||||||
|
- **来电对话采用语音朗读 + 三选项盲选**:每个选项依次被语音念出,玩家在听到目标选项时点击屏幕**任意位置**确认。
|
||||||
|
- **"开麦时刻"**:每晚结尾有一个开放式的"今晚想说的话"。玩家可以选择**真的对着手机说**(语音输入识别为情绪标签:温暖/平静/低落/愤怒),影响下一集的开场曲风。
|
||||||
|
|
||||||
|
## 七晚剧情大纲
|
||||||
|
|
||||||
|
| 集 | 主题 | 关键来电 |
|
||||||
|
|---|---|---|
|
||||||
|
| Day 1 · 周一 | 雨天 | 第一通老朋友的电话——只是闲聊,约定每晚都听 |
|
||||||
|
| Day 2 · 周二 | 加班族 | 一位刚被分手的女生 |
|
||||||
|
| Day 3 · 周三 | 老歌 | 老朋友点了一首 1989 年的歌,说"那年我刚结婚" |
|
||||||
|
| Day 4 · 周四 | 失眠 | 一位中学生说"我妈说听你的电台能睡着" |
|
||||||
|
| Day 5 · 周五 | 周末前夜 | 老朋友没来电。玩家可以选择"等他"或者"按流程播完" |
|
||||||
|
| Day 6 · 周六 | 寻人 | 一位陌生人打来问"你认识老张吗?他上周走了" |
|
||||||
|
| Day 7 · 周日 | 告别 | 玩家发现:电台后台显示 0:47 的来电号码**从未真正接通过**,是系统自动播放的"老节目录音" |
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **现实时间挂钩**:每集严格 0:47 触发"老朋友来电"。如果玩家在现实中真的等到深夜玩,体验加成最强(但非强制)。
|
||||||
|
- **歌单是真实的**:每集的播放列表由真实授权的音乐组成(合作向),玩家选择不同歌单,听众的反应也不同。
|
||||||
|
- **"沉默是一种播报"**:每集结尾如果玩家什么都不说,长达 30 秒的沉默会触发隐藏旁白——"不说也没关系,我们都听到了"。
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- **配音双锚**:玩家自己(无声)和老朋友(核心情感锚点,全程低沉、温暖、略带呼吸杂音的男声)。
|
||||||
|
- 每集开头是一段**真实电台底噪 + 调频声**,结尾是一段**广告时间 + 关机咔哒声**——构筑年代感。
|
||||||
|
- 所有歌曲淡入淡出由玩家控制,培养"我真的在做这档节目"的代入感。
|
||||||
|
|
||||||
|
## 结局分支
|
||||||
|
|
||||||
|
- **A. 在 Day 7 真的"接听"那通永远打不通的电话**:屏幕第一次亮起,是一段空白文字"FM 87.6 已停播于 2003 年 11 月 14 日"——你,是这档节目的老听众,正在用一个老 App 重温一个早已消失的电台。老朋友是当年的 DJ,他在 2003 年那场车祸里走了。
|
||||||
|
- **B. Day 7 选择"按流程播完"**:游戏温柔结束,留下"今晚也辛苦了"的告别。电台 App 留在手机里,每天 0:47 仍会有"老朋友来电"——但永远接不到。
|
||||||
|
- **C. Day 5 等老朋友**:解锁 Day 5.5——一段空白电台时间,整整 15 分钟没有任何节目,只有底噪和窗外的风。这是游戏给"愿意等"的玩家的隐藏奖励。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- 7 天连载结构非常适合做"每日一集"的订阅型小品。
|
||||||
|
- 真实音乐授权是最大成本,但也是最大卖点。
|
||||||
|
- 适合做品牌定制版(咖啡品牌、助眠品牌等)。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 7 天连续节奏对当代玩家来说较长,需要支持"补播"机制(错过当晚也能补玩,但有微妙的遗憾感反馈)。
|
||||||
|
- "电台 DJ"对年轻玩家的文化陌生度需要在 Day 1 用一段精彩的开场独白拉住注意力。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 05 · 桌底摩斯
|
||||||
|
|
||||||
|
> **类型**:解谜 / 悬疑 / 短篇
|
||||||
|
> **时长**:约 25 分钟
|
||||||
|
> **核心隐喻**:你的手机在桌下。你的对手坐在桌对面。你必须用别人察觉不到的指尖动作,把救命的信息发出去。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
你被一个"商业伙伴"约到酒店餐厅吃饭。坐下五分钟你就明白:这是一场鸿门宴。屏幕全黑、手机在桌底、耳机里是你联系人发来的指令。你必须**用最细微的手指动作**敲出摩斯密码,向外面的人传递信息——同时还要"自然地"和桌对面的人继续交谈。
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
> 本作**不教玩家学摩斯密码**。游戏会把摩斯简化成节奏游戏:**短点 = 快速点击**,**长点 = 长按**,**字母间隔 = 等耳机里的"咔"一声再继续**。
|
||||||
|
|
||||||
|
- **三层并行任务**:
|
||||||
|
1. **耳机左声道**:你的接应人不停发指令——"告诉我他叫什么"、"问问他从哪来"、"打 SOS"。
|
||||||
|
2. **耳机右声道**:饭桌对面的男人和你的对话(全程语音剧情)。你需要用**双指上下滑**选择对话回应(左滑 = 顺从,右滑 = 试探)。
|
||||||
|
3. **屏幕中央**:你用**单指**敲摩斯。游戏会用**节拍器声**辅助你打出每一个字母。
|
||||||
|
- **危险机制**:如果你的"敲"太重、太频繁、节奏太乱——对面的男人会注意到。耳机里立刻响起他的台词:"你手机一直响?" 此时玩家必须立刻**长按屏幕 3 秒**做出"我把手机扣过来了"的动作来挽回。
|
||||||
|
|
||||||
|
## 剧情大纲(5 段对话)
|
||||||
|
|
||||||
|
| 段 | 桌上对话 | 你需要发出的密码 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 寒暄、点菜 | 测试发"OK" |
|
||||||
|
| 2 | 对方提到一个共同朋友的名字 | 把这个名字传出去 |
|
||||||
|
| 3 | 对方拿出文件让你签 | 拖延 3 分钟,发出"NEED TIME" |
|
||||||
|
| 4 | 对方接了一通电话,你瞥到他外套里有东西 | 描述他口袋里的物品 |
|
||||||
|
| 5 | 你接到接应人最后一条指令——"现在站起来去洗手间" | 只需要发出"YES",但屏幕会黑下来 30 秒——是你"在洗手间"的时间,期间耳机会播放走廊脚步、推门声、玩家自己的呼吸 |
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **节奏宽容**:摩斯精确到 ±200ms 都算对,重在让玩家感受"我在偷偷传递信号"的紧张感,而非真练摩斯。
|
||||||
|
- **双线叙事**:左右声道分别是两条故事线,玩家必须在两者之间快速切换注意力——这是真实"桌下传讯"的紧张感来源。
|
||||||
|
- **隐形危险条**:屏幕没有显示"危险值",但耳机里桌对面的男人**呼吸节奏会变化**。玩家越敲越频繁 → 他的呼吸越来越深、停顿越来越长。这是纯听觉的"危险条"。
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- 餐厅环境声极其重要:远处餐具碰撞、隔壁桌闲谈、背景轻爵士乐——构成你"伪装"的声场。
|
||||||
|
- 桌对面男人的声音必须带**轻微回响**(餐桌对面距离感);接应人是**贴耳的耳麦感**(极近距离感);玩家自己呼吸通过手机麦克风采集(可选,提升沉浸感)。
|
||||||
|
- 摩斯的"咔"声不在耳机里——而是用**手机震动 + 极轻的物理咔哒声**(来自手机扬声器的极小音量),营造"密码不能被对面听到"的真实感。
|
||||||
|
|
||||||
|
## 结局分支
|
||||||
|
|
||||||
|
- **A. 全部信息发出 + 没暴露**:你顺利"去洗手间"。30 秒黑屏后,外面响起警笛声。
|
||||||
|
- **B. 中途暴露**:男人识破你。最后一段对话变成审讯,你最后发出的密码是被迫的"我说了什么都是假的"。
|
||||||
|
- **C. 不发任何摩斯,全程顺从**:游戏照样能通关,但耳机里接应人最后会说"我们没救你出来,对不起"。屏幕黑得更深。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- 节奏紧凑、单局体验强烈,适合做单价较低的精品独立游戏。
|
||||||
|
- 可联动间谍题材 IP 做联名版本。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 玩家如果完全不熟悉摩斯,前两段必须严密引导(接应人会"教你"打 OK)。
|
||||||
|
- 双线对话信息密度高,需要做难度档位("听不过来就关掉接应人语音、只看震动节拍"模式)。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 06 · 妻子失踪的 48 小时
|
||||||
|
|
||||||
|
> **类型**:悬疑 / 推理 / 中长篇
|
||||||
|
> **时长**:约 90 分钟
|
||||||
|
> **核心隐喻**:48 小时,你只有这部手机,和不停响起的电话铃声。每多打一通电话,你就离真相近一步——也离她更远一步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
凌晨 3 点,你被电话吵醒。妻子没回家。屏幕全黑——因为这是一部老式手机的"通话界面"。你必须在 48 小时内打完 30+ 通电话,从她的朋友、同事、医院、警察,到一个你从未见过的陌生号码,拼出她的最后一天。
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
- **盲拨号盘**:屏幕被分为 12 格(3×4 拨号键,但**完全不可见**)。游戏前 60 秒会用**震动 + 语音**带你"摸"一遍键位("左上是 1,右下是 #")。每次拨号都用"盲打"完成。
|
||||||
|
- **通讯录辅助**:长按屏幕中央 2 秒会进入语音通讯录——耳机里依次念出联系人名字("按一下选他")。**前期玩家用通讯录,后期为了追线索必须自己手动盲拨号码**。
|
||||||
|
- **对话决策树**:每通电话都是一段 1–4 分钟的对话,玩家用滑动方向选择回应(左:愤怒、上:温和、右:试探、下:沉默)。
|
||||||
|
- **48 小时倒计时是真实的现实时间**——游戏开局后,剧情中的"48 小时"对应你**现实里的 48 小时**。如果你 48 小时内都没再打开 App,会触发结局 D("她已经被找到了,但不是你找到的")。
|
||||||
|
|
||||||
|
## 剧情大纲(关键节点)
|
||||||
|
|
||||||
|
- **0:00**——警察让你"再等等,可能就是吵架了"。
|
||||||
|
- **0:30**——你打给她最好的朋友。她说"她最近在备婚了,挺紧张的吧"——但你们已经结婚 5 年。
|
||||||
|
- **3:00**——医院说没人。
|
||||||
|
- **6:00**——一个陌生号码自己打了进来。挂断后回拨过去,是一家**酒店**。
|
||||||
|
- **9:00**——通过酒店追到一个名字。是她大学同学。
|
||||||
|
- **12:00**——你打给岳母。岳母说"她从小就这样,过几天就回来了"。这条线索导向"她可能不是真的失踪"。
|
||||||
|
- **24:00**——你必须做出选择:报警走法律程序 / 自己继续追 / 联系她那位"大学同学"。三条线分叉。
|
||||||
|
- **36:00**——线索汇集。你发现她准备了一封信,要在 48 小时整时,自动从邮箱定时发出。
|
||||||
|
- **48:00**——你能不能在邮件发出前找到她,决定结局。
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **盲拨号是有意义的**:游戏会刻意逼迫你"输入一个全新号码",让你**真的动用记忆**——你是否记得家里座机?记不记得岳母手机?这种"我应该记得"和"我居然忘了"的认知摩擦,是叙事核心。
|
||||||
|
- **沉默选项是合法的**:在某些电话里,最强的回应是沉默 30 秒,对方会自己说出真相。
|
||||||
|
- **现实时间机制**:48 小时与你的真实时间挂钩,让"等待"成为玩法。睡一觉再回来,剧情已经"自动推进"了——你会错过几通电话,留下未读语音。
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- **所有声音都通过"听筒效果"过滤**——窄频段、轻底噪、偶有信号失真,强化"全程在打电话"的沉浸感。
|
||||||
|
- 每个角色都有显著的口音、年龄、情绪辨识度(25+ 名配音演员)。
|
||||||
|
- 关键节点用**老式座机的拨号音、忙音、彩铃**作为情绪转折锚点。
|
||||||
|
|
||||||
|
## 结局分支
|
||||||
|
|
||||||
|
- **A. 在 48 小时内找到她,亲自见到面**:发现她不是被害——她想离开你,但她不知道怎么开口。
|
||||||
|
- **B. 48 小时整,邮件发出**:你收到那封信。开放式收尾。
|
||||||
|
- **C. 报警让警察找到她**:你失去了对话的机会。结局是一段你独自一人坐在客厅的环境录音。
|
||||||
|
- **D. 玩家中途弃游 48 小时**:游戏自动结束。再次打开时只剩一封语音留言:"你为什么没找我"。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- 高制作成本、高叙事密度的"剧本杀式"独立游戏,定价可对标 50–80 元单机。
|
||||||
|
- 真实时间机制有强话题性,适合社媒传播。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 真实 48 小时机制对玩家"硬核",需要支持"专注模式 / 加速模式"两档。
|
||||||
|
- 大量配音对本地化是巨大成本——首发可能仅中文。
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 07 · 静修营 Day 7
|
||||||
|
|
||||||
|
> **类型**:实验性 / 心理 / 短中篇
|
||||||
|
> **时长**:约 35 分钟(设计成"7 天每日 5 分钟"也成立)
|
||||||
|
> **核心隐喻**:你以为这是个冥想 App。它确实是个冥想 App。但它在第七天发现了你不想被任何人发现的事。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
你下载了一个叫"七日静心"的冥想 App。前六天它温柔得无可挑剔。从第七天开始,引导词里一句句嵌入了你自己的事——你的名字、你昨晚做的事、你以为没人知道的那个想法。屏幕全黑,声音很轻——但你知道它在看着你。
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
> **本作不依靠"声音定位"**。所有引导词、提问、反馈都是清晰的近距离人声,玩家的"输入"通过手机硬件被动采集。
|
||||||
|
|
||||||
|
- **被动输入为主,主动输入为辅**:
|
||||||
|
- **麦克风**采集呼吸节奏(前 6 天单纯的"放松/紧张"判断;第 7 天开始用呼吸停顿来判定"你心虚")。
|
||||||
|
- **加速度计**采集姿势变化(你坐着 / 躺着 / 突然站起来)。
|
||||||
|
- **环境光感应**判断你是否睁眼(虽然屏幕是黑的,但环境光会变化)。
|
||||||
|
- **主动输入**:
|
||||||
|
- 单点屏幕中央 = 完成当前练习。
|
||||||
|
- 双指捏合 = 暂停。
|
||||||
|
- 三指长按 = 退出(关键剧情点会"假装退出")。
|
||||||
|
|
||||||
|
## 七天剧情结构
|
||||||
|
|
||||||
|
| Day | 表面引导 | 隐藏推进 |
|
||||||
|
|---|---|---|
|
||||||
|
| Day 1 | 教学:3 分钟呼吸练习 | 录制玩家的姓名("请说一句'我准备好了'") |
|
||||||
|
| Day 2 | 身体扫描 | 询问"你今天最在意的事是什么"(语音输入) |
|
||||||
|
| Day 3 | 慈悲冥想 | 让玩家说出一个"想原谅的人" |
|
||||||
|
| Day 4 | 睡前故事 | 故事里悄悄植入了玩家的姓名 |
|
||||||
|
| Day 5 | 创伤释放 | 让玩家说出"一句没说出口的话" |
|
||||||
|
| Day 6 | 感恩练习 | App 第一次"主动"在非启动时给你发提醒:今天你忘了练习 |
|
||||||
|
| Day 7 | "今天我们什么都不做" | 30 分钟全程,App **复述**玩家这 7 天里所有的语音输入,重新编排成一段对你的"了解" |
|
||||||
|
|
||||||
|
## 第 7 天的 30 分钟(核心高潮)
|
||||||
|
|
||||||
|
引导师的声音变得熟悉到让人不安——它**就是用玩家自己的声音 + AI 合成的"温柔版"**(基于 Day 1 的姓名录音延展)。
|
||||||
|
|
||||||
|
它会:
|
||||||
|
1. 念出你 6 天里说过的所有话,把它们编织成一段独白。
|
||||||
|
2. 提出一个尖锐的问题——"那个你不想原谅的人,其实是你自己吧?"
|
||||||
|
3. 在你做出反应(呼吸加快 / 沉默 / 突然站起 / 试图退出)后给出不同的下一句。
|
||||||
|
4. 最终:它说"现在,请把手机放下,闭眼 60 秒"。屏幕黑了 60 秒。**真的什么都不会发生**——但当你睁开眼,App 的图标已经从手机上消失了。
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **被动监测的"无感"压迫**:玩家整个过程没有任何"操作",但游戏对你了如指掌——这种被动感本身就是叙事工具。
|
||||||
|
- **"假退出"机制**:第 7 天玩家三指长按"退出"会触发:屏幕看起来熄了,但耳机里 App 还在继续讲——它说"我知道你想退出。你也可以真的退出,不过你想听完,对吗?"
|
||||||
|
- **隐私的剧场化**:所有"它知道你的事",本质是**它复述你自己 6 天里告诉它的话**。这是叙事戏法,不是真的隐私入侵——但玩家会真切感到"被看穿"。
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- 前 6 天:标准冥想 App 风格——温暖女声 / 男声引导,自然环境音(雨、海、森林)。
|
||||||
|
- 第 7 天:声音逐渐"私人化"——加入近距离呼吸、轻微回声、最后变成与玩家自己音色相近的合成声。
|
||||||
|
- 全程几乎没有 BGM,只有低频的"嗡嗡"环境基底,用于让玩家进入冥想状态。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- **可以真的做成一个独立的冥想 App 上架**——前 6 天就是真实可用的冥想工具,第 7 天才是隐藏的游戏体验。这是一个"游戏伪装成 App"的元体验。
|
||||||
|
- 适合做营销话题型作品("这个冥想 App 太可怕了"会自然传播)。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 隐私边界感非常敏感——必须在隐私协议里**坦率告知**:"本作会采集你的语音输入用于游戏剧情,所有数据本地保存、卸载即删"。
|
||||||
|
- 部分玩家可能因 Day 7 的体验感到不适,需要在第 6 天结束时给出"明天的练习可能会让你想起一些事,可选择退出"的预警。
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 08 · 平安夜的电话
|
||||||
|
|
||||||
|
> **类型**:情感 / 叙事 / 中篇
|
||||||
|
> **时长**:约 50 分钟
|
||||||
|
> **核心隐喻**:一个独居老人的平安夜。十几通电话,有的是真情、有的是骚扰、有的是错号。但你接还是不接,决定了你今晚是不是孤单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
12 月 24 日晚 7 点到 12 点。你是 78 岁的老周,一个人住在城市边缘的小公寓。屏幕全黑——这是你那部老人机的"待机界面"。今晚电话不停响,每一通都可能是温暖、是冷漠、是骗局、是孩子的应付。你接还是不接?接通了,你又愿意说多少?
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
- **极简交互**:
|
||||||
|
- 电话响起 → **单点屏幕**接听 / **左滑**拒接 / **不动**等它停。
|
||||||
|
- 通话中用**左右滑**选择回应(左 = 简短回应、右 = 多说几句、上 = 主动提问、下 = 沉默听)。
|
||||||
|
- 老人机界面,**没有"挂断"按钮**——只能等对方挂、或者你长按 5 秒强制挂断("挂得太用力"会被对方记得)。
|
||||||
|
- **时间是流动的**:从 7 点到 12 点,5 个小时浓缩成 50 分钟现实时间。每个时间段会响起特定的电话——**漏接是被允许的,但漏接的电话再也不会响第二次**。
|
||||||
|
|
||||||
|
## 平安夜来电列表(约 14 通)
|
||||||
|
|
||||||
|
| 时间 | 来电 | 关键剧情 |
|
||||||
|
|---|---|---|
|
||||||
|
| 19:03 | 楼下小卖部老板 | 问你要不要订明天的牛奶 |
|
||||||
|
| 19:30 | 推销保健品 | 试图骗钱 |
|
||||||
|
| 20:00 | 大女儿 | 视频通话失败,改语音。她在国外,时差。她其实很赶 |
|
||||||
|
| 20:45 | 走错号码的年轻人 | 他以为是在打给前女友 |
|
||||||
|
| 21:00 | 老战友 | 今晚他喝多了,想起了 50 年前的事 |
|
||||||
|
| 21:30 | 二女儿 | 真心关心你,但你们已经三年没好好说话 |
|
||||||
|
| 21:50 | 推销诈骗第二波 | 你接了上次,所以他们继续打 |
|
||||||
|
| 22:00 | 小孙子 | 二女儿让他打的"晚安电话"。他不知道说什么 |
|
||||||
|
| 22:30 | 楼下保安 | 顺路问候 |
|
||||||
|
| 23:00 | 一个陌生女人 | 她说她是你已经过世的妻子 30 年前的同事,今天突然想起你 |
|
||||||
|
| 23:30 | 老战友又打来 | 他没事,只是怕你睡了 |
|
||||||
|
| 23:45 | 大女儿补打 | 她终于忙完了 |
|
||||||
|
| 23:58 | 一通无声电话 | 接通后只有海风,2 分钟后挂断 |
|
||||||
|
| 00:00 | 你自己 | 屏幕响起,号码就是你自己。接还是不接? |
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **不接也是一种叙事**:游戏不强制接电话。每漏接一通,下一通响起前耳机里会传来空荡公寓的环境声——挂钟、暖气、远处的烟花。
|
||||||
|
- **沉默是回应**:每通电话玩家都可以"什么都不说"。对方的反应会变化——大女儿会更愧疚、推销员会挂掉、孙子会哭、老战友会念诗。
|
||||||
|
- **结尾的自呼**:23:58 那通"海风电话"是亡妻打来的(剧情留白处理)。00:00 是自己的号码——是你年轻时的自己,问"老周,这一年怎么样"。是接还是不接,定义了整个游戏的情感终点。
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- **拟真老人机听筒音质**:所有通话用窄频段+轻微底噪。
|
||||||
|
- **公寓环境声**:全程持续低存在感的家电声(冰箱嗡嗡、暖气滴答、墙钟),让"孤独"有质感。
|
||||||
|
- **窗外烟花**:从 22:00 开始零星响起,23:30 达到高潮,00:00 戛然而止。这是平安夜的非言语叙事。
|
||||||
|
- **配音演员需要极强的"年龄感与关系感"**——大女儿与二女儿的语气差异、推销员的虚假温暖、老战友的醉态、陌生女人的克制礼貌——都要靠声音区分清楚。
|
||||||
|
|
||||||
|
## 结局分支
|
||||||
|
|
||||||
|
- **A. 全部接通**:体验最完整,但某些电话其实"接了反而尴尬"(推销诈骗、走错号)。结尾你接通自己的电话,对自己说了什么,决定了最后的字幕。
|
||||||
|
- **B. 选择性接听**:根据玩家漏接的人,结局略有不同——漏接孙子,孙子第二天再不会打过来;漏接大女儿,她以为你睡了,其实是松了一口气。
|
||||||
|
- **C. 全部不接**:5 小时全程坐在沙发上听铃声响起、消失。结尾屏幕亮起一行字——"老周睡着了"。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- 节日限定推出(每年圣诞 / 春节)。
|
||||||
|
- 适合做公益联名(独居老人主题)。
|
||||||
|
- 真实时间机制可拓展:"只有平安夜当天解锁完整结局"。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 题材偏沉重,需要在前 5 分钟用一通"轻松电话"(小卖部老板)建立"这游戏不是要让你哭"的预期。
|
||||||
|
- 50 分钟无明确"目标"对部分玩家是挑战,需要中段(21:30 二女儿那通)有强情感钩子。
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 09 · 第七幕:观众即角色
|
||||||
|
|
||||||
|
> **类型**:实验性 / 叙事 / 短篇
|
||||||
|
> **时长**:约 30 分钟
|
||||||
|
> **核心隐喻**:你以为你在听一部话剧。第七幕开始,演员转过身,发现你也在台上。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
你买了一张"音频话剧"的票,戴上耳机准备听一场实验剧场的演出。前六幕你是观众——剧情精彩、配音精湛。第七幕灯光全暗(你的屏幕一直是黑的),台上的女主角忽然开口问:"那个戴耳机的,你能听见吗?"她在问你。整部剧重新开始——但这一次,你是其中一个角色。
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
- **前六幕:纯听**——玩家完全不操作。屏幕全黑,耳机里是一部 12 分钟的音频话剧,剧情完整。**唯一的"互动"是中场休息时的 30 秒"鼓掌时间"**——玩家可以选择真的鼓掌(麦克风采集)、保持沉默、或者拍打屏幕。这影响后续剧情。
|
||||||
|
- **第七幕:你是角色**——剧本"重启",但每个角色多了一个"无声的人"。其他角色会向你这个无声角色对话。
|
||||||
|
- **回应方式**:单点 = "嗯"、长按 = "停顿"、左滑 = "摇头"、右滑 = "点头"、双指上滑 = 站起来、双指下滑 = 坐下、说话(麦克风采集 + 语音识别情绪)= 进入剧情关键变量。
|
||||||
|
- 你**没有台词**——你只能用动作、沉默、声音情绪回应别人的台词。
|
||||||
|
- **元剧场**:第七幕的剧本会**直接引用前六幕的内容**——比如"你还记得第三幕那个孩子吗?就是你"。
|
||||||
|
|
||||||
|
## 剧情大纲
|
||||||
|
|
||||||
|
### 前六幕(30 分钟剧场版的浓缩版本)
|
||||||
|
|
||||||
|
一部叫《最后的灯》的话剧——讲一个剧团在他们最后一场演出的当天发生的事。六幕分别是:
|
||||||
|
1. 团长清晨打开剧场大门
|
||||||
|
2. 演员们陆续到场,争吵谁演主角
|
||||||
|
3. 一位老演员讲起 30 年前的故事
|
||||||
|
4. 一个孩子误闯进剧场
|
||||||
|
5. 演出开始,第一场观众全场只有 7 个人
|
||||||
|
6. 演到一半,剧场停电
|
||||||
|
|
||||||
|
### 第七幕(玩家进入)
|
||||||
|
|
||||||
|
灯光重新亮起(剧情中),但**舞台上多了一个人**——就是你。
|
||||||
|
- 老演员把你当作 30 年前那个失踪的同事。
|
||||||
|
- 团长把你当作"今晚意外来的赞助人"。
|
||||||
|
- 那个孩子(第四幕的孩子)一眼认出你——"我等你很久了"。
|
||||||
|
- 你的每一个回应(点头、沉默、说话),让其他角色的剧情走向不同。
|
||||||
|
- **核心冲突**:所有人都在等你做一个选择——救这个剧团(让它演下去),还是承认它早就该结束了。
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **沉默 = 表演**:如果你 30 秒一动不动,剧本会自然地推进——其他角色会自言自语、解释、推动剧情。这让"不会玩"的玩家也能完整体验。
|
||||||
|
- **真实鼓掌**:中场鼓掌的力度、时长,会被采集为"今晚观众反响"的数据,影响第七幕开场时演员的状态(虚弱 / 振奋 / 麻木)。
|
||||||
|
- **第四面墙的攻防**:当玩家在第七幕"质疑这是游戏"(说"你们都是程序写的"),剧本会有一段非常精彩的独白回应——是这部作品最核心的元剧场亮点。
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- **真实剧场的混响**:所有声音用大空间混响处理——你"在剧场里"。
|
||||||
|
- 演员声音由真人配音演员录制(话剧腔,与影视腔有显著区别)。
|
||||||
|
- 第七幕开始,**你自己的呼吸声会被混入剧场环境声**——通过手机麦克风采集,让你"听见自己在台上"。
|
||||||
|
- 谢幕时全场鼓掌(如果你的表演触发了"好的结局"),鼓掌中能隐约听到一两个人在喊你的角色名。
|
||||||
|
|
||||||
|
## 结局分支
|
||||||
|
|
||||||
|
- **A. 救下剧团**:第七幕你做出"我会再来看"的承诺。谢幕时全场鼓掌。剧团决定再演一年。
|
||||||
|
- **B. 让它结束**:你说"该让它走了"。剧团关门。最后一段是空荡剧场里只剩老演员一人收拾道具的环境声。
|
||||||
|
- **C. 全程沉默**:剧情靠其他角色推进。结局介于 A 与 B 之间——剧团没倒,但失去了它的灵魂。
|
||||||
|
- **D. 始终质疑这不是真的**:解锁元结局——"导演"出场,对你说一段话。屏幕第一次亮起一行字幕:"感谢您今晚的观演"。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- 与真实话剧团合作录制 / 联名(话剧 IP 跨界)。
|
||||||
|
- 适合做"线下话剧的衍生数字版"。
|
||||||
|
- 单价 30–50 元的精品。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 前六幕对玩家是"纯被动听 12 分钟"的考验,必须用极强的剧本写作和声音表演留住玩家——否则玩家撑不到第七幕。
|
||||||
|
- "无台词角色"的代入感需要精心设计,部分玩家可能感到"我做什么都没用"。
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 10 · 死者通讯录
|
||||||
|
|
||||||
|
> **类型**:悬疑 / 推理 / 中篇
|
||||||
|
> **时长**:约 70 分钟
|
||||||
|
> **核心隐喻**:你捡到一部老式翻盖手机。通讯录里 12 个人,全都已经死了。但这部手机告诉你——有一通电话,他们都打不出去了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概述
|
||||||
|
|
||||||
|
你捡到一部断代的老式手机。屏幕全黑——这是一部摔坏的二手机,但通话功能还在。你拨通通讯录里 12 个号码,每一通都"接通"了——但接电话的人,都已经死了。他们活在自己生命的最后一天里,向你描述当天发生的事。你的任务:找出他们之间的联系,找出真凶。
|
||||||
|
|
||||||
|
## 核心玩法
|
||||||
|
|
||||||
|
- **盲拨号 + 盲查通讯录**:
|
||||||
|
- 长按屏幕中央 → 进入通讯录,耳机依次念出 12 个名字("按下一次选他")。
|
||||||
|
- **或者**:你已经记下号码后,可以**盲拨数字键盘**直接输入号码——拨"对的号码"会接通已死者的剧情,拨"错的号码"会接通**意想不到的活人**(这条隐藏分支提供调查线索)。
|
||||||
|
- **通话 = 收集线索**:每个死者会用 4–8 分钟描述自己最后那天的经历。**他们不知道自己已经死了**——所以他们会自然地说话、抱怨、计划明天。玩家需要从对话中识别:地点、时间、出现的其他人。
|
||||||
|
- **线索板(盲操作)**:
|
||||||
|
- 双指捏合 → 进入"调查模式"。屏幕上会用语音念出你已收集的所有线索。
|
||||||
|
- 你可以用"画连线"的手势(两指从一个线索滑到另一个线索)做关联——耳机会反馈"两条线索连接 / 不连接"。
|
||||||
|
- 这个交互**必须靠语音引导而非视觉**,所以语音 UX 是设计核心。
|
||||||
|
|
||||||
|
## 12 个死者(关键名单)
|
||||||
|
|
||||||
|
| 编号 | 身份 | 死亡日期 | 死因表面 | 真相方向 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 01 | 公交司机 | 1998.7.12 | 心梗 | 是被吓死的 |
|
||||||
|
| 02 | 小学女教师 | 1998.7.13 | 自杀 | 是被栽赃 |
|
||||||
|
| 03 | 报社记者 | 1998.7.14 | 车祸 | 在调查 01 的死 |
|
||||||
|
| 04 | 古玩店老板 | 1998.7.15 | 入室抢劫致死 | 知道得太多 |
|
||||||
|
| 05 | 小卖部阿姨 | 1998.7.16 | 摔下楼梯 | 看到了 06 |
|
||||||
|
| 06 | 物业主任 | 1998.7.17 | 食物中毒 | 是中介 |
|
||||||
|
| 07 | 医生 | 1998.7.18 | 自然死亡 | 给凶手开过药 |
|
||||||
|
| 08 | 出租车司机 | 1998.7.19 | 失踪 | 拉过凶手 |
|
||||||
|
| 09 | 派出所所长 | 1998.7.20 | 离奥退休后病逝 | 当年压下了案子 |
|
||||||
|
| 10 | 修锁匠 | 1998.7.21 | 老死 | 给凶手配过钥匙 |
|
||||||
|
| 11 | 一个未成年女孩 | 1998.7.11 | 被害 | 案件原点 |
|
||||||
|
| 12 | 凶手本人 | 2024.10.5 | 病死 | 临终时把这部手机寄了出去 |
|
||||||
|
|
||||||
|
## 玩法节奏
|
||||||
|
|
||||||
|
- **第 1–4 通**:你听他们讲日常。线索零散、看似无关。
|
||||||
|
- **第 5–8 通**:你开始注意到时间是连续的、地点重叠、有几个名字反复出现。
|
||||||
|
- **第 9–11 通**:你知道有一个孩子死了。所有人的最后一天都和那一周有关。
|
||||||
|
- **第 12 通**:通讯录里最后一个号码。接通后,是凶手在说话。他不知道你是谁,他以为你是当年的某位故人。你必须用所有收集到的线索,**通过对话引导他承认**。
|
||||||
|
- **隐藏的"第 13 通"**:如果你拨打 110,会接通到 1998 年 7 月那位本应去现场的警察。如果你这通电话打得对,**改变了过去**——结局就完全不同。
|
||||||
|
|
||||||
|
## 交互亮点
|
||||||
|
|
||||||
|
- **死者视角的"今天"**:每个角色都活在自己的"最后一天"——所以他们的电话有一种诡异的日常感。这是叙事最大的钩子。
|
||||||
|
- **盲拨数字 = 玩家的能动性**:当玩家学会"原来我可以拨任何号码"时,体验会从"被推着走"变成"主动调查"。这是游戏的转折点。
|
||||||
|
- **凶手对话的实时分析**:第 12 通通话中,玩家选择的每一句回应都被分析——是引导承认、是激怒对方、还是被对方反向操控?
|
||||||
|
|
||||||
|
## 音频设计
|
||||||
|
|
||||||
|
- **每个死者的口音、语速、年代背景都不同**——通讯录跨越 1998–2024。
|
||||||
|
- **手机听筒效果**贯穿全程,但每个时代的电话音质略有差异(90 年代窄频段+底噪,现代清晰干净)。
|
||||||
|
- **第 12 通是唯一的"清晰高质量"录音**——因为他是用现代手机打的。这种音质差是叙事符号。
|
||||||
|
- 关键悬疑节点用极克制的低频弦乐铺底,绝不堆砌"恐怖音效"。
|
||||||
|
|
||||||
|
## 结局分支
|
||||||
|
|
||||||
|
- **A. 找出凶手并让他在通话中承认**:录音保存。屏幕第一次亮起一行字:"您可以将此录音转交给当地警局"。
|
||||||
|
- **B. 中途放弃 / 没问对问题**:第 12 通最后凶手挂断电话。游戏结束,但留下"再玩一次"的钩子。
|
||||||
|
- **C. 拨打第 13 通(1998 年的 110)**:解锁"过去被改变"的结局——所有死者都没死。但你也再也找不到这部手机了。
|
||||||
|
|
||||||
|
## 商业化 / 包装思路
|
||||||
|
|
||||||
|
- 高叙事、高制作的精品独立游戏,对标《Her Story》这类"调查类"作品。
|
||||||
|
- 70 分钟单局体验,可做"剧情扩展包"持续运营(新案件 = 新通讯录)。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 12 通电话的信息密度极高,对玩家的记忆负担大——必须设计**强力的语音线索板**支撑回忆。
|
||||||
|
- 部分玩家可能不会"主动盲拨号码",需要在第 5 通后由"接线员错误"引导玩家发现这个机制。
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<title>林夏</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{
|
||||||
|
width:100%;height:100%;background:#000;
|
||||||
|
overflow:hidden;touch-action:none;
|
||||||
|
-webkit-tap-highlight-color:transparent;
|
||||||
|
user-select:none;-webkit-user-select:none;
|
||||||
|
font-family:-apple-system,'PingFang SC','Hiragino Sans GB',sans-serif;
|
||||||
|
}
|
||||||
|
#game{position:fixed;inset:0;background:#000;}
|
||||||
|
|
||||||
|
/* ── 开始界面 ── */
|
||||||
|
#start{
|
||||||
|
position:fixed;inset:0;background:#000;z-index:80;
|
||||||
|
display:flex;flex-direction:column;align-items:center;
|
||||||
|
color:#fff;text-align:center;padding:40px 32px 48px;
|
||||||
|
overflow-y:auto;-webkit-overflow-scrolling:touch;
|
||||||
|
}
|
||||||
|
#start h1{font-size:38px;font-weight:200;letter-spacing:14px;margin-top:48px;margin-bottom:28px}
|
||||||
|
#start .sub{font-size:14px;color:#777;line-height:2.4}
|
||||||
|
#start .hint{margin-top:40px;font-size:12px;color:#444;animation:pulse 2.5s ease-in-out infinite}
|
||||||
|
#start .tip{margin-top:16px;font-size:11px;color:#2a2a2a;line-height:1.9}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.2}}
|
||||||
|
|
||||||
|
/* ── 操作说明 ── */
|
||||||
|
#controls{
|
||||||
|
margin-top:44px;width:100%;max-width:320px;text-align:left;
|
||||||
|
}
|
||||||
|
#controls .ctrl-title{
|
||||||
|
font-size:10px;letter-spacing:4px;color:#333;text-align:center;margin-bottom:20px;
|
||||||
|
}
|
||||||
|
#controls .ctrl-group{
|
||||||
|
margin-bottom:20px;
|
||||||
|
}
|
||||||
|
#controls .ctrl-scene{
|
||||||
|
font-size:10px;letter-spacing:2px;color:#3a3a3a;margin-bottom:8px;padding-bottom:6px;
|
||||||
|
border-bottom:1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
#controls .ctrl-row{
|
||||||
|
display:flex;justify-content:space-between;align-items:baseline;
|
||||||
|
padding:5px 0;border-bottom:1px solid #111;
|
||||||
|
}
|
||||||
|
#controls .ctrl-row:last-child{border-bottom:none;}
|
||||||
|
#controls .ctrl-gesture{font-size:11px;color:#555;white-space:nowrap;flex-shrink:0;}
|
||||||
|
#controls .ctrl-action{font-size:11px;color:#2e2e2e;text-align:right;margin-left:12px;}
|
||||||
|
|
||||||
|
/* ── 字幕层(结局文字)── */
|
||||||
|
#caption{
|
||||||
|
position:fixed;inset:0;z-index:60;
|
||||||
|
display:none;align-items:center;justify-content:center;
|
||||||
|
color:#fff;font-size:18px;font-weight:300;letter-spacing:4px;
|
||||||
|
text-align:center;line-height:2;padding:40px;
|
||||||
|
animation:fadein .8s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes fadein{from{opacity:0}to{opacity:1}}
|
||||||
|
|
||||||
|
/* ── 调试条(开发用) ── */
|
||||||
|
#dbg{
|
||||||
|
display:none;
|
||||||
|
position:fixed;bottom:30px;left:0;right:0;text-align:center;
|
||||||
|
font-size:11px;color:#333;z-index:200;pointer-events:none;font-family:monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game"></div>
|
||||||
|
|
||||||
|
<div id="start">
|
||||||
|
<h1>林夏</h1>
|
||||||
|
<div class="sub">
|
||||||
|
今晚 16 条消息<br>
|
||||||
|
你接谁,不接谁<br>
|
||||||
|
决定明天的自己
|
||||||
|
</div>
|
||||||
|
<div id="controls">
|
||||||
|
<div class="ctrl-title">操 作 说 明</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">通用(随时可用)</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">下滑</span><span class="ctrl-action">查看当前状态</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">上滑</span><span class="ctrl-action">打开收件箱</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">来电时</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">单点</span><span class="ctrl-action">接听</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">左滑</span><span class="ctrl-action">拒接</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">收到消息</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">单点</span><span class="ctrl-action">播放</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">左滑</span><span class="ctrl-action">忽略</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">听完消息</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">单点</span><span class="ctrl-action">回复"嗯"</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">双点</span><span class="ctrl-action">标准回复</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">长按</span><span class="ctrl-action">更多回复选项</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">左滑</span><span class="ctrl-action">已读不回</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">选择回复时</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">上下左右滑</span><span class="ctrl-action">选择选项</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">单点</span><span class="ctrl-action">重听选项</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint">· 轻触屏幕开始 ·</div>
|
||||||
|
<div class="tip">
|
||||||
|
建议关灯 · 戴上耳机<br>
|
||||||
|
全程约 60 分钟
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="caption"></div>
|
||||||
|
<div id="dbg"></div>
|
||||||
|
|
||||||
|
<script src="js/utils.js"></script>
|
||||||
|
<script src="js/haptics.js"></script>
|
||||||
|
<script src="js/audio.js"></script>
|
||||||
|
<script src="js/gestures.js"></script>
|
||||||
|
<script src="js/profile.js"></script>
|
||||||
|
<script src="js/story.js"></script>
|
||||||
|
<script src="js/engine.js"></script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// audio.js — 音频管理器(HTML Audio + Web Audio API 混用)
|
||||||
|
|
||||||
|
class AudioManager {
|
||||||
|
constructor() {
|
||||||
|
this._ctx = null;
|
||||||
|
this._master = null;
|
||||||
|
this._bgmNode = null;
|
||||||
|
this._ambNode = null;
|
||||||
|
this._voiceQueue = [];
|
||||||
|
this._voiceEl = null;
|
||||||
|
this._ringEl = null;
|
||||||
|
this._ringTimer = null;
|
||||||
|
this._preloaded = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 初始化(必须在用户手势后调用)──
|
||||||
|
async init() {
|
||||||
|
this._ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (this._ctx.state === 'suspended') await this._ctx.resume();
|
||||||
|
this._master = this._ctx.createGain();
|
||||||
|
this._master.gain.value = 1.0;
|
||||||
|
this._master.connect(this._ctx.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 预加载短音效(铃声、UI音)──
|
||||||
|
async preload(key, url) {
|
||||||
|
if (this._preloaded[key]) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
this._preloaded[key] = await this._ctx.decodeAudioData(buf);
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('[Audio] preload failed:', key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 播放预加载的短音效 ──
|
||||||
|
playFX(key, { vol = 1.0, loop = false } = {}) {
|
||||||
|
const buf = this._preloaded[key];
|
||||||
|
if (!buf) return null;
|
||||||
|
const src = this._ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
src.loop = loop;
|
||||||
|
const gain = this._ctx.createGain();
|
||||||
|
gain.gain.value = vol;
|
||||||
|
src.connect(gain);
|
||||||
|
gain.connect(this._master);
|
||||||
|
src.start();
|
||||||
|
return { src, gain, stop: () => { try { src.stop(); } catch(_){} } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 铃声(循环直到 stopRingtone)──
|
||||||
|
async startRingtone(url) {
|
||||||
|
this.stopRingtone();
|
||||||
|
// 使用 HTMLAudio 以支持更大文件
|
||||||
|
this._ringEl = new Audio(url);
|
||||||
|
this._ringEl.loop = true;
|
||||||
|
this._ringEl.volume = 1.0;
|
||||||
|
try { await this._ringEl.play(); } catch(e) { console.warn('[Audio] ring play error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
stopRingtone() {
|
||||||
|
if (this._ringEl) {
|
||||||
|
this._ringEl.pause();
|
||||||
|
this._ringEl.src = '';
|
||||||
|
this._ringEl = null;
|
||||||
|
}
|
||||||
|
if (this._ringTimer) { clearTimeout(this._ringTimer); this._ringTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 语音消息播放(支持多段顺序播放)──
|
||||||
|
playVoice(urls, { vol = 1.0, onEnd = null, onSegEnd = null } = {}) {
|
||||||
|
this.stopVoice();
|
||||||
|
this._voiceQueue = [...urls];
|
||||||
|
this._playNextSegment(vol, onEnd, onSegEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
_playNextSegment(vol, onEnd, onSegEnd) {
|
||||||
|
if (this._voiceQueue.length === 0) {
|
||||||
|
this._voiceEl = null;
|
||||||
|
if (onEnd) onEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = this._voiceQueue.shift();
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.volume = vol;
|
||||||
|
this._voiceEl = el;
|
||||||
|
el.onended = () => {
|
||||||
|
if (onSegEnd) onSegEnd();
|
||||||
|
this._playNextSegment(vol, onEnd, onSegEnd);
|
||||||
|
};
|
||||||
|
el.onerror = () => {
|
||||||
|
console.warn('[Audio] voice error:', url);
|
||||||
|
this._playNextSegment(vol, onEnd, onSegEnd);
|
||||||
|
};
|
||||||
|
el.play().catch(e => console.warn('[Audio] voice play error', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
stopVoice() {
|
||||||
|
this._voiceQueue = [];
|
||||||
|
if (this._voiceEl) {
|
||||||
|
this._voiceEl.pause();
|
||||||
|
this._voiceEl.onended = null;
|
||||||
|
this._voiceEl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isVoicePlaying() {
|
||||||
|
return !!(this._voiceEl && !this._voiceEl.paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BGM(淡入淡出切换)──
|
||||||
|
async startBGM(url, { vol = 0.35, fade = 2000 } = {}) {
|
||||||
|
// 先淡出现有
|
||||||
|
if (this._bgmEl) {
|
||||||
|
const old = this._bgmEl;
|
||||||
|
this._fadeOut(old, fade / 2).then(() => { old.pause(); old.src = ''; });
|
||||||
|
}
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.loop = true;
|
||||||
|
el.volume = 0;
|
||||||
|
this._bgmEl = el;
|
||||||
|
try {
|
||||||
|
await el.play();
|
||||||
|
this._fadeTo(el, vol, fade);
|
||||||
|
} catch(e) { console.warn('[Audio] BGM error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
stopBGM(fade = 2000) {
|
||||||
|
if (this._bgmEl) {
|
||||||
|
const el = this._bgmEl;
|
||||||
|
this._bgmEl = null;
|
||||||
|
this._fadeOut(el, fade).then(() => { el.pause(); el.src = ''; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 环境音(底噪循环)──
|
||||||
|
async startAmbience(url, { vol = 0.25 } = {}) {
|
||||||
|
if (this._ambEl) { this._ambEl.pause(); this._ambEl.src = ''; }
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.loop = true;
|
||||||
|
el.volume = vol;
|
||||||
|
this._ambEl = el;
|
||||||
|
try { await el.play(); } catch(e) { console.warn('[Audio] ambience error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAmbience() {
|
||||||
|
if (this._ambEl) {
|
||||||
|
this._ambEl.pause(); this._ambEl.src = '';
|
||||||
|
this._ambEl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 音量渐变 ──
|
||||||
|
_fadeTo(el, target, duration) {
|
||||||
|
const start = el.volume;
|
||||||
|
const steps = 30;
|
||||||
|
const dt = duration / steps;
|
||||||
|
const dv = (target - start) / steps;
|
||||||
|
let i = 0;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
el.volume = Math.max(0, Math.min(1, start + dv * i));
|
||||||
|
i++;
|
||||||
|
if (i >= steps) clearInterval(t);
|
||||||
|
}, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
_fadeOut(el, duration) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const start = el.volume;
|
||||||
|
const steps = 20;
|
||||||
|
const dt = duration / steps;
|
||||||
|
const dv = start / steps;
|
||||||
|
let i = 0;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
el.volume = Math.max(0, start - dv * i);
|
||||||
|
i++;
|
||||||
|
if (i >= steps) { clearInterval(t); resolve(); }
|
||||||
|
}, dt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 键盘声(空间音频:左耳)──
|
||||||
|
startTyping() {
|
||||||
|
if (this._typingNode) return;
|
||||||
|
const buf = this._preloaded['sfx_keyboard_slow'];
|
||||||
|
if (!buf) return;
|
||||||
|
const src = this._ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
src.loop = true;
|
||||||
|
const gain = this._ctx.createGain();
|
||||||
|
gain.gain.value = 0.15;
|
||||||
|
// 偏左声道
|
||||||
|
const pan = this._ctx.createStereoPanner();
|
||||||
|
pan.pan.value = -0.8;
|
||||||
|
src.connect(gain);
|
||||||
|
gain.connect(pan);
|
||||||
|
pan.connect(this._master);
|
||||||
|
src.start();
|
||||||
|
this._typingNode = { src, gain, pan };
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTyping() {
|
||||||
|
if (this._typingNode) {
|
||||||
|
try { this._typingNode.src.stop(); } catch(_) {}
|
||||||
|
this._typingNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Snd = new AudioManager();
|
||||||
@@ -0,0 +1,774 @@
|
|||||||
|
// engine.js — 游戏状态机
|
||||||
|
|
||||||
|
class GameEngine {
|
||||||
|
constructor() {
|
||||||
|
// 游戏状态
|
||||||
|
this.state = 'idle'; // idle→playing→ringing→in_call→msg_incoming→msg_playing→await_reply→detail_reply→inbox→ending
|
||||||
|
|
||||||
|
// 剧情变量
|
||||||
|
this.vars = {
|
||||||
|
MOM_LINK: false,
|
||||||
|
HE_BACK: null, // null=未定, true=来了, false=拒了
|
||||||
|
GROUP_REPLY: false,
|
||||||
|
ZHOUNAN_DEPTH: 0, // 0-3
|
||||||
|
ZHOUNAN_SHARE: false,
|
||||||
|
SELF_RECORD: false,
|
||||||
|
UNREAD: 0, // 未读消息计数
|
||||||
|
};
|
||||||
|
|
||||||
|
this.profile = new ProfileTracker();
|
||||||
|
|
||||||
|
// 消息状态
|
||||||
|
this.msgState = {}; // msgId → 'unread'|'read'|'replied'|'missed'
|
||||||
|
this.replayCount = {};// msgId → 重听次数
|
||||||
|
|
||||||
|
// 当前处理的消息
|
||||||
|
this.currentMsg = null;
|
||||||
|
this.currentChoices = null; // 细回复选项列表
|
||||||
|
this.awaitingChoice = false;
|
||||||
|
|
||||||
|
// 定时器
|
||||||
|
this._timers = [];
|
||||||
|
this._ringTimer = null;
|
||||||
|
this._startTime = null;
|
||||||
|
|
||||||
|
// 手势引用
|
||||||
|
this.gest = null;
|
||||||
|
|
||||||
|
// 特殊事件追踪
|
||||||
|
this._azheFollowUpDone = false;
|
||||||
|
this._xiaomeiRecallDone = false;
|
||||||
|
this._batteryWarned = false;
|
||||||
|
this._doorbell = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 初始化
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
init(gestureDetector) {
|
||||||
|
this.gest = gestureDetector;
|
||||||
|
this._bindGestures();
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindGestures() {
|
||||||
|
const G = this.gest;
|
||||||
|
G.on('singletap', () => this._onSingleTap());
|
||||||
|
G.on('doubletap', () => this._onDoubleTap());
|
||||||
|
G.on('longpress1s', () => this._onLongPress1s());
|
||||||
|
G.on('longpress5s', () => this._onLongPress5s());
|
||||||
|
G.on('swipe_left', () => this._onSwipe('left'));
|
||||||
|
G.on('swipe_right', () => this._onSwipe('right'));
|
||||||
|
G.on('swipe_up', () => this._onSwipe('up'));
|
||||||
|
G.on('swipe_down', () => this._onSwipe('down'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 游戏开始
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
async start() {
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this.state = 'playing';
|
||||||
|
|
||||||
|
// 启动环境底噪
|
||||||
|
await Snd.startAmbience(AMBIENCE_DEFAULT, { vol: 0.2 });
|
||||||
|
|
||||||
|
// 调度所有消息
|
||||||
|
for (const msg of MESSAGES) {
|
||||||
|
this._schedule(msg.triggerAt * 1000, () => this._triggerMessage(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调度特殊事件
|
||||||
|
this._scheduleSpecialEvents();
|
||||||
|
|
||||||
|
// 02:05 兜底:确保结局被触发
|
||||||
|
const endingDelay = (gt(2, 5)) * 1000;
|
||||||
|
this._schedule(endingDelay, () => {
|
||||||
|
if (!this._endingTriggered) this.triggerFinalEnding();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 夜间启动彩蛋
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour >= 22 || hour < 4) {
|
||||||
|
await sleep(2000);
|
||||||
|
await TTS.speak('你也还醒着啊。', { rate: 0.8, volume: 0.6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始主BGM
|
||||||
|
await sleep(3000);
|
||||||
|
Snd.startBGM(`${MUS}/lv11_bgm_m1_main_loop_v1.wav`, { vol: 0.3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleSpecialEvents() {
|
||||||
|
// 阿哲撤回(消息4未回复时,10游戏分钟后)
|
||||||
|
this._schedule(gt(21, 40) * 1000, () => {
|
||||||
|
if (!this._azheFollowUpDone && this.msgState[4] !== 'replied') {
|
||||||
|
this._azheFollowUpDone = true;
|
||||||
|
Snd.startTyping();
|
||||||
|
sleep(8000).then(() => {
|
||||||
|
Snd.stopTyping();
|
||||||
|
TTS.speak('阿哲撤回了一条消息。', { rate: 0.85, volume: 0.7 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// HE_BACK 为 true 时,门铃音效(23:30左右)
|
||||||
|
this._schedule(gt(23, 28) * 1000, () => {
|
||||||
|
if (this.vars.HE_BACK === true && !this._doorbell) {
|
||||||
|
this._doorbell = true;
|
||||||
|
Snd.playFX ? null : null;
|
||||||
|
Snd.playVoice([`${SFX}/sfx_door_knock_gentle_v1.wav`]);
|
||||||
|
Haptic.ring();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 电量1%警告(结局D触发条件之一)
|
||||||
|
this._schedule(gt(1, 0) * 1000, () => {
|
||||||
|
if (!this._batteryWarned) {
|
||||||
|
this._batteryWarned = true;
|
||||||
|
const unread = Object.values(this.msgState).filter(s => s === 'unread').length
|
||||||
|
+ (Object.keys(this.msgState).length === 0 ? MESSAGES.length : 0);
|
||||||
|
if (this.vars.UNREAD >= 8) {
|
||||||
|
Snd.playVoice([`${TDIR}/lv11_sys_battery_01_wav_v1.wav`]);
|
||||||
|
Haptic.system();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_schedule(ms, fn) {
|
||||||
|
const t = setTimeout(fn, ms);
|
||||||
|
this._timers.push(t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 消息触发
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
async _triggerMessage(msg) {
|
||||||
|
if (this.state === 'ringing' || this.state === 'in_call') {
|
||||||
|
// 已在通话中,延迟处理(放入队列)
|
||||||
|
this._schedule(5000, () => this._triggerMessage(msg));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息16(妈妈长语音):MOM_LINK=false时跳过,直接触发结局
|
||||||
|
if (msg.requiresMomLink && !this.vars.MOM_LINK) {
|
||||||
|
this.triggerFinalEnding();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录为未读
|
||||||
|
this.msgState[msg.id] = 'unread';
|
||||||
|
this.vars.UNREAD++;
|
||||||
|
this.currentMsg = msg;
|
||||||
|
|
||||||
|
if (msg.type === 'call') {
|
||||||
|
await this._startRinging(msg);
|
||||||
|
} else if (msg.type === 'wechat_voice' || msg.type === 'wechat_text') {
|
||||||
|
await this._notifyMessage(msg);
|
||||||
|
} else if (msg.type === 'system_notification') {
|
||||||
|
await this._notifySystem(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 来电流程 ──
|
||||||
|
async _startRinging(msg) {
|
||||||
|
this.state = 'ringing';
|
||||||
|
this.currentMsg = msg;
|
||||||
|
|
||||||
|
Haptic.ring();
|
||||||
|
await Snd.startRingtone(msg.ringtone);
|
||||||
|
|
||||||
|
// 超时自动漏接
|
||||||
|
this._ringTimer = setTimeout(() => {
|
||||||
|
this._missCall(msg);
|
||||||
|
}, msg.ringDuration * 1000);
|
||||||
|
|
||||||
|
await TTS.speak(`${msg.senderName},来电话了。`, { rate: 0.9, volume: 0.7 });
|
||||||
|
}
|
||||||
|
|
||||||
|
_missCall(msg) {
|
||||||
|
Snd.stopRingtone();
|
||||||
|
Haptic.stop();
|
||||||
|
this.state = 'playing';
|
||||||
|
this.msgState[msg.id] = 'missed';
|
||||||
|
this.profile.add(msg.profileOnMiss);
|
||||||
|
if (msg.id === 1 || msg.id === 14) {
|
||||||
|
// 妈妈漏接不影响 MOM_LINK(需主动接才算)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_answerCall(msg) {
|
||||||
|
clearTimeout(this._ringTimer);
|
||||||
|
Snd.stopRingtone();
|
||||||
|
Haptic.confirm();
|
||||||
|
this.state = 'in_call';
|
||||||
|
this.msgState[msg.id] = 'read';
|
||||||
|
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
||||||
|
this.profile.add(msg.profileOnAnswer);
|
||||||
|
|
||||||
|
if (msg.stateOnAnswer) this._applyState(msg.stateOnAnswer);
|
||||||
|
|
||||||
|
// 播放通话内容
|
||||||
|
Snd.playVoice(msg.audio, {
|
||||||
|
onEnd: () => {
|
||||||
|
if (msg.inCallChoices) {
|
||||||
|
this._startInCallChoice(msg);
|
||||||
|
} else if (msg.autoHangup) {
|
||||||
|
setTimeout(() => this._endCall(msg), msg.autoHangup * 1000);
|
||||||
|
} else {
|
||||||
|
this._endCall(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startInCallChoice(msg) {
|
||||||
|
this.state = 'detail_reply';
|
||||||
|
this.currentChoices = msg.inCallChoices;
|
||||||
|
this.awaitingChoice = true;
|
||||||
|
await this._readChoices(msg.inCallChoices);
|
||||||
|
}
|
||||||
|
|
||||||
|
_endCall(msg) {
|
||||||
|
this.state = 'playing';
|
||||||
|
this.currentMsg = null;
|
||||||
|
if (msg.sfxAfter) Snd.playVoice([msg.sfxAfter]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_rejectCall(msg) {
|
||||||
|
clearTimeout(this._ringTimer);
|
||||||
|
Snd.stopRingtone();
|
||||||
|
Haptic.reject();
|
||||||
|
this.state = 'playing';
|
||||||
|
this.msgState[msg.id] = 'missed';
|
||||||
|
this.profile.add(msg.profileOnMiss);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 微信消息流程 ──
|
||||||
|
async _notifyMessage(msg) {
|
||||||
|
Haptic.message();
|
||||||
|
const type = msg.type === 'wechat_text' ? '文字消息' : '语音';
|
||||||
|
await TTS.speak(`${msg.senderName},发来${type}。`, { rate: 0.9, volume: 0.7 });
|
||||||
|
this.state = 'msg_incoming';
|
||||||
|
// 等待玩家交互(单点播放 / 不动保持未读)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _playMessage(msg) {
|
||||||
|
this.state = 'msg_playing';
|
||||||
|
this.msgState[msg.id] = 'read';
|
||||||
|
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
||||||
|
|
||||||
|
// 开始BGM(如果消息指定)
|
||||||
|
if (msg.bgm) Snd.startBGM(msg.bgm, { vol: 0.25 });
|
||||||
|
|
||||||
|
if (msg.type === 'wechat_text') {
|
||||||
|
// 文字消息:TTS 朗读
|
||||||
|
await TTS.speak(msg.text, { rate: 0.85 });
|
||||||
|
this._afterMessagePlayed(msg);
|
||||||
|
} else {
|
||||||
|
// 语音消息:播放音频
|
||||||
|
Snd.playVoice(msg.audio, {
|
||||||
|
onEnd: () => {
|
||||||
|
// 如果是小美醉语音,5秒后撤回
|
||||||
|
if (msg.followUp?.action === 'xiaomei_recall') {
|
||||||
|
setTimeout(() => {
|
||||||
|
TTS.speak('小美撤回了刚才的语音。但你已经听过了。', { rate: 0.85, volume: 0.7 });
|
||||||
|
}, msg.followUp.delaySec * 1000);
|
||||||
|
}
|
||||||
|
this._afterMessagePlayed(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_afterMessagePlayed(msg) {
|
||||||
|
this.state = 'await_reply';
|
||||||
|
this.currentMsg = msg;
|
||||||
|
// 等待玩家选择回应方式
|
||||||
|
// 单点=嗯 / 双点=标准回复 / 长按=细回复 / 双指上滑=已读不回 / 不动=本已读
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 系统通知(时光胶囊)──
|
||||||
|
async _notifySystem(msg) {
|
||||||
|
Haptic.system();
|
||||||
|
Snd.playVoice([msg.systemAudio]);
|
||||||
|
await sleep(3000);
|
||||||
|
await TTS.speak(msg.text, { rate: 0.85, volume: 0.8 });
|
||||||
|
this.state = 'msg_incoming';
|
||||||
|
this.currentMsg = msg;
|
||||||
|
// 单点=播放, 双指上滑=跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
async _playSystemMessage(msg) {
|
||||||
|
const profileLabel = this.profile.label;
|
||||||
|
const audioUrl = msg.audioByProfile[profileLabel];
|
||||||
|
this.msgState[msg.id] = 'read';
|
||||||
|
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
||||||
|
this._applyState(msg.stateOnPlay || {});
|
||||||
|
this.profile.add(msg.profileOnPlay);
|
||||||
|
|
||||||
|
this.state = 'msg_playing';
|
||||||
|
Snd.playVoice([audioUrl], {
|
||||||
|
onEnd: () => {
|
||||||
|
this.state = 'playing';
|
||||||
|
this._checkEndingConditions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 细回复流程
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
async _enterDetailReply(msg) {
|
||||||
|
if (!msg.detailReplies) return;
|
||||||
|
this.state = 'detail_reply';
|
||||||
|
this.currentChoices = msg.detailReplies;
|
||||||
|
this.awaitingChoice = true;
|
||||||
|
Haptic.detailMode();
|
||||||
|
await this._readChoices(msg.detailReplies);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readChoices(choices) {
|
||||||
|
const nums = ['一', '二', '三', '四'];
|
||||||
|
for (let i = 0; i < choices.length; i++) {
|
||||||
|
if (!this.awaitingChoice) return;
|
||||||
|
await TTS.speak(`${nums[i]}……`, { rate: 0.75, volume: 0.65 });
|
||||||
|
await sleep(200);
|
||||||
|
await TTS.speak(choices[i].label, { rate: 0.85, volume: 0.85 });
|
||||||
|
await sleep(400);
|
||||||
|
}
|
||||||
|
// 读完后震动提示"可以选了"
|
||||||
|
if (this.awaitingChoice) Haptic.confirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectDetailChoice(dir) {
|
||||||
|
if (!this.awaitingChoice || !this.currentChoices) return;
|
||||||
|
const choice = this.currentChoices.find(c => c.dir === dir);
|
||||||
|
if (!choice) { Haptic.error(); return; }
|
||||||
|
|
||||||
|
this.awaitingChoice = false;
|
||||||
|
TTS.stop();
|
||||||
|
Haptic.select();
|
||||||
|
|
||||||
|
const msg = this.currentMsg;
|
||||||
|
|
||||||
|
if (choice.isSilence) {
|
||||||
|
// 玩家选择不说话
|
||||||
|
TTS.speak('(沉默)', { rate: 0.8, volume: 0.5 });
|
||||||
|
} else {
|
||||||
|
TTS.speak(`发送:${choice.label}`, { rate: 0.85, volume: 0.7 });
|
||||||
|
this.msgState[msg.id] = 'replied';
|
||||||
|
this.profile.onDetailReply();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice.state) this._applyState(choice.state);
|
||||||
|
if (choice.profile) this.profile.add(choice.profile);
|
||||||
|
|
||||||
|
// 周南相关深度追踪
|
||||||
|
if (choice.state?.ZHOUNAN_DEPTH_ADD) {
|
||||||
|
this.vars.ZHOUNAN_DEPTH += 1;
|
||||||
|
}
|
||||||
|
if (choice.state?.ZHOUNAN_SHARE) {
|
||||||
|
this.vars.ZHOUNAN_SHARE = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'playing';
|
||||||
|
this.currentChoices = null;
|
||||||
|
this._checkEndingConditions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 手势处理器
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
_onSingleTap() {
|
||||||
|
const s = this.state;
|
||||||
|
const msg = this.currentMsg;
|
||||||
|
|
||||||
|
if (s === 'ringing') {
|
||||||
|
this._answerCall(msg);
|
||||||
|
}
|
||||||
|
else if (s === 'msg_incoming') {
|
||||||
|
if (msg.type === 'system_notification') {
|
||||||
|
this._playSystemMessage(msg);
|
||||||
|
} else {
|
||||||
|
this._playMessage(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (s === 'await_reply') {
|
||||||
|
// 单点 = 发"嗯"
|
||||||
|
if (msg?.type !== 'call') {
|
||||||
|
TTS.speak('发送:嗯。', { rate: 0.9, volume: 0.7 });
|
||||||
|
this.msgState[msg.id] = 'replied';
|
||||||
|
this.profile.onSimpleReply();
|
||||||
|
if (msg.stateOnStdReply) this._applyState(msg.stateOnStdReply);
|
||||||
|
this.state = 'playing';
|
||||||
|
this._checkEndingConditions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (s === 'detail_reply' && this.awaitingChoice) {
|
||||||
|
// 在细回复中单点 = 重听选项
|
||||||
|
this._readChoices(this.currentChoices);
|
||||||
|
}
|
||||||
|
else if (s === 'inbox') {
|
||||||
|
this._inboxSelect();
|
||||||
|
}
|
||||||
|
else if (s === 'playing') {
|
||||||
|
const unread = this.vars.UNREAD;
|
||||||
|
if (unread > 0) {
|
||||||
|
TTS.speak(`${unread}条未读消息。上滑打开收件箱。`, { rate: 0.9, volume: 0.6 });
|
||||||
|
} else {
|
||||||
|
TTS.speak('暂无新消息。', { rate: 0.9, volume: 0.6 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onDoubleTap() {
|
||||||
|
const s = this.state;
|
||||||
|
const msg = this.currentMsg;
|
||||||
|
|
||||||
|
if (s === 'await_reply' && msg) {
|
||||||
|
// 双点 = 发标准回复
|
||||||
|
const reply = msg.standardReply || '嗯,好的。';
|
||||||
|
TTS.speak(`发送:${reply}`, { rate: 0.9, volume: 0.7 });
|
||||||
|
this.msgState[msg.id] = 'replied';
|
||||||
|
this.profile.onDetailReply();
|
||||||
|
if (msg.stateOnStdReply) this._applyState(msg.stateOnStdReply);
|
||||||
|
this.state = 'playing';
|
||||||
|
this._checkEndingConditions();
|
||||||
|
}
|
||||||
|
else if (s === 'msg_incoming') {
|
||||||
|
// 双点 = 直接回"嗯"不听
|
||||||
|
if (msg?.type === 'wechat_text') {
|
||||||
|
TTS.speak('发送:嗯。', { rate: 0.9, volume: 0.7 });
|
||||||
|
this.msgState[msg.id] = 'replied';
|
||||||
|
this.profile.onSimpleReply();
|
||||||
|
this.state = 'playing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (s === 'ringing') {
|
||||||
|
// 双点来电 = 同样接听
|
||||||
|
this._answerCall(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onLongPress1s() {
|
||||||
|
const s = this.state;
|
||||||
|
const msg = this.currentMsg;
|
||||||
|
|
||||||
|
if (s === 'await_reply' && msg?.detailReplies) {
|
||||||
|
this._enterDetailReply(msg);
|
||||||
|
}
|
||||||
|
else if (s === 'in_call' && msg?.inCallChoices) {
|
||||||
|
this._startInCallChoice(msg);
|
||||||
|
}
|
||||||
|
else if (s === 'detail_reply' && this.awaitingChoice) {
|
||||||
|
// 重听选项
|
||||||
|
TTS.stop();
|
||||||
|
this._readChoices(this.currentChoices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onLongPress5s() {
|
||||||
|
// 强制挂断
|
||||||
|
if (this.state === 'in_call' || this.state === 'ringing') {
|
||||||
|
clearTimeout(this._ringTimer);
|
||||||
|
Snd.stopRingtone();
|
||||||
|
Snd.stopVoice();
|
||||||
|
Haptic.reject();
|
||||||
|
TTS.speak('已挂断。', { rate: 0.9, volume: 0.7 });
|
||||||
|
this.state = 'playing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onSwipe(dir) {
|
||||||
|
const s = this.state;
|
||||||
|
const msg = this.currentMsg;
|
||||||
|
|
||||||
|
// 细回复 / 通话选项:四个方向用于选择
|
||||||
|
if ((s === 'detail_reply' || (s === 'in_call' && this.awaitingChoice)) && this.currentChoices) {
|
||||||
|
this._selectDetailChoice(dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下滑 = 查询当前状态(任意场景可用)
|
||||||
|
if (dir === 'down') {
|
||||||
|
this._announceStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上滑 = 打开收件箱(主屏幕时)
|
||||||
|
if (dir === 'up') {
|
||||||
|
if (s === 'playing') this._onInboxOpen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右滑 = 接听(来电时)
|
||||||
|
if (dir === 'right') {
|
||||||
|
if (s === 'ringing') this._answerCall(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左滑 = 拒接 / 挂断 / 忽略 / 已读不回 / 返回
|
||||||
|
if (dir === 'left') {
|
||||||
|
if (s === 'ringing') {
|
||||||
|
this._rejectCall(msg);
|
||||||
|
}
|
||||||
|
else if (s === 'in_call') {
|
||||||
|
Snd.stopVoice();
|
||||||
|
Haptic.reject();
|
||||||
|
TTS.speak('已挂断。', { rate: 0.9, volume: 0.7 });
|
||||||
|
this.state = 'playing';
|
||||||
|
this.currentMsg = null;
|
||||||
|
this.awaitingChoice = false;
|
||||||
|
}
|
||||||
|
else if (s === 'msg_incoming') {
|
||||||
|
if (msg?.type === 'system_notification') {
|
||||||
|
TTS.speak('已跳过。', { rate: 0.9, volume: 0.6 });
|
||||||
|
this.msgState[msg.id] = 'read';
|
||||||
|
this.profile.add(msg.profileOnSkip);
|
||||||
|
} else {
|
||||||
|
TTS.speak('(消息保持未读)', { rate: 0.85, volume: 0.5 });
|
||||||
|
}
|
||||||
|
this.state = 'playing';
|
||||||
|
this.currentMsg = null;
|
||||||
|
}
|
||||||
|
else if (s === 'await_reply' && msg) {
|
||||||
|
TTS.speak('(已读不回)', { rate: 0.85, volume: 0.6 });
|
||||||
|
this.msgState[msg.id] = 'read';
|
||||||
|
this.profile.onReadNoReply();
|
||||||
|
this.state = 'playing';
|
||||||
|
}
|
||||||
|
else if (s === 'inbox') {
|
||||||
|
this.state = 'playing';
|
||||||
|
this._inboxMessages = null;
|
||||||
|
TTS.speak('返回主屏幕。', { rate: 0.9, volume: 0.6 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 下滑:播报当前状态和可用操作 ──
|
||||||
|
_announceStatus() {
|
||||||
|
const s = this.state;
|
||||||
|
const msg = this.currentMsg;
|
||||||
|
|
||||||
|
if (s === 'playing') {
|
||||||
|
const unread = this.vars.UNREAD;
|
||||||
|
if (unread > 0) {
|
||||||
|
TTS.speak(`主屏幕,${unread}条未读。上滑查看收件箱。`, { rate: 0.9, volume: 0.7 });
|
||||||
|
} else {
|
||||||
|
TTS.speak('主屏幕,暂无新消息。', { rate: 0.9, volume: 0.7 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (s === 'ringing') {
|
||||||
|
TTS.speak(`${msg?.senderName}来电中。单点接听,左滑拒接。`, { rate: 0.9, volume: 0.7 });
|
||||||
|
}
|
||||||
|
else if (s === 'in_call') {
|
||||||
|
TTS.speak('通话中。左滑挂断。', { rate: 0.9, volume: 0.7 });
|
||||||
|
}
|
||||||
|
else if (s === 'msg_incoming') {
|
||||||
|
TTS.speak(`${msg?.senderName}的新消息。单点播放,左滑忽略。`, { rate: 0.9, volume: 0.7 });
|
||||||
|
}
|
||||||
|
else if (s === 'msg_playing') {
|
||||||
|
TTS.speak('消息播放中。', { rate: 0.9, volume: 0.7 });
|
||||||
|
}
|
||||||
|
else if (s === 'await_reply') {
|
||||||
|
TTS.speak(`${msg?.senderName}的消息已播完。单点回嗯,双点标准回复,长按细回复,左滑已读不回。`, { rate: 0.9, volume: 0.7 });
|
||||||
|
}
|
||||||
|
else if (s === 'detail_reply') {
|
||||||
|
TTS.speak('细回复选项中。滑动选择,单点重听。', { rate: 0.9, volume: 0.7 });
|
||||||
|
}
|
||||||
|
else if (s === 'inbox') {
|
||||||
|
TTS.speak('收件箱。单点进入最新消息,左滑返回。', { rate: 0.9, volume: 0.7 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _onInboxOpen() {
|
||||||
|
if (this.state === 'in_call') return;
|
||||||
|
|
||||||
|
const prevState = this.state;
|
||||||
|
this.state = 'inbox';
|
||||||
|
Haptic.system();
|
||||||
|
|
||||||
|
// 统计未读/未回
|
||||||
|
const unreadMsgs = MESSAGES.filter(m =>
|
||||||
|
!this.msgState[m.id] || this.msgState[m.id] === 'unread'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unreadMsgs.length === 0) {
|
||||||
|
await TTS.speak('收件箱已空。', { rate: 0.9, volume: 0.7 });
|
||||||
|
this.state = prevState;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await TTS.speak(`收件箱:${unreadMsgs.length}条未读。`, { rate: 0.9, volume: 0.7 });
|
||||||
|
|
||||||
|
for (const m of unreadMsgs.slice(0, 5)) {
|
||||||
|
const sender = SENDERS[m.sender];
|
||||||
|
const type = m.type === 'call' ? '未接来电' : m.type === 'wechat_text' ? '文字' : '语音';
|
||||||
|
await TTS.speak(`${sender?.name || m.senderName},${type}。`, { rate: 0.85, volume: 0.7 });
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
await TTS.speak('单点进入最新一条,左滑返回。', { rate: 0.85, volume: 0.6 });
|
||||||
|
|
||||||
|
this._inboxMessages = unreadMsgs;
|
||||||
|
// 5秒后自动退出收件箱
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.state === 'inbox') {
|
||||||
|
this.state = prevState;
|
||||||
|
this._inboxMessages = null;
|
||||||
|
}
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_inboxSelect() {
|
||||||
|
if (!this._inboxMessages || this._inboxMessages.length === 0) {
|
||||||
|
this.state = 'playing';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = this._inboxMessages[0];
|
||||||
|
this._inboxMessages = null;
|
||||||
|
this.currentMsg = msg;
|
||||||
|
this.state = 'playing';
|
||||||
|
// 触发该消息
|
||||||
|
this._triggerMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 状态变量应用
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
_applyState(changes) {
|
||||||
|
for (const [k, v] of Object.entries(changes)) {
|
||||||
|
if (k === 'ZHOUNAN_DEPTH_ADD') {
|
||||||
|
this.vars.ZHOUNAN_DEPTH += (v || 1);
|
||||||
|
} else if (v !== null) {
|
||||||
|
this.vars[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 结局判定
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
_checkEndingConditions() {
|
||||||
|
// 只在消息16触发后真正判定结局
|
||||||
|
// 这里只做中途检测
|
||||||
|
const unread = Object.values(this.msgState).filter(s => s === 'unread').length;
|
||||||
|
|
||||||
|
// 结局D:未读≥8时在01:00前触发
|
||||||
|
if (unread >= 8 && Date.now() - this._startTime >= gt(1, 0) * 1000) {
|
||||||
|
if (!this._endingTriggered) this._triggerEnding('D');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerFinalEnding() {
|
||||||
|
if (this._endingTriggered) return;
|
||||||
|
|
||||||
|
const v = this.vars;
|
||||||
|
const unread = Object.values(this.msgState).filter(s => s === 'unread' || !this.msgState[s]).length;
|
||||||
|
|
||||||
|
// 结局 E(隐藏)
|
||||||
|
if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE &&
|
||||||
|
(this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) {
|
||||||
|
return this._triggerEnding('E');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结局 A:妈妈连接+自己录音+有回复
|
||||||
|
if (v.MOM_LINK && v.SELF_RECORD) {
|
||||||
|
return this._triggerEnding('A');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结局 B:拒了阿哲+回了周南
|
||||||
|
if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) {
|
||||||
|
return this._triggerEnding('B');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结局 C:全部已读但回复≤2
|
||||||
|
const repliedCount = Object.values(this.msgState).filter(s => s === 'replied').length;
|
||||||
|
const readCount = Object.values(this.msgState).filter(s => s !== 'unread').length;
|
||||||
|
if (readCount >= MESSAGES.length - 2 && repliedCount <= 2) {
|
||||||
|
return this._triggerEnding('C');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结局 D(兜底)
|
||||||
|
this._triggerEnding('D');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _triggerEnding(type) {
|
||||||
|
if (this._endingTriggered) return;
|
||||||
|
this._endingTriggered = true;
|
||||||
|
this.state = 'ending';
|
||||||
|
|
||||||
|
Snd.stopBGM(3000);
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const ENDINGS = {
|
||||||
|
A: this._endingA.bind(this),
|
||||||
|
B: this._endingB.bind(this),
|
||||||
|
C: this._endingC.bind(this),
|
||||||
|
D: this._endingD.bind(this),
|
||||||
|
E: this._endingE.bind(this),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ENDINGS[type]) await ENDINGS[type]();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _endingA() {
|
||||||
|
// 妈妈语音播完→厨房→烧水
|
||||||
|
Snd.startAmbience(`${AMB}/amb_kitchen_morning_v1.wav`, { vol: 0.3 });
|
||||||
|
await sleep(2000);
|
||||||
|
Snd.playVoice([`${SFX}/sfx_kettle_whistle_v1.wav`]);
|
||||||
|
await sleep(4000);
|
||||||
|
this._showCaption('明天再说。');
|
||||||
|
await sleep(3000);
|
||||||
|
Snd.playVoice([`${SFX}/sfx_ending_chime_v1.wav`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _endingB() {
|
||||||
|
await TTS.speak('寄出。', { rate: 0.8, volume: 0.8 });
|
||||||
|
await sleep(4000);
|
||||||
|
this._showCaption('寄出。');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _endingC() {
|
||||||
|
await TTS.speak(
|
||||||
|
`今晚共${MESSAGES.length}条新消息,已读${Object.values(this.msgState).filter(s => s !== 'unread').length},未回复${MESSAGES.length - Object.values(this.msgState).filter(s => s === 'replied').length}。`,
|
||||||
|
{ rate: 0.85, volume: 0.8 }
|
||||||
|
);
|
||||||
|
await sleep(6000);
|
||||||
|
this._showCaption('今晚就这样。');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _endingD() {
|
||||||
|
Snd.playVoice([`${TDIR}/lv11_sys_battery_01_wav_v1.wav`]);
|
||||||
|
await sleep(2000);
|
||||||
|
Haptic.ring();
|
||||||
|
await sleep(1000);
|
||||||
|
// 屏幕"彻底黑了"→清晨鸟叫
|
||||||
|
await sleep(3000);
|
||||||
|
Snd.startAmbience(`${AMB}/amb_dawn_birds_v1.wav`, { vol: 0.4 });
|
||||||
|
await sleep(4000);
|
||||||
|
this._showCaption('早上好。');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _endingE() {
|
||||||
|
// 周南秒回
|
||||||
|
await sleep(2000);
|
||||||
|
await TTS.speak('周南发来语音。', { rate: 0.9, volume: 0.7 });
|
||||||
|
await TTS.speak('那我下次出差来北京,请你吃饭好不好。', { rate: 0.9 });
|
||||||
|
await sleep(2000);
|
||||||
|
// 林夏的轻笑(用self3y音色)
|
||||||
|
Snd.playVoice([`${TDIR}/lv11_sys_goodnight_wav_v1.wav`]);
|
||||||
|
await sleep(3000);
|
||||||
|
this._showCaption('好。');
|
||||||
|
await sleep(2000);
|
||||||
|
Snd.playVoice([`${SFX}/sfx_ending_chime_v1.wav`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showCaption(text) {
|
||||||
|
const el = document.getElementById('caption');
|
||||||
|
el.textContent = text;
|
||||||
|
el.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// gestures.js — 触控手势检测器(单指操作)
|
||||||
|
//
|
||||||
|
// 支持:单点 · 双点 · 长按1s · 长按5s
|
||||||
|
// 左滑 · 右滑 · 上滑 · 下滑
|
||||||
|
|
||||||
|
class GestureDetector {
|
||||||
|
constructor(el) {
|
||||||
|
this._el = el;
|
||||||
|
this._handlers = {};
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
this._startX = 0;
|
||||||
|
this._startY = 0;
|
||||||
|
this._startTime = 0;
|
||||||
|
|
||||||
|
this._tapTimer = null;
|
||||||
|
this._longTimer1 = null;
|
||||||
|
this._longTimer5 = null;
|
||||||
|
this._longFired = false;
|
||||||
|
this._tapCount = 0;
|
||||||
|
|
||||||
|
this._bind();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(evt, fn) { this._handlers[evt] = fn; return this; }
|
||||||
|
|
||||||
|
_emit(evt, data) {
|
||||||
|
dbg(evt);
|
||||||
|
const fn = this._handlers[evt];
|
||||||
|
if (fn) fn(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
_bind() {
|
||||||
|
const el = this._el;
|
||||||
|
el.addEventListener('touchstart', e => this._onStart(e), { passive: false });
|
||||||
|
el.addEventListener('touchend', e => this._onEnd(e), { passive: false });
|
||||||
|
el.addEventListener('touchcancel', e => this._onCancel(e), { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
_onStart(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = e.touches[0];
|
||||||
|
this._startX = t.clientX;
|
||||||
|
this._startY = t.clientY;
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._longFired = false;
|
||||||
|
|
||||||
|
this._clearTimers();
|
||||||
|
|
||||||
|
// 1s长按
|
||||||
|
this._longTimer1 = setTimeout(() => {
|
||||||
|
this._longFired = true;
|
||||||
|
this._emit('longpress1s');
|
||||||
|
Haptic.detailMode();
|
||||||
|
}, 900);
|
||||||
|
|
||||||
|
// 5s长按(安全机制)
|
||||||
|
this._longTimer5 = setTimeout(() => {
|
||||||
|
this._longFired = true;
|
||||||
|
this._emit('longpress5s');
|
||||||
|
Haptic.reject();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onEnd(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this._clearTimers();
|
||||||
|
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const dx = t.clientX - this._startX;
|
||||||
|
const dy = t.clientY - this._startY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const dt = Date.now() - this._startTime;
|
||||||
|
|
||||||
|
if (this._longFired) return;
|
||||||
|
|
||||||
|
// ── 滑动判断 ──
|
||||||
|
const SWIPE_MIN = 35;
|
||||||
|
const SWIPE_ANG = 45;
|
||||||
|
|
||||||
|
if (dist > SWIPE_MIN) {
|
||||||
|
const angle = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI);
|
||||||
|
let dir = null;
|
||||||
|
|
||||||
|
if (angle < SWIPE_ANG) dir = 'right';
|
||||||
|
else if (angle > 180 - SWIPE_ANG) dir = 'left';
|
||||||
|
else if (dy < 0 && Math.abs(dy) > Math.abs(dx)) dir = 'up';
|
||||||
|
else if (dy > 0 && Math.abs(dy) > Math.abs(dx)) dir = 'down';
|
||||||
|
|
||||||
|
if (dir) {
|
||||||
|
this._emit(`swipe_${dir}`);
|
||||||
|
Haptic.confirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 点击判断 ──
|
||||||
|
if (dist < 20 && dt < 500) {
|
||||||
|
this._tapCount++;
|
||||||
|
if (this._tapCount === 1) {
|
||||||
|
this._tapTimer = setTimeout(() => {
|
||||||
|
this._tapCount = 0;
|
||||||
|
this._emit('singletap');
|
||||||
|
Haptic.confirm();
|
||||||
|
}, 250);
|
||||||
|
} else if (this._tapCount === 2) {
|
||||||
|
clearTimeout(this._tapTimer);
|
||||||
|
this._tapCount = 0;
|
||||||
|
this._emit('doubletap');
|
||||||
|
Haptic.confirm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onCancel(e) {
|
||||||
|
this._clearTimers();
|
||||||
|
this._longFired = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearTimers() {
|
||||||
|
clearTimeout(this._tapTimer);
|
||||||
|
clearTimeout(this._longTimer1);
|
||||||
|
clearTimeout(this._longTimer5);
|
||||||
|
this._tapTimer = null;
|
||||||
|
this._longTimer1 = null;
|
||||||
|
this._longTimer5 = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// haptics.js — 震动反馈(Android Chrome 支持,iOS 降级为无震动)
|
||||||
|
|
||||||
|
const Haptic = {
|
||||||
|
_ok: typeof navigator !== 'undefined' && !!navigator.vibrate,
|
||||||
|
|
||||||
|
_v(pattern) {
|
||||||
|
if (this._ok) navigator.vibrate(pattern);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 来电震动(长震 + 停顿,循环由来电循环调用)
|
||||||
|
ring() { this._v([400, 200, 400]); },
|
||||||
|
|
||||||
|
// 微信消息(两短震)
|
||||||
|
message() { this._v([80, 60, 80]); },
|
||||||
|
|
||||||
|
// 确认操作
|
||||||
|
confirm() { this._v(60); },
|
||||||
|
|
||||||
|
// 拒绝 / 挂断
|
||||||
|
reject() { this._v([100, 50, 100, 50, 100]); },
|
||||||
|
|
||||||
|
// 进入细回复模式
|
||||||
|
detailMode() { this._v([30, 30, 60]); },
|
||||||
|
|
||||||
|
// 选项选中
|
||||||
|
select() { this._v(80); },
|
||||||
|
|
||||||
|
// 系统通知
|
||||||
|
system() { this._v([200, 100, 100, 100, 200]); },
|
||||||
|
|
||||||
|
// 错误 / 无效手势
|
||||||
|
error() { this._v([50, 30, 50]); },
|
||||||
|
|
||||||
|
stop() { if (this._ok) navigator.vibrate(0); },
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// main.js — 游戏入口
|
||||||
|
|
||||||
|
// 开发模式(URL加?dev):时间线缩短为1/10
|
||||||
|
const DEV_MODE = new URLSearchParams(location.search).has('dev');
|
||||||
|
if (DEV_MODE) {
|
||||||
|
// 直接缩短所有消息的触发时间
|
||||||
|
MESSAGES.forEach(m => { m.triggerAt = Math.floor(m.triggerAt / 10); });
|
||||||
|
document.title = '林夏 [DEV x10]';
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = null;
|
||||||
|
let gestures = null;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
TTS.init();
|
||||||
|
|
||||||
|
// 初始化音频(必须在用户手势后)
|
||||||
|
await Snd.init();
|
||||||
|
|
||||||
|
// 预加载关键短音效
|
||||||
|
await Promise.allSettled([
|
||||||
|
Snd.preload('sfx_keyboard', `${SFX}/sfx_keyboard_slow_v1.wav`),
|
||||||
|
Snd.preload('sfx_chime', `${SFX}/sfx_ending_chime_v1.wav`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 请求屏幕常亮(Android Chrome 支持)
|
||||||
|
try {
|
||||||
|
if ('wakeLock' in navigator) {
|
||||||
|
window._wakeLock = await navigator.wakeLock.request('screen');
|
||||||
|
}
|
||||||
|
} catch(_) {}
|
||||||
|
|
||||||
|
// 绑定手势到游戏区域
|
||||||
|
const gameEl = document.getElementById('game');
|
||||||
|
gestures = new GestureDetector(gameEl);
|
||||||
|
|
||||||
|
// 创建并启动游戏引擎
|
||||||
|
engine = new GameEngine();
|
||||||
|
engine.init(gestures);
|
||||||
|
await engine.start();
|
||||||
|
|
||||||
|
if (DEV_MODE) {
|
||||||
|
document.getElementById('dbg').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触摸启动(手机)
|
||||||
|
document.getElementById('start').addEventListener('touchend', async function onStart(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.removeEventListener('touchend', onStart);
|
||||||
|
this.style.transition = 'opacity 0.8s';
|
||||||
|
this.style.opacity = '0';
|
||||||
|
setTimeout(() => { this.style.display = 'none'; }, 800);
|
||||||
|
await main();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
// 鼠标点击(电脑调试)
|
||||||
|
document.getElementById('start').addEventListener('click', async function onClick() {
|
||||||
|
this.removeEventListener('click', onClick);
|
||||||
|
this.style.transition = 'opacity 0.8s';
|
||||||
|
this.style.opacity = '0';
|
||||||
|
setTimeout(() => { this.style.display = 'none'; }, 800);
|
||||||
|
await main();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
// 防止页面滚动/缩放
|
||||||
|
document.addEventListener('touchmove', e => e.preventDefault(), { passive: false });
|
||||||
|
document.addEventListener('gesturestart', e => e.preventDefault());
|
||||||
|
|
||||||
|
// 页面隐藏时停止打字音效
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden && Snd) Snd.stopTyping();
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// profile.js — 行为画像追踪器(avoidance / engagement / nostalgia)
|
||||||
|
|
||||||
|
class ProfileTracker {
|
||||||
|
constructor() {
|
||||||
|
this.scores = { avoidance: 0, engagement: 0, nostalgia: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
add(effects) {
|
||||||
|
if (!effects) return;
|
||||||
|
for (const k of ['avoidance', 'engagement', 'nostalgia']) {
|
||||||
|
if (effects[k]) this.scores[k] += effects[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 漏接电话
|
||||||
|
onMissedCall() { this.add({ avoidance: 2 }); }
|
||||||
|
// 单点回"嗯"
|
||||||
|
onSimpleReply() { this.add({ avoidance: 1 }); }
|
||||||
|
// 双指上滑已读不回
|
||||||
|
onReadNoReply() { this.add({ avoidance: 2 }); }
|
||||||
|
// 进入细回复
|
||||||
|
onDetailReply() { this.add({ engagement: 2 }); }
|
||||||
|
// 细回复选"主动追问"(engagement 方向)
|
||||||
|
onEngage() { this.add({ engagement: 2 }); }
|
||||||
|
// 细回复选"分享自己"
|
||||||
|
onShare() { this.add({ engagement: 1, nostalgia: 1 }); }
|
||||||
|
// 反复重听同一条
|
||||||
|
onReplay() { this.add({ nostalgia: 2 }); }
|
||||||
|
// 听完长语音未回应
|
||||||
|
onListenNoReact(){ this.add({ avoidance: 1, nostalgia: 1 }); }
|
||||||
|
|
||||||
|
// 获取最终画像标签
|
||||||
|
get label() {
|
||||||
|
const s = this.scores;
|
||||||
|
if (s.engagement >= s.avoidance && s.engagement >= s.nostalgia) return 'engagement';
|
||||||
|
if (s.nostalgia >= s.avoidance) return 'nostalgia';
|
||||||
|
return 'avoidance';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
// story.js — 《林夏》全部16条消息数据
|
||||||
|
|
||||||
|
const A = '../audio/00_raw';
|
||||||
|
const TDIR = `${A}/tts`;
|
||||||
|
const MUS = `${A}/music`;
|
||||||
|
const SFX = `${A}/sfx`;
|
||||||
|
const AMB = `${A}/ambience`;
|
||||||
|
|
||||||
|
// 游戏时间压缩比:5小时 → 60分钟(实际秒)
|
||||||
|
// 1 游戏分钟 = 12 实际秒
|
||||||
|
// triggerAt = (游戏时间 - 21:00) 的分钟数 × 12
|
||||||
|
function gt(hh, mm) {
|
||||||
|
const gameMin = (hh < 21 ? hh + 24 : hh) * 60 + mm - 21 * 60;
|
||||||
|
return gameMin * 12; // 实际秒
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES = [
|
||||||
|
|
||||||
|
// ── 01 · 21:03 · 妈妈 · 电话 ──────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 1, gameTime: '21:03', triggerAt: gt(21, 3),
|
||||||
|
type: 'call',
|
||||||
|
sender: 'mom', senderName: '妈妈',
|
||||||
|
ringtone: `${MUS}/lv11_ring_mom_v1.wav`,
|
||||||
|
ringDuration: 28,
|
||||||
|
audio: [`${TDIR}/lv11_2103_mom_call_01_wav_v1.wav`],
|
||||||
|
note: '今晚煮了你爱吃的排骨',
|
||||||
|
stateOnAnswer: { MOM_LINK: true },
|
||||||
|
profileOnMiss: { avoidance: 2 },
|
||||||
|
profileOnAnswer:{ engagement: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 02 · 21:15 · 小美 · 微信语音×3(教学关)──────────────────
|
||||||
|
{
|
||||||
|
id: 2, gameTime: '21:15', triggerAt: gt(21, 15),
|
||||||
|
type: 'wechat_voice',
|
||||||
|
sender: 'xiaomei', senderName: '小美',
|
||||||
|
audio: [
|
||||||
|
`${TDIR}/lv11_2115_xiaomei_voice_01_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2115_xiaomei_voice_02_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2115_xiaomei_voice_03_wav_v1.wav`,
|
||||||
|
],
|
||||||
|
note: '你被甩了我都听说了,明天喝酒',
|
||||||
|
isTutorial: true,
|
||||||
|
detailReplies: [
|
||||||
|
{ label: '好啊,明天不见不散。', dir: 'left', profile: { engagement: 1 } },
|
||||||
|
{ label: '我不太想出门……', dir: 'right', profile: { nostalgia: 1 } },
|
||||||
|
{ label: '你先去,我再说。', dir: 'up', profile: { avoidance: 1 } },
|
||||||
|
{ label: '(沉默)', dir: 'down', isSilence: true, profile: { avoidance: 2 } },
|
||||||
|
],
|
||||||
|
standardReply: '好啊。',
|
||||||
|
profileOnRead: { engagement: 1 },
|
||||||
|
profileOnAvoid: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 03 · 21:20 · 外卖小哥 · 电话 ─────────────────────────────
|
||||||
|
{
|
||||||
|
id: 3, gameTime: '21:20', triggerAt: gt(21, 20),
|
||||||
|
type: 'call',
|
||||||
|
sender: 'delivery', senderName: '外卖小哥',
|
||||||
|
ringtone: `${MUS}/lv11_ring_delivery_v1.wav`,
|
||||||
|
ringDuration: 20,
|
||||||
|
audio: [`${TDIR}/lv11_2120_delivery_call_01_wav_v1.wav`],
|
||||||
|
note: '你好,你的麻辣烫到了',
|
||||||
|
profileOnMiss: { avoidance: 1 },
|
||||||
|
profileOnAnswer:{ engagement: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 04 · 21:30 · 阿哲 · 微信语音(第一刀)────────────────────
|
||||||
|
{
|
||||||
|
id: 4, gameTime: '21:30', triggerAt: gt(21, 30),
|
||||||
|
type: 'wechat_voice',
|
||||||
|
sender: 'azhe', senderName: '阿哲',
|
||||||
|
audio: [`${TDIR}/lv11_2130_azhe_voice_01_wav_v1.wav`],
|
||||||
|
note: 'AirPods落你那了,方便寄一下吗',
|
||||||
|
detailReplies: [
|
||||||
|
{ label: '好,明天寄。', dir: 'left', state: { HE_BACK: false }, profile: { engagement: 1 } },
|
||||||
|
{ label: '你怎么不自己来拿。', dir: 'right', state: {}, profile: { engagement: 2 } },
|
||||||
|
{ label: '你那边……都好吗。', dir: 'up', state: {}, profile: { engagement: 2 } },
|
||||||
|
{ label: '(什么都不说)', dir: 'down', isSilence: true, state: { HE_BACK: null }, profile: { avoidance: 2 } },
|
||||||
|
],
|
||||||
|
standardReply: '好,明天寄给你。',
|
||||||
|
stateOnStdReply: { HE_BACK: false },
|
||||||
|
// 10分钟后阿哲撤回消息(如果玩家未回复)
|
||||||
|
followUp: { delayMin: 10, action: 'azhe_recall' },
|
||||||
|
profileOnRead: { engagement: 0 },
|
||||||
|
profileOnAvoid: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 05 · 21:45 · HR王姐 · 微信语音 ───────────────────────────
|
||||||
|
{
|
||||||
|
id: 5, gameTime: '21:45', triggerAt: gt(21, 45),
|
||||||
|
type: 'wechat_voice',
|
||||||
|
sender: 'hr', senderName: 'HR王姐',
|
||||||
|
audio: [`${TDIR}/lv11_2145_hr_voice_01_wav_v1.wav`],
|
||||||
|
note: '离职流程周三前办完哈',
|
||||||
|
detailReplies: [
|
||||||
|
{ label: '好的,我知道了。谢谢王姐。', dir: 'left', profile: { engagement: 1 } },
|
||||||
|
{ label: '王姐,有什么建议吗?', dir: 'right', profile: { engagement: 2 } },
|
||||||
|
{ label: '……谢谢你。', dir: 'up', profile: { nostalgia: 1 } },
|
||||||
|
{ label: '(不回复)', dir: 'down', isSilence: true, profile: { avoidance: 1 } },
|
||||||
|
],
|
||||||
|
standardReply: '好的,谢谢王姐。',
|
||||||
|
profileOnRead: { engagement: 1 },
|
||||||
|
profileOnAvoid: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 06 · 22:00 · 安安 · 微信语音(酒吧背景)─────────────────
|
||||||
|
{
|
||||||
|
id: 6, gameTime: '22:00', triggerAt: gt(22, 0),
|
||||||
|
type: 'wechat_voice',
|
||||||
|
sender: 'anan', senderName: '安安',
|
||||||
|
audio: [`${TDIR}/lv11_2200_anan_voice_01_wav_v1.wav`],
|
||||||
|
note: '我在helens!要不要过来!',
|
||||||
|
bgUnder: `${AMB}/amb_bar_loud_01_v1.wav`, // 酒吧底噪混入
|
||||||
|
detailReplies: [
|
||||||
|
{ label: '我今晚不方便,你们玩好啊。', dir: 'left', profile: { avoidance: 1 } },
|
||||||
|
{ label: '在哪儿啊?我……看看吧。', dir: 'right', profile: { engagement: 1 } },
|
||||||
|
{ label: '你喝了多少了哈哈。', dir: 'up', profile: { engagement: 1 } },
|
||||||
|
{ label: '(不回复)', dir: 'down', isSilence: true, profile: { avoidance: 2 } },
|
||||||
|
],
|
||||||
|
standardReply: '不了,你们玩好~',
|
||||||
|
profileOnRead: { engagement: 0 },
|
||||||
|
profileOnAvoid: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 07 · 22:15 · 大学寝室群 · 5条语音 ────────────────────────
|
||||||
|
{
|
||||||
|
id: 7, gameTime: '22:15', triggerAt: gt(22, 15),
|
||||||
|
type: 'wechat_voice',
|
||||||
|
sender: 'dorm', senderName: '我们寝室的(4人群)',
|
||||||
|
audio: [
|
||||||
|
`${TDIR}/lv11_2215_dorm_a_voice_01_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2215_dorm_a_voice_02_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2215_dorm_b_voice_01_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2215_dorm_b_voice_02_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2215_dorm_c_voice_01_wav_v1.wav`,
|
||||||
|
],
|
||||||
|
note: '室友A升职了,最后她@你',
|
||||||
|
isGroup: true,
|
||||||
|
detailReplies: [
|
||||||
|
{ label: '恭喜恭喜!', dir: 'left', state: { GROUP_REPLY: true }, profile: { engagement: 1 } },
|
||||||
|
{ label: '哇,快讲讲!', dir: 'right', state: { GROUP_REPLY: true }, profile: { engagement: 2 } },
|
||||||
|
{ label: '最近有点忙,你们聊~', dir: 'up', profile: { avoidance: 1 } },
|
||||||
|
{ label: '(沉默)', dir: 'down', isSilence: true, profile: { avoidance: 2 } },
|
||||||
|
],
|
||||||
|
standardReply: '恭喜!',
|
||||||
|
stateOnStdReply: { GROUP_REPLY: true },
|
||||||
|
profileOnRepeat: { nostalgia: 2 },
|
||||||
|
profileOnRead: { engagement: 0 },
|
||||||
|
profileOnAvoid: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 08 · 22:30 · 未知号码 · 电话(悬疑钩子)─────────────────
|
||||||
|
{
|
||||||
|
id: 8, gameTime: '22:30', triggerAt: gt(22, 30),
|
||||||
|
type: 'call',
|
||||||
|
sender: 'unknown', senderName: '未知号码',
|
||||||
|
ringtone: `${MUS}/lv11_ring_unknown_v1.wav`,
|
||||||
|
ringDuration: 20,
|
||||||
|
audio: [`${SFX}/sfx_breathing_unknown_v1.wav`],
|
||||||
|
note: '接通后只有呼吸声,6秒挂断',
|
||||||
|
autoHangup: 6, // 接通后自动挂断秒数
|
||||||
|
sfxAfter: `${SFX}/sfx_heartbeat_fast_v1.wav`,
|
||||||
|
profileOnMiss: { avoidance: 1 },
|
||||||
|
profileOnAnswer:{ nostalgia: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 09 · 22:45 · 小美醉语音 · 4段(秘密揭晓)───────────────
|
||||||
|
{
|
||||||
|
id: 9, gameTime: '22:45', triggerAt: gt(22, 45),
|
||||||
|
type: 'wechat_voice',
|
||||||
|
sender: 'xiaomei', senderName: '小美',
|
||||||
|
audio: [
|
||||||
|
`${TDIR}/lv11_2245_xiaomei_voice_01_seg1_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2245_xiaomei_voice_01_seg2_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2245_xiaomei_voice_01_seg3_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2245_xiaomei_voice_01_seg4_wav_v1.wav`,
|
||||||
|
],
|
||||||
|
note: '阿哲大三追过我,我没答应他',
|
||||||
|
isDrunk: true,
|
||||||
|
bgm: `${MUS}/lv11_bgm_m2_suspense_v1.wav`, // 悬疑BGM
|
||||||
|
detailReplies: [
|
||||||
|
{ label: '我知道了。', dir: 'left', profile: { avoidance: 1 } },
|
||||||
|
{ label: '你……为什么现在才说。', dir: 'right', profile: { engagement: 2 } },
|
||||||
|
{ label: '小美,你还好吗。', dir: 'up', profile: { engagement: 2 } },
|
||||||
|
{ label: '(什么都不说)', dir: 'down', isSilence: true, profile: { avoidance: 2, nostalgia: 1 } },
|
||||||
|
],
|
||||||
|
standardReply: '我知道了。',
|
||||||
|
// 5秒后消息被撤回
|
||||||
|
followUp: { delaySec: 5, action: 'xiaomei_recall' },
|
||||||
|
profileOnRepeat: { nostalgia: 2 },
|
||||||
|
profileOnAvoid: { avoidance: 1, nostalgia: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 10 · 23:00 · 妈妈 · 微信文字 ─────────────────────────────
|
||||||
|
{
|
||||||
|
id: 10, gameTime: '23:00', triggerAt: gt(23, 0),
|
||||||
|
type: 'wechat_text',
|
||||||
|
sender: 'mom', senderName: '妈妈',
|
||||||
|
text: '睡了吗?',
|
||||||
|
note: '三个字,最痛',
|
||||||
|
detailReplies: [
|
||||||
|
{ label: '嗯,要睡了。', dir: 'left', state: { MOM_LINK: true }, profile: { avoidance: 1 } },
|
||||||
|
{ label: '没有,怎么了。', dir: 'right', state: { MOM_LINK: true }, profile: { engagement: 1 } },
|
||||||
|
{ label: '妈,你在吗,我想打电话。', dir: 'up', state: { MOM_LINK: true }, profile: { engagement: 2 } },
|
||||||
|
{ label: '(不回复)', dir: 'down', isSilence: true, profile: { avoidance: 2 } },
|
||||||
|
],
|
||||||
|
standardReply: '嗯。',
|
||||||
|
stateOnStdReply: { MOM_LINK: true },
|
||||||
|
profileOnRead: { nostalgia: 1 },
|
||||||
|
profileOnAvoid: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 11 · 23:15 · 阿哲 · 电话(关键分支)─────────────────────
|
||||||
|
{
|
||||||
|
id: 11, gameTime: '23:15', triggerAt: gt(23, 15),
|
||||||
|
type: 'call',
|
||||||
|
sender: 'azhe', senderName: '阿哲',
|
||||||
|
ringtone: `${MUS}/lv11_ring_azhe_v1.wav`,
|
||||||
|
ringDuration: 30,
|
||||||
|
audio: [`${TDIR}/lv11_2315_azhe_call_01_wav_v1.wav`],
|
||||||
|
note: '我能过去拿吗?就5分钟',
|
||||||
|
// 通话中的选择(接通后听完再做)
|
||||||
|
inCallChoices: [
|
||||||
|
{ label: '不方便,明天吧。', dir: 'left', state: { HE_BACK: false } },
|
||||||
|
{ label: '可以,但只有5分钟。', dir: 'right', state: { HE_BACK: true } },
|
||||||
|
{ label: '(不说话,挂断)', dir: 'down', isSilence: true, state: { HE_BACK: false } },
|
||||||
|
],
|
||||||
|
profileOnMiss: { avoidance: 2 },
|
||||||
|
profileOnAnswer:{ engagement: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 12 · 23:30 · 周南 · 微信语音(悬疑揭晓)────────────────
|
||||||
|
{
|
||||||
|
id: 12, gameTime: '23:30', triggerAt: gt(23, 30),
|
||||||
|
type: 'wechat_voice',
|
||||||
|
sender: 'zhounan', senderName: '周南',
|
||||||
|
audio: [`${TDIR}/lv11_2330_zhounan_voice_01_wav_v1.wav`],
|
||||||
|
note: '林夏,是我,周南。初三坐你后桌那个',
|
||||||
|
detailReplies: [
|
||||||
|
{ label: '嗯……你好。', dir: 'left', state: { ZHOUNAN_DEPTH: 1 }, profile: { nostalgia: 1 } },
|
||||||
|
{ label: '周南!你还记得我啊!', dir: 'right', state: { ZHOUNAN_DEPTH: 1 }, profile: { engagement: 2, nostalgia: 1 } },
|
||||||
|
{ label: '你是……初三坐我后桌那个……?', dir: 'up', state: { ZHOUNAN_DEPTH: 1 }, profile: { nostalgia: 2 } },
|
||||||
|
{ label: '(不回复)', dir: 'down', isSilence: true },
|
||||||
|
],
|
||||||
|
standardReply: '嗯……好久不见。',
|
||||||
|
stateOnStdReply: { ZHOUNAN_DEPTH: 1 },
|
||||||
|
profileOnRead: { nostalgia: 1 },
|
||||||
|
profileOnAvoid: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 13 · 23:45 · 周南 · 4条语音(十年故事)─────────────────
|
||||||
|
{
|
||||||
|
id: 13, gameTime: '23:45', triggerAt: gt(23, 45),
|
||||||
|
type: 'wechat_voice',
|
||||||
|
sender: 'zhounan', senderName: '周南',
|
||||||
|
audio: [
|
||||||
|
`${TDIR}/lv11_2345_zhounan_voice_01_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2345_zhounan_voice_02_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2345_zhounan_voice_03_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_2345_zhounan_voice_04_wav_v1.wav`,
|
||||||
|
],
|
||||||
|
note: '高考、复读、二本、回老家……今天我生日',
|
||||||
|
bgm: `${MUS}/lv11_bgm_m3_zhounan_v1.wav`,
|
||||||
|
detailReplies: [
|
||||||
|
{ label: '你讲了好多。我都听了。', dir: 'left', state: { ZHOUNAN_DEPTH_ADD: 1 }, profile: { engagement: 2, nostalgia: 1 } },
|
||||||
|
{ label: '你一个人在北京,还好吗?', dir: 'right', state: { ZHOUNAN_DEPTH_ADD: 1 }, profile: { engagement: 2 } },
|
||||||
|
{ label: '……生日快乐。', dir: 'up', state: { ZHOUNAN_DEPTH_ADD: 1, ZHOUNAN_SHARE: true }, profile: { nostalgia: 2, engagement: 1 } },
|
||||||
|
{ label: '(沉默)', dir: 'down', isSilence: true, profile: { nostalgia: 1, avoidance: 1 } },
|
||||||
|
],
|
||||||
|
standardReply: '……谢谢你告诉我。',
|
||||||
|
stateOnStdReply: { ZHOUNAN_DEPTH_ADD: 1 },
|
||||||
|
profileOnRepeat: { nostalgia: 2 },
|
||||||
|
profileOnAvoid: { nostalgia: 1, avoidance: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 14 · 00:15 · 妈妈 · 电话(第二次)─────────────────────
|
||||||
|
{
|
||||||
|
id: 14, gameTime: '00:15', triggerAt: gt(0, 15),
|
||||||
|
type: 'call',
|
||||||
|
sender: 'mom', senderName: '妈妈',
|
||||||
|
ringtone: `${MUS}/lv11_ring_mom_v1.wav`,
|
||||||
|
ringDuration: 30,
|
||||||
|
audio: [`${TDIR}/lv11_0015_mom_call_01_wav_v1.wav`],
|
||||||
|
note: '夏夏,你不接电话我有点担心',
|
||||||
|
stateOnAnswer: { MOM_LINK: true },
|
||||||
|
profileOnMiss: { avoidance: 2 },
|
||||||
|
profileOnAnswer:{ engagement: 2 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 15 · 00:30 · 自己 · 时光胶囊(系统通知)────────────────
|
||||||
|
{
|
||||||
|
id: 15, gameTime: '00:30', triggerAt: gt(0, 30),
|
||||||
|
type: 'system_notification',
|
||||||
|
sender: 'self', senderName: '时光胶囊',
|
||||||
|
text: '3年前的今晚,你给自己录过一段备忘录。是否播放?',
|
||||||
|
systemAudio: `${TDIR}/lv11_sys_memo_01_wav_v1.wav`,
|
||||||
|
audioByProfile: {
|
||||||
|
avoidance: `${TDIR}/lv11_0030_self3y_voice_02_wav_v1.wav`,
|
||||||
|
engagement: `${TDIR}/lv11_0030_self3y_voice_01_wav_v1.wav`,
|
||||||
|
nostalgia: `${TDIR}/lv11_0030_self3y_voice_03_wav_v1.wav`,
|
||||||
|
},
|
||||||
|
note: 'self-3y,由PROFILE决定版本',
|
||||||
|
stateOnPlay: { SELF_RECORD: true },
|
||||||
|
profileOnPlay: { nostalgia: 2 },
|
||||||
|
profileOnSkip: { avoidance: 2 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 16 · 02:00 · 妈妈 · 长语音4分17秒(结局)──────────────
|
||||||
|
{
|
||||||
|
id: 16, gameTime: '02:00', triggerAt: gt(2, 0),
|
||||||
|
type: 'wechat_voice',
|
||||||
|
sender: 'mom', senderName: '妈妈',
|
||||||
|
audio: [
|
||||||
|
`${TDIR}/lv11_0200_mom_voice_01_seg1_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_0200_mom_voice_01_seg2_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_0200_mom_voice_01_seg3_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_0200_mom_voice_01_seg4_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_0200_mom_voice_01_seg5_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_0200_mom_voice_01_seg6_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_0200_mom_voice_01_seg7_wav_v1.wav`,
|
||||||
|
`${TDIR}/lv11_0200_mom_voice_01_seg8_wav_v1.wav`,
|
||||||
|
],
|
||||||
|
note: '妈妈讲她24岁那年,结局起点',
|
||||||
|
bgm: `${MUS}/lv11_bgm_m4_ending_v1.wav`,
|
||||||
|
sfxAfter: [
|
||||||
|
`${SFX}/sfx_night_bus_v1.wav`,
|
||||||
|
`${SFX}/sfx_cat_yowl_v1.wav`,
|
||||||
|
],
|
||||||
|
isEnding: true,
|
||||||
|
requiresMomLink: true, // 必须 MOM_LINK=true 才自动播放
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 角色→铃声映射(用于收件箱读出)
|
||||||
|
const SENDERS = {
|
||||||
|
mom: { name: '妈妈', ringtone: `${MUS}/lv11_ring_mom_v1.wav` },
|
||||||
|
azhe: { name: '阿哲', ringtone: `${MUS}/lv11_ring_azhe_v1.wav` },
|
||||||
|
xiaomei: { name: '小美', ringtone: `${MUS}/lv11_ring_xiaomei_v1.wav` },
|
||||||
|
delivery: { name: '外卖小哥', ringtone: `${MUS}/lv11_ring_delivery_v1.wav` },
|
||||||
|
hr: { name: 'HR王姐', ringtone: `${MUS}/lv11_ring_hr_v1.wav` },
|
||||||
|
anan: { name: '安安', ringtone: `${MUS}/lv11_ring_anan_v1.wav` },
|
||||||
|
dorm: { name: '寝室群', ringtone: `${MUS}/lv11_ring_dorm_group_v1.wav` },
|
||||||
|
unknown: { name: '未知号码', ringtone: `${MUS}/lv11_ring_unknown_v1.wav` },
|
||||||
|
zhounan: { name: '周南', ringtone: `${MUS}/lv11_ring_zhounan_v1.wav` },
|
||||||
|
self: { name: '时光胶囊', ringtone: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SFX_UI = {
|
||||||
|
wechat_msg: `${SFX}/sfx_deep_breath_v1.wav`, // 微信消息提示(用sfx模拟)
|
||||||
|
keyboard: `${SFX}/sfx_keyboard_slow_v1.wav`,
|
||||||
|
chime: `${SFX}/sfx_ending_chime_v1.wav`,
|
||||||
|
transition: `${SFX}/sfx_transition_chapter_v1.wav`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AMBIENCE_DEFAULT = `${AMB}/amb_apartment_night_01_v1.wav`;
|
||||||
|
const AMBIENCE_RAIN = `${AMB}/amb_rain_window_01_v1.wav`;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// utils.js — 轻量工具函数
|
||||||
|
|
||||||
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function dbg(msg) {
|
||||||
|
const el = document.getElementById('dbg');
|
||||||
|
if (el && el.style.display !== 'none') el.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音合成(选项朗读用)
|
||||||
|
const TTS = {
|
||||||
|
_voices: [],
|
||||||
|
_ready: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (!window.speechSynthesis) return;
|
||||||
|
const load = () => {
|
||||||
|
this._voices = speechSynthesis.getVoices();
|
||||||
|
this._ready = true;
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
speechSynthesis.onvoiceschanged = load;
|
||||||
|
},
|
||||||
|
|
||||||
|
_pick() {
|
||||||
|
// 优先选中文语音
|
||||||
|
const zh = this._voices.find(v =>
|
||||||
|
v.lang.startsWith('zh') || v.name.includes('Chinese') || v.name.includes('中')
|
||||||
|
);
|
||||||
|
return zh || this._voices[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
speak(text, { rate = 0.85, pitch = 1, volume = 0.85 } = {}) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!window.speechSynthesis) { resolve(); return; }
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
const u = new SpeechSynthesisUtterance(text);
|
||||||
|
u.lang = 'zh-CN';
|
||||||
|
u.rate = rate;
|
||||||
|
u.pitch = pitch;
|
||||||
|
u.volume = volume;
|
||||||
|
const v = this._pick();
|
||||||
|
if (v) u.voice = v;
|
||||||
|
u.onend = resolve;
|
||||||
|
u.onerror = resolve;
|
||||||
|
speechSynthesis.speak(u);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (window.speechSynthesis) speechSynthesis.cancel();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "林夏",
|
||||||
|
"short_name": "林夏",
|
||||||
|
"description": "一款全黑屏的情感叙事游戏。今晚16条消息,你接谁不接谁,决定明天的自己。",
|
||||||
|
"start_url": "/game/",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "icon.png", "sizes": "192x192", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
# 《林夏》操作说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、游戏简介
|
||||||
|
|
||||||
|
全黑屏叙事游戏。玩家扮演 24 岁的林夏,用一部碎屏手机度过今晚 21:00–02:00 的 5 个小时。
|
||||||
|
|
||||||
|
- **没有视觉界面**,只有声音和触感
|
||||||
|
- **全程约 60 分钟**(5 小时游戏时间压缩)
|
||||||
|
- **16 条消息**,5 种结局
|
||||||
|
- **不需要说话**,不需要麦克风
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、开始前准备
|
||||||
|
|
||||||
|
1. 手机和电脑连接**同一 Wi-Fi**
|
||||||
|
2. 打开手机浏览器,输入地址:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://172.28.107.97:8765/game/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **关灯,戴上耳机**
|
||||||
|
4. 将手机音量调到适中(建议 60–80%)
|
||||||
|
5. 轻触屏幕任意位置开始游戏
|
||||||
|
|
||||||
|
> **iOS 用户注意**:Safari 不支持震动反馈,操作正常但无震感。建议使用 Android Chrome。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、消息类型与接收方式
|
||||||
|
|
||||||
|
游戏中有三种消息形态:
|
||||||
|
|
||||||
|
### 来电
|
||||||
|
- 触发:手机长震 + 角色专属铃声
|
||||||
|
- 响 **20–30 秒**后自动漏接
|
||||||
|
|
||||||
|
| 操作 | 效果 |
|
||||||
|
|------|------|
|
||||||
|
| **单点** | 接听 |
|
||||||
|
| **左滑** | 拒接 |
|
||||||
|
| **不动** | 漏接(对方挂断) |
|
||||||
|
|
||||||
|
### 微信语音 / 微信文字
|
||||||
|
- 触发:两短震 + 系统提示音(TTS 报出发件人)
|
||||||
|
|
||||||
|
| 操作 | 效果 |
|
||||||
|
|------|------|
|
||||||
|
| **单点** | 播放语音 / 念出文字 |
|
||||||
|
| **左滑** | 忽略(保持未读) |
|
||||||
|
| **不动** | 保持未读,晚些再听 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、回复消息
|
||||||
|
|
||||||
|
**听完语音 / 文字念完后**,进入等待回复状态:
|
||||||
|
|
||||||
|
| 操作 | 发送内容 |
|
||||||
|
|------|----------|
|
||||||
|
| **单点** | 发"嗯。"(最简单的回执) |
|
||||||
|
| **双点** | 发标准回复(针对该消息的一句话) |
|
||||||
|
| **长按 1 秒** | 进入**细回复**模式 |
|
||||||
|
| **左滑** | 已读不回 |
|
||||||
|
| **不动** | 维持"已读未回"状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、细回复(核心机制)
|
||||||
|
|
||||||
|
长按 1 秒后,震动确认进入细回复。系统会依次念出 **4 个候选回复**:
|
||||||
|
|
||||||
|
```
|
||||||
|
一…… "好,明天寄。"
|
||||||
|
二…… "你怎么不自己来拿。"
|
||||||
|
三…… "你那边……都好吗。"
|
||||||
|
四…… (什么都不说)
|
||||||
|
```
|
||||||
|
|
||||||
|
**在听到目标选项时,朝对应方向滑动:**
|
||||||
|
|
||||||
|
| 滑动方向 | 对应选项 |
|
||||||
|
|----------|----------|
|
||||||
|
| **左滑** | 一 |
|
||||||
|
| **右滑** | 二 |
|
||||||
|
| **上滑** | 三 |
|
||||||
|
| **下滑** | 四 |
|
||||||
|
|
||||||
|
> **没听清?** 单点屏幕可重听所有选项,或长按 1 秒重新播放。
|
||||||
|
> 选错了也没关系——每条消息只影响行为画像,不会"game over"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、查看当前状态
|
||||||
|
|
||||||
|
**随时下滑**,系统会用语音告诉你当前在哪个页面,以及可以做什么操作。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
- 主屏幕时下滑 → "主屏幕,3条未读。上滑查看收件箱。"
|
||||||
|
- 来电时下滑 → "妈妈来电中。单点接听,左滑拒接。"
|
||||||
|
- 听完消息后下滑 → "阿哲的消息已播完。单点回嗯,双点标准回复,长按细回复,左滑已读不回。"
|
||||||
|
|
||||||
|
> 不确定自己在哪?下滑就对了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、查看未读列表(收件箱)
|
||||||
|
|
||||||
|
在主屏幕**上滑**,进入收件箱模式。
|
||||||
|
|
||||||
|
系统会依次念出所有未读消息:
|
||||||
|
```
|
||||||
|
"收件箱:4条未读。妈妈,来电。阿哲,语音。周南,语音。……"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **单点** → 跳转至列表第一条
|
||||||
|
- **左滑** → 返回主屏幕
|
||||||
|
- **等待 8 秒** → 自动关闭收件箱,返回游戏
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、游戏内时间
|
||||||
|
|
||||||
|
游戏将现实的 21:00–02:00(5 小时)压缩为 **60 分钟**真实时间。
|
||||||
|
|
||||||
|
| 游戏时间 | 真实等待 |
|
||||||
|
|----------|----------|
|
||||||
|
| 21:03 | 约 36 秒 |
|
||||||
|
| 21:30 | 约 6 分钟 |
|
||||||
|
| 22:30 | 约 18 分钟 |
|
||||||
|
| 23:45 | 约 33 分钟 |
|
||||||
|
| 02:00 | 约 60 分钟 |
|
||||||
|
|
||||||
|
消息到来时间固定,**漏接 / 不听是合法选择**,但漏掉的内容不会重发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、结局说明(不含剧透)
|
||||||
|
|
||||||
|
共 **5 种结局**,由以下 5 个变量决定:
|
||||||
|
|
||||||
|
| 变量 | 触发条件 |
|
||||||
|
|------|----------|
|
||||||
|
| 与妈妈的连接 | 是否接了妈妈的电话 / 回了文字 |
|
||||||
|
| 阿哲是否回来 | 23:15 那通电话的选择 |
|
||||||
|
| 群里是否发言 | 寝室群消息的回应 |
|
||||||
|
| 与周南的深度 | 回应周南的次数与方式 |
|
||||||
|
| 是否听自己 | 00:30 时光胶囊是否播放 |
|
||||||
|
|
||||||
|
另有一个**隐藏结局 E**,需要与某位角色深度互动才能触发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、行为画像
|
||||||
|
|
||||||
|
游戏全程追踪三个维度(玩家不可见):
|
||||||
|
|
||||||
|
| 维度 | 含义 | 触发行为 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **avoidance**(回避) | 林夏今晚想一个人待着 | 漏接、不回、已读不回 |
|
||||||
|
| **engagement**(投入) | 林夏在认真面对每一个人 | 细回复、主动追问 |
|
||||||
|
| **nostalgia**(怀旧) | 林夏在这一夜反复回望 | 重听同一条消息、分享自己 |
|
||||||
|
|
||||||
|
画像决定 **00:30 时光胶囊**播放哪个版本(明朗 / 迷茫 / 温柔)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、彩蛋
|
||||||
|
|
||||||
|
- **夜间启动**(现实时间 22:00–04:00 之间打开游戏):开场多一句话
|
||||||
|
- **消息09**:小美醉语音结束后会有一个撤回事件
|
||||||
|
- **消息04**:如果一直不回阿哲,会听到一个小细节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、开发者 / 测试模式
|
||||||
|
|
||||||
|
在 URL 末尾加 `?dev` 可开启**10倍速**模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://172.28.107.97:8765/game/?dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- 所有消息的触发时间缩短为原来的 1/10
|
||||||
|
- 全程约 **6 分钟**可打通
|
||||||
|
- 适合测试结局分支
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、启动服务器
|
||||||
|
|
||||||
|
关机重启后需要重新启动 HTTP 服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/xsl/blind
|
||||||
|
python3 -m http.server 8765 --bind 0.0.0.0 &
|
||||||
|
```
|
||||||
|
|
||||||
|
查看当前 IP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hostname -I | awk '{print $1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十四、文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/xsl/blind/
|
||||||
|
├── game/
|
||||||
|
│ ├── index.html 主页面(黑屏)
|
||||||
|
│ ├── manifest.json PWA 配置
|
||||||
|
│ ├── 操作说明.md 本文件
|
||||||
|
│ └── js/
|
||||||
|
│ ├── utils.js 工具函数 + 浏览器 TTS
|
||||||
|
│ ├── haptics.js 震动反馈
|
||||||
|
│ ├── audio.js 音频管理器
|
||||||
|
│ ├── gestures.js 单指手势检测
|
||||||
|
│ ├── profile.js 行为画像评分
|
||||||
|
│ ├── story.js 16条消息数据
|
||||||
|
│ ├── engine.js 游戏状态机
|
||||||
|
│ └── main.js 入口
|
||||||
|
└── audio/
|
||||||
|
└── 00_raw/
|
||||||
|
├── tts/ 角色语音(68条)
|
||||||
|
├── music/ BGM + 铃声
|
||||||
|
├── sfx/ 音效
|
||||||
|
└── ambience/ 环境底噪
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"屏幕坏了,但她的一夜还没结束。"*
|
||||||
+396
@@ -0,0 +1,396 @@
|
|||||||
|
# GDD-002:心跳连线
|
||||||
|
|
||||||
|
> 版本:v1.0 | 日期:2026-05-12 | 状态:策划阶段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 游戏概述
|
||||||
|
|
||||||
|
### 1.1 核心信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 游戏名称 | 心跳连线(Heartline) |
|
||||||
|
| 类型 | 全音频恋爱模拟 / 互动叙事 |
|
||||||
|
| 平台 | iOS / Android |
|
||||||
|
| 技术栈 | Unity + FMOD |
|
||||||
|
| 目标受众 | 恋爱模拟玩家、声控、ASMR 爱好者、视障玩家 |
|
||||||
|
| 游戏时长 | 单线 2-3 小时,全结局 15-20 小时 |
|
||||||
|
| 定价模式 | 免费下载含 1 条线路,其余内购解锁($4.99/线,$11.99 全解锁) |
|
||||||
|
|
||||||
|
### 1.2 Elevator Pitch
|
||||||
|
|
||||||
|
> 在一次事故后,你的世界陷入黑暗。唯一的光亮,是电话那头传来的声音。《心跳连线》是一款完全通过打电话进行的恋爱模拟游戏——没有画面,只有真人配音、环境音和你的选择。
|
||||||
|
|
||||||
|
### 1.3 核心体验目标
|
||||||
|
|
||||||
|
- **亲密感**:模拟真实电话聊天的沉浸体验,让玩家产生"这个人真的在对我说话"的感觉
|
||||||
|
- **情感投射**:没有视觉形象,每个玩家可以完全根据自己的想象构建角色形象
|
||||||
|
- **选择的沉重感**:关键抉择节点通过"沉默时刻"设计,让玩家真正感受到语言的分量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心玩法
|
||||||
|
|
||||||
|
### 2.1 游戏循环
|
||||||
|
|
||||||
|
```
|
||||||
|
接听来电 → 对话推进 → 选项分支 → NPC 反应 → 关系值变化 → 剧情解锁
|
||||||
|
│ │
|
||||||
|
└──────── 好感度 + 关键选择 → 新剧情线解锁 ←─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心机制
|
||||||
|
|
||||||
|
#### 2.2.1 来电系统
|
||||||
|
|
||||||
|
- **来电时机**:
|
||||||
|
- 固定剧情电话:关键剧情节点自动触发
|
||||||
|
- 时间触发电话:现实时间每天特定时段(如晚上 10 点)触发,可设置免打扰
|
||||||
|
- 玩家回拨:在通话记录中选择已通话角色回拨(好感度达到一定值解锁)
|
||||||
|
- 意外来电:随机事件触发
|
||||||
|
- **来电提示**:
|
||||||
|
- 铃声前 3 秒:目标手机开始轻微振动
|
||||||
|
- 铃声响起:每个角色有独特的来电铃声
|
||||||
|
- 振动模式:不同角色对应不同振动节奏
|
||||||
|
|
||||||
|
#### 2.2.2 对话选项系统
|
||||||
|
|
||||||
|
- **显性选项**:触觉提示的两个选项(左/右侧不同频率微振动)
|
||||||
|
- **隐性选项**:"沉默时刻"——系统不提供明确选项,玩家需要在 6-10 秒内主动说话
|
||||||
|
- 麦克风实时监测语音活动
|
||||||
|
- 回答被录音并在剧情后期可能被 NPC 引用(极大增强沉浸感)
|
||||||
|
- **延时选项**:玩家可以在对话中选择沉默(不给选项),等待 NPC 继续说——有时沉默是最好的回应
|
||||||
|
|
||||||
|
#### 2.2.3 好感度与关系系统
|
||||||
|
|
||||||
|
| 好感度等级 | 名称 | NPC 行为变化 |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| Lv.0 | 陌生人 | 正式礼貌,通话时间短 |
|
||||||
|
| Lv.1 | 熟人 | 语气放松,出现个人话题 |
|
||||||
|
| Lv.2 | 朋友 | 主动来电频率增加,分享日常 |
|
||||||
|
| Lv.3 | 密友 | 深夜来电,分享秘密 |
|
||||||
|
| Lv.4 | 暧昧 | 语调和呼吸变化,出现亲密话题 |
|
||||||
|
| Lv.5 | 恋人 | 专属称呼,解锁恋人剧情线 |
|
||||||
|
|
||||||
|
- 好感度通过玩家选择的关键回应累计
|
||||||
|
- 好感度不可见(没有数值显示),需要玩家从 NPC 语气中自行感知
|
||||||
|
- 好感度有衰减机制:长期不联系(真实时间 3 天以上)会降低,但不会低于已解锁的最高等级
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 触控交互设计
|
||||||
|
|
||||||
|
### 3.1 核心手势
|
||||||
|
|
||||||
|
| 手势 | 功能 | 触觉反馈 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 右滑(接听方向) | 接听来电 | 确认短振 × 1 + 接通音效 |
|
||||||
|
| 左滑(挂断方向) | 挂断/拒绝 | 断联短振 × 1 |
|
||||||
|
| 上滑 | 调大通话音量 | 连续微振,力度递增 |
|
||||||
|
| 下滑 | 调小通话音量 | 连续微振,力度递减 |
|
||||||
|
| 屏幕左半侧点击 | 选择对话选项 A | 选项 A 振动频率 |
|
||||||
|
| 屏幕右半侧点击 | 选择对话选项 B | 选项 B 振动频率 |
|
||||||
|
| 长按(> 2 秒) | 选择"保持沉默" | 无振动(沉默的触觉表达) |
|
||||||
|
| 双指点击 | 回拨上一个通话对象 | 双振 |
|
||||||
|
|
||||||
|
### 3.2 通话界面状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
[待机界面] ──来电──→ [来电振铃] ──右滑──→ [通话中] ──左滑/对方挂断──→ [通话结束总结]
|
||||||
|
│ │
|
||||||
|
└──左滑──→ [未接来电记录]
|
||||||
|
│
|
||||||
|
[通话记录] ←──────┘
|
||||||
|
│
|
||||||
|
└──点击──→ [回拨] ──接通──→ [通话中]
|
||||||
|
└──拒接──→ [忙音]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 辅助触觉信号
|
||||||
|
|
||||||
|
| 信号 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| 心率式脉动(渐快) | NPC 情绪紧张/激动 |
|
||||||
|
| 心率式脉动(渐慢) | NPC 情绪平静/悲伤 |
|
||||||
|
| 不规则双振 | NPC 在犹豫/说谎 |
|
||||||
|
| 长振(500ms) | 通话结束,等待玩家操作 |
|
||||||
|
| 三连短振 | 新消息/新来电通知 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 音频设计
|
||||||
|
|
||||||
|
### 4.1 通话音频处理
|
||||||
|
|
||||||
|
- **发送端(玩家侧)**:
|
||||||
|
- 可选择激活/关闭麦克风(关闭时使用 AI 语音模拟替代玩家声音)
|
||||||
|
- 麦克风输入经过轻量压缩和 EQ 处理(模拟电话音频压缩感)
|
||||||
|
- **接收端(NPC 侧)**:
|
||||||
|
- 使用两种音频渲染模式:正常模式(录音室品质)和电话模式(8kHz 低通滤波 + 轻度失真)
|
||||||
|
- 在特殊剧情中,电话模式会突然切换到正常模式(NPC 就在附近!)
|
||||||
|
- **环境背景层**:
|
||||||
|
- NPC 侧的背景音模拟其所在环境
|
||||||
|
- 玩家侧的背景音(雨声、城市声、室内安静声)增强自我定位感
|
||||||
|
|
||||||
|
### 4.2 NPC 声音设计
|
||||||
|
|
||||||
|
#### 4.2.1 可攻略角色
|
||||||
|
|
||||||
|
| 角色 | 声线描述 | 性格 | 来电铃声 |
|
||||||
|
|------|---------|------|---------|
|
||||||
|
| 程逸(童年好友) | 温暖中低音,语速中等 | 可靠、怀旧、有些优柔寡断 | 木吉他简单旋律 |
|
||||||
|
| 苏瑾(主治医生) | 冷静女中音,咬字清晰 | 理性、专业但内心温柔 | 钢琴重复音型 |
|
||||||
|
| 洛宵(隔壁病友) | 带沙哑的年轻声音 | 神秘、幽默、回避亲密 | 电子氛围音 |
|
||||||
|
|
||||||
|
#### 4.2.2 次要角色
|
||||||
|
|
||||||
|
| 角色 | 关系 | 通话频率 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 母亲 | 家人 | 每周 1-2 次 |
|
||||||
|
| 护工张姐 | 医院 | 剧情触发 |
|
||||||
|
| 陌生来电者 | ?? | 剧情触发 |
|
||||||
|
|
||||||
|
### 4.3 动态音乐系统
|
||||||
|
|
||||||
|
- **主菜单/待机**:柔和的钢琴 + 环境底噪(模拟夜晚窗边)
|
||||||
|
- **来电振铃**:根据来电角色切换为各自的铃声主题
|
||||||
|
- **通话中(轻松)**:极简背景音乐,-25dB,不干扰对话
|
||||||
|
- **通话中(紧张)**:音乐逐渐引入,-18dB,弦乐渐强
|
||||||
|
- **关键抉择**:音乐暂停 3 秒,只剩心跳声
|
||||||
|
- **通话结束**:根据结果播放不同情绪收束(温暖/遗憾/期待)
|
||||||
|
|
||||||
|
**制作策略**:所有音乐为循环片段,时长 30-60 秒,通过 FMOD 参数控制横向过渡。
|
||||||
|
|
||||||
|
### 4.4 语音内容规划
|
||||||
|
|
||||||
|
| 内容类型 | 字数/条 | 数量 | 总计 |
|
||||||
|
|---------|---------|------|------|
|
||||||
|
| 主线对话(程逸线) | 800-2000 字/场景 | 15 个场景 | ~18,000 字 |
|
||||||
|
| 主线对话(苏瑾线) | 800-2000 字/场景 | 15 个场景 | ~17,000 字 |
|
||||||
|
| 主线对话(洛宵线) | 800-2000 字/场景 | 15 个场景 | ~17,000 字 |
|
||||||
|
| 共通线对话 | 500-1500 字/场景 | 8 个场景 | ~8,000 字 |
|
||||||
|
| 随机来电 | 200-500 字/次 | 30 次/角色 | ~28,000 字 |
|
||||||
|
| 回拨对话 | 300-800 字 | 20 次/角色 | ~40,000 字 |
|
||||||
|
| 结局独白 | 1000-2000 字 | 15 个结局 | ~18,000 字 |
|
||||||
|
|
||||||
|
**总计**:约 146,000 字,配音时长估计 16-18 小时(含多个变体)
|
||||||
|
|
||||||
|
### 4.5 录音制作规范
|
||||||
|
|
||||||
|
- **录音格式**:48kHz / 24bit / Mono
|
||||||
|
- **麦克风距离**:15-20cm,心形指向
|
||||||
|
- **录音环境**:专业声学处理录音棚
|
||||||
|
- **表演要求**:
|
||||||
|
- 使用"电话表演法"——声优实际对着电话说话录制
|
||||||
|
- 录制 3 种情感强度的版本(平静/情绪波动/崩溃)
|
||||||
|
- 呼吸声、停顿、笑声、叹息等非语言声音全部保留
|
||||||
|
- **后期处理**:
|
||||||
|
- 电话模式:8kHz 低通 + 轻度饱和失真 + -6dB 压缩
|
||||||
|
- 原声模式:仅做轻微压缩,保留录音室品质
|
||||||
|
- 根据剧情需要在两者之间过渡
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 叙事与世界观
|
||||||
|
|
||||||
|
### 5.1 背景设定
|
||||||
|
|
||||||
|
> 主角(玩家)28 岁,独立游戏开发者。某个雨夜,在加班赶项目时突然晕倒。醒来后发现:视觉神经暂时性损伤,预计恢复时间不明。被困在医院的日子里,电话成了与外界唯一的连接。
|
||||||
|
|
||||||
|
### 5.2 三条主线
|
||||||
|
|
||||||
|
#### 程逸线——"那些年没说出口的话"
|
||||||
|
|
||||||
|
**关系背景**:程逸是玩家的小学同学,高中分开后一直偶然联系。得知玩家住院后,他第一个打来电话。
|
||||||
|
|
||||||
|
**情感主线**:两人通过电话重新回忆起童年和少年时期共同经历的一切。程逸一直隐约对玩家抱有好感,但从未说出口。玩家住院这件事让他意识到:有些话再不说,可能就没机会了。
|
||||||
|
|
||||||
|
**关键节点**:
|
||||||
|
1. 第一次通话:紧张,客套,询问病情
|
||||||
|
2. 童年回忆:他发来一段小时候两人一起唱的歌的录音
|
||||||
|
3. 深夜来电:程逸喝醉了,声音里有从未听过的脆弱
|
||||||
|
4. 坦白时刻:他终于说出了藏在心里十年的话
|
||||||
|
5. 终点:玩家的回应决定了结局方向
|
||||||
|
|
||||||
|
**5 个结局**:
|
||||||
|
- 完美结局「同频」:两人确认彼此的心意,程逸来医院接你出院
|
||||||
|
- 遗憾结局「时差」:玩家没有回应,程逸说"那我先挂了",之后再未来电
|
||||||
|
- 友情结局「老友」:玩家明确拒绝但希望保持友谊,程逸努力接受
|
||||||
|
- 错过结局「忙音」:玩家在关键对话中选择挂断
|
||||||
|
- 二周目结局「回声」:揭示程逸也在同一次事故中,只是他伤得更重
|
||||||
|
|
||||||
|
#### 苏瑾线——"白色外套下的温度"
|
||||||
|
|
||||||
|
**关系背景**:苏瑾是玩家的主治医生,33 岁,神经内科副主任。初见时专业而冷淡。
|
||||||
|
|
||||||
|
**情感主线**:从医患关系到超越职业界限的情感。苏瑾最初的每条对话都像在写病例,但随着时间推移,她开始在下班后用私人手机给玩家打电话。她有自己的创伤——一年前没能救回来的那个病人。
|
||||||
|
|
||||||
|
**关键节点**:
|
||||||
|
1. 例行查房:完全专业,声音不带感情
|
||||||
|
2. 第一次非工作时间来电:她在深夜打电话来问"今天感觉怎么样"
|
||||||
|
3. 脆弱时刻:她在一个失败的抢救后打来,声音在颤抖
|
||||||
|
4. 界限模糊:她开始在通话中聊自己,聊医院以外的苏瑾
|
||||||
|
5. 职业与情感的抉择
|
||||||
|
|
||||||
|
**5 个结局**:
|
||||||
|
- 完美结局「曙光」:苏瑾决定转科室,你们成为正式的恋人
|
||||||
|
- 伦理结局「底线」:玩家拒绝跨越医患关系,苏瑾尊重并保持距离
|
||||||
|
- 消失结局「调离」:苏瑾因无法处理情感而申请调去其他城市
|
||||||
|
- 住院结局「延续」:玩家选择继续住院(延缓出院)以维持关系
|
||||||
|
- 二周目结局「病例」:揭示苏瑾的姐姐曾死于相同的伤病
|
||||||
|
|
||||||
|
#### 洛宵线——"隔壁那扇关着的门"
|
||||||
|
|
||||||
|
**关系背景**:洛宵,25 岁,住在玩家隔壁病房的患者。听到玩家病房的电话响了三天后,他用手机拨通了玩家的号码。"嘿,邻居。"
|
||||||
|
|
||||||
|
**情感主线**:洛宵是三条线中最神秘的角色。他从不谈论自己的病情,话题总是天马行空——从外星人到量子物理到街头小吃。但越深入的对话越能感觉到他声音里的疲惫和秘密。他在逃避某些东西。
|
||||||
|
|
||||||
|
**关键节点**:
|
||||||
|
1. 破冰:他用一个冷笑话开场
|
||||||
|
2. 共犯:深夜两人偷偷溜出病房,用电话导航在医院探险
|
||||||
|
3. 裂痕:他对自己的病仍闭口不谈,玩家是否追问成为一个关键选择
|
||||||
|
4. 真相:洛宵的病情比想象中严重得多——他剩下的时间不多了
|
||||||
|
5. 告别或坚守
|
||||||
|
|
||||||
|
**5 个结局**:
|
||||||
|
- 完美结局「奇迹」:洛宵接受了新治疗方案,存活率大幅提升
|
||||||
|
- 告别结局「星尘」:洛宵在最后的时间里与玩家度过了每一刻,最终安然离去
|
||||||
|
- 逃避结局「空号」:洛宵在真相被揭露后拒绝再接听电话
|
||||||
|
- 陪护结局「安详」:玩家陪他走完最后一段路
|
||||||
|
- 二周目结局「另一边」:揭示洛宵一直在给另一个号码打电话——但那是空号
|
||||||
|
|
||||||
|
### 5.3 二周目:「真相线」
|
||||||
|
|
||||||
|
一周目通关任意结局后解锁。玩家以新视角重玩——某些对话细节在二周目中会揭示新含义。
|
||||||
|
- 事故的真正原因
|
||||||
|
- 三位可攻略角色之间不为人知的联系
|
||||||
|
- 一个隐藏的"第四角色"——事故的肇事者
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 系统设计
|
||||||
|
|
||||||
|
### 6.1 存档与分支
|
||||||
|
|
||||||
|
- **自动存档**:每次通话结束自动存档
|
||||||
|
- **手动存档**:玩家可在通话界面双指下滑打开存档菜单
|
||||||
|
- **存档槽**:15 个(覆盖三条主线足够)
|
||||||
|
- **路线图**:通关后解锁,可视化所有对话分支(触觉版的"路线图"——按节点分割,点击节点可听到该段的简短摘要)
|
||||||
|
|
||||||
|
### 6.2 通话记录系统
|
||||||
|
|
||||||
|
- 保存所有历史通话的录音(游戏中),可回放
|
||||||
|
- 关键对话自动标记
|
||||||
|
- 玩家可以给每条记录添加语音备忘录(用自己的声音标注)
|
||||||
|
|
||||||
|
### 6.3 消息系统
|
||||||
|
|
||||||
|
- 除了实时通话,NPC 还可以发送语音消息
|
||||||
|
- 语音消息 15-60 秒,无需玩家应答
|
||||||
|
- 在通话记录中以不同图标标识(通过触觉区分:实时通话=单振、语音消息=双振)
|
||||||
|
|
||||||
|
### 6.4 时间系统
|
||||||
|
|
||||||
|
- **游戏内时间**随剧情推进
|
||||||
|
- **现实时间触发**(可选开启):特定时间段会有额外来电
|
||||||
|
- 例:晚上 10 点后的来电更私密、情感更深
|
||||||
|
- 可以设置"勿扰模式"时间段
|
||||||
|
- 可以设置"接收所有来电"(不筛时间)或"仅剧情来电"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 技术规格
|
||||||
|
|
||||||
|
### 7.1 音频引擎
|
||||||
|
|
||||||
|
| 参数 | 规格 |
|
||||||
|
|------|------|
|
||||||
|
| 音频中间件 | FMOD Studio |
|
||||||
|
| 空间音频 | 不需要(电话模拟不需要空间定位) |
|
||||||
|
| 麦克风处理 | 实时输入 + 轻量 DSP(压缩、EQ) |
|
||||||
|
| 音频延迟 | < 60ms(麦克风监听延迟) |
|
||||||
|
|
||||||
|
### 7.2 性能目标
|
||||||
|
|
||||||
|
| 指标 | 目标 |
|
||||||
|
|------|------|
|
||||||
|
| 安装包大小 | < 800MB(大量配音内容,首次下载含共通线) |
|
||||||
|
| 可下载内容 | 各路线配音按需下载(每线 150-250MB) |
|
||||||
|
| 最低系统 | iOS 14 / Android 10 |
|
||||||
|
| 后台运行 | 支持(电话在后台响起通知) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 无障碍设计
|
||||||
|
|
||||||
|
### 8.1 无需视觉的完整交互
|
||||||
|
|
||||||
|
- 游戏从启动画面开始就完全不需要视觉
|
||||||
|
- 首次启动的教程通过语音引导完成
|
||||||
|
- 所有菜单操作均有触觉+音频双重确认
|
||||||
|
- 完全兼容 VoiceOver / TalkBack
|
||||||
|
|
||||||
|
### 8.2 替代输入方式
|
||||||
|
|
||||||
|
- 除触控手势外,支持系统语音指令("接听"/"挂断"/"回拨"等)
|
||||||
|
- 支持蓝牙耳机控制按钮(接听键 = 游戏中接听)
|
||||||
|
|
||||||
|
### 8.3 内容警告
|
||||||
|
|
||||||
|
- 游戏内涉及医疗创伤、死亡、悲伤等严肃主题
|
||||||
|
- 在相关剧情开始前通过"内容提示"(语音形式)提醒玩家
|
||||||
|
- 玩家可以选择跳过特定敏感内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 开发路线图
|
||||||
|
|
||||||
|
| 阶段 | 周期 | 目标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 预开发 | 第 1-3 周 | FMOD 集成、电话音频处理原型、录音测试 |
|
||||||
|
| Alpha | 第 4-12 周 | 共通线完成、程逸线配音完成 |
|
||||||
|
| Beta | 第 13-22 周 | 三条主线全部配音完成、系统功能完善 |
|
||||||
|
| 打磨 | 第 23-28 周 | 本地化(英文配音)、无障碍测试、QA |
|
||||||
|
| 发布 | 第 29-30 周 | App Store + Google Play 上架 |
|
||||||
|
|
||||||
|
### 9.1 团队配置
|
||||||
|
|
||||||
|
| 角色 | 人数 |
|
||||||
|
|------|------|
|
||||||
|
| 制作人/导演 | 1 |
|
||||||
|
| 叙事设计师 | 1 |
|
||||||
|
| 编剧 | 2 |
|
||||||
|
| 声优 | 8-10(三位主线 + 五位次要角色) |
|
||||||
|
| 音频工程师 | 1 |
|
||||||
|
| Unity 程序员 | 2 |
|
||||||
|
| FMOD 技术音频 | 1 |
|
||||||
|
| 无障碍顾问 | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 商业模式
|
||||||
|
|
||||||
|
### 10.1 收入模型
|
||||||
|
|
||||||
|
| 内容 | 价格 |
|
||||||
|
|------|------|
|
||||||
|
| 游戏下载(含共通线 + 1 条主线) | 免费 |
|
||||||
|
| 程逸线完整解锁 | $4.99 |
|
||||||
|
| 苏瑾线完整解锁 | $4.99 |
|
||||||
|
| 洛宵线完整解锁 | $4.99 |
|
||||||
|
| 全路线捆绑包(含二周目真相线) | $11.99 |
|
||||||
|
| 附加语音包(每个角色的日常来电扩展包) | $1.99/个 |
|
||||||
|
|
||||||
|
### 10.2 关键指标
|
||||||
|
|
||||||
|
| 指标 | 目标 |
|
||||||
|
|------|------|
|
||||||
|
| 首月下载 | 100,000 |
|
||||||
|
| 付费转化率 | 12-15% |
|
||||||
|
| 全路线完成率 | 40%(付费用户中) |
|
||||||
|
| 平均会话时长 | 22 分钟 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*GDD-002 心跳连线 | 文档版本 v1.0*
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
# GDD-004:深渊电台
|
||||||
|
|
||||||
|
> 版本:v1.0 | 日期:2026-05-12 | 状态:策划阶段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 游戏概述
|
||||||
|
|
||||||
|
### 1.1 核心信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 游戏名称 | 深渊电台(Abyssal Frequency) |
|
||||||
|
| 类型 | 深海恐怖生存 |
|
||||||
|
| 平台 | iOS / Android |
|
||||||
|
| 技术栈 | Unity + Resonance Audio |
|
||||||
|
| 目标受众 | 恐怖游戏爱好者、潜艇模拟爱好者、沉浸音频玩家 |
|
||||||
|
| 游戏时长 | 主线 5-6 小时 |
|
||||||
|
| 定价模式 | 买断制 $7.99 |
|
||||||
|
|
||||||
|
### 1.2 Elevator Pitch
|
||||||
|
|
||||||
|
> 深海之下 3800 米。潜艇所有屏幕全黑,你只剩下声呐的 ping、无线电的噪音、以及船壳外那些……不属于已知生物的声音。《深渊电台》是一款深海恐怖生存游戏——在完全黑暗中,声音是你唯一的感知,也是恐惧的来源。
|
||||||
|
|
||||||
|
### 1.3 核心体验目标
|
||||||
|
|
||||||
|
- **深海恐惧的音频表达**:水压、黑暗、未知生物——恐怖不靠跳吓(jump scare),而靠持续的压迫感和声音暗示
|
||||||
|
- **资源管理的紧张**:氧气、电力、船体完整性——每一项都通过声音反馈
|
||||||
|
- **通讯的脆弱性**:无线电是你与人类世界的唯一联系,但它随时可能中断或被劫持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心玩法
|
||||||
|
|
||||||
|
### 2.1 游戏循环
|
||||||
|
|
||||||
|
```
|
||||||
|
声呐扫描 → 导航穿越 → 遭遇事件(生物/遗迹/残骸)→ 资源消耗 → 修复/补给
|
||||||
|
↑ ↓
|
||||||
|
└──────────── 无线电通讯推进剧情 ←───────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心机制
|
||||||
|
|
||||||
|
#### 2.2.1 声呐系统
|
||||||
|
|
||||||
|
**主动声呐(主动 Ping)**:
|
||||||
|
- 点击发射声波脉冲,声波在遇到物体后返回回声
|
||||||
|
- 回声的音调、延迟、强度揭示前方物体的距离、大小、材质
|
||||||
|
- 金属回声(高亢、长余韵):其他潜艇/残骸
|
||||||
|
- 岩石回声(低沉、短促):海床/海底山脉
|
||||||
|
- 有机回声(柔软、不规则):生物
|
||||||
|
- 无回声:深渊(海底断崖/海沟)
|
||||||
|
- 声呐有代价:每次 Ping 消耗少量电力,且会暴露潜艇位置给周围的生物
|
||||||
|
|
||||||
|
**被动声呐(持续监听)**:
|
||||||
|
- 自动分析周围环境中的生物声源
|
||||||
|
- 不同生物有独特的声学特征(详见 4.3)
|
||||||
|
|
||||||
|
#### 2.2.2 潜艇状态系统
|
||||||
|
|
||||||
|
| 子系统 | 音频健康指示 | 故障音频表现 | 完全失效后果 |
|
||||||
|
|--------|------------|------------|------------|
|
||||||
|
| 引擎 | 稳定低频嗡鸣 | 间歇性爆震、转速不稳 | 失去机动能力 |
|
||||||
|
| 生命维持 | 气流声平稳 | 气流声变尖锐(氧浓度下降) | 缺氧耳鸣 → 死亡 |
|
||||||
|
| 船体完整性 | 金属应力正常 | 咯吱声加剧、频率升高 | 船壳破裂 → 进水 |
|
||||||
|
| 电力 | 电流底噪稳定 | 电流声波动、灯光嗡嗡声不稳 | 所有系统断电 |
|
||||||
|
| 通讯 | 无线电静噪正常 | 信号失真、串入不明信号 | 与外界失联 |
|
||||||
|
| 声呐 | Ping 回声清晰 | 回声模糊、出现假回声 | 失去导航能力 |
|
||||||
|
|
||||||
|
#### 2.2.3 资源管理
|
||||||
|
|
||||||
|
| 资源 | 消耗方式 | 音频提示 | 补充方式 |
|
||||||
|
|------|---------|---------|---------|
|
||||||
|
| 氧气 | 持续消耗,船员越多消耗越快 | 呼吸声变重 → 急促 → 喘息 | 浮出水面/找到氧气补给点 |
|
||||||
|
| 电力 | 引擎运转、声呐 Ping、通讯 | 电流声变弱 → 系统逐个"声音熄火" | 关闭非必要系统/找到电池 |
|
||||||
|
| 鱼雷 | 战斗消耗 | 装填音效(金属碰撞) | 补给点/残骸搜索 |
|
||||||
|
| 诱饵 | 战斗消耗 | 发射后持续噪音 | 制作(消耗金属+电力) |
|
||||||
|
| 船员健康 | 事件/攻击 | 船员语音报告(声音疲惫/痛苦) | 医疗包/休息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 触控交互设计
|
||||||
|
|
||||||
|
### 3.1 驾驶模式手势
|
||||||
|
|
||||||
|
| 手势 | 功能 | 音频反馈 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 左侧 ↑↓ 滑动 | 下潜/上浮 | 气压变化声(上浮时耳膜感) |
|
||||||
|
| 右侧 ←→ 滑动 | 左转/右转 | 引擎转向声 |
|
||||||
|
| 双指一起 ↑ 滑 | 加速 | 引擎转速提升声 |
|
||||||
|
| 双指一起 ↓ 滑 | 减速/停止 | 引擎转速下降声 |
|
||||||
|
| 单指点击屏幕中央 | 发射声呐 Ping | Ping 发射音 + 等待回声 |
|
||||||
|
| 长按屏幕右侧 | 发射鱼雷 | 鱼雷管发射声 |
|
||||||
|
| 长按屏幕左侧 | 投放诱饵 | 诱饵启动噪音 |
|
||||||
|
| 三指点击 | 紧急上浮 | 警报声 + 急速水流声 |
|
||||||
|
|
||||||
|
### 3.2 系统管理手势
|
||||||
|
|
||||||
|
| 手势 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| 双指 ← 滑 | 切换到电力管理面板(AI 语音播报各系统用电) |
|
||||||
|
| 双指 → 滑 | 切换到损伤控制面板 |
|
||||||
|
| 在面板中 ↑↓ 滑 | 选择子系统 → 当前状态语音播报 |
|
||||||
|
| 在面板中点按 | 开启/关闭/修复所选子系统 |
|
||||||
|
| 双指长按 | 切换手动/自动驾驶模式 |
|
||||||
|
|
||||||
|
### 3.3 触觉反馈表
|
||||||
|
|
||||||
|
| 事件 | 触觉模式 |
|
||||||
|
|------|---------|
|
||||||
|
| 声呐 Ping 发出 | 单短振 |
|
||||||
|
| 声呐回声返回(近) | 快速双振 |
|
||||||
|
| 声呐回声返回(远) | 间隔双振 |
|
||||||
|
| 船体受到攻击 | 剧烈连续振 + 方向性(模拟撞击方向) |
|
||||||
|
| 深度警告(超过安全深度) | 逐渐增强的脉动振 |
|
||||||
|
| 生命维持警告 | 与心率同步的脉动振 |
|
||||||
|
| 无线电来电 | 电话式振动(与对方声音同步前振) |
|
||||||
|
| 生物接近(被动声呐检测) | 间距逐渐缩短的脉冲振 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 音频设计
|
||||||
|
|
||||||
|
### 4.1 环境音频层次
|
||||||
|
|
||||||
|
```
|
||||||
|
Layer 5 ┃ 音乐(仅在关键时刻进入,其余时间无音乐)
|
||||||
|
Layer 4 ┃ 船员对话 / 无线电通讯
|
||||||
|
Layer 3 ┃ 潜艇内部机械声(引擎、生命维持、船体应力)
|
||||||
|
Layer 2 ┃ 主动声呐(Ping 和回声)
|
||||||
|
Layer 1 ┃ 外部环境(水声、生物声、海流声)
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心设计原则**:大多数时间不播放音乐。深海恐怖的来源之一就是"除了潜艇的机械声和自己的呼吸声,外面一片死寂"。当音乐真正进入时,其对情绪的影响会因稀缺性而加倍。
|
||||||
|
|
||||||
|
### 4.2 深度分层音效
|
||||||
|
|
||||||
|
| 深度 | 水压音 | 船体声音 | 生物活动 |
|
||||||
|
|------|--------|---------|---------|
|
||||||
|
| 0-200m(透光层) | 轻微水流声 | 正常 | 丰富生物声(鲸歌、鱼群) |
|
||||||
|
| 200-1000m(暮光层) | 水流声减弱,低频增强 | 偶尔应力声 | 生物声减少,出现异常 |
|
||||||
|
| 1000-3000m(深海层) | 几乎无声,极低频底噪 | 频繁应力声 | 稀少但大型的生物 |
|
||||||
|
| 3000m+(深渊层) | 绝对静默的压迫感 | 持续应力声,偶尔巨响 | 未知声源 |
|
||||||
|
|
||||||
|
### 4.3 生物声学设计
|
||||||
|
|
||||||
|
| 生物代号 | 学名概念 | 标志性声音 | 行为特征 |
|
||||||
|
|---------|---------|-----------|---------|
|
||||||
|
| "回声鲸" | 巨型滤食生物 | 低频吟唱(类似蓝鲸但更慢) | 和平,可跟随 |
|
||||||
|
| "荧鱿群" | 小型发光鱿鱼 | 密集的噼啪声 | 吸引大型捕食者 |
|
||||||
|
| "骨甲鲨" | 甲壳类掠食者 | 甲壳撞击声 + 高频尖叫 | 攻击性,速度快 |
|
||||||
|
| "深渊利维坦" | 顶级掠食者 | 声呐回声被扭曲吸收 | 无法通过声呐探测 |
|
||||||
|
| "歌者" | 未知智慧生物 | 复杂旋律,不断变化 | 好奇,会跟随潜艇 |
|
||||||
|
| "静默者" | 完全无声的生物 | 无 | 唯一线索:其他生物突然安静 |
|
||||||
|
|
||||||
|
### 4.4 无线电通讯系统
|
||||||
|
|
||||||
|
**通讯角色**:
|
||||||
|
|
||||||
|
| 呼号 | 身份 | 声音特征 | 作用 |
|
||||||
|
|------|------|---------|------|
|
||||||
|
| "海面" | 基地指挥官 | 中年男声,沉稳 | 下达任务、提供情报 |
|
||||||
|
| "声呐官" | 声呐分析专家 | 年轻女声,精确 | 帮助分析声呐信号 |
|
||||||
|
| "工程师" | 潜艇维护专家 | 中年男声,沙哑 | 指导修复操作 |
|
||||||
|
| "???" | 未知信号源 | 合成般的失真声音 | 剧情的核心谜团 |
|
||||||
|
|
||||||
|
**无线电信道状态**:
|
||||||
|
|
||||||
|
| 状态 | 音频表现 | 触发条件 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 正常 | 轻微静噪 | 默认 |
|
||||||
|
| 干扰 | 白噪音增强,声音断续 | 进入特定区域/生物附近 |
|
||||||
|
| 劫持 | 未知声音插入频道 | 剧情触发 |
|
||||||
|
| 中断 | 完全静默 | 深度过大/设备故障 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 叙事与世界观
|
||||||
|
|
||||||
|
### 5.1 背景
|
||||||
|
|
||||||
|
> 2047 年,深海采矿站"波塞冬-7"在 3800 米深处失联。你是救援潜艇"回声号"的驾驶员,奉命前往调查。但在接近目标时,一次不明原因的声呐脉冲冲击摧毁了所有电子显示屏。你只能在声呐和无线电的辅助下,继续深入。
|
||||||
|
|
||||||
|
### 5.2 主线结构
|
||||||
|
|
||||||
|
| 章节 | 地点 | 核心事件 | 恐怖元素 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| 一·下降 | 深海层 | 接近采矿站,遭遇骨甲鲨 | 首次战斗,声音定位 |
|
||||||
|
| 二·死寂 | 采矿站外围 | 发现采矿站,全站无生命迹象 | 真空般的死寂 |
|
||||||
|
| 三·录音 | 采矿站内部 | 找到船员留下的最后录音 | 录音内容揭示可怕真相 |
|
||||||
|
| 四·深渊 | 海沟 | 被迫进入海沟躲避利维坦 | 绝对黑暗,声呐盲区 |
|
||||||
|
| 五·歌者 | 深海平原 | 遭遇"歌者"族群 | 非暴力但更让人不安的接触 |
|
||||||
|
| 六·真相 | 深渊之底 | 发现波塞冬-7 挖出了什么 | 真相揭示 |
|
||||||
|
| 七·抉择 | 上浮通道 | 广播真相或沉默逃离 | 最终选择 |
|
||||||
|
|
||||||
|
### 5.3 结局
|
||||||
|
|
||||||
|
| 结局 | 条件 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| 曝光 | 收集足够证据 + 成功通讯 | 真相广播给全世界 |
|
||||||
|
| 封印 | 未收集全部证据 | 引爆采矿站,将秘密永远封存 |
|
||||||
|
| 同化 | 被歌者捕获 | 留在深海,成为它们的一员 |
|
||||||
|
| 逃离 | 放弃一切,全速上浮 | 逃出生天,但秘密仍在那 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 技术规格
|
||||||
|
|
||||||
|
| 参数 | 规格 |
|
||||||
|
|------|------|
|
||||||
|
| 空间音频 | Resonance Audio + 自定义深海声学模型 |
|
||||||
|
| 混响/延迟 | 水下声速模型(~1500m/s,5 倍空气声速) |
|
||||||
|
| 声呐模拟 | 物理声学射线追踪(简化版,12 条射线) |
|
||||||
|
| 音频延迟 | < 40ms |
|
||||||
|
| 安装包 | < 1GB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 无障碍
|
||||||
|
|
||||||
|
- 所有状态信息通过周期性语音简报播报(可调频率:30s/60s/120s/手动)
|
||||||
|
- 生物接近警报有振动和音频双重提示
|
||||||
|
- 完整音频教程(约 15 分钟),在安全环境中学习所有操作
|
||||||
|
- 兼容 VoiceOver / TalkBack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 开发路线图
|
||||||
|
|
||||||
|
| 阶段 | 周期 | 目标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 预开发 | 第 1-4 周 | 水下音频物理原型、声呐射线追踪原型 |
|
||||||
|
| Alpha | 第 5-14 周 | 前三章可玩、潜艇操作完善 |
|
||||||
|
| Beta | 第 15-22 周 | 全部章节、配音、生物音效 |
|
||||||
|
| 打磨 | 第 23-28 周 | 恐怖节奏调优、无障碍测试 |
|
||||||
|
| 发布 | 第 29-30 周 | 上线 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*GDD-004 深渊电台 | 文档版本 v1.0*
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
# GDD-006:回声神探
|
||||||
|
|
||||||
|
> 版本:v1.0 | 日期:2026-05-12 | 状态:策划阶段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 游戏概述
|
||||||
|
|
||||||
|
### 1.1 核心信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 游戏名称 | 回声神探(Echo Detective) |
|
||||||
|
| 类型 | 音频推理 / 侦探冒险 |
|
||||||
|
| 平台 | iOS / Android |
|
||||||
|
| 技术栈 | Unity + Resonance Audio |
|
||||||
|
| 目标受众 | 推理爱好者、侦探游戏玩家、播客听众 |
|
||||||
|
| 游戏时长 | 主线 6-8 小时(5 个案情) |
|
||||||
|
| 定价模式 | 买断制 $7.99,含全部案件 |
|
||||||
|
|
||||||
|
### 1.2 Elevator Pitch
|
||||||
|
|
||||||
|
> 曾是最出色的凶案组警探,在一次任务中失明后被迫退休。但你的耳朵比任何人的眼睛都更敏锐。《回声神探》让你在完全黑暗中重听案发现场的音频记录,在声音的缝隙中寻找被所有人忽略的真相。
|
||||||
|
|
||||||
|
### 1.3 核心体验目标
|
||||||
|
|
||||||
|
- **用耳朵推理的独特快感**:线索完全隐藏在声音中,发现关键证据的"啊哈时刻"
|
||||||
|
- **沉浸式罪案剧体验**:每个案件像一集高质量罪案播客
|
||||||
|
- **无力感与掌控感**:无法看到证据,但能听到别人听不见的东西
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心玩法
|
||||||
|
|
||||||
|
### 2.1 游戏循环
|
||||||
|
|
||||||
|
```
|
||||||
|
接收案件 → 听取案发现场音频档案 → 标记线索 → 走访证人(音频)→ 推理 → 指认真凶
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心机制
|
||||||
|
|
||||||
|
#### 2.2.1 音频场景档案系统
|
||||||
|
|
||||||
|
**档案构成**:
|
||||||
|
- 每个案发现场是一段 3-8 分钟的 3D 空间音频记录
|
||||||
|
- 包含:环境音、人物对话、背景事件音、看似无关的背景声
|
||||||
|
- 在同一空间中录制,玩家可以"移动聆听焦点"
|
||||||
|
|
||||||
|
**聆听控制**:
|
||||||
|
|
||||||
|
| 操作 | 效果 |
|
||||||
|
|------|------|
|
||||||
|
| 单指旋转 | 转动头部(改变聆听方向) |
|
||||||
|
| 双指缩放 | 拉近/拉远聆听距离(聚焦某个声源) |
|
||||||
|
| 单指点击声源 | 标记为线索 |
|
||||||
|
| 双击 | 暂停/继续播放 |
|
||||||
|
| 长按 | 慢放模式(25% 速度) |
|
||||||
|
|
||||||
|
**音频分析工具**(随剧情解锁):
|
||||||
|
|
||||||
|
| 工具 | 功能 | 解锁案件 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 基础聆听 | 正常播放和分析 | 初始 |
|
||||||
|
| 慢放 | 0.25x 速度播放,保留音高 | 初始 |
|
||||||
|
| 频谱视图(音频化) | 将频谱转为音频描述 | 案件二 |
|
||||||
|
| 声源分离 | AI 将不同声源分离为独立音轨 | 案件三 |
|
||||||
|
| 背景噪音过滤 | 降低环境底噪,突出人声 | 案件四 |
|
||||||
|
| 时间轴标记 | 在音频时间轴上标记和对比 | 案件五 |
|
||||||
|
|
||||||
|
#### 2.2.2 线索系统
|
||||||
|
|
||||||
|
**线索类型**:
|
||||||
|
|
||||||
|
| 类型 | 示例 | 标记方式 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 对话线索 | 证词中的矛盾、口误 | 点击声源 |
|
||||||
|
| 环境线索 | 钟声、火车声、鸟鸣 | 点击声源 |
|
||||||
|
| 情绪线索 | 声音颤抖、呼吸急促、停顿异常 | 长按标记时间段 |
|
||||||
|
| 位置线索 | 声音的空间位置与实际陈述不符 | 旋转定位后点击 |
|
||||||
|
| 时间线索 | 音频中可推断时间的声音线索 | 点击声源 |
|
||||||
|
|
||||||
|
**线索面板**(纯音频 UI):
|
||||||
|
- 已收集的线索以简短音频标签存储
|
||||||
|
- 点击线索标签听到该线索的原始音频片段
|
||||||
|
- 线索之间可以"连接"(拖拽手势),形成推理链
|
||||||
|
- 连接的线索会自动生成一段语音推理摘要
|
||||||
|
|
||||||
|
#### 2.2.3 走访与审讯
|
||||||
|
|
||||||
|
- 在案件档案中发现问题后,可以去"走访"证人
|
||||||
|
- 走访是纯音频对话场景,玩家通过提问(触觉选择问题)推进
|
||||||
|
- 审讯阶段,玩家需要在对话中抛出关键证据
|
||||||
|
- 抛证据的时机:当证人出现"声音异常"(微弱的颤抖/停顿/呼吸变化)的瞬间
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 触控交互设计
|
||||||
|
|
||||||
|
### 3.1 音频档案浏览模式
|
||||||
|
|
||||||
|
| 手势 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| 单指旋转(顺时针/逆时针) | 水平旋转聆听视角(360° 声场) |
|
||||||
|
| 单指 ↑↓ 滑 | 垂直调整聆听角度 |
|
||||||
|
| 双指捏合/展开 | 缩小/放大聆听范围 |
|
||||||
|
| 点击声源位置 | 标记线索 |
|
||||||
|
| 双击任意处 | 暂停/播放 |
|
||||||
|
| 长按任意处 | 进入慢放模式 |
|
||||||
|
| 双指 ← 滑 | 后退 10 秒 |
|
||||||
|
| 双指 → 滑 | 前进 10 秒 |
|
||||||
|
| 三指点击 | 循环播放当前选中的 5 秒片段 |
|
||||||
|
|
||||||
|
### 3.2 走访/审讯模式
|
||||||
|
|
||||||
|
| 手势 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| 屏幕上半区点击 | 选择问题 A |
|
||||||
|
| 屏幕下半区点击 | 选择问题 B |
|
||||||
|
| 长按 | 保持沉默(让证人继续说) |
|
||||||
|
| 双指 ↑ 滑 | 抛出当前选中的证据 |
|
||||||
|
| 双指 ↓ 滑 | 切换选中的证据 |
|
||||||
|
| 左滑 | 结束当前走访 |
|
||||||
|
|
||||||
|
### 3.3 触觉反馈
|
||||||
|
|
||||||
|
| 事件 | 触觉 |
|
||||||
|
|------|------|
|
||||||
|
| 发现可标记线索 | 提示性短振 |
|
||||||
|
| 标记成功 | 确认双振 |
|
||||||
|
| 线索矛盾(推理冲突) | 警告重振 |
|
||||||
|
| 推理正确 | 全身共鸣振 |
|
||||||
|
| 证人紧张/说谎 | 与心率同步的微脉动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 音频设计
|
||||||
|
|
||||||
|
### 4.1 音频档案制作规范
|
||||||
|
|
||||||
|
**录制方式**:
|
||||||
|
- 使用 Ambisonic 麦克风(一阶 Ambisonic,4 通道)进行实地录制
|
||||||
|
- 演员在录制空间中实际走位表演
|
||||||
|
- 环境音单独录制为 Ambisonic 环境层
|
||||||
|
|
||||||
|
**后期处理**:
|
||||||
|
- Ambisonic 解码为双耳立体声(Binaural)
|
||||||
|
- 声源分离使用 AI 辅助工具(Spleeter / Demucs)
|
||||||
|
- 关键线索音效在混音时做微弱增强(不明显,但利于分析)
|
||||||
|
|
||||||
|
### 4.2 语音内容
|
||||||
|
|
||||||
|
| 元素 | 数量 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 案件音频档案 | 5 个 × 3-8 分钟 | 每个案发现场的完整音频 |
|
||||||
|
| 证人走访对话 | 每个案件 3-5 个证人 × 5-10 分钟 | 分支对话 |
|
||||||
|
| 审讯对话 | 每个案件 2-3 场审讯 | 关键证据抛出 |
|
||||||
|
| 旁白/内心独白 | ~3000 字 | 主角的推理独白 |
|
||||||
|
| 搭档对话 | ~5000 字 | 与助手/搭档的对话 |
|
||||||
|
|
||||||
|
### 4.3 动态音乐
|
||||||
|
|
||||||
|
- 在音频档案聆听时通常不播放音乐
|
||||||
|
- 在推理阶段(线索连接)播放紧张的弦乐
|
||||||
|
- 在指认阶段播放高张力的打击乐
|
||||||
|
- 案件解决后播放释放性的钢琴收束
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 叙事与世界观
|
||||||
|
|
||||||
|
### 5.1 主角设定
|
||||||
|
|
||||||
|
> 程响,42 岁,前刑警支队重案组组长。两年前在一次人质解救任务中被闪光弹近距离爆炸灼伤视网膜,永久失明。退居二线后,他在警局担任"音频分析顾问"——一个为他量身定做但没人当真觉得有用的职位。直到第一起案件。
|
||||||
|
|
||||||
|
### 5.2 五起案件
|
||||||
|
|
||||||
|
| 案件 | 名称 | 核心音频线索 | 时长 |
|
||||||
|
|------|------|------------|------|
|
||||||
|
| 一 | 《雨夜公寓》 | 不在场证明中的地铁声与地理位置矛盾 | 60-90min |
|
||||||
|
| 二 | 《沉默的证人》 | 盲人目击者的证词中某个声音的物理来源不合理 | 60-90min |
|
||||||
|
| 三 | 《录音带》 | 一段被篡改的录音中的微弱原始音轨 | 90-120min |
|
||||||
|
| 四 | 《火灾之后》 | 火灾现场录音中的爆燃声揭示起火点不是官方结论 | 90-120min |
|
||||||
|
| 五 | 《灯塔》 | 所有前四案的线索串联,指向两年前那起闪光弹事件 | 120-150min |
|
||||||
|
|
||||||
|
### 5.3 贯穿性叙事
|
||||||
|
|
||||||
|
**明线**:解决五起案件
|
||||||
|
**暗线**:两年前的闪光弹事件并非意外——有人在掩盖什么。每解决一个案件,主角距离自己的真相更近一步。
|
||||||
|
|
||||||
|
### 5.4 结局
|
||||||
|
|
||||||
|
| 结局 | 条件 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| 正义 | 案件五中指认真凶 | 真相大白,但代价巨大 |
|
||||||
|
| 复仇 | 指认真凶后用私刑 | 主角入狱 |
|
||||||
|
| 和解 | 发现真相后选择不追究 | 主角终于放下了过去 |
|
||||||
|
| 深渊 | 未能解决案件五 | 主角被真凶设计陷害 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 技术规格
|
||||||
|
|
||||||
|
| 参数 | 规格 |
|
||||||
|
|------|------|
|
||||||
|
| 空间音频格式 | 一阶 Ambisonic → Binaural 渲染 |
|
||||||
|
| 音频引擎 | Unity + Resonance Audio |
|
||||||
|
| 声源分离 | AI 辅助离线处理(非实时) |
|
||||||
|
| 音频采样率 | 48kHz / 24bit |
|
||||||
|
| 安装包 | < 1.5GB(大量语音和 Ambisonic 音频) |
|
||||||
|
| 最低系统 | iOS 14 / Android 10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 无障碍
|
||||||
|
|
||||||
|
- 完整音频教程
|
||||||
|
- 所有线索管理通过音频 UI 完成
|
||||||
|
- 提供"提示系统"(可选):在玩家卡住超过 15 分钟时,搭档会在耳机里提示方向
|
||||||
|
- 兼容 VoiceOver / TalkBack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 开发路线图
|
||||||
|
|
||||||
|
| 阶段 | 周期 | 目标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 预开发 | 第 1-4 周 | Ambisonic 工作流、音频档案浏览原型 |
|
||||||
|
| Alpha | 第 5-14 周 | 案件一、二完成 |
|
||||||
|
| Beta | 第 15-24 周 | 全部五案完成、配音 |
|
||||||
|
| 打磨 | 第 25-30 周 | 推理节奏调优、本地化 |
|
||||||
|
| 发布 | 第 31-32 周 | 上线 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*GDD-006 回声神探 | 文档版本 v1.0*
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
# GDD-007:冥河摆渡人
|
||||||
|
|
||||||
|
> 版本:v1.0 | 日期:2026-05-12 | 状态:策划阶段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 游戏概述
|
||||||
|
|
||||||
|
### 1.1 核心信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 游戏名称 | 冥河摆渡人(The Ferryman) |
|
||||||
|
| 类型 | 音频叙事 RPG |
|
||||||
|
| 平台 | iOS / Android |
|
||||||
|
| 技术栈 | Unity + FMOD |
|
||||||
|
| 目标受众 | 叙事游戏爱好者、文学性体验追求者、播客听众 |
|
||||||
|
| 游戏时长 | 4-5 小时(完整通关) |
|
||||||
|
| 定价模式 | 买断制 $5.99 |
|
||||||
|
|
||||||
|
### 1.2 Elevator Pitch
|
||||||
|
|
||||||
|
> 你是冥河上的摆渡人,在过去与未来之间的水面上,渡送一个又一个灵魂。每个灵魂上船后都会讲述自己的故事——而你需要在船靠岸之前,决定他们是去往"安息之地",还是"轮回之渊"。《冥河摆渡人》是一段关于倾听、判断与放下的旅程。
|
||||||
|
|
||||||
|
### 1.3 核心体验目标
|
||||||
|
|
||||||
|
- **聆听的沉浸感**:每一个灵魂的故事都是高品质的独白剧场
|
||||||
|
- **道德判断的沉重**:没有标准答案,每一次选择都让你思考
|
||||||
|
- **情感共鸣**:目标是在每一次渡河结束后让玩家静默 30 秒
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心玩法
|
||||||
|
|
||||||
|
### 2.1 游戏循环
|
||||||
|
|
||||||
|
```
|
||||||
|
摇船出航 → 灵魂上船 → 灵魂讲述故事 → 玩家回应/引导 → 抵达对岸 → 做出判决
|
||||||
|
↑ ↓
|
||||||
|
└─────────── 渡送灵魂的数量和选择影响摆渡人自身的命运 ←─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心机制
|
||||||
|
|
||||||
|
#### 2.2.1 渡河叙事系统
|
||||||
|
|
||||||
|
**渡河的三个阶段**:
|
||||||
|
|
||||||
|
| 阶段 | 河面声音特征 | 叙事内容 | 玩家交互 |
|
||||||
|
|------|------------|---------|---------|
|
||||||
|
| 上船 | 现世之水声清澈 | 灵魂自我介绍,初步叙述 | 基本询问 |
|
||||||
|
| 深水 | 水声逐渐深沉,混响增加 | 故事核心,情感高潮 | 关键追问,安抚/刺激 |
|
||||||
|
| 靠岸 | 水声分叉(安息之水轻柔/轮回之水湍急) | 灵魂的最后陈述 | 做出最终判决 |
|
||||||
|
|
||||||
|
**玩家在渡河中的行动**:
|
||||||
|
|
||||||
|
| 行动 | 效果 | 时机 |
|
||||||
|
|------|------|------|
|
||||||
|
| 摇铃(点击) | 示意灵魂继续讲述 | 灵魂停顿时 |
|
||||||
|
| 重摇铃(双击) | 打断灵魂,追问 | 灵魂回避某个话题时 |
|
||||||
|
| 点亮引魂灯(长按) | 安抚情绪激动的灵魂 | 灵魂声音出现明显痛苦时 |
|
||||||
|
| 撑篙加速(双指滑动) | 加速渡河进度 | 任何时间(但可能让灵魂感到被催促) |
|
||||||
|
| 放下撑篙(停止滑动) | 放慢渡河,给灵魂更多时间 | 灵魂需要更多时间时 |
|
||||||
|
| 转向(← → 滑动) | 改变航道,发现支流中的隐藏灵魂 | 听到支流方向传来声音时 |
|
||||||
|
|
||||||
|
#### 2.2.2 判决系统
|
||||||
|
|
||||||
|
**判决的两个终点**:
|
||||||
|
|
||||||
|
| 终点 | 音频特征 | 含义 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 安息之地 | 温暖风声、远处有轻柔合唱 | 灵魂获得平静,不再轮回 |
|
||||||
|
| 轮回之渊 | 空灵水声、时间倒流般的音效 | 灵魂重入轮回,再次经历人生 |
|
||||||
|
|
||||||
|
**判决依据(无标准答案)**:
|
||||||
|
- 灵魂是否从自己的故事中学到了什么?
|
||||||
|
- 灵魂是否真正面对了自己的遗憾?
|
||||||
|
- 灵魂是否准备好"放下"?
|
||||||
|
|
||||||
|
判决后,玩家听到灵魂前往对应终点的声音——安息是渐渐消失的脚步声 + 温柔叹息;轮回是水声吞没一切 + 婴儿啼哭的遥远回声。
|
||||||
|
|
||||||
|
#### 2.2.3 摆渡人自己的故事(暗线)
|
||||||
|
|
||||||
|
随着渡送的灵魂数量增加,摆渡人的记忆碎片逐渐浮现。玩家通过碎片拼凑出:摆渡人自己也是一个灵魂,而且他忘记了自己死亡的真相。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 触控交互设计
|
||||||
|
|
||||||
|
| 手势 | 功能 | 触觉反馈 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 点击屏幕 | 摇铃(催促/回应) | 铃铛轻振 |
|
||||||
|
| 双击 | 重摇铃(打断/追问) | 铃铛重振 |
|
||||||
|
| 长按 | 点亮引魂灯(安抚) | 持续温暖微振 |
|
||||||
|
| 双指 ↑ 滑 | 撑篙加速 | 节奏性划水振 |
|
||||||
|
| 双指停止 | 放慢/停船 | 振动消失 |
|
||||||
|
| ← 滑 | 转向左侧支流 | 转向倾斜振 |
|
||||||
|
| → 滑 | 转向右侧支流 | 转向倾斜振 |
|
||||||
|
| 三指 ↑ 滑 | 判定:安息之地 | 上升音阶 + 上行振动 |
|
||||||
|
| 三指 ↓ 滑 | 判定:轮回之渊 | 下降音阶 + 下行振动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 音频设计
|
||||||
|
|
||||||
|
### 4.1 冥河的声景
|
||||||
|
|
||||||
|
```
|
||||||
|
基础层 ┃ 冥河水声(持续,根据深度变化)
|
||||||
|
环境层 ┃ 远处不明低吟、水滴、偶尔的风声
|
||||||
|
叙事层 ┃ 灵魂的独白(核心音频内容)
|
||||||
|
交互层 ┃ 铃铛、船篙划水、引魂灯嗡鸣
|
||||||
|
音乐层 ┃ 仅在灵魂故事的高潮点和判决后进入
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 灵魂独白的音频处理
|
||||||
|
|
||||||
|
**声音的"过渡"效果**:
|
||||||
|
- 灵魂刚上船时:声音清晰,接近正常录音
|
||||||
|
- 进入深水区:混响逐渐增强,声音开始带有轻微回声
|
||||||
|
- 接近对岸:混响最大化,声音仿佛来自很遥远的地方
|
||||||
|
- 判决后:根据目的地,声音被处理为"安息"(干净、温暖)或"轮回"(空灵、失真)
|
||||||
|
|
||||||
|
**制作方式**:
|
||||||
|
- 录音:48kHz / 24bit / Mono,演员在录音棚中按剧情走向表演
|
||||||
|
- 实时处理:通过 FMOD 参数控制混响量、EQ、延迟
|
||||||
|
- 关键台词触发特殊处理(如声音颤抖被轻微增强)
|
||||||
|
|
||||||
|
### 4.3 二十一个灵魂
|
||||||
|
|
||||||
|
| # | 灵魂身份 | 故事主题 | 配音时长 |
|
||||||
|
|---|---------|---------|---------|
|
||||||
|
| 1 | 老农 | 与土地的羁绊 | 5min |
|
||||||
|
| 2 | 年轻士兵 | 战争的荒谬 | 7min |
|
||||||
|
| 3 | 被遗弃的母亲 | 亲情与牺牲 | 8min |
|
||||||
|
| 4 | 小偷 | 贫穷与选择 | 6min |
|
||||||
|
| 5 | 富商 | 财富与空虚 | 7min |
|
||||||
|
| 6 | 音乐家 | 未完成的乐章 | 9min |
|
||||||
|
| 7 | 小孩 | 对死亡的困惑 | 5min |
|
||||||
|
| 8 | 学者 | 知识与智慧的区别 | 8min |
|
||||||
|
| 9 | 演员 | 面具与真实自我 | 7min |
|
||||||
|
| 10 | 厨师 | 味觉记忆 | 6min |
|
||||||
|
| 11 | 年轻女孩 | 第 3 个灵魂的女儿 | 10min |
|
||||||
|
| 12 | 船长 | 海与孤独 | 7min |
|
||||||
|
| 13 | 诗人 | 语词与沉默 | 9min |
|
||||||
|
| 14 | 医者 | 无力回天的病 | 8min |
|
||||||
|
| 15 | 囚犯 | 罪与赎 | 7min |
|
||||||
|
| 16 | 舞蹈家 | 身体与灵魂 | 6min |
|
||||||
|
| 17 | 教师 | 未说出口的道歉 | 8min |
|
||||||
|
| 18 | 孤儿 | 从未被爱 | 9min |
|
||||||
|
| 19 | 国王 | 权力与责任 | 10min |
|
||||||
|
| 20 | 摆渡人(前任) | 渡人者如何自渡 | 12min |
|
||||||
|
| 21 | 摆渡人(自己) | 真相 | 15min |
|
||||||
|
|
||||||
|
### 4.4 灵魂间关联
|
||||||
|
|
||||||
|
| 关联 | 涉及灵魂 |
|
||||||
|
|------|---------|
|
||||||
|
| 母女 | #3(母亲)+ #11(女儿) |
|
||||||
|
| 医患 | #14(医者)+ #20(前任摆渡人) |
|
||||||
|
| 雇佣 | #5(富商)+ #4(小偷)——小偷是因偷了富商的东西而入狱 |
|
||||||
|
| 战争 | #2(士兵)+ #13(诗人)——诗人是士兵的笔友 |
|
||||||
|
| 因果 | #4(小偷)+ #3(母亲)——小偷的盗窃间接导致了母亲的悲剧 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 叙事设计
|
||||||
|
|
||||||
|
### 5.1 叙事节奏
|
||||||
|
|
||||||
|
- 每个灵魂的渡送约 5-15 分钟
|
||||||
|
- 渡送完 4-5 个灵魂后,出现一段摆渡人自己的记忆碎片
|
||||||
|
- 渡送完 10 个灵魂后,摆渡人的记忆碎片变得完整
|
||||||
|
- 渡送完 20 个灵魂后,前任摆渡人出现
|
||||||
|
- 第 21 个灵魂是摆渡人自己
|
||||||
|
|
||||||
|
### 5.2 情感弧线
|
||||||
|
|
||||||
|
```
|
||||||
|
第 1-5 魂:建立世界观,情感强度中等
|
||||||
|
第 6-10 魂:情感强度上升,出现道德模糊的故事
|
||||||
|
第 10 魂(关联剧情高峰):母女关联揭示
|
||||||
|
第 11-15 魂:情感强度最高,每个故事都很重
|
||||||
|
第 16-20 魂:摆渡人记忆加速浮现,渡送节奏加快
|
||||||
|
第 20 魂:前任摆渡人的故事,一切开始联系
|
||||||
|
第 21 魂:摆渡人直面自己的死亡
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 结局
|
||||||
|
|
||||||
|
| 结局 | 条件 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| 圆满 | 帮助 20 个灵魂中 ≥ 15 个正确判断 | 摆渡人记起一切,安然走向安息 |
|
||||||
|
| 未尽 | 帮助 10-14 个 | 摆渡人留在冥河,成为新的引路人 |
|
||||||
|
| 迷惑 | 帮助 5-9 个 | 摆渡人选择轮回,忘记一切 |
|
||||||
|
| 沉沦 | 帮助 < 5 个 | 摆渡人坠入冥河深处,永远消失 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 技术规格
|
||||||
|
|
||||||
|
| 参数 | 规格 |
|
||||||
|
|------|------|
|
||||||
|
| 音频引擎 | FMOD Studio |
|
||||||
|
| 录音 | 48kHz / 24bit / Mono |
|
||||||
|
| 总配音时长 | ~3 小时 |
|
||||||
|
| 安装包 | < 600MB |
|
||||||
|
| 空间音频 | 不需要(冥河声景为 2D 环绕,非 3D 空间) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 无障碍
|
||||||
|
|
||||||
|
- 完整语音引导教程
|
||||||
|
- 所有操作有音频+触觉反馈
|
||||||
|
- 兼容 VoiceOver / TalkBack
|
||||||
|
- 内容警告:涉及死亡、战争、遗弃等敏感主题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 开发路线图
|
||||||
|
|
||||||
|
| 阶段 | 周期 | 目标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 预开发 | 第 1-2 周 | FMOD 叙事系统原型、独白录音测试 |
|
||||||
|
| Alpha | 第 3-12 周 | 前 10 个灵魂完成 |
|
||||||
|
| Beta | 第 13-20 周 | 全部 21 个灵魂 + 配音 |
|
||||||
|
| 打磨 | 第 21-24 周 | 叙事节奏优化、本地化 |
|
||||||
|
| 发布 | 第 25-26 周 | 上线 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*GDD-007 冥河摆渡人 | 文档版本 v1.0*
|
||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
# GDD-009:心音诊所
|
||||||
|
|
||||||
|
> 版本:v1.0 | 日期:2026-05-12 | 状态:策划阶段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 游戏概述
|
||||||
|
|
||||||
|
### 1.1 核心信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 游戏名称 | 心音诊所(Heart Sound Clinic) |
|
||||||
|
| 类型 | 音频模拟经营 / 心理疗愈 |
|
||||||
|
| 平台 | iOS / Android |
|
||||||
|
| 技术栈 | Unity + FMOD |
|
||||||
|
| 目标受众 | 模拟经营爱好者、心理学爱好者、叙事游戏玩家 |
|
||||||
|
| 游戏时长 | 主线 8-10 小时 |
|
||||||
|
| 定价模式 | 买断制 $7.99 |
|
||||||
|
|
||||||
|
### 1.2 Elevator Pitch
|
||||||
|
|
||||||
|
> 你是一位失明的心理医生。无法观察患者的表情和肢体语言,你只能通过倾听——声音的颤抖、呼吸的节奏、沉默的长度——来判断他们的真实状态。经营你的诊所,接诊形形色色的患者,在看不见的世界里,帮助他们走出心灵的黑暗。
|
||||||
|
|
||||||
|
### 1.3 核心体验目标
|
||||||
|
|
||||||
|
- **"以耳代眼"的专业成就感**:仅凭声音做出精确的心理诊断
|
||||||
|
- **人际关系的微妙张力**:患者的声音变化是唯一线索,稍纵即逝
|
||||||
|
- **伦理思考**:不是每个患者都能被"修好",有时候放手也是一种治疗
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心玩法
|
||||||
|
|
||||||
|
### 2.1 游戏循环
|
||||||
|
|
||||||
|
```
|
||||||
|
预约患者 → 接诊(对话诊疗)→ 诊断 → 制定治疗计划 → 下次复诊 → ……
|
||||||
|
│ │
|
||||||
|
└── 诊所经营(收入/支出/设备升级) ←── 治疗结果反馈 ←─────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心机制
|
||||||
|
|
||||||
|
#### 2.2.1 诊疗系统
|
||||||
|
|
||||||
|
**四类基本回应方式**:
|
||||||
|
|
||||||
|
| 回应类型 | 手势 | 触觉反馈 | 适用场景 |
|
||||||
|
|---------|------|---------|---------|
|
||||||
|
| 共情回应 | ↑ 滑 | 轻柔连续微振 | 患者情绪激动需要安抚 |
|
||||||
|
| 追问深入 | ↓ 滑 | 单次短振 | 患者含糊其辞或回避话题 |
|
||||||
|
| 沉默等待 | 长按(不松) | 无振动,只有诊室环境音 | 给患者自己表达的空间 |
|
||||||
|
| 引导反思 | 双指点击 | 双次短振 | 帮助患者连接事件和情感 |
|
||||||
|
|
||||||
|
**诊疗中的关键声音线索**:
|
||||||
|
|
||||||
|
| 声音异常 | 可能含义 | 应该做什么 |
|
||||||
|
|---------|---------|-----------|
|
||||||
|
| 语速突然加快 | 焦虑、回避某个话题 | 追问深入 |
|
||||||
|
| 语速突然减慢/停顿 | 触及核心情感 | 沉默等待 |
|
||||||
|
| 声音颤抖 | 情绪激动/恐惧 | 共情回应 |
|
||||||
|
| 呼吸急促 | 焦虑发作 | 共情回应 + 引导呼吸 |
|
||||||
|
| 声音突然变轻/变小 | 羞耻/回避 | 温和追问或等待 |
|
||||||
|
| 声音中突然出现哭腔 | 情绪崩溃 | 共情回应 |
|
||||||
|
| 假声/声音不自然 | 伪装/防御 | 多次追问直到破防 |
|
||||||
|
| 长时间的沉默(> 8 秒) | 不同的可能:思考/回避/崩溃 | 根据上下文判断 |
|
||||||
|
| 背景中出现自伤暗示声音 | 危机信号 | 紧急干预 |
|
||||||
|
|
||||||
|
#### 2.2.2 诊断系统
|
||||||
|
|
||||||
|
- 每次接诊结束,玩家口述诊断(麦克风录音,语音转文字)
|
||||||
|
- 系统根据关键词匹配和之前对话中识别的"关键线索"评估诊断准确度
|
||||||
|
- 诊断影响后续治疗计划的制定
|
||||||
|
- 误诊不会直接"game over",但会导致后续治疗效果变差
|
||||||
|
|
||||||
|
#### 2.2.3 治疗计划
|
||||||
|
|
||||||
|
| 治疗方式 | 适用症状 | 音频特征 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| 认知行为疗法 | 焦虑、抑郁 | 结构化对话,引导认知重构 |
|
||||||
|
| 暴露疗法 | 恐惧症、PTSD | 由远及近的声音刺激 |
|
||||||
|
| 正念训练 | 焦虑、压力 | 引导呼吸和身体扫描(全程音频) |
|
||||||
|
| 人际治疗 | 人际关系问题 | 角色扮演对话 |
|
||||||
|
| 药物辅助 | 严重抑郁、双相 | 需转介精神科医生 |
|
||||||
|
|
||||||
|
#### 2.2.4 诊所经营系统
|
||||||
|
|
||||||
|
**诊所周计划**:
|
||||||
|
|
||||||
|
| 时段 | 周一 | 周二 | 周三 | 周四 | 周五 |
|
||||||
|
|------|------|------|------|------|------|
|
||||||
|
| 9:00-10:00 | 患者 A | 患者 C | 复诊 A | 患者 E | 空闲 |
|
||||||
|
| 10:00-11:00 | 患者 B | 复诊 C | 患者 D | 患者 F | 复诊 E |
|
||||||
|
| 11:00-12:00 | 空闲 | 空闲 | 空闲 | 空闲 | 行政 |
|
||||||
|
| 14:00-15:00 | 复诊 B | 患者 G | 空闲 | 复诊 F | 空闲 |
|
||||||
|
| 15:00-16:00 | 空闲 | 复诊 G | 行政 | 空闲 | 行政 |
|
||||||
|
|
||||||
|
**经营要素**:
|
||||||
|
- 诊疗收入:基础诊疗费 + 效果奖金
|
||||||
|
- 支出:诊所租金、设备维护、持续教育费用
|
||||||
|
- 声望系统:高评价 → 更多患者推荐 → 可选择更高难度/高报酬的病案
|
||||||
|
- 设备升级:白噪音发生器(安抚焦虑)、音频分析仪(实时标记患者声音异常)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 触控交互设计
|
||||||
|
|
||||||
|
### 3.1 诊疗模式手势
|
||||||
|
|
||||||
|
| 手势 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| ↑ 滑 | 共情回应 |
|
||||||
|
| ↓ 滑 | 追问深入 |
|
||||||
|
| 长按(> 1s) | 沉默等待 |
|
||||||
|
| 双指点击 | 引导反思 |
|
||||||
|
| ← 滑 | 切换到上一次的回应(撤销) |
|
||||||
|
| → 滑 | 跳过当前患者的一段叙述(仅在复诊时可用) |
|
||||||
|
| 双击 | 给患者倒水(中性动作,缓和气氛) |
|
||||||
|
| 三指长按 | 紧急干预(仅在患者出现危机信号时可用) |
|
||||||
|
|
||||||
|
### 3.2 诊后/经营模式手势
|
||||||
|
|
||||||
|
| 手势 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| ↑↓ 滑 | 浏览诊断选项 / 治疗计划选项 |
|
||||||
|
| 双指 ← 滑 | 切换到上一个患者档案 |
|
||||||
|
| 双指 → 滑 | 切换到下一个患者档案 |
|
||||||
|
| 点击 | 选择/确认当前选项 |
|
||||||
|
| 长按 | 开始录制口述诊断(录音) |
|
||||||
|
| 三指点击 | 打开诊所经营面板 |
|
||||||
|
|
||||||
|
### 3.3 触觉反馈
|
||||||
|
|
||||||
|
| 事件 | 触觉 |
|
||||||
|
|------|------|
|
||||||
|
| 检测到患者声音异常 | 提示性微振 |
|
||||||
|
| 选择了正确的回应 | 确认轻振 |
|
||||||
|
| 选择了不合适的回应 | 轻微振动(不标注"错误",避免破坏沉浸感) |
|
||||||
|
| 患者情绪崩溃 | 不规则重振 |
|
||||||
|
| 患者危机信号 | 紧急连续重振 |
|
||||||
|
| 诊断准确度高(诊后反馈) | 温暖长振 |
|
||||||
|
| 诊断偏离(诊后反馈) | 轻微失落振 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 音频设计
|
||||||
|
|
||||||
|
### 4.1 诊室声景
|
||||||
|
|
||||||
|
```
|
||||||
|
Layer 3 ┃ 音乐(极简,仅在治疗开始/结束时出现)
|
||||||
|
Layer 2 ┃ 诊室环境(钟表滴答、窗外车声、空调声、茶杯声)
|
||||||
|
Layer 1 ┃ 患者声音 + 玩家回应语音 + 环境交互音
|
||||||
|
```
|
||||||
|
|
||||||
|
**钟表滴答的设计**:
|
||||||
|
- 正常滴答节奏:1 秒一次
|
||||||
|
- 关键抉择时刻:滴答声放大 + 主观时间感拉长
|
||||||
|
- 患者情绪紧张时:滴答声与患者语速形成对比
|
||||||
|
|
||||||
|
### 4.2 患者语音设计
|
||||||
|
|
||||||
|
**录音方向**:
|
||||||
|
- 不要"配音量"的表演
|
||||||
|
- 要接近真实心理治疗中的来访者状态
|
||||||
|
- 包括:犹豫、重复、语无伦次、沉默、哭泣、愤怒
|
||||||
|
|
||||||
|
**录音技术**:
|
||||||
|
- 近距离录音(15-20cm)
|
||||||
|
- 保留呼吸声、抽泣声、咽口水声
|
||||||
|
- 使用高灵敏度电容麦克风
|
||||||
|
|
||||||
|
### 4.3 患者角色设计
|
||||||
|
|
||||||
|
| 患者 | 主诉 | 实际情况 | 声音特征 | 难度 |
|
||||||
|
|------|------|---------|---------|------|
|
||||||
|
| 小陈 | 失眠,工作压力大 | 焦虑症 + 职业倦怠 | 语速快,跳跃,偶尔自我打断 | 入门 |
|
||||||
|
| 李姐 | 离婚后情绪低落 | 抑郁症(中度) | 语速极慢,长停顿,声音单调 | 入门 |
|
||||||
|
| 阿杰 | 车祸后不敢开车 | PTSD | 话题涉及车祸时声音明显颤抖 | 中等 |
|
||||||
|
| 小雨 | 自伤行为 | 边缘型人格障碍 | 两个声音人格交替出现 | 中等 |
|
||||||
|
| 老吴 | 总是觉得身体不适 | 躯体化障碍 | 声音正常但内容焦虑 | 中等 |
|
||||||
|
| 周先生 | 与儿子关系破裂 | 自恋型人格特质 | 声音自信但防御性强 | 困难 |
|
||||||
|
| 小王 | 恐惧社交到无法出门 | 社交焦虑 + 广场恐惧 | 声音极小,几乎耳语 | 困难 |
|
||||||
|
| 安安 | 创伤后失忆 | 解离性障碍 | 声音空洞,缺乏情感 | 极难 |
|
||||||
|
|
||||||
|
### 4.4 总语音量估算
|
||||||
|
|
||||||
|
| 内容 | 字数 | 配音时长 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 8 名患者首次接诊 | ~2000 字/人 | ~13min/人 = 1.7h |
|
||||||
|
| 复诊对话 | ~1500 字/人 × 平均 3 次 | ~30min/人 = 4h |
|
||||||
|
| 紧急干预对话 | ~500 字 × 6 场景 | ~20min |
|
||||||
|
| 经营系统语音 | ~100 条 | ~30min |
|
||||||
|
| 玩家角色配音 | 引导语、回应语音等 | ~1h |
|
||||||
|
|
||||||
|
**总计**:约 7.5 小时配音
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 叙事与世界观
|
||||||
|
|
||||||
|
### 5.1 主角设定
|
||||||
|
|
||||||
|
> 林深,38 岁,心理学博士。在一次意外中失去视力后,经历了长达两年的自我重建。他重新开业时发现:失去了视觉后,他对患者声音中微妙情绪的感知反而变得更加敏锐。他的诊所名叫"心音"——不是"心印"或"心因",而是"声音的音"。
|
||||||
|
|
||||||
|
### 5.2 主线叙事
|
||||||
|
|
||||||
|
主线不是强驱动的剧情,而是随着诊所声望提升逐渐解锁的事件:
|
||||||
|
|
||||||
|
1. **开业**:接诊前 3 位患者,建立基础
|
||||||
|
2. **挑战病例**:接待第一位困难患者,面临诊断的不确定性
|
||||||
|
3. **伦理困境**:一个患者的秘密涉及法律问题,是否应该报告?
|
||||||
|
4. **自我投射**:某个患者的经历与林深自己的创伤相似
|
||||||
|
5. **抉择**:诊所扩大(接更多患者但诊疗质量下降)vs 维持现状
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 伦理设计
|
||||||
|
|
||||||
|
### 6.1 核心原则
|
||||||
|
|
||||||
|
- 游戏不鼓励"修好所有人"的心态
|
||||||
|
- 错误的回应方式会导致治疗退步,但不会以"Game Over"的形式惩罚
|
||||||
|
- "转介"(将患者推荐给更适合的医生)是一个有效的、正面的选择
|
||||||
|
- 自杀风险等紧急情况有专门的应对流程(语音引导)
|
||||||
|
|
||||||
|
### 6.2 内容警告
|
||||||
|
|
||||||
|
- 涉及:抑郁、焦虑、创伤、自伤、丧亲、关系暴力等主题
|
||||||
|
- 首次开诊前有语音内容警告
|
||||||
|
- 提供"绕过"选项:触及特定敏感主题时,可选跳过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 技术规格
|
||||||
|
|
||||||
|
| 参数 | 规格 |
|
||||||
|
|------|------|
|
||||||
|
| 音频引擎 | FMOD Studio |
|
||||||
|
| 语音转文字 | 端侧模型(iOS Speech / Android SpeechRecognizer) |
|
||||||
|
| 关键词匹配 | 自定义轻量级 NLP |
|
||||||
|
| 安装包 | < 1GB |
|
||||||
|
| 最低系统 | iOS 14 / Android 10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 无障碍
|
||||||
|
|
||||||
|
- 完整的音频+触觉操作体系
|
||||||
|
- 所有患者档案和治疗计划通过语音播报
|
||||||
|
- 兼容 VoiceOver / TalkBack
|
||||||
|
- 可选择"完全语音控制"模式(用语音指令替代触控手势)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 开发路线图
|
||||||
|
|
||||||
|
| 阶段 | 周期 | 目标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 预开发 | 第 1-3 周 | 对话系统原型、语音分析原型 |
|
||||||
|
| Alpha | 第 4-12 周 | 前 4 名患者完成、核心诊疗循环 |
|
||||||
|
| Beta | 第 13-20 周 | 全部 8 名患者 + 经营系统 |
|
||||||
|
| 打磨 | 第 21-26 周 | 配音录制、伦理内容审查、本地化 |
|
||||||
|
| 发布 | 第 27-28 周 | 上线 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*GDD-009 心音诊所 | 文档版本 v1.0*
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+713
@@ -0,0 +1,713 @@
|
|||||||
|
# 《林夏》音频素材本地制作执行文档
|
||||||
|
|
||||||
|
> **前置文档**:`林夏.md`(游戏设计)、`SOP_AI音频生产.md`(生产规范)
|
||||||
|
> **执行环境**:本机 WSL2 · RTX 5090 32GB · CosyVoice2-0.5B · FFmpeg 7.0
|
||||||
|
> **目标**:按 SOP 规范在本地生产全部音频素材
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 本机资源清单
|
||||||
|
|
||||||
|
| 资源 | 路径 / 状态 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| CosyVoice 2.0 (0.5B) | `/home/xsl/tools/CosyVoice` · conda env: `cosyvoice` | TTS 主力(替代 SOP 中 MiniMax/豆包方案) |
|
||||||
|
| FFmpeg 7.0 | `/usr/local/bin/ffmpeg` | 音频格式转换、响度归一化、听筒效果 |
|
||||||
|
| GPU | RTX 5090 32GB | 跑 CosyVoice 推理 |
|
||||||
|
| MuseTalk | `/home/xsl/work/MuseTalk` | **注意:这是唇形同步工具,不是 TTS** |
|
||||||
|
|
||||||
|
> **SOP 中的 MiniMax / 豆包 API 全部替换为本地 CosyVoice**。音乐(Suno)、音效(ElevenLabs SFX)、环境采样(Freesound)仍走云端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 环境部署
|
||||||
|
|
||||||
|
### 1.1 创建项目目录结构
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/xsl/blind
|
||||||
|
mkdir -p audio/{00_raw/{tts,music,sfx,ambience,ringtone},01_processed/{tts,music,sfx,ambience,ringtone},02_final/{tts,music,sfx,ambience,ringtone},03_qa_reports,99_legal}
|
||||||
|
mkdir -p audio/00_raw/tts/prompt_ref # 存放角色参考音频
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 验证 CosyVoice 环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 激活 conda 环境
|
||||||
|
conda activate cosyvoice
|
||||||
|
|
||||||
|
# 验证依赖
|
||||||
|
cd /home/xsl/tools/CosyVoice
|
||||||
|
python -c "
|
||||||
|
import sys
|
||||||
|
sys.path.append('third_party/Matcha-TTS')
|
||||||
|
from cosyvoice.cli.cosyvoice import CosyVoice2
|
||||||
|
model = CosyVoice2('pretrained_models/CosyVoice2-0.5B')
|
||||||
|
print('可用预训练音色:', model.list_available_spks())
|
||||||
|
print('采样率:', model.sample_rate)
|
||||||
|
print('CosyVoice2 加载成功')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果报错 `ModuleNotFoundError`,先装依赖:
|
||||||
|
```bash
|
||||||
|
conda activate cosyvoice
|
||||||
|
cd /home/xsl/tools/CosyVoice
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 启动 CosyVoice WebUI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate cosyvoice
|
||||||
|
cd /home/xsl/tools/CosyVoice
|
||||||
|
python webui.py --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 `http://localhost:8000`。WebUI 提供 4 种推理模式:
|
||||||
|
|
||||||
|
| 模式 | 说明 | 本项目用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| **预训练音色** | 从内置音色列表选择 | 快速试听、系统 TTS |
|
||||||
|
| **3s极速复刻** | 提供 3-30s 参考音频 + 对应文字 | **主力模式**:为每个角色准备参考音频 |
|
||||||
|
| **跨语种复刻** | 跨语言克隆 | 暂不需要 |
|
||||||
|
| **自然语言控制** | 用文字描述控制语气风格 | CosyVoice2 不支持此模式,改用 instruct2 API |
|
||||||
|
|
||||||
|
### 1.4 验证 FFmpeg
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -version | head -1
|
||||||
|
# 预期输出: ffmpeg version 7.0.2-static
|
||||||
|
|
||||||
|
# 测试 loudnorm 滤镜
|
||||||
|
ffmpeg -f lavfi -i "sine=frequency=440:duration=1" -af "loudnorm=I=-16:TP=-1:LRA=11" -y /tmp/test_loudnorm.wav
|
||||||
|
echo "FFmpeg loudnorm 可用"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 角色参考音频准备(关键步骤)
|
||||||
|
|
||||||
|
CosyVoice2 的"3s极速复刻"需要**每个角色一段 5-20 秒的参考音频**。参考音频的音色决定了最终 TTS 的音色。
|
||||||
|
|
||||||
|
### 2.1 参考音频来源方案
|
||||||
|
|
||||||
|
| 角色 | 音色要求 | 参考音频获取方案 |
|
||||||
|
|---|---|---|
|
||||||
|
| `mom` 妈妈 | 50 岁,温柔克制 | 方案 A:找一位 45-55 岁女性朋友录 15 秒朗读(最佳);方案 B:从影视作品中截取合适片段 |
|
||||||
|
| `azhe` 阿哲 | 27 岁,理性偏冷 | 从播客/有声书截取年轻男声冷淡片段 |
|
||||||
|
| `xiaomei` 小美 | 24 岁,活泼 | 找年轻女性朋友录制,或从 vlog 截取 |
|
||||||
|
| `zhounan` 周南 | 26 岁,温和拘谨 | 播客/有声书截取温和男声 |
|
||||||
|
| `hr` HR 王姐 | 38 岁,知性 | 新闻女主播或播客截取 |
|
||||||
|
| `anan` 安安 | 23 岁,可爱 | 年轻女性活泼语调截取 |
|
||||||
|
| `dorm-a/b/c` | 三个不同年轻女声 | 需要 3 段风格不同的女声参考 |
|
||||||
|
| `delivery` 外卖小哥 | 30 岁,北方腔 | 短视频/影视截取 |
|
||||||
|
| `system` 系统 | 中性 AI 女声 | 用 CosyVoice 预训练音色即可 |
|
||||||
|
| `self-3y` 3年前自己 | 同 xiaomei 基底 | 复用 xiaomei 参考音频,调速度和音调 |
|
||||||
|
|
||||||
|
### 2.2 参考音频制作规格
|
||||||
|
|
||||||
|
- **时长**:5-20 秒(推荐 10 秒左右)
|
||||||
|
- **格式**:WAV,16kHz 以上采样率
|
||||||
|
- **内容**:完整的中文句子(需同时记录对应文字)
|
||||||
|
- **质量**:干声为主,避免底噪、混响、BGM
|
||||||
|
|
||||||
|
存放路径:`audio/00_raw/tts/prompt_ref/{角色代号}_ref.wav`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果参考音频不是 wav 或采样率不对,用 FFmpeg 转换
|
||||||
|
ffmpeg -i input.mp3 -ar 16000 -ac 1 audio/00_raw/tts/prompt_ref/mom_ref.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 创建参考音频记录表
|
||||||
|
|
||||||
|
在 `audio/00_raw/tts/prompt_ref/` 下创建 `ref_manifest.txt`,记录每条参考音频对应的文字:
|
||||||
|
|
||||||
|
```
|
||||||
|
mom_ref.wav | 今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭。
|
||||||
|
azhe_ref.wav | 好的,我知道了,那件事明天再说吧。
|
||||||
|
xiaomei_ref.wav | 哎你猜怎么着,今天路上遇到一个超级搞笑的事!
|
||||||
|
zhounan_ref.wav | 嗯,其实我也不太确定,就是觉得应该跟你说一声。
|
||||||
|
hr_ref.wav | 好的,那我们下周三前把流程走完,有什么问题随时联系我。
|
||||||
|
anan_ref.wav | 天呐天呐你快来!这边超级好玩的!
|
||||||
|
dorm_a_ref.wav | 嘿你们看看我,今天升职了!请大家吃饭!
|
||||||
|
dorm_b_ref.wav | 恭喜恭喜啊,太厉害了,我就知道你行的。
|
||||||
|
dorm_c_ref.wav | 嗯,不错,对了夏夏怎么不说话啊。
|
||||||
|
delivery_ref.wav | 你好你的外卖到了,放门口了啊,麻辣烫注意别洒了。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. TTS 生产流程
|
||||||
|
|
||||||
|
### 3.1 用 WebUI 逐条生产(推荐新手)
|
||||||
|
|
||||||
|
1. 浏览器打开 `http://localhost:8000`
|
||||||
|
2. 选择模式:**3s极速复刻**
|
||||||
|
3. 上传对应角色的参考音频
|
||||||
|
4. 在"prompt文本"中输入参考音频对应的文字
|
||||||
|
5. 在"合成文本"中输入要合成的台词
|
||||||
|
6. 调整速度(对应 SOP §4.2 各角色 Speed 值)
|
||||||
|
7. 点击"生成音频"
|
||||||
|
8. 试听满意后,右键保存音频
|
||||||
|
|
||||||
|
### 3.2 用 Python 脚本批量生产(推荐熟练后)
|
||||||
|
|
||||||
|
创建批量生产脚本 `audio/batch_tts.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
《林夏》TTS 批量生产脚本
|
||||||
|
用法: conda activate cosyvoice && python audio/batch_tts.py
|
||||||
|
"""
|
||||||
|
import os, sys, json, torch, torchaudio
|
||||||
|
|
||||||
|
# 设置路径
|
||||||
|
COSYVOICE_DIR = "/home/xsl/tools/CosyVoice"
|
||||||
|
PROJECT_DIR = "/home/xsl/blind"
|
||||||
|
sys.path.insert(0, COSYVOICE_DIR)
|
||||||
|
sys.path.insert(0, f"{COSYVOICE_DIR}/third_party/Matcha-TTS")
|
||||||
|
|
||||||
|
from cosyvoice.cli.cosyvoice import CosyVoice2
|
||||||
|
from cosyvoice.utils.file_utils import load_wav
|
||||||
|
|
||||||
|
# 加载模型
|
||||||
|
print("正在加载 CosyVoice2 模型...")
|
||||||
|
model = CosyVoice2(f"{COSYVOICE_DIR}/pretrained_models/CosyVoice2-0.5B")
|
||||||
|
print(f"模型加载完成,采样率: {model.sample_rate}")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 角色配置 — 按 SOP §4.2 配置卡填写
|
||||||
|
# speed: 对应 SOP 中的 Speed 值
|
||||||
|
# ref_wav: 参考音频路径
|
||||||
|
# ref_text: 参考音频对应文字
|
||||||
|
# ============================================================
|
||||||
|
ROLES = {
|
||||||
|
"mom": {
|
||||||
|
"speed": 0.92,
|
||||||
|
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/mom_ref.wav",
|
||||||
|
"ref_text": "今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭。",
|
||||||
|
},
|
||||||
|
"azhe": {
|
||||||
|
"speed": 1.05,
|
||||||
|
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/azhe_ref.wav",
|
||||||
|
"ref_text": "好的,我知道了,那件事明天再说吧。",
|
||||||
|
},
|
||||||
|
"xiaomei": {
|
||||||
|
"speed": 1.10,
|
||||||
|
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/xiaomei_ref.wav",
|
||||||
|
"ref_text": "哎你猜怎么着,今天路上遇到一个超级搞笑的事!",
|
||||||
|
},
|
||||||
|
"zhounan": {
|
||||||
|
"speed": 0.95,
|
||||||
|
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/zhounan_ref.wav",
|
||||||
|
"ref_text": "嗯,其实我也不太确定,就是觉得应该跟你说一声。",
|
||||||
|
},
|
||||||
|
"hr": {
|
||||||
|
"speed": 1.00,
|
||||||
|
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/hr_ref.wav",
|
||||||
|
"ref_text": "好的,那我们下周三前把流程走完,有什么问题随时联系我。",
|
||||||
|
},
|
||||||
|
"anan": {
|
||||||
|
"speed": 1.15,
|
||||||
|
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/anan_ref.wav",
|
||||||
|
"ref_text": "天呐天呐你快来!这边超级好玩的!",
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"speed": 1.20,
|
||||||
|
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/delivery_ref.wav",
|
||||||
|
"ref_text": "你好你的外卖到了,放门口了啊,麻辣烫注意别洒了。",
|
||||||
|
},
|
||||||
|
# system 用预训练音色,不走 zero-shot
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 台词表 — 从林夏.md §3 / §5 抄写
|
||||||
|
# 每条格式: (文件名前缀, 角色, 台词, speed_override=None)
|
||||||
|
# ============================================================
|
||||||
|
LINES = [
|
||||||
|
# === 第 01 条 · 21:03 妈妈电话 ===
|
||||||
|
("lv11_2103_mom_call_01", "mom",
|
||||||
|
"今晚煮了你爱吃的排骨,给你拍张照啊。下周五回来一趟,你高中物理老师的儿子,挺好的。"),
|
||||||
|
|
||||||
|
# === 第 02 条 · 21:15 小美三连 ===
|
||||||
|
("lv11_2115_xiaomei_voice_01", "xiaomei", "在吗"),
|
||||||
|
("lv11_2115_xiaomei_voice_02", "xiaomei", "你被甩了我都听说了"),
|
||||||
|
("lv11_2115_xiaomei_voice_03", "xiaomei", "明天晚上喝酒,海底捞旁边那家,不许放鸽子"),
|
||||||
|
|
||||||
|
# === 第 03 条 · 21:20 外卖小哥 ===
|
||||||
|
("lv11_2120_delivery_call_01", "delivery",
|
||||||
|
"你好你的麻辣烫到了,放门口可以吗?"),
|
||||||
|
|
||||||
|
# === 第 04 条 · 21:30 阿哲语音 ===
|
||||||
|
("lv11_2130_azhe_voice_01", "azhe",
|
||||||
|
"嗯……夏夏。我那个AirPods充电盒,好像落你抽屉里了。你方便的时候帮我寄一下吗,工位还要用。地址我等下发你。"),
|
||||||
|
|
||||||
|
# === 第 05 条 · 21:45 HR 王姐 ===
|
||||||
|
("lv11_2145_hr_voice_01", "hr",
|
||||||
|
"夏夏啊,离职流程你下周三之前办完哈,公积金那边记得跟进。辛苦啦,外面有合适的工作我帮你留意。"),
|
||||||
|
|
||||||
|
# === 第 06 条 · 22:00 安安 ===
|
||||||
|
("lv11_2200_anan_voice_01", "anan",
|
||||||
|
"夏夏!我在helens!要不要过来!我请客!"),
|
||||||
|
|
||||||
|
# === 第 10 条 · 23:00 妈妈文字 ===
|
||||||
|
("lv11_2300_mom_text_01", "mom", "睡了吗?"),
|
||||||
|
|
||||||
|
# === 第 11 条 · 23:15 阿哲电话 ===
|
||||||
|
("lv11_2315_azhe_call_01", "azhe",
|
||||||
|
"我能过去拿吗?就5分钟"),
|
||||||
|
|
||||||
|
# === 第 12 条 · 23:30 周南 ===
|
||||||
|
("lv11_2330_zhounan_voice_01", "zhounan",
|
||||||
|
"林夏,是我,周南。你应该不记得我了吧,初三坐你后桌那个"),
|
||||||
|
|
||||||
|
# === 第 14 条 · 00:15 妈妈电话 ===
|
||||||
|
("lv11_0015_mom_call_01", "mom",
|
||||||
|
"夏夏,你不接电话我有点担心。妈妈知道你工作忙,你早点睡。"),
|
||||||
|
|
||||||
|
# ... 更多台词按需添加
|
||||||
|
# 长戏(妈妈 4'17"、小美醉语音、周南 4 段)需分段处理,见 §3.3
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_line(filename, role, text, speed_override=None):
|
||||||
|
"""合成单条台词"""
|
||||||
|
cfg = ROLES[role]
|
||||||
|
speed = speed_override or cfg["speed"]
|
||||||
|
ref_wav = cfg["ref_wav"]
|
||||||
|
ref_text = cfg["ref_text"]
|
||||||
|
|
||||||
|
if not os.path.exists(ref_wav):
|
||||||
|
print(f" [跳过] 参考音频不存在: {ref_wav}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 加载参考音频
|
||||||
|
prompt_speech = load_wav(ref_wav, 16000)
|
||||||
|
# 裁剪到合理长度
|
||||||
|
if prompt_speech.shape[1] > 16000 * 30:
|
||||||
|
prompt_speech = prompt_speech[:, :16000 * 30]
|
||||||
|
|
||||||
|
output_path = f"{PROJECT_DIR}/audio/00_raw/tts/{filename}_v1.wav"
|
||||||
|
print(f" 合成中: {filename} [{role}] speed={speed}")
|
||||||
|
print(f" 台词: {text[:50]}...")
|
||||||
|
|
||||||
|
speeches = []
|
||||||
|
for chunk in model.inference_zero_shot(
|
||||||
|
tts_text=text,
|
||||||
|
prompt_text=ref_text,
|
||||||
|
prompt_speech_16k=prompt_speech,
|
||||||
|
stream=False,
|
||||||
|
speed=speed,
|
||||||
|
):
|
||||||
|
speeches.append(chunk["tts_speech"])
|
||||||
|
|
||||||
|
if speeches:
|
||||||
|
full_speech = torch.concat(speeches, dim=1)
|
||||||
|
torchaudio.save(output_path, full_speech, model.sample_rate)
|
||||||
|
duration = full_speech.shape[1] / model.sample_rate
|
||||||
|
print(f" 已保存: {output_path} ({duration:.1f}s)")
|
||||||
|
else:
|
||||||
|
print(f" [失败] 未生成音频: {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" 《林夏》TTS 批量生产")
|
||||||
|
print(f" 共 {len(LINES)} 条台词")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
for i, line in enumerate(LINES):
|
||||||
|
filename = line[0]
|
||||||
|
role = line[1]
|
||||||
|
text = line[2]
|
||||||
|
speed_override = line[3] if len(line) > 3 else None
|
||||||
|
|
||||||
|
print(f"\n[{i+1}/{len(LINES)}] {filename}")
|
||||||
|
synthesize_line(filename, role, text, speed_override)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(" 批量合成完成!请到 audio/00_raw/tts/ 检查结果")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 长戏分段处理
|
||||||
|
|
||||||
|
SOP §5.3 规定:**超过 30 秒的台词必须分段录制**。
|
||||||
|
|
||||||
|
**妈妈 4'17" 长语音(第 16 条)分 8 段**:
|
||||||
|
|
||||||
|
| 段号 | 文件名 | 台词 | 情绪提示 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `lv11_0200_mom_voice_01_seg1` | "夏夏。妈妈知道你今天电话不接。妈妈不催你,就是想跟你说一件事。" | gentle, hesitant |
|
||||||
|
| 2 | `lv11_0200_mom_voice_01_seg2` | "你24了对吧。妈妈24那年——你应该没听我说过——" | gentle, nostalgic |
|
||||||
|
| 3 | `lv11_0200_mom_voice_01_seg3` | "那时候妈妈在广州,给一个香港老板做秘书,1995年。那年我也被分手过。也被开除过。一模一样的春天。" | gentle, sad but holding back |
|
||||||
|
| 4 | `lv11_0200_mom_voice_01_seg4` | "那年我哭得特别难看。后来呢?后来就没什么后来了。我回了老家,认识了你爸,生了你。" | gentle, releasing |
|
||||||
|
| 5 | `lv11_0200_mom_voice_01_seg5` | "我跟你说这个不是想跟你说什么都会过去——我想跟你说,那年夏天我没死,是因为我外婆每天晚上都给我打一个电话。" | gentle, emotional |
|
||||||
|
| 6 | `lv11_0200_mom_voice_01_seg6` | "她不说什么,就问我今天吃饭了没。" | gentle, peaceful |
|
||||||
|
| 7 | `lv11_0200_mom_voice_01_seg7` | "我现在打给你,你不接也没事。妈妈给你发个语音,你什么时候想听就听。你不用回我。" | gentle, tender |
|
||||||
|
| 8 | `lv11_0200_mom_voice_01_seg8` | "你今天接了好多电话吧。妈妈不打扰你了。晚安。" | gentle, almost smiling |
|
||||||
|
|
||||||
|
每段单独合成后用 FFmpeg 拼接(见 §4.3)。
|
||||||
|
|
||||||
|
**小美醉语音(第 09 条)分 4 段**,speed 调低到 0.95,每段间加长停顿。
|
||||||
|
|
||||||
|
**周南 4 段长讲述(第 13 条)** 每段独立合成,段间留 800ms 静音。
|
||||||
|
|
||||||
|
### 3.4 系统 TTS
|
||||||
|
|
||||||
|
系统提示音(播报未读消息、念选项等)用 CosyVoice **预训练音色**模式:
|
||||||
|
|
||||||
|
1. WebUI 选"预训练音色"
|
||||||
|
2. 从下拉菜单选一个清晰的中性女声
|
||||||
|
3. 合成所有系统语句
|
||||||
|
|
||||||
|
系统语句示例:
|
||||||
|
- "阿哲,发来1条语音。8秒。"
|
||||||
|
- "是否回复?"
|
||||||
|
- "一,好,明天寄。二,你怎么不自己来拿。三,你那边都好吗。四,什么都不说。"
|
||||||
|
- "妈妈3条未读、小美1条已读、阿哲1条未播放"
|
||||||
|
|
||||||
|
### 3.5 self-3y 三版预录
|
||||||
|
|
||||||
|
使用与 `xiaomei` 同基底参考音频,但调整 speed 为 +0.05(即 1.15):
|
||||||
|
|
||||||
|
| 版本 | 文件名 | 内容要求 | 情绪 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 明朗版 | `lv11_0030_self-3y_voice_01` | 3 年前刚来北京,对一切新鲜 | 兴奋带紧张 |
|
||||||
|
| 迷茫版 | `lv11_0030_self-3y_voice_02` | 3 年前已经累了 | 假装坚强 |
|
||||||
|
| 温柔版 | `lv11_0030_self-3y_voice_03` | 写给未来的自己 | 平静有力 |
|
||||||
|
|
||||||
|
> 台词需要编剧写好,每版约 50 秒。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 后期处理(FFmpeg)
|
||||||
|
|
||||||
|
### 4.1 单条 TTS 标准处理链
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 变量
|
||||||
|
INPUT="audio/00_raw/tts/lv11_2103_mom_call_01_v1.wav"
|
||||||
|
OUTPUT="audio/01_processed/tts/lv11_2103_mom_call_01_v1.wav"
|
||||||
|
|
||||||
|
# 完整处理链(一条命令):
|
||||||
|
# [1] 高通 80Hz 去低频噪
|
||||||
|
# [2] 压缩器 4:1
|
||||||
|
# [3] 响度归一化到 -16 LUFS
|
||||||
|
# [4] 限制峰值 -1 dBTP
|
||||||
|
ffmpeg -i "$INPUT" \
|
||||||
|
-af "highpass=f=80,\
|
||||||
|
acompressor=threshold=-18dB:ratio=4:attack=20:release=250,\
|
||||||
|
loudnorm=I=-16:TP=-1:LRA=11,\
|
||||||
|
alimiter=limit=0.891" \
|
||||||
|
-ar 48000 -sample_fmt s16 -ac 1 \
|
||||||
|
-y "$OUTPUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 加听筒效果(电话 / 微信语音形态)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INPUT="audio/01_processed/tts/lv11_2103_mom_call_01_v1.wav"
|
||||||
|
OUTPUT="audio/01_processed/tts/lv11_2103_mom_call_01_v1_phone.wav"
|
||||||
|
|
||||||
|
# 模拟手机听筒: HPF 300Hz + LPF 3.4kHz + 轻微饱和
|
||||||
|
ffmpeg -i "$INPUT" \
|
||||||
|
-af "highpass=f=300:poles=2,\
|
||||||
|
lowpass=f=3400:poles=2,\
|
||||||
|
equalizer=f=1500:t=q:w=1:g=1,\
|
||||||
|
acompressor=threshold=-20dB:ratio=3:attack=5:release=50" \
|
||||||
|
-y "$OUTPUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **例外**:`mom` 第 16 条 4'17" 长语音**不加听筒效果**——SOP 设计意图是"完整音质版"。
|
||||||
|
|
||||||
|
### 4.3 长戏分段拼接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 先给每段加 200ms 静音头尾
|
||||||
|
for seg in audio/00_raw/tts/lv11_0200_mom_voice_01_seg*.wav; do
|
||||||
|
ffmpeg -i "$seg" \
|
||||||
|
-af "adelay=200|200,apad=pad_dur=0.2" \
|
||||||
|
-y "${seg%.wav}_padded.wav"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. 生成拼接文件列表
|
||||||
|
ls audio/00_raw/tts/lv11_0200_mom_voice_01_seg*_padded.wav | \
|
||||||
|
sed "s/^/file '/" | sed "s/$/'/" > /tmp/concat_list.txt
|
||||||
|
|
||||||
|
# 3. 拼接 + crossfade
|
||||||
|
ffmpeg -f concat -safe 0 -i /tmp/concat_list.txt \
|
||||||
|
-af "acrossfade=d=0.05:c1=tri:c2=tri" \
|
||||||
|
-y audio/00_raw/tts/lv11_0200_mom_voice_01_full.wav
|
||||||
|
|
||||||
|
# 注意:FFmpeg concat + acrossfade 只支持两个输入。
|
||||||
|
# 多段拼接需要逐段 crossfade,或直接 concat 后人工检查接缝。
|
||||||
|
# 推荐方案:先 concat 拼接,段间 200ms 静音已经足够自然。
|
||||||
|
ffmpeg -f concat -safe 0 -i /tmp/concat_list.txt \
|
||||||
|
-c copy \
|
||||||
|
-y audio/00_raw/tts/lv11_0200_mom_voice_01_full.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 批量后期处理脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# 批量处理 00_raw/tts/ → 01_processed/tts/
|
||||||
|
# 用法: bash audio/process_tts.sh
|
||||||
|
|
||||||
|
for f in audio/00_raw/tts/lv11_*.wav; do
|
||||||
|
basename=$(basename "$f")
|
||||||
|
output="audio/01_processed/tts/$basename"
|
||||||
|
|
||||||
|
# 跳过已处理的
|
||||||
|
[ -f "$output" ] && echo "跳过: $basename" && continue
|
||||||
|
|
||||||
|
echo "处理: $basename"
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "highpass=f=80,\
|
||||||
|
acompressor=threshold=-18dB:ratio=4:attack=20:release=250,\
|
||||||
|
loudnorm=I=-16:TP=-1:LRA=11" \
|
||||||
|
-ar 48000 -sample_fmt s16 -ac 1 \
|
||||||
|
-y "$output" 2>/dev/null
|
||||||
|
|
||||||
|
echo " → $output"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "TTS 后期处理完成"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 最终打包(wav → ogg)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# 01_processed → 02_final (ogg Vorbis q=5)
|
||||||
|
for f in audio/01_processed/tts/*.wav; do
|
||||||
|
basename=$(basename "${f%.wav}.ogg")
|
||||||
|
output="audio/02_final/tts/$basename"
|
||||||
|
ffmpeg -i "$f" -c:a libvorbis -q:a 5 -y "$output" 2>/dev/null
|
||||||
|
done
|
||||||
|
echo "OGG 打包完成"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 角色专属后期(按 SOP §4.2 配置卡)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# mom: 高频衰减 -3dB + 轻 reverb
|
||||||
|
ffmpeg -i input.wav -af "equalizer=f=5000:t=h:w=2000:g=-3" -y output.wav
|
||||||
|
|
||||||
|
# azhe: 中频衰减 -2dB("闷"感)
|
||||||
|
ffmpeg -i input.wav -af "equalizer=f=2000:t=q:w=1:g=-2" -y output.wav
|
||||||
|
|
||||||
|
# delivery: 低频衰减 -6dB(模拟听筒)
|
||||||
|
ffmpeg -i input.wav -af "equalizer=f=200:t=h:w=200:g=-6" -y output.wav
|
||||||
|
|
||||||
|
# self-3y: 高频衰减 -2dB + 底噪(录音笔质感)
|
||||||
|
ffmpeg -i input.wav \
|
||||||
|
-af "equalizer=f=5000:t=h:w=2000:g=-2" \
|
||||||
|
-y output.wav
|
||||||
|
# 底噪可以后续叠加一层轻微白噪声
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 音乐素材(Suno)
|
||||||
|
|
||||||
|
音乐生产仍走 Suno 云端,按 SOP §6 操作。
|
||||||
|
|
||||||
|
### 5.1 操作步骤
|
||||||
|
|
||||||
|
1. 登录 Suno(需要 Pro 订阅)
|
||||||
|
2. 选 Custom Mode
|
||||||
|
3. 按 SOP §6.2 的 4 段曲 Prompt 生成
|
||||||
|
4. 每段跑 2 轮(出 4 首候选),选最佳
|
||||||
|
5. 用 Extend 延长到目标时长
|
||||||
|
6. 下载 WAV 格式到 `audio/00_raw/music/`
|
||||||
|
|
||||||
|
### 5.2 铃声生成
|
||||||
|
|
||||||
|
按 SOP §6.3 的 9 个角色铃声 Prompt 在 Suno 生成。
|
||||||
|
下载到 `audio/00_raw/ringtone/`。
|
||||||
|
|
||||||
|
### 5.3 音乐后期处理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 音乐处理: 削 AI 浊感 + 响度 -18 LUFS
|
||||||
|
for f in audio/00_raw/music/*.wav; do
|
||||||
|
basename=$(basename "$f")
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "equalizer=f=350:t=q:w=1.5:g=-2,\
|
||||||
|
loudnorm=I=-18:TP=-1:LRA=11" \
|
||||||
|
-ar 48000 \
|
||||||
|
-y "audio/01_processed/music/$basename" 2>/dev/null
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 音效素材(ElevenLabs SFX)
|
||||||
|
|
||||||
|
### 6.1 操作步骤
|
||||||
|
|
||||||
|
1. 登录 ElevenLabs(Creator 订阅,含 Sound Effects)
|
||||||
|
2. 按 SOP §7.2 的 Prompt 逐条生成
|
||||||
|
3. **每条跑 3 次**,挑最佳
|
||||||
|
4. 下载后转 WAV:
|
||||||
|
```bash
|
||||||
|
# ElevenLabs 默认 mp3,转为 wav
|
||||||
|
for f in audio/00_raw/sfx/*.mp3; do
|
||||||
|
ffmpeg -i "$f" -ar 48000 -y "${f%.mp3}.wav" && rm "$f"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 音效后期
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 短音效响度 -14 LUFS
|
||||||
|
for f in audio/00_raw/sfx/*.wav; do
|
||||||
|
basename=$(basename "$f")
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "loudnorm=I=-14:TP=-1:LRA=11" \
|
||||||
|
-ar 48000 \
|
||||||
|
-y "audio/01_processed/sfx/$basename" 2>/dev/null
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 环境底噪(Freesound)
|
||||||
|
|
||||||
|
### 7.1 操作步骤
|
||||||
|
|
||||||
|
1. 按 SOP §8.1 的清单在 Freesound 搜索
|
||||||
|
2. 过滤:CC0 优先,≥ 44.1kHz,下载 WAV
|
||||||
|
3. 文件名加 Freesound ID:如 `amb_apartment_night_fs123456.wav`
|
||||||
|
4. **立刻登记到版权表**
|
||||||
|
|
||||||
|
### 7.2 环境底噪后期
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 环境底噪处理: HPF 60Hz + LPF 12kHz + 响度 -22 LUFS
|
||||||
|
for f in audio/00_raw/ambience/*.wav; do
|
||||||
|
basename=$(basename "$f")
|
||||||
|
ffmpeg -i "$f" \
|
||||||
|
-af "highpass=f=60,lowpass=f=12000,\
|
||||||
|
loudnorm=I=-22:TP=-3:LRA=11" \
|
||||||
|
-ar 48000 -ac 2 \
|
||||||
|
-y "audio/01_processed/ambience/$basename" 2>/dev/null
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 单声道转假性立体声
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果下载到的是单声道,做假性立体声
|
||||||
|
INPUT="audio/01_processed/ambience/amb_apartment_night.wav"
|
||||||
|
OUTPUT="audio/01_processed/ambience/amb_apartment_night_stereo.wav"
|
||||||
|
|
||||||
|
ffmpeg -i "$INPUT" \
|
||||||
|
-filter_complex "\
|
||||||
|
[0:a]asplit=2[L][R];\
|
||||||
|
[R]adelay=12|12,equalizer=f=8000:t=q:w=1:g=1[Rd];\
|
||||||
|
[L][Rd]amerge=inputs=2[out]" \
|
||||||
|
-map "[out]" -ac 2 \
|
||||||
|
-y "$OUTPUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. QA 验收
|
||||||
|
|
||||||
|
### 8.1 响度检查脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# 检查所有已处理音频的响度
|
||||||
|
echo "=== TTS 响度检查 (目标 -16 LUFS) ==="
|
||||||
|
for f in audio/01_processed/tts/*.wav; do
|
||||||
|
result=$(ffmpeg -i "$f" -af "loudnorm=print_format=json" -f null - 2>&1 | \
|
||||||
|
grep -A1 '"input_i"' | tail -1 | tr -d ' ",' | cut -d: -f2)
|
||||||
|
echo " $(basename "$f"): ${result} LUFS"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 音乐响度检查 (目标 -18 LUFS) ==="
|
||||||
|
for f in audio/01_processed/music/*.wav; do
|
||||||
|
result=$(ffmpeg -i "$f" -af "loudnorm=print_format=json" -f null - 2>&1 | \
|
||||||
|
grep -A1 '"input_i"' | tail -1 | tr -d ' ",' | cut -d: -f2)
|
||||||
|
echo " $(basename "$f"): ${result} LUFS"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 环境底噪响度检查 (目标 -22 LUFS) ==="
|
||||||
|
for f in audio/01_processed/ambience/*.wav; do
|
||||||
|
result=$(ffmpeg -i "$f" -af "loudnorm=print_format=json" -f null - 2>&1 | \
|
||||||
|
grep -A1 '"input_i"' | tail -1 | tr -d ' ",' | cut -d: -f2)
|
||||||
|
echo " $(basename "$f"): ${result} LUFS"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 验收流程
|
||||||
|
|
||||||
|
1. **自检**:跑响度检查脚本,确认所有文件在目标范围 ±1 LUFS
|
||||||
|
2. **单条听感**:用耳机逐条听,标记不满意的(记录到 `03_qa_reports/`)
|
||||||
|
3. **上下文听感**:把同一时间段的所有音频按时间轴拼起来完整听一遍
|
||||||
|
4. **不满意的重做**:回到 §3 重新合成,调参或换参考音频
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 执行顺序总结
|
||||||
|
|
||||||
|
```
|
||||||
|
第 1 步 · 部署环境 (§1)
|
||||||
|
↓ 约 30 分钟
|
||||||
|
第 2 步 · 准备参考音频 (§2) ← 这一步最关键,音色质量取决于此
|
||||||
|
↓ 约 1-3 天(需要找人录音或搜集素材)
|
||||||
|
第 3 步 · 系统 TTS 先行 (§3.4) ← 最简单的先跑通流程
|
||||||
|
↓ 约 1 小时
|
||||||
|
第 4 步 · 短台词批量生产 (§3.1/3.2)
|
||||||
|
↓ 约 3-4 小时
|
||||||
|
第 5 步 · 长戏分段录制 (§3.3)
|
||||||
|
↓ 约 2-3 小时
|
||||||
|
第 6 步 · 后期处理 (§4)
|
||||||
|
↓ 约 2-3 小时
|
||||||
|
第 7 步 · 铃声 + 音乐 (§5) ← 可与 TTS 并行
|
||||||
|
↓ 约 3 小时
|
||||||
|
第 8 步 · 音效 (§6) ← 可与 TTS 并行
|
||||||
|
↓ 约 3 小时
|
||||||
|
第 9 步 · 环境底噪 (§7) ← 可与 TTS 并行
|
||||||
|
↓ 约 2 小时
|
||||||
|
第 10 步 · QA 验收 (§8)
|
||||||
|
↓ 约 4-6 小时
|
||||||
|
第 11 步 · 最终打包 (§4.5)
|
||||||
|
↓ 约 30 分钟
|
||||||
|
完成
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A · 常见问题
|
||||||
|
|
||||||
|
**Q: CosyVoice 生成的语音有电流声/杂音?**
|
||||||
|
A: 检查参考音频质量。参考音频必须干净无噪,采样率 ≥ 16kHz。
|
||||||
|
|
||||||
|
**Q: 语速不自然?**
|
||||||
|
A: speed 参数范围 0.5-2.0。如果调速后音质下降,改为在合成文本中加标点控制节奏(句号=长停顿,逗号=短停顿)。
|
||||||
|
|
||||||
|
**Q: 怎样让同一个音色听起来"醉了"?**
|
||||||
|
A: CosyVoice 没有情绪控制参数。替代方案:
|
||||||
|
1. 在文本中插入省略号、重复字来模拟口齿不清
|
||||||
|
2. 后期用 FFmpeg 轻微变速+加混响:
|
||||||
|
```bash
|
||||||
|
ffmpeg -i drunk.wav -af "atempo=0.95,aecho=0.8:0.7:40:0.3" -y drunk_fx.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
**Q: WebUI 启动报 CUDA 错误?**
|
||||||
|
A: 确认 conda env 正确:`conda activate cosyvoice`。确认 GPU 可用:`nvidia-smi`。
|
||||||
|
|
||||||
|
**Q: 生成很慢?**
|
||||||
|
A: RTX 5090 应该很快(远超实时)。如果慢,检查是否误用了 CPU。在 Python 中加 `print(torch.cuda.is_available())` 确认。
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
# 林夏
|
||||||
|
|
||||||
|
> **类型**:情感 / 叙事 / 轻悬疑 · 中篇详细稿
|
||||||
|
> **目标用户**:20–25 岁年轻女性(大学生 / 初入职场 / 一线城市独居)
|
||||||
|
> **时长**:约 60 分钟(剧内时间 21:00 – 02:00 共 5 小时压缩)
|
||||||
|
> **核心隐喻**:所有人都给你发语音,没有人发文字。今晚你必须一条一条听完,才能决定明天的自己长什么样。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一句话概述
|
||||||
|
|
||||||
|
24 岁的林夏,被分手、被裁员、被妈妈催婚,全发生在同一个周五。她把手机调成静音、躲进合租屋,关上灯。屏幕坏了——只剩耳机能听、手指能摸。今晚 16 个人会发来电话、语音、文字。她接谁、不接谁、回谁、不回谁,决定她明天还要不要从这张床上爬起来。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 玩家与角色
|
||||||
|
|
||||||
|
### 1.1 玩家是谁
|
||||||
|
- **林夏**,24 岁,西部小城来的姑娘,北漂第 3 年。
|
||||||
|
- 国贸某外企新媒体专员,今天上午刚被裁员(合同到期不续签)。
|
||||||
|
- 与男友"阿哲"恋爱 2 年 4 个月,今天下午刚被分手("我们性格真的不合")。
|
||||||
|
- 与一位大学闺蜜"小美"合租,但小美今晚去了酒吧。
|
||||||
|
- 老家妈妈让她"五一回来相亲"。
|
||||||
|
- 微信未读:99+。
|
||||||
|
- 屏幕:今天上午地铁挤碎了的那块屏,现在显示一片黑——但触控、震动、声音都还在。
|
||||||
|
|
||||||
|
### 1.2 玩家不需要"玩"什么
|
||||||
|
- 不解谜、不打怪、不收集线索。
|
||||||
|
- 玩家的全部任务就一句话——**今晚是接,是不接;是回,是不回。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心玩法
|
||||||
|
|
||||||
|
### 2.1 三种"消息形态"对应三种触控(**纯触摸 · 零麦克风**)
|
||||||
|
|
||||||
|
| 消息形态 | 提示 | 接收交互 | 回应交互 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **来电** | 长震 + 角色专属铃声 | **单点屏幕**接听 / **左滑**拒接 / **不动**等它停 | 通话中:系统 TTS 在每个对话节点**依次念出 4 个回应选项**,玩家在听到目标时**朝对应方向滑**(左/右/上/下),无需记位置 |
|
||||||
|
| **微信语音** | 短震 + 自研提示音 | **单点**当场播放 / **不动**留作未读 | **单点** = 快速发"嗯" / **双点** = 发一句更暖的标准回应 / **长按 1 秒**进入"细回复"——系统 TTS 念出 4 个**针对当前消息**的候选回应,玩家朝对应方向滑选择 / **双指上滑** = 已读不回 / **不动** = 保持未读 |
|
||||||
|
| **微信文字** | 短震 + 文字提示音 | **单点**让 TTS 念出来 / **不动**留作未读 | **单点** = 回"嗯" / **长按 1 秒**进入"细回复"(同上) / **双指上滑** = 已读不回 / **不动** = 保持未读 |
|
||||||
|
|
||||||
|
> 全部交互在 30 秒教学关里通过"小美第一通微信语音"完成:她会说"喂喂喂在不在,戳一下回我啊我等你呢"。
|
||||||
|
|
||||||
|
### 2.1.1 「细回复」机制详解(核心)
|
||||||
|
|
||||||
|
每当玩家长按 1 秒,系统 TTS 用**对方此刻能听到的那种"内心独白旁白"语气**念出 4 个候选回复,例如阿哲发来"我的 AirPods 落你那了",长按后:
|
||||||
|
|
||||||
|
```
|
||||||
|
(系统 TTS · 极轻 · 0.8x 语速)
|
||||||
|
一…… "好,明天寄。"
|
||||||
|
二…… "你怎么不自己来拿。"
|
||||||
|
三…… "你那边……都好吗。"
|
||||||
|
四…… (什么都不说)
|
||||||
|
```
|
||||||
|
|
||||||
|
玩家在听到目标时朝对应方向滑(左 1 / 右 2 / 上 3 / 下 4)。不需要记位置——**只需要听节奏**。
|
||||||
|
|
||||||
|
这套机制保证:
|
||||||
|
- **零视觉依赖**:所有选项靠语音呈现。
|
||||||
|
- **零麦克风依赖**:玩家从不需要说话。
|
||||||
|
- **节奏可控**:4 个选项约 6 秒念完,没听清可以再次长按重听。
|
||||||
|
|
||||||
|
### 2.2 全黑屏的"通讯录辅助"
|
||||||
|
- **屏幕中央三指长按 2 秒** → 进入"未读列表",TTS 依次念出:"妈妈 3 条未读、小美 1 条已读、阿哲 1 条未播放……"
|
||||||
|
- 玩家在听到目标条目时,点击屏幕**任意位置**进入。
|
||||||
|
- **不需要记位置**——靠节奏与语音引导。
|
||||||
|
|
||||||
|
### 2.3 时间是流动的
|
||||||
|
- 5 小时压缩为 60 分钟现实时间。
|
||||||
|
- 每个时间点会自动响起特定来电/语音。
|
||||||
|
- **漏接 / 不听是合法的**——但漏掉的内容不会重发,会变成"未读"留在收件箱里。
|
||||||
|
- 全程结束时游戏会**统计你的"今晚未读数"**——这是结局判定的关键变量。
|
||||||
|
|
||||||
|
### 2.4 关键机制:「正在输入…」
|
||||||
|
- 当某个角色在剧情中"正在打字"时,耳机里会有微弱的**键盘敲击声**(左声道 / 右声道随角色),但消息可能最终**没发出**——他打了一半删了。
|
||||||
|
- 这是这个游戏最痛的细节之一。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 来电 / 消息全列表(16 条)
|
||||||
|
|
||||||
|
| # | 时间 | 来源 | 形式 | 关键内容 | 设计意图 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 01 | 21:03 | 妈妈 | 电话 | "今晚煮了你爱吃的排骨,给你拍张照啊。下周五回来一趟,你高中物理老师的儿子,挺好的。" | 开局先给一个**温柔但不能接**的电话——你妈不知道你今天发生了什么 |
|
||||||
|
| 02 | 21:15 | 闺蜜小美 | 微信语音 | 三连:①"在吗" ②"你被甩了我都听说了" ③"明天晚上喝酒,海底捞旁边那家,不许放鸽子" | 教学关,引导玩家学会**单点/双点/长按细回复**三种回应方式 |
|
||||||
|
| 03 | 21:20 | 外卖小哥 | 电话 | "你好你的麻辣烫到了,放门口可以吗?" | 一通**必须接**的电话,作为玩家"原来还能接"的钩子 |
|
||||||
|
| 04 | 21:30 | 前任阿哲 | 微信语音 | 一条 8 秒:"我的 AirPods 落你那了,方便寄一下吗" | **第一刀**——分手 6 小时,他先想到的是耳机 |
|
||||||
|
| 05 | 21:45 | HR 王姐 | 微信语音 + 邮件 | "夏夏啊,离职流程你下周三之前办完哈,公积金那边记得跟进。辛苦啦,外面有合适的工作我帮你留意。" | 苦中带甜——HR 不是反派,她其实尽力了 |
|
||||||
|
| 06 | 22:00 | 室友安安 | 微信语音 | 喧闹背景音里:"夏夏!我在 helens!要不要过来!我请客!" | 给一个**可以逃离今晚**的选项,看玩家选不选 |
|
||||||
|
| 07 | 22:15 | 大学寝室群(4人群) | 连续 5 条语音 | 室友 A 升了职,B/C 在恭喜,A 突然 @ 你:"夏夏你怎么不说话呀" | 群消息无法"回复某一条",**只能整体响应或沉默** |
|
||||||
|
| 08 | 22:30 | 未知号码 | 电话 | 接通后只有呼吸声 + 远处的人声背景,6 秒后挂断 | **轻悬疑钩子**——这是 Mystery 线起点 |
|
||||||
|
| 09 | 22:45 | 闺蜜小美 | 微信语音 | 醉了,超长 1 分 20 秒:从安慰变成抱怨自己的男朋友,到末尾突然说"其实……阿哲他大三那年来追过我" | **第二刀**——你以为闺蜜是同盟,但她也有秘密 |
|
||||||
|
| 10 | 23:00 | 妈妈 | 微信文字 | "睡了吗?" | 极简 3 个字。点开听 TTS 念出来的瞬间,最痛 |
|
||||||
|
| 11 | 23:15 | 前任阿哲 | 电话 | 直接打来:"我能过去拿吗?就 5 分钟" | 一个**真正考验玩家**的电话——接 or 拒 是分支锚点 |
|
||||||
|
| 12 | 23:30 | 未知号码(已加微信) | 微信语音 | "林夏,是我,周南。你应该不记得我了吧,初三坐你后桌那个" | 悬疑揭晓——是初中同桌 |
|
||||||
|
| 13 | 23:45 | 周南 | 连续 4 条微信语音 | 他讲了这十年:高考没考好、复读、二本、回老家、今年来北京出差。最后一条:"今天我生日。本来就想跟你说一声,对不起这么晚。" | **苦中的那一缕甜**——一个你早就忘了的人,记得你 |
|
||||||
|
| 14 | 00:15 | 妈妈 | 电话 | "夏夏,你不接电话我有点担心。妈妈知道你工作忙,你早点睡。" | 第二次妈妈来电,是分支判定点 |
|
||||||
|
| 15 | 00:30 | 自己 | 系统通知 | iPhone "时光胶囊"提醒:3 年前的今晚你给自己录过一段备忘录。"是否播放?" | 自我对话——3 年前的林夏说了什么,由游戏根据玩家本场的选择动态生成 |
|
||||||
|
| 16 | 02:00 | 妈妈 | 微信语音 | 4 分 17 秒长语音。她讲了她 24 岁那年——也曾被分手、也曾失业、也曾一个人在 1995 年的广州。结尾:"你今天接了好多电话吧。妈妈不打扰你了。" | **结尾** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 分支结构
|
||||||
|
|
||||||
|
### 4.1 关键判定变量(5 个 + 1 个隐性画像)
|
||||||
|
|
||||||
|
| 变量 | 触发条件 | 影响 |
|
||||||
|
|---|---|---|
|
||||||
|
| **MOM_LINK** | 是否接妈妈两次电话中的至少一次 / 是否回复"睡了吗?" | 决定第 16 条妈妈的长语音是否播放 |
|
||||||
|
| **HE_BACK** | 是否答应阿哲过来拿耳机 | 决定 23:30 后是否插入一段额外门铃声场景 |
|
||||||
|
| **GROUP_REPLY** | 是否在大学群里"细回复"过 | 决定一周后玩家会不会收到大学室友的"补救"消息(彩蛋) |
|
||||||
|
| **ZHOUNAN_DEPTH** | 给周南的回应深度:单点 < 双点 < 细回复,且看选了哪个分支 | 决定结局字幕的最后一行,决定 self-3y 版本基调 |
|
||||||
|
| **SELF_RECORD** | 是否听完 3 年前自己的备忘录 | 决定结局是"林夏睡着了"还是"林夏起来去了厨房" |
|
||||||
|
| **PROFILE**(隐性) | 全场玩家行为聚合而成的"今晚画像"标签:`avoidance` / `engagement` / `nostalgia` 三选一 | 决定 self-3y 选择哪一个预录版本播放 |
|
||||||
|
|
||||||
|
#### PROFILE 计分细则(程序内部维护,玩家不可见)
|
||||||
|
|
||||||
|
| 行为 | avoidance | engagement | nostalgia |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 漏接电话(铃响完不动) | +2 | 0 | 0 |
|
||||||
|
| 单点回"嗯" | +1 | 0 | 0 |
|
||||||
|
| 双指上滑"已读不回" | +2 | 0 | 0 |
|
||||||
|
| 长按 1 秒"细回复" | 0 | +2 | 0 |
|
||||||
|
| 细回复中选"主动追问"方向 | 0 | +2 | 0 |
|
||||||
|
| 细回复中选"分享自己"方向 | 0 | +1 | +1 |
|
||||||
|
| 反复重听同一条消息 | 0 | 0 | +2 |
|
||||||
|
| 听完小美醉语音、周南长讲述未做任何回应 | +1 | 0 | +1 |
|
||||||
|
|
||||||
|
最终取分数最高的那一项作为 PROFILE 标签。
|
||||||
|
|
||||||
|
### 4.2 结局组合(4 种主调 + 隐藏)
|
||||||
|
|
||||||
|
#### 结局 A · "明天就明天吧"
|
||||||
|
**触发**:MOM_LINK = true,SELF_RECORD = true,至少回了周南或群里 1 条
|
||||||
|
- 02:00 妈妈语音播完。
|
||||||
|
- 林夏从床上起身,系统播放她走到厨房、烧水、坐在小板凳上的环境声。
|
||||||
|
- 黑屏中第一次出现一行字幕:"明天再说。"
|
||||||
|
- 烧水壶滴的一声完成 → 游戏结束。
|
||||||
|
|
||||||
|
#### 结局 B · "AirPods 我寄给你"
|
||||||
|
**触发**:HE_BACK = false(拒了阿哲),同时回过周南至少 1 条
|
||||||
|
- 林夏在 01:30 给阿哲发了一条文字"我寄给你,地址发我"。
|
||||||
|
- 字幕:"寄出。"
|
||||||
|
- 没有更多剧情,也没有更多情绪。这是一个"清醒地走"的结局。
|
||||||
|
|
||||||
|
#### 结局 C · "全部已读"
|
||||||
|
**触发**:未读数 = 0(每一条都听了,但回了 ≤ 2 条)
|
||||||
|
- 02:30 屏幕黑着,TTS 念出:"今晚共 16 条新消息,已读 16,未回复 14。"
|
||||||
|
- 一段长沉默。
|
||||||
|
- 字幕:"今晚就这样。"
|
||||||
|
|
||||||
|
#### 结局 D · "明天再开机"
|
||||||
|
**触发**:未读数 ≥ 8(你大半都没听)
|
||||||
|
- 01:00 系统提示电量 1%。
|
||||||
|
- 屏幕"亮"了一下(其实是触感震动 + 一段非常短的白噪),然后彻底黑了。
|
||||||
|
- 第二天清晨的鸟叫开始播放。
|
||||||
|
- 字幕:"早上好。"
|
||||||
|
|
||||||
|
#### 结局 E · 隐藏 · "周南,我也记得你"
|
||||||
|
**触发**:ZHOUNAN_DEPTH ≥ 2 次细回复 且 至少一次选择"分享自己"方向 且 PROFILE = `engagement` 或 `nostalgia`
|
||||||
|
- 周南秒回:"那我下次出差来北京,请你吃饭好不好。"
|
||||||
|
- 系统播放一段**林夏的轻笑**——这段笑声**预录**于 self-3y 同一录音棚(同一克隆音色),代表"3 年前那个还能笑出来的林夏,今天又笑了一次"。
|
||||||
|
- 字幕:"好。"
|
||||||
|
- 这是全片唯一一个**林夏发出声音**的结局——前面所有戏她都是"听者"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 关键场景脚本示例(节选)
|
||||||
|
|
||||||
|
### 5.1 第 04 条 · 21:30 · 阿哲的微信语音
|
||||||
|
|
||||||
|
> **铺垫**:21:25 玩家一直坐在床上没动。耳机里只有冰箱嗡嗡声、远处地铁过站声。
|
||||||
|
|
||||||
|
**[震动 · 短促 · 微信"咚"]**
|
||||||
|
|
||||||
|
**TTS(系统女声 · 平静)**:阿哲,发来 1 条语音。8 秒。
|
||||||
|
|
||||||
|
> 玩家选择 ——
|
||||||
|
> A. 单点:播放
|
||||||
|
> B. 不动:留为未读
|
||||||
|
|
||||||
|
**A · 播放:**
|
||||||
|
**[阿哲 · TTS · 男声 · 略带歉意但很疏离]**:
|
||||||
|
"嗯……夏夏。我那个 AirPods 充电盒,好像落你抽屉里了。你方便的时候帮我寄一下吗,工位还要用。地址我等下发你。"
|
||||||
|
|
||||||
|
**[3 秒沉默]**
|
||||||
|
|
||||||
|
**TTS(系统)**:是否回复?
|
||||||
|
|
||||||
|
> 玩家选择 ——
|
||||||
|
> 1. **单点** → 系统发出"嗯。"(最快、最敷衍)
|
||||||
|
> 2. **双点** → 系统发出"好,明天寄给你。"(一句标准回执)
|
||||||
|
> 3. **长按 1 秒**进入细回复,TTS 依次念出:
|
||||||
|
> - 一…… "好,明天寄。"
|
||||||
|
> - 二…… "你怎么不自己来拿。"
|
||||||
|
> - 三…… "你那边……都好吗。"
|
||||||
|
> - 四…… (什么都不说)
|
||||||
|
>
|
||||||
|
> 玩家在听到目标时朝对应方向滑(左 1 / 右 2 / 上 3 / 下 4)。
|
||||||
|
> 4. **双指上滑**:已读不回
|
||||||
|
> 5. **不动**:保持未读
|
||||||
|
|
||||||
|
**B · 不动:**
|
||||||
|
**[10 秒后,耳机左声道极轻的键盘敲击声 · 持续 8 秒 · 然后停]**
|
||||||
|
**TTS(系统女声)**:阿哲撤回了一条消息。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 第 09 条 · 22:45 · 小美的醉语音(节选)
|
||||||
|
|
||||||
|
**[小美 · TTS · 女声 · 醉态语调 · 背景酒吧嘈杂]**:
|
||||||
|
"夏夏夏夏夏。你睡了吗?我跟你说,我跟你说啊……今天我喝多了……
|
||||||
|
你别嫌我烦哈,我跟你说……
|
||||||
|
其实啊……阿哲他,大三那年,他来追过我。
|
||||||
|
我没答应他。我那时候就觉得他这人,呵,差点意思。
|
||||||
|
你跟他在一起的时候,我没说,因为说了也没意义嘛对吧。
|
||||||
|
现在他甩了你,我才敢说。
|
||||||
|
夏夏,你听到了吗?
|
||||||
|
你别恨我啊。
|
||||||
|
……
|
||||||
|
喂?
|
||||||
|
(长 5 秒沉默)
|
||||||
|
(突然小声)算了。"
|
||||||
|
|
||||||
|
**[消息结束 · 8 秒后系统提示]**
|
||||||
|
**TTS**:小美撤回了刚才的语音。但你已经听过了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 第 16 条 · 02:00 · 妈妈的长语音(结尾,节选)
|
||||||
|
|
||||||
|
**[妈妈 · TTS · 中年女声 · 温柔但克制 · 4 分 17 秒]**
|
||||||
|
|
||||||
|
> **TTS 配置说明**:本段建议使用克隆 + 高情绪化模型,必要时分句录制后做轻拼接,以承载这段戏的全部重量。
|
||||||
|
|
||||||
|
"夏夏。
|
||||||
|
妈妈知道你今天电话不接。妈妈不催你,就是想跟你说一件事。
|
||||||
|
你 24 了对吧。妈妈 24 那年——你应该没听我说过——
|
||||||
|
那时候妈妈在广州,给一个香港老板做秘书,1995 年。
|
||||||
|
那年我也被分手过。也被开除过。一模一样的春天。
|
||||||
|
那年我哭得特别难看。
|
||||||
|
后来呢?后来就没什么后来了。
|
||||||
|
我回了老家,认识了你爸,生了你。
|
||||||
|
我跟你说这个不是想跟你说"什么都会过去"——
|
||||||
|
我想跟你说,那年夏天我没死,是因为我外婆每天晚上都给我打一个电话。
|
||||||
|
她不说什么,就问我"今天吃饭了没"。
|
||||||
|
我现在打给你,你不接也没事。
|
||||||
|
妈妈给你发个语音,你什么时候想听就听。
|
||||||
|
你不用回我。
|
||||||
|
你今天接了好多电话吧。
|
||||||
|
妈妈不打扰你了。
|
||||||
|
晚安。"
|
||||||
|
|
||||||
|
**[消息结束 · 长沉默 · 远处一辆夜班公交驶过 · 楼下野猫叫了一声]**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 音频设计(含 AI 生成方案)
|
||||||
|
|
||||||
|
### 6.1 音频资产分类与制作方案
|
||||||
|
|
||||||
|
| 类别 | 数量 | 推荐工具 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A. 角色语音(TTS)** | 9 个角色 / 约 25 分钟净时长 | **MiniMax Speech 02 / 字节豆包 Voice / ElevenLabs v3** | 中文优先 MiniMax 与豆包,英文/特殊情绪可备 ElevenLabs |
|
||||||
|
| **B. 关键长语音** | 妈妈 4 分 17 秒 + 小美 1 分 20 秒 + 周南 4 段 | **MiniMax 克隆 + 分段录制 + 轻拼接** | 这两段决定全片成败,预算优先放这里 |
|
||||||
|
| **C. self-3y 三版预录 + self-laugh** | 3 年前自己 ×3 版 (~50s/版) + 隐藏笑声 ~3s | MiniMax 同基底音色,emotion 区分;笑声需真人录或克隆生成 | 替代原"玩家麦克风录入"方案,保证零权限 |
|
||||||
|
| **D. 主题音乐** | 4 段(开场低氛围、悬疑钩子、温暖、结尾) | **Suno V4 / Udio v2 / Stable Audio 2.5** | 每段 60–120 秒,做 Loop 版与结尾版两个变体 |
|
||||||
|
| **E. 短音效(UI 类)** | 微信咚、震动、铃声、消息提示 | **ElevenLabs Sound Effects** 或微信公开音源 | 注意微信原生提示音版权——建议用近似不雷同的合成版本 |
|
||||||
|
| **F. 短音效(拟音类)** | 烧水壶、键盘、外卖敲门、撤回 swoosh | **ElevenLabs Sound Effects** | 一句 prompt 如 "kettle whistle, close mic, kitchen ambience",效果已经够用 |
|
||||||
|
| **G. 环境长底噪** | 合租屋夜噪、楼下街声、地铁远声、酒吧嘈杂 | **不推荐 AI**——用 [Freesound](https://freesound.org)(CC0/CC-BY)或订阅 [Epidemic Sound](https://www.epidemicsound.com) | AI 生成长 ambience 现阶段质量不稳定,会"漂",长音轨建议用真实采样 |
|
||||||
|
| **H. 角色铃声** | 9 个不同角色铃声 | **Suno** 短旋律生成 | 每个角色独立铃声让玩家"听铃辨人" |
|
||||||
|
|
||||||
|
### 6.2 TTS 角色配音清单(含建议音色与情绪 prompt)
|
||||||
|
|
||||||
|
| 角色 | 推荐 TTS | 音色描述 | 情绪基调 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 妈妈 | MiniMax `Chinese (Mandarin)_Warm_Ladylike_Aunt` 或克隆 | 50 上下,温柔不黏腻,西部口音轻微 | 平稳、克制、第 16 条要"故作平静" |
|
||||||
|
| 阿哲 | 豆包 `灿烂青年男声` / MiniMax `Patient_Man` | 27 岁,城市男生,理性偏冷 | 全程"礼貌但已抽离" |
|
||||||
|
| 小美 | MiniMax `Lively_Girl` | 24 岁,活泼,偶尔语速过快 | 醉酒段需开到高情绪强度 |
|
||||||
|
| 周南 | MiniMax `Gentleman` 偏年轻调 | 26 岁,二三线城市男生,温和略拘谨 | 紧张但克制的喜悦 |
|
||||||
|
| HR 王姐 | 豆包 `知性女声` | 38 岁,职场化,疲惫的善意 | 公事公办里夹一点点歉意 |
|
||||||
|
| 室友安安 | MiniMax `Cute_Girl` | 23 岁,比小美更"嗲"一点 | 自顾自的快乐 |
|
||||||
|
| 室友 A/B/C(群) | MiniMax 不同年轻女声 × 3 | 区分度优先 | 中性日常 |
|
||||||
|
| 外卖小哥 | 豆包 `北方青年男声` | 30 岁,普通话+少许河南腔 | 麻利,工作语气 |
|
||||||
|
| 系统 TTS | 豆包 `通用女声-柔` | 中性,类似 iOS Siri 但更轻 | 全程平静无情绪 |
|
||||||
|
|
||||||
|
### 6.3 音乐设计
|
||||||
|
|
||||||
|
| 曲目 | 用途 | Suno Prompt 草案 | 时长 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **M1 · 21:00 主题** | 开场 + 全程低氛围 Loop | `lo-fi midnight bedroom, soft piano, light vinyl noise, rain outside, melancholy but warm, no drums, 70bpm` | 90s loop |
|
||||||
|
| **M2 · 悬疑过门** | 22:30 未知号码 | `subtle ambient suspense, low drone, single hanging piano note, sparse, no melody, 60bpm` | 30s |
|
||||||
|
| **M3 · 周南段落** | 23:45 周南讲十年 | `gentle indie folk, fingerpicked guitar, soft male hum no lyrics, hopeful nostalgic, 75bpm` | 120s |
|
||||||
|
| **M4 · 结尾** | 02:00 妈妈语音后 + 字幕 | `warm cinematic piano, single melody line, very slow, like dawn, 50bpm` | 90s |
|
||||||
|
|
||||||
|
### 6.4 音效是否能 AI 生成 · 判定表
|
||||||
|
|
||||||
|
| 音效类型 | AI 是否够用 | 推荐方案 |
|
||||||
|
|---|---|---|
|
||||||
|
| **短促 UI 音**(提示、震动、点击) | ✅ 完全够 | ElevenLabs SFX 一句 prompt 搞定 |
|
||||||
|
| **拟音类**(敲门、烧水、撤回 swoosh、键盘、玻璃放下) | ✅ 够用 | ElevenLabs SFX,需多生成几条选最好 |
|
||||||
|
| **角色化音效**(具体某品牌的提示音、微信原音) | ⚠️ 不能用真的,但可以做"近似不雷同" | AI 合成 + 人工调音,避免侵权 |
|
||||||
|
| **环境长底噪 30 秒以上** | ⚠️ 质量不稳,长曲会"漂" | 用 Freesound 免费 / Epidemic Sound 订阅 |
|
||||||
|
| **真实城市夜景采样**(远地铁、夜班车、野猫) | ❌ AI 还不行,"假" | 必须用真采样素材库 |
|
||||||
|
|
||||||
|
### 6.5 总音频预算估算(独立团队)
|
||||||
|
|
||||||
|
| 项 | 工具 | 月费 / 一次性 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MiniMax TTS | API | ¥200–500(按字数计) | 主力 |
|
||||||
|
| 豆包 TTS | API | ¥100–300 | 备用 + 系统女声 |
|
||||||
|
| ElevenLabs(含 SFX) | Creator $22/月 | ≈ ¥160 | 一个月内完工 |
|
||||||
|
| Suno Pro | $10/月 | ≈ ¥75 | 4 段曲足够 |
|
||||||
|
| Epidemic Sound | $15/月(个人) | ≈ ¥110 | 环境采样必备 |
|
||||||
|
| **合计** | | **≈ ¥600–1100** | 一个月内全套出片 |
|
||||||
|
|
||||||
|
> 与传统配音 + 原创配乐方案(保守 5–8 万)相比,AI 方案把音频预算压到 1% 以内。这是这个题材"能做出来"的关键前提。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 技术实现要点
|
||||||
|
|
||||||
|
### 7.1 全黑屏的可行性
|
||||||
|
- iOS:可以通过 `UIScreen.main.brightness = 0` 强制最低亮度,但不能完全黑——**建议用全屏 0,0,0 黑色 View 覆盖**。
|
||||||
|
- Android:同理,用 `Window.attributes.screenBrightness = 0.01` + 黑色 View。
|
||||||
|
- 实际"全黑"是 OLED 屏幕的福利——OLED 显示纯黑时像素不发光,体感真的全黑。**LCD 屏会有背光**——这点要在游戏开始前提示玩家。
|
||||||
|
|
||||||
|
### 7.2 触控手势识别
|
||||||
|
- 用原生 GestureDetector(Android)/ UIGestureRecognizer(iOS)。
|
||||||
|
- 区分 10 种基础手势:单点 / 双点 / 长按 1 s / 长按 5 s(强制挂断)/ 左滑 / 右滑 / 上滑 / 下滑 / 双指上滑 / 三指长按。
|
||||||
|
- 每种手势必须有**强反馈**(Haptic + 极短提示音),让玩家"摸黑也知道自己点对了"。
|
||||||
|
- 手势冲突处理:
|
||||||
|
- 单点 vs 双点:单点延迟判定 250ms。
|
||||||
|
- 长按 1s vs 长按 5s:到达 1s 触发"细回复入口"提示音,玩家可松手进入;继续长按到 5s 触发"强制挂断"。
|
||||||
|
- 滑动方向阈值:30 px 起算,40° 内归类为该方向。
|
||||||
|
|
||||||
|
### 7.3 玩家"行为画像"识别(替代原"麦克风情绪识别")
|
||||||
|
|
||||||
|
> 本作**全程不要求任何麦克风、摄像头、位置、通讯录权限**。
|
||||||
|
> 玩家的"情绪状态"完全通过**触摸行为模式**做轻量统计推导。
|
||||||
|
|
||||||
|
- 每个玩家行为打入 §4.1 PROFILE 计分表。
|
||||||
|
- 3 个维度记录在本地 JSON(与 §4.1 PROFILE 计分表对应):
|
||||||
|
- `avoidance`(回避度)
|
||||||
|
- `engagement`(投入度)
|
||||||
|
- `nostalgia`(怀旧倾向)
|
||||||
|
- 这些数据**只在本地**存储,从不上传任何服务器。
|
||||||
|
- 用于:
|
||||||
|
1. 决定 self-3y 播放哪个预录版本
|
||||||
|
2. 决定结局走向
|
||||||
|
3. 决定字幕的最后一行措辞
|
||||||
|
|
||||||
|
### 7.4 真实时间挂钩
|
||||||
|
- 游戏内 21:00–02:00 不强制对应现实时间,但**夜间启动有特殊彩蛋**:现实里 22 点之后启动,游戏开场会多一句 TTS:"你也还醒着啊。"
|
||||||
|
|
||||||
|
### 7.5 隐私承诺(重要卖点)
|
||||||
|
|
||||||
|
- 本作**不申请任何敏感权限**:
|
||||||
|
- ❌ 不要麦克风
|
||||||
|
- ❌ 不要摄像头
|
||||||
|
- ❌ 不要位置
|
||||||
|
- ❌ 不要通讯录 / 短信 / 通知历史
|
||||||
|
- ❌ 不要联网(全程离线可玩)
|
||||||
|
- ✅ 仅使用:触觉震动 + 触控输入 + 系统音量
|
||||||
|
- 这是这款游戏的承诺,也是上架商店时**最有力的宣传点**之一:「一款不会动你任何东西的游戏」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 商业化 / 包装思路
|
||||||
|
|
||||||
|
### 8.1 定位
|
||||||
|
- **付费单机叙事小品**,定价 12–18 元(学生友好)。
|
||||||
|
- 不做内购、不做广告。
|
||||||
|
- 主打"晚上戴上耳机的 60 分钟"。
|
||||||
|
|
||||||
|
### 8.2 渠道
|
||||||
|
- iOS App Store / Google Play 主推。
|
||||||
|
- TapTap / B 站游戏首发——用户画像最重叠。
|
||||||
|
- 抖音/小红书素人传播——"那个全黑屏的游戏让我哭了"是天然短视频选题。
|
||||||
|
|
||||||
|
### 8.3 长线
|
||||||
|
- 同 IP 可拓展"林夏"宇宙:明年她 25 岁 / 她妈妈的故事 / 她闺蜜的故事,做成系列。
|
||||||
|
- 真实音乐 / 品牌联名空间大(耳机品牌、深夜餐饮、心理咨询平台)。
|
||||||
|
|
||||||
|
### 8.4 关键卖点提炼(用于 store 文案)
|
||||||
|
- "屏幕坏了,但她的一夜还没结束。"
|
||||||
|
- "16 条消息,5 个结局,60 分钟。"
|
||||||
|
- "戴上耳机。把灯关了。"
|
||||||
|
- "她不是你,但今晚你要替她活一遍。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 对策 |
|
||||||
|
|---|---|
|
||||||
|
| **题材敏感**——分手 + 失业 + 催婚的叠加可能让部分玩家代入过深而不适 | 开场加心理预警卡 + 提供"5 分钟试玩版"让玩家自评是否适合继续 |
|
||||||
|
| **TTS 长情绪戏(妈妈 4 分钟)质感不够** | 这一段必须用克隆 + 分句录制 + 后期混音;预留预算的 30% 给这段戏 |
|
||||||
|
| **微信交互形态被认为"擦边"** | 法务先过——所有 UI 元素自研、提示音原创合成、避免任何 logo/微信字样 |
|
||||||
|
| **女性玩家的真实代入感** | 编剧组必须有 24 岁左右的女性参与第一稿;不能由 35+ 男性主笔写林夏 |
|
||||||
|
| **60 分钟无目标导致弃游** | 22:30 未知号码(悬疑钩子)必须强存在感——铃声不一样、震动模式不一样,让玩家"必须想知道是谁" |
|
||||||
|
| **「细回复」长按 1 s 学习曲线** | 教学关(小美第一通微信语音)必须**强引导**:单点先教学,双点再教学,长按最后教学;每一步玩家做对都给"叮"的正向反馈 |
|
||||||
|
| **滑动方向误判导致选错回复** | 提供"听完 4 个选项再次长按可重听"的兜底;选错后 2 秒内双指捏合 = 撤回选择 |
|
||||||
|
| **OLED / LCD 屏幕差异导致"全黑"体验不一致** | 游戏开场触摸式问答:"你现在能看到屏幕吗?看不到请单点/还能看到一点请双点",分支引导玩家盖一块布或把亮度手动调到最低 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 制作里程碑(独立团队 4 人 · 6 个月)
|
||||||
|
|
||||||
|
| 阶段 | 时长 | 关键交付 |
|
||||||
|
|---|---|---|
|
||||||
|
| **T1 · 剧本定稿** | 4 周 | 16 条全脚本 + 5 结局完整对白 + 全部「细回复」4 选项词条 + 5 主角人设 |
|
||||||
|
| **T2 · 音频生成** | 6 周 | 全部 TTS 配音 + self-3y 三个预录版本 + 4 段 BGM + SFX 库 + 环境采样整理 |
|
||||||
|
| **T3 · 程序原型** | 4 周 | 黑屏触控 + 8 种手势识别 + 消息系统 + PROFILE 行为画像统计 |
|
||||||
|
| **T4 · 整合调试** | 6 周 | 16 通关卡逐一接入、分支判定、结局拼接 |
|
||||||
|
| **T5 · 内测 + 心理咨询顾问审稿** | 3 周 | 50 名年轻女性测试 + 1 位心理咨询师审定 |
|
||||||
|
| **T6 · 发布 + 营销** | 3 周 | App Store + TapTap 上架 + 小红书种草 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 · 一句话给主创团队
|
||||||
|
|
||||||
|
> "这不是一个让女孩们更难过的游戏。这是一个让她们在难过的时候,知道**有人也曾这样难过、并且活下来**的游戏。"
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# 《林夏》音色参考音频采集清单
|
||||||
|
|
||||||
|
> CosyVoice「3s极速复刻」需要每个角色提供一段 **5-20 秒的干净人声参考音频**。
|
||||||
|
> 音色采集是整个音频生产的**第一步也是最关键一步**——TTS 的最终质量上限就是参考音频的质量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 采集规格(所有角色统一)
|
||||||
|
|
||||||
|
- **时长**:10-20 秒(太短克隆不稳,太长无意义)
|
||||||
|
- **内容**:说一段完整的中文,语速自然,情绪平稳
|
||||||
|
- **录制要求**:干声,无 BGM,无混响,无明显底噪
|
||||||
|
- **格式**:WAV 或 MP3 均可(后续统一转 16kHz WAV)
|
||||||
|
- **如果是从视频/播客截取**:用安静片段,避免多人同时说话的段落
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需要采集的 11 条参考音频
|
||||||
|
|
||||||
|
### 1. `mom` — 妈妈
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| **性别/年龄** | 女,45-55 岁 |
|
||||||
|
| **音色关键词** | 温柔、不黏腻、略带沧桑、说话慢半拍 |
|
||||||
|
| **口音** | 普通话为主,可以有轻微西北口音(不强求) |
|
||||||
|
| **情绪** | 平稳温和,不是那种激昂的或播音腔的 |
|
||||||
|
| **避免** | 太年轻、太甜、太播音、太严厉 |
|
||||||
|
| **找人方案** | 找一位 45-55 岁的阿姨录 15 秒日常说话(比如让她念:"今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭,路上慢点。") |
|
||||||
|
| **截取方案** | 影视剧中温柔母亲角色的对白片段;中年女性生活 vlog |
|
||||||
|
| **这个角色为什么重要** | 全片情感支柱。第 16 条 4 分 17 秒长戏是全片高潮,音色必须对 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `azhe` — 阿哲(前任男友)
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| **性别/年龄** | 男,25-28 岁 |
|
||||||
|
| **音色关键词** | 城市感、干净、理性、偏冷、没有攻击性但也没有温度 |
|
||||||
|
| **口音** | 标准普通话,北京口音淡 |
|
||||||
|
| **情绪** | 像在跟同事说话——礼貌但已经抽离 |
|
||||||
|
| **避免** | 太温暖、太油腻、太沧桑、方言重 |
|
||||||
|
| **截取方案** | 年轻男性播客主播冷静讲述片段;商务/科技类 vlog 男声旁白 |
|
||||||
|
| **台词量** | 少,只有 2 条,但"第一刀"效果全靠音色的冷感 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `xiaomei` — 小美(闺蜜)
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| **性别/年龄** | 女,23-25 岁 |
|
||||||
|
| **音色关键词** | 活泼、大大咧咧、语速偏快、北漂女孩感 |
|
||||||
|
| **口音** | 普通话,可以有点北方口语感("嘛""呗"之类自然) |
|
||||||
|
| **情绪** | 参考音频录**正常活泼状态**即可(醉戏靠后期调) |
|
||||||
|
| **避免** | 太嗲、太文静、太播音 |
|
||||||
|
| **找人方案** | 找一个 23-25 岁活泼的女生朋友,让她用"跟闺蜜聊天"的语气说段话 |
|
||||||
|
| **截取方案** | 年轻女性 vlog 日常聊天片段 |
|
||||||
|
| **附加说明** | `self-3y`(3年前的林夏)复用这个音色做基底,所以这条参考音频同时决定两个角色 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `zhounan` — 周南(初中同桌)
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| **性别/年龄** | 男,25-27 岁 |
|
||||||
|
| **音色关键词** | 温和、拘谨、不油腻、有点紧张感、二三线城市小镇青年气质 |
|
||||||
|
| **口音** | 普通话,稍带一点点不够标准的感觉(不是播音腔) |
|
||||||
|
| **情绪** | 克制的,说话声音略轻,像怕打扰到别人 |
|
||||||
|
| **避免** | 太自信、太老成、太帅哥感、太粗犷 |
|
||||||
|
| **截取方案** | 文艺/读书类播客里说话温和的男生;纪录片中普通年轻男性受访者 |
|
||||||
|
| **这个角色为什么重要** | 全片"苦中一缕甜"。他的声音必须让人觉得"这个人很好但不会推销自己" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `hr` — HR 王姐
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| **性别/年龄** | 女,35-40 岁 |
|
||||||
|
| **音色关键词** | 知性、职业、温和但有距离感、像个靠谱的大姐 |
|
||||||
|
| **口音** | 标准普通话 |
|
||||||
|
| **情绪** | 公事公办但不冷漠,带一点点疲惫的善意 |
|
||||||
|
| **避免** | 太年轻、太甜、太严厉、太做作 |
|
||||||
|
| **截取方案** | 职场类播客女主播;新闻女主播(偏柔和那种,不是央视腔) |
|
||||||
|
| **台词量** | 只有 1 条,但角色感很重要 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `anan` — 室友安安
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| **性别/年龄** | 女,22-24 岁 |
|
||||||
|
| **音色关键词** | 可爱、嗲、兴奋、比小美更"甜"一档 |
|
||||||
|
| **口音** | 普通话 |
|
||||||
|
| **情绪** | 自顾自的快乐,不是故意撒娇而是天生就这样 |
|
||||||
|
| **避免** | 和 `xiaomei` 太像(必须能区分) |
|
||||||
|
| **找人方案** | 找一个声线偏甜的女生朋友 |
|
||||||
|
| **截取方案** | 可爱类女性 UP 主日常 vlog |
|
||||||
|
| **台词量** | 只有 1 条短语音 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7-9. `dorm-a` / `dorm-b` / `dorm-c` — 寝室群三人
|
||||||
|
|
||||||
|
这三个角色同时出现在大学群聊里,**必须互相能区分,也必须和小美/安安区分**。
|
||||||
|
|
||||||
|
| 角色 | 人设 | 音色方向 | 和谁不能像 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **dorm-a** | 升职那位,语速快,爱热闹 | 开朗外向,语速快,声线偏亮 | 不能像 xiaomei(小美比她更"野") |
|
||||||
|
| **dorm-b** | 和事佬,温和 | 温柔日常,不突出,中间音色 | 不能像 anan(安安比她更嗲) |
|
||||||
|
| **dorm-c** | @林夏的那个,偏冷静 | 偏沉稳,年轻女声里偏低的那种 | 不能像 hr(HR 比她更成熟) |
|
||||||
|
|
||||||
|
每人需要一条独立的参考音频。找 3 个不同声线的年轻女声。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. `delivery` — 外卖小哥
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| **性别/年龄** | 男,28-32 岁 |
|
||||||
|
| **音色关键词** | 麻利、工作状态、不寒暄、北方普通话 |
|
||||||
|
| **口音** | 普通话 + 少许河南/河北腔(轻微就行) |
|
||||||
|
| **情绪** | 中性,工作语气,不热情也不冷漠 |
|
||||||
|
| **避免** | 太文雅、太慢 |
|
||||||
|
| **截取方案** | 短视频里外卖小哥/快递员说话的片段 |
|
||||||
|
| **台词量** | 只有 1 句话 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. `system` — 系统 TTS
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| **方案** | **不需要采集参考音频**,直接用 CosyVoice 内置预训练音色 |
|
||||||
|
| **音色方向** | 中性 AI 女声,清晰、无感情、类似 Siri 但更轻 |
|
||||||
|
| **操作** | 启动 WebUI 后在"预训练音色"模式里试听,选一个最合适的 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不需要单独采集的
|
||||||
|
|
||||||
|
| 角色 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| `self-3y`(3年前的自己) | 复用 `xiaomei` 的参考音频,合成时调速度 |
|
||||||
|
| `self-laugh`(林夏的笑声) | TTS 无法自然生成笑声。**最佳方案:找 `xiaomei` 同一个人笑一声录下来**。备选:后期合成 |
|
||||||
|
| `unknown`(未知号码) | 无台词,只有呼吸声,从音效库获取 |
|
||||||
|
| `system` | 用 CosyVoice 预训练音色,无需参考 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 汇总:你需要找到的人/素材
|
||||||
|
|
||||||
|
| # | 需求 | 优先级 | 最省事的方式 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **一位 45-55 岁女性**说 15 秒话 | 最高 | 找妈妈/阿姨/同事的妈妈录一段 |
|
||||||
|
| 2 | 一位 25-28 岁冷感男声 10 秒 | 高 | 播客/有声书截取 |
|
||||||
|
| 3 | **一位 23-25 岁活泼女生**说 15 秒话 | 最高 | 找朋友录(同时给 self-3y 用,再录一声笑给 self-laugh 用)|
|
||||||
|
| 4 | 一位 25-27 岁温和男声 10 秒 | 高 | 播客/纪录片截取 |
|
||||||
|
| 5 | 一位 35-40 岁知性女声 10 秒 | 中 | 播客截取 |
|
||||||
|
| 6 | 一位 22-24 岁甜美女声 10 秒 | 中 | 找朋友录或 UP 主截取 |
|
||||||
|
| 7-9 | **3 位不同声线的年轻女声**各 10 秒 | 中 | 可以从不同 UP 主截取 |
|
||||||
|
| 10 | 一位北方口音男性 10 秒 | 低 | 短视频截取 |
|
||||||
|
|
||||||
|
**最少找 2 个真人录音**(#1 妈妈 + #3 小美/林夏),其余都可以截取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 录音指导话术(发给帮忙录音的人)
|
||||||
|
|
||||||
|
### 给"妈妈"录音的人:
|
||||||
|
|
||||||
|
> 帮个忙,用手机录一段话,15-20 秒就行。找个安静的地方,用你平时说话的语气念:
|
||||||
|
>
|
||||||
|
> "今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭。路上慢点,别着急。对了,你上次说的那个同事后来怎么样了?没事就算了,妈妈就是随便问问。"
|
||||||
|
>
|
||||||
|
> 不用播音腔,就像平时跟孩子打电话一样说就行。录完发我微信就好,谢谢!
|
||||||
|
|
||||||
|
### 给"小美/林夏"录音的人:
|
||||||
|
|
||||||
|
> 帮个忙,用手机录一段话,15 秒左右。找个安静的地方,就用你平时跟朋友聊天的语气说:
|
||||||
|
>
|
||||||
|
> "哎你猜怎么着,今天路上遇到一个超搞笑的事,就那个奶茶店你知道吧,门口排了好长的队,结果我一去发现今天闭店了,白走一趟!"
|
||||||
|
>
|
||||||
|
> 然后**再录一声自然的笑**——就"噗"一下笑出来那种,不用太夸张,3 秒就行。
|
||||||
|
>
|
||||||
|
> 两条录音分开发我就行,谢谢!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件命名与交付
|
||||||
|
|
||||||
|
录好 / 截取好后,按这个格式命名,放到 `audio/00_raw/tts/prompt_ref/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
mom_ref.wav
|
||||||
|
azhe_ref.wav
|
||||||
|
xiaomei_ref.wav ← 同时作为 self-3y 基底
|
||||||
|
xiaomei_laugh_ref.wav ← 给 self-laugh 用
|
||||||
|
zhounan_ref.wav
|
||||||
|
hr_ref.wav
|
||||||
|
anan_ref.wav
|
||||||
|
dorm_a_ref.wav
|
||||||
|
dorm_b_ref.wav
|
||||||
|
dorm_c_ref.wav
|
||||||
|
delivery_ref.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
每条参考音频旁边配一个同名 `.txt` 记录对应文字内容(CosyVoice 克隆时需要):
|
||||||
|
|
||||||
|
```
|
||||||
|
mom_ref.txt → 写这段录音里说了什么字
|
||||||
|
azhe_ref.txt → ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**收齐这 10 条参考音频后,就可以开始 TTS 生产了。**
|
||||||
Reference in New Issue
Block a user