// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // // 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, mixed by JoeyLib's module player (PTPlayer's // replay in C) into SB DMA (22 kHz 8-bit, stereo on an // SB16). // Atari ST -- raw .MOD, mixed by the module player at 12288 Hz and // played through the YM2149's channel A volume (PWM); the // STe build plays it in stereo through DMA sound at // 12517 Hz. // X68000 -- raw .MOD, mixed by the module player at 7812 Hz into the // MSM6258 ADPCM channel. // DVX -- raw .MOD through DVX's music library. // hosted -- raw .MOD, mixed by the module player in software. // // Game code always calls the same entry points; the per-port HAL hides // the engine choice. jlInit starts audio (jlConfigT.noAudio leaves it // off) and jlShutdown stops it. Sound that does not start is non-fatal: // jlInit still succeeds and audio calls become no-ops. // // The mix is the same on every port, within 3 dB: a sound effect at full // scale, and a module channel at full volume playing a full-scale sample, // are each as loud as a tone voice at attenuation 0 (a square wave sampled // at full scale peaks where the voice does), and the noise at attenuation 0 // is as loud as a voice. The ports that mix in software put that peak at a // quarter of full scale, so the three voices and the noise together, or a // module's four channels, fit; the plain ST puts it at half of its one // sound chip channel, so a module whose three or four channels peak at once // clips there. DOS keeps the mix on the Sound Blaster's mixer, which the // cards before the SB Pro do not have: there effects and modules play about // 5 dB over the voices. #ifndef JOEYLIB_AUDIO_H #define JOEYLIB_AUDIO_H #include "platform.h" #include "types.h" // SFX slot convention: the IIgs plays tone voices 0-2 on SFX slots 0-2 // and the noise voice on slot 3 (a voice and an effect on one slot cut // each other off, and a held voice takes its slot back at the next // jlAudioFrameTick), so applications that also play digital SFX while // music runs should prefer slot 4. On the Amiga effects, the module, the // voices, and the noise share Paula's four channels. On the other ports // the slots are independent of the voice layer. #define JL_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 < JL_AUDIO_VOICES. #define JL_AUDIO_VOICES 3 // Begin module playback. data points at the platform-native module // blob (.MOD on every platform but the IIgs, .NTP there); 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 on every port. // PTPlayer (Amiga) has no native song-end signal, so the Amiga HAL adds an // E8F stop marker to the module as it copies it to chip RAM; a module // with no room for one (all 128 song positions or patterns in use) loops // there anyway. The `joeymod` tool's .amod output adds the same marker // ahead of time. void jlAudioPlayMod(const uint8_t *data, uint32_t length, bool loop); // Stop the current module (if any), silent at once on every port (DOS and // the X68000 take it back out of what they have mixed ahead of the chip). // 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, once a loop=false song has ended, 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..JL_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, any rate at its pitch (the IIgs above 26 kHz and the // Amiga above 28.6 kHz skip samples, so a fast one loses its top there). If // the slot is currently playing, the new sample // replaces it. Most ports read the sample in place while it plays, so it // must stay valid and unchanged until it has finished or the slot is // stopped; the IIgs keeps a copy per slot keyed by address, length, and // rate, so changed bytes replayed with all three the same are not heard. // // 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 in // program time on every port, from the call that starts the stream and // then from jlAudioFrameTick (on DVX also while the desktop runs); must // not block, and should be quick. 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/hosted pull through the // shared SFX overlay mixer as jlAudioFrameTick refills their sound // buffers, while the IIgs (NTPstreamsound, 7 KB chunks) and Amiga // (PTPlayer mt_playfx, 4 KB chunks) play one chunk at a time and fire the // next from jlAudioFrameTick as the current one runs down -- so the host // must be calling jlAudioFrameTick for a stream to continue, and on those // two 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 costs ~7 us a byte, ~50 ms per 7 KB chunk. DVX takes the // IIgs's format too (its sound library's 8-bit PCM is unsigned) and skips // its flip. Every other port's raw format is signed PCM, 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 { JL_AUDIO_PAN_LEFT = 0, JL_AUDIO_PAN_CENTER, JL_AUDIO_PAN_RIGHT, JL_AUDIO_PAN_COUNT } jlAudioPanE; // Place a SFX slot in the stereo field. Slots start at JL_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). A channel plays on one side only, so a // centred sound plays on a free channel each side when both sides // have one, and otherwise on the one channel PTPlayer picks. // X68000 ignored. Everything is mixed into ONE MSM6258 ADPCM stream, so // its pan bits would move all audio at once, not one slot. // DOS all three on an SB16, the module's channels on Paula's sides; // ignored on the Sound Blasters before it, which play mono. // ST ignored -- its output is mono. The STe build plays all three // (its DMA chip plays stereo), the module's as on DOS. // DVX all three, through DVX's stereo mixer. // hosted all three (the mix is stereo), except Win16, which 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); // A single tone at `freqHz` until the next call: jlAudioVoice(0, freqHz, // 0), tone voice 0 at full volume, on every port. freqHz == 0 silences // it. 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. It shares voice 0 with jlAudioVoice and jlMusicPlay. On DOS // without an OPL2, voice 0 (and so this tone) is the PC speaker. 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 `hz` times per second on average, on every port, and may // fire from an interrupt (DOS, Amiga, ST, X68000): keep fn short, only // memory reads and direct hardware-register writes, no malloc / printf / // blocking, and share data with it through jlAudioCritical*. Pass // fn = NULL to uninstall (always returns true). Returns false when hz is // 0, audio is off or did not start, or the port cannot take the rate. // // 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). The Amiga uses a timer.device E-clock request and a // software interrupt; the ST and X68000 ride the system's Timer C // interrupt (200 and 100 Hz), so calls due between its ticks come // together. The IIgs calls fn in program time from jlAudioFrameTick, // and DVX and the hosted ports from the mixer, between the samples where // each tick falls. 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. Sections nest: the outermost pair holds the interrupt off // (on DOS, the Amiga, the ST, and the X68000, every interrupt, so keep // it short), and its Exit puts back the interrupt state its Enter found. // No-ops where the callback runs in program time (IIgs, DVX, hosted). 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, as near as the chip comes: the ST's YM2149 levels are // ~3.5 dB apart, so within 1.6 dB there). The mapping to underlying // hardware: // // DOS -- AdLib OPL2 melodic channels 0/1/2 (ports 0x388/0x389); // with no OPL2, voice 0 is the PC speaker, 1 and 2 silent. // Atari ST -- YM2149 PSG channels A/B/C (plain ST: voice 0 silent while // digital sound plays, which drives channel A). // Amiga -- Paula channels 0/1/2 looping a 2-byte square sample. // IIgs -- Ensoniq DOC oscillators playing a resident square on SFX // slots 0/1/2. // X68000 -- YM2151 channels 0/1/2, one sine operator each. // DVX -- square waves mixed in software into a stream of DVX's // mixer (DVX's own square tones with only the PC speaker). // hosted -- square waves mixed in software. // // A note sounds until the next call for that voice. The IIgs plays a // note as a short sample and restarts held notes from jlAudioFrameTick, // so call that every frame. On the Amiga the voices are the channels the // MOD engine drives, so while a MOD is playing, voice writes fight the // player's 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); the noise runs at // 125000 / pitch Hz (pitch 0 as 1) as far as each chip goes (the Amiga // tops out at 28.6 kHz and the IIgs at 26 kHz, so pitches 0-4 sound // alike there). 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 -- the YM2149's 17-bit generator, mixed into the Sound // Blaster's stream (~22 kHz, so pitches 0-5 sound // alike). An OPL2 without a Sound Blaster plays its // rhythm-mode snare drum instead, a tuned drum whose // pitch spreads over the snare's range. // 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. // X68000 -- the YM2151's noise generator, at the rate exactly. // DVX -- the YM2149's 17-bit generator, mixed in software (a // noise tone of DVX's sound library with only the PC // speaker). // hosted -- the YM2149's 17-bit generator, mixed in software. // // The same contract as jlAudioVoice: it sounds until the next call. 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 every port at register-write cost on the // retro machines' chips (DOS's noise and the DVX and hosted ports mix in // software), 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. 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; call it once per frame. Every // port does sound work here: DOS, ST, X68000, DVX, and hosted mix or // refill their sound buffers (modules and effects stop or repeat // without it), the IIgs and Amiga feed streams and notice a module's // end (and the IIgs holds notes), and every port advances jlMusicPlay. void jlAudioFrameTick(void); #endif