From fe484537f963f405a422f88115c048341bb1025c Mon Sep 17 00:00:00 2001 From: hanez Date: Tue, 27 Oct 2026 17:51:48 +0100 Subject: [PATCH] Phase 32: add MIDI track CLAP, VST2 and VST3 instruments --- Makefile | 21 +++ docs/KNOWN_LIMITATIONS.md | 33 +++- docs/MIDI.md | 31 ++++ docs/PLUGIN_HOSTING.md | 38 ++++- docs/PROJECT_FORMAT.md | 30 +++- docs/RECOVERY.md | 35 ++++- docs/RELEASE_NOTES.md | 8 +- docs/ROADMAP.md | 30 ++++ docs/USER_MANUAL.md | 33 +++- docs/decisions/0011-midi-track-instruments.md | 27 ++++ include/xtc/xtc_plugin.h | 9 ++ include/xtc/xtc_project.h | 2 +- src/graph/graph.c | 3 + src/gui/main.cpp | 2 +- src/gui/main_window.cpp | 51 ++++++ src/gui/main_window.hpp | 3 + src/gui/plugin_dialog.cpp | 3 +- src/gui/plugin_dialog.hpp | 2 +- src/main.c | 13 +- src/midi/instrument.c | 90 ++++++++++- src/midi/model.c | 37 +++-- src/persistence/project_file.c | 34 ++++ src/plugin/host.c | 6 +- src/plugin/ladspa.c | 4 +- src/plugin/modern.c | 147 ++++++++++++++++-- src/plugin/modern.h | 1 + src/plugin/vst2_abi.h | 6 + src/project/project.c | 50 ++++++ src/project/project_internal.h | 4 + tests/fixtures/README.md | 2 +- tests/fixtures/project-v12.xtc | 41 +++++ tests/integration/test_gui.cpp | 21 +++ tests/integration/test_install.py | 2 +- tests/integration/test_instruments.c | 121 ++++++++++++++ tests/integration/test_portability.c | 4 +- tests/integration/test_project_inspection.py | 20 ++- tests/plugins/clap_synth_fixture.c | 100 ++++++++++++ tests/plugins/vst2_synth_fixture.c | 45 ++++++ tests/plugins/vst3_synth_fixture.c | 88 +++++++++++ tests/unit/test_mixer.c | 2 +- tests/unit/test_project_diagnostics.c | 4 +- 41 files changed, 1138 insertions(+), 65 deletions(-) create mode 100644 docs/decisions/0011-midi-track-instruments.md create mode 100644 tests/fixtures/project-v12.xtc create mode 100644 tests/integration/test_instruments.c create mode 100644 tests/plugins/clap_synth_fixture.c create mode 100644 tests/plugins/vst2_synth_fixture.c create mode 100644 tests/plugins/vst3_synth_fixture.c diff --git a/Makefile b/Makefile index 99be9ae..74ca728 100644 --- a/Makefile +++ b/Makefile @@ -490,3 +490,24 @@ test-gui-modern-plugins: @echo "FLTK development files are required for modern plugin GUI tests." @exit 1 endif + +$(BUILD_DIR)/clap-synth-fixture.clap: tests/plugins/clap_synth_fixture.c + @mkdir -p $(@D) + $(CC) $(CPPFLAGS) $(CFLAGS) -isystem third_party/clap/include $(LDFLAGS) -fPIC -shared -o $@ $< +$(BUILD_DIR)/vst2-synth-fixture.so: tests/plugins/vst2_synth_fixture.c src/plugin/vst2_abi.h + @mkdir -p $(@D) + $(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) -fPIC -shared -o $@ $< +$(BUILD_DIR)/vst3-synth-fixture.so: tests/plugins/vst3_synth_fixture.c + @mkdir -p $(@D) + $(CC) $(CPPFLAGS) $(CFLAGS) -isystem third_party/vst3 $(LDFLAGS) -fPIC -shared -o $@ $< +$(BUILD_DIR)/test_instruments: $(BUILD_DIR)/tests/integration/test_instruments.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -ldl -Wl,--wrap=malloc,--wrap=calloc,--wrap=realloc,--wrap=free,--wrap=dlopen,--wrap=dlclose +.PHONY: test-instruments +test-instruments: $(BUILD_DIR)/test_instruments $(BUILD_DIR)/clap-synth-fixture.clap $(BUILD_DIR)/vst2-synth-fixture.so $(BUILD_DIR)/vst3-synth-fixture.so + ./$(BUILD_DIR)/test_instruments "$(abspath $(BUILD_DIR))/clap-synth-fixture.clap" "$(abspath $(BUILD_DIR))/vst2-synth-fixture.so" "$(abspath $(BUILD_DIR))/vst3-synth-fixture.so" +test: test-instruments +-include $(BUILD_DIR)/tests/integration/test_instruments.d + +.PHONY: test-gui-instruments +test-gui-instruments: $(BUILD_DIR)/test_gui $(BUILD_DIR)/clap-synth-fixture.clap $(BUILD_DIR)/vst2-synth-fixture.so $(BUILD_DIR)/vst3-synth-fixture.so + XTC_TEST_SYNTH_CLAP="$(abspath $(BUILD_DIR))/clap-synth-fixture.clap" XTC_TEST_SYNTH_VST2="$(abspath $(BUILD_DIR))/vst2-synth-fixture.so" XTC_TEST_SYNTH_VST3="$(abspath $(BUILD_DIR))/vst3-synth-fixture.so" xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index e6892cb..3bc11d2 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -22,7 +22,7 @@ plus baseline and at most one reserved metadata snapshot; GUI edits reserve history before mutation/publication. Media is never removed by undo/delete. Broad screen-reader/accessibility testing is pending. -- Format v11 reads v1–v10 and uses relative local media references. External absolute +- Format v12 reads v1–v11 and uses relative local media references. External absolute media/module paths need manual repair after relocation. Saving does not bundle media; missing media stays referenced, missing plugins reject GUI installation. Unknown data is rejected rather than stripped. Post-rename directory-sync errors @@ -81,3 +81,34 @@ workers, sidechains or true stereo/multichannel buses are implemented. Plugin bundles must be opened at their actual Linux shared-library binary. Only original ABI fixtures are qualified; arbitrary third-party modern plugins remain untested. See PLUGIN_HOSTING.md and decision 0010 for lifecycle and licensing boundaries. + +## Phase 32 instrument support + +MIDI tracks can bind CLAP, VST2 and VST3 instruments. In the Track menu, select +**Load instrument plugin** for the selected sequencer track; **Use built-in +instrument** removes the binding. Assignment also selects the track for live +MIDI. **Live MIDI to selected instrument** changes that target; **Live MIDI to +built-in sine** restores the legacy live source. MIDI clip notes always go to +their own track's instrument. Independent instances feed track gain/pan, +mute/solo, bus routing and automation. Bindings/live target save in v12. + +Supply the actual trusted Linux binary (including the inner .so of a VST3 +bundle). Supported instruments have zero audio inputs, one mono output, +float32 processing, zero latency and one note/event input. Default configuration +only: no custom editor, opaque state/preset restoration, modern parameter +editing/automation, stereo/multichannel instruments or MIDI output. No arbitrary +third-party instrument is qualified by the original ABI fixture tests. + +CLAP accepts MIDI or native notes; VST2 uses MIDI events; VST3 uses IEventList +note events. Live CC reaches CLAP MIDI/VST2; native CLAP/VST3 have no controller +mapping (including sustain). Live MIDI goes to exactly one selected target. +Seeks/loops retrigger notes crossing the destination with fresh envelopes; +plugin oscillator/opaque DSP state cannot be reconstructed. Panic delivers +all-off (VST3 held-key note-offs); plugin release tails may remain. Processing +uses preallocated 256-frame chunks and 256 incoming events per instance/chunk; +overflow or processing failure silences the block and schedules reset events. +The existing 32 simultaneous scheduled-note and 4096 stored-note bounds remain. +Do not unload/replace modules during playback; candidate plans are prepared off +the callback and old instances are reclaimed on the control thread. Missing +references remain inspectable without loading; failed GUI preparation retains +the current project. Save a backup for applications limited to v11 or earlier. diff --git a/docs/MIDI.md b/docs/MIDI.md index 8c2ef2c..ff7972f 100644 --- a/docs/MIDI.md +++ b/docs/MIDI.md @@ -136,3 +136,34 @@ Phase 26: CC7/10/64 are defaults, configurable per channel through Audio → MID controller mapping. Disabled/unmapped CCs have no effect; CC120/123 stay panic. Decode accepts valid CC0..127 messages within the existing event budget. See CONTROL.md for the exact bindings, ramp, sustain and automation precedence rules. + +## Phase 32 instrument support + +MIDI tracks can bind CLAP, VST2 and VST3 instruments. In the Track menu, select +**Load instrument plugin** for the selected sequencer track; **Use built-in +instrument** removes the binding. Assignment also selects the track for live +MIDI. **Live MIDI to selected instrument** changes that target; **Live MIDI to +built-in sine** restores the legacy live source. MIDI clip notes always go to +their own track's instrument. Independent instances feed track gain/pan, +mute/solo, bus routing and automation. Bindings/live target save in v12. + +Supply the actual trusted Linux binary (including the inner .so of a VST3 +bundle). Supported instruments have zero audio inputs, one mono output, +float32 processing, zero latency and one note/event input. Default configuration +only: no custom editor, opaque state/preset restoration, modern parameter +editing/automation, stereo/multichannel instruments or MIDI output. No arbitrary +third-party instrument is qualified by the original ABI fixture tests. + +CLAP accepts MIDI or native notes; VST2 uses MIDI events; VST3 uses IEventList +note events. Live CC reaches CLAP MIDI/VST2; native CLAP/VST3 have no controller +mapping (including sustain). Live MIDI goes to exactly one selected target. +Seeks/loops retrigger notes crossing the destination with fresh envelopes; +plugin oscillator/opaque DSP state cannot be reconstructed. Panic delivers +all-off (VST3 held-key note-offs); plugin release tails may remain. Processing +uses preallocated 256-frame chunks and 256 incoming events per instance/chunk; +overflow or processing failure silences the block and schedules reset events. +The existing 32 simultaneous scheduled-note and 4096 stored-note bounds remain. +Do not unload/replace modules during playback; candidate plans are prepared off +the callback and old instances are reclaimed on the control thread. Missing +references remain inspectable without loading; failed GUI preparation retains +the current project. Save a backup for applications limited to v11 or earlier. diff --git a/docs/PLUGIN_HOSTING.md b/docs/PLUGIN_HOSTING.md index 6462b82..cf686c8 100644 --- a/docs/PLUGIN_HOSTING.md +++ b/docs/PLUGIN_HOSTING.md @@ -19,7 +19,7 @@ plugin recovery policy, preparation failure/partial cleanup tests, bounded event and parameter publication, latency/tail handling, callback-safe unload after retirement, a GUI/main-thread boundary and an explicit crash-containment decision. Native ABI tests cover some ownership mechanics but do not prove third-party -code safe. Event graph ports and custom-instrument GUI selection remain gaps. +code safe. Event graph ports remain a gap; Phase 32 adds track instrument selection. Choose one format with concrete user needs after those contracts are specified; do not introduce a general multi-format framework now. See decision 0008. @@ -113,8 +113,7 @@ Physical listening and plugin CPU/endurance qualification remain pending. Discovery remains explicit-file only, bounded to 1024 descriptor indices; no recursive scan, plugin database or new dependency is introduced. Unsupported layouts are skipped with counts; source/effect roles describe audio ports, not -MIDI instrument capabilities. Built-in MIDI synthesis remains the only GUI MIDI -instrument. LADSPA sources can be identified, edited, replaced and removed in +MIDI instrument capabilities. Phase 32 adds modern track instruments (see below). LADSPA sources can be identified, edited, replaced and removed in the graph but cannot receive MIDI events. **Edit → Insert LADSPA on selected wire** replaces one existing connection with @@ -224,7 +223,7 @@ prefers CLAP, then VST2, then VST3, otherwise LADSPA; the C API also provides The modern subset is **one mono audio input and one mono output, float32, default configuration, zero reported latency**. Stereo graph playback uses independent -instances. True stereo/multichannel/sidechain buses, instruments, MIDI/events, +instances. For the Phase 31 effect subset, true stereo/multichannel/sidechain buses, instruments, MIDI/events, custom editors, parameter changes/automation, programs/presets, opaque state, transport/time information, workers and latency compensation are not implemented for these three formats. Modern metadata with controls/automation is rejected, @@ -257,3 +256,34 @@ CLAP start/process failures, graph replacement rollback, 50 plan retirements per format, missing modules, v11 save/reopen and GUI add/browser/undo/reopen. Host allocation/loading/unloading guards remain active while rendering. This evidence qualifies the subset above, not arbitrary external plugins or physical audio. + +## Phase 32 instrument support + +MIDI tracks can bind CLAP, VST2 and VST3 instruments. In the Track menu, select +**Load instrument plugin** for the selected sequencer track; **Use built-in +instrument** removes the binding. Assignment also selects the track for live +MIDI. **Live MIDI to selected instrument** changes that target; **Live MIDI to +built-in sine** restores the legacy live source. MIDI clip notes always go to +their own track's instrument. Independent instances feed track gain/pan, +mute/solo, bus routing and automation. Bindings/live target save in v12. + +Supply the actual trusted Linux binary (including the inner .so of a VST3 +bundle). Supported instruments have zero audio inputs, one mono output, +float32 processing, zero latency and one note/event input. Default configuration +only: no custom editor, opaque state/preset restoration, modern parameter +editing/automation, stereo/multichannel instruments or MIDI output. No arbitrary +third-party instrument is qualified by the original ABI fixture tests. + +CLAP accepts MIDI or native notes; VST2 uses MIDI events; VST3 uses IEventList +note events. Live CC reaches CLAP MIDI/VST2; native CLAP/VST3 have no controller +mapping (including sustain). Live MIDI goes to exactly one selected target. +Seeks/loops retrigger notes crossing the destination with fresh envelopes; +plugin oscillator/opaque DSP state cannot be reconstructed. Panic delivers +all-off (VST3 held-key note-offs); plugin release tails may remain. Processing +uses preallocated 256-frame chunks and 256 incoming events per instance/chunk; +overflow or processing failure silences the block and schedules reset events. +The existing 32 simultaneous scheduled-note and 4096 stored-note bounds remain. +Do not unload/replace modules during playback; candidate plans are prepared off +the callback and old instances are reclaimed on the control thread. Missing +references remain inspectable without loading; failed GUI preparation retains +the current project. Save a backup for applications limited to v11 or earlier. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index 8ef1381..b70976e 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -1,4 +1,4 @@ -# Project format v11 (reads v1–v10) +# Project format v12 (reads v1–v11) Phases 5–31 use a bounded text format implemented in C99 without a parser library. The extension is `.xtc`. File operations run exclusively on the control thread. @@ -104,8 +104,8 @@ 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. Version 3 accepts instrument nodes and MIDI/NOTES records. Versions 1–3 migrate all tracks to unity gain, centered pan, mute/solo off and no automation. -Saving always writes v11. Unknown versions are rejected without replacing the destination; older -applications without v11 support cannot read current saves. Migration and maximum-path roundtrips are tested. +Saving always writes v12. Unknown versions are rejected without replacing the destination; older +applications without v12 support cannot read current saves. Migration and maximum-path roundtrips are tested. ## Ownership and failures @@ -274,7 +274,7 @@ must rebuild and initialize the appended `xtc_automation_point.step` field. ## Phase 28 compatibility and diagnostic contract -The current reader accepts versions 1–11 and the writer always emits version 11. +The current reader accepts versions 1–12 and the writer always emits version 12. Phase 28 made no format change; Phase 31 adds plugin format identity in v11. Saving an older document upgrades it; retain an original backup if an older application must still open it. APP is informational; compatibility depends on the format version, not the producer's @@ -367,3 +367,25 @@ publishing. Missing module metadata survives C load/save; GUI preparation retain the existing document on failure. No automatic path search or plugin substitution occurs. Current and historical migration fixtures, malformed fields, identity mismatch, missing-module and exact-render roundtrips cover this extension. + +## Phase 32: version 12 track instruments + +After CONTROL_MAPS/MAP records and before END: + +``` +TRACK_INSTRUMENTS count +INSTRUMENT track_id format descriptor_index unique_id hex_label +MODULE hex_absolute_binary_path +IDENTITY hex_stable_id_or_dash +LIVE_INSTRUMENT track_id_or_18446744073709551615 +END +``` + +Each binding comprises INSTRUMENT/MODULE/IDENTITY. Formats 1/2/3 mean +CLAP/VST2/VST3. Bindings require distinct existing non-bus tracks and compatible +instrument metadata (zero audio inputs). The live target must have a binding; +UINT64_MAX selects built-in sine. Empty bindings are valid. Older documents +migrate to no bindings and built-in live sine. Parsing/inspection never executes +modules; preparing playback verifies binary identity and configuration. Deleting +tracks removes bindings; reordering preserves stable track identities. Undo +snapshots include bindings. Public clients must rebuild for the new spec field. diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 5e337b7..fac1474 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -1,6 +1,6 @@ # Persistence and failure recovery -The project format is v11. Readers migrate v1–v10, validate bounded input +The project format is v12. Readers migrate v1–v11, validate bounded input and atomically install a candidate only after complete success. Existing tests cover exact migration/roundtrip, truncations, malformed references, counts, IDs, nonfinite values and failed GUI/backend installation. Native processors @@ -87,7 +87,7 @@ rename and directory-sync errors. Pre-rename failures retain the previous bytes and clean temporary files. Post-rename sync errors leave a complete new file and return failure; there is no rollback after replacement. Load diagnostics identify the expected record and syntax line, or line zero for whole-model/I/O stages. -Unknown versions report the supported range (1–11); unknown records/fields are +Unknown versions report the supported range (1–12); unknown records/fields are rejected without partially changing the current project. Modern CLAP/VST2/VST3 module identity is preserved in v11 metadata even when the @@ -96,3 +96,34 @@ CLAP restart and VST2 I/O-change requests fail/silence subsequent blocks; stop and rebuild/reload rather than attempting callback-time reconfiguration. Modern instances restart at default configuration; no edited opaque state is exposed or saved by the initial adapters. Trusted plugin crashes cannot be contained. + +## Phase 32 instrument support + +MIDI tracks can bind CLAP, VST2 and VST3 instruments. In the Track menu, select +**Load instrument plugin** for the selected sequencer track; **Use built-in +instrument** removes the binding. Assignment also selects the track for live +MIDI. **Live MIDI to selected instrument** changes that target; **Live MIDI to +built-in sine** restores the legacy live source. MIDI clip notes always go to +their own track's instrument. Independent instances feed track gain/pan, +mute/solo, bus routing and automation. Bindings/live target save in v12. + +Supply the actual trusted Linux binary (including the inner .so of a VST3 +bundle). Supported instruments have zero audio inputs, one mono output, +float32 processing, zero latency and one note/event input. Default configuration +only: no custom editor, opaque state/preset restoration, modern parameter +editing/automation, stereo/multichannel instruments or MIDI output. No arbitrary +third-party instrument is qualified by the original ABI fixture tests. + +CLAP accepts MIDI or native notes; VST2 uses MIDI events; VST3 uses IEventList +note events. Live CC reaches CLAP MIDI/VST2; native CLAP/VST3 have no controller +mapping (including sustain). Live MIDI goes to exactly one selected target. +Seeks/loops retrigger notes crossing the destination with fresh envelopes; +plugin oscillator/opaque DSP state cannot be reconstructed. Panic delivers +all-off (VST3 held-key note-offs); plugin release tails may remain. Processing +uses preallocated 256-frame chunks and 256 incoming events per instance/chunk; +overflow or processing failure silences the block and schedules reset events. +The existing 32 simultaneous scheduled-note and 4096 stored-note bounds remain. +Do not unload/replace modules during playback; candidate plans are prepared off +the callback and old instances are reclaimed on the control thread. Missing +references remain inspectable without loading; failed GUI preparation retains +the current project. Save a backup for applications limited to v11 or earlier. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 497e4df..f04e980 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -15,7 +15,7 @@ undo/redo and a generic LADSPA editor. JACK is the only live audio/MIDI backend; core validation and offline rendering work without it. Native/LADSPA processors are trusted in-process code. No proprietary energyXT resources are included. -Project format v11 reads versions 1–11 transactionally. Syntax diagnostics identify +Project format v12 reads versions 1–12 transactionally. Syntax diagnostics identify records and lines. `xtc-check --inspect` lists dependency paths without executing plugins. Phase 29 adds timing/CPU/RSS reports, repeatable stress runs and virtual endurance. Phase 30 adds configurable install directories, shared install/remove @@ -24,7 +24,7 @@ inventory, installed MIDI examples and source archive checksum sidecars. ## Upgrading and removal Back up documents and external media before upgrading. Opening an older project -does not rewrite it; saving upgrades it to v11, which older readers may reject. +does not rewrite it; saving upgrades it to v12, which older readers may reject. Use Save As for a separate migrated copy. Moving a project folder preserves media stored inside it via relative references. Absolute media and plugin module paths must be restored separately. There is no bundled plugin/media migration or saved @@ -57,3 +57,7 @@ GUI discovery/insertion/replacement and v11 format identity. Pinned MIT CLAP and BSD-3-Clause VST3 C headers ship with notices; VST2 uses original compatibility declarations. Modern instruments, parameters/state/editors and multichannel layouts remain unsupported. No third-party modern plugin has been qualified. + +Phase 32 adds CLAP/VST2/VST3 mono instruments bound to MIDI tracks, sample-offset +notes, a saved live target, track mixing, GUI assignment/undo and v12 persistence. +See PLUGIN_HOSTING.md for compatibility and controller/state limits. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 78e986a..ea119cb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -901,6 +901,36 @@ These require separate future work; they are not claimed as implemented by loadi --- +## Phase 32 — MIDI Track Plugin Instruments + +### Goal +Play CLAP, VST2 and VST3 synthesizer/instrument plugins from MIDI clips and live MIDI. + +### Tasks and acceptance criteria +- [x] Discover and initialize compatible zero-input, mono float32, zero-latency instruments. +- [x] Bind independent instances to tracks; preserve built-in sine for unassigned tracks. +- [x] Deliver sample-offset notes and bounded event queues without callback allocations. +- [x] Apply track/bus mixing and provide an explicit saved live-MIDI target. +- [x] Handle discontinuity note chase, panic, teardown and failed preparation. +- [x] Save bindings in v12; migrate v1–v11 and retain missing module references. +- [x] Add GUI load/clear/live-target commands with undo and candidate preparation. +- [x] Test original fixtures for all three formats and document the supported subset. + +### Implementation status +Implemented with track-owned prepared instances and 256-frame processing chunks. +Only original ABI fixtures are qualified; arbitrary third-party instruments and +physical MIDI/audio hardware remain unverified. Mono/default configuration only; +no custom editors, opaque state or modern parameter automation. Native-note CLAP +and VST3 carry notes only; CLAP MIDI and VST2 also receive live controllers. +Seeking retriggers held scheduled notes with fresh envelopes; plugin release tails +can remain after note-off/panic. See PLUGIN_HOSTING.md and decision 0011. + +Validation: normal/minimal core and GUI builds, core/instrument/GUI fixtures, +JACK/workflow, reliability, ASan/UBSan core and GUI, static analysis, staged +installation, reproducible source releases and relocatable binary packages pass. + +--- + ## Recommended Execution Order Reorder the phases when the audit reveals a blocking dependency. Do not mark a phase complete merely because code exists: its acceptance criteria must be demonstrated by builds, tests, or documented manual verification. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 868b2a4..b26ef94 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -166,5 +166,36 @@ CLAP/VST2/VST3 effect subset. Select the actual module binary (`.clap` or `.so`, including a VST3 bundle's inner binary). Modern effects require one mono input and output, float32 and zero latency; they use default configuration. Instruments, modern parameter editing/automation, opaque state and custom editors are not -implemented. Projects now save v11 and read v1–v11. See PLUGIN_HOSTING.md for the +implemented. Projects now save v12 and read v1–v12. See PLUGIN_HOSTING.md for the full contract and test/qualification limits. + +## Phase 32 instrument support + +MIDI tracks can bind CLAP, VST2 and VST3 instruments. In the Track menu, select +**Load instrument plugin** for the selected sequencer track; **Use built-in +instrument** removes the binding. Assignment also selects the track for live +MIDI. **Live MIDI to selected instrument** changes that target; **Live MIDI to +built-in sine** restores the legacy live source. MIDI clip notes always go to +their own track's instrument. Independent instances feed track gain/pan, +mute/solo, bus routing and automation. Bindings/live target save in v12. + +Supply the actual trusted Linux binary (including the inner .so of a VST3 +bundle). Supported instruments have zero audio inputs, one mono output, +float32 processing, zero latency and one note/event input. Default configuration +only: no custom editor, opaque state/preset restoration, modern parameter +editing/automation, stereo/multichannel instruments or MIDI output. No arbitrary +third-party instrument is qualified by the original ABI fixture tests. + +CLAP accepts MIDI or native notes; VST2 uses MIDI events; VST3 uses IEventList +note events. Live CC reaches CLAP MIDI/VST2; native CLAP/VST3 have no controller +mapping (including sustain). Live MIDI goes to exactly one selected target. +Seeks/loops retrigger notes crossing the destination with fresh envelopes; +plugin oscillator/opaque DSP state cannot be reconstructed. Panic delivers +all-off (VST3 held-key note-offs); plugin release tails may remain. Processing +uses preallocated 256-frame chunks and 256 incoming events per instance/chunk; +overflow or processing failure silences the block and schedules reset events. +The existing 32 simultaneous scheduled-note and 4096 stored-note bounds remain. +Do not unload/replace modules during playback; candidate plans are prepared off +the callback and old instances are reclaimed on the control thread. Missing +references remain inspectable without loading; failed GUI preparation retains +the current project. Save a backup for applications limited to v11 or earlier. diff --git a/docs/decisions/0011-midi-track-instruments.md b/docs/decisions/0011-midi-track-instruments.md new file mode 100644 index 0000000..b760e81 --- /dev/null +++ b/docs/decisions/0011-midi-track-instruments.md @@ -0,0 +1,27 @@ +# 0011 — Prepared MIDI track instruments + +Accepted for Phase 32. Extend the modern C99 adapters with zero-audio-input, +mono-output instruments and one event input. Keep graph effects separate from +track bindings. Each assigned track owns one prepared instance; the existing +instrument source sums their mixed output and the sine output of unassigned +tracks. A stable saved live target prevents accidental broadcast/double playback. + +Bindings are copied metadata in the project/history model, serialized in v12. +Loading metadata never executes modules. GUI edits prepare a candidate before +publication, preserving the previous project on missing/invalid binaries. Existing +quiescent plan retirement owns destruction; no module operation belongs in audio. + +Preallocate 256-frame audio scratch and 256 incoming events per chunk. Stable +insertion orders reset, scheduled off/on and live events without callback sort or +allocation. VST3 translates into a bounded 2304-event IEventList (2048 possible +held keys plus 256 incoming events); panic expands held keys to note-offs. CLAP +prefers MIDI where supported, otherwise native note events; VST2 uses MIDI ABI +messages. Native CLAP and VST3 currently omit controller mapping. Existing +32 simultaneous scheduled notes and 4096 stored notes remain validation bounds. + +Discontinuities send all-off and chase spanning notes with fresh note-ons. +Panic suppresses already-started scheduled notes. Neither operation promises +opaque DSP reconstruction or removal of release tails. Overflow/process errors +silence the block and arm reset for the following render. Hosts cannot guarantee +that third-party processing is realtime safe. Qualification covers original ABI +fixtures; arbitrary third-party plugins, physical audio and MIDI remain unverified. diff --git a/include/xtc/xtc_plugin.h b/include/xtc/xtc_plugin.h index 8b2af37..6f55f32 100644 --- a/include/xtc/xtc_plugin.h +++ b/include/xtc/xtc_plugin.h @@ -21,6 +21,7 @@ typedef struct { xtc_plugin_format format; char identifier[128]; /* CLAP stable ID / VST3 class hex; empty for LADSPA/VST2. */ unsigned int index, unique_id, inputs, control_count; + unsigned int instrument; /* Modern zero-input note instrument; track binding only. */ unsigned int ports[XTC_PLUGIN_CONTROLS]; float values[XTC_PLUGIN_CONTROLS]; unsigned int tempo, point_count; /* milli-BPM, 20000..300000 */ @@ -69,6 +70,14 @@ void xtc_plugin_destroy(xtc_plugin *plugin); /* control, after quiescence */ /* Single audio owner. No loading/allocation/I/O in host process; trusted run() * must obey realtime rules. No MIDI; max one mono input and one mono output. * Transport reset retains LADSPA delay history (no realtime reset contract). */ +/* Instruments: sorted bounded MIDI, mono output, single audio owner. */ +xtc_result xtc_plugin_process_midi(xtc_plugin *plugin, const xtc_midi_event *events, size_t count, float *output, size_t frames); +/* Metadata-only track assignment. NULL restores built-in sine. Assignment ensures + * an instrument-source graph route transactionally. Missing modules stay saved. */ +xtc_result xtc_project_set_instrument(xtc_project *project, uint64_t track, const xtc_plugin_spec *spec); +xtc_result xtc_project_get_instrument(const xtc_project *project, uint64_t track, xtc_plugin_spec *out); +xtc_result xtc_project_set_live_instrument(xtc_project *project, uint64_t track); +xtc_result xtc_project_get_live_instrument(const xtc_project *project, uint64_t *track); xtc_result xtc_plugin_process_at(xtc_plugin *plugin, const float *input, float *output, size_t frames, uint64_t position); xtc_result xtc_plugin_process(xtc_plugin *plugin, const float *input, float *output, size_t frames); xtc_result xtc_graph_add_plugin(xtc_graph *graph, const xtc_plugin_spec *spec, size_t *id); diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index aa993f2..3c95771 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -7,7 +7,7 @@ extern "C" { #endif -#define XTC_PROJECT_FORMAT_VERSION 11U +#define XTC_PROJECT_FORMAT_VERSION 12U #define XTC_PROJECT_MAX_TRACKS 64U #define XTC_PROJECT_MAX_CLIPS 1024U #define XTC_PROJECT_NAME_BYTES 64U diff --git a/src/graph/graph.c b/src/graph/graph.c index 97b7ef2..995dbd5 100644 --- a/src/graph/graph.c +++ b/src/graph/graph.c @@ -568,6 +568,7 @@ xtc_result xtc_graph_add_plugin(xtc_graph *g, const xtc_plugin_spec *s, size_t * { xtc_result r = xtc_plugin_spec_validate(s); if (r != XTC_OK) return r; + if (s->instrument) return XTC_ERR_UNSUPPORTED; r = add_node(g,XTC_NODE_PLUGIN,0,id); if (r == XTC_OK) g->nodes[g->node_count-1].plugin = *s; return r; @@ -589,6 +590,7 @@ xtc_result xtc_graph_set_plugin(xtc_graph *g, size_t id, const xtc_plugin_spec * i = node_index(g,id); if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; if (g->nodes[i].type != XTC_NODE_PLUGIN) return XTC_ERR_INVALID_ARGUMENT; + if (s->instrument) return XTC_ERR_UNSUPPORTED; /* Parameter edits cannot replace identity/layout or invalidate wiring. */ if (s->format != g->nodes[i].plugin.format || strcmp(s->identifier,g->nodes[i].plugin.identifier) || strcmp(s->path,g->nodes[i].plugin.path) || strcmp(s->label,g->nodes[i].plugin.label) || @@ -605,6 +607,7 @@ xtc_result xtc_graph_replace_plugin(xtc_graph *g, size_t id, const xtc_plugin_sp i = node_index(g,id); if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; if (g->nodes[i].type != XTC_NODE_PLUGIN) return XTC_ERR_INVALID_ARGUMENT; + if (s->instrument) return XTC_ERR_UNSUPPORTED; if (g->nodes[i].plugin.inputs != s->inputs) return XTC_ERR_UNSUPPORTED; g->nodes[i].plugin = *s; return XTC_OK; diff --git a/src/gui/main.cpp b/src/gui/main.cpp index 9104802..c5f10a7 100644 --- a/src/gui/main.cpp +++ b/src/gui/main.cpp @@ -22,7 +22,7 @@ int main(int argc, char **argv) } if (argc == 2 && !std::strcmp(argv[1], "--diagnose")) { unsigned int features = xtc_build_features(); - std::printf("xtc %s; project formats 1..%u; JACK %s; LADSPA %s; CLAP/VST2/VST3 mono effects compiled\nFor limits and recovery commands run xtc-check --diagnose/--help.\n", + std::printf("xtc %s; project formats 1..%u; JACK %s; LADSPA %s; CLAP/VST2/VST3 mono effects/instruments compiled\nFor limits and recovery commands run xtc-check --diagnose/--help.\n", XTC_VERSION_STRING,XTC_PROJECT_FORMAT_VERSION,features & XTC_FEATURE_JACK ? "compiled" : "disabled", features & XTC_FEATURE_LADSPA ? "compiled" : "disabled"); return EXIT_SUCCESS; diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 5bac2aa..596aa7c 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -88,6 +88,10 @@ MainWindow::MainWindow(bool prompt_unsaved) 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("Track/Load instrument plugin", 0, instrument_callback, this); + menu->add("Track/Use built-in instrument", 0, instrument_callback, this); + menu->add("Track/Live MIDI to selected instrument", 0, instrument_callback, this); + menu->add("Track/Live MIDI to built-in sine", 0, instrument_callback, this); menu->add("File/Load plugin", 0, plugin_callback, this); menu->add("Edit/Replace selected plugin", 0, plugin_callback, this); menu->add("Edit/Insert plugin on selected wire", 0, plugin_callback, this); @@ -1424,6 +1428,53 @@ void MainWindow::history_callback(Fl_Widget *widget, void *context) window->report_error("Undo/redo failed", r); } +xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsigned int index) +{ + if (recorder_) return XTC_ERR_INVALID_STATE; + xtc_project *candidate = nullptr; + xtc_plugin_spec spec{}; + xtc_result r = path ? xtc_plugin_probe(path,index,48000,&spec) : XTC_OK; + if (r == XTC_OK && path && !spec.instrument) return XTC_ERR_UNSUPPORTED; + if (r == XTC_OK) r = xtc_project_clone(project_.get(),&candidate); + if (r == XTC_OK) r = xtc_project_set_instrument(candidate,track,path ? &spec : nullptr); + if (r == XTC_OK) r = xtc_history_reserve(history_.get()); + if (r == XTC_OK) r = install_project(candidate,false); + if (r != XTC_OK) xtc_project_destroy(candidate); else mark_dirty(); + return r; +} +xtc_result MainWindow::select_live_instrument(uint64_t track) +{ + if (recorder_) return XTC_ERR_INVALID_STATE; + xtc_project *candidate = nullptr; + xtc_result r = xtc_project_clone(project_.get(),&candidate); + if (r == XTC_OK) r = xtc_project_set_live_instrument(candidate,track); + if (r == XTC_OK) r = xtc_history_reserve(history_.get()); + if (r == XTC_OK) r = install_project(candidate,false); + if (r != XTC_OK) xtc_project_destroy(candidate); else mark_dirty(); + return r; +} +void MainWindow::instrument_callback(Fl_Widget *widget, void *context) +{ + auto *w = static_cast(context); + auto *menu = static_cast(widget); + const char *label = menu->mvalue()->label(); + uint64_t track = w->sequencer_->selected_track(); + xtc_result r; + if (std::strcmp(label,"Use built-in instrument") == 0) r = w->assign_instrument(track,nullptr,0); + else if (std::strcmp(label,"Live MIDI to selected instrument") == 0) r = w->select_live_instrument(track); + else if (std::strcmp(label,"Live MIDI to built-in sine") == 0) r = w->select_live_instrument(UINT64_MAX); + else { + Fl_Native_File_Chooser chooser; + chooser.title("Choose trusted mono instrument binary for selected track"); + chooser.filter("Plugin binaries\t*.{so,clap}\nAll files\t*"); + if (chooser.show() != 0 || !chooser.filename()) return; + unsigned int index = 0; + if (!xtc_choose_plugin(chooser.filename(),48000,index,true)) return; + r = w->assign_instrument(track,chooser.filename(),index); + } + if (r != XTC_OK) w->report_error("Instrument assignment failed; select a non-bus track and compatible mono instrument",r); +} + xtc_result MainWindow::set_control_map(unsigned int channel, const unsigned int controllers[3]) { if (recorder_) return XTC_ERR_INVALID_STATE; diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 85ad0c7..85e9947 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -22,6 +22,9 @@ class Fl_Menu_Bar; class MainWindow : public Fl_Double_Window { public: /* Control-thread plugin commands, transactional model adoption. */ + xtc_result assign_instrument(uint64_t track, const char *path, unsigned int index); + xtc_result select_live_instrument(uint64_t track); + static void instrument_callback(Fl_Widget *, void *); xtc_result set_control_map(unsigned int channel, const unsigned int controllers[3]); xtc_result add_plugin(const char *path, unsigned int index, size_t *id = nullptr); xtc_result set_plugin_automation(size_t id, const xtc_plugin_point &point, bool remove = false); diff --git a/src/gui/plugin_dialog.cpp b/src/gui/plugin_dialog.cpp index 41ef2be..b44f19a 100644 --- a/src/gui/plugin_dialog.cpp +++ b/src/gui/plugin_dialog.cpp @@ -22,7 +22,7 @@ struct Dialog : Fl_Double_Window { bool run() { callback(cancel,this); end(); show(); while (shown()) Fl::wait(); return accepted; } }; } -bool xtc_choose_plugin(const char *path, double rate, unsigned int &index) +bool xtc_choose_plugin(const char *path, double rate, unsigned int &index, bool instrument) { Dialog dialog(760,450,"Plugin descriptors"); auto *heading = new Fl_Box(15,10,730,40); @@ -36,6 +36,7 @@ bool xtc_choose_plugin(const char *path, double rate, unsigned int &index) xtc_result r = xtc_plugin_probe(path,i,rate,&spec); if (r == XTC_ERR_NOT_FOUND) break; if (r != XTC_OK) { ++rejected; continue; } + if (!!spec.instrument != instrument) { ++rejected; continue; } if (xtc_plugin_describe(&spec,rate,&details) != XTC_OK) { ++rejected; continue; } std::string row = std::to_string(i) + ": " + details.name + " (" + spec.label + ") — " + details.maker + (spec.inputs ? " [effect]" : " [source]"); diff --git a/src/gui/plugin_dialog.hpp b/src/gui/plugin_dialog.hpp index dcc21f9..a1aad8f 100644 --- a/src/gui/plugin_dialog.hpp +++ b/src/gui/plugin_dialog.hpp @@ -3,7 +3,7 @@ #include "xtc/xtc_plugin.h" /* Modal, control-thread-only views. They own copied metadata and widget state, * never engine/module handles. Cancel/close leaves caller configuration intact. */ -bool xtc_choose_plugin(const char *path, double rate, unsigned int &index); +bool xtc_choose_plugin(const char *path, double rate, unsigned int &index, bool instrument = false); bool xtc_edit_plugin(const xtc_plugin_spec &spec, const xtc_plugin_details &details, xtc_plugin_spec &edited); #endif diff --git a/src/main.c b/src/main.c index 4a523ac..932b62e 100644 --- a/src/main.c +++ b/src/main.c @@ -62,6 +62,17 @@ static int inspect(const xtc_project *project, uint64_t version) if (!available) ++missing; } } + for (i = 0; i < tracks; ++i) { + xtc_track_info track; xtc_plugin_spec spec; + xtc_project_get_track(project,i,&track); + if (xtc_project_get_instrument(project,track.id,&spec) == XTC_OK) { + int available = readable_file(spec.path); + printf("%s instrument track %" PRIu64 " descriptor %u ",xtc_plugin_format_name(spec.format),track.id,spec.index); + quoted(spec.identifier); putchar(' '); quoted(spec.label); + printf(": %s ",available ? "available" : "missing/unreadable"); quoted(spec.path); putchar('\n'); + if (!available) ++missing; + } + } printf("Dependency references missing/unreadable: %zu.\n",missing); puts("File availability only: WAV contents, plugin identity/initialization, JACK and hardware not checked; no plugin code executed."); return missing ? 2 : EXIT_SUCCESS; @@ -87,7 +98,7 @@ int main(int argc, char **argv) } if (argc == 2 && !strcmp(argv[1], "--diagnose")) { unsigned int features = xtc_build_features(); - printf("xtc %s; %zu-bit process\nProject formats: 1..%u\nJACK compiled: %s\nLADSPA compiled: %s\nCLAP/VST2/VST3: compiled (mono effects, default configuration)\n" + printf("xtc %s; %zu-bit process\nProject formats: 1..%u\nJACK compiled: %s\nLADSPA compiled: %s\nCLAP/VST2/VST3: compiled (mono effects/instruments, default configuration)\n" "Limits: 256 graph nodes (64/project), %u tracks, %u clips, %u notes, 65536 frames/block\n" "No server, device or plugin probed. Runtime errors appear in GUI status/stderr.\n", XTC_VERSION_STRING,sizeof(void *)*CHAR_BIT,XTC_PROJECT_FORMAT_VERSION, diff --git a/src/midi/instrument.c b/src/midi/instrument.c index b340201..a36dfdf 100644 --- a/src/midi/instrument.c +++ b/src/midi/instrument.c @@ -1,5 +1,6 @@ #include "xtc/xtc_midi.h" #include "xtc/xtc_dsp.h" +#include "xtc/xtc_plugin.h" #include "../mixer/mix_internal.h" #include #include @@ -33,6 +34,11 @@ struct xtc_midi_plan { unsigned char control_map[16][3]; xtc_mix_snapshot mix; span notes[XTC_PROJECT_MAX_NOTES]; + xtc_plugin *plugins[XTC_PROJECT_MAX_TRACKS]; + size_t live_track; + unsigned int plugin_reset; + float scratch[256]; + xtc_midi_event queue[256]; size_t count; voice voices[XTC_MIDI_VOICES]; double step[128]; @@ -96,6 +102,7 @@ void xtc_midi_plan_reset(xtc_midi_plan *p) memset(p->cutoff, 0, sizeof(p->cutoff)); p->next_position = UINT64_MAX; p->age = 0; + p->plugin_reset = 1; { unsigned int c; for (c = 0; c < 16; ++c) { p->sustain[c] = 0; (void)xtc_gain_ramp_reset(p->volume+c,1); @@ -189,6 +196,15 @@ xtc_result xtc_midi_plan_create(const xtc_project *project, unsigned int rate, x } } free(points); + p->live_track = SIZE_MAX; + for (i = 0; i < p->mix.count; ++i) { + xtc_plugin_spec spec; + if (xtc_project_get_instrument(project,p->mix.tracks[i].id,&spec) == XTC_OK) { + xtc_result r = xtc_plugin_create(&spec,rate,256,&p->plugins[i]); + if (r != XTC_OK) { xtc_midi_plan_destroy(p); return r; } + } + if (project->live_instrument == p->mix.tracks[i].id) p->live_track = i; + } memcpy(p->control_map,project->control_map,sizeof(p->control_map)); p->controller_frames = rate / 200U; if (!p->controller_frames) p->controller_frames = 1; @@ -204,6 +220,9 @@ xtc_result xtc_midi_plan_create(const xtc_project *project, unsigned int rate, x */ void xtc_midi_plan_destroy(xtc_midi_plan *p) { + size_t i; + if (!p) return; + for (i = 0; i < XTC_PROJECT_MAX_TRACKS; ++i) xtc_plugin_destroy(p->plugins[i]); free(p); } @@ -238,6 +257,7 @@ void xtc_midi_plan_panic(xtc_midi_plan *p, uint64_t position) for (c = 0; c < 16; ++c) channel_off(p, c, position); p->next_position = position; + p->plugin_reset = 1; } /** @@ -324,7 +344,7 @@ static void segment(xtc_midi_plan *p, uint64_t position, float *out, float *righ 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 == 0.0 || s->start < p->cutoff[s->channel] || end <= begin) + if ((s->track < p->mix.count && p->plugins[s->track]) || step == 0.0 || 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); @@ -371,6 +391,64 @@ static void segment(xtc_midi_plan *p, uint64_t position, float *out, float *righ } } +/* Stable insertion keeps resets, scheduled offs/ons and live events ordered. + * All storage belongs to the prepared plan; callback work is strictly bounded. */ +static int enqueue(xtc_midi_plan *p, size_t *count, xtc_midi_event e) +{ + size_t i = *count; + if (i == 256) return 0; + while (i && p->queue[i-1].offset > e.offset) { p->queue[i] = p->queue[i-1]; --i; } + p->queue[i] = e; ++*count; return 1; +} +static xtc_result instruments(xtc_midi_plan *p, uint64_t position, const xtc_midi_event *events, + size_t count, float *out, float *right, size_t frames) +{ + size_t track, offset, i, j; + for (track = 0; track < p->mix.count; ++track) { + if (!p->plugins[track]) continue; + for (offset = 0; offset < frames; offset += 256) { + size_t n = frames-offset < 256 ? frames-offset : 256, queued = 0; + uint64_t begin = position+offset, end = begin+n; + xtc_result r; + if (!offset && p->plugin_reset) { + unsigned int c; + for (c = 0; c < 16; ++c) + if (!enqueue(p,&queued,(xtc_midi_event){0,XTC_MIDI_ALL_OFF,c,0,0})) return XTC_ERR_CAPACITY; + } + /* End before start at equal offsets, including retriggered keys. */ + for (i = 0; i < p->count; ++i) { + const span *s = p->notes+i; + if (s->track == track && s->end >= begin && s->end < end) + if (!enqueue(p,&queued,(xtc_midi_event){(unsigned int)(s->end-begin),XTC_MIDI_NOTE_OFF,s->channel,s->pitch,0})) return XTC_ERR_CAPACITY; + } + for (i = 0; i < p->count; ++i) { + const span *s = p->notes+i; + int chase = !offset && p->plugin_reset && s->start < begin && s->end > begin; + if (s->track == track && s->start >= p->cutoff[s->channel] && + ((s->start >= begin && s->start < end) || chase)) + if (!enqueue(p,&queued,(xtc_midi_event){chase ? 0U : (unsigned int)(s->start-begin),XTC_MIDI_NOTE_ON,s->channel,s->pitch,s->velocity})) return XTC_ERR_CAPACITY; + } + if (track == p->live_track) for (i = 0; i < count; ++i) { + xtc_midi_event e = events[i]; + if (e.offset >= offset && e.offset < offset+n) { + e.offset -= (unsigned int)offset; + if (!enqueue(p,&queued,e)) return XTC_ERR_CAPACITY; + } + } + r = xtc_plugin_process_midi(p->plugins[track],p->queue,queued,p->scratch,n); + if (r != XTC_OK) return r; + for (j = 0; j < n; ++j) { + float sample = p->scratch[j]; + if (p->monitor) xtc_mix_monitor(&p->mix,p->monitor,p->monitor_stride,track,begin+j,offset+j,sample,right != NULL); + out[offset+j] += sample*xtc_mix_level(&p->mix,track,begin+j,right ? 0U : 2U); + if (right) right[offset+j] += sample*xtc_mix_level(&p->mix,track,begin+j,1); + } + } + } + p->plugin_reset = 0; + return XTC_OK; +} + /** * @brief Renders scheduled and live MIDI with sample-offset events and track gain and pan. * @@ -408,10 +486,18 @@ xtc_result xtc_midi_plan_render_stereo(xtc_midi_plan *p, uint64_t position, cons for (i = 0; i < count; ++i) { size_t next = events[i].offset; segment(p, position + cursor, out + cursor, right ? right + cursor : NULL, next - cursor); - event(p, events + i, position + next); + if (p->live_track == SIZE_MAX) event(p, events + i, position + next); cursor = next; } segment(p, position + cursor, out + cursor, right ? right + cursor : NULL, frames - cursor); + { + xtc_result r = instruments(p,position,events,count,out,right,frames); + if (r != XTC_OK) { + memset(out,0,frames*sizeof(*out)); + if (right) memset(right,0,frames*sizeof(*right)); + xtc_midi_plan_reset(p); return r; + } + } for (i = 0; i < frames; ++i) { out[i] = xtc_dsp_finite(out[i]); if (right) right[i] = xtc_dsp_finite(right[i]); diff --git a/src/midi/model.c b/src/midi/model.c index 1da7e52..4c885f7 100644 --- a/src/midi/model.c +++ b/src/midi/model.c @@ -2,6 +2,27 @@ #include "../graph/graph_internal.h" #include +xtc_result xtc_project_ensure_midi_route(xtc_project *c) +{ + size_t i, source = SIZE_MAX, output = SIZE_MAX; + xtc_result r = XTC_OK; + 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); + } + return r; +} + /** * @brief Adds a MIDI clip and ensures an instrument output route exists. * @@ -16,7 +37,6 @@ xtc_result xtc_project_add_midi_clip(xtc_project *p, uint64_t track, uint64_t st { 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; @@ -25,20 +45,7 @@ xtc_result xtc_project_add_midi_clip(xtc_project *p, uint64_t track, uint64_t st 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); - } + r = xtc_project_ensure_midi_route(c); } if (r == XTC_OK) { xtc_graph *old = p->graph; diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index ad4fa7d..4c85d87 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -443,6 +443,29 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * } } } + if (format_version >= 12U) { + size_t bindings; + READ("TRACK_INSTRUMENTS",2U); NUM(1U,value); + if (value > project->track_count) return XTC_ERR_FORMAT; + bindings = (size_t)value; + for (i = 0; i < bindings; ++i) { + uint64_t track; size_t slot; xtc_plugin_spec *s; + READ("INSTRUMENT",6U); NUM(1U,track); + for (slot = 0; slot < project->track_count && project->tracks[slot].id != track; ++slot) {} + if (slot == project->track_count || project->tracks[slot].is_bus || project->instruments[slot].instrument) return XTC_ERR_FORMAT; + s = project->instruments+slot; s->instrument = 1; s->tempo = project->tempo; + NUM(2U,value); if (value < XTC_PLUGIN_CLAP || value > XTC_PLUGIN_VST3) return XTC_ERR_UNSUPPORTED; + s->format = (xtc_plugin_format)value; + NUM(3U,value); if (value >= 1024) return XTC_ERR_FORMAT; s->index = (unsigned int)value; + NUM(4U,value); if (value > UINT32_MAX) return XTC_ERR_FORMAT; s->unique_id = (unsigned int)value; + if (!decode_bytes(input->tokens[5],s->label,sizeof(s->label))) return XTC_ERR_FORMAT; + READ("MODULE",2U); if (!decode_bytes(input->tokens[1],s->path,sizeof(s->path))) return XTC_ERR_FORMAT; + READ("IDENTITY",2U); + if (strcmp(input->tokens[1],"-") && !decode_bytes(input->tokens[1],s->identifier,sizeof(s->identifier))) return XTC_ERR_FORMAT; + if (xtc_plugin_spec_validate(s) != XTC_OK) return XTC_ERR_FORMAT; + } + READ("LIVE_INSTRUMENT",2U); NUM(1U,project->live_instrument); + } READ("END", 1U); if (fgetc(input->file) != EOF) return XTC_ERR_FORMAT; @@ -590,6 +613,17 @@ static xtc_result write_project(FILE *file, const xtc_project *project, const ch for (i = 0; i < 16; ++i) if (fprintf(file,"MAP %zu %u %u %u\n",i,(unsigned int)project->control_map[i][0], (unsigned int)project->control_map[i][1],(unsigned int)project->control_map[i][2]) < 0) return XTC_ERR_IO; + { size_t bindings = 0; + for (i = 0; i < project->track_count; ++i) if (project->instruments[i].instrument) ++bindings; + if (fprintf(file,"TRACK_INSTRUMENTS %zu\n",bindings) < 0) return XTC_ERR_IO; + for (i = 0; i < project->track_count; ++i) if (project->instruments[i].instrument) { + const xtc_plugin_spec *s = project->instruments+i; + if (fprintf(file,"INSTRUMENT %" PRIu64 " %u %u %u ",project->tracks[i].id,(unsigned int)s->format,s->index,s->unique_id) < 0 || + !write_name(file,s->label) || fputs("MODULE ",file) == EOF || !write_name(file,s->path) || fputs("IDENTITY ",file) == EOF || + (s->identifier[0] ? !write_name(file,s->identifier) : fputs("-\n",file) == EOF)) return XTC_ERR_IO; + } + if (fprintf(file,"LIVE_INSTRUMENT %" PRIu64 "\n",project->live_instrument) < 0) return XTC_ERR_IO; + } return fputs("END\n", file) == EOF ? XTC_ERR_IO : XTC_OK; } diff --git a/src/plugin/host.c b/src/plugin/host.c index 5d73562..51ab4af 100644 --- a/src/plugin/host.c +++ b/src/plugin/host.c @@ -42,7 +42,7 @@ xtc_result xtc_plugin_describe(const xtc_plugin_spec *s, double rate, xtc_plugin if (s->format == XTC_PLUGIN_LADSPA) return xtc_ladspa_describe(s,rate,out); r = xtc_plugin_probe_format(s->format,s->path,s->index,rate,&actual); if (r != XTC_OK) return r; - if (actual.unique_id != s->unique_id || strcmp(actual.identifier,s->identifier) || strcmp(actual.label,s->label)) return XTC_ERR_UNSUPPORTED; + if (actual.instrument != s->instrument || actual.unique_id != s->unique_id || strcmp(actual.identifier,s->identifier) || strcmp(actual.label,s->label)) return XTC_ERR_UNSUPPORTED; memset(out,0,sizeof(*out)); snprintf(out->name,sizeof(out->name),"%s",s->label); snprintf(out->maker,sizeof(out->maker),"Not exposed by this adapter"); snprintf(out->copyright,sizeof(out->copyright),"See plugin license"); @@ -61,7 +61,7 @@ xtc_result xtc_plugin_create(const xtc_plugin_spec *s, double rate, size_t frame if (s->format == XTC_PLUGIN_LADSPA) r = xtc_ladspa_create(s,rate,frames,&p->ladspa); else { r = xtc_modern_open(s->format,s->path,s->index,rate,frames,&p->modern,&actual); - if (r == XTC_OK && (actual.unique_id != s->unique_id || actual.inputs != s->inputs || + if (r == XTC_OK && (actual.instrument != s->instrument || actual.unique_id != s->unique_id || actual.inputs != s->inputs || strcmp(actual.identifier,s->identifier) || strcmp(actual.label,s->label))) r = XTC_ERR_UNSUPPORTED; } if (r != XTC_OK) { xtc_plugin_destroy(p); return r; } @@ -75,6 +75,8 @@ xtc_result xtc_plugin_process_at(xtc_plugin *p, const float *in, float *out, siz if (r == XTC_OK) p->next = position <= UINT64_MAX-frames ? position+frames : 0; return r; } +xtc_result xtc_plugin_process_midi(xtc_plugin *p, const xtc_midi_event *events, size_t count, float *out, size_t frames) +{ return p && p->modern ? xtc_modern_process_midi(p->modern,events,count,out,frames) : XTC_ERR_UNSUPPORTED; } xtc_result xtc_plugin_process(xtc_plugin *p, const float *in, float *out, size_t frames) { return xtc_plugin_process_at(p,in,out,frames,p ? p->next : 0); } xtc_result xtc_plugin_parameters(const xtc_plugin_spec *s, double rate, xtc_parameter_info out[XTC_PLUGIN_CONTROLS]) diff --git a/src/plugin/ladspa.c b/src/plugin/ladspa.c index c950447..432e597 100644 --- a/src/plugin/ladspa.c +++ b/src/plugin/ladspa.c @@ -27,7 +27,7 @@ xtc_result xtc_plugin_spec_validate(const xtc_plugin_spec *s) if (s->format < XTC_PLUGIN_LADSPA || s->format > XTC_PLUGIN_VST3 || !terminated(s->identifier,sizeof(s->identifier))) return XTC_ERR_INVALID_ARGUMENT; if (s->format != XTC_PLUGIN_LADSPA) { - if (s->inputs != 1 || s->control_count || s->point_count || + if (s->instrument > 1 || s->inputs != (s->instrument ? 0U : 1U) || s->control_count || s->point_count || ((s->format == XTC_PLUGIN_CLAP || s->format == XTC_PLUGIN_VST3) && !s->identifier[0])) return XTC_ERR_UNSUPPORTED; if (s->format == XTC_PLUGIN_VST2 && s->identifier[0]) return XTC_ERR_INVALID_ARGUMENT; @@ -37,7 +37,7 @@ xtc_result xtc_plugin_spec_validate(const xtc_plugin_spec *s) if (!((s->identifier[i] >= '0' && s->identifier[i] <= '9') || (s->identifier[i] >= 'a' && s->identifier[i] <= 'f'))) return XTC_ERR_INVALID_ARGUMENT; } - } else if (s->identifier[0]) return XTC_ERR_INVALID_ARGUMENT; + } else if (s->instrument || s->identifier[0]) return XTC_ERR_INVALID_ARGUMENT; for (i = 0; i < s->control_count; ++i) if (s->ports[i] >= 64 || (i && s->ports[i] <= s->ports[i-1]) || !isfinite(s->values[i])) return XTC_ERR_INVALID_ARGUMENT; diff --git a/src/plugin/modern.c b/src/plugin/modern.c index 411ea28..63e0906 100644 --- a/src/plugin/modern.c +++ b/src/plugin/modern.c @@ -24,6 +24,8 @@ typedef struct module { struct module *next; } module; static module *modules; +typedef union { clap_event_header_t header; clap_event_note_t note; clap_event_midi_t midi; } clap_note; +typedef struct { Steinberg_Vst_IEventList api; struct xtc_modern *owner; } vst_events; struct xtc_modern { module *module; xtc_plugin_format format; @@ -38,10 +40,19 @@ struct xtc_modern { double rate; pthread_t control_thread; int initialized, active, started; + unsigned int instrument, clap_midi; + size_t event_count, clap_count; + clap_note clap_events[XTC_MIDI_MAX_EVENTS]; + xtc_v2_midi v2_events[XTC_MIDI_MAX_EVENTS]; + struct Steinberg_Vst_Event vst_events[2304]; + size_t vst_count; + unsigned char keys[16][128]; + vst_events event_list; unsigned int callback, restart; struct xtc_modern *next; }; static xtc_modern *instances; +static Steinberg_Vst_IEventListVtbl event_vtable; static __thread int in_audio; static __thread xtc_modern *constructing; @@ -106,9 +117,10 @@ void xtc_plugin_idle(void) for (p = instances; p; p = p->next) if (p->clap && __atomic_exchange_n(&p->callback,0U,__ATOMIC_ACQ_REL)) p->clap->on_main_thread(p->clap); } -static uint32_t event_count(const clap_input_events_t *list) { (void)list; return 0; } +static uint32_t event_count(const clap_input_events_t *list) +{ const xtc_modern *p = list->ctx; return (uint32_t)p->clap_count; } static const clap_event_header_t *event_get(const clap_input_events_t *list, uint32_t index) -{ (void)list; (void)index; return NULL; } +{ const xtc_modern *p = list->ctx; return index < p->clap_count ? &p->clap_events[index].header : NULL; } static bool event_push(const clap_output_events_t *list, const clap_event_header_t *event) { (void)list; (void)event; return false; } static xtc_result open_clap(xtc_modern *p, unsigned int index, xtc_plugin_spec *s) @@ -130,11 +142,18 @@ static xtc_result open_clap(xtc_modern *p, unsigned int index, xtc_plugin_spec * return XTC_ERR_UNSUPPORTED; if (!p->clap->init(p->clap)) return XTC_ERR_INVALID_STATE; ports = p->clap->get_extension(p->clap,CLAP_EXT_AUDIO_PORTS); - if (!ports || !ports->count || !ports->get || ports->count(p->clap,true) != 1 || ports->count(p->clap,false) != 1 || - !ports->get(p->clap,0,true,&info) || info.channel_count != 1 || + if (!ports || !ports->count || !ports->get || ports->count(p->clap,false) != 1 || !ports->get(p->clap,0,false,&info) || info.channel_count != 1) return XTC_ERR_UNSUPPORTED; - { const clap_plugin_note_ports_t *notes = p->clap->get_extension(p->clap,CLAP_EXT_NOTE_PORTS); - if (notes && (!notes->count || notes->count(p->clap,true) || notes->count(p->clap,false))) return XTC_ERR_UNSUPPORTED; } + { uint32_t inputs = ports->count(p->clap,true); + const clap_plugin_note_ports_t *notes = p->clap->get_extension(p->clap,CLAP_EXT_NOTE_PORTS); + if (inputs == 0 && notes && notes->count && notes->get && notes->count(p->clap,true) == 1 && !notes->count(p->clap,false)) { + clap_note_port_info_t note; + if (!notes->get(p->clap,0,true,¬e) || !(note.supported_dialects & (CLAP_NOTE_DIALECT_CLAP|CLAP_NOTE_DIALECT_MIDI))) return XTC_ERR_UNSUPPORTED; + p->instrument = s->instrument = 1; s->inputs = 0; + p->clap_midi = (note.supported_dialects & CLAP_NOTE_DIALECT_MIDI) != 0; + } else if (inputs != 1 || !ports->get(p->clap,0,true,&info) || info.channel_count != 1 || + (notes && (!notes->count || notes->count(p->clap,true) || notes->count(p->clap,false)))) return XTC_ERR_UNSUPPORTED; + } if (!p->clap->activate(p->clap,p->rate,1,(uint32_t)p->frames)) return XTC_ERR_INVALID_STATE; p->active = 1; { const clap_plugin_latency_t *latency = p->clap->get_extension(p->clap,CLAP_EXT_LATENCY); @@ -147,6 +166,8 @@ static intptr_t v2_host(xtc_v2_effect *effect, int32_t opcode, int32_t index, in (void)index; (void)value; (void)option; switch (opcode) { case 1: return 2400; /* host API version */ + case 6: return 1; /* Legacy MIDI delivery request. */ + case 37: return ptr && (!strcmp(ptr,"sendVstEvents") || !strcmp(ptr,"sendVstMidiEvent")) ? 1 : 0; case 13: if (p) __atomic_store_n(&p->restart,1U,__ATOMIC_RELEASE); return 0; case 16: return p ? (intptr_t)p->rate : 48000; case 17: return p ? (intptr_t)p->frames : 256; @@ -167,10 +188,11 @@ static xtc_result open_v2(xtc_modern *p, unsigned int index, xtc_plugin_spec *s) if (!p->v2 || p->v2->magic != XTC_V2_MAGIC || !p->v2->dispatch) return XTC_ERR_UNSUPPORTED; p->v2->user = p; p->v2->dispatch(p->v2,0,0,0,NULL,0); p->initialized = 1; - if (p->v2->inputs != 1 || p->v2->outputs != 1 || !p->v2->replace || !(p->v2->flags & 16) || - (p->v2->flags & 256) || p->v2->initial_delay) return XTC_ERR_UNSUPPORTED; + if (p->v2->outputs != 1 || !p->v2->replace || !(p->v2->flags & 16) || p->v2->initial_delay || + ((p->v2->flags & 256) ? p->v2->inputs != 0 : p->v2->inputs != 1)) return XTC_ERR_UNSUPPORTED; p->v2->dispatch(p->v2,45,0,0,name,0); name[63] = 0; if (!name[0]) return XTC_ERR_UNSUPPORTED; + if (p->v2->flags & 256) { p->instrument = s->instrument = 1; s->inputs = 0; } snprintf(s->label,sizeof(s->label),"%s",name); s->unique_id = (unsigned int)p->v2->unique_id; p->v2->dispatch(p->v2,10,0,0,NULL,(float)p->rate); p->v2->dispatch(p->v2,11,0,(intptr_t)p->frames,NULL,0); @@ -222,11 +244,17 @@ static xtc_result open_v3(xtc_modern *p, unsigned int index, xtc_plugin_spec *s) if (!p->processor->lpVtbl || !p->processor->lpVtbl->release || !p->processor->lpVtbl->canProcessSampleSize || !p->processor->lpVtbl->setBusArrangements || !p->processor->lpVtbl->setupProcessing || !p->processor->lpVtbl->getLatencySamples || !p->processor->lpVtbl->setProcessing || !p->processor->lpVtbl->process) return XTC_ERR_UNSUPPORTED; - if (p->component->lpVtbl->getBusCount(p->component,0,0) != 1 || p->component->lpVtbl->getBusCount(p->component,0,1) != 1 || - p->component->lpVtbl->getBusCount(p->component,1,0) || p->component->lpVtbl->getBusCount(p->component,1,1) || - p->processor->lpVtbl->canProcessSampleSize(p->processor,0) != 0 || - p->processor->lpVtbl->setBusArrangements(p->processor,&mono,1,&mono,1) != 0) return XTC_ERR_UNSUPPORTED; - for (i = 0; i < 2; ++i) { + { Steinberg_int32 inputs = p->component->lpVtbl->getBusCount(p->component,0,0); + Steinberg_int32 event_inputs = p->component->lpVtbl->getBusCount(p->component,1,0); + if (inputs == 0 && event_inputs == 1) { p->instrument = s->instrument = 1; s->inputs = 0; } + else if (inputs != 1 || event_inputs != 0) return XTC_ERR_UNSUPPORTED; + if (p->component->lpVtbl->getBusCount(p->component,0,1) != 1 || + p->component->lpVtbl->getBusCount(p->component,1,1) || + p->processor->lpVtbl->canProcessSampleSize(p->processor,0) != 0 || + p->processor->lpVtbl->setBusArrangements(p->processor,inputs ? &mono : NULL,inputs,&mono,1) != 0) return XTC_ERR_UNSUPPORTED; + if (event_inputs && p->component->lpVtbl->activateBus(p->component,1,0,0,1) != 0) return XTC_ERR_UNSUPPORTED; + } + for (i = p->instrument ? 1U : 0U; i < 2; ++i) { struct Steinberg_Vst_BusInfo bus; if (p->component->lpVtbl->getBusInfo(p->component,0,(Steinberg_int32)i,0,&bus) != 0 || bus.channelCount != 1 || p->component->lpVtbl->activateBus(p->component,0,(Steinberg_int32)i,0,1) != 0) return XTC_ERR_UNSUPPORTED; @@ -244,6 +272,7 @@ xtc_result xtc_modern_open(xtc_plugin_format format, const char *path, unsigned p = calloc(1,sizeof(*p)); if (!p) return XTC_ERR_OUT_OF_MEMORY; p->format = format; p->frames = frames; p->rate = rate; p->control_thread = pthread_self(); + p->event_list.api.lpVtbl = &event_vtable; p->event_list.owner = p; p->input = calloc(frames,sizeof(float)); p->output = calloc(frames,sizeof(float)); if (!p->input || !p->output) { xtc_modern_destroy(p); return XTC_ERR_OUT_OF_MEMORY; } r = module_open(format,path,&p->module); @@ -291,25 +320,109 @@ xtc_result xtc_modern_process(xtc_modern *p, const float *input, float *output, memset(p->output,0,frames*sizeof(float)); in_audio = 1; if (p->clap) { - clap_input_events_t events = {NULL,event_count,event_get}; clap_output_events_t sink = {NULL,event_push}; + clap_input_events_t events = {p,event_count,event_get}; clap_output_events_t sink = {NULL,event_push}; clap_audio_buffer_t in = {&p->input,NULL,1,0,0}, out = {&p->output,NULL,1,0,0}; - clap_process_t block = {-1,(uint32_t)frames,NULL,&in,&out,1,1,&events,&sink}; + clap_process_t block = {-1,(uint32_t)frames,NULL,p->instrument ? NULL : &in,&out,p->instrument ? 0U : 1U,1,&events,&sink}; if (!p->started) { p->started = p->clap->start_processing(p->clap); if (!p->started) ok = 0; } if (ok) { clap_process_status status = p->clap->process(p->clap,&block); ok = status >= CLAP_PROCESS_CONTINUE && status <= CLAP_PROCESS_SLEEP; } - } else if (p->v2) p->v2->replace(p->v2,&p->input,&p->output,(int32_t)frames); + } else if (p->v2) { + if (p->event_count) { + xtc_v2_events events; memset(&events,0,sizeof(events)); events.count = (int32_t)p->event_count; + for (i = 0; i < p->event_count; ++i) events.events[i] = p->v2_events+i; + p->v2->dispatch(p->v2,25,0,0,&events,0); + } + p->v2->replace(p->v2,p->instrument ? NULL : &p->input,&p->output,(int32_t)frames); + } else { struct Steinberg_Vst_AudioBusBuffers in = {0}, out = {0}; struct Steinberg_Vst_ProcessData data = {0}; in.numChannels = out.numChannels = 1; in.Steinberg_Vst_AudioBusBuffers_channelBuffers32 = &p->input; out.Steinberg_Vst_AudioBusBuffers_channelBuffers32 = &p->output; - data.numSamples = (Steinberg_int32)frames; data.numInputs = data.numOutputs = 1; data.inputs = ∈ data.outputs = &out; + data.numSamples = (Steinberg_int32)frames; data.numInputs = p->instrument ? 0 : 1; data.numOutputs = 1; data.inputs = p->instrument ? NULL : ∈ data.outputs = &out; + if (p->instrument) data.inputEvents = &p->event_list.api; ok = p->processor->lpVtbl->process(p->processor,&data) == 0; } in_audio = 0; for (i = 0; i < frames; ++i) output[i] = ok ? xtc_dsp_finite(p->output[i]) : 0; return ok ? XTC_OK : XTC_ERR_INVALID_STATE; } + +static Steinberg_tresult list_query(void *self, const Steinberg_TUID iid, void **out) +{ + if (!out || !iid) return Steinberg_kInvalidArgument; + *out = NULL; + if (!memcmp(iid,Steinberg_Vst_IEventList_iid,16) || !memcmp(iid,Steinberg_FUnknown_iid,16)) { *out = self; return 0; } + return Steinberg_kNoInterface; +} +static Steinberg_int32 list_count(void *self) { return (Steinberg_int32)((vst_events *)self)->owner->vst_count; } +static Steinberg_tresult list_get(void *self, Steinberg_int32 index, struct Steinberg_Vst_Event *out) +{ + xtc_modern *p = ((vst_events *)self)->owner; + if (!out || index < 0 || (size_t)index >= p->vst_count) return Steinberg_kInvalidArgument; + *out = p->vst_events[index]; return 0; +} +static Steinberg_tresult list_add(void *self, struct Steinberg_Vst_Event *event) +{ (void)self; (void)event; return Steinberg_kNotImplemented; } +static Steinberg_Vst_IEventListVtbl event_vtable = {list_query,host_ref,host_ref,list_count,list_get,list_add}; +static void vst_note(xtc_modern *p, unsigned int offset, unsigned int channel, unsigned int pitch, unsigned int velocity, int on) +{ + struct Steinberg_Vst_Event *v = p->vst_events+p->vst_count++; + memset(v,0,sizeof(*v)); v->sampleOffset = (Steinberg_int32)offset; v->type = on ? 0 : 1; + if (on) { + v->Steinberg_Vst_Event_noteOn.channel = (Steinberg_int16)channel; + v->Steinberg_Vst_Event_noteOn.pitch = (Steinberg_int16)pitch; + v->Steinberg_Vst_Event_noteOn.velocity = (float)velocity/127; + v->Steinberg_Vst_Event_noteOn.noteId = -1; + } else { + v->Steinberg_Vst_Event_noteOff.channel = (Steinberg_int16)channel; + v->Steinberg_Vst_Event_noteOff.pitch = (Steinberg_int16)pitch; + v->Steinberg_Vst_Event_noteOff.noteId = -1; + } + p->keys[channel][pitch] = (unsigned char)on; +} +xtc_result xtc_modern_process_midi(xtc_modern *p, const xtc_midi_event *events, size_t count, float *output, size_t frames) +{ + size_t i; xtc_result r; + if (!p || !p->instrument || !output || (count && !events) || count > XTC_MIDI_MAX_EVENTS || frames > p->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->channel > 15 || e->pitch > 127 || e->velocity > 127 || + e->type < XTC_MIDI_NOTE_ON || e->type > XTC_MIDI_CONTROL || (e->type == XTC_MIDI_NOTE_ON && !e->velocity)) return XTC_ERR_INVALID_ARGUMENT; + } + p->event_count = count; p->vst_count = 0; p->clap_count = 0; + p->event_list.api.lpVtbl = &event_vtable; p->event_list.owner = p; + for (i = 0; i < count; ++i) { + const xtc_midi_event *e = events+i; + unsigned char status = (unsigned char)((e->type == XTC_MIDI_NOTE_ON ? 144 : e->type == XTC_MIDI_NOTE_OFF ? 128 : 176) | e->channel); + unsigned char key = (unsigned char)(e->type == XTC_MIDI_ALL_OFF ? 120 : e->pitch); + unsigned char value = (unsigned char)(e->type == XTC_MIDI_ALL_OFF ? 0 : e->velocity); + clap_note *c = p->clap_events+p->clap_count; memset(c,0,sizeof(*c)); + if (p->clap_midi) { + c->midi.header = (clap_event_header_t){sizeof(c->midi),e->offset,CLAP_CORE_EVENT_SPACE_ID,CLAP_EVENT_MIDI,0}; + c->midi.data[0] = status; c->midi.data[1] = key; c->midi.data[2] = value; + } else { + c->note.header = (clap_event_header_t){sizeof(c->note),e->offset,CLAP_CORE_EVENT_SPACE_ID, + e->type == XTC_MIDI_NOTE_ON ? CLAP_EVENT_NOTE_ON : e->type == XTC_MIDI_NOTE_OFF ? CLAP_EVENT_NOTE_OFF : CLAP_EVENT_NOTE_CHOKE,0}; + c->note.note_id = -1; c->note.port_index = 0; c->note.channel = (int16_t)e->channel; + c->note.key = e->type == XTC_MIDI_ALL_OFF ? -1 : (int16_t)e->pitch; c->note.velocity = (double)e->velocity/127; + /* No generic controller mapping in native-note CLAP or VST3. */ + if (e->type == XTC_MIDI_CONTROL) c->note.header.type = UINT16_MAX; + } + if (p->clap_midi || e->type != XTC_MIDI_CONTROL) ++p->clap_count; + memset(p->v2_events+i,0,sizeof(*p->v2_events)); + p->v2_events[i].type = 1; p->v2_events[i].bytes = sizeof(xtc_v2_midi); p->v2_events[i].offset = (int32_t)e->offset; + p->v2_events[i].data[0] = status; p->v2_events[i].data[1] = key; p->v2_events[i].data[2] = value; + if (e->type == XTC_MIDI_NOTE_ON || e->type == XTC_MIDI_NOTE_OFF) + vst_note(p,e->offset,e->channel,e->pitch,e->velocity,e->type == XTC_MIDI_NOTE_ON); + else if (e->type == XTC_MIDI_ALL_OFF) { + unsigned int k; + for (k = 0; k < 128; ++k) if (p->keys[e->channel][k]) vst_note(p,e->offset,e->channel,k,0,0); + } + } + r = xtc_modern_process(p,NULL,output,frames); + p->event_count = 0; p->clap_count = 0; p->vst_count = 0; return r; +} diff --git a/src/plugin/modern.h b/src/plugin/modern.h index 1f3b28f..9d017be 100644 --- a/src/plugin/modern.h +++ b/src/plugin/modern.h @@ -4,5 +4,6 @@ typedef struct xtc_modern xtc_modern; xtc_result xtc_modern_open(xtc_plugin_format format, const char *path, unsigned int index, double rate, size_t frames, xtc_modern **out, xtc_plugin_spec *spec); xtc_result xtc_modern_process(xtc_modern *plugin, const float *input, float *output, size_t frames); +xtc_result xtc_modern_process_midi(xtc_modern *plugin, const xtc_midi_event *events, size_t count, float *output, size_t frames); void xtc_modern_destroy(xtc_modern *plugin); #endif diff --git a/src/plugin/vst2_abi.h b/src/plugin/vst2_abi.h index d2e85b2..109b80e 100644 --- a/src/plugin/vst2_abi.h +++ b/src/plugin/vst2_abi.h @@ -22,5 +22,11 @@ struct xtc_v2_effect { void (*replace64)(xtc_v2_effect *, double **, double **, int32_t); char future[56]; }; +typedef struct { + int32_t type, bytes, offset, flags, note_length, note_offset; + unsigned char data[4]; + char detune, off_velocity, reserved1, reserved2; +} xtc_v2_midi; +typedef struct { int32_t count; intptr_t reserved; xtc_v2_midi *events[256]; } xtc_v2_events; #define XTC_V2_MAGIC INT32_C(0x56737450) #endif diff --git a/src/project/project.c b/src/project/project.c index 080e558..fcd7f2c 100644 --- a/src/project/project.c +++ b/src/project/project.c @@ -119,6 +119,7 @@ xtc_result xtc_project_create(xtc_project **out_project) } project->graph->require_output_nodes = 1; project->tempo = 120000U; + project->live_instrument = UINT64_MAX; { size_t channel; for (channel = 0; channel < 16; ++channel) { project->control_map[channel][0] = 7; @@ -331,6 +332,10 @@ xtc_result xtc_project_remove_track(xtc_project *project, uint64_t id) project->clip_count = count; for (i = 0; i < project->track_count; ++i) if (project->tracks[i].bus_id == id) project->tracks[i].bus_id = UINT64_MAX; + if (project->live_instrument == id) project->live_instrument = UINT64_MAX; + memmove(project->instruments+index,project->instruments+index+1, + (project->track_count-index-1)*sizeof(*project->instruments)); + memset(project->instruments+project->track_count-1,0,sizeof(*project->instruments)); memmove(project->tracks + index, project->tracks + index + 1U, (project->track_count - index - 1U) * sizeof(*project->tracks)); --project->track_count; @@ -530,7 +535,13 @@ xtc_result xtc_project_validate(const xtc_project *project) for (k = 0; k < j; ++k) if (cc != 128 && cc == project->control_map[i][k]) return XTC_ERR_FORMAT; } + if (project->live_instrument != UINT64_MAX) { + size_t target = track_index(project,project->live_instrument); + if (target == SIZE_MAX || !project->instruments[target].instrument) return XTC_ERR_FORMAT; + } for (i = 0U; i < project->track_count; ++i) { + if (project->instruments[i].instrument && (project->tracks[i].is_bus || + xtc_plugin_spec_validate(project->instruments+i) != XTC_OK)) return XTC_ERR_FORMAT; if (project->tracks[i].id >= project->next_track_id || !xtc_project_valid_name(project->tracks[i].name)) return XTC_ERR_FORMAT; { @@ -674,6 +685,10 @@ xtc_result xtc_project_move_track(xtc_project *p, uint64_t id, size_t destinatio size_t i; xtc_track_info track; if (!p || destination >= p->track_count) return XTC_ERR_INVALID_ARGUMENT; i = track_index(p,id); if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; + { xtc_plugin_spec instrument = p->instruments[i]; + if (i < destination) memmove(p->instruments+i,p->instruments+i+1,(destination-i)*sizeof(instrument)); + else if (i > destination) memmove(p->instruments+destination+1,p->instruments+destination,(i-destination)*sizeof(instrument)); + p->instruments[destination] = instrument; } track = p->tracks[i]; if (i < destination) memmove(p->tracks+i,p->tracks+i+1,(destination-i)*sizeof(track)); else if (i > destination) memmove(p->tracks+destination+1,p->tracks+destination,(i-destination)*sizeof(track)); @@ -718,3 +733,38 @@ xtc_result xtc_project_split_audio(xtc_project *p, uint64_t id, uint64_t tick, u *out = right.id; return XTC_OK; } + +xtc_result xtc_project_set_instrument(xtc_project *p, uint64_t track, const xtc_plugin_spec *spec) +{ + size_t i; xtc_project *candidate = NULL; xtc_result r; + if (!p) return XTC_ERR_INVALID_ARGUMENT; + i = track_index(p,track); + if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; + if (p->tracks[i].is_bus) return XTC_ERR_INVALID_STATE; + if (spec && (!spec->instrument || xtc_plugin_spec_validate(spec) != XTC_OK)) return XTC_ERR_UNSUPPORTED; + r = xtc_project_clone(p,&candidate); + if (r == XTC_OK && spec) r = xtc_project_ensure_midi_route(candidate); + if (r == XTC_OK) { + if (spec) { candidate->instruments[i] = *spec; candidate->live_instrument = track; } + else { memset(candidate->instruments+i,0,sizeof(*candidate->instruments)); + if (candidate->live_instrument == track) candidate->live_instrument = UINT64_MAX; } + { xtc_graph *old = p->graph; *p = *candidate; candidate->graph = old; } + } + xtc_project_destroy(candidate); return r; +} +xtc_result xtc_project_get_instrument(const xtc_project *p, uint64_t track, xtc_plugin_spec *out) +{ + size_t i; + if (!p || !out) return XTC_ERR_INVALID_ARGUMENT; + i = track_index(p,track); if (i == SIZE_MAX || !p->instruments[i].instrument) return XTC_ERR_NOT_FOUND; + *out = p->instruments[i]; return XTC_OK; +} +xtc_result xtc_project_set_live_instrument(xtc_project *p, uint64_t track) +{ + xtc_plugin_spec s; + if (!p) return XTC_ERR_INVALID_ARGUMENT; + if (track != UINT64_MAX && xtc_project_get_instrument(p,track,&s) != XTC_OK) return XTC_ERR_NOT_FOUND; + p->live_instrument = track; return XTC_OK; +} +xtc_result xtc_project_get_live_instrument(const xtc_project *p, uint64_t *track) +{ if (!p || !track) return XTC_ERR_INVALID_ARGUMENT; *track = p->live_instrument; return XTC_OK; } diff --git a/src/project/project_internal.h b/src/project/project_internal.h index 1d24e31..ff9fe03 100644 --- a/src/project/project_internal.h +++ b/src/project/project_internal.h @@ -4,9 +4,12 @@ #include "xtc/xtc_project.h" #include "xtc/xtc_midi.h" #include "xtc/xtc_mixer.h" +#include "xtc/xtc_plugin.h" struct xtc_project { xtc_graph *graph; + uint64_t live_instrument; + xtc_plugin_spec instruments[XTC_PROJECT_MAX_TRACKS]; uint64_t next_track_id, next_clip_id; unsigned int tempo; unsigned char control_map[16][3]; @@ -26,6 +29,7 @@ struct xtc_project { * @param name NUL-terminated name. * @return Nonzero for a valid name, zero otherwise. */ +xtc_result xtc_project_ensure_midi_route(xtc_project *project); int xtc_project_valid_name(const char *name); /** * @brief Validates project IDs, timeline ranges, names, graph, notes, and automation. diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index fa1b184..dc6ee0c 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -3,7 +3,7 @@ Store small, redistributable fixtures here. Document their origin and license. Do not commit large recordings or assets with unclear licensing. -`project-v1.xtc` through `project-v11.xtc` are hand-authored xtc metadata fixtures +`project-v1.xtc` through `project-v12.xtc` are hand-authored xtc metadata fixtures created for this repository, under the project license. They contain no audio or third-party resources. The v5/v6 plugin fixtures intentionally reference a missing module to exercise metadata migration without loading code. diff --git a/tests/fixtures/project-v12.xtc b/tests/fixtures/project-v12.xtc new file mode 100644 index 0000000..ed9e36f --- /dev/null +++ b/tests/fixtures/project-v12.xtc @@ -0,0 +1,41 @@ +XTC_PROJECT 12 +APP 0 1 0 +TEMPO 120000 +IDS 1 1 +GRAPH 1 2 2 1 +NODE 0 1 3f000000 +NODE 1 4 00000000 +EDGE 0 1 +TRACKS 1 +TRACK 0 4b657973 +MIX 3f800000 00000000 0 0 +ROUTE 0 18446744073709551615 +CLIPS 1 +CLIP 0 0 0 960 0 960 5665727365 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +NOTES 1 1 +NOTE 0 0 0 480 69 100 0 +AUTOMATION 1 +POINT 0 0 0 3f800000 0 +CONTROL_MAPS 16 +MAP 0 7 10 64 +MAP 1 7 10 64 +MAP 2 7 10 64 +MAP 3 7 10 64 +MAP 4 7 10 64 +MAP 5 7 10 64 +MAP 6 7 10 64 +MAP 7 7 10 64 +MAP 8 7 10 64 +MAP 9 7 10 64 +MAP 10 7 10 64 +MAP 11 7 10 64 +MAP 12 7 10 64 +MAP 13 7 10 64 +MAP 14 7 10 64 +MAP 15 7 10 64 +TRACK_INSTRUMENTS 0 +LIVE_INSTRUMENT 18446744073709551615 +END diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 6d732c1..5cc8b7f 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -827,6 +827,27 @@ int main(int argc, char **argv) unlink("/tmp/xtc-gui-modern-test.xtc"); window.do_callback(); } } + for (const char *variable : {"XTC_TEST_SYNTH_CLAP","XTC_TEST_SYNTH_VST2","XTC_TEST_SYNTH_VST3"}) { + if (const char *module = std::getenv(variable)) { + reset(); MainWindow window(false); auto *sequence = timeline(&window); + CHECK(sequence->add_track() == XTC_OK && sequence->add_midi_clip() == XTC_OK); + uint64_t track = sequence->selected_track(); xtc_plugin_spec spec{}; + CHECK(window.assign_instrument(track,module,0) == XTC_OK); + CHECK(xtc_project_get_instrument(sequence->project(),track,&spec) == XTC_OK && spec.instrument); + CHECK(window.undo(false) == XTC_OK); + CHECK(xtc_project_get_instrument(sequence->project(),track,&spec) == XTC_ERR_NOT_FOUND); + CHECK(window.undo(true) == XTC_OK); + CHECK(window.select_live_instrument(UINT64_MAX) == XTC_OK); + CHECK(window.select_live_instrument(track) == XTC_OK); + const xtc_project *before = sequence->project(); + CHECK(window.assign_instrument(track,"/absent/instrument.so",0) == XTC_ERR_NOT_FOUND && sequence->project() == before); + CHECK(window.save_project("/tmp/xtc-gui-instrument.xtc") == XTC_OK); + CHECK(window.new_project() == XTC_OK && window.open_project("/tmp/xtc-gui-instrument.xtc") == XTC_OK); + CHECK(xtc_project_get_instrument(sequence->project(),track,&spec) == XTC_OK && spec.instrument); + CHECK(window.assign_instrument(track,nullptr,0) == XTC_OK); + unlink("/tmp/xtc-gui-instrument.xtc"); window.do_callback(); + } + } if (failures != 0) return 1; std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes); return 0; diff --git a/tests/integration/test_install.py b/tests/integration/test_install.py index 1bcabfa..acd71d8 100644 --- a/tests/integration/test_install.py +++ b/tests/integration/test_install.py @@ -54,7 +54,7 @@ def main(): run([str(binaries / 'xtc-check'), '--inspect', str(example)]) assert (resources / 'xtc/examples/README.md').is_file() diagnose = subprocess.check_output([str(binaries / 'xtc-check'), '--diagnose'], text=True) - assert 'Project formats: 1..11' in diagnose and 'JACK compiled:' in diagnose + assert 'Project formats: 1..12' in diagnose and 'JACK compiled:' in diagnose # Replacing a file symlink must not overwrite an unrelated target. protected = stage / 'unrelated-target' protected.write_text('keep unrelated target\n') diff --git a/tests/integration/test_instruments.c b/tests/integration/test_instruments.c new file mode 100644 index 0000000..1f6ec23 --- /dev/null +++ b/tests/integration/test_instruments.c @@ -0,0 +1,121 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_plugin.h" +#include "xtc/xtc_mixer.h" +#include +#include +#include +#include +#include +#include +static int audio; +void *__real_malloc(size_t); +void *__real_calloc(size_t,size_t); +void *__real_realloc(void *,size_t); +void __real_free(void *); +void *__real_dlopen(const char *,int); +int __real_dlclose(void *); +void *__wrap_malloc(size_t n) { assert(!audio); return __real_malloc(n); } +void *__wrap_calloc(size_t n,size_t s) { assert(!audio); return __real_calloc(n,s); } +void *__wrap_realloc(void *p,size_t n) { assert(!audio); return __real_realloc(p,n); } +void __wrap_free(void *p) { assert(!audio); __real_free(p); } +void *__wrap_dlopen(const char *p,int mode) { assert(!audio); return __real_dlopen(p,mode); } +int __wrap_dlclose(void *p) { assert(!audio); return __real_dlclose(p); } +static xtc_result render(xtc_midi_plan *plan, uint64_t position, const xtc_midi_event *events, size_t count, float *out, size_t frames) +{ + xtc_result r; audio = 1; + r = xtc_midi_plan_render(plan,position,events,count,out,frames); + audio = 0; return r; +} +static void check(const char *path) +{ + xtc_project *p = NULL, *q = NULL; + xtc_midi_plan *plan = NULL; + xtc_plugin_spec spec, actual; + xtc_plugin *plugin = NULL; + uint64_t track, clip, note, start, end, live; + float out[600]; size_t i; + char file[256]; + xtc_midi_event events[] = {{3,XTC_MIDI_NOTE_ON,0,60,127},{19,XTC_MIDI_NOTE_OFF,0,60,0}}; + assert(xtc_plugin_probe(path,0,48000,&spec) == XTC_OK && spec.instrument && !spec.inputs); + assert(xtc_plugin_create(&spec,48000,256,&plugin) == XTC_OK); + assert(xtc_plugin_process_midi(plugin,events,2,out,31) == XTC_OK); + for (i = 0; i < 31; ++i) assert(out[i] == (i >= 3 && i < 19 ? 1.0f : 0)); + xtc_plugin_destroy(plugin); + assert(xtc_project_create(&p) == XTC_OK && xtc_project_create(&q) == XTC_OK); + assert(xtc_project_add_track(p,"Synth",&track) == XTC_OK); + assert(xtc_project_add_midi_clip(p,track,0,960,&clip) == XTC_OK); + assert(xtc_project_add_note(p,clip,2,10,60,127,0,¬e) == XTC_OK); + assert(xtc_project_tick_to_sample(p,2,48000,&start) == XTC_OK); + assert(xtc_project_tick_to_sample(p,12,48000,&end) == XTC_OK); + assert(xtc_project_set_instrument(p,track,&spec) == XTC_OK); + assert(xtc_project_get_live_instrument(p,&live) == XTC_OK && live == track); + assert(xtc_midi_plan_create(p,48000,&plan) == XTC_OK); + assert(render(plan,0,NULL,0,out,600) == XTC_OK); + for (i = 0; i < 600; ++i) assert(fabsf(out[i]-(i >= start && i < end ? 1.0f : 0)) < .000001f); + /* Seek chases notes, panic suppresses them until the next discontinuity. */ + assert(render(plan,start+1,NULL,0,out,1) == XTC_OK && out[0] == 1); + xtc_midi_plan_panic(plan,start+2); + assert(render(plan,start+2,NULL,0,out,1) == XTC_OK && out[0] == 0); + assert(render(plan,1000,events,2,out,31) == XTC_OK); + for (i = 0; i < 31; ++i) assert(out[i] == (i >= 3 && i < 19 ? 1.0f : 0)); + xtc_midi_plan_destroy(plan); + snprintf(file,sizeof(file),"/tmp/xtc-instrument-%ld.xtc",(long)getpid()); + assert(xtc_project_save(p,file) == XTC_OK); + assert(xtc_project_load(q,file) == XTC_OK); + assert(xtc_project_get_instrument(q,track,&actual) == XTC_OK && actual.instrument && !strcmp(actual.path,path)); + assert(xtc_project_get_live_instrument(q,&live) == XTC_OK && live == track); + assert(xtc_midi_plan_create(q,48000,&plan) == XTC_OK); + xtc_midi_plan_destroy(plan); + assert(xtc_project_set_instrument(q,track,NULL) == XTC_OK); + assert(xtc_project_get_instrument(q,track,&actual) == XTC_ERR_NOT_FOUND); + assert(xtc_project_get_live_instrument(q,&live) == XTC_OK && live == UINT64_MAX); + /* Independent instances sum through track gains, and stable IDs survive reorder. */ + { + uint64_t second, clip2, note2, bus; + assert(xtc_project_add_track(p,"Second",&second) == XTC_OK); + assert(xtc_project_add_midi_clip(p,second,0,960,&clip2) == XTC_OK); + assert(xtc_project_add_note(p,clip2,2,10,62,127,1,¬e2) == XTC_OK); + assert(xtc_project_set_instrument(p,second,&spec) == XTC_OK); + assert(xtc_project_set_mix(p,second,.25f,0,0,0) == XTC_OK); + assert(xtc_project_move_track(p,second,0) == XTC_OK); + assert(xtc_project_get_instrument(p,track,&actual) == XTC_OK); + assert(xtc_midi_plan_create(p,48000,&plan) == XTC_OK); + assert(render(plan,0,NULL,0,out,600) == XTC_OK); + assert(fabsf(out[start]-1.25f) < .000001f); + xtc_midi_plan_destroy(plan); + assert(xtc_project_add_bus(p,"Synth bus",&bus) == XTC_OK); + assert(xtc_project_set_instrument(p,bus,&spec) == XTC_ERR_INVALID_STATE); + assert(xtc_project_route_track(p,second,bus) == XTC_OK); + assert(xtc_project_set_mix(p,bus,.5f,0,0,0) == XTC_OK); + assert(xtc_midi_plan_create(p,48000,&plan) == XTC_OK); + assert(render(plan,0,NULL,0,out,600) == XTC_OK && fabsf(out[start]-1.125f) < .000001f); + xtc_midi_plan_destroy(plan); + assert(xtc_project_set_mix(p,second,.25f,0,1,0) == XTC_OK); + assert(xtc_midi_plan_create(p,48000,&plan) == XTC_OK); + assert(render(plan,0,NULL,0,out,600) == XTC_OK && out[start] == 1); + xtc_midi_plan_destroy(plan); + assert(xtc_project_remove_track(p,second) == XTC_OK); + assert(xtc_project_get_live_instrument(p,&live) == XTC_OK && live == UINT64_MAX); + } + /* Exhausting a chunk's event budget fails silent and recovers on next render. */ + { + xtc_midi_event many[256]; + assert(xtc_project_set_live_instrument(p,track) == XTC_OK); + for (i = 0; i < 256; ++i) many[i] = (xtc_midi_event){0,XTC_MIDI_NOTE_ON,0,60,127}; + assert(xtc_midi_plan_create(p,48000,&plan) == XTC_OK); + assert(render(plan,1000,many,256,out,31) == XTC_ERR_CAPACITY); + for (i = 0; i < 31; ++i) assert(out[i] == 0); + assert(render(plan,1031,events,2,out,31) == XTC_OK); + xtc_midi_plan_destroy(plan); + } + /* Missing modules remain model data; playback preparation fails cleanly. */ + actual = spec; strcpy(actual.path,"/absent/xtc-instrument.so"); + assert(xtc_project_set_instrument(q,track,&actual) == XTC_OK); + assert(xtc_project_save(q,file) == XTC_OK && xtc_project_load(p,file) == XTC_OK); + plan = NULL; + assert(xtc_midi_plan_create(p,48000,&plan) == XTC_ERR_NOT_FOUND && !plan); + xtc_project_destroy(q); xtc_project_destroy(p); unlink(file); + printf("%s instrument offsets, scheduling, live routing, seek/panic and v12 reload passed.\n",xtc_plugin_format_name(spec.format)); +} +int main(int argc, char **argv) +{ int i; assert(argc == 4); for (i = 1; i < argc; ++i) check(argv[i]); assert(setenv("XTC_TEST_NATIVE_NOTES","1",1) == 0); check(argv[1]); return 0; } diff --git a/tests/integration/test_portability.c b/tests/integration/test_portability.c index 08f2123..844c80b 100644 --- a/tests/integration/test_portability.c +++ b/tests/integration/test_portability.c @@ -19,7 +19,7 @@ int main(void) assert(mkdtemp(root)); assert(xtc_project_create(&p) == XTC_OK); snprintf(file,sizeof(file),"%s/migration.xtc",root); - for (version = 1; version <= 11; ++version) { + for (version = 1; version <= 12; ++version) { xtc_track_info t; xtc_clip_info c; size_t notes, points; snprintf(old,sizeof(old),"tests/fixtures/project-v%u.xtc",version); assert(xtc_project_load(p,old) == XTC_OK); @@ -89,6 +89,6 @@ int main(void) assert(xtc_project_load(p,file) == XTC_ERR_FORMAT); { xtc_clip_info c; assert(xtc_project_find_clip(p,clip,&c) == XTC_OK && c.asset_path[0]); } } xtc_project_destroy(p); unlink(file); rmdir(moved); - puts("v1-v11 migration, current folder relocation, cwd independence and missing-media preservation passed."); + puts("v1-v12 migration, current folder relocation, cwd independence and missing-media preservation passed."); return 0; } diff --git a/tests/integration/test_project_inspection.py b/tests/integration/test_project_inspection.py index 95ac2a6..ab403fc 100644 --- a/tests/integration/test_project_inspection.py +++ b/tests/integration/test_project_inspection.py @@ -65,8 +65,26 @@ with tempfile.TemporaryDirectory(prefix="xtc-inspect-") as directory: if number == 3: project.write_text(modern.replace(identity.encode().hex(), b"bad-class-id".hex())) check("--validate", project, 1) + # V12 track bindings retain references without executing module contents. + for number, identity in ((1, "org.xtc.instrument"), (2, ""), + (3, "0123456789abcdef0123456789abcdef")): + binding = (f"INSTRUMENT 0 {number} 0 42 {b'synth'.hex()}\n" + f"MODULE {str(module).encode().hex()}\n" + f"IDENTITY {identity.encode().hex() if identity else '-'}\n") + document = base.replace("XTC_PROJECT 10", "XTC_PROJECT 12").replace( + "END\n", "TRACK_INSTRUMENTS 1\n" + binding + "LIVE_INSTRUMENT 0\nEND\n") + project.write_text(document) + check("--inspect", project, 0, "instrument track 0", "no plugin code executed") + for malformed in ( + document.replace("TRACK_INSTRUMENTS 1", "TRACK_INSTRUMENTS 2").replace(binding,binding+binding), + document.replace("INSTRUMENT 0 ", "INSTRUMENT 99 "), + document.replace("LIVE_INSTRUMENT 0", "LIVE_INSTRUMENT 99"), + document.replace(f"INSTRUMENT 0 {number}", "INSTRUMENT 0 0"), + ): + project.write_text(malformed) + check("--validate", project, 1) for text, messages in [ - (base.replace("XTC_PROJECT 10", "XTC_PROJECT 999"), ("line 1", "999", "1..11")), + (base.replace("XTC_PROJECT 10", "XTC_PROJECT 999"), ("line 1", "999", "1..12")), ("XTC_PROJECT 10\n", ("line 2", "APP")), (base.replace("TEMPO 120000", "TEMPO 0"), ("line 3", "TEMPO")), (base + "OPTIONAL_FUTURE 1\n", ("Unknown fields",)), diff --git a/tests/plugins/clap_synth_fixture.c b/tests/plugins/clap_synth_fixture.c new file mode 100644 index 0000000..8698079 --- /dev/null +++ b/tests/plugins/clap_synth_fixture.c @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include +static int loaded, count; +static const char *features[] = {CLAP_PLUGIN_FEATURE_INSTRUMENT,NULL}; +static const clap_plugin_descriptor_t descriptors[] = { + {CLAP_VERSION,"xtc.fixture.gain","CLAP gain","xtc","","","","1","",features}, + {CLAP_VERSION,"xtc.fixture.stereo","Stereo","xtc","","","","1","",features}, + {CLAP_VERSION,"xtc.fixture.init","Init failure","xtc","","","","1","",features}, + {CLAP_VERSION,"xtc.fixture.activate","Activate failure","xtc","","","","1","",features}, + {CLAP_VERSION,"xtc.fixture.start","Start failure","xtc","","","","1","",features}, + {CLAP_VERSION,"xtc.fixture.process","Process failure","xtc","","","","1","",features}, + {CLAP_VERSION,"xtc.fixture.restart","Restart request","xtc","","","","1","",features} +}; +typedef struct { clap_plugin_t plugin; const clap_host_t *host; unsigned int index; int active, started; float level; } instance; +static bool init(const clap_plugin_t *plugin) +{ instance *p = plugin->plugin_data; assert(loaded); return p->index != 2; } +static void destroy(const clap_plugin_t *plugin) +{ instance *p = plugin->plugin_data; assert(!p->active && !p->started); --count; free(p); } +static bool activate(const clap_plugin_t *plugin, double rate, uint32_t min, uint32_t max) +{ instance *p = plugin->plugin_data; assert(rate > 0 && min && max >= min && !p->active); if (p->index == 3) return false; p->active = 1; return true; } +static void deactivate(const clap_plugin_t *plugin) +{ instance *p = plugin->plugin_data; assert(p->active && !p->started); p->active = 0; } +static bool start(const clap_plugin_t *plugin) +{ + instance *p = plugin->plugin_data; + const clap_host_thread_check_t *check = p->host->get_extension(p->host,CLAP_EXT_THREAD_CHECK); + assert(p->active && !p->started && check->is_audio_thread(p->host)); + if (p->index == 4) return false; + p->started = 1; return true; +} +static void stop(const clap_plugin_t *plugin) +{ instance *p = plugin->plugin_data; assert(p->started); p->started = 0; } +static void reset(const clap_plugin_t *plugin) { (void)plugin; } +static clap_process_status process(const clap_plugin_t *plugin, const clap_process_t *block) +{ + instance *p = plugin->plugin_data; uint32_t i; + uint32_t j = 0, event_total = block->in_events->size(block->in_events); + assert(p->started && block->audio_inputs_count == 0 && block->audio_outputs_count == 1); + if (p->index == 5) return CLAP_PROCESS_ERROR; + for (i = 0; i < block->frames_count; ++i) { + while (j < event_total) { + const clap_event_header_t *h = block->in_events->get(block->in_events,j); + const clap_event_midi_t *m = (const clap_event_midi_t *)h; + if (h->time != i) break; + ++j; + if (h->type != CLAP_EVENT_MIDI) { + const clap_event_note_t *note = (const clap_event_note_t *)h; + assert(h->type == CLAP_EVENT_NOTE_ON || h->type == CLAP_EVENT_NOTE_OFF || h->type == CLAP_EVENT_NOTE_CHOKE); + p->level = h->type == CLAP_EVENT_NOTE_ON ? (float)note->velocity : 0; + continue; + } + if ((m->data[0]&240) == 144) p->level = (float)m->data[2]/127; + else if ((m->data[0]&240) == 128 || ((m->data[0]&240) == 176 && m->data[1] == 120)) p->level = 0; + } + block->audio_outputs[0].data32[0][i] = p->level; + } + p->host->request_callback(p->host); + if (p->index == 6) p->host->request_restart(p->host); + return CLAP_PROCESS_CONTINUE; +} +static uint32_t port_count(const clap_plugin_t *plugin, bool input) { (void)plugin; return input ? 0 : 1; } +static bool port_get(const clap_plugin_t *plugin, uint32_t index, bool input, clap_audio_port_info_t *info) +{ instance *p = plugin->plugin_data; (void)input; if (index) return false; memset(info,0,sizeof(*info)); info->channel_count = p->index == 1 ? 2 : 1; return true; } +static uint32_t latency(const clap_plugin_t *plugin) +{ instance *p = plugin->plugin_data; assert(p->active); return 0; } +static uint32_t note_count(const clap_plugin_t *p, bool input) { (void)p; return input ? 1 : 0; } +static bool note_get(const clap_plugin_t *p, uint32_t index, bool input, clap_note_port_info_t *info) +{ (void)p; if (index || !input) return false; memset(info,0,sizeof(*info)); info->supported_dialects = info->preferred_dialect = getenv("XTC_TEST_NATIVE_NOTES") ? CLAP_NOTE_DIALECT_CLAP : CLAP_NOTE_DIALECT_MIDI; return true; } +static const void *extension(const clap_plugin_t *plugin, const char *id) +{ + static const clap_plugin_audio_ports_t ports = {port_count,port_get}; + static const clap_plugin_latency_t delay = {latency}; (void)plugin; + static const clap_plugin_note_ports_t notes = {note_count,note_get}; + if (!strcmp(id,CLAP_EXT_NOTE_PORTS)) return ¬es; + if (!strcmp(id,CLAP_EXT_AUDIO_PORTS)) return &ports; + return !strcmp(id,CLAP_EXT_LATENCY) ? &delay : NULL; +} +static void on_main(const clap_plugin_t *plugin) +{ instance *p = plugin->plugin_data; const clap_host_thread_check_t *check = p->host->get_extension(p->host,CLAP_EXT_THREAD_CHECK); assert(check->is_main_thread(p->host)); } +static uint32_t plugin_count(const clap_plugin_factory_t *factory) { (void)factory; return 7; } +static const clap_plugin_descriptor_t *descriptor(const clap_plugin_factory_t *factory, uint32_t index) +{ (void)factory; return index < 7 ? descriptors+index : NULL; } +static const clap_plugin_t *create(const clap_plugin_factory_t *factory, const clap_host_t *host, const char *id) +{ + unsigned int i; instance *p; (void)factory; + for (i = 0; i < 7 && strcmp(id,descriptors[i].id); ++i) {} + if (i == 7) return NULL; + p = calloc(1,sizeof(*p)); if (!p) return NULL; + p->host = host; p->index = i; + p->plugin = (clap_plugin_t){descriptors+i,p,init,destroy,activate,deactivate,start,stop,reset,process,extension,on_main}; + ++count; return &p->plugin; +} +static bool entry_init(const char *path) { (void)path; assert(!loaded); loaded = 1; return true; } +static void entry_exit(void) { assert(loaded && !count); loaded = 0; } +static const void *factory(const char *id) +{ static const clap_plugin_factory_t f = {plugin_count,descriptor,create}; return !strcmp(id,CLAP_PLUGIN_FACTORY_ID) ? &f : NULL; } +CLAP_EXPORT const clap_plugin_entry_t clap_entry = {CLAP_VERSION,entry_init,entry_exit,factory}; diff --git a/tests/plugins/vst2_synth_fixture.c b/tests/plugins/vst2_synth_fixture.c new file mode 100644 index 0000000..3d8fc26 --- /dev/null +++ b/tests/plugins/vst2_synth_fixture.c @@ -0,0 +1,45 @@ +/* Original MIT ABI fixture; not built from or linked to a proprietary SDK. */ +#include "../../src/plugin/vst2_abi.h" +#include +#include +#include +typedef struct { xtc_v2_effect effect; int open, active, started; float level; xtc_v2_midi events[256]; int count; } instance; +static intptr_t dispatch(xtc_v2_effect *e, int32_t op, int32_t index, intptr_t value, void *ptr, float option) +{ + instance *p = (instance *)e; (void)index; (void)option; + switch (op) { + case 0: assert(!p->open); p->open = 1; break; + case 1: assert(p->open && !p->active && !p->started); free(p); break; + case 12: p->active = value != 0; break; + case 25: { xtc_v2_events *events = ptr; int i; assert(events->count <= 256); p->count = events->count; for (i = 0; i < p->count; ++i) p->events[i] = *events->events[i]; break; } + case 45: strcpy(ptr,"VST2 gain"); return 1; + case 71: assert(p->active); p->started = 1; break; + case 72: p->started = 0; break; + default: break; + } + return 0; +} +static void process(xtc_v2_effect *e, float **input, float **output, int32_t frames) +{ instance *p = (instance *)e; int32_t i, j = 0; assert(p->started && !input); + for (i = 0; i < frames; ++i) { + while (j < p->count && p->events[j].offset == i) { + unsigned char *m = p->events[j++].data; + if ((m[0]&240) == 144) p->level = (float)m[2]/127; + else if ((m[0]&240) == 128 || ((m[0]&240) == 176 && m[1] == 120)) p->level = 0; + } + output[0][i] = p->level; + } p->count = 0; +} + +xtc_v2_effect *VSTPluginMain(xtc_v2_host host); +xtc_v2_effect *VSTPluginMain(xtc_v2_host host) +{ + instance *p; assert(host(NULL,1,0,0,NULL,0) == 2400); + p = calloc(1,sizeof(*p)); if (!p) return NULL; + p->effect.magic = XTC_V2_MAGIC; p->effect.dispatch = dispatch; + p->effect.inputs = 0; p->effect.outputs = 1; + if (getenv("XTC_VST2_FIXTURE_LAYOUT")) p->effect.inputs = 2; + p->effect.flags = 16|256; + p->effect.unique_id = 0x58544332; p->effect.replace = process; + return &p->effect; +} diff --git a/tests/plugins/vst3_synth_fixture.c b/tests/plugins/vst3_synth_fixture.c new file mode 100644 index 0000000..d01b33e --- /dev/null +++ b/tests/plugins/vst3_synth_fixture.c @@ -0,0 +1,88 @@ +#include +#include +#include +#include +#include +#include +static int loaded, instances; +static const Steinberg_TUID cid = SMTG_INLINE_UID(0x58544333,0x12345678,0x12345678,0x12345678); +typedef struct { Steinberg_Vst_IComponent component; Steinberg_Vst_IAudioProcessor processor; unsigned int refs; int initialized, active, started; float level; } instance; +static instance *from_processor(void *p) { return (instance *)((char *)p - offsetof(instance,processor)); } +static Steinberg_tresult cq(void *self, const Steinberg_TUID iid, void **out) +{ + instance *p = self; *out = NULL; + if (!memcmp(iid,Steinberg_Vst_IAudioProcessor_iid,16)) *out = &p->processor; + else if (!memcmp(iid,Steinberg_Vst_IComponent_iid,16) || !memcmp(iid,Steinberg_FUnknown_iid,16)) *out = &p->component; + if (!*out) return -1; + ++p->refs; return 0; +} +static Steinberg_uint32 add(void *self) { return ++((instance *)self)->refs; } +static Steinberg_uint32 release(void *self) +{ instance *p = self; unsigned int refs = --p->refs; if (!refs) { assert(!p->initialized); --instances; free(p); } return refs; } +static Steinberg_tresult pq(void *self, const Steinberg_TUID iid, void **out) { return cq(from_processor(self),iid,out); } +static Steinberg_uint32 pa(void *self) { return add(from_processor(self)); } +static Steinberg_uint32 pr(void *self) { return release(from_processor(self)); } +static Steinberg_tresult init(void *self, Steinberg_FUnknown *host) +{ instance *p = self; assert(loaded && host && !p->initialized); if (getenv("XTC_VST3_FIXTURE_INIT")) return -1; p->initialized = 1; return 0; } +static Steinberg_tresult terminate(void *self) { instance *p = self; assert(p->initialized && !p->active); p->initialized = 0; return 0; } +static Steinberg_tresult controller(void *self, Steinberg_TUID id) { (void)self; (void)id; return -1; } +static Steinberg_tresult mode(void *self, Steinberg_Vst_IoMode m) { (void)self; (void)m; return 0; } +static Steinberg_int32 buses(void *self, Steinberg_Vst_MediaType type, Steinberg_Vst_BusDirection dir) { (void)self; (void)dir; return type == 0 ? (dir == 1 ? 1 : 0) : (dir == 0 ? 1 : 0); } +static Steinberg_tresult bus_info(void *self, Steinberg_Vst_MediaType type, Steinberg_Vst_BusDirection dir, Steinberg_int32 index, struct Steinberg_Vst_BusInfo *info) +{ (void)self; (void)dir; assert(type == 0 && index == 0); memset(info,0,sizeof(*info)); info->channelCount = 1; return 0; } +static Steinberg_tresult routing(void *self, struct Steinberg_Vst_RoutingInfo *in, struct Steinberg_Vst_RoutingInfo *out) +{ (void)self; (void)in; (void)out; return -1; } +static Steinberg_tresult activate_bus(void *self, Steinberg_Vst_MediaType type, Steinberg_Vst_BusDirection dir, Steinberg_int32 index, Steinberg_TBool state) +{ (void)self; (void)type; (void)dir; (void)index; (void)state; return 0; } +static Steinberg_tresult active(void *self, Steinberg_TBool state) { instance *p = self; assert(!p->started); p->active = state; return 0; } +static Steinberg_tresult state(void *self, Steinberg_IBStream *stream) { (void)self; (void)stream; return -1; } +static Steinberg_tresult arrangements(void *self, Steinberg_Vst_SpeakerArrangement *in, Steinberg_int32 ni, Steinberg_Vst_SpeakerArrangement *out, Steinberg_int32 no) +{ (void)self; return ni == 0 && !in && no == 1 && out[0] == Steinberg_Vst_SpeakerArr_kMono ? 0 : -1; } +static Steinberg_tresult arrangement(void *self, Steinberg_Vst_BusDirection dir, Steinberg_int32 index, Steinberg_Vst_SpeakerArrangement *out) +{ (void)self; (void)dir; (void)index; *out = Steinberg_Vst_SpeakerArr_kMono; return 0; } +static Steinberg_tresult sample_size(void *self, Steinberg_int32 size) { (void)self; return size == 0 ? 0 : -1; } +static Steinberg_uint32 latency(void *self) { (void)self; return 0; } +static Steinberg_tresult setup(void *self, struct Steinberg_Vst_ProcessSetup *data) { (void)self; return data->maxSamplesPerBlock > 0 && data->sampleRate > 0 ? 0 : -1; } +static Steinberg_tresult processing(void *self, Steinberg_TBool value) { instance *p = from_processor(self); assert(p->active); p->started = value; return 0; } +static Steinberg_tresult process(void *self, struct Steinberg_Vst_ProcessData *data) +{ instance *p = from_processor(self); int32_t i, j = 0; + Steinberg_Vst_IEventList *events = data->inputEvents; + int32_t count = events->lpVtbl->getEventCount(events); + assert(p->started && data->numInputs == 0); + for (i = 0; i < data->numSamples; ++i) { + while (j < count) { + struct Steinberg_Vst_Event event; + assert(events->lpVtbl->getEvent(events,j,&event) == 0); + if (event.sampleOffset != i) break; + ++j; + if (event.type == 0) p->level = event.Steinberg_Vst_Event_noteOn.velocity; + else if (event.type == 1) p->level = 0; + } + data->outputs[0].Steinberg_Vst_AudioBusBuffers_channelBuffers32[0][i] = p->level; + } return 0; +} + +static Steinberg_Vst_IComponentVtbl component_vtable = {cq,add,release,init,terminate,controller,mode,buses,bus_info,routing,activate_bus,active,state,state}; +static Steinberg_Vst_IAudioProcessorVtbl processor_vtable = {pq,pa,pr,arrangements,arrangement,sample_size,latency,setup,processing,process,latency}; +static Steinberg_tresult fq(void *self, const Steinberg_TUID iid, void **out) { (void)iid; *out = self; return 0; } +static Steinberg_uint32 fr(void *self) { (void)self; return 1; } +static Steinberg_tresult factory_info(void *self, struct Steinberg_PFactoryInfo *info) { (void)self; memset(info,0,sizeof(*info)); return 0; } +static Steinberg_int32 classes(void *self) { (void)self; return 1; } +static Steinberg_tresult class_info(void *self, Steinberg_int32 index, struct Steinberg_PClassInfo *info) +{ (void)self; if (index) return -1; memset(info,0,sizeof(*info)); memcpy(info->cid,cid,16); strcpy(info->category,"Audio Module Class"); strcpy(info->name,"VST3 gain"); return 0; } +static Steinberg_tresult create(void *self, Steinberg_FIDString id, Steinberg_FIDString iid, void **out) +{ + instance *p; (void)self; *out = NULL; + if (memcmp(id,cid,16) || memcmp(iid,Steinberg_Vst_IComponent_iid,16)) return -1; + p = calloc(1,sizeof(*p)); if (!p) return -1; + p->component.lpVtbl = &component_vtable; p->processor.lpVtbl = &processor_vtable; p->refs = 1; + ++instances; *out = p; return 0; +} +static Steinberg_IPluginFactoryVtbl factory_vtable = {fq,fr,fr,factory_info,classes,class_info,create}; +static Steinberg_IPluginFactory factory = {&factory_vtable}; +bool ModuleEntry(void *handle); +bool ModuleExit(void); +Steinberg_IPluginFactory *GetPluginFactory(void); +bool ModuleEntry(void *handle) { assert(handle && !loaded); loaded = 1; return true; } +bool ModuleExit(void) { assert(loaded && !instances); loaded = 0; return true; } +Steinberg_IPluginFactory *GetPluginFactory(void) { assert(loaded); return &factory; } diff --git a/tests/unit/test_mixer.c b/tests/unit/test_mixer.c index 3c8451e..cf91a93 100644 --- a/tests/unit/test_mixer.c +++ b/tests/unit/test_mixer.c @@ -197,7 +197,7 @@ int main(void) fputc('\n',legacy); continue; } if (strncmp(line, "MIX ", 4) && strncmp(line, "AUTOMATION ", 11) && - strncmp(line, "POINT ", 6) && strncmp(line, "AUDIO ", 6) && strncmp(line, "ROUTE ", 6) && strncmp(line,"CONTROL_MAPS ",13) && strncmp(line,"MAP ",4)) + strncmp(line, "POINT ", 6) && strncmp(line, "AUDIO ", 6) && strncmp(line, "ROUTE ", 6) && strncmp(line,"CONTROL_MAPS ",13) && strncmp(line,"MAP ",4) && strncmp(line,"TRACK_INSTRUMENTS ",18) && strncmp(line,"LIVE_INSTRUMENT ",16)) assert(fputs(line, legacy) >= 0); } assert(fclose(f) == 0 && fclose(legacy) == 0); diff --git a/tests/unit/test_project_diagnostics.c b/tests/unit/test_project_diagnostics.c index da2e10e..5bcc52a 100644 --- a/tests/unit/test_project_diagnostics.c +++ b/tests/unit/test_project_diagnostics.c @@ -68,7 +68,7 @@ int main(void) assert(xtc_project_add_track(old,"Old",&id) == XTC_OK && xtc_project_add_track(next,"New",&id) == XTC_OK); assert(xtc_project_save(old,song) == XTC_OK); size = bytes(song,before); assert(xtc_project_load_diagnostic(loaded,song,&diagnostic) == XTC_OK && diagnostic.result == XTC_OK && - diagnostic.format_version == 11 && !diagnostic.line); + diagnostic.format_version == 12 && !diagnostic.line); for (mode = 1; mode <= 5; ++mode) { fault = mode; assert(xtc_project_save(next,song) == XTC_ERR_IO); @@ -81,7 +81,7 @@ int main(void) } write_text(bad,"XTC_PROJECT 999\n"); assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_UNSUPPORTED && diagnostic.line == 1 && - diagnostic.format_version == 999 && !strcmp(diagnostic.record,"XTC_PROJECT") && strstr(diagnostic.message,"1..11")); + diagnostic.format_version == 999 && !strcmp(diagnostic.record,"XTC_PROJECT") && strstr(diagnostic.message,"1..12")); write_text(bad,"XTC_PROJECT 10\nAPP 0 1 0\nTEMPO nope\n"); assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_FORMAT && diagnostic.line == 3 && !strcmp(diagnostic.record,"TEMPO"));