46 Commits

Author SHA1 Message Date
Loic Coenen
32fb5d3524 refactor: enable all e2e tests and fix audio port naming 2026-06-05 19:39:53 +00:00
Loic Coenen
20176517a4 refactor: rename looper ports to ch0in/ch0out and move connection logic to client 2026-05-31 13:05:28 +00:00
Loic Coenen
316320c294 feat: add direct JACK port connection and VU meter support 2026-05-27 20:28:09 +00:00
Loic Coenen
1e62ec9310 feat: add port connection display and audio pass-through test 2026-05-24 22:35:51 +00:00
Loic Coenen
c9c8afc602 Merge branch 'e2e' into integrate-fzf 2026-05-24 14:15:45 +00:00
Loic Coenen
cd1adba9e3 refactor: move shutdown logic out of signal handler into main loop 2026-05-24 09:45:21 +00:00
Loic Coenen
dd67576c45 feat: add address sanitizer, persistent FIFO fds, and latency test 2026-05-24 09:22:22 +00:00
Loic Coenen
0537263a7a refactor: improve stress test stability and memory ordering in engine 2026-05-23 15:13:04 +00:00
Loic Coenen
d6bd31fed5 refactor: improve TUI polling, FIFO reliability, and add stress tests 2026-05-23 12:29:13 +00:00
Loic Coenen
7c289e1496 feat: add scene-based recording, e2e tests, and improved TUI state indicators 2026-05-22 17:52:13 +00:00
Loic Coenen
f2993eac80 feat: add engine alive indicator, debug mode, and orchestrator retry logic 2026-05-20 20:59:58 +00:00
Loic Coenen
4bdb4c8c5d feat: add fzf-based file/plugin/port selection and update build system 2026-05-19 17:04:52 +00:00
Loic Coenen
e79c2ac116 feat: add logging system, orchestrator, and documentation 2026-05-19 09:10:43 +00:00
Loic Coenen
f776b8a361 feat: add script module for note-to-command mapping with FIFO support 2026-05-18 21:12:29 +00:00
Loic Coenen
16a800209f fix: update looper binary and object file 2026-05-18 17:43:39 +00:00
Loic Coenen
f38797fe0a refactor: replace writer thread with synchronous save and fix ring buffer memory ordering 2026-05-18 17:35:31 +00:00
Loic Coenen
10e47e6c0c Merge branch '3-integrate-carla' 2026-05-17 19:39:54 +00:00
Loic Coenen
6c19429fba Merge branch '8-add-tui' - tests not passing 2026-05-17 19:02:03 +00:00
Loic Coenen
3646f6c47e Merge branch '6-recording-wav-file' 2026-05-17 16:59:56 +00:00
Loic Coenen
bb648d471b fix: resolve cppcheck warnings for const pointer and static functions
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:58:20 +00:00
Loic Coenen
fa9dbf2185 style: fix code formatting and include order in looper and ringbuffer 2026-05-12 19:58:19 +00:00
Loic Coenen
51493d5cab docs: add WAV load/save documentation and update evaluation table
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:35:21 +00:00
Loic Coenen
ce2dd7be76 fix: make channel state variables atomic to eliminate data races
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:32:10 +00:00
Loic Coenen
87d5e658c5 fix: restore all integration tests in main()
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:18:20 +00:00
Loic Coenen
525516fe03 refactor: replace manual WAV I/O with libsndfile
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:15:12 +00:00
Loic Coenen
3e52142f62 feat: replace manual WAV parsing with libsndfile
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:14:35 +00:00
Loic Coenen
a92b5c51e1 fix: skip remaining fmt chunk bytes correctly in wav_read
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:09:58 +00:00
Loic Coenen
bb3dfa8b2a fix: correct RIFF chunk size in test WAV header
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:07:09 +00:00
Loic Coenen
3721c0c9e1 refactor: disable all tests except failing WAV load/save
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:04:36 +00:00
Loic Coenen
c041645019 fix: increase sleep duration in WAV load test to ensure control key processing
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:03:22 +00:00
Loic Coenen
6344eaed47 fix: add debug output and increase delay in WAV load test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:02:59 +00:00
Loic Coenen
f96d7d290d fix: ensure fresh MIDI connection before each integration test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:49:12 +00:00
Loic Coenen
2d254c0503 fix: ensure fresh MIDI connection before each integration test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:39:48 +00:00
Loic Coenen
4339fda529 fix: keep persistent MIDI client across notes in integration tests
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:37:15 +00:00
Loic Coenen
04b59999c8 fix: make loop_count atomic and increase remove channel delay
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:28:54 +00:00
Loic Coenen
df1f4fa6bd fix: only set loop_count from record_pos when transitioning from record state
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:22:55 +00:00
Loic Coenen
7e5362259b refactor: extract JACK MIDI client reconnection logic
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:19:35 +00:00
Loic Coenen
b10d218749 fix: reconnect MIDI client before each test to avoid stale connections
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:19:06 +00:00
Loic Coenen
cc50577444 fix: cast atomic pointer loads/stores and remove duplicate free in writer_thread
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:01:57 +00:00
Loic Coenen
346c15d1c3 fix: use persistent MIDI client and fix save_ring race condition
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 22:14:33 +00:00
Loic Coenen
7deea9266b fix: reorder passthrough setup before load command in WAV load test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:49:35 +00:00
Loic Coenen
7d842163a2 fix: increase listen duration and add RMS logging in WAV load test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:39:49 +00:00
Loic Coenen
54fa307360 fix: increase sleep durations in WAV load test to prevent false failure
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:31:29 +00:00
Loic Coenen
5430795510 feat: push loop output into save ring during playback
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:16:02 +00:00
Loic Coenen
5a2414b4c3 feat: add WAV load/save and ring buffer implementation
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:15:12 +00:00
Loic Coenen
6b490ed739 feat: add WAV file loading, saving, and dedicated I/O threads
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 20:58:00 +00:00
45 changed files with 4643 additions and 1509 deletions

View File

