Skip to content

Commit 49e2e62

Browse files
authored
Merge branch 'main' into add-component-test-coverage-v2
2 parents 5776d80 + 381b5b4 commit 49e2e62

13 files changed

Lines changed: 360 additions & 61 deletions

CMakeLists.txt

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ cmake_minimum_required(VERSION 3.16)
22
project(Amplitron VERSION 1.0.0 LANGUAGES CXX C)
33
include(GNUInstallDirs)
44

5+
enable_testing()
6+
57
set(AMPLITRON_VERSION "0.0.0" CACHE STRING "Application version (set by CI)")
68

79
set(CMAKE_CXX_STANDARD 17)
@@ -27,7 +29,7 @@ if(EMSCRIPTEN)
2729
elseif(MSVC)
2830
add_compile_options(/W4 /fp:fast)
2931
if(CMAKE_BUILD_TYPE STREQUAL "Release")
30-
add_compile_options(/O2 /arch:AVX)
32+
add_compile_options("$<$<CONFIG:Release>:/O2>")
3133
endif()
3234
else()
3335
add_compile_options(-Wall -Wextra -ffast-math)
@@ -49,6 +51,10 @@ endif()
4951
# Single-header C++17 JSON library used for preset serialization /
5052
# deserialization. We disable nlohmann's own tests and install rules so
5153
# they don't pollute the Amplitron build.
54+
55+
# Suppress cmake_minimum_required deprecation warnings from fetched deps
56+
set(CMAKE_WARN_DEPRECATED OFF CACHE BOOL "" FORCE)
57+
5258
find_package(nlohmann_json QUIET)
5359
if(NOT nlohmann_json_FOUND)
5460
include(FetchContent)
@@ -64,6 +70,19 @@ if(NOT nlohmann_json_FOUND)
6470
endif()
6571
# ---------------------------------------------------------------------------
6672

