From db9ccbeb562b1482f54c4a5afe101f77a7e836a3 Mon Sep 17 00:00:00 2001 From: regulus79 <117475203+regulus79@users.noreply.github.com> Date: Sat, 22 Mar 2025 05:54:40 -0400 Subject: [PATCH] Add Strum Tool to Piano Roll (#7725) Adds a complex strum tool to the Piano Roll, allowing the user to take a selection of chords, and drag around the notes to shape the strum exactly how they want it. --------- Co-authored-by: szeli1 <143485814+szeli1@users.noreply.github.com> Co-authored-by: Sotonye Atemie --- include/PianoRoll.h | 26 ++++- src/gui/editors/PianoRoll.cpp | 194 ++++++++++++++++++++++++++++++++-- 2 files changed, 212 insertions(+), 8 deletions(-) diff --git a/include/PianoRoll.h b/include/PianoRoll.h index a1d045ff4..8ac59fe0a 100644 --- a/include/PianoRoll.h +++ b/include/PianoRoll.h @@ -112,7 +112,8 @@ public: Erase, Select, Detuning, - Knife + Knife, + Strum }; /*! \brief Resets settings to default when e.g. creating a new project */ @@ -268,7 +269,8 @@ private: SelectNotes, ChangeNoteProperty, ResizeNoteEditArea, - Knife + Knife, + Strum }; enum class NoteEditMode @@ -324,6 +326,9 @@ private: void setKnifeAction(); void cancelKnifeAction(); + void setStrumAction(); + void cancelStrumAction(); + void updateScrollbars(); void updatePositionLineHeight(); @@ -347,6 +352,7 @@ private: QPixmap m_toolMove = embed::getIconPixmap("edit_move"); QPixmap m_toolOpen = embed::getIconPixmap("automation"); QPixmap m_toolKnife = embed::getIconPixmap("edit_knife"); + QPixmap m_toolStrum = embed::getIconPixmap("arp_free"); static std::array prKeyOrder; @@ -437,6 +443,7 @@ private: EditMode m_editMode; EditMode m_ctrlMode; // mode they were in before they hit ctrl EditMode m_knifeMode; // mode they where in before entering knife mode + EditMode m_strumMode; //< mode they where in before entering strum mode bool m_mouseDownRight; //true if right click is being held down @@ -465,6 +472,21 @@ private: void updateKnifePos(QMouseEvent* me, bool initial); + //! Stores the chords for the strum tool + std::vector m_selectedChords; + //! Computes which notes belong to which chords from the selection + void setupSelectedChords(); + + TimePos m_strumStartTime; + TimePos m_strumCurrentTime; + int m_strumStartVertical = 0; + int m_strumCurrentVertical = 0; + float m_strumHeightRatio = 0.0f; + bool m_strumEnabled = false; + //! Handles updating all of the note positions when performing a strum + void updateStrumPos(QMouseEvent* me, bool initial, bool warp); + + friend class PianoRollWindow; StepRecorderWidget m_stepRecorderWidget; diff --git a/src/gui/editors/PianoRoll.cpp b/src/gui/editors/PianoRoll.cpp index f0f54f0ba..d075a70b8 100644 --- a/src/gui/editors/PianoRoll.cpp +++ b/src/gui/editors/PianoRoll.cpp @@ -58,6 +58,7 @@ #include "FontHelper.h" #include "InstrumentTrack.h" #include "KeyboardShortcuts.h" +#include "lmms_math.h" #include "MainWindow.h" #include "MidiClip.h" #include "PatternStore.h" @@ -1396,7 +1397,7 @@ void PianoRoll::keyPressEvent(QKeyEvent* ke) } case Qt::Key_A: - if( ke->modifiers() & Qt::ControlModifier ) + if (ke->modifiers() & Qt::ControlModifier && m_editMode != EditMode::Strum && m_editMode != EditMode::Knife) { ke->accept(); if (ke->modifiers() & Qt::ShiftModifier) @@ -1414,11 +1415,15 @@ void PianoRoll::keyPressEvent(QKeyEvent* ke) break; case Qt::Key_Escape: - // On the Knife mode, ESC cancels it + // On the Knife mode or Strum mode, ESC cancels it if (m_editMode == EditMode::Knife) { cancelKnifeAction(); } + else if (m_editMode == EditMode::Strum) + { + cancelStrumAction(); + } else { // Same as Ctrl + Shift + A @@ -1519,6 +1524,7 @@ void PianoRoll::keyReleaseEvent(QKeyEvent* ke ) } computeSelectedNotes( ke->modifiers() & Qt::ShiftModifier); m_editMode = m_ctrlMode; + if (m_editMode == EditMode::Strum) { setupSelectedChords(); } update(); break; @@ -1614,6 +1620,19 @@ void PianoRoll::mousePressEvent(QMouseEvent * me ) return; } + if (m_editMode == EditMode::Strum && me->button() == Qt::LeftButton) + { + // Only strum if the user is dragging a selected note + const auto& selectedNotes = getSelectedNotes(); + if (std::find(selectedNotes.begin(), selectedNotes.end(), noteUnderMouse()) != selectedNotes.end()) + { + updateStrumPos(me, true, me->modifiers() & Qt::ShiftModifier); + m_strumEnabled = true; + update(); + } + return; + } + if( m_editMode == EditMode::Detuning && noteUnderMouse() ) { static QPointer detuningClip = nullptr; @@ -2141,7 +2160,27 @@ void PianoRoll::cancelKnifeAction() update(); } +void PianoRoll::setStrumAction() +{ + if (m_editMode != EditMode::Strum) + { + m_strumMode = m_editMode; + m_editMode = EditMode::Strum; + m_action = Action::Strum; + m_strumEnabled = false; + setupSelectedChords(); + setCursor(Qt::ArrowCursor); + update(); + } +} +void PianoRoll::cancelStrumAction() +{ + m_editMode = m_strumMode; + m_action = Action::None; + m_strumEnabled = false; + update(); +} void PianoRoll::testPlayKey( int key, int velocity, int pan ) { @@ -2241,11 +2280,15 @@ void PianoRoll::mouseReleaseEvent( QMouseEvent * me ) s_textFloat->hide(); - // Quit knife mode if we pressed and released the right mouse button + // Quit knife mode or strum mode if we pressed and released the right mouse button if (m_editMode == EditMode::Knife && me->button() == Qt::RightButton) { cancelKnifeAction(); } + else if (m_editMode == EditMode::Strum && me->button() == Qt::RightButton) + { + cancelStrumAction(); + } if( me->button() & Qt::LeftButton ) { @@ -2266,6 +2309,10 @@ void PianoRoll::mouseReleaseEvent( QMouseEvent * me ) m_midiClip->rearrangeAllNotes(); } + else if (m_action == Action::Strum || m_strumEnabled) + { + m_strumEnabled = false; + } else if (m_action == Action::Knife && hasValidMidiClip()) { bool deleteShortEnds = me->modifiers() & Qt::ShiftModifier; @@ -2313,7 +2360,7 @@ void PianoRoll::mouseReleaseEvent( QMouseEvent * me ) m_currentNote = nullptr; - if (m_action != Action::Knife) + if (m_action != Action::Knife && m_action != Action::Strum) { m_action = Action::None; } @@ -2379,6 +2426,12 @@ void PianoRoll::mouseMoveEvent( QMouseEvent * me ) updateKnifePos(me, false); } + // Update Strum position if we are on knife mode + if (m_editMode == EditMode::Strum && m_strumEnabled) + { + updateStrumPos(me, false, me->modifiers() & Qt::ShiftModifier); + } + if( me->y() > PR_TOP_MARGIN || m_action != Action::None ) { bool edit_note = ( me->y() > noteEditTop() ) @@ -2661,7 +2714,7 @@ void PianoRoll::mouseMoveEvent( QMouseEvent * me ) } } } - else if (me->buttons() == Qt::NoButton && m_editMode != EditMode::Draw && m_editMode != EditMode::Knife) + else if (me->buttons() == Qt::NoButton && m_editMode != EditMode::Draw && m_editMode != EditMode::Knife && m_editMode != EditMode::Strum) { // Is needed to restore cursor when it previously was set to // Qt::SizeVerCursor (between keyAreaBottom and noteEditTop) @@ -2780,6 +2833,120 @@ void PianoRoll::updateKnifePos(QMouseEvent* me, bool initial) m_knifeEndKey = mouseKey; } +/* + * Setup chords + * + * A chord is an island of notes--as the loop goes over the notes, if the notes overlap, + * they are part of the same chord. Else, they are part of a new chord. +*/ +void PianoRoll::setupSelectedChords() +{ + if (!hasValidMidiClip()) { return; } + m_selectedChords.clear(); + m_midiClip->rearrangeAllNotes(); + + const NoteVector& selectedNotes = getSelectedNotes(); + if (selectedNotes.empty()) { return; } + + int maxTime = -1; + NoteVector currentChord; + for (Note* note: selectedNotes) + { + // If the note is not in the current chord range (and this isn't the first chord), start a new chord. + if (note->pos() >= maxTime && maxTime != -1) + { + // Sort the notes by key before adding the chord to the vector + std::sort(currentChord.begin(), currentChord.end(), [](Note* a, Note* b){ return a->key() < b->key(); }); + m_selectedChords.push_back(currentChord); + currentChord.clear(); + maxTime = note->endPos(); + } + maxTime = std::max(maxTime, static_cast(note->endPos())); + currentChord.push_back(note); + } + // Add final chord + std::sort(currentChord.begin(), currentChord.end(), [](Note* a, Note* b){ return a->key() < b->key(); }); + m_selectedChords.push_back(currentChord); +} + +/* + * Perform the Strum + * + * Notes above the clicked note (relative to each chord) will be strummed down, notes below will be strummed up. + * Holding shift raises the amount of movement to a power, causing the strum to be curved/warped. +*/ +void PianoRoll::updateStrumPos(QMouseEvent* me, bool initial, bool warp) +{ + if (!hasValidMidiClip()) { return; } + // Calculate the TimePos from the mouse + int mouseViewportPos = me->x() - m_whiteKeyWidth; + int mouseTickPos = mouseViewportPos * TimePos::ticksPerBar() / m_ppb + m_currentPosition; + // Should we add quantization? probably not? + if (initial) + { + m_strumStartTime = mouseTickPos; + m_strumStartVertical = me->y(); + } + m_strumCurrentTime = mouseTickPos; + m_strumCurrentVertical = me->y(); + int strumTicksHorizontal = m_strumCurrentTime - m_strumStartTime; + float strumPower = fastPow10f(0.01f * (m_strumCurrentVertical - m_strumStartVertical)); + + if (initial) + { + m_midiClip->addJournalCheckPoint(); + + Note* clickedNote = noteUnderMouse(); + if (clickedNote == nullptr) { return; } + + for (NoteVector chord: m_selectedChords) + { + for (Note* note: chord) + { + // Save the current note position + note->setOldPos(note->pos()); + // if this is the clicked note, calculate it's ratio up the chord + if (note == clickedNote && chord.size() > 1) + { + m_strumHeightRatio = 1.f * std::distance(chord.begin(), std::find(chord.begin(), chord.end(), clickedNote)) / (chord.size() - 1); + } + } + } + } + + for (NoteVector chord: m_selectedChords) + { + // Don't strum a chord with only one note + if (chord.size() <= 1) { continue; } + for (size_t i = 0; i < chord.size(); ++i) + { + float heightRatio = 1.f * i / (chord.size() - 1); + float ratio = 0.0f; + + if (heightRatio == m_strumHeightRatio) + { + ratio = 1.f; + } + else if (heightRatio < m_strumHeightRatio) + { + ratio = heightRatio / m_strumHeightRatio; + } + else + { + ratio = (1.f - heightRatio) / (1.f - m_strumHeightRatio); + } + + if (warp) + { + ratio = std::pow(ratio, strumPower); + } + chord.at(i)->setPos(std::max(0, static_cast(chord.at(i)->oldPos() + ratio * strumTicksHorizontal))); + } + } + m_midiClip->rearrangeAllNotes(); + m_midiClip->updateLength(); + m_midiClip->dataChanged(); +} @@ -3682,6 +3849,9 @@ void PianoRoll::paintEvent(QPaintEvent * pe ) case EditMode::Knife: cursor = &m_toolKnife; break; + case EditMode::Strum: + cursor = &m_toolStrum; + break; } QPoint mousePosition = mapFromGlobal( QCursor::pos() ); if( cursor != nullptr && mousePosition.y() > keyAreaTop() && mousePosition.x() > noteEditLeft()) @@ -3896,7 +4066,14 @@ void PianoRoll::focusOutEvent( QFocusEvent * ) if (m_editMode == EditMode::Knife) { m_editMode = m_knifeMode; m_action = Action::None; - } else { + } + else if (m_editMode == EditMode::Strum) + { + m_editMode = m_strumMode; + m_action = Action::None; + } + else + { m_editMode = m_ctrlMode; } update(); @@ -4841,6 +5018,10 @@ PianoRollWindow::PianoRollWindow() : connect(knifeAction, &QAction::triggered, m_editor, &PianoRoll::setKnifeAction); knifeAction->setShortcut(combine(Qt::SHIFT, Qt::Key_K)); + auto strumAction = new QAction(embed::getIconPixmap("arp_free"), tr("Strum"), noteToolsButton); + connect(strumAction, &QAction::triggered, m_editor, &PianoRoll::setStrumAction); + strumAction->setShortcut(combine(Qt::SHIFT, Qt::Key_J)); + auto fillAction = new QAction(embed::getIconPixmap("fill"), tr("Fill"), noteToolsButton); connect(fillAction, &QAction::triggered, [this](){ m_editor->fitNoteLengths(true); }); fillAction->setShortcut(combine(Qt::SHIFT, Qt::Key_F)); @@ -4857,6 +5038,7 @@ PianoRollWindow::PianoRollWindow() : noteToolsButton->addAction(glueAction); noteToolsButton->addAction(knifeAction); + noteToolsButton->addAction(strumAction); noteToolsButton->addAction(fillAction); noteToolsButton->addAction(cutOverlapsAction); noteToolsButton->addAction(minLengthAction);