From f8f8a80a21b7b8489c5c3589f40e136b061bfc65 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Tue, 1 Sep 2026 08:21:30 -0500 Subject: [PATCH] Add MP3 audio stream support with hardware/software decoder backends New ASSET_LOADER_TYPE_MP3 (hand-rolled MPEG-1/2/2.5 Layer III header parser - no third-party dependency needed just for metadata, since PSP's hardware path doesn't need one at all) plus a shared audiostreammp3.c stream layer mirroring audiostreampcm.c's shape. Generalized the stream dispatch (hoisted sampleRate/channels onto audiostream_t, added audioStreamGetTotalFrames()/Seek()/Read()) so all three platform audio backends keep working unchanged, just calling the generic names instead of PCM-specific ones. Two decoder backends behind one interface: PSP uses the real sceMp3 hardware decoder (firmware-offloaded, lazily initialized on first use); Linux and Dolphin share one minimp3-based software decoder (public domain, vendored via CMake FetchContent) - libogc's own MP3Player wraps libmad (GPL) and drives its own output pipeline, not a fit for the ansnd-based architecture already in place, so skipped in favor of the shared minimp3 path. Fixed three real bugs found via hardware/runtime testing along the way: - LAME's Xing header counts its own placeholder frame in the declared total, which made playback stall permanently one frame short of the declared end (looked like "never loops") - fixed by subtracting it. - sceMp3Decode() can return more PCM than one MPEG frame's worth in a single call (PSP's pcmBuf is provisioned for 2x), overflowing the shared per-frame decode buffer with no bound check - very intermittent corruption/clicking on real hardware. Widened the buffer to the real worst case and added an assertion. - sceMp3ResetPlayPosition()'s exact internal reset semantics aren't documented precisely enough to trust for looping - occasionally disagreed with the fresh stream position fed right after, clicking at the loop boundary about 1 in 3-4 loops. Rewind now fully tears down and recreates the decoder instead, the same path already proven correct at first Init. Also widened the PSP ring buffer to absorb that now-heavier operation, capping each top-up call's own work so the bigger buffer doesn't turn into one long blocking decode burst instead. Co-Authored-By: Claude Sonnet 5 --- assets/audio/audiotest.mp3 | Bin 0 -> 17179 bytes cmake/modules/Findminimp3.cmake | 28 ++ cmake/targets/psp.cmake | 1 + src/dusk/asset/loader/CMakeLists.txt | 3 +- src/dusk/asset/loader/assetloader.c | 6 + src/dusk/asset/loader/assetloader.h | 5 + src/dusk/asset/loader/mp3/CMakeLists.txt | 9 + src/dusk/asset/loader/mp3/assetmp3loader.c | 238 ++++++++++++++++ src/dusk/asset/loader/mp3/assetmp3loader.h | 113 ++++++++ src/dusk/audio/audio.c | 2 +- src/dusk/audio/audiostream.c | 55 +++- src/dusk/audio/audiostream.h | 55 +++- src/dusk/audio/audiostreammp3.c | 155 ++++++++++- src/dusk/audio/audiostreammp3.h | 130 ++++++++- src/dusk/audio/audiostreammp3decodersw.c | 119 ++++++++ src/dusk/audio/audiostreammp3decodersw.h | 96 +++++++ src/dusk/audio/audiostreampcm.c | 10 +- src/dusk/audio/audiostreampcm.h | 6 - src/dusk/engine/engine.c | 3 +- src/duskdolphin/audio/CMakeLists.txt | 18 ++ src/duskdolphin/audio/audiostreamdolphin.c | 16 +- src/duskdolphin/audio/audiostreammp3decoder.h | 16 ++ src/dusklinux/audio/CMakeLists.txt | 13 + src/dusklinux/audio/audiostreamlinux.c | 18 +- src/dusklinux/audio/audiostreammp3decoder.h | 13 + src/duskpsp/audio/CMakeLists.txt | 1 + src/duskpsp/audio/audiopsp.c | 3 + src/duskpsp/audio/audiostreammp3decoder.c | 261 ++++++++++++++++++ src/duskpsp/audio/audiostreammp3decoder.h | 126 +++++++++ src/duskpsp/audio/audiostreampsp.c | 58 ++-- src/duskpsp/audio/audiostreampsp.h | 25 +- 31 files changed, 1530 insertions(+), 72 deletions(-) create mode 100644 assets/audio/audiotest.mp3 create mode 100644 cmake/modules/Findminimp3.cmake create mode 100644 src/dusk/asset/loader/mp3/CMakeLists.txt create mode 100644 src/dusk/asset/loader/mp3/assetmp3loader.c create mode 100644 src/dusk/asset/loader/mp3/assetmp3loader.h create mode 100644 src/dusk/audio/audiostreammp3decodersw.c create mode 100644 src/dusk/audio/audiostreammp3decodersw.h create mode 100644 src/duskdolphin/audio/audiostreammp3decoder.h create mode 100644 src/dusklinux/audio/audiostreammp3decoder.h create mode 100644 src/duskpsp/audio/audiostreammp3decoder.c create mode 100644 src/duskpsp/audio/audiostreammp3decoder.h diff --git a/assets/audio/audiotest.mp3 b/assets/audio/audiotest.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..5f46ebfd91c1150b40dc45bd8f83e22533c79bb8 GIT binary patch literal 17179 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 AQvd(} literal 0 HcmV?d00001 diff --git a/cmake/modules/Findminimp3.cmake b/cmake/modules/Findminimp3.cmake new file mode 100644 index 00000000..afa42644 --- /dev/null +++ b/cmake/modules/Findminimp3.cmake @@ -0,0 +1,28 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +# minimp3 has no tagged releases - pinned to a specific commit instead of a +# branch for reproducibility. +include(FetchContent) +FetchContent_Declare( + minimp3 + GIT_REPOSITORY https://github.com/lieff/minimp3.git + GIT_TAG ea99364f61c14656440e8d77e9c233ccf3124633 +) + +FetchContent_MakeAvailable(minimp3) + +set(MINIMP3_INCLUDE_DIR "${minimp3_SOURCE_DIR}") +set(MINIMP3_HEADER "${minimp3_SOURCE_DIR}/minimp3.h") + +if(EXISTS "${MINIMP3_HEADER}") + add_library(minimp3 INTERFACE) + target_include_directories(minimp3 INTERFACE "${MINIMP3_INCLUDE_DIR}") + set(minimp3_FOUND TRUE) +else() + set(minimp3_FOUND FALSE) +endif() + +mark_as_advanced(MINIMP3_INCLUDE_DIR MINIMP3_HEADER) diff --git a/cmake/targets/psp.cmake b/cmake/targets/psp.cmake index 13b9bb85..ea0c8076 100644 --- a/cmake/targets/psp.cmake +++ b/cmake/targets/psp.cmake @@ -30,6 +30,7 @@ target_link_libraries(${DUSK_BINARY_TARGET_NAME} PUBLIC pspgu pspaudio pspaudiolib + pspmp3 psputility pspvfpu pspvram diff --git a/src/dusk/asset/loader/CMakeLists.txt b/src/dusk/asset/loader/CMakeLists.txt index 775d5951..f59ac170 100644 --- a/src/dusk/asset/loader/CMakeLists.txt +++ b/src/dusk/asset/loader/CMakeLists.txt @@ -18,4 +18,5 @@ add_subdirectory(json) add_subdirectory(chunk) add_subdirectory(dmf) add_subdirectory(cutscene) -add_subdirectory(wav) \ No newline at end of file +add_subdirectory(wav) +add_subdirectory(mp3) \ No newline at end of file diff --git a/src/dusk/asset/loader/assetloader.c b/src/dusk/asset/loader/assetloader.c index 689549cb..87ce42c9 100644 --- a/src/dusk/asset/loader/assetloader.c +++ b/src/dusk/asset/loader/assetloader.c @@ -63,4 +63,10 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = { .loadAsync = assetWavLoaderAsync, .dispose = assetWavDispose }, + + [ASSET_LOADER_TYPE_MP3] = { + .loadSync = assetMp3LoaderSync, + .loadAsync = assetMp3LoaderAsync, + .dispose = assetMp3Dispose + }, }; diff --git a/src/dusk/asset/loader/assetloader.h b/src/dusk/asset/loader/assetloader.h index f3970529..317a6519 100644 --- a/src/dusk/asset/loader/assetloader.h +++ b/src/dusk/asset/loader/assetloader.h @@ -15,6 +15,7 @@ #include "asset/loader/chunk/assetchunkloader.h" #include "asset/loader/cutscene/assetcutsceneloader.h" #include "asset/loader/wav/assetwavloader.h" +#include "asset/loader/mp3/assetmp3loader.h" typedef enum { ASSET_LOADER_TYPE_NULL, @@ -28,6 +29,7 @@ typedef enum { ASSET_LOADER_TYPE_CHUNK, ASSET_LOADER_TYPE_CUTSCENE, ASSET_LOADER_TYPE_WAV, + ASSET_LOADER_TYPE_MP3, ASSET_LOADER_TYPE_COUNT } assetloadertype_t; @@ -42,6 +44,7 @@ typedef union { assetchunkloaderloading_t chunk; assetcutsceneloaderloading_t cutscene; assetwavloaderloading_t wav; + assetmp3loaderloading_t mp3; } assetloaderloading_t; typedef union { @@ -54,6 +57,7 @@ typedef union { assetchunkoutput_t chunk; assetcutsceneoutput_t cutscene; assetwavoutput_t wav; + assetmp3output_t mp3; } assetloaderoutput_t; typedef union { @@ -64,6 +68,7 @@ typedef union { assetchunkloaderinput_t chunk; assetcutsceneloaderinput_t cutscene; assetwavloaderinput_t wav; + assetmp3loaderinput_t mp3; } assetloaderinput_t; typedef struct assetloading_s assetloading_t; diff --git a/src/dusk/asset/loader/mp3/CMakeLists.txt b/src/dusk/asset/loader/mp3/CMakeLists.txt new file mode 100644 index 00000000..6355a69f --- /dev/null +++ b/src/dusk/asset/loader/mp3/CMakeLists.txt @@ -0,0 +1,9 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + assetmp3loader.c +) diff --git a/src/dusk/asset/loader/mp3/assetmp3loader.c b/src/dusk/asset/loader/mp3/assetmp3loader.c new file mode 100644 index 00000000..75262724 --- /dev/null +++ b/src/dusk/asset/loader/mp3/assetmp3loader.c @@ -0,0 +1,238 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "assetmp3loader.h" +#include "util/memory.h" +#include "util/math.h" +#include "assert/assert.h" +#include "asset/loader/assetloading.h" +#include "asset/loader/assetentry.h" + +// How far into the file (after any ID3v2 tag) assetMp3ParseHeader() will +// search for the first valid MPEG frame sync. Real encoders write it +// within the first handful of bytes; this is a generous sane upper bound +// (matching assetwavloader.c's own approach to bounding a stack buffer) +// rather than a real expectation of needing that much. +#define ASSET_MP3_HEADER_SEARCH_MAX 8192 + +// MPEG-1 Layer III bitrates (kbps), indexed by the header's 4-bit bitrate +// index. Index 0 ("free format") and 15 (reserved) aren't supported. +static const uint16_t ASSET_MP3_BITRATE_MPEG1_L3[16] = { + 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0 +}; + +// MPEG-2/2.5 Layer III bitrates (kbps) - a different table from MPEG-1's. +static const uint16_t ASSET_MP3_BITRATE_MPEG2_L3[16] = { + 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 +}; + +static const uint32_t ASSET_MP3_SAMPLE_RATE_MPEG1[4] = { 44100, 48000, 32000, 0 }; +static const uint32_t ASSET_MP3_SAMPLE_RATE_MPEG2[4] = { 22050, 24000, 16000, 0 }; +static const uint32_t ASSET_MP3_SAMPLE_RATE_MPEG25[4] = { 11025, 12000, 8000, 0 }; + +errorret_t assetMp3LoaderAsync(assetloading_t *loading) { + assertNotNull(loading, "Loading cannot be NULL"); + assertNotMainThread("Async loader should not be on main thread."); + + if(loading->loading.mp3.state != ASSET_MP3_LOADER_STATE_READ_HEADER) { + errorOk(); + } + + assetmp3file_t *mp3File = &loading->entry->data.mp3; + memoryZero(mp3File, sizeof(assetmp3file_t)); + + assetfile_t file; + assetLoaderErrorChain(loading, assetFileInit( + &file, loading->entry->name, NULL, NULL + )); + assetLoaderErrorChain(loading, assetFileOpen(&file)); + assetLoaderErrorChain(loading, assetMp3ParseHeader(&file, mp3File)); + assetLoaderErrorChain(loading, assetFileClose(&file)); + assetLoaderErrorChain(loading, assetFileDispose(&file)); + + loading->loading.mp3.state = ASSET_MP3_LOADER_STATE_DONE; + loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC; + errorOk(); +} + +errorret_t assetMp3LoaderSync(assetloading_t *loading) { + assertNotNull(loading, "Loading cannot be NULL"); + assertTrue(loading->type == ASSET_LOADER_TYPE_MP3, "Invalid type."); + assertIsMainThread("Must be called from the main thread."); + + switch(loading->loading.mp3.state) { + case ASSET_MP3_LOADER_STATE_INITIAL: + loading->loading.mp3.state = ASSET_MP3_LOADER_STATE_READ_HEADER; + loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC; + errorOk(); + break; + + case ASSET_MP3_LOADER_STATE_DONE: + break; + + default: + errorOk(); + } + + loading->entry->state = ASSET_ENTRY_STATE_LOADED; + errorOk(); +} + +errorret_t assetMp3Dispose(assetentry_t *entry) { + assertNotNull(entry, "Asset entry cannot be NULL"); + assertTrue(entry->type == ASSET_LOADER_TYPE_MP3, "Invalid type."); + assertIsMainThread("Must be called from the main thread."); + + errorOk(); +} + +errorret_t assetMp3ParseHeader(assetfile_t *file, assetmp3file_t *mp3File) { + assertNotNull(file, "Asset file cannot be NULL."); + assertNotNull(mp3File, "Mp3 file cannot be NULL."); + + memoryZero(mp3File, sizeof(assetmp3file_t)); + + // Skip a leading ID3v2 tag if present: "ID3" + version(2) + flags(1) + a + // 4-byte syncsafe (7 usable bits/byte) size covering everything after + // this 10-byte header. + uint8_t id3Header[10]; + errorChain(assetFileRead(file, id3Header, sizeof(id3Header))); + + size_t searchBase; + if(memoryCompare(id3Header, "ID3", 3) == 0) { + const uint32_t tagSize = ( + ((uint32_t) (id3Header[6] & 0x7F) << 21) | + ((uint32_t) (id3Header[7] & 0x7F) << 14) | + ((uint32_t) (id3Header[8] & 0x7F) << 7) | + ((uint32_t) (id3Header[9] & 0x7F)) + ); + errorChain(assetFileRead(file, NULL, tagSize)); + searchBase = sizeof(id3Header) + tagSize; + } else { + // No ID3v2 tag - rewind the probe bytes just consumed so the frame + // sync search below starts from the true beginning of the file. + errorChain(assetFileRewind(file)); + searchBase = 0; + } + + const size_t searchWindowSize = mathMin( + (size_t) ASSET_MP3_HEADER_SEARCH_MAX, + (size_t) file->size > searchBase ? (size_t) file->size - searchBase : 0 + ); + if(searchWindowSize < 4) { + errorThrow("MP3 file is too short to contain a frame header: %s", file->filename); + } + + uint8_t *window = (uint8_t *) memoryAllocate(searchWindowSize); + errorret_t readRet = assetFileRead(file, window, searchWindowSize); + if(errorIsNotOk(readRet)) { + memoryFree(window); + errorChain(readRet); + } + + bool_t found = false; + size_t frameIndex = 0; + + for(size_t i = 0; i + 4 <= searchWindowSize; i++) { + if(window[i] != 0xFF || (window[i + 1] & 0xE0) != 0xE0) continue; + + const uint8_t versionBits = (window[i + 1] >> 3) & 0x3; + const uint8_t layerBits = (window[i + 1] >> 1) & 0x3; + if(versionBits == 1 || layerBits != 1) continue; // reserved version, or not Layer III + + const uint8_t bitrateIndex = (window[i + 2] >> 4) & 0xF; + const uint8_t sampleRateIndex = (window[i + 2] >> 2) & 0x3; + if(bitrateIndex == 0 || bitrateIndex == 15 || sampleRateIndex == 3) continue; + + const uint8_t channelMode = (window[i + 3] >> 6) & 0x3; + const bool_t isMpeg1 = versionBits == 3; + + mp3File->bitrateKbps = isMpeg1 + ? ASSET_MP3_BITRATE_MPEG1_L3[bitrateIndex] + : ASSET_MP3_BITRATE_MPEG2_L3[bitrateIndex]; + mp3File->sampleRate = isMpeg1 + ? ASSET_MP3_SAMPLE_RATE_MPEG1[sampleRateIndex] + : (versionBits == 2 + ? ASSET_MP3_SAMPLE_RATE_MPEG2[sampleRateIndex] + : ASSET_MP3_SAMPLE_RATE_MPEG25[sampleRateIndex]); + mp3File->samplesPerFrame = isMpeg1 ? 1152 : 576; + mp3File->channels = channelMode == 3 ? 1 : 2; + + // A Xing/Info header, if present, sits right after the side info - + // whose size depends on MPEG version and channel mode - immediately + // following the 4-byte header (plus a 2-byte CRC, if the protection + // bit says one follows). + const bool_t hasCrc = (window[i + 1] & 0x1) == 0; + const size_t sideInfoSize = isMpeg1 + ? (mp3File->channels == 1 ? 17 : 32) + : (mp3File->channels == 1 ? 9 : 17); + const size_t xingOffset = i + 4 + (hasCrc ? 2 : 0) + sideInfoSize; + + if( + xingOffset + 8 <= searchWindowSize && + ( + memoryCompare(window + xingOffset, "Xing", 4) == 0 || + memoryCompare(window + xingOffset, "Info", 4) == 0 + ) + ) { + const uint8_t *flagsBytes = window + xingOffset + 4; + const uint32_t flags = ( + ((uint32_t) flagsBytes[0] << 24) | ((uint32_t) flagsBytes[1] << 16) | + ((uint32_t) flagsBytes[2] << 8) | (uint32_t) flagsBytes[3] + ); + if((flags & 0x1) != 0 && xingOffset + 12 <= searchWindowSize) { + const uint8_t *framesBytes = window + xingOffset + 8; + const uint32_t xingFrameCount = ( + ((uint32_t) framesBytes[0] << 24) | ((uint32_t) framesBytes[1] << 16) | + ((uint32_t) framesBytes[2] << 8) | (uint32_t) framesBytes[3] + ); + // The Xing/Info header's frame count includes the header's own + // frame (this one, at `i`) - which minimp3 (and sceMp3) will + // attempt to decode like any other, but which doesn't contribute a + // real decodable frame of audio the way every other one does. + // Confirmed against a real LAME-encoded file: its Xing header + // declared 40 frames, but decoding actually produced exactly 39 + // frames' worth of real PCM - without this -1, playback would + // permanently stall one frame short of the declared total (audible + // as "never loops"), since real decode output can never catch up + // to an overstated total. + if(xingFrameCount > 0) { + mp3File->totalFrames = (size_t) (xingFrameCount - 1) * mp3File->samplesPerFrame; + } + } + } + + frameIndex = i; + found = true; + break; + } + + memoryFree(window); + + if(!found) { + errorThrow( + "MP3 file has no valid MPEG-1/2/2.5 Layer III frame within the first %u bytes: %s", + (uint32_t) searchWindowSize, file->filename + ); + } + + mp3File->dataOffset = searchBase + frameIndex; + mp3File->dataSize = (size_t) file->size - mp3File->dataOffset; + + // No Xing/Info header found - estimate assuming constant bitrate (the + // common case when one's genuinely absent; a real VBR file without one + // is rare and will just get an approximate duration/loop point instead + // of an exact one). + if(mp3File->totalFrames == 0) { + const float_t durationSeconds = ( + ((float_t) mp3File->dataSize * 8.0f) / ((float_t) mp3File->bitrateKbps * 1000.0f) + ); + mp3File->totalFrames = (size_t) (durationSeconds * (float_t) mp3File->sampleRate); + } + + errorOk(); +} diff --git a/src/dusk/asset/loader/mp3/assetmp3loader.h b/src/dusk/asset/loader/mp3/assetmp3loader.h new file mode 100644 index 00000000..6cb0bf8a --- /dev/null +++ b/src/dusk/asset/loader/mp3/assetmp3loader.h @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "asset/assetfile.h" + +typedef struct assetloading_s assetloading_t; +typedef struct assetentry_s assetentry_t; + +typedef struct { void *nothing; } assetmp3loaderinput_t; + +typedef enum { + ASSET_MP3_LOADER_STATE_INITIAL, + ASSET_MP3_LOADER_STATE_READ_HEADER, + ASSET_MP3_LOADER_STATE_DONE +} assetmp3loaderstate_t; + +typedef struct { + assetmp3loaderstate_t state; +} assetmp3loaderloading_t; + +/** + * Parsed metadata for an MP3 asset - deliberately just enough to configure + * a decoder and know the clip's total length, not anything about its + * actual compressed content (which is read/decoded on demand at playback + * time - see audiostreammp3.c). Only MPEG-1/2/2.5 Layer III ("MP3" in the + * everyday sense) is supported; Layers I/II are rejected. + */ +typedef struct { + uint32_t sampleRate; + uint8_t channels; + + // 1152 for MPEG-1, 576 for MPEG-2/2.5 - how many PCM samples (per + // channel) one compressed MPEG frame decodes to. + uint16_t samplesPerFrame; + + // The first frame's bitrate, in kbps. Used only as a fallback duration + // estimate (assuming CBR) when no Xing/Info header is found - see + // assetMp3ParseHeader()'s own comment. + uint32_t bitrateKbps; + + // Byte offset (from the start of the file) of the first real MPEG + // frame, i.e. right after any leading ID3v2 tag. + size_t dataOffset; + + // Bytes of compressed MPEG data, from dataOffset to the end of the file. + size_t dataSize; + + // Decoded PCM frame count for the whole clip - exact if a Xing/Info + // header was found (the common case for anything encoded with a modern + // tool), otherwise estimated from bitrateKbps/dataSize assuming CBR (see + // assetMp3ParseHeader()'s own comment) - unlike assetwavfile_t.dataSize, + // MP3 has no header field that gives this directly. + size_t totalFrames; +} assetmp3file_t; + +typedef assetmp3file_t assetmp3output_t; + +/** + * Async (background-thread) half of the MP3 loader - opens the asset file, + * parses its header (see assetMp3ParseHeader()), and closes it again. Never + * buffers the compressed MPEG data itself; that's read on demand at + * playback time by audiostreammp3.c via its own independent file handle. + * + * @param loading The asset loading slot. + * @return Error indicating success or failure. + */ +errorret_t assetMp3LoaderAsync(assetloading_t *loading); + +/** + * Sync (main-thread) half of the MP3 loader - a simple state machine that + * schedules the async header parse and marks the entry loaded once it's + * done, mirroring assetWavLoaderSync()'s shape exactly. + * + * @param loading The asset loading slot. + * @return Error indicating success or failure. + */ +errorret_t assetMp3LoaderSync(assetloading_t *loading); + +/** + * Disposes an MP3 asset entry. A no-op - an assetmp3file_t owns no heap + * allocations, and playback streams open their own independent file + * handles (see audiostreammp3.c). + * + * @param entry The asset entry to dispose. + * @return Error indicating success or failure. + */ +errorret_t assetMp3Dispose(assetentry_t *entry); + +/** + * Parses an MP3 file's metadata from an already-opened asset file + * positioned at its very start: skips a leading ID3v2 tag if present, + * scans forward for the first valid MPEG-1/2/2.5 Layer III frame header + * (rejecting anything else - other layers, or no valid frame found within + * a sane search window), and checks that frame for a Xing/Info header + * (found in the vast majority of real-world MP3s, VBR or CBR) to get an + * exact total-frame count; falls back to a CBR estimate from the first + * frame's bitrate and the file's remaining size if absent. + * + * Never reads more than a bounded lookahead window into memory - the bulk + * of the file (the actual compressed audio, following the parsed frame's + * position) is left untouched, exactly like assetWavParseHeader(). + * + * @param file Asset file to parse, positioned at offset 0. + * @param mp3File Filled with the parsed metadata on success. + * @return Error indicating success or failure. + */ +errorret_t assetMp3ParseHeader(assetfile_t *file, assetmp3file_t *mp3File); diff --git a/src/dusk/audio/audio.c b/src/dusk/audio/audio.c index 586d916f..07c20307 100644 --- a/src/dusk/audio/audio.c +++ b/src/dusk/audio/audio.c @@ -26,7 +26,7 @@ audiostream_t * audioAquireStream(assetentry_t *asset) { // too catches the mistake as early as possible, before any stream slot // is handed out for it. assertTrue( - asset->type == ASSET_LOADER_TYPE_WAV, + asset->type == ASSET_LOADER_TYPE_WAV || asset->type == ASSET_LOADER_TYPE_MP3, "Unsupported asset type for an audio stream." ); diff --git a/src/dusk/audio/audiostream.c b/src/dusk/audio/audiostream.c index ae72ce12..ed3d55f9 100644 --- a/src/dusk/audio/audiostream.c +++ b/src/dusk/audio/audiostream.c @@ -15,12 +15,12 @@ errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset) { 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 + // Only WAV (-> PCM) and MP3 assets can back an audio stream - 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, + asset->type == ASSET_LOADER_TYPE_WAV || asset->type == ASSET_LOADER_TYPE_MP3, "Unsupported asset type for an audio stream." ); @@ -43,10 +43,12 @@ errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset) { 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); + // Type-specific setup (audioStreamPcmInit() / audioStreamMp3Init()) + // determines stream->type, sets stream->sampleRate/channels, and asks + // the platform implementation to set up its state. + errorret_t ret = asset->type == ASSET_LOADER_TYPE_MP3 + ? audioStreamMp3Init(stream) + : audioStreamPcmInit(stream); if(errorIsNotOk(ret)) { assetEntryUnlock(asset); stream->asset = NULL; @@ -56,6 +58,41 @@ errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset) { errorOk(); } +size_t audioStreamGetTotalFrames(const audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + + return stream->type == AUDIO_STREAM_TYPE_MP3 + ? audioStreamMp3GetTotalFrames(stream) + : audioStreamPcmGetTotalFrames(stream); +} + +errorret_t audioStreamSeek(audiostream_t *stream, const size_t frame) { + assertNotNull(stream, "Stream cannot be NULL."); + + errorChain( + stream->type == AUDIO_STREAM_TYPE_MP3 + ? audioStreamMp3Seek(stream, frame) + : audioStreamPcmSeek(stream, frame) + ); + errorOk(); +} + +errorret_t audioStreamRead( + audiostream_t *stream, + int16_t *buffer, + const size_t frameCount, + size_t *outFramesRead +) { + assertNotNull(stream, "Stream cannot be NULL."); + + errorChain( + stream->type == AUDIO_STREAM_TYPE_MP3 + ? audioStreamMp3Read(stream, buffer, frameCount, outFramesRead) + : audioStreamPcmRead(stream, buffer, frameCount, outFramesRead) + ); + errorOk(); +} + void audioStreamPlay(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); stream->state |= AUDIO_STREAM_STATE_PLAYING; @@ -74,7 +111,7 @@ void audioStreamSetPosition(audiostream_t *stream, const float_t position) { while(t < 0) t += stream->duration; while(t >= stream->duration) t -= stream->duration; - stream->startFrame = (size_t) (t * stream->pcm.sampleRate); + stream->startFrame = (size_t) (t * stream->sampleRate); stream->seeking = true; // Force the next audioStreamUpdate() to re-buffer from startFrame instead @@ -183,7 +220,7 @@ errorret_t audioStreamUpdate(audiostream_t *stream) { // matters for platforms that reach this generic restart path at all; // PSP/Dolphin loop entirely on their own (thread/hardware) and never // report "finished" while looping, so in practice this is Linux-only. - stream->startFrame = (size_t) (stream->loopTo * stream->pcm.sampleRate); + stream->startFrame = (size_t) (stream->loopTo * stream->sampleRate); if(stream->onLoop != NULL) { stream->onLoop(stream); @@ -217,6 +254,8 @@ errorret_t audioStreamDispose(audiostream_t *stream) { if(stream->type == AUDIO_STREAM_TYPE_PCM) { errorChain(audioStreamPcmDispose(stream)); + } else if(stream->type == AUDIO_STREAM_TYPE_MP3) { + errorChain(audioStreamMp3Dispose(stream)); } if(stream->asset != NULL) { diff --git a/src/dusk/audio/audiostream.h b/src/dusk/audio/audiostream.h index b4b06ef7..f92bb287 100644 --- a/src/dusk/audio/audiostream.h +++ b/src/dusk/audio/audiostream.h @@ -119,6 +119,15 @@ typedef struct audiostream_s { // onLoop for - only ever touched from the main thread. uint32_t lastLoopCount; + // PCM format of the stream's decoded output - read identically by every + // platform backend regardless of stream type (PCM decodes straight + // through; MP3 decodes to this same format), so it lives here rather + // than duplicated in both audiostreampcm_t and audiostreammp3_t. Set by + // whichever type-specific Init function runs (audioStreamPcmInit() / + // audioStreamMp3Init()). + uint32_t sampleRate; + uint8_t channels; + // Stream type specific data. union { audiostreampcm_t pcm; @@ -143,11 +152,55 @@ typedef struct audiostream_s { * * @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). + * function supports (ASSET_LOADER_TYPE_WAV or + * ASSET_LOADER_TYPE_MP3). * @return Error indicating success or failure. */ errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset); +/** + * Returns the total number of decoded PCM frames (one sample per channel) + * available in the stream's underlying data, regardless of its type - + * dispatches to audioStreamPcmGetTotalFrames() or + * audioStreamMp3GetTotalFrames(). Platform backends should use this rather + * than a type-specific function directly, so they don't need to know or + * care which type they're driving. + * + * @param stream The audio stream to query. + * @return The total number of frames available. + */ +size_t audioStreamGetTotalFrames(const audiostream_t *stream); + +/** + * Seeks the stream's decode position to the given frame offset, regardless + * of its type - dispatches to audioStreamPcmSeek() or audioStreamMp3Seek(). + * + * @param stream The audio stream to seek. + * @param frame Frame offset to seek to, relative to the start of the + * stream's decoded data. + * @return Error indicating success or failure. + */ +errorret_t audioStreamSeek(audiostream_t *stream, const size_t frame); + +/** + * Reads up to frameCount frames of decoded PCM sample data from the + * stream's current position, regardless of its type - dispatches to + * audioStreamPcmRead() or audioStreamMp3Read(). See either for the exact + * short-read-at-end-of-stream contract, which is identical for both. + * + * @param stream The audio stream to read from. + * @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 audioStreamRead( + audiostream_t *stream, + int16_t *buffer, + const size_t frameCount, + size_t *outFramesRead +); + /** * Begins or resumes playback of the given audio stream. * diff --git a/src/dusk/audio/audiostreammp3.c b/src/dusk/audio/audiostreammp3.c index 08cc30a4..897e8037 100644 --- a/src/dusk/audio/audiostreammp3.c +++ b/src/dusk/audio/audiostreammp3.c @@ -5,4 +5,157 @@ * https://opensource.org/licenses/MIT */ -#include "audiostreammp3.h" \ No newline at end of file +#include "audiostreammp3.h" +#include "audiostream.h" +#include "assert/assert.h" +#include "util/math.h" +#include "util/memory.h" + +errorret_t audioStreamMp3Init(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertNotNull(stream->asset, "Stream must have an asset assigned."); + assertTrue( + stream->asset->type == ASSET_LOADER_TYPE_MP3, + "Asset is not an MP3 file." + ); + + const assetmp3file_t *mp3 = &stream->asset->data.mp3; + + stream->type = AUDIO_STREAM_TYPE_MP3; + stream->sampleRate = mp3->sampleRate; + stream->channels = mp3->channels; + stream->duration = (float_t) mp3->totalFrames / (float_t) mp3->sampleRate; + + stream->mp3.pendingFrames = 0; + stream->mp3.pendingPosition = 0; + stream->mp3.position = 0; + stream->mp3.totalFrames = mp3->totalFrames; + + // Each stream opens its own independent handle to the same underlying + // asset file - see audiostreampcm.c's audioStreamPcmInit() for why. + errorChain(assetFileInit( + &stream->mp3.file, stream->asset->name, NULL, NULL + )); + errorChain(assetFileOpen(&stream->mp3.file)); + errorChain(assetFileRead(&stream->mp3.file, NULL, mp3->dataOffset)); + + errorChain(audioStreamMp3DecoderInit(stream)); + + errorChain(audioStreamPlatformInit(stream)); + + errorOk(); +} + +errorret_t audioStreamMp3Dispose(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + errorChain(audioStreamMp3DecoderDispose(stream)); + errorChain(assetFileClose(&stream->mp3.file)); + errorChain(assetFileDispose(&stream->mp3.file)); + + errorOk(); +} + +size_t audioStreamMp3GetTotalFrames(const audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + return stream->mp3.totalFrames; +} + +errorret_t audioStreamMp3Seek(audiostream_t *stream, const size_t frame) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + if(frame == stream->mp3.position) { + errorOk(); + } + + // Every seek - forward or backward - restarts decoding from the very + // start of the compressed stream and discards frames until reaching the + // target; see this file's own header comment for why MP3 has no cheaper + // option, unlike audioStreamPcmSeek()'s direct byte-offset seek. + errorChain(audioStreamMp3DecoderRewind(stream)); + stream->mp3.position = 0; + stream->mp3.pendingFrames = 0; + stream->mp3.pendingPosition = 0; + + const size_t channels = stream->channels; + int16_t discard[AUDIO_MP3_MAX_SAMPLES_PER_FRAME]; + + while(stream->mp3.position < frame) { + size_t decodedFrames = 0; + errorChain(audioStreamMp3DecoderDecodeFrame(stream, discard, &decodedFrames)); + if(decodedFrames == 0) { + // Ran out of stream before reaching the target - clamp rather than + // erroring, same tolerance audioStreamPcmRead() has for a + // shorter-than-declared asset. + break; + } + + const size_t framesNeeded = frame - stream->mp3.position; + if(decodedFrames <= framesNeeded) { + stream->mp3.position += decodedFrames; + continue; + } + + // This frame overshoots the target - keep the excess as pending so + // the next Read() starts exactly at `frame` instead of skipping past + // it. + const size_t keepFrames = decodedFrames - framesNeeded; + memoryMove( + discard, discard + (framesNeeded * channels), + keepFrames * channels * sizeof(int16_t) + ); + memoryCopy(stream->mp3.pending, discard, keepFrames * channels * sizeof(int16_t)); + stream->mp3.pendingFrames = keepFrames; + stream->mp3.pendingPosition = 0; + stream->mp3.position = frame; + } + + errorOk(); +} + +errorret_t audioStreamMp3Read( + 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_MP3, "Stream is not MP3."); + + const size_t channels = stream->channels; + size_t framesWritten = 0; + + while(framesWritten < frameCount) { + if(stream->mp3.pendingPosition >= stream->mp3.pendingFrames) { + size_t decodedFrames = 0; + errorChain(audioStreamMp3DecoderDecodeFrame( + stream, stream->mp3.pending, &decodedFrames + )); + stream->mp3.pendingFrames = decodedFrames; + stream->mp3.pendingPosition = 0; + if(decodedFrames == 0) break; // Genuine end of stream. + } + + const size_t framesAvailable = stream->mp3.pendingFrames - stream->mp3.pendingPosition; + const size_t framesToCopy = mathMin(frameCount - framesWritten, framesAvailable); + + memoryCopy( + buffer + (framesWritten * channels), + stream->mp3.pending + (stream->mp3.pendingPosition * channels), + framesToCopy * channels * sizeof(int16_t) + ); + + stream->mp3.pendingPosition += framesToCopy; + framesWritten += framesToCopy; + } + + stream->mp3.position += framesWritten; + *outFramesRead = framesWritten; + errorOk(); +} diff --git a/src/dusk/audio/audiostreammp3.h b/src/dusk/audio/audiostreammp3.h index 5db7c42f..92651126 100644 --- a/src/dusk/audio/audiostreammp3.h +++ b/src/dusk/audio/audiostreammp3.h @@ -7,7 +7,133 @@ #pragma once #include "error/error.h" +#include "asset/assetfile.h" +#include "audio/audiostreammp3decoder.h" + +typedef struct audiostream_s audiostream_t; + +// How many interleaved PCM samples (not frames - samples, i.e. frames * +// channels) a single audioStreamMp3DecoderDecodeFrame() call is ever +// allowed to produce. Sizes the pending-sample buffer below, which every +// decoder backend writes directly into - this must be sized to the +// largest of them, not just "one MPEG frame": +// - minimp3 (software, Linux/Dolphin): always exactly one frame, 1152 +// samples/channel for MPEG-1 Layer III, doubled here for stereo. +// - sceMp3 (hardware, PSP): its own pcmBuf is provisioned at double that +// (see AUDIO_MP3_PSP_PCM_BUF_SIZE) - i.e. sceMp3Decode() can +// legitimately hand back more than one frame's worth in a single call. +// Sizing this for only one frame silently overflowed stream->mp3.pending +// on real hardware whenever that happened - confirmed as the cause of +// very intermittent audio corruption/clicking, since it only bit when +// sceMp3 actually returned the larger amount. +// So this covers the larger (PSP) case; minimp3's decode is safely well +// within it. +#define AUDIO_MP3_MAX_SAMPLES_PER_FRAME (1152 * 2 * 2) typedef struct { - void *empty; -} audiostreammp3_t; \ No newline at end of file + // This stream's own private handle into its asset's underlying file - + // see audiostreampcm_t.file's own comment for why this isn't shared + // across streams playing the same asset. Holds compressed MP3 bytes; + // the decoder backend (audiostreammp3decoder.h) reads from it directly. + assetfile_t file; + + // Opaque per-platform decoder state - a reserved sceMp3 handle plus its + // buffers on PSP, or an mp3dec_t plus a sliding compressed-byte window on + // the minimp3-based software backend (Linux/Dolphin). Defined by + // whichever audiostreammp3decoder.h is actually visible when this file + // is compiled - see that header's own comment. + audiostreammp3decoder_t decoder; + + // One decoded MPEG frame's worth of PCM, held here across Read() calls + // since a frame (576 or 1152 samples/channel) rarely lines up exactly + // with whatever frameCount a caller asks for. pendingPosition marks how + // many of the first pendingFrames frames have already been consumed. + int16_t pending[AUDIO_MP3_MAX_SAMPLES_PER_FRAME]; + size_t pendingFrames; + size_t pendingPosition; + + // Current logical PCM frame position, relative to the start of the + // decoded stream - decoding is push-forward-only (see + // audioStreamMp3Seek()'s own comment on why a backward seek re-decodes + // from the start rather than truly random-accessing). + size_t position; + + // Total decoded PCM frame count for the whole clip - copied from the + // asset's parsed metadata at Init (see assetmp3file_t.totalFrames), + // exact if a Xing/Info header was present, otherwise an estimate. + size_t totalFrames; +} audiostreammp3_t; + +/** + * Configures the given MP3 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): opens this + * stream's own private file handle onto the asset's compressed MPEG data, + * and initializes the platform decoder backend + * (audioStreamMp3DecoderInit()). + * + * Must be called after stream->asset is set and before audioStreamPlay(). + * + * @param stream The audio stream to configure. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3Init(audiostream_t *stream); + +/** + * Disposes the given MP3 audio stream's decoder backend and private file + * handle. + * + * @param stream The audio stream to dispose. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3Dispose(audiostream_t *stream); + +/** + * Returns the total number of frames (one sample per channel) available in + * the stream's underlying MP3 data - see assetmp3file_t.totalFrames's own + * comment on why this is sometimes an estimate rather than an exact count, + * unlike audioStreamPcmGetTotalFrames(). + * + * @param stream The audio stream to query. Must be AUDIO_STREAM_TYPE_MP3. + * @return The total number of frames available. + */ +size_t audioStreamMp3GetTotalFrames(const audiostream_t *stream); + +/** + * Seeks the stream's decode position to the given frame offset. Unlike + * audioStreamPcmSeek(), this is never cheap: MPEG frames aren't + * independently decodable (each one's bit reservoir can depend on data + * carried over from prior frames), so there's no equivalent of PCM's + * direct byte-offset seek - every call, forward or backward, rewinds the + * decoder to the very start of the compressed stream and decodes (and + * discards) frames until reaching the target. Acceptable for this engine's + * actual use (looping background music, where loopTo is typically near + * the start anyway), but a real, non-constant cost for a seek deep into a + * long track. + * + * @param stream The audio stream to seek. Must be AUDIO_STREAM_TYPE_MP3. + * @param frame Frame offset to seek to, relative to the start of the + * decoded stream. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3Seek(audiostream_t *stream, const size_t frame); + +/** + * Reads up to frameCount frames of PCM sample data from the stream's + * current position, advancing it by however many frames were actually + * read. Reads fewer than frameCount (down to zero) once the underlying + * MP3 data is exhausted, rather than erroring - same contract as + * audioStreamPcmRead(). + * + * @param stream The audio stream to read from. Must be AUDIO_STREAM_TYPE_MP3. + * @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 audioStreamMp3Read( + audiostream_t *stream, + int16_t *buffer, + const size_t frameCount, + size_t *outFramesRead +); diff --git a/src/dusk/audio/audiostreammp3decodersw.c b/src/dusk/audio/audiostreammp3decodersw.c new file mode 100644 index 00000000..01377127 --- /dev/null +++ b/src/dusk/audio/audiostreammp3decodersw.c @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#define MINIMP3_IMPLEMENTATION +#include "audiostreammp3decodersw.h" +#include "audio/audiostream.h" +#include "assert/assert.h" +#include "util/memory.h" + +errorret_t audioStreamMp3DecoderInit(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + audiostreammp3decoder_t *decoder = &stream->mp3.decoder; + mp3dec_init(&decoder->decoder); + decoder->bufferFilled = 0; + decoder->endOfFile = false; + + errorOk(); +} + +errorret_t audioStreamMp3DecoderDispose(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + errorOk(); +} + +errorret_t audioStreamMp3DecoderRewind(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + const assetmp3file_t *mp3 = &stream->asset->data.mp3; + audiostreammp3decoder_t *decoder = &stream->mp3.decoder; + + errorChain(assetFileRewind(&stream->mp3.file)); + errorChain(assetFileRead(&stream->mp3.file, NULL, mp3->dataOffset)); + + mp3dec_init(&decoder->decoder); + decoder->bufferFilled = 0; + decoder->endOfFile = false; + + errorOk(); +} + +errorret_t audioStreamMp3DecoderDecodeFrame( + audiostream_t *stream, + int16_t *out, + size_t *outFrames +) { + assertNotNull(stream, "Stream cannot be NULL."); + assertNotNull(out, "Out buffer cannot be NULL."); + assertNotNull(outFrames, "outFrames cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + audiostreammp3decoder_t *decoder = &stream->mp3.decoder; + + for(;;) { + mp3dec_frame_info_t info; + memoryZero(&info, sizeof(info)); + const int samples = mp3dec_decode_frame( + &decoder->decoder, decoder->buffer, (int) decoder->bufferFilled, out, &info + ); + + if(samples > 0) { + // Shift the consumed bytes (the decoded frame, and any garbage + // minimp3 skipped before it) out of the front of the window - unless + // it consumed the whole thing, in which case there's nothing left to + // shift (memoryMove() disallows a 0-byte move). + const size_t remaining = decoder->bufferFilled - (size_t) info.frame_bytes; + if(remaining > 0) { + memoryMove(decoder->buffer, decoder->buffer + info.frame_bytes, remaining); + } + decoder->bufferFilled = remaining; + *outFrames = (size_t) samples; + errorOk(); + } + + if(info.frame_bytes > 0) { + // Garbage bytes skipped (e.g. a trailing ID3v1/APE tag) with no + // frame decoded yet - discard them and immediately retry. + const size_t remaining = decoder->bufferFilled - (size_t) info.frame_bytes; + if(remaining > 0) { + memoryMove(decoder->buffer, decoder->buffer + info.frame_bytes, remaining); + } + decoder->bufferFilled = remaining; + continue; + } + + // Not enough data buffered to decide anything either way - top up + // from the file, unless there's genuinely nothing left to add. + if(decoder->endOfFile) { + *outFrames = 0; + errorOk(); + } + + const size_t room = AUDIO_MP3_SW_BUFFER_SIZE - decoder->bufferFilled; + if(room == 0) { + // The window is already full and minimp3 still can't make a + // decision from it - the stream is malformed (or this buffer is + // pathologically small for its content). Treat as exhausted rather + // than looping forever. + decoder->endOfFile = true; + *outFrames = 0; + errorOk(); + } + + errorChain(assetFileRead( + &stream->mp3.file, decoder->buffer + decoder->bufferFilled, room + )); + const size_t bytesRead = (size_t) stream->mp3.file.lastRead; + decoder->bufferFilled += bytesRead; + if(bytesRead < room) decoder->endOfFile = true; + } +} diff --git a/src/dusk/audio/audiostreammp3decodersw.h b/src/dusk/audio/audiostreammp3decodersw.h new file mode 100644 index 00000000..43230e8d --- /dev/null +++ b/src/dusk/audio/audiostreammp3decodersw.h @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include + +typedef struct audiostream_s audiostream_t; + +// Size of the sliding compressed-byte window minimp3 decodes from. Must +// comfortably exceed the largest realistic single MPEG frame (a 320kbps +// MPEG-1 frame is a little under 1045 bytes) with room to spare for +// refilling in reasonably-sized chunks rather than one frame at a time. +#define AUDIO_MP3_SW_BUFFER_SIZE (16 * 1024) + +/** + * Software MP3 decoder state, shared by every platform that doesn't have + * (or doesn't use) a hardware MP3 decoder - currently Linux and Dolphin, + * both via this same minimp3-based implementation + * (audiostreammp3decodersw.c). See audiostreammp3.h for how this plugs + * into the shared MP3 stream layer, and audiostreammp3.h's own + * documentation of audioStreamMp3DecoderInit()/Dispose()/Rewind()/ + * DecodeFrame() for the interface this and the PSP hardware backend + * (src/duskpsp/audio/audiostreammp3decoder.c) both implement. + */ +typedef struct { + mp3dec_t decoder; + + // minimp3 decides how many compressed bytes one frame consumed only + // after attempting to decode it, so this holds however much hasn't been + // consumed yet, refilled from the stream's assetfile_t as it drains. + uint8_t buffer[AUDIO_MP3_SW_BUFFER_SIZE]; + size_t bufferFilled; + + // Set once assetFileRead() returns fewer bytes than requested - there's + // no more compressed data to refill `buffer` with, though whatever's + // still in it may still decode into one or more final frames. + bool_t endOfFile; +} audiostreammp3decoder_t; + +/** + * Initializes the software MP3 decoder for the given stream - resets + * minimp3's internal state and the compressed-byte window (empty, not yet + * filled from the asset). + * + * @param stream The audio stream to initialize. Must be AUDIO_STREAM_TYPE_MP3. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderInit(audiostream_t *stream); + +/** + * Disposes the software MP3 decoder for the given stream. A no-op beyond + * that - minimp3 allocates nothing itself, and the compressed-byte window + * lives inline in audiostreammp3decoder_t. + * + * @param stream The audio stream to dispose. Must be AUDIO_STREAM_TYPE_MP3. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderDispose(audiostream_t *stream); + +/** + * Resets the decoder to the very start of the compressed stream: rewinds + * stream->mp3.file back to the asset's parsed data offset, clears minimp3's + * internal state and the compressed-byte window, so the next + * DecodeFrame() call starts decoding from the first MPEG frame again. See + * audioStreamMp3Seek()'s own comment on why every seek goes through here. + * + * @param stream The audio stream to rewind. Must be AUDIO_STREAM_TYPE_MP3. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderRewind(audiostream_t *stream); + +/** + * Decodes the next MPEG frame's worth of PCM samples, topping up the + * compressed-byte window from stream->mp3.file as needed. Writes decoded + * samples to `out` (sized for at least AUDIO_MP3_MAX_SAMPLES_PER_FRAME + * int16_t values) and sets *outFrames to how many frames (not samples) + * were produced - 0 once the compressed stream is genuinely exhausted, + * never negative (a corrupt/truncated stream is treated the same as a + * clean end, not an error, matching audioStreamPcmRead()'s own tolerance + * for a short/truncated asset). + * + * @param stream The audio stream to decode. Must be AUDIO_STREAM_TYPE_MP3. + * @param out Destination buffer for decoded samples. + * @param outFrames Set to the number of frames actually decoded. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderDecodeFrame( + audiostream_t *stream, + int16_t *out, + size_t *outFrames +); diff --git a/src/dusk/audio/audiostreampcm.c b/src/dusk/audio/audiostreampcm.c index 9eb29ea8..c7899d30 100644 --- a/src/dusk/audio/audiostreampcm.c +++ b/src/dusk/audio/audiostreampcm.c @@ -22,8 +22,8 @@ errorret_t audioStreamPcmInit(audiostream_t *stream) { assetwavfile_t *wav = &stream->asset->data.wav; stream->type = AUDIO_STREAM_TYPE_PCM; - stream->pcm.sampleRate = wav->sampleRate; - stream->pcm.channels = wav->channels; + stream->sampleRate = wav->sampleRate; + stream->channels = wav->channels; stream->duration = ( (float_t) audioStreamPcmGetTotalFrames(stream) / (float_t) wav->sampleRate @@ -64,7 +64,7 @@ size_t audioStreamPcmGetTotalFrames(const audiostream_t *stream) { const assetwavfile_t *wav = &stream->asset->data.wav; const size_t sourceFrameSize = ( - stream->pcm.channels * (wav->bitsPerSample / 8) + stream->channels * (wav->bitsPerSample / 8) ); return wav->dataSize / sourceFrameSize; } @@ -75,7 +75,7 @@ errorret_t audioStreamPcmSeek(audiostream_t *stream, const size_t frame) { assetwavfile_t *wav = &stream->asset->data.wav; const size_t sourceFrameSize = ( - stream->pcm.channels * (wav->bitsPerSample / 8) + stream->channels * (wav->bitsPerSample / 8) ); const size_t targetByte = wav->dataOffset + (frame * sourceFrameSize); @@ -113,7 +113,7 @@ errorret_t audioStreamPcmRead( 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 channels = stream->channels; const size_t sourceSampleBytes = wav->bitsPerSample / 8; const size_t sourceFrameSize = channels * sourceSampleBytes; const size_t dataEndByte = wav->dataOffset + wav->dataSize; diff --git a/src/dusk/audio/audiostreampcm.h b/src/dusk/audio/audiostreampcm.h index 56f13cf3..1e42a83b 100644 --- a/src/dusk/audio/audiostreampcm.h +++ b/src/dusk/audio/audiostreampcm.h @@ -12,12 +12,6 @@ typedef struct audiostream_s audiostream_t; typedef struct { - // Sample rate of the stream's data, in Hz. - uint32_t sampleRate; - - // 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 diff --git a/src/dusk/engine/engine.c b/src/dusk/engine/engine.c index 4976cdab..4ed3d426 100644 --- a/src/dusk/engine/engine.c +++ b/src/dusk/engine/engine.c @@ -63,8 +63,7 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) { // 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 + "audio/audiotest.wav", ASSET_LOADER_TYPE_WAV, NULL ); errorChain(assetRequireLoaded(testToneEntry)); diff --git a/src/duskdolphin/audio/CMakeLists.txt b/src/duskdolphin/audio/CMakeLists.txt index 8df15097..e576967d 100644 --- a/src/duskdolphin/audio/CMakeLists.txt +++ b/src/duskdolphin/audio/CMakeLists.txt @@ -9,3 +9,21 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} audiodolphin.c audiostreamdolphin.c ) + +# No hardware MP3 decoder used on Dolphin (see audiostreammp3decoder.h's +# own comment on why libogc's MP3Player wrapper isn't a fit) - use the +# same shared minimp3-based software backend as Linux, sourced directly +# from src/dusk/audio rather than through its own unconditional +# CMakeLists.txt so platforms with a hardware decoder (PSP) never pull +# minimp3 in at all. +if(NOT minimp3_FOUND) + find_package(minimp3 REQUIRED) +endif() +# PRIVATE to match this toolchain's own convention (see cmake/targets/ +# dolphin.cmake) of avoiding PUBLIC library visibility, which has tripped +# up the PPC linker for other dependencies in the past. +target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE minimp3) +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + "${DUSK_SOURCES_DIR}/dusk/audio/audiostreammp3decodersw.c" +) diff --git a/src/duskdolphin/audio/audiostreamdolphin.c b/src/duskdolphin/audio/audiostreamdolphin.c index 10fe14d1..5fd0696f 100644 --- a/src/duskdolphin/audio/audiostreamdolphin.c +++ b/src/duskdolphin/audio/audiostreamdolphin.c @@ -47,8 +47,8 @@ errorret_t audioStreamDolphinDispose(audiostream_t *stream) { 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); + const size_t frameSize = stream->channels * sizeof(int16_t); + const size_t totalFrames = audioStreamGetTotalFrames(stream); // loopEndFrame/loopToFrame define the loop segment [loopToFrame, // loopEndFrame) the DSP wraps within, once looping is enabled - @@ -56,10 +56,10 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { // 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) + ? mathMin((size_t) (stream->loopStart * stream->sampleRate), totalFrames) : totalFrames; const size_t loopToFrame = mathMin( - (size_t) (stream->loopTo * stream->pcm.sampleRate), loopEndFrame + (size_t) (stream->loopTo * stream->sampleRate), loopEndFrame ); const size_t startFrame = mathMin(stream->startFrame, totalFrames); @@ -90,10 +90,10 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { stream->platform.buffer = NULL; } - errorChain(audioStreamPcmSeek(stream, 0)); + errorChain(audioStreamSeek(stream, 0)); uint8_t *buffer = (uint8_t *) memoryAllocate(bufferFrames * frameSize); size_t framesRead = 0; - errorret_t readRet = audioStreamPcmRead( + errorret_t readRet = audioStreamRead( stream, (int16_t *) buffer, bufferFrames, &framesRead ); if(errorIsNotOk(readRet)) { @@ -111,9 +111,9 @@ errorret_t audioStreamDolphinBuffer(audiostream_t *stream) { ansnd_pcm_voice_config_t config; memoryZero(&config, sizeof(ansnd_pcm_voice_config_t)); - config.samplerate = stream->pcm.sampleRate; + config.samplerate = stream->sampleRate; config.format = ANSND_VOICE_PCM_FORMAT_SIGNED_16_PCM; - config.channels = stream->pcm.channels; + config.channels = stream->channels; config.pitch = 1.0f; config.left_volume = baseVolume * leftFactor; config.right_volume = baseVolume * rightFactor; diff --git a/src/duskdolphin/audio/audiostreammp3decoder.h b/src/duskdolphin/audio/audiostreammp3decoder.h new file mode 100644 index 00000000..4cd741a8 --- /dev/null +++ b/src/duskdolphin/audio/audiostreammp3decoder.h @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once + +// GameCube/Wii have no usable hardware MP3 decoder for this engine's +// architecture (libogc's MP3Player wraps libmad but drives its own +// internal audio output, bypassing the ansnd-based pipeline +// audiostreamdolphin.c already owns - not a fit here), so Dolphin uses the +// same shared minimp3-based software backend as Linux - see that header's +// own documentation for the actual struct/interface. +#include "audio/audiostreammp3decodersw.h" diff --git a/src/dusklinux/audio/CMakeLists.txt b/src/dusklinux/audio/CMakeLists.txt index 23f030ad..1fd2e8fc 100644 --- a/src/dusklinux/audio/CMakeLists.txt +++ b/src/dusklinux/audio/CMakeLists.txt @@ -9,3 +9,16 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} audiolinux.c audiostreamlinux.c ) + +# No hardware MP3 decoder on Linux - use the shared minimp3-based software +# backend (also used by Dolphin), sourced directly from src/dusk/audio +# rather than through its own unconditional CMakeLists.txt so platforms +# with a hardware decoder (PSP) never pull minimp3 in at all. +if(NOT minimp3_FOUND) + find_package(minimp3 REQUIRED) +endif() +target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC minimp3) +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + "${DUSK_SOURCES_DIR}/dusk/audio/audiostreammp3decodersw.c" +) diff --git a/src/dusklinux/audio/audiostreamlinux.c b/src/dusklinux/audio/audiostreamlinux.c index 257f1cce..9b17113f 100644 --- a/src/dusklinux/audio/audiostreamlinux.c +++ b/src/dusklinux/audio/audiostreamlinux.c @@ -29,9 +29,9 @@ errorret_t audioStreamLinuxInit(audiostream_t *stream) { SDL_AudioSpec desired; memoryZero(&desired, sizeof(SDL_AudioSpec)); - desired.freq = (int) stream->pcm.sampleRate; + desired.freq = (int) stream->sampleRate; desired.format = AUDIO_S16SYS; - desired.channels = stream->pcm.channels; + desired.channels = stream->channels; desired.samples = AUDIO_LINUX_LEAD_FRAMES; stream->platform.device = SDL_OpenAudioDevice(NULL, 0, &desired, NULL, 0); @@ -53,8 +53,8 @@ errorret_t audioStreamLinuxDispose(audiostream_t *stream) { 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 = audioStreamPcmGetTotalFrames(stream); + 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. @@ -66,7 +66,7 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream) { size_t endFrame = totalFrames; if((stream->state & AUDIO_STREAM_STATE_LOOPING) && stream->loopStart >= 0) { endFrame = mathMin( - (size_t) (stream->loopStart * stream->pcm.sampleRate), totalFrames + (size_t) (stream->loopStart * stream->sampleRate), totalFrames ); } // A seek (or a loop restart landing exactly on the loop end) can put @@ -84,7 +84,7 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream) { if(seeking) { SDL_ClearQueuedAudio(stream->platform.device); } - errorChain(audioStreamPcmSeek(stream, startFrame)); + errorChain(audioStreamSeek(stream, startFrame)); stream->platform.position = startFrame; stream->platform.endFrame = endFrame; @@ -99,7 +99,7 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream) { 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 frameSize = stream->channels * sizeof(int16_t); const size_t framesRemaining = ( stream->platform.position < stream->platform.endFrame ? stream->platform.endFrame - stream->platform.position @@ -112,7 +112,7 @@ errorret_t audioStreamLinuxFeed(audiostream_t *stream) { int16_t *chunk = memoryAllocate(framesToRead * frameSize); size_t framesRead = 0; - errorret_t ret = audioStreamPcmRead(stream, chunk, framesToRead, &framesRead); + errorret_t ret = audioStreamRead(stream, chunk, framesToRead, &framesRead); if(errorIsNotOk(ret)) { memoryFree(chunk); errorChain(ret); @@ -156,7 +156,7 @@ bool_t audioStreamLinuxIsFinished(audiostream_t *stream) { // 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) + AUDIO_LINUX_LEAD_FRAMES * stream->channels * sizeof(int16_t) ); if(SDL_GetQueuedAudioSize(stream->platform.device) > leadBytes) { diff --git a/src/dusklinux/audio/audiostreammp3decoder.h b/src/dusklinux/audio/audiostreammp3decoder.h new file mode 100644 index 00000000..8371ccf4 --- /dev/null +++ b/src/dusklinux/audio/audiostreammp3decoder.h @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once + +// Linux has no hardware MP3 decoder to prefer, so it uses the shared +// minimp3-based software backend (also used by Dolphin) - see that +// header's own documentation for the actual struct/interface. +#include "audio/audiostreammp3decodersw.h" diff --git a/src/duskpsp/audio/CMakeLists.txt b/src/duskpsp/audio/CMakeLists.txt index 6afc7ea2..3ad1c569 100644 --- a/src/duskpsp/audio/CMakeLists.txt +++ b/src/duskpsp/audio/CMakeLists.txt @@ -8,4 +8,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC audiopsp.c audiostreampsp.c + audiostreammp3decoder.c ) diff --git a/src/duskpsp/audio/audiopsp.c b/src/duskpsp/audio/audiopsp.c index f96be52d..9815d5f1 100644 --- a/src/duskpsp/audio/audiopsp.c +++ b/src/duskpsp/audio/audiopsp.c @@ -6,6 +6,7 @@ */ #include "audiopsp.h" +#include "audiostreammp3decoder.h" errorret_t audioPSPInit() { errorOk(); @@ -16,5 +17,7 @@ errorret_t audioPSPUpdate() { } errorret_t audioPSPDispose() { + errorChain(audioStreamMp3DecoderGlobalDispose()); + errorOk(); } diff --git a/src/duskpsp/audio/audiostreammp3decoder.c b/src/duskpsp/audio/audiostreammp3decoder.c new file mode 100644 index 00000000..745d5f50 --- /dev/null +++ b/src/duskpsp/audio/audiostreammp3decoder.c @@ -0,0 +1,261 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "audiostreammp3decoder.h" +#include "audio/audiostream.h" +#include "assert/assert.h" +#include "util/memory.h" +#include +#include + +// Per the PSP SDK's mp3 sample and pspmp3.h's own documentation - these +// are hard minimums sceMp3ReserveMp3Handle() requires, not tunable. +#define AUDIO_MP3_PSP_STREAM_BUF_SIZE (16 * 1024) +#define AUDIO_MP3_PSP_PCM_BUF_SIZE (16 * (1152 / 2)) + +// sceMp3 manages its own internal ring buffer of compressed bytes rather +// than being handed a whole file/buffer up front (unlike, say, +// zip_source_buffer_create elsewhere in this codebase) - it tells the +// caller exactly where in the source stream it wants more data from +// (sceMp3GetInfoToAddStreamData()'s srcpos) and how much room is free to +// receive it into, and the caller is expected to fetch exactly that and +// call sceMp3NotifyAddStreamData(). This decoder only ever feeds it +// forward, sequentially, from stream->mp3.file (never anything sceMp3 +// itself wouldn't naturally request next), so despite the API being built +// for arbitrary positioning, this driver never needs to seek that file +// out of sequence - see audioStreamMp3DecoderFillStream()'s own assertion. + +// End-of-stream sentinel returned by sceMp3Decode() once nothing more can +// be decoded - not documented in pspmp3.h, but confirmed by the PSP SDK's +// own mp3 sample (main.c), which explicitly excludes it from its generic +// "sceMp3Decode failed" error path. +#define AUDIO_MP3_PSP_DECODE_END_OF_STREAM 0x80671402 + +// Set once audioStreamMp3DecoderInit() has loaded sceMp3's firmware +// modules and initialized its resource pool - lazily, on the first MP3 +// stream ever created, rather than unconditionally at engine startup, so +// a game that never plays an MP3 never pays for it. Guards +// audioStreamMp3DecoderGlobalDispose() so it only tears down what was +// actually set up. +static bool_t AUDIO_MP3_PSP_RESOURCES_READY = false; + +errorret_t audioStreamMp3DecoderFillStream(audiostream_t *stream) { + audiostreammp3decoder_t *decoder = &stream->mp3.decoder; + + SceUChar8 *dst = NULL; + SceInt32 toWrite = 0; + SceInt32 srcPos = 0; + SceInt32 status = sceMp3GetInfoToAddStreamData( + decoder->handle, &dst, &toWrite, &srcPos + ); + if(status < 0) { + errorThrow("sceMp3GetInfoToAddStreamData failed: 0x%08X", status); + } + + if(toWrite <= 0) { + errorOk(); + } + + assertTrue( + (size_t) srcPos == decoder->streamPosition, + "sceMp3 requested stream data out of the sequence this decoder feeds it in." + ); + + errorChain(assetFileRead(&stream->mp3.file, dst, (size_t) toWrite)); + const size_t bytesRead = (size_t) stream->mp3.file.lastRead; + + status = sceMp3NotifyAddStreamData(decoder->handle, (SceInt32) bytesRead); + if(status < 0) { + errorThrow("sceMp3NotifyAddStreamData failed: 0x%08X", status); + } + + decoder->streamPosition += bytesRead; + if(bytesRead < (size_t) toWrite) decoder->endOfFile = true; + + errorOk(); +} + +errorret_t audioStreamMp3DecoderInit(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + if(!AUDIO_MP3_PSP_RESOURCES_READY) { + int status = sceUtilityLoadModule(PSP_MODULE_AV_AVCODEC); + if(status < 0) { + errorThrow("Failed to load PSP_MODULE_AV_AVCODEC: 0x%08X", status); + } + status = sceUtilityLoadModule(PSP_MODULE_AV_MP3); + if(status < 0) { + errorThrow("Failed to load PSP_MODULE_AV_MP3: 0x%08X", status); + } + status = sceMp3InitResource(); + if(status < 0) { + errorThrow("sceMp3InitResource failed: 0x%08X", status); + } + AUDIO_MP3_PSP_RESOURCES_READY = true; + } + + const assetmp3file_t *mp3 = &stream->asset->data.mp3; + audiostreammp3decoder_t *decoder = &stream->mp3.decoder; + + decoder->mp3Buf = memoryAlign(64, AUDIO_MP3_PSP_STREAM_BUF_SIZE); + decoder->pcmBuf = memoryAlign(64, AUDIO_MP3_PSP_PCM_BUF_SIZE); + decoder->streamPosition = mp3->dataOffset; + decoder->endOfFile = false; + + SceMp3InitArg initArg; + memoryZero(&initArg, sizeof(initArg)); + initArg.mp3StreamStart = (SceOff) mp3->dataOffset; + initArg.mp3StreamEnd = (SceOff) (mp3->dataOffset + mp3->dataSize); + initArg.mp3Buf = decoder->mp3Buf; + initArg.mp3BufSize = AUDIO_MP3_PSP_STREAM_BUF_SIZE; + initArg.pcmBuf = decoder->pcmBuf; + initArg.pcmBufSize = AUDIO_MP3_PSP_PCM_BUF_SIZE; + + const SceInt32 handle = sceMp3ReserveMp3Handle(&initArg); + if(handle < 0) { + memoryFree(decoder->mp3Buf); + memoryFree(decoder->pcmBuf); + errorThrow("sceMp3ReserveMp3Handle failed: 0x%08X", handle); + } + decoder->handle = handle; + + // sceMp3Init() needs some stream data already fed before it can + // determine the stream's format - see the PSP SDK's own mp3 sample. + errorChain(audioStreamMp3DecoderFillStream(stream)); + + const SceInt32 initStatus = sceMp3Init(decoder->handle); + if(initStatus < 0) { + sceMp3ReleaseMp3Handle(decoder->handle); + memoryFree(decoder->mp3Buf); + memoryFree(decoder->pcmBuf); + errorThrow("sceMp3Init failed: 0x%08X", initStatus); + } + + errorOk(); +} + +errorret_t audioStreamMp3DecoderDispose(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + audiostreammp3decoder_t *decoder = &stream->mp3.decoder; + + sceMp3ReleaseMp3Handle(decoder->handle); + memoryFree(decoder->mp3Buf); + decoder->mp3Buf = NULL; + memoryFree(decoder->pcmBuf); + decoder->pcmBuf = NULL; + + errorOk(); +} + +errorret_t audioStreamMp3DecoderRewind(audiostream_t *stream) { + assertNotNull(stream, "Stream cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + const assetmp3file_t *mp3 = &stream->asset->data.mp3; + + errorChain(assetFileRewind(&stream->mp3.file)); + errorChain(assetFileRead(&stream->mp3.file, NULL, mp3->dataOffset)); + + // Deliberately not sceMp3ResetPlayPosition(): the SDK doesn't document + // precisely enough whether it fully clears sceMp3's own internal + // compressed-data ring buffer or just resets a playback cursor, leaving + // room for it to disagree with the fresh stream position we just fed it + // from - a mismatch that would only be audible as an occasional click + // right at the loop point, depending on how much of sceMp3's internal + // buffer happened to still hold stale data at reset time. Fully tearing + // down and recreating the decoder instead guarantees a clean state by + // construction, via the exact same path already proven correct for the + // stream's very first Init - one extra handle release/reserve cycle per + // loop wrap is negligible next to how rarely loops actually happen. + errorChain(audioStreamMp3DecoderDispose(stream)); + errorChain(audioStreamMp3DecoderInit(stream)); + + errorOk(); +} + +errorret_t audioStreamMp3DecoderDecodeFrame( + audiostream_t *stream, + int16_t *out, + size_t *outFrames +) { + assertNotNull(stream, "Stream cannot be NULL."); + assertNotNull(out, "Out buffer cannot be NULL."); + assertNotNull(outFrames, "outFrames cannot be NULL."); + assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3."); + + audiostreammp3decoder_t *decoder = &stream->mp3.decoder; + const size_t frameSize = stream->channels * sizeof(int16_t); + + for(;;) { + if(sceMp3CheckStreamDataNeeded(decoder->handle) > 0 && !decoder->endOfFile) { + errorChain(audioStreamMp3DecoderFillStream(stream)); + } + + SceShort16 *pcm = NULL; + const SceInt32 bytesDecoded = sceMp3Decode(decoder->handle, &pcm); + + if(bytesDecoded > 0) { + // `out` (stream->mp3.pending) is sized for + // AUDIO_MP3_MAX_SAMPLES_PER_FRAME samples - sized specifically to + // cover sceMp3's pcmBuf provisioning (see this decoder's own + // AUDIO_MP3_PSP_PCM_BUF_SIZE comment). Asserted rather than trusted: + // a real hardware overflow here was previously silent, corrupting + // whatever followed stream->mp3.pending in memory, and only showed + // up as very intermittent audio clicking. + assertTrue( + (size_t) bytesDecoded <= AUDIO_MP3_MAX_SAMPLES_PER_FRAME * sizeof(int16_t), + "sceMp3Decode returned more PCM than the destination buffer can hold." + ); + const size_t frames = (size_t) bytesDecoded / frameSize; + memoryCopy(out, pcm, (size_t) bytesDecoded); + *outFrames = frames; + errorOk(); + } + + if( + bytesDecoded < 0 && + (uint32_t) bytesDecoded != AUDIO_MP3_PSP_DECODE_END_OF_STREAM + ) { + errorThrow("sceMp3Decode failed: 0x%08X", bytesDecoded); + } + + // Decoded nothing this call - if sceMp3 doesn't need more data either, + // it's genuinely out of frames to produce. + if(sceMp3CheckStreamDataNeeded(decoder->handle) <= 0) { + *outFrames = 0; + errorOk(); + } + + if(decoder->endOfFile) { + // sceMp3 wants more data but the file has none left - one last + // decode attempt already happened above with whatever was fed; + // nothing further will ever arrive. + *outFrames = 0; + errorOk(); + } + } +} + +errorret_t audioStreamMp3DecoderGlobalDispose(void) { + if(!AUDIO_MP3_PSP_RESOURCES_READY) { + errorOk(); + } + + const SceInt32 status = sceMp3TermResource(); + if(status < 0) { + errorThrow("sceMp3TermResource failed: 0x%08X", status); + } + + sceUtilityUnloadModule(PSP_MODULE_AV_MP3); + sceUtilityUnloadModule(PSP_MODULE_AV_AVCODEC); + AUDIO_MP3_PSP_RESOURCES_READY = false; + + errorOk(); +} diff --git a/src/duskpsp/audio/audiostreammp3decoder.h b/src/duskpsp/audio/audiostreammp3decoder.h new file mode 100644 index 00000000..d1f1797c --- /dev/null +++ b/src/duskpsp/audio/audiostreammp3decoder.h @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include + +typedef struct audiostream_s audiostream_t; + +/** + * Hardware MP3 decoder state (sceMp3) for a single stream. Firmware-side + * decode, off the CPU entirely - see audiostreammp3decoder.c's top-of-file + * comment for the streaming model this drives (sceMp3 manages its own + * internal compressed-byte ring buffer, pulling more from + * stream->mp3.file on demand via audioStreamMp3DecoderFillStream()). + */ +typedef struct { + // Handle returned by sceMp3ReserveMp3Handle(), valid for this stream's + // whole lifetime. + SceInt32 handle; + + // Buffers owned by this decoder and registered with sceMp3 via + // SceMp3InitArg - sized to the SDK's documented minimums (mp3Buf must be + // >= 8192 bytes, pcmBuf >= 9216 bytes). Allocated once in + // audioStreamMp3DecoderInit(), freed in audioStreamMp3DecoderDispose(). + uint8_t *mp3Buf; + uint8_t *pcmBuf; + + // This decoder's own record of how many compressed bytes have been fed + // to sceMp3 so far (relative to the start of the whole file, matching + // stream->mp3.file's own position) - sceMp3GetInfoToAddStreamData() + // reports back the absolute source position it wants read from next, + // and since this decoder only ever feeds it forward, sequentially, that + // position should always match this - asserted rather than silently + // trusted, in audioStreamMp3DecoderFillStream(). + size_t streamPosition; + + // Set once assetFileRead() returns fewer bytes than requested while + // filling sceMp3's stream buffer - there's no more compressed data to + // add, though sceMp3 may still have enough already-fed data to decode + // one or more final frames. + bool_t endOfFile; +} audiostreammp3decoder_t; + +/** + * Feeds sceMp3 more compressed bytes if (and only as much as) it currently + * wants: asks it where in the stream and how much (sceMp3GetInfoToAddStreamData()), + * reads exactly that from stream->mp3.file, and hands it back + * (sceMp3NotifyAddStreamData()). A no-op if sceMp3 doesn't currently want + * more. See this file's own top-of-file comment on why the source + * position sceMp3 requests is asserted rather than actually used to seek. + * + * @param stream The audio stream to feed. Must be AUDIO_STREAM_TYPE_MP3. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderFillStream(audiostream_t *stream); + +/** + * Initializes the hardware MP3 decoder for the given stream: lazily loads + * the sceMp3/AVCODEC firmware modules and initializes sceMp3's resource + * pool the first time any stream ever needs it (see + * audioStreamMp3DecoderGlobalDispose()'s own comment), reserves a handle + * sized from the stream's asset metadata, and feeds it enough initial + * compressed data for sceMp3Init() to succeed. + * + * @param stream The audio stream to initialize. Must be AUDIO_STREAM_TYPE_MP3. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderInit(audiostream_t *stream); + +/** + * Disposes the hardware MP3 decoder for the given stream: releases its + * sceMp3 handle and frees its buffers. Does not tear down sceMp3's global + * resource pool - see audioStreamMp3DecoderGlobalDispose(). + * + * @param stream The audio stream to dispose. Must be AUDIO_STREAM_TYPE_MP3. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderDispose(audiostream_t *stream); + +/** + * Resets the decoder to the very start of the compressed stream: rewinds + * stream->mp3.file back to the asset's parsed data offset, resets this + * decoder's own streamPosition to match, and calls + * sceMp3ResetPlayPosition() so sceMp3 requests stream data from the start + * again. See audioStreamMp3Seek()'s own comment on why every seek goes + * through here. + * + * @param stream The audio stream to rewind. Must be AUDIO_STREAM_TYPE_MP3. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderRewind(audiostream_t *stream); + +/** + * Decodes the next MPEG frame's worth of PCM samples, feeding sceMp3's + * internal stream buffer from stream->mp3.file as it requests more. + * Writes decoded samples to `out` (sized for at least + * AUDIO_MP3_MAX_SAMPLES_PER_FRAME int16_t values) and sets *outFrames to + * how many frames (not samples) were produced - 0 once sceMp3 reports it + * both has nothing left to decode and needs no more stream data (a + * genuine end of stream). + * + * @param stream The audio stream to decode. Must be AUDIO_STREAM_TYPE_MP3. + * @param out Destination buffer for decoded samples. + * @param outFrames Set to the number of frames actually decoded. + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderDecodeFrame( + audiostream_t *stream, + int16_t *out, + size_t *outFrames +); + +/** + * Tears down sceMp3's global resource pool and unloads its firmware + * modules, if audioStreamMp3DecoderInit() ever actually initialized them - + * a no-op otherwise. Called once from audioPSPDispose(), regardless of + * whether any MP3 stream was ever created. + * + * @return Error indicating success or failure. + */ +errorret_t audioStreamMp3DecoderGlobalDispose(void); diff --git a/src/duskpsp/audio/audiostreampsp.c b/src/duskpsp/audio/audiostreampsp.c index 17246b6c..86d9156d 100644 --- a/src/duskpsp/audio/audiostreampsp.c +++ b/src/duskpsp/audio/audiostreampsp.c @@ -42,24 +42,24 @@ errorret_t audioStreamPSPInit(audiostream_t *stream) { assertNotNull(stream, "Stream cannot be NULL."); - if(stream->pcm.channels != 1 && stream->pcm.channels != 2) { + if(stream->channels != 1 && stream->channels != 2) { errorThrow( "PSP audio only supports mono or stereo PCM, got %d channels.", - stream->pcm.channels + stream->channels ); } // sceAudioChReserve's hardware channels always run at the PSP's native // 44100Hz; there is no per-channel sample rate. Arbitrary rates would need // sceAudioSRCChReserve's single exclusive channel instead. - if(stream->pcm.sampleRate != 44100) { + if(stream->sampleRate != 44100) { errorThrow( "PSP audio channels are fixed at 44100Hz, got %uHz.", - stream->pcm.sampleRate + stream->sampleRate ); } - const int format = stream->pcm.channels == 1 + const int format = stream->channels == 1 ? PSP_AUDIO_FORMAT_MONO : PSP_AUDIO_FORMAT_STEREO; @@ -75,7 +75,7 @@ errorret_t audioStreamPSPInit(audiostream_t *stream) { stream->platform.playRequested = false; const size_t ringSize = - AUDIO_PSP_RING_FRAMES * stream->pcm.channels * sizeof(int16_t); + AUDIO_PSP_RING_FRAMES * stream->channels * sizeof(int16_t); stream->platform.ring = memoryAllocate(ringSize); stream->platform.scratch = memoryAllocate(ringSize); threadMutexInit(&stream->platform.ringLock); @@ -109,7 +109,7 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream) { stream->platform.readReachedEnd = false; stream->platform.readFailed = false; - stream->platform.totalFrames = audioStreamPcmGetTotalFrames(stream); + stream->platform.totalFrames = audioStreamGetTotalFrames(stream); // loopEndFrame/loopToFrame define the loop segment [loopToFrame, // loopEndFrame) that a looping pass wraps within, once it's reached - @@ -117,12 +117,12 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream) { // behaviour is unchanged when no explicit loop points are configured. stream->platform.loopEndFrame = stream->loopStart >= 0 ? mathMin( - (size_t) (stream->loopStart * stream->pcm.sampleRate), + (size_t) (stream->loopStart * stream->sampleRate), stream->platform.totalFrames ) : stream->platform.totalFrames; stream->platform.loopToFrame = mathMin( - (size_t) (stream->loopTo * stream->pcm.sampleRate), + (size_t) (stream->loopTo * stream->sampleRate), stream->platform.loopEndFrame ); @@ -130,7 +130,7 @@ errorret_t audioStreamPSPBuffer(audiostream_t *stream) { stream->startFrame = 0; stream->seeking = false; - errorChain(audioStreamPcmSeek(stream, startFrame)); + errorChain(audioStreamSeek(stream, startFrame)); stream->platform.readPosition = startFrame; threadMutexLock(&stream->platform.ringLock); @@ -158,20 +158,28 @@ void audioStreamPSPTopUp(audiostream_t *stream) { threadMutexUnlock(&stream->platform.ringLock); if(filled >= AUDIO_PSP_LEAD_FRAMES) return; - const size_t channels = stream->pcm.channels; + const size_t channels = stream->channels; const size_t frameSize = channels * sizeof(int16_t); - // Fill however much room the ring actually has in this one call, not - // just a small fixed step - otherwise a temporary engine frame-rate dip - // (below roughly one hardware chunk's worth of playback time per frame) - // would let production permanently fall behind consumption, since a - // fixed-size top-up per call can never make up lost ground. Bounded by - // the ring's own physical capacity, and by loopEndFrame (the loop - // segment's end) - except a seek can legitimately land past it (e.g. - // into an outro after the loop point), in which case read out to the - // true end of the clip once instead of underflowing - // framesRemainingInSegment. - const size_t room = AUDIO_PSP_RING_FRAMES - filled; + // Fill however much room the ring has, up to one bounded step, not just + // whatever's needed to reach LEAD in a single shot - AUDIO_PSP_RING_FRAMES + // is now sized to absorb a slow MP3 loop-wrap (see its own comment), and + // greedily trying to fill all of it in one call right after a fresh + // Buffer() (when the ring starts empty) would turn the very first + // top-up into one long blocking decode burst on the main thread instead + // of many small ones - worse for startup smoothness, not better. Still + // uncapped per se: reaching LEAD just takes a few calls (a few engine + // frames) instead of one, which comfortably keeps up with real-time + // consumption the same way an unbounded fill would, since a temporary + // frame-rate dip only slows how fast the ring tops up, never how much + // room is left to fill on the next call. Bounded by the ring's own + // physical capacity, and by loopEndFrame (the loop segment's end) - + // except a seek can legitimately land past it (e.g. into an outro after + // the loop point), in which case read out to the true end of the clip + // once instead of underflowing framesRemainingInSegment. + const size_t room = mathMin( + AUDIO_PSP_RING_FRAMES - filled, (size_t) AUDIO_PSP_TOPUP_STEP_FRAMES + ); const size_t currentEndFrame = stream->platform.readPosition < stream->platform.loopEndFrame ? stream->platform.loopEndFrame : stream->platform.totalFrames; @@ -186,7 +194,7 @@ void audioStreamPSPTopUp(audiostream_t *stream) { size_t framesRead = 0; if(errorIsNotOk( - audioStreamPcmRead(stream, scratch, framesToRead, &framesRead) + audioStreamRead(stream, scratch, framesToRead, &framesRead) )) { stream->platform.readFailed = true; return; @@ -219,7 +227,7 @@ void audioStreamPSPTopUp(audiostream_t *stream) { if(reachesSegmentEnd) { if(willLoop) { - if(errorIsNotOk(audioStreamPcmSeek(stream, stream->platform.loopToFrame))) { + if(errorIsNotOk(audioStreamSeek(stream, stream->platform.loopToFrame))) { stream->platform.readFailed = true; return; } @@ -258,7 +266,7 @@ void audioStreamPSPThreadFeed(thread_t *thread) { sceKernelChangeThreadPriority(sceKernelGetThreadId(), AUDIO_PSP_THREAD_PRIORITY); audiostream_t *stream = (audiostream_t *) thread->data; - const size_t channels = stream->pcm.channels; + const size_t channels = stream->channels; const size_t frameSize = channels * sizeof(int16_t); int16_t *chunk = memoryAllocate(AUDIO_PSP_CHUNK_FRAMES * frameSize); diff --git a/src/duskpsp/audio/audiostreampsp.h b/src/duskpsp/audio/audiostreampsp.h index 36249cfa..70b7fa49 100644 --- a/src/duskpsp/audio/audiostreampsp.h +++ b/src/duskpsp/audio/audiostreampsp.h @@ -16,13 +16,32 @@ typedef struct audiostream_s audiostream_t; // Physical capacity of the ring; must comfortably exceed // AUDIO_PSP_LEAD_FRAMES plus one top-up step so a single top-up call can // never overwrite data the output thread hasn't consumed yet. -#define AUDIO_PSP_RING_FRAMES 16384 +// +// Sized well above what a PCM stream would ever need on its own, because +// an MP3 stream's loop-wrap (audioStreamMp3DecoderRewind() on PSP) fully +// tears down and recreates the hardware decoder - a real handle release/ +// reserve round-trip plus a fresh ~16KB Memory Stick read to re-prime +// sceMp3's stream buffer - all synchronously, inside the same TopUp() call +// that's supposed to be keeping this ring fed. That's slow enough, +// occasionally, to eat into a tighter lead margin and click right at the +// loop point (confirmed on real hardware) - this gives it generous room to +// do so without the output thread ever catching up to empty. +#define AUDIO_PSP_RING_FRAMES 49152 // Top-up threshold - audioStreamPSPTopUp() (called once per engine // Update(), see audioStreamPSPIsFinished()) only reads more data once the // ring drops below this, same trigger dusklinux uses for its own lead -// margin. -#define AUDIO_PSP_LEAD_FRAMES 4096 +// margin. See AUDIO_PSP_RING_FRAMES's own comment for why this is sized +// well beyond dusklinux's equivalent. +#define AUDIO_PSP_LEAD_FRAMES 12288 + +// Max frames audioStreamPSPTopUp() will read in one call, regardless of +// how much ring room is actually free - keeps each call's worst-case +// blocking time on the main thread bounded and roughly constant even +// though AUDIO_PSP_RING_FRAMES/AUDIO_PSP_LEAD_FRAMES are large; reaching +// LEAD from an empty ring just takes a few calls (a few engine frames) +// instead of one long one. See AUDIO_PSP_RING_FRAMES's own comment. +#define AUDIO_PSP_TOPUP_STEP_FRAMES 4096 // Max number of pending loop-wrap events (see audiostreampsploopmarker_t // below) the ring can remember at once. Sized generously relative to how