Phase 34: add snap grids, MIDI velocity editing and track instrument menus

This commit is contained in:
Johannes Findeisen 2026-10-28 10:08:13 +01:00
commit 4873b70672
14 changed files with 481 additions and 29 deletions

View file

@ -167,3 +167,15 @@ Do not unload/replace modules during playback; candidate plans are prepared off
the callback and old instances are reclaimed on the control thread. Missing
references remain inspectable without loading; failed GUI preparation retains
the current project. Save a backup for applications limited to v11 or earlier.
## Phase 34 note dynamics and clip editing
Double-click a MIDI clip to open its piano roll. The Note velocity slider and
fixed Velocity lane edit individual notes from 1 to 127, preserving timing,
pitch and channel. Chord notes at the same start can be selected individually in
the keys before editing their highlighted bar. Completed gestures are undoable
and save in the existing note velocity field. Velocity is not a separate dB
volume stage: the built-in sine scales amplitude, while plugins choose their
own patch-dependent response. The track header instrument dropdown reuses saved
project instruments or opens explicit loading; bus tracks cannot host instruments.
See WORKSPACE.md for grid/snap divisions, filters and control behavior.

View file

@ -68,3 +68,9 @@ previews. New MIDI/audio track commands are undoable and prepare candidates
before adoption. The default window is 1280 × 800, minimum 1220 × 720.
No dependency or project-format change. See WORKSPACE.md and the new
`modern-session.xtc` example; existing engine/plugin limitations still apply.
Phase 34 adds a sequencer grid/Snap dropdown, absolute grid snapping for moves
and trims, MIDI double-click editing, a piano velocity lane/slider and per-track
instrument dropdowns. Snap preferences are UI state; velocity and instrument
bindings use the existing v12 format. No new dependency or engine feature is
required. See WORKSPACE.md for supported divisions, controller semantics and limits.

View file

@ -960,6 +960,35 @@ See WORKSPACE.md for the workflow, tests and limits.
---
## Phase 34 — Grid, Clip Editing and Track Instrument Controls
### Goal
Expose common DAW editing actions directly in the flat workspace: selectable
snap, MIDI clip double-click, individual note velocity and track instruments.
### Tasks and acceptance criteria
- [x] Draw bar/beat/subdivision grid lines and expose a sequencer Snap dropdown.
- [x] Offer Off, straight, triplet, half-note and bar divisions; synchronize the existing snap menu.
- [x] Snap clip moves and both trim edges to absolute grid positions, including off-grid clips.
- [x] Open the selected MIDI clip's piano roll on left-button double-click without moving it.
- [x] Add a fixed velocity lane and selected-note slider with 1..127 validation and one undo step per edit.
- [x] Keep coincident chord notes individually editable and preserve timing/pitch/channel.
- [x] Add a dropdown to every non-bus track for built-in sine, project instruments and explicit plugin loading.
- [x] Preserve track identity through reorder, undo, clear and project reload; retain candidate failure rollback.
- [x] Keep controls flat, retain current project format and verify builds, GUI regressions and releases.
### Implementation status
Implemented in the C++ GUI over existing C model APIs. Snap preferences are
session UI state; note velocity and track instruments already persist in v12.
The project instrument list is built from existing assignments, with Load plugin
for other trusted binaries; automatic disk scanning is not introduced.
At low zoom the visual grid coarsens for readability while snap remains exact to
the selected tick division. Off uses pointer/model tick precision. MIDI velocity
is a performance value, not a per-note dB gain stage; third-party instrument
patches control its audible response. See WORKSPACE.md and MIDI.md.
---
## Recommended Execution Order
Reorder the phases when the audit reveals a blocking dependency. Do not mark a phase complete merely because code exists: its acceptance criteria must be demonstrated by builds, tests, or documented manual verification.

View file

@ -206,3 +206,8 @@ The flat workspace puts New MIDI/audio track, WAV import and instrument loading
in a persistent sidebar, with a musical transport display and selected-track
overview. See [WORKSPACE.md](WORKSPACE.md) for shortcuts and workflow. Open
`modern-session.xtc` in the installed examples for an original four-track arrangement.
Phase 34: use the sequencer Snap dropdown for straight/triplet/bar grids or Off.
Double-click MIDI clips to edit notes, then use the Velocity lane or selected-note
slider for dynamics. Each non-bus track header has an instrument dropdown offering
built-in sine, project instruments and explicit plugin loading. See WORKSPACE.md.

View file

