diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ddf65..d200ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,22 @@ -# Changelog +# Changelog All notable changes to the Spore firmware are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses [Semantic Versioning](https://semver.org/) (`vMAJOR.MINOR.PATCH`). ## [Unreleased] +- **Fix: audio crackle in tempo-synced delay.** When synced to BPM (1/4, 1/8, 1/8., 1/16), + the delay line crackled heavily due to two issues: + 1. Incoming MIDI clock tick intervals were measured with 1 ms resolution (`System::GetNow()`), + causing the calculated tempo to oscillate by ±1 BPM on every tick due to integer quantization + and USB jitter. `MidiClock` now tracks ticks with microsecond resolution (`System::GetUs()`) + over a 24-tick (1 quarter note) sliding window. + 2. The delay buffer read pointer previously jumped instantaneously on tempo fluctuations without + smoothing. `GlobalFx` now slews the delay time per sample with a one-pole lowpass filter + ($\tau \approx 50\text{ ms}$), providing continuous fractional interpolation and tape-delay + smoothness without audio discontinuities. +- **Toolchain: support official Arm GNU Embedded Toolchain** alongside xPack in `scripts/env.*`, + resolving compatibility on Windows systems where Smart App Control blocks unsigned MinGW DLLs. ## [v0.5.1] - 2026-09-07 - **Maintenance release: vendored libDaisy bumped to upstream HEAD** (`c02245d` -> `cc146d5`). diff --git a/scripts/env.ps1 b/scripts/env.ps1 index b59b5de..c2e0676 100644 --- a/scripts/env.ps1 +++ b/scripts/env.ps1 @@ -4,7 +4,7 @@ $envRoot = Split-Path $PSScriptRoot -Parent $tc = Join-Path $envRoot "toolchain" if (Test-Path $tc) { - $arm = Get-ChildItem -Path $tc -Directory -Filter "xpack-arm-none-eabi-gcc-*" -ErrorAction SilentlyContinue | Select-Object -First 1 + $arm = Get-ChildItem -Path $tc -Directory -Filter "*arm-none-eabi*" -ErrorAction SilentlyContinue | Select-Object -First 1 $bt = Get-ChildItem -Path $tc -Directory -Filter "xpack-windows-build-tools-*" -ErrorAction SilentlyContinue | Select-Object -First 1 if ($arm) { $env:PATH = (Join-Path $arm.FullName "bin") + ";" + $env:PATH } if ($bt) { $env:PATH = (Join-Path $bt.FullName "bin") + ";" + $env:PATH } diff --git a/scripts/env.sh b/scripts/env.sh index 8d1f04f..ed1e65b 100644 --- a/scripts/env.sh +++ b/scripts/env.sh @@ -4,7 +4,7 @@ _envroot="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)" _tc="$_envroot/toolchain" if [ -d "$_tc" ]; then - _arm=$(ls -d "$_tc"/xpack-arm-none-eabi-gcc-*/bin 2>/dev/null | head -1) + _arm=$(ls -d "$_tc"/*arm-none-eabi*/bin 2>/dev/null | head -1) _bt=$(ls -d "$_tc"/xpack-windows-build-tools-*/bin 2>/dev/null | head -1) [ -n "$_arm" ] && export PATH="$_arm:$PATH" [ -n "$_bt" ] && export PATH="$_bt:$PATH" diff --git a/src/fx/effects.h b/src/fx/effects.h index 81b1417..d7c258d 100644 --- a/src/fx/effects.h +++ b/src/fx/effects.h @@ -45,8 +45,13 @@ class GlobalFx { bpm_ = 120.0f; sync_ = 0; lp_l_ = lp_r_ = 0.0f; + target_delay_ = 48000.0f * 0.3f; + cur_delay_ = target_delay_; + delay_slew_coef_ = 1.0f - expf(-1.0f / (0.05f * sample_rate)); s_fx_del_l.Init(); s_fx_del_r.Init(); + s_fx_del_l.SetDelay(cur_delay_); + s_fx_del_r.SetDelay(cur_delay_); reverb_.Init(sample_rate); reverb_.SetFeedback(0.85f); reverb_.SetLpFreq(12000.0f); @@ -59,6 +64,9 @@ class GlobalFx { s_fx_del_l.Reset(); s_fx_del_r.Reset(); lp_l_ = lp_r_ = 0.0f; + cur_delay_ = target_delay_; + s_fx_del_l.SetDelay(cur_delay_); + s_fx_del_r.SetDelay(cur_delay_); } wet_ramp_ = 0.0f; // fade the newly-engaged effect in from silence (~30 ms) mode_ = m; @@ -72,6 +80,9 @@ class GlobalFx { s_fx_del_l.Reset(); s_fx_del_r.Reset(); lp_l_ = lp_r_ = 0.0f; + cur_delay_ = target_delay_; + s_fx_del_l.SetDelay(cur_delay_); + s_fx_del_r.SetDelay(cur_delay_); reverb_.Init(sr_); // clears ReverbSc's internal state/tail wet_ramp_ = 0.0f; } @@ -96,8 +107,7 @@ class GlobalFx { } if (ds < 1.0f) ds = 1.0f; if (ds > static_cast(kFxDelayMax - 1)) ds = static_cast(kFxDelayMax - 1); - s_fx_del_l.SetDelay(ds); - s_fx_del_r.SetDelay(ds); + target_delay_ = ds; fb_ = daisysp::fmap(fb, 0.0f, kDelayFbMax, daisysp::Mapping::LINEAR); float tone_hz = @@ -121,6 +131,16 @@ class GlobalFx { const float wmix = mix_ * wet_ramp_; // ramped wet level avoids the engage transient float dry_l = l[i], dry_r = r[i]; if (mode_ == DELAY) { + float diff = target_delay_ - cur_delay_; + if (fabsf(diff) > 0.001f) { + cur_delay_ += diff * delay_slew_coef_; + s_fx_del_l.SetDelay(cur_delay_); + s_fx_del_r.SetDelay(cur_delay_); + } else if (cur_delay_ != target_delay_) { + cur_delay_ = target_delay_; + s_fx_del_l.SetDelay(cur_delay_); + s_fx_del_r.SetDelay(cur_delay_); + } float d_l = s_fx_del_l.Read(); float d_r = s_fx_del_r.Read(); // One-pole lowpass in the feedback path = darker repeats. @@ -157,6 +177,9 @@ class GlobalFx { float bpm_; // clock tempo for synced delay int sync_; // 0 = free (knob ms), 1..4 = tempo division Mode mode_; + float target_delay_; + float cur_delay_; + float delay_slew_coef_; }; } // namespace synthbox diff --git a/src/io/clock.h b/src/io/clock.h index 6d211ae..3631e9a 100644 --- a/src/io/clock.h +++ b/src/io/clock.h @@ -16,38 +16,70 @@ namespace synthbox { class MidiClock { public: + static constexpr int kHistoryTicks = 24; // 1 quarter note (24 PPQN) + void Init() { bpm_ = 120.0f; est_ = 120.0f; ext_ = false; running_ = false; - last_tick_ms_ = 0; + tick_count_ = 0; + head_ = 0; last_seen_ms_ = 0; + for (int i = 0; i < kHistoryTicks; ++i) { + history_us_[i] = 0; + } } // Called on each incoming MIDI timing-clock tick (24 per quarter note). - void Tick(uint32_t now) { - if (last_tick_ms_) { - uint32_t dt = now - last_tick_ms_; - if (dt > 1 && dt < 300) { - float inst = 60000.0f / (dt * 24.0f); // BPM from one tick interval - est_ = est_ * 0.85f + inst * 0.15f; // smooth jitter + // now_us is in microseconds (from daisy::System::GetUs()). + void Tick(uint32_t now_us) { + last_seen_ms_ = daisy::System::GetNow(); + ext_ = true; + running_ = true; + + if (tick_count_ > 0) { + // Measure time over the sliding window (up to 24 ticks == 1 quarter note). + int span = tick_count_ < kHistoryTicks ? tick_count_ : kHistoryTicks; + int oldest_idx = (head_ - span + kHistoryTicks) % kHistoryTicks; + uint32_t dt_us = now_us - history_us_[oldest_idx]; + + // 1 beat = 60,000,000 us. span ticks = (span / 24) beats. + // Expected dt_us = (60,000,000 * span) / (BPM * 24). + // Therefore inst BPM = (60,000,000 * span) / (dt_us * 24). + if (dt_us > 1000 && dt_us < 10000000) { + float inst = (60000000.0f * span) / (static_cast(dt_us) * 24.0f); + if (inst >= 20.0f && inst <= 300.0f) { + if (tick_count_ < 6) { + est_ = inst; // lock on fast when playback starts + } else { + est_ = est_ * 0.90f + inst * 0.10f; // smooth any residual jitter + } + } } } - last_tick_ms_ = now; - last_seen_ms_ = now; - ext_ = true; + + history_us_[head_] = now_us; + head_ = (head_ + 1) % kHistoryTicks; + if (tick_count_ < kHistoryTicks) { + tick_count_++; + } + } + void Start() { running_ = true; + tick_count_ = 0; } - void Start() { running_ = true; } void Continue() { running_ = true; } void Stop() { running_ = false; } void SetInternalBpm(float b) { bpm_ = b < 20.0f ? 20.0f : (b > 300.0f ? 300.0f : b); } // Call at control rate: drop back to internal tempo if clock stops arriving. - void Update(uint32_t now) { - if (ext_ && (now - last_seen_ms_) > 500) ext_ = false; + void Update(uint32_t now_ms) { + if (ext_ && (now_ms - last_seen_ms_) > 500) { + ext_ = false; + tick_count_ = 0; + } } float Bpm() const { return ext_ ? est_ : bpm_; } @@ -57,7 +89,10 @@ class MidiClock { private: float bpm_, est_; bool ext_, running_; - uint32_t last_tick_ms_, last_seen_ms_; + int tick_count_; + int head_; + uint32_t history_us_[kHistoryTicks]; + uint32_t last_seen_ms_; }; } // namespace synthbox diff --git a/src/io/midi_in.h b/src/io/midi_in.h index acca69e..6fd58f1 100644 --- a/src/io/midi_in.h +++ b/src/io/midi_in.h @@ -78,10 +78,9 @@ inline bool PumpMidi(daisy::MidiUsbHandler& midi, MidiContext ctx) { auto msg = midi.PopEvent(); switch (msg.type) { case daisy::SystemRealTime: { // tempo clock / transport from GUI or device - uint32_t now = daisy::System::GetNow(); switch (msg.srt_type) { case daisy::TimingClock: - clock.Tick(now); + clock.Tick(daisy::System::GetUs()); break; case daisy::Start: clock.Start();