Skip to content

Add drag-handle overlay to edit per-note velocities - #2

Open
tharos-devs wants to merge 30 commits into
feature/note-offset-drag-handlesfrom
feature/note-velocity-drag-handles
Open

Add drag-handle overlay to edit per-note velocities#2
tharos-devs wants to merge 30 commits into
feature/note-offset-drag-handlesfrom
feature/note-velocity-drag-handles

Conversation

@tharos-devs

@tharos-devs tharos-devs commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a per-note velocity drag-handle overlay (vertical bars, one per note), toggleable from the toolbar, mirroring the existing note-offset drag-handle overlay architecture.
  • Bar height maps velocity 0-127 between the staff's bottom line and a virtual/real 5th line (matching a DAW piano-roll velocity lane), including for staves with fewer than 5 lines (e.g. 1-line percussion), which use a computed virtual 5th line.
  • Chord notes stack their bars (Cubase/FL-Studio-style occlusion: lowest-pitch note frontmost/opaque) instead of offsetting horizontally.
  • Dragging with a multi-note selection (including a partial-chord selection via shift-click) moves only the selected notes by a relative delta.
  • Bars are color-coded: green for MuseScore-computed (dynamics-derived) velocity, orange for user-modified velocity, blue when selected.
  • Depends on Forward per-note velocity overrides to MuseSampler's main playback stream musescore/muse_framework#221 to make edited velocities actually audible via MuseSampler - without it the values are stored but the sampler output is unaffected.

Stacked on

This PR is the third in a stack and targets feature/note-offset-drag-handles (this fork's #1) rather than main, because the note-velocity overlay controller shares its core architecture with the note-offset overlay controller - notably the SysStaffKey map-key pattern, the overlay-reuse-in-place optimization, and the View mode (Page/Continuous) crash fix that came out of reviewing that code. Merge order:

  1. Add per-note playback start/duration offset properties musescore/MuseScore#34546 (feature/note-offsets, upstream) - data model
  2. Add drag-handle overlay to edit per-note playback start/duration offsets #1 (feature/note-offset-drag-handles) - offset drag-handle UI, including the shared overlay-controller fixes this PR builds on
  3. This PR (feature/note-velocity-drag-handles) - velocity drag-handle UI

Test plan

  • Per-note velocity audible with third-party MuseSounds (Spitfire/OT/VSL) via MuseSampler
  • Switch between Page and Continuous view while the editor is active; no crash
  • Drag a bar on a single note and confirm the velocity value updates as expected
  • Select multiple notes (including a partial chord via shift-click) and drag; confirm only the selection moves, by a relative delta
  • Confirm chord bars stack correctly with lowest-pitch note frontmost
  • Confirm color-coding: green (computed) vs orange (user-modified) vs blue (selected)

Mirrors the note-offset drag-handle overlay's architecture with a
NoteVelocityOverlay/NotationNoteVelocityController pair: a draggable
vertical bar per note maps its position between the staff's bottom
line (velocity 0) and where a 5th line would sit if the staff had one
(velocity 127), so it works the same way on non-5-line staves (e.g.
1-line percussion) as on a standard staff.

Chord notes stack their bars by pitch (lowest-pitched note painted
frontmost) instead of offsetting horizontally, matching how DAW piano
rolls stack overlapping velocity lanes. Dragging one note in a
multi-note selection applies the same delta to every selected note,
including ones hidden behind others in the same chord's stack - only
what's selected moves.

Unedited notes display the velocity their dynamics marking/hairpin
context alone would produce at that exact tick (via new
PlaybackModel::appliableDynamicLevel(), exposed through
INotationPlayback and converted with a shared
muse::mpe::dynamicLevelToVelocityRatio() curve) rather than a flat
default, so nudging one starts from a musically coherent baseline.
VeloType::OFFSET_VAL notes (a percentage nudge on the dynamics
baseline, distinct from an absolute VeloType::USER_VAL override) are
read correctly rather than treated as an absolute value.

Bars are colored to show state at a glance: green for the
dynamics-derived default, orange for a user-modified velocity, blue
when selected.

Requires the companion musescore/muse_framework fix that forwards
per-note velocity overrides to MuseSampler's main playback stream
(without it, per-note velocity is audible via the legacy/FluidSynth
sound library but not via MuseSampler-hosted MuseSounds).
Picks up the fix for per-note velocity overrides not reaching
MuseSampler's main playback stream, so edits made with the note
velocity drag-handle overlay are actually audible.
The merge from feature/note-offset-drag-handles reset the submodule
pointer back to upstream main; this branch actually needs the
MuseSampler velocity fix (musescore/muse_framework#221), so re-pin it
to fix/musesampler-note-velocity's tip. Also fixes an extra blank
line flagged by the codestyle CI check in
NotationNoteVelocityController.
… handle

Displays the velocity value (0-127) in a small chip next to the bar's
top edge, updated in real time as the bar is dragged, so the exact
new value is visible without guessing from bar height alone - matches
the convention used by Dorico's own velocity lane. Only the actively
dragged bar shows the readout, to keep the staff uncluttered the rest
of the time.

The chip's colors are picked from the score's current background
color (INotationConfiguration::backgroundColor(), already
theme-aware: light/dark/high-contrast paper, or a user-customized
color) rather than hardcoded, so it stays legible against light or
dark paper alike instead of only working for the default white
background.
Dragging a note's velocity only updated that note's own USER_VELOCITY/
VELO_TYPE properties. A tied-continuation note is usually skipped
entirely by playback rendering, but in some configurations (tremolo
across the tie, partial ties across a repeat, multi-note articulations,
a trill ending on the tie's start chord) it is still rendered as its
own independent event using its own, never-touched velocity, causing
an audible volume jump. Mirror the dragged velocity onto the whole
forward tie chain so every note in it stays in sync.
VELO_TYPE was read from XML on load but never included in the note
property write list, so it was silently dropped on save. Harmless for
the common USER_VAL case (the in-class default happens to match), but
loses an OFFSET_VAL (percentage nudge) override on save/reload.
Mirrors the existing "Reset note offsets" entry: only appears when
note-velocity edit mode is enabled (NotationContextMenuModel::loadItems),
and resets USER_VELOCITY to 0 for every selected note via the undo
stack, so notes revert to the system-default (dynamics-derived)
velocity - matching the userModified check already used by the
velocity overlay's own color-coding.
Bars are painted back-to-front by pitch (lowest note frontmost) to
mimic a piano-roll velocity lane, but that meant selecting a
non-frontmost chord note left its bar visible only where a taller
neighbor didn't cover it - and often not clickable at all, since
hit-testing only exposed the portion of a back bar poking out above
the front one. Selected bars are now redrawn on top of every other bar
in their column, and hit-tested first, ignoring stacking-order
occlusion, so picking a note (however it's selected) always makes its
velocity bar fully visible and draggable.
The "Velocity" spinbox hardcoded a flat 64 whenever a note had no
explicit userVelocity() (0), completely ignoring any dynamic (piano,
forte...) actually in effect at that note - unlike the on-canvas
velocity-bar overlay, which already falls back to the real
dynamics-derived value (NotationNoteVelocityController::
displayedVelocity()/contextVelocity(), via
INotationPlayback::appliableDynamicLevel() +
muse::mpe::dynamicLevelToVelocityRatio()). A forte note that had never
been dragged showed a bar at ~96 but a spinbox stuck at 64.

NotePlaybackModel::effectiveVelocity() mirrors that same fallback so
both surfaces agree. loadPropertyItem()'s convertElementPropertyValueFunc
only ever sees the already-read value, not the element it came from -
not enough to compute a per-note contextual fallback - so the velocity
spinbox is now loaded through a dedicated loadVelocityProperty()
instead of the generic path.

Also fixes a related, more subtle bug found while testing this: since
an unset note's displayed value is now a computed fallback rather than
a fixed constant, dragging such a note to a value that happens to
match its own displayed fallback (e.g. dragging a forte note to
exactly 96) left the spinbox showing the same number both before and
after, even though the note genuinely went from "following the
dynamic" to "explicit user velocity" underneath - PropertyItem::
updateCurrentValue() only notifies when the displayed value itself
changes, which can't tell those two states apart when they coincide
numerically. Gave updateCurrentValue() an optional forceNotify
parameter (defaults to false, so every other call site is unaffected)
and pass it whenever isModified is about to flip, so the spinbox never
silently disagrees with the (always-correct) isModified-driven color
in that situation.
Qt Quick's per-item cursor arbitration follows whichever topmost item has
ever called setCursor(), independent of hover event accept/ignore.
NoteOffsetOverlay unconditionally declares a cursor on every hover move, so
its east-west edge cursor was winning even where a velocity bar - painted
on top, and already capturing mouse presses there - visually covered one
of its drag handles. NoteVelocityOverlay now claims/releases its own cursor
declaratively so hover matches what a click there actually does.
Clicking anywhere on a velocity bar and dragging used to snap the
velocity to whatever absolute value the click's Y position corresponded
to, which felt wrong for a click that landed mid-bar rather than exactly
on its top edge. The whole bar now acts as a drag handle: barDragged
reports the mouse's own displacement since the press instead of an
absolute position, and the controller nudges the note's pre-drag
velocity by that amount rather than computing an absolute target.
Code review of the two preceding commits found: the drag delta compared
a press-time position already normalized by height() against a
move/release-time position normalized by a height() read later - if the
overlay's height changes mid-drag (window resize, view zoom/pan), the
two ends of the subtraction used different scales. Now stores the raw
pixel press position and divides once by the current height(). Also
made hoverMoveEvent skip redundant setCursor()/unsetCursor() calls when
the hovered/not-hovered state hasn't changed, matching the cached-state
pattern NoteOffsetOverlay::updateCursor() already uses.
Draw the offset rectangle body with plain square corners instead of a
fully-rounded pill shape, per user preference.
Lets the user hear the effect of a velocity edit before committing it,
mirroring the existing pitch-drag audition pattern via
IPlaybackController::playNotes() with an ad-hoc velocityOverride on a
throwaway NoteVal - the real Note is never touched until the drag
completes. Also plays once on a plain click with no movement, since the
overlay otherwise swallows the click MuseScore would normally give
audible feedback for on note selection.

Throttled to at most one retrigger per 200ms during the drag (untriggered
mouse-move events fire far more often than that, which sounded like a
machine gun without a minimum interval), always auditions the exact
value that ends up committed on release regardless of the throttle
window, copies headGroup so cross/diamond noteheads audition with their
own articulation, skips entirely while real transport playback is
running so it doesn't fight the transport for the track, and resets its
throttle state if the drag's mouse grab is stolen mid-gesture (e.g. by a
popup) rather than only on a normal release.
With both note-offset and note-velocity edit modes active, a velocity
bar visually covering an offset edge handle also always won clicks and
hover there, since it's stacked on top - making that handle both
invisible and unreachable whenever a bar happened to cover it.

A standalone Cmd (macOS) / Ctrl (Windows, Linux) tap - pressed and
released with nothing else happening in between - now swaps which of
the two overlay containers paints, and is hit-tested, on top of the
other, persisting until tapped again. Committing only on release, and
only if nothing else used the modifier in the meantime, keeps this from
firing as a side effect of every other Cmd/Ctrl interaction (copy,
undo, Ctrl-click to extend a selection, Ctrl-wheel zoom, passive hover
in note-input mode, ...): a single general check in event() cancels
the pending toggle for any QInputEvent that carries the modifier and
isn't the Control key's own press/release, rather than reproducing
that check in every individual handler.

Also fixes a pre-existing gap surfaced while reviewing this: a velocity
bar drag interrupted by its mouse grab being stolen mid-gesture (e.g. a
popup opening) only reset the audition throttle, leaving the bar's
live-preview height on screen indefinitely instead of snapping back to
the note's actual velocity.
A plain click (press+release without moving past a small threshold)
now sets the note's velocity directly to whatever value the clicked
position corresponds to, instead of being a no-op. Pressing and
dragging past that threshold keeps today's existing relative-nudge
behavior unchanged - the two are distinguished by tracking whether the
mouse ever moved past CLICK_MOVE_THRESHOLD_PX before release.

Implemented without a second code path: a click's delta is expressed
as (clicked position - the bar's current top edge), which resolves
through the same linear canvasY-to-velocity mapping used for drags to
exactly the velocity at the clicked position, regardless of what
that delta happens to be measured from.
…vert

A genuinely zero-delta gesture (a plain click landing back on the bar's
own current position, or a drag that ends up where it started) was
still run through the [MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY]
clamp, silently flooring a note whose dynamics-derived velocity is
legitimately 0 (e.g. under ppppppppp) to 1 and pinning it to an
explicit VeloType::USER_VAL it never asked for - same issue for any
other co-selected note whose own displayed velocity was 0. Now skips
the clamp (and the property write entirely, for any note whose target
value already matches what's displayed) whenever the actual delta is
zero, and skips the whole undo entry if nothing ends up changing.

Also, onDragCancelled() (mouse grab stolen mid-drag, e.g. by a popup)
only reverted the one bar that owned the grab - if the dragged note was
part of a multi-selection, every other selected note's bar (and their
tie chains) had been live-previewed too and stayed stuck at that
uncommitted height indefinitely. Now reverts the whole affected set,
mirroring onBarDragged()'s own selection/tie-chain expansion.
Which overlay's cursor is displayed over an overlap is only
re-evaluated by Qt on the next hover event (see the cursor-priority
handling in notevelocityoverlay.cpp/noteoffsetoverlay.cpp). Without
this, swapping which of the note-offset/note-velocity overlays is on
top left a stationary mouse showing the previous top overlay's cursor
until it happened to move even a pixel, even though a click there
would already route to the new top overlay - a visible mismatch
between the cursor and what a click would actually do.

Synthesizes a button-less mouse-move at the current pointer position
right after the swap, forcing Qt Quick's normal hover-delivery path to
run again immediately, the same as a real (zero-distance) move would.
effectiveVelocity() always treated a nonzero userVelocity() as an
absolute value, but for VeloType::OFFSET_VAL notes it's actually a
percentage nudge on top of the dynamics-derived context velocity (see
Note::customizeVelocity()) - the spinbox showed a raw, meaningless
number instead of either the percentage or the actual playing
velocity, disagreeing with the on-canvas velocity-bar overlay this
was meant to mirror (NotationNoteVelocityController::
displayedVelocity()). Now shares the same VeloType-aware logic,
factored into a new contextVelocity() helper mirroring the
controller's own.

Editing the spinbox had the matching write-side bug: it went through
the default single-Pid write path, which never touched VELO_TYPE, so
typing an absolute value into an OFFSET_VAL note's velocity field
silently got reinterpreted as a percentage the next time it was read.
A dedicated callback now forces VELO_TYPE to USER_VAL first, matching
what dragging the on-canvas bar already does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant