From 949a7eeda23adda15706eb603e9ddbfdfe356edd Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 24 Jul 2026 22:22:31 +0200 Subject: [PATCH] Factor5-Sound vollstaendig: Ausklang-Tail im TFMX-Renderer (loop_mode-Stopp + Fade) 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 --- game/audio.py | 29 ++++++++++--- game/intro.py | 25 ++++++++--- game/tfmx_player/render_tfmx.c | 79 +++++++++++++++++++++++++++++++--- 3 files changed, 114 insertions(+), 19 deletions(-) diff --git a/game/audio.py b/game/audio.py index 08af8db..143bd28 100644 --- a/game/audio.py +++ b/game/audio.py @@ -62,7 +62,7 @@ def _ensure_binary(): subprocess.run(['bash', os.path.join(_PLAYER_DIR, 'build.sh')], check=True) -def render_wav(container, name, seconds=180, force=False, song_index=None): +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- @@ -78,13 +78,24 @@ def render_wav(container, name, seconds=180, force=False, song_index=None): 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. 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, egal was `seconds` diesmal sagt.""" + `_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 @@ -100,8 +111,12 @@ def render_wav(container, name, seconds=180, force=False, song_index=None): f.write(sam_data) cmd = [_BINARY, tfx_path, sam_path, out_path, str(seconds)] - if song_index: - cmd.append(str(song_index)) + 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}') diff --git a/game/intro.py b/game/intro.py index 49b3e4d..1039d89 100644 --- a/game/intro.py +++ b/game/intro.py @@ -453,7 +453,7 @@ def wait_for_key(clock, draw_fn, fps=30): clock.tick(fps) -def render_audio_with_loading(clock, container, name, song_index, label): +def render_audio_with_loading(clock, container, name, song_index, label, tail_seconds=None): '''Rendert einen TFMX-Song per tfmx_audio.render_wav in einem Hintergrund- Thread, waehrend der Hauptthread weiter Events pumpt und einen simplen Ladebildschirm zeichnet. @@ -485,7 +485,8 @@ def render_audio_with_loading(clock, container, name, song_index, label): def worker(): try: - result['path'] = tfmx_audio.render_wav(container, name, song_index=song_index) + result['path'] = tfmx_audio.render_wav(container, name, song_index=song_index, + tail_seconds=tail_seconds) except Exception as exc: result['error'] = exc @@ -536,15 +537,25 @@ def preload_all_audio(clock, assets): verfuegbar ist (dann bliebe es sowieso stumm, siehe main()).''' if not pygame.mixer.get_init(): return {}, False + # Die beiden kurzen Jingles bekommen einen Ausklang-Tail (siehe + # render_wav-Docstring: real_song_end schneidet sonst die noch klingenden + # Samples ab -- Stefans "Factor5-Soundfile ist noch nicht ganz abgespielt"). + # Die geloopte Titelmusik bewusst OHNE Tail (exakter Loop-Punkt-Schnitt). + # Factor5-Tail = 2.6s -> Gesamt ~5.0s: das Brausen-Sample loopt endlos + # (kein natuerliches Ende), im Original zeigt die Engine den Screen ~5s + # (DOSBox-Messung ~4.8s; Stefans Erinnerung "ca. 5 Sekunden") und stoppt + # den Sound beim Wechsel. render_tfmx blendet das Ende sauber aus (600ms + # Fade), scene_factor5 haelt den Screen genau Songlaenge. jobs = [ - (('TITEL2', 0), 'Rauser-Sound'), - (('TITEL2', 1), 'Factor5-Sound'), - (('TITEL', None), 'Titelmusik'), + (('TITEL2', 0), 'Rauser-Sound', 5.0), + (('TITEL2', 1), 'Factor5-Sound', 2.6), + (('TITEL', None), 'Titelmusik', None), ] audio_paths = {} - for key, label in jobs: + for key, label, tail in jobs: name, song_index = key - path, quit_requested = render_audio_with_loading(clock, assets.container, name, song_index, label) + path, quit_requested = render_audio_with_loading(clock, assets.container, name, song_index, + label, tail_seconds=tail) audio_paths[key] = path if quit_requested: return audio_paths, True diff --git a/game/tfmx_player/render_tfmx.c b/game/tfmx_player/render_tfmx.c index f12cbeb..39c630d 100644 --- a/game/tfmx_player/render_tfmx.c +++ b/game/tfmx_player/render_tfmx.c @@ -72,7 +72,7 @@ static void write_wav(const char *path, int16_t *pcm, int32_t frames, int32_t sa int main(int argc, char **argv) { if (argc < 4) { - fprintf(stderr, "usage: %s [max_seconds] [song_index]\n", argv[0]); + fprintf(stderr, "usage: %s [max_seconds] [song_index] [tail_max_seconds]\n", argv[0]); return 1; } // WICHTIG (Fix 22.07.2026 abends): [max_seconds] ist NUR noch eine @@ -88,6 +88,19 @@ int main(int argc, char **argv) { // nur als Notbremse, falls ein Modul (defekt/kaputt) nie ein Ende meldet. double max_seconds = argc > 4 ? atof(argv[4]) : 180.0; int song_index = argc > 5 ? atoi(argv[5]) : -1; // -1 = Default-Song (0) + // TAIL (24.07.2026, Stefans Meldung "beim Factor5 ist die Zeit noch ein + // bisschen zu kurz, die Soundfile ist noch nicht ganz abgespielt"): + // real_song_end feuert, sobald die TRACK-SEQUENZ einmal durch ist -- die + // zu dem Zeitpunkt noch klingenden Samples (Ausklang/Release, beim + // Factor5-"Brausen" ein ansteigendes Crescendo bis zum letzten Frame, + // RMS-Analyse: 3692 im letzten 100ms-Fenster statt Abfall Richtung 0) + // wurden bisher hart abgeschnitten. Mit tail_max_seconds > 0 rendern wir + // nach dem Sequenz-Ende weiter, bis die Ausgabe ~300ms praktisch still + // ist (oder das Tail-Limit erreicht ist). Opt-in per 6. CLI-Arg, weil bei + // LANGEN geloopten Tracks (TITEL, wird per pygame nahtlos geloopt) ein + // Tail den Loop-Uebergang verschlechtern wuerde -- dort bleibt das alte + // Verhalten (exakt am Loop-Punkt schneiden) richtig. + double tail_max_seconds = argc > 6 ? atof(argv[6]) : 0.0; uint32_t mdat_len = 0, smpl_len = 0; uint8_t *mdat = load_file(argv[1], &mdat_len); @@ -134,6 +147,19 @@ int main(int argc, char **argv) { tfmx_restart(s); } + // TAIL-MODUS: loop_mode abschalten. tfmx.h laesst den Sequencer dann bei + // song_end einfach STEHEN (kein tfmx_soft_restart, keine neuen Notes -- + // siehe die `if(!s->song_end || s->loop_mode)`-Gates in tfmx_do_all), + // waehrend tfmx_get_audio die noch klingenden Paula-Voices weiter mischt. + // Genau das ist der gewuenschte natuerliche Ausklang. (Erster Versuch + // OHNE dieses Flag nahm stattdessen die naechste Loop-Iteration auf -- + // per RMS-Selbstaehnlichkeit von 3 identischen Anschwell-Mustern im + // Tail-WAV nachgewiesen.) Ende-Erkennung im Tail-Modus via s->song_end + // (real_song_end wird nur im Loop-Zweig gesetzt). + if (tail_max_seconds > 0.0) { + s->loop_mode = 0; + } + int32_t max_frames = (int32_t)(max_seconds * sample_rate); // Chunk klein genug waehlen, dass real_song_end nicht durch einen // zweiten Tick INNERHALB desselben Chunks schon wieder auf 0 zurueck- @@ -149,6 +175,11 @@ int main(int argc, char **argv) { int32_t total = 0; int natural_end = 0; + int32_t tail_frames = 0; + int32_t silent_frames = 0; + const int32_t SILENCE_NEEDED = sample_rate * 3 / 10; // ~300ms Stille = Ausklang fertig + const float SILENCE_THRESHOLD = 0.001f; // |sample| unterhalb ~33/32767 + int32_t tail_max_frames = (int32_t)(tail_max_seconds * sample_rate); while (total < max_frames) { int32_t this_chunk = CHUNK; if (total + this_chunk > max_frames) { @@ -159,23 +190,61 @@ int main(int argc, char **argv) { tfmx_get_audio(s, scratch, this_chunk); int16_t *out_ptr = pcm + (size_t)total * 2; + float peak = 0.0f; for (int32_t i = 0; i < this_chunk * 2; ++i) { - float v = scratch[i] * 32767.0f; + float v = scratch[i]; + float av = v < 0 ? -v : v; + if (av > peak) peak = av; + v *= 32767.0f; if (v > 32767.0f) v = 32767.0f; if (v < -32768.0f) v = -32768.0f; out_ptr[i] = (int16_t)v; } total += this_chunk; - if (s->real_song_end) { + // Ende-Signal: im Loop-Modus real_song_end (Sequenz einmal durch, + // startet intern neu -> exakt dort schneiden), im Tail-Modus song_end + // (Sequencer steht, Voices klingen aus). + int end_now = (tail_max_frames > 0) ? (s->song_end != 0) : (s->real_song_end != 0); + if (!natural_end && end_now) { natural_end = 1; - break; + if (tail_max_frames <= 0) { + break; // altes Verhalten: exakt am Sequenz-Ende/Loop-Punkt schneiden + } + continue; // Tail-Phase: Ausklang der noch spielenden Samples mitnehmen + } + if (natural_end) { + tail_frames += this_chunk; + if (peak < SILENCE_THRESHOLD) { + silent_frames += this_chunk; + } else { + silent_frames = 0; + } + if (silent_frames >= SILENCE_NEEDED || tail_frames >= tail_max_frames) { + break; + } + } + } + + // Endete der Tail am LIMIT statt in Stille (z.B. Factor5-"Brausen": das + // Sample loopt auf Paula-Ebene endlos weiter, es gibt kein natuerliches + // Sample-Ende -- im Original stoppt die Engine den Sound einfach beim + // Screenwechsel), dann haerten wir das Ende mit einem kurzen Fade-out ab, + // statt mitten im Klang abzureissen. + if (natural_end && tail_max_frames > 0 && silent_frames < SILENCE_NEEDED) { + int32_t fade = sample_rate * 6 / 10; // 600ms + if (fade > total) fade = total; + for (int32_t i = 0; i < fade; ++i) { + float g = (float)(fade - 1 - i) / (float)fade; + pcm[(size_t)(total - fade + i) * 2] = (int16_t)(pcm[(size_t)(total - fade + i) * 2] * g); + pcm[(size_t)(total - fade + i) * 2 + 1] = (int16_t)(pcm[(size_t)(total - fade + i) * 2 + 1] * g); } } write_wav(argv[3], pcm, total, sample_rate); - fprintf(stderr, "wrote %s: song=%d %d frames @ %d Hz (%.2fs)%s\n", + fprintf(stderr, "wrote %s: song=%d %d frames @ %d Hz (%.2fs, davon %.2fs Ausklang-Tail)%s\n", argv[3], song_index, total, sample_rate, (double)total / sample_rate, + (double)tail_frames / sample_rate, natural_end ? " [natuerliches Songende/Loop-Punkt erkannt]" : " [Sicherheits-Obergrenze erreicht, kein Songende gefunden -- Modul pruefen]");