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>
254 lines
12 KiB
C
254 lines
12 KiB
C
// Minimal harness: load <name>.TFX (TFMX module) + <name>.SAM (samples),
|
|
// bundle into a TFHD container (same layout as amiga_exotic_players'
|
|
// try_tfmx_bundle), run tfmx.h's player for N seconds, write a WAV file.
|
|
//
|
|
// Provenance: tfmx.h, paula.h and player_api.h in this directory are
|
|
// vendored, unmodified, from https://github.com/vtlmks/amiga_exotic_players
|
|
// (MIT License, itself a C99 port of the TFMX replayer from NostalgicPlayer,
|
|
// https://github.com/neumatho/NostalgicPlayer, also MIT). See LICENSE in
|
|
// this directory for both notices. This file (render_tfmx.c) and build.sh
|
|
// are original code written for the Kellogg's Remake project. Kellogg's
|
|
// Tony & Friends stores TFMX music as separate <NAME>.TFX (module/"mdat")
|
|
// and <NAME>.SAM (samples/"smpl") files inside PCKELL.DAT; this harness
|
|
// re-bundles that pair into the TFHD container tfmx.h expects, since the
|
|
// game's naming convention differs from TFMX's usual mdat.<name>/smpl.<name>
|
|
// convention.
|
|
//
|
|
// SONG-INDEX (added 22.07.2026 abends, Stefans Meldung "das Spiel hat noch
|
|
// wesentlich mehr Lieder"): ein einzelnes TFX-Modul kann MEHRERE Songs
|
|
// buendeln (tfmx.h's v_songs[]/v_songs_count, ausgewaehlt ueber
|
|
// player_info.admin.start_song). Bisher haben wir immer nur Song 0
|
|
// gerendert. Stichprobe ergab: TITEL.TFX hat 3 Songs, TITEL2.TFX hat 2,
|
|
// ONGAME2.TFX sogar 14 (vermutlich je ein Level-Track). Der optionale 5.
|
|
// CLI-Arg waehlt den gewuenschten Song; ohne Angabe bleibt das Verhalten
|
|
// exakt wie vorher (Song 0, kein tfmx_restart-Aufruf noetig weil das schon
|
|
// der Init-Default ist).
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <stdint.h>
|
|
#include <string.h>
|
|
|
|
#include "player_api.h"
|
|
#include "tfmx.h"
|
|
|
|
static uint8_t *load_file(const char *path, uint32_t *out_len) {
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f) { perror(path); return 0; }
|
|
fseek(f, 0, SEEK_END);
|
|
long len = ftell(f);
|
|
fseek(f, 0, SEEK_SET);
|
|
uint8_t *buf = malloc(len);
|
|
fread(buf, 1, len, f);
|
|
fclose(f);
|
|
*out_len = (uint32_t)len;
|
|
return buf;
|
|
}
|
|
|
|
static void write_wav(const char *path, int16_t *pcm, int32_t frames, int32_t sample_rate) {
|
|
FILE *f = fopen(path, "wb");
|
|
uint32_t data_bytes = frames * 2 * sizeof(int16_t);
|
|
uint32_t riff_size = 36 + data_bytes;
|
|
fwrite("RIFF", 1, 4, f);
|
|
fwrite(&riff_size, 4, 1, f);
|
|
fwrite("WAVE", 1, 4, f);
|
|
fwrite("fmt ", 1, 4, f);
|
|
uint32_t fmt_size = 16;
|
|
fwrite(&fmt_size, 4, 1, f);
|
|
uint16_t audio_format = 1, num_channels = 2;
|
|
fwrite(&audio_format, 2, 1, f);
|
|
fwrite(&num_channels, 2, 1, f);
|
|
fwrite(&sample_rate, 4, 1, f);
|
|
uint32_t byte_rate = sample_rate * 2 * sizeof(int16_t);
|
|
fwrite(&byte_rate, 4, 1, f);
|
|
uint16_t block_align = 2 * sizeof(int16_t);
|
|
fwrite(&block_align, 2, 1, f);
|
|
uint16_t bits_per_sample = 16;
|
|
fwrite(&bits_per_sample, 2, 1, f);
|
|
fwrite("data", 1, 4, f);
|
|
fwrite(&data_bytes, 4, 1, f);
|
|
fwrite(pcm, 1, data_bytes, f);
|
|
fclose(f);
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc < 4) {
|
|
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
|
|
// 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
|
|
// frueher genutzten 60s bzw. einen stehengebliebenen 8s-Cache-Rest).
|
|
// Stattdessen rendern wir jetzt in kleinen Haeppchen und beobachten
|
|
// tfmx.h's eigenes `real_song_end`-Flag -- 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;
|
|
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);
|
|
uint8_t *smpl = load_file(argv[2], &smpl_len);
|
|
if (!mdat || !smpl) return 1;
|
|
|
|
uint32_t hdr_off = 18;
|
|
uint32_t bundle_len = hdr_off + mdat_len + smpl_len;
|
|
uint8_t *bundle = malloc(bundle_len);
|
|
memset(bundle, 0, hdr_off);
|
|
bundle[0] = 'T'; bundle[1] = 'F'; bundle[2] = 'H'; bundle[3] = 'D';
|
|
bundle[4] = 0; bundle[5] = 0;
|
|
bundle[6] = (uint8_t)(hdr_off >> 8); bundle[7] = (uint8_t)hdr_off;
|
|
bundle[8] = 0; bundle[9] = 0;
|
|
bundle[10] = (uint8_t)(mdat_len >> 24); bundle[11] = (uint8_t)(mdat_len >> 16);
|
|
bundle[12] = (uint8_t)(mdat_len >> 8); bundle[13] = (uint8_t)mdat_len;
|
|
bundle[14] = (uint8_t)(smpl_len >> 24); bundle[15] = (uint8_t)(smpl_len >> 16);
|
|
bundle[16] = (uint8_t)(smpl_len >> 8); bundle[17] = (uint8_t)smpl_len;
|
|
memcpy(bundle + hdr_off, mdat, mdat_len);
|
|
memcpy(bundle + hdr_off + mdat_len, smpl, smpl_len);
|
|
|
|
int32_t sample_rate = 44100;
|
|
// 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_init failed (module not recognized)\n");
|
|
return 2;
|
|
}
|
|
|
|
if (song_index >= 0) {
|
|
if ((uint32_t)song_index >= s->v_songs_count) {
|
|
fprintf(stderr, "song_index %d out of range (v_songs_count=%u)\n", song_index, s->v_songs_count);
|
|
return 4;
|
|
}
|
|
// tfmx_init hat bereits Song 0 aufgesetzt (tfmx_init_decoder ->
|
|
// tfmx_restart, siehe tfmx.h). Fuer einen anderen Index muessen wir
|
|
// den Sequencer neu auf den gewuenschten Song ausrichten:
|
|
// start_song setzen, dann tfmx_restart() erneut aufrufen (liest
|
|
// v_songs[start_song] und initialisiert Sequencer-Position/Speed neu).
|
|
s->player_info.admin.start_song = song_index;
|
|
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-
|
|
// 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;
|
|
}
|
|
|
|
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) {
|
|
this_chunk = max_frames - total;
|
|
}
|
|
|
|
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;
|
|
float peak = 0.0f;
|
|
for (int32_t i = 0; i < this_chunk * 2; ++i) {
|
|
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;
|
|
|
|
// 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;
|
|
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, 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]");
|
|
|
|
tfmx_free(s);
|
|
return 0;
|
|
}
|