From 5e687d38324b9f71b9c67bf0b7fad7f3ece009d1 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 21:44:17 +0200 Subject: [PATCH 01/26] chore: update libDaisy to v8.1.0 --- .github/workflows/firmware.yaml | 2 +- .vscode/tasks.json | 8 --- firmware/CMakeLists.txt | 73 +++++++++--------------- firmware/src/hardware/UiHardware.h | 88 ++++++++++++++--------------- firmware/tests/libDaisyCombined.cpp | 4 +- lib/libDaisy | 2 +- 6 files changed, 77 insertions(+), 100 deletions(-) diff --git a/.github/workflows/firmware.yaml b/.github/workflows/firmware.yaml index 3512b08..ee92679 100644 --- a/.github/workflows/firmware.yaml +++ b/.github/workflows/firmware.yaml @@ -37,7 +37,7 @@ jobs: submodules: recursive - name: Configure - run: cmake -S firmware -B firmware/build -G "Unix Makefiles" -D CMAKE_TOOLCHAIN_FILE="$(pwd)/lib/libDaisy/cmake/toolchains/stm32h750xx.cmake" -D CMAKE_BUILD_TYPE=RelWithDebInfo -D TOOLCHAIN_PREFIX=$(which arm-none-eabi-gcc | sed -e "s/bin\/arm-none-eabi-gcc//") + run: cmake -S firmware -B firmware/build -G "Unix Makefiles" -D CMAKE_BUILD_TYPE=RelWithDebInfo -D TOOLCHAIN_PREFIX=$(which arm-none-eabi-gcc | sed -e "s/bin\/arm-none-eabi-gcc//") - name: Build run: cmake --build firmware/build diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8b75eb1..3ac0b10 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -19,8 +19,6 @@ "-D", "TOOLCHAIN_PREFIX=\"${env:HOME}/dev/gcc-arm-none-eabi-9-2020-q2-update\"", "-D", - "CMAKE_TOOLCHAIN_FILE=\"${workspaceRoot}/lib/libDaisy/cmake/toolchains/stm32h750xx.cmake\"", - "-D", "CMAKE_BUILD_TYPE=RelWithDebInfo", "-S", "firmware", @@ -40,8 +38,6 @@ "-D", "TOOLCHAIN_PREFIX=\\\"C:/Program Files (x86)/GNU Arm Embedded Toolchain/9 2020-q2-update\\\"", "-D", - "CMAKE_TOOLCHAIN_FILE=\\\"${workspaceRoot}/lib/libDaisy/cmake/toolchains/stm32h750xx.cmake\\\"", - "-D", "CMAKE_BUILD_TYPE=RelWithDebInfo", "-S", "firmware", @@ -66,8 +62,6 @@ "-D", "TOOLCHAIN_PREFIX=\"${env:HOME}/dev/gcc-arm-none-eabi-9-2020-q2-update\"", "-D", - "CMAKE_TOOLCHAIN_FILE=\"${workspaceRoot}/lib/libDaisy/cmake/toolchains/stm32h750xx.cmake\"", - "-D", "CMAKE_BUILD_TYPE=Debug", "-S", "firmware", @@ -87,8 +81,6 @@ "-D", "TOOLCHAIN_PREFIX=\\\"C:/Program Files (x86)/GNU Arm Embedded Toolchain/9 2020-q2-update\\\"", "-D", - "CMAKE_TOOLCHAIN_FILE=\\\"${workspaceRoot}/lib/libDaisy/cmake/toolchains/stm32h750xx.cmake\\\"", - "-D", "CMAKE_BUILD_TYPE=Debug", "-S", "firmware", diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index 255a826..39fa292 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -1,9 +1,27 @@ -cmake_policy(SET CMP0048 NEW) -project(TapeLooper) -cmake_minimum_required(VERSION 3.19) -set(FIRMWARE_NAME TapeLooper) +cmake_minimum_required(VERSION 3.30) + +if(WIN32) + set(TOOLCHAIN_EXT ".exe") +else() + set(TOOLCHAIN_EXT "") +endif() + +set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}/bin/arm-none-eabi-gcc${TOOLCHAIN_EXT}) +set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}/bin/arm-none-eabi-g++${TOOLCHAIN_EXT}) + +if(NOT DEFINED CMAKE_TOOLCHAIN_FILE) + include(${CMAKE_CURRENT_LIST_DIR}/../lib/libDaisy/cmake/toolchains/ArmGNUToolchain.cmake) + #include(${CMAKE_CURRENT_LIST_DIR}/../lib/libDaisy/cmake/toolchains/autodetect.cmake) +endif() + +project(TapeLoope) + +if(COMMAND cmake_policy) + cmake_policy(SET CMP0003 NEW) +endif(COMMAND cmake_policy) +set(FIRMWARE_NAME TapeLooper) set(FIRMWARE_SOURCES src/main.cpp ) @@ -12,16 +30,13 @@ set(FIRMWARE_SOURCES add_subdirectory(../dsp libTapeLooperDsp) # include libDaisy -set(LIBDAISY_DIR ../lib/libDaisy) -add_subdirectory(${LIBDAISY_DIR} libDaisy) +add_subdirectory(../lib/libDaisy daisy) -# linker script -get_filename_component(LIBDAISY_DIR_ABS "${LIBDAISY_DIR}" - REALPATH BASE_DIR "${CMAKE_SOURCE_DIR}") -set(LINKER_SCRIPT ${LIBDAISY_DIR_ABS}/core/STM32H750IB_flash.lds) +cmake_path(SET LOCAL_CMAKE_PATH "../lib/libDaisy/cmake") +cmake_path(ABSOLUTE_PATH LOCAL_CMAKE_PATH) -# firmware binary image -add_executable(${FIRMWARE_NAME} ${FIRMWARE_SOURCES}) +list(APPEND CMAKE_MODULE_PATH ${LOCAL_CMAKE_PATH}) +include(DaisyProject) target_link_libraries(${FIRMWARE_NAME} PRIVATE @@ -32,36 +47,4 @@ target_link_libraries(${FIRMWARE_NAME} set_target_properties(${FIRMWARE_NAME} PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED YES - SUFFIX ".elf" - LINK_DEPENDS ${LINKER_SCRIPT} -) - -target_link_options(${FIRMWARE_NAME} PUBLIC - -T ${LINKER_SCRIPT} - -Wl,-Map=${FIRMWARE_NAME}.map,--cref - -Wl,--check-sections - -Wl,--unresolved-symbols=report-all - -Wl,--warn-common - -Wl,--warn-section-align - -Wl,--print-memory-usage -) - -add_custom_command(TARGET ${FIRMWARE_NAME} POST_BUILD - COMMAND ${CMAKE_OBJCOPY} - ARGS -O ihex - -S ${FIRMWARE_NAME}.elf - ${FIRMWARE_NAME}.hex - BYPRODUCTS - ${FIRMWARE_NAME}.hex - COMMENT "Generating HEX image" -VERBATIM) - -add_custom_command(TARGET ${FIRMWARE_NAME} POST_BUILD - COMMAND ${CMAKE_OBJCOPY} - ARGS -O binary - -S ${FIRMWARE_NAME}.elf - ${FIRMWARE_NAME}.bin - BYPRODUCTS - ${FIRMWARE_NAME}.bin - COMMENT "Generating binary image" -VERBATIM) +) \ No newline at end of file diff --git a/firmware/src/hardware/UiHardware.h b/firmware/src/hardware/UiHardware.h index 7b6e1f2..41c5670 100644 --- a/firmware/src/hardware/UiHardware.h +++ b/firmware/src/hardware/UiHardware.h @@ -40,11 +40,11 @@ class ButtonReader dmaBuffer_ = dmaBuffer; // shift register for the channel buttons - shiftRegisterLoadPin_.pin = { DSY_GPIOA, 4 }; - shiftRegisterLoadPin_.mode = DSY_GPIO_MODE_OUTPUT_PP; - shiftRegisterLoadPin_.pull = DSY_GPIO_NOPULL; - dsy_gpio_init(&shiftRegisterLoadPin_); - dsy_gpio_write(&shiftRegisterLoadPin_, 1); + shiftRegisterLoadPin_.Init({ daisy::GPIOPort::PORTA, 4 }, + daisy::GPIO::Mode::OUTPUT, + daisy::GPIO::Pull::NOPULL, + daisy::GPIO::Speed::MEDIUM); + shiftRegisterLoadPin_.Write(true); daisy::SpiHandle::Config config; config.periph = daisy::SpiHandle::Config::Peripheral::SPI_1; @@ -55,27 +55,27 @@ class ButtonReader config.clock_phase = daisy::SpiHandle::Config::ClockPhase::ONE_EDGE; config.nss = daisy::SpiHandle::Config::NSS::SOFT; config.baud_prescaler = daisy::SpiHandle::Config::BaudPrescaler::PS_16; - config.pin_config.sclk = { DSY_GPIOA, 5 }; - config.pin_config.miso = { DSY_GPIOG, 9 }; + config.pin_config.sclk = { daisy::GPIOPort::PORTA, 5 }; + config.pin_config.miso = { daisy::GPIOPort::PORTG, 9 }; shiftRegisterSpi_.Init(config); // common section buttons - commonGpios_.settings.pin = { DSY_GPIOB, 7 }; - commonGpios_.settings.mode = DSY_GPIO_MODE_INPUT; - commonGpios_.settings.pull = DSY_GPIO_NOPULL; - dsy_gpio_init(&commonGpios_.settings); - commonGpios_.save.pin = { DSY_GPIOG, 11 }; - commonGpios_.save.mode = DSY_GPIO_MODE_INPUT; - commonGpios_.save.pull = DSY_GPIO_NOPULL; - dsy_gpio_init(&commonGpios_.save); - commonGpios_.load.pin = { DSY_GPIOG, 10 }; - commonGpios_.load.mode = DSY_GPIO_MODE_INPUT; - commonGpios_.load.pull = DSY_GPIO_NOPULL; - dsy_gpio_init(&commonGpios_.load); - commonGpios_.rec.pin = { DSY_GPIOB, 12 }; - commonGpios_.rec.mode = DSY_GPIO_MODE_INPUT; - commonGpios_.rec.pull = DSY_GPIO_NOPULL; - dsy_gpio_init(&commonGpios_.rec); + commonGpios_.settings.Init({ daisy::GPIOPort::PORTB, 7 }, + daisy::GPIO::Mode::INPUT, + daisy::GPIO::Pull::NOPULL, + daisy::GPIO::Speed::LOW); + commonGpios_.save.Init({ daisy::GPIOPort::PORTG, 11 }, + daisy::GPIO::Mode::INPUT, + daisy::GPIO::Pull::NOPULL, + daisy::GPIO::Speed::LOW); + commonGpios_.load.Init({ daisy::GPIOPort::PORTG, 10 }, + daisy::GPIO::Mode::INPUT, + daisy::GPIO::Pull::NOPULL, + daisy::GPIO::Speed::LOW); + commonGpios_.rec.Init({ daisy::GPIOPort::PORTB, 12 }, + daisy::GPIO::Mode::INPUT, + daisy::GPIO::Pull::NOPULL, + daisy::GPIO::Speed::LOW); } bool getButtonState(Button buttonId) const @@ -84,13 +84,13 @@ class ButtonReader switch (buttonId) { case Button::save: - return !dsy_gpio_read(&commonGpios_.save); + return !const_cast(&commonGpios_.save)->Read(); case Button::load: - return !dsy_gpio_read(&commonGpios_.load); + return !const_cast(&commonGpios_.load)->Read(); case Button::settings: - return !dsy_gpio_read(&commonGpios_.settings); + return !const_cast(&commonGpios_.settings)->Read(); case Button::record: - return !dsy_gpio_read(&commonGpios_.rec); + return !const_cast(&commonGpios_.rec)->Read(); case Button::chA_play: return !getBit<2>(); case Button::chA_up: @@ -123,12 +123,12 @@ class ButtonReader void triggerReadout() { - dsy_gpio_write(&shiftRegisterLoadPin_, 0); + shiftRegisterLoadPin_.Write(false); constexpr auto kNumBytes = 2; shiftRegisterSpi_.DmaReceive((uint8_t*) dmaBuffer_, kNumBytes, nullptr, nullptr, nullptr); - dsy_gpio_write(&shiftRegisterLoadPin_, 1); + shiftRegisterLoadPin_.Write(true); } private: @@ -140,15 +140,15 @@ class ButtonReader struct { - dsy_gpio settings; - dsy_gpio save; - dsy_gpio load; - dsy_gpio rec; + daisy::GPIO settings; + daisy::GPIO save; + daisy::GPIO load; + daisy::GPIO rec; } commonGpios_; uint16_t* dmaBuffer_ = nullptr; daisy::SpiHandle shiftRegisterSpi_; - dsy_gpio shiftRegisterLoadPin_; + daisy::GPIO shiftRegisterLoadPin_; }; class KnobAndCvReader @@ -163,18 +163,18 @@ class KnobAndCvReader { calibrationStorage_.Init(getDefaultCalibrationData(), kCalibrationDataOffset); - const dsy_gpio_pin mux0 = { DSY_GPIOA, 1 }; - const dsy_gpio_pin mux1 = { DSY_GPIOA, 0 }; - const dsy_gpio_pin mux2 = { DSY_GPIOD, 11 }; + const daisy::Pin mux0 = { daisy::GPIOPort::PORTA, 1 }; + const daisy::Pin mux1 = { daisy::GPIOPort::PORTA, 0 }; + const daisy::Pin mux2 = { daisy::GPIOPort::PORTD, 11 }; constexpr auto kNumChannelsPerAdc = 7; constexpr auto kNumAdcs = 4; daisy::AdcChannelConfig channelConfigs[kNumAdcs]; - channelConfigs[0].InitMux({ DSY_GPIOC, 0 }, kNumChannelsPerAdc, mux0, mux1, mux2); - channelConfigs[1].InitMux({ DSY_GPIOA, 3 }, kNumChannelsPerAdc, mux0, mux1, mux2); - channelConfigs[2].InitMux({ DSY_GPIOB, 1 }, kNumChannelsPerAdc, mux0, mux1, mux2); - channelConfigs[3].InitMux({ DSY_GPIOA, 7 }, kNumChannelsPerAdc, mux0, mux1, mux2); + channelConfigs[0].InitMux({ daisy::GPIOPort::PORTC, 0 }, kNumChannelsPerAdc, mux0, mux1, mux2); + channelConfigs[1].InitMux({ daisy::GPIOPort::PORTA, 3 }, kNumChannelsPerAdc, mux0, mux1, mux2); + channelConfigs[2].InitMux({ daisy::GPIOPort::PORTB, 1 }, kNumChannelsPerAdc, mux0, mux1, mux2); + channelConfigs[3].InitMux({ daisy::GPIOPort::PORTA, 7 }, kNumChannelsPerAdc, mux0, mux1, mux2); adc_.Init(channelConfigs, kNumAdcs); adc_.Start(); @@ -423,13 +423,13 @@ class UiHardware daisy::I2CHandle i2c; daisy::I2CHandle::Config i2cCfg; i2cCfg.periph = daisy::I2CHandle::Config::Peripheral::I2C_1; - i2cCfg.pin_config.sda = { DSY_GPIOB, 9 }; - i2cCfg.pin_config.scl = { DSY_GPIOB, 8 }; + i2cCfg.pin_config.sda = { daisy::GPIOPort::PORTB, 9 }; + i2cCfg.pin_config.scl = { daisy::GPIOPort::PORTB, 8 }; i2cCfg.speed = daisy::I2CHandle::Config::Speed::I2C_400KHZ; i2cCfg.mode = daisy::I2CHandle::Config::Mode::I2C_MASTER; i2c.Init(i2cCfg); - ledDriver_.Init(i2c, { 0b00, 0b01, 0b10 }, bufferA, bufferB, { DSY_GPIOB, 6 }); + ledDriver_.Init(i2c, { 0b00, 0b01, 0b10 }, bufferA, bufferB, { daisy::GPIOPort::PORTB, 6 }); } void updateLedBrightnessValues() diff --git a/firmware/tests/libDaisyCombined.cpp b/firmware/tests/libDaisyCombined.cpp index 1f590a7..7f88597 100644 --- a/firmware/tests/libDaisyCombined.cpp +++ b/firmware/tests/libDaisyCombined.cpp @@ -1,4 +1,6 @@ // include unit-testable code from libDaisy // which is required for some of the integration tests // that use the daisy::UI class. -#include <../tests/libDaisyCombined.cpp> + +#include +#include diff --git a/lib/libDaisy b/lib/libDaisy index 8d106a2..9498417 160000 --- a/lib/libDaisy +++ b/lib/libDaisy @@ -1 +1 @@ -Subproject commit 8d106a2c822f687bcb8c86bed0b6f2c22f680999 +Subproject commit 9498417add4a4c76bc10737d5e9f43750a4da639 From 8780bca8654bc8ed29203d65de4072b2114b3868 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Thu, 5 Sep 2024 08:27:59 +0200 Subject: [PATCH 02/26] chore: update gcc-arm-none-eabi to version 10-2020-q4-major --- .github/workflows/firmware.yaml | 2 +- .vscode/launch.json | 4 ++-- .vscode/tasks.json | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/firmware.yaml b/.github/workflows/firmware.yaml index ee92679..c403fb0 100644 --- a/.github/workflows/firmware.yaml +++ b/.github/workflows/firmware.yaml @@ -24,7 +24,7 @@ jobs: - name: Install arm-none-eabi-gcc uses: fiam/arm-none-eabi-gcc@v1 with: - release: '9-2019-q4' # The arm-none-eabi-gcc release to use. + release: '10-2020-q4' # The arm-none-eabi-gcc release to use. - name: Setup cmake uses: jwlawson/actions-setup-cmake@v1.9 diff --git a/.vscode/launch.json b/.vscode/launch.json index b1df9b1..5a824f1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -34,12 +34,12 @@ }, "linux": { "MIMode": "gdb", - "MIDebuggerPath": "~/dev/gcc-arm-none-eabi-9-2020-q2-update/bin/arm-none-eabi-gdb", + "MIDebuggerPath": "~/dev/gcc-arm-none-eabi-10-2020-q4-major/bin/arm-none-eabi-gdb", "debugServerPath": "openocd" }, "osx": { "MIMode": "gdb", - "MIDebuggerPath": "/Users/johanneselliesen/dev/gcc-arm-none-eabi-9-2020-q2-update/bin/arm-none-eabi-gdb", + "MIDebuggerPath": "/Users/johanneselliesen/dev/gcc-arm-none-eabi-10-2020-q4-major/bin/arm-none-eabi-gdb", "debugServerPath": "openocd" }, "windows": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3ac0b10..ef65058 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -17,7 +17,7 @@ "command": "cmake", "args": [ "-D", - "TOOLCHAIN_PREFIX=\"${env:HOME}/dev/gcc-arm-none-eabi-9-2020-q2-update\"", + "TOOLCHAIN_PREFIX=\"${env:HOME}/dev/gcc-arm-none-eabi-10-2020-q4-major\"", "-D", "CMAKE_BUILD_TYPE=RelWithDebInfo", "-S", @@ -36,7 +36,7 @@ "command": "cmake.exe", "args": [ "-D", - "TOOLCHAIN_PREFIX=\\\"C:/Program Files (x86)/GNU Arm Embedded Toolchain/9 2020-q2-update\\\"", + "TOOLCHAIN_PREFIX=\\\"C:/Program Files (x86)/GNU Arm Embedded Toolchain/10 2020-q4-major\\\"", "-D", "CMAKE_BUILD_TYPE=RelWithDebInfo", "-S", @@ -60,7 +60,7 @@ "command": "cmake", "args": [ "-D", - "TOOLCHAIN_PREFIX=\"${env:HOME}/dev/gcc-arm-none-eabi-9-2020-q2-update\"", + "TOOLCHAIN_PREFIX=\"${env:HOME}/dev/gcc-arm-none-eabi-10-2020-q4-major\"", "-D", "CMAKE_BUILD_TYPE=Debug", "-S", @@ -79,7 +79,7 @@ "command": "cmake.exe", "args": [ "-D", - "TOOLCHAIN_PREFIX=\\\"C:/Program Files (x86)/GNU Arm Embedded Toolchain/9 2020-q2-update\\\"", + "TOOLCHAIN_PREFIX=\\\"C:/Program Files (x86)/GNU Arm Embedded Toolchain/10 2020-q4-major\\\"", "-D", "CMAKE_BUILD_TYPE=Debug", "-S", From 0d0b47dadc87e4ca4f27c4e7a57b949307df5f6c Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Sat, 10 Aug 2024 08:40:50 +0200 Subject: [PATCH 03/26] feat: allow to abort a recording immediately this will be used when we want to restore a sample from storage while a recording is running --- dsp/src/dsp/Recorder.h | 9 +++++++++ dsp/tests/Recorder_gtest.cpp | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/dsp/src/dsp/Recorder.h b/dsp/src/dsp/Recorder.h index 6645163..feb4479 100644 --- a/dsp/src/dsp/Recorder.h +++ b/dsp/src/dsp/Recorder.h @@ -62,6 +62,15 @@ class Recorder recHeadIdx_ = 0; } + void stopRecordingImmediately() + { + isRecording_ = false; + isFadingOut_ = false; + xfadePhase_ = 0.0f; + currentLength_ = recHeadIdx_; + recHeadIdx_ = 0; + } + void setCrossfadeLength(size_t crossfadeLengthInSamples) { xFadeLengthInSamples_ = std::min(crossfadeLengthInSamples, buffer_.size_); diff --git a/dsp/tests/Recorder_gtest.cpp b/dsp/tests/Recorder_gtest.cpp index 068822e..c93b42c 100644 --- a/dsp/tests/Recorder_gtest.cpp +++ b/dsp/tests/Recorder_gtest.cpp @@ -97,12 +97,15 @@ TEST_F(DSP_Recorder, b_recordAndStop) { xfade += xFadeIncrement_; const float expectedSample = xfade * inputSequence_[ch][i] - + (1.0f - xfade) * inputSequence_[ch][i + expectedTotalNumSamples]; - EXPECT_NEAR(expectedSample, storage_[ch][i], absError_) << "at i=" << i; + + (1.0f - xfade) + * inputSequence_[ch][i + expectedTotalNumSamples]; + EXPECT_NEAR(expectedSample, storage_[ch][i], absError_) + << "at ch=" << ch << ", at i=" << i; } // compare the samples in the non-crossfaded portion for (size_t i = xFadeLengthInSamples_; i < expectedTotalNumSamples; i++) - EXPECT_NEAR(inputSequence_[ch][i], storage_[ch][i], absError_) << "at i=" << i; + EXPECT_NEAR(inputSequence_[ch][i], storage_[ch][i], absError_) + << "at ch=" << ch << ", at i=" << i; } if (HasFailure()) @@ -127,4 +130,30 @@ TEST_F(DSP_Recorder, b_recordAndStop) std::cout << "\n"; } } +} + +TEST_F(DSP_Recorder, c_stopRecordingImmediately) +{ + fillInputSequence(); + + recorder_.startRecording(); + // record some samples, but don't fill the recording buffer yet + constexpr auto kNumSamples = blockSize_ / 2; + recorder_.process(inputSequence_.subBlock(0, kNumSamples)); + + recorder_.stopRecordingImmediately(); + + // No longer recording - no crossfading + EXPECT_FALSE(recorder_.isRecording()); + + // The recorded data is preserved + EXPECT_EQ(recorder_.getCurrentRecordingLength(), kNumSamples); + for (size_t ch = 0; ch < numChannels_; ch++) + { + for (size_t i = 0; i < kNumSamples; i++) + { + EXPECT_NEAR(inputSequence_[ch][i], storage_[ch][i], absError_) + << "at ch=" << ch << ", at i=" << i; + } + } } \ No newline at end of file From 91266202c98470631290678640094fb2f8341611 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Fri, 9 Aug 2024 19:37:02 +0200 Subject: [PATCH 04/26] feat: allow to prevent recording/playing in `TapeLooper` This will be used to lock the looper while we save/restore samples. --- dsp/src/dsp/TapeLooper.h | 50 +++++++++- dsp/tests/TapeLooper_gtest.cpp | 177 +++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 dsp/tests/TapeLooper_gtest.cpp diff --git a/dsp/src/dsp/TapeLooper.h b/dsp/src/dsp/TapeLooper.h index 788ec42..849b46f 100644 --- a/dsp/src/dsp/TapeLooper.h +++ b/dsp/src/dsp/TapeLooper.h @@ -107,9 +107,9 @@ class TapeLooper { } - void switchState(LooperState state) + void switchState(LooperState newState) { - switch (state) + switch (newState) { default: case LooperState::stopped: @@ -119,19 +119,25 @@ class TapeLooper player_.stopPlaying(); break; case LooperState::playing: + if (preventPlayback_) + return; if (state_ == LooperState::recording) recorder_.stopRecording(); player_.startPlaying(recorder_.getCurrentRecordingLength()); break; case LooperState::recording: + if (preventRecording_) + return; if (state_ == LooperState::playing) player_.stopPlaying(); recorder_.startRecording(); break; } - state_ = state; + state_ = newState; } + bool isRecording() const { return recorder_.isRecording(); } + LooperState getState() const { return state_; } void reset() @@ -205,6 +211,8 @@ class TapeLooper template bool restore(ReadableMemory& mem) { + if (preventRecording_) + return false; if (!recorder_.restore(mem)) return false; @@ -222,11 +230,45 @@ class TapeLooper return true; } - const LooperStoragePtr getSampleStoragePtr() const { return storage_; } + LooperStoragePtr getSampleStoragePtr() { return storage_; } + + void preventPlaybackAndRecording(bool stopRecordingImmediately = false) + { + preventPlayback_ = true; + preventRecording_ = true; + const auto previousState = getState(); + switchState(LooperState::stopped); + if (previousState == LooperState::recording) + { + if (stopRecordingImmediately) + recorder_.stopRecordingImmediately(); + } + } + + void preventRecording(bool stopRecordingImmediately = false) + { + preventPlayback_ = false; + preventRecording_ = true; + + if (getState() == LooperState::recording) + { + switchState(LooperState::stopped); + if (stopRecordingImmediately) + recorder_.stopRecordingImmediately(); + } + } + + void liftRestrictions() + { + preventPlayback_ = false; + preventRecording_ = false; + } private: const LooperStoragePtr storage_; LooperState state_; PlayerType player_; RecorderType recorder_; + bool preventPlayback_ = false; + bool preventRecording_ = false; }; diff --git a/dsp/tests/TapeLooper_gtest.cpp b/dsp/tests/TapeLooper_gtest.cpp new file mode 100644 index 0000000..c0aa3a6 --- /dev/null +++ b/dsp/tests/TapeLooper_gtest.cpp @@ -0,0 +1,177 @@ +/** + * Copyright (C) Johannes Elliesen, 2021 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include + +class TapeLooperFixture : public ::testing::Test +{ +public: + TapeLooperFixture() : + looper_(storage_) {} + + void processOneBuffer() + { + TapeProcessorParameters processorParameters; + processorParameters.driveGain = 1.0f; + processorParameters.grainAmt = 0.0f; + AudioBuffer<1, kNumSamples> inBuffer; + AudioBuffer<1, kNumSamples> outBuffer; + looper_.process(1.0f, 0.0f, Direction::forwards, processorParameters, 1.0f, inBuffer, outBuffer); + } + + void recordSomeAudio() + { + looper_.switchState(LooperState::recording); + processOneBuffer(); + looper_.switchState(LooperState::stopped); + processOneBuffer(); + EXPECT_FALSE(looper_.isRecording()); + } + + static constexpr auto kSampleRate = 100; + static constexpr auto kNumSamples = 100; + + LooperStorage storage_; + TapeLooper looper_; +}; + +TEST_F(TapeLooperFixture, a_preventRecordingStopsCurrentRecording) +{ + looper_.switchState(LooperState::recording); + EXPECT_EQ(looper_.getState(), LooperState::recording); + + looper_.preventRecording(); + // looper aborts the recording + EXPECT_EQ(looper_.getState(), LooperState::stopped); + // recorder is still crossfading out + EXPECT_TRUE(looper_.isRecording()); + + // after processing for a while + processOneBuffer(); + // recorder has stopped + EXPECT_FALSE(looper_.isRecording()); +} + +TEST_F(TapeLooperFixture, b_preventRecordingImmediatelyStopsCurrentRecordingWithoutCrossfade) +{ + looper_.switchState(LooperState::recording); + EXPECT_EQ(looper_.getState(), LooperState::recording); + + looper_.preventRecording(true); + // looper aborts the recording + EXPECT_EQ(looper_.getState(), LooperState::stopped); + // recorder has stopped immediately + EXPECT_FALSE(looper_.isRecording()); +} + +TEST_F(TapeLooperFixture, c_preventRecordingPreventsStartingARecording) +{ + looper_.switchState(LooperState::stopped); + EXPECT_EQ(looper_.getState(), LooperState::stopped); + + looper_.preventRecording(); + looper_.switchState(LooperState::recording); + EXPECT_EQ(looper_.getState(), LooperState::stopped); + + looper_.liftRestrictions(); + looper_.switchState(LooperState::recording); + EXPECT_EQ(looper_.getState(), LooperState::recording); +} + +TEST_F(TapeLooperFixture, d_preventRecordingStillAllowsPlayback) +{ + recordSomeAudio(); + + looper_.switchState(LooperState::stopped); + EXPECT_EQ(looper_.getState(), LooperState::stopped); + + looper_.preventRecording(); + looper_.switchState(LooperState::playing); + EXPECT_EQ(looper_.getState(), LooperState::playing); +} + +TEST_F(TapeLooperFixture, e_preventPlaybackAndRecordingStopsPlayback) +{ + recordSomeAudio(); + + looper_.switchState(LooperState::playing); + EXPECT_EQ(looper_.getState(), LooperState::playing); + + looper_.preventPlaybackAndRecording(); + // looper aborts the playback + EXPECT_EQ(looper_.getState(), LooperState::stopped); +} + +TEST_F(TapeLooperFixture, f_preventPlaybackAndRecordingStopsCurrentRecording) +{ + looper_.switchState(LooperState::recording); + EXPECT_EQ(looper_.getState(), LooperState::recording); + + looper_.preventPlaybackAndRecording(); + // looper aborts the recording + EXPECT_EQ(looper_.getState(), LooperState::stopped); + // recorder is still crossfading out + EXPECT_TRUE(looper_.isRecording()); + + // after processing for a while + processOneBuffer(); + // recorder has stopped + EXPECT_FALSE(looper_.isRecording()); +} + +TEST_F(TapeLooperFixture, g_preventPlaybackAndRecordingImmediatelyStopsCurrentRecordingWithoutCrossfade) +{ + looper_.switchState(LooperState::recording); + EXPECT_EQ(looper_.getState(), LooperState::recording); + + looper_.preventPlaybackAndRecording(true); + // looper aborts the recording + EXPECT_EQ(looper_.getState(), LooperState::stopped); + // recorder has stopped + EXPECT_FALSE(looper_.isRecording()); +} + +TEST_F(TapeLooperFixture, h_preventPlaybackAndRecordingPreventsStartingARecording) +{ + looper_.switchState(LooperState::stopped); + EXPECT_EQ(looper_.getState(), LooperState::stopped); + + looper_.preventPlaybackAndRecording(); + looper_.switchState(LooperState::recording); + EXPECT_EQ(looper_.getState(), LooperState::stopped); + + looper_.liftRestrictions(); + looper_.switchState(LooperState::recording); + EXPECT_EQ(looper_.getState(), LooperState::recording); +} + +TEST_F(TapeLooperFixture, i_preventPlaybackAndRecordingPreventsStartingPlayback) +{ + recordSomeAudio(); + + looper_.switchState(LooperState::stopped); + EXPECT_EQ(looper_.getState(), LooperState::stopped); + + looper_.preventPlaybackAndRecording(); + looper_.switchState(LooperState::playing); + EXPECT_EQ(looper_.getState(), LooperState::stopped); + + looper_.liftRestrictions(); + looper_.switchState(LooperState::playing); + EXPECT_EQ(looper_.getState(), LooperState::playing); +} \ No newline at end of file From 8f214e35f67936b33fc6220d3cff1ca82de96f94 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Sat, 10 Aug 2024 08:48:17 +0200 Subject: [PATCH 05/26] feat: Add more getters to `TapeLooper` --- dsp/src/dsp/TapeLooper.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dsp/src/dsp/TapeLooper.h b/dsp/src/dsp/TapeLooper.h index 849b46f..f082294 100644 --- a/dsp/src/dsp/TapeLooper.h +++ b/dsp/src/dsp/TapeLooper.h @@ -230,8 +230,6 @@ class TapeLooper return true; } - LooperStoragePtr getSampleStoragePtr() { return storage_; } - void preventPlaybackAndRecording(bool stopRecordingImmediately = false) { preventPlayback_ = true; @@ -264,6 +262,11 @@ class TapeLooper preventRecording_ = false; } + LooperStoragePtr getSampleStoragePtr() { return storage_; } + size_t getPlaybackLength() const { return recorder_.getCurrentRecordingLength(); } + static constexpr size_t getNumChannels() { return numChannels; } + static constexpr size_t getSampleRate() { return sampleRate; } + private: const LooperStoragePtr storage_; LooperState state_; From e8bca5133639404b42d72d99a57ff66b46cdf7a1 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Fri, 16 Aug 2024 08:13:22 +0200 Subject: [PATCH 06/26] feat: Allow to set playback length without recording this will be needed when we restore samples and write them to the buffer directly --- dsp/src/dsp/Recorder.h | 1 + dsp/src/dsp/TapeLooper.h | 1 + 2 files changed, 2 insertions(+) diff --git a/dsp/src/dsp/Recorder.h b/dsp/src/dsp/Recorder.h index feb4479..00f78a2 100644 --- a/dsp/src/dsp/Recorder.h +++ b/dsp/src/dsp/Recorder.h @@ -140,6 +140,7 @@ class Recorder return true; } + void setCurrentPlaybackLength(size_t length) { currentLength_ = length; } size_t getCurrentRecordingLength() const { return currentLength_; } bool isRecording() const { return isRecording_ || isFadingOut_; } diff --git a/dsp/src/dsp/TapeLooper.h b/dsp/src/dsp/TapeLooper.h index f082294..5502126 100644 --- a/dsp/src/dsp/TapeLooper.h +++ b/dsp/src/dsp/TapeLooper.h @@ -263,6 +263,7 @@ class TapeLooper } LooperStoragePtr getSampleStoragePtr() { return storage_; } + void setPlaybackLength(size_t length) { recorder_.setCurrentPlaybackLength(length); } size_t getPlaybackLength() const { return recorder_.getCurrentRecordingLength(); } static constexpr size_t getNumChannels() { return numChannels; } static constexpr size_t getSampleRate() { return sampleRate; } From 30619d77a95692d0e10691dbd7a036b0c197825d Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Fri, 16 Aug 2024 09:26:19 +0200 Subject: [PATCH 07/26] feat: Add empty `AudioSaveAndRecall` --- firmware/src/AudioSaveAndRecall.h | 41 +++++++++++++++++++ firmware/src/LooperController.h | 50 ++++++++++++++++++++--- firmware/src/hardware/FatFsFileIo.h | 22 ++++++++++ firmware/src/main.cpp | 9 +++- firmware/tests/LooperController_gtest.cpp | 11 ++++- 5 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 firmware/src/AudioSaveAndRecall.h create mode 100644 firmware/src/hardware/FatFsFileIo.h diff --git a/firmware/src/AudioSaveAndRecall.h b/firmware/src/AudioSaveAndRecall.h new file mode 100644 index 0000000..36bb3d3 --- /dev/null +++ b/firmware/src/AudioSaveAndRecall.h @@ -0,0 +1,41 @@ +/** + * Copyright (C) Johannes Elliesen, 2024 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +enum class AudioSaveAndRecallResult +{ + ok, + error +}; + +enum class StorageBank +{ + green, + yellow, + red, + + count +}; + +typedef void (*AudioSaveAndRecallDoneCallbackPtr)(void* context, AudioSaveAndRecallResult result); + +template +class AudioSaveAndRecall +{ +public: +}; diff --git a/firmware/src/LooperController.h b/firmware/src/LooperController.h index 8b83679..716bb54 100644 --- a/firmware/src/LooperController.h +++ b/firmware/src/LooperController.h @@ -24,6 +24,7 @@ #include "constants.h" #include "util/LateInitializedObject.h" +#include "AudioSaveAndRecall.h" enum class ChannelLayout { @@ -48,15 +49,18 @@ class LooperController using MonoLooperType = typename LooperTypes::MonoLooperType; using StereoLooperType = typename LooperTypes::StereoLooperType; using ParameterProviderType = typename LooperTypes::ParameterProvider; + using AudioSaveAndRecallType = typename LooperTypes::AudioSaveAndRecallType; LooperController(std::array looperStorage, AudioBufferPtr<1> monoDownmixBuffer, AudioBufferPtr<1> temporaryBuffer, - const ParameterProviderType& paramProvider) : + const ParameterProviderType& paramProvider, + AudioSaveAndRecallType& audioSaveAndRecall) : looperStorage_(looperStorage), monoDownmixBuffer_(monoDownmixBuffer), temporaryBuffer_(temporaryBuffer), - paramProvider_(paramProvider) + paramProvider_(paramProvider), + audioSaveAndRecall_(audioSaveAndRecall) { for (size_t i = 0; i < numLoopers; i++) loopers_[i].initializeToLayout(ChannelLayout::stereo, looperStorage_[i]); @@ -78,18 +82,51 @@ class LooperController return loopers_[looperIdx].looper.template as().getState(); } - void saveTo(size_t looperIdx, size_t slot) + void saveTo(StorageBank bank, + size_t looperIdx, + size_t slot, + AudioSaveAndRecallDoneCallbackPtr doneCallback, + void* doneCallbackContext) { + (void) (bank); (void) (looperIdx); (void) (slot); - // TODO + (void) (doneCallback); + (void) (doneCallbackContext); + /* + audioSaveAndRecall_.startSavingToFile(bank, + slot, + loopers_[looperIdx].looper, + doneCallback, + doneCallbackContext); + */ } - void loadFrom(size_t looperIdx, size_t slot) + float getCurrentSaveOrLoadProgress() { + // return audioSaveAndRecall_.getCurrentProgress(); + return 0.0f; + } + + void loadFrom(StorageBank bank, + size_t looperIdx, + size_t slot, + AudioSaveAndRecallDoneCallbackPtr doneCallback, + void* doneCallbackContext) + { + (void) (bank); (void) (looperIdx); (void) (slot); - // TODO + (void) (doneCallback); + (void) (doneCallbackContext); + /* + // TODO: Reconfigure to correct channel layout + audioSaveAndRecall_.startReadingFromFiles(bank, + slot, + loopers_[looperIdx].looper, + doneCallback, + doneCallbackContext); + */ } void setChannelLayout(size_t looperIdx, ChannelLayout channelLayout) @@ -298,4 +335,5 @@ class LooperController AudioBufferPtr<1> monoDownmixBuffer_; AudioBufferPtr<1> temporaryBuffer_; const ParameterProviderType& paramProvider_; + AudioSaveAndRecallType& audioSaveAndRecall_; }; \ No newline at end of file diff --git a/firmware/src/hardware/FatFsFileIo.h b/firmware/src/hardware/FatFsFileIo.h new file mode 100644 index 0000000..2f81384 --- /dev/null +++ b/firmware/src/hardware/FatFsFileIo.h @@ -0,0 +1,22 @@ +/** + * Copyright (C) Johannes Elliesen, 2024 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +class FatFsFileIo +{ +}; \ No newline at end of file diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 4ee83ca..13021d9 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -21,6 +21,7 @@ #include "constants.h" #include "dsp/PeakMeter.h" +#include "hardware/FatFsFileIo.h" #include "hardware/UiHardware.h" #include "ui/TapeLooperUi.h" #include "dsp/TapeLooper.h" @@ -37,6 +38,7 @@ struct LooperTypes using MonoLooperType = TapeLooper; using StereoLooperType = TapeLooper; using ParameterProvider = LooperParameterProviderType; + using AudioSaveAndRecallType = AudioSaveAndRecall; }; using LooperControllerType = LooperController; using TapeLooperUiType = TapeLooperUi, 4> peakMeters; #define EXTERNAL_SDRAM_SECTION __attribute__((section(".sdram_bss"))) LateInitializedObject EXTERNAL_SDRAM_SECTION looperStorages; LateInitializedObject looperParameterProvider; +LateInitializedObject audioSaveAndRecall; AudioBuffer<1, blockSize> monoDownmixBuffer; AudioBuffer<1, blockSize> temporaryBuffer; LateInitializedObject looperController; @@ -108,9 +111,10 @@ void initUi() void initDsp() { - // init the parameter provider looperParameterProvider.create(); + audioSaveAndRecall.create(); + // initialize the looper storage in SDRAM const auto& rawStorages = *looperStorages.create(); // build an array of storage pointers @@ -123,7 +127,8 @@ void initDsp() arrayOfStoragePtrs, monoDownmixBuffer, temporaryBuffer, - looperParameterProvider); + looperParameterProvider, + audioSaveAndRecall); // init peak meters for (auto& peakMeter : peakMeters) diff --git a/firmware/tests/LooperController_gtest.cpp b/firmware/tests/LooperController_gtest.cpp index 5828a60..5a72031 100644 --- a/firmware/tests/LooperController_gtest.cpp +++ b/firmware/tests/LooperController_gtest.cpp @@ -125,11 +125,18 @@ class ParameterProviderMock MOCK_METHOD(ProcessorParametersMock, getProcessorParameters, (size_t looperChannel), (const)); }; +class AudioSaveAndRecallMock +{ +public: + // MOCK_METHOD(ReturnType, MethodName, (Args...), (Specs...)); +}; + struct LooperMockTypes { using MonoLooperType = ::testing::NiceMock; using StereoLooperType = ::testing::NiceMock; using ParameterProvider = ::testing::NiceMock; + using AudioSaveAndRecallType = ::testing::NiceMock; }; class LooperController_Test : public ::testing::Test @@ -139,7 +146,8 @@ class LooperController_Test : public ::testing::Test controller_(storage_, monoDownmixBuffer_, temporaryBuffer_, - parameterProvider_) + parameterProvider_, + audioSaveAndRecall_) { } @@ -164,6 +172,7 @@ class LooperController_Test : public ::testing::Test AudioBuffer<1, numSamples_> monoDownmixBuffer_; AudioBuffer<1, numSamples_> temporaryBuffer_; ::testing::NiceMock parameterProvider_; + ::testing::NiceMock audioSaveAndRecall_; LooperController controller_; MockInstanceRegistry& looperInstanceRegistry_ = *mockInstances.GetStateForCurrentTest(); }; From d28bb952503d9fa0578462c859ce54cd989f4b1a Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 4 Sep 2024 20:44:51 +0200 Subject: [PATCH 08/26] feat: allow to set recording progress from tests --- dsp/src/dsp/Recorder.h | 2 ++ dsp/src/dsp/TapeLooper.h | 2 ++ 2 files changed, 4 insertions(+) diff --git a/dsp/src/dsp/Recorder.h b/dsp/src/dsp/Recorder.h index 00f78a2..21310e8 100644 --- a/dsp/src/dsp/Recorder.h +++ b/dsp/src/dsp/Recorder.h @@ -144,6 +144,8 @@ class Recorder size_t getCurrentRecordingLength() const { return currentLength_; } bool isRecording() const { return isRecording_ || isFadingOut_; } + void setRecordingProgressForTesting(size_t numSamplesDone) { recHeadIdx_ = numSamplesDone; } + private: static constexpr float defaultXFadeLengthInS_ = 0.1f; diff --git a/dsp/src/dsp/TapeLooper.h b/dsp/src/dsp/TapeLooper.h index 5502126..75f53a7 100644 --- a/dsp/src/dsp/TapeLooper.h +++ b/dsp/src/dsp/TapeLooper.h @@ -268,6 +268,8 @@ class TapeLooper static constexpr size_t getNumChannels() { return numChannels; } static constexpr size_t getSampleRate() { return sampleRate; } + RecorderType& recorderForTesting() { return recorder_; } + private: const LooperStoragePtr storage_; LooperState state_; From 53a9e51dafdc31456afcc49956114493ad7418cb Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 01:56:13 +0200 Subject: [PATCH 09/26] chore: Move some libDaisy stubs in tests to a dedicated header file --- firmware/tests/LooperController_gtest.cpp | 1 + firmware/tests/Ui_gtest.cpp | 1 + firmware/tests/libDaisyCombined.h | 25 +++++++++++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 firmware/tests/libDaisyCombined.h diff --git a/firmware/tests/LooperController_gtest.cpp b/firmware/tests/LooperController_gtest.cpp index 5a72031..f0a3094 100644 --- a/firmware/tests/LooperController_gtest.cpp +++ b/firmware/tests/LooperController_gtest.cpp @@ -18,6 +18,7 @@ #include #include +#include "libDaisyCombined.h" #include "LooperController.h" #include "../libDaisy/tests/TestIsolator.h" diff --git a/firmware/tests/Ui_gtest.cpp b/firmware/tests/Ui_gtest.cpp index 9f980cd..7ac9fbe 100644 --- a/firmware/tests/Ui_gtest.cpp +++ b/firmware/tests/Ui_gtest.cpp @@ -20,6 +20,7 @@ #include <../tests/TestIsolator.h> // from libDaisy #include // from libDaisy +#include "libDaisyCombined.h" // remaining libDaisy mocks/stubs #include "ui/TapeLooperUi.h" #include "mocks/LooperController_mock.h" diff --git a/firmware/tests/libDaisyCombined.h b/firmware/tests/libDaisyCombined.h new file mode 100644 index 0000000..0998f63 --- /dev/null +++ b/firmware/tests/libDaisyCombined.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +// substitutes for the stuff provided by the daisy library +#define FBIPMAX 0.999985f /**< close to 1.0f-LSB at 16 bit */ +#define FBIPMIN (-FBIPMAX) /**< - (1 - LSB) */ +#define F2S32_SCALE 2147483647.f /**< (2 ** 31) - 1 */ +#define S322F_SCALE 4.6566129e-10f /**< 1 / (2** 31) */ +#define S162F_SCALE 3.0517578125e-05f /**< 1 / (2** 15) */ + +inline int32_t f2s32(float x) +{ + x = x <= FBIPMIN ? FBIPMIN : x; + x = x >= FBIPMAX ? FBIPMAX : x; + return (int32_t) (x * F2S32_SCALE); +} +inline float s322f(int32_t x) +{ + return (float) x * S322F_SCALE; +} +inline float s162f(int16_t x) +{ + return (float) x * S162F_SCALE; +} \ No newline at end of file From 9d78b402edc61010d550bbe1c046753057e4d20e Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 4 Sep 2024 21:45:24 +0200 Subject: [PATCH 10/26] feat: add WavFileFormat.cpp --- firmware/CMakeLists.txt | 1 + firmware/src/util/WavFileFormat.cpp | 61 +++++++++++++++++++++++++++ firmware/src/util/WavFileFormat.h | 63 ++++++++++++++++++++++++++++ firmware/tests/firmware_combined.cpp | 1 + 4 files changed, 126 insertions(+) create mode 100644 firmware/src/util/WavFileFormat.cpp create mode 100644 firmware/src/util/WavFileFormat.h create mode 100644 firmware/tests/firmware_combined.cpp diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index 39fa292..8a68ed9 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -24,6 +24,7 @@ endif(COMMAND cmake_policy) set(FIRMWARE_NAME TapeLooper) set(FIRMWARE_SOURCES src/main.cpp + src/util/WavFileFormat.cpp ) # include dsp library diff --git a/firmware/src/util/WavFileFormat.cpp b/firmware/src/util/WavFileFormat.cpp new file mode 100644 index 0000000..e9ae956 --- /dev/null +++ b/firmware/src/util/WavFileFormat.cpp @@ -0,0 +1,61 @@ +/** + * Copyright (C) Johannes Elliesen, 2024 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "WavFileFormat.h" + +void updateWavFileHeaderSizes(WavFileHeader& header, const int numSamples) +{ + header.dataBlocSize = numSamples * header.numBytesPerBloc; + header.fileSizeMinus8Bytes = header.dataBlocSize + sizeof(WavFileHeader) - 8; +} + +WavFileHeader makeWavFileHeader32bitInt(const int sampleRate, + const int numChannels, + const int numSamples) +{ + WavFileHeader header; + header.audioFormat = WavFileFormatCode::pcm; + header.numAudioChannels = numChannels; + header.sampleRate = sampleRate; + header.numBitsPerSample = 32; + header.numBytesPerBloc = header.numAudioChannels + * header.numBitsPerSample / 8; + header.numBytesPerSec = sampleRate * header.numBytesPerBloc; + updateWavFileHeaderSizes(header, numSamples); + return header; +} + +WavFileHeader makeWavFileHeader32bitFloat(const int sampleRate, + const int numChannels, + const int numSamples) +{ + WavFileHeader header; + header.audioFormat = WavFileFormatCode::ieeeFloat; + header.numAudioChannels = numChannels; + header.sampleRate = sampleRate; + header.numBitsPerSample = 32; + header.numBytesPerBloc = header.numAudioChannels + * header.numBitsPerSample / 8; + header.numBytesPerSec = sampleRate * header.numBytesPerBloc; + updateWavFileHeaderSizes(header, numSamples); + return header; +} + +uint32_t getNumSamples(const WavFileHeader& header) +{ + return uint32_t(header.dataBlocSize / header.numBytesPerBloc); +} diff --git a/firmware/src/util/WavFileFormat.h b/firmware/src/util/WavFileFormat.h new file mode 100644 index 0000000..27c15ea --- /dev/null +++ b/firmware/src/util/WavFileFormat.h @@ -0,0 +1,63 @@ +/** + * Copyright (C) Johannes Elliesen, 2024 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +enum class WavFileFormatCode +{ + pcm = 0x0001, + ieeeFloat = 0x0003, + aLaw = 0x0006, + uLaw = 0x0007, + extensible = 0xFFFE, +}; + +#ifdef _MSC_VER +# pragma pack(push, 1) +# define PACKED_ATTRIBUTE +#else +# define PACKED_ATTRIBUTE __attribute__((__packed__)) +#endif +struct WavFileHeader +{ + uint32_t fileTypeBlocId = 0x46464952; // "RIFF" + uint32_t fileSizeMinus8Bytes; + uint32_t fileFormatId = 0x45564157; // "WAVE" + uint32_t formatBlocId = 0x20746d66; // "fmt " + uint32_t formatBlocSize = 0x10; + WavFileFormatCode audioFormat : 16; + uint16_t numAudioChannels; + uint32_t sampleRate; + uint32_t numBytesPerSec; + uint16_t numBytesPerBloc; + uint16_t numBitsPerSample; + uint32_t dataBlocId = 0x61746164; // "data" + uint32_t dataBlocSize; +} PACKED_ATTRIBUTE; +#ifdef _MSC_VER +# pragma pack(pop) +#endif + +void updateWavFileHeaderSizes(WavFileHeader& header, int numSamples); + +WavFileHeader makeWavFileHeader32bitInt(int sampleRate, int numChannels, int numSamples = 0); + +WavFileHeader makeWavFileHeader32bitFloat(int sampleRate, int numChannels, int numSamples = 0); + +uint32_t getNumSamples(const WavFileHeader& header); diff --git a/firmware/tests/firmware_combined.cpp b/firmware/tests/firmware_combined.cpp new file mode 100644 index 0000000..9178721 --- /dev/null +++ b/firmware/tests/firmware_combined.cpp @@ -0,0 +1 @@ +#include "../src/util/WavFileFormat.cpp" \ No newline at end of file From 11ef983db7ec4319f54fde07ed8d5bd017ccac4b Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Tue, 7 Oct 2025 22:56:58 +0200 Subject: [PATCH 11/26] fix: don't destroy never created object When calling `initializeToLayout` for the very first time, the `LateInitializedObject` wasn't initialized and we'd be calling the destructor of a dead object. --- firmware/src/LooperController.h | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/firmware/src/LooperController.h b/firmware/src/LooperController.h index 716bb54..72af340 100644 --- a/firmware/src/LooperController.h +++ b/firmware/src/LooperController.h @@ -29,7 +29,8 @@ enum class ChannelLayout { mono, - stereo + stereo, + invalid }; enum class MotorAcceleration @@ -226,23 +227,37 @@ class LooperController looper.template destroy(); } - ChannelLayout layout = ChannelLayout::stereo; + ChannelLayout layout = ChannelLayout::invalid; Direction direction = Direction::forwards; MotorAcceleration acceleration = MotorAcceleration::medium; LateInitializedObject looper; void initializeToLayout(const ChannelLayout newLayout, MonoOrStereoLooperStoragePtr& storage) { - layout = newLayout; - if (layout == ChannelLayout::mono) + switch (layout) { - looper.template destroy(); - looper.template create(storage); + case ChannelLayout::mono: + looper.template destroy(); + break; + case ChannelLayout::stereo: + looper.template destroy(); + break; + default: + break; } - else + + layout = newLayout; + + switch (newLayout) { - looper.template destroy(); - looper.template create(storage); + case ChannelLayout::mono: + looper.template create(storage); + break; + case ChannelLayout::stereo: + looper.template create(storage); + break; + default: + break; } } }; From 08b1a7a639f8d79638aef752f9346cf575f1c33d Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Tue, 7 Oct 2025 23:24:49 +0200 Subject: [PATCH 12/26] chore: run firmware tests in test folder to be able to access files there --- .github/workflows/firmware.yaml | 6 +++--- .vscode/launch.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/firmware.yaml b/.github/workflows/firmware.yaml index c403fb0..7fd98a2 100644 --- a/.github/workflows/firmware.yaml +++ b/.github/workflows/firmware.yaml @@ -72,14 +72,14 @@ jobs: - name: Run Tests run: | - cd firmware/tests/build/bin - ./TapeLooper_gtest --gtest_output=xml:gtestresults.xml + cd firmware/tests + ./build/bin/TapeLooper_gtest --gtest_output=xml:gtestresults.xml - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v1 if: always() with: - files: firmware/tests/build/bin/**/*.xml + files: firmware/tests/**/*.xml github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.vscode/launch.json b/.vscode/launch.json index 5a824f1..a27a706 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -91,7 +91,7 @@ "program": "${workspaceFolder}/firmware/tests/build/bin/TapeLooper_gtest", "args": [], "stopAtEntry": false, - "cwd": "${workspaceFolder}/firmware/tests/build/bin", + "cwd": "${workspaceFolder}/firmware/tests/", "environment": [], "externalConsole": false, "logging": { From 95c9d3aea23c418ccde3aeddf11993f3667953d1 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:36:26 +0200 Subject: [PATCH 13/26] chore: update Visual Studio version and github actions --- .github/workflows/firmware.yaml | 4 ++-- .github/workflows/plugin.yaml | 26 +++++++++++++------------- .vscode/tasks.json | 4 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/firmware.yaml b/.github/workflows/firmware.yaml index 7fd98a2..c2e9a24 100644 --- a/.github/workflows/firmware.yaml +++ b/.github/workflows/firmware.yaml @@ -43,13 +43,13 @@ jobs: run: cmake --build firmware/build - name: Upload Firmware hex - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: TapeLooper.hex path: firmware/build/TapeLooper.hex - name: Upload Firmware bin - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: TapeLooper.bin path: firmware/build/TapeLooper.bin diff --git a/.github/workflows/plugin.yaml b/.github/workflows/plugin.yaml index d5ae327..afcdec5 100644 --- a/.github/workflows/plugin.yaml +++ b/.github/workflows/plugin.yaml @@ -18,7 +18,7 @@ jobs: ############################################################################### # builds the plugin on windows testBuildWindows: - runs-on: windows-2019 + runs-on: windows-2025 steps: - name: Setup cmake @@ -33,7 +33,7 @@ jobs: - name: Configure run: | - cmake.exe -G "Visual Studio 16 2019" -A x64 -B plugin/build -S plugin + cmake.exe -G "Visual Studio 17 2022" -A x64 -B plugin/build -S plugin - name: Build run: | @@ -44,13 +44,13 @@ jobs: plugin/build/Release/TapeLooperPlugin_Gtest.exe --gtest_output=xml:TestResults\gtestResults_windows.xml - name: Upload Test Results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: TestResults + name: TestResults_Windows path: TestResults/** - name: Upload executable - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: TapeLooperPlugin_Windows path: | @@ -95,13 +95,13 @@ jobs: plugin/build/Release/TapeLooperPlugin_Gtest --gtest_output=xml:TestResults/gtestResults_macos.xml - name: Upload Test Results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: TestResults + name: TestResults_MacOS path: TestResults/** - name: Upload executable - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: TapeLooperPlugin_MacOS path: | @@ -142,13 +142,13 @@ jobs: plugin/build/TapeLooperPlugin_Gtest --gtest_output=xml:TestResults/gtestResults_ubuntu.xml - name: Upload Test Results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: TestResults + name: TestResults_Linux path: TestResults/** - name: Upload executable - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: TapeLooperPlugin_Linux path: | @@ -167,9 +167,9 @@ jobs: steps: - name: Download Artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: TestResults + pattern: TestResults_* path: TestResults - name: Publish Test Results diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ef65058..dea65e9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -168,7 +168,7 @@ "-D", "CMAKE_EXPORT_COMPILE_COMMANDS=1", "-G", - "\\\"Visual Studio 16 2019\\\"" + "\\\"Visual Studio 17 2022\\\"" ] }, "group": { @@ -207,7 +207,7 @@ "-D", "CMAKE_EXPORT_COMPILE_COMMANDS=1", "-G", - "\\\"Visual Studio 16 2019\\\"" + "\\\"Visual Studio 17 2022\\\"" ] }, "group": { From 31cf105e4f04f5ade1c3d7560ca2b7bde844763a Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 4 Sep 2024 21:50:37 +0200 Subject: [PATCH 14/26] feat: implement AudioSaveAndRecall --- firmware/src/AudioSaveAndRecall.h | 626 ++++++++++++++++++ firmware/src/hardware/FatFsFileIo.h | 26 + firmware/tests/AudioSaveAndRecall_gtest.cpp | 540 +++++++++++++++ firmware/tests/data/monoSaw_float.wav | Bin 0 -> 272 bytes firmware/tests/data/monoSaw_int16.wav | Bin 0 -> 140 bytes firmware/tests/data/monoSaw_int24.wav | Bin 0 -> 188 bytes firmware/tests/data/monoSaw_int32.wav | Bin 0 -> 236 bytes firmware/tests/data/monoSineWaves.wav | Bin 0 -> 19244 bytes firmware/tests/data/stereoSaw_float.wav | Bin 0 -> 472 bytes .../tests/data/stereoSaw_float_Ableton.wav | Bin 0 -> 464 bytes firmware/tests/data/stereoSaw_int16.wav | Bin 0 -> 236 bytes firmware/tests/data/stereoSaw_int24.wav | Bin 0 -> 332 bytes firmware/tests/data/stereoSaw_int32.wav | Bin 0 -> 428 bytes firmware/tests/data/stereoSineWaves.wav | Bin 0 -> 38444 bytes firmware/tests/libDaisyCombined.h | 7 + 15 files changed, 1199 insertions(+) create mode 100644 firmware/tests/AudioSaveAndRecall_gtest.cpp create mode 100644 firmware/tests/data/monoSaw_float.wav create mode 100644 firmware/tests/data/monoSaw_int16.wav create mode 100644 firmware/tests/data/monoSaw_int24.wav create mode 100644 firmware/tests/data/monoSaw_int32.wav create mode 100644 firmware/tests/data/monoSineWaves.wav create mode 100644 firmware/tests/data/stereoSaw_float.wav create mode 100644 firmware/tests/data/stereoSaw_float_Ableton.wav create mode 100644 firmware/tests/data/stereoSaw_int16.wav create mode 100644 firmware/tests/data/stereoSaw_int24.wav create mode 100644 firmware/tests/data/stereoSaw_int32.wav create mode 100644 firmware/tests/data/stereoSineWaves.wav diff --git a/firmware/src/AudioSaveAndRecall.h b/firmware/src/AudioSaveAndRecall.h index 36bb3d3..b6b92cf 100644 --- a/firmware/src/AudioSaveAndRecall.h +++ b/firmware/src/AudioSaveAndRecall.h @@ -17,6 +17,12 @@ #pragma once +#include +#include +#include "util/WavFileFormat.h" + +using AudioFileName = daisy::FixedCapStr<32>; + enum class AudioSaveAndRecallResult { ok, @@ -38,4 +44,624 @@ template class AudioSaveAndRecall { public: + AudioSaveAndRecall() + { + isStorageSlotUsedBitfield_[0] = 0; + isStorageSlotUsedBitfield_[1] = 0; + isStorageSlotUsedBitfield_[2] = 0; + } + + void scanOrInitLibrary() + { + fileIo_.makeFolderIfNotExistent(libraryBaseFolder_); + + for (size_t bank = 0; bank < kNumBanks; bank++) + { + for (size_t slot = 0; slot < kNumSlots; slot++) + { + const auto filename = makeWavFileNameFor(StorageBank(bank), slot); + + if (const auto isInUse = fileIo_.hasFile(filename)) + isStorageSlotUsedBitfield_[bank] |= (1 << slot); + else + isStorageSlotUsedBitfield_[bank] &= ~(1 << slot); + } + } + } + + template + void startSavingToFile(StorageBank bank, + int slot, + LooperType& looper, + AudioSaveAndRecallDoneCallbackPtr doneCallback, + void* doneCallbackContext) + { + startSavingToFile(makeWavFileNameFor(bank, slot), + looper, + doneCallback, + doneCallbackContext); + } + + template + void startSavingToFile(const AudioFileName& filename, + LooperType& looper, + AudioSaveAndRecallDoneCallbackPtr doneCallback, + void* doneCallbackContext) + { + static_assert(LooperType::getNumChannels() <= kMaxNumChannels_); + + looperPtr_ = &looper; + doneCallback_ = doneCallback; + doneCallbackContext_ = doneCallbackContext; + + looper.preventRecording(); + liftRestrictionsFunc_ = [](void* looperPtr) + { + LooperType* looper = reinterpret_cast(looperPtr); + looper->liftRestrictions(); + }; + + totalNumFrames_ = looper.getPlaybackLength(); + + if (totalNumFrames_ == 0) + { + abort(false); + return; + } + + if ( + !fileIo_.openForWriting(filename) + || !writeWavHeader(totalNumFrames_, looper.getNumChannels(), looper.getSampleRate())) + { + abort(true); + return; + } + + writeOrReadFunc_ = [](AudioSaveAndRecall* storage, void* looperPtr) + { + LooperType* looper = reinterpret_cast(looperPtr); + + // wait while the current recording is still crossfading out + if (looper->isRecording()) + return ReadOrWriteResult::inProgress; + + const auto numSamplesLeft = storage->totalNumFrames_ - storage->numFramesDone_; + + if (numSamplesLeft <= 0) + { + return ReadOrWriteResult::error; + } + + const auto numFramesInThisChunk = std::min(size_t(numSamplesLeft), kNumFramesPerChunk_); + const auto writeSuccessful = storage->writeBuffer(looper->getSampleStoragePtr(), + storage->numFramesDone_, + numFramesInThisChunk); + if (!writeSuccessful) + { + return ReadOrWriteResult::error; + } + + storage->numFramesDone_ += numFramesInThisChunk; + + const auto isNowDone = storage->numFramesDone_ == storage->totalNumFrames_; + if (isNowDone) + { + storage->fileIo_.closeFile(); + return ReadOrWriteResult::completed; + } + return ReadOrWriteResult::inProgress; + }; + } + + size_t getNumChannelsInFile(StorageBank bank, + int slot) + { + return getNumChannelsInFile(makeWavFileNameFor(bank, slot)); + } + + size_t getNumChannelsInFile(const AudioFileName& filename) + { + if (isSavingOrRecalling()) + { + return 0; + } + + if (!fileIo_.openForReading(filename)) + { + return 0; + } + + if (!readWavHeader() || !isCompatibleFormat()) + { + fileIo_.closeFile(); + return 0; + } + + fileIo_.closeFile(); + return size_t(wavHeader_.numAudioChannels); + } + + template + void startReadingFromFile(StorageBank bank, + int slot, + LooperType& looper, + AudioSaveAndRecallDoneCallbackPtr doneCallback, + void* doneCallbackContext) + { + startReadingFromFile(makeWavFileNameFor(bank, slot), + looper, + doneCallback, + doneCallbackContext); + } + + template + void startReadingFromFile(const AudioFileName& filename, + LooperType& looper, + AudioSaveAndRecallDoneCallbackPtr doneCallback, + void* doneCallbackContext) + { + static_assert(LooperType::getNumChannels() <= kMaxNumChannels_); + + looperPtr_ = &looper; + doneCallback_ = doneCallback; + doneCallbackContext_ = doneCallbackContext; + + looper.preventPlaybackAndRecording(true); + liftRestrictionsFunc_ = [](void* looperPtr) + { + LooperType* looper = reinterpret_cast(looperPtr); + looper->liftRestrictions(); + }; + + if (!fileIo_.openForReading(filename)) + { + abort(false); + return; + } + + if (!readWavHeader() + || !isCompatibleFormat() + || LooperType::getNumChannels() != wavHeader_.numAudioChannels) + { + abort(true); + return; + } + + totalNumFrames_ = std::min(looper.getSampleStoragePtr().numSamples, + size_t(getNumSamples(wavHeader_))); + + writeOrReadFunc_ = [](AudioSaveAndRecall* storage, void* looperPtr) + { + LooperType* looper = reinterpret_cast(looperPtr); + + const auto numSamplesLeft = storage->totalNumFrames_ - storage->numFramesDone_; + const auto numFramesInThisChunk = std::min(numSamplesLeft, kNumFramesPerChunk_); + + const auto numSamplesRead = storage->readBuffer(looper->getSampleStoragePtr(), + storage->numFramesDone_, + numFramesInThisChunk); + if (numSamplesRead < 0) + { + return ReadOrWriteResult::error; + } + + storage->numFramesDone_ += numSamplesRead; + + const auto isNowDone = storage->totalNumFrames_ == storage->numFramesDone_; + if (isNowDone) + { + looper->setPlaybackLength(storage->totalNumFrames_); + storage->fileIo_.closeFile(); + return ReadOrWriteResult::completed; + } + return ReadOrWriteResult::inProgress; + }; + } + + float getCurrentProgress() const + { + if (totalNumFrames_ <= 0) + return -1.0f; + return float(numFramesDone_) / float(totalNumFrames_); + } + + bool isSavingOrRecalling() const + { + return totalNumFrames_ > 0; + } + + void readOrWriteNextChunk() + { + if (writeOrReadFunc_) + { + const auto result = writeOrReadFunc_(this, looperPtr_); + + if (result == ReadOrWriteResult::error) + { + abort(true); + return; + } + else if (result == ReadOrWriteResult::completed) + { + liftRestrictionsFunc_(looperPtr_); + if (doneCallback_) + { + doneCallback_(doneCallbackContext_, AudioSaveAndRecallResult::ok); + } + + doneCallback_ = nullptr; + doneCallbackContext_ = nullptr; + liftRestrictionsFunc_ = nullptr; + looperPtr_ = nullptr; + } + } + } + + FileIoProvider& getFileIoProviderForTesting() { return fileIo_; } + + void abort() + { + if (isSavingOrRecalling()) + { + abort(true); + } + } + +private: + void abort(bool shouldCloseFile) + { + if (shouldCloseFile) + fileIo_.closeFile(); + + if (liftRestrictionsFunc_) + { + liftRestrictionsFunc_(looperPtr_); + } + + if (doneCallback_) + { + doneCallback_(doneCallbackContext_, AudioSaveAndRecallResult::error); + } + + doneCallback_ = nullptr; + doneCallbackContext_ = nullptr; + liftRestrictionsFunc_ = nullptr; + looperPtr_ = nullptr; + totalNumFrames_ = 0; + numFramesDone_ = 0; + } + + bool writeWavHeader(size_t totalNumFrames, size_t numChannelsPerFrame, int sampleRate) + { + wavHeader_ = makeWavFileHeader32bitInt(sampleRate, numChannelsPerFrame, totalNumFrames); + + return fileIo_.write(&wavHeader_, sizeof(wavHeader_)); + } + + bool readWavHeader() + { + const auto skipForwardUntil = [&](int32_t value) + { + int32_t last4Bytes = 0; + while (last4Bytes != value) + { + uint8_t nextByte; + if (fileIo_.read(&nextByte, 1) != 1) + { + return false; + } + + last4Bytes = ((last4Bytes >> 8) & 0x00FFFFFF) + | (nextByte << 24); + } + return true; + }; + + // read Master RIFF chunk + { + const auto kMasterRiffChunkSize = 3 * sizeof(int32_t); + const auto numBytesRead = fileIo_.read(&wavHeader_.fileTypeBlocId, kMasterRiffChunkSize); + + // invalid Master RIFF chunk + if (numBytesRead != kMasterRiffChunkSize + || wavHeader_.fileTypeBlocId != 0x46464952 // "RIFF" + || wavHeader_.fileFormatId != 0x45564157 // "WAVE" + ) + { + return false; + } + } + + // read fmt chunk + { + if (!skipForwardUntil(0x20746d66)) // "fmt" + { + return false; + } + wavHeader_.formatBlocId = 0x20746d66; + + const auto kFmtChunkContentSize = 5 * sizeof(int32_t); + if (fileIo_.read(&wavHeader_.formatBlocSize, kFmtChunkContentSize) != kFmtChunkContentSize) + { + return false; + } + + if (wavHeader_.formatBlocSize < 0x10) // fmt chunk size can never be <16bytes + { + return false; + } + } + + // there can be additional sections in the file. Skip forward if our + // data section doesn't point to the right data yet. + { + if (!skipForwardUntil(0x61746164)) // "data" + { + return false; + } + + wavHeader_.dataBlocId = 0x61746164; + if (fileIo_.read(&wavHeader_.dataBlocSize, sizeof(wavHeader_.dataBlocSize)) != sizeof(wavHeader_.dataBlocSize)) + { + return false; + } + } + + return true; + } + + bool isCompatibleFormat() const + { + const auto isCompatibleSampleFormat = + // 16bit PCM or 24bit PCM or 32bit PCM + (wavHeader_.audioFormat == WavFileFormatCode::pcm + && (wavHeader_.numBitsPerSample == 16 + || wavHeader_.numBitsPerSample == 24 + || wavHeader_.numBitsPerSample == 32)) + // 32bit float + || (wavHeader_.audioFormat == WavFileFormatCode::ieeeFloat + && wavHeader_.numBitsPerSample == 32); + + const auto isCompatibleNumChannels = + wavHeader_.numAudioChannels == 1 + || wavHeader_.numAudioChannels == 2; + + return isCompatibleSampleFormat && isCompatibleNumChannels; + } + + template + bool writeBuffer(LooperStoragePtr storage, const size_t startFrame, const size_t numFrames) + { + float* inPtr[numChannels]; + + for (size_t ch = 0; ch < numChannels; ch++) + inPtr[ch] = &storage.data[ch][startFrame]; + + int32_t* outPtr = &writeBuffer_[0]; + + size_t numFramesLeft = numFrames; + while (numFramesLeft > 0) + { + for (size_t ch = 0; ch < numChannels; ch++) + { + *outPtr = f2s32(*inPtr[ch]); + outPtr++; + inPtr[ch]++; + } + numFramesLeft--; + } + + const auto numBytes = std::distance(&writeBuffer_[0], outPtr) * sizeof(int32_t); + return fileIo_.write(writeBuffer_, numBytes); + } + + template + int32_t readBuffer(LooperStoragePtr storage, const size_t startFrame, const size_t maxNumFrames) + { + float* outPtr[numChannels]; + + for (size_t ch = 0; ch < numChannels; ch++) + outPtr[ch] = &storage.data[ch][startFrame]; + + uint8_t* inPtr = reinterpret_cast(&writeBuffer_[0]); + int32_t numFramesRead = 0; + + if (wavHeader_.audioFormat == WavFileFormatCode::ieeeFloat) + { + constexpr auto kFrameSize = sizeof(float); + const auto maxNumBytes = maxNumFrames * numChannels * kFrameSize; + const auto bytesRead = fileIo_.read(writeBuffer_, maxNumBytes); + + if (bytesRead < 0) + { + return -1; + } + + numFramesRead = bytesRead / kFrameSize; + size_t numFramesLeft = numFramesRead; + while (numFramesLeft > 0) + { + for (size_t ch = 0; ch < numChannels; ch++) + { + *outPtr[ch] = *reinterpret_cast(inPtr); + inPtr += kFrameSize; + outPtr[ch]++; + } + numFramesLeft--; + } + } + else if (wavHeader_.audioFormat == WavFileFormatCode::pcm && wavHeader_.numBitsPerSample == 32) + { + constexpr auto kFrameSize = sizeof(int32_t); + const auto maxNumBytes = maxNumFrames * numChannels * kFrameSize; + const auto bytesRead = fileIo_.read(writeBuffer_, maxNumBytes); + + if (bytesRead < 0) + { + return -1; + } + + numFramesRead = bytesRead / kFrameSize; + size_t numFramesLeft = numFramesRead; + while (numFramesLeft > 0) + { + for (size_t ch = 0; ch < numChannels; ch++) + { + *outPtr[ch] = s322f(*reinterpret_cast(inPtr)); + inPtr += kFrameSize; + outPtr[ch]++; + } + numFramesLeft--; + } + } + else if (wavHeader_.audioFormat == WavFileFormatCode::pcm && wavHeader_.numBitsPerSample == 24) + { + constexpr auto kFrameSize = sizeof(int8_t) * 3; + const auto maxNumBytes = maxNumFrames * numChannels * kFrameSize; + const auto bytesRead = fileIo_.read(writeBuffer_, maxNumBytes); + + if (bytesRead < 0) + { + return -1; + } + + numFramesRead = bytesRead / kFrameSize; + size_t numFramesLeft = numFramesRead; + while (numFramesLeft > 0) + { + for (size_t ch = 0; ch < numChannels; ch++) + { + *outPtr[ch] = s242f(*reinterpret_cast(inPtr) & 0xFFFFFF); + inPtr += kFrameSize; + outPtr[ch]++; + } + numFramesLeft--; + } + } + else if (wavHeader_.audioFormat == WavFileFormatCode::pcm && wavHeader_.numBitsPerSample == 16) + { + constexpr auto kFrameSize = sizeof(int16_t); + const auto maxNumBytes = maxNumFrames * numChannels * kFrameSize; + const auto bytesRead = fileIo_.read(writeBuffer_, maxNumBytes); + + if (bytesRead < 0) + { + return -1; + } + + numFramesRead = bytesRead / kFrameSize; + size_t numFramesLeft = numFramesRead; + while (numFramesLeft > 0) + { + for (size_t ch = 0; ch < numChannels; ch++) + { + *outPtr[ch] = s162f(*reinterpret_cast(inPtr)); + inPtr += kFrameSize; + outPtr[ch]++; + } + numFramesLeft--; + } + } + + return numFramesRead / numChannels; + } + + AudioFileName makeFileNameWithoutExtensionFor(const StorageBank bank, + const int slot) + { + auto filename = libraryBaseFolder_; + switch (bank) + { + case StorageBank::green: + filename.Append("green-"); + break; + case StorageBank::yellow: + filename.Append("yellow-"); + break; + case StorageBank::red: + filename.Append("red-"); + break; + default: + return ""; + } + switch (slot / 4) + { + case 0: + filename.Append("A"); + break; + case 1: + filename.Append("B"); + break; + case 2: + filename.Append("C"); + break; + case 3: + filename.Append("D"); + break; + default: + return ""; + } + switch (slot % 4) + { + case 0: + filename.Append("0"); + break; + case 1: + filename.Append("1"); + break; + case 2: + filename.Append("2"); + break; + case 3: + filename.Append("3"); + break; + default: + return ""; + } + return filename; + } + + AudioFileName makeWavFileNameFor(const StorageBank bank, + const int slot) + { + auto name = makeFileNameWithoutExtensionFor(bank, slot); + name.Append(".wav"); + return name; + } + + static constexpr size_t kNumFramesPerChunk_ = 1000; + static constexpr size_t kBitsPerSample_ = 32u; + static constexpr size_t kMaxNumChannels_ = 2u; + static constexpr size_t kWriteBufferSizeBytes_ = kNumFramesPerChunk_ + * kMaxNumChannels_ + * kBitsPerSample_ / 8u; + + WavFileHeader wavHeader_; + + enum class ReadOrWriteResult + { + inProgress, + completed, + error + }; + typedef ReadOrWriteResult (*WriteOrReadFuncPtr)(AudioSaveAndRecall* storage, void* looper); + typedef void (*LiftRestrictionsFuncPtr)(void* looper); + + FileIoProvider fileIo_; + + size_t totalNumFrames_ = 0; + size_t numFramesDone_ = 0; + WriteOrReadFuncPtr writeOrReadFunc_ = nullptr; + LiftRestrictionsFuncPtr liftRestrictionsFunc_ = nullptr; + void* looperPtr_ = nullptr; + AudioSaveAndRecallDoneCallbackPtr doneCallback_ = nullptr; + void* doneCallbackContext_ = nullptr; + + int32_t writeBuffer_[kWriteBufferSizeBytes_ / sizeof(int32_t)]; + + static constexpr auto kNumSlots = 16; + static constexpr auto kNumBanks = int(StorageBank::count); + int16_t isStorageSlotUsedBitfield_[kNumBanks]; + + const AudioFileName libraryBaseFolder_ = "loops"; }; diff --git a/firmware/src/hardware/FatFsFileIo.h b/firmware/src/hardware/FatFsFileIo.h index 2f81384..4d37701 100644 --- a/firmware/src/hardware/FatFsFileIo.h +++ b/firmware/src/hardware/FatFsFileIo.h @@ -19,4 +19,30 @@ class FatFsFileIo { +public: + bool openForReading(const char* fileName) + { + (void) (fileName); + return false; + } + + bool openForWriting(const char* fileName) + { + (void) (fileName); + return false; + } + + bool write(void* data, size_t size) + { + return false; + } + + int32_t read(void* data, size_t maxSize) + { + return -1; + } + + void closeFile() + { + } }; \ No newline at end of file diff --git a/firmware/tests/AudioSaveAndRecall_gtest.cpp b/firmware/tests/AudioSaveAndRecall_gtest.cpp new file mode 100644 index 0000000..4c0312f --- /dev/null +++ b/firmware/tests/AudioSaveAndRecall_gtest.cpp @@ -0,0 +1,540 @@ +/** + * Copyright (C) Johannes Elliesen, 2024 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include +#include + +// uncomment to update the test references +// #define WRITE_REFERENCES + +#include "libDaisyCombined.h" +#include "AudioSaveAndRecall.h" +namespace +{ + static constexpr auto kSampleRate = 48000; + + std::vector makeSawWave(double frequency, size_t numSamples) + { + const float kIncrement = float(2.0f * frequency / double(kSampleRate)); + float value = 0.0f; + + std::vector result(numSamples); + for (auto& sample : result) + { + sample = value; + value += kIncrement; + if (value >= 0.9999f) + { + value = -1.0f; + } + } + + return result; + } + + std::vector readReferenceFile(const char* filename) + { + std::ifstream referenceFile(filename, std::ios::binary); + EXPECT_TRUE(referenceFile.is_open()); + + std::vector contents; + + char b; + while (referenceFile.read(&b, sizeof(b))) + { + contents.push_back(std::byte(b)); + } + referenceFile.close(); + + return contents; + } + +#ifdef WRITE_REFERENCES + void writeReferenceFile(const char* filename, const std::vector& data) + { + std::ofstream referenceFile(filename, std::ios::binary); + EXPECT_TRUE(referenceFile.is_open()); + + for (std::byte byte : data) + { + referenceFile << std::ofstream::char_type(byte); + } + referenceFile.close(); + } +#endif +} // namespace + +class TestFileIo +{ +public: + bool openForReading(const AudioFileName& fileName) + { + lastFileName_ = fileName; + + if (simulateFileOpenError_) + return false; + + readIndex_ = 0; + return true; + } + + bool openForWriting(const AudioFileName& fileName) + { + lastFileName_ = fileName; + + if (simulateFileOpenError_) + return false; + + dataWritten_.clear(); + return true; + } + + bool write(void* data, size_t size) + { + if (simulateFileWriteError_) + return false; + + dataWritten_.insert(dataWritten_.end(), + static_cast(data), + static_cast(data) + size); + return true; + } + + int32_t read(void* data, size_t maxSize) + { + if (simulateFileOpenError_) + { + return -1; + } + + uint8_t* outPtr = reinterpret_cast(data); + + const auto numRead = std::min(maxSize, dataToRead_.size() - readIndex_); + for (size_t i = 0; i < numRead; i++) + { + *outPtr = uint8_t(dataToRead_[readIndex_ + i]); + outPtr++; + } + + readIndex_ += numRead; + return numRead; + } + + void closeFile() + { + closeFileCalled_ = true; + } + + AudioFileName lastFileName_; + std::vector dataWritten_; + std::vector dataToRead_; + size_t readIndex_ = 0; + bool closeFileCalled_ = false; + + bool simulateFileOpenError_ = false; + bool simulateFileWriteError_ = false; +}; + +class AudioSaveAndRecallFixture : public ::testing::Test +{ +public: + AudioSaveAndRecallFixture() : + monoLooper_(monoBuffer_), + stereoLooper_(stereoBuffer_) + { + fillBuffersWithSilence(); + monoLooper_.setPlaybackLength(kNumSamples); + stereoLooper_.setPlaybackLength(kNumSamples); + } + + static constexpr auto kNumSamples = 48000; + LooperStorage monoBuffer_; + LooperStorage stereoBuffer_; + TapeLooper monoLooper_; + TapeLooper stereoLooper_; + + AudioSaveAndRecallResult doneCallbackResult_ = + AudioSaveAndRecallResult(-1); // invalid + + static void doneCallback(void* contextPtr, AudioSaveAndRecallResult result) + { + auto* resultFlag = reinterpret_cast(contextPtr); + *resultFlag = result; + }; + + void fillBuffersWithSilence() + { + for (size_t i = 0; i < kNumSamples; i++) + { + stereoBuffer_.data[0][i] = 0.0f; + stereoBuffer_.data[1][i] = 0.0f; + monoBuffer_.data[0][i] = 0.0f; + } + } + + void fillBuffersWithSineWaves() + { + const auto freq1 = 480.0f; + const auto phaseIncrement1 = freq1 / float(kSampleRate); + const auto freq2 = 640.0f; + const auto phaseIncrement2 = freq2 / float(kSampleRate); + + auto phase1 = 0.0f; + auto phase2 = 0.0f; + + for (size_t i = 0; i < kNumSamples; i++) + { + stereoBuffer_.data[0][i] = std::sin(phase1 * 2.0f * M_PI); + stereoBuffer_.data[1][i] = std::sin(phase2 * 2.0f * M_PI); + monoBuffer_.data[0][i] = stereoBuffer_.data[0][i]; + + phase1 += phaseIncrement1; + if (phase1 >= 1.0f) + phase1 -= 1.0f; + phase2 += phaseIncrement2; + if (phase2 >= 1.0f) + phase2 -= 1.0f; + } + } +}; + +TEST_F(AudioSaveAndRecallFixture, a_saveToFile_failWhenNoDataInLooper) +{ + AudioSaveAndRecall sut; + + monoLooper_.setPlaybackLength(0); + + sut.getFileIoProviderForTesting().simulateFileOpenError_ = true; + sut.startSavingToFile("dummyFilename", + monoLooper_, + &doneCallback, + &doneCallbackResult_); + + // doesn't even open the file + EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, ""); + EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::error); + EXPECT_EQ(sut.getCurrentProgress(), -1.0f); +} + +TEST_F(AudioSaveAndRecallFixture, b_saveToFile_failWhenFileDoesntOpen) +{ + AudioSaveAndRecall sut; + + sut.getFileIoProviderForTesting().simulateFileOpenError_ = true; + sut.startSavingToFile("dummyFilename", + monoLooper_, + &doneCallback, + &doneCallbackResult_); + + EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::error); + EXPECT_EQ(sut.getCurrentProgress(), -1.0f); + EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); +} + +TEST_F(AudioSaveAndRecallFixture, c_saveToFile_failWhenErrorWhileWriting) +{ + AudioSaveAndRecall sut; + + sut.startSavingToFile("dummyFilename", + monoLooper_, + &doneCallback, + &doneCallbackResult_); + + EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult(-1)); // not called yet + EXPECT_EQ(sut.getCurrentProgress(), 0.0f); + EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + + // make some progress + sut.readOrWriteNextChunk(); + EXPECT_GT(sut.getCurrentProgress(), 0.0f); + EXPECT_LT(sut.getCurrentProgress(), 1.0f); + + // simulate file IO error + sut.getFileIoProviderForTesting().simulateFileWriteError_ = true; + sut.readOrWriteNextChunk(); + + // operation was aborted + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::error); + EXPECT_EQ(sut.getCurrentProgress(), -1.0f); + EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); +} + +TEST_F(AudioSaveAndRecallFixture, d_saveToFile_saveFileButWaitUntilRecordingDoneAndPreventNewRecording) +{ + AudioSaveAndRecall sut; + + monoLooper_.switchState(LooperState::recording); + monoLooper_.recorderForTesting().setRecordingProgressForTesting(2000); + + sut.startSavingToFile("dummyFilename", + monoLooper_, + &doneCallback, + &doneCallbackResult_); + + EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult(-1)); // not called yet + EXPECT_EQ(sut.getCurrentProgress(), 0.0f); + EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + + // the looper was stopped when saving started... + EXPECT_EQ(monoLooper_.getState(), LooperState::stopped); + // the looper should still be recording (crossfading out) + EXPECT_TRUE(monoLooper_.isRecording()); + + // attempt to write + sut.readOrWriteNextChunk(); + // nothing happened yet + EXPECT_EQ(sut.getCurrentProgress(), 0.0f); + + // simulate that crossfading is now complete by brutally aborting the recording + monoLooper_.recorderForTesting().stopRecordingImmediately(); + monoLooper_.setPlaybackLength(kNumSamples); + + // attempt to write again + sut.readOrWriteNextChunk(); + // now we have some progress + EXPECT_GT(sut.getCurrentProgress(), 0.0f); +} + +TEST_F(AudioSaveAndRecallFixture, e_saveToFile_saveFileAndAllowPlaybackButPreventNewRecording) +{ + AudioSaveAndRecall sut; + + sut.startSavingToFile("dummyFilename", + monoLooper_, + &doneCallback, + &doneCallbackResult_); + + EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult(-1)); // not called yet + EXPECT_EQ(sut.getCurrentProgress(), 0.0f); + EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + + // attempt to start a new recording while we're saving + monoLooper_.switchState(LooperState::recording); + // but new recordings should be prevented + EXPECT_EQ(monoLooper_.getState(), LooperState::stopped); + EXPECT_FALSE(monoLooper_.isRecording()); + + // but we can still play the loop while we're saving + monoLooper_.switchState(LooperState::playing); + EXPECT_EQ(monoLooper_.getState(), LooperState::playing); + + // complete the saving operation + int timeout = 1000; + while (timeout-- > 0 && doneCallbackResult_ != AudioSaveAndRecallResult::ok) + { + sut.readOrWriteNextChunk(); + } + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::ok); + + // attempt a recording again + monoLooper_.switchState(LooperState::recording); + // and new recordings should be allowed again + EXPECT_EQ(monoLooper_.getState(), LooperState::recording); + EXPECT_TRUE(monoLooper_.isRecording()); +} + +TEST_F(AudioSaveAndRecallFixture, f_saveToFile_saveFileAndReportProgress) +{ + AudioSaveAndRecall sut; + + sut.startSavingToFile("dummyFilename", + monoLooper_, + &doneCallback, + &doneCallbackResult_); + + EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult(-1)); // not called yet + EXPECT_EQ(sut.getCurrentProgress(), 0.0f); + EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + + // write until completed + int timeout = 1000; + auto lastProgress = 0.0f; + while (timeout-- > 0 && doneCallbackResult_ != AudioSaveAndRecallResult::ok) + { + sut.readOrWriteNextChunk(); + EXPECT_GT(sut.getCurrentProgress(), lastProgress); + lastProgress = sut.getCurrentProgress(); + } + EXPECT_EQ(sut.getCurrentProgress(), 1.0f); + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::ok); + EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); +} + +TEST_F(AudioSaveAndRecallFixture, g_saveToFile_producesValidMonoWavFile) +{ + AudioSaveAndRecall sut; + + constexpr auto kNumTestSamples = 4800; + constexpr auto kReferenceFilename = "data/monoSineWaves.wav"; + + fillBuffersWithSineWaves(); + monoLooper_.setPlaybackLength(kNumTestSamples); + + sut.startSavingToFile("dummyFilename", + monoLooper_, + &doneCallback, + &doneCallbackResult_); + + // write until completed + int timeout = 1000; + while (timeout-- > 0 && doneCallbackResult_ != AudioSaveAndRecallResult::ok) + { + sut.readOrWriteNextChunk(); + } + EXPECT_EQ(sut.getCurrentProgress(), 1.0f); + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::ok); + EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); + +#ifdef WRITE_REFERENCES + writeReferenceFile(kReferenceFilename, sut.getFileIoProviderForTesting().dataWritten_); +#endif + + const auto expectedFileContents = readReferenceFile(kReferenceFilename); + EXPECT_EQ(sut.getFileIoProviderForTesting().dataWritten_, expectedFileContents); +} + +TEST_F(AudioSaveAndRecallFixture, h_saveToFile_producesValidStereoWavFile) +{ + AudioSaveAndRecall sut; + + constexpr auto kNumTestSamples = 4800; + constexpr auto kReferenceFilename = "data/stereoSineWaves.wav"; + + fillBuffersWithSineWaves(); + stereoLooper_.setPlaybackLength(kNumTestSamples); + + sut.startSavingToFile("dummyFilename", + stereoLooper_, + &doneCallback, + &doneCallbackResult_); + + // write until completed + int timeout = 1000; + while (timeout-- > 0 && doneCallbackResult_ != AudioSaveAndRecallResult::ok) + { + sut.readOrWriteNextChunk(); + } + EXPECT_EQ(sut.getCurrentProgress(), 1.0f); + EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::ok); + EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); + +#ifdef WRITE_REFERENCES + writeReferenceFile(kReferenceFilename, sut.getFileIoProviderForTesting().dataWritten_); +#endif + + const auto expectedFileContents = readReferenceFile(kReferenceFilename); + EXPECT_EQ(sut.getFileIoProviderForTesting().dataWritten_, expectedFileContents); +} + +class AudioSaveAndRecallParameterizedFileFormatFixture : + public AudioSaveAndRecallFixture, + public ::testing::WithParamInterface> +{ +}; + +TEST_P(AudioSaveAndRecallParameterizedFileFormatFixture, canReadMonoWav) +{ + const auto [referenceFileNameMono, referenceFileNameStereo, epsilon] = GetParam(); + + AudioSaveAndRecall sut; + + constexpr auto kExpectedNumSamples = 48; + + sut.getFileIoProviderForTesting().dataToRead_ = readReferenceFile(referenceFileNameMono); + + sut.startReadingFromFile("dummyFilename", + monoLooper_, + &doneCallback, + &doneCallbackResult_); + + // read until completed + int timeout = 1000; + while (timeout-- > 0 && doneCallbackResult_ != AudioSaveAndRecallResult::ok) + { + sut.readOrWriteNextChunk(); + } + + const auto kExpectedSawWav = makeSawWave(1000.0, kExpectedNumSamples); + + const auto actualSamples = std::vector(monoBuffer_.data[0], + monoBuffer_.data[0] + monoLooper_.getPlaybackLength()); + + EXPECT_THAT(actualSamples, + ::testing::Pointwise(::testing::FloatNear(epsilon), + kExpectedSawWav)); +} + +TEST_P(AudioSaveAndRecallParameterizedFileFormatFixture, canReadStereoWav) +{ + const auto [referenceFileNameMono, referenceFileNameStereo, epsilon] = GetParam(); + + AudioSaveAndRecall sut; + + constexpr auto kExpectedNumSamples = 48; + + sut.getFileIoProviderForTesting().dataToRead_ = readReferenceFile(referenceFileNameStereo); + + sut.startReadingFromFile("dummyFilename", + stereoLooper_, + &doneCallback, + &doneCallbackResult_); + + // read until completed + int timeout = 1000; + while (timeout-- > 0 && doneCallbackResult_ != AudioSaveAndRecallResult::ok) + { + sut.readOrWriteNextChunk(); + } + + const auto kExpectedSawWavLeft = makeSawWave(1000.0, kExpectedNumSamples); + const auto kExpectedSawWavRight = makeSawWave(2000.0, kExpectedNumSamples); + + const auto actualSamplesLeft = std::vector(stereoBuffer_.data[0], + stereoBuffer_.data[0] + stereoLooper_.getPlaybackLength()); + const auto actualSamplesRight = std::vector(stereoBuffer_.data[1], + stereoBuffer_.data[1] + stereoLooper_.getPlaybackLength()); + + EXPECT_THAT(actualSamplesLeft, + ::testing::Pointwise(::testing::FloatNear(epsilon), + kExpectedSawWavLeft)); + EXPECT_THAT(actualSamplesRight, + ::testing::Pointwise(::testing::FloatNear(epsilon), + kExpectedSawWavRight)); +} + +INSTANTIATE_TEST_SUITE_P( + AudioSaveAndRecall_loadFromFile_canReadWavs, + AudioSaveAndRecallParameterizedFileFormatFixture, + ::testing::Values( + std::make_tuple("data/monoSaw_int16.wav", "data/stereoSaw_int16.wav", 3e-4f), + std::make_tuple("data/monoSaw_int24.wav", "data/stereoSaw_int24.wav", 1e-6f), + std::make_tuple("data/monoSaw_int32.wav", "data/stereoSaw_int32.wav", 1e-6f), + std::make_tuple("data/monoSaw_float.wav", "data/stereoSaw_float.wav", 1e-6f), + // Ableton uses the JUNK segment to encode a messsage in the file. + std::make_tuple("data/monoSaw_float.wav", "data/stereoSaw_float_Ableton.wav", 1e-6f))); diff --git a/firmware/tests/data/monoSaw_float.wav b/firmware/tests/data/monoSaw_float.wav new file mode 100644 index 0000000000000000000000000000000000000000..eb2a36c07b3a3243d879e124093b70898586c24e GIT binary patch literal 272 zcmWm7I|{-;6oujAqYx__!7_C!o0KN8h+u3)7;Ozo5Wy~jWj2x41x%J8N!)}>uu0(> z{4+3|?_6fMyY*@{>Pd&?K1y$yGm$hB z_?%?Po9s?$-9>5b0{YlD#`wG=3%bQ`{J||&P}*@|%ruZ3>|zgxIK~Da@fF{3-jEHw kVU7)safm(aVh2z07>jIc_SNjI*^djHlZNhMb@K{We`tB9E>B=gGhVa(qU91xVr>>jz rZ+_^~wX6QDkJ`F?7t?{zqbpA_UkJLo?$+P?5l>gVV*U{Pb@eX*S%o)$ literal 0 HcmV?d00001 diff --git a/firmware/tests/data/monoSaw_int24.wav b/firmware/tests/data/monoSaw_int24.wav new file mode 100644 index 0000000000000000000000000000000000000000..d23edd2827f8a10c333acab1e6ce63f8e8803daa GIT binary patch literal 188 zcmV;t07L&$Nk&Gr00012K~_a(ZFC?I000010097iy8r-yG6Dbt02lydVRT`T00000 z003201+1zH{r?YHRTZbI8Uz3!QdK3bsw)2fFIQDHsj4~x064mbqezM(T~Q=Okxp1O*d>25=?*#wGoMUfOmx?;00T~R1k6iHDe zfg+fqNFMJ~es8nxICo=AyV|M^MiVQG6BHc4D``^3GChCd$K3xqBI!sq1GAn;E>z89 z%N8lbszqGFWn95_AW{od8`#ByyV%2hJj7!>#WNh>IbPr;Ug0$k@dhi7@DA^BY>OOY Y)e}DB3%=r;C-MkYpZM*He736JA4~R7u>b%7 literal 0 HcmV?d00001 diff --git a/firmware/tests/data/monoSineWaves.wav b/firmware/tests/data/monoSineWaves.wav new file mode 100644 index 0000000000000000000000000000000000000000..635cda99cc6a41c51085910deab78c469020b8f4 GIT binary patch literal 19244 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 Wi?LhUH`dge+^;$~oe>-IT>lSORHljm literal 0 HcmV?d00001 diff --git a/firmware/tests/data/stereoSaw_float.wav b/firmware/tests/data/stereoSaw_float.wav new file mode 100644 index 0000000000000000000000000000000000000000..7aaf68f7cfcb726cf385f70eafa70ef64a383f0a GIT binary patch literal 472 zcmXYrJx;?w5Jo@#BotB5SfX@8*?BWN`<@@B)1MP*K7N@jKbrJf(wR;bzB&9}=u)Yc)oXK3c}F}=#*a5- zuITgj?_CPHYv(KeZ&gzTH;-Q%v->v2V`L*R5k_z#j>@tm`aPqUm*t8&$=zcnm$vi@ zc<)*G4qn4WgszPPSl#&!{Z2~Z1w4ai@F6^b7jVI6)cxkLD{iC(JZ!v^dH5GOuDzRi zQFF)sHQD|Pc(98(E;HV+f8lv{52@{+d7j-V-2MsN{sZ{l-+MUk{5HM46Kso<{UhA| ZE1c{f;PMXY-aW$pBJ3a62bPiJ`X7Q}q=x_i literal 0 HcmV?d00001 diff --git a/firmware/tests/data/stereoSaw_float_Ableton.wav b/firmware/tests/data/stereoSaw_float_Ableton.wav new file mode 100644 index 0000000000000000000000000000000000000000..7aa9666c8a81899c5a6c9c260016a6b870842963 GIT binary patch literal 464 zcmXwzK}y3w6o#LQAYHl1&c&>Q+b&HaXu%W|gwbsyrP4s_qIIFGT%wnlxbOmLdWl@3 zr||pJ8hA|J_ul;TJwJ>_zb9s|w=eg(e;i+#+0*yeqF7Gn#p>FP=gW8ZG3(Ejb8fa+ zyGwS??9}dl%-q|#U6`|Ju`Vid{=YiJH+2ZdiW4|hC2%Sv-g~O+8MD;;4Skeq#!hOs zW0t`??ZWr)5^hx0)?olETis#aVgk?LF+7G3;SoH88$74)wuh{+HJiYztuon%e{zOK z+3XuVcjRAk%6|f{cxJCj7e`pRY<_ygr2nMI@ literal 0 HcmV?d00001 diff --git a/firmware/tests/data/stereoSaw_int16.wav b/firmware/tests/data/stereoSaw_int16.wav new file mode 100644 index 0000000000000000000000000000000000000000..25318d4f683ee62b3787d13d8fb86797376cbeef GIT binary patch literal 236 zcmV-KrPye4+RjsP3T2r+EU;@BaWvI%jYFE_(Z~)*|b*t*CdQ|lQfB^tig{lgx zidGf>j{qQ8m8vSInpQOgpa4KorK(Dj`ozyN?$#jA>~%2k#A m&j6rT)vBtg+E=v!;Qzl><*Ul8>Q>bN@crLa^{?ux`cd^`7h$CU literal 0 HcmV?d00001 diff --git a/firmware/tests/data/stereoSaw_int24.wav b/firmware/tests/data/stereoSaw_int24.wav new file mode 100644 index 0000000000000000000000000000000000000000..ed5f42289ff8e66152e0e6033ee85e9e4940b6a7 GIT binary patch literal 332 zcmW-bAxr{c0EOQjXrO3ea~3HUl7(UsH(`^agL?;F5jHWfISUjM#X_=BELaroabpyU z7K(*pqL?Tal7)iqes8|?w)ghFS3J$9aKpl@s z8xKEb&tZ3@bf8Cvnb7orY=zGeXdt9=Zh4VCGvevnQMI(UC?eP{33h{5_SEs69Y z-jI?$!^R}h1ygcHZ?Ql!{UWQr;guIzVAo`c*GcsozZr|Hut*$!YuoWAbo=k%0-t5u zsCw_a{R#K@BUTcn3pOW#-eMJbrq@^|(TludO}0(l{&zg#3taIJyv3Fz2azwkO>DIr O?8n}0v)PmCuk}BVBbNC9 literal 0 HcmV?d00001 diff --git a/firmware/tests/data/stereoSineWaves.wav b/firmware/tests/data/stereoSineWaves.wav new file mode 100644 index 0000000000000000000000000000000000000000..136c28a91f594d68de6bf372ea1eda9ce9156906 GIT binary patch literal 38444 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 + return (float) x * S242F_SCALE; +} inline float s162f(int16_t x) { return (float) x * S162F_SCALE; From 30359c1a0becd23b0bc2d9e183f0726b68685978 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:34:56 +0200 Subject: [PATCH 15/26] feat: LooperController can start save and recall --- firmware/src/LooperController.h | 79 +++++++++++++------- firmware/tests/mocks/LooperController_mock.h | 34 ++++++++- 2 files changed, 82 insertions(+), 31 deletions(-) diff --git a/firmware/src/LooperController.h b/firmware/src/LooperController.h index 72af340..ac9d31b 100644 --- a/firmware/src/LooperController.h +++ b/firmware/src/LooperController.h @@ -89,24 +89,23 @@ class LooperController AudioSaveAndRecallDoneCallbackPtr doneCallback, void* doneCallbackContext) { - (void) (bank); - (void) (looperIdx); - (void) (slot); - (void) (doneCallback); - (void) (doneCallbackContext); - /* - audioSaveAndRecall_.startSavingToFile(bank, - slot, - loopers_[looperIdx].looper, - doneCallback, - doneCallbackContext); - */ + if (loopers_[looperIdx].layout == ChannelLayout::mono) + audioSaveAndRecall_.startSavingToFile(bank, + slot, + loopers_[looperIdx].looper.template as(), + doneCallback, + doneCallbackContext); + else + audioSaveAndRecall_.startSavingToFile(bank, + slot, + loopers_[looperIdx].looper.template as(), + doneCallback, + doneCallbackContext); } float getCurrentSaveOrLoadProgress() { - // return audioSaveAndRecall_.getCurrentProgress(); - return 0.0f; + return audioSaveAndRecall_.getCurrentProgress(); } void loadFrom(StorageBank bank, @@ -115,19 +114,45 @@ class LooperController AudioSaveAndRecallDoneCallbackPtr doneCallback, void* doneCallbackContext) { - (void) (bank); - (void) (looperIdx); - (void) (slot); - (void) (doneCallback); - (void) (doneCallbackContext); - /* - // TODO: Reconfigure to correct channel layout - audioSaveAndRecall_.startReadingFromFiles(bank, - slot, - loopers_[looperIdx].looper, - doneCallback, - doneCallbackContext); - */ + const auto numChannels = audioSaveAndRecall_.getNumChannelsInFile(bank, slot); + if (numChannels == 0) + { + if (doneCallback) + { + doneCallback(doneCallbackContext, AudioSaveAndRecallResult::error); + } + return; + } + + if (numChannels == 1 && getChannelLayout(looperIdx) != ChannelLayout::mono) + { + setChannelLayout(looperIdx, ChannelLayout::mono); + } + else if (numChannels == 2 && getChannelLayout(looperIdx) != ChannelLayout::stereo) + { + setChannelLayout(looperIdx, ChannelLayout::stereo); + } + + if (loopers_[looperIdx].layout == ChannelLayout::mono) + audioSaveAndRecall_.startReadingFromFile(bank, + slot, + loopers_[looperIdx].looper.template as(), + doneCallback, + doneCallbackContext); + else + audioSaveAndRecall_.startReadingFromFile(bank, + slot, + loopers_[looperIdx].looper.template as(), + doneCallback, + doneCallbackContext); + } + + void abortLoadOrSaveOperation() + { + if (audioSaveAndRecall_.isSavingOrRecalling()) + { + audioSaveAndRecall_.abort(); + } } void setChannelLayout(size_t looperIdx, ChannelLayout channelLayout) diff --git a/firmware/tests/mocks/LooperController_mock.h b/firmware/tests/mocks/LooperController_mock.h index b3362f9..ece8ae9 100644 --- a/firmware/tests/mocks/LooperController_mock.h +++ b/firmware/tests/mocks/LooperController_mock.h @@ -23,6 +23,7 @@ #include #include "LooperController.h" +#include "AudioSaveAndRecall.h" template class LooperControllerMock @@ -64,20 +65,39 @@ class LooperControllerMock return LooperState::stopped; } - void saveTo(size_t looperIdx, size_t slot) + void saveTo(StorageBank bank, + size_t looperIdx, + size_t slot, + AudioSaveAndRecallDoneCallbackPtr doneCallback, + void* doneCallbackContext) { + (void) (doneCallback); + (void) (doneCallbackContext); + std::ostringstream stringStream; - stringStream << "saveTo(" << looperIdx << ", " << slot << ")"; + stringStream << "saveTo(" << int(bank) << ", " << looperIdx << ", " << slot << ")"; events += stringStream.str() + "\n"; } - void loadFrom(size_t looperIdx, size_t slot) + void loadFrom(StorageBank bank, + size_t looperIdx, + size_t slot, + AudioSaveAndRecallDoneCallbackPtr doneCallback, + void* doneCallbackContext) { + (void) (doneCallback); + (void) (doneCallbackContext); + std::ostringstream stringStream; - stringStream << "loadFrom(" << looperIdx << ", " << slot << ")"; + stringStream << "loadFrom(" << int(bank) << ", " << looperIdx << ", " << slot << ")"; events += stringStream.str() + "\n"; } + void abortLoadOrSaveOperation() + { + events += "abortLoadOrSaveOperation()\n"; + } + void setChannelLayout(size_t looperIdx, ChannelLayout channelLayout) { std::ostringstream stringStream; @@ -87,6 +107,12 @@ class LooperControllerMock events += stringStream.str() + "\n"; } + float getCurrentSaveOrLoadProgress() + { + events += "getCurrentSaveOrLoadProgress()\n"; + return -1.0f; + } + std::function getChannelLayoutMock_; ChannelLayout getChannelLayout(size_t looperIdx) const { From 20c62a9ea150bf749dad6ed933a547d156d11f94 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:35:18 +0200 Subject: [PATCH 16/26] feat: UiHardware provides array of all channel LEDs --- firmware/src/hardware/UiHardware.h | 22 ++++++++++++++++++++++ firmware/tests/mocks/UiHardware_mock.h | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/firmware/src/hardware/UiHardware.h b/firmware/src/hardware/UiHardware.h index 41c5670..be864b9 100644 --- a/firmware/src/hardware/UiHardware.h +++ b/firmware/src/hardware/UiHardware.h @@ -407,6 +407,28 @@ class UiHardware buttons_.triggerReadout(); } + static const std::array allChannelLeds() + { + return { + Led::chA_m2, + Led::chA_m1, + Led::chA_p1, + Led::chA_p2, + Led::chB_m2, + Led::chB_m1, + Led::chB_p1, + Led::chB_p2, + Led::chC_m2, + Led::chC_m1, + Led::chC_p1, + Led::chC_p2, + Led::chD_m2, + Led::chD_m1, + Led::chD_p1, + Led::chD_p2, + }; + } + private: UiHardware(const UiHardware&) = delete; UiHardware& operator=(const UiHardware&) = delete; diff --git a/firmware/tests/mocks/UiHardware_mock.h b/firmware/tests/mocks/UiHardware_mock.h index de101be..c7d2418 100644 --- a/firmware/tests/mocks/UiHardware_mock.h +++ b/firmware/tests/mocks/UiHardware_mock.h @@ -69,6 +69,28 @@ class UiHardwareMock } } + static const std::array allChannelLeds() + { + return { + Led::chA_m2, + Led::chA_m1, + Led::chA_p1, + Led::chA_p2, + Led::chB_m2, + Led::chB_m1, + Led::chB_p1, + Led::chB_p2, + Led::chC_m2, + Led::chC_m1, + Led::chC_p1, + Led::chC_p2, + Led::chD_m2, + Led::chD_m1, + Led::chD_p1, + Led::chD_p2, + }; + } + KnobsAndCvMock& getKnobsAndCv() { return knobsAndCv_; }; KnobsAndCvMock knobsAndCv_; From c5b91043e5e2b779c7e5f2fd4e8c2c6f71692b64 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Tue, 7 Oct 2025 23:25:14 +0200 Subject: [PATCH 17/26] feat: implement UI pages for loading and saving --- firmware/src/ui/TapeLooperUi.h | 3 +- firmware/src/ui/UiLoadOrSavePage.h | 407 +++++++++++++++++++++++++++++ firmware/src/ui/UiLoadPage.h | 102 -------- firmware/src/ui/UiSavePage.h | 102 -------- 4 files changed, 408 insertions(+), 206 deletions(-) create mode 100644 firmware/src/ui/UiLoadOrSavePage.h delete mode 100644 firmware/src/ui/UiLoadPage.h delete mode 100644 firmware/src/ui/UiSavePage.h diff --git a/firmware/src/ui/TapeLooperUi.h b/firmware/src/ui/TapeLooperUi.h index 7686ffc..8499a44 100644 --- a/firmware/src/ui/TapeLooperUi.h +++ b/firmware/src/ui/TapeLooperUi.h @@ -26,8 +26,7 @@ #include "UiBasePage.h" #include "UiCalibrationPage.h" #include "UiSettingsPage.h" -#include "UiSavePage.h" -#include "UiLoadPage.h" +#include "UiLoadOrSavePage.h" #include "UiRecordingPage.h" template . + */ + +#pragma once + +#include +#include + +#include +#include + +#include "../constants.h" +#include "../hardware/UiHardwareTypes.h" +#include "LooperParameterProvider.h" + +template +class UiLoadOrSavePage : public daisy::UiPage +{ +public: + UiLoadOrSavePage(LooperControllerType& looperController) : + looperController_(looperController) + { + } + + virtual ~UiLoadOrSavePage() {} + + bool IsOpaque(const daisy::UiCanvasDescriptor&) override { return false; } + + void OnShow() override + { + stage_ = Stage::selectChannel; + selectedChannel_ = -1; + } + + void Draw(const daisy::UiCanvasDescriptor& canvas) override + { + UiHardwareType& hardware = *((UiHardwareType*) canvas.handle_); + + // light up the page led + hardware.setLed(PageLed, LedColour::pulsingRed); + + switch (stage_) + { + case Stage::selectChannel: + drawSelectChannel(hardware); + break; + case Stage::selectBankAndSlot: + drawSelectBankAndSlot(hardware); + break; + case Stage::saveOrLoadInProgress: + drawProgress(hardware); + break; + case Stage::displayError: + drawResult(hardware, AudioSaveAndRecallResult::error); + break; + case Stage::displaySuccess: + drawResult(hardware, AudioSaveAndRecallResult::ok); + break; + default: + break; + } + } + + bool OnButton(uint16_t buttonID, uint8_t numberOfPresses, bool isRetriggering) override + { + (void) (isRetriggering); // ignore this argument + + // swallow but ignore button-up messages + if (numberOfPresses < 1) + return true; + + const Button button = Button(buttonID); + switch (button) + { + case Button::record: + [[fallthrough]]; + case Button::settings: + looperController_.abortLoadOrSaveOperation(); + Close(); // close this page + return false; // pass event to the page below to open the respective page + case Button::save: + return handleSaveButton(); + case Button::load: + return handleLoadButton(); + default: + break; + } + + switch (stage_) + { + case Stage::selectChannel: + return onButtonInChannelSelection(button); + case Stage::selectBankAndSlot: + return onButtonInBankAndSlotSelection(button); + default: + // any button press while showing + if (stage_ == Stage::displaySuccess || stage_ == Stage::displayError) + { + Close(); + } + break; + } + + return true; + } + + bool OnPotMoved(uint16_t, float) override + { + return false; // passthrough pot events to the base page + } + +protected: + UiLoadOrSavePage(const UiLoadOrSavePage&) = delete; + UiLoadOrSavePage& operator=(const UiLoadOrSavePage&) = delete; + + static LedColour getBankColourPulsing(StorageBank bank) + { + if (bank == StorageBank::green) + return LedColour::pulsingGreen; + if (bank == StorageBank::yellow) + return LedColour::pulsingYellow; + if (bank == StorageBank::red) + return LedColour::pulsingRed; + + return LedColour::off; + }; + + static LedColour getBankColour(StorageBank bank) + { + if (bank == StorageBank::green) + return LedColour::green; + if (bank == StorageBank::yellow) + return LedColour::yellow; + if (bank == StorageBank::red) + return LedColour::red; + + return LedColour::off; + }; + + void drawSelectChannel(UiHardwareType& hardware) + { + hardware.setLed(Led::chA_p2, LedColour::pulsingYellow); + hardware.setLed(Led::chA_p1, LedColour::pulsingYellow); + hardware.setLed(Led::chA_m1, LedColour::pulsingYellow); + hardware.setLed(Led::chA_m2, LedColour::pulsingYellow); + + hardware.setLed(Led::chB_p2, LedColour::pulsingYellow); + hardware.setLed(Led::chB_p1, LedColour::pulsingYellow); + hardware.setLed(Led::chB_m1, LedColour::pulsingYellow); + hardware.setLed(Led::chB_m2, LedColour::pulsingYellow); + + hardware.setLed(Led::chC_p2, LedColour::pulsingYellow); + hardware.setLed(Led::chC_p1, LedColour::pulsingYellow); + hardware.setLed(Led::chC_m1, LedColour::pulsingYellow); + hardware.setLed(Led::chC_m2, LedColour::pulsingYellow); + + hardware.setLed(Led::chD_p2, LedColour::pulsingYellow); + hardware.setLed(Led::chD_p1, LedColour::pulsingYellow); + hardware.setLed(Led::chD_m1, LedColour::pulsingYellow); + hardware.setLed(Led::chD_m2, LedColour::pulsingYellow); + } + + void drawSelectBankAndSlot(UiHardwareType& hardware) + { + const auto bankColour = this->getBankColour(this->selectedBank_); + const auto bankColourPulsing = this->getBankColourPulsing(this->selectedBank_); + + hardware.setLed(Led::settings, bankColourPulsing); + + const auto allLeds = UiHardwareType::allChannelLeds(); + for (size_t i = 0; i < allLeds.size(); i++) + { + hardware.setLed(allLeds[i], this->selectedSlot_ == int(i) ? bankColorPulsing : LedColour::off); + } + } + + void drawProgress(UiHardwareType& hardware) + { + const auto allLeds = UiHardwareType::allChannelLeds(); + for (size_t i = 0; i < allLeds.size(); i++) + { + const auto shouldBeOn = looperController_.getCurrentSaveOrLoadProgress() + > float(i) / float(allLeds.size() - 1); + hardware.setLed(allLeds[i], shouldBeOn ? LedColour::pulsingYellow : LedColour::off); + } + } + + void drawResult(UiHardwareType& hardware, AudioSaveAndRecallResult result) + { + const auto color = result == AudioSaveAndRecallResult::ok ? LedColour::pulsingGreen : LedColour::pulsingRed; + for (auto led : UiHardwareType::allChannelLeds()) + { + hardware.setLed(led, color); + } + + if (daisy::System::GetNow() >= closeAfterTime_) + { + Close(); + } + } + + bool onButtonInChannelSelection(Button button) + { + switch (button) + { + case Button::chA_play: + selectedChannel_ = 0; + stage_ = Stage::selectBankAndSlot; + break; + case Button::chB_play: + selectedChannel_ = 1; + stage_ = Stage::selectBankAndSlot; + break; + case Button::chC_play: + selectedChannel_ = 2; + stage_ = Stage::selectBankAndSlot; + break; + case Button::chD_play: + selectedChannel_ = 3; + stage_ = Stage::selectBankAndSlot; + break; + default: + break; + } + + return true; + } + + bool onButtonInBankAndSlotSelection(Button button) + { + const auto handleChannelUp = [&](int channel) + { + if (selectedSlot_ / 4 != channel) + { + selectedSlot_ = selectedSlot_ % 4 + channel * 4; + return; + } + selectedSlot_ = std::min(selectedSlot_ + 1, channel * 4 + 3); + }; + const auto handleChannelDown = [&](int channel) + { + if (selectedSlot_ / 4 != channel) + { + selectedSlot_ = selectedSlot_ % 4 + channel * 4; + return; + } + selectedSlot_ = std::max(selectedSlot_ - 1, channel * 4); + }; + + switch (button) + { + case Button::chA_down: + handleChannelDown(0); + break; + case Button::chA_up: + handleChannelUp(0); + break; + case Button::chB_down: + handleChannelDown(1); + break; + case Button::chB_up: + handleChannelUp(1); + break; + case Button::chC_down: + handleChannelDown(2); + break; + case Button::chC_up: + handleChannelUp(2); + break; + case Button::chD_down: + handleChannelDown(3); + break; + case Button::chD_up: + handleChannelUp(3); + break; + case Button::settings: + selectedBank_ = StorageBank((int(selectedBank_) + 1) % int(StorageBank::count)); + break; + default: + break; + } + + return true; + } + + virtual bool handleSaveButton() = 0; + virtual bool handleLoadButton() = 0; + + static void doneCallback(void* context, AudioSaveAndRecallResult result) + { + const auto page = static_cast(context); + page->stage_ = result == AudioSaveAndRecallResult::ok ? Stage::displaySuccess : Stage::displayError; + page->closeAfterTime_ = daisy::System::GetNow() + 2000; + } + + enum class Stage + { + selectChannel, + selectBankAndSlot, + saveOrLoadInProgress, + displaySuccess, + displayError, + }; + + Stage stage_ = Stage::selectChannel; + StorageBank selectedBank_ = StorageBank::green; + int selectedChannel_ = -1; + int selectedSlot_ = 0; + LooperControllerType& looperController_; + uint32_t closeAfterTime_ = -1; +}; + +template +class UiSavePage : public UiLoadOrSavePage +{ + using ParentType = UiLoadOrSavePage; + +public: + UiSavePage(LooperControllerType& looperController) : + ParentType(looperController) + { + } + + ~UiSavePage() = default; + + bool handleSaveButton() override + { + if (this->stage_ == ParentType::Stage::selectBankAndSlot) + { + this->stage_ = ParentType::Stage::saveOrLoadInProgress; + this->looperController_.saveTo(this->selectedBank_, + this->selectedChannel_, + this->selectedSlot_, + &this->doneCallback, + this); + } + else if (this->stage_ == ParentType::Stage::selectChannel + || this->stage_ == ParentType::Stage::displaySuccess + || this->stage_ == ParentType::Stage::displayError) + { + Close(); + } + return true; + } + + bool handleLoadButton() override + { + this->looperController_.abortLoadOrSaveOperation(); + this->Close(); + return false; // pass event to the page below to open the respective page + } +}; + +template +class UiLoadPage : public UiLoadOrSavePage +{ + using ParentType = UiLoadOrSavePage; + +public: + UiLoadPage(LooperControllerType& looperController) : + ParentType(looperController) + { + } + + ~UiLoadPage() = default; + + bool handleSaveButton() override + { + this->looperController_.abortLoadOrSaveOperation(); + this->Close(); + return false; // pass event to the page below to open the respective page + } + + bool handleLoadButton() override + { + if (this->stage_ == ParentType::Stage::selectBankAndSlot) + { + this->stage_ = ParentType::Stage::saveOrLoadInProgress; + this->looperController_.loadFrom(this->selectedBank_, + this->selectedChannel_, + this->selectedSlot_, + &this->doneCallback, + this); + } + else if (this->stage_ == ParentType::Stage::selectChannel + || this->stage_ == ParentType::Stage::displaySuccess + || this->stage_ == ParentType::Stage::displayError) + { + Close(); + } + return true; + } +}; \ No newline at end of file diff --git a/firmware/src/ui/UiLoadPage.h b/firmware/src/ui/UiLoadPage.h deleted file mode 100644 index 58f3157..0000000 --- a/firmware/src/ui/UiLoadPage.h +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include - -#include -#include - -#include "../constants.h" -#include "../hardware/UiHardwareTypes.h" -#include "LooperParameterProvider.h" - -template -class UiLoadPage : public daisy::UiPage -{ -public: - UiLoadPage(LooperControllerType& looperController) : - looperController_(looperController) - { - } - - virtual ~UiLoadPage() {} - - bool IsOpaque(const daisy::UiCanvasDescriptor&) override { return false; } - - void Draw(const daisy::UiCanvasDescriptor& canvas) override - { - UiHardwareType& hardware = *((UiHardwareType*) canvas.handle_); - - // light up the load page led - hardware.setLed(Led::load, LedColour::pulsingRed); - - // update channel octave LEDs to show the currently selected slot - // TODO - const auto updateChannelOctaveLeds = [&](size_t looperChannel, std::array ledIds) - { - (void) (looperChannel); - hardware.setLed(ledIds[0], LedColour::off); - hardware.setLed(ledIds[1], LedColour::off); - hardware.setLed(ledIds[2], LedColour::off); - hardware.setLed(ledIds[3], LedColour::off); - }; - updateChannelOctaveLeds(0, { Led::chA_m2, Led::chA_m1, Led::chA_p1, Led::chA_p2 }); - updateChannelOctaveLeds(1, { Led::chB_m2, Led::chB_m1, Led::chB_p1, Led::chB_p2 }); - updateChannelOctaveLeds(2, { Led::chC_m2, Led::chC_m1, Led::chC_p1, Led::chC_p2 }); - updateChannelOctaveLeds(3, { Led::chD_m2, Led::chD_m1, Led::chD_p1, Led::chD_p2 }); - } - - bool OnButton(uint16_t buttonID, uint8_t numberOfPresses, bool isRetriggering) override - { - (void) (isRetriggering); // ignore this argument - - // swallow but ignore button-up messages - if (numberOfPresses < 1) - return true; - - const Button button = Button(buttonID); - switch (button) - { - // channel play buttons - case Button::load: - Close(); // close this page - break; - case Button::record: - case Button::save: - case Button::settings: - Close(); // close this page - return false; // pass event to the page below to open the respective page - default: - break; - } - return true; - } - - bool OnPotMoved(uint16_t, float) override - { - return false; // passthrough pot events to the base page - } - -private: - UiLoadPage(const UiLoadPage&) = delete; - UiLoadPage& operator=(const UiLoadPage&) = delete; - - LooperControllerType& looperController_; -}; \ No newline at end of file diff --git a/firmware/src/ui/UiSavePage.h b/firmware/src/ui/UiSavePage.h deleted file mode 100644 index 338d50f..0000000 --- a/firmware/src/ui/UiSavePage.h +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include - -#include -#include - -#include "../constants.h" -#include "../hardware/UiHardwareTypes.h" -#include "LooperParameterProvider.h" - -template -class UiSavePage : public daisy::UiPage -{ -public: - UiSavePage(LooperControllerType& looperController) : - looperController_(looperController) - { - } - - virtual ~UiSavePage() {} - - bool IsOpaque(const daisy::UiCanvasDescriptor&) override { return false; } - - void Draw(const daisy::UiCanvasDescriptor& canvas) override - { - UiHardwareType& hardware = *((UiHardwareType*) canvas.handle_); - - // light up the save page led - hardware.setLed(Led::save, LedColour::pulsingRed); - - // update channel octave LEDs to show the currently selected slot - // TODO - const auto updateChannelOctaveLeds = [&](size_t looperChannel, std::array ledIds) - { - (void) (looperChannel); - hardware.setLed(ledIds[0], LedColour::off); - hardware.setLed(ledIds[1], LedColour::off); - hardware.setLed(ledIds[2], LedColour::off); - hardware.setLed(ledIds[3], LedColour::off); - }; - updateChannelOctaveLeds(0, { Led::chA_m2, Led::chA_m1, Led::chA_p1, Led::chA_p2 }); - updateChannelOctaveLeds(1, { Led::chB_m2, Led::chB_m1, Led::chB_p1, Led::chB_p2 }); - updateChannelOctaveLeds(2, { Led::chC_m2, Led::chC_m1, Led::chC_p1, Led::chC_p2 }); - updateChannelOctaveLeds(3, { Led::chD_m2, Led::chD_m1, Led::chD_p1, Led::chD_p2 }); - } - - bool OnButton(uint16_t buttonID, uint8_t numberOfPresses, bool isRetriggering) override - { - (void) (isRetriggering); // ignore this argument - - // swallow but ignore button-up messages - if (numberOfPresses < 1) - return true; - - const Button button = Button(buttonID); - switch (button) - { - // channel play buttons - case Button::save: - Close(); // close this page - break; - case Button::record: - case Button::load: - case Button::settings: - Close(); // close this page - return false; // pass event to the page below to open the respective page - default: - break; - } - return true; - } - - bool OnPotMoved(uint16_t, float) override - { - return false; // passthrough pot events to the base page - } - -private: - UiSavePage(const UiSavePage&) = delete; - UiSavePage& operator=(const UiSavePage&) = delete; - - LooperControllerType& looperController_; -}; \ No newline at end of file From e739627881487628c4f5e1c6aa3105ace3a732ae Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:04:19 +0200 Subject: [PATCH 18/26] feat: main loop processes save or load operation --- firmware/src/LooperController.h | 20 +++++++++++++++----- firmware/src/main.cpp | 4 ++++ firmware/tests/mocks/LooperController_mock.h | 5 +++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/firmware/src/LooperController.h b/firmware/src/LooperController.h index ac9d31b..7b6031d 100644 --- a/firmware/src/LooperController.h +++ b/firmware/src/LooperController.h @@ -103,11 +103,6 @@ class LooperController doneCallbackContext); } - float getCurrentSaveOrLoadProgress() - { - return audioSaveAndRecall_.getCurrentProgress(); - } - void loadFrom(StorageBank bank, size_t looperIdx, size_t slot, @@ -147,6 +142,16 @@ class LooperController doneCallbackContext); } + float getCurrentSaveOrLoadProgress() + { + return audioSaveAndRecall_.getCurrentProgress(); + } + + bool isSavingOrRecalling() + { + return audioSaveAndRecall_.isSavingOrRecalling(); + } + void abortLoadOrSaveOperation() { if (audioSaveAndRecall_.isSavingOrRecalling()) @@ -155,6 +160,11 @@ class LooperController } } + void processSaveOrLoadOperation() + { + audioSaveAndRecall_.readOrWriteNextChunk(); + } + void setChannelLayout(size_t looperIdx, ChannelLayout channelLayout) { loopers_[looperIdx].initializeToLayout(channelLayout, looperStorage_[looperIdx]); diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 13021d9..6f3e33c 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -216,6 +216,10 @@ int main(void) constexpr auto kUpdateIntervalMs = 20; while (daisy::System::GetNow() < lastUpdate + kUpdateIntervalMs) { + if (looperController->isSavingOrRecalling()) + { + looperController->processSaveOrLoadOperation(); + } } lastUpdate = daisy::System::GetNow(); } diff --git a/firmware/tests/mocks/LooperController_mock.h b/firmware/tests/mocks/LooperController_mock.h index ece8ae9..ebe8da1 100644 --- a/firmware/tests/mocks/LooperController_mock.h +++ b/firmware/tests/mocks/LooperController_mock.h @@ -113,6 +113,11 @@ class LooperControllerMock return -1.0f; } + bool isSavingOrRecalling() + { + return false; + } + std::function getChannelLayoutMock_; ChannelLayout getChannelLayout(size_t looperIdx) const { From 8c58c175f8b945d88ef788e0b4f0fd7a80e287d0 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:54:06 +0200 Subject: [PATCH 19/26] chore: remove plugin The plugin isn't actively used anymore for development and will hopefully be replaced with a VCV rack version eventually. For now, let's remove the plugin to reduce the maintenance. --- .github/workflows/plugin.yaml | 180 -------------- .vscode/launch.json | 47 ---- .vscode/tasks.json | 117 --------- README.md | 3 +- plugin/CMakeLists.txt | 189 --------------- plugin/lib/JUCE | 1 - plugin/src/DspDefinitions.h | 37 --- plugin/src/PluginEditor.cpp | 67 ------ plugin/src/PluginEditor.h | 42 ---- plugin/src/PluginProcessor.cpp | 249 -------------------- plugin/src/PluginProcessor.h | 72 ------ plugin/src/TapeLooperComponent.h | 196 ---------------- plugin/src/TapeLooperProcessor.h | 391 ------------------------------- plugin/tests/Example_gtest.cpp | 41 ---- 14 files changed, 1 insertion(+), 1631 deletions(-) delete mode 100644 .github/workflows/plugin.yaml delete mode 100644 plugin/CMakeLists.txt delete mode 160000 plugin/lib/JUCE delete mode 100644 plugin/src/DspDefinitions.h delete mode 100644 plugin/src/PluginEditor.cpp delete mode 100644 plugin/src/PluginEditor.h delete mode 100644 plugin/src/PluginProcessor.cpp delete mode 100644 plugin/src/PluginProcessor.h delete mode 100644 plugin/src/TapeLooperComponent.h delete mode 100644 plugin/src/TapeLooperProcessor.h delete mode 100644 plugin/tests/Example_gtest.cpp diff --git a/.github/workflows/plugin.yaml b/.github/workflows/plugin.yaml deleted file mode 100644 index afcdec5..0000000 --- a/.github/workflows/plugin.yaml +++ /dev/null @@ -1,180 +0,0 @@ -name: Plugin Checks And Builds -on: - # run for all pull requests - # Run for all commits (e.g. on feature branches) - push: - paths: - - 'dsp/**' - - 'plugin/**' - - 'lib/**' - - '.github/workflows/**' - pull_request: - paths: - - 'dsp/**' - - 'plugin/**' - - 'lib/**' - - '.github/workflows/**' -jobs: - ############################################################################### - # builds the plugin on windows - testBuildWindows: - runs-on: windows-2025 - - steps: - - name: Setup cmake - uses: jwlawson/actions-setup-cmake@v1.8 - with: - cmake-version: '3.30.x' - - - name: Checkout - uses: actions/checkout@v2 - with: - submodules: recursive - - - name: Configure - run: | - cmake.exe -G "Visual Studio 17 2022" -A x64 -B plugin/build -S plugin - - - name: Build - run: | - cmake --build plugin/build --config Release - - - name: Run Tests - run: | - plugin/build/Release/TapeLooperPlugin_Gtest.exe --gtest_output=xml:TestResults\gtestResults_windows.xml - - - name: Upload Test Results - uses: actions/upload-artifact@v4 - with: - name: TestResults_Windows - path: TestResults/** - - - name: Upload executable - uses: actions/upload-artifact@v4 - with: - name: TapeLooperPlugin_Windows - path: | - plugin\build\TapeLooperPlugin_artefacts\Release\VST3\Tape Looper Plugin.vst3\Contents\x86_64-win\Tape Looper Plugin.vst3 - plugin\build\TapeLooperPlugin_artefacts\Release\Standalone\Tape Looper Plugin.exe - plugin/build/Release/TapeLooperPlugin_Gtest.exe - TestResults\gtestResults_windows.xml - ############################################################################### - # builds the plugin on macos - testBuildMacOS: - runs-on: macos-latest - - steps: - - name: Setup cmake - uses: jwlawson/actions-setup-cmake@v1.8 - with: - cmake-version: '3.30.x' - - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - - name: Select Xcode - run: | - sudo xcode-select --reset - - - name: Checkout - uses: actions/checkout@v2 - with: - submodules: recursive - - - name: Configure - run: | - cmake -G "Xcode" -B plugin/build -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" -S plugin - - - name: Build - run: | - cmake --build plugin/build --config Release - - - name: Run Tests - run: | - plugin/build/Release/TapeLooperPlugin_Gtest --gtest_output=xml:TestResults/gtestResults_macos.xml - - - name: Upload Test Results - uses: actions/upload-artifact@v4 - with: - name: TestResults_MacOS - path: TestResults/** - - - name: Upload executable - uses: actions/upload-artifact@v4 - with: - name: TapeLooperPlugin_MacOS - path: | - plugin/build/TapeLooperPlugin_artefacts/Release/VST3/Tape Looper Plugin.vst3/** - plugin/build/TapeLooperPlugin_artefacts/Release/Standalone/Tape Looper Plugin.app/** - plugin/build/Release/TapeLooperPlugin_Gtest - TestResults/gtestResults_macos.xml - ############################################################################### - # builds the plugin on ubuntu - testBuildUbuntu: - runs-on: ubuntu-latest - - steps: - - name: Setup cmake - uses: jwlawson/actions-setup-cmake@v1.8 - with: - cmake-version: '3.30.x' - - - name: Setup dependencies - run: | - sudo apt-get update - sudo apt-get -y install g++ libfreetype6-dev libx11-dev libxinerama-dev libxrandr-dev libxcursor-dev mesa-common-dev libasound2-dev freeglut3-dev libxcomposite-dev - - name: Checkout - uses: actions/checkout@v2 - with: - submodules: recursive - - - name: Configure - run: | - cmake -G "Unix Makefiles" -B plugin/build -S plugin - - - name: Build - run: | - cmake --build plugin/build --config Release - - - name: Run Tests - run: | - plugin/build/TapeLooperPlugin_Gtest --gtest_output=xml:TestResults/gtestResults_ubuntu.xml - - - name: Upload Test Results - uses: actions/upload-artifact@v4 - with: - name: TestResults_Linux - path: TestResults/** - - - name: Upload executable - uses: actions/upload-artifact@v4 - with: - name: TapeLooperPlugin_Linux - path: | - plugin/build/TapeLooperPlugin_artefacts/VST3/Tape Looper Plugin.vst3 - plugin/build/TapeLooperPlugin_artefacts/Standalone/Tape Looper Plugin - plugin/build/TapeLooperPlugin_Gtest - TestResults/gtestResults_ubuntu.xml - ############################################################################### - # uploads test results from the previous jobs - publishTestResults: - name: "Publish Unit Tests Results" - needs: [testBuildUbuntu, testBuildMacOS, testBuildWindows] - runs-on: ubuntu-latest - # the dependency jobs might be skipped, we don't need to run this job then - if: success() || failure() - - steps: - - name: Download Artifacts - uses: actions/download-artifact@v4 - with: - pattern: TestResults_* - path: TestResults - - - name: Publish Test Results - uses: EnricoMi/publish-unit-test-result-action@v1 - if: always() - with: - files: TestResults/**/*.xml - github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.vscode/launch.json b/.vscode/launch.json index a27a706..34d3657 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -106,53 +106,6 @@ "windows": { "MIMode": "gdb", } - }, - { - "name": "Plugin Tests", - "type": "cppdbg", - "request": "launch", - "program": "${workspaceFolder}/plugin/build/TapeLooperPlugin_Gtest", - "args": [], - "stopAtEntry": false, - "cwd": "${workspaceFolder}/plugin/build", - "environment": [], - "externalConsole": false, - "logging": { - "engineLogging": false - }, - "preLaunchTask": "build plugin", - - "osx": { - "type": "lldb", - "MIMode": "lldb", - }, - "windows": { - "MIMode": "gdb", - } - }, - { - "name": "Standalone Plugin", - "type": "cppdbg", - "request": "launch", - "args": [], - "stopAtEntry": false, - "cwd": "${workspaceFolder}/plugin/build", - "environment": [], - "externalConsole": false, - "logging": { - "engineLogging": false - }, - "preLaunchTask": "build plugin", - - "osx": { - "type": "lldb", - "MIMode": "lldb", - "program": "${workspaceFolder}/plugin/build/TapeLooperPlugin_artefacts/Standalone/Tape Looper Plugin.app/Contents/MacOS/Tape Looper Plugin", - }, - "windows": { - "MIMode": "gdb", - "program": "${workspaceFolder}/plugin/build/TapeLooperPlugin_artefacts/Standalone/Tape Looper Plugin.exe", - } } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index dea65e9..6f6c9d5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -141,123 +141,6 @@ }, "problemMatcher": [] }, - { - "label": "configure plugin", - "type": "shell", - "command": "cmake", - "args": [ - "-S", - "plugin", - "-B", - "plugin/build", - "-D", - "CMAKE_EXPORT_COMPILE_COMMANDS=1", - "-G", - "\"Unix Makefiles\"" - ], - "options": { - "cwd": "${workspaceRoot}" - }, - "windows": { - "command": "cmake.exe", - "args": [ - "-S", - "plugin", - "-B", - "plugin/build", - "-D", - "CMAKE_EXPORT_COMPILE_COMMANDS=1", - "-G", - "\\\"Visual Studio 17 2022\\\"" - ] - }, - "group": { - "kind": "build" - }, - "problemMatcher": [] - }, - { - "label": "configure plugin (debug)", - "type": "shell", - "command": "cmake", - "args": [ - "-D", - "CMAKE_BUILD_TYPE=Debug", - "-S", - "plugin", - "-B", - "plugin/build", - "-D", - "CMAKE_EXPORT_COMPILE_COMMANDS=1", - "-G", - "\"Unix Makefiles\"" - ], - "options": { - "cwd": "${workspaceRoot}" - }, - "windows": { - "command": "cmake.exe", - "args": [ - "-D", - "CMAKE_BUILD_TYPE=Debug", - "-S", - "plugin", - "-B", - "plugin/build", - "-D", - "CMAKE_EXPORT_COMPILE_COMMANDS=1", - "-G", - "\\\"Visual Studio 17 2022\\\"" - ] - }, - "group": { - "kind": "build" - }, - "problemMatcher": [] - }, - { - "label": "build plugin", - "type": "shell", - "command": "cmake", - "args": [ - "--build", - "plugin/build" - ], - "options": { - "cwd": "${workspaceRoot}" - }, - "group": { - "kind": "build" - }, - "problemMatcher": [ - "$gcc" - ] - }, - { - "label": "clean-plugin", - "osx": { - "command": "make", - "args": [ - "clean" - ] - }, - "linux": { - "command": "make", - "args": [ - "clean" - ] - }, - "windows": { - "command": "make.exe", - "args": [ - "clean" - ] - }, - "options": { - "cwd": "${workspaceRoot}/plugin/build" - }, - "problemMatcher": [] - }, { "label": "buildTests-TapeLooperDSP", "command": "make", diff --git a/README.md b/README.md index 7205bec..0c44599 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,8 @@ [![DSP Library Checks](https://github.com/TheSlowGrowth/TapeLooper/actions/workflows/dsp.yaml/badge.svg)](https://github.com/TheSlowGrowth/TapeLooper/actions/workflows/dsp.yaml) [![Firmware Checks And Builds](https://github.com/TheSlowGrowth/TapeLooper/actions/workflows/firmware.yaml/badge.svg)](https://github.com/TheSlowGrowth/TapeLooper/actions/workflows/firmware.yaml) -[![Plugin Checks And Builds](https://github.com/TheSlowGrowth/TapeLooper/actions/workflows/plugin.yaml/badge.svg)](https://github.com/TheSlowGrowth/TapeLooper/actions/workflows/plugin.yaml) [![Formatting](https://github.com/TheSlowGrowth/TapeLooper/actions/workflows/formatting.yaml/badge.svg)](https://github.com/TheSlowGrowth/TapeLooper/actions/workflows/formatting.yaml) -Hey explorer! This repo is just a construction site. We're trying to build a digital tape looper, that will hopefully become a desktop device and / or a Eurorack synth module. +Hey explorer! This repo is just a construction site. We're trying to build a digital tape looper, that will hopefully become a desktop device and / or a Eurorack synth module. diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt deleted file mode 100644 index 9704abe..0000000 --- a/plugin/CMakeLists.txt +++ /dev/null @@ -1,189 +0,0 @@ -# Example Audio Plugin CMakeLists.txt - -# To get started on a new plugin, copy this entire folder (containing this file and C++ sources) to -# a convenient location, and then start making modifications. - -# The first line of any CMake project should be a call to `cmake_minimum_required`, which checks -# that the installed CMake will be able to understand the following CMakeLists, and ensures that -# CMake's behaviour is compatible with the named version. This is a standard CMake command, so more -# information can be found in the CMake docs. - -cmake_minimum_required(VERSION 3.15) - -# The top-level CMakeLists.txt file for a project must contain a literal, direct call to the -# `project()` command. `project()` sets up some helpful variables that describe source/binary -# directories, and the current project version. This is a standard CMake command. - -project(TAPE_LOOPER_PLUGIN VERSION 0.0.1) - -add_subdirectory(lib/JUCE) -add_subdirectory(../dsp build) - -# If you are building a VST2 or AAX plugin, CMake needs to be told where to find these SDKs on your -# system. This setup should be done before calling `juce_add_plugin`. - -# juce_set_vst2_sdk_path(...) -# juce_set_aax_sdk_path(...) - -# `juce_add_plugin` adds a static library target with the name passed as the first argument -# (TapeLooperPlugin here). This target is a normal CMake target, but has a lot of extra properties set -# up by default. As well as this shared code static library, this function adds targets for each of -# the formats specified by the FORMATS arguments. This function accepts many optional arguments. -# Check the readme at `docs/CMake API.md` in the JUCE repo for the full list. - -juce_add_plugin(TapeLooperPlugin - # VERSION ... # Set this if the plugin version is different to the project version - # ICON_BIG ... # ICON_* arguments specify a path to an image file to use as an icon for the Standalone - # ICON_SMALL ... - # COMPANY_NAME ... # Specify the name of the plugin's author - # IS_SYNTH TRUE/FALSE # Is this a synth or an effect? - # NEEDS_MIDI_INPUT TRUE/FALSE # Does the plugin need midi input? - # NEEDS_MIDI_OUTPUT TRUE/FALSE # Does the plugin need midi output? - # IS_MIDI_EFFECT TRUE/FALSE # Is this plugin a MIDI effect? - # EDITOR_WANTS_KEYBOARD_FOCUS TRUE/FALSE # Does the editor need keyboard focus? - # COPY_PLUGIN_AFTER_BUILD TRUE/FALSE # Should the plugin be installed to a default location after building? - PLUGIN_MANUFACTURER_CODE TSG # A four-character manufacturer id with at least one upper-case character - PLUGIN_CODE Tplp # A unique four-character plugin id with exactly one upper-case character - # GarageBand 10.3 requires the first letter to be upper-case, and the remaining letters to be lower-case - FORMATS AU VST3 Standalone # The formats to build. Other valid formats are: AAX Unity VST AU AUv3 - PRODUCT_NAME "Tape Looper Plugin") # The name of the final executable, which can differ from the target name -set_property(TARGET TapeLooperPlugin PROPERTY CXX_STANDARD 17) - -# `juce_generate_juce_header` will create a JuceHeader.h for a given target, which will be generated -# into your build tree. This should be included with `#include `. The include path for -# this header will be automatically added to the target. The main function of the JuceHeader is to -# include all your JUCE module headers; if you're happy to include module headers directly, you -# probably don't need to call this. - -juce_generate_juce_header(TapeLooperPlugin) - -# `target_sources` adds source files to a target. We pass the target that needs the sources as the -# first argument, then a visibility parameter for the sources which should normally be PRIVATE. -# Finally, we supply a list of source files that will be built into the target. This is a standard -# CMake command. - -set( - plugin_sourcefiles - - src/PluginEditor.h - src/PluginEditor.cpp - src/PluginProcessor.h - src/PluginProcessor.cpp - - src/DspDefinitions.h - src/TapeLooperProcessor.h - - src/TapeLooperComponent.h -) - -target_sources(TapeLooperPlugin - PRIVATE - ${plugin_sourcefiles} -) - -foreach(source IN LISTS plugin_sourcefiles) - get_filename_component(source_path "${source}" PATH) - string(REPLACE "/" "\\" source_path_msvc "${source_path}") - source_group("${source_path_msvc}" FILES "${source}") -endforeach() - -# `target_compile_definitions` adds some preprocessor definitions to our target. In a Projucer -# project, these might be passed in the 'Preprocessor Definitions' field. JUCE modules also make use -# of compile definitions to switch certain features on/off, so if there's a particular feature you -# need that's not on by default, check the module header for the correct flag to set here. These -# definitions will be visible both to your code, and also the JUCE module code, so for new -# definitions, pick unique names that are unlikely to collide! This is a standard CMake command. - -target_compile_definitions(TapeLooperPlugin - PUBLIC - # JUCE_WEB_BROWSER and JUCE_USE_CURL would be on by default, but you might not need them. - JUCE_WEB_BROWSER=0 # If you remove this, add `NEEDS_WEB_BROWSER TRUE` to the `juce_add_plugin` call - JUCE_USE_CURL=0 # If you remove this, add `NEEDS_CURL TRUE` to the `juce_add_plugin` call - JUCE_MODAL_LOOPS_PERMITTED=1 - JUCE_VST3_CAN_REPLACE_VST2=0 - MANUAL_INLINE=inline # overrides inline statements used to optimize dsp code for the hardware - _USE_MATH_DEFINES -) - -# If your target needs extra binary assets, you can add them here. The first argument is the name of -# a new static library target that will include all the binary resources. There is an optional -# `NAMESPACE` argument that can specify the namespace of the generated binary data class. Finally, -# the SOURCES argument should be followed by a list of source files that should be built into the -# static library. These source files can be of any kind (wav data, images, fonts, icons etc.). -# Conversion to binary-data will happen when your target is built. - -# juce_add_binary_data(AudioPluginData SOURCES ...) - -# `target_link_libraries` links libraries and JUCE modules to other libraries or executables. Here, -# we're linking our executable target to the `juce::juce_audio_utils` module. Inter-module -# dependencies are resolved automatically, so `juce_core`, `juce_events` and so on will also be -# linked automatically. If we'd generated a binary data target above, we would need to link to it -# here too. This is a standard CMake command. - -target_link_libraries(TapeLooperPlugin - PRIVATE - # AudioPluginData # If we'd created a binary data target, we'd link to it here - juce::juce_audio_utils - PUBLIC - tapeLooperDsp - juce::juce_recommended_config_flags - juce::juce_recommended_lto_flags - juce::juce_recommended_warning_flags) - -################################################################################################### -## Googletest console application -################################################################################################### - -# find thread library -find_package(Threads) - -# include directories -include_directories( - ../lib/googletest/googletest/include - ../lib/googletest/googletest - src/ -) - -set( - test_sourcefiles - - tests/Example_gtest.cpp -) - -# This target adds a googletest executable -add_executable(TapeLooperPlugin_Gtest) - -set_target_properties (TapeLooperPlugin_Gtest PROPERTIES - FOLDER TapeLooperPlugin -) - -target_sources(TapeLooperPlugin_Gtest - PRIVATE - ${test_sourcefiles} - ../lib/googletest/googletest/src/gtest-all.cc - ../lib/googletest/googletest/src/gtest_main.cc) - -foreach(source IN LISTS test_sourcefiles) - get_filename_component(source_path "${source}" PATH) - string(REPLACE "/" "\\" source_path_msvc "${source_path}") - source_group("${source_path_msvc}" FILES "${source}") -endforeach() - -# link thread library -target_link_libraries(TapeLooperPlugin_Gtest - PRIVATE - ${CMAKE_THREAD_LIBS_INIT} - juce::juce_gui_extra - juce::juce_dsp - TapeLooperPlugin - PUBLIC - juce::juce_recommended_config_flags - juce::juce_recommended_lto_flags - juce::juce_recommended_warning_flags - ) - - -set_property(TARGET TapeLooperPlugin_Gtest - PROPERTY - CXX_STANDARD 17 -) \ No newline at end of file diff --git a/plugin/lib/JUCE b/plugin/lib/JUCE deleted file mode 160000 index 46ea879..0000000 --- a/plugin/lib/JUCE +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 46ea879739533ca0cdc689b967edfc5390c46ef7 diff --git a/plugin/src/DspDefinitions.h b/plugin/src/DspDefinitions.h deleted file mode 100644 index f699283..0000000 --- a/plugin/src/DspDefinitions.h +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -namespace dspdefs -{ - constexpr size_t numLoopers = 4; - constexpr double loopLengthInS = 60.0; - - constexpr float speedMin = 0.25f; - constexpr float speedMax = 4.0f; - - namespace paramIds - { - const auto chState = "chState"; - const auto chSpeed = "chSpeed"; - const auto chDrive = "chDrive"; - const auto chGrainAmt = "chGrainAmt"; - const auto chWowAndFlutterAmt = "chWowAndFlutterAmt"; - const auto chPostGain = "chPostGain"; - } // namespace paramIds -} // namespace dspdefs diff --git a/plugin/src/PluginEditor.cpp b/plugin/src/PluginEditor.cpp deleted file mode 100644 index fa85324..0000000 --- a/plugin/src/PluginEditor.cpp +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include "PluginProcessor.h" -#include "PluginEditor.h" -#include "DspDefinitions.h" - -//============================================================================== -TapeLooperPluginAudioProcessorEditor::TapeLooperPluginAudioProcessorEditor(TapeLooperPluginAudioProcessor& p) : - AudioProcessorEditor(&p), processorRef(p) -{ - juce::ignoreUnused(processorRef); - - for (size_t i = 0; i < dspdefs::numLoopers; i++) - { - const juce::String chStr(i); - auto comp = std::make_unique( - *(juce::AudioParameterChoice*) p.parameters_.getParameter(dspdefs::paramIds::chState + chStr), - *(juce::AudioParameterFloat*) p.parameters_.getParameter(dspdefs::paramIds::chSpeed + chStr), - *(juce::AudioParameterFloat*) p.parameters_.getParameter(dspdefs::paramIds::chDrive + chStr), - *(juce::AudioParameterFloat*) p.parameters_.getParameter(dspdefs::paramIds::chGrainAmt + chStr), - *(juce::AudioParameterFloat*) p.parameters_.getParameter(dspdefs::paramIds::chWowAndFlutterAmt + chStr), - *(juce::AudioParameterFloat*) p.parameters_.getParameter(dspdefs::paramIds::chPostGain + chStr)); - addAndMakeVisible(*comp); - looperComponents_.add(std::move(comp)); - } - - // Make sure that before the constructor has finished, you've set the - // editor's size to whatever you need it to be. - setSize(400, 600); -} - -TapeLooperPluginAudioProcessorEditor::~TapeLooperPluginAudioProcessorEditor() -{ -} - -//============================================================================== -void TapeLooperPluginAudioProcessorEditor::paint(juce::Graphics& g) -{ - // (Our component is opaque, so we must completely fill the background with a solid colour) - g.fillAll(getLookAndFeel().findColour(juce::ResizableWindow::backgroundColourId)); -} - -void TapeLooperPluginAudioProcessorEditor::resized() -{ - const auto numComponents = looperComponents_.size(); - const auto looperCompWidth = float(getWidth()) / float(numComponents); - for (int i = 0; i < numComponents; i++) - { - const auto rect = juce::Rectangle(0.0f, 0.0f, looperCompWidth, float(getHeight())); - looperComponents_[i]->setBounds(rect.withPosition(looperCompWidth * float(i), 0.0f).toNearestInt()); - } -} diff --git a/plugin/src/PluginEditor.h b/plugin/src/PluginEditor.h deleted file mode 100644 index fbc030d..0000000 --- a/plugin/src/PluginEditor.h +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include "PluginProcessor.h" -#include "TapeLooperComponent.h" - -//============================================================================== -class TapeLooperPluginAudioProcessorEditor : public juce::AudioProcessorEditor -{ -public: - explicit TapeLooperPluginAudioProcessorEditor(TapeLooperPluginAudioProcessor&); - ~TapeLooperPluginAudioProcessorEditor() override; - - //============================================================================== - void paint(juce::Graphics&) override; - void resized() override; - -private: - // This reference is provided as a quick way for your editor to - // access the processor object that created it. - TapeLooperPluginAudioProcessor& processorRef; - - juce::OwnedArray looperComponents_; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(TapeLooperPluginAudioProcessorEditor) -}; diff --git a/plugin/src/PluginProcessor.cpp b/plugin/src/PluginProcessor.cpp deleted file mode 100644 index 8ea2d62..0000000 --- a/plugin/src/PluginProcessor.cpp +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include "PluginProcessor.h" -#include "PluginEditor.h" -#include "DspDefinitions.h" - -//============================================================================== -TapeLooperPluginAudioProcessor::TapeLooperPluginAudioProcessor() : - AudioProcessor(BusesProperties() - .withInput("Input", juce::AudioChannelSet::stereo(), true) - .withOutput("Output", juce::AudioChannelSet::stereo(), true)), - parameters_(*this, nullptr, juce::Identifier("TapeLooper"), getParameterLayout()), - loopers_(parameters_) -{ -} - -TapeLooperPluginAudioProcessor::~TapeLooperPluginAudioProcessor() -{ -} - -//============================================================================== -const juce::String TapeLooperPluginAudioProcessor::getName() const -{ - return JucePlugin_Name; -} - -bool TapeLooperPluginAudioProcessor::acceptsMidi() const -{ -#if JucePlugin_WantsMidiInput - return true; -#else - return false; -#endif -} - -bool TapeLooperPluginAudioProcessor::producesMidi() const -{ -#if JucePlugin_ProducesMidiOutput - return true; -#else - return false; -#endif -} - -bool TapeLooperPluginAudioProcessor::isMidiEffect() const -{ -#if JucePlugin_IsMidiEffect - return true; -#else - return false; -#endif -} - -double TapeLooperPluginAudioProcessor::getTailLengthSeconds() const -{ - return 0.0; -} - -int TapeLooperPluginAudioProcessor::getNumPrograms() -{ - return 1; // NB: some hosts don't cope very well if you tell them there are 0 programs, - // so this should be at least 1, even if you're not really implementing programs. -} - -int TapeLooperPluginAudioProcessor::getCurrentProgram() -{ - return 0; -} - -void TapeLooperPluginAudioProcessor::setCurrentProgram(int index) -{ - juce::ignoreUnused(index); -} - -const juce::String TapeLooperPluginAudioProcessor::getProgramName(int index) -{ - juce::ignoreUnused(index); - return {}; -} - -void TapeLooperPluginAudioProcessor::changeProgramName(int index, const juce::String& newName) -{ - juce::ignoreUnused(index, newName); -} - -//============================================================================== -void TapeLooperPluginAudioProcessor::prepareToPlay(double sampleRate, int samplesPerBlock) -{ - juce::dsp::ProcessSpec specs; - specs.numChannels = juce::uint32(getMainBusNumInputChannels()); - specs.maximumBlockSize = juce::uint32(samplesPerBlock) * 2u; // safety for some weird hosts - specs.sampleRate = sampleRate; - - loopers_.prepareToPlay(specs); -} - -void TapeLooperPluginAudioProcessor::releaseResources() -{ - // When playback stops, you can use this as an opportunity to free up any - // spare memory, etc. -} - -bool TapeLooperPluginAudioProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const -{ - // This is the place where you check if the layout is supported. - // Some plugin hosts, such as certain GarageBand versions, will only - // load plugins that support stereo bus layouts. - if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) - return false; - if (layouts.getMainOutputChannelSet() != layouts.getMainInputChannelSet()) - return false; - return true; -} - -void TapeLooperPluginAudioProcessor::processBlock(juce::AudioBuffer& buffer, - juce::MidiBuffer& midiMessages) -{ - juce::ignoreUnused(midiMessages); - - juce::ScopedNoDenormals noDenormals; - - loopers_.processBlock(buffer); -} - -//============================================================================== -bool TapeLooperPluginAudioProcessor::hasEditor() const -{ - return true; // (change this to false if you choose to not supply an editor) -} - -juce::AudioProcessorEditor* TapeLooperPluginAudioProcessor::createEditor() -{ - return new TapeLooperPluginAudioProcessorEditor(*this); -} - -//============================================================================== -void TapeLooperPluginAudioProcessor::getStateInformation(juce::MemoryBlock& destData) -{ - try - { - loopers_.saveState(destData); - } - catch (const std::exception& e) - { - juce::NativeMessageBox::showAsync( - juce::MessageBoxOptions() - .withTitle(juce::String("Exception saving state")) - .withMessage(juce::String("Exception saving state: ") + e.what()) - .withIconType(juce::MessageBoxIconType::WarningIcon), - [](int) {}); - } -} - -void TapeLooperPluginAudioProcessor::setStateInformation(const void* data, int sizeInBytes) -{ - try - { - loopers_.recallState(data, size_t(sizeInBytes)); - } - catch (const std::exception& e) - { - juce::NativeMessageBox::showAsync( - juce::MessageBoxOptions() - .withTitle(juce::String("Exception recalling state")) - .withMessage(juce::String("Exception recalling state: ") + e.what()) - .withIconType(juce::MessageBoxIconType::WarningIcon), - [](int) {}); - } -} - -juce::AudioProcessorValueTreeState::ParameterLayout TapeLooperPluginAudioProcessor::getParameterLayout() -{ - juce::AudioProcessorValueTreeState::ParameterLayout result; - - for (size_t i = 0; i < dspdefs::numLoopers; i++) - { - const juce::String chStr(i); - - result.add(std::make_unique(juce::String(dspdefs::paramIds::chState) + chStr, // parameterID - "Playback State " + chStr, // parameter name - juce::StringArray({ "<", "stop", ">", "rec" }), // choices - 1) // default index - ); - juce::NormalisableRange speedRange(dspdefs::speedMin, dspdefs::speedMax); - speedRange.setSkewForCentre(1.0f); - result.add(std::make_unique(juce::String(dspdefs::paramIds::chSpeed) + chStr, // parameterID - "Speed " + chStr, // parameter name - speedRange, // mapping - 1.0f, // default value - "", // label - juce::AudioProcessorParameter::genericParameter, // category - [](float value, int) - { return juce::String(value, 2); }) // value-to-string function - ); - juce::NormalisableRange driveRange(0.25f, 4.0f); - driveRange.setSkewForCentre(1.0f); - result.add(std::make_unique(juce::String(dspdefs::paramIds::chDrive) + chStr, // parameterID - "Drive " + chStr, // parameter name - driveRange, // mapping - 1.0f, // default value - "", // label - juce::AudioProcessorParameter::genericParameter, // category - [](float value, int) - { return juce::String(value, 2); }) // value-to-string function - ); - result.add(std::make_unique(juce::String(dspdefs::paramIds::chGrainAmt) + chStr, // parameterID - "GrainAmt " + chStr, // parameter name - 0.0f, // minimum value - 1.0f, // maximum value - 0.25f) // default value - ); - result.add(std::make_unique(juce::String(dspdefs::paramIds::chWowAndFlutterAmt) + chStr, // parameterID - "WowAndFlutterAmt " + chStr, // parameter name - 0.0f, // minimum value - 1.0f, // maximum value - 0.25f) // default value - ); - result.add(std::make_unique(juce::String(dspdefs::paramIds::chPostGain) + chStr, // parameterID - "PostGain " + chStr, // parameter name - 0.0f, // minimum value - 2.0f, // maximum value - 1.0f) // default value - ); - } - - return result; -} - -//============================================================================== -// This creates new instances of the plugin.. -juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() -{ - return new TapeLooperPluginAudioProcessor(); -} diff --git a/plugin/src/PluginProcessor.h b/plugin/src/PluginProcessor.h deleted file mode 100644 index 3a1efaf..0000000 --- a/plugin/src/PluginProcessor.h +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include "TapeLooperProcessor.h" - -//============================================================================== -class TapeLooperPluginAudioProcessor : public juce::AudioProcessor -{ -public: - //============================================================================== - TapeLooperPluginAudioProcessor(); - ~TapeLooperPluginAudioProcessor() override; - - //============================================================================== - void prepareToPlay(double sampleRate, int samplesPerBlock) override; - void releaseResources() override; - - bool isBusesLayoutSupported(const BusesLayout& layouts) const override; - - void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; - using AudioProcessor::processBlock; - - //============================================================================== - juce::AudioProcessorEditor* createEditor() override; - bool hasEditor() const override; - - //============================================================================== - const juce::String getName() const override; - - bool acceptsMidi() const override; - bool producesMidi() const override; - bool isMidiEffect() const override; - double getTailLengthSeconds() const override; - - //============================================================================== - int getNumPrograms() override; - int getCurrentProgram() override; - void setCurrentProgram(int index) override; - const juce::String getProgramName(int index) override; - void changeProgramName(int index, const juce::String& newName) override; - - //============================================================================== - void getStateInformation(juce::MemoryBlock& destData) override; - void setStateInformation(const void* data, int sizeInBytes) override; - - juce::AudioProcessorValueTreeState parameters_; - -private: - //============================================================================== - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(TapeLooperPluginAudioProcessor) - - TapeLooperBank loopers_; - - juce::AudioProcessorValueTreeState::ParameterLayout getParameterLayout(); -}; diff --git a/plugin/src/TapeLooperComponent.h b/plugin/src/TapeLooperComponent.h deleted file mode 100644 index 5864700..0000000 --- a/plugin/src/TapeLooperComponent.h +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include -#include - -class PlayStopRecButtons : public juce::Component, private juce::Button::Listener -{ -public: - PlayStopRecButtons(juce::AudioParameterChoice& playStateParameter) : - paramAttachment_(playStateParameter, [this](float val) - { updateButtons(val); }) - { - recButton_.setButtonText("Rec"); - addAndMakeVisible(recButton_); - recButton_.addListener(this); - - revButton_.setButtonText("<"); - addAndMakeVisible(revButton_); - revButton_.addListener(this); - - stopButton_.setButtonText("Stop"); - addAndMakeVisible(stopButton_); - stopButton_.addListener(this); - - forwButton_.setButtonText(">"); - addAndMakeVisible(forwButton_); - forwButton_.addListener(this); - - paramAttachment_.sendInitialUpdate(); - } - - ~PlayStopRecButtons() override - { - recButton_.removeListener(this); - revButton_.removeListener(this); - stopButton_.removeListener(this); - forwButton_.removeListener(this); - } - - void resized() override - { - auto bounds = getLocalBounds(); - const auto quarterOfWidth = int(float(getWidth()) / 4.0f); - - recButton_.setBounds(bounds.removeFromTop(getHeight() / 2)); - revButton_.setBounds(bounds.removeFromLeft(quarterOfWidth)); - forwButton_.setBounds(bounds.removeFromRight(quarterOfWidth)); - stopButton_.setBounds(bounds); - } - -private: - juce::TextButton recButton_; - juce::TextButton revButton_; - juce::TextButton stopButton_; - juce::TextButton forwButton_; - juce::ParameterAttachment paramAttachment_; - - void updateButtons(float val) - { - const auto choice = int(std::round(val)); - revButton_.setToggleState(choice == 0, juce::NotificationType::dontSendNotification); - stopButton_.setToggleState(choice == 1, juce::NotificationType::dontSendNotification); - forwButton_.setToggleState(choice == 2, juce::NotificationType::dontSendNotification); - recButton_.setToggleState(choice == 3, juce::NotificationType::dontSendNotification); - } - - void buttonClicked(juce::Button* bttn) override - { - if (bttn == &revButton_) - paramAttachment_.setValueAsCompleteGesture(0.0f); - else if (bttn == &stopButton_) - paramAttachment_.setValueAsCompleteGesture(1.0f); - else if (bttn == &forwButton_) - paramAttachment_.setValueAsCompleteGesture(2.0f); - else if (bttn == &recButton_) - paramAttachment_.setValueAsCompleteGesture(3.0f); - } -}; - -class TapeLooperComponent : public juce::Component -{ -public: - TapeLooperComponent(juce::AudioParameterChoice& playStateParameter, - juce::AudioParameterFloat& speedParameter, - juce::AudioParameterFloat& driveParameter, - juce::AudioParameterFloat& grainAmtParameter, - juce::AudioParameterFloat& wowAndFlutterAmtParameter, - juce::AudioParameterFloat& postGainParameter) : - playStopRecButtons_(playStateParameter), - speedSliderAttachment_(speedParameter, speedSlider_), - driveSliderAttachment_(driveParameter, driveSlider_), - grainAmtSliderAttachment_(grainAmtParameter, grainAmtSlider_), - wowAndFlutterAmtSliderAttachment_(wowAndFlutterAmtParameter, wowAndFlutterAmtSlider_), - postGainSliderAttachment_(postGainParameter, postGainSlider_) - { - addAndMakeVisible(playStopRecButtons_); - - speedSlider_.setSliderStyle(juce::Slider::SliderStyle::RotaryHorizontalVerticalDrag); - speedSlider_.setTextBoxStyle(juce::Slider::TextEntryBoxPosition::TextBoxBelow, false, 40, 25); - addAndMakeVisible(speedSlider_); - - driveSlider_.setSliderStyle(juce::Slider::SliderStyle::RotaryHorizontalVerticalDrag); - driveSlider_.setTextBoxStyle(juce::Slider::TextEntryBoxPosition::TextBoxBelow, false, 40, 25); - addAndMakeVisible(driveSlider_); - - grainAmtSlider_.setSliderStyle(juce::Slider::SliderStyle::RotaryHorizontalVerticalDrag); - grainAmtSlider_.setTextBoxStyle(juce::Slider::TextEntryBoxPosition::TextBoxBelow, false, 40, 25); - addAndMakeVisible(grainAmtSlider_); - - wowAndFlutterAmtSlider_.setSliderStyle(juce::Slider::SliderStyle::RotaryHorizontalVerticalDrag); - wowAndFlutterAmtSlider_.setTextBoxStyle(juce::Slider::TextEntryBoxPosition::TextBoxBelow, false, 40, 25); - addAndMakeVisible(wowAndFlutterAmtSlider_); - - postGainSlider_.setSliderStyle(juce::Slider::SliderStyle::LinearVertical); - postGainSlider_.setTextBoxStyle(juce::Slider::TextEntryBoxPosition::TextBoxBelow, false, 40, 25); - addAndMakeVisible(postGainSlider_); - - speedSliderAttachment_.sendInitialUpdate(); - driveSliderAttachment_.sendInitialUpdate(); - grainAmtSliderAttachment_.sendInitialUpdate(); - wowAndFlutterAmtSliderAttachment_.sendInitialUpdate(); - postGainSliderAttachment_.sendInitialUpdate(); - } - - void resized() override - { - auto bounds = getLocalBounds().reduced(1); - - const auto buttonHeight = 25; - - playStopRecButtons_.setBounds(bounds.removeFromTop(2 * buttonHeight)); - bounds.removeFromTop(1); // padding - const auto rotarySliderWidth = std::min(bounds.getWidth(), 60); - speedSlider_.setBounds(bounds.removeFromTop(rotarySliderWidth + buttonHeight)); - bounds.removeFromTop(1); // padding - driveSlider_.setBounds(bounds.removeFromTop(rotarySliderWidth + buttonHeight)); - bounds.removeFromTop(1); // padding - grainAmtSlider_.setBounds(bounds.removeFromTop(rotarySliderWidth + buttonHeight)); - bounds.removeFromTop(1); // padding - wowAndFlutterAmtSlider_.setBounds(bounds.removeFromTop(rotarySliderWidth + buttonHeight)); - bounds.removeFromTop(1); // padding - postGainSlider_.setBounds(bounds); - } - - void paint(juce::Graphics& g) override - { - const auto drawVerticalText = [&](const juce::String& text, juce::Rectangle boundingBox) - { - g.saveState(); - g.setOrigin(boundingBox.getCentre()); - g.addTransform(juce::AffineTransform().rotated(-juce::MathConstants::halfPi)); - g.drawSingleLineText(text, - 0, - 0, - juce::Justification::horizontallyCentred); - g.restoreState(); - }; - - g.setColour(findColour(juce::Label::ColourIds::textColourId)); - drawVerticalText("Speed", speedSlider_.getBounds().withWidth(20)); - drawVerticalText("Drive", driveSlider_.getBounds().withWidth(20)); - drawVerticalText("Grain", grainAmtSlider_.getBounds().withWidth(20)); - drawVerticalText("Wow & Flutter", wowAndFlutterAmtSlider_.getBounds().withWidth(20)); - drawVerticalText("Volume", postGainSlider_.getBounds().withWidth(20)); - } - -private: - PlayStopRecButtons playStopRecButtons_; - juce::Slider speedSlider_; - juce::SliderParameterAttachment speedSliderAttachment_; - juce::Slider driveSlider_; - juce::SliderParameterAttachment driveSliderAttachment_; - juce::Slider grainAmtSlider_; - juce::SliderParameterAttachment grainAmtSliderAttachment_; - juce::Slider wowAndFlutterAmtSlider_; - juce::SliderParameterAttachment wowAndFlutterAmtSliderAttachment_; - juce::Slider postGainSlider_; - juce::SliderParameterAttachment postGainSliderAttachment_; -}; diff --git a/plugin/src/TapeLooperProcessor.h b/plugin/src/TapeLooperProcessor.h deleted file mode 100644 index df6a75c..0000000 --- a/plugin/src/TapeLooperProcessor.h +++ /dev/null @@ -1,391 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include "DspDefinitions.h" -#include -#include -#include - -#include -#include - -class MemoryBlockStorageProvider -{ -public: - MemoryBlockStorageProvider(juce::MemoryBlock& mem) : - size_(mem.getSize()), - ptr_((uint8_t*) mem.getData()) - { - } - - constexpr size_t getAvailableSize() const { return size_; } - - void write(const void* srcData, size_t sizeToWrite) - { - if (size_ >= sizeToWrite) - { - memcpy(ptr_, srcData, sizeToWrite); - ptr_ += sizeToWrite; - size_ -= sizeToWrite; - } - } - - size_t size_; - uint8_t* ptr_; -}; - -class RawMemoryStorageProvider -{ -public: - RawMemoryStorageProvider(const void* data, size_t size) : - size_(size), - ptr_((uint8_t*) data) - { - } - - constexpr size_t getAvailableSize() const { return size_; } - - void read(void* destData, size_t sizeToRead) - { - if (size_ >= sizeToRead) - { - memcpy(destData, ptr_, sizeToRead); - ptr_ += sizeToRead; - size_ -= sizeToRead; - } - } - - size_t size_; - uint8_t* ptr_; -}; - -class ITapeLooperProcessor -{ -public: - virtual ~ITapeLooperProcessor() {} - - virtual void prepareToPlay() = 0; - virtual void processBlock(juce::dsp::AudioBlock input, - juce::dsp::AudioBlock outputToAddTo) = 0; - virtual size_t getStateSizeInBytes() const = 0; - virtual void saveState(WritableMemory& mem) const = 0; - virtual void recallState(ReadableMemory& mem) = 0; -}; - -template -class TapeLooperProcessor : public ITapeLooperProcessor -{ -public: - TapeLooperProcessor(juce::AudioParameterChoice& stateParameter, - juce::AudioParameterFloat& speedParameter, - juce::AudioParameterFloat& driveParameter, - juce::AudioParameterFloat& grainAmtParameter, - juce::AudioParameterFloat& wowAndFlutterAmtParameter, - juce::AudioParameterFloat& postGainParameter, - const LooperStoragePtr<2>& storage) : - stateParameter_(stateParameter), - speedParameter_(speedParameter), - driveParameter_(driveParameter), - grainAmtParameter_(grainAmtParameter), - wowAndFlutterAmtParameter_(wowAndFlutterAmtParameter), - postGainParameter_(postGainParameter), - looper_(storage) - { - } - - void prepareToPlay() override - { - looper_.reset(); - } - - void processBlock(juce::dsp::AudioBlock input, - juce::dsp::AudioBlock outputToAddTo) override - { - const auto speed = speedParameter_.get(); - const auto drive = driveParameter_.get(); - const auto grainAmt = grainAmtParameter_.get(); - const auto wowAndFlutterAmt = wowAndFlutterAmtParameter_.get(); - const auto postGain = postGainParameter_.get(); - - Direction direction = Direction::forwards; - LooperState nextState = LooperState::stopped; - const auto stateIndex = stateParameter_.getIndex(); - if (stateIndex == 0) - { - nextState = LooperState::playing; - direction = Direction::backwards; - } - else if (stateIndex == 1) - nextState = LooperState::stopped; - else if (stateIndex == 2) - { - nextState = LooperState::playing; - direction = Direction::forwards; - } - else if (stateIndex == 3) - nextState = LooperState::recording; - - if (nextState != looper_.getState()) - looper_.switchState(nextState); - - typename TapeLooper::ProcessorType::Parameters params; - params.driveGain = drive; - params.grainAmt = grainAmt; - - const float* inChPtrs[2] = { input.getChannelPointer(0), - input.getChannelPointer(1) }; - float* outChPtrs[2] = { outputToAddTo.getChannelPointer(0), - outputToAddTo.getChannelPointer(1) }; - looper_.process(speed, - wowAndFlutterAmt, - direction, - params, - postGain, - AudioBufferPtr<2, const float>( - inChPtrs, input.getNumSamples()), - AudioBufferPtr<2, float>( - outChPtrs, outputToAddTo.getNumSamples())); - } - - size_t getStateSizeInBytes() const override - { - return looper_.getSaveAndRecallStorageSize() - + sizeof(ParameterValues); - } - - void saveState(WritableMemory& mem) const override - { - if (mem.getRemainingSize() < getStateSizeInBytes()) - throw std::runtime_error("Not enough memory to store looper state"); - - bool result = true; - - result &= looper_.save(mem); - - ParameterValues params; - params.state = uint8_t(stateParameter_.getIndex()); - params.speed = speedParameter_.get(); - params.drive = driveParameter_.get(); - params.grainAmt = grainAmtParameter_.get(); - params.wowAndFlutter = wowAndFlutterAmtParameter_.get(); - params.postGain = postGainParameter_.get(); - result &= mem.writeItem(params); - - if (!result) - throw std::runtime_error("Error saving looper state"); - } - - void recallState(ReadableMemory& mem) override - { - if (mem.getRemainingSize() < getStateSizeInBytes()) - throw std::runtime_error("Not enough memory to restore looper state"); - - bool result = true; - - result &= looper_.restore(mem); - - ParameterValues params; - result &= mem.readItem(params); - - if (!result) - throw std::runtime_error("Error loading looper state"); - - const auto setParam = [](juce::RangedAudioParameter& param, float value) - { - param.beginChangeGesture(); - param.setValueNotifyingHost(param.convertTo0to1(value)); - param.endChangeGesture(); - }; - setParam(stateParameter_, float(params.state)); - setParam(speedParameter_, params.speed); - setParam(driveParameter_, params.drive); - setParam(grainAmtParameter_, params.grainAmt); - setParam(wowAndFlutterAmtParameter_, params.wowAndFlutter); - setParam(postGainParameter_, params.postGain); - } - -private: - juce::AudioParameterChoice& stateParameter_; - juce::AudioParameterFloat& speedParameter_; - juce::AudioParameterFloat& driveParameter_; - juce::AudioParameterFloat& grainAmtParameter_; - juce::AudioParameterFloat& wowAndFlutterAmtParameter_; - juce::AudioParameterFloat& postGainParameter_; - - struct ParameterValues - { - uint8_t state; - float speed; - float drive; - float grainAmt; - float wowAndFlutter; - float postGain; - }; - - using TapeLooperType = TapeLooper; - TapeLooperType looper_; -}; - -class TapeLooperBank -{ -public: - TapeLooperBank(juce::AudioProcessorValueTreeState& parameters) : - parameters_(parameters), - currentSampleRate_(0) - { - } - - void prepareToPlay(const juce::dsp::ProcessSpec& specs) - { - inputTmpBuffer_.setSize(2, int(specs.maximumBlockSize)); - inputTmpBuffer_.clear(); - - createInstancesFor(specs.sampleRate); - - for (auto processor : processors_) - processor->prepareToPlay(); - } - - void processBlock(juce::dsp::AudioBlock buffer) - { - for (size_t ch = 0; ch < std::min(buffer.getNumChannels(), size_t(inputTmpBuffer_.getNumChannels())); ch++) - inputTmpBuffer_.copyFrom(int(ch), 0, buffer.getChannelPointer(ch), int(buffer.getNumSamples())); - const auto inputBuffer = juce::dsp::AudioBlock( - inputTmpBuffer_.getArrayOfWritePointers(), - buffer.getNumChannels(), - buffer.getNumSamples()); - - // process loopers - buffer.clear(); - for (auto processor : processors_) - processor->processBlock(inputBuffer, buffer); - } - - void saveState(juce::MemoryBlock& memory) - { - size_t sizeNeeded = 0; - for (auto processor : processors_) - sizeNeeded += processor->getStateSizeInBytes(); - - memory.setSize(sizeNeeded, true); - MemoryBlockStorageProvider storageProvider(memory); - WritableMemory writableMem(storageProvider); - - for (auto processor : processors_) - processor->saveState(writableMem); - } - - void recallState(const void* data, size_t sizeInBytes) - { - RawMemoryStorageProvider storageProvider(data, sizeInBytes); - ReadableMemory readableMem(storageProvider); - - for (auto processor : processors_) - processor->recallState(readableMem); - } - -private: - void createInstancesFor(double sampleRate) - { - static constexpr auto supportedSampleRates = { 44100, 48000, 88200, 96000 }; - - if (sampleRate != currentSampleRate_) - { - // init storage - const int numSamples = int(std::ceil(dspdefs::loopLengthInS * sampleRate)); - storage_.setSize(int(dspdefs::numLoopers) * 2 /* stereo */, - numSamples, - false, - true); - - // init processors - const auto sampleRateToUse = getClosestSampleRate(supportedSampleRates, sampleRate); - switch (sampleRateToUse) - { - default: - case 44100u: - createProcessorInstances>(); - break; - case 48000u: - createProcessorInstances>(); - break; - case 88200u: - createProcessorInstances>(); - break; - case 96000u: - createProcessorInstances>(); - break; - } - } - - currentSampleRate_ = sampleRate; - } - - size_t getClosestSampleRate(const std::initializer_list& supportedSampleRates, double sampleRate) - { - size_t result = 0; - double currentDistance = 10.0e12; - for (const auto sr : supportedSampleRates) - { - const auto distance = std::abs(double(sr) - sampleRate); - if (distance < currentDistance) - { - result = size_t(sr); - currentDistance = distance; - } - } - return result; - } - - template - void createProcessorInstances() - { - processors_.clear(); - for (size_t i = 0; i < dspdefs::numLoopers; i++) - { - LooperStoragePtr<2> storagePtr; - storagePtr.data[0] = storage_.getWritePointer(int(2 * i)); - storagePtr.data[1] = storage_.getWritePointer(int(2 * i + 1)); - storagePtr.numSamples = size_t(storage_.getNumSamples()); - - const juce::String chStr(i); - auto& stateParameter = *(juce::AudioParameterChoice*) parameters_.getParameter(dspdefs::paramIds::chState + chStr); - auto& speedParameter = *(juce::AudioParameterFloat*) parameters_.getParameter(dspdefs::paramIds::chSpeed + chStr); - auto& driveParameter = *(juce::AudioParameterFloat*) parameters_.getParameter(dspdefs::paramIds::chDrive + chStr); - auto& grainAmtParameter = *(juce::AudioParameterFloat*) parameters_.getParameter(dspdefs::paramIds::chGrainAmt + chStr); - auto& wowAndFlutterAmtParameter = *(juce::AudioParameterFloat*) parameters_.getParameter(dspdefs::paramIds::chWowAndFlutterAmt + chStr); - auto& postGainParameter = *(juce::AudioParameterFloat*) parameters_.getParameter(dspdefs::paramIds::chPostGain + chStr); - - processors_.add(new ProcessorType(stateParameter, - speedParameter, - driveParameter, - grainAmtParameter, - wowAndFlutterAmtParameter, - postGainParameter, - storagePtr)); - } - } - - juce::AudioProcessorValueTreeState& parameters_; - - double currentSampleRate_; - juce::AudioBuffer storage_; - juce::AudioBuffer inputTmpBuffer_; - juce::OwnedArray processors_; -}; diff --git a/plugin/tests/Example_gtest.cpp b/plugin/tests/Example_gtest.cpp deleted file mode 100644 index 24ec4f1..0000000 --- a/plugin/tests/Example_gtest.cpp +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (C) Johannes Elliesen, 2021 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include "PluginProcessor.h" -#include - -/* This example test shows how to create an instance of the plugin as - part of a test. */ -TEST(ExampleTest, CreatePluginAndGUITest) -{ - juce::ScopedJuceInitialiser_GUI juceInitializer; - auto messageManager = juce::MessageManager::getInstance(); - messageManager->setCurrentThreadAsMessageThread(); - - auto pluginInstance = std::make_unique(); - EXPECT_NE(pluginInstance, nullptr); - auto gui = pluginInstance->createEditorIfNeeded(); - EXPECT_NE(gui, nullptr); - - messageManager->runDispatchLoopUntil(200); - - pluginInstance->editorBeingDeleted(gui); - delete gui; - - pluginInstance.reset(); - messageManager->deleteInstance(); -} \ No newline at end of file From c267be24ac282dc7a1199f5e56feadce9e6f58c6 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:16:17 +0200 Subject: [PATCH 20/26] feat: extract loop library into its own class and namespace --- firmware/CMakeLists.txt | 1 + firmware/src/AudioSaveAndRecall.cpp | 84 +++++++++++++++ firmware/src/AudioSaveAndRecall.h | 152 +++++++++++---------------- firmware/tests/firmware_combined.cpp | 3 +- 4 files changed, 149 insertions(+), 91 deletions(-) create mode 100644 firmware/src/AudioSaveAndRecall.cpp diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index 8a68ed9..37da238 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -23,6 +23,7 @@ endif(COMMAND cmake_policy) set(FIRMWARE_NAME TapeLooper) set(FIRMWARE_SOURCES + src/AudioSaveAndRecall.cpp src/main.cpp src/util/WavFileFormat.cpp ) diff --git a/firmware/src/AudioSaveAndRecall.cpp b/firmware/src/AudioSaveAndRecall.cpp new file mode 100644 index 0000000..fcee53e --- /dev/null +++ b/firmware/src/AudioSaveAndRecall.cpp @@ -0,0 +1,84 @@ +/** + * Copyright (C) Johannes Elliesen, 2024 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "AudioSaveAndRecall.h" + +namespace loop_library +{ + AudioFileName makeFileNameWithoutExtensionFor(const StorageBank bank, + const int slot) + { + auto filename = AudioFileName(kLibraryBaseFolder); + switch (bank) + { + case StorageBank::green: + filename.Append("green-"); + break; + case StorageBank::yellow: + filename.Append("yellow-"); + break; + case StorageBank::red: + filename.Append("red-"); + break; + default: + return ""; + } + switch (slot / 4) + { + case 0: + filename.Append("A"); + break; + case 1: + filename.Append("B"); + break; + case 2: + filename.Append("C"); + break; + case 3: + filename.Append("D"); + break; + default: + return ""; + } + switch (slot % 4) + { + case 0: + filename.Append("0"); + break; + case 1: + filename.Append("1"); + break; + case 2: + filename.Append("2"); + break; + case 3: + filename.Append("3"); + break; + default: + return ""; + } + return filename; + } + + AudioFileName makeWavFileNameFor(const StorageBank bank, + const int slot) + { + auto name = makeFileNameWithoutExtensionFor(bank, slot); + name.Append(".wav"); + return name; + } +} // namespace loop_library diff --git a/firmware/src/AudioSaveAndRecall.h b/firmware/src/AudioSaveAndRecall.h index b6b92cf..a9a0e41 100644 --- a/firmware/src/AudioSaveAndRecall.h +++ b/firmware/src/AudioSaveAndRecall.h @@ -21,6 +21,11 @@ #include #include "util/WavFileFormat.h" +extern int32_t f2s32(float x); +extern float s322f(int32_t x); +extern float s242f(int32_t x); +extern float s162f(int16_t x); + using AudioFileName = daisy::FixedCapStr<32>; enum class AudioSaveAndRecallResult @@ -40,33 +45,69 @@ enum class StorageBank typedef void (*AudioSaveAndRecallDoneCallbackPtr)(void* context, AudioSaveAndRecallResult result); -template -class AudioSaveAndRecall +namespace loop_library { -public: - AudioSaveAndRecall() - { - isStorageSlotUsedBitfield_[0] = 0; - isStorageSlotUsedBitfield_[1] = 0; - isStorageSlotUsedBitfield_[2] = 0; - } + static auto kLibraryBaseFolder = "loops/"; + + AudioFileName makeFileNameWithoutExtensionFor(const StorageBank bank, + const int slot); + AudioFileName makeWavFileNameFor(const StorageBank bank, + const int slot); - void scanOrInitLibrary() + template + class Library { - fileIo_.makeFolderIfNotExistent(libraryBaseFolder_); + public: + Library(FileIoProvider& fileIo) : + fileIo_(fileIo) + { + for (int i = 0; i < int(StorageBank::count); i++) + { + isStorageSlotUsedBitfield_[i] = 0; + } + } - for (size_t bank = 0; bank < kNumBanks; bank++) + void scanOrInitLibrary() { - for (size_t slot = 0; slot < kNumSlots; slot++) + fileIo_.makeFolderIfNotExistent(loop_library::kLibraryBaseFolder); + + for (size_t bank = 0; bank < kNumBanks; bank++) { - const auto filename = makeWavFileNameFor(StorageBank(bank), slot); + for (size_t slot = 0; slot < kNumSlots; slot++) + { + const auto filename = loop_library::makeWavFileNameFor(StorageBank(bank), slot); - if (const auto isInUse = fileIo_.hasFile(filename)) - isStorageSlotUsedBitfield_[bank] |= (1 << slot); - else - isStorageSlotUsedBitfield_[bank] &= ~(1 << slot); + if (const auto isInUse = fileIo_.hasFile(filename)) + isStorageSlotUsedBitfield_[bank] |= (1 << slot); + else + isStorageSlotUsedBitfield_[bank] &= ~(1 << slot); + } } } + + bool hasLoop(StorageBank bank, int slot) + { + if (slot < 0 || slot >= kNumSlots) + return false; + + return isStorageSlotUsedBitfield_[int(bank)] & (1 << slot); + } + + private: + FileIoProvider& fileIo_; + + static constexpr auto kNumSlots = 16; + static constexpr auto kNumBanks = int(StorageBank::count); + int16_t isStorageSlotUsedBitfield_[kNumBanks]; + }; +}; // namespace loop_library + +template +class AudioSaveAndRecall +{ +public: + AudioSaveAndRecall() + { } template @@ -76,7 +117,7 @@ class AudioSaveAndRecall AudioSaveAndRecallDoneCallbackPtr doneCallback, void* doneCallbackContext) { - startSavingToFile(makeWavFileNameFor(bank, slot), + startSavingToFile(loop_library::makeWavFileNameFor(bank, slot), looper, doneCallback, doneCallbackContext); @@ -156,7 +197,7 @@ class AudioSaveAndRecall size_t getNumChannelsInFile(StorageBank bank, int slot) { - return getNumChannelsInFile(makeWavFileNameFor(bank, slot)); + return getNumChannelsInFile(loop_library::makeWavFileNameFor(bank, slot)); } size_t getNumChannelsInFile(const AudioFileName& filename) @@ -188,7 +229,7 @@ class AudioSaveAndRecall AudioSaveAndRecallDoneCallbackPtr doneCallback, void* doneCallbackContext) { - startReadingFromFile(makeWavFileNameFor(bank, slot), + startReadingFromFile(loop_library::makeWavFileNameFor(bank, slot), looper, doneCallback, doneCallbackContext); @@ -566,69 +607,6 @@ class AudioSaveAndRecall return numFramesRead / numChannels; } - AudioFileName makeFileNameWithoutExtensionFor(const StorageBank bank, - const int slot) - { - auto filename = libraryBaseFolder_; - switch (bank) - { - case StorageBank::green: - filename.Append("green-"); - break; - case StorageBank::yellow: - filename.Append("yellow-"); - break; - case StorageBank::red: - filename.Append("red-"); - break; - default: - return ""; - } - switch (slot / 4) - { - case 0: - filename.Append("A"); - break; - case 1: - filename.Append("B"); - break; - case 2: - filename.Append("C"); - break; - case 3: - filename.Append("D"); - break; - default: - return ""; - } - switch (slot % 4) - { - case 0: - filename.Append("0"); - break; - case 1: - filename.Append("1"); - break; - case 2: - filename.Append("2"); - break; - case 3: - filename.Append("3"); - break; - default: - return ""; - } - return filename; - } - - AudioFileName makeWavFileNameFor(const StorageBank bank, - const int slot) - { - auto name = makeFileNameWithoutExtensionFor(bank, slot); - name.Append(".wav"); - return name; - } - static constexpr size_t kNumFramesPerChunk_ = 1000; static constexpr size_t kBitsPerSample_ = 32u; static constexpr size_t kMaxNumChannels_ = 2u; @@ -658,10 +636,4 @@ class AudioSaveAndRecall void* doneCallbackContext_ = nullptr; int32_t writeBuffer_[kWriteBufferSizeBytes_ / sizeof(int32_t)]; - - static constexpr auto kNumSlots = 16; - static constexpr auto kNumBanks = int(StorageBank::count); - int16_t isStorageSlotUsedBitfield_[kNumBanks]; - - const AudioFileName libraryBaseFolder_ = "loops"; }; diff --git a/firmware/tests/firmware_combined.cpp b/firmware/tests/firmware_combined.cpp index 9178721..ca7393b 100644 --- a/firmware/tests/firmware_combined.cpp +++ b/firmware/tests/firmware_combined.cpp @@ -1 +1,2 @@ -#include "../src/util/WavFileFormat.cpp" \ No newline at end of file +#include "../src/util/WavFileFormat.cpp" +#include "../src/AudioSaveAndRecall.cpp" \ No newline at end of file From a74f946ad488eee52d260e0f89d1bf69fea296f9 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:24:07 +0200 Subject: [PATCH 21/26] refactor: extract file io ownership from `AudioSaveAndRecall` --- firmware/src/AudioSaveAndRecall.h | 5 +- firmware/src/main.cpp | 5 +- firmware/tests/AudioSaveAndRecall_gtest.cpp | 71 +++++++++++---------- 3 files changed, 43 insertions(+), 38 deletions(-) diff --git a/firmware/src/AudioSaveAndRecall.h b/firmware/src/AudioSaveAndRecall.h index a9a0e41..b28e4c4 100644 --- a/firmware/src/AudioSaveAndRecall.h +++ b/firmware/src/AudioSaveAndRecall.h @@ -106,7 +106,8 @@ template class AudioSaveAndRecall { public: - AudioSaveAndRecall() + AudioSaveAndRecall(FileIoProvider& fileIo) : + fileIo_(fileIo) { } @@ -625,7 +626,7 @@ class AudioSaveAndRecall typedef ReadOrWriteResult (*WriteOrReadFuncPtr)(AudioSaveAndRecall* storage, void* looper); typedef void (*LiftRestrictionsFuncPtr)(void* looper); - FileIoProvider fileIo_; + FileIoProvider& fileIo_; size_t totalNumFrames_ = 0; size_t numFramesDone_ = 0; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 6f3e33c..482b021 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -55,6 +55,7 @@ daisy::UiEventQueue uiEventQueue; UiHardware::LedDmaBufferType DMA_BUFFER_MEM_SECTION ledDmaBufferA, ledDmaBufferB; uint16_t DMA_BUFFER_MEM_SECTION buttonShiftRegisterDmaBuffer; +LateInitializedObject fileIo; LateInitializedObject uiHardware; LateInitializedObject ui; @@ -113,7 +114,7 @@ void initDsp() { looperParameterProvider.create(); - audioSaveAndRecall.create(); + audioSaveAndRecall.create(fileIo); // initialize the looper storage in SDRAM const auto& rawStorages = *looperStorages.create(); @@ -147,6 +148,8 @@ void configurePlatform() seed.Init(true /* enable 480MHz boost */); seed.SetAudioBlockSize(blockSize); seed.SetAudioSampleRate(sampleRate); + + fileIo.create(); } void audioCallback(const float* const* in, float** out, size_t size) diff --git a/firmware/tests/AudioSaveAndRecall_gtest.cpp b/firmware/tests/AudioSaveAndRecall_gtest.cpp index 4c0312f..a2956c5 100644 --- a/firmware/tests/AudioSaveAndRecall_gtest.cpp +++ b/firmware/tests/AudioSaveAndRecall_gtest.cpp @@ -171,6 +171,7 @@ class AudioSaveAndRecallFixture : public ::testing::Test LooperStorage stereoBuffer_; TapeLooper monoLooper_; TapeLooper stereoLooper_; + TestFileIo fileIo_; AudioSaveAndRecallResult doneCallbackResult_ = AudioSaveAndRecallResult(-1); // invalid @@ -219,19 +220,19 @@ class AudioSaveAndRecallFixture : public ::testing::Test TEST_F(AudioSaveAndRecallFixture, a_saveToFile_failWhenNoDataInLooper) { - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); monoLooper_.setPlaybackLength(0); - sut.getFileIoProviderForTesting().simulateFileOpenError_ = true; + fileIo_.simulateFileOpenError_ = true; sut.startSavingToFile("dummyFilename", monoLooper_, &doneCallback, &doneCallbackResult_); // doesn't even open the file - EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, ""); - EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_STREQ(fileIo_.lastFileName_, ""); + EXPECT_FALSE(fileIo_.closeFileCalled_); EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::error); EXPECT_EQ(sut.getCurrentProgress(), -1.0f); @@ -239,33 +240,33 @@ TEST_F(AudioSaveAndRecallFixture, a_saveToFile_failWhenNoDataInLooper) TEST_F(AudioSaveAndRecallFixture, b_saveToFile_failWhenFileDoesntOpen) { - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); - sut.getFileIoProviderForTesting().simulateFileOpenError_ = true; + fileIo_.simulateFileOpenError_ = true; sut.startSavingToFile("dummyFilename", monoLooper_, &doneCallback, &doneCallbackResult_); - EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_STREQ(fileIo_.lastFileName_, "dummyFilename"); EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::error); EXPECT_EQ(sut.getCurrentProgress(), -1.0f); - EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_TRUE(fileIo_.closeFileCalled_); } TEST_F(AudioSaveAndRecallFixture, c_saveToFile_failWhenErrorWhileWriting) { - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); sut.startSavingToFile("dummyFilename", monoLooper_, &doneCallback, &doneCallbackResult_); - EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_STREQ(fileIo_.lastFileName_, "dummyFilename"); EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult(-1)); // not called yet EXPECT_EQ(sut.getCurrentProgress(), 0.0f); - EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_FALSE(fileIo_.closeFileCalled_); // make some progress sut.readOrWriteNextChunk(); @@ -273,18 +274,18 @@ TEST_F(AudioSaveAndRecallFixture, c_saveToFile_failWhenErrorWhileWriting) EXPECT_LT(sut.getCurrentProgress(), 1.0f); // simulate file IO error - sut.getFileIoProviderForTesting().simulateFileWriteError_ = true; + fileIo_.simulateFileWriteError_ = true; sut.readOrWriteNextChunk(); // operation was aborted EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::error); EXPECT_EQ(sut.getCurrentProgress(), -1.0f); - EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_TRUE(fileIo_.closeFileCalled_); } TEST_F(AudioSaveAndRecallFixture, d_saveToFile_saveFileButWaitUntilRecordingDoneAndPreventNewRecording) { - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); monoLooper_.switchState(LooperState::recording); monoLooper_.recorderForTesting().setRecordingProgressForTesting(2000); @@ -294,10 +295,10 @@ TEST_F(AudioSaveAndRecallFixture, d_saveToFile_saveFileButWaitUntilRecordingDone &doneCallback, &doneCallbackResult_); - EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_STREQ(fileIo_.lastFileName_, "dummyFilename"); EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult(-1)); // not called yet EXPECT_EQ(sut.getCurrentProgress(), 0.0f); - EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_FALSE(fileIo_.closeFileCalled_); // the looper was stopped when saving started... EXPECT_EQ(monoLooper_.getState(), LooperState::stopped); @@ -321,17 +322,17 @@ TEST_F(AudioSaveAndRecallFixture, d_saveToFile_saveFileButWaitUntilRecordingDone TEST_F(AudioSaveAndRecallFixture, e_saveToFile_saveFileAndAllowPlaybackButPreventNewRecording) { - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); sut.startSavingToFile("dummyFilename", monoLooper_, &doneCallback, &doneCallbackResult_); - EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_STREQ(fileIo_.lastFileName_, "dummyFilename"); EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult(-1)); // not called yet EXPECT_EQ(sut.getCurrentProgress(), 0.0f); - EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_FALSE(fileIo_.closeFileCalled_); // attempt to start a new recording while we're saving monoLooper_.switchState(LooperState::recording); @@ -360,17 +361,17 @@ TEST_F(AudioSaveAndRecallFixture, e_saveToFile_saveFileAndAllowPlaybackButPreven TEST_F(AudioSaveAndRecallFixture, f_saveToFile_saveFileAndReportProgress) { - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); sut.startSavingToFile("dummyFilename", monoLooper_, &doneCallback, &doneCallbackResult_); - EXPECT_STREQ(sut.getFileIoProviderForTesting().lastFileName_, "dummyFilename"); + EXPECT_STREQ(fileIo_.lastFileName_, "dummyFilename"); EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult(-1)); // not called yet EXPECT_EQ(sut.getCurrentProgress(), 0.0f); - EXPECT_FALSE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_FALSE(fileIo_.closeFileCalled_); // write until completed int timeout = 1000; @@ -383,12 +384,12 @@ TEST_F(AudioSaveAndRecallFixture, f_saveToFile_saveFileAndReportProgress) } EXPECT_EQ(sut.getCurrentProgress(), 1.0f); EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::ok); - EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_TRUE(fileIo_.closeFileCalled_); } TEST_F(AudioSaveAndRecallFixture, g_saveToFile_producesValidMonoWavFile) { - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); constexpr auto kNumTestSamples = 4800; constexpr auto kReferenceFilename = "data/monoSineWaves.wav"; @@ -409,19 +410,19 @@ TEST_F(AudioSaveAndRecallFixture, g_saveToFile_producesValidMonoWavFile) } EXPECT_EQ(sut.getCurrentProgress(), 1.0f); EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::ok); - EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_TRUE(fileIo_.closeFileCalled_); #ifdef WRITE_REFERENCES - writeReferenceFile(kReferenceFilename, sut.getFileIoProviderForTesting().dataWritten_); + writeReferenceFile(kReferenceFilename, fileIo_.dataWritten_); #endif const auto expectedFileContents = readReferenceFile(kReferenceFilename); - EXPECT_EQ(sut.getFileIoProviderForTesting().dataWritten_, expectedFileContents); + EXPECT_EQ(fileIo_.dataWritten_, expectedFileContents); } TEST_F(AudioSaveAndRecallFixture, h_saveToFile_producesValidStereoWavFile) { - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); constexpr auto kNumTestSamples = 4800; constexpr auto kReferenceFilename = "data/stereoSineWaves.wav"; @@ -442,14 +443,14 @@ TEST_F(AudioSaveAndRecallFixture, h_saveToFile_producesValidStereoWavFile) } EXPECT_EQ(sut.getCurrentProgress(), 1.0f); EXPECT_EQ(doneCallbackResult_, AudioSaveAndRecallResult::ok); - EXPECT_TRUE(sut.getFileIoProviderForTesting().closeFileCalled_); + EXPECT_TRUE(fileIo_.closeFileCalled_); #ifdef WRITE_REFERENCES - writeReferenceFile(kReferenceFilename, sut.getFileIoProviderForTesting().dataWritten_); + writeReferenceFile(kReferenceFilename, fileIo_.dataWritten_); #endif const auto expectedFileContents = readReferenceFile(kReferenceFilename); - EXPECT_EQ(sut.getFileIoProviderForTesting().dataWritten_, expectedFileContents); + EXPECT_EQ(fileIo_.dataWritten_, expectedFileContents); } class AudioSaveAndRecallParameterizedFileFormatFixture : @@ -462,11 +463,11 @@ TEST_P(AudioSaveAndRecallParameterizedFileFormatFixture, canReadMonoWav) { const auto [referenceFileNameMono, referenceFileNameStereo, epsilon] = GetParam(); - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); constexpr auto kExpectedNumSamples = 48; - sut.getFileIoProviderForTesting().dataToRead_ = readReferenceFile(referenceFileNameMono); + fileIo_.dataToRead_ = readReferenceFile(referenceFileNameMono); sut.startReadingFromFile("dummyFilename", monoLooper_, @@ -494,11 +495,11 @@ TEST_P(AudioSaveAndRecallParameterizedFileFormatFixture, canReadStereoWav) { const auto [referenceFileNameMono, referenceFileNameStereo, epsilon] = GetParam(); - AudioSaveAndRecall sut; + AudioSaveAndRecall sut(fileIo_); constexpr auto kExpectedNumSamples = 48; - sut.getFileIoProviderForTesting().dataToRead_ = readReferenceFile(referenceFileNameStereo); + fileIo_.dataToRead_ = readReferenceFile(referenceFileNameStereo); sut.startReadingFromFile("dummyFilename", stereoLooper_, From 8d4cc9c3fe8c16935a6d091d64160b671059e957 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:40:51 +0200 Subject: [PATCH 22/26] feat: pass loop library into save and load ui pages --- firmware/src/main.cpp | 11 +++++-- firmware/src/ui/TapeLooperUi.h | 16 +++++++---- firmware/src/ui/UiLoadOrSavePage.h | 35 ++++++++++++++--------- firmware/tests/Ui_gtest.cpp | 8 ++++-- firmware/tests/mocks/LoopLibrary_mock.h | 38 +++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 firmware/tests/mocks/LoopLibrary_mock.h diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 482b021..62275f6 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -41,10 +41,13 @@ struct LooperTypes using AudioSaveAndRecallType = AudioSaveAndRecall; }; using LooperControllerType = LooperController; +using LoopLibraryType = loop_library::Library; + using TapeLooperUiType = TapeLooperUi, LooperControllerType, - LooperParameterProviderType>; + LooperParameterProviderType, + LoopLibraryType>; using LooperStoragesType = std::array, numLoopers>; // hardware static objects @@ -57,6 +60,7 @@ uint16_t DMA_BUFFER_MEM_SECTION buttonShiftRegisterDmaBuffer; LateInitializedObject fileIo; LateInitializedObject uiHardware; +LateInitializedObject loopLibrary; LateInitializedObject ui; // dsp static objects @@ -95,6 +99,8 @@ void initUi() ledDmaBufferB, &buttonShiftRegisterDmaBuffer); + loopLibrary.create(fileIo); + // init the UI ui.create( hardware, @@ -103,7 +109,8 @@ void initUi() uiEventQueue, peakMeters, looperController, - looperParameterProvider); + looperParameterProvider, + loopLibrary); } // =================================================================== diff --git a/firmware/src/ui/TapeLooperUi.h b/firmware/src/ui/TapeLooperUi.h index 8499a44..c706294 100644 --- a/firmware/src/ui/TapeLooperUi.h +++ b/firmware/src/ui/TapeLooperUi.h @@ -32,7 +32,8 @@ template + typename ParameterProviderType, + typename LoopLibraryType> class TapeLooperUi { public: @@ -42,15 +43,16 @@ class TapeLooperUi daisy::UiEventQueue& eventQueue, const std::array& peakMeters, LooperControllerType& looperController, - ParameterProviderType& looperParameterProvider) : + ParameterProviderType& looperParameterProvider, + LoopLibraryType& loopLibrary) : uiHardware_(uiHardware), eventQueue_(eventQueue), calibrationPage_(uiHardware), settingsPage_(looperController, looperParameterProvider, calibrationPage_), - savePage_(looperController), - loadPage_(looperController), + savePage_(looperController, loopLibrary), + loadPage_(looperController, loopLibrary), recordingPage_(looperController), basePage_(peakMeters, looperController, @@ -92,10 +94,12 @@ class TapeLooperUi ParameterProviderType> settingsPage_; UiSavePage + UiHardwareType, + LoopLibraryType> savePage_; UiLoadPage + UiHardwareType, + LoopLibraryType> loadPage_; UiRecordingPage diff --git a/firmware/src/ui/UiLoadOrSavePage.h b/firmware/src/ui/UiLoadOrSavePage.h index 26eddd9..c5a08e3 100644 --- a/firmware/src/ui/UiLoadOrSavePage.h +++ b/firmware/src/ui/UiLoadOrSavePage.h @@ -27,12 +27,16 @@ #include "../hardware/UiHardwareTypes.h" #include "LooperParameterProvider.h" -template +template class UiLoadOrSavePage : public daisy::UiPage { public: - UiLoadOrSavePage(LooperControllerType& looperController) : - looperController_(looperController) + UiLoadOrSavePage(LooperControllerType& looperController, LoopLibraryType& loopLibrary) : + looperController_(looperController), + loopLibrary_(loopLibrary) { } @@ -321,17 +325,20 @@ class UiLoadOrSavePage : public daisy::UiPage int selectedChannel_ = -1; int selectedSlot_ = 0; LooperControllerType& looperController_; + LoopLibraryType& loopLibrary_; uint32_t closeAfterTime_ = -1; }; -template -class UiSavePage : public UiLoadOrSavePage +template +class UiSavePage : public UiLoadOrSavePage { - using ParentType = UiLoadOrSavePage; + using ParentType = UiLoadOrSavePage; public: - UiSavePage(LooperControllerType& looperController) : - ParentType(looperController) + UiSavePage(LooperControllerType& looperController, LoopLibraryType& loopLibrary) : + ParentType(looperController, loopLibrary) { } @@ -365,14 +372,16 @@ class UiSavePage : public UiLoadOrSavePage -class UiLoadPage : public UiLoadOrSavePage +template +class UiLoadPage : public UiLoadOrSavePage { - using ParentType = UiLoadOrSavePage; + using ParentType = UiLoadOrSavePage; public: - UiLoadPage(LooperControllerType& looperController) : - ParentType(looperController) + UiLoadPage(LooperControllerType& looperController, LoopLibraryType& loopLibrary) : + ParentType(looperController, loopLibrary) { } diff --git a/firmware/tests/Ui_gtest.cpp b/firmware/tests/Ui_gtest.cpp index 7ac9fbe..c931351 100644 --- a/firmware/tests/Ui_gtest.cpp +++ b/firmware/tests/Ui_gtest.cpp @@ -24,6 +24,7 @@ #include "ui/TapeLooperUi.h" #include "mocks/LooperController_mock.h" +#include "mocks/LoopLibrary_mock.h" #include "mocks/ParameterProvider_mock.h" #include "mocks/PeakMeter_mock.h" #include "mocks/UiHardware_mock.h" @@ -119,7 +120,8 @@ class UiFixture : public ::testing::Test eventQueue_, peakMeters_, looperController_, - looperParameterProvider_) + looperParameterProvider_, + loopLibrary_) { uiTestMockFunctionIsolator.GetStateForCurrentTest()->clearCanvasFunction = [&](const daisy::UiCanvasDescriptor&) @@ -328,10 +330,12 @@ class UiFixture : public ::testing::Test std::array peakMeters_; LooperControllerMock looperController_; ParameterProviderMock looperParameterProvider_; + LoopLibraryMock loopLibrary_; TapeLooperUi, - ParameterProviderMock> + ParameterProviderMock, + LoopLibraryMock> ui_; }; diff --git a/firmware/tests/mocks/LoopLibrary_mock.h b/firmware/tests/mocks/LoopLibrary_mock.h new file mode 100644 index 0000000..5828635 --- /dev/null +++ b/firmware/tests/mocks/LoopLibrary_mock.h @@ -0,0 +1,38 @@ +/** + * Copyright (C) Johannes Elliesen, 2025 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +#include "AudioSaveAndRecall.h" + +class LoopLibraryMock +{ +public: + void scanOrInitLibrary() + { + } + + bool hasLoop(StorageBank bank, int slot) + { + (void) (bank); + (void) (slot); + + return false; + } +}; \ No newline at end of file From 7acffb04d4aa692561d75e132038fa68982c4eaa Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 20:48:59 +0200 Subject: [PATCH 23/26] feat: Save and load pages show existing loops --- firmware/src/AudioSaveAndRecall.h | 6 +++++ firmware/src/hardware/FatFsFileIo.h | 14 ++++++++++ firmware/src/ui/UiLoadOrSavePage.h | 29 ++++++++++++++------- firmware/tests/AudioSaveAndRecall_gtest.cpp | 2 ++ 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/firmware/src/AudioSaveAndRecall.h b/firmware/src/AudioSaveAndRecall.h index b28e4c4..0962c3d 100644 --- a/firmware/src/AudioSaveAndRecall.h +++ b/firmware/src/AudioSaveAndRecall.h @@ -69,6 +69,8 @@ namespace loop_library void scanOrInitLibrary() { + fileIo_.ensureVolumeMounted(); + fileIo_.makeFolderIfNotExistent(loop_library::kLibraryBaseFolder); for (size_t bank = 0; bank < kNumBanks; bank++) @@ -132,6 +134,8 @@ class AudioSaveAndRecall { static_assert(LooperType::getNumChannels() <= kMaxNumChannels_); + fileIo_.ensureVolumeMounted(); + looperPtr_ = &looper; doneCallback_ = doneCallback; doneCallbackContext_ = doneCallbackContext; @@ -244,6 +248,8 @@ class AudioSaveAndRecall { static_assert(LooperType::getNumChannels() <= kMaxNumChannels_); + fileIo_.ensureVolumeMounted(); + looperPtr_ = &looper; doneCallback_ = doneCallback; doneCallbackContext_ = doneCallbackContext; diff --git a/firmware/src/hardware/FatFsFileIo.h b/firmware/src/hardware/FatFsFileIo.h index 4d37701..e735782 100644 --- a/firmware/src/hardware/FatFsFileIo.h +++ b/firmware/src/hardware/FatFsFileIo.h @@ -20,6 +20,20 @@ class FatFsFileIo { public: + void ensureVolumeMounted() {} + + bool makeFolderIfNotExistent(const char* folderName) + { + (void) (folderName); + return false; + } + + bool hasFile(const char* fileName) + { + (void) (fileName); + return false; + } + bool openForReading(const char* fileName) { (void) (fileName); diff --git a/firmware/src/ui/UiLoadOrSavePage.h b/firmware/src/ui/UiLoadOrSavePage.h index c5a08e3..d66003e 100644 --- a/firmware/src/ui/UiLoadOrSavePage.h +++ b/firmware/src/ui/UiLoadOrSavePage.h @@ -90,12 +90,6 @@ class UiLoadOrSavePage : public daisy::UiPage const Button button = Button(buttonID); switch (button) { - case Button::record: - [[fallthrough]]; - case Button::settings: - looperController_.abortLoadOrSaveOperation(); - Close(); // close this page - return false; // pass event to the page below to open the respective page case Button::save: return handleSaveButton(); case Button::load: @@ -111,7 +105,7 @@ class UiLoadOrSavePage : public daisy::UiPage case Stage::selectBankAndSlot: return onButtonInBankAndSlotSelection(button); default: - // any button press while showing + // any button press while showing success or error closes the page if (stage_ == Stage::displaySuccess || stage_ == Stage::displayError) { Close(); @@ -127,6 +121,11 @@ class UiLoadOrSavePage : public daisy::UiPage return false; // passthrough pot events to the base page } + void OnFocusGained() override + { + loopLibrary_.scanOrInitLibrary(); + } + protected: UiLoadOrSavePage(const UiLoadOrSavePage&) = delete; UiLoadOrSavePage& operator=(const UiLoadOrSavePage&) = delete; @@ -188,7 +187,17 @@ class UiLoadOrSavePage : public daisy::UiPage const auto allLeds = UiHardwareType::allChannelLeds(); for (size_t i = 0; i < allLeds.size(); i++) { - hardware.setLed(allLeds[i], this->selectedSlot_ == int(i) ? bankColorPulsing : LedColour::off); + if (this->selectedSlot_ == int(i)) + { + hardware.setLed(allLeds[i], bankColourPulsing); + } + else + { + hardware.setLed(allLeds[i], + loopLibrary_.hasLoop(selectedBank_, i) + ? bankColour + : LedColour::off); + } } } @@ -359,7 +368,7 @@ class UiSavePage : public UiLoadOrSavePagestage_ == ParentType::Stage::displaySuccess || this->stage_ == ParentType::Stage::displayError) { - Close(); + this->Close(); } return true; } @@ -409,7 +418,7 @@ class UiLoadPage : public UiLoadOrSavePagestage_ == ParentType::Stage::displaySuccess || this->stage_ == ParentType::Stage::displayError) { - Close(); + this->Close(); } return true; } diff --git a/firmware/tests/AudioSaveAndRecall_gtest.cpp b/firmware/tests/AudioSaveAndRecall_gtest.cpp index a2956c5..022d46e 100644 --- a/firmware/tests/AudioSaveAndRecall_gtest.cpp +++ b/firmware/tests/AudioSaveAndRecall_gtest.cpp @@ -86,6 +86,8 @@ namespace class TestFileIo { public: + void ensureVolumeMounted() {} + bool openForReading(const AudioFileName& fileName) { lastFileName_ = fileName; From f780e022d3c386f16e8ddc2df3e105d585744295 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Wed, 8 Oct 2025 21:25:21 +0200 Subject: [PATCH 24/26] feat: implement fatfs based file IO --- firmware/src/AudioSaveAndRecall.h | 4 +- firmware/src/hardware/FatFsFileIo.h | 82 +++++++++++++++++---- firmware/tests/AudioSaveAndRecall_gtest.cpp | 7 +- 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/firmware/src/AudioSaveAndRecall.h b/firmware/src/AudioSaveAndRecall.h index 0962c3d..0514534 100644 --- a/firmware/src/AudioSaveAndRecall.h +++ b/firmware/src/AudioSaveAndRecall.h @@ -383,7 +383,7 @@ class AudioSaveAndRecall { wavHeader_ = makeWavFileHeader32bitInt(sampleRate, numChannelsPerFrame, totalNumFrames); - return fileIo_.write(&wavHeader_, sizeof(wavHeader_)); + return fileIo_.write(&wavHeader_, sizeof(wavHeader_)) == int32_t(sizeof(wavHeader_)); } bool readWavHeader() @@ -500,7 +500,7 @@ class AudioSaveAndRecall } const auto numBytes = std::distance(&writeBuffer_[0], outPtr) * sizeof(int32_t); - return fileIo_.write(writeBuffer_, numBytes); + return fileIo_.write(writeBuffer_, numBytes) == int32_t(numBytes); } template diff --git a/firmware/src/hardware/FatFsFileIo.h b/firmware/src/hardware/FatFsFileIo.h index e735782..717fe93 100644 --- a/firmware/src/hardware/FatFsFileIo.h +++ b/firmware/src/hardware/FatFsFileIo.h @@ -17,46 +17,102 @@ #pragma once +#include +#include +#include + +extern SD_HandleTypeDef hsd1; + class FatFsFileIo { public: - void ensureVolumeMounted() {} + FatFsFileIo() + { + Init(); + } + + void ensureVolumeMounted() + { + isMounted_ = f_mount(&fileSystem_.GetSDFileSystem(), fileSystem_.GetSDPath(), 1) == FR_OK; + + // In case of an error, reinitialize the peripheral and try again. + // Someone could have removed the SD and plugged it back in + if (!isMounted_) + { + // unmount + f_mount(nullptr, fileSystem_.GetSDPath(), 0); + fileSystem_.DeInit(); + + HAL_SD_DeInit(&hsd1); + memset(&hsd1, 0, sizeof(hsd1)); + + daisy::System::DelayUs(10000); + + Init(); + + isMounted_ = f_mount(&fileSystem_.GetSDFileSystem(), fileSystem_.GetSDPath(), 1) == FR_OK; + } + } bool makeFolderIfNotExistent(const char* folderName) { - (void) (folderName); - return false; + return f_mkdir(folderName) == FR_OK; } bool hasFile(const char* fileName) { - (void) (fileName); - return false; + const auto canOpenForReading = openForReading(fileName); + if (canOpenForReading) + { + closeFile(); + } + return canOpenForReading; } bool openForReading(const char* fileName) { - (void) (fileName); - return false; + const auto result = f_open(&file_, fileName, FA_OPEN_EXISTING | FA_READ); + return result == FR_OK; } bool openForWriting(const char* fileName) { - (void) (fileName); - return false; + const auto result = f_open(&file_, fileName, FA_CREATE_ALWAYS | FA_WRITE); + return result == FR_OK; } - bool write(void* data, size_t size) + int32_t write(const void* data, size_t size) { - return false; + UINT bytesWritten = 0; + const auto result = f_write(&file_, data, UINT(size), &bytesWritten); + return result == FR_OK ? int32_t(bytesWritten) : -1; } int32_t read(void* data, size_t maxSize) { - return -1; + UINT bytesRead = 0; + const auto result = f_read(&file_, data, UINT(maxSize), &bytesRead); + return result == FR_OK ? int32_t(bytesRead) : -1; } - void closeFile() + bool closeFile() { + const auto result = f_close(&file_); + return result == FR_OK; } + +private: + void Init() + { + daisy::SdmmcHandler::Config sdCfg; + sdCfg.Defaults(); + sdCfg.speed = daisy::SdmmcHandler::Speed::STANDARD; + sd_.Init(sdCfg); + fileSystem_.Init(daisy::FatFSInterface::Config::MEDIA_SD); + } + + bool isMounted_ = false; + daisy::SdmmcHandler sd_; + daisy::FatFSInterface fileSystem_; + FIL file_; }; \ No newline at end of file diff --git a/firmware/tests/AudioSaveAndRecall_gtest.cpp b/firmware/tests/AudioSaveAndRecall_gtest.cpp index 022d46e..13bedd2 100644 --- a/firmware/tests/AudioSaveAndRecall_gtest.cpp +++ b/firmware/tests/AudioSaveAndRecall_gtest.cpp @@ -110,15 +110,16 @@ class TestFileIo return true; } - bool write(void* data, size_t size) + int32_t write(void* data, size_t size) { if (simulateFileWriteError_) - return false; + return -1; dataWritten_.insert(dataWritten_.end(), static_cast(data), static_cast(data) + size); - return true; + + return int32_t(size); } int32_t read(void* data, size_t maxSize) From aa1e765be239d6692eb2211d6de614a52bd631cf Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Thu, 9 Oct 2025 23:35:51 +0200 Subject: [PATCH 25/26] chore: use a more up to date setup step in firmware CI build --- .github/workflows/firmware.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/firmware.yaml b/.github/workflows/firmware.yaml index c2e9a24..2d3a661 100644 --- a/.github/workflows/firmware.yaml +++ b/.github/workflows/firmware.yaml @@ -22,9 +22,9 @@ jobs: steps: - name: Install arm-none-eabi-gcc - uses: fiam/arm-none-eabi-gcc@v1 + uses: carlosperate/arm-none-eabi-gcc-action@v1 with: - release: '10-2020-q4' # The arm-none-eabi-gcc release to use. + release: '10-2020-q4' - name: Setup cmake uses: jwlawson/actions-setup-cmake@v1.9 From f2721066fd826af648da34b68142fd8a1ce610c5 Mon Sep 17 00:00:00 2001 From: TheSlowGrowth <9356320+TheSlowGrowth@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:16:43 +0200 Subject: [PATCH 26/26] chore: add .cache to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index dd4a973..1713b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ plugin/build # macOS crap .DS_Store +firmware/.cache