Add prototype gameplay loop with character/battlefield assets and server game APIs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user