73+
find_package(GTest QUIET)
74+
if(NOT TARGET GTest::gtest AND NOT EMSCRIPTEN AND NOT ANDROID AND NOT IOS)
75+
include(FetchContent)
76+
FetchContent_Declare(
77+
googletest
78+
GIT_REPOSITORY https://github.com/google/googletest.git
79+
GIT_TAG release-1.12.1
80+
GIT_SHALLOW TRUE
81+
)
82+
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
83+
FetchContent_MakeAvailable(googletest)
84+
endif()
85+
6786
file(MAKE_DIRECTORY "${CMAKE_SOURCE_DIR}/external")
6887
if(NOT EXISTS "${CMAKE_SOURCE_DIR}/external/nanosvg.h" OR
6988
NOT EXISTS "${CMAKE_SOURCE_DIR}/external/nanosvgrast.h" OR
@@ -81,6 +100,29 @@ endif()
81100
if(NOT EMSCRIPTEN AND NOT ANDROID AND NOT IOS)
82101

83102
# PortAudio
103+
find_package(portaudio CONFIG QUIET)
104+
if(TARGET portaudio)
105+
set(PORTAUDIO_FOUND TRUE)
106+
set(PORTAUDIO_LIBRARIES portaudio)
107+
else()
108+
find_package(PkgConfig QUIET)
109+
if(PkgConfig_FOUND)
110+
pkg_check_modules(PORTAUDIO portaudio-2.0)
111+
endif()
112+
if(NOT PORTAUDIO_FOUND)
113+
find_path(PORTAUDIO_INCLUDE_DIRS portaudio.h
114+
PATHS /usr/include /usr/local/include /opt/homebrew/include
115+
"C:/Program Files/portaudio/include"
116+
"${CMAKE_SOURCE_DIR}/external/portaudio/include")
117+
find_library(PORTAUDIO_LIBRARIES NAMES portaudio portaudio_x64
118+
PATHS /usr/lib /usr/local/lib /opt/homebrew/lib
119+
"C:/Program Files/portaudio/lib"
120+
"${CMAKE_SOURCE_DIR}/external/portaudio/lib")
121+
endif()
122+
if(PORTAUDIO_LIBRARY_DIRS)
123+
link_directories(${PORTAUDIO_LIBRARY_DIRS})
124+
endif()
125+
endif()
84126
find_package(PkgConfig QUIET)
85127
if(PkgConfig_FOUND)
86128
pkg_check_modules(PORTAUDIO portaudio-2.0)
@@ -123,7 +165,7 @@ if(PkgConfig_FOUND)
123165
pkg_check_modules(SDL2 sdl2)
124166
endif()
125167
if(NOT SDL2_FOUND)
126-
find_package(SDL2 QUIET)
168+
find_package(SDL2 CONFIG QUIET)
127169
if(SDL2_FOUND)
128170
set(SDL2_INCLUDE_DIRS ${SDL2_INCLUDE_DIRS})
129171
set(SDL2_LIBRARIES SDL2::SDL2 SDL2::SDL2main)
@@ -152,7 +194,7 @@ if(NOT RTMIDI_LIBRARIES)
152194
pkg_check_modules(RTMIDI rtmidi)
153195
endif()
154196
if(NOT RTMIDI_FOUND)
155-
find_path(RTMIDI_INCLUDE_DIRS rtmidi/RtMidi.h
197+
find_path(RTMIDI_INCLUDE_DIRS NAMES RtMidi.h rtmidi/RtMidi.h
156198
PATHS /usr/include /usr/local/include /opt/homebrew/include
157199
"C:/Program Files/rtmidi/include"
158200
"${CMAKE_SOURCE_DIR}/external/rtmidi")
@@ -680,6 +722,7 @@ else() # NOT EMSCRIPTEN, ANDROID, or IOS
680722
tests/unit/test_effects_modulation.cpp
681723
tests/unit/test_effects_spatial.cpp
682724
tests/unit/test_effects_pitch.cpp
725+
tests/unit/test_pitch_shifter.cpp
683726
tests/unit/test_effects_utility.cpp
684727
tests/unit/test_looper.cpp
685728
tests/unit/test_theme.cpp

src/audio/effects/distortion.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ void Distortion::process(float* buffer, int num_samples) {
4545
float x_hard = hard_clip(x, 1.0f);
4646

4747
// Blend: more drive → more hard clipping
48-
float hard_amount = (drive - 1.0f) / 19.0f; // 0 at drive=1, 1 at drive=20
48+
float hard_amount = (drive - params_[0].min_val) / (params_[0].max_val - params_[0].min_val);
4949
x = x_soft * (1.0f - hard_amount) + x_hard * hard_amount;
5050

5151
// Tone filter (one-pole LP)

src/audio/effects/pitch_shifter.cpp

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ static float wrap_phase(float phase, int buf_size) {
2020
return phase;
2121
}
2222

23+
void PitchShifter::build_hann_lut() {
24+
25+
for (size_t i = 0; i < hann_lut_.size(); ++i) {
26+
// Calculates the Hann window curve once and stores it.
27+
// Assumes TWO_PI is already defined
28+
hann_lut_[i] = 0.5f * (1.0f - std::cos(TWO_PI * static_cast<float>(i) / 8192.0f));
29+
}
30+
}
31+
2332
PitchShifter::PitchShifter() {
2433
params_ = {
2534
{"Shift", 0.0f, -12.0f, 12.0f, 0.0f, "st",
@@ -29,6 +38,10 @@ PitchShifter::PitchShifter() {
2938
{"Mix", 0.0f, 0.0f, 1.0f, 0.0f, "",
3039
"Dry/wet blend. 0 = fully dry, 1 = fully pitch-shifted."},
3140
};
41+
42+
// --- CALL OPTIMIZATION ---
43+
build_hann_lut();
44+
3245
set_sample_rate(DEFAULT_SAMPLE_RATE);
3346
}
3447

@@ -52,30 +65,37 @@ float PitchShifter::read_linear(float phase) const {
5265
void PitchShifter::process(float* buffer, int num_samples) {
5366
if (!enabled_) return;
5467

55-
const float alpha = 1.0f - std::exp(-1.0f / (sample_rate_ * 0.010f));
68+
if (mix_smooth_ < 0.001f && params_[P_MIX].value < 0.001f) {
69+
// We process in-place, so the input buffer is already the output buffer.
70+
// Exit immediately
71+
return;
72+
}
73+
74+
// Smooth parameters
75+
// Hoisting: Calculate smoothing and std::pow ONCE per block, not per sample
76+
const float block_alpha = 1.0f - std::exp(-static_cast<float>(num_samples) / (sample_rate_ * 0.010f));
77+
shift_smooth_ += block_alpha * (params_[P_SHIFT].value - shift_smooth_);
78+
fine_smooth_ += block_alpha * (params_[P_FINE].value - fine_smooth_);
79+
mix_smooth_ += block_alpha * (params_[P_MIX].value - mix_smooth_);
5680

57-
for (int i = 0; i < num_samples; ++i) {
58-
const float dry = buffer[i];
81+
// Total shift in semitones (coarse + fine)
82+
float total_semitones = shift_smooth_ + fine_smooth_ / 100.0f;
5983

60-
// Write input into circular grain buffer
61-
grain_buf_[write_pos_] = dry;
84+
// Pitch ratio: 2^(semitones/12)
85+
float ratio = std::pow(2.0f, total_semitones / 12.0f);
6286

63-
// Smooth parameters
64-
shift_smooth_ += alpha * (params_[P_SHIFT].value - shift_smooth_);
65-
fine_smooth_ += alpha * (params_[P_FINE].value - fine_smooth_);
66-
mix_smooth_ += alpha * (params_[P_MIX].value - mix_smooth_);
87+
// Read pointer increment: how much faster/slower we read vs write
88+
// ratio > 1 means pitch up -> read faster -> increment > 1
89+
// We want the *offset* from write to change, so increment = 1 - ratio
90+
// gives us the drift rate of the read pointer relative to write.
91+
float drift = 1.0f - ratio;
6792

68-
// Total shift in semitones (coarse + fine)
69-
float total_semitones = shift_smooth_ + fine_smooth_ / 100.0f;
7093

71-
// Pitch ratio: 2^(semitones/12)
72-
float ratio = std::pow(2.0f, total_semitones / 12.0f);
94+
for (int i = 0; i < num_samples; ++i) {
95+
const float dry = buffer[i];
7396

74-
// Read pointer increment: how much faster/slower we read vs write
75-
// ratio > 1 means pitch up -> read faster -> increment > 1
76-
// We want the *offset* from write to change, so increment = 1 - ratio
77-
// gives us the drift rate of the read pointer relative to write.
78-
float drift = 1.0f - ratio;
97+
// Write input into circular grain buffer
98+
grain_buf_[write_pos_] = dry;
7999

80100
// Advance read phases (they drift relative to write position)
81101
read_phase_a_ += drift;
@@ -99,8 +119,11 @@ void PitchShifter::process(float* buffer, int num_samples) {
99119
// Use read_phase_a_ as the crossfade driver: as it sweeps 0..buf_size_,
100120
// we fade A in for the first half and B in for the second half.
101121
float fade_pos = read_phase_a_ / static_cast<float>(buf_size_);
122+
123+
// LUT optimization: Fast array lookup with bitwise AND (& 8191)
124+
int lut_idx = static_cast<int>(fade_pos * 8192.0f) & 8191;
102125
// Hann window for smooth crossfade
103-
float gain_a = 0.5f * (1.0f - std::cos(TWO_PI * fade_pos));
126+
float gain_a = hann_lut_[lut_idx];
104127
float gain_b = 1.0f - gain_a;
105128

106129
float wet = tap_a * gain_a + tap_b * gain_b;

src/audio/effects/pitch_shifter.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#include "audio/effects/effect.h"
99
#include <vector>
10+
#include <array>
1011

1112
namespace Amplitron {
1213

@@ -47,6 +48,16 @@ class PitchShifter : public Effect {
4748
float fine_smooth_ = 0.0f;
4849
float mix_smooth_ = 0.0f;
4950

51+
// --- NEW OPTIMIZATION VARIABLES ---
52+
// Adds the function to pre-calculate the cosine math
53+
void build_hann_lut();
54+
55+
// 8192 is a power of 2 (allows fast bitwise wrapping) and fits easily in L1 cache
56+
std::array<float, 8192> hann_lut_;
57+
58+
// Cached pitch ratio to avoid per-sample pow()
59+
float ratio_ = 1.0f;
60+
5061
float read_linear(float phase) const;
5162
};
5263

src/audio/engine/audio_engine.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ class AudioEngine {
344344
std::atomic<float> output_rms_{0.0f};
345345
std::atomic<bool> input_clipped_{false};
346346
std::atomic<bool> output_clipped_{false};
347-
std::atomic<bool> analyzer_enabled_{false};
347+
std::atomic<bool> analyzer_enabled_{true};
348348

349349
// std::vector<std::shared_ptr<Effect>> effects_;
350350
std::vector<float> process_buffer_;

src/audio/engine/audio_engine_process.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ void AudioEngine::process_audio(const float* input, float* output, int frame_cou
141141

142142
float out_gain = output_gain_.load(std::memory_order_relaxed);
143143
float peak_out = 0.0f;
144-
constexpr float kTwoPi = 6.28318530718f;
145144
auto next_metronome_sample = [this]() -> float {
146145

147146
metronome_bpm_smoothed_ += metronome_bpm_smooth_alpha_ * (metronome_bpm_ - metronome_bpm_smoothed_);
@@ -164,6 +163,7 @@ void AudioEngine::process_audio(const float* input, float* output, int frame_cou
164163
if (metronome_click_samples_remaining_ <= 0) {
165164
return 0.0f;
166165
}
166+
static constexpr float kTwoPi = 6.28318530718f;
167167
float click = std::sin(metronome_click_phase_) * metronome_click_env_ * metronome_volume_smoothed_;
168168
metronome_click_phase_ += metronome_click_phase_inc_;
169169
if (metronome_click_phase_ >= kTwoPi) {

src/gui/gui_manager.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ GuiManager::GuiManager(AudioEngine& engine)
4848
pedal_board_ = std::make_unique<PedalBoard>(engine_, command_history_, &gui_midi_);
4949
gui_presets_.set_pedal_board(pedal_board_.get());
5050
gui_presets_.set_midi_manager(&midi_manager_);
51+
gui_analyzer_.set_expanded(engine_.is_analyzer_enabled());
5152
}
5253

5354
GuiManager::~GuiManager() {

src/gui/pedalboard/pedal_board_chain.cpp

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -116,33 +116,17 @@ void PedalBoard::render_signal_chain() {
116116
ui_state.target_zoom = 1.0f;
117117
}
118118
}
119-
if (ui_state.show_grid) {
120-
float GRID_SZ = 32.0f * ui_state.zoom;
121-
ImU32 GRID_COLOR = IM_COL32(36, 34, 30, 255);
122-
for (float x = std::fmod(ui_state.scrolling.x, GRID_SZ); x < canvas_size.x;
123-
x += GRID_SZ) {
124-
draw_list->AddLine(ImVec2(canvas_pos.x + x, canvas_pos.y),
125-
ImVec2(canvas_pos.x + x, canvas_end.y), GRID_COLOR);
126-
}
127-
for (float y = std::fmod(ui_state.scrolling.y, GRID_SZ); y < canvas_size.y;
128-
y += GRID_SZ) {
129-
draw_list->AddLine(ImVec2(canvas_pos.x, canvas_pos.y + y),
130-
ImVec2(canvas_end.x, canvas_pos.y + y), GRID_COLOR);
131-
}
132-
}
133-
draw_list->PushClipRect(canvas_pos, canvas_end, true);
134-
ImVec2 offset = ImVec2(canvas_pos.x + ui_state.scrolling.x,
135-
canvas_pos.y + ui_state.scrolling.y);
136-
std::unordered_map<int, ImVec2> pin_positions_cache;
137-
int node_to_delete = -1; // Safely track deletions outside the render loop
138-
// Prune stale nodes from the UI state if the backend graph was reset or
139-
// rebuilt
140119
std::vector<int> stale_ids;
141-
for (auto &pair : ui_state.node_positions) {
142-
if (!audio_graph.find_node(pair.first)) {
143-
stale_ids.push_back(pair.first);
120+
for (auto it = ui_state.node_positions.begin(); it != ui_state.node_positions.end(); ) {
121+
bool found = false;
122+
for (const auto &node : audio_graph.get_nodes()) {
123+
if (node.id == it->first) { found = true; break; }
144124
}
125+
if (!found) { stale_ids.push_back(it->first); it = ui_state.node_positions.erase(it); }
126+
else ++it;
145127
}
128+
draw_list->PushClipRect(canvas_pos, canvas_end, true);
129+
ImVec2 offset(canvas_pos.x + ui_state.scrolling.x, canvas_pos.y + ui_state.scrolling.y);
146130
for (int id : stale_ids) {
147131
ui_state.node_positions.erase(id);
148132
}
@@ -178,6 +162,8 @@ void PedalBoard::render_signal_chain() {
178162
float level = engine_.get_output_level();
179163
float time = (float)ImGui::GetTime();
180164
bool is_running = engine_.is_running();
165+
int node_to_delete = -1;
166+
std::unordered_map<int, ImVec2> pin_positions_cache;
181167
for (const auto &node : audio_graph.get_nodes()) {
182168
auto &node_layout = ui_state.node_positions[node.id];
183169
ImVec2 node_screen_pos =
@@ -260,14 +246,57 @@ void PedalBoard::render_signal_chain() {
260246
node_layout.is_dragging = true;
261247
node_layout.drag_start_pos = node_layout.position;
262248
}
263-
node_layout.position.x += ImGui::GetIO().MouseDelta.x / ui_state.zoom;
264-
node_layout.position.y += ImGui::GetIO().MouseDelta.y / ui_state.zoom;
265-
} else if (node_layout.is_dragging && ImGui::IsItemDeactivated()) {
266-
node_layout.is_dragging = false;
267-
if (node_layout.position.x != node_layout.drag_start_pos.x ||
268-
node_layout.position.y != node_layout.drag_start_pos.y) {
269-
history_.push_executed(std::make_unique<MoveGraphNodeCommand>(
270-
node.id, node_layout.drag_start_pos, node_layout.position));
249+
float node_width = (target_widget ? (is_mb_comp ? 190.0f * 2.2f : 190.0f) : 110.0f) * ui_state.zoom;
250+
float node_height = (target_widget ? 360.0f : 70.0f) * ui_state.zoom;
251+
ImGui::PushID(node.id);
252+
if (target_widget) {
253+
ImGui::SetCursorScreenPos(node_screen_pos);
254+
ImGui::BeginGroup();
255+
ImGui::SetWindowFontScale(ui_state.zoom);
256+
target_widget->render(ui_state.zoom);
257+
ImGui::SetWindowFontScale(1.0f);
258+
ImGui::EndGroup();
259+
ImGui::SetCursorScreenPos(node_screen_pos);
260+
ImGui::SetNextItemAllowOverlap();
261+
ImGui::InvisibleButton("native_drag_handle", ImVec2(node_width - 25.0f * ui_state.zoom, 30.0f * ui_state.zoom));
262+
if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
263+
if (!node_layout.is_dragging) {
264+
node_layout.is_dragging = true;
265+
node_layout.drag_start_pos = node_layout.position;
266+
}
267+
node_layout.position.x += ImGui::GetIO().MouseDelta.x / ui_state.zoom;
268+
node_layout.position.y += ImGui::GetIO().MouseDelta.y / ui_state.zoom;
269+
} else if (node_layout.is_dragging && ImGui::IsItemDeactivated()) {
270+
node_layout.is_dragging = false;
271+
if (node_layout.position.x != node_layout.drag_start_pos.x || node_layout.position.y != node_layout.drag_start_pos.y) {
272+
history_.push_executed(std::make_unique<MoveGraphNodeCommand>(node.id, node_layout.drag_start_pos, node_layout.position));
273+
}
274+
}
275+
} else {
276+
ImVec2 node_end = ImVec2(node_screen_pos.x + node_width, node_screen_pos.y + node_height);
277+
ImU32 bg_color = IM_COL32(50, 35, 60, 255);
278+
draw_list->AddRectFilled(node_screen_pos, node_end, bg_color, Theme::ROUNDING_MD * ui_state.zoom);
279+
draw_list->AddRect(node_screen_pos, node_end, IM_COL32(180, 140, 80, 180), Theme::ROUNDING_MD * ui_state.zoom, 0, 1.5f * ui_state.zoom);
280+
ImGui::SetCursorScreenPos(node_screen_pos);
281+
ImGui::SetNextItemAllowOverlap();
282+
ImGui::InvisibleButton("util_drag_handle", ImVec2(node_width - 25.0f * ui_state.zoom, node_height));
283+
if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
284+
if (!node_layout.is_dragging) {
285+
node_layout.is_dragging = true;
286+
node_layout.drag_start_pos = node_layout.position;
287+
}
288+
node_layout.position.x += ImGui::GetIO().MouseDelta.x / ui_state.zoom;
289+
node_layout.position.y += ImGui::GetIO().MouseDelta.y / ui_state.zoom;
290+
} else if (node_layout.is_dragging && ImGui::IsItemDeactivated()) {
291+
node_layout.is_dragging = false;
292+
if (node_layout.position.x != node_layout.drag_start_pos.x || node_layout.position.y != node_layout.drag_start_pos.y) {
293+
history_.push_executed(std::make_unique<MoveGraphNodeCommand>(node.id, node_layout.drag_start_pos, node_layout.position));
294+
}
295+
}
296+
ImVec2 text_pos = ImVec2(node_screen_pos.x + 12.0f * ui_state.zoom, node_screen_pos.y + 25.0f * ui_state.zoom);
297+
ImGui::SetWindowFontScale(ui_state.zoom);
298+
draw_list->AddText(text_pos, IM_COL32(255, 255, 255, 255), node.name.c_str());
299+
ImGui::SetWindowFontScale(1.0f);
271300
}
272301
}
273302
ImVec2 text_pos = ImVec2(node_screen_pos.x + 12.0f * ui_state.zoom, node_screen_pos.y + 25.0f * ui_state.zoom);

src/gui/views/gui_analyzer.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ void GuiAnalyzer::render() {
142142
float panel_h = expanded_ ? 230.0f : 34.0f;
143143
ImGui::BeginChild("AnalyzerPanel", ImVec2(0, panel_h), true, ImGuiWindowFlags_NoScrollbar);
144144

145-
const bool expanded = ImGui::CollapsingHeader("Real-Time Analyzer", ImGuiTreeNodeFlags_DefaultOpen);
145+
ImGui::SetNextItemOpen(expanded_, ImGuiCond_Once);
146+
const bool expanded = ImGui::CollapsingHeader("Real-Time Analyzer");
146147
if (expanded != expanded_) {
147148
expanded_ = expanded;
148149
if (p.on_expanded_changed) p.on_expanded_changed(expanded_);

0 commit comments

Comments
 (0)