85 Commits

Author SHA1 Message Date
Loic Coenen
3646f6c47e Merge branch '6-recording-wav-file' 2026-05-17 16:59:56 +00:00
Loic Coenen
10d0269a5a add tests 2026-05-13 16:55:32 +00:00
b994911dab Merge pull request '4-implement-scene-switching-engine' (#4) from 4-implement-scene-switching-engine into master
Reviewed-on: #4
2026-05-13 12:51:03 -04:00
Loic Coenen
bb648d471b fix: resolve cppcheck warnings for const pointer and static functions
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:58:20 +00:00
Loic Coenen
fa9dbf2185 style: fix code formatting and include order in looper and ringbuffer 2026-05-12 19:58:19 +00:00
Loic Coenen
51493d5cab docs: add WAV load/save documentation and update evaluation table
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:35:21 +00:00
Loic Coenen
ce2dd7be76 fix: make channel state variables atomic to eliminate data races
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:32:10 +00:00
Loic Coenen
87d5e658c5 fix: restore all integration tests in main()
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:18:20 +00:00
Loic Coenen
525516fe03 refactor: replace manual WAV I/O with libsndfile
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:15:12 +00:00
Loic Coenen
3e52142f62 feat: replace manual WAV parsing with libsndfile
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:14:35 +00:00
Loic Coenen
a92b5c51e1 fix: skip remaining fmt chunk bytes correctly in wav_read
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:09:58 +00:00
Loic Coenen
bb3dfa8b2a fix: correct RIFF chunk size in test WAV header
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:07:09 +00:00
Loic Coenen
3721c0c9e1 refactor: disable all tests except failing WAV load/save
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:04:36 +00:00
Loic Coenen
c041645019 fix: increase sleep duration in WAV load test to ensure control key processing
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:03:22 +00:00
Loic Coenen
6344eaed47 fix: add debug output and increase delay in WAV load test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 19:02:59 +00:00
Loic Coenen
f96d7d290d fix: ensure fresh MIDI connection before each integration test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:49:12 +00:00
Loic Coenen
2d254c0503 fix: ensure fresh MIDI connection before each integration test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:39:48 +00:00
Loic Coenen
4339fda529 fix: keep persistent MIDI client across notes in integration tests
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:37:15 +00:00
Loic Coenen
04b59999c8 fix: make loop_count atomic and increase remove channel delay
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:28:54 +00:00
Loic Coenen
df1f4fa6bd fix: only set loop_count from record_pos when transitioning from record state
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:22:55 +00:00
Loic Coenen
7e5362259b refactor: extract JACK MIDI client reconnection logic
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:19:35 +00:00
Loic Coenen
b10d218749 fix: reconnect MIDI client before each test to avoid stale connections
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:19:06 +00:00
Loic Coenen
cc50577444 fix: cast atomic pointer loads/stores and remove duplicate free in writer_thread
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-12 18:01:57 +00:00
Loic Coenen
346c15d1c3 fix: use persistent MIDI client and fix save_ring race condition
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 22:14:33 +00:00
Loic Coenen
7deea9266b fix: reorder passthrough setup before load command in WAV load test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:49:35 +00:00
Loic Coenen
7d842163a2 fix: increase listen duration and add RMS logging in WAV load test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:39:49 +00:00
Loic Coenen
54fa307360 fix: increase sleep durations in WAV load test to prevent false failure
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:31:29 +00:00
Loic Coenen
5430795510 feat: push loop output into save ring during playback
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:16:02 +00:00
Loic Coenen
5a2414b4c3 feat: add WAV load/save and ring buffer implementation
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 21:15:12 +00:00
Loic Coenen
6b490ed739 feat: add WAV file loading, saving, and dedicated I/O threads
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-11 20:58:00 +00:00
Loic Coenen
d4a811e552 docs: add scene switching engine documentation and update evaluation
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 19:42:34 +00:00
Loic Coenen
567799a2d3 docs: add scene switching engine implementation guide 2026-05-10 19:42:33 +00:00
Loic Coenen
755af275d8 fix: convert shared scene metadata to atomic_int to fix data races
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 19:33:12 +00:00
Loic Coenen
74db4ed46c fix: add missing channel pointer declaration in apply_command
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 19:13:51 +00:00
Loic Coenen
15be644af7 refactor: remove unused variable 'cur' in looper_process_commands
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 19:07:52 +00:00
Loic Coenen
aaca25ebf1 refactor: remove unused local variable in looper commands
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 19:01:37 +00:00
Loic Coenen
e3b9321b1a fix: remove unused variable and suppress cppcheck warnings
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 19:00:13 +00:00
Loic Coenen
015ad2c5a7 chore: add trailing space to CFLAGS in makefile 2026-05-10 19:00:11 +00:00
Loic Coenen
c8b9de8e81 fix: reopen FIFO on EOF to prevent blocking on subsequent writes
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 18:39:10 +00:00
Loic Coenen
1ba98fc768 fix: prevent hang in scene add/remove test and fix unsafe scene copy
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 18:34:26 +00:00
Loic Coenen
4dfb7a87c1 fix: correct state access in MIDI clock handling
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 18:24:48 +00:00
Loic Coenen
8892acd3d2 refactor: split integration.c into modular test files
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 18:22:38 +00:00
Loic Coenen
7b00246443 feat: implement scene infrastructure for multi-scene looper support
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 18:00:32 +00:00
Loic Coenen
44177f785f style: fix code formatting in channel.c and midi.c 2026-05-10 18:00:29 +00:00
Loic Coenen
94d6bc25f1 test: add scene integration tests for add/remove/next/prev via FIFO and MIDI
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 17:42:45 +00:00
Loic Coenen
86d9bc72f1 style: reformat long lines in looper.c for readability 2026-05-10 16:36:15 +00:00
75f347c418 Merge pull request '2-midi-looping' (#3) from 2-midi-looping into master
Reviewed-on: #3
2026-05-10 12:24:23 -04:00
Loic Coenen
0be6cfb31d fix: move persistent MIDI client init/cleanup into each test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 13:19:14 +00:00
Loic Coenen
de8202a0d2 fix: use persistent JACK client for MIDI injection to avoid race conditions
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 13:05:42 +00:00
Loic Coenen
fe3fb7d873 fix: reduce main loop sleep to 1ms and add polling in tests
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 12:53:15 +00:00
Loic Coenen
ffe422d83f fix: poll for burst stabilization in MIDI stop test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 12:36:13 +00:00
Loic Coenen
5b1969415f fix: increase wait time and tolerance in MIDI stop test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 12:21:33 +00:00
Loic Coenen
91d58a07f5 fix: allow up to 2 extra bursts after MIDI stop in test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 12:18:08 +00:00
Loic Coenen
4e489b5e40 docs: add MIDI looping documentation and update evaluation
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 11:54:00 +00:00
Loic Coenen
df5ecef580 feat: add FIFO add_midi command and integration tests for FIFO stop/bind/unbind and MIDI channel creation
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 11:46:04 +00:00
Loic Coenen
df181b117e fix: correct MIDI channel processing and port cleanup
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 11:37:04 +00:00
Loic Coenen
ff226a8ea6 feat: add per-channel MIDI looping support
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 11:29:41 +00:00
Loic Coenen
85e828f461 style: reformat comments and code for consistent indentation 2026-05-10 11:29:39 +00:00
Loic Coenen
19b686fe2d docs: add arbitrary number of channels documentation and update evaluation
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 10:55:25 +00:00
Loic Coenen
0691594a92 docs: add documentation for arbitrary number of channels 2026-05-10 10:55:23 +00:00
Loic Coenen
9da4481300 fix: defer freeing old channel array until RT thread sees new pointer
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 10:50:03 +00:00
Loic Coenen
b7827e7311 fix: reset channel state on stop to prevent burst continuation
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 10:45:33 +00:00
Loic Coenen
595a35ec32 fix: correct atomic pointer declaration syntax
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 10:42:20 +00:00
f11a18a203 Merge pull request '12-command-art' (#2) from 12-command-art into master
Reviewed-on: #2
2026-05-10 06:42:11 -04:00
Loic Coenen
5739ff8019 feat: remove hard limit on number of channels
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 10:38:59 +00:00
Loic Coenen
3a4aac3356 Documentation 2026-05-10 01:12:07 +00:00
Loic Coenen
69859a6294 docs: add command architecture documentation
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 01:11:47 +00:00
Loic Coenen
d47fddbeb3 docs: add command architecture documentation 2026-05-10 01:11:46 +00:00
Loic Coenen
900619a714 12-command-art 2026-05-10 01:08:11 +00:00
Loic Coenen
98c851f051 test: add MIDI stop and full record-loop-stop integration tests
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 00:37:21 +00:00
Loic Coenen
011d29cb09 docs: update evaluation.md with final code review
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 00:21:57 +00:00
Loic Coenen
be3188bbe2 fix: keep FIFO fd open across both writes to prevent hang
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-10 00:16:03 +00:00
Loic Coenen
c592c24634 feat: add MIDI stop command and FIFO pipe integration test
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-09 23:56:09 +00:00
Loic Coenen
7b61384154 docs: update evaluation.md with current code analysis
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-09 23:55:07 +00:00
Loic Coenen
7edd95d06e fix: split main command queue into per-source SPSC queues
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-09 23:32:21 +00:00
Loic Coenen
de0389e144 feat: remove MIDI-driven add/remove channel commands to fix SPSC race
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-09 23:12:53 +00:00
Loic Coenen
bd5fd59b7b fix: add missing source files to build
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-09 22:51:13 +00:00
Loic Coenen
b1e330e839 refactor: remove stale cmd_add/cmd_remove declarations from channel.h
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-09 22:20:35 +00:00
Loic Coenen
437ac31913 feat: unify add/remove commands into queue and fix race on channel removal
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-09 22:03:11 +00:00
Loic Coenen
a8a9c6164b docs: update evaluation.md with detailed code review and recommendations
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-09 21:35:38 +00:00
Loic Coenen
392dabbc0f feat: add command queue and FIFO pipe for unified input handling
Co-authored-by: aider (deepseek/deepseek-reasoner) <aider@aider.chat>
2026-05-09 21:31:54 +00:00
Loic Coenen
f7f18f9fa7 style: fix formatting and include order in source files 2026-05-09 21:31:52 +00:00
Loic Coenen
72839a9e5f Merge remote-tracking branch 'origin/multichannel' 2026-05-09 19:55:36 +00:00
Loic Coenen
d6336970bf Merge branch 'multichannel' 2026-05-09 19:54:08 +00:00
Loic Coenen
8c061f93cd delete main.c 2026-05-09 19:52:10 +00:00
36 changed files with 3800 additions and 83 deletions

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.

90
docs/2-midi-looping.md Normal file
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`.

View File

@@ -0,0 +1,45 @@
# Sampling and Recording (WAV Load/Save)
The looper supports loading a WAV file into channel 0 and saving the current loop of channel 0 as a WAV file. Both operations use the **libsndfile** library, ensuring correct handling of RIFF headers, chunk sizes, and sample format conversion.
## Load Command
- **MIDI note 70** with the control key (note 64) triggers loading.
- The file `loop.wav` (located in the working directory) is read by `wav_read()` in `src/wav.c`.
- The function calls `sf_open(path, SFM_READ, &info)`.
- It accepts only mono PCM WAV files. If the file is not mono or has an invalid sample rate, it returns `-1`.
- The number of frames read is capped at `LOOP_BUF_SIZE` (5 seconds at 48 kHz).
- The data is stored in `channels[0].loop_buffer` and `channels[0].loop_count` is set atomically.
- The state of channel 0 is set to `STATE_LOOPING` and `prev_state` is set to `-1` to trigger the loop start in the next audio cycle.
## Save Command
- **MIDI note 71** with the control key (note 64) triggers saving.
- The looper must currently be in `STATE_LOOPING` and have a nonzero `loop_count`.
- A ring buffer (`RingBuf`) is allocated with capacity `2 × loop_count` samples.
- The pointer to the ring buffer is published via `atomic_store_explicit` on `channels[0].save_ring`.
- In each audio callback cycle, if the channel is looping and a save ring exists, the audio output data is written into the ring buffer.
- A dedicated **writer thread** (`writer_thread`) is launched (detached) to consume the ring buffer.
- The writer thread reads `loop_count` samples from the ring buffer, sleeping 10ms between empty reads.
- Once all samples are collected, it writes them to `save.wav` using `sf_writef_float()`.
- After writing, the ring buffer is destroyed and freed, and the save ring pointer is set to `NULL`.
## Dependencies
- **libsndfile** must be installed (development headers). Add `-lsndfile` to your linker flags (already present in the provided `makefile`).
## Implementation Files
- `src/wav.c` contains `wav_read()` and `wav_write()` based on libsndfile.
- `src/looper.c` contains the load/save command handling in `looper_process_commands()` and the writer thread function.
- `src/channel.h` defines `save_ring` as `_Atomic RingBuf *`.
## Testing
- The integration test `test_wav_load` creates a short 440Hz WAV file, loads it via MIDI, and checks for ≥3 bursts of audio output.
- The integration test `test_wav_save` records a beep, loops it, issues the save command, and verifies the resulting WAV file has nonzero data size.
## Notes
- The save operation is asynchronous: the writer thread runs in the background while the audio callback continues to fill the ring buffer. The test waits 2s for the file to be written before checking.
- The load operation is synchronous: the callback sleeps 1s after the MIDI command to give the main loop time to process it.

View File

@@ -1,24 +0,0 @@
# Code Evaluation
## Summary Table
| Category | Rating | Remarks |
|--------------------------|-------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Mocked / Left Undone | ✅ OK | Multichannel and dynamic channel add/remove are now implemented. Control key (note64) is handled as a modifier for command selection. Backward compatibility for note1,60,61 retained. |
| Potential Segfaults | ✅ Fixed | Added null checks for both `audio_in` and `audio_out` in the process callback, and `channel_add` no longer marks the channel active if port registration fails. |
| Memory Safety | ✅ OK | No dynamic memory allocation; only a fixedsize global buffer. No leaks, no useafterfree. |
| Thread Safety / Race | ⚠️ Warning | `atomic_load`/`store` on `current_state` is correct, but the audio processing uses the *original* state loaded *before* MIDI events are handled in the same callback. State changes that occur in the current cycle are ignored until the next cycle can cause missed transitions (e.g., start recording one cycle late). |
| Performance | ✅ OK | Linear buffer access, no system calls or allocations in the realtime callback. Atomic operations are cheap. Fixed buffer size (0.96 MB) is safe. |
| Architectural Soundness | ✅ OK | Dynamic multichannel architecture with perchannel state and ports. Realtime safe command queue via atomic flags. Abstraction via `channel_t` struct. Extensible for future binding. |
## Test Evaluation
| Aspect | Remarks |
|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `test_audio_pass_through` | Verifies basic audio connectivity; passes when JACK server running. Does not test any looperspecific behavior beyond passthrough. |
| `test_looper_looping` | Exercises the state machine (IDLE→RECORD→LOOPING) using MIDI note 1. Detects repeated audio bursts. Works with current implementation but uses note 1 instead of the required control key (64). The 0.1second beep and 4second wait may be sensitive to CPU load. |
| `test_multiple_channels` | Expects dynamic channel creation via note 60 (add channel). Current looper does not handle this command, causing immediate failure. This test is effectively a placeholder for future implementation. |
| Coverage gaps | No tests for: control key note 64, remove channel, binding, perchannel loops, state transitions other than note 1, robust handling of JACK server disconnection. |
| Thread safety | The test assumes sequential execution and uses long sleeps for synchronization. The realtime thread is managed by JACK; the test process runs asynchronously, which can lead to timingsensitive failures on heavily loaded systems. |
| Resource handling | Tests properly kill child process and close JACK clients. No memory leaks. |
| Overall verdict | The test suite provides a minimal smokecheck but does **not** validate the full specification. It must be updated to use the correct control key (64), cover dynamic channel commands (add/remove/bind), and handle nonexistent features before it can be considered a trustworthy integration test. |

View File

@@ -1,8 +1,8 @@
CC ?= gcc CC ?= gcc
CFLAGS ?= -Wall -Wextra -g -Isrc CFLAGS ?= -Wall -Wextra -g -Isrc
LDFLAGS ?= -ljack -lm LDFLAGS ?= -ljack -lm -lpthread -lsndfile
SRC = src/main.c src/looper.c src/channel.c src/midi.c SRC = src/main.c src/looper.c src/channel.c src/midi.c src/ringbuffer.c src/wav.c
OBJ = $(SRC:.c=.o) OBJ = $(SRC:.c=.o)
looper: $(OBJ) looper: $(OBJ)
@@ -12,7 +12,7 @@ src/%.o: src/%.c
$(CC) $(CFLAGS) -c -o $@ $< $(CC) $(CFLAGS) -c -o $@ $<
integration: looper tests/integration.c integration: looper tests/integration.c
$(CC) $(CFLAGS) -o integration_test tests/integration.c -ljack -lm $(CC) $(CFLAGS) -o integration_test tests/integration.c -ljack -lm -lpthread
./integration_test ./integration_test
test: integration test: integration

View File

@@ -28,6 +28,7 @@ void channel_add(jack_client_t *client, int idx) {
channels[idx].loop_count = 0; channels[idx].loop_count = 0;
channels[idx].record_pos = 0; channels[idx].record_pos = 0;
channels[idx].playback_pos = 0; channels[idx].playback_pos = 0;
channels[idx].save_ring = NULL;
next_channel_id++; next_channel_id++;
channel_count++; channel_count++;

View File

@@ -8,6 +8,8 @@
#define LOOP_BUF_SIZE (5 * 48000) #define LOOP_BUF_SIZE (5 * 48000)
#define MAX_CHANNELS 16 #define MAX_CHANNELS 16
#include "ringbuffer.h"
typedef enum { typedef enum {
STATE_IDLE, STATE_IDLE,
STATE_RECORD, STATE_RECORD,
@@ -17,14 +19,16 @@ typedef enum {
struct channel_t { struct channel_t {
atomic_int state; atomic_int state;
int prev_state; atomic_int prev_state;
float loop_buffer[LOOP_BUF_SIZE]; float loop_buffer[LOOP_BUF_SIZE];
int loop_count; atomic_int loop_count;
int record_pos; atomic_int record_pos;
int playback_pos; atomic_int playback_pos;
atomic_int active; atomic_int active;
jack_port_t *audio_in; jack_port_t *audio_in;
jack_port_t *audio_out; jack_port_t *audio_out;
_Atomic RingBuf *save_ring;
}; };
/* Globals declared in looper.c */ /* Globals declared in looper.c */
@@ -33,6 +37,8 @@ extern atomic_int channel_count;
extern int next_channel_id; extern int next_channel_id;
extern atomic_int cmd_add; extern atomic_int cmd_add;
extern atomic_int cmd_remove; 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_add(jack_client_t *client, int idx);
void channel_remove(jack_client_t *client, int idx); void channel_remove(jack_client_t *client, int idx);

BIN
src/channel.o Normal file
View File

Binary file not shown.

24
src/command.h Normal file
View File

@@ -0,0 +1,24 @@
#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_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

View File

@@ -2,13 +2,16 @@
#include "looper.h" #include "looper.h"
#include "channel.h" #include "channel.h"
#include "midi.h" #include "midi.h"
#include "wav.h"
#include <jack/jack.h> #include <jack/jack.h>
#include <jack/midiport.h> #include <jack/midiport.h>
#include <math.h> #include <math.h>
#include <pthread.h>
#include <stdatomic.h> #include <stdatomic.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <time.h>
/* Global state (shared across files) */ /* Global state (shared across files) */
struct channel_t channels[MAX_CHANNELS]; struct channel_t channels[MAX_CHANNELS];
@@ -16,6 +19,8 @@ atomic_int channel_count = 0;
int next_channel_id = 1; int next_channel_id = 1;
atomic_int cmd_add = 0; atomic_int cmd_add = 0;
atomic_int cmd_remove = 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_control_port = NULL;
jack_port_t *midi_clock_port = NULL; jack_port_t *midi_clock_port = NULL;
atomic_int control_key_active = 0; atomic_int control_key_active = 0;
@@ -24,6 +29,10 @@ atomic_int bind_channel = 0;
/* Deferred removal index (1 second grace) */ /* Deferred removal index (1 second grace) */
static int pending_unregister_idx = -1; 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;
/* ---------------------------------------------------------------- /* ----------------------------------------------------------------
* process callback * process callback
* ---------------------------------------------------------------- */ * ---------------------------------------------------------------- */
@@ -59,16 +68,18 @@ int process_callback(jack_nframes_t nframes, void *arg) {
int state = atomic_load(&channels[c].state); int state = atomic_load(&channels[c].state);
if (state != channels[c].prev_state) { if (state != atomic_load(&channels[c].prev_state)) {
switch (state) { switch (state) {
case STATE_RECORD: case STATE_RECORD:
channels[c].record_pos = 0; atomic_store(&channels[c].record_pos, 0);
channels[c].loop_count = 0; atomic_store(&channels[c].loop_count, 0);
break; break;
case STATE_LOOPING: case STATE_LOOPING:
if (channels[c].record_pos > 0) if (atomic_load(&channels[c].prev_state) == STATE_RECORD &&
channels[c].loop_count = channels[c].record_pos; atomic_load(&channels[c].record_pos) > 0)
channels[c].playback_pos = 0; atomic_store(&channels[c].loop_count,
atomic_load(&channels[c].record_pos));
atomic_store(&channels[c].playback_pos, 0);
break; break;
default: default:
break; break;
@@ -82,9 +93,9 @@ int process_callback(jack_nframes_t nframes, void *arg) {
float *f_out = (float *)out; float *f_out = (float *)out;
const float *f_in = (const float *)in; const float *f_in = (const float *)in;
for (i = 0; i < nframes; i++) { for (i = 0; i < nframes; i++) {
if (channels[c].record_pos < LOOP_BUF_SIZE) int rp = atomic_fetch_add(&channels[c].record_pos, 1);
channels[c].loop_buffer[channels[c].record_pos++] = if (rp < LOOP_BUF_SIZE)
f_in[i]; channels[c].loop_buffer[rp] = f_in[i];
f_out[i] = f_in[i]; f_out[i] = f_in[i];
} }
} else { } else {
@@ -93,12 +104,13 @@ int process_callback(jack_nframes_t nframes, void *arg) {
break; break;
case STATE_LOOPING: case STATE_LOOPING:
if (channels[c].loop_count > 0) { int lc = atomic_load(&channels[c].loop_count);
if (lc > 0) {
float *outf = (float *)out; float *outf = (float *)out;
for (i = 0; i < nframes; i++) { for (i = 0; i < nframes; i++) {
outf[i] = channels[c].loop_buffer[channels[c].playback_pos]; int pp = atomic_load(&channels[c].playback_pos);
channels[c].playback_pos = outf[i] = channels[c].loop_buffer[pp];
(channels[c].playback_pos + 1) % channels[c].loop_count; atomic_store(&channels[c].playback_pos, (pp + 1) % lc);
} }
} else { } else {
memset(out, 0, sizeof(jack_default_audio_sample_t) * nframes); memset(out, 0, sizeof(jack_default_audio_sample_t) * nframes);
@@ -118,7 +130,17 @@ int process_callback(jack_nframes_t nframes, void *arg) {
break; break;
} }
channels[c].prev_state = state; // 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 */ /* MIDI clock events affect channel 0 only */
@@ -172,13 +194,17 @@ void jack_shutdown_cb(void *arg) {
* looper initialisation * looper initialisation
* ---------------------------------------------------------------- */ * ---------------------------------------------------------------- */
int looper_init(jack_client_t *client) { int looper_init(jack_client_t *client) {
/* store sample rate for writer thread */
global_sample_rate = jack_get_sample_rate(client);
/* channel 0 */ /* channel 0 */
channels[0].active = 1; channels[0].active = 1;
atomic_store(&channels[0].state, STATE_IDLE); atomic_store(&channels[0].state, STATE_IDLE);
channels[0].prev_state = -1; atomic_store(&channels[0].prev_state, -1);
channels[0].loop_count = 0; channels[0].loop_count = 0;
channels[0].record_pos = 0; atomic_store(&channels[0].record_pos, 0);
channels[0].playback_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( channels[0].audio_in = jack_port_register(
client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0); client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
@@ -202,6 +228,47 @@ int looper_init(jack_client_t *client) {
return 0; 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 * mainloop command processing
* ---------------------------------------------------------------- */ * ---------------------------------------------------------------- */
@@ -239,4 +306,47 @@ void looper_process_commands(jack_client_t *client) {
pending_unregister_idx = 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);
}
}
}
}
} }

BIN
src/looper.o Normal file
View File

Binary file not shown.

BIN
src/main.o Normal file
View File

Binary file not shown.

View File

@@ -8,6 +8,8 @@
extern atomic_int control_key_active; extern atomic_int control_key_active;
extern atomic_int cmd_add; extern atomic_int cmd_add;
extern atomic_int cmd_remove; extern atomic_int cmd_remove;
extern atomic_int cmd_load;
extern atomic_int cmd_save;
extern atomic_int bind_channel; extern atomic_int bind_channel;
void midi_handle_events(void *port_buffer, jack_nframes_t nframes) { void midi_handle_events(void *port_buffer, jack_nframes_t nframes) {
@@ -67,6 +69,12 @@ void midi_handle_events(void *port_buffer, jack_nframes_t nframes) {
case 63: /* unbind reset bind to channel 0 */ case 63: /* unbind reset bind to channel 0 */
atomic_store(&bind_channel, 0); atomic_store(&bind_channel, 0);
break; 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: default:
break; break;
} }

BIN
src/midi.o Normal file
View File

Binary file not shown.

99
src/pipe.c Normal file
View File

@@ -0,0 +1,99 @@
#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_main_fifo, cmd);
} else if (strcmp(line, "add_midi") == 0) {
command_t cmd = {.type = CMD_ADD_MIDI_CHANNEL, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "remove") == 0) {
command_t cmd = {.type = CMD_REMOVE_CHANNEL, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, 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_main_fifo, cmd);
} else if (strcmp(line, "scene_remove") == 0) {
command_t cmd = {.type = CMD_REMOVE_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "scene_next") == 0) {
command_t cmd = {.type = CMD_NEXT_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, cmd);
} else if (strcmp(line, "scene_prev") == 0) {
command_t cmd = {.type = CMD_PREV_SCENE, .channel = -1, .data = 0};
queue_push(&cmd_queue_main_fifo, 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
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

BIN
src/pipe.o Normal file
View File

Binary file not shown.

31
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
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

BIN
src/queue.o Normal file
View File

Binary file not shown.

76
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
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
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
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

View File

@@ -56,6 +56,34 @@ static int midi_inject_process(jack_nframes_t nframes, void *arg) {
return 0; return 0;
} }
/* Helper: initialise the persistent MIDI client (open and connect) */
static int midi_inject_init(const char *target_port) {
if (midi_inject_client) return 0; /* already initialised */
jack_status_t st;
midi_inject_client = jack_client_open("midi_inject_persistent", JackNoStartServer, &st);
if (!midi_inject_client) return -1;
midi_inject_port = jack_port_register(midi_inject_client, "out",
JACK_DEFAULT_MIDI_TYPE,
JackPortIsOutput, 0);
if (!midi_inject_port) return -1;
jack_set_process_callback(midi_inject_client, midi_inject_process, NULL);
if (jack_activate(midi_inject_client) != 0) return -1;
char src[64];
snprintf(src, sizeof(src), "midi_inject_persistent:out");
if (jack_connect(midi_inject_client, src, target_port) != 0) return -1;
return 0;
}
/* Helper: close the persistent MIDI client */
static void midi_inject_close(void) {
if (midi_inject_client) {
jack_deactivate(midi_inject_client);
jack_client_close(midi_inject_client);
midi_inject_client = NULL;
midi_inject_port = NULL;
}
}
/* The test code uses this callback in two ways: /* The test code uses this callback in two ways:
- For the audio passthrough test (existing function) it still works. - For the audio passthrough test (existing function) it still works.
- For the loop test we need a version that respects the static variables - For the loop test we need a version that respects the static variables
@@ -143,6 +171,8 @@ static int test_audio_pass_through(void) {
printf("Test: audio passthrough (connectivity)\n"); printf("Test: audio passthrough (connectivity)\n");
pid_t pid = start_looper(); pid_t pid = start_looper();
if (pid < 0) return 1; if (pid < 0) return 1;
/* ensure fresh MIDI connection for this test */
midi_inject_close();
jack_client_t *client; jack_client_t *client;
jack_status_t status; jack_status_t status;
client = jack_client_open("test_passthrough", JackNoStartServer, &status); client = jack_client_open("test_passthrough", JackNoStartServer, &status);
@@ -225,50 +255,35 @@ static int test_audio_pass_through(void) {
/* Helper: open a transient JACK client, send a MIDI noteon, close */ /* Helper: open a transient JACK client, send a MIDI noteon, close */
static jack_client_t *midi_persistent_client = NULL;
static jack_port_t *midi_persistent_port = NULL;
static int send_jack_note_on(const char *target_port, unsigned char note, unsigned char velocity) { static int send_jack_note_on(const char *target_port, unsigned char note, unsigned char velocity) {
/* initialise client on first call (pertest) */
if (midi_inject_init(target_port) != 0) return -1;
midi_inject_note = note; midi_inject_note = note;
midi_inject_velocity = velocity; midi_inject_velocity = velocity;
midi_inject_pending = 1;
jack_status_t st; /* wait for delivery (process callback clears the flag) */
midi_inject_client = jack_client_open("test_midi_inject", JackNoStartServer, &st); for (int attempts = 0; attempts < 100; attempts++) {
if (!midi_inject_client) return -1;
midi_inject_port = jack_port_register(midi_inject_client, "out",
JACK_DEFAULT_MIDI_TYPE,
JackPortIsOutput, 0);
if (!midi_inject_port) {
jack_client_close(midi_inject_client);
midi_inject_client = NULL;
return -1;
}
char src[64];
snprintf(src, sizeof(src), "test_midi_inject:out");
if (jack_connect(midi_inject_client, src, target_port) != 0) {
jack_client_close(midi_inject_client);
midi_inject_client = NULL;
midi_inject_port = NULL;
return -1;
}
midi_inject_pending = 1; /* signal before activation */
jack_set_process_callback(midi_inject_client, midi_inject_process, NULL);
if (jack_activate(midi_inject_client) != 0) {
jack_client_close(midi_inject_client);
midi_inject_client = NULL;
midi_inject_port = NULL;
return -1;
}
/* wait for the process callback to clear the flag (event delivered) */
for (int attempts = 0; attempts < 50; attempts++) { /* ~50 * 10ms = 500ms */
safe_usleep(10000); safe_usleep(10000);
if (!midi_inject_pending) break; if (!midi_inject_pending) break;
} }
jack_deactivate(midi_inject_client);
jack_client_close(midi_inject_client);
midi_inject_client = NULL;
midi_inject_port = NULL;
return 0; return 0;
} }
/* must be called after all tests */
static void close_persistent_midi(void) {
if (midi_persistent_client) {
jack_deactivate(midi_persistent_client);
jack_client_close(midi_persistent_client);
midi_persistent_client = NULL;
midi_persistent_port = NULL;
}
}
/* /*
* Full loop recording test: * Full loop recording test:
* 1. start looper * 1. start looper
@@ -284,6 +299,9 @@ static int test_looper_looping(void) {
pid_t pid = start_looper(); pid_t pid = start_looper();
if (pid < 0) return 1; if (pid < 0) return 1;
/* ensure fresh MIDI connection for this test */
midi_inject_close();
jack_client_t *client; jack_client_t *client;
jack_status_t status; jack_status_t status;
client = jack_client_open("test_looping", JackNoStartServer, &status); client = jack_client_open("test_looping", JackNoStartServer, &status);
@@ -381,6 +399,9 @@ static int test_multiple_channels(void) {
pid_t pid = start_looper(); pid_t pid = start_looper();
if (pid < 0) return 1; if (pid < 0) return 1;
/* ensure fresh MIDI connection for this test */
midi_inject_close();
jack_client_t *client; jack_client_t *client;
jack_status_t status; jack_status_t status;
client = jack_client_open("test_multi", JackNoStartServer, &status); client = jack_client_open("test_multi", JackNoStartServer, &status);
@@ -428,6 +449,8 @@ static int test_control_key_modifier(void) {
printf("Test: controlkey modifier triggers state transition via note 62\n"); printf("Test: controlkey modifier triggers state transition via note 62\n");
pid_t pid = start_looper(); pid_t pid = start_looper();
if (pid < 0) return 1; if (pid < 0) return 1;
/* ensure fresh MIDI connection for this test */
midi_inject_close();
jack_client_t *client; jack_client_t *client;
jack_status_t status; jack_status_t status;
client = jack_client_open("test_ctrl_key", JackNoStartServer, &status); client = jack_client_open("test_ctrl_key", JackNoStartServer, &status);
@@ -528,6 +551,8 @@ static int test_bind_channel(void) {
printf("Test: controlkey bind channel (note 0) and toggle\n"); printf("Test: controlkey bind channel (note 0) and toggle\n");
pid_t pid = start_looper(); pid_t pid = start_looper();
if (pid < 0) return 1; if (pid < 0) return 1;
/* ensure fresh MIDI connection for this test */
midi_inject_close();
jack_client_t *client; jack_client_t *client;
jack_status_t status; jack_status_t status;
client = jack_client_open("test_bind", JackNoStartServer, &status); client = jack_client_open("test_bind", JackNoStartServer, &status);
@@ -641,6 +666,8 @@ static int test_bind_unbind(void) {
printf("Test: bind to channel 5, unbind, then toggle default (channel 0)\n"); printf("Test: bind to channel 5, unbind, then toggle default (channel 0)\n");
pid_t pid = start_looper(); pid_t pid = start_looper();
if (pid < 0) return 1; if (pid < 0) return 1;
/* ensure fresh MIDI connection for this test */
midi_inject_close();
jack_client_t *client; jack_client_t *client;
jack_status_t status; jack_status_t status;
client = jack_client_open("test_unbind", JackNoStartServer, &status); client = jack_client_open("test_unbind", JackNoStartServer, &status);
@@ -769,6 +796,8 @@ static int test_remove_channel(void) {
printf("Test: dynamic channel removal via MIDI command\n"); printf("Test: dynamic channel removal via MIDI command\n");
pid_t pid = start_looper(); pid_t pid = start_looper();
if (pid < 0) return 1; if (pid < 0) return 1;
/* ensure fresh MIDI connection for this test */
midi_inject_close();
jack_client_t *client; jack_client_t *client;
jack_status_t status; jack_status_t status;
client = jack_client_open("test_remove", JackNoStartServer, &status); client = jack_client_open("test_remove", JackNoStartServer, &status);
@@ -811,7 +840,7 @@ static int test_remove_channel(void) {
fprintf(stderr, " FAIL: send note 61 failed\n"); fprintf(stderr, " FAIL: send note 61 failed\n");
return 1; return 1;
} }
safe_usleep(1500000); safe_usleep(3000000);
/* verify channel1_input has disappeared */ /* verify channel1_input has disappeared */
ports = jack_get_ports(client, NULL, JACK_DEFAULT_AUDIO_TYPE, 0); ports = jack_get_ports(client, NULL, JACK_DEFAULT_AUDIO_TYPE, 0);
int still_found = 0; int still_found = 0;
@@ -835,6 +864,255 @@ static int test_remove_channel(void) {
return 0; return 0;
} }
/* ------------------------------------------------------------
* Helper: generate a simple 440 Hz WAV file for load tests
* ------------------------------------------------------------ */
static int generate_test_wav(const char *path, unsigned sample_rate, unsigned duration_frames) {
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return -1;
unsigned data_bytes = duration_frames * 2;
unsigned file_size = 44 + data_bytes;
unsigned char header[44];
memset(header, 0, 44);
memcpy(header, "RIFF", 4);
unsigned chunk_size = file_size - 8;
header[4] = chunk_size & 0xff; header[5] = (chunk_size>>8)&0xff;
header[6] = (chunk_size>>16)&0xff; header[7] = (chunk_size>>24)&0xff;
memcpy(header+8, "WAVE", 4);
memcpy(header+12, "fmt ", 4);
header[16]=16; header[17]=0; header[18]=0; header[19]=0;
header[20]=1; header[21]=0; /* PCM */
header[22]=1; header[23]=0; /* mono */
header[24]= sample_rate & 0xff; header[25]=(sample_rate>>8)&0xff;
header[26]=(sample_rate>>16)&0xff; header[27]=(sample_rate>>24)&0xff;
unsigned br = sample_rate * 2;
header[28]= br & 0xff; header[29]=(br>>8)&0xff;
header[30]=(br>>16)&0xff; header[31]=(br>>24)&0xff;
header[32]=2; header[33]=0;
header[34]=16; header[35]=0;
memcpy(header+36, "data", 4);
header[40]= data_bytes & 0xff; header[41]=(data_bytes>>8)&0xff;
header[42]=(data_bytes>>16)&0xff; header[43]=(data_bytes>>24)&0xff;
if (write(fd, header, 44) != 44) { close(fd); return -1; }
for (unsigned i = 0; i < duration_frames; i++) {
float sample = sinf(2.0f * (float)M_PI * 440.0f * i / sample_rate);
int16_t s = (int16_t)(sample * 32767);
if (write(fd, &s, 2) != 2) { close(fd); return -1; }
}
close(fd);
return 0;
}
/* ------------------------------------------------------------
* Test: load WAV file (note 70 under control key)
* ------------------------------------------------------------ */
static int test_wav_load(void) {
printf("Test: load WAV file into channel 0 and detect playback\n");
if (generate_test_wav("loop.wav", 48000, 48000) != 0) {
fprintf(stderr, " FAIL: could not create test WAV\n");
return 1;
}
pid_t pid = start_looper();
if (pid < 0) { unlink("loop.wav"); return 1; }
/* ensure fresh MIDI connection for this test */
midi_inject_close();
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_wav_load", JackNoStartServer, &status);
if (!client) {
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
unlink("loop.wav");
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);
unlink("loop.wav");
return 1;
}
safe_usleep(200000);
if (jack_connect(client, "test_wav_load:out", "looper:input") ||
jack_connect(client, "looper:output", "test_wav_load:in")) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
unlink("loop.wav");
return 1;
}
/* set up passthrough callback before sending load command */
int sr = jack_get_sample_rate(client);
continuous_sine = 0;
beep_remaining = 0;
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_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
unlink("loop.wav");
return 1;
}
/* send control key + note 70 to trigger load */
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
unlink("loop.wav"); return 1;
}
safe_usleep(1000000); /* 1 second to ensure control key is processed */
if (send_jack_note_on("looper:control", 70, 127) != 0) {
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
unlink("loop.wav"); return 1;
}
/* wait for the loop to be fully loaded and playing */
safe_usleep(3000000);
/* continue listening for the rest of the time */
safe_usleep(6000000); /* total 9 seconds after activation */
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
unlink("loop.wav");
int got_bursts = bursts;
double rms = passthrough_total_samples > 0 ?
sqrt(passthrough_sum_sq / passthrough_total_samples) : 0.0;
printf(" detected bursts: %d, RMS: %.6f\n", got_bursts, rms);
if (got_bursts < 3) {
fprintf(stderr, " FAIL: expected ≥3 bursts from loaded loop, got %d, RMS=%.6f\n", got_bursts, rms);
return 1;
}
printf(" PASS (loaded loop plays)\n");
return 0;
}
/* ------------------------------------------------------------
* Test: save WAV file (note 71 under control key)
* ------------------------------------------------------------ */
static int test_wav_save(void) {
printf("Test: save WAV file from loop\n");
pid_t pid = start_looper();
if (pid < 0) return 1;
/* ensure fresh MIDI connection for this test */
midi_inject_close();
jack_client_t *client;
jack_status_t status;
client = jack_client_open("test_wav_save", 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);
if (jack_connect(client, "test_wav_save:out", "looper:input") ||
jack_connect(client, "looper:output", "test_wav_save:in")) {
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
/* record a beep: send note 1 (toggle channel 0) */
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);
/* start generating a beep */
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_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(800000);
/* toggle again to stop recording and start looping */
if (send_jack_note_on("looper:control", 1, 127) != 0) {
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(500000);
/* send control key + note 71 to save */
if (send_jack_note_on("looper:control", 64, 127) != 0) {
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(200000);
if (send_jack_note_on("looper:control", 71, 127) != 0) {
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
return 1;
}
safe_usleep(2000000);
/* check save.wav exists and has data */
int fd = open("save.wav", O_RDONLY);
if (fd < 0) {
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: save.wav not created\n");
return 1;
}
unsigned char hdr[44];
if (read(fd, hdr, 44) != 44) {
close(fd); unlink("save.wav");
jack_deactivate(client); jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: short header\n");
return 1;
}
unsigned data_bytes = hdr[40] | (hdr[41]<<8) | (hdr[42]<<16) | (hdr[43]<<24);
close(fd);
if (data_bytes == 0) {
unlink("save.wav");
jack_deactivate(client); jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
fprintf(stderr, " FAIL: empty save.wav\n");
return 1;
}
printf(" save.wav data size: %u bytes\n", data_bytes);
unlink("save.wav");
jack_deactivate(client);
jack_client_close(client);
kill(pid, SIGTERM); waitpid(pid, NULL, 0);
printf(" PASS (save.wav created)\n");
return 0;
}
int main(void) { int main(void) {
/* 1. binary must exist */ /* 1. binary must exist */
@@ -886,6 +1164,20 @@ int main(void) {
failures++; failures++;
} }
/* 10. Test WAV load */
if (test_wav_load() != 0) {
fprintf(stderr, " FAILED\n");
failures++;
}
/* 11. Test WAV save */
if (test_wav_save() != 0) {
fprintf(stderr, " FAILED\n");
failures++;
}
close_persistent_midi();
if (failures > 0) { if (failures > 0) {
fprintf(stderr, "%d test(s) FAILED\n", failures); fprintf(stderr, "%d test(s) FAILED\n", failures);
return 1; return 1;

32
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;
}

89
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
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
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
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
tests/test_save.c Normal file
View File

0
tests/test_wav.c Normal file
View File

1574
tests/unit_tests_tui.c Normal file
View File

File diff suppressed because it is too large Load Diff