From eab95c9242999813c9689326dcc252be522d553d Mon Sep 17 00:00:00 2001 From: JakubTheDeveloper Date: Wed, 9 Sep 2026 14:39:38 +0200 Subject: [PATCH 1/2] sample validation --- README.md | 2 + docs/documentation.md | 14 ++- docs/manual.md | 5 +- include/audio.h | 4 + include/boot_screen_flow.h | 3 +- include/display_ssd1309.h | 2 + include/sample_classifier.h | 4 + include/sample_library.h | 10 +- include/sampler_runtime.h | 2 + include/stream_manager.h | 4 +- include/ui.h | 7 +- include/validated_wav_source.h | 20 ++++ include/wav_validation.h | 22 ++++ src/audio.cpp | 2 +- src/boot_screen_flow.cpp | 4 +- src/display_ssd1309.cpp | 17 ++- src/sample_classifier.cpp | 148 ++++-------------------- src/sample_library.cpp | 32 ++++- src/sample_ram_manager.cpp | 59 +--------- src/sampler_app.cpp | 10 +- src/sampler_playback_router.cpp | 3 +- src/sampler_runtime.cpp | 3 +- src/stream_manager.cpp | 21 +++- src/ui.cpp | 14 ++- src/validated_wav_source.cpp | 49 ++++++++ src/wav_validation.cpp | 73 ++++++++++++ test/support/Arduino.h | 7 ++ test/support/SD.h | 49 ++++++++ test/test_playback_router/test_main.cpp | 30 +++++ test/test_ui/test_main.cpp | 22 ++++ test/test_wav_validation/test_main.cpp | 124 ++++++++++++++++++++ todo.md | 6 +- 32 files changed, 560 insertions(+), 212 deletions(-) create mode 100644 include/validated_wav_source.h create mode 100644 include/wav_validation.h create mode 100644 src/validated_wav_source.cpp create mode 100644 src/wav_validation.cpp create mode 100644 test/support/SD.h create mode 100644 test/test_wav_validation/test_main.cpp diff --git a/README.md b/README.md index b8de9ec..20dba27 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ Take tip (left) or ring (right), plus sleeve (headphone ground), from the headph 4. Open `LIB`, preview a sample, then hold the right button and send a MIDI note or press a keypad key to assign it. 5. Trigger the sound, set `VOL` and `SHOT/LOOP`, then select `SAVE` before powering off. +During boot, every loaded library entry (up to 32, including unassigned samples) is checked for the supported WAV format and valid file structure. The display shows progress and the rejected count. Rejected entries remain visible in `LIB` with `!` and a reason; they cannot play. Results are cached in RAM: restart after changing files on the SD card. + No configuration file is required for first boot; without one, the device starts with no assignments, one-shot playback, and the default RAM budget. The repository includes a batch conversion command, requiring `ffmpeg` and Make: diff --git a/docs/documentation.md b/docs/documentation.md index d8c8e85..f39777f 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -117,9 +117,19 @@ Hardware verification (2026-09-09): `CHIPPOWER = 0xAA` introduced output noise o - `.wav` and `.WAV` are recognized, - file list is sorted alphabetically, - UI sample limit: `32` (the first 32 matching files encountered are collected, then sorted). -- Assigned playback requires uncompressed PCM (`audioFormat = 1`), 16-bit, 44100 Hz, mono. Prepare every library sample in this format, including previews. +- All playback paths require uncompressed PCM (`audioFormat = 1`), 16-bit, 44100 Hz, mono, including previews and stream fallback. - Use a FAT32 card. The conversion command in section 9 modifies files in place, including leading-silence trimming and gain adjustment. +### Boot-time WAV validation + +Before playback tasks start, `SampleLibrary::loadFromSd()` validates every collected entry, including unassigned files. The boot screen displays `Checking WAV: n/N` and `Rejected`. Invalid entries stay in the library with `!` and a status (`BAD FORMAT`, `BAD WAV`, or `READ ERROR`); the playback router blocks them before enqueueing preview or MIDI triggers. Unchecked entries are also blocked. Valid entries continue to work. + +`wav_validation.cpp` checks RIFF/WAVE identification, exact RIFF/file size agreement, chunk boundaries and odd-byte padding, one `fmt ` chunk before one nonempty `data` chunk, supported PCM parameters, byte rate, block alignment, and whole PCM16 frames. Extended `fmt ` chunks must have a consistent extension length. Unknown chunks are skipped, including metadata after PCM; duplicate or truncated chunks are rejected. Files must fit the signed 32-bit playback seek range. Chunk padding follows the [RIFF specification](https://learn.microsoft.com/en-us/windows/win32/xaudio2/resource-interchange-file-format--riff-). + +Validation reads headers and seeks past PCM and metadata payloads; it does not scan audio content, detect clicks/clipping, or guarantee that every PCM sector is readable. Each catalog entry caches status, PCM offset, and length in RAM. Assignment classification and saving reuse this cache. RAM preload reads the cached PCM range directly. SD streaming uses `ValidatedWavSource` to supply a canonical 44-byte header from memory and expose only the cached PCM range, avoiding on-disk header parsing at each trigger or loop restart. Opening the file, checking that the cached range still fits, and reading PCM still involve SD access during playback. + +Restart after changing files on the SD card. There is no runtime revalidation or hot-swap support; cache validity assumes files remain unchanged for the session. Validation covers the loaded library's existing 32-entry limit, not additional files outside that list. + ## 5. `sampler_config.json` Configuration Location and parser: `src/settings_store.cpp`. @@ -163,7 +173,7 @@ Saving writes and parses the temporary JSON file before rotating the previous co Sample preparation pipeline: -- MIDI-assigned samples are classified as `RAM` or `STREAM`, +- MIDI-assigned samples are classified from cached boot validation as `RAM` or `STREAM`, - `RAM` is used only for WAV files that meet all conditions: - PCM format (`audioFormat = 1`), - `16-bit`, diff --git a/docs/manual.md b/docs/manual.md index 33c5626..20cb5ff 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -6,7 +6,7 @@ Connect the isolated mono output jack to your mixer and start at a low monitorin ## 1. Power On and Wait for Ready -When you boot the device, you will first see loading status. +When you boot the device, `Checking WAV: n/N` shows validation progress for the loaded library (up to 32 samples). `Rejected` counts files that cannot play. Validation runs once at startup, before playback starts; valid samples remain available even if other files are rejected. The screenshots below show an earlier screen layout. ![Boot loading screen](screenshots/boot.jpg) @@ -36,6 +36,9 @@ In `LIB`: - Right rotate: browse samples. - Right click: play preview. +- Entries marked `!` cannot play: `BAD FORMAT` means unsupported audio settings, `BAD WAV` means invalid file structure, and `READ ERROR` means the file could not be read. Convert or replace the file using PCM16, 44.1 kHz, mono, then restart the device. + +Results remain in memory until power-off. Restart after changing SD files; saving assignments does not refresh validation. - Left click: go back. ## 4. Assign a Sample to a MIDI Note diff --git a/include/audio.h b/include/audio.h index 80dbd6b..4a20282 100644 --- a/include/audio.h +++ b/include/audio.h @@ -3,6 +3,8 @@ #include #include +namespace SampleLibrary { struct Catalog; } + class Audio { public: static constexpr uint8_t kVoiceCount = 32; @@ -24,6 +26,7 @@ class Audio { Audio(); ~Audio(); + void setSampleCatalog(const SampleLibrary::Catalog *catalog) { catalog_ = catalog; } bool begin(); void update(); void playSamplePath(const String &samplePath, @@ -47,5 +50,6 @@ class Audio { bool waveformSnapshot(WaveformSnapshot &snapshot) const; private: + const SampleLibrary::Catalog *catalog_ = nullptr; Impl *impl_ = nullptr; }; diff --git a/include/boot_screen_flow.h b/include/boot_screen_flow.h index eb74d1b..41ec304 100644 --- a/include/boot_screen_flow.h +++ b/include/boot_screen_flow.h @@ -11,7 +11,8 @@ class BootScreenFlow { static constexpr unsigned long kDefaultDismissTimeoutMs = 5000; void begin(DisplaySsd1309 *display, Input *input, Ui *ui); - void render(bool loading, int totalSamples, int assignedSamples, int ramUsagePercent); + void render(bool loading, int totalSamples, int assignedSamples, int ramUsagePercent, + int checkedSamples = 0, int rejectedSamples = 0); void waitForDismissOrTimeout(unsigned long timeoutMs = kDefaultDismissTimeoutMs); private: diff --git a/include/display_ssd1309.h b/include/display_ssd1309.h index c6a9d15..7320c40 100644 --- a/include/display_ssd1309.h +++ b/include/display_ssd1309.h @@ -10,6 +10,8 @@ class DisplaySsd1309 { struct BootScreenModel { int totalSamples = 0; int assignedSamples = 0; + int checkedSamples = 0; + int rejectedSamples = 0; int ramUsagePercent = 0; bool loading = true; }; diff --git a/include/sample_classifier.h b/include/sample_classifier.h index 180c005..35e0bcd 100644 --- a/include/sample_classifier.h +++ b/include/sample_classifier.h @@ -5,6 +5,8 @@ #include "settings_store.h" +namespace SampleLibrary { struct Catalog; } + namespace SampleClassifier { constexpr float kFixedPreloadThresholdSeconds = 5.0f; @@ -28,6 +30,7 @@ struct AssignedSampleClassification { uint16_t bitsPerSample = 0; uint32_t sampleRate = 0; uint32_t dataBytes = 0; + uint32_t dataOffset = 0; float durationSeconds = 0.0f; StorageMode mode = StorageMode::ReadError; }; @@ -48,6 +51,7 @@ struct ClassificationReport { }; void classifyAssignedSamples(const SettingsStore::SamplerSettings &settings, + const SampleLibrary::Catalog &catalog, ClassificationReport &report); const char *storageModeLabel(StorageMode mode); diff --git a/include/sample_library.h b/include/sample_library.h index 177a83b..a07f651 100644 --- a/include/sample_library.h +++ b/include/sample_library.h @@ -3,6 +3,7 @@ #include #include "ui.h" +#include "wav_validation.h" namespace SampleLibrary { @@ -11,11 +12,18 @@ struct Catalog { String paths[kMaxSamples]; String names[kMaxSamples]; + WavValidation::Result validation[kMaxSamples]; int count = 0; + int checkedCount = 0; + int rejectedCount = 0; + bool playable(int index) const { + return index >= 0 && index < count && validation[index].playable(); + } }; void clear(Catalog &catalog); -void loadFromSd(Catalog &catalog); +using ValidationProgress = void (*)(void *context); +void loadFromSd(Catalog &catalog, ValidationProgress progress = nullptr, void *context = nullptr); int findIndexByPath(const Catalog &catalog, const String &path); } // namespace SampleLibrary diff --git a/include/sampler_runtime.h b/include/sampler_runtime.h index 0b5e807..c8f5544 100644 --- a/include/sampler_runtime.h +++ b/include/sampler_runtime.h @@ -16,6 +16,7 @@ class SamplerRuntime { void applyAssignmentsToUi(Ui &ui, const SampleLibrary::Catalog &catalog) const; void collectAssignmentsFromUi(const Ui &ui, const SampleLibrary::Catalog &catalog); + void setCatalog(const SampleLibrary::Catalog *catalog) { catalog_ = catalog; } void rebuildPreparedSamples(); bool saveSettingsToSd() const; @@ -35,6 +36,7 @@ class SamplerRuntime { void loadClassifiedRamSamplesAndLog(); void buildActiveRegistryAndLog(); + const SampleLibrary::Catalog *catalog_ = nullptr; SettingsStore::SamplerSettings settings_; SampleClassifier::ClassificationReport classificationReport_; SampleRamManager::LoadReport ramLoadReport_; diff --git a/include/stream_manager.h b/include/stream_manager.h index 1951988..bf33a80 100644 --- a/include/stream_manager.h +++ b/include/stream_manager.h @@ -5,6 +5,7 @@ #include class AudioFileSource; +namespace SampleLibrary { struct Catalog; } class StreamManager { public: @@ -22,7 +23,7 @@ class StreamManager { StreamManager() = default; ~StreamManager(); - bool begin(uint8_t streamCount = kMaxStreams); + bool begin(uint8_t streamCount = kMaxStreams, const SampleLibrary::Catalog *catalog = nullptr); void shutdown(); bool openStream(uint8_t streamId, const char *path); @@ -38,4 +39,5 @@ class StreamManager { BufferedSdSource *streams_[kMaxStreams] = {nullptr}; uint8_t streamCount_ = 0; Diagnostics diagnostics_; + const SampleLibrary::Catalog *catalog_ = nullptr; }; diff --git a/include/ui.h b/include/ui.h index b38970f..38bed68 100644 --- a/include/ui.h +++ b/include/ui.h @@ -1,6 +1,7 @@ #pragma once #include +#include "wav_validation.h" class Ui { public: @@ -65,7 +66,8 @@ class Ui { int sampleIndex, void *context); - void begin(const String *sampleNames, const String *samplePaths, int sampleCount); + void begin(const String *sampleNames, const String *samplePaths, int sampleCount, + const WavValidation::Result *validation = nullptr); void setPreviewCallback(OnPreviewSampleCallback callback, void *context); void setSaveCallback(OnSaveCallback callback, void *context); void setPlaybackModeChangedCallback(OnPlaybackModeChangedCallback callback, void *context); @@ -77,6 +79,8 @@ class Ui { const String &samplePathAt(int sampleIndex) const; const String &sampleNameAt(int sampleIndex) const; bool hasSamples() const; + bool samplePlayable(int sampleIndex) const; + const char *sampleValidationLabel(int sampleIndex) const; bool setMidiAssignment(int note, int sampleIndex); int assignedSampleForMidiNote(int note) const; bool setPanicMidiNote(int note); @@ -113,6 +117,7 @@ class Ui { static int wrapIndex(int value, int size); int findAssignedNoteForSample(int sampleIndex) const; + const WavValidation::Result *validation_ = nullptr; const String *sampleNames_ = nullptr; const String *samplePaths_ = nullptr; int sampleCount_ = 0; diff --git a/include/validated_wav_source.h b/include/validated_wav_source.h new file mode 100644 index 0000000..654dfee --- /dev/null +++ b/include/validated_wav_source.h @@ -0,0 +1,20 @@ +#pragma once +#include "AudioFileSource.h" +#include "wav_validation.h" + +// Presents cached, validated PCM as a canonical WAV without rereading its header. +// The underlying file is opened/owned by the stream manager. +class ValidatedWavSource : public AudioFileSource { + public: + bool attach(AudioFileSource *source, const WavValidation::Result &info); + uint32_t read(void *data, uint32_t len) override; + bool seek(int32_t pos, int dir) override; + bool close() override; + bool isOpen() override; + uint32_t getSize() override { return size_; } + uint32_t getPos() override { return pos_; } + private: + AudioFileSource *source_ = nullptr; + uint8_t header_[44] = {}; + uint32_t offset_ = 0, size_ = 0, pos_ = 0; +}; diff --git a/include/wav_validation.h b/include/wav_validation.h new file mode 100644 index 0000000..86c8146 --- /dev/null +++ b/include/wav_validation.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include + +namespace WavValidation { +enum class Status : uint8_t { Unchecked, Valid, Unsupported, Invalid, ReadError }; +struct Result { + Status status = Status::Unchecked; + uint32_t dataOffset = 0; + uint32_t dataBytes = 0; + bool playable() const { return status == Status::Valid; } +}; +class Reader { + public: + virtual ~Reader() = default; + virtual uint32_t size() const = 0; + virtual bool readAt(uint32_t offset, uint8_t *dst, size_t bytes) = 0; +}; +Result validate(Reader &reader); +const char *label(Status status); +void pcmHeader(uint8_t *header, uint32_t dataBytes); +} // namespace WavValidation diff --git a/src/audio.cpp b/src/audio.cpp index daa11cd..734cabc 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -83,7 +83,7 @@ bool Audio::begin() { return false; } - if (!impl_->streamManager.begin(kVoiceCount)) { + if (!impl_->streamManager.begin(kVoiceCount, catalog_)) { delete impl_->mixer; impl_->mixer = nullptr; delete impl_->waveformOut; diff --git a/src/boot_screen_flow.cpp b/src/boot_screen_flow.cpp index 1456ef8..23ec7e5 100644 --- a/src/boot_screen_flow.cpp +++ b/src/boot_screen_flow.cpp @@ -10,9 +10,11 @@ void BootScreenFlow::begin(DisplaySsd1309 *display, Input *input, Ui *ui) { void BootScreenFlow::render(bool loading, int totalSamples, int assignedSamples, - int ramUsagePercent) { + int ramUsagePercent, int checkedSamples, int rejectedSamples) { if (!display_) return; + model_.checkedSamples = checkedSamples; + model_.rejectedSamples = rejectedSamples; model_.loading = loading; model_.totalSamples = totalSamples; model_.assignedSamples = assignedSamples; diff --git a/src/display_ssd1309.cpp b/src/display_ssd1309.cpp index 02599f5..f1d4be9 100644 --- a/src/display_ssd1309.cpp +++ b/src/display_ssd1309.cpp @@ -98,13 +98,19 @@ void DisplaySsd1309::renderBootScreen(const BootScreenModel &model) { char line1[28]; char line2[28]; char line3[28]; - snprintf(line1, sizeof(line1), "Samples total: %d", model.totalSamples); + snprintf(line1, sizeof(line1), "Samples:%d Rejected:%d", model.totalSamples, model.rejectedSamples); snprintf(line2, sizeof(line2), "Assigned: %d", model.assignedSamples); snprintf(line3, sizeof(line3), "RAM used: %d%%", model.ramUsagePercent); gDisplay.drawStr(0, 34, line1); gDisplay.drawStr(0, 44, line2); gDisplay.drawStr(0, 54, line3); - gDisplay.drawStr(0, 63, model.loading ? "Loading..." : "Ready"); + char status[28]; + if (model.loading && model.checkedSamples < model.totalSamples) { + snprintf(status, sizeof(status), "Checking WAV: %d/%d", model.checkedSamples, model.totalSamples); + } else { + snprintf(status, sizeof(status), "%s", model.loading ? "Loading..." : "Ready"); + } + gDisplay.drawStr(0, 63, status); gDisplay.sendBuffer(); dirty_ = false; @@ -255,6 +261,7 @@ void DisplaySsd1309::renderLibrary(const Ui::RenderModel &model, const Ui &ui) { const bool selected = (sampleIndex == model.currentSampleIndex); String line = (selected ? "> " : " "); + if (!ui.samplePlayable(sampleIndex)) line += "!"; line += sampleLabel(sampleIndex, ui.sampleNameAt(sampleIndex)); if (selected) { gDisplay.drawBox(0, y - 7, 128, 9); @@ -289,13 +296,15 @@ void DisplaySsd1309::renderLibrary(const Ui::RenderModel &model, const Ui &ui) { } else { assigned += "--"; } - gDisplay.drawStr(0, 47, assigned.c_str()); + gDisplay.drawStr(0, 47, ui.samplePlayable(model.currentSampleIndex) + ? assigned.c_str() : ui.sampleValidationLabel(model.currentSampleIndex)); } gDisplay.setFont(u8g2_font_4x6_tf); gDisplay.drawStr(0, 57, model.libraryAssignsPanic ? "L clk:back L rot:mode" : "L clk:back L rot:mode R:browse"); - gDisplay.drawStr(0, 63, "R click: play R hold: assign"); + gDisplay.drawStr(0, 63, !model.libraryAssignsPanic && !ui.samplePlayable(model.currentSampleIndex) + ? "Playback blocked: check WAV" : "R click: play R hold: assign"); } void DisplaySsd1309::renderAssign(const Ui::RenderModel &model, const Ui &ui) { diff --git a/src/sample_classifier.cpp b/src/sample_classifier.cpp index fcb26f5..2d07427 100644 --- a/src/sample_classifier.cpp +++ b/src/sample_classifier.cpp @@ -1,109 +1,10 @@ #include "sample_classifier.h" -#include +#include "sample_library.h" #include namespace { -struct WavInfo { - uint16_t audioFormat = 0; - uint16_t channelCount = 0; - uint16_t bitsPerSample = 0; - uint32_t sampleRate = 0; - uint32_t dataBytes = 0; - bool hasFmt = false; - bool hasData = false; -}; - -uint32_t readLe32(const uint8_t *buf) { - return static_cast(buf[0]) | (static_cast(buf[1]) << 8) | - (static_cast(buf[2]) << 16) | (static_cast(buf[3]) << 24); -} - -uint16_t readLe16(const uint8_t *buf) { - return static_cast(buf[0]) | (static_cast(buf[1]) << 8); -} - -bool readWavInfo(const String &path, WavInfo &info) { - File file = SD.open(path, FILE_READ); - if (!file) { - return false; - } - - uint8_t riffHeader[12] = {0}; - if (file.read(riffHeader, sizeof(riffHeader)) != static_cast(sizeof(riffHeader))) { - file.close(); - return false; - } - - if (memcmp(riffHeader, "RIFF", 4) != 0 || memcmp(&riffHeader[8], "WAVE", 4) != 0) { - file.close(); - return false; - } - - while (file.available()) { - uint8_t chunkHeader[8] = {0}; - if (file.read(chunkHeader, sizeof(chunkHeader)) != static_cast(sizeof(chunkHeader))) { - break; - } - - const uint32_t chunkSize = readLe32(&chunkHeader[4]); - const size_t chunkDataPos = file.position(); - - if (memcmp(chunkHeader, "fmt ", 4) == 0) { - if (chunkSize < 16) { - file.close(); - return false; - } - uint8_t fmtCore[16] = {0}; - if (file.read(fmtCore, sizeof(fmtCore)) != static_cast(sizeof(fmtCore))) { - file.close(); - return false; - } - info.audioFormat = readLe16(&fmtCore[0]); - info.channelCount = readLe16(&fmtCore[2]); - info.sampleRate = readLe32(&fmtCore[4]); - info.bitsPerSample = readLe16(&fmtCore[14]); - info.hasFmt = true; - } else if (memcmp(chunkHeader, "data", 4) == 0) { - info.dataBytes = chunkSize; - info.hasData = true; - } - - if (info.hasFmt && info.hasData) { - file.close(); - return true; - } - - size_t skipTo = chunkDataPos + chunkSize; - if ((chunkSize & 1U) != 0) { - skipTo += 1U; - } - if (!file.seek(skipTo)) { - file.close(); - return false; - } - } - - file.close(); - return info.hasFmt && info.hasData; -} - -bool isSupportedV1Format(const WavInfo &info) { - return info.audioFormat == SampleClassifier::kRequiredAudioFormatPcm && - info.bitsPerSample == SampleClassifier::kRequiredBitsPerSample && - info.sampleRate == SampleClassifier::kRequiredSampleRate && - info.channelCount == SampleClassifier::kRequiredChannelCount; -} - -float durationSeconds(const WavInfo &info) { - const uint32_t bytesPerFrame = - static_cast(info.channelCount) * (static_cast(info.bitsPerSample) / 8U); - if (bytesPerFrame == 0 || info.sampleRate == 0) return 0.0f; - return static_cast(info.dataBytes) / - static_cast(bytesPerFrame * static_cast(info.sampleRate)); -} - int findExistingPathIndex(const SampleClassifier::ClassificationReport &report, const String &path) { for (int i = 0; i < report.itemCount; i++) { if (report.items[i].path == path) return i; @@ -119,6 +20,7 @@ void resetReport(SampleClassifier::ClassificationReport &report) { report.items[i].bitsPerSample = 0; report.items[i].sampleRate = 0; report.items[i].dataBytes = 0; + report.items[i].dataOffset = 0; report.items[i].durationSeconds = 0.0f; report.items[i].mode = SampleClassifier::StorageMode::ReadError; } @@ -138,6 +40,7 @@ void resetReport(SampleClassifier::ClassificationReport &report) { namespace SampleClassifier { void classifyAssignedSamples(const SettingsStore::SamplerSettings &settings, + const SampleLibrary::Catalog &catalog, ClassificationReport &report) { resetReport(report); report.sampleRamBudgetBytes = settings.sampleRamBudgetBytes; @@ -157,38 +60,29 @@ void classifyAssignedSamples(const SettingsStore::SamplerSettings &settings, item.bitsPerSample = existing.bitsPerSample; item.sampleRate = existing.sampleRate; item.dataBytes = existing.dataBytes; + item.dataOffset = existing.dataOffset; item.durationSeconds = existing.durationSeconds; item.mode = existing.mode; - } else if (!SD.exists(item.path)) { - item.mode = StorageMode::MissingFile; } else { - WavInfo info; - if (!readWavInfo(item.path, info)) { - item.mode = StorageMode::ReadError; - } else if (!isSupportedV1Format(info)) { - item.channelCount = info.channelCount; - item.bitsPerSample = info.bitsPerSample; - item.sampleRate = info.sampleRate; - item.dataBytes = info.dataBytes; - item.durationSeconds = durationSeconds(info); - item.mode = StorageMode::InvalidFormat; + const int index = SampleLibrary::findIndexByPath(catalog, item.path); + if (index < 0) { + item.mode = StorageMode::MissingFile; + } else if (!catalog.playable(index)) { + item.mode = catalog.validation[index].status == WavValidation::Status::ReadError || + catalog.validation[index].status == WavValidation::Status::Unchecked + ? StorageMode::ReadError : StorageMode::InvalidFormat; } else { - item.channelCount = info.channelCount; - item.bitsPerSample = info.bitsPerSample; - item.sampleRate = info.sampleRate; + const auto &info = catalog.validation[index]; + item.channelCount = kRequiredChannelCount; + item.bitsPerSample = kRequiredBitsPerSample; + item.sampleRate = kRequiredSampleRate; item.dataBytes = info.dataBytes; - item.durationSeconds = durationSeconds(info); - - const bool fitsThreshold = item.durationSeconds <= kFixedPreloadThresholdSeconds; - const uint32_t ramRemaining = - (report.sampleRamBudgetBytes > report.sampleRamUsedBytes) - ? (report.sampleRamBudgetBytes - report.sampleRamUsedBytes) - : 0; - const bool fitsRamBudget = item.dataBytes <= ramRemaining; - item.mode = (fitsThreshold && fitsRamBudget) ? StorageMode::Ram : StorageMode::Stream; - if (item.mode == StorageMode::Ram) { - report.sampleRamUsedBytes += item.dataBytes; - } + item.dataOffset = info.dataOffset; + item.durationSeconds = static_cast(info.dataBytes) / 88200.0f; + const uint32_t remaining = report.sampleRamBudgetBytes - report.sampleRamUsedBytes; + item.mode = item.durationSeconds <= kFixedPreloadThresholdSeconds && item.dataBytes <= remaining + ? StorageMode::Ram : StorageMode::Stream; + if (item.mode == StorageMode::Ram) report.sampleRamUsedBytes += item.dataBytes; } } diff --git a/src/sample_library.cpp b/src/sample_library.cpp index 0321d04..e592e52 100644 --- a/src/sample_library.cpp +++ b/src/sample_library.cpp @@ -29,13 +29,15 @@ namespace SampleLibrary { void clear(Catalog &catalog) { catalog.count = 0; + catalog.checkedCount = catalog.rejectedCount = 0; for (int i = 0; i < Catalog::kMaxSamples; i++) { catalog.paths[i] = ""; catalog.names[i] = ""; + catalog.validation[i] = {}; } } -void loadFromSd(Catalog &catalog) { +void loadFromSd(Catalog &catalog, ValidationProgress progress, void *context) { clear(catalog); File dir = SD.open("/samples"); @@ -63,6 +65,34 @@ void loadFromSd(Catalog &catalog) { dir.close(); sortByName(catalog); + if (progress) progress(context); + class SdReader : public WavValidation::Reader { + public: + explicit SdReader(File &file) : file_(file) {} + uint32_t size() const override { return file_.size(); } + bool readAt(uint32_t offset, uint8_t *dst, size_t bytes) override { + return file_.seek(offset) && file_.read(dst, bytes) == static_cast(bytes); + } + private: + File &file_; + }; + for (int i = 0; i < catalog.count; ++i) { + File file = SD.open(catalog.paths[i], FILE_READ); + if (!file || file.isDirectory()) { + catalog.validation[i].status = WavValidation::Status::ReadError; + } else { + SdReader reader(file); + catalog.validation[i] = WavValidation::validate(reader); + } + file.close(); + ++catalog.checkedCount; + if (!catalog.playable(i)) { + ++catalog.rejectedCount; + Serial.printf("WAV rejected: %s (%s)\n", catalog.paths[i].c_str(), + WavValidation::label(catalog.validation[i].status)); + } + if (progress) progress(context); + } } diff --git a/src/sample_ram_manager.cpp b/src/sample_ram_manager.cpp index d458203..4aefee8 100644 --- a/src/sample_ram_manager.cpp +++ b/src/sample_ram_manager.cpp @@ -22,11 +22,6 @@ LoadedEntry gLoadedEntries[SettingsStore::SamplerSettings::kMaxAssignments]; bool gPoolBudgetLocked = false; uint32_t gFixedPoolBudget = 0; -uint32_t readLe32(const uint8_t *buf) { - return static_cast(buf[0]) | (static_cast(buf[1]) << 8) | - (static_cast(buf[2]) << 16) | (static_cast(buf[3]) << 24); -} - void clearLoadedEntries() { for (int i = 0; i < SettingsStore::SamplerSettings::kMaxAssignments; i++) { gLoadedEntries[i].path = ""; @@ -76,50 +71,6 @@ int findFreeLoadedEntrySlot() { return -1; } -bool findWavDataChunk(const String &path, uint32_t &dataOffset, uint32_t &dataSize) { - File file = SD.open(path, FILE_READ); - if (!file) return false; - - uint8_t riffHeader[12] = {0}; - if (file.read(riffHeader, sizeof(riffHeader)) != static_cast(sizeof(riffHeader))) { - file.close(); - return false; - } - - if (memcmp(riffHeader, "RIFF", 4) != 0 || memcmp(&riffHeader[8], "WAVE", 4) != 0) { - file.close(); - return false; - } - - while (file.available()) { - uint8_t chunkHeader[8] = {0}; - if (file.read(chunkHeader, sizeof(chunkHeader)) != static_cast(sizeof(chunkHeader))) { - file.close(); - return false; - } - - const uint32_t chunkSize = readLe32(&chunkHeader[4]); - const uint32_t chunkDataPos = static_cast(file.position()); - - if (memcmp(chunkHeader, "data", 4) == 0) { - dataOffset = chunkDataPos; - dataSize = chunkSize; - file.close(); - return true; - } - - uint32_t skipTo = chunkDataPos + chunkSize; - if ((chunkSize & 1U) != 0) skipTo += 1U; - if (!file.seek(skipTo)) { - file.close(); - return false; - } - } - - file.close(); - return false; -} - bool readFileRangeToBuffer(const String &path, uint32_t offset, uint32_t size, uint8_t *dst) { File file = SD.open(path, FILE_READ); if (!file) return false; @@ -202,15 +153,7 @@ bool prepare(const SettingsStore::SamplerSettings &settings, continue; } - uint32_t dataOffset = 0; - uint32_t dataSize = 0; - if (!findWavDataChunk(item.path, dataOffset, dataSize) || dataSize < item.dataBytes) { - report.readErrorCount++; - report.fallbackToStreamCount++; - continue; - } - - if (!readFileRangeToBuffer(item.path, dataOffset, item.dataBytes, gPool + used)) { + if (!readFileRangeToBuffer(item.path, item.dataOffset, item.dataBytes, gPool + used)) { report.readErrorCount++; report.fallbackToStreamCount++; continue; diff --git a/src/sampler_app.cpp b/src/sampler_app.cpp index 6875165..0dc02ec 100644 --- a/src/sampler_app.cpp +++ b/src/sampler_app.cpp @@ -76,7 +76,9 @@ void SamplerApp::initializeRuntimeDefaults() { void SamplerApp::loadStorageAndSettings() { const bool sdReady = StorageSD::init(); if (sdReady) { - SampleLibrary::loadFromSd(catalog_); + SampleLibrary::loadFromSd(catalog_, [](void *context) { + static_cast(context)->renderBootScreen(true); + }, this); renderBootScreen(true); runtime_.loadSettingsFromSd(); } else { @@ -88,7 +90,9 @@ void SamplerApp::loadStorageAndSettings() { void SamplerApp::initializeInteractiveModules() { input_.begin(); - ui_.begin(catalog_.names, catalog_.paths, catalog_.count); + runtime_.setCatalog(&catalog_); + audio_.setSampleCatalog(&catalog_); + ui_.begin(catalog_.names, catalog_.paths, catalog_.count, catalog_.validation); midi_.begin(&ui_); runtime_.applyAssignmentsToUi(ui_, catalog_); ui_.clearUnsavedChanges(); @@ -162,7 +166,7 @@ void SamplerApp::renderBootScreen(bool loading) { bootScreenFlow_.render(loading, catalog_.count, runtime_.assignedSamplesCount(), - runtime_.ramUsagePercent()); + runtime_.ramUsagePercent(), catalog_.checkedCount, catalog_.rejectedCount); } void SamplerApp::loop() { diff --git a/src/sampler_playback_router.cpp b/src/sampler_playback_router.cpp index 2a899a1..6b7b73c 100644 --- a/src/sampler_playback_router.cpp +++ b/src/sampler_playback_router.cpp @@ -15,7 +15,7 @@ void SamplerPlaybackRouter::begin(Ui *ui, void SamplerPlaybackRouter::onPreviewSample(int sampleIndex) const { if (!ui_ || !catalog_ || !triggerEngine_) return; - if (sampleIndex < 0 || sampleIndex >= catalog_->count) return; + if (!catalog_->playable(sampleIndex)) return; TriggerEvent event; event.source = TriggerSourceType::StreamPath; @@ -43,6 +43,7 @@ void SamplerPlaybackRouter::onAssignedMidiNoteOn(int midiNote) const { return; } + if (!catalog_->playable(sampleIndex)) return; ui_->reportTriggeredSample(sampleIndex); const String &assignedPath = catalog_->paths[sampleIndex]; const uint8_t assignedVolume = static_cast(ui_->sampleVolumeForSample(sampleIndex)); diff --git a/src/sampler_runtime.cpp b/src/sampler_runtime.cpp index b7870f6..c524fed 100644 --- a/src/sampler_runtime.cpp +++ b/src/sampler_runtime.cpp @@ -1,6 +1,7 @@ #include "sampler_runtime.h" void SamplerRuntime::applyDefaultSettings() { + catalog_ = nullptr; SettingsStore::applyDefaults(settings_); classificationReport_ = SampleClassifier::ClassificationReport{}; ramLoadReport_ = SampleRamManager::LoadReport{}; @@ -141,7 +142,7 @@ const SampleClassifier::AssignedSampleClassification *SamplerRuntime::findClassi } void SamplerRuntime::classifyAssignedSamplesAndLog() { - SampleClassifier::classifyAssignedSamples(settings_, classificationReport_); + if (catalog_) SampleClassifier::classifyAssignedSamples(settings_, *catalog_, classificationReport_); } void SamplerRuntime::loadClassifiedRamSamplesAndLog() { diff --git a/src/stream_manager.cpp b/src/stream_manager.cpp index 26a7be6..2e81774 100644 --- a/src/stream_manager.cpp +++ b/src/stream_manager.cpp @@ -2,6 +2,8 @@ #include "AudioFileSource.h" #include "AudioFileSourceSD.h" +#include "validated_wav_source.h" +#include "sample_library.h" #include #include @@ -38,6 +40,14 @@ class StreamManager::BufferedSdSource : public AudioFileSource { return true; } + bool openValidated(const char *path, const WavValidation::Result &info) { + if (!info.playable() || !open(path)) return false; + if (view_.attach(this, info)) return true; + close(); + return false; + } + AudioFileSource *validatedSource() { return &view_; } + uint32_t read(void *data, uint32_t len) override { if (!open_ || !data || len == 0) return 0; if (!sourceMutex_ || xSemaphoreTake(sourceMutex_, pdMS_TO_TICKS(5)) != pdTRUE) return 0; @@ -103,6 +113,7 @@ class StreamManager::BufferedSdSource : public AudioFileSource { } AudioFileSourceSD source_; + ValidatedWavSource view_; SemaphoreHandle_t sourceMutex_ = xSemaphoreCreateMutex(); Diagnostics *diagnostics_ = nullptr; bool open_ = false; @@ -115,12 +126,13 @@ StreamManager::~StreamManager() { shutdown(); } -bool StreamManager::begin(uint8_t streamCount) { +bool StreamManager::begin(uint8_t streamCount, const SampleLibrary::Catalog *catalog) { shutdown(); if (streamCount == 0 || streamCount > kMaxStreams) { return false; } + catalog_ = catalog; diagnostics_ = Diagnostics{}; for (uint8_t i = 0; i < streamCount; i++) { streams_[i] = new BufferedSdSource(&diagnostics_); @@ -144,12 +156,15 @@ void StreamManager::shutdown() { bool StreamManager::openStream(uint8_t streamId, const char *path) { if (streamId >= streamCount_ || !streams_[streamId]) return false; - return streams_[streamId]->open(path); + if (!catalog_) return false; + const int index = SampleLibrary::findIndexByPath(*catalog_, path); + if (!catalog_->playable(index)) return false; + return streams_[streamId]->openValidated(path, catalog_->validation[index]); } AudioFileSource *StreamManager::sourceForStream(uint8_t streamId) { if (streamId >= streamCount_) return nullptr; - return streams_[streamId]; + return streams_[streamId]->validatedSource(); } void StreamManager::closeStream(uint8_t streamId) { diff --git a/src/ui.cpp b/src/ui.cpp index 8725187..a36bf0e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -38,7 +38,9 @@ int logicalMainItemForSelection(int selection, bool hasLastSample, bool hasUnsav } // namespace -void Ui::begin(const String *sampleNames, const String *samplePaths, int sampleCount) { +void Ui::begin(const String *sampleNames, const String *samplePaths, int sampleCount, + const WavValidation::Result *validation) { + validation_ = validation; sampleNames_ = sampleNames; samplePaths_ = samplePaths; sampleCount_ = clampValue(sampleCount, 0, kMaxSamples); @@ -473,7 +475,7 @@ void Ui::logNotImplemented(const char *functionName) const { } void Ui::triggerPreview(int sampleIndex) { - if (sampleIndex < 0 || sampleIndex >= sampleCount_) return; + if (!samplePlayable(sampleIndex)) return; if (onPreview_) onPreview_(sampleIndex, previewContext_); lastTriggeredSampleIndex_ = sampleIndex; markDirty(); @@ -511,3 +513,11 @@ int Ui::findAssignedNoteForSample(int sampleIndex) const { } return -1; } + +bool Ui::samplePlayable(int index) const { + return index >= 0 && index < sampleCount_ && (!validation_ || validation_[index].playable()); +} +const char *Ui::sampleValidationLabel(int index) const { + if (index < 0 || index >= sampleCount_ || !validation_) return ""; + return WavValidation::label(validation_[index].status); +} diff --git a/src/validated_wav_source.cpp b/src/validated_wav_source.cpp new file mode 100644 index 0000000..a4a6ac8 --- /dev/null +++ b/src/validated_wav_source.cpp @@ -0,0 +1,49 @@ +#include "validated_wav_source.h" +#include +#include +#include + +bool ValidatedWavSource::attach(AudioFileSource *source, const WavValidation::Result &info) { + source_ = nullptr; size_ = pos_ = 0; + if (!source || !source->isOpen() || !info.playable() || info.dataBytes == 0 || + info.dataBytes > 0x7FFFFFD3U || info.dataOffset > source->getSize() || + info.dataBytes > source->getSize() - info.dataOffset) return false; + if (!source->seek(info.dataOffset, SEEK_SET)) return false; + source_ = source; offset_ = info.dataOffset; size_ = info.dataBytes + 44; + WavValidation::pcmHeader(header_, info.dataBytes); + return true; +} +uint32_t ValidatedWavSource::read(void *data, uint32_t len) { + if (!isOpen() || !data || pos_ >= size_) return 0; + len = std::min(len, size_ - pos_); + auto *dst = static_cast(data); + uint32_t copied = 0; + if (pos_ < 44) { + copied = std::min(len, 44 - pos_); + std::memcpy(dst, header_ + pos_, copied); + pos_ += copied; + } + if (len > copied) { + const uint32_t bytes = source_->read(dst + copied, len - copied); + pos_ += bytes; copied += bytes; + } + return copied; +} +bool ValidatedWavSource::seek(int32_t pos, int dir) { + if (!isOpen()) return false; + int64_t target = pos; + if (dir == SEEK_CUR) target += pos_; + else if (dir == SEEK_END) target += size_; + else if (dir != SEEK_SET) return false; + if (target < 0 || target > size_) return false; + const uint32_t physical = offset_ + (target > 44 ? static_cast(target - 44) : 0); + if (!source_->seek(physical, SEEK_SET)) return false; + pos_ = static_cast(target); + return true; +} +bool ValidatedWavSource::close() { + const bool ok = source_ ? source_->close() : true; + source_ = nullptr; size_ = pos_ = 0; + return ok; +} +bool ValidatedWavSource::isOpen() { return source_ && source_->isOpen(); } diff --git a/src/wav_validation.cpp b/src/wav_validation.cpp new file mode 100644 index 0000000..46e30a3 --- /dev/null +++ b/src/wav_validation.cpp @@ -0,0 +1,73 @@ +#include "wav_validation.h" +#include + +namespace WavValidation { +namespace { +uint32_t le32(const uint8_t *p) { + return uint32_t(p[0]) | uint32_t(p[1]) << 8 | uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24; +} +uint16_t le16(const uint8_t *p) { return uint16_t(p[0]) | uint16_t(p[1]) << 8; } +void put32(uint8_t *p, uint32_t v) { + for (int i = 0; i < 4; ++i) p[i] = static_cast(v >> (8 * i)); +} +} +Result validate(Reader &reader) { + Result result; + result.status = Status::Invalid; + const uint32_t size = reader.size(); + // Playback sources use signed 32-bit seek positions, including a virtual header. + if (size < 44 || size > 0x7FFFFFD3U) return result; + uint8_t riff[12]; + if (!reader.readAt(0, riff, 12)) { result.status = Status::ReadError; return result; } + if (std::memcmp(riff, "RIFF", 4) || std::memcmp(riff + 8, "WAVE", 4) || + le32(riff + 4) != size - 8) return result; + bool fmt = false, data = false, supported = false; + uint32_t pos = 12; + while (pos < size) { + if (size - pos < 8) return result; + uint8_t chunk[8]; + if (!reader.readAt(pos, chunk, 8)) { result.status = Status::ReadError; return result; } + const uint32_t bytes = le32(chunk + 4); + pos += 8; + // Subtraction-based bounds checks also reject malicious size overflows. + if (bytes > size - pos || (bytes & 1U) > size - pos - bytes) return result; + if (std::memcmp(chunk, "fmt ", 4) == 0) { + if (fmt || data || bytes < 16 || bytes == 17) return result; + uint8_t core[18]; + const size_t coreBytes = bytes == 16 ? 16 : 18; + if (!reader.readAt(pos, core, coreBytes)) { result.status = Status::ReadError; return result; } + if (bytes >= 18 && le16(core + 16) != bytes - 18) return result; + fmt = true; + supported = le16(core) == 1 && le16(core + 2) == 1 && + le32(core + 4) == 44100 && le16(core + 14) == 16; + if (supported && (le16(core + 12) != 2 || le32(core + 8) != 88200)) return result; + } else if (std::memcmp(chunk, "data", 4) == 0) { + if (!fmt || data || bytes == 0 || (supported && bytes % 2 != 0)) return result; + data = true; + result.dataOffset = pos; + result.dataBytes = bytes; + } + pos += bytes + (bytes & 1U); + } + if (fmt && data) result.status = supported ? Status::Valid : Status::Unsupported; + return result; +} +const char *label(Status status) { + switch (status) { + case Status::Valid: return "OK"; + case Status::Unsupported: return "BAD FORMAT"; + case Status::Invalid: return "BAD WAV"; + case Status::ReadError: return "READ ERROR"; + case Status::Unchecked: return "UNCHECKED"; + } + return "UNCHECKED"; +} +void pcmHeader(uint8_t *header, uint32_t dataBytes) { + std::memset(header, 0, 44); + std::memcpy(header, "RIFF", 4); put32(header + 4, dataBytes + 36); + std::memcpy(header + 8, "WAVEfmt ", 8); put32(header + 16, 16); + header[20] = 1; header[22] = 1; put32(header + 24, 44100); + put32(header + 28, 88200); header[32] = 2; header[34] = 16; + std::memcpy(header + 36, "data", 4); put32(header + 40, dataBytes); +} +} // namespace WavValidation diff --git a/test/support/Arduino.h b/test/support/Arduino.h index 7c6b7ce..a016674 100644 --- a/test/support/Arduino.h +++ b/test/support/Arduino.h @@ -38,6 +38,13 @@ class String { bool operator==(const char *other) const { return data_ == (other ? other : ""); } bool operator!=(const char *other) const { return !(*this == other); } + bool endsWith(const char *suffix) const { + const std::string s(suffix); + return data_.size() >= s.size() && data_.compare(data_.size() - s.size(), s.size(), s) == 0; + } + bool operator<(const String &other) const { return data_ < other.data_; } + friend String operator+(const char *prefix, const String &s) { return String(std::string(prefix) + s.data_); } + int length() const { return static_cast(data_.size()); } const char *c_str() const { return data_.c_str(); } diff --git a/test/support/SD.h b/test/support/SD.h new file mode 100644 index 0000000..0ee1992 --- /dev/null +++ b/test/support/SD.h @@ -0,0 +1,49 @@ +#pragma once +#include "Arduino.h" +#include +#include +#include +#include +#include + +constexpr int FILE_READ = 0; +namespace FakeSD { +inline std::map> files; +inline int opens = 0, reads = 0, seeks = 0; +inline size_t bytesRead = 0; +} +class File { + public: + File() = default; + explicit File(std::string name, bool directory = false) : name_(name), valid_(true), directory_(directory) { + if (directory) for (const auto &entry : FakeSD::files) entries_.push_back(entry.first); + } + explicit operator bool() const { return valid_; } + bool isDirectory() const { return directory_; } + const char *name() const { return name_.c_str(); } + uint32_t size() const { return valid_ && !directory_ ? FakeSD::files.at(name_).size() : 0; } + bool seek(uint32_t offset) { ++FakeSD::seeks; if (offset > size()) return false; pos_ = offset; return true; } + int read(uint8_t *dst, size_t bytes) { + ++FakeSD::reads; + bytes = std::min(bytes, static_cast(size() - pos_)); + std::memcpy(dst, FakeSD::files.at(name_).data() + pos_, bytes); + pos_ += bytes; FakeSD::bytesRead += bytes; + return bytes; + } + File openNextFile() { return next_ < entries_.size() ? File(entries_[next_++]) : File(); } + void close() { valid_ = false; } + private: + std::string name_; + bool valid_ = false, directory_ = false; + uint32_t pos_ = 0; + size_t next_ = 0; + std::vector entries_; +}; +struct SDClass { + File open(const String &path, int = FILE_READ) { + ++FakeSD::opens; + if (path == "/samples") return File("/samples", true); + return FakeSD::files.count(path.c_str()) ? File(path.c_str()) : File(); + } +}; +inline SDClass SD; diff --git a/test/test_playback_router/test_main.cpp b/test/test_playback_router/test_main.cpp index f05f61d..b445ab3 100644 --- a/test/test_playback_router/test_main.cpp +++ b/test/test_playback_router/test_main.cpp @@ -1,4 +1,5 @@ #include +#include "../../src/wav_validation.cpp" #include #include @@ -95,6 +96,7 @@ Ui createUi() { SampleLibrary::Catalog createCatalog() { SampleLibrary::Catalog catalog; catalog.count = kSampleCount; + for (int i = 0; i < kSampleCount; ++i) catalog.validation[i].status = WavValidation::Status::Valid; catalog.names[0] = kNames[0]; catalog.names[1] = kNames[1]; catalog.paths[0] = kPaths[0]; @@ -102,6 +104,33 @@ SampleLibrary::Catalog createCatalog() { return catalog; } +void test_cached_validation_blocks_preview_ram_and_unsaved_stream_fallback() { + Ui ui = createUi(); + SampleLibrary::Catalog catalog = createCatalog(); + SamplerRuntime runtime; + TriggerEngine trigger; + SamplerPlaybackRouter router; + router.begin(&ui, &catalog, &runtime, &trigger); + TEST_ASSERT_TRUE(ui.setMidiAssignment(60, 1)); + for (auto status : {WavValidation::Status::Unchecked, WavValidation::Status::Unsupported, + WavValidation::Status::Invalid, WavValidation::Status::ReadError}) { + catalog.validation[1].status = status; + router.onPreviewSample(1); + router.onAssignedMidiNoteOn(60); // No prepared entry: unsaved-assignment fallback. + ActiveSampleRegistry::Entry entry; + entry.path = catalog.paths[1]; + entry.effectiveMode = ActiveSampleRegistry::EffectiveStorageMode::Ram; + setRegistryEntry(runtime, 60, entry); + router.onAssignedMidiNoteOn(60); + TEST_ASSERT_TRUE(gTriggerStub.events.empty()); + TEST_ASSERT_EQUAL_INT(-1, ui.model().lastTriggeredSampleIndex); + clearRuntimeState(runtime); + } + catalog.validation[1].status = WavValidation::Status::Valid; + router.onAssignedMidiNoteOn(60); + TEST_ASSERT_EQUAL_UINT(1, gTriggerStub.events.size()); +} + void test_preview_enqueues_stream_event_with_ui_volume_and_path() { Ui ui = createUi(); const SampleLibrary::Catalog catalog = createCatalog(); @@ -425,6 +454,7 @@ bool getLoadedSampleDataByPath(const String &path, LoadedSampleData &data) { int main() { UNITY_BEGIN(); + RUN_TEST(test_cached_validation_blocks_preview_ram_and_unsaved_stream_fallback); RUN_TEST(test_preview_enqueues_stream_event_with_ui_volume_and_path); RUN_TEST(test_preview_enqueues_loop_when_ui_mode_is_loop); RUN_TEST(test_assigned_note_without_assignment_clears_last_triggered_sample); diff --git a/test/test_ui/test_main.cpp b/test/test_ui/test_main.cpp index dd9e0c3..284ac14 100644 --- a/test/test_ui/test_main.cpp +++ b/test/test_ui/test_main.cpp @@ -1,4 +1,5 @@ #include +#include "../../src/wav_validation.cpp" #include "Arduino.h" #include "input_ui_bridge.h" @@ -12,6 +13,26 @@ namespace { +void test_invalid_library_sample_cannot_preview_or_change_last_sample() { + String names[2] = {"Good", "Bad"}, paths[2] = {"/samples/good.wav", "/samples/bad.wav"}; + WavValidation::Result validation[2]; + validation[0].status = WavValidation::Status::Valid; + validation[1].status = WavValidation::Status::Invalid; + Ui ui; + ui.begin(names, paths, 2, validation); + int previews = 0; + ui.setPreviewCallback([](int, void *context) { ++*static_cast(context); }, &previews); + ui.handleEvent({Ui::EventType::RightClick, 0}); // Main -> library. + ui.handleEvent({Ui::EventType::RightClick, 0}); + TEST_ASSERT_EQUAL_INT(1, previews); + ui.handleEvent({Ui::EventType::RightRotate, 1}); + ui.handleEvent({Ui::EventType::RightClick, 0}); + TEST_ASSERT_EQUAL_INT(1, previews); + TEST_ASSERT_EQUAL_INT(0, ui.model().lastTriggeredSampleIndex); + TEST_ASSERT_FALSE(ui.samplePlayable(1)); + TEST_ASSERT_EQUAL_STRING("BAD WAV", ui.sampleValidationLabel(1)); +} + void test_keypad_notes_follow_measured_physical_order() { const uint8_t scanOrder[] = {0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15}; for (int i = 0; i < 16; ++i) { @@ -230,6 +251,7 @@ void tearDown() {} int main() { UNITY_BEGIN(); + RUN_TEST(test_invalid_library_sample_cannot_preview_or_change_last_sample); RUN_TEST(test_keypad_notes_follow_measured_physical_order); RUN_TEST(test_keypad_event_is_not_interpreted_as_encoder_event); RUN_TEST(test_begin_sets_initial_model); diff --git a/test/test_wav_validation/test_main.cpp b/test/test_wav_validation/test_main.cpp new file mode 100644 index 0000000..e979f95 --- /dev/null +++ b/test/test_wav_validation/test_main.cpp @@ -0,0 +1,124 @@ +#include +#include +#include +#include "../support/arduino_stubs.cpp" +#include "../../src/wav_validation.cpp" +#include "../../src/validated_wav_source.cpp" +#include "../../src/sample_library.cpp" +#include "../../src/sample_classifier.cpp" +using Bytes = std::vector; +using Status = WavValidation::Status; +void set32(Bytes &b, size_t p, uint32_t v) { for (int i=0;i<4;++i) b[p+i]=v>>(8*i); } +Bytes fixture() { Bytes b(52, 0x37); WavValidation::pcmHeader(b.data(),8); return b; } +void resizeRiff(Bytes &b) { set32(b,4,b.size()-8); } +void insertChunk(Bytes &b, size_t pos, const char *name, Bytes payload) { + Bytes c(8); std::memcpy(c.data(),name,4); set32(c,4,payload.size()); + c.insert(c.end(),payload.begin(),payload.end()); if(payload.size()%2) c.push_back(0); + b.insert(b.begin()+pos,c.begin(),c.end()); resizeRiff(b); +} +struct Reader : WavValidation::Reader { + Bytes bytes; int failAt=-1; std::vector> reads; + explicit Reader(Bytes b):bytes(b){} + uint32_t size() const override{return bytes.size();} + bool readAt(uint32_t off,uint8_t *dst,size_t n) override { + reads.push_back({off,n}); if(int(off)==failAt || off>bytes.size() || n>bytes.size()-off)return false; + std::memcpy(dst,bytes.data()+off,n); return true; + } +}; +void expect(Bytes b, Status status) { Reader r(b); TEST_ASSERT_EQUAL_INT(int(status),int(WavValidation::validate(r).status)); } +void test_valid_headers_skip_pcm_and_odd_metadata() { + Bytes b=fixture(); insertChunk(b,12,"JUNK",{1,2,3}); insertChunk(b,b.size(),"LIST",{4}); + Reader r(b); auto info=WavValidation::validate(r); + TEST_ASSERT_TRUE(info.playable()); TEST_ASSERT_EQUAL_UINT32(56,info.dataOffset); + TEST_ASSERT_EQUAL_UINT32(8,info.dataBytes); + for(auto range:r.reads) TEST_ASSERT_TRUE(range.first+range.second<=56 || range.first>=64); + b=fixture(); b.insert(b.begin()+36,2,0); set32(b,16,18); resizeRiff(b); expect(b,Status::Valid); +} +void test_unsupported_formats() { + for(auto change:std::vector>{{20,3},{20,0xfe},{22,2},{24,0},{34,24},{34,8}}) { + auto b=fixture(); b[change.first]=change.second; expect(b,Status::Unsupported); + } +} +void test_invalid_sizes_and_format_fields() { + auto original=fixture(); + for(auto change:std::vector>{{0,'X'},{8,'X'},{4,0},{28,0},{32,4},{40,0},{40,7},{16,17}}) { + auto b=original; b[change.first]=change.second; expect(b,Status::Invalid); + } + auto b=original; set32(b,40,0xffffffff); expect(b,Status::Invalid); + b=original; b.pop_back(); expect(b,Status::Invalid); + b=original; b.push_back(0); expect(b,Status::Invalid); + resizeRiff(b); expect(b,Status::Invalid); + b=original; b.insert(b.begin()+36,2,1); set32(b,16,18); resizeRiff(b); expect(b,Status::Invalid); +} +void test_missing_duplicate_and_out_of_order_chunks() { + auto b=fixture(); std::memcpy(b.data()+36,"JUNK",4); expect(b,Status::Invalid); + b=fixture(); std::memcpy(b.data()+12,"JUNK",4); expect(b,Status::Invalid); + b=fixture(); insertChunk(b,b.size(),"data",{0,0}); expect(b,Status::Invalid); + b=fixture(); Bytes fmt(b.begin()+20,b.begin()+36); insertChunk(b,36,"fmt ",fmt); expect(b,Status::Invalid); + b=fixture(); insertChunk(b,12,"data",{0,0}); expect(b,Status::Invalid); + b=fixture(); insertChunk(b,b.size(),"JUNK",{1}); b.pop_back(); resizeRiff(b); expect(b,Status::Invalid); +} +void test_read_failure_is_not_playable() { + for(int off:{0,12,20,36}) { Reader r(fixture()); r.failAt=off; auto result=WavValidation::validate(r); + TEST_ASSERT_EQUAL_INT(int(Status::ReadError),int(result.status)); TEST_ASSERT_FALSE(result.playable()); } +} +void test_catalog_validates_unassigned_and_classifier_uses_only_cache() { + FakeSD::files.clear(); FakeSD::files["/samples/z.wav"]=fixture(); + auto bad=fixture(); bad[22]=2; FakeSD::files["/samples/a.wav"]=bad; + FakeSD::files["/samples/ignored.txt"]=fixture(); FakeSD::bytesRead=0; + SampleLibrary::Catalog catalog; int callbacks=0; + SampleLibrary::loadFromSd(catalog,[](void *p){++*static_cast(p);},&callbacks); + TEST_ASSERT_EQUAL_INT(2,catalog.checkedCount); TEST_ASSERT_EQUAL_INT(1,catalog.rejectedCount); + TEST_ASSERT_EQUAL_INT(3,callbacks); TEST_ASSERT_EQUAL_STRING("a.wav",catalog.names[0].c_str()); + TEST_ASSERT_FALSE(catalog.playable(0)); TEST_ASSERT_TRUE(catalog.playable(1)); + TEST_ASSERT_EQUAL_UINT32(88,FakeSD::bytesRead); + SettingsStore::SamplerSettings settings; settings.assignmentCount=4; settings.sampleRamBudgetBytes=8; + settings.assignments[0].samplePath="/samples/z.wav"; settings.assignments[1].samplePath="/samples/z.wav"; + settings.assignments[2].samplePath="/samples/a.wav"; settings.assignments[3].samplePath="/samples/missing.wav"; + FakeSD::files.clear(); int opens=FakeSD::opens, reads=FakeSD::reads; + SampleClassifier::ClassificationReport report; + SampleClassifier::classifyAssignedSamples(settings,catalog,report); + TEST_ASSERT_EQUAL_INT(2,report.ramSampleCount); TEST_ASSERT_EQUAL_UINT32(8,report.sampleRamUsedBytes); + TEST_ASSERT_EQUAL_UINT32(44,report.items[1].dataOffset); TEST_ASSERT_EQUAL_INT(1,report.invalidFormatCount); + TEST_ASSERT_EQUAL_INT(1,report.missingFileCount); + settings.sampleRamBudgetBytes=0; SampleClassifier::classifyAssignedSamples(settings,catalog,report); + TEST_ASSERT_EQUAL_INT(2,report.streamSampleCount); + TEST_ASSERT_EQUAL_INT(opens,FakeSD::opens); TEST_ASSERT_EQUAL_INT(reads,FakeSD::reads); +} +struct Source : AudioFileSource { + Bytes bytes; uint32_t pos=0; int reads=0; bool opened=true; + explicit Source(Bytes b):bytes(b){} + bool isOpen() override{return opened;} + uint32_t getSize() override{return bytes.size();} + uint32_t read(void *dst,uint32_t n) override {++reads; n=std::min(n,uint32_t(bytes.size()-pos)); std::memcpy(dst,bytes.data()+pos,n);pos+=n;return n;} + bool seek(int32_t p,int dir) override {if(dir!=SEEK_SET || p<0 || size_t(p)>bytes.size())return false;pos=p;return true;} + bool close() override{opened=false;return true;} +}; +void test_stream_uses_virtual_header_and_cached_data_bounds() { + auto b=fixture(); insertChunk(b,36,"JUNK",{1,2,3}); insertChunk(b,b.size(),"LIST",{9}); + Reader reader(b); auto info=WavValidation::validate(reader); Source source(b); ValidatedWavSource view; + TEST_ASSERT_TRUE(view.attach(&source,info)); uint8_t out[100]={}; + TEST_ASSERT_EQUAL_UINT32(44,view.read(out,44)); TEST_ASSERT_EQUAL_INT(0,source.reads); + auto canonical=fixture(); TEST_ASSERT_EQUAL_MEMORY(canonical.data(),out,44); + TEST_ASSERT_EQUAL_UINT32(8,view.read(out,100)); TEST_ASSERT_EQUAL_MEMORY(b.data()+info.dataOffset,out,8); + TEST_ASSERT_EQUAL_UINT32(0,view.read(out,1)); TEST_ASSERT_TRUE(view.seek(-2,SEEK_END)); + TEST_ASSERT_EQUAL_UINT32(2,view.read(out,8)); TEST_ASSERT_FALSE(view.seek(1,SEEK_END)); + TEST_ASSERT_FALSE(view.seek(-1,SEEK_SET)); TEST_ASSERT_TRUE(view.seek(40,SEEK_SET)); + TEST_ASSERT_EQUAL_UINT32(12,view.read(out,100)); TEST_ASSERT_EQUAL_MEMORY(canonical.data()+40,out,12); + TEST_ASSERT_TRUE(view.close()); TEST_ASSERT_FALSE(source.isOpen()); +} +void test_stream_rejects_unchecked_and_truncated_files() { + Source source(fixture()); ValidatedWavSource view; WavValidation::Result info; + TEST_ASSERT_FALSE(view.attach(&source,info)); + Reader reader(fixture()); info=WavValidation::validate(reader); source.bytes.pop_back(); + TEST_ASSERT_FALSE(view.attach(&source,info)); TEST_ASSERT_FALSE(view.isOpen()); +} +void setUp(){} void tearDown(){} +int main(){UNITY_BEGIN(); + RUN_TEST(test_valid_headers_skip_pcm_and_odd_metadata); + RUN_TEST(test_unsupported_formats); RUN_TEST(test_invalid_sizes_and_format_fields); + RUN_TEST(test_missing_duplicate_and_out_of_order_chunks); RUN_TEST(test_read_failure_is_not_playable); + RUN_TEST(test_catalog_validates_unassigned_and_classifier_uses_only_cache); + RUN_TEST(test_stream_uses_virtual_header_and_cached_data_bounds); + RUN_TEST(test_stream_rejects_unchecked_and_truncated_files); + return UNITY_END();} diff --git a/todo.md b/todo.md index 6d1e5f8..114e349 100644 --- a/todo.md +++ b/todo.md @@ -22,10 +22,10 @@ Findings from source review and targeted host-side probes of the locally install Both playback entry points in `src/audio.cpp` set `fadeInUs` to zero, natural EOF has no fade-out, and `restartVoiceLoop()` closes and reopens the source without a crossfade. Resetting `lastSample` also inserts a zero sample at each decoder restart. Add suitable start/end envelopes and continuous loop handling with an optional short crossfade. Preserve intentional percussion attacks. Test nonzero endpoints, DC-offset samples, seamless loops, and repeated triggers. - [ ] **Move SD reads out of the time-critical playback path and measure underruns.** - `StreamManager::BufferedSdSource` performs synchronous SD reads and has no read-ahead buffer; the local WAV decoder reads 128-byte chunks. File opening and header parsing also run in the audio task. Add a dedicated reader task and per-stream ring buffers, distinguish temporary starvation from EOF, and expose buffer occupancy and underrun counters. Stress-test multiple streams and repeated file starts on hardware; compare with RAM playback. + `StreamManager::BufferedSdSource` performs synchronous SD reads and has no read-ahead buffer; the local WAV decoder reads 128-byte chunks. File opening also runs in the audio task; WAV headers now come from the boot validation cache. Add a dedicated reader task and per-stream ring buffers, distinguish temporary starvation from EOF, and expose buffer occupancy and underrun counters. Stress-test multiple streams and repeated file starts on hardware; compare with RAM playback. -- [ ] **Enforce the supported WAV format on preview and fallback playback paths.** - `SamplerPlaybackRouter::onPreviewSample()` routes files directly to streaming without the assigned-sample format validation. A different sample rate can reconfigure the shared I2S output while other voices are playing, and the mixer does not resample. The locally installed decoder also has a PCM8 conversion bug involving stale `lastSample` values. Validate PCM16, 44.1 kHz, mono before every playback path, or implement explicit conversion. Verify that unsupported previews cannot disrupt active playback. +- [x] **Enforce the supported WAV format on preview and fallback playback paths.** + All loaded library entries are validated once during boot (PCM16, 44.1 kHz, mono and RIFF/chunk bounds), including unassigned files. Cached results gate preview, MIDI and stream fallback. Boot shows progress/rejections; library entries show `!` and a reason. Classification, RAM preload and streamed playback reuse cached PCM offsets/lengths; streams supply a canonical header from memory. Restart after SD file changes. Native tests cover malformed/unsupported files, metadata padding, read failures, cache-only classification and blocked playback paths. - [ ] **Review ES8388 startup sequencing, register definitions, and analog gain.** Startup now establishes I2S and digital silence before unmuting (startup hum fix confirmed on hardware), and DAC Control 3 preserves its default control bits. Remaining review: analog outputs now use `0x1E` (0 dB) with full-level digital mixing; verify available analog headroom with the actual transformer and mixer load. Choose analog levels based on measured headroom. Measure startup pops, idle noise, and distortion at several output levels, including the transformer path. Reference: [ES8388 datasheet](https://www.boardcon.com/download/ES8388_datasheet.pdf). From 30bb6ba2d952b42db8a91ce3ac78b4819142d091 Mon Sep 17 00:00:00 2001 From: JakubTheDeveloper Date: Thu, 10 Sep 2026 08:26:18 +0200 Subject: [PATCH 2/2] Fix overlaping samples broken --- README.md | 4 +- docs/audio-regression.md | 31 +++ docs/documentation.md | 14 +- docs/measurements/sample-test-2026-09-09.md | 50 ++++ include/audio_internal.h | 9 +- include/budgeted_audio_output.h | 9 + include/pcm_block_buffer.h | 20 ++ src/audio_output_chain.cpp | 27 ++ src/audio_voice_engine.cpp | 3 + src/budgeted_audio_output.cpp | 41 ++- src/codec_es8388.cpp | 14 ++ src/pcm_block_buffer.cpp | 20 ++ test/support/Arduino.h | 8 + test/support/AudioFileSourceSD.h | 27 ++ test/support/AudioOutputI2S.h | 40 +++ test/support/arduino_stubs.cpp | 2 + test/support/freertos/FreeRTOS.h | 5 + test/support/freertos/semphr.h | 7 + test/test_audio_playback/test_main.cpp | 264 ++++++++++++++++++++ test/test_codec_es8388/test_main.cpp | 12 + test/test_i2s_transport/test_main.cpp | 106 ++++++++ todo.md | 17 +- 22 files changed, 713 insertions(+), 17 deletions(-) create mode 100644 docs/audio-regression.md create mode 100644 docs/measurements/sample-test-2026-09-09.md create mode 100644 include/pcm_block_buffer.h create mode 100644 src/pcm_block_buffer.cpp create mode 100644 test/support/AudioFileSourceSD.h create mode 100644 test/support/AudioOutputI2S.h create mode 100644 test/support/freertos/semphr.h create mode 100644 test/test_audio_playback/test_main.cpp create mode 100644 test/test_i2s_transport/test_main.cpp diff --git a/README.md b/README.md index 20dba27..a35eea4 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The keypad sends notes `36..51` in the measured physical key order and uses the At startup, the firmware scans `/samples`, loads saved assignments, and prepares eligible samples in RAM. Preloading is limited to supported files no longer than 5 seconds that fit within the configured RAM budget (1 MiB by default). Other supported files stream from SD; failed preloads fall back to streaming. Missing or unsupported files are marked unavailable when assignments are prepared. -Each trigger starts a voice. Retriggering the same sample fades out its older voices, and if all 32 slots are occupied, the oldest voice is replaced. RAM and SD playback use the same decoder and mixer, with float summation and a look-ahead peak limiter before PCM16 conversion. A single voice at `VOL=100` keeps its original digital level; overlapping voices are attenuated when their sum would exceed full scale. The audio task runs on core 1; the UI and sample loader run on core 0 and communicate with it through queues. +Each trigger starts a voice. Retriggering the same sample fades out its older voices, and if all 32 slots are occupied, the oldest voice is replaced. RAM and SD playback use the same decoder and mixer, with float summation and a look-ahead peak limiter before PCM16 conversion. Each voice has a short 35-frame (about 0.8 ms) ramp at the file boundaries. Outside these ramps, a single voice at `VOL=100` keeps its original digital level; overlapping voices are attenuated when their sum would exceed full scale. The audio task runs on core 1; the UI and sample loader run on core 0 and communicate with it through queues. Saving stores assignments, assigned sample volumes, playback modes, and the panic note in `/sampler_config.json`, and refreshes RAM preparation. Save between performances: the save process waits for playback to finish and can stop running loops before rebuilding the sample pool. @@ -135,7 +135,7 @@ Run the native tests without an ESP32 connected: pio test -e native ``` -`make test` runs the same command. The tests in `test/` cover UI navigation, sample and panic assignment, keypad mapping, saving state, and routing playback requests to RAM or SD, including fallback and loop controls. The audio mixer regression tests exercise the production Samplotron mixer with a simulated output: unity solo playback, summation, 32 full-scale voices, linked limiting, look-ahead/release, backpressure, tail draining, idle silence and fade retries. Run it alone with `pio test -e native -f test_audio_mixer`. Shared hardware stubs live in `test/support/`; audio timing, SD throughput, and physical wiring require checks on the device. +`make test` runs the same command. The tests in `test/` cover UI navigation, sample and panic assignment, keypad mapping, saving state, and routing playback requests to RAM or SD, including fallback and loop controls. The audio mixer regression tests exercise the production Samplotron mixer with a simulated output: unity solo playback, summation, 32 full-scale voices, linked limiting, look-ahead/release, backpressure, tail draining, idle silence and fade retries. Run it alone with `pio test -e native -f test_audio_mixer`. The full playback regression (`pio test -e native -f test_audio_playback`) also runs the production WAV decoder, voice engine, RAM/SD source adapters, fades and mixer; it checks simultaneous/staggered playback, 6 ms retriggers, nonzero endpoints and repeated slot reuse. The `test_i2s_transport` suite separately covers the ESP32 block writer and a continuously advancing simulated output clock. See [audio regression coverage](docs/audio-regression.md). Shared hardware stubs live in `test/support/`; audio timing, SD throughput, and physical wiring require checks on the device. ### Code structure diff --git a/docs/audio-regression.md b/docs/audio-regression.md new file mode 100644 index 0000000..a9d90b5 --- /dev/null +++ b/docs/audio-regression.md @@ -0,0 +1,31 @@ +# Audio playback regression + +Run `pio test -e native -f test_audio_playback` for playback continuity, or `pio test -e native` for the complete regression suite. No ESP32 is needed. These tests run on the development computer, not during firmware boot or performance. + +The playback suite compiles the production `Audio` entry points and update loop, voice allocation/retrigger/stop code, ESP8266Audio 2.4.1 WAV decoder, RAM source, validated SD source view, stream manager, budgeted output, mixer and waveform capture. Only the SD filesystem, I2S device, clock and RTOS primitives are simulated. It therefore tests the actual decoding and scheduling interaction, not just arithmetic on an ideal array of simultaneous samples. + +| Scenario | Assertion | +| --- | --- | +| Solo and 2/8 simultaneous distinct samples, RAM and SD | Output matches the independently summed source PCM within 1 PCM unit, including unequal file endings and trailing silence. | +| Mixed RAM/SD, offsets 1, 63, 96, 511, 977 and 3000 frames | Sample order, phase, duration and start offsets are preserved across decoder-budget and mixer-buffer boundaries. | +| Same sample triggered again before EOF | New occurrence plus the independently rendered 6 ms fade of the previous one matches the actual retrigger output. Both RAM and SD are covered. | +| Repeated source-slot reuse after EOF | No stale PCM, missing tail, extra I2S start/stop, or unintended voice stealing. | +| Nonzero start, EOF and retrigger | Boundary steps are bounded; an ongoing background voice continues unchanged. Sustain retains unity level. | +| Staggered 2/8/32 loud voices | Actual overload occurs; PCM stays bounded, follows the wide sum's polarity, and has continuous inferred limiter gain. | +| Output rejection and retry | A blocked sink accepts no frames and advances no simulated audio time. Once it accepts data again, the output matches the uninterrupted run. No claim about real hardware deadlines is made. | +| Very short files and positive/negative full-scale edges | 1–1501-frame fixtures stay bounded and end cleanly, identically under retries. | +| 6 ms explicit fade | Fade lasts 265 frames (rounding tolerance 1 frame) after queued PCM; every step of a 12000-level fixture is below 48 PCM units. | +| Deliberately damaged output | The same PCM comparison used by the continuity tests rejects a dropped frame, repeated frame, inserted zero and a 500-unit spike, even though those signals remain below full scale. | + +Source tones have silent margins and smooth envelopes so an exact-reference failure identifies a playback defect rather than a discontinuity in the test recording. Separate constant-level fixtures deliberately have discontinuous endpoints to test boundary handling. The reference includes 64 frames of mixer look-ahead and the WAV decoder's initial pending zero. Changes to that decoder behavior must update the interface deliberately; the test should not silently realign broken output. + +Before the boundary fix, the start and retrigger tests measured a 12000-unit jump in one frame; the EOF test measured 12045 including the continuing background tone. Each exceeded its 900/1000-unit fixture-specific limit. Playback now applies 35-frame (about 0.8 ms) smoothstep ramps at file boundaries. The 6 ms fade test also found a 108-unit final jump caused by discarded Q15 division remainder; the remainder is now distributed across the ramp. These are reproducible digital defects, not proof that every audible crackle has the same cause. + +The existing `test_audio_mixer` suite separately checks unity gain, cancellation, linked limiting, 32 full-scale voices, aligned-sine shape against an independent unity sine, attack/release and sample acceptance. The playback suite's inferred-gain check bounds adjacent gain changes to 0.033 away from zero crossings (32-frame attack plus PCM rounding tolerance). It is a discontinuity check, not a perceptual transparency or distortion measurement. + +Host tests cannot establish ESP32 CPU headroom, SD read latency, I2S underruns, transformer saturation or analog distortion. Confirm on the device with the same pair of samples played solo, simultaneously, with offsets, and retriggered, comparing RAM-loaded short samples against SD-streamed long ones. Capture the headphone-derived output if crackles remain and keep note of trigger timing and volume. Continuous loop crossfades and click-free stealing when all 32 slots are occupied remain separate work; the current suite does not certify them. + + +## I2S transport timing regression + +`pio test -e native -f test_i2s_transport` tests the 128-frame staging buffer and the actual ESP32 branch of `StableAudioOutputI2S` against a driver fake. The ordinary playback suite still uses its immediate simulated I2S sink; it does not include this additional staging latency in its PCM timeline reference. Transport tests cover exact stereo packing and unity level, one driver call per complete DMA block, byte-granular partial writes and failed writes. A continuously advancing clock model also demonstrates underruns with a deliberately insufficient single-frame write budget; this corrects the blind spot of tests where simulated time stops whenever output is blocked. The timing assumptions are synthetic and do not benchmark the ESP32. See the [recording investigation](measurements/sample-test-2026-09-09.md). diff --git a/docs/documentation.md b/docs/documentation.md index f39777f..bc98626 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -188,10 +188,12 @@ Playback engine behavior: - fixed `32`-voice playback pool (`Audio::kVoiceCount`), - each trigger allocates a free voice slot when available, -- retriggering the same sample starts a new voice instance and requests short fade-out on already active voices in the same retrigger group, +- retriggering the same sample starts a new voice instance and requests a 6 ms fade-out on already active voices in the same retrigger group, +- both RAM and SD playback apply 35-frame (about 0.8 ms) smoothstep ramps at file start and natural EOF. These advance only on accepted samples, preserve the sustain level, and do not add SD reads. On extremely short samples the two ramps overlap and reduce the peak, - if all voices are active, the incoming trigger steals the oldest active voice (deterministic `oldest-voice` policy), - if incoming MIDI NOTE ON matches configured panic note, all currently active voices are quickly faded out and pending trigger backlog is cleared, - works for both SD-streamed and RAM-backed sample playback, +- ESP32 I2S writes are staged in 128-frame blocks (512 bytes, up to 2.9 ms additional buffering); short writes retain their exact byte suffix and EOF tails complete with continuous idle silence, - voice update loop applies bounded per-voice decode budget (`kVoiceLoopSampleBudget`) to keep scheduling predictable, - per-voice gain follows `VOL / 100` in floating point. The 32-input `SamplerMixer` sums before limiting; it never narrows an overloaded sum to PCM16 first. A stereo-linked peak limiter uses 64 frames of look-ahead (1.45 ms at 44.1 kHz), linear predictive gain bounds reached within 32 frames and held for the remainder of the look-ahead, and a 50 ms peak-envelope decay constant. It reduces gain only around overloads, including the look-ahead and release intervals. - trigger events are sent through a queue from UI/MIDI domain to dedicated audio task (no direct playback calls from UI code path). @@ -282,10 +284,14 @@ Assignment rules: ### `include/audio_internal.h` - Audio mixer buffer size: `kMixerBufferSamples = 512` -- Retrigger fade-in (new voice): `kRetriggerFadeInUs = 800` - Retrigger fade-out (older voices in same group): `kRetriggerFadeOutUs = 6000` - Default control stop fade-out: `kDefaultStopFadeOutUs = 9000` - Decode budget per voice update: `kVoiceLoopSampleBudget = 96` +### `include/budgeted_audio_output.h` + +- File boundary ramps: `kEdgeFrames = 35` (about 0.8 ms at 44.1 kHz), including the decoder’s initial pending zero in playback position accounting. +- Explicit stop fades distribute the Q15 division remainder over their full duration to avoid an extra final step. Queued audio is preserved, so the 6 ms retrigger fade starts at the voice’s next unqueued frame. + ### `include/sampler_mixer.h` - Mixer inputs: `kMaxInputs = 32` @@ -373,9 +379,9 @@ The repository workflow runs native tests and builds the main firmware. Pushes t ### Test scope and diagnostics -`pio test -e native` covers UI navigation, sample/panic learning, keypad mapping, saving state, and playback routing, including RAM-to-stream fallback and loop control. These tests use stubs and do not exercise the real audio engine, SD hardware, or I2C wiring. +`pio test -e native` covers UI navigation, sample/panic learning, keypad mapping, saving state, and playback routing, including RAM-to-stream fallback and loop control. The `test_audio_playback` suite additionally runs the real WAV decoder, voice engine, source adapters, budgeted fades and mixer against simulated SD/I2S hardware. It compares sample timelines, tests 2/8/32 overlapping voices at high levels, and includes negative controls for missing, repeated, zeroed and spiked PCM. See [coverage and limits](audio-regression.md). Host tests do not measure ESP32 deadlines, actual SD throughput or analog output. -Main firmware serial output is limited to keypad initialization and key presses from `src/input.cpp`. For encoder or MIDI diagnostics, upload the corresponding debug environment and open the monitor at 115200 baud. These are separate applications; upload the main environment again to resume sampling. +Main firmware serial output includes keypad diagnostics, WAV rejection reasons and codec volume-register verification at boot. For encoder or MIDI diagnostics, upload the corresponding debug environment and open the monitor at 115200 baud. These are separate applications; upload the main environment again to resume sampling. ## 10. Module Map (Code Orientation) diff --git a/docs/measurements/sample-test-2026-09-09.md b/docs/measurements/sample-test-2026-09-09.md new file mode 100644 index 0000000..b9f87a6 --- /dev/null +++ b/docs/measurements/sample-test-2026-09-09.md @@ -0,0 +1,50 @@ +# Recording investigation: sample-test.wav, 2026-09-09 + +User setup: one headphone channel through the existing 600:600 transformer, Allen & Heath ZEDi-10, JACK and Reaper at 0 dB. User reports maximum Samplotron volume and +40 channel gain. Host audio settings were not inspected or changed. + +Inputs supplied on the desktop: + +- `sample-test.wav`: 27 s, 44.1 kHz, mono PCM24; SHA256 `cbf1e5763b5c22b7dd55c6b300e2f8d5d604af59cad6ebc512ffcb996e8c4c47`. +- `amen.wav`: 11.162789 s, 44.1 kHz, mono PCM16; SHA256 `49fa405e2aaf21a25d34c5dd8483c9c4dfb0eea9a0a239b6d5dabb50274fe3f3`. + +Levels were calculated from signed PCM normalized to its full-scale integer range, without normalization or gain processing. RMS includes all frames in each selected interval. + +| Signal / interval | Peak dBFS | RMS dBFS | +| --- | ---: | ---: | +| Original amen.wav | -0.080 | -17.690 | +| Recorded drum solo, 1.15–12.31 s | -28.421 | -46.057 | +| Recorded overlap, 21–27 s | -21.682 | -36.392 | +| Recorded idle, 18–21 s | -74.429 | -85.744 | + +The near-full-scale source rules out a quietly prepared drum sample. The solo peak difference is about 28.3 dB between source PCM and recorded PCM; it is not a calibrated voltage measurement or a measurement of transformer loss alone. Mixer input gain, routing and converter headroom belong to the complete transfer path. The ZEDi-10 specifies 18 dB USB headroom above nominal, so a nominal analog meter indication is not 0 dBFS. Its line gain range reaches +40 dB. [Manufacturer specifications](https://www.allen-heath.com/content/uploads/2023/06/ZEDi-10-Technical-Datasheet-1.pdf). + +Large one-frame steps concentrate in the overlaps. Examples: 23.145850, 23.198095, 23.294966, 23.444785, 23.494127, 23.540476, 25.388503, 25.438549, 25.486508 and 26.079932 s. These were selected with absolute sample difference >0.023 FS and a 300-frame minimum separation. That threshold is a locator for this recording, not a general-purpose click detector. Many steps are spaced roughly 50 ms apart and occur during sustained overlap, rather than only at note or file boundaries. + +Local normalized cross-correlation of a 6 kHz high-pass version against the source was used to track drum timing while suppressing the lower-frequency added sounds. Example matches (20 ms windows, local search around the expected source position): + +| Recording time | Source position | Correlation | Recording minus source time | +| ---: | ---: | ---: | ---: | +| 22.000 s | 0.900159 s | 0.8602 | 21.099841 s | +| 23.000 s | 1.900272 s | 0.9802 | 21.099728 s | +| 25.500 s | 4.331293 s | 0.9806 | 21.168707 s | +| 26.000 s | 4.800113 s | 0.8600 | 21.199887 s | + +The increase is consistent with roughly 0.1 s of lost timing continuity by 26 s. Repeated drum patterns, analog filtering and independent recording/playback clocks limit exact alignment; this does not identify a specific DMA or SD failure by itself. Short local matches around 23.1–23.35 s also show stepwise lag increases on the order of one 128-frame DMA descriptor. Repeated or interrupted transport is a stronger hypothesis than file-edge clicks for this recording. Neither CPU deadlines nor physical underrun counters were measured on the ESP32. + +## Changes prepared for another device test + +The ESP32 output previously called `i2s_channel_write()` for each four-byte stereo frame. `StableAudioOutputI2S` now stages 128 frames, matching the existing DMA descriptor size, and submits 512 bytes per call when possible. Partial writes retain their exact unsent byte suffix; a full queue returns backpressure without advancing the rejected input. Continuous idle silence completes partial tail blocks. This uses about 0.5 KiB of staging memory and adds up to 2.9 ms of buffering. It reduces API overhead, but does not move SD reads to another task or certify real-time performance on hardware. The driver itself performs queue/semaphore work per write. [ESP-IDF 5.5.4 implementation](https://github.com/espressif/esp-idf/blob/v5.5.4/components/esp_driver_i2s/i2s_common.c). + +New transport regressions check exact signed stereo packing at unity, one driver call per full DMA block, partial-byte writes, timeouts, and a clock that keeps consuming samples during processing and driver calls. The timing model explicitly assumes 19 us processing per frame and 6 us API overhead per call: the single-frame negative control underruns while the block writer does not. Those costs are synthetic, not claimed ESP32 measurements. The actual ESP32 adapter branch is compiled against a fake I2S driver in this suite. + +Codec startup now reads back both DAC volume registers and all four analog output level registers, rejecting mismatches before unmute. A successful boot reports `Codec: DAC and analog outputs verified at 0 dB`. This checks register state, not output voltage. The existing 0 dB settings remain; the datasheet documents only up to +4.5 dB analog output gain, which would not explain a loss on the scale observed here. [ES8388 datasheet, sections 6.3.24–27](https://www.boardcon.com/download/ES8388_datasheet.pdf). + +## Follow-up measurement + +A four-second mono PCM16/44.1 kHz 1 kHz probe with -12 dBFS peak and 10 ms boundary fades was generated at `/tmp/samplotron-audio-check/calibration-1k-minus12dBFS.wav`. Its sustained sine RMS is approximately -15 dBFS. Play it at sample volume 100 to obtain a known digital reference; start the external mixer gain low, then record the settings, PFL indication and recorded level. Compare the one-channel headphone signal before and after the transformer if practical. Input socket/cable topology and actual analog levels remain to be checked. + +Firmware build and all 64 native test cases passed. + +## Device confirmation — 2026-09-10 + +The user flashed the firmware with 128-frame I2S writes and confirmed that the sound interruption disappeared. The reported overlapping-playback issue is therefore resolved in the user’s device test. This is listening feedback, not a measurement of underrun counts or maximum sustainable polyphony. Low output level remains unresolved; no level confirmation was provided. diff --git a/include/audio_internal.h b/include/audio_internal.h index 4e03696..cb28151 100644 --- a/include/audio_internal.h +++ b/include/audio_internal.h @@ -9,6 +9,7 @@ #include "AudioOutputI2S.h" #include "sampler_mixer.h" #include "budgeted_audio_output.h" +#include "pcm_block_buffer.h" #include "audio.h" #include "stream_manager.h" @@ -18,8 +19,6 @@ constexpr uint8_t kVolumeScaleMax = 100; constexpr int kMixerBufferSamples = 512; // Limit per-update work so SD streamed voices don't monopolize audio task cycles. constexpr uint16_t kVoiceLoopSampleBudget = 96; -// Short anti-click fade only when retriggering an active group. -constexpr uint32_t kRetriggerFadeInUs = 800; // Retriggered voices from the same group are softly cut to avoid clicks. // Keep this short to avoid audible comb/distortion from long overlap of the same sample. constexpr uint32_t kRetriggerFadeOutUs = 6000; @@ -82,11 +81,17 @@ class StableAudioOutputI2S : public AudioOutputI2S { bool SetRate(int hz) override; + bool ConsumeSample(int16_t sample[2]) override; + uint32_t rateSetCalls() const; uint32_t skippedRateSetCalls() const; uint32_t appliedRateSetCalls() const; private: +#ifdef ESP32 + static size_t writeBlock(void *context, const uint8_t *data, size_t bytes); + PcmBlockBuffer block_; +#endif int lastRateHz_ = -1; uint32_t rateSetCalls_ = 0; uint32_t skippedRateSetCalls_ = 0; diff --git a/include/budgeted_audio_output.h b/include/budgeted_audio_output.h index 4598564..121790b 100644 --- a/include/budgeted_audio_output.h +++ b/include/budgeted_audio_output.h @@ -10,6 +10,10 @@ class BudgetedAudioOutput : public AudioOutput { void resetBudget(uint16_t sampleCount); void resetFadeEnvelope(); + // FreshStartAudioGeneratorWAV emits one initial pending zero before PCM. + // Shape only the first/last 0.8 ms; configure before starting each decoder. + static constexpr uint32_t kEdgeFrames = 35; // ceil(44100 * 0.0008) + void setSampleFrames(uint32_t pcmFrames); void beginFadeOut(uint32_t fadeOutUs); bool isFadeOutComplete() const; @@ -23,10 +27,15 @@ class BudgetedAudioOutput : public AudioOutput { private: AudioOutput *sink_ = nullptr; uint16_t budgetSamples_ = 0; + uint32_t playbackFrames_ = 0; + uint32_t playbackPosition_ = 0; int sampleRateHz_ = 44100; uint32_t fadeEnvelopeQ15_ = 32768; uint32_t fadeStepQ15_ = 0; uint32_t fadeSamplesRemaining_ = 0; + uint32_t fadeDurationSamples_ = 0; + uint32_t fadeRemainderQ15_ = 0; + uint32_t fadeErrorQ15_ = 0; bool fadeActive_ = false; bool fadeComplete_ = false; }; diff --git a/include/pcm_block_buffer.h b/include/pcm_block_buffer.h new file mode 100644 index 0000000..f47f399 --- /dev/null +++ b/include/pcm_block_buffer.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +namespace AudioInternal { +// One ESP32 DMA descriptor, submitted in a single driver call when possible. +// A short/failed write retains the exact unsubmitted byte suffix. +class PcmBlockBuffer { + public: + static constexpr size_t kFrames = 128; + using Writer = size_t (*)(void *, const uint8_t *, size_t); + bool consume(const int16_t sample[2], Writer writer, void *context); + size_t pendingBytes() const { return count_ * 4 - sent_; } + private: + void submit(Writer writer, void *context); + uint32_t words_[kFrames] = {}; + size_t count_ = 0; + size_t sent_ = 0; +}; +} // namespace AudioInternal diff --git a/src/audio_output_chain.cpp b/src/audio_output_chain.cpp index 262f293..f720643 100644 --- a/src/audio_output_chain.cpp +++ b/src/audio_output_chain.cpp @@ -151,6 +151,33 @@ bool StableAudioOutputI2S::SetRate(int hz) { return AudioOutputI2S::SetRate(hz); } +#ifdef ESP32 +size_t StableAudioOutputI2S::writeBlock(void *context, const uint8_t *data, size_t bytes) { + auto *self = static_cast(context); + size_t written = 0; + // Nonblocking: a full DMA queue must not prevent servicing other voices. + // Preserve partial writes even when the API reports a timeout. + i2s_channel_write(self->_tx_handle, data, bytes, &written, 0); + return written; +} +#endif + +bool StableAudioOutputI2S::ConsumeSample(int16_t sample[2]) { +#ifdef ESP32 + if (!i2sOn) return false; + int16_t output[2] = {sample[0], sample[1]}; + MakeSampleStereo16(output); + if (mono) output[0] = output[1] = (int32_t(output[0]) + output[1]) / 2; + output[0] = Amplify(output[0]); + output[1] = Amplify(output[1]); + // Audio continuously feeds silence after EOF, so partial blocks complete + // without stopping I2S or dropping queued tails. Adds at most 128 frames. + return block_.consume(output, writeBlock, this); +#else + return AudioOutputI2S::ConsumeSample(sample); +#endif +} + uint32_t StableAudioOutputI2S::rateSetCalls() const { return rateSetCalls_; } uint32_t StableAudioOutputI2S::skippedRateSetCalls() const { return skippedRateSetCalls_; } diff --git a/src/audio_voice_engine.cpp b/src/audio_voice_engine.cpp index a82bef2..eae4d84 100644 --- a/src/audio_voice_engine.cpp +++ b/src/audio_voice_engine.cpp @@ -237,6 +237,8 @@ bool beginVoiceFromPath(EngineState *impl, voice.fadeOutUs = 0; voice.currentGain = (voice.fadeInUs > 0) ? 0.0f : (voice.targetGain); voice.budgetedOut->resetFadeEnvelope(); + // StreamManager exposes validated mono PCM16 behind a canonical 44-byte header. + voice.budgetedOut->setSampleFrames((voice.activeSource->getSize() - 44U) / 2U); voice.stub->SetGain(voice.currentGain); if (!voice.wav->begin(voice.activeSource, voice.budgetedOut)) { voice.activeSource->close(); @@ -291,6 +293,7 @@ bool beginVoiceFromRam(EngineState *impl, voice.fadeOutUs = 0; voice.currentGain = (voice.fadeInUs > 0) ? 0.0f : (voice.targetGain); voice.budgetedOut->resetFadeEnvelope(); + voice.budgetedOut->setSampleFrames(dataBytes / (channelCount * (bitsPerSample / 8U))); voice.stub->SetGain(voice.currentGain); if (!voice.wav->begin(voice.activeSource, voice.budgetedOut)) { voice.activeSource->close(); diff --git a/src/budgeted_audio_output.cpp b/src/budgeted_audio_output.cpp index c637fd1..515e53d 100644 --- a/src/budgeted_audio_output.cpp +++ b/src/budgeted_audio_output.cpp @@ -1,4 +1,6 @@ #include "budgeted_audio_output.h" +#include +#include namespace AudioInternal { @@ -6,10 +8,17 @@ BudgetedAudioOutput::BudgetedAudioOutput(AudioOutput *sink) : sink_(sink) {} void BudgetedAudioOutput::resetBudget(uint16_t sampleCount) { budgetSamples_ = sampleCount; } +void BudgetedAudioOutput::setSampleFrames(uint32_t pcmFrames) { + playbackFrames_ = pcmFrames + 1; // Include the decoder's initial pending zero. + playbackPosition_ = 0; +} + void BudgetedAudioOutput::resetFadeEnvelope() { + playbackFrames_ = playbackPosition_ = 0; fadeEnvelopeQ15_ = 32768; fadeStepQ15_ = 0; fadeSamplesRemaining_ = 0; + fadeDurationSamples_ = fadeRemainderQ15_ = fadeErrorQ15_ = 0; fadeActive_ = false; fadeComplete_ = false; } @@ -36,10 +45,10 @@ void BudgetedAudioOutput::beginFadeOut(uint32_t fadeOutUs) { } fadeSamplesRemaining_ = static_cast(fadeSamples); - fadeStepQ15_ = (fadeSamplesRemaining_ > 0) ? (fadeEnvelopeQ15_ / fadeSamplesRemaining_) : fadeEnvelopeQ15_; - if (fadeStepQ15_ == 0 && fadeEnvelopeQ15_ > 0) { - fadeStepQ15_ = 1; - } + fadeDurationSamples_ = fadeSamplesRemaining_; + fadeStepQ15_ = fadeEnvelopeQ15_ / fadeDurationSamples_; + fadeRemainderQ15_ = fadeEnvelopeQ15_ % fadeDurationSamples_; + fadeErrorQ15_ = 0; fadeActive_ = true; fadeComplete_ = false; } @@ -76,12 +85,32 @@ bool BudgetedAudioOutput::ConsumeSample(int16_t sample[2]) { static_cast(envelopeQ15) + 16384) >> 15); } } + if (playbackFrames_ > 0) { + const uint32_t remaining = playbackPosition_ < playbackFrames_ + ? playbackFrames_ - 1 - playbackPosition_ : 0; + const uint32_t distance = std::min(playbackPosition_, remaining); + if (distance < kEdgeFrames) { + const float x = static_cast(distance) / kEdgeFrames; + // Smoothstep has zero slope at both ends, unlike an abrupt gain switch. + const float edge = x * x * (3.0f - 2.0f * x); + for (int c = 0; c < 2; ++c) output[c] = static_cast(std::lround(output[c] * edge)); + } + } if (!sink_->ConsumeSample(output)) return false; + if (playbackPosition_ < playbackFrames_) ++playbackPosition_; if (fadeActive_) { if (fadeSamplesRemaining_ > 0) { - if (fadeEnvelopeQ15_ > fadeStepQ15_) { - fadeEnvelopeQ15_ -= fadeStepQ15_; + // Spread fixed-point division remainder across the ramp instead of + // dropping the accumulated residue abruptly on the final sample. + uint32_t step = fadeStepQ15_; + fadeErrorQ15_ += fadeRemainderQ15_; + if (fadeErrorQ15_ >= fadeDurationSamples_) { + ++step; + fadeErrorQ15_ -= fadeDurationSamples_; + } + if (fadeEnvelopeQ15_ > step) { + fadeEnvelopeQ15_ -= step; } else { fadeEnvelopeQ15_ = 0; } diff --git a/src/codec_es8388.cpp b/src/codec_es8388.cpp index 7d08937..0e005f6 100644 --- a/src/codec_es8388.cpp +++ b/src/codec_es8388.cpp @@ -148,6 +148,20 @@ bool init() { if (!codecRead(kRegDacControl3, control3) || control3 != kDacControl3Muted) { return false; } + const uint8_t levels[][2] = { + {kRegDacVolumeLeft, 0}, {kRegDacVolumeRight, 0}, + {kRegOut1Left, kAnalogOutputPlaybackCode}, {kRegOut1Right, kAnalogOutputPlaybackCode}, + {kRegOut2Left, kAnalogOutputPlaybackCode}, {kRegOut2Right, kAnalogOutputPlaybackCode}, + }; + for (const auto &level : levels) { + uint8_t actual = 0; + if (!codecRead(level[0], actual) || actual != level[1]) { + Serial.printf("Codec: volume readback failed reg=0x%02X expected=0x%02X actual=0x%02X\n", + level[0], level[1], actual); + return false; + } + } + Serial.println("Codec: DAC and analog outputs verified at 0 dB"); gCodecReady = true; return true; } diff --git a/src/pcm_block_buffer.cpp b/src/pcm_block_buffer.cpp new file mode 100644 index 0000000..f2e9da3 --- /dev/null +++ b/src/pcm_block_buffer.cpp @@ -0,0 +1,20 @@ +#include "pcm_block_buffer.h" + +namespace AudioInternal { +void PcmBlockBuffer::submit(Writer writer, void *context) { + if (count_ != kFrames || !writer) return; + const size_t remaining = sizeof(words_) - sent_; + const size_t written = writer(context, reinterpret_cast(words_) + sent_, remaining); + if (written > remaining) return; + sent_ += written; + if (sent_ == sizeof(words_)) count_ = sent_ = 0; +} +bool PcmBlockBuffer::consume(const int16_t sample[2], Writer writer, void *context) { + if (count_ == kFrames) submit(writer, context); + if (count_ == kFrames) return false; + words_[count_++] = static_cast(sample[0]) | + (uint32_t(static_cast(sample[1])) << 16); + if (count_ == kFrames) submit(writer, context); + return true; +} +} // namespace AudioInternal diff --git a/test/support/Arduino.h b/test/support/Arduino.h index a016674..8950dc0 100644 --- a/test/support/Arduino.h +++ b/test/support/Arduino.h @@ -88,6 +88,9 @@ class HardwareSerial { template void printf(const char *, Args...) {} + template + void printf_P(const char *, Args...) {} + void println(const char * = "") {} void print(const char *) {} }; @@ -97,3 +100,8 @@ extern HardwareSerial Serial; unsigned long millis(); void testSetMillis(unsigned long value); void testAdvanceMillis(unsigned long delta); + +#ifndef PSTR +#define PSTR(s) (s) +#endif +unsigned long micros(); diff --git a/test/support/AudioFileSourceSD.h b/test/support/AudioFileSourceSD.h new file mode 100644 index 0000000..e7b78d1 --- /dev/null +++ b/test/support/AudioFileSourceSD.h @@ -0,0 +1,27 @@ +#pragma once +#include "AudioFileSource.h" +#include "SD.h" +#include +class AudioFileSourceSD : public AudioFileSource { + public: + bool open(const char *path) override { file_ = SD.open(path); pos_ = 0; return bool(file_); } + uint32_t read(void *dst, uint32_t n) override { + if (!file_) return 0; + const auto bytes = file_.read(static_cast(dst), n); pos_ += bytes; return bytes; + } + bool seek(int32_t p, int dir) override { + int64_t next = p; + if (dir == SEEK_CUR) next += pos_; + else if (dir == SEEK_END) next += getSize(); + else if (dir != SEEK_SET) return false; + if (next < 0 || next > getSize() || !file_.seek(next)) return false; + pos_ = next; return true; + } + bool close() override { file_.close(); return true; } + bool isOpen() override { return bool(file_); } + uint32_t getSize() override { return file_.size(); } + uint32_t getPos() override { return pos_; } + private: + File file_; + uint32_t pos_ = 0; +}; diff --git a/test/support/AudioOutputI2S.h b/test/support/AudioOutputI2S.h new file mode 100644 index 0000000..25d6699 --- /dev/null +++ b/test/support/AudioOutputI2S.h @@ -0,0 +1,40 @@ +#pragma once +#include "AudioOutput.h" +#include +#include +// Hardware boundary only. Production decoder, voice engine and mixer run unchanged. +namespace FakeI2S { +inline int capacity = 0, starts = 0, stops = 0, driverCalls = 0; +inline std::vector> frames; +inline void reset() { capacity = starts = stops = driverCalls = 0; frames.clear(); } +} +class AudioOutputI2S : public AudioOutput { + public: + enum { EXTERNAL_I2S = 0, APLL_ENABLE = 1 }; + AudioOutputI2S(int, int, int, int) {} + bool SetPinout(int, int, int) { return true; } + bool begin() override { ++FakeI2S::starts; i2sOn = true; return true; } + bool stop() override { ++FakeI2S::stops; i2sOn = false; return true; } + bool ConsumeSample(int16_t sample[2]) override { + if (FakeI2S::capacity == 0) return false; + --FakeI2S::capacity; + FakeI2S::frames.push_back({sample[0], sample[1]}); + return true; + } + protected: + bool i2sOn = false; + bool mono = false; + void *_tx_handle = this; +}; +inline int i2s_channel_write(void *, const void *src, size_t bytes, size_t *written, uint32_t) { + ++FakeI2S::driverCalls; + const auto *data = static_cast(src); + *written = 0; + while (*written + 4 <= bytes && FakeI2S::capacity > 0) { + const uint8_t *p = data + *written; + FakeI2S::frames.push_back({static_cast(p[0] | uint16_t(p[1]) << 8), + static_cast(p[2] | uint16_t(p[3]) << 8)}); + --FakeI2S::capacity; *written += 4; + } + return *written == bytes ? 0 : -1; +} diff --git a/test/support/arduino_stubs.cpp b/test/support/arduino_stubs.cpp index cd89a14..b96ca2f 100644 --- a/test/support/arduino_stubs.cpp +++ b/test/support/arduino_stubs.cpp @@ -17,3 +17,5 @@ void testSetMillis(unsigned long value) { void testAdvanceMillis(unsigned long delta) { gNowMs += delta; } + +unsigned long micros() { return gNowMs * 1000UL; } diff --git a/test/support/freertos/FreeRTOS.h b/test/support/freertos/FreeRTOS.h index 323dee9..fe98ca0 100644 --- a/test/support/freertos/FreeRTOS.h +++ b/test/support/freertos/FreeRTOS.h @@ -19,3 +19,8 @@ typedef uint32_t TickType_t; #ifndef pdMS_TO_TICKS #define pdMS_TO_TICKS(ms) (static_cast(ms)) #endif + +using portMUX_TYPE = int; +#define portMUX_INITIALIZER_UNLOCKED 0 +inline void portENTER_CRITICAL(portMUX_TYPE *) {} +inline void portEXIT_CRITICAL(portMUX_TYPE *) {} diff --git a/test/support/freertos/semphr.h b/test/support/freertos/semphr.h new file mode 100644 index 0000000..42fb86a --- /dev/null +++ b/test/support/freertos/semphr.h @@ -0,0 +1,7 @@ +#pragma once +#include "FreeRTOS.h" +using SemaphoreHandle_t = int *; +inline SemaphoreHandle_t xSemaphoreCreateMutex() { return new int(0); } +inline BaseType_t xSemaphoreTake(SemaphoreHandle_t, TickType_t) { return pdTRUE; } +inline void xSemaphoreGive(SemaphoreHandle_t) {} +inline void vSemaphoreDelete(SemaphoreHandle_t mutex) { delete mutex; } diff --git a/test/test_audio_playback/test_main.cpp b/test/test_audio_playback/test_main.cpp new file mode 100644 index 0000000..b9fc937 --- /dev/null +++ b/test/test_audio_playback/test_main.cpp @@ -0,0 +1,264 @@ +#include +#include +#include +#include +#include +#include "../support/arduino_stubs.cpp" +#include "AudioGeneratorWAV.cpp" +#include "../../src/wav_validation.cpp" +#include "../../src/validated_wav_source.cpp" +#include "../../src/sample_library.cpp" +#include "../../src/stream_manager.cpp" +#include "../../src/sampler_mixer.cpp" +#include "../../src/budgeted_audio_output.cpp" +#include "../../src/audio_output_chain.cpp" +#include "../../src/audio_voice_engine.cpp" +#include "../../src/audio.cpp" + +namespace { +using Pcm = std::vector; +constexpr int kDelay = AudioInternal::SamplerMixer::kLookaheadSamples + 1; // Decoder's initial pending zero. +Pcm tone(int n, double hz, double amplitude) { + Pcm pcm(n); + for (int i = 0; i < n; ++i) { + // Smooth edges remove clicks already present in the fixture itself. + const double edge = std::min(1.0, std::max(0.0, std::min((i - 64) / 256.0, (n - 65 - i) / 256.0))); + const double envelope = 0.5 - 0.5 * std::cos(3.141592653589793 * edge); + pcm[i] = std::lround(amplitude * envelope * std::sin(i * 6.283185307179586 * hz / 44100)); + } + return pcm; +} +void saveWav(const char *path, const Pcm &pcm) { + auto &bytes = FakeSD::files[path]; bytes.resize(44 + pcm.size() * 2); + WavValidation::pcmHeader(bytes.data(), pcm.size() * 2); + for (size_t i = 0; i < pcm.size(); ++i) { bytes[44+2*i] = pcm[i]; bytes[45+2*i] = uint16_t(pcm[i]) >> 8; } +} +struct Rig { + SampleLibrary::Catalog catalog; + Audio audio; + Rig() { FakeI2S::reset(); testSetMillis(0); } + void begin() { SampleLibrary::loadFromSd(catalog); audio.setSampleCatalog(&catalog); TEST_ASSERT_TRUE(audio.begin()); } + void start(const Pcm &pcm, bool sd, const char *path, int group = -1) { + if (sd) audio.playSamplePath(path, 100, group); + else TEST_ASSERT_TRUE(audio.playSampleRam(reinterpret_cast(pcm.data()), pcm.size()*2, 1, 44100, 16, 100, group)); + } + void advance(int frames, bool blocked = false) { + const size_t end = FakeI2S::frames.size() + frames; + int calls = 0; + while (FakeI2S::frames.size() < end && calls < frames * 20 + 100) { + const int batch = blocked ? (calls % 5 == 0 ? 0 : 1 + (calls * 17) % 97) : 1; + FakeI2S::capacity = std::min(size_t(batch), end - FakeI2S::frames.size()); + audio.update(); + testSetMillis(FakeI2S::frames.size() * 1000 / 44100); + ++calls; + } + TEST_ASSERT_EQUAL_UINT(end, FakeI2S::frames.size()); + } +}; +int maximumPcmError(const Pcm &reference, const Pcm &candidate) { + if (reference.size() != candidate.size()) return 65536; + int worst = 0; + for (size_t i = 0; i < reference.size(); ++i) + worst = std::max(worst, std::abs(int(candidate[i]) - reference[i])); + return worst; +} +void assertReference(const Pcm &reference) { + TEST_ASSERT_GREATER_OR_EQUAL_UINT(reference.size() + kDelay, FakeI2S::frames.size()); + Pcm actual; + for (size_t i = 0; i < reference.size(); ++i) actual.push_back(FakeI2S::frames[i+kDelay][0]); + const bool matches = maximumPcmError(reference, actual) <= 1; + for (size_t i = 0; i < reference.size(); ++i) { + if (!matches && std::abs(actual[i] - reference[i]) > 1) { + char message[160]; std::snprintf(message, sizeof(message), "PCM differs at frame %zu: expected %d, got %d", i, reference[i], FakeI2S::frames[i+kDelay][0]); + TEST_FAIL_MESSAGE(message); + } + TEST_ASSERT_EQUAL_INT(FakeI2S::frames[i+kDelay][0], FakeI2S::frames[i+kDelay][1]); + } + TEST_ASSERT_EQUAL_INT(1, FakeI2S::starts); TEST_ASSERT_EQUAL_INT(0, FakeI2S::stops); +} +void test_single_and_simultaneous_voices_match_pcm_through_eof() { + for (bool sd : {false, true}) for (bool blocked : {false, true}) for (int voices : {1, 2, 8}) { + FakeSD::files.clear(); std::vector pcm; Pcm sum(6000, 0); + for (int v = 0; v < voices; ++v) { + pcm.push_back(tone(4097 + v*113, 137 + v*73, 2500)); + for (size_t i = 0; i < pcm.back().size(); ++i) sum[i] += pcm.back()[i]; + saveWav(("/samples/"+std::to_string(v)+".wav").c_str(), pcm.back()); + } + Rig rig; rig.begin(); + for (int v = 0; v < voices; ++v) rig.start(pcm[v], sd, ("/samples/"+std::to_string(v)+".wav").c_str()); + rig.advance(sum.size()+kDelay, blocked); assertReference(sum); + TEST_ASSERT_EQUAL_INT(0, rig.audio.runtimeStats().activeVoices); + } +} +void test_staggered_mixed_ram_sd_voices_preserve_timeline() { + for (bool blocked : {false, true}) for (int offset : {1, 63, 96, 511, 977, 3000}) { + FakeSD::files.clear(); const auto a = tone(4201, 173, 9000), b = tone(2707, 431, 7000); + saveWav("/samples/b.wav", b); Rig rig; rig.begin(); + rig.start(a, false, ""); rig.advance(offset, blocked); rig.start(b, true, "/samples/b.wav"); + Pcm expected(8000, 0); + for (size_t i = 0; i < a.size(); ++i) expected[i] += a[i]; + for (size_t i = 0; i < b.size(); ++i) expected[i+offset] += b[i]; + rig.advance(expected.size()+kDelay-offset, blocked); assertReference(expected); + } +} +void test_retrigger_matches_independent_fade_and_new_voice() { + for (bool sd : {false, true}) for (bool blocked : {false, true}) { + FakeSD::files.clear(); const auto pcm = tone(7003, 197, 10000); saveWav("/samples/a.wav", pcm); + constexpr int trigger = 2003, length = 11000; + Pcm faded; + { // Render the old occurrence's explicitly requested 6 ms fade in isolation. + Rig rig; rig.begin(); rig.start(pcm, sd, "/samples/a.wav"); rig.advance(trigger, blocked); + rig.audio.fadeOutAllVoices(6000); rig.advance(length+kDelay-trigger, blocked); + for (int i = 0; i < length; ++i) faded.push_back(FakeI2S::frames[i+kDelay][0]); + } + Rig rig; rig.begin(); rig.start(pcm, sd, "/samples/a.wav", 12); rig.advance(trigger, blocked); + rig.start(pcm, sd, "/samples/a.wav", 12); + TEST_ASSERT_EQUAL_INT(2, rig.audio.runtimeStats().activeVoices); + rig.advance(length+kDelay-trigger, blocked); + for (size_t i = 0; i < pcm.size(); ++i) faded[i+trigger] += pcm[i]; + assertReference(faded); TEST_ASSERT_EQUAL_INT(0, rig.audio.runtimeStats().activeVoices); + } +} +void test_slot_reuse_after_natural_end_has_no_stale_pcm() { + FakeSD::files.clear(); const auto a = tone(911, 233, 11000), b = tone(1407, 317, 8000); + saveWav("/samples/b.wav", b); Rig rig; rig.begin(); Pcm expected(12000, 0); + for (int t : {0, 2200, 4400, 6600, 8800}) { + if (t > 0) rig.advance(t - FakeI2S::frames.size(), true); + rig.start(a, false, "", 10); rig.start(b, true, "/samples/b.wav", 11); + for (size_t i = 0; i < a.size(); ++i) expected[t+i] += a[i]; + for (size_t i = 0; i < b.size(); ++i) expected[t+i] += b[i]; + } + rig.advance(expected.size()+kDelay-FakeI2S::frames.size(), true); assertReference(expected); + TEST_ASSERT_EQUAL_UINT(0, rig.audio.voiceStealCount()); +} + +int largestStep(size_t first, size_t end) { + int step = 0; + for (size_t i = std::max(size_t(1), first); i < end; ++i) + step = std::max(step, std::abs(int(FakeI2S::frames[i][0])-FakeI2S::frames[i-1][0])); + return step; +} +void test_nonzero_start_is_ramped_without_reducing_sustain() { + FakeSD::files.clear(); Pcm pcm(1103, 12000); Rig rig; rig.begin(); rig.start(pcm, false, ""); + rig.advance(1600, true); + TEST_ASSERT_LESS_THAN(900, largestStep(0, 150)); + TEST_ASSERT_EQUAL_INT(12000, FakeI2S::frames[600][0]); +} +void test_nonzero_eof_is_ramped_without_cutting_other_voice() { + FakeSD::files.clear(); Pcm pcm(1103, 12000); auto background = tone(3001, 173, 4000); + saveWav("/samples/a.wav", pcm); Rig rig; rig.begin(); + rig.start(background, false, ""); rig.start(pcm, true, "/samples/a.wav"); rig.advance(3400, true); + TEST_ASSERT_LESS_THAN(1000, largestStep(1000, 1250)); + for (int i = 1200; i < 3000; ++i) TEST_ASSERT_EQUAL_INT(background[i], FakeI2S::frames[i+kDelay][0]); +} +void test_nonzero_retrigger_start_does_not_click_over_old_tail() { + FakeSD::files.clear(); Pcm pcm(3001, 12000); Rig rig; rig.begin(); rig.start(pcm, false, "", 7); + rig.advance(1103, true); rig.start(pcm, false, "", 7); rig.advance(3500, true); + TEST_ASSERT_LESS_THAN(900, largestStep(1000, 2000)); +} + +std::vector> renderOverload(bool blocked, int voices) { + FakeSD::files.clear(); std::vector pcm; + for (int v = 0; v < voices; ++v) { + pcm.push_back(tone(4801 + v * 31, 137 + (v % 5) * 53, 29000)); + saveWav(("/samples/"+std::to_string(v)+".wav").c_str(), pcm.back()); + } + Rig rig; rig.begin(); + for (int v = 0; v < voices; ++v) { + const int when = v * 79; + rig.advance(when - FakeI2S::frames.size(), blocked); + rig.start(pcm[v], v % 2, ("/samples/"+std::to_string(v)+".wav").c_str()); + } + rig.advance(12000 + kDelay - FakeI2S::frames.size(), blocked); + TEST_ASSERT_EQUAL_INT(0, rig.audio.runtimeStats().activeVoices); + TEST_ASSERT_EQUAL_INT(0, FakeI2S::stops); + double previousGain = 1; bool previousKnown = false; int limited = 0; + for (int i = 0; i < 12000; ++i) { + int wide = 0; + for (int v = 0; v < voices; ++v) { const int at = i - v * 79; if (at >= 0 && at < int(pcm[v].size())) wide += pcm[v][at]; } + const int out = FakeI2S::frames[i+kDelay][0]; + TEST_ASSERT_EQUAL_INT(out, FakeI2S::frames[i+kDelay][1]); + TEST_ASSERT_INT_WITHIN(32767, 0, out); + TEST_ASSERT_TRUE(std::abs(out) <= std::abs(wide)+1); + TEST_ASSERT_TRUE(wide >= 0 ? out >= 0 : out <= 0); + if (std::abs(wide) > 32767) ++limited; + // Infer the common gain only away from zero crossings, where division is stable. + // A gain jump larger than the limiter's 32-frame attack cannot be valid; + // rounding error is at most 1/2000 here. This catches dropouts/spikes under limiting. + const bool known = std::abs(wide) >= 2000; + if (known) { + const double gain = double(out) / wide; + if (previousKnown) TEST_ASSERT_TRUE(std::abs(gain - previousGain) < 0.033); + previousGain = gain; + } + previousKnown = known; + } + TEST_ASSERT_GREATER_THAN(100, limited); + return FakeI2S::frames; +} +void test_overloaded_staggered_voices_have_continuous_gain_and_survive_backpressure() { + for (int voices : {2, 8, 32}) { + const auto reference = renderOverload(false, voices), blocked = renderOverload(true, voices); + TEST_ASSERT_EQUAL_UINT(reference.size(), blocked.size()); + for (size_t i = 0; i < reference.size(); ++i) TEST_ASSERT_EQUAL_MEMORY(reference[i].data(), blocked[i].data(), 4); + } +} +void test_continuity_oracle_rejects_drop_repeat_zero_and_spike() { + // Negative controls: a broken transport can stay below full scale and still click. + const auto pcm = tone(3001, 317, 12000); + auto error = [&](const Pcm &candidate) { return maximumPcmError(pcm, candidate); }; + TEST_ASSERT_EQUAL_INT(0, error(pcm)); + auto broken = pcm; broken[1001] = 0; TEST_ASSERT_GREATER_THAN(1, error(broken)); + broken = pcm; broken[1001] += 500; TEST_ASSERT_GREATER_THAN(1, error(broken)); + broken = pcm; broken.insert(broken.begin()+1001, broken[1000]); broken.pop_back(); TEST_ASSERT_GREATER_THAN(1, error(broken)); + broken = pcm; broken.erase(broken.begin()+1001); broken.push_back(0); TEST_ASSERT_GREATER_THAN(1, error(broken)); +} + +void test_short_and_full_scale_edges_are_identical_under_retries() { + for (int length : {1, 2, 17, 34, 35, 69, 70, 97, 511, 1501}) for (int level : {-32767, 32767}) { + std::vector> reference; + for (bool blocked : {false, true}) { + FakeSD::files.clear(); Pcm pcm(length, level); saveWav("/samples/edge.wav", pcm); + Rig rig; rig.begin(); rig.start(pcm, true, "/samples/edge.wav"); rig.advance(length+kDelay+200, blocked); + TEST_ASSERT_LESS_THAN(1500, largestStep(0, FakeI2S::frames.size())); + TEST_ASSERT_EQUAL_INT(0, rig.audio.runtimeStats().activeVoices); + for (size_t i = length+kDelay; i < FakeI2S::frames.size(); ++i) TEST_ASSERT_EQUAL_INT(0, FakeI2S::frames[i][0]); + if (!blocked) reference = FakeI2S::frames; + else for (size_t i = 0; i < reference.size(); ++i) TEST_ASSERT_EQUAL_MEMORY(reference[i].data(), FakeI2S::frames[i].data(), 4); + } + } +} +void test_retrigger_fade_lasts_six_ms_after_queued_audio() { + // Constant PCM exposes the envelope directly, without dividing around tone zero crossings. + FakeSD::files.clear(); Pcm pcm(4001, 12000); Rig rig; rig.begin(); rig.start(pcm, false, "", 4); + rig.advance(1301, true); rig.audio.fadeOutAllVoices(AudioInternal::kRetriggerFadeOutUs); rig.advance(3000, true); + size_t start = 1301; + while (start < FakeI2S::frames.size() && FakeI2S::frames[start][0] == 12000) ++start; + // The already queued PCM is preserved; the fade begins at the writer frontier. + TEST_ASSERT_LESS_THAN(1301 + 512 + kDelay, start); + size_t end = start; + while (end < FakeI2S::frames.size() && FakeI2S::frames[end][0] != 0) { + TEST_ASSERT_TRUE(FakeI2S::frames[end][0] <= FakeI2S::frames[end-1][0]); ++end; + } + TEST_ASSERT_INT_WITHIN(1, 265, end-start); // ceil(44100 * 0.006) + TEST_ASSERT_LESS_THAN(48, largestStep(start, end+1)); // ceil(12000/265) plus PCM rounding. +} + +} +void setUp() {} void tearDown() {} +int main() { UNITY_BEGIN(); + RUN_TEST(test_single_and_simultaneous_voices_match_pcm_through_eof); + RUN_TEST(test_staggered_mixed_ram_sd_voices_preserve_timeline); + RUN_TEST(test_retrigger_matches_independent_fade_and_new_voice); + RUN_TEST(test_slot_reuse_after_natural_end_has_no_stale_pcm); + + RUN_TEST(test_nonzero_start_is_ramped_without_reducing_sustain); + RUN_TEST(test_nonzero_eof_is_ramped_without_cutting_other_voice); + RUN_TEST(test_nonzero_retrigger_start_does_not_click_over_old_tail); + + RUN_TEST(test_overloaded_staggered_voices_have_continuous_gain_and_survive_backpressure); + RUN_TEST(test_continuity_oracle_rejects_drop_repeat_zero_and_spike); + + RUN_TEST(test_short_and_full_scale_edges_are_identical_under_retries); + RUN_TEST(test_retrigger_fade_lasts_six_ms_after_queued_audio); + return UNITY_END(); } diff --git a/test/test_codec_es8388/test_main.cpp b/test/test_codec_es8388/test_main.cpp index 500b9cd..913639b 100644 --- a/test/test_codec_es8388/test_main.cpp +++ b/test/test_codec_es8388/test_main.cpp @@ -1,6 +1,7 @@ #include #include "Arduino.h" #include "pins.h" +#include "../support/arduino_stubs.cpp" namespace { int amplifierState = LOW; @@ -97,8 +98,19 @@ void test_playback_never_enables_inputs_or_speaker_amplifiers() { } } +void test_init_rejects_unapplied_output_level() { + for (int reg : {0x1A, 0x1B, 0x2E, 0x2F, 0x30, 0x31}) { + gCodecWire = TwoWire(1); + gCodecWire.registers[reg] = 0x01; + gCodecWire.ignoredWriteRegister = reg; + TEST_ASSERT_FALSE(CodecES8388::init()); + TEST_ASSERT_FALSE(CodecES8388::unmute()); + } +} + int main() { UNITY_BEGIN(); + RUN_TEST(test_init_rejects_unapplied_output_level); RUN_TEST(test_playback_never_enables_inputs_or_speaker_amplifiers); RUN_TEST(test_startup_preserves_control_bits_while_muted); RUN_TEST(test_unmute_preserves_profile_and_can_be_repeated); diff --git a/test/test_i2s_transport/test_main.cpp b/test/test_i2s_transport/test_main.cpp new file mode 100644 index 0000000..909a363 --- /dev/null +++ b/test/test_i2s_transport/test_main.cpp @@ -0,0 +1,106 @@ +#include +#include +#include +#include +// Exercise the ESP32 branch of our actual I2S adapter against the driver fake. +#define ESP32 1 +#include "../support/arduino_stubs.cpp" +#include "AudioGeneratorWAV.cpp" +#include "../../src/audio_output_chain.cpp" +#include "../../src/pcm_block_buffer.cpp" +using AudioInternal::PcmBlockBuffer; +namespace { +struct Writer { + std::vector bytes; + size_t allowance = 1000000, calls = 0; + static size_t write(void *p, const uint8_t *data, size_t length) { + auto &w = *static_cast(p); ++w.calls; + const size_t n = std::min(length, w.allowance); + w.bytes.insert(w.bytes.end(), data, data+n); return n; + } +}; +void test_esp32_adapter_packs_full_level_pcm_without_extra_attenuation() { + FakeI2S::reset(); + AudioInternal::StableAudioOutputI2S out(0, 0, 8, 1); + TEST_ASSERT_TRUE(out.SetGain(1)); TEST_ASSERT_TRUE(out.SetChannels(2)); TEST_ASSERT_TRUE(out.begin()); + FakeI2S::capacity = 128; + for (int i = 0; i < 128; ++i) { + int16_t frame[2] = {32767, -32767}; + TEST_ASSERT_TRUE(out.ConsumeSample(frame)); + } + TEST_ASSERT_EQUAL_INT(1, FakeI2S::driverCalls); + TEST_ASSERT_EQUAL_UINT(128, FakeI2S::frames.size()); + for (auto frame : FakeI2S::frames) { + TEST_ASSERT_EQUAL_INT(32767, frame[0]); TEST_ASSERT_EQUAL_INT(-32767, frame[1]); + } +} +void test_one_driver_call_per_dma_block_preserves_signed_stereo_pcm() { + PcmBlockBuffer block; Writer w; std::vector expected; + for (int i = 0; i < 4096; ++i) { + int16_t s[2] = {static_cast(i*31), static_cast(-i*19)}; + for (auto v:s) { expected.push_back(uint16_t(v)&255); expected.push_back(uint16_t(v)>>8); } + TEST_ASSERT_TRUE(block.consume(s, Writer::write, &w)); + } + TEST_ASSERT_EQUAL_UINT(32, w.calls); TEST_ASSERT_EQUAL_UINT(0, block.pendingBytes()); + TEST_ASSERT_EQUAL_MEMORY(expected.data(), w.bytes.data(), expected.size()); +} +void test_short_writes_and_timeouts_retain_exact_byte_suffix() { + Writer reference, actual; PcmBlockBuffer first, second; + for (int i = 0; i < 512; ++i) { + int16_t s[2] = {static_cast(i*53), static_cast(-i*23)}; + TEST_ASSERT_TRUE(first.consume(s, Writer::write, &reference)); + actual.allowance = 0; + bool accepted = second.consume(s, Writer::write, &actual); + if (!accepted) { + for (int retry=0; retry<8; ++retry) TEST_ASSERT_FALSE(second.consume(s, Writer::write, &actual)); + actual.allowance = 7; // Also test an incomplete stereo frame, not just whole frames. + int attempts = 0; + while (!accepted && ++attempts < 100) accepted = second.consume(s, Writer::write, &actual); + TEST_ASSERT_TRUE(accepted); + } + } + // Continuous idle silence submits the final block while retaining the new frame. + actual.allowance = 1000000; int16_t silence[2] = {}; + TEST_ASSERT_TRUE(second.consume(silence, Writer::write, &actual)); + TEST_ASSERT_EQUAL_UINT(reference.bytes.size(), actual.bytes.size()); + TEST_ASSERT_EQUAL_MEMORY(reference.bytes.data(), actual.bytes.data(), actual.bytes.size()); +} +struct ClockedDma { + double timeUs = 0, nextFrameUs = 1000000.0/44100; + size_t queued = 1024, underruns = 0, calls = 0; + void advance(double us) { + timeUs += us; + while (nextFrameUs <= timeUs) { + if (queued) --queued; else ++underruns; + nextFrameUs += 1000000.0/44100; + } + } + static size_t write(void *context, const uint8_t *, size_t bytes) { + auto &dma = *static_cast(context); ++dma.calls; dma.advance(6); + const size_t n = std::min(bytes/4, 1024-dma.queued); dma.queued += n; return n*4; + } +}; +void test_clock_keeps_running_during_processing_and_driver_calls() { + // Explicit synthetic timing model, NOT an ESP32 speed measurement: + // processing 19 us/frame + driver overhead 6 us/call. At 44.1 kHz the + // old one-call-per-frame path misses deadlines although every write succeeds. + ClockedDma single, batched; PcmBlockBuffer buffer; + for (int i = 0; i < 30000; ++i) { + int16_t s[2] = {1000, -1000}; + single.advance(19); ClockedDma::write(&single, reinterpret_cast(s), 4); + batched.advance(19); + while (!buffer.consume(s, ClockedDma::write, &batched)) {} + } + TEST_ASSERT_GREATER_THAN(1000, single.underruns); // Negative control for the clock-aware oracle. + TEST_ASSERT_EQUAL_UINT(0, batched.underruns); +} +} +void setUp() {} void tearDown() {} +int main() { + UNITY_BEGIN(); + RUN_TEST(test_esp32_adapter_packs_full_level_pcm_without_extra_attenuation); + RUN_TEST(test_one_driver_call_per_dma_block_preserves_signed_stereo_pcm); + RUN_TEST(test_short_writes_and_timeouts_retain_exact_byte_suffix); + RUN_TEST(test_clock_keeps_running_during_processing_and_driver_calls); + return UNITY_END(); +} diff --git a/todo.md b/todo.md index 114e349..30cf80b 100644 --- a/todo.md +++ b/todo.md @@ -16,10 +16,15 @@ Findings from source review and targeted host-side probes of the locally install - [x] **Limit the wide sum before PCM16 conversion and remove excessive fixed attenuation.** `SamplerMixer` now uses float summation, 32 inputs and a linked look-ahead limiter. Regression tests cover unity solo level, quiet overlaps, wide peaks, phase cancellation, 32 full-scale voices, attack/release and rejected sink writes. Voice input gains no longer use 1/64 quantization or active-count gain steps. Mixer reuse aligns to the current frame or its remaining queued tail. Fade budgets/envelopes and waveform capture advance only on accepted writes. - **Remaining hardware verification:** analog headroom (including inter-sample peaks and transformer saturation), perceived limiter pumping, SD/CPU throughput with many voices, and end/retrigger artifacts. The digital ceiling is not an oversampled true-peak guarantee. Per-voice fade-in still uses the existing control-time envelope; only fade-out and master limiting are advanced per accepted sample. + **Remaining hardware verification:** analog headroom (including inter-sample peaks and transformer saturation), perceived limiter pumping, SD/CPU throughput with many voices, and end/retrigger artifacts. The digital ceiling is not an oversampled true-peak guarantee. File boundary ramps, explicit fade-out and master limiting advance per accepted sample. -- [ ] **Make sample starts, natural endings, and loop boundaries click-safe.** - Both playback entry points in `src/audio.cpp` set `fadeInUs` to zero, natural EOF has no fade-out, and `restartVoiceLoop()` closes and reopens the source without a crossfade. Resetting `lastSample` also inserts a zero sample at each decoder restart. Add suitable start/end envelopes and continuous loop handling with an optional short crossfade. Preserve intentional percussion attacks. Test nonzero endpoints, DC-offset samples, seamless loops, and repeated triggers. +- [x] **Add playback continuity regression coverage and ramp file boundaries.** + `test_audio_playback` runs the production decoder, RAM/SD source adapters, voice engine, fades and mixer. It covers single/simultaneous/staggered voices, same-sample retriggers, source-slot reuse, output backpressure, short samples and full-scale boundaries. Three tests reproduced abrupt start/EOF/retrigger steps (about 12000 PCM units for a 12000-level fixture); 35-frame smoothstep boundary ramps now pass them. A separate 6 ms fade test caught the final Q15 remainder drop; distributing that remainder removes the extra step. The retrigger policy remains a 6 ms fade of older voices. See [test details](docs/audio-regression.md). + + **Hardware feedback (2026-09-10):** the user confirmed that the reported overlapping-sample interruption disappeared after flashing the subsequent 128-frame I2S block-write firmware. The boundary tests remain separate regression coverage; broader SD/I2S timing and CPU headroom measurements remain open. + +- [ ] **Make loop restarts continuous and handle voice stealing without abrupt cuts.** + File edges now fade, but `restartVoiceLoop()` still closes/reopens sources and the decoder emits an initial zero on each restart. Boundary ramps prevent an instantaneous step but can cause a short amplitude dip; they do not implement a seamless crossfade or hide SD open latency. At full 32-voice occupancy, replacing the oldest voice can still cut a nonzero signal. Test seamless loops and repeated triggers at saturation before changing these policies. - [ ] **Move SD reads out of the time-critical playback path and measure underruns.** `StreamManager::BufferedSdSource` performs synchronous SD reads and has no read-ahead buffer; the local WAV decoder reads 128-byte chunks. File opening also runs in the audio task; WAV headers now come from the boot validation cache. Add a dedicated reader task and per-stream ring buffers, distinguish temporary starvation from EOF, and expose buffer occupancy and underrun counters. Stress-test multiple streams and repeated file starts on hardware; compare with RAM playback. @@ -29,3 +34,9 @@ Findings from source review and targeted host-side probes of the locally install - [ ] **Review ES8388 startup sequencing, register definitions, and analog gain.** Startup now establishes I2S and digital silence before unmuting (startup hum fix confirmed on hardware), and DAC Control 3 preserves its default control bits. Remaining review: analog outputs now use `0x1E` (0 dB) with full-level digital mixing; verify available analog headroom with the actual transformer and mixer load. Choose analog levels based on measured headroom. Measure startup pops, idle noise, and distortion at several output levels, including the transformer path. Reference: [ES8388 datasheet](https://www.boardcon.com/download/ES8388_datasheet.pdf). + +- [x] **Resolve the reported interruption during overlapping playback.** + The user's `sample-test.wav` showed periodic discontinuities and increasing drum timeline lag during overlap. Switching to 128-frame I2S writes reduced per-frame driver overhead. On 2026-09-10, the user flashed the firmware and confirmed that the interruption disappeared. Transport regressions include a continuously advancing simulated clock; all 64 native tests and the ESP32 build passed. This confirms the reported case, not a measured maximum polyphony or SD throughput guarantee. Details: [recording investigation](docs/measurements/sample-test-2026-09-09.md). + +- [ ] **Investigate low output level on hardware.** + `amen.wav` peaks at -0.08 dBFS; its recorded solo peaks at -28.42 dBFS despite the reported +40 mixer gain (ZEDi-10, JACK/Reaper at 0 dB). Firmware reads back DAC/analog volume registers at boot. Analog level localization remains open; the user's confirmation about uninterrupted playback does not establish that the level issue is resolved.