From 577d80f01825212759759f59af84f87eb4e822d9 Mon Sep 17 00:00:00 2001 From: hanez Date: Mon, 26 Oct 2026 23:32:04 +0100 Subject: [PATCH] Phase 2. --- Makefile | 47 +++- README.md | 12 +- docs/ARCHITECTURE.md | 9 +- docs/AUDIO_ENGINE.md | 58 ++++- docs/BUILD.md | 49 +++- docs/ROADMAP.md | 9 +- docs/decisions/0004-jack-and-plan-handoff.md | 84 +++++++ include/xtc/xtc_backend.h | 63 +++++ include/xtc/xtc_realtime.h | 38 +++ src/backend/README.md | 11 +- src/backend/jack_backend.c | 249 +++++++++++++++++++ src/engine/realtime.c | 106 ++++++++ tests/integration/README.md | 33 ++- tests/integration/test_jack.c | 119 +++++++++ tests/unit/test_backend.c | 172 +++++++++++++ tests/unit/test_realtime.c | 80 ++++++ 16 files changed, 1099 insertions(+), 40 deletions(-) create mode 100644 docs/decisions/0004-jack-and-plan-handoff.md create mode 100644 include/xtc/xtc_backend.h create mode 100644 include/xtc/xtc_realtime.h create mode 100644 src/backend/jack_backend.c create mode 100644 src/engine/realtime.c create mode 100644 tests/integration/test_jack.c create mode 100644 tests/unit/test_backend.c create mode 100644 tests/unit/test_realtime.c diff --git a/Makefile b/Makefile index 31dfb71..d378b4f 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +.DEFAULT_GOAL := all + CC ?= cc CXX ?= c++ PKG_CONFIG ?= pkg-config @@ -11,7 +13,15 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/core/xtc.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c +CORE_SOURCES := src/core/xtc.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c +JACK_AVAILABLE := $(shell $(PKG_CONFIG) --exists jack 2>/dev/null && echo yes) +ifeq ($(JACK_AVAILABLE),yes) +JACK_CFLAGS := $(shell $(PKG_CONFIG) --cflags jack) +JACK_LIBS := $(shell $(PKG_CONFIG) --libs jack) +BACKEND_TEST := $(BUILD_DIR)/test_backend +$(BUILD_DIR)/tests/unit/test_backend.o: CPPFLAGS += $(JACK_CFLAGS) +$(BUILD_DIR)/src/backend/jack_backend.o: CPPFLAGS += -DXTC_HAVE_JACK $(JACK_CFLAGS) +endif CORE_OBJECTS := $(CORE_SOURCES:%.c=$(BUILD_DIR)/%.o) TEST_OBJECT := $(BUILD_DIR)/tests/unit/test_core.o ifeq ($(shell $(PKG_CONFIG) --exists fltk 2>/dev/null && echo yes),yes) @@ -24,7 +34,7 @@ FLTK_LIBS := $(shell $(FLTK_CONFIG) --ldflags) GUI_AVAILABLE := yes endif -.PHONY: all clean test debug gui install +.PHONY: all clean test test-jack debug gui install all: $(BUILD_DIR)/libxtc.a $(BUILD_DIR)/xtc-check $(BUILD_DIR)/xtc-check: $(BUILD_DIR)/src/main.o $(BUILD_DIR)/libxtc.a @@ -36,15 +46,15 @@ $(BUILD_DIR)/libxtc.a: $(CORE_OBJECTS) $(BUILD_DIR)/%.o: %.c @mkdir -p $(@D) - $(CC) $(CFLAGS) -MMD -MP -c $< -o $@ + $(CC) $(CPPFLAGS) $(CFLAGS) -MMD -MP -c $< -o $@ $(BUILD_DIR)/%.o: %.cpp @mkdir -p $(@D) - $(CXX) $(CXXFLAGS) $(FLTK_CXXFLAGS) -MMD -MP -c $< -o $@ + $(CXX) $(CPPFLAGS) $(CXXFLAGS) $(FLTK_CXXFLAGS) -MMD -MP -c $< -o $@ $(TEST_OBJECT): tests/unit/test_core.c @mkdir -p $(@D) - $(CC) $(CFLAGS) -MMD -MP -c $< -o $@ + $(CC) $(CPPFLAGS) $(CFLAGS) -MMD -MP -c $< -o $@ $(BUILD_DIR)/test_core: $(TEST_OBJECT) $(BUILD_DIR)/libxtc.a $(CC) $(LDFLAGS) -o $@ $(TEST_OBJECT) $(BUILD_DIR)/libxtc.a -lm @@ -52,7 +62,15 @@ $(BUILD_DIR)/test_core: $(TEST_OBJECT) $(BUILD_DIR)/libxtc.a $(BUILD_DIR)/test_offline: $(BUILD_DIR)/tests/unit/test_offline.o $(BUILD_DIR)/libxtc.a $(CC) $(LDFLAGS) -o $@ $^ -lm -test: $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check +$(BUILD_DIR)/test_realtime: $(BUILD_DIR)/tests/unit/test_realtime.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -pthread + +$(BUILD_DIR)/test_backend: $(BUILD_DIR)/tests/unit/test_backend.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm + +test: $(BACKEND_TEST) $(BUILD_DIR)/test_realtime $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check + $(if $(BACKEND_TEST),./$(BACKEND_TEST),@echo "JACK fault tests skipped: JACK development files unavailable") + ./$(BUILD_DIR)/test_realtime ./$(BUILD_DIR)/test_core ./$(BUILD_DIR)/test_offline ./$(BUILD_DIR)/xtc-check @@ -60,6 +78,19 @@ test: $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check debug: $(MAKE) BUILD_DIR="$(BUILD_DIR)/debug" CFLAGS="$(CFLAGS) -O0 -g3" CXXFLAGS="$(CXXFLAGS) -O0 -g3" all test +ifeq ($(JACK_AVAILABLE),yes) +$(BUILD_DIR)/tests/integration/test_jack.o: CPPFLAGS += $(JACK_CFLAGS) +$(BUILD_DIR)/test_jack: $(BUILD_DIR)/tests/integration/test_jack.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ $(JACK_LIBS) -lm + +test-jack: $(BUILD_DIR)/test_jack + ./$(BUILD_DIR)/test_jack +else +test-jack: + @echo "JACK development files are required for the live integration test." + @exit 1 +endif + ifeq ($(GUI_AVAILABLE),yes) gui: $(BUILD_DIR)/xtc @@ -79,5 +110,5 @@ install: all clean: rm -rf "$(BUILD_DIR)" --include $(CORE_OBJECTS:.o=.d) $(TEST_OBJECT:.o=.d) $(BUILD_DIR)/src/main.d $(BUILD_DIR)/tests/unit/test_offline.d \ - $(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d +-include $(CORE_OBJECTS:.o=.d) $(TEST_OBJECT:.o=.d) $(BUILD_DIR)/src/main.d $(BUILD_DIR)/tests/unit/test_offline.d $(BUILD_DIR)/tests/unit/test_realtime.d $(BUILD_DIR)/tests/unit/test_backend.d \ + $(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/tests/integration/test_jack.d diff --git a/README.md b/README.md index 275a968..453a505 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,14 @@ This repository is a starter scaffold, not a production DAW. It currently provid - a mono graph model with port validation, cycle rejection and topological ordering; - compiled offline plans with preallocated buffers, sine oscillator, gain, meter and output nodes; - C tests for deterministic rendering, routing, validation and lifecycle; +- an optional JACK-compatible mono backend with start/stop, routing and status; +- safe control/audio plan handoff and concurrent replacement tests; - an optional FLTK GUI shell; - architecture, format, GUI, roadmap, and decision documents. -Real audio-device integration, recording, MIDI, plugin hosting, project persistence, and the actual sequencer UI are not implemented yet. +The JACK backend has passed software-loopback testing; physical-device playback +is unverified. Recording, MIDI, plugin hosting, project persistence, and the +actual sequencer UI are not implemented yet. ## Build @@ -47,7 +51,11 @@ Run the GUI: ./build/xtc ``` -The current GUI is only a shell and does not yet produce sound. The core's offline graph test is the first executable audio-processing milestone. +The current GUI is only a shell and is not connected to the backend yet. +With JACK development files and an existing JACK-compatible server, run the +isolated software-loopback test with `make test-jack`. It connects only test +clients. See [engine documentation](docs/AUDIO_ENGINE.md) for configuration, +routing, ownership and recovery. Clean generated files: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 808e7fc..61aaebe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,7 +29,9 @@ buffers and oscillator/meter state. Each plan has one processing owner. Graph edits and destruction cannot affect an existing plan. All nodes are scheduled, including disconnected nodes; only explicit output nodes are rendered (or terminal nodes when no output node exists). See AUDIO_ENGINE.md for contracts. -Plan publication across threads and retirement remain Phase 2 work. +`xtc_realtime` now provides a one-pending/one-retired handoff around an +exclusively audio-owned active plan. `xtc_backend` is the C backend boundary; +the optional JACK implementation drives that same realtime engine. ## Ownership and threads @@ -38,5 +40,6 @@ Plan publication across threads and retirement remain Phase 2 work. - Audio thread owns the active processing plan and DSP state. - Backend callbacks follow the backend's documented threading contract. -Do not free an active plan until the audio thread has released it. Pick and -document a bounded handoff/reclamation strategy before real-time graph swaps. +Only acknowledged retired plans are reclaimed during processing. Backend close +synchronizes callbacks before reclaiming remaining plans. See decision 0004 +for the release/acquire protocol, bounded resource retention and recovery. diff --git a/docs/AUDIO_ENGINE.md b/docs/AUDIO_ENGINE.md index 826c6b1..0db5df8 100644 --- a/docs/AUDIO_ENGINE.md +++ b/docs/AUDIO_ENGINE.md @@ -77,5 +77,59 @@ Tests in `tests/unit/test_offline.c` cover known oscillator samples, gain and meter output, reset and partition equivalence, fan-out/fan-in, reverse insertion order, disconnected nodes, in-place processing, nonfinite input, invalid ports, cycles, capacity limits, plan lifetime and repeated compilation/cleanup. -Hardware playback, concurrent graph publication and backend stress tests remain -Phase 2 work; this offline API does not claim hardware real-time qualification. +The offline API remains single-owner. The realtime/backend APIs below add +publication and JACK processing; offline tests alone do not qualify hardware. + +## Phase 2 backend and realtime bridge + +Use `xtc_backend.h` on one serialized control thread: + +1. Open a config with `XTC_BACKEND_JACK`, a client name, channel_count 1, and + max_frames 1..65536. It must accommodate the server's current buffer size. + The actual rate and buffer size are returned through get_status. Open does + not start the server or acquire devices directly. Invalid config returns + invalid argument, missing build support returns unsupported, and JACK setup + failures return I/O error. If cleanup itself fails, a non-NULL output handle + is preserved for close retry; no other operation is allowed on that handle. +2. Submit a graph (copied/compiled at the server rate), then start. Starting + an already running client is an invalid-state error; stopping a stopped + client succeeds. With no submitted graph, callbacks produce silence. +3. Enumerate available audio ports with visit_ports. Names are borrowed only + during the visitor; do not reenter backend operations from the visitor. + Connect the client's input to a remote output or its output to a remote + input explicitly. JACK maps device selection to server/device port routes; + ALSA device identifiers are not accepted here. The server controls rate, + period and hardware configuration outside this API. Names/ports can disappear + between enumeration and connection; route failures are returned as I/O errors. +4. During playback, submit edited graphs and periodically collect retired + resources. Capacity means one pending graph is still awaiting adoption; + keep the current graph and retry later. get_status is a nonblocking snapshot + of rate, frames, running/server-loss/rate-change flags, xruns, processed + blocks and last process result. No active DSP memory is exposed. +5. Stop pauses the retained DSP state after deactivation. Restart resumes it. + Close removes the JACK client, synchronizes all callbacks, then destroys + plans. Stop/close failures preserve the handle for recovery/retry. No plan + is freed on the audio thread. Sample-rate change or server loss requires + close/reopen and a fresh submission; there is no automatic reconnect. + +Buffers can change size. In-limit changes process normally; oversized blocks +are silent and report capacity errors until the size is supported again. +Rate changes latch silence and invalid-state processing results. A missing input +buffer is treated as unavailable input (silence). A missing output buffer is +reported as an I/O process error. Shutdown callbacks only set the loss flag; +the control thread closes the client. No JACK or GUI types cross the C API. + +The standalone `xtc_realtime.h` API allows hardware-independent consumers and +stress tests. Submit compiles on the producer; process adopts at a block +boundary without allocation/free, I/O, locks or waits. All process calls must +come from one consumer thread, and destruction requires that consumer stopped +and joined. Release/acquire publication, retirement backpressure and atomic +requirements are specified in decision 0004. Ordinary project processing and +JACK notification callbacks were audited for prohibited operations; JACK's own +internal realtime guarantees depend on the installed implementation/server. + +Validation commands and results are recorded in BUILD.md and the integration +test README. Current limitations: mono input/output, no parameter ramping or +crossfaded graph changes, no realtime meter bridge, no automatic reconnect, +no physical-device or long-duration endurance qualification. GUI controls and +status display are Phase 3; the GUI remains a placeholder shell. diff --git a/docs/BUILD.md b/docs/BUILD.md index ac2ecf9..608ac47 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -15,7 +15,8 @@ make gui ``` `all` builds the static C library and startup check. `test` runs the existing -core and offline graph tests plus the startup check. `debug` builds and tests separately in +core/offline tests, concurrent plan-handoff stress, JACK fault injection +(when JACK headers are available), and the startup check. `debug` builds and tests separately in `build/debug`. `gui` builds the existing placeholder shell; it does not produce audio. Generated files remain under `build` and header changes trigger rebuilds. Compiler, flags, `BUILD_DIR`, `PKG_CONFIG`, `FLTK_CONFIG`, and installation @@ -29,7 +30,7 @@ Compiler, flags, `BUILD_DIR`, `PKG_CONFIG`, `FLTK_CONFIG`, and installation | GNU Make | 4.4.1 | Build and test driver | | pkg-config | 3.0.10 | Development package discovery | | FLTK | 1.4.5 | Optional GUI; discovered through `fltk-config` | -| JACK | 3.1609.0 (pkg-config) | Candidate backend; not linked yet | +| JACK | 3.1609.0 (pkg-config) | Optional JACK backend (PipeWire compatibility library) | | ALSA | 1.2.16.1 (pkg-config) | Future MIDI candidate; not linked yet | | Clang | 23.1.3 | Alternative compiler / diagnostics | @@ -39,18 +40,44 @@ the Makefile tries pkg-config first, then `fltk-config`. ## Dependencies and licensing -No new dependencies are introduced. The core needs the C runtime and math -library. The repository is MIT licensed. The optional GUI uses maintained +The offline core needs the C runtime and math library. Phase 2 adds optional +JACK support, selected with pkg-config; missing JACK does not prevent core/tests +from building. Realtime publication uses GCC/Clang lock-free atomic builtins in +C99. Tests also use POSIX threads. Backend consumers must link JACK explicitly; +backend objects are pulled from the static library only when used. The repository is MIT licensed. The optional GUI uses maintained FLTK 1.4 (LGPL with FLTK's static-linking exception); its platform link flags come from the installed package. Distribution still requires review of all -linked dependencies and notices. JACK and ALSA remain candidates only; backend -selection and license review belong to Phase 2. No packages or system settings +linked dependencies and notices. The installed PipeWire JACK library is MIT; +traditional JACK client libraries use LGPL licensing. See decision 0004 for +the dependency choice and caveats. ALSA remains a future MIDI candidate. No packages or system settings are changed by these build commands. +## Phase 2 verification + +```sh +make all test gui debug +make test-jack +# Extended live run after building the integration binary: +./build/test_jack 30 +# Verify optional build without JACK metadata, in a separate directory: +make BUILD_DIR=build/nojack PKG_CONFIG=false all test +# Address/undefined sanitizers (non-realtime tests only): +make BUILD_DIR=build/sanitize CC=clang CFLAGS='-O1 -g -std=c99 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -Iinclude -fsanitize=address,undefined -fno-omit-frame-pointer' LDFLAGS='-fsanitize=address,undefined' all test +# Thread sanitizer for publication/reclamation: +make BUILD_DIR=build/tsan CC=cc CFLAGS='-O1 -g -std=c99 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -Iinclude -fsanitize=thread' LDFLAGS='-fsanitize=thread' build/tsan/test_realtime +./build/tsan/test_realtime +``` + +Normal, sanitizer and no-JACK tests pass. ThreadSanitizer reports no race in +2,000 concurrent submissions. The 30-second live loopback test passed at 48 kHz +with zero xruns and ten restart cycles; see tests/integration/README.md. Live +server termination/format changes were not performed on the shared server; +fault-injection tests cover those notification paths. No packages installed +or system/device configuration changed. Sanitizers are not enabled in the live +callback qualification run. + ## Next milestone -Phase 1 implements compiled offline plans, preallocated buffers, oscillator, -gain, meter/output nodes and routing tests; see AUDIO_ENGINE.md. Phase 2 adds -a hardware backend and safe cross-thread plan publication/retirement. Public -project persistence declarations remain unimplemented. Hardware playback and -interactive GUI behavior are not verified by the offline tests. +Phase 3 connects the FLTK shell to the backend C API for engine controls, +status/error reporting and clean shutdown. Physical playback and endurance, +project persistence and sequencing remain unverified/unimplemented as applicable. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 12d94f6..31148f1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,12 +15,19 @@ preallocated buffers, test oscillator, gain and offline render tests. Implemented: mono ports, transactional cycle rejection, independent compiled plans, precomputed input routing, oscillator/gain/meter/output, deterministic variable-block tests and lifecycle/error coverage. See AUDIO_ENGINE.md for API -limits and compatibility behavior. Cross-thread publication remains Phase 2. +limits and compatibility behavior. ## Phase 2 — Real-time backend Choose and implement a Linux backend. Document callback semantics, start/stop, device errors, shutdown, and safe graph publication. +Implemented: optional JACK-compatible mono backend, explicit audio port routing, +format/xrun/loss status, start/stop/reopen, and bounded pending/retired plan +handoff using lock-free atomics. Hardware-independent failure/concurrency tests +and a 30-second software-loopback run pass. See decision 0004 and AUDIO_ENGINE.md. +Physical hardware playback/endurance and live server-loss recovery remain +unverified; no GUI engine integration until Phase 3. + ## Phase 3 — FLTK shell Main window, transport controls, view switching, backend status, clean startup and shutdown through the C API. diff --git a/docs/decisions/0004-jack-and-plan-handoff.md b/docs/decisions/0004-jack-and-plan-handoff.md new file mode 100644 index 0000000..ec92f6f --- /dev/null +++ b/docs/decisions/0004-jack-and-plan-handoff.md @@ -0,0 +1,84 @@ +# Decision 0004: JACK backend and bounded plan handoff + +**Status:** Accepted for Phase 2 + +## Backend + +Use the JACK C client API. The inspected system provides PipeWire's maintained +JACK compatibility library (`pkg-config jack` reports 3.1609.0), headers, a live +server, and mono audio ports. This is JACK client integration, not native +PipeWire or ALSA PCM. JACK owns device access, timing, sample rate and block +size. The application registers one mono input/output pair and exposes port +inspection and explicit routing. It neither starts the server nor changes its +configuration or automatically connects hardware. + +JACK support is optional at build time. Without JACK development metadata, +backend open returns unsupported; offline and handoff tests still work. +Consumers that call backend APIs link `pkg-config --libs jack` as well as `-lm`. +No backend headers appear in public headers. The kind/configuration/status API +is the boundary for adding another backend; there is no speculative plugin +framework or second driver implementation. + +The optional dependency provides actual audio callbacks and port routing. +PipeWire's installed JACK library is MIT licensed (`pipewire-jack/COPYING`). +Traditional JACK client libraries use LGPL licensing; review the actual linked +implementation, transitive libraries and notices when distributing. Nothing +is bundled or installed here. ALSA is still a future MIDI candidate. + +## C99 and atomic operations + +Retain C99 and use GCC/Clang `__atomic` builtins. This is a documented compiler +extension, avoiding a language-standard change and keeping the existing core +C99-compatible on the selected compilers. Initialization requires pointer and +unsigned-int atomics to be always lock-free, or returns unsupported before +callbacks are installed. Naturally aligned fields reside in calloc-allocated +objects. There is no mutex or libatomic fallback in the callback path. + +## Ownership and publication + +One serialized control producer and one audio consumer share two pointer slots: + +- `pending`: producer publishes a fully compiled plan with release; consumer + exchanges it for NULL with acquire at the next eligible block boundary. +- `retired`: consumer releases its previous active plan into this slot only + after its final access. Producer exchanges with NULL using acquire/release, + then destroys that acknowledged plan on the control thread. + +The active pointer and all active DSP state belong exclusively to the consumer. +Producer never reads them. Caller graph memory is copied before publication; +callers never receive plan pointers through the realtime/backend API. + +A full pending slot returns capacity without replacing anything. A full retired +slot defers adoption while the current plan continues processing. The consumer +performs at most one adoption per block and never loops waiting for the producer. +Producer collection can overlap processing because the retired plan is no +longer reachable from the consumer. Collection clears the slot before freeing; +consumer may then retire another, distinct plan. Publication never immediately +frees the previously active plan. + +Submitting collects any already retired plan, so a producer repeatedly submitting +also reclaims memory. If it becomes idle, one pending, one active, and one retired +plan bound retained resources (at most three plans, each with up to 64 MiB of +sample buffers). Periodic collect is recommended. Shutdown closes the JACK client +and synchronizes its callbacks before destroying any active/pending/retired plan. +Standalone realtime-engine callers must stop and join their consumer themselves. + +## Status, errors and recovery + +Unsigned-int status fields use lock-free atomics. Values are individual snapshots, +not one transactional state. Counters wrap naturally; no string formatting, +logging, allocation, GUI operation or blocking synchronization occurs in project +callbacks. JACK's shutdown notification only stores a flag and leaves cleanup to +the control thread. Other notifications store the current format and xrun count. + +Buffer changes within the configured maximum require no allocation. Larger +blocks are fully silenced and reported as capacity errors; smaller blocks can +resume processing. Sample-rate changes latch a flag and silence processing +rather than replaying an oscillator compiled for a different rate. Close/reopen +and resubmit after a rate change or server loss. There is no automatic reconnect. +JACK-deactivate pauses the retained plan; restart continues its oscillator phase. +Plan replacement starts the newly compiled oscillator at phase zero, without +crossfade. Smooth parameter ramps and click-free graph transitions are future work. + +No stereo, recording, MIDI, native PipeWire API, GUI engine controls or hardware +configuration dialogs are part of this phase. diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h new file mode 100644 index 0000000..b90b225 --- /dev/null +++ b/include/xtc/xtc_backend.h @@ -0,0 +1,63 @@ +#ifndef XTC_BACKEND_H +#define XTC_BACKEND_H +#include "xtc_engine.h" +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct xtc_backend xtc_backend; +typedef enum { XTC_BACKEND_JACK = 1 } xtc_backend_kind; +typedef struct { + xtc_backend_kind kind; + const char *client_name; /* Borrowed for open only; nonempty. */ + size_t max_frames; /* 1..65536, at least the server's current block. */ + size_t channel_count; /* Currently must be 1. */ +} xtc_backend_config; +typedef struct { + unsigned int sample_rate; + unsigned int buffer_frames; + unsigned int xruns; /* Wraps modulo UINT_MAX + 1. */ + unsigned int processed_blocks; + int running; + int server_lost; + int sample_rate_changed; /* Sticky: close/reopen and resubmit to recover. */ + xtc_result process_result; +} xtc_backend_status; + +/* All operations are serialized on one control thread. Public API contains + * no JACK types. JACK is optional at build time (otherwise UNSUPPORTED). + * Opens without starting a server, changing devices, or connecting ports. + * Returns IO if server/client setup fails. On failure *out_backend is NULL, + * except if cleanup itself fails: a non-NULL handle must be closed/retried; + * only close is permitted on that partially initialized handle. */ +xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_backend); +/* Stop/close establish callback quiescence before state can be destroyed. + * On failure the handle remains owned by caller; retry close. NULL close OK. */ +xtc_result xtc_backend_close(xtc_backend *backend); +xtc_result xtc_backend_start(xtc_backend *backend); +xtc_result xtc_backend_stop(xtc_backend *backend); +/* Submit allowed stopped or running. Copies graph; capacity = pending slot busy. */ +xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph); +void xtc_backend_collect(xtc_backend *backend); +/* Snapshot fields are individually coherent, not a transactional snapshot. */ +xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status *out_status); +/* Borrowed names valid until close. Direction is the client's own port. */ +xtc_result xtc_backend_port_name(const xtc_backend *backend, xtc_port_direction direction, + const char **out_name); +/* Enumerates available mono audio ports (devices and software routes). Visitor + * receives a borrowed name valid only during call; return nonzero to stop. + * Control-thread only. JACK NULL list means no matching ports. */ +typedef int (*xtc_backend_port_visitor)(const char *name, void *context); +xtc_result xtc_backend_visit_ports(xtc_backend *backend, xtc_port_direction direction, + xtc_backend_port_visitor visitor, void *context); +/* Connect/disconnect this client's port with a remote opposite-direction port. + * own_direction INPUT receives remote output; OUTPUT sends to remote input. + * Explicit user choice only; no automatic physical routing. */ +xtc_result xtc_backend_connect(xtc_backend *backend, xtc_port_direction own_direction, + const char *remote_name); +xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction own_direction, + const char *remote_name); +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/xtc/xtc_realtime.h b/include/xtc/xtc_realtime.h new file mode 100644 index 0000000..6f032cc --- /dev/null +++ b/include/xtc/xtc_realtime.h @@ -0,0 +1,38 @@ +#ifndef XTC_REALTIME_H +#define XTC_REALTIME_H +#include "xtc_engine.h" +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct xtc_realtime xtc_realtime; +typedef struct { + unsigned int processed_blocks; /* Wraps modulo UINT_MAX + 1. */ + xtc_result last_result; +} xtc_realtime_status; + +/* Control thread only. A single control producer and single audio consumer. + * C99 with GCC/Clang lock-free __atomic builtins is required; unsupported + * targets return XTC_ERR_UNSUPPORTED. No ownership is shared with callers. */ +xtc_result xtc_realtime_create(double sample_rate, size_t max_frames, + xtc_realtime **out_engine); +/* Requires the audio consumer to have stopped and joined. */ +void xtc_realtime_destroy(xtc_realtime *engine); +/* Compiles/copies a graph. One pending plan allowed; full mailbox returns + * XTC_ERR_CAPACITY without changing the active or pending plan. */ +xtc_result xtc_realtime_submit(xtc_realtime *engine, const xtc_graph *graph); +/* Frees acknowledged retired state, never the audio thread's active plan. */ +void xtc_realtime_collect(xtc_realtime *engine); +xtc_result xtc_realtime_get_status(const xtc_realtime *engine, + xtc_realtime_status *out_status); +/* Audio consumer only. Adopts at most one plan per block, without allocation, + * freeing, locks or I/O. No plan means silence. On failure writes silence. + * frame_count must be <= configured max_frames and output must hold it. + * Oversized buffers are silenced too; callers must bound their own frame count. + * NULL input means silence. Zero frames is a no-op. */ +xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, + float *output, size_t frame_count); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/backend/README.md b/src/backend/README.md index 4a27622..6f398cf 100644 --- a/src/backend/README.md +++ b/src/backend/README.md @@ -1,8 +1,9 @@ # Audio backend -No hardware backend is implemented yet. Keep backend-specific headers out of -public headers. The first backend should be selected after checking the target -Linux environment and its development packages. +`jack_backend.c` implements the public `xtc_backend.h` API using JACK when +available and returns unsupported otherwise. JACK headers stay private. +It registers one mono input and output, supports start/stop and explicit port +routing, and reports server/format/process/xrun status without callback logging. -A backend implementation must document callback threading, buffer sizes, -sample-rate negotiation, device loss, and shutdown semantics. +See `docs/decisions/0004-jack-and-plan-handoff.md` for ownership and licensing, +and `docs/AUDIO_ENGINE.md` for API usage, recovery and test limitations. diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c new file mode 100644 index 0000000..994000e --- /dev/null +++ b/src/backend/jack_backend.c @@ -0,0 +1,249 @@ +#include "xtc/xtc_backend.h" +#include "xtc/xtc_realtime.h" +#include +#include + +#ifdef XTC_HAVE_JACK +#include +#include + +struct xtc_backend { + jack_client_t *client; + jack_port_t *input, *output; + xtc_realtime *engine; + size_t max_frames; + unsigned int expected_rate; + unsigned int rate, frames, lost, rate_changed, xruns; + unsigned int process_result; + int ready, running; /* Control thread only. */ +}; + +static int process(jack_nframes_t frames, void *context) +{ + xtc_backend *backend = context; + float *output = jack_port_get_buffer(backend->output, frames); + xtc_result result = XTC_OK; + if (output == NULL) { + __atomic_store_n(&backend->process_result, (unsigned int)XTC_ERR_IO, __ATOMIC_RELAXED); + return 0; + } + if (__atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE) || + __atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE)) { + result = XTC_ERR_INVALID_STATE; + } else if ((size_t)frames > backend->max_frames) { + result = XTC_ERR_CAPACITY; + } else { + const float *input = jack_port_get_buffer(backend->input, frames); + result = xtc_realtime_process(backend->engine, input, output, (size_t)frames); + } + if (result != XTC_OK) memset(output, 0, (size_t)frames * sizeof(*output)); + __atomic_store_n(&backend->process_result, (unsigned int)result, __ATOMIC_RELAXED); + return 0; /* Keep connected and silent on recoverable processing errors. */ +} +static void shutdown_callback(void *context) +{ + xtc_backend *backend = context; + __atomic_store_n(&backend->lost, 1U, __ATOMIC_RELEASE); +} +static int rate_callback(jack_nframes_t rate, void *context) +{ + xtc_backend *backend = context; + __atomic_store_n(&backend->rate, (unsigned int)rate, __ATOMIC_RELAXED); + if ((unsigned int)rate != backend->expected_rate) + __atomic_store_n(&backend->rate_changed, 1U, __ATOMIC_RELEASE); + return 0; +} +static int buffer_callback(jack_nframes_t frames, void *context) +{ + xtc_backend *backend = context; + __atomic_store_n(&backend->frames, (unsigned int)frames, __ATOMIC_RELAXED); + return 0; +} +static int xrun_callback(void *context) +{ + xtc_backend *backend = context; + (void)__atomic_fetch_add(&backend->xruns, 1U, __ATOMIC_RELAXED); + return 0; +} + +xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_backend) +{ + xtc_backend *backend; + xtc_result result; + jack_status_t status = 0; + if (out_backend == NULL) return XTC_ERR_INVALID_ARGUMENT; + *out_backend = NULL; + if (config == NULL || config->client_name == NULL || config->client_name[0] == '\0' || + config->max_frames == 0U || config->max_frames > 65536U || config->channel_count != 1U) + return XTC_ERR_INVALID_ARGUMENT; + if (config->kind != XTC_BACKEND_JACK) return XTC_ERR_UNSUPPORTED; + if (strlen(config->client_name) >= (size_t)jack_client_name_size()) return XTC_ERR_INVALID_ARGUMENT; + backend = calloc(1U, sizeof(*backend)); + if (backend == NULL) return XTC_ERR_OUT_OF_MEMORY; + backend->max_frames = config->max_frames; + backend->client = jack_client_open(config->client_name, JackNoStartServer, &status); + if (backend->client == NULL) { free(backend); return XTC_ERR_IO; } + backend->expected_rate = (unsigned int)jack_get_sample_rate(backend->client); + backend->rate = backend->expected_rate; + backend->frames = (unsigned int)jack_get_buffer_size(backend->client); + result = backend->frames == 0U || backend->frames > config->max_frames ? XTC_ERR_CAPACITY : + xtc_realtime_create((double)backend->rate, config->max_frames, &backend->engine); + if (result != XTC_OK) goto fail; + backend->input = jack_port_register(backend->client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0U); + backend->output = jack_port_register(backend->client, "output", JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0U); + if (backend->input == NULL || backend->output == NULL) { result = XTC_ERR_IO; goto fail; } + if (jack_set_process_callback(backend->client, process, backend) != 0 || + jack_set_sample_rate_callback(backend->client, rate_callback, backend) != 0 || + jack_set_buffer_size_callback(backend->client, buffer_callback, backend) != 0 || + jack_set_xrun_callback(backend->client, xrun_callback, backend) != 0) { + result = XTC_ERR_IO; + goto fail; + } + jack_on_shutdown(backend->client, shutdown_callback, backend); + backend->ready = 1; + *out_backend = backend; + return XTC_OK; +fail: + /* Client never activated: no processing callback can reference the engine. + * close synchronizes any registered notification callbacks. */ + if (jack_client_close(backend->client) != 0) { + *out_backend = backend; /* Preserve callback context for close retry. */ + return XTC_ERR_IO; + } + xtc_realtime_destroy(backend->engine); + free(backend); + return result; +} + +xtc_result xtc_backend_close(xtc_backend *backend) +{ + if (backend == NULL) return XTC_OK; + /* JACK removes this client and joins callbacks before returning success. */ + if (jack_client_close(backend->client) != 0) return XTC_ERR_IO; + xtc_realtime_destroy(backend->engine); + free(backend); + return XTC_OK; +} +xtc_result xtc_backend_start(xtc_backend *backend) +{ + if (backend == NULL) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready) return XTC_ERR_INVALID_STATE; + if (__atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE) || + __atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE)) return XTC_ERR_INVALID_STATE; + if (backend->running) return XTC_ERR_INVALID_STATE; + if (jack_activate(backend->client) != 0) return XTC_ERR_IO; + backend->running = 1; + return XTC_OK; +} +xtc_result xtc_backend_stop(xtc_backend *backend) +{ + if (backend == NULL) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready) return XTC_ERR_INVALID_STATE; + if (!backend->running) return XTC_OK; + if (jack_deactivate(backend->client) != 0) return XTC_ERR_IO; + backend->running = 0; + xtc_realtime_collect(backend->engine); + return XTC_OK; +} +xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) +{ + if (backend == NULL) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready) return XTC_ERR_INVALID_STATE; + if (__atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE) || + __atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE)) return XTC_ERR_INVALID_STATE; + return xtc_realtime_submit(backend->engine, graph); +} +void xtc_backend_collect(xtc_backend *backend) +{ if (backend != NULL) xtc_realtime_collect(backend->engine); } +xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status *out_status) +{ + xtc_realtime_status engine_status; + if (backend == NULL || out_status == NULL) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready) return XTC_ERR_INVALID_STATE; + (void)xtc_realtime_get_status(backend->engine, &engine_status); + out_status->sample_rate = __atomic_load_n(&backend->rate, __ATOMIC_RELAXED); + out_status->buffer_frames = __atomic_load_n(&backend->frames, __ATOMIC_RELAXED); + out_status->xruns = __atomic_load_n(&backend->xruns, __ATOMIC_RELAXED); + out_status->server_lost = (int)__atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE); + out_status->sample_rate_changed = (int)__atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE); + out_status->running = backend->running && !out_status->server_lost; + out_status->processed_blocks = engine_status.processed_blocks; + out_status->process_result = (xtc_result)__atomic_load_n(&backend->process_result, __ATOMIC_RELAXED); + return XTC_OK; +} +xtc_result xtc_backend_port_name(const xtc_backend *backend, xtc_port_direction direction, + const char **out_name) +{ + if (backend == NULL || out_name == NULL || + (direction != XTC_PORT_INPUT && direction != XTC_PORT_OUTPUT)) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready || __atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE)) return XTC_ERR_INVALID_STATE; + *out_name = jack_port_name(direction == XTC_PORT_INPUT ? backend->input : backend->output); + return *out_name == NULL ? XTC_ERR_IO : XTC_OK; +} +xtc_result xtc_backend_visit_ports(xtc_backend *backend, xtc_port_direction direction, + xtc_backend_port_visitor visitor, void *context) +{ + const char **ports; + size_t i; + if (backend == NULL || visitor == NULL || + (direction != XTC_PORT_INPUT && direction != XTC_PORT_OUTPUT)) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready || __atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE)) return XTC_ERR_INVALID_STATE; + ports = jack_get_ports(backend->client, NULL, JACK_DEFAULT_AUDIO_TYPE, + direction == XTC_PORT_INPUT ? JackPortIsInput : JackPortIsOutput); + if (ports == NULL) return XTC_OK; + for (i = 0U; ports[i] != NULL; ++i) if (visitor(ports[i], context) != 0) break; + jack_free(ports); + return XTC_OK; +} +static xtc_result route(xtc_backend *backend, xtc_port_direction direction, + const char *remote, int disconnect) +{ + const char *own = NULL; + const char *source, *dest; + xtc_result result; + int code; + if (remote == NULL || remote[0] == '\0') return XTC_ERR_INVALID_ARGUMENT; + result = xtc_backend_port_name(backend, direction, &own); + if (result != XTC_OK) return result; + if (!backend->running) return XTC_ERR_INVALID_STATE; + source = direction == XTC_PORT_OUTPUT ? own : remote; + dest = direction == XTC_PORT_OUTPUT ? remote : own; + code = disconnect ? jack_disconnect(backend->client, source, dest) : + jack_connect(backend->client, source, dest); + return code == 0 || (!disconnect && code == EEXIST) ? XTC_OK : XTC_ERR_IO; +} +xtc_result xtc_backend_connect(xtc_backend *backend, xtc_port_direction direction, const char *remote) +{ return route(backend, direction, remote, 0); } +xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction direction, const char *remote) +{ return route(backend, direction, remote, 1); } + +#else +/* Keep core/offline builds available on machines without JACK headers. */ +xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_backend) +{ + if (out_backend == NULL) return XTC_ERR_INVALID_ARGUMENT; + *out_backend = NULL; + if (config == NULL) return XTC_ERR_INVALID_ARGUMENT; + return XTC_ERR_UNSUPPORTED; +} +xtc_result xtc_backend_close(xtc_backend *backend) +{ return backend == NULL ? XTC_OK : XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_start(xtc_backend *backend) +{ (void)backend; return XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_stop(xtc_backend *backend) +{ (void)backend; return XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) +{ (void)backend; (void)graph; return XTC_ERR_UNSUPPORTED; } +void xtc_backend_collect(xtc_backend *backend) { (void)backend; } +xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status *out_status) +{ (void)backend; (void)out_status; return XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_port_name(const xtc_backend *backend, xtc_port_direction direction, const char **out_name) +{ (void)backend; (void)direction; (void)out_name; return XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_visit_ports(xtc_backend *backend, xtc_port_direction direction, + xtc_backend_port_visitor visitor, void *context) +{ (void)backend; (void)direction; (void)visitor; (void)context; return XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_connect(xtc_backend *backend, xtc_port_direction direction, const char *remote) +{ (void)backend; (void)direction; (void)remote; return XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction direction, const char *remote) +{ (void)backend; (void)direction; (void)remote; return XTC_ERR_UNSUPPORTED; } +#endif diff --git a/src/engine/realtime.c b/src/engine/realtime.c new file mode 100644 index 0000000..711df70 --- /dev/null +++ b/src/engine/realtime.c @@ -0,0 +1,106 @@ +#include "xtc/xtc_realtime.h" +#include +#include +#include + +struct xtc_realtime { + double sample_rate; + size_t max_frames; + xtc_plan *pending; + xtc_plan *retired; + xtc_plan *active; /* Audio owner only, until consumer is stopped/joined. */ + unsigned int blocks; + unsigned int result; +}; + +xtc_result xtc_realtime_create(double sample_rate, size_t max_frames, + xtc_realtime **out_engine) +{ + xtc_realtime *engine; + if (out_engine == NULL) return XTC_ERR_INVALID_ARGUMENT; + *out_engine = NULL; + if (!isfinite(sample_rate) || sample_rate <= 0.0 || max_frames == 0U || + max_frames > 65536U) return XTC_ERR_INVALID_ARGUMENT; + if (!__atomic_always_lock_free(sizeof(xtc_plan *), 0) || + !__atomic_always_lock_free(sizeof(unsigned int), 0)) return XTC_ERR_UNSUPPORTED; + engine = calloc(1U, sizeof(*engine)); + if (engine == NULL) return XTC_ERR_OUT_OF_MEMORY; + engine->sample_rate = sample_rate; + engine->max_frames = max_frames; + *out_engine = engine; + return XTC_OK; +} + +void xtc_realtime_collect(xtc_realtime *engine) +{ + xtc_plan *retired; + if (engine == NULL) return; + retired = __atomic_exchange_n(&engine->retired, NULL, __ATOMIC_ACQ_REL); + xtc_plan_destroy(retired); +} + +void xtc_realtime_destroy(xtc_realtime *engine) +{ + if (engine == NULL) return; + /* Lifecycle contract: no callback can still execute. */ + xtc_plan_destroy(engine->active); + xtc_plan_destroy(engine->pending); + xtc_plan_destroy(engine->retired); + free(engine); +} + +xtc_result xtc_realtime_submit(xtc_realtime *engine, const xtc_graph *graph) +{ + xtc_plan *plan = NULL; + xtc_result result; + if (engine == NULL || graph == NULL) return XTC_ERR_INVALID_ARGUMENT; + xtc_realtime_collect(engine); + if (__atomic_load_n(&engine->pending, __ATOMIC_ACQUIRE) != NULL) + return XTC_ERR_CAPACITY; + result = xtc_graph_compile(graph, engine->sample_rate, engine->max_frames, &plan); + if (result != XTC_OK) return result; + __atomic_store_n(&engine->pending, plan, __ATOMIC_RELEASE); + return XTC_OK; +} + +xtc_result xtc_realtime_get_status(const xtc_realtime *engine, + xtc_realtime_status *out_status) +{ + if (engine == NULL || out_status == NULL) return XTC_ERR_INVALID_ARGUMENT; + out_status->processed_blocks = __atomic_load_n(&engine->blocks, __ATOMIC_RELAXED); + out_status->last_result = (xtc_result)__atomic_load_n(&engine->result, __ATOMIC_RELAXED); + return XTC_OK; +} + +xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, + float *output, size_t frame_count) +{ + xtc_result result = XTC_OK; + if (engine == NULL || output == NULL || frame_count > SIZE_MAX / sizeof(*output)) + return XTC_ERR_INVALID_ARGUMENT; + if (frame_count == 0U) return XTC_OK; + if (frame_count > engine->max_frames) { + result = XTC_ERR_CAPACITY; + } else { + /* A full retirement slot delays adoption without delaying processing. + * acquire pairs with control's release exchange after its last read. */ + if (__atomic_load_n(&engine->retired, __ATOMIC_ACQUIRE) == NULL) { + xtc_plan *next = __atomic_exchange_n(&engine->pending, NULL, __ATOMIC_ACQUIRE); + if (next != NULL) { + xtc_plan *old = engine->active; + engine->active = next; + /* No reference to old is used after this release. */ + __atomic_store_n(&engine->retired, old, __ATOMIC_RELEASE); + } + } + if (engine->active != NULL) + result = xtc_plan_process(engine->active, input, output, frame_count); + else + memset(output, 0, frame_count * sizeof(*output)); + } + if (result != XTC_OK) memset(output, 0, frame_count * sizeof(*output)); + __atomic_store_n(&engine->result, (unsigned int)result, __ATOMIC_RELAXED); + __atomic_store_n(&engine->blocks, + __atomic_load_n(&engine->blocks, __ATOMIC_RELAXED) + 1U, __ATOMIC_RELAXED); + return result; +} diff --git a/tests/integration/README.md b/tests/integration/README.md index d19d0c3..d6751e4 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,11 +1,28 @@ # Integration tests -Planned integration coverage: -- deterministic offline render; -- repeated engine initialization and shutdown; -- graph replacement while processing; -- project save/load round trip; -- backend disconnect/reconnect; -- MIDI panic and transport stop. +`make test` is hardware/server independent: offline render tests, concurrent +plan-replacement stress, and (when JACK headers are present) JACK ABI fault +injection. The fake links against installed declarations without linking +libjack. It does not claim to reproduce JACK scheduling; the separate +threaded stress test exercises publication/reclamation. -Do not mark these tests as passing until they exist and run. +`make test-jack` explicitly runs a five-second live JACK loopback test. Requires +an existing JACK or PipeWire JACK-compatible server; does not start a server, +change its sample rate/block size, or connect physical outputs. Source output +is connected only to the test's capture client, then disconnected and removed. +It checks finite/bounded captured samples at two gains, active plan replacement, +status and ten restart cycles. For a longer run: + +```sh +make build/test_jack +./build/test_jack 30 +``` + +On the inspected system the 30-second test passed at 48000 Hz (initial 1024 +frames): 704 engine blocks, 716 probe blocks, both gains observed, zero xruns, +and ten restart cycles. This is a limited software-loopback result, not an +endurance or physical-device qualification. No live server termination or global +format change is performed on the shared server. Those paths, missing server, +port/callback setup failure, activation/deactivation/close failure, null buffers, +invalid configuration, oversize buffers, and reopen are tested with injected +JACK callbacks/results in `tests/unit/test_backend.c`. diff --git a/tests/integration/test_jack.c b/tests/integration/test_jack.c new file mode 100644 index 0000000..e64a765 --- /dev/null +++ b/tests/integration/test_jack.c @@ -0,0 +1,119 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_backend.h" +#include +#include +#include +#include +#include +#include + +/* Optional live test. The only route is xtc's output -> this test's input. + * No ports are connected to hardware. Never changes server configuration. */ +typedef struct { + jack_port_t *input; + unsigned int blocks, bad, low, high, lost; +} probe_state; +static int probe_process(jack_nframes_t frames, void *context) +{ + probe_state *probe = context; + const float *input = jack_port_get_buffer(probe->input, frames); + jack_nframes_t frame; + float peak = 0.0f; + if (input == NULL) { __atomic_store_n(&probe->bad, 1U, __ATOMIC_RELAXED); return 0; } + for (frame = 0U; frame < frames; ++frame) { + float sample = input[frame]; + if (!isfinite(sample) || fabsf(sample) > 0.201f) + __atomic_store_n(&probe->bad, 1U, __ATOMIC_RELAXED); + if (fabsf(sample) > peak) peak = fabsf(sample); + } + if (peak > 0.09f && peak < 0.101f) __atomic_store_n(&probe->low, 1U, __ATOMIC_RELAXED); + if (peak > 0.19f) __atomic_store_n(&probe->high, 1U, __ATOMIC_RELAXED); + (void)__atomic_fetch_add(&probe->blocks, 1U, __ATOMIC_RELAXED); + return 0; +} +static void probe_shutdown(void *context) +{ probe_state *probe = context; __atomic_store_n(&probe->lost, 1U, __ATOMIC_RELEASE); } +static xtc_result tone(float gain, xtc_graph **out_graph) +{ + xtc_result result; + size_t osc, level, output; + result = xtc_graph_create(3U, out_graph); + if (result != XTC_OK) return result; + result = xtc_graph_add_oscillator(*out_graph, 440.0f, &osc); + if (result == XTC_OK) result = xtc_graph_add_gain(*out_graph, gain, &level); + if (result == XTC_OK) result = xtc_graph_add_output(*out_graph, &output); + if (result == XTC_OK) result = xtc_graph_connect(*out_graph, osc, level); + if (result == XTC_OK) result = xtc_graph_connect(*out_graph, level, output); + return result; +} +static int count_port(const char *name, void *context) +{ (void)name; ++*(size_t *)context; return 0; } +#define REQUIRE(c) do { if (!(c)) { fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); goto cleanup; } } while (0) +int main(int argc, char **argv) +{ + long seconds = 5; + char *end; + char name[64]; + xtc_backend_config config = {XTC_BACKEND_JACK, name, 4096U, 1U}; + xtc_backend *backend = NULL; + xtc_backend_status status; + xtc_graph *low = NULL, *high = NULL; + jack_client_t *probe_client = NULL; + jack_status_t jack_status = 0; + probe_state probe = {0}; + struct timespec pause = {0, 50000000L}; + size_t port_count = 0U; + long tick; + int result = EXIT_FAILURE; + if (argc > 2) { fprintf(stderr,"Usage: %s [seconds: 1..300]\n",argv[0]); return EXIT_FAILURE; } + if (argc == 2) { + seconds = strtol(argv[1], &end, 10); + if (*end != '\0' || seconds < 1 || seconds > 300) return EXIT_FAILURE; + } + (void)snprintf(name, sizeof(name), "xtc-test-%ld", (long)getpid()); + REQUIRE(xtc_backend_open(&config, &backend) == XTC_OK); + REQUIRE(xtc_backend_get_status(backend, &status) == XTC_OK); + printf("JACK: %u Hz, %u frames, mono, max 4096 frames\n", status.sample_rate, status.buffer_frames); + REQUIRE(xtc_backend_visit_ports(backend, XTC_PORT_INPUT, count_port, &port_count) == XTC_OK); + REQUIRE(port_count > 0U); + REQUIRE(tone(0.1f, &low) == XTC_OK && tone(0.2f, &high) == XTC_OK); + probe_client = jack_client_open("xtc-test-probe", JackNoStartServer, &jack_status); + REQUIRE(probe_client != NULL); + probe.input = jack_port_register(probe_client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0U); + REQUIRE(probe.input != NULL); + REQUIRE(jack_set_process_callback(probe_client, probe_process, &probe) == 0); + jack_on_shutdown(probe_client, probe_shutdown, &probe); + REQUIRE(jack_activate(probe_client) == 0); + REQUIRE(xtc_backend_submit(backend, low) == XTC_OK); + REQUIRE(xtc_backend_start(backend) == XTC_OK); + REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(probe.input)) == XTC_OK); + for (tick = 0; tick < seconds * 20; ++tick) { + (void)nanosleep(&pause, NULL); + REQUIRE(!__atomic_load_n(&probe.lost, __ATOMIC_ACQUIRE)); + REQUIRE(xtc_backend_get_status(backend, &status) == XTC_OK); + REQUIRE(status.running && !status.sample_rate_changed && status.process_result == XTC_OK); + xtc_backend_collect(backend); + if (tick % 8 == 7) { + xtc_result submitted = xtc_backend_submit(backend, tick % 16 == 7 ? high : low); + REQUIRE(submitted == XTC_OK || submitted == XTC_ERR_CAPACITY); + } + } + REQUIRE(__atomic_load_n(&probe.bad, __ATOMIC_RELAXED) == 0U); + REQUIRE(__atomic_load_n(&probe.low, __ATOMIC_RELAXED) && __atomic_load_n(&probe.high, __ATOMIC_RELAXED)); + REQUIRE(status.processed_blocks > 0U); + REQUIRE(xtc_backend_disconnect(backend, XTC_PORT_OUTPUT, jack_port_name(probe.input)) == XTC_OK); + for (tick = 0; tick < 10; ++tick) { + REQUIRE(xtc_backend_stop(backend) == XTC_OK); + REQUIRE(xtc_backend_start(backend) == XTC_OK); + (void)nanosleep(&pause, NULL); + } + REQUIRE(xtc_backend_stop(backend) == XTC_OK); + printf("PASS: %ld seconds, %u engine blocks, %u captured blocks, both gains observed, %u xruns; 10 restart cycles\n", + seconds, status.processed_blocks, __atomic_load_n(&probe.blocks, __ATOMIC_RELAXED), status.xruns); + result = EXIT_SUCCESS; +cleanup: + if (xtc_backend_close(backend) != XTC_OK) { fprintf(stderr,"JACK client close failed\n"); result = EXIT_FAILURE; } + if (probe_client != NULL && jack_client_close(probe_client) != 0) result = EXIT_FAILURE; + xtc_graph_destroy(low); xtc_graph_destroy(high); + return result; +} diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c new file mode 100644 index 0000000..eb38b13 --- /dev/null +++ b/tests/unit/test_backend.c @@ -0,0 +1,172 @@ +/* JACK ABI fake: deterministic fault injection, linked without libjack. + * Processing is driven synchronously here; realtime concurrency is tested + * separately by test_realtime. No fake header replaces installed declarations. */ +#include "xtc/xtc_backend.h" +#include +#include +#include +#include +#include +#define CHECK(c) do { if (!(c)) { fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); return 1; } } while (0) +static JackProcessCallback process_cb; +static JackSampleRateCallback rate_cb; +static JackBufferSizeCallback size_cb; +static JackXRunCallback xrun_cb; +static JackShutdownCallback shutdown_cb; +static void *context; +static float input[128], output[128]; +static int client_token, input_token, output_token; +static int open_fail, register_fail, callback_fail, activate_fail, deactivate_fail, close_fail; +static int active, closed, port_calls, null_buffer; +static unsigned int server_frames = 32U; +jack_client_t *jack_client_open(const char *name, jack_options_t options, jack_status_t *status, ...) +{ + (void)name; (void)status; + if (options != JackNoStartServer || open_fail) return NULL; + port_calls = 0; + process_cb = NULL; rate_cb = NULL; size_cb = NULL; xrun_cb = NULL; shutdown_cb = NULL; + return (jack_client_t *)&client_token; +} +int jack_client_close(jack_client_t *client) +{ (void)client; if (close_fail) return -1; active = 0; ++closed; return 0; } +int jack_client_name_size(void) { return 64; } +jack_nframes_t jack_get_sample_rate(jack_client_t *client) { (void)client; return 48000U; } +jack_nframes_t jack_get_buffer_size(jack_client_t *client) { (void)client; return server_frames; } +jack_port_t *jack_port_register(jack_client_t *client, const char *name, const char *type, + unsigned long flags, unsigned long buffer_size) +{ + (void)client; (void)name; (void)type; (void)buffer_size; + ++port_calls; + if (register_fail == port_calls) return NULL; + return (jack_port_t *)(flags == JackPortIsInput ? &input_token : &output_token); +} +int jack_set_process_callback(jack_client_t *client, JackProcessCallback callback, void *arg) +{ (void)client; process_cb = callback; context = arg; return callback_fail ? -1 : 0; } +int jack_set_sample_rate_callback(jack_client_t *client, JackSampleRateCallback callback, void *arg) +{ (void)client; (void)arg; rate_cb = callback; return 0; } +int jack_set_buffer_size_callback(jack_client_t *client, JackBufferSizeCallback callback, void *arg) +{ (void)client; (void)arg; size_cb = callback; return 0; } +int jack_set_xrun_callback(jack_client_t *client, JackXRunCallback callback, void *arg) +{ (void)client; (void)arg; xrun_cb = callback; return 0; } +void jack_on_shutdown(jack_client_t *client, JackShutdownCallback callback, void *arg) +{ (void)client; (void)arg; shutdown_cb = callback; } +int jack_activate(jack_client_t *client) +{ (void)client; if (activate_fail) return -1; active = 1; return 0; } +int jack_deactivate(jack_client_t *client) +{ (void)client; if (deactivate_fail) return -1; active = 0; return 0; } +void *jack_port_get_buffer(jack_port_t *port, jack_nframes_t frames) +{ (void)frames; if (null_buffer) return NULL; return port == (jack_port_t *)&input_token ? input : output; } +const char *jack_port_name(const jack_port_t *port) +{ return port == (jack_port_t *)&input_token ? "test:input" : "test:output"; } +const char **jack_get_ports(jack_client_t *client, const char *pattern, const char *type, unsigned long flags) +{ + const char **list = malloc(2U * sizeof(*list)); + (void)client; (void)pattern; (void)type; (void)flags; + if (list != NULL) { list[0] = "fake:audio"; list[1] = NULL; } + return list; +} +void jack_free(void *pointer) { free(pointer); } +int jack_connect(jack_client_t *client, const char *source, const char *dest) +{ (void)client; return strstr(source, "missing") || strstr(dest, "missing") ? -1 : EEXIST; } +int jack_disconnect(jack_client_t *client, const char *source, const char *dest) +{ (void)client; (void)source; (void)dest; return 0; } +static int visit(const char *name, void *arg) +{ if (strcmp(name, "fake:audio") == 0) ++*(int *)arg; return 0; } + +int main(void) +{ + xtc_backend_config config = {XTC_BACKEND_JACK, "test", 64U, 1U}; + xtc_backend *backend = NULL; + xtc_backend_status status; + xtc_graph *graph = NULL; + size_t node, i; + const char *name; + int visits = 0, iteration; + CHECK(xtc_backend_open(NULL, &backend) == XTC_ERR_INVALID_ARGUMENT && backend == NULL); + config.channel_count = 2U; + CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_INVALID_ARGUMENT); + config.channel_count = 1U; + config.kind = (xtc_backend_kind)999; + CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_UNSUPPORTED); + config.kind = XTC_BACKEND_JACK; + open_fail = 1; + CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_IO && backend == NULL); + open_fail = 0; + server_frames = 128U; + CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_CAPACITY && backend == NULL); + server_frames = 32U; + register_fail = 2; + CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_IO && backend == NULL); + register_fail = 0; + callback_fail = 1; + CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_IO && backend == NULL); + close_fail = 1; + CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_IO && backend != NULL); + CHECK(xtc_backend_start(backend) == XTC_ERR_INVALID_STATE); + close_fail = 0; + CHECK(xtc_backend_close(backend) == XTC_OK); + callback_fail = 0; + CHECK(xtc_graph_create(1U, &graph) == XTC_OK); + CHECK(xtc_graph_add_gain(graph, 0.5f, &node) == XTC_OK); + CHECK(xtc_backend_open(&config, &backend) == XTC_OK); + CHECK(xtc_backend_submit(backend, graph) == XTC_OK); + CHECK(xtc_backend_submit(backend, graph) == XTC_ERR_CAPACITY); + CHECK(xtc_backend_port_name(backend, XTC_PORT_OUTPUT, &name) == XTC_OK && strcmp(name,"test:output") == 0); + CHECK(xtc_backend_visit_ports(backend, XTC_PORT_INPUT, visit, &visits) == XTC_OK && visits == 1); + CHECK(xtc_backend_connect(backend, XTC_PORT_OUTPUT, "fake:in") == XTC_ERR_INVALID_STATE); + activate_fail = 1; + CHECK(xtc_backend_start(backend) == XTC_ERR_IO); + activate_fail = 0; + CHECK(xtc_backend_start(backend) == XTC_OK); + CHECK(xtc_backend_start(backend) == XTC_ERR_INVALID_STATE); + CHECK(xtc_backend_connect(backend, XTC_PORT_OUTPUT, "fake:in") == XTC_OK); + CHECK(xtc_backend_connect(backend, XTC_PORT_INPUT, "missing:out") == XTC_ERR_IO); + CHECK(xtc_backend_disconnect(backend, XTC_PORT_OUTPUT, "fake:in") == XTC_OK); + for (i = 0U; i < 128U; ++i) input[i] = 1.0f; + CHECK(process_cb(32U, context) == 0 && output[0] == 0.5f); + CHECK(size_cb(128U, context) == 0); + CHECK(process_cb(128U, context) == 0 && output[127] == 0.0f); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.process_result == XTC_ERR_CAPACITY); + CHECK(size_cb(16U, context) == 0); + CHECK(process_cb(16U, context) == 0 && output[15] == 0.5f); + CHECK(xrun_cb(context) == 0); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.xruns == 1U && status.buffer_frames == 16U); + null_buffer = 1; + CHECK(process_cb(16U, context) == 0); + null_buffer = 0; + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.process_result == XTC_ERR_IO); + CHECK(rate_cb(44100U, context) == 0); + CHECK(process_cb(16U, context) == 0 && output[0] == 0.0f); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.sample_rate_changed && status.sample_rate == 44100U); + CHECK(xtc_backend_submit(backend, graph) == XTC_ERR_INVALID_STATE); + deactivate_fail = 1; + CHECK(xtc_backend_stop(backend) == XTC_ERR_IO); + deactivate_fail = 0; + CHECK(xtc_backend_stop(backend) == XTC_OK && !active); + CHECK(xtc_backend_start(backend) == XTC_ERR_INVALID_STATE); + CHECK(xtc_backend_close(backend) == XTC_OK); + for (iteration = 0; iteration < 100; ++iteration) { + CHECK(xtc_backend_open(&config, &backend) == XTC_OK); + CHECK(xtc_backend_submit(backend, graph) == XTC_OK); + CHECK(xtc_backend_start(backend) == XTC_OK); + CHECK(process_cb(32U, context) == 0); + if (iteration % 2 == 0) { + shutdown_cb(context); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.server_lost && !status.running); + CHECK(xtc_backend_start(backend) == XTC_ERR_INVALID_STATE); + } else { + CHECK(xtc_backend_stop(backend) == XTC_OK); + CHECK(xtc_backend_start(backend) == XTC_OK); + } + CHECK(xtc_backend_submit(backend, graph) == (iteration % 2 == 0 ? XTC_ERR_INVALID_STATE : XTC_OK)); + close_fail = 1; + CHECK(xtc_backend_close(backend) == XTC_ERR_IO); + close_fail = 0; + CHECK(xtc_backend_close(backend) == XTC_OK); + } + CHECK(closed >= 104); + CHECK(xtc_backend_close(NULL) == XTC_OK); + xtc_graph_destroy(graph); + puts("JACK backend fault-injection tests passed."); + return 0; +} diff --git a/tests/unit/test_realtime.c b/tests/unit/test_realtime.c new file mode 100644 index 0000000..aea0c5a --- /dev/null +++ b/tests/unit/test_realtime.c @@ -0,0 +1,80 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_realtime.h" +#include +#include +#include +#include +#define CHECK(c) do { if (!(c)) { fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); return 1; } } while (0) + +typedef struct { xtc_realtime *engine; unsigned int stop, failed; } worker_state; +static void *worker(void *context) +{ + worker_state *state = context; + float input[32], output[32]; + size_t i; + for (i = 0U; i < 32U; ++i) input[i] = 1.0f; + while (!__atomic_load_n(&state->stop, __ATOMIC_ACQUIRE)) { + if (xtc_realtime_process(state->engine, input, output, 32U) != XTC_OK) + __atomic_store_n(&state->failed, 1U, __ATOMIC_RELAXED); + /* A whole block must come from one plan. No partially edited gains. */ + for (i = 0U; i < 32U; ++i) + if ((output[i] != 0.0f && output[i] != 1.0f && output[i] != 2.0f) || output[i] != output[0]) + __atomic_store_n(&state->failed, 1U, __ATOMIC_RELAXED); + sched_yield(); + } + return NULL; +} +static int run(void) +{ + xtc_realtime *engine = NULL; + xtc_graph *one = NULL, *two = NULL; + xtc_realtime_status status; + size_t node, i; + float input[5] = {1,1,1,1,1}, output[5] = {9,9,9,9,9}; + pthread_t thread; + worker_state state; + CHECK(xtc_realtime_create(48000.0, 32U, &engine) == XTC_OK); + CHECK(xtc_realtime_process(engine, input, output, 4U) == XTC_OK && output[0] == 0.0f); + CHECK(xtc_graph_create(1U, &one) == XTC_OK); + CHECK(xtc_graph_add_gain(one, 1.0f, &node) == XTC_OK); + CHECK(xtc_graph_create(1U, &two) == XTC_OK); + CHECK(xtc_graph_add_gain(two, 2.0f, &node) == XTC_OK); + CHECK(xtc_realtime_submit(engine, one) == XTC_OK); + CHECK(xtc_realtime_submit(engine, two) == XTC_ERR_CAPACITY); + CHECK(xtc_realtime_process(engine, input, output, 4U) == XTC_OK && output[0] == 1.0f); + CHECK(xtc_realtime_submit(engine, two) == XTC_OK); + CHECK(xtc_realtime_process(engine, input, output, 4U) == XTC_OK && output[0] == 2.0f); + xtc_realtime_collect(engine); + CHECK(xtc_realtime_process(engine, input, output, 0U) == XTC_OK && output[0] == 2.0f); + CHECK(xtc_realtime_get_status(engine, &status) == XTC_OK && status.processed_blocks == 3U); + state.engine = engine; state.stop = 0U; state.failed = 0U; + CHECK(pthread_create(&thread, NULL, worker, &state) == 0); + for (i = 0U; i < 2000U; ++i) { + xtc_result result; + do { + result = xtc_realtime_submit(engine, i % 2U ? one : two); + if (result == XTC_ERR_CAPACITY) sched_yield(); + } while (result == XTC_ERR_CAPACITY); + CHECK(result == XTC_OK); + } + __atomic_store_n(&state.stop, 1U, __ATOMIC_RELEASE); + CHECK(pthread_join(thread, NULL) == 0); + CHECK(state.failed == 0U); + CHECK(xtc_realtime_get_status(engine, &status) == XTC_OK && status.processed_blocks > 3U); + xtc_realtime_destroy(engine); /* Also cleans pending and retired plans. */ + xtc_graph_destroy(one); xtc_graph_destroy(two); + for (i = 0U; i < 100U; ++i) { + CHECK(xtc_realtime_create(48000.0, 4U, &engine) == XTC_OK); + CHECK(xtc_realtime_process(engine, input, output, 5U) == XTC_ERR_CAPACITY); + CHECK(output[0] == 0.0f && output[4] == 0.0f); + CHECK(xtc_realtime_get_status(engine, &status) == XTC_OK && status.last_result == XTC_ERR_CAPACITY); + xtc_realtime_destroy(engine); + } + return 0; +} +int main(void) +{ + CHECK(run() == 0); + puts("Realtime handoff and concurrent replacement tests passed."); + return 0; +}