// Audio: 4-channel Protracker .MOD music with digital one-shot SFX. // // Authors compose music as .MOD modules. The host-side asset pipeline // (tools/joeymod) converts each module into the runtime form the // target platform expects: // // Apple IIgs -- NinjaTrackerPlus .NTP, played by Ninjaforce's // 65816 replayer linked into the binary. // Amiga -- raw .MOD, played by Frank Wille's PTPlayer. // DOS -- raw .MOD, played by libxmp-lite over SB DMA. // Atari ST -- raw .MOD, played by libxmp-lite (parser only) plus // a hand-rolled 68k 4-channel mixer that outputs via // YM2149 4-bit PWM at ~12.5 kHz. // // Game code always calls the same five entry points; the per-port // HAL hides the engine choice. A failed audio init is non-fatal -- // jlInit still succeeds and audio calls become no-ops. #ifndef JOEYLIB_AUDIO_H #define JOEYLIB_AUDIO_H #include "platform.h" #include "types.h" // SFX slot convention: the IIgs maps tracker tone voices onto SFX // slots 0-2 and the tracker noise voice onto slot 3, so applications // that also play digital SFX while music runs should prefer slot 4. // On the other ports the slots are independent of the voice layer. #define JOEY_AUDIO_SFX_SLOTS 5 // Number of PSG-style polyphonic tone voices (jlAudioVoice selects // voice 0/1/2). The core gate range-checks against this so each // per-port HAL can assume voice < JOEY_AUDIO_VOICES. #define JOEY_AUDIO_VOICES 3 // Initialize audio. Returns true if the platform has a working audio // engine and was able to start it. Returns false silently otherwise; // the rest of the API stays callable but produces no sound. bool jlAudioInit(void); // Tear down the engine. Safe to call when audio is not initialized. void jlAudioShutdown(void); // Begin module playback. data points at the platform-native module // blob (.MOD on most platforms, .NTP on IIgs); the asset pipeline // produces the right form for each target. If a module is already // playing, it is replaced. // // loop=true plays forever. loop=false stops at song end, but Amiga // requires the module to contain an E8FF effect at song end for the // stop to fire (PTPlayer has no native song-end signal). The // `joeymod` tool's .amod output extension injects that marker // automatically; ship .amod for Amiga and .mod for the other ports. // loop=false on a .mod (no E8FF) loops anyway on Amiga. void jlAudioPlayMod(const uint8_t *data, uint32_t length, bool loop); // Stop the current module (if any). The playhead is reset so the next // jlAudioPlayMod starts from the top. void jlAudioStopMod(void); // True if a module is currently producing output (false during silence // after StopMod or before the first PlayMod, and on platforms where // audio init failed). bool jlAudioIsPlayingMod(void); // Trigger a one-shot digital SFX on the given slot (0..JOEY_AUDIO_SFX_SLOTS-1). // sample points at raw signed 8-bit PCM. rateHz is the playback rate // the sample was recorded at; the engine pitches as needed for its // own output rate. If the slot is currently playing, the new sample // replaces it. // // The full -128..127 range is accepted everywhere, but the IIgs plays // -128 as -127: its DOC reads 0x00 (which is what -128 becomes in the // chip's unsigned format) as a wave-end marker and would halt the voice // on that sample. One level out of 256, inaudible in practice. void jlAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz); // Stream-fill callback signature. Called by the mixer to refill a // streaming slot's prefetch buffer; should write up to `count` signed // 8-bit samples to `dst` and return the number written (see // jlAudioPlaySfx for the one clamp the IIgs applies). Returning 0 // signals end-of-stream and the slot auto-deactivates. Called from // the same context as the audio engine's refill (main thread on // DOS/ST, audio ISR on Amiga/IIgs); must not block or allocate. typedef uint32_t (*jlAudioStreamFillT)(void *ctx, int8_t *dst, uint32_t count); // Trigger streaming SFX playback on the given slot. The mixer pulls // samples from `fill(ctx, ...)` as the slot's internal prefetch // buffer drains, so SFX can be arbitrarily long with no caller-side // allocation. `rateHz` is the sample rate `fill` produces; the // engine resamples (with linear interpolation) to its own output // rate. If the slot is currently playing, the new stream replaces // it. Implemented on every port: DOS/ST/X68000 pull through the shared // SFX overlay mixer, while the IIgs (NTPstreamsound) and Amiga // (PTPlayer mt_playfx) play one chunk at a time and fire the next from // jlAudioFrameTick as the current one runs down -- so on those two the // host must be calling jlAudioFrameTick for a stream to continue past // its first chunk, and a seam can carry up to one frame of jitter. void jlAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz); // Raw-format streaming: jlAudioPlaySfxStream, except `fill` delivers // bytes already in the port's raw stream format (jlAudioSfxRawFormat), // so the port skips its per-byte conversion of every chunk. On the IIgs // that conversion is a 65816 byte loop over each 3.5 KB chunk -- it // stalled Space Taxi's ride for a third of a second per chunk of speech. // Every other port streams signed PCM natively, so there Raw is the // plain call. A producer that cannot make the port's format uses // jlAudioPlaySfxStream. #define JL_SFX_RAW_SIGNED8 0u // int8_t PCM, the jlAudioPlaySfxStream contract #define JL_SFX_RAW_UNSIGNED8_NOZERO 1u // uint8_t PCM biased 0x80; 0x00 is forbidden (DOC stop byte) uint8_t jlAudioSfxRawFormat(void); void jlAudioPlaySfxStreamRaw(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz); // Stereo placement for a SFX slot. typedef enum { AUDIO_PAN_LEFT = 0, AUDIO_PAN_CENTER, AUDIO_PAN_RIGHT, AUDIO_PAN_COUNT } jlAudioPanE; // Place a SFX slot in the stereo field. Slots start at AUDIO_PAN_CENTER, and // the setting sticks to the SLOT, so it survives retriggers -- set it once // when a sound is meant to come from one side. // // What each port can actually do, because the hardware differs: // IIgs all three. The DOC pans per OSCILLATOR (control bits 7:4 are the // output channel, and per Apple TN #19 odd = left, even = right), // so a centred sound is played on two oscillators, one per side. // A stock IIgs sums to mono anyway; this only shows up on a stereo // card or an emulator. // Amiga left and right, by picking the Paula channel side (0 and 3 are // left, 1 and 2 right). Paula cannot place one voice in both ears, // so AUDIO_PAN_CENTER keeps PTPlayer's own channel choice. // X68000 ignored. Everything is mixed into ONE MSM6258 ADPCM stream, so // its pan bits would move all audio at once, not one slot. // DOS, ST ignored -- their output is mono. // Passing an out-of-range pan is ignored. void jlAudioSetPan(uint8_t slot, jlAudioPanE pan); // Stop a SFX slot early. No-op if the slot is already idle. void jlAudioStopSfx(uint8_t slot); // Direct hardware tone generator: continuously play a square wave at // `freqHz` until the next jlAudioTone call. freqHz == 0 silences the // generator. Designed for AGI-era PSG-style music where the engine // just programs a divisor and lets the chip play; CPU cost during // playback is zero. Single voice on platforms with multiple // hardware oscillators (uses voice 0). On DOS this programs PIT // counter 2 and the speaker gate (port 0x61). No-op on platforms // without a reachable tone generator -- callers should fall back to // jlAudioPlaySfxStream when they need PCM-synth sound on those. void jlAudioTone(uint16_t freqHz); // Periodic tick callback for sound schedulers that need better-than- // frame-rate resolution. Used by PSG-era music drivers (AGI, AdLib // patches, etc.) to write hardware register changes at exact note // boundaries -- frame-rate-driven scheduling batches multiple notes // into one render frame, so the chip only "hears" the last write // per batch and short notes are dropped. // // fn fires from interrupt context at `hz` times per second. Keep // fn short: only memory reads, direct hardware-register writes, // no malloc / printf / blocking. Pass fn = NULL to uninstall. // // Per-port impl: DOS reprograms PIT counter 0 + hooks INT 8, // keeping the BIOS tick counter at $0040:$006C advancing at the // canonical 18.2 Hz via an accumulator (so uclock / time stay // correct). Other ports return false until a port-appropriate // timer source is wired. typedef void (*jlAudioTickFnT)(void); bool jlAudioTickRegister(jlAudioTickFnT fn, uint16_t hz); // Critical section against the tick ISR. Callers that mutate // state read by the registered tick callback must wrap the // mutation in Enter/Exit so the ISR can't observe a half-written // state. On DOS this is `cli` / `sti`; on ports without an // audio ISR these are no-ops. void jlAudioCriticalEnter(void); void jlAudioCriticalExit(void); // 3-voice PSG-style polyphonic tone generator. `voice` selects 0/1/2; // `freqHz` is the new tone (0 = silence this voice); `atten` is the // AGI/SN76489-style 4-bit attenuation, 0 = loudest .. 15 = silent // (2 dB per step). The mapping to underlying hardware: // // DOS -- AdLib OPL2 melodic channels 0/1/2 (ports 0x388/0x389). // Atari ST -- YM2149 PSG channels A/B/C. // Amiga -- Paula channels 0/1/2 looping a 2-byte square sample. // IIgs -- Ensoniq DOC oscillators playing a resident square, // retriggered as an ~80 ms one-shot per call. Needs // jlAudioInit (the DOC path rides the NTP engine); // callers that hold a note longer than ~80 ms must // re-assert it about once a frame (jlMusicPlay's // sequencer and AGI's per-frame driver both do). // // Voice state is sticky: a voice keeps playing its last frequency // until the next jlAudioVoice call on it (IIgs excepted, above). CPU // cost during sustained playback is zero (the hardware oscillates on // its own). Safe to call without jlAudioInit on DOS/ST/Amiga -- the // voice path is direct hardware programming, independent of the // MOD/SFX mixer. On ST and Amiga the voice channels are the same // hardware the MOD engine drives: while a MOD is playing, voice // writes fight the player's register writes and lose. Use the voice/ // tracker layer or the MOD layer per scene, not both at once. void jlAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten); // Noise/percussion generator, the 4th logical voice of the tracker // layer. `pitch` is 0..31 in YM2149 noise-period style: 0 = the // brightest hiss (hats), 31 = the lowest rumble (toms); atten as in // jlAudioVoice, and atten >= 15 silences. The per-port mapping: // // Atari ST -- the YM2149 noise generator (reg 6) mixed onto // channel C. Noise shares channel C's volume register // with tone voice 2: author noise and voice-2 notes // apart (songbake warns on overlap). // DOS -- OPL2 rhythm-mode snare drum (the chip's LFSR noise // source) on channel 7; the three tone channels are // untouched. // Amiga -- Paula channel 3 looping a chip-RAM LFSR noise sample // (channels 0-2 are the tone voices). // IIgs -- a resident LFSR noise sample on SFX slot 3, same // ~80 ms retriggered one-shot model as the tone voices // (jlMusicPlay's sequencer re-asserts it per frame). // // Same init contract as jlAudioVoice: direct hardware programming on // DOS/ST/Amiga, jlAudioInit required on the IIgs. void jlAudioNoise(uint8_t pitch, uint8_t atten); // Portable chip-tracker music: play a JYM1 event stream (baked from // tools/songbake text notation) through the jlAudioVoice tone layer. // One authored song plays on all four ports at register-write cost -- // there is no CPU mixing, so unlike jlAudioPlayMod this path is fully // viable on the ST and cheap on DOS. The stream is read IN PLACE: // data must stay valid until jlMusicStop or song end. Playback is // pumped by jlAudioFrameTick (call it once per frame, as usual) and // paced by jlMillisElapsed, so tempo holds across 50/60/70 Hz ports. // loop=true restarts at the song's authored loop point (or the top // when it has none); loop=false silences the voices at song end. // Returns false (and plays nothing) on a malformed stream. Call // jlAudioInit first for IIgs audibility (see jlAudioVoice). bool jlMusicPlay(const uint8_t *data, uint32_t length, bool loop); // Stop tracker playback and silence the voices (tones and noise). // Safe to call when nothing is playing. The playhead is discarded; // use jlMusicPause to keep it. void jlMusicStop(void); // True while a jlMusicPlay stream is loaded and active (paused // counts as active; stopped/finished does not). bool jlMusicIsPlaying(void); // Pause playback in place: voices go silent, the playhead keeps its // position. jlMusicResume picks up where the song left off (the // current row restarts; wall-clock time spent paused is not // fast-forwarded through). Both are safe no-ops when there is no // active song. void jlMusicPause(void); void jlMusicResume(void); // Global music attenuation, added to every authored voice/noise // attenuation (0 = as authored .. 15 = silent). Applies immediately // to sounding voices and to everything played afterwards; jlMusicPlay // does NOT reset it, so a fade-out can carry into the next song. // Step it over successive frames for fades. void jlMusicSetAtten(uint8_t atten); // Hook the engine into the game loop. Most platforms drive their // engines off a hardware IRQ and ignore this call, but it's safe to // invoke once per frame regardless and required for any port that // runs its mixer in user-thread context. void jlAudioFrameTick(void); #endif