joeylib2/examples/spacetaxi/stAudio.c

577 lines
20 KiB
C

// Space Taxi -- audio sink for the simulation's SID events.
//
// The C64 drives three SID voices: voice 1 carries the SFX programs
// (landing, gear, crash, fuel) and the thrust/crash frequency sweep,
// voice 2 the level gimmick tones (laser hum, puzzle chime), voice 3
// the jet noise. JoeyLib's audio HAL is a PSG-style tone/noise
// interface, so each SID program is mapped to its pitch and release
// time; envelopes and waveforms are approximated with an attenuation
// ramp. The speech samples of the original are not extracted; each
// spoken character becomes a short chirp so the cues still land.
//
// UPDATE: the speech IS extracted now (assets/extractSpeech.py), so the
// chirp is gone. stSpeechData.c carries the C64's own 1-bit run-length
// streams; assets/genSpeechPcm.py bakes them to sample bytes
// (DATA/speech.bin) and speechFill below streams those through
// jlAudioPlaySfxStream.
#include <string.h>
#include "spacetaxi.h"
#include <joey/file.h>
#include "stSongData.h"
// SID voice index in a program's byte 8 -> JoeyLib voice slot.
#define ST_VOICE_SFX 1u // SID voice 1
#define ST_VOICE_TONE 2u // SID voice 2
#define ST_VOICE_NOISE 3u // SID voice 3 (noise)
#define ST_ATTEN_LOUD 4u
#define ST_ATTEN_SOFT 8u
#define ST_ATTEN_OFF 15u
// NTSC SID: Hz = word * 1022727 / 16777216 = word * 0.06096.
#define ST_SID_HZ_NUM 1022727UL
#define ST_SID_HZ_DEN 16777216UL
// ---- speech ----
//
// The C64 player ($9802) busy-waits a delay loop per stream byte and then
// toggles its 1-bit output, so a byte is one half-cycle. The loop costs
// (5k + 12) cycles per pass plus a 33-cycle tail, where k is the inner
// count at $98A2 -- normally 3, giving 27. A code below $09 does not
// speak: it REWRITES k, which is how the original gives each fare a
// slightly different voice pitch. stAudioSpeech honours that.
#define ST_SPEECH_SLOT 0u // slots 1..3 are the SID voices
#define ST_SPEECH_RATE 5000u // enough for a 1-bit source
#define ST_SPEECH_PITCH_BASE 3u // $98A2's power-on value
#define ST_SPEECH_PITCH_MAX 15u // the player's AND #$0F
// Longest phrase is "PX?" -- pad, digit, please.
#define ST_SPEECH_QUEUE 4u
// The speech is baked to sample bytes per pitch, in both polarities, by
// assets/genSpeechPcm.py (DATA/speech.bin, format in that script), so
// playing it is a block copy. Decoding the C64's run-length stream at
// play time cost the 65816 2.4 s of CPU per second of speech and stalled
// the ride half a second on every "HEY TAXI"; the 1-bit intermediate
// format that followed still needed a per-byte expansion loop.
#define ST_SPEECH_FILE "speech.bin"
#define ST_SPEECH_VERSION 2u
#define ST_SPEECH_HEADER_BYTES 16u
#define ST_SPEECH_ENTRY_BYTES 8u
#define ST_SPEECH_POLARITIES 2u
#define ST_SPEECH_FORMAT_SIGNED 0u
#define ST_SPEECH_FORMAT_BIASED 1u
#define ST_SPEECH_LOAD_CHUNK 16384u
#define ST_SPEECH_STAGE_MASK (ST_SPEECH_STAGE_BYTES - 1u)
// Decode-ahead staging: a whole engine chunk (7168 at 5 kHz, 1.4 s) plus
// slack. The per-tick slice covers playback (5000/s is ~330 a tick)
// with room to spare and is cheap enough to never need a burst; a
// starved pull still copies ST_SPEECH_STARVED_SLICE on the spot rather
// than ending the stream.
#define ST_SPEECH_STAGE_BYTES 8192u
#define ST_SPEECH_TICK_SLICE 384u
#define ST_SPEECH_STARVED_SLICE 1024u
#define ST_SPEECH_ARM_SLICE 1024u
// Thrust sweep register value -> Hz: the C64 writes the same byte to
// both frequency bytes, so the word is value * 257.
#define ST_SWEEP_WORD_SCALE 257u
typedef struct {
uint16_t ticksLeft; // release countdown (game ticks); 0 = idle
uint8_t voice;
} StSfxSlotT;
static StSfxSlotT gSfx; // SID voice 1 (programs + sweep)
static StSfxSlotT gTone; // SID voice 2
static bool gNoiseOn;
// Speech playback state. The fill callback walks a queue of utterances so
// a phrase like "HT" ("Hey" + "Taxi") runs as one continuous stream
// instead of each character cutting off the last.
static uint8_t gSpeechQueue[ST_SPEECH_QUEUE];
static uint8_t gSpeechHead;
static uint8_t gSpeechTail;
static bool gSpeechActive;
static uint8_t gSpeechPitch = ST_SPEECH_PITCH_BASE;
// DATA/speech.bin, whole: header, entry table, sample bytes. The bytes
// are already in the port's raw stream format (jlAudioSfxRawFormat):
// signed +/-100, or 0x80-biased on the IIgs so a chunk needs no
// conversion; speechLoad refuses a file baked for the other one.
static uint8_t *gSpeechBlob;
static uint8_t gSpeechPitchBase;
static uint8_t gSpeechPitchCount;
static uint8_t gSpeechCount; // utterances per pitch
static const uint8_t *gSpeechPcm; // current utterance's samples
static uint16_t gSpeechSample; // next sample index in it
static uint16_t gSpeechSamples; // its length
static uint8_t gSpeechParity; // it ends on the opposite level
static uint8_t gSpeechPol; // 0 / 1: which baked polarity the next utterance plays
// Copied-ahead PCM ring for the stream (see speechFill). Allocated by
// stAudioInit rather than static: 8 KB of BSS is a fifth of the IIgs
// entry bank, which text, rodata, BSS and the C heap all share.
static uint8_t *gSpeechStage;
static uint16_t gSpeechStageRead; // byte index of the next unread sample
static uint16_t gSpeechStageWrite; // byte index of the next free byte
static uint16_t gSpeechStageCount; // bytes staged and unread
static bool gSpeechDecoding; // the decoder still has stream left
static uint16_t sidHz(uint16_t word);
static void speechLoad(void);
static bool speechNext(void);
static void speechArm(void);
static uint16_t speechDecode(uint8_t *dst, uint16_t count);
static uint32_t speechFill(void *ctx, int8_t *dst, uint32_t count);
static void speechPumpSlice(uint16_t samples);
static bool speechQueue(uint8_t ch);
static void voiceOff(StSfxSlotT *slot);
static uint16_t sidHz(uint16_t word) {
uint32_t hz = ((uint32_t)word * ST_SID_HZ_NUM) / ST_SID_HZ_DEN;
if (hz > 20000u) {
hz = 20000u;
}
return (uint16_t)hz;
}
// Point the decoder at the next queued utterance. False when the queue
// is empty, which ends the stream.
// DATA/speech.bin into one jlAlloc block; without it the game is mute
// but otherwise fine.
static void speechLoad(void) {
FILE *fp = jlDataOpen(ST_SPEECH_FILE, "rb");
uint8_t head[ST_SPEECH_HEADER_BYTES];
uint8_t format;
uint32_t dataBytes;
uint32_t total;
uint32_t done;
gSpeechBlob = 0;
if (fp == 0) {
return;
}
format = (jlAudioSfxRawFormat() == JL_SFX_RAW_UNSIGNED8_NOZERO) ? ST_SPEECH_FORMAT_BIASED : ST_SPEECH_FORMAT_SIGNED;
if (fread(head, 1u, sizeof(head), fp) != sizeof(head) || head[0] != 'S' || head[1] != 'T' || head[2] != 'S' || head[3] != 'P' || head[4] != ST_SPEECH_VERSION || head[8] != format) {
fclose(fp);
return;
}
gSpeechPitchBase = head[5];
gSpeechPitchCount = head[6];
gSpeechCount = head[7];
dataBytes = (uint32_t)head[12] | ((uint32_t)head[13] << 8) | ((uint32_t)head[14] << 16) | ((uint32_t)head[15] << 24);
total = ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES + dataBytes;
gSpeechBlob = (uint8_t *)jlAlloc(total);
if (gSpeechBlob == 0) {
fclose(fp);
return;
}
memcpy(gSpeechBlob, head, sizeof(head));
// The block spans banks on the IIgs; read it in bank-safe pieces.
done = ST_SPEECH_HEADER_BYTES;
while (done < total) {
uint32_t want = total - done;
if (want > ST_SPEECH_LOAD_CHUNK) {
want = ST_SPEECH_LOAD_CHUNK;
}
if (fread(gSpeechBlob + done, 1u, (size_t)want, fp) != (size_t)want) {
jlFree(gSpeechBlob);
gSpeechBlob = 0;
break;
}
done += want;
}
fclose(fp);
}
static bool speechNext(void) {
uint8_t row = 0u;
if (gSpeechBlob == 0) {
return false;
}
if (gSpeechPitch > gSpeechPitchBase) {
row = (uint8_t)(gSpeechPitch - gSpeechPitchBase);
if (row >= gSpeechPitchCount) {
row = (uint8_t)(gSpeechPitchCount - 1u);
}
}
while (gSpeechHead != gSpeechTail) {
uint8_t code = gSpeechQueue[gSpeechHead];
const uint8_t *e = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint16_t)row * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES;
uint8_t k;
gSpeechHead = (uint8_t)((gSpeechHead + 1u) % ST_SPEECH_QUEUE);
for (k = 0u; k < gSpeechCount; k++, e += ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES) {
if (e[0] == code) {
const uint8_t *v = e + (uint16_t)gSpeechPol * ST_SPEECH_ENTRY_BYTES;
uint32_t offset = (uint32_t)v[4] | ((uint32_t)v[5] << 8) | ((uint32_t)v[6] << 16) | ((uint32_t)v[7] << 24);
gSpeechParity = v[1];
gSpeechSamples = (uint16_t)(v[2] | ((uint16_t)v[3] << 8));
gSpeechSample = 0u;
gSpeechPcm = gSpeechBlob + ST_SPEECH_HEADER_BYTES + (uint32_t)gSpeechPitchCount * gSpeechCount * ST_SPEECH_POLARITIES * ST_SPEECH_ENTRY_BYTES + offset;
return true;
}
}
// Unknown code: skip it rather than dropping the whole phrase.
}
return false;
}
// Copy up to `count` samples of the queued utterances into `dst`, fewer
// when they run out (0 = none left). jlMemCopy is an MVN on the IIgs;
// the utterance bytes are already in the stream's format, so this is
// the whole of the play-time speech work.
static uint16_t speechDecode(uint8_t *dst, uint16_t count) {
uint16_t n = 0u;
while (n < count) {
uint16_t left = (uint16_t)(gSpeechSamples - gSpeechSample);
uint16_t take = (uint16_t)(count - n);
if (left == 0u) {
// The next utterance carries on from the level this one left.
if (gSpeechParity != 0u) {
gSpeechPol ^= 1u;
}
if (!speechNext()) {
return n;
}
continue;
}
if (take > left) {
take = left;
}
jlMemCopy(dst + n, gSpeechPcm + gSpeechSample, take);
gSpeechSample = (uint16_t)(gSpeechSample + take);
n = (uint16_t)(n + take);
}
return n;
}
// The audio engine's chunk pull. It used to decode the whole chunk right
// here -- 7168 samples, ~1.5 s of speech, in ONE frame: every "HEY
// TAXI" froze the ride for over a second on the IIgs. Now the chunk is
// copied out of the staging buffer that speechPumpSlice fills a slice at
// a time across the ticks before it is due, and only a stream that has
// outrun the pump decodes here, a bounded slice, so it never starves.
static uint32_t speechFill(void *ctx, int8_t *dst, uint32_t count) {
uint16_t want = (count > ST_SPEECH_STAGE_BYTES) ? (uint16_t)ST_SPEECH_STAGE_BYTES : (uint16_t)count;
uint16_t n;
(void)ctx;
if (gSpeechStageCount == 0u && gSpeechDecoding) {
speechPumpSlice(ST_SPEECH_STARVED_SLICE);
}
n = (gSpeechStageCount < want) ? gSpeechStageCount : want;
if (n != 0u) {
// jlMemCopy (MVN on the IIgs; libc memcpy is a byte loop there),
// in two runs when the unread bytes wrap the ring's end.
uint8_t *to = (uint8_t *)dst;
uint16_t left = n;
while (left != 0u) {
uint16_t run = (uint16_t)(ST_SPEECH_STAGE_BYTES - gSpeechStageRead);
if (run > left) {
run = left;
}
jlMemCopy(to, gSpeechStage + gSpeechStageRead, run);
to += run;
gSpeechStageRead = (uint16_t)((gSpeechStageRead + run) & ST_SPEECH_STAGE_MASK);
left = (uint16_t)(left - run);
}
gSpeechStageCount = (uint16_t)(gSpeechStageCount - n);
}
if (n == 0u) {
// Out of queued utterances. Only END the stream if this chunk
// produced nothing at all -- a phrase is fed one character at a
// time ("HT" is "Hey" then "Taxi"), and the first arm decodes
// all of 'H' before 'T' has even been queued. Ending here would
// let the 'T' arm a NEW stream that cut 'H' off, so the player
// only ever heard the last character. Returning a short chunk
// keeps the slot armed and the next pull picks up whatever
// arrived since.
gSpeechActive = false;
}
return n;
}
// Decode up to `samples` more into the staging buffer. Once the decoder
// reports nothing left it stays quiet until the next speechArm. Reads
// and writes stay even so the chunk copy can move words.
static void speechPumpSlice(uint16_t samples) {
uint16_t room = (uint16_t)(ST_SPEECH_STAGE_BYTES - gSpeechStageCount);
if (!gSpeechDecoding || room == 0u) {
return;
}
if (samples > room) {
samples = room;
}
// Fill towards the ring's end, then from its start.
while (samples != 0u) {
uint16_t run = (uint16_t)(ST_SPEECH_STAGE_BYTES - gSpeechStageWrite);
uint16_t got;
if (run > samples) {
run = samples;
}
got = speechDecode(gSpeechStage + gSpeechStageWrite, run);
if (got < run) {
gSpeechDecoding = false;
}
gSpeechStageWrite = (uint16_t)((gSpeechStageWrite + got) & ST_SPEECH_STAGE_MASK);
gSpeechStageCount = (uint16_t)(gSpeechStageCount + got);
samples = (uint16_t)(samples - got);
if (!gSpeechDecoding) {
return;
}
}
}
static void voiceOff(StSfxSlotT *slot) {
if (slot->ticksLeft != 0u) {
jlAudioVoice(slot->voice, 0u, ST_ATTEN_OFF);
slot->ticksLeft = 0u;
}
}
// Once per game tick: run the release timers ($4320 sfxEnvelopeTick).
void stAudioTick(void) {
if (gSpeechActive) {
speechPumpSlice(ST_SPEECH_TICK_SLICE);
}
if (gSfx.ticksLeft != 0u) {
gSfx.ticksLeft--;
if (gSfx.ticksLeft == 0u) {
jlAudioVoice(gSfx.voice, 0u, ST_ATTEN_OFF);
}
}
if (gTone.ticksLeft != 0u) {
gTone.ticksLeft--;
if (gTone.ticksLeft == 0u) {
jlAudioVoice(gTone.voice, 0u, ST_ATTEN_OFF);
}
}
}
void stAudioInit(void) {
(void)jlAudioInit();
gSpeechStage = (uint8_t *)jlAlloc(ST_SPEECH_STAGE_BYTES);
speechLoad();
gSfx.voice = ST_VOICE_SFX;
gSfx.ticksLeft = 0u;
gTone.voice = ST_VOICE_TONE;
gTone.ticksLeft = 0u;
gNoiseOn = false;
gSpeechHead = 0u;
gSpeechTail = 0u;
gSpeechActive = false;
gSpeechPitch = ST_SPEECH_PITCH_BASE;
gSpeechPol = 0u;
}
void stAudioShutdown(void) {
voiceOff(&gSfx);
voiceOff(&gTone);
jlAudioNoise(0u, ST_ATTEN_OFF);
jlAudioShutdown();
jlFree(gSpeechStage);
gSpeechStage = 0;
jlFree(gSpeechBlob);
gSpeechBlob = 0;
}
// $6D6A / $6A3B -- voice 3 jet noise gate.
void stAudioNoise(bool on) {
if (on == gNoiseOn) {
return;
}
gNoiseOn = on;
if (on) {
jlAudioNoise(31u, ST_ATTEN_SOFT);
} else {
jlAudioNoise(0u, ST_ATTEN_OFF);
}
}
// $42E9 -- a 9-byte SID program: freq lo/hi, pulse lo/hi, control,
// AD, SR, release ticks, voice.
void stAudioSfx(const uint8_t *program9) {
uint16_t word = (uint16_t)(program9[0] | ((uint16_t)program9[1] << 8));
uint8_t ctrl = program9[4];
uint8_t ticks = program9[7];
StSfxSlotT *slot = (program9[8] == 1u) ? &gTone : &gSfx;
if ((ctrl & 0x80u) != 0u) {
// Noise waveform: a burst on the noise generator.
jlAudioNoise(31u, ST_ATTEN_LOUD);
gNoiseOn = true;
return;
}
jlAudioVoice(slot->voice, sidHz(word), ST_ATTEN_LOUD);
slot->ticksLeft = (uint16_t)(ticks + 1u);
}
// $6C7F / $6CAA -- voices 1 and 2 gated off.
void stAudioSilence(void) {
voiceOff(&gSfx);
voiceOff(&gTone);
}
// $44CF -- play one of the game's tunes and block. The C64 silences the
// SID first ($5C1A / $6906) and then spins until the song ends; the host
// does the spinning (ST_STATE_JINGLE), so here it is just the start.
void stAudioJingle(uint8_t song) {
stAudioMusic(song, false);
}
// $CB02 -- start a tune and leave it to the player: the level intro's
// song and the high-score table's. Speech is cancelled too: it streams
// on the slot the tracker's voice 0 rides.
void stAudioMusic(uint8_t song, bool loop) {
if (song >= ST_SONG_COUNT) {
return;
}
stAudioSilence();
stAudioNoise(false);
gSpeechActive = false;
gSpeechDecoding = false;
gSpeechStageRead = 0u;
gSpeechStageWrite = 0u;
gSpeechStageCount = 0u;
gSpeechHead = gSpeechTail;
jlAudioStopSfx(ST_SPEECH_SLOT);
jlMusicPlay(kStSongs[song].data, kStSongs[song].length, loop);
}
bool stAudioJingleActive(void) {
return jlMusicIsPlaying();
}
// Queue one utterance code. Returns false when the code is not speech (a
// pitch change) or the queue is full.
static bool speechQueue(uint8_t ch) {
uint8_t next;
if (ch < 9u) {
// Below $09 the original does not speak: it rewrites the delay
// loop's inner count, re-pitching every later utterance. That is
// where the passenger's voice variation comes from, so keep it.
gSpeechPitch = (uint8_t)(ch & ST_SPEECH_PITCH_MAX);
if (gSpeechPitch == 0u) {
gSpeechPitch = ST_SPEECH_PITCH_BASE;
}
return false;
}
next = (uint8_t)((gSpeechTail + 1u) % ST_SPEECH_QUEUE);
if (next == gSpeechHead) {
return false; // queue full; drop rather than stall
}
gSpeechQueue[gSpeechTail] = ch;
gSpeechTail = next;
return true;
}
// Start the stream if it is idle. Call this ONCE, after the whole phrase is
// queued: arming decodes everything queued so far into a single chunk, and
// each extra chunk costs a refill deadline of dead air, because the refill
// only happens when the game next calls jlAudioFrameTick -- once per rendered
// frame. Arming per character is what broke phrases into words with silence
// between them.
static void speechArm(void) {
if (gSpeechActive || gSpeechStage == 0 || gSpeechBlob == 0) {
return;
}
if (!speechNext()) {
return;
}
gSpeechPol = 0u; // a phrase starts on +100
gSpeechActive = true;
gSpeechDecoding = true;
gSpeechStageRead = 0u;
gSpeechStageWrite = 0u;
gSpeechStageCount = 0u;
// A first slice now, so the opening chunk sounds this tick.
speechPumpSlice(ST_SPEECH_ARM_SLICE);
jlAudioPlaySfxStreamRaw(ST_SPEECH_SLOT, speechFill, (void *)0, ST_SPEECH_RATE);
}
// $9802 -- one spoken character. Speaks immediately; prefer
// stAudioSpeechPhrase for anything longer than a single code.
void stAudioSpeech(uint8_t ch) {
if (speechQueue(ch)) {
speechArm();
}
}
// A whole phrase: queue every code FIRST, then arm once, so the phrase
// decodes into as few chunks as it fits into.
void stAudioSpeechPhrase(const uint8_t *speech) {
bool any = false;
while (*speech != 0u) {
if (speechQueue(*speech)) {
any = true;
}
speech++;
}
if (any) {
speechArm();
}
}
// $6A8A -- the crash scream: voice 1 frequency written from the sweep.
void stAudioThrustSweep(uint8_t value) {
jlAudioVoice(ST_VOICE_SFX, sidHz((uint16_t)(value * ST_SWEEP_WORD_SCALE)), ST_ATTEN_LOUD);
gSfx.ticksLeft = 3u;
}
// $6E71 -- the fuel pump's rising note on voice 1.
void stAudioVoice1Freq(uint8_t value) {
jlAudioVoice(ST_VOICE_SFX, sidHz((uint16_t)(value * ST_SWEEP_WORD_SCALE)), ST_ATTEN_LOUD);
gSfx.ticksLeft = 2u;
}
// Level hooks poke voice 2 directly (laser hum, puzzle chime).
void stAudioVoice2(uint8_t freqLo, uint8_t freqHi, uint8_t ctrl) {
uint16_t word = (uint16_t)(freqLo | ((uint16_t)freqHi << 8));
if ((ctrl & 0x80u) != 0u) {
jlAudioNoise((uint8_t)(freqHi & 31u), ST_ATTEN_SOFT);
gNoiseOn = true;
return;
}
jlAudioVoice(ST_VOICE_TONE, sidHz(word), ST_ATTEN_SOFT);
gTone.ticksLeft = 2u;
}