// Apple IIgs audio HAL -- single source linked into every IIgs demo.
// Memory Manager + the 34 KB NTP replayer only need to be present in
// AUDIO binaries; link-time dead-stripping drops this implementation
// code from non-AUDIO binaries, which pay only for the data
// references they actually use.
//
// The NinjaTrackerPlus replayer is Merlin32-assembled at build time to
// ntpplayer.bin and LOADED FROM DISK at runtime (NTP_PATH). jlpAudioInit
// reads those bytes into a fixed-bank Memory Manager handle and JSLs into
// them through the self-modifying call stub below; NTP is bank-internal /
// position-independent so it runs at whatever address Memory Manager picked.

#include <stdio.h>
#include <string.h>

// Memory Manager via <iigs/toolbox.h>. Handles/pointers are void* and the
// owner ID comes from MMStartUp(); _ownerid maps to MMStartUp() and
// _toolErr collapses to 0 so each `|| _toolErr != 0` guard reduces to the
// NULL-handle failure check. Memory Manager attribute bits are defined
// here (values from Apple's Memory Manager reference).
#include <iigs/toolbox.h>
typedef void *Handle;
typedef void *Pointer;
#define _ownerid    (MMStartUp())
#define _toolErr    0
#define attrPage    0x0004
#define attrNoCross 0x0010
#define attrNoSpec  0x0020
#define attrFixed   0x4000
#define attrLocked  0x8000

#include "port.h"
#include "joey/audio.h"

// The ~34 KB NTP replayer is LOADED FROM DISK at runtime, not baked into
// this TU. As rodata it overflows the IIgs bank-0 ceiling, and clang's near
// (IMM16 + $00BE load-bank) form for &blob cannot reach a far rodata segment
// in another bank -- so a baked blob is impossible on the IIgs regardless of
// link816 splitting. ntpplayer.bin must ship alongside the application and is
// resolved against the current GS/OS prefix.
#define NTP_PATH    "ntpplayer"

// ----- Constants -----

#define NTP_BUFFER_BYTES   (64L * 1024L)

// Ensoniq 5503 (DOC) I/O at $E1C03C..$E1C03E. NTP saves its preferred
// sound_control byte at $E100CA so callers that want DOC-register
// access don't have to know NTP's mode bits.
#define DOC_SOUND_CONTROL  ((volatile uint8_t *)0xE1C03CL)
#define DOC_SOUND_DATA     ((volatile uint8_t *)0xE1C03DL)
#define DOC_SOUND_ADDRESS  ((volatile uint8_t *)0xE1C03EL)
#define DOC_IRQ_VOLUME     ((volatile uint8_t *)0xE100CAL)

// DOC oscillator control register layout: bit 0 = halt. NTPstreamsound
// itself uses 0x03 (halt + IE) when it wants an osc stopped, so we
// mirror that.
#define DOC_OSC_CTRL_BASE  0xA0
#define DOC_OSC_HALT_BITS  0x03

// SFX: one fixed-bank handle holding all slots' stream structures +
// sample buffers. Per slot: 16-byte struct followed by 4080 bytes of
// sample, total 4 KB. JOEY_AUDIO_SFX_SLOTS slots fit in 20 KB.
#define SFX_SLOT_BYTES     (4L * 1024L)
#define SFX_BUFFER_BYTES   (SFX_SLOT_BYTES * (long)JOEY_AUDIO_SFX_SLOTS)

// NTP uses oscillators 0..N for music. Each SFX slot reserves 2 oscs
// (1 streamer + 1 player) starting at 14: slot 0 = 14..15, slot 1 =
// 16..17, etc. With 7 max music tracks * 2 oscs = 14 (oscs 0..13)
// and NTP's interrupt osc at 31, this avoids both ranges. Each slot
// also gets its own 512-byte-aligned DOC RAM page so they don't share
// the streaming buffer.
#define SFX_BASE_OSC       14
#define SFX_OSCS_PER_SLOT  2
#define SFX_BASE_DOC_PAGE  0xC0      // 512-aligned (bit 0 must be 0)
#define SFX_DOC_PAGE_STEP  2         // 512 bytes per slot
#define SFX_VOLUME         255
#define SFX_CHANNEL_LEFT   0x00      // Upper 4 bits = output channel

// DOC freq formula: rate_Hz = freq * input_clk / (8 * num_oscs * 65536).
// NTP's max_tracks_available = 31 + 1 interrupt osc => 32 active oscs.
// IIgs DOC input clock = 7158000 Hz; (input_clk / 8 / 32) = 27961.
// Inverted: freq_word = rate * 65536 / 27961. Caps usable rate at
// ~27 kHz (above that freq_word overflows uint16).
#define IIGS_DOC_DIVISOR   27961UL
#define IIGS_MAX_SFX_RATE  27000U

