Skip to content

Add drag-handle overlay to edit per-note playback start/duration offsets - #34569

Draft
tharos-devs wants to merge 26 commits into
musescore:mainfrom
tharos-devs:feature/note-offset-drag-handles
Draft

Add drag-handle overlay to edit per-note playback start/duration offsets#34569
tharos-devs wants to merge 26 commits into
musescore:mainfrom
tharos-devs:feature/note-offset-drag-handles

Conversation

@tharos-devs

Copy link
Copy Markdown

⚠️ Draft — depends on #34546.
This branch is built on top of feature/note-offsets (#34546), which hasn't merged into main yet, so the diff below currently includes #34546's changes as well. Once #34546 merges, this diff will automatically shrink to just the drag-handle commit shown here. Opening this now purely to give visibility into the planned follow-up and its value — not intended to be merged before #34546.

Summary

Adds a visual, on-canvas way to edit the playbackStartOffset/
playbackDurationOffset properties introduced in #34546, as an alternative
to the Properties panel spinboxes.

  • Toggleable "Note offsets" overlay (toolbar action, next to Automation): draws a small rounded rectangle above each note, positioned on the note's own layout position.
  • Dragging the rectangle's left/right edge adjusts the start/duration offset respectively, with a live preview and a single undo/redo step on release.
  • Dragging a note that's part of a multi-note selection applies the same tick delta to every selected note.
  • The notation dims while the mode is active, matching the existing Automation overlay.
image

Architecture

Mirrors the existing Automation overlay pattern (NotationAutomationController + PolylinePlot), simplified since offset persistence goes through the existing generic undoChangeProperty/Pid mechanism rather than a dedicated curve/undo command:

  • INotationNoteOffsets / NotationNoteOffsets: edit-mode toggle state.
  • NotationNoteOffsetController: builds/positions one overlay per staff from actual note layout, converts drag input to ticks via segment interpolation, commits through undoChangeProperty.
  • NoteOffsetOverlay: QQuickPaintedItem drawing rectangles/handles and doing mouse hit-testing, independent of the Grip/NotationInteraction path.
  • segmentcanvasinterpolation.{h,cpp}: canvasX<->tick interpolation shared between automation and note-offset controllers.

Test plan

  • Built in Release mode, app launches cleanly
  • Toggle shows rectangles above each note, width = notated duration, correct stacking on chords
  • Dragging left/right edges updates playbackStartOffset/playbackDurationOffset live, matches Properties panel after release
  • Multi-note selection: dragging one edge applies the same delta to all selected notes in a single undo step
  • Undo/redo works after a committed drag
  • Scroll/zoom keep overlays aligned; switching Page View <-> Continuous View refreshes overlay geometry correctly
  • Toggling off removes overlays with no regression to normal selection/Grip editing

Depends on #34546 — please merge that one first.

tharosd and others added 2 commits August 11, 2026 16:19
Adds two new per-note properties (playbackStartOffset, playbackDurationOffset)
that let users nudge a note's audio playback timing independently of its
notated position and duration. Exposed in the Properties panel's Play
section and on the plugins API.

Resolves musescore#34545
- Note::Note(const Note&, bool) did not copy m_playbackStartOffset /
  m_playbackDurationOffset, so cloning a note (copy-paste, duplication,
  linked parts) silently reset both offsets to 0.
- effectivePlaybackStartTime()/effectivePlaybackDuration() had no lower
  bound. Since the two offsets are set independently (Properties panel
  spinboxes each range -1920..1920 with no cross-validation), an
  inconsistent combination could produce a negative effective start tick
  or a non-positive effective duration, both unguarded downstream in
  NoteRenderer.

The duration formula intentionally keeps "- playbackStartOffset()" so that
the effective end time (chordTick + chordTicks + durationOffset) does not
depend on the start offset - this keeps start/duration independently
adjustable, which upcoming UI work relies on. Clamping is applied at the
computation itself rather than changing the formula, so every caller
(Properties panel, plugins, future UI) is protected centrally.
@tharos-devs
tharos-devs force-pushed the feature/note-offset-drag-handles branch from 1b030ad to e77b97b Compare August 13, 2026 12:05
Adds an on-canvas alternative to the Properties panel spinboxes for editing
the playbackStartOffset/playbackDurationOffset introduced in
"Add per-note playback start/duration offset properties": a toggleable
overlay ("Note offsets" toolbar action, alongside Automation) draws a small
rounded rectangle above each note, anchored on the note's own position.
Dragging the rectangle's left/right edge adjusts the start/duration offset
respectively, with a live preview during the drag and a single undo/redo
step on release. Dragging a note that is part of a multi-note selection
applies the same tick delta to every selected note. The notation dims while
the mode is active, matching the existing Automation overlay's behavior.

New files:
- INotationNoteOffsets / NotationNoteOffsets: edit-mode toggle state,
  mirroring INotationAutomation.
- NotationNoteOffsetController: builds/positions one overlay per staff from
  the actual note layout (not a fixed lane), converts drag input to ticks
  via segment interpolation, and commits offset changes through the
  existing undoChangeProperty/Pid machinery (no new UndoableCommand needed).
- NoteOffsetOverlay: QQuickPaintedItem drawing the rectangles/handles and
  doing its own hit-testing, independent of the Grip/NotationInteraction
  edit path - the same pattern PolylinePlot uses for automation.
- segmentcanvasinterpolation.{h,cpp}: canvasX<->tick interpolation shared
  between the automation and note-offset controllers (previously
  duplicated).

Wiring follows the existing "toggle-automation" action end to end
(notationuiactions.cpp, notationactioncontroller.cpp,
notationcommandsregister.cpp, notationcommandsstate.cpp,
notationtoolbarmodel.cpp) and reuses AbstractNotationPaintView's existing
per-view-matrix redraw/dim hooks.
@tharos-devs
tharos-devs force-pushed the feature/note-offset-drag-handles branch from e77b97b to 8990603 Compare August 13, 2026 14:16
Live-dragging a style value (e.g. "Staff space (sp)" in Page Settings)
relayouts the score without going through changesChannel(), so the
overlay's cached note positions went stale and stopped tracking the
rescaled notation in real time.
timestampAndDurationFromStartAndDurationTicks() was called with a
hardcoded tick-position offset of 0 instead of ctx.positionTickOffset,
so every note (not just ones with a non-zero playback offset) played at
first-playthrough timing on repeat/volta/D.C. passes.

effectivePlaybackDuration() also independently recomputed from the raw,
unclamped playbackStartOffset() instead of the same (possibly clamped)
start effectivePlaybackStartTime() returns, so the two could disagree
once the start clamp kicked in, making the note play longer than its
clamped start implied.
- Compare SysStaffKey's System pointer by address only, never
  dereferencing it - a stale key still held from a previous rebuild
  had a dangling System* once Page/Continuous view mode switching
  destroyed and recreated every System, crashing on
  MeasureBase::index().
- Skip tied-continuation notes when building overlay handles: their
  own playback offset is ignored by NoteRenderer::shouldRender() in
  most cases, so a handle there could never actually do anything.
- Disconnect from a document's viewModeChanged/styleChanged the moment
  a different document becomes current, instead of leaking one
  subscription per document ever opened in the session.
- Don't delete an overlay that's mid-drag (holding the mouse grab)
  when a rebuild is triggered; defer until the drag finishes.
- Handle mouseUngrabEvent so a stolen mouse grab mid-drag doesn't
  leave the overlay stuck thinking a drag is still in progress.
- Consolidate the three parallel per-staff maps (overlay/notes/band
  rect) into one map to a per-staff struct, and reuse an existing
  overlay item in place instead of destroying and recreating every
  overlay on every rebuild.
- Mutate a single rect in place during a drag instead of copying the
  whole staff's rect vector out and back on every mouse-move.
Continuation-line indentation was off by one space, flagged by the
codestyle CI check.
This branch doesn't depend on the (not yet merged) MuseSampler
velocity fix, so pin it to a commit that's actually on
musescore/muse_framework:main - the check_muse_framework CI check
rejects fork-only commits.
Note::effectivePlaybackStartTime()/effectivePlaybackDuration() derived
the note's playback window from the chord's own tick()/ticks(), which
is correct for an ordinary note but not for a grace note, an arpeggio
note, or a note inside a repeated section: their actual playback
window is computed separately (see GraceChordCtx::buildCtx and the
repeat-aware positionTickOffset handling) and can differ substantially
from the chord's notated tick/duration. Recomputing from chord
tick/ticks discarded that and collapsed grace notes back onto their
principal note's timing, breaking 16 unit tests.

Apply playbackStartOffset()/playbackDurationOffset() directly in
NoteRenderer::render() instead, on top of the RenderingContext's
already-correct nominal tick range, and only when an offset is
actually set (so unedited notes take the exact same code path as
before this feature existed).
Continuation-line indentation was off by one space, flagged by the
codestyle CI check.
Note::playbackStartOffset()/playbackDurationOffset() were only wired
into read460/tread.cpp's XML reader, but files saved by this app go
through read500 (the current format version, dispatched by
RWRegister::reader() for version >= 500) - read460 is only ever used
to open older 4.60-4.99 files, which can never contain this property
in the first place since it didn't exist yet. The property was
written correctly (twrite.cpp) but silently dropped on reload because
the reader that actually matters never looked for the tag, resetting
both values to 0 every time a file was saved and reopened.

Moved the read hooks to read500/tread.cpp, removed the dead ones from
read460, and added a save/reload regression test (writeReadElement
round-trip) to Engraving_NoteTests.note.
createOverlayForStaff() anchored the nominal (zero-offset) right edge on
"the next ChordRest segment", but that segment is shared across every
voice/track on the staff. If a different voice had a shorter simultaneous
note (e.g. an eighth note under a quarter note), its segment became the
"next" one for every voice, cutting the longer note's rectangle down to
the shorter note's end tick instead of its own.

Anchor the right edge on this chord's own end tick via canvasXFromTick
instead, which already correctly interpolates/snaps to the real segment
at that tick regardless of which voice created it.
Match the note-velocity overlay's convention: green when both playback
offsets are at their default (0), orange as soon as either the start
or duration offset has been user-modified, blue when the note is
selected (taking priority over the modified color, same as velocity).
Both offsets already round-trip correctly through save/reload, so the
color - computed live from those persisted values on every rebuild -
does too.
Mirrors the existing Automation context-menu entry: only appears when
note-offset edit mode is enabled (NotationContextMenuModel::loadItems),
and resets both playback offsets to 0 for every selected note via the
undo stack, so notes revert to the system-default (unmodified) offset.
The edge-drag handle nubs were always a fixed gray regardless of
whether the pill body was showing default/modified/selected color,
making them visually disconnected from the rectangle they belong to.
Handles now use a darker shade of the same state color (green/orange/
blue) as the pill body they're attached to.
Previously a tied-into note (note->tieBack()) got no overlay entry at
all, so the rectangle stopped at the first note's own duration and
left a visual gap over the rest of the tied chain, even though
playback already correctly sounds through the whole chain.

NoteEntry now tracks headNote/tailNote/anchorNote instead of a single
note. A chain-head's rectangle extends to the tail's own end tick
(walked via Note::lastTiedNote(), playback-accurate to match
NoteRenderer::renderNormalTie()), with the right/duration handle shown
only if the tail resolves within the same System. A new branch
handles a tied-continuation note whose *predecessor* lives in a
different System: it gets its own continuation fragment starting at
that System's own left edge, which is what actually closes the gap
for a tie crossing a system or page break - each System still only
knows its own coordinate space, so a single rectangle can't literally
span two of them, but a fragment per System reads as continuous.

Only the chain's first note owns the left/start handle and only its
last note owns the right/duration handle; everything in between has
neither, matching the fact that dragging duration from the middle of
a tie chain (or start from past its own end) has no sensible meaning.
Property writes always target the chain's head note regardless of
which fragment/handle was actually dragged, since only the head's own
offset is ever honored during playback.

Also added two one-sided drag clamps, both only active for a real tie
(tailNote != headNote): the duration handle can't shrink the total
span to end before the tail note's own start, and the start handle
can't push the start past the head note's own end.
The "Start offset"/"Duration offset" spinboxes read and wrote
Pid::PLAYBACK_START_OFFSET/DURATION_OFFSET on the exact selected note
with no tie-awareness at all: selecting a tied-continuation note let
you type a value that was silently ignored during playback (only the
chain's head note's own offset is ever honored) and, now that the
on-canvas overlay is tie-aware too, disagreed with what it shows for
that same chain.

NotePlaybackModel::headNoteElements() now remaps each selected note to
its firstTiedNote(false) for both directions, via a custom
onPropertyChangedCallBack passed to buildPropertyItem - ties into an
extension point PropertiesPanelAbstractModel already supported, no
base-class changes needed. A note that is neither its chain's head nor
its tail (a middle link in a 3+-note chain) owns no handle at all in
the overlay, so it's dropped from the list entirely rather than
redirected, leaving both spinboxes disabled for it instead of quietly
editing a value it has no visual handle for.

Also fixed a separate, pre-existing refresh bug found while testing
this: committing a property change (e.g. releasing a drag) never
live-updated these spinboxes without leaving and re-entering the
Properties tab. GeneralSettingsModel::onNotationChanged() only ever
reloaded its own four Pids and never forwarded the notification down
into m_playbackProxyModel (unlike onCurrentNotationChanged() right
below it, which does forward to both nested models), and
NotePlaybackModel never overrode onNotationChanged() to react even if
it had been forwarded. Both gaps are now closed for NotePlaybackModel;
PlaybackProxyModel's other sibling models (arpeggio/fermata/breath/
glissando/gradual tempo change) have the same gap and are left as a
known, separate follow-up.
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.

4 participants