diff --git a/include/Clip.h b/include/Clip.h index e3c29138254..72a29e0a80e 100644 --- a/include/Clip.h +++ b/include/Clip.h @@ -100,6 +100,12 @@ class LMMS_EXPORT Clip : public Model, public JournallingObject bool manuallyResizable() const; + // Returns whether the clip can be looped + virtual bool loopable() const + { + return false; + } + /*! \brief Set whether a clip has been resized yet by the user or the knife tool. * * If a clip has been resized previously, it will not automatically @@ -116,6 +122,25 @@ class LMMS_EXPORT Clip : public Model, public JournallingObject return m_autoResize; } + int loopCount() const + { + return m_loopCount; + } + + // Increase/decrease loop count by one + // Note : does not create / close the corresponding view + virtual void increaseLoopCount() + { + ++m_loopCount; + } + virtual void decreaseLoopCount() + { + if (m_loopCount > 0) + { + --m_loopCount; + } + } + auto color() const -> const std::optional& { return m_color; } void setColor(const std::optional& color); @@ -170,6 +195,7 @@ public slots: TimePos m_startPosition; TimePos m_length; TimePos m_startTimeOffset; + int m_loopCount; BoolModel m_mutedModel; BoolModel m_soloModel; diff --git a/include/ClipView.h b/include/ClipView.h index 6e6ca4d6607..48831d233c6 100644 --- a/include/ClipView.h +++ b/include/ClipView.h @@ -66,7 +66,7 @@ class ClipView : public selectableObject, public ModelView public: const static int BORDER_WIDTH = 2; - ClipView( Clip * clip, TrackView * tv ); + ClipView(Clip * clip, TrackView * tv, int offset = 0); ~ClipView() override; bool fixedClips(); @@ -81,6 +81,11 @@ class ClipView : public selectableObject, public ModelView return m_trackView; } + inline int offset() const + { + return m_offset; + } + // qproperty access func QColor mutedColor() const; QColor mutedBackgroundColor() const; @@ -135,6 +140,10 @@ public slots: void randomizeColor(); void resetColor(); +signals: + void closing(); + void extandLoop(); + protected: enum class ContextMenuAction { @@ -142,7 +151,8 @@ public slots: Cut, Copy, Paste, - Mute + Mute, + Loop }; TrackView * m_trackView; @@ -181,9 +191,22 @@ public slots: auto hasCustomColor() const -> bool; + inline bool lastLoopView() + { + return m_offset == m_clip->loopCount(); + } + protected slots: void updateLength(); void updatePosition(); + void closeLoopViews(); + + /** + * Create a new loop view + */ + virtual void loop() + { + }; private: @@ -202,6 +225,7 @@ protected slots: static TextFloat * s_textFloat; Clip * m_clip; + int m_offset; // Offset of the View from the Clip, in Clip's lengths (offset != 0 => loop view) Action m_action; QPoint m_initialMousePos; QPoint m_initialMouseGlobalPos; diff --git a/include/MidiClip.h b/include/MidiClip.h index d8553e87eab..a9d4e5a4124 100644 --- a/include/MidiClip.h +++ b/include/MidiClip.h @@ -93,6 +93,8 @@ class LMMS_EXPORT MidiClip : public Clip return m_clipType; } + bool loopable() const override; + // next/previous track based on position in the containing track MidiClip * previousMidiClip() const; diff --git a/include/MidiClipView.h b/include/MidiClipView.h index 50ef635dd14..9cd046ed6c9 100644 --- a/include/MidiClipView.h +++ b/include/MidiClipView.h @@ -43,7 +43,7 @@ class MidiClipView : public ClipView Q_OBJECT public: - MidiClipView( MidiClip* clip, TrackView* parent ); + MidiClipView(MidiClip* clip, TrackView* parent, int offset = 0); ~MidiClipView() override = default; Q_PROPERTY(QColor noteFillColor READ getNoteFillColor WRITE setNoteFillColor) @@ -83,6 +83,8 @@ protected slots: void transposeSelection(); void clearNotesOutOfBounds(); + virtual void loop(); + protected: void constructContextMenu( QMenu * ) override; diff --git a/src/core/Clip.cpp b/src/core/Clip.cpp index 3e769f4ea13..2f69a3d6ea0 100644 --- a/src/core/Clip.cpp +++ b/src/core/Clip.cpp @@ -49,6 +49,7 @@ Clip::Clip( Track * track ) : m_track( track ), m_startPosition(), m_length(), + m_loopCount(0), m_mutedModel( false, this, tr( "Mute" ) ), m_selectViewOnCreate{false} { @@ -76,6 +77,7 @@ Clip::Clip(const Clip& other): m_startPosition(other.m_startPosition), m_length(other.m_length), m_startTimeOffset(other.m_startTimeOffset), + m_loopCount(0), m_mutedModel(other.m_mutedModel.value(), this, tr( "Mute" )), m_autoResize(other.m_autoResize), m_selectViewOnCreate{other.m_selectViewOnCreate}, diff --git a/src/core/Track.cpp b/src/core/Track.cpp index e44475d9374..a7520f6c0f1 100644 --- a/src/core/Track.cpp +++ b/src/core/Track.cpp @@ -459,7 +459,7 @@ void Track::getClipsInRange( clipVector & clipV, const TimePos & start, for( Clip* clip : m_clips ) { int s = clip->startPosition(); - int e = clip->endPosition(); + int e = clip->endPosition() + (clip->loopCount() * clip->length()); if( ( s <= end ) && ( e >= start ) ) { // Clip is within given range diff --git a/src/gui/clips/ClipView.cpp b/src/gui/clips/ClipView.cpp index 6bc0e28d539..9e1d6a7453d 100644 --- a/src/gui/clips/ClipView.cpp +++ b/src/gui/clips/ClipView.cpp @@ -78,13 +78,14 @@ TextFloat * ClipView::s_textFloat = nullptr; * \param _tv The track view that will contain the new object */ ClipView::ClipView( Clip * clip, - TrackView * tv ) : + TrackView * tv , int offset) : selectableObject( tv->getTrackContentWidget() ), ModelView( nullptr, this ), m_trackView( tv ), m_initialClipPos( TimePos(0) ), m_initialClipEnd( TimePos(0) ), m_clip( clip ), + m_offset( offset ), m_action( Action::None ), m_initialMousePos( QPoint( 0, 0 ) ), m_initialMouseGlobalPos( QPoint( 0, 0 ) ), @@ -131,6 +132,11 @@ ClipView::ClipView( Clip * clip, if (!m_clip->color().has_value()) { update(); } }); + if (offset != 0) + { + clip->increaseLoopCount(); + } + m_trackView->getTrackContentWidget()->addClipView( this ); updateLength(); updatePosition(); @@ -264,6 +270,10 @@ void ClipView::setNeedsUpdate( bool b ) */ bool ClipView::close() { + if (m_offset != 0) + { + m_clip->decreaseLoopCount(); + } m_trackView->getTrackContentWidget()->removeClipView( this ); return QWidget::close(); } @@ -343,6 +353,15 @@ void ClipView::updatePosition() m_trackView->trackContainerView()->update(); } +void ClipView::closeLoopViews() +{ + if (m_offset != 0) + { + closing(); + close(); + } +} + void ClipView::selectColor() { // Get a color from the user @@ -486,8 +505,8 @@ void ClipView::updateCursor(QMouseEvent * me) { const auto posX = position(me).x(); - // If we are at the edges, use the resize cursor - if (!me->buttons() && m_clip->manuallyResizable() && !isSelected() + // If we are at the edges, use the resize cursor (loop views are not allowed to be resized directly) + if (!me->buttons() && m_clip->manuallyResizable() && !isSelected() && !m_offset && ((posX > width() - RESIZE_GRIP_WIDTH) || (posX < RESIZE_GRIP_WIDTH))) { setCursor(Qt::SizeHorCursor); @@ -495,7 +514,14 @@ void ClipView::updateCursor(QMouseEvent * me) // If we are in the middle on knife mode, use the knife cursor else if (m_trackView->trackContainerView()->knifeMode() && !isSelected()) { - setCursor(Qt::SplitHCursor); + if (m_offset == 0) + { + setCursor(Qt::SplitHCursor); + } + else // Knife mode have no effect on loop views, we use the Forbidden cursor + { + setCursor(Qt::ForbiddenCursor); + } } // If we are in the middle in any other mode, use the hand cursor else { setCursor(Qt::PointingHandCursor); } @@ -719,7 +745,7 @@ void ClipView::mousePressEvent( QMouseEvent * me ) { hint = tr("Press <%1> and drag to make a copy."); } - else if (m_action == Action::Split) + else if (m_action == Action::Split && m_offset == 0) { hint = dynamic_cast(this) ? tr("Press <%1> or for unquantized splitting.\nPress for destructive splitting.") @@ -743,10 +769,12 @@ void ClipView::mousePressEvent( QMouseEvent * me ) { remove( active ); } - if (m_action == Action::Split) + if (m_action == Action::Split && m_offset == 0) { m_action = Action::None; setMarkerEnabled(false); + // Destroy the loop + closing(); update(); } } @@ -758,7 +786,15 @@ void ClipView::mousePressEvent( QMouseEvent * me ) } else if( !fixedClips() ) { - remove( active ); + closing(); + if (m_offset) + { + close(); + } + else + { + remove( active ); + } } } } @@ -873,7 +909,7 @@ void ClipView::mouseMoveEvent( QMouseEvent * me ) ( *it )->movePosition( newPos + m_initialOffsets[index] ); } } - else if( m_action == Action::Resize || m_action == Action::ResizeLeft ) + else if( ( m_action == Action::Resize || m_action == Action::ResizeLeft ) && !m_offset ) // Loop views can't be resized directly { const float snapSize = getGUI()->songEditor()->m_editor->getSnapSize(); // Length in ticks of one snap increment @@ -987,8 +1023,9 @@ void ClipView::mouseMoveEvent( QMouseEvent * me ) arg( m_clip->endPosition().getTicks() % TimePos::ticksPerBar() ) ); s_textFloat->moveGlobal( this, QPoint( width() + 2, height() + 2) ); + updatePosition(); } - else if( m_action == Action::Split ) + else if( m_action == Action::Split && m_offset == 0 ) { setCursor(Qt::SplitHCursor); setMarkerPos(knifeMarkerPos(me)); @@ -1024,7 +1061,7 @@ void ClipView::mouseReleaseEvent( QMouseEvent * me ) // TODO: Fix m_clip->setJournalling() consistency m_clip->setJournalling( true ); } - else if( m_action == Action::Split ) + else if( m_action == Action::Split && m_offset == 0 ) { const float ppb = m_trackView->trackContainerView()->pixelsPerBar(); const TimePos relPos = position(me).x() * TimePos::ticksPerBar() / ppb; @@ -1037,6 +1074,8 @@ void ClipView::mouseReleaseEvent( QMouseEvent * me ) splitClip(unquantizedModHeld(me) ? relPos : quantizeSplitPos(relPos)); } setMarkerEnabled(false); + // Destroy loop + closing(); } m_action = Action::None; @@ -1103,6 +1142,14 @@ void ClipView::contextMenuEvent( QContextMenuEvent * cme ) tr( "Paste" ), [this](){ contextMenuAction( ContextMenuAction::Paste ); } ); + if (m_clip->loopable()) + { + contextMenu.addAction( + embed::getIconPixmap( "loop_points_on" ), + tr( "Loop" ), + [this](){ contextMenuAction( ContextMenuAction::Loop ); } ); + } + contextMenu.addSeparator(); contextMenu.addAction( @@ -1155,6 +1202,9 @@ void ClipView::contextMenuAction( ContextMenuAction action ) case ContextMenuAction::Mute: toggleMute( active ); break; + case ContextMenuAction::Loop: + loop(); + break; } } diff --git a/src/gui/clips/MidiClipView.cpp b/src/gui/clips/MidiClipView.cpp index c92215d65aa..ba8c436a88c 100644 --- a/src/gui/clips/MidiClipView.cpp +++ b/src/gui/clips/MidiClipView.cpp @@ -50,8 +50,8 @@ namespace lmms::gui constexpr int BeatStepButtonOffset = 4; -MidiClipView::MidiClipView( MidiClip* clip, TrackView* parent ) : - ClipView( clip, parent ), +MidiClipView::MidiClipView( MidiClip* clip, TrackView* parent, int offset ) : + ClipView( clip, parent, offset ), m_clip( clip ), m_paintPixmap(), m_noteFillColor(255, 255, 255, 220), @@ -66,6 +66,20 @@ MidiClipView::MidiClipView( MidiClip* clip, TrackView* parent ) : update(); setStyle( QApplication::style() ); + + if ( offset == 0 && clip->loopCount() > 0 ) + { + int loopCount = m_clip->loopCount(); + while ( m_clip->loopCount() > 0 ) + { + // We set the loop count to 0 so the lastLoopView() check in loop() returns the expected value + m_clip->decreaseLoopCount(); + } + while ( m_clip->loopCount() < loopCount ) + { + loop(); + } + } } @@ -597,7 +611,42 @@ void MidiClipView::paintEvent( QPaintEvent * ) } else { - p.fillRect( rect(), c ); + if (this->offset() == 0) + { + p.fillRect(rect(), c); + } + // Draw loop views with a slight color difference and hatch them + else + { + p.fillRect(rect(), current ? c.lighter( 65 ) : c.darker( 150 )); + p.setPen(c); + + // Change the pen's width in a rather painfull way + QPen previousPen = p.pen(); + QPen newPen(previousPen); + newPen.setWidth(2); + p.setPen(newPen); + + for (int x = -height(); x < width(); x += 10) + { + int y1, y2; + y1 = 0; + if (x < 0) + { + y1 = -x; + } + y2 = height(); + if (x + height() > width()) + { + y2 = height() - (x + height() - width()); + } + if (y1 < height() && y2 > 0) + { + p.drawLine(std::max( 0, x ), y1, std::min( width(), x + height() ), y2); + } + } + p.setPen(previousPen); + } } // Check whether we will paint a text box and compute its potential height @@ -835,25 +884,41 @@ void MidiClipView::paintEvent( QPaintEvent * ) } // clip name - if (drawTextBox) + if (drawTextBox && this->offset() == 0) { paintTextLabel(m_clip->name(), p); } if( !( fixedClips() && beatClip ) ) { - // inner border - p.setPen( c.lighter( current ? 160 : 130 ) ); - p.drawRect( 1, 1, rect().right() - BORDER_WIDTH, - rect().bottom() - BORDER_WIDTH ); + if (this->offset() == 0) + { + // inner border + p.setPen( c.lighter( current ? 160 : 130 ) ); + p.drawRect( 1, 1, rect().right() - BORDER_WIDTH, + rect().bottom() - BORDER_WIDTH ); - // outer border - p.setPen( current ? c.lighter( 130 ) : c.darker( 300 ) ); - p.drawRect( 0, 0, rect().right(), rect().bottom() ); + // outer border + p.setPen( current ? c.lighter( 130 ) : c.darker( 300 ) ); + p.drawRect( 0, 0, rect().right(), rect().bottom() ); + } + // In case of a loop view, we don't draw inner border and don't draw borders between loop views + else + { + p.setPen( current ? c.lighter( 130 ) : c.darker( 300 ) ); + p.drawLine( 0, 0, rect().right(), 0 ); + p.drawLine( 0, rect().bottom(), rect().right(), rect().bottom() ); + + // Last loop view gets a right border + if ( this->offset() == m_clip->loopCount() ) + { + p.drawLine( rect().right(), 0, rect().right(), rect().bottom() ); + } + } } - // draw the 'muted' pixmap only if the clip was manually muted - if( m_clip->isMuted() ) + // draw the 'muted' pixmap only if the clip was manually muted, only on the root view + if( m_clip->isMuted() && this->offset() == 0 ) { const int spacing = BORDER_WIDTH; const int size = 14; @@ -861,7 +926,7 @@ void MidiClipView::paintEvent( QPaintEvent * ) embed::getIconPixmap( "muted", size, size ) ); } - if (m_marker) + if ( m_marker && this->offset() == 0 ) { p.setPen(markerColor()); p.drawLine(m_markerPos, rect().bottom(), m_markerPos, rect().top()); @@ -936,4 +1001,19 @@ bool MidiClipView::destructiveSplitClip(const TimePos pos) } +void MidiClipView::loop() +{ + // We don't create a loop if there's already one + if ( lastLoopView() ) + { + MidiClipView* newLoop = new MidiClipView(m_clip, m_trackView, offset() + 1); + connect(this, SIGNAL(closing()), newLoop, SLOT(closeLoopViews())); + connect(this, SIGNAL(extandLoop()), newLoop, SLOT(loop())); + } + else + { + extandLoop(); + } +} + } // namespace lmms::gui diff --git a/src/gui/tracks/TrackContentWidget.cpp b/src/gui/tracks/TrackContentWidget.cpp index 086a77c12b7..82b0e01b50e 100644 --- a/src/gui/tracks/TrackContentWidget.cpp +++ b/src/gui/tracks/TrackContentWidget.cpp @@ -273,8 +273,8 @@ void TrackContentWidget::changePosition( const TimePos & newPos ) clip->changeLength( clip->length() ); - const int ts = clip->startPosition(); - const int te = clip->endPosition()-3; + const int ts = clip->startPosition() + clipView->offset() * clip->length(); + const int te = clip->endPosition()-3 + clipView->offset() * clip->length(); if( ( ts >= begin && ts <= end ) || ( te >= begin && te <= end ) || ( ts <= begin && te >= end ) ) diff --git a/src/tracks/InstrumentTrack.cpp b/src/tracks/InstrumentTrack.cpp index 18ad5c95844..8e352eb0232 100644 --- a/src/tracks/InstrumentTrack.cpp +++ b/src/tracks/InstrumentTrack.cpp @@ -754,55 +754,68 @@ bool InstrumentTrack::play( const TimePos & _start, const fpp_t _frames, cur_start -= c->startPosition() + c->startTimeOffset(); } - // get all notes from the given clip... + // get all notes from the given clip const NoteVector & notes = c->notes(); - // ...and set our index to zero - auto nit = notes.begin(); - - // very effective algorithm for playing notes that are - // posated within the current sample-frame - - if( cur_start > 0 ) + + for ( int loop = 0; loop <= c->loopCount(); loop++ ) { - // skip notes which end before start-bar - while( nit != notes.end() && ( *nit )->endPos() < cur_start ) + TimePos loopOffset = loop * c->length(); + + if ( cur_start < loopOffset - c->startTimeOffset() ) { - ++nit; + // we can skip all the loops that start after the current position + // this also avoid to loop notes starting before startTimeOffset + break; } - } + // set our index to zero + auto nit = notes.begin(); - while (nit != notes.end() && (*nit)->pos() < c->length() - c->startTimeOffset()) - { - const auto currentNote = *nit; - // Skip any notes note at the current time pos or not overlapping with the start. - if (!(currentNote->pos() == cur_start - || (cur_start == -c->startTimeOffset() && (*nit)->pos() < cur_start && (*nit)->endPos() > cur_start))) + // very effective algorithm for playing notes that are + // posated within the current sample-frame + + if( cur_start > 0 ) { - ++nit; - continue; + // skip notes which end before start-bar + while( nit != notes.end() && ( *nit )->endPos() + loopOffset < cur_start ) + { + ++nit; + } } - // Calculate the overlap of the note over the clip end. - const auto noteOverlap = std::max(0, currentNote->endPos() - (c->length() - c->startTimeOffset())); - // If the note is a Step Note, frames will be 0 so the NotePlayHandle - // plays for the whole length of the sample - const auto noteFrames = currentNote->type() == Note::Type::Step - ? 0 - : (currentNote->endPos() - cur_start - noteOverlap) * frames_per_tick; - - NotePlayHandle* notePlayHandle = NotePlayHandleManager::acquire(this, _offset, noteFrames, *currentNote); - notePlayHandle->setPatternTrack(pattern_track); - // are we playing global song? - if( _clip_num < 0 ) + while ( nit != notes.end() && (*nit)->pos() < c->length() - c->startTimeOffset() ) { - // then set song-global offset of clip in order to - // properly perform the note detuning - notePlayHandle->setSongGlobalParentOffset( c->startPosition() + c->startTimeOffset()); - } + const auto currentNote = *nit; + // Skip any notes note not at the current time pos and not overlapping with the start. + if (!(currentNote->pos() + loopOffset == cur_start + || (cur_start == -c->startTimeOffset() + loopOffset && (*nit)->pos() + loopOffset < cur_start && (*nit)->endPos() + loopOffset > cur_start))) + { + ++nit; + continue; + } + + // Calculate the overlap of the note over the clip end. + const auto noteOverlap = std::max(0, currentNote->endPos() - (c->length() - c->startTimeOffset())); + // If the note is a Step Note, frames will be 0 so the NotePlayHandle + // plays for the whole length of the sample + const auto noteFrames = currentNote->type() == Note::Type::Step + ? 0 + : (currentNote->endPos() + loopOffset - cur_start - noteOverlap) * frames_per_tick; + + NotePlayHandle* notePlayHandle = NotePlayHandleManager::acquire( this, _offset, noteFrames, *currentNote ); + notePlayHandle->setPatternTrack(pattern_track); + // are we playing global song? + if( _clip_num < 0 ) + { + // then set song-global offset of clip in order to + // properly perform the note detuning + notePlayHandle->setSongGlobalParentOffset( c->startPosition() + c->startTimeOffset()); + } + + Engine::audioEngine()->addPlayHandle( notePlayHandle ); - Engine::audioEngine()->addPlayHandle( notePlayHandle ); - played_a_note = true; - ++nit; + played_a_note = true; + ++nit; + } } } unlock(); diff --git a/src/tracks/MidiClip.cpp b/src/tracks/MidiClip.cpp index 73a8435b235..9483a538ead 100644 --- a/src/tracks/MidiClip.cpp +++ b/src/tracks/MidiClip.cpp @@ -402,6 +402,16 @@ void MidiClip::splitNotesAlongLine(const NoteVector notes, TimePos pos1, int key +bool MidiClip::loopable() const +{ + if ( isInPattern() ) { + return false; + } + return true; +} + + + void MidiClip::setType( Type _new_clip_type ) { if( _new_clip_type == Type::BeatClip || @@ -449,6 +459,7 @@ void MidiClip::exportToXML(QDomDocument& doc, QDomElement& midiClipElement, bool midiClipElement.setAttribute("muted", isMuted()); midiClipElement.setAttribute("steps", m_steps); midiClipElement.setAttribute("len", length()); + midiClipElement.setAttribute("loopcount", loopCount()); // now save settings of all notes for (auto& note : m_notes) @@ -522,6 +533,12 @@ void MidiClip::loadSettings( const QDomElement & _this ) { changeLength(len); } + + int loopCount = _this.attribute("loopcount").toInt(); + for (int i = 0; i < loopCount; ++i) + { + increaseLoopCount(); + } setAutoResize(_this.attribute("autoresize", "1").toInt()); setStartTimeOffset(_this.attribute("off").toInt());