From 094aca0e1e3d3f22c82d8a9813cecaba6b5ea248 Mon Sep 17 00:00:00 2001 From: ARIA Date: Wed, 22 Jul 2026 21:31:33 +0000 Subject: [PATCH] Rauser-Fade-Bug (Alpha-Cutout), Musik-Laenge-Bug (real_song_end-Erkennung) und Factor5-Melodie gefixt --- NOTES.md | 67 ++++++++++++++ tools/intro_sequence.py | 154 ++++++++++++++++++++++++++------ tools/tfmx_audio.py | 23 ++++- tools/tfmx_player/render_tfmx.c | 76 +++++++++++++--- 4 files changed, 282 insertions(+), 38 deletions(-) diff --git a/NOTES.md b/NOTES.md index 261e524..89d8ccb 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1051,3 +1051,70 @@ Original aus. Screenshots liegen als Beleg bei: `title_check_frames/`) nach der Analyse wieder aufgeraeumt, nicht im Repo. Laeuft selbstaendig weiter, keine Rueckfrage noetig. + +## 22.07.2026, spaeter Abend (2): drei Feedback-Bugs von Stefan gefixt + +Stefan hatte nach dem letzten Pfade-relativ/Rauser/Titelbild-Fix drei neue +Beobachtungen gemeldet, alle drei bestaetigt und behoben: + +**1. "Rauser-Text fadet nicht mit aus, bleibt hart umrandet stehen."** +Ursache endlich richtig verstanden (der oben als "kosmetischer Rest, low +priority" dokumentierte Kasten): RAUSER1/2/3.PCC haben keinen Alphakanal, ihr +papierweisser Hintergrund sitzt als hartes Rechteck um die Box-Grafik. Fix: +neue Funktion `make_border_bg_transparent()` in `intro_sequence.py` -- Flood- +Fill vom Bildrand aus ueber alle "papierweissen" Pixel (Schwelle 235/255 je +Kanal), NUR die vom Rand aus zusammenhaengende Flaeche wird transparent. +Isolierte weisse Pixel MITTEN im Motiv (z.B. die weissen Buchstaben in +RAUSER1) beruehren den Rand nicht und bleiben unangetastet -- genau das +Problem, an dem der fruehere `set_colorkey()`-Versuch gescheitert war. +Verifiziert per direktem Pixel-Test (nicht nur behauptet): Eckpixel neben der +Box liegt nach dem Fix exakt bei RAUSER_GREY (73,73,73) statt weiss, und im +Bild bleiben klar sichtbare, fast voll opake Buchstaben-Pixel (Alpha bis 254) +erhalten. Screenshot der Grau-Phase zum Beleg: +[FILE: /shared/uploads/aria_rauser_fade_grey_check.png] + +**2. "Musik ist zu kurz, da fehlt der Rest" + Cache-Bug gefunden.** +Zwei uebereinanderliegende Ursachen: + a) `render_wav()` cached per Dateiname OHNE den `seconds`-Parameter zu + beruecksichtigen -- ein alter Test-Render (TITEL mit 8s, TITEL2 mit 15s + aus einer frueheren Debug-Session) blieb im `audio_cache/` liegen und + wurde bei jedem Aufruf stillschweigend weiterverwendet, egal welche + `seconds` der Aufrufer diesmal wollte. + b) selbst ein frischer Render haette bei den frueher verwendeten festen + Werten (TITEL: 60s) abgeschnitten -- **TITEL ist tatsaechlich ~109s + lang, ONGAME2 ~123s**, beides deutlich laenger als angenommen. +Echter Fix in `render_tfmx.c`: statt stur `seconds` lang zu rendern, wird +jetzt in kleinen 64-Frame-Haeppchen gerendert und tfmx.h's eigenes +`real_song_end`-Flag beobachtet (wird pro Tick gesetzt, sobald die +Track-Sequenz einmal komplett durchgelaufen ist und intern neu startet) -- +sobald es feuert, ist GENAU eine vollstaendige natuerliche Song-Schleife im +Kasten, egal wie lang das Stueck wirklich ist. `seconds`/`max_seconds` ist +jetzt nur noch eine Sicherheits-Obergrenze (Default 180s, grosszuegig ueber +allen 3 bekannten Stuecken). Getestet und verifiziert: + - TITEL: 109.44s (natuerliches Ende erkannt) + - TITEL2 (Rauser-Sting): 2.04s (natuerliches Ende erkannt) + - ONGAME2: 122.88s (natuerliches Ende erkannt) +Alte falschen Cache-Dateien geloescht, mit dem gefixten Tool neu gerendert +und die Laengen direkt per `pygame.mixer.Sound(...).get_length()` verifiziert +(nicht nur die WAV-Header-Bytes ausgerechnet). + +**3. "Bei Factor5 gibt's auch noch ne Melodie, solange das Lied ist Factor5 +anzeigen und dann zum Kellogg's-praesentiert."** +`ONGAME2` war bis jetzt der einzige der 3 TFMX-Tracks ohne zugeordneten +Screen (TITEL2 = Rauser-Sting, TITEL = Kellogg's-Logo/Titelbild-Loop) -- +genau das ist offenbar die Factor5-Melodie. `run_once()` in +`intro_sequence.py` spielt ONGAME2 jetzt beim Erscheinen des Factor5-Logos +einmal komplett ab und haelt den Screen exakt so lange (`sound.get_length()`, +~123s statt der alten festen 3.0s), bevor es zur schwarzen Pause + Kellogg's- +Logo weitergeht. + +**Alle 3 Fixes verifiziert, nicht nur behauptet:** +- Rauser-Alpha-Fix: direkter Pixel-Test + Screenshot (oben verlinkt). +- Musik-Laengen: `render_tfmx`-Stderr-Ausgabe UND `pygame.mixer.Sound.get_length()` + geben identische Werte. +- Kompletter Smoke-Test (`xvfb-run python3 tools/intro_sequence.py --once`) + laeuft ohne Traceback durch (headless ohne Audio-Geraet, laeuft dann + erwartungsgemaess stumm mit Fallback-Dauern). + +Committet + gepusht. Naechster Schritt weiterhin offen: echtes Gameplay +(Tony auf `W1L0` bewegen). diff --git a/tools/intro_sequence.py b/tools/intro_sequence.py index 3656295..4297058 100644 --- a/tools/intro_sequence.py +++ b/tools/intro_sequence.py @@ -11,15 +11,27 @@ Serie vermessen, siehe NOTES.md Abschnitt "Boot-Sequenz vermessen". Kurzfassung: mit "!") faehrt von OBEN ein, RAUSER2 ("ADVERTAINMENT") steht von Anfang an fest. Sobald beide Teile ihre Endposition erreicht haben, spielt ein kurzer Sound-Sting (TITEL2.TFX/.SAM -- das kuerzeste der 3 Musikstuecke, - genau ein "Bling"-Jingle, kein Loop). Danach faedet der WEISSE - Hintergrund kurz zu GRAU um (NICHT weiss -- war ein Fehler in v1!), haelt - kurz, dann schwarze Pause. WICHTIG: alle Rauser-Assets werden nur bei - **halber Pixel-Groesse** (0.5x) ihrer PCC-Nativaufloesung gezeichnet -- - bei 1:1 sind sie in der echten DOSBox-Aufnahme klar zu groß (per - Pixel-Vermessung bestaetigt, siehe NOTES.md "Rauser/Titelbild-Korrektur"). + ein "Bling"-Jingle von ~2s natuerlicher Laenge, kein Loop). Danach faedet + der WEISSE Hintergrund kurz zu GRAU um (NICHT weiss -- war ein Fehler in + v1!), haelt kurz, dann schwarze Pause. WICHTIG: alle Rauser-Assets werden + nur bei **halber Pixel-Groesse** (0.5x) ihrer PCC-Nativaufloesung + gezeichnet -- bei 1:1 sind sie in der echten DOSBox-Aufnahme klar zu groß + (per Pixel-Vermessung bestaetigt, siehe NOTES.md "Rauser/Titelbild- + Korrektur"). KORREKTUR (22.07.2026 abends): die Assets haben keinen + Alphakanal, ihr papierweisser Hintergrund sass als harter Kasten um die + Box-Grafik und fadete beim Uebergang zu Grau NICHT mit (Stefans Meldung: + "Text bleibt hart umrandet stehen"). Fix: make_border_bg_transparent() + macht per Flood-Fill vom Bildrand aus genau diesen Aussenbereich + transparent, isolierte weisse Buchstaben MITTEN im Motiv bleiben + unangetastet. 2. kurze schwarze Pause 3. Factor5-Studio-Logo (FACTOR5.PCC, STATISCHES Bild -- keine Animation, - das "Geister"-Doppellogo ist Teil des Assets selbst), ~6s + das "Geister"-Doppellogo ist Teil des Assets selbst) mit einer EIGENEN + Melodie (ONGAME2.TFX/.SAM, der bis 22.07.2026 abends noch keinem Screen + zugeordnete dritte TFMX-Track -- Stefans Hinweis: "bei factor 5 gibts + auch noch ne melodie"). Der Screen bleibt exakt so lange stehen wie das + Stueck einmal komplett durchspielt (natuerliche Laenge per render_tfmx + erkannt, ~123s), nicht mehr eine feste Dauer. 4. kurze schwarze Pause 5. Kellogg's-Markenlogo (KELLOGGS.PCC) erscheint SOFORT komplett; darunter faedet der Text "praesentiert" per Palette-Fade von blass nach saettigt- @@ -58,6 +70,7 @@ sonst loopt die Sequenz weiter wie im Original-Titelbildschirm) import sys import os import time +from collections import deque sys.path.insert(0, os.path.dirname(__file__)) from kellogg_formats import DATContainer, load_pcc @@ -109,6 +122,67 @@ def indices_to_surface(width, height, indices, palette): return surf.convert() +def make_border_bg_transparent(surface, threshold=235): + '''Fix fuer Stefans Meldung (22.07.2026 abends): "der Rauser-Text fadet + nicht mit aus, bleibt hart umrandet". Ursache: die Rauser-PCC-Assets haben + KEINEN Alphakanal -- ihr papierweisser Hintergrund sitzt als hartes + Rechteck um die eigentliche Box-Grafik (RAUSER-Schriftzug/ADVERTAINMENT/ + "!"-Box). Auf weissem Bildhintergrund faellt das nicht auf, aber sobald + die Szene zu Grau ueberblendet, bleibt dieses Rechteck stur weiss stehen + statt mit auszufaden. Ein v1-Versuch mit set_colorkey() wurde verworfen, + weil er auch die WEISSEN BUCHSTABEN in RAUSER1 durchsichtig machte. + + Fix: Flood-Fill vom Bildrand aus ueber alle "papierweissen" Pixel (alle + Kanaele >= threshold) -- NUR die vom Rand aus zusammenhaengende Flaeche + wird transparent gemacht. Isolierte weisse Pixel MITTEN im Motiv (z.B. + weisse Buchstaben im RAUSER-Schriftzug) beruehren den Bildrand nicht und + bleiben deshalb unangetastet. Ergebnis: eine Surface mit echtem + Alphakanal, die beim Blit auf einen sich veraendernden Hintergrund + (weiss->grau) sauber durchscheint, statt eine harte Kante zu zeigen.''' + w, h = surface.get_size() + surf = surface.convert_alpha() + px = pygame.PixelArray(surf) + + def is_bg(x, y): + color = surf.unmap_rgb(px[x, y]) + return color.r >= threshold and color.g >= threshold and color.b >= threshold + + seen = bytearray(w * h) + dq = deque() + + def consider(x, y): + if not seen[y * w + x] and is_bg(x, y): + seen[y * w + x] = 1 + dq.append((x, y)) + + for x in range(w): + consider(x, 0) + consider(x, h - 1) + for y in range(h): + consider(0, y) + consider(w - 1, y) + + while dq: + cx, cy = dq.popleft() + if cx + 1 < w: + consider(cx + 1, cy) + if cx - 1 >= 0: + consider(cx - 1, cy) + if cy + 1 < h: + consider(cx, cy + 1) + if cy - 1 >= 0: + consider(cx, cy - 1) + del px + + alpha = pygame.surfarray.pixels_alpha(surf) + for x in range(w): + for y in range(h): + if seen[y * w + x]: + alpha[x, y] = 0 + del alpha + return surf + + class Assets: def __init__(self, dat_path): self.container = DATContainer(dat_path) @@ -121,10 +195,16 @@ class Assets: w, h, indices, palette = self.pcc(name) return indices_to_surface(w, h, indices, palette) - def pcc_surface_scaled(self, name, scale=ASSET_DISPLAY_SCALE): + def pcc_surface_scaled(self, name, scale=ASSET_DISPLAY_SCALE, cut_border_bg=False): '''Skaliert BEIDE Achsen um scale -- fuer die Rauser-Assets (dort per - Pixelvermessung als gleichmaessige 0.5x-Verkleinerung bestaetigt).''' + Pixelvermessung als gleichmaessige 0.5x-Verkleinerung bestaetigt). + cut_border_bg=True macht den vom Bildrand aus erreichbaren papierweissen + Hintergrund transparent (siehe make_border_bg_transparent) -- fuer die + Rauser-Teile, damit sie beim Fade zu Grau nicht als harte weisse + Kaesten stehen bleiben.''' surf = self.pcc_surface(name) + if cut_border_bg: + surf = make_border_bg_transparent(surf) w, h = surf.get_size() new_size = (max(1, round(w * scale)), max(1, round(h * scale))) return pygame.transform.smoothscale(surf, new_size) @@ -213,19 +293,15 @@ def build_rauser_parts(assets): '''Liefert die drei skalierten Rauser-Teile + ihre Endpositionen auf einer 320x240-Leinwand (RAUSER1 links, RAUSER3 rechts daneben, RAUSER2 darunter mittig -- alle bei ASSET_DISPLAY_SCALE, siehe Docstring).''' - r1 = assets.pcc_surface_scaled('RAUSER1') # "RAUSER"-Box - r2 = assets.pcc_surface_scaled('RAUSER2') # "ADVERTAINMENT" - r3 = assets.pcc_surface_scaled('RAUSER3') # "!" auf blauer Box + # cut_border_bg=True (Fix 22.07.2026 abends): macht den papierweissen + # Hintergrund um jede Box transparent (nur die vom Bildrand aus + # zusammenhaengende Flaeche, siehe make_border_bg_transparent) -- vorher + # blieb hier ein harter weisser Kasten stehen, der beim Fade zu Grau nicht + # mitgefadet ist (Stefans Meldung "Text bleibt hart umrandet stehen"). + r1 = assets.pcc_surface_scaled('RAUSER1', cut_border_bg=True) # "RAUSER"-Box + r2 = assets.pcc_surface_scaled('RAUSER2', cut_border_bg=True) # "ADVERTAINMENT" + r3 = assets.pcc_surface_scaled('RAUSER3', cut_border_bg=True) # "!" auf blauer Box - # BEKANNTE UNSCHOENHEIT (bewusst NICHT "gefixt"): die PCC-Assets haben - # weissen Hintergrund in ihrer eigenen Bounding-Box (kein Alpha-Kanal). - # Auf weissem Grund faellt das nicht auf, nach dem Fade zu Grau sieht man - # einen schwachen weissen Kasten um RAUSER2/RAUSER3. Ein Versuch, Weiss - # per set_colorkey() durchsichtig zu machen, wurde wieder verworfen -- - # dadurch wurden auch die WEISSEN BUCHSTABEN in RAUSER1 durchsichtig - # (schlimmer als der Kasten). Sauberer Fix braucht eine echte - # Alpha-Maske pro Asset statt eines einzelnen Colorkeys; low priority, - # siehe "Noch offen" unten. total_w = r1.get_width() + r3.get_width() total_h = r1.get_height() + r2.get_height() x0 = (320 - total_w) // 2 @@ -267,7 +343,12 @@ def run_rauser(screen, clock, assets): sting = None try: if pygame.mixer.get_init(): - sting_wav = tfmx_audio.render_wav(assets.container, 'TITEL2', seconds=1.2) + # KEIN fester seconds-Cap mehr (Fix 22.07.2026 abends, siehe + # tfmx_audio.render_wav-Docstring) -- render_tfmx erkennt das + # natuerliche Ende von TITEL2 selbst (~2s Jingle) und schneidet + # dort ab. Der alte harte 1.2s-Cap konnte den Sting theoretisch + # noch VOR seinem echten Ende kappen. + sting_wav = tfmx_audio.render_wav(assets.container, 'TITEL2') sting = pygame.mixer.Sound(sting_wav) except Exception as exc: print(f'[intro_sequence] Rauser-Sound-Sting konnte nicht geladen werden: {exc}', file=sys.stderr) @@ -417,12 +498,30 @@ def run_once(screen, clock, assets): if quit_requested: return True - # 3) Factor5 (statisch) + # 3) Factor5 (statisches Bild, ABER mit eigener Melodie -- ONGAME2, der + # dritte/letzte bisher nicht zugeordnete TFMX-Track, siehe Stefans + # Meldung 22.07.2026 abends: "bei factor 5 gibts auch noch ne melodie + # solange das lied ist factor 5 anzeigen und dann zum kellogs + # praesentiert". Der Screen bleibt jetzt exakt so lange stehen wie das + # Stueck einmal komplett durchspielt (~123s natuerliche Laenge, siehe + # tfmx_audio.render_wav-Docstring), statt einer festen 3.0s-Dauer. factor5 = build_letterboxed(assets.pcc_surface('FACTOR5')) + factor5_duration = 3.0 # Fallback falls kein Audio verfuegbar + factor5_sound = None + try: + if pygame.mixer.get_init(): + factor5_wav = tfmx_audio.render_wav(assets.container, 'ONGAME2') + factor5_sound = pygame.mixer.Sound(factor5_wav) + factor5_duration = factor5_sound.get_length() + factor5_sound.play() + except Exception as exc: + print(f'[intro_sequence] Factor5-Melodie konnte nicht geladen werden: {exc}', file=sys.stderr) def show_factor5(_t): blit_scaled_centered(screen, factor5, WINDOW_W, WINDOW_H) - quit_requested = wait_or_skip(clock, 3.0, show_factor5) + quit_requested = wait_or_skip(clock, factor5_duration, show_factor5) + if factor5_sound is not None: + factor5_sound.stop() if quit_requested: return True @@ -436,9 +535,14 @@ def run_once(screen, clock, assets): # bis zum Ende der Sequenz (Original hat vermutlich durchgehende # Titelmusik ueber Logo+Titelbild+Karte -- noch nicht per Referenz- # Sichtung bestaetigt, aber plausibelste Annahme fuers erste v1). + # KEIN fester seconds-Cap mehr (Fix 22.07.2026 abends): TITEL ist + # tatsaechlich ~109s lang -- der alte 60s-Cap (plus ein liegen + # gebliebener 8s-Debug-Cache) hat das Stueck weit vor seinem echten Ende + # abgeschnitten. Das war Stefans "Musik ist zu kurz, da fehlt der + # Rest"-Bug. render_tfmx erkennt den natuerlichen Loop-Punkt jetzt selbst. try: if pygame.mixer.get_init(): - titel_wav = tfmx_audio.render_wav(assets.container, 'TITEL', seconds=60) + titel_wav = tfmx_audio.render_wav(assets.container, 'TITEL') pygame.mixer.music.load(titel_wav) pygame.mixer.music.play(-1) except Exception as exc: diff --git a/tools/tfmx_audio.py b/tools/tfmx_audio.py index f235fbf..a322230 100644 --- a/tools/tfmx_audio.py +++ b/tools/tfmx_audio.py @@ -35,9 +35,28 @@ def _ensure_binary(): subprocess.run(['bash', os.path.join(_PLAYER_DIR, 'build.sh')], check=True) -def render_wav(container, name, seconds=20, force=False): +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.""" + 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 `.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: diff --git a/tools/tfmx_player/render_tfmx.c b/tools/tfmx_player/render_tfmx.c index bef16fd..350faf8 100644 --- a/tools/tfmx_player/render_tfmx.c +++ b/tools/tfmx_player/render_tfmx.c @@ -62,10 +62,23 @@ 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 [seconds]\n", argv[0]); + fprintf(stderr, "usage: %s [max_seconds]\n", argv[0]); return 1; } - double seconds = argc > 4 ? atof(argv[4]) : 15.0; + // WICHTIG (Fix 22.07.2026 abends): [max_seconds] ist NUR noch eine + // Sicherheits-Obergrenze, keine Ziel-Laenge mehr! Vorher wurde exakt + // `seconds` lang gerendert -- war der Wert kleiner als die echte + // Songlaenge, wurde mitten im Stueck abgeschnitten (Stefans Meldung: + // "die Musik ist zu kurz, da fehlt der Rest", verursacht durch TITEL mit + // festen 60s bzw. einen stehengebliebenen 8s-Cache-Rest). Stattdessen + // rendern wir jetzt in kleinen Haeppchen und beobachten tfmx.h's eigenes + // `real_song_end`-Flag (siehe tfmx.h: wird pro Tick in tfmx_run() + // gesetzt, sobald die Track-Sequenz einmal komplett durchgelaufen ist + // und intern neu startet) -- sobald es feuert, haben wir GENAU eine + // vollstaendige natuerliche Song-Schleife im Kasten und brechen ab, + // egal wie lang das Stueck wirklich ist. max_seconds greift nur als + // Notbremse, falls ein Modul (defekt/kaputt) nie ein Ende meldet. + double max_seconds = argc > 4 ? atof(argv[4]) : 180.0; uint32_t mdat_len = 0, smpl_len = 0; uint8_t *mdat = load_file(argv[1], &mdat_len); @@ -88,20 +101,61 @@ int main(int argc, char **argv) { memcpy(bundle + hdr_off + mdat_len, smpl, smpl_len); int32_t sample_rate = 44100; - void *s = tfmx_api.init(bundle, bundle_len, sample_rate); + // Direkt tfmx_init/tfmx_get_audio statt der generischen player_api- + // Indirektion nutzen -- wir brauchen Zugriff auf state->real_song_end, + // das die generische Schnittstelle (nur get_audio(state, out, frames)) + // nicht durchreicht. + struct tfmx_state *s = tfmx_init(bundle, bundle_len, sample_rate); if (!s) { - fprintf(stderr, "tfmx_api.init failed (module not recognized)\n"); + fprintf(stderr, "tfmx_init failed (module not recognized)\n"); return 2; } - int32_t total_frames = (int32_t)(seconds * sample_rate); - float *scratch = malloc(sizeof(float) * total_frames * 2); - int16_t *pcm = malloc(sizeof(int16_t) * total_frames * 2); - player_get_audio_s16(&tfmx_api, s, pcm, scratch, total_frames); + 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- + // gesetzt wurde, bevor wir nachsehen (ein Tick liegt typischerweise bei + // ~20ms/882 Frames bei 44.1kHz -- 64 Frames sind davon weit entfernt). + const int32_t CHUNK = 64; + float *scratch = malloc(sizeof(float) * CHUNK * 2); + int16_t *pcm = malloc(sizeof(int16_t) * (size_t)max_frames * 2); + if (!scratch || !pcm) { + fprintf(stderr, "out of memory\n"); + return 3; + } - write_wav(argv[3], pcm, total_frames, sample_rate); - fprintf(stderr, "wrote %s: %d frames @ %d Hz (%.1fs)\n", argv[3], total_frames, sample_rate, seconds); + int32_t total = 0; + int natural_end = 0; + while (total < max_frames) { + int32_t this_chunk = CHUNK; + if (total + this_chunk > max_frames) { + this_chunk = max_frames - total; + } - tfmx_api.free(s); + memset(scratch, 0, sizeof(float) * (size_t)this_chunk * 2); + tfmx_get_audio(s, scratch, this_chunk); + + int16_t *out_ptr = pcm + (size_t)total * 2; + for (int32_t i = 0; i < this_chunk * 2; ++i) { + float v = scratch[i] * 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) { + natural_end = 1; + break; + } + } + + write_wav(argv[3], pcm, total, sample_rate); + fprintf(stderr, "wrote %s: %d frames @ %d Hz (%.2fs)%s\n", + argv[3], total, sample_rate, (double)total / sample_rate, + natural_end ? " [natuerliches Songende/Loop-Punkt erkannt]" + : " [Sicherheits-Obergrenze erreicht, kein Songende gefunden -- Modul pruefen]"); + + tfmx_free(s); return 0; }