// Atari ST audio HAL: libxmp-lite Protracker decoder + 68k Timer A IRQ // running PWM through YM2149 channel-A volume register. // // Pipeline: // * halAudioInit creates an xmp_context, allocates a 2 KB mix buffer // (in BSS), parks the Timer A vector at our 68k ISR (audio_isr.s), // and starts Timer A at ~12.3 kHz (MFP prescaler 200, data 1). // * halAudioPlayMod loads a .MOD via xmp_load_module_from_memory and // calls xmp_start_player at the same 12288 Hz mono unsigned-8-bit // output that the ISR consumes one byte at a time. // * Each Timer A tick the ISR pops one byte off the buffer, writes // its high 4 bits to YM register 8 (channel A volume), advances // the play pointer, raises a refill flag at half/end. The refill // itself happens in halAudioFrameTick on the main thread because // libxmp-lite is not interrupt-safe. // // Quality budget: 4-bit unsigned PWM at 12.3 kHz on the YM is grim by // modern standards but period-correct for stock 520ST/1040ST without // the STe DMA chip. Library users that want the higher-fidelity STe // path can skip this HAL and build their own. #include #include #include #include "hal.h" #include "audioSfxMixInternal.h" #include "joey/audio.h" // ----- Constants ----- // MFP Timer A registers and the autovector slot for its IRQ (vector // $134, Setexc(0x134/4, ...) = Setexc(13, ...)). #define ST_MFP_TACR ((volatile uint8_t *)0xFFFFFA19L) #define ST_MFP_TADR ((volatile uint8_t *)0xFFFFFA1FL) #define ST_MFP_IERA ((volatile uint8_t *)0xFFFFFA07L) #define ST_MFP_IMRA ((volatile uint8_t *)0xFFFFFA13L) #define ST_MFP_ISRA ((volatile uint8_t *)0xFFFFFA0FL) // YM2149 (sound chip) supervisor-only ports. Index reg 7 (mixer) // controls per-channel tone + noise enables; reg 8/9/A are volumes // for channels A/B/C; regs 0/1, 2/3, 4/5 are tone period for those // channels; reg 6 is noise period. #define ST_YM_SELECT ((volatile uint8_t *)0xFFFF8800L) #define ST_YM_DATA ((volatile uint8_t *)0xFFFF8802L) // YM mixer (reg 7) high bits 6+7 are the I/O port directions TOS // leaves configured (port A output, port B = centronics printer // output). Always preserved when we rewrite the mixer. #define YM_MIXER_IO_DIR 0xC0u #define YM_REG_MIXER 7u #define MFP_TA_BIT 0x20 #define MFP_TACR_STOP 0x00 #define MFP_TACR_DIV200 0x07 #define VEC_MFP_TA (0x134 / 4) #define INT_TIMER_A 13 // Jenabint / Jdisint id for Timer A #define MIX_RATE 12288 // Hz, matches MFP 2.4576 MHz / 200 #define MIX_BUFFER 2048 #define MIX_HALF (MIX_BUFFER / 2) // ----- Module state ----- // The ISR (audio_isr.s) reads/writes these. Names must match the // .extern declarations there. volatile uint8_t gMixBuf[MIX_BUFFER]; volatile uint8_t *gMixPos = NULL; volatile uint8_t *gMixMid = NULL; volatile uint8_t *gMixEnd = NULL; volatile uint8_t gNeedRefill[2] = { 0, 0 }; extern void mfpTimerAIsr(void); static xmp_context gXmpCtx = NULL; static bool gReady = false; static bool gXmpStarted = false; static bool gXmpLoaded = false; static int gXmpLoopCount = 0; static void (*gOldTimerAVec)(void) = NULL; // SFX overlay shared with the DOS HAL (see src/core/audioSfxMix.c). // Mixed in over libxmp's MOD output during halAudioFrameTick. static AudioSfxSlotT gSfxSlots[JOEY_AUDIO_SFX_SLOTS]; // Single shadow of YM mixer reg 7's low 6 bits (tone+noise disables). // Both the PWM (installTimerA) and PSG-tone (applyYmVoice) paths // drive the mixer through this one shadow so they share one source of // truth: 0x3F = all tones and noise off (PWM-DAC default). The YM // can't be read back, so this shadow IS the authoritative state. static volatile uint8_t gYmToneMask = 0x3Fu; // ----- Internal helpers ----- // Write YM mixer reg 7 from the gYmToneMask shadow, preserving the // TOS I/O-port-direction high bits. Supervisor context required. static void ymWriteMixer(void) { *ST_YM_SELECT = YM_REG_MIXER; *ST_YM_DATA = (uint8_t)(YM_MIXER_IO_DIR | gYmToneMask); } static long installTimerA(void) { uint16_t i; // Park the buffer in known-silent state (unsigned 8-bit middle // = 128) before opening the IRQ gate. for (i = 0; i < MIX_BUFFER; i++) { gMixBuf[i] = 0x80; } gMixPos = (volatile uint8_t *)gMixBuf; gMixMid = (volatile uint8_t *)(gMixBuf + MIX_HALF); gMixEnd = (volatile uint8_t *)(gMixBuf + MIX_BUFFER); gNeedRefill[0] = 0; gNeedRefill[1] = 0; // YM2149 setup for PWM-via-volume on channel A: // reg 7 (mixer): set bits 0 (tone A off) and 3 (noise A off); // preserve bits 6+7 (I/O port directions, used // by TOS for floppy / keyboard / printer). // reg 8 (channel A volume): start at 0 to avoid a pop at start. // // Without the mixer setup, whatever state TOS left noise A in // gets gated by our 12 kHz volume writes -- if noise A was on, // a constant volume = constant hiss. Standard PWM-DAC trick is // to disable both tone and noise so the volume reg is a pure // 4-bit amplitude DAC. // // We can't reliably read back YM regs on the ST (the data port // returns last-write, not register contents), so we OR in the // disable bits over an assumed-safe TOS-default mask. Bit 6 set // (port A output) matches stock TOS; bit 7 set (port B output) // matches the centronics-printer direction TOS configures. gYmToneMask = 0x3Fu; // all tones + noise disabled (PWM-DAC default) ymWriteMixer(); // -> 0xFF: tones+noise off, I/O ports A+B output *ST_YM_SELECT = 8; *ST_YM_DATA = 0; // channel A volume = 0 to avoid a pop at start *ST_YM_SELECT = 9; *ST_YM_DATA = 0; // channel B volume = 0 *ST_YM_SELECT = 10; *ST_YM_DATA = 0; // channel C volume = 0 // MFP Timer A: stop, install our vector, set prescaler 200 + data // 1 (= 2.4576 MHz / 200 = 12288 Hz), then start. *ST_MFP_TACR = MFP_TACR_STOP; gOldTimerAVec = (void (*)(void))Setexc(VEC_MFP_TA, (long)mfpTimerAIsr); *ST_MFP_TADR = 1; *ST_MFP_TACR = MFP_TACR_DIV200; Jenabint(INT_TIMER_A); return 0; } static long uninstallTimerA(void) { Jdisint(INT_TIMER_A); *ST_MFP_TACR = MFP_TACR_STOP; if (gOldTimerAVec != NULL) { (void)Setexc(VEC_MFP_TA, (long)gOldTimerAVec); gOldTimerAVec = NULL; } /* Silence channel A volume so handoff back to TOS is clean (no * residual DC level on the speaker). */ *ST_YM_SELECT = 8; *ST_YM_DATA = 0; return 0; } static void silenceMixBuffer(void) { uint16_t i; for (i = 0; i < MIX_BUFFER; i++) { gMixBuf[i] = 0x80; } } // ----- HAL API (alphabetical) ----- bool halAudioInit(void) { if (gReady) { return true; } gXmpCtx = xmp_create_context(); if (gXmpCtx == NULL) { return false; } Supexec(installTimerA); gReady = true; return true; } void halAudioShutdown(void) { if (!gReady) { return; } Supexec(uninstallTimerA); silenceMixBuffer(); if (gXmpCtx != NULL) { if (gXmpStarted) { xmp_end_player(gXmpCtx); gXmpStarted = false; } if (gXmpLoaded) { xmp_release_module(gXmpCtx); gXmpLoaded = false; } xmp_free_context(gXmpCtx); gXmpCtx = NULL; } gReady = false; } bool halAudioIsPlayingMod(void) { return gReady && gXmpStarted; } void halAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { if (!gReady || gXmpCtx == NULL) { return; } if (gXmpStarted) { xmp_end_player(gXmpCtx); gXmpStarted = false; } if (gXmpLoaded) { xmp_release_module(gXmpCtx); gXmpLoaded = false; } if (xmp_load_module_from_memory(gXmpCtx, (void *)data, (long)length) != 0) { return; } gXmpLoaded = true; if (xmp_start_player(gXmpCtx, MIX_RATE, XMP_FORMAT_8BIT | XMP_FORMAT_UNSIGNED | XMP_FORMAT_MONO) != 0) { xmp_release_module(gXmpCtx); gXmpLoaded = false; return; } gXmpLoopCount = loop ? -1 : 0; gXmpStarted = true; } void halAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) { if (!gReady || slot >= JOEY_AUDIO_SFX_SLOTS) { return; } audioSfxSlotArm(&gSfxSlots[slot], sample, length, rateHz, MIX_RATE); } void halAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz) { if (!gReady || slot >= JOEY_AUDIO_SFX_SLOTS) { return; } audioSfxSlotArmStream(&gSfxSlots[slot], fill, ctx, rateHz, MIX_RATE); } // YM2149 has three PSG voices; a proper tone-mode driver would // program voice A here. Not wired yet -- AGI-on-ST will be silent // until this is filled in. void halAudioTone(uint16_t freqHz) { (void)freqHz; } // YM2149 PSG: three tone channels (A/B/C) addressed via reg 0..5 // (12-bit tone period each) and reg 8/9/A (4-bit volume each). // Reg 7 (mixer) gates tone + noise per channel; bits 0..2 = tone // disable A/B/C (0 = enabled), 3..5 = noise disable, 6..7 = I/O // port direction (preserve TOS defaults). // // Tone period units: 2 MHz / 16 / period = output Hz. So period = // 125000 / freqHz. 12-bit range covers 30 Hz - 62 kHz. // // Volume mapping: AGI atten 0..15 (0=loud) -> YM vol 15..0 (15=loud). // YM also accepts bit 4 = envelope-controlled volume; we never set // it (no envelope on AGI tones). // // YM regs are supervisor-only on the ST, so this is a Supexec block. // We stash the request in static globals (Supexec'd functions have // no parameters of their own). static volatile uint8_t gYmVoice; static volatile uint16_t gYmFreqHz; static volatile uint8_t gYmAtten; // gYmToneMask is the shared YM mixer shadow, defined in module state. static long applyYmVoice(void) { uint16_t period; uint8_t vol; uint8_t toneReg; uint8_t volReg; toneReg = (uint8_t)(gYmVoice * 2u); // reg 0/2/4 volReg = (uint8_t)(8u + gYmVoice); // reg 8/9/A if (gYmFreqHz == 0u || gYmAtten >= 15u) { // Silence: disable tone on this channel (mixer bit 0/1/2 set), // zero volume. Don't touch other channels' bits. gYmToneMask = (uint8_t)(gYmToneMask | ((uint8_t)1u << gYmVoice)); ymWriteMixer(); *ST_YM_SELECT = volReg; *ST_YM_DATA = 0; return 0; } period = (uint16_t)(125000u / (uint32_t)gYmFreqHz); if (period == 0u) { period = 1u; } if (period > 0x0FFFu) { period = 0x0FFFu; } *ST_YM_SELECT = toneReg; *ST_YM_DATA = (uint8_t)(period & 0xFFu); *ST_YM_SELECT = (uint8_t)(toneReg + 1u); *ST_YM_DATA = (uint8_t)((period >> 8) & 0x0Fu); vol = (uint8_t)(15u - gYmAtten); *ST_YM_SELECT = volReg; *ST_YM_DATA = (uint8_t)(vol & 0x0Fu); // Enable tone on this channel (clear its bit in mixer). gYmToneMask = (uint8_t)(gYmToneMask & ~((uint8_t)1u << gYmVoice)); ymWriteMixer(); return 0; } // PIT-style tick ISR not wired on ST yet: Timer A is already busy // driving 12 kHz PWM for MOD output (see installTimerA). A future // AGI-on-ST sound path could lift the YM2149 tone driver onto // Timer B or C and register it here. bool halAudioTickRegister(jlAudioTickFnT fn, uint16_t hz) { (void)fn; (void)hz; return false; } void halAudioCriticalEnter(void) { // No tick-callback ISR is registered (halAudioTickRegister returns // false). The Timer A MOD ISR synchronizes with the producer via // the gNeedRefill flags, not this critical section, so masking // here would only starve that ~12 kHz audio ISR for no benefit. } void halAudioCriticalExit(void) { } void halAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { if (voice >= JOEY_AUDIO_VOICES) { return; } // The PWM-via-volume engine in halAudioInit hammers YM reg 8 // (channel A volume) at 12 kHz for MOD output. If that IRQ is // running, it will fight the per-voice writes here. AGI does not // call jlAudioInit, so Timer A is not installed and the YM is // ours -- but a future caller mixing modes would hear collisions // on channel A. gYmVoice = voice; gYmFreqHz = freqHz; gYmAtten = atten; Supexec(applyYmVoice); } void halAudioStopMod(void) { if (!gReady || gXmpCtx == NULL) { return; } if (gXmpStarted) { xmp_end_player(gXmpCtx); gXmpStarted = false; } if (gXmpLoaded) { xmp_release_module(gXmpCtx); gXmpLoaded = false; } silenceMixBuffer(); } void halAudioStopSfx(uint8_t slot) { if (slot >= JOEY_AUDIO_SFX_SLOTS) { return; } gSfxSlots[slot].active = false; } // Drains ISR-raised refill flags by re-running libxmp's mixer into // the consumed half then overlaying any active SFX. Called from the // game loop, never from IRQ. // // Soft-real-time constraint: the producer (this function) must refill // a consumed half within MIX_HALF / MIX_RATE = 1024 / 12288 ~= 83 ms // of the ISR raising its flag, or the ISR drains into the half still // being rewritten (audible tearing). A full MOD-frame mix plus // audioSfxOverlayMix can approach that budget under load, so callers // should run the game loop comfortably faster than ~12 Hz. void halAudioFrameTick(void) { if (!gReady) { return; } // Underrun guard: both flags set means the producer fell a full // buffer behind, so the ISR is now draining a half this tick is // about to rewrite. Silence first so the in-flight torn read plays // as silence rather than garbage, then refill both halves below. if (gNeedRefill[0] && gNeedRefill[1]) { silenceMixBuffer(); } if (gNeedRefill[0]) { if (gXmpStarted) { xmp_play_buffer(gXmpCtx, (void *)gMixBuf, MIX_HALF, gXmpLoopCount); } else { memset((void *)gMixBuf, 0x80, MIX_HALF); } audioSfxOverlayMix(gMixBuf, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS); gNeedRefill[0] = 0; } if (gNeedRefill[1]) { if (gXmpStarted) { xmp_play_buffer(gXmpCtx, (void *)(gMixBuf + MIX_HALF), MIX_HALF, gXmpLoopCount); } else { memset((void *)(gMixBuf + MIX_HALF), 0x80, MIX_HALF); } audioSfxOverlayMix(gMixBuf + MIX_HALF, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS); gNeedRefill[1] = 0; } }