commit 9c5d9e586c332ffe2f1181a21f5dc6d0cedb1a2a Author: xiangsilian Date: Mon May 18 18:39:49 2026 +0800 从github迁移过来 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3223e0a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/SOP_AI音频生产.md b/SOP_AI音频生产.md new file mode 100644 index 0000000..15dc2e5 --- /dev/null +++ b/SOP_AI音频生产.md @@ -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 控制 | 长句中段加 ``,结尾"晚安"前加 `` | +| 后期处理 | 加 -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 中插入 `` 模拟停顿 + 末句故意拉长元音;后期再叠 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 年前刚来北京,对一切都新鲜
②「**迷茫版**」:3 年前其实已经累,只是没说
③「**温柔版**」:3 年前对未来的自己说"撑住" | +| 选择逻辑 | 按 PROFILE 标签匹配:
`engagement` → 明朗版(与今夜的投入呼应)
`avoidance` → 迷茫版(与今夜的回避呼应)
`nostalgia` → 温柔版(与今夜的怀旧呼应) | +| 时长 | 每版 ~50 秒 | +| 后期处理 | 加 -2 dB 高频衰减 + 微弱"录音笔底噪",模拟"3 年前用手机备忘录录的"质感 | +| Prompt 前缀(明朗版) | `<语气:23 岁,刚到北京半年,兴奋里带一点紧张,相信明天比今天好>` | +| Prompt 前缀(迷茫版) | `<语气:23 岁,独自在出租屋里,假装坚强,最后一句声音轻下去>` | +| Prompt 前缀(温柔版) | `<语气:23 岁,写给未来的自己,平静、有力、像在拥抱什么>` | + +#### 卡 11 · `self-laugh`(隐藏结局 E 的笑声) + +| 项 | 值 | +|---|---| +| 主用 TTS | MiniMax Speech 02 HD(同 `self-3y` 同一克隆音色) | +| 内容 | 一段 3 秒的轻笑——"噗"+ 一声短笑 + 一声轻轻的鼻息 | +| 制作方式 | TTS 难以直接生成自然笑声,**建议方案**:
① 找声线接近的 24 岁女声真人**录一次笑**(5 分钟搞定,零成本)
② 用 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} +{速度} +{情绪标签} +{角色 prompt 前缀} + +[SSML 正文] +``` + +**SSML 关键标记速查**: + +| 标记 | 作用 | 用例 | +|---|---|---| +| `` | 停顿 | 长句中喘气、"她说……(停)后来呢" | +| `` | 重读 | 关键词强调 | +| `慢句` | 局部变速 | 醉酒段、临终段 | +| `低声` | 局部降调 | 内心戏 | +| `XYZ` | 字母逐字读 | "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 diff --git a/audio/batch_tts.py b/audio/batch_tts.py new file mode 100644 index 0000000..23fcb8e --- /dev/null +++ b/audio/batch_tts.py @@ -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() diff --git a/audio/batch_tts_voxcpm.py b/audio/batch_tts_voxcpm.py new file mode 100644 index 0000000..196ad57 --- /dev/null +++ b/audio/batch_tts_voxcpm.py @@ -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() diff --git a/audio/check_tts_env.sh b/audio/check_tts_env.sh new file mode 100644 index 0000000..97826ed --- /dev/null +++ b/audio/check_tts_env.sh @@ -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 "=== 检查完成:可开始生产 ===" diff --git a/audio/check_voice_library.sh b/audio/check_voice_library.sh new file mode 100644 index 0000000..8ca8ddc --- /dev/null +++ b/audio/check_voice_library.sh @@ -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。" diff --git a/audio/gen_music_acestep.py b/audio/gen_music_acestep.py new file mode 100644 index 0000000..3b85a5b --- /dev/null +++ b/audio/gen_music_acestep.py @@ -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() diff --git a/audio/gen_sfx_acestep.py b/audio/gen_sfx_acestep.py new file mode 100644 index 0000000..71cf65e --- /dev/null +++ b/audio/gen_sfx_acestep.py @@ -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() diff --git a/audio/gen_tts_missing.py b/audio/gen_tts_missing.py new file mode 100644 index 0000000..0939716 --- /dev/null +++ b/audio/gen_tts_missing.py @@ -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() diff --git a/audio/process_audio.sh b/audio/process_audio.sh new file mode 100644 index 0000000..87c5a6b --- /dev/null +++ b/audio/process_audio.sh @@ -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')" diff --git a/audio/start_webui.sh b/audio/start_webui.sh new file mode 100644 index 0000000..a95dc0d --- /dev/null +++ b/audio/start_webui.sh @@ -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 diff --git a/audio/test_acestep.py b/audio/test_acestep.py new file mode 100644 index 0000000..3d760e7 --- /dev/null +++ b/audio/test_acestep.py @@ -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()) diff --git a/case_doc/gpt_case/03_盲语审判.md b/case_doc/gpt_case/03_盲语审判.md new file mode 100644 index 0000000..fb0f4aa --- /dev/null +++ b/case_doc/gpt_case/03_盲语审判.md @@ -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 分钟案件,验证玩家是否能通过声音发现矛盾,是否理解触摸卷宗操作,是否接受无视觉证据的法庭推理节奏。 diff --git a/case_doc/gpt_case/08_最后热线.md b/case_doc/gpt_case/08_最后热线.md new file mode 100644 index 0000000..781f097 --- /dev/null +++ b/case_doc/gpt_case/08_最后热线.md @@ -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 类救援队,验证玩家是否能通过背景声定位、理解调度后果,并在没有地图的情况下感到自己掌控城市局势。 diff --git a/case_doc/opus_case/00_总览.md b/case_doc/opus_case/00_总览.md new file mode 100644 index 0000000..17be9c7 --- /dev/null +++ b/case_doc/opus_case/00_总览.md @@ -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 三件短篇打包,作为"实验剧场"主题盒子。 diff --git a/case_doc/opus_case/01_盲打字员的最后一天.md b/case_doc/opus_case/01_盲打字员的最后一天.md new file mode 100644 index 0000000..0ff4ebd --- /dev/null +++ b/case_doc/opus_case/01_盲打字员的最后一天.md @@ -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 分钟"的小品级体验,非游戏受众也能接受。 + +## 风险点 + +- 玩家可能误以为"我打错了所以游戏不动",需要在前两章用极强的反馈引导(每次输入都有正向音效)。 +- 录音权限需在开场温柔地要——可以做成"今天精神好不好?说一句话给我听"。 diff --git a/case_doc/opus_case/02_梦的接线员.md b/case_doc/opus_case/02_梦的接线员.md new file mode 100644 index 0000000..e10e699 --- /dev/null +++ b/case_doc/opus_case/02_梦的接线员.md @@ -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 秒的轻教学。 diff --git a/case_doc/opus_case/03_空房子的调律师.md b/case_doc/opus_case/03_空房子的调律师.md new file mode 100644 index 0000000..8a74864 --- /dev/null +++ b/case_doc/opus_case/03_空房子的调律师.md @@ -0,0 +1,66 @@ +# 03 · 空房子的调律师 + +> **类型**:解谜 / 情感 / 中篇 +> **时长**:约 40 分钟 +> **核心隐喻**:钢琴里关着一个十年没人弹过的下午。你不是来调音的,你是来把它放出来的。 + +--- + +## 一句话概述 + +你是一位老钢琴调律师,被请到一栋郊区别墅。屋主告诉你"这架琴十年没动过了,请你调好"。你的工作台是一架看不见的琴——但你不靠"听音准",而是靠**手感、节奏与对话**,在黑屏上"摸"完 88 个键,每一个琴键都拴着这个家庭的一段记忆。 + +## 核心玩法 + +> **明确说明**:本作**不依赖玩家的音感与音高判断**。所有"调音"的判断都由游戏完成;玩家的任务是**完成手势** + **听对话**。 + +- **触觉调音**:屏幕代表琴弦面板。每个琴键由游戏自动"亮起"(伴随轻微震动),玩家的任务是: + 1. 用单指**长按**找到当前调的弦(震动会变强)。 + 2. 用另一只手在屏幕另一侧**上下滑动**调整张力——上滑收紧、下滑放松。 + 3. 当震动节奏稳定下来 = 调好。 +- **整个过程不需要听音准**。判断"调好"的依据是**震动反馈是否平稳**——这是一种触觉解谜,而非听觉解谜。 +- 88 个键不会全调,只会调到剧情需要的 12 个键。每个键调好后,钢琴会**自动弹出**一段乐句——伴随**屋主的一段回忆独白**。 + +## 剧情大纲 + +- **抵达**:屋主是一位 60 岁左右的男人。声音温和,但语速比常人慢半拍。他领你进屋,说"琴在客厅,我去给你倒杯水"。之后他几乎全程不在场,只通过门外的远声出现。 +- **12 段记忆**(按你调音的顺序触发,玩家可以随意挑键,但调键的**顺序会改变结局**): + - 一位小女孩第一次找到中央 C + - 父女合奏的儿童曲 + - 女儿青春期摔门 + - 母亲生病 + - 女儿出国前最后一次弹奏 + - 母亲葬礼后的沉默 + - 女儿打来的国际长途 + - 女儿结婚,他没去 + - 女儿生了孩子 + - 一封从未寄出的信 + - 屋主自己学着弹了一段女儿小时候的曲子 + - 今天——他请你来调琴的真正原因 +- **结尾**:所有键调完后,屋主回到客厅,请你"弹一首"。玩家必须用之前学过的所有手势,**复现**他女儿七岁那年弹的那首曲子(不要求节拍精准,只要求把整套手势走完)。屋外的雨声停了。 + +## 交互亮点 + +- **"听不清的对话"**:屋主很多话是从隔壁房间传来的,**故意不让你听清每一个字**。这是一种"礼貌的距离"——你是匠人,不是亲人,但你恰好在这个下午陪他一起回忆。 +- **键的顺序 = 故事的顺序**:玩家调键的顺序会改变独白的衔接逻辑(用 AI 文本编排),让每个玩家的故事略有不同。 +- **"破音"机制**:如果玩家粗暴调律(滑动太猛),琴会"崩"一根弦,对应的那段记忆**永远无法再听到**。这成为隐藏成就/遗憾的来源。 + +## 音频设计 + +- **无环境 BGM**,全程靠: + - 房间环境声(远处雨声、墙上挂钟、二楼地板偶尔的吱嘎) + - 钢琴本身的木质共鸣 + - 调音叉、扳手等工具拟音 + - 屋主的独白与远声 +- 关键章节用单段独奏钢琴乐句作为"记忆主题"。每段记忆有自己的主旋律。 +- 触觉震动作为"无声的音"——是这个游戏最重要的输出通道之一。 + +## 商业化 / 包装思路 + +- 中篇付费单品,定位安静、克制的"客厅向"作品。 +- 可与钢琴品牌、唱片品牌联名做主题音乐包。 + +## 风险点 + +- 触觉震动的强弱差异在不同手机上差距很大,需要做"开场震动校准"。 +- "调律师"这个职业陌生,需要在前 3 分钟用一段简短的电话铃声+对话把背景交代清楚("喂,是来调琴的师傅吗?")。 diff --git a/case_doc/opus_case/04_晚安电台FM87.6.md b/case_doc/opus_case/04_晚安电台FM87.6.md new file mode 100644 index 0000000..c04c489 --- /dev/null +++ b/case_doc/opus_case/04_晚安电台FM87.6.md @@ -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 用一段精彩的开场独白拉住注意力。 diff --git a/case_doc/opus_case/05_桌底摩斯.md b/case_doc/opus_case/05_桌底摩斯.md new file mode 100644 index 0000000..bdf080e --- /dev/null +++ b/case_doc/opus_case/05_桌底摩斯.md @@ -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)。 +- 双线对话信息密度高,需要做难度档位("听不过来就关掉接应人语音、只看震动节拍"模式)。 diff --git a/case_doc/opus_case/06_妻子失踪的48小时.md b/case_doc/opus_case/06_妻子失踪的48小时.md new file mode 100644 index 0000000..22a776b --- /dev/null +++ b/case_doc/opus_case/06_妻子失踪的48小时.md @@ -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 小时机制对玩家"硬核",需要支持"专注模式 / 加速模式"两档。 +- 大量配音对本地化是巨大成本——首发可能仅中文。 diff --git a/case_doc/opus_case/07_静修营Day7.md b/case_doc/opus_case/07_静修营Day7.md new file mode 100644 index 0000000..0208983 --- /dev/null +++ b/case_doc/opus_case/07_静修营Day7.md @@ -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 天结束时给出"明天的练习可能会让你想起一些事,可选择退出"的预警。 diff --git a/case_doc/opus_case/08_平安夜的电话.md b/case_doc/opus_case/08_平安夜的电话.md new file mode 100644 index 0000000..93f1a7f --- /dev/null +++ b/case_doc/opus_case/08_平安夜的电话.md @@ -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 二女儿那通)有强情感钩子。 diff --git a/case_doc/opus_case/09_第七幕观众即角色.md b/case_doc/opus_case/09_第七幕观众即角色.md new file mode 100644 index 0000000..bf7dc6a --- /dev/null +++ b/case_doc/opus_case/09_第七幕观众即角色.md @@ -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 分钟"的考验,必须用极强的剧本写作和声音表演留住玩家——否则玩家撑不到第七幕。 +- "无台词角色"的代入感需要精心设计,部分玩家可能感到"我做什么都没用"。 diff --git a/case_doc/opus_case/10_死者通讯录.md b/case_doc/opus_case/10_死者通讯录.md new file mode 100644 index 0000000..d516c1c --- /dev/null +++ b/case_doc/opus_case/10_死者通讯录.md @@ -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 通后由"接线员错误"引导玩家发现这个机制。 diff --git a/game/index.html b/game/index.html new file mode 100644 index 0000000..3bee316 --- /dev/null +++ b/game/index.html @@ -0,0 +1,139 @@ + + + + + + + + + 林夏 + + + +
+ +
+

林夏

+
+ 今晚 16 条消息
+ 你接谁,不接谁
+ 决定明天的自己 +
+
+
操 作 说 明
+ +
+
通用(随时可用)
+
下滑查看当前状态
+
上滑打开收件箱
+
+ +
+
来电时
+
单点接听
+
左滑拒接
+
+ +
+
收到消息
+
单点播放
+
左滑忽略
+
+ +
+
听完消息
+
单点回复"嗯"
+
双点标准回复
+
长按更多回复选项
+
左滑已读不回
+
+ +
+
选择回复时
+
上下左右滑选择选项
+
单点重听选项
+
+
+ +
· 轻触屏幕开始 ·
+
+ 建议关灯 · 戴上耳机
+ 全程约 60 分钟 +
+
+ +
+
+ + + + + + + + + + + diff --git a/game/js/audio.js b/game/js/audio.js new file mode 100644 index 0000000..5199bbc --- /dev/null +++ b/game/js/audio.js @@ -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(); diff --git a/game/js/engine.js b/game/js/engine.js new file mode 100644 index 0000000..0c2e823 --- /dev/null +++ b/game/js/engine.js @@ -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'; + } +} diff --git a/game/js/gestures.js b/game/js/gestures.js new file mode 100644 index 0000000..74c0c28 --- /dev/null +++ b/game/js/gestures.js @@ -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; + } +} diff --git a/game/js/haptics.js b/game/js/haptics.js new file mode 100644 index 0000000..02bbbce --- /dev/null +++ b/game/js/haptics.js @@ -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); }, +}; diff --git a/game/js/main.js b/game/js/main.js new file mode 100644 index 0000000..4a819c9 --- /dev/null +++ b/game/js/main.js @@ -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(); +}); diff --git a/game/js/profile.js b/game/js/profile.js new file mode 100644 index 0000000..c5d6024 --- /dev/null +++ b/game/js/profile.js @@ -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'; + } +} diff --git a/game/js/story.js b/game/js/story.js new file mode 100644 index 0000000..9167a9d --- /dev/null +++ b/game/js/story.js @@ -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`; diff --git a/game/js/utils.js b/game/js/utils.js new file mode 100644 index 0000000..e277616 --- /dev/null +++ b/game/js/utils.js @@ -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(); + }, +}; diff --git a/game/manifest.json b/game/manifest.json new file mode 100644 index 0000000..af6abcf --- /dev/null +++ b/game/manifest.json @@ -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" } + ] +} diff --git a/game/操作说明.md b/game/操作说明.md new file mode 100644 index 0000000..9601460 --- /dev/null +++ b/game/操作说明.md @@ -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/ 环境底噪 +``` + +--- + +*"屏幕坏了,但她的一夜还没结束。"* diff --git a/gdd/02-心跳连线.md b/gdd/02-心跳连线.md new file mode 100644 index 0000000..e15b710 --- /dev/null +++ b/gdd/02-心跳连线.md @@ -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* diff --git a/gdd/04-深渊电台.md b/gdd/04-深渊电台.md new file mode 100644 index 0000000..4747344 --- /dev/null +++ b/gdd/04-深渊电台.md @@ -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* diff --git a/gdd/06-回声神探.md b/gdd/06-回声神探.md new file mode 100644 index 0000000..586c7cc --- /dev/null +++ b/gdd/06-回声神探.md @@ -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* diff --git a/gdd/07-冥河摆渡人.md b/gdd/07-冥河摆渡人.md new file mode 100644 index 0000000..3f5f64b --- /dev/null +++ b/gdd/07-冥河摆渡人.md @@ -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* diff --git a/gdd/09-心音诊所.md b/gdd/09-心音诊所.md new file mode 100644 index 0000000..6784996 --- /dev/null +++ b/gdd/09-心音诊所.md @@ -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* diff --git a/voice/anan/anan_extra_13.wav b/voice/anan/anan_extra_13.wav new file mode 100644 index 0000000..387758c Binary files /dev/null and b/voice/anan/anan_extra_13.wav differ diff --git a/voice/azhe/azhe_extra_05.wav b/voice/azhe/azhe_extra_05.wav new file mode 100644 index 0000000..ced6c7c Binary files /dev/null and b/voice/azhe/azhe_extra_05.wav differ diff --git a/voice/delivery/delivery_01.wav b/voice/delivery/delivery_01.wav new file mode 100644 index 0000000..bb6c004 Binary files /dev/null and b/voice/delivery/delivery_01.wav differ diff --git a/voice/delivery/delivery_02.wav b/voice/delivery/delivery_02.wav new file mode 100644 index 0000000..486dc63 Binary files /dev/null and b/voice/delivery/delivery_02.wav differ diff --git a/voice/delivery/delivery_03.wav b/voice/delivery/delivery_03.wav new file mode 100644 index 0000000..ce5981f Binary files /dev/null and b/voice/delivery/delivery_03.wav differ diff --git a/voice/delivery/delivery_04.wav b/voice/delivery/delivery_04.wav new file mode 100644 index 0000000..69204f9 Binary files /dev/null and b/voice/delivery/delivery_04.wav differ diff --git a/voice/delivery/delivery_05.wav b/voice/delivery/delivery_05.wav new file mode 100644 index 0000000..c43cdec Binary files /dev/null and b/voice/delivery/delivery_05.wav differ diff --git a/voice/delivery/delivery_extra_01.wav b/voice/delivery/delivery_extra_01.wav new file mode 100644 index 0000000..5f17407 Binary files /dev/null and b/voice/delivery/delivery_extra_01.wav differ diff --git a/voice/delivery/delivery_extra_02.wav b/voice/delivery/delivery_extra_02.wav new file mode 100644 index 0000000..50b655f Binary files /dev/null and b/voice/delivery/delivery_extra_02.wav differ diff --git a/voice/delivery/delivery_extra_03.wav b/voice/delivery/delivery_extra_03.wav new file mode 100644 index 0000000..06253a1 Binary files /dev/null and b/voice/delivery/delivery_extra_03.wav differ diff --git a/voice/delivery/delivery_extra_04.wav b/voice/delivery/delivery_extra_04.wav new file mode 100644 index 0000000..c266eb5 Binary files /dev/null and b/voice/delivery/delivery_extra_04.wav differ diff --git a/voice/delivery/delivery_extra_05.wav b/voice/delivery/delivery_extra_05.wav new file mode 100644 index 0000000..dc3f80a Binary files /dev/null and b/voice/delivery/delivery_extra_05.wav differ diff --git a/voice/delivery/delivery_extra_06.wav b/voice/delivery/delivery_extra_06.wav new file mode 100644 index 0000000..63f3d75 Binary files /dev/null and b/voice/delivery/delivery_extra_06.wav differ diff --git a/voice/delivery/delivery_extra_07.wav b/voice/delivery/delivery_extra_07.wav new file mode 100644 index 0000000..4041137 Binary files /dev/null and b/voice/delivery/delivery_extra_07.wav differ diff --git a/voice/delivery/delivery_extra_08.wav b/voice/delivery/delivery_extra_08.wav new file mode 100644 index 0000000..01093e3 Binary files /dev/null and b/voice/delivery/delivery_extra_08.wav differ diff --git a/voice/delivery/delivery_extra_09.wav b/voice/delivery/delivery_extra_09.wav new file mode 100644 index 0000000..3a29143 Binary files /dev/null and b/voice/delivery/delivery_extra_09.wav differ diff --git a/voice/delivery/delivery_extra_10.wav b/voice/delivery/delivery_extra_10.wav new file mode 100644 index 0000000..d6f2e69 Binary files /dev/null and b/voice/delivery/delivery_extra_10.wav differ diff --git a/voice/delivery/delivery_extra_11.wav b/voice/delivery/delivery_extra_11.wav new file mode 100644 index 0000000..88976fb Binary files /dev/null and b/voice/delivery/delivery_extra_11.wav differ diff --git a/voice/delivery/delivery_extra_12.wav b/voice/delivery/delivery_extra_12.wav new file mode 100644 index 0000000..11cc97c Binary files /dev/null and b/voice/delivery/delivery_extra_12.wav differ diff --git a/voice/delivery/delivery_extra_13.wav b/voice/delivery/delivery_extra_13.wav new file mode 100644 index 0000000..a378c28 Binary files /dev/null and b/voice/delivery/delivery_extra_13.wav differ diff --git a/voice/delivery/delivery_extra_14.wav b/voice/delivery/delivery_extra_14.wav new file mode 100644 index 0000000..3d756c3 Binary files /dev/null and b/voice/delivery/delivery_extra_14.wav differ diff --git a/voice/delivery/delivery_extra_15.wav b/voice/delivery/delivery_extra_15.wav new file mode 100644 index 0000000..146540d Binary files /dev/null and b/voice/delivery/delivery_extra_15.wav differ diff --git a/voice/delivery/delivery_extra_16.wav b/voice/delivery/delivery_extra_16.wav new file mode 100644 index 0000000..fe5d403 Binary files /dev/null and b/voice/delivery/delivery_extra_16.wav differ diff --git a/voice/delivery/delivery_extra_17.wav b/voice/delivery/delivery_extra_17.wav new file mode 100644 index 0000000..cf164cd Binary files /dev/null and b/voice/delivery/delivery_extra_17.wav differ diff --git a/voice/delivery/delivery_extra_18.wav b/voice/delivery/delivery_extra_18.wav new file mode 100644 index 0000000..171269d Binary files /dev/null and b/voice/delivery/delivery_extra_18.wav differ diff --git a/voice/delivery/delivery_extra_19.wav b/voice/delivery/delivery_extra_19.wav new file mode 100644 index 0000000..707bad9 Binary files /dev/null and b/voice/delivery/delivery_extra_19.wav differ diff --git a/voice/delivery/delivery_extra_20.wav b/voice/delivery/delivery_extra_20.wav new file mode 100644 index 0000000..2ddd878 Binary files /dev/null and b/voice/delivery/delivery_extra_20.wav differ diff --git a/voice/dorm_a/000017e3-c450-4ec6-ae77-d1e8a6a57897.wav b/voice/dorm_a/000017e3-c450-4ec6-ae77-d1e8a6a57897.wav new file mode 100644 index 0000000..eb0b9fb Binary files /dev/null and b/voice/dorm_a/000017e3-c450-4ec6-ae77-d1e8a6a57897.wav differ diff --git a/voice/dorm_b/a001c446-ef9b-47fe-a690-9d359f67ae90.wav b/voice/dorm_b/a001c446-ef9b-47fe-a690-9d359f67ae90.wav new file mode 100644 index 0000000..ca6fce5 Binary files /dev/null and b/voice/dorm_b/a001c446-ef9b-47fe-a690-9d359f67ae90.wav differ diff --git a/voice/dorm_c/dorm_c_extra_15.wav b/voice/dorm_c/dorm_c_extra_15.wav new file mode 100644 index 0000000..cf637ec Binary files /dev/null and b/voice/dorm_c/dorm_c_extra_15.wav differ diff --git a/voice/hr/hr_extra_18.wav b/voice/hr/hr_extra_18.wav new file mode 100644 index 0000000..67b0911 Binary files /dev/null and b/voice/hr/hr_extra_18.wav differ diff --git a/voice/mom/mom_extra_04.wav b/voice/mom/mom_extra_04.wav new file mode 100644 index 0000000..fdcc3c2 Binary files /dev/null and b/voice/mom/mom_extra_04.wav differ diff --git a/voice/xiaomei/xiaomei_03.wav b/voice/xiaomei/xiaomei_03.wav new file mode 100644 index 0000000..12e55de Binary files /dev/null and b/voice/xiaomei/xiaomei_03.wav differ diff --git a/voice/zhounan/zhounan_extra_10.wav b/voice/zhounan/zhounan_extra_10.wav new file mode 100644 index 0000000..36cb646 Binary files /dev/null and b/voice/zhounan/zhounan_extra_10.wav differ diff --git a/voice_mp3/anan/candidate_04.mp3 b/voice_mp3/anan/candidate_04.mp3 new file mode 100644 index 0000000..7fc076c Binary files /dev/null and b/voice_mp3/anan/candidate_04.mp3 differ diff --git a/voice_mp3/azhe/candidate_05.mp3 b/voice_mp3/azhe/candidate_05.mp3 new file mode 100644 index 0000000..d11b4d0 Binary files /dev/null and b/voice_mp3/azhe/candidate_05.mp3 differ diff --git a/voice_mp3/delivery/candidate_03.mp3 b/voice_mp3/delivery/candidate_03.mp3 new file mode 100644 index 0000000..8e0d966 Binary files /dev/null and b/voice_mp3/delivery/candidate_03.mp3 differ diff --git a/voice_mp3/dorm_a/candidate_03.mp3 b/voice_mp3/dorm_a/candidate_03.mp3 new file mode 100644 index 0000000..e335077 Binary files /dev/null and b/voice_mp3/dorm_a/candidate_03.mp3 differ diff --git a/voice_mp3/dorm_b/candidate_02.mp3 b/voice_mp3/dorm_b/candidate_02.mp3 new file mode 100644 index 0000000..b645fe2 Binary files /dev/null and b/voice_mp3/dorm_b/candidate_02.mp3 differ diff --git a/voice_mp3/dorm_c/candidate_08.mp3 b/voice_mp3/dorm_c/candidate_08.mp3 new file mode 100644 index 0000000..1a477d5 Binary files /dev/null and b/voice_mp3/dorm_c/candidate_08.mp3 differ diff --git a/voice_mp3/hr/candidate_02.mp3 b/voice_mp3/hr/candidate_02.mp3 new file mode 100644 index 0000000..9b1ed60 Binary files /dev/null and b/voice_mp3/hr/candidate_02.mp3 differ diff --git a/voice_mp3/mom/candidate_11.mp3 b/voice_mp3/mom/candidate_11.mp3 new file mode 100644 index 0000000..79d072f Binary files /dev/null and b/voice_mp3/mom/candidate_11.mp3 differ diff --git a/voice_mp3/xiaomei/candidate_15.mp3 b/voice_mp3/xiaomei/candidate_15.mp3 new file mode 100644 index 0000000..2c78134 Binary files /dev/null and b/voice_mp3/xiaomei/candidate_15.mp3 differ diff --git a/voice_mp3/xiaomei/candidate_19.mp3 b/voice_mp3/xiaomei/candidate_19.mp3 new file mode 100644 index 0000000..651c4a5 Binary files /dev/null and b/voice_mp3/xiaomei/candidate_19.mp3 differ diff --git a/voice_mp3/zhounan/candidate_12.mp3 b/voice_mp3/zhounan/candidate_12.mp3 new file mode 100644 index 0000000..a81a814 Binary files /dev/null and b/voice_mp3/zhounan/candidate_12.mp3 differ diff --git a/执行文档_音频素材制作.md b/执行文档_音频素材制作.md new file mode 100644 index 0000000..c173295 --- /dev/null +++ b/执行文档_音频素材制作.md @@ -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())` 确认。 diff --git a/林夏.md b/林夏.md new file mode 100644 index 0000000..04a6a46 --- /dev/null +++ b/林夏.md @@ -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 上架 + 小红书种草 | + +--- + +## 附录 · 一句话给主创团队 + +> "这不是一个让女孩们更难过的游戏。这是一个让她们在难过的时候,知道**有人也曾这样难过、并且活下来**的游戏。" diff --git a/音色采集清单.md b/音色采集清单.md new file mode 100644 index 0000000..1e0f7e0 --- /dev/null +++ b/音色采集清单.md @@ -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 生产了。**