@ -55,3 +55,47 @@ and workflow tests cover the retained commands. A modern presentation does not
add commercial-DAW features: detachable/resizable docks, mixer channel strips,
automatic plugin scanning, custom plugin editors and new audio/MIDI features
remain outside this phase. Existing plugin and engine limits still apply.
## Phase 34 editing controls
The **Snap** dropdown above the sequencer selects Off, 1/32, 1/16 triplet,
1/16, 1/8 triplet, 1/8, 1/4, 1/2 or one bar. Existing View/Snap commands update
the same preference. Bar lines are strongest, beats medium, subdivisions subtle.
At low zoom closely spaced lines are coarsened to at least six pixels; snapping
still uses the selected division. Clip moves and trim boundaries snap to the
nearest absolute grid position (halfway rounds forward), including clips that
start off the grid. Off removes musical quantization; pointer resolution and
integer model ticks still apply. Invalid trims retain the original clip.
Snap is a UI preference, not a project edit or a saved project setting.
Double-click a MIDI clip with the left mouse button to open its piano roll.
The clip is selected and the click does not move/trim it. Audio/silent clips
retain their existing selection/drag behavior. The piano roll also has a Snap
dropdown for its existing note grid; sequencer and piano snap remain independent.
The piano roll has a fixed **Velocity** lane beneath the keys and a **Note
velocity** slider. Select a note, then drag its bar vertically or release the
slider at a value from 1 to 127. Only that note changes; pitch, timing, length,
channel and other notes stay intact. One completed drag/slider edit is one undo
step; Escape cancels a lane drag. For chord notes sharing a start time, select
the desired note in the keys first; its highlighted velocity bar and slider
remain individually editable. Horizontal scrolling aligns the lane with note
starts; vertical scrolling moves the keys while the lane stays available.
Filters apply to lane visibility and selection. Values save with existing note
metadata and affect built-in synthesis; plugin patches determine velocity response.
This is MIDI velocity, not a per-note audio gain or loudness meter.
Every ordinary track header has an instrument dropdown. Choose **Built-in sine**,
an instrument already assigned elsewhere in the project, or **Load plugin...**
for a trusted CLAP/VST2/VST3 binary. Entries show the format and plugin label.
Each assignment creates an independent track instance, selects that track for
live MIDI and remains undoable; clear/reorder/reopen use stable track IDs. Bus
tracks show their bus label instead. Cancel or failed preparation restores the
model's selection in the dropdown. The list is project-local, not a scanner or
installed-plugin database. Existing mono/default-configuration host limits apply.
Instrument changes stop/reprepare playback and are refused while recording.
Verification includes off-grid move/trim, snap Off, dropdown labels, double-click,
coincident notes, per-note slider/lane edits, invalid velocities, undo/reload and
instrument reuse/clear/reorder for all three fixture formats. Manual checks use
the original modern-session example and the flat sequencer/piano windows.

View file

