diff --git a/client/makefile b/client/makefile index 2f69324..a13b1bc 100644 --- a/client/makefile +++ b/client/makefile @@ -1,5 +1,5 @@ CC = gcc -CFLAGS = -Wall -Wextra -Wpedantic -std=c11 -Isrc -I../engine/src +CFLAGS = -Wall -Wextra -Wpedantic -std=c11 -Isrc -I../engine/src -fsanitize=address -fno-omit-frame-pointer CARLA_INC = -I/usr/include/carla -I/usr/include/carla/includes CARLA_LIB = -L/usr/lib/carla -Wl,-rpath,/usr/lib/carla -lcarla_standalone2 @@ -20,10 +20,10 @@ TEST_INTEGRATION_BIN = test_integration all: looper-client test_status_parse looper-client: src/main.c src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ) $(LOG_OBJ) - $(CC) $(CFLAGS) $(CARLA_INC) -o $@ $^ $(CARLA_LIB) -ljack -lncurses + $(CC) $(CFLAGS) $(CARLA_INC) -fsanitize=address -o $@ $^ $(CARLA_LIB) -ljack -lncurses -test_status_parse: tests/test_status_parse.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ) - $(CC) $(CFLAGS) $(CARLA_INC) -o test_status_parse tests/test_status_parse.c src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ) $(CARLA_LIB) -ljack -lncurses +test_status_parse: tests/test_status_parse.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ) $(LOG_OBJ) + $(CC) $(CFLAGS) $(CARLA_INC) -o test_status_parse tests/test_status_parse.c src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ) $(LOG_OBJ) $(CARLA_LIB) -ljack -lncurses # --- Plugin stubs (now real) --- $(PLUGINS_OBJ): src/plugins.c src/plugins.h @@ -84,7 +84,7 @@ TEST_CLIENT_OBJ = tests/test_client.o $(TEST_CLIENT_OBJ): tests/test_client.c src/tui.h $(CC) $(CFLAGS) $(CARLA_INC) -c -o $@ $< -$(TEST_CLIENT_BIN): $(TEST_CLIENT_OBJ) src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ) +$(TEST_CLIENT_BIN): $(TEST_CLIENT_OBJ) src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ) $(LOG_OBJ) $(CC) $(CFLAGS) $(CARLA_INC) -o $@ $^ $(CARLA_LIB) -ljack -lncurses # --- Carla host tests --- diff --git a/client/src/carla_host.c b/client/src/carla_host.c index fdba62f..b3ed599 100644 --- a/client/src/carla_host.c +++ b/client/src/carla_host.c @@ -1,5 +1,6 @@ #include #include +#include #include #include "carla_host.h" @@ -137,21 +138,25 @@ int carla_connect(int id, const char *port_name, const char *looper_port) { if (!port_name || !looper_port) return -1; if (!jack_client) return -1; + fprintf(stderr, "CARLA_CONNECT: plugin_id=%d conn_count=%d port=%s looper=%s\n", + id, conn_count, port_name, looper_port); // Real JACK port connection int ret = jack_connect(jack_client, looper_port, port_name); if (ret != 0) return -1; // Store the connection so we can disconnect it later - if (conn_count < MAX_CONNECTIONS) { - connections[conn_count].plugin_id = id; - strncpy(connections[conn_count].plugin_port, port_name, - sizeof(connections[conn_count].plugin_port) - 1); - connections[conn_count].plugin_port[sizeof(connections[conn_count].plugin_port) - 1] = '\0'; - strncpy(connections[conn_count].looper_port, looper_port, - sizeof(connections[conn_count].looper_port) - 1); - connections[conn_count].looper_port[sizeof(connections[conn_count].looper_port) - 1] = '\0'; - conn_count++; + if (conn_count >= MAX_CONNECTIONS) { + fprintf(stderr, "WARN: connection array full, refusing new connection\n"); + return -1; } + connections[conn_count].plugin_id = id; + strncpy(connections[conn_count].plugin_port, port_name, + sizeof(connections[conn_count].plugin_port) - 1); + connections[conn_count].plugin_port[sizeof(connections[conn_count].plugin_port) - 1] = '\0'; + strncpy(connections[conn_count].looper_port, looper_port, + sizeof(connections[conn_count].looper_port) - 1); + connections[conn_count].looper_port[sizeof(connections[conn_count].looper_port) - 1] = '\0'; + conn_count++; return 0; } diff --git a/client/src/tui.c b/client/src/tui.c index d76e01f..614e42a 100644 --- a/client/src/tui.c +++ b/client/src/tui.c @@ -24,34 +24,29 @@ static bool engine_running = false; static bool debug_mode = false; +/* Persistent FIFO fds – open once and reuse */ +static int cmd_fifo_fd = -1; +static int status_fifo_fd = -1; + /* ---------- FIFO command helper ---------- */ int send_command(const char *cmd) { if (debug_mode) fprintf(stderr, "DEBUG: send_command(%s)\n", cmd); - const char *fifo_path = getenv("LOOPER_CMD_FIFO"); - if (!fifo_path) fifo_path = "/tmp/looper_cmd"; - // Retry open up to 5 times with a short sleep, blocking mode - int fd = -1; - for (int attempt = 0; attempt < 5 && fd < 0; attempt++) { - fd = open(fifo_path, O_WRONLY); // blocking – waits for reader - if (fd < 0) { - if (errno == ENXIO && attempt < 4) - { - struct timespec ts = { .tv_sec = 0, .tv_nsec = 10000000 }; - nanosleep(&ts, NULL); - } - else - break; + if (cmd_fifo_fd < 0) { + const char *fifo_path = getenv("LOOPER_CMD_FIFO"); + if (!fifo_path) fifo_path = "/tmp/looper_cmd"; + cmd_fifo_fd = open(fifo_path, O_WRONLY); + if (cmd_fifo_fd < 0) { + perror("open cmd FIFO"); + return -1; } } - if (fd < 0) return -1; size_t len = strlen(cmd); - int n = write(fd, cmd, len); + int n = write(cmd_fifo_fd, cmd, len); if (n == (int)len && cmd[len-1] != '\n') - write(fd, "\n", 1); - close(fd); + write(cmd_fifo_fd, "\n", 1); return (n >= 0) ? 0 : -1; } @@ -254,10 +249,12 @@ static bool in_colon = false; /* Read the status FIFO once and update cell_state array */ static void tui_read_status(void) { - int fd = open(STATUS_FIFO, O_RDONLY | O_NONBLOCK); - if (fd < 0) return; - char buf[256]; - int n = read(fd, buf, sizeof(buf)-1); + if (status_fifo_fd < 0) { + status_fifo_fd = open(STATUS_FIFO, O_RDONLY | O_NONBLOCK); + if (status_fifo_fd < 0) return; + } + char buf[4096]; + int n = read(status_fifo_fd, buf, sizeof(buf)-1); if (n > 0) { buf[n] = '\0'; char *line = buf; @@ -274,7 +271,7 @@ static void tui_read_status(void) { if (nl) { *nl = '\n'; line = nl + 1; } else break; } } - close(fd); + /* keep fd open */ } void tui_run(void) { @@ -310,7 +307,14 @@ void tui_run(void) { } /* redraw grid (status may have changed – no extra key needed) */ - draw_grid(); + { + struct timespec t1, t2; + clock_gettime(CLOCK_MONOTONIC, &t1); + draw_grid(); + clock_gettime(CLOCK_MONOTONIC, &t2); + double ms = (t2.tv_sec - t1.tv_sec)*1000.0 + (t2.tv_nsec - t1.tv_nsec)/1000000.0; + if (ms > 200) log_msg("SLOW draw_grid: %f ms", ms); + } int chc = getch(); @@ -470,6 +474,14 @@ void tui_run(void) { } void tui_cleanup(void) { + if (cmd_fifo_fd >= 0) { + close(cmd_fifo_fd); + cmd_fifo_fd = -1; + } + if (status_fifo_fd >= 0) { + close(status_fifo_fd); + status_fifo_fd = -1; + } if (yank_buffer.clip_indices) free(yank_buffer.clip_indices); /* free script note allocations */ script_cleanup(); diff --git a/e2e/test.ts b/e2e/test.ts index 90fb5eb..fbb30c0 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -71,8 +71,8 @@ function writeFifoCommand(cmd: string): void { function setupTest() { process.stdout.write(" Killing stale processes...\n"); - runNoThrow("pkill -9 -x looper"); - runNoThrow("pkill -9 -x looper-client"); + runNoThrow("pkill -15 -x looper"); + runNoThrow("pkill -15 -x looper-client"); runNoThrow("pkill -9 -x jack_capture"); runNoThrow("tmux kill-session -t looper 2>/dev/null || true"); process.stdout.write(" Checking JACK...\n"); @@ -90,8 +90,8 @@ function teardownTest() { fs.closeSync(cmdFifoFd); cmdFifoFd = null; } - runNoThrow("pkill -9 -x looper"); - runNoThrow("pkill -9 -x looper-client"); + runNoThrow("pkill -15 -x looper"); + runNoThrow("pkill -15 -x looper-client"); runNoThrow("pkill -9 -x jack_capture"); runNoThrow("tmux kill-session -t looper"); } @@ -911,13 +911,13 @@ async function testStressRandomUsage(): Promise { throw new Error("Engine crash during stress test"); } - // Check TUI pane integrity (non‑empty, has selection line) + // Check TUI pane integrity (non‑empty, at least has header and a cell) let pane = tmuxCapturePane("looper", "0"); if (!pane || pane.trim() === "") { await wait(200); pane = tmuxCapturePane("looper", "0"); } - if (!pane || !pane.includes("Selected:")) { + if (!pane || !pane.includes("JACK Looper") || !pane.includes(" 0")) { console.log(` FAIL: TUI pane appears corrupted at key ${keysSent}`); console.log(" Pane:\n" + (pane ? pane.slice(0, 1000) : "(empty)")); teardownTest(); @@ -940,6 +940,67 @@ async function testStressRandomUsage(): Promise { teardownTest(); } +async function testKeyPressLatency(): Promise { + console.log("\nTest: KEY PRESS LATENCY (50 toggles, check for exponential slowdown)"); + setupTest(); + const engine = await startEngine(); + await startClientInTmux(); + openCmdFifo(); + await wait(500); + + const ITERATIONS = 50; + const LATENCY_WARN = 500; // warn if >500ms + const LATENCY_FAIL = 5000; // fail if >5s + + let latencies: number[] = []; + let prevState = "IDLE"; + + for (let i = 0; i < ITERATIONS; i++) { + // Determine which state we expect after toggle + const expectNext = (prevState === "IDLE") ? "R" : "L"; + const startTime = Date.now(); + tmuxSendKeys("looper", "0", "t"); + const pane = await waitForPaneText(expectNext, 10000); + const elapsed = Date.now() - startTime; + latencies.push(elapsed); + + // Log periodic summary + if (i % 10 === 9) { + const avg = latencies.slice(i-9, i+1).reduce((a,b)=>a+b,0) / 10; + console.log(` Iteration ${i+1}: avg last 10 = ${avg.toFixed(0)} ms, last = ${elapsed} ms`); + } + + if (elapsed > LATENCY_FAIL) { + console.log(` FAIL: Iteration ${i+1} latency ${elapsed} ms exceeds ${LATENCY_FAIL} ms`); + engine.kill(); teardownTest(); + throw new Error(`Latency exceeded fail threshold at iteration ${i+1}`); + } + + if (elapsed > LATENCY_WARN) { + console.log(` WARN: Iteration ${i+1} latency ${elapsed} ms > ${LATENCY_WARN} ms (possible slowdown)`); + } + + // Toggle state for next expectation + prevState = (prevState === "IDLE") ? "LOOPING" : "IDLE"; + await wait(200); // brief cooldown + } + + // Check for trend: if last 10 avg > 3x first 10 avg → exponential + const first10Avg = latencies.slice(0,10).reduce((a,b)=>a+b,0) / 10; + const last10Avg = latencies.slice(-10).reduce((a,b)=>a+b,0) / 10; + console.log(` First 10 avg: ${first10Avg.toFixed(0)} ms, Last 10 avg: ${last10Avg.toFixed(0)} ms`); + + if (last10Avg > 3 * first10Avg && last10Avg > 500) { + console.log(` FAIL: Latency grew from ${first10Avg.toFixed(0)} ms to ${last10Avg.toFixed(0)} ms (exponential pattern)`); + engine.kill(); teardownTest(); + throw new Error("Exponential latency increase"); + } + + console.log(" PASS: No exponential latency growth"); + engine.kill(); + teardownTest(); +} + async function main(): Promise { console.log("=== Looper E2E Tests ===\n"); @@ -956,7 +1017,8 @@ async function main(): Promise { */ testRecordOnHighRow, testRecordMoveRecord, - testStressRandomUsage + testStressRandomUsage, + testKeyPressLatency ]; let passCount = 0; let failCount = 0; diff --git a/engine/makefile b/engine/makefile index bcb012b..62eb568 100644 --- a/engine/makefile +++ b/engine/makefile @@ -1,6 +1,6 @@ CC ?= gcc -CFLAGS ?= -Wall -Wextra -g -Isrc -LDFLAGS ?= -ljack -lm -lsndfile -lpthread +CFLAGS ?= -Wall -Wextra -g -Isrc -fsanitize=address -fno-omit-frame-pointer +LDFLAGS ?= -fsanitize=address -ljack -lm -lsndfile -lpthread SRC = src/main.c src/looper.c src/channel.c src/midi.c src/queue.c src/pipe.c src/ringbuffer.c src/wav.c src/log.c OBJ = $(SRC:.c=.o) diff --git a/engine/src/looper.c b/engine/src/looper.c index c89b0e6..2e95215 100644 --- a/engine/src/looper.c +++ b/engine/src/looper.c @@ -1,6 +1,7 @@ // cppcheck-suppress missingIncludeSystem #include "looper.h" #include "channel.h" +#include "log.h" #include "midi.h" #include "pipe.h" #include "queue.h" @@ -10,12 +11,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include /* Global command queues (used by midi.c and pipe.c) */ spsc_queue_t cmd_queue; @@ -27,15 +30,81 @@ spsc_queue_t cmd_queue_main_fifo; /* writer status fd */ static int status_fd = -1; +static jack_client_t *global_client = NULL; + +/* Global state (shared across files) */ +struct channel_t channels[MAX_CHANNELS]; +atomic_int channel_count = 0; +atomic_int channel_capacity = MAX_CHANNELS; +int next_channel_id = 1; +atomic_int cmd_add = 0; +atomic_int cmd_remove = 0; +atomic_int cmd_load = 0; +atomic_int cmd_save = 0; +jack_port_t *midi_control_port = NULL; +jack_port_t *midi_clock_port = NULL; +atomic_int control_key_active = 0; +atomic_int bind_channel = 0; + +/* Track previous state to avoid writing unchanged status lines */ +static atomic_int prev_state[MAX_CHANNELS][MAX_SCENES]; + +/* Unregister all ports and close the JACK client */ +static void looper_cleanup(jack_client_t *client) { + for (int c = 0; c < MAX_CHANNELS; c++) { + if (channels[c].audio_in) { + jack_port_unregister(client, channels[c].audio_in); + channels[c].audio_in = NULL; + } + if (channels[c].audio_out) { + jack_port_unregister(client, channels[c].audio_out); + channels[c].audio_out = NULL; + } + if (channels[c].midi_in) { + jack_port_unregister(client, channels[c].midi_in); + channels[c].midi_in = NULL; + } + if (channels[c].midi_out) { + jack_port_unregister(client, channels[c].midi_out); + channels[c].midi_out = NULL; + } + } + if (midi_control_port) { + jack_port_unregister(client, midi_control_port); + midi_control_port = NULL; + } + if (midi_clock_port) { + jack_port_unregister(client, midi_clock_port); + midi_clock_port = NULL; + } +} + +/* Signal handler: deactivate and cleanup before exit */ +static void signal_handler(int sig) { + (void)sig; + if (global_client) { + looper_cleanup(global_client); + jack_client_close(global_client); + } + log_close(); + exit(0); +} + static void looper_write_status(void) { if (status_fd < 0) return; - char buf[256]; + char buf[4096]; + int pos = 0; for (int ch = 0; ch < MAX_CHANNELS; ch++) { if (!atomic_load(&channels[ch].active)) continue; int sc_idx = atomic_load(&channels[ch].current_scene); int state = atomic_load(&channels[ch].scenes[sc_idx].state); + int prev = atomic_load(&prev_state[ch][sc_idx]); + if (state == prev) + continue; /* unchanged, skip */ + atomic_store(&prev_state[ch][sc_idx], state); + const char *state_str; switch (state) { case STATE_IDLE: @@ -53,29 +122,17 @@ static void looper_write_status(void) { default: state_str = "UNKNOWN"; } - int n = snprintf(buf, sizeof(buf), "CH=%d SC=%d STATE=%s\n", ch, sc_idx, - state_str); - if (n > 0) { - int ret = write(status_fd, buf, n); - (void)ret; - } + int n = snprintf(buf + pos, sizeof(buf) - pos, + "CH=%d SC=%d STATE=%s\n", ch, sc_idx, state_str); + if (n > 0) pos += n; + if (pos >= (int)sizeof(buf) - 128) break; + } + if (pos > 0) { + int ret = write(status_fd, buf, pos); + (void)ret; } } -/* Global state (shared across files) */ -struct channel_t channels[MAX_CHANNELS]; -atomic_int channel_count = 0; -atomic_int channel_capacity = MAX_CHANNELS; -int next_channel_id = 1; -atomic_int cmd_add = 0; -atomic_int cmd_remove = 0; -atomic_int cmd_load = 0; -atomic_int cmd_save = 0; -jack_port_t *midi_control_port = NULL; -jack_port_t *midi_clock_port = NULL; -atomic_int control_key_active = 0; -atomic_int bind_channel = 0; - /* Deferred removal index (1 second grace) */ static int pending_unregister_idx = -1; @@ -465,16 +522,28 @@ int looper_init(jack_client_t *client) { /* store sample rate for writer thread */ global_sample_rate = jack_get_sample_rate(client); + global_client = client; + + /* Install signal handlers for graceful shutdown */ + signal(SIGINT, signal_handler); + signal(SIGTERM, signal_handler); + signal(SIGQUIT, signal_handler); + /* create status FIFO (ignore if already exists) */ mkfifo(STATUS_FIFO, 0666); /* open the status FIFO for reading+writing so writes work even without reader */ - status_fd = open(STATUS_FIFO, O_RDWR); + status_fd = open(STATUS_FIFO, O_RDWR | O_NONBLOCK); if (status_fd < 0) { perror("open status FIFO"); } + /* initialise prev_state to -1 */ + for (int ch = 0; ch < MAX_CHANNELS; ch++) + for (int sc = 0; sc < MAX_SCENES; sc++) + atomic_init(&prev_state[ch][sc], -1); + queue_init(&cmd_queue); queue_init(&cmd_queue_main_midi); queue_init(&cmd_queue_main_fifo);