Skip to content

Commit eb2d0c1

Browse files
feat: add looper effect with UI integration (#116)
* feat: add looper effect with UI integration * feat: implement loop level smoothing for improved audio transitions * fix(gui): constrain looper slider width * test(looper): add pedal coverage * fix(gui): avoid unnecessary horizontal scroll * fix: correct cursor positioning in pedal board and remove progress bar text overlay --------- Co-authored-by: Sudip Mondal <sudip.mondal@mando.work>
1 parent 7e333c9 commit eb2d0c1

10 files changed

Lines changed: 608 additions & 6 deletions

File tree

CMakeLists.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ set(APP_SOURCES
167167
src/audio/effects/equalizer.cpp
168168
src/audio/effects/noise_gate.cpp
169169
src/audio/effects/compressor.cpp
170+
src/audio/effects/looper.cpp
170171
src/audio/effects/cabinet_sim.cpp
171172
src/audio/effects/ir_cabinet.cpp
172173
src/audio/effects/amp_simulator.cpp
@@ -485,6 +486,7 @@ else() # NOT EMSCRIPTEN, ANDROID, or IOS
485486
tests/test_common.cpp
486487
tests/test_oboe_ring_buffer.cpp
487488
tests/test_effects.cpp
489+
tests/test_looper.cpp
488490
tests/test_preset_manager.cpp
489491
tests/test_json_serialization.cpp
490492
tests/test_recorder.cpp
@@ -524,6 +526,7 @@ else() # NOT EMSCRIPTEN, ANDROID, or IOS
524526
src/audio/effects/equalizer.cpp
525527
src/audio/effects/noise_gate.cpp
526528
src/audio/effects/compressor.cpp
529+
src/audio/effects/looper.cpp
527530
src/audio/effects/cabinet_sim.cpp
528531
src/audio/effects/ir_cabinet.cpp
529532
src/audio/effects/amp_simulator.cpp
@@ -601,4 +604,4 @@ else() # NOT EMSCRIPTEN, ANDROID, or IOS
601604
install(DIRECTORY presets DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/amplitron)
602605
install(DIRECTORY assets/fonts DESTINATION bin/assets)
603606

604-
endif() # EMSCRIPTEN / ANDROID / IOS / desktop
607+
endif() # EMSCRIPTEN / ANDROID / IOS / desktop

src/audio/effects/looper.cpp

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
#include "audio/effects/looper.h"
2+
#include "audio/effect_factory.h"
3+
4+
#include <algorithm>
5+
#include <cmath>
6+
7+
namespace Amplitron {
8+
9+
static EffectRegistrar<Looper> reg("Looper");
10+
11+
Looper::Looper() {
12+
params_ = {
13+
{"Loop Level", 0.80f, 0.0f, 1.0f, 0.80f, "", "Playback volume of the recorded loop mixed with live input."},
14+
{"Crossfade", 5.0f, 0.0f, 20.0f, 5.0f, "ms", "Crossfade length at the loop boundary to reduce clicks/pops."},
15+
};
16+
ensure_capacity();
17+
const float sr = static_cast<float>(std::max(sample_rate_, 1));
18+
loop_level_alpha_ = 1.0f - std::exp(-1.0f / (sr * kLoopLevelSmoothingSeconds));
19+
reset();
20+
}
21+
22+
void Looper::set_sample_rate(int sample_rate) {
23+
Effect::set_sample_rate(sample_rate);
24+
const float sr = static_cast<float>(std::max(sample_rate_, 1));
25+
loop_level_alpha_ = 1.0f - std::exp(-1.0f / (sr * kLoopLevelSmoothingSeconds));
26+
ensure_capacity();
27+
reset();
28+
}
29+
30+
void Looper::ensure_capacity() {
31+
const int sr = std::max(sample_rate_, 1);
32+
const int cap = std::max(sr * kMaxSeconds, 1);
33+
if (cap == max_samples_) return;
34+
max_samples_ = cap;
35+
buffer_l_.assign(static_cast<size_t>(max_samples_), 0.0f);
36+
buffer_r_.assign(static_cast<size_t>(max_samples_), 0.0f);
37+
}
38+
39+
void Looper::reset() {
40+
state_rt_ = State::Empty;
41+
has_loop_rt_ = false;
42+
record_pos_ = 0;
43+
playhead_ = 0;
44+
loop_length_ = 0;
45+
loop_level_smoothed_ = clamp(params_[0].value, 0.0f, 1.0f);
46+
pending_commands_.store(0, std::memory_order_relaxed);
47+
publish_ui_snapshot();
48+
}
49+
50+
void Looper::request_record_toggle() {
51+
pending_commands_.fetch_or(CmdRecordToggle, std::memory_order_relaxed);
52+
}
53+
54+
void Looper::request_play_toggle() {
55+
pending_commands_.fetch_or(CmdPlayToggle, std::memory_order_relaxed);
56+
}
57+
58+
void Looper::request_overdub_toggle() {
59+
pending_commands_.fetch_or(CmdOverdubToggle, std::memory_order_relaxed);
60+
}
61+
62+
void Looper::request_clear() {
63+
pending_commands_.fetch_or(CmdClear, std::memory_order_relaxed);
64+
}
65+
66+
int Looper::crossfade_samples_rt() const {
67+
const float ms = params_[1].value;
68+
const int xf = static_cast<int>(std::round((ms / 1000.0f) * static_cast<float>(sample_rate_)));
69+
return std::clamp(xf, 0, std::max(loop_length_ / 2, 0));
70+
}
71+
72+
void Looper::publish_ui_snapshot() {
73+
ui_state_.store(static_cast<uint32_t>(state_rt_), std::memory_order_relaxed);
74+
ui_has_loop_.store(has_loop_rt_ ? 1 : 0, std::memory_order_relaxed);
75+
ui_loop_length_samples_.store(loop_length_, std::memory_order_relaxed);
76+
ui_playhead_samples_.store(playhead_, std::memory_order_relaxed);
77+
}
78+
79+
void Looper::clear_loop_rt() {
80+
has_loop_rt_ = false;
81+
loop_length_ = 0;
82+
record_pos_ = 0;
83+
playhead_ = 0;
84+
state_rt_ = State::Empty;
85+
}
86+
87+
void Looper::start_recording_rt() {
88+
has_loop_rt_ = false;
89+
loop_length_ = 0;
90+
record_pos_ = 0;
91+
playhead_ = 0;
92+
state_rt_ = State::Recording;
93+
}
94+
95+
void Looper::stop_recording_rt_and_play_rt() {
96+
loop_length_ = std::clamp(record_pos_, 0, max_samples_);
97+
const int min_len = static_cast<int>(std::round(kMinLoopSeconds * static_cast<float>(sample_rate_)));
98+
if (loop_length_ < min_len) {
99+
clear_loop_rt();
100+
return;
101+
}
102+
has_loop_rt_ = true;
103+
playhead_ = 0;
104+
state_rt_ = State::Playing;
105+
}
106+
107+
void Looper::toggle_play_rt() {
108+
if (!has_loop_rt_) return;
109+
if (state_rt_ == State::Playing || state_rt_ == State::Overdubbing) {
110+
state_rt_ = State::Idle;
111+
} else if (state_rt_ == State::Idle || state_rt_ == State::Empty) {
112+
state_rt_ = State::Playing;
113+
}
114+
}
115+
116+
void Looper::toggle_overdub_rt() {
117+
if (!has_loop_rt_) return;
118+
if (state_rt_ == State::Overdubbing) {
119+
state_rt_ = State::Playing;
120+
} else if (state_rt_ == State::Playing) {
121+
state_rt_ = State::Overdubbing;
122+
} else if (state_rt_ == State::Idle) {
123+
state_rt_ = State::Overdubbing;
124+
}
125+
}
126+
127+
void Looper::apply_pending_commands() {
128+
const uint32_t cmds = pending_commands_.exchange(0, std::memory_order_relaxed);
129+
if (cmds == 0) return;
130+
131+
if (cmds & CmdClear) {
132+
clear_loop_rt();
133+
}
134+
135+
if (cmds & CmdRecordToggle) {
136+
if (state_rt_ == State::Recording) {
137+
stop_recording_rt_and_play_rt();
138+
} else {
139+
start_recording_rt();
140+
}
141+
}
142+
143+
if (cmds & CmdPlayToggle) {
144+
if (state_rt_ == State::Recording) {
145+
stop_recording_rt_and_play_rt();
146+
} else if (state_rt_ == State::Empty || state_rt_ == State::Idle ||
147+
state_rt_ == State::Playing || state_rt_ == State::Overdubbing) {
148+
toggle_play_rt();
149+
}
150+
}
151+
152+
if (cmds & CmdOverdubToggle) {
153+
if (state_rt_ != State::Recording) {
154+
toggle_overdub_rt();
155+
}
156+
}
157+
}
158+
159+
void Looper::process(float* buffer, int num_samples) {
160+
if (!enabled_) return;
161+
process_core(buffer, nullptr, num_samples, false);
162+
}
163+
164+
void Looper::process_stereo(float* left, float* right, int num_samples) {
165+
if (!enabled_) return;
166+
process_core(left, right, num_samples, true);
167+
}
168+
169+
void Looper::process_core(float* left, float* right, int num_samples, bool stereo) {
170+
apply_pending_commands();
171+
172+
const float loop_level_target = clamp(params_[0].value, 0.0f, 1.0f);
173+
const int cap = max_samples_;
174+
if (cap <= 0) {
175+
publish_ui_snapshot();
176+
return;
177+
}
178+
179+
if (state_rt_ == State::Recording) {
180+
for (int i = 0; i < num_samples; ++i) {
181+
if (record_pos_ >= cap) {
182+
stop_recording_rt_and_play_rt();
183+
break;
184+
}
185+
buffer_l_[record_pos_] = left[i];
186+
if (stereo && right) buffer_r_[record_pos_] = right[i];
187+
++record_pos_;
188+
}
189+
}
190+
191+
if (has_loop_rt_ && loop_length_ > 0 &&
192+
(state_rt_ == State::Playing || state_rt_ == State::Overdubbing)) {
193+
const int xf = crossfade_samples_rt();
194+
for (int i = 0; i < num_samples; ++i) {
195+
loop_level_smoothed_ += loop_level_alpha_ * (loop_level_target - loop_level_smoothed_);
196+
const float loop_level = loop_level_smoothed_;
197+
const int pos = playhead_;
198+
float loop_l = buffer_l_[pos];
199+
float loop_r = (stereo && right) ? buffer_r_[pos] : loop_l;
200+
201+
if (xf > 0 && pos >= loop_length_ - xf) {
202+
const int t = pos - (loop_length_ - xf); // 0..xf-1
203+
const float w_end = static_cast<float>(xf - t) / static_cast<float>(xf);
204+
const float w_start = 1.0f - w_end;
205+
const int start_pos = t;
206+
loop_l = buffer_l_[pos] * w_end + buffer_l_[start_pos] * w_start;
207+
if (stereo && right) {
208+
loop_r = buffer_r_[pos] * w_end + buffer_r_[start_pos] * w_start;
209+
} else {
210+
loop_r = loop_l;
211+
}
212+
}
213+
214+
const float in_l = left[i];
215+
const float in_r = (stereo && right) ? right[i] : in_l;
216+
217+
float out_l = in_l + loop_l * loop_level;
218+
float out_r = in_r + loop_r * loop_level;
219+
220+
if (state_rt_ == State::Overdubbing) {
221+
buffer_l_[pos] = soft_clip(buffer_l_[pos] + in_l);
222+
if (stereo && right) {
223+
buffer_r_[pos] = soft_clip(buffer_r_[pos] + in_r);
224+
}
225+
}
226+
227+
left[i] = soft_clip(out_l);
228+
if (stereo && right) right[i] = soft_clip(out_r);
229+
230+
++playhead_;
231+
if (playhead_ >= loop_length_) playhead_ = 0;
232+
}
233+
} else {
234+
// Keep smoothing responsive even when not actively mixing the loop.
235+
loop_level_smoothed_ += loop_level_alpha_ * (loop_level_target - loop_level_smoothed_);
236+
}
237+
238+
publish_ui_snapshot();
239+
}
240+
241+
} // namespace Amplitron

src/audio/effects/looper.h

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#pragma once
2+
3+
#include "audio/effect.h"
4+
5+
#include <atomic>
6+
#include <cmath>
7+
#include <cstdint>
8+
#include <vector>
9+
10+
namespace Amplitron {
11+
12+
/**
13+
* A simple in-chain looper (record / play / overdub / clear).
14+
*
15+
* Design goals:
16+
* - No allocations inside process/process_stereo (buffer is preallocated).
17+
* - Thread-safe UI visibility for state and loop position via atomics.
18+
* - Minimal, predictable state machine for practice workflows.
19+
*/
20+
class Looper : public Effect {
21+
public:
22+
enum class State : uint32_t {
23+
Empty = 0, // no loop in memory
24+
Idle, // loop exists but not playing
25+
Recording,
26+
Playing,
27+
Overdubbing,
28+
};
29+
30+
Looper();
31+
32+
void process(float* buffer, int num_samples) override;
33+
void process_stereo(float* left, float* right, int num_samples) override;
34+
void set_sample_rate(int sample_rate) override;
35+
void reset() override;
36+
const char* name() const override { return "Looper"; }
37+
std::vector<EffectParam>& params() override { return params_; }
38+
39+
// --- UI control (thread-safe) ---
40+
void request_record_toggle();
41+
void request_play_toggle();
42+
void request_overdub_toggle();
43+
void request_clear();
44+
45+
// --- UI status snapshot (thread-safe) ---
46+
State state() const { return static_cast<State>(ui_state_.load(std::memory_order_relaxed)); }
47+
bool has_loop() const { return ui_has_loop_.load(std::memory_order_relaxed) != 0; }
48+
int loop_length_samples() const { return ui_loop_length_samples_.load(std::memory_order_relaxed); }
49+
int playhead_samples() const { return ui_playhead_samples_.load(std::memory_order_relaxed); }
50+
51+
private:
52+
static constexpr int kMaxSeconds = 60;
53+
static constexpr float kMinLoopSeconds = 0.10f;
54+
static constexpr float kLoopLevelSmoothingSeconds = 0.02f;
55+
56+
enum CommandBits : uint32_t {
57+
CmdRecordToggle = 1u << 0,
58+
CmdPlayToggle = 1u << 1,
59+
CmdOverdubToggle = 1u << 2,
60+
CmdClear = 1u << 3,
61+
};
62+
63+
// Params (saved in presets): loop playback level + crossfade length.
64+
std::vector<EffectParam> params_;
65+
66+
// Preallocated buffers (full capacity, mono or stereo).
67+
std::vector<float> buffer_l_;
68+
std::vector<float> buffer_r_;
69+
int max_samples_ = 0;
70+
71+
// Audio-thread state (not atomic; only touched in process/process_stereo).
72+
State state_rt_ = State::Empty;
73+
bool has_loop_rt_ = false;
74+
int record_pos_ = 0;
75+
int playhead_ = 0;
76+
int loop_length_ = 0;
77+
78+
float loop_level_smoothed_ = 0.80f;
79+
float loop_level_alpha_ = 0.0f;
80+
81+
// UI-visible atomics (written from audio thread, read by GUI thread).
82+
std::atomic<uint32_t> ui_state_{static_cast<uint32_t>(State::Empty)};
83+
std::atomic<int> ui_has_loop_{0};
84+
std::atomic<int> ui_loop_length_samples_{0};
85+
std::atomic<int> ui_playhead_samples_{0};
86+
87+
// UI -> audio thread command mailbox (bitmask).
88+
std::atomic<uint32_t> pending_commands_{0};
89+
90+
// Helpers
91+
void ensure_capacity();
92+
void apply_pending_commands();
93+
void publish_ui_snapshot();
94+
void clear_loop_rt();
95+
void start_recording_rt();
96+
void stop_recording_rt_and_play_rt();
97+
void toggle_play_rt();
98+
void toggle_overdub_rt();
99+
100+
static inline float soft_clip(float x) {
101+
const float ax = std::fabs(x);
102+
return x / (1.0f + ax);
103+
}
104+
105+
inline int crossfade_samples_rt() const;
106+
inline void process_core(float* left, float* right, int num_samples, bool stereo);
107+
};
108+
109+
} // namespace Amplitron

0 commit comments

Comments
 (0)