@ -5,6 +5,10 @@
#include "mixer_widget.hpp"
#include "plugin_dialog.hpp"
#include "theme.hpp"
#include "snap.hpp"
#include "velocity_lane.hpp"
#include <FL/Fl_Choice.H>
#include <FL/Fl_Value_Slider.H>
#include <FL/Fl_Native_File_Chooser.H>
#include "xtc/xtc_version.h"
@ -169,13 +173,32 @@ MainWindow::MainWindow(bool prompt_unsaved)
sequencer_ = new SequencerWidget(228, 206, project_.get(), seek, this);
timeline_scroll->end();
sequencer_->before_edit = before_arrangement_edit;
sequencer_->open_clip = [](void *c) { piano_callback(nullptr,c); };
sequencer_->instrument_select = [](void *c, uint64_t track, const xtc_plugin_spec *spec, bool browse) {
auto *w = static_cast<MainWindow *>(c);
w->sequencer_->select(track);
if (browse) { instrument_callback(nullptr,c); return; }
xtc_result r = w->assign_instrument(track,spec ? spec->path : nullptr,spec ? spec->index : 0);
if (r != XTC_OK) w->report_error("Track instrument unchanged",r);
};
// Keep this in the editor group rather than the scrollable timeline.
auto *parent = Fl_Group::current(); Fl_Group::current(sequencer);
snap_ = new Fl_Choice(1040,168,210,26,"Snap");
for (size_t i = 0; i < xtc_ui::snap_count; ++i) { int item = snap_->add("Snap"); snap_->replace(item,xtc_ui::snap_labels[i]); }
snap_->value(xtc_ui::snap_index(sequencer_->grid()));
snap_->callback([](Fl_Widget *widget, void *c) {
auto *choice = static_cast<Fl_Choice *>(widget); int value = choice->value();
if (value >= 0 && static_cast<size_t>(value) < xtc_ui::snap_count)
(void)static_cast<MainWindow *>(c)->sequencer_->grid(xtc_ui::snap_ticks[value]);
},this);
Fl_Group::current(parent);
menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->duplicate_selection(); },sequencer_);
menu->add("Edit/Split audio at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->split_selection(); }, sequencer_);
menu->add("Edit/Audio clip gain and fades", 0, [](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->audio_properties_dialog(); }, sequencer_);
menu->add("Edit/Rename clip or track",FL_F+2,[](Fl_Widget *, void *c) {
const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) (void)static_cast<SequencerWidget *>(c)->rename_selection(name);
},sequencer_);
for (const char *label : {"View/Snap/1 tick","View/Snap/120 ticks","View/Snap/240 ticks","View/Snap/480 ticks","View/Snap/960 ticks"})
for (const char *label : {"View/Snap/1 tick","View/Snap/120 ticks","View/Snap/160 ticks","View/Snap/240 ticks","View/Snap/320 ticks","View/Snap/480 ticks","View/Snap/960 ticks","View/Snap/1920 ticks","View/Snap/3840 ticks"})
menu->add(label,0,[](Fl_Widget *widget,void *c) {
auto *m = static_cast<Fl_Menu_Bar *>(widget);
(void)static_cast<SequencerWidget *>(c)->grid(std::strtoull(m->mvalue()->label(),nullptr,10));
@ -1354,20 +1377,38 @@ xtc_result MainWindow::open_piano_roll()
menu->add("Edit/Undo",FL_CTRL+'z',history_callback,this);
menu->add("Edit/Redo",FL_CTRL+FL_SHIFT+'z',history_callback,this);
menu->add("Edit/Redo alternate",FL_CTRL+'y',history_callback,this);
auto *scroll = new Fl_Scroll(12, 48, 876, 560);
piano_ = new PianoRollWidget(12, 48, project_.get(), clip.id, before_arrangement_edit, this);
piano_snap_ = new Fl_Choice(60,46,150,26,"Snap");
for (size_t i = 0; i < xtc_ui::snap_count; ++i) { int item = piano_snap_->add("Snap"); piano_snap_->replace(item,xtc_ui::snap_labels[i]); }
piano_snap_->callback([](Fl_Widget *widget, void *c) {
auto *w = static_cast<MainWindow *>(c); int value = static_cast<Fl_Choice *>(widget)->value();
if (value >= 0 && static_cast<size_t>(value) < xtc_ui::snap_count) (void)w->piano_->set_grid(xtc_ui::snap_ticks[value]);
},this);
note_velocity_ = new Fl_Value_Slider(340,46,300,26,"Note velocity");
note_velocity_->align(FL_ALIGN_LEFT); note_velocity_->value(100);
note_velocity_->tooltip("Selected note MIDI velocity, 1..127. Instrument patches determine the response.");
note_velocity_->type(FL_HOR_FILL_SLIDER); note_velocity_->bounds(1,127); note_velocity_->step(1);
note_velocity_->when(FL_WHEN_RELEASE);
note_velocity_->callback([](Fl_Widget *widget, void *c) {
auto *w = static_cast<MainWindow *>(c);
(void)w->piano_->set_velocity(w->piano_->selected_note(),static_cast<unsigned int>(static_cast<Fl_Value_Slider *>(widget)->value()));
},this);
auto *scroll = new Fl_Scroll(12,84,876,404);
piano_ = new PianoRollWidget(12,84, project_.get(), clip.id, before_arrangement_edit, this);
piano_->callback(
[](Fl_Widget *, void *c) {
static_cast<MainWindow *>(c)->mark_dirty();
},
this);
scroll->end();
velocity_lane_ = new VelocityLane(12,500,876,108,*piano_);
piano_window_->end();
piano_window_->size_range(800,520);
xtc_ui::style(piano_window_.get());
piano_window_->resizable(scroll);
scroll->scroll_to(0, 60 * 16);
} else
piano_->set_project(project_.get(), clip.id);
refresh_workspace();
piano_window_->show();
return XTC_OK;
}
@ -1496,6 +1537,19 @@ xtc_result MainWindow::create_track(bool midi)
}
void MainWindow::refresh_workspace()
{
if (snap_) snap_->value(xtc_ui::snap_index(sequencer_->grid()));
if (piano_) {
piano_snap_->value(xtc_ui::snap_index(piano_->grid()));
size_t count = 0; bool selected = false; xtc_project_note_count(project_.get(),&count);
for (size_t i = 0; i < count; ++i) {
xtc_note_info note{}; xtc_project_get_note(project_.get(),i,&note);
if (note.id == piano_->selected_note() && piano_->note_visible(note)) {
selected = true; if (Fl::pushed() != note_velocity_) note_velocity_->value(note.velocity); break;
}
}
if (selected) note_velocity_->activate(); else note_velocity_->deactivate();
velocity_lane_->redraw();
}
char position[64], tempo[40], info[512];
uint64_t tick = sequencer_->position();
std::snprintf(position,sizeof(position),"%03llu : %u : %03u",
@ -1521,6 +1575,7 @@ void MainWindow::refresh_workspace()
xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsigned int index)
{
uint64_t clip = sequencer_->selected_track() == track ? sequencer_->selected_clip() : UINT64_MAX;
if (recorder_) return XTC_ERR_INVALID_STATE;
xtc_project *candidate = nullptr;
xtc_plugin_spec spec{};
@ -1530,7 +1585,7 @@ xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsig
if (r == XTC_OK) r = xtc_project_set_instrument(candidate,track,path ? &spec : nullptr);
if (r == XTC_OK) r = xtc_history_reserve(history_.get());
if (r == XTC_OK) r = install_project(candidate,false);
if (r != XTC_OK) xtc_project_destroy(candidate); else mark_dirty();
if (r != XTC_OK) xtc_project_destroy(candidate); else { sequencer_->select(track,clip); mixer_->select_track(track); mark_dirty(); }
return r;
}
xtc_result MainWindow::select_live_instrument(uint64_t track)
@ -1547,6 +1602,7 @@ xtc_result MainWindow::select_live_instrument(uint64_t track)
void MainWindow::instrument_callback(Fl_Widget *widget, void *context)
{
auto *w = static_cast<MainWindow *>(context);
if (w->recorder_) { w->report_error("Stop recording before instrument changes",XTC_ERR_INVALID_STATE); return; }
auto *menu = dynamic_cast<Fl_Menu_Bar *>(widget);
const char *label = menu ? menu->mvalue()->label() : "Load instrument plugin";
uint64_t track = w->sequencer_->selected_track();

View file

@ -18,6 +18,8 @@ class Fl_Box;
class Fl_Button;
class Fl_Tabs;
class Fl_Menu_Bar;
class Fl_Choice;
class Fl_Value_Slider;
class MainWindow : public Fl_Double_Window {
public:
@ -25,6 +27,7 @@ class MainWindow : public Fl_Double_Window {
* Preparation stops/resets transport; recording and failed candidates retain
* the current document. No engine/model changes happen in audio callbacks. */
xtc_result create_track(bool midi);
Fl_Widget *velocity_lane() const { return velocity_lane_; }
/* Control-thread plugin commands, transactional model adoption. */
xtc_result assign_instrument(uint64_t track, const char *path, unsigned int index);
xtc_result select_live_instrument(uint64_t track);
@ -177,6 +180,9 @@ class MainWindow : public Fl_Double_Window {
private:
void draw() override;
void refresh_workspace();
Fl_Choice *snap_ = nullptr, *piano_snap_ = nullptr;
Fl_Value_Slider *note_velocity_ = nullptr;
Fl_Widget *velocity_lane_ = nullptr;
Fl_Box *transport_position_ = nullptr;
Fl_Box *tempo_display_ = nullptr;
Fl_Box *selection_info_ = nullptr;

View file

@ -1,4 +1,5 @@
#include "theme.hpp"
#include "snap.hpp"
#include "piano_roll.hpp"
#include <FL/Fl.H>
#include <FL/fl_draw.H>
@ -352,7 +353,7 @@ bool PianoRollWidget::visible(const xtc_note_info &n) const
xtc_result PianoRollWidget::set_grid(uint64_t ticks)
{
if (ticks != 1 && ticks != 120 && ticks != 160 && ticks != 240 && ticks != 320 && ticks != 480 && ticks != 960)
if (xtc_ui::snap_index(ticks) < 0)
return XTC_ERR_INVALID_ARGUMENT;
grid_ = ticks;
redraw();
@ -400,7 +401,7 @@ void PianoRollWidget::command(int action)
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");
text = fl_input("Snap ticks: 1, 120, 160 (1/16 triplet), 240, 320 (1/8 triplet), 480, 960, 1920, 3840", "240");
unsigned int value;
if (text && (!parse(text, value) || set_grid(value) != XTC_OK))
fl_alert("Choose one of the listed grids.");
@ -440,3 +441,18 @@ void PianoRollWidget::command(int action)
} else return;
apply(edit);
}
xtc_result PianoRollWidget::set_velocity(uint64_t id, unsigned int velocity)
{
if (velocity < 1 || velocity > 127) return XTC_ERR_INVALID_ARGUMENT;
size_t count = 0; xtc_project_note_count(project_,&count);
for (size_t i = 0; i < count; ++i) {
xtc_note_info note{}; xtc_project_get_note(project_,i,&note);
if (note.id != id || !visible(note)) continue;
if (note.velocity == velocity) return XTC_OK;
xtc_result r = before_ ? before_(context_) : XTC_OK;
if (r == XTC_OK) r = xtc_project_edit_note(project_,id,note.start_tick,note.length_tick,note.pitch,velocity,note.channel);
return change(r);
}
return XTC_ERR_NOT_FOUND;
}

View file

@ -69,6 +69,11 @@ class PianoRollWidget : public Fl_Widget {
*
* @return XTC_OK on success, the guard's error, or the project error.
*/
xtc_project *project() const { return project_; }
bool note_visible(const xtc_note_info &note) const { return visible(note); }
int note_x(uint64_t tick) const { return x()+64+static_cast<int>(tick*96/960); }
uint64_t grid() const { return grid_; }
xtc_result set_velocity(uint64_t note, unsigned int velocity);
uint64_t selected_note() const { return selected_; }
bool select_note(uint64_t id);
xtc_result delete_selected();

View file

@ -1,5 +1,7 @@
#include "sequencer_widget.hpp"
#include "theme.hpp"
#include "snap.hpp"
#include <cstring>
#include "xtc/xtc_midi.h"
#include <FL/Fl.H>
#include <FL/Fl_Scroll.H>
@ -21,9 +23,11 @@
* @param context Opaque callback context.
*/
SequencerWidget::SequencerWidget(int left, int top, xtc_project *project, Seek seek, void *context)
: Fl_Widget(left, top, 6300, 3700), project_(project), seek_(seek), context_(context)
: Fl_Group(left, top, 6300, 3700), project_(project), seek_(seek), context_(context)
{
resizable(nullptr);
extent();
end();
}
/**
@ -65,15 +69,15 @@ void SequencerWidget::set_project(xtc_project *project, bool reset)
}
/**
* @brief Converts a screen x coordinate to a bounded timeline tick snapped to a sixteenth note.
* @brief Converts a screen x coordinate to a bounded timeline tick snapped to the selected grid.
*
* @param px Screen x coordinate in pixels.
* @return Snapped tick within the supported project range.
*/
uint64_t SequencerWidget::tick(int px) const
{
return std::min(XTC_PROJECT_MAX_TICK, static_cast<uint64_t>(std::max(0, px - x() - 180)) * 960U /
static_cast<unsigned int>(scale_) / grid_ * grid_);
int64_t raw = static_cast<int64_t>(std::max(0,px-x()-180))*960/scale_;
return std::min(XTC_PROJECT_MAX_TICK,static_cast<uint64_t>(xtc_ui::snap_position(raw,grid_)));
}
/**
@ -91,6 +95,7 @@ void SequencerWidget::extent()
}
size(static_cast<int>(end * static_cast<unsigned int>(scale_) / 960U + 200U),
std::max(400, static_cast<int>(tracks) * 56 + 100));
refresh_instruments();
if (parent())
parent()->redraw();
}
@ -237,16 +242,22 @@ void SequencerWidget::draw()
fl_rectf(x()+180,py,w()-180,55);
}
fl_font(FL_HELVETICA,11);
int first = std::max(0,(cx-x()-180)/scale_);
int last = (cx+cw-x()-180)/scale_+1;
for (int beat = first; beat <= last; ++beat) {
int px = x()+180+beat*scale_;
fl_color(beat%4 ? fl_rgb_color(43,48,56) : fl_rgb_color(65,72,83));
uint64_t visual = grid_ == 1 ? 960 : grid_;
while (visual*static_cast<unsigned int>(scale_)/960 < 6) visual *= 2;
int spacing = static_cast<int>(visual*static_cast<unsigned int>(scale_)/960);
int first = std::max(0,(cx-x()-180)/spacing);
int last = (cx+cw-x()-180)/spacing+1;
for (int step = first; step <= last; ++step) {
uint64_t tick = static_cast<uint64_t>(step)*visual;
int px = x()+180+static_cast<int>(tick*static_cast<unsigned int>(scale_)/960);
fl_color(tick%3840 == 0 ? fl_rgb_color(76,84,96) : tick%960 == 0 ? fl_rgb_color(56,63,73) : fl_rgb_color(42,48,56));
fl_line(px,y()+28,px,y()+h());
if (beat%4 == 0) {
char label[32]; std::snprintf(label,sizeof(label),"%d",beat/4+1);
fl_color(xtc_ui::muted()); fl_draw(label,px+6,y()+19);
}
}
first = std::max(0,(cx-x()-180)/(scale_*4));
last = (cx+cw-x()-180)/(scale_*4)+1;
for (int bar = first; bar <= last; ++bar) {
char label[32]; std::snprintf(label,sizeof(label),"%d",bar+1);
fl_color(xtc_ui::muted()); fl_draw(label,x()+180+bar*scale_*4+6,y()+19);
}
fl_color(xtc_ui::muted()); fl_draw("TRACKS / 4:4",x()+12,y()+19);
for (size_t i = 0; i < tracks; ++i) {
@ -259,11 +270,12 @@ void SequencerWidget::draw()
fl_color(color); fl_rectf(x(),py,3,55);
fl_push_clip(x()+9,py,162,55);
fl_color(xtc_ui::text()); fl_font(FL_HELVETICA_BOLD,12);
fl_draw(t.name,x()+12,py+23);
fl_draw(t.name,x()+12,py+18);
char detail[64];
std::snprintf(detail,sizeof(detail),"%02zu %s%s%s",i+1,t.is_bus ? "BUS" : "TRACK",
t.mute ? " MUTE" : "",t.solo ? " SOLO" : "");
fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(detail,x()+12,py+42);
if (t.is_bus) { fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(detail,x()+12,py+42); }
if (t.mute || t.solo) { fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(t.mute ? "M" : "S",x()+164,py+18); }
fl_pop_clip();
for (size_t j = 0; j < clips; ++j) {
xtc_clip_info c{}; xtc_project_get_clip(project_,j,&c);
@ -314,6 +326,7 @@ void SequencerWidget::draw()
fl_color(xtc_ui::accent()); fl_line(playhead,y(),playhead,y()+h());
fl_polygon(playhead-4,y(),playhead+4,y(),playhead,y()+6);
}
draw_children();
fl_pop_clip();
}
@ -325,6 +338,13 @@ void SequencerWidget::draw()
*/
int SequencerWidget::handle(int event)
{
for (const auto &choice : track_choices_) {
if (!choice.widget || !choice.widget->visible()) continue;
if ((event == FL_PUSH && Fl::event_inside(choice.widget)) ||
((event == FL_DRAG || event == FL_RELEASE) && Fl::pushed() == choice.widget) ||
(event == FL_KEYDOWN && Fl::focus() == choice.widget))
return Fl_Group::handle(event);
}
if (event == FL_PUSH && (Fl::event_button() == FL_LEFT_MOUSE || Fl::event_button() == FL_RIGHT_MOUSE)) {
take_focus();
mode_ = 0;
@ -360,6 +380,11 @@ int SequencerWidget::handle(int event)
break;
}
}
if (Fl::event_button() == FL_LEFT_MOUSE && Fl::event_clicks() && clip_ != UINT64_MAX && original_.midi) {
mode_ = 0;
if (open_clip) open_clip(context_);
return 1;
}
if (Fl::event_button() == FL_RIGHT_MOUSE) {
mode_ = 0;
Fl_Menu_Item items[6]{};
@ -380,23 +405,24 @@ int SequencerWidget::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_ / static_cast<int64_t>(grid_) * static_cast<int64_t>(grid_);
int64_t delta = static_cast<int64_t>(Fl::event_x() - drag_x_) * 960 / scale_;
int64_t start = static_cast<int64_t>(original_.start_tick);
int64_t length = static_cast<int64_t>(original_.length_tick);
int64_t offset = static_cast<int64_t>(original_.offset_tick);
uint64_t target = original_.track_id;
if (mode == 1) {
start = std::max<int64_t>(0, start + delta);
start = xtc_ui::snap_position(start+delta,grid_);
int row = (Fl::event_y() - y() - 28) / 56;
xtc_track_info t{};
if (row >= 0 && xtc_project_get_track(project_, static_cast<size_t>(row), &t) == XTC_OK)
target = t.id;
} else if (mode == 2) {
delta = xtc_ui::snap_position(start+delta,grid_)-start;
start += delta;
offset += delta;
length -= delta;
} else
length += delta;
length = xtc_ui::snap_position(start+length+delta,grid_)-start;
if (start < 0 || offset < 0 || length <= 0)
return 1;
if (before_edit) {
@ -430,12 +456,12 @@ int SequencerWidget::handle(int event)
}
if (event == FL_FOCUS || event == FL_UNFOCUS)
return 1;
return Fl_Widget::handle(event);
return Fl_Group::handle(event);
}
xtc_result SequencerWidget::grid(uint64_t ticks)
{
if (ticks != 1 && ticks != 120 && ticks != 240 && ticks != 480 && ticks != 960) return XTC_ERR_INVALID_ARGUMENT;
if (xtc_ui::snap_index(ticks) < 0) return XTC_ERR_INVALID_ARGUMENT;
grid_ = ticks; redraw(); return XTC_OK;
}
xtc_result SequencerWidget::duplicate_selection()
@ -557,3 +583,73 @@ void SequencerWidget::draw_waveform(const xtc_clip_info &c, int left, int top)
fl_rectf(px, y1, width, std::max(1, y2-y1+1));
}
}
Fl_Choice *SequencerWidget::instrument_choice(uint64_t track) const
{
for (const auto &entry : track_choices_) if (entry.widget && entry.track == track) return entry.widget;
return nullptr;
}
void SequencerWidget::refresh_instruments() try
{
size_t tracks = 0, clips = 0;
xtc_project_get_counts(project_,&tracks,&clips);
catalog_.clear();
for (size_t i = 0; i < tracks; ++i) {
xtc_track_info t{}; xtc_plugin_spec spec{};
xtc_project_get_track(project_,i,&t);
if (xtc_project_get_instrument(project_,t.id,&spec) != XTC_OK) continue;
bool present = false;
for (const auto &item : catalog_) if (!std::strcmp(item.path,spec.path) && item.index == spec.index) present = true;
if (!present) catalog_.push_back(spec);
}
for (size_t i = 0; i < track_choices_.size(); ++i) {
auto &entry = track_choices_[i];
xtc_track_info t{};
if (i >= tracks || xtc_project_get_track(project_,i,&t) != XTC_OK || t.is_bus) {
if (entry.widget) entry.widget->hide();
entry.track = UINT64_MAX; continue;
}
if (!entry.widget) {
struct GroupScope {
Fl_Group *previous;
explicit GroupScope(Fl_Group *group) : previous(Fl_Group::current()) { Fl_Group::current(group); }
~GroupScope() { Fl_Group::current(previous); }
} scope(this);
entry.widget = new Fl_Choice(x()+10,y()+58+static_cast<int>(i)*56,158,20);
xtc_ui::style(entry.widget); entry.widget->textsize(10); entry.widget->textcolor(xtc_ui::text());
entry.widget->tooltip("Track instrument: built-in sine, an instrument used in this project, or Load plugin...");
entry.widget->callback(instrument_callback,this);
}
entry.track = t.id;
entry.widget->resize(x()+10,y()+58+static_cast<int>(i)*56,158,20);
entry.widget->clear(); entry.widget->add("Built-in sine");
xtc_plugin_spec current{}; int selected = 0;
bool assigned = xtc_project_get_instrument(project_,t.id,&current) == XTC_OK;
for (size_t j = 0; j < catalog_.size(); ++j) {
std::string name = std::string(xtc_plugin_format_name(catalog_[j].format))+": "+catalog_[j].label;
int item = entry.widget->add("Instrument"); entry.widget->replace(item,name.c_str());
if (assigned && !std::strcmp(current.path,catalog_[j].path) && current.index == catalog_[j].index) selected = static_cast<int>(j)+1;
}
entry.widget->add("Load plugin..."); entry.widget->value(selected); entry.widget->show();
}
} catch (const std::bad_alloc &) {
catalog_.clear();
for (auto &entry : track_choices_) if (entry.widget) entry.widget->hide();
}
void SequencerWidget::instrument_callback(Fl_Widget *widget, void *context)
{
auto *owner = static_cast<SequencerWidget *>(context);
auto *choice = static_cast<Fl_Choice *>(widget);
for (const auto &entry : owner->track_choices_) if (entry.widget == choice) {
uint64_t track = entry.track;
int index = choice->value();
bool browse = index == static_cast<int>(owner->catalog_.size())+1;
xtc_plugin_spec spec{};
if (index > 0 && !browse) {
if (static_cast<size_t>(index) > owner->catalog_.size()) return;
spec = owner->catalog_[static_cast<size_t>(index)-1];
}
if (owner->instrument_select) owner->instrument_select(owner->context_,track,index > 0 && !browse ? &spec : nullptr,browse);
owner->refresh_instruments(); owner->redraw(); return;
}
}

View file

@ -1,18 +1,27 @@
#ifndef XTC_SEQUENCER_WIDGET_HPP
#define XTC_SEQUENCER_WIDGET_HPP
#include <FL/Fl_Widget.H>
#include <FL/Fl_Group.H>
#include <FL/Fl_Choice.H>
#include "xtc/xtc_plugin.h"
#include <array>
#include "xtc/xtc_audio.h"
#include <memory>
#include <string>
#include <vector>
class SequencerWidget : public Fl_Widget {
class SequencerWidget : public Fl_Group {
public:
/**
* @brief Prepares a model edit using the widget's opaque callback context.
*
* @return XTC_OK to allow the edit, or an error to cancel it.
*/
using OpenClip = void (*)(void *);
using InstrumentSelect = void (*)(void *, uint64_t, const xtc_plugin_spec *, bool);
OpenClip open_clip = nullptr;
InstrumentSelect instrument_select = nullptr;
Fl_Choice *instrument_choice(uint64_t track) const;
void refresh_instruments();
bool select(uint64_t track, uint64_t clip = UINT64_MAX);
xtc_result duplicate_selection();
xtc_result split_selection();
@ -141,6 +150,10 @@ class SequencerWidget : public Fl_Widget {
void draw() override;
private:
struct TrackChoice { Fl_Choice *widget = nullptr; uint64_t track = UINT64_MAX; };
std::array<TrackChoice,XTC_PROJECT_MAX_TRACKS> track_choices_{};
std::vector<xtc_plugin_spec> catalog_;
static void instrument_callback(Fl_Widget *, void *);
struct Wave {
std::string path;
std::unique_ptr<xtc_waveform, decltype(&xtc_waveform_destroy)> data{nullptr, xtc_waveform_destroy};
@ -154,7 +167,7 @@ class SequencerWidget : public Fl_Widget {
int scale_ = 48, drag_x_ = 0, mode_ = 0;
xtc_clip_info original_{};
/**
* @brief Converts a screen x coordinate to a bounded timeline tick snapped to a sixteenth note.
* @brief Converts a screen x coordinate to a bounded timeline tick snapped to the selected grid.
*
* @param px Screen x coordinate in pixels.
* @return Snapped tick within the supported project range.

19
src/gui/snap.hpp Normal file
View file

@ -0,0 +1,19 @@
#ifndef XTC_GUI_SNAP_HPP
#define XTC_GUI_SNAP_HPP
#include <cstdint>
#include <cstddef>
namespace xtc_ui {
constexpr uint64_t snap_ticks[] = {1,120,160,240,320,480,960,1920,3840};
constexpr const char *snap_labels[] = {"Off","1/32","1/16 triplet","1/16","1/8 triplet","1/8","1/4","1/2","1 bar"};
constexpr size_t snap_count = sizeof(snap_ticks)/sizeof(snap_ticks[0]);
inline int snap_index(uint64_t ticks) {
for (size_t i = 0; i < snap_count; ++i) if (snap_ticks[i] == ticks) return static_cast<int>(i);
return -1;
}
inline int64_t snap_position(int64_t ticks, uint64_t grid) {
if (ticks <= 0) return 0;
int64_t step = static_cast<int64_t>(grid);
return (ticks+step/2)/step*step;
}
}
#endif

73
src/gui/velocity_lane.hpp Normal file
View file

@ -0,0 +1,73 @@
#ifndef XTC_GUI_VELOCITY_LANE_HPP
#define XTC_GUI_VELOCITY_LANE_HPP
#include "piano_roll.hpp"
#include "theme.hpp"
#include <algorithm>
#include <cstdlib>
/* Fixed-height controller lane, separate from the vertically scrolled keys.
* X coordinates follow the piano widget, including its horizontal scroll. */
class VelocityLane : public Fl_Widget {
PianoRollWidget &piano_;
uint64_t editing_ = UINT64_MAX;
unsigned int preview_ = 100;
const xtc_project *editing_project_ = nullptr;
uint64_t editing_clip_ = UINT64_MAX;
unsigned int value(int py) const {
return static_cast<unsigned int>(std::clamp((y()+h()-8-py)*127/std::max(1,h()-34),1,127));
}
public:
VelocityLane(int x, int y, int w, int h, PianoRollWidget &piano)
: Fl_Widget(x,y,w,h,"Note velocity"), piano_(piano) {}
void draw() override {
fl_push_clip(x(),y(),w(),h());
fl_color(xtc_ui::background()); fl_rectf(x(),y(),w(),h());
fl_color(xtc_ui::border()); fl_line(x(),y(),x()+w(),y());
fl_font(FL_HELVETICA,11); fl_color(xtc_ui::muted());
fl_draw("VELOCITY 1..127 / Select a note, then drag its bar or use the velocity slider",x()+8,y()+17);
size_t count = 0; xtc_project_note_count(piano_.project(),&count);
for (int pass = 0; pass < 2; ++pass) for (size_t i = 0; i < count; ++i) {
xtc_note_info note{}; xtc_project_get_note(piano_.project(),i,&note);
if (!piano_.note_visible(note) || (note.id == piano_.selected_note()) != (pass == 1)) continue;
int px = piano_.note_x(note.start_tick);
unsigned int velocity = editing_ == note.id ? preview_ : note.velocity;
int top = y()+h()-8-static_cast<int>(velocity)*(h()-34)/127;
fl_color(note.id == piano_.selected_note() ? xtc_ui::accent() : fl_rgb_color(95,137,172));
fl_rectf(px-2,top,4,y()+h()-8-top); fl_rectf(px-4,top-2,8,4);
}
fl_pop_clip();
}
int handle(int event) override {
if (event == FL_PUSH && Fl::event_button() == FL_LEFT_MOUSE && Fl::event_y() >= y()+24) {
take_focus(); editing_ = UINT64_MAX;
size_t count = 0; int distance = 9;
xtc_project_note_count(piano_.project(),&count);
for (size_t i = 0; i < count; ++i) {
xtc_note_info note{}; xtc_project_get_note(piano_.project(),i,&note);
if (!piano_.note_visible(note)) continue;
int d = std::abs(Fl::event_x()-piano_.note_x(note.start_tick));
if (d < distance || (d == distance && note.id == piano_.selected_note())) {
distance = d; editing_ = note.id;
}
}
if (editing_ != UINT64_MAX) {
editing_project_ = piano_.project(); editing_clip_ = piano_.clip();
piano_.select_note(editing_); preview_ = value(Fl::event_y());
}
redraw(); piano_.redraw(); return 1;
}
if (event == FL_DRAG && editing_ != UINT64_MAX) {
preview_ = value(Fl::event_y()); redraw(); return 1;
}
if (event == FL_RELEASE && editing_ != UINT64_MAX) {
uint64_t id = editing_; editing_ = UINT64_MAX;
if (editing_project_ == piano_.project() && editing_clip_ == piano_.clip())
(void)piano_.set_velocity(id,value(Fl::event_y()));
redraw(); return 1;
}
if (event == FL_KEYDOWN && Fl::event_key() == FL_Escape) { editing_ = UINT64_MAX; redraw(); return 1; }
if (event == FL_FOCUS || event == FL_UNFOCUS) return 1;
return Fl_Widget::handle(event);
}
};
#endif

View file

@ -6,6 +6,7 @@
#include "../../src/gui/plugin_dialog.hpp"
#include <FL/Fl_Hold_Browser.H>
#include <FL/Fl_Choice.H>
#include <FL/Fl_Value_Slider.H>
#include <unistd.h>
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
@ -845,6 +846,19 @@ int main(int argc, char **argv)
CHECK(window.save_project("/tmp/xtc-gui-instrument.xtc") == XTC_OK);
CHECK(window.new_project() == XTC_OK && window.open_project("/tmp/xtc-gui-instrument.xtc") == XTC_OK);
CHECK(xtc_project_get_instrument(sequence->project(),track,&spec) == XTC_OK && spec.instrument);
CHECK(window.create_track(true) == XTC_OK);
uint64_t second = sequence->selected_track();
auto *selector = sequence->instrument_choice(second); CHECK(selector && selector->value() == 0);
CHECK(selector->size() == 4); // Sine, current project instrument, load, terminator.
selector->value(1); selector->do_callback();
CHECK(xtc_project_get_instrument(sequence->project(),second,&spec) == XTC_OK && spec.instrument);
CHECK(sequence->selected_track() == second);
CHECK(sequence->move_track(-1) == XTC_OK);
selector = sequence->instrument_choice(second); selector->value(0); selector->do_callback();
CHECK(xtc_project_get_instrument(sequence->project(),second,&spec) == XTC_ERR_NOT_FOUND);
CHECK(xtc_project_get_instrument(sequence->project(),track,&spec) == XTC_OK);
CHECK(window.undo(false) == XTC_OK);
CHECK(xtc_project_get_instrument(sequence->project(),second,&spec) == XTC_OK);
CHECK(window.assign_instrument(track,nullptr,0) == XTC_OK);
unlink("/tmp/xtc-gui-instrument.xtc"); window.do_callback();
}
@ -892,6 +906,64 @@ int main(int argc, char **argv)
xtc_project_get_counts(sequence->project(),&tracks,&clips); CHECK(tracks == 2 && clips == 1);
unlink("/tmp/xtc-workspace.xtc"); window.do_callback();
}
reset();
{
MainWindow window(false); window.show(); auto *sequence = timeline(&window);
CHECK(window.create_track(true) == XTC_OK);
uint64_t clip = sequence->selected_clip(), track = sequence->selected_track();
auto *snap = dynamic_cast<Fl_Choice *>(find(&window,"Snap")); CHECK(snap && snap->value() == 3);
CHECK(!std::strcmp(snap->text(3),"1/16") && !std::strcmp(snap->text(8),"1 bar"));
snap->value(5); snap->do_callback(); CHECK(sequence->grid() == 480);
CHECK(xtc_project_edit_clip(sequence->project(),clip,track,100,3840,0) == XTC_OK);
CHECK(sequence->rename_selection("Off-grid") == XTC_OK);
gesture(sequence,205,50,100,0);
xtc_clip_info info{}; xtc_project_find_clip(sequence->project(),clip,&info);
CHECK(info.start_tick == 1920); // Snap absolute position, including an off-grid source.
snap->value(0); snap->do_callback(); CHECK(sequence->grid() == 1);
gesture(sequence,300,50,1,0); xtc_project_find_clip(sequence->project(),clip,&info);
CHECK(info.start_tick == 1940); // Off retains pointer precision rather than a musical grid.
CHECK(window.undo(false) == XTC_OK);
CHECK(sequence->select(track,clip));
snap->value(5); snap->do_callback();
gesture(sequence,466,50,-13,0); xtc_project_find_clip(sequence->project(),clip,&info);
CHECK(info.start_tick+info.length_tick == 5280);
gesture(sequence,277,50,13,0); xtc_project_find_clip(sequence->project(),clip,&info);
CHECK(info.start_tick == 2400 && info.offset_tick == 480 && info.length_tick == 2880);
int old_x = Fl::e_x, old_y = Fl::e_y, old_key = Fl::e_keysym, old_clicks = Fl::e_clicks;
Fl::e_x = sequence->x()+320; Fl::e_y = sequence->y()+50;
Fl::e_keysym = FL_Button+FL_LEFT_MOUSE; Fl::e_clicks = 1;
CHECK(sequence->handle(FL_PUSH) == 1); CHECK(window.piano_roll() && window.piano_roll()->clip() == clip);
Fl::e_x = old_x; Fl::e_y = old_y; Fl::e_keysym = old_key; Fl::e_clicks = old_clicks;
auto *piano = window.piano_roll();
CHECK(piano->add_note(0,960,60,100,2) == XTC_OK);
uint64_t note_id = piano->selected_note();
CHECK(piano->add_note(0,960,64,90,2) == XTC_OK); // Coincident notes remain individually editable.
uint64_t chord_id = piano->selected_note();
CHECK(piano->select_note(note_id)); pump();
auto *velocity = dynamic_cast<Fl_Value_Slider *>(find(piano->window(),"Note velocity"));
CHECK(velocity && velocity->active()); velocity->value(29); velocity->do_callback();
xtc_note_info note{}, chord{};
for (size_t i = 0; i < 2; ++i) { xtc_note_info n{}; xtc_project_get_note(sequence->project(),i,&n);
if (n.id == note_id) note = n;
if (n.id == chord_id) chord = n;
}
CHECK(note.velocity == 29 && note.start_tick == 0 && note.length_tick == 960 && note.pitch == 60 && note.channel == 2);
CHECK(chord.velocity == 90);
CHECK(piano->set_velocity(note_id,0) == XTC_ERR_INVALID_ARGUMENT);
CHECK(piano->set_velocity(note_id,128) == XTC_ERR_INVALID_ARGUMENT);
CHECK(piano->set_velocity(UINT64_MAX,40) == XTC_ERR_NOT_FOUND);
auto *lane = window.velocity_lane(); CHECK(lane);
gesture(lane,piano->note_x(0)-lane->x(),70,0,-30);
xtc_project_get_note(sequence->project(),0,&note); CHECK(note.id == note_id && note.velocity > 29);
xtc_project_get_note(sequence->project(),1,&chord); CHECK(chord.id == chord_id && chord.velocity == 90);
CHECK(window.undo(false) == XTC_OK);
xtc_project_get_note(sequence->project(),0,&note); CHECK(note.velocity == 29);
CHECK(window.undo(true) == XTC_OK);
CHECK(window.save_project("/tmp/xtc-grid-velocity.xtc") == XTC_OK);
CHECK(window.new_project() == XTC_OK && window.open_project("/tmp/xtc-grid-velocity.xtc") == XTC_OK);
xtc_project_get_note(sequence->project(),0,&note); CHECK(note.velocity > 29 && note.pitch == 60 && note.channel == 2);
unlink("/tmp/xtc-grid-velocity.xtc"); window.do_callback();
}
if (failures != 0) return 1;
std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes);
return 0;