diff --git a/ROADMAP.md b/ROADMAP.md index d5b9dd2..06e4d76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -323,56 +323,105 @@ interface SaveData { - [x] `public/videos/stay_loop.mp4` — 6s 测试视频(0-3s 蓝色正文 + 3-6s 绿色循环段) - [x] 验证:正文播放完毕 → 进入循环 → 选项浮现 → 画面无缝来回 → 选择后跳转 -### P6 独立背景音乐 — 画面循环不打断 BGM(待实现) +### P6 独立背景音乐 + Ducking — 画面循环不打断 BGM ✅ 已完成 2026-06-08 -目标:将背景音乐从视频中剥离,由独立 AudioManager 驱动。视频循环/切换时 BGM 保持连贯播放, -不同场景之间用交叉淡化衔接。这也是《底特律:变人》《The Dark Pictures Anthology》等商业游戏的标配。 +目标:将 BGM 从视频中剥离,由独立 AudioSystem 驱动。视频循环/切换时 BGM 保持连贯,场景间交叉淡化衔接。 +QTE 和选择面板出现时 BGM 自动闪避(ducking)以确保提示音不被淹没。 + +**技术选型** + +- **Web Audio API** — `GainNode.exponentialRampToValueAtTime()` 实现指数渐变(听感均匀,Wwise/FMOD/UE 同款做法) +- **MP3** — 全浏览器支持(含 Safari),解码快。OGG 暂不采用(Safari 不支持),P14 短循环音效需要 OGG 时单独处理 +- **预加载** — `fetch(url) → decodeAudioData() → 缓存 AudioBuffer`,已解码 buffer 最多 3 个,LRU 淘汰 +- **视频不自动静音** — `videoMuted` 字段由制作者手动设置,引擎不做自动静音 **架构变更:** ``` Engine ├── VideoManager(A/B 双缓冲,只管画面和视频内音轨) -│ └── loopVideo 循环时只管画面 → BGM 不受影响 -└── AudioManager(独立 AudioContext/HTMLAudioElement,只管 BGM) - └── 按场景播放/交叉淡化/循环,独立于视频生命周期 +│ └── loopStart/loopEnd 循环 → BGM 不受影响 +└── AudioSystem(Web Audio API) + ├── AudioContext → GainNode(BGM) → destination + │ └── 多个 BufferSourceNode(新旧 BGM 交叉淡化,指数 ramp) + └── ducking 控制:QTE/选择/热点触发 → GainNode ramp 降 → 事件结束 → ramp 恢复 ``` +**BGM 切换策略:** + +``` +goToScene(Scene B) + ├── bgmUrl 相同?→ 什么都不做,继续播(bgmVolume 变化 → ramp 调整) + ├── bgmUrl 为 null?→ 当前 BGM 指数 fade out(bgmCrossFade 秒) + └── bgmUrl 不同? + ├── fetch + decode BGM B(若未缓存) + ├── AudioBufferSourceNode 播 BGM B,gain 从 0.001 ramp 到 bgmVolume + ├── 同时 BGM A 的 gain ramp 到 0.001,耗时 bgmCrossFade 秒 + ├── ramp 完成后 stop BGM A 的 source(释放) + └── 画面交叉淡化照常(画面和 BGM 各自独立过渡) +``` + +**Ducking 自动闪避策略:** + +| 触发事件 | duck 目标值 | 进入耗时 | 恢复耗时 | +|----------|------------|---------|---------| +| QTE 触发 | bgmDuckLevel × bgmVolume | 0.3s | bgmDuckFade | +| 选择面板出现 | bgmDuckLevel × bgmVolume | bgmDuckFade | bgmDuckFade | +| 视频热点出现 | bgmDuckLevel × bgmVolume | bgmDuckFade | bgmDuckFade | + +实现方式:AudioSystem 内部维护一个"当前 duck 等级"计数器(允许多个事件重叠)。 +GainNode 的 ramp 目标值 = `Math.min(bgmVolume, bgmDuckLevel × bgmVolume)`。 +最后一个事件结束时恢复为 `bgmVolume`。 + **场景数据设计:** ```json { "id": "tense_moment", "videoUrl": "/videos/tense_no_bgm.mp4", - "loopVideoUrl": "/videos/tense_loop_no_bgm.mp4", + "loopStart": 8.0, + "loopEnd": 10.0, "bgmUrl": "/audio/tense_bgm.mp3", "bgmVolume": 0.8, "bgmCrossFade": 2.0, - "bgmContinue": true, - "videoMuted": true, + "bgmDuckLevel": 0.35, + "bgmDuckFade": 0.5, + "videoMuted": false, "choices": [...] } ``` -**工作流对比:** +**字段说明:** -| 阶段 | 改进前 | 改进后 | -|------|--------|--------| -| 主视频播放 | 视频自带 BGM | AudioManager 独立播 BGM,视频 muted | -| 进入选择等待 | 循环视频 → BGM 断掉重来 | 视频循环 → BGM 继续连贯 | -| 选择后切场景 | 下一段视频 BGM 硬切 | BGM 交叉淡化过渡(bgmCrossFade 秒) | -| 关音乐/调音量 | 无法控制 | AudioManager 提供音量/静音接口 | -| 同一 BGM 多场景 | 每个 mp4 嵌一份,浪费 | 共用同一文件,节省带宽 | +| 字段 | 类型 | 默认 | 说明 | +|------|------|------|------| +| `bgmUrl` | string | null | BGM 文件路径(MP3),null/falsy 表示静默并 fade out 当前 BGM | +| `bgmVolume` | number | 0.8 | 目标音量(0~1) | +| `bgmCrossFade` | number | 2.0 | BGM 切换交叉淡化时长(秒) | +| `bgmDuckLevel` | number | 0.35 | QTE/选择/热点时 duck 到 bgmVolume 的百分比 | +| `bgmDuckFade` | number | 0.5 | duck 进入和恢复的渐变时长(秒) | +| `videoMuted` | bool | false | 制作者手动设置,引擎不自动静音 | **实现清单:** -- [ ] `engine/systems/AudioSystem.ts` — 新增:BGM 播放、交叉淡化(GainNode ramp)、循环、音量/静音 -- [ ] `engine/core/Engine.ts` — 集成 AudioSystem;场景切换时对比 bgmUrl,同源→继续,不同→crossFade -- [ ] `engine/types.ts` — `SceneNode` 加 `bgmUrl`、`bgmVolume`、`bgmCrossFade`、`bgmContinue`、`videoMuted` -- [ ] `engine/core/VideoManager.ts` — 支持 `muted` 参数(视频音轨静音但在 BGM 模式下禁用) -- [ ] `public/scenes/demo.json` — 示例场景拆分视频+音频 -- [ ] `editor/components/NodeEditor.vue` — BGM 字段编辑面板 -- [ ] 验证:BGM 跨越视频循环连续播放、场景切换交叉淡化、音量控制生效 +- [x] `engine/systems/AudioSystem.ts` — Web Audio API:fetch+decode 缓存、BufferSourceNode 创建、GainNode 指数 ramp 交叉淡化、同源继续/不同 crossFade/静音 fade out、ducking 事件接口 +- [x] `engine/core/Engine.ts` — 集成 AudioSystem;`goToScene` 对比 `bgmUrl` 调度切换;QTE/choice/hotspot 触发时调用 `audioSystem.duckOn()`/`duckOff()` +- [x] `engine/types.ts` — `SceneNode` 加 `bgmUrl`、`bgmVolume`、`bgmCrossFade`、`bgmDuckLevel`、`bgmDuckFade`、`videoMuted` +- [x] `engine/core/VideoManager.ts` — 根据 `videoMuted` 设置 `