// PSG-style tone voices: each voice's SFX slot holds a RESIDENT
// full-amplitude square wave (whole periods so retriggers seam), and
// per-note changes are just stream-struct pokes -- pitch rides the
// DOC freq word (playback rate = toneHz * TONE_PERIOD_BYTES), and
// loudness rides the struct's playing-osc volume byte. The square is
// stored in DOC-unsigned form; the low level is 0x01 because a 0x00
// sample byte is the DOC's wave-end marker and would halt the osc.
#define TONE_PERIOD_BYTES  8u      // samples per square period (4 hi + 4 lo)
#define TONE_SQUARE_BYTES  2040u   // 255 whole periods, fits the 4080-byte slot
#define TONE_LEVEL_HI      0xFFu
#define TONE_LEVEL_LO      0x01u
#define TONE_MAX_HZ        3495u   // freq word ceiling: 3495 * 8 * 65536 / 27961 = 65533
#define TONE_SUSTAIN_MUL   41u     // len = freqHz * 41 / 64 bytes -> ~80 ms sustain
#define AGI_ATTEN_OFF      15u     // AGI attenuation: 0 = loudest .. 15 = silent
#define IIGS_FRAME_HZ      60U

// Noise voice: SFX slot 3 holds a resident 1-bit LFSR noise sample
// (levels 0xFF/0x01 -- 0x00 is the DOC wave-end halt byte, same rule
// as TONE_LEVEL_LO). Pitch 0..31 maps linearly to playback rate
// 26000..2750 Hz; the rate ceiling must stay below IIGS_DOC_DIVISOR
// (27961) or the freq word wraps uint16 into a near-zero growl.
#define NOISE_SLOT         3u
#define NOISE_BYTES        4080u   // whole sample area: SFX_SLOT_BYTES - SFX_SAMPLE_OFFSET
#define NOISE_LEVEL_HI     0xFFu
#define NOISE_LEVEL_LO     0x01u
#define NOISE_RATE_MAX     26000u
#define NOISE_RATE_STEP    750u    // rate = 26000 - pitch * 750
#define NOISE_SUSTAIN_MUL  41u     // len = rate * 41 / 512 -> ~80 ms sustain
#define NOISE_LFSR_SEED    0xACE1u
#define NOISE_LFSR_TAPS    0xB400u // 16-bit maximal Galois LFSR, period 65535

// ----- Module state -----

static Handle    gNTPHandle    = NULL;
static uint32_t  gNTPBase      = 0;
static Handle    gModuleHandle = NULL;
static uint32_t  gModuleBase   = 0;
static Handle    gSfxHandle    = NULL;
static uint32_t  gSfxBase      = 0;
static bool      gNTPReady     = false;
static bool      gNTPPlaying   = false;

// Per-slot config cache. jlpAudioPlaySfx's biggest cost is the
// per-byte XOR-with-$80 loop over the entire sample (signed -> DOC's
// unsigned format), which on a 4 KB sample is ~120 k cyc / ~43 ms.
// Most callers play the same SFX repeatedly into the same
// slot. Cache (sample ptr, length, rate) per slot; on cache hit
// (same sample re-triggered) skip the byte copy AND the struct
// rebuild, just re-fire NTPstreamsound.
static const uint8_t *gSfxSlotSample[JOEY_AUDIO_SFX_SLOTS] = { 0 };
static uint32_t       gSfxSlotLength[JOEY_AUDIO_SFX_SLOTS] = { 0 };
static uint16_t       gSfxSlotRateHz[JOEY_AUDIO_SFX_SLOTS] = { 0 };

// Sentinel address stored in gSfxSlotSample[] while a slot's sample
// area holds the resident tone square (see toneSquareEnsure). It can
// never collide with a real caller sample pointer, and the paired
// length of 0 can never satisfy jlpAudioPlaySfx's cache test (that
// path rejects length == 0 up front).
static const uint8_t  gToneMarker = 0;

// Distinct sentinel for "slot holds the resident noise sample". It
// must not be gToneMarker: either jlpAudioPlaySfx (a real sample) or
// a tone fill can clobber slot 3's area, and only a distinct address
// makes noiseEnsure regenerate in both cases.
static const uint8_t  gNoiseMarker = 0;

// Retrigger phase rotation for the noise voice: restarting the same
// bytes every per-frame re-assert adds a faint 60 Hz tint to held
// noise; rotating the start offset (stride co-prime with the buffer)
// decorrelates the retriggers. Max start 1023 + max len 2082 = 3105,
// inside the 4080-byte area.
static uint16_t       gNoisePhase = 0;

// SFX handle layout: stream structure first, sample bytes after.
// Both end up at known 24-bit addresses, side-stepping the small
// memory model's 16-bit pointer issue.
//
// Stream structure consumed by NTPstreamsound (per NTP docs):
//   0..3 : sample pointer (4 bytes, little-endian, 24-bit in low 3)
//   4..7 : sample length (4 bytes)
//   8..9 : freq word (DOC frequency, 2 bytes)
//  10    : DOC RAM page (512-aligned)
//  11    : first oscillator (0..29)
//  12    : playing-osc count (1..30; total oscs used = 1 + count)
//  13    : volume of playing osc 0 (0..255)
//  14    : channel of playing osc 0 (upper 4 bits)
#define SFX_STRUCT_BYTES   16
#define SFX_SAMPLE_OFFSET  SFX_STRUCT_BYTES

// Self-modifying call stub. Bakes the X/Y/A register loads AND the
// JSL target into the buffer, so the C-side inline asm only needs
// `jsl gCallStub` followed by `rep #0x30` -- no global-operand
// references in the asm block.
//
// The trailing REP restores M=16/X=16 for the function epilogue: the
// byte writes the call sites do to patch this stub leave M=8, and
// NTP's RTL leaves M/X in whatever state NTP chose. Without REP, the
// C epilogue's ADC/LDX widths decode wrong and the function returns
// to garbage. See project_iigs_inline_asm_mode.md.
//
// Folding the X/Y/A loads and the JSL target into the stub keeps the
// inline asm block free of absolute-global operands entirely.
//
// Layout (15 bytes):
//   00: C2 30              REP #$30          ; 16-bit M/X
//   02: A2 lo hi            LDX #X
//   05: A0 lo hi            LDY #Y
//   08: A9 lo hi            LDA #A
//   0B: 22 lo hi bk        JSL target
//   0F: 6B                  RTL
// GLOBAL (not static): referenced by name from the inline `jsl gCallStub`
// blocks below; link816 resolves .text relocations only against global
// symbols, so a file-local buffer links as "unresolved gCallStub".
unsigned char gCallStub[16];


// ----- Internal helpers -----

static void buildCallStub(uint32_t target, uint16_t x, uint16_t y, uint16_t a) {
    gCallStub[0]  = 0xC2;
    gCallStub[1]  = 0x30;
    gCallStub[2]  = 0xA2;
    gCallStub[3]  = (unsigned char)(x & 0xFFu);
    gCallStub[4]  = (unsigned char)((x >> 8) & 0xFFu);
    gCallStub[5]  = 0xA0;
    gCallStub[6]  = (unsigned char)(y & 0xFFu);
    gCallStub[7]  = (unsigned char)((y >> 8) & 0xFFu);
    gCallStub[8]  = 0xA9;
    gCallStub[9]  = (unsigned char)(a & 0xFFu);
    gCallStub[10] = (unsigned char)((a >> 8) & 0xFFu);
    gCallStub[11] = 0x22;
    gCallStub[12] = (unsigned char)(target & 0xFFu);
    gCallStub[13] = (unsigned char)((target >> 8) & 0xFFu);
    gCallStub[14] = (unsigned char)((target >> 16) & 0xFFu);
    gCallStub[15] = 0x6B;
}

static bool loadNTP(void) {
    Handle  h;
    Pointer p;
    FILE   *fp;
    size_t  got;

    h = NewHandle(NTP_BUFFER_BYTES, _ownerid,
                  attrFixed | attrLocked | attrPage | attrNoCross,
                  NULL);
    if (h == NULL || _toolErr != 0) {
        return false;
    }
    HLock(h);
    p = *(void **)h;
    if (p == NULL) {
        DisposeHandle(h);
        return false;
    }

    // Read the replayer from disk (NTP_PATH) into the locked, bank-internal
    // handle -- see the NTP_PATH note above for why it is not baked in.
    fp = fopen(NTP_PATH, "rb");
    if (fp == NULL) {
        DisposeHandle(h);
        return false;
    }
    got = fread(p, 1, NTP_BUFFER_BYTES, fp);
    fclose(fp);
    if (got == 0) {
        DisposeHandle(h);
        return false;
    }

    gNTPHandle = h;
    gNTPBase   = (uint32_t)p;
    return true;
}


// Ensure slot 3's DOC sample area holds the resident noise sample and
// its stream struct's constant bytes are built. 1-bit maximal-LFSR
// noise mapped to 0xFF/0x01 (a 0x00 byte would halt the oscillator --
// the DOC wave-end rule), so the YM-style "spectral edge = shift
// rate" semantic carries over. One-time fill, redone only after
// jlpAudioPlaySfx (or a stray tone fill) clobbers the slot and
// retargets its cache away from gNoiseMarker.
static void noiseEnsure(uint8_t slot, uint32_t slotBase) {
    unsigned char *sfx;
    unsigned char *dst;
    uint32_t       sampleAddr;
    uint16_t       lfsr;
    uint16_t       i;

    if (gSfxSlotSample[slot] == &gNoiseMarker) {
        return;
    }

    sampleAddr = slotBase + SFX_SAMPLE_OFFSET;
    dst        = (unsigned char *)sampleAddr;
    lfsr       = NOISE_LFSR_SEED;
    for (i = 0; i < NOISE_BYTES; i++) {
        dst[i] = (lfsr & 1u) ? NOISE_LEVEL_HI : NOISE_LEVEL_LO;
        lfsr   = (uint16_t)((lfsr >> 1) ^ ((lfsr & 1u) ? NOISE_LFSR_TAPS : 0u));
    }

    // Constant stream-struct bytes; sample pointer bytes 0..2 are
    // per-call on this slot (phase rotation), the rest mirrors
    // toneSquareEnsure.
    sfx     = (unsigned char *)slotBase;
    sfx[0]  = (unsigned char)(sampleAddr & 0xFFu);
    sfx[1]  = (unsigned char)((sampleAddr >> 8) & 0xFFu);
    sfx[2]  = (unsigned char)((sampleAddr >> 16) & 0xFFu);
    sfx[3]  = 0;
    sfx[10] = (unsigned char)(SFX_BASE_DOC_PAGE + slot * SFX_DOC_PAGE_STEP);
    sfx[11] = (unsigned char)(SFX_BASE_OSC + slot * SFX_OSCS_PER_SLOT);
    sfx[12] = 1;                    // one playing osc
    sfx[14] = SFX_CHANNEL_LEFT;

    gSfxSlotSample[slot] = &gNoiseMarker;
    gSfxSlotLength[slot] = 0;
    gSfxSlotRateHz[slot] = 0;
}


// Fire NTPstreamsound on a slot's stream structure. structAddr is the
// 24-bit address of the 16-byte struct; NTP takes it low 16 in X, bank
// in Y (same packing as NTPprepare).
static void sfxTrigger(uint32_t structAddr) {
    buildCallStub(gNTPBase + 24,
                  (uint16_t)(structAddr & 0xFFFFu),
                  (uint16_t)((structAddr >> 16) & 0xFFu),
                  0);
    __asm__ volatile (
        "jsl gCallStub\n\t"
        "rep #0x30"
        ::: "memory"
    );
}


// Ensure the slot's DOC sample area holds the resident tone square and
// its stream struct's constant bytes are built. The square is 255
// whole 8-sample periods at full amplitude, already in DOC-unsigned
// form (0x00 is the DOC's wave-end halt byte, so the low level is
// 0x01, not 0x00). Pitch and loudness are NOT baked in here -- per
// note, jlpAudioVoice pokes the struct's freq word, length, and
// volume byte instead, so this fill runs once per slot (and again
// only if jlpAudioPlaySfx overwrote the area with a real sample,
// which retargets the slot cache away from gToneMarker).
static void toneSquareEnsure(uint8_t slot, uint32_t slotBase) {
    unsigned char *sfx;
    unsigned char *dst;
    uint32_t       sampleAddr;
    uint16_t       i;

    if (gSfxSlotSample[slot] == &gToneMarker) {
        return;
    }

    sampleAddr = slotBase + SFX_SAMPLE_OFFSET;
    dst        = (unsigned char *)sampleAddr;
    for (i = 0; i < TONE_SQUARE_BYTES; i += TONE_PERIOD_BYTES) {
        dst[i]     = TONE_LEVEL_HI;
        dst[i + 1] = TONE_LEVEL_HI;
        dst[i + 2] = TONE_LEVEL_HI;
        dst[i + 3] = TONE_LEVEL_HI;
        dst[i + 4] = TONE_LEVEL_LO;
        dst[i + 5] = TONE_LEVEL_LO;
        dst[i + 6] = TONE_LEVEL_LO;
        dst[i + 7] = TONE_LEVEL_LO;
    }

    // Constant stream-struct bytes: sample pointer, DOC page, osc
    // pair, playing-osc count, channel. Length (4..7), freq word
    // (8..9), and volume (13) are per-note, written by jlpAudioVoice.
    sfx     = (unsigned char *)slotBase;
    sfx[0]  = (unsigned char)(sampleAddr & 0xFFu);
    sfx[1]  = (unsigned char)((sampleAddr >> 8) & 0xFFu);
    sfx[2]  = (unsigned char)((sampleAddr >> 16) & 0xFFu);
    sfx[3]  = 0;
    sfx[10] = (unsigned char)(SFX_BASE_DOC_PAGE + slot * SFX_DOC_PAGE_STEP);
    sfx[11] = (unsigned char)(SFX_BASE_OSC + slot * SFX_OSCS_PER_SLOT);
    sfx[12] = 1;                    // one playing osc
    sfx[14] = SFX_CHANNEL_LEFT;

    gSfxSlotSample[slot] = &gToneMarker;
    gSfxSlotLength[slot] = 0;
    gSfxSlotRateHz[slot] = 0;
}


// ----- HAL API (alphabetical) -----

bool jlpAudioInit(void) {
    Handle modHandle;
    Handle sfxHandle;

    if (gNTPReady) {
        return true;
    }
    if (!loadNTP()) {
        return false;
    }
    // A second fixed-bank handle holds the per-call .NTP module bytes.
    // BlockMove copies the caller's data here so we have a known 24-bit
    // address to pass to NTPprepare in X/Y.
    modHandle = NewHandle(NTP_BUFFER_BYTES, _ownerid,
                          attrFixed | attrLocked | attrPage | attrNoCross,
                          NULL);
    if (modHandle == NULL || _toolErr != 0) {
        DisposeHandle(gNTPHandle);
        gNTPHandle = NULL;
        return false;
    }
    HLock(modHandle);
    gModuleHandle = modHandle;
    gModuleBase   = (uint32_t)*(void **)modHandle;

    // Third fixed-bank handle for SFX sample storage. Same idea as
    // the module handle: NTPstreamsound wants a 24-bit pointer, and
    // the small-memory-model caller can only hand us a 16-bit one.
    sfxHandle = NewHandle(SFX_BUFFER_BYTES, _ownerid,
                          attrFixed | attrLocked | attrPage | attrNoCross,
                          NULL);
    if (sfxHandle == NULL || _toolErr != 0) {
        DisposeHandle(gModuleHandle);
        DisposeHandle(gNTPHandle);
        gModuleHandle = NULL;
        gNTPHandle    = NULL;
        return false;
    }
    HLock(sfxHandle);
    gSfxHandle = sfxHandle;
    gSfxBase   = (uint32_t)*(void **)sfxHandle;

    gNTPReady = true;
    return true;
}


void jlpAudioShutdown(void) {
    if (!gNTPReady) {
        return;
    }
    if (gNTPPlaying) {
        jlpAudioStopMod();
    }
    // Silence every SFX slot before disposing the handles. NTP's DOC
    // IRQ vector points into the buffer we are about to free; if any
    // oscillator finishes its sample after the dispose, the wave-done
    // interrupt fires into freed memory and the IIgs reports
    // "Unclaimed Sound Interrupt" plus a stuck high-pitched whine
    // (whatever sample byte was last loaded into the DOC).
    {
        uint8_t i;
        for (i = 0; i < JOEY_AUDIO_SFX_SLOTS; i++) {
            jlpAudioStopSfx(i);
            gSfxSlotSample[i] = (const uint8_t *)0;
            gSfxSlotLength[i] = 0;
            gSfxSlotRateHz[i] = 0;
        }
    }
    if (gSfxHandle != NULL) {
        DisposeHandle(gSfxHandle);
        gSfxHandle = NULL;
        gSfxBase   = 0;
    }
    if (gModuleHandle != NULL) {
        DisposeHandle(gModuleHandle);
        gModuleHandle = NULL;
        gModuleBase   = 0;
    }
    if (gNTPHandle != NULL) {
        DisposeHandle(gNTPHandle);
        gNTPHandle = NULL;
        gNTPBase   = 0;
    }
    gNTPReady = false;
}


bool jlpAudioIsPlayingMod(void) {
    return gNTPReady && gNTPPlaying;
}


