diff --git a/README.md b/README.md index a6e5cac..6106dc6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ For a walkthrough with screen photos, see the [musician's manual](docs/manual.md ## Technical Details -Samplotron uses an ESP32 with PSRAM, built with the `esp-wrover-kit` PlatformIO board configuration, and an ES8388 audio codec. It plays mono PCM16 WAV files at 44.1 kHz through a shared 32-voice engine. Short assigned samples can be preloaded into RAM; longer samples stream from SD. +Samplotron uses an ESP32 with PSRAM, built with the `esp-wrover-kit` PlatformIO board configuration, and an ES8388 audio codec. It plays mono PCM16 WAV files at 44.1 kHz through a shared 32-voice engine. Assigned samples are preloaded into PSRAM when they fit; the rest stream from SD. ### Controls @@ -51,7 +51,7 @@ The keypad sends notes `36..51` in the measured physical key order and uses the ### How playback works -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. +At startup, the firmware scans `/samples`, loads saved assignments, and prepares eligible samples in RAM. The RAM pool is sized from free PSRAM at startup (most of a 4 MB module, several tens of seconds of mono audio), and there is no per-sample length limit. If the assigned samples do not all fit, the shortest are preloaded first. Other supported files stream from SD through a separate reader task, with their first 16 KiB kept in RAM so they start without delay; failed preloads also fall back to streaming. Serial output reports how many samples were loaded and how long it took. 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. 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. @@ -90,7 +90,7 @@ The separate L/R speaker terminals carry a switching, speaker-level signal from 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. +No configuration file is required for first boot; without one, the device starts with no assignments and one-shot playback. On Linux, install FFmpeg using your distribution's package manager (for example, `sudo apt install ffmpeg` on Debian/Ubuntu). Run this in Bash, setting `samples_dir` to the directory containing your WAV files: @@ -121,7 +121,7 @@ Run it on copies of your source recordings: it replaces WAV files in place, conv Display orientation is configured by `DisplayConfig::ROTATE_180` in [include/display_config.h](include/display_config.h). It defaults to `true` (180° rotation); set it to `false` for the original orientation. Rebuild and upload the firmware after changing it. This applies to all screens in the main firmware and the input diagnostic firmware. -The on-device `SAVE` action writes `/sampler_config.json`. For manual configuration, including the RAM budget and panic note, see the [configuration format](docs/documentation.md#5-sampler_configjson-configuration). Changing the RAM budget requires a reboot. +The on-device `SAVE` action writes `/sampler_config.json`. For manual configuration, including the panic note, see the [configuration format](docs/documentation.md#5-sampler_configjson-configuration). The main firmware prints keypad initialization and key-press diagnostics at 115200 baud. Dedicated firmware environments are available for testing encoders and MIDI input: diff --git a/docs/documentation.md b/docs/documentation.md index 875928b..5d206ec 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -141,7 +141,7 @@ Before playback tasks start, `SampleLibrary::loadFromSd()` validates every colle `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. +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 (`StreamManager`) supplies a canonical 44-byte header from memory and exposes only the cached PCM range, avoiding on-disk header parsing at each trigger or loop restart. An `sd_reader` task on core 0 opens files, checks that the cached range still fits, and fills a 16 KiB ring buffer (about 185 ms) per stream in PSRAM, serving the emptiest stream first; the audio task on core 1 only copies buffered PCM. Up to 16 SD streams can play at once; a further SD trigger is dropped. The first 16 KiB (about 185 ms) of every assigned sample that streams is preloaded into the RAM pool, so it starts immediately while the reader opens the file and reads on from there; loops replay that start from RAM. Samples without a preloaded start (library previews, unsaved assignments) begin with silence until their first 2 KiB arrives (roughly 10–30 ms at 4 MHz). If a stream runs dry, only that voice receives silence for the affected update; other voices keep playing. Looped SD samples keep their file open and the reader continues into the next iteration, so restarts need no SD access. 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. @@ -155,7 +155,6 @@ Minimal format: { "version": "1.0", "global_settings": { - "sample_ram_budget_bytes": 1048576, "panic_note": 24 }, "midi_assignments": [ @@ -178,7 +177,8 @@ Notes: - `volume = 100`: unity per-voice gain; a single sample keeps its original level (no automatic normalization of quiet WAV files) - `sample_path`: full SD path, for example `/samples/snare.wav` - maximum assignments in the settings structure: `128`; the UI catalog holds at most `32` samples and assigns each sample to one note -- without a readable configuration, loading begins from defaults: no assignments, no panic note, a 1 MiB RAM budget, and one-shot playback +- without a readable configuration, loading begins from defaults: no assignments, no panic note, and one-shot playback +- `sample_ram_budget_bytes`, written by older firmware, is ignored; the RAM pool is sized automatically (section 6) The writer also saves `sample_playback_modes`, an array of `sample_path` / `playback_mode` objects. The UI save flow includes library samples set to `loop`, including those without note assignments; omitted unassigned samples use the default `shot` mode. Assigned sample volumes are saved in `midi_assignments`; unassigned preview volumes are not persisted. @@ -194,8 +194,11 @@ Sample preparation pipeline: - `16-bit`, - `44100 Hz`, - `mono`, - - duration `<= 5.0 s`, - - fit into the RAM budget, + - fit into the RAM budget; there is no per-sample duration limit, +- the budget is the largest free PSRAM block at the first preparation minus a 512 KiB reserve (`SampleRamManager::budgetBytes()`); without PSRAM it falls back to 1 MiB, +- the first 16 KiB of every assigned sample is reserved before packing (`SampleClassifier::kStreamHeadBytes`); samples that end up streaming keep that start in RAM so they begin without waiting for SD. If even these starts do not fit, none are reserved, +- when the assigned samples do not all fit, they are packed shortest first, so the fewest samples stream from SD, +- preload reads 32 KiB chunks; Serial reports the loaded count, size and time. At the 4 MHz SD fallback, a full pool takes several seconds to load at boot and on every `SAVE`, - if preload fails, the entry falls back to `STREAM`. - if assigned sample format is unsupported/missing, playback for that note is blocked (`UNAVAILABLE`) instead of trying to decode anyway. @@ -215,9 +218,7 @@ Playback engine behavior: Important behavior: -- RAM pool budget is "locked" after the first `prepare()` (`sample_ram_manager.cpp`), -- changing `sample_ram_budget_bytes` in the same runtime session is recorded in the preparation result as `fixedBudgetMismatch`, -- a real budget change requires a device reboot. +- the RAM pool is allocated once at the first `prepare()` and never resized (`sample_ram_manager.cpp`); later saves repack samples into the same pool. ### Mixing level policy @@ -281,13 +282,9 @@ Assignment rules: - Encoder detent: `4` ticks - Long press (right encoder): `700 ms` -### `include/sample_classifier.h` - -- RAM preload threshold: `kFixedPreloadThresholdSeconds = 5.0f` - ### `include/settings_store.h` -- Default RAM budget: `kDefaultSampleRamBudgetBytes = 1 MB` +- Fallback RAM budget without PSRAM: `kDefaultSampleRamBudgetBytes = 1 MB` ### `include/ui.h` @@ -396,7 +393,7 @@ The repository workflow runs native tests and builds the main firmware. Pushes t `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 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. +Main firmware serial output includes keypad diagnostics, WAV rejection reasons and codec volume-register verification at boot. It also reports the SD SPI clock the card mounted at (20 MHz, falling back to 10 or 4 MHz). During playback, a line starting with `Audio:` appears in any second with new I2S underruns or SD reads slower than 3 ms. Underruns make the DMA replay stale blocks, heard as stutter and stretched-sounding playback; a quiet log means playback kept up. 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/include/audio.h b/include/audio.h index 4a20282..f2c02f9 100644 --- a/include/audio.h +++ b/include/audio.h @@ -16,6 +16,21 @@ class Audio { uint32_t voiceStealCount = 0; }; + // Cumulative counters for diagnosing playback that cannot keep up. Safe to + // poll from another task; values may be a few updates stale. + struct StreamingDiagnostics { + uint32_t i2sUnderrunCount = 0; + // Voice updates that got silence because the SD reader fell behind. + uint32_t starvedUpdateCount = 0; + uint32_t sdReadCount = 0; + uint32_t sdBytesRead = 0; + uint32_t sdMaxReadUs = 0; + uint32_t sdMaxReadBytes = 0; + uint32_t sdOpenFailureCount = 0; + // SD triggers dropped because every stream buffer was in use. + uint32_t sdNoFreeStreamCount = 0; + }; + struct WaveformSnapshot { int8_t points[kWaveformPointCount] = {0}; uint16_t validPoints = 0; @@ -28,11 +43,17 @@ class Audio { void setSampleCatalog(const SampleLibrary::Catalog *catalog) { catalog_ = catalog; } bool begin(); + // Moves SD streaming reads to their own task. Until then, update() reads. + bool startStreamReader(uint8_t priority, int core); void update(); + // head/headBytes: optional preloaded start of the file's PCM, played from + // RAM while the SD reader catches up. void playSamplePath(const String &samplePath, uint8_t volume = 100, int16_t retriggerGroupId = -1, - bool loopEnabled = false); + bool loopEnabled = false, + const uint8_t *head = nullptr, + uint32_t headBytes = 0); void stopAllVoices(); void fadeOutAllVoices(uint32_t fadeOutUs); void stopLoopingVoicesForGroup(int16_t retriggerGroupId); @@ -47,6 +68,7 @@ class Audio { bool loopEnabled = false); RuntimeStats runtimeStats() const; uint32_t voiceStealCount() const; + StreamingDiagnostics streamingDiagnostics() const; bool waveformSnapshot(WaveformSnapshot &snapshot) const; private: diff --git a/include/audio_internal.h b/include/audio_internal.h index cb28151..5f8653d 100644 --- a/include/audio_internal.h +++ b/include/audio_internal.h @@ -79,6 +79,7 @@ class StableAudioOutputI2S : public AudioOutputI2S { public: StableAudioOutputI2S(int port, int outputMode, int dmaCount, int useApll); + bool begin() override; bool SetRate(int hz) override; bool ConsumeSample(int16_t sample[2]) override; @@ -86,12 +87,16 @@ class StableAudioOutputI2S : public AudioOutputI2S { uint32_t rateSetCalls() const; uint32_t skippedRateSetCalls() const; uint32_t appliedRateSetCalls() const; + // DMA blocks replayed because no new PCM arrived in time (ESP32 only). + uint32_t underrunCount() const; private: #ifdef ESP32 static size_t writeBlock(void *context, const uint8_t *data, size_t bytes); + static bool onSendQueueOverflow(i2s_chan_handle_t handle, i2s_event_data_t *event, void *context); PcmBlockBuffer block_; #endif + volatile uint32_t underrunCount_ = 0; int lastRateHz_ = -1; uint32_t rateSetCalls_ = 0; uint32_t skippedRateSetCalls_ = 0; @@ -125,6 +130,7 @@ struct VoiceState { FreshStartAudioGeneratorWAV *wav = nullptr; AudioFileSourceRamWav *ramSource = nullptr; AudioFileSource *activeSource = nullptr; + StreamManager::SdStream *stream = nullptr; // Set for StreamPath voices. SamplerMixerInput *stub = nullptr; BudgetedAudioOutput *budgetedOut = nullptr; float targetGain = 0.0f; // Per-voice gain from sample volume (0..1), before playback fades. @@ -174,6 +180,8 @@ int allocateVoiceSlot(EngineState *impl, int16_t retriggerGroupId, bool &voiceWa bool beginVoiceFromPath(EngineState *impl, int voiceIndex, const String &samplePath, + const uint8_t *head, + uint32_t headBytes, uint8_t volume, int16_t retriggerGroupId, bool loopEnabled, diff --git a/include/sample_classifier.h b/include/sample_classifier.h index 35e0bcd..737f6f1 100644 --- a/include/sample_classifier.h +++ b/include/sample_classifier.h @@ -9,7 +9,9 @@ namespace SampleLibrary { struct Catalog; } namespace SampleClassifier { -constexpr float kFixedPreloadThresholdSeconds = 5.0f; +// Start of each streamed sample kept in RAM (~185 ms), so playback begins +// at once while the SD reader opens the file and catches up. +constexpr uint32_t kStreamHeadBytes = 16 * 1024; constexpr uint32_t kRequiredSampleRate = 44100; constexpr uint16_t kRequiredChannelCount = 1; constexpr uint16_t kRequiredBitsPerSample = 16; @@ -33,6 +35,7 @@ struct AssignedSampleClassification { uint32_t dataOffset = 0; float durationSeconds = 0.0f; StorageMode mode = StorageMode::ReadError; + uint32_t headBytes = 0; // Stream only: bytes preloaded from the start. }; struct ClassificationReport { diff --git a/include/sample_ram_manager.h b/include/sample_ram_manager.h index 04ee43e..ccc0d81 100644 --- a/include/sample_ram_manager.h +++ b/include/sample_ram_manager.h @@ -25,17 +25,25 @@ struct LoadReport { uint32_t usedBytes = 0; int requestedRamCount = 0; int loadedRamCount = 0; + int requestedHeadCount = 0; + int loadedHeadCount = 0; int fallbackToStreamCount = 0; int readErrorCount = 0; bool fixedBudgetMismatch = false; }; +// RAM pool size: free PSRAM at the first call minus a reserve, then fixed +// until release(). Falls back to kDefaultSampleRamBudgetBytes without PSRAM. +uint32_t budgetBytes(); + bool prepare(const SettingsStore::SamplerSettings &settings, const SampleClassifier::ClassificationReport &classification, LoadReport &report); bool getLoadedSampleByPath(const String &path, LoadedSampleInfo &info); bool getLoadedSampleDataByPath(const String &path, LoadedSampleData &data); +// Start of a streamed sample, preloaded so playback begins before SD data. +bool getLoadedHeadByPath(const String &path, LoadedSampleData &data); void release(); } // namespace SampleRamManager diff --git a/include/sampler_app.h b/include/sampler_app.h index f83f36d..a270d3d 100644 --- a/include/sampler_app.h +++ b/include/sampler_app.h @@ -38,6 +38,7 @@ class SamplerApp { void processLoaderCommand(const LoaderCommand &command); void runLoaderTask(); void runUiTask(); + void logStreamingDiagnostics(); Audio audio_; Input input_; @@ -55,4 +56,6 @@ class SamplerApp { QueueHandle_t uiStatusQueue_ = nullptr; TaskHandle_t loaderTaskHandle_ = nullptr; TaskHandle_t uiTaskHandle_ = nullptr; + Audio::StreamingDiagnostics loggedDiagnostics_; + uint32_t lastSdBytesRead_ = 0; }; diff --git a/include/sampler_mixer.h b/include/sampler_mixer.h index 0d6a9e9..ddbd6d4 100644 --- a/include/sampler_mixer.h +++ b/include/sampler_mixer.h @@ -2,6 +2,7 @@ #include "AudioOutput.h" #include +#include namespace AudioInternal { @@ -45,6 +46,7 @@ class SamplerMixer { bool start(int id); bool consume(int id, float left, float right); bool emit(float left, float right); + int queued(int id) const; AudioOutput *sink_; Frame *mix_ = nullptr; int capacity_; @@ -52,7 +54,9 @@ class SamplerMixer { bool sinkStarted_ = false; bool allocated_[kMaxInputs] = {}; bool running_[kMaxInputs] = {}; - int queued_[kMaxInputs] = {}; + // Absolute frame positions; queued(id) = written_[id] - emitted_. + uint32_t written_[kMaxInputs] = {}; + uint32_t emitted_ = 0; Frame delay_[kLookaheadSamples]; int delayHead_ = 0; float gain_ = 1; diff --git a/include/storage_sd.h b/include/storage_sd.h index 068df09..c16b10d 100644 --- a/include/storage_sd.h +++ b/include/storage_sd.h @@ -1,7 +1,11 @@ #pragma once +#include + namespace StorageSD { bool init(); +// SPI clock the card was mounted at, or 0 when not mounted. +uint32_t spiFrequencyHz(); } // namespace StorageSD diff --git a/include/stream_manager.h b/include/stream_manager.h index bf33a80..18dd3ee 100644 --- a/include/stream_manager.h +++ b/include/stream_manager.h @@ -1,43 +1,120 @@ #pragma once #include -#include +#include +#include #include -class AudioFileSource; +#include "AudioFileSource.h" + namespace SampleLibrary { struct Catalog; } +// Streams SD samples through per-stream ring buffers. A reader task performs +// all SD access (open, seek, read, close); the audio task only copies +// buffered PCM and never waits for the card. Without a reader task (host +// tests), Audio::update() services streams synchronously before decoding. class StreamManager { public: - static constexpr uint8_t kMaxStreams = 32; + // SD streams that can play at once. At the 4 MHz fallback clock the card + // sustains only a few, but retriggers briefly hold two streams per sample. + static constexpr uint8_t kMaxStreams = 16; + static constexpr uint32_t kRingBytes = 16 * 1024; // ~185 ms of mono PCM16. struct Diagnostics { - uint32_t sourceReadCount = 0; - uint32_t sourceSlowReadCount = 0; - uint32_t sourceMaxReadUs = 0; - uint32_t sourceMaxReadBytes = 0; - uint32_t sourceBytesRead = 0; - uint32_t bufferRefillCount = 0; + uint32_t readCount = 0; + uint32_t bytesRead = 0; + uint32_t maxReadUs = 0; + uint32_t maxReadBytes = 0; + uint32_t openFailureCount = 0; + uint32_t noFreeStreamCount = 0; + uint32_t starvedUpdateCount = 0; + }; + + // Decoder-facing view of one stream: a canonical 44-byte WAV header from + // memory, then an optional preloaded head of the PCM from RAM, then PCM + // from the ring. Owned by StreamManager, borrowed by one voice from + // openStream() until release(). + class SdStream : public AudioFileSource { + public: + uint32_t read(void *data, uint32_t len) override; + bool seek(int32_t pos, int dir) override; + // The decoder closes its source at EOF. Keep the stream: a loop restart + // continues from PCM the reader has already buffered. + bool close() override { return true; } + bool isOpen() override { return true; } + uint32_t getSize() override { return kHeaderBytes + dataBytes_; } + uint32_t getPos() override { return pos_; } + + // True when one voice update can decode without running dry. + bool ready() const; + bool failed() const { return failed_.load(); } + // Next loop iteration: header again, PCM continues from the ring. + void rewind() { pos_ = 0; } + void setLoop(bool loop) { loop_.store(loop); } + void release(); + + private: + friend class StreamManager; + enum class State : uint8_t { Free, OpenRequested, Streaming, CloseRequested }; + static constexpr uint32_t kHeaderBytes = 44; + + // Written by the audio task before OpenRequested, then read-only. + char path_[128] = {0}; + uint32_t fileDataOffset_ = 0; + uint32_t dataBytes_ = 0; + const uint8_t *head_ = nullptr; // First headBytes_ of PCM, in RAM. + uint32_t headBytes_ = 0; + uint8_t header_[kHeaderBytes] = {0}; + StreamManager *owner_ = nullptr; + // Shared between tasks. + std::atomic state_{State::Free}; + std::atomic loop_{false}; + std::atomic failed_{false}; + std::atomic written_{0}; // Advanced by the reader. + std::atomic consumed_{0}; // Advanced by the audio task. + uint8_t *ring_ = nullptr; + // Audio task only: position in the virtual WAV file. + uint32_t pos_ = 0; + // Reader task only. + File file_; + uint32_t readerDataPos_ = 0; }; StreamManager() = default; ~StreamManager(); - bool begin(uint8_t streamCount = kMaxStreams, const SampleLibrary::Catalog *catalog = nullptr); + bool begin(const SampleLibrary::Catalog *catalog); void shutdown(); + // Moves SD access to a task on another core. Before this, serviceAll() + // must be called by whoever reads the streams. + bool startReaderTask(uint8_t priority, int core); + bool hasReaderTask() const { return readerTask_ != nullptr; } - bool openStream(uint8_t streamId, const char *path); - AudioFileSource *sourceForStream(uint8_t streamId); - void closeStream(uint8_t streamId); - void closeAll(); + // Claims a stream for a validated catalog sample; the reader opens it. + // With a preloaded head, the reader starts after it and playback can + // begin before any SD data arrives; loops replay the head from RAM. + SdStream *openStream(const char *path, + bool loop, + const uint8_t *head = nullptr, + uint32_t headBytes = 0); + // Performs all pending reader work (host tests / no reader task). + void serviceAll(); + void noteStarvedUpdate() { diagnostics_.starvedUpdateCount++; } - const Diagnostics &diagnostics() const; + const Diagnostics &diagnostics() const { return diagnostics_; } private: - class BufferedSdSource; + static void readerTaskEntry(void *context); + void runReader(); + // One unit of reader work; false when nothing needed doing. + bool serviceOnce(); + bool openFile(SdStream &stream); + bool fillOnce(); + void closeFile(SdStream &stream); - BufferedSdSource *streams_[kMaxStreams] = {nullptr}; - uint8_t streamCount_ = 0; - Diagnostics diagnostics_; + SdStream streams_[kMaxStreams]; + uint8_t *ringMemory_ = nullptr; const SampleLibrary::Catalog *catalog_ = nullptr; + void *readerTask_ = nullptr; + Diagnostics diagnostics_; }; diff --git a/include/trigger_engine.h b/include/trigger_engine.h index b2f82aa..98a2d2c 100644 --- a/include/trigger_engine.h +++ b/include/trigger_engine.h @@ -23,6 +23,9 @@ struct TriggerEvent { int16_t retriggerGroupId = -1; bool loopEnabled = false; char path[128] = {0}; + // StreamPath only: preloaded start of the sample, played while SD catches up. + const uint8_t *streamHead = nullptr; + uint32_t streamHeadBytes = 0; const uint8_t *ramData = nullptr; uint32_t ramDataBytes = 0; diff --git a/include/validated_wav_source.h b/include/validated_wav_source.h deleted file mode 100644 index 654dfee..0000000 --- a/include/validated_wav_source.h +++ /dev/null @@ -1,20 +0,0 @@ -#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/src/audio.cpp b/src/audio.cpp index 734cabc..2623c13 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -83,7 +83,7 @@ bool Audio::begin() { return false; } - if (!impl_->streamManager.begin(kVoiceCount, catalog_)) { + if (!impl_->streamManager.begin(catalog_)) { delete impl_->mixer; impl_->mixer = nullptr; delete impl_->waveformOut; @@ -121,9 +121,16 @@ bool Audio::begin() { return true; } +bool Audio::startStreamReader(uint8_t priority, int core) { + return impl_ && impl_->streamManager.startReaderTask(priority, core); +} + void Audio::update() { if (!impl_) return; const uint32_t nowUs = micros(); + if (!impl_->streamManager.hasReaderTask()) { + impl_->streamManager.serviceAll(); + } AudioInternal::refreshStats(impl_); @@ -160,6 +167,22 @@ void Audio::update() { } } + if (voice.stream && !voice.stream->ready()) { + if (voice.stream->failed() || voice.stopping) { + AudioInternal::stopVoice(voice); + stateChanged = true; + continue; + } + // The card fell behind for this voice only. Feed it silence so the + // mixer, which advances in lockstep, keeps every other voice playing. + impl_->streamManager.noteStarvedUpdate(); + int16_t silence[2] = {0, 0}; + for (uint16_t n = 0; n < AudioInternal::kVoiceLoopSampleBudget; n++) { + if (!voice.stub->ConsumeSample(silence)) break; + } + continue; + } + if (voice.budgetedOut) { voice.budgetedOut->resetBudget(AudioInternal::kVoiceLoopSampleBudget); } @@ -186,7 +209,9 @@ void Audio::update() { void Audio::playSamplePath(const String &samplePath, uint8_t volume, int16_t retriggerGroupId, - bool loopEnabled) { + bool loopEnabled, + const uint8_t *head, + uint32_t headBytes) { if (!impl_ || samplePath.length() == 0) return; const uint32_t fadeInUs = 0; @@ -200,8 +225,15 @@ void Audio::playSamplePath(const String &samplePath, AudioInternal::VoiceState &voice = impl_->voices[voiceIndex]; AudioInternal::stopVoice(voice); - if (!AudioInternal::beginVoiceFromPath( - impl_, voiceIndex, samplePath, volume, retriggerGroupId, loopEnabled, fadeInUs)) { + if (!AudioInternal::beginVoiceFromPath(impl_, + voiceIndex, + samplePath, + head, + headBytes, + volume, + retriggerGroupId, + loopEnabled, + fadeInUs)) { AudioInternal::refreshStats(impl_); return; } @@ -251,6 +283,7 @@ void Audio::setLoopEnabledForGroup(int16_t retriggerGroupId, bool loopEnabled) { if (!voice.active) continue; if (voice.retriggerGroupId != retriggerGroupId) continue; voice.loopEnabled = loopEnabled; + if (voice.stream) voice.stream->setLoop(loopEnabled); } } @@ -304,6 +337,21 @@ uint32_t Audio::voiceStealCount() const { return impl_->stats.voiceStealCount; } +Audio::StreamingDiagnostics Audio::streamingDiagnostics() const { + StreamingDiagnostics diagnostics; + if (!impl_) return diagnostics; + if (impl_->out) diagnostics.i2sUnderrunCount = impl_->out->underrunCount(); + const StreamManager::Diagnostics &sd = impl_->streamManager.diagnostics(); + diagnostics.starvedUpdateCount = sd.starvedUpdateCount; + diagnostics.sdReadCount = sd.readCount; + diagnostics.sdBytesRead = sd.bytesRead; + diagnostics.sdMaxReadUs = sd.maxReadUs; + diagnostics.sdMaxReadBytes = sd.maxReadBytes; + diagnostics.sdOpenFailureCount = sd.openFailureCount; + diagnostics.sdNoFreeStreamCount = sd.noFreeStreamCount; + return diagnostics; +} + bool Audio::waveformSnapshot(WaveformSnapshot &snapshot) const { if (!impl_) { snapshot.validPoints = 0; diff --git a/src/audio_output_chain.cpp b/src/audio_output_chain.cpp index f720643..62a9a36 100644 --- a/src/audio_output_chain.cpp +++ b/src/audio_output_chain.cpp @@ -140,6 +140,23 @@ bool FreshStartAudioGeneratorWAV::stop() { StableAudioOutputI2S::StableAudioOutputI2S(int port, int outputMode, int dmaCount, int useApll) : AudioOutputI2S(port, outputMode, dmaCount, useApll) {} +bool StableAudioOutputI2S::begin() { + if (!AudioOutputI2S::begin()) return false; +#ifdef ESP32 + // Callbacks can be registered only while the channel is disabled. The codec + // is still muted when the mixer first starts this sink. + i2s_event_callbacks_t callbacks = {}; + callbacks.on_send_q_ovf = onSendQueueOverflow; + if (i2s_channel_disable(_tx_handle) == ESP_OK) { + i2s_channel_register_event_callback(_tx_handle, &callbacks, this); + i2sOn = (i2s_channel_enable(_tx_handle) == ESP_OK); + } + return i2sOn; +#else + return true; +#endif +} + bool StableAudioOutputI2S::SetRate(int hz) { rateSetCalls_++; if (lastRateHz_ == hz) { @@ -160,6 +177,16 @@ size_t StableAudioOutputI2S::writeBlock(void *context, const uint8_t *data, size i2s_channel_write(self->_tx_handle, data, bytes, &written, 0); return written; } + +bool IRAM_ATTR StableAudioOutputI2S::onSendQueueOverflow(i2s_chan_handle_t, + i2s_event_data_t *, + void *context) { + // The DMA finished every queued block before the next write arrived, so it + // replays stale data: audible as stutter and apparent time stretching. + auto *self = static_cast(context); + self->underrunCount_ = self->underrunCount_ + 1; + return false; +} #endif bool StableAudioOutputI2S::ConsumeSample(int16_t sample[2]) { @@ -184,6 +211,8 @@ uint32_t StableAudioOutputI2S::skippedRateSetCalls() const { return skippedRateS uint32_t StableAudioOutputI2S::appliedRateSetCalls() const { return appliedRateSetCalls_; } +uint32_t StableAudioOutputI2S::underrunCount() const { return underrunCount_; } + WaveformAudioOutput::WaveformAudioOutput(AudioOutput *sink, WaveformCaptureState *waveformCapture) : sink_(sink), waveformCapture_(waveformCapture) {} diff --git a/src/audio_voice_engine.cpp b/src/audio_voice_engine.cpp index eae4d84..00a2bde 100644 --- a/src/audio_voice_engine.cpp +++ b/src/audio_voice_engine.cpp @@ -55,6 +55,10 @@ void stopVoice(VoiceState &voice) { voice.activeSource->close(); voice.activeSource = nullptr; } + if (voice.stream) { + voice.stream->release(); + voice.stream = nullptr; + } voice.active = false; voice.startOrder = 0; voice.startUs = 0; @@ -102,6 +106,7 @@ void requestVoiceStop(VoiceState &voice, uint32_t fadeOutUs) { } // Keep stop deterministic: no loop restart while voice is tailing out. voice.loopEnabled = false; + if (voice.stream) voice.stream->setLoop(false); } void refreshStats(EngineState *impl) { @@ -211,25 +216,22 @@ int allocateVoiceSlot(EngineState *impl, int16_t retriggerGroupId, bool &voiceWa return stolenIndex; } -bool beginVoiceFromPath(EngineState *impl, - int voiceIndex, - const String &samplePath, - uint8_t volume, - int16_t retriggerGroupId, - bool loopEnabled, - uint32_t fadeInUs) { - if (!impl || voiceIndex < 0 || voiceIndex >= Audio::kVoiceCount) return false; +namespace { +// Starts a voice on a claimed stream. The stream need not have data yet: the +// decoder reads only the in-memory header here, and Audio::update() feeds +// silence until PCM is buffered. +bool beginVoiceFromStream(EngineState *impl, + int voiceIndex, + StreamManager::SdStream *stream, + const String &samplePath, + uint8_t volume, + int16_t retriggerGroupId, + bool loopEnabled, + uint32_t fadeInUs) { VoiceState &voice = impl->voices[voiceIndex]; - if (!voice.wav || !voice.stub || !voice.budgetedOut) return false; - if (!impl->streamManager.openStream(static_cast(voiceIndex), samplePath.c_str())) { - return false; - } - - voice.activeSource = impl->streamManager.sourceForStream(static_cast(voiceIndex)); - if (!voice.activeSource) { - return false; - } + voice.stream = stream; + voice.activeSource = stream; voice.targetGain = gainFromVolume(volume); voice.fadeInUs = fadeInUs; voice.stopping = false; @@ -241,8 +243,9 @@ bool beginVoiceFromPath(EngineState *impl, voice.budgetedOut->setSampleFrames((voice.activeSource->getSize() - 44U) / 2U); voice.stub->SetGain(voice.currentGain); if (!voice.wav->begin(voice.activeSource, voice.budgetedOut)) { - voice.activeSource->close(); voice.activeSource = nullptr; + voice.stream = nullptr; + stream->release(); voice.stub->stop(); return false; } @@ -266,6 +269,28 @@ bool beginVoiceFromPath(EngineState *impl, return true; } +} // namespace + +bool beginVoiceFromPath(EngineState *impl, + int voiceIndex, + const String &samplePath, + const uint8_t *head, + uint32_t headBytes, + uint8_t volume, + int16_t retriggerGroupId, + bool loopEnabled, + uint32_t fadeInUs) { + if (!impl || voiceIndex < 0 || voiceIndex >= Audio::kVoiceCount) return false; + + VoiceState &voice = impl->voices[voiceIndex]; + if (!voice.wav || !voice.stub || !voice.budgetedOut) return false; + StreamManager::SdStream *stream = + impl->streamManager.openStream(samplePath.c_str(), loopEnabled, head, headBytes); + if (!stream) return false; + return beginVoiceFromStream( + impl, voiceIndex, stream, samplePath, volume, retriggerGroupId, loopEnabled, fadeInUs); +} + bool beginVoiceFromRam(EngineState *impl, int voiceIndex, const uint8_t *pcmData, @@ -337,11 +362,17 @@ bool restartVoiceLoop(EngineState *impl, int voiceIndex) { const uint16_t channelCount = voice.channelCount; const uint32_t sampleRate = voice.sampleRate; const uint16_t bitsPerSample = voice.bitsPerSample; + // Keep the stream across the restart: the reader has already buffered the + // next iteration, so the loop continues without waiting for the card. + StreamManager::SdStream *stream = voice.stream; + impl->voices[voiceIndex].stream = nullptr; stopVoice(impl->voices[voiceIndex]); - if (sourceType == VoiceSourceType::StreamPath && path.length() > 0) { - return beginVoiceFromPath(impl, voiceIndex, path, volume, retriggerGroupId, loopEnabled, 0); + if (sourceType == VoiceSourceType::StreamPath && stream) { + stream->rewind(); + return beginVoiceFromStream( + impl, voiceIndex, stream, path, volume, retriggerGroupId, loopEnabled, 0); } if (sourceType == VoiceSourceType::RamData && ramData && ramDataBytes > 0) { return beginVoiceFromRam(impl, diff --git a/src/sample_classifier.cpp b/src/sample_classifier.cpp index 2d07427..ee79efb 100644 --- a/src/sample_classifier.cpp +++ b/src/sample_classifier.cpp @@ -23,6 +23,7 @@ void resetReport(SampleClassifier::ClassificationReport &report) { report.items[i].dataOffset = 0; report.items[i].durationSeconds = 0.0f; report.items[i].mode = SampleClassifier::StorageMode::ReadError; + report.items[i].headBytes = 0; } report.itemCount = 0; @@ -45,17 +46,80 @@ void classifyAssignedSamples(const SettingsStore::SamplerSettings &settings, resetReport(report); report.sampleRamBudgetBytes = settings.sampleRamBudgetBytes; + // First occurrence of each path; later duplicates share its classification. + int firstIndex[ClassificationReport::kMaxItems]; + // Playable first occurrences, packed into RAM shortest first. + int candidates[ClassificationReport::kMaxItems]; + int candidateCount = 0; + for (int i = 0; i < settings.assignmentCount && report.itemCount < ClassificationReport::kMaxItems; i++) { const SettingsStore::MidiAssignment &assignment = settings.assignments[i]; - AssignedSampleClassification &item = report.items[report.itemCount++]; + const int itemIndex = report.itemCount++; + AssignedSampleClassification &item = report.items[itemIndex]; item.note = assignment.note; item.path = assignment.samplePath; const int existingIndex = findExistingPathIndex(report, item.path); - if (existingIndex >= 0 && existingIndex < (report.itemCount - 1)) { - const AssignedSampleClassification &existing = report.items[existingIndex]; + firstIndex[itemIndex] = existingIndex; + if (existingIndex < itemIndex) continue; + + 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 { + const auto &info = catalog.validation[index]; + item.channelCount = kRequiredChannelCount; + item.bitsPerSample = kRequiredBitsPerSample; + item.sampleRate = kRequiredSampleRate; + item.dataBytes = info.dataBytes; + item.dataOffset = info.dataOffset; + item.durationSeconds = static_cast(info.dataBytes) / 88200.0f; + item.mode = StorageMode::Stream; + // Stable insertion keeps assignment order among equal sizes. + int pos = candidateCount++; + while (pos > 0 && report.items[candidates[pos - 1]].dataBytes > item.dataBytes) { + candidates[pos] = candidates[pos - 1]; + pos--; + } + candidates[pos] = itemIndex; + } + } + + // Reserve the start of every sample first: whichever of them end up + // streaming then start from RAM. Skip heads if even they do not fit. + uint32_t headTotal = 0; + for (int i = 0; i < candidateCount; i++) { + const AssignedSampleClassification &item = report.items[candidates[i]]; + headTotal += item.dataBytes < kStreamHeadBytes ? item.dataBytes : kStreamHeadBytes; + } + const bool useHeads = headTotal <= report.sampleRamBudgetBytes; + if (useHeads) report.sampleRamUsedBytes = headTotal; + + // Preload as many whole samples as the budget allows; each costs only what + // its reserved head does not cover. Shortest first leaves the fewest on SD. + for (int i = 0; i < candidateCount; i++) { + AssignedSampleClassification &item = report.items[candidates[i]]; + const uint32_t head = useHeads ? (item.dataBytes < kStreamHeadBytes ? item.dataBytes + : kStreamHeadBytes) + : 0; + if (item.dataBytes - head <= report.sampleRamBudgetBytes - report.sampleRamUsedBytes) { + item.mode = StorageMode::Ram; + report.sampleRamUsedBytes += item.dataBytes - head; + } else { + item.headBytes = head; + } + } + + for (int i = 0; i < report.itemCount; i++) { + AssignedSampleClassification &item = report.items[i]; + if (firstIndex[i] < i) { + const AssignedSampleClassification &existing = report.items[firstIndex[i]]; item.channelCount = existing.channelCount; item.bitsPerSample = existing.bitsPerSample; item.sampleRate = existing.sampleRate; @@ -63,27 +127,7 @@ void classifyAssignedSamples(const SettingsStore::SamplerSettings &settings, item.dataOffset = existing.dataOffset; item.durationSeconds = existing.durationSeconds; item.mode = existing.mode; - } else { - 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 { - const auto &info = catalog.validation[index]; - item.channelCount = kRequiredChannelCount; - item.bitsPerSample = kRequiredBitsPerSample; - item.sampleRate = kRequiredSampleRate; - item.dataBytes = info.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; - } + item.headBytes = existing.headBytes; } switch (item.mode) { diff --git a/src/sample_ram_manager.cpp b/src/sample_ram_manager.cpp index 4aefee8..4aa9011 100644 --- a/src/sample_ram_manager.cpp +++ b/src/sample_ram_manager.cpp @@ -2,18 +2,25 @@ #include #include +#include +#include #include #include namespace { -constexpr size_t kReadChunkBytes = 1024; +// Reads larger than the 4 KiB stdio buffer go straight to FATFS as +// multi-sector transfers, which matters at the 4 MHz fallback SD clock. +constexpr size_t kReadChunkBytes = 32 * 1024; +// PSRAM left for everything else (JSON documents, FATFS state, buffers). +constexpr uint32_t kPsramReserveBytes = 512UL * 1024UL; struct LoadedEntry { String path; uint32_t dataBytes = 0; uint32_t poolOffset = 0; bool valid = false; + bool head = false; // Only the start of a streamed sample. }; uint8_t *gPool = nullptr; @@ -28,6 +35,7 @@ void clearLoadedEntries() { gLoadedEntries[i].dataBytes = 0; gLoadedEntries[i].poolOffset = 0; gLoadedEntries[i].valid = false; + gLoadedEntries[i].head = false; } } @@ -55,15 +63,28 @@ bool allocatePool(uint32_t budgetBytes) { return true; } -int findLoadedEntryByPath(const String &path) { +int findLoadedEntryByPath(const String &path, bool head) { for (int i = 0; i < SettingsStore::SamplerSettings::kMaxAssignments; i++) { - if (gLoadedEntries[i].valid && gLoadedEntries[i].path == path) { + if (gLoadedEntries[i].valid && gLoadedEntries[i].head == head && gLoadedEntries[i].path == path) { return i; } } return -1; } +bool loadedDataAt(int idx, SampleRamManager::LoadedSampleData &data) { + data = SampleRamManager::LoadedSampleData{}; + if (idx < 0 || !gPool) return false; + const uint32_t offset = gLoadedEntries[idx].poolOffset; + const uint32_t bytes = gLoadedEntries[idx].dataBytes; + if (bytes == 0 || offset > gPoolCapacity || (gPoolCapacity - offset) < bytes) { + return false; + } + data.data = gPool + offset; + data.dataBytes = bytes; + return true; +} + int findFreeLoadedEntrySlot() { for (int i = 0; i < SettingsStore::SamplerSettings::kMaxAssignments; i++) { if (!gLoadedEntries[i].valid) return i; @@ -89,6 +110,9 @@ bool readFileRangeToBuffer(const String &path, uint32_t offset, uint32_t size, u return false; } totalRead += static_cast(readNow); + // SPI SD transfers busy-wait. Loading several MiB would otherwise keep + // this core from IDLE long enough to trip the task watchdog. + vTaskDelay(1); } file.close(); @@ -99,28 +123,39 @@ bool readFileRangeToBuffer(const String &path, uint32_t offset, uint32_t size, u namespace SampleRamManager { +uint32_t budgetBytes() { + if (!gPoolBudgetLocked) { + // The pool is allocated once and never resized, so size it from PSRAM + // available at the first preparation rather than a fixed setting. + const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + gFixedPoolBudget = largest > kPsramReserveBytes + ? static_cast(largest - kPsramReserveBytes) + : SettingsStore::SamplerSettings::kDefaultSampleRamBudgetBytes; + gPoolBudgetLocked = true; + } + return gFixedPoolBudget; +} + bool prepare(const SettingsStore::SamplerSettings &settings, const SampleClassifier::ClassificationReport &classification, LoadReport &report) { report = LoadReport{}; report.budgetBytes = settings.sampleRamBudgetBytes; - - if (!gPoolBudgetLocked) { - gPoolBudgetLocked = true; - gFixedPoolBudget = settings.sampleRamBudgetBytes; - } - report.effectiveBudgetBytes = gFixedPoolBudget; + report.effectiveBudgetBytes = budgetBytes(); if (settings.sampleRamBudgetBytes != gFixedPoolBudget) { report.fixedBudgetMismatch = true; } for (int i = 0; i < classification.itemCount; i++) { - if (classification.items[i].mode == SampleClassifier::StorageMode::Ram) { + const SampleClassifier::AssignedSampleClassification &item = classification.items[i]; + if (item.mode == SampleClassifier::StorageMode::Ram) { report.requestedRamCount++; + } else if (item.mode == SampleClassifier::StorageMode::Stream && item.headBytes > 0) { + report.requestedHeadCount++; } } - if (report.requestedRamCount == 0) { + if (report.requestedRamCount == 0 && report.requestedHeadCount == 0) { clearLoadedEntries(); report.allocatedBytes = gPoolCapacity; return true; @@ -138,40 +173,45 @@ bool prepare(const SettingsStore::SamplerSettings &settings, for (int i = 0; i < classification.itemCount; i++) { const SampleClassifier::AssignedSampleClassification &item = classification.items[i]; - if (item.mode != SampleClassifier::StorageMode::Ram) { + const bool head = item.mode == SampleClassifier::StorageMode::Stream && item.headBytes > 0; + if (item.mode != SampleClassifier::StorageMode::Ram && !head) { continue; } + const uint32_t bytes = head ? item.headBytes : item.dataBytes; - const int existingIndex = findLoadedEntryByPath(item.path); - if (existingIndex >= 0) { - report.loadedRamCount++; + if (findLoadedEntryByPath(item.path, head) >= 0) { + if (head) report.loadedHeadCount++; + else report.loadedRamCount++; continue; } - if (item.dataBytes == 0 || item.dataBytes > gPoolCapacity || (gPoolCapacity - used) < item.dataBytes) { - report.fallbackToStreamCount++; + // A missing head only delays that sample's start; it still streams. + if (bytes == 0 || bytes > gPoolCapacity || (gPoolCapacity - used) < bytes) { + if (!head) report.fallbackToStreamCount++; continue; } - if (!readFileRangeToBuffer(item.path, item.dataOffset, item.dataBytes, gPool + used)) { + if (!readFileRangeToBuffer(item.path, item.dataOffset, bytes, gPool + used)) { report.readErrorCount++; - report.fallbackToStreamCount++; + if (!head) report.fallbackToStreamCount++; continue; } const int slot = findFreeLoadedEntrySlot(); if (slot < 0) { - report.fallbackToStreamCount++; + if (!head) report.fallbackToStreamCount++; continue; } gLoadedEntries[slot].path = item.path; - gLoadedEntries[slot].dataBytes = item.dataBytes; + gLoadedEntries[slot].dataBytes = bytes; gLoadedEntries[slot].poolOffset = used; gLoadedEntries[slot].valid = true; + gLoadedEntries[slot].head = head; - used += item.dataBytes; - report.loadedRamCount++; + used += bytes; + if (head) report.loadedHeadCount++; + else report.loadedRamCount++; } report.usedBytes = used; @@ -186,7 +226,7 @@ void release() { } bool getLoadedSampleByPath(const String &path, LoadedSampleInfo &info) { - const int idx = findLoadedEntryByPath(path); + const int idx = findLoadedEntryByPath(path, false); if (idx < 0) return false; info.dataBytes = gLoadedEntries[idx].dataBytes; info.poolOffset = gLoadedEntries[idx].poolOffset; @@ -194,19 +234,11 @@ bool getLoadedSampleByPath(const String &path, LoadedSampleInfo &info) { } bool getLoadedSampleDataByPath(const String &path, LoadedSampleData &data) { - data = LoadedSampleData{}; - const int idx = findLoadedEntryByPath(path); - if (idx < 0 || !gPool) return false; - - const uint32_t offset = gLoadedEntries[idx].poolOffset; - const uint32_t bytes = gLoadedEntries[idx].dataBytes; - if (bytes == 0 || offset > gPoolCapacity || (gPoolCapacity - offset) < bytes) { - return false; - } + return loadedDataAt(findLoadedEntryByPath(path, false), data); +} - data.data = gPool + offset; - data.dataBytes = bytes; - return true; +bool getLoadedHeadByPath(const String &path, LoadedSampleData &data) { + return loadedDataAt(findLoadedEntryByPath(path, true), data); } } // namespace SampleRamManager diff --git a/src/sampler_app.cpp b/src/sampler_app.cpp index 0dc02ec..7842565 100644 --- a/src/sampler_app.cpp +++ b/src/sampler_app.cpp @@ -13,6 +13,10 @@ namespace { constexpr UBaseType_t kAudioTaskPriority = 6; constexpr UBaseType_t kLoaderTaskPriority = 4; constexpr UBaseType_t kUiTaskPriority = 2; +// Above the UI so streams stay fed; the reader yields every 10 ms of SD work. +constexpr UBaseType_t kSdReaderTaskPriority = 3; +constexpr BaseType_t kSdReaderTaskCore = 0; +constexpr uint32_t kDiagnosticsIntervalMs = 1000; constexpr BaseType_t kAudioTaskCore = 1; constexpr BaseType_t kLoaderTaskCore = 0; constexpr BaseType_t kUiTaskCore = 0; @@ -22,6 +26,8 @@ constexpr uint16_t kUiStatusQueueLength = 16; constexpr uint16_t kAudioTaskStackWords = 6144; constexpr uint16_t kLoaderTaskStackWords = 6144; constexpr uint16_t kUiTaskStackWords = 8192; +// Preloading several MiB of samples at the 4 MHz SD fallback takes seconds. +constexpr uint32_t kBootRebuildTimeoutMs = 60000; const char *startupTitleForResetReason() { const esp_reset_reason_t reason = esp_reset_reason(); @@ -42,7 +48,7 @@ void SamplerApp::setup() { if (!startTasks()) { return; } - if (!requestLoaderRebuildAndWait(8000)) { + if (!requestLoaderRebuildAndWait(kBootRebuildTimeoutMs)) { return; } if (!CodecES8388::unmute()) { @@ -128,6 +134,13 @@ bool SamplerApp::startTasks() { return false; } + // SD reads for streamed samples run on core 0, so the audio task on core 1 + // never waits for the card. Without the task, playback still works but + // reads block the audio task. + if (!audio_.startStreamReader(kSdReaderTaskPriority, kSdReaderTaskCore)) { + Serial.println("Audio: SD reader task failed; streaming from the audio task"); + } + if (!triggerEngine_.begin( &audio_, kAudioTaskPriority, @@ -173,6 +186,34 @@ void SamplerApp::loop() { vTaskDelay(pdMS_TO_TICKS(1000)); } +void SamplerApp::logStreamingDiagnostics() { + // Runs once per second from the UI task on core 0: the audio task keeps + // core 1 busy while voices play. Report only seconds with new problems, so + // a quiet log means playback kept up. + const Audio::StreamingDiagnostics now = audio_.streamingDiagnostics(); + const Audio::StreamingDiagnostics &last = loggedDiagnostics_; + const uint32_t sdBytesPerSecond = now.sdBytesRead - lastSdBytesRead_; + lastSdBytesRead_ = now.sdBytesRead; + if (now.i2sUnderrunCount == last.i2sUnderrunCount && + now.starvedUpdateCount == last.starvedUpdateCount && + now.sdNoFreeStreamCount == last.sdNoFreeStreamCount && + now.sdOpenFailureCount == last.sdOpenFailureCount) { + return; + } + Serial.printf("Audio: +%lu I2S underruns, +%lu starved SD voice updates, +%lu SD triggers " + "without a free stream, +%lu SD open failures; SD %lu KiB/s at %lu Hz, " + "max read %lu us for %lu B\n", + static_cast(now.i2sUnderrunCount - last.i2sUnderrunCount), + static_cast(now.starvedUpdateCount - last.starvedUpdateCount), + static_cast(now.sdNoFreeStreamCount - last.sdNoFreeStreamCount), + static_cast(now.sdOpenFailureCount - last.sdOpenFailureCount), + static_cast(sdBytesPerSecond / 1024U), + static_cast(StorageSD::spiFrequencyHz()), + static_cast(now.sdMaxReadUs), + static_cast(now.sdMaxReadBytes)); + loggedDiagnostics_ = now; +} + void SamplerApp::uiTaskEntry(void *param) { auto *self = static_cast(param); self->runUiTask(); @@ -245,7 +286,12 @@ void SamplerApp::processLoaderCommand(const LoaderCommand &command) { } void SamplerApp::runUiTask() { + uint32_t lastDiagnosticsMs = millis(); while (true) { + if (millis() - lastDiagnosticsMs >= kDiagnosticsIntervalMs) { + lastDiagnosticsMs = millis(); + logStreamingDiagnostics(); + } midi_.update(); callbackBinder_.pollInput(input_); ui_.update(); diff --git a/src/sampler_mixer.cpp b/src/sampler_mixer.cpp index ccdbefe..909260d 100644 --- a/src/sampler_mixer.cpp +++ b/src/sampler_mixer.cpp @@ -96,27 +96,46 @@ bool SamplerMixer::emit(float left, float right) { return true; } +int SamplerMixer::queued(int id) const { + // Wrap-safe: positions are absolute frame counters. + const int32_t ahead = static_cast(written_[id] - emitted_); + return ahead > 0 ? ahead : 0; +} + bool SamplerMixer::loop() { if (!mix_ || !sinkStarted_) return false; - for (;;) { - for (int i = 0; i < kMaxInputs; ++i) { - if (running_[i] && queued_[i] == 0) return true; - } + // A frame is final once every running input has written it. Scan inputs once + // per call, not once per consumed sample. + int ready = -1; + for (int i = 0; i < kMaxInputs; ++i) { + if (!running_[i]) continue; + const int count = queued(i); + if (ready < 0 || count < ready) ready = count; + } + // With no running writers, continue flushing tails and then digital silence. + while (ready != 0) { if (!emit(mix_[read_].left, mix_[read_].right)) return true; mix_[read_] = {}; read_ = (read_ + 1) % capacity_; - for (int &count : queued_) if (count > 0) --count; - // With no running writers, continue flushing tails and then digital silence. + ++emitted_; + if (ready > 0) --ready; } + return true; } bool SamplerMixer::consume(int id, float left, float right) { if (!running_[id]) return false; - loop(); - if (queued_[id] >= capacity_ - 1) return false; - Frame &frame = mix_[(read_ + queued_[id]) % capacity_]; + int count = queued(id); + // Emission normally happens once per Audio::update(); only a full queue + // forces it here. + if (count >= capacity_ - 1) { + loop(); + count = queued(id); + if (count >= capacity_ - 1) return false; + } + Frame &frame = mix_[(read_ + count) % capacity_]; frame.left += left; frame.right += right; - ++queued_[id]; + written_[id] = emitted_ + count + 1; return true; } diff --git a/src/sampler_playback_router.cpp b/src/sampler_playback_router.cpp index 6b7b73c..a181ec9 100644 --- a/src/sampler_playback_router.cpp +++ b/src/sampler_playback_router.cpp @@ -84,6 +84,11 @@ void SamplerPlaybackRouter::onAssignedMidiNoteOn(int midiNote) const { event.retriggerGroupId = static_cast(sampleIndex); event.loopEnabled = ui_->sampleLoopPlaybackEnabled(sampleIndex); assignedPath.toCharArray(event.path, sizeof(event.path)); + SampleRamManager::LoadedSampleData head; + if (hasPreparedEntry && SampleRamManager::getLoadedHeadByPath(assignedPath, head)) { + event.streamHead = head.data; + event.streamHeadBytes = head.dataBytes; + } triggerEngine_->enqueue(event); } diff --git a/src/sampler_runtime.cpp b/src/sampler_runtime.cpp index c524fed..06d9163 100644 --- a/src/sampler_runtime.cpp +++ b/src/sampler_runtime.cpp @@ -85,6 +85,7 @@ void SamplerRuntime::collectAssignmentsFromUi(const Ui &ui, const SampleLibrary: } void SamplerRuntime::rebuildPreparedSamples() { + settings_.sampleRamBudgetBytes = SampleRamManager::budgetBytes(); classifyAssignedSamplesAndLog(); loadClassifiedRamSamplesAndLog(); buildActiveRegistryAndLog(); @@ -146,7 +147,17 @@ void SamplerRuntime::classifyAssignedSamplesAndLog() { } void SamplerRuntime::loadClassifiedRamSamplesAndLog() { + const uint32_t startMs = millis(); SampleRamManager::prepare(settings_, classificationReport_, ramLoadReport_); + Serial.printf("RAM: %d samples loaded (%lu of %lu KiB) in %lu ms, %d streamed from SD " + "(%d with preloaded start), %d failed to load\n", + ramLoadReport_.loadedRamCount, + static_cast(ramLoadReport_.usedBytes / 1024U), + static_cast(ramLoadReport_.effectiveBudgetBytes / 1024U), + static_cast(millis() - startMs), + classificationReport_.streamSampleCount + ramLoadReport_.fallbackToStreamCount, + ramLoadReport_.loadedHeadCount, + ramLoadReport_.readErrorCount); } void SamplerRuntime::buildActiveRegistryAndLog() { diff --git a/src/settings_store.cpp b/src/settings_store.cpp index 23d3910..8c286c3 100644 --- a/src/settings_store.cpp +++ b/src/settings_store.cpp @@ -203,9 +203,8 @@ bool loadFromSd(SamplerSettings &settings) { JsonObject globalSettings = gSettingsJsonDoc["global_settings"].as(); bool defaultLoopPlaybackEnabled = false; if (!globalSettings.isNull()) { - if (globalSettings["sample_ram_budget_bytes"].is()) { - settings.sampleRamBudgetBytes = globalSettings["sample_ram_budget_bytes"].as(); - } + // "sample_ram_budget_bytes" from older firmware is ignored: the RAM pool + // is sized from free PSRAM (SampleRamManager::budgetBytes()). const long panicNote = globalSettings["panic_note"] | -1; settings.panicNote = isValidNote(panicNote) ? static_cast(panicNote) : -1; defaultLoopPlaybackEnabled = @@ -275,7 +274,6 @@ bool saveToSd(const SamplerSettings &settings) { (settings.version.length() > 0) ? settings.version : String(kCurrentVersion); JsonObject globalSettings = gSettingsJsonDoc.createNestedObject("global_settings"); - globalSettings["sample_ram_budget_bytes"] = settings.sampleRamBudgetBytes; if (settings.panicNote >= 0 && settings.panicNote <= 127) { globalSettings["panic_note"] = settings.panicNote; } diff --git a/src/storage_sd.cpp b/src/storage_sd.cpp index 9de01ae..503a593 100644 --- a/src/storage_sd.cpp +++ b/src/storage_sd.cpp @@ -3,11 +3,51 @@ #include #include #include +#include #include "pins.h" namespace StorageSD { +namespace { + +// Long samples stream from SD in the audio task. At the library default of +// 4 MHz, three simultaneous streams need most of the audio core's time and +// synchronized 4 KiB refills outlast the I2S DMA buffer. Fall back to slower +// clocks if the wiring cannot mount the card at full speed. +constexpr uint32_t kSpiFrequenciesHz[] = {20000000, 10000000, 4000000}; +// Every SD stream (StreamManager::kMaxStreams = 16) keeps its file open; +// leave room for settings and preload files. The library default is 5. +constexpr uint8_t kMaxOpenFiles = 20; +// Mounting reads only a few sectors, so wiring that is marginal at a given +// clock can still mount and then fail directory and WAV reads. Read a larger +// range twice before trusting a clock. +constexpr uint32_t kVerifySectorCount = 128; +constexpr size_t kSectorBytes = 512; + +uint32_t mountedFrequencyHz = 0; + +bool readsAreStable() { + static uint8_t first[kSectorBytes]; + static uint8_t second[kSectorBytes]; + const size_t sectorCount = SD.numSectors(); + const uint32_t count = + sectorCount < kVerifySectorCount ? static_cast(sectorCount) : kVerifySectorCount; + for (uint32_t sector = 0; sector < count; ++sector) { + const bool firstOk = SD.readRAW(first, sector); + const bool secondOk = firstOk && SD.readRAW(second, sector); + if (!secondOk || memcmp(first, second, kSectorBytes) != 0) { + Serial.printf("SD: unstable read of sector %lu (%s)\n", static_cast(sector), + !firstOk ? "first read failed" : !secondOk ? "second read failed" + : "data mismatch"); + return false; + } + } + return count > 0; +} + +} // namespace + bool init() { pinMode(Pins::SD_CS, OUTPUT); digitalWrite(Pins::SD_CS, HIGH); @@ -16,13 +56,31 @@ bool init() { SPI.begin(Pins::SD_SCK, Pins::SD_MISO, Pins::SD_MOSI, Pins::SD_CS); delay(800); - if (!SD.begin(Pins::SD_CS)) { - - return false; + constexpr size_t kFrequencyCount = sizeof(kSpiFrequenciesHz) / sizeof(kSpiFrequenciesHz[0]); + for (size_t i = 0; i < kFrequencyCount; ++i) { + const uint32_t frequencyHz = kSpiFrequenciesHz[i]; + const bool slowest = (i + 1 == kFrequencyCount); + if (SD.begin(Pins::SD_CS, SPI, frequencyHz, "/sd", kMaxOpenFiles)) { + // The slowest clock is the previous known-good baseline; keep it even + // if verification fails, as before this check existed. Still run the + // check there: failing at every clock points at the check, not wiring. + const bool stable = readsAreStable(); + if (stable || slowest) { + if (!stable) Serial.println("SD: verification failed at the slowest clock too"); + mountedFrequencyHz = frequencyHz; + Serial.printf("SD: mounted at %lu Hz\n", static_cast(frequencyHz)); + return true; + } + } + Serial.printf("SD: %lu Hz rejected\n", static_cast(frequencyHz)); + SD.end(); } - - return true; + Serial.println("SD: mount failed"); + mountedFrequencyHz = 0; + return false; } +uint32_t spiFrequencyHz() { return mountedFrequencyHz; } + } // namespace StorageSD diff --git a/src/stream_manager.cpp b/src/stream_manager.cpp index 2e81774..a59f5ce 100644 --- a/src/stream_manager.cpp +++ b/src/stream_manager.cpp @@ -1,185 +1,287 @@ #include "stream_manager.h" -#include "AudioFileSource.h" -#include "AudioFileSourceSD.h" -#include "validated_wav_source.h" #include "sample_library.h" +#include "wav_validation.h" #include -#include +#include +#include +#include +#ifdef ESP32 +#include +#endif namespace { -constexpr uint32_t kSlowReadThresholdUs = 3000; +// A voice update decodes up to kVoiceLoopSampleBudget frames, which is at +// most two 128-byte decoder reads. +constexpr uint32_t kReadyBytes = 256; +// Small first reads start playback quickly; larger ones amortize SD command +// overhead once a stream has some margin. +constexpr uint32_t kUrgentBufferedBytes = 4 * 1024; +constexpr uint32_t kUrgentReadBytes = 2 * 1024; +constexpr uint32_t kReadBytes = 8 * 1024; +constexpr uint32_t kMinReadBytes = 2 * 1024; +// SPI SD transfers busy-wait; yield so lower-priority tasks and IDLE run. +constexpr uint32_t kReaderBusySliceMs = 10; +constexpr uint32_t kReaderIdleWaitMs = 5; +// ESP-IDF task stacks are sized in bytes; FATFS keeps long names on the stack. +constexpr uint32_t kReaderStackBytes = 8192; + +uint32_t minU32(uint32_t a, uint32_t b) { return a < b ? a : b; } } // namespace -class StreamManager::BufferedSdSource : public AudioFileSource { - public: - explicit BufferedSdSource(Diagnostics *diagnostics) : diagnostics_(diagnostics) {} - ~BufferedSdSource() override { - close(); - if (sourceMutex_) { - vSemaphoreDelete(sourceMutex_); - sourceMutex_ = nullptr; - } +uint32_t StreamManager::SdStream::read(void *data, uint32_t len) { + if (!data || len == 0) return 0; + auto *out = static_cast(data); + uint32_t copied = 0; + if (pos_ < kHeaderBytes) { + copied = minU32(len, kHeaderBytes - pos_); + memcpy(out, header_ + pos_, copied); + pos_ += copied; + if (pos_ < kHeaderBytes) return copied; } - bool open(const char *path) override { - if (!path || path[0] == '\0') return false; - close(); - if (!sourceMutex_ || xSemaphoreTake(sourceMutex_, pdMS_TO_TICKS(20)) != pdTRUE) return false; - const bool opened = source_.open(path); - if (opened) { - size_ = source_.getSize(); - pos_ = 0; - eof_ = (size_ == 0); - open_ = true; - } - xSemaphoreGive(sourceMutex_); - if (!opened) return false; - 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; + const uint32_t dataPos = pos_ - kHeaderBytes; + if (dataPos < headBytes_) { + const uint32_t bytes = minU32(len - copied, headBytes_ - dataPos); + memcpy(out + copied, head_ + dataPos, bytes); + copied += bytes; + pos_ += bytes; + if (copied == len) return copied; } - 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; + const uint32_t remaining = dataBytes_ - (pos_ - kHeaderBytes); + const uint32_t consumed = consumed_.load(); + const uint32_t available = written_.load() - consumed; + uint32_t bytes = minU32(minU32(len - copied, remaining), available); + uint32_t index = consumed % kRingBytes; + const uint32_t first = minU32(bytes, kRingBytes - index); + memcpy(out + copied, ring_ + index, first); + memcpy(out + copied + first, ring_, bytes - first); + consumed_.store(consumed + bytes); + pos_ += bytes; + return copied + bytes; +} - const uint32_t readStartUs = micros(); - const uint32_t readBytes = source_.read(data, len); - const uint32_t elapsedUs = micros() - readStartUs; - xSemaphoreGive(sourceMutex_); +bool StreamManager::SdStream::seek(int32_t pos, int dir) { + int64_t target = pos; + if (dir == SEEK_CUR) target += pos_; + else if (dir == SEEK_END) target += getSize(); + else if (dir != SEEK_SET) return false; + if (target == pos_) return true; + // PCM arrives sequentially; only the in-memory header can be revisited. + if (target < 0 || target > kHeaderBytes || pos_ > kHeaderBytes) return false; + pos_ = static_cast(target); + return true; +} - updateDiagnostics(readBytes, elapsedUs); - if (readBytes == 0) { - eof_ = true; - return 0; - } +bool StreamManager::SdStream::ready() const { + if (failed_.load()) return false; + const uint32_t dataPos = pos_ > kHeaderBytes ? pos_ - kHeaderBytes : 0; + const uint32_t needed = minU32(kReadyBytes, dataBytes_ - dataPos); + const uint32_t fromHead = dataPos < headBytes_ ? headBytes_ - dataPos : 0; + return fromHead + (written_.load() - consumed_.load()) >= needed; +} - pos_ += readBytes; - return readBytes; +void StreamManager::SdStream::release() { + State expected = State::OpenRequested; + if (!state_.compare_exchange_strong(expected, State::CloseRequested)) { + expected = State::Streaming; + if (!state_.compare_exchange_strong(expected, State::CloseRequested)) return; } + if (owner_ && owner_->readerTask_) xTaskNotifyGive(static_cast(owner_->readerTask_)); +} - bool seek(int32_t pos, int dir) override { - if (!open_) return false; - if (!sourceMutex_ || xSemaphoreTake(sourceMutex_, pdMS_TO_TICKS(5)) != pdTRUE) return false; - const bool ok = source_.seek(pos, dir); - if (ok) { - pos_ = source_.getPos(); - eof_ = (pos_ >= size_); - } - xSemaphoreGive(sourceMutex_); - return ok; - } +StreamManager::~StreamManager() { shutdown(); } - bool close() override { - bool closed = false; - if (sourceMutex_ && xSemaphoreTake(sourceMutex_, pdMS_TO_TICKS(20)) == pdTRUE) { - closed = source_.close(); - xSemaphoreGive(sourceMutex_); - } - open_ = false; - eof_ = false; - pos_ = 0; - size_ = 0; - return closed; +bool StreamManager::begin(const SampleLibrary::Catalog *catalog) { + shutdown(); + catalog_ = catalog; + diagnostics_ = Diagnostics{}; + const size_t bytes = static_cast(kMaxStreams) * kRingBytes; +#ifdef ESP32 + // Allocated before the RAM sample pool, which is sized from what remains. + ringMemory_ = static_cast(heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); +#endif + if (!ringMemory_) ringMemory_ = static_cast(malloc(bytes)); + if (!ringMemory_) return false; + for (uint8_t i = 0; i < kMaxStreams; i++) { + streams_[i].ring_ = ringMemory_ + static_cast(i) * kRingBytes; + streams_[i].owner_ = this; } + return true; +} - bool isOpen() override { return open_; } - uint32_t getSize() override { return size_; } - uint32_t getPos() override { return pos_; } - bool serviceRefill() { return false; } - - private: - void updateDiagnostics(uint32_t readBytes, uint32_t elapsedUs) { - if (!diagnostics_) return; - - diagnostics_->sourceReadCount++; - diagnostics_->sourceBytesRead += readBytes; - if (elapsedUs > diagnostics_->sourceMaxReadUs) { - diagnostics_->sourceMaxReadUs = elapsedUs; - diagnostics_->sourceMaxReadBytes = readBytes; - } - if (elapsedUs >= kSlowReadThresholdUs) { - diagnostics_->sourceSlowReadCount++; - } +void StreamManager::shutdown() { + // The reader task is never stopped; shutdown() only runs when the audio + // engine is destroyed, which the firmware does not do after startup. + for (SdStream &stream : streams_) { + closeFile(stream); + stream.state_.store(SdStream::State::Free); + stream.ring_ = nullptr; } - - AudioFileSourceSD source_; - ValidatedWavSource view_; - SemaphoreHandle_t sourceMutex_ = xSemaphoreCreateMutex(); - Diagnostics *diagnostics_ = nullptr; - bool open_ = false; - bool eof_ = false; - uint32_t pos_ = 0; - uint32_t size_ = 0; -}; - -StreamManager::~StreamManager() { - shutdown(); + free(ringMemory_); + ringMemory_ = nullptr; } -bool StreamManager::begin(uint8_t streamCount, const SampleLibrary::Catalog *catalog) { - shutdown(); - if (streamCount == 0 || streamCount > kMaxStreams) { +bool StreamManager::startReaderTask(uint8_t priority, int core) { + if (readerTask_ || !ringMemory_) return readerTask_ != nullptr; + TaskHandle_t handle = nullptr; + if (xTaskCreatePinnedToCore(readerTaskEntry, "sd_reader", kReaderStackBytes, this, priority, + &handle, core) != pdPASS) { return false; } + readerTask_ = handle; + return true; +} - catalog_ = catalog; - diagnostics_ = Diagnostics{}; - for (uint8_t i = 0; i < streamCount; i++) { - streams_[i] = new BufferedSdSource(&diagnostics_); - if (!streams_[i]) { - shutdown(); - return false; - } +StreamManager::SdStream *StreamManager::openStream(const char *path, + bool loop, + const uint8_t *head, + uint32_t headBytes) { + if (!catalog_ || !ringMemory_ || !path) return nullptr; + const int index = SampleLibrary::findIndexByPath(*catalog_, path); + if (!catalog_->playable(index)) return nullptr; + const WavValidation::Result &info = catalog_->validation[index]; + if (strlen(path) >= sizeof(streams_[0].path_)) return nullptr; + // Host tests have no reader task: reclaim released streams here. + if (!readerTask_) serviceAll(); + + for (SdStream &stream : streams_) { + if (stream.state_.load() != SdStream::State::Free) continue; + strcpy(stream.path_, path); + stream.fileDataOffset_ = info.dataOffset; + stream.dataBytes_ = info.dataBytes; + stream.head_ = head; + stream.headBytes_ = head ? minU32(headBytes, info.dataBytes) : 0; + WavValidation::pcmHeader(stream.header_, info.dataBytes); + stream.pos_ = 0; + stream.loop_.store(loop); + stream.failed_.store(false); + stream.written_.store(0); + stream.consumed_.store(0); + stream.state_.store(SdStream::State::OpenRequested); + if (readerTask_) xTaskNotifyGive(static_cast(readerTask_)); + return &stream; } + diagnostics_.noFreeStreamCount++; + return nullptr; +} - streamCount_ = streamCount; - return true; +void StreamManager::serviceAll() { + while (serviceOnce()) { + } } -void StreamManager::shutdown() { - for (uint8_t i = 0; i < kMaxStreams; i++) { - delete streams_[i]; - streams_[i] = nullptr; +void StreamManager::readerTaskEntry(void *context) { + static_cast(context)->runReader(); +} + +void StreamManager::runReader() { + TickType_t busySince = xTaskGetTickCount(); + for (;;) { + if (serviceOnce()) { + if (xTaskGetTickCount() - busySince >= pdMS_TO_TICKS(kReaderBusySliceMs)) { + vTaskDelay(1); + busySince = xTaskGetTickCount(); + } + continue; + } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kReaderIdleWaitMs)); + busySince = xTaskGetTickCount(); } - streamCount_ = 0; } -bool StreamManager::openStream(uint8_t streamId, const char *path) { - if (streamId >= streamCount_ || !streams_[streamId]) return false; - 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]); +bool StreamManager::serviceOnce() { + bool worked = false; + for (SdStream &stream : streams_) { + if (stream.state_.load() != SdStream::State::CloseRequested) continue; + closeFile(stream); + stream.state_.store(SdStream::State::Free); + worked = true; + } + // Open one file per pass so a burst of triggers cannot starve running streams. + for (SdStream &stream : streams_) { + if (stream.state_.load() != SdStream::State::OpenRequested) continue; + if (!openFile(stream)) { + stream.failed_.store(true); + diagnostics_.openFailureCount++; + closeFile(stream); + } + SdStream::State expected = SdStream::State::OpenRequested; + stream.state_.compare_exchange_strong(expected, SdStream::State::Streaming); + worked = true; + break; + } + return fillOnce() || worked; } -AudioFileSource *StreamManager::sourceForStream(uint8_t streamId) { - if (streamId >= streamCount_) return nullptr; - return streams_[streamId]->validatedSource(); +bool StreamManager::openFile(SdStream &stream) { + stream.file_ = SD.open(stream.path_); + stream.readerDataPos_ = stream.headBytes_; + if (!stream.file_) return false; + // The validation cache may be stale if the card changed since boot. + if (stream.file_.size() < stream.fileDataOffset_ + stream.dataBytes_) return false; + return stream.file_.seek(stream.fileDataOffset_ + stream.headBytes_); } -void StreamManager::closeStream(uint8_t streamId) { - if (streamId >= streamCount_ || !streams_[streamId]) return; - streams_[streamId]->close(); +void StreamManager::closeFile(SdStream &stream) { + if (stream.file_) stream.file_.close(); + stream.file_ = File(); } -void StreamManager::closeAll() { - for (uint8_t i = 0; i < streamCount_; i++) { - if (streams_[i]) { - streams_[i]->close(); +bool StreamManager::fillOnce() { + // Serve the stream closest to running dry. + SdStream *target = nullptr; + uint32_t targetBuffered = 0; + bool worked = false; + for (SdStream &stream : streams_) { + if (stream.state_.load() != SdStream::State::Streaming || stream.failed_.load()) continue; + // A head covering the whole sample leaves nothing to read. + if (stream.headBytes_ >= stream.dataBytes_) continue; + if (stream.readerDataPos_ >= stream.dataBytes_) { + if (!stream.loop_.load()) continue; + // Loops continue straight into the next iteration, so a restart + // finds PCM already buffered. The head replays from RAM. + if (!stream.file_.seek(stream.fileDataOffset_ + stream.headBytes_)) { + stream.failed_.store(true); + continue; + } + stream.readerDataPos_ = stream.headBytes_; + worked = true; + } + const uint32_t buffered = stream.written_.load() - stream.consumed_.load(); + const uint32_t remaining = stream.dataBytes_ - stream.readerDataPos_; + if (kRingBytes - buffered < minU32(kMinReadBytes, remaining)) continue; + if (!target || buffered < targetBuffered) { + target = &stream; + targetBuffered = buffered; } } -} + if (!target) return worked; -const StreamManager::Diagnostics &StreamManager::diagnostics() const { - return diagnostics_; + const uint32_t written = target->written_.load(); + const uint32_t index = written % kRingBytes; + uint32_t bytes = targetBuffered < kUrgentBufferedBytes ? kUrgentReadBytes : kReadBytes; + bytes = minU32(bytes, kRingBytes - targetBuffered); + bytes = minU32(bytes, kRingBytes - index); + bytes = minU32(bytes, target->dataBytes_ - target->readerDataPos_); + + const uint32_t startUs = micros(); + const int got = static_cast(target->file_.read(target->ring_ + index, bytes)); + const uint32_t elapsedUs = micros() - startUs; + diagnostics_.readCount++; + if (elapsedUs > diagnostics_.maxReadUs) { + diagnostics_.maxReadUs = elapsedUs; + diagnostics_.maxReadBytes = bytes; + } + if (got <= 0) { + target->failed_.store(true); + return true; + } + diagnostics_.bytesRead += static_cast(got); + target->readerDataPos_ += static_cast(got); + target->written_.store(written + static_cast(got)); + return true; } diff --git a/src/trigger_engine.cpp b/src/trigger_engine.cpp index 993a4f4..99f7554 100644 --- a/src/trigger_engine.cpp +++ b/src/trigger_engine.cpp @@ -190,6 +190,11 @@ void TriggerEngine::processTriggerEvent(const TriggerEvent &event) { } if (event.path[0] != '\0') { - audio_->playSamplePath(String(event.path), event.volume, event.retriggerGroupId, event.loopEnabled); + audio_->playSamplePath(String(event.path), + event.volume, + event.retriggerGroupId, + event.loopEnabled, + event.streamHead, + event.streamHeadBytes); } } diff --git a/src/validated_wav_source.cpp b/src/validated_wav_source.cpp deleted file mode 100644 index a4a6ac8..0000000 --- a/src/validated_wav_source.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#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/test/support/AudioOutputI2S.h b/test/support/AudioOutputI2S.h index 25d6699..c7a90b2 100644 --- a/test/support/AudioOutputI2S.h +++ b/test/support/AudioOutputI2S.h @@ -3,17 +3,46 @@ #include #include // Hardware boundary only. Production decoder, voice engine and mixer run unchanged. +#ifndef IRAM_ATTR +#define IRAM_ATTR +#endif +#ifndef ESP_OK +#define ESP_OK 0 +#endif +using i2s_chan_handle_t = void *; +struct i2s_event_data_t { void *dma_buf; size_t size; }; +using i2s_isr_callback_t = bool (*)(i2s_chan_handle_t, i2s_event_data_t *, void *); +struct i2s_event_callbacks_t { + i2s_isr_callback_t on_recv, on_recv_q_ovf, on_sent, on_send_q_ovf; +}; namespace FakeI2S { -inline int capacity = 0, starts = 0, stops = 0, driverCalls = 0; +inline int capacity = 0, starts = 0, stops = 0, driverCalls = 0, enabled = 0; inline std::vector> frames; -inline void reset() { capacity = starts = stops = driverCalls = 0; frames.clear(); } +inline i2s_isr_callback_t onSendQueueOverflow = nullptr; +inline void *callbackContext = nullptr; +inline void reset() { + capacity = starts = stops = driverCalls = enabled = 0; + frames.clear(); + onSendQueueOverflow = nullptr; + callbackContext = nullptr; +} +// Simulate the DMA running out of queued blocks. +inline void underrun() { + i2s_event_data_t event{nullptr, 512}; + if (onSendQueueOverflow) onSendQueueOverflow(nullptr, &event, callbackContext); +} } 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 begin() override { + ++FakeI2S::starts; + FakeI2S::enabled = 1; + 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; @@ -26,6 +55,23 @@ class AudioOutputI2S : public AudioOutput { bool mono = false; void *_tx_handle = this; }; +inline int i2s_channel_disable(void *) { + if (!FakeI2S::enabled) return -1; + FakeI2S::enabled = 0; + return ESP_OK; +} +inline int i2s_channel_enable(void *) { + if (FakeI2S::enabled) return -1; + FakeI2S::enabled = 1; + return ESP_OK; +} +inline int i2s_channel_register_event_callback(void *, const i2s_event_callbacks_t *callbacks, + void *context) { + if (FakeI2S::enabled) return -1; // The driver rejects registration while running. + FakeI2S::onSendQueueOverflow = callbacks->on_send_q_ovf; + FakeI2S::callbackContext = context; + return ESP_OK; +} 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); diff --git a/test/support/freertos/task.h b/test/support/freertos/task.h index 215a0f2..1a92817 100644 --- a/test/support/freertos/task.h +++ b/test/support/freertos/task.h @@ -8,3 +8,12 @@ inline void vTaskDelay(TickType_t) {} inline void vTaskDelete(void *) {} inline BaseType_t xPortGetCoreID() { return 0; } inline UBaseType_t uxTaskPriorityGet(TaskHandle_t) { return 0; } +typedef void (*TaskFunction_t)(void *); +// Host tests run without tasks; creation fails so callers stay synchronous. +inline BaseType_t xTaskCreatePinnedToCore(TaskFunction_t, const char *, uint32_t, void *, + UBaseType_t, TaskHandle_t *, BaseType_t) { + return 0; +} +inline void xTaskNotifyGive(TaskHandle_t) {} +inline uint32_t ulTaskNotifyTake(BaseType_t, TickType_t) { return 0; } +inline TickType_t xTaskGetTickCount() { return 0; } diff --git a/test/test_audio_playback/test_main.cpp b/test/test_audio_playback/test_main.cpp index b9fc937..ff0223d 100644 --- a/test/test_audio_playback/test_main.cpp +++ b/test/test_audio_playback/test_main.cpp @@ -6,7 +6,6 @@ #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" @@ -132,6 +131,25 @@ void test_slot_reuse_after_natural_end_has_no_stale_pcm() { TEST_ASSERT_EQUAL_UINT(0, rig.audio.voiceStealCount()); } +void test_preloaded_head_matches_plain_sd_stream() { + // Head shorter than the sample, and one covering all of it (no SD reads). + for (bool loop : {false, true}) for (uint32_t headBytes : {4096u, 20000u}) { + FakeSD::files.clear(); const auto pcm = tone(7003, 197, 10000); saveWav("/samples/a.wav", pcm); + const auto *head = reinterpret_cast(pcm.data()); + std::vector> plain; + for (bool withHead : {false, true}) { + Rig rig; rig.begin(); + rig.audio.playSamplePath("/samples/a.wav", 100, -1, loop, withHead ? head : nullptr, + withHead ? headBytes : 0); + rig.advance(3 * pcm.size() + kDelay, true); + if (!withHead) plain = FakeI2S::frames; + } + TEST_ASSERT_EQUAL_UINT(plain.size(), FakeI2S::frames.size()); + for (size_t i = 0; i < plain.size(); ++i) TEST_ASSERT_EQUAL_MEMORY(plain[i].data(), FakeI2S::frames[i].data(), 4); + if (!loop) { Pcm expected(pcm); expected.resize(3 * pcm.size(), 0); assertReference(expected); } + } +} + int largestStep(size_t first, size_t end) { int step = 0; for (size_t i = std::max(size_t(1), first); i < end; ++i) @@ -251,6 +269,7 @@ int main() { UNITY_BEGIN(); 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_preloaded_head_matches_plain_sd_stream); RUN_TEST(test_nonzero_start_is_ramped_without_reducing_sustain); RUN_TEST(test_nonzero_eof_is_ramped_without_cutting_other_voice); diff --git a/test/test_i2s_transport/test_main.cpp b/test/test_i2s_transport/test_main.cpp index 909a363..6fb4a26 100644 --- a/test/test_i2s_transport/test_main.cpp +++ b/test/test_i2s_transport/test_main.cpp @@ -34,6 +34,23 @@ void test_esp32_adapter_packs_full_level_pcm_without_extra_attenuation() { TEST_ASSERT_EQUAL_INT(32767, frame[0]); TEST_ASSERT_EQUAL_INT(-32767, frame[1]); } } +void test_esp32_adapter_counts_dma_underruns_and_stays_enabled() { + FakeI2S::reset(); + AudioInternal::StableAudioOutputI2S out(0, 0, 8, 1); + TEST_ASSERT_TRUE(out.begin()); + TEST_ASSERT_EQUAL_INT(1, FakeI2S::enabled); + TEST_ASSERT_NOT_NULL(FakeI2S::onSendQueueOverflow); + TEST_ASSERT_EQUAL_UINT32(0, out.underrunCount()); + FakeI2S::underrun(); + FakeI2S::underrun(); + TEST_ASSERT_EQUAL_UINT32(2, out.underrunCount()); + FakeI2S::capacity = 128; + for (int i = 0; i < 128; ++i) { + int16_t frame[2] = {1, -1}; + TEST_ASSERT_TRUE(out.ConsumeSample(frame)); + } + TEST_ASSERT_EQUAL_UINT(128, FakeI2S::frames.size()); +} 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) { @@ -99,6 +116,7 @@ void setUp() {} void tearDown() {} int main() { UNITY_BEGIN(); RUN_TEST(test_esp32_adapter_packs_full_level_pcm_without_extra_attenuation); + RUN_TEST(test_esp32_adapter_counts_dma_underruns_and_stays_enabled); 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); diff --git a/test/test_playback_router/test_main.cpp b/test/test_playback_router/test_main.cpp index b445ab3..f9b73d0 100644 --- a/test/test_playback_router/test_main.cpp +++ b/test/test_playback_router/test_main.cpp @@ -40,6 +40,7 @@ struct RuntimeStubState { TriggerStubState gTriggerStub; std::unordered_map gRuntimeState; std::unordered_map gRamByPath; +std::unordered_map gHeadByPath; constexpr int kSampleCount = 2; const String kNames[kSampleCount] = {"Kick", "Snare"}; @@ -85,6 +86,14 @@ void setLoadedRamSample(const String &path, const uint8_t *data, uint32_t bytes) void clearLoadedRamSamples() { gRamByPath.clear(); + gHeadByPath.clear(); +} + +void setLoadedHead(const String &path, const uint8_t *data, uint32_t bytes) { + SampleRamManager::LoadedSampleData head; + head.data = data; + head.dataBytes = bytes; + gHeadByPath[path.c_str()] = head; } Ui createUi() { @@ -270,6 +279,39 @@ void test_assigned_note_ram_mode_enqueues_ram_event() { clearRuntimeState(runtime); } +void test_assigned_stream_note_passes_preloaded_head() { + Ui ui = createUi(); + const SampleLibrary::Catalog catalog = createCatalog(); + SamplerRuntime runtime; + TriggerEngine trigger; + SamplerPlaybackRouter router; + router.begin(&ui, &catalog, &runtime, &trigger); + + static const uint8_t kHead[4] = {1, 2, 3, 4}; + TEST_ASSERT_TRUE(ui.setMidiAssignment(60, 0)); + TEST_ASSERT_TRUE(ui.setMidiAssignment(61, 1)); + for (int note : {60, 61}) { + ActiveSampleRegistry::Entry entry; + entry.note = note; + entry.path = kPaths[note - 60]; + entry.effectiveMode = ActiveSampleRegistry::EffectiveStorageMode::Stream; + setRegistryEntry(runtime, note, entry); + } + setLoadedHead("/samples/kick.wav", kHead, sizeof(kHead)); + + router.onAssignedMidiNoteOn(60); + router.onAssignedMidiNoteOn(61); // No head loaded: streams from the first byte. + + TEST_ASSERT_EQUAL_UINT32(2, gTriggerStub.events.size()); + TEST_ASSERT_EQUAL(TriggerSourceType::StreamPath, gTriggerStub.events[0].source); + TEST_ASSERT_EQUAL_PTR(kHead, gTriggerStub.events[0].streamHead); + TEST_ASSERT_EQUAL_UINT32(sizeof(kHead), gTriggerStub.events[0].streamHeadBytes); + TEST_ASSERT_NULL(gTriggerStub.events[1].streamHead); + TEST_ASSERT_EQUAL_UINT32(0, gTriggerStub.events[1].streamHeadBytes); + + clearRuntimeState(runtime); +} + void test_assigned_note_ram_enqueue_failure_falls_back_to_stream() { Ui ui = createUi(); const SampleLibrary::Catalog catalog = createCatalog(); @@ -447,6 +489,16 @@ bool getLoadedSampleDataByPath(const String &path, LoadedSampleData &data) { return true; } +bool getLoadedHeadByPath(const String &path, LoadedSampleData &data) { + const auto it = gHeadByPath.find(path.c_str()); + if (it == gHeadByPath.end()) { + data = LoadedSampleData{}; + return false; + } + data = it->second; + return true; +} + } // namespace SampleRamManager #include "../../src/ui.cpp" @@ -462,6 +514,7 @@ int main() { RUN_TEST(test_assigned_note_unavailable_does_not_enqueue); RUN_TEST(test_assigned_note_ram_mode_enqueues_ram_event); RUN_TEST(test_assigned_note_ram_enqueue_failure_falls_back_to_stream); + RUN_TEST(test_assigned_stream_note_passes_preloaded_head); RUN_TEST(test_playback_mode_change_to_oneshot_stops_looping_voices); RUN_TEST(test_playback_mode_change_to_loop_does_not_stop_voices); return UNITY_END(); diff --git a/test/test_wav_validation/test_main.cpp b/test/test_wav_validation/test_main.cpp index e979f95..f232c13 100644 --- a/test/test_wav_validation/test_main.cpp +++ b/test/test_wav_validation/test_main.cpp @@ -3,9 +3,9 @@ #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" +#include "../../src/stream_manager.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); } @@ -94,24 +94,106 @@ struct Source : AudioFileSource { 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_classifier_preloads_long_samples_shortest_first_within_budget() { + SampleLibrary::Catalog catalog; catalog.count = 3; + const uint32_t sizes[3] = {10 * 88200, 20 * 88200, 3 * 88200}; // 10 s, 20 s, 3 s + for (int i = 0; i < 3; ++i) { + const char name[] = {'/', char('a' + i), '\0'}; + catalog.paths[i] = String(name); + catalog.validation[i].status = WavValidation::Status::Valid; + catalog.validation[i].dataOffset = 44; catalog.validation[i].dataBytes = sizes[i]; + } + SettingsStore::SamplerSettings settings; settings.assignmentCount = 3; + for (int i = 0; i < 3; ++i) settings.assignments[i].samplePath = catalog.paths[i]; + SampleClassifier::ClassificationReport report; + // Everything fits: no per-sample duration limit. + settings.sampleRamBudgetBytes = 33 * 88200; + SampleClassifier::classifyAssignedSamples(settings, catalog, report); + TEST_ASSERT_EQUAL_INT(3, report.ramSampleCount); + TEST_ASSERT_EQUAL_UINT32(33 * 88200, report.sampleRamUsedBytes); + TEST_ASSERT_EQUAL_UINT32(0, report.items[1].headBytes); + // 13 s plus one head fit: the two shortest go to RAM regardless of + // assignment order, and the streamed one keeps its start in RAM. + const uint32_t head = SampleClassifier::kStreamHeadBytes; + settings.sampleRamBudgetBytes = 13 * 88200 + head; + SampleClassifier::classifyAssignedSamples(settings, catalog, report); + TEST_ASSERT_TRUE(report.items[0].mode == SampleClassifier::StorageMode::Ram); + TEST_ASSERT_TRUE(report.items[1].mode == SampleClassifier::StorageMode::Stream); + TEST_ASSERT_TRUE(report.items[2].mode == SampleClassifier::StorageMode::Ram); + TEST_ASSERT_EQUAL_UINT32(head, report.items[1].headBytes); + TEST_ASSERT_EQUAL_UINT32(13 * 88200 + head, report.sampleRamUsedBytes); + TEST_ASSERT_EQUAL_INT(2, report.ramSampleCount); TEST_ASSERT_EQUAL_INT(1, report.streamSampleCount); + // Without room for the head, the 10 s sample streams too. + settings.sampleRamBudgetBytes = 13 * 88200; + SampleClassifier::classifyAssignedSamples(settings, catalog, report); + TEST_ASSERT_TRUE(report.items[0].mode == SampleClassifier::StorageMode::Stream); + TEST_ASSERT_EQUAL_UINT32(head, report.items[0].headBytes); +} +SampleLibrary::Catalog catalogWith(const Bytes &file) { + FakeSD::files.clear(); FakeSD::files["/samples/x.wav"]=file; + SampleLibrary::Catalog catalog; SampleLibrary::loadFromSd(catalog); return catalog; +} 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 catalog=catalogWith(b); const auto &info=catalog.validation[0]; + StreamManager streams; TEST_ASSERT_TRUE(streams.begin(&catalog)); + auto *stream=streams.openStream("/samples/x.wav",false); TEST_ASSERT_NOT_NULL(stream); + uint8_t out[100]={}; const int reads=FakeSD::reads; + // The header comes from memory; PCM is not ready until the reader runs. + TEST_ASSERT_EQUAL_UINT32(44,stream->read(out,44)); TEST_ASSERT_EQUAL_INT(reads,FakeSD::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()); + TEST_ASSERT_FALSE(stream->ready()); TEST_ASSERT_EQUAL_UINT32(0,stream->read(out,100)); + streams.serviceAll(); TEST_ASSERT_TRUE(stream->ready()); + TEST_ASSERT_EQUAL_UINT32(8,stream->read(out,100)); TEST_ASSERT_EQUAL_MEMORY(b.data()+info.dataOffset,out,8); + TEST_ASSERT_EQUAL_UINT32(0,stream->read(out,1)); TEST_ASSERT_TRUE(stream->ready()); + // PCM is sequential: only the header can be revisited, and only before PCM. + TEST_ASSERT_FALSE(stream->seek(40,SEEK_SET)); TEST_ASSERT_TRUE(stream->seek(0,SEEK_END)); + stream->release(); streams.serviceAll(); + TEST_ASSERT_EQUAL_UINT32(0,streams.diagnostics().openFailureCount); +} +void test_looping_stream_buffers_next_iteration() { + auto catalog=catalogWith(fixture()); StreamManager streams; TEST_ASSERT_TRUE(streams.begin(&catalog)); + auto *stream=streams.openStream("/samples/x.wav",true); streams.serviceAll(); + uint8_t first[52]={}, second[52]={}; + TEST_ASSERT_EQUAL_UINT32(52,stream->read(first,100)); TEST_ASSERT_EQUAL_UINT32(0,stream->read(first+52,1)); + // The next iteration is already buffered: no SD access is needed to restart. + const int reads=FakeSD::reads; stream->rewind(); TEST_ASSERT_TRUE(stream->ready()); + TEST_ASSERT_EQUAL_UINT32(52,stream->read(second,100)); TEST_ASSERT_EQUAL_INT(reads,FakeSD::reads); + TEST_ASSERT_EQUAL_MEMORY(first,second,52); +} +void test_preloaded_head_plays_before_sd_and_is_not_reread() { + Bytes b(44+600); WavValidation::pcmHeader(b.data(),600); + for (int i=0;i<600;++i) b[44+i]=uint8_t(i*7+1); + auto catalog=catalogWith(b); StreamManager streams; TEST_ASSERT_TRUE(streams.begin(&catalog)); + const Bytes head(b.begin()+44,b.begin()+44+300); + auto *stream=streams.openStream("/samples/x.wav",true,head.data(),300); + // Playable before the reader has opened the file. + uint8_t out[700]={}; FakeSD::bytesRead=0; + TEST_ASSERT_TRUE(stream->ready()); TEST_ASSERT_EQUAL_UINT32(44,stream->read(out,44)); + TEST_ASSERT_EQUAL_UINT32(300,stream->read(out,700)); TEST_ASSERT_EQUAL_MEMORY(head.data(),out,300); + TEST_ASSERT_EQUAL_size_t(0,FakeSD::bytesRead); TEST_ASSERT_FALSE(stream->ready()); + streams.serviceAll(); TEST_ASSERT_TRUE(stream->ready()); + // The reader skips the head on every iteration of the loop. + TEST_ASSERT_GREATER_THAN(0,FakeSD::bytesRead); TEST_ASSERT_EQUAL_size_t(0,FakeSD::bytesRead%300); + TEST_ASSERT_EQUAL_UINT32(300,stream->read(out,700)); TEST_ASSERT_EQUAL_MEMORY(b.data()+344,out,300); + stream->rewind(); TEST_ASSERT_EQUAL_UINT32(44,stream->read(out,44)); + TEST_ASSERT_EQUAL_UINT32(600,stream->read(out,700)); TEST_ASSERT_EQUAL_MEMORY(b.data()+44,out,600); } 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()); + auto catalog=catalogWith(fixture()); StreamManager streams; TEST_ASSERT_TRUE(streams.begin(&catalog)); + TEST_ASSERT_NULL(streams.openStream("/samples/missing.wav",false)); + catalog.validation[0].status=Status::Unchecked; + TEST_ASSERT_NULL(streams.openStream("/samples/x.wav",false)); + catalog.validation[0].status=Status::Valid; FakeSD::files["/samples/x.wav"].pop_back(); + auto *stream=streams.openStream("/samples/x.wav",false); streams.serviceAll(); + TEST_ASSERT_TRUE(stream->failed()); TEST_ASSERT_FALSE(stream->ready()); + TEST_ASSERT_EQUAL_UINT32(1,streams.diagnostics().openFailureCount); +} +void test_all_streams_in_use_rejects_trigger() { + auto catalog=catalogWith(fixture()); StreamManager streams; TEST_ASSERT_TRUE(streams.begin(&catalog)); + for (int i=0;i