Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -389,13 +389,22 @@ jobs:
run: npx playwright test

- name: Upload Playwright HTML report
if: failure()
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: tests/web/playwright-report/
retention-days: 7

- name: Upload Playwright test results
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-test-results
path: tests/web/test-results/
retention-days: 7


test-windows:
name: Test on Windows
runs-on: windows-latest
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ desktop.ini
external/
node_modules/
package-lock.json
# Playwright test results & reports
test-results/
playwright-report/
blob-report/
tests/web/test-results/
tests/web/playwright-report/
tests/web/blob-report/

# Compiled
*.o
Expand Down
5 changes: 5 additions & 0 deletions src/gui/gui_graph_state.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ class GuiGraphState {

// Canvas panning and zoom/grid configurations
ImVec2 scrolling = ImVec2(0.0f, 0.0f);
ImVec2 target_scrolling = ImVec2(0.0f, 0.0f);
bool show_grid = true;
bool is_fullscreen = false;
bool hand_tool_active = false;
float zoom = 1.0f;
float target_zoom = 1.0f;
float dpi_scale = 1.0f;
ImVec2 last_canvas_pos = ImVec2(0.0f, 0.0f);
bool canvas_hovered = false;

// Node positioning registry mapped by Node ID
std::unordered_map<int, NodeLayoutState> node_positions;
Expand Down
3 changes: 3 additions & 0 deletions src/gui/gui_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "gui/theme.h"
#include "gui/file_dialog.h"
#include "gui/command.h"
#include "gui/gui_graph_state.h"
#include "preset_manager.h"

#include "gui/gl_setup.h"
Expand Down Expand Up @@ -123,6 +124,8 @@ bool GuiManager::initialize(int width, int height) {
}
#endif

GuiGraphState::get_instance().dpi_scale = dpi_scale;

{
const float base_font_size = 14.0f;
const float scaled_size = base_font_size * dpi_scale;
Expand Down
1 change: 1 addition & 0 deletions src/gui/gui_manager.h
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class GuiManager {
bool run_frame();

MidiManager& midi_manager() { return midi_manager_; }
AudioEngine& audio_engine() { return engine_; }

private:
void render_menu_bar();
Expand Down
131 changes: 68 additions & 63 deletions src/gui/pedal_board_chain.cpp

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "common.h"
#include "audio/audio_engine.h"
#include "gui/gui_manager.h"
#include "gui/gui_graph_state.h"
#include "preset_manager.h"
#include "cli.h"

Expand Down Expand Up @@ -88,6 +89,110 @@ extern "C" EMSCRIPTEN_KEEPALIVE void on_midi_device_connected(const char* device

emscripten_log(EM_LOG_INFO, "[MIDI] Device connected: %s", device_name);
}

extern "C" EMSCRIPTEN_KEEPALIVE void on_canvas_touch_gesture(float dx, float dy, float dscale, float local_x, float local_y) {
auto& ui = Amplitron::GuiGraphState::get_instance();
if (dscale != 0.0f) {
float factor = 1.0f + dscale;
float old_zoom = ui.target_zoom;
float new_zoom = old_zoom * factor;
if (new_zoom < 0.2f) new_zoom = 0.2f;
if (new_zoom > 5.0f) new_zoom = 5.0f;
float actual_factor = new_zoom / old_zoom;
ui.target_scrolling.x = local_x - (local_x - ui.target_scrolling.x) * actual_factor;
ui.target_scrolling.y = local_y - (local_y - ui.target_scrolling.y) * actual_factor;
ui.target_zoom = new_zoom;
}
ui.target_scrolling.x += dx;
ui.target_scrolling.y += dy;
}

extern "C" EMSCRIPTEN_KEEPALIVE bool is_canvas_hovered() {
return Amplitron::GuiGraphState::get_instance().canvas_hovered;
}

extern "C" EMSCRIPTEN_KEEPALIVE float get_canvas_zoom() {
return Amplitron::GuiGraphState::get_instance().target_zoom;
}

extern "C" EMSCRIPTEN_KEEPALIVE float get_canvas_scroll_x() {
return Amplitron::GuiGraphState::get_instance().target_scrolling.x;
}

extern "C" EMSCRIPTEN_KEEPALIVE float get_canvas_scroll_y() {
return Amplitron::GuiGraphState::get_instance().target_scrolling.y;
}

extern "C" EMSCRIPTEN_KEEPALIVE int get_node_count() {
if (!g_gui) return 0;
return static_cast<int>(g_gui->audio_engine().graph().get_nodes().size());
}

extern "C" EMSCRIPTEN_KEEPALIVE int get_link_count() {
if (!g_gui) return 0;
return static_cast<int>(g_gui->audio_engine().graph().get_links().size());
}

extern "C" EMSCRIPTEN_KEEPALIVE bool has_node_of_type(int routing_type) {
if (!g_gui) return false;
for (const auto& n : g_gui->audio_engine().graph().get_nodes()) {
if (static_cast<int>(n.routing_type) == routing_type) return true;
}
return false;
}

extern "C" EMSCRIPTEN_KEEPALIVE int trigger_add_splitter_node() {
if (!g_gui) return -1;
auto& graph = g_gui->audio_engine().graph();
int id = graph.add_node("Splitter", Amplitron::NodeRoutingType::Splitter);
g_gui->audio_engine().commit_graph_changes();
return id;
}

extern "C" EMSCRIPTEN_KEEPALIVE int trigger_add_link(int src_pin, int dst_pin) {
if (!g_gui) return -1;
auto& graph = g_gui->audio_engine().graph();
int id = graph.add_link(src_pin, dst_pin);
g_gui->audio_engine().commit_graph_changes();
return id;
}

extern "C" EMSCRIPTEN_KEEPALIVE int get_node_output_pin_by_index(int node_index, int pin_index) {
if (!g_gui) return -1;
const auto& nodes = g_gui->audio_engine().graph().get_nodes();
if (node_index < 0 || node_index >= static_cast<int>(nodes.size())) return -1;
const auto& node = nodes[node_index];
if (pin_index < 0 || pin_index >= static_cast<int>(node.output_pin_ids.size())) return -1;
return node.output_pin_ids[pin_index];
}

extern "C" EMSCRIPTEN_KEEPALIVE int get_node_input_pin_by_index(int node_index, int pin_index) {
if (!g_gui) return -1;
const auto& nodes = g_gui->audio_engine().graph().get_nodes();
if (node_index < 0 || node_index >= static_cast<int>(nodes.size())) return -1;
const auto& node = nodes[node_index];
if (pin_index < 0 || pin_index >= static_cast<int>(node.input_pin_ids.size())) return -1;
return node.input_pin_ids[pin_index];
}

extern "C" EMSCRIPTEN_KEEPALIVE bool trigger_delete_last_node() {
if (!g_gui) return false;
auto& graph = g_gui->audio_engine().graph();
const auto& nodes = graph.get_nodes();
// Walk backwards to find the last deletable node (mirrors GUI rules: Input and Amp Sim are protected)
for (int i = static_cast<int>(nodes.size()) - 1; i >= 0; --i) {
const auto& node = nodes[i];
if (node.name == "Input" || node.name == "Amp Sim") continue;
bool ok = graph.remove_node(node.id);
if (ok) {
Amplitron::GuiGraphState::get_instance().node_positions.erase(node.id);
g_gui->audio_engine().commit_graph_changes();
}
return ok;
}
return false; // No deletable node found
}

#endif

void signal_handler(int /*signal*/) {
Expand Down
146 changes: 146 additions & 0 deletions tests/web/amplitron.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@

import { test, expect, Page, ConsoleMessage } from '@playwright/test';

// Emscripten injects `Module` into the page's global scope at runtime.
// Declare it here so TypeScript doesn't report ts(2304) errors.
// Overloads narrow the return type based on the `returnType` string literal.
declare const Module: {
ccall(ident: string, returnType: 'number', argTypes: string[], args: (number | string | boolean)[]): number;
ccall(ident: string, returnType: 'boolean', argTypes: string[], args: (number | string | boolean)[]): boolean;
ccall(ident: string, returnType: 'string', argTypes: string[], args: (number | string | boolean)[]): string;
ccall(ident: string, returnType: null, argTypes: string[], args: (number | string | boolean)[]): void;
};


// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -465,4 +476,139 @@ test.describe('Web MIDI Support', () => {
timeout: 5000
});
});
});
test.describe('Modular Graph Canvas Interactions', () => {
async function waitForRuntime(page: Page) {
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
page.on('pageerror', err => console.error('BROWSER ERROR:', err.message));
await page.goto('/');
await page.waitForSelector('#loading.hidden', { timeout: 60_000 });
const overlay = page.locator('#audio-unlock');
if (await overlay.isVisible()) await overlay.click();
await page.waitForTimeout(500);
}

test('canvas pan via right-click drag shifts scrolling', async ({ page }) => {
await waitForRuntime(page);

const before = await page.evaluate(() => ({
x: Module.ccall('get_canvas_scroll_x', 'number', [], []),
y: Module.ccall('get_canvas_scroll_y', 'number', [], []),
}));

const canvas = page.locator('#canvas');
const box = await canvas.boundingBox();
if (!box) throw new Error('canvas not visible');

const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await page.mouse.click(cx, cy, { button: 'right' });
await page.mouse.move(cx, cy);
await page.mouse.down({ button: 'right' });
await page.mouse.move(cx + 80, cy + 60, { steps: 10 });
await page.mouse.up({ button: 'right' });

await page.waitForTimeout(200);

const after = await page.evaluate(() => ({
x: Module.ccall('get_canvas_scroll_x', 'number', [], []),
y: Module.ccall('get_canvas_scroll_y', 'number', [], []),
}));

expect(after.x).not.toBeCloseTo(before.x, 0);
expect(after.y).not.toBeCloseTo(before.y, 0);
});

test('two-finger touch gesture pans and zooms the canvas', async ({ page }) => {
await waitForRuntime(page);

const before = await page.evaluate(() => ({
zoom: Module.ccall('get_canvas_zoom', 'number', [], []),
sx: Module.ccall('get_canvas_scroll_x', 'number', [], []),
}));

await page.evaluate(() => {
Module.ccall('on_canvas_touch_gesture', null, ['number','number','number','number','number'], [30, 20, 0.15, 640, 360]);
});

const after = await page.evaluate(() => ({
zoom: Module.ccall('get_canvas_zoom', 'number', [], []),
sx: Module.ccall('get_canvas_scroll_x', 'number', [], []),
}));

expect(after.zoom).toBeGreaterThan(before.zoom);
expect(after.sx).not.toBeCloseTo(before.sx, 0);
});

test('adding a Splitter node increases the node count', async ({ page }) => {
await waitForRuntime(page);

const countBefore: number = await page.evaluate(() =>
Module.ccall('get_node_count', 'number', [], [])
);

await page.evaluate(() =>
Module.ccall('trigger_add_splitter_node', 'number', [], [])
);
await page.waitForTimeout(200);

const countAfter: number = await page.evaluate(() =>
Module.ccall('get_node_count', 'number', [], [])
);

expect(countAfter).toBe(countBefore + 1);

const hasSplitter: boolean = await page.evaluate(() =>
Module.ccall('has_node_of_type', 'boolean', ['number'], [1])
);
expect(hasSplitter).toBe(true);
});

test('drawing a cable between two nodes increases link count', async ({ page }) => {
await waitForRuntime(page);

const linksBefore: number = await page.evaluate(() =>
Module.ccall('get_link_count', 'number', [], [])
);

await page.evaluate(() => {
Module.ccall('trigger_add_splitter_node', 'number', [], []);
});
await page.waitForTimeout(100);

const result: number = await page.evaluate(() => {
const srcPin = Module.ccall('get_node_output_pin_by_index', 'number', ['number', 'number'], [2, 0]);
const dstPin = Module.ccall('get_node_input_pin_by_index', 'number', ['number', 'number'], [3, 0]);
return Module.ccall('trigger_add_link', 'number', ['number', 'number'], [srcPin, dstPin]);
});

const linksAfter: number = await page.evaluate(() =>
Module.ccall('get_link_count', 'number', [], [])
);

expect(linksAfter).toBeGreaterThan(linksBefore);
});

test('deleting a node decreases the node count', async ({ page }) => {
await waitForRuntime(page);

await page.evaluate(() =>
Module.ccall('trigger_add_splitter_node', 'number', [], [])
);
await page.waitForTimeout(100);

const countBefore: number = await page.evaluate(() =>
Module.ccall('get_node_count', 'number', [], [])
);

const deleted: boolean = await page.evaluate(() =>
Module.ccall('trigger_delete_last_node', 'boolean', [], [])
);
expect(deleted).toBe(true);

const countAfter: number = await page.evaluate(() =>
Module.ccall('get_node_count', 'number', [], [])
);
expect(countAfter).toBe(countBefore - 1);
});
});
85 changes: 0 additions & 85 deletions tests/web/playwright-report/index.html

This file was deleted.

4 changes: 1 addition & 3 deletions tests/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,7 @@ export default defineConfig({
'--use-fake-audio-capture',
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
// SwiftShader software rasteriser for WebGL in CI (no GPU)
'--use-gl=swiftshader',
'--disable-gpu',
...(process.env.CI ? ['--use-gl=swiftshader', '--disable-gpu'] : ['--use-gl=angle']),
'--no-sandbox',
],
},
Expand Down
Loading
Loading