1
0
Fork 0
forked from hanez/xtc

Phase 21: add filtered MIDI composition edits and triplet piano-roll grids

This commit is contained in:
Johannes Findeisen 2026-10-27 15:34:54 +01:00
commit 73fc5eea8d
12 changed files with 796 additions and 39 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.
*

View file

@ -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(

View file

@ -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);
}

View file

@ -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

View file

@ -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;
}

View file

@ -351,6 +351,51 @@ int main(int argc, char **argv)
CHECK(xtc_project_note_count(sequence->project(), &note_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(), &note_count) == XTC_OK && note_count == 2);
CHECK(roll->delete_selected() == XTC_OK);
CHECK(xtc_project_note_count(sequence->project(), &note_count) == XTC_OK && note_count == 0);
CHECK(window.undo() == XTC_OK);
CHECK(xtc_project_note_count(sequence->project(), &note_count) == XTC_OK && note_count == 2);
CHECK(window.undo(true) == XTC_OK);
CHECK(xtc_project_note_count(sequence->project(), &note_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(), &note_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(), &note_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);

View 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;
}