Files
AIGame/Tools/prepare_main_menu_music.py
T

115 lines
5.8 KiB
Python

"""Archive the DGX original and prepare a gapless-container loop before Unity import."""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import imageio_ffmpeg
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
WORK = ROOT / "Tools/output/main_menu_music"
DESIGN = ROOT / "Doc/Design/Sound/MainMenu"
RATE = 48000
def main():
global WORK, DESIGN
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--work", type=Path, default=WORK)
parser.add_argument("--design", type=Path, default=DESIGN)
parser.add_argument("--stem", default="CandyCamp_MainMenu")
parser.add_argument("--title", default="Candy Camp - Main Menu")
parser.add_argument("--crossfade-bars", type=float, default=2.0)
parser.add_argument("--target-rms-dbfs", type=float)
args = parser.parse_args()
WORK, DESIGN = args.work.resolve(), args.design.resolve()
if not args.stem.replace("_", "").isalnum():
raise SystemExit("Stem must contain letters, numbers and underscores only")
stem = args.stem
downloaded = json.loads((WORK / "downloaded.json").read_text(encoding="utf-8"))
source = WORK / next(name for name in downloaded.values() if name.endswith(".mp3"))
metadata = json.loads(source.with_suffix(".json").read_text(encoding="utf-8"))
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
decoded = subprocess.run(
[ffmpeg, "-v", "error", "-i", str(source), "-f", "f32le", "-acodec",
"pcm_f32le", "-ar", str(RATE), "-ac", "2", "-"],
capture_output=True, check=True,
)
pcm = np.frombuffer(decoded.stdout, dtype="<f4").reshape(-1, 2)
# Use LM-planned BPM and meter. Trim the final fade to the last full
# planned bar; overlap two bars. This smooths the waveform transition; it
# is not a claim of human-verified harmonic or rhythmic seamlessness.
bar_seconds = 60.0 / metadata["cot_bpm"] * int(metadata["cot_timesignature"])
end_seconds = np.floor((len(pcm) / RATE - 1.0) / bar_seconds) * bar_seconds
if args.crossfade_bars <= 0:
raise SystemExit("Crossfade bars must be positive")
overlap_seconds = args.crossfade_bars * bar_seconds
if end_seconds <= 2 * overlap_seconds:
raise SystemExit("Audio too short for selected crossfade")
n = round(overlap_seconds * RATE)
body = pcm[:round(end_seconds * RATE)]
t = np.linspace(0.0, 1.0, n, dtype=np.float32)[:, None]
blend = body[-n:] * np.cos(t * np.pi / 2) + body[:n] * np.sin(t * np.pi / 2)
loop = np.concatenate((body[n:-n], blend))
gain = min(1.0, 10 ** (-3 / 20) / float(np.max(np.abs(loop))))
if args.target_rms_dbfs is not None:
gain = min(gain, 10 ** (args.target_rms_dbfs / 20) / float(np.sqrt(np.mean(loop**2))))
loop = np.asarray(loop * gain, dtype="<f4")
output = WORK / (stem + "_Loop.ogg")
subprocess.run(
[ffmpeg, "-y", "-v", "error", "-f", "f32le", "-ar", str(RATE),
"-ac", "2", "-i", "-", "-c:a", "libvorbis", "-q:a", "5",
"-metadata", "title=" + args.title, "-metadata",
"comment=ACE-Step 1.5 XL SFT; cyclic crossfade", str(output)],
input=loop.tobytes(), capture_output=True, check=True,
)
verify = subprocess.run(
[ffmpeg, "-v", "error", "-i", str(output), "-f", "f32le", "-ar", str(RATE),
"-ac", "2", "-"], capture_output=True, check=True,
)
checked = np.frombuffer(verify.stdout, dtype="<f4").reshape(-1, 2)
assert np.isfinite(checked).all() and len(checked) > RATE * 30
assert np.max(np.abs(checked)) < 1.0, "Encoded output clips"
diffs = np.abs(np.diff(checked, axis=0))
seam_delta = float(np.max(np.abs(checked[-1] - checked[0])))
report = {
"date": "2026-10-20", "model": "acestep-v15-xl-sft",
"lm_model": "acestep-5Hz-lm-4B", "inference_steps": metadata["inference_steps"],
"seed": metadata["seed"], "planned_bpm": metadata["cot_bpm"],
"planned_key": metadata["cot_keyscale"], "planned_meter": metadata["cot_timesignature"],
"prompt": metadata["caption"], "lyrics": metadata["lyrics"],
"source": {"file": stem + "_Source.mp3", "seconds": len(pcm)/RATE,
"sample_rate": RATE, "channels": 2, "bitrate": "128 kbps",
"sha256": hashlib.sha256(source.read_bytes()).hexdigest()},
"loop": {"asset": "AIClient/Assets/Bundles/Sound/BGM/" + stem + "_Loop.ogg",
"seconds": len(checked)/RATE, "sample_rate": RATE, "channels": 2,
"bytes": output.stat().st_size, "codec": "Vorbis", "quality": 5,
"source_end_seconds": float(end_seconds), "crossfade_seconds": overlap_seconds,
"crossfade_planned_bars": args.crossfade_bars,
"target_rms_dbfs": args.target_rms_dbfs,
"gain_db": float(20*np.log10(gain)),
"decoded_peak_dbfs": float(20*np.log10(np.max(np.abs(checked)))),
"decoded_rms_dbfs": float(20*np.log10(np.sqrt(np.mean(checked**2)))),
"boundary_sample_delta": seam_delta,
"internal_sample_delta_p99": float(np.percentile(diffs, 99)),
"sha256": hashlib.sha256(output.read_bytes()).hexdigest()},
"verification": {"decode": "passed", "finite_samples": True, "clipping": False,
"musical_seam": "Cyclic crossfade applied; human listening review pending",
"runtime_playback": "Not wired; resource generation/import only"},
}
DESIGN.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, DESIGN / (stem + "_Source.mp3"))
shutil.copy2(source.with_suffix(".json"), DESIGN / "server_metadata.json")
(DESIGN / "generation.json").write_text(
json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
print(json.dumps(report["loop"], indent=2))
if __name__ == "__main__":
main()