diff --git a/releases/62_BioMimicry/.gitignore b/releases/62_BioMimicry/.gitignore new file mode 100644 index 00000000..a8755f8a --- /dev/null +++ b/releases/62_BioMimicry/.gitignore @@ -0,0 +1,30 @@ +build/ +build-*/ + +# Generated from samples/*.raw by tools/mksamples.py at build time +samples.h + +# The converted one-shots ARE committed: they are the card's voice, and without +# them a fresh clone builds a silent instrument. Sourced from Pixabay and +# redistributable under the Pixabay Content License. +# +# Source WAVs dropped in for tools/importwav.py to convert are not — they are +# large and the converted .raw files are what the build actually uses. +samples/incoming/ + +# Host-side simulation harness binary +tools/simulate.exe +tools/*.o +__pycache__/ +*.pyc + +# The released firmware IS published, so the site can offer a download and +# compute its sha256. Working copies in FLASHME/ are not. +FLASHME/*.uf2 +FLASHME/*.bin +FLASHME/*.elf + +# Editor / OS noise +.vscode/ +.DS_Store +Thumbs.db diff --git a/releases/62_BioMimicry/CMakeLists.txt b/releases/62_BioMimicry/CMakeLists.txt new file mode 100644 index 00000000..ab840fef --- /dev/null +++ b/releases/62_BioMimicry/CMakeLists.txt @@ -0,0 +1,93 @@ +if(WIN32) + set(USERHOME $ENV{USERPROFILE}) +else() + set(USERHOME $ENV{HOME}) +endif() +set(sdkVersion 2.2.0) +set(toolchainVersion 14_2_Rel1) +set(picotoolVersion 2.2.0-a4) +set(picoVscode ${USERHOME}/.pico-sdk/cmake/pico-vscode.cmake) +if (EXISTS ${picoVscode}) + include(${picoVscode}) +endif() + +set(PICO_BOARD pico CACHE STRING "Board type") +cmake_minimum_required (VERSION 3.13) +include(pico_sdk_import.cmake) + +set(CARD_NAME "biomimicry") +project(${CARD_NAME} C CXX ASM) +set(CMAKE_CXX_STANDARD 17) +pico_sdk_init() + +# --- Optional baked-in PCM samples --------------------------------------- +# tools/mksamples.py writes samples.h from samples/*.raw (8-bit signed mono +# 48kHz). Neither the .raw files nor the generated header are committed: with no +# samples present the build still succeeds and alt-boot falls back to the +# synthesized voices (see samples_default.h). +find_package(Python3 COMPONENTS Interpreter) +set(SAMPLE_DIR ${CMAKE_CURRENT_LIST_DIR}/samples) +if (Python3_FOUND AND EXISTS ${SAMPLE_DIR}) + file(GLOB SAMPLE_FILES ${SAMPLE_DIR}/*.raw) + add_custom_command( + OUTPUT ${CMAKE_CURRENT_LIST_DIR}/samples.h + COMMAND ${Python3_EXECUTABLE} tools/mksamples.py samples samples.h + WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR} + DEPENDS tools/mksamples.py ${SAMPLE_FILES} + COMMENT "Generating samples.h from samples/*.raw") + add_custom_target(pcm_samples DEPENDS ${CMAKE_CURRENT_LIST_DIR}/samples.h) + # The generated header is included by voices.cpp (via samples_default.h); + # name it as a source dependency so editing a sample actually relinks. + set_source_files_properties(voices.cpp PROPERTIES + OBJECT_DEPENDS ${CMAKE_CURRENT_LIST_DIR}/samples.h) +endif() + +add_executable(${CARD_NAME} + main.cpp + engines.cpp + voices.cpp + fastmath.cpp + webui.cpp + usb_descriptors.c) + +if (TARGET pcm_samples) + add_dependencies(${CARD_NAME} pcm_samples) +endif() + +# Cycle-count ProcessSample and show the worst case on the LEDs. Build into a +# SEPARATE directory so the released firmware is never a profile build: +# cmake -B build-profile -G Ninja -DBIO_PROFILE=ON +option(BIO_PROFILE "Cycle-count ProcessSample and report on the LEDs" OFF) +if (BIO_PROFILE) + target_compile_definitions(${CARD_NAME} PRIVATE BIO_PROFILE=1) + message(STATUS "BIO_PROFILE on - this build reports timing, do not release it") +endif() + +target_compile_options(${CARD_NAME} PRIVATE -Wdouble-promotion -Wfloat-conversion -Wall -Wextra) +target_link_options(${CARD_NAME} PRIVATE -Wl,--print-memory-usage) +# The Workshop Computer's crystal needs a long startup delay before the PLL locks. +target_compile_definitions(${CARD_NAME} PRIVATE PICO_XOSC_STARTUP_DELAY_MULTIPLIER=64) + +# Project root, so #include "ComputerCard.h" and the card's own headers resolve. +target_include_directories(${CARD_NAME} PUBLIC ${CMAKE_CURRENT_LIST_DIR}) +target_link_libraries(${CARD_NAME} + pico_unique_id pico_stdlib pico_multicore + hardware_dma hardware_i2c hardware_pwm hardware_adc hardware_spi hardware_vreg + hardware_flash tinyusb_device tinyusb_board) + +# The last 1MB of flash is reserved for user samples uploaded over USB (see +# samplestore.h). Keep the firmware image out of it. +target_compile_definitions(${CARD_NAME} PRIVATE PICO_FLASH_SIZE_BYTES=2097152) + +# Guard the boundary. The firmware image (code + baked samples) must not reach +# 0x100000, or flashing it would overwrite uploaded user samples and an upload +# would overwrite the firmware. Baked audio is the thing most likely to push it +# over, so fail the build loudly rather than corrupt a card. +add_custom_command(TARGET ${CARD_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} + -DELF=$ + -DOBJDUMP=${CMAKE_OBJDUMP} + -DLIMIT=1048576 + -P ${CMAKE_CURRENT_LIST_DIR}/tools/checksize.cmake) +pico_add_extra_outputs(${CARD_NAME}) +pico_enable_stdio_usb(${CARD_NAME} 0) diff --git a/releases/62_BioMimicry/ComputerCard.h b/releases/62_BioMimicry/ComputerCard.h new file mode 100644 index 00000000..36f4273a --- /dev/null +++ b/releases/62_BioMimicry/ComputerCard.h @@ -0,0 +1,1200 @@ +/* +ComputerCard - by Chris Johnson + +version 0.3.0 - 12 May 2026 + +ComputerCard is a header-only C++ library, providing a class that +manages the hardware aspects of the Music Thing Modular Workshop +System Computer. + +It aims to present a very simple C++ interface for card programmers +to use the jacks, knobs, switch and LEDs, for programs running at +a fixed 48kHz audio sample rate. + +See examples/ directory +*/ + + +#ifndef COMPUTERCARD_H +#define COMPUTERCARD_H + +#include "hardware/gpio.h" +#include "hardware/pwm.h" + +#define PULSE_1_RAW_OUT 8 +#define PULSE_2_RAW_OUT 9 + +#define CV_OUT_1 23 +#define CV_OUT_2 22 + +// USB host status pin +#define USB_HOST_STATUS 20 + +class ComputerCard +{ + constexpr static int numLeds = 6; + constexpr static uint8_t leds[numLeds] = { 10, 11, 12, 13, 14, 15 }; +public: + + /// Knob index, used by KnobVal + enum Knob {Main, X, Y}; + /// Switch position, used by SwitchVal + enum Switch {Down, Middle, Up}; + /// Input jack socket, used by Connected and Disconnected + enum Input {Audio1, Audio2, CV1, CV2, Pulse1, Pulse2}; + /// Hardware version + enum HardwareVersion_t {Proto1=0x2a, Proto2_Rev1=0x30, Rev1_1=0x0C, Unknown=0xFF}; + /// USB Power state + enum USBPowerState_t {DFP, UFP, Unsupported}; + + ComputerCard(); + + /** \brief Start audio processing. + + The Run method starts audio processing, calling ProcessSample using an interrupt. + Run is a blocking function (it never returns) + */ + void Run() + { + ComputerCard::thisptr = this; + AudioWorker(); + } + + /// Use before Run() to enable Connected/Disconnected detection + void EnableNormalisationProbe() {useNormProbe = true;} + + static ComputerCard *ThisPtr() {return thisptr;} + +protected: + + class NotchFilter + { + public: + NotchFilter() + { + mix1 = mix2 = mixf1 = mixf2 = 0; + } + int32_t operator()(int32_t val) + { + int32_t mixf = (ooa0 * (val + mix2) - a2oa0 * mixf2) >> 14; + mix2 = mix1; + mix1 = val; + mixf2 = mixf1; + mixf1 = mixf; + return mixf; + } + private: + // 12kHz notch filter, to remove interference from mux lines + int32_t mix1, mix2, mixf1, mixf2; + static constexpr int32_t ooa0 = 16302, a2oa0 = 16221; // Q = 100, very narrow notch + + }; + + NotchFilter notchLeft, notchRight; + + /// Callback, called once per sample at 48kHz + virtual void ProcessSample() = 0; + + + + + /// Read knob position (returns 0-4095) + int32_t __not_in_flash_func(KnobVal)(Knob ind) {return knobs[ind];} + + /// Read switch position + Switch __not_in_flash_func(SwitchVal)() {return switchVal;} + + /// Read switch position + bool __not_in_flash_func(SwitchChanged)() {return switchVal != lastSwitchVal;} + + + /// Set Audio output (values -2048 to 2047) + void __not_in_flash_func(AudioOut)(int i, int16_t val) + { + dacOut[i] = val; + } + + /// Set Audio 1 output (values -2048 to 2047) + void __not_in_flash_func(AudioOut1)(int16_t val) + { + dacOut[0] = val; + } + + /// Set Audio 2 output (values -2048 to 2047) + void __not_in_flash_func(AudioOut2)(int16_t val) + { + dacOut[1] = val; + } + + + /// Set CV output (values -2048 to 2047) + void __not_in_flash_func(CVOut)(int i, int16_t val) + { + if (val<-2048) val = -2048; + if (val > 2047) val = 2047; + cvValue[i] = (2047-val)*125; + } + + /// Set CV 1 output (values -2048 to 2047) + void __not_in_flash_func(CVOut1)(int16_t val) + { + if (val<-2048) val = -2048; + if (val > 2047) val = 2047; + cvValue[0] = (2047-val)*125; + } + + /// Set CV 2 output (values -2048 to 2047) + void __not_in_flash_func(CVOut2)(int16_t val) + { + if (val<-2048) val = -2048; + if (val > 2047) val = 2047; + cvValue[1] = (2047-val)*125; + } + + + /// Set CV output (values -262144 to 262143) + void __not_in_flash_func(CVOutPrecise)(int i, int32_t val) + { + if (val<-262144) val = -262144; + if (val > 262143) val = 262143; + cvValue[i] = ((262143-val)*125)>>7; + } + + /// Set CV 1 output (values -262144 to 262143) + void __not_in_flash_func(CVOut1Precise)(int32_t val) + { + if (val<-262144) val = -262144; + if (val > 262143) val = 262143; + cvValue[0] = ((262143-val)*125)>>7; + } + + /// Set CV 2 output (values -262144 to 262143) + void __not_in_flash_func(CVOut2Precise)(int32_t val) + { + if (val<-262144) val = -262144; + if (val > 262143) val = 262143; + cvValue[1] = ((262143-val)*125)>>7; + } + + /// Set CV 1 output from calibrated MIDI note number (values 0 to 127) + void __not_in_flash_func(CVOutMIDINote)(int i, uint8_t noteNum) + { + cvValue[i] = MIDIToDAC(noteNum, i); + } + + /// Set CV 1 output from calibrated MIDI note number (values 0 to 127) + void __not_in_flash_func(CVOut1MIDINote)(uint8_t noteNum) + { + cvValue[0] = MIDIToDAC(noteNum, 0); + } + + /// Set CV 2 output from calibrated MIDI note number (values 0 to 127) + void __not_in_flash_func(CVOut2MIDINote)(uint8_t noteNum) + { + cvValue[1] = MIDIToDAC(noteNum, 1); + } + + + /// Set CV 1 output from calibrated MIDI note number (values 0 to 127) + bool __not_in_flash_func(CVOutMillivolts)(int i, int32_t millivolts) + { + bool limited = false; + cvValue[i] = MillivoltsToDAC(millivolts, i, limited); + return limited; + } + + /// Set CV 1 output from calibrated MIDI note number (values 0 to 127) + bool __not_in_flash_func(CVOut1Millivolts)(int32_t millivolts) + { + bool limited = false; + cvValue[0] = MillivoltsToDAC(millivolts, 0, limited); + return limited; + } + + /// Set CV 2 output from calibrated MIDI note number (values 0 to 127) + bool __not_in_flash_func(CVOut2Millivolts)(int32_t millivolts) + { + bool limited = false; + cvValue[1] = MillivoltsToDAC(millivolts, 1, limited); + return limited; + } + + + /// Set Pulse output (true = on) + void __not_in_flash_func(PulseOut)(int i, bool val) + { + gpio_put(PULSE_1_RAW_OUT + i, !val); + } + + /// Set Pulse 1 output (true = on) + void __not_in_flash_func(PulseOut1)(bool val) + { + gpio_put(PULSE_1_RAW_OUT, !val); + } + + /// Set Pulse 2 output (true = on) + void __not_in_flash_func(PulseOut2)(bool val) + { + gpio_put(PULSE_2_RAW_OUT, !val); + } + + /// Return audio in (-2048 to 2047) + int16_t __not_in_flash_func(AudioIn)(int i){return i?adcInR:adcInL;} + + /// Return audio in 1 (-2048 to 2047) + int16_t __not_in_flash_func(AudioIn1)(){return adcInL;} + + /// Return audio in 1 (-2048 to 2047) + int16_t __not_in_flash_func(AudioIn2)(){return adcInR;} + + /// Return CV in (-2048 to 2047) + int16_t __not_in_flash_func(CVIn)(int i){return cv[i];} + + /// Return CV in 1 (-2048 to 2047) + int16_t __not_in_flash_func(CVIn1)(){return cv[0];} + + /// Return CV in 2 (-2048 to 2047) + int16_t __not_in_flash_func(CVIn2)(){return cv[1];} + + /// Read pulse in + bool __not_in_flash_func(PulseIn)(int i){return pulse[i];} + /// Return true for one sample on pulse rising edge + bool __not_in_flash_func(PulseInRisingEdge)(int i){return pulse[i] && !last_pulse[i];} + /// Return true for one sample on pulse falling edge + bool __not_in_flash_func(PulseInFallingEdge)(int i){return !pulse[i] && last_pulse[i];} + + /// Read pulse in 1 + bool __not_in_flash_func(PulseIn1)(){return pulse[0];} + /// Return true for one sample on pulse 1 rising edge + bool __not_in_flash_func(PulseIn1RisingEdge)(){return pulse[0] && !last_pulse[0];} + /// Return true for one sample on pulse 1 falling edge + bool __not_in_flash_func(PulseIn1FallingEdge)(){return !pulse[0] && last_pulse[0];} + + /// Read pulse in 2 + bool __not_in_flash_func(PulseIn2)(){return pulse[1];} + /// Return true for one sample on pulse 2 falling edge + bool __not_in_flash_func(PulseIn2FallingEdge)(){return !pulse[1] && last_pulse[1];} + /// Return true for one sample on pulse 2 rising edge + bool __not_in_flash_func(PulseIn2RisingEdge)(){return pulse[1] && !last_pulse[1];} + + + /// Return true if jack connected to input + bool __not_in_flash_func(Connected)(Input i){return connected[i];} + /// Return true if no jack connected to input + bool __not_in_flash_func(Disconnected)(Input i){return !connected[i];} + + + /// Set LED brightness, values 0-4095 + // Led numbers are: + // 0 1 + // 2 3 + // 4 5 + void __not_in_flash_func(LedBrightness)(uint32_t index, uint16_t value) + { + pwm_set_gpio_level(leds[index], (value*value)>>8); + } + + /// Turn LED on/off + void __not_in_flash_func(LedOn)(uint32_t index, bool value = true) + { + pwm_set_gpio_level(leds[index], value?65535:0); + } + + /// Turn LED off + void __not_in_flash_func(LedOff)(uint32_t index) + { + pwm_set_gpio_level(leds[index], 0); + } + + // Return power state of USB port + USBPowerState_t USBPowerState() + { + if (HardwareVersion() != Rev1_1) + return Unsupported; + else if (gpio_get(USB_HOST_STATUS)) + return UFP; + else + return DFP; + } + + /// Return hardware version + HardwareVersion_t HardwareVersion() const + { + return hw; + } + + /// Return ID number unique to flash card + uint64_t UniqueCardID() const + { + return uniqueID; + } + + /// Return true iff CV outputs are calibrated. + /// Returns false if using default calibration values. + bool CVOutsCalibrated() const + { + return cvOutsCalibrated; + } + + + void Abort(); + + uint16_t CRCencode(const uint8_t *data, int length); + +private: + + typedef struct + { + float m, b; + int32_t mi, bi; + } CalCoeffs; + + typedef struct + { + int32_t dacSetting; + int8_t voltage; + } CalPoint; + + static constexpr int calMaxChannels = 2; + static constexpr int calMaxPoints = 10; + + static volatile uint32_t cvValue[2]; + + uint8_t numCalibrationPoints[calMaxChannels]; + CalPoint calibrationTable[calMaxChannels][calMaxPoints]; + CalCoeffs calCoeffs[calMaxChannels]; + + uint64_t uniqueID; + + uint8_t ReadByteFromEEPROM(unsigned int eeAddress, bool &failed); + int ReadIntFromEEPROM(unsigned int eeAddress, bool &failed); + void CalcCalCoeffs(int channel); + int ReadEEPROM(); + uint32_t MIDIToDAC(int midiNote, int channel); + uint32_t MillivoltsToDAC(int millivolts, int channel, bool &limited); + + HardwareVersion_t hw; + HardwareVersion_t ProbeHardwareVersion(); + + int16_t dacOut[2]; + + volatile int32_t knobs[4] = { 0, 0, 0, 0 }; // 0-4095 + volatile bool pulse[2] = { 0, 0 }; + volatile bool last_pulse[2] = { 0, 0 }; + volatile int32_t cv[2] = { 0, 0 }; // -2047 - 2048 + volatile int16_t adcInL = 0x800, adcInR = 0x800; + + volatile uint8_t mxPos = 0; // external multiplexer value + + volatile int32_t plug_state[6] = {0,0,0,0,0,0}; + volatile bool connected[6] = {0,0,0,0,0,0}; + bool useNormProbe; + + Switch switchVal, lastSwitchVal; + + volatile uint8_t runADCMode; + + bool cvOutsCalibrated; + +// Buffers that DMA reads into / out of + uint16_t ADC_Buffer[2][8]; + uint16_t SPI_Buffer[2][2]; + + uint8_t adc_dma, spi_dma; // DMA ids + + + + uint8_t dmaPhase = 0; + + // Convert signed int16 value into data string for DAC output + uint16_t __not_in_flash_func(dacval)(int16_t value, uint16_t dacChannel) + { + if (value<-2048) value = -2048; + if (value > 2047) value = 2047; + return (dacChannel | 0x3000) | (((uint16_t)((value & 0x0FFF) + 0x800)) & 0x0FFF); + } + uint32_t next_norm_probe(); + + + void CorrectADCDNL(uint16_t &value) const; + + void BufferFull(); + + void AudioWorker(); + + static void AudioCallback() + { + thisptr->BufferFull(); + } + static ComputerCard *thisptr; + + // 19-bit CV outputs + static void OnCVPWMWrap() + { + static int32_t error1 = 0, error2 = 0; + + pwm_clear_irq(pwm_gpio_to_slice_num(CV_OUT_1)); // clear the interrupt flag + uint32_t truncated_cv1_val = (cvValue[0]-error1) & 0xFFFFFF00; + error1 += truncated_cv1_val - cvValue[0]; + pwm_set_gpio_level(CV_OUT_1, (truncated_cv1_val>>8)); + uint32_t truncated_cv2_val = (cvValue[1]-error2) & 0xFFFFFF00; + error2 += truncated_cv2_val - cvValue[1]; + pwm_set_gpio_level(CV_OUT_2, (truncated_cv2_val>>8)); + } + +}; + + +#ifndef COMPUTERCARD_NOIMPL + + +#include "hardware/adc.h" +#include "hardware/clocks.h" +#include "hardware/dma.h" +#include "hardware/flash.h" +#include "hardware/i2c.h" +#include "hardware/irq.h" +#include "hardware/spi.h" + +// Input normalisation probe pin +#define NORMALISATION_PROBE 4 + +// Mux pins +#define MX_A 24 +#define MX_B 25 + +// ADC input pins +#define AUDIO_L_IN_1 27 +#define AUDIO_R_IN_1 26 +#define MUX_IO_1 28 +#define MUX_IO_2 29 + +#define DAC_CHANNEL_A 0x0000 +#define DAC_CHANNEL_B 0x8000 + +#define DAC_CS 21 +#define DAC_SCK 18 +#define DAC_TX 19 + +#define EEPROM_SDA 16 +#define EEPROM_SCL 17 + +#define PULSE_1_INPUT 2 +#define PULSE_2_INPUT 3 + +#define DEBUG_1 0 +#define DEBUG_2 1 + +#define SPI_PORT spi0 +#define SPI_DREQ DREQ_SPI0_TX + + +#define BOARD_ID_0 7 +#define BOARD_ID_1 6 +#define BOARD_ID_2 5 + +// The ADC (/DMA) run mode, used to stop DMA in a known state before writing to flash +#define RUN_ADC_MODE_RUNNING 0 +#define RUN_ADC_MODE_REQUEST_ADC_STOP 1 +#define RUN_ADC_MODE_ADC_STOPPED 2 +#define RUN_ADC_MODE_REQUEST_ADC_RESTART 3 + + +#define EEPROM_ADDR_ID 0 +#define EEPROM_ADDR_VERSION 2 +#define EEPROM_ADDR_CRC_L 87 +#define EEPROM_ADDR_CRC_H 86 +#define EEPROM_VAL_ID 2001 +#define EEPROM_NUM_BYTES 88 + +#define EEPROM_PAGE_ADDRESS 0x50 + + +// Initialise CV output delta-sigma target to half-way (near 0V) +volatile uint32_t ComputerCard::cvValue[2] = {262144,262144}; + + +ComputerCard *ComputerCard::thisptr; + +// Return pseudo-random bit for normalisation probe +uint32_t __not_in_flash_func(ComputerCard::next_norm_probe)() +{ + static uint32_t lcg_seed = 1; + lcg_seed = 1664525 * lcg_seed + 1013904223; + return lcg_seed >> 31; +} + +// Main audio core function +void __not_in_flash_func(ComputerCard::AudioWorker)() +{ + + adc_select_input(0); + adc_set_round_robin(0b0001111U); + + // enabled, with DMA request when FIFO contains data, no erro flag, no byte shift + adc_fifo_setup(true, true, 1, false, false); + + + // ADC clock runs at 48MHz + // 48MHz ÷ (124+1) = 384kHz ADC sample rate + // = 8×48kHz audio sample rate + adc_set_clkdiv(124); + + // claim and setup DMAs for reading to ADC, and writing to SPI DAC + adc_dma = dma_claim_unused_channel(true); + spi_dma = dma_claim_unused_channel(true); + + dma_channel_config adc_dmacfg, spi_dmacfg; + adc_dmacfg = dma_channel_get_default_config(adc_dma); + spi_dmacfg = dma_channel_get_default_config(spi_dma); + + // Reading from ADC into memory buffer, so increment on write, but no increment on read + channel_config_set_transfer_data_size(&adc_dmacfg, DMA_SIZE_16); + channel_config_set_read_increment(&adc_dmacfg, false); + channel_config_set_write_increment(&adc_dmacfg, true); + + // Synchronise ADC DMA the ADC samples + channel_config_set_dreq(&adc_dmacfg, DREQ_ADC); + + // Setup DMA for 8 ADC samples + dma_channel_configure(adc_dma, &adc_dmacfg, ADC_Buffer[dmaPhase], &adc_hw->fifo, 8, true); + + // Turn on IRQ for ADC DMA + dma_channel_set_irq0_enabled(adc_dma, true); + + // Call buffer_full ISR when ADC DMA finished + irq_set_enabled(DMA_IRQ_0, true); + irq_set_exclusive_handler(DMA_IRQ_0, ComputerCard::AudioCallback); + + + // Turn on IRQ for CV output PWM + uint slice_num = pwm_gpio_to_slice_num(CV_OUT_1); + pwm_clear_irq(slice_num); + pwm_set_irq_enabled(slice_num, true); + + irq_set_exclusive_handler(PWM_IRQ_WRAP, ComputerCard::OnCVPWMWrap); + irq_set_priority(PWM_IRQ_WRAP, 255); + irq_set_enabled(PWM_IRQ_WRAP, true); + + + // Set up DMA for SPI + spi_dmacfg = dma_channel_get_default_config(spi_dma); + channel_config_set_transfer_data_size(&spi_dmacfg, DMA_SIZE_16); + + // SPI DMA timed to SPI TX + channel_config_set_dreq(&spi_dmacfg, SPI_DREQ); + + // Set up DMA to transmit 2 samples to SPI + dma_channel_configure(spi_dma, &spi_dmacfg, &spi_get_hw(SPI_PORT)->dr, NULL, 2, false); + + // Pre-fill SPI buffers with 0V DAC words so the first DMA transfer outputs + // silence rather than uninitialised RAM, eliminating the power-on click. + uint16_t silence_a = dacval(0, DAC_CHANNEL_A); + uint16_t silence_b = dacval(0, DAC_CHANNEL_B); + for (int i = 0; i < 2; i++) { + SPI_Buffer[i][0] = silence_a; + SPI_Buffer[i][1] = silence_b; + } + + adc_run(true); + + while (1) + { + // If ready to restart + if (runADCMode == RUN_ADC_MODE_REQUEST_ADC_RESTART) + { + runADCMode = RUN_ADC_MODE_RUNNING; + + dma_hw->ints0 = 1u << adc_dma; // reset adc interrupt flag + dma_channel_set_write_addr(adc_dma, ADC_Buffer[dmaPhase], true); // start writing into new buffer + dma_channel_set_read_addr(spi_dma, SPI_Buffer[dmaPhase], true); // start reading from new buffer + + adc_set_round_robin(0); + adc_select_input(0); + adc_set_round_robin(0b0001111U); + adc_run(true); + } + else if (runADCMode == RUN_ADC_MODE_ADC_STOPPED) + { + // We can't remove the PWM IRQ from within the ADC IRQ callback, so we do it here instead. + irq_set_enabled(PWM_IRQ_WRAP, false); + pwm_clear_irq(pwm_gpio_to_slice_num(CV_OUT_1)); // reset CV PWM interrupt flag + irq_remove_handler(PWM_IRQ_WRAP, ComputerCard::OnCVPWMWrap); + break; + } + + + } +} + +void ComputerCard::Abort() +{ + runADCMode = RUN_ADC_MODE_REQUEST_ADC_STOP; +} + +void __not_in_flash_func(ComputerCard::CorrectADCDNL)(uint16_t &value) const +{ + uint16_t adc512 = value + 512; + value += ((value & 0x3FF) == 0x1FF) << 2; + value += (adc512 >> 10) << 3; + value = uint32_t(value * 520349) >> 19; // Multiply by factor that maps 0-4095 input into 0-4095 output +} + +// Per-audio-sample ISR, called when two sets of ADC samples have been collected from all four inputs +void __not_in_flash_func(ComputerCard::BufferFull)() +{ + static int startupCounter = 8; // Decreases by 1 each sample, can do startup things when nonzero. + static int mux_state = 0; + static int norm_probe_count = 0; + + // Internal variables for IIR filters on knobs/cv + static volatile int32_t knobssm[4] = { 0, 0, 0, 0 }; + static volatile int32_t cvsm[2] = { 0, 0 }; + __attribute__((unused)) static int np = 0, np1 = 0, np2 = 0; + + // Stop ADC before touching AINSEL — guarantees any in-progress conversion + // finishes and lands in the FIFO before we drain it, so DMA re-arm always + // starts on ch0 with an empty FIFO. adc_select_input() mid-conversion is + // not enough: the current conversion completes on the old channel first and + // that sample ends up as ADC_Buffer[n][0], shifting the burst by 1–3 slots. + hw_clear_bits(&adc_hw->cs, ADC_CS_START_MANY_BITS); + while (!(adc_hw->cs & ADC_CS_READY_BITS)) {} // wait for any in-progress conversion to complete + while (!adc_fifo_is_empty()) (void)adc_fifo_get(); + adc_select_input(0); + + // Advance external mux to next state + int next_mux_state = (mux_state + 1) & 0x3; + gpio_put(MX_A, next_mux_state & 1); + gpio_put(MX_B, next_mux_state & 2); + + // Set up new writes into next buffer + uint8_t cpuPhase = dmaPhase; + dmaPhase = 1 - dmaPhase; + + dma_hw->ints0 = 1u << adc_dma; // reset adc interrupt flag + dma_channel_set_write_addr(adc_dma, ADC_Buffer[dmaPhase], true); // start writing into new buffer + dma_channel_set_read_addr(spi_dma, SPI_Buffer[dmaPhase], true); // start reading from new buffer + hw_set_bits(&adc_hw->cs, ADC_CS_START_MANY_BITS); + + //////////////////////////////////////// + // Collect various inputs and put them in variables for the DSP + + // Set CV inputs, with ~240Hz LPF on CV input + int cvi = mux_state % 2; + + // Compensation of ADC DNL errors. + CorrectADCDNL(ADC_Buffer[cpuPhase][7]); // CV inputs + CorrectADCDNL(ADC_Buffer[cpuPhase][0]); // Audio inputs + CorrectADCDNL(ADC_Buffer[cpuPhase][4]); + CorrectADCDNL(ADC_Buffer[cpuPhase][1]); + CorrectADCDNL(ADC_Buffer[cpuPhase][5]); + + cvsm[cvi] = (15 * (cvsm[cvi]) + 16 * ADC_Buffer[cpuPhase][7]) >> 4; + cv[cvi] = 2048 - (cvsm[cvi] >> 4); + + + // Set audio inputs, by averaging the two samples collected. + // Invert to counteract inverting op-amp input configuration + adcInR = -(((ADC_Buffer[cpuPhase][0] + ADC_Buffer[cpuPhase][4]) - 0x1000) >> 1); + adcInL = -(((ADC_Buffer[cpuPhase][1] + ADC_Buffer[cpuPhase][5]) - 0x1000) >> 1); + + // 12kHz notch filters + adcInR = notchRight(adcInR); + adcInL = notchLeft(adcInL); + + // Set pulse inputs + last_pulse[0] = pulse[0]; + last_pulse[1] = pulse[1]; + pulse[0] = !gpio_get(PULSE_1_INPUT); + pulse[1] = !gpio_get(PULSE_2_INPUT); + + // Set knobs, with ~60Hz LPF + int knob = mux_state; + knobssm[knob] = (127 * (knobssm[knob]) + 16 * ADC_Buffer[cpuPhase][6]) >> 7; + knobs[knob] = knobssm[knob] >> 4; + + // Set switch value + switchVal = static_cast((knobs[3]>1000) + (knobs[3]>3000)); + if (startupCounter) + { + // Don't detect switch changes in first few cycles + lastSwitchVal = switchVal; + // Should initialise knob and CV smoothing filters here too + } + + //////////////////////////// + // Normalisation probe + + if (useNormProbe) + { + // Set normalisation probe output value + // and update np to the expected history string + if (norm_probe_count == 0) + { + int32_t normprobe = next_norm_probe(); + gpio_put(NORMALISATION_PROBE, normprobe); + np = (np<<1)+(normprobe&0x1); + } + + // CV sampled at 24kHz comes in over two successive samples + if (norm_probe_count == 14 || norm_probe_count == 15) + { + plug_state[2+cvi] = (plug_state[2+cvi]<<1)+(ADC_Buffer[cpuPhase][7]<1800); + } + + // Audio and pulse measured every sample at 48kHz + if (norm_probe_count == 15) + { + plug_state[Input::Audio1] = (plug_state[Input::Audio1]<<1)+(ADC_Buffer[cpuPhase][5]<1800); + plug_state[Input::Audio2] = (plug_state[Input::Audio2]<<1)+(ADC_Buffer[cpuPhase][4]<1800); + plug_state[Input::Pulse1] = (plug_state[Input::Pulse1]<<1)+(pulse[0]); + plug_state[Input::Pulse2] = (plug_state[Input::Pulse2]<<1)+(pulse[1]); + + for (int i=0; i<6; i++) + { + connected[i] = (np != plug_state[i]); + } + } + + // Force disconnected values to zero, rather than the normalisation probe garbage + if (Disconnected(Input::Audio1)) adcInL = 0; + if (Disconnected(Input::Audio2)) adcInR = 0; + if (Disconnected(Input::CV1)) cv[0] = 0; + if (Disconnected(Input::CV2)) cv[1] = 0; + if (Disconnected(Input::Pulse1)) pulse[0] = 0; + if (Disconnected(Input::Pulse2)) pulse[1] = 0; + } + + //////////////////////////////////////// + // Run the DSP + ProcessSample(); + + //////////////////////////////////////// + // Collect DSP outputs and put them in the DAC SPI buffer + // CV/Pulse outputs are done immediately in ProcessSample + + // Invert dacout to counteract inverting output configuration + SPI_Buffer[cpuPhase][0] = dacval(-dacOut[0], DAC_CHANNEL_A); + SPI_Buffer[cpuPhase][1] = dacval(-dacOut[1], DAC_CHANNEL_B); + + mux_state = next_mux_state; + + // If Abort called, stop ADC and DMA + if (runADCMode == RUN_ADC_MODE_REQUEST_ADC_STOP) + { + adc_run(false); + adc_set_round_robin(0); + adc_select_input(0); + + dma_hw->ints0 = 1u << adc_dma; // reset adc interrupt flag + dma_channel_cleanup(adc_dma); + dma_channel_cleanup(spi_dma); + irq_set_enabled(DMA_IRQ_0, false); + irq_remove_handler(DMA_IRQ_0, ComputerCard::AudioCallback); + + + + runADCMode = RUN_ADC_MODE_ADC_STOPPED; + } + + norm_probe_count = (norm_probe_count + 1) & 0xF; + + lastSwitchVal = switchVal; + + if (startupCounter) startupCounter--; +} + +ComputerCard::HardwareVersion_t ComputerCard::ProbeHardwareVersion() +{ + // Enable pull-downs, and measure + gpio_set_pulls(BOARD_ID_0, false, true); + gpio_set_pulls(BOARD_ID_1, false, true); + gpio_set_pulls(BOARD_ID_2, false, true); + sleep_us(1); + + // Pull-down state in bits 0, 2, 4 + uint8_t pd = gpio_get(BOARD_ID_0) | (gpio_get(BOARD_ID_1) << 2) | (gpio_get(BOARD_ID_2) << 4); + + // Enable pull-ups, and measure + gpio_set_pulls(BOARD_ID_0, true, false); + gpio_set_pulls(BOARD_ID_1, true, false); + gpio_set_pulls(BOARD_ID_2, true, false); + sleep_us(1); + + // Pull-up state in bits 1, 3, 5 + uint8_t pu = (gpio_get(BOARD_ID_0) << 1) | (gpio_get(BOARD_ID_1) << 3) | (gpio_get(BOARD_ID_2) << 5); + + // Combine to give 6-bit ID + uint8_t id = pd | pu; + + // Set pull-downs + gpio_set_pulls(BOARD_ID_0, false, true); + gpio_set_pulls(BOARD_ID_1, false, true); + gpio_set_pulls(BOARD_ID_2, false, true); + + switch (id) + { + case Proto1: + case Proto2_Rev1: + case Rev1_1: + return static_cast(id); + default: + return Unknown; + } +} + +ComputerCard::ComputerCard() +{ + runADCMode = RUN_ADC_MODE_RUNNING; + + adc_run(false); + adc_select_input(0); + + + useNormProbe = false; + for (int i=0; i<6; i++) + { + connected[i] = false; + } + + + //////////////////////////////////////// + // Initialise LEDs (PWM, set up in pairs due pinout and PWM hardware) + for (int i = 0; i < numLeds; i+=2) + { + gpio_set_function(leds[i], GPIO_FUNC_PWM); + gpio_set_function(leds[i]+1, GPIO_FUNC_PWM); + + // now create PWM config struct + pwm_config config = pwm_get_default_config(); + pwm_config_set_wrap(&config, 65535); // 16-bit PWM + + + // now set this PWM config to apply to the two outputs + pwm_init(pwm_gpio_to_slice_num(leds[i]), &config, true); + pwm_init(pwm_gpio_to_slice_num(leds[i]+1), &config, true); + + // set initial level + pwm_set_gpio_level(leds[i], 0); + pwm_set_gpio_level(leds[i]+1, 0); + } + + + //////////////////////////////////////// + // Initialise knobs / audio in / CV in (ADC + Mux) + + adc_init(); // Initialize the ADC + + // Set ADC pins + adc_gpio_init(AUDIO_L_IN_1); + adc_gpio_init(AUDIO_R_IN_1); + adc_gpio_init(MUX_IO_1); + adc_gpio_init(MUX_IO_2); + + // Initialize Mux Control pins + gpio_init(MX_A); + gpio_init(MX_B); + gpio_set_dir(MX_A, GPIO_OUT); + gpio_set_dir(MX_B, GPIO_OUT); + + + //////////////////////////////////////// + + gpio_init(PULSE_1_RAW_OUT); + gpio_set_dir(PULSE_1_RAW_OUT, GPIO_OUT); + gpio_put(PULSE_1_RAW_OUT, true); // set raw value high (output low) + + + gpio_init(PULSE_2_RAW_OUT); + gpio_set_dir(PULSE_2_RAW_OUT, GPIO_OUT); + gpio_put(PULSE_2_RAW_OUT, true); // set raw value high (output low) + + + //////////////////////////////////////// + // Initialise pulse inputs + gpio_init(PULSE_1_INPUT); + gpio_set_dir(PULSE_1_INPUT, GPIO_IN); + gpio_pull_up(PULSE_1_INPUT); // NB Needs pullup to activate transistor on inputs + + gpio_init(PULSE_2_INPUT); + gpio_set_dir(PULSE_2_INPUT, GPIO_IN); + gpio_pull_up(PULSE_2_INPUT); // NB: Needs pullup to activate transistor on inputs + + + //////////////////////////////////////// + // Initialise audio outputs (SPI for external DAC) + spi_init(SPI_PORT, 15625000); + spi_set_format(SPI_PORT, 16, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST); + gpio_set_function(DAC_SCK, GPIO_FUNC_SPI); + gpio_set_function(DAC_TX, GPIO_FUNC_SPI); + gpio_set_function(DAC_CS, GPIO_FUNC_SPI); + + + //////////////////////////////////////// + // Initialise CV outputs + // We set up the PWM here, and add the IRQ for sigma-delta later one Run() is called + + // First, tell the CV pins that the PWM is in charge of the value. + gpio_set_function(CV_OUT_1, GPIO_FUNC_PWM); + gpio_set_function(CV_OUT_2, GPIO_FUNC_PWM); + + // now create PWM config struct + { + pwm_config config = pwm_get_default_config(); + pwm_config_set_wrap(&config, 1999); // less than 11-bit PWM + // now set this PWM config to apply to the two outputs + // NB: CV_A and CV_B share the same PWM slice, which means that they share a PWM config + // They have separate 'gpio_level's (output compare unit) though, so they can have different PWM on-times + pwm_init(pwm_gpio_to_slice_num(CV_OUT_1), &config, true); // Slice 1, channel A + pwm_init(pwm_gpio_to_slice_num(CV_OUT_2), &config, true); // slice 1 channel B (redundant to set up again) + + } + // set initial level to half way (0V) + pwm_set_gpio_level(CV_OUT_1, 1000); + pwm_set_gpio_level(CV_OUT_2, 1000); + + + //////////////////////////////////////// + // Miscellaneous pins + + // Initialise board version ID pins + gpio_init(BOARD_ID_0); + gpio_init(BOARD_ID_1); + gpio_init(BOARD_ID_2); + gpio_set_dir(BOARD_ID_0, GPIO_IN); + gpio_set_dir(BOARD_ID_1, GPIO_IN); + gpio_set_dir(BOARD_ID_2, GPIO_IN); + + // Initialise USB host status pin + gpio_init(USB_HOST_STATUS); + gpio_disable_pulls(USB_HOST_STATUS); + + // Initialise normalisation probe pin + gpio_init(NORMALISATION_PROBE); + gpio_set_dir(NORMALISATION_PROBE, GPIO_OUT); + gpio_put(NORMALISATION_PROBE, false); + + // Initialise EEPROM (I2C) + i2c_init(i2c0, 100 * 1000); + gpio_set_function(EEPROM_SDA, GPIO_FUNC_I2C); + gpio_set_function(EEPROM_SCL, GPIO_FUNC_I2C); + + + // If not using UART pins for UART, instead use as debug lines +#ifndef ENABLE_UART_DEBUGGING + // Debug pins + gpio_init(DEBUG_1); + gpio_set_dir(DEBUG_1, GPIO_OUT); + + gpio_init(DEBUG_2); + gpio_set_dir(DEBUG_2, GPIO_OUT); +#endif + + // Read hardware version + hw = ProbeHardwareVersion(); + + // Read EEPROM calibration values + cvOutsCalibrated = (ReadEEPROM() == 0); + + // Read unique card ID + flash_get_unique_id((uint8_t *) &uniqueID); + // Do some mixing up of the bits using full-cycle 64-bit LCG + // Should help ensure most bytes change even if many bits of + // the original flash unique ID are the same between flash chips. + for (int i=0; i<20; i++) + { + uniqueID = uniqueID * 6364136223846793005ULL + 1442695040888963407ULL; + } +} + + + +// Read a byte from EEPROM +uint8_t ComputerCard::ReadByteFromEEPROM(unsigned int eeAddress, bool &failed) +{ + uint8_t deviceAddress = EEPROM_PAGE_ADDRESS | ((eeAddress >> 8) & 0x0F); + uint8_t data = 0xFF; + + uint8_t addr_low_byte = eeAddress & 0xFF; + + if (i2c_write_timeout_us(i2c0, deviceAddress, &addr_low_byte, 1, false, 10000) <= 0) + { + failed = true; + return 0; + } + + if (i2c_read_timeout_us(i2c0, deviceAddress, &data, 1, false, 10000) <= 0) + { + failed = true; + return 0; + } + + return data; +} + +// Read a 16-bit integer from EEPROM +int ComputerCard::ReadIntFromEEPROM(unsigned int eeAddress, bool &failed) +{ + uint8_t highByte = ReadByteFromEEPROM(eeAddress, failed); + uint8_t lowByte = ReadByteFromEEPROM(eeAddress + 1, failed); + + return (highByte << 8) | lowByte; +} + +uint16_t ComputerCard::CRCencode(const uint8_t *data, int length) +{ + uint16_t crc = 0xFFFF; // Initial CRC value + for (int i = 0; i < length; i++) + { + crc ^= ((uint16_t)data[i]) << 8; // Bring in the next byte + for (uint8_t bit = 0; bit < 8; bit++) + { + if (crc & 0x8000) + { + crc = (crc << 1) ^ 0x1021; // CRC-CCITT polynomial + } + else + { + crc = crc << 1; + } + } + } + return crc; +} + + +int ComputerCard::ReadEEPROM() +{ + // Set up default values in the calibration table, + // to be used if we can't read valid calibration from EEPROM + for (unsigned channel = 0; channel < calMaxChannels; channel++) + { + numCalibrationPoints[channel] = 3; + calibrationTable[channel][0].voltage = -20; // -2V + calibrationTable[channel][0].dacSetting = 347700; + calibrationTable[channel][1].voltage = 0; // 0V + calibrationTable[channel][1].dacSetting = 261200; + calibrationTable[channel][2].voltage = 20; // +2V + calibrationTable[channel][2].dacSetting = 174400; + CalcCalCoeffs(channel); // calculate the coefficients + } + + // Read magic number + // Failure here could occur if I2C failed, or if incorrect/no magic number stored in EEPROM + bool i2cFailed = false; + if (ReadIntFromEEPROM(EEPROM_ADDR_ID, i2cFailed) != EEPROM_VAL_ID) + { + return 1; + } + + // Read the EEPROM into RAM + uint8_t buf[EEPROM_NUM_BYTES]; + for (int i = 0; i < EEPROM_NUM_BYTES; i++) + { + buf[i] = ReadByteFromEEPROM(i, i2cFailed); + } + + // Check CRC and fail if incorrect + uint16_t calculatedCRC = CRCencode(buf, 86); + uint16_t foundCRC = ((uint16_t)buf[EEPROM_ADDR_CRC_H] << 8) | buf[EEPROM_ADDR_CRC_L]; + if (calculatedCRC != foundCRC) + { + return 1; + } + + // CRC passed, so now read the calibration information + for (uint8_t channel = 0; channel < calMaxChannels; channel++) + { + int channelOffset = 4 + (41 * channel); // channel 0 = 4, channel 1 = 45 + numCalibrationPoints[channel] = buf[channelOffset++]; + for (uint8_t point = 0; point < numCalibrationPoints[channel]; point++) + { + // Unpack Pack targetVoltage (int8_t) from buf + int8_t targetVoltage = (int8_t)buf[channelOffset++]; + + // Unpack dacSetting (uint32_t) from buf (4 bytes) + uint32_t dacSetting = 0; + dacSetting |= ((uint32_t)buf[channelOffset++]) << 24; // MSB + dacSetting |= ((uint32_t)buf[channelOffset++]) << 16; + dacSetting |= ((uint32_t)buf[channelOffset++]) << 8; + dacSetting |= ((uint32_t)buf[channelOffset++]); // LSB + + // Write settings into calibration table + calibrationTable[channel][point].voltage = targetVoltage; + calibrationTable[channel][point].dacSetting = dacSetting; + } + + // Now calculate the calibration coeffs that are actually used + // by the calibrated CVOut functions + CalcCalCoeffs(channel); + } + + return 0; +} + +void ComputerCard::CalcCalCoeffs(int channel) +{ + float sumV = 0.0; + float sumDAC = 0.0; + float sumV2 = 0.0; + float sumVDAC = 0.0; + int N = numCalibrationPoints[channel]; + + for (int i = 0; i < N; i++) + { + float v = calibrationTable[channel][i].voltage * 0.1f; + float dac = calibrationTable[channel][i].dacSetting; + sumV += v; + sumDAC += dac; + sumV2 += v * v; + sumVDAC += v * dac; + } + + float denominator = N * sumV2 - sumV * sumV; + if (denominator != 0) + { + calCoeffs[channel].m = (N * sumVDAC - sumV * sumDAC) / denominator; + } + else + { + calCoeffs[channel].m = 0.0; + } + calCoeffs[channel].b = (sumDAC - calCoeffs[channel].m * sumV) / N; + + calCoeffs[channel].mi = int32_t(calCoeffs[channel].m * 1.333333333333333f + 0.5f); + calCoeffs[channel].bi = int32_t(calCoeffs[channel].b + 0.5f); +} + + +uint32_t ComputerCard::MIDIToDAC(int midiNote, int channel) +{ + int32_t dacValue = ((calCoeffs[channel].mi * (midiNote - 60)) >> 4) + calCoeffs[channel].bi; + if (dacValue > 524287) dacValue = 524287; + if (dacValue < 0) dacValue = 0; + return (dacValue*125)>>7; +} + +/// Converts voltage in millivolts to corresponding 19-bit sigma-delta PWM DAC value +/// Returns true if requested voltage is outside of full range of DAC values +/// millivolts should be in range -6000 to 6000. +/// Accuracy is dependent, of course, on the calibration coefficients +uint32_t ComputerCard::MillivoltsToDAC(int millivolts, int channel, bool &limited) +{ + limited = false; + int32_t dacValue = ((((calCoeffs[channel].mi * millivolts) >> 9) * 1573) >> 12) + calCoeffs[channel].bi; + if (dacValue > 524287) + { + dacValue = 524287; + limited = true; + } + if (dacValue < 0) + { + dacValue = 0; + limited = true; + } + return (dacValue*125)>>7; +} + +#endif + +#endif diff --git a/releases/62_BioMimicry/LICENSE b/releases/62_BioMimicry/LICENSE new file mode 100644 index 00000000..c3854e5e --- /dev/null +++ b/releases/62_BioMimicry/LICENSE @@ -0,0 +1,424 @@ +BioMimicry — a program card for the Music Thing Modular Workshop System Computer +Copyright (c) 2026 Andy Jenkinson (uglifruit) + +Licensed under the Creative Commons Attribution 4.0 International License +(CC BY 4.0). You may share and adapt this work, including commercially, so long +as you give appropriate credit. + + https://creativecommons.org/licenses/by/4.0/ + +WHAT THIS COVERS + +This licence applies to the card's own source: the physics engines, the voice +and granular rendering, the USB sample-upload firmware, the web interface and +the build tooling. + +WHAT IT DOES NOT COVER + + * samples/*.raw — the animal recordings are from Pixabay and are used under + the Pixabay Content License. They are not the copyright holder's to + relicense; see samples/README.md. + + * ComputerCard.h — the Music Thing Modular card library by Chris Johnson, + which keeps its own MIT licence. + +The full licence text follows. + +======================================================================= + +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/releases/62_BioMimicry/README.md b/releases/62_BioMimicry/README.md new file mode 100644 index 00000000..016a1249 --- /dev/null +++ b/releases/62_BioMimicry/README.md @@ -0,0 +1,445 @@ +# BioMimicry — Organic Rhythms for the Workshop Computer + +**A generative stochastic rhythm card born under the dark skies of rural Wales.** + +BioMimicry uses **six distinct mathematical physics engines** to generate triggers, CV and +audio that feel organically alive. + +It was built to capture the semi-random behaviours of the natural world: the polyrhythmic clopping of horses on a road, the cascading panic of a flock of geese, the rushing +accumulator of a waterfall walk, and the sudden silent clustering of a meteor shower overhead. (I didn't hear frogs, but the swamp mathematics were too good to leave out.) + + +--- + +## The Ecosystems + +Tap the momentary switch (Down) to cycle habitats. Each has its own internal logic. + +| Mode | Model | Behaviour | +|---|---|---| +| **Horses** | Equine gaits | A **herd**: each horse has its own stride clock driving four hooves at their true footfall offsets, and the animals slide in and out of step the way real horses travelling together never quite match pace. Walk, trot, canter and gallop are the actual biomechanical patterns — including the suspension phase where all four feet leave the ground. | +| **Geese** | Stochastic contagion | A cascading probability network across a **flock of twelve**. One bird honking raises the odds for all the others, creating tight reactive clusters that erupt and fade. | +| **Frogs** | Coupled oscillators | The Kuramoto model. Voices pull on each other's timing, fighting between perfect metronomic synchronisation and chaotic swarms — and they'll entrain to an external clock if you give them one. | +| **Rain** | Leaky integrate-and-fire | Buckets fill with noise and constantly leak. An overflow **splashes downstream** into the next bucket, so drips pull each other along into rushing clusters, then fall apart. | +| **Meteors** | Inhomogeneous Poisson | An invisible slow-moving weather system dictates the density of a **swarm of twelve**. Long eerie silences swell smoothly into heavy overlapping barrages. | +| **Cicadas** | Amplitude feedback | Twelve insects in **four patches**, each hearing mostly its own neighbours. They call faster the louder their patch already is, and tire from being in it. Patches swell out of step with each other, so the field surges and subsides irregularly. Where Frogs couple on *phase* and Geese on *events*, Cicadas couple on *loudness*. | + +### The gaits are based on real horses + +Horses mode is clocked with each hoof landing at its correct point in the stride: + +| Gait | Footfall (fraction of stride) | Suspension | +|---|---|---| +| **Walk** | LH 0.00 → LF 0.25 → RH 0.50 → RF 0.75 | — (4-beat lateral, same-side legs consecutive) | +| **Trot** | [LH+RF] 0.00 → [LF+RH] 0.50 | 2-beat diagonal | +| **Canter** | LH 0.00 → [LF+RH] 0.22 → RF 0.44 | 56% float | +| **Gallop** | LH 0.00 → RH 0.10 → LF 0.21 → RF 0.31 | 69% float (rotary — both hinds, then both fores) | + +Because each horse's four hooves share one stride clock, its gait *holds together* +however long it runs; Knob Y jitters each hoof's timing without breaking the pattern. +Knob X adds **whole horses**, never partial ones; a three-legged horse is not a +smaller herd. Each animal runs slightly off its neighbours' pace, so the herd phases +continuously. `python tools/simulate.py gaits` verifies the footfalls biomechanically. + +## Panel + +| Control | Function | +|---------|----------| +| **Knob Main** | **The Physics** — the fundamental law of the current ecosystem (Gait & tempo · Contagion · Decoupling · Downpour · Debris density) | +| **Knob X** | **Population** — how many agents are alive: **horses in the herd**, birds in the flock, frogs in the pond, buckets on the leaf, meteors in the sky, insects in the field | +| **Knob Y** | **Chaos / Humanize** — per-mode randomness and spread (timing jitter, spark rate, frequency spread, threshold variance, LFO wander) | +| **Switch Down** (momentary) | **Tap to cycle** the ecosystem. **Hold** for two seconds to hand the card to USB, hold again to return to playing. **Hold at power-on** to boot **Tuned** mode instead of Rhythm. | +| **Switch Up** | Routing: **Discrete** | +| **Switch Middle** | Routing: **Summed / CV** | + +### Per-mode meaning of Knob Main + +| Mode | Knob Main | +|---|---| +| **Horses** | **Gait and stride rate.** `0.00–0.25` Walk · `0.25–0.50` Trot · `0.50–0.75` Canter · `0.75–1.00` Gallop. Each gait sweeps its own stride-rate band, so a gallop is genuinely faster than a walk rather than the same pattern sped up. | +| **Geese** | **Contagion** — 0.0 birds ignore each other; 1.0 one honk sets off a panicked chain reaction | +| **Frogs** | **Decoupling** — 0.0 is maximum coupling (locked metronomic sync); 1.0 is zero coupling (total chaos) | +| **Rain** | **Downpour** — 0.0 leak exceeds input (silence); 1.0 rapid stuttering torrents | +| **Meteors** | **Debris density** — 0.0 rare isolated hits; 0.5 long silences swelling into dense waves; 1.0 constant barrage | +| **Cicadas** | **Coupling depth** — CCW: independent insects, a steady even drone. CW: the field drives itself into deep surges that collapse into near-silence and swell back. Intensity stays up as you turn clockwise; what changes is how strongly the field pulses. | + +### Per-mode meaning of Knob Y (Chaos) + +| Mode | Knob Y | +|---|---| +| **Horses** | Per-hoof timing jitter — an uneven, real animal rather than a machine | +| **Geese** | Spontaneous spark rate — how readily a bird honks unprompted | +| **Frogs** | Natural-frequency spread — how hard sync is to reach | +| **Rain** | **Leak rate** — slow leak lets buckets accumulate into heavy irregular drips; fast leak keeps only the strongest bursts | +| **Meteors** | Density wander on top of the hidden weather system | +| **Cicadas** | Rate spread across the field, so it never sounds like one insect multiplied | + +## Inputs + +| Jack | Function | +|------|----------| +| **Pulse In 1** | **The Spook** — a hardware interrupt that disrupts the environment. Horses: the herd startles into step, every horse landing together before drifting apart again. Geese: spook the flock into a guaranteed cascade. Frogs: splash — scramble every phase, destroying sync. Rain: wind gust — dump energy into every bucket. Meteors: bolide — spike density to maximum. **Cicadas: a footstep in the grass — the whole field falls silent at once**, then creeps back in. | +| **Pulse In 2** | **The Clock** — an external tempo the ecosystem *entrains to* rather than obeys. Frogs treat it as a phantom frog in the pond and couple to it with whatever strength Knob Main is set to, so you can dial anywhere from locked-to-the-clock to completely indifferent. Horses lock their stride to it. Geese lean their honks toward the beat; Rain tops up every bucket so the nearest tips on the beat; Meteors swell the debris field. Stop the clock and the ecosystem drifts back to its own timing within ~3 seconds. | +| **CV In 1** | Modulates **Knob Main** (the physics variable) | +| **CV In 2** | Modulates **Knob X** (population) | +| **Audio In 1** | **Loudness** — the ecosystem hears the rest of your patch. A loud room agitates the geese into honking and shuts the cicadas up; the shy modes thin out as the patch gets busy and fill back in when it quietens. | +| **Audio In 2** | **Disturbance** — transient-sensitive rather than level-sensitive, because it is sudden movement that alarms an animal, not steady noise. A sharp attack counts as a Spook. | + +Both audio inputs only act when something is patched in. + +## Outputs + +Routing is chosen with the toggle. The Computer has two pulse outs, so in Discrete mode +agents 3 and 4 fire as **calibrated 5 V blips on the CV outs** — every agent gets a +physical trigger. + +**Switch Up — Discrete** + +| Jack | Function | +|------|----------| +| **Pulse Out 1 / 2** | Agent 1 / Agent 2 triggers (5 ms gates) | +| **CV Out 1 / 2** | Agent 3 / Agent 4 triggers, as 5 V blips | + +**Switch Middle — Summed / CV** + +| Jack | Function | +|------|----------| +| **Pulse Out 1** | All active agents logically OR'd | +| **Pulse Out 2** | **Accent** — fires when two or more agents hit at once | +| **CV Out 1** | Continuous internal state of agent 1 (phase ramp, bucket level, excitation…) as 0–5 V | +| **CV Out 2** | Global ecosystem state — debris density, flock agitation, chorus coherence | + +**Audio Out 1 / 2** — all agents rendered and placed in the stereo field (see below). + +**LEDs** — one LED per ecosystem. The active mode's LED sits at a dim "you are here" +glow and flares to full on every trigger, so one light carries both meanings. + +Both tables above describe the **Rhythm** boot. Tuned routes differently — see +[Two Modes](#two-modes-rhythm-and-tuned). + + +### Round robins + +Every trigger picks a variant, and the variant *means* something: + +| Mode | Variants | What a variant is | +|---|---|---| +| **Horses** | 4 | **One per hoof.** The engine reports which hoof landed and the voice plays *that hoof* — hinds lower and heavier than fores. Every horse in the herd plays all four of its own. This is most of what stops a gait sounding like a drum machine. | +| **Geese** | 8 | Birds of different size | +| **Frogs** | 8 | Species in the chorus | +| **Rain** | 8 | Drip sizes | +| **Meteors** | 5 | Distances — the baked library ships five swooshes, not eight | +| **Cicadas** | 8 | Insects, tightly spread — a real field is fairly uniform | + +Everything except Horses picks at random with a **no-immediate-repeat** rule, so you never +hear the same honk or drip twice running. On top of that each agent has a fixed playback +rate — a body size, so agent 1 is always the largest animal — and the crowd modes jitter +slightly per event, which stops two overlapping calls fusing into one doubled sound. +Horses deliberately does *not* jitter: a horse is one animal, and a clop that changes +pitch hit to hit stops sounding like a horse. + +### Stereo placement + +Panning comes from the ecosystem, not from a knob: + +- **Fixed** (Horses) — each horse holds its own place in the field, its four hooves + sitting just either side of that spot (near side / off side). The animals stay put; + you hear a herd spread in front of you, not four wandering sounds. +- **Spread** (Geese, Frogs, Cicadas) — every swarm member has its own place, so twelve + birds occupy twelve positions and a cascade sweeps across the field. +- **Random** (Rain, Meteors) — each hit lands somewhere new, because each is a new object. + +## Two Modes: Rhythm and Tuned + +Hold the momentary switch **Down at power-on** to boot **TUNED** instead of the normal +**RHYTHM** card. On power-up the LEDs announce which you got: Rhythm lights the left +column, Tuned the right. + +Both play the same engines, the same recordings and the same one-shot voices. The +difference is **pitch**. + +**Rhythm** humanises every hit. Each agent carries a fixed rate offset — four +different-sized bodies — and every trigger adds a random detune on top. That is what +stops four hooves sounding like one sample fired four times, and what stops two +overlapping honks fusing into a single doubled sound. + +**Tuned** switches both off. Samples play at their recorded pitch, every time. On +animal recordings that is a subtle tightening; on *pitched* material it is the whole +point — the detuning that flatters a goose spreads a struck note across ±3 semitones, +randomly, hit to hit. Upload notes, drips or hits and the ecosystems become rhythm +generators for them: a gallop of plucks, rain made of woodblocks, a Kuramoto chorus +that stays in tune. + +**Tuned's outputs** are the same triggers, with the CV outs describing the ecosystem +rather than firing blips: + +| | Switch Up | Switch Middle | +|---|---|---| +| **Pulse Out 1** | Agent 1 | All agents | +| **Pulse Out 2** | Agent 2 | Pulse 1 **÷ 4** | +| **CV Out 1** | Agent 1 density | Overall density | +| **CV Out 2** | Agent 2 density | **1 V/oct — which sample fired** | + +That last one is the useful one. Each round-robin slot maps to a semitone (slot 1 = 0 V, +slot 2 = 1/12 V, and so on), stepped rather than slewed, so patching CV 2 to an +oscillator's pitch input gives you a melody whose notes follow whichever recording the +ecosystem chose. Pair it with Pulse Out 1 as the gate. + +The CV outs are always continuous in Tuned — it never fires CV trigger blips. + +## Replacing samples without a rebuild + +Open [`web/index.html`](web/index.html) in Chrome or Edge, plug the card in over USB and +click **Connect**. Drag WAVs onto the mode slots and press **Upload** — the browser +converts them (any rate, mono or stereo), matches loudness across everything you load, +and streams them into a 1 MB region of the card's flash over WebMIDI SysEx. + +Uploaded samples **override the baked ones per slot**, so you can replace just the geese +and keep everything else. **Revert to built-in** forgets them again. A full library +takes a few seconds. + +> The card is **silent throughout**. Holding the switch stops the ecosystem and hands +> the card to USB; it **reboots** back into playing when you are done. Writing flash +> halts the RP2040 — and takes USB down with it, since the USB stack itself lives in +> flash — so the whole transfer is buffered in RAM and committed in one go at the end. +> That caps a single upload at **160 KB**, about 3.4 seconds of audio, but uploads +> **append**, so the full 1 MB region is reachable over successive passes. Uploading is +> a setup activity, not a performance one. + +Flash layout: firmware and baked samples occupy the first 1 MB, user samples the +second. The build fails loudly if the firmware ever grows into the user region, because +that would make flashing destroy uploads and uploads destroy the firmware. + + +## Advanced Use + +## PCM samples (baked at build time) + +Sample playback is a **build-time** choice, not a boot mode: bake recordings into +`samples/` and they replace the synthesized voices in Rhythm boot. With no `samples/` +directory the firmware still builds and uses synthesis. + +Each mode takes up to **eight round-robin recordings**, and the variants are not decoration. + +Drop WAV files into `samples/incoming/` (or any folder) and run the importer: + +```sh +python tools/importwav.py samples/incoming +``` + +The converted `samples/*.raw` are committed here, so this builds as-is — you only need +the importer to replace the recordings. (The original source WAVs live in the +[upstream repo](https://github.com/uglifruit/WorkshopBio); they are not duplicated here.) + +It accepts **any sample rate, mono or stereo, 8/16/24/32-bit or float**, and converts to +the 8-bit signed mono 48 kHz `.raw` the build bakes in — resampling, summing to mono and +trimming silence. Standard library only, no ffmpeg needed. + +**It also matches loudness across the whole library.** Sample packs are typically all +over the place; the importer measures every source and scales each to a common RMS, then +soft-limits the result. RMS rather than peak, because a sample's peak is usually a single +transient — two recordings peak-normalised to the same ceiling can still sound nothing +alike. A real pack needed corrections from **-15 dB to +21 dB**, and came out matched to +1.07x with no clipping. + +Name them `mode_variant.wav`, or use common animal names (`HORSE_1.wav`, `GOOSE_3.wav`, +`WHOOSH_2.wav`, `DRIP_5.wav`, `CICADA_8.wav`) — the importer maps those onto modes: + +``` +horses_1..4 (four: the hooves) +geese_1..8 frogs_1..8 rain_1..8 meteors_1..8 cicadas_1..8 +``` + +**`horses_1..4` are LH, LF, RH, RF** — left hind, left fore, right hind, right fore. The +firmware asks for the hoof that actually landed, and hind hooves strike lower and heavier +than fores on a real animal, so putting them in the right slots is most of what makes a +gait sound like an animal. For the other modes the four are simply different individuals, +picked at random with no immediate repeat. + +Fewer is fine: missing variants reuse whichever you supplied — and the baker points the +repeats at one copy in flash rather than storing it twice. A bare `horses.wav` with no +number covers every slot. + +**What makes a good source:** a single isolated hit, trimmed tight to the transient (the +attack is what identifies the sound), and **dry** — the card has no reverb, so any +recorded ambience is baked in forever. + +The library that ships with the card, for scale: + +| Mode | Variants | Average length | Flash | +|---|---|---|---| +| Horses | 4 (the hooves) | ~147 ms | 27 KB | +| Geese | 8 | ~179 ms | 67 KB | +| Frogs | 8 | ~770 ms | 289 KB | +| Rain | 8 | ~104 ms | 39 KB | +| Meteors | 5 | ~1461 ms | 342 KB | +| Cicadas | 8 | ~148 ms | 55 KB | + +That is **822 KB** in total. Firmware and baked samples share the first 1 MB of flash and +currently end ~95 KB short of the boundary; the build fails with an explanation if they +ever reach it, because past that point flashing would destroy uploaded samples and an +upload would destroy the firmware. The second 1 MB is the user region — about 21 seconds +of audio — which the web uploader writes to. + +`python tools/gensamples.py` writes procedural placeholders in the same layout, so the +whole path works before you have a single recording. + +--- + +## Voices + +**The fallback — synthesized.** Recordings are the normal case; this is what plays if a +card is built with no PCM baked in and nothing uploaded. Each mode has its own DSP +timbre, built around whatever detail actually identifies the sound: hooves get a pitch-dropping body *plus a sharp band-passed noise transient* for shoe-on-stone; honks are a saw whose filter opens at the +attack for that nasal kink; ribbits are Karplus-Strong; **drips rise in pitch as they +decay**, which is the acoustic signature of a bubble collapsing in liquid and the reason a +drip sounds like a drip; meteors are noise swept by a closing filter; cicadas are a high +tone ring-modulated by a wing-beat buzz. + + +## Under the hood + +`ProcessSample()` runs at 48 kHz, but the physics don't need audio rate: engines tick at +**1.5 kHz** (every 32nd sample), which cuts average CPU load 32× and still gives 0.67 ms +timing resolution — far finer than the ear resolves for triggers. Voice rendering, gate +timing and CV output stay at the full 48 kHz. + +That divider buys **throughput, not slack**. `controlTick()` is called inline from +`ProcessSample()`, which runs inside the DMA interrupt, so on the sample where the physics +fire the whole engine must still finish inside that one **20.83 µs** slot. The worst +single sample is what decides whether audio glitches, not the average. + +To measure it, build with the profiler on: + +```sh +cmake -B build-profile -G Ninja -DBIO_PROFILE=ON +cmake --build build-profile +``` + +That build times the whole callback and each phase with the Cortex-M0+ SysTick counter +and shows the worst case on the LEDs — one LED per ~16% of the 4000-cycle budget, all six +flashing if any sample ever overran. A Down tap clears the peaks so each ecosystem can be +measured separately, and Pulse Out 2 mirrors the callback duration for a scope. It +compiles to nothing when off: the normal build is byte-identical either way. + +Everything is **integer fixed-point** — Q16 for levels and probabilities, `uint32_t` +phase accumulators that wrap for free, a 257-entry quarter-wave sine LUT, and xorshift32 +for randomness. There is no `float` in the hot path and no libm; the RP2040 has no FPU. + +| File | Purpose | +|---|---| +| [fastmath.h](fastmath.h) / [fastmath.cpp](fastmath.cpp) | Sine LUT, PRNG, fixed-point helpers | +| [biomimicry.h](biomimicry.h) | Shared types, `Engine` interface, control-rate constants | +| [engines.cpp](engines.cpp) | The six physics models | +| [voices.cpp](voices.cpp) | Synth + PCM voice rendering, panning | +| [main.cpp](main.cpp) | I/O, mode/routing UI, LEDs, boot dispatch | + +### Verifying the physics + +`tools/simulate.py` models the engine math in Python and reports gait correctness, the +Kuramoto sync curve, and triggers/second per agent across the knob range — used to +confirm every mode sweeps a musically useful range before flashing hardware: + +```sh +python tools/simulate.py # everything +python tools/simulate.py gaits # just the biomechanical gait check +``` + +It has caught real defects that compiled perfectly cleanly: Kuramoto coupling too weak +to ever synchronise, contagion saturating into a flat buzz, a gallop table byte-identical +to the walk, and modes topping out at rates that read as hiss rather than rhythm. + +(`tools/simulate.cpp` compiles the *real* engine sources natively if you have a host C++ +compiler; the Python model is the fallback.) + +## Building + +Raspberry Pi Pico SDK 2.2.0, Arm GCC 14.2, Ninja: + +```sh +cmake -B build -G Ninja +cmake --build build +``` + +Produces `build/biomimicry.uf2`. Hold **BOOTSEL** while plugging in USB and drop it on +the mounted drive. On Windows, `cmake`/`ninja` live in `~/.pico-sdk/` and are not on the +default PATH — see the build notes in the +[upstream repo](https://github.com/uglifruit/WorkshopBio) for the exact invocation. + +--- +## Requirements and Help + +Runs on a Music Thing Modular Workshop System Computer. Built on the RP2040 with +[ComputerCard](https://github.com/TomWhitwell/Workshop_Computer). + +Why the card is the way it is — including the things that were wrong first — is in +[docs/DEVLOG.md](docs/DEVLOG.md). Questions, bugs and patches: +[**the BioMimicry thread**](https://discord.com/channels/1210238368898879569/1533895615442849812) +on the Music Thing Discord. + + +--- +## Credits + +- **Music Thing Modular Workshop System Computer** — Tom Whitwell / Music Thing Modular. + **ComputerCard** by **Chris Johnson** (MIT, header-only). +- **Raspberry Pi Pico SDK** / RP2040 — Raspberry Pi Ltd. +- The Kuramoto model — Yoshiki Kuramoto. Leaky integrate-and-fire and inhomogeneous + Poisson processes are standard computational-neuroscience and point-process models. +- **Sample library** — animal and environment recordings from + [**Pixabay**](https://pixabay.com), used under the Pixabay Content License. +- BioMimicry for the Workshop Computer — **Andy Jenkinson** (**uglifruit**), 2026, with + **Claude Code** (Anthropic). + +## Licence + +**[CC-BY-4.0](LICENSE)** — Creative Commons Attribution 4.0 International. +Use it, fork it, sell it, put it in your own card; just credit +**Andy Jenkinson (uglifruit)**. + +Two things in this repository are not mine to relicense and keep their own terms: + +| | | +|---|---| +| [`samples/*.raw`](samples/) | Recordings from [Pixabay](https://pixabay.com), under the Pixabay Content License — see [samples/README.md](samples/README.md) | +| `ComputerCard.h` | The Music Thing card library by Chris Johnson, MIT | + +--- + +## Release notes + +**v1.2.0** — alt boot is now **TUNED**: the same one-shot voices as Rhythm, but +played at their recorded pitch with no per-animal or per-hit detuning, so +uploaded notes gallop or drip instead of being smeared across ±3 semitones. Its +Summed routing puts **1 V/oct on CV 2** telling you which round-robin slot just +fired. The granular renderer is gone. + +The browser editor now maps a **pool** of uploaded files onto slots, so one +recording can be reused across ecosystems without being sent twice. + +Both audio inputs now work on hardware — and Audio In 1 never had, in any +firmware: its envelope was computed on one core and read on the other, with +nothing writing it across. Patch a pad into **Loudness** and the cicadas +recede while the geese get agitated; patch a drum into **Disturb** and every +ecosystem flinches on the transient. + +**Frogs synchronise again.** The knob mapped almost its whole travel to +coupling strengths that were already locked, so the chorus sounded the same +everywhere; the sync/chaos transition now sits in the middle of the sweep where +it can be played. And a footstep in the grass genuinely silences the cicadas +rather than thinning them. + +**v1.1.0** — six ecosystems, two boot modes, a full library of real animal +recordings, and a browser app for swapping them over USB. + +**The card now runs inside its timing budget.** The physics moved to the second +core, so the engine and the voices no longer land in the same 20.83 µs sample: +the worst mode went from **334% of budget to 70%, with zero overruns**. The +clock is 192 MHz. USB is modal — hold the switch to hand the card over, hold +again to go back to playing — so nothing of TinyUSB runs while you perform. + +Audibly: samples less audibly truncate when hits overlap (eight voices, and a +steal is a crossfade rather than a cut), the library is re-baked ~1 bit louder +with dither, and the round robins finally produce as many distinct sounds as +they claim — panning no longer collapses eight variants to four, and Meteors +stops playing its first three swooshes twice as often as the rest. diff --git a/releases/62_BioMimicry/UF2/biomimicry.uf2 b/releases/62_BioMimicry/UF2/biomimicry.uf2 new file mode 100644 index 00000000..6ed669db Binary files /dev/null and b/releases/62_BioMimicry/UF2/biomimicry.uf2 differ diff --git a/releases/62_BioMimicry/biomimicry.h b/releases/62_BioMimicry/biomimicry.h new file mode 100644 index 00000000..845123af --- /dev/null +++ b/releases/62_BioMimicry/biomimicry.h @@ -0,0 +1,108 @@ +// biomimicry.h — shared types for the BioMimicry card. +// +// Five physics engines drive four agents. Every engine implements the same +// contract: given the control state, advance the model by one control tick and +// return a bitmask of which agents fired. + +#pragma once +#include +#include "fastmath.h" + +namespace bio { + +/// Number of agents exposed to the outside world: four trigger outputs, four +/// voices, four state CVs. +constexpr int kNumAgents = 4; + +/// The flock modes (Geese, Meteors) run more birds internally than they have +/// outputs. Four agents cannot sound like a flock; twelve can. Each internal +/// member is only a probability roll, so the cost is trivial, and members fold +/// down onto the four output channels — a channel fires if ANY of its members +/// fires. Density and overlap read as a swarm rather than as four things. +constexpr int kSwarmSize = 12; +constexpr int kSwarmPerAgent = kSwarmSize / kNumAgents; // 3 + +/// ProcessSample() runs at 48kHz; the physics run every kCtrlDiv samples. +/// 48000/32 = 1500Hz control rate — 0.67ms timing granularity, far finer than +/// the ear resolves for triggers, and 32x cheaper than running physics at +/// audio rate. +constexpr int kCtrlDiv = 32; +constexpr int kSampleRate = 48000; +constexpr int kCtrlRate = kSampleRate / kCtrlDiv; // 1500 Hz + +/// The six ecosystems, in cycling order. +enum class Mode : uint8_t +{ + Horses = 0, + Geese, + Frogs, + Rain, + Meteors, + Cicadas, + Count +}; +constexpr int kNumModes = static_cast(Mode::Count); + +/// Gate routing, selected by the toggle position. +enum class Routing : uint8_t +{ + Discrete, // Switch Up: agents -> individual outputs + Summed // Switch Middle: agents OR'd -> Pulse 1, CV outs carry state +}; + +/// Chosen by holding the momentary switch at power-on. Same six engines, two +/// completely different instruments made out of them. +enum class BootMode : uint8_t +{ + Rhythm, // normal: the physics fire discrete triggers and one-shot voices + Drone // alt: the physics drive continuous tone, an ambient counterpart +}; + +/// Control state, resampled once per control tick and handed to the engine. +/// All the Q16 fields are 0..65536. +struct Ctrl +{ + int32_t physics; // Knob Main (+ CV In 1): the per-mode physics variable + int32_t chaos; // Knob Y: global randomness / spread + int population; // Knob X (+ CV In 2): 1..kNumAgents active agents + bool spook; // Pulse In 1 rising edge this tick + bool clock; // Pulse In 2 rising edge this tick + int32_t clockPeriod; // control ticks between the last two Pulse In 2 edges, + // 0 if no clock is running. Lets an engine entrain to + // an external tempo rather than just being nudged. + int32_t loudness; // Q16 envelope of Audio In 1: how loud the room is. + // A disturbed environment - it quietens the shy modes + // and agitates the reactive ones. 0 when unpatched. +}; + +/// What an engine produces each control tick. +struct EngineOut +{ + uint8_t triggers; // bit i set = agent i fired this tick + int32_t state[kNumAgents]; // Q16 0..65536, continuous internal state, + // exposed on the CV outs in Summed routing + int32_t global; // Q16, a whole-ecosystem value (density, + // sync coherence, ...) for CV Out 2 + + /// Which sub-member of each agent fired, when the mode has a meaningful one: + /// in Horses this is WHICH HOOF (0=LH 1=LF 2=RH 3=RF), so the voice can play + /// that hoof's own sound. Engines that have no such distinction leave it 0 + /// and the voice falls back to its own round robin. + uint8_t member[kNumAgents]; +}; + +/// Common base for the five engines. Virtual dispatch happens once per control +/// tick (1500Hz), not per sample, so the indirect call is irrelevant. +class Engine +{ +public: + virtual ~Engine() {} + + /// Re-seed / reset to a sane starting state. Called on mode change. + virtual void reset(uint32_t seed) = 0; + + /// Advance one control tick. + virtual void tick(const Ctrl &c, EngineOut &out) = 0; +}; + +} // namespace bio diff --git a/releases/62_BioMimicry/crosscore.h b/releases/62_BioMimicry/crosscore.h new file mode 100644 index 00000000..ebd4b387 --- /dev/null +++ b/releases/62_BioMimicry/crosscore.h @@ -0,0 +1,171 @@ +// crosscore.h — the ONLY state shared between the two cores. +// +// v1.1.0 moves the physics onto core 1 so the engine and the voices stop landing +// in the same 20.83us sample. Everything that has to cross between them lives +// here, following the same discipline WorkshopZX uses for its CrossCore (see +// ../WorkshopZX/spectrum.h): every field volatile, ONE WRITER PER FIELD, no +// locks, no SDK FIFO, no barriers on plain scalars. +// +// That works because every field below is a single naturally-aligned word or +// byte, so each core sees either the old value or the new one and never a +// mixture. It is a real constraint, not a style preference: +// +// THE RULE. Word-tearing is acceptable for smoothed continuous values — a +// one-tick mismatch between densityAgent[0] and densityAgent[2] is a control +// voltage a fraction of a millisecond stale, and inaudible. It is NEVER +// acceptable for pointers, lengths or counts, where a torn read is an +// out-of-bounds access. Anything with that shape crosses through TrigRing +// below, packed into one word, never as a struct. +// +// This is why note-on data crosses as a packed word rather than by calling +// VoiceBank::note() from core 1: note() writes ~20 fields of Voice, including a +// 128-entry ks[] buffer, while render() reads and mutates the same struct every +// sample. No amount of volatile makes that safe. + +#pragma once +#include +#include "biomimicry.h" + +namespace bio { + +// --------------------------------------------------------------------------- +// Trigger ring: core 1 (physics) -> core 0 (voices) +// --------------------------------------------------------------------------- +// +// A note-on carries agent, mode, member and a variation value. accent is always +// kQ16One today, and variation only ever feeds `variation >> 3` in note(), so +// the whole payload packs into ONE 32-bit word — a single aligned store, which +// cannot tear. +// +// bits 0..1 agent 0..3 +// bits 2..4 mode 0..5 +// bits 5..6 member 0..3 +// bits 7..22 variation Q16, truncated to 16 bits +// bits 23..31 spare (if accent ever varies, take 8 bits here as Q8) +typedef uint32_t TrigWord; + +static inline TrigWord PackTrig(int agent, int mode, int member, int32_t variation) +{ + uint32_t v = static_cast(variation); + if (variation < 0) v = 0; + else if (v > 0xFFFFu) v = 0xFFFFu; + return static_cast(agent & 3) + | (static_cast(mode & 7) << 2) + | (static_cast(member & 3) << 5) + | (v << 7); +} + +static inline int TrigAgent(TrigWord w) { return w & 3; } +static inline int TrigMode(TrigWord w) { return (w >> 2) & 7; } +static inline int TrigMember(TrigWord w) { return (w >> 5) & 3; } +static inline int32_t TrigVariation(TrigWord w) { return static_cast((w >> 7) & 0xFFFF); } + +// Power of two so head/tail wrap with a mask — the M0+ has no divider. +constexpr int kTrigRingBits = 4; +constexpr int kTrigRingSize = 1 << kTrigRingBits; + +/// Single-producer (core 1), single-consumer (core 0) ring. +/// +/// Safe without a lock because `head` is written only by core 1 and `tail` only +/// by core 0, both single words. The payload is stored BEFORE head is bumped and +/// read AFTER tail is compared against head, so the consumer can never see a +/// slot the producer has not finished filling. +/// +/// Sized 16 against a worst case of 4 (one note per agent per control tick), +/// drained once per control tick — four ticks of slack, 64 bytes. +struct TrigRing +{ + volatile TrigWord slot[kTrigRingSize]; + volatile uint32_t head; // WRITER: core 1 + volatile uint32_t tail; // WRITER: core 0 + volatile uint32_t dropped; // WRITER: core 1 — see below +}; + +// --------------------------------------------------------------------------- + +struct CrossCore +{ + // --- written by CORE 0, read by core 1 --------------------------------- + + /// ++ every ProcessSample. This is core 1's only clock: it derives how many + /// physics ticks it owes from the elapsed count, so the 1.5kHz control rate + /// stays anchored to audio time however fast core 1 actually runs. + volatile uint32_t sampleCount; + + /// ComputerCard::SwitchVal() returns a member that is NOT declared volatile, + /// so core 1 could cache it forever. Core 0 republishes it here instead. + /// CORE 1 MUST NEVER CALL SwitchVal() — it will appear to work and then stop. + volatile uint8_t switchMirror; + + /// Edge counters, not flags. The old bools were set at 48kHz by core 0 and + /// cleared at 1.5kHz by the consumer; with the consumer on the other core + /// that is a lost-update race, and two edges inside one tick already + /// collapsed into one. Core 1 compares these against its own private + /// last-seen values and never writes them. + volatile uint32_t spookSeq; + volatile uint32_t clockSeq; + volatile uint32_t startleSeq; + + /// Q16 envelope of Audio In 1, from listen(). + volatile int32_t loudness; + + /// ++ when a short tap is released. Core 1 owns mode_ (it selects the engine + /// and calls reset()), so core 0 requests a change rather than making one. + volatile uint32_t modeCycleReq; + + /// Core 0 has finished its boot window and published the globals. + volatile bool bootReady; + volatile uint8_t bootMode; // BootMode, latched once at boot + + // --- written by CORE 1, read by core 0 --------------------------------- + + /// Single bytes: cannot tear, so core 0 can read them at 48kHz safely. + volatile uint8_t mode; + volatile uint8_t routing; + volatile uint8_t population; + + /// Continuous CV, Q16 (Summed routing and Drone). + volatile int32_t cvTarget[2]; + + /// Bitmask of CV outs carrying a STEPPED value that must not be slewed: + /// bit0 = CV 1, bit1 = CV 2. Alt boot's pitch CV sets bit1, because + /// gliding between semitones would turn a sequence into a portamento + /// smear rather than notes. + volatile uint8_t cvStep; + + /// Gate arming. Core 0 notices pulseSeq change and starts its timers. + volatile uint8_t pulseArm; // bit0/bit1 -> Pulse Out 1/2 + volatile uint8_t cvTrigArm; // bit0/bit1 -> CV Out 1/2 blips + volatile uint32_t pulseSeq; + + /// ++ on any trigger. uiTick() on core 0 owns the decay of its own activity + /// level; nothing shares a mutable brightness. + volatile uint32_t activitySeq; + + /// Smoothed trigger density, Q16 — how BUSY the ecosystem is, independent of + /// what it is doing. Goes to CV 1 in alt boot. Decays far more slowly than + /// the LED activity glow, which is tuned to flash per hit; this has to read + /// as a continuous control voltage over seconds. + volatile int32_t density; + + /// Per-agent smoothed density, Q16. Alt boot's Discrete routing puts agents 1 + /// and 2 on the pulse outs and THEIR OWN densities on the CV outs, so each + /// half of the patch is a trigger and a matching control voltage. + volatile int32_t densityAgent[kNumAgents]; + + /// Perf meters, both read out over SysEx with the profile buckets. + /// maxBacklog is the worst number of ticks core 1 ever owed at once: 1 is + /// healthy, a steady 2+ means the engine is taking longer than one control + /// period and the physics are running in slow motion. + volatile uint32_t maxBacklog; + + // --- USB mode ---------------------------------------------------------- + + /// Core 1 has stopped the physics and handed itself to TinyUSB. + volatile bool usbActive; +}; + +inline CrossCore gXC = {}; +inline TrigRing gTrig = {}; + +} // namespace bio diff --git a/releases/62_BioMimicry/docs/.gitkeep b/releases/62_BioMimicry/docs/.gitkeep new file mode 100644 index 00000000..27f97a88 --- /dev/null +++ b/releases/62_BioMimicry/docs/.gitkeep @@ -0,0 +1 @@ +# placeholder — remove when this folder has real content diff --git a/releases/62_BioMimicry/docs/DEVLOG.md b/releases/62_BioMimicry/docs/DEVLOG.md new file mode 100644 index 00000000..03b537a7 --- /dev/null +++ b/releases/62_BioMimicry/docs/DEVLOG.md @@ -0,0 +1,1170 @@ +# BioMimicry — development log + +Why the card is built the way it is, and what listening to it changed. Written +as we went, newest last. + +--- + +## 0.1.0 — the five engines + +Built the scaffold into a working card: five physics engines driving four agents, +synthesized voices, an alt-boot PCM path, and the CV/gate routing the Workshop +Computer's real I/O allows. + +Three hardware constraints shaped the whole design and are worth restating, +because they look like arbitrary choices otherwise: + +- **There are two pulse outs, not four.** Agents 3 and 4 fire as calibrated 5 V + blips on the CV outs, so every agent still gets a physical trigger. +- **There is one switch, not two.** A Down tap cycles the mode; the Up/Middle + position selects routing. Holding Down at power-on picks the boot mode. +- **There are six LEDs.** With five modes the sixth showed activity; at six modes + they share (see 0.2.0). + +Physics run at a **1.5 kHz control rate** (every 32nd sample) while voices, gates +and CV run at the full 48 kHz. That split is what makes four agents of Kuramoto +coupling affordable, and 0.67 ms of timing granularity is far finer than the ear +resolves for triggers. + +### What simulation caught before any hardware trip + +`tools/simulate.py` models the engine maths and reports trigger rates. Four real +defects, none of which a compiler would have found: + +| Defect | Symptom | +|---|---| +| Kuramoto coupling ~2% authority | Frogs could **never** synchronise — the model was decorative | +| Geese excitation saturating | Knob was a switch: silence, or a flat 16 Hz buzz | +| Rain topping out at 100 Hz | Hiss, not rhythm | +| Meteors topping out at 47 Hz | Same | + +A useful lesson from this round: **flat trigger rates do not mean coupling is +broken.** Synchronised oscillators fire at the same rate, they just align in +time. Measuring the Kuramoto order parameter showed the coupling working when +the rate table suggested it wasn't. + +--- + +## Reality check — the gaits were wrong + +Asked directly whether the horse gaits were actually correct. They were not. +Three separate errors: + +- **The gallop was byte-identical to the walk.** A `kGaitSteps` table meant to + compress it into a burst-plus-suspension was `{4,4,4,4}` and silenced behind a + `(void)` cast. The knob's entire top quadrant was a faster walk. +- **The walk wasn't a walk.** It fired both forelegs consecutively. A real walk + is 4-beat *lateral* — same-side legs follow each other. +- **The canter had no suspension.** Evenly spaced 90° beats read as a waltz. + +Underneath was a structural flaw: **four free-running clocks at 0.95–1.03× +cannot hold a gait.** A trot's diagonal pair separated within seconds. The drift +was the mode's poetry, but only the walk survived it. + +Rebuilt around **one stride clock** with per-hoof landing offsets from real +equine footfall timing. Chaos jitters each hoof's *timing* rather than its rate, +so gaits stay locked however long they run. `python tools/simulate.py gaits` +asserts all four biomechanically. + +Also this round: Rain got downstream splash coupling (it was statistically close +to Meteors — Poisson with a refractory period), Geese and Meteors went to +12-member swarms, and Pulse In 2 became an entrainment clock rather than a second +spook. + +--- + +## 0.2.0 — Cicadas, round robins, stereo + +Added a sixth ecosystem coupling on **amplitude**, where Frogs couple on phase +and Geese on events. It took two attempts, both caught in simulation: + +1. With insects tiring only when *calling*, the swarm self-organised into an even + spread and never surged — defeating the entire point of the mode. Ambient + fatigue (being in a loud field is itself tiring) more than doubled the swing. +2. The knob still did nothing, because feedback speeds calls while fatigue is + call-driven — they cancelled. Scaling **both halves** of the loop by the knob + fixed it. + +Round robins were wired for meaning rather than variety: in Horses the variant +**is the hoof**, hinds pitched lower and heavier than fores. Stereo placement +comes from the ecosystem — fixed for the horse, spread for flocks, random per hit +for rain and meteors. + +Six modes filled all six LEDs, so mode and activity now share one light: the +active mode glows dim and flares to full on each trigger. + +--- + +## Hardware: "the horse sounds like one horse" + +Correct, and a real bug rather than a tuning problem — one I introduced when +consolidating to a single stride clock. + +`HorsesEngine::tick()` **never read `c.population`**. Knob X only reached the mode +through the generic agent mask, which here removed *legs from one animal*. +Turning it up added nothing; turning it down made a lame horse. + +Population is now a **herd**: one stride clock per horse, all four hooves intact, +with per-animal speed offsets so the animals slide in and out of step. That is +the phasing the old per-leg drift was reaching for, at the level where it doesn't +destroy the gaits. `EngineOut::member` carries which hoof landed so each animal +plays all four of its own. + +--- + +## Hardware: trot and canter sound like a density drop + +Also correct, with an acoustic explanation. Trot and canter were the **only** +gaits with mathematically coincident landings — and two identical clops fired on +the same sample don't sound like two hooves, they **sum into one louder clop**. +So a trot genuinely was half the density of a walk, not just perceptually. + +Real diagonal pairs land 10–30 ms apart. The second foot of each pair now holds +back by a fixed delay (trot 12/18 ms, canter 15 ms), given in absolute time +rather than as a fraction of the stride — a real animal's flam doesn't stretch +with tempo. Walk and gallop already had four distinct landings and are untouched. + +Frogs also went to a pond of twelve. That needed the Kuramoto sum rewritten +mean-field (O(n) instead of O(n²)) — **and the first attempt had the angle +identity's terms swapped and was 100% wrong.** Checking against a direct O(n²) +sum over 300 random ponds caught it; the corrected form agrees to 0.006%. Worth +recording because it compiled and ran silently. + +--- + +## Alt-boot became Drone + +Alt-boot was spending a whole boot mode on a sample toggle. Sample playback is +now a **build-time** choice (bake `samples/` or don't), which freed it for +**Drone**: the same six engines driving sustained tone instead of triggers. + +Both Audio In jacks were also completely unused. Audio In 1 is now loudness (a +loud room agitates geese, silences cicadas), Audio In 2 transient-sensitive — +sudden movement alarms animals, steady noise doesn't. + +--- + +## Hardware: "samples playing far far too slow — like racing cars" + +They were not playing at all. **Every boot was entering Drone mode**, whose old +Meteors root was a 40 Hz saw pair through a sweeping filter — which is exactly +what a passing vehicle sounds like. + +`ComputerCard` derives the switch from `knobs[3]`, which comes off a ~60 Hz +smoothing filter **initialised to zero — and zero decodes as `Switch::Down`**. +For roughly the first 5 ms of every boot the card reports Down wherever the +switch actually is. The boot window latched on "Down seen at any point", so it +latched on *every* boot. + +Two follow-ups from the bench settled it: *"both boot modes seem to be doing the +same"* pointed at the latch, and *"four sounds, rising in pitch"* was the synth +hoof pitches (153/202/164/216 Hz), proving the PCM path was never reached. The +fix is a single reading after settling — what WorkshopZX's `BootSelector` does, +and what is proven on this hardware. I had claimed to be following that pattern +and was not. + +There is now a **boot splash** — Rhythm lights the left LED column, Drone the +right — because the whole diagnosis was slow for want of any way to tell the +modes apart. + +Found while tracing it: the PCM end-of-sample test used `pcmLen << 16`, which +wraps above 65536 bytes. It was silently truncating the two longest meteor +swooshes to under half their length. + +--- + +## Drone was useless, and rightly called out + +The first Drone mapped engine `state[]` to oscillator pitch. Most engines put a +**phase ramp** in `state[]`, so the pitch swept upward and snapped back forever, +in half the modes — and it ignored the entire sample library in favour of saw +oscillators. + +Rebuilt with **no oscillators**: Drone now granulates the same recordings, up to +eight overlapping grains per voice started at random points and played stretched. +The engines drive grain *density* and *spread*, never pitch. Grain periods were +sized against the real sample lengths so overlap lands at 2–4×; the first pass +asked for 16× overlap against four grain slots, which would have stolen grains +mid-playback and chopped. + +--- + +## Hardware: cicadas galloping + +Reported as clusters then silence, *"almost like galloping horses"* at CW. That +detail was the diagnosis — it said the clusters were **periodic**. Measured: +every 0.32 s, standard deviation 0.01 s. A metronome. + +**My first guess was wrong.** I assumed the twelve insects shared one fatigue +time constant, gave each its own recovery rate and stamina, and it stayed +rhythmic in every configuration. Measuring phase clustering instead found the +real problem: phases converged from R=0.24 to **R=0.88 within five seconds** and +froze, because every insect ran off one shared rate law with no natural frequency +of its own. Detuning them dropped clustering to R=0.26. + +That stopped the unison but the field still pulsed, for a reason no amount of +tuning fixes: + +> One shared field, plus "everyone speeds up when it's loud and tires when they +> call", **is a relaxation oscillator**. Charge, discharge, repeat. It has exactly +> one period. + +So the field became **four patches** that mostly hear themselves. Patches charge +and discharge independently — measured correlation **0.01** — and cluster spacing +went from a fixed 0.32 s to an irregular 0.4–8.6 s (cv 0.06 → 0.44–0.78). + +*Note for anyone reading `simulate.py`:* `cicadas_swing` reports **one patch**, +not the mean. The mean is deliberately flat because independent patches cancel, +and that cancellation is the point. + +--- + +## Real samples, and a way to change them + +42 Pixabay recordings, already 48 kHz 16-bit mono, with levels spanning ~36 dB. +All of them fit (834 KB of 1943 KB free), so rather than discarding +three-quarters of the round robins the firmware went to **eight variants** — +except Horses, whose variants are the hooves. + +Levels are matched by **RMS, not peak**. A peak is usually one transient: the +quietest goose had a body at RMS 0.004 against another at 0.059, yet both peaked +near 1.0, so peak-normalising would have left the quiet one still sounding quiet. +Corrections ran −15.5 dB to +20.6 dB; the library came out matched to 1.07×. + +The web editor then removes the toolchain from the loop entirely: drag WAVs +into a browser, they are converted and loudness-matched there and streamed over +WebMIDI SysEx into a reserved 1 MB flash region, overriding the baked recordings +per slot. The card mutes during the write because flash writes halt execution — +honest about the constraint rather than glitching through it. + +The firmware image now sits only ~95 KB below that region, and adding baked audio +would silently push it over, making flashing destroy uploads and uploads destroy +firmware. `tools/checksize.cmake` reads the real image end from the ELF and fails +the build if it ever reaches the boundary. + +--- + +## A developer's correction: /32 does not buy you time + +A Workshop Computer developer read the code and pointed out: + +> *"Unless I misunderstand, calling the physics once per 32 samples doesn't help +> with performance — because the physics still needs to finish within that ~20us +> sample in which it is run?"* + +He is right, and it invalidated a claim we had been repeating. + +`controlTick()` is called **inline** from `ProcessSample()`, which runs inside +the DMA interrupt handler (`ComputerCard::AudioCallback`). So on the one sample +in 32 where the physics run, the whole engine still has to finish inside that +single **20.83 µs** slot. Dividing by `kCtrlDiv` lowers the *average* load. It +does not move the deadline. + +The figure in this log — "~55 cycles/sample amortised against a 2604-cycle +budget" — was therefore measuring **throughput**, not the thing that decides +whether audio glitches. What matters is the **worst single sample**, and that had +never been measured. Static instruction counts could not settle it either: the +Geese tick is 1233 instructions but contains 243 branches, so the count is a very +loose upper bound rather than a real path. + +The honest response was to measure rather than argue, so `profile.h` was added: +a SysTick-based cycle counter around the whole callback and each of its phases, +reporting the worst case on the LEDs (one LED per ~16% of budget, all six +flashing on an overrun). It compiles to nothing unless `-DBIO_PROFILE=ON`, and +this was verified by checking that the normal build is byte-for-byte identical to +the released firmware. + +Geese is the mode to watch: its excitation spread is the only O(n²) path left, and +a full cascade is 12×11 = 132 inner iterations in one tick. Frogs was already made +O(n) by the mean-field rewrite. + +> **Both halves of that paragraph turned out to be wrong.** Geese measured +> *fourth* of six, and Frogs' O(n) rewrite is still second worst. See "The +> measurements, and two wrong predictions" below. Left here unedited because the +> reasoning looked sound right up until it was checked. + +His second point stands too: if the measurement shows headroom, the control rate +can go *up* for finer trigger timing. That is a one-line change to `kCtrlDiv` — +but worth making only once the headroom is a number rather than an assumption. + +**Status: measured — see below.** + +--- + +## The measurements, and two wrong predictions + +Three rounds of hardware readings settled it. The first two rounds each fixed a +real problem and each ended with a guess about what would matter next. **Both +guesses were wrong**, and the sweep that proved it took ten minutes. + +### Round 1 — the overruns were XIP flash misses + +Moving the hot path into RAM: Engine 3.5× faster, Voices 3×, Outputs 4×, +overruns down ~92%. Everything the card computes itself came inside budget. + +### Round 2 — USB was the entire remaining problem + +`tud_task()` measured **29895–35850 cycles**, up to 14× the whole 20.83 µs +sample. TinyUSB's device stack is unbounded by design and was being called from +inside the audio interrupt. It moved to core 1. + +**That worked completely.** USB now measures **0 cycles** on core 0 in every mode. +It is the one unambiguous success in this log. + +The same round split `uiTick()` (LED rendering) half a divider away from the +engine so the two costs never land in the same slot. That worked too: Outputs +peaks at 336–526 cycles across all six modes. + +### Round 3 — the full sweep + +| Mode | Engine | Total | Overruns | +|------|-------:|------:|---------:| +| **Cicadas** | **7830** (300.7%) | 8708 (334.4%) | 1225 | +| **Frogs** | **5606** (215.3%) | 6509 (250.0%) | 1640 | +| Horses (pop 4) | 3847 (147.7%) | 4581 (175.9%) | **11030** | +| Geese | 3118 (119.7%) | 3900 (149.8%) | 249 | +| Drips | 2648 (101.7%) | 3530 (135.6%) | 3 | +| Meteors | 2139 (82.1%) | 2915 (111.9%) | 3 | + +Budget is 2604 cycles. **Every mode is over on Total. Four of six are over on +Engine alone.** + +### The two wrong predictions + +**"Geese is the mode to watch."** It is *fourth*. The reasoning was that its +excitation spread is the only O(n²) path left, 12×11 = 132 inner iterations. But +Cicadas is **2× worse** than Horses and 3× over budget with no O(n²) path at all +— it walks its swarm up to four separate times per tick (spook, loudness, main +loop, patch reduction), and each iteration is heavy. **Iteration count did not +predict cost; per-iteration weight did.** Same class of error as the "/32 buys +you time" claim: reasoning about the code instead of measuring it. + +**"Frogs was already made O(n) by the mean-field rewrite."** True, and it is +still second worst at 5606 cycles. O(n) is not the same as cheap. + +### Peak and frequency are different faults + +The overrun counts do not track the peaks. Horses has **11030** overruns at 3847 +cycles; Cicadas has **1225** at 7830. Peak says *how far* over, overrun count says +*how often*. Horses overruns constantly by a little, Cicadas rarely but +massively. Both are audible, differently — and a fix that only chases the peak +would leave Horses' 11030 in place. + +### Horses: the cost is mostly fixed, not per-agent + +Sweeping population on Horses: + +| Population | Engine | Overruns | +|-----------:|-------:|---------:| +| 1 | 2065 (79.3%) | 3 | +| 4 | 3847 (147.7%) | 11030 | + +Three extra horses cost 1782 cycles — **~594 per horse**, leaving a fixed floor +around **1470 cycles**, roughly 70% of the population-1 cost. The `Engine` scope +wraps all of `controlTick()`, so knob reads, CV reads, clock tracking and voice +dispatch are inside that floor. Two loops in the Horses tick also run +`kNumAgents²` = 16 iterations regardless of population (`c.spook` reset and the +`gait != lastGait_` reset), ignoring the early-out at `engines.cpp:143`. + +A prediction that per-agent splitting would quarter the cost was therefore also +wrong: it caps the variable part but leaves the floor untouched. + +### What the numbers rule out + +The developer's second point — that headroom could buy a *higher* control rate — +is dead. There is no headroom. `kCtrlDiv` cannot go below 32 until Engine fits. + +Per-mode micro-optimisation is the wrong strategy: the two modes worth starting +on by intuition (Horses, Geese) are third and fourth. The costs share one shape +— **every engine does its whole swarm in one tick, inside one 20.83 µs slot** — +and `kCtrlDiv` cannot help, because it lowers average load and not the deadline. + +**Caveat on this data:** one reading per mode, and peaks reset on each read. The +Horses sweep showed the range *within* one mode is wider than the gap between +modes, so Drips and Meteors sitting at 3 overruns is provisional — it may only +mean that knob position was never swept. + +**Status: measured. Fix not yet attempted.** + +--- + +## Drone was the real glitch, and the uploader never worked at all + +The measured sweep above was **Rhythm mode only**, and that turned out to matter. + +### The ear found what the sweep missed + +Asked whether the overruns were actually audible, the answer was that they were +mostly masked — chirps and hooves are broadband and transient-dense, so a +one-sample discontinuity hides in material that already sounds like noise. Fair, +and it nearly stopped the work. + +But then: discontinuity **in Drone**, on Geese, Frogs and Meteors, with a guess +that it was "the longer samples". Meteors measures 2139 cycles — the one mode +never over on Engine — so Engine could not be the cause. Reading timing in Drone: + +| bucket | Rhythm (Geese) | Drone (Geese) | +|--------|---------------:|--------------:| +| Engine | 3118 | 3145 | +| **Voices** | **800** | **17546** | +| Total | 3900 | 18230 | +| overruns | 249 | **373862** | + +**674% of budget, and 1500x the overruns.** The engines were never the problem in +Drone. `droneRender()` is, and unlike the physics it runs on *every* sample. + +The cause was one line: a triangular grain window computing `(dist << 16) / half` +per grain per sample — 8 grains x 4 voices = **32 hardware divides at 48kHz** on a +core with no divider. `g.len` never changes once a grain launches, so the whole +thing was recomputing a constant 48000 times a second. The reciprocal is now +taken once at launch and the render loop multiplies. + +The "longer samples" hunch was right, and better than my reasoning: longer grains +stay active longer, so more of the 32 divides are live at once. + +### Getting the reciprocal right took three attempts + +Worth recording, because the first two would have shipped audible bugs and a +Python check caught both before flashing: + +1. **Q16 reciprocal** — `(0xFFFFFFFF/half)>>16`. Fine for short grains; on + `meteors_5` (118596 bytes) it truncates to 1 against a true 1.105, a **10% + window error**, and an uploaded 1MB sample truncates it to **0** — silence. +2. **Q48 reciprocal** — fixes the long grains, **overflows 32 bits** on short ones. +3. **Q32 with a clamped `dist`** — the range of `half` (8 to ~500000) is too wide + for any single fixed scale. The survivor also needs `dist < half`, because an + odd `len` lets `dist == half`, and `half * (2^32/half)` is exactly 2^32, which + wraps to zero and turns the window's **peak** into silence. + +Verified exhaustively against the original across every real sample length: +zero overflows, max error 0.19% of full scale on the shortest baked recording. + +### The uploader deadlocked the card, every time + +Reported as: web UI says it will go silent, then no sound, no LEDs, no control, +until a power cycle. Three separate defects, all in a path that had **never once +run on hardware**: + +1. **The park loop was inside the DMA interrupt handler.** Core 0 spun in + `ProcessSample()` waiting for core 1 to finish writing flash. Spinning for the + seconds an erase takes starves the audio DMA, and it never restarts. +2. **`ProcessSample()` is `virtual`.** Merely *dispatching* to it reads a vtable + that lives in flash — so with XIP down, core 0 faults before reaching any + guard. The RAM-residency of the function body cannot help you get to it. +3. **Every upload erased the whole 1MB region** and `memset` the slot table, so + replacing one 6KB recording destroyed the entire library, after a multi-second + stall the browser read as a hang. + +Fixed by giving up on the pretence. `EnterUploadMode()` disables `DMA_IRQ_0` +outright, the whole USB path is now RAM-resident, core 1 drives the progress +LEDs (core 0 is not running at all), and the card **reboots** when the upload +finishes. The web UI already said "the card mutes while uploading"; this makes +that true instead of aspirational. + +Uploads are now incremental: the header is seeded from what is already on the +card and new audio is appended, so untouched slots survive. The append point is +rounded **up** to a sector boundary — erase works a sector at a time, and +starting mid-sector would have wiped the tail of the previous recording, which is +the exact corruption the change existed to prevent. Space is reclaimed with +"revert to built-in", which empties the region. + +### Drips: the first 40% of the knob did nothing + +Reported as basically never firing until ~40%, then firing often. Measured, and +exactly right: **0.00 triggers/sec everywhere below 45%**, then straight to 3.3. + +This was not a knob-taper problem, so remapping the curve would only have moved +the cliff. Below a certain inflow the buckets leak as fast as they fill, so the +level sits at an equilibrium under the threshold and *nothing ever fires* — a +uniform random drop cannot cross it, no matter how the knob is scaled. + +Two changes. The inflow law is square-rooted (`fast_sqrt_q16`, a new bitwise +integer sqrt — no libm, no float, no divide) so the bottom of the travel moves +fastest, with a small floor. And the drop is now **heavy-tailed**: cubing the +random keeps the mean low but lengthens the tail, so a weak downpour still tips a +bucket occasionally. That tail is what removes the dead zone, and it is closer to +real dripping, where water gathers and then lets go. + +Result: drips from 2% of travel, rising smoothly to ~19/sec, under the ~25/sec +ceiling at which a rhythm stops reading as one. + +The obvious `* 4 / 5` gain trim compiled to an `__aeabi_idiv` call **inside the +inner loop**, checked in the disassembly and replaced with a Q16 multiply. The +same M0+-has-no-divider lesson the Cicadas patch walk already learned. + +**Status: all three built and staged, none heard on hardware yet.** + +--- + +## Round two on all three, from hardware + +### Drone: the divide was half of it + +`droneRender()` went **17546 -> 9608** cycles. Real, and not enough — still 369% +of budget, and Geese still audibly glitched. + +The other half was in the same loop and the same shape: `mul_q16()` widens to +`int64_t`, which on the M0+ is an **`__aeabi_lmul` library call**. Two of them per +grain per sample, up to 32 grains, at 48kHz. Confirmed in the disassembly rather +than guessed. Neither needed 64 bits: `|s| <= 2048` and `win <= 65536`, so the +product peaks at 1.3e8 and the accumulator at 5.4e8, both comfortably inside +int32. They are now plain 32-bit multiplies, and `droneRender()` contains **no +library calls at all**. + +`droneUpdate()` had three more (`* 2 / 5`, `% 32`, `% kNumGrains`) — the first +became a Q16 multiply, the other two masks, since both divisors are powers of +two. It runs at control rate so it matters ~32x less, but it sits in the same +Engine bucket that measured 4676 in Drone. + +The one remaining 64-bit divide is the window reciprocal at grain launch, which +is the whole point: one divide per grain instead of one per grain per sample. + +### The uploader: the ack never left the device + +"Upload did nothing, revert reset to stock samples" — so the header never +committed, and the browser saw nothing. + +`tud_midi_stream_write()` only fills a FIFO. **`tud_task()` is what puts bytes on +the wire**, and this TinyUSB has no MIDI flush call. Every reply was queued and +then immediately followed by an erase, a page program, or a `busy_wait` before +the reboot — all of which block without servicing USB. So: + +- chunk acks sat in the buffer while the browser waited for one before sending + the next chunk, and +- the final ack died with the `watchdog_reboot()` 120ms later. + +`Send()` now pumps `tud_task()` itself (guarded against unbounded recursion, +since it is called from inside `tud_task()`), and the reboot paths spin on +`FlushUsb()` instead of a blind `busy_wait`. + +Worth noting this bug was invisible to the profiler reads, which worked fine: +those reply once and then return to the normal `Task()` loop, which flushes them +on the next iteration. Only the upload path blocks immediately after replying. + +### Drips: right shape, wrong speed + +The dead zone was gone but the first drips arrived faster than one a second, +where the ask was nearer one every four. Lowering the floor barely moved it and +lowering the gain capped the torrent at the top. + +The fix was to stop treating gain as a constant: it now **ramps 0.4 -> 0.8 across +the sweep**, so the bottom is sparse without flattening the top. With the floor +raised to 9000, drips start at ~4% of travel at 0.41/s (one every 2.4s), pass 1/s +around 10%, and reach ~18/s at full. + +**Status: built and staged; not yet heard.** + +--- + +## The uploader, third attempt: masking an interrupt is not stopping a core + +Two rewrites in, upload still hung the card. The mistake was the same both times, +just better hidden: **`irq_set_enabled(DMA_IRQ_0, false)` stops the interrupt from +firing again. It does not stop core 0 from executing flash.** + +Three things were still live when `flash_range_erase` dropped XIP: + +- `ComputerCard::AudioCallback` and `BufferFull` are **in flash**. Core 0 can be + inside them at the moment of the erase — masking the IRQ does not evict it. +- `AudioWorker`'s outer `while(1)` is RAM-resident, but it returns into and calls + flash-resident code. +- The CV outputs run a **second flash-resident ISR**, `PWM_IRQ_WRAP -> + OnCVPWMWrap`, which was never masked at all and kept firing throughout. + +Any one of them faults the chip. `ProcessSample` being `__not_in_flash_func` was +never the point: what matters is everything *around* it. + +Core 0 now parks inside `ProcessSample` itself — which is RAM-resident — mutes +its outputs, sets `core0Parked`, and **spins forever**. It never returns, so the +flash-resident caller never runs again. Core 1 raises `uploadMode`, waits for the +acknowledgement (the next callback is 21us away), and only *then* masks both +IRQs and touches flash. Order matters: masking first would mean `ProcessSample` +never runs again, never sees the flag, and never parks — a deadlock built out of +the fix for a deadlock. + +This is safe now in a way it was not in the first attempt, and the reason is +worth stating: USB moved to core 1, so spinning core 0 no longer stalls the very +transfer it is waiting on. + +### Two browser bugs, both reported rather than found + +- **The file picker opened twice per sample.** Each slot was a `