diff --git a/assets/audio/audiotest.wav b/assets/audio/audiotest.wav new file mode 100644 index 00000000..1a2628c3 Binary files /dev/null and b/assets/audio/audiotest.wav differ diff --git a/src/dusk/asset/asset.c b/src/dusk/asset/asset.c index 0ef0a6dd..e1aa2ed7 100644 --- a/src/dusk/asset/asset.c +++ b/src/dusk/asset/asset.c @@ -119,6 +119,15 @@ errorret_t assetRequireLoaded(assetentry_t *entry) { assetEntryLock(entry); while(entry->state != ASSET_ENTRY_STATE_LOADED) { + // A failed load transitions to ERROR, not LOADED - without this check + // this loop spins forever on any load failure (assetUpdate() itself + // still returns OK, since a single asset failing isn't meant to halt + // the whole update loop - see its ASSET_ENTRY_STATE_ERROR handling). + if(entry->state == ASSET_ENTRY_STATE_ERROR) { + assetEntryUnlock(entry); + errorThrow("Failed to load asset: %s", entry->name); + } + usleep(1000); errorret_t ret = assetUpdate(); if(errorIsNotOk(ret)) { diff --git a/src/dusk/asset/assetdsk.c b/src/dusk/asset/assetdsk.c index bc8fc374..b17cc9ce 100644 --- a/src/dusk/asset/assetdsk.c +++ b/src/dusk/asset/assetdsk.c @@ -55,6 +55,19 @@ errorret_t assetDskOpenFromPath( const char_t *path, zip_t **outCompressed, zip_t **outStored +) { + errorChain(assetDskOpenFromPathRange( + path, 0, SIZE_MAX, outCompressed, outStored + )); + errorOk(); +} + +errorret_t assetDskOpenFromPathRange( + const char_t *path, + const size_t baseOffset, + const size_t baseSize, + zip_t **outCompressed, + zip_t **outStored ) { assertNotNull(path, "Path cannot be NULL."); assertNotNull(outCompressed, "Out compressed cannot be NULL."); @@ -68,6 +81,11 @@ errorret_t assetDskOpenFromPath( errorThrow("Failed to open dusk.dsk: %s", path); } + if(fseek(headerFile, (long) baseOffset, SEEK_SET) != 0) { + fclose(headerFile); + errorThrow("Failed to seek to dusk.dsk range in file: %s", path); + } + uint8_t headerBytes[ASSET_DSK_HEADER_SIZE]; size_t headerRead = fread(headerBytes, 1, sizeof(headerBytes), headerFile); fclose(headerFile); @@ -78,11 +96,21 @@ errorret_t assetDskOpenFromPath( assetdskheader_t header; errorChain(assetDskParseHeader(headerBytes, sizeof(headerBytes), &header)); + if( + (size_t) header.compressedOffset + header.compressedSize > baseSize || + (size_t) header.storedOffset + header.storedSize > baseSize + ) { + errorThrow("dusk.dsk header describes ranges beyond its containing file."); + } + zip_error_t zipError; zip_error_init(&zipError); zip_source_t *compressedSource = zip_source_file_create( - path, header.compressedOffset, (zip_int64_t) header.compressedSize, &zipError + path, + (zip_uint64_t) (baseOffset + header.compressedOffset), + (zip_int64_t) header.compressedSize, + &zipError ); if(compressedSource == NULL) { errorThrow( @@ -99,7 +127,10 @@ errorret_t assetDskOpenFromPath( } zip_source_t *storedSource = zip_source_file_create( - path, header.storedOffset, (zip_int64_t) header.storedSize, &zipError + path, + (zip_uint64_t) (baseOffset + header.storedOffset), + (zip_int64_t) header.storedSize, + &zipError ); if(storedSource == NULL) { zip_close(*outCompressed); @@ -132,7 +163,7 @@ errorret_t assetDskOpenFromPath( *outCompressed = NULL; errorThrow("Failed to re-open dusk.dsk to verify stored checksum: %s", path); } - fseek(storedFile, (long) header.storedOffset, SEEK_SET); + fseek(storedFile, (long) (baseOffset + header.storedOffset), SEEK_SET); size_t storedRead = fread(storedBytes, 1, header.storedSize, storedFile); fclose(storedFile); if(storedRead != header.storedSize) { diff --git a/src/dusk/asset/assetdsk.h b/src/dusk/asset/assetdsk.h index 055a005f..345e950b 100644 --- a/src/dusk/asset/assetdsk.h +++ b/src/dusk/asset/assetdsk.h @@ -64,6 +64,9 @@ errorret_t assetDskParseHeader( * archive's checksum is intentionally not verified here since doing so * would require reading the bulk of the game's assets just to compute it. * + * See assetDskOpenFromPathRange() for the case where the DSK2 blob isn't + * the whole file (e.g. embedded inside another container). + * * @param path Filesystem path to the dusk.dsk file. * @param outCompressed Set to the opened compressed archive on success. * @param outStored Set to the opened stored archive on success. @@ -76,6 +79,30 @@ errorret_t assetDskOpenFromPath( zip_t **outStored ); +/** + * Same as assetDskOpenFromPath(), but the DSK2 blob doesn't start at the + * beginning of `path` - it's a byte range embedded inside a larger + * container file (e.g. the PSAR region of an EBOOT.PBP). Every offset the + * header describes is relative to `baseOffset`; `baseSize` bounds them + * (the size of the embedded blob, not the whole container file) - pass + * SIZE_MAX to skip that check when the caller doesn't know/care. + * + * @param path Filesystem path to the container file. + * @param baseOffset Byte offset within `path` where the DSK2 blob starts. + * @param baseSize Number of bytes available at `baseOffset`. + * @param outCompressed Set to the opened compressed archive on success. + * @param outStored Set to the opened stored archive on success. + * @return OK on success, error if the file is missing, too short, has a + * bad header, or either archive fails to open/verify. + */ +errorret_t assetDskOpenFromPathRange( + const char_t *path, + const size_t baseOffset, + const size_t baseSize, + zip_t **outCompressed, + zip_t **outStored +); + /** * Opens both archives of a dusk.dsk file already fully resident in memory * (e.g. a PSAR embedded in an EBOOT.PBP, or an ISO-embedded file already diff --git a/src/dusk/asset/assetfile.c b/src/dusk/asset/assetfile.c index 19adfa3d..0ecf7ac0 100644 --- a/src/dusk/asset/assetfile.c +++ b/src/dusk/asset/assetfile.c @@ -100,8 +100,10 @@ errorret_t assetFileRead( uint8_t tempBuffer[256]; while(bytesRemaining > 0) { size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer)); + // The recursive call below already advances file->position by + // chunkSize (the non-NULL branch does this itself) - do not also + // advance it here, or every skip ends up double-counted. errorChain(assetFileRead(file, tempBuffer, chunkSize)); - file->position += chunkSize; bytesRemaining -= chunkSize; } file->lastRead = bufferSize; diff --git a/src/dusk/asset/loader/CMakeLists.txt b/src/dusk/asset/loader/CMakeLists.txt index 5edd469c..775d5951 100644 --- a/src/dusk/asset/loader/CMakeLists.txt +++ b/src/dusk/asset/loader/CMakeLists.txt @@ -17,4 +17,5 @@ add_subdirectory(locale) add_subdirectory(json) add_subdirectory(chunk) add_subdirectory(dmf) -add_subdirectory(cutscene) \ No newline at end of file +add_subdirectory(cutscene) +add_subdirectory(wav) \ No newline at end of file diff --git a/src/dusk/asset/loader/assetloader.c b/src/dusk/asset/loader/assetloader.c index 1bfa92b5..689549cb 100644 --- a/src/dusk/asset/loader/assetloader.c +++ b/src/dusk/asset/loader/assetloader.c @@ -57,4 +57,10 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = { .loadAsync = assetCutsceneLoaderAsync, .dispose = assetCutsceneDispose }, + + [ASSET_LOADER_TYPE_WAV] = { + .loadSync = assetWavLoaderSync, + .loadAsync = assetWavLoaderAsync, + .dispose = assetWavDispose + }, }; diff --git a/src/dusk/asset/loader/assetloader.h b/src/dusk/asset/loader/assetloader.h index fdfd3ecb..f3970529 100644 --- a/src/dusk/asset/loader/assetloader.h +++ b/src/dusk/asset/loader/assetloader.h @@ -14,6 +14,7 @@ #include "asset/loader/json/assetjsonloader.h" #include "asset/loader/chunk/assetchunkloader.h" #include "asset/loader/cutscene/assetcutsceneloader.h" +#include "asset/loader/wav/assetwavloader.h" typedef enum { ASSET_LOADER_TYPE_NULL, @@ -26,6 +27,7 @@ typedef enum { ASSET_LOADER_TYPE_JSON, ASSET_LOADER_TYPE_CHUNK, ASSET_LOADER_TYPE_CUTSCENE, + ASSET_LOADER_TYPE_WAV, ASSET_LOADER_TYPE_COUNT } assetloadertype_t; @@ -39,6 +41,7 @@ typedef union { assetjsonloaderloading_t json; assetchunkloaderloading_t chunk; assetcutsceneloaderloading_t cutscene; + assetwavloaderloading_t wav; } assetloaderloading_t; typedef union { @@ -50,6 +53,7 @@ typedef union { assetjsonoutput_t json; assetchunkoutput_t chunk; assetcutsceneoutput_t cutscene; + assetwavoutput_t wav; } assetloaderoutput_t; typedef union { @@ -59,6 +63,7 @@ typedef union { assetjsonloaderinput_t json; assetchunkloaderinput_t chunk; assetcutsceneloaderinput_t cutscene; + assetwavloaderinput_t wav; } assetloaderinput_t; typedef struct assetloading_s assetloading_t; diff --git a/src/dusk/asset/loader/wav/CMakeLists.txt b/src/dusk/asset/loader/wav/CMakeLists.txt new file mode 100644 index 00000000..585adba9 --- /dev/null +++ b/src/dusk/asset/loader/wav/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +# Sources +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + assetwavloader.c +) diff --git a/src/dusk/asset/loader/wav/assetwavloader.c b/src/dusk/asset/loader/wav/assetwavloader.c new file mode 100644 index 00000000..fa32d7c7 --- /dev/null +++ b/src/dusk/asset/loader/wav/assetwavloader.c @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "assetwavloader.h" +#include "util/memory.h" +#include "util/endian.h" +#include "assert/assert.h" +#include "asset/loader/assetloading.h" +#include "asset/loader/assetentry.h" + +// "RIFF" + chunkSize + "WAVE", before any sub-chunks begin. +#define ASSET_WAV_RIFF_HEADER_SIZE 12 + +// A sub-chunk header is a 4-byte id followed by a 4-byte little-endian size. +#define ASSET_WAV_CHUNK_HEADER_SIZE 8 + +#define ASSET_WAV_FMT_CHUNK_SIZE_MIN 16 +// Sane upper bound for a stack buffer - real fmt chunks (PCM or otherwise) +// never come close to this; anything bigger is treated as malformed. +#define ASSET_WAV_FMT_CHUNK_SIZE_MAX 64 + +errorret_t assetWavLoaderAsync(assetloading_t *loading) { + assertNotNull(loading, "Loading cannot be NULL"); + assertNotMainThread("Async loader should not be on main thread."); + + if(loading->loading.wav.state != ASSET_WAV_LOADER_STATE_READ_HEADER) { + errorOk(); + } + + assetwavfile_t *wavFile = &loading->entry->data.wav; + memoryZero(wavFile, sizeof(assetwavfile_t)); + + assetfile_t file; + assetLoaderErrorChain(loading, assetFileInit( + &file, loading->entry->name, NULL, NULL + )); + assetLoaderErrorChain(loading, assetFileOpen(&file)); + assetLoaderErrorChain(loading, assetWavParseHeader(&file, wavFile)); + assetLoaderErrorChain(loading, assetFileClose(&file)); + assetLoaderErrorChain(loading, assetFileDispose(&file)); + + loading->loading.wav.state = ASSET_WAV_LOADER_STATE_DONE; + loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC; + errorOk(); +} + +errorret_t assetWavLoaderSync(assetloading_t *loading) { + assertNotNull(loading, "Loading cannot be NULL"); + assertTrue(loading->type == ASSET_LOADER_TYPE_WAV, "Invalid type."); + assertIsMainThread("Must be called from the main thread."); + + switch(loading->loading.wav.state) { + case ASSET_WAV_LOADER_STATE_INITIAL: + loading->loading.wav.state = ASSET_WAV_LOADER_STATE_READ_HEADER; + loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC; + errorOk(); + break; + + case ASSET_WAV_LOADER_STATE_DONE: + break; + + default: + errorOk(); + } + + loading->entry->state = ASSET_ENTRY_STATE_LOADED; + errorOk(); +} + +errorret_t assetWavDispose(assetentry_t *entry) { + assertNotNull(entry, "Asset entry cannot be NULL"); + assertTrue(entry->type == ASSET_LOADER_TYPE_WAV, "Invalid type."); + assertIsMainThread("Must be called from the main thread."); + + errorOk(); +} + +errorret_t assetWavParseHeader( + assetfile_t *file, + assetwavfile_t *wavFile +) { + assertNotNull(file, "Asset file cannot be NULL."); + assertNotNull(wavFile, "Wav file cannot be NULL."); + + uint8_t riffHeader[ASSET_WAV_RIFF_HEADER_SIZE]; + errorChain(assetFileRead(file, riffHeader, sizeof(riffHeader))); + + if(memoryCompare(riffHeader, "RIFF", 4) != 0) { + errorThrow("WAV file has an invalid RIFF header: %s", file->filename); + } + if(memoryCompare(riffHeader + 8, "WAVE", 4) != 0) { + errorThrow("WAV file has an invalid WAVE header: %s", file->filename); + } + + bool_t foundFormat = false; + bool_t foundData = false; + uint16_t audioFormat = 0; + + // Walk the chunk list, reading only chunk headers (and the small "fmt " + // body) - every other chunk, "data"'s sample bytes included, is skipped + // via a NULL-buffer read rather than buffered into memory. Stops as soon + // as "data" is found; nothing after it matters here. + while(!foundData && (size_t) file->position < (size_t) file->size) { + uint8_t chunkHeader[ASSET_WAV_CHUNK_HEADER_SIZE]; + errorChain(assetFileRead(file, chunkHeader, sizeof(chunkHeader))); + + uint32_t chunkSizeLE; + memoryCopy(&chunkSizeLE, chunkHeader + 4, sizeof(chunkSizeLE)); + const uint32_t chunkSize = endianLittleToHost32(chunkSizeLE); + + // Chunks are padded to an even total size - the pad byte (if any) + // isn't included in chunkSize but still needs to be skipped over. + const uint32_t chunkSizePadded = chunkSize + (chunkSize % 2); + + if(memoryCompare(chunkHeader, "fmt ", 4) == 0) { + if( + chunkSize < ASSET_WAV_FMT_CHUNK_SIZE_MIN || + chunkSize > ASSET_WAV_FMT_CHUNK_SIZE_MAX + ) { + errorThrow("WAV 'fmt ' chunk has an unsupported size: %u", chunkSize); + } + + uint8_t fmtBuffer[ASSET_WAV_FMT_CHUNK_SIZE_MAX]; + errorChain(assetFileRead(file, fmtBuffer, chunkSize)); + if(chunkSize % 2 != 0) { + errorChain(assetFileRead(file, NULL, 1)); + } + + uint16_t u16; + uint32_t u32; + + memoryCopy(&u16, fmtBuffer + 0, sizeof(u16)); + audioFormat = endianLittleToHost16(u16); + + memoryCopy(&u16, fmtBuffer + 2, sizeof(u16)); + wavFile->channels = (uint8_t) endianLittleToHost16(u16); + + memoryCopy(&u32, fmtBuffer + 4, sizeof(u32)); + wavFile->sampleRate = endianLittleToHost32(u32); + + memoryCopy(&u16, fmtBuffer + 14, sizeof(u16)); + wavFile->bitsPerSample = (uint8_t) endianLittleToHost16(u16); + + foundFormat = true; + } else if(memoryCompare(chunkHeader, "data", 4) == 0) { + wavFile->dataOffset = (size_t) file->position; + wavFile->dataSize = chunkSize; + foundData = true; + } else { + errorChain(assetFileRead(file, NULL, chunkSizePadded)); + } + } + + if(!foundFormat) { + errorThrow("WAV file is missing its 'fmt ' chunk: %s", file->filename); + } + if(!foundData) { + errorThrow("WAV file is missing its 'data' chunk: %s", file->filename); + } + if(audioFormat != 1) { + errorThrow( + "Unsupported WAV audio format: %u (only PCM is supported): %s", + audioFormat, file->filename + ); + } + if(wavFile->bitsPerSample != 16 && wavFile->bitsPerSample != 24) { + errorThrow( + "Unsupported WAV bits per sample: %u (only 16/24-bit is supported): %s", + wavFile->bitsPerSample, file->filename + ); + } + if(wavFile->channels == 0) { + errorThrow("WAV file declares 0 channels: %s", file->filename); + } + + errorOk(); +} diff --git a/src/dusk/asset/loader/wav/assetwavloader.h b/src/dusk/asset/loader/wav/assetwavloader.h new file mode 100644 index 00000000..35002d4d --- /dev/null +++ b/src/dusk/asset/loader/wav/assetwavloader.h @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "asset/assetfile.h" + +typedef struct assetloading_s assetloading_t; +typedef struct assetentry_s assetentry_t; + +/** Input passed to the wav loader - currently unused. */ +typedef struct { void *nothing; } assetwavloaderinput_t; + +typedef enum { + ASSET_WAV_LOADER_STATE_INITIAL, + ASSET_WAV_LOADER_STATE_READ_HEADER, + ASSET_WAV_LOADER_STATE_DONE +} assetwavloaderstate_t; + +/** Per-slot scratch data used while the wav file is loading. */ +typedef struct { + assetwavloaderstate_t state; +} assetwavloaderloading_t; + +/** + * Parsed metadata for a WAV asset - only the RIFF/WAVE/fmt/data chunk + * headers are ever read; the PCM sample data itself is never loaded here + * (see @ref assetWavParseHeader). Playback reads sample data directly + * from the archive on demand instead - see audiostreampcm.h. + */ +typedef struct { + /** Sample rate of the PCM data, in Hz. */ + uint32_t sampleRate; + + /** Number of interleaved channels in the PCM data. */ + uint8_t channels; + + /** + * Bits per sample of the *source* file data - 16 or 24 (see + * assetWavParseHeader). Playback always reads 16-bit samples out via + * audioStreamPcmRead() regardless of this - a 24-bit source is + * truncated to 16-bit there, since none of this project's audio + * backends (PSP/Dolphin hardware, SDL2 on Linux) accept anything wider. + */ + uint8_t bitsPerSample; + + /** Byte offset from the start of the file to the first PCM sample. */ + size_t dataOffset; + + /** Size of the PCM data, in bytes. */ + size_t dataSize; +} assetwavfile_t; + +/** Convenience alias - the loaded output type of a wav asset entry. */ +typedef assetwavfile_t assetwavoutput_t; + +/** + * Asynchronous loader callback. Opens the WAV file and reads just enough of + * it to locate and parse the `fmt ` chunk and locate (not read) the `data` + * chunk - see @ref assetWavParseHeader. All I/O happens here so the main + * thread is not blocked. Sets entry state to `ASSET_ENTRY_STATE_PENDING_SYNC` + * on success or `ASSET_ENTRY_STATE_ERROR` on failure. + * + * @param loading The loading slot for this asset entry. + * @return OK on success, error otherwise. + */ +errorret_t assetWavLoaderAsync(assetloading_t *loading); + +/** + * Synchronous loader callback. Confirms the async phase completed and marks + * the entry as `ASSET_ENTRY_STATE_LOADED`. + * + * @param loading The loading slot for this asset entry. + * @return OK on success, error otherwise. + */ +errorret_t assetWavLoaderSync(assetloading_t *loading); + +/** + * Dispose callback. The wav asset owns no allocations of its own (its data + * chunk is read directly from the archive by whichever streams are playing + * it, each through their own handle - see audiostreampcm.h), so this is + * currently a no-op beyond the standard asserts. + * + * @param entry The asset entry to dispose. + * @return OK on success, error otherwise. + */ +errorret_t assetWavDispose(assetentry_t *entry); + +/** + * Parses a RIFF/WAVE file's chunk structure from an already-open asset + * file, reading only chunk headers (and the small `fmt ` chunk body) - + * every other chunk, including `data`'s actual sample bytes, is skipped + * over via `assetFileRead(file, NULL, size)` rather than read into memory, + * so this never buffers the (potentially large) PCM payload. + * + * Stops as soon as the `data` chunk header is found, recording its file + * offset and declared size in `wavFile` without reading any of its bytes - + * the file is left positioned at the start of the PCM data. + * + * Only PCM (audio format 1), 16- or 24-bit-per-sample WAV data is + * supported - see audiostreampcm.h's audioStreamPcmRead() for how a + * 24-bit source gets truncated to the 16-bit output every platform + * backend expects. Requires the `fmt ` chunk to appear before `data`, per + * the WAV spec's recommended ordering. + * + * @param file An open asset file, positioned at the start of the WAV data. + * @param wavFile Struct whose fields will be filled in. + * @return OK on success, error if the file is malformed or an unsupported + * format. + */ +errorret_t assetWavParseHeader( + assetfile_t *file, + assetwavfile_t *wavFile +); diff --git a/src/dusk/audio/audio.c b/src/dusk/audio/audio.c index 65ba62c2..586d916f 100644 --- a/src/dusk/audio/audio.c +++ b/src/dusk/audio/audio.c @@ -7,6 +7,7 @@ #include "audio.h" #include "util/memory.h" +#include "assert/assert.h" audio_t AUDIO; @@ -18,7 +19,17 @@ errorret_t audioInit() { errorOk(); } -audiostream_t * audioAquireStream() { +audiostream_t * audioAquireStream(assetentry_t *asset) { + assertNotNull(asset, "Asset cannot be NULL."); + // Same check audioStreamInit() makes - see its own comment on why this + // is an assert (a programmer error, not untrusted data). Checking here + // too catches the mistake as early as possible, before any stream slot + // is handed out for it. + assertTrue( + asset->type == ASSET_LOADER_TYPE_WAV, + "Unsupported asset type for an audio stream." + ); + for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) { audiostream_t *stream = &AUDIO.streams[i]; if(stream->type == AUDIO_STREAM_TYPE_NULL) { diff --git a/src/dusk/audio/audio.h b/src/dusk/audio/audio.h index 237f8c30..def8bd4f 100644 --- a/src/dusk/audio/audio.h +++ b/src/dusk/audio/audio.h @@ -36,12 +36,18 @@ extern audio_t AUDIO; errorret_t audioInit(); /** - * Aquires an available audio stream, can return NULL if there is no available - * stream. - * - * @return Pointer to an available audio stream. + * Aquires an available audio stream for playing the given asset. Can return + * NULL if there is no available stream slot. + * + * The returned stream is not yet configured to play `asset` - call + * audioStreamInit(stream, asset) next. + * + * @param asset The asset the caller intends to play - validated eagerly so + * an unsupported asset type is caught here rather than only + * once audioStreamInit() is called. + * @return Pointer to an available audio stream, or NULL if none are free. */ -audiostream_t * audioAquireStream(); +audiostream_t * audioAquireStream(assetentry_t *asset); /** * Updates the audio subsystem, updating every active stream. Should be diff --git a/src/dusk/audio/audiostream.c b/src/dusk/audio/audiostream.c index bf3afc8a..ae72ce12 100644 --- a/src/dusk/audio/audiostream.c +++ b/src/dusk/audio/audiostream.c @@ -8,12 +8,23 @@ #include "audiostream.h" #include "assert/assert.h" -errorret_t audioStreamInit(audiostream_t *stream) { +errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset) { assertNotNull(stream, "Stream cannot be NULL."); + assertNotNull(asset, "Asset cannot be NULL."); + assertTrue( + asset->state == ASSET_ENTRY_STATE_LOADED, + "Asset must be loaded before it can back an audio stream." + ); + // Only WAV (-> PCM) assets can back an audio stream today - a caller + // passing the wrong kind of asset entry is a programmer error, not + // something that can happen from untrusted data, so this is an assert + // rather than an errorThrow (see feedback_assert_vs_error convention). + assertTrue( + asset->type == ASSET_LOADER_TYPE_WAV, + "Unsupported asset type for an audio stream." + ); stream->state = 0; - stream->data = NULL; - stream->dataSize = 0; stream->volume = 0xFF; stream->directionality = AUDIO_STREAM_CENTER; stream->loopStart = -1; @@ -27,8 +38,20 @@ errorret_t audioStreamInit(audiostream_t *stream) { stream->loopCount = 0; stream->lastLoopCount = 0; - // Type-specific setup (audioStreamPcmInit() / audioStreamMp3Init()) sets - // stream->type and asks the platform implementation to set up its state. + // Locked for as long as the stream is in use (see audiostream_t.asset's + // own comment) - released in audioStreamDispose(). + stream->asset = asset; + assetEntryLock(asset); + + // Type-specific setup (audioStreamPcmInit() / audioStreamMp3Init(), once + // MP3 exists) determines stream->type and asks the platform + // implementation to set up its state. + errorret_t ret = audioStreamPcmInit(stream); + if(errorIsNotOk(ret)) { + assetEntryUnlock(asset); + stream->asset = NULL; + errorChain(ret); + } errorOk(); } @@ -192,6 +215,15 @@ errorret_t audioStreamDispose(audiostream_t *stream) { errorChain(audioStreamPlatformDispose(stream)); } + if(stream->type == AUDIO_STREAM_TYPE_PCM) { + errorChain(audioStreamPcmDispose(stream)); + } + + if(stream->asset != NULL) { + assetEntryUnlock(stream->asset); + stream->asset = NULL; + } + stream->type = AUDIO_STREAM_TYPE_NULL; errorOk(); diff --git a/src/dusk/audio/audiostream.h b/src/dusk/audio/audiostream.h index 9b456f1c..b4b06ef7 100644 --- a/src/dusk/audio/audiostream.h +++ b/src/dusk/audio/audiostream.h @@ -9,6 +9,7 @@ #include "audio/audiostreampcm.h" #include "audio/audiostreammp3.h" #include "audio/audiostreamplatform.h" +#include "asset/loader/assetentry.h" #ifndef audioStreamPlatformInit #error "audioStreamPlatformInit is not defined" @@ -50,11 +51,14 @@ typedef struct audiostream_s { // What state the stream is in. uint8_t state; - // Raw PCM Data, already decoded. Not owned by the stream. - uint8_t *data; - - // Size of the buffer pointed to by data, in bytes. - size_t dataSize; + // The asset backing this stream's data (e.g. a WAV file) - determines + // `type` (see audioStreamInit()) and is read from on demand by the + // type-specific module (e.g. audiostreampcm.h) rather than ever being + // fully decoded into memory up front. The stream holds its own lock on + // this entry (assetEntryLock()/assetEntryUnlock()) for as long as it's + // in use, independent of whatever lock(s) the caller that requested + // playback may also be holding. + assetentry_t *asset; // Loudness. Can only be 0 to 0xFF uint8_t volume; @@ -127,15 +131,22 @@ typedef struct audiostream_s { } audiostream_t; /** - * Initializes an audio stream in preparation for playback. Does not begin - * playback; call audioStreamPlay() once the stream is configured (see - * audioStreamPcmInit() / audioStreamMp3Init() for the type-specific setup - * this must be followed by). + * Initializes an audio stream to play the given asset, determining the + * stream's type from the asset's loader type (e.g. a WAV asset becomes an + * AUDIO_STREAM_TYPE_PCM stream) and dispatching to that type's own setup + * (e.g. audioStreamPcmInit()). Does not begin playback; call + * audioStreamPlay() once this returns. + * + * The stream takes its own lock on `asset` (see audiostream_t.asset's own + * comment), released by audioStreamDispose() - the asset must already be + * loaded (ASSET_ENTRY_STATE_LOADED) when this is called. * * @param stream The audio stream to initialize. + * @param asset The loaded asset to play - its type must be one this + * function supports (currently only ASSET_LOADER_TYPE_WAV). * @return Error indicating success or failure. */ -errorret_t audioStreamInit(audiostream_t *stream); +errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset); /** * Begins or resumes playback of the given audio stream. @@ -218,10 +229,10 @@ void audioStreamSetDirectionality( ); /** - * Sets whether the given audio stream loops back to the start when it - * reaches the end, rather than stopping and firing onEnd. Only looping - * back to the very start of the buffer is currently supported - loopTo is - * not yet honored. + * Sets whether the given audio stream loops back to loopTo (or the start + * of the buffer, by default - see audioStreamSetLoopPoints()) when it + * reaches loopStart (or the end of the buffer), rather than stopping and + * firing onEnd. * * @param stream The audio stream to update. * @param looping Whether the stream should loop. diff --git a/src/dusk/audio/audiostreampcm.c b/src/dusk/audio/audiostreampcm.c index 26a915ee..9eb29ea8 100644 --- a/src/dusk/audio/audiostreampcm.c +++ b/src/dusk/audio/audiostreampcm.c @@ -8,29 +8,162 @@ #include "audiostreampcm.h" #include "audiostream.h" #include "assert/assert.h" +#include "util/math.h" +#include "util/memory.h" -errorret_t audioStreamPcmInit( - audiostream_t *stream, - uint8_t *data, - const size_t dataSize, - const uint32_t sampleRate, - const uint8_t channels -) { +errorret_t audioStreamPcmInit(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); - assertNotNull(data, "Data cannot be NULL."); + assertNotNull(stream->asset, "Stream must have an asset assigned."); + assertTrue( + stream->asset->type == ASSET_LOADER_TYPE_WAV, + "Asset is not a WAV file." + ); + + assetwavfile_t *wav = &stream->asset->data.wav; stream->type = AUDIO_STREAM_TYPE_PCM; - stream->data = data; - stream->dataSize = dataSize; - stream->pcm.sampleRate = sampleRate; - stream->pcm.channels = channels; + stream->pcm.sampleRate = wav->sampleRate; + stream->pcm.channels = wav->channels; stream->duration = ( - (float_t) dataSize / - (float_t) (channels * sizeof(int16_t)) / - (float_t) sampleRate + (float_t) audioStreamPcmGetTotalFrames(stream) / + (float_t) wav->sampleRate ); + // Each stream opens its own independent handle to the same underlying + // asset file, rather than sharing a single handle on the asset entry - + // multiple streams playing the same asset concurrently (e.g. two + // simultaneous plays of the same sound effect) would otherwise fight + // over one shared read position. This isn't the final answer for that + // (a shared decode/cache layer would scale better than N independent + // decompression streams of the same data), but it's a correct one for + // now - a problem to revisit once it actually matters. + errorChain(assetFileInit( + &stream->pcm.file, stream->asset->name, NULL, NULL + )); + errorChain(assetFileOpen(&stream->pcm.file)); + errorChain(assetFileRead(&stream->pcm.file, NULL, wav->dataOffset)); + errorChain(audioStreamPlatformInit(stream)); errorOk(); } + +errorret_t audioStreamPcmDispose(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM."); + + errorChain(assetFileClose(&stream->pcm.file)); + errorChain(assetFileDispose(&stream->pcm.file)); + + errorOk(); +} + +size_t audioStreamPcmGetTotalFrames(const audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM."); + + const assetwavfile_t *wav = &stream->asset->data.wav; + const size_t sourceFrameSize = ( + stream->pcm.channels * (wav->bitsPerSample / 8) + ); + return wav->dataSize / sourceFrameSize; +} + +errorret_t audioStreamPcmSeek(audiostream_t *stream, const size_t frame) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM."); + + assetwavfile_t *wav = &stream->asset->data.wav; + const size_t sourceFrameSize = ( + stream->pcm.channels * (wav->bitsPerSample / 8) + ); + const size_t targetByte = wav->dataOffset + (frame * sourceFrameSize); + + assertTrue( + frame * sourceFrameSize <= wav->dataSize, + "Seek frame is beyond the end of the PCM data." + ); + + const size_t currentByte = (size_t) stream->pcm.file.position; + if(targetByte < currentByte) { + // Only a full rewind can move a read cursor earlier once it's already + // advanced past a point - a compressed archive entry can't be decoded + // backward. See this function's own doc comment for the performance + // implications of a deep loopTo. + errorChain(assetFileRewind(&stream->pcm.file)); + errorChain(assetFileRead(&stream->pcm.file, NULL, targetByte)); + } else if(targetByte > currentByte) { + errorChain(assetFileRead( + &stream->pcm.file, NULL, targetByte - currentByte + )); + } + + errorOk(); +} + +errorret_t audioStreamPcmRead( + audiostream_t *stream, + int16_t *buffer, + const size_t frameCount, + size_t *outFramesRead +) { + assertNotNull(stream, "Stream cannot be NULL."); + assertNotNull(buffer, "Buffer cannot be NULL."); + assertNotNull(outFramesRead, "outFramesRead cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM."); + + assetwavfile_t *wav = &stream->asset->data.wav; + const size_t channels = stream->pcm.channels; + const size_t sourceSampleBytes = wav->bitsPerSample / 8; + const size_t sourceFrameSize = channels * sourceSampleBytes; + const size_t dataEndByte = wav->dataOffset + wav->dataSize; + const size_t currentByte = (size_t) stream->pcm.file.position; + + const size_t bytesAvailable = ( + currentByte < dataEndByte ? dataEndByte - currentByte : 0 + ); + const size_t framesAvailable = bytesAvailable / sourceFrameSize; + const size_t framesToRead = mathMin(frameCount, framesAvailable); + + if(framesToRead == 0) { + *outFramesRead = 0; + errorOk(); + } + + if(sourceSampleBytes == sizeof(int16_t)) { + // Output format already matches the source - read straight through. + errorChain(assetFileRead( + &stream->pcm.file, buffer, framesToRead * sourceFrameSize + )); + } else { + // 24-bit source (the only other width assetWavParseHeader() accepts) - + // read the raw 3-byte samples into a scratch buffer, then truncate + // each down to 16-bit, since none of PSP/Dolphin's audio hardware (or + // SDL2 on Linux) accepts anything wider - see assetwavfile_t's own + // comment on bitsPerSample. + assertTrue(sourceSampleBytes == 3, "Unsupported PCM sample width."); + + const size_t rawBytes = framesToRead * sourceFrameSize; + uint8_t *raw = memoryAllocate(rawBytes); + errorret_t ret = assetFileRead(&stream->pcm.file, raw, rawBytes); + if(errorIsNotOk(ret)) { + memoryFree(raw); + errorChain(ret); + } + + // 24-bit PCM samples are little-endian two's complement - the top two + // bytes of each (indices 1 and 2) already form that same value + // truncated to 16-bit (equivalent to an arithmetic right-shift by 8 + // bits, which preserves the sign correctly since byte 2 carries it). + const size_t sampleCount = framesToRead * channels; + for(size_t i = 0; i < sampleCount; i++) { + const uint8_t *sample = raw + (i * 3); + buffer[i] = (int16_t) (sample[1] | (sample[2] << 8)); + } + + memoryFree(raw); + } + + *outFramesRead = framesToRead; + errorOk(); +} diff --git a/src/dusk/audio/audiostreampcm.h b/src/dusk/audio/audiostreampcm.h index 457fa084..56f13cf3 100644 --- a/src/dusk/audio/audiostreampcm.h +++ b/src/dusk/audio/audiostreampcm.h @@ -7,6 +7,7 @@ #pragma once #include "error/error.h" +#include "asset/assetfile.h" typedef struct audiostream_s audiostream_t; @@ -16,25 +17,89 @@ typedef struct { // Number of interleaved channels in the stream's data. uint8_t channels; + + // This stream's own private handle into its asset's underlying file - + // deliberately not shared with any other stream reading the same asset + // (see audioStreamPcmInit's own comment for why). Its read cursor is + // what audioStreamPcmRead()/audioStreamPcmSeek() operate on. + assetfile_t file; } audiostreampcm_t; /** - * Configures the given audio stream to play raw, already-decoded 16-bit - * signed PCM data. Must be called after audioStreamInit() and before - * audioStreamPlay(). + * Configures the given PCM audio stream from its already-assigned asset + * (see audiostream_t.asset - set by audioStreamInit(), which is what + * should be calling this, not application code directly) rather than + * taking raw decoded data: sampleRate/channels/duration are read from the + * asset's parsed WAV header, and a private file handle is opened for + * reading sample data on demand as playback consumes it (see + * audioStreamPcmRead()) - the PCM data itself is never read into memory + * all at once, so this works the same regardless of how long the + * underlying clip is. + * + * Must be called after stream->asset is set and before audioStreamPlay(). * * @param stream The audio stream to configure. - * @param data The raw PCM data to play. Not copied; must remain valid for as - * long as the stream is playing it. - * @param dataSize The size of data, in bytes. - * @param sampleRate The sample rate of data, in Hz. - * @param channels The number of interleaved channels in data. * @return Error indicating success or failure. */ -errorret_t audioStreamPcmInit( +errorret_t audioStreamPcmInit(audiostream_t *stream); + +/** + * Disposes the given PCM audio stream's own private file handle. + * + * @param stream The audio stream to dispose. + * @return Error indicating success or failure. + */ +errorret_t audioStreamPcmDispose(audiostream_t *stream); + +/** + * Returns the total number of frames (one sample per channel) available + * in the stream's underlying PCM data - NOT simply the asset's declared + * byte size divided by a 16-bit frame size, since the source data isn't + * always 16-bit even though audioStreamPcmRead() always produces 16-bit + * output (see assetwavfile_t.bitsPerSample). Platform backends should use + * this rather than computing frame counts from dataSize themselves. + * + * @param stream The audio stream to query. Must be AUDIO_STREAM_TYPE_PCM. + * @return The total number of frames available. + */ +size_t audioStreamPcmGetTotalFrames(const audiostream_t *stream); + +/** + * Seeks the stream's private read cursor to the given frame offset + * (relative to the start of the PCM data - the same units as + * audiostream_t's startFrame/loopStart/loopTo, once converted from + * seconds). Seeking backward re-reads from the start of the underlying + * asset file (see assetFileRewind()) since a compressed archive entry can + * only be decoded forward - this makes a loop with a deep loopTo more + * expensive to restart than one near the start, which is a real + * performance caveat, not just a theoretical one, for anything backed by + * a compressed (not stored) asset archive entry. + * + * @param stream The audio stream to seek. Must be AUDIO_STREAM_TYPE_PCM. + * @param frame Frame offset to seek to, relative to the start of the PCM + * data. + * @return Error indicating success or failure. + */ +errorret_t audioStreamPcmSeek(audiostream_t *stream, const size_t frame); + +/** + * Reads up to frameCount frames of PCM sample data from the stream's + * current read position, advancing it by however many frames were + * actually read. Reads fewer than frameCount (down to zero) once the + * underlying asset's PCM data is exhausted, rather than erroring - it's + * up to the caller (platform code, which already knows about loop points) + * to request no more than what it wants read from within the current + * loop segment or the true end of the clip. + * + * @param stream The audio stream to read from. Must be AUDIO_STREAM_TYPE_PCM. + * @param buffer Destination buffer, sized for at least frameCount frames. + * @param frameCount Maximum number of frames to read. + * @param outFramesRead Set to the number of frames actually read. + * @return Error indicating success or failure. + */ +errorret_t audioStreamPcmRead( audiostream_t *stream, - uint8_t *data, - const size_t dataSize, - const uint32_t sampleRate, - const uint8_t channels + int16_t *buffer, + const size_t frameCount, + size_t *outFramesRead ); diff --git a/src/dusk/engine/engine.c b/src/dusk/engine/engine.c index 3b7f9b69..4976cdab 100644 --- a/src/dusk/engine/engine.c +++ b/src/dusk/engine/engine.c @@ -23,23 +23,9 @@ #include "console/console.h" #include "save/save.h" #include "audio/audio.h" -#include "util/math.h" -#include engine_t ENGINE; -// 44100Hz: PSP's sceAudioChReserve hardware channels are fixed at this rate. -#define AUDIO_TEST_TONE_SAMPLE_RATE 44100 -// 441Hz (not 440): divides 44100Hz evenly into exactly 100 samples/cycle, -// so a whole-second buffer's last sample exactly matches its first - -// a seamless loop point with no source-data discontinuity to click on. -#define AUDIO_TEST_TONE_FREQUENCY 441 -#define AUDIO_TEST_TONE_SAMPLES AUDIO_TEST_TONE_SAMPLE_RATE -#define AUDIO_TEST_TONE_SIZE (AUDIO_TEST_TONE_SAMPLES * sizeof(int16_t)) - -// 1 second, 16-bit signed mono PCM sine wave, used to smoke-test playback. -static uint8_t AUDIO_TEST_TONE[AUDIO_TEST_TONE_SIZE]; - void engineTestToneOnEnd(audiostream_t *stream) { consolePrint("Test tone finished playing"); } @@ -73,20 +59,20 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) { #endif errorChain(sceneInit()); - int16_t *toneSamples = (int16_t *) AUDIO_TEST_TONE; - for(uint32_t i = 0; i < AUDIO_TEST_TONE_SAMPLES; i++) { - float_t t = (float_t) i / AUDIO_TEST_TONE_SAMPLE_RATE; - toneSamples[i] = (int16_t) ( - sinf(2.0f * MATH_PI * AUDIO_TEST_TONE_FREQUENCY * t) * INT16_MAX - ); - } + // Smoke-tests the audio subsystem end to end (asset loading -> PCM + // streaming -> platform playback) against a real WAV asset rather than + // synthesizing PCM data at runtime. + assetentry_t *testToneEntry = assetLock( + "audio/pepsiman.wav", ASSET_LOADER_TYPE_WAV, NULL + // "audio/audiotest.wav", ASSET_LOADER_TYPE_WAV, NULL + ); + errorChain(assetRequireLoaded(testToneEntry)); + + audiostream_t *stream = audioAquireStream(testToneEntry); + assertNotNull(stream, "No free audio stream slots available."); + errorChain(audioStreamInit(stream, testToneEntry)); + assetUnlockEntry(testToneEntry); // The stream now holds its own lock. - audiostream_t *stream = audioAquireStream(); - errorChain(audioStreamInit(stream)); - errorChain(audioStreamPcmInit( - stream, AUDIO_TEST_TONE, AUDIO_TEST_TONE_SIZE, - AUDIO_TEST_TONE_SAMPLE_RATE, 1 - )); stream->onEnd = engineTestToneOnEnd; stream->onLoop = engineTestToneOnLoop; audioStreamSetLooping(stream, true); diff --git a/src/duskdolphin/audio/audiostreamdolphin.c b/src/duskdolphin/audio/audiostreamdolphin.c index 568676d3..10fe14d1 100644 --- a/src/duskdolphin/audio/audiostreamdolphin.c +++ b/src/duskdolphin/audio/audiostreamdolphin.c @@ -9,6 +9,7 @@ #include "audio/audiostream.h" #include "assert/assert.h" #include "util/memory.h" +#include "util/math.h" #include #include #include @@ -25,6 +26,7 @@ errorret_t audioStreamDolphinInit(audiostream_t *stream) { stream->platform.voiceId = voiceId; stream->platform.finished = false; + stream->platform.buffer = NULL; errorOk(); } @@ -34,6 +36,11 @@ errorret_t audioStreamDolphinDispose(audiostream_t *stream) { ansnd_deallocate_voice((u32) stream->platform.voiceId); + if(stream->platform.buffer != NULL) { + memoryFree(stream->platform.buffer); + stream->platform.buffer = NULL; + } + errorOk(); } @@ -41,6 +48,28 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); const size_t frameSize = stream->pcm.channels * sizeof(int16_t); + const size_t totalFrames = audioStreamPcmGetTotalFrames(stream); + + // loopEndFrame/loopToFrame define the loop segment [loopToFrame, + // loopEndFrame) the DSP wraps within, once looping is enabled - + // defaulting to the whole clip (loopStart == -1, loopTo == 0) so + // behaviour is unchanged when no explicit loop points are configured. + // Same math as PSP/Linux - see their own comments. + const size_t loopEndFrame = stream->loopStart >= 0 + ? mathMin((size_t) (stream->loopStart * stream->pcm.sampleRate), totalFrames) + : totalFrames; + const size_t loopToFrame = mathMin( + (size_t) (stream->loopTo * stream->pcm.sampleRate), loopEndFrame + ); + + const size_t startFrame = mathMin(stream->startFrame, totalFrames); + stream->startFrame = 0; + stream->seeking = false; + + // A seek can legitimately land past the loop segment's end (e.g. into an + // outro) - buffer out to the true end of the clip in that case, same as + // PSP/Linux, instead of only ever buffering the loop segment. + const size_t bufferFrames = startFrame < loopEndFrame ? loopEndFrame : totalFrames; float_t leftFactor, rightFactor; audioStreamGetPanFactors(stream, &leftFactor, &rightFactor); @@ -49,6 +78,37 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { stream->platform.finished = false; + // ansnd's hardware loop needs one contiguous buffer spanning everything + // the voice might play - both start_offset and the loop segment address + // the same frame_data_ptr - so (unlike PSP/Linux, which stream bounded + // windows on demand) this reads the whole segment up front. Still + // bounded to the current loop segment's length rather than the whole + // clip if the clip is longer than one loop - see this struct field's + // own comment. Re-read fresh every Buffer() call. + if(stream->platform.buffer != NULL) { + memoryFree(stream->platform.buffer); + stream->platform.buffer = NULL; + } + + errorChain(audioStreamPcmSeek(stream, 0)); + uint8_t *buffer = (uint8_t *) memoryAllocate(bufferFrames * frameSize); + size_t framesRead = 0; + errorret_t readRet = audioStreamPcmRead( + stream, (int16_t *) buffer, bufferFrames, &framesRead + ); + if(errorIsNotOk(readRet)) { + memoryFree(buffer); + errorChain(readRet); + } + if(framesRead < bufferFrames) { + // The asset is shorter than its declared header size (corrupt or + // truncated) - pad what's missing with silence. + memoryZero( + buffer + (framesRead * frameSize), (bufferFrames - framesRead) * frameSize + ); + } + stream->platform.buffer = buffer; + ansnd_pcm_voice_config_t config; memoryZero(&config, sizeof(ansnd_pcm_voice_config_t)); config.samplerate = stream->pcm.sampleRate; @@ -63,12 +123,10 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { // ANSND_ERROR_INVALID_MEMORY) - it needs the physical address instead. // Flush first so the DMA sees what the CPU actually wrote, not stale // memory contents. - DCFlushRange(stream->data, stream->dataSize); - config.frame_data_ptr = MEM_VIRTUAL_TO_PHYSICAL(stream->data); - config.frame_count = (u32) (stream->dataSize / frameSize); - config.start_offset = (u32) stream->startFrame; - stream->startFrame = 0; - stream->seeking = false; + DCFlushRange(buffer, bufferFrames * frameSize); + config.frame_data_ptr = MEM_VIRTUAL_TO_PHYSICAL(buffer); + config.frame_count = (u32) bufferFrames; + config.start_offset = (u32) startFrame; config.voice_callback = audioStreamDolphinVoiceCallback; config.stream_callback = NULL; // single-buffer playback config.user_pointer = stream; @@ -87,10 +145,8 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { // loopTo's (true today only because the shared 441Hz test tone was // deliberately chosen to divide evenly into the sample rate). if(stream->state & AUDIO_STREAM_STATE_LOOPING) { - config.loop_start_offset = (u32) (stream->loopTo * stream->pcm.sampleRate); - config.loop_end_offset = stream->loopStart >= 0 - ? (u32) (stream->loopStart * stream->pcm.sampleRate) - 1 - : config.frame_count - 1; + config.loop_start_offset = (u32) loopToFrame; + config.loop_end_offset = (u32) loopEndFrame - 1; } s32 result = ansnd_configure_pcm_voice((u32) stream->platform.voiceId, &config); diff --git a/src/duskdolphin/audio/audiostreamdolphin.h b/src/duskdolphin/audio/audiostreamdolphin.h index de38e983..0b7daabc 100644 --- a/src/duskdolphin/audio/audiostreamdolphin.h +++ b/src/duskdolphin/audio/audiostreamdolphin.h @@ -19,6 +19,19 @@ typedef struct { // itself (unlike PSP/Linux), so this flag is how audioStreamDolphinIsFinished() // answers without a callback of its own. volatile bool_t finished; + + // ansnd's DSP hardware loop (loop_start_offset/loop_end_offset) needs one + // contiguous physical buffer spanning the whole segment it may play - + // both the pre-loop lead-in and the loop segment itself, since the DSP + // addresses both as offsets into the same frame_data_ptr. Unlike + // PSP/Linux (which stream bounded windows on demand), a Dolphin voice + // can't be handed data incrementally mid-playback, so this buffer is + // read from the asset and owned here for the lifetime of one pass - + // bounded to the current loop segment's length (not the whole clip, if + // the clip is longer than one loop), freed/reallocated on each + // audioStreamDolphinBuffer() call and released for good in + // audioStreamDolphinDispose(). + uint8_t *buffer; } audiostreamdolphin_t; /** @@ -40,7 +53,8 @@ errorret_t audioStreamDolphinInit(audiostream_t *stream); errorret_t audioStreamDolphinDispose(audiostream_t *stream); /** - * Sends the stream's currently staged PCM data (stream->data) to its + * Reads the current loop segment's PCM data from the stream's asset (see + * audiostreamdolphin_t.buffer's own comment) and sends it to its * allocated voice. * * @param stream The audio stream to output. diff --git a/src/dusklinux/audio/audiostreamlinux.c b/src/dusklinux/audio/audiostreamlinux.c index e5d742c4..257f1cce 100644 --- a/src/dusklinux/audio/audiostreamlinux.c +++ b/src/dusklinux/audio/audiostreamlinux.c @@ -10,12 +10,20 @@ #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."); @@ -46,7 +54,7 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); const size_t frameSize = stream->pcm.channels * sizeof(int16_t); - const size_t totalFrames = stream->dataSize / frameSize; + const size_t totalFrames = audioStreamPcmGetTotalFrames(stream); // Consumed synchronously (right here, in the same call that decided to // (re)buffer) rather than left for later - see startFrame's own comment. @@ -67,57 +75,106 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream) { // as PSP. if(startFrame >= endFrame) endFrame = totalFrames; - const size_t bytes = (endFrame - startFrame) * frameSize; - const uint8_t *segment = stream->data + (startFrame * frameSize); - // 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 on every Buffer() call would destroy that overlap and + // 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(audioStreamPcmSeek(stream, startFrame)); - int queued; - if(stream->volume == 0xFF) { - // Nothing to mix at full volume - queue the segment directly instead of - // allocating/zeroing a same-size scratch buffer just to copy it in. - queued = SDL_QueueAudio(stream->platform.device, segment, (Uint32) bytes); - } else { - uint8_t *mixed = memoryAllocate(bytes); - memoryZero(mixed, bytes); - SDL_MixAudioFormat( - mixed, segment, AUDIO_S16SYS, (Uint32) bytes, - (stream->volume * SDL_MIX_MAXVOLUME) / 0xFF - ); - queued = SDL_QueueAudio(stream->platform.device, mixed, (Uint32) bytes); - memoryFree(mixed); - } + stream->platform.position = startFrame; + stream->platform.endFrame = endFrame; - if(queued != 0) { - errorThrow("Failed to queue SDL2 audio data: %s", SDL_GetError()); - } + 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->pcm.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 = audioStreamPcmRead(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 be re-buffered) with AUDIO_LINUX_LEAD_FRAMES - // of margin still queued, rather than waiting for the queue to actually - // run dry. SDL_QueueAudio only ever appends to a FIFO, so queuing the next - // loop this early never causes overlap - it just avoids ever going silent - // while our once-per-frame Update() notices and catches up. Waiting for - // truly empty (as this used to) guarantees a gap by definition: silence - // has already started by the time "empty" can be observed. + // 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->pcm.channels * sizeof(int16_t) ); - return SDL_GetQueuedAudioSize(stream->platform.device) <= leadBytes; + 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; } diff --git a/src/dusklinux/audio/audiostreamlinux.h b/src/dusklinux/audio/audiostreamlinux.h index d7bbf423..48c21059 100644 --- a/src/dusklinux/audio/audiostreamlinux.h +++ b/src/dusklinux/audio/audiostreamlinux.h @@ -13,6 +13,17 @@ typedef struct audiostream_s audiostream_t; typedef struct { SDL_AudioDeviceID device; + + // Frame offset (relative to the start of the PCM data) the next + // audioStreamLinuxFeed() call should read from - advances as each + // window is queued, so a long clip is streamed progressively rather + // than read/queued all at once. + size_t position; + + // Frame offset this pass stops at (the current loop segment's end, or + // the true end of the clip if not looping) - recomputed by + // audioStreamLinuxBuffer() at the start of each pass. + size_t endFrame; } audiostreamlinux_t; /** @@ -34,17 +45,39 @@ errorret_t audioStreamLinuxInit(audiostream_t *stream); errorret_t audioStreamLinuxDispose(audiostream_t *stream); /** - * Sends the stream's currently staged PCM data (stream->data) to its SDL2 - * audio device and starts/resumes playback. + * Starts a new playback pass: seeks the stream's PCM read cursor to + * stream->startFrame (clearing the SDL queue first if this is an explicit + * seek rather than a natural loop restart - see stream->seeking's own + * comment), determines this pass's loop-segment end, and queues the first + * window via audioStreamLinuxFeed(). * * @param stream The audio stream to output. * @return Error state if any. */ errorret_t audioStreamLinuxBuffer(audiostream_t *stream); +/** + * Reads and queues one bounded window of PCM data (AUDIO_LINUX_WINDOW_FRAMES) + * from the stream's current platform.position up to platform.endFrame, + * advancing platform.position by however many frames were actually read. + * A no-op once platform.position has reached platform.endFrame. + * + * Kept as a small, bounded read/queue operation (rather than the whole + * pass at once) specifically so a long clip is never fully resident in + * memory at once - called both by audioStreamLinuxBuffer() (the first + * window of a pass) and by audioStreamLinuxIsFinished() (subsequent + * top-ups as the SDL queue drains). + * + * @param stream The audio stream to feed. + * @return Error state if any. + */ +errorret_t audioStreamLinuxFeed(audiostream_t *stream); + /** * Checks whether the stream's SDL2 audio device has finished playing all - * queued data. + * queued data for the current pass. As a side effect, tops up the queue + * (via audioStreamLinuxFeed()) whenever it's running low but more of the + * current pass remains to be read, rather than reporting finished early. * * @param stream The audio stream to check. * @return true if playback has finished, false otherwise. diff --git a/src/duskpsp/asset/assetpbp.c b/src/duskpsp/asset/assetpbp.c index cb472d58..b55c5ee9 100644 --- a/src/duskpsp/asset/assetpbp.c +++ b/src/duskpsp/asset/assetpbp.c @@ -9,129 +9,75 @@ #include "asset/assetdsk.h" #include "assert/assert.h" #include "util/memory.h" -#include "util/math.h" errorret_t assetInitPBP(const char_t *pbpPath) { assertNotNull(pbpPath, "PBP path cannot be null."); assertStrLenMin(pbpPath, 1, "PBP path cannot be empty."); assertStrLenMax(pbpPath, ASSET_PBP_PATH_MAX, "PBP path is too long."); - ASSET.platform.pbpFile = fopen(pbpPath, "rb"); - if(ASSET.platform.pbpFile == NULL) { + FILE *pbpFile = fopen(pbpPath, "rb"); + if(pbpFile == NULL) { errorThrow("Failed to open PBP file: %s", pbpPath); } // Get size of PBP file. - if(fseek(ASSET.platform.pbpFile, 0, SEEK_END) != 0) { - fclose(ASSET.platform.pbpFile); + if(fseek(pbpFile, 0, SEEK_END) != 0) { + fclose(pbpFile); errorThrow("Failed to seek to end of PBP file : %s", pbpPath); } - size_t pbpSize = ftell(ASSET.platform.pbpFile); + size_t pbpSize = ftell(pbpFile); if(pbpSize == -1L) { - fclose(ASSET.platform.pbpFile); + fclose(pbpFile); errorThrow("Failed to get size of PBP file : %s", pbpPath); } if(pbpSize < sizeof(assetpbpheader_t)) { - fclose(ASSET.platform.pbpFile); + fclose(pbpFile); errorThrow("PBP file is too small to be valid: %s", pbpPath); } // Rewind to start - if(fseek(ASSET.platform.pbpFile, 0, SEEK_SET) != 0) { - fclose(ASSET.platform.pbpFile); + if(fseek(pbpFile, 0, SEEK_SET) != 0) { + fclose(pbpFile); errorThrow("Failed to seek to start of PBP file : %s", pbpPath); } // Read the PBP header size_t read = fread( - &ASSET.platform.pbpHeader, - 1, - sizeof(assetpbpheader_t), - ASSET.platform.pbpFile + &ASSET.platform.pbpHeader, 1, sizeof(assetpbpheader_t), pbpFile ); + fclose(pbpFile); if(read != sizeof(assetpbpheader_t)) { - fclose(ASSET.platform.pbpFile); errorThrow("Failed to read PBP header", pbpPath); } - + if(memoryCompare( ASSET.platform.pbpHeader.signature, ASSET_PBP_SIGNATURE, sizeof(ASSET_PBP_SIGNATURE) ) != 0) { - fclose(ASSET.platform.pbpFile); errorThrow("Invalid PBP signature in file: %s", pbpPath); } - // If we seek to the PSAR offset, we can read the WAD file from there. - // I'm not sure what PSAR was intended for, but it holds any user data we - // want, so I shoved the entire dusk wad there. - if(fseek( - ASSET.platform.pbpFile, ASSET.platform.pbpHeader.psarOffset, SEEK_SET - ) != 0) { - fclose(ASSET.platform.pbpFile); - errorThrow("Failed to seek to PSAR offset in PBP file: %s", pbpPath); - } - + // The PSAR region holds the embedded dusk.dsk (DSK2) archive - I'm not + // sure what PSAR was intended for, but it holds any user data we want, + // so I shoved the entire dusk.dsk there. Opened lazily/on-demand + // straight from the PBP file by path+offset (see + // assetDskOpenFromPathRange), the same way every other platform opens + // dusk.dsk, rather than reading the whole PSAR into memory up front. size_t psarSize = pbpSize - ASSET.platform.pbpHeader.psarOffset; + errorChain(assetDskOpenFromPathRange( + pbpPath, + ASSET.platform.pbpHeader.psarOffset, + psarSize, + &ASSET.zip, + &ASSET.zipStored + )); - // Read the whole PSAR (the embedded dusk.dsk zip archive) into memory up - // front and hand libzip an in-memory buffer, instead of a lazily-seeked - // FILE source: repeated seeked reads directly against the open PBP file - // proved unreliable on PSP (zip_fread() failing with EINVAL, then with - // zlib data corruption, depending on the read chunk size used). Reading - // once, straight through, and letting libzip operate on memory from then - // on avoids that read path entirely. psarSize is small (tens of KB) so - // holding it fully in RAM is cheap. - uint8_t *psarData = (uint8_t *)malloc(psarSize); - if(psarData == NULL) { - fclose(ASSET.platform.pbpFile); - errorThrow("Failed to allocate PSAR buffer for file: %s", pbpPath); - } - - size_t totalRead = 0; - while(totalRead < psarSize) { - size_t chunkSize = mathMin( - psarSize - totalRead, ASSET_FILE_READ_CHUNK_MAX - ); - size_t chunkRead = fread( - psarData + totalRead, 1, chunkSize, ASSET.platform.pbpFile - ); - if(chunkRead == 0) { - free(psarData); - fclose(ASSET.platform.pbpFile); - errorThrow("Failed to read PSAR data from file: %s", pbpPath); - } - totalRead += chunkRead; - } - - fclose(ASSET.platform.pbpFile); - ASSET.platform.pbpFile = NULL; - - errorret_t ret = assetDskOpenFromBuffer( - psarData, psarSize, &ASSET.zip, &ASSET.zipStored - ); - if(errorIsNotOk(ret)) { - free(psarData); - errorChain(ret); - } - - ASSET.platform.dskData = psarData; errorOk(); } errorret_t assetDisposePBP(void) { - if(ASSET.platform.pbpFile != NULL) { - fclose(ASSET.platform.pbpFile); - ASSET.platform.pbpFile = NULL; - } - - if(ASSET.platform.dskData != NULL) { - free(ASSET.platform.dskData); - ASSET.platform.dskData = NULL; - } - errorOk(); } \ No newline at end of file diff --git a/src/duskpsp/asset/assetpbp.h b/src/duskpsp/asset/assetpbp.h index a6e7b819..dbbcc02c 100644 --- a/src/duskpsp/asset/assetpbp.h +++ b/src/duskpsp/asset/assetpbp.h @@ -28,13 +28,7 @@ typedef struct { } assetpbpheader_t; typedef struct { - FILE *pbpFile; assetpbpheader_t pbpHeader; - - // Whole dusk.dsk (PSAR) blob, kept alive for as long as ASSET.zip/ - // ASSET.zipStored are open since they're non-owning windows into it (see - // assetDskOpenFromBuffer). Freed in assetDisposePBP. - uint8_t *dskData; } assetpbp_t; /** diff --git a/src/duskpsp/audio/audiostreampsp.c b/src/duskpsp/audio/audiostreampsp.c index f56f94ca..b4a4a219 100644 --- a/src/duskpsp/audio/audiostreampsp.c +++ b/src/duskpsp/audio/audiostreampsp.c @@ -128,7 +128,7 @@ void audioStreamPSPThreadFeed(thread_t *thread) { } stream->platform.playRequested = false; - const size_t totalFrames = stream->dataSize / frameSize; + const size_t totalFrames = audioStreamPcmGetTotalFrames(stream); // loopEndFrame/loopToFrame define the loop segment [loopToFrame, // loopEndFrame) that a looping pass wraps within, once it's reached - @@ -147,6 +147,14 @@ void audioStreamPSPThreadFeed(thread_t *thread) { size_t position = stream->platform.startFrame; bool_t reachedEnd = false; + // Samples are read on demand from the asset (via audioStreamPcmRead(), + // sequentially, plus an explicit audioStreamPcmSeek() whenever jumping + // backward for a loop wrap) rather than indexed out of a fully + // resident buffer - a read/seek failure here (a corrupt or truncated + // asset, an I/O error) stops playback cleanly instead of crashing the + // thread on bad data. + bool_t readFailed = errorIsNotOk(audioStreamPcmSeek(stream, position)); + // Every call always sends a full, constant-size AUDIO_PSP_CHUNK_FRAMES // buffer - the channel is never re-declared to a different length, to // avoid relying on sceAudioSetChannelDataLen's undocumented behavior @@ -157,7 +165,7 @@ void audioStreamPSPThreadFeed(thread_t *thread) { // engine's finished+looping path is designed for platforms with no // better option (see audiostream.c), but here the thread can just // keep going and call onLoop itself instead. - while(!threadShouldStop(thread) && !reachedEnd) { + while(!readFailed && !threadShouldStop(thread) && !reachedEnd) { // Normally bounded by loopEndFrame (the loop segment's end), but a // seek can legitimately land past it (e.g. into an outro after the // loop point) - in that case play out to the true end of the buffer @@ -173,9 +181,22 @@ void audioStreamPSPThreadFeed(thread_t *thread) { const bool_t willLoop = reachesEndThisChunk && (stream->state & AUDIO_STREAM_STATE_LOOPING); - memoryCopy( - chunk, stream->data + (position * frameSize), framesThisChunk * frameSize - ); + size_t framesRead = 0; + if(errorIsNotOk( + audioStreamPcmRead(stream, chunk, framesThisChunk, &framesRead) + )) { + readFailed = true; + break; + } + if(framesRead < framesThisChunk) { + // The asset is shorter than its declared header size (corrupt or + // truncated) - pad what's missing with silence rather than play + // whatever was left in `chunk` from a previous pass. + memoryZero( + chunk + (framesRead * channels), + (framesThisChunk - framesRead) * frameSize + ); + } const size_t remainderFrames = AUDIO_PSP_CHUNK_FRAMES - framesThisChunk; size_t wrapFrames = 0; @@ -205,15 +226,22 @@ void audioStreamPSPThreadFeed(thread_t *thread) { wrapFrames = remainderFrames < loopSegmentFrames ? remainderFrames : loopSegmentFrames; - memoryCopy( - chunk + (framesThisChunk * channels), - stream->data + (loopToFrame * frameSize), - wrapFrames * frameSize - ); - if(wrapFrames < remainderFrames) { + + if(errorIsNotOk(audioStreamPcmSeek(stream, loopToFrame))) { + readFailed = true; + break; + } + size_t wrapRead = 0; + if(errorIsNotOk(audioStreamPcmRead( + stream, chunk + (framesThisChunk * channels), wrapFrames, &wrapRead + ))) { + readFailed = true; + break; + } + if(wrapRead < remainderFrames) { memoryZero( - chunk + ((framesThisChunk + wrapFrames) * channels), - (remainderFrames - wrapFrames) * frameSize + chunk + ((framesThisChunk + wrapRead) * channels), + (remainderFrames - wrapRead) * frameSize ); } } else if(reachesEndThisChunk) { diff --git a/src/duskpsp/audio/audiostreampsp.h b/src/duskpsp/audio/audiostreampsp.h index a2d8ac2d..4a59ea93 100644 --- a/src/duskpsp/audio/audiostreampsp.h +++ b/src/duskpsp/audio/audiostreampsp.h @@ -60,8 +60,9 @@ errorret_t audioStreamPSPInit(audiostream_t *stream); errorret_t audioStreamPSPDispose(audiostream_t *stream); /** - * Wakes the stream's persistent feeder thread to stream stream->data to - * its reserved hardware output channel in small chunks until exhausted. + * Wakes the stream's persistent feeder thread to stream PCM data (read on + * demand from the stream's asset via audioStreamPcmRead()) to its reserved + * hardware output channel in small chunks until exhausted. * * @param stream The audio stream to output. * @return Error state if any. @@ -80,8 +81,9 @@ bool_t audioStreamPSPIsFinished(audiostream_t *stream); /** * Feeder thread entry point, run once for the stream's whole lifetime. * Idles (polling playRequested) until woken by audioStreamPSPBuffer(), - * then streams stream->data (passed via thread->data) to its hardware - * channel in fixed-size chunks, blocking naturally on each + * then reads and streams the stream's asset PCM data (via the audiostream_t + * passed as thread->data) to its hardware channel in fixed-size chunks, + * blocking naturally on each * sceAudioOutputPannedBlocking() call, until the whole buffer has been * sent - then goes back to idling, ready for the next play request, until * the thread is asked to stop.