From e664414796a86e1d71268382209b67dff28c44ed Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 21:39:38 +0100 Subject: [PATCH] Seperating the sequencer into multiple widgets. --- README.md | 2 +- cmake/Gui.cmake | 7 +- docs/ARCHITECTURE.md | 57 ++ docs/BUILD.md | 5 + docs/GUI.md | 19 + docs/IDEA.md | 3 + docs/ROADMAP.md | 49 ++ src/gui/sequencer_clip_widget.cpp | 79 +++ src/gui/sequencer_clip_widget.hpp | 17 + src/gui/sequencer_commands.cpp | 412 +++++++++++ src/gui/sequencer_group_scope.hpp | 12 + src/gui/sequencer_instruments.cpp | 63 ++ src/gui/sequencer_ruler_widget.cpp | 68 ++ src/gui/sequencer_ruler_widget.hpp | 13 + src/gui/sequencer_track_widget.cpp | 147 ++++ src/gui/sequencer_track_widget.hpp | 42 ++ src/gui/sequencer_velocity_widget.cpp | 189 +++++ src/gui/sequencer_velocity_widget.hpp | 26 + src/gui/sequencer_waveforms.cpp | 59 ++ src/gui/sequencer_widget.cpp | 977 ++------------------------ src/gui/sequencer_widget.hpp | 29 +- tests/integration/README.md | 19 + tests/integration/test_gui.cpp | 75 ++ 23 files changed, 1455 insertions(+), 914 deletions(-) create mode 100644 src/gui/sequencer_clip_widget.cpp create mode 100644 src/gui/sequencer_clip_widget.hpp create mode 100644 src/gui/sequencer_commands.cpp create mode 100644 src/gui/sequencer_group_scope.hpp create mode 100644 src/gui/sequencer_instruments.cpp create mode 100644 src/gui/sequencer_ruler_widget.cpp create mode 100644 src/gui/sequencer_ruler_widget.hpp create mode 100644 src/gui/sequencer_track_widget.cpp create mode 100644 src/gui/sequencer_track_widget.hpp create mode 100644 src/gui/sequencer_velocity_widget.cpp create mode 100644 src/gui/sequencer_velocity_widget.hpp create mode 100644 src/gui/sequencer_waveforms.cpp diff --git a/README.md b/README.md index 1dbf25b..afe4551 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ - **Modular engine first:** sequencer, instruments, effects, mixers and I/O are nodes in one processing graph. - **C core:** project model, graph, DSP and audio engine are implemented in C. -- **FLTK/C++ GUI:** C++ is confined to the graphical interface and calls the core through a C API. +- **FLTK/C++ GUI:** C++ is confined to the graphical interface and calls the core through a C API. The sequencer coordinates nested ruler, track, header, clip and velocity widgets; see [GUI ownership](docs/ARCHITECTURE.md#sequencer-widget-ownership). - **Linux first:** keep backend-specific details behind an interface. - **Small increments:** build and test each milestone before adding features. diff --git a/cmake/Gui.cmake b/cmake/Gui.cmake index c7b45df..066088c 100644 --- a/cmake/Gui.cmake +++ b/cmake/Gui.cmake @@ -26,7 +26,11 @@ endif() enable_language(CXX) set(XTC_GUI_AVAILABLE TRUE) add_library(xtc-gui STATIC src/gui/main_window.cpp src/gui/graph_widget.cpp - src/gui/sequencer_widget.cpp src/gui/piano_roll.cpp src/gui/mixer_widget.cpp + src/gui/sequencer_widget.cpp src/gui/sequencer_commands.cpp + src/gui/sequencer_track_widget.cpp src/gui/sequencer_clip_widget.cpp + src/gui/sequencer_velocity_widget.cpp src/gui/sequencer_ruler_widget.cpp + src/gui/sequencer_instruments.cpp src/gui/sequencer_waveforms.cpp + src/gui/piano_roll.cpp src/gui/mixer_widget.cpp src/gui/plugin_dialog.cpp src/gui/sample_editor.cpp) xtc_warnings(xtc-gui) target_link_libraries(xtc-gui PUBLIC xtc-core ${xtc_fltk}) @@ -54,6 +58,7 @@ if(XTC_XVFB_RUN AND BUILD_TESTING) set_tests_properties(${name} PROPERTIES WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" LABELS gui ENVIRONMENT "${ARGN}") endfunction() xtc_gui_test(gui) + xtc_gui_test(gui-sequencer "XTC_TEST_SEQUENCER_COMPONENTS_ONLY=1") if(XTC_LADSPA_AVAILABLE) xtc_gui_test(gui-plugins "XTC_TEST_PLUGIN=$") endif() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ce9de3..a7e3d3c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,6 +65,63 @@ commands and publish independent compiled snapshots. See GUI.md. --- +## Sequencer Widget Ownership + +`SequencerWidget` is the master FLTK group. It owns the timeline coordinate +system, grid/background, selection, zoom, scrolling extent, expansion state, +shared edit hooks and gestures that cross tracks. Drawing and local input belong +to child widgets: + +```mermaid +flowchart TD + S[SequencerWidget] --> R[SequencerRulerWidget] + S --> T[SequencerTrackWidget per track] + T --> H[SequencerTrackHeader] + H --> I[Fl_Choice instrument selector] + T --> C[SequencerClipWidget per clip] + T --> V[SequencerVelocityWidget] +``` + +FLTK groups own and destroy their children. The master's track index and each +track's clip index contain borrowed pointers; they do not independently delete +children. Views reconcile copied C metadata by stable IDs when edits, project +installation or drawing refresh the timeline. Surviving track views retain their +identity through reorder and undo; clips moved between tracks acquire a view +under their destination group. No child keeps a borrowed pointer into a C +project snapshot. Removed views are detached, hidden and scheduled with +`Fl::delete_widget`, allowing the current event handler to return safely. +A project replacement cancels captured gestures before refreshing the hierarchy. + +The ruler handles seeking and loop selection. Track groups manage row layout; +headers handle disclosure/selection and own instrument controls. Clip widgets +draw notes or cached waveforms and start selection/move/trim gestures; the +master completes cross-track edits. Velocity children own MIDI/audio previews +and local point/bar hit testing. All mutations still use the master's +before-edit and changed callbacks, preserving C history, transport stopping and +plan publication. Instrument keyboard focus is handled before timeline shortcuts. + +Implementation map: + +| Files in `src/gui/` | Responsibility | +| --- | --- | +| `sequencer_widget.hpp/.cpp` | Master API, layout/reconciliation, shared selection, gestures and timeline overlays | +| `sequencer_track_widget.hpp/.cpp` | Track groups, pinned header groups and nested instrument selectors | +| `sequencer_clip_widget.hpp/.cpp` | Clip drawing and local selection/gesture initiation | +| `sequencer_velocity_widget.hpp/.cpp` | MIDI velocity bars and continuous audio gain envelopes, including preview state | +| `sequencer_ruler_widget.hpp/.cpp` | Bar/marker labels, loop display, seeking and loop gestures | +| `sequencer_commands.cpp` | Editing commands and dialogs exposed by the master | +| `sequencer_instruments.cpp` | Instrument catalog and selector callbacks | +| `sequencer_waveforms.cpp` | Shared control-thread waveform cache and waveform drawing | +| `sequencer_group_scope.hpp` | Restore FLTK's implicit parent around dynamic child construction | + +`cmake/Gui.cmake` lists all implementation files explicitly in `xtc-gui`. +The hierarchy adds no dependency, C API, project format or audio-thread work. +GUI integration checks cover actual parent relationships, stable track views, +FLTK window-dispatched clip/velocity drags, cross-track movement, deferred +removal, project replacement during a captured gesture and undo/redo. + +--- + ## Editable Graph and Canvas Read-only C snapshots, monotonic node IDs, and a small set of C edit commands diff --git a/docs/BUILD.md b/docs/BUILD.md index 2a26a1d..a982f76 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -120,6 +120,11 @@ cmake --build build --target analyze cmake --build build --target quality-preview builtin-preview ``` +The `xtc-gui` target explicitly includes the sequencer master, track/header, +clip, velocity and ruler implementations, plus command, instrument and waveform +support files. See the [component map](ARCHITECTURE.md#sequencer-widget-ownership) +when adding GUI sources; update `cmake/Gui.cmake` alongside new implementation files. + Each registered root test also has a `test-NAME` build target (for example `test-core`, `test-gui`, `test-track-fx`). `test-builtins` aliases `test-builtin`. `gui` builds the optional main application. Preview targets write WAVs below the diff --git a/docs/GUI.md b/docs/GUI.md index 9081eb0..21b098b 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -148,6 +148,25 @@ Tests cover C-model/persistence boundaries, GUI move/trim gestures and zoom, save/reopen, failed load/submission rollback, sample-derived cursor updates, and live JACK playhead advance/pause. +### Sequencer components + +The sequencer is a master `Fl_Group` containing a ruler and one group per track. +Each track contains clip widgets, a velocity lane, and a pinned header group +with its instrument selector. Local drawing and input live with those children; +the master coordinates selection, scrolling, zoom and cross-track edits. +See [widget ownership and source files](ARCHITECTURE.md#sequencer-widget-ownership) +for the implementation map and lifetime rules. + +Click the small track-header arrow to expand a track downward. MIDI tracks +expose note velocity bars (1–127); drag a cap to edit a note. Audio tracks expose +the saved track-gain envelope (0–400%): click at any timeline position to add a +point, drag vertically to adjust it, or right-click a cap to remove it. Audio +positions ignore the snap grid. Escape cancels a preview; undo/redo restores +committed edits. Instrument selectors retain their own keyboard focus. +Expansion is temporary view state; audio gain points are shared with the mixer +and saved with the project. This component refactor preserves the existing +workflow and project format. + --- ## Audio Workflow diff --git a/docs/IDEA.md b/docs/IDEA.md index 9f6aca2..17581fd 100644 --- a/docs/IDEA.md +++ b/docs/IDEA.md @@ -216,6 +216,9 @@ xtc/ └── build/ # generated; do not commit ``` +The implemented sequencer now uses nested track, header, clip, velocity and +ruler widgets; see the [current source and ownership map](ARCHITECTURE.md#sequencer-widget-ownership). + Keep C and C++ files clearly separated. C headers exposed to C++ must use `extern "C"` guards. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a3744de..7d1bd47 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3656,6 +3656,55 @@ and inspection of the actual [audio envelope capture](../screenshots/audio-veloc --- +## Phase 111 — Sequencer Master and Child Widgets + +### Goal + +Split the sequencer GUI into focused components. Keep `SequencerWidget` as the +master with real nested FLTK track, header, clip, velocity and ruler widgets, +preserving current editing behavior, the C domain boundary and saved projects. + +### Tasks and acceptance criteria + +- [x] Make the master own ruler and track groups; each track owns its header, + instrument selector, clip widgets and velocity lane through FLTK ownership. +- [x] Move child drawing and local input into dedicated track/header, clip, + velocity and ruler implementation files. Keep shared coordinates, selection, + scrolling and cross-track gesture completion in the master. +- [x] Extract editing commands/dialogs, instrument support and waveform caching + into separate source files and register them explicitly in CMake. +- [x] Reconcile views by stable C IDs, preserving surviving track identity through + reorder/undo and moving clip views into the destination track hierarchy. +- [x] Cancel gestures on project replacement and defer removed widget deletion + until event handlers can return; preserve instrument focus and shortcuts. +- [x] Exercise real parent/child membership, cross-track movement, velocity edits, + undo/redo, removal and project replacement through GUI integration tests, + including FLTK window-level event dispatch. +- [x] Update the architecture/component map, GUI guide, build guidance, README + and integration-test documentation. + +### Implementation status + +Implemented on 2026-11-06. The master now delegates track/header, clip, velocity +and ruler drawing/input to child classes; commands and support code are separate +translation units. See [the ownership and source map](ARCHITECTURE.md#sequencer-widget-ownership). +FLTK owns the hierarchy; indexes and captured-gesture pointers are borrowed. +Child views hold copied C metadata rather than pointers into project snapshots. +Removed children are detached/hidden and deleted through FLTK's deferred queue. +The master remains the single selection and edit/publication coordinator. + +No dependency, core API, persistence or intended visual/workflow change is +introduced. Existing screenshots continue to describe the same interface. +Validation: the warning-enabled CMake build succeeds, all 55 existing CTests +pass, and the added `gui-sequencer` hierarchy/dispatched-gesture check passes. +The final build's main GUI and focused hierarchy tests were also rerun successfully. +The focused hierarchy/lifetime test passes Clang ASan/UBSan with leak detection +disabled for the unsanitized system FLTK library. Both audio-envelope captures +remain pixel-identical to the existing gallery. Physical hardware qualification +is unaffected. + +--- + ## Recommended Execution Order Reorder the phases when the audit reveals a blocking dependency. Do not mark a diff --git a/src/gui/sequencer_clip_widget.cpp b/src/gui/sequencer_clip_widget.cpp new file mode 100644 index 0000000..1540409 --- /dev/null +++ b/src/gui/sequencer_clip_widget.cpp @@ -0,0 +1,79 @@ +#include "sequencer_clip_widget.hpp" +#include "sequencer_widget.hpp" +#include "theme.hpp" +#include "xtc/xtc_midi.h" +#include +#include +#include + +SequencerClipWidget::SequencerClipWidget(SequencerWidget &owner, const xtc_clip_info &clip) + : Fl_Widget(0,0,1,46), owner_(owner), info_(clip) {} +void SequencerClipWidget::sync(const xtc_clip_info &clip, int top) +{ + info_=clip; + int left=owner_.x()+owner_.header_width_+static_cast(clip.start_tick*static_cast(owner_.scale_)/960U); + int width=std::max(4,static_cast(clip.length_tick*static_cast(owner_.scale_)/960U)); + resize(left,top+5,width,46); +} +void SequencerClipWidget::draw() +{ + const auto &c=info_; + int left=x(),py=y()-5,cx,cy,cw,ch; + fl_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); + int a=std::max(left,cx),b=std::min(x()+w(),cx+cw); + if(b<=a) return; + size_t notes=0; xtc_project_note_count(owner_.project_,¬es); + Fl_Color color=xtc_ui::track(c.track_id); + fl_color(fl_color_average(color,xtc_ui::background(),owner_.clip_selected(c.id) ? .75f : .45f)); + fl_rectf(a,py+5,b-a,46); + fl_color(owner_.clip_selected(c.id) ? xtc_ui::text() : color); fl_rect(a,py+5,b-a,46); + fl_push_clip(a+1,py+6,std::max(0,b-a-2),44); + if (c.midi) { + /* Real note positions, pitch and duration; no placeholder waveform. */ + unsigned int low = 127, high = 0; + for (size_t n = 0; n < notes; ++n) { + xtc_note_info note{}; xtc_project_get_note(owner_.project_,n,¬e); + if (note.clip_id == c.id) { low = std::min(low,note.pitch); high = std::max(high,note.pitch); } + } + unsigned int range = high >= low ? std::max(12U,high-low) : 12U; + fl_color(fl_color_average(color,FL_WHITE,.55f)); + for (size_t n = 0; n < notes; ++n) { + xtc_note_info note{}; xtc_project_get_note(owner_.project_,n,¬e); + if (note.clip_id != c.id) continue; + uint64_t begin = std::max(note.start_tick,c.offset_tick); + uint64_t end = std::min(note.start_tick+note.length_tick,c.offset_tick+c.length_tick); + if (begin >= end) continue; + int nx = left+static_cast((begin-c.offset_tick)*static_cast(owner_.scale_)/960U); + int nw = std::max(2,static_cast((end-begin)*static_cast(owner_.scale_)/960U)); + fl_rectf(nx,py+26+static_cast((high-note.pitch)*18/range),nw,2); + } + } else owner_.draw_waveform(c,left,py); + fl_color(fl_color_average(color,xtc_ui::background(),.35f)); fl_rectf(a+1,py+6,std::max(0,b-a-2),15); + fl_font(FL_HELVETICA,11); fl_color(xtc_ui::text()); + fl_draw(c.name,left+7,py+18); + fl_pop_clip(); +} + +int SequencerClipWidget::handle(int event) +{ + if(event==FL_PUSH && (Fl::event_button()==FL_LEFT_MOUSE || Fl::event_button()==FL_RIGHT_MOUSE)) { + owner_.take_focus(); + uint64_t previous=owner_.clip_; + owner_.track_=info_.track_id; owner_.clip_=info_.id; + owner_.original_=info_; owner_.drag_x_=Fl::event_x(); + owner_.mode_=Fl::event_x()-x()<7 ? 2 : x()+w()-Fl::event_x()<7 ? 3 : 1; + if(Fl::event_state(FL_SHIFT)) { + owner_.clip_=previous; owner_.toggle_clip(info_.id); owner_.mode_=0; return 1; + } + owner_.selection_.clear(); + if(Fl::event_button()==FL_LEFT_MOUSE && Fl::event_clicks()) { + owner_.mode_=0; + if(owner_.open_clip) owner_.open_clip(owner_.context_); + return 1; + } + if(Fl::event_button()==FL_RIGHT_MOUSE) { owner_.mode_=0; owner_.context_menu(); } + owner_.redraw(); return 1; + } + if(event==FL_DRAG || event==FL_RELEASE) return owner_.handle(event); + return 0; +} diff --git a/src/gui/sequencer_clip_widget.hpp b/src/gui/sequencer_clip_widget.hpp new file mode 100644 index 0000000..74e0942 --- /dev/null +++ b/src/gui/sequencer_clip_widget.hpp @@ -0,0 +1,17 @@ +#ifndef XTC_SEQUENCER_CLIP_WIDGET_HPP +#define XTC_SEQUENCER_CLIP_WIDGET_HPP +#include +#include "xtc/xtc_project.h" +class SequencerWidget; +class SequencerClipWidget : public Fl_Widget { + SequencerWidget &owner_; + xtc_clip_info info_{}; + public: + SequencerClipWidget(SequencerWidget &owner, const xtc_clip_info &clip); + uint64_t clip_id() const { return info_.id; } + void sync(const xtc_clip_info &clip, int track_top); + int handle(int event) override; + protected: + void draw() override; +}; +#endif diff --git a/src/gui/sequencer_commands.cpp b/src/gui/sequencer_commands.cpp new file mode 100644 index 0000000..0188fa3 --- /dev/null +++ b/src/gui/sequencer_commands.cpp @@ -0,0 +1,412 @@ +#include "sequencer_widget.hpp" +#include "snap.hpp" +#include "xtc/xtc_midi.h" +#include +#include +#include +#include +#include +#include + +// User-facing editing commands use the master's single history/publication path. + +xtc_result SequencerWidget::add_track() +{ + if (before_edit) { + xtc_result r = before_edit(context_); + if (r != XTC_OK) { + changed(r); + return r; + } + } + uint64_t id; + xtc_result r = xtc_project_add_track(project_, "Track", &id); + if (r == XTC_OK) { + track_ = id; + clip_ = UINT64_MAX;selection_.clear(); + } + changed(r); + return r; +} + +xtc_result SequencerWidget::add_region() +{ + if (before_edit) { + xtc_result r = before_edit(context_); + if (r != XTC_OK) { + changed(r); + return r; + } + } + size_t tracks, clips; + xtc_project_get_counts(project_, &tracks, &clips); + if (track_ == UINT64_MAX && tracks) { + xtc_track_info t{}; + xtc_project_get_track(project_, 0, &t); + track_ = t.id; + } + uint64_t id; + xtc_result r = xtc_project_add_clip(project_, track_, "Region (silent)", cursor_, new_clip_ticks, &id); + if (r == XTC_OK) {clip_ = id;selection_.clear();} + changed(r); + return r; +} + +xtc_result SequencerWidget::add_midi_clip() +{ + xtc_result r = before_edit ? before_edit(context_) : XTC_OK; + uint64_t id; + if (r == XTC_OK) { + if (track_ == UINT64_MAX) { + xtc_track_info t{}; + r = xtc_project_get_track(project_, 0, &t); + track_ = r == XTC_OK ? t.id : UINT64_MAX; + } + if (r == XTC_OK) + r = xtc_project_add_midi_clip(project_, track_, cursor_, new_clip_ticks, &id); + if (r == XTC_OK) {clip_ = id;selection_.clear();} + } + changed(r); + return r; +} + +xtc_result SequencerWidget::delete_selection() +{ + if(!selected_clips().empty())return transform_selection(XTC_CLIPS_DELETE,0); + xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_remove_track(project_,track_); + if(r==XTC_OK)track_=clip_=UINT64_MAX; + changed(r);return r; +} + +xtc_result SequencerWidget::merge_midi() +{ + auto ids=selected_clips();uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_merge_midi(project_,ids.data(),ids.size(),&id); + if(r==XTC_OK){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);select(c.track_id,id);} + changed(r);return r; +} + +xtc_result SequencerWidget::duplicate_track(const char *name) +{ + const uint64_t source=track_;uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_duplicate_track(project_,source,name,&id); + if(r==XTC_OK)select(id); + changed(r);return r; +} + +void SequencerWidget::duplicate_track_dialog() +{ + const char *name=fl_input("Name for complete track duplicate","Track copy");if(name)duplicate_track(name); +} + +xtc_result SequencerWidget::ripple_time(uint64_t length,bool remove) +{ + const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_ripple_time(project_,at,length,remove); + changed(r);return r; +} + +void SequencerWidget::ripple_dialog(bool remove) +{ + const char *input=fl_input(remove ? "Remove time from cursor: ticks (split crossing clips first)" : "Insert time at cursor: ticks (split crossing clips first)","3840");if(!input)return; + uint64_t length=0;char extra;std::istringstream values(input); + if(!(values>>length) || (values>>extra) || !length || length>XTC_PROJECT_MAX_TICK){fl_alert("Enter a positive bounded tick duration.");return;} + ripple_time(length,remove); +} + +xtc_result SequencerWidget::marker(const char *name) +{ + const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=name ? xtc_project_set_marker(project_,at,name) : xtc_project_remove_marker(project_,at); + changed(r);return r; +} + +void SequencerWidget::marker_dialog() +{ + const char *name=fl_input("Marker at cursor (1..63 UTF-8 bytes)","Section");if(name)marker(name); +} + +std::vector SequencerWidget::selected_clips() const +{ + std::vector ids;xtc_clip_info c{}; + for(auto id:selection_)if(xtc_project_find_clip(project_,id,&c)==XTC_OK)ids.push_back(id); + if(ids.empty() && clip_!=UINT64_MAX && xtc_project_find_clip(project_,clip_,&c)==XTC_OK)ids.push_back(clip_); + return ids; +} + +bool SequencerWidget::clip_selected(uint64_t id) const +{ + return selection_.empty() ? id==clip_ : std::find(selection_.begin(),selection_.end(),id)!=selection_.end(); +} + +bool SequencerWidget::toggle_clip(uint64_t id) +{ + xtc_clip_info c{};if(xtc_project_find_clip(project_,id,&c)!=XTC_OK)return false; + if(selection_.empty() && clip_!=UINT64_MAX)selection_.push_back(clip_); + auto found=std::find(selection_.begin(),selection_.end(),id); + if(found==selection_.end())selection_.push_back(id);else selection_.erase(found); + clip_=selection_.empty() ? UINT64_MAX : selection_.back(); + if(clip_!=UINT64_MAX)xtc_project_find_clip(project_,clip_,&c); + track_=c.track_id;redraw();return true; +} + +void SequencerWidget::select_all_clips() +{ + selection_.clear();size_t tracks=0,clips=0;xtc_project_get_counts(project_,&tracks,&clips); + for(size_t i=0;i output(ids.size()); + xtc_result r=ids.empty() ? XTC_ERR_NOT_FOUND : XTC_OK; + if(r==XTC_OK && before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_project_transform_clips(project_,ids.data(),ids.size(),op,amount,output.data()); + if(r==XTC_OK) { + selection_=op==XTC_CLIPS_DELETE ? std::vector{} : output; + clip_=selection_.empty() ? UINT64_MAX : selection_.back(); + } + changed(r);return r; +} + +xtc_result SequencerWidget::transform_midi(const xtc_midi_transform &edit) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_transform_midi(project_,ids.data(),ids.size(),&edit); + changed(r);return r; +} + +void SequencerWidget::fades_dialog() +{ + const char *input=fl_input("Symmetric fade length in ticks (0 removes; at most half each clip)","10");if(!input)return; + int64_t ticks=0;char extra;std::istringstream values(input); + if(!(values>>ticks) || (values>>extra) || ticks<0 || ticks>static_cast(XTC_PROJECT_MAX_TICK)){fl_alert("Enter a valid nonnegative tick length.");return;} + transform_selection(XTC_CLIPS_FADE,ticks); +} + +xtc_result SequencerWidget::make_chord(const std::vector &intervals) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_chordify(project_,ids.data(),ids.size(),intervals.data(),intervals.size()); + changed(r);return r; +} + +xtc_result SequencerWidget::arpeggiate(uint64_t step,unsigned int gate,bool descending) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_arpeggiate(project_,ids.data(),ids.size(),step,gate,descending); + changed(r);return r; +} + +xtc_result SequencerWidget::normalize_audio(float target) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_normalize_audio(project_,ids.data(),ids.size(),target); + changed(r);return r; +} + +void SequencerWidget::normalize_dialog() +{ + const char *input=fl_input("Source sample peak target in dBFS (-60..0; excludes fades and mixer)","-1");if(!input)return; + float db=0;char extra;std::istringstream values(input); + if(!(values>>db) || (values>>extra) || !std::isfinite(db) || db < -60 || db>0){fl_alert("Enter a finite sample peak target from -60 to 0 dBFS.");return;} + normalize_audio(std::pow(10.0f,db/20.0f)); +} + +void SequencerWidget::arpeggio_dialog() +{ + const char *input=fl_input("Step ticks (1..3840), gate percent (1..100), direction (0=up, 1=down)","240 80 0");if(!input)return; + uint64_t step=0;unsigned int gate=0;int down=0;char extra;std::istringstream values(input); + if(!(values>>step>>gate>>down) || (values>>extra) || !step || step>3840 || !gate || gate>100 || down<0 || down>1){fl_alert("Enter a valid step, gate and direction.");return;} + arpeggiate(step,gate,down!=0); +} + +void SequencerWidget::chord_dialog() +{ + const char *input=fl_input("Additional semitone intervals: 1..8 unique nonzero values (-127..127)","4 7");if(!input)return; + std::istringstream values(input);std::vector intervals;int interval; + while(values>>interval){intervals.push_back(interval);if(intervals.size()>8)break;} + if(!values.eof() || intervals.empty() || intervals.size()>8){fl_alert("Enter 1 to 8 semitone intervals.");return;} + make_chord(intervals); +} + +void SequencerWidget::humanize_dialog() +{ + const char *input=fl_input("Timing variation ticks (0..960), velocity variation (0..127), seed","12 8 1");if(!input)return; + unsigned int timing=0,velocity=0,seed=0;char extra;std::istringstream values(input); + if(!(values>>timing>>velocity>>seed) || (values>>extra) || timing>960 || velocity>127){fl_alert("Enter bounded timing/velocity variation and a 32-bit seed.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_HUMANIZE;edit.timing_variation=timing;edit.velocity_variation=velocity;edit.seed=seed;transform_midi(edit); +} + +void SequencerWidget::velocity_dialog() +{ + const char *input=fl_input("Velocity percentage (0..400), then offset (-127..127)","100 0");if(!input)return; + unsigned int percent=0;int offset=0;char extra;std::istringstream values(input); + if(!(values>>percent>>offset) || (values>>extra) || percent>400 || offset < -127 || offset>127){fl_alert("Enter a valid percentage and velocity offset.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_VELOCITY;edit.velocity_percent=percent;edit.velocity_offset=offset;transform_midi(edit); +} + +void SequencerWidget::scale_dialog() +{ + const char *input=fl_input("Root (0=C..11=B), scale (0=chromatic, 1=major, 2=natural minor)","0 1");if(!input)return; + unsigned int root=0,scale=0;char extra;std::istringstream values(input); + if(!(values>>root>>scale) || (values>>extra) || root>11 || scale>2){fl_alert("Enter a root from 0 to 11 and scale from 0 to 2.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SCALE;edit.root=root;edit.scale=scale;transform_midi(edit); +} + +void SequencerWidget::gate_dialog() +{ + const char *input=fl_input("Note length percentage (1..400)","50");if(!input)return; + unsigned int percent=0;char extra;std::istringstream values(input); + if(!(values>>percent) || (values>>extra) || percent<1 || percent>400){fl_alert("Enter a whole percentage from 1 to 400.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_GATE;edit.gate_percent=percent;transform_midi(edit); +} + +void SequencerWidget::swing_dialog() +{ + const char *input=fl_input("Grid ticks (1..3840), swing (0..100), strength (0..100)","240 50 100");if(!input)return; + int grid=0,swing=-1,strength=-1;char extra; + std::istringstream values(input); + if(!(values>>grid>>swing>>strength) || (values>>extra) || grid<1 || grid>3840 || swing<0 || swing>100 || strength<0 || strength>100){fl_alert("Enter a valid grid, swing and strength.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SWING;edit.grid=static_cast(grid);edit.swing=static_cast(swing);edit.strength=static_cast(strength);transform_midi(edit); +} + +xtc_result SequencerWidget::repeat_selection(unsigned int copies) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_repeat_clips(project_,ids.data(),ids.size(),copies); + changed(r);return r; +} + +void SequencerWidget::repeat_dialog() +{ + const char *input=fl_input("Additional phrase copies (1..1024)","3");if(!input)return; + char *end=nullptr;unsigned long copies=std::strtoul(input,&end,10); + if(end==input || *end || copies<1 || copies>1024){fl_alert("Enter a whole number from 1 to 1024.");return;} + repeat_selection(static_cast(copies)); +} + +xtc_result SequencerWidget::copy_selection() +{ + auto ids=selected_clips();xtc_clipboard *copy=nullptr; + xtc_result r=xtc_clipboard_create(project_,ids.data(),ids.size(),©); + if(r==XTC_OK)clipboard_.reset(copy); + else fl_alert("Copy unchanged: %s",xtc_result_string(r)); + return r; +} + +xtc_result SequencerWidget::paste_selection() +{ + const uint64_t start=cursor_,track=track_; + std::vector output(xtc_clipboard_count(clipboard_.get())); + xtc_result r=output.empty() ? XTC_ERR_INVALID_STATE : XTC_OK; + if(r==XTC_OK && before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_clipboard_paste(clipboard_.get(),project_,track,start,output.data()); + if(r==XTC_OK){selection_=output;clip_=selection_.back();xtc_clip_info c{};xtc_project_find_clip(project_,clip_,&c);track_=c.track_id;} + changed(r);return r; +} + +xtc_result SequencerWidget::nudge_selection(int direction) +{ + return transform_selection(XTC_CLIPS_MOVE,direction<0 ? -static_cast(grid_) : static_cast(grid_)); +} + +xtc_result SequencerWidget::duplicate_selection() +{ + auto ids=selected_clips();uint64_t begin=XTC_PROJECT_MAX_TICK,end=0; + for(auto id:ids){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);begin=std::min(begin,c.start_tick);end=std::max(end,c.start_tick+c.length_tick);} + return transform_selection(XTC_CLIPS_DUPLICATE,static_cast(end)-static_cast(begin)); +} + +xtc_result SequencerWidget::rename_selection(const char *name) +{ + xtc_result r = before_edit ? before_edit(context_) : XTC_OK; + if (r == XTC_OK) r = clip_ != UINT64_MAX ? xtc_project_rename_clip(project_,clip_,name) : xtc_project_rename_track(project_,track_,name); + changed(r); return r; +} + +xtc_result SequencerWidget::move_track(int direction) +{ + size_t tracks, clips, i; xtc_project_get_counts(project_,&tracks,&clips); + xtc_track_info track{}; + for (i = 0; i < tracks; ++i) { xtc_project_get_track(project_,i,&track); if (track.id == track_) break; } + if (i == tracks || (direction < 0 ? i == 0 : i+1 == tracks)) return XTC_ERR_NOT_FOUND; + xtc_result r = before_edit ? before_edit(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_move_track(project_,track_,direction < 0 ? i-1 : i+1); + changed(r); return r; +} + +xtc_result SequencerWidget::split_selection() +{ + uint64_t right; + const uint64_t split_tick = cursor_; // Stop/status refresh may move the playhead. + xtc_result result = before_edit ? before_edit(context_) : XTC_OK; + if (result == XTC_OK) { + xtc_clip_info c{};result=xtc_project_find_clip(project_,clip_,&c); + if(result==XTC_OK)result=c.midi ? xtc_project_split_midi(project_,clip_,split_tick,&right) : xtc_project_split_audio(project_,clip_,split_tick,&right); + } + if (result == XTC_OK) {clip_ = right;selection_.clear();} + changed(result); + return result; +} + +xtc_result SequencerWidget::audio_properties(float gain, uint64_t in, uint64_t out) +{ + xtc_result result = before_edit ? before_edit(context_) : XTC_OK; + if (result == XTC_OK) result = xtc_project_set_clip_audio(project_, clip_, gain, in, out); + changed(result); + return result; +} + +void SequencerWidget::audio_properties_dialog() +{ + xtc_clip_info clip{}; + if (xtc_project_find_clip(project_, clip_, &clip) != XTC_OK || clip.midi) { + fl_alert("Select an audio clip or silent region first."); + return; + } + char defaults[128]; + std::snprintf(defaults, sizeof(defaults), "%.9g %llu %llu", static_cast(clip.gain), + static_cast(clip.fade_in_tick), static_cast(clip.fade_out_tick)); + const char *text = fl_input("Linear gain (0..4), fade-in ticks, fade-out ticks (0 disables)", defaults); + if (!text) return; + std::istringstream input(text); + float gain; uint64_t in, out; + if (!(input >> gain >> in >> out) || !(input >> std::ws).eof()) { + fl_alert("Enter gain and two fade durations in ticks."); return; + } + audio_properties(gain, in, out); +} + +xtc_result SequencerWidget::crossfade_selection() +{ + auto ids=selected_clips();xtc_result r=ids.size()==2 ? XTC_OK : XTC_ERR_INVALID_ARGUMENT; + if(r==XTC_OK && before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_project_crossfade(project_,ids[0],ids[1]); + changed(r);return r; +} + +xtc_result SequencerWidget::strum_chords(uint64_t spacing,bool descending) +{ + auto ids=selected_clips();xtc_result r=XTC_OK; + if(before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_project_strum(project_,ids.data(),ids.size(),spacing,descending ? 1 : 0); + changed(r);return r; +} + +void SequencerWidget::strum_dialog() +{ + const char *input=fl_input("Spacing ticks (1..960), direction (0=up, 1=down)","30 0");if(!input)return; + uint64_t spacing=0;int down=0;char extra;std::istringstream values(input); + if(!(values>>spacing>>down) || (values>>extra) || !spacing || spacing>960 || down<0 || down>1){fl_alert("Enter a valid spacing and direction.");return;} + strum_chords(spacing,down!=0); +} diff --git a/src/gui/sequencer_group_scope.hpp b/src/gui/sequencer_group_scope.hpp new file mode 100644 index 0000000..b54bd38 --- /dev/null +++ b/src/gui/sequencer_group_scope.hpp @@ -0,0 +1,12 @@ +#ifndef XTC_SEQUENCER_GROUP_SCOPE_HPP +#define XTC_SEQUENCER_GROUP_SCOPE_HPP +#include +class SequencerGroupScope { + Fl_Group *previous_; + public: + explicit SequencerGroupScope(Fl_Group *group) : previous_(Fl_Group::current()) { Fl_Group::current(group); } + ~SequencerGroupScope() { Fl_Group::current(previous_); } + SequencerGroupScope(const SequencerGroupScope &) = delete; + SequencerGroupScope &operator=(const SequencerGroupScope &) = delete; +}; +#endif diff --git a/src/gui/sequencer_instruments.cpp b/src/gui/sequencer_instruments.cpp new file mode 100644 index 0000000..cd7fd63 --- /dev/null +++ b/src/gui/sequencer_instruments.cpp @@ -0,0 +1,63 @@ +#include "sequencer_widget.hpp" +#include "sequencer_track_widget.hpp" +#include +#include + +Fl_Choice *SequencerWidget::instrument_choice(uint64_t track) const +{ + auto *view=track_widget(track); + return view && !view->is_bus() ? view->instrument_choice() : nullptr; +} + +void SequencerWidget::refresh_instruments() try +{ + sync_views(); + size_t tracks = 0, clips = 0; + xtc_project_get_counts(project_,&tracks,&clips); + catalog_.clear(); + for (unsigned int i=0;i<2;++i) { xtc_plugin_spec s{}; if (xtc_plugin_probe(XTC_BUILTIN_PATH,i,48000,&s)==XTC_OK) catalog_.push_back(s); } + 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(auto *view:track_widgets_) { + auto *choice=view->instrument_choice(); + xtc_track_info t{}; + for(size_t i=0;itrack_id()) break;} + if(t.is_bus) {choice->hide(); continue;} + choice->clear(); choice->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 = choice->add("Instrument"); choice->replace(item,name.c_str()); + if (assigned && !std::strcmp(current.path,catalog_[j].path) && current.index == catalog_[j].index) selected = static_cast(j)+1; + } + choice->add("Load plugin..."); choice->value(selected); choice->show(); + } +} catch (const std::bad_alloc &) { + catalog_.clear(); + for(auto *view:track_widgets_) view->instrument_choice()->hide(); +} + +void SequencerWidget::instrument_callback(Fl_Widget *widget, void *context) +{ + auto *owner = static_cast(context); + auto *choice = static_cast(widget); + for (auto *view : owner->track_widgets_) if (view->instrument_choice() == choice) { + uint64_t track = view->track_id(); + 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_ruler_widget.cpp b/src/gui/sequencer_ruler_widget.cpp new file mode 100644 index 0000000..b0489a2 --- /dev/null +++ b/src/gui/sequencer_ruler_widget.cpp @@ -0,0 +1,68 @@ +#include "sequencer_ruler_widget.hpp" +#include "sequencer_widget.hpp" +#include "theme.hpp" +#include +#include +#include +#include +#include + +SequencerRulerWidget::SequencerRulerWidget(SequencerWidget &owner) + : Fl_Widget(owner.x(),owner.y(),owner.w(),28), owner_(owner) {} +void SequencerRulerWidget::draw() +{ + int cx,cy,cw,ch; fl_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); + fl_push_clip(x(),y(),w(),h()); + fl_font(FL_HELVETICA,11); + int first=std::max(0,(cx-owner_.x()-owner_.header_width_)/(owner_.scale_*4)); + int last=(cx+cw-owner_.x()-owner_.header_width_)/(owner_.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,owner_.x()+owner_.header_width_+bar*owner_.scale_*4+6,y()+19); + } + uint64_t begin=owner_.mode_==4 ? std::min(owner_.loop_anchor_,owner_.loop_preview_) : owner_.loop_begin_; + uint64_t end=owner_.mode_==4 ? std::max(owner_.loop_anchor_,owner_.loop_preview_) : owner_.loop_end_; + int left=owner_.x()+owner_.header_width_+static_cast(begin*static_cast(owner_.scale_)/960U); + int right=owner_.x()+owner_.header_width_+static_cast(end*static_cast(owner_.scale_)/960U); + fl_color(owner_.loop_enabled_ ? xtc_ui::accent() : xtc_ui::muted()); fl_rectf(left,y()+23,std::max(0,right-left),4); + size_t markers=0; xtc_project_marker_count(owner_.project_,&markers); + for(size_t i=0;i(marker.tick*static_cast(owner_.scale_)/960U); + if(pxcx+cw) continue; + fl_push_clip(px,y(),160,12); fl_font(FL_HELVETICA,10); fl_color(xtc_ui::accent()); fl_draw(marker.name,px+3,y()+10); fl_pop_clip(); + } + int playhead=owner_.x()+owner_.header_width_+static_cast(owner_.cursor_*static_cast(owner_.scale_)/960U); + fl_color(xtc_ui::accent()); fl_polygon(playhead-4,y(),playhead+4,y(),playhead,y()+6); + int pinned=owner_.header_left(); + fl_color(xtc_ui::background()); fl_rectf(pinned,y(),owner_.header_width_,28); + fl_color(xtc_ui::muted()); fl_font(FL_HELVETICA,10); fl_draw("TRACKS / drag edge to resize",pinned+10,y()+19); + fl_pop_clip(); +} +int SequencerRulerWidget::handle(int event) +{ + if (event==FL_DRAG && owner_.mode_==4) {owner_.loop_preview_=owner_.tick(Fl::event_x());owner_.redraw();return 1;} + if (event==FL_RELEASE && owner_.mode_==4) { + owner_.loop_preview_=owner_.tick(Fl::event_x()); owner_.mode_=0; + uint64_t begin=std::min(owner_.loop_anchor_,owner_.loop_preview_),end=std::max(owner_.loop_anchor_,owner_.loop_preview_); + if(end>begin && owner_.loop_select) { + xtc_result r=owner_.loop_select(owner_.context_,begin,end); + if(r!=XTC_OK) fl_alert("Loop unchanged: %s",xtc_result_string(r)); + } + owner_.redraw();return 1; + } + + if(event==FL_PUSH && (Fl::event_button()==FL_LEFT_MOUSE || Fl::event_button()==FL_RIGHT_MOUSE)) { + owner_.take_focus(); owner_.mode_=0; + if(Fl::event_x() +class SequencerWidget; +class SequencerRulerWidget : public Fl_Widget { + SequencerWidget &owner_; + public: + explicit SequencerRulerWidget(SequencerWidget &owner); + int handle(int event) override; + protected: + void draw() override; +}; +#endif diff --git a/src/gui/sequencer_track_widget.cpp b/src/gui/sequencer_track_widget.cpp new file mode 100644 index 0000000..37d1bc6 --- /dev/null +++ b/src/gui/sequencer_track_widget.cpp @@ -0,0 +1,147 @@ +#include "sequencer_track_widget.hpp" +#include "sequencer_widget.hpp" +#include "sequencer_clip_widget.hpp" +#include "sequencer_velocity_widget.hpp" +#include "sequencer_group_scope.hpp" +#include "theme.hpp" +#include +#include +#include +#include + +SequencerTrackWidget::SequencerTrackWidget(SequencerWidget &owner, const xtc_track_info &track) + : Fl_Group(owner.x(),owner.y()+28,owner.w(),56), owner_(owner), info_(track) +{ + resizable(nullptr); + clips_.reserve(XTC_PROJECT_MAX_CLIPS); + velocity_=new SequencerVelocityWidget(owner,track.id); + header_=new SequencerTrackHeader(*this); + end(); +} + +Fl_Choice *SequencerTrackWidget::instrument_choice() const { return header_->instrument_choice(); } +Fl_Group *SequencerTrackWidget::header() const { return header_; } +SequencerClipWidget *SequencerTrackWidget::clip_widget(uint64_t clip) const +{ + for(auto *view:clips_) if(view->clip_id()==clip) return view; + return nullptr; +} + +void SequencerTrackWidget::sync(size_t row, const xtc_track_info &track) +{ + row_=row; info_=track; + resize(owner_.x(),owner_.track_top(row),owner_.w(),owner_.track_height(row)); + header_->resize(owner_.header_left(),y(),owner_.header_width_,h()); + instrument_choice()->resize(header_->x()+10,y()+30,header_->w()-22,20); + if(info_.is_bus) instrument_choice()->hide(); + velocity_->resize(owner_.x()+owner_.header_width_,y()+56,owner_.w()-owner_.header_width_,88); + if(owner_.track_expanded(info_.id)) velocity_->show(); else velocity_->hide(); + for(auto it=clips_.begin();it!=clips_.end();) { + xtc_clip_info c{}; + if(xtc_project_find_clip(owner_.project_,(*it)->clip_id(),&c)!=XTC_OK || c.track_id!=info_.id) { + auto *removed=*it; it=clips_.erase(it); + removed->hide(); remove(removed); Fl::delete_widget(removed); + } else ++it; + } + size_t tracks=0,clips=0; xtc_project_get_counts(owner_.project_,&tracks,&clips); + SequencerGroupScope scope(this); + for(size_t i=0;isync(c,y()); + // Match dense model order for overlapping clip drawing and hit testing. + insert(*view,header_); + } +} + +void SequencerTrackWidget::draw() +{ + fl_push_clip(x(),y(),w(),h()); + draw_children(); + fl_pop_clip(); +} + +int SequencerTrackWidget::handle(int event) +{ + if(event==FL_PUSH) { + if(Fl::event_inside(header_)) return header_->handle(event); + if(velocity_->visible() && Fl::event_inside(velocity_)) return velocity_->handle(event); + // Preserve the full-row clip hit area, including its small vertical margins. + for(int i=children()-1;i>=0;--i) { + auto *view=dynamic_cast(child(i)); + if(view && Fl::event_x()>=view->x() && Fl::event_x()x()+view->w()) return view->handle(event); + } + owner_.take_focus(); owner_.track_=info_.id; owner_.clip_=UINT64_MAX; + owner_.selection_.clear(); owner_.mode_=0; + if(Fl::event_button()==FL_RIGHT_MOUSE) owner_.context_menu(); + owner_.redraw(); return 1; + } + if(event==FL_DRAG || event==FL_RELEASE) return owner_.handle(event); + return Fl_Group::handle(event); +} + +SequencerTrackHeader::SequencerTrackHeader(SequencerTrackWidget &track) + : Fl_Group(track.x(),track.y(),180,56), track_(track) +{ + resizable(nullptr); + instrument_=new Fl_Choice(x()+10,y()+30,w()-22,20); + xtc_ui::style(instrument_); instrument_->textsize(10); instrument_->textcolor(xtc_ui::text()); + instrument_->tooltip("Track instrument: built-in sine, an instrument used in this project, or Load plugin..."); + instrument_->callback(SequencerWidget::instrument_callback,&track.owner_); + end(); +} + +void SequencerTrackHeader::draw() +{ + auto &owner=track_.owner_; const auto &t=track_.info_; + fl_push_clip(x(),y(),w(),h()); + fl_color(t.id==owner.track_ ? fl_rgb_color(49,62,70) : xtc_ui::panel()); + fl_rectf(x(),y(),w()-2,h()-1); + fl_color(xtc_ui::track(t.id)); fl_rectf(x(),y(),3,h()-1); + fl_push_clip(x()+9,y(),w()-18,55); + fl_color(xtc_ui::text()); fl_font(FL_HELVETICA_BOLD,12); + if(owner.track_expanded(t.id)) fl_polygon(x()+10,y()+10,x()+20,y()+10,x()+15,y()+16); + else fl_polygon(x()+12,y()+8,x()+12,y()+18,x()+18,y()+13); + fl_draw(t.name,x()+26,y()+18); + if(t.is_bus) { + char detail[64]; std::snprintf(detail,sizeof(detail),"%02zu BUS%s%s",track_.row_+1,t.mute ? " MUTE" : "",t.solo ? " SOLO" : ""); + fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(detail,x()+12,y()+42); + } + if(t.mute || t.solo) { + fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(t.mute ? "M" : "S",x()+w()-16,y()+18); + } + fl_pop_clip(); + if(owner.track_expanded(t.id)) { + fl_color(xtc_ui::muted()); fl_font(FL_HELVETICA,11); + bool audio=track_.velocity_->audio(); + fl_draw(audio ? "AUDIO VELOCITY" : "VELOCITY 1..127",x()+12,y()+76); + fl_font(FL_HELVETICA,10); + fl_draw(audio ? "Level envelope 0..400%" : "Drag bars to edit notes",x()+12,y()+94); + char value[32]; if(track_.velocity_->preview_label(value,sizeof(value))) fl_draw(value,x()+12,y()+114); + } + draw_children(); + fl_pop_clip(); +} + +int SequencerTrackHeader::handle(int event) +{ + auto &owner=track_.owner_; + if(event==FL_PUSH) { + if(instrument_->visible() && Fl::event_inside(instrument_)) return Fl_Group::handle(event); + owner.take_focus(); + if(Fl::event_button()==FL_LEFT_MOUSE && Fl::event_x()>=x()+7 && Fl::event_x()=y()+56) return 1; + owner.track_=track_.info_.id; owner.clip_=UINT64_MAX; owner.selection_.clear(); owner.mode_=0; + if(Fl::event_button()==FL_RIGHT_MOUSE) owner.context_menu(); + owner.redraw(); return 1; + } + return Fl_Group::handle(event); +} diff --git a/src/gui/sequencer_track_widget.hpp b/src/gui/sequencer_track_widget.hpp new file mode 100644 index 0000000..f8a8b63 --- /dev/null +++ b/src/gui/sequencer_track_widget.hpp @@ -0,0 +1,42 @@ +#ifndef XTC_SEQUENCER_TRACK_WIDGET_HPP +#define XTC_SEQUENCER_TRACK_WIDGET_HPP +#include +#include +#include "xtc/xtc_project.h" +#include +class SequencerWidget; +class SequencerClipWidget; +class SequencerVelocityWidget; +class SequencerTrackHeader; +class SequencerTrackWidget : public Fl_Group { + SequencerWidget &owner_; + xtc_track_info info_{}; + size_t row_ = 0; + SequencerTrackHeader *header_; + SequencerVelocityWidget *velocity_; + std::vector clips_; // Borrowed; FLTK owns children. + friend class SequencerTrackHeader; + public: + SequencerTrackWidget(SequencerWidget &owner, const xtc_track_info &track); + uint64_t track_id() const { return info_.id; } + bool is_bus() const { return info_.is_bus != 0; } + void sync(size_t row, const xtc_track_info &track); + Fl_Choice *instrument_choice() const; + SequencerClipWidget *clip_widget(uint64_t clip) const; + SequencerVelocityWidget *velocity_lane() const { return velocity_; } + Fl_Group *header() const; + int handle(int event) override; + protected: + void draw() override; +}; +class SequencerTrackHeader : public Fl_Group { + SequencerTrackWidget &track_; + Fl_Choice *instrument_; + public: + explicit SequencerTrackHeader(SequencerTrackWidget &track); + Fl_Choice *instrument_choice() const { return instrument_; } + int handle(int event) override; + protected: + void draw() override; +}; +#endif diff --git a/src/gui/sequencer_velocity_widget.cpp b/src/gui/sequencer_velocity_widget.cpp new file mode 100644 index 0000000..4a5ae24 --- /dev/null +++ b/src/gui/sequencer_velocity_widget.cpp @@ -0,0 +1,189 @@ +#include "sequencer_velocity_widget.hpp" +#include "sequencer_widget.hpp" +#include "theme.hpp" +#include +#include +#include +#include +#include +#include + +SequencerVelocityWidget::SequencerVelocityWidget(SequencerWidget &owner, uint64_t track) + : Fl_Widget(0,0,1,88), owner_(owner), track_(track) {} +bool SequencerVelocityWidget::preview_label(char *text, size_t size) const +{ + if(owner_.active_velocity_!=this) return false; + if(owner_.mode_==7) std::snprintf(text,size,"Level: %.0f%%",audio_preview_.value*100); + else if(owner_.mode_==6) std::snprintf(text,size,"Velocity: %u",preview_); + else return false; + return true; +} +bool SequencerVelocityWidget::note_position(const xtc_note_info ¬e, int &px) const +{ + xtc_clip_info c{}; + if(xtc_project_find_clip(owner_.project_,note.clip_id,&c)!=XTC_OK || c.track_id!=track_ || !c.midi || + note.start_tick=c.length_tick) return false; + px=owner_.x()+owner_.header_width_+static_cast((c.start_tick+note.start_tick-c.offset_tick)*static_cast(owner_.scale_)/960U); + return true; +} + +bool SequencerVelocityWidget::audio() const +{ + size_t tracks=0, clips=0; xtc_project_get_counts(owner_.project_,&tracks,&clips); + bool midi=false; + for(size_t i=0;i(y()+h()-8-py)*4.0f/(h()-28),0.0f,4.0f); +} + +void SequencerVelocityWidget::draw_audio(const xtc_track_info &track, int left, int right) +{ + std::array points{}; + size_t count=0,total=0; xtc_project_automation_count(owner_.project_,&total); + for(size_t i=0;i(t*static_cast(owner_.scale_)/960U);}; + auto py=[&](float v){return y()+h()-8-static_cast(v*(h()-28)/4.0f);}; + fl_color(xtc_ui::track(track.id)); + if(!count) fl_line(left,py(track.gain),right,py(track.gain)); + else { + fl_line(left,py(points[0].value),px(points[0].tick),py(points[0].value)); + for(size_t i=1;iright+4) continue; + fl_rectf(point_x-4,py(points[i].value)-3,8,6); + } + } +} + +unsigned int SequencerVelocityWidget::note_value(int py) const +{ + return static_cast(std::clamp((y()+h()-8-py)*127/(h()-28),1,127)); +} + +void SequencerVelocityWidget::draw() +{ + int cx,cy,cw,ch; fl_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); + int left=std::max(cx,owner_.header_left()+owner_.header_width_); + fl_push_clip(left,y(),std::max(0,cx+cw-left),h()); + fl_color(xtc_ui::border()); fl_line(x(),y(),x()+w(),y()); + fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); + bool is_audio=audio(); + fl_draw(is_audio ? "Click anywhere to add a level point; drag vertically; right-click a point to remove" : "Drag a note bar to change velocity",left+8,y()+14); + xtc_track_info track{}; + size_t tracks=0,clips=0; xtc_project_get_counts(owner_.project_,&tracks,&clips); + for(size_t i=0;i(value)*(h()-28)/127; + fl_color(editing ? xtc_ui::text() : xtc_ui::track(track_)); + fl_rectf(px-2,ny,4,bottom-ny); fl_rectf(px-4,ny-2,8,4); + } + fl_pop_clip(); +} + +int SequencerVelocityWidget::handle(int event) +{ + if(owner_.mode_==7) { + if(event==FL_KEYDOWN && Fl::event_key()==FL_Escape) { owner_.mode_=0; owner_.redraw(); return 1; } + if(event==FL_DRAG) { audio_preview_.value=audio_value(Fl::event_y()); owner_.redraw(); return 1; } + if(event==FL_RELEASE) { + owner_.mode_=0; + auto point=audio_preview_; + point.value=audio_value(Fl::event_y()); + if(audio_existing_) { + size_t count=0; xtc_project_automation_count(owner_.project_,&count); + for(size_t i=0;i(std::max(0,Fl::event_x()-owner_.x()-owner_.header_width_))*960/static_cast(owner_.scale_)); + audio_preview_={track_,position,XTC_AUTOMATION_GAIN,audio_value(Fl::event_y()),0}; + audio_existing_=false; + size_t count=0; xtc_project_automation_count(owner_.project_,&count); int distance=12; + for(size_t i=0;i(a.tick*static_cast(owner_.scale_)/960U); + int py=y()+h()-8-static_cast(a.value*(h()-28)/4.0f); + int d=std::abs(Fl::event_x()-px)+std::abs(Fl::event_y()-py); + if(d(note.velocity)*(h()-28)/127; + int d=std::abs(Fl::event_x()-px)+std::abs(Fl::event_y()-ny); + if(d +#include "xtc/xtc_mixer.h" +#include "xtc/xtc_midi.h" +class SequencerWidget; +class SequencerVelocityWidget : public Fl_Widget { + SequencerWidget &owner_; + uint64_t track_; + xtc_note_info original_{}; + unsigned int preview_ = 100; + xtc_automation_point audio_preview_{}; + bool audio_existing_ = false; + bool note_position(const xtc_note_info ¬e, int &px) const; + unsigned int note_value(int py) const; + float audio_value(int py) const; + void draw_audio(const xtc_track_info &track, int left, int right); + public: + SequencerVelocityWidget(SequencerWidget &owner, uint64_t track); + bool audio() const; + bool preview_label(char *text, size_t size) const; + int handle(int event) override; + protected: + void draw() override; +}; +#endif diff --git a/src/gui/sequencer_waveforms.cpp b/src/gui/sequencer_waveforms.cpp new file mode 100644 index 0000000..52e108a --- /dev/null +++ b/src/gui/sequencer_waveforms.cpp @@ -0,0 +1,59 @@ +#include "sequencer_widget.hpp" +#include +#include +#include + +void SequencerWidget::refresh_waveforms() try +{ + // Control-thread loading only. Keep cached failures as well as successes; + // removed/relinked paths are evicted. Reopening rebuilds externally changed files. + size_t tracks, clips; + xtc_project_get_counts(project_, &tracks, &clips); + std::vector next; + for (size_t i = 0; i < clips && next.size() < 64; ++i) { + xtc_clip_info c{}; + xtc_project_get_clip(project_, i, &c); + if (!c.asset_path[0]) continue; + bool present = false; + for (const auto &wave : next) if (wave.path == c.asset_path) { present = true; break; } + if (present) continue; + Wave entry; + entry.path = c.asset_path; + for (auto &wave : waves_) if (wave.path == entry.path) { + entry.data = std::move(wave.data); present = true; break; + } + if (!present) { + xtc_waveform *summary = nullptr; + (void)xtc_waveform_create(c.asset_path, &summary); + entry.data.reset(summary); + } + next.push_back(std::move(entry)); + } + waves_ = std::move(next); +} catch (const std::bad_alloc &) { + waves_.clear(); // Preview failure must not interrupt the model/history edit. +} + +void SequencerWidget::draw_waveform(const xtc_clip_info &c, int left, int top) +{ + const xtc_waveform *summary = nullptr; + for (const auto &wave : waves_) if (wave.path == c.asset_path) { summary = wave.data.get(); break; } + if (!summary) return; + unsigned int rate; size_t frames, bins; + if (xtc_waveform_info(summary, &rate, &frames, &bins) != XTC_OK) return; + double ticks_per_frame = static_cast(xtc_project_tempo(project_)) * 960 / (rate * 60000.0); + fl_color(fl_rgb_color(30, 65, 80)); + for (size_t bin = 0; bin < bins; ++bin) { + double start = static_cast(bin * frames / bins) * ticks_per_frame; + double end = static_cast((bin+1) * frames / bins) * ticks_per_frame; + double offset = static_cast(c.offset_tick); + if (end <= offset || start >= offset + static_cast(c.length_tick)) continue; + int px = left + static_cast((std::max(start, offset) - offset) * scale_ / 960); + int width = std::max(1, static_cast((end - std::max(start, offset)) * scale_ / 960)); + float low, high; + if (xtc_waveform_peak(summary, bin, &low, &high) != XTC_OK) continue; + int y1 = top + 28 - static_cast(std::clamp(high, -1.0f, 1.0f) * 19); + int y2 = top + 28 - static_cast(std::clamp(low, -1.0f, 1.0f) * 19); + fl_rectf(px, y1, width, std::max(1, y2-y1+1)); + } +} diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 2b8fbcc..30d1336 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -1,43 +1,29 @@ -#include #include "sequencer_widget.hpp" +#include "sequencer_track_widget.hpp" +#include "sequencer_velocity_widget.hpp" +#include "sequencer_ruler_widget.hpp" +#include "sequencer_group_scope.hpp" #include "theme.hpp" #include "snap.hpp" -#include -#include "xtc/xtc_midi.h" #include #include #include #include #include #include -#include -#include #include -/** - * @brief Constructs the timeline widget with a borrowed project and seek callback. - * - * @param left Left coordinate in pixels. - * @param top Top coordinate in pixels. - * @param project Borrowed project; must outlive the widget's use of it. - * @param seek Callback used to request a transport seek. - * @param context Opaque callback context. - */ SequencerWidget::SequencerWidget(int left, int top, xtc_project *project, Seek seek, void *context) : Fl_Group(left, top, 6300, 3700), project_(project), seek_(seek), context_(context) { expanded_tracks_.fill(UINT64_MAX); resizable(nullptr); + track_widgets_.reserve(XTC_PROJECT_MAX_TRACKS); + ruler_=new SequencerRulerWidget(*this); extent(); end(); } -/** - * @brief Replaces the borrowed project and refreshes the widget's displayed state. - * - * @param project Borrowed project; must outlive the widget's use of it. - * @param reset Whether to clear selection, cursor, and drag state. - */ bool SequencerWidget::select(uint64_t track, uint64_t clip) { size_t tracks = 0, clips = 0; @@ -59,7 +45,7 @@ bool SequencerWidget::select(uint64_t track, uint64_t clip) void SequencerWidget::set_project(xtc_project *project, bool reset, bool preserve_expansion) { - if(project_ != project) mode_=0; + if(project_ != project) { mode_=0; active_velocity_=nullptr; } project_ = project; if (reset) { if(!preserve_expansion) expanded_tracks_.fill(UINT64_MAX); @@ -74,22 +60,18 @@ void SequencerWidget::set_project(xtc_project *project, bool reset, bool preserv redraw(); } -/** - * @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. - */ int SequencerWidget::header_left() const { auto *scroll=dynamic_cast(parent());return scroll ? std::max(x(),scroll->x()) : x(); } + void SequencerWidget::header_width(int width) { width=std::clamp(width,180,360);if(width==header_width_)return; auto *scroll=dynamic_cast(parent());int sx=scroll ? scroll->xposition() : 0,sy=scroll ? scroll->yposition() : 0; header_width_=width;extent();if(scroll)scroll->scroll_to(sx,sy);redraw(); } + uint64_t SequencerWidget::tick(int px) const { int64_t raw = static_cast(std::max(0,px-x()-header_width_))*960/scale_; @@ -144,75 +126,6 @@ int SequencerWidget::track_row(int py) const return -1; } -bool SequencerWidget::velocity_note(const xtc_note_info ¬e, uint64_t track, int &px) const -{ - xtc_clip_info c{}; - if(xtc_project_find_clip(project_,note.clip_id,&c)!=XTC_OK || c.track_id!=track || !c.midi || - note.start_tick=c.length_tick) return false; - px=x()+header_width_+static_cast((c.start_tick+note.start_tick-c.offset_tick)*static_cast(scale_)/960U); - return true; -} - -bool SequencerWidget::audio_velocity_lane(uint64_t track) const -{ - size_t tracks=0, clips=0; xtc_project_get_counts(project_,&tracks,&clips); - bool midi=false; - for(size_t i=0;i(velocity_top_+velocity_height_-8-py)*4.0f/(velocity_height_-28),0.0f,4.0f); -} - -void SequencerWidget::draw_audio_velocity(const xtc_track_info &track, int top, int left, int right) -{ - std::array points{}; - size_t count=0,total=0; xtc_project_automation_count(project_,&total); - for(size_t i=0;i(t*static_cast(scale_)/960U);}; - auto py=[&](float v){return top+velocity_height_-8-static_cast(v*(velocity_height_-28)/4.0f);}; - fl_color(xtc_ui::track(track.id)); - if(!count) fl_line(left,py(track.gain),right,py(track.gain)); - else { - fl_line(left,py(points[0].value),px(points[0].tick),py(points[0].value)); - for(size_t i=1;iright+4) continue; - fl_rectf(point_x-4,py(points[i].value)-3,8,6); - } - } -} - -unsigned int SequencerWidget::velocity_value(int py) const -{ - return static_cast(std::clamp((velocity_top_+velocity_height_-8-py)*127/(velocity_height_-28),1,127)); -} - -/** - * @brief Resizes the timeline's scrollable area to fit its tracks and clip ends. - */ void SequencerWidget::extent() { size_t tracks, clips; @@ -240,16 +153,12 @@ void SequencerWidget::extent() } size(static_cast(end * static_cast(scale_) / 960U + static_cast(header_width_+20)), std::max(400, track_top(tracks)-y()+72)); + sync_views(); refresh_instruments(); if (parent()) parent()->redraw(); } -/** - * @brief Reports an edit result, refreshes the widget, and notifies listeners on success. - * - * @param result Result of the attempted operation. - */ void SequencerWidget::changed(xtc_result result) { if (result != XTC_OK) @@ -262,96 +171,6 @@ void SequencerWidget::changed(xtc_result result) redraw(); } -/** - * @brief Creates and selects a track through the arrangement edit hook. - * @return XTC_OK on success, or an error code describing the failure. - */ -xtc_result SequencerWidget::add_track() -{ - if (before_edit) { - xtc_result r = before_edit(context_); - if (r != XTC_OK) { - changed(r); - return r; - } - } - uint64_t id; - xtc_result r = xtc_project_add_track(project_, "Track", &id); - if (r == XTC_OK) { - track_ = id; - clip_ = UINT64_MAX;selection_.clear(); - } - changed(r); - return r; -} - -/** - * @brief Creates and selects a one-bar silent region at the timeline cursor. - * @return XTC_OK on success, or an error code describing the failure. - */ -xtc_result SequencerWidget::add_region() -{ - if (before_edit) { - xtc_result r = before_edit(context_); - if (r != XTC_OK) { - changed(r); - return r; - } - } - size_t tracks, clips; - xtc_project_get_counts(project_, &tracks, &clips); - if (track_ == UINT64_MAX && tracks) { - xtc_track_info t{}; - xtc_project_get_track(project_, 0, &t); - track_ = t.id; - } - uint64_t id; - xtc_result r = xtc_project_add_clip(project_, track_, "Region (silent)", cursor_, new_clip_ticks, &id); - if (r == XTC_OK) {clip_ = id;selection_.clear();} - changed(r); - return r; -} - -/** - * @brief Creates and selects a one-bar MIDI clip at the timeline cursor. - * @return XTC_OK on success, or an error code describing the failure. - */ -xtc_result SequencerWidget::add_midi_clip() -{ - xtc_result r = before_edit ? before_edit(context_) : XTC_OK; - uint64_t id; - if (r == XTC_OK) { - if (track_ == UINT64_MAX) { - xtc_track_info t{}; - r = xtc_project_get_track(project_, 0, &t); - track_ = r == XTC_OK ? t.id : UINT64_MAX; - } - if (r == XTC_OK) - r = xtc_project_add_midi_clip(project_, track_, cursor_, new_clip_ticks, &id); - if (r == XTC_OK) {clip_ = id;selection_.clear();} - } - changed(r); - return r; -} - -/** - * @brief Removes the selected clip, or the selected track when no clip is selected. - * @return XTC_OK on success, or an error code describing the failure. - */ -xtc_result SequencerWidget::delete_selection() -{ - if(!selected_clips().empty())return transform_selection(XTC_CLIPS_DELETE,0); - xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=xtc_project_remove_track(project_,track_); - if(r==XTC_OK)track_=clip_=UINT64_MAX; - changed(r);return r; -} - -/** - * @brief Adjusts timeline zoom within its supported bounds and updates the scroll extent. - * - * @param direction Zoom direction; positive zooms in, nonpositive zooms out. - */ void SequencerWidget::zoom(int direction) { scale_ = std::clamp(direction > 0 ? scale_ * 2 : scale_ / 2, 24, 384); @@ -359,17 +178,21 @@ void SequencerWidget::zoom(int direction) redraw(); } -/** - * @brief Draws the widget's current model, selection, and interaction feedback. - */ +xtc_result SequencerWidget::set_zoom(int scale) +{ + if(scale!=24 && scale!=48 && scale!=96 && scale!=192 && scale!=384) return XTC_ERR_INVALID_ARGUMENT; + scale_=scale;extent();redraw();return XTC_OK; +} + void SequencerWidget::draw() { + sync_views(); fl_push_clip(x(),y(),w(),h()); int cx, cy, cw, ch; fl_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); fl_color(xtc_ui::background()); fl_rectf(cx,cy,cw,ch); - size_t tracks = 0, clips = 0, notes = 0; - xtc_project_get_counts(project_,&tracks,&clips); xtc_project_note_count(project_,¬es); + size_t tracks = 0, clips = 0; + xtc_project_get_counts(project_,&tracks,&clips); for (size_t i = 0; i < tracks; ++i) { int py = track_top(i); if (py+track_height(i) < cy || py > cy+ch) continue; @@ -388,75 +211,6 @@ void SequencerWidget::draw() 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()); } - first = std::max(0,(cx-x()-header_width_)/(scale_*4)); - last = (cx+cw-x()-header_width_)/(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()+header_width_+bar*scale_*4+6,y()+19); - } - for (size_t i = 0; i < tracks; ++i) { - xtc_track_info t{}; xtc_project_get_track(project_,i,&t); - int py = track_top(i); - if (py+track_height(i) < cy || py > cy+ch) continue; - Fl_Color color = xtc_ui::track(t.id); - for (size_t j = 0; j < clips; ++j) { - xtc_clip_info c{}; xtc_project_get_clip(project_,j,&c); - if (c.track_id != t.id) continue; - int left = x()+header_width_+static_cast(c.start_tick*static_cast(scale_)/960U); - int right = left+std::max(4,static_cast(c.length_tick*static_cast(scale_)/960U)); - int a = std::max(left,cx), b = std::min(right,cx+cw); - if (b <= a) continue; - fl_color(fl_color_average(color,xtc_ui::background(),clip_selected(c.id) ? .75f : .45f)); - fl_rectf(a,py+5,b-a,46); - fl_color(clip_selected(c.id) ? xtc_ui::text() : color); fl_rect(a,py+5,b-a,46); - fl_push_clip(a+1,py+6,std::max(0,b-a-2),44); - if (c.midi) { - /* Real note positions, pitch and duration; no placeholder waveform. */ - unsigned int low = 127, high = 0; - for (size_t n = 0; n < notes; ++n) { - xtc_note_info note{}; xtc_project_get_note(project_,n,¬e); - if (note.clip_id == c.id) { low = std::min(low,note.pitch); high = std::max(high,note.pitch); } - } - unsigned int range = high >= low ? std::max(12U,high-low) : 12U; - fl_color(fl_color_average(color,FL_WHITE,.55f)); - for (size_t n = 0; n < notes; ++n) { - xtc_note_info note{}; xtc_project_get_note(project_,n,¬e); - if (note.clip_id != c.id) continue; - uint64_t begin = std::max(note.start_tick,c.offset_tick); - uint64_t end = std::min(note.start_tick+note.length_tick,c.offset_tick+c.length_tick); - if (begin >= end) continue; - int nx = left+static_cast((begin-c.offset_tick)*static_cast(scale_)/960U); - int nw = std::max(2,static_cast((end-begin)*static_cast(scale_)/960U)); - fl_rectf(nx,py+26+static_cast((high-note.pitch)*18/range),nw,2); - } - } else draw_waveform(c,left,py); - fl_color(fl_color_average(color,xtc_ui::background(),.35f)); fl_rectf(a+1,py+6,std::max(0,b-a-2),15); - fl_font(FL_HELVETICA,11); fl_color(xtc_ui::text()); - fl_draw(c.name,left+7,py+18); - fl_pop_clip(); - } - } - for(size_t i=0;i(value)*(velocity_height_-28)/127; - fl_color(mode_==6 && velocity_original_.id==note.id ? xtc_ui::text() : xtc_ui::track(t.id)); - fl_rectf(px-2,ny,4,bottom-ny); fl_rectf(px-4,ny-2,8,4); - } - fl_pop_clip(); - } if (!tracks) { fl_font(FL_HELVETICA_BOLD,18); fl_color(xtc_ui::text()); fl_draw("Your next track starts here",x()+220,y()+105); @@ -464,111 +218,28 @@ void SequencerWidget::draw() fl_draw("Create a MIDI track or import audio from the panel on the left.",x()+220,y()+135); fl_draw("Arrange clips here. Open the piano roll to draw and edit MIDI notes.",x()+220,y()+161); } - { - uint64_t begin=mode_==4 ? std::min(loop_anchor_,loop_preview_) : loop_begin_; - uint64_t end=mode_==4 ? std::max(loop_anchor_,loop_preview_) : loop_end_; - int left=x()+header_width_+static_cast(begin*static_cast(scale_)/960); - int right=x()+header_width_+static_cast(end*static_cast(scale_)/960); - fl_color(loop_enabled_ ? xtc_ui::accent() : xtc_ui::muted()); - fl_rectf(left,y()+23,std::max(0,right-left),4); - } + draw_children(); size_t markers=0;xtc_project_marker_count(project_,&markers); for(size_t i=0;i(marker.tick*static_cast(scale_)/960U); - if(pxcx+cw)continue; + if(pxcx+cw)continue; fl_color(xtc_ui::accent());fl_line(px,y()+24,px,y()+h()); - fl_push_clip(px,y(),160,12);fl_font(FL_HELVETICA,10);fl_draw(marker.name,px+3,y()+10);fl_pop_clip(); } int playhead = x()+header_width_+static_cast(cursor_*static_cast(scale_)/960U); - if (playhead >= cx && playhead <= cx+cw) { + if (playhead >= std::max(cx,header_left()+header_width_) && playhead <= cx+cw) { fl_color(xtc_ui::accent()); fl_line(playhead,y(),playhead,y()+h()); - fl_polygon(playhead-4,y(),playhead+4,y(),playhead,y()+6); } - int pinned=header_left(); - fl_color(xtc_ui::background());fl_rectf(pinned,y(),header_width_,28); - fl_color(xtc_ui::muted());fl_draw("TRACKS / drag edge to resize",pinned+10,y()+19); - for(size_t i=0;icy+ch)continue; - Fl_Color color = xtc_ui::track(t.id); - fl_color(t.id == track_ ? fl_rgb_color(49,62,70) : xtc_ui::panel()); - fl_rectf(pinned,py,header_width_-2,track_height(i)-1); - fl_color(color); fl_rectf(pinned,py,3,track_height(i)-1); - fl_push_clip(pinned+9,py,header_width_-18,55); - fl_color(xtc_ui::text()); fl_font(FL_HELVETICA_BOLD,12); - if(track_expanded(t.id)) fl_polygon(pinned+10,py+10,pinned+20,py+10,pinned+15,py+16); - else fl_polygon(pinned+12,py+8,pinned+12,py+18,pinned+18,py+13); - fl_draw(t.name,pinned+26,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" : ""); - if (t.is_bus) { fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(detail,pinned+12,py+42); } - if (t.mute || t.solo) { fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(t.mute ? "M" : "S",pinned+header_width_-16,py+18); } - fl_pop_clip(); - if(track_expanded(t.id)) { - fl_color(xtc_ui::muted()); fl_font(FL_HELVETICA,11); - bool audio=audio_velocity_lane(t.id); - fl_draw(audio ? "AUDIO VELOCITY" : "VELOCITY 1..127",pinned+12,py+76); - fl_font(FL_HELVETICA,10); - fl_draw(audio ? "Level envelope 0..400%" : "Drag bars to edit notes",pinned+12,py+94); - if(mode_==7 && t.id==track_) { - char value[32]; std::snprintf(value,sizeof(value),"Level: %.0f%%",audio_velocity_preview_.value*100); - fl_draw(value,pinned+12,py+114); - } - if(mode_==6 && t.id==track_) { - char value[24]; std::snprintf(value,sizeof(value),"Velocity: %u",velocity_preview_); - fl_draw(value,pinned+12,py+114); - } - } - } - fl_color(xtc_ui::muted());fl_rectf(pinned+header_width_-3,cy,3,ch); - for(size_t i=0;iresize(pinned+10,track_top(i)+30,header_width_-22,20); - draw_children(); + fl_color(xtc_ui::muted()); fl_rectf(header_left()+header_width_-3,cy,3,ch); fl_pop_clip(); } -/** - * @brief Handles FLTK input events and delegates unhandled events to the base widget. - * - * @param event FLTK event code. - * @return Nonzero if the event was handled, zero otherwise. - */ int SequencerWidget::handle(int event) { - if(mode_==7) { - if(event==FL_KEYDOWN && Fl::event_key()==FL_Escape) { mode_=0; redraw(); return 1; } - if(event==FL_DRAG) { audio_velocity_preview_.value=audio_velocity_value(Fl::event_y()); redraw(); return 1; } - if(event==FL_RELEASE) { - mode_=0; - auto point=audio_velocity_preview_; - point.value=audio_velocity_value(Fl::event_y()); - if(audio_velocity_existing_) { - size_t count=0; xtc_project_automation_count(project_,&count); - for(size_t i=0;ihandle(event); + if(mode_==4 && (event==FL_DRAG || event==FL_RELEASE)) return ruler_->handle(event); if(event==FL_PUSH && Fl::event_button()==FL_LEFT_MOUSE && Fl::event_x()>=header_left()+header_width_-5 && Fl::event_x()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); + for(auto *track:track_widgets_) { + auto *choice=track->instrument_choice(); + if(((event==FL_DRAG || event==FL_RELEASE) && Fl::pushed()==choice) || + (event==FL_KEYDOWN && Fl::focus()==choice)) return choice->handle(event); } - if (event == FL_PUSH && (Fl::event_button() == FL_LEFT_MOUSE || Fl::event_button() == FL_RIGHT_MOUSE)) { - take_focus(); - mode_ = 0; - if (Fl::event_y() < y() + 28 && Fl::event_x() >= header_left()+header_width_ && - Fl::event_button()==FL_LEFT_MOUSE && Fl::event_state(FL_SHIFT)) { - loop_anchor_=loop_preview_=tick(Fl::event_x()); mode_=4; redraw(); return 1; - } - if (Fl::event_y() < y() + 28) { - if(Fl::event_x()(row) >= tracks) - return 1; - xtc_track_info t{}; - xtc_project_get_track(project_, static_cast(row), &t); - if(Fl::event_button()==FL_LEFT_MOUSE && Fl::event_x()>=header_left()+7 && - Fl::event_x()(row))+26) { - expand_track(t.id,!track_expanded(t.id)); return 1; - } - if(Fl::event_y()>=track_top(static_cast(row))+56) { - if(Fl::event_x()(row))+56; - if(audio_velocity_lane(t.id)) { - track_=t.id; clip_=UINT64_MAX; selection_.clear(); - uint64_t position=std::min(XTC_PROJECT_MAX_TICK,static_cast(std::max(0,Fl::event_x()-x()-header_width_))*960/static_cast(scale_)); - audio_velocity_preview_={t.id,position,XTC_AUTOMATION_GAIN,audio_velocity_value(Fl::event_y()),0}; - audio_velocity_existing_=false; - size_t count=0; xtc_project_automation_count(project_,&count); int distance=12; - for(size_t i=0;i(a.tick*static_cast(scale_)/960U); - int py=velocity_top_+velocity_height_-8-static_cast(a.value*(velocity_height_-28)/4.0f); - int d=std::abs(Fl::event_x()-px)+std::abs(Fl::event_y()-py); - if(d(row))+56; - for(size_t n=0;n(note.velocity)*(velocity_height_-28)/127; - int d=std::abs(Fl::event_x()-px)+std::abs(Fl::event_y()-ny); - if(d 0 && Fl::event_x()>=header_left()+header_width_; --j) { - xtc_clip_info c{}; - xtc_project_get_clip(project_, j - 1, &c); - int left = x() + header_width_ + static_cast(c.start_tick * static_cast(scale_) / 960U); - int right = left + std::max(4, static_cast(c.length_tick * static_cast(scale_) / 960U)); - if (c.track_id == track_ && Fl::event_x() >= left && Fl::event_x() < right) { - clip_ = c.id; - original_ = c; - drag_x_ = Fl::event_x(); - mode_ = Fl::event_x() - left < 7 ? 2 : right - Fl::event_x() < 7 ? 3 : 1; - break; - } - } - if (Fl::event_state(FL_SHIFT) && clip_ != UINT64_MAX) { - uint64_t hit=clip_;clip_=previous_clip;toggle_clip(hit);mode_=0;return 1; - } - selection_.clear(); - if (Fl::event_button() == FL_LEFT_MOUSE && Fl::event_clicks() && clip_ != UINT64_MAX) { - mode_ = 0; - if (open_clip) open_clip(context_); - return 1; - } - if (Fl::event_button() == FL_RIGHT_MOUSE) { - mode_ = 0; - Fl_Menu_Item items[6]{}; - items[0].text = "Duplicate clip (Ctrl+D)"; items[1].text = "Rename (F2)"; items[2].text = "Delete (undoable)"; - items[3].text = "Split clip at cursor (S)"; items[4].text = "Audio gain and fades (A)"; - const Fl_Menu_Item *picked = items->popup(Fl::event_x(),Fl::event_y()); - if (picked == items) duplicate_selection(); - else if (picked == items+1) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); } - else if (picked == items+2) delete_selection(); - else if (picked == items+3) split_selection(); - else if (picked == items+4) audio_properties_dialog(); - } - redraw(); + if(event==FL_PUSH) { + sync_views(); + if(Fl::event_y()handle(event); + mode_=0; + Fl_Group::handle(event); return 1; } - if (event==FL_DRAG && mode_==4) {loop_preview_=tick(Fl::event_x());redraw();return 1;} - if (event==FL_RELEASE && mode_==4) { - loop_preview_=tick(Fl::event_x()); mode_=0; - uint64_t begin=std::min(loop_anchor_,loop_preview_),end=std::max(loop_anchor_,loop_preview_); - if(end>begin && loop_select) { - xtc_result r=loop_select(context_,begin,end); - if(r!=XTC_OK) fl_alert("Loop unchanged: %s",xtc_result_string(r)); - } - redraw();return 1; - } if (event == FL_DRAG) return mode_ != 0; if (event == FL_RELEASE && mode_) { @@ -739,6 +295,9 @@ int SequencerWidget::handle(int event) static_cast(length), static_cast(offset))); return 1; } + // A child may forward a release after its gesture was cancelled. Do not + // send that release back through Fl_Group to the same captured child. + if(event==FL_RELEASE) return 0; if (event == FL_KEYDOWN && !Fl::event_state(FL_CTRL | FL_ALT)) { if (Fl::event_key() == 's') { split_selection(); return 1; } if (Fl::event_key() == 'a') { audio_properties_dialog(); return 1; } @@ -766,432 +325,50 @@ int SequencerWidget::handle(int event) return Fl_Group::handle(event); } -xtc_result SequencerWidget::merge_midi() +void SequencerWidget::context_menu() { - auto ids=selected_clips();uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=xtc_project_merge_midi(project_,ids.data(),ids.size(),&id); - if(r==XTC_OK){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);select(c.track_id,id);} - changed(r);return r; -} -xtc_result SequencerWidget::duplicate_track(const char *name) -{ - const uint64_t source=track_;uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=xtc_project_duplicate_track(project_,source,name,&id); - if(r==XTC_OK)select(id); - changed(r);return r; -} -void SequencerWidget::duplicate_track_dialog() -{ - const char *name=fl_input("Name for complete track duplicate","Track copy");if(name)duplicate_track(name); -} -xtc_result SequencerWidget::ripple_time(uint64_t length,bool remove) -{ - const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=xtc_project_ripple_time(project_,at,length,remove); - changed(r);return r; -} -void SequencerWidget::ripple_dialog(bool remove) -{ - const char *input=fl_input(remove ? "Remove time from cursor: ticks (split crossing clips first)" : "Insert time at cursor: ticks (split crossing clips first)","3840");if(!input)return; - uint64_t length=0;char extra;std::istringstream values(input); - if(!(values>>length) || (values>>extra) || !length || length>XTC_PROJECT_MAX_TICK){fl_alert("Enter a positive bounded tick duration.");return;} - ripple_time(length,remove); -} -xtc_result SequencerWidget::marker(const char *name) -{ - const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=name ? xtc_project_set_marker(project_,at,name) : xtc_project_remove_marker(project_,at); - changed(r);return r; -} -void SequencerWidget::marker_dialog() -{ - const char *name=fl_input("Marker at cursor (1..63 UTF-8 bytes)","Section");if(name)marker(name); -} -std::vector SequencerWidget::selected_clips() const -{ - std::vector ids;xtc_clip_info c{}; - for(auto id:selection_)if(xtc_project_find_clip(project_,id,&c)==XTC_OK)ids.push_back(id); - if(ids.empty() && clip_!=UINT64_MAX && xtc_project_find_clip(project_,clip_,&c)==XTC_OK)ids.push_back(clip_); - return ids; -} -bool SequencerWidget::clip_selected(uint64_t id) const -{ - return selection_.empty() ? id==clip_ : std::find(selection_.begin(),selection_.end(),id)!=selection_.end(); -} -bool SequencerWidget::toggle_clip(uint64_t id) -{ - xtc_clip_info c{};if(xtc_project_find_clip(project_,id,&c)!=XTC_OK)return false; - if(selection_.empty() && clip_!=UINT64_MAX)selection_.push_back(clip_); - auto found=std::find(selection_.begin(),selection_.end(),id); - if(found==selection_.end())selection_.push_back(id);else selection_.erase(found); - clip_=selection_.empty() ? UINT64_MAX : selection_.back(); - if(clip_!=UINT64_MAX)xtc_project_find_clip(project_,clip_,&c); - track_=c.track_id;redraw();return true; -} -void SequencerWidget::select_all_clips() -{ - selection_.clear();size_t tracks=0,clips=0;xtc_project_get_counts(project_,&tracks,&clips); - for(size_t i=0;i output(ids.size()); - xtc_result r=ids.empty() ? XTC_ERR_NOT_FOUND : XTC_OK; - if(r==XTC_OK && before_edit)r=before_edit(context_); - if(r==XTC_OK)r=xtc_project_transform_clips(project_,ids.data(),ids.size(),op,amount,output.data()); - if(r==XTC_OK) { - selection_=op==XTC_CLIPS_DELETE ? std::vector{} : output; - clip_=selection_.empty() ? UINT64_MAX : selection_.back(); - } - changed(r);return r; -} -xtc_result SequencerWidget::transform_midi(const xtc_midi_transform &edit) -{ - auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=xtc_project_transform_midi(project_,ids.data(),ids.size(),&edit); - changed(r);return r; -} -void SequencerWidget::fades_dialog() -{ - const char *input=fl_input("Symmetric fade length in ticks (0 removes; at most half each clip)","10");if(!input)return; - int64_t ticks=0;char extra;std::istringstream values(input); - if(!(values>>ticks) || (values>>extra) || ticks<0 || ticks>static_cast(XTC_PROJECT_MAX_TICK)){fl_alert("Enter a valid nonnegative tick length.");return;} - transform_selection(XTC_CLIPS_FADE,ticks); -} -xtc_result SequencerWidget::make_chord(const std::vector &intervals) -{ - auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=xtc_project_chordify(project_,ids.data(),ids.size(),intervals.data(),intervals.size()); - changed(r);return r; -} -xtc_result SequencerWidget::arpeggiate(uint64_t step,unsigned int gate,bool descending) -{ - auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=xtc_project_arpeggiate(project_,ids.data(),ids.size(),step,gate,descending); - changed(r);return r; -} -xtc_result SequencerWidget::normalize_audio(float target) -{ - auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=xtc_project_normalize_audio(project_,ids.data(),ids.size(),target); - changed(r);return r; -} -void SequencerWidget::normalize_dialog() -{ - const char *input=fl_input("Source sample peak target in dBFS (-60..0; excludes fades and mixer)","-1");if(!input)return; - float db=0;char extra;std::istringstream values(input); - if(!(values>>db) || (values>>extra) || !std::isfinite(db) || db < -60 || db>0){fl_alert("Enter a finite sample peak target from -60 to 0 dBFS.");return;} - normalize_audio(std::pow(10.0f,db/20.0f)); -} -void SequencerWidget::arpeggio_dialog() -{ - const char *input=fl_input("Step ticks (1..3840), gate percent (1..100), direction (0=up, 1=down)","240 80 0");if(!input)return; - uint64_t step=0;unsigned int gate=0;int down=0;char extra;std::istringstream values(input); - if(!(values>>step>>gate>>down) || (values>>extra) || !step || step>3840 || !gate || gate>100 || down<0 || down>1){fl_alert("Enter a valid step, gate and direction.");return;} - arpeggiate(step,gate,down!=0); -} -void SequencerWidget::chord_dialog() -{ - const char *input=fl_input("Additional semitone intervals: 1..8 unique nonzero values (-127..127)","4 7");if(!input)return; - std::istringstream values(input);std::vector intervals;int interval; - while(values>>interval){intervals.push_back(interval);if(intervals.size()>8)break;} - if(!values.eof() || intervals.empty() || intervals.size()>8){fl_alert("Enter 1 to 8 semitone intervals.");return;} - make_chord(intervals); -} -void SequencerWidget::humanize_dialog() -{ - const char *input=fl_input("Timing variation ticks (0..960), velocity variation (0..127), seed","12 8 1");if(!input)return; - unsigned int timing=0,velocity=0,seed=0;char extra;std::istringstream values(input); - if(!(values>>timing>>velocity>>seed) || (values>>extra) || timing>960 || velocity>127){fl_alert("Enter bounded timing/velocity variation and a 32-bit seed.");return;} - xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_HUMANIZE;edit.timing_variation=timing;edit.velocity_variation=velocity;edit.seed=seed;transform_midi(edit); -} -void SequencerWidget::velocity_dialog() -{ - const char *input=fl_input("Velocity percentage (0..400), then offset (-127..127)","100 0");if(!input)return; - unsigned int percent=0;int offset=0;char extra;std::istringstream values(input); - if(!(values>>percent>>offset) || (values>>extra) || percent>400 || offset < -127 || offset>127){fl_alert("Enter a valid percentage and velocity offset.");return;} - xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_VELOCITY;edit.velocity_percent=percent;edit.velocity_offset=offset;transform_midi(edit); -} -void SequencerWidget::scale_dialog() -{ - const char *input=fl_input("Root (0=C..11=B), scale (0=chromatic, 1=major, 2=natural minor)","0 1");if(!input)return; - unsigned int root=0,scale=0;char extra;std::istringstream values(input); - if(!(values>>root>>scale) || (values>>extra) || root>11 || scale>2){fl_alert("Enter a root from 0 to 11 and scale from 0 to 2.");return;} - xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SCALE;edit.root=root;edit.scale=scale;transform_midi(edit); -} -void SequencerWidget::gate_dialog() -{ - const char *input=fl_input("Note length percentage (1..400)","50");if(!input)return; - unsigned int percent=0;char extra;std::istringstream values(input); - if(!(values>>percent) || (values>>extra) || percent<1 || percent>400){fl_alert("Enter a whole percentage from 1 to 400.");return;} - xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_GATE;edit.gate_percent=percent;transform_midi(edit); -} -void SequencerWidget::swing_dialog() -{ - const char *input=fl_input("Grid ticks (1..3840), swing (0..100), strength (0..100)","240 50 100");if(!input)return; - int grid=0,swing=-1,strength=-1;char extra; - std::istringstream values(input); - if(!(values>>grid>>swing>>strength) || (values>>extra) || grid<1 || grid>3840 || swing<0 || swing>100 || strength<0 || strength>100){fl_alert("Enter a valid grid, swing and strength.");return;} - xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SWING;edit.grid=static_cast(grid);edit.swing=static_cast(swing);edit.strength=static_cast(strength);transform_midi(edit); -} -xtc_result SequencerWidget::repeat_selection(unsigned int copies) -{ - auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; - if(r==XTC_OK)r=xtc_project_repeat_clips(project_,ids.data(),ids.size(),copies); - changed(r);return r; -} -void SequencerWidget::repeat_dialog() -{ - const char *input=fl_input("Additional phrase copies (1..1024)","3");if(!input)return; - char *end=nullptr;unsigned long copies=std::strtoul(input,&end,10); - if(end==input || *end || copies<1 || copies>1024){fl_alert("Enter a whole number from 1 to 1024.");return;} - repeat_selection(static_cast(copies)); -} -xtc_result SequencerWidget::copy_selection() -{ - auto ids=selected_clips();xtc_clipboard *copy=nullptr; - xtc_result r=xtc_clipboard_create(project_,ids.data(),ids.size(),©); - if(r==XTC_OK)clipboard_.reset(copy); - else fl_alert("Copy unchanged: %s",xtc_result_string(r)); - return r; -} -xtc_result SequencerWidget::paste_selection() -{ - const uint64_t start=cursor_,track=track_; - std::vector output(xtc_clipboard_count(clipboard_.get())); - xtc_result r=output.empty() ? XTC_ERR_INVALID_STATE : XTC_OK; - if(r==XTC_OK && before_edit)r=before_edit(context_); - if(r==XTC_OK)r=xtc_clipboard_paste(clipboard_.get(),project_,track,start,output.data()); - if(r==XTC_OK){selection_=output;clip_=selection_.back();xtc_clip_info c{};xtc_project_find_clip(project_,clip_,&c);track_=c.track_id;} - changed(r);return r; -} -xtc_result SequencerWidget::nudge_selection(int direction) -{ - return transform_selection(XTC_CLIPS_MOVE,direction<0 ? -static_cast(grid_) : static_cast(grid_)); -} -xtc_result SequencerWidget::duplicate_selection() -{ - auto ids=selected_clips();uint64_t begin=XTC_PROJECT_MAX_TICK,end=0; - for(auto id:ids){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);begin=std::min(begin,c.start_tick);end=std::max(end,c.start_tick+c.length_tick);} - return transform_selection(XTC_CLIPS_DUPLICATE,static_cast(end)-static_cast(begin)); -} -xtc_result SequencerWidget::rename_selection(const char *name) -{ - xtc_result r = before_edit ? before_edit(context_) : XTC_OK; - if (r == XTC_OK) r = clip_ != UINT64_MAX ? xtc_project_rename_clip(project_,clip_,name) : xtc_project_rename_track(project_,track_,name); - changed(r); return r; -} -xtc_result SequencerWidget::move_track(int direction) -{ - size_t tracks, clips, i; xtc_project_get_counts(project_,&tracks,&clips); - xtc_track_info track{}; - for (i = 0; i < tracks; ++i) { xtc_project_get_track(project_,i,&track); if (track.id == track_) break; } - if (i == tracks || (direction < 0 ? i == 0 : i+1 == tracks)) return XTC_ERR_NOT_FOUND; - xtc_result r = before_edit ? before_edit(context_) : XTC_OK; - if (r == XTC_OK) r = xtc_project_move_track(project_,track_,direction < 0 ? i-1 : i+1); - changed(r); return r; + Fl_Menu_Item items[6]{}; + items[0].text = "Duplicate clip (Ctrl+D)"; items[1].text = "Rename (F2)"; items[2].text = "Delete (undoable)"; + items[3].text = "Split clip at cursor (S)"; items[4].text = "Audio gain and fades (A)"; + const Fl_Menu_Item *picked = items->popup(Fl::event_x(),Fl::event_y()); + if (picked == items) duplicate_selection(); + else if (picked == items+1) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); } + else if (picked == items+2) delete_selection(); + else if (picked == items+3) split_selection(); + else if (picked == items+4) audio_properties_dialog(); } -xtc_result SequencerWidget::split_selection() +SequencerTrackWidget *SequencerWidget::track_widget(uint64_t track) const { - uint64_t right; - const uint64_t split_tick = cursor_; // Stop/status refresh may move the playhead. - xtc_result result = before_edit ? before_edit(context_) : XTC_OK; - if (result == XTC_OK) { - xtc_clip_info c{};result=xtc_project_find_clip(project_,clip_,&c); - if(result==XTC_OK)result=c.midi ? xtc_project_split_midi(project_,clip_,split_tick,&right) : xtc_project_split_audio(project_,clip_,split_tick,&right); - } - if (result == XTC_OK) {clip_ = right;selection_.clear();} - changed(result); - return result; -} - -xtc_result SequencerWidget::audio_properties(float gain, uint64_t in, uint64_t out) -{ - xtc_result result = before_edit ? before_edit(context_) : XTC_OK; - if (result == XTC_OK) result = xtc_project_set_clip_audio(project_, clip_, gain, in, out); - changed(result); - return result; -} - -void SequencerWidget::audio_properties_dialog() -{ - xtc_clip_info clip{}; - if (xtc_project_find_clip(project_, clip_, &clip) != XTC_OK || clip.midi) { - fl_alert("Select an audio clip or silent region first."); - return; - } - char defaults[128]; - std::snprintf(defaults, sizeof(defaults), "%.9g %llu %llu", static_cast(clip.gain), - static_cast(clip.fade_in_tick), static_cast(clip.fade_out_tick)); - const char *text = fl_input("Linear gain (0..4), fade-in ticks, fade-out ticks (0 disables)", defaults); - if (!text) return; - std::istringstream input(text); - float gain; uint64_t in, out; - if (!(input >> gain >> in >> out) || !(input >> std::ws).eof()) { - fl_alert("Enter gain and two fade durations in ticks."); return; - } - audio_properties(gain, in, out); -} - -void SequencerWidget::refresh_waveforms() try -{ - // Control-thread loading only. Keep cached failures as well as successes; - // removed/relinked paths are evicted. Reopening rebuilds externally changed files. - size_t tracks, clips; - xtc_project_get_counts(project_, &tracks, &clips); - std::vector next; - for (size_t i = 0; i < clips && next.size() < 64; ++i) { - xtc_clip_info c{}; - xtc_project_get_clip(project_, i, &c); - if (!c.asset_path[0]) continue; - bool present = false; - for (const auto &wave : next) if (wave.path == c.asset_path) { present = true; break; } - if (present) continue; - Wave entry; - entry.path = c.asset_path; - for (auto &wave : waves_) if (wave.path == entry.path) { - entry.data = std::move(wave.data); present = true; break; - } - if (!present) { - xtc_waveform *summary = nullptr; - (void)xtc_waveform_create(c.asset_path, &summary); - entry.data.reset(summary); - } - next.push_back(std::move(entry)); - } - waves_ = std::move(next); -} catch (const std::bad_alloc &) { - waves_.clear(); // Preview failure must not interrupt the model/history edit. -} - -void SequencerWidget::draw_waveform(const xtc_clip_info &c, int left, int top) -{ - const xtc_waveform *summary = nullptr; - for (const auto &wave : waves_) if (wave.path == c.asset_path) { summary = wave.data.get(); break; } - if (!summary) return; - unsigned int rate; size_t frames, bins; - if (xtc_waveform_info(summary, &rate, &frames, &bins) != XTC_OK) return; - double ticks_per_frame = static_cast(xtc_project_tempo(project_)) * 960 / (rate * 60000.0); - fl_color(fl_rgb_color(30, 65, 80)); - for (size_t bin = 0; bin < bins; ++bin) { - double start = static_cast(bin * frames / bins) * ticks_per_frame; - double end = static_cast((bin+1) * frames / bins) * ticks_per_frame; - double offset = static_cast(c.offset_tick); - if (end <= offset || start >= offset + static_cast(c.length_tick)) continue; - int px = left + static_cast((std::max(start, offset) - offset) * scale_ / 960); - int width = std::max(1, static_cast((end - std::max(start, offset)) * scale_ / 960)); - float low, high; - if (xtc_waveform_peak(summary, bin, &low, &high) != XTC_OK) continue; - int y1 = top + 28 - static_cast(std::clamp(high, -1.0f, 1.0f) * 19); - int y2 = top + 28 - static_cast(std::clamp(low, -1.0f, 1.0f) * 19); - 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; + for(auto *view:track_widgets_) if(view->track_id()==track) return view; return nullptr; } -void SequencerWidget::refresh_instruments() try -{ - size_t tracks = 0, clips = 0; - xtc_project_get_counts(project_,&tracks,&clips); - catalog_.clear(); - for (unsigned int i=0;i<2;++i) { xtc_plugin_spec s{}; if (xtc_plugin_probe(XTC_BUILTIN_PATH,i,48000,&s)==XTC_OK) catalog_.push_back(s); } - 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(header_left()+10,track_top(i)+30,header_width_-22,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(header_left()+10,track_top(i)+30,header_width_-22,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; - } -} -xtc_result SequencerWidget::set_zoom(int scale) +void SequencerWidget::sync_views() try { - if(scale!=24 && scale!=48 && scale!=96 && scale!=192 && scale!=384) return XTC_ERR_INVALID_ARGUMENT; - scale_=scale;extent();redraw();return XTC_OK; -} - -xtc_result SequencerWidget::crossfade_selection() -{ - auto ids=selected_clips();xtc_result r=ids.size()==2 ? XTC_OK : XTC_ERR_INVALID_ARGUMENT; - if(r==XTC_OK && before_edit)r=before_edit(context_); - if(r==XTC_OK)r=xtc_project_crossfade(project_,ids[0],ids[1]); - changed(r);return r; -} - -xtc_result SequencerWidget::strum_chords(uint64_t spacing,bool descending) -{ - auto ids=selected_clips();xtc_result r=XTC_OK; - if(before_edit)r=before_edit(context_); - if(r==XTC_OK)r=xtc_project_strum(project_,ids.data(),ids.size(),spacing,descending ? 1 : 0); - changed(r);return r; -} -void SequencerWidget::strum_dialog() -{ - const char *input=fl_input("Spacing ticks (1..960), direction (0=up, 1=down)","30 0");if(!input)return; - uint64_t spacing=0;int down=0;char extra;std::istringstream values(input); - if(!(values>>spacing>>down) || (values>>extra) || !spacing || spacing>960 || down<0 || down>1){fl_alert("Enter a valid spacing and direction.");return;} - strum_chords(spacing,down!=0); + size_t tracks=0,clips=0; xtc_project_get_counts(project_,&tracks,&clips); + ruler_->resize(x(),y(),w(),28); + for(auto it=track_widgets_.begin();it!=track_widgets_.end();) { + bool found=false; + for(size_t i=0;itrack_id()) { found=true; break; } + } + if(!found) { + auto *removed=*it; it=track_widgets_.erase(it); + if(active_velocity_==removed->velocity_lane()) { active_velocity_=nullptr; mode_=0; } + removed->hide(); remove(removed); Fl::delete_widget(removed); + } else ++it; + } + SequencerGroupScope scope(this); + for(size_t i=0;isync(i,t); view->show(); + } +} catch(const std::bad_alloc &) { + // Rendering allocation failure must not invalidate an accepted C model edit. + active_velocity_=nullptr; mode_=0; + for(auto *view:track_widgets_) view->hide(); } diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index aade3bb..3f6b4c6 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -11,6 +11,12 @@ #include #include +class SequencerTrackWidget; +class SequencerTrackHeader; +class SequencerClipWidget; +class SequencerVelocityWidget; +class SequencerRulerWidget; + class SequencerWidget : public Fl_Group { public: /** @@ -32,6 +38,7 @@ class SequencerWidget : public Fl_Group { OpenClip open_clip = nullptr; InstrumentSelect instrument_select = nullptr; Fl_Choice *instrument_choice(uint64_t track) const; + SequencerTrackWidget *track_widget(uint64_t track) const; void refresh_instruments(); bool track_expanded(uint64_t track) const; bool expand_track(uint64_t track, bool expanded); @@ -202,18 +209,16 @@ class SequencerWidget : public Fl_Group { std::array expanded_tracks_{}; int track_height(size_t row) const; int track_row(int py) const; - bool velocity_note(const xtc_note_info ¬e, uint64_t track, int &px) const; - unsigned int velocity_value(int py) const; - xtc_note_info velocity_original_{}; - unsigned int velocity_preview_ = 100; - int velocity_top_ = 0; - bool audio_velocity_lane(uint64_t track) const; - void draw_audio_velocity(const xtc_track_info &track, int top, int left, int right); - float audio_velocity_value(int py) const; - xtc_automation_point audio_velocity_preview_{}; - bool audio_velocity_existing_ = false; - struct TrackChoice { Fl_Choice *widget = nullptr; uint64_t track = UINT64_MAX; }; - std::array track_choices_{}; + friend class SequencerTrackWidget; + friend class SequencerTrackHeader; + friend class SequencerClipWidget; + friend class SequencerVelocityWidget; + friend class SequencerRulerWidget; + std::vector track_widgets_; // FLTK owns children. + SequencerRulerWidget *ruler_ = nullptr; + SequencerVelocityWidget *active_velocity_ = nullptr; // Captured gesture, not ownership. + void sync_views(); + void context_menu(); std::vector catalog_; static void instrument_callback(Fl_Widget *, void *); struct Wave { diff --git a/tests/integration/README.md b/tests/integration/README.md index 7d76fcb..842ad19 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -35,6 +35,25 @@ port/callback setup failure, activation/deactivation/close failure, null buffers invalid configuration, oversize buffers, and reopen are tested with injected JACK callbacks/results in `tests/unit/test_backend.c`. +## Sequencer component regression checks + +`test_gui` verifies the actual FLTK hierarchy: a master sequencer owns its ruler +and track groups; tracks own clips, velocity lanes and header groups; headers +own instrument selectors. It checks view identity through track reordering and +undo, child membership after cross-track moves, deferred deletion and project +replacement during an active gesture. Clip and MIDI velocity gestures also run +through `Fl::handle` at the window boundary to exercise native event dispatch +and capture, alongside the existing direct-widget gesture tests. + +```sh +cmake --build build --target test_gui +ctest --test-dir build -R '^gui-sequencer$' --output-on-failure +ctest --test-dir build -R '^gui$' --output-on-failure +``` + +Run GUI variants sequentially (`ctest --test-dir build -L gui -j 1`) because +several existing workflows use the same fixed temporary project filenames. + ## FLTK shell tests ```sh diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 96bf2fe..18d6434 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -2,6 +2,10 @@ #include "../../src/gui/sample_editor.hpp" #include "../../src/gui/graph_widget.hpp" #include "../../src/gui/sequencer_widget.hpp" +#include "../../src/gui/sequencer_track_widget.hpp" +#include "../../src/gui/sequencer_clip_widget.hpp" +#include "../../src/gui/sequencer_velocity_widget.hpp" +#include "../../src/gui/sequencer_ruler_widget.hpp" #include "../../src/gui/piano_roll.hpp" #include "../../src/gui/mixer_widget.hpp" #include "../../src/gui/plugin_dialog.hpp" @@ -428,8 +432,78 @@ int midi_device_label_tests() press(find(&dialog,"Apply"));CHECK(!applied.midi_input_port[0] && !applied.midi_output_port[0]); puts("MIDI device dropdown labels/selection/refresh checks passed.");return failures ? 1 : 0; } +int sequencer_component_tests() +{ + reset(); + { + MainWindow window(false,""); window.show(); auto *s=timeline(&window); + CHECK(window.create_track(true)==XTC_OK); + uint64_t first=s->selected_track(),clip=s->selected_clip(); + CHECK(window.create_track(true)==XTC_OK); + uint64_t second=s->selected_track(); + auto *first_view=s->track_widget(first),*second_view=s->track_widget(second); + CHECK(first_view && second_view && first_view->parent()==s && second_view->parent()==s); + CHECK(first_view->clip_widget(clip) && first_view->clip_widget(clip)->parent()==first_view); + CHECK(first_view->velocity_lane()->parent()==first_view && !first_view->velocity_lane()->visible()); + CHECK(first_view->header()->parent()==first_view && s->instrument_choice(first)->parent()==first_view->header()); + int rulers=0; + for(int i=0;ichildren();++i) if(dynamic_cast(s->child(i))) ++rulers; + CHECK(rulers==1 && s->children()==3); + CHECK(s->expand_track(first,true)); + CHECK(first_view->velocity_lane()->visible() && second_view->y()==first_view->y()+first_view->h()); + CHECK(s->select(first,clip)); + CHECK(s->move_track(1)==XTC_OK); + CHECK(s->track_widget(first)==first_view && s->track_widget(second)==second_view); + CHECK(s->move_track(-1)==XTC_OK); + // Route through FLTK's window dispatch and mouse capture, across track boundaries. + pump(); + int ox=Fl::e_x,oy=Fl::e_y,ok=Fl::e_keysym,os=Fl::e_state; + Fl::e_x=s->x()+s->header_width()+20; Fl::e_y=s->track_top(0)+20; + Fl::e_keysym=FL_Button+FL_LEFT_MOUSE; Fl::e_state=FL_BUTTON1; + CHECK(Fl::handle(FL_PUSH,&window)==1 && Fl::pushed()); + Fl::e_x+=48; Fl::e_y=s->track_top(1)+20; + CHECK(Fl::handle(FL_DRAG,&window)==1); + Fl::e_state=0; CHECK(Fl::handle(FL_RELEASE,&window)==1); + Fl::e_x=ox; Fl::e_y=oy; Fl::e_keysym=ok; Fl::e_state=os; + xtc_clip_info moved{}; CHECK(xtc_project_find_clip(s->project(),clip,&moved)==XTC_OK); + CHECK(moved.track_id==second && moved.start_tick==960); + CHECK(!first_view->clip_widget(clip) && second_view->clip_widget(clip)); + CHECK(window.undo(false)==XTC_OK); + CHECK(s->track_widget(first)==first_view && first_view->clip_widget(clip)); + CHECK(window.undo(true)==XTC_OK && second_view->clip_widget(clip)); + CHECK(s->expand_track(second,true) && s->select(second,clip)); + uint64_t note_id=0; + CHECK(xtc_project_add_note(s->project(),clip,480,480,60,90,0,¬e_id)==XTC_OK); + CHECK(s->rename_selection("Nested clip")==XTC_OK); pump(); + Fl::e_x=s->x()+s->header_width()+72; + Fl::e_y=second_view->y()+56+80-90*60/127; + Fl::e_keysym=FL_Button+FL_LEFT_MOUSE; Fl::e_state=FL_BUTTON1; + CHECK(Fl::handle(FL_PUSH,&window)==1); + Fl::e_y+=20; CHECK(Fl::handle(FL_DRAG,&window)==1); + Fl::e_state=0; CHECK(Fl::handle(FL_RELEASE,&window)==1); + xtc_note_info note{}; CHECK(xtc_project_get_note(s->project(),0,¬e)==XTC_OK && note.velocity<90); + Fl::e_x=ox; Fl::e_y=oy; Fl::e_keysym=ok; Fl::e_state=os; + CHECK(s->select(first) && s->delete_selection()==XTC_OK); + CHECK(!s->track_widget(first) && s->track_widget(second)==second_view); + pump(); // Deferred removal of a widget during its event is safe. + CHECK(window.undo(false)==XTC_OK && s->track_widget(first)); + CHECK(s->expand_track(second,true)); pump(); + Fl::e_x=s->x()+s->header_width()+72; + Fl::e_y=second_view->y()+56+80-static_cast(note.velocity)*60/127; + Fl::e_keysym=FL_Button+FL_LEFT_MOUSE; Fl::e_state=FL_BUTTON1; + CHECK(Fl::handle(FL_PUSH,&window)==1); + // Replacing a project also cancels a captured child gesture. + CHECK(window.new_project()==XTC_OK && s->children()==1 && !s->track_widget(second)); + Fl::e_state=0; Fl::handle(FL_RELEASE,&window); + Fl::e_x=ox; Fl::e_y=oy; Fl::e_keysym=ok; Fl::e_state=os; + pump(); window.do_callback(); + } + return failures ? 1 : 0; +} + int main(int argc, char **argv) { + if(std::getenv("XTC_TEST_SEQUENCER_COMPONENTS_ONLY")) return sequencer_component_tests(); if(std::getenv("XTC_TEST_MIDI_LABELS_ONLY"))return midi_device_label_tests(); if(std::getenv("XTC_TEST_MIDI_WORKFLOWS_ONLY"))return midi_workflow_tests(); if(std::getenv("XTC_TEST_CREATIVE_ONLY"))return creative_workflow_tests(); @@ -1213,6 +1287,7 @@ 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(); } + sequencer_component_tests(); reset(); { MainWindow window(false,""); window.show(); auto *s=timeline(&window);