Phase 21: add filtered MIDI composition edits and triplet piano-roll grids
This commit is contained in:
parent
830b6e1914
commit
73fc5eea8d
12 changed files with 796 additions and 39 deletions
8
Makefile
8
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
|
||||
|
|
|
|||
69
docs/MIDI.md
69
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.
|
||||
|
|
|
|||
233
docs/ROADMAP.md
233
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1238,11 +1238,16 @@ xtc_result MainWindow::open_piano_roll()
|
|||
if (!piano_window_) {
|
||||
piano_window_ = std::make_unique<Fl_Double_Window>(900, 620, "xtc — Piano roll");
|
||||
piano_window_->begin();
|
||||
auto *hint = new Fl_Box(12, 8, 876, 32,
|
||||
"Source ticks: click draws; drag body moves/pitches, right edge resizes; "
|
||||
"right-click/Delete removes. Snap 1/16.");
|
||||
hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
||||
hint->labelsize(12);
|
||||
auto *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<MainWindow *>(data);
|
||||
auto *bar = static_cast<Fl_Menu_Bar *>(widget);
|
||||
owner->piano_->command(static_cast<int>(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(
|
||||
|
|
|
|||
|
|
@ -4,9 +4,16 @@
|
|||
#include <FL/fl_ask.H>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <sstream>
|
||||
|
||||
namespace {
|
||||
constexpr int key_width = 64, row_height = 16, scale = 96, header = 28;
|
||||
template <typename... Values> 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<uint64_t>(std::max(0, px - x() - key_width)) * 960U / scale / 240U * 240U;
|
||||
return static_cast<uint64_t>(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<uint64_t>(120, grid_);
|
||||
int spacing = static_cast<int>(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<int>(static_cast<uint64_t>(step) * visual * scale / 960);
|
||||
fl_color((static_cast<uint64_t>(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<int>(n.start_tick * scale / 960);
|
||||
int right = left + std::max(3, static_cast<int>(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<uchar>(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<unsigned long long>(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<int>(n.start_tick * scale / 960);
|
||||
int right = left + std::max(3, static_cast<int>(n.length_tick * scale / 960));
|
||||
int top = y() + header + (127 - static_cast<int>(n.pitch)) * row_height;
|
||||
if (n.clip_id == clip_ && Fl::event_x() >= left && Fl::event_x() < right && Fl::event_y() >= top &&
|
||||
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<int>(low_) && pitch <= static_cast<int>(high_) && xtc_project_find_clip(project_, clip_, &c) == XTC_OK &&
|
||||
start < c.source_length_tick)
|
||||
add_note(start, std::min<uint64_t>(960, c.source_length_tick - start),
|
||||
static_cast<unsigned int>(pitch));
|
||||
static_cast<unsigned int>(pitch), 100, channel_filter_ < 0 ? 0U : static_cast<unsigned int>(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<int64_t>(Fl::event_x() - drag_x_) * 960 / scale / 240 * 240;
|
||||
int64_t delta = static_cast<int64_t>(Fl::event_x() - drag_x_) * 960 / scale / static_cast<int64_t>(grid_) * static_cast<int64_t>(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<int>(pitch) - (Fl::event_y() - drag_y_) / row_height, 0, 127));
|
||||
} else {
|
||||
int64_t maximum = static_cast<int64_t>(c.source_length_tick) - start;
|
||||
length = std::clamp<int64_t>(length + delta, std::min<int64_t>(240, maximum), maximum);
|
||||
length = std::clamp<int64_t>(length + delta, std::min<int64_t>(static_cast<int64_t>(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<unsigned int>(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<uint64_t>(interval);
|
||||
} else return;
|
||||
apply(edit);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
137
tests/unit/test_composition.c
Normal file
137
tests/unit/test_composition.c
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#include "xtc/xtc_midi.h"
|
||||
#include "xtc/xtc_mixer.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue