From 73fc5eea8d5a6995881c2083099c11761db93343 Mon Sep 17 00:00:00 2001 From: hanez Date: Tue, 27 Oct 2026 15:34:54 +0100 Subject: [PATCH] Phase 21: add filtered MIDI composition edits and triplet piano-roll grids --- Makefile | 8 ++ docs/MIDI.md | 69 +++++++++- docs/ROADMAP.md | 233 +++++++++++++++++++++++++++++++++ docs/USER_MANUAL.md | 9 ++ docs/WORKFLOW.md | 2 +- include/xtc/xtc_midi.h | 29 +++- src/gui/main_window.cpp | 15 ++- src/gui/piano_roll.cpp | 180 +++++++++++++++++++++---- src/gui/piano_roll.hpp | 21 ++- src/midi/model.c | 87 ++++++++++++ tests/integration/test_gui.cpp | 45 +++++++ tests/unit/test_composition.c | 137 +++++++++++++++++++ 12 files changed, 796 insertions(+), 39 deletions(-) create mode 100644 tests/unit/test_composition.c diff --git a/Makefile b/Makefile index 8d3d095..91b405c 100644 --- a/Makefile +++ b/Makefile @@ -375,3 +375,11 @@ package: release $(PYTHON) tools/package.py --build-dir "$(BUILD_DIR)/release" --output "$(PACKAGE_FILE)" $(if $(GUI_AVAILABLE),--gui) test-package: package $(PYTHON) tests/integration/test_package.py "$(PACKAGE_FILE)" --build-dir "$(BUILD_DIR)/release" + +$(BUILD_DIR)/test_composition: $(BUILD_DIR)/tests/unit/test_composition.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -ldl +.PHONY: test-composition +test-composition: $(BUILD_DIR)/test_composition + ./$(BUILD_DIR)/test_composition +test: test-composition +-include $(BUILD_DIR)/tests/unit/test_composition.d diff --git a/docs/MIDI.md b/docs/MIDI.md index 1aa2d78..b7c77ff 100644 --- a/docs/MIDI.md +++ b/docs/MIDI.md @@ -14,9 +14,9 @@ are connected automatically. Connect a MIDI source to the backend's `midi_in` JACK port, and route audio output explicitly to hear it while Play is running. The piano roll shows all 128 pitches and edits clip-source time. Click blank -space to draw a one-beat note (velocity 100, channel 0); drag its body to move +space to draw a one-beat note (velocity 100, filtered channel or channel 0); drag its body to move it, drag the right edge to resize, or right-click/Delete to remove it. Edits -snap to 1/16 notes. Escape cancels a drag. Trimmed-out source notes remain editable +snap to the configured grid (default 1/16). Escape cancels a drag. Trimmed-out source notes remain editable and are preserved. Arrangement/note edits stop playback and are blocked during audio recording. Save/Open preserves MIDI flags, notes and stable IDs. @@ -37,8 +37,9 @@ builds audio and MIDI candidates before committing the stopped backend state. JACK events have sample offsets within the current block. Supported messages are complete note on/off (including velocity-zero note on) and CC120/123 on -channels 0..15. Equal-offset events retain input order. Other messages are -ignored; sustain, bend, program changes, running status and SysEx are unsupported. +channels 0..15, plus CC7 volume, CC10 pan and CC64 sustain (see CONTROL.md). +Equal-offset events retain input order. Bend, program changes, running status +and SysEx are unsupported. At most 256 events are accepted per block. Malformed/overflowed input discards that block's events and triggers panic, with a MIDI-drop status counter. @@ -58,8 +59,8 @@ rejects more than 32 overlapping scheduled notes with CAPACITY. Such metadata can be saved by the C model, but GUI Open/Play rejects it until overlap is reduced. Immediate note-off/stealing can click; there is no envelope or limiter, and summed voices can exceed unity. Above-Nyquist pitches are silent. -There is no per-track/channel instrument routing, velocity editor, MIDI recording, -SMF import, undo or piano-roll zoom yet. +There is no per-track/channel instrument routing, MIDI recording, SMF import +or piano-roll zoom yet. Note edits participate in project snapshot undo/redo. ## Verification @@ -74,3 +75,59 @@ Physical MIDI devices, bridge/device loss behind an unchanged port, latency and long-running performance remain unverified. Panic/Stop provides manual recovery. Phase 8 adds track mixing, automation and project undo/redo; see MIXER.md. + +## Phase 21 composition commands + +The piano-roll **Notes** menu exposes these commands. Shortcuts apply while the +piano roll has focus; use the main window Edit menu for undo/redo. + +| Command | Shortcut | Behavior | +| --- | --- | --- | +| Grid | G | 1, 120, 160, 240, 320, 480 or 960 ticks | +| Filter | F | Channel -1 (all) or 0..15, inclusive pitch range 0..127 | +| Velocity and channel | V | Set either property; -1 preserves it | +| Quantize | Q | Snap starts to nearest configured grid; ties go later | +| Transpose | T / Up / Down | Semitone count / one semitone up / down | +| Repeat | Ctrl+D | Additional copies at a positive source-tick spacing | +| Select filtered | Ctrl+A | Select every note matching the current clip filter | +| Delete | Delete | Remove the single selected note or all filtered notes | + +160 ticks is a sixteenth-note triplet; 320 is an eighth-note triplet. +The grid governs drawing, drag movement and minimum resize duration. At 1-tick +snap the visual grid remains 120 ticks to stay legible at the fixed zoom. +The header shows current grid, channel/pitch filter and selection scope. +Velocity also affects unselected note brightness. Preferences are session-only. + +Click selects one visible note; Ctrl+A selects the whole filtered set. A click +or filter change clears that set selection. Hidden notes cannot be mouse-edited. +A channel filter also sets the channel for newly drawn notes. No lasso or +arbitrary disjoint multi-selection is provided. Moving/resizing uses the mouse; +V edits velocity/channel without changing timing, pitch or IDs. Quantization +changes starts only, preserving durations and other properties. Repetition +keeps originals and gives every copy a fresh stable ID; spacing need not equal +note length, so both overlapping patterns and rests are possible. The existing +clip source must have room for every result; extend it in Sequencer first if +needed. Source notes outside the visible arrangement trim remain editable. + +All batch edits validate before mutation. Invalid pitch/range, copy counts, +capacity overflow or a note crossing the source boundary rejects the entire +operation; notes are never silently clamped, merged or shortened. Unmatched +notes, notes in other clips and unrelated properties are preserved. Each +successful command records one undo step and stops playback before model edits; +Play prepares the new engine plan. Undo/redo reinstalls the corresponding plan. +These edits use the existing persisted note fields; format remains v7. + +Scheduled notes occupy [start,end), with ends counted before starts at an equal +sample for the 32-voice limit. Overlapping same-channel/pitch scheduled notes +are independent sine voices; adjacent repetitions restart phase. Live repeated +notes instead retrigger the matching channel/pitch voice, and a note-off releases +that voice (or defers release under sustain). Equal-sample live on/off/control +messages execute in caller/JACK order, with no implicit priority reordering. +The renderer rejects malformed/unsorted/out-of-range events before touching +output or runtime; raw velocity-zero note-ons decode to note-off. + +`make test-composition` covers filters, property preservation, triplet rounding, +repeats/fresh IDs, rejected batch rollback, capacity, undo snapshots, equal-offset +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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ff68136..541d759 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -458,6 +458,239 @@ or distro registration is performed. Independent human installation/usability, physical-device/endurance and listening acceptance remain pending and must be completed before claiming production qualification. +## Phase 21 — Advanced MIDI and Composition + +### Goal +Make MIDI composition expressive and efficient without turning the application into an unnecessarily complicated notation editor. + +### Tasks +- Improve piano-roll editing for note creation, selection, movement, resizing, and deletion. +- Support configurable snap and grid resolution, including triplets where practical. +- Add velocity editing and editing for supported MIDI note properties. +- Support MIDI channel and note-range filtering where appropriate. +- Improve quantization, transposition, duplication, and pattern repetition. +- Ensure MIDI event ordering is deterministic, especially when note-off and note-on events share a timestamp. +- Define handling for overlapping notes, repeated notes, and malformed MIDI events. +- Add regression tests for MIDI editing and playback scheduling. + +### Acceptance criteria +- Common MIDI editing tasks can be completed without manually editing project files. +- Editing preserves unrelated notes and event properties. +- MIDI scheduling remains deterministic at buffer boundaries and under transport changes. +- MIDI operations are covered by automated tests. + +Implemented: transactional C filtered note transforms, nearest-grid quantization, +velocity/channel edits, transposition, bounded fresh-ID repetition and deletion; +piano-roll Notes menu, keyboard commands, configurable straight/triplet grids, +channel/pitch filtering and filtered-set selection. Existing mouse move/resize +and project undo/redo remain supported. Core and GUI regression tests demonstrate +property/other-clip preservation, rollback, undo/redo, adjacent/overlapping notes, +equal-offset caller ordering, exact block partitions and seek chase. See MIDI.md. +No format change, MIDI capture, SMF import, lasso selection or piano-roll zoom; +physical-device and listening validation remain pending. + +Acceptance evidence: +- [x] Menu/keyboard/mouse commands edit notes without project-file editing. +- [x] Filtered batch tests preserve unmatched notes and unrelated properties. +- [x] Exact scheduling tests cover buffer boundaries and transport seeks. +- [x] Core composition and GUI undo/redo regressions exercise these operations. + +## Phase 22 — Audio Clip Editing and Utility Operations + +### Goal +Provide focused, non-destructive audio editing tools for arranging and preparing recorded or imported material. + +### Tasks +- Support moving, trimming, splitting, duplicating, and deleting audio clips. +- Define clip-boundary behavior, including fades and source offsets. +- Add non-destructive clip gain and basic fade-in/fade-out controls. +- Support time-stretching and pitch-shifting only after selecting a suitable implementation and quality target; otherwise document them as future work. +- Ensure edits do not unexpectedly modify source audio files. +- Add waveform display caching that never blocks the audio thread. +- Test edits at project boundaries, source-file boundaries, and very short clip lengths. +- Document which operations are destructive and non-destructive. + +### Acceptance criteria +- Basic clip editing is predictable and undoable. +- Source files remain unchanged unless a documented destructive operation is explicitly invoked. +- Clip boundaries do not produce unintended clicks from invalid offsets or discontinuities. +- Editing and waveform generation never perform blocking work in the real-time audio callback. + +## Phase 23 — Modular Workflow and Routing Refinement + +### Goal +Preserve the modular spirit of the application while making complex projects understandable and manageable. + +### Tasks +- Refine graph and routing views so signal paths are easy to inspect. +- Make connections, disconnections, and invalid routing states clear. +- Provide useful names and visual distinctions for tracks, instruments, effects, buses, and I/O ports. +- Support track grouping or folders if the project model can represent them cleanly. +- Add routing presets or reusable track templates only if they do not complicate the core model. +- Define behavior for cycles and feedback paths; reject unsupported cycles explicitly. +- Make graph changes transactional so failed edits do not leave partially updated routing. +- Add tests for graph changes while playback is running. + +### Acceptance criteria +- Users can inspect and understand a project's signal path. +- Invalid graph edits fail safely and leave the previous valid graph intact. +- Graph updates follow the documented real-time synchronization strategy. +- GUI routing concepts map cleanly to the public C engine API. + +## Phase 24 — Mixing and Mastering Essentials + +### Goal +Provide the essential mixing controls needed to finish a small or medium-sized project. + +### Tasks +- Complete track and bus gain, pan, mute, solo, and metering behavior. +- Define how solo states affect buses, sends, and sidechains. +- Add peak meters with clear units and documented ballistics. +- Add master-bus monitoring and clipping indicators. +- Support routing to buses and auxiliary sends if the graph model supports them. +- Make gain staging explicit and avoid hidden or surprising level changes. +- Document meter tap points and whether values are pre-fader, post-fader, or post-effects. +- Test mute, solo, pan, gain, and routing interactions. + +### Acceptance criteria +- Mixer controls behave consistently across tracks, buses, and master output. +- Meter readings correspond to documented signal points and units. +- Solo and mute behavior is covered by automated tests. +- No mixer operation introduces allocations, locks, or other prohibited work in the audio callback. + +## Phase 25 — Plugin and Instrument Usability + +### Goal +Make instruments and effects practical to use, manage, and diagnose in real projects. + +### Tasks +- Improve plugin discovery, insertion, removal, replacement, and ordering. +- Show useful plugin identity and status information, including load failures. +- Define parameter metadata, ranges, defaults, units, and formatting. +- Support parameter reset and safe default values. +- Preserve plugin state across save/load where the plugin API supports it. +- Define behavior when a saved plugin is missing or incompatible. +- Manage plugin UI lifetime separately from engine object lifetime. +- Add tests for plugin lifecycle, state restoration, and failure paths. +- Document plugin API versioning and compatibility expectations. + +### Acceptance criteria +- Users can identify and manage loaded instruments and effects without editing configuration files. +- Missing or incompatible plugins produce actionable diagnostics and do not silently corrupt projects. +- Plugin state restoration is tested for supported plugin types. +- The engine remains safe if plugin initialization or processing fails, within the documented failure model. + +## Phase 26 — Automation and Control Mapping + +### Goal +Enable repeatable changes to mix and instrument parameters over time, with clear timing and ownership rules. + +### Tasks +- Define a common parameter identity and metadata model. +- Support automation lanes for a carefully selected set of parameters. +- Define interpolation modes and behavior at clip boundaries. +- Specify how automation interacts with live parameter changes and MIDI control. +- Add control mapping for selected external MIDI controllers if feasible. +- Schedule automation deterministically without allocating memory in the audio callback. +- Define behavior when a target parameter or plugin is missing. +- Test automation at buffer boundaries, during seeks, and across loop points. +- 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. + +## Phase 27 — Editing Workflow, Undo/Redo, and Usability + +### Goal +Make common editing actions quick, discoverable, and safe to reverse. + +### Tasks +- Audit user-visible editing operations for undo/redo support. +- Implement command-based edits with explicit ownership and rollback behavior. +- Group related low-level changes into one meaningful undo step. +- Define undo behavior for operations affecting engine state or external files. +- Improve keyboard shortcuts, selection behavior, focus handling, and context menus. +- Use confirmation only for genuinely destructive or difficult-to-reverse actions. +- Provide clear feedback for long-running operations and failures. +- Add GUI tests or reproducible manual test procedures for critical workflows. +- Keep presentation logic out of the core C engine. + +### Acceptance criteria +- Documented editing operations have predictable undo/redo behavior. +- Undo and redo restore the project model and relevant engine state consistently. +- Failed operations do not leave partial changes behind. +- Common tasks can be completed using documented keyboard and mouse interactions. + +## Phase 28 — Project Format, Compatibility, and Portability + +### Goal +Make project files robust, diagnosable, and maintainable across application versions and supported systems. + +### Tasks +- Version the project format and document its compatibility policy. +- Validate project input before applying it to the live engine. +- Handle missing media, plugins, and external dependencies with clear diagnostics. +- Define relative-path, absolute-path, and media-relocation behavior. +- Add safe migration routines for supported older versions. +- Preserve unknown optional fields where practical, or document when they cannot be preserved. +- Test malformed files, truncated files, oversized input, and invalid values. +- Make saves atomic where the platform permits, using a temporary file and replacement strategy. +- Document portability limits, including plugin and audio-backend dependencies. + +### Acceptance criteria +- Invalid project files are rejected safely without partially replacing the current project. +- Save failures do not silently destroy the previous valid project file. +- Version incompatibilities and missing dependencies are clearly reported. +- Supported project versions have migration and regression tests. + +## Phase 29 — Performance, Scalability, and Stress Testing + +### Goal +Establish measurable performance expectations and verify that larger projects remain stable. + +### Tasks +- Define representative test projects and target buffer sizes. +- Measure audio callback duration, deadline misses, CPU use, memory use, and graph rebuild costs. +- Test projects with increasing numbers of tracks, graph nodes, connections, MIDI events, and automation points. +- Test long playback sessions and repeated start, stop, seek, and loop operations. +- Stress-test device changes, plugin failures, missing media, and project reloads. +- Use profiling to identify bottlenecks before introducing optimizations. +- Verify that the real-time callback does not allocate, block on mutexes, perform file I/O, or wait on GUI work. +- Document expected limits and known performance bottlenecks. +- Add sanitizers and static analysis to regular development builds where supported. + +### Acceptance criteria +- Performance is measured against documented test cases, not subjective impressions alone. +- Stress tests can run repeatedly and produce reproducible results. +- No known real-time safety violation remains in the engine's own callback path. +- Performance regressions can be detected by repeatable tests or benchmark reports. + +## Phase 30 — Release Engineering, Packaging, and Maintenance + +### Goal +Turn the application into a maintainable release that users can build, install, run, and troubleshoot. + +### Tasks +- Define supported operating systems, compiler versions, and audio backends. +- Document build dependencies and provide clear build and test commands. +- Add install and uninstall targets that respect `PREFIX` and standard directory variables. +- Provide appropriate package metadata and desktop integration where relevant. +- Verify installation paths for binaries, libraries, headers, resources, documentation, and example projects. +- Add version reporting and a concise diagnostic mode where useful. +- Prepare release notes, known limitations, and upgrade guidance. +- Test clean builds from a fresh checkout and installation into a staging directory. +- Define a release checklist covering tests, warnings, packaging, and project compatibility. +- Document how users can report reproducible bugs and how maintainers should triage them. + +### Acceptance criteria +- A clean checkout can be built and tested using documented commands. +- Installation works with a configurable prefix and does not overwrite unrelated user files. +- The release includes documentation, known limitations, and a reproducible version identifier. +- The project has a repeatable process for preparing and validating releases. + --- ## Recommended Execution Order diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 68002aa..3f34ed7 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -96,3 +96,12 @@ core files may contain media paths and audio memory; share only what you intend. Report bugs following MAINTENANCE.md, including whether the issue reproduces without plugins. Physical listening and independent human install/usability acceptance remain pending. + +## MIDI composition + +In Piano roll, use the Notes menu for grid (including triplets), channel/pitch +filters, velocity/channel, quantization, transposition and pattern repetition. +Click selects a single note; Ctrl+A selects all filtered notes. Ctrl+D repeats +that selection at a chosen spacing within the existing clip source. Up/Down +transposes by a semitone. Commands are undoable through the main Edit menu. +See [MIDI.md](MIDI.md#phase-21-composition-commands) for shortcuts, timing and limits. diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index f7f122f..8253725 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -113,7 +113,7 @@ Duplicate, Rename and Delete. Invalid/capacity edits retain the existing model. View/Snap selects 1, 120, 240 (default), 480 or 960 ticks. It affects ruler seeks, clip moves and trims; 1 tick is the finest grid. Existing +/- zoom and scrollbars -retain their behavior. Piano roll and mixer lanes retain their documented 240-tick +retain their behavior. Piano roll has its own configurable grid (see MIDI.md); mixer lanes retain their 240-tick grid. Grid/zoom/selection are session UI preferences, not undoable music edits. Escape cancels a drag; Delete affects the focused editor; text fields keep their own editing keys. Commands are named in menus/tooltips and timeline focus accepts diff --git a/include/xtc/xtc_midi.h b/include/xtc/xtc_midi.h index acd08b3..06a90b3 100644 --- a/include/xtc/xtc_midi.h +++ b/include/xtc/xtc_midi.h @@ -16,6 +16,31 @@ typedef struct { unsigned int pitch, velocity, channel; /* 0..127, 1..127, 0..15. */ } xtc_note_info; +/* Control-thread batch edits. UINT64_MAX selects all notes in the clip; + * channel -1 selects all channels. Pitch bounds are inclusive. */ +typedef struct { + uint64_t clip_id, note_id; + int channel; + unsigned int pitch_min, pitch_max; +} xtc_note_filter; +typedef enum { XTC_NOTES_EDIT, XTC_NOTES_REPEAT, XTC_NOTES_DELETE } xtc_note_action; +typedef struct { + xtc_note_action action; + uint64_t grid_tick; /* 0 preserves start; nearest grid, ties round later. */ + int transpose; /* -127..127; out-of-range pitches reject the entire edit. */ + int velocity, channel; /* -1 preserves, otherwise 1..127 / 0..15. */ + unsigned int copies; /* REPEAT only: 1..4096 additional copies. */ + uint64_t interval_tick; /* REPEAT only: positive spacing between copies. */ +} xtc_note_edit; +/* Atomic validation then mutation; no partial edits or consumed IDs on failure. + * EDIT keeps IDs/durations. REPEAT leaves originals untouched and gives copies + * fresh IDs, transformed then offset by k*interval. DELETE uses only action. + * No matches returns NOT_FOUND. All notes must fit the existing source length; + * nothing is clamped, extended or merged. out_count is unchanged on failure, + * and reports original matched notes on success. No callback-thread use. */ +xtc_result xtc_project_edit_notes(xtc_project *project, const xtc_note_filter *filter, + const xtc_note_edit *edit, size_t *out_count); + typedef enum { XTC_MIDI_NOTE_ON = 1, XTC_MIDI_NOTE_OFF, @@ -99,9 +124,9 @@ 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 CC120/123 only. Velocity-zero on -> off. +/* Complete 3-byte note on/off and CC7/10/64/120/123. Velocity-zero on -> off. * Unsupported messages return UNSUPPORTED; malformed supported messages INVALID. - * Output unchanged on error. No running-status/SysEx/pedal/program/bend support. */ + * 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. * diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 85d17cd..f56ba86 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -1238,11 +1238,16 @@ xtc_result MainWindow::open_piano_roll() if (!piano_window_) { piano_window_ = std::make_unique(900, 620, "xtc — Piano roll"); piano_window_->begin(); - auto *hint = new Fl_Box(12, 8, 876, 32, - "Source ticks: click draws; drag body moves/pitches, right edge resizes; " - "right-click/Delete removes. Snap 1/16."); - hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); - hint->labelsize(12); + auto *menu = new Fl_Menu_Bar(12, 8, 876, 30); + const char *commands[] = {"Notes/Grid (G)", "Notes/Filter (F)", "Notes/Velocity and channel (V)", + "Notes/Quantize (Q)", "Notes/Transpose (T)", "Notes/Repeat (Ctrl+D)", + "Notes/Select filtered (Ctrl+A)", "Notes/Delete"}; + for (int i = 0; i < 8; ++i) + menu->add(commands[i], 0, [](Fl_Widget *widget, void *data) { + auto *owner = static_cast(data); + auto *bar = static_cast(widget); + owner->piano_->command(static_cast(bar->mvalue() - bar->menu()) - 1); + }, this); auto *scroll = new Fl_Scroll(12, 48, 876, 560); piano_ = new PianoRollWidget(12, 48, project_.get(), clip.id, before_arrangement_edit, this); piano_->callback( diff --git a/src/gui/piano_roll.cpp b/src/gui/piano_roll.cpp index 6e48399..2be7e41 100644 --- a/src/gui/piano_roll.cpp +++ b/src/gui/piano_roll.cpp @@ -4,9 +4,16 @@ #include #include #include +#include namespace { constexpr int key_width = 64, row_height = 16, scale = 96, header = 28; +template bool parse(const char *text, Values &...values) +{ + std::istringstream input(text); + (input >> ... >> values); + return !input.fail() && (input >> std::ws).eof(); +} } /** @@ -34,15 +41,18 @@ PianoRollWidget::PianoRollWidget(int left, int top, xtc_project *project, uint64 * @brief Points the editor at a different project or clip. * * Clears any in-progress drag when the project or clip changes and resizes the - * widget to fit the clip's source length. The current selection is not changed. + * widget to fit the clip's source length. Selection is cleared on a new model or clip. * * @param project Borrowed project that must outlive the widget. * @param clip ID of the MIDI clip to display. */ void PianoRollWidget::set_project(xtc_project *project, uint64_t clip) { - if (project != project_ || clip != clip_) + if (project != project_ || clip != clip_) { mode_ = 0; + selected_ = UINT64_MAX; + all_ = false; + } project_ = project; clip_ = clip; xtc_clip_info c{}; @@ -57,7 +67,7 @@ void PianoRollWidget::set_project(xtc_project *project, uint64_t clip) * @brief Converts a horizontal pixel position to a snapped tick. * * The position is measured in absolute window coordinates and snapped down to - * the nearest sixteenth note (240 ticks). + * the current configured grid. * * @param px Horizontal position in window coordinates. * @return Tick offset within the clip source, or 0 for positions on or left of @@ -65,7 +75,7 @@ void PianoRollWidget::set_project(xtc_project *project, uint64_t clip) */ uint64_t PianoRollWidget::tick(int px) const { - return static_cast(std::max(0, px - x() - key_width)) * 960U / scale / 240U * 240U; + return static_cast(std::max(0, px - x() - key_width)) * 960U / scale / grid_ * grid_; } /** @@ -106,8 +116,10 @@ xtc_result PianoRollWidget::add_note(uint64_t start, uint64_t length, unsigned i uint64_t id; if (r == XTC_OK) r = xtc_project_add_note(project_, clip_, start, length, pitch, velocity, channel, &id); - if (r == XTC_OK) + if (r == XTC_OK) { selected_ = id; + all_ = false; + } return change(r); } @@ -121,12 +133,9 @@ xtc_result PianoRollWidget::add_note(uint64_t start, uint64_t length, unsigned i */ xtc_result PianoRollWidget::delete_selected() { - xtc_result r = before_ ? before_(context_) : XTC_OK; - if (r == XTC_OK) - r = xtc_project_remove_note(project_, selected_); - if (r == XTC_OK) - selected_ = UINT64_MAX; - return change(r); + xtc_note_edit edit{}; + edit.action = XTC_NOTES_DELETE; + return apply(edit); } /** @@ -164,11 +173,14 @@ void PianoRollWidget::draw() fl_draw(text, x() + 6, py + 12); } } - int first = std::max(0, (cx - x() - key_width) / (scale / 4)); - int last = (cx + cw - x() - key_width) / (scale / 4) + 1; + // At 1-tick snap, show at least a 120-tick visual grid to avoid pixel overdraw. + uint64_t visual = std::max(120, grid_); + int spacing = static_cast(visual * scale / 960); + int first = std::max(0, (cx - x() - key_width) / spacing); + int last = (cx + cw - x() - key_width) / spacing + 1; for (int step = first; step <= last; ++step) { - int px = x() + key_width + step * (scale / 4); - fl_color(step % 16 == 0 ? fl_rgb_color(95, 98, 105) : fl_rgb_color(52, 55, 62)); + int px = x() + key_width + static_cast(static_cast(step) * visual * scale / 960); + fl_color((static_cast(step) * visual) % 3840 == 0 ? fl_rgb_color(95, 98, 105) : fl_rgb_color(52, 55, 62)); fl_line(px, cy, px, cy + ch); } size_t count = 0; @@ -176,7 +188,7 @@ void PianoRollWidget::draw() for (size_t i = 0; i < count; ++i) { xtc_note_info n{}; xtc_project_get_note(project_, i, &n); - if (n.clip_id != clip_) + if (!visible(n)) continue; int left = x() + key_width + static_cast(n.start_tick * scale / 960); int right = left + std::max(3, static_cast(n.length_tick * scale / 960)); @@ -184,9 +196,18 @@ void PianoRollWidget::draw() int a = std::max(cx, left), b = std::min(cx + cw, right); if (a >= b || py + row_height < cy || py > cy + ch) continue; - fl_color(n.id == selected_ ? fl_rgb_color(230, 190, 80) : fl_rgb_color(90, 175, 130)); + fl_color(all_ || n.id == selected_ ? fl_rgb_color(230, 190, 80) + : fl_rgb_color(50, static_cast(70 + n.velocity), 110)); fl_rectf(a, py + 1, b - a, row_height - 3); } + fl_color(fl_rgb_color(30, 33, 38)); + fl_rectf(x(), y(), w(), header); + char status[160]; + std::snprintf(status, sizeof(status), "Snap %llu ticks | Channel %d (-1 all) | Pitches %u..%u | %s", + static_cast(grid_), channel_filter_, low_, high_, + all_ ? "All filtered notes selected" : "Click selects; Ctrl+A selects filtered notes"); + fl_color(FL_WHITE); + fl_draw(status, x() + 6, y() + 18); fl_pop_clip(); } @@ -209,13 +230,14 @@ int PianoRollWidget::handle(int event) size_t count = 0; xtc_project_note_count(project_, &count); selected_ = UINT64_MAX; + all_ = false; for (size_t i = count; i > 0; --i) { xtc_note_info n{}; xtc_project_get_note(project_, i - 1, &n); int left = x() + key_width + static_cast(n.start_tick * scale / 960); int right = left + std::max(3, static_cast(n.length_tick * scale / 960)); int top = y() + header + (127 - static_cast(n.pitch)) * row_height; - if (n.clip_id == clip_ && Fl::event_x() >= left && Fl::event_x() < right && Fl::event_y() >= top && + if (visible(n) && Fl::event_x() >= left && Fl::event_x() < right && Fl::event_y() >= top && Fl::event_y() < top + row_height) { selected_ = n.id; original_ = n; @@ -233,10 +255,10 @@ int PianoRollWidget::handle(int event) int pitch = 127 - (Fl::event_y() - y() - header) / row_height; xtc_clip_info c{}; uint64_t start = tick(Fl::event_x()); - if (pitch >= 0 && pitch <= 127 && xtc_project_find_clip(project_, clip_, &c) == XTC_OK && + if (pitch >= static_cast(low_) && pitch <= static_cast(high_) && xtc_project_find_clip(project_, clip_, &c) == XTC_OK && start < c.source_length_tick) add_note(start, std::min(960, c.source_length_tick - start), - static_cast(pitch)); + static_cast(pitch), 100, channel_filter_ < 0 ? 0U : static_cast(channel_filter_)); } redraw(); return 1; @@ -246,7 +268,7 @@ int PianoRollWidget::handle(int event) if (event == FL_RELEASE && mode_) { int mode = mode_; mode_ = 0; - int64_t delta = static_cast(Fl::event_x() - drag_x_) * 960 / scale / 240 * 240; + int64_t delta = static_cast(Fl::event_x() - drag_x_) * 960 / scale / static_cast(grid_) * static_cast(grid_); xtc_clip_info c{}; if (xtc_project_find_clip(project_, clip_, &c) != XTC_OK) return 1; @@ -259,7 +281,7 @@ int PianoRollWidget::handle(int event) std::clamp(static_cast(pitch) - (Fl::event_y() - drag_y_) / row_height, 0, 127)); } else { int64_t maximum = static_cast(c.source_length_tick) - start; - length = std::clamp(length + delta, std::min(240, maximum), maximum); + length = std::clamp(length + delta, std::min(static_cast(grid_), maximum), maximum); } xtc_result r = before_ ? before_(context_) : XTC_OK; if (r == XTC_OK) @@ -268,6 +290,23 @@ int PianoRollWidget::handle(int event) change(r); return 1; } + if (event == FL_KEYDOWN) { + int key = Fl::event_key(); + if (key == 'a' && (Fl::event_state() & FL_CTRL)) { select_filtered(); return 1; } + if (key == 'q') { command(3); return 1; } + if (key == 'v') { command(2); return 1; } + if (key == 'g') { command(0); return 1; } + if (key == 'f') { command(1); return 1; } + if (key == 't') { command(4); return 1; } + if (key == 'd' && (Fl::event_state() & FL_CTRL)) { command(5); return 1; } + if (key == FL_Up || key == FL_Down) { + xtc_note_edit edit{}; + edit.velocity = edit.channel = -1; + edit.transpose = key == FL_Up ? 1 : -1; + apply(edit); + return 1; + } + } if (event == FL_KEYDOWN && Fl::event_key() == FL_Delete) { delete_selected(); return 1; @@ -280,3 +319,100 @@ int PianoRollWidget::handle(int event) return 1; return Fl_Widget::handle(event); } + +bool PianoRollWidget::visible(const xtc_note_info &n) const +{ + return n.clip_id == clip_ && (channel_filter_ < 0 || n.channel == static_cast(channel_filter_)) && + n.pitch >= low_ && n.pitch <= high_; +} + +xtc_result PianoRollWidget::set_grid(uint64_t ticks) +{ + if (ticks != 1 && ticks != 120 && ticks != 160 && ticks != 240 && ticks != 320 && ticks != 480 && ticks != 960) + return XTC_ERR_INVALID_ARGUMENT; + grid_ = ticks; + redraw(); + return XTC_OK; +} + +xtc_result PianoRollWidget::set_filter(int channel, unsigned int low, unsigned int high) +{ + if (channel < -1 || channel > 15 || low > high || high > 127) return XTC_ERR_INVALID_ARGUMENT; + channel_filter_ = channel; + low_ = low; + high_ = high; + selected_ = UINT64_MAX; + all_ = false; + mode_ = 0; + redraw(); + return XTC_OK; +} + +void PianoRollWidget::select_filtered() +{ + selected_ = UINT64_MAX; + all_ = true; + mode_ = 0; + redraw(); +} + +xtc_result PianoRollWidget::apply(const xtc_note_edit &edit) +{ + if (!all_ && selected_ == UINT64_MAX) return XTC_ERR_NOT_FOUND; + xtc_note_filter filter{clip_, all_ ? UINT64_MAX : selected_, channel_filter_, low_, high_}; + size_t count; + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_edit_notes(project_, &filter, &edit, &count); + if (r == XTC_OK && edit.action == XTC_NOTES_DELETE) { + selected_ = UINT64_MAX; + all_ = false; + } + return change(r); +} + +void PianoRollWidget::command(int action) +{ + const char *text; + xtc_note_edit edit{}; + edit.velocity = edit.channel = -1; + if (action == 0) { + text = fl_input("Snap ticks: 1, 120, 160 (1/16 triplet), 240, 320 (1/8 triplet), 480, 960", "240"); + unsigned int value; + if (text && (!parse(text, value) || set_grid(value) != XTC_OK)) + fl_alert("Choose one of the listed grids."); + return; + } + if (action == 1) { + text = fl_input("Channel (-1 all, 0..15), lowest pitch, highest pitch", "-1 0 127"); + int channel; unsigned int low, high; + if (text && (!parse(text, channel, low, high) || + set_filter(channel, low, high) != XTC_OK)) fl_alert("Invalid note filter."); + return; + } + if (action == 6) { select_filtered(); return; } + if (action == 7) { delete_selected(); return; } + if (action == 2) { + text = fl_input("Velocity (1..127, -1 preserve), channel (0..15, -1 preserve)", "100 -1"); + if (!text) return; + if (!parse(text, edit.velocity, edit.channel)) { + fl_alert("Enter velocity and channel."); return; + } + } else if (action == 3) edit.grid_tick = grid_; + else if (action == 4) { + text = fl_input("Transpose semitones (-127..127)", "12"); + if (!text) return; + if (!parse(text, edit.transpose)) { + fl_alert("Enter a semitone count."); return; + } + } else if (action == 5) { + text = fl_input("Additional copies (1..4096), spacing in source ticks (clip must have room)", "1 960"); + unsigned long long interval; + if (!text) return; + if (!parse(text, edit.copies, interval)) { + fl_alert("Enter copy count and spacing."); return; + } + edit.action = XTC_NOTES_REPEAT; + edit.interval_tick = static_cast(interval); + } else return; + apply(edit); +} diff --git a/src/gui/piano_roll.hpp b/src/gui/piano_roll.hpp index acf0bdf..b08536e 100644 --- a/src/gui/piano_roll.hpp +++ b/src/gui/piano_roll.hpp @@ -29,8 +29,8 @@ class PianoRollWidget : public Fl_Widget { * @brief Points the editor at a different project or clip. * * Clears any in-progress drag when the project or clip changes and resizes - * the widget to fit the clip's source length. The current selection is not - * changed. + * the widget to fit the clip's source length. Selection is cleared on a new + * model or clip. * * @param project Borrowed project that must outlive the widget. * @param clip ID of the MIDI clip to display. @@ -70,6 +70,15 @@ class PianoRollWidget : public Fl_Widget { * @return XTC_OK on success, the guard's error, or the project error. */ xtc_result delete_selected(); + /* UI preferences; independent of persisted project state. */ + xtc_result set_grid(uint64_t ticks); + xtc_result set_filter(int channel, unsigned int low, unsigned int high); + void select_filtered(); + xtc_result apply(const xtc_note_edit &edit); + /* Menu/keyboard commands: grid, filter, properties, quantize, transpose, + repeat, select all, delete. */ + void command(int action); + /** * @brief Handles mouse, keyboard, and focus events for the editor. * @@ -98,6 +107,12 @@ class PianoRollWidget : public Fl_Widget { void *context_; xtc_note_info original_{}; int mode_ = 0, drag_x_ = 0, drag_y_ = 0; + uint64_t grid_ = 240; + int channel_filter_ = -1; + unsigned int low_ = 0, high_ = 127; + bool all_ = false; + bool visible(const xtc_note_info &n) const; + /** * @brief Reports an edit result and notifies listeners on success. * @@ -112,7 +127,7 @@ class PianoRollWidget : public Fl_Widget { * @brief Converts a horizontal pixel position to a snapped tick. * * The position is measured in absolute window coordinates and snapped down - * to the nearest sixteenth note (240 ticks). + * to the current configured grid. * * @param px Horizontal position in window coordinates. * @return Tick offset within the clip source, or 0 for positions on or left diff --git a/src/midi/model.c b/src/midi/model.c index 523d821..1da7e52 100644 --- a/src/midi/model.c +++ b/src/midi/model.c @@ -204,3 +204,90 @@ xtc_result xtc_project_remove_note(xtc_project *p, uint64_t id) --p->note_count; return XTC_OK; } + +/* Selection is evaluated against original properties, never transformed ones. */ +static int matches(const xtc_note_info *n, const xtc_note_filter *f) +{ + return n->clip_id == f->clip_id && (f->note_id == UINT64_MAX || n->id == f->note_id) && + (f->channel == -1 || n->channel == (unsigned int)f->channel) && + n->pitch >= f->pitch_min && n->pitch <= f->pitch_max; +} + +static xtc_result transformed(const xtc_project *p, xtc_note_info *n, const xtc_note_edit *e, + unsigned int copy) +{ + int pitch = (int)n->pitch + e->transpose; + if (pitch < 0 || pitch > 127) return XTC_ERR_INVALID_ARGUMENT; + n->pitch = (unsigned int)pitch; + if (e->velocity != -1) n->velocity = (unsigned int)e->velocity; + if (e->channel != -1) n->channel = (unsigned int)e->channel; + if (e->grid_tick) { + uint64_t rem = n->start_tick % e->grid_tick; + n->start_tick -= rem; + if (rem >= e->grid_tick - rem) { + if (n->start_tick > UINT64_MAX - e->grid_tick) return XTC_ERR_INVALID_ARGUMENT; + n->start_tick += e->grid_tick; + } + } + if (copy) { + if (e->interval_tick > (UINT64_MAX - n->start_tick) / copy) return XTC_ERR_INVALID_ARGUMENT; + n->start_tick += e->interval_tick * copy; + } + return valid_note(p, n->clip_id, n->start_tick, n->length_tick, n->pitch, n->velocity, n->channel); +} + +xtc_result xtc_project_edit_notes(xtc_project *p, const xtc_note_filter *f, + const xtc_note_edit *e, size_t *out) +{ + size_t i, count = 0, original, write = 0; + unsigned int k; + xtc_clip_info clip; + xtc_result r; + if (!p || !f || !e || !out || f->channel < -1 || f->channel > 15 || + f->pitch_min > f->pitch_max || f->pitch_max > 127 || + e->action < XTC_NOTES_EDIT || e->action > XTC_NOTES_DELETE) + return XTC_ERR_INVALID_ARGUMENT; + r = xtc_project_find_clip(p, f->clip_id, &clip); + if (r != XTC_OK) return r; + if (!clip.midi) return XTC_ERR_INVALID_STATE; + if (e->action != XTC_NOTES_DELETE && (e->transpose < -127 || e->transpose > 127 || + e->velocity < -1 || e->velocity == 0 || e->velocity > 127 || e->channel < -1 || e->channel > 15 || + (e->action == XTC_NOTES_REPEAT && (!e->copies || e->copies > XTC_PROJECT_MAX_NOTES || !e->interval_tick)))) + return XTC_ERR_INVALID_ARGUMENT; + original = p->note_count; + for (i = 0; i < original; ++i) if (matches(p->notes+i, f)) ++count; + if (!count) return XTC_ERR_NOT_FOUND; + if (e->action == XTC_NOTES_REPEAT && + (count > (XTC_PROJECT_MAX_NOTES-original) / e->copies || + (uint64_t)count > (UINT64_MAX-p->next_note_id) / e->copies)) return XTC_ERR_CAPACITY; + /* Validate every result before changing anything. Bounded by note capacity. */ + if (e->action != XTC_NOTES_DELETE) + for (i = 0; i < original; ++i) if (matches(p->notes+i, f)) { + unsigned int copies = e->action == XTC_NOTES_REPEAT ? e->copies : 0; + for (k = copies ? 1U : 0U; k <= copies; ++k) { + xtc_note_info n = p->notes[i]; + r = transformed(p, &n, e, k); + if (r != XTC_OK) return r; + } + } + for (i = 0; i < original; ++i) { + xtc_note_info n = p->notes[i]; + if (!matches(&n, f)) { + if (e->action == XTC_NOTES_DELETE) p->notes[write++] = n; + continue; + } + if (e->action == XTC_NOTES_DELETE) continue; + if (e->action == XTC_NOTES_EDIT) { + (void)transformed(p, &n, e, 0); + p->notes[i] = n; + } else for (k = 1; k <= e->copies; ++k) { + n = p->notes[i]; + (void)transformed(p, &n, e, k); + n.id = p->next_note_id++; + p->notes[p->note_count++] = n; + } + } + if (e->action == XTC_NOTES_DELETE) p->note_count = write; + *out = count; + return XTC_OK; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 9776e71..603453c 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -351,6 +351,51 @@ int main(int argc, char **argv) CHECK(xtc_project_note_count(sequence->project(), ¬e_count) == XTC_OK && note_count == 1); CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK && drawn.start_tick == 720 && drawn.length_tick == 1440 && drawn.pitch == 61); + // Piano-roll filtered commands use one undo step and preserve durations. + uint64_t edited_clip = roll->clip(); + CHECK(roll->set_grid(160) == XTC_OK && roll->set_grid(320) == XTC_OK); + CHECK(roll->set_grid(7) == XTC_ERR_INVALID_ARGUMENT); + CHECK(roll->set_filter(0, 60, 72) == XTC_OK); + int note_key = Fl::e_keysym, note_state = Fl::e_state; + Fl::e_keysym = 'a'; Fl::e_state = FL_CTRL; + CHECK(roll->handle(FL_KEYDOWN) == 1); + Fl::e_keysym = note_key; Fl::e_state = note_state; + xtc_note_edit properties{XTC_NOTES_EDIT, 0, 0, 77, 2, 0, 0}; + CHECK(roll->apply(properties) == XTC_OK); + CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK && drawn.velocity == 77 && drawn.channel == 2); + CHECK(roll->set_filter(-1, 0, 127) == XTC_OK); + roll->select_filtered(); + Fl::e_keysym = 'q'; Fl::e_state = 0; + CHECK(roll->handle(FL_KEYDOWN) == 1); + Fl::e_keysym = note_key; Fl::e_state = note_state; + CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK && drawn.start_tick == 640 && drawn.length_tick == 1440); + Fl::e_keysym = FL_Up; Fl::e_state = 0; + CHECK(roll->handle(FL_KEYDOWN) == 1); + Fl::e_keysym = note_key; Fl::e_state = note_state; + CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK && drawn.pitch == 62); + xtc_note_edit repeat{XTC_NOTES_REPEAT, 0, 0, -1, -1, 1, 960}; + CHECK(roll->apply(repeat) == XTC_OK); + CHECK(xtc_project_note_count(sequence->project(), ¬e_count) == XTC_OK && note_count == 2); + CHECK(roll->delete_selected() == XTC_OK); + CHECK(xtc_project_note_count(sequence->project(), ¬e_count) == XTC_OK && note_count == 0); + CHECK(window.undo() == XTC_OK); + CHECK(xtc_project_note_count(sequence->project(), ¬e_count) == XTC_OK && note_count == 2); + CHECK(window.undo(true) == XTC_OK); + CHECK(xtc_project_note_count(sequence->project(), ¬e_count) == XTC_OK && note_count == 0); + for (int step = 0; step < 5; ++step) CHECK(window.undo() == XTC_OK); + CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK && drawn.pitch == 61 && drawn.start_tick == 720 && drawn.velocity == 100 && drawn.channel == 0); + CHECK(xtc_project_note_count(sequence->project(), ¬e_count) == XTC_OK && note_count == 1); + roll->set_project(sequence->project(), edited_clip); + CHECK(roll->set_grid(160) == XTC_OK && roll->set_filter(5, 60, 60) == XTC_OK); + Fl::e_x = roll->x() + 104; Fl::e_y = roll->y() + 1110; + Fl::e_keysym = FL_Button + FL_LEFT_MOUSE; + CHECK(roll->handle(FL_PUSH) == 1); (void)roll->handle(FL_RELEASE); + Fl::e_x = saved_x; Fl::e_y = saved_y; Fl::e_keysym = saved_key; + CHECK(xtc_project_get_note(sequence->project(), 1, &drawn) == XTC_OK && drawn.start_tick == 320 && drawn.channel == 5 && drawn.pitch == 60); + CHECK(window.undo() == XTC_OK); + CHECK(xtc_project_note_count(sequence->project(), ¬e_count) == XTC_OK && note_count == 1); + CHECK(roll->set_grid(240) == XTC_OK && roll->set_filter(-1, 0, 127) == XTC_OK); + roll->set_project(sequence->project(), edited_clip); CHECK(window.save_project(filename.c_str()) == XTC_OK); CHECK(window.open_project(filename.c_str()) == XTC_OK); CHECK(xtc_project_get_note(sequence->project(), 0, &drawn) == XTC_OK && drawn.pitch == 61 && drawn.start_tick == 720); diff --git a/tests/unit/test_composition.c b/tests/unit/test_composition.c new file mode 100644 index 0000000..abc57eb --- /dev/null +++ b/tests/unit/test_composition.c @@ -0,0 +1,137 @@ +#include "xtc/xtc_midi.h" +#include "xtc/xtc_mixer.h" +#include +#include +#include + +static void editing(void) +{ + xtc_project *p = NULL, *snapshot = NULL; + xtc_history *history = NULL; + uint64_t track, clip, second, id, next; + xtc_note_info n, original, untouched, foreign; + xtc_note_filter f; + xtc_note_edit e = {XTC_NOTES_EDIT, 160, 12, -1, -1, 0, 0}; + size_t count = 999, total; + assert(xtc_project_create(&p) == XTC_OK); + assert(xtc_project_add_track(p, "Keys", &track) == XTC_OK); + assert(xtc_project_add_midi_clip(p, track, 0, 3840, &clip) == XTC_OK); + assert(xtc_project_add_midi_clip(p, track, 3840, 3840, &second) == XTC_OK); + assert(xtc_project_add_note(p, clip, 80, 100, 60, 73, 2, &id) == XTC_OK); + assert(xtc_project_get_note(p, 0, &original) == XTC_OK); + assert(xtc_project_add_note(p, clip, 90, 101, 61, 91, 3, &next) == XTC_OK); + assert(xtc_project_get_note(p, 1, &untouched) == XTC_OK); + assert(xtc_project_add_note(p, second, 80, 100, 60, 73, 2, &next) == XTC_OK); + assert(xtc_project_get_note(p, 2, &foreign) == XTC_OK); + f = (xtc_note_filter){clip, UINT64_MAX, 2, 60, 60}; + assert(xtc_history_create(p, &history) == XTC_OK); + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_OK && count == 1); + assert(xtc_project_get_note(p, 0, &n) == XTC_OK); + assert(n.id == id && n.start_tick == 160 && n.length_tick == 100 && n.pitch == 72 && n.velocity == 73 && n.channel == 2); + assert(xtc_project_get_note(p, 1, &n) == XTC_OK && !memcmp(&n, &untouched, sizeof(n))); + assert(xtc_project_get_note(p, 2, &n) == XTC_OK && !memcmp(&n, &foreign, sizeof(n))); + assert(xtc_history_record(history, p) == XTC_OK); + assert(xtc_history_checkout(history, 0, &snapshot) == XTC_OK); + assert(xtc_project_get_note(snapshot, 0, &n) == XTC_OK && !memcmp(&n, &original, sizeof(n))); + xtc_project_destroy(snapshot); + assert(xtc_history_accept(history, 0) == XTC_OK); + assert(xtc_history_checkout(history, 1, &snapshot) == XTC_OK); + assert(xtc_project_get_note(snapshot, 0, &n) == XTC_OK && n.start_tick == 160 && n.pitch == 72); + xtc_project_destroy(snapshot); + f.pitch_min = 0; f.pitch_max = 127; + e = (xtc_note_edit){XTC_NOTES_REPEAT, 0, -12, 110, 4, 2, 320}; + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_OK && count == 1); + assert(xtc_project_get_note(p, 3, &n) == XTC_OK && n.id > foreign.id && n.start_tick == 480 && n.pitch == 60 && n.velocity == 110 && n.channel == 4); + next = n.id; + assert(xtc_project_get_note(p, 4, &n) == XTC_OK && n.id > next && n.start_tick == 800 && n.length_tick == 100); + next = n.id; + /* Reject entire batch if a later match overflows pitch/range/capacity. */ + f.channel = -1; + e = (xtc_note_edit){XTC_NOTES_EDIT, 0, 67, -1, -1, 0, 0}; + count = 999; + assert(xtc_project_get_note(p, 0, &original) == XTC_OK); + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_ERR_INVALID_ARGUMENT && count == 999); + assert(xtc_project_get_note(p, 0, &n) == XTC_OK && !memcmp(&n, &original, sizeof(n))); + e = (xtc_note_edit){XTC_NOTES_REPEAT, 0, 0, -1, -1, 2, UINT64_MAX}; + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_ERR_INVALID_ARGUMENT && count == 999); + e.copies = XTC_PROJECT_MAX_NOTES; + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_ERR_CAPACITY && count == 999); + e = (xtc_note_edit){XTC_NOTES_EDIT, UINT64_MAX, 0, 0, -1, 0, 0}; + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_ERR_INVALID_ARGUMENT); + e.velocity = -1; e.grid_tick = 3840; + f.note_id = id; + /* Nearest grid preserves duration, including rounding down near zero. */ + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_OK); + assert(xtc_project_get_note(p, 0, &n) == XTC_OK && n.start_tick == 0 && n.length_tick == 100); + assert(xtc_project_add_note(p, clip, 3800, 40, 50, 50, 0, &id) == XTC_OK && id == next + 1); + f.note_id = id; + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_ERR_INVALID_ARGUMENT); + f.note_id = UINT64_MAX; f.channel = 4; + e.action = XTC_NOTES_DELETE; + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_OK && count == 2); + assert(xtc_project_note_count(p, &total) == XTC_OK && total == 4); + assert(xtc_project_get_note(p, 1, &n) == XTC_OK && !memcmp(&n, &untouched, sizeof(n))); + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_ERR_NOT_FOUND); + f.channel = 16; + assert(xtc_project_edit_notes(p, &f, &e, &count) == XTC_ERR_INVALID_ARGUMENT); + xtc_history_destroy(history); + xtc_project_destroy(p); +} + +static void scheduling(void) +{ + xtc_project *p = NULL; + xtc_midi_plan *a = NULL, *b = NULL; + uint64_t track, clip, id; + float whole[512], split[512], reference[512]; + xtc_midi_event events[] = {{0, XTC_MIDI_NOTE_ON, 0, 69, 127}, + {17, XTC_MIDI_NOTE_OFF, 0, 69, 0}, + {17, XTC_MIDI_NOTE_ON, 0, 69, 127}}; + assert(xtc_project_create(&p) == XTC_OK); + assert(xtc_midi_plan_create(p, 48000, &a) == XTC_OK); + assert(xtc_midi_plan_create(p, 48000, &b) == XTC_OK); + assert(xtc_midi_plan_render(a, 0, events, 3, whole, 64) == XTC_OK); + assert(xtc_midi_plan_render(b, 0, events, 1, split, 17) == XTC_OK); + events[1].offset = events[2].offset = 0; + assert(xtc_midi_plan_render(b, 17, events+1, 2, split+17, 47) == XTC_OK); + assert(!memcmp(whole, split, 64*sizeof(float)) && whole[17] == 0 && whole[18] > 0); + /* Equal offsets preserve caller order: on then off must end silent. */ + events[0].type = XTC_MIDI_NOTE_ON; events[1].type = XTC_MIDI_NOTE_OFF; + assert(xtc_midi_plan_render(a, 100, events, 2, whole, 64) == XTC_OK); + for (size_t i = 0; i < 64; ++i) assert(whole[i] == 0); + events[0].channel = 16; + whole[0] = 123; + assert(xtc_midi_plan_render(a, 164, events, 1, whole, 64) == XTC_ERR_INVALID_ARGUMENT && whole[0] == 123); + xtc_midi_plan_destroy(a); xtc_midi_plan_destroy(b); + assert(xtc_project_add_track(p, "Keys", &track) == XTC_OK); + assert(xtc_project_add_midi_clip(p, track, 0, 960, &clip) == XTC_OK); + /* Adjacent repeated pitch and an overlapping same-pitch note remain independent. */ + assert(xtc_project_add_note(p, clip, 0, 8, 69, 127, 0, &id) == XTC_OK); + assert(xtc_project_add_note(p, clip, 8, 8, 69, 127, 0, &id) == XTC_OK); + assert(xtc_project_add_note(p, clip, 4, 8, 69, 64, 0, &id) == XTC_OK); + assert(xtc_midi_plan_create(p, 48000, &a) == XTC_OK); + assert(xtc_midi_plan_create(p, 48000, &b) == XTC_OK); + assert(xtc_midi_plan_render(a, 0, NULL, 0, whole, 512) == XTC_OK); + assert(xtc_midi_plan_render(b, 0, NULL, 0, split, 200) == XTC_OK); + assert(xtc_midi_plan_render(b, 200, NULL, 0, split+200, 200) == XTC_OK); + assert(xtc_midi_plan_render(b, 400, NULL, 0, split+400, 112) == XTC_OK); + assert(!memcmp(whole, split, sizeof(whole))); + assert(xtc_midi_plan_render(b, 199, NULL, 0, reference, 201) == XTC_OK); + assert(!memcmp(whole+199, reference, 201*sizeof(float))); /* seek chase */ + xtc_midi_plan_destroy(a); xtc_midi_plan_destroy(b); + /* 32 ends before 32 starts at the same timestamp fits voice bound. */ + assert(xtc_project_remove_clip(p, clip) == XTC_OK); + assert(xtc_project_add_midi_clip(p, track, 0, 960, &clip) == XTC_OK); + for (unsigned int i = 0; i < 32; ++i) { + assert(xtc_project_add_note(p, clip, 0, 8, 60, 100, 0, &id) == XTC_OK); + assert(xtc_project_add_note(p, clip, 8, 8, 60, 100, 0, &id) == XTC_OK); + } + assert(xtc_midi_plan_create(p, 48000, &a) == XTC_OK); + xtc_midi_plan_destroy(a); xtc_project_destroy(p); +} +int main(void) +{ + editing(); scheduling(); + puts("Filtered transactional MIDI edits, triplet quantize, repeats, undo and deterministic ordering passed."); + return 0; +}