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