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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user