From f37cb5c0a6763b6eefcfcaa477c210a69c5ddd52 Mon Sep 17 00:00:00 2001 From: Loic Coenen Date: Sat, 2 May 2026 10:18:04 +0000 Subject: [PATCH] feat: add save/load thread and WAV file I/O for clip persistence Co-authored-by: aider (deepseek/deepseek-coder) --- cli.c | 24 ++++++ engine.c | 184 ++++++++++++++++++++++++++++++++++++++++++++ engine.h | 35 +++++++++ tui.c | 22 +++++- wav_io.c | 231 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ wav_io.h | 10 +++ 6 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 wav_io.c create mode 100644 wav_io.h diff --git a/cli.c b/cli.c index 43cabc6..e0f59a1 100644 --- a/cli.c +++ b/cli.c @@ -1,4 +1,5 @@ #include "cli.h" +#include "wav_io.h" #include #include #include @@ -56,6 +57,8 @@ int cli_process_line(Engine *engine, const char *line) { printf(" toggle - Toggle play/pause\n"); printf(" clock internal|midi - Set clock source\n"); printf(" bpm - Set BPM (1.0-999.0)\n"); + printf(" load - Load WAV file into clip\n"); + printf(" save - Save clip to samples/clip_.wav\n"); printf(" help - Show this help\n"); printf(" quit - Exit CLI\n"); return 1; @@ -157,6 +160,27 @@ int cli_process_line(Engine *engine, const char *line) { printf("Unknown clock source: %s\n", source_str); } } + else if (strcasecmp(token, "load") == 0) { + char *clip_str = strtok(NULL, " \t"); + char *filename = strtok(NULL, " \t"); + if (!clip_str || !filename) { + printf("Usage: load \n"); + return 1; + } + int clip_idx = atoi(clip_str); + save_load_queue_push(&engine->save_load_queue, REQ_LOAD_CLIP, clip_idx, filename); + printf("Loading %s into clip %d...\n", filename, clip_idx); + } + else if (strcasecmp(token, "save") == 0) { + char *clip_str = strtok(NULL, " \t"); + if (!clip_str) { + printf("Usage: save \n"); + return 1; + } + int clip_idx = atoi(clip_str); + save_load_queue_push(&engine->save_load_queue, REQ_SAVE_CLIP, clip_idx, ""); + printf("Saving clip %d...\n", clip_idx); + } else if (strcasecmp(token, "bpm") == 0) { char *bpm_str = strtok(NULL, " \t"); if (!bpm_str) { diff --git a/engine.c b/engine.c index 50e34dc..d5bd29f 100644 --- a/engine.c +++ b/engine.c @@ -1,9 +1,14 @@ #include "engine.h" +#include "wav_io.h" #include #include #include #include #include +#include +#include +#include +#include // Forward declarations static void process_queued_triggers(Engine *engine, jack_nframes_t current_frame); @@ -243,6 +248,158 @@ void command_queue_init(CommandQueue *q) { atomic_store(&q->read_index, 0); } +// Initialize save/load queue +void save_load_queue_init(SaveLoadQueue *q) { + atomic_store(&q->write_index, 0); + atomic_store(&q->read_index, 0); +} + +// Push a save/load request (called from audio thread) +int save_load_queue_push(SaveLoadQueue *q, SaveLoadType type, int clip_index, const char *filename) { + if (!q || !filename) return -1; + + unsigned int write = atomic_load(&q->write_index); + unsigned int read = atomic_load(&q->read_index); + + if ((write - read) >= MAX_QUEUED_COMMANDS) { + fprintf(stderr, "Save/Load queue full, dropping request\n"); + return -1; + } + + unsigned int slot = write % MAX_QUEUED_COMMANDS; + q->buffer[slot].type = type; + q->buffer[slot].clip_index = clip_index; + strncpy(q->buffer[slot].filename, filename, sizeof(q->buffer[slot].filename) - 1); + q->buffer[slot].filename[sizeof(q->buffer[slot].filename) - 1] = '\0'; + + atomic_store(&q->write_index, write + 1); + return 0; +} + +// Pop a save/load request (called from save/load thread) +int save_load_queue_pop(SaveLoadQueue *q, SaveLoadRequest *req) { + if (!q || !req) return -1; + + unsigned int read = atomic_load(&q->read_index); + unsigned int write = atomic_load(&q->write_index); + + if (read >= write) return 0; // Empty + + unsigned int slot = read % MAX_QUEUED_COMMANDS; + *req = q->buffer[slot]; + + atomic_store(&q->read_index, read + 1); + return 1; +} + +// Save/Load thread function +void* save_load_thread_func(void *arg) { + Engine *engine = (Engine *)arg; + if (!engine) return NULL; + + // Create samples directory if it doesn't exist + mkdir("samples", 0755); + + while (engine->save_load_running) { + SaveLoadRequest req; + int ret = save_load_queue_pop(&engine->save_load_queue, &req); + + if (ret == 1) { + char filepath[512]; + + switch (req.type) { + case REQ_SAVE_CLIP: { + if (req.clip_index < 0 || req.clip_index >= MAX_CLIPS) break; + Clip *clip = &engine->clips[req.clip_index]; + + // Build filename: samples/clip_.wav + snprintf(filepath, sizeof(filepath), "samples/clip_%d.wav", req.clip_index); + + if (clip->buffer && clip->buffer_size > 0) { + int result = save_wav_float(filepath, clip->buffer, clip->buffer_size, engine->sample_rate); + if (result == 0) { + printf("Saved clip %d to %s (%zu samples)\n", req.clip_index, filepath, clip->buffer_size); + } else { + fprintf(stderr, "Failed to save clip %d to %s\n", req.clip_index, filepath); + } + } + break; + } + + case REQ_LOAD_CLIP: { + if (req.clip_index < 0 || req.clip_index >= MAX_CLIPS) break; + Clip *clip = &engine->clips[req.clip_index]; + + float *new_buffer = NULL; + size_t num_samples = 0; + unsigned int file_sample_rate = 0; + + int result = load_wav_float(req.filename, &new_buffer, &num_samples, &file_sample_rate); + if (result == 0 && new_buffer && num_samples > 0) { + // Allocate a new buffer for the clip + float *clip_buffer = (float *)calloc(MAX_BUFFER_SIZE, sizeof(float)); + if (!clip_buffer) { + free(new_buffer); + break; + } + + // Copy samples (truncate if too long) + size_t copy_size = (num_samples < MAX_BUFFER_SIZE) ? num_samples : MAX_BUFFER_SIZE; + memcpy(clip_buffer, new_buffer, copy_size * sizeof(float)); + + // Atomically swap the clip's buffer + float *old_buffer = atomic_exchange(&clip->buffer, clip_buffer); + + // Update clip state atomically + clip->state = CLIP_LOOPING; + clip->buffer_size = copy_size; + clip->write_position = copy_size; + clip->read_position = 0; + + // Free old buffer and temporary buffer + if (old_buffer) free(old_buffer); + free(new_buffer); + + printf("Loaded clip %d from %s (%zu samples, %u Hz)\n", + req.clip_index, req.filename, num_samples, file_sample_rate); + } else { + fprintf(stderr, "Failed to load %s into clip %d\n", req.filename, req.clip_index); + } + break; + } + } + } else { + // No requests, sleep a bit to avoid busy-waiting + usleep(1000); // 1ms + } + } + + return NULL; +} + +// Start the save/load thread +int engine_start_save_load_thread(Engine *engine) { + if (!engine) return -1; + + engine->save_load_running = true; + save_load_queue_init(&engine->save_load_queue); + + if (pthread_create(&engine->save_load_thread, NULL, save_load_thread_func, engine) != 0) { + engine->save_load_running = false; + return -1; + } + + return 0; +} + +// Stop the save/load thread +void engine_stop_save_load_thread(Engine *engine) { + if (!engine) return; + + engine->save_load_running = false; + pthread_join(engine->save_load_thread, NULL); +} + // Submit command from frontend thread (non-blocking) int engine_submit_command(Engine *engine, CommandType type, int index, jack_nframes_t value) { if (!engine) return -1; @@ -309,6 +466,8 @@ void engine_process_commands(Engine *engine) { action.previous_read_position = clip->read_position; engine_push_undo_action(engine, &action); + ClipState prev_state = clip->state; + switch (clip->state) { case CLIP_EMPTY: clip->state = CLIP_RECORDING; @@ -330,6 +489,11 @@ void engine_process_commands(Engine *engine) { clip->read_position = 0; break; } + + // Auto-save when recording finishes (RECORDING -> LOOPING) + if (prev_state == CLIP_RECORDING && clip->state == CLIP_LOOPING) { + save_load_queue_push(&engine->save_load_queue, REQ_SAVE_CLIP, cmd.index, ""); + } break; } @@ -347,6 +511,8 @@ void engine_process_commands(Engine *engine) { int clip_idx = CLIP_INDEX(cmd.index, ch); Clip *clip = &engine->clips[clip_idx]; + ClipState prev_state = clip->state; + switch (clip->state) { case CLIP_EMPTY: clip->state = CLIP_RECORDING; @@ -368,6 +534,11 @@ void engine_process_commands(Engine *engine) { clip->read_position = 0; break; } + + // Auto-save when recording finishes + if (prev_state == CLIP_RECORDING && clip->state == CLIP_LOOPING) { + save_load_queue_push(&engine->save_load_queue, REQ_SAVE_CLIP, clip_idx, ""); + } } break; } @@ -788,6 +959,9 @@ int engine_init(Engine *engine, const char *client_name) { // Initialize command queue command_queue_init(&engine->command_queue); + // Initialize save/load queue + save_load_queue_init(&engine->save_load_queue); + // Initialize atomic state mirrors atomic_store(&engine->quantize_mode_atomic, (int)QUANTIZE_OFF); atomic_store(&engine->quantize_threshold_atomic, 0); @@ -921,6 +1095,15 @@ int engine_start(Engine *engine) { } engine->running = true; + + // Start save/load thread + if (engine_start_save_load_thread(engine) != 0) { + fprintf(stderr, "Failed to start save/load thread\n"); + jack_deactivate(engine->client); + engine->running = false; + return -1; + } + return 0; } @@ -928,6 +1111,7 @@ void engine_stop(Engine *engine) { if (!engine || !engine->client) return; engine->running = false; + engine_stop_save_load_thread(engine); jack_deactivate(engine->client); } diff --git a/engine.h b/engine.h index 288b90e..a9d71a0 100644 --- a/engine.h +++ b/engine.h @@ -6,6 +6,7 @@ #include #include #include +#include #include "transport.h" #define MAX_SCENES 8 @@ -114,6 +115,25 @@ typedef struct { atomic_uint read_index; } CommandQueue; +// Save/Load request types +typedef enum { + REQ_SAVE_CLIP, + REQ_LOAD_CLIP +} SaveLoadType; + +typedef struct { + SaveLoadType type; + int clip_index; + char filename[256]; +} SaveLoadRequest; + +// Lock-free queue for save/load requests (audio thread -> save/load thread) +typedef struct { + SaveLoadRequest buffer[MAX_QUEUED_COMMANDS]; + atomic_uint write_index; + atomic_uint read_index; +} SaveLoadQueue; + // Queued trigger for quantization typedef struct QueuedTrigger { int clip_index; @@ -154,6 +174,11 @@ typedef struct { // Undo/Redo UndoHistory undo_history; + + // Save/Load queue and thread + SaveLoadQueue save_load_queue; + pthread_t save_load_thread; + volatile bool save_load_running; } Engine; // Engine lifecycle @@ -189,6 +214,16 @@ void engine_process_commands(Engine *engine); // Initialize command queue (exposed for testing) void command_queue_init(CommandQueue *q); +// Save/Load queue management +void save_load_queue_init(SaveLoadQueue *q); +int save_load_queue_push(SaveLoadQueue *q, SaveLoadType type, int clip_index, const char *filename); +int save_load_queue_pop(SaveLoadQueue *q, SaveLoadRequest *req); + +// Save/Load thread +void* save_load_thread_func(void *arg); +int engine_start_save_load_thread(Engine *engine); +void engine_stop_save_load_thread(Engine *engine); + // Utility const char* clip_state_to_string(ClipState state); uint8_t clip_state_to_velocity(ClipState state); diff --git a/tui.c b/tui.c index d1af181..72fad90 100644 --- a/tui.c +++ b/tui.c @@ -1,4 +1,5 @@ #include "tui.h" +#include "wav_io.h" #include #include #include @@ -366,8 +367,27 @@ static bool handle_command_mode(void) { // Restore previous nodelay state before returning nodelay(stdscr, prev_nodelay); return true; // Quit + } else if (strncmp(cmd_buffer, "load ", 5) == 0) { + // :load + char *rest = cmd_buffer + 5; + int clip_idx = atoi(rest); + // Find filename after clip index + char *filename = rest; + while (*filename && *filename != ' ') filename++; + if (*filename) { + *filename = '\0'; + filename++; + while (*filename == ' ') filename++; + if (*filename) { + // Submit load request via save/load queue + save_load_queue_push(&g_engine->save_load_queue, REQ_LOAD_CLIP, clip_idx, filename); + } + } + } else if (strncmp(cmd_buffer, "save ", 5) == 0) { + // :save + int clip_idx = atoi(cmd_buffer + 5); + save_load_queue_push(&g_engine->save_load_queue, REQ_SAVE_CLIP, clip_idx, ""); } - // Add more commands here as needed // Restore previous nodelay state before returning nodelay(stdscr, prev_nodelay); diff --git a/wav_io.c b/wav_io.c new file mode 100644 index 0000000..2ff6f31 --- /dev/null +++ b/wav_io.c @@ -0,0 +1,231 @@ +#include "wav_io.h" +#include +#include +#include +#include + +// WAV file header structures (little-endian) +typedef struct { + char chunkID[4]; // "RIFF" + uint32_t chunkSize; // file size - 8 + char format[4]; // "WAVE" +} WAVHeader; + +typedef struct { + char subchunk1ID[4]; // "fmt " + uint32_t subchunk1Size; // 16 for PCM + uint16_t audioFormat; // 1 = PCM, 3 = IEEE float + uint16_t numChannels; + uint32_t sampleRate; + uint32_t byteRate; + uint16_t blockAlign; + uint16_t bitsPerSample; +} FMTSubchunk; + +typedef struct { + char subchunk2ID[4]; // "data" + uint32_t subchunk2Size; +} DataSubchunk; + +static void write_le16(FILE *f, uint16_t v) { + unsigned char buf[2]; + buf[0] = v & 0xFF; + buf[1] = (v >> 8) & 0xFF; + fwrite(buf, 1, 2, f); +} + +static void write_le32(FILE *f, uint32_t v) { + unsigned char buf[4]; + buf[0] = v & 0xFF; + buf[1] = (v >> 8) & 0xFF; + buf[2] = (v >> 16) & 0xFF; + buf[3] = (v >> 24) & 0xFF; + fwrite(buf, 1, 4, f); +} + +static uint16_t read_le16(const unsigned char *buf) { + return (uint16_t)buf[0] | ((uint16_t)buf[1] << 8); +} + +static uint32_t read_le32(const unsigned char *buf) { + return (uint32_t)buf[0] | ((uint32_t)buf[1] << 8) | + ((uint32_t)buf[2] << 16) | ((uint32_t)buf[3] << 24); +} + +int save_wav_float(const char *filename, const float *buffer, size_t num_samples, unsigned int sample_rate) { + if (!filename || !buffer || num_samples == 0) return -1; + + FILE *f = fopen(filename, "wb"); + if (!f) return -1; + + // Calculate sizes + uint32_t data_size = (uint32_t)(num_samples * sizeof(float)); + uint32_t chunk_size = 36 + data_size; + + // Write RIFF header + fwrite("RIFF", 1, 4, f); + write_le32(f, chunk_size); + fwrite("WAVE", 1, 4, f); + + // Write fmt subchunk + fwrite("fmt ", 1, 4, f); + write_le32(f, 16); // subchunk1Size + write_le16(f, 3); // audioFormat = IEEE float + write_le16(f, 1); // numChannels = mono + write_le32(f, sample_rate); + write_le32(f, sample_rate * sizeof(float)); // byteRate + write_le16(f, sizeof(float)); // blockAlign + write_le16(f, 32); // bitsPerSample + + // Write data subchunk + fwrite("data", 1, 4, f); + write_le32(f, data_size); + + // Write samples + fwrite(buffer, sizeof(float), num_samples, f); + + fclose(f); + return 0; +} + +int load_wav_float(const char *filename, float **buffer, size_t *num_samples, unsigned int *sample_rate) { + if (!filename || !buffer || !num_samples || !sample_rate) return -1; + + FILE *f = fopen(filename, "rb"); + if (!f) return -1; + + // Read RIFF header + unsigned char header[12]; + if (fread(header, 1, 12, f) != 12) { + fclose(f); + return -1; + } + + if (memcmp(header, "RIFF", 4) != 0 || memcmp(header + 8, "WAVE", 4) != 0) { + fclose(f); + return -1; + } + + // Read chunks until we find fmt and data + uint16_t audio_format = 0; + uint16_t num_channels = 0; + uint32_t sample_rate_val = 0; + uint16_t bits_per_sample = 0; + uint32_t data_size = 0; + float *data_buffer = NULL; + + while (1) { + unsigned char chunk_header[8]; + if (fread(chunk_header, 1, 8, f) != 8) break; + + uint32_t chunk_size = read_le32(chunk_header + 4); + + if (memcmp(chunk_header, "fmt ", 4) == 0) { + unsigned char fmt_data[16]; + if (chunk_size < 16) { + fseek(f, chunk_size, SEEK_CUR); + continue; + } + if (fread(fmt_data, 1, 16, f) != 16) break; + + audio_format = read_le16(fmt_data); + num_channels = read_le16(fmt_data + 2); + sample_rate_val = read_le32(fmt_data + 4); + bits_per_sample = read_le16(fmt_data + 14); + + // Skip any extra fmt data + if (chunk_size > 16) fseek(f, chunk_size - 16, SEEK_CUR); + } else if (memcmp(chunk_header, "data", 4) == 0) { + data_size = chunk_size; + + // Allocate buffer + size_t num_frames = data_size / (bits_per_sample / 8) / num_channels; + data_buffer = (float *)calloc(num_frames, sizeof(float)); + if (!data_buffer) { + fclose(f); + return -1; + } + + if (audio_format == 3 && bits_per_sample == 32) { + // IEEE float + size_t read_size = num_frames * num_channels * sizeof(float); + if (read_size > data_size) read_size = data_size; + float *temp = (float *)malloc(read_size); + if (!temp) { + free(data_buffer); + fclose(f); + return -1; + } + if (fread(temp, 1, read_size, f) != read_size) { + free(temp); + free(data_buffer); + fclose(f); + return -1; + } + // Mix to mono if stereo + if (num_channels == 1) { + memcpy(data_buffer, temp, num_frames * sizeof(float)); + } else { + for (size_t i = 0; i < num_frames; i++) { + float sum = 0.0f; + for (uint16_t ch = 0; ch < num_channels; ch++) { + sum += temp[i * num_channels + ch]; + } + data_buffer[i] = sum / num_channels; + } + } + free(temp); + } else if (audio_format == 1 && bits_per_sample == 16) { + // 16-bit PCM + size_t read_size = num_frames * num_channels * sizeof(int16_t); + if (read_size > data_size) read_size = data_size; + int16_t *temp = (int16_t *)malloc(read_size); + if (!temp) { + free(data_buffer); + fclose(f); + return -1; + } + if (fread(temp, 1, read_size, f) != read_size) { + free(temp); + free(data_buffer); + fclose(f); + return -1; + } + // Convert to float and mix to mono + if (num_channels == 1) { + for (size_t i = 0; i < num_frames; i++) { + data_buffer[i] = (float)temp[i] / 32768.0f; + } + } else { + for (size_t i = 0; i < num_frames; i++) { + float sum = 0.0f; + for (uint16_t ch = 0; ch < num_channels; ch++) { + sum += (float)temp[i * num_channels + ch] / 32768.0f; + } + data_buffer[i] = sum / num_channels; + } + } + free(temp); + } else { + // Unsupported format + free(data_buffer); + fclose(f); + return -1; + } + + *buffer = data_buffer; + *num_samples = num_frames; + *sample_rate = sample_rate_val; + fclose(f); + return 0; + } else { + // Skip unknown chunk + fseek(f, chunk_size, SEEK_CUR); + } + } + + // If we get here, we didn't find data + if (data_buffer) free(data_buffer); + fclose(f); + return -1; +} diff --git a/wav_io.h b/wav_io.h new file mode 100644 index 0000000..e11d954 --- /dev/null +++ b/wav_io.h @@ -0,0 +1,10 @@ +#ifndef WAV_IO_H +#define WAV_IO_H + +#include +#include + +int save_wav_float(const char *filename, const float *buffer, size_t num_samples, unsigned int sample_rate); +int load_wav_float(const char *filename, float **buffer, size_t *num_samples, unsigned int *sample_rate); + +#endif