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:
co-authored by
Claude Fable 5
parent
ccad606a43
commit
949a7eeda2
+22
-7
@@ -62,7 +62,7 @@ def _ensure_binary():
|
|||||||
subprocess.run(['bash', os.path.join(_PLAYER_DIR, 'build.sh')], check=True)
|
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).
|
"""container: kellogg_formats.DATContainer; name: z.B. 'TITEL' (ohne Extension).
|
||||||
song_index: optional -- welcher der ggf. mehreren in <name>.TFX gebuendelten
|
song_index: optional -- welcher der ggf. mehreren in <name>.TFX gebuendelten
|
||||||
Songs gerendert werden soll (siehe Modul-Docstring). None/0 = Default-
|
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
|
laengsten bekannten Stuecke hinaus gewaehlt, damit nichts abgeschnitten
|
||||||
wird; im Normalfall stoppt render_tfmx laengst vorher von selbst.
|
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.
|
ACHTUNG Cache: der Dateiname ist `<name>.wav` (song_index None/0) bzw.
|
||||||
`<name>_song<N>.wav` fuer N>0. Wird `seconds` kleiner gewaehlt als die
|
`<name>_song<N>.wav` fuer N>0; mit tail_seconds kommt `_tail` dazu (damit
|
||||||
echte Songlaenge, MUSS `force=True` gesetzt werden, sonst liefert diese
|
alte, noch abgeschnittene Cache-Dateien nicht faelschlich weiterverwendet
|
||||||
Funktion einen ggf. laenger/kuerzer gerenderten alten Cache-Treffer
|
werden). Wird `seconds` kleiner gewaehlt als die echte Songlaenge, MUSS
|
||||||
zurueck, egal was `seconds` diesmal sagt."""
|
`force=True` gesetzt werden, sonst liefert diese Funktion einen ggf.
|
||||||
|
laenger/kuerzer gerenderten alten Cache-Treffer zurueck."""
|
||||||
os.makedirs(_CACHE_DIR, exist_ok=True)
|
os.makedirs(_CACHE_DIR, exist_ok=True)
|
||||||
suffix = f'_song{song_index}' if song_index else ''
|
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')
|
out_path = os.path.join(_CACHE_DIR, f'{name}{suffix}.wav')
|
||||||
if os.path.exists(out_path) and not force:
|
if os.path.exists(out_path) and not force:
|
||||||
return out_path
|
return out_path
|
||||||
@@ -100,8 +111,12 @@ def render_wav(container, name, seconds=180, force=False, song_index=None):
|
|||||||
f.write(sam_data)
|
f.write(sam_data)
|
||||||
|
|
||||||
cmd = [_BINARY, tfx_path, sam_path, out_path, str(seconds)]
|
cmd = [_BINARY, tfx_path, sam_path, out_path, str(seconds)]
|
||||||
if song_index:
|
if song_index or tail_seconds:
|
||||||
cmd.append(str(song_index))
|
# 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)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(f'render_tfmx failed for {name} (song_index={song_index}): {result.stderr}')
|
raise RuntimeError(f'render_tfmx failed for {name} (song_index={song_index}): {result.stderr}')
|
||||||
|
|||||||
+18
-7
@@ -453,7 +453,7 @@ def wait_for_key(clock, draw_fn, fps=30):
|
|||||||
clock.tick(fps)
|
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-
|
'''Rendert einen TFMX-Song per tfmx_audio.render_wav in einem Hintergrund-
|
||||||
Thread, waehrend der Hauptthread weiter Events pumpt und einen simplen
|
Thread, waehrend der Hauptthread weiter Events pumpt und einen simplen
|
||||||
Ladebildschirm zeichnet.
|
Ladebildschirm zeichnet.
|
||||||
@@ -485,7 +485,8 @@ def render_audio_with_loading(clock, container, name, song_index, label):
|
|||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
result['error'] = exc
|
result['error'] = exc
|
||||||
|
|
||||||
@@ -536,15 +537,25 @@ def preload_all_audio(clock, assets):
|
|||||||
verfuegbar ist (dann bliebe es sowieso stumm, siehe main()).'''
|
verfuegbar ist (dann bliebe es sowieso stumm, siehe main()).'''
|
||||||
if not pygame.mixer.get_init():
|
if not pygame.mixer.get_init():
|
||||||
return {}, False
|
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 = [
|
jobs = [
|
||||||
(('TITEL2', 0), 'Rauser-Sound'),
|
(('TITEL2', 0), 'Rauser-Sound', 5.0),
|
||||||
(('TITEL2', 1), 'Factor5-Sound'),
|
(('TITEL2', 1), 'Factor5-Sound', 2.6),
|
||||||
(('TITEL', None), 'Titelmusik'),
|
(('TITEL', None), 'Titelmusik', None),
|
||||||
]
|
]
|
||||||
audio_paths = {}
|
audio_paths = {}
|
||||||
for key, label in jobs:
|
for key, label, tail in jobs:
|
||||||
name, song_index = key
|
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
|
audio_paths[key] = path
|
||||||
if quit_requested:
|
if quit_requested:
|
||||||
return audio_paths, True
|
return audio_paths, True
|
||||||
|
|||||||
@@ -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) {
|
int main(int argc, char **argv) {
|
||||||
if (argc < 4) {
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
// WICHTIG (Fix 22.07.2026 abends): [max_seconds] ist NUR noch eine
|
// 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.
|
// nur als Notbremse, falls ein Modul (defekt/kaputt) nie ein Ende meldet.
|
||||||
double max_seconds = argc > 4 ? atof(argv[4]) : 180.0;
|
double max_seconds = argc > 4 ? atof(argv[4]) : 180.0;
|
||||||
int song_index = argc > 5 ? atoi(argv[5]) : -1; // -1 = Default-Song (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;
|
uint32_t mdat_len = 0, smpl_len = 0;
|
||||||
uint8_t *mdat = load_file(argv[1], &mdat_len);
|
uint8_t *mdat = load_file(argv[1], &mdat_len);
|
||||||
@@ -134,6 +147,19 @@ int main(int argc, char **argv) {
|
|||||||
tfmx_restart(s);
|
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);
|
int32_t max_frames = (int32_t)(max_seconds * sample_rate);
|
||||||
// Chunk klein genug waehlen, dass real_song_end nicht durch einen
|
// Chunk klein genug waehlen, dass real_song_end nicht durch einen
|
||||||
// zweiten Tick INNERHALB desselben Chunks schon wieder auf 0 zurueck-
|
// zweiten Tick INNERHALB desselben Chunks schon wieder auf 0 zurueck-
|
||||||
@@ -149,6 +175,11 @@ int main(int argc, char **argv) {
|
|||||||
|
|
||||||
int32_t total = 0;
|
int32_t total = 0;
|
||||||
int natural_end = 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) {
|
while (total < max_frames) {
|
||||||
int32_t this_chunk = CHUNK;
|
int32_t this_chunk = CHUNK;
|
||||||
if (total + this_chunk > max_frames) {
|
if (total + this_chunk > max_frames) {
|
||||||
@@ -159,23 +190,61 @@ int main(int argc, char **argv) {
|
|||||||
tfmx_get_audio(s, scratch, this_chunk);
|
tfmx_get_audio(s, scratch, this_chunk);
|
||||||
|
|
||||||
int16_t *out_ptr = pcm + (size_t)total * 2;
|
int16_t *out_ptr = pcm + (size_t)total * 2;
|
||||||
|
float peak = 0.0f;
|
||||||
for (int32_t i = 0; i < this_chunk * 2; ++i) {
|
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 > 32767.0f) v = 32767.0f;
|
||||||
if (v < -32768.0f) v = -32768.0f;
|
if (v < -32768.0f) v = -32768.0f;
|
||||||
out_ptr[i] = (int16_t)v;
|
out_ptr[i] = (int16_t)v;
|
||||||
}
|
}
|
||||||
total += this_chunk;
|
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;
|
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;
|
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);
|
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,
|
argv[3], song_index, total, sample_rate, (double)total / sample_rate,
|
||||||
|
(double)tail_frames / sample_rate,
|
||||||
natural_end ? " [natuerliches Songende/Loop-Punkt erkannt]"
|
natural_end ? " [natuerliches Songende/Loop-Punkt erkannt]"
|
||||||
: " [Sicherheits-Obergrenze erreicht, kein Songende gefunden -- Modul pruefen]");
|
: " [Sicherheits-Obergrenze erreicht, kein Songende gefunden -- Modul pruefen]");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user