Created
May 18, 2026 01:41
-
-
Save matpalm/f40584dd59164fefbf35ac26be632683 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include <array> | |
| #include <cmath> | |
| #include <cstdint> | |
| #include "daisy_patch.h" | |
| #include "daisysp.h" | |
| using namespace daisy; | |
| namespace | |
| { | |
| constexpr size_t kNumVoices = 4; | |
| constexpr float kMaxSeconds = 11.0f; | |
| constexpr float kMinSeconds = 0.020f; // flanger-like minimum at full CCW | |
| DaisyPatch patch; | |
| float g_sample_rate = 48000.0f; | |
| size_t g_max_len_samples = 528000; | |
| float DSY_SDRAM_BSS circ[kNumVoices][530000]; | |
| size_t write_pos[kNumVoices] = {0, 0, 0, 0}; | |
| size_t eff_len[kNumVoices] = {2048, 2579, 3121, 4001}; | |
| struct VoicePlayback | |
| { | |
| bool active = false; | |
| bool reverse = false; | |
| float read_pos = 0.0f; | |
| size_t samples_remaining = 0; | |
| size_t total_samples = 1; | |
| }; | |
| VoicePlayback voices[kNumVoices]; | |
| float env_amount = 0.0f; | |
| float reverse_probability = 0.5f; | |
| float len_knob = 0.0f; | |
| float feedback_amount = 0.0f; | |
| float fdn_state[kNumVoices] = {0.0f, 0.0f, 0.0f, 0.0f}; | |
| uint32_t rng_state = 0x13579BDFu; | |
| inline uint32_t Xorshift32() | |
| { | |
| uint32_t x = rng_state; | |
| x ^= x << 13; | |
| x ^= x >> 17; | |
| x ^= x << 5; | |
| rng_state = x; | |
| return x; | |
| } | |
| inline float Rand01() | |
| { | |
| return (float)(Xorshift32() & 0x00FFFFFFu) / 16777215.0f; | |
| } | |
| bool IsPrime(size_t n) | |
| { | |
| if(n < 2) | |
| { | |
| return false; | |
| } | |
| if((n & 1u) == 0u) | |
| { | |
| return n == 2; | |
| } | |
| for(size_t d = 3; d * d <= n; d += 2) | |
| { | |
| if((n % d) == 0) | |
| { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| size_t NextPrime(size_t n) | |
| { | |
| if(n < 2) | |
| { | |
| return 2; | |
| } | |
| if((n & 1u) == 0u) | |
| { | |
| n += 1; | |
| } | |
| while(!IsPrime(n)) | |
| { | |
| n += 2; | |
| } | |
| return n; | |
| } | |
| int WrapIndex(int idx, size_t len) | |
| { | |
| const int l = (int)len; | |
| while(idx < 0) | |
| { | |
| idx += l; | |
| } | |
| while(idx >= l) | |
| { | |
| idx -= l; | |
| } | |
| return idx; | |
| } | |
| size_t ZeroCrossNear(size_t voice, int center, int window) | |
| { | |
| const size_t len = eff_len[voice]; | |
| int best = center; | |
| int best_dist = window + 1; | |
| for(int d = 0; d <= window; d++) | |
| { | |
| const int cands[2] = {center - d, center + d}; | |
| for(int k = 0; k < 2; k++) | |
| { | |
| const int i0 = WrapIndex(cands[k], len); | |
| const int i1 = WrapIndex(cands[k] + 1, len); | |
| const float a = circ[voice][i0]; | |
| const float b = circ[voice][i1]; | |
| const bool cross = (a <= 0.0f && b > 0.0f) || (a >= 0.0f && b < 0.0f); | |
| if(cross) | |
| { | |
| if(d < best_dist) | |
| { | |
| best = i0; | |
| best_dist = d; | |
| } | |
| break; | |
| } | |
| } | |
| if(best_dist == 0) | |
| { | |
| break; | |
| } | |
| } | |
| return (size_t)WrapIndex(best, len); | |
| } | |
| void UpdateLengths() | |
| { | |
| // knob3: fully CCW -> short flanger-like times, fully CW -> 11s max. | |
| const float max_samps_f = g_sample_rate * kMaxSeconds; | |
| const float min_samps_f = g_sample_rate * kMinSeconds; | |
| const float longest_target = min_samps_f + (max_samps_f - min_samps_f) * len_knob; | |
| const float ratios[kNumVoices] = {0.53f, 0.67f, 0.83f, 1.00f}; | |
| for(size_t v = 0; v < kNumVoices; v++) | |
| { | |
| size_t target = (size_t)(longest_target * ratios[v]); | |
| if(target < 257) | |
| { | |
| target = 257; | |
| } | |
| if(target > g_max_len_samples) | |
| { | |
| target = g_max_len_samples; | |
| } | |
| // Use distinct primes so all lengths are pairwise relatively prime. | |
| size_t prime = NextPrime(target + v * 10); | |
| if(prime > g_max_len_samples) | |
| { | |
| prime = NextPrime(g_max_len_samples - 127); | |
| } | |
| eff_len[v] = prime; | |
| if(write_pos[v] >= eff_len[v]) | |
| { | |
| write_pos[v] %= eff_len[v]; | |
| } | |
| } | |
| } | |
| void TriggerPlayback() | |
| { | |
| const size_t one_sec = (size_t)g_sample_rate; | |
| for(size_t v = 0; v < kNumVoices; v++) | |
| { | |
| const size_t len = eff_len[v]; | |
| const bool reverse = Rand01() < reverse_probability; | |
| // Opposite side of write position in the circular buffer. | |
| int nominal_start = (int)write_pos[v] + (int)(len / 2); | |
| nominal_start = WrapIndex(nominal_start, len); | |
| // Start at nearby zero crossing to reduce click on onset. | |
| const size_t start = ZeroCrossNear(v, nominal_start, 256); | |
| // End near a zero crossing around nominal one-second span to reduce pop. | |
| const int dir = reverse ? -1 : 1; | |
| const int nominal_end = WrapIndex((int)start + dir * (int)one_sec, len); | |
| const size_t aligned_end = ZeroCrossNear(v, nominal_end, 512); | |
| size_t play_len; | |
| if(!reverse) | |
| { | |
| if(aligned_end >= start) | |
| { | |
| play_len = aligned_end - start; | |
| } | |
| else | |
| { | |
| play_len = (len - start) + aligned_end; | |
| } | |
| } | |
| else | |
| { | |
| if(start >= aligned_end) | |
| { | |
| play_len = start - aligned_end; | |
| } | |
| else | |
| { | |
| play_len = start + (len - aligned_end); | |
| } | |
| } | |
| if(play_len < 32) | |
| { | |
| play_len = one_sec; | |
| } | |
| if(play_len > one_sec + 1024) | |
| { | |
| play_len = one_sec + 1024; | |
| } | |
| voices[v].active = true; | |
| voices[v].reverse = reverse; | |
| voices[v].read_pos = (float)start; | |
| voices[v].samples_remaining = play_len; | |
| voices[v].total_samples = play_len; | |
| } | |
| } | |
| float ReadInterp(size_t voice, float pos) | |
| { | |
| const size_t len = eff_len[voice]; | |
| int i0 = (int)pos; | |
| while(i0 < 0) | |
| { | |
| i0 += (int)len; | |
| } | |
| while(i0 >= (int)len) | |
| { | |
| i0 -= (int)len; | |
| } | |
| int i1 = i0 + 1; | |
| if(i1 >= (int)len) | |
| { | |
| i1 -= (int)len; | |
| } | |
| const float frac = pos - (float)i0; | |
| return circ[voice][i0] + (circ[voice][i1] - circ[voice][i0]) * frac; | |
| } | |
| inline float EnvelopeGain(const VoicePlayback& vp) | |
| { | |
| if(vp.total_samples <= 1) | |
| { | |
| return 1.0f; | |
| } | |
| const float phase = 1.0f - ((float)vp.samples_remaining / (float)vp.total_samples); | |
| const float tri = 1.0f - fabsf(2.0f * phase - 1.0f); | |
| return (1.0f - env_amount) + env_amount * tri; | |
| } | |
| void ProcessControls() | |
| { | |
| patch.ProcessAnalogControls(); | |
| patch.ProcessDigitalControls(); | |
| env_amount = patch.GetKnobValue(DaisyPatch::CTRL_1); | |
| // knob2: CW => always forward, CCW => always backward. | |
| reverse_probability = 1.0f - patch.GetKnobValue(DaisyPatch::CTRL_2); | |
| len_knob = patch.GetKnobValue(DaisyPatch::CTRL_3); | |
| // keep < 1.0 to guarantee bounded feedback in this simple FDN. | |
| feedback_amount = 0.85f * patch.GetKnobValue(DaisyPatch::CTRL_4); | |
| UpdateLengths(); | |
| } | |
| inline float SoftClip(float x) | |
| { | |
| return tanhf(x); | |
| } | |
| void ProcessFdn(const float dry[kNumVoices], float out_fdn[kNumVoices]) | |
| { | |
| const float s0 = fdn_state[0]; | |
| const float s1 = fdn_state[1]; | |
| const float s2 = fdn_state[2]; | |
| const float s3 = fdn_state[3]; | |
| // 4x4 Hadamard feedback mix normalized by 1/2. | |
| const float m0 = 0.5f * (s0 + s1 + s2 + s3); | |
| const float m1 = 0.5f * (s0 - s1 + s2 - s3); | |
| const float m2 = 0.5f * (s0 + s1 - s2 - s3); | |
| const float m3 = 0.5f * (s0 - s1 - s2 + s3); | |
| float next[kNumVoices]; | |
| next[0] = SoftClip(dry[0] + feedback_amount * m0); | |
| next[1] = SoftClip(dry[1] + feedback_amount * m1); | |
| next[2] = SoftClip(dry[2] + feedback_amount * m2); | |
| next[3] = SoftClip(dry[3] + feedback_amount * m3); | |
| for(size_t v = 0; v < kNumVoices; v++) | |
| { | |
| // hard safety clamp in addition to soft clip. | |
| if(next[v] > 0.98f) | |
| { | |
| next[v] = 0.98f; | |
| } | |
| if(next[v] < -0.98f) | |
| { | |
| next[v] = -0.98f; | |
| } | |
| fdn_state[v] = next[v]; | |
| out_fdn[v] = next[v]; | |
| } | |
| } | |
| void DrawKnobLine(int y, const char* label, float value) | |
| { | |
| constexpr int kBarX = 30; | |
| constexpr int kBarW = 90; | |
| constexpr int kBarH = 8; | |
| const int fill_w = (int)(value * (float)kBarW); | |
| patch.display.SetCursor(0, y); | |
| patch.display.WriteString(label, Font_6x8, true); | |
| patch.display.DrawRect(kBarX, y, kBarX + kBarW, y + kBarH, true, false); | |
| if(fill_w > 0) | |
| { | |
| patch.display.DrawRect(kBarX + 1, | |
| y + 1, | |
| kBarX + fill_w - 1, | |
| y + kBarH - 1, | |
| true, | |
| true); | |
| } | |
| } | |
| void UpdateDisplay() | |
| { | |
| patch.display.Fill(false); | |
| const float k1 = patch.GetKnobValue(DaisyPatch::CTRL_1); | |
| const float k2 = patch.GetKnobValue(DaisyPatch::CTRL_2); | |
| const float k3 = patch.GetKnobValue(DaisyPatch::CTRL_3); | |
| const float k4 = patch.GetKnobValue(DaisyPatch::CTRL_4); | |
| DrawKnobLine(0, "K1", k1); | |
| DrawKnobLine(10, "K2", k2); | |
| DrawKnobLine(20, "K3", k3); | |
| DrawKnobLine(30, "K4", k4); | |
| FixedCapStr<32> line; | |
| line.Append("1:"); | |
| line.AppendInt((int)(k1 * 100.0f)); | |
| line.Append(" 2:"); | |
| line.AppendInt((int)(k2 * 100.0f)); | |
| line.Append(" 3:"); | |
| line.AppendInt((int)(k3 * 100.0f)); | |
| line.Append(" 4:"); | |
| line.AppendInt((int)(k4 * 100.0f)); | |
| patch.display.SetCursor(0, 50); | |
| patch.display.WriteString(line, Font_6x8, true); | |
| patch.display.Update(); | |
| } | |
| } | |
| static void AudioCallback(AudioHandle::InputBuffer in, | |
| AudioHandle::OutputBuffer out, | |
| size_t size) | |
| { | |
| ProcessControls(); | |
| if(patch.gate_input[0].Trig()) | |
| { | |
| TriggerPlayback(); | |
| } | |
| for(size_t i = 0; i < size; i++) | |
| { | |
| // Record input channel IN1 (index 1) into 4 circular buffers. | |
| const float x = in[1][i]; | |
| for(size_t v = 0; v < kNumVoices; v++) | |
| { | |
| circ[v][write_pos[v]] = x; | |
| write_pos[v]++; | |
| if(write_pos[v] >= eff_len[v]) | |
| { | |
| write_pos[v] = 0; | |
| } | |
| } | |
| // Playback each buffer on its corresponding output. | |
| float dry[kNumVoices] = {0.0f, 0.0f, 0.0f, 0.0f}; | |
| for(size_t v = 0; v < kNumVoices; v++) | |
| { | |
| float y = 0.0f; | |
| if(voices[v].active) | |
| { | |
| const float g = EnvelopeGain(voices[v]); | |
| y = ReadInterp(v, voices[v].read_pos) * g; | |
| const float step = voices[v].reverse ? -1.0f : 1.0f; | |
| voices[v].read_pos += step; | |
| if(voices[v].read_pos < 0.0f) | |
| { | |
| voices[v].read_pos += (float)eff_len[v]; | |
| } | |
| else if(voices[v].read_pos >= (float)eff_len[v]) | |
| { | |
| voices[v].read_pos -= (float)eff_len[v]; | |
| } | |
| if(voices[v].samples_remaining > 0) | |
| { | |
| voices[v].samples_remaining--; | |
| } | |
| if(voices[v].samples_remaining == 0) | |
| { | |
| voices[v].active = false; | |
| } | |
| } | |
| dry[v] = y; | |
| } | |
| float wet[kNumVoices]; | |
| ProcessFdn(dry, wet); | |
| for(size_t v = 0; v < kNumVoices; v++) | |
| { | |
| // Dry + FDN wet mix with output safety clip. | |
| float y = 0.45f * dry[v] + 0.55f * wet[v]; | |
| if(y > 0.98f) | |
| { | |
| y = 0.98f; | |
| } | |
| if(y < -0.98f) | |
| { | |
| y = -0.98f; | |
| } | |
| out[v][i] = y; | |
| } | |
| } | |
| } | |
| int main(void) | |
| { | |
| patch.Init(); | |
| g_sample_rate = patch.AudioSampleRate(); | |
| g_max_len_samples = (size_t)(g_sample_rate * kMaxSeconds); | |
| if(g_max_len_samples > 529999) | |
| { | |
| g_max_len_samples = 529999; | |
| } | |
| for(size_t v = 0; v < kNumVoices; v++) | |
| { | |
| for(size_t i = 0; i < 530000; i++) | |
| { | |
| circ[v][i] = 0.0f; | |
| } | |
| } | |
| patch.StartAdc(); | |
| patch.StartAudio(AudioCallback); | |
| while(1) | |
| { | |
| patch.ProcessAllControls(); | |
| UpdateDisplay(); | |
| patch.DelayMs(10); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment