Merge branch '8-add-tui' - tests not passing

This commit is contained in:
Loic Coenen
2026-05-17 19:02:03 +00:00
57 changed files with 2786 additions and 92 deletions

View File

@@ -0,0 +1,72 @@
# MultiChannel & Bind Feature
The looper supports up to 16 independent channels (numbered 015).
Channel0 is always present and connected to the `looper:input` / `looper:output` audio ports.
Additional channels can be created and removed dynamically using MIDI commands.
## MIDI Ports
- **`looper:control`** receives MIDI noteon events for channel management and state toggling.
- **`looper:clock`** receives MIDI clock messages (0xFA, 0xFC, 0xFB) that affect channel0 only.
## ControlKey Modifier
Hold the **control key** (MIDI note64) pressed *before* sending another note to put the looper in “command mode”.
While controlkey is active, the next noteon (with velocity > 0) performs a special action instead of its direct mapping.
The control key is released either by sending noteoff (note64 or any note) or by sending a noteon while controlkey is already active (the action is performed and controlkey is cleared).
## Available Commands (under control key)
| Note | Action |
|------|----------------------------------------------------------------------------------------------|
| 015 | **Bind** the next `control+62` toggle to the channel with that index. |
| 60 | **Add** a new dynamic channel (creates `channelX_input` / `channelX_output` ports). |
| 61 | **Remove** the highestnumbered active channel (excluding channel0). |
| 62 | **Toggle** the current bound channel through its state machine: |
| | IDLE → RECORD → LOOPING → PAUSED → LOOPING → … (each press advances one step). |
| 63 | **Unbind** reset the bound channel back to **0**. |
> **Notes:**
> - The default bound channel is **0**. If you never send a bind command, `control+62` controls channel0.
> - To bind a different channel, send `control + note <16>` (e.g., control + note5 binds channel5).
> - Bind is sticky it stays until overwritten by another bind command.
> - To **unbind** (reset to channel0), send `control + note63`.
## Direct Mapping (without control key)
For backward compatibility, the following notes work **without** the controlkey modifier:
| Note | Action |
|------|----------------------------------------------------------------------------------------------|
| 1 | Toggle channel0 state (IDLE→RECORD→LOOPING→PAUSED→LOOPING…). |
| 60 | Add a dynamic channel (same as `control+60`). |
| 61 | Remove the highestnumbered active channel (same as `control+61`). |
## Example Usage
1. **Record a loop on channel0 (using direct note1)**
- Send noteon, note1, velocity127 → channel0 enters RECORD.
- Play some audio into `looper:input`.
- Send noteon, note1, velocity127 again → channel0 enters LOOPING.
- The recorded audio repeats indefinitely.
2. **Use the controlkey to toggle channel0**
- Send `noteon, note64` (control key).
- Then send `noteon, note62` → toggles channel0 (IDLE→RECORD).
- Send `noteon, note64` again, then `noteon, note62` again → RECORD→LOOPING.
3. **Add a new channel and bind it**
- Send `noteon, note64` + `noteon, note60` → creates channel1.
- Send `noteon, note64` + `noteon, note1` → binds channel1.
- Now `control+62` toggles channel1 instead of channel0.
- Record audio on channel1 by sending `control+62` twice.
4. **Remove a dynamic channel**
- Send `noteon, note64` + `noteon, note61` → removes the highestnumbered active channel (e.g., channel1).
## Notes
- The looper must be connected to a running JACK server.
- Channel buffers hold up to 5 seconds of audio at 48kHz.
- After removal, the channels audio ports are unregistered on the next mainloop cycle (deferred to avoid race conditions).
- The bind index is stored as an integer (015); values outside 015 are ignored (the note is processed as a command rather than a bind).

View File

@@ -0,0 +1,38 @@
# Arbitrary Number of Channels
## Overview
Originally the looper had a fixed maximum of 16 channels (`MAX_CHANNELS = 16`).
The limitation has been removed; channels are now stored in a **dynamically allocated array** that grows on demand.
## Implementation
- The global `channels` is a pointer (`struct channel_t *_Atomic channels`) instead of a fixedsize array.
- An atomic variable `channel_capacity` tracks the allocated size.
- Initial allocation is for 8 channels; when a channel index >= current capacity is needed, the array is doubled.
- The old array is **not freed immediately** it is kept alive for at least one realtime audio cycle (using the same deferred mechanism as port unregistration) to guarantee that the RT callback never accesses freed memory.
## Key Files
| File | Role |
|--------------------|-----------------------------------------------------------|
| `src/channel.h` | Removes `MAX_CHANNELS`, adds `channels` pointer declaration and `get_channels_array()` inline accessor. |
| `src/looper.c` | Contains `ensure_capacity()`, deferred free, and replaces all fixedsize loop bounds with `channel_capacity`. |
| `src/channel.c` | Adapted to use the current array pointer atomically. |
| `src/midi.c` | Uses `atomic_load(&channel_capacity)` for bounds checks. |
## Thread Safety During Resize
1. A new, larger array is allocated (`calloc`).
2. Existing channels are copied via `memcpy`.
3. The global `channels` pointer is swapped with `atomic_exchange`.
4. `channel_capacity` is updated.
5. The old pointer is stored in `pending_old` along with the current cycle count (`pending_old_cycle`).
6. In the main loop, `pending_old` is freed only after `global_rt_cycles` has advanced by at least 1, ensuring any RT callback that loaded the old pointer has finished.
This is a lightweight RCUlike pattern that avoids locks and keeps the RT path deterministic.
## Compatibility
All existing MIDI commands and FIFO pipe commands work unchanged with the dynamic array.
The maximum practical number of channels is limited only by available memory and JACK port limits (typically 1024 per client on modern systems).

View File

View File

@@ -0,0 +1,65 @@
# Command Architecture
## Overview
The looper uses a **lockfree, singleproducer singleconsumer (SPSC)** command queue to communicate between the realtime JACK audio thread and the main (nonRT) thread.
There are two families of queues:
- **`cmd_queue`** (RTsafe) used for commands that can be handled directly inside the process callback (`CMD_CYCLE`, `CMD_STOP`, `CMD_BIND_CHANNEL`, `CMD_UNBIND`).
The producer is the MIDI handler (`midi_handle_events`) or the FIFO pipe reader (`pipe_thread_func`); the consumer is `process_callback`.
- **`cmd_queue_main_midi`** / **`cmd_queue_main_fifo`** used for commands that require memory allocation or JACK API calls (`CMD_ADD_CHANNEL`, `CMD_REMOVE_CHANNEL`).
The producer is the MIDI handler (or FIFO reader), and the consumer is `looper_process_commands`, which runs in the main loop approximately every 50ms.
## Command Types
The `command_t` struct (defined in `command.h`) contains:
- `type` one of the `cmd_type_t` enumerators.
- `channel` target channel index; `-1` means “current bind channel” for some commands.
- `data` extra parameter (e.g., bind channel number for `CMD_BIND_CHANNEL`).
### RTsafe Commands (pushed to `cmd_queue`)
| Type | Effect |
|--------------------|---------------------------------------------------------------------|
| `CMD_CYCLE` | Toggle the state machine of the target channel (IDLE→RECORD→LOOPING→PAUSED→LOOPING…). |
| `CMD_STOP` | Force the target channel (or all channels, if `channel == -1`) to `STATE_IDLE`. |
| `CMD_BIND_CHANNEL` | Set the global `bind_channel` index to `data`. |
| `CMD_UNBIND` | Reset `bind_channel` to 0. |
### Mainthread Commands (pushed to `cmd_queue_main_midi` / `cmd_queue_main_fifo`)
| Type | Effect |
|---------------------|---------------------------------------------------------------------|
| `CMD_ADD_CHANNEL` | Create a new dynamic channel (port registration). |
| `CMD_REMOVE_CHANNEL`| Remove the highestnumbered active dynamic channel (excluding channel0). |
## Command Flow
1. **MIDI input** `midi_handle_events` parses incoming noteon events and decides which command to push.
RTsafe commands are pushed to `cmd_queue`; add/remove commands are pushed to `cmd_queue_main_midi`.
2. **FIFO input** `pipe_thread_func` reads lines from `/tmp/looper_cmd` and pushes the corresponding command.
RTsafe commands go to `cmd_queue`; add/remove go to `cmd_queue_main_fifo`.
3. **Process callback** `process_callback` is invoked by JACK for each audio cycle. It drains `cmd_queue` and applies each command via `apply_command`. This function modifies the channel state and bind index atomically.
4. **Main loop** `looper_process_commands` is called in the main loop (≈ every 50ms). It drains `cmd_queue_main_midi` and `cmd_queue_main_fifo`, performing the necessary port registrations/unregistrations and calling `channel_add` / `channel_remove`.
## Deferred Port Unregistration
When a dynamic channel is removed, the RT thread first sets `active = 0`. The main thread waits until it has seen at least one full RT cycle pass (using `global_rt_cycles`) before calling `jack_port_unregister`. This prevents a race between the RT thread still holding a reference to the port buffer and the port being unregistered.
## SPSC Queue Implementation
The queue itself (defined in `queue.c`/`queue.h`) is a simple circular buffer with head and tail indices. It uses C11 atomic loads/stores with appropriate memory ordering (`memory_order_acquire`/`memory_order_release`) to guarantee visibility without locks. Capacity is fixed at `QUEUE_CAPACITY` (256 commands). Push/pop operations are O(1) and never block.
## Thread Safety
- The JACK process callback runs in an RT thread.
- The MIDI handler runs inside the process callback (it is called from `process_callback`).
- The FIFO reader lives in a separate POSIX thread.
- The main thread runs the rest of the program.
The twoqueue design ensures that memoryallocating operations never happen inside the RT thread, while RTpertinent commands are processed with minimal latency.

View File

@@ -0,0 +1,90 @@
# PerChannel MIDI Looping
## Overview
Each looper channel can be either **audio** or **MIDI**. Audio channels record and loop audio samples (existing behaviour). MIDI channels record and loop MIDI event sequences, using separate JACK MIDI input/output ports. The state machine (`IDLE → RECORD → LOOPING → PAUSED`) operates identically for both types.
## Commands
| Command | Source | Action |
|----------------------------|-----------------|------------------------------------------------------------|
| `CMD_ADD_MIDI_CHANNEL` | MIDI note66 | Adds a new MIDI looping channel |
| `add_midi` | FIFO pipe | Same |
| `CMD_REMOVE_CHANNEL` | MIDI note61 | Removes the lastadded channel (audio or MIDI) |
| `CMD_CYCLE` | any note binding| Toggles channel state (IDLE→RECORD→LOOPING→PAUSED) |
## Ports
When a MIDI channel is created, two JACK MIDI ports are registered:
- `looper:channel<N>_midi_in` (input)
- `looper:channel<N>_midi_out` (output)
The `<N>` is a global counter, independent of the index inside the internal channel array.
## Recording
During `STATE_RECORD`:
1. All incoming MIDI events on the `_midi_in` port are stored in the channels event buffer, along with their frame offset relative to the start of the recording.
2. The incoming events are also **forwarded** to the `_midi_out` port, providing a direct passthrough during recording.
**Buffer limit:** A channel can hold up to `MAX_MIDI_EVENTS` (1024) events.
## Looping
During `STATE_LOOPING`:
- All recorded events are output at the **start** of every cycle (frame0). This is a simplification; no perevent timestamp scheduling is implemented. The loop length is determined by the total number of recorded events.
## PassThrough
During `STATE_IDLE` (and `STATE_PAUSED` for MIDI) incoming MIDI events are **copied** from `_midi_in` to `_midi_out` unchanged.
## FIFO Pipe Commands
The FIFO pipe at `/tmp/looper_cmd` accepts the following new linebased commands:
| Command | Effect |
|---------------|--------------------------------------------|
| `add_midi` | Adds a MIDI channel |
| `stop` | Resets all channels to idle |
| `bind <ch>` | Binds the next control note to channel `<ch>` |
| `unbind` | Resets binding to channel 0 |
## Example Workflow
1. Start the looper.
2. Connect a MIDI keyboard to `looper:channel1_midi_in`.
3. Send MIDI note66 on `looper:control` to create a MIDI channel.
4. Send a CYCLE command (e.g., MIDI note62 under control key) to start recording.
5. Play notes on the keyboard the events are captured.
6. Send CYCLE again to enter LOOPING mode the captured sequence repeats.
7. Send CYCLE again to pause, or send STOP (note65 under control key) to reset.
## Implementation Details
- **Channel structure** (`struct channel_t` in `channel.h`):
- `type` field (`CHANNEL_AUDIO` or `CHANNEL_MIDI`)
- `loop` union containing `audio_buffer[MAX_BUFFER]` or `midi_events[MAX_MIDI_EVENTS]`
- **MIDI event type** (`midi_event_t`):
- `timestamp` (frame offset relative to loop start)
- `status`, `note`, `velocity`
- **Processing** (`process_callback` in `looper.c`):
- The callback checks `type` before routing to the appropriate handler block.
- MIDI handler reads from `midi_in` port, writes to `midi_out` port.
- **Port cleanup**: On channel removal, both MIDI ports are unregistered via `jack_port_unregister()` after a oneRTcycle grace period.
## Testing
Integration tests in `tests/integration.c` cover:
- `test_midi_channel_add` verifies that sending `add_midi` via FIFO creates `looper:channel<N>_midi_in` ports.
- `test_fifo_stop_bind_unbind` verifies that `stop`, `bind`, and `unbind` FIFO commands are processed correctly.
- Other existing tests continue to verify audioonly functionality.
Run the test suite with:
```bash
make test
```

View File

@@ -0,0 +1,91 @@
# Scene Switching Engine
## Overview
The scene switching engine allows a channel to have multiple independent recording/playback states (scenes).
Only one scene per channel is active at a time. The active scene's state (IDLE / RECORD / LOOPING / PAUSED) is
controlled independently of other scenes.
## Data Model
Each `channel_t` holds an array of up to `MAX_SCENES` (16) `scene_t` structures. Two atomic integers keep track
of the number of scenes and which scene is currently active:
```c
atomic_int scene_count; // number of scenes for this channel
atomic_int current_scene; // index of the active scene (0 ≤ current_scene < scene_count)
```
Each `scene_t` contains the loop buffer (audio or MIDI events) and the perscene atomic state:
```c
union {
float audio_buffer[LOOP_BUF_SIZE];
midi_event_t midi_events[MAX_MIDI_EVENTS];
} loop;
atomic_int loop_count;
atomic_int record_pos;
atomic_int playback_pos;
atomic_int state; // STATE_IDLE / STATE_RECORD / STATE_LOOPING / STATE_PAUSED
atomic_int prev_state; // previous state (used by RT callback to detect transitions)
```
## Commands
| Command | Trigger (MIDI) | Trigger (FIFO) | Effect |
|--------------------------|------------------------|-----------------------|---------------------------------------------------------|
| **CMD_NEXT_SCENE** | note 67 (control key) | `scene_next\n` | Increments `current_scene` (wraps around). |
| **CMD_PREV_SCENE** | note 68 (control key) | `scene_prev\n` | Decrements `current_scene` (wraps around). |
| **CMD_ADD_SCENE** | note 69 (control key) | `scene_add\n` | Appends a new empty scene, increments `scene_count`. |
| **CMD_REMOVE_SCENE** | note 70 (control key) | `scene_remove\n` | Removes the current scene (shifts remaining scenes). |
All scene commands are processed on the main loop (not in the RT callback). They are pushed to
`cmd_queue_main_midi` (for MIDI) or `cmd_queue_main_fifo` (for FIFO) and applied by
`looper_process_commands()`.
## Thread Safety
- `scene_count` and `current_scene` are `atomic_int`; all reads/writes use `atomic_load`/`atomic_store`.
- The perscene fields (`loop_count`, `record_pos`, `playback_pos`, `state`, `prev_state`) are also `atomic_int`,
so the RT callback and the main loop can safely read and write them concurrently.
- The audio loop buffer itself (a plain `float` array) is not atomic. During scene removal the buffer is copied
via `memcpy`. If a scene is actively looping, this copy may produce a temporarily inconsistent buffer.
**Known limitation:** scene removal should only be performed when the channel is idle (all scenes in
`STATE_IDLE`). The integration test `test_scene_add_remove` does exactly this.
## Implementation Details
1. **`channel_add_scene`**
- Called from main loop.
- Checks `scene_count < MAX_SCENES` (atomically).
- Calls `init_scene()` to zero the new scene and set its state to `STATE_IDLE`.
- Atomically increments `scene_count`.
2. **`channel_remove_scene`**
- Called from main loop.
- Refuses if `scene_count <= 1` (at least one scene must always exist).
- Shifts all scenes after the current one down one position each scene field is copied with
`atomic_store`/`atomic_load`.
- The audio buffer is copied with `memcpy` (see limitation above).
- Decrements `scene_count` and adjusts `current_scene` if it would become out of bounds.
3. **`channel_next_scene` / `channel_prev_scene`**
- Called from main loop.
- If `scene_count > 1`, atomically increments/decrements `current_scene` (wrapping using modulo).
4. **RT callback (`process_callback`)**
- At the start of each frame it reads `current_scene` atomically to obtain the scene index for that
channel.
- All perscene reads (state, loop_count, record_pos, playback_pos) use `atomic_load`.
- When the state changes, the callback atomically resets `record_pos`, `loop_count`, `playback_pos`
as appropriate.
## Tests
- `test_scene_add_remove` (FIFO) adds a scene, cycles next, removes the scene, exits.
- `test_scene_next_prev_midi` sends control key + notes 67/68 to switch scenes.
- `test_scene_cycle_per_scene` records a loop on scene 0, switches to scene 1, verifies scene 1 is idle.
- `test_scene_add_remove_midi` sends control key + notes 69/70 to add/remove scenes.
All scene tests pass as part of `make test`.

36
engine/makefile Normal file
View File

@@ -0,0 +1,36 @@
CC ?= gcc
CFLAGS ?= -Wall -Wextra -g -Isrc
LDFLAGS ?= -ljack -lm -lsndfile -lpthread
SRC = src/main.c src/looper.c src/channel.c src/midi.c src/queue.c src/pipe.c src/ringbuffer.c src/wav.c
OBJ = $(SRC:.c=.o)
looper: $(OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
src/%.o: src/%.c
$(CC) $(CFLAGS) -c -o $@ $<
integration: looper tests/integration.c
$(CC) $(CFLAGS) -o integration_test tests/integration.c -ljack -lm -lsndfile -lpthread
test_status_fifo: looper tests/test_status_fifo.c
$(CC) $(CFLAGS) -o test_status_fifo tests/test_status_fifo.c -ljack -lm -lsndfile -lpthread
test: integration test_status_fifo
./test_status_fifo
./integration_test
.PHONY: clean integration test_status_fifo test
clean:
rm -f looper integration_test test_status_fifo src/*.o
check:
cppcheck --enable=all --error-exitcode=1 --suppress=missingIncludeSystem --suppress=normalCheckLevelMaxBranches src/*.c --library=posix
# Optional: Format code using clang-format
format:
clang-format -i src/*.c
install-hooks:
git config core.hooksPath .githooks

41
engine/src/channel.c Normal file
View File

@@ -0,0 +1,41 @@
// cppcheck-suppress missingIncludeSystem
#include "channel.h"
#include <jack/jack.h>
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
void channel_add(jack_client_t *client, int idx) {
char in_name[64], out_name[64];
snprintf(in_name, sizeof(in_name), "channel%d_input", next_channel_id);
snprintf(out_name, sizeof(out_name), "channel%d_output", next_channel_id);
channels[idx].audio_in = jack_port_register(
client, in_name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
channels[idx].audio_out = jack_port_register(
client, out_name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0);
if (!channels[idx].audio_in || !channels[idx].audio_out) {
fprintf(stderr, "Failed to register ports for channel %d\n",
next_channel_id);
/* Do NOT mark channel active process loop will skip it */
atomic_store(&channels[idx].active, 0);
return;
}
atomic_store(&channels[idx].active, 1);
atomic_store(&channels[idx].state, STATE_IDLE);
channels[idx].prev_state = -1;
channels[idx].loop_count = 0;
channels[idx].record_pos = 0;
channels[idx].playback_pos = 0;
channels[idx].save_ring = NULL;
next_channel_id++;
channel_count++;
}
void channel_remove(jack_client_t *client, int idx) {
(void)client;
atomic_store(&channels[idx].active, 0);
channel_count--;
}

