diff --git a/plugins/TapTempo/TapTempo.cpp b/plugins/TapTempo/TapTempo.cpp index 9ea5bcc5c..e498156f1 100644 --- a/plugins/TapTempo/TapTempo.cpp +++ b/plugins/TapTempo/TapTempo.cpp @@ -1,9 +1,7 @@ /* * TapTempo.cpp - Plugin to count beats per minute * - * - * Copyright (c) 2022 saker - * + * Copyright (c) 2026 saker * * This file is part of LMMS - https://lmms.io * @@ -28,6 +26,8 @@ #include +#include "SamplePlayHandle.h" +#include "Song.h" #include "embed.h" #include "plugin_export.h" @@ -47,27 +47,83 @@ PLUGIN_EXPORT Plugin* lmms_plugin_main(Model*, void*) TapTempo::TapTempo() : ToolPlugin(&taptempo_plugin_descriptor, nullptr) { + m_intervals.fill(std::chrono::milliseconds::zero()); } -void TapTempo::onBpmClick() +void TapTempo::tap(bool play) { - const auto currentTime = clock::now(); - if (m_numTaps == 0) + using namespace std::literals; + + if (play) { - m_startTime = currentTime; + const auto metronomeFile = m_beat == 0 ? "misc/metronome02.ogg" : "misc/metronome01.ogg"; + Engine::audioEngine()->addPlayHandle(new SamplePlayHandle(metronomeFile)); + } + + m_beat = (m_beat + 1) % Engine::getSong()->getTimeSigModel().getNumerator(); + + if (m_lastTap.time_since_epoch() == 0ms) + { + m_lastTap = clock::now(); + return; + } + + const auto delta = std::chrono::duration_cast(clock::now() - m_lastTap); + constexpr auto resetTime = 2000ms; + + if (delta >= resetTime) + { + m_bpm = 0; + m_taps = 0; + m_beat = 0; + m_lastTap = clock::now(); + return; + } + + m_intervals[(m_taps++) % MaxIntervals] = delta; + + constexpr auto millisecondsPerMinute = 60000.0; + if (m_taps >= MaxIntervals) + { + // calculate the median of the stored intervals to reject outliers + std::nth_element(m_intervals.begin(), m_intervals.begin() + m_intervals.size() / 2, m_intervals.end()); + const auto newBpm = millisecondsPerMinute / m_intervals[m_intervals.size() / 2].count(); + + // use an adaptive EMA to smooth out jitter when in the ballpark and update quickly when moving to a new BPM + const auto error = std::abs(newBpm - m_bpm); + const auto alpha = std::clamp(error / 100.0, 0.2, 0.8); + m_bpm = alpha * newBpm + (1.0 - alpha) * m_bpm; } else { - using namespace std::chrono_literals; - const auto secondsElapsed = (currentTime - m_startTime) / 1.0s; - if (m_numTaps >= m_tapsNeededToDisplay) { m_bpm = m_numTaps / secondsElapsed * 60; } + // calculate the instant BPM for now until we have enough taps + m_bpm = millisecondsPerMinute / delta.count(); } - ++m_numTaps; + m_lastTap = clock::now(); +} + +void TapTempo::sync() +{ + Engine::getSong()->setTempo(std::round(m_bpm)); +} + +void TapTempo::reset() +{ + m_bpm = 0; + m_taps = 0; + m_beat = 0; + m_lastTap = std::chrono::time_point{}; } QString TapTempo::nodeName() const { return taptempo_plugin_descriptor.name; } -} // namespace lmms \ No newline at end of file + +double TapTempo::bpm() const +{ + return m_bpm; +} + +} // namespace lmms diff --git a/plugins/TapTempo/TapTempo.h b/plugins/TapTempo/TapTempo.h index be540b8da..7c033b3c5 100644 --- a/plugins/TapTempo/TapTempo.h +++ b/plugins/TapTempo/TapTempo.h @@ -1,7 +1,7 @@ /* * TapTempo.h - Plugin to count beats per minute * - * Copyright (c) 2022 saker + * Copyright (c) 2026 saker * * This file is part of LMMS - https://lmms.io * @@ -36,10 +36,11 @@ class TapTempo : public ToolPlugin { Q_OBJECT public: - using clock = std::chrono::steady_clock; - TapTempo(); - void onBpmClick(); + void tap(bool play); + void sync(); + void reset(); + double bpm() const; QString nodeName() const override; void saveSettings(QDomDocument&, QDomElement&) override {} @@ -48,13 +49,13 @@ public: gui::PluginView* instantiateView(QWidget*) override { return new gui::TapTempoView(this); } private: - std::chrono::time_point m_startTime; - int m_numTaps = 0; - int m_tapsNeededToDisplay = 2; - double m_bpm = 0.0; - bool m_showDecimal = false; - - friend class gui::TapTempoView; + static constexpr auto MaxIntervals = 3; + using clock = std::chrono::steady_clock; + std::chrono::time_point m_lastTap; + std::array m_intervals; + int m_beat = 0; + int m_taps = 0; + double m_bpm = 0; }; } // namespace lmms diff --git a/plugins/TapTempo/TapTempoView.cpp b/plugins/TapTempo/TapTempoView.cpp index 468e951f3..99e5dde19 100644 --- a/plugins/TapTempo/TapTempoView.cpp +++ b/plugins/TapTempo/TapTempoView.cpp @@ -1,9 +1,7 @@ /* * TapTempoView.cpp - Plugin to count beats per minute * - * - * Copyright (c) 2022 saker - * + * Copyright (c) 2026 saker * * This file is part of LMMS - https://lmms.io * @@ -32,58 +30,58 @@ #include #include #include +#include -#include "Engine.h" #include "FontHelper.h" -#include "SamplePlayHandle.h" -#include "Song.h" #include "TapTempo.h" namespace lmms::gui { TapTempoView::TapTempoView(TapTempo* plugin) : ToolPluginView(plugin) + , m_tapButton(new QPushButton()) + , m_resetButton(new QPushButton()) + , m_syncButton(new QPushButton()) + , m_precisionCheckBox(new QCheckBox()) + , m_muteCheckBox(new QCheckBox()) + , m_msLabel(new QLabel()) + , m_hzLabel(new QLabel()) , m_plugin(plugin) { setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + setFocusPolicy(Qt::FocusPolicy::StrongFocus); - auto font = QFont(); - - m_tapButton = new QPushButton(); + m_tapButton->setFocusPolicy(Qt::NoFocus); m_tapButton->setFixedSize(200, 200); - m_tapButton->setFont(adjustedToPixelSize(font, 32)); - m_tapButton->setText(tr("0")); + m_tapButton->setFont(adjustedToPixelSize(QFont{}, 24)); + m_tapButton->setText(tr("Tap to begin")); - auto precisionCheckBox = new QCheckBox(tr("Precision")); - precisionCheckBox->setFocusPolicy(Qt::NoFocus); - precisionCheckBox->setToolTip(tr("Display in high precision")); - precisionCheckBox->setText(tr("Precision")); + m_precisionCheckBox->setFocusPolicy(Qt::NoFocus); + m_precisionCheckBox->setToolTip(tr("Display in high precision")); + m_precisionCheckBox->setText(tr("Precision")); - auto muteCheckBox = new QCheckBox(tr("0.0 ms")); - muteCheckBox->setFocusPolicy(Qt::NoFocus); - muteCheckBox->setToolTip(tr("Mute metronome")); - muteCheckBox->setText(tr("Mute")); + m_muteCheckBox->setFocusPolicy(Qt::NoFocus); + m_muteCheckBox->setToolTip(tr("Mute metronome")); + m_muteCheckBox->setText(tr("Mute")); - m_msLabel = new QLabel(); m_msLabel->setFocusPolicy(Qt::NoFocus); m_msLabel->setToolTip(tr("BPM in milliseconds")); m_msLabel->setText(tr("0 ms")); - m_hzLabel = new QLabel(); m_hzLabel->setFocusPolicy(Qt::NoFocus); m_hzLabel->setToolTip(tr("Frequency of BPM")); - m_hzLabel->setText(tr("0.0000 hz")); + m_hzLabel->setText(tr("0.0000 Hz")); - auto resetButton = new QPushButton(tr("Reset")); - resetButton->setFocusPolicy(Qt::NoFocus); - resetButton->setToolTip(tr("Reset counter and sidebar information")); + m_resetButton->setFocusPolicy(Qt::NoFocus); + m_resetButton->setToolTip(tr("Reset counter and sidebar information")); + m_resetButton->setText(tr("Reset")); - auto syncButton = new QPushButton(tr("Sync")); - syncButton->setFocusPolicy(Qt::NoFocus); - syncButton->setToolTip(tr("Sync with project tempo")); + m_syncButton->setFocusPolicy(Qt::NoFocus); + m_syncButton->setToolTip(tr("Sync with project tempo")); + m_syncButton->setText(tr("Sync")); auto optionLayout = new QVBoxLayout(); - optionLayout->addWidget(precisionCheckBox); - optionLayout->addWidget(muteCheckBox); + optionLayout->addWidget(m_precisionCheckBox); + optionLayout->addWidget(m_muteCheckBox); auto bpmInfoLayout = new QVBoxLayout(); bpmInfoLayout->addWidget(m_msLabel, 0, Qt::AlignHCenter); @@ -94,41 +92,26 @@ TapTempoView::TapTempoView(TapTempo* plugin) sidebarLayout->addLayout(bpmInfoLayout); auto buttonsLayout = new QHBoxLayout(); - buttonsLayout->addWidget(resetButton, 0, Qt::AlignCenter); - buttonsLayout->addWidget(syncButton, 0, Qt::AlignCenter); + buttonsLayout->addWidget(m_resetButton, 0, Qt::AlignCenter); + buttonsLayout->addWidget(m_syncButton, 0, Qt::AlignCenter); auto mainLayout = new QVBoxLayout(this); mainLayout->addWidget(m_tapButton, 0, Qt::AlignCenter); mainLayout->addLayout(buttonsLayout); mainLayout->addLayout(sidebarLayout); - connect(m_tapButton, &QPushButton::pressed, this, [this, muteCheckBox]() { - if (!muteCheckBox->isChecked()) - { - const auto timeSigNumerator = Engine::getSong()->getTimeSigModel().getNumerator(); - Engine::audioEngine()->addPlayHandle(new SamplePlayHandle( - m_plugin->m_numTaps % timeSigNumerator == 0 ? "misc/metronome02.ogg" : "misc/metronome01.ogg")); - } - - m_plugin->onBpmClick(); - updateLabels(); + connect(m_tapButton, &QPushButton::pressed, this, [this] { + m_plugin->tap(!m_muteCheckBox->isChecked()); + update(); }); - connect(resetButton, &QPushButton::pressed, this, [this]() { closeEvent(nullptr); }); - - connect(precisionCheckBox, &QCheckBox::toggled, [this](bool checked) { - m_plugin->m_showDecimal = checked; - updateLabels(); - }); - - connect(syncButton, &QPushButton::clicked, this, [this]() { - const auto& tempoModel = Engine::getSong()->tempoModel(); - if (m_plugin->m_bpm < tempoModel.minValue() || m_plugin->m_bpm > tempoModel.maxValue()) { return; } - Engine::getSong()->setTempo(std::round(m_plugin->m_bpm)); - }); + connect(m_precisionCheckBox, &QCheckBox::toggled, this, &TapTempoView::update); + connect(m_resetButton, &QPushButton::pressed, this, &TapTempoView::reset); + connect(m_syncButton, &QPushButton::clicked, m_plugin, &TapTempo::sync); hide(); layout()->setSizeConstraint(QLayout::SetFixedSize); + if (parentWidget()) { parentWidget()->hide(); @@ -140,28 +123,40 @@ TapTempoView::TapTempoView(TapTempo* plugin) } } -void TapTempoView::updateLabels() +void TapTempoView::update() { - const double bpm = m_plugin->m_showDecimal ? m_plugin->m_bpm : std::round(m_plugin->m_bpm); + const double bpm = m_precisionCheckBox->isChecked() ? m_plugin->bpm() : std::round(m_plugin->bpm()); const double hz = bpm / 60; const double ms = bpm > 0 ? 1 / hz * 1000 : 0; - m_tapButton->setText(QString::number(bpm, 'f', m_plugin->m_showDecimal ? 1 : 0)); - m_msLabel->setText(tr("%1 ms").arg(ms, 0, 'f', m_plugin->m_showDecimal ? 1 : 0)); - m_hzLabel->setText(tr("%1 hz").arg(hz, 0, 'f', 4)); + m_tapButton->setText(QString::number(bpm, 'f', m_precisionCheckBox->isChecked() ? 1 : 0)); + m_msLabel->setText(tr("%1 ms").arg(ms, 0, 'f', m_precisionCheckBox->isChecked() ? 1 : 0)); + m_hzLabel->setText(tr("%1 Hz").arg(hz, 0, 'f', 4)); +} + +void TapTempoView::reset() +{ + m_tapButton->setText(tr("Tap to begin")); + m_msLabel->setText(tr("0 ms")); + m_hzLabel->setText(tr("0.0000 Hz")); + m_plugin->reset(); +} + +void TapTempoView::closeEvent(QCloseEvent* event) +{ + reset(); + ToolPluginView::closeEvent(event); } void TapTempoView::keyPressEvent(QKeyEvent* event) { - QWidget::keyPressEvent(event); - if (!event->isAutoRepeat()) { m_plugin->onBpmClick(); } -} + if (event->key() == Qt::Key_Space) + { + m_tapButton->animateClick(); + return; + } -void TapTempoView::closeEvent(QCloseEvent*) -{ - m_plugin->m_numTaps = 0; - m_plugin->m_bpm = 0; - updateLabels(); + ToolPluginView::keyPressEvent(event); } } // namespace lmms::gui diff --git a/plugins/TapTempo/TapTempoView.h b/plugins/TapTempo/TapTempoView.h index 0f5cbb228..1e30b0580 100644 --- a/plugins/TapTempo/TapTempoView.h +++ b/plugins/TapTempo/TapTempoView.h @@ -1,9 +1,7 @@ /* * TapTempoView.h - Plugin to count beats per minute * - * - * Copyright (c) 2022 saker - * + * Copyright (c) 2026 saker * * This file is part of LMMS - https://lmms.io * @@ -29,8 +27,9 @@ #include "ToolPluginView.h" -class QPushButton; +class QCheckBox; class QLabel; +class QPushButton; namespace lmms { class TapTempo; @@ -43,16 +42,20 @@ class TapTempoView : public ToolPluginView Q_OBJECT public: TapTempoView(TapTempo* plugin); - void updateLabels(); - void keyPressEvent(QKeyEvent* event) override; - void closeEvent(QCloseEvent*) override; private: + void closeEvent(QCloseEvent*) override; + void keyPressEvent(QKeyEvent*) override; + void update(); + void reset(); QPushButton* m_tapButton; + QPushButton* m_resetButton; + QPushButton* m_syncButton; + QCheckBox* m_precisionCheckBox; + QCheckBox* m_muteCheckBox; QLabel* m_msLabel; QLabel* m_hzLabel; TapTempo* m_plugin; - friend class TapTempo; }; } // namespace lmms::gui