""" Duenner Python-Wrapper um das MIT-lizenzierte `tfmx_player/render_tfmx` (siehe game/tfmx_player/LICENSE, Copyright Peter Fors) -- rendert ein TFMX-Musikstueck (.TFX + .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 game/original/ und extracted_dat/) -- liegen nur lokal im Cache-Verzeichnis. MEHRERE SONGS PRO MODUL (Fund 22.07.2026 abends, Stefans Hinweis "das Spiel hat noch wesentlich mehr Lieder"): ein einzelnes TFX-Modul kann intern MEHRERE Songs buendeln (tfmx.h's v_songs[]/v_songs_count, ausgewaehlt per start_song-Index). Stichprobe per Debug-Tool ergab: TITEL.TFX -> 3 Songs (song0/1 lang/loopend >15s, song2 ~8.0s) TITEL2.TFX -> 2 Songs (song0 ~2.04s = Rauser-Bling, song1 ~2.40s = Factor5- Brausen -- Laenge passt zu Stefans "nur ein bisschen laenger als der Rauser-Sound") ONGAME2.TFX -> 14 Songs (>15s-Tracks vermutlich Level-Musik, song3/11/12 sehr kurz (0.16-1.36s) vermutlich Jingles/Stinger, song2/13 ~10.3s). Noch nicht im Detail zugeordnet -- fuer die spaetere Gameplay-Musik relevant, siehe NOTES.md. render_wav() nimmt jetzt optional `song_index` entgegen und reicht ihn als 5. CLI-Arg an render_tfmx durch. Der Cache-Dateiname enthaelt den Song-Index NUR wenn er nicht der Default (0/None) ist, damit bereits vorhandene Caches fuer Song 0 (z.B. TITEL.wav) gueltig bleiben. """ 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(): '''Baut render_tfmx, wenn das Binary fehlt ODER aelter ist als eine der Quelldateien. Das "aelter als die Quelle"-Kriterium ist wichtig: nach einem git pull (z.B. dem Song-Index-Feature in render_tfmx.c) blieb sonst ein veraltetes Binary liegen, das falsche/zu lange WAVs rendert -- das hat am 24.07.2026 einen 180s-statt-2.4s-Render und damit einen Intro-Hang verursacht (Factor5-Dauer haengt an der Songlaenge).''' srcs = [os.path.join(_PLAYER_DIR, f) for f in ('render_tfmx.c', 'tfmx.h', 'paula.h', 'player_api.h')] if os.path.exists(_BINARY): bmtime = os.path.getmtime(_BINARY) if all(not os.path.exists(s) or os.path.getmtime(s) <= bmtime for s in srcs): return # Binary ist aktuell subprocess.run(['bash', os.path.join(_PLAYER_DIR, 'build.sh')], check=True) def render_wav(container, name, seconds=180, force=False, song_index=None, tail_seconds=None): """container: kellogg_formats.DATContainer; name: z.B. 'TITEL' (ohne Extension). song_index: optional -- welcher der ggf. mehreren in .TFX gebuendelten Songs gerendert werden soll (siehe Modul-Docstring). None/0 = Default- Verhalten wie bisher (erster/einziger Song). 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 (song 0) tatsaechlich ~109s lang, ONGAME2 (song 0) ~123s, TITEL2 song0 (Rauser-Sting) nur ~2.04s. Vorher wurde hart bei `seconds` abgeschnitten. 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. tail_seconds (24.07.2026, Factor5-"Soundfile nicht ganz abgespielt"-Fix): fuer kurze Jingles/Stinger. real_song_end markiert nur das Ende der TRACK-SEQUENZ -- die dann noch klingenden Samples (Ausklang) wuerden ohne Tail hart abgeschnitten. Mit tail_seconds > 0 rendert render_tfmx nach dem Sequenz-Ende weiter, bis ~300ms Stille erreicht sind (max. tail_seconds). Fuer geloopte lange Tracks (Titelmusik) NICHT setzen -- dort ist der exakte Loop-Punkt-Schnitt fuers nahtlose Loopen der richtige. ACHTUNG Cache: der Dateiname ist `.wav` (song_index None/0) bzw. `_song.wav` fuer N>0; mit tail_seconds kommt `_tail` dazu (damit alte, noch abgeschnittene Cache-Dateien nicht faelschlich weiterverwendet werden). Wird `seconds` kleiner gewaehlt als die echte Songlaenge, MUSS `force=True` gesetzt werden, sonst liefert diese Funktion einen ggf. laenger/kuerzer gerenderten alten Cache-Treffer zurueck.""" os.makedirs(_CACHE_DIR, exist_ok=True) suffix = f'_song{song_index}' if song_index else '' if tail_seconds: suffix += '_tail' out_path = os.path.join(_CACHE_DIR, f'{name}{suffix}.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) cmd = [_BINARY, tfx_path, sam_path, out_path, str(seconds)] if song_index or tail_seconds: # song_index ist Positionsarg vor tail_seconds -- bei tail ohne # expliziten Song -1 uebergeben (= Default-Song, siehe render_tfmx.c) cmd.append(str(song_index if song_index else -1)) if tail_seconds: cmd.append(str(tail_seconds)) result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f'render_tfmx failed for {name} (song_index={song_index}): {result.stderr}') return out_path if __name__ == '__main__': # Kleiner CLI-Test: python3 -m game.audio [seconds] [song_index] sys.path.insert(0, _PROJECT_ROOT) from game.formats import DATContainer dat_path = sys.argv[1] name = sys.argv[2] seconds = int(sys.argv[3]) if len(sys.argv) > 3 else 20 song_index = int(sys.argv[4]) if len(sys.argv) > 4 else None c = DATContainer(dat_path) path = render_wav(c, name, seconds, force=True, song_index=song_index) print(f'rendered: {path}')