Plan: Boot-/Intro-Sequenz als erster Python-Meilenstein, tfmx_player-Referenz gesichert

This commit is contained in:
ARIA
2026-07-22 15:36:09 +00:00
parent 3410a53ee7
commit 3622bd0a1a
7 changed files with 3657 additions and 0 deletions
+49
View File
@@ -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.
+10
View File
@@ -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"
+632
View File
@@ -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
+61
View File
@@ -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;
}
}
+107
View File
@@ -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