From da6096227d832539272b47c49966bcbeb733e541 Mon Sep 17 00:00:00 2001 From: Felix Wiegand Date: Sat, 27 Jun 2026 18:31:54 +0200 Subject: [PATCH] feat: rework audio engine, simplify UI, add CI Audio: - render on core1 paced by the I2S DMA buffer instead of a timer ISR - soft-clip the mix for headroom, precompute per-voice gain, ramp attacks - fix constrain() no-ops, the interpolation read past the sample end and the off-by-one LED and trigger loop bounds UI: - four encoder pages: select, sample, volume, pitch - per-channel LED colours with a 10s trigger-activity screensaver - save sample, pitch, volume and CV assignment to flash - encoder direction and sound kit are build-time options CI: - matrix build of every kit for normal and reversed encoders - release-please for versioning and releases --- .github/workflows/build.yml | 74 +++++ .github/workflows/release-please.yml | 29 ++ .release-please-manifest.json | 3 + README.md | 71 ++++- firmware/Pikobeats/Pikobeats.ino | 439 +++++++++++++++++---------- firmware/Pikobeats/timers.h | 124 +------- release-please-config.json | 8 + 7 files changed, 466 insertions(+), 282 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7bc8c7c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,74 @@ +name: build firmware + +on: + push: + pull_request: + workflow_dispatch: + workflow_call: + inputs: + release_tag: + description: "If set, upload the built .uf2 files to this release tag" + type: string + required: false + default: "" + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + kit: + - { name: trippy, flag: "" } + - { name: 80s, flag: "-DCHAMBORD_KIT_80S" } + - { name: angularj, flag: "-DCHAMBORD_KIT_ANGULARJ" } + - { name: mix, flag: "-DCHAMBORD_KIT_MIX" } + - { name: tekke, flag: "-DCHAMBORD_KIT_TEKKE" } + - { name: acoustic3, flag: "-DCHAMBORD_KIT_ACOUSTIC3" } + enc: + - { dir: "1", suffix: "" } # normal encoder + - { dir: "-1", suffix: ".encoder_reversed" } # reversed encoder + + steps: + - uses: actions/checkout@v4 + + - name: Install arduino-cli + uses: arduino/setup-arduino-cli@v2 + + - name: Install RP2350 core and libraries + run: | + arduino-cli config init --additional-urls https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json + arduino-cli core update-index + arduino-cli core install rp2040:rp2040@5.6.1 + arduino-cli lib install \ + RotaryEncoder@1.5.3 \ + Bounce2@2.71.0 \ + RPi_Pico_TimerInterrupt@1.3.1 + + - name: Build ${{ matrix.kit.name }}${{ matrix.enc.suffix }} + run: | + arduino-cli compile \ + -b rp2040:rp2040:rpipico2:freq=276,opt=Optimize3 \ + --build-property "compiler.cpp.extra_flags=-DCHAMBORD_ENC_DIR=${{ matrix.enc.dir }} ${{ matrix.kit.flag }}" \ + --output-dir out \ + firmware/Pikobeats + mv out/Pikobeats.ino.uf2 "Pikobeats.${{ matrix.kit.name }}${{ matrix.enc.suffix }}.rp2350.uf2" + + # Downloadable from the Actions run for every commit + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: Pikobeats.${{ matrix.kit.name }}${{ matrix.enc.suffix }}.rp2350 + path: Pikobeats.${{ matrix.kit.name }}${{ matrix.enc.suffix }}.rp2350.uf2 + + # When called from the release workflow, attach the binaries to the release + - name: Upload to release + if: ${{ inputs.release_tag != '' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ inputs.release_tag }}" \ + "Pikobeats.${{ matrix.kit.name }}${{ matrix.enc.suffix }}.rp2350.uf2" --clobber diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..e0d8f4c --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,29 @@ +name: release-please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.rp.outputs.release_created }} + tag_name: ${{ steps.rp.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v4 + id: rp + + # On a new release, build every kit (normal + reversed encoder) and attach + # the .uf2 files to the release. + firmware: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + uses: ./.github/workflows/build.yml + with: + release_tag: ${{ needs.release-please.outputs.tag_name }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/README.md b/README.md index 0cabfb8..e4b37d7 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,69 @@ # Chambord -Chambord is a eurorack module, using the RP2040 or 2350 to implement a sampler. +Chambord is a eurorack module, using the RP2040 or 2350 to implement an +8-channel trigger-in drum sampler. -# UI +The 8 channels are laid out **top to bottom = channel 1 to 8**. Each channel +has a trigger input jack and a bicolor (red/green) LED. There is one encoder +(turn + press) and one CV input. -The ui is controlled via the encoder and encoder button. The feedback is the color of the current selected track or track 7. +# How to use it -1. In the default mode (0) turning the encoder selects the current track to work on. It will blink orange from default green. -2. Press the encoder button (short press) once, enter mode 1 to change the pitch of the current track. -3. Press the encoder again (short press) to enter mode 2, to change the sample. Each track has 4 samples associated with it. -5. Hold the ecoder buton for 1/2 a second (say 'one') and you assign the CV input to modulate the volume of the current track. +There are only two controls: **turn** the encoder and **press** the encoder. -That's about it. +## Turning the encoder + +What turning does depends on the current "page". You step through the pages by +**short-pressing** the encoder: + +``` +press press press press +SELECT --> SAMPLE --> VOLUME --> PITCH --> (back to SELECT) +``` + +- **SELECT** (start here): turn to choose the channel you want to work on. +- **SAMPLE**: turn to change the sample on the selected channel. Each channel + has 4 samples (banks A–D); turning steps between them. +- **VOLUME**: turn to change the volume of the selected channel. +- **PITCH**: turn to change the pitch of the selected channel. + +A short press always advances to the next page and wraps back to SELECT. + +## Reading the LEDs + +While you're editing, only the **selected channel's** LED lights, colour-coded +by page (green = choosing a thing, amber = setting an amount): + +| LED | Page | +|------------------|---------------------------------------| +| green steady | SELECT (turn to pick the channel) | +| green blinking | SAMPLE (turn to change the sample) | +| amber steady | VOLUME (turn to change the volume) | +| amber blinking | PITCH (turn to change the pitch) | + +You'll also hear sample/volume/pitch changes as you turn. + +## Screensaver / trigger activity + +When you haven't touched the encoder for about **10 seconds**, the row switches +to a trigger-activity display: every channel flashes **green** when it gets a +trigger. Touch the encoder and it goes back to showing what you're editing. +(This keeps trigger flashes from distracting you while you work.) + +## CV input + +**Long-press** the encoder (hold ~3/4 second) to assign the CV input to the +**volume** of the currently selected channel. Long-press again on the same +channel to turn CV off. + +## Settings are saved + +Your per-channel sample and pitch choices (and the CV assignment) are saved +automatically to flash a moment after you stop adjusting, and restored on the +next power-up. Saving only happens while nothing is sounding, so it never +interrupts playback. + +## If the encoder turns the wrong way + +Open `firmware/Pikobeats/Pikobeats.ino` and change `ENCODER_DIR` from `1` to +`-1` (near the top), then rebuild. diff --git a/firmware/Pikobeats/Pikobeats.ino b/firmware/Pikobeats/Pikobeats.ino index 90fb04c..1fd0807 100644 --- a/firmware/Pikobeats/Pikobeats.ino +++ b/firmware/Pikobeats/Pikobeats.ino @@ -52,22 +52,31 @@ bool debug = false; #include "euclid.h" #include "filter.h" +#include // tanhf for the output soft-clip +#include // flash-backed settings persistence + // we have 8 voices that can play any sample when triggered // this structure holds the settings for each voice // 80s only to 20, jungle to 29 -//we use a header per sample set - -#include "trippy.h" -//#include "80s.h" -//#include "angularj.h" -//#include "mix.h" -//#include "tekke.h" - -// not ready -//#include "world.h" -//#include "acoustic3.h" -//#include "beatbox.h" -//#include "bbox.h" +// we use a header per sample set +// +// Pick the sound kit at compile time. The CI builds one firmware per kit by +// passing e.g. -DCHAMBORD_KIT_80S ; locally, define one of these (or leave the +// default trippy). Each kit header defines voice_t/voice[] and pulls in its +// own samples. ("not ready" kits world/beatbox/bbox have no sample data.) +#if defined(CHAMBORD_KIT_80S) + #include "80s.h" +#elif defined(CHAMBORD_KIT_ANGULARJ) + #include "angularj.h" +#elif defined(CHAMBORD_KIT_MIX) + #include "mix.h" +#elif defined(CHAMBORD_KIT_TEKKE) + #include "tekke.h" +#elif defined(CHAMBORD_KIT_ACOUSTIC3) + #include "acoustic3.h" +#else + #include "trippy.h" +#endif // we can have an arbitrary number of samples but you will run out of memory at some point // sound sample files are 22khz 16 bit signed PCM format - see the sample include files for examples // you can change the sample rate to whatever you want but most testing was done at 22khz. 44khz probably works but not much testing was done @@ -82,13 +91,24 @@ bool debug = false; #define NUM_SAMPLES (sizeof(sample)/sizeof(sample_t)) +// What turning the encoder does. Press the encoder to step through these +// for the currently selected channel: select -> sample -> volume -> pitch. enum { - MODE_PLAY = 0, - MODE_CONFIG, - MODE_COUNT // how many modes we got + MODE_SELECT = 0, // turn = choose channel (1-8) + MODE_SAMPLE, // turn = change the sample of the selected channel + MODE_VOLUME, // turn = change the volume of the selected channel + MODE_PITCH, // turn = change the pitch of the selected channel + MODE_COUNT }; -int display_mode = MODE_PLAY; +int display_mode = MODE_SELECT; + +// Encoder direction. 1 = normal, -1 = inverted. The CI builds both variants by +// passing -DCHAMBORD_ENC_DIR=... ; to change it locally edit the default below. +#ifndef CHAMBORD_ENC_DIR +#define CHAMBORD_ENC_DIR 1 +#endif +const int ENCODER_DIR = CHAMBORD_ENC_DIR; // on the long ec11 these are swapped A 19, B 18 const int encoderA_pin = 18; @@ -100,7 +120,7 @@ const int encoderSW_pin = 28; #include RotaryEncoder encoder(encoderB_pin, encoderA_pin, RotaryEncoder::LatchMode::FOUR3); -void checkEncoderPosition() { +void __not_in_flash_func(checkEncoderPosition)() { encoder.tick(); // call tick() to check the state. } @@ -129,8 +149,6 @@ inline bool canBufferAudioOutput() { return (DAC.availableForWrite()); } -int32_t samplesum = 0; // needed by timer0 - // these are irq timers for handling led signals #include "timers.h" @@ -254,6 +272,128 @@ bool scanbuttons(void) #include "seq.h" +// --------------------------------------------------------------------------- +// Audio mixing: precomputed per-voice gain + attack ramp (anti-click) +// --------------------------------------------------------------------------- +#define RAMP_LEN 48 // ~1.1ms attack at 44.1kHz to kill retrigger clicks +int32_t voice_gain[NTRACKS]; // Q15 gain (level/1000) precomputed so the mix loop has no divide +uint16_t voice_ramp[NTRACKS]; // attack ramp counter per track, counts up to RAMP_LEN + +// set a track level (0-1000) and recompute its Q15 mix gain +inline void setLevel(int track, int level) { + if (level < 0) level = 0; + if (level > 1000) level = 1000; + voice[track].level = level; + voice_gain[track] = ((int32_t)level * 32768) / 1000; +} + +// --------------------------------------------------------------------------- +// Settings persistence to flash (survives power cycle) +// stored: per-track sample + pitch, plus CV/selected track +// --------------------------------------------------------------------------- +#define EE_MAGIC 0xB4 +#define EE_SIZE 256 +struct persist_t { + uint8_t magic; + uint8_t cv_track; + uint8_t current_track; + int16_t sample[NTRACKS]; + uint16_t incr[NTRACKS]; + int16_t level[NTRACKS]; +}; + +bool settings_dirty = false; +uint32_t settings_change_ms = 0; +inline void markDirty() { settings_dirty = true; settings_change_ms = millis(); } + +void loadSettings() { + persist_t p; + EEPROM.get(0, p); + if (p.magic != EE_MAGIC) return; // nothing valid stored yet + cv_track = p.cv_track; + current_track = p.current_track % NTRACKS; + for (int t = 0; t < NTRACKS; ++t) { + if (p.sample[t] >= 0 && p.sample[t] < (int)NUM_SAMPLES) voice[t].sample = p.sample[t]; + voice[t].sampleincrement = constrain(p.incr[t], 2048, 8192); + setLevel(t, p.level[t]); + } +} + +void saveSettings() { + persist_t p; + p.magic = EE_MAGIC; + p.cv_track = cv_track; + p.current_track = current_track; + for (int t = 0; t < NTRACKS; ++t) { + p.sample[t] = voice[t].sample; + p.incr[t] = voice[t].sampleincrement; + p.level[t] = voice[t].level; + } + EEPROM.put(0, p); + EEPROM.commit(); // idles core1 internally during the flash erase/program +} + +// --------------------------------------------------------------------------- +// Unified LED feedback engine +// Each LED is a single GPIO over a dual-colour part: GPIO low = red (floor), +// high = green, and a duty cycle in between = amber. ledRender() does software +// PWM from led_duty[]; updateUI() sets led_duty[] from the current UI state. +// --------------------------------------------------------------------------- +#define LED_LEVELS 8 // PWM steps -> 1ms tick gives ~125Hz refresh +#define LED_RED 0 // GPIO mostly low -> red (also the idle floor colour) +#define LED_AMBER 4 // ~50% duty -> amber +#define LED_GREEN 8 // GPIO high -> green + +volatile uint8_t led_duty[8] = {0}; +volatile uint8_t led_phase = 0; + +// software PWM, called every 1ms from the ISR timer (kept in RAM so it never +// waits on the flash bus that the audio core is reading samples from) +void __not_in_flash_func(ledRender)() { + uint8_t ph = led_phase; + for (int i = 0; i < 8; ++i) { + digitalWrite(led[i], (led_duty[i] > ph) ? HIGH : LOW); + } + led_phase = (ph + 1) % LED_LEVELS; +} + +// When the encoder hasn't been touched for this long, the row turns into a +// trigger-activity display ("screensaver"). While you ARE editing, the row +// shows only the selected channel's page so nothing distracts you. +#define SCREENSAVER_MS 10000 +uint32_t ui_activity_ms = 0; + +// Recompute per-channel LED colours. While editing, feedback lives only on the +// selected channel's own LED, colour-coded by page (green = choosing a thing, +// amber = setting an amount): +// green steady = SELECT (turn to pick the channel) +// green blinking = SAMPLE (turn to change the sample) +// amber steady = VOLUME (turn to change the volume) +// amber blinking = PITCH (turn to change the pitch) +// After SCREENSAVER_MS idle, every channel flashes green on a trigger instead. +// Throttled by the caller (~5ms) so it doesn't hog the flash bus. +void updateUI() { + uint32_t now = millis(); + bool blink = (now / 150) & 1; // ~3 Hz + bool screensaver = (now - ui_activity_ms) > SCREENSAVER_MS; + + for (int i = 0; i < 8; ++i) { + uint8_t d = LED_RED; // idle floor + + if (screensaver) { + if (voice[i].isPlaying) d = LED_GREEN; // trigger-activity display + } else if (i == current_track) { + switch (display_mode) { + case MODE_SELECT: d = LED_GREEN; break; // green steady + case MODE_SAMPLE: d = blink ? LED_GREEN : LED_RED; break; // green blink + case MODE_VOLUME: d = LED_AMBER; break; // amber steady + case MODE_PITCH: d = blink ? LED_AMBER : LED_RED; break; // amber blink + } + } + led_duty[i] = d; + } +} + #define DISPLAY_TIME 2000 // time in ms to display numbers on LEDS int32_t display_timer; @@ -293,19 +433,11 @@ void setup() { } analogReadResolution(10); - // This is the timer for audio rate rendering: - if (ITimer0.attachInterruptInterval(TIMER0_INTERVAL_MS, TimerHandler0)) // that's 48kHz - { - if (debug) Serial.print(F("Starting ITimer0 OK, millis() = ")); Serial.println(millis()); - } else { - if (debug) Serial.println(F("Can't set ITimer0. Select another freq. or timer")); - } + // Audio is rendered free-running on core1 and paced by the I2S DMA buffer + // (DAC.write blocks when full), so no dedicated audio-rate timer is needed. - // These are the timers that set the balance of green/red for feedback, hits, etc. - ISR_timer.setInterval(TINTERVAL_2mS, b2mS); - ISR_timer.setInterval(TINTERVAL_5mS, b5mS); - ISR_timer.setInterval(TINTERVAL_7mS, b7mS); - ISR_timer.setInterval(TINTERVAL_10mS, b10mS); + // single LED software-PWM renderer (replaces the old b2/b5/b7/b10 handlers) + ISR_timer.setInterval(1L, ledRender); if (debug) Serial.flush(); @@ -361,6 +493,14 @@ void setup() { */ //display_value(NUM_SAMPLES); // show number of samples on the display + // init per-voice mix gains and ramps, then restore saved settings from flash + for (int t = 0; t < NTRACKS; ++t) { + setLevel(t, voice[t].level); + voice_ramp[t] = RAMP_LEN; // not ramping until first trigger + } + EEPROM.begin(EE_SIZE); + loadSettings(); + startAudio(); } @@ -375,11 +515,12 @@ void loop() { scanbuttons(); // actually jack inputs // update the channel & play sample - for (int i = 0; i <= 8; ++i) { // scan all the buttons + for (int i = 0; i < NTRACKS; ++i) { // scan all the trigger inputs (not the encoder button) if (button[i]) { //digitalWrite(led[i], 1); // we're doing the leds in timers.h voice[i].sampleindex = 0; // trigger sample for this track voice[i].isPlaying = true; + voice_ramp[i] = 0; // restart attack ramp -> click-free retrigger } } @@ -387,7 +528,7 @@ void loop() { int encoder_pos = encoder.getPosition(); if ( (encoder_pos != encoder_pos_last )) { - encoder_delta = encoder_pos - encoder_pos_last; + encoder_delta = (encoder_pos - encoder_pos_last) * ENCODER_DIR; } // set play mode 0 play 1 edit pitch, 2 edit channel sample, @@ -413,47 +554,43 @@ void loop() { // use encoder and button if (encoder_delta) { - - // mode 0, channel select - if ( display_mode == 0 && ! enc_button.pressed() ) { - // select a channel in mode one + ui_activity_ms = now; // wake the display out of screensaver - // We add 8 to ensure we stay in positive range + // MODE_SELECT: turn to choose which channel (1-8) you're working on + if ( display_mode == MODE_SELECT && ! enc_button.pressed() ) { + // +8 keeps us in positive range for the modulo current_track = (current_track + encoder_delta + 8) % 8; - - // constrain(current_track, 0, 7); - // reset level if CV is in use - - voice[current_track].level = 300; - - } - - // mode 1, adjust pitch. - if ( display_mode == 1 ) { - int pitch_change = voice[current_track].sampleincrement - (encoder_delta * 10); - constrain(pitch_change, 2048, 8192); - - // divisible by 2 and it won't click - if (pitch_change % 2 == 0) { - voice[current_track].sampleincrement = pitch_change; - } + // NOTE: selecting a channel no longer changes its volume (that caused clicks) } - // permits us to switch sample on channel in mode 2 - if ( display_mode == 2 ) { - int result ; - if (encoder_delta > 0) { - result = voice[current_track].sample + 8 ; - } else { - result = voice[current_track].sample - 8 ; - } - + // MODE_SAMPLE: turn to change the sample of the selected channel + if ( display_mode == MODE_SAMPLE ) { + int result = voice[current_track].sample + (encoder_delta > 0 ? 8 : -8); if (debug) Serial.println(result); - if (result >= 0 && result <= NUM_SAMPLES - 1) { voice[current_track].sample = result; + voice_ramp[current_track] = 0; // re-attack to soften the swap if it's ringing + markDirty(); } } + + // MODE_VOLUME: turn to change the volume of the selected channel + if ( display_mode == MODE_VOLUME ) { + setLevel(current_track, voice[current_track].level + encoder_delta * 50); + markDirty(); + } + + // MODE_PITCH: turn to change the pitch of the selected channel. + // Unity = 4096 (1:12 fixed point); 2048..8192 spans one octave down..up. + // 128/detent (~2.6 detents per semitone) - the old step of 10 was so tiny + // (~0.24%/detent) that it felt like nothing happened. + if ( display_mode == MODE_PITCH ) { + int pitch_change = voice[current_track].sampleincrement + (encoder_delta * 128); + pitch_change = constrain(pitch_change, 2048, 8192); // constrain returns a value, must assign + pitch_change &= ~1; // keep even -> no click + voice[current_track].sampleincrement = pitch_change; + markDirty(); + } } /// only set new pos last after use of the delta @@ -461,31 +598,53 @@ void loop() { encoder_delta = 0; // we've used it - // if button one was held for more than 75 millis set current track as CV track. + // Encoder button: + // short press -> step turn-function: select -> pitch -> sample -> select + // long press (>700ms) -> toggle CV control of the selected channel's volume if (enc_button.rose()) { + ui_activity_ms = now; btnOneLastTime = enc_button.previousDuration(); - if (btnOneLastTime > 700) cv_track = current_track ; + if (btnOneLastTime > 700) { + cv_track = (cv_track == current_track) ? 99 : current_track; // 99 = CV off + markDirty(); + } } else if (enc_button.pressed() ) { - // start tracking time encoder button held + ui_activity_ms = now; encoder_push_millis = now; - // switch mode display_mode = display_mode + 1; - if ( display_mode > 2) { // switched back to play mode - display_mode = 0; - } + if ( display_mode >= MODE_COUNT) display_mode = MODE_SELECT; } else { encoder_push_millis = 0; encoder_held = false; } - // change sample volume level on current_track with cv in. - // ADC is on a timer - if (cv_track <= NUM_SAMPLES - 1) { - if (CV != CV_last) { - constrain(CV,0, 350); - voice[cv_track].level = CV; - CV_last = CV; + // CV input modulates the volume of the assigned channel (if any). + // CV-driven volume is intentionally NOT persisted (would wear flash). + if (cv_track < NTRACKS) { + int16_t cvv = CV; + if (cvv != CV_last) { + setLevel(cv_track, constrain(cvv, 0, 350)); + CV_last = cvv; + } + } + + // Refresh UI + sample CV at ~200Hz (throttled so core0 doesn't starve the + // audio core's flash reads -> this was a source of pops while turning). + static uint32_t ui_last = 0; + if (now - ui_last >= 5) { + ui_last = now; + CV = analogRead(A0); + updateUI(); + } + // Debounced auto-save, but only while nothing is sounding, so the brief + // audio pause during the flash write happens in silence (no pop). + if (settings_dirty && (now - settings_change_ms) > 1500) { + bool anyPlaying = false; + for (int i = 0; i < NTRACKS; ++i) if (voice[i].isPlaying) { anyPlaying = true; break; } + if (!anyPlaying) { + saveSettings(); + settings_dirty = false; } } @@ -529,31 +688,6 @@ void loop() { */ } -void update_leds() { - // update the channel led & play sample - for (int i = 0; i <= 8; ++i) { // scan all the buttons - if (button[i]) { - digitalWrite(led[i], 1); - //voice[i].isPlaying = false; - voice[i].sampleindex = 0; // trigger sample for this track - voice[i].isPlaying = true; - - } else { - // not a hit, turn it off, except for pin 7 in mode 1&2 - /* - if (i != current_track && display_mode == 0) { - digitalWrite(led[i], 0); - }*/ - if (i != current_track ) { - if ( ( display_mode != 0 && i != 7 ) || ( display_mode == 0 ) ) { - digitalWrite(led[i], 0); - } - } - //voice[i].isPlaying = false; - } - } -} - // second core setup @@ -562,59 +696,52 @@ void setup1() { delay (2000); // wait for main core to start up perhipherals } -// second core calculates samples and sends to DAC -void loop1() { - - // check if we have a new bpm value from interrupt - // since debouncing is flaky, force more than 1 bpm diff - //if (ra.Value() != bpm && ra.Value() > 49) { - /* if (RPM > bpm + 1 || RPM < bpm -1 && RPM > 49) { - //reset = true; //reset seq - bpm = RPM; - } - do_clocks(); // process sequencer clocks +// render a single output frame by mixing all playing voices +// (resampling with linear interpolation, precomputed gain, attack ramp, +// then tanh soft-clip for headroom instead of a hard clip) +int16_t __not_in_flash_func(renderAudioFrame)() { + int32_t samplesum = 0; + + /* oct 22 2023 resampling code + to change pitch we step through the sample by .5 rate for half pitch up to 2 for double pitch + sample.sampleindex is a fixed point 20:12 fractional number + we step through the sample array by sampleincrement - sampleincrement is treated as a 1 bit integer and a 12 bit fraction + for sample lookup sample.sampleindex is converted to a 20 bit integer which limits the max sample size to 2**20 or about 1 million samples, about 45 seconds */ - - - if (counter == 1) { // don't calculate too quickly - samplesum = 0; - int32_t newsample, filtersum; - uint32_t index; - int16_t samp0, samp1, delta, tracksample; - - /* oct 22 2023 resampling code - to change pitch we step through the sample by .5 rate for half pitch up to 2 for double pitch - sample.sampleindex is a fixed point 20:12 fractional number - we step through the sample array by sampleincrement - sampleincrement is treated as a 1 bit integer and a 12 bit fraction - for sample lookup sample.sampleindex is converted to a 20 bit integer which limits the max sample size to 2**20 or about 1 million samples, about 45 seconds - */ - for (int track = 0; track < NTRACKS; ++track) { // look for samples that are playing, scale their volume, and add them up - tracksample = voice[track].sample; // precompute for a little more speed below - index = voice[track].sampleindex >> 12; // get the integer part of the sample increment - if (index >= sample[tracksample].samplesize) voice[track].isPlaying = false; // have we played the whole sample? - if (voice[track].isPlaying) { // if sample is still playing, do interpolation - samp0 = sample[tracksample].samplearray[index]; // get the first sample to interpolate - samp1 = sample[tracksample].samplearray[index + 1]; // get the second sample - delta = samp1 - samp0; - newsample = (int32_t)samp0 + ((int32_t)delta * ((int32_t)voice[track].sampleindex & 0x0fff)) / 4096; // interpolate between the two samples - //samplesum+=((int32_t)samp0+(int32_t)delta*(sample[i].sampleindex & 0x0fff)/4096)*sample[i].play_volume; - samplesum += (newsample * (127 * voice[track].level)) / 1000; - voice[track].sampleindex += voice[track].sampleincrement; // add step increment - } + for (int track = 0; track < NTRACKS; ++track) { // look for samples that are playing, scale their volume, and add them up + if (!voice[track].isPlaying) continue; + int16_t tracksample = voice[track].sample; + uint32_t index = voice[track].sampleindex >> 12; // integer part of the fixed-point index + // stop one sample early so the index+1 interpolation read stays in bounds + if (index >= sample[tracksample].samplesize - 1) { + voice[track].isPlaying = false; + continue; } - - samplesum = samplesum >> 7; // adjust for play_volume multiply above - if (samplesum > 32767) samplesum = 32767; // clip if sample sum is too large - if (samplesum < -32767) samplesum = -32767; - - /* - // filter - if (filter_fc <= LPF_MAX) { - filtersum = (uint8_t)filter_lpf( (int64_t)samplesum, filter_fc ,filter_q); - } - */ - counter = 0; // reset counter until the audio callback sets it again in timers.h - + int16_t samp0 = sample[tracksample].samplearray[index]; // first sample to interpolate + int16_t samp1 = sample[tracksample].samplearray[index + 1]; // second sample + int32_t delta = samp1 - samp0; + int32_t newsample = (int32_t)samp0 + (delta * (int32_t)(voice[track].sampleindex & 0x0fff)) / 4096; // interpolate + + int32_t s = ((int32_t)newsample * voice_gain[track]) >> 15; // apply precomputed Q15 gain (no per-sample divide) + if (voice_ramp[track] < RAMP_LEN) { // brief attack ramp suppresses retrigger clicks + s = (s * voice_ramp[track]) / RAMP_LEN; + voice_ramp[track]++; + } + samplesum += s; + voice[track].sampleindex += voice[track].sampleincrement; // advance by pitch step } + // headroom + soft clip: a single full-level hit passes ~unchanged while + // simultaneous voices saturate smoothly through tanh instead of hard-clipping. + float norm = samplesum * (1.0f / 32767.0f); + return (int16_t)(tanhf(norm) * 32767.0f); +} + +// second core calculates samples and sends them to the DAC. +// DAC.write() blocks on the I2S DMA ring buffer, which paces this loop at the +// sample rate, so no separate audio-rate timer is required. +void __not_in_flash_func(loop1)() { + int16_t out = renderAudioFrame(); + DAC.write(out); // left + DAC.write(out); // right } diff --git a/firmware/Pikobeats/timers.h b/firmware/Pikobeats/timers.h index 0752981..d294dd9 100644 --- a/firmware/Pikobeats/timers.h +++ b/firmware/Pikobeats/timers.h @@ -3,33 +3,12 @@ // These define's must be placed at the beginning before #include "TimerInterrupt_Generic.h" // _TIMERINTERRUPT_LOGLEVEL_ from 0 to 4 #define _TIMERINTERRUPT_LOGLEVEL_ 4 -#define TIMER0_INTERVAL_MS 22.67573698 -// 44100 - -//20.833333333333 -// 48khz for the audio rate timer // Can be included as many times as necessary, without `Multiple Definitions` Linker Error #include "RPi_Pico_TimerInterrupt.h" -volatile int counter = 0; - -// Init RPI_PICO_Timer, can use any from 0-15 pseudo-hardware timers -RPI_PICO_Timer ITimer0(0); - -bool TimerHandler0(struct repeating_timer *t) { - (void) t; - bool sync = true; - - if ( DAC.availableForWrite()) { - DAC.write(int16_t(samplesum)); // left - DAC.write(int16_t(samplesum)); // right - counter = 1; - } - - return true; -} - +// Audio is now rendered free-running on core1 (loop1) and paced by the I2S DMA +// buffer, so the old audio-rate timer (ITimer0 / TimerHandler0) has been removed. // To be included only in main(), .ino with setup() to avoid `Multiple Definitions` Linker Error #include "RPi_Pico_ISR_Timer.h" @@ -61,99 +40,8 @@ typedef void (*irqCallback) (); #define HW_TIMER_INTERVAL_MS 1L -#define TINTERVAL_2mS 2L -#define TINTERVAL_5mS 5L -#define TINTERVAL_7mS 7L // blue green 7, red green 6 -#define TINTERVAL_10mS 12L - -#define LED_INT_4_MS 4L - -volatile bool toggle_one = false; -volatile bool toggle_two = false; -volatile int16_t CV; - - -/* Red / Green Variant, red on */ - -void b2mS() { - // update the channel led - for (int i = 0; i <= 8; ++i) { // scan all the buttons - if (button[i]) { - digitalWrite(led[i], 1); - } else { - if (i != current_track ) { - if ( ( display_mode != 0 && i != 7 ) || ( display_mode == 0 ) ) { - digitalWrite(led[i], 0); - } - } - } - } -} -void b5mS() { - if (display_mode == 2 || display_mode == 1) { - digitalWrite(led[current_track], 1); - } - if (voice[current_track].isPlaying == false) { - digitalWrite( led[current_track], 1); - } -} -void b7mS() { - if (display_mode == 2) { - digitalWrite(led[current_track], 0); - } -} - -void b10mS() { - if (voice[current_track].isPlaying == false) { - digitalWrite( led[current_track], 0); - } - if (display_mode == 1) { - digitalWrite(led[current_track], 0); - } - CV = analogRead(A0); -} - - +volatile int16_t CV; // latest CV ADC reading (sampled in updateUI()) - -/* Blue / Green Variant */ - -/* -void b2mS() { - // update the channel led - for (int i = 0; i <= 8; ++i) { // scan all the buttons - if (button[i]) { - digitalWrite(led[i], 1); - } else { - if (i != current_track ) { - if ( ( display_mode != 0 && i != 7 ) || ( display_mode == 0 ) ) { - digitalWrite(led[i], 0); - } - } - } - } -} -void b5mS() { - if (display_mode == 2 || display_mode == 1) { - digitalWrite(led[7], 1); - } - if (voice[current_track].isPlaying == false) { - digitalWrite( led[current_track], 1); - } -} -void b7mS() { - if (display_mode == 1) { - digitalWrite(led[7], 0); - } -} - -void b10mS() { - if (voice[current_track].isPlaying == false) { - digitalWrite( led[current_track], 0); - } - if (display_mode == 2) { - digitalWrite(led[7], 0); - } - CV = analogRead(A0); -} -*/ +// LED feedback is now handled by the unified ledRender()/updateUI() engine in +// Pikobeats.ino. The old b2/b5/b7/b10 mS handlers (and their blue/green +// variant) have been removed. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..63bffd4 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple" + } + } +}