// Noise voice: retrigger the resident slot-3 noise sample, same
// one-shot model as the tone voices (~80 ms; the sequencer re-asserts
// held noise per frame). Per call: 10 struct pokes (start pointer,
// length, freq word, volume) + one NTPstreamsound JSL.
void jlpAudioNoise(uint8_t pitch, uint8_t atten) {
    unsigned char *sfx;
    uint32_t       slotBase;
    uint32_t       startAddr;
    uint16_t       rate;
    uint16_t       freqWord;
    uint16_t       len;
    uint8_t        vol;

    if (!gNTPReady || gSfxHandle == NULL) {
        return;
    }
    if (atten >= AGI_ATTEN_OFF) {
        jlpAudioStopSfx((uint8_t)NOISE_SLOT);
        return;
    }

    slotBase = gSfxBase + (uint32_t)NOISE_SLOT * (uint32_t)SFX_SLOT_BYTES;
    noiseEnsure((uint8_t)NOISE_SLOT, slotBase);

    // Core clamps pitch <= 31, so the subtrahend is at most 23250.
    rate     = (uint16_t)(NOISE_RATE_MAX - (uint16_t)pitch * NOISE_RATE_STEP);
    freqWord = (uint16_t)(((uint32_t)rate * 65536UL) / IIGS_DOC_DIVISOR);
    len      = (uint16_t)(((uint32_t)rate * NOISE_SUSTAIN_MUL) >> 9);

    // Rotate the start offset so per-frame retriggers do not replay
    // the same bytes (stride 509 is co-prime with the 1024 window;
    // bound: 1023 + 2082 max len = 3105 <= 4080).
    gNoisePhase = (uint16_t)((gNoisePhase + 509u) & 0x3FFu);
    startAddr   = slotBase + SFX_SAMPLE_OFFSET + gNoisePhase;

    vol = (uint8_t)(254u - (uint16_t)atten * 17u);

    sfx     = (unsigned char *)slotBase;
    sfx[0]  = (unsigned char)(startAddr & 0xFFu);
    sfx[1]  = (unsigned char)((startAddr >> 8) & 0xFFu);
    sfx[2]  = (unsigned char)((startAddr >> 16) & 0xFFu);
    sfx[4]  = (unsigned char)(len & 0xFFu);
    sfx[5]  = (unsigned char)((len >> 8) & 0xFFu);
    sfx[6]  = 0;
    sfx[7]  = 0;
    sfx[8]  = (unsigned char)(freqWord & 0xFFu);
    sfx[9]  = (unsigned char)((freqWord >> 8) & 0xFFu);
    sfx[13] = vol;
    sfxTrigger(slotBase);
}


void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) {
    if (!gNTPReady || gModuleHandle == NULL) {
        return;
    }
    if (length == 0 || length > NTP_BUFFER_BYTES) {
        return;
    }
    // Drop any previous song before loading a new one.
    if (gNTPPlaying) {
        jlpAudioStopMod();
    }
    BlockMove((Pointer)data, (Pointer)gModuleBase, length);

    // NTPprepare(modPtr in X/Y, doubling in A). modPtr is a 24-bit
    // address: low 16 bits in X, bank byte in Y (high byte is don't
    // care, low byte is the bank).
    buildCallStub(gNTPBase + 0,
                  (uint16_t)(gModuleBase & 0xFFFFu),
                  (uint16_t)((gModuleBase >> 16) & 0xFFu),
                  0);
    __asm__ volatile (
        "jsl gCallStub\n\t"
        "rep #0x30"
        ::: "memory"
    );

    // NTPplay(loopFlag in A). 0 = loop forever, 1 = play once.
    buildCallStub(gNTPBase + 3, 0, 0, loop ? 0 : 1);
    __asm__ volatile (
        "jsl gCallStub\n\t"
        "rep #0x30"
        ::: "memory"
    );
    gNTPPlaying = true;
}