47
engine/src/channel.h Normal file
View File

@@ -0,0 +1,47 @@
#ifndef CHANNEL_H
#define CHANNEL_H
// cppcheck-suppress missingIncludeSystem
#include <jack/jack.h>
#include <stdatomic.h>
#define LOOP_BUF_SIZE (5 * 48000)
#define MAX_CHANNELS 16
#include "ringbuffer.h"
typedef enum {
STATE_IDLE,
STATE_RECORD,
STATE_LOOPING,
STATE_PAUSED
} looper_state;
struct channel_t {
atomic_int state;
atomic_int prev_state;
float loop_buffer[LOOP_BUF_SIZE];
atomic_int loop_count;
atomic_int record_pos;
atomic_int playback_pos;
atomic_int active;
jack_port_t *audio_in;
jack_port_t *audio_out;
_Atomic RingBuf *save_ring;
};
/* Globals declared in looper.c */
extern struct channel_t channels[MAX_CHANNELS];
extern atomic_int channel_count;
extern atomic_int channel_capacity;
extern int next_channel_id;
extern atomic_int cmd_add;
extern atomic_int cmd_remove;
extern atomic_int cmd_load;
extern atomic_int cmd_save;
void channel_add(jack_client_t *client, int idx);
void channel_remove(jack_client_t *client, int idx);
#endif

26
engine/src/command.h Normal file
View File

@@ -0,0 +1,26 @@
#ifndef COMMAND_H
#define COMMAND_H
typedef enum {
CMD_CYCLE, // toggle record/stop for the current scene of a channel
CMD_STOP, // force to idle for all scenes
CMD_BIND_CHANNEL, // bind a channel index (data = channel)
CMD_UNBIND, // reset bind to channel 0
CMD_ADD_CHANNEL, // add a new dynamic channel
CMD_REMOVE_CHANNEL, // remove last dynamic channel
CMD_ADD_MIDI_CHANNEL, // add a new dynamic MIDI channel
CMD_LOAD, // load WAV file into channel 0
CMD_SAVE, // save loop as WAV file
CMD_NEXT_SCENE,
CMD_PREV_SCENE,
CMD_ADD_SCENE,
CMD_REMOVE_SCENE,
} cmd_type_t;
typedef struct {
cmd_type_t type;
int channel; // which channel; -1 means "current/bound"
int data; // extra parameter (e.g. bind channel number)
} command_t;
#endif

508
engine/src/looper.c Normal file
View File

