diff --git a/Makefile b/Makefile index 5daa4fd..ed52831 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c +CORE_SOURCES := src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.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) @@ -34,7 +34,7 @@ FLTK_LIBS := $(shell $(FLTK_CONFIG) --ldflags) GUI_AVAILABLE := yes endif -.PHONY: all clean test test-jack-audio test-jack test-gui test-gui-jack debug gui install +.PHONY: all clean test test-jack-midi test-jack-audio test-jack test-gui test-gui-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 @@ -77,8 +77,12 @@ $(BUILD_DIR)/test_project: $(BUILD_DIR)/tests/unit/test_project.o $(BUILD_DIR)/l $(BUILD_DIR)/test_audio: $(BUILD_DIR)/tests/unit/test_audio.o $(BUILD_DIR)/libxtc.a $(CC) $(LDFLAGS) -o $@ $^ -lm -pthread -test: $(BUILD_DIR)/test_audio $(BUILD_DIR)/test_project $(BUILD_DIR)/test_graph_edits $(BACKEND_TEST) $(BUILD_DIR)/test_realtime $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check +$(BUILD_DIR)/test_midi: $(BUILD_DIR)/tests/unit/test_midi.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm + +test: $(BUILD_DIR)/test_midi $(BUILD_DIR)/test_audio $(BUILD_DIR)/test_project $(BUILD_DIR)/test_graph_edits $(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_midi ./$(BUILD_DIR)/test_audio ./$(BUILD_DIR)/test_project ./$(BUILD_DIR)/test_graph_edits @@ -91,6 +95,13 @@ 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_midi.o: CPPFLAGS += $(JACK_CFLAGS) +$(BUILD_DIR)/test_jack_midi: $(BUILD_DIR)/tests/integration/test_jack_midi.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ $(JACK_LIBS) -lm + +test-jack-midi: $(BUILD_DIR)/test_jack_midi + ./$(BUILD_DIR)/test_jack_midi + $(BUILD_DIR)/tests/integration/test_jack_audio.o: CPPFLAGS += $(JACK_CFLAGS) $(BUILD_DIR)/test_jack_audio: $(BUILD_DIR)/tests/integration/test_jack_audio.o $(BUILD_DIR)/libxtc.a $(CC) $(LDFLAGS) -o $@ $^ $(JACK_LIBS) -lm @@ -105,7 +116,7 @@ $(BUILD_DIR)/test_jack: $(BUILD_DIR)/tests/integration/test_jack.o $(BUILD_DIR)/ test-jack: $(BUILD_DIR)/test_jack ./$(BUILD_DIR)/test_jack else -test-jack-audio test-jack: +test-jack-midi test-jack-audio test-jack: @echo "JACK development files are required for the live integration test." @exit 1 endif @@ -113,14 +124,14 @@ endif ifeq ($(GUI_AVAILABLE),yes) gui: $(BUILD_DIR)/xtc -$(BUILD_DIR)/test_gui: $(BUILD_DIR)/tests/integration/test_gui.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/libxtc.a +$(BUILD_DIR)/test_gui: $(BUILD_DIR)/tests/integration/test_gui.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/libxtc.a $(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) -lm test-gui: $(BUILD_DIR)/test_gui xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui ifeq ($(JACK_AVAILABLE),yes) -$(BUILD_DIR)/test_gui_jack: $(BUILD_DIR)/tests/integration/test_gui_jack.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/libxtc.a +$(BUILD_DIR)/test_gui_jack: $(BUILD_DIR)/tests/integration/test_gui_jack.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/libxtc.a $(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm test-gui-jack: $(BUILD_DIR)/test_gui_jack @@ -131,7 +142,7 @@ test-gui-jack: @exit 1 endif -$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/libxtc.a +$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/libxtc.a $(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm else test-gui-jack test-gui: @@ -152,10 +163,14 @@ 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)/tests/unit/test_realtime.d $(BUILD_DIR)/tests/unit/test_backend.d $(BUILD_DIR)/tests/unit/test_graph_edits.d \ - $(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/src/gui/graph_widget.d $(BUILD_DIR)/src/gui/sequencer_widget.d $(BUILD_DIR)/tests/integration/test_jack.d $(BUILD_DIR)/tests/integration/test_gui.d $(BUILD_DIR)/tests/integration/test_gui_jack.d + $(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/src/gui/graph_widget.d $(BUILD_DIR)/src/gui/sequencer_widget.d $(BUILD_DIR)/src/gui/piano_roll.d $(BUILD_DIR)/tests/integration/test_jack.d $(BUILD_DIR)/tests/integration/test_gui.d $(BUILD_DIR)/tests/integration/test_gui_jack.d -include $(BUILD_DIR)/tests/unit/test_project.d -include $(BUILD_DIR)/tests/unit/test_audio.d -include $(BUILD_DIR)/tests/integration/test_jack_audio.d + +-include $(BUILD_DIR)/tests/unit/test_midi.d + +-include $(BUILD_DIR)/tests/integration/test_jack_midi.d diff --git a/README.md b/README.md index 401c38d..e87d0ab 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,11 @@ This repository is a starter scaffold, not a production DAW. It currently provid - versioned project save/load and a timeline for tracks and clips; - sample-counted transport, selection, move/trim, timeline zoom and scrolling; - WAV import/graph playback, bounded recording and missing-asset relinking; +- MIDI clips, a sine instrument, JACK MIDI input and a basic piano roll; - architecture, format, GUI, roadmap, and decision documents. The JACK backend has passed software-loopback testing; physical-device playback -and capture are unverified. MIDI and plugin hosting are not implemented yet. +and capture are unverified. MIDI recording and plugin hosting are not implemented. ## Build @@ -56,11 +57,14 @@ Run the GUI: The GUI opens JACK and offers Play/Stop/Reconnect, backend status and view switching. The initial empty graph is silent. Modular Graph supports adding, -moving and deleting oscillator/audio/gain/meter/output nodes and connecting ports. +moving and deleting oscillator/audio/instrument/gain/meter/output nodes and connecting ports. Import WAV (Ctrl+I) creates playable clips; Add region creates silent placeholders. Record captures JACK input to a chosen WAV; Stop finalizes and adds the take. Save the project after recording. External asset paths must remain available; select a missing clip and use File/Relink selected audio to repair it. +Add MIDI clip on an existing track, then open Piano roll (Ctrl+4) to draw notes. +Connect a MIDI source to the JACK `midi_in` port for live playing; Panic/F9 +silences held notes. See [MIDI workflow and limits](docs/MIDI.md). See [audio workflow and limits](docs/AUDIO_FILES.md). The mixer remains a placeholder. Use Add track/Add region, drag regions or their edges, and Zoom +/-; scrollbars pan. Ctrl+N/O/S creates, opens or saves a project; Save As chooses another path. @@ -73,7 +77,8 @@ active graph intact; queued changes are labeled until the engine adopts them. See [GUI documentation](docs/GUI.md). With JACK development files and an existing JACK-compatible server, run the isolated software-loopback test with `make test-jack` and the record/reopen/play -acceptance test with `make test-jack-audio`. It connects only test +acceptance test with `make test-jack-audio` and MIDI acceptance with +`make test-jack-midi`. It connects only test clients. See [engine documentation](docs/AUDIO_ENGINE.md) for configuration, routing, ownership and recovery. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6108f76..4880b8d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,3 +106,15 @@ SPSC ring publishes complete payload blocks through lock-free indices. Only the consumer writes WAV bytes; finalization follows successful callback stop. No editable project pointer, GUI object or FILE is accessed by the producer. See decision 0005 and AUDIO_FILES.md for format, limits and recovery contracts. + +## Phase 7 MIDI source + +The C MIDI plan prepares bounded source-relative notes into sample spans and +renders a separate instrument buffer. `xtc_plan_process_sources` and +`xtc_realtime_process_sources` distinguish external audio, WAV timeline and +instrument input; older wrappers supply silence for omitted sources. Graph +instrument nodes consume the shared mono instrument buffer. The backend owns +prepared MIDI runtime state exclusively while active, with lock-free panic +requests and no callback allocation/I/O. Stop/seek clear live voices; scheduled +notes chase their absolute phase. See MIDI.md for event ordering, panic semantics, +capacities and test coverage. No new dependency is required. diff --git a/docs/AUDIO_ENGINE.md b/docs/AUDIO_ENGINE.md index 096777a..9dd5867 100644 --- a/docs/AUDIO_ENGINE.md +++ b/docs/AUDIO_ENGINE.md @@ -238,3 +238,15 @@ I/O, logging, locks or FLTK calls were added. Backend stop failure preserves the borrowed recorder; the caller must not free it before successful quiescence. Live software source/capture acceptance and concurrent ring tests pass; physical recording, latency compensation and disk endurance remain unverified. + +## Phase 7 MIDI source + +The C MIDI plan prepares bounded source-relative notes into sample spans and +renders a separate instrument buffer. `xtc_plan_process_sources` and +`xtc_realtime_process_sources` distinguish external audio, WAV timeline and +instrument input; older wrappers supply silence for omitted sources. Graph +instrument nodes consume the shared mono instrument buffer. The backend owns +prepared MIDI runtime state exclusively while active, with lock-free panic +requests and no callback allocation/I/O. Stop/seek clear live voices; scheduled +notes chase their absolute phase. See MIDI.md for event ordering, panic semantics, +capacities and test coverage. No new dependency is required. diff --git a/docs/BUILD.md b/docs/BUILD.md index f3bf392..98940f5 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -154,3 +154,24 @@ ASan/UBSan passes the core and GUI tests (GUI retains the existing fontconfig-on LSan suppression). ThreadSanitizer passes the concurrent recorder and existing realtime handoff tests. Hardware capture/playback, disk endurance and real server loss during recording remain unverified. See AUDIO_FILES.md. + +## Phase 7 verification + +```sh +make all test gui debug test-gui test-gui-jack test-jack-audio test-jack-midi +make BUILD_DIR=build/nojack PKG_CONFIG=false all test gui test-gui +``` + +`test_midi` adds exact sample scheduling, trim/seek/panic, block independence, +voice capacities, stable notes and v3 malformed-file/roundtrip/v2 migration +coverage. Backend fault injection checks event offsets, dropped/malformed input, +disconnection, xruns, missing output buffers and stop/restart cleanup. GUI tests +draw/move/resize notes, save/reopen and render them. The live MIDI target connects +only software clients and checks on/off, panic, disconnect, and saved-note +playback/seek. It needs an existing JACK-compatible server. No hardware routes +or system changes are made. MIDI recording/SMF and physical devices are deferred. + +A fresh `build/verify` build passed all targets above with no compiler warnings. +The no-JACK core/GUI suite and Clang ASan/UBSan core/GUI suite also passed; GUI +leak checking uses only the existing fontconfig suppression documented in the +integration README. Physical MIDI and audio devices remain unverified. diff --git a/docs/GUI.md b/docs/GUI.md index cee876d..dd15398 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -124,7 +124,7 @@ A ruler click seeks while stopped (or positions the cursor when disconnected). The 100 ms GUI refresh converts the backend's actual sample counter to ticks; it never integrates wall-clock time. Play resumes and Stop pauses. Reconnect, New and Open reset position. Add region creates silent metadata; imported WAV clips play through the -audio source node (Phase 6 below). MIDI content is not implemented. The C model +audio source node (Phase 6 below). MIDI clips and notes are described below. The C model supports a constant tempo; a tempo map and scheduler are future work. Tests cover C-model/persistence boundaries, GUI move/trim gestures and zoom, @@ -155,3 +155,14 @@ See AUDIO_FILES.md for supported WAV formats, memory limits, musical placement, recording overflow/partial-take recovery, external asset portability and the software-only acceptance tests. Waveform drawing, input monitoring, recording latency compensation and per-track mixing are not implemented. + +## Phase 7 piano roll + +Add MIDI clip creates a one-bar clip on the selected/first existing track at the +cursor. Select it and choose Piano roll (View/Piano roll, Ctrl+4). Click to draw, +drag a body to move pitch/time, drag its right edge to resize, and right-click +or Delete to remove. Notes use source ticks with 1/16 snapping; trimmed source +content is retained. Edits stop transport and are blocked during audio recording. +Panic/F9 silences held notes; Record continues to mean WAV audio capture. +Modular Graph offers Add instrument for explicit routing. Status includes MIDI +drops. See MIDI.md for voice limits, unsupported messages and live input routing. diff --git a/docs/MIDI.md b/docs/MIDI.md new file mode 100644 index 0000000..c330866 --- /dev/null +++ b/docs/MIDI.md @@ -0,0 +1,73 @@ +# MIDI clips and instrument + +Phase 7 supports drawing persisted notes and live JACK MIDI input. Record still +captures audio to WAV: MIDI capture and Standard MIDI File import are deferred. + +## Workflow + +Add/select a track in Sequencer, choose Add MIDI clip, then Piano roll (Ctrl+4). +Creation adds an instrument-to-output route if no instrument exists. Existing +instruments retain their routes; use Modular Graph to connect them as needed. +All MIDI clips and live input share one mono instrument mix. No hardware ports +are connected automatically. Connect a MIDI source to the backend's `midi_in` +JACK port, and route audio output explicitly to hear it while Play is running. + +The piano roll shows all 128 pitches and edits clip-source time. Click blank +space to draw a one-beat note (velocity 100, channel 0); drag its body to move +it, drag the right edge to resize, or right-click/Delete to remove it. Edits +snap to 1/16 notes. Escape cancels a drag. Trimmed-out source notes remain editable +and are preserved. Arrangement/note edits stop playback and are blocked during +audio recording. Save/Open preserves MIDI flags, notes and stable IDs. + +## Timing and ownership + +The C model owns up to 4096 notes per project, with source-relative ticks at +960 ticks/quarter and constant tempo. Control-thread preparation intersects +notes with clip trims and converts boundaries to integer sample positions. +Scheduled rendering uses absolute note age: block partitions do not change +samples, and seeking into a note chases its phase. Clip ends cut notes off. +The instrument source has its own buffer, separate from external audio input +and WAV timeline playback, and routes through the ordinary graph. + +Prepared ranges are immutable; only their audio owner changes voice/panic state. +Preparation allocates; rendering uses bounded preallocated storage and performs +no allocation, freeing, I/O, locks, logging or GUI calls. Project preparation +builds audio and MIDI candidates before committing the stopped backend state. + +JACK events have sample offsets within the current block. Supported messages +are complete note on/off (including velocity-zero note on) and CC120/123 on +channels 0..15. Equal-offset events retain input order. Other messages are +ignored; sustain, bend, program changes, running status and SysEx are unsupported. +At most 256 events are accepted per block. Malformed/overflowed input discards +that block's events and triggers panic, with a MIDI-drop status counter. + +## Recovery and limits + +Panic/F9 clears live voices and suppresses scheduled notes already started; +future timeline notes can still start. CC120/123 does this for its channel. +Stop/start and seek clear live voices and reset suppression, allowing timeline +notes to be chased again. MIDI port disconnection, xruns and callback processing +failures request panic. Control requests use lock-free generation counters; +voice state changes on the audio thread, or while stopped. + +The sine instrument scales amplitude by 0.1 times velocity/127. It supports +32 simultaneous scheduled notes and a separate 32 live voices; live overflow +steals the oldest voice, and repeated channel/pitch retriggers it. Preparation +rejects more than 32 overlapping scheduled notes with CAPACITY. Such metadata +can be saved by the C model, but GUI Open/Play rejects it until overlap is +reduced. Immediate note-off/stealing can click; there is no envelope or limiter, +and summed voices can exceed unity. Above-Nyquist pitches are silent. +There is no per-track/channel instrument routing, velocity editor, MIDI recording, +SMF import, undo or piano-roll zoom yet. + +## Verification + +`make test` checks exact onset/off samples, block partition independence, trim, +seek, panic, channel handling, voice/event limits, persistence migration and +malformed files, plus callback fault recovery. `make test-gui` draws, moves and +resizes notes with FLTK events, then saves/reopens and renders them. +`make test-jack-midi` uses only software MIDI/audio ports to verify live on/off, +panic, disconnection and saved/reopened scheduled playback. JACK port discovery +may lag activation under PipeWire; the test waits before connecting. +Physical MIDI devices, bridge/device loss behind an unchanged port, latency and +long-running performance remain unverified. Panic/Stop provides manual recovery. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index 1915c3e..924c03d 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -1,9 +1,9 @@ -# Project format v2 (reads v1) +# Project format v3 (reads v1/v2) -Phases 5/6 use a bounded text format implemented in C99 without a parser library. +Phases 5–7 use a bounded text format implemented in C99 without a parser library. The extension is `.xtc`. File operations run exclusively on the control thread. The project owns its graph, tracks and silent/audio arrangement regions. Audio samples remain in external WAV -files; MIDI content is a subsequent phase. +files; MIDI notes are stored inline. ## Schema @@ -12,7 +12,7 @@ Fields use ASCII spaces. Integers are unsigned decimal, without signs. Counts determine the number of subsequent records: ```text -XTC_PROJECT 2 +XTC_PROJECT 3 APP 0 1 0 TEMPO 120000 IDS 1 1 @@ -25,6 +25,8 @@ TRACK 0 547261636b CLIPS 1 CLIP 0 0 3840 2880 960 3840 526567696f6e ASSET - +MIDI 0 +NOTES 0 0 END ``` @@ -35,7 +37,7 @@ END - `GRAPH`: output-node policy (0/1), next node ID, node count, edge count. Nodes retain stable IDs (limited to the host's size_t range). No pointers or compiled state are serialized. -- `NODE`: ID, type (1 gain, 2 oscillator, 3 meter, 4 output, 5 audio source), and exactly eight +- `NODE`: ID, type (1 gain, 2 oscillator, 3 meter, 4 output, 5 audio source, 6 instrument), and exactly eight hexadecimal digits encoding the IEEE binary32 parameter bits, independent of locale and host byte order. Nonfinite values are rejected. - `EDGE`: source and destination node IDs. Mono port directions are implied @@ -45,24 +47,32 @@ END length tick, hex-encoded name bytes. Trimming preserves source bounds, so a region can re-extend only within its original length. Overlaps are allowed and audio clips mix through the audio source node. -- `ASSET`: required after each CLIP in v2; "-" means silent, otherwise +- `ASSET`: required after each CLIP in v2/v3; "-" means no audio asset, otherwise hex-encoded absolute path bytes, <=511 bytes after decoding, no embedded NUL or ASCII control characters. Each clip directly references a WAV; no sample data, pointers or DSP state are embedded. Paths can contain spaces. +- `MIDI`: required after each ASSET in v3; 0 for silent/audio regions, 1 for + MIDI clips. MIDI clips must have no asset. +- `NOTES`: next stable note ID and global note count, followed by that many + `NOTE id clip_id start_tick length_tick pitch velocity channel` records. + Notes reference MIDI clips; start/length are source-relative, positive length + within source bounds. Pitch is 0..127, velocity 1..127, channel 0..15. + Note IDs are unique, monotonic uint64 IDs with UINT64_MAX reserved. - Names decode to nonempty valid UTF-8 of at most 63 bytes; ASCII control characters and embedded NULs are forbidden. Hex avoids quoting ambiguity. Ticks use 960 per quarter note, 3840 per bar. All region endpoints are bounded by 38400000 ticks (10000 bars). Limits: 64 tracks, 1024 clips, 64 graph nodes, -4096 edges, 2 MiB input, 1535 bytes per record, 12 tokens per record. The reader +4096 edges, 4096 notes, 4 MiB input, 1535 bytes per record, 12 tokens per record. The reader rejects invalid references, duplicate IDs, inconsistent next IDs, ranges, truncated records, raw control/non-ASCII bytes, and trailing data. Unsupported format versions return UNSUPPORTED. Other malformed data returns FORMAT. The reader explicitly accepts v1 records without ASSET lines, with -node types 1..4, migrating their clips to empty asset paths. Saving always writes -v2. Unknown versions are rejected without replacing the destination; older -applications cannot read v2. Migration and maximum-path roundtrips are tested. +node types 1..4, migrating their clips to empty asset paths. Version 2 accepts +node types 1..5 and ASSET records. Both migrate to MIDI=0 and no notes. +Saving always writes v3. Unknown versions are rejected without replacing the destination; older +applications cannot read v3. Migration and maximum-path roundtrips are tested. ## Ownership and failures diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5c9de4c..cb0a7e9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -80,6 +80,14 @@ No new dependency or callback I/O was introduced. MIDI input/events/clips, simple internal instrument, note-off/panic handling, then a basic piano roll. +Implemented: bounded source-relative note events with stable IDs; sample-offset +JACK MIDI input; a velocity-scaled sine instrument and separate graph source; +panic, note-off, stop and seek handling; and a piano roll for drawing, moving, +resizing and deleting notes. Version 3 saves notes and reads v1/v2 projects. +Core, fault-injection, GUI and live software MIDI/save/reopen/play tests pass. +Acceptance uses drawn notes; MIDI recording and SMF import are deferred. +See MIDI.md for capacities, immediate note-off, routing and hardware limits. + ## Phase 8 — Mixer and editing Gain/pan, meters, mute/solo, automation, undo/redo, keyboard workflow. diff --git a/docs/decisions/0006-midi-instrument-and-notes.md b/docs/decisions/0006-midi-instrument-and-notes.md new file mode 100644 index 0000000..0221fe8 --- /dev/null +++ b/docs/decisions/0006-midi-instrument-and-notes.md @@ -0,0 +1,16 @@ +# 0006 — Bounded MIDI notes and internal instrument + +Status: accepted, Phase 7. + +Use the existing JACK backend for timestamped MIDI input, avoiding another +backend/dependency. Store source-relative notes in the C project and v3 format; +read v1/v2 without MIDI. Prepare sample spans on the control thread and render +a small sine instrument into its own graph source buffer. Use a piano roll to +meet draw/play/save/reopen acceptance before adding MIDI capture or SMF import. + +Keep explicit bounds: 4096 project notes, 32 overlapping scheduled notes, 32 live +voices and 256 events/block. Reject scheduled overcapacity at preparation; steal +oldest live voices. Panic handles event loss, disconnects and callback failures; +Stop/seek resets live state. Immediate note-off and a fixed sine voice keep this +prototype small, at the cost of possible clicks and no expressive controllers. +See ../MIDI.md for ownership, detailed limits and validation. diff --git a/include/xtc/xtc_backend.h b/include/xtc/xtc_backend.h index 25799d1..b9f972f 100644 --- a/include/xtc/xtc_backend.h +++ b/include/xtc/xtc_backend.h @@ -2,6 +2,7 @@ #define XTC_BACKEND_H #include "xtc_engine.h" #include "xtc_audio.h" +#include "xtc_midi.h" #ifdef __cplusplus extern "C" { #endif @@ -19,6 +20,7 @@ typedef struct { unsigned int buffer_frames; unsigned int xruns; /* Wraps modulo UINT_MAX + 1. */ unsigned int processed_blocks; + unsigned int midi_dropped; /* Malformed/overflowed input; unsigned wrap. */ unsigned int asset_issues; /* Unique missing/unreadable/unsupported assets. */ uint64_t sample_position; /* Successful callback frames; retained across stop/submit. */ unsigned int submitted_revision, active_revision; @@ -52,6 +54,10 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph); xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project); const char *xtc_backend_asset_issue(const xtc_backend *backend); /* Borrowed recorder, set/detach only while stopped. Stop before finish/destroy. */ +/* Control-thread panic is delivered at a block boundary via lock-free counter. + * Stops, seeks, xruns and event overflow also clear instrument voices. */ +xtc_result xtc_backend_panic(xtc_backend *backend); +xtc_result xtc_backend_midi_port_name(const xtc_backend *backend, const char **out_name); xtc_result xtc_backend_set_recorder(xtc_backend *backend, xtc_recorder *recorder); void xtc_backend_collect(xtc_backend *backend); /* Snapshot fields are individually coherent, not a transactional snapshot. */ diff --git a/include/xtc/xtc_engine.h b/include/xtc/xtc_engine.h index 0123c76..abfbd44 100644 --- a/include/xtc/xtc_engine.h +++ b/include/xtc/xtc_engine.h @@ -33,7 +33,8 @@ typedef enum { XTC_NODE_OSCILLATOR, XTC_NODE_METER, XTC_NODE_OUTPUT, - XTC_NODE_AUDIO /* Timeline mix source, output only. */ + XTC_NODE_AUDIO, /* Timeline mix source, output only. */ + XTC_NODE_INSTRUMENT /* Internal MIDI instrument, output only. */ } xtc_node_type; typedef struct { @@ -81,7 +82,7 @@ void xtc_graph_destroy(xtc_graph *graph); xtc_result xtc_graph_add_gain(xtc_graph *graph, float gain, size_t *out_node_id); xtc_result xtc_graph_connect(xtc_graph *graph, size_t source_node, size_t destination_node); -/* Port 0 is input; port 1 is output. Oscillators and audio sources have only output 1; +/* Port 0 is input; port 1 is output. Oscillators, audio sources and instruments have only output 1; * output nodes have only input 0. All ports carry mono audio. */ xtc_result xtc_graph_get_port(const xtc_graph *graph, size_t node_id, size_t port_id, xtc_port_info *out_port); @@ -91,6 +92,7 @@ xtc_result xtc_graph_connect_ports(xtc_graph *graph, size_t source_node, xtc_result xtc_graph_add_oscillator(xtc_graph *graph, float frequency, size_t *out_node_id); xtc_result xtc_graph_add_meter(xtc_graph *graph, size_t *out_node_id); +xtc_result xtc_graph_add_instrument(xtc_graph *graph, size_t *out_node_id); xtc_result xtc_graph_add_audio(xtc_graph *graph, size_t *out_node_id); xtc_result xtc_graph_add_output(xtc_graph *graph, size_t *out_node_id); /* Compile copies graph state; caller owns the plan. Sample rate must be @@ -106,6 +108,9 @@ void xtc_plan_destroy(xtc_plan *plan); * Sums explicit output nodes; if absent, sums terminal nodes for compatibility * unless require_output_nodes is set. Empty graphs produce silence. * Unconnected gain/meter/output inputs receive external input. */ +/* Separate mono source buffers; NULL is silence. Existing wrappers omit MIDI. */ +xtc_result xtc_plan_process_sources(xtc_plan *plan, const float *input, const float *timeline, + const float *instrument, float *output, size_t frames); /* timeline is a separate mono input for XTC_NODE_AUDIO, NULL means silence. */ xtc_result xtc_plan_process_audio(xtc_plan *plan, const float *input, const float *timeline, float *output, size_t frames); diff --git a/include/xtc/xtc_midi.h b/include/xtc/xtc_midi.h new file mode 100644 index 0000000..c16fc1e --- /dev/null +++ b/include/xtc/xtc_midi.h @@ -0,0 +1,57 @@ +#ifndef XTC_MIDI_H +#define XTC_MIDI_H +#include "xtc_project.h" +#ifdef __cplusplus +extern "C" { +#endif +#define XTC_PROJECT_MAX_NOTES 4096U +#define XTC_MIDI_MAX_EVENTS 256U +#define XTC_MIDI_VOICES 32U +typedef struct { + uint64_t id, clip_id, start_tick, length_tick; /* Positions in clip source. */ + unsigned int pitch, velocity, channel; /* 0..127, 1..127, 0..15. */ +} xtc_note_info; +typedef enum { XTC_MIDI_NOTE_ON = 1, XTC_MIDI_NOTE_OFF, XTC_MIDI_ALL_OFF } xtc_midi_type; +typedef struct { + unsigned int offset; /* Sample offset within THIS block, strictly < frames. */ + xtc_midi_type type; + unsigned int channel, pitch, velocity; +} xtc_midi_event; +typedef struct xtc_midi_plan xtc_midi_plan; +/* Control-thread model APIs, stable note IDs; deletion cascades from clips/tracks. + * MIDI clips cannot reference audio. Creation transactionally adds an instrument + * source -> output route if no instrument exists. Other clip edits stay shared. */ +xtc_result xtc_project_add_midi_clip(xtc_project *project, uint64_t track, + uint64_t start, uint64_t length, uint64_t *out_id); +xtc_result xtc_project_note_count(const xtc_project *project, size_t *out_count); +xtc_result xtc_project_get_note(const xtc_project *project, size_t index, xtc_note_info *out_note); +xtc_result xtc_project_add_note(xtc_project *project, uint64_t clip, uint64_t start, + uint64_t length, unsigned int pitch, unsigned int velocity, unsigned int channel, uint64_t *out_id); +xtc_result xtc_project_edit_note(xtc_project *project, uint64_t id, uint64_t start, + uint64_t length, unsigned int pitch, unsigned int velocity, unsigned int channel); +xtc_result xtc_project_remove_note(xtc_project *project, uint64_t id); +/* Complete 3-byte note on/off and CC120/123 only. Velocity-zero on -> off. + * Unsupported messages return UNSUPPORTED; malformed supported messages INVALID. + * Output unchanged on error. No running-status/SysEx/pedal/program/bend support. */ +xtc_result xtc_midi_decode(const unsigned char *bytes, size_t size, unsigned int offset, + xtc_midi_event *out_event); +/* Control-thread compile; immutable ranges plus exclusively audio-owned runtime. + * Rejects >32 simultaneous scheduled notes. Separate 32 live voices steal oldest. + * Constant-tempo sine instrument, velocity *0.1, above-Nyquist notes silent. */ +xtc_result xtc_midi_plan_create(const xtc_project *project, unsigned int sample_rate, xtc_midi_plan **out_plan); +void xtc_midi_plan_destroy(xtc_midi_plan *plan); +/* Audio owner or stopped only. Reset clears live notes and panic suppression. + * Panic silences live notes and scheduled notes already started at position; + * later scheduled notes can start. MIDI CC120/123 affects its own channel. */ +void xtc_midi_plan_reset(xtc_midi_plan *plan); +void xtc_midi_plan_panic(xtc_midi_plan *plan, uint64_t position); +/* No allocation, I/O, locks or GUI. Events sorted by offset, equal offsets retain + * caller order, count<=256, frames<=65536. Invalid input leaves state/output alone. + * Renders [position,position+frames); discontinuity clears live voices, chases + * scheduled notes at their correct phase. Note-off is immediate (no envelope). */ +xtc_result xtc_midi_plan_render(xtc_midi_plan *plan, uint64_t position, + const xtc_midi_event *events, size_t count, float *output, size_t frames); +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index 88d4f48..021b534 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -18,6 +18,7 @@ typedef struct { uint64_t id, track_id, start_tick, length_tick, offset_tick, source_length_tick; char name[XTC_PROJECT_NAME_BYTES]; char asset_path[XTC_ASSET_PATH_BYTES]; + int midi; /* 0 silent/audio region, 1 MIDI clip; no asset path in MIDI. */ } xtc_clip_info; /* Single control-thread ownership. Names: nonempty UTF-8, <=63 bytes, no ASCII controls. * IDs are monotonic, never reused, UINT64_MAX reserved. Snapshots are copies. */ diff --git a/include/xtc/xtc_realtime.h b/include/xtc/xtc_realtime.h index 55037f6..802c8e7 100644 --- a/include/xtc/xtc_realtime.h +++ b/include/xtc/xtc_realtime.h @@ -31,6 +31,8 @@ xtc_result xtc_realtime_get_status(const xtc_realtime *engine, * 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_sources(xtc_realtime *engine, const float *input, const float *timeline, + const float *instrument, float *output, size_t frames); xtc_result xtc_realtime_process_audio(xtc_realtime *engine, const float *input, const float *timeline, float *output, size_t frames); xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, diff --git a/src/backend/jack_backend.c b/src/backend/jack_backend.c index fffad99..8b13ff7 100644 --- a/src/backend/jack_backend.c +++ b/src/backend/jack_backend.c @@ -5,15 +5,18 @@ #ifdef XTC_HAVE_JACK #include +#include #include struct xtc_backend { jack_client_t *client; - jack_port_t *input, *output; + jack_port_t *input, *output, *midi_input; xtc_realtime *engine; xtc_audio_plan *audio; + xtc_midi_plan *midi; + unsigned int panic_requested, panic_seen, midi_dropped; xtc_recorder *recorder; /* Borrowed, installed while callbacks quiescent. */ - float *timeline; + float *timeline, *instrument; size_t max_frames; uint64_t sample_position; unsigned int expected_rate; @@ -22,12 +25,46 @@ struct xtc_backend { int ready, running; /* Control thread only. */ }; +static xtc_result render_midi(xtc_backend *backend, uint64_t position, jack_nframes_t frames) +{ + xtc_midi_event events[XTC_MIDI_MAX_EVENTS]; + size_t count = 0; + uint32_t i, total; + unsigned int requested = __atomic_load_n(&backend->panic_requested, __ATOMIC_ACQUIRE); + void *buffer; + if (!backend->midi) return XTC_OK; + if (requested != backend->panic_seen) { + xtc_midi_plan_panic(backend->midi, position); backend->panic_seen = requested; + } + buffer = jack_port_get_buffer(backend->midi_input, frames); + total = buffer ? jack_midi_get_event_count(buffer) : 0; + if (!buffer || total > XTC_MIDI_MAX_EVENTS) { + (void)__atomic_fetch_add(&backend->midi_dropped, total ? total : 1U, __ATOMIC_RELAXED); + xtc_midi_plan_panic(backend->midi, position); + } else for (i = 0; i < total; ++i) { + jack_midi_event_t raw; + xtc_result result; + if (jack_midi_event_get(&raw, buffer, i) != 0 || raw.time >= frames) { + (void)__atomic_fetch_add(&backend->midi_dropped, 1U, __ATOMIC_RELAXED); + xtc_midi_plan_panic(backend->midi, position); count = 0; break; + } + result = xtc_midi_decode(raw.buffer, raw.size, (unsigned int)raw.time, events + count); + if (result == XTC_ERR_UNSUPPORTED) continue; + if (result != XTC_OK || (count && events[count].offset < events[count - 1].offset)) { + (void)__atomic_fetch_add(&backend->midi_dropped, 1U, __ATOMIC_RELAXED); + xtc_midi_plan_panic(backend->midi, position); count = 0; break; + } + ++count; + } + return xtc_midi_plan_render(backend->midi, position, events, count, backend->instrument, (size_t)frames); +} 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) { + (void)__atomic_fetch_add(&backend->panic_requested, 1U, __ATOMIC_RELEASE); if (backend->recorder) xtc_recorder_push(backend->recorder, NULL, 1U); __atomic_store_n(&backend->process_result, (unsigned int)XTC_ERR_IO, __ATOMIC_RELAXED); return 0; @@ -43,9 +80,11 @@ static int process(jack_nframes_t frames, void *context) if (backend->recorder) xtc_recorder_push(backend->recorder, input, (size_t)frames); if (backend->audio) result = xtc_audio_plan_render(backend->audio, position, backend->timeline, (size_t)frames); + if (result == XTC_OK) result = render_midi(backend, position, frames); if (result == XTC_OK) - result = xtc_realtime_process_audio(backend->engine, input, - backend->audio ? backend->timeline : NULL, output, (size_t)frames); + result = xtc_realtime_process_sources(backend->engine, input, + backend->audio ? backend->timeline : NULL, + backend->midi ? backend->instrument : NULL, output, (size_t)frames); } if (result == XTC_OK) { uint64_t position = __atomic_load_n(&backend->sample_position, __ATOMIC_RELAXED); @@ -53,6 +92,7 @@ static int process(jack_nframes_t frames, void *context) else __atomic_store_n(&backend->sample_position, position + frames, __ATOMIC_RELAXED); } if (result != XTC_OK) { + (void)__atomic_fetch_add(&backend->panic_requested, 1U, __ATOMIC_RELEASE); if (backend->recorder) xtc_recorder_push(backend->recorder, NULL, 1U); memset(output, 0, (size_t)frames * sizeof(*output)); } @@ -64,6 +104,13 @@ static void shutdown_callback(void *context) xtc_backend *backend = context; __atomic_store_n(&backend->lost, 1U, __ATOMIC_RELEASE); } +static void port_connection(jack_port_id_t source, jack_port_id_t destination, int connected, void *context) +{ + xtc_backend *backend = context; + if (!connected && (jack_port_by_id(backend->client, source) == backend->midi_input || + jack_port_by_id(backend->client, destination) == backend->midi_input)) + (void)__atomic_fetch_add(&backend->panic_requested, 1U, __ATOMIC_RELEASE); +} static int rate_callback(jack_nframes_t rate, void *context) { xtc_backend *backend = context; @@ -82,6 +129,7 @@ static int xrun_callback(void *context) { xtc_backend *backend = context; (void)__atomic_fetch_add(&backend->xruns, 1U, __ATOMIC_RELAXED); + (void)__atomic_fetch_add(&backend->panic_requested, 1U, __ATOMIC_RELEASE); return 0; } @@ -101,10 +149,13 @@ xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_ backend = calloc(1U, sizeof(*backend)); if (backend == NULL) return XTC_ERR_OUT_OF_MEMORY; backend->timeline = calloc(config->max_frames, sizeof(float)); - if (!backend->timeline) { free(backend); return XTC_ERR_OUT_OF_MEMORY; } + backend->instrument = calloc(config->max_frames, sizeof(float)); + if (!backend->timeline || !backend->instrument) { + free(backend->timeline); free(backend->instrument); free(backend); 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->timeline); free(backend); return XTC_ERR_IO; } + if (backend->client == NULL) { free(backend->instrument); free(backend->timeline); 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); @@ -113,8 +164,10 @@ xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out_ 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 || + backend->midi_input = jack_port_register(backend->client, "midi_in", JACK_DEFAULT_MIDI_TYPE, JackPortIsInput, 0U); + if (backend->input == NULL || backend->output == NULL || backend->midi_input == NULL) { result = XTC_ERR_IO; goto fail; } + if (jack_set_port_connect_callback(backend->client, port_connection, backend) != 0 || + 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) { @@ -134,6 +187,8 @@ fail: } xtc_realtime_destroy(backend->engine); xtc_audio_plan_destroy(backend->audio); + xtc_midi_plan_destroy(backend->midi); + free(backend->instrument); free(backend->timeline); free(backend); return result; @@ -146,6 +201,8 @@ xtc_result xtc_backend_close(xtc_backend *backend) if (jack_client_close(backend->client) != 0) return XTC_ERR_IO; xtc_realtime_destroy(backend->engine); xtc_audio_plan_destroy(backend->audio); + xtc_midi_plan_destroy(backend->midi); + free(backend->instrument); free(backend->timeline); free(backend); return XTC_OK; @@ -157,6 +214,8 @@ xtc_result xtc_backend_start(xtc_backend *backend) 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; + xtc_midi_plan_reset(backend->midi); + backend->panic_seen = __atomic_load_n(&backend->panic_requested, __ATOMIC_ACQUIRE); if (jack_activate(backend->client) != 0) return XTC_ERR_IO; backend->running = 1; return XTC_OK; @@ -165,9 +224,10 @@ 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 (!backend->running) { xtc_midi_plan_reset(backend->midi); return XTC_OK; } if (jack_deactivate(backend->client) != 0) return XTC_ERR_IO; backend->running = 0; + xtc_midi_plan_reset(backend->midi); xtc_realtime_collect(backend->engine); return XTC_OK; } @@ -177,6 +237,7 @@ xtc_result xtc_backend_seek(xtc_backend *backend, uint64_t sample) if (!backend->ready || backend->running || __atomic_load_n(&backend->lost, __ATOMIC_ACQUIRE) || __atomic_load_n(&backend->rate_changed, __ATOMIC_ACQUIRE)) return XTC_ERR_INVALID_STATE; + xtc_midi_plan_reset(backend->midi); __atomic_store_n(&backend->sample_position, sample, __ATOMIC_RELAXED); return XTC_OK; } @@ -204,13 +265,16 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project) { xtc_audio_plan *candidate = NULL; + xtc_midi_plan *midi = NULL; xtc_result result; if (!backend || !project) return XTC_ERR_INVALID_ARGUMENT; if (!backend->ready || backend->running) return XTC_ERR_INVALID_STATE; result = xtc_audio_plan_create(project, backend->expected_rate, &candidate); + if (result == XTC_OK) result = xtc_midi_plan_create(project, backend->expected_rate, &midi); if (result == XTC_OK) result = xtc_backend_submit(backend, xtc_project_graph(project)); - if (result != XTC_OK) { xtc_audio_plan_destroy(candidate); return result; } + if (result != XTC_OK) { xtc_audio_plan_destroy(candidate); xtc_midi_plan_destroy(midi); return result; } xtc_audio_plan_destroy(backend->audio); backend->audio = candidate; + xtc_midi_plan_destroy(backend->midi); backend->midi = midi; return XTC_OK; } const char *xtc_backend_asset_issue(const xtc_backend *backend) @@ -228,6 +292,20 @@ xtc_result xtc_backend_set_recorder(xtc_backend *backend, xtc_recorder *recorder backend->recorder = recorder; return XTC_OK; } +xtc_result xtc_backend_panic(xtc_backend *backend) +{ + if (!backend) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready) return XTC_ERR_INVALID_STATE; + (void)__atomic_fetch_add(&backend->panic_requested, 1U, __ATOMIC_RELEASE); + if (!backend->running) xtc_midi_plan_reset(backend->midi); + return XTC_OK; +} +xtc_result xtc_backend_midi_port_name(const xtc_backend *backend, const char **out) +{ + if (!backend || !out) return XTC_ERR_INVALID_ARGUMENT; + if (!backend->ready) return XTC_ERR_INVALID_STATE; + *out = jack_port_name(backend->midi_input); return XTC_OK; +} 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) @@ -236,6 +314,7 @@ xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_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->midi_dropped = __atomic_load_n(&backend->midi_dropped, __ATOMIC_RELAXED); out_status->asset_issues = (unsigned int)xtc_audio_plan_issues(backend->audio, NULL); out_status->sample_rate = __atomic_load_n(&backend->rate, __ATOMIC_RELAXED); out_status->buffer_frames = __atomic_load_n(&backend->frames, __ATOMIC_RELAXED); @@ -297,6 +376,10 @@ xtc_result xtc_backend_disconnect(xtc_backend *backend, xtc_port_direction direc { return route(backend, direction, remote, 1); } #else +xtc_result xtc_backend_panic(xtc_backend *backend) +{ (void)backend; return XTC_ERR_UNSUPPORTED; } +xtc_result xtc_backend_midi_port_name(const xtc_backend *backend, const char **out) +{ (void)backend; (void)out; return XTC_ERR_UNSUPPORTED; } xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project) { (void)backend; (void)project; return XTC_ERR_UNSUPPORTED; } const char *xtc_backend_asset_issue(const xtc_backend *backend) diff --git a/src/engine/offline_engine.c b/src/engine/offline_engine.c index 1b77384..c710be9 100644 --- a/src/engine/offline_engine.c +++ b/src/engine/offline_engine.c @@ -109,7 +109,7 @@ xtc_result xtc_plan_meter_peak(const xtc_plan *plan, size_t node_id, float *out_ return XTC_OK; } -xtc_result xtc_plan_process_audio(xtc_plan *plan, const float *input, const float *timeline, float *output, +xtc_result xtc_plan_process_sources(xtc_plan *plan, const float *input, const float *timeline, const float *instrument, float *output, size_t frame_count) { size_t index, frame, edge; @@ -126,7 +126,9 @@ xtc_result xtc_plan_process_audio(xtc_plan *plan, const float *input, const floa double phase = plan->phases[node]; for (frame = 0U; frame < frame_count; ++frame) { float sample = 0.0f; - if (spec.type == XTC_NODE_AUDIO) { + if (spec.type == XTC_NODE_INSTRUMENT) { + sample = instrument ? instrument[frame] : 0.0f; + } else if (spec.type == XTC_NODE_AUDIO) { sample = timeline ? timeline[frame] : 0.0f; } else if (spec.type == XTC_NODE_OSCILLATOR) { sample = (float)sin(XTC_TAU * phase); @@ -172,3 +174,6 @@ xtc_result xtc_graph_process_offline(xtc_graph *graph, const float *input, xtc_result xtc_plan_process(xtc_plan *plan, const float *input, float *output, size_t frames) { return xtc_plan_process_audio(plan, input, NULL, output, frames); } + +xtc_result xtc_plan_process_audio(xtc_plan *plan, const float *input, const float *timeline, float *output, size_t frames) +{ return xtc_plan_process_sources(plan, input, timeline, NULL, output, frames); } diff --git a/src/engine/realtime.c b/src/engine/realtime.c index c7f7433..1bd4072 100644 --- a/src/engine/realtime.c +++ b/src/engine/realtime.c @@ -80,7 +80,7 @@ xtc_result xtc_realtime_get_status(const xtc_realtime *engine, return XTC_OK; } -xtc_result xtc_realtime_process_audio(xtc_realtime *engine, const float *input, const float *timeline, +xtc_result xtc_realtime_process_sources(xtc_realtime *engine, const float *input, const float *timeline, const float *instrument, float *output, size_t frame_count) { xtc_result result = XTC_OK; @@ -107,7 +107,7 @@ xtc_result xtc_realtime_process_audio(xtc_realtime *engine, const float *input, } } if (engine->active != NULL) - result = xtc_plan_process_audio(engine->active, input, timeline, output, frame_count); + result = xtc_plan_process_sources(engine->active, input, timeline, instrument, output, frame_count); else memset(output, 0, frame_count * sizeof(*output)); } @@ -120,3 +120,6 @@ xtc_result xtc_realtime_process_audio(xtc_realtime *engine, const float *input, xtc_result xtc_realtime_process(xtc_realtime *engine, const float *input, float *output, size_t frames) { return xtc_realtime_process_audio(engine, input, NULL, output, frames); } + +xtc_result xtc_realtime_process_audio(xtc_realtime *engine, const float *input, const float *timeline, float *output, size_t frames) +{ return xtc_realtime_process_sources(engine, input, timeline, NULL, output, frames); } diff --git a/src/graph/graph.c b/src/graph/graph.c index 6ba5a32..3f07f70 100644 --- a/src/graph/graph.c +++ b/src/graph/graph.c @@ -59,6 +59,8 @@ xtc_result xtc_graph_add_oscillator(xtc_graph *graph, float frequency, size_t *o } xtc_result xtc_graph_add_meter(xtc_graph *graph, size_t *out_node_id) { return add_node(graph, XTC_NODE_METER, 0.0f, out_node_id); } +xtc_result xtc_graph_add_instrument(xtc_graph *graph, size_t *out_node_id) +{ return add_node(graph, XTC_NODE_INSTRUMENT, 0.0f, out_node_id); } xtc_result xtc_graph_add_audio(xtc_graph *graph, size_t *out_node_id) { return add_node(graph, XTC_NODE_AUDIO, 0.0f, out_node_id); } xtc_result xtc_graph_add_output(xtc_graph *graph, size_t *out_node_id) @@ -72,7 +74,7 @@ xtc_result xtc_graph_get_port(const xtc_graph *graph, size_t node_id, node_id = node_index(graph, node_id); if (node_id == SIZE_MAX) return XTC_ERR_NOT_FOUND; type = graph->nodes[node_id].type; - if (port_id > 1U || (port_id == 0U && (type == XTC_NODE_OSCILLATOR || type == XTC_NODE_AUDIO)) || + if (port_id > 1U || (port_id == 0U && (type == XTC_NODE_OSCILLATOR || type == XTC_NODE_AUDIO || type == XTC_NODE_INSTRUMENT)) || (port_id == 1U && type == XTC_NODE_OUTPUT)) return XTC_ERR_NOT_FOUND; out_port->direction = port_id == 0U ? XTC_PORT_INPUT : XTC_PORT_OUTPUT; out_port->signal_type = XTC_SIGNAL_AUDIO; @@ -228,6 +230,7 @@ xtc_result xtc_graph_apply_edit(const xtc_graph *source, const xtc_graph_edit *e case XTC_NODE_GAIN: result = xtc_graph_add_gain(graph, edit->data.add.value, &added_id); break; case XTC_NODE_OSCILLATOR: result = xtc_graph_add_oscillator(graph, edit->data.add.value, &added_id); break; case XTC_NODE_METER: result = xtc_graph_add_meter(graph, &added_id); break; + case XTC_NODE_INSTRUMENT: result = xtc_graph_add_instrument(graph, &added_id); break; case XTC_NODE_AUDIO: result = xtc_graph_add_audio(graph, &added_id); break; case XTC_NODE_OUTPUT: result = xtc_graph_add_output(graph, &added_id); break; default: result = XTC_ERR_INVALID_ARGUMENT; break; diff --git a/src/gui/graph_widget.cpp b/src/gui/graph_widget.cpp index d6d63ed..1dd2658 100644 --- a/src/gui/graph_widget.cpp +++ b/src/gui/graph_widget.cpp @@ -14,6 +14,7 @@ const char *node_name(xtc_node_type type) case XTC_NODE_OSCILLATOR: return "Oscillator"; case XTC_NODE_GAIN: return "Gain"; case XTC_NODE_METER: return "Meter"; + case XTC_NODE_INSTRUMENT: return "Instrument"; case XTC_NODE_AUDIO: return "Audio clips"; case XTC_NODE_OUTPUT: return "Output"; } @@ -187,7 +188,7 @@ void GraphWidget::draw() fl_font(FL_HELVETICA, 12); if (node.type == XTC_NODE_OSCILLATOR) std::snprintf(text, sizeof(text), "%.0f Hz", static_cast(node.value)); else if (node.type == XTC_NODE_GAIN) std::snprintf(text, sizeof(text), "x %.2f", static_cast(node.value)); - else std::snprintf(text, sizeof(text), "%s", node.type == XTC_NODE_METER ? "Peak pass-through" : node.type == XTC_NODE_AUDIO ? "Timeline mix source" : "Mono audio output"); + else std::snprintf(text, sizeof(text), "%s", node.type == XTC_NODE_METER ? "Peak pass-through" : node.type == XTC_NODE_INSTRUMENT ? "MIDI sine source" : node.type == XTC_NODE_AUDIO ? "Timeline mix source" : "Mono audio output"); fl_draw(text, left + 10, top + 37); for (size_t port = 0U; port < 2U; ++port) { int px, py; diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 28da98d..61a6e41 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -1,6 +1,7 @@ #include "main_window.hpp" #include "graph_widget.hpp" #include "sequencer_widget.hpp" +#include "piano_roll.hpp" #include #include "xtc/xtc_version.h" @@ -40,6 +41,8 @@ MainWindow::MainWindow(bool prompt_unsaved) menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this); menu->add("File/Import WAV", FL_CTRL + 'i', audio_file_callback, this); menu->add("File/Relink selected audio", 0, audio_file_callback, this); + menu->add("Audio/Panic", FL_F + 9, panic_callback, this); + menu->add("View/Piano roll", FL_CTRL + '4', piano_callback, this); menu->add("Audio/Record WAV", FL_SHIFT + FL_F + 5, audio_file_callback, this); menu->add("File/Quit", FL_CTRL + 'q', close_callback, this); menu->add("Audio/Start engine", FL_F + 5, play_callback, this); @@ -58,7 +61,10 @@ MainWindow::MainWindow(bool prompt_unsaved) record_ = new Fl_Button(306, 40, 90, 30, "Record"); record_->callback(audio_file_callback, this); record_->tooltip("Record JACK input to WAV; choose path, then Stop to finalize and add the take."); - indicator_ = new Fl_Box(404, 40, 584, 30, "Audio: disconnected"); + auto *panic = new Fl_Button(404, 40, 70, 30, "Panic"); + panic->callback(panic_callback, this); + panic->tooltip("Silence held instrument notes (F9)."); + indicator_ = new Fl_Box(482, 40, 506, 30, "Audio: disconnected"); indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); indicator_->labelfont(FL_BOLD); auto *hint = new Fl_Box(12, 78, 976, 24, @@ -75,6 +81,9 @@ MainWindow::MainWindow(bool prompt_unsaved) auto *delete_region = new Fl_Button(246, 150, 130, 26, "Delete region/track"); auto *zoom_in = new Fl_Button(384, 150, 80, 26, "Zoom +"); auto *zoom_out = new Fl_Button(472, 150, 80, 26, "Zoom -"); + auto *add_midi = new Fl_Button(560, 150, 110, 26, "Add MIDI clip"); + auto *piano = new Fl_Button(678, 150, 100, 26, "Piano roll"); + piano->callback(piano_callback, this); auto *timeline_hint = new Fl_Box(20, 548, 956, 28, "Import WAV (Ctrl+I) or Record. Drag body to move, edges to trim; ruler seeks while stopped."); timeline_hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); timeline_hint->labelsize(12); @@ -84,6 +93,7 @@ MainWindow::MainWindow(bool prompt_unsaved) sequencer_->before_edit = before_arrangement_edit; sequencer_->callback([](Fl_Widget *, void *c) { static_cast(c)->mark_dirty(); }, this); add_track->callback([](Fl_Widget *, void *c) { static_cast(c)->add_track(); }, sequencer_); + add_midi->callback([](Fl_Widget *, void *c) { static_cast(c)->add_midi_clip(); }, sequencer_); add_region->callback([](Fl_Widget *, void *c) { static_cast(c)->add_region(); }, sequencer_); delete_region->callback([](Fl_Widget *, void *c) { static_cast(c)->delete_selection(); }, sequencer_); zoom_in->callback([](Fl_Widget *, void *c) { static_cast(c)->zoom(1); }, sequencer_); @@ -126,6 +136,8 @@ MainWindow::MainWindow(bool prompt_unsaved) audio->callback([](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_AUDIO); }, graph_widget_); + auto *instrument = new Fl_Button(788, 150, 130, 26, "Add instrument"); + instrument->callback([](Fl_Widget *, void *c) { (void)static_cast(c)->add_node(XTC_NODE_INSTRUMENT); }, graph_widget_); auto *remove = new Fl_Button(512, 150, 130, 26, "Delete selection"); remove->callback([](Fl_Widget *, void *context) { (void)static_cast(context)->delete_selected(); @@ -299,9 +311,9 @@ void MainWindow::refresh_status() indicator_->labelcolor(fault ? fl_rgb_color(255, 155, 120) : running_ ? fl_rgb_color(125, 225, 160) : fl_rgb_color(215, 215, 215)); char text[256]; - std::snprintf(text, sizeof(text), "JACK | %u Hz | %u frames | mono | %u blocks | %u xruns | %s | %u asset issues", + std::snprintf(text, sizeof(text), "JACK | %u Hz | %u frames | mono | %u blocks | %u xruns | %s | %u asset issues | %u MIDI drops", status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns, - xtc_result_string(status.process_result), status.asset_issues); + xtc_result_string(status.process_result), status.asset_issues, status.midi_dropped); set_label(status_, text); if (fault) set_label(graph_state_, "Graph: engine unavailable — Reconnect required"); else if (status.submitted_revision != status.active_revision) @@ -330,6 +342,7 @@ bool MainWindow::close_window() running_ = false; closing_ = true; Fl::remove_timeout(timer_callback, this); + if (piano_window_) piano_window_->hide(); hide(); return true; } @@ -359,7 +372,7 @@ void MainWindow::view_callback(Fl_Widget *widget, void *view) } void MainWindow::about_callback(Fl_Widget *, void *) { - fl_message("%s %s\nModular audio workstation\nPhase 6: WAV import, graph clip playback and bounded recording; MIDI follows.", + fl_message("%s %s\nModular audio workstation\nPhase 7: MIDI input, sine instrument, persisted notes and piano roll.", XTC_PROJECT_NAME, XTC_VERSION_STRING); } void MainWindow::timer_callback(void *context) @@ -378,7 +391,11 @@ void MainWindow::timer_callback(void *context) void MainWindow::mark_dirty() -{ dirty_ = true; copy_label("xtc * — unsaved changes"); } +{ + graph_widget_->set_graph(xtc_project_graph(project_.get())); + if (piano_) piano_->set_project(project_.get(), piano_->clip()); + dirty_ = true; copy_label("xtc * — unsaved changes"); +} bool MainWindow::discard_changes() { return !dirty_ || !prompt_unsaved_ || @@ -414,13 +431,18 @@ xtc_result MainWindow::install_project(xtc_project *candidate) } xtc_audio_plan *offline = nullptr; if (r == XTC_OK && !ready_) r = xtc_audio_plan_create(candidate, 48000, &offline); - if (r != XTC_OK) return r; + xtc_midi_plan *offline_midi = nullptr; + if (r == XTC_OK && !ready_) r = xtc_midi_plan_create(candidate, 48000, &offline_midi); + xtc_midi_plan_destroy(offline_midi); + if (r != XTC_OK) { xtc_audio_plan_destroy(offline); return r; } std::string issue = ready_ ? xtc_backend_asset_issue(backend_) : ""; if (offline) { const char *path = ""; xtc_audio_plan_issues(offline, &path); issue = path; xtc_audio_plan_destroy(offline); } + if (piano_window_) piano_window_->hide(); + if (piano_) piano_->set_project(candidate, UINT64_MAX); project_.reset(candidate); graph_widget_->reset_layout(); graph_widget_->set_graph(xtc_project_graph(candidate)); @@ -577,3 +599,38 @@ void MainWindow::audio_file_callback(Fl_Widget *widget, void *context) } else if (r == XTC_OK) r = recording ? window->record_audio(chooser.filename()) : window->import_audio(chooser.filename()); if (r != XTC_OK) window->report_error("Project audio operation failed", r); } + + +xtc_result MainWindow::open_piano_roll() +{ + xtc_clip_info clip{}; + if (xtc_project_find_clip(project_.get(), sequencer_->selected_clip(), &clip) != XTC_OK || !clip.midi) + return XTC_ERR_INVALID_STATE; + if (!piano_window_) { + piano_window_ = std::make_unique(900, 620, "xtc — Piano roll"); + piano_window_->begin(); + auto *hint = new Fl_Box(12, 8, 876, 32, + "Source ticks: click draws; drag body moves/pitches, right edge resizes; right-click/Delete removes. Snap 1/16."); + hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); hint->labelsize(12); + auto *scroll = new Fl_Scroll(12, 48, 876, 560); + piano_ = new PianoRollWidget(12, 48, project_.get(), clip.id, before_arrangement_edit, this); + piano_->callback([](Fl_Widget *, void *c) { static_cast(c)->mark_dirty(); }, this); + scroll->end(); piano_window_->end(); piano_window_->resizable(scroll); + scroll->scroll_to(0, 60 * 16); + } else piano_->set_project(project_.get(), clip.id); + piano_window_->show(); return XTC_OK; +} +void MainWindow::piano_callback(Fl_Widget *, void *context) +{ + auto *window = static_cast(context); + if (window->recorder_) { window->report_error("Stop recording before editing MIDI", XTC_ERR_INVALID_STATE); return; } + xtc_result r = window->open_piano_roll(); + if (r != XTC_OK) set_label(window->error_, "Select a MIDI clip, then open Piano roll."); +} +void MainWindow::panic_callback(Fl_Widget *, void *context) +{ + auto *window = static_cast(context); + if (!window->ready_) return; + xtc_result r = xtc_backend_panic(window->backend_); + if (r != XTC_OK) window->report_error("Panic failed", r); +} diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index a037050..a547671 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -8,6 +8,7 @@ #include "xtc/xtc_backend.h" class GraphWidget; +class PianoRollWidget; class SequencerWidget; class Fl_Box; class Fl_Button; @@ -22,12 +23,18 @@ public: using Fl_Double_Window::show; int handle(int event) override; + xtc_result open_piano_roll(); + PianoRollWidget *piano_roll() const { return piano_; } xtc_result import_audio(const char *path); xtc_result record_audio(const char *path); xtc_result save_project(const char *path); xtc_result open_project(const char *path); private: std::unique_ptr project_{nullptr, xtc_project_destroy}; + std::unique_ptr piano_window_; + PianoRollWidget *piano_ = nullptr; + static void piano_callback(Fl_Widget *, void *context); + static void panic_callback(Fl_Widget *, void *context); SequencerWidget *sequencer_ = nullptr; xtc_recorder *recorder_ = nullptr; std::string recording_path_; diff --git a/src/gui/piano_roll.cpp b/src/gui/piano_roll.cpp new file mode 100644 index 0000000..6b6abf2 --- /dev/null +++ b/src/gui/piano_roll.cpp @@ -0,0 +1,138 @@ +#include "piano_roll.hpp" +#include +#include +#include +#include +#include +namespace { constexpr int key_width = 64, row_height = 16, scale = 96, header = 28; } +PianoRollWidget::PianoRollWidget(int left, int top, xtc_project *project, uint64_t clip, BeforeEdit before, void *context) + : Fl_Widget(left, top, 900, header + 128 * row_height), project_(project), clip_(clip), before_(before), context_(context) +{ set_project(project, clip); } +void PianoRollWidget::set_project(xtc_project *project, uint64_t clip) +{ + if (project != project_ || clip != clip_) mode_ = 0; + project_ = project; clip_ = clip; + xtc_clip_info c{}; + int width = xtc_project_find_clip(project_, clip_, &c) == XTC_OK ? + static_cast(c.source_length_tick * scale / 960) + key_width + 20 : 900; + size(std::max(900, width), header + 128 * row_height); + redraw(); +} +uint64_t PianoRollWidget::tick(int px) const +{ return static_cast(std::max(0, px - x() - key_width)) * 960U / scale / 240U * 240U; } +xtc_result PianoRollWidget::change(xtc_result result) +{ + if (result == XTC_OK) { do_callback(); redraw(); } + else fl_alert("Note unchanged: %s", xtc_result_string(result)); + return result; +} +xtc_result PianoRollWidget::add_note(uint64_t start, uint64_t length, unsigned int pitch, + unsigned int velocity, unsigned int channel) +{ + xtc_result r = before_ ? before_(context_) : XTC_OK; + uint64_t id; + if (r == XTC_OK) r = xtc_project_add_note(project_, clip_, start, length, pitch, velocity, channel, &id); + if (r == XTC_OK) selected_ = id; + return change(r); +} +xtc_result PianoRollWidget::delete_selected() +{ + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_remove_note(project_, selected_); + if (r == XTC_OK) selected_ = UINT64_MAX; + return change(r); +} +void PianoRollWidget::draw() +{ + fl_push_clip(x(), y(), w(), h()); + fl_color(fl_rgb_color(30, 33, 38)); fl_rectf(x(), y(), w(), h()); + xtc_clip_info clip{}; + if (xtc_project_find_clip(project_, clip_, &clip) != XTC_OK || !clip.midi) { fl_pop_clip(); return; } + int cx, cy, cw, ch; fl_clip_box(x(), y(), w(), h(), cx, cy, cw, ch); + fl_font(FL_HELVETICA, 11); + for (int pitch = 0; pitch < 128; ++pitch) { + int py = y() + header + (127 - pitch) * row_height; + if (py + row_height < cy || py > cy + ch) continue; + int pc = pitch % 12; + bool black = pc == 1 || pc == 3 || pc == 6 || pc == 8 || pc == 10; + fl_color(black ? fl_rgb_color(29, 31, 35) : fl_rgb_color(43, 46, 52)); + fl_rectf(cx, py, cw, row_height - 1); + fl_color(black ? fl_rgb_color(60, 63, 70) : fl_rgb_color(130, 135, 140)); + fl_rectf(x(), py, key_width - 2, row_height - 1); + if (!pc) { + char text[20]; std::snprintf(text, sizeof(text), "C%d", pitch / 12 - 1); + fl_color(FL_WHITE); fl_draw(text, x() + 6, py + 12); + } + } + int first = std::max(0, (cx - x() - key_width) / (scale / 4)); + int last = (cx + cw - x() - key_width) / (scale / 4) + 1; + for (int step = first; step <= last; ++step) { + int px = x() + key_width + step * (scale / 4); + fl_color(step % 16 == 0 ? fl_rgb_color(95, 98, 105) : fl_rgb_color(52, 55, 62)); + fl_line(px, cy, px, cy + ch); + } + size_t count = 0; xtc_project_note_count(project_, &count); + for (size_t i = 0; i < count; ++i) { + xtc_note_info n{}; xtc_project_get_note(project_, i, &n); + if (n.clip_id != clip_) continue; + int left = x() + key_width + static_cast(n.start_tick * scale / 960); + int right = left + std::max(3, static_cast(n.length_tick * scale / 960)); + int py = y() + header + (127 - static_cast(n.pitch)) * row_height; + int a = std::max(cx, left), b = std::min(cx + cw, right); + if (a >= b || py + row_height < cy || py > cy + ch) continue; + fl_color(n.id == selected_ ? fl_rgb_color(230, 190, 80) : fl_rgb_color(90, 175, 130)); + fl_rectf(a, py + 1, b - a, row_height - 3); + } + fl_pop_clip(); +} +int PianoRollWidget::handle(int event) +{ + if (event == FL_PUSH) { + take_focus(); mode_ = 0; + size_t count = 0; xtc_project_note_count(project_, &count); + selected_ = UINT64_MAX; + for (size_t i = count; i > 0; --i) { + xtc_note_info n{}; xtc_project_get_note(project_, i - 1, &n); + int left = x() + key_width + static_cast(n.start_tick * scale / 960); + int right = left + std::max(3, static_cast(n.length_tick * scale / 960)); + int top = y() + header + (127 - static_cast(n.pitch)) * row_height; + if (n.clip_id == clip_ && Fl::event_x() >= left && Fl::event_x() < right && + Fl::event_y() >= top && Fl::event_y() < top + row_height) { + selected_ = n.id; original_ = n; drag_x_ = Fl::event_x(); drag_y_ = Fl::event_y(); + mode_ = right - Fl::event_x() <= 6 ? 2 : 1; break; + } + } + if (selected_ != UINT64_MAX && Fl::event_button() == FL_RIGHT_MOUSE) { delete_selected(); mode_ = 0; } + else if (Fl::event_button() == FL_LEFT_MOUSE && selected_ == UINT64_MAX && + Fl::event_x() >= x() + key_width && Fl::event_y() >= y() + header) { + int pitch = 127 - (Fl::event_y() - y() - header) / row_height; + xtc_clip_info c{}; uint64_t start = tick(Fl::event_x()); + if (pitch >= 0 && pitch <= 127 && xtc_project_find_clip(project_, clip_, &c) == XTC_OK && start < c.source_length_tick) + add_note(start, std::min(960, c.source_length_tick - start), static_cast(pitch)); + } + redraw(); return 1; + } + if (event == FL_DRAG) return mode_ != 0; + if (event == FL_RELEASE && mode_) { + int mode = mode_; mode_ = 0; + int64_t delta = static_cast(Fl::event_x() - drag_x_) * 960 / scale / 240 * 240; + xtc_clip_info c{}; if (xtc_project_find_clip(project_, clip_, &c) != XTC_OK) return 1; + int64_t start = static_cast(original_.start_tick), length = static_cast(original_.length_tick); + unsigned int pitch = original_.pitch; + if (mode == 1) { + start = std::clamp(start + delta, 0, static_cast(c.source_length_tick) - length); + pitch = static_cast(std::clamp(static_cast(pitch) - (Fl::event_y() - drag_y_) / row_height, 0, 127)); + } else { + int64_t maximum = static_cast(c.source_length_tick) - start; + length = std::clamp(length + delta, std::min(240, maximum), maximum); + } + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_edit_note(project_, selected_, static_cast(start), + static_cast(length), pitch, original_.velocity, original_.channel); + change(r); return 1; + } + if (event == FL_KEYDOWN && Fl::event_key() == FL_Delete) { delete_selected(); return 1; } + if (event == FL_KEYDOWN && Fl::event_key() == FL_Escape) { mode_ = 0; return 1; } + if (event == FL_FOCUS || event == FL_UNFOCUS) return 1; + return Fl_Widget::handle(event); +} diff --git a/src/gui/piano_roll.hpp b/src/gui/piano_roll.hpp new file mode 100644 index 0000000..bd072e0 --- /dev/null +++ b/src/gui/piano_roll.hpp @@ -0,0 +1,27 @@ +#ifndef XTC_PIANO_ROLL_HPP +#define XTC_PIANO_ROLL_HPP +#include +#include "xtc/xtc_midi.h" +class PianoRollWidget : public Fl_Widget { +public: + using BeforeEdit = xtc_result (*)(void *); + PianoRollWidget(int x, int y, xtc_project *project, uint64_t clip, BeforeEdit before, void *context); + void set_project(xtc_project *project, uint64_t clip); + uint64_t clip() const { return clip_; } + xtc_result add_note(uint64_t start, uint64_t length, unsigned int pitch, + unsigned int velocity = 100, unsigned int channel = 0); + xtc_result delete_selected(); + int handle(int event) override; +protected: + void draw() override; +private: + xtc_project *project_; + uint64_t clip_, selected_ = UINT64_MAX; + BeforeEdit before_; + void *context_; + xtc_note_info original_{}; + int mode_ = 0, drag_x_ = 0, drag_y_ = 0; + xtc_result change(xtc_result result); + uint64_t tick(int px) const; +}; +#endif diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 2347cb2..e3d8284 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -1,4 +1,5 @@ #include "sequencer_widget.hpp" +#include "xtc/xtc_midi.h" #include #include #include @@ -55,6 +56,20 @@ xtc_result SequencerWidget::add_region() if (r == XTC_OK) clip_ = id; changed(r); return r; } +xtc_result SequencerWidget::add_midi_clip() +{ + xtc_result r = before_edit ? before_edit(context_) : XTC_OK; + uint64_t id; + if (r == XTC_OK) { + if (track_ == UINT64_MAX) { + xtc_track_info t{}; + r = xtc_project_get_track(project_, 0, &t); track_ = r == XTC_OK ? t.id : UINT64_MAX; + } + if (r == XTC_OK) r = xtc_project_add_midi_clip(project_, track_, cursor_, 3840, &id); + if (r == XTC_OK) clip_ = id; + } + changed(r); return r; +} xtc_result SequencerWidget::delete_selection() { if (before_edit) { xtc_result r = before_edit(context_); if (r != XTC_OK) { changed(r); return r; } } @@ -99,7 +114,7 @@ void SequencerWidget::draw() fl_color(c.id == clip_ ? fl_rgb_color(100, 170, 205) : fl_rgb_color(65, 120, 150)); fl_rectf(a, py + 5, b - a, 44); fl_push_clip(a, py + 5, b - a, 44); fl_color(FL_WHITE); - fl_draw(c.asset_path[0] ? "Audio" : c.name, std::max(a, left + 6), py + 31); fl_pop_clip(); + fl_draw(c.midi ? "MIDI" : c.asset_path[0] ? "Audio" : c.name, std::max(a, left + 6), py + 31); fl_pop_clip(); } } int playhead = x() + 120 + static_cast(cursor_ * static_cast(scale_) / 960U); diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 9411398..23eb961 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -15,6 +15,7 @@ public: void position(uint64_t tick) { cursor_ = tick; redraw(); } xtc_result add_track(); xtc_result add_region(); + xtc_result add_midi_clip(); xtc_result delete_selection(); void zoom(int direction); xtc_project *project() const { return project_; } diff --git a/src/midi/README.md b/src/midi/README.md index 1fd840f..e1296b1 100644 --- a/src/midi/README.md +++ b/src/midi/README.md @@ -1,5 +1,7 @@ # MIDI and events -MIDI input, MIDI clips, event scheduling, panic behavior, and device handling -are not implemented yet. Define a bounded event representation and explicit -sample-offset semantics before adding real-time event delivery. +`model.c` implements bounded MIDI clips and stable source-relative notes through +`xtc_midi.h`. `instrument.c` prepares timeline spans and renders the sine +instrument with sample-offset live events, note-off and panic handling. +Preparation/destruction are control-thread operations; render/runtime state has +one audio owner. See [MIDI workflow, ownership and limits](../../docs/MIDI.md). diff --git a/src/midi/instrument.c b/src/midi/instrument.c new file mode 100644 index 0000000..501dc3c --- /dev/null +++ b/src/midi/instrument.c @@ -0,0 +1,173 @@ +#include "xtc/xtc_midi.h" +#include "../project/project_internal.h" +#include +#include +#include +#define TAU 6.28318530717958647692 +typedef struct { uint64_t start, end, age; unsigned int pitch, velocity, channel; } span; +typedef struct { uint64_t time; int change; } endpoint; +typedef struct { unsigned int active, channel, pitch, velocity; uint64_t age; double phase; } voice; +struct xtc_midi_plan { + span notes[XTC_PROJECT_MAX_NOTES]; + size_t count; + voice voices[XTC_MIDI_VOICES]; + double step[128]; + uint64_t cutoff[16], next_position, age; +}; +xtc_result xtc_midi_decode(const unsigned char *bytes, size_t size, unsigned int offset, xtc_midi_event *out) +{ + xtc_midi_event e; + unsigned int command; + if (!bytes || !size || !out) return XTC_ERR_INVALID_ARGUMENT; + command = bytes[0] & 240U; + if (command != 128 && command != 144 && command != 176) return XTC_ERR_UNSUPPORTED; + if (size != 3 || bytes[1] > 127 || bytes[2] > 127) return XTC_ERR_INVALID_ARGUMENT; + if (command == 176 && bytes[1] != 120 && bytes[1] != 123) return XTC_ERR_UNSUPPORTED; + e.offset = offset; e.channel = bytes[0] & 15U; e.pitch = bytes[1]; e.velocity = bytes[2]; + e.type = command == 176 ? XTC_MIDI_ALL_OFF : + command == 128 || !e.velocity ? XTC_MIDI_NOTE_OFF : XTC_MIDI_NOTE_ON; + *out = e; return XTC_OK; +} +static int compare_endpoint(const void *left, const void *right) +{ + const endpoint *a = left, *b = right; + if (a->time != b->time) return a->time < b->time ? -1 : 1; + return a->change - b->change; /* End before start at the same sample. */ +} +void xtc_midi_plan_reset(xtc_midi_plan *p) +{ + if (!p) return; + memset(p->voices, 0, sizeof(p->voices)); memset(p->cutoff, 0, sizeof(p->cutoff)); + p->next_position = UINT64_MAX; p->age = 0; +} +xtc_result xtc_midi_plan_create(const xtc_project *project, unsigned int rate, xtc_midi_plan **out) +{ + xtc_midi_plan *p; + endpoint *points; + size_t i; + int active = 0; + if (!out) return XTC_ERR_INVALID_ARGUMENT; + *out = NULL; + if (!project || !rate || rate > 768000) return XTC_ERR_INVALID_ARGUMENT; + p = calloc(1, sizeof(*p)); points = calloc(XTC_PROJECT_MAX_NOTES * 2U, sizeof(*points)); + if (!p || !points) { free(p); free(points); return XTC_ERR_OUT_OF_MEMORY; } + for (i = 0; i < 128; ++i) { + double frequency = 440.0 * pow(2.0, ((double)i - 69.0) / 12.0); + p->step[i] = frequency < rate * 0.5 ? frequency / rate : 0; + } + for (i = 0; i < project->note_count; ++i) { + const xtc_note_info *n = project->notes + i; + xtc_clip_info c; + uint64_t begin, end; + span *s = p->notes + p->count; + xtc_result r = xtc_project_find_clip(project, n->clip_id, &c); + if (r != XTC_OK) { free(points); free(p); return XTC_ERR_INVALID_STATE; } + begin = n->start_tick > c.offset_tick ? n->start_tick : c.offset_tick; + end = n->start_tick + n->length_tick < c.offset_tick + c.length_tick ? + n->start_tick + n->length_tick : c.offset_tick + c.length_tick; + if (end <= begin) continue; + r = xtc_project_tick_to_sample(project, c.start_tick + begin - c.offset_tick, rate, &s->start); + if (r == XTC_OK) r = xtc_project_tick_to_sample(project, c.start_tick + end - c.offset_tick, rate, &s->end); + if (r == XTC_OK) r = xtc_project_tick_to_sample(project, begin - n->start_tick, rate, &s->age); + if (r != XTC_OK) { free(points); free(p); return r; } + if (s->end <= s->start) continue; + s->pitch = n->pitch; s->velocity = n->velocity; s->channel = n->channel; + points[p->count * 2].time = s->start; points[p->count * 2].change = 1; + points[p->count * 2 + 1].time = s->end; points[p->count * 2 + 1].change = -1; + ++p->count; + } + qsort(points, p->count * 2, sizeof(*points), compare_endpoint); + for (i = 0; i < p->count * 2; ++i) { + active += points[i].change; + if (active > (int)XTC_MIDI_VOICES) { free(points); free(p); return XTC_ERR_CAPACITY; } + } + free(points); xtc_midi_plan_reset(p); *out = p; return XTC_OK; +} +void xtc_midi_plan_destroy(xtc_midi_plan *p) { free(p); } +static void channel_off(xtc_midi_plan *p, unsigned int channel, uint64_t position) +{ + size_t i; + for (i = 0; i < XTC_MIDI_VOICES; ++i) + if (p->voices[i].channel == channel) p->voices[i].active = 0; + p->cutoff[channel] = position == UINT64_MAX ? position : position + 1; +} +void xtc_midi_plan_panic(xtc_midi_plan *p, uint64_t position) +{ + unsigned int c; + if (!p) return; + for (c = 0; c < 16; ++c) channel_off(p, c, position); + p->next_position = position; +} +static void event(xtc_midi_plan *p, const xtc_midi_event *e, uint64_t position) +{ + size_t i, slot = SIZE_MAX; + if (e->type == XTC_MIDI_ALL_OFF) { channel_off(p, e->channel, position); return; } + for (i = 0; i < XTC_MIDI_VOICES; ++i) { + if (p->voices[i].active && p->voices[i].channel == e->channel && p->voices[i].pitch == e->pitch) { + slot = i; break; + } + } + if (e->type == XTC_MIDI_NOTE_OFF) { + if (slot != SIZE_MAX) p->voices[slot].active = 0; + return; + } + if (slot == SIZE_MAX) { + for (i = 0; i < XTC_MIDI_VOICES; ++i) if (!p->voices[i].active) { slot = i; break; } + } + if (slot == SIZE_MAX) { + slot = 0; + for (i = 1; i < XTC_MIDI_VOICES; ++i) if (p->voices[i].age < p->voices[slot].age) slot = i; + } + p->voices[slot].active = 1; p->voices[slot].channel = e->channel; + p->voices[slot].pitch = e->pitch; p->voices[slot].velocity = e->velocity; + p->voices[slot].phase = 0; p->voices[slot].age = p->age++; +} +static void segment(xtc_midi_plan *p, uint64_t position, float *out, size_t frames) +{ + size_t i, j; + for (i = 0; i < p->count; ++i) { + const span *s = p->notes + i; + uint64_t begin = position > s->start ? position : s->start; + uint64_t end = position + frames < s->end ? position + frames : s->end; + double step = p->step[s->pitch]; + if (!step || s->start < p->cutoff[s->channel] || end <= begin) continue; + for (j = (size_t)(begin - position); j < (size_t)(end - position); ++j) { + double phase = fmod((double)(position + j - s->start + s->age) * step, 1.0); + out[j] += (float)(sin(TAU * phase) * 0.1 * s->velocity / 127.0); + } + } + for (i = 0; i < XTC_MIDI_VOICES; ++i) { + voice *v = p->voices + i; + double step = p->step[v->pitch]; + if (!v->active || !step) continue; + for (j = 0; j < frames; ++j) { + out[j] += (float)(sin(TAU * v->phase) * 0.1 * v->velocity / 127.0); + v->phase += step; if (v->phase >= 1) v->phase -= 1; + } + } +} +xtc_result xtc_midi_plan_render(xtc_midi_plan *p, uint64_t position, + const xtc_midi_event *events, size_t count, float *out, size_t frames) +{ + size_t i, cursor = 0; + if (!p || !out || count > XTC_MIDI_MAX_EVENTS || (count && !events) || + frames > 65536U || position > UINT64_MAX - frames) return XTC_ERR_INVALID_ARGUMENT; + for (i = 0; i < count; ++i) { + const xtc_midi_event *e = events + i; + if (e->offset >= frames || (i && e->offset < events[i - 1].offset) || + e->type < XTC_MIDI_NOTE_ON || e->type > XTC_MIDI_ALL_OFF || + e->pitch > 127 || e->velocity > 127 || e->channel > 15 || + (e->type == XTC_MIDI_NOTE_ON && !e->velocity)) return XTC_ERR_INVALID_ARGUMENT; + } + if (!frames) return XTC_OK; + if (p->next_position != position) xtc_midi_plan_reset(p); + memset(out, 0, frames * sizeof(*out)); + for (i = 0; i < count; ++i) { + size_t next = events[i].offset; + segment(p, position + cursor, out + cursor, next - cursor); + event(p, events + i, position + next); cursor = next; + } + segment(p, position + cursor, out + cursor, frames - cursor); + p->next_position = position + frames; + return XTC_OK; +} diff --git a/src/midi/model.c b/src/midi/model.c new file mode 100644 index 0000000..2ffecd3 --- /dev/null +++ b/src/midi/model.c @@ -0,0 +1,88 @@ +#include "../project/project_internal.h" +#include "../graph/graph_internal.h" +#include +xtc_result xtc_project_add_midi_clip(xtc_project *p, uint64_t track, uint64_t start, uint64_t length, uint64_t *out) +{ + xtc_project *c = NULL; + xtc_result r; + size_t i, source = SIZE_MAX, output = SIZE_MAX; + uint64_t id; + if (!p || !out) return XTC_ERR_INVALID_ARGUMENT; + r = xtc_project_clone(p, &c); + if (r == XTC_OK) r = xtc_project_add_clip(c, track, "MIDI", start, length, &id); + if (r == XTC_OK) { + c->clips[c->clip_count - 1].midi = 1; + for (i = 0; i < c->graph->node_count; ++i) { + xtc_node *n = c->graph->nodes + i; + if (n->type == XTC_NODE_INSTRUMENT) source = n->id; + if (n->type == XTC_NODE_OUTPUT) output = n->id; + } + if (source == SIZE_MAX) { + r = xtc_graph_add_instrument(c->graph, &source); + if (r == XTC_OK && output == SIZE_MAX) r = xtc_graph_add_output(c->graph, &output); + if (r == XTC_OK) r = xtc_graph_connect(c->graph, source, output); + } + } + if (r == XTC_OK) { + xtc_graph *old = p->graph; *p = *c; c->graph = old; *out = id; + } + xtc_project_destroy(c); return r; +} +xtc_result xtc_project_note_count(const xtc_project *p, size_t *out) +{ + if (!p || !out) return XTC_ERR_INVALID_ARGUMENT; + *out = p->note_count; return XTC_OK; +} +xtc_result xtc_project_get_note(const xtc_project *p, size_t i, xtc_note_info *out) +{ + if (!p || !out) return XTC_ERR_INVALID_ARGUMENT; + if (i >= p->note_count) return XTC_ERR_NOT_FOUND; + *out = p->notes[i]; return XTC_OK; +} +static xtc_result valid_note(const xtc_project *p, uint64_t clip, uint64_t start, uint64_t length, + unsigned int pitch, unsigned int velocity, unsigned int channel) +{ + xtc_clip_info c; + if (pitch > 127 || !velocity || velocity > 127 || channel > 15 || !length) return XTC_ERR_INVALID_ARGUMENT; + if (xtc_project_find_clip(p, clip, &c) != XTC_OK) return XTC_ERR_NOT_FOUND; + if (!c.midi) return XTC_ERR_INVALID_STATE; + if (start >= c.source_length_tick || length > c.source_length_tick - start) return XTC_ERR_INVALID_ARGUMENT; + return XTC_OK; +} +xtc_result xtc_project_add_note(xtc_project *p, uint64_t clip, uint64_t start, uint64_t length, + unsigned int pitch, unsigned int velocity, unsigned int channel, uint64_t *out) +{ + xtc_result r; + xtc_note_info n; + if (!p || !out) return XTC_ERR_INVALID_ARGUMENT; + r = valid_note(p, clip, start, length, pitch, velocity, channel); + if (r != XTC_OK) return r; + if (p->note_count == XTC_PROJECT_MAX_NOTES || p->next_note_id == UINT64_MAX) return XTC_ERR_CAPACITY; + memset(&n, 0, sizeof(n)); + n.id = p->next_note_id++; n.clip_id = clip; n.start_tick = start; n.length_tick = length; + n.pitch = pitch; n.velocity = velocity; n.channel = channel; + p->notes[p->note_count++] = n; *out = n.id; return XTC_OK; +} +xtc_result xtc_project_edit_note(xtc_project *p, uint64_t id, uint64_t start, uint64_t length, + unsigned int pitch, unsigned int velocity, unsigned int channel) +{ + size_t i; + xtc_result r; + if (!p) return XTC_ERR_INVALID_ARGUMENT; + for (i = 0; i < p->note_count; ++i) if (p->notes[i].id == id) break; + if (i == p->note_count) return XTC_ERR_NOT_FOUND; + r = valid_note(p, p->notes[i].clip_id, start, length, pitch, velocity, channel); + if (r != XTC_OK) return r; + p->notes[i].start_tick = start; p->notes[i].length_tick = length; + p->notes[i].pitch = pitch; p->notes[i].velocity = velocity; p->notes[i].channel = channel; + return XTC_OK; +} +xtc_result xtc_project_remove_note(xtc_project *p, uint64_t id) +{ + size_t i; + if (!p) return XTC_ERR_INVALID_ARGUMENT; + for (i = 0; i < p->note_count; ++i) if (p->notes[i].id == id) break; + if (i == p->note_count) return XTC_ERR_NOT_FOUND; + memmove(p->notes + i, p->notes + i + 1, (p->note_count - i - 1) * sizeof(*p->notes)); + --p->note_count; return XTC_OK; +} diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index d0bc291..22990d4 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -19,7 +19,7 @@ static xtc_result line(reader *input) char *cursor; input->count = 0U; while ((byte = fgetc(input->file)) != EOF) { - if (++input->bytes > 2097152U) return XTC_ERR_FORMAT; + if (++input->bytes > 4194304U) return XTC_ERR_FORMAT; if (byte == '\n') break; if (byte < 32 || byte > 126 || used == sizeof(input->line) - 1U) return XTC_ERR_FORMAT; input->line[used++] = (char)byte; @@ -89,7 +89,7 @@ static xtc_result read_project(reader *input, xtc_project *project) size_t i, j; xtc_graph *graph = project->graph; READ("XTC_PROJECT", 2U); NUM(1U, version); - if (version != 1U && version != 2U) return XTC_ERR_UNSUPPORTED; + if (version < 1U || version > 3U) return XTC_ERR_UNSUPPORTED; format_version = version; READ("APP", 4U); for (i = 1U; i < 4U; ++i) { NUM(i, value); if (value > UINT_MAX) return XTC_ERR_FORMAT; } @@ -104,11 +104,11 @@ static xtc_result read_project(reader *input, xtc_project *project) for (i = 0U; i < (size_t)node_count; ++i) { xtc_node node; READ("NODE", 4U); NUM(1U, value); NUM(2U, version); - if (value >= next_id || value >= SIZE_MAX || version < XTC_NODE_GAIN || version > (format_version == 1U ? XTC_NODE_OUTPUT : XTC_NODE_AUDIO) || + if (value >= next_id || value >= SIZE_MAX || version < XTC_NODE_GAIN || version > (format_version == 1U ? XTC_NODE_OUTPUT : format_version == 2U ? XTC_NODE_AUDIO : XTC_NODE_INSTRUMENT) || !decode_float(input->tokens[3], &node.value)) return XTC_ERR_FORMAT; node.id = (size_t)value; node.type = (xtc_node_type)version; if (node.type == XTC_NODE_OSCILLATOR && node.value < 0.0f) return XTC_ERR_FORMAT; - if ((node.type == XTC_NODE_METER || node.type == XTC_NODE_OUTPUT || node.type == XTC_NODE_AUDIO) && node.value != 0.0f) return XTC_ERR_FORMAT; + if ((node.type == XTC_NODE_METER || node.type == XTC_NODE_OUTPUT || node.type == XTC_NODE_AUDIO || node.type == XTC_NODE_INSTRUMENT) && node.value != 0.0f) return XTC_ERR_FORMAT; for (j = 0U; j < i; ++j) if (graph->nodes[j].id == node.id) return XTC_ERR_FORMAT; graph->nodes[graph->node_count++] = node; } @@ -134,18 +134,37 @@ static xtc_result read_project(reader *input, xtc_project *project) NUM(1U, clip->id); NUM(2U, clip->track_id); NUM(3U, clip->start_tick); NUM(4U, clip->length_tick); NUM(5U, clip->offset_tick); NUM(6U, clip->source_length_tick); if (!decode_name(input->tokens[7], clip->name)) return XTC_ERR_FORMAT; - if (format_version == 2U) { + if (format_version >= 2U) { size_t k, length; READ("ASSET", 2U); - if (!strcmp(input->tokens[1], "-")) continue; - length = strlen(input->tokens[1]); - if (!length || length % 2 || length >= XTC_ASSET_PATH_BYTES * 2U) return XTC_ERR_FORMAT; - for (k = 0; k < length / 2; ++k) { - int high = hex_digit(input->tokens[1][k * 2]), low = hex_digit(input->tokens[1][k * 2 + 1]); - if (high < 0 || low < 0 || (high == 0 && low == 0)) return XTC_ERR_FORMAT; - clip->asset_path[k] = (char)(high * 16 + low); + if (strcmp(input->tokens[1], "-")) { + length = strlen(input->tokens[1]); + if (!length || length % 2 || length >= XTC_ASSET_PATH_BYTES * 2U) return XTC_ERR_FORMAT; + for (k = 0; k < length / 2; ++k) { + int high = hex_digit(input->tokens[1][k * 2]), low = hex_digit(input->tokens[1][k * 2 + 1]); + if (high < 0 || low < 0 || (high == 0 && low == 0)) return XTC_ERR_FORMAT; + clip->asset_path[k] = (char)(high * 16 + low); + } + clip->asset_path[length / 2] = 0; } - clip->asset_path[length / 2] = 0; + } + if (format_version >= 3U) { + READ("MIDI", 2U); NUM(1U, value); + if (value > 1) return XTC_ERR_FORMAT; + clip->midi = (int)value; + } + } + if (format_version >= 3U) { + READ("NOTES", 3U); NUM(1U, project->next_note_id); NUM(2U, value); + if (value > XTC_PROJECT_MAX_NOTES) return XTC_ERR_FORMAT; + project->note_count = (size_t)value; + for (i = 0; i < project->note_count; ++i) { + xtc_note_info *n = project->notes + i; + READ("NOTE", 8U); + NUM(1U, n->id); NUM(2U, n->clip_id); NUM(3U, n->start_tick); NUM(4U, n->length_tick); + NUM(5U, value); if (value > 127) return XTC_ERR_FORMAT; n->pitch = (unsigned int)value; + NUM(6U, value); if (!value || value > 127) return XTC_ERR_FORMAT; n->velocity = (unsigned int)value; + NUM(7U, value); if (value > 15) return XTC_ERR_FORMAT; n->channel = (unsigned int)value; } } READ("END", 1U); @@ -164,7 +183,7 @@ static xtc_result write_project(FILE *file, const xtc_project *project) const xtc_graph *graph = project->graph; size_t i; if (sizeof(float) != sizeof(uint32_t) || FLT_RADIX != 2 || FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128) return XTC_ERR_UNSUPPORTED; - if (fprintf(file, "XTC_PROJECT 2\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n", + if (fprintf(file, "XTC_PROJECT 3\nAPP %d %d %d\nTEMPO %u\nIDS %" PRIu64 " %" PRIu64 "\nGRAPH %d %zu %zu %zu\n", XTC_VERSION_MAJOR, XTC_VERSION_MINOR, XTC_VERSION_PATCH, project->tempo, project->next_track_id, project->next_clip_id, graph->require_output_nodes, graph->next_id, graph->node_count, graph->edge_count) < 0) return XTC_ERR_IO; @@ -190,6 +209,13 @@ static xtc_result write_project(FILE *file, const xtc_project *project) if (clip->asset_path[0]) { if (!write_name(file, clip->asset_path)) return XTC_ERR_IO; } else if (fputs("-\n", file) == EOF) return XTC_ERR_IO; + if (fprintf(file, "MIDI %d\n", clip->midi) < 0) return XTC_ERR_IO; + } + if (fprintf(file, "NOTES %" PRIu64 " %zu\n", project->next_note_id, project->note_count) < 0) return XTC_ERR_IO; + for (i = 0; i < project->note_count; ++i) { + const xtc_note_info *n = project->notes + i; + if (fprintf(file, "NOTE %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 " %u %u %u\n", + n->id, n->clip_id, n->start_tick, n->length_tick, n->pitch, n->velocity, n->channel) < 0) return XTC_ERR_IO; } return fputs("END\n", file) == EOF ? XTC_ERR_IO : XTC_OK; } diff --git a/src/project/project.c b/src/project/project.c index 0f79f3d..2bd4c53 100644 --- a/src/project/project.c +++ b/src/project/project.c @@ -142,7 +142,11 @@ xtc_result xtc_project_remove_track(xtc_project *project, uint64_t id) project->clip_count = count; memmove(project->tracks + index, project->tracks + index + 1U, (project->track_count - index - 1U) * sizeof(*project->tracks)); - --project->track_count; return XTC_OK; + --project->track_count; + count = 0; + for (i = 0; i < project->note_count; ++i) + if (clip_index(project, project->notes[i].clip_id) != SIZE_MAX) project->notes[count++] = project->notes[i]; + project->note_count = count; return XTC_OK; } xtc_result xtc_project_add_clip(xtc_project *project, uint64_t track, const char *name, uint64_t start, uint64_t length, uint64_t *id) @@ -165,7 +169,14 @@ xtc_result xtc_project_remove_clip(xtc_project *project, uint64_t id) if (index == SIZE_MAX) return XTC_ERR_NOT_FOUND; memmove(project->clips + index, project->clips + index + 1U, (project->clip_count - index - 1U) * sizeof(*project->clips)); - --project->clip_count; return XTC_OK; + --project->clip_count; + { + size_t i, count = 0; + for (i = 0; i < project->note_count; ++i) + if (project->notes[i].clip_id != id) project->notes[count++] = project->notes[i]; + project->note_count = count; + } + return XTC_OK; } xtc_result xtc_project_edit_clip(xtc_project *project, uint64_t id, uint64_t track, uint64_t start, uint64_t length, uint64_t offset) @@ -208,7 +219,7 @@ xtc_result xtc_project_validate(const xtc_project *project) { size_t i, j; if (project->tempo < 20000U || project->tempo > 300000U || - project->track_count > XTC_PROJECT_MAX_TRACKS || project->clip_count > XTC_PROJECT_MAX_CLIPS) return XTC_ERR_FORMAT; + project->track_count > XTC_PROJECT_MAX_TRACKS || project->clip_count > XTC_PROJECT_MAX_CLIPS || project->note_count > XTC_PROJECT_MAX_NOTES) return XTC_ERR_FORMAT; for (i = 0U; i < project->track_count; ++i) { if (project->tracks[i].id >= project->next_track_id || !xtc_project_valid_name(project->tracks[i].name)) return XTC_ERR_FORMAT; for (j = 0U; j < i; ++j) if (project->tracks[j].id == project->tracks[i].id) return XTC_ERR_FORMAT; @@ -221,10 +232,20 @@ xtc_result xtc_project_validate(const xtc_project *project) if (byte < 32U || byte == 127U) return XTC_ERR_FORMAT; } if (path_length == XTC_ASSET_PATH_BYTES || (path_length && clip->asset_path[0] != '/')) return XTC_ERR_FORMAT; + if ((clip->midi != 0 && clip->midi != 1) || (clip->midi && path_length)) return XTC_ERR_FORMAT; if (clip->id >= project->next_clip_id || track_index(project, clip->track_id) == SIZE_MAX || !xtc_project_valid_name(clip->name) || !valid_range(clip->start_tick, clip->length_tick, clip->offset_tick, clip->source_length_tick)) return XTC_ERR_FORMAT; for (j = 0U; j < i; ++j) if (project->clips[j].id == clip->id) return XTC_ERR_FORMAT; } + for (i = 0; i < project->note_count; ++i) { + const xtc_note_info *n = project->notes + i; + size_t c = clip_index(project, n->clip_id); + if (c == SIZE_MAX || !project->clips[c].midi || n->id >= project->next_note_id || + n->pitch > 127 || !n->velocity || n->velocity > 127 || n->channel > 15 || + !n->length_tick || n->start_tick >= project->clips[c].source_length_tick || + n->length_tick > project->clips[c].source_length_tick - n->start_tick) return XTC_ERR_FORMAT; + for (j = 0; j < i; ++j) if (project->notes[j].id == n->id) return XTC_ERR_FORMAT; + } return XTC_OK; } @@ -238,6 +259,7 @@ xtc_result xtc_project_set_clip_asset(xtc_project *project, uint64_t id, const c if ((unsigned char)path[i] < 32U || path[i] == 127) return XTC_ERR_INVALID_ARGUMENT; index = clip_index(project, id); if (index == SIZE_MAX) return XTC_ERR_NOT_FOUND; + if (project->clips[index].midi) return XTC_ERR_INVALID_STATE; memmove(project->clips[index].asset_path, path, length + 1U); return XTC_OK; } diff --git a/src/project/project_internal.h b/src/project/project_internal.h index 3ba8ace..34b78b0 100644 --- a/src/project/project_internal.h +++ b/src/project/project_internal.h @@ -1,11 +1,15 @@ #ifndef XTC_PROJECT_INTERNAL_H #define XTC_PROJECT_INTERNAL_H #include "xtc/xtc_project.h" +#include "xtc/xtc_midi.h" struct xtc_project { xtc_graph *graph; uint64_t next_track_id, next_clip_id; unsigned int tempo; size_t track_count, clip_count; + uint64_t next_note_id; + size_t note_count; + xtc_note_info notes[XTC_PROJECT_MAX_NOTES]; xtc_track_info tracks[XTC_PROJECT_MAX_TRACKS]; xtc_clip_info clips[XTC_PROJECT_MAX_CLIPS]; }; diff --git a/tests/integration/README.md b/tests/integration/README.md index 6ea0428..a3058cc 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -119,3 +119,19 @@ test_gui_jack records a silent take, finalizes and saves/reopens it, then starts playback before callback-safe close. Decoder and producer/consumer concurrency checks are part of make test; run test_audio and test_realtime under TSan as in BUILD.md. Physical input/output and latency compensation are not tested. + +## Phase 7 verification + +```sh +make all test gui debug test-gui test-gui-jack test-jack-audio test-jack-midi +make BUILD_DIR=build/nojack PKG_CONFIG=false all test gui test-gui +``` + +`test_midi` adds exact sample scheduling, trim/seek/panic, block independence, +voice capacities, stable notes and v3 malformed-file/roundtrip/v2 migration +coverage. Backend fault injection checks event offsets, dropped/malformed input, +disconnection, xruns, missing output buffers and stop/restart cleanup. GUI tests +draw/move/resize notes, save/reopen and render them. The live MIDI target connects +only software clients and checks on/off, panic, disconnect, and saved-note +playback/seek. It needs an existing JACK-compatible server. No hardware routes +or system changes are made. MIDI recording/SMF and physical devices are deferred. diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index b59cf06..9be88a3 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1,6 +1,7 @@ #include "../../src/gui/main_window.hpp" #include "../../src/gui/graph_widget.hpp" #include "../../src/gui/sequencer_widget.hpp" +#include "../../src/gui/piano_roll.hpp" #include #include #include @@ -16,7 +17,7 @@ /* Fake only the public C backend boundary. Real FLTK widgets, callbacks and * timers run under Xvfb; no server/device is required or touched. */ -struct xtc_backend { int running = 0; xtc_plan *plan = nullptr; xtc_audio_plan *audio = nullptr; xtc_recorder *recorder = nullptr; }; +struct xtc_backend { int running = 0; xtc_plan *plan = nullptr; xtc_audio_plan *audio = nullptr; xtc_midi_plan *midi = nullptr; xtc_recorder *recorder = nullptr; }; namespace { xtc_backend handle; xtc_result open_result = XTC_OK, start_result = XTC_OK, stop_result = XTC_OK, close_result = XTC_OK; @@ -59,7 +60,7 @@ SequencerWidget *timeline(Fl_Group *group) } return nullptr; } -void gesture(SequencerWidget *s, int left, int top, int dx, int dy) +void gesture(Fl_Widget *s, int left, int top, int dx, int dy) { int old_x = Fl::e_x, old_y = Fl::e_y, old_key = Fl::e_keysym; Fl::e_x = s->x() + left; Fl::e_y = s->y() + top; Fl::e_keysym = FL_Button + FL_LEFT_MOUSE; @@ -98,7 +99,7 @@ xtc_result xtc_backend_close(xtc_backend *backend) { if (backend == nullptr) return XTC_OK; ++closes; - if (close_result == XTC_OK) { CHECK(alive == 1U); --alive; handle.running = 0; xtc_plan_destroy(handle.plan); handle.plan = nullptr; xtc_audio_plan_destroy(handle.audio); handle.audio = nullptr; } + if (close_result == XTC_OK) { CHECK(alive == 1U); --alive; handle.running = 0; xtc_plan_destroy(handle.plan); handle.plan = nullptr; xtc_audio_plan_destroy(handle.audio); handle.audio = nullptr; xtc_midi_plan_destroy(handle.midi); handle.midi = nullptr; } return close_result; } xtc_result xtc_backend_start(xtc_backend *backend) @@ -126,13 +127,17 @@ xtc_result xtc_backend_submit(xtc_backend *backend, const xtc_graph *graph) ++engine_status.submitted_revision; return XTC_OK; } +xtc_result xtc_backend_panic(xtc_backend *) { xtc_midi_plan_panic(handle.midi, engine_status.sample_position); return XTC_OK; } xtc_result xtc_backend_prepare_project(xtc_backend *backend, const xtc_project *project) { xtc_audio_plan *plan = nullptr; + xtc_midi_plan *midi = nullptr; xtc_result r = xtc_audio_plan_create(project, 48000, &plan); + if (r == XTC_OK) r = xtc_midi_plan_create(project, 48000, &midi); if (r == XTC_OK) r = xtc_backend_submit(backend, xtc_project_graph(project)); - if (r != XTC_OK) { xtc_audio_plan_destroy(plan); return r; } + if (r != XTC_OK) { xtc_audio_plan_destroy(plan); xtc_midi_plan_destroy(midi); return r; } xtc_audio_plan_destroy(handle.audio); handle.audio = plan; + xtc_midi_plan_destroy(handle.midi); handle.midi = midi; engine_status.asset_issues = static_cast(xtc_audio_plan_issues(plan, nullptr)); return XTC_OK; } @@ -300,6 +305,29 @@ int main(int argc, char **argv) CHECK(unlink(take.c_str()) == 0); CHECK(window.open_project(filename.c_str()) == XTC_OK && contains(&window, "Missing/unreadable audio")); CHECK(window.import_audio("/missing/xtc-audio.wav") == XTC_ERR_IO); + press(find(&window, "Add MIDI clip")); + CHECK(window.open_piano_roll() == XTC_OK); + auto *roll = window.piano_roll(); CHECK(roll != nullptr); + int saved_x = Fl::e_x, saved_y = Fl::e_y, saved_key = Fl::e_keysym; + Fl::e_x = roll->x() + 112; Fl::e_y = roll->y() + 1110; Fl::e_keysym = FL_Button + FL_LEFT_MOUSE; + CHECK(roll->handle(FL_PUSH) == 1); (void)roll->handle(FL_RELEASE); + Fl::e_x = saved_x; Fl::e_y = saved_y; Fl::e_keysym = saved_key; + gesture(roll, 130, 1110, 24, -16); // Move one sixteenth and a semitone. + gesture(roll, 230, 1094, 48, 0); // Extend right edge by half a beat. + xtc_note_info drawn{}; size_t note_count; + CHECK(xtc_project_note_count(sequence->project(), ¬e_count) == XTC_OK && note_count == 1); + CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK && + drawn.start_tick == 720 && drawn.length_tick == 1440 && drawn.pitch == 61); + CHECK(window.save_project(filename.c_str()) == XTC_OK); + CHECK(window.open_project(filename.c_str()) == XTC_OK); + CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK && drawn.pitch == 61 && drawn.start_tick == 720); + float instrument[32], heard[32]; + CHECK(xtc_midi_plan_render(handle.midi, 18000, nullptr, 0, instrument, 32) == XTC_OK && instrument[1] > 0); + CHECK(xtc_plan_process_sources(handle.plan, nullptr, nullptr, instrument, heard, 32) == XTC_OK && heard[1] == instrument[1]); + engine_status.sample_position = 18032; + press(find(&window, "Panic")); + CHECK(xtc_midi_plan_render(handle.midi, 18032, nullptr, 0, instrument, 32) == XTC_OK && instrument[0] == 0); + engine_status.sample_position = 0; CHECK(unlink(filename.c_str()) == 0 && rmdir(directory) == 0); engine_status.xruns = 3U; engine_status.processed_blocks = 123U; diff --git a/tests/integration/test_gui_jack.cpp b/tests/integration/test_gui_jack.cpp index 05e7d8b..f195b8c 100644 --- a/tests/integration/test_gui_jack.cpp +++ b/tests/integration/test_gui_jack.cpp @@ -1,6 +1,7 @@ #include "../../src/gui/main_window.hpp" #include "../../src/gui/graph_widget.hpp" #include "../../src/gui/sequencer_widget.hpp" +#include "../../src/gui/piano_roll.hpp" #include #include #include @@ -106,6 +107,15 @@ int main(int argc, char **argv) CHECK(window.open_project(song.c_str()) == XTC_OK); play->do_callback(); pump(); CHECK(find(window, "running — modular graph") != nullptr); + auto *add_midi = find(window, "Add MIDI clip"); CHECK(add_midi != nullptr); + add_midi->do_callback(); CHECK(window.open_piano_roll() == XTC_OK); + CHECK(window.piano_roll()->add_note(0, 960, 69) == XTC_OK); + CHECK(window.save_project(song.c_str()) == XTC_OK); + CHECK(window.open_project(song.c_str()) == XTC_OK); + size_t notes; + CHECK(xtc_project_note_count(timeline->project(), ¬es) == XTC_OK && notes == 1); + play->do_callback(); pump(); + CHECK(find(window, "Graph: active in engine") != nullptr); CHECK(unlink(take.c_str()) == 0 && unlink(song.c_str()) == 0 && rmdir(directory) == 0); window.do_callback(); // Close with an active client; synchronizes callbacks. CHECK(!window.shown()); diff --git a/tests/integration/test_jack_midi.c b/tests/integration/test_jack_midi.c new file mode 100644 index 0000000..8144f0f --- /dev/null +++ b/tests/integration/test_jack_midi.c @@ -0,0 +1,108 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_backend.h" +#include +#include +#include +#include +#include +#include +#include +#include +typedef struct { + jack_port_t *input, *midi; + unsigned int command, seen, loud, heard, bad; +} probe; +static int process(jack_nframes_t frames, void *context) +{ + probe *p = context; + const float *input = jack_port_get_buffer(p->input, frames); + void *midi = jack_port_get_buffer(p->midi, frames); + unsigned int command = __atomic_load_n(&p->command, __ATOMIC_ACQUIRE), loud = 0; + jack_nframes_t i; + if (!input || !midi) { __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED); return 0; } + jack_midi_clear_buffer(midi); + if (command && command != p->seen) { + unsigned char bytes[3] = {0x90, 69, command % 2 ? 127 : 0}; + if (jack_midi_event_write(midi, frames > 8 ? 8 : 0, bytes, 3) != 0) + __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED); + p->seen = command; + } + for (i = 0; i < frames; ++i) { + if (!isfinite(input[i]) || fabsf(input[i]) > 0.101f) __atomic_store_n(&p->bad, 1U, __ATOMIC_RELAXED); + if (fabsf(input[i]) > 0.01f) loud = 1; + } + __atomic_store_n(&p->loud, loud, __ATOMIC_RELAXED); + if (loud) __atomic_store_n(&p->heard, 1U, __ATOMIC_RELAXED); + return 0; +} +static void wait_blocks(void) +{ struct timespec pause = {0, 200000000L}; nanosleep(&pause, NULL); } +#define REQUIRE(c) do { if (!(c)) { fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); goto cleanup; } } while (0) +int main(void) +{ + char directory[] = "/tmp/xtc-live-midi-XXXXXX", song[256] = "", name[64]; + xtc_backend_config config = {XTC_BACKEND_JACK, name, 65536, 1}; + xtc_backend *backend = NULL; + xtc_project *p = NULL, *q = NULL; + jack_client_t *client = NULL; + jack_status_t flags = 0; + probe state = {0}; + const char *destination; + uint64_t track, clip, note; + int result = EXIT_FAILURE; + REQUIRE(mkdtemp(directory)); + snprintf(song, sizeof(song), "%s/song.xtc", directory); + snprintf(name, sizeof(name), "xtc-midi-%ld", (long)getpid()); + REQUIRE(xtc_project_create(&p) == XTC_OK && xtc_project_create(&q) == XTC_OK); + REQUIRE(xtc_project_add_track(p, "Keys", &track) == XTC_OK); + REQUIRE(xtc_project_add_midi_clip(p, track, 0, 3840, &clip) == XTC_OK); + REQUIRE(xtc_backend_open(&config, &backend) == XTC_OK); + REQUIRE(xtc_backend_prepare_project(backend, p) == XTC_OK); + client = jack_client_open("xtc-midi-probe", JackNoStartServer, &flags); REQUIRE(client); + state.input = jack_port_register(client, "input", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0); + state.midi = jack_port_register(client, "midi", JACK_DEFAULT_MIDI_TYPE, JackPortIsOutput, 0); + REQUIRE(state.input && state.midi); + REQUIRE(jack_set_process_callback(client, process, &state) == 0 && jack_activate(client) == 0); + REQUIRE(xtc_backend_start(backend) == XTC_OK); + REQUIRE(xtc_backend_midi_port_name(backend, &destination) == XTC_OK); + wait_blocks(); /* PipeWire JACK publishes newly activated ports asynchronously. */ + { + int connected = jack_connect(client, jack_port_name(state.midi), destination); + if (connected && connected != EEXIST) fprintf(stderr, "MIDI connection returned %d: %s -> %s\n", connected, jack_port_name(state.midi), destination); + REQUIRE(connected == 0 || connected == EEXIST); + } + REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK); + __atomic_store_n(&state.command, 1U, __ATOMIC_RELEASE); wait_blocks(); + REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED)); + __atomic_store_n(&state.command, 2U, __ATOMIC_RELEASE); wait_blocks(); + REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED)); + __atomic_store_n(&state.command, 3U, __ATOMIC_RELEASE); wait_blocks(); + REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED)); + REQUIRE(xtc_backend_panic(backend) == XTC_OK); wait_blocks(); + REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED)); + __atomic_store_n(&state.command, 5U, __ATOMIC_RELEASE); wait_blocks(); + REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED)); + REQUIRE(jack_disconnect(client, jack_port_name(state.midi), destination) == 0); + wait_blocks(); REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED)); + REQUIRE(xtc_backend_stop(backend) == XTC_OK); + REQUIRE(xtc_project_add_note(p, clip, 0, 960, 69, 100, 0, ¬e) == XTC_OK); + REQUIRE(xtc_project_save(p, song) == XTC_OK && xtc_project_load(q, song) == XTC_OK); + REQUIRE(xtc_backend_prepare_project(backend, q) == XTC_OK); + REQUIRE(xtc_backend_seek(backend, 0) == XTC_OK && xtc_backend_start(backend) == XTC_OK); + REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK); + wait_blocks(); REQUIRE(__atomic_load_n(&state.loud, __ATOMIC_RELAXED)); + REQUIRE(xtc_backend_stop(backend) == XTC_OK); + REQUIRE(xtc_backend_seek(backend, 7680000) == XTC_OK && xtc_backend_start(backend) == XTC_OK); + REQUIRE(xtc_backend_connect(backend, XTC_PORT_OUTPUT, jack_port_name(state.input)) == XTC_OK); + wait_blocks(); REQUIRE(!__atomic_load_n(&state.loud, __ATOMIC_RELAXED)); + REQUIRE(__atomic_load_n(&state.heard, __ATOMIC_RELAXED) && !__atomic_load_n(&state.bad, __ATOMIC_RELAXED)); + puts("Live JACK MIDI input/note-off/panic and saved/reopened scheduled instrument playback passed."); + result = EXIT_SUCCESS; +cleanup: + if (backend && xtc_backend_close(backend) != XTC_OK) return EXIT_FAILURE; + if (client && jack_client_close(client) != 0) return EXIT_FAILURE; + xtc_project_destroy(p); xtc_project_destroy(q); + if (song[0]) unlink(song); + rmdir(directory); + return result; +} diff --git a/tests/unit/test_backend.c b/tests/unit/test_backend.c index 7750c09..e9ae8e0 100644 --- a/tests/unit/test_backend.c +++ b/tests/unit/test_backend.c @@ -4,6 +4,7 @@ * separately by test_realtime. No fake header replaces installed declarations. */ #include "xtc/xtc_backend.h" #include +#include #include #include #include @@ -11,13 +12,16 @@ #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 JackPortConnectCallback connection_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 client_token, input_token, output_token, midi_token; +static unsigned int midi_count; +static jack_midi_event_t midi_events[256]; 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; @@ -40,8 +44,13 @@ jack_port_t *jack_port_register(jack_client_t *client, const char *name, const c (void)client; (void)name; (void)type; (void)buffer_size; ++port_calls; if (register_fail == port_calls) return NULL; + if (!strcmp(type, JACK_DEFAULT_MIDI_TYPE)) return (jack_port_t *)&midi_token; return (jack_port_t *)(flags == JackPortIsInput ? &input_token : &output_token); } +int jack_set_port_connect_callback(jack_client_t *client, JackPortConnectCallback callback, void *arg) +{ (void)client; (void)arg; connection_cb = callback; return 0; } +jack_port_t *jack_port_by_id(jack_client_t *client, jack_port_id_t id) +{ (void)client; return id == 3 ? (jack_port_t *)&midi_token : NULL; } 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) @@ -57,9 +66,12 @@ int jack_activate(jack_client_t *client) 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; } +{ (void)frames; if (null_buffer) return NULL; if (port == (jack_port_t *)&midi_token) return &midi_token; return port == (jack_port_t *)&input_token ? input : output; } +uint32_t jack_midi_get_event_count(void *buffer) { (void)buffer; return midi_count; } +int jack_midi_event_get(jack_midi_event_t *event, void *buffer, uint32_t index) +{ (void)buffer; if (index >= midi_count || index >= 256) return -1; *event = midi_events[index]; return 0; } const char *jack_port_name(const jack_port_t *port) -{ return port == (jack_port_t *)&input_token ? "test:input" : "test:output"; } +{ if (port == (jack_port_t *)&midi_token) return "test:midi_in"; 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)); @@ -99,6 +111,8 @@ int main(void) server_frames = 32U; register_fail = 2; CHECK(xtc_backend_open(&config, &backend) == XTC_ERR_IO && backend == NULL); + register_fail = 3; + 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); @@ -236,6 +250,53 @@ int main(void) xtc_project_destroy(p); xtc_project_destroy(q); CHECK(unlink(song) == 0 && rmdir(directory) == 0); } + { + xtc_project *p = NULL; + uint64_t track, clip; + unsigned char on[3] = {0x90, 69, 127}, off[3] = {0x90, 69, 0}, bad[3] = {0x90, 255, 127}; + CHECK(xtc_project_create(&p) == XTC_OK && xtc_project_add_track(p, "MIDI", &track) == XTC_OK); + CHECK(xtc_project_add_midi_clip(p, track, 0, 3840, &clip) == XTC_OK); + CHECK(xtc_backend_open(&config, &backend) == XTC_OK && xtc_backend_prepare_project(backend, p) == XTC_OK); + CHECK(xtc_backend_midi_port_name(backend, &name) == XTC_OK && strcmp(name, "test:midi_in") == 0); + CHECK(xtc_backend_start(backend) == XTC_OK); + midi_count = 1; midi_events[0].time = 4; midi_events[0].size = 3; midi_events[0].buffer = on; + CHECK(process_cb(32, context) == 0 && output[0] == 0 && output[4] == 0 && output[5] > 0); + midi_events[0].time = 8; midi_events[0].buffer = off; + CHECK(process_cb(32, context) == 0 && output[0] != 0 && output[8] == 0 && output[31] == 0); + midi_events[0].time = 0; midi_events[0].buffer = on; + CHECK(process_cb(32, context) == 0 && output[1] > 0); + midi_count = 0; + CHECK(xtc_backend_panic(backend) == XTC_OK); + CHECK(process_cb(32, context) == 0 && output[0] == 0 && output[31] == 0); + midi_count = 1; + CHECK(process_cb(32, context) == 0 && output[1] > 0); + midi_count = 257; + CHECK(process_cb(32, context) == 0 && output[1] == 0); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.midi_dropped == 257); + midi_count = 1; midi_events[0].buffer = bad; + CHECK(process_cb(32, context) == 0 && output[1] == 0); + CHECK(xtc_backend_get_status(backend, &status) == XTC_OK && status.midi_dropped == 258); + midi_events[0].buffer = on; + CHECK(process_cb(32, context) == 0 && output[1] > 0); + midi_count = 0; + connection_cb(999, 3, 0, context); + CHECK(process_cb(32, context) == 0 && output[1] == 0); + midi_count = 1; + CHECK(process_cb(32, context) == 0 && output[1] > 0); + midi_count = 0; CHECK(xrun_cb(context) == 0); + CHECK(process_cb(32, context) == 0 && output[1] == 0); + midi_count = 1; + CHECK(process_cb(32, context) == 0 && output[1] > 0); + midi_count = 0; null_buffer = 1; CHECK(process_cb(32, context) == 0); null_buffer = 0; + CHECK(process_cb(32, context) == 0 && output[1] == 0); + midi_count = 1; + CHECK(process_cb(32, context) == 0 && output[1] > 0); + midi_count = 0; + CHECK(xtc_backend_stop(backend) == XTC_OK && xtc_backend_seek(backend, 0) == XTC_OK && xtc_backend_start(backend) == XTC_OK); + CHECK(process_cb(32, context) == 0 && output[1] == 0); + CHECK(xtc_backend_close(backend) == XTC_OK); + xtc_project_destroy(p); + } CHECK(closed >= 104); CHECK(xtc_backend_close(NULL) == XTC_OK); xtc_graph_destroy(invalid_graph); diff --git a/tests/unit/test_midi.c b/tests/unit/test_midi.c new file mode 100644 index 0000000..a8967b6 --- /dev/null +++ b/tests/unit/test_midi.c @@ -0,0 +1,167 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_midi.h" +#include "xtc/xtc_audio.h" +#include +#include +#include +#include +#include +#include +#define TAU 6.28318530717958647692 +static int silent(const float *p, size_t n) +{ size_t i; for (i = 0; i < n; ++i) if (p[i] != 0) return 0; return 1; } +int main(void) +{ + xtc_project *p = NULL, *q = NULL; + xtc_midi_plan *a = NULL, *b = NULL; + xtc_plan *graph = NULL; + uint64_t track, clip, note, other; + size_t count, i; + float whole[512], split[512], output[512], routed[512]; + xtc_note_info n; + xtc_midi_event event = {4, XTC_MIDI_NOTE_ON, 0, 69, 127}, decoded = {0}; + unsigned char on[] = {0x92, 69, 100}, off[] = {0x92, 69, 0}, panic[] = {0xb2, 123, 0}, bad[] = {0x90, 255, 127}; + char directory[] = "/tmp/xtc-midi-XXXXXX", path[256], copy[256], bytes[8192], roundtrip[8192]; + FILE *f; size_t length; + assert(mkdtemp(directory)); + snprintf(path, sizeof(path), "%s/song.xtc", directory); + snprintf(copy, sizeof(copy), "%s/copy.xtc", directory); + assert(xtc_midi_decode(on, 3, 17, &decoded) == XTC_OK && decoded.channel == 2 && decoded.offset == 17 && decoded.type == XTC_MIDI_NOTE_ON); + assert(xtc_midi_decode(off, 3, 0, &decoded) == XTC_OK && decoded.type == XTC_MIDI_NOTE_OFF); + assert(xtc_midi_decode(panic, 3, 0, &decoded) == XTC_OK && decoded.type == XTC_MIDI_ALL_OFF); + assert(xtc_midi_decode(bad, 3, 0, &decoded) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_midi_decode(on, 2, 0, &decoded) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_create(&p) == XTC_OK && xtc_project_create(&q) == XTC_OK); + assert(xtc_project_add_track(p, "Keys", &track) == XTC_OK); + assert(xtc_project_add_midi_clip(p, track, 1, 3840, &clip) == XTC_OK); + assert(xtc_project_add_note(p, clip, 1, 10, 69, 127, 0, ¬e) == XTC_OK); + assert(xtc_project_set_clip_asset(p, clip, "/bad.wav") == XTC_ERR_INVALID_STATE); + assert(xtc_project_add_note(p, clip, 3840, 1, 69, 127, 0, &other) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_add_note(p, clip, 0, 1, 128, 127, 0, &other) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_edit_note(p, note, 0, UINT64_MAX, 69, 127, 0) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_midi_plan_create(p, 48000, &a) == XTC_OK && xtc_midi_plan_create(p, 48000, &b) == XTC_OK); + assert(xtc_midi_plan_render(a, 0, NULL, 0, whole, 512) == XTC_OK); + assert(silent(whole, 51) && whole[51] > 0 && silent(whole + 300, 212)); + assert(fabs((double)whole[51] - sin(TAU * 440 / 48000) * 0.1) < 0.000001); + assert(xtc_midi_plan_render(b, 0, NULL, 0, split, 53) == XTC_OK); + assert(xtc_midi_plan_render(b, 53, NULL, 0, split + 53, 247) == XTC_OK); + assert(xtc_midi_plan_render(b, 300, NULL, 0, split + 300, 212) == XTC_OK); + assert(memcmp(whole, split, sizeof(whole)) == 0); /* Block partition independent. */ + assert(xtc_graph_compile(xtc_project_graph(p), 48000, 512, &graph) == XTC_OK); + assert(xtc_plan_process_sources(graph, NULL, NULL, whole, routed, 512) == XTC_OK && memcmp(whole, routed, sizeof(whole)) == 0); + xtc_plan_destroy(graph); + xtc_midi_plan_panic(a, 100); + assert(xtc_midi_plan_render(a, 100, NULL, 0, output, 128) == XTC_OK && silent(output, 128)); + xtc_midi_plan_reset(a); + assert(xtc_midi_plan_render(a, 100, NULL, 0, output, 128) == XTC_OK && output[0] == whole[100]); + assert(xtc_project_save(p, path) == XTC_OK && xtc_project_load(q, path) == XTC_OK); + assert(xtc_project_get_note(q, 0, &n) == XTC_OK && n.id == note && n.pitch == 69 && n.start_tick == 1); + assert(xtc_project_save(q, copy) == XTC_OK); + f = fopen(path, "rb"); assert(f); length = fread(bytes, 1, sizeof(bytes), f); assert(fclose(f) == 0); + f = fopen(copy, "rb"); assert(f); assert(fread(roundtrip, 1, sizeof(roundtrip), f) == length && fclose(f) == 0); + assert(memcmp(bytes, roundtrip, length) == 0); + /* All truncations rejected without changing the loaded notes. */ + for (i = 0; i < length; ++i) { + f = fopen(copy, "wb"); assert(f); + assert(fwrite(bytes, 1, i, f) == i && fclose(f) == 0); + assert(xtc_project_load(q, copy) != XTC_OK); + assert(xtc_project_get_note(q, 0, &n) == XTC_OK && n.id == note); + } + bytes[length] = 0; + { + const char *invalid[] = { + "NOTES 1 1\nNOTE 0 999 1 10 69 127 0\nEND\n", + "NOTES 1 1\nNOTE 0 0 3840 10 69 127 0\nEND\n", + "NOTES 1 1\nNOTE 0 0 1 10 128 127 0\nEND\n", + "NOTES 1 1\nNOTE 0 0 1 10 69 0 0\nEND\n", + "NOTES 1 1\nNOTE 0 0 1 10 69 127 16\nEND\n", + "NOTES 1 1\nNOTE 1 0 1 10 69 127 0\nEND\n", + "NOTES 2 2\nNOTE 0 0 1 10 69 127 0\nNOTE 0 0 1 10 69 127 0\nEND\n" + }; + const char *notes = strstr(bytes, "NOTES "); assert(notes); + size_t prefix = (size_t)(notes - bytes), k; + for (k = 0; k < sizeof(invalid) / sizeof(invalid[0]); ++k) { + f = fopen(copy, "wb"); assert(f); + assert(fwrite(bytes, 1, prefix, f) == prefix && fputs(invalid[k], f) >= 0 && fclose(f) == 0); + assert(xtc_project_load(q, copy) == XTC_ERR_FORMAT); + assert(xtc_project_get_note(q, 0, &n) == XTC_OK && n.id == note && n.pitch == 69); + } + } + assert(xtc_project_remove_note(q, note) == XTC_OK); + assert(xtc_project_add_note(q, clip, 0, 960, 60, 100, 1, &other) == XTC_OK && other > note); + assert(xtc_project_remove_clip(q, clip) == XTC_OK && xtc_project_note_count(q, &count) == XTC_OK && count == 0); + assert(xtc_project_edit_clip(p, clip, track, 0, 5, 5) == XTC_OK); + xtc_midi_plan_destroy(a); xtc_midi_plan_destroy(b); + assert(xtc_midi_plan_create(p, 48000, &a) == XTC_OK); + assert(xtc_midi_plan_render(a, 0, NULL, 0, output, 128) == XTC_OK); + assert(fabs((double)output[0] - sin(TAU * 100 * 440 / 48000) * 0.1) < 0.000001); + assert(silent(output + 125, 3)); /* Trim hard-stops note, no stuck tail. */ + xtc_midi_plan_destroy(a); + assert(xtc_project_remove_track(p, track) == XTC_OK && xtc_project_note_count(p, &count) == XTC_OK && count == 0); + assert(xtc_midi_plan_create(p, 48000, &a) == XTC_OK); + assert(xtc_midi_plan_render(a, 0, &event, 1, output, 32) == XTC_OK); + assert(silent(output, 5) && output[5] > 0); + event.offset = 8; event.type = XTC_MIDI_NOTE_OFF; + assert(xtc_midi_plan_render(a, 32, &event, 1, output, 32) == XTC_OK && silent(output + 8, 24)); + event.offset = 0; event.type = XTC_MIDI_NOTE_ON; + assert(xtc_midi_plan_render(a, 64, &event, 1, output, 32) == XTC_OK); + event.offset = 7; event.type = XTC_MIDI_ALL_OFF; + assert(xtc_midi_plan_render(a, 96, &event, 1, output, 32) == XTC_OK && silent(output + 7, 25)); + event.offset = 0; event.type = XTC_MIDI_NOTE_ON; + assert(xtc_midi_plan_render(a, 128, &event, 1, output, 32) == XTC_OK); + assert(xtc_midi_plan_render(a, 500, NULL, 0, output, 32) == XTC_OK && silent(output, 32)); /* Seek kills live. */ + for (i = 0; i < 512; ++i) output[i] = 123; + event.offset = 32; + assert(xtc_midi_plan_render(a, 532, &event, 1, output, 32) == XTC_ERR_INVALID_ARGUMENT && output[0] == 123); + xtc_midi_plan_destroy(a); + assert(xtc_project_add_track(p, "Poly", &track) == XTC_OK); + assert(xtc_project_add_midi_clip(p, track, 0, 3840, &clip) == XTC_OK); + for (i = 0; i < 33; ++i) assert(xtc_project_add_note(p, clip, 0, 960, (unsigned int)i + 48, 100, 0, ¬e) == XTC_OK); + assert(xtc_midi_plan_create(p, 48000, &a) == XTC_ERR_CAPACITY && a == NULL); + assert(xtc_project_remove_note(p, note) == XTC_OK); + assert(xtc_midi_plan_create(p, 48000, &a) == XTC_OK); xtc_midi_plan_destroy(a); + for (i = 32; i < XTC_PROJECT_MAX_NOTES; ++i) + assert(xtc_project_add_note(p, clip, 0, 1, 60, 100, 0, ¬e) == XTC_OK); + assert(xtc_project_add_note(p, clip, 0, 1, 60, 100, 0, ¬e) == XTC_ERR_CAPACITY); + assert(xtc_project_save(p, path) == XTC_OK && xtc_project_load(p, path) == XTC_OK); + assert(xtc_project_remove_track(p, track) == XTC_OK && xtc_project_note_count(p, &count) == XTC_OK && count == 0); + assert(xtc_midi_plan_create(q, 48000, &a) == XTC_OK); + { + xtc_midi_event many[33]; + for (i = 0; i < 33; ++i) { + many[i].offset = 0; many[i].type = XTC_MIDI_NOTE_ON; many[i].channel = 0; + many[i].pitch = (unsigned int)i + 48; many[i].velocity = 127; + } + assert(xtc_midi_plan_render(a, 0, many, 33, output, 32) == XTC_OK && output[1] > 0); + many[0].type = XTC_MIDI_ALL_OFF; + assert(xtc_midi_plan_render(a, 32, many, 1, output, 32) == XTC_OK && silent(output, 32)); + many[0].type = many[1].type = XTC_MIDI_NOTE_ON; + many[0].pitch = many[1].pitch = 69; many[1].channel = 1; + assert(xtc_midi_plan_render(a, 64, many, 2, output, 32) == XTC_OK); + many[0].type = XTC_MIDI_ALL_OFF; many[0].offset = 8; + assert(xtc_midi_plan_render(a, 96, many, 1, output, 32) == XTC_OK); + assert(fabs((double)output[8] - sin(TAU * 40 * 440 / 48000) * 0.1) < 0.000001); + xtc_midi_plan_panic(a, 128); + assert(xtc_midi_plan_render(a, 128, NULL, 0, output, 32) == XTC_OK && silent(output, 32)); + } + xtc_midi_plan_destroy(a); + /* Explicit migration from the previous audio format. */ + f = fopen(copy, "wb"); assert(f); + assert(fputs("XTC_PROJECT 2\nAPP 0 1 0\nTEMPO 120000\nIDS 1 1\nGRAPH 1 0 0 0\nTRACKS 1\nTRACK 0 41\nCLIPS 1\nCLIP 0 0 0 960 0 960 42\nASSET -\nEND\n", f) >= 0 && fclose(f) == 0); + assert(xtc_project_load(q, copy) == XTC_OK && xtc_project_note_count(q, &count) == XTC_OK && count == 0); + { + xtc_graph *full = NULL; + size_t id, tracks, clips; + uint64_t unchanged = UINT64_MAX; + assert(xtc_graph_create(64, &full) == XTC_OK); + for (i = 0; i < 64; ++i) assert(xtc_graph_add_gain(full, 1, &id) == XTC_OK); + assert(xtc_project_set_graph(q, full) == XTC_OK); + xtc_graph_destroy(full); + assert(xtc_project_add_midi_clip(q, 0, 0, 3840, &unchanged) == XTC_ERR_CAPACITY && unchanged == UINT64_MAX); + assert(xtc_project_get_counts(q, &tracks, &clips) == XTC_OK && tracks == 1 && clips == 1); + } + xtc_project_destroy(p); xtc_project_destroy(q); + assert(unlink(path) == 0 && unlink(copy) == 0 && rmdir(directory) == 0); + puts("MIDI notes/persistence/scheduling/trim/seek/panic/live offsets and polyphony passed."); + return 0; +}