diff --git a/docs/EVIDENCE_LEDGER.md b/docs/EVIDENCE_LEDGER.md index c4be6822..067b2eba 100644 --- a/docs/EVIDENCE_LEDGER.md +++ b/docs/EVIDENCE_LEDGER.md @@ -12,6 +12,7 @@ | 2026-09-13 | M60 可玩性真相(W1) | `e1cad068`/`dac5de40` | `m60-playthrough` `20260913T145904Z-38281` | runtime_verified | 34 步 0 阻断;视频与逐步截图 | | 2026-09-13 | M61 设计/密度(W2) | `310d2620` | `m61-five-profile` `20260913T162625Z-2172` | simulation_verified | decisions=330, max_gap=31.3s, 反支配/存档/可达通过 | | 2026-09-13 | M62 组件统一(W3) | `2e5540ed` | `m62-ink-baselines` `20260913T164335Z-38711` | runtime_verified | 组件库+矩阵+探针接入;基线 Guild 0 / Contract 1 / Readiness+Result 6 / Market 严格 0 | +| 2026-09-14 | M76-D 整机试听带(4 段合成试听) | `feat(m76-d)` | runtime_pending(Owner 听感)| guild/market/risk/result 四段;音乐床 + 真实音效时序 | | 2026-09-14 | M76-C 音频接线(服务/总线/事件/音乐族)+ 卡内模块点击缺陷 | `feat(m76-c)`/`fix(m76-c)` `b0db3b46` | `m76-audio` `20260914T131826Z-19299` | runtime_verified(待整机听感)| 144 事件、16 总线;全流程 34 步 0 阻断且音频门通过;修复市场卡无法点击 | | 2026-09-14 | M76-B SFX 全切片(144 事件 / 158 条)+ 音乐 7 床 | `feat(m76-b)` | machine_verified(待接线与听感)| 校验 0 错误;8 条静音失败件重生成通过;花费累计 ≈ $1.7 | | 2026-09-14 | M76-A 音频试听批(ElevenLabs;查明旧音频为本地合成占位) | `feat(m76-a)` `4efe4d2c`/`7f7ec58c` | runtime_pending(待人工听感)| 音乐 7 床 + SFX 23 候选;机器校验 0 错误;花费 ≈ $1.35(音乐 $0.15/min、音效 $0.12/min)| diff --git a/docs/audio/mixdowns/guild_page.mp3 b/docs/audio/mixdowns/guild_page.mp3 new file mode 100644 index 00000000..7defa80b Binary files /dev/null and b/docs/audio/mixdowns/guild_page.mp3 differ diff --git a/docs/audio/mixdowns/market_transaction.mp3 b/docs/audio/mixdowns/market_transaction.mp3 new file mode 100644 index 00000000..3be7cd89 Binary files /dev/null and b/docs/audio/mixdowns/market_transaction.mp3 differ diff --git a/docs/audio/mixdowns/mixdown_manifest.json b/docs/audio/mixdowns/mixdown_manifest.json new file mode 100644 index 00000000..59939187 --- /dev/null +++ b/docs/audio/mixdowns/mixdown_manifest.json @@ -0,0 +1,182 @@ +{ + "guild_page": { + "title": "公会页(音乐 + 检视/焦点/整队/整队完成)", + "music": "MUS-G01-GUILD-base.mp3", + "length": 26, + "cues": [ + [ + 1.5, + "SFX-UI-005-page_open.v01.mp3" + ], + [ + 3.4, + "SFX-UI-001-focus_move.v01.mp3" + ], + [ + 5.0, + "SFX-UI-007-inspect_open.v01.mp3" + ], + [ + 6.6, + "SFX-UI-008-inspect_close.v01.mp3" + ], + [ + 8.4, + "SFX-RUN-015-save_begin.v01.mp3" + ], + [ + 10.0, + "SFX-RUN-016-save_commit.v01.mp3" + ], + [ + 12.5, + "SFX-UI-001-focus_move.v01.mp3" + ], + [ + 14.0, + "SFX-ORD-006-reorder_commit.v01.mp3" + ], + [ + 16.5, + "SFX-ORD-017-ready_check_pass.v01.mp3" + ], + [ + 19.0, + "SFX-UI-018-confirm_commit.v01.mp3" + ] + ] + }, + "market_transaction": { + "title": "市场成交(音乐 + 选卡/检视/成交/扣币)", + "music": "MUS-G02-SHOP-base.mp3", + "length": 24, + "cues": [ + [ + 1.2, + "SFX-UI-005-page_open.v01.mp3" + ], + [ + 2.6, + "SFX-SHP-002-offer_reveal.v01.mp3" + ], + [ + 4.6, + "SFX-SHP-012-recruit_card_reveal.v01.mp3" + ], + [ + 6.6, + "SFX-SHP-013-recruit_inspect.v01.mp3" + ], + [ + 8.8, + "SFX-SHP-014-recruit_hire_review.v01.mp3" + ], + [ + 11.0, + "SFX-SHP-015-recruit_hire_commit.v01.mp3" + ], + [ + 13.4, + "SFX-SHP-011-currency_spend.v01.mp3" + ], + [ + 16.0, + "SFX-SHP-021-trait_benefit_reveal.v01.mp3" + ], + [ + 18.4, + "SFX-EQP-007-equip_commit.v01.mp3" + ], + [ + 20.6, + "SFX-SHP-024-shop_exit.v01.mp3" + ] + ] + }, + "risk_and_departure": { + "title": "风险与开战(音乐 + 选风险/落章/确认/出发)", + "music": "MUS-G04-RUN-base.mp3", + "length": 24, + "cues": [ + [ + 1.2, + "SFX-UI-005-page_open.v01.mp3" + ], + [ + 3.0, + "SFX-RUN-001-risk_option_focus.v01.mp3" + ], + [ + 5.0, + "SFX-RUN-002-risk_compare.v01.mp3" + ], + [ + 7.2, + "SFX-RUN-003-risk_commit.v01.mp3" + ], + [ + 9.8, + "SFX-UI-017-confirm_arm.v01.mp3" + ], + [ + 12.0, + "SFX-UI-018-confirm_commit.v01.mp3" + ], + [ + 14.6, + "SFX-ORD-019-battle_start_arm.v01.mp3" + ], + [ + 17.0, + "SFX-ORD-020-battle_start_commit.v01.mp3" + ], + [ + 19.4, + "SFX-RUN-004-encounter_enter.v01.mp3" + ] + ] + }, + "result_win": { + "title": "结算·胜(音乐 + 胜利/奖励/升级)", + "music": "MUS-G05-RESULT-WIN-base.mp3", + "length": 24, + "cues": [ + [ + 1.2, + "SFX-UI-005-page_open.v01.mp3" + ], + [ + 2.8, + "SFX-RES-012-battle_victory.v01.mp3" + ], + [ + 5.4, + "SFX-RES-016-reward_reveal.v01.mp3" + ], + [ + 7.8, + "SFX-RES-017-reward_option_focus.v01.mp3" + ], + [ + 10.0, + "SFX-RES-019-reward_accept.v01.mp3" + ], + [ + 12.4, + "SFX-RES-022-currency_reward.v01.mp3" + ], + [ + 14.8, + "SFX-RES-025-experience_gain.v01.mp3" + ], + [ + 17.2, + "SFX-RES-026-level_gain.v01.mp3" + ], + [ + 20.0, + "SFX-UI-018-confirm_commit.v01.mp3" + ] + ] + } +} \ No newline at end of file diff --git a/docs/audio/mixdowns/result_win.mp3 b/docs/audio/mixdowns/result_win.mp3 new file mode 100644 index 00000000..71f2d40b Binary files /dev/null and b/docs/audio/mixdowns/result_win.mp3 differ diff --git a/docs/audio/mixdowns/risk_and_departure.mp3 b/docs/audio/mixdowns/risk_and_departure.mp3 new file mode 100644 index 00000000..8d2c44ab Binary files /dev/null and b/docs/audio/mixdowns/risk_and_departure.mp3 differ diff --git a/docs/plans/M76_AUDIO_SLICE.md b/docs/plans/M76_AUDIO_SLICE.md index d9adca97..00f35039 100644 --- a/docs/plans/M76_AUDIO_SLICE.md +++ b/docs/plans/M76_AUDIO_SLICE.md @@ -58,6 +58,16 @@ - **接线暴露并修复真缺陷**:市场招募卡的 `IdentityModule` 等模块根节点默认 `MOUSE_FILTER_STOP`,**吃掉卡片点击 → 新开局无法选卡/招募**(全流程驱动此前在 hire 步骤阻断)。已修并写入共享 UI 规则。 - **BAT 60 条与区域/BOSS 音乐**仍随战斗里程碑(清单里显式列为 deferred)。 +## M76-D 门(2026-09-14,待 Owner 听感) + +- 机器检查:`validate_audio.py`(158 SFX + 7 音乐)0 错误;严格运行时诊断 0 泄漏(修复了 + 音乐交叉淡化 Tween 与 Dummy 驱动下的流播放泄漏;playthrough 关掉遗留音调适配器)。 +- 运行时证据:`m76-audio` attempt(13 markers 各一次)——音频服务单测、DEV-2 入口音频断言、 + 34 步全流程 0 阻断 + 音频门(families=10 / page_open=35 / events=55 / switches=10)、八组墨迹探针。 +- 整机试听带:`docs/audio/mixdowns/{guild_page,market_transaction,risk_and_departure,result_win}.mp3` + (音乐床 + 该页真实派发的音效时序;合成试听,不等价于设备播放证据)。 +- **人工听感 = Owner**:音箱与耳机分别听;结论记录后本里程碑收口。 + ## Acceptance checks - 试听批:Owner 听过并认可方向(或给出反例修订) diff --git a/tools/audio/build_mixdowns.py b/tools/audio/build_mixdowns.py new file mode 100644 index 00000000..076de255 --- /dev/null +++ b/tools/audio/build_mixdowns.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Compose listenable mixdowns of the runtime audio (music bed + page cue timing). + +The mixdown approximates what a page sounds like in the running game: the music +family bed under the semantic cues that page actually dispatches (see +`runtime/entry/entry_shell.gd` wiring). It is a listening aid for the Owner gate, +not runtime evidence of device playback. +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +AUDIO = ROOT / "runtime/assets/audio" +SFX = AUDIO / "sfx" +MUSIC = AUDIO / "music" +OUT = ROOT / "docs/audio/mixdowns" + +MUSIC_GAIN_DB = -13.0 +SFX_GAIN_DB = -1.0 + +MIXDOWNS: dict[str, dict] = { + "guild_page": { + "title": "公会页(音乐 + 检视/焦点/整队/整队完成)", + "music": "MUS-G01-GUILD-base.mp3", + "length": 26, + "cues": [ + (1.5, "SFX-UI-005-page_open.v01.mp3"), + (3.4, "SFX-UI-001-focus_move.v01.mp3"), + (5.0, "SFX-UI-007-inspect_open.v01.mp3"), + (6.6, "SFX-UI-008-inspect_close.v01.mp3"), + (8.4, "SFX-RUN-015-save_begin.v01.mp3"), + (10.0, "SFX-RUN-016-save_commit.v01.mp3"), + (12.5, "SFX-UI-001-focus_move.v01.mp3"), + (14.0, "SFX-ORD-006-reorder_commit.v01.mp3"), + (16.5, "SFX-ORD-017-ready_check_pass.v01.mp3"), + (19.0, "SFX-UI-018-confirm_commit.v01.mp3"), + ], + }, + "market_transaction": { + "title": "市场成交(音乐 + 选卡/检视/成交/扣币)", + "music": "MUS-G02-SHOP-base.mp3", + "length": 24, + "cues": [ + (1.2, "SFX-UI-005-page_open.v01.mp3"), + (2.6, "SFX-SHP-002-offer_reveal.v01.mp3"), + (4.6, "SFX-SHP-012-recruit_card_reveal.v01.mp3"), + (6.6, "SFX-SHP-013-recruit_inspect.v01.mp3"), + (8.8, "SFX-SHP-014-recruit_hire_review.v01.mp3"), + (11.0, "SFX-SHP-015-recruit_hire_commit.v01.mp3"), + (13.4, "SFX-SHP-011-currency_spend.v01.mp3"), + (16.0, "SFX-SHP-021-trait_benefit_reveal.v01.mp3"), + (18.4, "SFX-EQP-007-equip_commit.v01.mp3"), + (20.6, "SFX-SHP-024-shop_exit.v01.mp3"), + ], + }, + "risk_and_departure": { + "title": "风险与开战(音乐 + 选风险/落章/确认/出发)", + "music": "MUS-G04-RUN-base.mp3", + "length": 24, + "cues": [ + (1.2, "SFX-UI-005-page_open.v01.mp3"), + (3.0, "SFX-RUN-001-risk_option_focus.v01.mp3"), + (5.0, "SFX-RUN-002-risk_compare.v01.mp3"), + (7.2, "SFX-RUN-003-risk_commit.v01.mp3"), + (9.8, "SFX-UI-017-confirm_arm.v01.mp3"), + (12.0, "SFX-UI-018-confirm_commit.v01.mp3"), + (14.6, "SFX-ORD-019-battle_start_arm.v01.mp3"), + (17.0, "SFX-ORD-020-battle_start_commit.v01.mp3"), + (19.4, "SFX-RUN-004-encounter_enter.v01.mp3"), + ], + }, + "result_win": { + "title": "结算·胜(音乐 + 胜利/奖励/升级)", + "music": "MUS-G05-RESULT-WIN-base.mp3", + "length": 24, + "cues": [ + (1.2, "SFX-UI-005-page_open.v01.mp3"), + (2.8, "SFX-RES-012-battle_victory.v01.mp3"), + (5.4, "SFX-RES-016-reward_reveal.v01.mp3"), + (7.8, "SFX-RES-017-reward_option_focus.v01.mp3"), + (10.0, "SFX-RES-019-reward_accept.v01.mp3"), + (12.4, "SFX-RES-022-currency_reward.v01.mp3"), + (14.8, "SFX-RES-025-experience_gain.v01.mp3"), + (17.2, "SFX-RES-026-level_gain.v01.mp3"), + (20.0, "SFX-UI-018-confirm_commit.v01.mp3"), + ], + }, +} + + +def find_sfx(prefix: str) -> Path | None: + matches = sorted(SFX.glob(prefix)) + return matches[0] if matches else None + + +def build(slug: str, spec: dict) -> bool: + music = MUSIC / str(spec["music"]) + if not music.exists(): + print(f"SKIP {slug}: music missing {music.name}") + return False + inputs: list[str] = ["-i", str(music)] + filters: list[str] = [ + f"[0:a]volume={MUSIC_GAIN_DB}dB,atrim=0:{spec['length']},afade=t=out:st={max(0, spec['length'] - 2)}:d=2[music]" + ] + labels = ["[music]"] + index = 1 + for at, filename in spec["cues"]: + path = find_sfx(filename) + if path is None: + print(f" cue missing: {filename}") + continue + inputs += ["-i", str(path)] + delay = int(float(at) * 1000) + filters.append(f"[{index}:a]volume={SFX_GAIN_DB}dB,adelay={delay}:all=1[cue{index}]") + labels.append(f"[cue{index}]") + index += 1 + filters.append( + "".join(labels) + f"amix=inputs={len(labels)}:normalize=0:dropout_transition=0,alimiter=limit=0.95[out]" + ) + OUT.mkdir(parents=True, exist_ok=True) + target = OUT / f"{slug}.mp3" + subprocess.run( + ["ffmpeg", "-y", "-v", "error", *inputs, "-filter_complex", ";".join(filters), + "-map", "[out]", "-c:a", "libmp3lame", "-q:a", "2", str(target)], + check=True, + ) + print(f"OK {slug} -> {target.relative_to(ROOT)} ({target.stat().st_size} bytes)") + return True + + +def main() -> int: + built = 0 + for slug, spec in MIXDOWNS.items(): + if build(slug, spec): + built += 1 + print(f"mixdowns built: {built}/{len(MIXDOWNS)}") + (OUT / "mixdown_manifest.json").write_text(json.dumps(MIXDOWNS, ensure_ascii=False, indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())