joeylib2/src/dvx/audio.c
2026-09-29 19:31:34 -05:00

491 lines
15 KiB
C

// DVX audio HAL: JoeyLib's sound through DVX's sound library (audio.lib)
// and music library (music.lib), which own the sound card for every
// program on the desktop and mix them all together.
//
// MOD music.lib (libxmp-lite inside DVX: no second copy here),
// loaded from memory; music.lib plays it through audio.lib.
// SFX five slots, each at most one sound: a one-shot is an
// audio.lib sample made from the program's signed 8-bit PCM
// (the top bit flipped: DVX's 8-bit samples are unsigned); a
// stream is an audio.lib pull stream whose fill calls the
// program's, flipping the same bit. JoeyLib's end of stream
// (a fill returning 0) closes the stream once what it queued
// has played. Pan sticks to the slot, as on every port.
// Tone, audio.lib tones that play until changed: jlAudioTone a
// voices, square, the three jlAudioVoice voices squares, jlAudioNoise
// noise a noise tone. DVX puts squares on the FM chip when there is
// one and mixes them otherwise, and keeps noise in the mixer.
//
// None of this may run inside an interrupt (audio.lib's rule), so
// jlAudioTickRegister's callback runs in program time: a DVX timer counts
// the ticks at the asked rate, and jlAudioFrameTick delivers the ones that
// came due, in order. A callback can therefore do anything; what DVX loses
// against DOS is the sub-frame placement of what it does. jlAudioCritical*
// stay the generic no-ops: nothing the callback touches can be interrupted
// by it.
//
// jlAudioFrameTick is also where DVX's music and streams are fed
// (musicPump, which pumps audio.lib too) when the program keeps the
// desktop busy. A context that could not be opened -- DVX without a sound
// library -- leaves everything silent; DVX without a sound card still opens
// one (its null driver plays nothing).
#include "dvxSdk.h"
#include <stdint.h>
#include <stdlib.h>
#include "port.h"
#include "joey/audio.h"
#include "dvxPort.h"
// ----- Constants -----
#define NO_VOICE 0 // audio.lib voice ids are > 0
#define NO_TIMER (-1)
// JoeyLib SFX are signed 8-bit; DVX's 8-bit PCM is unsigned.
#define PCM8_SIGN_FLIP 0x80u
#define SFX_BITS 8
#define SFX_CHANNELS 1
#define SFX_VOLUME AUDIO_VOLUME_MAX
// A stream's own buffer: DVX refills it every desktop frame or so.
#define STREAM_BUFFER_MS 100
// Tone voices at half scale: the three voices, the noise and the tone
// together stay clear of the mixer's clipping at full volume.
#define TONE_VOLUME (AUDIO_VOLUME_MAX / 2)
#define TONE_UNTIL_STOPPED 0
// Attenuation 15 (and up) is silence (joey/audio.h).
#define ATTEN_SILENT 15u
// jlAudioNoise pitch 0..31, spread linearly over the noise rates like the
// shared curve the other ports follow (26000 - pitch * 750 Hz, whose top
// audio.lib cannot reach): from the fastest rate a tone may have down to
// that curve's slowest.
#define NOISE_PITCH_MAX 31u
#define NOISE_CURVE_TOP_HZ 26000u
#define NOISE_CURVE_STEP_HZ 750u
#define NOISE_MAX_HZ AUDIO_TONE_MAX_HZ
#define NOISE_MIN_HZ (NOISE_CURVE_TOP_HZ - NOISE_PITCH_MAX * NOISE_CURVE_STEP_HZ)
// Pinned for the timer interrupt: the tick counter's code (one small
// function; a page is as fine a grain as pinning has) and data.
#define TICK_ISR_LOCK_BYTES 4096u
// ----- Types -----
// One SFX slot: a one-shot sample or a stream (or neither), its voice and
// its sticky pan.
typedef struct {
AudioSampleT *sample;
AudioStreamT *stream;
jlAudioStreamFillT fill;
void *fillCtx;
int32_t voice;
int32_t pan;
bool streamEnded;
} SfxSlotT;
// One tone channel (the tone, a voice, the noise): an audio.lib tone that
// plays until changed, or NO_VOICE while silent.
typedef struct {
AudioWaveE wave;
int32_t voice;
} ToneChannelT;
// ----- Prototypes -----
static AudioCtxT *audioContext(void);
static void sfxFormat(uint16_t rateHz, AudioFormatT *fmt, int32_t *pitch);
static void sfxStart(uint8_t slot, int32_t voice, uint16_t rateHz);
static void sfxStop(uint8_t slot);
static void streamEndReap(void);
static uint32_t streamFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData);
static void tickDeliver(void);
static void tickIsr(void *userData);
static void tickStop(void);
static void toneSet(ToneChannelT *channel, uint32_t hz, int32_t volume);
static int32_t voiceVolume(uint8_t atten);
// ----- Module state -----
static AudioCtxT *gCtx = NULL;
static MusicT *gMusic = NULL;
static SfxSlotT gSlots[JOEY_AUDIO_SFX_SLOTS];
// DVX pan for each JoeyLib pan.
static const int32_t gPanToDvx[AUDIO_PAN_COUNT] = {
[AUDIO_PAN_LEFT] = DVX_PAN_LEFT,
[AUDIO_PAN_CENTER] = DVX_PAN_CENTER,
[AUDIO_PAN_RIGHT] = DVX_PAN_RIGHT
};
static ToneChannelT gTone = { AUDIO_WAVE_SQUARE, NO_VOICE };
static ToneChannelT gVoices[JOEY_AUDIO_VOICES] = {
{ AUDIO_WAVE_SQUARE, NO_VOICE },
{ AUDIO_WAVE_SQUARE, NO_VOICE },
{ AUDIO_WAVE_SQUARE, NO_VOICE }
};
static ToneChannelT gNoise = { AUDIO_WAVE_NOISE, NO_VOICE };
// Volume for an SN76489 / AGI attenuation below silence: 2 dB per step,
// AUDIO_VOLUME_MAX * 10^(-atten / 10).
static const uint16_t gAttenVolume[ATTEN_SILENT] = {
256, 203, 162, 128, 102, 81, 64, 51, 41, 32, 26, 20, 16, 13, 10
};
// The converted copy of the last one-shot's PCM (audio.lib copies it again).
static uint8_t *gPcm = NULL;
static uint32_t gPcmSize = 0;
// jlAudioTickRegister: the callback, its DVX timer and the ticks the timer
// has counted that are not delivered yet.
static jlAudioTickFnT gTickFn = NULL;
static int32_t gTickTimer = NO_TIMER;
static volatile uint32_t gTicksDue = 0;
static bool gTickPinned = false;
// ----- Internal helpers (alphabetical) -----
// The program's audio context, opened on first use: the voice layer may be
// used without jlAudioInit, as on the other ports.
static AudioCtxT *audioContext(void) {
if (gCtx == NULL) {
gCtx = audioOpen();
}
return gCtx;
}
// A one-shot's PCM format, and the pitch that makes a rate audio.lib cannot
// take play right: it is created at the nearest rate it takes and played
// slower or faster.
static void sfxFormat(uint16_t rateHz, AudioFormatT *fmt, int32_t *pitch) {
uint32_t rate;
rate = rateHz < AUDIO_RATE_MIN ? AUDIO_RATE_MIN : (rateHz > AUDIO_RATE_MAX ? AUDIO_RATE_MAX : rateHz);
fmt->rate = rate;
fmt->bits = SFX_BITS;
fmt->channels = SFX_CHANNELS;
*pitch = (int32_t)(AUDIO_PITCH_NORMAL * (uint32_t)rateHz / rate);
}
// A slot's new voice: its pan, and its pitch for a rate audio.lib cannot
// take directly.
static void sfxStart(uint8_t slot, int32_t voice, uint16_t rateHz) {
AudioFormatT fmt;
int32_t pitch;
if (voice <= 0) {
return;
}
gSlots[slot].voice = voice;
sfxFormat(rateHz, &fmt, &pitch);
if (pitch != AUDIO_PITCH_NORMAL) {
(void)audioVoiceSetPitch(gCtx, voice, pitch);
}
(void)audioVoiceSetPan(gCtx, voice, gSlots[slot].pan);
}
// Stop a slot: its stream closed or its sample freed (either stops the
// voice).
static void sfxStop(uint8_t slot) {
SfxSlotT *s;
s = &gSlots[slot];
audioStreamClose(s->stream);
audioSampleFree(s->sample);
s->stream = NULL;
s->sample = NULL;
s->fill = NULL;
s->voice = NO_VOICE;
s->streamEnded = false;
}
// Close the streams whose program fill has ended, once what they queued
// has played.
static void streamEndReap(void) {
uint8_t slot;
for (slot = 0; slot < JOEY_AUDIO_SFX_SLOTS; slot++) {
if (gSlots[slot].streamEnded && audioStreamQueued(gSlots[slot].stream) == 0) {
sfxStop(slot);
}
}
}
// audio.lib asks a slot's stream for PCM: the program's fill writes signed
// bytes in place, which then become unsigned. Its 0 is the end of the
// stream; audio.lib's 0 only means "nothing yet", so the slot remembers.
static uint32_t streamFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData) {
SfxSlotT *s;
uint8_t *pcm;
uint32_t got;
uint32_t i;
(void)stream;
s = (SfxSlotT *)userData;
if (s->streamEnded) {
return 0;
}
got = s->fill(s->fillCtx, (int8_t *)buf, frames);
if (got == 0) {
s->streamEnded = true;
return 0;
}
got = got < frames ? got : frames;
pcm = (uint8_t *)buf;
for (i = 0; i < got; i++) {
pcm[i] ^= PCM8_SIGN_FLIP;
}
return got;
}
// Run the tick callback once for every tick the timer counted since last
// time.
static void tickDeliver(void) {
uint32_t state;
uint32_t ticks;
if (gTickFn == NULL) {
return;
}
state = platformIrqSave();
ticks = gTicksDue;
gTicksDue = 0;
platformIrqRestore(state);
while (ticks > 0 && gTickFn != NULL) {
ticks--;
gTickFn();
}
}
// The DVX timer callback, inside the timer interrupt: count, nothing more.
static void tickIsr(void *userData) {
(void)userData;
gTicksDue++;
}
static void tickStop(void) {
if (gTickTimer != NO_TIMER) {
platformTimerRemove(gTickTimer);
gTickTimer = NO_TIMER;
}
gTickFn = NULL;
gTicksDue = 0;
if (gTickPinned) {
(void)platformUnlockRegion((const void *)tickIsr, TICK_ISR_LOCK_BYTES, true);
(void)platformUnlockRegion((const void *)&gTicksDue, sizeof(gTicksDue), false);
gTickPinned = false;
}
}
// A tone channel at hz and volume, 0 for either being silence. A playing
// tone is changed in place; one audio.lib dropped (its voice limit) or
// never started is started.
static void toneSet(ToneChannelT *channel, uint32_t hz, int32_t volume) {
AudioCtxT *ctx;
// Silence needs no context: a channel only has a voice when there is one.
if (hz == 0 || volume == 0) {
if (channel->voice != NO_VOICE) {
(void)audioVoiceStop(gCtx, channel->voice);
channel->voice = NO_VOICE;
}
return;
}
ctx = audioContext();
if (ctx == NULL) {
return;
}
hz = hz < AUDIO_TONE_MIN_HZ ? AUDIO_TONE_MIN_HZ : (hz > AUDIO_TONE_MAX_HZ ? AUDIO_TONE_MAX_HZ : hz);
if (channel->voice != NO_VOICE &&
audioVoiceSetFrequency(ctx, channel->voice, hz) == AUDIO_OK &&
audioVoiceSetVolume(ctx, channel->voice, volume) == AUDIO_OK) {
return;
}
channel->voice = audioTone(ctx, channel->wave, hz, volume, TONE_UNTIL_STOPPED);
if (channel->voice < 0) {
channel->voice = NO_VOICE;
}
}
static int32_t voiceVolume(uint8_t atten) {
if (atten >= ATTEN_SILENT) {
return 0;
}
return (int32_t)gAttenVolume[atten] * TONE_VOLUME / AUDIO_VOLUME_MAX;
}
// ----- HAL API (alphabetical) -----
void jlpAudioFrameTick(void) {
tickDeliver();
streamEndReap();
// Feeds the music and the streams; pumps audio.lib too.
musicPump();
}
bool jlpAudioInit(void) {
uint8_t slot;
for (slot = 0; slot < JOEY_AUDIO_SFX_SLOTS; slot++) {
gSlots[slot].pan = DVX_PAN_CENTER;
}
return audioContext() != NULL;
}
bool jlpAudioIsPlayingMod(void) {
return gMusic != NULL && musicIsPlaying(gMusic);
}
void jlpAudioNoise(uint8_t pitch, uint8_t atten) {
uint32_t hz;
hz = NOISE_MAX_HZ - (uint32_t)pitch * (NOISE_MAX_HZ - NOISE_MIN_HZ) / NOISE_PITCH_MAX;
toneSet(&gNoise, hz, voiceVolume(atten));
}
void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) {
jlpAudioStopMod();
if (musicLoadMem(data, length, &gMusic) != MUSIC_OK) {
gMusic = NULL;
return;
}
(void)musicPlay(gMusic, loop);
}
void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) {
AudioFormatT fmt;
int32_t pitch;
uint8_t *grown;
uint32_t i;
sfxStop(slot);
if (length > gPcmSize) {
grown = (uint8_t *)realloc(gPcm, length);
if (grown == NULL) {
return;
}
gPcm = grown;
gPcmSize = length;
}
for (i = 0; i < length; i++) {
gPcm[i] = (uint8_t)(sample[i] ^ PCM8_SIGN_FLIP);
}
sfxFormat(rateHz, &fmt, &pitch);
if (audioSampleCreate(gCtx, gPcm, length, &fmt, &gSlots[slot].sample) != AUDIO_OK) {
gSlots[slot].sample = NULL;
return;
}
sfxStart(slot, audioPlay(gSlots[slot].sample, SFX_VOLUME, gSlots[slot].pan, false), rateHz);
}
void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz) {
AudioFormatT fmt;
int32_t pitch;
SfxSlotT *s;
sfxStop(slot);
s = &gSlots[slot];
s->fill = fill;
s->fillCtx = ctx;
sfxFormat(rateHz, &fmt, &pitch);
if (audioStreamOpen(gCtx, &fmt, STREAM_BUFFER_MS, streamFill, s, &s->stream) != AUDIO_OK) {
s->stream = NULL;
s->fill = NULL;
return;
}
sfxStart(slot, audioStreamVoice(s->stream), rateHz);
}
void jlpAudioSetPan(uint8_t slot, uint8_t pan) {
gSlots[slot].pan = gPanToDvx[pan];
if (gSlots[slot].voice != NO_VOICE) {
(void)audioVoiceSetPan(gCtx, gSlots[slot].voice, gSlots[slot].pan);
}
}
void jlpAudioShutdown(void) {
uint8_t i;
tickStop();
jlpAudioStopMod();
for (i = 0; i < JOEY_AUDIO_SFX_SLOTS; i++) {
sfxStop(i);
}
toneSet(&gTone, 0, 0);
toneSet(&gNoise, 0, 0);
for (i = 0; i < JOEY_AUDIO_VOICES; i++) {
toneSet(&gVoices[i], 0, 0);
}
free(gPcm);
gPcm = NULL;
gPcmSize = 0;
audioClose(gCtx);
gCtx = NULL;
}
void jlpAudioStopMod(void) {
musicFree(gMusic);
gMusic = NULL;
}
void jlpAudioStopSfx(uint8_t slot) {
sfxStop(slot);
}
bool jlpAudioTickRegister(jlAudioTickFnT fn, uint16_t hz) {
tickStop();
if (fn == NULL) {
return true;
}
if (platformLockRegion((const void *)tickIsr, TICK_ISR_LOCK_BYTES, true) != 0) {
return false;
}
if (platformLockRegion((const void *)&gTicksDue, sizeof(gTicksDue), false) != 0) {
(void)platformUnlockRegion((const void *)tickIsr, TICK_ISR_LOCK_BYTES, true);
return false;
}
gTickPinned = true;
gTickFn = fn;
gTickTimer = gameTimerAdd(gDvxPortProgram->shellCtx, hz, tickIsr, NULL);
if (gTickTimer < 0) {
gTickTimer = NO_TIMER;
tickStop();
return false;
}
return true;
}
void jlpAudioTone(uint16_t freqHz) {
toneSet(&gTone, freqHz, TONE_VOLUME);
}
void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) {
toneSet(&gVoices[voice], freqHz, voiceVolume(atten));
}