feat: integrate Carla plugin host with rack view, fuzzy search, and volume control

Co-authored-by: aider (deepseek/deepseek-coder) <aider@aider.chat>
This commit is contained in:
Loic Coenen
2026-05-02 21:29:56 +00:00
parent fd00d51ff7
commit 6bd2e762cb
7 changed files with 932 additions and 5 deletions

471
tui.c
View File

@@ -1,11 +1,13 @@
#include "tui.h"
#include "wav_io.h"
#include "transport.h"
#include "carla.h"
#include <ncurses.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
#include <stdatomic.h>
#include <ctype.h>
#define GRID_ROWS 8
#define GRID_COLS 8
@@ -28,6 +30,31 @@ static int selected_row = 0;
static int selected_col = 0;
static bool show_help = false;
// View modes
typedef enum {
VIEW_GRID,
VIEW_RACK
} UIView;
static UIView current_view = VIEW_GRID;
static int rack_selected_channel = 0;
static int rack_selected_plugin = -1;
static int rack_selected_param = -1;
// Fuzzy search state
typedef struct {
char query[256];
int query_len;
int selected_index;
int num_results;
int result_indices[256];
bool active;
char prompt[64];
void (*callback)(const char *selection);
} FuzzySearch;
static FuzzySearch fuzzy_search = {0};
// Modes
typedef enum {
MODE_NORMAL,
@@ -194,6 +221,345 @@ static void play_prev_scene(void) {
selected_row = prev_row;
}
// Fuzzy matching function
static bool fuzzy_match(const char *pattern, const char *text) {
if (!pattern || !*pattern) return true;
if (!text) return false;
while (*pattern) {
char p = *pattern;
while (*text && tolower((unsigned char)*text) != tolower((unsigned char)p)) text++;
if (!*text) return false;
text++;
pattern++;
}
return true;
}
// Draw fuzzy search
static void draw_fuzzy_search(void) {
int start_y = LINES / 3;
int start_x = COLS / 4;
int width = COLS / 2;
int height = LINES / 3;
// Draw border
for (int y = start_y - 1; y <= start_y + height; y++) {
for (int x = start_x - 1; x <= start_x + width; x++) {
mvaddch(y, x, ' ');
}
}
// Draw title
attron(A_BOLD);
mvprintw(start_y - 1, start_x, "%s", fuzzy_search.prompt);
attroff(A_BOLD);
// Draw search box
mvprintw(start_y, start_x, "> %s", fuzzy_search.query);
// Draw results
for (int i = 0; i < height - 2 && i < fuzzy_search.num_results; i++) {
int idx = fuzzy_search.result_indices[i];
if (i == fuzzy_search.selected_index) {
attron(A_REVERSE);
}
int count;
const char **plugins = carla_get_available_plugins(NULL, &count);
if (plugins && idx >= 0 && idx < count) {
mvprintw(start_y + 1 + i, start_x, "%s", plugins[idx]);
}
if (i == fuzzy_search.selected_index) {
attroff(A_REVERSE);
}
}
}
// Handle fuzzy search input
static bool handle_fuzzy_search(int ch) {
if (!fuzzy_search.active) return false;
switch (ch) {
case '\n': case '\r': {
if (fuzzy_search.num_results > 0 && fuzzy_search.selected_index >= 0) {
int idx = fuzzy_search.result_indices[fuzzy_search.selected_index];
int count;
const char **plugins = carla_get_available_plugins(NULL, &count);
if (plugins && idx >= 0 && idx < count && fuzzy_search.callback) {
fuzzy_search.callback(plugins[idx]);
}
}
fuzzy_search.active = false;
return true;
}
case 27: { // ESC
fuzzy_search.active = false;
return true;
}
case KEY_BACKSPACE: case 127: {
if (fuzzy_search.query_len > 0) {
fuzzy_search.query[--fuzzy_search.query_len] = '\0';
// Update results
fuzzy_search.num_results = 0;
int count;
const char **plugins = carla_get_available_plugins(NULL, &count);
for (int i = 0; i < count; i++) {
if (fuzzy_match(fuzzy_search.query, plugins[i])) {
fuzzy_search.result_indices[fuzzy_search.num_results++] = i;
}
}
if (fuzzy_search.selected_index >= fuzzy_search.num_results) {
fuzzy_search.selected_index = fuzzy_search.num_results - 1;
}
}
return true;
}
case KEY_UP: {
if (fuzzy_search.selected_index > 0) {
fuzzy_search.selected_index--;
}
return true;
}
case KEY_DOWN: {
if (fuzzy_search.selected_index < fuzzy_search.num_results - 1) {
fuzzy_search.selected_index++;
}
return true;
}
default: {
if (ch >= 32 && ch <= 126 && fuzzy_search.query_len < 255) {
fuzzy_search.query[fuzzy_search.query_len++] = (char)ch;
fuzzy_search.query[fuzzy_search.query_len] = '\0';
// Update results
fuzzy_search.num_results = 0;
int count;
const char **plugins = carla_get_available_plugins(NULL, &count);
for (int i = 0; i < count; i++) {
if (fuzzy_match(fuzzy_search.query, plugins[i])) {
fuzzy_search.result_indices[fuzzy_search.num_results++] = i;
}
}
fuzzy_search.selected_index = 0;
}
return true;
}
}
}
// Start fuzzy search
static void start_fuzzy_search(const char *prompt, void (*callback)(const char *)) {
fuzzy_search.active = true;
fuzzy_search.query_len = 0;
fuzzy_search.query[0] = '\0';
fuzzy_search.selected_index = 0;
strncpy(fuzzy_search.prompt, prompt, sizeof(fuzzy_search.prompt) - 1);
fuzzy_search.prompt[sizeof(fuzzy_search.prompt) - 1] = '\0';
fuzzy_search.callback = callback;
// Initialize results with all plugins
fuzzy_search.num_results = 0;
int count;
const char **plugins = carla_get_available_plugins(NULL, &count);
for (int i = 0; i < count; i++) {
fuzzy_search.result_indices[fuzzy_search.num_results++] = i;
}
}
// Draw rack view
static void draw_rack_view(void) {
clear();
attron(A_BOLD);
mvprintw(0, 0, "JACK Looper - Rack View (Channel %d)", rack_selected_channel);
attroff(A_BOLD);
AppState state = dispatcher_get_state();
ChannelRack *rack = &state.carla_host.channel_racks[rack_selected_channel];
// Draw channel selector
mvprintw(1, 0, "Channel: ");
for (int ch = 0; ch < 8; ch++) {
if (ch == rack_selected_channel) {
attron(A_REVERSE);
}
mvprintw(1, 10 + ch * 4, "Ch%d", ch);
if (ch == rack_selected_channel) {
attroff(A_REVERSE);
}
}
// Draw volume
mvprintw(2, 0, "Volume: %.2f [ - ] to decrease, [ = ] to increase", rack->volume);
// Draw bypass toggle
mvprintw(3, 0, "Bypass: %s [b] to toggle", rack->bypassed ? "ON" : "OFF");
// Draw plugin chain
mvprintw(4, 0, "=== Plugin Chain ===");
int y = 5;
for (int p = 0; p < rack->num_plugins; p++) {
PluginInfo *plugin = &rack->plugins[p];
if (p == rack_selected_plugin) {
attron(A_REVERSE);
}
mvprintw(y++, 2, "[%d] %s", p, plugin->name);
if (p == rack_selected_plugin) {
attroff(A_REVERSE);
}
// Draw parameters if this plugin is selected
if (p == rack_selected_plugin) {
for (int param = 0; param < plugin->num_parameters; param++) {
if (param == rack_selected_param) {
attron(A_REVERSE);
}
mvprintw(y++, 4, "%s: %.3f",
plugin->parameter_names[param],
plugin->parameters[param]);
if (param == rack_selected_param) {
attroff(A_REVERSE);
}
}
}
}
// Draw controls help
mvprintw(y + 1, 0, "=== Controls ===");
mvprintw(y + 2, 0, "h/l - Previous/Next channel");
mvprintw(y + 3, 0, "j/k - Navigate plugins");
mvprintw(y + 4, 0, "a - Add plugin to rack");
mvprintw(y + 5, 0, "d - Remove selected plugin");
mvprintw(y + 6, 0, "b - Toggle bypass");
mvprintw(y + 7, 0, "-/= - Decrease/Increase volume");
mvprintw(y + 8, 0, "Enter - Select parameter to edit");
mvprintw(y + 9, 0, "Tab - Switch to grid view");
mvprintw(y + 10, 0, "q/Esc - Quit");
refresh();
}
// Handle rack view input
static bool handle_rack_view(int ch) {
AppState state = dispatcher_get_state();
ChannelRack *rack = &state.carla_host.channel_racks[rack_selected_channel];
switch (ch) {
case 'h': case KEY_LEFT:
rack_selected_channel = (rack_selected_channel - 1 + 8) % 8;
rack_selected_plugin = -1;
rack_selected_param = -1;
break;
case 'l': case KEY_RIGHT:
rack_selected_channel = (rack_selected_channel + 1) % 8;
rack_selected_plugin = -1;
rack_selected_param = -1;
break;
case 'j': case KEY_DOWN:
if (rack_selected_plugin < rack->num_plugins - 1) {
rack_selected_plugin++;
rack_selected_param = -1;
}
break;
case 'k': case KEY_UP:
if (rack_selected_plugin > 0) {
rack_selected_plugin--;
rack_selected_param = -1;
} else if (rack_selected_plugin == 0) {
rack_selected_plugin = -1;
}
break;
case 'a': {
// Start fuzzy search for plugin selection
start_fuzzy_search("Add plugin:", [](const char *selection) {
if (selection) {
// Parse URI from selection (format: "uri - name")
char uri[512];
const char *space = strchr(selection, ' ');
if (space) {
size_t len = space - selection;
strncpy(uri, selection, len);
uri[len] = '\0';
} else {
strncpy(uri, selection, sizeof(uri) - 1);
}
Action action = {
.type = ACTION_RACK_ADD_PLUGIN,
.data.rack_add_plugin = {
.channel = rack_selected_channel,
.type = PLUGIN_TYPE_INTERNAL
}
};
strncpy(action.data.rack_add_plugin.uri, uri, sizeof(action.data.rack_add_plugin.uri) - 1);
action.data.rack_add_plugin.uri[sizeof(action.data.rack_add_plugin.uri) - 1] = '\0';
g_dispatch(action);
}
});
break;
}
case 'd': {
if (rack_selected_plugin >= 0 && rack_selected_plugin < rack->num_plugins) {
Action action = {
.type = ACTION_RACK_REMOVE_PLUGIN,
.data.rack_remove_plugin = {
.channel = rack_selected_channel,
.plugin_index = rack_selected_plugin
}
};
g_dispatch(action);
rack_selected_plugin = -1;
rack_selected_param = -1;
}
break;
}
case 'b': {
Action action = {
.type = ACTION_RACK_BYPASS,
.data.rack_bypass = {
.channel = rack_selected_channel,
.bypass = !rack->bypassed
}
};
g_dispatch(action);
break;
}
case '-': case '_': {
float new_vol = fmaxf(0.0f, rack->volume - 0.1f);
Action action = {
.type = ACTION_RACK_SET_VOLUME,
.data.rack_set_volume = {
.channel = rack_selected_channel,
.volume = new_vol
}
};
g_dispatch(action);
break;
}
case '=': case '+': {
float new_vol = fminf(2.0f, rack->volume + 0.1f);
Action action = {
.type = ACTION_RACK_SET_VOLUME,
.data.rack_set_volume = {
.channel = rack_selected_channel,
.volume = new_vol
}
};
g_dispatch(action);
break;
}
case '\t': {
current_view = VIEW_GRID;
break;
}
case 'q': case 27:
return true; // Quit
}
return false;
}
// Draw a single cell
static void draw_cell(int row, int col, bool selected) {
int clip_idx = grid_to_clip_index(row, col);
@@ -318,6 +684,40 @@ static bool handle_command_mode(void) {
if (strcmp(cmd_buffer, "q") == 0) {
nodelay(stdscr, prev_nodelay);
return true;
} else if (strcmp(cmd_buffer, "view rack") == 0) {
current_view = VIEW_RACK;
rack_selected_channel = selected_row;
rack_selected_plugin = -1;
rack_selected_param = -1;
} else if (strncmp(cmd_buffer, "rack ", 5) == 0) {
// :rack <plugin_name> - add plugin to selected channel's rack
const char *plugin_name = cmd_buffer + 5;
Action action = {
.type = ACTION_RACK_ADD_PLUGIN,
.data.rack_add_plugin = {
.channel = selected_row,
.type = PLUGIN_TYPE_INTERNAL
}
};
strncpy(action.data.rack_add_plugin.uri, plugin_name, sizeof(action.data.rack_add_plugin.uri) - 1);
action.data.rack_add_plugin.uri[sizeof(action.data.rack_add_plugin.uri) - 1] = '\0';
g_dispatch(action);
} else if (strcmp(cmd_buffer, "from") == 0) {
// :from - open fuzzy search for MIDI input source
start_fuzzy_search("Select MIDI input source:", [](const char *selection) {
if (selection) {
printf("Selected MIDI input: %s\n", selection);
// In a real implementation, this would connect JACK ports
}
});
} else if (strcmp(cmd_buffer, "to") == 0) {
// :to - open fuzzy search for MIDI output destination
start_fuzzy_search("Select MIDI output destination:", [](const char *selection) {
if (selection) {
printf("Selected MIDI output: %s\n", selection);
// In a real implementation, this would connect JACK ports
}
});
} else if (strncmp(cmd_buffer, "load ", 5) == 0) {
char *rest = cmd_buffer + 5;
int clip_idx = atoi(rest);
@@ -434,9 +834,42 @@ void tui_run(Engine *engine) {
marks[i] = -1;
}
// Initialize Carla
AppState init_state = dispatcher_get_state();
carla_init(&init_state.carla_host, engine->client);
carla_scan_plugins(&init_state.carla_host);
draw_grid();
while (1) {
// Handle fuzzy search first
if (fuzzy_search.active) {
draw_fuzzy_search();
int ch = getch();
if (handle_fuzzy_search(ch)) {
// Fuzzy search closed, redraw current view
if (current_view == VIEW_GRID) {
draw_grid();
} else {
draw_rack_view();
}
} else {
draw_fuzzy_search();
}
continue;
}
// Handle current view
if (current_view == VIEW_RACK) {
draw_rack_view();
int ch = getch();
if (handle_rack_view(ch)) {
return; // Quit
}
continue;
}
// Grid view (existing code)
int ch = getch();
if (ch == ERR) {
break;
@@ -651,6 +1084,44 @@ void tui_run(Engine *engine) {
g_dispatch(action);
break;
}
case '-': case '_': {
// Decrease volume of selected channel
AppState state = dispatcher_get_state();
float current_vol = carla_get_channel_volume(&state.carla_host, selected_row);
float new_vol = fmaxf(0.0f, current_vol - 0.1f);
Action action = {
.type = ACTION_RACK_SET_VOLUME,
.data.rack_set_volume = {
.channel = selected_row,
.volume = new_vol
}
};
g_dispatch(action);
break;
}
case '=': case '+': {
// Increase volume of selected channel
AppState state = dispatcher_get_state();
float current_vol = carla_get_channel_volume(&state.carla_host, selected_row);
float new_vol = fminf(2.0f, current_vol + 0.1f);
Action action = {
.type = ACTION_RACK_SET_VOLUME,
.data.rack_set_volume = {
.channel = selected_row,
.volume = new_vol
}
};
g_dispatch(action);
break;
}
case '\t': {
// Switch to rack view
current_view = VIEW_RACK;
rack_selected_channel = selected_row;
rack_selected_plugin = -1;
rack_selected_param = -1;
break;
}
case 27: case 'Q':
return;
default: