From cce8d05069bad691a7b12f7d25a86f76a3b7f7b6 Mon Sep 17 00:00:00 2001 From: Loic Coenen Date: Fri, 1 May 2026 00:41:53 +0000 Subject: [PATCH] feat: add JACK audio looper with clip state machine and tests Co-authored-by: aider (deepseek/deepseek-coder) --- Let's produce the blocks.makefile | 28 +++ engine.c | 257 +++++++++++++++++++++++++++ engine.h | 56 ++++++ main.c | 84 +++++++++ test_engine.c | 278 ++++++++++++++++++++++++++++++ 5 files changed, 703 insertions(+) create mode 100644 Let's produce the blocks.makefile create mode 100644 engine.c create mode 100644 engine.h create mode 100644 test_engine.c diff --git a/Let's produce the blocks.makefile b/Let's produce the blocks.makefile new file mode 100644 index 0000000..2fc0513 --- /dev/null +++ b/Let's produce the blocks.makefile @@ -0,0 +1,28 @@ +CC = gcc +CFLAGS = -Wall -Wextra -O2 -g `pkg-config --cflags jack` +LDFLAGS = `pkg-config --libs jack` +TARGET = jack-looper +SRCS = main.c engine.c +OBJS = $(SRCS:.c=.o) +TEST_SRCS = test_engine.c +TEST_OBJS = $(TEST_SRCS:.c=.o) +TEST_TARGET = test_engine + +.PHONY: all clean test + +all: $(TARGET) + +$(TARGET): $(OBJS) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +%.o: %.c + $(CC) $(CFLAGS) -c -o $@ $< + +test: $(TEST_TARGET) + ./$(TEST_TARGET) + +$(TEST_TARGET): $(TEST_OBJS) engine.o + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +clean: + rm -f $(OBJS) $(TEST_OBJS) $(TARGET) $(TEST_TARGET) diff --git a/engine.c b/engine.c new file mode 100644 index 0000000..b0c791c --- /dev/null +++ b/engine.c @@ -0,0 +1,257 @@ +#include "engine.h" +#include +#include +#include +#include + +// JACK process callback +static int process_callback(jack_nframes_t nframes, void *arg) { + Engine *engine = (Engine *)arg; + + // Get ports + jack_default_audio_sample_t *audio_in = (jack_default_audio_sample_t *) + jack_port_get_buffer(engine->audio_in_port, nframes); + jack_default_audio_sample_t *audio_out = (jack_default_audio_sample_t *) + jack_port_get_buffer(engine->audio_out_port, nframes); + void *midi_in_buf = jack_port_get_buffer(engine->midi_in_port, nframes); + void *midi_out_buf = jack_port_get_buffer(engine->midi_out_port, nframes); + + // Clear output MIDI buffer + jack_midi_clear_buffer(midi_out_buf); + + // Process MIDI input + jack_midi_event_t midi_event; + jack_nframes_t event_index = 0; + + while (jack_midi_event_get(&midi_event, midi_in_buf, event_index) == 0) { + event_index++; + + uint8_t *data = midi_event.buffer; + uint8_t status = data[0] & 0xF0; + uint8_t channel = data[0] & 0x0F; + uint8_t note = data[1]; + uint8_t velocity = data[2]; + + // Only process note on messages on the control channel + if (status == 0x90 && channel == engine->control_channel && velocity > 0) { + int clip_index = note % MAX_CLIPS; + engine_trigger_clip(engine, clip_index); + + // Send note with velocity representing state + uint8_t out_velocity = clip_state_to_velocity(engine->clips[clip_index].state); + uint8_t out_msg[3] = {0x90 | channel, note, out_velocity}; + + if (jack_midi_event_write(midi_out_buf, midi_event.time, out_msg, 3) != 0) { + fprintf(stderr, "Failed to write MIDI event\n"); + } + } else { + // Pass through all other MIDI messages + if (jack_midi_event_write(midi_out_buf, midi_event.time, + midi_event.buffer, midi_event.size) != 0) { + fprintf(stderr, "Failed to write MIDI event\n"); + } + } + } + + // Process audio + memset(audio_out, 0, sizeof(jack_default_audio_sample_t) * nframes); + + for (jack_nframes_t i = 0; i < nframes; i++) { + // Record input to recording clips + for (int c = 0; c < MAX_CLIPS; c++) { + Clip *clip = &engine->clips[c]; + + if (clip->state == CLIP_RECORDING) { + if (clip->write_position < MAX_BUFFER_SIZE) { + clip->buffer[clip->write_position++] = audio_in[i]; + } else { + // Buffer full, stop recording + clip->state = CLIP_LOOPING; + clip->buffer_size = clip->write_position; + clip->read_position = 0; + } + } + + // Play looping clips + if (clip->state == CLIP_LOOPING && clip->buffer_size > 0) { + audio_out[i] += clip->buffer[clip->read_position]; + clip->read_position = (clip->read_position + 1) % clip->buffer_size; + } + } + } + + return 0; +} + +// JACK shutdown callback +static void shutdown_callback(jack_status_t code, const char *reason, void *arg) { + Engine *engine = (Engine *)arg; + engine->running = false; + fprintf(stderr, "JACK shutdown: %s\n", reason); +} + +int engine_init(Engine *engine, const char *client_name) { + if (!engine || !client_name) return -1; + + memset(engine, 0, sizeof(Engine)); + engine->control_channel = 0; + engine->running = false; + + // Initialize clips + for (int i = 0; i < MAX_CLIPS; i++) { + engine->clips[i].state = CLIP_EMPTY; + engine->clips[i].buffer = (float *)calloc(MAX_BUFFER_SIZE, sizeof(float)); + if (!engine->clips[i].buffer) { + // Cleanup on allocation failure + for (int j = 0; j < i; j++) { + free(engine->clips[j].buffer); + } + return -1; + } + engine->clips[i].buffer_size = 0; + engine->clips[i].write_position = 0; + engine->clips[i].read_position = 0; + } + + // Open JACK client + jack_status_t status; + engine->client = jack_client_open(client_name, JackNullOption, &status, NULL); + if (!engine->client) { + fprintf(stderr, "Failed to open JACK client, status = 0x%2.0x\n", status); + return -1; + } + + // Register ports + engine->audio_in_port = jack_port_register(engine->client, "audio_in", + JACK_DEFAULT_AUDIO_TYPE, + JackPortIsInput, 0); + engine->audio_out_port = jack_port_register(engine->client, "audio_out", + JACK_DEFAULT_AUDIO_TYPE, + JackPortIsOutput, 0); + engine->midi_in_port = jack_port_register(engine->client, "midi_in", + JACK_DEFAULT_MIDI_TYPE, + JackPortIsInput, 0); + engine->midi_out_port = jack_port_register(engine->client, "midi_out", + JACK_DEFAULT_MIDI_TYPE, + JackPortIsOutput, 0); + + if (!engine->audio_in_port || !engine->audio_out_port || + !engine->midi_in_port || !engine->midi_out_port) { + fprintf(stderr, "Failed to register ports\n"); + engine_cleanup(engine); + return -1; + } + + // Set callbacks + jack_set_process_callback(engine->client, process_callback, engine); + jack_on_shutdown(engine->client, shutdown_callback, engine); + + // Get sample rate + engine->sample_rate = jack_get_sample_rate(engine->client); + + return 0; +} + +void engine_cleanup(Engine *engine) { + if (!engine) return; + + if (engine->client) { + jack_client_close(engine->client); + engine->client = NULL; + } + + for (int i = 0; i < MAX_CLIPS; i++) { + free(engine->clips[i].buffer); + engine->clips[i].buffer = NULL; + } +} + +int engine_start(Engine *engine) { + if (!engine || !engine->client) return -1; + + if (jack_activate(engine->client) != 0) { + fprintf(stderr, "Failed to activate JACK client\n"); + return -1; + } + + engine->running = true; + return 0; +} + +void engine_stop(Engine *engine) { + if (!engine || !engine->client) return; + + engine->running = false; + jack_deactivate(engine->client); +} + +void engine_trigger_clip(Engine *engine, int clip_index) { + if (!engine || clip_index < 0 || clip_index >= MAX_CLIPS) return; + + Clip *clip = &engine->clips[clip_index]; + + switch (clip->state) { + case CLIP_EMPTY: + // Start recording + clip->state = CLIP_RECORDING; + clip->write_position = 0; + clip->buffer_size = 0; + clip->read_position = 0; + printf("Clip %d: Recording started\n", clip_index); + break; + + case CLIP_RECORDING: + // Stop recording, start looping + clip->state = CLIP_LOOPING; + clip->buffer_size = clip->write_position; + clip->read_position = 0; + printf("Clip %d: Recording stopped, looping %zu samples\n", + clip_index, clip->buffer_size); + break; + + case CLIP_LOOPING: + // Stop looping + clip->state = CLIP_STOPPED; + clip->read_position = 0; + printf("Clip %d: Looping stopped\n", clip_index); + break; + + case CLIP_STOPPED: + // Start looping again + clip->state = CLIP_LOOPING; + clip->read_position = 0; + printf("Clip %d: Looping resumed\n", clip_index); + break; + } +} + +void engine_reset_clip(Engine *engine, int clip_index) { + if (!engine || clip_index < 0 || clip_index >= MAX_CLIPS) return; + + Clip *clip = &engine->clips[clip_index]; + clip->state = CLIP_EMPTY; + clip->buffer_size = 0; + clip->write_position = 0; + clip->read_position = 0; + memset(clip->buffer, 0, MAX_BUFFER_SIZE * sizeof(float)); +} + +const char* clip_state_to_string(ClipState state) { + switch (state) { + case CLIP_EMPTY: return "Empty"; + case CLIP_RECORDING: return "Recording"; + case CLIP_LOOPING: return "Looping"; + case CLIP_STOPPED: return "Stopped"; + default: return "Unknown"; + } +} + +uint8_t clip_state_to_velocity(ClipState state) { + switch (state) { + case CLIP_EMPTY: return 0; + case CLIP_RECORDING: return 64; + case CLIP_LOOPING: return 127; + case CLIP_STOPPED: return 32; + default: return 0; + } +} diff --git a/engine.h b/engine.h new file mode 100644 index 0000000..119ce01 --- /dev/null +++ b/engine.h @@ -0,0 +1,56 @@ +#ifndef ENGINE_H +#define ENGINE_H + +#include +#include +#include +#include + +#define MAX_CLIPS 128 +#define MAX_BUFFER_SIZE 441000 // 10 seconds at 44.1kHz + +typedef enum { + CLIP_EMPTY, + CLIP_RECORDING, + CLIP_LOOPING, + CLIP_STOPPED +} ClipState; + +typedef struct { + ClipState state; + float *buffer; + size_t buffer_size; + size_t write_position; + size_t read_position; + bool is_playing; +} Clip; + +typedef struct { + jack_client_t *client; + jack_port_t *audio_in_port; + jack_port_t *audio_out_port; + jack_port_t *midi_in_port; + jack_port_t *midi_out_port; + + Clip clips[MAX_CLIPS]; + int control_channel; + jack_nframes_t sample_rate; + + bool running; +} Engine; + +// Engine lifecycle +int engine_init(Engine *engine, const char *client_name); +void engine_cleanup(Engine *engine); +int engine_start(Engine *engine); +void engine_stop(Engine *engine); + +// Clip management +void engine_trigger_clip(Engine *engine, int clip_index); +void engine_reset_clip(Engine *engine, int clip_index); + +// Utility +const char* clip_state_to_string(ClipState state); +uint8_t clip_state_to_velocity(ClipState state); + +#endif // ENGINE_H diff --git a/main.c b/main.c index e69de29..acdc771 100644 --- a/main.c +++ b/main.c @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include "engine.h" + +static Engine engine; +static volatile int keep_running = 1; + +void signal_handler(int sig) { + keep_running = 0; +} + +void print_usage(const char *program) { + printf("Usage: %s [options]\n", program); + printf("Options:\n"); + printf(" -n JACK client name (default: jack-looper)\n"); + printf(" -c MIDI control channel (default: 0)\n"); + printf(" -h Show this help\n"); +} + +int main(int argc, char *argv[]) { + const char *client_name = "jack-looper"; + int control_channel = 0; + int opt; + + while ((opt = getopt(argc, argv, "n:c:h")) != -1) { + switch (opt) { + case 'n': + client_name = optarg; + break; + case 'c': + control_channel = atoi(optarg); + if (control_channel < 0 || control_channel > 15) { + fprintf(stderr, "Control channel must be 0-15\n"); + return 1; + } + break; + case 'h': + print_usage(argv[0]); + return 0; + default: + print_usage(argv[0]); + return 1; + } + } + + // Initialize engine + if (engine_init(&engine, client_name) != 0) { + fprintf(stderr, "Failed to initialize engine\n"); + return 1; + } + + engine.control_channel = control_channel; + + // Set up signal handler + signal(SIGINT, signal_handler); + signal(SIGTERM, signal_handler); + + // Start engine + if (engine_start(&engine) != 0) { + fprintf(stderr, "Failed to start engine\n"); + engine_cleanup(&engine); + return 1; + } + + printf("JACK Looper started\n"); + printf("Client name: %s\n", client_name); + printf("Control channel: %d\n", control_channel); + printf("Sample rate: %u Hz\n", engine.sample_rate); + printf("Press Ctrl+C to stop\n\n"); + + // Main loop + while (keep_running) { + sleep(1); + } + + // Cleanup + printf("\nShutting down...\n"); + engine_stop(&engine); + engine_cleanup(&engine); + + return 0; +} diff --git a/test_engine.c b/test_engine.c new file mode 100644 index 0000000..f18c61e --- /dev/null +++ b/test_engine.c @@ -0,0 +1,278 @@ +#include +#include +#include +#include +#include "engine.h" + +// Test helper +static Engine *create_test_engine(void) { + Engine *engine = (Engine *)calloc(1, sizeof(Engine)); + assert(engine != NULL); + + engine->control_channel = 0; + engine->sample_rate = 48000; + + for (int i = 0; i < MAX_CLIPS; i++) { + engine->clips[i].state = CLIP_EMPTY; + engine->clips[i].buffer = (float *)calloc(MAX_BUFFER_SIZE, sizeof(float)); + assert(engine->clips[i].buffer != NULL); + engine->clips[i].buffer_size = 0; + engine->clips[i].write_position = 0; + engine->clips[i].read_position = 0; + } + + return engine; +} + +static void destroy_test_engine(Engine *engine) { + if (engine) { + for (int i = 0; i < MAX_CLIPS; i++) { + free(engine->clips[i].buffer); + } + free(engine); + } +} + +// Test 1: Initial state is empty +void test_initial_state(void) { + printf("Test 1: Initial state is empty... "); + Engine *engine = create_test_engine(); + + for (int i = 0; i < MAX_CLIPS; i++) { + assert(engine->clips[i].state == CLIP_EMPTY); + assert(engine->clips[i].buffer_size == 0); + assert(engine->clips[i].write_position == 0); + assert(engine->clips[i].read_position == 0); + } + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +// Test 2: Trigger empty clip starts recording +void test_trigger_empty_starts_recording(void) { + printf("Test 2: Trigger empty clip starts recording... "); + Engine *engine = create_test_engine(); + + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_RECORDING); + assert(engine->clips[0].write_position == 0); + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +// Test 3: Trigger recording clip stops and starts looping +void test_trigger_recording_starts_looping(void) { + printf("Test 3: Trigger recording clip stops and starts looping... "); + Engine *engine = create_test_engine(); + + // Start recording + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_RECORDING); + + // Simulate some recording + engine->clips[0].write_position = 100; + + // Trigger again to stop recording and start looping + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_LOOPING); + assert(engine->clips[0].buffer_size == 100); + assert(engine->clips[0].read_position == 0); + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +// Test 4: Trigger looping clip stops it +void test_trigger_looping_stops(void) { + printf("Test 4: Trigger looping clip stops it... "); + Engine *engine = create_test_engine(); + + // Set up a looping clip + engine->clips[0].state = CLIP_LOOPING; + engine->clips[0].buffer_size = 100; + engine->clips[0].read_position = 50; + + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_STOPPED); + assert(engine->clips[0].read_position == 0); + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +// Test 5: Trigger stopped clip starts looping again +void test_trigger_stopped_resumes_looping(void) { + printf("Test 5: Trigger stopped clip starts looping again... "); + Engine *engine = create_test_engine(); + + // Set up a stopped clip + engine->clips[0].state = CLIP_STOPPED; + engine->clips[0].buffer_size = 100; + + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_LOOPING); + assert(engine->clips[0].read_position == 0); + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +// Test 6: Full cycle test +void test_full_cycle(void) { + printf("Test 6: Full cycle test... "); + Engine *engine = create_test_engine(); + + // Empty -> Recording + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_RECORDING); + + // Recording -> Looping + engine->clips[0].write_position = 200; + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_LOOPING); + assert(engine->clips[0].buffer_size == 200); + + // Looping -> Stopped + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_STOPPED); + + // Stopped -> Looping + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_LOOPING); + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +// Test 7: Multiple clips work independently +void test_multiple_clips(void) { + printf("Test 7: Multiple clips work independently... "); + Engine *engine = create_test_engine(); + + // Clip 0: Empty -> Recording + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_RECORDING); + + // Clip 1: Empty -> Recording + engine_trigger_clip(engine, 1); + assert(engine->clips[1].state == CLIP_RECORDING); + + // Clip 0: Recording -> Looping + engine->clips[0].write_position = 100; + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_LOOPING); + assert(engine->clips[1].state == CLIP_RECORDING); // Clip 1 unaffected + + // Clip 2: Empty -> Recording + engine_trigger_clip(engine, 2); + assert(engine->clips[2].state == CLIP_RECORDING); + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +// Test 8: Reset clip +void test_reset_clip(void) { + printf("Test 8: Reset clip... "); + Engine *engine = create_test_engine(); + + // Set up a clip with data + engine->clips[0].state = CLIP_LOOPING; + engine->clips[0].buffer_size = 100; + engine->clips[0].write_position = 100; + engine->clips[0].read_position = 50; + + engine_reset_clip(engine, 0); + assert(engine->clips[0].state == CLIP_EMPTY); + assert(engine->clips[0].buffer_size == 0); + assert(engine->clips[0].write_position == 0); + assert(engine->clips[0].read_position == 0); + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +// Test 9: Clip state to velocity mapping +void test_state_to_velocity(void) { + printf("Test 9: Clip state to velocity mapping... "); + + assert(clip_state_to_velocity(CLIP_EMPTY) == 0); + assert(clip_state_to_velocity(CLIP_RECORDING) == 64); + assert(clip_state_to_velocity(CLIP_LOOPING) == 127); + assert(clip_state_to_velocity(CLIP_STOPPED) == 32); + + printf("PASSED\n"); +} + +// Test 10: Clip state to string +void test_state_to_string(void) { + printf("Test 10: Clip state to string... "); + + assert(strcmp(clip_state_to_string(CLIP_EMPTY), "Empty") == 0); + assert(strcmp(clip_state_to_string(CLIP_RECORDING), "Recording") == 0); + assert(strcmp(clip_state_to_string(CLIP_LOOPING), "Looping") == 0); + assert(strcmp(clip_state_to_string(CLIP_STOPPED), "Stopped") == 0); + + printf("PASSED\n"); +} + +// Test 11: Invalid clip index +void test_invalid_clip_index(void) { + printf("Test 11: Invalid clip index... "); + Engine *engine = create_test_engine(); + + // These should not crash + engine_trigger_clip(engine, -1); + engine_trigger_clip(engine, MAX_CLIPS); + engine_reset_clip(engine, -1); + engine_reset_clip(engine, MAX_CLIPS); + + // Verify no state changes + assert(engine->clips[0].state == CLIP_EMPTY); + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +// Test 12: Buffer overflow protection +void test_buffer_overflow(void) { + printf("Test 12: Buffer overflow protection... "); + Engine *engine = create_test_engine(); + + // Start recording + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_RECORDING); + + // Fill buffer to max + engine->clips[0].write_position = MAX_BUFFER_SIZE; + + // Trigger should stop recording and start looping + engine_trigger_clip(engine, 0); + assert(engine->clips[0].state == CLIP_LOOPING); + assert(engine->clips[0].buffer_size == MAX_BUFFER_SIZE); + + destroy_test_engine(engine); + printf("PASSED\n"); +} + +int main(void) { + printf("Running JACK Looper tests...\n\n"); + + test_initial_state(); + test_trigger_empty_starts_recording(); + test_trigger_recording_starts_looping(); + test_trigger_looping_stops(); + test_trigger_stopped_resumes_looping(); + test_full_cycle(); + test_multiple_clips(); + test_reset_clip(); + test_state_to_velocity(); + test_state_to_string(); + test_invalid_clip_index(); + test_buffer_overflow(); + + printf("\nAll tests passed!\n"); + return 0; +}