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 <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-24 22:22:31 +02:00
co-authored by Claude Fable 5
parent ccad606a43
commit 949a7eeda2
3 changed files with 114 additions and 19 deletions
+74 -5
View File
@@ -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 <mdat.tfx> <smpl.sam> <out.wav> [max_seconds] [song_index]\n", argv[0]);
fprintf(stderr, "usage: %s <mdat.tfx> <smpl.sam> <out.wav> [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]");