diff --git a/docs/MIDI.md b/docs/MIDI.md index ff7972f..a3562fe 100644 --- a/docs/MIDI.md +++ b/docs/MIDI.md @@ -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. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 89ffe08..48734e6 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2d22fce..4240bb8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index ab19bd5..5a1957e 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -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. diff --git a/docs/WORKSPACE.md b/docs/WORKSPACE.md index 2435c88..17ed4b7 100644 --- a/docs/WORKSPACE.md +++ b/docs/WORKSPACE.md @@ -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. diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 54b2743..b0a5fd6 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -5,6 +5,10 @@ #include "mixer_widget.hpp" #include "plugin_dialog.hpp" #include "theme.hpp" +#include "snap.hpp" +#include "velocity_lane.hpp" +#include +#include #include #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(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(widget); int value = choice->value(); + if (value >= 0 && static_cast(value) < xtc_ui::snap_count) + (void)static_cast(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(c)->duplicate_selection(); },sequencer_); menu->add("Edit/Split audio at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->split_selection(); }, sequencer_); menu->add("Edit/Audio clip gain and fades", 0, [](Fl_Widget *, void *c) { static_cast(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(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(widget); (void)static_cast(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(c); int value = static_cast(widget)->value(); + if (value >= 0 && static_cast(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(c); + (void)w->piano_->set_velocity(w->piano_->selected_note(),static_cast(static_cast(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(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,¬e); + 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(context); + if (w->recorder_) { w->report_error("Stop recording before instrument changes",XTC_ERR_INVALID_STATE); return; } auto *menu = dynamic_cast(widget); const char *label = menu ? menu->mvalue()->label() : "Load instrument plugin"; uint64_t track = w->sequencer_->selected_track(); diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 0f79d9d..748175d 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -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; diff --git a/src/gui/piano_roll.cpp b/src/gui/piano_roll.cpp index 1a203c3..02f1a9c 100644 --- a/src/gui/piano_roll.cpp +++ b/src/gui/piano_roll.cpp @@ -1,4 +1,5 @@ #include "theme.hpp" +#include "snap.hpp" #include "piano_roll.hpp" #include #include @@ -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,¬e); + 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; +} diff --git a/src/gui/piano_roll.hpp b/src/gui/piano_roll.hpp index fb73c4d..55df253 100644 --- a/src/gui/piano_roll.hpp +++ b/src/gui/piano_roll.hpp @@ -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 ¬e) const { return visible(note); } + int note_x(uint64_t tick) const { return x()+64+static_cast(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(); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index b5782dd..0810813 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -1,5 +1,7 @@ #include "sequencer_widget.hpp" #include "theme.hpp" +#include "snap.hpp" +#include #include "xtc/xtc_midi.h" #include #include @@ -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(std::max(0, px - x() - 180)) * 960U / - static_cast(scale_) / grid_ * grid_); + int64_t raw = static_cast(std::max(0,px-x()-180))*960/scale_; + return std::min(XTC_PROJECT_MAX_TICK,static_cast(xtc_ui::snap_position(raw,grid_))); } /** @@ -91,6 +95,7 @@ void SequencerWidget::extent() } size(static_cast(end * static_cast(scale_) / 960U + 200U), std::max(400, static_cast(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(scale_)/960 < 6) visual *= 2; + int spacing = static_cast(visual*static_cast(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(step)*visual; + int px = x()+180+static_cast(tick*static_cast(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(Fl::event_x() - drag_x_) * 960 / scale_ / static_cast(grid_) * static_cast(grid_); + int64_t delta = static_cast(Fl::event_x() - drag_x_) * 960 / scale_; int64_t start = static_cast(original_.start_tick); int64_t length = static_cast(original_.length_tick); int64_t offset = static_cast(original_.offset_tick); uint64_t target = original_.track_id; if (mode == 1) { - start = std::max(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(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(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(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,¤t) == 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(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(context); + auto *choice = static_cast(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(owner->catalog_.size())+1; + xtc_plugin_spec spec{}; + if (index > 0 && !browse) { + if (static_cast(index) > owner->catalog_.size()) return; + spec = owner->catalog_[static_cast(index)-1]; + } + if (owner->instrument_select) owner->instrument_select(owner->context_,track,index > 0 && !browse ? &spec : nullptr,browse); + owner->refresh_instruments(); owner->redraw(); return; + } +} diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 9cfe161..194d504 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -1,18 +1,27 @@ #ifndef XTC_SEQUENCER_WIDGET_HPP #define XTC_SEQUENCER_WIDGET_HPP -#include +#include +#include +#include "xtc/xtc_plugin.h" +#include #include "xtc/xtc_audio.h" #include #include #include -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 track_choices_{}; + std::vector catalog_; + static void instrument_callback(Fl_Widget *, void *); struct Wave { std::string path; std::unique_ptr 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. diff --git a/src/gui/snap.hpp b/src/gui/snap.hpp new file mode 100644 index 0000000..1388365 --- /dev/null +++ b/src/gui/snap.hpp @@ -0,0 +1,19 @@ +#ifndef XTC_GUI_SNAP_HPP +#define XTC_GUI_SNAP_HPP +#include +#include +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(i); + return -1; +} +inline int64_t snap_position(int64_t ticks, uint64_t grid) { + if (ticks <= 0) return 0; + int64_t step = static_cast(grid); + return (ticks+step/2)/step*step; +} +} +#endif diff --git a/src/gui/velocity_lane.hpp b/src/gui/velocity_lane.hpp new file mode 100644 index 0000000..5221b58 --- /dev/null +++ b/src/gui/velocity_lane.hpp @@ -0,0 +1,73 @@ +#ifndef XTC_GUI_VELOCITY_LANE_HPP +#define XTC_GUI_VELOCITY_LANE_HPP +#include "piano_roll.hpp" +#include "theme.hpp" +#include +#include + +/* 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(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,¬e); + 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(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,¬e); + 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 diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 115b920..42e1e24 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -6,6 +6,7 @@ #include "../../src/gui/plugin_dialog.hpp" #include #include +#include #include #include #include @@ -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(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(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,¬e); 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,¬e); 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,¬e); 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;