// 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 // The jet's noise pitch: jlAudioNoise takes 0..31 in YM noise-period // style, where 31 is the lowest rumble (0 is the brightest hiss). #define ST_NOISE_JET_PITCH 31u // Noise sound effects (the crash and the wreck's impact) use the same // deep end of the range. The C64 takes the rate from the program's // frequency word; this is the one simplification in that mapping. #define ST_NOISE_SFX_PITCH 31u #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 3u #define ST_SPEECH_HEADER_BYTES 16u #define ST_SPEECH_ENTRY_BYTES 8u // speech.bin carries each utterance in one polarity or two. Two is the // cheap case -- the player just picks the baked variant -- but it doubles // the file, and the X68000's Human68k floppy cannot spare the other // 60 KB, so that port bakes one and the player negates the samples // itself. Negation is the same byte arithmetic in both sample formats: // signed 0 - v, and 0x80-biased 0x100 - v, are one operation. #define ST_SPEECH_POLARITIES_MAX 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; // Release countdown for a NOISE sound effect, in game ticks, mirroring // the two tone slots' ticksLeft. 0 = no burst running. static uint16_t gNoiseTicks; // 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: the polarity the next utterance plays in static uint8_t gSpeechPolarities; // how many the file carries, 1 or 2 static bool gSpeechInvert; // negate on the way out (1-polarity file, odd polarity) // 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 bool 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 bool 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) { ST_LOG("staudio: ! cannot open DATA/%s", ST_SPEECH_FILE); return false; } 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 || head[9] == 0u || head[9] > ST_SPEECH_POLARITIES_MAX) { ST_LOG("staudio: ! speech.bin rejected (want v%u fmt%u)", ST_SPEECH_VERSION, format); fclose(fp); return false; } gSpeechPolarities = head[9]; 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 * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES + dataBytes; gSpeechBlob = (uint8_t *)jlAlloc(total); if (gSpeechBlob == 0) { // The blob is ~120 KB of sample bytes, so on the tighter // machines this is the failure most likely to bite. It stops // the game: a silent Space Taxi is a broken Space Taxi. ST_LOG("staudio: ! speech alloc FAILED (%lu bytes)", (unsigned long)total); fclose(fp); return false; } 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) { ST_LOG("staudio: ! speech read FAILED at %lu of %lu", (unsigned long)done, (unsigned long)total); jlFree(gSpeechBlob); gSpeechBlob = 0; break; } done += want; } fclose(fp); if (gSpeechBlob == 0) { return false; } ST_LOG("staudio: speech %lu bytes, %u utterances x %u pitches x %u polarities", (unsigned long)total, gSpeechCount, gSpeechPitchCount, gSpeechPolarities); return true; } 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 * gSpeechPolarities * ST_SPEECH_ENTRY_BYTES; uint8_t k; gSpeechHead = (uint8_t)((gSpeechHead + 1u) % ST_SPEECH_QUEUE); for (k = 0u; k < gSpeechCount; k++, e += gSpeechPolarities * ST_SPEECH_ENTRY_BYTES) { if (e[0] == code) { // Two polarities baked: take the variant. One: take the // only one and negate it on the way out when the running // polarity is the odd one. uint8_t pol = (gSpeechPolarities == 2u) ? gSpeechPol : 0u; const uint8_t *v = e + (uint16_t)pol * 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); gSpeechInvert = (gSpeechPolarities == 1u && gSpeechPol != 0u); 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 * gSpeechPolarities * 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; } if (gSpeechInvert) { const uint8_t *from = gSpeechPcm + gSpeechSample; uint8_t *to = dst + n; uint16_t i; for (i = 0u; i < take; i++) { to[i] = (uint8_t)(0u - (uint16_t)from[i]); } } else { 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), 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); } } if (gNoiseTicks != 0u) { gNoiseTicks--; if (gNoiseTicks == 0u) { gNoiseOn = false; jlAudioNoise(0u, ST_ATTEN_OFF); } else { // Re-assert: one call buys about 80 ms on the IIgs. jlAudioNoise(ST_NOISE_SFX_PITCH, ST_ATTEN_LOUD); } } } bool stAudioInit(void) { bool ok; (void)jlAudioInit(); gSpeechStage = (uint8_t *)jlAlloc(ST_SPEECH_STAGE_BYTES); ok = (gSpeechStage != 0) && speechLoad(); gSfx.voice = ST_VOICE_SFX; gNoiseTicks = 0u; 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; gSpeechInvert = false; return ok; } 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. // $6D6A's voice-3 gate. The C64 holds SID voice 3 open for as long as a // direction is held, so the jet has to keep sounding for the whole burn. // // This re-asserts on EVERY call rather than only on the rising edge, // because jlAudioNoise is a per-frame assertion and not a latch: the // IIgs plays one ~80 ms one-shot per call and expects the caller to keep // asking, exactly as jlMusicPlay's sequencer does for a held noise note // (include/joey/audio.h). Latching it on the edge is why the thruster // used to sound one short burst and then die with the taxi still // climbing. The ports that DO latch take the repeat harmlessly: the ST // rewrites the same YM registers, and Paula's redundant DMA set is // click-free by design. void stAudioNoise(bool on) { if (on && gNoiseTicks != 0u) { // A sound effect owns the voice until its release runs out, so // the jet does not talk over the crash. The C64 shares SID // voice 3 between them the same way and silences the jet before // firing one. An explicit silence below still wins, which // matters because stAudioMusic clears this voice before a tune. return; } if (on) { gNoiseOn = true; jlAudioNoise(ST_NOISE_JET_PITCH, ST_ATTEN_SOFT); return; } if (!gNoiseOn && gNoiseTicks == 0u) { return; } gNoiseOn = false; gNoiseTicks = 0u; 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, held for the // program's release time exactly as the tone slots below hold // theirs. It used to fire one jlAudioNoise and return, which is // not a burst at all: that call is a per-frame ASSERTION, not a // latch (include/joey/audio.h), so on the IIgs the wreck's // explosion lasted the ~80 ms of one one-shot and was easy to // miss entirely, while on Paula it would have hung until some // later stAudioNoise(false). stAudioTick re-asserts it. gNoiseTicks = (uint16_t)(ticks + 1u); gNoiseOn = true; jlAudioNoise(ST_NOISE_SFX_PITCH, ST_ATTEN_LOUD); 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; }