// 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 #include #include #include #include #include #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; } }