diff --git a/Makefile b/Makefile index fd668e8..7745b08 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ CXXFLAGS ?= -O2 -g CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude LDFLAGS ?= -CORE_SOURCES := src/audio/waveform.c src/plugin/ladspa.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c +CORE_SOURCES := src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c JACK_AVAILABLE := $(shell $(PKG_CONFIG) --exists jack 2>/dev/null && echo yes) ifeq ($(JACK_AVAILABLE),yes) JACK_CFLAGS := $(shell $(PKG_CONFIG) --cflags jack) @@ -409,3 +409,11 @@ test: test-buses -include $(BUILD_DIR)/tests/unit/test_buses.d -include $(BUILD_DIR)/src/gui/plugin_dialog.d + +$(BUILD_DIR)/test_parameters: $(BUILD_DIR)/tests/unit/test_parameters.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -ldl +.PHONY: test-parameters +test-parameters: $(BUILD_DIR)/test_parameters + ./$(BUILD_DIR)/test_parameters +test: test-parameters +-include $(BUILD_DIR)/tests/unit/test_parameters.d diff --git a/README.md b/README.md index bf0125f..5cb5e0c 100644 --- a/README.md +++ b/README.md @@ -145,3 +145,7 @@ clipping indicators, persisted in v9 (reading v1–v8). See [MIXER.md](docs/MIXE Phase 25 adds LADSPA descriptor browsing, an identity/parameter editor with reset, transactional replacement and insertion into graph wires. See [PLUGIN_HOSTING.md](docs/PLUGIN_HOSTING.md) for compatibility and lifecycle limits. + +Phase 26 adds common parameter targets, Linear/Hold mixer automation and saved +per-channel live MIDI CC bindings. Project v10 reads v1–v9. See +[CONTROL.md](docs/CONTROL.md) for timing, precedence and qualification limits. diff --git a/docs/CONTROL.md b/docs/CONTROL.md index f89cf18..2b07e90 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -1,7 +1,7 @@ -# Automation and expressive control (Phase 16) +# Automation and expressive control (Phases 16 and 26) Track Gain/Pan lanes in the Mixer apply to audio and scheduled instrument voices. -They use absolute project ticks, linear interpolation, hold-first/last outside +They use absolute project ticks, linear or hold interpolation, hold-first/last outside points and the constant project tempo snapshot. A clip trim/move does not move track or effect automation: lanes belong to the project timeline. Instrument volume is controlled by its track Gain lane; the current sine has no timbre @@ -25,9 +25,9 @@ clock is called from the host audio path. Native ABI 2 callers continue to use sample-offset parameter events, applied before equal-offset MIDI events. Live MIDI input maps CC7 to per-channel volume (0..1), CC10 to pan (64 center), -and CC64 to sustain (>=64 down). Volume/pan ramp over 5 ms. These fixed mappings +and CC64 to sustain (>=64 down). Volume/pan ramp over 5 ms. These default mappings apply to live sine voices, not track faders or prerecorded clips; controller -capture, configurable learn and plugin MIDI are not supported. CC120/123 and +capture, automatic learn and plugin MIDI are not supported. CC120/123 and Panic release all live voices and sustain and suppress already-started scheduled notes. Note-on velocity zero decodes to note-off. Repeated live note-on on one channel/pitch retriggers one voice; note-off releases it or marks it pending while @@ -47,3 +47,101 @@ make test test-control test-plugins test-gui-plugins test-plugins-real Physical controller devices and listening remain unqualified; no tempo map, latency compensation, MIDI capture, pitch bend or plugin crash isolation is added. + +## Phase 26: common parameter targets + +`xtc_control.h` identifies a parameter by `(kind, stable owner ID, parameter ID)`. +IDs never mean a dense list index and are not DSP pointers: + +| Kind | Owner | Parameter IDs | Metadata/reset defaults | +| --- | --- | --- | --- | +| TRACK | Track or mix-bus stable uint64 ID | 0 gain, 1 pan | Gain 0..4 linear, default 1; pan -1..1 balance, default 0 | +| PLUGIN | LADSPA graph node ID | Actual input-control port | Descriptor name, bounds, default and discrete/logarithmic hints; units unspecified | +| LIVE | MIDI channel 0..15 | 0 volume, 1 pan, 2 sustain | Volume 0..1 default 1; pan -1..1 default 0; sustain boolean default 0 | + +`xtc_project_parameter` returns copied metadata, static configuration and whether +a lane exists. LIVE values are reset defaults, not callback-owned controller +readbacks. `xtc_project_set_parameter` changes TRACK/PLUGIN static configuration +without deleting automation. `xtc_project_automate` sets/replaces/removes points +for TRACK/PLUGIN through one C contract. LIVE static setters and timeline lanes +are unsupported: these controls belong to incoming MIDI events, not the arranged +instrument. Plugin metadata/control edits execute trusted discovery on the +control thread; they never run inside audio. Model-only clients must compile and +publish accepted snapshots before rendering; GUI edits do this transactionally. + +Unknown owners/ports fail without touching the destination model/output. Missing +plugin modules fail discovery rather than guessing ranges or installing a dummy. +Track deletion removes its lanes; plugin deletion removes its embedded lanes. +Compatible plugin replacement resets controls/automation as described in +PLUGIN_HOSTING.md. Persisted dangling references reject the complete load; +the current project remains intact. The GUI reports rejected edits/Open failures. + +## Interpolation and precedence + +The Mixer Interpolation chooser selects Linear or Hold (step) for the point +being added/replaced. A point owns the segment beginning there. At a hold point, +its value remains until the next point's exact sample. Linear segments interpolate +between endpoint values without an extra smoothing stage. Hold first/last before/ +after the lane. Gain/pan for tracks and buses support both; integer/toggle LADSPA +controls require hold. Existing files migrate mixer points to linear. + +Automation is absolute project time, independent of clip starts, ends, trims, +splits and moves; it neither restarts nor resets at a clip boundary. Tick-to-sample +rounding uses the prepared constant tempo/rate; distinct ticks landing on the +same sample choose the greatest tick. Empty lanes restore the static value. +Static edits while a lane exists change the fallback value, not the audible lane. +GUI changes stop/rebuild snapshots; live mutation of faders/plugin instances is +not introduced. Plugin reset removes lanes only when explicitly applied. + +At each sample, arranged track/bus and plugin lanes apply to their own targets. +Incoming MIDI volume/pan/sustain affect only live sine voices on that channel, +never those lanes or the static project controls. Equal-offset MIDI events run +in input order before synthesis. A controller event then affects that sample's +ramp; gain/pan reach the target over max(1, rate/200) frames. Sustain changes at +the event sample. Panic CC120/123 remains reserved and also suppresses already +started arranged notes according to the existing panic contract. + +Seek/discontinuous positions clear live voices and runtime controllers to their +reset values but retain prepared bindings. Arranged lanes evaluate directly at +the new position. Backward jumps evaluate the earlier timeline again; tests +exercise explicit wraps. There is no loop transport feature or promise to +restore opaque LADSPA delay history across jumps (transport reset retains it). + +## Configurable live MIDI bindings + +Audio → MIDI controller mapping lists the current 16 channels. Enter +`channel volume_CC pan_CC sustain_CC`, for example `2 1 74 11`. Channels are +zero-based; CCs are 0..127, or 128 to disable a target. Enabled CCs must be distinct +within a channel; CC120 and CC123 cannot be rebound. All channels default to +7/10/64. Rebinding replaces the old binding; it is not an additional route. An +unmapped CC does nothing. Other channels remain independent. The same volume, +pan and sustain transfer functions apply regardless of the chosen CC number. +Disabling a target leaves it at its runtime reset value until plan replacement. +Changing a map stops/rebuilds playback and is one undoable edit; maps survive +save/Open and undo/redo. Cancel and invalid/duplicate/reserved bindings retain +the prior configuration. There is no automatic learn, arbitrary plugin/track CC +mapping, MIDI capture or write/touch/latch automation mode. + +Plans copy only 48 binding bytes on the control thread. The callback compares +at most three CC numbers per control event, uses existing preallocated ramps/ +voices and the existing 256-event block budget. Decode now accepts all valid +three-byte CC messages; even unmapped CCs count against that event budget. +Mixer points compile to bounded sorted arrays with binary lane lookup; processing +does no allocation, sort, file/module I/O, locking or GUI work. LADSPA automated +run(1) cost and native plugin trust limits remain unchanged. + +## Persistence and verification + +Project v10 appends a mixer interpolation field and 16 controller-map records; +v1–v9 migrate to linear mixer lanes and default 7/10/64 maps. LADSPA point encoding +is unchanged. Public `xtc_automation_point` gains `step`: rebuild clients and +initialize it to 0 (linear) or 1 (hold). See PROJECT_FORMAT.md for exact grammar. + +`test_parameters` covers metadata, missing targets, static/automation precedence, +exact hold/linear boundaries, save/reopen, malformed maps, low-rate sample +collisions, odd blocks, seeks and backward jumps. `test_control` compares default +and remapped event streams, stereo pan, disabled/unmapped controls and immutable +plan maps across resets. Plugin tests cover common-target edits and persistence; +GUI tests cover interpolation, map rollback, undo/redo and save/Open. Existing +callback guards and sanitizers verify host ownership constraints. Physical +controller devices, listening and extended high-density endurance are pending. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 6c111b3..fcfc10b 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -11,7 +11,7 @@ events/block, 32 voices, 65536 frames/block. See API headers for exact bounds. - WAV samples preload with the memory/format limits in AUDIO_FILES.md. Callback overflow reports/silences; there is no CPU preemption or expensive-plugin rescue. -- Live MIDI fixed volume/pan/sustain mapping; no controller learn, capture, SMF +- Live MIDI configurable per-channel volume/pan/sustain CC mapping; no controller learn, capture, SMF import, pitch bend, custom-instrument chooser or instrument timbre controls. - Native ABI 1 graph nodes are session-only. ABI 2 state is parameter-only. LADSPA supports the documented mono amp/inverter/sine set; no opaque state, native @@ -21,7 +21,7 @@ zoom/selection and graph positions are session UI state. Undo retains 32 edits plus baseline; allocation failures are reported, not silently hidden. Media is never removed by undo/delete. Broad screen-reader/accessibility testing is pending. -- Format v9 reads v1–v8 and uses relative local media references. External absolute +- Format v10 reads v1–v9 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 @@ -54,3 +54,8 @@ PERFORMANCE.md, PLUGIN_HOSTING.md and RECOVERY.md for tested contracts/evidence. requires the same mono audio layout and resets controls/automation. Missing modules reject Open while retaining the current project; GUI module relink for unopened documents is deferred. In-process plugin crashes/hangs are uncontained. + +- Common parameter targets cover track/bus gain/pan, LADSPA controls and metadata + for live sine volume/pan/sustain. Live mappings do not drive arranged lanes, + faders or plugins. No automation capture, touch/latch/write modes or loop + transport. Backward jumps are deterministic for lanes, not opaque plugin tails. diff --git a/docs/MIDI.md b/docs/MIDI.md index b7c77ff..8c2ef2c 100644 --- a/docs/MIDI.md +++ b/docs/MIDI.md @@ -131,3 +131,8 @@ repeats/fresh IDs, rejected batch rollback, capacity, undo snapshots, equal-offs live order, adjacent/overlapping notes, exact block partitions and seek chase. `make test-gui` covers mouse edits and the new commands with undo/redo and engine reinstallation. Listening and physical MIDI-device validation remain pending. + +Phase 26: CC7/10/64 are defaults, configurable per channel through Audio → MIDI +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. diff --git a/docs/MIXER.md b/docs/MIXER.md index 472359b..54516a5 100644 --- a/docs/MIXER.md +++ b/docs/MIXER.md @@ -26,7 +26,8 @@ static control. There are at most 1024 points globally, with one per track/parameter/tick. Track deletion cascades to its automation. A nonempty lane replaces that parameter's static value. Between points the C -engine interpolates linearly at each sample, holding the first/last value outside +engine interpolates linearly or holds each segment (Interpolation chooser), +holding the first/last value outside their range. Preparation converts ticks to integer sample endpoints, sorts and indexes lanes. When several ticks quantize to one sample at very low sample rates, the last point wins at that sample. Rendering searches prepared bounded @@ -137,3 +138,8 @@ project performance remain unverified; no new dependency was introduced. `test_buses` covers bus routing, exact taps, audio/MIDI cancellation, controls, automation, persistence, invalid routes, deletion/undo and dBFS ballistics. Backend tests cover consuming interval peaks; GUI tests cover routing undo and clipping. + +Phase 26 adds Linear/Hold interpolation for each point added or replaced. The +point's mode applies to its following segment. Static controls remain fallbacks +while a lane exists; moving/splitting clips does not move absolute track/bus lanes. +See CONTROL.md for common parameter IDs and controller/automation precedence. diff --git a/docs/PLUGIN_HOSTING.md b/docs/PLUGIN_HOSTING.md index d7bef92..3bf46eb 100644 --- a/docs/PLUGIN_HOSTING.md +++ b/docs/PLUGIN_HOSTING.md @@ -193,3 +193,14 @@ replacements with host allocation/free/dlopen/dlclose guards. Native ABI tests cover process-error silence/reset. The separately installed amp/inverter/sine modules remain the qualified LADSPA set. Human listening, arbitrary third-party editors/plugins and extended realtime endurance remain unverified. + +## Phase 26 parameter targets + +`xtc_control.h` supplies common stable owner/parameter IDs and control-thread +metadata/edit APIs for track/bus gain/pan and LADSPA input ports. The GUI's plugin +automation command uses this contract. Linear/hold LADSPA encoding and its +integer/toggle restrictions remain unchanged; v10 adds mixer interpolation and +live-sine CC maps without changing plugin state records. Direct static edits do +not override existing lanes. Live CCs do not map to plugins; missing targets or +modules fail without installing partial state. See CONTROL.md for timing, +precedence, reset and backward-jump behavior. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index 0835787..715bf4c 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -1,6 +1,6 @@ -# Project format v9 (reads v1–v8) +# Project format v10 (reads v1–v9) -Phases 5–15 use a bounded text format implemented in C99 without a parser library. +Phases 5–26 use a bounded text format implemented in C99 without a parser library. The extension is `.xtc`. File operations run exclusively on the control thread. The project owns its graph, tracks and silent/audio arrangement regions. Audio samples remain in external WAV files; MIDI notes are stored inline. @@ -12,7 +12,7 @@ Fields use ASCII spaces. Integers are unsigned decimal, without signs. Counts determine the number of subsequent records: ```text -XTC_PROJECT 7 +XTC_PROJECT 10 APP 0 1 0 TEMPO 120000 IDS 1 1 @@ -23,12 +23,31 @@ EDGE 0 1 TRACKS 1 TRACK 0 547261636b MIX 3f800000 00000000 0 0 +ROUTE 0 18446744073709551615 CLIPS 1 CLIP 0 0 3840 2880 960 3840 526567696f6e ASSET - MIDI 0 +AUDIO 3f800000 0 0 NOTES 0 0 AUTOMATION 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 END ``` @@ -47,6 +66,8 @@ END - `TRACK`: ID and hex-encoded name bytes. In v4/v5 each is immediately followed by `MIX gain_bits pan_bits mute solo`: finite binary32 gain 0..4, pan -1..1 encoded as eight hex digits, mute and solo unsigned 0/1. +- `ROUTE`: since v9, required after MIX: bus flag and destination bus ID; + UINT64_MAX denotes master. See Phase 24 constraints below. - `CLIP`: ID, track ID, start tick, length tick, source offset tick, source length tick, hex-encoded name bytes. Trimming preserves source bounds, so a region can re-extend only within its original @@ -63,7 +84,8 @@ END within source bounds. Pitch is 0..127, velocity 1..127, channel 0..15. Note IDs are unique, monotonic uint64 IDs with UINT64_MAX reserved. - `AUTOMATION`: global point count, after the last NOTE and before END in v4/v5. - Follow with `POINT track_id parameter tick value_bits` for each point. + Follow with `POINT track_id parameter tick value_bits step` in v10 (v4–v9 + omit step and migrate to linear). Step is 0 linear or 1 hold for the next segment. Parameter 0 is gain (0..4), 1 pan (-1..1); values use finite binary32 hex. Track must exist; tick <= MAX_TICK; duplicate track/parameter/tick is invalid. Record order is preserved; rendering sorts a separate prepared copy. @@ -227,3 +249,23 @@ Bus controls and automation use existing MIX and POINT records. Older readers reject v9 rather than discard routing. The public `xtc_track_info` grew fields; API consumers must rebuild. Fixtures cover v1–v8 migration and tests exercise v9 exact roundtrips and malformed routing. + +## Phase 26: version 10 interpolation and controller bindings + +The current writer emits v10 and reads v1–v9. Track/bus automation points are +`POINT track_id parameter tick value_bits step`, where step is 0 linear or 1 +hold for the segment starting there. v1–v9 points migrate to step=0. LADSPA +`PARAM` encoding remains unchanged (already has interpolation mode). + +Immediately after all POINT records, before END, v10 requires `CONTROL_MAPS 16` +and exactly 16 records in ascending channel order: +`MAP channel volume_CC pan_CC sustain_CC`. Channels are 0..15. Controllers are +0..127 or 128 (disabled); enabled numbers must be distinct within a row and may +not be 120 or 123. Duplicate/missing/out-of-order channels, invalid CCs, reserved +bindings and invalid interpolation modes reject the complete candidate. v1–v9 +supply default 7/10/64 bindings on every channel. No event history/current live +controller values are serialized. Older readers reject v10. + +Fixtures cover v1–v9 migration. Exact roundtrips, malformed metadata, step/linear +renders and remapped live events cover the new writer/reader. Public clients +must rebuild and initialize the appended `xtc_automation_point.step` field. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a47a312..6344e78 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -672,10 +672,36 @@ Enable repeatable changes to mix and instrument parameters over time, with clear - Document precedence rules for automation, controller input, and direct user changes. ### Acceptance criteria -- Supported parameters can be automated and saved in a project. -- Playback produces deterministic automation values for a given project and transport position. -- Missing targets fail safely and are reported to the user. -- Automation processing meets the engine's real-time safety requirements. +- [x] Supported parameters can be automated and saved in a project. +- [x] Playback produces deterministic automation values for a given project and transport position. +- [x] Missing targets fail safely and are reported to the user. +- [x] Automation processing meets the engine's real-time safety requirements. + +Implemented: common stable owner/parameter keys and copied metadata for +track/bus gain/pan, LADSPA input controls and live sine controller targets; +validated common static/lane edit APIs; per-point linear/hold mixer automation; +per-channel saved volume/pan/sustain CC bindings with explicit disable and reserved +panic controllers. GUI adds Interpolation and Audio/MIDI controller mapping; +edits participate in undo/redo. Project v10 reads v1–v9 with linear/default-map +migration. CONTROL.md documents target identity, clip boundaries, precedence, +missing-target handling, controller ramps and real-time ownership. + +Validation: warning-enabled core/GUI builds and full tests; common metadata, +missing owner/port/module failures and retained state; exact step/ramp endpoints, +low-rate collisions, odd blocks, seeks and explicit backward wraps; default/ +remapped/disabled/unmapped CCs and stereo pan, immutable maps after preparation; +GUI map/interpolation/save/Open/undo/redo and invalid-edit rollback; v1–v9 +migration, v10 exact persistence and malformed records; fixture/installed plugin +tests, callback-allocation guards, reliability/static analysis, performance and +capacity tests; software JACK GUI/audio/MIDI and workflow; dependency-free build, +installation/package relocation, core/GUI ASan+UBSan (existing fontconfig leak +suppression only for GUI/system font allocations). + +No loop transport, MIDI learn/capture, write/touch/latch modes, arbitrary MIDI +mapping to arranged faders/plugins, timbre parameter for the built-in sine or +opaque-plugin seek restoration is claimed. Live MIDI targets have reset-default +metadata and controller bindings, not timeline lanes. Physical controllers, +listening and extended third-party-plugin endurance remain unverified. ## Phase 27 — Editing Workflow, Undo/Redo, and Usability diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index b4e0f39..a622830 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -138,3 +138,10 @@ reset and insertion of an effect on a selected wire. Graph wires determine processing order; Delete removes a selected plugin and its wires. Changes are undoable. Replacement keeps compatible audio wiring but resets plugin controls and automation. See PLUGIN_HOSTING.md for supported formats and failure behavior. + +Phase 26: Mixer Interpolation selects Linear or Hold for each point added or +replaced. Audio → MIDI controller mapping lists all channel bindings; enter +`channel volume_CC pan_CC sustain_CC` (channels 0..15, 128 disables, enabled CCs +must be distinct, 120/123 reserved). Maps are undoable and saved. Controllers +modify live sine voices; arranged track/bus/plugin automation takes precedence +over its static controls and is unaffected by those MIDI bindings. See CONTROL.md. diff --git a/include/xtc/xtc_control.h b/include/xtc/xtc_control.h new file mode 100644 index 0000000..37003ab --- /dev/null +++ b/include/xtc/xtc_control.h @@ -0,0 +1,40 @@ +#ifndef XTC_CONTROL_H +#define XTC_CONTROL_H +#include "xtc_mixer.h" +#include "xtc_plugin.h" +#ifdef __cplusplus +extern "C" { +#endif +/* Stable owner ID + parameter ID, never a dense list index or pointer. + * TRACK includes mix buses: parameters 0 gain / 1 pan. + * PLUGIN owner is a graph node; parameter is its actual LADSPA port. + * LIVE owner is MIDI channel 0..15; parameters 0 volume / 1 pan / 2 sustain. + * Live metadata describes reset defaults, not callback-owned current values. */ +typedef enum { XTC_PARAMETER_TRACK = 1, XTC_PARAMETER_PLUGIN, XTC_PARAMETER_LIVE } xtc_parameter_kind; +typedef struct { xtc_parameter_kind kind; uint64_t owner; unsigned int parameter; } xtc_parameter_key; +typedef struct { + xtc_parameter_info info; + unsigned int flags; /* XTC_PLUGIN_* hint flags; units empty if unspecified. */ + float value; /* Static configuration; automation may override it. */ + int automated; +} xtc_parameter_state; +/* Control thread only. Plugin metadata executes trusted module discovery. + * Failure leaves output/model unchanged. Setter changes static configuration, + * never erases a lane. Compile/publish snapshots before callback use. */ +xtc_result xtc_project_parameter(const xtc_project *project, xtc_parameter_key key, double rate, xtc_parameter_state *out); +xtc_result xtc_project_set_parameter(xtc_project *project, xtc_parameter_key key, double rate, float value); +/* Absolute ticks; step 0 linear / 1 hold for segment starting at this point. + * Integer/toggle plugin controls require hold. Live automation is unsupported. + * Remove ignores value/step and fails NOT_FOUND when no point exists. */ +xtc_result xtc_project_automate(xtc_project *project, xtc_parameter_key key, double rate, + uint64_t tick, unsigned int step, float value, int remove); +#define XTC_CC_DISABLED 128U +/* Per-channel bindings in order volume, pan, sustain. Defaults 7,10,64. + * Enabled controllers must be distinct and <=127; 120/123 are reserved panic. + * No learn/capture. Copied into MIDI plans; never read from model in callback. */ +xtc_result xtc_project_get_control_map(const xtc_project *project, unsigned int channel, unsigned int out[3]); +xtc_result xtc_project_set_control_map(xtc_project *project, unsigned int channel, const unsigned int controllers[3]); +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/xtc/xtc_midi.h b/include/xtc/xtc_midi.h index 06a90b3..2c06721 100644 --- a/include/xtc/xtc_midi.h +++ b/include/xtc/xtc_midi.h @@ -124,11 +124,12 @@ xtc_result xtc_project_edit_note(xtc_project *project, uint64_t id, uint64_t sta * @return XTC_OK on success, or an error code describing the failure. */ xtc_result xtc_project_remove_note(xtc_project *project, uint64_t id); -/* Complete 3-byte note on/off and CC7/10/64/120/123. Velocity-zero on -> off. +/* Complete 3-byte note on/off and CC0..127. Velocity-zero on -> off. + * CC120/123 decode to ALL_OFF; other CCs are interpreted by prepared bindings. * Unsupported messages return UNSUPPORTED; malformed supported messages INVALID. * Output unchanged on error. No running-status/SysEx/program/bend support. */ /** - * @brief Decodes a complete note or all-notes-off MIDI message into a block event. + * @brief Decodes a complete note, controller or all-notes-off MIDI message into a block event. * * @param bytes Borrowed MIDI message bytes. * @param size Number of message bytes. diff --git a/include/xtc/xtc_mixer.h b/include/xtc/xtc_mixer.h index 1de3ad5..0663eb6 100644 --- a/include/xtc/xtc_mixer.h +++ b/include/xtc/xtc_mixer.h @@ -19,6 +19,7 @@ typedef struct { uint64_t track_id, tick; xtc_automation_parameter parameter; float value; + unsigned int step; /* 0 linear, 1 hold; mode starts at this point. */ } xtc_automation_point; /* Buses share the 64 track/automation capacities. A bus cannot contain clips @@ -35,7 +36,7 @@ xtc_result xtc_peak_meter_update(xtc_peak_meter *meter, float linear_peak, doubl /* Gain 0..4, pan -1..1, mute/solo 0 or 1. Track audio and scheduled MIDI only. * Linear pan: center unity on both outputs; hard pan attenuates opposite side. - * Automation replaces the corresponding static value, linear between points, + * Automation replaces the corresponding static value, linear/hold per point, * holds first/last outside the lane. Duplicate track/parameter/tick replaces. */ /** * @brief Sets a track's gain, pan, mute, and solo controls after validation. diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index cc55cea..cff6b95 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 9U +#define XTC_PROJECT_FORMAT_VERSION 10U #define XTC_PROJECT_MAX_TRACKS 64U #define XTC_PROJECT_MAX_CLIPS 1024U #define XTC_PROJECT_NAME_BYTES 64U diff --git a/src/control/model.c b/src/control/model.c new file mode 100644 index 0000000..2e20000 --- /dev/null +++ b/src/control/model.c @@ -0,0 +1,130 @@ +#include "xtc/xtc_control.h" +#include "../project/project_internal.h" +#include +#include +#include + +xtc_result xtc_project_get_control_map(const xtc_project *p, unsigned int channel, unsigned int out[3]) +{ + size_t i; + if (!p || !out || channel > 15) return XTC_ERR_INVALID_ARGUMENT; + for (i = 0; i < 3; ++i) out[i] = p->control_map[channel][i]; + return XTC_OK; +} +xtc_result xtc_project_set_control_map(xtc_project *p, unsigned int channel, const unsigned int cc[3]) +{ + size_t i, j; + if (!p || !cc || channel > 15) return XTC_ERR_INVALID_ARGUMENT; + for (i = 0; i < 3; ++i) { + if (cc[i] > XTC_CC_DISABLED || cc[i] == 120 || cc[i] == 123) return XTC_ERR_INVALID_ARGUMENT; + for (j = 0; j < i; ++j) if (cc[i] != XTC_CC_DISABLED && cc[i] == cc[j]) return XTC_ERR_INVALID_ARGUMENT; + } + for (i = 0; i < 3; ++i) p->control_map[channel][i] = (unsigned char)cc[i]; + return XTC_OK; +} +xtc_result xtc_project_parameter(const xtc_project *p, xtc_parameter_key key, double rate, xtc_parameter_state *out) +{ + xtc_parameter_state state = {0}; + size_t i; + if (!p || !out || !isfinite(rate) || rate < 1 || rate > 768000) return XTC_ERR_INVALID_ARGUMENT; + state.info.id = key.parameter; + if (key.kind == XTC_PARAMETER_TRACK) { + xtc_track_info track; + xtc_result r = XTC_ERR_NOT_FOUND; + if (key.parameter > 1) return XTC_ERR_NOT_FOUND; + for (i = 0; i < p->track_count; ++i) if (p->tracks[i].id == key.owner) { track = p->tracks[i]; r = XTC_OK; break; } + if (r != XTC_OK) return r; + snprintf(state.info.name,sizeof(state.info.name),"%s",key.parameter ? "Pan" : "Gain"); + snprintf(state.info.unit,sizeof(state.info.unit),"%s",key.parameter ? "balance" : "linear"); + state.info.minimum = key.parameter ? -1 : 0; + state.info.maximum = key.parameter ? 1 : 4; + state.info.default_value = key.parameter ? 0 : 1; + state.value = key.parameter ? track.pan : track.gain; + state.flags = XTC_PLUGIN_BOUNDED_BELOW | XTC_PLUGIN_BOUNDED_ABOVE; + for (i = 0; i < p->automation_count; ++i) + if (p->automation[i].track_id == key.owner && (unsigned int)p->automation[i].parameter == key.parameter) state.automated = 1; + } else if (key.kind == XTC_PARAMETER_PLUGIN) { + xtc_plugin_spec spec; + xtc_plugin_details details; + xtc_result r; + if (key.owner > SIZE_MAX) return XTC_ERR_NOT_FOUND; + r = xtc_graph_get_plugin(p->graph,(size_t)key.owner,&spec); + if (r != XTC_OK) return r; + r = xtc_plugin_describe(&spec,rate,&details); + if (r != XTC_OK) return r; + for (i = 0; i < spec.control_count && spec.ports[i] != key.parameter; ++i) {} + if (i == spec.control_count) return XTC_ERR_NOT_FOUND; + state.info = details.parameters[i]; state.flags = details.flags[i]; state.value = spec.values[i]; + for (i = 0; i < spec.point_count; ++i) if (spec.points[i].port == key.parameter) state.automated = 1; + } else if (key.kind == XTC_PARAMETER_LIVE) { + if (key.owner > 15 || key.parameter > 2) return XTC_ERR_NOT_FOUND; + snprintf(state.info.name,sizeof(state.info.name),"%s",key.parameter == 0 ? "Live volume" : key.parameter == 1 ? "Live pan" : "Live sustain"); + snprintf(state.info.unit,sizeof(state.info.unit),"%s",key.parameter == 0 ? "linear" : key.parameter == 1 ? "balance" : "boolean"); + state.info.minimum = key.parameter == 1 ? -1 : 0; + state.info.maximum = 1; + state.info.default_value = key.parameter == 0 ? 1 : 0; + state.value = state.info.default_value; + state.flags = XTC_PLUGIN_BOUNDED_BELOW | XTC_PLUGIN_BOUNDED_ABOVE | (key.parameter == 2 ? XTC_PLUGIN_TOGGLED : 0U); + } else return XTC_ERR_INVALID_ARGUMENT; + /* Keep all output untouched on failure. */ + *out = state; + return XTC_OK; +} +static int valid_value(const xtc_parameter_state *state, float value) +{ + return isfinite(value) && value >= state->info.minimum && value <= state->info.maximum && + (!(state->flags & XTC_PLUGIN_INTEGER) || value == floorf(value)) && + (!(state->flags & XTC_PLUGIN_TOGGLED) || value == 0 || value == 1); +} +xtc_result xtc_project_set_parameter(xtc_project *p, xtc_parameter_key key, double rate, float value) +{ + xtc_parameter_state state; + xtc_result r = xtc_project_parameter(p,key,rate,&state); + size_t i; + if (r != XTC_OK) return r; + if (!valid_value(&state,value)) return XTC_ERR_INVALID_ARGUMENT; + if (key.kind == XTC_PARAMETER_TRACK) { + for (i = 0; i < p->track_count; ++i) if (p->tracks[i].id == key.owner) { + if (key.parameter) p->tracks[i].pan = value; else p->tracks[i].gain = value; + return XTC_OK; + } + } else if (key.kind == XTC_PARAMETER_PLUGIN) { + xtc_plugin_spec spec; + r = xtc_graph_get_plugin(p->graph,(size_t)key.owner,&spec); + if (r != XTC_OK) return r; + for (i = 0; i < spec.control_count; ++i) if (spec.ports[i] == key.parameter) spec.values[i] = value; + return xtc_graph_set_plugin(p->graph,(size_t)key.owner,&spec); + } + return XTC_ERR_UNSUPPORTED; +} +xtc_result xtc_project_automate(xtc_project *p, xtc_parameter_key key, double rate, + uint64_t tick, unsigned int step, float value, int remove) +{ + xtc_parameter_state state; + xtc_result r = xtc_project_parameter(p,key,rate,&state); + if (r != XTC_OK) return r; + if ((remove != 0 && remove != 1) || tick > XTC_PROJECT_MAX_TICK) return XTC_ERR_INVALID_ARGUMENT; + if (key.kind == XTC_PARAMETER_LIVE) return XTC_ERR_UNSUPPORTED; + if (!remove && (step > 1 || !valid_value(&state,value) || + (!step && (state.flags & (XTC_PLUGIN_INTEGER|XTC_PLUGIN_TOGGLED))))) return XTC_ERR_INVALID_ARGUMENT; + if (key.kind == XTC_PARAMETER_TRACK) { + xtc_automation_point point = {key.owner,tick,(xtc_automation_parameter)key.parameter,value,step}; + return remove ? xtc_project_remove_automation(p,key.owner,point.parameter,tick) : xtc_project_set_automation(p,&point); + } else { + xtc_plugin_spec spec; + unsigned int i; + r = xtc_graph_get_plugin(p->graph,(size_t)key.owner,&spec); + if (r != XTC_OK) return r; + for (i = 0; i < spec.point_count; ++i) if (spec.points[i].port == key.parameter && spec.points[i].tick == tick) break; + if (remove) { + if (i == spec.point_count) return XTC_ERR_NOT_FOUND; + memmove(spec.points+i,spec.points+i+1,(spec.point_count-i-1)*sizeof(*spec.points)); + --spec.point_count; + } else { + if (i == XTC_PLUGIN_POINTS) return XTC_ERR_CAPACITY; + spec.points[i] = (xtc_plugin_point){tick,key.parameter,step,value}; + if (i == spec.point_count) ++spec.point_count; + } + return xtc_graph_set_plugin(p->graph,(size_t)key.owner,&spec); + } +} diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 443b166..438d90a 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -73,6 +73,7 @@ MainWindow::MainWindow(bool prompt_unsaved) menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this); menu->add("File/Import WAV", FL_CTRL + 'i', audio_file_callback, this); menu->add("File/Relink selected audio", 0, audio_file_callback, this); + menu->add("Audio/MIDI controller mapping", 0, control_map_callback, this); menu->add("Audio/Panic", FL_F + 9, panic_callback, this); menu->add("View/Piano roll", FL_CTRL + '4', piano_callback, this); menu->add("Audio/Record WAV", FL_SHIFT + FL_F + 5, audio_file_callback, this); @@ -1366,6 +1367,36 @@ void MainWindow::history_callback(Fl_Widget *widget, void *context) window->report_error("Undo/redo failed", r); } +xtc_result MainWindow::set_control_map(unsigned int channel, const unsigned int controllers[3]) +{ + 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_control_map(candidate,channel,controllers); + if (r == XTC_OK) r = install_project(candidate,false); + if (r != XTC_OK) xtc_project_destroy(candidate); else mark_dirty(); + return r; +} +void MainWindow::control_map_callback(Fl_Widget *, void *context) +{ + auto *w = static_cast(context); + if (w->recorder_) { w->report_error("Stop recording before controller mapping",XTC_ERR_INVALID_STATE); return; } + unsigned int controllers[3]{}; + std::string prompt = "Live sine MIDI bindings (channels 0..15; 128 disables). CC120/123 reserved.\n"; + for (unsigned int channel = 0; channel < 16; ++channel) { + xtc_project_get_control_map(w->project_.get(),channel,controllers); + prompt += std::to_string(channel) + ": volume " + std::to_string(controllers[0]) + + ", pan " + std::to_string(controllers[1]) + ", sustain " + std::to_string(controllers[2]) + "\n"; + } + prompt += "Enter channel volume_CC pan_CC sustain_CC (distinct enabled CCs):"; + const char *answer = fl_input("%s","0 7 10 64",prompt.c_str()); + if (!answer) return; + unsigned int channel; char extra; + xtc_result r = std::sscanf(answer,"%u %u %u %u %c",&channel,controllers,controllers+1,controllers+2,&extra) == 4 ? + w->set_control_map(channel,controllers) : XTC_ERR_INVALID_ARGUMENT; + if (r != XTC_OK) w->report_error("Controller mapping rejected: check channel, unique CCs and reserved panic controllers",r); +} + xtc_result MainWindow::install_plugin_graph(xtc_graph *graph) { xtc_project *candidate = nullptr; @@ -1454,21 +1485,16 @@ xtc_result MainWindow::set_plugin_control(size_t id, unsigned int port, float va xtc_result MainWindow::set_plugin_automation(size_t id, const xtc_plugin_point &point, bool remove) { if (recorder_) return XTC_ERR_INVALID_STATE; + double rate = 48000; xtc_backend_status status{}; + if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; + xtc_project *candidate = nullptr; + xtc_result r = xtc_project_clone(project_.get(),&candidate); + xtc_parameter_key key{XTC_PARAMETER_PLUGIN,static_cast(id),point.port}; + if (r == XTC_OK) r = xtc_project_automate(candidate,key,rate,point.tick,point.step,point.value,remove ? 1 : 0); xtc_plugin_spec spec{}; - xtc_result r = xtc_graph_get_plugin(xtc_project_graph(project_.get()),id,&spec); - if (r != XTC_OK) return r; - unsigned i; - for (i = 0; i < spec.point_count; ++i) if (spec.points[i].port == point.port && spec.points[i].tick == point.tick) break; - if (remove) { - if (i == spec.point_count) return XTC_ERR_NOT_FOUND; - for (unsigned j = i+1; j < spec.point_count; ++j) spec.points[j-1] = spec.points[j]; - --spec.point_count; - } else { - if (i == XTC_PLUGIN_POINTS) return XTC_ERR_CAPACITY; - spec.points[i] = point; - if (i == spec.point_count) ++spec.point_count; - } - return apply_plugin_spec(id,spec); + if (r == XTC_OK) r = xtc_graph_get_plugin(xtc_project_graph(candidate),id,&spec); + xtc_project_destroy(candidate); + return r == XTC_OK ? apply_plugin_spec(id,spec) : r; } xtc_result MainWindow::apply_plugin_spec(size_t id, const xtc_plugin_spec &spec, bool replace) { diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 68454ae..f53273d 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -8,6 +8,7 @@ #include "xtc/xtc_backend.h" #include "xtc/xtc_mixer.h" #include "xtc/xtc_plugin.h" +#include "xtc/xtc_control.h" class GraphWidget; class MixerWidget; @@ -20,6 +21,7 @@ class Fl_Tabs; class MainWindow : public Fl_Double_Window { public: /* Control-thread plugin commands, transactional model adoption. */ + 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); xtc_result replace_plugin(size_t id, const char *path, unsigned int index); @@ -292,6 +294,7 @@ class MainWindow : public Fl_Double_Window { * @param widget Triggering Fl_Menu_Bar whose selected label names the action. * @param context MainWindow * owning the project and file path. */ + static void control_map_callback(Fl_Widget *widget, void *context); static void plugin_callback(Fl_Widget *widget, void *context); xtc_result apply_plugin_spec(size_t id, const xtc_plugin_spec &spec, bool replace = false); xtc_result install_plugin_graph(xtc_graph *graph); diff --git a/src/gui/mixer_widget.cpp b/src/gui/mixer_widget.cpp index bb6e9ea..fdd843b 100644 --- a/src/gui/mixer_widget.cpp +++ b/src/gui/mixer_widget.cpp @@ -1,4 +1,5 @@ #include "mixer_widget.hpp" +#include "xtc/xtc_control.h" #include #include #include @@ -88,6 +89,7 @@ class AutomationLane : public Fl_Widget { if (it == points.end()) return points.back().value; const auto &a = *(it - 1); + if (a.step) return a.value; return a.value + static_cast(static_cast(tick - a.tick) / static_cast(it->tick - a.tick)) * (it->value - a.value); @@ -222,6 +224,8 @@ MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit start_->bounds(0, XTC_PROJECT_MAX_TICK - lane_ticks); start_->step(3840); start_->callback(select, this); + mode_ = new Fl_Choice(x + 705, y + 146, 150, 26, "Interpolation"); + mode_->add("Linear|Hold (step)"); mode_->value(0); auto *hint = new Fl_Box(x + 10, y + 180, w - 20, 24, "Gain 0..4; pan -1 left / +1 right. M/S toggles; arrows change gain, " "Shift+arrows pan; Up/Down selects track."); @@ -413,10 +417,9 @@ xtc_result MixerWidget::mix(float gain, float pan, int mute, int solo) xtc_result MixerWidget::point(uint64_t tick, float value, bool remove) { xtc_result r = before_ ? before_(context_) : XTC_OK; - xtc_automation_point a{selected_track(), tick, parameter(), value}; + xtc_parameter_key key{XTC_PARAMETER_TRACK,selected_track(),static_cast(parameter())}; if (r == XTC_OK) - r = remove ? xtc_project_remove_automation(project_, a.track_id, a.parameter, tick) - : xtc_project_set_automation(project_, &a); + r = xtc_project_automate(project_,key,48000,tick,mode_->value() == 1 ? 1U : 0U,value,remove ? 1 : 0); return changed(r); } diff --git a/src/gui/mixer_widget.hpp b/src/gui/mixer_widget.hpp index 43e5c5b..5de73ea 100644 --- a/src/gui/mixer_widget.hpp +++ b/src/gui/mixer_widget.hpp @@ -143,7 +143,7 @@ class MixerWidget : public Fl_Group { xtc_project *project_; BeforeEdit before_; void *context_; - Fl_Choice *tracks_, *parameter_, *routes_; + Fl_Choice *tracks_, *parameter_, *routes_, *mode_; std::vector route_ids_; Fl_Value_Slider *gain_, *pan_; Fl_Check_Button *mute_, *solo_; diff --git a/src/midi/instrument.c b/src/midi/instrument.c index d28aaf7..b340201 100644 --- a/src/midi/instrument.c +++ b/src/midi/instrument.c @@ -30,6 +30,7 @@ struct xtc_midi_plan { uint64_t monitor_position; xtc_gain_ramp volume[16], pan[16]; unsigned int sustain[16]; + unsigned char control_map[16][3]; xtc_mix_snapshot mix; span notes[XTC_PROJECT_MAX_NOTES]; size_t count; @@ -39,7 +40,7 @@ struct xtc_midi_plan { }; /** - * @brief Decodes a complete note or all-notes-off MIDI message into a block event. + * @brief Decodes a complete note, controller or all-notes-off MIDI message into a block event. * * @param bytes Borrowed MIDI message bytes. * @param size Number of message bytes. @@ -58,8 +59,6 @@ xtc_result xtc_midi_decode(const unsigned char *bytes, size_t size, unsigned int return XTC_ERR_UNSUPPORTED; if (size != 3 || bytes[1] > 127 || bytes[2] > 127) return XTC_ERR_INVALID_ARGUMENT; - if (command == 176 && bytes[1] != 120 && bytes[1] != 123 && bytes[1] != 7 && bytes[1] != 10 && bytes[1] != 64) - return XTC_ERR_UNSUPPORTED; e.offset = offset; e.channel = bytes[0] & 15U; e.pitch = bytes[1]; @@ -190,6 +189,7 @@ xtc_result xtc_midi_plan_create(const xtc_project *project, unsigned int rate, x } } free(points); + memcpy(p->control_map,project->control_map,sizeof(p->control_map)); p->controller_frames = rate / 200U; if (!p->controller_frames) p->controller_frames = 1; xtc_midi_plan_reset(p); @@ -255,10 +255,10 @@ static void event(xtc_midi_plan *p, const xtc_midi_event *e, uint64_t position) return; } if (e->type == XTC_MIDI_CONTROL) { - if (e->pitch == 7) (void)xtc_gain_ramp_target(p->volume+e->channel,(float)e->velocity/127.0f,p->controller_frames); - else if (e->pitch == 10) (void)xtc_gain_ramp_target(p->pan+e->channel, + if (e->pitch == p->control_map[e->channel][0]) (void)xtc_gain_ramp_target(p->volume+e->channel,(float)e->velocity/127.0f,p->controller_frames); + else if (e->pitch == p->control_map[e->channel][1]) (void)xtc_gain_ramp_target(p->pan+e->channel, e->velocity >= 64 ? (float)(e->velocity-64)/63.0f : ((float)e->velocity-64)/64.0f,p->controller_frames); - else if (e->pitch == 64) { + else if (e->pitch == p->control_map[e->channel][2]) { p->sustain[e->channel] = e->velocity >= 64; if (!p->sustain[e->channel]) for (i = 0; i < XTC_MIDI_VOICES; ++i) if (p->voices[i].channel == e->channel && p->voices[i].released) p->voices[i].active = 0; diff --git a/src/mixer/mix_internal.h b/src/mixer/mix_internal.h index 8378184..dd1a833 100644 --- a/src/mixer/mix_internal.h +++ b/src/mixer/mix_internal.h @@ -7,7 +7,7 @@ typedef struct { uint64_t sample, tick; size_t track; - unsigned int parameter; + unsigned int parameter, step; float value; } xtc_mix_point; diff --git a/src/mixer/model.c b/src/mixer/model.c index e5e0a21..e839d08 100644 --- a/src/mixer/model.c +++ b/src/mixer/model.c @@ -55,7 +55,7 @@ xtc_result xtc_project_set_mix(xtc_project *p, uint64_t id, float gain, float pa xtc_result xtc_project_set_automation(xtc_project *p, const xtc_automation_point *a) { size_t i; - if (!p || !a || !isfinite(a->value) || a->tick > XTC_PROJECT_MAX_TICK || + if (!p || !a || a->step > 1 || !isfinite(a->value) || a->tick > XTC_PROJECT_MAX_TICK || (a->parameter != XTC_AUTOMATION_GAIN && a->parameter != XTC_AUTOMATION_PAN) || a->value < (a->parameter == XTC_AUTOMATION_GAIN ? 0 : -1) || a->value > (a->parameter == XTC_AUTOMATION_GAIN ? 4 : 1)) diff --git a/src/mixer/render.c b/src/mixer/render.c index 9251ff5..22c484c 100644 --- a/src/mixer/render.c +++ b/src/mixer/render.c @@ -63,6 +63,7 @@ xtc_result xtc_mix_prepare(const xtc_project *p, unsigned int rate, xtc_mix_snap return XTC_ERR_INVALID_STATE; b->tick = a->tick; b->value = a->value; + b->step = a->step; r = xtc_project_tick_to_sample(p, a->tick, rate, &b->sample); if (r != XTC_OK) return r; @@ -102,8 +103,8 @@ static float value(const xtc_mix_snapshot *m, size_t track, unsigned int param, } if (!low) return p[0].value; - if (low == count) - return p[count - 1].value; + if (low == count || p[low-1].step) + return p[low - 1].value; return (float)(p[low - 1].value + ((double)sample - (double)p[low - 1].sample) / (double)(p[low].sample - p[low - 1].sample) * (p[low].value - p[low - 1].value)); diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index 19e433a..4238146 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -403,7 +403,7 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * project->automation_count = (size_t)value; for (i = 0; i < project->automation_count; ++i) { xtc_automation_point *a = project->automation + i; - READ("POINT", 5U); + READ("POINT", format_version >= 10U ? 6U : 5U); NUM(1U, a->track_id); NUM(2U, value); if (value > 1) @@ -412,6 +412,22 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * NUM(3U, a->tick); if (!decode_float(input->tokens[4], &a->value)) return XTC_ERR_FORMAT; + if (format_version >= 10U) { + NUM(5U,value); if (value > 1) return XTC_ERR_FORMAT; + a->step = (unsigned int)value; + } + } + } + if (format_version >= 10U) { + READ("CONTROL_MAPS",2U); NUM(1U,value); + if (value != 16) return XTC_ERR_FORMAT; + for (i = 0; i < 16; ++i) { + READ("MAP",5U); NUM(1U,value); + if (value != i) return XTC_ERR_FORMAT; + for (j = 0; j < 3; ++j) { + NUM(j+2,value); if (value > 128) return XTC_ERR_FORMAT; + project->control_map[i][j] = (unsigned char)value; + } } } READ("END", 1U); @@ -549,10 +565,14 @@ static xtc_result write_project(FILE *file, const xtc_project *project, const ch uint32_t bits; const xtc_automation_point *a = project->automation + i; memcpy(&bits, &a->value, sizeof(bits)); - if (fprintf(file, "POINT %" PRIu64 " %u %" PRIu64 " %08" PRIx32 "\n", a->track_id, (unsigned int)a->parameter, - a->tick, bits) < 0) + if (fprintf(file, "POINT %" PRIu64 " %u %" PRIu64 " %08" PRIx32 " %u\n", a->track_id, (unsigned int)a->parameter, + a->tick, bits, a->step) < 0) return XTC_ERR_IO; } + if (fputs("CONTROL_MAPS 16\n",file) == EOF) return XTC_ERR_IO; + 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; return fputs("END\n", file) == EOF ? XTC_ERR_IO : XTC_OK; } diff --git a/src/project/project.c b/src/project/project.c index 826bd03..080e558 100644 --- a/src/project/project.c +++ b/src/project/project.c @@ -119,6 +119,13 @@ xtc_result xtc_project_create(xtc_project **out_project) } project->graph->require_output_nodes = 1; project->tempo = 120000U; + { size_t channel; + for (channel = 0; channel < 16; ++channel) { + project->control_map[channel][0] = 7; + project->control_map[channel][1] = 10; + project->control_map[channel][2] = 64; + } + } *out_project = project; return XTC_OK; } @@ -516,6 +523,13 @@ xtc_result xtc_project_validate(const xtc_project *project) if (project->tempo < 20000U || project->tempo > 300000U || project->track_count > XTC_PROJECT_MAX_TRACKS || project->clip_count > XTC_PROJECT_MAX_CLIPS || project->note_count > XTC_PROJECT_MAX_NOTES) return XTC_ERR_FORMAT; + for (i = 0; i < 16; ++i) for (j = 0; j < 3; ++j) { + unsigned int cc = project->control_map[i][j]; + size_t k; + if (cc > 128 || cc == 120 || cc == 123) return XTC_ERR_FORMAT; + for (k = 0; k < j; ++k) + if (cc != 128 && cc == project->control_map[i][k]) return XTC_ERR_FORMAT; + } for (i = 0U; i < project->track_count; ++i) { if (project->tracks[i].id >= project->next_track_id || !xtc_project_valid_name(project->tracks[i].name)) return XTC_ERR_FORMAT; @@ -577,7 +591,7 @@ xtc_result xtc_project_validate(const xtc_project *project) } for (i = 0; i < project->automation_count; ++i) { const xtc_automation_point *a = project->automation + i; - if (track_index(project, a->track_id) == SIZE_MAX || a->tick > XTC_PROJECT_MAX_TICK || !isfinite(a->value) || + if (track_index(project, a->track_id) == SIZE_MAX || a->tick > XTC_PROJECT_MAX_TICK || a->step > 1 || !isfinite(a->value) || (a->parameter != XTC_AUTOMATION_GAIN && a->parameter != XTC_AUTOMATION_PAN) || a->value < (a->parameter == XTC_AUTOMATION_GAIN ? 0 : -1) || a->value > (a->parameter == XTC_AUTOMATION_GAIN ? 4 : 1)) diff --git a/src/project/project_internal.h b/src/project/project_internal.h index bc93d08..1d24e31 100644 --- a/src/project/project_internal.h +++ b/src/project/project_internal.h @@ -9,6 +9,7 @@ struct xtc_project { xtc_graph *graph; uint64_t next_track_id, next_clip_id; unsigned int tempo; + unsigned char control_map[16][3]; size_t track_count, clip_count; uint64_t next_note_id; size_t note_count; diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index c59ebd5..55e8247 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-v8.xtc` are hand-authored xtc metadata fixtures +`project-v1.xtc` through `project-v9.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-v9.xtc b/tests/fixtures/project-v9.xtc new file mode 100644 index 0000000..627a973 --- /dev/null +++ b/tests/fixtures/project-v9.xtc @@ -0,0 +1,22 @@ +XTC_PROJECT 9 +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 +END diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 7b7210d..ed3ffee 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -5,6 +5,7 @@ #include "../../src/gui/mixer_widget.hpp" #include "../../src/gui/plugin_dialog.hpp" #include +#include #include #include #include @@ -630,6 +631,31 @@ int main(int argc, char **argv) mixer->reset_meters(); CHECK(!mixer->master_clipped()); window.do_callback(); } + { + reset(); MainWindow window(false); + auto *sequence = timeline(&window); + unsigned int bindings[3] = {1,74,11}, actual[3]; + CHECK(window.set_control_map(2,bindings) == XTC_OK); + CHECK(xtc_project_get_control_map(sequence->project(),2,actual) == XTC_OK && actual[0] == 1); + CHECK(window.undo(false) == XTC_OK); + CHECK(xtc_project_get_control_map(sequence->project(),2,actual) == XTC_OK && actual[0] == 7); + CHECK(window.undo(true) == XTC_OK); + const xtc_project *before = sequence->project(); + bindings[1] = 1; + CHECK(window.set_control_map(2,bindings) == XTC_ERR_INVALID_ARGUMENT && sequence->project() == before); + auto *mixer_ui = window.mixer(); + CHECK(sequence->add_track() == XTC_OK); + auto *mode = dynamic_cast(find(&window,"Interpolation")); CHECK(mode); + if (mode) mode->value(1); + CHECK(mixer_ui->point(0,.25f) == XTC_OK); + xtc_automation_point point{}; + CHECK(xtc_project_get_automation(sequence->project(),0,&point) == XTC_OK && point.step == 1); + CHECK(window.save_project("/tmp/xtc-gui-controls-test.xtc") == XTC_OK); + CHECK(window.new_project() == XTC_OK && window.open_project("/tmp/xtc-gui-controls-test.xtc") == XTC_OK); + CHECK(xtc_project_get_control_map(sequence->project(),2,actual) == XTC_OK && actual[0] == 1 && actual[1] == 74); + CHECK(xtc_project_get_automation(sequence->project(),0,&point) == XTC_OK && point.step == 1); + unlink("/tmp/xtc-gui-controls-test.xtc"); window.do_callback(); + } if (const char *module = std::getenv("XTC_TEST_PLUGIN")) { reset(); MainWindow window(false); diff --git a/tests/integration/test_install.py b/tests/integration/test_install.py index 4d76220..29d09bf 100644 --- a/tests/integration/test_install.py +++ b/tests/integration/test_install.py @@ -37,7 +37,7 @@ def main(): assert os.access(root / 'bin/xtc-check', os.X_OK) run([str(root / 'bin/xtc-check')]) diagnose = subprocess.check_output([str(root / 'bin/xtc-check'), '--diagnose'], text=True) - assert 'Project formats: 1..9' in diagnose and 'JACK compiled:' in diagnose + assert 'Project formats: 1..10' in diagnose and 'JACK compiled:' in diagnose # Upgrade/repair repeats the same owned-file install and keeps unrelated data. (root / 'include/xtc/xtc.h').write_text('old installation marker\n') run(make + ['install'] + variables, env=env) diff --git a/tests/integration/test_performance.c b/tests/integration/test_performance.c index 41a9a91..3817735 100644 --- a/tests/integration/test_performance.c +++ b/tests/integration/test_performance.c @@ -56,7 +56,7 @@ int main(int argc, char **argv) for (j = 0; j < 2; ++j) REQUIRE(xtc_project_add_note(project, clip, 0, 384000, 48 + (unsigned int)i, 100, 0, ¬e) == XTC_OK); - point = (xtc_automation_point){track, 0, XTC_AUTOMATION_GAIN, 0.1f}; + point = (xtc_automation_point){track, 0, XTC_AUTOMATION_GAIN, 0.1f,0}; REQUIRE(xtc_project_set_automation(project, &point) == XTC_OK); point.tick = 384000; point.value = 0.2f; diff --git a/tests/integration/test_plugins.c b/tests/integration/test_plugins.c index b3fedd1..9cdfd8a 100644 --- a/tests/integration/test_plugins.c +++ b/tests/integration/test_plugins.c @@ -1,5 +1,6 @@ #define _POSIX_C_SOURCE 200809L #include "xtc/xtc_plugin.h" +#include "xtc/xtc_control.h" #include "xtc/xtc_project.h" #include "xtc/xtc_realtime.h" #include @@ -79,6 +80,12 @@ static void graph_roundtrip(const xtc_plugin_spec *spec) assert(xtc_project_create(&loaded) == XTC_OK); assert(xtc_project_load(loaded,path) == XTC_OK); assert(xtc_graph_compile(xtc_project_graph(loaded),48000,31,&again) == XTC_ERR_NOT_FOUND && !again); + { + xtc_parameter_key key = {XTC_PARAMETER_PLUGIN,plugin,copy.control_count ? copy.ports[0] : 0}; + xtc_parameter_state state = {0}; state.value = .75f; + assert(xtc_project_parameter(loaded,key,48000,&state) == XTC_ERR_NOT_FOUND && state.value == .75f); + assert(xtc_project_automate(loaded,key,48000,0,1,1,0) == XTC_ERR_NOT_FOUND); + } assert(xtc_project_save(loaded,path) == XTC_OK); xtc_graph_destroy(g); xtc_project_destroy(project); xtc_project_destroy(loaded); xtc_plan_destroy(p); unlink(path); @@ -167,6 +174,25 @@ int main(int argc, char **argv) assert(xtc_plugin_process_at(other,in,split,1,25) == XTC_OK && split[0] == 2); xtc_plugin_destroy(p); xtc_plugin_destroy(other); p = NULL; } + { + xtc_project *project = NULL; xtc_graph *g = NULL; + xtc_parameter_key key; + xtc_parameter_state state; + xtc_plugin_spec copy; + size_t id; + assert(xtc_project_create(&project) == XTC_OK && xtc_graph_create(8,&g) == XTC_OK); + assert(xtc_graph_add_plugin(g,&s,&id) == XTC_OK && xtc_project_set_graph(project,g) == XTC_OK); + key = (xtc_parameter_key){XTC_PARAMETER_PLUGIN,id,s.ports[0]}; + assert(xtc_project_parameter(project,key,48000,&state) == XTC_OK && state.automated && state.info.id == s.ports[0]); + assert(xtc_project_set_parameter(project,key,48000,.75f) == XTC_OK); + assert(xtc_project_automate(project,key,48000,3,1,1,0) == XTC_OK); + assert(xtc_project_automate(project,key,48000,3,99,NAN,1) == XTC_OK); // Removal ignores mode/value. + assert(xtc_project_set_parameter(project,key,48000,3) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_graph_get_plugin(xtc_project_graph(project),id,©) == XTC_OK && copy.values[0] == .75f && copy.point_count == 2); + key.parameter = 63; + assert(xtc_project_automate(project,key,48000,0,1,0,0) == XTC_ERR_NOT_FOUND); + xtc_graph_destroy(g); xtc_project_destroy(project); + } s.point_count = 0; s.values[0] = 3; assert(xtc_plugin_create(&s,48000,17,&p) == XTC_ERR_UNSUPPORTED && !p); assert(xtc_plugin_probe(argv[1],1,48000,&s) == XTC_OK && !s.inputs); diff --git a/tests/integration/test_portability.c b/tests/integration/test_portability.c index a6aa5d9..01da632 100644 --- a/tests/integration/test_portability.c +++ b/tests/integration/test_portability.c @@ -3,6 +3,7 @@ #include "xtc/xtc_mixer.h" #include "xtc/xtc_midi.h" #include "xtc/xtc_plugin.h" +#include "xtc/xtc_control.h" #include #include #include @@ -18,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 <= 8; ++version) { + for (version = 1; version <= 9; ++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); @@ -28,6 +29,9 @@ int main(void) assert(c.gain == 1 && c.fade_in_tick == 0 && c.fade_out_tick == 0); assert(xtc_project_note_count(p,¬es) == XTC_OK && notes == (size_t)(version >= 3)); assert(xtc_project_automation_count(p,&points) == XTC_OK && points == (size_t)(version >= 4)); + { unsigned int map[3]; xtc_automation_point point; + assert(xtc_project_get_control_map(p,0,map) == XTC_OK && map[0] == 7 && map[1] == 10 && map[2] == 64); + if (points) assert(xtc_project_get_automation(p,0,&point) == XTC_OK && !point.step); } assert(xtc_project_save(p,file) == XTC_OK && xtc_project_load(p,file) == XTC_OK); } for (version = 5; version <= 6; ++version) { @@ -85,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-v8 migration, current folder relocation, cwd independence and missing-media preservation passed."); + puts("v1-v9 migration, current folder relocation, cwd independence and missing-media preservation passed."); return 0; } diff --git a/tests/integration/test_scale.c b/tests/integration/test_scale.c index 204e7f3..6dca90a 100644 --- a/tests/integration/test_scale.c +++ b/tests/integration/test_scale.c @@ -18,10 +18,10 @@ int main(void) for (i = 0; i < XTC_PROJECT_MAX_NOTES; ++i) assert(xtc_project_add_note(p,clips[i%1024],i/1024,1,60,100,0,&id) == XTC_OK); assert(xtc_project_add_note(p,clips[0],0,1,60,100,0,&id) == XTC_ERR_CAPACITY); for (i = 0; i < XTC_MAX_AUTOMATION_POINTS; ++i) { - xtc_automation_point a = {tracks[i%64],i/64,XTC_AUTOMATION_GAIN,.1f}; + xtc_automation_point a = {tracks[i%64],i/64,XTC_AUTOMATION_GAIN,.1f,0}; assert(xtc_project_set_automation(p,&a) == XTC_OK); } - { xtc_automation_point a = {tracks[0],1000,XTC_AUTOMATION_GAIN,1}; assert(xtc_project_set_automation(p,&a) == XTC_ERR_CAPACITY); } + { xtc_automation_point a = {tracks[0],1000,XTC_AUTOMATION_GAIN,1,0}; assert(xtc_project_set_automation(p,&a) == XTC_ERR_CAPACITY); } assert(xtc_graph_create(256,&graph) == XTC_OK && xtc_graph_add_instrument(graph,&previous) == XTC_OK); for (i = 1; i < 256; ++i) { assert((i == 255 ? xtc_graph_add_output(graph,&node) : xtc_graph_add_meter(graph,&node)) == XTC_OK); diff --git a/tests/unit/test_buses.c b/tests/unit/test_buses.c index ca61931..45f18eb 100644 --- a/tests/unit/test_buses.c +++ b/tests/unit/test_buses.c @@ -62,7 +62,7 @@ int main(void) assert(xtc_project_set_mix(p,bus,.5f,1,1,1)==XTC_OK); render(p,left,right,meters); assert(right[0]==0 && meters[0]==.5f && meters[7*64]==0); assert(xtc_project_set_mix(p,bus,.5f,0,0,0)==XTC_OK); - point=(xtc_automation_point){bus,0,XTC_AUTOMATION_GAIN,.25f}; + point=(xtc_automation_point){bus,0,XTC_AUTOMATION_GAIN,.25f,0}; assert(xtc_project_set_automation(p,&point)==XTC_OK); render(p,left,right,meters); assert(left[0]==.5f && right[0]==.5f); /* .25 bus + .25 direct */ assert(xtc_project_save(p,song)==XTC_OK && xtc_project_load(q,song)==XTC_OK); diff --git a/tests/unit/test_control.c b/tests/unit/test_control.c index 3274cbf..e4acdb8 100644 --- a/tests/unit/test_control.c +++ b/tests/unit/test_control.c @@ -1,4 +1,5 @@ #include "xtc/xtc_midi.h" +#include "xtc/xtc_control.h" #include #include #include @@ -29,10 +30,55 @@ int main(void) for (i = 0; i < 400; ++i) assert(whole[i] == split[i]); assert(whole[6] != 0); /* Sustain retains the released note. */ for (i = 250; i < 400; ++i) assert(whole[i] == 0); /* CC7 ramp reaches zero in 240 samples. */ + { + xtc_midi_plan *mapped = NULL, *disabled = NULL; + xtc_parameter_state state; + xtc_parameter_key key = {XTC_PARAMETER_LIVE,2,2}; + unsigned int bindings[3] = {1,74,11}; + xtc_midi_event remapped[9]; + float actual[400], right[400]; + unsigned char message[3] = {0xb2,1,0}; + assert(xtc_midi_decode(message,3,0,&decoded) == XTC_OK && decoded.type == XTC_MIDI_CONTROL); + message[1] = 120; + assert(xtc_midi_decode(message,3,0,&decoded) == XTC_OK && decoded.type == XTC_MIDI_ALL_OFF); + assert(xtc_project_parameter(project,key,48000,&state) == XTC_OK && !state.automated && + (state.flags & XTC_PLUGIN_TOGGLED) && state.value == 0); + assert(xtc_project_automate(project,key,48000,0,1,1,0) == XTC_ERR_UNSUPPORTED); + assert(xtc_project_set_control_map(project,2,bindings) == XTC_OK); + assert(xtc_midi_plan_create(project,48000,&mapped) == XTC_OK); + for (i = 0; i < 8; ++i) { + remapped[i+(i ? 1 : 0)] = events[i]; + if (events[i].type == XTC_MIDI_CONTROL) + remapped[i+1].pitch = events[i].pitch == 7 ? 1 : events[i].pitch == 10 ? 74 : 11; + } + // Former CC7 binding is ignored, not an additional volume binding. + remapped[1] = (xtc_midi_event){1,XTC_MIDI_CONTROL,2,7,0}; + assert(xtc_midi_plan_render(mapped,0,remapped,9,actual,400) == XTC_OK); + for (i = 0; i < 400; ++i) assert(actual[i] == whole[i]); + { + const xtc_midi_event pan[] = {{0,XTC_MIDI_NOTE_ON,2,69,127},{0,XTC_MIDI_CONTROL,2,74,127}}; + xtc_midi_plan_reset(mapped); + assert(xtc_midi_plan_render_stereo(mapped,0,pan,2,actual,right,400) == XTC_OK); + assert(actual[300] == 0 && right[300] != 0); + } + // Mutating the model does not change an already prepared mapping. + bindings[0] = bindings[1] = bindings[2] = XTC_CC_DISABLED; + assert(xtc_project_set_control_map(project,2,bindings) == XTC_OK); + xtc_midi_plan_reset(mapped); + assert(xtc_midi_plan_render(mapped,0,remapped,9,actual,400) == XTC_OK); + for (i = 0; i < 400; ++i) assert(actual[i] == whole[i]); + assert(xtc_midi_plan_create(project,48000,&disabled) == XTC_OK); + { + const xtc_midi_event unbound[] = {{0,XTC_MIDI_NOTE_ON,2,69,127},{3,XTC_MIDI_CONTROL,2,1,0}}; + assert(xtc_midi_plan_render(disabled,0,unbound,2,actual,400) == XTC_OK); + assert(actual[300] != 0); // No mapped volume ramp reaches zero. + } + xtc_midi_plan_destroy(mapped); xtc_midi_plan_destroy(disabled); + } xtc_midi_plan_reset(a); assert(xtc_midi_plan_render(a,400,NULL,0,whole,400) == XTC_OK); for (i = 0; i < 400; ++i) assert(whole[i] == 0); xtc_midi_plan_destroy(a); xtc_midi_plan_destroy(b); xtc_project_destroy(project); - puts("Sample-timed CC7/CC10/sustain, retrigger, note-off and block partition checks passed."); + puts("Sample-timed default/remapped/disabled controllers, immutable maps, sustain, retrigger, note-off and block partition checks passed."); return 0; } diff --git a/tests/unit/test_mixer.c b/tests/unit/test_mixer.c index 3d8b363..3c8451e 100644 --- a/tests/unit/test_mixer.c +++ b/tests/unit/test_mixer.c @@ -71,12 +71,12 @@ int main(void) assert(xtc_project_set_mix(p, a, NAN, 0, 0, 0) == XTC_ERR_INVALID_ARGUMENT); assert(xtc_project_set_mix(p, a, 1, 2, 0, 0) == XTC_ERR_INVALID_ARGUMENT); assert(xtc_project_set_mix(p, a, 1, 0, 2, 0) == XTC_ERR_INVALID_ARGUMENT); - point = (xtc_automation_point){a, 0, XTC_AUTOMATION_GAIN, 0}; + point = (xtc_automation_point){a, 0, XTC_AUTOMATION_GAIN, 0,0}; assert(xtc_project_set_automation(p, &point) == XTC_OK); point.tick = 20; point.value = 1; assert(xtc_project_set_automation(p, &point) == XTC_OK); /* sample 500 */ - point = (xtc_automation_point){a, 0, XTC_AUTOMATION_PAN, -1}; + point = (xtc_automation_point){a, 0, XTC_AUTOMATION_PAN, -1,0}; assert(xtc_project_set_automation(p, &point) == XTC_OK); point.tick = 20; point.value = 1; @@ -165,8 +165,8 @@ int main(void) { const char *bad[] = {"MIX 7fc00000 00000000 0 0", "MIX 40800001 00000000 0 0", "MIX 3f800000 40000000 0 0", "MIX 3f800000 00000000 2 0", - "POINT 999 0 0 3f800000", "POINT 0 2 0 3f800000", - "POINT 0 0 0 7f800000", "POINT 0 0 20 3f800000"}; + "POINT 999 0 0 3f800000 0", "POINT 0 2 0 3f800000 0", + "POINT 0 0 0 7f800000 0", "POINT 0 0 20 3f800000 0", "POINT 0 0 0 3f800000 2"}; for (i = 0; i < sizeof(bad) / sizeof(*bad); ++i) { char *start = strstr(bytes, i < 4 ? "MIX " : "POINT "), *end; assert(start); @@ -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, "POINT ", 6) && strncmp(line, "AUDIO ", 6) && strncmp(line, "ROUTE ", 6) && strncmp(line,"CONTROL_MAPS ",13) && strncmp(line,"MAP ",4)) assert(fputs(line, legacy) >= 0); } assert(fclose(f) == 0 && fclose(legacy) == 0); @@ -237,7 +237,7 @@ int main(void) xtc_history_destroy(history); assert(xtc_project_remove_track(p, a) == XTC_OK && xtc_project_automation_count(p, &count) == XTC_OK && count == 0); - point = (xtc_automation_point){b, 0, XTC_AUTOMATION_GAIN, 1}; + point = (xtc_automation_point){b, 0, XTC_AUTOMATION_GAIN, 1,0}; for (i = 0; i < XTC_MAX_AUTOMATION_POINTS; ++i) { point.tick = i; assert(xtc_project_set_automation(p, &point) == XTC_OK); diff --git a/tests/unit/test_parameters.c b/tests/unit/test_parameters.c new file mode 100644 index 0000000..014d046 --- /dev/null +++ b/tests/unit/test_parameters.c @@ -0,0 +1,97 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_control.h" +#include "xtc/xtc_midi.h" +#include "../../src/mixer/mix_internal.h" +#include +#include +#include +#include +#include +#include + +int main(void) +{ + xtc_project *p = NULL, *loaded = NULL; + xtc_midi_plan *reference = NULL, *whole = NULL, *split = NULL; + xtc_parameter_state state, sentinel; + uint64_t track, clip, note; + float base[80], left[80], right[80], sl[80], sr[80]; + unsigned int map[3] = {1,2,3}, actual[3]; + size_t i, cursor; + char path[] = "/tmp/xtc-parameters-XXXXXX"; + int fd = mkstemp(path); assert(fd >= 0); close(fd); + assert(xtc_project_create(&p) == XTC_OK); + assert(xtc_project_add_track(p,"Parameters",&track) == XTC_OK); + assert(xtc_project_add_midi_clip(p,track,0,960,&clip) == XTC_OK); + assert(xtc_project_add_note(p,clip,0,960,69,127,0,¬e) == XTC_OK); + assert(xtc_midi_plan_create(p,48000,&reference) == XTC_OK); + assert(xtc_midi_plan_render(reference,0,NULL,0,base,80) == XTC_OK); + { + xtc_parameter_key gain = {XTC_PARAMETER_TRACK,track,0}, pan = {XTC_PARAMETER_TRACK,track,1}; + xtc_parameter_key missing = {XTC_PARAMETER_TRACK,UINT64_MAX,0}; + xtc_mix_snapshot mix; + assert(xtc_project_parameter(p,gain,48000,&state) == XTC_OK && !state.automated && state.value == 1); + assert(!strcmp(state.info.unit,"linear") && state.info.minimum == 0 && state.info.maximum == 4); + sentinel = state; + assert(xtc_project_parameter(p,missing,48000,&state) == XTC_ERR_NOT_FOUND && !memcmp(&state,&sentinel,sizeof(state))); + assert(xtc_project_automate(p,missing,48000,0,0,1,0) == XTC_ERR_NOT_FOUND); + assert(xtc_project_automate(p,gain,48000,0,1,.25f,0) == XTC_OK); + assert(xtc_project_automate(p,gain,48000,1,0,1,0) == XTC_OK); + assert(xtc_project_automate(p,gain,48000,2,0,.5f,0) == XTC_OK); + assert(xtc_project_automate(p,pan,48000,0,1,-1,0) == XTC_OK); + assert(xtc_project_automate(p,pan,48000,2,1,1,0) == XTC_OK); + assert(xtc_project_automate(p,gain,48000,3,2,.5f,0) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_set_parameter(p,gain,48000,3) == XTC_OK); + assert(xtc_project_parameter(p,gain,48000,&state) == XTC_OK && state.automated && state.value == 3); + assert(xtc_project_set_parameter(p,gain,48000,NAN) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_mix_prepare(p,1,&mix) == XTC_OK && xtc_mix_level(&mix,0,0,2) == .5f); // Colliding ticks: latest wins. + } + assert(xtc_project_get_control_map(p,0,actual) == XTC_OK && actual[0] == 7 && actual[1] == 10 && actual[2] == 64); + assert(xtc_project_set_control_map(p,0,map) == XTC_OK); + map[1] = 1; + assert(xtc_project_set_control_map(p,0,map) == XTC_ERR_INVALID_ARGUMENT); + map[1] = 120; + assert(xtc_project_set_control_map(p,0,map) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_get_control_map(p,0,actual) == XTC_OK && actual[0] == 1 && actual[1] == 2 && actual[2] == 3); + assert(xtc_project_save(p,path) == XTC_OK); + assert(xtc_project_create(&loaded) == XTC_OK && xtc_project_load(loaded,path) == XTC_OK); + assert(xtc_project_get_control_map(loaded,0,actual) == XTC_OK && actual[0] == 1 && actual[1] == 2 && actual[2] == 3); + { + xtc_automation_point point; + assert(xtc_project_get_automation(loaded,0,&point) == XTC_OK && point.step == 1); + } + assert(xtc_midi_plan_create(loaded,48000,&whole) == XTC_OK && xtc_midi_plan_create(loaded,48000,&split) == XTC_OK); + assert(xtc_midi_plan_render_stereo(whole,0,NULL,0,left,right,80) == XTC_OK); + for (cursor = 0; cursor < 80;) { + size_t frames = 13; if (frames > 80-cursor) frames = 80-cursor; + assert(xtc_midi_plan_render_stereo(split,cursor,NULL,0,sl+cursor,sr+cursor,frames) == XTC_OK); + cursor += frames; + } + for (i = 0; i < 80; ++i) { + float gain = i < 25 ? .25f : i < 50 ? 1-(float)(i-25)/50 : .5f; + assert(left[i] == sl[i] && right[i] == sr[i]); + assert(fabsf(left[i] - (i < 50 ? base[i]*gain : 0)) < .000001f); + assert(fabsf(right[i] - (i < 50 ? 0 : base[i]*gain)) < .000001f); + } + assert(xtc_midi_plan_render_stereo(split,29,NULL,0,sl,sr,13) == XTC_OK); + for (i = 0; i < 13; ++i) assert(sl[i] == left[i+29] && sr[i] == right[i+29]); + // Explicit backward transport wrap (no loop transport feature is implied). + assert(xtc_midi_plan_render_stereo(split,0,NULL,0,sl,sr,80) == XTC_OK); + for (i = 0; i < 80; ++i) assert(sl[i] == left[i] && sr[i] == right[i]); + { + FILE *source = fopen(path,"r"), *dest; + char bad[128], line[1024]; + snprintf(bad,sizeof(bad),"%s-bad",path); dest = fopen(bad,"w"); assert(source && dest); + while (fgets(line,sizeof(line),source)) { + if (!strncmp(line,"MAP 0 ",6)) fputs("MAP 0 1 1 3\n",dest); else fputs(line,dest); + } + fclose(source); assert(fclose(dest) == 0); + assert(xtc_project_load(loaded,bad) == XTC_ERR_FORMAT); + assert(xtc_project_get_control_map(loaded,0,actual) == XTC_OK && actual[1] == 2); + unlink(bad); + } + xtc_midi_plan_destroy(reference); xtc_midi_plan_destroy(whole); xtc_midi_plan_destroy(split); + xtc_project_destroy(p); xtc_project_destroy(loaded); unlink(path); + puts("Common parameter identity, hold/linear lanes, static precedence, persistence, seeks and wrap passed."); + return 0; +}