// 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 #include "spacetaxi.h" #include #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; }