feat: audio system, demo scene updates, docs, and engine improvements

This commit is contained in:
2026-06-08 23:18:33 +08:00
parent 514c8f5207
commit 4bfdfbc27d
8 changed files with 316 additions and 24 deletions

View File

@@ -323,56 +323,105 @@ interface SaveData {
- [x] `public/videos/stay_loop.mp4` 6s 测试视频0-3s 蓝色正文 + 3-6s 绿色循环段 - [x] `public/videos/stay_loop.mp4` 6s 测试视频0-3s 蓝色正文 + 3-6s 绿色循环段
- [x] 验证正文播放完毕 进入循环 选项浮现 画面无缝来回 选择后跳转 - [x] 验证正文播放完毕 进入循环 选项浮现 画面无缝来回 选择后跳转
### P6 独立背景音乐 — 画面循环不打断 BGM(待实现) ### P6 独立背景音乐 + Ducking — 画面循环不打断 BGM ✅ 已完成 2026-06-08
目标背景音乐从视频中剥离由独立 AudioManager 驱动视频循环/切换时 BGM 保持连贯播放 目标 BGM 从视频中剥离由独立 AudioSystem 驱动视频循环/切换时 BGM 保持连贯场景间交叉淡化衔接
不同场景之间用交叉淡化衔接这也是底特律变人》《The Dark Pictures Anthology等商业游戏的标配 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 Engine
├── VideoManagerA/B 双缓冲,只管画面和视频内音轨) ├── VideoManagerA/B 双缓冲,只管画面和视频内音轨)
│ └── loopVideo 循环时只管画面 → BGM 不受影响 │ └── loopStart/loopEnd 循环 → BGM 不受影响
└── AudioManager独立 AudioContext/HTMLAudioElement只管 BGM └── AudioSystemWeb 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 outbgmCrossFade 秒)
└── bgmUrl 不同?
├── fetch + decode BGM B若未缓存
├── AudioBufferSourceNode 播 BGM Bgain 从 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 ```json
{ {
"id": "tense_moment", "id": "tense_moment",
"videoUrl": "/videos/tense_no_bgm.mp4", "videoUrl": "/videos/tense_no_bgm.mp4",
"loopVideoUrl": "/videos/tense_loop_no_bgm.mp4", "loopStart": 8.0,
"loopEnd": 10.0,
"bgmUrl": "/audio/tense_bgm.mp3", "bgmUrl": "/audio/tense_bgm.mp3",
"bgmVolume": 0.8, "bgmVolume": 0.8,
"bgmCrossFade": 2.0, "bgmCrossFade": 2.0,
"bgmContinue": true, "bgmDuckLevel": 0.35,
"videoMuted": true, "bgmDuckFade": 0.5,
"videoMuted": false,
"choices": [...] "choices": [...]
} }
``` ```
**工作流对比** **字段说明**
| | 改进前 | 改进后 | | | 类型 | 默认 | 说明 |
|------|--------|--------| |------|------|------|------|
| 主视频播放 | 视频自带 BGM | AudioManager 独立播 BGM视频 muted | | `bgmUrl` | string | null | BGM 文件路径MP3null/falsy 表示静默并 fade out 当前 BGM |
| 进入选择等待 | 循环视频 BGM 断掉重来 | 视频循环 BGM 继续连贯 | | `bgmVolume` | number | 0.8 | 目标音量0~1 |
| 选择后切场景 | 下一段视频 BGM 硬切 | BGM 交叉淡化过渡bgmCrossFade | | `bgmCrossFade` | number | 2.0 | BGM 切换交叉淡化时长 |
| 关音乐/调音量 | 无法控制 | AudioManager 提供音量/静音接口 | | `bgmDuckLevel` | number | 0.35 | QTE/选择/热点时 duck bgmVolume 的百分比 |
| 同一 BGM 多场景 | 每个 mp4 嵌一份浪费 | 共用同一文件节省带宽 | | `bgmDuckFade` | number | 0.5 | duck 进入和恢复的渐变时长 |
| `videoMuted` | bool | false | 制作者手动设置引擎不自动静音 |
**实现清单:** **实现清单:**
- [ ] `engine/systems/AudioSystem.ts` 新增BGM 播放交叉淡化GainNode ramp)、循环音量/静音 - [x] `engine/systems/AudioSystem.ts` Web Audio APIfetch+decode 缓存BufferSourceNode 创建GainNode 指数 ramp 交叉淡化同源继续/不同 crossFade/静音 fade outducking 事件接口
- [ ] `engine/core/Engine.ts` 集成 AudioSystem场景切换时对比 bgmUrl同源继续不同crossFade - [x] `engine/core/Engine.ts` 集成 AudioSystem`goToScene` 对比 `bgmUrl` 调度切换QTE/choice/hotspot 触发时调用 `audioSystem.duckOn()`/`duckOff()`
- [ ] `engine/types.ts` `SceneNode` `bgmUrl``bgmVolume``bgmCrossFade``bgmContinue``videoMuted` - [x] `engine/types.ts` `SceneNode` `bgmUrl``bgmVolume``bgmCrossFade``bgmDuckLevel``bgmDuckFade``videoMuted`
- [ ] `engine/core/VideoManager.ts` 支持 `muted` 参数视频音轨静音但在 BGM 模式下禁用 - [x] `engine/core/VideoManager.ts` 根据 `videoMuted` 设置 `<video>.muted`手工字段不自动
- [ ] `public/scenes/demo.json` 示例场景拆分视频+音频 - [x] `public/audio/` BGM 测试 MP3calm_bgm.mp3, tense_bgm.mp3
- [ ] `editor/components/NodeEditor.vue` BGM 字段编辑面板 - [x] `public/scenes/demo.json` intro/stay/right_door 配置 BGM + cross-fade + ducking 示例
- [ ] 验证BGM 跨越视频循环连续播放场景切换交叉淡化音量控制生效 - [ ] `editor/components/NodeEditor.vue` BGM 字段编辑面板6 个字段
- [x] 验证BGM 跨视频循环连续场景切换交叉淡化ducking /恢复同源不中断指数曲线听感均匀
**远期功能(不纳入 P6**
| 功能 | 说明 |
|------|------|
| 自适应 BGM | StateManager 变量值切换变奏 suspicion < 50 放安静版>= 50 放紧张版) |
| 水平分段编排 | BGM 前奏/主体/变奏/尾奏自动串联 |
| 分层 Stems | 多轨独立 GainNode 动态叠加,按变量增减层数 |
| Stingers | 短乐句事件音(发现线索的"叮"、惊悚弦乐刺音) |
| BGM 弧线 | 一条 BGM 覆盖多个连续场景而不被切换打断 |
### P7 全屏模式 — 沉浸式浏览器体验(待实现) ### P7 全屏模式 — 沉浸式浏览器体验(待实现)

View File

@@ -4,6 +4,7 @@ import { VideoManager } from './VideoManager'
import { StateManager } from './StateManager' import { StateManager } from './StateManager'
import { ChoiceSystem } from '../systems/ChoiceSystem' import { ChoiceSystem } from '../systems/ChoiceSystem'
import { QTESystem } from '../systems/QTESystem' import { QTESystem } from '../systems/QTESystem'
import { AudioSystem } from '../systems/AudioSystem'
type EventHandler = (...args: any[]) => void type EventHandler = (...args: any[]) => void
@@ -13,6 +14,7 @@ export class Engine {
stateManager: StateManager stateManager: StateManager
choiceSystem: ChoiceSystem choiceSystem: ChoiceSystem
qteSystem: QTESystem qteSystem: QTESystem
audioSystem: AudioSystem
private currentScene: SceneNode | null = null private currentScene: SceneNode | null = null
private events: Map<EngineEvent, Set<EventHandler>> = new Map() private events: Map<EngineEvent, Set<EventHandler>> = new Map()
@@ -29,6 +31,7 @@ export class Engine {
this.stateManager = new StateManager() this.stateManager = new StateManager()
this.choiceSystem = new ChoiceSystem() this.choiceSystem = new ChoiceSystem()
this.qteSystem = new QTESystem() this.qteSystem = new QTESystem()
this.audioSystem = new AudioSystem()
this.videoManager.onTimeUpdate(this.onTimeUpdate) this.videoManager.onTimeUpdate(this.onTimeUpdate)
} }
@@ -63,6 +66,25 @@ export class Engine {
this.stateManager.apply(scene.onEnter) this.stateManager.apply(scene.onEnter)
} }
if (scene.videoMuted) {
this.videoManager.setMuted(true)
} else {
this.videoManager.setMuted(false)
}
const bgmUrl = scene.bgmUrl || null
if (bgmUrl) {
this.audioSystem.play(
bgmUrl,
scene.bgmVolume ?? 0.8,
scene.bgmCrossFade ?? 2.0,
scene.bgmDuckLevel,
scene.bgmDuckFade,
)
} else {
this.audioSystem.stop(scene.bgmCrossFade ?? 2.0)
}
if (scene.type === 'image') { if (scene.type === 'image') {
this.justCameFromImage = true this.justCameFromImage = true
this.isInitialScene = false this.isInitialScene = false
@@ -123,6 +145,8 @@ export class Engine {
this.emit('qteTrigger', qte) this.emit('qteTrigger', qte)
this.audioSystem.duckOn('qte')
this.qteSystem.trigger( this.qteSystem.trigger(
qte, qte,
(remaining, total) => { (remaining, total) => {
@@ -130,6 +154,7 @@ export class Engine {
}, },
(success) => { (success) => {
this.qteResolved = true this.qteResolved = true
this.audioSystem.duckOff('qte')
if (success) { if (success) {
if (qte.effects?.success) { if (qte.effects?.success) {
this.stateManager.apply(qte.effects.success) this.stateManager.apply(qte.effects.success)
@@ -167,6 +192,7 @@ export class Engine {
const validChoices = this.getValidChoices(scene) const validChoices = this.getValidChoices(scene)
if (validChoices.length > 0) { if (validChoices.length > 0) {
this.emit('choiceRequest', validChoices) this.emit('choiceRequest', validChoices)
this.audioSystem.duckOn('choice')
this.choiceSystem.start( this.choiceSystem.start(
validChoices, validChoices,
(timerState) => { (timerState) => {
@@ -196,6 +222,12 @@ export class Engine {
}) })
this.emit('hotspotUpdate', visible) this.emit('hotspotUpdate', visible)
if (visible.length > 0) {
this.audioSystem.duckOn('hotspot')
} else {
this.audioSystem.duckOff('hotspot')
}
} }
getVisibleHotspots(scene: SceneNode): Hotspot[] { getVisibleHotspots(scene: SceneNode): Hotspot[] {
@@ -209,6 +241,8 @@ export class Engine {
clickHotspot(hotspot: Hotspot) { clickHotspot(hotspot: Hotspot) {
if (!this.currentScene) return if (!this.currentScene) return
this.audioSystem.duckOff('hotspot')
if (hotspot.effects) { if (hotspot.effects) {
this.stateManager.apply(hotspot.effects) this.stateManager.apply(hotspot.effects)
} }
@@ -234,6 +268,7 @@ export class Engine {
if (validChoices.length > 0) { if (validChoices.length > 0) {
this.emit('choiceRequest', validChoices) this.emit('choiceRequest', validChoices)
this.audioSystem.duckOn('choice')
this.choiceSystem.start( this.choiceSystem.start(
validChoices, validChoices,
@@ -241,6 +276,7 @@ export class Engine {
this.emit('choiceTimer', timerState) this.emit('choiceTimer', timerState)
}, },
(defaultChoice) => { (defaultChoice) => {
this.audioSystem.duckOff('choice')
this.emit('choiceTimeout', defaultChoice) this.emit('choiceTimeout', defaultChoice)
this.makeChoice(defaultChoice) this.makeChoice(defaultChoice)
} }
@@ -269,6 +305,8 @@ export class Engine {
makeChoice(choice: Choice) { makeChoice(choice: Choice) {
if (!this.currentScene) return if (!this.currentScene) return
this.audioSystem.duckOff('choice')
if (choice.effects) { if (choice.effects) {
this.stateManager.apply(choice.effects) this.stateManager.apply(choice.effects)
} }
@@ -291,6 +329,7 @@ export class Engine {
this.ended = true this.ended = true
this.loopActive = false this.loopActive = false
this.qteSystem.cancel() this.qteSystem.cancel()
this.audioSystem.stop(2.0)
this.emit('gameEnd') this.emit('gameEnd')
} }
@@ -338,6 +377,7 @@ export class Engine {
destroy() { destroy() {
this.qteSystem.destroy() this.qteSystem.destroy()
this.audioSystem.destroy()
this.videoManager.detach() this.videoManager.detach()
this.events.clear() this.events.clear()
} }

View File

@@ -154,6 +154,11 @@ export class VideoManager {
this.active.currentTime = time this.active.currentTime = time
} }
setMuted(muted: boolean) {
if (this.elA) this.elA.muted = muted
if (this.elB) this.elB.muted = muted
}
onEnd(cb: VideoEndCallback) { onEnd(cb: VideoEndCallback) {
this.onEndCallback = cb this.onEndCallback = cb
} }

View File

@@ -0,0 +1,181 @@
type DuckReason = 'qte' | 'choice' | 'hotspot'
export class AudioSystem {
private ctx: AudioContext | null = null
private gainNode: GainNode | null = null
private currentSource: AudioBufferSourceNode | null = null
private currentUrl = ''
private currentVolume = 0.8
private bufferCache: Map<string, AudioBuffer> = new Map()
private maxCache = 3
private duckCount: Map<DuckReason, number> = new Map()
private duckLevel = 0.35
private duckFade = 0.5
private getCtx(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext()
this.gainNode = this.ctx.createGain()
this.gainNode.connect(this.ctx.destination)
}
if (this.ctx.state === 'suspended') {
this.ctx.resume()
}
return this.ctx
}
async play(
url: string,
volume: number,
crossFade: number,
duckLvl?: number,
duckFd?: number,
) {
if (duckLvl !== undefined) this.duckLevel = duckLvl
if (duckFd !== undefined) this.duckFade = duckFd
this.currentVolume = volume
if (url === this.currentUrl) {
const target = this.duckTarget()
if (this.gainNode) {
this.gainNode.gain.cancelScheduledValues(this.getCtx().currentTime)
this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, this.getCtx().currentTime)
this.gainNode.gain.exponentialRampToValueAtTime(
Math.max(0.001, target),
this.getCtx().currentTime + 0.3,
)
}
return
}
const prevSource = this.currentSource
this.currentUrl = url
const buffer = await this.loadBuffer(url)
if (!buffer) return
const ctx = this.getCtx()
const gain = this.gainNode!
const target = this.duckTarget()
if (prevSource) {
gain.gain.cancelScheduledValues(ctx.currentTime)
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + crossFade)
try { prevSource.stop(ctx.currentTime + crossFade + 0.1) } catch {}
}
const source = ctx.createBufferSource()
source.buffer = buffer
source.loop = true
source.connect(gain)
gain.gain.cancelScheduledValues(ctx.currentTime)
gain.gain.setValueAtTime(0.001, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.001, target),
ctx.currentTime + crossFade,
)
source.start(0)
this.currentSource = source
}
async stop(fadeOut: number) {
if (!this.currentSource || !this.gainNode) return
const ctx = this.getCtx()
this.currentUrl = ''
const gain = this.gainNode
try {
gain.gain.cancelScheduledValues(ctx.currentTime)
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + fadeOut)
const src = this.currentSource
setTimeout(() => {
try { src.stop() } catch {}
}, (fadeOut + 0.1) * 1000)
} catch {}
this.currentSource = null
}
duckOn(reason: DuckReason) {
this.duckCount.set(reason, (this.duckCount.get(reason) ?? 0) + 1)
this.applyDuck()
}
duckOff(reason: DuckReason) {
const cur = this.duckCount.get(reason) ?? 0
if (cur <= 1) {
this.duckCount.delete(reason)
} else {
this.duckCount.set(reason, cur - 1)
}
this.applyDuck()
}
private applyDuck() {
if (!this.gainNode || !this.currentSource) return
const target = this.duckTarget()
const ctx = this.getCtx()
const dt = this.duckCount.size > 0 ? this.duckFade : this.duckFade
this.gainNode.gain.cancelScheduledValues(ctx.currentTime)
this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, ctx.currentTime)
this.gainNode.gain.exponentialRampToValueAtTime(
Math.max(0.001, target),
ctx.currentTime + dt,
)
}
private duckTarget(): number {
if (this.duckCount.size > 0) {
return this.currentVolume * this.duckLevel
}
return this.currentVolume
}
setVolumeRaw(v: number) {
if (!this.gainNode) return
const ctx = this.getCtx()
this.gainNode.gain.cancelScheduledValues(ctx.currentTime)
this.gainNode.gain.setValueAtTime(v, ctx.currentTime)
}
private async loadBuffer(url: string): Promise<AudioBuffer | null> {
const cached = this.bufferCache.get(url)
if (cached) return cached
try {
const resp = await fetch(url)
const arrayBuf = await resp.arrayBuffer()
const ctx = this.getCtx()
const buffer = await ctx.decodeAudioData(arrayBuf)
this.bufferCache.set(url, buffer)
// LRU eviction
if (this.bufferCache.size > this.maxCache) {
const firstKey = this.bufferCache.keys().next().value
if (firstKey) this.bufferCache.delete(firstKey)
}
return buffer
} catch {
return null
}
}
destroy() {
if (this.currentSource) {
try { this.currentSource.stop() } catch {}
this.currentSource = null
}
this.bufferCache.clear()
this.duckCount.clear()
this.currentUrl = ''
if (this.ctx) {
this.ctx.close()
this.ctx = null
this.gainNode = null
}
}
}

View File

@@ -11,6 +11,12 @@ export interface SceneNode {
onEnter?: Effect[] onEnter?: Effect[]
loopStart?: number loopStart?: number
loopEnd?: number loopEnd?: number
bgmUrl?: string
bgmVolume?: number
bgmCrossFade?: number
bgmDuckLevel?: number
bgmDuckFade?: number
videoMuted?: boolean
} }
export interface Choice { export interface Choice {

BIN
public/audio/calm_bgm.mp3 Normal file

Binary file not shown.

BIN
public/audio/tense_bgm.mp3 Normal file

Binary file not shown.

View File

@@ -10,6 +10,10 @@
"id": "intro", "id": "intro",
"videoUrl": "/videos/intro.mp4", "videoUrl": "/videos/intro.mp4",
"subtitleUrl": "/subtitles/intro.vtt", "subtitleUrl": "/subtitles/intro.vtt",
"bgmUrl": "/audio/calm_bgm.mp3",
"bgmVolume": 0.6,
"bgmCrossFade": 1.5,
"videoMuted": true,
"choices": [ "choices": [
{ {
"text": "走向左边那扇发光的门", "text": "走向左边那扇发光的门",
@@ -126,6 +130,10 @@
"right_door": { "right_door": {
"id": "right_door", "id": "right_door",
"videoUrl": "/videos/right_door.mp4", "videoUrl": "/videos/right_door.mp4",
"bgmUrl": "/audio/tense_bgm.mp3",
"bgmVolume": 0.7,
"bgmCrossFade": 2.0,
"videoMuted": true,
"qte": { "qte": {
"triggerTime": 1.0, "triggerTime": 1.0,
"prompt": "躲避飞来的石块!", "prompt": "躲避飞来的石块!",
@@ -185,6 +193,9 @@
"id": "stay", "id": "stay",
"videoUrl": "/videos/stay_loop.mp4", "videoUrl": "/videos/stay_loop.mp4",
"subtitleUrl": "/subtitles/stay.vtt", "subtitleUrl": "/subtitles/stay.vtt",
"bgmUrl": "/audio/calm_bgm.mp3",
"bgmVolume": 0.6,
"videoMuted": true,
"loopStart": 3.0, "loopStart": 3.0,
"loopEnd": 6.0, "loopEnd": 6.0,
"choices": [ "choices": [