@@ -0,0 +1,508 @@
// cppcheck-suppress missingIncludeSystem
#include "looper.h"
#include "channel.h"
#include "midi.h"
#include "wav.h"
#include "ringbuffer.h"
#include "pipe.h"
#include <jack/jack.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <jack/midiport.h>
#include <math.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "queue.h"
#include "command.h"
/* Global command queues */
spsc_queue_t cmd_queue;
spsc_queue_t cmd_queue_main_midi;
spsc_queue_t cmd_queue_main_fifo;
#define STATUS_FIFO "/tmp/looper_status"
/* writer status fd */
static int status_fd = -1;
static void looper_write_status(void) {
if (status_fd < 0)
return;
char buf[256];
for (int ch = 0; ch < MAX_CHANNELS; ch++) {
if (!atomic_load(&channels[ch].active))
continue;
int state_val = atomic_load(&channels[ch].state);
const char *state_str;
switch (state_val) {
case STATE_IDLE: state_str = "IDLE"; break;
case STATE_RECORD: state_str = "RECORD"; break;
case STATE_LOOPING: state_str = "LOOPING"; break;
case STATE_PAUSED: state_str = "PAUSED"; break;
default: state_str = "UNKNOWN";
}
int n = snprintf(buf, sizeof(buf),
"CH=%d SC=%d STATE=%s\n",
ch, 0, state_str);
if (n > 0) {
int ret = write(status_fd, buf, n);
(void)ret;
}
}
}
/* Global state (shared across files) */
struct channel_t channels[MAX_CHANNELS];
atomic_int channel_count = 0;
atomic_int channel_capacity = MAX_CHANNELS;
int next_channel_id = 1;
atomic_int cmd_add = 0;
atomic_int cmd_remove = 0;
atomic_int cmd_load = 0;
atomic_int cmd_save = 0;
jack_port_t *midi_control_port = NULL;
jack_port_t *midi_clock_port = NULL;
atomic_int control_key_active = 0;
atomic_int bind_channel = 0;
/* Deferred removal index (1 second grace) */
static int pending_unregister_idx = -1;
/* writer thread function and sample rate holder */
static void *writer_thread(void *arg);
static int global_sample_rate = 0;
/* execute a single command (called from looper_process_commands) */
static void exec_command(command_t cmd, jack_client_t *client) {
int ch = cmd.channel;
if (ch < 0) ch = 0;
switch (cmd.type) {
case CMD_CYCLE: {
int state = atomic_load(&channels[ch].state);
switch (state) {
case STATE_IDLE:
atomic_store(&channels[ch].state, STATE_RECORD);
break;
case STATE_RECORD:
atomic_store(&channels[ch].state, STATE_LOOPING);
break;
case STATE_LOOPING:
atomic_store(&channels[ch].state, STATE_PAUSED);
break;
case STATE_PAUSED:
atomic_store(&channels[ch].state, STATE_LOOPING);
break;
}
atomic_store(&channels[ch].prev_state, -1);
break;
}
case CMD_STOP:
atomic_store(&channels[ch].state, STATE_IDLE);
atomic_store(&channels[ch].prev_state, -1);
break;
case CMD_ADD_CHANNEL:
case CMD_ADD_MIDI_CHANNEL: {
int idx;
for (idx = 0; idx < MAX_CHANNELS; idx++)
if (!channels[idx].active)
break;
if (idx < MAX_CHANNELS)
channel_add(client, idx);
break;
}
case CMD_REMOVE_CHANNEL: {
int remove_idx = -1;
for (int idx = 1; idx < MAX_CHANNELS; idx++)
if (channels[idx].active)
remove_idx = idx;
if (remove_idx != -1) {
channel_remove(client, remove_idx);
pending_unregister_idx = remove_idx;
}
break;
}
case CMD_BIND_CHANNEL:
atomic_store(&bind_channel, cmd.data);
break;
case CMD_UNBIND:
atomic_store(&bind_channel, 0);
break;
case CMD_LOAD:
atomic_store(&cmd_load, 1);
break;
case CMD_SAVE:
atomic_store(&cmd_save, 1);
break;
case CMD_ADD_SCENE:
case CMD_REMOVE_SCENE:
case CMD_NEXT_SCENE:
case CMD_PREV_SCENE:
break;
default:
break;
}
}
/* ----------------------------------------------------------------
* process callback
* ---------------------------------------------------------------- */
int process_callback(jack_nframes_t nframes, void *arg) {
(void)arg;
if (midi_control_port) {
void *midi_ctrl_buf = jack_port_get_buffer(midi_control_port, nframes);
if (midi_ctrl_buf) {
midi_handle_events(midi_ctrl_buf, nframes);
}
}
/* process each active channel */
for (int c = 0; c < MAX_CHANNELS; c++) {
if (!atomic_load(&channels[c].active))
continue;
/* Guard against NULL ports (e.g. if port registration failed) */
if (!channels[c].audio_in || !channels[c].audio_out) {
fprintf(stderr, "WARN: channel %d has NULL audio port(s), skipping\n", c);
continue;
}
const jack_default_audio_sample_t *in =
(const jack_default_audio_sample_t *)jack_port_get_buffer(
channels[c].audio_in, nframes);
jack_default_audio_sample_t *out =
(jack_default_audio_sample_t *)jack_port_get_buffer(
channels[c].audio_out, nframes);
if (!out)
continue;
int state = atomic_load(&channels[c].state);
if (state != atomic_load(&channels[c].prev_state)) {
switch (state) {
case STATE_RECORD:
atomic_store(&channels[c].record_pos, 0);
atomic_store(&channels[c].loop_count, 0);
break;
case STATE_LOOPING:
if (atomic_load(&channels[c].prev_state) == STATE_RECORD &&
atomic_load(&channels[c].record_pos) > 0)
atomic_store(&channels[c].loop_count,
atomic_load(&channels[c].record_pos));
atomic_store(&channels[c].playback_pos, 0);
break;
default:
break;
}
}
jack_nframes_t i;
switch (state) {
case STATE_RECORD:
if (in) {
float *f_out = (float *)out;
const float *f_in = (const float *)in;
for (i = 0; i < nframes; i++) {
int rp = atomic_fetch_add(&channels[c].record_pos, 1);
if (rp < LOOP_BUF_SIZE)
channels[c].loop_buffer[rp] = f_in[i];
f_out[i] = f_in[i];
}
} else {
memset(out, 0, sizeof(jack_default_audio_sample_t) * nframes);
}
break;
case STATE_LOOPING:
int lc = atomic_load(&channels[c].loop_count);
if (lc > 0) {
float *outf = (float *)out;
for (i = 0; i < nframes; i++) {
int pp = atomic_load(&channels[c].playback_pos);
outf[i] = channels[c].loop_buffer[pp];
atomic_store(&channels[c].playback_pos, (pp + 1) % lc);
}
} else {
memset(out, 0, sizeof(jack_default_audio_sample_t) * nframes);
}
break;
case STATE_PAUSED:
memset(out, 0, sizeof(jack_default_audio_sample_t) * nframes);
break;
default: /* IDLE */
if (in) {
memcpy(out, in, sizeof(jack_default_audio_sample_t) * nframes);
} else {
memset(out, 0, sizeof(jack_default_audio_sample_t) * nframes);
}
break;
}
// push loop output into save ring if saving (atomic load)
RingBuf *r = (RingBuf *)atomic_load_explicit(&channels[c].save_ring,
memory_order_acquire);
if (r != NULL) {
if (state == STATE_LOOPING && atomic_load(&channels[c].loop_count) > 0) {
const float *outf = (const float *)out;
ring_write(r, outf, nframes);
}
}
atomic_store(&channels[c].prev_state, state);
}
/* MIDI clock events affect channel 0 only */
if (midi_clock_port) {
void *midi_clock_buf = jack_port_get_buffer(midi_clock_port, nframes);
if (midi_clock_buf) {
jack_nframes_t n_clock_events = jack_midi_get_event_count(midi_clock_buf);
jack_midi_event_t cev;
for (jack_nframes_t j = 0; j < n_clock_events; j++) {
if (jack_midi_event_get(&cev, midi_clock_buf, j) != 0)
continue;
if (cev.size >= 1) {
unsigned char msg = cev.buffer[0];
switch (msg) {
case 0xFA: {
int s = atomic_load(&channels[0].state);
if (s == STATE_IDLE)
atomic_store(&channels[0].state, STATE_RECORD);
break;
}
case 0xFC:
atomic_store(&channels[0].state, STATE_IDLE);
break;
case 0xFB: {
int s = atomic_load(&channels[0].state);
if (s == STATE_PAUSED)
atomic_store(&channels[0].state, STATE_LOOPING);
break;
}
default:
break;
}
}
}
}
}
return 0;
}
/* ----------------------------------------------------------------
* shutdown callback
* ---------------------------------------------------------------- */
void jack_shutdown_cb(void *arg) {
(void)arg;
fprintf(stderr, "JACK shutdown\n");
exit(0);
}
/* ----------------------------------------------------------------
* looper initialisation
* ---------------------------------------------------------------- */
int looper_init(jack_client_t *client) {
/* store sample rate for writer thread */
global_sample_rate = jack_get_sample_rate(client);
/* create status FIFO (ignore if already exists) */
mkfifo(STATUS_FIFO, 0666);
/* open the status FIFO for reading+writing so writes work even without reader */
status_fd = open(STATUS_FIFO, O_RDWR);
if (status_fd < 0) {
perror("open status FIFO");
}
queue_init(&cmd_queue);
queue_init(&cmd_queue_main_midi);
queue_init(&cmd_queue_main_fifo);
/* start the FIFO reader thread */
pipe_start_reader();
/* channel 0 */
channels[0].active = 1;
atomic_store(&channels[0].state, STATE_IDLE);
atomic_store(&channels[0].prev_state, -1);
channels[0].loop_count = 0;
atomic_store(&channels[0].record_pos, 0);
atomic_store(&channels[0].playback_pos, 0);
atomic_store_explicit(&channels[0].save_ring, NULL, memory_order_release);
channels[0].audio_in = jack_port_register(
client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
channels[0].audio_out = jack_port_register(
client, "output", JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0);
if (!channels[0].audio_in || !channels[0].audio_out) {
fprintf(stderr, "Could not create audio ports for channel 0\n");
return -1;
}
channel_count = 1;
midi_control_port = jack_port_register(
client, "control", JACK_DEFAULT_MIDI_TYPE, JackPortIsInput, 0);
midi_clock_port = jack_port_register(client, "clock", JACK_DEFAULT_MIDI_TYPE,
JackPortIsInput, 0);
if (!midi_control_port || !midi_clock_port) {
fprintf(stderr, "Could not create MIDI ports\n");
return -1;
}
return 0;
}
/* ----------------------------------------------------------------
* writer thread consumes the save ring and writes WAV file
* ---------------------------------------------------------------- */
static void *writer_thread(void *arg) {
struct channel_t *ch = (struct channel_t *)arg;
RingBuf *ring = (RingBuf *)ch->save_ring;
if (!ring)
return NULL;
static const char *path = "save.wav";
unsigned sr = (unsigned)global_sample_rate;
if (sr == 0)
sr = 48000;
int lc = atomic_load(&ch->loop_count);
float *outbuf = malloc((size_t)lc * sizeof(float));
if (!outbuf) {
ring_destroy(ring);
free(ring);
ch->save_ring = NULL;
return NULL;
}
size_t collected = 0;
size_t want = (size_t)lc;
while (collected < want) {
size_t got = ring_read(ring, outbuf + collected, want - collected);
collected += got;
if (got == 0) {
struct timespec req = {.tv_sec = 0, .tv_nsec = 10000000};
nanosleep(&req, NULL);
}
}
wav_write(path, outbuf, (unsigned)lc, sr);
free(outbuf);
ring_destroy(ring);
free(ring);
atomic_store_explicit(&ch->save_ring, NULL, memory_order_release);
return NULL;
}
/* ----------------------------------------------------------------
* mainloop command processing
* ---------------------------------------------------------------- */
void looper_process_commands(jack_client_t *client) {
/* process commands from the three queues FIRST */
command_t cmd;
while (queue_pop(&cmd_queue, &cmd))
exec_command(cmd, client);
while (queue_pop(&cmd_queue_main_midi, &cmd))
exec_command(cmd, client);
while (queue_pop(&cmd_queue_main_fifo, &cmd))
exec_command(cmd, client);
/* Unregister any ports that were marked for deferred removal.
By now the realtime thread has had at least one full cycle
to see the `active = 0` store. */
if (pending_unregister_idx != -1) {
int idx = pending_unregister_idx;
if (channels[idx].audio_in)
jack_port_unregister(client, channels[idx].audio_in);
if (channels[idx].audio_out)
jack_port_unregister(client, channels[idx].audio_out);
pending_unregister_idx = -1;
}
/* ---------- add channel ---------- */
if (atomic_exchange(&cmd_add, 0)) {
int idx;
for (idx = 0; idx < MAX_CHANNELS; idx++)
if (!channels[idx].active)
break;
if (idx < MAX_CHANNELS) {
channel_add(client, idx);
}
}
/* ---------- remove channel ---------- */
if (atomic_exchange(&cmd_remove, 0)) {
int remove_idx = -1;
for (int idx = 1; idx < MAX_CHANNELS; idx++)
if (channels[idx].active)
remove_idx = idx;
if (remove_idx != -1) {
/* Mark inactive now; ports will be unregistered next round */
channel_remove(client, remove_idx);
pending_unregister_idx = remove_idx;
}
}
/* ---------- load command ---------- */
if (atomic_exchange(&cmd_load, 0)) {
float *buf = NULL;
unsigned frames = 0;
printf("LOAD: wav_read called\n");
if (wav_read("loop.wav", &buf, &frames) == 0 && frames > 0) {
printf("LOAD: success, frames=%u\n", frames);
if (frames > LOOP_BUF_SIZE)
frames = LOOP_BUF_SIZE;
memcpy(channels[0].loop_buffer, buf, frames * sizeof(float));
atomic_store(&channels[0].loop_count, (int)frames);
atomic_store(&channels[0].record_pos, 0);
atomic_store(&channels[0].playback_pos, 0);
atomic_store(&channels[0].state, STATE_LOOPING);
atomic_store(&channels[0].prev_state, -1);
free(buf);
} else {
fprintf(stderr, "Failed to load loop.wav\n");
printf("LOAD: FAILED\n");
}
}
/* ---------- save command (writer thread) ---------- */
if (atomic_exchange(&cmd_save, 0)) {
int lc = atomic_load(&channels[0].loop_count);
if (atomic_load(&channels[0].state) == STATE_LOOPING && lc > 0 &&
channels[0].save_ring == NULL) {
RingBuf *ring = (RingBuf *)malloc(sizeof(RingBuf));
if (ring) {
size_t sz = (size_t)lc * 2;
if (ring_init(ring, sz) == 0) {
atomic_store_explicit(&channels[0].save_ring, (_Atomic RingBuf *)ring,
memory_order_release);
pthread_t th;
pthread_create(&th, NULL, writer_thread, &channels[0]);
pthread_detach(th);
} else {
free(ring);
}
}
}
}
/* write current state to status FIFO */
looper_write_status();
}

19
engine/src/looper.h Normal file
View File

@@ -0,0 +1,19 @@
#ifndef LOOPER_H
#define LOOPER_H
// cppcheck-suppress missingIncludeSystem
#include <jack/jack.h>
/* Initialisation must be called after setting process callback */
int looper_init(jack_client_t *client);
/* Process callback to be called by JACK */
int process_callback(jack_nframes_t nframes, void *arg);
/* Shutdown callback */
void jack_shutdown_cb(void *arg);
/* Mainloop command processing (add/remove channels) */
void looper_process_commands(jack_client_t *client);
#endif

51
engine/src/main.c Normal file
View File

@@ -0,0 +1,51 @@
// cppcheck-suppress missingIncludeSystem
#include "looper.h"
#include <jack/jack.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
int main(int argc, char *argv[]) {
(void)argc;
(void)argv;
const char *client_name = "looper";
jack_options_t options = JackNullOption;
jack_status_t status;
jack_client_t *client = jack_client_open(client_name, options, &status);
if (client == NULL) {
fprintf(stderr, "jack_client_open() failed, status = 0x%2.0x\n", status);
if (status & JackServerFailed)
fprintf(stderr, "Unable to connect to JACK server\n");
return 1;
}
if (status & JackNameNotUnique)
client_name = jack_get_client_name(client);
jack_set_process_callback(client, process_callback, NULL);
jack_on_shutdown(client, jack_shutdown_cb, NULL);
if (looper_init(client) != 0) {
fprintf(stderr, "looper initialisation failed\n");
jack_client_close(client);
return 1;
}
if (jack_activate(client)) {
fprintf(stderr, "Cannot activate client\n");
jack_client_close(client);
return 1;
}
fprintf(stderr, "looper running (client name '%s')\n", client_name);
while (1) {
looper_process_commands(client);
{ struct timespec ts = { .tv_sec = 0, .tv_nsec = 50000000 }; nanosleep(&ts, NULL); } /* check commands every 50 ms */
}
jack_client_close(client);
return 0;
}

119
engine/src/midi.c Normal file
View File

@@ -0,0 +1,119 @@
// cppcheck-suppress missingIncludeSystem
#include "midi.h"
#include "channel.h"
#include <jack/jack.h>
#include <jack/midiport.h>
#include <stdatomic.h>
extern atomic_int control_key_active;
extern atomic_int cmd_add;
extern atomic_int cmd_remove;
extern atomic_int cmd_load;
extern atomic_int cmd_save;
extern atomic_int bind_channel;
void midi_handle_events(void *port_buffer, jack_nframes_t nframes) {
(void)nframes;
jack_nframes_t nevents = jack_midi_get_event_count(port_buffer);
jack_midi_event_t ev;
for (jack_nframes_t i = 0; i < nevents; i++) {
if (jack_midi_event_get(&ev, port_buffer, i) != 0)
continue;
if (ev.size < 3)
continue;
unsigned char status = ev.buffer[0];
unsigned char note = ev.buffer[1];
unsigned char vel = ev.buffer[2];
/* noteon */
if ((status & 0xf0) == 0x90 && vel > 0) {
if (note == 64) {
atomic_store(&control_key_active, 1);
} else {
int ck = atomic_load(&control_key_active);
if (ck) {
atomic_store(&control_key_active, 0);
if (note < 16) {
atomic_store(&bind_channel, note);
} else {
switch (note) {
case 60:
atomic_store(&cmd_add, 1);
break;
case 61:
atomic_store(&cmd_remove, 1);
break;
case 62: /* trigger looper channel via bind_channel */
{
int bch = atomic_load(&bind_channel);
if (bch >= 0 && bch < MAX_CHANNELS) {
int cur = atomic_load(&channels[bch].state);
switch (cur) {
case STATE_IDLE:
atomic_store(&channels[bch].state, STATE_RECORD);
break;
case STATE_RECORD:
atomic_store(&channels[bch].state, STATE_LOOPING);
break;
case STATE_LOOPING:
atomic_store(&channels[bch].state, STATE_PAUSED);
break;
case STATE_PAUSED:
atomic_store(&channels[bch].state, STATE_LOOPING);
break;
}
}
} break;
case 63: /* unbind reset bind to channel 0 */
atomic_store(&bind_channel, 0);
break;
case 70: /* load WAV into channel 0 */
atomic_store(&cmd_load, 1);
break;
case 71: /* save WAV of channel 0 */
atomic_store(&cmd_save, 1);
break;
default:
break;
}
}
} else {
/* direct mapping */
switch (note) {
case 1: /* toggle channel 0 */
{
int cur0 = atomic_load(&channels[0].state);
switch (cur0) {
case STATE_IDLE:
atomic_store(&channels[0].state, STATE_RECORD);
break;
case STATE_RECORD:
atomic_store(&channels[0].state, STATE_LOOPING);
break;
case STATE_LOOPING:
atomic_store(&channels[0].state, STATE_PAUSED);
break;
case STATE_PAUSED:
atomic_store(&channels[0].state, STATE_LOOPING);
break;
}
} break;
case 60:
atomic_store(&cmd_add, 1);
break;
case 61:
atomic_store(&cmd_remove, 1);
break;
default:
break;
}
}
}
} else if ((status & 0xf0) == 0x80 ||
((status & 0xf0) == 0x90 && vel == 0)) {
atomic_store(&control_key_active, 0);
}
}
}

9
engine/src/midi.h Normal file
View File

@@ -0,0 +1,9 @@
#ifndef MIDI_H
#define MIDI_H
// cppcheck-suppress missingIncludeSystem
#include <jack/types.h>
void midi_handle_events(void *port_buffer, jack_nframes_t nframes);
#endif

105
engine/src/pipe.c Normal file
View File

@@ -0,0 +1,105 @@
#include "pipe.h"
#include "command.h"
#include "queue.h"
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h>
#include <unistd.h>
#define FIFO_PATH "/tmp/looper_cmd"
#define LINE_MAX 256
/* forwarddeclare the global queues (defined in looper.c) */
extern spsc_queue_t cmd_queue;
extern spsc_queue_t cmd_queue_main_fifo;
static void *pipe_thread_func(void *arg) {
(void)arg;
char line[LINE_MAX];
while (1) {
FILE *fifo = fopen(FIFO_PATH, "r");
if (!fifo) {
perror("fopen fifo");
return NULL;
}
while (fgets(line, sizeof(line), fifo)) {
/* strip newline */
size_t len = strlen(line);
if (len > 0 && line[len - 1] == '\n')
line[len - 1] = '\0';
if (strcmp(line, "add") == 0) {
command_t cmd = {.type = CMD_ADD_CHANNEL, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "add_midi") == 0) {
command_t cmd = {.type = CMD_ADD_MIDI_CHANNEL, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "remove") == 0) {
command_t cmd = {.type = CMD_REMOVE_CHANNEL, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strncmp(line, "record ", 7) == 0) {
int ch = atoi(line + 7);
command_t cmd = {.type = CMD_CYCLE, .channel = ch, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "stop") == 0) {
command_t cmd = {.type = CMD_STOP, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strncmp(line, "bind ", 5) == 0) {
int ch = atoi(line + 5);
command_t cmd = {.type = CMD_BIND_CHANNEL, .channel = -1, .data = ch};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "unbind") == 0) {
command_t cmd = {.type = CMD_UNBIND, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "scene_add") == 0) {
command_t cmd = {.type = CMD_ADD_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "scene_remove") == 0) {
command_t cmd = {.type = CMD_REMOVE_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "scene_next") == 0) {
command_t cmd = {.type = CMD_NEXT_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "scene_prev") == 0) {
command_t cmd = {.type = CMD_PREV_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "load") == 0) {
command_t cmd = {.type = CMD_LOAD, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
} else if (strcmp(line, "save") == 0) {
command_t cmd = {.type = CMD_SAVE, .channel = -1, .data = 0};
queue_push(&cmd_queue, cmd);
}
/* ignore unknown lines */
}
/* EOF all writers closed, reopen for next connection */
fclose(fifo);
{
struct timespec ts = {.tv_sec = 0, .tv_nsec = 50000000};
nanosleep(&ts, NULL);
} /* small pause before retrying */
}
return NULL; /* unreachable */
}
int pipe_start_reader(void) {
/* create FIFO if it doesn't exist */
if (mkfifo(FIFO_PATH, 0666) != 0 && errno != EEXIST) {
perror("mkfifo");
return -1;
}
pthread_t tid;
if (pthread_create(&tid, NULL, pipe_thread_func, NULL) != 0) {
perror("pthread_create");
return -1;
}
pthread_detach(tid); /* we don't need to join */
return 0;
}

9
engine/src/pipe.h Normal file
View File

@@ -0,0 +1,9 @@
#ifndef PIPE_H
#define PIPE_H
/* Start the FIFO reader thread.
* Creates /tmp/looper_cmd (or aborts on error).
* Returns 0 on success, -1 on failure. */
int pipe_start_reader(void);
#endif

31
engine/src/queue.c Normal file
View File

@@ -0,0 +1,31 @@
#include "queue.h"
#include <stdatomic.h>
#include <stdbool.h>
void queue_init(spsc_queue_t *q) {
/* nothing to allocate, just ensure head/tail start at 0 */
q->head = 0;
q->tail = 0;
}
bool queue_push(spsc_queue_t *q, command_t cmd) {
int h = atomic_load_explicit(&q->head, memory_order_relaxed);
int t = atomic_load_explicit(&q->tail, memory_order_acquire);
int next = (h + 1) % QUEUE_CAPACITY;
if (next == t)
return false; /* queue full */
q->buffer[h] = cmd;
atomic_store_explicit(&q->head, next, memory_order_release);
return true;
}
bool queue_pop(spsc_queue_t *q, command_t *cmd) {
int t = atomic_load_explicit(&q->tail, memory_order_relaxed);
int h = atomic_load_explicit(&q->head, memory_order_acquire);
if (t == h)
return false; /* queue empty */
*cmd = q->buffer[t];
atomic_store_explicit(&q->tail, (t + 1) % QUEUE_CAPACITY,
memory_order_release);
return true;
}

31
engine/src/queue.h Normal file
View File

@@ -0,0 +1,31 @@
#ifndef QUEUE_H
#define QUEUE_H
#include "command.h"
#include <stdbool.h>
/* Fixedsize lockfree SPSC queue (single producer, single consumer).
* The queue is safe for one thread writing (producer) and one thread
* reading (consumer). No locks, no dynamic memory allocation.
* Must be initialised before first use. All operations are RTsafe. */
#define QUEUE_CAPACITY 256
typedef struct {
command_t buffer[QUEUE_CAPACITY];
/* head: index where next element will be written (producer only)
* tail: index of next element to read (consumer only) */
int head;
int tail;
} spsc_queue_t;
/* Initialise queue (must be called once before any push/pop). */
void queue_init(spsc_queue_t *q);
/* Push a command. Returns true on success, false if queue full. */
bool queue_push(spsc_queue_t *q, command_t cmd);
/* Pop a command. Returns true if a command was retrieved, false if empty. */
bool queue_pop(spsc_queue_t *q, command_t *cmd);
#endif

76
engine/src/ringbuffer.c Normal file
View File

@@ -0,0 +1,76 @@
#include "ringbuffer.h"
#include <stdlib.h>
static inline size_t load_head(const RingBuf *r) {
return atomic_load_explicit(&r->head, memory_order_relaxed);
}
static inline size_t load_tail(const RingBuf *r) {
return atomic_load_explicit(&r->tail, memory_order_relaxed);
}
static inline void store_head(RingBuf *r, size_t v) {
atomic_store_explicit(&r->head, v, memory_order_relaxed);
}
static inline void store_tail(RingBuf *r, size_t v) {
atomic_store_explicit(&r->tail, v, memory_order_relaxed);
}
int ring_init(RingBuf *r, size_t capacity) {
r->buf = (float *)malloc(capacity * sizeof(float));
if (!r->buf)
return -1;
r->capacity = capacity;
store_head(r, 0);
store_tail(r, 0);
return 0;
}
void ring_destroy(RingBuf *r) {
free(r->buf);
r->buf = NULL;
r->capacity = 0;
}
static size_t ring_readable(const RingBuf *r) {
size_t h = load_head(r);
size_t t = load_tail(r);
if (h >= t)
return h - t;
else
return r->capacity - (t - h);
}
static size_t ring_writeable(const RingBuf *r) {
return r->capacity - 1 - ring_readable(r);
}
size_t ring_write(RingBuf *r, const float *data, size_t count) {
size_t avail = ring_writeable(r);
if (count > avail)
count = avail;
if (count == 0)
return 0;
size_t head = load_head(r);
size_t cap = r->capacity;
for (size_t i = 0; i < count; ++i) {
r->buf[head] = data[i];
head = (head + 1) % cap;
}
store_head(r, head);
return count;
}
size_t ring_read(RingBuf *r, float *data, size_t count) {
size_t avail = ring_readable(r);
if (count > avail)
count = avail;
if (count == 0)
return 0;
size_t tail = load_tail(r);
size_t cap = r->capacity;
for (size_t i = 0; i < count; ++i) {
data[i] = r->buf[tail];
tail = (tail + 1) % cap;
}
store_tail(r, tail);
return count;
}

19
engine/src/ringbuffer.h Normal file
View File

@@ -0,0 +1,19 @@
#ifndef RINGBUFFER_H
#define RINGBUFFER_H
#include <stddef.h>
#include <stdatomic.h>
typedef struct {
atomic_size_t head;
atomic_size_t tail;
size_t capacity;
float *buf;
} RingBuf;
int ring_init(RingBuf *r, size_t capacity);
void ring_destroy(RingBuf *r);
size_t ring_write(RingBuf *r, const float *data, size_t count);
size_t ring_read(RingBuf *r, float *data, size_t count);
#endif

41
engine/src/wav.c Normal file
View File

@@ -0,0 +1,41 @@
#include "wav.h"
#include "channel.h"
#include <stdio.h>
#include <stdlib.h>
#include <sndfile.h>
int wav_read(const char *path, float **buffer, unsigned *frames) {
SF_INFO info;
info.format = 0;
SNDFILE *sf = sf_open(path, SFM_READ, &info);
if (!sf) return -1;
/* We need mono 16-bit PCM; refuse anything else */
if (info.channels != 1 || info.samplerate <= 0) {
sf_close(sf);
return -1;
}
unsigned total = (info.frames > (sf_count_t)LOOP_BUF_SIZE) ? LOOP_BUF_SIZE : (unsigned)info.frames;
float *buf = (float*)malloc(total * sizeof(float));
if (!buf) { sf_close(sf); return -1; }
sf_count_t nread = sf_readf_float(sf, buf, total);
sf_close(sf);
*buffer = buf;
*frames = (unsigned)nread;
return 0;
}
int wav_write(const char *path, const float *data, unsigned frames, unsigned sample_rate) {
SF_INFO info;
info.samplerate = sample_rate;
info.channels = 1;
info.format = SF_FORMAT_WAV | SF_FORMAT_PCM_16;
SNDFILE *sf = sf_open(path, SFM_WRITE, &info);
if (!sf) return -1;
sf_writef_float(sf, data, frames);
sf_close(sf);
return 0;
}

9
engine/src/wav.h Normal file
View File

@@ -0,0 +1,9 @@
#ifndef WAV_H
#define WAV_H
#include <stddef.h>
int wav_read(const char *path, float **buffer, unsigned *frames);
int wav_write(const char *path, const float *data, unsigned frames, unsigned sample_rate);
#endif

1187
engine/tests/integration.c Normal file
View File

File diff suppressed because it is too large Load Diff

32
engine/tests/main.c Normal file
View File

@@ -0,0 +1,32 @@
#include "test_common.h"
/* Declare test group functions */
int test_audio(void);
int test_loop(void);
int test_channel(void);
int test_scene_all(void);
int test_fifo(void);
int main(void) {
if (system("test -x ./looper") != 0) {
fprintf(stderr, "FATAL: looper binary not found\n");
return 1;
}
int failures = 0;
/* Audio passthrough (nonfatal) */
test_audio();
failures += test_loop();
failures += test_channel();
failures += test_scene_all();
failures += test_fifo();
if (failures > 0) {
fprintf(stderr, "%d test(s) FAILED\n", failures);
return 1;
}
printf("All tests completed successfully.\n");
return 0;
}

16
engine/tests/makefile Normal file
View File

@@ -0,0 +1,16 @@
CC = gcc
CFLAGS = -Wall -Wextra -Wpedantic -std=c11 -I../src -I$(JACK_CFLAGS)
LDFLAGS = -ljack -lpthread -lm
all: test_status_fifo
test_status_fifo: test_status_fifo.c ../src/looper.c ../src/channel.c ../src/midi.c ../src/queue.c ../src/pipe.c
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test: test_status_fifo
./test_status_fifo
.PHONY: all test clean
clean:
rm -f test_status_fifo

89
engine/tests/test_audio.c Normal file
View File

@@ -0,0 +1,89 @@
#include "test_common.h"
static int test_audio_pass_through(void) {
printf("Test: audio passthrough (connectivity)\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_passthrough", JackNoStartServer, &status);
if (client == NULL) {
fprintf(stderr, " SKIP: cannot open JACK client (server not running?)\n");
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
return 1;
}
jack_port_t *output_port = jack_port_register(client, "output",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsOutput, 0);
jack_port_t *input_port = jack_port_register(client, "input",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsInput, 0);
if (!output_port || !input_port) {
fprintf(stderr, " FAIL: could not register ports\n");
jack_client_close(client);
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
const char *looper_input = "looper:input";
const char *looper_output = "looper:output";
char my_output[64], my_input[64];
snprintf(my_output, sizeof(my_output), "test_passthrough:output");
snprintf(my_input, sizeof(my_input), "test_passthrough:input");
if (jack_connect(client, my_output, looper_input) != 0) {
fprintf(stderr, " FAIL: cannot connect\n");
jack_client_close(client);
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
return 1;
}
if (jack_connect(client, looper_output, my_input) != 0) {
fprintf(stderr, " FAIL: cannot connect\n");
jack_client_close(client);
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
return 1;
}
passthrough_output_port = output_port;
passthrough_input_port = input_port;
passthrough_phase = 0.0f;
passthrough_freq = 440.0f;
passthrough_sample_rate = jack_get_sample_rate(client);
passthrough_total_samples = 0;
passthrough_sum_sq = 0.0;
passthrough_done = 0;
continuous_sine = 1;
beep_remaining = 0;
jack_set_process_callback(client, passthrough_process, NULL);
if (jack_activate(client) != 0) {
fprintf(stderr, " FAIL: cannot activate client\n");
jack_client_close(client);
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(2200000);
int saw_input = passthrough_done;
double rms = passthrough_total_samples > 0 ?
sqrt(passthrough_sum_sq / passthrough_total_samples) : 0.0;
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
if (!saw_input) {
fprintf(stderr, " FAIL: looper did not produce output (no callback run?)\n");
return 1;
}
if (rms < 0.001) {
fprintf(stderr, " FAIL: looper output RMS too small (%.6f)\n", rms);
return 1;
}
printf(" PASS (RMS %.6f)\n", rms);
return 0;
}
int test_audio(void) {
return test_audio_pass_through();
}

611
engine/tests/test_channel.c Normal file
View File

@@ -0,0 +1,611 @@
#include "test_common.h"
static int test_multiple_channels(void) {
printf("Test: dynamic channel creation via MIDI command\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
if (init_persistent_midi_client() != 0) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: cannot initialise persistent MIDI client\n");
return 1;
}
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_multi", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
if (send_jack_note_on("looper:control", 60, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
int found = 0;
for (int retries = 0; retries < 30; retries++) {
safe_usleep(100000);
const char **ports = jack_get_ports(client, NULL, JACK_DEFAULT_AUDIO_TYPE, 0);
if (ports) {
for (int i = 0; ports[i]; i++) {
if (strstr(ports[i], "looper:channel1_input")) {
found = 1;
jack_free(ports);
goto port_found;
}
}
jack_free(ports);
}
}
port_found:
;
jack_client_close(client);
cleanup_persistent_midi_client();
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
if (!found) {
fprintf(stderr, " FAIL: channel1_input port not created\n");
return 1;
}
printf(" PASS (channel created)\n");
return 0;
}
static int test_control_key_modifier(void) {
printf("Test: controlkey modifier triggers state transition via note 62\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
if (init_persistent_midi_client() != 0) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: cannot initialise persistent MIDI client\n");
return 1;
}
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_ctrl_key", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
jack_port_t *audio_out = jack_port_register(client, "out",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsOutput, 0);
jack_port_t *audio_in = jack_port_register(client, "in",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsInput, 0);
if (!audio_out || !audio_in) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
char my_out[64], my_in[64];
snprintf(my_out, sizeof(my_out), "test_ctrl_key:out");
snprintf(my_in, sizeof(my_in), "test_ctrl_key:in");
if (jack_connect(client, my_out, "looper:input") ||
jack_connect(client, "looper:output", my_in)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 62, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
int sr = jack_get_sample_rate(client);
continuous_sine = 0;
beep_remaining = (int)(0.1f * sr);
bursts = 0;
prev_above = 0;
passthrough_output_port = audio_out;
passthrough_input_port = audio_in;
passthrough_phase = 0.0f;
passthrough_freq = 440.0f;
passthrough_sample_rate = sr;
passthrough_total_samples = 0;
passthrough_sum_sq = 0.0;
passthrough_done = 0;
jack_set_process_callback(client, passthrough_process, NULL);
if (jack_activate(client)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 62, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(2000000);
jack_deactivate(client);
jack_client_close(client);
cleanup_persistent_midi_client();
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
int got_bursts = bursts;
printf(" detected bursts: %d\n", got_bursts);
if (got_bursts < 3) {
fprintf(stderr, " FAIL: expected ≥3 bursts, got %d\n", got_bursts);
return 1;
}
printf(" PASS (controlkey modifier works)\n");
return 0;
}
static int test_bind_channel(void) {
printf("Test: controlkey bind channel (note 0) and toggle\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
if (init_persistent_midi_client() != 0) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: cannot initialise persistent MIDI client\n");
return 1;
}
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_bind", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
jack_port_t *audio_out = jack_port_register(client, "out",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsOutput, 0);
jack_port_t *audio_in = jack_port_register(client, "in",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsInput, 0);
if (!audio_out || !audio_in) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
char my_out[64], my_in[64];
snprintf(my_out, sizeof(my_out), "test_bind:out");
snprintf(my_in, sizeof(my_in), "test_bind:in");
if (jack_connect(client, my_out, "looper:input") ||
jack_connect(client, "looper:output", my_in)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 0, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 62, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
int sr = jack_get_sample_rate(client);
continuous_sine = 0;
beep_remaining = (int)(0.1f * sr);
bursts = 0;
prev_above = 0;
passthrough_output_port = audio_out;
passthrough_input_port = audio_in;
passthrough_phase = 0.0f;
passthrough_freq = 440.0f;
passthrough_sample_rate = sr;
passthrough_total_samples = 0;
passthrough_sum_sq = 0.0;
passthrough_done = 0;
jack_set_process_callback(client, passthrough_process, NULL);
if (jack_activate(client)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 62, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(2000000);
jack_deactivate(client);
jack_client_close(client);
cleanup_persistent_midi_client();
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
int got_bursts = bursts;
printf(" detected bursts: %d\n", got_bursts);
if (got_bursts < 3) {
fprintf(stderr, " FAIL: expected >=3 bursts, got %d\n", got_bursts);
return 1;
}
printf(" PASS (bind and toggle)\n");
return 0;
}
static int test_bind_unbind(void) {
printf("Test: bind to channel 5, unbind, then toggle default (channel 0)\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
if (init_persistent_midi_client() != 0) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: cannot initialise persistent MIDI client\n");
return 1;
}
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_unbind", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
jack_port_t *audio_out = jack_port_register(client, "out",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsOutput, 0);
jack_port_t *audio_in = jack_port_register(client, "in",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsInput, 0);
if (!audio_out || !audio_in) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
char my_out[64], my_in[64];
snprintf(my_out, sizeof(my_out), "test_unbind:out");
snprintf(my_in, sizeof(my_in), "test_unbind:in");
if (jack_connect(client, my_out, "looper:input") ||
jack_connect(client, "looper:output", my_in)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 5, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 63, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 62, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
int sr = jack_get_sample_rate(client);
continuous_sine = 0;
beep_remaining = (int)(0.1f * sr);
bursts = 0;
prev_above = 0;
passthrough_output_port = audio_out;
passthrough_input_port = audio_in;
passthrough_phase = 0.0f;
passthrough_freq = 440.0f;
passthrough_sample_rate = sr;
passthrough_total_samples = 0;
passthrough_sum_sq = 0.0;
passthrough_done = 0;
jack_set_process_callback(client, passthrough_process, NULL);
if (jack_activate(client)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 62, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(2000000);
jack_deactivate(client);
jack_client_close(client);
cleanup_persistent_midi_client();
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
int got_bursts = bursts;
printf(" detected bursts: %d\n", got_bursts);
if (got_bursts < 3) {
fprintf(stderr, " FAIL: expected >=3 bursts, got %d\n", got_bursts);
return 1;
}
printf(" PASS (unbind works, toggle channel 0)\n");
return 0;
}
static int test_remove_channel(void) {
printf("Test: dynamic channel removal via MIDI command\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
if (init_persistent_midi_client() != 0) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: cannot initialise persistent MIDI client\n");
return 1;
}
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_remove", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
if (send_jack_note_on("looper:control", 60, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(1500000);
const char **ports = jack_get_ports(client, NULL, JACK_DEFAULT_AUDIO_TYPE, 0);
int found = 0;
if (ports) {
for (int i = 0; ports[i]; i++) {
if (strstr(ports[i], "looper:channel1_input")) {
found = 1;
break;
}
}
jack_free(ports);
}
if (!found) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: channel1_input not created\n");
return 1;
}
printf(" channel1_input created\n");
if (send_jack_note_on("looper:control", 61, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
int still_found = 1;
for (int retries = 0; retries < 30; retries++) {
safe_usleep(100000);
ports = jack_get_ports(client, NULL, JACK_DEFAULT_AUDIO_TYPE, 0);
still_found = 0;
if (ports) {
for (int i = 0; ports[i]; i++) {
if (strstr(ports[i], "looper:channel1_input")) {
still_found = 1;
break;
}
}
jack_free(ports);
}
if (!still_found) break;
}
jack_client_close(client);
cleanup_persistent_midi_client();
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
if (still_found) {
fprintf(stderr, " FAIL: channel1_input not removed after remove command\n");
return 1;
}
printf(" PASS (channel removed)\n");
return 0;
}
static int test_stop_midi(void) {
printf("Test: MIDI stop (note 65 under control key)\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
if (init_persistent_midi_client() != 0) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: cannot initialise persistent MIDI client\n");
return 1;
}
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_stop", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
jack_port_t *audio_out = jack_port_register(client, "out",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsOutput, 0);
jack_port_t *audio_in = jack_port_register(client, "in",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsInput, 0);
if (!audio_out || !audio_in) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
char my_out[64], my_in[64];
snprintf(my_out, sizeof(my_out), "test_stop:out");
snprintf(my_in, sizeof(my_in), "test_stop:in");
if (jack_connect(client, my_out, "looper:input") ||
jack_connect(client, "looper:output", my_in)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
if (send_jack_note_on("looper:control", 1, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
int sr = jack_get_sample_rate(client);
continuous_sine = 0;
beep_remaining = (int)(0.2f * sr);
bursts = 0;
prev_above = 0;
passthrough_output_port = audio_out;
passthrough_input_port = audio_in;
passthrough_phase = 0.0f;
passthrough_freq = 440.0f;
passthrough_sample_rate = sr;
passthrough_total_samples = 0;
passthrough_sum_sq = 0.0;
passthrough_done = 0;
jack_set_process_callback(client, passthrough_process, NULL);
if (jack_activate(client)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(150000);
if (send_jack_note_on("looper:control", 1, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(500000);
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 65, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
int prev = bursts;
for (int retries = 0; retries < 20; retries++) {
safe_usleep(100000);
int cur = bursts;
if (cur == prev) break;
prev = cur;
}
int bursts_before = bursts;
safe_usleep(500000);
int bursts_after = bursts;
jack_deactivate(client);
jack_client_close(client);
cleanup_persistent_midi_client();
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
if (bursts_after > bursts_before + 5) {
fprintf(stderr, " FAIL: bursts continued after stop (%d -> %d)\n",
bursts_before, bursts_after);
return 1;
}
printf(" PASS (stop stopped playback)\n");
return 0;
}
static int test_midi_channel_add(void) {
printf("Test: MIDI channel creation via FIFO (add_midi)\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_midi_add", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
int fd = open("/tmp/looper_cmd", O_WRONLY);
if (fd < 0) {
perror("open fifo");
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
write(fd, "add_midi\n", 9);
close(fd);
safe_usleep(1500000);
const char **ports = jack_get_ports(client, NULL, JACK_DEFAULT_MIDI_TYPE, 0);
int found = 0;
if (ports) {
for (int i = 0; ports[i]; i++) {
if (strstr(ports[i], "looper:channel1_midi_in")) {
found = 1;
break;
}
}
jack_free(ports);
}
jack_client_close(client);
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
if (!found) {
fprintf(stderr, " FAIL: channel1_midi_in port not created\n");
return 1;
}
printf(" PASS (MIDI channel created)\n");
return 0;
}
int test_channel(void) {
int failures = 0;
failures += test_multiple_channels();
failures += test_control_key_modifier();
failures += test_bind_channel();
failures += test_bind_unbind();
failures += test_remove_channel();
failures += test_stop_midi();
failures += test_midi_channel_add();
return failures;
}

160
engine/tests/test_fifo.c Normal file
View File

@@ -0,0 +1,160 @@
#include "test_common.h"
static int test_fifo_pipe(void) {
printf("Test: FIFO pipe add/remove\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_fifo", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
int fd = open("/tmp/looper_cmd", O_WRONLY);
if (fd < 0) {
perror("open fifo");
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
write(fd, "add\n", 4);
safe_usleep(1500000);
const char **ports = jack_get_ports(client, NULL, JACK_DEFAULT_AUDIO_TYPE, 0);
int found = 0;
if (ports) {
for (int i = 0; ports[i]; i++) {
if (strstr(ports[i], "looper:channel1_input")) {
found = 1;
break;
}
}
jack_free(ports);
}
write(fd, "remove\n", 7);
close(fd);
safe_usleep(1500000);
ports = jack_get_ports(client, NULL, JACK_DEFAULT_AUDIO_TYPE, 0);
int still_found = 0;
if (ports) {
for (int i = 0; ports[i]; i++) {
if (strstr(ports[i], "looper:channel1_input")) {
still_found = 1;
break;
}
}
jack_free(ports);
}
jack_client_close(client);
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
if (!found) {
fprintf(stderr, " FAIL: channel not added via FIFO\n");
return 1;
}
if (still_found) {
fprintf(stderr, " FAIL: channel not removed via FIFO\n");
return 1;
}
printf(" PASS (FIFO add/remove works)\n");
return 0;
}
static int test_fifo_stop_bind_unbind(void) {
printf("Test: FIFO stop, bind, unbind\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
if (init_persistent_midi_client() != 0) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: cannot initialise persistent MIDI client\n");
return 1;
}
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_fifo_stop", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
jack_port_t *audio_out = jack_port_register(client, "out",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsOutput, 0);
jack_port_t *audio_in = jack_port_register(client, "in",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsInput, 0);
if (!audio_out || !audio_in) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
char my_out[64], my_in[64];
snprintf(my_out, sizeof(my_out), "test_fifo_stop:out");
snprintf(my_in, sizeof(my_in), "test_fifo_stop:in");
if (jack_connect(client, my_out, "looper:input") ||
jack_connect(client, "looper:output", my_in)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
if (send_jack_note_on("looper:control", 1, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
int sr = jack_get_sample_rate(client);
continuous_sine = 0;
beep_remaining = (int)(0.1f * sr);
bursts = 0;
prev_above = 0;
passthrough_output_port = audio_out;
passthrough_input_port = audio_in;
passthrough_phase = 0.0f;
passthrough_freq = 440.0f;
passthrough_sample_rate = sr;
passthrough_total_samples = 0;
passthrough_sum_sq = 0.0;
passthrough_done = 0;
jack_set_process_callback(client, passthrough_process, NULL);
if (jack_activate(client)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(150000);
int fd = open("/tmp/looper_cmd", O_WRONLY);
if (fd < 0) {
perror("open fifo");
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
write(fd, "stop\n", 5);
write(fd, "bind 0\n", 7);
write(fd, "unbind\n", 7);
close(fd);
safe_usleep(500000);
int bursts_after = bursts;
jack_deactivate(client);
jack_client_close(client);
cleanup_persistent_midi_client();
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
if (bursts_after < 1) {
fprintf(stderr, " FAIL: no burst detected (probably no recording)\n");
return 1;
}
printf(" PASS (FIFO stop, bind, unbind executed)\n");
return 0;
}
int test_fifo(void) {
int failures = 0;
failures += test_fifo_pipe();
failures += test_fifo_stop_bind_unbind();
return failures;
}

190
engine/tests/test_loop.c Normal file
View File

@@ -0,0 +1,190 @@
#include "test_common.h"
static int test_looper_looping(void) {
printf("Test: loop recording and playback (expect ≥3 repetitions)\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
if (init_persistent_midi_client() != 0) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: cannot initialise persistent MIDI client\n");
return 1;
}
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_looping", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: JACK not running?\n");
return 1;
}
jack_port_t *audio_out = jack_port_register(client, "out",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsOutput, 0);
jack_port_t *audio_in = jack_port_register(client, "in",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsInput, 0);
if (!audio_out || !audio_in) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
char my_out[64], my_in[64];
snprintf(my_out, sizeof(my_out), "test_looping:out");
snprintf(my_in, sizeof(my_in), "test_looping:in");
if (jack_connect(client, my_out, "looper:input") ||
jack_connect(client, "looper:output", my_in)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
if (send_jack_note_on("looper:control", 1, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(500000);
int sr = jack_get_sample_rate(client);
continuous_sine = 0;
beep_remaining = (int)(0.1f * sr);
bursts = 0;
prev_above = 0;
passthrough_output_port = audio_out;
passthrough_input_port = audio_in;
passthrough_phase = 0.0f;
passthrough_freq = 440.0f;
passthrough_sample_rate = sr;
passthrough_total_samples = 0;
passthrough_sum_sq = 0.0;
passthrough_done = 0;
jack_set_process_callback(client, passthrough_process, NULL);
if (jack_activate(client)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(150000);
safe_usleep(800000);
if (send_jack_note_on("looper:control", 1, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(4000000);
jack_deactivate(client);
jack_client_close(client);
cleanup_persistent_midi_client();
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
int got_bursts = bursts;
printf(" detected bursts: %d\n", got_bursts);
if (got_bursts < 3) {
fprintf(stderr, " FAIL: expected ≥3 bursts, got %d\n", got_bursts);
return 1;
}
printf(" PASS (at least 3 repetitions)\n");
return 0;
}
static int test_record_loop_stop(void) {
printf("Test: full recordloopstop (≥5 repetitions)\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
if (init_persistent_midi_client() != 0) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: cannot initialise persistent MIDI client\n");
return 1;
}
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_full", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " SKIP: no JACK\n");
return 1;
}
jack_port_t *audio_out = jack_port_register(client, "out",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsOutput, 0);
jack_port_t *audio_in = jack_port_register(client, "in",
JACK_DEFAULT_AUDIO_TYPE,
JackPortIsInput, 0);
if (!audio_out || !audio_in) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
char my_out[64], my_in[64];
snprintf(my_out, sizeof(my_out), "test_full:out");
snprintf(my_in, sizeof(my_in), "test_full:in");
if (jack_connect(client, my_out, "looper:input") ||
jack_connect(client, "looper:output", my_in)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
if (send_jack_note_on("looper:control", 1, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(500000);
int sr = jack_get_sample_rate(client);
continuous_sine = 0;
beep_remaining = (int)(0.5f * sr);
bursts = 0;
prev_above = 0;
passthrough_output_port = audio_out;
passthrough_input_port = audio_in;
passthrough_phase = 0.0f;
passthrough_freq = 440.0f;
passthrough_sample_rate = sr;
passthrough_total_samples = 0;
passthrough_sum_sq = 0.0;
passthrough_done = 0;
jack_set_process_callback(client, passthrough_process, NULL);
if (jack_activate(client)) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 1, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(2500000);
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 65, 127) != 0) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
int total_bursts = bursts;
jack_deactivate(client);
jack_client_close(client);
cleanup_persistent_midi_client();
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
if (total_bursts < 5) {
fprintf(stderr, " FAIL: expected ≥5 bursts, got %d\n", total_bursts);
return 1;
}
printf(" PASS (≥5 repetitions, stopped cleanly)\n");
return 0;
}
int test_loop(void) {
int failures = 0;
failures += test_looper_looping();
failures += test_record_loop_stop();
return failures;
}

0
engine/tests/test_save.c Normal file
View File

View File

@@ -0,0 +1,116 @@
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <sys/select.h>
#define STATUS_FIFO "/tmp/looper_status"
#define CMD_FIFO "/tmp/looper_cmd"
static pid_t start_looper(void) {
pid_t pid = fork();
if (pid < 0) { perror("fork"); return -1; }
if (pid == 0) {
close(2);
open("/dev/null", O_WRONLY);
execl("./looper", "looper", NULL);
perror("execl");
_exit(1);
}
return pid;
}
/* Drain any stale data from the status FIFO */
static void drain_fifo(void) {
int fd = open(STATUS_FIFO, O_RDONLY | O_NONBLOCK);
if (fd < 0) return;
char buf[4096];
while (read(fd, buf, sizeof(buf)) > 0);
close(fd);
}
/* Read the first status line with a timeout (milliseconds).
* Returns 0 on success, -1 on timeout/error. */
static int read_status_line_timeout(char *buf, size_t bufsize, int timeout_ms) {
int fd = open(STATUS_FIFO, O_RDONLY | O_NONBLOCK);
if (fd < 0) return -1;
fd_set set;
struct timeval tv;
FD_ZERO(&set);
FD_SET(fd, &set);
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
int ret = select(fd + 1, &set, NULL, NULL, &tv);
if (ret <= 0) {
close(fd);
return -1;
}
int n = read(fd, buf, bufsize - 1);
close(fd);
if (n <= 0) return -1;
buf[n] = '\0';
/* keep only the first line */
char *nl = strchr(buf, '\n');
if (nl) *nl = '\0';
return 0;
}
static int test_status_after_record(void) {
printf("Test: status FIFO reports RECORD state after record command\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
/* Give looper time to start main loop and begin writing status */
usleep(1500000);
drain_fifo();
/* Send record 0 command via FIFO */
int fd_cmd = open(CMD_FIFO, O_WRONLY);
if (fd_cmd < 0) {
fprintf(stderr, " FAIL: cannot open command FIFO\n");
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
write(fd_cmd, "record 0\n", 9);
close(fd_cmd);
/* Keep reading status lines until we see RECORD or timeout (5 seconds) */
int found = 0;
int ch, sc;
char state[32];
char line[256];
for (int tries = 0; tries < 50; tries++) {
if (read_status_line_timeout(line, sizeof(line), 100) != 0) {
usleep(100000);
continue;
}
if (sscanf(line, "CH=%d SC=%d STATE=%31s", &ch, &sc, state) != 3)
continue;
if (ch == 0 && sc == 0 && strcmp(state, "RECORD") == 0) {
found = 1;
break;
}
}
if (!found) {
fprintf(stderr, " FAIL: did not see STATE=RECORD for CH=0 SC=0 within 5 seconds\n");
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
printf(" PASS\n");
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 0;
}
int main(void) {
int fail = 0;
fail += test_status_after_record();
return fail;
}

1574
engine/tests/test_tui.c Normal file
View File

File diff suppressed because it is too large Load Diff

0
engine/tests/test_wav.c Normal file
View File

View File

File diff suppressed because it is too large Load Diff