@@ -1,5 +1,6 @@
CC = gcc CC = gcc
CFLAGS = -Wall -Wextra -Wpedantic -std=c11 -Isrc 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_INC = -I/usr/include/carla -I/usr/include/carla/includes
CARLA_LIB = -L/usr/lib/carla -Wl,-rpath,/usr/lib/carla -lcarla_standalone2 CARLA_LIB = -L/usr/lib/carla -Wl,-rpath,/usr/lib/carla -lcarla_standalone2
@@ -7,6 +8,8 @@ CARLA_LIB = -L/usr/lib/carla -Wl,-rpath,/usr/lib/carla -lcarla_standalone2
CARLA_OBJ = src/carla_host.o CARLA_OBJ = src/carla_host.o
PLUGINS_OBJ = src/plugins.o PLUGINS_OBJ = src/plugins.o
CLIENT_CMD_OBJ = src/client_cmd.o CLIENT_CMD_OBJ = src/client_cmd.o
SCRIPT_OBJ = src/script.o
LOG_OBJ = src/log.o
# Test binaries # Test binaries
TEST_PLUGINS_BIN = test_plugins TEST_PLUGINS_BIN = test_plugins
@@ -17,27 +20,53 @@ TEST_INTEGRATION_BIN = test_integration
all: looper-client test_status_parse all: looper-client test_status_parse
looper-client: src/main.c src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) 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) 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) $(CARLA_LIB) -ljack -lncurses $(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) --- # --- Plugin stubs (now real) ---
$(PLUGINS_OBJ): src/plugins.c src/plugins.h $(PLUGINS_OBJ): src/plugins.c src/plugins.h
$(CC) $(CFLAGS) $(CARLA_INC) -c -o $@ $< $(CC) $(CFLAGS) $(CARLA_INC) -c -o $@ $<
$(CARLA_OBJ): src/carla_host.c src/carla_host.h $(CARLA_OBJ): src/carla_host.c src/carla_host.h
$(CC) -Wall -Wextra -std=c11 -Isrc $(CARLA_INC) -c -o $@ $< $(CC) -Wall -Wextra -std=gnu11 -Isrc -I../engine/src $(CARLA_INC) -c -o $@ $<
CARLA_TEST_OBJ = src/carla_host_test.o CARLA_TEST_OBJ = src/carla_host_test.o
$(CARLA_TEST_OBJ): src/carla_host.c src/carla_host.h $(CARLA_TEST_OBJ): src/carla_host.c src/carla_host.h
$(CC) -Wall -Wextra -std=c11 -Isrc $(CARLA_INC) -DTESTING -c -o $@ $< $(CC) -Wall -Wextra -std=gnu11 -Isrc -I../engine/src $(CARLA_INC) -DTESTING -c -o $@ $<
$(CLIENT_CMD_OBJ): src/client_cmd.c src/client_cmd.h $(CLIENT_CMD_OBJ): src/client_cmd.c src/client_cmd.h
$(CC) $(CFLAGS) $(CARLA_INC) -c -o $@ $< $(CC) $(CFLAGS) $(CARLA_INC) -c -o $@ $<
$(SCRIPT_OBJ): src/script.c src/script.h
$(CC) $(CFLAGS) -c -o $@ $<
# --- Script test ---
TEST_SCRIPT_BIN = test_script
TEST_SCRIPT_OBJ = tests/test_script.o
$(TEST_SCRIPT_OBJ): tests/test_script.c src/script.h
$(CC) $(CFLAGS) -c -o $@ $<
TEST_TUI_STUB_OBJ = tests/test_tui_stub.o
tests/test_tui_stub.c:
mkdir -p tests
@printf '%s\n' '#include "tui.h"' '' 'char *tui_fzf_select(const char *const items[], size_t count, const char *prompt){(void)items;(void)count;(void)prompt;return NULL;}' '' 'void tui_cleanup(void){}' > $@
$(TEST_TUI_STUB_OBJ): tests/test_tui_stub.c
$(CC) $(CFLAGS) -c -o $@ $<
$(TEST_SCRIPT_BIN): $(TEST_SCRIPT_OBJ) $(SCRIPT_OBJ) $(PLUGINS_OBJ) $(CLIENT_CMD_OBJ) $(CARLA_OBJ) $(TEST_TUI_STUB_OBJ)
$(CC) $(CFLAGS) $(CARLA_INC) -o $@ $^ $(CARLA_LIB) -ljack -lncurses
$(LOG_OBJ): src/log.c
$(CC) $(CFLAGS) -c -o $@ $<
# --- Plugin tests --- # --- Plugin tests ---
TEST_PLUGINS_OBJ = tests/test_plugins.o TEST_PLUGINS_OBJ = tests/test_plugins.o
@@ -65,7 +94,7 @@ TEST_CLIENT_OBJ = tests/test_client.o
$(TEST_CLIENT_OBJ): tests/test_client.c src/tui.h $(TEST_CLIENT_OBJ): tests/test_client.c src/tui.h
$(CC) $(CFLAGS) $(CARLA_INC) -c -o $@ $< $(CC) $(CFLAGS) $(CARLA_INC) -c -o $@ $<
$(TEST_CLIENT_BIN): $(TEST_CLIENT_OBJ) src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_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 $(CC) $(CFLAGS) $(CARLA_INC) -o $@ $^ $(CARLA_LIB) -ljack -lncurses
# --- Carla host tests --- # --- Carla host tests ---
@@ -82,7 +111,7 @@ TEST_CARLA_MOCK_BIN = test_carla_host_mock
CARLA_MOCK_OBJ = src/carla_host_mock.o CARLA_MOCK_OBJ = src/carla_host_mock.o
$(CARLA_MOCK_OBJ): src/carla_host.c src/carla_host.h $(CARLA_MOCK_OBJ): src/carla_host.c src/carla_host.h
$(CC) -Wall -Wextra -std=c11 -Isrc $(CARLA_INC) -DTESTING -DMOCK_JACK -c -o $@ $< $(CC) -Wall -Wextra -std=gnu11 -Isrc -I../engine/src $(CARLA_INC) -DTESTING -DMOCK_JACK -c -o $@ $<
$(TEST_CARLA_MOCK_BIN): tests/test_carla_host_mock.c $(CARLA_MOCK_OBJ) $(TEST_CARLA_MOCK_BIN): tests/test_carla_host_mock.c $(CARLA_MOCK_OBJ)
$(CC) $(CFLAGS) $(CARLA_INC) -DTESTING -DMOCK_JACK -o $@ $^ $(CARLA_LIB) -ljack $(CC) $(CFLAGS) $(CARLA_INC) -DTESTING -DMOCK_JACK -o $@ $^ $(CARLA_LIB) -ljack
@@ -91,7 +120,7 @@ $(TEST_CARLA_MOCK_BIN): tests/test_carla_host_mock.c $(CARLA_MOCK_OBJ)
$(TEST_INTEGRATION_BIN): tests/test_integration.c $(CARLA_TEST_OBJ) $(TEST_INTEGRATION_BIN): tests/test_integration.c $(CARLA_TEST_OBJ)
$(CC) $(CFLAGS) $(CARLA_INC) -DTESTING -o $@ $^ $(CARLA_LIB) -ljack $(CC) $(CFLAGS) $(CARLA_INC) -DTESTING -o $@ $^ $(CARLA_LIB) -ljack
test: looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(TEST_CARLA_BIN) $(TEST_CLIENT_CMD_BIN) $(TEST_INTEGRATION_BIN) $(TEST_CARLA_MOCK_BIN) test: looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(TEST_CARLA_BIN) $(TEST_CLIENT_CMD_BIN) $(TEST_INTEGRATION_BIN) $(TEST_CARLA_MOCK_BIN) $(TEST_SCRIPT_BIN)
./test_status_parse ./test_status_parse
./$(TEST_PLUGINS_BIN) ./$(TEST_PLUGINS_BIN)
./$(TEST_CLIENT_BIN) ./$(TEST_CLIENT_BIN)
@@ -99,8 +128,9 @@ test: looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(T
./$(TEST_CLIENT_CMD_BIN) ./$(TEST_CLIENT_CMD_BIN)
./$(TEST_INTEGRATION_BIN) ./$(TEST_INTEGRATION_BIN)
./$(TEST_CARLA_MOCK_BIN) ./$(TEST_CARLA_MOCK_BIN)
./$(TEST_SCRIPT_BIN)
.PHONY: all test clean .PHONY: all test clean
clean: clean:
rm -f looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(TEST_CARLA_BIN) $(TEST_CLIENT_CMD_BIN) $(TEST_INTEGRATION_BIN) $(TEST_CARLA_MOCK_BIN) *.o tests/*.o src/*.o rm -f looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(TEST_CARLA_BIN) $(TEST_CLIENT_CMD_BIN) $(TEST_INTEGRATION_BIN) $(TEST_CARLA_MOCK_BIN) $(TEST_SCRIPT_BIN) *.o tests/*.o src/*.o

View File

@@ -1,5 +1,8 @@
#define _GNU_SOURCE
#include <stdlib.h>
#include <CarlaHost.h> #include <CarlaHost.h>
#include <CarlaBackend.h> #include <CarlaBackend.h>
#include <stdio.h>
#include <string.h> #include <string.h>
#include "carla_host.h" #include "carla_host.h"
@@ -55,6 +58,11 @@ int carla_init_jack(void) {
jack_status_t status; jack_status_t status;
jack_client = jack_client_open("looper-connector", JackNoStartServer, &status); jack_client = jack_client_open("looper-connector", JackNoStartServer, &status);
// It's okay if jack_client is NULL; we still try Carla // It's okay if jack_client is NULL; we still try Carla
if (jack_client) {
if (jack_activate(jack_client) != 0) {
fprintf(stderr, "WARN: could not activate looper-connector JACK client\n");
}
}
#endif #endif
// 2) Create the Carla host handle // 2) Create the Carla host handle
@@ -80,6 +88,28 @@ int carla_init_jack(void) {
return 0; return 0;
} }
int carla_connect_direct(const char *source, const char *target) {
if (!source || !target) return -1;
if (!jack_client) return -1;
int ret = jack_connect(jack_client, source, target);
if (ret != 0) {
fprintf(stderr, "JACK connect failed %s -> %s (ret=%d)\n", source, target, ret);
return ret;
}
// Store the connection so get_connected_port can find it
if (conn_count < MAX_CONNECTIONS) {
strncpy(connections[conn_count].plugin_port, source,
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, target,
sizeof(connections[conn_count].looper_port)-1);
connections[conn_count].looper_port[sizeof(connections[conn_count].looper_port)-1] = '\0';
connections[conn_count].plugin_id = -1; // direct connection
conn_count++;
}
return 0;
}
void carla_cleanup_jack(void) { void carla_cleanup_jack(void) {
if (handle != NULL) { if (handle != NULL) {
carla_engine_close(handle); carla_engine_close(handle);
@@ -132,17 +162,27 @@ int carla_unload(int id) {
int carla_connect(int id, const char *port_name, const char *looper_port) { int carla_connect(int id, const char *port_name, const char *looper_port) {
// Check that the plugin id is valid // Check that the plugin id is valid
if (id < 0 || id >= plugin_count) if (id < 0 || id >= plugin_count) {
fprintf(stderr, "CARLA_CONNECT: invalid plugin id %d (plugin_count=%d)\n", id, plugin_count);
return -1; return -1;
}
if (!port_name || !looper_port) return -1; if (!port_name || !looper_port) return -1;
if (!jack_client) 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 // Real JACK port connection
int ret = jack_connect(jack_client, looper_port, port_name); int ret = jack_connect(jack_client, port_name, looper_port);
if (ret != 0) return -1; if (ret != 0) {
fprintf(stderr, "CARLA_CONNECT: jack_connect(%s, %s) failed with %d\n", looper_port, port_name, ret);
return -1;
}
// Store the connection so we can disconnect it later // Store the connection so we can disconnect it later
if (conn_count < MAX_CONNECTIONS) { if (conn_count >= MAX_CONNECTIONS) {
fprintf(stderr, "WARN: connection array full, refusing new connection\n");
return -1;
}
connections[conn_count].plugin_id = id; connections[conn_count].plugin_id = id;
strncpy(connections[conn_count].plugin_port, port_name, strncpy(connections[conn_count].plugin_port, port_name,
sizeof(connections[conn_count].plugin_port) - 1); sizeof(connections[conn_count].plugin_port) - 1);
@@ -151,7 +191,6 @@ int carla_connect(int id, const char *port_name, const char *looper_port) {
sizeof(connections[conn_count].looper_port) - 1); sizeof(connections[conn_count].looper_port) - 1);
connections[conn_count].looper_port[sizeof(connections[conn_count].looper_port) - 1] = '\0'; connections[conn_count].looper_port[sizeof(connections[conn_count].looper_port) - 1] = '\0';
conn_count++; conn_count++;
}
return 0; return 0;
} }
@@ -206,6 +245,48 @@ int carla_disconnect_plugin(int id) {
return any ? 0 : -1; // return -1 if no connections were found (harmless) return any ? 0 : -1; // return -1 if no connections were found (harmless)
} }
#ifdef MOCK_JACK
/* Mock: return a few fake port names */
int carla_get_ports(const char *type, char ***ports, int *count) {
(void)type;
static const char *fake[] = {"system:capture_1", "system:capture_2", "system:playback_1", "system:playback_2"};
*count = 4;
*ports = malloc(*count * sizeof(char*));
if (!*ports) { *count = 0; return -1; }
for (int i = 0; i < *count; i++)
(*ports)[i] = strdup(fake[i]);
return 0;
}
#else
#include <jack/jack.h>
int carla_get_ports(const char *type, char ***ports, int *count) {
(void)type;
if (!jack_client) {
*ports = NULL;
*count = 0;
return -1;
}
const char **jports = jack_get_ports(jack_client, NULL, NULL, 0);
if (!jports) {
*ports = NULL;
*count = 0;
return -1;
}
int n = 0;
while (jports[n]) n++;
*count = n;
*ports = malloc(n * sizeof(char*));
if (!*ports) {
jack_free(jports);
return -1;
}
for (int i = 0; i < n; i++)
(*ports)[i] = strdup(jports[i]);
jack_free(jports);
return 0;
}
#endif
#ifdef TESTING #ifdef TESTING
int carla_test_connection_count(void) { int carla_test_connection_count(void) {
return conn_count; return conn_count;
@@ -230,3 +311,17 @@ int carla_test_add_connection(int plugin_id, const char *plugin_port, const char
CarlaHostHandle carla_get_handle(void) { CarlaHostHandle carla_get_handle(void) {
return handle; return handle;
} }
bool carla_get_connected_port(int channel, bool is_input, char *buf, size_t bufsize) {
char needle[64];
snprintf(needle, sizeof(needle), "ch%d%s", channel, is_input ? "in" : "out");
for (int i = 0; i < conn_count; i++) {
if (strstr(connections[i].looper_port, needle)) {
strncpy(buf, connections[i].plugin_port, bufsize - 1);
buf[bufsize - 1] = '\0';
return true;
}
}
buf[0] = '\0';
return false;
}

View File

@@ -6,6 +6,8 @@
/* All functions return -1 on error, 0 on success (except carla_load which returns 0 on success and sets *out_id) */ /* All functions return -1 on error, 0 on success (except carla_load which returns 0 on success and sets *out_id) */
bool carla_get_connected_port(int channel, bool is_input, char *buf, size_t bufsize);
int carla_init_jack(void); int carla_init_jack(void);
void carla_cleanup_jack(void); void carla_cleanup_jack(void);
@@ -14,8 +16,9 @@ int carla_unload(int id);
int carla_connect(int id, const char *port_name, const char *looper_port); int carla_connect(int id, const char *port_name, const char *looper_port);
int carla_disconnect(const char *from, const char *to); int carla_disconnect(const char *from, const char *to);
void carla_set_bypass(int id, bool bypass); void carla_set_bypass(int id, bool bypass);
int carla_connect_direct(const char *source, const char *target);
int carla_get_ports(const char *type, char ***ports, int *count);
/* Get internal Carla host handle, may be NULL */
int carla_disconnect_plugin(int id); int carla_disconnect_plugin(int id);
CarlaHostHandle carla_get_handle(void); CarlaHostHandle carla_get_handle(void);

View File

@@ -1,11 +1,13 @@
#include "client_cmd.h" #include "client_cmd.h"
#include "plugins.h" #include "plugins.h"
#include "carla_host.h"
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
static char from_port[256] = ""; static char from_port[256] = "";
static char to_port[256] = ""; static char to_port[256] = "";
char g_connect_error[512] = "";
const char* get_stored_from(void) { return from_port; } const char* get_stored_from(void) { return from_port; }
const char* get_stored_to(void) { return to_port; } const char* get_stored_to(void) { return to_port; }
@@ -34,18 +36,32 @@ int handle_client_command(const char *input, int *out_id) {
if (strcmp(token, "from") == 0) { if (strcmp(token, "from") == 0) {
const char *port = strtok(NULL, " "); const char *port = strtok(NULL, " ");
if (!port) return -1; if (!port) return -1;
int ret = carla_connect_direct(port, "looper:ch0in");
if (ret == 0) {
strncpy(from_port, port, sizeof(from_port)-1); strncpy(from_port, port, sizeof(from_port)-1);
from_port[sizeof(from_port)-1] = '\0'; from_port[sizeof(from_port)-1] = '\0';
return 0; g_connect_error[0] = '\0';
} else {
snprintf(g_connect_error, sizeof(g_connect_error),
"Failed: %s -> looper:ch0in (ret=%d)", port, ret);
}
return ret;
} }
// --- to <port> --- // --- to <port> ---
if (strcmp(token, "to") == 0) { if (strcmp(token, "to") == 0) {
const char *port = strtok(NULL, " "); const char *port = strtok(NULL, " ");
if (!port) return -1; if (!port) return -1;
int ret = carla_connect_direct("looper:ch0out", port);
if (ret == 0) {
strncpy(to_port, port, sizeof(to_port)-1); strncpy(to_port, port, sizeof(to_port)-1);
to_port[sizeof(to_port)-1] = '\0'; to_port[sizeof(to_port)-1] = '\0';
return 0; g_connect_error[0] = '\0';
} else {
snprintf(g_connect_error, sizeof(g_connect_error),
"Failed: looper:ch0out -> %s (ret=%d)", port, ret);
}
return ret;
} }
// --- addplugin <path> --- // --- addplugin <path> ---

View File

@@ -13,4 +13,6 @@ int handle_client_command(const char *input, int *out_id);
const char* get_stored_from(void); const char* get_stored_from(void);
const char* get_stored_to(void); const char* get_stored_to(void);
extern char g_connect_error[512];
#endif #endif

1
client/src/log.c Normal file
View File

@@ -0,0 +1 @@
#include "../../engine/src/log.c"

View File

@@ -1,8 +1,39 @@
#include "tui.h" #include "tui.h"
#include "script.h"
#include "log.h"
#include "carla_host.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
log_init();
if (carla_init_jack() != 0) {
log_msg("Warning: could not initialise JACK connector client");
}
const char *script_path = NULL;
if (argc > 2 && strcmp(argv[1], "-s") == 0) {
script_path = argv[2];
} else {
const char *home = getenv("HOME");
if (home) {
static char default_path[1024];
snprintf(default_path, sizeof(default_path),
"%s/.config/looper/scripts/launchpad.rc", home);
script_path = default_path;
}
}
if (script_path && script_load(script_path) != 0) {
log_msg("Warning: could not load script '%s'", script_path);
}
int main(void) {
tui_init(); tui_init();
tui_run(); tui_run();
tui_cleanup(); tui_cleanup();
log_close();
return 0; return 0;
} }

178
client/src/script.c Normal file
View File

@@ -0,0 +1,178 @@
#define _GNU_SOURCE
#include "script.h"
#include "tui.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* Forward declarations for functions used from carla_host.c */
int carla_load(const char *binary, const char *plugin_id, int *out_id);
int carla_get_ports(const char *type, char ***ports, int *count);
#include "tui.h"
#include <glob.h>
#define MAX_NOTES 128
static char *note_actions[MAX_NOTES] = {0};
char g_selected_port[256] = {0};
int script_load(const char *path) {
FILE *fp = fopen(path, "r");
if (!fp) return -1;
char line[512];
while (fgets(line, sizeof(line), fp)) {
char *s = line;
while (*s == ' ' || *s == '\t') s++;
if (*s == '#' || *s == '\n') continue;
int note;
char macro[256];
int matched = sscanf(s, "%d %255[^\n]", &note, macro);
if (matched >= 1 && note >= 0 && note < MAX_NOTES) {
free(note_actions[note]);
if (matched == 2) {
// Trim leading and trailing whitespace from macro
char *start = macro;
while (*start == ' ' || *start == '\t') start++;
char *end = start + strlen(start) - 1;
while (end > start && (*end == ' ' || *end == '\t')) end--;
*(end + 1) = '\0';
if (*start == '\0') {
note_actions[note] = NULL;
} else {
note_actions[note] = strdup(start);
}
} else {
note_actions[note] = NULL;
}
}
}
fclose(fp);
return 0;
}
int script_handle_fzf_command(const char *type) {
if (!type) return -1;
if (strcmp(type, "sample") == 0) {
// Get sample directory from env or home
const char *dir = getenv("LOOPER_SAMPLE_DIR");
if (!dir) dir = getenv("HOME");
if (!dir) dir = ".";
char pattern[1024];
snprintf(pattern, sizeof(pattern), "%s/**/*.wav", dir);
glob_t g;
if (glob(pattern, GLOB_TILDE, NULL, &g) != 0) {
// Try without wildcard
snprintf(pattern, sizeof(pattern), "%s/*.wav", dir);
if (glob(pattern, GLOB_TILDE, NULL, &g) != 0)
return -1;
}
const char **items = malloc((g.gl_pathc + 1) * sizeof(char*));
if (!items) { globfree(&g); return -1; }
for (size_t i = 0; i < g.gl_pathc; i++)
items[i] = g.gl_pathv[i];
items[g.gl_pathc] = NULL;
char *selected = tui_fzf_select(items, g.gl_pathc, "Load sample: ");
if (selected) {
int out_id;
carla_load(selected, "", &out_id);
free(selected);
}
free(items);
globfree(&g);
return 0;
}
if (strcmp(type, "plugin") == 0) {
// List .so files in common paths
const char *dirs[] = {
getenv("VST_PATH") ? getenv("VST_PATH") : "/usr/lib/vst",
getenv("HOME"),
NULL
};
char **paths = NULL;
size_t count = 0;
for (int d = 0; dirs[d]; d++) {
char pattern[1024];
snprintf(pattern, sizeof(pattern), "%s/**/*.so", dirs[d]);
glob_t g;
if (glob(pattern, GLOB_TILDE, NULL, &g) == 0) {
for (size_t i = 0; i < g.gl_pathc; i++) {
paths = realloc(paths, (count+1) * sizeof(char*));
paths[count] = strdup(g.gl_pathv[i]);
count++;
}
globfree(&g);
}
}
if (count == 0) return -1;
const char **items = malloc(count * sizeof(char*));
for (size_t i = 0; i < count; i++) items[i] = paths[i];
char *selected = tui_fzf_select(items, count, "Load plugin: ");
if (selected) {
int out_id;
carla_load(selected, "", &out_id);
free(selected);
}
for (size_t i = 0; i < count; i++) free(paths[i]);
free(paths);
free(items);
return 0;
}
if (strcmp(type, "from") == 0 || strcmp(type, "to") == 0) {
char **ports;
int count;
// For "to" we need input ports (where output will go), for "from" we need output ports
if (carla_get_ports("audio", &ports, &count) != 0)
return -1;
char *selected = tui_fzf_select((const char**)ports, count,
(strcmp(type,"to")==0) ? "Select plugin input port: " : "Select looper output port: ");
if (selected) {
strncpy(g_selected_port, selected, sizeof(g_selected_port)-1);
g_selected_port[sizeof(g_selected_port)-1] = '\0';
free(selected);
}
for (int i = 0; i < count; i++) free(ports[i]);
free(ports);
return (selected ? 0 : -1);
}
return -1;
}
void script_cleanup(void) {
for (int i = 0; i < MAX_NOTES; i++) {
free(note_actions[i]);
note_actions[i] = NULL;
}
}
void script_handle_note(int note) {
if (note < 0 || note >= MAX_NOTES) return;
char *macro = note_actions[note];
if (!macro) return;
char macro_copy[512];
strncpy(macro_copy, macro, sizeof(macro_copy) - 1);
macro_copy[sizeof(macro_copy) - 1] = '\0';
char *token = strtok(macro_copy, ";");
while (token) {
while (*token == ' ') token++;
if (*token == '\0') {
token = strtok(NULL, ";");
continue;
}
char *end = token + strlen(token) - 1;
while (end > token && *end == ' ') end--;
*(end + 1) = '\0';
send_command(token);
token = strtok(NULL, ";");
}
}

10
client/src/script.h Normal file
View File

@@ -0,0 +1,10 @@
#ifndef SCRIPT_H
#define SCRIPT_H
int script_load(const char *path);
void script_handle_note(int note);
void script_cleanup(void);
int script_handle_fzf_command(const char *type);
extern char g_selected_port[256];
#endif

View File

@@ -1,48 +1,111 @@
#define _POSIX_C_SOURCE 200809L
#include "tui.h" #include "tui.h"
#include <ncurses.h> #include <ncurses.h>
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
#include <stdbool.h> #include <stdbool.h>
#include <stdio.h>
#include <unistd.h> #include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h> #include <fcntl.h>
#include <ctype.h>
#include <dirent.h> #include <dirent.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <math.h> #include <time.h>
#include "carla_host.h" #include "carla_host.h"
#include "client_cmd.h" #include "client_cmd.h"
#include "plugins.h" #include "plugins.h"
#include "script.h"
#include <CarlaHost.h> #include <CarlaHost.h>
#include "log.h"
extern char g_selected_port[256];
/* Stored connected port names for channel 0 display fallback */
static char g_from_port[256] = "";
static char g_to_port[256] = "";
/* ---------- engine alive indicator ---------- */
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 ---------- */ /* ---------- FIFO command helper ---------- */
int send_command(const char *cmd) { int send_command(const char *cmd) {
if (debug_mode)
fprintf(stderr, "DEBUG: send_command(%s)\n", cmd);
if (cmd_fifo_fd < 0) {
const char *fifo_path = getenv("LOOPER_CMD_FIFO"); const char *fifo_path = getenv("LOOPER_CMD_FIFO");
if (!fifo_path) fifo_path = "/tmp/looper_cmd"; if (!fifo_path) fifo_path = "/tmp/looper_cmd";
int fd = open(fifo_path, O_WRONLY | O_NONBLOCK); cmd_fifo_fd = open(fifo_path, O_WRONLY);
if (fd < 0) return -1; if (cmd_fifo_fd < 0) {
perror("open cmd FIFO");
return -1;
}
}
size_t len = strlen(cmd); 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') if (n == (int)len && cmd[len-1] != '\n')
write(fd, "\n", 1); write(cmd_fifo_fd, "\n", 1);
close(fd);
return (n >= 0) ? 0 : -1; return (n >= 0) ? 0 : -1;
} }
/* ---------- Helper to resolve channel port ---------- */
static bool carla_resolve_channel_port(int channel, bool is_to, char *buf, size_t bufsize) {
char **ports = NULL;
int count = 0;
if (carla_get_ports(NULL, &ports, &count) != 0) {
return false;
}
char pattern[64];
if (is_to) {
snprintf(pattern, sizeof(pattern), "ch%dout", channel);
} else {
snprintf(pattern, sizeof(pattern), "ch%din", channel);
}
bool found = false;
for (int i = 0; i < count && !found; i++) {
if (strstr(ports[i], pattern)) {
strncpy(buf, ports[i], bufsize - 1);
buf[bufsize - 1] = '\0';
found = true;
}
free(ports[i]);
}
free(ports);
return found;
}
/* ---------- Stub functions (no engine) ---------- */ /* ---------- Stub functions (no engine) ---------- */
// Clip states dummy values used as placeholders // Clip states dummy values used as placeholders
typedef enum { CLIP_EMPTY, CLIP_RECORDING, CLIP_LOOPING, CLIP_STOPPED } ClipState; typedef enum { CLIP_EMPTY, CLIP_RECORDING, CLIP_LOOPING, CLIP_STOPPED } ClipState;
static const char *clip_state_string(ClipState s) { (void)s; return "?"; } static const char *clip_state_string(ClipState s) {
switch (s) {
case CLIP_EMPTY: return " ";
case CLIP_RECORDING: return "R";
case CLIP_LOOPING: return "L";
case CLIP_STOPPED: return "P";
default: return "?";
}
}
/* Grid dimensions */ /* Grid dimensions */
#define GRID_ROWS 8 #define GRID_ROWS 8
#define GRID_COLS 8 #define GRID_COLS 8
#define NUM_GRIDS 8 #define NUM_GRIDS 8
#define CELL_WIDTH 6 #define CELL_WIDTH 20
#define CELL_HEIGHT 3 #define CELL_HEIGHT 3
/* status FIFO path */ /* status FIFO path */
#define STATUS_FIFO "/tmp/looper_status" #define STATUS_FIFO "/tmp/looper_status"
#define CMD_FIFO "/tmp/looper_cmd" #define CMD_FIFO "/tmp/looper_cmd"
#define NOTES_FIFO "/tmp/looper_notes"
/* Percell state array (indexed by row*GRID_COLS+col) */ /* Percell state array (indexed by row*GRID_COLS+col) */
typedef enum { STATE_IDLE, STATE_RECORD, STATE_LOOPING, STATE_PAUSED } ChannelState; typedef enum { STATE_IDLE, STATE_RECORD, STATE_LOOPING, STATE_PAUSED } ChannelState;
@@ -78,6 +141,12 @@ typedef struct {
static FuzzySearch fuzzy_search = {0}; static FuzzySearch fuzzy_search = {0};
/* ---------- Parse status line from engine status FIFO ---------- */ /* ---------- Parse status line from engine status FIFO ---------- */
static float vu_level[16] = {0.0f}; /* perchannel RMS level (index = channel number) */
static bool parse_level_line(const char *line, int *ch, float *level) {
return sscanf(line, "CH=%d LEVEL=%f", ch, level) == 2;
}
bool parse_status_line(const char *line, int *ch, int *scene, ChannelState *state) { bool parse_status_line(const char *line, int *ch, int *scene, ChannelState *state) {
int sta; int sta;
if (sscanf(line, "CH=%d SC=%d STATE=%d", ch, scene, &sta) == 3) { if (sscanf(line, "CH=%d SC=%d STATE=%d", ch, scene, &sta) == 3) {
@@ -119,7 +188,13 @@ static void draw_cell(int grid, int row, int col, bool selected) {
for (int dy=0; dy<CELL_HEIGHT; dy++) for (int dy=0; dy<CELL_HEIGHT; dy++)
for (int dx=0; dx<CELL_WIDTH; dx++) for (int dx=0; dx<CELL_WIDTH; dx++)
mvaddch(y+dy, x+dx, ' '); mvaddch(y+dy, x+dx, ' ');
mvprintw(y+1, x+1, "%2d", grid*GRID_ROWS*GRID_COLS + row*GRID_COLS + col); int ch = grid * GRID_ROWS * GRID_COLS + row * GRID_COLS + col;
const char state_char = (s == STATE_RECORD) ? 'R' :
(s == STATE_LOOPING) ? 'L' :
(s == STATE_PAUSED) ? 'P' : '.';
mvprintw(y, x, "ch %2d", ch);
mvaddch(y, x+5, state_char);
attroff(COLOR_PAIR(color)); attroff(COLOR_PAIR(color));
} }
@@ -160,16 +235,67 @@ static void draw_grid(void) {
} }
clear(); clear();
attron(A_BOLD); attron(A_BOLD);
mvprintw(0,0,"JACK Looper - Client (FIFO only)"); mvprintw(0,0,"JACK Looper - Client (FIFO only) %s", engine_running ? "[online]" : "[offline]");
attroff(A_BOLD); attroff(A_BOLD);
for (int r=0; r<GRID_ROWS; r++) for (int r=0; r<GRID_ROWS; r++)
for (int c=0; c<GRID_COLS; c++) for (int c=0; c<GRID_COLS; c++)
draw_cell(selected_grid, r, c, r==selected_row && c==selected_col); draw_cell(selected_grid, r, c, r==selected_row && c==selected_col);
mvprintw(GRID_ROWS*CELL_HEIGHT+3, 0, "Selected: Grid %d, Row %d, Col %d",
/* ---------- Footer: percolumn input / output ---------- */
int footer_y = GRID_ROWS * CELL_HEIGHT + 3;
for (int c=0; c<GRID_COLS; c++) {
int x = c * CELL_WIDTH + 1;
int global_ch = selected_grid * GRID_ROWS * GRID_COLS + c;
char input_buf[80], output_buf[80];
bool has_input = carla_get_connected_port(global_ch, true, input_buf, sizeof(input_buf));
bool has_output = carla_get_connected_port(global_ch, false, output_buf, sizeof(output_buf));
if (global_ch == 0) {
if (!has_input && g_from_port[0]) {
strncpy(input_buf, g_from_port, sizeof(input_buf)-1);
input_buf[sizeof(input_buf)-1] = '\0';
has_input = true;
}
if (!has_output && g_to_port[0]) {
strncpy(output_buf, g_to_port, sizeof(output_buf)-1);
output_buf[sizeof(output_buf)-1] = '\0';
has_output = true;
}
}
char fallback[16];
snprintf(fallback, sizeof(fallback), "ch%d", global_ch);
mvprintw(footer_y, x, "i:%-20.20s", has_input ? input_buf : fallback);
mvprintw(footer_y+1, x, "o:%-20.20s", has_output ? output_buf : fallback);
}
/* VU meter line per channel */
int vu_y = footer_y + 2;
for (int c = 0; c < GRID_COLS; c++) {
int x = c * CELL_WIDTH + 1;
float level = vu_level[c];
int bar_width = CELL_WIDTH - 2;
int filled = (int)(level * bar_width);
if (filled > bar_width) filled = bar_width;
mvprintw(vu_y, x, "%*s", CELL_WIDTH, "");
for (int i = 0; i < filled; i++) {
char ch = (i < bar_width * 0.3f) ? '.' :
(i < bar_width * 0.6f) ? 'x' : '#';
mvaddch(vu_y, x + 1 + i, ch);
}
}
/* Display connection error if any */
if (g_connect_error[0]) {
attron(COLOR_PAIR(COLOR_RECORDING));
mvprintw(vu_y + 1, 0, "ERROR: %-60s", g_connect_error);
attroff(COLOR_PAIR(COLOR_RECORDING));
g_connect_error[0] = '\0';
}
mvprintw(vu_y + 2, 0, "Selected: Grid %d, Row %d, Col %d",
selected_grid, selected_row, selected_col); selected_grid, selected_row, selected_col);
if (show_help) { if (show_help) {
attron(COLOR_PAIR(COLOR_HELP)); attron(COLOR_PAIR(COLOR_HELP));
mvprintw(GRID_ROWS*CELL_HEIGHT+4, 0, "Help: h/j/k/l navigate, t record, d/D stop, s/S scene, a add, A add_midi, r remove, b bind, u unbind, R rack, ? help, Esc/Q quit"); mvprintw(vu_y + 3, 0, "Help: h/j/k/l navigate, t record, d/D stop, s/S scene, a add, A add_midi, r remove, b bind, u unbind, R rack, ? help, Esc/Q quit");
attroff(COLOR_PAIR(COLOR_HELP)); attroff(COLOR_PAIR(COLOR_HELP));
} }
refresh(); refresh();
@@ -177,8 +303,10 @@ static void draw_grid(void) {
/* ---------- TUI init ---------- */ /* ---------- TUI init ---------- */
void tui_init(void) { void tui_init(void) {
log_init();
initscr(); initscr();
cbreak(); noecho(); keypad(stdscr, TRUE); curs_set(0); cbreak(); noecho(); keypad(stdscr, TRUE); curs_set(0);
debug_mode = (getenv("LOOPER_DEBUG") != NULL);
if (!has_colors()) { endwin(); fprintf(stderr,"No colors\n"); exit(1); } if (!has_colors()) { endwin(); fprintf(stderr,"No colors\n"); exit(1); }
start_color(); start_color();
init_pair(COLOR_EMPTY, COLOR_WHITE, COLOR_BLACK); init_pair(COLOR_EMPTY, COLOR_WHITE, COLOR_BLACK);
@@ -193,6 +321,9 @@ void tui_init(void) {
cell_state[i] = STATE_IDLE; cell_state[i] = STATE_IDLE;
/* open the JACK client used for Carla plugins */ /* open the JACK client used for Carla plugins */
carla_init_jack(); carla_init_jack();
/* create note FIFO (for scripted controller input) */
unlink(NOTES_FIFO);
mkfifo(NOTES_FIFO, 0666);
} }
/* ---------- TUI run ---------- */ /* ---------- TUI run ---------- */
@@ -200,42 +331,85 @@ static char colon_buf[256];
static int colon_len = 0; static int colon_len = 0;
static bool in_colon = false; static bool in_colon = false;
void tui_run(void) { /* Read the status FIFO once and update cell_state array */
draw_grid(); static void tui_read_status(void) {
while (1) { if (status_fifo_fd < 0) {
/* read any available status lines */ status_fifo_fd = open(STATUS_FIFO, O_RDONLY | O_NONBLOCK);
int fd = open(STATUS_FIFO, O_RDONLY | O_NONBLOCK); if (status_fifo_fd < 0) return;
if (fd >= 0) { }
char buf[256]; char buf[4096];
int n = read(fd, buf, sizeof(buf)-1); int n = read(status_fifo_fd, buf, sizeof(buf)-1);
if (n > 0) { if (n > 0) {
buf[n] = '\0'; buf[n] = '\0';
char *line = buf; char *line = buf;
while (*line) { while (*line) {
char *nl = strchr(line, '\n'); char *nl = strchr(line, '\n');
if (nl) *nl = '\0'; if (nl) *nl = '\0';
int ch, sc; int ch, sc; ChannelState st; float level_val;
ChannelState st; if (parse_level_line(line, &ch, &level_val)) {
if (parse_status_line(line, &ch, &sc, &st)) { if (ch >= 0 && ch < 16)
if (ch >= 0 && ch < GRID_ROWS * GRID_COLS) vu_level[ch] = level_val;
cell_state[ch] = st; } else if (parse_status_line(line, &ch, &sc, &st)) {
if (ch >= 0 && ch < GRID_COLS && sc >= 0 && sc < GRID_ROWS) {
int idx = sc * GRID_COLS + ch;
cell_state[idx] = st;
} }
}
if (nl) { *nl = '\n'; line = nl + 1; } else break;
}
}
/* keep fd open */
}
void tui_run(void) {
draw_grid();
nodelay(stdscr, TRUE); // nonblocking input getch returns ERR when no key is pressed
while (1) {
/* read status FIFO once per iteration always */
tui_read_status();
/* Check if engine is alive */
engine_running = (access(STATUS_FIFO, F_OK) == 0);
/* read any available note events (for script macros) */
int nfd = open(NOTES_FIFO, O_RDONLY | O_NONBLOCK);
if (nfd >= 0) {
char nbuf[256];
int m = read(nfd, nbuf, sizeof(nbuf)-1);
if (m > 0) {
nbuf[m] = '\0';
char *p = nbuf;
while (*p) {
char *nl = strchr(p, '\n');
if (nl) *nl = '\0';
int note = atoi(p);
script_handle_note(note);
if (nl) { if (nl) {
*nl = '\n'; *nl = '\n';
line = nl + 1; p = nl + 1;
} else break; } else break;
} }
} }
close(fd); close(nfd);
}
/* redraw grid (status may have changed no extra key needed) */
{
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);
} }
if (in_colon) {
int chc = getch(); int chc = getch();
if (in_colon) {
if (chc == '\n') { if (chc == '\n') {
colon_buf[colon_len] = '\0'; colon_buf[colon_len] = '\0';
colon_len = 0; colon_len = 0;
in_colon = false; in_colon = false;
// Check first token before calling handle_client_command
char cmd_copy[256]; char cmd_copy[256];
strncpy(cmd_copy, colon_buf, sizeof(cmd_copy)-1); strncpy(cmd_copy, colon_buf, sizeof(cmd_copy)-1);
cmd_copy[sizeof(cmd_copy)-1] = '\0'; cmd_copy[sizeof(cmd_copy)-1] = '\0';
@@ -246,6 +420,62 @@ void tui_run(void) {
rack_selected = 0; rack_selected = 0;
} else if (strcmp(first, "grid") == 0) { } else if (strcmp(first, "grid") == 0) {
rack_mode = false; rack_mode = false;
} else if ((strcmp(first, "from") == 0 || strcmp(first, "to") == 0)) {
char *potential_arg = strtok(NULL, " ");
const char *port_name = NULL;
if (potential_arg != NULL) {
port_name = potential_arg;
// Do NOT strip client prefix keep full JACK port name
} else {
script_handle_fzf_command(first);
if (g_selected_port[0] != '\0') {
port_name = g_selected_port;
}
}
/* port assignment happens only after successful connection below */
if (port_name) {
/* Resolve the looper port for the currently selected channel */
char looper_port[256] = "";
const bool is_to = (strcmp(first, "to") == 0);
int channel = selected_col; // selected column = channel number
bool found = carla_resolve_channel_port(channel, is_to, looper_port, sizeof(looper_port));
if (!found) {
/* Fallback to generic name with looper: prefix */
if (is_to)
snprintf(looper_port, sizeof(looper_port), "looper:ch%dout", channel);
else
snprintf(looper_port, sizeof(looper_port), "looper:ch%din", channel);
}
int ret;
const char *src, *dst;
if (is_to) {
ret = carla_connect_direct(looper_port, port_name);
src = looper_port;
dst = port_name;
} else {
ret = carla_connect_direct(port_name, looper_port);
src = port_name;
dst = looper_port;
}
if (ret == 0) {
if (is_to) {
strncpy(g_to_port, port_name, sizeof(g_to_port)-1);
g_to_port[sizeof(g_to_port)-1] = '\0';
} else {
strncpy(g_from_port, port_name, sizeof(g_from_port)-1);
g_from_port[sizeof(g_from_port)-1] = '\0';
}
g_connect_error[0] = '\0';
log_msg("Connected %s -> %s", src, dst);
} else {
snprintf(g_connect_error, sizeof(g_connect_error),
"Failed: %s -> %s (ret=%d)", src, dst, ret);
log_msg("Failed to connect %s -> %s (ret=%d)", src, dst, ret);
}
}
if (!potential_arg) g_selected_port[0] = '\0';
draw_grid();
continue;
} }
} }
int dummy_id; int dummy_id;
@@ -266,10 +496,10 @@ void tui_run(void) {
clrtoeol(); clrtoeol();
move(LINES-1, colon_len+1); move(LINES-1, colon_len+1);
refresh(); refresh();
napms(50);
continue; continue;
} }
int chc = getch();
if (chc == ':') { if (chc == ':') {
in_colon = true; in_colon = true;
colon_len = 0; colon_len = 0;
@@ -280,6 +510,7 @@ void tui_run(void) {
refresh(); refresh();
continue; continue;
} }
switch (chc) { switch (chc) {
case 'h': case KEY_LEFT: selected_col = (selected_col-1+GRID_COLS)%GRID_COLS; break; case 'h': case KEY_LEFT: selected_col = (selected_col-1+GRID_COLS)%GRID_COLS; break;
case 'j': case KEY_DOWN: selected_row = (selected_row+1)%GRID_ROWS; break; case 'j': case KEY_DOWN: selected_row = (selected_row+1)%GRID_ROWS; break;
@@ -287,8 +518,20 @@ void tui_run(void) {
case 'l': case KEY_RIGHT: selected_col = (selected_col+1)%GRID_COLS; break; case 'l': case KEY_RIGHT: selected_col = (selected_col+1)%GRID_COLS; break;
case 't': { case 't': {
char cmd[32]; char cmd[32];
log_msg("DIAG t pressed: selected_row=%d selected_col=%d", selected_row, selected_col);
// First bind to the selected channel so engine knows which channel to operate on
snprintf(cmd, sizeof(cmd), "bind %d\n", selected_col);
send_command(cmd);
log_msg("DIAG sent: %s", cmd);
// Then set the scene for that channel
snprintf(cmd, sizeof(cmd), "set_scene %d %d\n", selected_col, selected_row);
send_command(cmd);
log_msg("DIAG sent: %s", cmd);
// Finally trigger record
snprintf(cmd, sizeof(cmd), "record %d\n", selected_col); snprintf(cmd, sizeof(cmd), "record %d\n", selected_col);
send_command(cmd); send_command(cmd);
log_msg("DIAG sent: %s", cmd);
// tui_read_status already called at top of loop
break; break;
} }
case 's': case 's':
@@ -311,6 +554,8 @@ void tui_run(void) {
break; break;
case 'b': { case 'b': {
char cmd[16]; char cmd[16];
snprintf(cmd, sizeof(cmd), "set_scene %d %d\n", selected_col, selected_row);
send_command(cmd);
snprintf(cmd, sizeof(cmd), "bind %d\n", selected_col); snprintf(cmd, sizeof(cmd), "bind %d\n", selected_col);
send_command(cmd); send_command(cmd);
break; break;
@@ -323,12 +568,27 @@ void tui_run(void) {
rack_mode = !rack_mode; rack_mode = !rack_mode;
rack_selected = 0; rack_selected = 0;
break; break;
case KEY_F(5):
script_handle_fzf_command("sample");
draw_grid();
break;
case KEY_F(6):
script_handle_fzf_command("plugin");
draw_grid();
break;
case KEY_F(7):
script_handle_fzf_command("from");
draw_grid();
break;
case 27: case 'Q': case 27: case 'Q':
if (rack_mode) { if (rack_mode) {
rack_mode = false; rack_mode = false;
break; break;
} }
return; return;
case ERR:
/* no key pressed just continue the loop */
break;
default: default:
if (rack_mode) { if (rack_mode) {
switch (chc) { switch (chc) {
@@ -348,7 +608,6 @@ void tui_run(void) {
break; break;
case 'b': case 'B': case 'b': case 'B':
plugin_set_bypass(rack_selected, true); plugin_set_bypass(rack_selected, true);
// toggle would be better, but for now just enable bypass
break; break;
case 'd': case 'D': case 'd': case 'D':
plugin_unload(rack_selected); plugin_unload(rack_selected);
@@ -365,15 +624,89 @@ void tui_run(void) {
} }
break; break;
} }
draw_grid(); napms(50); // avoid busywaste grid redraws frequently enough
} }
} }
char* tui_fzf_select(const char *const items[], size_t count, const char *prompt) {
if (!items || count == 0) return NULL;
// Save ncurses state
def_prog_mode();
endwin();
// Build a temporary file with the items list
char tmpfile[] = "/tmp/tui_fzf_XXXXXX";
int fd = mkstemp(tmpfile);
if (fd == -1) {
reset_prog_mode();
refresh();
return NULL;
}
FILE *tmp = fdopen(fd, "w");
if (!tmp) {
close(fd);
unlink(tmpfile);
reset_prog_mode();
refresh();
return NULL;
}
for (size_t i = 0; i < count; i++) {
if (items[i])
fprintf(tmp, "%s\n", items[i]);
}
fclose(tmp);
// Build fzf command reading from the temporary file
char cmd[8192];
snprintf(cmd, sizeof(cmd),
"fzf --prompt='%s' < %s",
prompt ? prompt : "Select: ",
tmpfile);
FILE *result = popen(cmd, "r");
if (!result) {
unlink(tmpfile);
reset_prog_mode();
refresh();
return NULL;
}
char selected[4096] = {0};
if (fgets(selected, sizeof(selected), result) != NULL) {
size_t len = strlen(selected);
if (len > 0 && selected[len-1] == '\n')
selected[len-1] = '\0';
}
pclose(result);
unlink(tmpfile);
// Restore ncurses
reset_prog_mode();
refresh();
if (selected[0] == '\0')
return NULL;
return strdup(selected);
}
void tui_cleanup(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); if (yank_buffer.clip_indices) free(yank_buffer.clip_indices);
/* free script note allocations */
script_cleanup();
/* delete FIFOs */ /* delete FIFOs */
unlink(STATUS_FIFO); unlink(STATUS_FIFO);
unlink(CMD_FIFO); unlink(CMD_FIFO);
unlink(NOTES_FIFO);
/* close the Carla JACK client */ /* close the Carla JACK client */
carla_cleanup_jack(); carla_cleanup_jack();
curs_set(1); endwin(); curs_set(1); endwin();

View File

@@ -1,9 +1,12 @@
#ifndef TUI_H #ifndef TUI_H
#define TUI_H #define TUI_H
#include <stddef.h>
void tui_init(void); void tui_init(void);
void tui_run(void); void tui_run(void);
void tui_cleanup(void); void tui_cleanup(void);
int send_command(const char *cmd); int send_command(const char *cmd);
char* tui_fzf_select(const char *const items[], size_t count, const char *prompt);
#endif #endif

188
client/tests/test_script.c Normal file
View File

@@ -0,0 +1,188 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* mock send_command records last command */
static char last_cmd[4096] = "";
int send_command(const char *cmd) {
strncpy(last_cmd, cmd, sizeof(last_cmd)-1);
last_cmd[sizeof(last_cmd)-1] = '\0';
return 0;
}
#include "../src/script.h"
static int tests_passed = 0;
static int tests_failed = 0;
static void test_load_valid(void) {
const char *path = "/tmp/test_script_1.rc";
FILE *f = fopen(path, "w");
if (!f) { tests_failed++; return; }
fprintf(f, "# comment\n11 record 0\n12 stop\n\n13 add\n");
fclose(f);
int r = script_load(path);
if (r != 0) {
printf("FAIL: script_load returned %d\n", r);
tests_failed++;
unlink(path);
return;
}
unlink(path);
tests_passed++;
printf("PASS\n");
}
static void test_single_command(void) {
const char *path = "/tmp/test_script_2.rc";
FILE *f = fopen(path, "w");
if (!f) { tests_failed++; return; }
fprintf(f, "11 record 0\n");
fclose(f);
script_load(path);
last_cmd[0] = '\0';
script_handle_note(11);
if (strcmp(last_cmd, "record 0") != 0) {
printf("FAIL: expected 'record 0' got '%s'\n", last_cmd);
tests_failed++;
unlink(path);
return;
}
unlink(path);
tests_passed++;
printf("PASS\n");
}
static void test_multiple_commands(void) {
const char *path = "/tmp/test_script_3.rc";
FILE *f = fopen(path, "w");
if (!f) { tests_failed++; return; }
fprintf(f, "21 record 0 ; stop\n");
fclose(f);
script_load(path);
last_cmd[0] = '\0';
script_handle_note(21);
if (strcmp(last_cmd, "stop") != 0) {
printf("FAIL: expected 'stop' got '%s'\n", last_cmd);
tests_failed++;
unlink(path);
return;
}
unlink(path);
tests_passed++;
printf("PASS\n");
}
static void test_unmapped_note(void) {
const char *path = "/tmp/test_script_4.rc";
FILE *f = fopen(path, "w");
if (!f) { tests_failed++; return; }
fprintf(f, "30 add\n");
fclose(f);
script_load(path);
last_cmd[0] = '\0';
script_handle_note(31);
if (last_cmd[0] != '\0') {
printf("FAIL: expected empty last_cmd\n");
tests_failed++;
unlink(path);
return;
}
unlink(path);
tests_passed++;
printf("PASS\n");
}
static void test_ignores_comments_and_blanks(void) {
const char *path = "/tmp/test_script_5.rc";
FILE *f = fopen(path, "w");
if (!f) { tests_failed++; return; }
fprintf(f, "\n# this is a comment\n \n40 bind 2\n\n");
fclose(f);
int r = script_load(path);
if (r != 0) {
printf("FAIL: script_load returned %d\n", r);
tests_failed++;
unlink(path);
return;
}
last_cmd[0] = '\0';
script_handle_note(40);
if (strcmp(last_cmd, "bind 2") != 0) {
printf("FAIL: expected 'bind 2' got '%s'\n", last_cmd);
tests_failed++;
unlink(path);
return;
}
unlink(path);
tests_passed++;
printf("PASS\n");
}
static void test_note_out_of_range(void) {
const char *path = "/tmp/test_script_6.rc";
FILE *f = fopen(path, "w");
if (!f) { tests_failed++; return; }
fprintf(f, "11 load\n");
fclose(f);
script_load(path);
last_cmd[0] = '\0';
script_handle_note(200);
if (last_cmd[0] != '\0') {
printf("FAIL: expected empty last_cmd\n");
tests_failed++;
unlink(path);
return;
}
unlink(path);
tests_passed++;
printf("PASS\n");
}
static void test_empty_macro(void) {
const char *path = "/tmp/test_script_7.rc";
FILE *f = fopen(path, "w");
if (!f) { tests_failed++; return; }
fprintf(f, "11 \n12 record 0\n");
fclose(f);
int r = script_load(path);
if (r != 0) {
printf("FAIL: script_load returned %d\n", r);
tests_failed++;
unlink(path);
return;
}
last_cmd[0] = '\0';
script_handle_note(11);
if (last_cmd[0] != '\0') {
printf("FAIL: empty macro produced command\n");
tests_failed++;
unlink(path);
return;
}
script_handle_note(12);
if (strcmp(last_cmd, "record 0") != 0) {
printf("FAIL: expected 'record 0' got '%s'\n", last_cmd);
tests_failed++;
unlink(path);
return;
}
unlink(path);
tests_passed++;
printf("PASS\n");
}
int main(void) {
printf("Script module tests:\n");
test_load_valid();
test_single_command();
test_multiple_commands();
test_unmapped_note();
test_ignores_comments_and_blanks();
test_note_out_of_range();
test_empty_macro();
printf("\nResults: %d passed, %d failed\n", tests_passed, tests_failed);
return tests_failed > 0 ? 1 : 0;
}

View File

@@ -0,0 +1,5 @@
#include "tui.h"
char *tui_fzf_select(const char *const items[], size_t count, const char *prompt){(void)items;(void)count;(void)prompt;return NULL;}
void tui_cleanup(void){}

View File

@@ -0,0 +1,45 @@
# Sampling and Recording (WAV Load/Save)
The looper supports loading a WAV file into channel 0 and saving the current loop of channel 0 as a WAV file. Both operations use the **libsndfile** library, ensuring correct handling of RIFF headers, chunk sizes, and sample format conversion.
## Load Command
- **MIDI note 70** with the control key (note 64) triggers loading.
- The file `loop.wav` (located in the working directory) is read by `wav_read()` in `src/wav.c`.
- The function calls `sf_open(path, SFM_READ, &info)`.
- It accepts only mono PCM WAV files. If the file is not mono or has an invalid sample rate, it returns `-1`.
- The number of frames read is capped at `LOOP_BUF_SIZE` (5 seconds at 48 kHz).
- The data is stored in `channels[0].loop_buffer` and `channels[0].loop_count` is set atomically.
- The state of channel 0 is set to `STATE_LOOPING` and `prev_state` is set to `-1` to trigger the loop start in the next audio cycle.
## Save Command
- **MIDI note 71** with the control key (note 64) triggers saving.
- The looper must currently be in `STATE_LOOPING` and have a nonzero `loop_count`.
- A ring buffer (`RingBuf`) is allocated with capacity `2 × loop_count` samples.
- The pointer to the ring buffer is published via `atomic_store_explicit` on `channels[0].save_ring`.
- In each audio callback cycle, if the channel is looping and a save ring exists, the audio output data is written into the ring buffer.
- A dedicated **writer thread** (`writer_thread`) is launched (detached) to consume the ring buffer.
- The writer thread reads `loop_count` samples from the ring buffer, sleeping 10ms between empty reads.
- Once all samples are collected, it writes them to `save.wav` using `sf_writef_float()`.
- After writing, the ring buffer is destroyed and freed, and the save ring pointer is set to `NULL`.
## Dependencies
- **libsndfile** must be installed (development headers). Add `-lsndfile` to your linker flags (already present in the provided `makefile`).
## Implementation Files
- `src/wav.c` contains `wav_read()` and `wav_write()` based on libsndfile.
- `src/looper.c` contains the load/save command handling in `looper_process_commands()` and the writer thread function.
- `src/channel.h` defines `save_ring` as `_Atomic RingBuf *`.
## Testing
- The integration test `test_wav_load` creates a short 440Hz WAV file, loads it via MIDI, and checks for ≥3 bursts of audio output.
- The integration test `test_wav_save` records a beep, loops it, issues the save command, and verifies the resulting WAV file has nonzero data size.
## Notes
- The save operation is asynchronous: the writer thread runs in the background while the audio callback continues to fill the ring buffer. The test waits 2s for the file to be written before checking.
- The load operation is synchronous: the callback sleeps 1s after the MIDI command to give the main loop time to process it.

61
docs/evaluation.md Normal file
View File

@@ -0,0 +1,61 @@
# Evaluation of Looper Codebase
## Summary Table
| Category | Rating | Notes |
|-------------------------|--------|-------|
| **Mocked / leftdoing** | ⚠️ Moderate | No mock objects; real Carla dependency. Tests for Carla host are stubs. Integration test requires running JACK server. Script module test now passes 7/7 (empty macro bug fixed). |
| **Potential segfaults** | ✅ Low | `exec_command` validates channel bounds (`ch < MAX_CHANNELS`). `note_actions` is checked for NULL before use. `strdup` returns not checked but safe in practice. No outofbounds access identified. |
| **Memory management** | ✅ Good | `note_actions` strings freed via `script_cleanup()` called in `tui_cleanup()`. `strdup` allocations are freed on reload. All dynamically allocated audio buffers are freed. No leaks at exit. |
| **Thread safety** | ✅ Good | All shared scene/channel fields use C11 atomics. Logging mutex never held in audio thread. Save deactivation uses atomic active flag with two wait periods (500ms + 200ms) guaranteeing RT thread sees the change. FIFO writes are atomic (`PIPE_BUF`). No race conditions identified. |
| **Performance** | ✅ Fair | Audio path: `memcpy`, linear loops, no allocations realtime safe. Main loop sleeps 50ms fine. Save pauses audio for 500ms (acceptable for a tool). Logging adds negligible mutex overhead outside RT thread. JACK callback time is deterministic. |
| **Architectural soundness** | ⚠️ Medium | Separation of engine and client via FIFO is clean. Orchestrator simple but effective. Three command queues still add unnecessary complexity (single queue would suffice). `prev_state` transition detection works. Carla integration remains tightly coupled to JACK client. Script/macro mechanism is extensible and well isolated. Logging design correctly avoids audio thread. |
## Detailed Commentary
### 1. Mocked / Leftdoing
- **Engine tests** require a live JACK server no mocking.
- **Client tests** for `carla_host` and `plugins` use stubs when `TESTING` is defined; real Carla library is still linked. Integration test also requires JACK.
- **Script tests** pass 7/7 after the empty macro bug was fixed.
- **No mock for MIDI** engine integration test uses actual MIDI events.
### 2. Potential Segfaults
- **Channel bounds** are now validated in `exec_command`: `if (ch < 0 || ch >= MAX_CHANNELS) ch = 0;`. Safe.
- **NULL pointer dereference**: `script_handle_note` checks for NULL before using `macro`. `strdup` failure would set `note_actions[note]` to NULL, then checked. Safe.
- **FIFO write errors** silently ignored no crash.
### 3. Memory Management
- `note_actions[]` strings are freed on every `script_load` write and on cleanup (`script_cleanup` called in `tui_cleanup`). No leak.
- Audio loop buffer (`loop_data_t`) is embedded in `scene_t` no heap allocation.
- Save path uses `malloc/free` correctly; freed after write.
- Log file pointer is closed at exit.
### 4. Thread Safety
- All `scene_t` fields accessed from both threads are atomic (`state`, `prev_state`, `record_pos`, `loop_count`, `playback_pos`). Correct.
- `channel_t.active`, `channel_t.save_ring` are atomic. Correct.
- **Save sequence**: set `active=0`, sleep 500ms, copy buffer, set `active=1`. The sleep guarantees the RT thread has seen the deactivation. No race.
- **Logging mutex** acquired only outside audio thread fine.
- **FIFO writes** from multiple threads are not serialized but `write` to a FIFO is atomic for writes ≤ `PIPE_BUF` (4096 bytes). Our messages are smaller. Safe.
### 5. Performance
- Audio buffer processing uses simple loops with no function calls. `nframes` is typically 64256 fine.
- `state` transitions check `prev_state` each callback cheap.
- Save mechanism: 500ms pause may cause one xrun. Acceptable for a prototype.
- Main loop sleep 50ms ensures low CPU usage.
### 6. Architectural Soundness
- **Good**: Clear separation between engine (realtime audio) and client (UI, plugin management). Communication via FIFO files.
- **Weakness**: Three command queues (`cmd_queue`, `cmd_queue_main_midi`, `cmd_queue_main_fifo`) are redundant all feed `exec_command`. Could consolidate.
- **Weakness**: Carla integration tied to the engines JACK client. A separate Carla engine instance would be cleaner.
- **Strength**: Script/macro system is simple textbased and extensible. The notes FIFO allows any external controller to inject note numbers.
- **Strength**: Logging nonintrusive and never used in realtime path.
---
*Overall, the codebase is functional and stable.* All previously identified critical issues (channel bounds, memory leak, empty macro) have been fixed. Recommendations for further improvement:
- Replace three command queues with a single queue.
- Use a doublebuffer for save to eliminate the 500ms pause.
- Consider mock objects for engine tests to remove JACK dependency.
- Add more unit tests for edge cases.
**Evaluation date**: 18 May 2026

413
docs/manual.md Normal file
View File

@@ -0,0 +1,413 @@
# Looper JACKbased audio looper
## Overview
`looper` is a realtime audio and MIDI looper that runs as a JACK client. It supports multiple channels (each with multiple scenes), recording, looping, and saving/loading loops as WAV files. It can be controlled via MIDI notes or via a named FIFO (`/tmp/looper_cmd`).
## Building
### Prerequisites
- JACK development libraries (`libjack-dev` or `libjack-jackd2-dev`)
- libsndfile development libraries (`libsndfile1-dev`)
- POSIX threads, C11 atomics
- `make`
### Compilation
```sh
cd engine
make
```
This produces the `looper` binary and a set of test executables.
## Running
Start the JACK server if it is not already running:
```sh
jackd -d alsa -r 48000 -p 256 # example parameters
```
Then launch the looper:
```sh
./looper
```
The looper will register the following JACK ports:
- `looper:input` (audio in)
- `looper:output` (audio out)
- `looper:control` (MIDI input control messages)
- `looper:clock` (MIDI input transport clock)
- `looper:channel1_input`, `looper:channel1_output` (first dynamic channel)
Additional ports are created for every extra channel (e.g. `looper:channel2_input`, …).
## Architecture
- **Channels**: Each channel is independent and contains up to `MAX_SCENES` scenes. Channel 0 always exists; additional channels can be added/removed at runtime.
- **Scenes**: Each scene can be in one of four states: `IDLE`, `RECORD`, `LOOPING`, `PAUSED`. Only the current scene of a channel is active.
- **MIDI Control**: Many operations are triggered by MIDI notes received on the `looper:control` port. A special *control key* (note 64) acts as a modifier: while held, subsequent notes select a different function.
- **FIFO Commands**: A set of humanreadable commands can be written to `/tmp/looper_cmd` to control the looper from scripts or terminals.
- **Status FIFO**: The looper writes its current state to `/tmp/looper_status` (one line per active channel).
## Control
### MIDI Control (port `looper:control`)
All MIDI notes must be on channel 0 (status byte `0x90`).
| Note | Without modifier | With modifier (hold note 64) |
|------|------------------|-----------------------------|
| 0 | (reserved) | **Bind channel** set the channel that will be affected by subsequent commands (note value = channel index). |
| 1 | **Cycle** toggles the current scene of the *bound* channel (or channel 0 if unbound) through IDLE→RECORD→LOOPING→PAUSED→LOOPING→… | |
| 62 | | **Cycle** same as note 1 but always acts on the bound channel. |
| 63 | | **Unbind** resets the bound channel to channel 0. |
| 64 | | *Control key* held while other notes are pressed to apply the modifier column. |
| 70 | | **Load WAV** loads `loop.wav` from the current directory into channel 0's current scene. |
| 71 | | **Save WAV** saves the current loop (if the scene is in LOOPING state) to `save.wav`. |
*Notes for developers*: The MIDI handler is implemented in `engine/src/midi.c`. The controlkey state is stored in `atomic_int control_key_active`.
### FIFO Commands (file `/tmp/looper_cmd`)
Write a line to the FIFO; each line activates one command.
| Command line | Description |
|-----------------------|-------------|
| `record <ch>` | Cycle the current scene of channel `<ch>` (same as MIDI note 1). |
| `stop` | Force all scenes of the bound channel to IDLE. |
| `add` | Add a new audio channel. |
| `add_midi` | Add a new MIDI channel. |
| `remove` | Remove the last added dynamic channel. |
| `bind <ch>` | Bind subsequent commands to channel `<ch>`. |
| `unbind` | Unbind (revert to channel 0). |
| `scene_add` | Add a new scene to the bound channel. |
| `scene_remove` | Remove the current scene (not the last one) from the bound channel. |
| `scene_next` | Switch to the next scene of the bound channel. |
| `scene_prev` | Switch to the previous scene of the bound channel. |
| `load` | Load `loop.wav` into channel 0's current scene. |
| `save` | Save the current loop (if in LOOPING state) to `save.wav`. |
#### Example session (shell)
```sh
# record something on channel 0
echo "record 0" > /tmp/looper_cmd
# after some seconds, stop recording (cycle again)
echo "record 0" > /tmp/looper_cmd
# now the loop plays back; save it
echo "save" > /tmp/looper_cmd
# add a new channel
echo "add" > /tmp/looper_cmd
# bind to that channel (assuming it is channel 1)
echo "bind 1" > /tmp/looper_cmd
# record a loop on channel 1
echo "record 1" > /tmp/looper_cmd
```
### MIDI Clock (port `looper:clock`)
The looper responds to a subset of MIDI Real Time messages:
| Message | Action |
|---------|--------|
| `0xFA` (Start) | If channel 0's current scene is IDLE, switches it to RECORD. |
| `0xFC` (Stop) | Sets channel 0's current scene to IDLE. |
| `0xFB` (Continue) | If channel 0's current scene is PAUSED, switches it to LOOPING. |
These allow synchronisation with an external sequencer.
## Detailed Behaviour
### Audio Channels
- **IDLE**: Input is passed through to output (live monitoring).
- **RECORD**: Input is written to the loop buffer (up to `LOOP_BUF_SIZE` frames). The buffer is written circularly; when the buffer is full, recording overwrites from the beginning.
- **LOOPING**: The recorded loop is played back repeatedly. The loop length equals the number of frames written during the last RECORD session (stored in `loop_count`).
- **PAUSED**: Output is silent; the loop is not playing.
Transitioning from RECORD→LOOPING immediately finalises the loop length and begins playback from the start of the buffer.
### MIDI Channels
MIDI channels work similarly but record MIDI events instead of audio. Received MIDI events are stored in a fixed-size array (`MAX_MIDI_EVENTS`). During LOOPING, all recorded events are played back at the beginning of each cycle (timestamps are not used in playback) this is suitable for drum patterns and short phrases.
### Dynamic Channels
You can add and remove channels at runtime using FIFO commands or MIDI notes. Each new channel gets its own audio input/output ports and, for MIDI channels, separate MIDI ports. Removing a channel deactivates it and eventually unregisters its ports. The removal happens with a onesecond grace delay to avoid disrupting the audio thread.
### Scenes
Each channel can have several scenes (default one). You can add/remove scenes and switch between them. Only the current scene is active; switching scenes preserves their individual state and loop data. This allows you to prepare multiple loops on the same channel and swap between them.
### Loading WAV files
The `load` command reads a file named `loop.wav` (mono, 16bit PCM, any sample rate) from the working directory. It loads the audio into channel 0's current scene, sets the scene state to LOOPING, and begins playback. The loop length is the number of frames read (up to `LOOP_BUF_SIZE`).
### Saving WAV files
The `save` command writes the current loop of channel 0's current scene to `save.wav` (mono, 16bit PCM, same sample rate as the JACK server). Saving is synchronous the audio thread is briefly deactivated to safely copy the buffer. The file is created in the working directory.
### Status FIFO
The looper periodically writes its state to `/tmp/looper_status`. Each line has the format:
```
CH=<channel> SC=<scene_index> STATE=<IDLE|RECORD|LOOPING|PAUSED>
```
This can be used by external monitoring tools or scripts to track the looper's state.
## Testing
### Engine Tests
The `engine` directory contains several test executables that are built by `make test`. They require a running JACK server.
```sh
cd engine
make test
```
Tests include:
- Audio passthrough (connectivity)
- Loop recording and playback (counts bursts of audio)
- Dynamic channel creation and removal
- Controlkey modifier and channel binding
- WAV file loading and saving
Test results are reported to stdout. If any test fails, the exit code is nonzero.
### Client Tests
The `client` directory also contains unit and integration tests. They can be run with:
```sh
cd client
make test
```
These tests verify the client command parser, plugin stubs, Carla host interface, and status FIFO parsing. They do **not** require a running JACK server for the mockbased tests.
---
# Client (TUI Application)
The looper project includes a terminalbased user interface client that runs alongside the engine. It provides a grid view of channels/scenes, a rack view for managing LV2/VST plugins via Carla, and a command line (`:`mode) for advanced operations.
## Building the Client
```sh
cd client
make
```
This produces the `looper-client` binary and a test executable (`test_status_parse`).
## Running the Client
Start the looper engine first (see [Running the Engine](#running)), then launch the client:
```sh
./looper-client
```
The client will connect to the engine via the FIFO `/tmp/looper_cmd` and read status from `/tmp/looper_status`. It opens a ncurses interface.
## TUI Keybindings
| Key | Action |
|-----|--------|
| `h` / Left | Move selection left |
| `j` / Down | Move selection down |
| `k` / Up | Move selection up |
| `l` / Right | Move selection right |
| `t` | Toggle recording on the selected channel (sends `record N`) |
| `s` | Switch to next scene |
| `S` | Switch to previous scene |
| `d` / `D` | Stop all scenes on the bound channel |
| `a` | Add a new audio channel |
| `A` | Add a new MIDI channel |
| `r` | Remove the last dynamic channel |
| `b` | Bind to the selected channel (sends `bind N`) |
| `u` | Unbind (revert to channel 0) |
| `?` | Toggle help overlay |
| `R` | Toggle rack view (for plugin management) |
| `Esc` / `Q` | Quit (in grid mode) or return to grid (in rack mode) |
| `:` | Enter coloncommand mode (see below) |
### Rack View (plugin management)
When you press `R`, the TUI switches to a rack view showing all plugins loaded via Carla. In this mode:
| Key | Action |
|-----|--------|
| `j` / Down | Select next plugin |
| `k` / Up | Select previous plugin |
| `b` / `B` | Bypass the selected plugin |
| `d` / `D` | Unload the selected plugin |
| `x` / `X` | Disconnect all JACK connections for the selected plugin |
| `Esc` | Return to grid view |
## Colon Commands
Press `:` to enter commandline mode. Type a command and press `Enter`. The following commands are recognised:
| Command | Description |
|---------|-------------|
| `from <port>` | Store a source port name (e.g. `looper:out_0`) |
| `to <port>` | Store a destination port name (e.g. `plugin:in`) |
| `addplugin <path>` | Load a plugin from the given binary path. If `from` and `to` are set, it also autoconnects the plugin. |
| `connect [from] [to]` | Connect two JACK ports. If omitted, uses stored `from`/`to`. |
| `disconnect [from] [to]` | Disconnect two JACK ports. |
| `rack` | Switch to rack view (same as `R`) |
| `grid` | Switch to grid view |
### Example colon session
```
:from looper:channel1_output
:to system:playback_2
:addplugin /usr/lib/lv2/amsynth.lv2/amsynth.so
:connect looper:channel1_output amsynth:in
```
## Status Display
The TUI reads the engines status FIFO (`/tmp/looper_status`) and displays the state of each channel as coloured cells in a 8×8 grid:
- **White** (IDLE) channel is monitoring
- **Red** (RECORD) channel is recording
- **Green** (LOOPING) loop is playing back
- **Blue** (PAUSED) loop is paused
- **Cyan** currently selected cell
The status is updated continuously in real time.
## Plugin Management Internals
The client uses the **Carla** host library to load and manage plugins. You must have Carla development libraries installed (`libcarla-standalone-dev` or equivalent). Plugins are loaded in a separate Carla engine instance, and their JACK ports are connected to the loopers ports using the loopers own JACK client.
The `carla_host.c` module wraps Carlas API and provides `carla_load`, `carla_unload`, `carla_connect`, etc. The `plugins.c` module provides a simpler interface used by the command parser.
## Communication with the Engine
All actions in the TUI are translated into FIFO commands written to `/tmp/looper_cmd`. The engines pipe reader thread picks them up and executes them. The status FIFO `/tmp/looper_status` is polled by the TUIs main loop to update the display.
---
## Configuration
The following constants can be adjusted at compile time by editing `engine/src/channel.h` and `engine/src/looper.c`:
| Constant | Default | Description |
|----------|---------|-------------|
| `MAX_CHANNELS` | 8 | Maximum number of dynamic channels. |
| `MAX_SCENES` | 4 | Maximum scenes per channel. |
| `LOOP_BUF_SIZE` | 48000 * 8 | Maximum loop length in frames (8 seconds at 48 kHz). |
| `MAX_MIDI_EVENTS` | 1024 | Maximum MIDI events that can be recorded per scene. |
## Troubleshooting
- **No sound**: Ensure that the JACK server is running and that you have connected `looper:output` to your system playback ports.
- **MIDI not working**: Use `jack_connect` to connect your controller's output to `looper:control`. The note must be on MIDI channel 0.
- **save.wav not created**: The scene must be in `LOOPING` state before issuing the save command. Also verify that a loop has been recorded (loop length > 0).
- **FIFO not working**: The FIFO is created automatically. You can write to it with a simple `echo "record 0" > /tmp/looper_cmd`. If you get "no such file or directory", start the looper first.
## Source Code Organisation
| File | Purpose |
|------|---------|
| `engine/src/main.c` | Entry point; opens JACK client, calls `looper_init()` and enters the main loop. |
| `engine/src/looper.c` | Core logic: `process_callback`, `looper_process_commands`, command execution, WAV load/save. |
| `engine/src/channel.c` | Channel and scene management (`channel_add`, `channel_remove`, `init_scene`, etc.). |
| `engine/src/midi.c` | MIDI event parsing and handling for the control port. |
| `engine/src/queue.c` | Lockfree SPSC queue used for passing commands between threads. |
| `engine/src/ringbuffer.c` | Ring buffer used for saving loop audio to disk asynchronously (deprecated, now synchronous). |
| `engine/src/pipe.c` | FIFO reader thread that translates text lines into `command_t` structs. |
| `engine/src/wav.c` | WAV file reading and writing (libsndfile wrapper). |
| `engine/src/command.h` | `command_t` type definition and `cmd_type_t` enum. |
| `engine/src/channel.h` | Data structures for `channel_t`, `scene_t`, `loop_data_t`. |
| `engine/src/looper.h` | Function prototypes for callbacks and initialisation. |
| `engine/tests/` | Integration tests for the above features. |
## License
[Add your license information here.]
---
## Orchestrator and Logging
The orchestrator (`orchestrator.c`, built to `./looper`) launches both the engine and the TUI client in a single process group. It handles graceful shutdown of both children when you press Ctrl+C.
### Building
```sh
make # builds engine, client, and orchestrator
```
The target `orchestrator` is built by the toplevel `make` automatically.
### Running
```sh
./looper # starts engine and client
./looper -s ~/my.rc # loads a custom script file for the launchpad
```
To stop, press `Ctrl+C`. The orchestrator sends `SIGTERM` to both children and waits for them to exit.
### Logging
Both the engine and the client write messages to `/tmp/looper.log` (appended). The file is opened at startup and closed at shutdown. The audio thread (`process_callback`) never calls any logging function, so realtime performance is unaffected.
To watch the log in real time:
```sh
tail -f /tmp/looper.log
```
### Launchpad Scripting (via notes FIFO)
The client reads note events from `/tmp/looper_notes` (created automatically). You can feed note numbers into this FIFO using any external tool (JACK MIDItoFIFO bridge, shell script, etc.). Each line must contain a single integer note number (0127). The client then calls `script_handle_note`, which executes the macro associated with that note in the currently loaded script file.
The default script path is `~/.config/looper/scripts/launchpad.rc`. A sample script:
```sh
mkdir -p ~/.config/looper/scripts
cat > ~/.config/looper/scripts/launchpad.rc << 'EOF'
# Grid notes 1188
11 record 0
12 record 1
13 record 2
...
# Right column (scene triggers)
19 scene_next
29 scene_next
39 scene_next
...
# Top row control keys 9198
91 stop
92 scene_prev
93 load
94 save
95 add
96 remove
97 add_midi
98 unbind
EOF
```
You can override the script path with the `-s` flag:
```sh
./looper -s /home/user/my_custom.rc
```
*Manual generated from the looper source code v1.0.*

View File

@@ -0,0 +1,301 @@
# Manual Test Protocols Guitar / Audio Looper
This document provides stepbystep manual testing procedures using a real guitar (or any linelevel mono audio source) with the `looper` engine.
## Prerequisites
- A running JACK server (e.g. `jackd -d alsa -r 48000 -p 256`)
- The looper binary compiled (`cd engine && make`)
- An audio interface recognised by ALSA (or PulseAudio JACK bridge)
- A guitar connected to your interfaces input
- `qjackctl` (optional, for visual wiring) or knowledge of `jack_connect` commands
## Test 1 Basic Audio PassThrough (Guitar Monitor)
1. Start the looper in a terminal:
```sh
./looper
```
2. Launch `qjackctl` (or use `jack_connect` from shell) to view available ports.
3. Connect your interfaces capture port to the loopers input:
```sh
jack_connect system:capture_1 looper:input
```
4. Connect the loopers output to your interface playback ports:
```sh
jack_connect looper:output system:playback_1
```
5. Pluck a few strings you should hear your guitar coming through the looper immediately (channel 0 is in **IDLE** state, which passes input straight to output).
6. To stop, press `Ctrl+C` in the looper terminal.
**Expected result**: You hear your guitar with no latency issues (depending on JACK buffer size). If you hear nothing, check port names with `jack_lsp`.
---
## Test 2 Record a Short Loop (MIDI Control)
### 2a Using MIDI keyboard (or a MIDI controller)
1. Start the looper as above.
2. Connect your MIDI controller to the loopers control port:
```sh
jack_connect <controller>:midi_out looper:control
```
Replace `<controller>` with the actual MIDI port name (use `jack_lsp` to find it).
3. Send **note 1** (velocity 127) to switch channel 0 into **RECORD** state.
- On most keyboards, this is the C# key two octaves above middle C (MIDI note 1). Press it once.
4. Play your guitar for about 2 seconds. The looper is recording.
5. Press **note 1** again. The looper transitions to **LOOPING** state. The recorded 2second phrase starts playing back repeatedly.
6. You should hear the loop repeating. Pluck strings while the loop plays the IDLE monitoring is still active on channel 0 (the loop is mixed with the live input).
7. Press **note 1** a third time to **PAUSE** the loop; press again to resume.
### 2b Using FIFO commands (if you have no MIDI keyboard)
1. Start the looper.
2. Open a second terminal.
3. Send:
```sh
echo "record 0" > /tmp/looper_cmd
```
4. Play guitar for a few seconds.
5. Send again:
```sh
echo "record 0" > /tmp/looper_cmd
```
6. Loop should start repeating. Test pause by sending again (second command cycles IDLE→RECORD→LOOPING→PAUSED→…).
**Expected result**: The loop repeats seamlessly. If you hold a chord while the loop is playing, the live input still passes through.
---
## Test 3 Save the Loop to a WAV File
1. Ensure a loop is playing (LOOPING state) on channel 0.
2. Send the save command:
```sh
echo "save" > /tmp/looper_cmd
```
or (MIDI) press controlkey (note 64) + note 71.
3. After a brief delay (the loop buffer is written synchronously), a file `save.wav` appears in the engine directory.
4. Check the file size is > 44 bytes and play it with any media player:
```sh
aplay save.wav
```
**Expected result**: The saved file contains exactly what the looper was playing (your recorded guitar phrase). The RMS of the playback should be similar to the live signal.
---
## Test 4 Load a WAV File into a Channel
1. Put a mono 16bit WAV file named `loop.wav` in the engine directory (e.g. a short drum loop or a guitar riff).
2. Start the looper and send the load command:
```sh
echo "load" > /tmp/looper_cmd
```
or (MIDI) controlkey + note 70.
3. The loaded audio begins playing immediately on channel 0 (state = LOOPING).
4. Verify you hear the loop repeating.
**Expected result**: The WAV is loaded and plays correctly. The loop length matches the duration of the file (up to `LOOP_BUF_SIZE` frames, default 8 seconds).
---
## Test 5 Dynamic Channel Creation and Binding
1. Start the looper.
2. Add a second audio channel:
```sh
echo "add" > /tmp/looper_cmd
```
3. Check that new ports appear:
```sh
jack_lsp | grep channel1
```
4. Bind the client to channel 1:
```sh
echo "bind 1" > /tmp/looper_cmd
```
5. Connect your guitar to both channels for stereo testing? Not necessary. But you can route differently.
6. Now when you send `record 1`, the bind ensures the command affects channel 1 instead of channel 0.
7. Repeat the record/loop process on channel 1, while channel 0 continues its own loop.
**Expected result**: Two independent loops can play simultaneously without interfering.
---
## Test 6 Scene Switching
1. Make sure a loop is playing on channel 0.
2. Add a second scene to channel 0:
```sh
echo "scene_add" > /tmp/looper_cmd
```
(Only adds scene if `MAX_SCENES` not exceeded, default 4.)
3. Switch to the new scene:
```sh
echo "scene_next" > /tmp/looper_cmd
```
The playback stops because the new scene is IDLE.
4. Record a different phrase on the new scene (send `record 0`).
5. Switch back to the first scene (`scene_prev`) the original loop resumes.
**Expected result**: Different independent loops in separate scenes; switching scenes does not lose previously recorded loops.
---
## Test 7 MIDI Clock Sync
If you have an external MIDI clock source (e.g. a drum machine or DAW sending MIDI start/stop):
1. Connect the clock source to `looper:clock` port.
2. Send MIDI Start (`0xFA`). The loopers current scene (if IDLE) transitions to RECORD.
3. Send MIDI Stop (`0xFC`). The current scene goes IDLE (loop stops).
4. Send MIDI Continue (`0xFB`) while the scene is PAUSED it resumes LOOPING.
**Expected result**: Transport commands control looper state reliably.
---
## Test 8 Edge Cases
### 8a Rapid toggling
Cycle the RECORD/LOOPING/PAUSED states many times in quick succession (send `record 0` every 200 ms for 5 seconds). The looper should not crash or produce glitches.
### 8b Remove channel while playing
Add a channel, start a loop on it, then remove the channel with:
```sh
echo "remove" > /tmp/looper_cmd
```
The loop should stop gracefully after a onesecond grace period; the client should not crash.
### 8c Save empty loop
Attempt to `save` when the current scene is not LOOPING or loop_count == 0. No file should be created. The engine should log a message to stderr.
---
## Environment Variables
- `LOOPER_CMD_FIFO` (overrides `/tmp/looper_cmd`) useful for running multiple instances for testing.
- `JACK_DEFAULT_SERVER` (JACK environment) can be set to run a separate JACK server.
---
## Troubleshooting
- **No audio after connection**: Ensure `jack_lsp` shows both source and destination ports, and that the looper is the only client using those ports.
- **MIDI not recognised**: Verify that `midi_control_port` is created (`looper:control`). Use `jack_midi_dump` to see if note events arrive.
- **“save.wav not created“** after save command: The scene must be in LOOPING state and `loop_count` > 0. Check the engines terminal output for error messages.
---
## Carla Plugin Management Manual Tests
### Test C1 Load a plugin via colon command
1. Ensure the looper engine is running, and the client (`looper-client`) is also running.
2. In the client, enter colon mode (`:`) and type:
```
from looper:output
```
then press Enter.
3. Enter colon mode again and type:
```
to system:playback_1
```
4. Load a test LV2 plugin (e.g., /usr/lib/lv2/amsynth.lv2/amsynth.so):
```
addplugin /usr/lib/lv2/amsynth.lv2/amsynth.so
```
5. The plugin should be loaded into Carla and its JACK ports are automatically connected (if `from` and `to` were set). You should see the plugin appear in the rack view when you press `R`.
6. Play some audio through the looper it should be processed by the plugin.
### Test C2 Toggle bypass
1. In rack view (`R`), select the plugin using `j`/`k`.
2. Press `b` or `B` to toggle bypass.
3. The effect should stop processing (bypass mode active); pressing again reactivates.
### Test C3 Disconnect a plugin
1. In rack view, select the plugin.
2. Press `x` or `X` to disconnect all its JACK connections.
3. The plugin should no longer be connected to any looper ports; the audio should pass through unaffected.
### Test C4 Unload a plugin
1. In rack view, select the plugin.
2. Press `d` or `D` to unload (remove) the plugin.
3. The plugin disappears from the rack list.
### Test C5 Manual connection using colon commands
1. Set `from` and `to` ports as in Test C1.
2. Load a plugin without autoconnection:
- Do **not** set `from`/`to`, or set them after loading.
- Use `addplugin` with only a path.
3. Manually connect ports in colon mode:
```
connect looper:output amsynth:in
```
The connection should be established.
4. Verify in `jack_lsp` that the ports are connected.
### Test C6 Disconnect using colon commands
1. After a manual connection, disconnect using:
```
disconnect looper:output amsynth:in
```
2. The ports should be disconnected.
---
*Last updated:* 18 May 2026

78
e2e/gen_tone.c Normal file
View File

@@ -0,0 +1,78 @@
#include <jack/jack.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static jack_port_t *output_port;
static jack_client_t *client;
static volatile int running = 1;
static double phase = 0.0;
static double freq = 440.0;
static int sample_rate = 48000;
static int total_samples = 0;
static int samples_written = 0;
int process(jack_nframes_t nframes, void *arg) {
jack_default_audio_sample_t *out =
(jack_default_audio_sample_t *)jack_port_get_buffer(output_port, nframes);
if (!out) return 0;
for (jack_nframes_t i = 0; i < nframes; i++) {
out[i] = sin(2 * M_PI * phase);
phase += freq / sample_rate;
if (phase >= 1.0) phase -= 1.0;
samples_written++;
if (total_samples > 0 && samples_written >= total_samples) {
running = 0;
break;
}
}
return 0;
}
void shutdown(void *arg) { running = 0; }
int main(int argc, char **argv) {
if (argc < 3) {
fprintf(stderr, "Usage: gen_tone <duration_seconds> <target_port> [frequency]\n");
return 1;
}
double duration = atof(argv[1]);
const char *target = argv[2];
if (argc >= 4) freq = atof(argv[3]);
jack_status_t status;
client = jack_client_open("gen_tone", JackNoStartServer, &status);
if (!client) { fprintf(stderr, "Cannot open JACK client\n"); return 1; }
sample_rate = jack_get_sample_rate(client);
total_samples = (int)(duration * sample_rate + 0.5);
output_port = jack_port_register(client, "output",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsOutput, 0);
if (!output_port) { fprintf(stderr, "Cannot register port\n"); return 1; }
jack_set_process_callback(client, process, NULL);
jack_on_shutdown(client, shutdown, NULL);
if (jack_activate(client)) { fprintf(stderr, "Cannot activate client\n"); return 1; }
// Connect to target
const char **ports = jack_get_ports(client, target,
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsInput);
if (!ports || !ports[0]) {
fprintf(stderr, "Target port '%s' not found\n", target);
return 1;
}
if (jack_connect(client, jack_port_name(output_port), ports[0])) {
fprintf(stderr, "Cannot connect port\n");
return 1;
}
while (running) sleep(1);
jack_client_close(client);
return 0;
}

13
e2e/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "looper-e2e",
"private": true,
"scripts": {
"test": "tsx test.ts",
"compile": "tsc"
},
"devDependencies": {
"typescript": "^5.0.0",
"tsx": "^4.0.0",
"@types/node": "^20.0.0"
}
}

1216
e2e/test.ts Normal file
View File

File diff suppressed because it is too large Load Diff

12
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["*.ts"]
}

View File

@@ -1,8 +1,8 @@
CC ?= gcc CC ?= gcc
CFLAGS ?= -Wall -Wextra -g -Isrc CFLAGS ?= -Wall -Wextra -g -Isrc -fsanitize=address -fno-omit-frame-pointer
LDFLAGS ?= -ljack -lm 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 = 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) OBJ = $(SRC:.c=.o)
looper: $(OBJ) looper: $(OBJ)
@@ -12,10 +12,10 @@ src/%.o: src/%.c
$(CC) $(CFLAGS) -c -o $@ $< $(CC) $(CFLAGS) -c -o $@ $<
integration: looper tests/integration.c integration: looper tests/integration.c
$(CC) $(CFLAGS) -o integration_test tests/integration.c -ljack -lm $(CC) $(CFLAGS) -o integration_test tests/integration.c -ljack -lm -lsndfile -lpthread
test_status_fifo: looper tests/test_status_fifo.c test_status_fifo: looper tests/test_status_fifo.c
$(CC) $(CFLAGS) -o test_status_fifo tests/test_status_fifo.c -ljack -lm $(CC) $(CFLAGS) -o test_status_fifo tests/test_status_fifo.c -ljack -lm -lsndfile -lpthread
test: integration test_status_fifo test: integration test_status_fifo
./test_status_fifo ./test_status_fifo

View File

@@ -4,133 +4,129 @@
#include <stdatomic.h> #include <stdatomic.h>
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <unistd.h>
/* Helper: zero a scene and set its state to IDLE */ /* Helper: zero a scene and set its state to IDLE */
static void init_scene(scene_t *sc) { void init_scene(scene_t *sc) {
memset(sc, 0, sizeof(scene_t)); memset(sc, 0, sizeof(scene_t));
atomic_store(&sc->state, STATE_IDLE); atomic_store(&sc->state, STATE_IDLE);
atomic_store(&sc->prev_state, -1); atomic_store(&sc->prev_state, -1);
} }
void channel_add(jack_client_t *client, int idx) { void channel_add(jack_client_t *client, int idx) {
struct channel_t *cur = get_channels_array();
char in_name[64], out_name[64]; char in_name[64], out_name[64];
snprintf(in_name, sizeof(in_name), "channel%d_input", next_channel_id); pid_t pid = getpid();
snprintf(out_name, sizeof(out_name), "channel%d_output", next_channel_id); snprintf(in_name, sizeof(in_name), "ch%din", next_channel_id);
snprintf(out_name, sizeof(out_name), "ch%dout", next_channel_id);
cur[idx].audio_in = jack_port_register( /* Always register audio ports (needed for pass-through even for MIDI
* channels?) */
channels[idx].audio_in = jack_port_register(
client, in_name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0); client, in_name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
cur[idx].audio_out = jack_port_register( channels[idx].audio_out = jack_port_register(
client, out_name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0); client, out_name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0);
if (!cur[idx].audio_in || !cur[idx].audio_out) { if (!channels[idx].audio_in || !channels[idx].audio_out) {
fprintf(stderr, "Failed to register ports for channel %d\n", fprintf(stderr, "Failed to register ports for channel %d\n",
next_channel_id); next_channel_id);
atomic_store(&cur[idx].active, 0); /* Do NOT mark channel active process loop will skip it */
atomic_store(&channels[idx].active, 0);
return; return;
} }
atomic_store(&cur[idx].active, 1); /* If this is a MIDI channel, register MIDI ports */
cur[idx].type = CHANNEL_AUDIO; if (channels[idx].type == CHANNEL_MIDI) {
atomic_store(&cur[idx].scene_count, 1); char midi_in_name[64], midi_out_name[64];
atomic_store(&cur[idx].current_scene, 0); snprintf(midi_in_name, sizeof(midi_in_name), "ch%dmidiin", next_channel_id);
init_scene(&cur[idx].scenes[0]); snprintf(midi_out_name, sizeof(midi_out_name), "ch%dmidiout",
next_channel_id);
next_channel_id++; channels[idx].midi_in = jack_port_register(
atomic_fetch_add(&channel_count, 1); client, midi_in_name, JACK_DEFAULT_MIDI_TYPE, JackPortIsInput, 0);
} channels[idx].midi_out = jack_port_register(
client, midi_out_name, JACK_DEFAULT_MIDI_TYPE, JackPortIsOutput, 0);
void channel_add_midi(jack_client_t *client, int idx) { if (!channels[idx].midi_in || !channels[idx].midi_out) {
struct channel_t *cur = get_channels_array();
char in_name[64], out_name[64];
snprintf(in_name, sizeof(in_name), "channel%d_midi_in", next_channel_id);
snprintf(out_name, sizeof(out_name), "channel%d_midi_out", next_channel_id);
cur[idx].midi_in = jack_port_register(client, in_name, JACK_DEFAULT_MIDI_TYPE,
JackPortIsInput, 0);
cur[idx].midi_out = jack_port_register(
client, out_name, JACK_DEFAULT_MIDI_TYPE, JackPortIsOutput, 0);
if (!cur[idx].midi_in || !cur[idx].midi_out) {
fprintf(stderr, "Failed to register MIDI ports for channel %d\n", fprintf(stderr, "Failed to register MIDI ports for channel %d\n",
next_channel_id); next_channel_id);
atomic_store(&cur[idx].active, 0); atomic_store(&channels[idx].active, 0);
jack_port_unregister(client, channels[idx].audio_in);
jack_port_unregister(client, channels[idx].audio_out);
return; return;
} }
} else {
channels[idx].midi_in = NULL;
channels[idx].midi_out = NULL;
}
atomic_store(&cur[idx].active, 1); atomic_store(&channels[idx].active, 1);
cur[idx].type = CHANNEL_MIDI; /* Initialise first scene */
atomic_store(&cur[idx].scene_count, 1); channels[idx].scene_count = 1;
atomic_store(&cur[idx].current_scene, 0); channels[idx].current_scene = 0;
init_scene(&cur[idx].scenes[0]); init_scene(&channels[idx].scenes[0]);
channels[idx].save_ring = NULL;
atomic_store(&channels[idx].save_complete, 0);
next_channel_id++; next_channel_id++;
atomic_fetch_add(&channel_count, 1); channel_count++;
} }
void channel_remove(jack_client_t *client, int idx) { void channel_remove(jack_client_t *client, int idx) {
(void)client; (void)client;
struct channel_t *cur = get_channels_array(); atomic_store_explicit(&channels[idx].active, 0, memory_order_release);
atomic_store(&cur[idx].active, 0); atomic_fetch_sub_explicit(&channel_count, 1, memory_order_release);
atomic_fetch_sub(&channel_count, 1);
} }
void channel_add_scene(jack_client_t *client, int idx) { void channel_add_scene(jack_client_t *client, int idx) {
(void)client; (void)client;
struct channel_t *cur = get_channels_array(); if (atomic_load(&channels[idx].scene_count) >= MAX_SCENES)
if (atomic_load(&cur[idx].scene_count) >= MAX_SCENES)
return; return;
int ns = atomic_load(&cur[idx].scene_count); int ns = atomic_load(&channels[idx].scene_count);
init_scene(&cur[idx].scenes[ns]); init_scene(&channels[idx].scenes[ns]);
atomic_fetch_add(&cur[idx].scene_count, 1); atomic_fetch_add(&channels[idx].scene_count, 1);
} }
void channel_remove_scene(jack_client_t *client, int idx) { void channel_remove_scene(jack_client_t *client, int idx) {
(void)client; (void)client;
struct channel_t *cur = get_channels_array(); int sc = atomic_load(&channels[idx].scene_count);
int sc = atomic_load(&cur[idx].scene_count);
if (sc <= 1) if (sc <= 1)
return; return;
int cs = atomic_load(&cur[idx].current_scene); int cs = atomic_load(&channels[idx].current_scene);
/* shift remaining scenes down (atomic copy of fields) */ /* shift remaining scenes down (atomic copy of fields) */
for (int i = cs; i < sc - 1; i++) { for (int i = cs; i < sc - 1; i++) {
atomic_store(&cur[idx].scenes[i].loop_count, atomic_store(&channels[idx].scenes[i].loop_count,
atomic_load(&cur[idx].scenes[i + 1].loop_count)); atomic_load(&channels[idx].scenes[i + 1].loop_count));
atomic_store(&cur[idx].scenes[i].record_pos, atomic_store(&channels[idx].scenes[i].record_pos,
atomic_load(&cur[idx].scenes[i + 1].record_pos)); atomic_load(&channels[idx].scenes[i + 1].record_pos));
atomic_store(&cur[idx].scenes[i].playback_pos, atomic_store(&channels[idx].scenes[i].playback_pos,
atomic_load(&cur[idx].scenes[i + 1].playback_pos)); atomic_load(&channels[idx].scenes[i + 1].playback_pos));
atomic_store(&cur[idx].scenes[i].state, atomic_store(&channels[idx].scenes[i].state,
atomic_load(&cur[idx].scenes[i + 1].state)); atomic_load(&channels[idx].scenes[i + 1].state));
atomic_store(&cur[idx].scenes[i].prev_state, atomic_store(&channels[idx].scenes[i].prev_state,
atomic_load(&cur[idx].scenes[i + 1].prev_state)); atomic_load(&channels[idx].scenes[i + 1].prev_state));
/* copy loop data (may race with RT thread; acceptable for this release) */ /* copy loop data (may race with RT thread; acceptable for this release) */
memcpy(cur[idx].scenes[i].loop.audio_buffer, memcpy(channels[idx].scenes[i].loop.audio_buffer,
cur[idx].scenes[i + 1].loop.audio_buffer, channels[idx].scenes[i + 1].loop.audio_buffer,
LOOP_BUF_SIZE * sizeof(float)); LOOP_BUF_SIZE * sizeof(float));
} }
atomic_fetch_sub(&cur[idx].scene_count, 1); atomic_fetch_sub(&channels[idx].scene_count, 1);
int new_sc = atomic_load(&cur[idx].scene_count); int new_sc = atomic_load(&channels[idx].scene_count);
if (cs >= new_sc) if (cs >= new_sc)
atomic_store(&cur[idx].current_scene, new_sc - 1); atomic_store(&channels[idx].current_scene, new_sc - 1);
} }
void channel_next_scene(jack_client_t *client, int idx) { void channel_next_scene(jack_client_t *client, int idx) {
(void)client; (void)client;
struct channel_t *cur = get_channels_array(); int sc = atomic_load(&channels[idx].scene_count);
int sc = atomic_load(&cur[idx].scene_count);
if (sc > 1) { if (sc > 1) {
int cs = atomic_load(&cur[idx].current_scene); int cs = atomic_load(&channels[idx].current_scene);
atomic_store(&cur[idx].current_scene, (cs + 1) % sc); atomic_store(&channels[idx].current_scene, (cs + 1) % sc);
} }
} }
void channel_prev_scene(jack_client_t *client, int idx) { void channel_prev_scene(jack_client_t *client, int idx) {
(void)client; (void)client;
struct channel_t *cur = get_channels_array(); int sc = atomic_load(&channels[idx].scene_count);
int sc = atomic_load(&cur[idx].scene_count);
if (sc > 1) { if (sc > 1) {
int cs = atomic_load(&cur[idx].current_scene); int cs = atomic_load(&channels[idx].current_scene);
atomic_store(&cur[idx].current_scene, (cs - 1 + sc) % sc); atomic_store(&channels[idx].current_scene, (cs - 1 + sc) % sc);
} }
} }

View File

@@ -5,23 +5,14 @@
#include <jack/jack.h> #include <jack/jack.h>
#include <stdatomic.h> #include <stdatomic.h>
#define MAX_SCENES 8
#define LOOP_BUF_SIZE (5 * 48000) #define LOOP_BUF_SIZE (5 * 48000)
#define MAX_MIDI_EVENTS 1024 #define MAX_MIDI_EVENTS 1024
#define MAX_CHANNELS 16
#define MAX_SCENES 16 #include "ringbuffer.h"
typedef enum { typedef enum { CHANNEL_AUDIO, CHANNEL_MIDI } channel_type_t;
CHANNEL_AUDIO,
CHANNEL_MIDI
} channel_type_t;
typedef struct {
jack_nframes_t timestamp; /* frame offset relative to loop start */
unsigned char status;
unsigned char note;
unsigned char velocity;
} midi_event_t;
typedef enum { typedef enum {
STATE_IDLE, STATE_IDLE,
@@ -30,46 +21,59 @@ typedef enum {
STATE_PAUSED STATE_PAUSED
} looper_state; } looper_state;
/* Structure for a recorded or playing MIDI event */
typedef struct {
jack_nframes_t timestamp;
unsigned char status;
unsigned char note;
unsigned char velocity;
} midi_event_t;
/* Loop data for a scene */
typedef struct { typedef struct {
union {
float audio_buffer[LOOP_BUF_SIZE]; float audio_buffer[LOOP_BUF_SIZE];
midi_event_t midi_events[MAX_MIDI_EVENTS]; midi_event_t midi_events[MAX_MIDI_EVENTS];
} loop; } loop_data_t;
/* A single scene within a channel */
typedef struct {
atomic_int state;
atomic_int prev_state;
atomic_int loop_count; atomic_int loop_count;
atomic_int record_pos; atomic_int record_pos;
atomic_int playback_pos; atomic_int playback_pos;
atomic_int state; loop_data_t loop;
atomic_int prev_state;
} scene_t; } scene_t;
struct channel_t { struct channel_t {
channel_type_t type; channel_type_t type; /* AUDIO or MIDI */
atomic_int active; atomic_int active;
jack_port_t *audio_in; jack_port_t *audio_in;
jack_port_t *audio_out; jack_port_t *audio_out;
jack_port_t *midi_in; jack_port_t *midi_in; /* NULL for audio channels */
jack_port_t *midi_out; jack_port_t *midi_out;
int scene_count; /* number of scenes (max MAX_SCENES) */
int current_scene; /* index of currently active scene */
scene_t scenes[MAX_SCENES]; scene_t scenes[MAX_SCENES];
atomic_int scene_count;
atomic_int current_scene; _Atomic RingBuf *save_ring;
atomic_int save_complete; /* 1 when writer is done; RT thread must stop writing */
_Atomic float rms_level; /* RMS output level (computed in RT thread) */
}; };
/* Globals declared in looper.c */ /* Globals declared in looper.c */
extern struct channel_t *_Atomic channels; extern struct channel_t channels[MAX_CHANNELS];
extern atomic_int channel_capacity;
extern atomic_int channel_count; extern atomic_int channel_count;
extern atomic_int channel_capacity;
extern int next_channel_id; extern int next_channel_id;
extern atomic_int cmd_add;
extern atomic_int cmd_remove;
extern atomic_int cmd_load;
extern atomic_int cmd_save;
/* Safe accessor for the realtime thread (returns a snapshot of the current pointer) */ void init_scene(scene_t *sc);
static inline struct channel_t *get_channels_array(void) {
return atomic_load(&channels);
}
void channel_add(jack_client_t *client, int idx); void channel_add(jack_client_t *client, int idx);
void channel_remove(jack_client_t *client, int idx); void channel_remove(jack_client_t *client, int idx);
void channel_add_midi(jack_client_t *client, int idx);
/* Scene management (called from main loop) */
void channel_add_scene(jack_client_t *client, int idx); void channel_add_scene(jack_client_t *client, int idx);
void channel_remove_scene(jack_client_t *client, int idx); void channel_remove_scene(jack_client_t *client, int idx);
void channel_next_scene(jack_client_t *client, int idx); void channel_next_scene(jack_client_t *client, int idx);

View File

@@ -9,10 +9,13 @@ typedef enum {
CMD_ADD_CHANNEL, // add a new dynamic channel CMD_ADD_CHANNEL, // add a new dynamic channel
CMD_REMOVE_CHANNEL, // remove last dynamic channel CMD_REMOVE_CHANNEL, // remove last dynamic channel
CMD_ADD_MIDI_CHANNEL, // add a new dynamic MIDI channel CMD_ADD_MIDI_CHANNEL, // add a new dynamic MIDI channel
CMD_LOAD, // load WAV file into channel 0
CMD_SAVE, // save loop as WAV file
CMD_NEXT_SCENE, CMD_NEXT_SCENE,
CMD_PREV_SCENE, CMD_PREV_SCENE,
CMD_ADD_SCENE, CMD_ADD_SCENE,
CMD_REMOVE_SCENE, CMD_REMOVE_SCENE,
CMD_SET_SCENE,
} cmd_type_t; } cmd_type_t;
typedef struct { typedef struct {

33
engine/src/log.c Normal file
View File

@@ -0,0 +1,33 @@
#include "log.h"
#include <pthread.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
static FILE *logfile = NULL;
static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER;
void log_init(void) {
logfile = fopen("./looper.log", "a");
if (!logfile)
logfile = stderr;
setbuf(logfile, NULL);
}
void log_msg(const char *fmt, ...) {
if (!logfile)
return;
pthread_mutex_lock(&log_mutex);
va_list args;
va_start(args, fmt);
vfprintf(logfile, fmt, args);
va_end(args);
fputc('\n', logfile);
pthread_mutex_unlock(&log_mutex);
}
void log_close(void) {
if (logfile && logfile != stderr)
fclose(logfile);
logfile = NULL;
}

8
engine/src/log.h Normal file
View File

@@ -0,0 +1,8 @@
#ifndef LOG_H
#define LOG_H
void log_init(void);
void log_msg(const char *fmt, ...);
void log_close(void);
#endif

View File

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@
// cppcheck-suppress missingIncludeSystem // cppcheck-suppress missingIncludeSystem
#include <jack/jack.h> #include <jack/jack.h>
extern jack_client_t *global_client;
/* Initialisation must be called after setting process callback */ /* Initialisation must be called after setting process callback */
int looper_init(jack_client_t *client); int looper_init(jack_client_t *client);
@@ -16,4 +18,10 @@ void jack_shutdown_cb(void *arg);
/* Mainloop command processing (add/remove channels) */ /* Mainloop command processing (add/remove channels) */
void looper_process_commands(jack_client_t *client); void looper_process_commands(jack_client_t *client);
/* Shutdown (must be called from the main thread after looper_quit is set) */
void looper_shutdown(jack_client_t *client);
/* Flag set by signal handler main loop should check this */
extern volatile int looper_quit;
#endif #endif

View File

@@ -1,4 +1,5 @@
// cppcheck-suppress missingIncludeSystem // cppcheck-suppress missingIncludeSystem
#include "log.h"
#include "looper.h" #include "looper.h"
#include "pipe.h" #include "pipe.h"
#include <jack/jack.h> #include <jack/jack.h>
@@ -10,15 +11,20 @@
int main(int argc, char *argv[]) { int main(int argc, char *argv[]) {
(void)argc; (void)argc;
(void)argv; (void)argv;
log_init();
log_msg("looper engine starting");
const char *client_name = "looper"; const char *client_name = "looper";
jack_options_t options = JackNullOption; jack_options_t options = JackNullOption;
jack_status_t status; jack_status_t status;
jack_client_t *client = jack_client_open(client_name, options, &status); jack_client_t *client = jack_client_open(client_name, options, &status);
if (client == NULL) { if (client == NULL) {
fprintf(stderr, "jack_client_open() failed, status = 0x%2.0x\n", status); log_msg("jack_client_open() failed, status = 0x%2.0x", status);
if (status & JackServerFailed) if (status & JackServerFailed)
fprintf(stderr, "Unable to connect to JACK server\n"); log_msg("Unable to connect to JACK server");
log_close();
return 1; return 1;
} }
@@ -29,33 +35,36 @@ int main(int argc, char *argv[]) {
jack_on_shutdown(client, jack_shutdown_cb, NULL); jack_on_shutdown(client, jack_shutdown_cb, NULL);
if (looper_init(client) != 0) { if (looper_init(client) != 0) {
fprintf(stderr, "looper initialisation failed\n"); log_msg("looper initialisation failed");
jack_client_close(client);
return 1;
}
if (pipe_start_reader() != 0) {
fprintf(stderr, "pipe reader initialisation failed\n");
jack_client_close(client); jack_client_close(client);
log_close();
return 1; return 1;
} }
if (jack_activate(client)) { if (jack_activate(client)) {
fprintf(stderr, "Cannot activate client\n"); log_msg("Cannot activate client");
jack_client_close(client); jack_client_close(client);
log_close();
return 1; return 1;
} }
fprintf(stderr, "looper running (client name '%s')\n", client_name); if (pipe_start_reader() != 0) {
log_msg("pipe_start_reader() failed");
jack_client_close(client);
log_close();
return 1;
}
while (1) { log_msg("looper running (client name '%s')", client_name);
while (!looper_quit) {
looper_process_commands(client); looper_process_commands(client);
{ {
struct timespec ts = {.tv_sec = 0, .tv_nsec = 1000000}; struct timespec ts = {.tv_sec = 0, .tv_nsec = 10000000};
nanosleep(&ts, NULL); nanosleep(&ts, NULL);
} /* check commands every 1 ms */ }
} }
jack_client_close(client); looper_shutdown(client);
return 0; return 0;
} }

View File

@@ -1,16 +1,20 @@
// cppcheck-suppress missingIncludeSystem // cppcheck-suppress missingIncludeSystem
#include "midi.h" #include "midi.h"
#include "channel.h" #include "channel.h"
#include "command.h"
#include "queue.h" #include "queue.h"
#include <jack/jack.h> #include <jack/jack.h>
#include <jack/midiport.h> #include <jack/midiport.h>
#include <stdatomic.h> #include <stdatomic.h>
extern atomic_int control_key_active; /* queues declared in looper.c */
extern atomic_int bind_channel;
extern spsc_queue_t cmd_queue; extern spsc_queue_t cmd_queue;
extern spsc_queue_t cmd_queue_main_midi; extern spsc_queue_t cmd_queue_main_midi;
extern atomic_int control_key_active;
extern atomic_int cmd_add;
extern atomic_int cmd_remove;
extern atomic_int cmd_load;
extern atomic_int cmd_save;
extern atomic_int bind_channel;
void midi_handle_events(void *port_buffer, jack_nframes_t nframes) { void midi_handle_events(void *port_buffer, jack_nframes_t nframes) {
(void)nframes; (void)nframes;
@@ -35,27 +39,40 @@ void midi_handle_events(void *port_buffer, jack_nframes_t nframes) {
int ck = atomic_load(&control_key_active); int ck = atomic_load(&control_key_active);
if (ck) { if (ck) {
atomic_store(&control_key_active, 0); atomic_store(&control_key_active, 0);
if (note < 16 && note < atomic_load(&channel_capacity)) { if (note < 16) {
command_t cmd = { atomic_store(&bind_channel, note);
.type = CMD_BIND_CHANNEL, .channel = -1, .data = note};
queue_push(&cmd_queue, cmd);
} else { } else {
switch (note) { switch (note) {
case 60: { case 60:
command_t cmd = { atomic_store(&cmd_add, 1);
.type = CMD_ADD_CHANNEL, .channel = -1, .data = 0}; break;
queue_push(&cmd_queue_main_midi, cmd); case 61:
} break; atomic_store(&cmd_remove, 1);
case 61: { break;
command_t cmd = { case 62: /* trigger looper channel via bind_channel */
.type = CMD_REMOVE_CHANNEL, .channel = -1, .data = 0}; {
queue_push(&cmd_queue_main_midi, cmd);
} break;
case 62: {
int bch = atomic_load(&bind_channel); int bch = atomic_load(&bind_channel);
if (bch >= 0 && bch < atomic_load(&channel_capacity)) { if (bch >= 0 && bch < MAX_CHANNELS) {
command_t cmd = {.type = CMD_CYCLE, .channel = bch, .data = 0}; int sc_idx = atomic_load(&channels[bch].current_scene);
queue_push(&cmd_queue, cmd); int cur = atomic_load(&channels[bch].scenes[sc_idx].state);
switch (cur) {
case STATE_IDLE:
atomic_store(&channels[bch].scenes[sc_idx].state,
STATE_RECORD);
break;
case STATE_RECORD:
atomic_store(&channels[bch].scenes[sc_idx].state,
STATE_LOOPING);
break;
case STATE_LOOPING:
atomic_store(&channels[bch].scenes[sc_idx].state,
STATE_PAUSED);
break;
case STATE_PAUSED:
atomic_store(&channels[bch].scenes[sc_idx].state,
STATE_LOOPING);
break;
}
} }
} break; } break;
case 63: { case 63: {
@@ -97,19 +114,31 @@ void midi_handle_events(void *port_buffer, jack_nframes_t nframes) {
} else { } else {
/* direct mapping */ /* direct mapping */
switch (note) { switch (note) {
case 1: { case 1: /* toggle channel 0 */
command_t cmd = {.type = CMD_CYCLE, .channel = 0, .data = 0}; {
queue_push(&cmd_queue, cmd); int sc0 = atomic_load(&channels[0].current_scene);
} break; int cur0 = atomic_load(&channels[0].scenes[sc0].state);
case 60: { switch (cur0) {
command_t cmd = {.type = CMD_ADD_CHANNEL, .channel = -1, .data = 0}; case STATE_IDLE:
queue_push(&cmd_queue_main_midi, cmd); atomic_store(&channels[0].scenes[sc0].state, STATE_RECORD);
} break; break;
case 61: { case STATE_RECORD:
command_t cmd = { atomic_store(&channels[0].scenes[sc0].state, STATE_LOOPING);
.type = CMD_REMOVE_CHANNEL, .channel = -1, .data = 0}; break;
queue_push(&cmd_queue_main_midi, cmd); case STATE_LOOPING:
atomic_store(&channels[0].scenes[sc0].state, STATE_PAUSED);
break;
case STATE_PAUSED:
atomic_store(&channels[0].scenes[sc0].state, STATE_LOOPING);
break;
}
} break; } break;
case 60:
atomic_store(&cmd_add, 1);
break;
case 61:
atomic_store(&cmd_remove, 1);
break;
default: default:
break; break;
} }

View File

@@ -10,10 +10,16 @@
#include <sys/stat.h> #include <sys/stat.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
#include <jack/jack.h>
extern jack_client_t *global_client;
#define FIFO_PATH "/tmp/looper_cmd" #define FIFO_PATH "/tmp/looper_cmd"
#define LINE_MAX 256 #define LINE_MAX 256
/* Filename for the next load command (default "loop.wav") */
char load_filename[256] = "loop.wav";
/* forwarddeclare the global queues (defined in looper.c) */ /* forwarddeclare the global queues (defined in looper.c) */
extern spsc_queue_t cmd_queue; extern spsc_queue_t cmd_queue;
extern spsc_queue_t cmd_queue_main_fifo; extern spsc_queue_t cmd_queue_main_fifo;
@@ -47,18 +53,19 @@ static void *pipe_thread_func(void *arg) {
queue_push(&cmd_queue_main_fifo, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strncmp(line, "record ", 7) == 0) { } else if (strncmp(line, "record ", 7) == 0) {
int ch = atoi(line + 7); int ch = atoi(line + 7);
fprintf(stderr, "FIFO: received record %d\n", ch);
command_t cmd = {.type = CMD_CYCLE, .channel = ch, .data = 0}; command_t cmd = {.type = CMD_CYCLE, .channel = ch, .data = 0};
queue_push(&cmd_queue, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "stop") == 0) { } else if (strcmp(line, "stop") == 0) {
command_t cmd = {.type = CMD_STOP, .channel = -1, .data = 0}; command_t cmd = {.type = CMD_STOP, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strncmp(line, "bind ", 5) == 0) { } else if (strncmp(line, "bind ", 5) == 0) {
int ch = atoi(line + 5); int ch = atoi(line + 5);
command_t cmd = {.type = CMD_BIND_CHANNEL, .channel = -1, .data = ch}; command_t cmd = {.type = CMD_BIND_CHANNEL, .channel = -1, .data = ch};
queue_push(&cmd_queue, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "unbind") == 0) { } else if (strcmp(line, "unbind") == 0) {
command_t cmd = {.type = CMD_UNBIND, .channel = -1, .data = 0}; command_t cmd = {.type = CMD_UNBIND, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "scene_add") == 0) { } else if (strcmp(line, "scene_add") == 0) {
command_t cmd = {.type = CMD_ADD_SCENE, .channel = -1, .data = 0}; command_t cmd = {.type = CMD_ADD_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, cmd); queue_push(&cmd_queue_main_fifo, cmd);
@@ -71,15 +78,50 @@ static void *pipe_thread_func(void *arg) {
} else if (strcmp(line, "scene_prev") == 0) { } else if (strcmp(line, "scene_prev") == 0) {
command_t cmd = {.type = CMD_PREV_SCENE, .channel = -1, .data = 0}; command_t cmd = {.type = CMD_PREV_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strncmp(line, "set_scene ", 10) == 0) {
int ch, sc;
if (sscanf(line + 10, "%d %d", &ch, &sc) == 2) {
command_t cmd = {.type = CMD_SET_SCENE, .channel = ch, .data = sc};
queue_push(&cmd_queue_main_fifo, cmd);
}
} else if (strncmp(line, "load", 4) == 0) {
/* Parse optional filename after "load " */
const char *fn = line + 4;
while (*fn == ' ')
fn++;
if (*fn == '\0') {
strncpy(load_filename, "loop.wav", sizeof(load_filename) - 1);
} else {
strncpy(load_filename, fn, sizeof(load_filename) - 1);
}
load_filename[sizeof(load_filename) - 1] = '\0';
fprintf(stderr, "FIFO RECEIVED load: %s\n", load_filename);
command_t cmd = {.type = CMD_LOAD, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "save") == 0) {
command_t cmd = {.type = CMD_SAVE, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, cmd);
} else if (strncmp(line, "from ", 5) == 0) {
const char *port = line + 5;
fprintf(stderr, "FIFO RECEIVED from: %s\n", port);
if (global_client) {
int ret = jack_connect(global_client, port, "looper:ch0in");
if (ret != 0)
fprintf(stderr, "Failed to connect %s -> looper:ch0in (ret=%d)\n", port, ret);
}
} else if (strncmp(line, "to ", 3) == 0) {
const char *port = line + 3;
fprintf(stderr, "FIFO RECEIVED to: %s\n", port);
if (global_client) {
int ret = jack_connect(global_client, "looper:ch0out", port);
if (ret != 0)
fprintf(stderr, "Failed to connect looper:ch0out -> %s (ret=%d)\n", port, ret);
}
} }
/* ignore unknown lines */ /* ignore unknown lines */
} }
/* EOF all writers closed, reopen for next connection */ /* EOF all writers closed, reopen for next connection */
fclose(fifo); fclose(fifo);
{
struct timespec ts = {.tv_sec = 0, .tv_nsec = 50000000};
nanosleep(&ts, NULL);
} /* small pause before retrying */
} }
return NULL; /* unreachable */ return NULL; /* unreachable */
} }

View File

@@ -6,4 +6,7 @@
* Returns 0 on success, -1 on failure. */ * Returns 0 on success, -1 on failure. */
int pipe_start_reader(void); int pipe_start_reader(void);
/** Filename for the next load command (default "loop.wav") */
extern char load_filename[256];
#endif #endif

View File

View File

View File

@@ -1,6 +1,7 @@
#ifndef QUEUE_H #ifndef QUEUE_H
#define QUEUE_H #define QUEUE_H
#include <stdatomic.h>
#include "command.h" #include "command.h"
#include <stdbool.h> #include <stdbool.h>
@@ -9,14 +10,14 @@
* reading (consumer). No locks, no dynamic memory allocation. * reading (consumer). No locks, no dynamic memory allocation.
* Must be initialised before first use. All operations are RTsafe. */ * Must be initialised before first use. All operations are RTsafe. */
#define QUEUE_CAPACITY 256 #define QUEUE_CAPACITY 1024
typedef struct { typedef struct {
command_t buffer[QUEUE_CAPACITY]; command_t buffer[QUEUE_CAPACITY];
/* head: index where next element will be written (producer only) /* head: index where next element will be written (producer only)
* tail: index of next element to read (consumer only) */ * tail: index of next element to read (consumer only) */
int head; atomic_int head;
int tail; atomic_int tail;
} spsc_queue_t; } spsc_queue_t;
/* Initialise queue (must be called once before any push/pop). */ /* Initialise queue (must be called once before any push/pop). */

74
engine/src/ringbuffer.c Normal file
View File

@@ -0,0 +1,74 @@
#include "ringbuffer.h"
#include <stdlib.h>
static inline size_t load_head(const RingBuf *r) {
return atomic_load_explicit(&r->head, memory_order_relaxed);
}
static inline size_t load_tail(const RingBuf *r) {
return atomic_load_explicit(&r->tail, memory_order_relaxed);
}
static inline void store_head(RingBuf *r, size_t v) {
atomic_store_explicit(&r->head, v,
memory_order_release); // release after data written
}
static inline void store_tail(RingBuf *r, size_t v) {
atomic_store_explicit(&r->tail, v,
memory_order_release); // release after data read
}
int ring_init(RingBuf *r, size_t capacity) {
r->buf = (float *)malloc(capacity * sizeof(float));
if (!r->buf)
return -1;
r->capacity = capacity;
atomic_init(&r->head, 0);
atomic_init(&r->tail, 0);
return 0;
}
void ring_destroy(RingBuf *r) {
free(r->buf);
r->buf = NULL;
r->capacity = 0;
}
size_t ring_write(RingBuf *r, const float *data, size_t count) {
size_t tail =
load_tail(r); // producer reads consumer's tail (relaxed is fine)
size_t head = load_head(r); // own head
size_t cap = r->capacity;
size_t used = (head >= tail) ? (head - tail) : (cap - (tail - head));
size_t avail = cap - 1 - used;
if (count > avail)
count = avail;
if (count == 0)
return 0;
size_t pos = head;
for (size_t i = 0; i < count; ++i) {
r->buf[pos] = data[i];
pos = (pos + 1) % cap;
}
store_head(r, pos); // release makes data visible to consumer
return count;
}
size_t ring_read(RingBuf *r, float *data, size_t count) {
size_t head = atomic_load_explicit(
&r->head, memory_order_acquire); // acquire see producer's writes
size_t tail = load_tail(r); // own tail
size_t cap = r->capacity;
size_t used = (head >= tail) ? (head - tail) : (cap - (tail - head));
if (count > used)
count = used;
if (count == 0)
return 0;
size_t pos = tail;
for (size_t i = 0; i < count; ++i) {
data[i] = r->buf[pos];
pos = (pos + 1) % cap;
}
store_tail(r, pos); // release makes consumer's tail visible to producer
return count;
}

19
engine/src/ringbuffer.h Normal file
View File

@@ -0,0 +1,19 @@
#ifndef RINGBUFFER_H
#define RINGBUFFER_H
#include <stddef.h>
#include <stdatomic.h>
typedef struct {
atomic_size_t head;
atomic_size_t tail;
size_t capacity;
float *buf;
} RingBuf;
int ring_init(RingBuf *r, size_t capacity);
void ring_destroy(RingBuf *r);
size_t ring_write(RingBuf *r, const float *data, size_t count);
size_t ring_read(RingBuf *r, float *data, size_t count);
#endif

49
engine/src/wav.c Normal file
View File

@@ -0,0 +1,49 @@
#include "wav.h"
#include "channel.h"
#include <sndfile.h>
#include <stdio.h>
#include <stdlib.h>
int wav_read(const char *path, float **buffer, unsigned *frames) {
SF_INFO info;
info.format = 0;
SNDFILE *sf = sf_open(path, SFM_READ, &info);
if (!sf)
return -1;
/* We need mono 16-bit PCM; refuse anything else */
if (info.channels != 1 || info.samplerate <= 0) {
sf_close(sf);
return -1;
}
unsigned total = (info.frames > (sf_count_t)LOOP_BUF_SIZE)
? LOOP_BUF_SIZE
: (unsigned)info.frames;
float *buf = (float *)malloc(total * sizeof(float));
if (!buf) {
sf_close(sf);
return -1;
}
sf_count_t nread = sf_readf_float(sf, buf, total);
sf_close(sf);
*buffer = buf;
*frames = (unsigned)nread;
return 0;
}
int wav_write(const char *path, const float *data, unsigned frames,
unsigned sample_rate) {
SF_INFO info;
info.samplerate = sample_rate;
info.channels = 1;
info.format = SF_FORMAT_WAV | SF_FORMAT_PCM_16;
SNDFILE *sf = sf_open(path, SFM_WRITE, &info);
if (!sf)
return -1;
sf_writef_float(sf, data, frames);
sf_close(sf);
return 0;
}

9
engine/src/wav.h Normal file
View File

@@ -0,0 +1,9 @@
#ifndef WAV_H
#define WAV_H
#include <stddef.h>
int wav_read(const char *path, float **buffer, unsigned *frames);
int wav_write(const char *path, const float *data, unsigned frames, unsigned sample_rate);
#endif

View File

File diff suppressed because it is too large Load Diff

BIN
looper
View File

Binary file not shown.

View File

@@ -1,22 +1,63 @@
# Toplevel Makefile delegates build/clean/test to subdirectories # Top-level Makefile delegates build/clean/test to subdirectories
CC ?= gcc
SUBDIRS = engine client SUBDIRS = engine client
.PHONY: all build clean test check format $(SUBDIRS) VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo "0.0.0")
all: build .PHONY: all build clean test check format orchestrator run e2e package $(SUBDIRS)
all: build orchestrator
build: $(SUBDIRS) build: $(SUBDIRS)
@echo "Build complete." @echo "Build complete."
orchestrator: orchestrator.c
$(CC) -Wall -Wextra -std=c11 -o looper orchestrator.c
GEN_TONE_BIN = /tmp/gen_tone
$(GEN_TONE_BIN): e2e/gen_tone.c
$(CC) -o $@ $< -ljack -lm
$(SUBDIRS): $(SUBDIRS):
$(MAKE) -C $@ $(MAKE) -C $@
run: orchestrator
./looper
# Run unit tests for engine and client, and end-to-end tests
test: test:
# $(MAKE) -C engine test # FIXME reenable engine and client unit tests later
$(MAKE) -C client test $(MAKE) e2e
# Run endtoend tests (installs npm dependencies if missing)
# Skip if any required tool is missing
REQUIRED_TOOLS = tmux sox jack_capture jack_wait node
e2e: build $(GEN_TONE_BIN)
@missing="" ; \
for cmd in $(REQUIRED_TOOLS); do \
if ! command -v $$cmd >/dev/null 2>&1; then \
missing="$$missing $$cmd"; \
fi ; \
done ; \
if [ -n "$$missing" ]; then \
echo "Skipping e2e tests (missing:$$missing)"; \
exit 0; \
fi ; \
cd e2e && npm install --silent && npm test
# Create a distribution archive
package: build
tar czf looper-$(VERSION).tar.gz \
--transform 's,^,looper-$(VERSION)/,' \
looper \
README.md LICENSE 2>/dev/null; \
echo "Created looper-$(VERSION).tar.gz"
clean: clean:
rm -f looper
@for dir in $(SUBDIRS); do \ @for dir in $(SUBDIRS); do \
echo "Cleaning $$dir..."; \ echo "Cleaning $$dir..."; \
$(MAKE) -C $$dir clean; \ $(MAKE) -C $$dir clean; \

146
orchestrator.c Normal file
View File

@@ -0,0 +1,146 @@
/*
* orchestrator.c - Launches both the engine and client processes,
* forwards signals, and waits for either to exit before cleaning up
* the other. If a child exits abnormally it is retried up to 3 times.
*/
#define _GNU_SOURCE
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <string.h>
static pid_t engine_pid = 0;
static pid_t client_pid = 0;
static void terminate_children(void) {
if (engine_pid > 0) kill(engine_pid, SIGTERM);
if (client_pid > 0) kill(client_pid, SIGTERM);
}
static void wait_children(void) {
int status;
while (waitpid(-1, &status, 0) > 0);
}
static void cleanup(int sig) {
(void)sig;
terminate_children();
wait_children();
_exit(0);
}
static pid_t start_engine(void) {
pid_t pid = fork();
if (pid == -1) {
perror("fork engine");
return -1;
}
if (pid == 0) {
execl("./engine/looper", "looper", NULL);
perror("execl engine");
_exit(1);
}
return pid;
}
static pid_t start_client(int argc, char *argv[]) {
pid_t pid = fork();
if (pid == -1) {
perror("fork client");
return -1;
}
if (pid == 0) {
if (argc > 2 && strcmp(argv[1], "-s") == 0) {
execl("./client/looper-client", "looper-client", "-s", argv[2], NULL);
} else {
execl("./client/looper-client", "looper-client", NULL);
}
perror("execl client");
_exit(1);
}
return pid;
}
int main(int argc, char *argv[]) {
signal(SIGINT, cleanup);
signal(SIGTERM, cleanup);
int i;
for (i = 1; i < argc; i++) {
if (strcmp(argv[i], "--debug") == 0) {
setenv("LOOPER_DEBUG", "1", 1);
break;
}
}
int attempt = 0;
const int MAX_ATTEMPTS = 3;
while (attempt < MAX_ATTEMPTS) {
attempt++;
engine_pid = start_engine();
if (engine_pid == -1) {
if (attempt >= MAX_ATTEMPTS) {
fprintf(stderr, "Failed to start engine after %d attempts\n", MAX_ATTEMPTS);
return 1;
}
usleep(500000);
continue;
}
client_pid = start_client(argc, argv);
if (client_pid == -1) {
kill(engine_pid, SIGTERM);
waitpid(engine_pid, NULL, 0);
if (attempt >= MAX_ATTEMPTS) {
fprintf(stderr, "Failed to start client after %d attempts\n", MAX_ATTEMPTS);
return 1;
}
usleep(500000);
continue;
}
/* Both children have started. Wait for either to exit. */
int status;
pid_t exited = waitpid(-1, &status, 0);
pid_t other = 0;
if (exited == engine_pid) {
other = client_pid;
} else if (exited == client_pid) {
other = engine_pid;
} else {
/* unexpected waitpid failure */
terminate_children();
wait_children();
return 1;
}
/* Kill the other child now that one has exited. */
if (other > 0) {
kill(other, SIGTERM);
waitpid(other, NULL, 0);
}
/* Normal clean exit (zero status) means we are done. */
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
return 0;
}
if (attempt >= MAX_ATTEMPTS) {
fprintf(stderr, "Child exited abnormally after %d attempts. Quitting.\n",
MAX_ATTEMPTS);
return 1;
}
fprintf(stderr, "Child exited abnormally, retrying...\n");
usleep(500000);
/* loop back to try another fresh start */
}
return 1;
}

32
tests/test_tui_stub.c Normal file
View File

@@ -0,0 +1,32 @@
/* Stub for tui functions used by script.c in test builds */
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include "tui.h"
char *tui_fzf_select(const char *const items[], size_t count, const char *prompt) {
(void)items;
(void)count;
(void)prompt;
return NULL;
}
void tui_cleanup(void) {
/* no operation */
}
/* Stub for tui functions used by script.c in test builds */
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include "tui.h"
char *tui_fzf_select(const char *const items[], size_t count, const char *prompt) {
(void)items;
(void)count;
(void)prompt;
return NULL;
}
void tui_cleanup(void) {
/* no operation */
}