Stefans Meldung "Factor5-Zeit zu kurz, Soundfile nicht ganz abgespielt" -- die
Ursache lag im Renderer: real_song_end markiert nur das Ende der TRACK-SEQUENZ
(2.4s), die dann noch klingenden Samples wurden hart abgeschnitten.
- render_tfmx.c: neues optionales 6. CLI-Arg tail_max_seconds. Im Tail-Modus
wird loop_mode=0 gesetzt -- tfmx.h laesst den Sequencer bei song_end dann
STEHEN (kein Restart, keine neuen Noten), waehrend die Paula-Voices weiter
ausklingen. (Erster Versuch ohne loop_mode-Stopp nahm stattdessen die
naechste Loop-Iteration auf -- per RMS-Selbstaehnlichkeit nachgewiesen.)
Tail endet bei ~300ms Stille oder tail-Limit; bei Limit-Ende (loopende
Samples ohne natuerliches Ende) wird ein 600ms-Fade-out angewendet.
- Factor5 ("Brausen") loopt auf Paula-Ebene endlos -> Tail 2.6s => Gesamt
exakt 5.0s (DOSBox-Messung ~4.8s Screendauer, Stefans "ca. 5 Sekunden"),
sauber ausgeblendet. Rauser-Sting endet natuerlich bei 2.34s.
- audio.py: tail_seconds-Parameter, Cache-Suffix _tail (alte abgeschnittene
Caches greifen nicht mehr). intro.py: Jingles mit Tail, Titelmusik bewusst
ohne (exakter Loop-Punkt-Schnitt fuers nahtlose pygame-Loopen).
Screen haelt weiterhin exakt Songlaenge (jetzt 5.0s), verifiziert: WAV klingt
aus (RMS 2065->261), --once headless exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
7.0 KiB
Python
137 lines
7.0 KiB
Python
"""
|
|
Duenner Python-Wrapper um das MIT-lizenzierte `tfmx_player/render_tfmx`
|
|
(siehe game/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 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 <name>.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 `<name>.wav` (song_index None/0) bzw.
|
|
`<name>_song<N>.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 <DAT_PATH> <NAME> [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}')
|