From 5d356429b313dca52ac73f2d9f44012c19292f65 Mon Sep 17 00:00:00 2001 From: Benjamin Munoz-Palma Date: Wed, 19 Aug 2026 00:16:07 -0400 Subject: [PATCH] Allow clicking on the score to seek during playback Previously all clicks on the score were ignored while playing, so the only way to jump to a different part of the score was to stop playback, move the caret, and start again. Clicking on the staff now moves the caret and seeks playback to that location without interrupting the playback session. Clicks on other items (barlines, tempo markers, etc) are still ignored while playing, since those open editing dialogs. MidiPlayer::seekToLocation() records the requested location, which the playback loop polls alongside the existing stop flag. playScore() then restarts the loop from the new location, silencing any sounding notes and skipping the count-in. It is deliberately not a slot, since the MIDI thread's event loop is blocked while playback is running. Merging the MIDI events is now done once in playScore() rather than in playEvents(), as converting the tracks to absolute ticks is not idempotent. myIsPlaying is also now set for the duration of the whole session, so that stopPlayback() cannot observe it as false in between two seek iterations and let playback resume. --- source/app/powertabeditor.cpp | 19 ++++++++ source/app/powertabeditor.h | 5 ++ source/audio/midiplayer.cpp | 88 +++++++++++++++++++++++++++-------- source/audio/midiplayer.h | 37 +++++++++++++-- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/source/app/powertabeditor.cpp b/source/app/powertabeditor.cpp index 0d1b0dda..f57146b7 100644 --- a/source/app/powertabeditor.cpp +++ b/source/app/powertabeditor.cpp @@ -682,6 +682,19 @@ void PowerTabEditor::startStopPlayback(bool from_measure_start) } } +void PowerTabEditor::seekPlaybackToLocation(const ConstScoreLocation &location) +{ + Caret &caret = getCaret(); + caret.moveToLocation(location); + caret.setSelectedItem(ScoreItem::Staff); + + const ScoreLocation &new_location = caret.getLocation(); + // Called directly rather than queued, since the MIDI thread's event loop is + // blocked while playback is running. + myMidiPlayer->seekToLocation(SystemLocation( + new_location.getSystemIndex(), new_location.getPositionIndex())); +} + void PowerTabEditor::redrawSystem(int index) { getCaret().moveToValidPosition(); @@ -3687,8 +3700,14 @@ void PowerTabEditor::setupNewTab() connect(scorearea, &ScoreArea::itemClicked, [&](ScoreItem item, const ConstScoreLocation &location, ScoreItemAction action) { + // While playing, clicking on the staff seeks playback to that + // location instead of editing the score. if (getCaret().isInPlaybackMode()) + { + if (item == ScoreItem::Staff) + seekPlaybackToLocation(location); return; + } myIsHandlingClick = true; diff --git a/source/app/powertabeditor.h b/source/app/powertabeditor.h index 9411e07e..f8b9cddd 100644 --- a/source/app/powertabeditor.h +++ b/source/app/powertabeditor.h @@ -29,6 +29,7 @@ class AutoBackup; class Caret; class Command; +class ConstScoreLocation; class DocumentManager; class FileFormatManager; class Instrument; @@ -301,6 +302,10 @@ private slots: /// Set up the MIDI thread. void createMidiThread(); + /// Moves the caret to the given location and jumps playback there, without + /// interrupting playback. + void seekPlaybackToLocation(const ConstScoreLocation &location); + /// Load any custom keyboard shortcuts. void loadKeyboardShortcuts(); /// Save any custom keyboard shortcuts. diff --git a/source/audio/midiplayer.cpp b/source/audio/midiplayer.cpp index b12f91cd..398bb456 100644 --- a/source/audio/midiplayer.cpp +++ b/source/audio/midiplayer.cpp @@ -169,21 +169,14 @@ mergeMidiEvents(MidiFile &file) return events; } -bool -MidiPlayer::playEvents(MidiFile &file, const SystemLocation &start_location, +MidiPlayer::PlaybackResult +MidiPlayer::playEvents(const MidiEventList &events, int ticks_per_beat, + const SystemLocation &start_location, const MidiPlaybackSettings &initial_settings, bool allow_count_in, const Score *score) { setPlaybackSettings(initial_settings); - myIsPlaying = true; - Util::ScopeExit on_exit([&]() { - myIsPlaying = false; - }); - - MidiEventList events = mergeMidiEvents(file); - const int ticks_per_beat = file.getTicksPerBeat(); - bool started = false; Midi::Tempo beat_duration = Midi::BEAT_DURATION_120_BPM; SystemLocation current_location = start_location; @@ -195,7 +188,11 @@ MidiPlayer::playEvents(MidiFile &file, const SystemLocation &start_location, for (const MidiEvent &event : events) { if (!myIsPlaying) - return false; + return PlaybackResult::Stopped; + + // A seek was requested, so restart the loop from the new location. + if (mySeekRequested) + return PlaybackResult::Seek; if (event.isTempoChange()) beat_duration = event.getTempo(); @@ -313,7 +310,7 @@ MidiPlayer::playEvents(MidiFile &file, const SystemLocation &start_location, clock_drift += actual_duration - sleep_duration; } - return true; + return PlaybackResult::Finished; } void @@ -331,16 +328,61 @@ MidiPlayer::playScore(const ConstScoreLocation &start_score_location, MidiFile file; file.load(score, options); - const SystemLocation start_location(myStartLocation->getSystemIndex(), - myStartLocation->getPositionIndex()); + // The events can only be merged once, since doing so converts the file's + // tracks to absolute ticks. + const MidiEventList events = mergeMidiEvents(file); + const int ticks_per_beat = file.getTicksPerBeat(); + + SystemLocation start_location(myStartLocation->getSystemIndex(), + myStartLocation->getPositionIndex()); + + // Keep myIsPlaying set for the whole session so that stopPlayback() can't + // miss the window in between seeks. + mySeekRequested = false; + myIsPlaying = true; + Util::ScopeExit on_exit([&]() { myIsPlaying = false; }); - if (playEvents(file, start_location, initial_settings, true, - &score)) + // Restart the event loop whenever a seek is requested. + bool allow_count_in = true; + while (true) { - emit playbackFinished(); + const PlaybackResult result = + playEvents(events, ticks_per_beat, start_location, + initial_settings, allow_count_in, &score); + + if (result != PlaybackResult::Seek) + { + if (result == PlaybackResult::Finished) + emit playbackFinished(); + break; + } + + { + std::lock_guard lock(mySeekMutex); + start_location = mySeekLocation; + mySeekRequested = false; + } + + // Silence anything that was sounding at the old location, and don't + // repeat the count-in when jumping around mid-playback. + myDevice->stopAllNotes(); + allow_count_in = false; } } +void +MidiPlayer::seekToLocation(const SystemLocation &location) +{ + if (!myIsPlaying) + return; + + // Set both under the lock so that a request can't be lost if it races with + // the playback thread consuming the previous one. + std::lock_guard lock(mySeekMutex); + mySeekLocation = location; + mySeekRequested = true; +} + MidiFile MidiPlayer::generateSingleNote(const ConstScoreLocation &location, const SettingsManager &settings) @@ -364,8 +406,13 @@ MidiPlayer::playSingleNote(MidiFile &file, const ConstScoreLocation &location, SystemLocation start_location(location.getSystemIndex(), location.getPositionIndex()); - playEvents(file, start_location, initial_settings, - /* allow_count_in */ false, nullptr); + const MidiEventList events = mergeMidiEvents(file); + + myIsPlaying = true; + Util::ScopeExit on_exit([&]() { myIsPlaying = false; }); + + playEvents(events, file.getTicksPerBeat(), start_location, + initial_settings, /* allow_count_in */ false, nullptr); myDevice->stopAllNotes(); } @@ -420,6 +467,9 @@ MidiPlayer::performCountIn(const Score &score, const SystemLocation &location, void MidiPlayer::stopPlayback() { + // Discard any pending seek so it can't restart the loop. + mySeekRequested = false; + if (myIsPlaying) { myIsPlaying = false; diff --git a/source/audio/midiplayer.h b/source/audio/midiplayer.h index 1961e24d..1ddcdeb0 100644 --- a/source/audio/midiplayer.h +++ b/source/audio/midiplayer.h @@ -23,17 +23,19 @@ #include #include #include +#include #include #include #include #include +#include #include +class MidiEventList; class MidiFile; class MidiOutputDevice; class Score; class SettingsManager; -class SystemLocation; /// Initial values for playback settings which can be updated live from the /// mixer, see liveChangePlayerSettings() and liveChangePlaybackSpeed(). @@ -67,6 +69,12 @@ class MidiPlayer : public QObject void stopPlayback(); + /// Jumps playback to the given location without interrupting the playback + /// session. Has no effect if playback isn't running. + /// Thread-safe, and must be called directly rather than through the event + /// loop since the MIDI thread is busy while playback is running. + void seekToLocation(const SystemLocation &location); + static MidiFile generateSingleNote(const ConstScoreLocation &location, const SettingsManager &settings); @@ -102,14 +110,28 @@ private slots: void updateLiveSettings(); private: + /// How a playEvents() run ended. + enum class PlaybackResult + { + /// Reached the end of the event list. + Finished, + /// Aborted via stopPlayback(). + Stopped, + /// Interrupted by seekToLocation(); should be restarted from the + /// requested location. + Seek + }; + void setPlaybackSettings(const MidiPlaybackSettings &settings); void performCountIn(const Score &score, const SystemLocation &location, Midi::Tempo beat_duration); - bool playEvents(MidiFile &file, const SystemLocation &start_location, - const MidiPlaybackSettings &initial_settings, - bool allow_count_in = false, const Score *score = nullptr); + PlaybackResult playEvents(const MidiEventList &events, int ticks_per_beat, + const SystemLocation &start_location, + const MidiPlaybackSettings &initial_settings, + bool allow_count_in = false, + const Score *score = nullptr); const SettingsManager &mySettingsManager; boost::signals2::scoped_connection mySettingsListener; @@ -124,6 +146,13 @@ private slots: /// Location where playback began. std::optional myStartLocation; + /// Set when a seek has been requested; polled by the playback loop. + std::atomic mySeekRequested = false; + /// Guards mySeekLocation, which isn't trivially copyable. + std::mutex mySeekMutex; + /// Location requested by the most recent seekToLocation() call. + SystemLocation mySeekLocation; + /// Max volume and pan for each channel. std::array, Midi::NUM_MIDI_CHANNELS_PER_PORT> myChannelMaxVolumes; std::array, Midi::NUM_MIDI_CHANNELS_PER_PORT> myChannelPans;