void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) {
    unsigned char *sfx;
    uint32_t       slotBase;
    uint32_t       sampleAddr;
    uint32_t       structAddr;
    uint16_t       freqWord;
    uint16_t       clampedRate;

    if (!gNTPReady || gSfxHandle == NULL || slot >= JOEY_AUDIO_SFX_SLOTS) {
        return;
    }
    if (length == 0 || length > (SFX_SLOT_BYTES - SFX_SAMPLE_OFFSET)) {
        return;
    }

    clampedRate = (rateHz > IIGS_MAX_SFX_RATE) ? IIGS_MAX_SFX_RATE : rateHz;
    freqWord = (uint16_t)(((uint32_t)clampedRate * 65536UL) / IIGS_DOC_DIVISOR);

    slotBase   = gSfxBase + (uint32_t)slot * (uint32_t)SFX_SLOT_BYTES;
    structAddr = slotBase;
    sampleAddr = slotBase + SFX_SAMPLE_OFFSET;

    // Cache check: same sample, length, and rate as the prior trigger
    // for this slot? Then the slot's DOC sample bytes and stream
    // struct are already correct -- skip the 4 KB byte-XOR loop and
    // the 15-byte struct rebuild, both of which together can run
    // ~50 ms per call.
    if (sample == gSfxSlotSample[slot] &&
        length == gSfxSlotLength[slot] &&
        rateHz == gSfxSlotRateHz[slot]) {
        // Cache hit -- jump straight to the NTPstreamsound trigger.
    } else {
        // Cache miss: copy the sample into this slot's fixed-bank
        // region, converting signed 8-bit (public API contract) to
        // unsigned 8-bit (DOC RAM format) by flipping the sign bit.
        {
            unsigned char *dst;
            uint32_t       i;

            dst = (unsigned char *)sampleAddr;
            for (i = 0; i < length; i++) {
                dst[i] = (unsigned char)(sample[i] ^ 0x80);
            }
        }

        // Build the stream structure in this slot's first 16 bytes.
        sfx = (unsigned char *)slotBase;
        sfx[0]  = (unsigned char)(sampleAddr & 0xFFu);
        sfx[1]  = (unsigned char)((sampleAddr >> 8) & 0xFFu);
        sfx[2]  = (unsigned char)((sampleAddr >> 16) & 0xFFu);
        sfx[3]  = 0;
        sfx[4]  = (unsigned char)(length & 0xFFu);
        sfx[5]  = (unsigned char)((length >> 8) & 0xFFu);
        sfx[6]  = (unsigned char)((length >> 16) & 0xFFu);
        sfx[7]  = (unsigned char)((length >> 24) & 0xFFu);
        sfx[8]  = (unsigned char)(freqWord & 0xFFu);
        sfx[9]  = (unsigned char)((freqWord >> 8) & 0xFFu);
        sfx[10] = (unsigned char)(SFX_BASE_DOC_PAGE + slot * SFX_DOC_PAGE_STEP);
        sfx[11] = (unsigned char)(SFX_BASE_OSC + slot * SFX_OSCS_PER_SLOT);
        sfx[12] = 1;                    // one playing osc
        sfx[13] = SFX_VOLUME;
        sfx[14] = SFX_CHANNEL_LEFT;

        gSfxSlotSample[slot] = sample;
        gSfxSlotLength[slot] = length;
        gSfxSlotRateHz[slot] = rateHz;
    }

    sfxTrigger(structAddr);
}


// Streaming SFX not wired on IIgs: the DOC-RAM streamer is tuned for
// fully-resident samples handed to NTPstreamsound. Live refill from
// a callback would need a DOC-IRQ ring buffer, which is a separate
// project. No-op until then.
void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz) {
    (void)slot;
    (void)fill;
    (void)ctx;
    (void)rateHz;
}


// PSG-style tone/voice playback rides the SFX slots, which already
// cooperate with the NTP MOD engine (SFX uses DOC oscillators 14-23;
// NTP uses the lower ones). The slot holds a resident full-amplitude
// square (toneSquareEnsure); each note is ~10 stream-struct pokes plus
// one NTPstreamsound retrigger, so the per-call cost is register-write
// scale, not sample-synthesis scale (the old synthesize-per-call shape
// cost ~120-150 ms/call -- perf hunt 3 rank 4). The host re-asserts
// active voices each frame, which sustains them between the ~80 ms
// one-shot triggers and lets dropped calls decay to silence.
static jlAudioTickFnT gTickFn     = (jlAudioTickFnT)0;
static uint16_t       gTickReload = 1;
static uint16_t       gTickCount  = 1;


