joeylib2/docs/ApiAudio.adoc
2026-10-13 14:01:46 -05:00

554 lines
16 KiB
Text

== Audio
Declared in `<joey/audio.h>`.
There are three layers, and on two platforms they share physical hardware:
* *Module playback* -- `jlAudioPlayMod`, four-channel Protracker `.MOD`.
* *Chip tracker* -- `jlMusicPlay`, a portable event stream driving three tone
voices plus noise at register-write cost, with no CPU mixing. The recommended
music path.
* *Voices and effects* -- `jlAudioVoice`, `jlAudioNoise`, `jlAudioPlaySfx` and
the streaming forms.
[WARNING]
====
On the Atari ST and Amiga the tone voices *are* the hardware the module player
drives. While a `.MOD` plays, `jlAudioVoice` writes fight the player's register
writes and lose. Choose the module layer or the voice and tracker layer per
scene, never both at once.
====
A failed `jlAudioInit` is not fatal: `jlInit` still succeeds and every audio
call becomes a silent no-op.
=== Constants
[cols="3,1,4", options="header"]
|===
|Name |Value |Meaning
|`JOEY_AUDIO_SFX_SLOTS` |5 |Sound effect slots, 0..4.
|`JOEY_AUDIO_VOICES` |3 |PSG-style tone voices, 0..2.
|`JL_SFX_RAW_SIGNED8` |0 |Raw stream format: `int8_t` PCM.
|`JL_SFX_RAW_UNSIGNED8_NOZERO` |1 |Raw stream format: `uint8_t` biased 0x80,
and 0x00 is forbidden (it is the DOC stop byte).
|===
[TIP]
====
Prefer *slot 4* for game sound effects when music is playing. The IIgs maps
tracker tone voices onto slots 0..2 and the noise voice onto slot 3. On the
other ports the slots are independent of the voice layer.
====
=== jlAudioInit
[source,c]
----
bool jlAudioInit(void);
----
*Parameters:* none.
*Returns:* `true` if the platform has a working audio engine and started it;
`false` silently otherwise, with the rest of the API still callable but silent.
=== jlAudioShutdown
[source,c]
----
void jlAudioShutdown(void);
----
Tears the engine down. Safe when audio was never initialised.
*Parameters:* none. *Returns:* nothing.
=== jlAudioFrameTick
[source,c]
----
void jlAudioFrameTick(void);
----
Hooks the engine into your game loop. Most platforms drive their engines from a
hardware interrupt and ignore this, but it is required for any port that mixes
in user context, it advances tracker playback, and on IIgs and Amiga it is what
fires the next chunk of a streaming sound effect.
Call it once per frame, unconditionally.
*Parameters:* none. *Returns:* nothing.
=== jlAudioPlayMod
[source,c]
----
void jlAudioPlayMod(const uint8_t *data, uint32_t length, bool loop);
----
Begins module playback. If a module is already playing it is replaced.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`data` |`const uint8_t *` |Platform-native module blob: `.mod` on most ports,
`.amod` on Amiga, `.ntp` on IIgs. The asset pipeline produces the right form.
|`length` |`uint32_t` |Blob size in bytes.
|`loop` |`bool` |`true` plays forever.
|===
*Returns:* nothing.
[NOTE]
====
`loop = false` needs help on the Amiga: PTPlayer has no native song-end signal,
so the module must contain an `E8FF` marker at song end. The `joeymod` tool's
`.amod` output injects it. A plain `.mod` with no marker loops anyway on Amiga.
====
=== jlAudioStopMod
[source,c]
----
void jlAudioStopMod(void);
----
Stops the current module and resets the playhead, so the next `jlAudioPlayMod`
starts from the top.
*Parameters:* none. *Returns:* nothing.
=== jlAudioIsPlayingMod
[source,c]
----
bool jlAudioIsPlayingMod(void);
----
*Parameters:* none.
*Returns:* `true` while a module is producing output. `false` after a stop,
before the first play, and on platforms where audio init failed.
=== jlAudioPlaySfx
[source,c]
----
void jlAudioPlaySfx(uint8_t slot, const uint8_t *sample,
uint32_t length, uint16_t rateHz);
----
Triggers a one-shot digital sound effect. If the slot is already playing, the
new sample replaces it.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`slot` |`uint8_t` |Slot 0..`JOEY_AUDIO_SFX_SLOTS`-1. Prefer 4 alongside music.
|`sample` |`const uint8_t *` |Raw signed 8-bit PCM.
|`length` |`uint32_t` |Sample count in bytes.
|`rateHz` |`uint16_t` |The rate the sample was recorded at; the engine
re-pitches to its own output rate.
|===
*Returns:* nothing.
[NOTE]
====
The full -128..127 range is accepted everywhere, but the IIgs plays -128 as
-127: its DOC treats the corresponding byte as a wave-end marker and would halt
the voice. One level in 256, inaudible in practice.
====
=== jlAudioStreamFillT
[source,c]
----
typedef uint32_t (*jlAudioStreamFillT)(void *ctx, int8_t *dst, uint32_t count);
----
Stream refill callback. Write up to `count` signed 8-bit samples to `dst` and
return how many you wrote. Returning 0 signals end of stream and the slot
deactivates.
[WARNING]
====
This is called from the audio engine's refill context -- the main thread on DOS,
ST and SDL3 (on DVX, DVX's own sound pump, while the program is yielded), but the
*audio interrupt* on Amiga and IIgs. It must not block and must not allocate.
====
=== jlAudioPlaySfxStream
[source,c]
----
void jlAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill,
void *ctx, uint16_t rateHz);
----
Plays a streaming sound effect, pulling samples from your callback as the
slot's prefetch buffer drains. Sound effects can therefore be arbitrarily long
with no caller-side allocation.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`slot` |`uint8_t` |Slot 0..4. Replaces whatever is playing there.
|`fill` |`jlAudioStreamFillT` |Refill callback.
|`ctx` |`void *` |Opaque pointer handed back to the callback.
|`rateHz` |`uint16_t` |Rate your callback produces; the engine resamples with
linear interpolation.
|===
*Returns:* nothing.
[IMPORTANT]
====
On IIgs and Amiga the engine plays one chunk at a time and fires the next from
`jlAudioFrameTick`, so *you must be calling `jlAudioFrameTick` every frame* or
the stream stops after its first chunk. A chunk seam on those ports can carry
up to one frame of jitter.
====
=== jlAudioSfxRawFormat / jlAudioPlaySfxStreamRaw
[source,c]
----
uint8_t jlAudioSfxRawFormat(void);
void jlAudioPlaySfxStreamRaw(uint8_t slot, jlAudioStreamFillT fill,
void *ctx, uint16_t rateHz);
----
`jlAudioSfxRawFormat` returns this port's raw stream format, either
`JL_SFX_RAW_SIGNED8` or `JL_SFX_RAW_UNSIGNED8_NOZERO`.
`jlAudioPlaySfxStreamRaw` is `jlAudioPlaySfxStream` except your callback
delivers bytes *already in that format*, 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, and it is enough to stall a game for a third of a
second per chunk of speech. Every other port streams signed PCM natively, so
there this is simply the plain call.
If your producer cannot make the port's format, use `jlAudioPlaySfxStream`.
*Parameters:* as `jlAudioPlaySfxStream`. *Returns:* nothing.
=== jlAudioSetPan
[source,c]
----
typedef enum {
AUDIO_PAN_LEFT = 0, AUDIO_PAN_CENTER, AUDIO_PAN_RIGHT, AUDIO_PAN_COUNT
} jlAudioPanE;
void jlAudioSetPan(uint8_t slot, jlAudioPanE pan);
----
Places a sound effect slot in the stereo field. Slots start centred, and the
setting sticks to the *slot*, surviving retriggers -- so set it once for a
sound that should come from one side.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`slot` |`uint8_t` |Slot 0..4.
|`pan` |`jlAudioPanE` |Placement. Out-of-range values are ignored.
|===
*Returns:* nothing.
What each port can actually do:
[cols="1,4", options="header"]
|===
|Platform |Behaviour
|IIgs |All three. The DOC pans per oscillator, so a centred sound plays on two
oscillators, one per side. A stock IIgs sums to mono, so this shows up only on
a stereo card or an emulator.
|Amiga |Left and right only, by choosing the Paula channel side. Paula cannot
place one voice in both ears, so `AUDIO_PAN_CENTER` leaves PTPlayer's own
channel choice alone.
|X68000 |Ignored. Everything mixes into one ADPCM stream, so panning would move
all audio at once.
|DOS, ST |Ignored. Output is mono.
|DVX |All three, through DVX's stereo mixer (on a stereo card).
|SDL3 |All three: the port mixes in stereo.
|===
=== jlAudioStopSfx
[source,c]
----
void jlAudioStopSfx(uint8_t slot);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`slot` |`uint8_t` |Slot to silence. A no-op if already idle.
|===
*Returns:* nothing.
=== jlAudioTone
[source,c]
----
void jlAudioTone(uint16_t freqHz);
----
Direct hardware tone generator: plays a square wave continuously until the next
call. Designed for AGI-era music where the engine programs a divisor and lets
the chip play, so CPU cost during playback is zero. Single voice; uses voice 0
on platforms with several oscillators. On DOS this programs PIT counter 2 and
the speaker gate; on DVX it is a square tone of DVX's sound library, on the FM
chip when there is one; on SDL3 it is voice 0 of the port's mixer at full
volume.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`freqHz` |`uint16_t` |Frequency in Hz. 0 silences the generator.
|===
*Returns:* nothing. A no-op on platforms with no reachable tone generator --
fall back to `jlAudioPlaySfxStream` there if you need synthesised sound.
=== jlAudioVoice
[source,c]
----
void jlAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten);
----
Three-voice PSG-style polyphonic tone generator.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`voice` |`uint8_t` |Voice 0, 1 or 2.
|`freqHz` |`uint16_t` |Frequency in Hz. 0 silences this voice.
|`atten` |`uint8_t` |AGI / SN76489-style 4-bit attenuation: 0 loudest,
15 silent, about 2 dB per step.
|===
*Returns:* nothing.
Mapping to hardware: DOS uses AdLib OPL2 melodic channels 0..2; the ST uses
YM2149 channels A, B and C; the Amiga uses Paula channels 0..2 looping a
two-byte square sample; the IIgs uses Ensoniq DOC oscillators playing a
resident square; DVX plays square tones of its sound library, which puts them
on the FM chip when there is a free voice and mixes them otherwise; SDL3 mixes
square waves in software.
Voice state is sticky -- a voice keeps its last frequency until you change it
-- and CPU cost during sustained playback is zero, because the hardware
oscillates on its own.
[IMPORTANT]
====
The IIgs is the exception to stickiness: each call is an approximately 80 ms
retriggered one-shot, so a note held longer must be re-asserted about once a
frame. It also requires `jlAudioInit`, because the DOC path rides the tracker
engine. On DOS, ST and Amiga this is direct hardware programming and works
without `jlAudioInit`; on DVX it opens the program's sound on first use. On
SDL3 the voices are mixed in software and sound only after `jlAudioInit`.
====
=== jlAudioNoise
[source,c]
----
void jlAudioNoise(uint8_t pitch, uint8_t atten);
----
Noise and percussion generator -- the fourth logical voice of the tracker
layer.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`pitch` |`uint8_t` |0..31 in YM2149 noise-period style: 0 is the brightest
hiss (hats), 31 the lowest rumble (toms).
|`atten` |`uint8_t` |As `jlAudioVoice`. 15 or above silences.
|===
*Returns:* nothing.
Mapping: the ST uses the YM2149 noise generator mixed onto channel C; DOS uses
the OPL2 rhythm-mode snare, leaving the three tone channels untouched; the
Amiga uses Paula channel 3 with an LFSR noise sample; the IIgs uses a resident
noise sample on slot 3 with the same retriggered one-shot model as its tone
voices; DVX uses a noise tone of its sound library; SDL3 mixes the YM2149's
own noise in software (its 17-bit generator, clocked at 125 kHz divided by
the pitch), so it sounds as on the ST.
[WARNING]
====
On the ST, noise shares channel C's volume register with tone voice 2. Author
noise hits and voice-2 notes so they do not sound simultaneously; `songbake`
warns when they overlap.
====
=== jlMusicPlay
[source,c]
----
bool jlMusicPlay(const uint8_t *data, uint32_t length, bool loop);
----
Plays a JYM1 event stream baked by `songbake` through the tone and noise layer.
One authored song plays on every port at register-write cost. Because there
is no CPU mixing, this is fully viable on the ST and cheap on DOS, which
`.MOD` playback is not.
Playback is pumped by `jlAudioFrameTick` and paced by `jlMillisElapsed`, so
tempo holds across 50, 60 and 70 Hz ports.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`data` |`const uint8_t *` |JYM1 stream. *Read in place* -- it must stay valid
until `jlMusicStop` or song end.
|`length` |`uint32_t` |Stream size in bytes.
|`loop` |`bool` |`true` restarts at the authored loop point, or the top if the
song has none. `false` silences the voices at song end.
|===
*Returns:* `true` on success; `false` on a malformed stream, in which case
nothing plays.
[NOTE]
====
Call `jlAudioInit` first for the song to be audible on the IIgs -- see
`jlAudioVoice`.
====
=== jlMusicStop
[source,c]
----
void jlMusicStop(void);
----
Stops tracker playback and silences tones and noise. The playhead is discarded;
use `jlMusicPause` to keep it. Safe when nothing is playing.
*Parameters:* none. *Returns:* nothing.
=== jlMusicIsPlaying
[source,c]
----
bool jlMusicIsPlaying(void);
----
*Parameters:* none.
*Returns:* `true` while a stream is loaded and active. Paused counts as active;
stopped or finished does not.
=== jlMusicPause / jlMusicResume
[source,c]
----
void jlMusicPause(void);
void jlMusicResume(void);
----
Pauses in place -- voices go silent, the playhead keeps its position -- and
picks up where it left off. The current row restarts, and wall-clock time spent
paused is not fast-forwarded through. Both are safe no-ops when there is no
active song.
*Parameters:* none. *Returns:* nothing.
=== jlMusicSetAtten
[source,c]
----
void jlMusicSetAtten(uint8_t atten);
----
Global music attenuation, added to every authored voice and noise attenuation.
Applies immediately to sounding voices and to everything played afterwards.
Step it over successive frames for fades.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`atten` |`uint8_t` |0 plays as authored, 15 is silent.
|===
*Returns:* nothing.
[NOTE]
====
`jlMusicPlay` does *not* reset this, so a fade-out carries into the next song
unless you clear it.
====
=== jlAudioTickRegister
[source,c]
----
typedef void (*jlAudioTickFnT)(void);
bool jlAudioTickRegister(jlAudioTickFnT fn, uint16_t hz);
----
Registers a periodic callback for sound schedulers needing better than
frame-rate resolution. PSG-era music drivers use this to write register changes
at exact note boundaries: frame-rate scheduling batches several notes into one
frame, so the chip only hears the last write and short notes vanish.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`fn` |`jlAudioTickFnT` |Callback, fired from interrupt context. Pass `NULL` to
uninstall.
|`hz` |`uint16_t` |Firings per second.
|===
*Returns:* `true` if installed. Ports without a suitable timer source return
`false`.
[WARNING]
====
`fn` runs in interrupt context. Keep it to memory reads and direct hardware
register writes -- no allocation, no `printf`, nothing blocking.
====
On DOS this reprograms PIT counter 0 and hooks INT 8, keeping the BIOS tick
counter advancing at the canonical 18.2 Hz through an accumulator so `time`
stays correct. `jlMillisElapsed` carries on from where it was when the tick
starts or stops; it never jumps back.
On DVX a timer of DVX's (at most 1000 Hz) counts the ticks, and `fn` runs in
program time, once per counted tick, at the next `jlAudioFrameTick`: DVX's
sound library, which `jlAudioVoice` and the rest go through there, may not be
called from an interrupt. `fn` may therefore do anything, but what it does
lands on the frame, not between frames.
On SDL3 the sound is mixed in program time, a few frames ahead, and `fn` is
called by the mixer between exactly the two samples where its tick falls: in
program time, so it may do anything, yet a note it starts begins at the right
place in the sound. `hz` may be anything up to the mixer's 44100.
=== jlAudioCriticalEnter / jlAudioCriticalExit
[source,c]
----
void jlAudioCriticalEnter(void);
void jlAudioCriticalExit(void);
----
Critical section against the tick interrupt. If you mutate state that the
registered tick callback reads, wrap the mutation in these so the interrupt
cannot observe a half-written value. On DOS this is `cli` and `sti`; on ports
with no audio interrupt, and on DVX and SDL3, where the callback runs in
program time, both are no-ops.
*Parameters:* none. *Returns:* nothing.