diff --git a/DECISIONS.md b/DECISIONS.md index 9947943..bfd8b97 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -111,3 +111,4 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-105 | The id a shared loop carries (§10.2, T-59): the share view hands the encoder the loop's **own** 6-character base36 id in place of whatever lineage the loop holds, so a device that loads the code stores that id as its lineage and its own share view says `based on `. The id is FNV-1a over the loop's code without a lineage, folded into six base36 characters, and it lives in `io/share.cpp`; `engine::encode` is untouched. | 2026-09-03. §10.2 says the device generates an id for every loop that is shared and that loading a code stores that id as the child's lineage; share-format §2 and §4 read as though `~` always named the parent, which cannot track parentage at all — a re-shared loop would name its grandparent and a loop made from scratch would share with no id, so no receiver could ever name what it came from. A hash rather than a draw from the seeded PRNG because it needs no storage and no PRNG state, survives a power cycle, and gives the same loop the same id: an unchanged loop re-shared produces the identical code, and the chain moves only when the loop does. Replacing the lineage rather than appending an id keeps the worst case at 230 characters, so the QR stays version 10-L (§10.2) and every golden code round-trips unchanged. It does cost a QR version on short codes — the tutorial's own loop goes from 33 modules to 37 — and the taller square leaves no room for the scan hint on the share view's tutorial step, which the layout drops rather than overrun; `spec/screens/06-share-tutorial-step6.png` shows it. Rejected: a second `State` field for the loop's own id (a wider struct and a grammar question for a value that is a function of the loop), and a random id per share (the QR would change every time the view opened). | If two loops that are the same but made by different people ever need to be told apart, which a hash cannot do, or if the scan hint's absence on the tutorial's share step confuses testers in usability round 1. | | D-106 | The share-code size cap (T-62) lives in `engine::decode` and `decode_song`, not at each path the bytes arrive on: 512 characters for a section and 2048 for a song, NUL not counted, scanning at most one past the limit so a missing terminator costs a bounded read. | 2026-09-03. The engine's decoder is linear and allocates nothing, so the cap is about refusing a code rather than about safety; putting it where the knowledge is means the card, `tools/render`, and the SysEx, USB and paste-box paths still to come are all covered without each remembering to. Twice the canonical worst case (238 and 988) rather than the buffer sizes, because T-16 says a decoder skips the fields a later version adds and a cap at the buffer would leave almost no room for them. Rejected: a cap at each arriving path (three places to forget, and nothing to write today since none of those paths exists yet). | If a later version's fields need more than the canonical code's length again. | | D-107 | A song slot whose file will not parse (§9.6, T-97): a tap refuses it and says `hold to replace song 2`; a hold on that pad copies the song on screen over it and says `song 2 replaced`. The hold is live only on a slot the device already knows it cannot read, so no hold can destroy a song the player could still open, and a hold anywhere else in the song view still does nothing. | 2026-09-03. D-104 made a file that will not parse a slot rather than a gap, which stopped a pick from silently copying over somebody's song but left the slot unreachable: the only thing that replaced it was the player already being on it, so recovery meant a computer or a factory reset — a dead pad on an instrument that ships with no manual. Press-then-hold is already this device's idiom for a destructive thing, in `hold dice to clear` and `hold play to reset`, and §9.6 said the pads' hold gestures were inactive in the song view, so the gesture was free and consistent rather than new; PRD §9.6 gains the exception. Rejected: a second tap inside the arming timeout (a double tap is easy by accident, and every other confirmation here is a hold); quarantining the file at boot (needs a HAL rename or delete, and silently moves a player's file aside); a settings row to clear a slot (a sub-menu for a rare failure, against §9.6's one screen). | If usability round 1 shows nobody finds the hold, or if a fourth tile state would say it better than the status line. | +| D-108 | A kit's samples come off the card, not the firmware image: `io::load_samples` reads `kits//` into the PSRAM `hal::sample_memory()` hands it — 1.5 MB, the eight pads of two seconds D-081 allows each — and fills a `sound::SampleBank` with what it found. Each WAV is read straight into the room left in that memory and parsed where it lands, then moved down over its own header, so nothing is ever staged twice. A file that is missing or is not 16-bit 48 kHz mono PCM costs its own pad its sound and no more. The simulator does the same thing with the same code: `host/CMakeLists.txt` seeds `out/sdcard/kits/` from `spec/kits/` at configure time. | 2026-09-03. §7.5 puts the samples in PSRAM and the kits on the card, and until now the device passed `app::init` an empty bank, so the drum pads were silent on hardware and the simulator used the render tool's host-only loader — two paths, one of them not the product's. Reading in place rather than through a staging buffer because a sample is up to 192 KB and the device has 63 KB of ordinary RAM free; there is nowhere to put a copy. `hal::sample_memory()` answers with nothing when no PSRAM is fitted, which is every board until bring-up, because writing to a section that is not backed would fault rather than fail. Rejected: samples in the firmware image (a kit could never be changed without a rebuild, which is what §12 rule 6 is against); streaming from the card at play time (file I/O in the audio path, forbidden by §12 rule 4). | At bring-up, when a real PSRAM chip says whether reads from it keep up with sixteen voices; or if a kit needs more than eight samples of two seconds. | diff --git a/firmware/src/hal/hal.h b/firmware/src/hal/hal.h index 78603f3..7f701bf 100644 --- a/firmware/src/hal/hal.h +++ b/firmware/src/hal/hal.h @@ -15,6 +15,12 @@ constexpr int kPadCount = 8; constexpr int kAudioSampleRate = 48000; // §7.4; equals sound::kSampleRate constexpr int kAudioBlockFrames = 128; // equals sound::kBlockSize +// Room for one kit: eight pads of the two seconds D-081 allows each, and a little +// over so a sample of exactly two seconds still has room for its file's header while +// io/ reads it in place. +constexpr uint32_t kMaxSampleFramesPerPad = kAudioSampleRate * 2; +constexpr uint32_t kSampleMemoryFrames = kPadCount * kMaxSampleFramesPerPad + 64; + // The round and section buttons in the order of §7.2. enum class Button : uint8_t { split, swap, skip, undo, dice, show, play, section_a, section_b, section_c, section_d }; constexpr int kButtonCount = 11; @@ -98,6 +104,13 @@ enum class FileRead : uint8_t { missing, unusable, ok }; FileRead read_file(const char* path, uint8_t* out, uint32_t capacity, uint32_t* size); bool write_file(const char* path, const uint8_t* data, uint32_t size); +// Where a kit's samples are kept once io/ has read them off the card: the PSRAM of +// §7.5 on the device, ordinary memory on the host. Megabytes, so not the RAM2 that +// HAL_BULK_MEMORY names. Returns nullptr and 0 frames when the board has no PSRAM +// fitted — which every board does until bring-up — and io/ then leaves the sample +// pads silent rather than writing to memory that is not there. +int16_t* sample_memory(uint32_t* frames); + // Power (§7.7, §7.4): 0–100, and whether the headphone jack has a plug in it. int battery_percent(); bool headphones_inserted(); diff --git a/firmware/src/hal/sdl/storage_sdl.cpp b/firmware/src/hal/sdl/storage_sdl.cpp index 0ffb383..309b440 100644 --- a/firmware/src/hal/sdl/storage_sdl.cpp +++ b/firmware/src/hal/sdl/storage_sdl.cpp @@ -14,6 +14,13 @@ std::string full_path(const char* path) { return std::string(ROTA_STORAGE_DIR) + namespace hal { +// The host has no PSRAM to speak of and no reason to care: an ordinary array. +int16_t* sample_memory(uint32_t* frames) { + static int16_t memory[kSampleMemoryFrames]; + *frames = kSampleMemoryFrames; + return memory; +} + FileRead read_file(const char* path, uint8_t* out, uint32_t capacity, uint32_t* size) { *size = 0; const std::filesystem::path target = full_path(path); diff --git a/firmware/src/hal/teensy/storage_teensy.cpp b/firmware/src/hal/teensy/storage_teensy.cpp index 20a0071..9bd4ea4 100644 --- a/firmware/src/hal/teensy/storage_teensy.cpp +++ b/firmware/src/hal/teensy/storage_teensy.cpp @@ -38,6 +38,22 @@ void storage_init() { namespace hal { +// The 8 MB PSRAM on the board's small QSPI pads (§7.5, D-060), which the Teensy core +// maps as EXTMEM. `external_psram_size` is the core's own count in megabytes and is 0 +// when no chip is fitted; writing to the section then would fault, so io/ is told +// there is nowhere to put samples and leaves the sample pads silent. +extern "C" uint8_t external_psram_size; +EXTMEM int16_t sample_memory_[kSampleMemoryFrames]; + +int16_t* sample_memory(uint32_t* frames) { + if (external_psram_size == 0) { + *frames = 0; + return nullptr; + } + *frames = kSampleMemoryFrames; + return sample_memory_; +} + FileRead read_file(const char* path, uint8_t* out, uint32_t capacity, uint32_t* size) { *size = 0; if (!card_ready_) return FileRead::missing; diff --git a/firmware/src/io/kit.cpp b/firmware/src/io/kit.cpp new file mode 100644 index 0000000..0c80ff8 --- /dev/null +++ b/firmware/src/io/kit.cpp @@ -0,0 +1,121 @@ +#include "io/kit.h" + +#include +#include + +#include "hal/hal.h" +#include "sound/limits.h" + +namespace io { + +namespace { + +constexpr int kPathCapacity = 64; +constexpr uint32_t kRiffHeaderBytes = 12; +constexpr uint32_t kChunkHeaderBytes = 8; +constexpr uint32_t kFormatChunkBytes = 16; // the least a fmt chunk may hold +constexpr uint16_t kPcm = 1; +constexpr uint16_t kMono = 1; +constexpr uint16_t kBitsPerSample = 16; + +uint16_t little_u16(const uint8_t* at) { return static_cast(at[0] | (at[1] << 8)); } + +uint32_t little_u32(const uint8_t* at) { + return static_cast(at[0]) | (static_cast(at[1]) << 8) | (static_cast(at[2]) << 16) | + (static_cast(at[3]) << 24); +} + +void refuse(const char* path, const char* why) { + char line[kPathCapacity + 48]; + std::snprintf(line, sizeof line, "io: %s %s", path, why); + hal::log(line); +} + +// Finds the PCM inside a RIFF WAVE already in memory: on success `offset` and +// `frames` say where the samples are and how many. A card is a boundary, so every +// field is checked and nothing is trusted to be there (D-081, T-77). +bool find_pcm(const uint8_t* bytes, uint32_t size, const char* path, uint32_t& offset, uint32_t& frames) { + if (size < kRiffHeaderBytes || std::memcmp(bytes, "RIFF", 4) != 0 || std::memcmp(bytes + 8, "WAVE", 4) != 0) { + refuse(path, "is not a RIFF WAVE file"); + return false; + } + bool has_format = false; + uint32_t at = kRiffHeaderBytes; + while (at + kChunkHeaderBytes <= size) { + const uint8_t* chunk = bytes + at; + const uint32_t length = little_u32(chunk + 4); + const uint32_t body = at + kChunkHeaderBytes; + if (length > size - body) { // subtraction, not addition: a huge length must not wrap + refuse(path, "has a chunk that runs past the end of the file"); + return false; + } + if (std::memcmp(chunk, "fmt ", 4) == 0) { + if (length < kFormatChunkBytes) { + refuse(path, "has a fmt chunk too short to read"); + return false; + } + if (little_u16(chunk + 8) != kPcm || little_u16(chunk + 10) != kMono || + little_u32(chunk + 12) != static_cast(sound::kSampleRate) || + little_u16(chunk + 22) != kBitsPerSample) { + refuse(path, "is not 16-bit 48 kHz mono PCM"); + return false; + } + has_format = true; + } else if (std::memcmp(chunk, "data", 4) == 0) { + if (!has_format) { + refuse(path, "has its samples before it says what they are"); + return false; + } + frames = length / 2; + if (frames == 0 || frames > hal::kMaxSampleFramesPerPad) { + refuse(path, "is empty or longer than the two seconds a sample may be"); + return false; + } + offset = body; + return true; + } + at = body + length + (length & 1); // chunks are padded to an even length + } + refuse(path, "has no samples in it"); + return false; +} + +} // namespace + +bool load_samples(const engine::Kit& kit, sound::SampleBank& bank) { + bank = sound::SampleBank{}; + uint32_t capacity = 0; + int16_t* memory = hal::sample_memory(&capacity); + if (memory == nullptr || capacity == 0) { + hal::log("io: no memory for samples, so the sample pads are silent"); + return false; + } + + uint32_t used = 0; + for (int i = 0; i < engine::kTrackCount; ++i) { + const engine::KitPad& pad = kit.pads[i]; + if (pad.voice != engine::Voice::sample) continue; + char path[kPathCapacity]; + std::snprintf(path, sizeof path, "kits/%s/%s", kit.id, pad.source); + + // The file is read straight into the room left in the arena and parsed where it + // lands, so no second buffer the size of a sample is ever needed; the samples are + // then moved down over the header they came with. + int16_t* into = memory + used; + uint32_t size = 0; + if (hal::read_file(path, reinterpret_cast(into), (capacity - used) * sizeof(int16_t), &size) != + hal::FileRead::ok) { + refuse(path, "did not come off the card, so its pad is silent"); + continue; + } + uint32_t offset = 0; + uint32_t frames = 0; + if (!find_pcm(reinterpret_cast(into), size, path, offset, frames)) continue; + std::memmove(into, reinterpret_cast(into) + offset, frames * sizeof(int16_t)); + bank.samples[i] = sound::Sample{into, static_cast(frames)}; + used += frames; + } + return true; +} + +} // namespace io diff --git a/firmware/src/io/kit.h b/firmware/src/io/kit.h new file mode 100644 index 0000000..055b626 --- /dev/null +++ b/firmware/src/io/kit.h @@ -0,0 +1,17 @@ +#pragma once + +#include "engine/kit.h" +#include "sound/voice.h" + +// A kit's samples, off the card (PRD §7.5, §12 rule 6). The kit itself is still the +// one compiled in; this is the half that makes it audible on the device, where the +// WAVs are `kits//` on the microSD. +namespace io { + +// Reads every sample pad's WAV into the memory hal::sample_memory() gives, and fills +// `bank` with what was read. A pad whose file is missing or is not a sample this +// firmware can play is left silent and logged, so one bad file costs one sound rather +// than the whole kit. False when the board has nowhere to put samples at all. +bool load_samples(const engine::Kit& kit, sound::SampleBank& bank); + +} // namespace io diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 65487c8..33d7d6d 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -1,17 +1,21 @@ // Device entry point. The Teensy Arduino core owns main(): it calls setup() once // and loop() forever. Everything below hal:: is portable and shared with host/main.cpp. -// The kit's samples arrive when io/ reads them from the card; until then the sample -// pads are silent and the synth pads play. #include #include "app/app.h" +#include "engine/kits/lofi.h" #include "hal/hal.h" +#include "io/kit.h" #include "sound/voice.h" void setup() { hal::init(); - const sound::SampleBank silent{}; - app::init(silent); + // The kit's WAVs come off the card into PSRAM. Whatever is missing — a card, the + // PSRAM, one file — costs those pads their sound and nothing else: io/ says what it + // could not read over the serial log and the synth pads play either way. + sound::SampleBank samples; + io::load_samples(engine::kits::kLofi, samples); + app::init(samples); } void loop() { diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index a8379eb..e3747a0 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -64,9 +64,13 @@ endif() target_compile_definitions(hal_sdl PRIVATE ROTA_STORAGE_DIR="${REPO_ROOT}/out/sdcard" ROTA_SCREENS_DIR="${REPO_ROOT}/out/screens") -# The simulator reads the kit's WAVs through the render tool's loader (host-only code). +# The simulator reads its kit off its "SD card" through io/, as the device does, so the +# card starts with the kits the repo ships. Copied at configure time, which is when the +# other build-time paths are settled; re-run cmake after changing a kit's WAVs. +file(COPY "${REPO_ROOT}/spec/kits/" DESTINATION "${REPO_ROOT}/out/sdcard/kits") + add_executable(simulator "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp") -target_link_libraries(simulator PRIVATE hal_sdl offline) +target_link_libraries(simulator PRIVATE hal_sdl core) # tests: doctest on the host. No SDL, so it runs headless in CI; tests/hal_fake.cpp # stands in for the HAL so app/ runs under a scripted clock. diff --git a/host/main.cpp b/host/main.cpp index 486e5bd..1e9278c 100644 --- a/host/main.cpp +++ b/host/main.cpp @@ -1,25 +1,20 @@ // Host simulator entry point (PRD §12). The loop is the same as firmware/src/main.cpp; // only the HAL underneath differs. Deliberately includes no SDL header: hal/sdl/ owns SDL. -// The kit's samples are read from spec/kits/ through the render tool's loader, which -// is host-only code; on the device io/ will read them from the card. +// The kit's samples come off the simulator's "SD card" through io/, exactly as they do +// on the device: host/CMakeLists.txt seeds that card with the kits the repo ships. #include -#include #include "app/app.h" #include "engine/kits/lofi.h" #include "hal/hal.h" -#include "render/offline.h" +#include "io/kit.h" +#include "sound/voice.h" int main() { hal::init(); - const engine::Kit& kit = engine::kits::kLofi; - render::KitSamples samples; - std::string error; - if (!render::load_kit_samples(std::string(ROTA_KITS_DIR) + "/" + kit.id, kit, samples, error)) { - std::fprintf(stderr, "simulator: %s\n", error.c_str()); - return 1; - } - app::init(samples.bank()); + sound::SampleBank samples; + io::load_samples(engine::kits::kLofi, samples); + app::init(samples); std::puts("simulator: 1-8 pads; s w k z d e space = split swap skip undo dice show play; a b c shift+d sections;"); std::puts("simulator: up/down or the wheel turn the selected knob, left/right pick it, - = volume; Escape quits"); std::fflush(stdout); diff --git a/spec/scenarios.md b/spec/scenarios.md index 041acd7..f3bafb7 100644 --- a/spec/scenarios.md +++ b/spec/scenarios.md @@ -1,6 +1,6 @@ # Acceptance scenarios -Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps; the PRD section each comes from is in brackets. +Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 for the kit's samples; the PRD section each comes from is in brackets. Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 bpm are assumed unless stated; "tap ×n" means n taps on that pad starting from empty, following the kit's smart defaults (§6.6); per-track modifier strings are the share-code form from `spec/share-format.md` §3. IDs are never reused: retire a scenario by striking it through, not by deleting it. @@ -105,6 +105,7 @@ Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 | T-97 | Save a song of four different sections with an arrangement and a section that carries a lineage, and read it back; the same with no arrangement; then a file whose third line is junk, and one written by a card that refuses (§6.8, §7.5, D-104) | The song comes back as it went: every section, the arrangement, and each section's own lineage, which no `RT2S` code can carry. A song with no arrangement comes back with none, which no `RT2S` code can express either. The junk file does not load, and it is told apart from a slot with no file at all — as are an empty file and one too big to be a song: all of them read as **taken**, so the song view draws the slot filled, a tap refuses it with `hold to replace song 2`, and leaving it writes nothing over it, rather than copying over what may still be somebody's song. Two things replace the file: the player playing on that slot, and a hold on its pad, which copies the song on screen over it and says `song 2 replaced`. A hold on a slot that reads perfectly well does nothing at all — not a replace and not a pick. Nothing on the card is changed otherwise, and the log names the file and what was wrong with it. A refused write leaves the card as it was and says so. A song's own lineage survives the model as well as the file: a song loaded with one and then edited is written back with it. | | T-98 | Write the settings, read them back; then a file with an unknown key, a value outside its range, a line that is not `key=value` and a missing row; then no file at all (§9.4, §7.5, D-104) | Every row and the open song come back as they were. The unknown key, the junk line and the out-of-range value are ignored and the rows they name keep what they had; a missing row keeps its default. A value the settings view itself could never set is not one the card gets to introduce: brightness outside 10–100 and a sleep that is not one of 0, 5, 10, 20, 30 or 60 keep their defaults. No file at all is the defaults: song 1, brightness 100, sleep 10, MIDI and sync on. | | T-99 | Kick ×1, then a second of frames, on a card that refuses writes, then a card that accepts (§7.5, §9.6, D-104) | Nothing is saved by hand: the card takes the song a second after the last change, so one tap costs one write, a refused write is tried again a second later and not on every frame, and the loop plays on either way. Once the card takes it, the next boot comes back to that loop. A pick the card cannot carry out is refused rather than half done: a card that will not take the song being left says `song 1 did not save` and the player stays on it with the edit still in hand, and a slot whose file did not parse says `hold to replace song 2` and is left alone until that hold comes (D-107). A boot writes nothing at all until the player plays something: an absent file and an empty song say the same thing. | +| T-100 | Boot with the kit's WAVs on the card; then with one missing, one that is not 16-bit 48 kHz mono, one longer than the two seconds a sample may be, and one that is not a WAVE at all; then with no card, and on a board with no PSRAM fitted (§7.5, §12 rule 6, D-081, D-108) | Each sample pad plays the samples in its own file, packed one after another into the PSRAM and none of them overlapping; a pad whose file is missing or is not a sample this firmware can play is silent and the log names the file and what was wrong with it, while every other pad still sounds. With no card every sample pad is silent and the synth pads play on; with no PSRAM there is nowhere to put a sample at all, which is said once rather than per pad. A pad whose sample came off the card is heard: the same tap is loud with it and inaudible without. | ## Watch in testing diff --git a/tests/app_support.h b/tests/app_support.h index c740537..bfd3d1c 100644 --- a/tests/app_support.h +++ b/tests/app_support.h @@ -45,10 +45,19 @@ struct World { // The tutorial has run unless a test asks for a first boot (§8.5, T-22). explicit World(bool first_run = false) { + const sound::SampleBank silent{}; + start(first_run, silent); + } + + // A world whose sample pads have sounds, for the tests that need to hear one. The + // bank is read off the card before the world starts; hal_fake::reset() clears the + // card's files but not the memory the samples were read into, so it stays valid. + explicit World(const sound::SampleBank& samples) { start(false, samples); } + + void start(bool first_run, const sound::SampleBank& samples) { hal_fake::reset(); if (!first_run) hal::write_file(app::kTutorialDoneFile, &app::kTutorialRan, 1); - const sound::SampleBank silent{}; - app::init(silent); + app::init(samples); timer_frames = static_cast(hal_fake::timer_period_us()) * sound::kSampleRate / 1000000; REQUIRE(timer_frames > 0); // a period under 21 us would never advance the world REQUIRE(hal_fake::audio_callback() != nullptr); diff --git a/tests/hal_fake.cpp b/tests/hal_fake.cpp index 15afced..36c7205 100644 --- a/tests/hal_fake.cpp +++ b/tests/hal_fake.cpp @@ -16,6 +16,7 @@ hal_fake::Led leds_[hal::kPadCount]; hal_fake::Led button_leds_[hal::kButtonCount]; int brightness_ = 100; bool refuse_writes_ = false; +bool refuse_sample_memory_ = false; int writes_ = 0; std::map writes_by_path_; uint16_t framebuffer_[hal::kScreenWidth * hal::kScreenHeight]; @@ -37,6 +38,7 @@ void reset() { std::memset(button_leds_, 0, sizeof button_leds_); brightness_ = 100; refuse_writes_ = false; + refuse_sample_memory_ = false; writes_ = 0; writes_by_path_.clear(); std::memset(framebuffer_, 0, sizeof framebuffer_); @@ -46,6 +48,7 @@ void reset() { void set_time_us(uint64_t now_us) { now_us_ = now_us; } void refuse_writes(bool refuse) { refuse_writes_ = refuse; } +void refuse_sample_memory(bool refuse) { refuse_sample_memory_ = refuse; } void push(const hal::InputEvent& event) { input_.push_back(event); } hal::AudioCallback audio_callback() { return audio_callback_; } hal::TimerCallback timer_callback() { return timer_callback_; } @@ -121,6 +124,16 @@ bool write_file(const char* path, const uint8_t* data, uint32_t size) { return true; } +int16_t* sample_memory(uint32_t* frames) { + static int16_t memory[kSampleMemoryFrames]; + if (refuse_sample_memory_) { + *frames = 0; + return nullptr; + } + *frames = kSampleMemoryFrames; + return memory; +} + int battery_percent() { return 87; } bool headphones_inserted() { return false; } void log(const char* line) { log_.emplace_back(line); } diff --git a/tests/hal_fake.h b/tests/hal_fake.h index 697f88b..e47a3cd 100644 --- a/tests/hal_fake.h +++ b/tests/hal_fake.h @@ -30,6 +30,9 @@ hal::AudioCallback audio_callback(); hal::TimerCallback timer_callback(); uint32_t timer_period_us(); +// A board with no PSRAM fitted, which is every board until bring-up (T-100). +void refuse_sample_memory(bool refuse); + // Every hal::write_file the card was asked for, refused ones included (T-99); with // a path, only the ones for that file. int writes(); diff --git a/tests/io_test.cpp b/tests/io_test.cpp index 8e608bc..7a3e497 100644 --- a/tests/io_test.cpp +++ b/tests/io_test.cpp @@ -1,4 +1,4 @@ -// io/ (spec/scenarios.md T-56, T-59, T-89, T-97, T-98, T-99): the song and settings +// io/ (spec/scenarios.md T-56, T-59, T-89, T-97, T-98, T-99, T-100): the song and settings // files the card holds, the app keeping them as the player plays, and the id a // shared loop carries. #include @@ -7,6 +7,7 @@ #include "app_support.h" #include "engine_support.h" +#include "io/kit.h" #include "io/share.h" #include "io/store.h" #include "ui/settings.h" @@ -498,3 +499,145 @@ TEST_CASE("T-89 A pad in settings is inert: no sound, no mute, no steps") { CHECK(w.audition_samples(Pad::hat).size() == sounded); CHECK(engine::is_empty(engine::track_of(w.state(0), Pad::hat))); // and it is a menu, so nothing was tapped in } + +namespace { + +void put_u16(std::string& out, uint16_t value) { + out += static_cast(value & 0xff); + out += static_cast((value >> 8) & 0xff); +} + +void put_u32(std::string& out, uint32_t value) { + put_u16(out, static_cast(value & 0xffff)); + put_u16(out, static_cast(value >> 16)); +} + +// A RIFF WAVE around `data`, right or wrong in whichever way the case is about. +std::string wave_of(const std::string& data, uint16_t format, uint16_t channels, uint32_t rate, uint16_t bits) { + std::string fmt; + put_u16(fmt, format); + put_u16(fmt, channels); + put_u32(fmt, rate); + put_u32(fmt, rate * channels * bits / 8); // byte rate + put_u16(fmt, static_cast(channels * bits / 8)); // block align + put_u16(fmt, bits); + std::string out = "RIFF"; + put_u32(out, static_cast(4 + 8 + fmt.size() + 8 + data.size())); + out += "WAVEfmt "; + put_u32(out, static_cast(fmt.size())); + out += fmt; + out += "data"; + put_u32(out, static_cast(data.size())); + out += data; + return out; +} + +// Every frame holds its own index, so a test can tell one sample from another and see +// where in the sample memory it landed. +std::string counted(int frames) { + std::string data; + for (int i = 0; i < frames; ++i) put_u16(data, static_cast(i)); + return data; +} + +std::string wave(int frames, uint16_t format, uint16_t channels, uint32_t rate, uint16_t bits) { + return wave_of(counted(frames), format, channels, rate, bits); +} + +std::string mono_wave(int frames) { return wave(frames, 1, 1, 48000, 16); } + +// Full scale, alternating, so a pad playing it is unmistakably heard. +std::string loud_wave(int frames) { + std::string data; + for (int i = 0; i < frames; ++i) put_u16(data, static_cast(i % 2 == 0 ? 30000 : -30000)); + return wave_of(data, 1, 1, 48000, 16); +} + +} // namespace + +TEST_CASE("T-100 The kit's samples come off the card, and one file it cannot use costs one pad") { + hal_fake::reset(); + put("kits/lofi/kick.wav", mono_wave(100)); + put("kits/lofi/snare.wav", mono_wave(50)); + // hat has no file at all; the other two have one that cannot be used. + put("kits/lofi/clap.wav", wave(30, 1, 2, 48000, 16)); // stereo + put("kits/lofi/rim.wav", mono_wave(2 * sound::kSampleRate + 1)); // longer than two seconds + + sound::SampleBank bank; + REQUIRE(io::load_samples(kit(), bank)); + CHECK(bank.samples[0].frame_count == 100); + CHECK(bank.samples[0].frames[7] == 7); // the file's own samples + CHECK(bank.samples[1].frame_count == 50); + CHECK(bank.samples[1].frames == bank.samples[0].frames + 100); // packed, not overlapping + CHECK(bank.samples[0].frames[99] == 99); // and the first is untouched by the second + + for (const int silent : {2, 3, 7}) { // hat missing, clap stereo, rim too long + CAPTURE(silent); + CHECK(bank.samples[silent].frames == nullptr); + CHECK(bank.samples[silent].frame_count == 0); + } + CHECK(logged("kits/lofi/hat.wav")); + CHECK(logged("kits/lofi/clap.wav")); + CHECK(logged("kits/lofi/rim.wav")); + + // A file that is there but is not a WAVE at all is the same story: that pad and no + // other. The two that loaded are read again and land where they did before. + put("kits/lofi/hat.wav", "not a wave at all"); + REQUIRE(io::load_samples(kit(), bank)); + CHECK(bank.samples[2].frames == nullptr); + CHECK(bank.samples[0].frame_count == 100); + CHECK(bank.samples[0].frames[7] == 7); + CHECK(bank.samples[1].frame_count == 50); + + for (const int synth : {4, 5, 6}) { // bass, chord and pluck have no sample to read + CAPTURE(synth); + CHECK(bank.samples[synth].frames == nullptr); + } +} + +TEST_CASE("T-100 With no card, or nowhere to put samples, every sample pad is silent") { + hal_fake::reset(); + sound::SampleBank bank; + CHECK(io::load_samples(kit(), bank)); // no card: it read what there was, which was nothing + for (const sound::Sample& sample : bank.samples) CHECK(sample.frames == nullptr); + CHECK(logged("kits/lofi/kick.wav")); + + hal_fake::reset(); + put("kits/lofi/kick.wav", mono_wave(100)); + hal_fake::refuse_sample_memory(true); // a board with no PSRAM fitted, which is every board today + const size_t before = hal_fake::log().size(); + CHECK_FALSE(io::load_samples(kit(), bank)); + for (const sound::Sample& sample : bank.samples) CHECK(sample.frames == nullptr); + CHECK(hal_fake::log().size() == before + 1); // said once, not once a pad +} + +TEST_CASE("T-100 With no card the synth pads play on") { + World w; // an empty card: nothing for the sample pads, everything for the rest + w.tap(Pad::bass); + w.run_for(kSecond / 10); + CHECK(w.last_peak > 0.05f); + + World quiet; // and a sample pad, on the same empty card, has nothing to play + quiet.tap(Pad::kick); + quiet.run_for(kSecond / 10); + CHECK(quiet.last_peak < 1e-6f); +} + +TEST_CASE("T-100 A sample read off the card is what the pad plays") { + hal_fake::reset(); + put("kits/lofi/kick.wav", loud_wave(4800)); // a tenth of a second of it + + sound::SampleBank bank; + REQUIRE(io::load_samples(kit(), bank)); + REQUIRE(bank.samples[0].frames != nullptr); + + World w(bank); + w.tap(Pad::kick); + w.run_for(kSecond / 10); + CHECK(w.last_peak > 0.05f); // the card's own samples reached the output + + World silent; // and without them the same tap leaves nothing anyone could hear + silent.tap(Pad::kick); + silent.run_for(kSecond / 10); + CHECK(silent.last_peak < 1e-6f); // not exactly zero: the effects chain has its own tail +}