void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) {
    unsigned char *sfx;
    uint32_t       slotBase;
    uint32_t       lenWide;
    uint16_t       freqWord;
    uint16_t       len;
    uint8_t        vol;

    // Tone voices own SFX slots 0-2 ONLY: slot 3 is the noise sample,
    // slot 4 the app SFX slot -- a wider bound would let a stray call
    // stamp a tone square over them.
    if (voice >= JOEY_AUDIO_VOICES) {
        return;
    }
    if (!gNTPReady || gSfxHandle == NULL) {
        return;
    }
    if (freqHz == 0u || atten >= AGI_ATTEN_OFF) {
        jlpAudioStopSfx(voice);                 // freq 0 / max atten = silence
        return;
    }
    if (freqHz > TONE_MAX_HZ) {
        freqHz = TONE_MAX_HZ;
    }

    slotBase = gSfxBase + (uint32_t)voice * (uint32_t)SFX_SLOT_BYTES;
    toneSquareEnsure(voice, slotBase);

    // Pitch: play the 8-sample square period at toneHz, so the DOC
    // playback rate is freqHz * TONE_PERIOD_BYTES samples/sec and
    // freq_word = rate * 65536 / 27961. Tone resolution ~0.05 Hz.
    // Overflow check: TONE_MAX_HZ * 524288 = 1.83e9 < UINT32_MAX.
    freqWord = (uint16_t)(((uint32_t)freqHz * (65536UL * (uint32_t)TONE_PERIOD_BYTES)) / IIGS_DOC_DIVISOR);

    // Sustain: scale the one-shot length to ~80 ms of audio at this
    // rate (len = rate * 0.08 = freqHz * 8 * 0.08 ~= freqHz * 41/64),
    // rounded down to whole periods so retriggers seam.
    lenWide = ((uint32_t)freqHz * TONE_SUSTAIN_MUL) >> 6;
    len     = (uint16_t)(lenWide & ~(uint32_t)(TONE_PERIOD_BYTES - 1u));
    if (len < TONE_PERIOD_BYTES) {
        len = TONE_PERIOD_BYTES;
    }
    if (len > TONE_SQUARE_BYTES) {
        len = TONE_SQUARE_BYTES;
    }

    // AGI attenuation 0..14 -> DOC playing-osc volume 254..16 (linear,
    // same slope as the old baked-amplitude map).
    vol = (uint8_t)(254u - (uint16_t)atten * 17u);

    sfx     = (unsigned char *)slotBase;
    sfx[4]  = (unsigned char)(len & 0xFFu);
    sfx[5]  = (unsigned char)((len >> 8) & 0xFFu);
    sfx[6]  = 0;
    sfx[7]  = 0;
    sfx[8]  = (unsigned char)(freqWord & 0xFFu);
    sfx[9]  = (unsigned char)((freqWord >> 8) & 0xFFu);
    sfx[13] = vol;
    sfxTrigger(slotBase);
}


void jlpAudioTone(uint16_t freqHz) {
    jlpAudioVoice(0u, freqHz, 0u);              // single full-volume tone on voice 0
}


// Frame-synced tick. The IIgs VBL is ~60 Hz and AGI sound is 60 Hz, so we
// ride the host's per-frame jlAudioFrameTick pump rather than install a
// raw VBL ISR (which would fight GS/OS heartbeat tasks). hz > 60 clamps to
// per-frame; lower rates divide down.
bool jlpAudioTickRegister(jlAudioTickFnT fn, uint16_t hz) {
    if (fn == (jlAudioTickFnT)0 || hz == 0u) {
        gTickFn = (jlAudioTickFnT)0;
        return false;
    }
    gTickFn     = fn;
    gTickReload = (hz >= IIGS_FRAME_HZ) ? 1u : (uint16_t)(IIGS_FRAME_HZ / hz);
    gTickCount  = gTickReload;
    return true;
}


void jlpAudioCriticalEnter(void) {
}


void jlpAudioCriticalExit(void) {
}


void jlpAudioStopMod(void) {
    if (!gNTPReady || !gNTPPlaying) {
        return;
    }
    buildCallStub(gNTPBase + 6, 0, 0, 0);
    __asm__ volatile (
        "jsl gCallStub\n\t"
        "rep #0x30"
        ::: "memory"
    );
    gNTPPlaying = false;
}


void jlpAudioStopSfx(uint8_t slot) {
    uint8_t firstOsc;
    uint8_t i;

    if (!gNTPReady || slot >= JOEY_AUDIO_SFX_SLOTS) {
        return;
    }

    firstOsc = (uint8_t)(SFX_BASE_OSC + slot * SFX_OSCS_PER_SLOT);

    // NTP has no SFX-stop entry point. We poke DOC oscillator control
    // registers directly to halt the slot's osc pair. SEI/CLI keeps
    // NTP's DOC IRQ from racing our sound_address writes.
    __asm__ volatile ("sei" ::: "memory");
    *DOC_SOUND_CONTROL = *DOC_IRQ_VOLUME;
    for (i = 0; i < SFX_OSCS_PER_SLOT; i++) {
        *DOC_SOUND_ADDRESS = (uint8_t)(DOC_OSC_CTRL_BASE + firstOsc + i);
        *DOC_SOUND_DATA    = DOC_OSC_HALT_BITS;
    }
    __asm__ volatile ("cli" ::: "memory");
}


void jlpAudioFrameTick(void) {
    // NTP is DOC-IRQ driven; nothing to pump there. But a registered audio
    // tick (jlpAudioTickRegister) rides this per-frame hook to re-assert PSG
    // voices and advance AGI-style sound at up to IIGS_FRAME_HZ.
    if (gTickFn != (jlAudioTickFnT)0) {
        gTickCount--;
        if (gTickCount == 0u) {
            gTickCount = gTickReload;
            gTickFn();
        }
    }
}
