Files
aetherbound-guild/tools/generate_fg2_audio.py
T

228 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""Generate deterministic, original FG-2 PCM audio from local synthesis."""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import math
import random
import struct
import wave
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = ROOT / "runtime/assets/fg2/audio-manifest.json"
DEFAULT_REPORT = ROOT / "docs/runtime/fg2/audio/GENERATION_REPORT.json"
TOOL_VERSION = 1
def sine(frequency: float, time_value: float, phase: float = 0.0) -> float:
return math.sin(math.tau * frequency * time_value + phase)
def decay(time_value: float, duration: float, power: float = 3.0) -> float:
if time_value < 0.0 or time_value >= duration:
return 0.0
return (1.0 - time_value / duration) ** power
def fade_edges(samples: list[float], sample_rate: int, seconds: float) -> None:
count = min(len(samples) // 2, int(sample_rate * seconds))
for index in range(count):
gain = math.sin((index + 1) / count * math.pi * 0.5) ** 2
samples[index] *= gain
samples[-1 - index] *= gain
def normalize(samples: list[float], target_peak: float) -> list[float]:
peak = max(abs(value) for value in samples) if samples else 0.0
if peak <= 0.0:
return samples
scale = target_peak / peak
return [max(-0.98, min(0.98, value * scale)) for value in samples]
def music(duration: float, sample_rate: int, seed: int) -> list[float]:
del seed
frames = int(duration * sample_rate)
beat_seconds = 60.0 / 64.0
motif = [293.665, 349.228, 440.000, 329.628, 293.665, 392.000, 349.228, 329.628]
result: list[float] = []
for index in range(frames):
time_value = index / sample_rate
slow = 0.72 + 0.16 * sine(1.0 / 15.0, time_value, -math.pi * 0.5)
bed = (
0.090 * sine(73.416, time_value)
+ 0.045 * sine(110.000, time_value, 0.4)
+ 0.025 * sine(146.832, time_value, 0.9)
) * slow
beat = int(time_value / beat_seconds)
beat_phase = time_value - beat * beat_seconds
pulse = 0.0
if beat % 2 == 0:
pulse_env = math.exp(-beat_phase * 8.5)
pulse = 0.050 * sine(146.832, beat_phase) * pulse_env
phrase_beat = beat % 8
note = 0.0
if phrase_beat in (0, 3) and beat_phase < 0.58:
frequency = motif[(beat // 8 + (1 if phrase_beat == 3 else 0)) % len(motif)]
note_env = math.exp(-beat_phase * 5.2) * math.sin(min(1.0, beat_phase / 0.015) * math.pi * 0.5)
note = 0.065 * (sine(frequency, beat_phase) + 0.32 * sine(frequency * 2.01, beat_phase)) * note_env
result.append(bed + pulse + note)
fade_edges(result, sample_rate, 0.08)
return result
def ambience(duration: float, sample_rate: int, seed: int) -> list[float]:
rng = random.Random(seed)
components = []
for _index in range(14):
cycles = rng.randint(2100, 9200)
components.append((cycles / duration, rng.uniform(0.0, math.tau), rng.uniform(0.006, 0.018)))
frames = int(duration * sample_rate)
result: list[float] = []
for index in range(frames):
time_value = index / sample_rate
wind = sum(amplitude * sine(frequency, time_value, phase) for frequency, phase, amplitude in components)
pressure = 0.030 * sine(1.0 / 10.0, time_value) * sine(92.0, time_value)
timber = 0.0
for onset in (5.5, 14.0, 23.5):
local = time_value - onset
timber += 0.055 * sine(164.0 - max(0.0, local) * 18.0, max(0.0, local)) * decay(local, 0.8, 4.0)
result.append(wind + pressure + timber)
fade_edges(result, sample_rate, 0.08)
return result
def short_sound(kind: str, duration: float, sample_rate: int, seed: int) -> list[float]:
rng = random.Random(seed)
frames = int(duration * sample_rate)
result: list[float] = []
phase = 0.0
for index in range(frames):
time_value = index / sample_rate
noise = rng.uniform(-1.0, 1.0)
value = 0.0
if kind == "commit":
value = 0.42 * sine(108.0, time_value) * decay(time_value, 0.18, 4.0)
value += 0.24 * sine(820.0, time_value) * decay(time_value - 0.055, 0.12, 5.0)
local = time_value - 0.10
value += 0.16 * sine(164.0, max(0.0, local)) * decay(local, 0.14, 2.2)
value += 0.08 * noise * decay(time_value, 0.055, 6.0)
elif kind == "forecast":
frequency = 250.0 + 690.0 * (time_value / duration) ** 1.7
phase += math.tau * frequency / sample_rate
value = 0.30 * math.sin(phase) * math.sin(math.pi * time_value / duration) ** 1.5
value += 0.08 * sine(frequency * 1.51, time_value) * decay(duration - time_value, duration, 1.0)
elif kind == "impact":
value = 0.38 * sine(96.0, time_value) * decay(time_value, duration, 5.0)
value += 0.22 * noise * decay(time_value, 0.09, 5.0)
elif kind == "guard":
value = 0.24 * noise * decay(time_value, 0.055, 6.0)
value += 0.28 * sine(370.0, time_value) * decay(time_value, duration, 3.2)
value += 0.18 * sine(740.0, time_value, 0.4) * decay(time_value - 0.018, duration - 0.018, 3.5)
elif kind == "casualty":
frequency = 132.0 - 74.0 * (time_value / duration)
phase += math.tau * frequency / sample_rate
value = 0.34 * math.sin(phase) * decay(time_value, duration, 1.6)
value += 0.12 * noise * decay(time_value - 0.08, duration - 0.08, 1.3)
elif kind == "victory":
for onset, frequency in ((0.03, 587.33), (0.29, 440.00)):
local = time_value - onset
value += 0.27 * (sine(frequency, max(0.0, local)) + 0.22 * sine(frequency * 2.0, max(0.0, local))) * decay(local, duration - onset, 2.8)
elif kind == "recovery":
for onset, frequency in ((0.02, 190.0), (0.13, 285.0)):
local = time_value - onset
value += 0.30 * sine(frequency, max(0.0, local)) * decay(local, 0.16, 4.5)
value += 0.10 * noise * decay(local, 0.035, 6.0)
local = time_value - 0.21
value += 0.16 * sine(520.0, max(0.0, local)) * decay(local, 0.19, 3.5)
result.append(value)
fade_edges(result, sample_rate, 0.004)
return result
def render(asset: dict, sample_rate: int) -> bytes:
duration = float(asset["duration_seconds"])
synthesis = str(asset["synthesis"])
seed = int(asset["seed"])
if synthesis == "music":
samples = music(duration, sample_rate, seed)
elif synthesis == "ambience":
samples = ambience(duration, sample_rate, seed)
else:
samples = short_sound(synthesis, duration, sample_rate, seed)
samples = normalize(samples, float(asset["target_peak"]))
payload = b"".join(struct.pack("<h", round(value * 32767.0)) for value in samples)
output = io.BytesIO()
with wave.open(output, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
wav_file.writeframes(payload)
return output.getvalue()
def output_path(resource_path: str) -> Path:
prefix = "res://"
if not resource_path.startswith(prefix):
raise ValueError(f"resource path must start with {prefix}: {resource_path}")
return ROOT / "runtime" / resource_path[len(prefix):]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
if manifest.get("kind") != "abg-fg2-audio-manifest":
raise ValueError("unexpected manifest kind")
sample_rate = int(manifest["sample_rate"])
records = []
for asset in manifest["assets"]:
data = render(asset, sample_rate)
destination = output_path(str(asset["path"]))
if args.check:
if not destination.is_file() or destination.read_bytes() != data:
raise ValueError(f"deterministic audio mismatch: {asset['id']}")
else:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(data)
records.append({
"id": asset["id"],
"path": asset["path"],
"sha256": hashlib.sha256(data).hexdigest(),
"bytes": len(data),
"duration_seconds": asset["duration_seconds"],
"source": "deterministic_local_synthesis",
"seed": asset["seed"],
})
report = {
"version": 1,
"kind": "abg-fg2-audio-generation-report",
"tool_version": TOOL_VERSION,
"paid_calls": 0,
"provider": None,
"assets": records,
}
encoded = json.dumps(report, ensure_ascii=True, indent=2, sort_keys=True) + "\n"
if args.check:
if not args.report.is_file() or args.report.read_text(encoding="utf-8") != encoded:
raise ValueError("generation report mismatch")
print(f"ABG_FG2_AUDIO_GENERATION_OK mode=check assets={len(records)} paid_calls=0 deterministic=true")
else:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(encoded, encoding="utf-8")
print(f"ABG_FG2_AUDIO_GENERATION_OK mode=write assets={len(records)} paid_calls=0 deterministic=true")
return 0
if __name__ == "__main__":
raise SystemExit(main())