feat: add script module for note-to-command mapping with FIFO support

This commit is contained in:
Loic Coenen
2026-05-18 21:12:29 +00:00
committed by Loic Coenen (aider)
parent 16a800209f
commit f776b8a361
7 changed files with 638 additions and 5 deletions

View File

@@ -7,6 +7,7 @@ CARLA_LIB = -L/usr/lib/carla -Wl,-rpath,/usr/lib/carla -lcarla_standalone2
CARLA_OBJ = src/carla_host.o
PLUGINS_OBJ = src/plugins.o
CLIENT_CMD_OBJ = src/client_cmd.o
SCRIPT_OBJ = src/script.o
# Test binaries
TEST_PLUGINS_BIN = test_plugins
@@ -17,7 +18,7 @@ TEST_INTEGRATION_BIN = test_integration
all: looper-client test_status_parse
looper-client: src/main.c src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ)
looper-client: src/main.c src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ)
$(CC) $(CFLAGS) $(CARLA_INC) -o $@ $^ $(CARLA_LIB) -ljack -lncurses
test_status_parse: tests/test_status_parse.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ)
@@ -38,6 +39,20 @@ $(CARLA_TEST_OBJ): src/carla_host.c src/carla_host.h
$(CLIENT_CMD_OBJ): src/client_cmd.c src/client_cmd.h
$(CC) $(CFLAGS) $(CARLA_INC) -c -o $@ $<
$(SCRIPT_OBJ): src/script.c src/script.h
$(CC) $(CFLAGS) -c -o $@ $<
# --- Script test ---
TEST_SCRIPT_BIN = test_script
TEST_SCRIPT_OBJ = tests/test_script.o
$(TEST_SCRIPT_OBJ): tests/test_script.c src/script.h
$(CC) $(CFLAGS) -c -o $@ $<
$(TEST_SCRIPT_BIN): $(TEST_SCRIPT_OBJ) $(SCRIPT_OBJ)
$(CC) $(CFLAGS) -o $@ $^
# --- Plugin tests ---
TEST_PLUGINS_OBJ = tests/test_plugins.o
@@ -65,7 +80,7 @@ TEST_CLIENT_OBJ = tests/test_client.o
$(TEST_CLIENT_OBJ): tests/test_client.c src/tui.h
$(CC) $(CFLAGS) $(CARLA_INC) -c -o $@ $<
$(TEST_CLIENT_BIN): $(TEST_CLIENT_OBJ) src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ)
$(TEST_CLIENT_BIN): $(TEST_CLIENT_OBJ) src/tui.c $(PLUGINS_OBJ) $(CARLA_OBJ) $(CLIENT_CMD_OBJ) $(SCRIPT_OBJ)
$(CC) $(CFLAGS) $(CARLA_INC) -o $@ $^ $(CARLA_LIB) -ljack -lncurses
# --- Carla host tests ---
@@ -91,7 +106,7 @@ $(TEST_CARLA_MOCK_BIN): tests/test_carla_host_mock.c $(CARLA_MOCK_OBJ)
$(TEST_INTEGRATION_BIN): tests/test_integration.c $(CARLA_TEST_OBJ)
$(CC) $(CFLAGS) $(CARLA_INC) -DTESTING -o $@ $^ $(CARLA_LIB) -ljack
test: looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(TEST_CARLA_BIN) $(TEST_CLIENT_CMD_BIN) $(TEST_INTEGRATION_BIN) $(TEST_CARLA_MOCK_BIN)
test: looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(TEST_CARLA_BIN) $(TEST_CLIENT_CMD_BIN) $(TEST_INTEGRATION_BIN) $(TEST_CARLA_MOCK_BIN) $(TEST_SCRIPT_BIN)
./test_status_parse
./$(TEST_PLUGINS_BIN)
./$(TEST_CLIENT_BIN)
@@ -99,8 +114,9 @@ test: looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(T
./$(TEST_CLIENT_CMD_BIN)
./$(TEST_INTEGRATION_BIN)
./$(TEST_CARLA_MOCK_BIN)
./$(TEST_SCRIPT_BIN)
.PHONY: all test clean
clean:
rm -f looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(TEST_CARLA_BIN) $(TEST_CLIENT_CMD_BIN) $(TEST_INTEGRATION_BIN) $(TEST_CARLA_MOCK_BIN) *.o tests/*.o src/*.o
rm -f looper-client test_status_parse $(TEST_PLUGINS_BIN) $(TEST_CLIENT_BIN) $(TEST_CARLA_BIN) $(TEST_CLIENT_CMD_BIN) $(TEST_INTEGRATION_BIN) $(TEST_CARLA_MOCK_BIN) $(TEST_SCRIPT_BIN) *.o tests/*.o src/*.o

View File

@@ -1,6 +1,28 @@
#include "tui.h"
#include "script.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
const char *script_path = NULL;
if (argc > 2 && strcmp(argv[1], "-s") == 0) {
script_path = argv[2];
} else {
const char *home = getenv("HOME");
if (home) {
static char default_path[1024];
snprintf(default_path, sizeof(default_path),
"%s/.config/looper/scripts/launchpad.rc", home);
script_path = default_path;
}
}
if (script_path && script_load(script_path) != 0) {
fprintf(stderr, "Warning: could not load script '%s'\n", script_path);
}
int main(void) {
tui_init();
tui_run();
tui_cleanup();

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

@@ -0,0 +1,71 @@
#define _GNU_SOURCE
#include "script.h"
#include "tui.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define MAX_NOTES 128
static char *note_actions[MAX_NOTES] = {0};
int script_load(const char *path) {
FILE *fp = fopen(path, "r");
if (!fp) return -1;
char line[512];
while (fgets(line, sizeof(line), fp)) {
char *s = line;
while (*s == ' ' || *s == '\t') s++;
if (*s == '#' || *s == '\n') continue;
int note;
char macro[256];
int matched = sscanf(s, "%d %255[^\n]", &note, macro);
if (matched >= 1 && note >= 0 && note < MAX_NOTES) {
free(note_actions[note]);
if (matched == 2) {
// Trim leading and trailing whitespace from macro
char *start = macro;
while (*start == ' ' || *start == '\t') start++;
char *end = start + strlen(start) - 1;
while (end > start && (*end == ' ' || *end == '\t')) end--;
*(end + 1) = '\0';
if (*start == '\0') {
note_actions[note] = NULL;
} else {
note_actions[note] = strdup(start);
}
} else {
note_actions[note] = NULL;
}
}
}
fclose(fp);
return 0;
}
void script_handle_note(int note) {
if (note < 0 || note >= MAX_NOTES) return;
char *macro = note_actions[note];
if (!macro) return;
char macro_copy[512];
strncpy(macro_copy, macro, sizeof(macro_copy) - 1);
macro_copy[sizeof(macro_copy) - 1] = '\0';
char *token = strtok(macro_copy, ";");
while (token) {
while (*token == ' ') token++;
if (*token == '\0') {
token = strtok(NULL, ";");
continue;
}
char *end = token + strlen(token) - 1;
while (end > token && *end == ' ') end--;
*(end + 1) = '\0';
send_command(token);
token = strtok(NULL, ";");
}
}

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

@@ -0,0 +1,7 @@
#ifndef SCRIPT_H
#define SCRIPT_H
int script_load(const char *path);
void script_handle_note(int note);
#endif

View File

@@ -12,6 +12,7 @@
#include "carla_host.h"
#include "client_cmd.h"
#include "plugins.h"
#include "script.h"
#include <CarlaHost.h>
/* ---------- FIFO command helper ---------- */
@@ -43,6 +44,7 @@ static const char *clip_state_string(ClipState s) { (void)s; return "?"; }
/* status FIFO path */
#define STATUS_FIFO "/tmp/looper_status"
#define CMD_FIFO "/tmp/looper_cmd"
#define NOTES_FIFO "/tmp/looper_notes"
/* Percell state array (indexed by row*GRID_COLS+col) */
typedef enum { STATE_IDLE, STATE_RECORD, STATE_LOOPING, STATE_PAUSED } ChannelState;
@@ -193,6 +195,9 @@ void tui_init(void) {
cell_state[i] = STATE_IDLE;
/* open the JACK client used for Carla plugins */
carla_init_jack();
/* create note FIFO (for scripted controller input) */
unlink(NOTES_FIFO);
mkfifo(NOTES_FIFO, 0666);
}
/* ---------- TUI run ---------- */
@@ -229,6 +234,28 @@ void tui_run(void) {
close(fd);
}
/* read any available note events (for script macros) */
int nfd = open(NOTES_FIFO, O_RDONLY | O_NONBLOCK);
if (nfd >= 0) {
char nbuf[256];
int m = read(nfd, nbuf, sizeof(nbuf)-1);
if (m > 0) {
nbuf[m] = '\0';
char *p = nbuf;
while (*p) {
char *nl = strchr(p, '\n');
if (nl) *nl = '\0';
int note = atoi(p);
script_handle_note(note);
if (nl) {
*nl = '\n';
p = nl + 1;
} else break;
}
}
close(nfd);
}
if (in_colon) {
int chc = getch();
if (chc == '\n') {
@@ -374,6 +401,7 @@ void tui_cleanup(void) {
/* delete FIFOs */
unlink(STATUS_FIFO);
unlink(CMD_FIFO);
unlink(NOTES_FIFO);
/* close the Carla JACK client */
carla_cleanup_jack();
curs_set(1); endwin();

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

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