94 lines
4.1 KiB
Python
94 lines
4.1 KiB
Python
"""
|
|
Duenner Python-Wrapper um das MIT-lizenzierte `tfmx_player/render_tfmx`
|
|
(siehe tools/tfmx_player/LICENSE, Copyright Peter Fors) -- rendert ein
|
|
TFMX-Musikstueck (<NAME>.TFX + <NAME>.SAM aus PCKELL.DAT) einmalig zu einer
|
|
WAV-Datei und cached das Ergebnis, damit die Intro-Sequenz die Musik per
|
|
pygame.mixer abspielen kann.
|
|
|
|
Warum Subprocess statt reinem Python-Reimplement: TFMX ist ein komplexes
|
|
Amiga-Tracker-Format (siehe tfmx.h, ~95KB reine Player-Logik). Das
|
|
MIT-lizenzierte C-Referenztool duerfen wir direkt nutzen (im Gegensatz zum
|
|
lizenzlosen C#-Referenzprojekt fuer PCX/BOB/MAP, das wir bewusst nur als
|
|
Format-Wissen genommen und selbst neu geschrieben haben). Fuer Musik-
|
|
Wiedergabe ist der Sound selbst das Ziel, nicht der Decoder-Code -- ein
|
|
funktionierender, korrekt lizenzierter Renderer ist hier der pragmatische
|
|
Weg statt TFMX in Python neu zu erfinden.
|
|
|
|
Gerenderte WAVs sind urheberrechtlich das Original-Spielmaterial (Musik),
|
|
werden daher NICHT ins Git-Repo committed (wie raw/ und extracted_dat/) --
|
|
liegen nur lokal im Cache-Verzeichnis.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_PLAYER_DIR = os.path.join(_HERE, 'tfmx_player')
|
|
_BINARY = os.path.join(_PLAYER_DIR, 'render_tfmx')
|
|
_PROJECT_ROOT = os.path.dirname(_HERE)
|
|
_CACHE_DIR = os.environ.get('KELLOGG_AUDIO_CACHE', os.path.join(_PROJECT_ROOT, 'audio_cache'))
|
|
|
|
|
|
def _ensure_binary():
|
|
if os.path.exists(_BINARY):
|
|
return
|
|
subprocess.run(['bash', os.path.join(_PLAYER_DIR, 'build.sh')], check=True)
|
|
|
|
|
|
def render_wav(container, name, seconds=180, force=False):
|
|
"""container: kellogg_formats.DATContainer; name: z.B. 'TITEL' (ohne Extension).
|
|
Gibt den Pfad zur gerenderten (gecachten) WAV-Datei zurueck.
|
|
|
|
WICHTIG (Fix 22.07.2026 abends): `seconds` ist nur noch eine Sicherheits-
|
|
OBERGRENZE, keine Ziel-Laenge! render_tfmx erkennt das natuerliche Song-
|
|
Ende/den Loop-Punkt selbst (tfmx.h real_song_end) und schneidet dort ab --
|
|
z.B. ist TITEL tatsaechlich ~109s lang, ONGAME2 ~123s, TITEL2 (der Rauser-
|
|
Sting) nur ~2s. Vorher wurde hart bei `seconds` abgeschnitten, was TITEL
|
|
(mit frueher genutzten 60s) VOR dem echten Songende kappte -- das war der
|
|
Bug hinter "Musik ist zu kurz, da fehlt der Rest". Der Default hier (180s)
|
|
ist bewusst grosszuegig ueber die laengsten bekannten Stuecke hinaus
|
|
gewaehlt, damit nichts abgeschnitten wird; im Normalfall stoppt render_tfmx
|
|
laengst vorher von selbst.
|
|
|
|
ACHTUNG Cache: der Dateiname ist rein `<name>.wav` OHNE den seconds-Wert.
|
|
Wird `seconds` also mal kleiner gewaehlt als die echte Songlaenge (z.B.
|
|
fuer einen bewusst kurzen Teaser), MUSS `force=True` gesetzt werden, sonst
|
|
liefert diese Funktion den ggf. laenger/kuerzer gerenderten alten Cache-
|
|
Treffer zurueck, egal was `seconds` diesmal sagt -- genau das hat den
|
|
urspruenglichen Bug mit ausgeloest (ein alter 8s-Debug-Render von TITEL
|
|
blieb liegen und wurde stillschweigend weiterverwendet)."""
|
|
os.makedirs(_CACHE_DIR, exist_ok=True)
|
|
out_path = os.path.join(_CACHE_DIR, f'{name}.wav')
|
|
if os.path.exists(out_path) and not force:
|
|
return out_path
|
|
_ensure_binary()
|
|
|
|
tfx_data = container.entries[name + '.TFX']
|
|
sam_data = container.entries[name + '.SAM']
|
|
tfx_path = os.path.join(_CACHE_DIR, f'{name}.TFX')
|
|
sam_path = os.path.join(_CACHE_DIR, f'{name}.SAM')
|
|
with open(tfx_path, 'wb') as f:
|
|
f.write(tfx_data)
|
|
with open(sam_path, 'wb') as f:
|
|
f.write(sam_data)
|
|
|
|
result = subprocess.run(
|
|
[_BINARY, tfx_path, sam_path, out_path, str(seconds)],
|
|
capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f'render_tfmx failed for {name}: {result.stderr}')
|
|
return out_path
|
|
|
|
|
|
if __name__ == '__main__':
|
|
# Kleiner CLI-Test: python3 tfmx_audio.py <DAT_PATH> <NAME> [seconds]
|
|
sys.path.insert(0, _HERE)
|
|
from kellogg_formats import DATContainer
|
|
dat_path = sys.argv[1]
|
|
name = sys.argv[2]
|
|
seconds = int(sys.argv[3]) if len(sys.argv) > 3 else 20
|
|
c = DATContainer(dat_path)
|
|
path = render_wav(c, name, seconds, force=True)
|
|
print(f'rendered: {path}')
|