165 lines
6.1 KiB
Python
165 lines
6.1 KiB
Python
#!/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"),
|
|
],
|
|
},
|
|
|
|
"battle_exchange": {
|
|
"title": "\u6218\u6597\u4ea4\u950b\uff08\u6218\u573a\u97f3\u4e50 + \u9884\u8b66/\u683c\u6321/\u5f39\u9053/\u6cd5\u672f/\u6cbb\u7597/\u91cd\u51fb\uff09",
|
|
"music": "MUS-R01-BATTLE-base.mp3",
|
|
"length": 24,
|
|
"cues": [
|
|
(1.0, "SFX-BAT-002.v01.mp3"),
|
|
(3.0, "SFX-BAT-055.v01.mp3"),
|
|
(5.0, "SFX-BAT-035.v01.mp3"),
|
|
(7.0, "SFX-BAT-036.v01.mp3"),
|
|
(9.0, "SFX-BAT-026.v01.mp3"),
|
|
(11.0, "SFX-BAT-027.v01.mp3"),
|
|
(13.0, "SFX-BAT-028.v01.mp3"),
|
|
(15.0, "SFX-BAT-029.v01.mp3"),
|
|
(17.0, "SFX-BAT-044.v01.mp3"),
|
|
(19.0, "SFX-BAT-024.v01.mp3"),
|
|
(21.0, "SFX-BAT-057.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())
|