Refactor and improve Tap Tempo (#8098)

Improves the tap tempo algorithm and usage, as well refactoring the code for better maintainability.

---------

Co-authored-by: Dalton Messmer <messmer.dalton@gmail.com>
This commit is contained in:
Sotonye Atemie
2026-03-10 23:31:37 -04:00
committed by GitHub
parent 6c75b30dca
commit a45a2b86a0
4 changed files with 153 additions and 98 deletions

View File

@@ -1,9 +1,7 @@
/*
* TapTempo.cpp - Plugin to count beats per minute
*
*
* Copyright (c) 2022 saker <sakertooth@gmail.com>
*
* Copyright (c) 2026 saker <sakertooth@gmail.com>
*
* This file is part of LMMS - https://lmms.io
*
@@ -28,6 +26,8 @@
#include <string>
#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<std::chrono::milliseconds>(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<clock>{};
}
QString TapTempo::nodeName() const
{
return taptempo_plugin_descriptor.name;
}
} // namespace lmms
double TapTempo::bpm() const
{
return m_bpm;
}
} // namespace lmms

View File

@@ -1,7 +1,7 @@
/*
* TapTempo.h - Plugin to count beats per minute
*
* Copyright (c) 2022 saker <sakertooth@gmail.com>
* Copyright (c) 2026 saker <sakertooth@gmail.com>
*
* 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<clock> 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<clock> m_lastTap;
std::array<std::chrono::milliseconds, MaxIntervals> m_intervals;
int m_beat = 0;
int m_taps = 0;
double m_bpm = 0;
};
} // namespace lmms

View File

@@ -1,9 +1,7 @@
/*
* TapTempoView.cpp - Plugin to count beats per minute
*
*
* Copyright (c) 2022 saker <sakertooth@gmail.com>
*
* Copyright (c) 2026 saker <sakertooth@gmail.com>
*
* This file is part of LMMS - https://lmms.io
*
@@ -32,58 +30,58 @@
#include <QPushButton>
#include <QVBoxLayout>
#include <QWidget>
#include <cmath>
#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

View File

@@ -1,9 +1,7 @@
/*
* TapTempoView.h - Plugin to count beats per minute
*
*
* Copyright (c) 2022 saker <sakertooth@gmail.com>
*
* Copyright (c) 2026 saker <sakertooth@gmail.com>
*
* 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