diff --git a/README.md b/README.md index 49a43f9..e9bcf1f 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ cmake --build build --target clean - Ctrl+Z undoes; Ctrl+Shift+Z or Ctrl+Y redoes up to 32 edits. - The GUI now exposes left `output` and `output_right` JACK ports; route both explicitly for stereo. See [mixer workflow and limits](docs/MIXER.md). - Use Add track/Add region, drag regions or their edges, and Zoom +/-; scrollbars pan. +- Click the small arrow beside a track name to expand its MIDI velocity lane downward. Drag a note bar’s cap vertically to set velocity (1–127); Escape cancels and Ctrl+Z undoes. Click the arrow again to collapse. See the [expanded-track screenshot](screenshots/track-velocity-expanded.png). - Ctrl+N/O/S creates, opens or saves a project; Save As chooses another path. - Arrangement edits stop playback; graph edits remain live. - The ruler seeks while stopped. Stop pauses; Reconnect and project load reset position. F5 starts, F8 stops, Space toggles, F6 reconnects, and Ctrl+1/2/3 switches views. Ctrl+Q closes the client and window. No physical ports are connected automatically. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cccb518..fd73130 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3575,6 +3575,43 @@ qualification. --- +## Phase 109 — Expandable Track Velocity Lanes + +### Goal + +Expand arrangement tracks downward with a small disclosure arrow and edit MIDI +note velocities directly below their clips. This user-requested GUI increment +uses the existing C note-editing, persistence and history contracts. + +### Tasks and acceptance criteria + +- [x] Add a small arrow beside each track name; independently expand/collapse + tracks downward and reposition following clips and instrument selectors. +- [x] Draw velocity bars for visible note starts in each track's MIDI clips, + respecting clip offsets, timeline zoom and scrolling. +- [x] Drag a bar's cap vertically to preview and commit velocity (1–127), preserving + note timing, pitch and channel; Escape cancels and undo/redo restores edits. +- [x] Preserve expansion by track ID through reordering and ordinary edits; + reset it on project replacement. Expansion is temporary GUI state. +- [x] Verify real FLTK arrow gestures, lane edits, coincident notes, cancellation, + undo/redo, shifted clip selection and track reordering in GUI integration tests. +- [x] Update README guidance and capture actual expanded and compact sequencer + screenshots; regenerate the promotional overview. + +### Implementation status + +Implemented on 2026-11-06 in the FLTK sequencer using the existing C API; no +engine or project-format changes. Audio-only tracks and buses can expand but +have no MIDI note bars. Notes beginning before a trimmed clip's visible source +range are omitted. Coincident notes with different velocities can be selected +by their caps; fully overlapping caps select one note, with individual editing +still available in the piano roll. Track expansion is not saved in projects. + +Validation: warning-enabled CMake build, CTest GUI integration and offline +checks, plus rendered screenshot inspection. See [the gallery](../screenshots/README.md). + +--- + ## Recommended Execution Order Reorder the phases when the audit reveals a blocking dependency. Do not mark a diff --git a/screenshots/ALL.md b/screenshots/ALL.md new file mode 100644 index 0000000..397ec3f --- /dev/null +++ b/screenshots/ALL.md @@ -0,0 +1,588 @@ +# xtc — the complete screenshot gallery + +Browse every screenshot, from arranging and editing to mixing, plugins and workspace preferences. +See [capture notes and reproduction instructions](README.md) for image provenance and workflow limits. + +--- + +## The xtc workspace at a glance + +![The xtc workspace at a glance](overview.png) + +A promotional composite of the arrangement, piano roll, mixer and track insert views. + +--- + +## A compact workspace for composition + +![A compact workspace for composition](compact-workspace.png) + +The sequencer brings track creation, arrangement tools and transport controls into one view. + +--- + +## Arrange clips around a loop + +![Arrange clips around a loop](sequencer-loop.png) + +MIDI clips sit beneath section markers and a highlighted loop range. + +--- + +## Edit velocity beneath each track + +![Edit velocity beneath each track](track-velocity-expanded.png) + +Keys and Bass expand downward to reveal MIDI velocity bars while following tracks move down. + +--- + +## Start a new track + +![Start a new track](create-track-menu.png) + +The Create dropdown opens over the arrangement with track creation choices. + +--- + +## Give the song a structure + +![Give the song a structure](arrangement-markers.png) + +Named Intro, Verse, Chorus and Outro markers identify sections along the ruler. + +--- + +## Name an arrangement marker + +![Name an arrangement marker](marker-edit.png) + +The marker editor provides a name for a position in the song. + +--- + +## Navigate song sections + +![Navigate song sections](section-navigation.png) + +The arrangement shows a loop around the current song section. + +--- + +## Set the loop boundaries + +![Set the loop boundaries](loop-range.png) + +The loop range dialog controls the start and end of repeated playback. + +--- + +## Make room in the arrangement + +![Make room in the arrangement](ripple-time.png) + +The ripple time dialog requests a duration to insert or remove at the cursor. + +--- + +## See the inserted space + +![See the inserted space](ripple-result.png) + +The arrangement shows the gap created by inserting time. + +--- + +## Build an independent track layer + +![Build an independent track layer](track-duplication.png) + +A duplicated track appears alongside the original arrangement. + +--- + +## Split a MIDI clip + +![Split a MIDI clip](midi-split.png) + +The arrangement displays separate MIDI regions after a nondestructive split. + +--- + +## Bring MIDI regions together + +![Bring MIDI regions together](midi-consolidation.png) + +Separate MIDI material is consolidated into a single clip. + +--- + +## Shape notes and their dynamics + +![Shape notes and their dynamics](piano-roll-velocity.png) + +The piano roll combines note editing with a dedicated velocity lane. + +--- + +## Add timing and velocity variation + +![Add timing and velocity variation](midi-humanize.png) + +The humanization dialog exposes timing variation, velocity variation and a repeatable seed. + +--- + +## Build chords from intervals + +![Build chords from intervals](midi-chord-builder.png) + +The chord dialog accepts intervals for constructing notes around a root. + +--- + +## Review the generated chords + +![Review the generated chords](midi-chords-result.png) + +The piano roll displays the notes written by the chord builder. + +--- + +## Turn chords into patterns + +![Turn chords into patterns](midi-arpeggio.png) + +The arpeggio dialog controls step duration, gate and note direction. + +--- + +## Review an arpeggiated pattern + +![Review an arpeggiated pattern](midi-arpeggio-result.png) + +The piano roll shows the sequence of notes produced by arpeggiation. + +--- + +## Spread chord notes in time + +![Spread chord notes in time](creative-strum-dialog.png) + +The strumming dialog configures spacing and direction for chord notes. + +--- + +## Review a strummed chord + +![Review a strummed chord](creative-strum-result.png) + +The piano roll shows staggered note starts after applying chord strumming. + +--- + +## Exchange standard MIDI files + +![Exchange standard MIDI files](midi-file-menu.png) + +The File menu exposes MIDI import and selected-clip export commands. + +--- + +## Bring MIDI into the song + +![Bring MIDI into the song](midi-import-arrangement.png) + +An exported MIDI clip has been imported into the arrangement. + +--- + +## Inspect imported MIDI notes + +![Inspect imported MIDI notes](midi-import-piano-roll.png) + +The piano roll displays imported notes and their velocities. + +--- + +## Arrange audio alongside MIDI + +![Arrange audio alongside MIDI](sequencer-audio-waveform.png) + +An imported audio clip displays its waveform directly in the sequencer. + +--- + +## Adjust clip gain and fades + +![Adjust clip gain and fades](audio-clip-properties.png) + +The audio clip properties dialog exposes nondestructive gain and fade settings. + +--- + +## Choose a normalization target + +![Choose a normalization target](audio-normalization.png) + +The normalization dialog requests a target peak level in dBFS. + +--- + +## Review normalized audio + +![Review normalized audio](audio-normalization-result.png) + +The arrangement shows the audio clip after normalization. + +--- + +## Work directly with an audio source + +![Work directly with an audio source](sample-editor.png) + +The sample editor displays waveform selection, statistics and processing controls. + +--- + +## Find attacks for slicing + +![Find attacks for slicing](sample-transients.png) + +Detected source attacks appear alongside the sample editor's slicing controls. + +--- + +## Collect portable project media + +![Collect portable project media](project-collection.png) + +The collection prompt requests a destination folder for the project and its media. + +--- + +## Monitor the mix during playback + +![Monitor the mix during playback](mixer-playing.png) + +The mixer shows track controls and meters driven by actual JACK playback callbacks. + +--- + +## Shape the mix over time + +![Shape the mix over time](mixer-automation.png) + +The mixer presents track controls together with automation editing. + +--- + +## Mix in a smaller window + +![Mix in a smaller window](mixer-minimum-size.png) + +The mixer is shown at the supported 1220 × 720 minimum main-window size. + +--- + +## Navigate a larger session + +![Navigate a larger session](mixer-scrolled-session.png) + +A session with additional tracks demonstrates scrolling through the mixer. + +--- + +## Explore the signal flow + +![Explore the signal flow](modular-graph.png) + +The modular graph displays connected processing nodes and their routing. + +--- + +## Chain installed VST3 effects + +![Chain installed VST3 effects](track-inserts-vst3.png) + +The Keys track hosts ZamEQ2, ZamAutoSat and ZamDelay in its insert graph. + +--- + +## Inspect a hosted VST3 effect + +![Inspect a hosted VST3 effect](track-insert-vst3-identity.png) + +The identity view reports the plugin and the current adapter capabilities. + +--- + +## Create parallel effect branches + +![Create parallel effect branches](track-inserts-parallel.png) + +The insert graph shows parallel wet and dry signal paths. + +--- + +## Start with a Dub Echo rack + +![Start with a Dub Echo rack](creative-dub-rack.png) + +The insert graph displays the internal Dub Echo template and its idle meters. + +--- + +## Manage a complete effect rack + +![Manage a complete effect rack](creative-rack-actions.png) + +Rack actions expose bulk operations, comparison and chain clipboard controls. + +--- + +## Browse the plugin library + +![Browse the plugin library](plugin-library.png) + +The library presents available plugins for selection. + +--- + +## Inspect plugin descriptors + +![Inspect plugin descriptors](plugin-descriptors.png) + +The descriptor browser displays metadata from repository plugin fixtures. + +--- + +## Adjust hosted plugin controls + +![Adjust hosted plugin controls](plugin-controls.png) + +The control editor exposes parameters provided by a repository plugin fixture. + +--- + +## Choose an internal instrument + +![Choose an internal instrument](internal-instruments.png) + +The instrument menu presents the built-in devices available to a track. + +--- + +## Configure an internal delay + +![Configure an internal delay](internal-delay.png) + +The delay editor presents its available device controls. + +--- + +## Configure an internal reverb + +![Configure an internal reverb](internal-reverb.png) + +The reverb editor presents its available device controls. + +--- + +## Inspect a CLAP instrument + +![Inspect a CLAP instrument](clap-instrument-descriptors.png) + +The host displays descriptor information for a CLAP instrument. + +--- + +## Inspect a VST2 instrument + +![Inspect a VST2 instrument](vst2-instrument-descriptors.png) + +The host displays descriptor information for a VST2 instrument. + +--- + +## Inspect a VST3 instrument + +![Inspect a VST3 instrument](vst3-instrument-descriptors.png) + +The host displays descriptor information for a VST3 instrument. + +--- + +## Host the Steinberg SDK example effect + +![Host the Steinberg SDK example effect](steinberg-sdk-track-insert.png) + +The actual SDK-built Half Gain effect appears in xtc's track insert graph. + +--- + +## Inspect the SDK effect identity + +![Inspect the SDK effect identity](steinberg-sdk-identity.png) + +The host reports the Steinberg SDK effect's identity and current adapter limits. + +--- + +## Discover the SDK sine instrument + +![Discover the SDK sine instrument](steinberg-sdk-instrument.png) + +The plugin chooser displays the instrument from the Steinberg SDK example bundle. + +--- + +## Discover the JUCE instrument + +![Discover the JUCE instrument](juce-vst3-instrument.png) + +The plugin chooser displays the actual JUCE VST3 instrument bundle. + +--- + +## Play with the Soft Keys interface + +![Play with the Soft Keys interface](juce-soft-keys-editor.png) + +The actual JUCE editor is rendered directly, showing presets, parameter controls and an on-screen keyboard. + +--- + +## Connect MIDI controllers to parameters + +![Connect MIDI controllers to parameters](midi-controller-mapping.png) + +The controller mapping interface exposes MIDI control assignments. + +--- + +## Configure audio preferences + +![Configure audio preferences](preferences-audio.png) + +The audio preferences tab presents the application's audio settings. + +--- + +## Choose MIDI sources and destinations + +![Choose MIDI sources and destinations](preferences-midi.png) + +MIDI preferences provide port choices, channel filtering and thru settings. + +--- + +## Browse real MIDI input devices + +![Browse real MIDI input devices](preferences-midi-input-devices.png) + +The open input dropdown lists actual MIDI sources exposed by the host; no route is selected. + +--- + +## Understand MIDI setup and defaults + +![Understand MIDI setup and defaults](preferences-midi-help.png) + +The preferences help page explains device routing and MIDI editing defaults. + +--- + +## Configure plugin preferences + +![Configure plugin preferences](preferences-plugins.png) + +The plugin preferences tab presents plugin discovery settings. + +--- + +## Adjust the workspace preferences + +![Adjust the workspace preferences](preferences-interface.png) + +The interface preferences tab presents appearance and layout settings. + +--- + +## Set editing defaults + +![Set editing defaults](preferences-editing.png) + +The editing preferences tab includes defaults for new MIDI notes. + +--- + +## Set project defaults + +![Set project defaults](preferences-project_defaults.png) + +The project defaults tab presents settings used when creating project content. + +--- + +## Configure transport preferences + +![Configure transport preferences](preferences-transport.png) + +The transport preferences tab presents playback and navigation settings. + +--- + +## Find an application command + +![Find an application command](commands.png) + +The command palette provides access to the current application action list. + +--- + +## Keep transport controls within reach + +![Keep transport controls within reach](transport-detached.png) + +The transport floats in a separate window with BPM and loop settings. + +--- + +## Give the mixer its own window + +![Give the mixer its own window](mixer-detached.png) + +The mixer is detached from the main workspace. + +--- + +## Spread the workspace across windows + +![Spread the workspace across windows](workspace-detached.png) + +Transport and mixer panels float alongside the main application window. + +--- + +## Arrange in a separate window + +![Arrange in a separate window](sequencer-detached.png) + +The sequencer is detached from the main workspace. + +--- + +## Route signals in a separate window + +![Route signals in a separate window](graph-detached.png) + +The modular graph is detached from the main workspace. + +--- + +## About xtc + +![About xtc](about.png) + +The About dialog presents application information. diff --git a/screenshots/README.md b/screenshots/README.md index d7dfc4f..4d787a1 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -1,5 +1,7 @@ # xtc screenshots +[Browse all screenshots with captions](ALL.md). + The complete gallery was refreshed after Phases 100–105 on 2026-11-06, including MIDI preferences/import, the sample editor, creative workflows and real installed VST3 insert chains. @@ -30,6 +32,24 @@ APIs and GUI history/preparation paths. Original projects, media and user configuration are not overwritten. Black margins are the virtual desktop; floating views show the actual detached windows together. +## Expandable track velocity lanes (Phase 109) + +![Tracks expanded downward with editable MIDI velocity bars](track-velocity-expanded.png) + +Captured from the real application on 2026-11-06. Keys and Bass are expanded; +subsequent tracks move downward. Click the arrow beside a track name, then drag +a note bar's cap to edit velocity. The compact workspace, sequencer loop and +promotional overview were also refreshed to show the disclosure arrows. + +Reproduce these sequencer captures without replacing the rest of the gallery: + +```sh +cmake --build build --target capture_screenshots +XTC_CAPTURE_VELOCITY_ONLY=1 xvfb-run -a -s '-screen 0 1440x960x24' \ + ./build/capture_screenshots "$PWD/screenshots" +python3 tools/create_promotional_overview.py +``` + ## Steinberg and JUCE SDK integration (Phases 106–107) Captured on 2026-11-06 from the built SDK examples, with no fabricated UI. diff --git a/screenshots/compact-workspace.png b/screenshots/compact-workspace.png index 3ea5ba2..8b1340c 100644 Binary files a/screenshots/compact-workspace.png and b/screenshots/compact-workspace.png differ diff --git a/screenshots/overview.png b/screenshots/overview.png index 47c8822..c1ed078 100644 Binary files a/screenshots/overview.png and b/screenshots/overview.png differ diff --git a/screenshots/sequencer-loop.png b/screenshots/sequencer-loop.png index 3ea5ba2..8b1340c 100644 Binary files a/screenshots/sequencer-loop.png and b/screenshots/sequencer-loop.png differ diff --git a/screenshots/track-velocity-expanded.png b/screenshots/track-velocity-expanded.png new file mode 100644 index 0000000..b1e805c Binary files /dev/null and b/screenshots/track-velocity-expanded.png differ diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 1a3fdcb..6c3ff6c 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -1428,7 +1428,7 @@ xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_histor } graph_widget_->reset_layout(); graph_widget_->set_graph(xtc_project_graph(candidate)); - sequencer_->set_project(candidate); + sequencer_->set_project(candidate,true,!reset_history); if (reset_history) tabs_->value(tabs_->child(0)); dirty_ = false; diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 3e7bcf4..87021b8 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -26,6 +26,7 @@ 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); extent(); end(); @@ -56,10 +57,12 @@ bool SequencerWidget::select(uint64_t track, uint64_t clip) return true; } -void SequencerWidget::set_project(xtc_project *project, bool reset) +void SequencerWidget::set_project(xtc_project *project, bool reset, bool preserve_expansion) { + if(project_ != project) mode_=0; project_ = project; if (reset) { + if(!preserve_expansion) expanded_tracks_.fill(UINT64_MAX); waves_.clear(); selection_.clear(); track_ = clip_ = UINT64_MAX; @@ -93,6 +96,68 @@ uint64_t SequencerWidget::tick(int px) const return std::min(XTC_PROJECT_MAX_TICK,static_cast(xtc_ui::snap_position(raw,grid_))); } +bool SequencerWidget::track_expanded(uint64_t track) const +{ + return track != UINT64_MAX && std::find(expanded_tracks_.begin(),expanded_tracks_.end(),track) != expanded_tracks_.end(); +} + +bool SequencerWidget::expand_track(uint64_t track, bool expanded) +{ + size_t tracks=0, clips=0; + xtc_project_get_counts(project_,&tracks,&clips); + bool found=false; + for(size_t i=0;i=top && py(i); + top=bottom; + } + 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; +} + +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. */ @@ -100,6 +165,15 @@ void SequencerWidget::extent() { size_t tracks, clips; xtc_project_get_counts(project_, &tracks, &clips); + for(auto &id:expanded_tracks_) { + if(id==UINT64_MAX) continue; + bool found=false; + for(size_t i=0;i(end * static_cast(scale_) / 960U + static_cast(header_width_+20)), - std::max(400, static_cast(tracks) * 56 + 100)); + std::max(400, track_top(tracks)-y()+72)); refresh_instruments(); if (parent()) parent()->redraw(); @@ -245,10 +319,10 @@ void SequencerWidget::draw() size_t tracks = 0, clips = 0, notes = 0; xtc_project_get_counts(project_,&tracks,&clips); xtc_project_note_count(project_,¬es); for (size_t i = 0; i < tracks; ++i) { - int py = y()+28+static_cast(i)*56; - if (py+56 < cy || py > cy+ch) continue; + int py = track_top(i); + if (py+track_height(i) < cy || py > cy+ch) continue; fl_color(i%2 ? fl_rgb_color(30,34,40) : fl_rgb_color(33,37,43)); - fl_rectf(x()+header_width_,py,w()-header_width_,55); + fl_rectf(x()+header_width_,py,w()-header_width_,track_height(i)-1); } fl_font(FL_HELVETICA,11); uint64_t visual = grid_ == 1 ? 960 : grid_; @@ -270,8 +344,8 @@ void SequencerWidget::draw() } for (size_t i = 0; i < tracks; ++i) { xtc_track_info t{}; xtc_project_get_track(project_,i,&t); - int py = y()+28+static_cast(i)*56; - if (py+56 < cy || py > cy+ch) continue; + 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); @@ -310,6 +384,24 @@ void SequencerWidget::draw() 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); @@ -342,25 +434,37 @@ void SequencerWidget::draw() 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;i(i)*56; - if(py+56cy+ch)continue; + xtc_track_info t{};xtc_project_get_track(project_,i,&t);int py=track_top(i); + if(py+track_height(i)cy+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,55); - fl_color(color); fl_rectf(pinned,py,3,55); + 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); - fl_draw(t.name,pinned+12,py+18); + 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); + fl_draw("VELOCITY 1..127",pinned+12,py+76); + fl_font(FL_HELVETICA,10); + fl_draw(t.is_bus ? "No MIDI notes on buses" : "Drag bars to edit notes",pinned+12,py+94); + 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,y()+58+static_cast(i)*56,header_width_-22,20); + track_choices_[i].widget->resize(pinned+10,track_top(i)+30,header_width_-22,20); draw_children(); fl_pop_clip(); } @@ -373,6 +477,19 @@ void SequencerWidget::draw() */ int SequencerWidget::handle(int event) { + if(mode_==6) { + if(event==FL_KEYDOWN && Fl::event_key()==FL_Escape) { mode_=0; redraw(); return 1; } + if(event==FL_DRAG) { velocity_preview_=velocity_value(Fl::event_y()); redraw(); return 1; } + if(event==FL_RELEASE) { + mode_=0; + unsigned int value=velocity_value(Fl::event_y()); + if(value==velocity_original_.velocity) { redraw(); return 1; } + xtc_note_info note=velocity_original_; + xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK) r=xtc_project_edit_note(project_,note.id,note.start_tick,note.length_tick,note.pitch,value,note.channel); + changed(r); return 1; + } + } if(event==FL_PUSH && Fl::event_button()==FL_LEFT_MOUSE && Fl::event_x()>=header_left()+header_width_-5 && 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_button()!=FL_LEFT_MOUSE || Fl::event_x()(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 && xtc_project_get_track(project_, static_cast(row), &t) == XTC_OK) target = t.id; @@ -887,13 +1023,13 @@ void SequencerWidget::refresh_instruments() try 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,y()+58+static_cast(i)*56,header_width_-22,20); + 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,y()+58+static_cast(i)*56,header_width_-22,20); + 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; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index a48453b..8d6e251 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -32,6 +32,9 @@ class SequencerWidget : public Fl_Group { InstrumentSelect instrument_select = nullptr; Fl_Choice *instrument_choice(uint64_t track) const; void refresh_instruments(); + bool track_expanded(uint64_t track) const; + bool expand_track(uint64_t track, bool expanded); + int track_top(size_t row) const; bool select(uint64_t track, uint64_t clip = UINT64_MAX); xtc_result merge_midi(); xtc_result duplicate_track(const char *name); @@ -119,8 +122,9 @@ class SequencerWidget : public Fl_Group { * * @param project Borrowed project; must outlive the widget's use of it. * @param reset Whether to clear selection, cursor, and drag state. + * @param preserve_expansion Keep disclosure state when installing history snapshots. */ - void set_project(xtc_project *project, bool reset = true); + void set_project(xtc_project *project, bool reset = true, bool preserve_expansion = false); /** * @brief Returns the timeline cursor position in musical ticks. @@ -193,6 +197,15 @@ class SequencerWidget : public Fl_Group { void draw() override; private: + static constexpr int velocity_height_ = 88; + 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; struct TrackChoice { Fl_Choice *widget = nullptr; uint64_t track = UINT64_MAX; }; std::array track_choices_{}; std::vector catalog_; diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index fc83d1d..4264d90 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1214,6 +1214,56 @@ int main(int argc, char **argv) unlink("/tmp/xtc-workspace.xtc"); window.do_callback(); } reset(); + { + MainWindow window(false,""); window.show(); auto *s=timeline(&window); + CHECK(window.create_track(true)==XTC_OK); + uint64_t track=s->selected_track(), clip=s->selected_clip(), note_id=0, chord_id=0; + CHECK(xtc_project_add_note(s->project(),clip,960,480,60,100,2,¬e_id)==XTC_OK); + CHECK(xtc_project_add_note(s->project(),clip,960,480,64,40,2,&chord_id)==XTC_OK); + CHECK(window.create_track(true)==XTC_OK); // Publish notes and add a second row. + uint64_t second=s->selected_track(), second_clip=s->selected_clip(); + int base=s->track_top(1)-s->y(); + auto arrow=[&]() { + int ox=Fl::e_x,oy=Fl::e_y,ok=Fl::e_keysym; + Fl::e_x=s->header_left()+15; Fl::e_y=s->track_top(0)+13; + Fl::e_keysym=FL_Button+FL_LEFT_MOUSE; CHECK(s->handle(FL_PUSH)==1); + s->handle(FL_RELEASE); + Fl::e_x=ox; Fl::e_y=oy; Fl::e_keysym=ok; + }; + arrow(); // Actual disclosure arrow. + CHECK(s->track_expanded(track) && s->track_top(1)-s->y()==base+88); + CHECK(s->instrument_choice(second)->y()==s->track_top(1)+30); + int px=s->header_width()+s->zoom_level(); + int top=28+56, ny=top+88-8-100*60/127; + gesture(s,px,ny,0,30); + xtc_note_info note{},chord{}; + xtc_project_get_note(s->project(),0,¬e); xtc_project_get_note(s->project(),1,&chord); + CHECK(note.id==note_id && note.velocity<100 && note.pitch==60 && note.channel==2 && note.start_tick==960); + CHECK(chord.id==chord_id && chord.velocity==40); + CHECK(window.undo(false)==XTC_OK); + xtc_project_get_note(s->project(),0,¬e); CHECK(note.velocity==100 && s->track_expanded(track)); + CHECK(window.undo(true)==XTC_OK); + // Escape cancels a preview without changing the model. + xtc_project_get_note(s->project(),0,¬e); + int old_x=Fl::e_x,old_y=Fl::e_y,old_key=Fl::e_keysym; + Fl::e_x=s->x()+px; Fl::e_y=s->y()+top+80-static_cast(note.velocity)*60/127; + Fl::e_keysym=FL_Button+FL_LEFT_MOUSE; CHECK(s->handle(FL_PUSH)==1); + Fl::e_y-=20; CHECK(s->handle(FL_DRAG)==1); + Fl::e_keysym=FL_Escape; CHECK(s->handle(FL_KEYDOWN)==1); + xtc_note_info unchanged{}; xtc_project_get_note(s->project(),0,&unchanged); CHECK(unchanged.velocity==note.velocity); + Fl::e_x=old_x; Fl::e_y=old_y; Fl::e_keysym=old_key; + gesture(s,s->header_width()+20,s->track_top(1)-s->y()+15,0,0); + CHECK(s->selected_track()==second && s->selected_clip()==second_clip); + CHECK(s->move_track(-1)==XTC_OK && s->track_expanded(track)); + CHECK(s->track_top(1)-s->y()==base); // Expansion follows the stable track ID. + CHECK(s->move_track(1)==XTC_OK); + arrow(); + CHECK(!s->track_expanded(track) && s->track_top(1)-s->y()==base); + CHECK(!s->expand_track(UINT64_MAX,true)); + CHECK(window.new_project()==XTC_OK && !s->track_expanded(track)); + window.do_callback(); + } + reset(); { MainWindow window(false,""); window.show(); auto *sequence = timeline(&window); CHECK(window.create_track(true) == XTC_OK); diff --git a/tools/capture_screenshots.cpp b/tools/capture_screenshots.cpp index be4af59..770fce2 100644 --- a/tools/capture_screenshots.cpp +++ b/tools/capture_screenshots.cpp @@ -64,6 +64,13 @@ int main(int argc,char **argv) { MainWindow window(false,"");main_window=&window;window.resize(0,0,1400,900);window.show();check(window.open_project(project.c_str()));pump(); auto *s=first(window);if(!s)throw std::runtime_error("Sequencer missing"); auto reset=[&](){window.reset_layout();check(window.open_project(project.c_str()));window.resize(0,20,1400,900);tab("Sequencer");s->select(0,0);s->position(0);check(window.set_loop(3840,7680,true));window.fit_song();pump();}; + if(std::getenv("XTC_CAPTURE_VELOCITY_ONLY")) { + reset(); capture("compact-workspace"); capture("sequencer-loop"); + check(s->set_zoom(96)); + if(!s->expand_track(0,true) || !s->expand_track(1,true)) throw std::runtime_error("Expand track failed"); + pump(); capture("track-velocity-expanded"); + window.do_callback(); return 0; + } if(std::getenv("XTC_CAPTURE_MIDI_INPUT_ONLY")) { reset(); if(window.midi_devices(true).empty())throw std::runtime_error("No real MIDI input sources exposed by the host"); @@ -137,6 +144,8 @@ int main(int argc,char **argv) { window.do_callback();std::puts("Sample-editor captures completed.");return 0; } reset();capture("overview");capture("sequencer-loop");capture("arrangement-markers"); + check(s->set_zoom(96)); s->expand_track(0,true); s->expand_track(1,true); + pump(); capture("track-velocity-expanded"); reset(); s->position(4800);check(window.loop_section());capture("section-navigation"); dialog("marker-edit",[&]{s->marker_dialog();}); dialog("ripple-time",[&]{s->ripple_dialog(false);});