feat: add JACK port management, VU meter, fzf integration, and e2e tests

This commit is contained in:
Loic Coenen
2026-06-06 13:32:20 +00:00
committed by Loic Coenen (aider)
30 changed files with 2303 additions and 177 deletions

View File

@@ -1,5 +1,6 @@
CC = gcc CC = gcc
CFLAGS = -Wall -Wextra -Wpedantic -std=c11 -Isrc -I../engine/src CFLAGS = -Wall -Wextra -Wpedantic -std=c11 -Isrc -I../engine/src -fsanitize=address -fno-omit-frame-pointer
CARLA_INC = -I/usr/include/carla -I/usr/include/carla/includes CARLA_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
@@ -20,22 +21,22 @@ 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) $(SCRIPT_OBJ) $(LOG_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) $(SCRIPT_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) $(SCRIPT_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 $@ $<
@@ -51,8 +52,17 @@ TEST_SCRIPT_OBJ = tests/test_script.o
$(TEST_SCRIPT_OBJ): tests/test_script.c src/script.h $(TEST_SCRIPT_OBJ): tests/test_script.c src/script.h
$(CC) $(CFLAGS) -c -o $@ $< $(CC) $(CFLAGS) -c -o $@ $<
$(TEST_SCRIPT_BIN): $(TEST_SCRIPT_OBJ) $(SCRIPT_OBJ) TEST_TUI_STUB_OBJ = tests/test_tui_stub.o
$(CC) $(CFLAGS) -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 $(LOG_OBJ): src/log.c
$(CC) $(CFLAGS) -c -o $@ $< $(CC) $(CFLAGS) -c -o $@ $<
@@ -84,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) $(SCRIPT_OBJ) $(TEST_CLIENT_BIN): $(TEST_CLIENT_OBJ) src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ) $(LOG_OBJ)
$(CC) $(CFLAGS) $(CARLA_INC) -o $@ $^ $(CARLA_LIB) -ljack -lncurses $(CC) $(CFLAGS) $(CARLA_INC) -o $@ $^ $(CARLA_LIB) -ljack -lncurses
# --- Carla host tests --- # --- Carla host tests ---
@@ -101,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

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,14 +1,16 @@
#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] = ""; char g_from_port[256] = "";
static char to_port[256] = ""; char g_to_port[256] = "";
char g_connect_error[512] = "";
const char* get_stored_from(void) { return from_port; } const char* get_stored_from(void) { return g_from_port; }
const char* get_stored_to(void) { return to_port; } const char* get_stored_to(void) { return g_to_port; }
static int get_plugin_id_for_port(const char *port_spec) { static int get_plugin_id_for_port(const char *port_spec) {
// port_spec format: "plugin_id:port_name" // port_spec format: "plugin_id:port_name"
@@ -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;
strncpy(from_port, port, sizeof(from_port)-1); int ret = carla_connect_direct(port, "looper:ch0in");
from_port[sizeof(from_port)-1] = '\0'; if (ret == 0) {
return 0; strncpy(g_from_port, port, sizeof(g_from_port)-1);
g_from_port[sizeof(g_from_port)-1] = '\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;
strncpy(to_port, port, sizeof(to_port)-1); int ret = carla_connect_direct("looper:ch0out", port);
to_port[sizeof(to_port)-1] = '\0'; if (ret == 0) {
return 0; strncpy(g_to_port, port, sizeof(g_to_port)-1);
g_to_port[sizeof(g_to_port)-1] = '\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> ---
@@ -58,12 +74,12 @@ int handle_client_command(const char *input, int *out_id) {
if (ret == 0 && out_id) *out_id = id; if (ret == 0 && out_id) *out_id = id;
// auto-connect using stored :from/:to if set // auto-connect using stored :from/:to if set
if (ret == 0 && from_port[0] && to_port[0]) { if (ret == 0 && g_from_port[0] && g_to_port[0]) {
// parse plugin port name from stored from_port ("plugin_id:port_name") // parse plugin port name from stored from_port ("plugin_id:port_name")
const char *colon = strchr(from_port, ':'); const char *colon = strchr(g_from_port, ':');
if (colon) { if (colon) {
const char *pname = colon + 1; const char *pname = colon + 1;
plugin_connect(id, pname, to_port); plugin_connect(id, pname, g_to_port);
} }
} }
@@ -75,11 +91,11 @@ int handle_client_command(const char *input, int *out_id) {
const char *from = strtok(NULL, " "); const char *from = strtok(NULL, " ");
const char *to = strtok(NULL, " "); const char *to = strtok(NULL, " ");
if (!from) { if (!from) {
if (from_port[0]) from = from_port; if (g_from_port[0]) from = g_from_port;
else return -1; else return -1;
} }
if (!to) { if (!to) {
if (to_port[0]) to = to_port; if (g_to_port[0]) to = g_to_port;
else return -1; else return -1;
} }
@@ -99,11 +115,11 @@ int handle_client_command(const char *input, int *out_id) {
const char *from = strtok(NULL, " "); const char *from = strtok(NULL, " ");
const char *to = strtok(NULL, " "); const char *to = strtok(NULL, " ");
if (!from) { if (!from) {
if (from_port[0]) from = from_port; if (g_from_port[0]) from = g_from_port;
else return -1; else return -1;
} }
if (!to) { if (!to) {
if (to_port[0]) to = to_port; if (g_to_port[0]) to = g_to_port;
else return -1; else return -1;
} }
return plugin_disconnect(from, to); return plugin_disconnect(from, to);

View File

@@ -13,4 +13,8 @@ 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];
extern char g_from_port[256];
extern char g_to_port[256];
#endif #endif

View File

@@ -1,3 +1,4 @@
#include "carla_host.h"
#include "log.h" #include "log.h"
#include "script.h" #include "script.h"
#include "tui.h" #include "tui.h"
@@ -8,6 +9,10 @@
int main(int argc, char *argv[]) { int main(int argc, char *argv[]) {
log_init(); log_init();
if (carla_init_jack() != 0) {
log_msg("Warning: could not initialise JACK connector client");
}
const char *script_path = NULL; const char *script_path = NULL;
if (argc > 2 && strcmp(argv[1], "-s") == 0) { if (argc > 2 && strcmp(argv[1], "-s") == 0) {

View File

@@ -4,10 +4,16 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <stdlib.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 #define MAX_NOTES 128
static char *note_actions[MAX_NOTES] = {0}; static char *note_actions[MAX_NOTES] = {0};
char g_selected_port[256] = {0};
int script_load(const char *path) { int script_load(const char *path) {
FILE *fp = fopen(path, "r"); FILE *fp = fopen(path, "r");
@@ -45,6 +51,100 @@ int script_load(const char *path) {
return 0; 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) { void script_cleanup(void) {
for (int i = 0; i < MAX_NOTES; i++) { for (int i = 0; i < MAX_NOTES; i++) {
free(note_actions[i]); free(note_actions[i]);

View File

@@ -4,5 +4,7 @@
int script_load(const char *path); int script_load(const char *path);
void script_handle_note(int note); void script_handle_note(int note);
void script_cleanup(void); void script_cleanup(void);
int script_handle_fzf_command(const char *type);
extern char g_selected_port[256];
#endif #endif

View File

@@ -1,51 +1,88 @@
#define _POSIX_C_SOURCE 200809L
#include "carla_host.h" #include "carla_host.h"
#include "client_cmd.h" #include "client_cmd.h"
#include "log.h"
#include "plugins.h" #include "plugins.h"
#include "script.h" #include "script.h"
#include "tui.h" #include "tui.h"
#include <CarlaHost.h> #include <CarlaHost.h>
#include <ctype.h>
#include <dirent.h> #include <dirent.h>
#include <fcntl.h> #include <fcntl.h>
#include <math.h>
#include <ncurses.h> #include <ncurses.h>
#include <stdbool.h> #include <stdbool.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h> #include <unistd.h>
/* ---------- engine alive indicator ---------- */
static bool engine_running = false; static bool engine_running = false;
static bool debug_mode = false; static bool debug_mode = false;
static int cmd_fifo_fd = -1;
static int status_fifo_fd = -1;
int send_command(const char *cmd) { int send_command(const char *cmd) {
if (debug_mode) { if (debug_mode)
fprintf(stderr, "DEBUG: send_command(%s)\n", cmd); 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;
} }
/* ---------- Stub functions (no engine) ---------- */ static bool carla_resolve_channel_port(int channel, bool is_to, char *buf, size_t bufsize) {
// Clip states dummy values used as placeholders 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;
}
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 */
@@ -87,6 +124,11 @@ 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) {
@@ -128,7 +170,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));
} }
@@ -174,11 +222,62 @@ static void draw_grid(void) {
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();
@@ -186,6 +285,7 @@ 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); debug_mode = (getenv("LOOPER_DEBUG") != NULL);
@@ -213,36 +313,42 @@ 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) { static void tui_read_status(void) {
draw_grid(); if (status_fifo_fd < 0) {
while (1) { status_fifo_fd = open(STATUS_FIFO, O_RDONLY | O_NONBLOCK);
/* read any available status lines */ if (status_fifo_fd < 0) return;
int fd = open(STATUS_FIFO, O_RDONLY | O_NONBLOCK); }
if (fd >= 0) { char buf[4096];
char buf[256]; int n = read(status_fifo_fd, buf, sizeof(buf)-1);
int n = read(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) {
if (nl) { int idx = sc * GRID_COLS + ch;
*nl = '\n'; cell_state[idx] = st;
line = nl + 1;
} else break;
} }
} }
close(fd); 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 by testing existence of status FIFO */ /* Check if engine is alive */
engine_running = (access(STATUS_FIFO, F_OK) == 0); engine_running = (access(STATUS_FIFO, F_OK) == 0);
/* read any available note events (for script macros) */ /* read any available note events (for script macros) */
@@ -267,13 +373,23 @@ void tui_run(void) {
close(nfd); close(nfd);
} }
if (in_colon) { /* 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);
}
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';
@@ -284,6 +400,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;
@@ -304,10 +476,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;
@@ -318,6 +490,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;
@@ -325,8 +498,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':
@@ -349,6 +534,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;
@@ -361,12 +548,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) {
@@ -386,7 +588,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);
@@ -403,11 +604,81 @@ 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 */ /* free script note allocations */
script_cleanup(); script_cleanup();
@@ -419,3 +690,5 @@ void tui_cleanup(void) {
carla_cleanup_jack(); carla_cleanup_jack();
curs_set(1); endwin(); curs_set(1); endwin();
} }
extern char g_selected_port[256];

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

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){}

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,6 +1,6 @@
CC ?= gcc CC ?= gcc
CFLAGS ?= -Wall -Wextra -g -Isrc CFLAGS ?= -Wall -Wextra -g -Isrc -fsanitize=address -fno-omit-frame-pointer
LDFLAGS ?= -ljack -lm -lsndfile -lpthread LDFLAGS ?= -fsanitize=address -ljack -lm -lsndfile -lpthread
SRC = src/main.c src/looper.c src/channel.c src/midi.c src/queue.c src/pipe.c src/ringbuffer.c src/wav.c src/log.c 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)

View File

@@ -4,6 +4,7 @@
#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 */
void init_scene(scene_t *sc) { void init_scene(scene_t *sc) {
@@ -14,8 +15,9 @@ void init_scene(scene_t *sc) {
void channel_add(jack_client_t *client, int idx) { void channel_add(jack_client_t *client, int idx) {
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);
/* Always register audio ports (needed for pass-through even for MIDI /* Always register audio ports (needed for pass-through even for MIDI
* channels?) */ * channels?) */
@@ -34,9 +36,8 @@ void channel_add(jack_client_t *client, int idx) {
/* If this is a MIDI channel, register MIDI ports */ /* If this is a MIDI channel, register MIDI ports */
if (channels[idx].type == CHANNEL_MIDI) { if (channels[idx].type == CHANNEL_MIDI) {
char midi_in_name[64], midi_out_name[64]; char midi_in_name[64], midi_out_name[64];
snprintf(midi_in_name, sizeof(midi_in_name), "channel%d_midi_in", snprintf(midi_in_name, sizeof(midi_in_name), "ch%dmidiin", next_channel_id);
next_channel_id); snprintf(midi_out_name, sizeof(midi_out_name), "ch%dmidiout",
snprintf(midi_out_name, sizeof(midi_out_name), "channel%d_midi_out",
next_channel_id); next_channel_id);
channels[idx].midi_in = jack_port_register( channels[idx].midi_in = jack_port_register(
client, midi_in_name, JACK_DEFAULT_MIDI_TYPE, JackPortIsInput, 0); client, midi_in_name, JACK_DEFAULT_MIDI_TYPE, JackPortIsInput, 0);
@@ -70,8 +71,8 @@ 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)client; (void)client;
atomic_store(&channels[idx].active, 0); atomic_store_explicit(&channels[idx].active, 0, memory_order_release);
atomic_fetch_sub(&channel_count, 1); atomic_fetch_sub_explicit(&channel_count, 1, memory_order_release);
} }
void channel_add_scene(jack_client_t *client, int idx) { void channel_add_scene(jack_client_t *client, int idx) {

View File

@@ -5,7 +5,7 @@
#include <jack/jack.h> #include <jack/jack.h>
#include <stdatomic.h> #include <stdatomic.h>
#define MAX_SCENES 4 #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_CHANNELS 16
@@ -58,6 +58,7 @@ struct channel_t {
_Atomic RingBuf *save_ring; _Atomic RingBuf *save_ring;
atomic_int save_complete; /* 1 when writer is done; RT thread must stop writing */ 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 */

View File

@@ -15,6 +15,7 @@ typedef enum {
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 {

View File

@@ -1,21 +1,22 @@
#include "log.h" #include "log.h"
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <pthread.h> #include <pthread.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
static FILE *logfile = NULL; static FILE *logfile = NULL;
static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER;
void log_init(void) { void log_init(void) {
logfile = fopen("/tmp/looper.log", "a"); logfile = fopen("./looper.log", "a");
if (!logfile) if (!logfile)
logfile = stderr; logfile = stderr;
setbuf(logfile, NULL); setbuf(logfile, NULL);
} }
void log_msg(const char *fmt, ...) { void log_msg(const char *fmt, ...) {
if (!logfile) return; if (!logfile)
return;
pthread_mutex_lock(&log_mutex); pthread_mutex_lock(&log_mutex);
va_list args; va_list args;
va_start(args, fmt); va_start(args, fmt);

View File

@@ -1,15 +1,18 @@
// cppcheck-suppress missingIncludeSystem // cppcheck-suppress missingIncludeSystem
#include "looper.h" #include "looper.h"
#include "channel.h" #include "channel.h"
#include "log.h"
#include "midi.h" #include "midi.h"
#include "pipe.h" #include "pipe.h"
#include "queue.h" #include "queue.h"
#include "wav.h" #include "wav.h"
#include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <jack/jack.h> #include <jack/jack.h>
#include <jack/midiport.h> #include <jack/midiport.h>
#include <math.h> #include <math.h>
#include <pthread.h> #include <pthread.h>
#include <signal.h>
#include <stdatomic.h> #include <stdatomic.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -27,15 +30,28 @@ spsc_queue_t cmd_queue_main_fifo;
/* writer status fd */ /* writer status fd */
static int status_fd = -1; static int status_fd = -1;
/* global sample rate (set during init) */
static int global_sample_rate = 0;
/* global JACK client pointer used by channel.c */
jack_client_t *global_client = NULL;
/* default filename for load/save */
/* ---------- prev_state moved before first user ---------- */
static atomic_int prev_state[MAX_CHANNELS][MAX_SCENES];
static void looper_write_status(void) { static void looper_write_status(void) {
if (status_fd < 0) if (status_fd < 0)
return; return;
char buf[256]; char buf[4096];
int pos = 0;
for (int ch = 0; ch < MAX_CHANNELS; ch++) { for (int ch = 0; ch < MAX_CHANNELS; ch++) {
if (!atomic_load(&channels[ch].active)) if (!atomic_load(&channels[ch].active))
continue; continue;
int sc_idx = atomic_load(&channels[ch].current_scene); int sc_idx = atomic_load(&channels[ch].current_scene);
int state = atomic_load(&channels[ch].scenes[sc_idx].state); int state = atomic_load(&channels[ch].scenes[sc_idx].state);
const char *state_str; const char *state_str;
switch (state) { switch (state) {
case STATE_IDLE: case STATE_IDLE:
@@ -53,16 +69,31 @@ static void looper_write_status(void) {
default: default:
state_str = "UNKNOWN"; state_str = "UNKNOWN";
} }
int n = snprintf(buf, sizeof(buf), "CH=%d SC=%d STATE=%s\n", ch, sc_idx, /* Always write state line */
state_str); int n = snprintf(buf + pos, sizeof(buf) - pos, "CH=%d SC=%d STATE=%s\n", ch,
if (n > 0) { sc_idx, state_str);
int ret = write(status_fd, buf, n); if (n > 0)
(void)ret; pos += n;
if (pos >= (int)sizeof(buf) - 128)
break;
/* Write RMS level line every time */
{
float level = atomic_load(&channels[ch].rms_level);
int n2 =
snprintf(buf + pos, sizeof(buf) - pos, "CH=%d LEVEL=%f\n", ch, level);
if (n2 > 0)
pos += n2;
if (pos >= (int)sizeof(buf) - 128)
break;
} }
} }
if (pos > 0) {
int ret = write(status_fd, buf, pos);
(void)ret;
}
} }
/* Global state (shared across files) */
struct channel_t channels[MAX_CHANNELS]; struct channel_t channels[MAX_CHANNELS];
atomic_int channel_count = 0; atomic_int channel_count = 0;
atomic_int channel_capacity = MAX_CHANNELS; atomic_int channel_capacity = MAX_CHANNELS;
@@ -76,11 +107,48 @@ jack_port_t *midi_clock_port = NULL;
atomic_int control_key_active = 0; atomic_int control_key_active = 0;
atomic_int bind_channel = 0; atomic_int bind_channel = 0;
/* Deferred removal index (1 second grace) */ static void looper_cleanup(jack_client_t *client) {
static int pending_unregister_idx = -1; for (int c = 0; c < MAX_CHANNELS; c++) {
if (channels[c].audio_in) {
jack_port_unregister(client, channels[c].audio_in);
channels[c].audio_in = NULL;
}
if (channels[c].audio_out) {
jack_port_unregister(client, channels[c].audio_out);
channels[c].audio_out = NULL;
}
if (channels[c].midi_in) {
jack_port_unregister(client, channels[c].midi_in);
channels[c].midi_in = NULL;
}
if (channels[c].midi_out) {
jack_port_unregister(client, channels[c].midi_out);
channels[c].midi_out = NULL;
}
}
if (midi_control_port) {
jack_port_unregister(client, midi_control_port);
midi_control_port = NULL;
}
if (midi_clock_port) {
jack_port_unregister(client, midi_clock_port);
midi_clock_port = NULL;
}
}
/* sample rate holder */ void looper_shutdown(jack_client_t *client) {
static int global_sample_rate = 0; jack_deactivate(client);
looper_cleanup(client);
jack_client_close(client);
log_close();
}
volatile int looper_quit = 0;
static void signal_handler(int sig) {
(void)sig;
looper_quit = 1;
}
static int pending_unregister_idx = -1;
/* execute a single command (called from looper_process_commands) */ /* execute a single command (called from looper_process_commands) */
static void exec_command(command_t cmd, jack_client_t *client) { static void exec_command(command_t cmd, jack_client_t *client) {
@@ -90,9 +158,40 @@ static void exec_command(command_t cmd, jack_client_t *client) {
switch (cmd.type) { switch (cmd.type) {
case CMD_CYCLE: { case CMD_CYCLE: {
int ch = cmd.channel;
if (ch < 0 || ch >= MAX_CHANNELS)
ch = 0;
// Save the desired scene (may have been set by CMD_SET_SCENE)
int requested_scene = atomic_load(&channels[ch].current_scene);
// Clamp requested_scene to valid range
if (requested_scene < 0)
requested_scene = 0;
if (requested_scene >= MAX_SCENES)
requested_scene = MAX_SCENES - 1;
// Auto-create channel if it doesn't exist
if (!channels[ch].active) {
channel_add(client, ch);
}
// Ensure enough scenes exist to satisfy requested_scene
int sc_count = atomic_load(&channels[ch].scene_count);
while (requested_scene >= sc_count && sc_count < MAX_SCENES) {
channel_add_scene(client, ch);
sc_count = atomic_load(&channels[ch].scene_count);
}
// Clamp requested_scene if MAX_SCENES prevents adding enough scenes
if (requested_scene >= sc_count)
requested_scene = sc_count - 1;
// Restore the requested scene (channel_add or add_scene may have reset
// current_scene)
atomic_store(&channels[ch].current_scene, requested_scene);
int sc_idx = atomic_load(&channels[ch].current_scene); int sc_idx = atomic_load(&channels[ch].current_scene);
scene_t *sc_ptr = &channels[ch].scenes[sc_idx]; scene_t *sc_ptr = &channels[ch].scenes[sc_idx];
int state = atomic_load(&sc_ptr->state); int state = atomic_load(&sc_ptr->state);
fprintf(stderr, "CMD_CYCLE: ch=%d old_state=%d\n", ch, state);
switch (state) { switch (state) {
case STATE_IDLE: case STATE_IDLE:
atomic_store(&sc_ptr->state, STATE_RECORD); atomic_store(&sc_ptr->state, STATE_RECORD);
@@ -171,6 +270,15 @@ static void exec_command(command_t cmd, jack_client_t *client) {
channel_prev_scene(client, ch); channel_prev_scene(client, ch);
break; break;
case CMD_SET_SCENE: {
int sc = cmd.data;
// Allow any scene index; scenes will be added by CMD_CYCLE if needed
if (sc >= 0) {
atomic_store(&channels[ch].current_scene, sc);
}
break;
}
default: default:
break; break;
} }
@@ -301,8 +409,19 @@ int process_callback(jack_nframes_t nframes, void *arg) {
if (!out) if (!out)
continue; continue;
if (c == 0 && !atomic_load(&channels[c].active)) {
fprintf(stderr, "CHANNEL0_NOT_ACTIVE during record\n");
}
switch (state) { switch (state) {
case STATE_RECORD: case STATE_RECORD:
if (c == 0 && atomic_load(&sc->record_pos) == 0) {
if (in) {
fprintf(stderr, "FIRST_INPUT_SAMPLE = %f\n", ((const float *)in)[0]);
} else {
fprintf(stderr, "FIRST_INPUT_SAMPLE = NULL\n");
}
}
if (in) { if (in) {
float *f_out = (float *)out; float *f_out = (float *)out;
const float *f_in = (const float *)in; const float *f_in = (const float *)in;
@@ -346,6 +465,21 @@ int process_callback(jack_nframes_t nframes, void *arg) {
break; break;
} }
/* Compute RMS level for this channel */
{
float sum_sq = 0.0f;
const float *f_out = (const float *)out;
for (jack_nframes_t i = 0; i < nframes; i++)
sum_sq += f_out[i] * f_out[i];
float rms = sqrtf(sum_sq / nframes);
atomic_store(&channels[c].rms_level, rms);
static float last_rms[MAX_CHANNELS] = {0};
if (rms > 0.001f && fabsf(rms - last_rms[c]) > 0.005f) {
fprintf(stderr, "RMS ch%d = %f\n", c, rms);
last_rms[c] = rms;
}
}
/* push loop output into save ring if saving (atomic load) */ /* push loop output into save ring if saving (atomic load) */
RingBuf *r = (RingBuf *)atomic_load_explicit(&channels[c].save_ring, RingBuf *r = (RingBuf *)atomic_load_explicit(&channels[c].save_ring,
memory_order_acquire); memory_order_acquire);
@@ -417,23 +551,32 @@ int looper_init(jack_client_t *client) {
/* store sample rate for writer thread */ /* store sample rate for writer thread */
global_sample_rate = jack_get_sample_rate(client); global_sample_rate = jack_get_sample_rate(client);
global_client = client;
/* Install signal handlers for graceful shutdown */
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
signal(SIGQUIT, signal_handler);
/* create status FIFO (ignore if already exists) */ /* create status FIFO (ignore if already exists) */
mkfifo(STATUS_FIFO, 0666); mkfifo(STATUS_FIFO, 0666);
/* open the status FIFO for reading+writing so writes work even without reader /* open the status FIFO for reading+writing so writes work even without reader
*/ */
status_fd = open(STATUS_FIFO, O_RDWR); status_fd = open(STATUS_FIFO, O_RDWR | O_NONBLOCK);
if (status_fd < 0) { if (status_fd < 0) {
perror("open status FIFO"); perror("open status FIFO");
} }
/* initialise prev_state to -1 */
for (int ch = 0; ch < MAX_CHANNELS; ch++)
for (int sc = 0; sc < MAX_SCENES; sc++)
atomic_init(&prev_state[ch][sc], -1);
queue_init(&cmd_queue); queue_init(&cmd_queue);
queue_init(&cmd_queue_main_midi); queue_init(&cmd_queue_main_midi);
queue_init(&cmd_queue_main_fifo); queue_init(&cmd_queue_main_fifo);
/* start the FIFO reader thread */
pipe_start_reader();
/* channel 0 */ /* channel 0 */
channels[0].active = 1; channels[0].active = 1;
channels[0].type = CHANNEL_AUDIO; /* default */ channels[0].type = CHANNEL_AUDIO; /* default */
@@ -447,9 +590,9 @@ int looper_init(jack_client_t *client) {
atomic_store(&channels[0].save_complete, 0); atomic_store(&channels[0].save_complete, 0);
channels[0].audio_in = jack_port_register( channels[0].audio_in = jack_port_register(
client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0); client, "ch0in", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
channels[0].audio_out = jack_port_register( channels[0].audio_out = jack_port_register(
client, "output", JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0); client, "ch0out", JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0);
if (!channels[0].audio_in || !channels[0].audio_out) { if (!channels[0].audio_in || !channels[0].audio_out) {
fprintf(stderr, "Could not create audio ports for channel 0\n"); fprintf(stderr, "Could not create audio ports for channel 0\n");
return -1; return -1;
@@ -471,6 +614,9 @@ int looper_init(jack_client_t *client) {
nanosleep(&req, NULL); nanosleep(&req, NULL);
} }
/* start the FIFO reader thread (after ports are registered) */
pipe_start_reader();
return 0; return 0;
} }
@@ -527,9 +673,9 @@ void looper_process_commands(jack_client_t *client) {
if (atomic_exchange(&cmd_load, 0)) { if (atomic_exchange(&cmd_load, 0)) {
float *buf = NULL; float *buf = NULL;
unsigned frames = 0; unsigned frames = 0;
printf("LOAD: wav_read called\n"); fprintf(stderr, "LOAD: wav_read called for %s\n", load_filename);
if (wav_read("loop.wav", &buf, &frames) == 0 && frames > 0) { if (wav_read(load_filename, &buf, &frames) == 0 && frames > 0) {
printf("LOAD: success, frames=%u\n", frames); fprintf(stderr, "LOAD: success, frames=%u\n", frames);
int sc_idx = atomic_load(&channels[0].current_scene); int sc_idx = atomic_load(&channels[0].current_scene);
scene_t *sc = &channels[0].scenes[sc_idx]; scene_t *sc = &channels[0].scenes[sc_idx];
if (frames > LOOP_BUF_SIZE) if (frames > LOOP_BUF_SIZE)
@@ -542,8 +688,8 @@ void looper_process_commands(jack_client_t *client) {
atomic_store(&sc->prev_state, -1); atomic_store(&sc->prev_state, -1);
free(buf); free(buf);
} else { } else {
fprintf(stderr, "Failed to load loop.wav\n"); fprintf(stderr, "Failed to load %s\n", load_filename);
printf("LOAD: FAILED\n"); fprintf(stderr, "LOAD: FAILED\n");
} }
} }
@@ -552,7 +698,17 @@ void looper_process_commands(jack_client_t *client) {
int sc_idx = atomic_load(&channels[0].current_scene); int sc_idx = atomic_load(&channels[0].current_scene);
scene_t *sc = &channels[0].scenes[sc_idx]; scene_t *sc = &channels[0].scenes[sc_idx];
int lc = atomic_load(&sc->loop_count); int lc = atomic_load(&sc->loop_count);
if (atomic_load(&sc->state) == STATE_LOOPING && lc > 0) { int rp = atomic_load(&sc->record_pos);
int state = atomic_load(&sc->state);
printf("SAVE debug: state=%d loop_count=%d record_pos=%d\n", state, lc, rp);
/* Allow save from any state where we have data */
int frames_to_save = 0;
if ((state == STATE_LOOPING || state == STATE_PAUSED) && lc > 0) {
frames_to_save = lc;
} else if (state == STATE_RECORD && rp > 0) {
frames_to_save = rp;
}
if (frames_to_save > 0) {
/* Deactivate channel to prevent RT thread from reading the buffer */ /* Deactivate channel to prevent RT thread from reading the buffer */
int was_active = atomic_load(&channels[0].active); int was_active = atomic_load(&channels[0].active);
if (was_active) { if (was_active) {
@@ -561,13 +717,19 @@ void looper_process_commands(jack_client_t *client) {
nanosleep(&req, NULL); nanosleep(&req, NULL);
} }
/* Now safe to copy the loop buffer */ /* Now safe to copy the loop buffer */
float *data = malloc((size_t)lc * sizeof(float)); float *data = malloc((size_t)frames_to_save * sizeof(float));
if (data) { if (data) {
memcpy(data, sc->loop.audio_buffer, (size_t)lc * sizeof(float)); memcpy(data, sc->loop.audio_buffer,
(size_t)frames_to_save * sizeof(float));
unsigned sr = (unsigned)global_sample_rate; unsigned sr = (unsigned)global_sample_rate;
if (sr == 0) if (sr == 0)
sr = 48000; sr = 48000;
wav_write("save.wav", data, (unsigned)lc, sr); char save_path[256];
snprintf(save_path, sizeof(save_path), "save.wav");
printf("SAVE: writing %u frames, first sample = %f\n",
(unsigned)frames_to_save, data[0]);
int ret = wav_write(save_path, data, (unsigned)frames_to_save, sr);
printf("SAVE: wav_write returned %d\n", ret);
free(data); free(data);
} }
/* Reactivate channel use a shorter sleep to reduce xrun risk */ /* Reactivate channel use a shorter sleep to reduce xrun risk */
@@ -576,6 +738,8 @@ void looper_process_commands(jack_client_t *client) {
nanosleep(&req, NULL); nanosleep(&req, NULL);
atomic_store(&channels[0].active, 1); atomic_store(&channels[0].active, 1);
} }
} else {
printf("SAVE: condition not met, state=%d lc=%d rp=%d\n", state, lc, rp);
} }
} }

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,6 +1,7 @@
// cppcheck-suppress missingIncludeSystem // cppcheck-suppress missingIncludeSystem
#include "looper.h"
#include "log.h" #include "log.h"
#include "looper.h"
#include "pipe.h"
#include <jack/jack.h> #include <jack/jack.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -47,17 +48,23 @@ int main(int argc, char *argv[]) {
return 1; return 1;
} }
if (pipe_start_reader() != 0) {
log_msg("pipe_start_reader() failed");
jack_client_close(client);
log_close();
return 1;
}
log_msg("looper running (client name '%s')", client_name); log_msg("looper running (client name '%s')", client_name);
while (1) { while (!looper_quit) {
looper_process_commands(client); looper_process_commands(client);
{ {
struct timespec ts = {.tv_sec = 0, .tv_nsec = 50000000}; struct timespec ts = {.tv_sec = 0, .tv_nsec = 10000000};
nanosleep(&ts, NULL); nanosleep(&ts, NULL);
} }
} }
jack_client_close(client); looper_shutdown(client);
log_close();
return 0; return 0;
} }

View File

@@ -3,6 +3,7 @@
#include "queue.h" #include "queue.h"
#include <errno.h> #include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <jack/jack.h>
#include <pthread.h> #include <pthread.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -11,9 +12,14 @@
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.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;
@@ -37,55 +43,87 @@ static void *pipe_thread_func(void *arg) {
if (strcmp(line, "add") == 0) { if (strcmp(line, "add") == 0) {
command_t cmd = {.type = CMD_ADD_CHANNEL, .channel = -1, .data = 0}; command_t cmd = {.type = CMD_ADD_CHANNEL, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "add_midi") == 0) { } else if (strcmp(line, "add_midi") == 0) {
command_t cmd = { command_t cmd = {
.type = CMD_ADD_MIDI_CHANNEL, .channel = -1, .data = 0}; .type = CMD_ADD_MIDI_CHANNEL, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "remove") == 0) { } else if (strcmp(line, "remove") == 0) {
command_t cmd = {.type = CMD_REMOVE_CHANNEL, .channel = -1, .data = 0}; command_t cmd = {.type = CMD_REMOVE_CHANNEL, .channel = -1, .data = 0};
queue_push(&cmd_queue, 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, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "scene_remove") == 0) { } else if (strcmp(line, "scene_remove") == 0) {
command_t cmd = {.type = CMD_REMOVE_SCENE, .channel = -1, .data = 0}; command_t cmd = {.type = CMD_REMOVE_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "scene_next") == 0) { } else if (strcmp(line, "scene_next") == 0) {
command_t cmd = {.type = CMD_NEXT_SCENE, .channel = -1, .data = 0}; command_t cmd = {.type = CMD_NEXT_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} 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, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "load") == 0) { } 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}; command_t cmd = {.type = CMD_LOAD, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd); queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "save") == 0) { } else if (strcmp(line, "save") == 0) {
command_t cmd = {.type = CMD_SAVE, .channel = -1, .data = 0}; command_t cmd = {.type = CMD_SAVE, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd); 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

@@ -10,7 +10,7 @@
* 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];

View File

@@ -1018,17 +1018,19 @@ static int test_wav_save(void) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0); kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1; return 1;
} }
/* FIFO: record channel 0, then stop to create a loop */
/* Use FIFO command to start recording */
if (send_fifo_command("record 0") != 0) { if (send_fifo_command("record 0") != 0) {
jack_client_close(client); jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0); kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1; return 1;
} }
safe_usleep(200000); safe_usleep(200000);
/* start generating a beep */
/* Set up beep generation for 3 seconds */
int sr = jack_get_sample_rate(client); int sr = jack_get_sample_rate(client);
continuous_sine = 0; continuous_sine = 0;
beep_remaining = (int)(0.5f * sr); beep_remaining = (int)(3.0f * sr);
bursts = 0; prev_above = 0; bursts = 0; prev_above = 0;
passthrough_output_port = audio_out; passthrough_output_port = audio_out;
passthrough_input_port = audio_in; passthrough_input_port = audio_in;
@@ -1040,23 +1042,23 @@ static int test_wav_save(void) {
passthrough_done = 0; passthrough_done = 0;
jack_set_process_callback(client, passthrough_process, NULL); jack_set_process_callback(client, passthrough_process, NULL);
if (jack_activate(client)) { if (jack_activate(client)) {
jack_deactivate(client);
jack_client_close(client); jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0); kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1; return 1;
} }
safe_usleep(3000000); /* record for 3s (ensure enough beep) */ safe_usleep(3000000); /* record for 3s (ensure enough beep) */
/* Send second record command to transition RECORD → LOOPING */ /* Second FIFO command to transition RECORD → LOOPING */
if (send_fifo_command("record 0") != 0) { if (send_fifo_command("record 0") != 0) {
jack_deactivate(client); jack_deactivate(client);
jack_client_close(client); jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0); kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1; return 1;
} }
safe_usleep(1000000); /* give time for state change and loop_count to be set */ safe_usleep(3000000); /* give time for state change and loop_count to be set */
/* save */ /* save via FIFO command */
if (send_fifo_command("save") != 0) { if (send_fifo_command("save") != 0) {
jack_deactivate(client); jack_deactivate(client);
jack_client_close(client); jack_client_close(client);

View File

@@ -4,7 +4,9 @@ CC ?= gcc
SUBDIRS = engine client SUBDIRS = engine client
.PHONY: all build clean test check format orchestrator run $(SUBDIRS) VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo "0.0.0")
.PHONY: all build clean test check format orchestrator run e2e package $(SUBDIRS)
all: build orchestrator all: build orchestrator
@@ -14,15 +16,45 @@ build: $(SUBDIRS)
orchestrator: orchestrator.c orchestrator: orchestrator.c
$(CC) -Wall -Wextra -std=c11 -o looper 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 run: orchestrator
./looper ./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 rm -f looper

View File

@@ -5,6 +5,7 @@
*/ */
#define _GNU_SOURCE #define _GNU_SOURCE
#define _POSIX_C_SOURCE 200809L #define _POSIX_C_SOURCE 200809L
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <unistd.h> #include <unistd.h>

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 */
}