音效需求清单 · OCC: Planet Civilization
状态:规划稿(项目当前音频接入度为 0) 本文是「要做什么声音」的单一清单,按代码事件点倒推,每一条都有落点。 与本文配套的工程方案见 docs/系统架构.md;资产生产/瘦身见 docs/美术资产管线.md。
0. 现状:从零开始
| 检查项 | 结果 |
|---|
音频文件(.mp3/.ogg/.wav/.m4a/.aac/.opus) | 0 个(全仓库,含 assets/、public/) |
AudioContext / <audio> / Howler 等调用 | 0 处 |
assets/ 目录 | 只有 buildings / drones / icons / mecha / resources 五个视觉资产目录 |
| 预留注释 | 仅 2 处,见下 |
两处"等着接"的地方:
public/src/main.js:1266 —— 机甲撞树/撞石:"碰撞只给'重'的反馈 —— 火星 + 镜头震动 + 撞击音效(如果有)"docs/玩法方案-Planet Civilization-v1.md:513 —— 无人机物流:"保证起降有实体、有音效、有货箱视觉变化"
结论:没有任何历史包袱,可以从架构开始一次做对。
1. 硬约束(先看这节,否则素材做好了也接不上)
| # | 约束 | 对音频的直接影响 |
|---|
| 1 | 前端纯 ES Module、无打包器(importmap → public/vendor/) | 音频不能被打进 bundle;只能放静态目录,运行时 fetch + decodeAudioData,或用 <audio src> |
| 2 | 首屏 2.2MB 是优化过的成果 | 音频绝不能进首屏关键路径;首次用户手势后才解码 |
| 3 | 生产只发瘦身版资产(tools/slim_assets.mjs + deploy/prod/push.sh 排除清单) | 新增 public/assets/audio/ 必须确认不在排除名单里,否则线上静默无声 |
| 4 | 主循环「抛一次错就冻结整局」(setAnimationLoop 回调不再排下一帧) | 所有音频调用必须包在 safe('audio', …) 内;未解锁时静默降级,不许抛 |
| 5 | 世界是球面,位置=单位方向 × 半径,距离算弧长 | 3D 音效的听者位置/朝向要按球面换算,不能拿世界坐标直接线性衰减 |
| 6 | 浏览器自动播放策略 | AudioContext 必须在用户手势(登录点击)里 resume(),否则全程无声 |
| 7 | 单帧 dt ≤ 0.1s、draw call 51 | 音频走独立线程不占 draw call,但禁止每帧 new 对象;AudioBuffer 必须池化复用 |
| 8 | 两颗星球切世界走整页重载 | BGM/环境音会在切世界时被浏览器掐断,必须在 onTravel 做过渡音 + 新世界启动 |
2. 音频总线设计(建议)
┌─────────────┐
ui ──► gain ──┐ │ │
sfx ──► gain ──┼──►│ master │──► DynamicsCompressor ──► destination
amb ──► gain ──┤ │ (总音量) │ (防削顶)
bgm ──► gain ──┘ └─────────────┘
| 总线 | 内容 | 空间化 | 备注 |
|---|
ui | 点击、开关面板、Toast、错误 | ❌ 纯 2D | 恒定音量,不受距离影响 |
sfx | 机甲、武器、采矿、建造、NPC、Boss、无人机 | ✅ 3D | 走 PannerNode,按弧长衰减 |
amb | 风、余烬、鸟鸣、机械低鸣 | ✅ 分层 3D(贴地壳) | 环境音,loop |
bgm | 苗圃平和 / 焦土紧张 / Boss 战 | ❌ 2D | 交叉淡入,切世界必须重启 |
默认建议值:master 0.7 / sfx 0.9 / ui 0.6 / amb 0.5 / bgm 0.45
2.1 三条必须有的"防炸"机制
- 同名限流:同一音效最小间隔
minGap(机甲脚步 0.28s、命中 0.05s、拾取 0.06s)。
没有这条,50 台 NPC + Boss 齐射会瞬间叠 60 个voice → 爆音。
- 最大并发
MAX_VOICES(建议 24):超出时按优先级挤掉最低优先级(BGM/Ambient 永不挤)。 - 距离降级:>120 弧长只发 P0 音效;>260 弧长直接不发声(与
renderDist 1100 无关,听觉距离要短得多)。
2.2 3D 声源坐标换算(球面专用)
// 听者 = 玩家:位置取 player 方向 × 半径,朝向取 +Z(车头),上取 +Y(法线)
// 声源 = 事件点 direction:单位向量 → 乘半径得到球面位置
// 衰减距离直接用三点间的**弧长**,不要欧氏直线距离(远距离差异很明显)
3. 音效清单
优先级:P0 = 一进作品就能感知、缺了明显"哑";P1 = 玩法闭环必需;P2 = 氛围与打磨。 "落点"= 建议挂接的代码位置。
3.1 UI / 系统(总线 ui,无空间)
| # | 音效 | 触发点 | 落点 | 时长 | 优先级 |
|---|
| U01 | 按钮悬停 | 悬停可点元素 | styles.css + hud.js 委托 | 0.06s | P2 |
| U02 | 按钮点击 | 任意按钮 click | hud.js 事件委托 | 0.08s | P0 |
| U03 | 面板打开 | Tab 背包 / J 制造 / U 研究所 / Y 星图 | bag.js / craft.js openUI() / lab.js / starmap.js | 0.25s | P0 |
| U04 | 面板关闭 | Esc 或再次按键 | 各模块 close() | 0.2s | P0 |
| U05 | 标签页切换 | 面板内分栏切换 | build.js 三栏工作台 | 0.1s | P1 |
| U06 | Toast 轻提示 | hud.toast() | hud.js:281 | 0.15s | P0 |
| U07 | 拾取流水滚动 | 连续入包条目滚动 | hud.js:49 gainRetire() | 0.12s | P1 |
| U08 | 操作失败 / 被拒绝 | 背包满、材料不足、建造越界 | build.js canMake()、bag.js | 0.3s | P0 |
| U09 | 登录 / 创建角色成功 | auth-ui.js 回包成功 | auth-ui.js | 0.6s | P1 |
| U10 | 新手引导步骤完成 | 6/16 步推进 | guide.js handleMessage() | 0.5s | P1 |
| U11 | 任务完成 | 任务达成 | tasks.js done() | 0.7s | P1 |
| U12 | 任务进度推进 | 任务计数 +1 | tasks.js set() | 0.15s | P2 |
| U13 | 断线 / 连接错误 | WS close / error | net.js | 0.5s | P1 |
| U14 | 重连成功 | WS 重连 + hello | net.js | 0.5s | P2 |
| U15 | 音量 / 静音开关 | 设置面板 | 待建(见 §5) | 0.08s | P1 |
3.2 机甲本体(总线 sfx,1:1 跟随玩家 —— 最"响"的一组)
| # | 音效 | 触发点 | 落点 | 时长 | 优先级 |
|---|
| M01 | 步行脚步(左/右两组) | 走速 10 迈步 | assets/mecha/model/rig.js、legmath.js 步态相位 | 0.2s | P0 |
| M02 | 奔跑脚步(加重) | 跑速 52 | 同上,按速度切样本 | 0.25s | P0 |
| M03 | 起飞 / 推进启动 | Space 按住起飞 | mecha.js update() 状态机 | 0.6s | P0 |
| M04 | 悬停推进循环 | 空中停留时 loop | mecha.js update() | loop | P1 |
| M05 | 落地重击 | 触地帧(配合 rig.addTrauma) | player.js / main.js 落地检测 | 0.5s | P0 |
| M06 | 引擎关机 | 落地后推进停止 | 同上 | 0.4s | P2 |
| M07 | 受击(被打中掉血) | 血量回包 hp 减少 | main.js WS 血量分支 | 0.3s | P0 |
| M08 | 被摧毁 / 重生 | 血量归零 / 复活 | main.js 存活分支 | 1.2s | P1 |
| M09 | 撞击障碍物 | 撞树/撞石,impact 强度 | main.js:1264 rig.addTrauma(min(0.5, impact/110)) ← 已有注释预留 | 0.35s | P0 |
| M10 | 转向伺服 / 液压 | 大幅转向 | player.js 转向限速分支 | 0.25s | P2 |
| M11 | 低血量警报(loop) | 血量低于阈值 | hud.js updateStats() | loop | P1 |
| M12 | 机甲就位 / 世界加载完成 | 首屏 ready、切世界后进场 | main.js init 末尾 | 0.8s | P1 |
3.3 纸飞机 · 武器与命中(总线 sfx)
攻击是四态 orbit → climb → dive → return,音效必须贴着这个状态机做,否则节奏对不上。
| # | 音效 | 触发点 | 落点 | 时长 | 优先级 |
|---|
| W01 | 瞄准锁定 | pickAimTarget 抓到目标 | paper-planes.js pickAimTarget() | 0.1s | P0 |
| W02 | 发射 / 爬升开始 | climb 段开始 | paper-planes.js applyControl() 状态切换 | 0.35s | P0 |
| W03 | 飞行呼啸(loop) | 飞机在空中,3D 跟随 | paper-planes.js 每个 plane 挂一个源 | loop | P1 |
| W04 | 抛出 / 俯冲下坠 | onLaunch 广播那一刻(真正抛出) | main.js:989 onLaunch | 0.3s | P0 |
| W05 | 命中 NPC | onStrikeSegment 命中 | main.js:926 onStrikeSegment | 0.25s | P0 |
| W06 | 命中精英 / Boss(厚重版) | 命中精英或 Boss | 同上(按体积 d²/r² 分支) | 0.4s | P1 |
| W07 | 命中玩家 / 被击中 | onPlaneHitPlayer | main.js | 0.3s | P0 |
| W08 | 打空(命中地形/障碍) | 线段打到障碍物 | main.js onStrikeSegment 空分支 | 0.2s | P1 |
| W09 | 爆炸 / 冲击波 | explode() 触发 | paper-planes.js:185 explode()、effects.js:142 spawnImpact() | 0.6s | P0 |
| W10 | 纸飞机补充 / 数量变化 | setCapacity | paper-planes.js:519 | 0.3s | P2 |
| W11 | 弹药耗尽 | 无飞机可发 | paper-planes.js fire() | 0.25s | P1 |
3.4 采矿 · 采集(总线 sfx)
| # | 音效 | 触发点 | 落点 | 时长 | 优先级 |
|---|
| G01 | 采矿光束启动 | 按住左键 / E 开采集 | mining.js setActive() / layBeam() | 0.4s | P0 |
| G02 | 采矿光束循环 | 持续采矿时 | mining.js layBeam() loop | loop | P0 |
| G03 | 采矿光束停止 | 松手 / 移开目标 | mining.js setActive(false) | 0.35s | P0 |
| G04 | 矿石碎裂 / 颗粒喷溅 | 命中矿脉,出颗粒 | mining.js emit()/makeSpray() | 0.3s | P1 |
| G05 | 矿脉采空 | onDepleted / 服务端 code:'depleted' | main.js:808 onDepleted | 0.8s | P0 |
| G06 | 入包"叮"(音高递增防疲劳) | 每次 onGatherResult | main.js:1988 mining.onGatherResult() | 0.15s | P0 |
| G07 | 采集植物(草丛/灌木) | strikeFlora | mining.js:140 strikeFlora()、flora-harvest.js | 0.35s | P1 |
| G08 | 树木砍倒 | 树被采空 | mining.js strikeFlora 树分支 | 1.0s | P1 |
| G09 | 机械臂伸缩 | applyArmPose 动作 | mining.js:588 | 0.2s | P2 |
3.5 建造 · 生产 · 科技(总线 sfx)
| # | 音效 | 触发点 | 落点 | 时长 | 优先级 |
|---|
| B01 | 进入建造模式 | 打开建造面板 | build.js enter | 0.4s | P0 |
| B02 | 幽灵吸附 / 网格对齐 | 幽灵落到合法格 | build.js solve() 成功分支 | 0.08s | P2 |
| B03 | 放置非法 | 越界 / 重叠 / 材料不足 | build.js solve()/confirm() 拒绝分支 | 0.3s | P0 |
| B04 | 放置成功 | confirm() + onPlaceBuilding | build.js:389 confirm() | 0.5s | P0 |
| B05 | 施工中循环 | 建筑建造进度中 | build.js / onPlaceBuilding 回包 | loop | P1 |
| B06 | 建造完成 | 进度 100% | 同上 | 1.0s | P1 |
| B07 | 拆除建筑 | 拆除动作 | build.js 拆除分支 | 0.6s | P1 |
| B08 | 旋转幽灵 | R 键 | build.js:405 rotate() | 0.15s | P2 |
| B09 | Sign out建造模式 | Esc / 主动Sign out | build.js:361 exit() | 0.3s | P1 |
| B10 | 生产完成(建筑产出) | 服务端 1Hz 模拟产出 | server/sim.js → WS 广播 | 0.4s | P1 |
| B11 | 能源不足 / 停产警告 | 能源不足停产 | sim.js 能源分支 | 0.5s | P1 |
| B12 | 冶炼 / 装配工作循环 | 建筑在工作 | sim.js 生产态 | loop | P2 |
| B13 | 仓储装卸 | 入库 / 出库 | depot.js | 0.25s | P2 |
| B14 | 科技解锁成功 | lab.onMessage 解锁回包 | lab.js:170 onMessage() | 0.9s | P0 |
| B15 | 科技条件不足 | 前置未满足 | lab.js 按钮拒绝 | 0.3s | P1 |
| B16 | 星门充能 / 冷却 | 脱战 5s + 冷却 30s | starmap.js / main.js 星门分支 | loop | P1 |
| B17 | 世界切换(穿越星门) | onTravel(id) | main.js:251 onTravel → goToWorld() | 2.0s | P0 |
| B18 | 星图打开 / 选中星球 | 星图渲染 | starmap.js render() | 0.3s | P1 |
3.6 物品 · 背包 · 仓库(总线 ui / sfx 混用)
| # | 音效 | 触发点 | 落点 | 时长 | 优先级 |
|---|
| I01 | 掉落物飞向玩家 | startFly | loot.js:216 startFly() | 0.3s | P1 |
| I02 | 物品入包 | 拾取成功 | loot.js remove() / hud 入包 | 0.18s | P0 |
| I03 | 背包满 / 无法拾取 | 拾取失败 | loot.js / bag.js | 0.3s | P1 |
| I04 | 丢弃物品 | 拖出背包 | loot.js:143 add() | 0.25s | P2 |
| I05 | 背包格拖拽放下 | 物品移动 | bag.js 拖拽 | 0.1s | P1 |
| I06 | 物品悬停提示出现 | hover 物品 | item-tip.js | 0.06s | P2 |
| I07 | 打开储物仓 / 仓库面板 | 点储物仓 | depot.js | 0.3s | P1 |
| I08 | 存入 / 取出成功 | 仓库操作成功 | depot.js | 0.2s | P1 |
| I09 | 存取失败(超 46 米) | 服务端距离校验拒绝 | depot.js | 0.3s | P1 |
| I10 | 快捷栏切换 | 1~9 / 0 | input.js / hud.js | 0.08s | P1 |
| I11 | 随身制造(J)产物完成 | onCrafted | main.js:263 onCrafted | 0.5s | P1 |
3.7 NPC / 精英坦克 / Boss
| # | 音效 | 触发点 | 落点 | 时长 | 优先级 |
|---|
| N01 | NPC 引擎接近(3D loop) | 50 台 NPC 在跑 | npc.js:646 update() | loop | P1 |
| N02 | 炮塔瞄准伺服 | aimTurret() | npc.js:483 | 0.2s | P2 |
| N03 | NPC 开火 | 开火环触发 | npc.js:450 updateFireRing() | 0.3s | P0 |
| N04 | NPC 掉血命中 | 被纸飞机打中 | setHp() | 0.15s | P1 |
| N05 | NPC 被击毁 | 碎片炸开 | npc.js:300 burstIntoPieces() | 0.9s | P0 |
| N06 | NPC 重生 / 换地方 | respawn() / teleport() | npc.js:572/524 | 0.5s | P2 |
| N07 | 精英坦克出场(强调) | 精英进入视野 | elite-tank.js | 1.0s | P1 |
| N08 | 精英炮口闪光 / 开火 | muzzleFlash() | elite-tank.js:136 | 0.35s | P1 |
| N09 | Boss 现身 / 激活 | Boss 上线 | world-boss.js:127 | 2.0s | P0 |
| N10 | Boss 阶段切换 | onPhase | world-boss.js:293 update() 分支 | 1.5s | P0 |
| N11 | Boss 加农炮发射 | flashCannon() | world-boss.js:379、boss-fx.js:67 | 0.6s | P0 |
| N12 | Boss 迫击炮发射 | flashMortar() | world-boss.js:388、boss-fx.js:92 | 0.6s | P1 |
| N13 | 迫击炮落点预警(嘀嘀嘀) | 弹落地前 delay 窗口 | boss-fx.js spawnMortar(…, delay) | 1.5s | P0 |
| N14 | Boss 爆炸 / 冲击 | blast() | boss-fx.js:117 / :166 blastFromServer() | 1.2s | P0 |
| N15 | Boss 被击毁 | Boss 死亡 | world-boss.js 死亡分支 | 2.5s | P0 |
| N16 | 远处战场炮火(氛围 3D) | 世界内随机远点 | 新增 amb 源 | loop | P2 |
3.8 无人机 · 物流(总线 sfx,3D)
docs/玩法方案-Planet Civilization-v1.md:513 明确要求"起降有实体、有音效"。
| # | 音效 | 触发点 | 落点 | 时长 | 优先级 |
|---|
| D01 | 无人机起飞 | 航班开始 | logistics.js:116 setFlights() / update() | 0.5s | P0 |
| D02 | 飞行循环(3D,随距离) | 航班进行中 | logistics.js:134 update() | loop | P1 |
| D03 | 降落 / 装载 | 抵达需求站 | 同上 | 0.6s | P1 |
| D04 | 卸货完成 | 航班结束 | 同上 | 0.4s | P1 |
| D05 | 无人机损失 / 被击落 | 若有该机制 | 待定 | 1.0s | P2 |
3.9 多人 · 世界 · 氛围
| # | 音效 | 触发点 | 落点 | 时长 | 优先级 |
|---|
| R01 | 远端玩家进场 / 离场 | WS 玩家增删 | net.js / remote-fx.js | 0.5s | P1 |
| R02 | 远端玩家开火(3D) | 远端 onLaunch | remote-fx.js | 0.35s | P1 |
| R03 | 远端玩家命中 / 被击中 | 远端战斗事件 | remote-fx.js | 0.3s | P2 |
| R04 | 掉落物出现提示 | 新掉落物 | loot.js:143 add() | 0.2s | P2 |
| R05 | 雷达扫描脉冲 | 雷达周期刷新 | radar.js:244 update() | 0.15s | P2 |
| R06 | 环境音 · 苗圃(风、鸟、机械低鸣) | home 世界常驻 | 新建 amb 层 | loop | P0 |
| R07 | 环境音 · 焦土(风、余烬、远处爆裂) | battle 世界常驻 | 新建 amb 层 | loop | P0 |
| R08 | BGM · 苗圃(平和、可循环) | home 世界 | 新建 bgm 层 | 90~120s | P1 |
| R09 | BGM · 焦土(紧张、可循环) | battle 世界 | 新建 bgm 层 | 90~120s | P1 |
| R10 | BGM · Boss 战(交叉淡入) | Boss 上线/下线 | world-boss.js 状态 | 60~90s | P1 |
| R11 | BGM 危机层(低血量叠加) | 血量阈值 | hud.js updateStats() | loop | P2 |
3.10 条目合计
| 分组 | 条数 | 其中 P0 |
|---|
| UI / 系统 | 15 | 5 |
| 机甲本体 | 12 | 6 |
| 纸飞机 · 武器 | 11 | 6 |
| 采矿 · 采集 | 9 | 5 |
| 建造 · 生产 · 科技 | 18 | 5 |
| 物品 · 背包 · 仓库 | 11 | 1 |
| NPC · 精英 · Boss | 16 | 8 |
| 无人机 · 物流 | 5 | 1 |
| 多人 · 世界 · 氛围 | 11 | 2 |
| 合计 | 108 | 39 |
最小可用集(先做 39 条 P0) 就能让整局作品"不再哑火"。
4. 素材规格与命名
4.1 格式与体量
| Purpose | 格式 | 采样率 | 码率 | 实测单文件 |
|---|
| 短音效(<1s) | .mp3(libmp3lame) + .m4a(AAC) 双份 | 44.1kHz 立体声 | 128kbps | 7~14KB |
| 长音效(>1s) | 同上 | 44.1kHz 立体声 | 128kbps | 40~80KB |
| 循环环境音(amb) | 同上 | 44.1kHz 立体声 | 128kbps | 30s ≈ 470KB |
| BGM | 同上(走 ACE-Step 1.5) | 44.1kHz 立体声 | 128kbps | 90~120s ≈ 1.5~2MB |
🔴 本机没有 libvorbis(PyAV 只带 libmp3lame / aac / opus / flac)⇒ 原计划的 ogg 方案落不了地,改用 MP3 为主 + m4a 兜底。两者在现代浏览器全通吃, 判不判定 canPlayType 都无所谓了。真要 ogg:装 ffmpeg 后改 postprocess.py --format。 104 条实测总体积 7.6MB(mp3+m4a 双份;只留 mp3 是 3.51MB,平均 34KB/条), 其中两条 30s 环境音各占 470KB —— 它们本来就是大头。
4.2 目录结构
public/assets/audio/
├─ ui/ ui_click.ogg / ui_panel_open.ogg / ...
├─ sfx/
│ ├─ mecha/ sfx_mecha_step_l.ogg / sfx_mecha_land.ogg / ...
│ ├─ weapon/ sfx_plane_launch.ogg / sfx_plane_hit_npc.ogg / ...
│ ├─ mine/ sfx_mine_beam_loop.ogg / sfx_depleted.ogg / ...
│ ├─ build/ sfx_build_place.ogg / sfx_tech_unlock.ogg / ...
│ ├─ npc/ sfx_npc_fire.ogg / sfx_npc_die.ogg / ...
│ └─ boss/ sfx_boss_phase.ogg / sfx_boss_mortar_warn.ogg / ...
├─ amb/ amb_home.ogg / amb_battle.ogg / ...
└─ bgm/ bgm_home.ogg / bgm_battle.ogg / bgm_boss.ogg
命名规范:<总线>_<系统>_<事件>[_<变体>].<ext>,全小写下划线,不允许中文文件名(部署 tar/scp 链路对非 ASCII 路径不友好 —— 见 deploy/prod/push.sh)。
4.3 一次性总量估算
| 项 | 数量 | 估算体积 |
|---|
| P0 音效 41 条 | 41 | ~1.4MB |
| P1 音效 38 条 | 38 | ~1.2MB |
| P2 音效 29 条 | 29 | ~0.8MB |
| 环境音 2 + BGM 3 | 5 | ~6MB(BGM 是大头) |
| 合计 | 113 个文件 | ~9.4MB(双格式后 ~11MB) |
BGM 是唯一需要认真权衡的部分:如果坚持"首屏 2.2MB"的精神,BGM 应该按世界懒加载(进苗圃只拉 bgm_home,切焦土再拉 bgm_battle),绝不全量预载。
5. 实施路径(分三期)
一期 · 骨架 + P0(核心体验)
- 新增
public/src/audio.js:AudioContext 单例、4 总线、限流、MAX_VOICES、球面 3D 衰减、safe() 包裹。 - 解锁时机:
auth-ui.js 登录点击里 ctx.resume()。 - 接入 41 条 P0 音效的落点(本文档已给全)。
- 验收:
safe('audio', …) 无抛错;50 NPC + Boss 齐射不爆音;切世界不残留上一世界的 amb/bgm。
二期 · 全量 + 设置面板
- 补 P1/P2 共 67 条。
- 新增设置面板:
master / sfx / ui / amb / bgm 五滑杆 + 静音开关,落 localStorage(与现有 I 键反转 Y 轴同级别设置项,支持 H 操作说明面板里能看到)。 - 音频懒加载:进世界后按世界拉取 amb/bgm,其余按需。
三期 · 动态混音
- Boss 战 BGM 交叉淡入(
bgm_battle → bgm_boss)。 - 战斗强度驱动 amb 层(远处炮火密度)。
- 低血量危机层。
6. 验收清单(做完必须过)
- [ ] 断网/无音频文件时,作品完全正常运行(静默降级,不抛错)
- [ ] 帧率不因音频下降(对比
node tools/e2e/perf.cjs 基线:draw call 51 / 三角形 50.8 万) - [ ] 首屏体积不增加(音频全部懒加载)
- [ ] 50 台 NPC + Boss 全开火时无爆音、无 voice 溢出
- [ ] 切世界后旧世界 amb/bgm 完全停止
- [ ] 音量设置刷新页面后保留
- [ ] 生产环境(阿里云 nginx 8090)音频能正常加载 —— 确认
deploy/prod/push.sh 排除清单没有漏掉 public/assets/audio - [ ] 主循环里所有音频调用都在
safe() 内
7. 素材来源(三条路,可混用)
| 路径 | 适合 | 备注 |
|---|
| 本地程序化合成 | UI、脚步、命中、激光、爆炸等短音效 | 用 Web Audio 或离线渲染脚本批量生成,零版权风险、体积极小,且参数可调(如音高递增) |
| 免费素材库 | 环境音、BGM | 必须核对授权(CC0 / CC-BY),BGM 尤其注意 |
| AI 音频生成 | 机甲、Boss 这类"厚重机械/巨型生物" | 一致性难保证,建议同一组一次生成 |
🔴 版权红线:本项目是要上生产、要对外演示的产品,所有素材必须有可追溯授权记录,建议在 docs/美术资产管线.md 里补一节"音频素材来源与授权"。
8. 生产状态(2026-10-16)
104 条已全部生成并落地(108 − 4 条 BGM,BGM 走 ACE-Step),整条链路跑通。 生成侧的具体内容(每条提示词)见 docs/音效提示词库.md。
| 环节 | 文件 / 位置 |
|---|
| 提示词(单一真相来源) | tools/audio/prompts.json(108 条) |
| 批量生成 | tools/audio/gen.py → ComfyUI(Stable Audio 3 Small-SFX) → _logs/audio/<日期>/*.flac |
| 后处理 | tools/audio/postprocess.py(裁剪 / 无缝循环 / 峰值归一化 / 编码 / 写索引) |
| 成品 | public/assets/audio/{ui, sfx/<组>, amb, bgm}/.mp3 + .m4a |
| 索引 | public/assets/audio/_index.json(试听页读它) |
| 试听台 | public/_audio_preview.html ← 打开就听,支持循环条目试接缝 |
| 人读提示词 | tools/audio/prompts2md.py → docs/音效提示词库.md |
python tools/audio/gen.py --prio P0 # 生成(单条 1.5~4.5s @ 3070 Ti)
python tools/audio/postprocess.py --also-m4a # 出成品 + 写索引
实测:RTX 3070 Ti / 8GB 上 small-sfx 单条 1.5~4.5 秒(8 步 lcm、cfg 1.0)。 104 条从零到成品约 15 分钟(含下载模型)。
| 分组 | 条数 | 分组 | 条数 |
|---|
| UI / 系统 | 15 | 物品 · 背包 · 仓库 | 11 |
| 机甲本体 | 12 | NPC · 精英 · Boss | 16 |
| 纸飞机 · 武器 | 11 | 无人机 · 物流 | 5 |
| 采矿 · 采集 | 9 | 多人 · 世界 · 氛围 | 7 |
| 建造 · 生产 · 科技 | 18 | 合计 | 104 |
其中 12 条需要无缝循环(含两条 30s 环境音),已全部做等功率交叉淡入。
三条实测踩坑(已写进脚本注释)
EmptyLatentAudio.seconds 下限是 1.0 —— ComfyUI 提交 0.4s 会直接报
value_smaller_than_min。所以 0.3~0.9s 的音效一律按 1s 生成再裁, 且裁剪必须按能量包络峰值定位起点(从 0 秒硬切会把瞬态切掉)。
- 🔴 PyAV 解 FLAC 出来是 packed
s16,to_ndarray() 给的是 (1, samples×channels),
不是 (ch, samples)。当单声道处理会让时长翻倍、左右声道被串联 (听感上整体慢一倍 + 变调)。planar 格式(s16p/fltp)才是 (ch, samples)。
- 🔴 写 MP3 时
AudioFrame.from_ndarray 要求 s16 的 shape 是 (1, n×ch),
传 (n, ch) 会报 Expected packed array.shape[0] to equal 1。
已接入作品(2026-10-16)
public/src/audio.js 已落地并全量挂点 —— 作品里能听到声音了。
| 项 | 状态 |
|---|
| 音频引擎 | 新建 public/src/audio.js:4 总线 + 三防炸机制 + 球面弧长 3D 定位 |
| 主循环 | safe('audio', …) 内更新听者朝向 + 机甲脚步 / 起降 / 悬停 |
| 挂点 | 覆盖 §3 清单的全部 P0 + 大部分 P1(见下表) |
| 音量面板 | 右下 dock「音量」按钮(五滑杆 + 静音,落 localStorage,换世界不丢) |
| 调试出口 | __OCC__.audio.{ready, mix, play, setBus, setEnabled, loops, stats} |
挂点清单(改某条音效去哪个文件找):
| 事件 | 位置 |
|---|
| 发射 / 命中 NPC·精英·玩家 / 打空 | main.js onLaunch · onPlaneHitNpc · onPlaneHitPlayer · onStrikeSegment |
| 采空 + 小爆炸 / 采集入包 | main.js onDepleted · gatherResult |
| 玩家受击 / 被摧毁 / 重生 | main.js applyServerHp · die · respawn |
| 世界切换(星门) | main.js goToWorld |
| Boss 现身·气炮·迫击炮·落点预警·爆炸·死亡 | main.js bossRespawn · bossFire · bossMortar · bossBlast · bossDown |
| 机甲 脚步 / 起飞 / 落地 / 悬停 / 撞击 | main.js tickMechaAudio · collide 回调 |
| 采矿光束 起 / 循环 / 停 | mining.js setActive |
| 采集植物 | mining.js strikeFlora |
| 建造 进入 / Sign out / 放置 / 拒绝 / 旋转 | build.js enter · exit · confirm · rotate |
| NPC·精英 开火 / 击毁 | npc.js updateFireRing · setAlive |
| 无人机 起飞 / 飞行循环 | logistics.js setFlights · update |
| 科技 完成 / 开始 / 条件不足 | lab.js onMessage |
| 掉落物 被吸走 / 入包 | loot.js startFly · lootGot |
| 面板 开 / 关(背包·制造·研究所·星图·仓库) | bag.js / craft.js / lab.js / starmap.js / depot.js |
| 任意 UI 按钮点击 | audio.js bindUiSounds(全局委托;元素加 data-sfx="音效名" 可指定具体声音) |
| 全部 Toast | hud.js toast |
| 环境音(按世界) | main.js boot 末尾 —— amb_home_loop / amb_battle_loop |
两颗星球的声音("背景音必须不同")
| 苗圃 home(R520) | 焦土 battle(R820) | 焦土 · Boss 活着 |
|---|
| 环境音 | amb_home_loop 深空低频共鸣 + 缓慢的风 + 远处机械低鸣 · 空旷孤独、巨大的空间感 | amb_battle_loop 低频共鸣 + 干风 + 余烬 + 远处交火 · 荒凉浩瀚 | 同左 |
| BGM | bgm_home 深空 drone + 缓慢演化的暖 pad + 巨大洞穴式混响 + 遥远星光般的高频微光 · 空旷幽深、失重 | bgm_battle 次低频 drone + 不安的缓慢涌浪 + 冷金属质地 + 遥远不祥脉冲 · 压抑浩瀚 | bgm_boss 合成铜管式涌浪 + 低频撞击 + 洞穴混响 · 史诗压迫 |
| 低血量 | 之上再叠 bgm_tension_layer(纯低频 drone,无旋律无节奏,专为垫在音乐下面设计) | 同左 | 同左 |
🔴 整体基调是「空旷幽深的太空宏大感」,不是地球田园(老大 2026-10-16 晚)。 第一版写进了 faint birdsong texture / soft bird-like calls / 毛毡钢琴 / 木管 / 太鼓 —— 出来是"田园牧歌",和太空题材拧着。现在:
- 全部
no nature sounds / no birds / no piano / no woodwind / no acoustic drums - 换成
deep cosmic drone / cavernous reverb / infinite distance / weightless / vast - 环境音 + 三条 BGM 都加了条目级 neg(
birds, nature sounds, piano, woodwind, taiko, acoustic drums…)
频谱实测(同一套算法跑新旧两版,旧版从 git 取):
| <300Hz 低频 | 2–6kHz(鸟叫/虫鸣区) |
|---|
| 环境音 · 苗圃 旧 → 新 | 6.4% → 60.9% | 54.7% → 9.8% |
| BGM · 苗圃 旧 → 新 | 31.9% → 42.8% | 1.9% → 5.4% |
| BGM · 焦土 旧 → 新 | 30.5% → 55.0% | 22.5% → 5.7%(太鼓/金属打击消失) |
环境音那条变化最大 —— 老大听到的"鸟叫"主要就是它。
🔴 BGM 是「状态驱动」而不是「事件驱动」:主循环每帧由 desiredBgm() 问一句"现在该放哪条"。 一开始写成"Boss 上线切曲 / 进世界按世界选"三处事件,翻过车 —— 那条按世界补发的 NPC/Boss 消息 可能比 boot 末尾的 loopStart 更早到,于是 deferredLoops 的重放顺序颠倒, 实测 bossAlive=true 却放着 bgm_battle。改成状态驱动后与事件到达顺序彻底解耦。
BGM 由哪个模型生成:正式版用 Stable Audio 3 Small-Music(同模型族、同工作流, 为音乐/循环训练);同时用 ACE-Step 1.5 Turbo(10GB 大模型,歌曲架构)出了一套对照版, 放在同目录下以 .ace.mp3 结尾,在试听台里标着「(ACE-Step 对照)」—— 听感偏好由人来定, 代码里默认走 Small-Music 那套。
python tools/audio/gen.py --music # 正式版(Small-Music)
python tools/audio/gen.py --ace # 对照版(ACE-Step,输出到 <日期>/ace/)
python tools/audio/postprocess.py --src _logs/audio/<日期>/ace --suffix .ace --also-m4a
验收 _logs/bgm_world_check.cjs:10/10 通过(两个世界各验环境音 + BGM,并断言"两套必须不同")。
还没做
- 音量面板没接进「H 操作说明」 —— 目前是独立的 dock 按钮。
- ACE-Step 版未接入作品 —— 只作为试听对照;要切过去改
desiredBgm() 的返回值即可。 - 对照版会一起发布(
bgm/.ace.,约 12MB)—— 不想发就删掉这些文件并把 _index.json 里
对应条目去掉(重跑一次 postprocess.py 会自动剔除索引)。
- 没有"音效开关"的独立面板 —— 五条总线滑杆已够用,但没做"一键关所有音效"。