joeylib2/src/amiga/audio.c
2026-09-24 22:01:56 -05:00

601 lines
20 KiB
C

// Amiga audio HAL: PTPlayer (Frank Wille, public domain) drives Paula
// for both module playback and one-shot SFX overlay.
//
// We build PTPlayer in OSCOMPAT mode so it cooperates with audio.device
// rather than taking Paula away from the OS -- matches our cooperative-
// with-Intuition graphics HAL. mt_install allocates all four Paula
// channels via audio.device; mt_init loads a .MOD; mt_Enable=1 starts
// playback; mt_playfx triggers a SFX on a free channel (or the worst
// channel if all are busy) with priority arbitration.
//
// PTPlayer expects modules and SFX samples to live in Chip RAM. The
// asset pipeline does not yet partition into Chip vs. Fast, so we
// AllocMem(MEMF_CHIP) a copy at PlayMod / PlaySfx time and free it at
// StopMod / StopSfx (or shutdown).
#include <string.h>
#include <exec/types.h>
#include <exec/memory.h>
#include <hardware/custom.h>
#include <hardware/dmabits.h>
#include <proto/exec.h>
#include "ptplayer.h"
#include "port.h"
#include "joey/audio.h"
extern struct Custom custom;
extern UBYTE mt_Enable;
extern UBYTE mt_E8Trigger;
// ----- Constants -----
// Paula's NTSC PAL period for middle-C-ish playback. PTPlayer's SFX
// API takes a hardware period (cycles per sample) rather than a
// frequency. To convert from a sample's authored Hz: period = clock /
// rate, where clock is ~3546895 (PAL) or ~3579545 (NTSC). We default
// PAL since that's what our screen mode requests, and clamp to legal
// Paula period limits.
#define PAULA_CLOCK_PAL 3546895UL
#define PAULA_PERIOD_MIN 124 // Paula HW limit
#define PAULA_PERIOD_MAX 65535
#define PAULA_CHANNELS 4 // hardware audio channel count
#define SFX_VOLUME_MAX 64
// PSG-style tone voices: Paula loops a 2-byte full-swing square per
// channel (DMA auto-reloads the location/length latches at length
// exhaustion, so a started voice sustains with zero CPU -- the sticky
// contract falls straight out of the hardware). Pitch is the period
// register: toneHz = PAULA_CLOCK_PAL / (period * 2). Loudness is the
// channel's 0..64 volume. The square must live in chip RAM; it is
// AllocMem'd on first use because jlAudioVoice is callable without
// jlAudioInit (jlpAudioShutdown frees it independently of gInstalled).
#define VOICE_SQUARE_BYTES 2
#define VOICE_VOLUME_MAX 64
#define VOICE_ATTEN_OFF 15
// Noise voice: Paula has no noise generator, so channel 3 loops a
// chip-RAM sample of 1-bit LFSR noise (full-swing +127/-128 per LFSR
// bit -- the YM2149 timbre, so the portable pitch semantic carries
// over: spectral edge = sample rate). 1024 bytes puts the worst-case
// loop-repeat rate (~28 Hz at pitch 0) below tonal salience; 512
// would buzz at ~56 Hz. Generated once with a fixed seed so output
// is deterministic.
#define NOISE_BYTES 1024
#define NOISE_CHANNEL 3
#define NOISE_PITCH_MAX 31
#define NOISE_LFSR_SEED 0xACE1u
#define NOISE_LFSR_TAPS 0xB400u // 16-bit maximal Galois LFSR
// PTPlayer has no built-in "play once" mode -- songs loop forever
// unless the author placed an `E8FF` effect at song end. mt_E8Trigger
// reflects the most-recently-seen E8 value; mt_init resets it to 0.
// When loop=false on PlayMod, we poll for this sentinel in
// jlpAudioFrameTick and clear mt_Enable when seen.
#define PTPLAYER_LOOP_END_MARKER 0xFF
// ----- Module state -----
// Streaming SFX chunk size, in bytes. PTPlayer's mt_playfx plays a
// one-shot sample and does not queue, so a stream is played one chunk at
// a time with the next one fired from jlpAudioFrameTick when the current
// is due to end. Bigger chunks mean fewer seams; 4 KB is ~0.8 s at 5 kHz,
// which covers a whole speech sample with no seam at all.
#define SFX_STREAM_CHUNK 4096u
// The host calls jlpAudioFrameTick once per display frame, and this port
// is PAL throughout (see PAULA_CLOCK_PAL in periodForRate), so chunk
// lifetimes are counted in 50 Hz frames.
#define AMIGA_FRAME_HZ 50u
typedef struct {
UBYTE *samples; // Chip-RAM copy of one SFX sample
UWORD lengthWords; // sample length in 16-bit words
// ---- streaming (jlpAudioPlaySfxStream) ----
UBYTE *chunk; // Chip-RAM chunk buffer, SFX_STREAM_CHUNK
BYTE cha; // Paula channel, -1 = let PTPlayer pick
jlAudioStreamFillT fill;
void *ctx;
uint16_t rateHz;
uint16_t framesLeft; // until the playing chunk runs out
bool active;
} SfxSlotT;
static UBYTE *gModuleChip = NULL; // Chip-RAM copy of the playing module
static ULONG gModuleLength = 0; // bytes to FreeMem on swap / shutdown
static SfxSlotT gSfxSlots[JOEY_AUDIO_SFX_SLOTS];
static bool gInstalled = false;
static bool gModPlaying = false;
static bool gWantLoopOnce = false; // honor loop=false via E8 marker
static BYTE *gVoiceChip = NULL; // chip-RAM square shared by all voices
static BYTE *gNoiseChip = NULL; // chip-RAM LFSR noise for channel 3
// pitch 0..31 -> Paula period, geometric 124..1773 (~9%/step, about
// 1.5 semitones). Effective sample rate = 3546895/period: pitch 0 =
// ~28.6 kHz bright hiss, pitch 31 = ~2.0 kHz sample-and-hold rumble.
// Every entry is inside Paula's legal 124..65535 range by
// construction. Derivation: round(124 * (1773/124)^(i/31)).
// Paula periods for noise pitch 0..31, chosen so the sample plays at the
// SAME rate the other ports give that pitch: rate = 26000 - pitch * 750,
// period = 3546895 / rate (PAL colour clock). That is the IIgs curve
// (NOISE_RATE_MAX / NOISE_RATE_STEP in src/iigs/audio_full.c), and it
// also lands within about a tenth of the ST's YM2149 at the top of the
// range, so all three agree on what a given pitch sounds like.
//
// The old table was geometric, an even ratio per step from 124 to 1773.
// It matched at pitch 0 and drifted ever darker above that: at the
// pitches Space Taxi's level-intro tune actually uses -- 28, 29 and 30,
// its only noise notes -- it ran 2587/2376/2180 Hz against the IIgs's
// 5000/4250/3500 and the ST's 4464/4310/4167. An octave too dark, which
// is why that one screen's music sounded wrong on the Amiga while every
// other song, none of which touches the noise voice, sounded right.
// Smallest period here is 136, comfortably above Paula's 124 floor.
static const UWORD kNoisePeriod[NOISE_PITCH_MAX + 1] = {
136, 140, 145, 149, 154, 159, 165, 171,
177, 184, 192, 200, 209, 218, 229, 240,
253, 268, 284, 302, 322, 346, 373, 405,
443, 489, 546, 617, 709, 835, 1013, 1290
};
// ----- Internal helpers -----
// Keep PTPlayer's automatic SFX channel selection (sfx_cha = -1) off
// the noise channel. mt_end clears the per-channel music-only flags
// (its resetch does so on every channel), and mt_end runs in the
// PlayMod/StopMod/Shutdown paths -- so the mask must be re-applied
// after every mt_end, not set once at install.
static void maskNoiseChannel(void) {
if (gInstalled) {
mt_musicmask((void *)&custom, (UBYTE)(1u << NOISE_CHANNEL));
}
}
static UWORD periodForRate(uint16_t rateHz) {
unsigned long period;
if (rateHz == 0) {
return 428; // ~middle C, sane default
}
period = PAULA_CLOCK_PAL / (unsigned long)rateHz;
if (period < PAULA_PERIOD_MIN) {
period = PAULA_PERIOD_MIN;
}
if (period > PAULA_PERIOD_MAX) {
period = PAULA_PERIOD_MAX;
}
return (UWORD)period;
}
// ----- HAL API (alphabetical) -----
bool jlpAudioInit(void) {
int i;
if (gInstalled) {
return true;
}
if (mt_install() == 0) {
return false;
}
mt_Enable = 0;
gInstalled = true;
maskNoiseChannel();
gModPlaying = false;
gWantLoopOnce = false;
gModuleChip = NULL;
gModuleLength = 0;
for (i = 0; i < JOEY_AUDIO_SFX_SLOTS; i++) {
gSfxSlots[i].samples = NULL;
gSfxSlots[i].lengthWords = 0;
// -1, not 0: zero is Paula channel 0, which would silently pin every
// slot hard left instead of letting PTPlayer choose.
gSfxSlots[i].cha = -1;
}
return true;
}
void jlpAudioShutdown(void) {
int i;
// Voice-layer teardown first, independent of gInstalled: the
// tone square and noise sample are allocated on first use, which
// does not require jlpAudioInit. DMA must stop before the frees
// or Paula keeps looping freed chip RAM.
custom.dmacon = DMAF_AUD0 | DMAF_AUD1 | DMAF_AUD2 | DMAF_AUD3;
for (i = 0; i < PAULA_CHANNELS; i++) {
custom.aud[i].ac_vol = 0;
}
if (gVoiceChip != NULL) {
FreeMem(gVoiceChip, VOICE_SQUARE_BYTES);
gVoiceChip = NULL;
}
if (gNoiseChip != NULL) {
FreeMem(gNoiseChip, NOISE_BYTES);
gNoiseChip = NULL;
}
if (!gInstalled) {
return;
}
mt_Enable = 0;
mt_end((void *)&custom);
mt_remove();
gInstalled = false;
gModPlaying = false;
if (gModuleChip != NULL) {
FreeMem(gModuleChip, gModuleLength);
gModuleChip = NULL;
gModuleLength = 0;
}
for (i = 0; i < JOEY_AUDIO_SFX_SLOTS; i++) {
if (gSfxSlots[i].samples != NULL) {
FreeMem(gSfxSlots[i].samples, (ULONG)gSfxSlots[i].lengthWords * 2UL);
gSfxSlots[i].samples = NULL;
gSfxSlots[i].lengthWords = 0;
}
gSfxSlots[i].active = false;
if (gSfxSlots[i].chunk != NULL) {
FreeMem(gSfxSlots[i].chunk, (ULONG)SFX_STREAM_CHUNK);
gSfxSlots[i].chunk = NULL;
}
}
}
bool jlpAudioIsPlayingMod(void) {
return gModPlaying && mt_Enable != 0;
}
void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) {
UBYTE *chip;
if (!gInstalled) {
return;
}
mt_Enable = 0;
mt_end((void *)&custom);
gModPlaying = false;
gWantLoopOnce = false;
if (gModuleChip != NULL) {
FreeMem(gModuleChip, gModuleLength);
gModuleChip = NULL;
gModuleLength = 0;
}
chip = (UBYTE *)AllocMem((ULONG)length, MEMF_CHIP);
if (chip == NULL) {
return;
}
memcpy(chip, data, length);
gModuleChip = chip;
gModuleLength = (ULONG)length;
// PTPlayer takes the module pointer in a0, sample-data pointer in
// a1 (NULL = samples follow the module header), song position in
// d0. mt_init resets mt_E8Trigger to 0 so subsequent jlpAudioFrame
// Tick polling sees a clean state.
mt_init((void *)&custom, chip, NULL, 0);
maskNoiseChannel(); // mt_end above cleared the mask
mt_Enable = 1;
gWantLoopOnce = !loop;
gModPlaying = true;
}
// Pull one chunk from a streaming slot and hand it to PTPlayer.
// Returns false when the stream has ended (fill returned 0).
static bool streamChunk(uint8_t slot) {
SfxSlotT *s = &gSfxSlots[slot];
SfxStructure sfx;
uint32_t got;
// The fill contract is SIGNED 8-bit, which is exactly what Paula
// wants, so the callback writes straight into Chip RAM.
got = s->fill(s->ctx, (int8_t *)s->chunk, (uint32_t)SFX_STREAM_CHUNK);
if (got < 2u || got > (uint32_t)SFX_STREAM_CHUNK) {
return false;
}
got &= ~1u; // Paula counts 16-bit words
sfx.sfx_ptr = s->chunk;
sfx.sfx_len = (UWORD)(got >> 1);
sfx.sfx_per = periodForRate(s->rateHz);
sfx.sfx_vol = SFX_VOLUME_MAX;
sfx.sfx_cha = s->cha;
sfx.sfx_pri = (BYTE)(slot + 1);
mt_playfx((void *)&custom, &sfx);
// Fire the next chunk a frame early rather than leaving a gap.
{
uint32_t frames = (got * (uint32_t)AMIGA_FRAME_HZ) / (uint32_t)s->rateHz;
if (frames > 1u) {
frames--;
}
s->framesLeft = (uint16_t)frames;
}
return true;
}
void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) {
SfxStructure sfx;
SfxSlotT *s;
UBYTE *chip;
UWORD words;
if (!gInstalled || slot >= JOEY_AUDIO_SFX_SLOTS) {
return;
}
if (length < 2 || length > 0x1FFFEUL) {
return; // Paula sample length is 16 bits of words = 128KB max
}
s = &gSfxSlots[slot];
if (s->samples != NULL) {
FreeMem(s->samples, (ULONG)s->lengthWords * 2UL);
s->samples = NULL;
s->lengthWords = 0;
}
chip = (UBYTE *)AllocMem((ULONG)length, MEMF_CHIP);
if (chip == NULL) {
return;
}
memcpy(chip, sample, length);
words = (UWORD)(length >> 1);
s->samples = chip;
s->lengthWords = words;
sfx.sfx_ptr = chip;
sfx.sfx_len = words;
sfx.sfx_per = periodForRate(rateHz);
sfx.sfx_vol = SFX_VOLUME_MAX;
sfx.sfx_cha = s->cha; // -1 = best free channel
sfx.sfx_pri = (BYTE)(slot + 1); // priority 1..N from slot index
mt_playfx((void *)&custom, &sfx);
}
// Streaming SFX. PTPlayer's mt_playfx wants a complete sample at fire
// time, so the stream is chunked rather than refilled mid-playback:
// arm the slot, push the first chunk now, and let jlpAudioFrameTick
// push each following one as the previous is due to run out. The seam
// therefore quantises to a 50 Hz frame, which is the jitter the public
// contract documents. Each slot gets one SFX_STREAM_CHUNK Chip-RAM
// buffer, allocated on first use and freed in jlpAudioShutdown.
void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz) {
SfxSlotT *s;
if (!gInstalled || slot >= JOEY_AUDIO_SFX_SLOTS) {
return;
}
if (fill == (jlAudioStreamFillT)0 || rateHz == 0) {
return;
}
s = &gSfxSlots[slot];
if (s->chunk == NULL) {
s->chunk = (UBYTE *)AllocMem((ULONG)SFX_STREAM_CHUNK, MEMF_CHIP);
if (s->chunk == NULL) {
return;
}
}
s->fill = fill;
s->ctx = ctx;
s->rateHz = rateHz;
s->active = true;
if (!streamChunk(slot)) {
s->active = false;
}
}
// Noise voice on Paula channel 3: a looped chip-RAM LFSR sample.
// DMA auto-reload sustains it with zero CPU, exactly like the tone
// squares; while a MOD plays, PTPlayer's interrupt owns the channel
// and noise writes lose -- the same exclusivity as the tone voices.
void jlpAudioNoise(uint8_t pitch, uint8_t atten) {
UWORD vol;
if (atten >= VOICE_ATTEN_OFF) {
custom.dmacon = DMAF_AUD3;
custom.aud[NOISE_CHANNEL].ac_vol = 0;
return;
}
if (gNoiseChip == NULL) {
uint16_t lfsr;
int i;
gNoiseChip = (BYTE *)AllocMem(NOISE_BYTES, MEMF_CHIP);
if (gNoiseChip == NULL) {
return;
}
lfsr = NOISE_LFSR_SEED;
for (i = 0; i < NOISE_BYTES; i++) {
gNoiseChip[i] = (BYTE)((lfsr & 1u) ? 127 : -128);
lfsr = (uint16_t)((lfsr >> 1) ^ ((lfsr & 1u) ? NOISE_LFSR_TAPS : 0u));
}
}
if (pitch > NOISE_PITCH_MAX) {
pitch = NOISE_PITCH_MAX;
}
vol = (UWORD)(((UWORD)(VOICE_ATTEN_OFF - atten) * VOICE_VOLUME_MAX) / VOICE_ATTEN_OFF);
// ptr/len writes latch at the next loop reload; per/vol take
// effect immediately -- a live retune is click-free and the
// redundant DMA set is harmless.
custom.aud[NOISE_CHANNEL].ac_ptr = (UWORD *)gNoiseChip;
custom.aud[NOISE_CHANNEL].ac_len = NOISE_BYTES / 2; // Paula length is in words
custom.aud[NOISE_CHANNEL].ac_per = kNoisePeriod[pitch];
custom.aud[NOISE_CHANNEL].ac_vol = vol;
custom.dmacon = (UWORD)(DMAF_SETCLR | DMAF_MASTER | DMAF_AUD3);
}
void jlpAudioTone(uint16_t freqHz) {
jlpAudioVoice(0u, freqHz, 0u); // single full-volume tone on voice 0
}
// 3-voice PSG-style tone on Paula channels 0-2, programmed directly
// (no audio.device AllocAudio -- consistent with the ST's direct YM
// pokes; a JoeyLib game owns the machine while it runs). Caveats:
// while a MOD is playing, PTPlayer rewrites all four channels' regs
// from its CIA interrupt and voice writes lose -- same voices-vs-MOD
// exclusivity as the ST's YM. mt_playfx SFX can also land on a busy
// voice channel (it picks any free-looking channel); prefer the
// tracker/voice layer OR the MOD/SFX layer per scene, not both.
void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) {
ULONG period;
UWORD vol;
if (voice >= JOEY_AUDIO_VOICES) {
return;
}
if (freqHz == 0u || atten >= VOICE_ATTEN_OFF) {
// Silence: stop the channel's DMA and zero its volume. The
// dmacon write without DMAF_SETCLR clears the named bit only.
custom.dmacon = (UWORD)(DMAF_AUD0 << voice);
custom.aud[voice].ac_vol = 0;
return;
}
if (gVoiceChip == NULL) {
gVoiceChip = (BYTE *)AllocMem(VOICE_SQUARE_BYTES, MEMF_CHIP);
if (gVoiceChip == NULL) {
return;
}
gVoiceChip[0] = 127;
gVoiceChip[1] = -128;
}
period = PAULA_CLOCK_PAL / ((ULONG)freqHz * 2UL);
if (period < PAULA_PERIOD_MIN) {
period = PAULA_PERIOD_MIN;
}
if (period > PAULA_PERIOD_MAX) {
period = PAULA_PERIOD_MAX;
}
// AGI atten 0..14 (0 = loud) -> Paula volume 64..4, linear.
vol = (UWORD)(((UWORD)(VOICE_ATTEN_OFF - atten) * VOICE_VOLUME_MAX) / VOICE_ATTEN_OFF);
custom.aud[voice].ac_ptr = (UWORD *)gVoiceChip;
custom.aud[voice].ac_len = VOICE_SQUARE_BYTES / 2; // Paula length is in words
custom.aud[voice].ac_per = (UWORD)period;
custom.aud[voice].ac_vol = vol;
custom.dmacon = (UWORD)(DMAF_SETCLR | DMAF_MASTER | (DMAF_AUD0 << voice));
}
bool jlpAudioTickRegister(jlAudioTickFnT fn, uint16_t hz) {
// CIA-B Timer B at hz could drive this; not wired until the
// Paula voice path lands.
(void)fn;
(void)hz;
return false;
}
void jlpAudioCriticalEnter(void) {
}
void jlpAudioCriticalExit(void) {
}
void jlpAudioStopMod(void) {
if (!gInstalled) {
return;
}
mt_Enable = 0;
mt_end((void *)&custom);
maskNoiseChannel(); // mt_end cleared the mask
gModPlaying = false;
gWantLoopOnce = false;
}
// Paula pans per CHANNEL: 0 and 3 feed the left output, 1 and 2 the right.
// One voice cannot reach both ears, so AUDIO_PAN_CENTER keeps PTPlayer's own
// "best free channel" pick (sfx_cha = -1) -- which is what every slot did
// before this call existed, so the default behaviour is unchanged. A panned
// slot pins its SFX to a channel on that side instead.
void jlpAudioSetPan(uint8_t slot, uint8_t pan) {
if (slot >= JOEY_AUDIO_SFX_SLOTS) {
return;
}
switch (pan) {
case AUDIO_PAN_LEFT:
gSfxSlots[slot].cha = 3;
break;
case AUDIO_PAN_RIGHT:
gSfxSlots[slot].cha = 2;
break;
default:
gSfxSlots[slot].cha = -1; // PTPlayer picks
break;
}
}
void jlpAudioStopSfx(uint8_t slot) {
UBYTE i;
if (!gInstalled || slot >= JOEY_AUDIO_SFX_SLOTS) {
return;
}
gSfxSlots[slot].active = false;
// PTPlayer addresses SFX by Paula channel (0..3), but mt_playfx
// picks a channel dynamically and does not return which one. We
// can't reliably map slot -> channel after the fact, so stop all
// PAULA_CHANNELS channels' SFX state. mt_stopfx is idempotent on
// idle channels, so stopping a quiet one is harmless.
for (i = 0; i < PAULA_CHANNELS; i++) {
mt_stopfx((void *)&custom, i);
}
}
void jlpAudioFrameTick(void) {
// Streaming slots: mt_playfx plays one chunk and stops, so the next
// chunk goes out here as the previous one runs down.
{
uint8_t slot;
for (slot = 0u; slot < JOEY_AUDIO_SFX_SLOTS; slot++) {
if (!gSfxSlots[slot].active) {
continue;
}
if (gSfxSlots[slot].framesLeft != 0u) {
gSfxSlots[slot].framesLeft--;
continue;
}
if (!streamChunk(slot)) {
gSfxSlots[slot].active = false;
}
}
}
// PTPlayer drives itself off CIA-B; the only other host-loop work is
// the play-once watchdog. When the song author placed an `E8FF` at
// song end, mt_E8Trigger latches to 0xFF -- if the caller passed
// loop=false to PlayMod, that's our cue to stop the song.
if (gWantLoopOnce && gModPlaying && mt_E8Trigger == PTPLAYER_LOOP_END_MARKER) {
mt_Enable = 0;
gModPlaying = false;
gWantLoopOnce = false;
}
}