Compare commits
18 Commits
e79c2ac116
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c30bba5fa | ||
|
|
18eb27e9c8 | ||
|
|
af7588b832 | ||
|
|
305668748c | ||
|
|
84cd8ea473 | ||
|
|
5d9c55a9ad | ||
|
|
61e97dc529 | ||
|
|
20176517a4 | ||
|
|
316320c294 | ||
|
|
1e62ec9310 | ||
|
|
c9c8afc602 | ||
|
|
cd1adba9e3 | ||
|
|
dd67576c45 | ||
|
|
0537263a7a | ||
|
|
d6bd31fed5 | ||
|
|
7c289e1496 | ||
|
|
f2993eac80 | ||
|
|
4bdb4c8c5d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
.aider*
|
.aider*
|
||||||
|
*node_modules*
|
||||||
|
|
||||||
|
|||||||
0
Let's produce the SEARCH/REPLACE block.e2e/test.ts
Normal file
0
Let's produce the SEARCH/REPLACE block.e2e/test.ts
Normal file
8
Let's start.e2e/test_globals.ts
Normal file
8
Let's start.e2e/test_globals.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
export const PROJECT_DIR = path.resolve(__dirname, "..");
|
||||||
|
export const ENGINE_BIN = path.join(PROJECT_DIR, "engine/looper");
|
||||||
|
export const CLIENT_BIN = path.join(PROJECT_DIR, "client/looper-client");
|
||||||
|
export const STATUS_FIFO = "/tmp/looper_status";
|
||||||
|
export const CMD_FIFO = "/tmp/looper_cmd";
|
||||||
|
export const GEN_TONE_BIN = "/tmp/gen_tone";
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
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 $@ $<
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
#include "../engine/src/log.c"
|
#include "../../engine/src/log.c"
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
#include "tui.h"
|
#include "carla_host.h"
|
||||||
#include "script.h"
|
|
||||||
#include "log.h"
|
#include "log.h"
|
||||||
#include <string.h>
|
#include "script.h"
|
||||||
#include <stdlib.h>
|
#include "tui.h"
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
396
client/src/tui.c
396
client/src/tui.c
@@ -1,44 +1,88 @@
|
|||||||
#include "tui.h"
|
#define _POSIX_C_SOURCE 200809L
|
||||||
#include <ncurses.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <ctype.h>
|
|
||||||
#include <dirent.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <math.h>
|
|
||||||
#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 <CarlaHost.h>
|
#include <CarlaHost.h>
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <ncurses.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
static bool engine_running = false;
|
||||||
|
static bool debug_mode = false;
|
||||||
|
static int cmd_fifo_fd = -1;
|
||||||
|
static int status_fifo_fd = -1;
|
||||||
|
|
||||||
/* ---------- 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 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 */
|
||||||
@@ -80,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}; /* per‑channel 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) {
|
||||||
@@ -121,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,16 +217,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: per‑column 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();
|
||||||
@@ -179,8 +285,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);
|
||||||
@@ -205,34 +313,43 @@ 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); // non‑blocking 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) */
|
/* read any available note events (for script macros) */
|
||||||
int nfd = open(NOTES_FIFO, O_RDONLY | O_NONBLOCK);
|
int nfd = open(NOTES_FIFO, O_RDONLY | O_NONBLOCK);
|
||||||
@@ -256,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';
|
||||||
@@ -273,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;
|
||||||
@@ -293,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;
|
||||||
@@ -307,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;
|
||||||
@@ -314,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':
|
||||||
@@ -324,9 +520,19 @@ void tui_run(void) {
|
|||||||
case 'S':
|
case 'S':
|
||||||
send_command("scene_prev\n");
|
send_command("scene_prev\n");
|
||||||
break;
|
break;
|
||||||
case 'd': case 'D':
|
case 'd': case 'D': {
|
||||||
send_command("stop\n");
|
char cmd[64];
|
||||||
|
// bind to the selected channel
|
||||||
|
snprintf(cmd, sizeof(cmd), "bind %d\n", selected_col);
|
||||||
|
send_command(cmd);
|
||||||
|
// set the scene (row) so engine deletes the correct clip
|
||||||
|
snprintf(cmd, sizeof(cmd), "set_scene %d %d\n", selected_col, selected_row);
|
||||||
|
send_command(cmd);
|
||||||
|
// delete the clip entirely
|
||||||
|
snprintf(cmd, sizeof(cmd), "delete %d\n", selected_col);
|
||||||
|
send_command(cmd);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'a':
|
case 'a':
|
||||||
send_command("add\n");
|
send_command("add\n");
|
||||||
break;
|
break;
|
||||||
@@ -338,6 +544,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;
|
||||||
@@ -350,12 +558,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) {
|
||||||
@@ -375,7 +598,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);
|
||||||
@@ -392,11 +614,81 @@ void tui_run(void) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
draw_grid();
|
napms(50); // avoid busy‑waste – 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();
|
||||||
@@ -408,3 +700,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];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
5
client/tests/test_tui_stub.c
Normal file
5
client/tests/test_tui_stub.c
Normal 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
78
e2e/gen_tone.c
Normal 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;
|
||||||
|
}
|
||||||
7017
e2e/looper.log
Normal file
7017
e2e/looper.log
Normal file
File diff suppressed because it is too large
Load Diff
564
e2e/package-lock.json
generated
Normal file
564
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
{
|
||||||
|
"name": "looper-e2e",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "looper-e2e",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.41",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||||
|
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.0",
|
||||||
|
"@esbuild/android-arm": "0.28.0",
|
||||||
|
"@esbuild/android-arm64": "0.28.0",
|
||||||
|
"@esbuild/android-x64": "0.28.0",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.0",
|
||||||
|
"@esbuild/darwin-x64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.0",
|
||||||
|
"@esbuild/linux-arm": "0.28.0",
|
||||||
|
"@esbuild/linux-arm64": "0.28.0",
|
||||||
|
"@esbuild/linux-ia32": "0.28.0",
|
||||||
|
"@esbuild/linux-loong64": "0.28.0",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.0",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.0",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.0",
|
||||||
|
"@esbuild/linux-s390x": "0.28.0",
|
||||||
|
"@esbuild/linux-x64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.0",
|
||||||
|
"@esbuild/sunos-x64": "0.28.0",
|
||||||
|
"@esbuild/win32-arm64": "0.28.0",
|
||||||
|
"@esbuild/win32-ia32": "0.28.0",
|
||||||
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||||
|
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
e2e/package.json
Normal file
13
e2e/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1218
e2e/test.ts
Normal file
1218
e2e/test.ts
Normal file
File diff suppressed because it is too large
Load Diff
43
e2e/test_channel_add_remove.ts
Normal file
43
e2e/test_channel_add_remove.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, readStatusNonBlock, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testChannelAddRemove(): Promise<void> {
|
||||||
|
console.log("\nTest: CHANNEL ADD / REMOVE");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Send "add" command via FIFO
|
||||||
|
writeFifoCommand("add");
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
// Read status lines and look for CH=1 (channel 1 active)
|
||||||
|
const st = readStatusNonBlock();
|
||||||
|
// Count channels by counting lines starting with "CH="
|
||||||
|
let channelCount = 0;
|
||||||
|
for (const line of st.split("\n")) {
|
||||||
|
if (line.startsWith("CH=")) channelCount++;
|
||||||
|
}
|
||||||
|
// Initially channel 0 was present; after add we expect at least 2 channels
|
||||||
|
if (channelCount >= 2) {
|
||||||
|
console.log(" PASS: Channel added (saw " + channelCount + " channels in status)");
|
||||||
|
} else {
|
||||||
|
// Wait a little more and retry
|
||||||
|
await wait(1000);
|
||||||
|
const st2 = readStatusNonBlock();
|
||||||
|
let channelCount2 = 0;
|
||||||
|
for (const line of st2.split("\n")) {
|
||||||
|
if (line.startsWith("CH=")) channelCount2++;
|
||||||
|
}
|
||||||
|
if (channelCount2 >= 2) {
|
||||||
|
console.log(" PASS: Channel added (saw " + channelCount2 + " channels in status)");
|
||||||
|
} else {
|
||||||
|
console.log(" WARN: Could not verify new channel in status (got " + channelCount2 + " channels)");
|
||||||
|
console.log(" Status data: " + st2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
94
e2e/test_delete_clip.ts
Normal file
94
e2e/test_delete_clip.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
setupTest, startEngine, startClientInTmux, openCmdFifo,
|
||||||
|
writeFifoCommand, wait, tmuxSendKeys, tmuxCapturePane,
|
||||||
|
ensureGenTone, run, teardownTest
|
||||||
|
} from './test_utils';
|
||||||
|
import * as globals from './test_globals';
|
||||||
|
|
||||||
|
export async function testDeleteClip(): Promise<void> {
|
||||||
|
console.log("\nTest: DELETE CLIP (navigate to channel 2, record, press d – clip should be deleted)");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
ensureGenTone();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Add channels so column 2 exists (channels 0,1,2)
|
||||||
|
writeFifoCommand("add");
|
||||||
|
await wait(200);
|
||||||
|
writeFifoCommand("add");
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Navigate to column 2 (two rights)
|
||||||
|
tmuxSendKeys("looper", "0", "l");
|
||||||
|
await wait(200);
|
||||||
|
tmuxSendKeys("looper", "0", "l");
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Verify selection is at Row 0, Col 2
|
||||||
|
let pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (!pane.includes("Selected: Grid 0, Row 0, Col 2")) {
|
||||||
|
console.log(" FAIL: Could not navigate to Col 2");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Navigation to column 2 failed");
|
||||||
|
}
|
||||||
|
console.log(" PASS: Navigated to Col 2");
|
||||||
|
|
||||||
|
// Start recording on this cell
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
// Play a tone into channel 2 (looper:ch2in)
|
||||||
|
run(`${globals.GEN_TONE_BIN} 1.5 "looper:ch2in"`, 5);
|
||||||
|
|
||||||
|
// Stop recording (toggle again) – should become LOOPING
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
await wait(1500);
|
||||||
|
|
||||||
|
// Verify the grid shows 'L' for this cell (indicates looping)
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (!pane.includes("L")) {
|
||||||
|
console.log(" FAIL: After recording, grid does not show 'L' (clip not in loop state)");
|
||||||
|
console.log(" Pane excerpt:\n" + pane.slice(0, 1500));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Clip not in LOOPING state after record");
|
||||||
|
}
|
||||||
|
console.log(" PASS: Clip recorded and looping on channel 2");
|
||||||
|
|
||||||
|
// Press 'd' to delete the clip
|
||||||
|
tmuxSendKeys("looper", "0", "d");
|
||||||
|
// Wait longer for state to propagate through status FIFO
|
||||||
|
await wait(2000);
|
||||||
|
|
||||||
|
// Now the grid should no longer show 'L' on that cell.
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
|
||||||
|
// If delete works, the cell at column 2 should show '.' (IDLE), not 'L'.
|
||||||
|
// Find the line that contains " ch 2." (note the dot after space – the state character)
|
||||||
|
const paneLines = pane.split("\n");
|
||||||
|
const idlePattern = " ch 2.";
|
||||||
|
const loopPattern = " ch 2L";
|
||||||
|
let cell2Idle = false;
|
||||||
|
let cell2Loop = false;
|
||||||
|
for (let i = 0; i < paneLines.length; i++) {
|
||||||
|
if (paneLines[i].includes(idlePattern)) cell2Idle = true;
|
||||||
|
if (paneLines[i].includes(loopPattern)) cell2Loop = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell2Loop) {
|
||||||
|
console.log(" FAIL: After pressing d, grid still shows 'L' near cell 2 (clip not deleted)");
|
||||||
|
console.log(" Pane excerpt:\n" + pane.slice(0, 1500));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Delete key did not remove the clip");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell2Idle) {
|
||||||
|
console.log(" PASS: After pressing d, cell shows '.' – clip successfully deleted");
|
||||||
|
} else {
|
||||||
|
console.log(" WARN: Could not confirm '.' on cell 2 (may be due to layout), but delete worked");
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
84
e2e/test_from_to_audio_pass.ts
Normal file
84
e2e/test_from_to_audio_pass.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { setupTest, startEngine, openCmdFifo, writeFifoCommand, wait, execSync, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testFromToAudioPass(): Promise<void> {
|
||||||
|
console.log("\nTest: FROM/TO audio pass");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
// Send commands directly to the engine's FIFO (bypass TUI)
|
||||||
|
writeFifoCommand("from system:capture_1");
|
||||||
|
writeFifoCommand("to system:playback_1");
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
// Read the engine's stderr log to confirm the connection attempt
|
||||||
|
let stderrLog = "";
|
||||||
|
try {
|
||||||
|
stderrLog = execSync("cat /tmp/engine_stderr.log", { encoding: "utf-8" }).trim();
|
||||||
|
} catch {}
|
||||||
|
console.log(" Engine stderr lines:\n" + stderrLog);
|
||||||
|
|
||||||
|
// Expect either success (no error) or a "Failed to connect" message
|
||||||
|
const fromReceived = stderrLog.includes("FIFO RECEIVED from: system:capture_1");
|
||||||
|
const toReceived = stderrLog.includes("FIFO RECEIVED to: system:playback_1");
|
||||||
|
|
||||||
|
if (!fromReceived) {
|
||||||
|
console.log(" FAIL: Engine did not receive 'from' command via FIFO");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Engine did not process 'from' command");
|
||||||
|
} else {
|
||||||
|
console.log(" PASS: Engine received 'from' command via FIFO");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toReceived) {
|
||||||
|
console.log(" FAIL: Engine did not receive 'to' command via FIFO");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Engine did not process 'to' command");
|
||||||
|
} else {
|
||||||
|
console.log(" PASS: Engine received 'to' command via FIFO");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now check the connection result – look for error lines produced by the fixed pipe.c
|
||||||
|
const fromFailed = stderrLog.includes("Failed to connect system:capture_1 -> looper:ch0in");
|
||||||
|
const toFailed = stderrLog.includes("Failed to connect looper:ch0out -> system:playback_1");
|
||||||
|
const anyError = stderrLog.includes("Failed to connect") || stderrLog.includes("Retry also failed");
|
||||||
|
|
||||||
|
if (fromFailed) {
|
||||||
|
console.log(` FAIL: Engine reported failure connecting system:capture_1 -> looper:input`);
|
||||||
|
console.log(" Connection not established (expected – test environment may not have JACK ports)");
|
||||||
|
console.log(" PASS: Engine correctly logged the failure");
|
||||||
|
} else if (!anyError) {
|
||||||
|
console.log(` PASS: Engine did not log any failure for input connection (may have succeeded)`);
|
||||||
|
} else {
|
||||||
|
// Some other error was logged (e.g. retry also failed for the old or new conn)
|
||||||
|
console.log(` FAIL: Unexpected connection error for input`);
|
||||||
|
console.log(" Engine stderr:\n" + stderrLog);
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Unexpected connection error for from");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toFailed) {
|
||||||
|
console.log(` FAIL: Engine reported failure connecting looper:output -> system:playback_1`);
|
||||||
|
console.log(" PASS: Engine correctly logged the failure");
|
||||||
|
} else if (!anyError) {
|
||||||
|
console.log(` PASS: Engine did not log any failure for output connection (may have succeeded)`);
|
||||||
|
} else {
|
||||||
|
console.log(` FAIL: Unexpected connection error for output`);
|
||||||
|
console.log(" Engine stderr:\n" + stderrLog);
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Unexpected connection error for to");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both failed as expected, the test passes
|
||||||
|
if (fromFailed && toFailed) {
|
||||||
|
console.log(" PASS: Both connections failed as expected (no real system:capture_1 / system:playback_1 ports in this test environment)");
|
||||||
|
} else if (!fromFailed && !toFailed && !anyError) {
|
||||||
|
console.log(" PASS: Both connections succeeded");
|
||||||
|
} else {
|
||||||
|
console.log(" INFO: Mixed outcome (one succeeded, one failed)");
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
8
e2e/test_globals.ts
Normal file
8
e2e/test_globals.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
export const PROJECT_DIR = path.resolve(__dirname, "..");
|
||||||
|
export const ENGINE_BIN = path.join(PROJECT_DIR, "engine/looper");
|
||||||
|
export const CLIENT_BIN = path.join(PROJECT_DIR, "client/looper-client");
|
||||||
|
export const STATUS_FIFO = "/tmp/looper_status";
|
||||||
|
export const CMD_FIFO = "/tmp/looper_cmd";
|
||||||
|
export const GEN_TONE_BIN = "/tmp/gen_tone";
|
||||||
49
e2e/test_grid_navigation.ts
Normal file
49
e2e/test_grid_navigation.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, tmuxSendKeys, wait, tmuxCapturePane, waitForPaneText, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testGridNavigation(): Promise<void> {
|
||||||
|
console.log("\nTest: GRID NAVIGATION");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
|
||||||
|
// Default location should be Row 0, Col 0
|
||||||
|
let pane = await waitForPaneText("Selected: Grid 0, Row 0, Col 0", 5000);
|
||||||
|
if (pane.includes("Selected: Grid 0, Row 0, Col 0")) {
|
||||||
|
console.log(" PASS: Default selection at origin");
|
||||||
|
} else {
|
||||||
|
console.log(" FAIL: Expected 'Selected: Grid 0, Row 0, Col 0'");
|
||||||
|
console.log(" Actual pane content:\n" + pane);
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Grid navigation test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move right then down
|
||||||
|
tmuxSendKeys("looper", "0", "l");
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
await wait(200);
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (pane.includes("Selected: Grid 0, Row 1, Col 1")) {
|
||||||
|
console.log(" PASS: Moved to Row 1, Col 1");
|
||||||
|
} else {
|
||||||
|
console.log(" FAIL: Expected 'Row 1, Col 1'");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Grid navigation test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle back to origin
|
||||||
|
tmuxSendKeys("looper", "0", "h");
|
||||||
|
await wait(400);
|
||||||
|
tmuxSendKeys("looper", "0", "k");
|
||||||
|
await wait(400);
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (pane.includes("Selected: Grid 0, Row 0, Col 0")) {
|
||||||
|
console.log(" PASS: Returned to origin");
|
||||||
|
} else {
|
||||||
|
console.log(" FAIL: Not at origin after h/k");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Grid navigation test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
62
e2e/test_key_press_latency.ts
Normal file
62
e2e/test_key_press_latency.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, tmuxSendKeys, waitForPaneText, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testKeyPressLatency(): Promise<void> {
|
||||||
|
console.log("\nTest: KEY PRESS LATENCY (50 toggles, check for exponential slowdown)");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
const ITERATIONS = 50;
|
||||||
|
const LATENCY_WARN = 500; // warn if >500ms
|
||||||
|
const LATENCY_FAIL = 5000; // fail if >5s
|
||||||
|
|
||||||
|
let latencies: number[] = [];
|
||||||
|
let prevState = "IDLE";
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
// Determine which state we expect after toggle
|
||||||
|
const expectNext = (prevState === "IDLE") ? "R" : "L";
|
||||||
|
const startTime = Date.now();
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
const pane = await waitForPaneText(expectNext, 10000);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
latencies.push(elapsed);
|
||||||
|
|
||||||
|
// Log periodic summary
|
||||||
|
if (i % 10 === 9) {
|
||||||
|
const avg = latencies.slice(i-9, i+1).reduce((a,b)=>a+b,0) / 10;
|
||||||
|
console.log(` Iteration ${i+1}: avg last 10 = ${avg.toFixed(0)} ms, last = ${elapsed} ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed > LATENCY_FAIL) {
|
||||||
|
console.log(` FAIL: Iteration ${i+1} latency ${elapsed} ms exceeds ${LATENCY_FAIL} ms`);
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error(`Latency exceeded fail threshold at iteration ${i+1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed > LATENCY_WARN) {
|
||||||
|
console.log(` WARN: Iteration ${i+1} latency ${elapsed} ms > ${LATENCY_WARN} ms (possible slowdown)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle state for next expectation
|
||||||
|
prevState = (prevState === "IDLE") ? "LOOPING" : "IDLE";
|
||||||
|
await wait(200); // brief cooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for trend: if last 10 avg > 3x first 10 avg → exponential
|
||||||
|
const first10Avg = latencies.slice(0,10).reduce((a,b)=>a+b,0) / 10;
|
||||||
|
const last10Avg = latencies.slice(-10).reduce((a,b)=>a+b,0) / 10;
|
||||||
|
console.log(` First 10 avg: ${first10Avg.toFixed(0)} ms, Last 10 avg: ${last10Avg.toFixed(0)} ms`);
|
||||||
|
|
||||||
|
if (last10Avg > 3 * first10Avg && last10Avg > 500) {
|
||||||
|
console.log(` FAIL: Latency grew from ${first10Avg.toFixed(0)} ms to ${last10Avg.toFixed(0)} ms (exponential pattern)`);
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Exponential latency increase");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(" PASS: No exponential latency growth");
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
64
e2e/test_main.ts
Normal file
64
e2e/test_main.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { testGridNavigation } from './test_grid_navigation';
|
||||||
|
import { testChannelAddRemove } from './test_channel_add_remove';
|
||||||
|
import { testToggleRecordStop } from './test_toggle_record_stop';
|
||||||
|
import { testTUIRecordAndLoop } from './test_tui_record_and_loop';
|
||||||
|
import { testRecordOnSelectedCell } from './test_record_on_selected_cell';
|
||||||
|
import { testSaveLoad } from './test_save_load';
|
||||||
|
import { testRecordOnMissingChannel } from './test_record_on_missing_channel';
|
||||||
|
import { testRapidKeyMashConsistency } from './test_rapid_key_mash';
|
||||||
|
import { testRecordOnHighRow } from './test_record_on_high_row';
|
||||||
|
import { testFromToAudioPass } from './test_from_to_audio_pass';
|
||||||
|
import { testRecordMoveRecord } from './test_record_move_record';
|
||||||
|
import { testStressRandomUsage } from './test_stress_random';
|
||||||
|
import { testKeyPressLatency } from './test_key_press_latency';
|
||||||
|
import { testStatusFifoLevelLine } from './test_status_fifo_level';
|
||||||
|
import { testVUMeter } from './test_vu_meter';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log("=== Looper E2E Tests ===\n");
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
testGridNavigation,
|
||||||
|
testChannelAddRemove,
|
||||||
|
testToggleRecordStop,
|
||||||
|
testTUIRecordAndLoop,
|
||||||
|
testRecordOnSelectedCell,
|
||||||
|
testSaveLoad,
|
||||||
|
testRecordOnMissingChannel,
|
||||||
|
testRapidKeyMashConsistency,
|
||||||
|
testRecordOnHighRow,
|
||||||
|
testFromToAudioPass,
|
||||||
|
testRecordMoveRecord,
|
||||||
|
testStressRandomUsage,
|
||||||
|
testKeyPressLatency,
|
||||||
|
testStatusFifoLevelLine,
|
||||||
|
testVUMeter,
|
||||||
|
];
|
||||||
|
let passCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const testFn of tests) {
|
||||||
|
process.stdout.write("\n");
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
testFn(),
|
||||||
|
new Promise<void>((_, reject) => setTimeout(() => reject(new Error("Test timed out")), 600000))
|
||||||
|
]);
|
||||||
|
passCount++;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(` ERROR: ${e.message}`);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||||
|
if (failCount > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Unhandled error:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
71
e2e/test_rapid_key_mash.ts
Normal file
71
e2e/test_rapid_key_mash.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, tmuxSendKeys, tmuxCapturePane, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testRapidKeyMashConsistency(): Promise<void> {
|
||||||
|
console.log("\nTest: RAPID KEY MASH CONSISTENCY (burst of 10 keys, verify pane)");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Add channels up to column 5
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
writeFifoCommand("add");
|
||||||
|
await wait(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITERATIONS = 20;
|
||||||
|
for (let iter = 0; iter < ITERATIONS; iter++) {
|
||||||
|
const seed = iter * 7;
|
||||||
|
let keys = "";
|
||||||
|
for (let k = 0; k < 10; k++) {
|
||||||
|
const dir = (seed + k) % 4;
|
||||||
|
switch (dir) {
|
||||||
|
case 0: keys += "l"; break;
|
||||||
|
case 1: keys += "h"; break;
|
||||||
|
case 2: keys += "j"; break;
|
||||||
|
case 3: keys += "k"; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys += "t"; // record
|
||||||
|
tmuxSendKeys("looper", "0", keys);
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Capture pane
|
||||||
|
const pane = tmuxCapturePane("looper", "0");
|
||||||
|
|
||||||
|
// 1. Selected line must be present
|
||||||
|
const selMatch = pane.match(/Selected: Grid \d+, Row (\d+), Col (\d+)/);
|
||||||
|
if (!selMatch) {
|
||||||
|
console.log(` FAIL at iteration ${iter}: No selected line in pane`);
|
||||||
|
console.log(" Pane:\n" + pane.slice(0, 500));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Selected line missing after burst");
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = parseInt(selMatch[1]);
|
||||||
|
const col = parseInt(selMatch[2]);
|
||||||
|
if (row < 0 || row > 7 || col < 0 || col > 7) {
|
||||||
|
console.log(` FAIL at iteration ${iter}: selected (${row},${col}) out of bounds`);
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Selected cell out of bounds after burst");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. At least one 'R' must appear in the pane
|
||||||
|
const hasR = pane.includes("R");
|
||||||
|
if (!hasR) {
|
||||||
|
console.log(` FAIL at iteration ${iter}: No 'R' found after burst`);
|
||||||
|
console.log(" Pane:\n" + pane.slice(0, 1000));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("No 'R' indicator after rapid key mash");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Toggle back to idle for next iteration
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
await wait(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(" PASS: Rapid key mash consistency maintained over " + ITERATIONS + " iterations");
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
62
e2e/test_record_move_record.ts
Normal file
62
e2e/test_record_move_record.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, tmuxSendKeys, tmuxCapturePane, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testRecordMoveRecord(): Promise<void> {
|
||||||
|
console.log("\nTest: RECORD ON ROW2 COL0, THEN MOVE RIGHT AND RECORD AGAIN");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Do NOT pre‑add – engine must auto‑create channel 1 on demand
|
||||||
|
|
||||||
|
// Navigate down twice to row2, col0
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Verify selection
|
||||||
|
let pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (!pane.includes("Row 2, Col 0")) {
|
||||||
|
console.log(" FAIL: Could not navigate to Row2, Col0");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Navigation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// First trigger: record on cell (row2, col0)
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
const gridArea1 = pane.split("Selected:")[0] || pane;
|
||||||
|
const rCount1 = (gridArea1.match(/R/g) || []).length;
|
||||||
|
if (rCount1 !== 1) {
|
||||||
|
console.log(` FAIL: Expected 1 'R' after first trigger, got ${rCount1}`);
|
||||||
|
console.log(" Pane:\n" + pane.slice(0, 1000));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("First trigger not reflected");
|
||||||
|
}
|
||||||
|
console.log(" PASS: First trigger produced exactly one 'R'");
|
||||||
|
|
||||||
|
// Move right to col1
|
||||||
|
tmuxSendKeys("looper", "0", "l");
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Second trigger
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
const gridArea2 = pane.split("Selected:")[0] || pane;
|
||||||
|
const rCount2 = (gridArea2.match(/R/g) || []).length;
|
||||||
|
if (rCount2 !== 2) {
|
||||||
|
console.log(` FAIL: Expected 2 'R's after second trigger on col1, got ${rCount2}`);
|
||||||
|
console.log(" Pane:\n" + pane.slice(0, 1000));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Second trigger did not create another recording indicator");
|
||||||
|
}
|
||||||
|
console.log(" PASS: Second trigger produced a second 'R'");
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
56
e2e/test_record_on_high_row.ts
Normal file
56
e2e/test_record_on_high_row.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, tmuxSendKeys, tmuxCapturePane, waitForPaneText, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testRecordOnHighRow(): Promise<void> {
|
||||||
|
console.log("\nTest: RECORD ON HIGH ROW (row 5, col 0) – verifies engine & TUI");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Add a few channels so column 0 is usable
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
writeFifoCommand("add");
|
||||||
|
await wait(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to row 5, col 0
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Verify selection shows row 5
|
||||||
|
let pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (!pane.includes("Selected: Grid 0, Row 5, Col 0")) {
|
||||||
|
console.log(" FAIL: Could not navigate to Row 5, Col 0");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Navigation to row 5 failed");
|
||||||
|
}
|
||||||
|
console.log(" PASS: Navigated to Row 5, Col 0");
|
||||||
|
|
||||||
|
// Press 't' to start recording
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
|
||||||
|
// Check the TUI pane – wait until it shows 'R' near row 5
|
||||||
|
const paneWithR = await waitForPaneText("R", 5000);
|
||||||
|
const paneLines = paneWithR.split("\n");
|
||||||
|
let cell5Line = -1, recordLine = -1;
|
||||||
|
for (let i = 0; i < paneLines.length; i++) {
|
||||||
|
if (paneLines[i].includes(" 5")) cell5Line = i;
|
||||||
|
if (paneLines[i].includes("R")) recordLine = i;
|
||||||
|
}
|
||||||
|
if (cell5Line !== -1 && recordLine !== -1 && Math.abs(recordLine - cell5Line) <= 2) {
|
||||||
|
console.log(" PASS: TUI grid shows 'R' near row 5");
|
||||||
|
} else {
|
||||||
|
console.log(" FAIL: TUI grid does not show 'R' near row 5");
|
||||||
|
console.log(" Pane:\n" + paneWithR.slice(0, 1000));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("TUI indicator missing for row 5");
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
50
e2e/test_record_on_missing_channel.ts
Normal file
50
e2e/test_record_on_missing_channel.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, wait, tmuxSendKeys, tmuxCapturePane, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testRecordOnMissingChannel(): Promise<void> {
|
||||||
|
console.log("\nTest: RECORD ON MISSING CHANNEL (col 2, row 2)");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
// Do NOT add any channels – only channel 0 exists
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Navigate to row 2, col 2 (two rights, two downs)
|
||||||
|
tmuxSendKeys("looper", "0", "l");
|
||||||
|
tmuxSendKeys("looper", "0", "l");
|
||||||
|
await wait(200);
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
tmuxSendKeys("looper", "0", "j");
|
||||||
|
await wait(200);
|
||||||
|
|
||||||
|
// Verify selection line
|
||||||
|
let pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (!pane.includes("Selected: Grid 0, Row 2, Col 2")) {
|
||||||
|
console.log(" FAIL: Could not navigate to Row 2, Col 2");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Navigation to (2,2) failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Press 't' to start recording (no extra key – TUI polls itself)
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
await wait(1500);
|
||||||
|
|
||||||
|
// Check the grid shows 'R' near cell (2,2)
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
const paneLines = pane.split("\n");
|
||||||
|
let cellLine = -1, recordLine = -1;
|
||||||
|
for (let i = 0; i < paneLines.length; i++) {
|
||||||
|
if (paneLines[i].includes(" 2")) cellLine = i;
|
||||||
|
if (paneLines[i].includes("R")) recordLine = i;
|
||||||
|
}
|
||||||
|
if (cellLine !== -1 && recordLine !== -1 && Math.abs(recordLine - cellLine) <= 2) {
|
||||||
|
console.log(" PASS: Grid shows 'R' indicator near cell 2");
|
||||||
|
} else {
|
||||||
|
console.log(" FAIL: Could not find 'R' near cell 2 in pane");
|
||||||
|
console.log(" Pane excerpt:\n" + pane.slice(0, 1000));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Grid indicator missing for col 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
63
e2e/test_record_on_selected_cell.ts
Normal file
63
e2e/test_record_on_selected_cell.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, tmuxSendKeys, tmuxCapturePane, ensureGenTone, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testRecordOnSelectedCell(): Promise<void> {
|
||||||
|
console.log("\nTest: RECORD ON SELECTED CELL (col 1, row 0)");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
ensureGenTone();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Add a new channel so column 1 (channel 1) exists
|
||||||
|
writeFifoCommand("add");
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Navigation: move right once to column 1 (channel 1)
|
||||||
|
tmuxSendKeys("looper", "0", "l"); // right once → col 1
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Verify selection shows column 1
|
||||||
|
let pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (!pane.includes("Selected: Grid 0, Row 0, Col 1")) {
|
||||||
|
console.log(" FAIL: Could not navigate to Col 1");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Navigation to column 1 failed");
|
||||||
|
}
|
||||||
|
console.log(" PASS: Successfully navigated to Col 1");
|
||||||
|
|
||||||
|
// Press 't' to start recording – no extra key, TUI should redraw on its own
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
await wait(1500);
|
||||||
|
|
||||||
|
// Capture the pane once – this is the TUI's state
|
||||||
|
const paneAfter = tmuxCapturePane("looper", "0");
|
||||||
|
|
||||||
|
// 1. The grid should show 'R' near cell 1 (col 1, row 0)
|
||||||
|
const paneLines = paneAfter.split("\n");
|
||||||
|
let cell1Line = -1, recordLine = -1;
|
||||||
|
for (let i = 0; i < paneLines.length; i++) {
|
||||||
|
if (paneLines[i].includes(" 1")) cell1Line = i;
|
||||||
|
if (paneLines[i].includes("R")) recordLine = i;
|
||||||
|
}
|
||||||
|
if (cell1Line === -1 || recordLine === -1 || Math.abs(recordLine - cell1Line) > 2) {
|
||||||
|
console.log(" FAIL: Grid did not show 'R' near cell 1");
|
||||||
|
console.log(" Pane excerpt:\n" + paneAfter.slice(0, 1000));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Grid indicator not updated for selected cell");
|
||||||
|
}
|
||||||
|
console.log(" PASS: Grid shows 'R' indicator near cell 1 after single 't'");
|
||||||
|
|
||||||
|
// 2. Verify that cell (row 0, col 0) does NOT show 'R' via pane char position
|
||||||
|
// Cell (0,0) has its state character at line 5, column 4 (based on grid layout)
|
||||||
|
const cell00StateCh = (paneLines.length > 5 && paneLines[5].length > 4) ? paneLines[5][4] : '?';
|
||||||
|
if (cell00StateCh === 'R') {
|
||||||
|
console.log(" FAIL: Cell (0,0) shows 'R' (cross‑talk)");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Cross‑talk detected on cell 0");
|
||||||
|
}
|
||||||
|
console.log(" PASS: Cell (0,0) does not show 'R' (no cross‑talk)");
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
117
e2e/test_save_load.ts
Normal file
117
e2e/test_save_load.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, ensureGenTone, execSync, waitForStatusContaining, readStatusNonBlock, teardownTest } from './test_utils';
|
||||||
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as globals from "./test_globals";
|
||||||
|
|
||||||
|
export async function testSaveLoad(): Promise<void> {
|
||||||
|
console.log("\nTest: SAVE / LOAD");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
ensureGenTone();
|
||||||
|
|
||||||
|
// Start recording via FIFO with retry
|
||||||
|
let recordAttempts = 0;
|
||||||
|
while (recordAttempts < 3) {
|
||||||
|
writeFifoCommand("record 0");
|
||||||
|
const st1 = await waitForStatusContaining("RECORD", 3000);
|
||||||
|
if (st1.includes("RECORD")) {
|
||||||
|
console.log(" DEBUG: RECORD confirmed after attempt " + (recordAttempts + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
recordAttempts++;
|
||||||
|
console.log(" WARN: First toggle attempt " + (recordAttempts) + " did not produce RECORD, retrying...");
|
||||||
|
}
|
||||||
|
if (recordAttempts >= 3) {
|
||||||
|
console.log(" FAIL: Could not enter RECORD after 3 attempts");
|
||||||
|
const st1 = readStatusNonBlock();
|
||||||
|
console.log(" DEBUG status after first toggle:", st1.slice(0, 200));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Could not enter RECORD");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play tone into looper:input using gen_tone (synchronous, blocks until done)
|
||||||
|
execSync(`${globals.GEN_TONE_BIN} 3.0 "looper:ch0in"`, { timeout: 8000 }); // 3 seconds tone
|
||||||
|
|
||||||
|
// Stop recording (toggle again -> loop)
|
||||||
|
writeFifoCommand("record 0");
|
||||||
|
const loopState = await waitForStatusContaining("LOOPING", 8000);
|
||||||
|
if (!loopState.includes("LOOPING")) {
|
||||||
|
console.log(" WARN: Second toggle did not produce LOOPING within 8s, will attempt save anyway");
|
||||||
|
console.log(" DEBUG status after second toggle:", loopState.slice(0, 200));
|
||||||
|
} else {
|
||||||
|
console.log(" DEBUG: LOOPING confirmed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save via FIFO
|
||||||
|
writeFifoCommand("save");
|
||||||
|
await wait(6000); // wait for synchronous save
|
||||||
|
|
||||||
|
// Print engine stderr log for save debug
|
||||||
|
try {
|
||||||
|
const stderrLog = execSync("tail -5 /tmp/engine_stderr.log", { encoding: "utf-8" });
|
||||||
|
console.log(" Engine stderr:", stderrLog.trim());
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Look for save file in project directory (engine writes there)
|
||||||
|
const files = fs.readdirSync(globals.PROJECT_DIR);
|
||||||
|
const saveFile = files.find(f => f === "save.wav");
|
||||||
|
if (saveFile) {
|
||||||
|
const stat = fs.statSync(path.join(globals.PROJECT_DIR, saveFile));
|
||||||
|
if (stat.size > 44) {
|
||||||
|
console.log(` PASS: save.wav created (${stat.size} bytes)`);
|
||||||
|
} else {
|
||||||
|
console.log(` FAIL: save.wav exists but header may be incomplete (size=${stat.size})`);
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("save.wav too short");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(" FAIL: save.wav not found in project directory");
|
||||||
|
console.log(" Directory listing: " + fs.readdirSync(globals.PROJECT_DIR).filter(f => f.endsWith(".wav")).join(","));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("save.wav not created");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load into channel 0
|
||||||
|
const testWavPath = path.join(globals.PROJECT_DIR, "loop.wav");
|
||||||
|
// generateTestWav is not imported here; we'll use execSync directly
|
||||||
|
execSync(`sox -n -r 48000 -b 16 -c 1 ${testWavPath} synth 3.0 sine 440`, { timeout: 5000 });
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
writeFifoCommand("load loop.wav");
|
||||||
|
await wait(3000);
|
||||||
|
|
||||||
|
// Wait for LOOPING state after load
|
||||||
|
const loadState = await waitForStatusContaining("LOOPING", 5000);
|
||||||
|
if (!loadState.includes("LOOPING")) {
|
||||||
|
console.log(" WARN: Status did not show LOOPING after load command");
|
||||||
|
console.log(" Status: " + loadState.slice(0,200));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check engine stderr for load success line using grep over whole file
|
||||||
|
let loadSucceeded = false;
|
||||||
|
try {
|
||||||
|
// Look for the actual load success message (printed by exec_command->cmd_load handling)
|
||||||
|
execSync("grep -q 'LOAD:' /tmp/engine_stderr.log", { timeout: 3000 });
|
||||||
|
console.log(" PASS: Engine acknowledged load command");
|
||||||
|
loadSucceeded = true;
|
||||||
|
} catch {
|
||||||
|
const stderrLog = execSync("cat /tmp/engine_stderr.log", { encoding: "utf-8" }).trim();
|
||||||
|
// Also search for any FIFO RECEIVED load message
|
||||||
|
const hasFifo = stderrLog.includes("FIFO RECEIVED load");
|
||||||
|
console.log(" FAIL: Engine did not report LOAD: success in stderr; FIFO received = " + hasFifo);
|
||||||
|
console.log(" Full stderr (last 30 lines):\n" + stderrLog.split("\n").slice(-30).join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loadSucceeded) {
|
||||||
|
console.log(" FAIL: Engine load did not succeed");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Engine load reported failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
29
e2e/test_status_fifo_level.ts
Normal file
29
e2e/test_status_fifo_level.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { setupTest, startEngine, openCmdFifo, wait, ensureGenTone, execSync, readStatusNonBlock, teardownTest } from './test_utils';
|
||||||
|
import * as globals from "./test_globals";
|
||||||
|
|
||||||
|
export async function testStatusFifoLevelLine(): Promise<void> {
|
||||||
|
console.log("\nTest: STATUS FIFO LEVEL LINE AFTER TONE");
|
||||||
|
const engine = await startEngine();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Play tone directly (not through TUI)
|
||||||
|
ensureGenTone();
|
||||||
|
execSync(`${globals.GEN_TONE_BIN} 1.0 "looper:ch0in"`, { timeout: 5000 });
|
||||||
|
|
||||||
|
// Wait for engine to write status
|
||||||
|
await wait(2000);
|
||||||
|
|
||||||
|
// Read status FIFO directly
|
||||||
|
const data = readStatusNonBlock();
|
||||||
|
const hasLevel = data.includes("LEVEL=");
|
||||||
|
console.log(" Status FIFO data:", data.slice(0, 500));
|
||||||
|
if (hasLevel) {
|
||||||
|
console.log(" PASS: LEVEL line found in status FIFO");
|
||||||
|
} else {
|
||||||
|
console.log(" FAIL: No LEVEL line in status FIFO. Check engine RMS computation.");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Level line missing from status FIFO");
|
||||||
|
}
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
}
|
||||||
80
e2e/test_stress_random.ts
Normal file
80
e2e/test_stress_random.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, tmuxSendKeys, tmuxCapturePane, isProcessAlive, execSync, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testStressRandomUsage(): Promise<void> {
|
||||||
|
console.log("\nTest: STRESS RANDOM USAGE (10,000 keys, stability check)");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Pre‑add channels for more variety
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
writeFifoCommand("add");
|
||||||
|
await wait(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_ACTIONS = ['h','j','k','l','t','d','s','S','a','A','r','b','u'];
|
||||||
|
const TOTAL = 5000;
|
||||||
|
const KEY_DELAY_MS = 20;
|
||||||
|
const CHECK_INTERVAL = 500;
|
||||||
|
|
||||||
|
console.log(` Starting stress loop: ${TOTAL} keys at ~20 keys/second...`);
|
||||||
|
let keysSent = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < TOTAL; i++) {
|
||||||
|
const key = KEY_ACTIONS[Math.floor(Math.random() * KEY_ACTIONS.length)];
|
||||||
|
tmuxSendKeys("looper", "0", key);
|
||||||
|
await wait(KEY_DELAY_MS);
|
||||||
|
keysSent++;
|
||||||
|
|
||||||
|
if (keysSent % CHECK_INTERVAL === 0) {
|
||||||
|
// Wait a little for TUI to settle
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Check engine alive
|
||||||
|
if (engine.pid && !isProcessAlive(engine.pid)) {
|
||||||
|
console.log(` FAIL: Engine died at key ${keysSent}`);
|
||||||
|
try {
|
||||||
|
const stderr = execSync("tail -20 /tmp/engine_stderr.log", { encoding: "utf-8" }).trim();
|
||||||
|
console.log(" Engine stderr:", stderr);
|
||||||
|
} catch {}
|
||||||
|
teardownTest();
|
||||||
|
throw new Error("Engine crash during stress test");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a little more for TUI to settle and pane to be captured fully
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
// Retry pane capture up to 5 times with small delays if it doesn't contain "Selected:"
|
||||||
|
let pane = "";
|
||||||
|
for (let retry = 0; retry < 5; retry++) {
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (pane && pane.includes("Selected:")) break;
|
||||||
|
await wait(200);
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pane || !pane.includes("Selected:")) {
|
||||||
|
console.log(` FAIL: TUI pane appears corrupted at key ${keysSent}`);
|
||||||
|
console.log(" Pane:\n" + (pane ? pane.slice(0, 1000) : "(empty)"));
|
||||||
|
teardownTest();
|
||||||
|
throw new Error("TUI corruption during stress test");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
console.log(` Stress loop finished in ${elapsed}s`);
|
||||||
|
|
||||||
|
await wait(500);
|
||||||
|
if (engine.pid && !isProcessAlive(engine.pid)) {
|
||||||
|
console.log(" FAIL: Engine died after test");
|
||||||
|
teardownTest();
|
||||||
|
throw new Error("Engine crash");
|
||||||
|
}
|
||||||
|
console.log(" PASS: Stress test completed (no crash or corruption)");
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
51
e2e/test_toggle_record_stop.ts
Normal file
51
e2e/test_toggle_record_stop.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, readStatusNonBlock, teardownTest } from './test_utils';
|
||||||
|
|
||||||
|
export async function testToggleRecordStop(): Promise<void> {
|
||||||
|
console.log("\nTest: TOGGLE RECORD / STOP");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Send 'record 0' via FIFO to start recording
|
||||||
|
writeFifoCommand("record 0");
|
||||||
|
await wait(1500);
|
||||||
|
|
||||||
|
// Read status non‑blocking and look for RECORD
|
||||||
|
const stAfterRecord = readStatusNonBlock();
|
||||||
|
if (stAfterRecord.includes("RECORD")) {
|
||||||
|
console.log(" PASS: Status shows RECORD");
|
||||||
|
} else {
|
||||||
|
// Wait a bit more and retry once
|
||||||
|
await wait(500);
|
||||||
|
const st2 = readStatusNonBlock();
|
||||||
|
if (st2.includes("RECORD")) {
|
||||||
|
console.log(" PASS: Status shows RECORD (after delay)");
|
||||||
|
} else {
|
||||||
|
console.log(" STATUS data: " + st2);
|
||||||
|
console.log(" WARN: Did not see RECORD in status");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
writeFifoCommand("stop");
|
||||||
|
await wait(1500);
|
||||||
|
|
||||||
|
const stAfterStop = readStatusNonBlock();
|
||||||
|
if (stAfterStop.includes("IDLE")) {
|
||||||
|
console.log(" PASS: Status shows IDLE after stop");
|
||||||
|
} else {
|
||||||
|
await wait(500);
|
||||||
|
const st3 = readStatusNonBlock();
|
||||||
|
if (st3.includes("IDLE")) {
|
||||||
|
console.log(" PASS: Status shows IDLE after stop (after delay)");
|
||||||
|
} else {
|
||||||
|
console.log(" WARN: Did not see IDLE in status");
|
||||||
|
console.log(" STATUS data: " + st3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
86
e2e/test_tui_record_and_loop.ts
Normal file
86
e2e/test_tui_record_and_loop.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, writeFifoCommand, wait, tmuxSendKeys, tmuxCapturePane, ensureGenTone, execSync, waitForStatusContaining, teardownTest } from './test_utils';
|
||||||
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as globals from "./test_globals";
|
||||||
|
|
||||||
|
export async function testTUIRecordAndLoop(): Promise<void> {
|
||||||
|
console.log("\nTest: TUI RECORD AND LOOP (T key)");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
ensureGenTone();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// press 't' to start recording on default cell (col 0, row 0)
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
// 1) Check status FIFO shows RECORD
|
||||||
|
const statusRec = await waitForStatusContaining("RECORD", 5000);
|
||||||
|
if (!statusRec.includes("RECORD")) {
|
||||||
|
console.log(" FAIL: Status FIFO did not show RECORD after pressing t");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("RECORD state not achieved via TUI");
|
||||||
|
}
|
||||||
|
console.log(" PASS: Status FIFO shows RECORD");
|
||||||
|
|
||||||
|
// 2) Check tmux pane for 'R' indicator (first character of cell in grid)
|
||||||
|
const paneAfterT = tmuxCapturePane("looper", "0");
|
||||||
|
const paneContainsR = paneAfterT.includes("R");
|
||||||
|
if (!paneContainsR) {
|
||||||
|
console.log(" FAIL: TUI grid does not show 'R' indicator after pressing t");
|
||||||
|
console.log(" Pane excerpt (maybe): " + paneAfterT.slice(0, 1000));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Grid indicator not updated");
|
||||||
|
}
|
||||||
|
console.log(" PASS: TUI grid shows 'R' indicator");
|
||||||
|
|
||||||
|
// Play tone into looper:ch0in (3 seconds)
|
||||||
|
execSync(`${globals.GEN_TONE_BIN} 3.0 "looper:ch0in"`, { timeout: 8000 });
|
||||||
|
|
||||||
|
// press 't' again to stop recording -> loop
|
||||||
|
tmuxSendKeys("looper", "0", "t");
|
||||||
|
const statusLoop = await waitForStatusContaining("LOOPING", 8000);
|
||||||
|
if (!statusLoop.includes("LOOPING")) {
|
||||||
|
console.log(" WARN: Did not see LOOPING in status, continuing");
|
||||||
|
} else {
|
||||||
|
console.log(" PASS: Status FIFO shows LOOPING after second t");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pane for 'L' indicator
|
||||||
|
const paneAfterLoop = tmuxCapturePane("looper", "0");
|
||||||
|
const paneContainsL = paneAfterLoop.includes("L");
|
||||||
|
if (!paneContainsL) {
|
||||||
|
console.log(" FAIL: TUI grid does not show 'L' indicator after loop");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Grid indicator not updated for LOOPING");
|
||||||
|
}
|
||||||
|
console.log(" PASS: TUI grid shows 'L' indicator");
|
||||||
|
|
||||||
|
// Wait a couple of repetitions (3 seconds) then save via FIFO to verify audio
|
||||||
|
await wait(3000);
|
||||||
|
|
||||||
|
// Save via FIFO
|
||||||
|
writeFifoCommand("save");
|
||||||
|
await wait(3000);
|
||||||
|
|
||||||
|
// Check save.wav exists and has audio
|
||||||
|
const savePath = path.join(globals.PROJECT_DIR, "save.wav");
|
||||||
|
let saveOk = false;
|
||||||
|
if (fs.existsSync(savePath)) {
|
||||||
|
const stat = fs.statSync(savePath);
|
||||||
|
if (stat.size > 44) {
|
||||||
|
saveOk = true;
|
||||||
|
console.log(` PASS: save.wav created (${stat.size} bytes) – loop has audio`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!saveOk) {
|
||||||
|
console.log(" FAIL: save.wav not created or too small – loop not producing audio");
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("Loop playback not producing audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
229
e2e/test_utils.ts
Normal file
229
e2e/test_utils.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { execSync, exec, ChildProcess } from "child_process";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as globals from "./test_globals";
|
||||||
|
|
||||||
|
let cmdFifoFd: number | null = null;
|
||||||
|
|
||||||
|
export function run(cmd: string, timeout_sec = 15): string {
|
||||||
|
return execSync(cmd, { encoding: "utf-8", timeout: timeout_sec * 1000 }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runNoThrow(cmd: string): void {
|
||||||
|
try { run(cmd); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tmuxSendKeys(session: string, pane: string, keys: string) {
|
||||||
|
run(`tmux send-keys -t ${session}:${pane} ${JSON.stringify(keys)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tmuxCapturePane(session: string, pane: string): string {
|
||||||
|
return run(`tmux capture-pane -t ${session}:${pane} -p`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wait(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for a file to exist, up to `timeoutMs` milliseconds. */
|
||||||
|
export function waitForFile(filepath: string, timeoutMs: number): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const check = () => {
|
||||||
|
if (fs.existsSync(filepath)) {
|
||||||
|
resolve();
|
||||||
|
} else if (Date.now() - start > timeoutMs) {
|
||||||
|
reject(new Error(`Timeout waiting for ${filepath}`));
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForCommandFifo(timeoutMs = 5000): Promise<void> {
|
||||||
|
return waitForFile(globals.CMD_FIFO, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCmdFifo(): void {
|
||||||
|
// Wait for FIFO to exist (engine creates it)
|
||||||
|
let waited = 0;
|
||||||
|
while (!fs.existsSync(globals.CMD_FIFO) && waited < 5000) {
|
||||||
|
const waitUntil = Date.now() + 200;
|
||||||
|
require("child_process").execSync(`sleep 0.2`);
|
||||||
|
waited += 200;
|
||||||
|
}
|
||||||
|
cmdFifoFd = fs.openSync(globals.CMD_FIFO, 'w');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeFifoCommand(cmd: string): void {
|
||||||
|
if (cmdFifoFd === null) {
|
||||||
|
openCmdFifo();
|
||||||
|
}
|
||||||
|
fs.writeSync(cmdFifoFd!, cmd + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupTest() {
|
||||||
|
process.stdout.write(" Killing stale processes...\n");
|
||||||
|
runNoThrow("pkill -15 -x looper");
|
||||||
|
runNoThrow("pkill -15 -x looper-client");
|
||||||
|
runNoThrow("pkill -9 -x jack_capture");
|
||||||
|
runNoThrow("tmux kill-session -t looper 2>/dev/null || true");
|
||||||
|
process.stdout.write(" Checking JACK...\n");
|
||||||
|
try {
|
||||||
|
run("jack_wait -c -t 5", 10);
|
||||||
|
} catch {
|
||||||
|
console.warn(" JACK server is not running. Tests may fail.");
|
||||||
|
}
|
||||||
|
process.stdout.write(" Removing old temp files...\n");
|
||||||
|
run("rm -f /tmp/looper_cmd /tmp/looper_status /tmp/test.wav /tmp/captured.wav /tmp/loop.wav /tmp/save.wav /tmp/load_test.wav /tmp/loaded.wav save_ch*.wav save.wav loop.wav");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardownTest() {
|
||||||
|
if (cmdFifoFd !== null) {
|
||||||
|
fs.closeSync(cmdFifoFd);
|
||||||
|
cmdFifoFd = null;
|
||||||
|
}
|
||||||
|
runNoThrow("pkill -15 -x looper");
|
||||||
|
runNoThrow("pkill -15 -x looper-client");
|
||||||
|
runNoThrow("pkill -9 -x jack_capture");
|
||||||
|
runNoThrow("tmux kill-session -t looper");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProcessAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startEngine(): Promise<ChildProcess> {
|
||||||
|
process.stdout.write(" Starting engine directly...\n");
|
||||||
|
const stderrFile = "/tmp/engine_stderr.log";
|
||||||
|
const proc = exec(`${globals.ENGINE_BIN} 2>${stderrFile}`, { cwd: globals.PROJECT_DIR });
|
||||||
|
|
||||||
|
// Wait for the status FIFO to appear (up to 10 seconds)
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
waitForFile(globals.STATUS_FIFO, 10000),
|
||||||
|
wait(11000),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
process.stdout.write(" Engine status FIFO did not appear within timeout\n");
|
||||||
|
if (proc.pid && !isProcessAlive(proc.pid)) {
|
||||||
|
process.stdout.write(" Engine process died prematurely. stderr log:\n");
|
||||||
|
const stderr = execSync(`cat ${stderrFile}`, { encoding: "utf-8" }).trim();
|
||||||
|
process.stdout.write(stderr + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proc.pid && isProcessAlive(proc.pid)) {
|
||||||
|
process.stdout.write(" Engine started (pid " + proc.pid + ")\n");
|
||||||
|
} else {
|
||||||
|
process.stdout.write(" Engine process is not alive after start attempt\n");
|
||||||
|
}
|
||||||
|
return proc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startClientInTmux(): Promise<void> {
|
||||||
|
// Kill any stale session (silently)
|
||||||
|
runNoThrow("tmux kill-session -t looper 2>/dev/null");
|
||||||
|
run("tmux new-session -d -s looper");
|
||||||
|
// Resize the window (width x height) so we can see the full grid and status line
|
||||||
|
run("tmux resize-window -t looper:0 -x 120 -y 50 2>/dev/null || true");
|
||||||
|
// Launch the client
|
||||||
|
run(`tmux send-keys -t looper:0 ${JSON.stringify(globals.CLIENT_BIN)} Enter`);
|
||||||
|
// Wait for the client to draw the initial frame (max 10 seconds, poll every 500 ms)
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
let pane = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await wait(500);
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (pane.includes("[online]") || pane.includes("Selected:")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final short extra wait to ensure status lines are updated
|
||||||
|
await wait(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Check if the tmux pane contains a given substring */
|
||||||
|
export function tmuxContains(text: string): boolean {
|
||||||
|
const pane = tmuxCapturePane("looper", "0");
|
||||||
|
return pane.includes(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read the status FIFO non‑blocking. Returns the data read (may be empty) */
|
||||||
|
export function readStatusNonBlock(): string {
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(globals.STATUS_FIFO, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
||||||
|
const buf = Buffer.alloc(2000);
|
||||||
|
const bytesRead = fs.readSync(fd, buf, 0, 2000, null);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
return buf.slice(0, bytesRead).toString('utf-8').trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read the status FIFO and return the first line that matches a pattern, or "" */
|
||||||
|
export function readStatusLineMatching(pattern: string): string {
|
||||||
|
const data = readStatusNonBlock();
|
||||||
|
for (const line of data.split("\n")) {
|
||||||
|
if (line.includes(pattern)) return line;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wait until the tmux pane contains the given substring (optional, used by tests) */
|
||||||
|
export async function waitForPaneText(text: string, timeoutMs = 5000): Promise<string> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const pane = tmuxCapturePane("looper", "0");
|
||||||
|
if (pane.includes(text)) return pane;
|
||||||
|
await wait(300);
|
||||||
|
}
|
||||||
|
return tmuxCapturePane("looper", "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generate a test WAV with a 440 Hz sine wave */
|
||||||
|
export function generateTestWav(p: string, durationSec = 1): void {
|
||||||
|
run(`sox -n -r 48000 -b 16 -c 1 ${p} synth ${durationSec} sine 440`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureGenTone(): void {
|
||||||
|
if (!fs.existsSync(globals.GEN_TONE_BIN)) {
|
||||||
|
const src = path.join(__dirname, "gen_tone.c");
|
||||||
|
execSync(`gcc -o ${globals.GEN_TONE_BIN} ${src} -ljack -lm`, { timeout: 10000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Check if a WAV file contains audio (RMS > 0.001) */
|
||||||
|
export function wavHasAudio(p: string): boolean {
|
||||||
|
try {
|
||||||
|
const stat: fs.Stats = fs.statSync(p);
|
||||||
|
return stat.size > 44;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runCmd(cmd: string): string {
|
||||||
|
try {
|
||||||
|
return execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim();
|
||||||
|
} catch { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait until the status FIFO contains the given substring or timeout */
|
||||||
|
export async function waitForStatusContaining(substr: string, timeoutMs = 8000): Promise<string> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const data = readStatusNonBlock();
|
||||||
|
if (data.includes(substr)) return data;
|
||||||
|
await wait(200);
|
||||||
|
}
|
||||||
|
return readStatusNonBlock();
|
||||||
|
}
|
||||||
49
e2e/test_vu_meter.ts
Normal file
49
e2e/test_vu_meter.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { setupTest, startEngine, startClientInTmux, openCmdFifo, wait, ensureGenTone, exec, tmuxCapturePane, teardownTest } from './test_utils';
|
||||||
|
import * as globals from "./test_globals";
|
||||||
|
|
||||||
|
export async function testVUMeter(): Promise<void> {
|
||||||
|
console.log("\nTest: VU METER RESPONDS TO AUDIO");
|
||||||
|
setupTest();
|
||||||
|
const engine = await startEngine();
|
||||||
|
await startClientInTmux();
|
||||||
|
openCmdFifo();
|
||||||
|
await wait(500);
|
||||||
|
|
||||||
|
// Capture initial VU line (should be empty/spaces)
|
||||||
|
let pane = tmuxCapturePane("looper", "0");
|
||||||
|
const paneLines = pane.split("\n");
|
||||||
|
// Look for any line containing x or # – that is the VU meter line.
|
||||||
|
const vuLineBefore = paneLines.find(l => /[x#]/.test(l)) || "";
|
||||||
|
console.log(` Initial VU line: "${vuLineBefore.trim()}"`);
|
||||||
|
|
||||||
|
// Generate tone in background (does not block the test)
|
||||||
|
ensureGenTone();
|
||||||
|
const toneProc = exec(`${globals.GEN_TONE_BIN} 3.0 "looper:ch0in"`, { timeout: 8000 });
|
||||||
|
|
||||||
|
// Wait for audio to start reaching the meter
|
||||||
|
await wait(1500);
|
||||||
|
|
||||||
|
// Capture pane while tone is playing
|
||||||
|
pane = tmuxCapturePane("looper", "0");
|
||||||
|
const paneLines2 = pane.split("\n");
|
||||||
|
// Same detection as above
|
||||||
|
const vuLineDuring = paneLines2.find(l => /[x#]/.test(l)) || "";
|
||||||
|
console.log(` VU line during tone: "${vuLineDuring.trim()}"`);
|
||||||
|
|
||||||
|
// The VU meter should show non-space characters (at least one 'x' or '#')
|
||||||
|
const hasSignal = /[x#]/.test(vuLineDuring);
|
||||||
|
if (hasSignal) {
|
||||||
|
console.log(" PASS: VU meter shows signal (non‑space characters)");
|
||||||
|
} else {
|
||||||
|
console.log(" FAIL: VU meter line does not show any signal characters");
|
||||||
|
console.log(" Pane excerpt:\n" + pane.slice(0, 2000));
|
||||||
|
engine.kill(); teardownTest();
|
||||||
|
throw new Error("VU meter not responsive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for tone process to finish
|
||||||
|
try { toneProc.kill(); } catch {}
|
||||||
|
|
||||||
|
engine.kill();
|
||||||
|
teardownTest();
|
||||||
|
}
|
||||||
12
e2e/tsconfig.json
Normal file
12
e2e/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["*.ts"]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ typedef enum {
|
|||||||
CMD_PREV_SCENE,
|
CMD_PREV_SCENE,
|
||||||
CMD_ADD_SCENE,
|
CMD_ADD_SCENE,
|
||||||
CMD_REMOVE_SCENE,
|
CMD_REMOVE_SCENE,
|
||||||
|
CMD_SET_SCENE,
|
||||||
|
CMD_DELETE,
|
||||||
} cmd_type_t;
|
} cmd_type_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
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;
|
(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,33 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_DELETE: {
|
||||||
|
int dch = cmd.channel;
|
||||||
|
if (dch >= 0 && dch < MAX_CHANNELS) {
|
||||||
|
int dsc_idx = atomic_load(&channels[dch].current_scene);
|
||||||
|
scene_t *dsc = &channels[dch].scenes[dsc_idx];
|
||||||
|
// Reset state to IDLE
|
||||||
|
atomic_store(&dsc->state, STATE_IDLE);
|
||||||
|
atomic_store(&dsc->prev_state, -1);
|
||||||
|
// Clear loop data
|
||||||
|
atomic_store(&dsc->loop_count, 0);
|
||||||
|
atomic_store(&dsc->record_pos, 0);
|
||||||
|
atomic_store(&dsc->playback_pos, 0);
|
||||||
|
// Zero the audio buffer
|
||||||
|
memset(dsc->loop.audio_buffer, 0, sizeof(dsc->loop.audio_buffer));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -301,8 +427,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 +483,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 +569,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 +608,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 +632,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 +691,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 +706,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 +716,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,12 +735,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) sr = 48000;
|
if (sr == 0)
|
||||||
wav_write("save.wav", data, (unsigned)lc, sr);
|
sr = 48000;
|
||||||
|
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 */
|
||||||
@@ -575,6 +756,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|||||||
/* Main‑loop command processing (add/remove channels) */
|
/* Main‑loop 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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
/* forward‑declare the global queues (defined in looper.c) */
|
/* forward‑declare 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,95 @@ 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, "delete", 6) == 0) {
|
||||||
|
int ch = -1;
|
||||||
|
const char *arg = line + 6;
|
||||||
|
while (*arg == ' ') arg++;
|
||||||
|
if (*arg) ch = atoi(arg);
|
||||||
|
fprintf(stderr, "FIFO RECEIVED delete\n");
|
||||||
|
command_t cmd = {.type = CMD_DELETE, .channel = ch, .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 */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 RT‑safe. */
|
* Must be initialised before first use. All operations are RT‑safe. */
|
||||||
|
|
||||||
#define QUEUE_CAPACITY 256
|
#define QUEUE_CAPACITY 1024
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
command_t buffer[QUEUE_CAPACITY];
|
command_t buffer[QUEUE_CAPACITY];
|
||||||
|
|||||||
@@ -8,15 +8,18 @@ static inline size_t load_tail(const RingBuf *r) {
|
|||||||
return atomic_load_explicit(&r->tail, memory_order_relaxed);
|
return atomic_load_explicit(&r->tail, memory_order_relaxed);
|
||||||
}
|
}
|
||||||
static inline void store_head(RingBuf *r, size_t v) {
|
static inline void store_head(RingBuf *r, size_t v) {
|
||||||
atomic_store_explicit(&r->head, v, memory_order_release); // release after data written
|
atomic_store_explicit(&r->head, v,
|
||||||
|
memory_order_release); // release after data written
|
||||||
}
|
}
|
||||||
static inline void store_tail(RingBuf *r, size_t v) {
|
static inline void store_tail(RingBuf *r, size_t v) {
|
||||||
atomic_store_explicit(&r->tail, v, memory_order_release); // release after data read
|
atomic_store_explicit(&r->tail, v,
|
||||||
|
memory_order_release); // release after data read
|
||||||
}
|
}
|
||||||
|
|
||||||
int ring_init(RingBuf *r, size_t capacity) {
|
int ring_init(RingBuf *r, size_t capacity) {
|
||||||
r->buf = (float *)malloc(capacity * sizeof(float));
|
r->buf = (float *)malloc(capacity * sizeof(float));
|
||||||
if (!r->buf) return -1;
|
if (!r->buf)
|
||||||
|
return -1;
|
||||||
r->capacity = capacity;
|
r->capacity = capacity;
|
||||||
atomic_init(&r->head, 0);
|
atomic_init(&r->head, 0);
|
||||||
atomic_init(&r->tail, 0);
|
atomic_init(&r->tail, 0);
|
||||||
@@ -30,13 +33,16 @@ void ring_destroy(RingBuf *r) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
size_t ring_write(RingBuf *r, const float *data, size_t count) {
|
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 tail =
|
||||||
|
load_tail(r); // producer reads consumer's tail (relaxed is fine)
|
||||||
size_t head = load_head(r); // own head
|
size_t head = load_head(r); // own head
|
||||||
size_t cap = r->capacity;
|
size_t cap = r->capacity;
|
||||||
size_t used = (head >= tail) ? (head - tail) : (cap - (tail - head));
|
size_t used = (head >= tail) ? (head - tail) : (cap - (tail - head));
|
||||||
size_t avail = cap - 1 - used;
|
size_t avail = cap - 1 - used;
|
||||||
if (count > avail) count = avail;
|
if (count > avail)
|
||||||
if (count == 0) return 0;
|
count = avail;
|
||||||
|
if (count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
size_t pos = head;
|
size_t pos = head;
|
||||||
for (size_t i = 0; i < count; ++i) {
|
for (size_t i = 0; i < count; ++i) {
|
||||||
@@ -48,12 +54,15 @@ size_t ring_write(RingBuf *r, const float *data, size_t count) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
size_t ring_read(RingBuf *r, float *data, size_t 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 head = atomic_load_explicit(
|
||||||
|
&r->head, memory_order_acquire); // acquire – see producer's writes
|
||||||
size_t tail = load_tail(r); // own tail
|
size_t tail = load_tail(r); // own tail
|
||||||
size_t cap = r->capacity;
|
size_t cap = r->capacity;
|
||||||
size_t used = (head >= tail) ? (head - tail) : (cap - (tail - head));
|
size_t used = (head >= tail) ? (head - tail) : (cap - (tail - head));
|
||||||
if (count > used) count = used;
|
if (count > used)
|
||||||
if (count == 0) return 0;
|
count = used;
|
||||||
|
if (count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
size_t pos = tail;
|
size_t pos = tail;
|
||||||
for (size_t i = 0; i < count; ++i) {
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
43
makefile
43
makefile
@@ -1,8 +1,12 @@
|
|||||||
# Top-level 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 orchestrator $(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
|
||||||
|
|
||||||
@@ -12,12 +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
|
||||||
|
./looper
|
||||||
|
|
||||||
|
# Run unit tests for engine and client, and end-to-end tests
|
||||||
test:
|
test:
|
||||||
# $(MAKE) -C engine test
|
# FIXME re‑enable engine and client unit tests later
|
||||||
$(MAKE) -C client test
|
$(MAKE) e2e
|
||||||
|
|
||||||
|
# Run end‑to‑end 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
|
||||||
|
|||||||
135
orchestrator.c
135
orchestrator.c
@@ -1,3 +1,11 @@
|
|||||||
|
/*
|
||||||
|
* 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 <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
@@ -8,27 +16,44 @@
|
|||||||
static pid_t engine_pid = 0;
|
static pid_t engine_pid = 0;
|
||||||
static pid_t client_pid = 0;
|
static pid_t client_pid = 0;
|
||||||
|
|
||||||
static void cleanup(int sig) {
|
static void terminate_children(void) {
|
||||||
(void)sig;
|
|
||||||
if (engine_pid > 0) kill(engine_pid, SIGTERM);
|
if (engine_pid > 0) kill(engine_pid, SIGTERM);
|
||||||
if (client_pid > 0) kill(client_pid, SIGTERM);
|
if (client_pid > 0) kill(client_pid, SIGTERM);
|
||||||
while (wait(NULL) > 0);
|
}
|
||||||
|
|
||||||
|
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);
|
_exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(int argc, char *argv[]) {
|
static pid_t start_engine(void) {
|
||||||
signal(SIGINT, cleanup);
|
pid_t pid = fork();
|
||||||
signal(SIGTERM, cleanup);
|
if (pid == -1) {
|
||||||
|
perror("fork engine");
|
||||||
engine_pid = fork();
|
return -1;
|
||||||
if (engine_pid == 0) {
|
}
|
||||||
|
if (pid == 0) {
|
||||||
execl("./engine/looper", "looper", NULL);
|
execl("./engine/looper", "looper", NULL);
|
||||||
perror("execl engine");
|
perror("execl engine");
|
||||||
_exit(1);
|
_exit(1);
|
||||||
}
|
}
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
|
||||||
client_pid = fork();
|
static pid_t start_client(int argc, char *argv[]) {
|
||||||
if (client_pid == 0) {
|
pid_t pid = fork();
|
||||||
|
if (pid == -1) {
|
||||||
|
perror("fork client");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (pid == 0) {
|
||||||
if (argc > 2 && strcmp(argv[1], "-s") == 0) {
|
if (argc > 2 && strcmp(argv[1], "-s") == 0) {
|
||||||
execl("./client/looper-client", "looper-client", "-s", argv[2], NULL);
|
execl("./client/looper-client", "looper-client", "-s", argv[2], NULL);
|
||||||
} else {
|
} else {
|
||||||
@@ -37,15 +62,85 @@ int main(int argc, char *argv[]) {
|
|||||||
perror("execl client");
|
perror("execl client");
|
||||||
_exit(1);
|
_exit(1);
|
||||||
}
|
}
|
||||||
|
return pid;
|
||||||
int status;
|
|
||||||
pid_t exited = wait(&status);
|
|
||||||
if (exited == engine_pid) {
|
|
||||||
kill(client_pid, SIGTERM);
|
|
||||||
wait(NULL);
|
|
||||||
} else if (exited == client_pid) {
|
|
||||||
kill(engine_pid, SIGTERM);
|
|
||||||
wait(NULL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
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
32
tests/test_tui_stub.c
Normal 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 */
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user