feat: add parallel MIDI grid with separate clip storage and view toggle

Co-authored-by: aider (deepseek/deepseek-coder) <aider@aider.chat>
This commit is contained in:
Loic Coenen
2026-05-03 18:49:21 +00:00
parent 5e4d4e4d44
commit 61ab2f0b19
6 changed files with 216 additions and 25 deletions

12
cli.c
View File

@@ -58,6 +58,7 @@ int cli_process_line(Engine *engine, const char *line) {
printf(" bpm <value> - Set BPM (1.0-999.0)\n");
printf(" load <clip> <file> - Load WAV file into clip\n");
printf(" save <clip> - Save clip to samples/clip_<N>.wav\n");
printf(" grid audio|midi - Switch between Audio and MIDI grid\n");
printf(" help - Show this help\n");
printf(" quit - Exit CLI\n");
return 1;
@@ -189,6 +190,17 @@ int cli_process_line(Engine *engine, const char *line) {
engine->dispatch(action);
printf("Saving clip %d...\n", clip_idx);
}
else if (strcasecmp(token, "grid") == 0) {
char *mode_str = strtok(NULL, " \t");
if (!mode_str) {
printf("Usage: grid audio|midi\n");
return 1;
}
bool show_midi = (strcasecmp(mode_str, "midi") == 0);
Action action = { .type = ACTION_SET_SHOW_MIDI_GRID, .data.set_show_midi_grid = { .show = show_midi } };
engine->dispatch(action);
printf("Grid: %s\n", show_midi ? "MIDI" : "AUDIO");
}
else if (strcasecmp(token, "bpm") == 0) {
char *bpm_str = strtok(NULL, " \t");
if (!bpm_str) {

View File

@@ -167,6 +167,34 @@ static AppState clip_trigger(AppState state, int clip_index) {
return state;
}
static AppState midi_clip_trigger(AppState state, int clip_index) {
if (clip_index < 0 || clip_index >= MAX_CLIPS) return state;
MidiClip *clip = &state.midi_clips[clip_index];
switch (clip->state) {
case CLIP_EMPTY:
clip->state = CLIP_RECORDING;
clip->event_count = 0;
clip->read_index = 0;
break;
case CLIP_RECORDING:
clip->state = CLIP_LOOPING;
clip->read_index = 0;
break;
case CLIP_LOOPING:
clip->state = CLIP_STOPPED;
clip->read_index = 0;
break;
case CLIP_STOPPED:
clip->state = CLIP_LOOPING;
clip->read_index = 0;
break;
}
return state;
}
static AppState scene_trigger(AppState state, int scene_index) {
if (scene_index < 0 || scene_index >= MAX_SCENES * 8) return state; // 8 grids
@@ -454,6 +482,32 @@ AppState reducer(AppState state, Action action) {
case ACTION_PROCESS_AUDIO:
return state;
case ACTION_SET_SHOW_MIDI_GRID:
state.show_midi_grid = action.data.set_show_midi_grid.show;
return state;
case ACTION_MIDI_CLIP_TRIGGER:
return midi_clip_trigger(state, action.data.midi_clip_trigger.clip_index);
case ACTION_MIDI_CLIP_RESET: {
int idx = action.data.midi_clip_reset.clip_index;
if (idx >= 0 && idx < MAX_CLIPS) {
state.midi_clips[idx].state = CLIP_EMPTY;
state.midi_clips[idx].event_count = 0;
state.midi_clips[idx].read_index = 0;
}
return state;
}
case ACTION_SET_CHANNEL_NAME: {
int ch = action.data.set_channel_name.channel;
if (ch >= 0 && ch < MAX_CHANNELS) {
strncpy(state.channel_names[ch], action.data.set_channel_name.name, 63);
state.channel_names[ch][63] = '\0';
}
return state;
}
case ACTION_SAVE_PROJECT: {
fs_save_project(action.data.save_project.filename, &state);
return state;

View File

@@ -46,6 +46,22 @@ typedef enum {
CLOCK_SOURCE_MIDI
} ClockSource;
typedef struct {
uint8_t note;
uint8_t velocity;
uint32_t timestamp; // sample offset within the clip
} MidiEvent;
#define MAX_MIDI_EVENTS 44100 // ~1 second of dense MIDI data
typedef struct {
ClipState state;
MidiEvent events[MAX_MIDI_EVENTS];
int event_count;
int read_index;
char channel_name[64]; // per-channel name for MIDI grid
} MidiClip;
typedef struct {
ClipState state;
float *buffer;
@@ -73,6 +89,11 @@ typedef struct {
// Clips
Clip clips[MAX_CLIPS];
// MIDI clips (separate grid)
MidiClip midi_clips[MAX_CLIPS];
bool show_midi_grid; // View toggle: true = MIDI grid, false = Audio grid
char channel_names[MAX_CHANNELS][64]; // Channel names for audio grid
// Undo history
struct {
int undo_index;
@@ -126,7 +147,11 @@ typedef enum {
ACTION_SAVE_PROJECT,
ACTION_LOAD_PROJECT,
ACTION_PROCESS_AUDIO,
ACTION_QUIT
ACTION_QUIT,
ACTION_SET_SHOW_MIDI_GRID,
ACTION_MIDI_CLIP_TRIGGER,
ACTION_MIDI_CLIP_RESET,
ACTION_SET_CHANNEL_NAME
} ActionType;
typedef struct {
@@ -151,6 +176,10 @@ typedef struct {
struct { char filename[512]; } save_project;
struct { char filename[512]; } load_project;
struct { jack_nframes_t nframes; } process_audio;
struct { bool show; } set_show_midi_grid;
struct { int clip_index; } midi_clip_trigger;
struct { int clip_index; } midi_clip_reset;
struct { int channel; char name[64]; } set_channel_name;
} data;
} Action;

View File

@@ -3,6 +3,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
static int process_callback(jack_nframes_t nframes, void *arg) {
Engine *engine = (Engine *)arg;
@@ -77,6 +78,34 @@ static int process_callback(jack_nframes_t nframes, void *arg) {
}
}
// MIDI clip playback
for (int ch = 0; ch < MAX_CHANNELS; ch++) {
for (int s = 0; s < MAX_SCENES; s++) {
for (int g = 0; g < 8; g++) {
int clip_idx = g * GRID_ROWS * GRID_COLS + s * GRID_COLS + ch;
MidiClip *mclip = &state.midi_clips[clip_idx];
if (mclip->state == CLIP_LOOPING && mclip->event_count > 0) {
if (mclip->read_index < mclip->event_count) {
int idx = mclip->read_index;
uint8_t msg[3] = {
0x90 | ch,
mclip->events[idx].note,
mclip->events[idx].velocity
};
jack_midi_event_write(midi_out_buf,
mclip->events[idx].timestamp, msg, 3);
mclip->read_index++;
if (mclip->read_index >= mclip->event_count) {
mclip->read_index = 0;
}
}
}
}
}
}
// Process audio per-channel
for (int ch = 0; ch < MAX_CHANNELS; ch++) {
memset(audio_out[ch], 0, sizeof(jack_default_audio_sample_t) * nframes);

11
main.c
View File

@@ -95,8 +95,19 @@ int main(int argc, char *argv[]) {
initial_state.clips[i].buffer_size = 0;
initial_state.clips[i].write_position = 0;
initial_state.clips[i].read_position = 0;
// Initialize MIDI clips
initial_state.midi_clips[i].state = CLIP_EMPTY;
initial_state.midi_clips[i].event_count = 0;
initial_state.midi_clips[i].read_index = 0;
}
// Initialize channel names
for (int ch = 0; ch < MAX_CHANNELS; ch++) {
snprintf(initial_state.channel_names[ch], 64, "Channel %d", ch);
}
initial_state.show_midi_grid = false;
// Carla host is now initialized in engine_init
// Initialize dispatcher
dispatch = dispatcher_init(&initial_state);

104
tui.c
View File

@@ -749,12 +749,16 @@ static bool handle_rack_view(int ch) {
static void draw_cell(int grid, int row, int col, bool selected) {
int clip_idx = grid_to_clip_index(grid, row, col);
AppState state = dispatcher_get_state();
Clip *clip = &state.clips[clip_idx];
int y = row * CELL_HEIGHT + 3; // Offset by 2 for grid selector
int x = col * CELL_WIDTH + 1;
int color = state_to_color(clip->state);
int color;
if (state.show_midi_grid) {
color = state_to_color(state.midi_clips[clip_idx].state);
} else {
color = state_to_color(state.clips[clip_idx].state);
}
if (selected) {
color = COLOR_SELECTED;
} else if (current_mode == MODE_VISUAL && is_in_visual_selection(row, col)) {
@@ -769,17 +773,31 @@ static void draw_cell(int grid, int row, int col, bool selected) {
}
}
mvprintw(y + 1, x + 1, "%2d", clip_idx);
char state_char;
switch (clip->state) {
case CLIP_EMPTY: state_char = ' '; break;
case CLIP_RECORDING: state_char = 'R'; break;
case CLIP_LOOPING: state_char = 'L'; break;
case CLIP_STOPPED: state_char = 'S'; break;
default: state_char = '?'; break;
if (state.show_midi_grid) {
MidiClip *mclip = &state.midi_clips[clip_idx];
mvprintw(y + 1, x + 1, "%2d", clip_idx);
char state_char;
switch (mclip->state) {
case CLIP_EMPTY: state_char = ' '; break;
case CLIP_RECORDING: state_char = 'R'; break;
case CLIP_LOOPING: state_char = 'L'; break;
case CLIP_STOPPED: state_char = 'S'; break;
default: state_char = '?'; break;
}
mvaddch(y + 1, x + 4, state_char);
} else {
Clip *clip = &state.clips[clip_idx];
mvprintw(y + 1, x + 1, "%2d", clip_idx);
char state_char;
switch (clip->state) {
case CLIP_EMPTY: state_char = ' '; break;
case CLIP_RECORDING: state_char = 'R'; break;
case CLIP_LOOPING: state_char = 'L'; break;
case CLIP_STOPPED: state_char = 'S'; break;
default: state_char = '?'; break;
}
mvaddch(y + 1, x + 4, state_char);
}
mvaddch(y + 1, x + 4, state_char);
attroff(COLOR_PAIR(color));
}
@@ -827,7 +845,11 @@ static void draw_grid(void) {
// Normal grid view
attron(A_BOLD);
mvprintw(0, 0, "JACK Looper - 8x8 Clip Grid (Grid %d)", selected_grid);
if (state.show_midi_grid) {
mvprintw(0, 0, "JACK Looper - MIDI Grid %d", selected_grid);
} else {
mvprintw(0, 0, "JACK Looper - Audio Grid %d", selected_grid);
}
attroff(A_BOLD);
// Draw grid selector bar at top
@@ -852,19 +874,26 @@ static void draw_grid(void) {
}
int clip_idx = grid_to_clip_index(selected_grid, selected_row, selected_col);
Clip *clip = &state.clips[clip_idx];
mvprintw(GRID_ROWS * CELL_HEIGHT + 3, 0,
"Selected: Clip %d | Grid %d | State: %s | Buffer: %zu samples",
clip_idx, selected_grid, clip_state_to_string(clip->state), clip->buffer_size);
if (state.show_midi_grid) {
MidiClip *mclip = &state.midi_clips[clip_idx];
mvprintw(GRID_ROWS * CELL_HEIGHT + 3, 0,
"Selected: Clip %d | Grid %d | State: %s | MIDI events: %d",
clip_idx, selected_grid, clip_state_to_string(mclip->state), mclip->event_count);
} else {
Clip *clip = &state.clips[clip_idx];
mvprintw(GRID_ROWS * CELL_HEIGHT + 3, 0,
"Selected: Clip %d | Grid %d | State: %s | Buffer: %zu samples",
clip_idx, selected_grid, clip_state_to_string(clip->state), clip->buffer_size);
}
mvprintw(GRID_ROWS * CELL_HEIGHT + 4, 0,
"Transport: %s | Clock: %s | BPM: %.1f | Quantize: %s | Threshold: %u",
"Transport: %s | Clock: %s | BPM: %.1f | Quantize: %s | Grid: %s",
transport_state_to_string(state.transport_state),
clock_source_to_string(state.clock_source),
state.bpm,
quantize_mode_to_string(state.quantize_mode),
(unsigned int)state.quantize_threshold);
state.show_midi_grid ? "MIDI" : "AUDIO");
const char *mode_str;
switch (current_mode) {
@@ -893,6 +922,7 @@ static void draw_grid(void) {
mvprintw(GRID_ROWS * CELL_HEIGHT + 19, 0, "Esc/q - Quit");
mvprintw(GRID_ROWS * CELL_HEIGHT + 20, 0, "In fuzzy search: j/k navigate, h/l page, Enter select, Esc cancel");
mvprintw(GRID_ROWS * CELL_HEIGHT + 21, 0, "L - Load sample into selected clip (fuzzy search)");
mvprintw(GRID_ROWS * CELL_HEIGHT + 22, 0, "G - Toggle Audio/MIDI grid");
attroff(COLOR_PAIR(COLOR_HELP));
}
@@ -1217,7 +1247,15 @@ void tui_run(Engine *engine) {
int count;
int *clips = get_selected_clips(&count);
if (clips) {
delete_clips(clips, count);
AppState s = dispatcher_get_state();
if (s.show_midi_grid) {
for (int i = 0; i < count; i++) {
Action action = { .type = ACTION_MIDI_CLIP_RESET, .data.midi_clip_reset = { .clip_index = clips[i] } };
g_dispatch(action);
}
} else {
delete_clips(clips, count);
}
free(clips);
}
current_mode = MODE_NORMAL;
@@ -1257,14 +1295,26 @@ void tui_run(Engine *engine) {
break;
case 't': {
int clip_idx = grid_to_clip_index(selected_grid, selected_row, selected_col);
Action action = { .type = ACTION_TRIGGER_CLIP, .data.trigger_clip = { .clip_index = clip_idx } };
g_dispatch(action);
AppState s = dispatcher_get_state();
if (s.show_midi_grid) {
Action action = { .type = ACTION_MIDI_CLIP_TRIGGER, .data.midi_clip_trigger = { .clip_index = clip_idx } };
g_dispatch(action);
} else {
Action action = { .type = ACTION_TRIGGER_CLIP, .data.trigger_clip = { .clip_index = clip_idx } };
g_dispatch(action);
}
break;
}
case 'd': {
int clip_idx = grid_to_clip_index(selected_grid, selected_row, selected_col);
Action action = { .type = ACTION_RESET_CLIP, .data.reset_clip = { .clip_index = clip_idx } };
g_dispatch(action);
AppState s = dispatcher_get_state();
if (s.show_midi_grid) {
Action action = { .type = ACTION_MIDI_CLIP_RESET, .data.midi_clip_reset = { .clip_index = clip_idx } };
g_dispatch(action);
} else {
Action action = { .type = ACTION_RESET_CLIP, .data.reset_clip = { .clip_index = clip_idx } };
g_dispatch(action);
}
break;
}
case 'y': {
@@ -1335,6 +1385,12 @@ void tui_run(Engine *engine) {
zoom_mode = !zoom_mode;
break;
}
case 'G': {
AppState s = dispatcher_get_state();
Action a = { .type = ACTION_SET_SHOW_MIDI_GRID, .data.set_show_midi_grid = { .show = !s.show_midi_grid } };
g_dispatch(a);
break;
}
case 'L': {
// Start fuzzy search for WAV files
const char *samples_dir = getenv("JACK_LOOPER_SAMPLES_DIR");