Phase 7: Add MIDI clips, instrument and piano roll
This commit is contained in:
parent
8a37090b81
commit
cacc086805
38 changed files with 1373 additions and 74 deletions
31
Makefile
31
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
|
||||
|
|
|
|||
11
README.md
11
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
13
docs/GUI.md
13
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.
|
||||
|
|
|
|||
73
docs/MIDI.md
Normal file
73
docs/MIDI.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
16
docs/decisions/0006-midi-instrument-and-notes.md
Normal file
16
docs/decisions/0006-midi-instrument-and-notes.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
57
include/xtc/xtc_midi.h
Normal file
57
include/xtc/xtc_midi.h
Normal file
|
|
@ -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
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,18 @@
|
|||
|
||||
#ifdef XTC_HAVE_JACK
|
||||
#include <jack/jack.h>
|
||||
#include <jack/midiport.h>
|
||||
#include <errno.h>
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<double>(node.value));
|
||||
else if (node.type == XTC_NODE_GAIN) std::snprintf(text, sizeof(text), "x %.2f", static_cast<double>(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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "main_window.hpp"
|
||||
#include "graph_widget.hpp"
|
||||
#include "sequencer_widget.hpp"
|
||||
#include "piano_roll.hpp"
|
||||
#include <FL/Fl_Native_File_Chooser.H>
|
||||
#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<MainWindow *>(c)->mark_dirty(); }, this);
|
||||
add_track->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_track(); }, sequencer_);
|
||||
add_midi->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_midi_clip(); }, sequencer_);
|
||||
add_region->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_region(); }, sequencer_);
|
||||
delete_region->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->delete_selection(); }, sequencer_);
|
||||
zoom_in->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->zoom(1); }, sequencer_);
|
||||
|
|
@ -126,6 +136,8 @@ MainWindow::MainWindow(bool prompt_unsaved)
|
|||
audio->callback([](Fl_Widget *, void *context) {
|
||||
(void)static_cast<GraphWidget *>(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<GraphWidget *>(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<GraphWidget *>(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<Fl_Double_Window>(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<MainWindow *>(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<MainWindow *>(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<MainWindow *>(context);
|
||||
if (!window->ready_) return;
|
||||
xtc_result r = xtc_backend_panic(window->backend_);
|
||||
if (r != XTC_OK) window->report_error("Panic failed", r);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<xtc_project, decltype(&xtc_project_destroy)> project_{nullptr, xtc_project_destroy};
|
||||
std::unique_ptr<Fl_Double_Window> 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_;
|
||||
|
|
|
|||
138
src/gui/piano_roll.cpp
Normal file
138
src/gui/piano_roll.cpp
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#include "piano_roll.hpp"
|
||||
#include <FL/Fl.H>
|
||||
#include <FL/fl_draw.H>
|
||||
#include <FL/fl_ask.H>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
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<int>(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<uint64_t>(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<int>(n.start_tick * scale / 960);
|
||||
int right = left + std::max(3, static_cast<int>(n.length_tick * scale / 960));
|
||||
int py = y() + header + (127 - static_cast<int>(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<int>(n.start_tick * scale / 960);
|
||||
int right = left + std::max(3, static_cast<int>(n.length_tick * scale / 960));
|
||||
int top = y() + header + (127 - static_cast<int>(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<uint64_t>(960, c.source_length_tick - start), static_cast<unsigned int>(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<int64_t>(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<int64_t>(original_.start_tick), length = static_cast<int64_t>(original_.length_tick);
|
||||
unsigned int pitch = original_.pitch;
|
||||
if (mode == 1) {
|
||||
start = std::clamp<int64_t>(start + delta, 0, static_cast<int64_t>(c.source_length_tick) - length);
|
||||
pitch = static_cast<unsigned int>(std::clamp(static_cast<int>(pitch) - (Fl::event_y() - drag_y_) / row_height, 0, 127));
|
||||
} else {
|
||||
int64_t maximum = static_cast<int64_t>(c.source_length_tick) - start;
|
||||
length = std::clamp<int64_t>(length + delta, std::min<int64_t>(240, maximum), maximum);
|
||||
}
|
||||
xtc_result r = before_ ? before_(context_) : XTC_OK;
|
||||
if (r == XTC_OK) r = xtc_project_edit_note(project_, selected_, static_cast<uint64_t>(start),
|
||||
static_cast<uint64_t>(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);
|
||||
}
|
||||
27
src/gui/piano_roll.hpp
Normal file
27
src/gui/piano_roll.hpp
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#ifndef XTC_PIANO_ROLL_HPP
|
||||
#define XTC_PIANO_ROLL_HPP
|
||||
#include <FL/Fl_Widget.H>
|
||||
#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
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
#include "sequencer_widget.hpp"
|
||||
#include "xtc/xtc_midi.h"
|
||||
#include <FL/Fl.H>
|
||||
#include <FL/Fl_Scroll.H>
|
||||
#include <FL/fl_draw.H>
|
||||
|
|
@ -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<int>(cursor_ * static_cast<unsigned int>(scale_) / 960U);
|
||||
|
|
|
|||
|
|
@ -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_; }
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
173
src/midi/instrument.c
Normal file
173
src/midi/instrument.c
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
#include "xtc/xtc_midi.h"
|
||||
#include "../project/project_internal.h"
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#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;
|
||||
}
|
||||
88
src/midi/model.c
Normal file
88
src/midi/model.c
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#include "../project/project_internal.h"
|
||||
#include "../graph/graph_internal.h"
|
||||
#include <string.h>
|
||||
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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <unistd.h>
|
||||
#include <FL/Fl.H>
|
||||
#include <FL/Fl_Box.H>
|
||||
|
|
@ -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<unsigned int>(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;
|
||||
|
|
|
|||
|
|
@ -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 <FL/Fl.H>
|
||||
#include <FL/Fl_Group.H>
|
||||
#include <chrono>
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
108
tests/integration/test_jack_midi.c
Normal file
108
tests/integration/test_jack_midi.c
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "xtc/xtc_backend.h"
|
||||
#include <jack/jack.h>
|
||||
#include <jack/midiport.h>
|
||||
#include <errno.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
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;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
* separately by test_realtime. No fake header replaces installed declarations. */
|
||||
#include "xtc/xtc_backend.h"
|
||||
#include <jack/jack.h>
|
||||
#include <jack/midiport.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
|
@ -11,13 +12,16 @@
|
|||
#include <unistd.h>
|
||||
#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);
|
||||
|
|
|
|||
167
tests/unit/test_midi.c
Normal file
167
tests/unit/test_midi.c
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "xtc/xtc_midi.h"
|
||||
#include "xtc/xtc_audio.h"
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue