Plan: Boot-/Intro-Sequenz als erster Python-Meilenstein, tfmx_player-Referenz gesichert
This commit is contained in:
@@ -718,3 +718,63 @@ werden muessen.
|
||||
Python/pygame-Projektstruktur aufsetzen — Asset-Loader (Palette-Regeln
|
||||
aus `kellogg_formats.py` direkt uebernehmen), dann Tony-Bewegung/
|
||||
Kollision/Level-Scrolling als erste spielbare Vertical Slice.
|
||||
|
||||
## ENTSCHEIDUNG 2026-07-22 abends — erster Meilenstein: Boot-/Intro-Sequenz statt Gameplay
|
||||
|
||||
Stefan-Wunsch: als leichten, sichtbaren Einstieg zuerst die komplette
|
||||
Boot-Sequenz nachbauen (Rauser-Card → Factor5-Logo → Kellogg's-Logo mit
|
||||
"Vorhang"-Aufdeck-Effekt → Titelbild → Hauptmenue mit Menuepunkten →
|
||||
Weltkarte), BEVOR Tony ueberhaupt beweglich ist. Sinnvolle Wahl: das
|
||||
zwingt genau die Kern-Engine-Bausteine, die spaeter das ganze Spiel
|
||||
braucht (Asset-Loader, Bild-Blitting mit Palette, Timing/Sequenzer,
|
||||
Sound-Playback, Input-Handling fuers Menue) — nur eben am kleinstmoeglichen
|
||||
Slice, bevor Physik/Kollision/Level-Logik dazukommen.
|
||||
|
||||
**Stand der benoetigten Assets (alle schon extrahiert, nur noch nicht
|
||||
zu einer Sequenz zusammengesetzt):**
|
||||
- Rauser-Card: `RAUSER1/2/3.PCC` (3 Scroll-/Fade-Frames, siehe oben)
|
||||
- Factor5-Logo: `FACTOR5.PCC` + `FAC0-3.PCC` (kleine Begleit-Frames,
|
||||
vermutlich Blink-/Reflex-Animation)
|
||||
- Kellogg's-Logo: `KELLOGGS.PCC` — **"Vorhang"-Effekt (Stefans Beobachtung:
|
||||
oberer Teil muesste sich von rechts nach links aufdecken) ist NICHT als
|
||||
Asset gespeichert**, sondern eine Laufzeit-Animation von RTM.EXE (reines
|
||||
Wipe/Reveal auf einem statischen Vollbild). Muss durch Beobachtung
|
||||
nachgebaut werden (Screenshot-Serie waehrend des Boot-Vorgangs in
|
||||
DOSBox, kurze Intervalle, um Richtung/Geschwindigkeit/Dauer zu
|
||||
bestimmen) — noch nicht gemacht, naechster Schritt.
|
||||
- Titelbild ("Tony & Friends in Kellogg's Land"): noch keine eigene PCC-
|
||||
Datei dafuer identifiziert unter den 77 — moeglich, dass das Kellogg's-
|
||||
Logo direkt das Titelbild IST (ein Screen), oder der Text wird von der
|
||||
Engine ueber ein weiteres, noch nicht namentlich zugeordnetes PCC
|
||||
gelegt. Zu klaeren beim naechsten DOSBox-Screenshot-Durchlauf.
|
||||
- Hauptmenue: `MENU.PCC` (Rahmen/Box vorhanden) — Menuepunkte selbst
|
||||
vermutlich als Text von der Engine gerendert (kein Font-Asset bisher
|
||||
identifiziert) oder als eigene ICO/BOB-Sprites. Noch offen.
|
||||
- Karte: `KARTE.PCC` + `KARTE0-9.PCC` (10 Zusatz-Frames, vermutlich
|
||||
kleine Animationen auf der Karte — Wasser/Flagge/Feuer o.ae., noch
|
||||
nicht einzeln zugeordnet).
|
||||
- Sound: 3x SAM + 3x TFX (TFMX-Format, Amiga/DOS-Tracker) — bisher NICHT
|
||||
decodiert. Ob Rauser/Factor5/Kellogg's-Screens ueberhaupt Sound haben,
|
||||
ist unklar (DOSBox lief bisher im ALSA-nosound-Modus, kein Ton
|
||||
gehoert) — zu klaeren durch Sichtung von `tools/tfmx_player` (C-Referenz-
|
||||
Tool, liegt schon lokal unter `tools/tfmx_player/`, noch uncommitted)
|
||||
und/oder DOSBox mit echtem Audio-Sink testen.
|
||||
|
||||
**Plan (in dieser Reihenfolge):**
|
||||
1. pygame auf `aria-wohnung` installiert (User-pip, erledigt).
|
||||
2. DOSBox-Screenshot-Serie waehrend des Bootvorgangs aufnehmen (kurze
|
||||
Intervalle ueber Rauser→Factor5→Kellogg's→Titel→Menue), um Timing +
|
||||
Wipe-Richtung/-Geschwindigkeit des Kellogg's-Vorhang-Effekts zu
|
||||
bestimmen, und um das noch unklare Titelbild/Menue-Text-Rendering zu
|
||||
identifizieren.
|
||||
3. `tools/intro_sequence.py` (neu): pygame-basierter Player, der die
|
||||
Screens in der beobachteten Reihenfolge + Timing zeigt (erst ohne
|
||||
Ton), Wipe-Effekt fuer Kellogg's-Logo nachbauen.
|
||||
4. TFMX-Sound anhaengen, sobald `tfmx_player` als Referenz ausgewertet
|
||||
ist (Python-Wrapper oder Re-Implementierung der Decodierung).
|
||||
5. Committen/pushen nach jedem funktionierenden Teilschritt (nicht erst
|
||||
am Ende), NOTES.md nach jedem Meilenstein aktualisieren — wie von
|
||||
Stefan gewuenscht, damit bei einem VM-/Brain-Neustart nichts verloren
|
||||
geht und einfach hier weitergelesen werden kann.
|
||||
|
||||
Laeuft jetzt selbstaendig weiter, keine Rueckfrage noetig.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Peter Fors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
|
||||
This project is a port of replayers from NostalgicPlayer
|
||||
(https://github.com/neumatho/NostalgicPlayer), which is distributed under the
|
||||
MIT License with the following notice:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Thomas Neumann
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
# Builds render_tfmx, a tiny standalone harness around tfmx.h (see NOTICE.md
|
||||
# in this directory for provenance/license). Produces ./render_tfmx which
|
||||
# takes <mdat.tfx> <smpl.sam> <out.wav> [seconds] and writes a stereo
|
||||
# 16-bit/44100Hz WAV rendering of the TFMX module.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
gcc -std=gnu99 -O2 -Wall -Wno-unused-function -Wno-unused-variable -Wno-unused-parameter \
|
||||
-o render_tfmx render_tfmx.c -lm
|
||||
echo "built: $(dirname "$0")/render_tfmx"
|
||||
@@ -0,0 +1,632 @@
|
||||
// Copyright (c) 2026 Peter Fors
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Amiga 500 Paula emulator for custom replayers.
|
||||
//
|
||||
// This is a hardware model, not a resampler. The channel mixer runs in the
|
||||
// Paula clock domain (3546895 Hz PAL / 3579545 Hz NTSC). Each hardware
|
||||
// channel has a period counter that, when it expires, latches the next 8-bit
|
||||
// sample byte; between latches the channel holds that byte (the zero-order-
|
||||
// hold staircase a real Paula produces). Volume is the real 6-bit PWM over a
|
||||
// 64-clock window, not a multiply, so its quantization noise is reproduced.
|
||||
// Channels 0+3 are summed to the left output, 1+2 to the right, hard-panned,
|
||||
// in the Paula clock domain. The analog filter chain (always-on RC low-pass --
|
||||
// ~4.4 kHz on A500, ~34 kHz on A1200 -- plus the switchable ~3.3 kHz LED
|
||||
// Butterworth) runs at the Paula clock rate. Only the final stage decimates
|
||||
// to the host rate, by box-filter integration of the Paula-clock samples that
|
||||
// fall in each output window. Aliasing and quantization noise that a real
|
||||
// Amiga produces are preserved.
|
||||
//
|
||||
// Paula has exactly four hardware channels (0..3). There is no software-
|
||||
// mixer extension and no side bus: a real Amiga has no extra channel in its
|
||||
// signal path. Every format with more than four voices built a mixed buffer
|
||||
// on the CPU and DMA'd THAT through these four channels, so any such mixdown
|
||||
// is the replayer's job and its output IS Paula channel sample data, <= 4
|
||||
// channels, passing through this same hardware path.
|
||||
//
|
||||
// Output is ACCUMULATED into the caller's float buffer. The hardware output
|
||||
// chain is modelled end to end, to the RCA jack, not just to the summer node:
|
||||
//
|
||||
// 1. Resistive averaging summer: the two channels on each side (0+3 left,
|
||||
// 1+2 right) join through equal board resistors, so the per-side node
|
||||
// is (ch_a + ch_b) / 2 (a ~6 dB attenuation).
|
||||
// 2. The analog filter chain acts on that node: an always-on RC low-pass
|
||||
// (~4.4 kHz on A500, ~34 kHz on A1200) plus the switchable ~3.3 kHz
|
||||
// LED Butterworth.
|
||||
// 3. Output buffer/amp: normalises int8 full scale to unity. The
|
||||
// resistive divider's ~6 dB attenuation is preserved (not compensated)
|
||||
// so the per-side level matches real hardware: a single full-scale
|
||||
// channel lands at ~0.5, two correlated full-scale channels on the
|
||||
// same side at ~1.0. No analog rail saturation is modelled -- at line
|
||||
// out on a stock A500 the output op-amp runs with ~10 V of usable
|
||||
// rail headroom against a ~1 V peak signal and never reaches its
|
||||
// rails in practice. The output is then clamped to [-1, +1] purely
|
||||
// as a digital safety guard for callers converting to fixed-point:
|
||||
// small Butterworth step overshoot on transients (a few percent)
|
||||
// cannot leak out as wrap/click noise after a (int16_t)(x * 32768)
|
||||
// style cast. Absolute level is the host's concern.
|
||||
//
|
||||
// Host integration: the filter chain (always-on RC LP + switchable LED
|
||||
// Butterworth + decimation anti-alias) runs IIR state at the Paula clock,
|
||||
// and that state decays exponentially toward zero when channels go silent.
|
||||
// Once any state slot crosses the float denormal threshold (~1.18e-38),
|
||||
// every subsequent multiply touching it is denormal-slow on x86 (roughly
|
||||
// two orders of magnitude); a single mix can blow past the host's audio
|
||||
// buffer duration -- audible as underrun. Adding per-sample denormal-
|
||||
// prevention bias inside the filter inner loops would cost a fadd per
|
||||
// stage per Paula clock (millions/sec), so the agreed convention is:
|
||||
// THE HOST AUDIO THREAD MUST RUN WITH MXCSR FTZ+DAZ ENABLED. Any thread
|
||||
// that calls paula_mix_frames is in scope. The standard recipe is
|
||||
// #include <pmmintrin.h>
|
||||
// _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);
|
||||
// _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON);
|
||||
// at the top of the audio thread proc. MXCSR is per-thread on x86 so this
|
||||
// must be set inside the thread, not once at program start.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
|
||||
// L+R packed double, used through the filter chain to halve biquad cost: all
|
||||
// three filter stages use identical coefficients per side, only state differs,
|
||||
// so each biquad line becomes one packed instruction (one packed FMA on
|
||||
// x86-64-v3). GCC/Clang vector extension; arithmetic operators are overloaded
|
||||
// to the right SIMD ops per -march.
|
||||
typedef double paula_v2df __attribute__((vector_size(16)));
|
||||
|
||||
// Opt-in mixer profiler. Compiled in only when PAULA_PROFILE is defined, so
|
||||
// normal builds carry zero footprint. Accumulates process CPU time spent
|
||||
// strictly inside paula_mix_frames (not replayer tick work) and the number of
|
||||
// frames produced; paula_profile_report() turns that into a realtime factor.
|
||||
#ifdef PAULA_PROFILE
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
static double paula_profile_cpu_ns = 0.0;
|
||||
static uint64_t paula_profile_frames = 0;
|
||||
#endif
|
||||
|
||||
// Paula has exactly four hardware channels. Formats with more voices must
|
||||
// CPU-mix down to <= 4 themselves; there is no extra channel here.
|
||||
#define PAULA_NUM_CHANNELS 4
|
||||
#define PAULA_PAL_CLOCK 3546895
|
||||
#define PAULA_NTSC_CLOCK 3579545
|
||||
// Real Paula audio DMA floor. The Hardware Reference Manual's period-124
|
||||
// figure is the rate at which all four channels can DMA without the bus
|
||||
// falling behind during display fetch; a single channel goes lower. The true
|
||||
// hardware floor is period 113 -- the ProTracker/Soundtracker note table
|
||||
// bottoms at exactly 113 (B-3) because that is where Paula stops. Clamping to
|
||||
// 124 detunes the whole top octave flat (period 113 -> ~160 cents). All four
|
||||
// hardware channels clamp to this.
|
||||
#define PAULA_DMA_MIN_PERIOD 113
|
||||
|
||||
// Period accumulator fixed-point: one Paula clock advances the accumulator by
|
||||
// PAULA_PERIOD_ONE; a channel consumes one sample byte every period_q of
|
||||
// these. period_q is integer-exact for the Paula register path and fractional
|
||||
// for the Hz path, so both keep exact pitch.
|
||||
#define PAULA_PERIOD_SHIFT 16
|
||||
#define PAULA_PERIOD_ONE (1ull << PAULA_PERIOD_SHIFT)
|
||||
|
||||
struct paula_channel {
|
||||
int8_t *sample;
|
||||
uint32_t length; // bytes (becomes loop_start+loop_length after first wrap)
|
||||
uint32_t loop_start; // bytes
|
||||
uint32_t loop_length; // bytes, 0 => one-shot
|
||||
uint32_t pos; // current byte index into sample
|
||||
uint64_t period_q; // Paula clocks per sample byte, Q16
|
||||
uint64_t period_acc; // period accumulator, Q16
|
||||
int8_t *pending_sample; // deferred switch on next wrap (Paula AUDxLC trick)
|
||||
uint32_t pending_pos;
|
||||
uint32_t pending_length;
|
||||
int8_t cur; // latched sample byte (zero-order-hold output)
|
||||
uint16_t volume; // 0..64 Amiga scale
|
||||
uint8_t pwm_cnt; // 0..63 volume-PWM phase
|
||||
uint8_t active;
|
||||
uint8_t muted;
|
||||
uint8_t has_pending;
|
||||
uint8_t backwards; // 1 -> step DOWN through sample (DBP E3, etc.)
|
||||
};
|
||||
|
||||
// Amiga model. Selects the always-on post-DAC RC low-pass corner: ~4.4 kHz on
|
||||
// A500 (the classic muffled top end), ~34 kHz on A1200 (bright but not brick-
|
||||
// walled -- the slight roll into the top octave that real hardware has,
|
||||
// neither aliasing brightness nor A500 muffling). The LED filter exists on
|
||||
// both. Default is the A500.
|
||||
#define PAULA_MODEL_A500 0
|
||||
#define PAULA_MODEL_A1200 1
|
||||
|
||||
struct paula {
|
||||
struct paula_channel ch[PAULA_NUM_CHANNELS];
|
||||
int32_t sample_rate; // host output rate
|
||||
int32_t clock; // Paula clock (PAL/NTSC), internal mix rate
|
||||
int32_t samples_per_tick;
|
||||
int32_t tick_offset;
|
||||
int32_t model;
|
||||
|
||||
// Box-filter decimation from the Paula clock domain to the host rate.
|
||||
// Each output sample averages the decim_step (Q16) Paula clocks that
|
||||
// fall in its window; decim_phase carries the fraction across calls so
|
||||
// the clock count alternates with no pitch drift.
|
||||
uint64_t decim_step;
|
||||
uint64_t decim_phase;
|
||||
|
||||
// Always-on 1-pole RC low-pass, at the Paula clock rate. Corner depends
|
||||
// on model: ~4.4 kHz for A500, ~34 kHz for A1200. State is L+R packed.
|
||||
double fixed_lp_a;
|
||||
paula_v2df fixed_lp;
|
||||
|
||||
// Switchable LED filter: 2-pole Butterworth low-pass (~3.3 kHz,
|
||||
// Q=1/sqrt(2)), at the Paula clock rate, RBJ bilinear coefficients.
|
||||
// Driven by the replayer via paula_set_lp_filter; biquad state (TDF-II,
|
||||
// L+R packed) persists across toggles so flips don't click.
|
||||
int32_t lp_filter_on;
|
||||
double led_b0;
|
||||
double led_b1;
|
||||
double led_b2;
|
||||
double led_a1;
|
||||
double led_a2;
|
||||
paula_v2df led_z1;
|
||||
paula_v2df led_z2;
|
||||
|
||||
// Decimation anti-alias low-pass: 8th-order Butterworth (4 cascaded RBJ
|
||||
// biquads) at 0.45*host_rate, run in the Paula clock domain just before the
|
||||
// rate drop. This is a resampler reconstruction filter, NOT modelled
|
||||
// hardware: it bandlimits to below the host Nyquist so the box-average
|
||||
// decimation cannot fold ultrasonic ZOH images down into the audible band.
|
||||
// Keyed to host_rate, so it runs for both models; on the A500 the analog
|
||||
// chain has already removed everything near Nyquist, making it a no-op.
|
||||
// State is L+R packed per stage.
|
||||
double aa_b0[4];
|
||||
double aa_a1[4];
|
||||
double aa_a2[4];
|
||||
paula_v2df aa_z1[4];
|
||||
paula_v2df aa_z2[4];
|
||||
};
|
||||
|
||||
// [=]===^=[ paula_recalc ]=======================================================================[=]
|
||||
// Recompute every rate-dependent coefficient from p->clock and
|
||||
// p->sample_rate. The analog filters run at the Paula clock, so their
|
||||
// coefficients are bilinear-transformed for that rate, not the host rate.
|
||||
static void paula_recalc(struct paula *p) {
|
||||
double fs = (double)p->clock;
|
||||
double dt = 1.0 / fs;
|
||||
|
||||
// Always-on RC low-pass. Corner is model-dependent: A500 ~4.4 kHz,
|
||||
// A1200 ~34 kHz. a = dt / (RC + dt).
|
||||
double lp_fc = (p->model == PAULA_MODEL_A1200) ? 34000.0 : 4400.0;
|
||||
double lp_rc = 1.0 / (2.0 * 3.14159265358979323846 * lp_fc);
|
||||
p->fixed_lp_a = dt / (lp_rc + dt);
|
||||
|
||||
// LED filter: 2-pole Butterworth low-pass, ~3.3 kHz, Q = 1/sqrt(2),
|
||||
// RBJ cookbook low-pass mapped via the bilinear transform at fs.
|
||||
double fc = 3300.0;
|
||||
double q = 0.70710678118654752440;
|
||||
double w0 = 2.0 * 3.14159265358979323846 * fc / fs;
|
||||
double cw = cos(w0);
|
||||
double sw = sin(w0);
|
||||
double alpha = sw / (2.0 * q);
|
||||
double a0 = 1.0 + alpha;
|
||||
p->led_b0 = ((1.0 - cw) * 0.5) / a0;
|
||||
p->led_b1 = (1.0 - cw) / a0;
|
||||
p->led_b2 = ((1.0 - cw) * 0.5) / a0;
|
||||
p->led_a1 = (-2.0 * cw) / a0;
|
||||
p->led_a2 = (1.0 - alpha) / a0;
|
||||
|
||||
// Decimation anti-alias: 8th-order Butterworth low-pass at 0.45*host_rate,
|
||||
// mapped via RBJ bilinear at the Paula clock. The four sections carry the
|
||||
// standard 8th-order Butterworth section Q's; cascaded DC gain is unity.
|
||||
double aa_q[4] = {0.50979558, 0.60134489, 0.89997622, 2.56291545};
|
||||
double aa_w0 = 2.0 * 3.14159265358979323846 * (0.45 * (double)p->sample_rate) / fs;
|
||||
double aa_cw = cos(aa_w0);
|
||||
double aa_sw = sin(aa_w0);
|
||||
for(uint32_t st = 0; st < 4; ++st) {
|
||||
double al = aa_sw / (2.0 * aa_q[st]);
|
||||
double a0 = 1.0 + al;
|
||||
p->aa_b0[st] = ((1.0 - aa_cw) * 0.5) / a0;
|
||||
p->aa_a1[st] = (-2.0 * aa_cw) / a0;
|
||||
p->aa_a2[st] = (1.0 - al) / a0;
|
||||
}
|
||||
|
||||
// Box-filter decimation step: Paula clocks per host output sample, Q16.
|
||||
p->decim_step = ((uint64_t)p->clock << PAULA_PERIOD_SHIFT) / (uint64_t)p->sample_rate;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_init ]=========================================================================[=]
|
||||
static void paula_init(struct paula *p, int32_t sample_rate, int32_t tick_rate_hz) {
|
||||
memset(p, 0, sizeof(*p));
|
||||
p->sample_rate = sample_rate;
|
||||
p->clock = PAULA_PAL_CLOCK;
|
||||
p->samples_per_tick = sample_rate / tick_rate_hz;
|
||||
p->model = PAULA_MODEL_A500;
|
||||
// Hard-panned: channels 0+3 -> left, 1+2 -> right (fixed Paula wiring).
|
||||
paula_recalc(p);
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_set_clock ]====================================================================[=]
|
||||
// Select the Paula clock (PAULA_PAL_CLOCK / PAULA_NTSC_CLOCK). Recomputes the
|
||||
// rate-dependent coefficients. Default after paula_init is PAL.
|
||||
static void paula_set_clock(struct paula *p, int32_t clock_hz) {
|
||||
p->clock = clock_hz > 0 ? clock_hz : PAULA_PAL_CLOCK;
|
||||
paula_recalc(p);
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_set_model ]====================================================================[=]
|
||||
// Select the emulated machine. The always-on post-DAC RC low-pass corner
|
||||
// changes with model (A500 ~4.4 kHz, A1200 ~34 kHz); the LED filter exists on
|
||||
// both. Default is the A500.
|
||||
static void paula_set_model(struct paula *p, int32_t model) {
|
||||
p->model = (model == PAULA_MODEL_A1200) ? PAULA_MODEL_A1200 : PAULA_MODEL_A500;
|
||||
paula_recalc(p);
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_set_lp_filter ]================================================================[=]
|
||||
// Enable or disable the switchable Amiga LED filter (the power-LED-gated
|
||||
// 2-pole low-pass). Replayers call this to mirror the module's own filter
|
||||
// state. The always-on RC low-pass is not affected (A500 ~4.4 kHz, A1200
|
||||
// ~34 kHz).
|
||||
static void paula_set_lp_filter(struct paula *p, int32_t on) {
|
||||
p->lp_filter_on = on ? 1 : 0;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_set_period ]===================================================================[=]
|
||||
// Amiga AUDxPER (DMA) period. All four hardware channels clamp to the real
|
||||
// Paula DMA minimum period.
|
||||
static void paula_set_period(struct paula *p, int32_t idx, uint16_t period) {
|
||||
if(period != 0 && period < PAULA_DMA_MIN_PERIOD) {
|
||||
period = PAULA_DMA_MIN_PERIOD;
|
||||
}
|
||||
if(period == 0) {
|
||||
p->ch[idx].period_q = 0;
|
||||
return;
|
||||
}
|
||||
p->ch[idx].period_q = (uint64_t)period << PAULA_PERIOD_SHIFT;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_set_freq_hz ]==================================================================[=]
|
||||
// Set a channel's DMA rate directly in Hz, for replayers that DMA a
|
||||
// CPU-built mixdown buffer through a Paula channel (DigiBoosterPro, FaceThe-
|
||||
// Music). The period is fractional in the Paula clock domain so pitch stays
|
||||
// exact. No DMA period floor: mixdown rates are well above it anyway.
|
||||
static void paula_set_freq_hz(struct paula *p, int32_t idx, uint32_t freq_hz) {
|
||||
if(freq_hz == 0) {
|
||||
p->ch[idx].period_q = 0;
|
||||
return;
|
||||
}
|
||||
p->ch[idx].period_q = ((uint64_t)p->clock << PAULA_PERIOD_SHIFT) / (uint64_t)freq_hz;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_set_volume ]===================================================================[=]
|
||||
static void paula_set_volume(struct paula *p, int32_t idx, uint16_t volume) {
|
||||
if(volume > 64) {
|
||||
volume = 64;
|
||||
}
|
||||
p->ch[idx].volume = volume;
|
||||
}
|
||||
|
||||
// Volume is passed in 0..256 range in NostalgicPlayer convention; divide to 0..64.
|
||||
// [=]===^=[ paula_set_volume_256 ]===============================================================[=]
|
||||
static void paula_set_volume_256(struct paula *p, int32_t idx, uint16_t volume) {
|
||||
if(volume > 256) {
|
||||
volume = 256;
|
||||
}
|
||||
p->ch[idx].volume = volume >> 2;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_play_sample ]==================================================================[=]
|
||||
static void paula_play_sample(struct paula *p, int32_t idx, int8_t *sample, uint32_t length) {
|
||||
struct paula_channel *c = &p->ch[idx];
|
||||
c->sample = sample;
|
||||
c->length = length;
|
||||
c->pos = (c->backwards && length > 0) ? (length - 1) : 0;
|
||||
c->loop_start = 0;
|
||||
c->loop_length = 0;
|
||||
c->has_pending = 0;
|
||||
c->pending_sample = 0;
|
||||
c->period_acc = 0;
|
||||
c->active = (sample != 0) && (length > 0);
|
||||
c->cur = c->active ? sample[c->pos] : 0;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_set_backwards ]================================================================[=]
|
||||
// Set or clear the backwards-playback flag for a channel. Takes effect on the
|
||||
// next paula_play_sample (which seeds pos at the high end) and reverses the
|
||||
// per-byte advance direction.
|
||||
static void paula_set_backwards(struct paula *p, int32_t idx, int32_t on) {
|
||||
p->ch[idx].backwards = on ? 1 : 0;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_set_pos ]======================================================================[=]
|
||||
// Move the channel's read position to `byte_offset` within the current sample
|
||||
// and re-latch the held byte. Used by effects like ProTracker 9xx (sample
|
||||
// offset). Clamps to [0, length-1].
|
||||
static void paula_set_pos(struct paula *p, int32_t idx, uint32_t byte_offset) {
|
||||
struct paula_channel *c = &p->ch[idx];
|
||||
if(c->sample == 0 || c->length == 0) {
|
||||
c->pos = 0;
|
||||
c->cur = 0;
|
||||
return;
|
||||
}
|
||||
if(byte_offset >= c->length) {
|
||||
byte_offset = c->length - 1;
|
||||
}
|
||||
c->pos = byte_offset;
|
||||
c->cur = c->sample[byte_offset];
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_queue_sample ]=================================================================[=]
|
||||
// If the channel is active, the new sample takes effect when the current one
|
||||
// reaches length (Amiga "write AUDxLC/AUDxLEN mid-DMA"). If inactive, it
|
||||
// starts immediately. Plays from sample[start_offset] for `length` bytes,
|
||||
// then wraps using the channel's current loop_start / loop_length.
|
||||
static void paula_queue_sample(struct paula *p, int32_t idx, int8_t *sample, uint32_t start_offset, uint32_t length) {
|
||||
struct paula_channel *c = &p->ch[idx];
|
||||
if(!c->active && sample != 0 && length > 0) {
|
||||
c->sample = sample;
|
||||
c->pos = start_offset;
|
||||
c->length = start_offset + length;
|
||||
c->has_pending = 0;
|
||||
c->pending_sample = 0;
|
||||
c->period_acc = 0;
|
||||
c->active = 1;
|
||||
c->cur = sample[start_offset];
|
||||
return;
|
||||
}
|
||||
c->pending_sample = sample;
|
||||
c->pending_pos = start_offset;
|
||||
c->pending_length = start_offset + length;
|
||||
c->has_pending = (sample != 0) && (length > 0);
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_set_loop ]=====================================================================[=]
|
||||
static void paula_set_loop(struct paula *p, int32_t idx, uint32_t start, uint32_t length) {
|
||||
struct paula_channel *c = &p->ch[idx];
|
||||
c->loop_start = start;
|
||||
c->loop_length = length;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_mute ]=========================================================================[=]
|
||||
static void paula_mute(struct paula *p, int32_t idx) {
|
||||
p->ch[idx].active = 0;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_ch_advance ]===================================================================[=]
|
||||
// Consume one sample byte for a channel: step the read position one byte
|
||||
// (forward or backward), apply the pending-sample swap / loop wrap / one-shot
|
||||
// stop exactly as Paula DMA does, and re-latch the held byte.
|
||||
__attribute__((always_inline))
|
||||
static inline void paula_ch_advance(struct paula_channel *c) {
|
||||
if(!c->backwards) {
|
||||
uint32_t np = c->pos + 1;
|
||||
if(np >= c->length) {
|
||||
if(c->has_pending) {
|
||||
c->sample = c->pending_sample;
|
||||
np = c->pending_pos;
|
||||
c->length = c->pending_length;
|
||||
c->has_pending = 0;
|
||||
c->pending_sample = 0;
|
||||
} else if(c->loop_length > 0) {
|
||||
uint32_t over = np - c->length;
|
||||
np = c->loop_start + (over % c->loop_length);
|
||||
c->length = c->loop_start + c->loop_length;
|
||||
} else {
|
||||
c->active = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
c->pos = np;
|
||||
} else {
|
||||
if(c->pos == 0 || (c->loop_length > 0 && c->pos <= c->loop_start)) {
|
||||
if(c->has_pending) {
|
||||
c->sample = c->pending_sample;
|
||||
c->length = c->pending_length;
|
||||
c->pos = c->pending_length - 1;
|
||||
c->has_pending = 0;
|
||||
c->pending_sample = 0;
|
||||
} else if(c->loop_length > 0) {
|
||||
c->pos = c->loop_start + c->loop_length - 1;
|
||||
} else {
|
||||
c->active = 0;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
c->pos = c->pos - 1;
|
||||
}
|
||||
}
|
||||
c->cur = c->sample[c->pos];
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_ch_sample ]====================================================================[=]
|
||||
// Consume one Paula clock for a single channel: bump the period accumulator,
|
||||
// advance the read position by as many bytes as the accumulator demands (may
|
||||
// deactivate a one-shot channel), then return the PWM-gated sample value the
|
||||
// channel contributes this clock. Returns 0.0 for a channel that is or just
|
||||
// went inactive, so the caller's accumulator can stay branch-free.
|
||||
// always_inline: called per Paula clock per active channel (~14M/s of audio),
|
||||
// and the inliner's -O2 size budget refuses on its own.
|
||||
__attribute__((always_inline))
|
||||
static inline double paula_ch_sample(struct paula_channel *c) {
|
||||
if(!c->active) {
|
||||
return 0.0;
|
||||
}
|
||||
c->period_acc += PAULA_PERIOD_ONE;
|
||||
while(c->period_acc >= c->period_q) {
|
||||
c->period_acc -= c->period_q;
|
||||
paula_ch_advance(c);
|
||||
if(!c->active) {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
c->pwm_cnt = (uint8_t)((c->pwm_cnt + 1) & 63);
|
||||
int32_t v = (c->pwm_cnt < c->volume) ? (int32_t)c->cur : 0;
|
||||
return (double)v;
|
||||
}
|
||||
|
||||
// [=]===^=[ paula_mix_frames ]===================================================================[=]
|
||||
// Accumulates `frames` float stereo frames into `output`. Caller must
|
||||
// pre-clear. The inner loop runs at the Paula clock; each output frame is the
|
||||
// box-filter average of the Paula-clock samples in its window.
|
||||
//
|
||||
// L+R run packed as paula_v2df through the analog/AA chain: all three filter
|
||||
// stages share coefficients across sides, only state differs, so each biquad
|
||||
// line is one packed instruction (one packed FMA on x86-64-v3). The output
|
||||
// safety clamp at the box-average store is also packed (one minpd, one
|
||||
// maxpd).
|
||||
static void paula_mix_frames(struct paula *p, float *output, int32_t frames) {
|
||||
#ifdef PAULA_PROFILE
|
||||
struct timespec prof_t0;
|
||||
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &prof_t0);
|
||||
#endif
|
||||
int32_t led = p->lp_filter_on;
|
||||
paula_v2df fa = {p->fixed_lp_a, p->fixed_lp_a};
|
||||
paula_v2df fl = p->fixed_lp;
|
||||
paula_v2df lb0 = {p->led_b0, p->led_b0};
|
||||
paula_v2df lb1 = {p->led_b1, p->led_b1};
|
||||
paula_v2df lb2 = {p->led_b2, p->led_b2};
|
||||
paula_v2df la1 = {p->led_a1, p->led_a1};
|
||||
paula_v2df la2 = {p->led_a2, p->led_a2};
|
||||
paula_v2df lz1 = p->led_z1;
|
||||
paula_v2df lz2 = p->led_z2;
|
||||
paula_v2df ab0[4];
|
||||
paula_v2df aa1[4];
|
||||
paula_v2df aa2[4];
|
||||
paula_v2df az1[4];
|
||||
paula_v2df az2[4];
|
||||
for(uint32_t st = 0; st < 4; ++st) {
|
||||
ab0[st] = (paula_v2df){p->aa_b0[st], p->aa_b0[st]};
|
||||
aa1[st] = (paula_v2df){p->aa_a1[st], p->aa_a1[st]};
|
||||
aa2[st] = (paula_v2df){p->aa_a2[st], p->aa_a2[st]};
|
||||
az1[st] = p->aa_z1[st];
|
||||
az2[st] = p->aa_z2[st];
|
||||
}
|
||||
paula_v2df two = {2.0, 2.0};
|
||||
paula_v2df half = {0.5, 0.5};
|
||||
// amp_gain normalises int8 full scale (128) to 1.0 and deliberately does
|
||||
// NOT make up the resistive divider's 6 dB attenuation -- the per-side
|
||||
// level then matches real hardware (single full-scale channel at ~0.5,
|
||||
// two correlated full-scale channels on a side at ~1.0). Output is the
|
||||
// box-filter average over the window (/n), clamped to [-1, +1] at the
|
||||
// float store as a digital safety guard for fixed-point conversion.
|
||||
paula_v2df amp = {1.0 / 128.0, 1.0 / 128.0};
|
||||
uint64_t phase = p->decim_phase;
|
||||
uint64_t dstep = p->decim_step;
|
||||
|
||||
// Active-channel working set, split by side. The selection predicate
|
||||
// (active / unmuted / has sample / nonzero period) is stable within a
|
||||
// mix call: only `active` can drop when a one-shot sample ends mid-call,
|
||||
// which paula_ch_sample handles per channel. Splitting by side kills the
|
||||
// per-Paula-clock "ci == 0 || ci == 3" branch -- each per-side scalar
|
||||
// accumulator now stays in a register through its sweep. Output is bit-
|
||||
// identical because pl and pr are separate accumulators with fixed
|
||||
// channel assignments (0+3 -> pl, 1+2 -> pr): the per-side sum only
|
||||
// depends on which channels are active, not on iteration order.
|
||||
struct paula_channel *hw_l[PAULA_NUM_CHANNELS];
|
||||
struct paula_channel *hw_r[PAULA_NUM_CHANNELS];
|
||||
uint32_t nl = 0;
|
||||
uint32_t nr = 0;
|
||||
for(int32_t ci = 0; ci < PAULA_NUM_CHANNELS; ++ci) {
|
||||
struct paula_channel *c = &p->ch[ci];
|
||||
if(!c->active || c->muted || c->sample == 0 || c->period_q == 0) {
|
||||
continue;
|
||||
}
|
||||
if(ci == 0 || ci == 3) {
|
||||
hw_l[nl++] = c;
|
||||
} else {
|
||||
hw_r[nr++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
for(int32_t i = 0; i < frames; ++i) {
|
||||
phase += dstep;
|
||||
uint32_t n = (uint32_t)(phase >> PAULA_PERIOD_SHIFT);
|
||||
phase &= (PAULA_PERIOD_ONE - 1);
|
||||
if(n == 0) {
|
||||
n = 1;
|
||||
}
|
||||
paula_v2df s = {0.0, 0.0};
|
||||
for(uint32_t k = 0; k < n; ++k) {
|
||||
double pl = 0.0;
|
||||
double pr = 0.0;
|
||||
for(uint32_t j = 0; j < nl; ++j) {
|
||||
pl += paula_ch_sample(hw_l[j]);
|
||||
}
|
||||
for(uint32_t j = 0; j < nr; ++j) {
|
||||
pr += paula_ch_sample(hw_r[j]);
|
||||
}
|
||||
// Passive resistive averaging summer: the per-side filter node
|
||||
// is (ch_a + ch_b) / 2, so it cannot exceed a single channel's
|
||||
// full scale and the hardware path never clips.
|
||||
paula_v2df x = (paula_v2df){pl, pr} * half;
|
||||
// Always-on RC pole (model-dependent corner baked into fa).
|
||||
fl = fl + (x - fl) * fa;
|
||||
x = fl;
|
||||
if(led) {
|
||||
paula_v2df y = lb0 * x + lz1;
|
||||
lz1 = lb1 * x - la1 * y + lz2;
|
||||
lz2 = lb2 * x - la2 * y;
|
||||
x = y;
|
||||
}
|
||||
// Downstream output buffer/amp: int8 -> unity normalisation.
|
||||
// No rail saturation is modelled (see header for rationale).
|
||||
x = x * amp;
|
||||
// Anti-alias before the rate drop: 4 cascaded Butterworth biquads
|
||||
// (TDF-II), L+R packed. Bandlimits below host Nyquist so the box-
|
||||
// average decimation below cannot fold ultrasonic images down.
|
||||
// RBJ low-pass identities baked in here: b1 = 2*b0 and b2 = b0,
|
||||
// so only ab0[] is stored. Do not reuse this loop for a non-LP
|
||||
// section -- it will silently produce wrong output.
|
||||
for(uint32_t st = 0; st < 4; ++st) {
|
||||
paula_v2df y = ab0[st] * x + az1[st];
|
||||
az1[st] = two * ab0[st] * x - aa1[st] * y + az2[st];
|
||||
az2[st] = ab0[st] * x - aa2[st] * y;
|
||||
x = y;
|
||||
}
|
||||
s = s + x;
|
||||
}
|
||||
// Box-filter average over the window, then clamp to [-1, +1] as a
|
||||
// digital safety guard so callers casting to fixed-point cannot get
|
||||
// wrap/click from small Butterworth transient overshoot. Branchless
|
||||
// packed -- one minpd, one maxpd via the GCC vector built-ins.
|
||||
double inv = 1.0 / (double)n;
|
||||
paula_v2df out = s * (paula_v2df){inv, inv};
|
||||
paula_v2df hi = {1.0, 1.0};
|
||||
paula_v2df lo = {-1.0, -1.0};
|
||||
out = __builtin_ia32_minpd(out, hi);
|
||||
out = __builtin_ia32_maxpd(out, lo);
|
||||
output[2 * i] += (float)out[0];
|
||||
output[2 * i + 1] += (float)out[1];
|
||||
}
|
||||
|
||||
p->fixed_lp = fl;
|
||||
p->led_z1 = lz1;
|
||||
p->led_z2 = lz2;
|
||||
for(uint32_t st = 0; st < 4; ++st) {
|
||||
p->aa_z1[st] = az1[st];
|
||||
p->aa_z2[st] = az2[st];
|
||||
}
|
||||
p->decim_phase = phase;
|
||||
|
||||
#ifdef PAULA_PROFILE
|
||||
struct timespec prof_t1;
|
||||
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &prof_t1);
|
||||
paula_profile_cpu_ns += (double)(prof_t1.tv_sec - prof_t0.tv_sec) * 1.0e9 + (double)(prof_t1.tv_nsec - prof_t0.tv_nsec);
|
||||
paula_profile_frames += (uint64_t)frames;
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PAULA_PROFILE
|
||||
// [=]===^=[ paula_profile_report ]===============================================================[=]
|
||||
// Print the accumulated mixer cost as a realtime factor. Call once at exit.
|
||||
static void paula_profile_report(int32_t sample_rate) {
|
||||
if(paula_profile_frames == 0) {
|
||||
fprintf(stderr, "paula_mix_frames: never called (this player has its own mixer, not paula.h)\n");
|
||||
return;
|
||||
}
|
||||
double cpu_s = paula_profile_cpu_ns * 1.0e-9;
|
||||
double audio_s = (sample_rate > 0) ? (double)paula_profile_frames / (double)sample_rate : 0.0;
|
||||
double rt = (cpu_s > 0.0) ? audio_s / cpu_s : 0.0;
|
||||
double core_pct = (audio_s > 0.0) ? 100.0 * cpu_s / audio_s : 0.0;
|
||||
fprintf(stderr, "paula_mix_frames: %.3fs CPU for %.1fs audio -> %.1fx realtime (%.2f%% of one core)\n",
|
||||
cpu_s, audio_s, rt, core_pct);
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Peter Fors
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Common interface every ported replayer exports. Each player's header declares
|
||||
// a `struct player_api <name>_api` global that the test player (or any host)
|
||||
// can iterate to auto-detect the right replayer for a file.
|
||||
//
|
||||
// Audio output is interleaved float stereo, nominal range [-1.0, 1.0]. Players
|
||||
// ACCUMULATE into the caller's buffer (caller must pre-clear). They do NOT
|
||||
// clip; the host is responsible for any final saturation, dithering, or
|
||||
// conversion to the audio backend's native sample format.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Optional file-loader callback used by players that read companion files
|
||||
// (IFF SMUS instrument files, Face The Music external samples, etc.). Hosts
|
||||
// that pass a loader implement `fetch` to resolve a logical name (e.g.
|
||||
// "Instruments/Bass6.instr") to a heap-allocated byte buffer + length. The
|
||||
// player calls free() on the returned pointer when done.
|
||||
struct player_loader {
|
||||
void *ctx;
|
||||
uint8_t *(*fetch)(void *ctx, const char *name, uint32_t *out_len);
|
||||
};
|
||||
|
||||
struct player_api {
|
||||
const char *name;
|
||||
const char **extensions; /* null-terminated list of lowercase extensions, no dot */
|
||||
void *(*init)(void *data, uint32_t len, int32_t sample_rate);
|
||||
void (*free)(void *state);
|
||||
void (*get_audio)(void *state, float *output, int32_t frames);
|
||||
// Optional: when non-null and the host has a loader for sibling files,
|
||||
// the host should prefer this entry point. Players that don't need
|
||||
// external files leave this null and the host falls back to init().
|
||||
void *(*init_ex)(void *data, uint32_t len, int32_t sample_rate, struct player_loader *loader);
|
||||
};
|
||||
|
||||
// [=]===^=[ player_get_audio_s16 ]===============================================================[=]
|
||||
// Convenience wrapper for hosts that want signed-16 PCM out. Drives the
|
||||
// player's float get_audio into the caller-supplied scratch buffer (must hold
|
||||
// at least frames * 2 floats), clears it first, then converts with hard
|
||||
// saturation into `output` (frames * 2 int16 stereo samples). The scratch is
|
||||
// caller-owned so the hot path never allocates; reuse the same buffer across
|
||||
// calls.
|
||||
#include <string.h>
|
||||
static void player_get_audio_s16(struct player_api *api, void *state, int16_t *output, float *scratch, int32_t frames) {
|
||||
int32_t samples = frames * 2;
|
||||
memset(scratch, 0, (size_t)samples * sizeof(float));
|
||||
api->get_audio(state, scratch, frames);
|
||||
for(int32_t i = 0; i < samples; ++i) {
|
||||
float v = scratch[i] * 32767.0f;
|
||||
if(v > 32767.0f) {
|
||||
v = 32767.0f;
|
||||
}
|
||||
if(v < -32768.0f) {
|
||||
v = -32768.0f;
|
||||
}
|
||||
output[i] = (int16_t)v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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.
|
||||
#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> [seconds]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
double seconds = argc > 4 ? atof(argv[4]) : 15.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;
|
||||
void *s = tfmx_api.init(bundle, bundle_len, sample_rate);
|
||||
if (!s) {
|
||||
fprintf(stderr, "tfmx_api.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);
|
||||
|
||||
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);
|
||||
|
||||
tfmx_api.free(s);
|
||||
return 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user