/** * Copyright (c) 2026 Dominic Masters * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #include "audiostreamlinux.h" #include "audio/audiostream.h" #include "assert/assert.h" #include "util/memory.h" #include "util/math.h" #include "error/error.h" // How many frames of lead time to keep queued ahead of playback. Matches // SDL_AudioSpec.samples below - the device's own internal buffer size - so // this is "never let the queue run drier than one SDL-internal buffer." #define AUDIO_LINUX_LEAD_FRAMES 4096 // How many frames audioStreamLinuxFeed() reads and queues per call - a // few multiples of the lead margin, so one top-up comfortably outlasts // the time between Update() calls without ever needing to hold more than // this much decoded audio in memory at once, regardless of how long the // underlying clip is. #define AUDIO_LINUX_WINDOW_FRAMES (AUDIO_LINUX_LEAD_FRAMES * 4) errorret_t audioStreamLinuxInit(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); SDL_AudioSpec desired; memoryZero(&desired, sizeof(SDL_AudioSpec)); desired.freq = (int) stream->sampleRate; desired.format = AUDIO_S16SYS; desired.channels = stream->channels; desired.samples = AUDIO_LINUX_LEAD_FRAMES; stream->platform.device = SDL_OpenAudioDevice(NULL, 0, &desired, NULL, 0); if(stream->platform.device == 0) { errorThrow("Failed to open SDL2 audio device: %s", SDL_GetError()); } errorOk(); } errorret_t audioStreamLinuxDispose(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); SDL_CloseAudioDevice(stream->platform.device); errorOk(); } errorret_t audioStreamLinuxBuffer(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); const size_t frameSize = stream->channels * sizeof(int16_t); const size_t totalFrames = audioStreamGetTotalFrames(stream); // Consumed synchronously (right here, in the same call that decided to // (re)buffer) rather than left for later - see startFrame's own comment. const size_t startFrame = mathMin(stream->startFrame, totalFrames); const bool_t seeking = stream->seeking; stream->startFrame = 0; stream->seeking = false; size_t endFrame = totalFrames; if((stream->state & AUDIO_STREAM_STATE_LOOPING) && stream->loopStart >= 0) { endFrame = mathMin( (size_t) (stream->loopStart * stream->sampleRate), totalFrames ); } // A seek (or a loop restart landing exactly on the loop end) can put // startFrame at or past endFrame - e.g. seeking into an outro after the // loop point. Play out to the true end of the buffer once instead, same // as PSP. if(startFrame >= endFrame) endFrame = totalFrames; // Only an explicit seek discards whatever's still queued and jumps - // a natural loop restart deliberately leaves the previous pass's tail // (AUDIO_LINUX_LEAD_FRAMES worth) queued and appends the new pass after // it, which is what makes looping gapless (see IsFinished()'s comment). // Clearing here on every pass would destroy that overlap and // reintroduce the exact gap this was built to avoid. if(seeking) { SDL_ClearQueuedAudio(stream->platform.device); } errorChain(audioStreamSeek(stream, startFrame)); stream->platform.position = startFrame; stream->platform.endFrame = endFrame; errorChain(audioStreamLinuxFeed(stream)); SDL_PauseAudioDevice(stream->platform.device, 0); errorOk(); } errorret_t audioStreamLinuxFeed(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); const size_t frameSize = stream->channels * sizeof(int16_t); const size_t framesRemaining = ( stream->platform.position < stream->platform.endFrame ? stream->platform.endFrame - stream->platform.position : 0 ); const size_t framesToRead = mathMin(framesRemaining, AUDIO_LINUX_WINDOW_FRAMES); if(framesToRead == 0) { errorOk(); } int16_t *chunk = memoryAllocate(framesToRead * frameSize); size_t framesRead = 0; errorret_t ret = audioStreamRead(stream, chunk, framesToRead, &framesRead); if(errorIsNotOk(ret)) { memoryFree(chunk); errorChain(ret); } const size_t bytesRead = framesRead * frameSize; int queued; if(stream->volume == 0xFF) { // Nothing to mix at full volume - queue the window directly instead of // allocating/zeroing a same-size scratch buffer just to copy it in. queued = SDL_QueueAudio(stream->platform.device, chunk, (Uint32) bytesRead); } else { uint8_t *mixed = memoryAllocate(bytesRead); memoryZero(mixed, bytesRead); SDL_MixAudioFormat( mixed, (uint8_t *) chunk, AUDIO_S16SYS, (Uint32) bytesRead, (stream->volume * SDL_MIX_MAXVOLUME) / 0xFF ); queued = SDL_QueueAudio(stream->platform.device, mixed, (Uint32) bytesRead); memoryFree(mixed); } memoryFree(chunk); if(queued != 0) { errorThrow("Failed to queue SDL2 audio data: %s", SDL_GetError()); } stream->platform.position += framesRead; errorOk(); } bool_t audioStreamLinuxIsFinished(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); // Reports "finished" (ready to loop/end) with AUDIO_LINUX_LEAD_FRAMES of // margin still queued, rather than waiting for the queue to actually run // dry - queuing the next pass this early never causes overlap (SDL's // queue is a plain FIFO), it just avoids ever going silent while our // once-per-frame Update() notices and catches up. Waiting for truly // empty guarantees a gap by definition: silence has already started by // the time "empty" can be observed. const Uint32 leadBytes = (Uint32) ( AUDIO_LINUX_LEAD_FRAMES * stream->channels * sizeof(int16_t) ); if(SDL_GetQueuedAudioSize(stream->platform.device) > leadBytes) { return false; } // Below the lead margin - if more of the current pass is left to read, // top up now rather than reporting finished, which would otherwise // trigger a full loop-restart/end while still mid-pass, just because // the queue happened to run low. if(stream->platform.position < stream->platform.endFrame) { errorret_t ret = audioStreamLinuxFeed(stream); if(errorIsNotOk(ret)) { errorCatch(errorPrint(ret)); return true; // Can't recover - let the shared layer end/loop it. } return false; } return true; }