From 16b4c2b4b7c0bf491f0554713388fe095b0fd4f7 Mon Sep 17 00:00:00 2001 From: Augusto Daniele <12686734+tasken@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:13:39 -0300 Subject: [PATCH] Support for BCSTM files in themes --- Themes & Covers.md | 8 +- arm9/source/bcstmsource.cpp | 339 ++++++++++++++++++++++++++++++++++++ arm9/source/bcstmsource.h | 62 +++++++ arm9/source/dspadpcm.cpp | 38 ++++ arm9/source/dspadpcm.h | 35 ++++ arm9/source/musicsource.h | 27 +++ arm9/source/thememusic.cpp | 182 +++++-------------- arm9/source/thememusic.h | 10 +- arm9/source/wavsource.cpp | 162 +++++++++++++++++ arm9/source/wavsource.h | 42 +++++ 10 files changed, 759 insertions(+), 146 deletions(-) create mode 100644 arm9/source/bcstmsource.cpp create mode 100644 arm9/source/bcstmsource.h create mode 100644 arm9/source/dspadpcm.cpp create mode 100644 arm9/source/dspadpcm.h create mode 100644 arm9/source/musicsource.h create mode 100644 arm9/source/wavsource.cpp create mode 100644 arm9/source/wavsource.h diff --git a/Themes & Covers.md b/Themes & Covers.md index dd2d626d..53be3899 100644 --- a/Themes & Covers.md +++ b/Themes & Covers.md @@ -140,8 +140,10 @@ fade = 0 `x` and `y` position the image; omitting either axis centres the cover on that axis. `darken` dims the complete top screen behind a loaded cover (`0` normal, `100` black), and `fade` makes the cover transparent (`0` opaque, `100` invisible). Both effects default to `0`. Themes without a `[cover]` section never display covers. The **Interface settings → Game covers** option overrides whether covers are displayed or not for themes that support them. -## Theme music (`bgm.wav`) +## Theme music (`bgm.bcstm` / `bgm.wav`) -Place a `bgm.wav` in the theme directory, alongside its theme images (for example, `_nds/akmenunext/ui/blue skies/bgm.wav`). The music will play in the menu on a loop. **Interface settings → Theme music** can enable or disable playback. +Place a `bgm.bcstm` or `bgm.wav` in the theme directory, alongside its theme images (for example, `_nds/akmenunext/ui/blue skies/bgm.bcstm`). The music will play in the menu on a loop. **Interface settings → Theme music** can enable or disable playback. If both files exist, `bgm.bcstm` is used; if it cannot be played, `bgm.wav` is used instead. -The supported format is uncompressed RIFF/WAVE PCM, 16-bit Signed at 22,050 Hz mono. You can use tools such as Audacity & FFMPEG to export audio in this format. +`bgm.bcstm` is a 3DS music file: DSP-ADPCM, mono or stereo, up to 48,000 Hz. Loop points saved in the file are used, so the music can repeat past an intro instead of restarting from the beginning. Wii U `.bfstm` files are not supported. You can create these files with tools such as VGAudio or LoopingAudioConverter. The DS mixes its sound output at 32.768 kHz, so 32,000 Hz keeps the file small without losing quality. + +`bgm.wav` must be uncompressed RIFF/WAVE PCM, 16-bit signed, mono or stereo (22,050 Hz mono keeps files small). It always repeats from the beginning. You can use tools such as Audacity & FFMPEG to export audio in this format. diff --git a/arm9/source/bcstmsource.cpp b/arm9/source/bcstmsource.cpp new file mode 100644 index 00000000..6d6451ae --- /dev/null +++ b/arm9/source/bcstmsource.cpp @@ -0,0 +1,339 @@ +/* + bcstmsource.cpp + Copyright (C) 2026 Augusto Daniele + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +// BCSTM reader for DSP-ADPCM streams. Format references: 3dbrew / +// docs.mikage.app "BCSTM", GBATEK "3DS Files - Sound Wave Streams (CSTM +// Format)", vgmstream src/meta/bcstm.c. + +#include "bcstmsource.h" + +#include +#include + +namespace { +const u32 kMinFileBytes = 0x40; +const u32 kHeaderBytes = 0x14 + 8 * 12; // fixed header + up to 8 block references +const u32 kMaxBlockReferences = 8; +const u32 kMaxInfoBytes = 64 * 1024; +const u32 kMaxBlockBytes = 64 * 1024; +const u32 kStreamInfoBytes = 0x38; +const u32 kAdpcmInfoBytes = 0x2E; +const u32 kNoBlock = 0xFFFFFFFF; + +const u16 kByteOrderLittle = 0xFEFF; +const u16 kTypeInfoBlock = 0x4000; +const u16 kTypeDataBlock = 0x4002; +const u16 kTypeStreamInfo = 0x4100; +const u16 kTypeDspAdpcmInfo = 0x0300; +const u8 kCodecDspAdpcm = 2; + +u16 readLe16(const u8* p) { + return (u16)(p[0] | (p[1] << 8)); +} + +u32 readLe32(const u8* p) { + return (u32)p[0] | ((u32)p[1] << 8) | ((u32)p[2] << 16) | ((u32)p[3] << 24); +} + +// True when [offset, offset + length) lies inside `size` bytes. +bool fits(u64 offset, u64 length, u64 size) { + return offset <= size && length <= size - offset; +} +} // namespace + +cBcstmSource::cBcstmSource() + : _file(NULL), + _error(""), + _sampleRate(0), + _channels(0), + _fileChannels(0), + _loop(false), + _loopStart(0), + _loopEnd(0), + _totalSamples(0), + _blockCount(0), + _blockSize(0), + _samplesPerBlock(0), + _lastBlockPaddedSize(0), + _dataStart(0), + _loadedBlock(kNoBlock), + _pos(0), + _haveLoopHistory(false) { + _blockData[0] = NULL; + _blockData[1] = NULL; + memset(_state, 0, sizeof(_state)); + memset(_startHistory, 0, sizeof(_startHistory)); + memset(_loopHistory, 0, sizeof(_loopHistory)); +} + +cBcstmSource::~cBcstmSource() { + close(); +} + +bool cBcstmSource::fail(const char* reason) { + _error = reason; + return false; +} + +void cBcstmSource::close() { + if (_file) { + fclose(_file); + _file = NULL; + } + for (int c = 0; c < 2; c++) { + free(_blockData[c]); + _blockData[c] = NULL; + } + _sampleRate = 0; + _channels = 0; + _loadedBlock = kNoBlock; +} + +bool cBcstmSource::open(const char* path) { + close(); + _error = ""; + _file = fopen(path, "rb"); + if (!_file) return fail("bcstm: file not found"); + if (!parse()) { + close(); + return false; + } + return true; +} + +bool cBcstmSource::parse() { + if (fseek(_file, 0, SEEK_END) != 0) return fail("bcstm: seek failed"); + const long fileLength = ftell(_file); + if (fileLength < (long)kMinFileBytes) return fail("bcstm: file too small"); + const u32 fileSize = (u32)fileLength; + + u8 header[kHeaderBytes]; + const u32 headerRead = fileSize < kHeaderBytes ? fileSize : kHeaderBytes; + if (fseek(_file, 0, SEEK_SET) != 0 || fread(header, 1, headerRead, _file) != headerRead) { + return fail("bcstm: header read failed"); + } + if (memcmp(header, "CSTM", 4) != 0) return fail("bcstm: bad magic"); + if (readLe16(header + 0x04) != kByteOrderLittle) return fail("bcstm: not little-endian"); + + const u32 references = readLe16(header + 0x10); + if (references < 1 || references > kMaxBlockReferences || 0x14 + references * 12 > headerRead) + return fail("bcstm: bad block count"); + + bool haveInfo = false; + bool haveData = false; + u32 infoOffset = 0, infoSize = 0, dataOffset = 0, dataSize = 0; + for (u32 i = 0; i < references; i++) { + const u8* reference = header + 0x14 + i * 12; + const u16 type = readLe16(reference); + if (type != kTypeInfoBlock && type != kTypeDataBlock) continue; + const u32 offset = readLe32(reference + 4); + const u32 size = readLe32(reference + 8); + if (!fits(offset, size, fileSize)) return fail("bcstm: block outside file"); + if (type == kTypeInfoBlock) { + haveInfo = true; + infoOffset = offset; + infoSize = size; + } else { + haveData = true; + dataOffset = offset; + dataSize = size; + } + } + if (!haveInfo || !haveData) return fail("bcstm: missing INFO or DATA block"); + if (infoSize < 0x20 + kStreamInfoBytes || infoSize > kMaxInfoBytes) + return fail("bcstm: bad INFO size"); + + u8* info = (u8*)malloc(infoSize); + if (!info) return fail("bcstm: out of memory"); + bool ok = fseek(_file, (long)infoOffset, SEEK_SET) == 0 && + fread(info, 1, infoSize, _file) == infoSize; + if (!ok) + fail("bcstm: INFO read failed"); + else + ok = parseInfo(info, infoSize, dataOffset, dataSize); + free(info); + if (!ok) return false; + + for (u32 c = 0; c < _channels; c++) { + _blockData[c] = (u8*)malloc(_blockSize); + if (!_blockData[c]) return fail("bcstm: out of memory"); + } + rewind(); + return true; +} + +bool cBcstmSource::parseInfo(const u8* info, u32 size, u32 dataOffset, u32 dataSize) { + if (memcmp(info, "INFO", 4) != 0) return fail("bcstm: bad INFO magic"); + + // The references at INFO+0x08/0x10/0x18 are relative to INFO+0x08. + if (readLe16(info + 0x08) != kTypeStreamInfo) return fail("bcstm: missing stream info"); + const u64 streamOffset = 8 + (u64)readLe32(info + 0x0C); + if (!fits(streamOffset, kStreamInfoBytes, size)) return fail("bcstm: stream info outside INFO"); + const u8* stream = info + streamOffset; + + const u8 codec = stream[0x00]; + _loop = stream[0x01] != 0; + _fileChannels = stream[0x02]; + _sampleRate = readLe32(stream + 0x04); + _loopStart = readLe32(stream + 0x08); + _loopEnd = readLe32(stream + 0x0C); + _blockCount = readLe32(stream + 0x10); + _blockSize = readLe32(stream + 0x14); + _samplesPerBlock = readLe32(stream + 0x18); + const u32 lastBlockSamples = readLe32(stream + 0x20); + _lastBlockPaddedSize = readLe32(stream + 0x24); + const u32 sampleDataOffset = readLe32(stream + 0x34); + + if (codec != kCodecDspAdpcm) return fail("bcstm: codec not DSP-ADPCM"); + if (_fileChannels < 1) return fail("bcstm: no channels"); + _channels = _fileChannels > 2 ? 2 : _fileChannels; + if (_blockCount < 1) return fail("bcstm: no sample blocks"); + if (_samplesPerBlock == 0 || _samplesPerBlock % kDspAdpcmFrameSamples != 0) + return fail("bcstm: samples per block not a multiple of 14"); + if (_blockSize > kMaxBlockBytes || + _blockSize < _samplesPerBlock / kDspAdpcmFrameSamples * kDspAdpcmFrameBytes) { + return fail("bcstm: bad block size"); + } + if (lastBlockSamples < 1 || lastBlockSamples > _samplesPerBlock) + return fail("bcstm: bad last block sample count"); + const u32 lastBlockMinBytes = (lastBlockSamples + kDspAdpcmFrameSamples - 1) / + kDspAdpcmFrameSamples * kDspAdpcmFrameBytes; + if (_lastBlockPaddedSize < lastBlockMinBytes || _lastBlockPaddedSize > _blockSize) + return fail("bcstm: bad last block size"); + + const u64 totalSamples = (u64)(_blockCount - 1) * _samplesPerBlock + lastBlockSamples; + if (totalSamples > 0xFFFFFFFFu) return fail("bcstm: stream too long"); + _totalSamples = (u32)totalSamples; + if (_loop && !(_loopStart < _loopEnd && _loopEnd <= _totalSamples)) + return fail("bcstm: bad loop points"); + + const u64 sampleBytes = (u64)(_blockCount - 1) * _fileChannels * _blockSize + + (u64)_fileChannels * _lastBlockPaddedSize; + if (dataSize < 8 || !fits(sampleDataOffset, sampleBytes, dataSize - 8)) + return fail("bcstm: sample data outside DATA block"); + _dataStart = dataOffset + 8 + sampleDataOffset; + + // Channel table: u32 count, then references relative to the table start. + const u64 tableOffset = 8 + (u64)readLe32(info + 0x1C); + if (!fits(tableOffset, 4, size)) return fail("bcstm: channel table outside INFO"); + const u32 tableCount = readLe32(info + tableOffset); + if (tableCount < _fileChannels || !fits(tableOffset + 4, (u64)tableCount * 8, size)) + return fail("bcstm: bad channel table"); + + for (u32 c = 0; c < _channels; c++) { + const u64 channelOffset = tableOffset + readLe32(info + tableOffset + 4 + c * 8 + 4); + if (!fits(channelOffset, 8, size)) return fail("bcstm: channel info outside INFO"); + // Channel info starts with a reference relative to itself. + if (readLe16(info + channelOffset) != kTypeDspAdpcmInfo) + return fail("bcstm: channel is not DSP-ADPCM"); + const u64 adpcmOffset = channelOffset + readLe32(info + channelOffset + 4); + if (!fits(adpcmOffset, kAdpcmInfoBytes, size)) + return fail("bcstm: ADPCM info outside INFO"); + const u8* adpcm = info + adpcmOffset; + + // Clamped so the decoder can work in 32-bit arithmetic; real files are far + // below the limit, so this only bounds corrupt data. + for (int k = 0; k < 16; k++) { + s32 coef = (s16)readLe16(adpcm + k * 2); + if (coef > kDspAdpcmMaxCoef) coef = kDspAdpcmMaxCoef; + if (coef < -kDspAdpcmMaxCoef) coef = -kDspAdpcmMaxCoef; + _state[c].coefs[k] = (s16)coef; + } + // Start context: 0x20 predictor/scale, 0x22 yn1, 0x24 yn2. The loop + // context at 0x26 is not used; history is captured at loopStart. + _startHistory[c][0] = (s16)readLe16(adpcm + 0x22); + _startHistory[c][1] = (s16)readLe16(adpcm + 0x24); + } + return true; +} + +void cBcstmSource::setHistory(s16 history[2][2]) { + for (int c = 0; c < 2; c++) { + _state[c].hist1 = history[c][0]; + _state[c].hist2 = history[c][1]; + } +} + +void cBcstmSource::captureHistory(s16 history[2][2]) const { + for (int c = 0; c < 2; c++) { + history[c][0] = _state[c].hist1; + history[c][1] = _state[c].hist2; + } +} + +void cBcstmSource::rewind() { + _pos = 0; + _loadedBlock = kNoBlock; + _haveLoopHistory = false; + setHistory(_startHistory); +} + +bool cBcstmSource::loadBlock(u32 block) { + _loadedBlock = kNoBlock; + // A block stores channel 0, channel 1, ... back to back. The last block's + // per-channel size is lastBlockPaddedSize, so channels are read in sequence. + const u64 offset = _dataStart + (u64)block * _fileChannels * _blockSize; + const u32 bytes = block == _blockCount - 1 ? _lastBlockPaddedSize : _blockSize; + if (fseek(_file, (long)offset, SEEK_SET) != 0) return fail("bcstm: seek failed"); + for (u32 c = 0; c < _channels; c++) { + if (fread(_blockData[c], 1, bytes, _file) != bytes) + return fail("bcstm: sample read failed"); + } + _loadedBlock = block; + return true; +} + +bool cBcstmSource::read(s16* dst, u32 frames) { + if (!_file) return fail("bcstm: not open"); + s16 decoded[2][kDspAdpcmFrameSamples]; + + while (frames) { + const u32 end = _loop ? _loopEnd : _totalSamples; + if (_pos == end) { + if (_loop) { + _pos = _loopStart; + setHistory(_loopHistory); + } else { + _pos = 0; + setHistory(_startHistory); + } + } + // Playback always reaches loopStart before loopEnd, so the history + // needed to resume at loopStart is recorded before the first wrap. + if (_loop && _pos == _loopStart && !_haveLoopHistory) { + captureHistory(_loopHistory); + _haveLoopHistory = true; + } + + const u32 block = _pos / _samplesPerBlock; + if (block != _loadedBlock && !loadBlock(block)) return false; + + const u32 inBlock = _pos - block * _samplesPerBlock; + const u32 first = inBlock % kDspAdpcmFrameSamples; + u32 count = kDspAdpcmFrameSamples - first; + if (count > frames) count = frames; + if (count > end - _pos) count = end - _pos; + if (_loop && _pos < _loopStart && count > _loopStart - _pos) count = _loopStart - _pos; + + const u32 frameOffset = inBlock / kDspAdpcmFrameSamples * kDspAdpcmFrameBytes; + for (u32 c = 0; c < _channels; c++) + dspAdpcmDecode(_blockData[c] + frameOffset, _state[c], decoded[c], first, count); + + if (_channels == 1) { + memcpy(dst, decoded[0], count * sizeof(s16)); + dst += count; + } else { + for (u32 i = 0; i < count; i++) { + *dst++ = decoded[0][i]; + *dst++ = decoded[1][i]; + } + } + _pos += count; + frames -= count; + } + return true; +} diff --git a/arm9/source/bcstmsource.h b/arm9/source/bcstmsource.h new file mode 100644 index 00000000..fd435ccc --- /dev/null +++ b/arm9/source/bcstmsource.h @@ -0,0 +1,62 @@ +/* + bcstmsource.h + Copyright (C) 2026 Augusto Daniele + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#pragma once + +#include +#include + +#include "dspadpcm.h" +#include "musicsource.h" + +// Plays a DSP-ADPCM BCSTM (3DS stream) with its loop points. +class cBcstmSource : public cMusicSource { + public: + cBcstmSource(); + ~cBcstmSource(); + + bool open(const char* path); + u32 sampleRate() const { return _sampleRate; } + u32 channels() const { return _channels; } + bool read(s16* dst, u32 frames); + const char* error() const { return _error; } + + private: + cBcstmSource(const cBcstmSource&); + cBcstmSource& operator=(const cBcstmSource&); + + bool fail(const char* reason); + bool parse(); + bool parseInfo(const u8* info, u32 size, u32 dataOffset, u32 dataSize); + bool loadBlock(u32 block); + void rewind(); + void setHistory(s16 history[2][2]); + void captureHistory(s16 history[2][2]) const; + void close(); + + FILE* _file; + const char* _error; + u32 _sampleRate; + u32 _channels; // decoded channels, 1 or 2 + u32 _fileChannels; // channels stored in the file + bool _loop; + u32 _loopStart; + u32 _loopEnd; // exclusive + u32 _totalSamples; + u32 _blockCount; + u32 _blockSize; + u32 _samplesPerBlock; + u32 _lastBlockPaddedSize; + u32 _dataStart; + u8* _blockData[2]; + u32 _loadedBlock; + u32 _pos; + sDspAdpcmState _state[2]; + s16 _startHistory[2][2]; // [channel][0] = hist1, [channel][1] = hist2 + s16 _loopHistory[2][2]; + bool _haveLoopHistory; +}; diff --git a/arm9/source/dspadpcm.cpp b/arm9/source/dspadpcm.cpp new file mode 100644 index 00000000..62768f5b --- /dev/null +++ b/arm9/source/dspadpcm.cpp @@ -0,0 +1,38 @@ +/* + dspadpcm.cpp + Copyright (C) 2026 Augusto Daniele + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#include "dspadpcm.h" + +void dspAdpcmDecode(const u8* frame, sDspAdpcmState& state, s16* out, u32 first, u32 count) { + u32 pair = frame[0] >> 4; + if (pair > 7) pair = 7; // corrupt header: stay inside the coefficient table + // 32-bit arithmetic is enough: the nibble term is at most (8 << 15) * 2048 and + // each coefficient term at most kDspAdpcmMaxCoef * 32768, so the sum stays + // below 2^31. Callers must keep coefficients within that limit. + const s32 scale = (s32)1 << (frame[0] & 0x0F); + const s32 coef1 = state.coefs[pair * 2]; + const s32 coef2 = state.coefs[pair * 2 + 1]; + s32 hist1 = state.hist1; + s32 hist2 = state.hist2; + + for (u32 i = first; i < first + count; i++) { + const u8 packed = frame[1 + i / 2]; + s32 nibble = (i & 1) ? (packed & 0x0F) : (packed >> 4); + if (nibble >= 8) nibble -= 16; + + s32 sample = (nibble * scale * 2048 + 1024 + coef1 * hist1 + coef2 * hist2) >> 11; + if (sample > 32767) sample = 32767; + if (sample < -32768) sample = -32768; + + *out++ = (s16)sample; + hist2 = hist1; + hist1 = sample; + } + + state.hist1 = (s16)hist1; + state.hist2 = (s16)hist2; +} diff --git a/arm9/source/dspadpcm.h b/arm9/source/dspadpcm.h new file mode 100644 index 00000000..c90b57eb --- /dev/null +++ b/arm9/source/dspadpcm.h @@ -0,0 +1,35 @@ +/* + dspadpcm.h + Copyright (C) 2026 Augusto Daniele + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#pragma once + +#include + +// DSP-ADPCM decoder (used by GameCube, Wii and 3DS audio). +// A frame is 8 bytes: a header byte (high nibble = coefficient pair index, +// low nibble = scale exponent) followed by 14 signed 4-bit samples, high +// nibble first. References: vgmstream src/coding/ngc_dsp_decoder.c, +// 3dbrew "BCSTM" (DSP ADPCM info). + +const u32 kDspAdpcmFrameBytes = 8; +const u32 kDspAdpcmFrameSamples = 14; + +// Coefficient magnitude limit. Real files stay far below it (the largest seen in +// 3DS theme music is about 4000), and it keeps the decoder's arithmetic inside +// 32 bits: 2 * 16384 * 32768 + (8 << 15) * 2048 + 1024 < 2^31. +const s16 kDspAdpcmMaxCoef = 16384; + +struct sDspAdpcmState { + s16 coefs[16]; // 8 pairs of 5.11 coefficients, each within kDspAdpcmMaxCoef + s16 hist1; // previous decoded sample + s16 hist2; // sample before hist1 +}; + +// Decodes samples [first, first + count) of one frame into out[0 .. count). +// Requires first + count <= 14. The history in `state` must be the history +// just before sample `first`; it is updated to the last decoded samples. +void dspAdpcmDecode(const u8* frame, sDspAdpcmState& state, s16* out, u32 first, u32 count); diff --git a/arm9/source/musicsource.h b/arm9/source/musicsource.h new file mode 100644 index 00000000..d2179f30 --- /dev/null +++ b/arm9/source/musicsource.h @@ -0,0 +1,27 @@ +/* + musicsource.h + Copyright (C) 2026 Augusto Daniele + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#pragma once + +#include + +// An endlessly looping source of 16-bit PCM for theme music. +class cMusicSource { + public: + virtual ~cMusicSource() {} + + // Opens and validates `path`. On failure returns false; error() says why. + virtual bool open(const char* path) = 0; + virtual u32 sampleRate() const = 0; + // 1 (mono) or 2 (stereo). + virtual u32 channels() const = 0; + // Writes `frames` frames of interleaved native-endian 16-bit samples to + // `dst`, wrapping at the loop end. Returns false on an I/O or data error. + virtual bool read(s16* dst, u32 frames) = 0; + // Short static description of the last failure, or "". + virtual const char* error() const = 0; +}; diff --git a/arm9/source/thememusic.cpp b/arm9/source/thememusic.cpp index 43512baf..2cb96ac2 100644 --- a/arm9/source/thememusic.cpp +++ b/arm9/source/thememusic.cpp @@ -12,50 +12,26 @@ #include #include +#include "bcstmsource.h" #include "dbgtool.h" #include "globalsettings.h" #include "irqs.h" #include "systemfilenames.h" #include "fifotool.h" +#include "wavsource.h" namespace { const u32 kRingBytes = 512 * 1024; const u32 kStartupPrimeBytes = 128 * 1024; const u32 kReadChunkBytes = 16 * 1024; const u32 kMaxmodBufferFrames = 16384; +const u32 kMinSampleRate = 1024; +const u32 kMaxSampleRate = 48000; // DS mixes output at 32.768 kHz; higher rates gain nothing cThemeMusic gThemeMusic; cThemeMusic* volatile gActiveThemeMusic = NULL; mm_stream gStream; -u32 readLe16(FILE* file, bool& ok) { - int lo = fgetc(file); - int hi = fgetc(file); - if (lo == EOF || hi == EOF) { - ok = false; - return 0; - } - return (u32)lo | ((u32)hi << 8); -} - -u32 readLe32(FILE* file, bool& ok) { - int b0 = fgetc(file); - int b1 = fgetc(file); - int b2 = fgetc(file); - int b3 = fgetc(file); - if (b0 == EOF || b1 == EOF || b2 == EOF || b3 == EOF) { - ok = false; - return 0; - } - return (u32)b0 | ((u32)b1 << 8) | ((u32)b2 << 16) | ((u32)b3 << 24); -} - -bool readFourCC(FILE* file, const char* expected) { - char id[4]; - return fread(id, 1, sizeof(id), file) == sizeof(id) && - memcmp(id, expected, sizeof(id)) == 0; -} - mm_word maxmodStreamCallback(mm_word length, mm_addr destination, mm_stream_formats format) { if (gActiveThemeMusic) return gActiveThemeMusic->fillStream(length, destination); u32 frameBytes = (format & 2) ? 2 : 1; @@ -67,107 +43,39 @@ mm_word maxmodStreamCallback(mm_word length, mm_addr destination, mm_stream_form void compilerMemoryBarrier() { __asm__ volatile("" ::: "memory"); } + +// Returns `source` if it opens with a playable sample rate; deletes it otherwise. +cMusicSource* openSource(cMusicSource* source, const std::string& path) { + if (source->open(path.c_str())) { + const u32 rate = source->sampleRate(); + if (rate >= kMinSampleRate && rate <= kMaxSampleRate) return source; + dbg_printf("Theme music ignored: '%s' sample rate %lu Hz unsupported\n", path.c_str(), + (unsigned long)rate); + } else { + dbg_printf("Theme music: '%s' not used (%s)\n", path.c_str(), source->error()); + } + delete source; + return NULL; +} } // namespace cThemeMusic::cThemeMusic() - : _file(NULL), + : _source(NULL), _ring(NULL), _readTotal(0), _writeTotal(0), - _dataStart(0), - _dataLength(0), - _fileDataOffset(0), _frameBytes(0), _sampleRate(0), _format(MM_STREAM_16BIT_MONO), _maxmodInitialized(false), _streamOpen(false), + _sourceFailed(false), _playing(false) {} cThemeMusic& themeMusic() { return gThemeMusic; } -bool cThemeMusic::parseWave() { - if (!_file || fseek(_file, 0, SEEK_END) != 0) return false; - long fileLength = ftell(_file); - if (fileLength < 12 || fseek(_file, 0, SEEK_SET) != 0) return false; - - bool ok = true; - if (!readFourCC(_file, "RIFF")) return false; - u32 riffLength = readLe32(_file, ok); - if (!ok || riffLength > (u32)(fileLength - 8) || !readFourCC(_file, "WAVE")) return false; - long riffEnd = 8 + (long)riffLength; - - bool haveFormat = false; - bool haveData = false; - u32 channels = 0; - u32 bitsPerSample = 0; - u32 blockAlign = 0; - u32 byteRate = 0; - - while (ftell(_file) >= 0 && ftell(_file) + 8 <= riffEnd) { - char chunkId[4]; - if (fread(chunkId, 1, sizeof(chunkId), _file) != sizeof(chunkId)) return false; - u32 chunkLength = readLe32(_file, ok); - if (!ok) return false; - long chunkStart = ftell(_file); - if (chunkStart < 0 || chunkLength > (u32)(riffEnd - chunkStart)) return false; - - if (memcmp(chunkId, "fmt ", 4) == 0) { - if (chunkLength < 16) return false; - u32 encoding = readLe16(_file, ok); - channels = readLe16(_file, ok); - _sampleRate = readLe32(_file, ok); - byteRate = readLe32(_file, ok); - blockAlign = readLe16(_file, ok); - bitsPerSample = readLe16(_file, ok); - if (!ok || encoding != 1) return false; - haveFormat = true; - } else if (memcmp(chunkId, "data", 4) == 0) { - _dataStart = (u32)chunkStart; - _dataLength = chunkLength; - haveData = true; - } - - long nextChunk = chunkStart + (long)chunkLength + (chunkLength & 1); - if (nextChunk > riffEnd || fseek(_file, nextChunk, SEEK_SET) != 0) return false; - } - - if (!haveFormat || !haveData || channels < 1 || channels > 2 || bitsPerSample != 16 || - _sampleRate < 1024 || _sampleRate > 32768) { - return false; - } - - _frameBytes = channels * (bitsPerSample / 8); - if (!blockAlign || blockAlign != _frameBytes || byteRate != _sampleRate * _frameBytes || - !_dataLength || _dataLength % _frameBytes) { - return false; - } - - _format = channels == 1 ? MM_STREAM_16BIT_MONO : MM_STREAM_16BIT_STEREO; - return fseek(_file, (long)_dataStart, SEEK_SET) == 0; -} - -bool cThemeMusic::readLooping(u8* destination, u32 length) { - u32 written = 0; - while (written < length) { - if (_fileDataOffset >= _dataLength) { - if (fseek(_file, (long)_dataStart, SEEK_SET) != 0) return false; - _fileDataOffset = 0; - } - - u32 available = _dataLength - _fileDataOffset; - u32 request = length - written; - if (request > available) request = available; - size_t got = fread(destination + written, 1, request, _file); - if (got != request) return false; - written += request; - _fileDataOffset += request; - } - return true; -} - bool cThemeMusic::queueBytes(u32 length) { u32 written = _writeTotal; u32 read = _readTotal; @@ -182,7 +90,12 @@ bool cThemeMusic::queueBytes(u32 length) { u32 contiguous = kRingBytes - offset; u32 segment = length < contiguous ? length : contiguous; segment -= segment % _frameBytes; - if (!segment || !readLooping(_ring + offset, segment)) return false; + if (!segment) return false; + if (!_source->read((s16*)(_ring + offset), segment / _frameBytes)) { + _sourceFailed = true; + dbg_printf("Theme music stopped refilling: %s\n", _source->error()); + return false; + } compilerMemoryBarrier(); written += segment; @@ -195,35 +108,32 @@ bool cThemeMusic::queueBytes(u32 length) { bool cThemeMusic::start() { if (!gs().playThemeMusic || _playing) return _playing; - std::string path = SFN_UI_CURRENT_DIRECTORY + "bgm.wav"; - FILE* file = fopen(path.c_str(), "rb"); - if (!file) return false; + const std::string directory = SFN_UI_CURRENT_DIRECTORY; + _source = openSource(new cBcstmSource(), directory + "bgm.bcstm"); + if (!_source) _source = openSource(new cWavSource(), directory + "bgm.wav"); + if (!_source) return false; - _file = file; - if (!parseWave()) { - fclose(_file); - _file = NULL; - dbg_printf("Theme music ignored: unsupported or invalid WAV '%s'\n", path.c_str()); - return false; - } + _sampleRate = _source->sampleRate(); + _frameBytes = _source->channels() * 2; + _format = _source->channels() == 1 ? MM_STREAM_16BIT_MONO : MM_STREAM_16BIT_STEREO; + _sourceFailed = false; _ring = (u8*)malloc(kRingBytes); if (!_ring) { - fclose(_file); - _file = NULL; - dbg_printf("Theme music ignored: not enough memory for WAV buffer\nYou obviously did not read the documentation!"); + delete _source; + _source = NULL; + dbg_printf("Theme music ignored: not enough memory for music buffer\nYou obviously did not read the documentation!"); return false; } _readTotal = 0; _writeTotal = 0; - _fileDataOffset = 0; if (!queueBytes(kStartupPrimeBytes)) { free(_ring); _ring = NULL; - fclose(_file); - _file = NULL; - dbg_printf("Theme music ignored: unable to read WAV data\n"); + delete _source; + _source = NULL; + dbg_printf("Theme music ignored: unable to read music data\n"); return false; } @@ -234,8 +144,8 @@ bool cThemeMusic::start() { if (fifoGetValue32(FIFO_USER_01) != 1) { free(_ring); _ring = NULL; - fclose(_file); - _file = NULL; + delete _source; + _source = NULL; return false; } @@ -277,10 +187,8 @@ void cThemeMusic::stop() { _streamOpen = false; } if (gActiveThemeMusic == this) gActiveThemeMusic = NULL; - if (_file) { - fclose(_file); - _file = NULL; - } + delete _source; + _source = NULL; if (_ring) { free(_ring); _ring = NULL; @@ -290,7 +198,7 @@ void cThemeMusic::stop() { } void cThemeMusic::update() { - if (!_playing || !_ring) return; + if (!_playing || !_ring || _sourceFailed) return; u32 used = _writeTotal - _readTotal; if (used >= kRingBytes) return; queueBytes(kReadChunkBytes); diff --git a/arm9/source/thememusic.h b/arm9/source/thememusic.h index 24c08ea7..2578733a 100644 --- a/arm9/source/thememusic.h +++ b/arm9/source/thememusic.h @@ -10,6 +10,8 @@ #include #include +class cMusicSource; + class cThemeMusic { public: cThemeMusic(); @@ -21,22 +23,18 @@ class cThemeMusic { u32 fillStream(u32 length, void* destination); private: - bool parseWave(); - bool readLooping(u8* destination, u32 length); bool queueBytes(u32 length); - FILE* _file; + cMusicSource* _source; u8* _ring; volatile u32 _readTotal; volatile u32 _writeTotal; - u32 _dataStart; - u32 _dataLength; - u32 _fileDataOffset; u32 _frameBytes; u32 _sampleRate; u32 _format; bool _maxmodInitialized; bool _streamOpen; + bool _sourceFailed; volatile bool _playing; }; diff --git a/arm9/source/wavsource.cpp b/arm9/source/wavsource.cpp new file mode 100644 index 00000000..e546af65 --- /dev/null +++ b/arm9/source/wavsource.cpp @@ -0,0 +1,162 @@ +/* + wavsource.cpp + Copyright (C) 2026 coderkei + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#include "wavsource.h" + +#include + +namespace { +u32 readLe16(FILE* file, bool& ok) { + int lo = fgetc(file); + int hi = fgetc(file); + if (lo == EOF || hi == EOF) { + ok = false; + return 0; + } + return (u32)lo | ((u32)hi << 8); +} + +u32 readLe32(FILE* file, bool& ok) { + int b0 = fgetc(file); + int b1 = fgetc(file); + int b2 = fgetc(file); + int b3 = fgetc(file); + if (b0 == EOF || b1 == EOF || b2 == EOF || b3 == EOF) { + ok = false; + return 0; + } + return (u32)b0 | ((u32)b1 << 8) | ((u32)b2 << 16) | ((u32)b3 << 24); +} + +bool readFourCC(FILE* file, const char* expected) { + char id[4]; + return fread(id, 1, sizeof(id), file) == sizeof(id) && memcmp(id, expected, sizeof(id)) == 0; +} +} // namespace + +cWavSource::cWavSource() + : _file(NULL), + _error(""), + _sampleRate(0), + _channels(0), + _dataStart(0), + _dataLength(0), + _dataOffset(0) {} + +cWavSource::~cWavSource() { + close(); +} + +bool cWavSource::fail(const char* reason) { + _error = reason; + return false; +} + +void cWavSource::close() { + if (_file) { + fclose(_file); + _file = NULL; + } + _sampleRate = 0; + _channels = 0; +} + +bool cWavSource::open(const char* path) { + close(); + _error = ""; + _file = fopen(path, "rb"); + if (!_file) return fail("wav: file not found"); + if (!parse()) { + close(); + return false; + } + return true; +} + +bool cWavSource::parse() { + if (fseek(_file, 0, SEEK_END) != 0) return fail("wav: seek failed"); + const long fileLength = ftell(_file); + if (fileLength < 12 || fseek(_file, 0, SEEK_SET) != 0) return fail("wav: file too small"); + + bool ok = true; + if (!readFourCC(_file, "RIFF")) return fail("wav: bad RIFF header"); + const u32 riffLength = readLe32(_file, ok); + if (!ok || riffLength > (u32)(fileLength - 8) || !readFourCC(_file, "WAVE")) + return fail("wav: bad RIFF header"); + const long riffEnd = 8 + (long)riffLength; + + bool haveFormat = false; + bool haveData = false; + u32 bitsPerSample = 0; + u32 blockAlign = 0; + u32 byteRate = 0; + + while (ftell(_file) >= 0 && ftell(_file) + 8 <= riffEnd) { + char chunkId[4]; + if (fread(chunkId, 1, sizeof(chunkId), _file) != sizeof(chunkId)) + return fail("wav: chunk read failed"); + const u32 chunkLength = readLe32(_file, ok); + if (!ok) return fail("wav: chunk read failed"); + const long chunkStart = ftell(_file); + if (chunkStart < 0 || chunkLength > (u32)(riffEnd - chunkStart)) + return fail("wav: chunk outside file"); + + if (memcmp(chunkId, "fmt ", 4) == 0) { + if (chunkLength < 16) return fail("wav: bad fmt chunk"); + const u32 encoding = readLe16(_file, ok); + _channels = readLe16(_file, ok); + _sampleRate = readLe32(_file, ok); + byteRate = readLe32(_file, ok); + blockAlign = readLe16(_file, ok); + bitsPerSample = readLe16(_file, ok); + if (!ok || encoding != 1) return fail("wav: not PCM"); + haveFormat = true; + } else if (memcmp(chunkId, "data", 4) == 0) { + _dataStart = (u32)chunkStart; + _dataLength = chunkLength; + haveData = true; + } + + const long nextChunk = chunkStart + (long)chunkLength + (chunkLength & 1); + if (nextChunk > riffEnd || fseek(_file, nextChunk, SEEK_SET) != 0) + return fail("wav: chunk outside file"); + } + + if (!haveFormat || !haveData) return fail("wav: missing fmt or data chunk"); + if (_channels < 1 || _channels > 2 || bitsPerSample != 16) + return fail("wav: only 16-bit mono or stereo supported"); + const u32 frameBytes = _channels * 2; + if (blockAlign != frameBytes || byteRate != _sampleRate * frameBytes || _dataLength == 0 || + _dataLength % frameBytes != 0) { + return fail("wav: inconsistent format fields"); + } + + _dataOffset = 0; + if (fseek(_file, (long)_dataStart, SEEK_SET) != 0) return fail("wav: seek failed"); + return true; +} + +// WAV data is little-endian, like the DS and the host test machine, so bytes +// are copied straight into the sample buffer. +bool cWavSource::read(s16* dst, u32 frames) { + if (!_file) return fail("wav: not open"); + u8* out = (u8*)dst; + u32 remaining = frames * _channels * 2; + while (remaining) { + if (_dataOffset >= _dataLength) { + if (fseek(_file, (long)_dataStart, SEEK_SET) != 0) return fail("wav: seek failed"); + _dataOffset = 0; + } + u32 request = _dataLength - _dataOffset; + if (request > remaining) request = remaining; + if (fread(out, 1, request, _file) != request) return fail("wav: sample read failed"); + out += request; + remaining -= request; + _dataOffset += request; + } + return true; +} diff --git a/arm9/source/wavsource.h b/arm9/source/wavsource.h new file mode 100644 index 00000000..f290b40e --- /dev/null +++ b/arm9/source/wavsource.h @@ -0,0 +1,42 @@ +/* + wavsource.h + Copyright (C) 2026 coderkei + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#pragma once + +#include +#include + +#include "musicsource.h" + +// Plays a 16-bit PCM RIFF/WAVE file (mono or stereo), repeating the whole file. +class cWavSource : public cMusicSource { + public: + cWavSource(); + ~cWavSource(); + + bool open(const char* path); + u32 sampleRate() const { return _sampleRate; } + u32 channels() const { return _channels; } + bool read(s16* dst, u32 frames); + const char* error() const { return _error; } + + private: + cWavSource(const cWavSource&); + cWavSource& operator=(const cWavSource&); + + bool fail(const char* reason); + bool parse(); + void close(); + + FILE* _file; + const char* _error; + u32 _sampleRate; + u32 _channels; + u32 _dataStart; + u32 _dataLength; + u32 _dataOffset; +};