115 lines
5.8 KiB
Python
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()
|