feat(transport): set project tempo by tapping a pulse (phase 97)

This commit is contained in:
Johannes Findeisen 2026-11-06 02:09:02 +01:00
commit b7a4a1acd4
9 changed files with 92 additions and 5 deletions

View file

@ -27,7 +27,7 @@ CXXFLAGS ?= -O2 -g
CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude
LDFLAGS ?=
CORE_SOURCES := src/project/chain_tools.c src/project/chain_preset.c src/mixer/track_fx.c src/audio/sample.c src/persistence/collect.c src/audio/normalize.c src/project/sections.c src/project/editing.c src/audio/export.c src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
CORE_SOURCES := src/control/tempo.c src/project/chain_tools.c src/project/chain_preset.c src/mixer/track_fx.c src/audio/sample.c src/persistence/collect.c src/audio/normalize.c src/project/sections.c src/project/editing.c src/audio/export.c src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
JACK_AVAILABLE := $(shell $(PKG_CONFIG) --exists jack 2>/dev/null && echo yes)
ifeq ($(JACK_AVAILABLE),yes)
JACK_CFLAGS := $(shell $(PKG_CONFIG) --cflags jack)

View file

@ -61,3 +61,13 @@ fader and before its bus, including the effects' output. Green bars release at
it. Stopped, offline or missing-track readings show the -90 dBFS floor. Changing
tracks clears the indicator. These are sample peaks, not true-peak or loudness
measurements. The display shares the mixer's existing backend snapshot read.
## Tap tempo
Tap the transport **Tap** button at a quarter-note pulse. The estimator averages
the last four valid intervals and accepts 20–300 BPM. The first tap primes it;
a pause longer than three seconds restarts it. Too-fast or backward timestamps
are ignored. Each accepted tempo is an undoable edit and stops playback before
preparation; tapping is unavailable while recording. New/Open resets tap history.
The estimator uses monotonic control-thread time, with no audio-callback clock
access or allocation. Tap timing is not a beat detector or MIDI clock sync.

View file

@ -2163,12 +2163,12 @@ Implemented: UI-only stereo peak display in the insert workspace shares the exis
Provide a transport Tap button backed by a bounded C99 rolling interval estimator, with timeout/reset, sensible tempo limits and undoable project tempo adoption.
### Tasks and acceptance criteria
- [ ] Implement the workflow with bounded validation, safe preparation and documented limits.
- [ ] Pass meaningful core and/or GUI checks, including error and undo behavior.
- [ ] Document results and commit this phase separately.
- [x] Implement the workflow with bounded validation, safe preparation and documented limits.
- [x] Pass meaningful core and/or GUI checks, including error and undo behavior.
- [x] Document results and commit this phase separately.
### Implementation status
Planned.
Implemented: transport Tap uses a bounded C99 four-interval estimator, 20–300 BPM, monotonic timestamps and a three-second restart gap. Invalid/fast/backward taps preserve estimator/output; accepted tempo edits use normal prepared project adoption and undo. Core rolling-average/timeout/invalid tests and GUI tempo/undo/New reset checks passed with a warning-enabled build.
---

18
include/xtc/xtc_tempo.h Normal file
View file

@ -0,0 +1,18 @@
#ifndef XTC_TEMPO_H
#define XTC_TEMPO_H
#include "xtc_types.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Control-thread estimator, four rolling intervals, 20..300 BPM. Zero initialize
* or reset before use. Caller supplies monotonic seconds; no clock or I/O here. */
typedef struct {double last,intervals[4];unsigned int count,head,started;} xtc_tap_tempo;
void xtc_tap_tempo_reset(xtc_tap_tempo *state);
/* First tap or gap >3s primes/restarts and returns NOT_FOUND without changing
* out. Invalid arguments/too-fast/backward timestamps preserve state and out.
* Success returns rounded milli-BPM and updates state. */
xtc_result xtc_tap_tempo_push(xtc_tap_tempo *state,double seconds,unsigned int *out);
#ifdef __cplusplus
}
#endif
#endif

23
src/control/tempo.c Normal file
View file

@ -0,0 +1,23 @@
#include "xtc/xtc_tempo.h"
#include <math.h>
#include <string.h>
void xtc_tap_tempo_reset(xtc_tap_tempo *s){if(s)memset(s,0,sizeof(*s));}
xtc_result xtc_tap_tempo_push(xtc_tap_tempo *s,double seconds,unsigned int *out)
{
xtc_tap_tempo copy;double interval,sum=0;unsigned int i,tempo;
if(!s || !out || !isfinite(seconds) || seconds<0 || s->started>1 || s->count>4 || s->head>=4 ||
(s->count<4 && s->head!=s->count) || (!s->started && s->count) ||
(s->started && (!isfinite(s->last) || s->last<0)))return XTC_ERR_INVALID_ARGUMENT;
for(i=0;i<s->count;++i)if(!isfinite(s->intervals[i]) || s->intervals[i]<.2 || s->intervals[i]>3)return XTC_ERR_INVALID_ARGUMENT;
if(!s->started || seconds-s->last>3) {
xtc_tap_tempo_reset(s);s->started=1;s->last=seconds;return XTC_ERR_NOT_FOUND;
}
interval=seconds-s->last;
if(interval<.2)return XTC_ERR_INVALID_ARGUMENT;
copy=*s;copy.last=seconds;copy.intervals[copy.head]=interval;copy.head=(copy.head+1)%4;
if(copy.count<4)++copy.count;
for(i=0;i<copy.count;++i)sum+=copy.intervals[i];
tempo=(unsigned int)lround(60000.0*copy.count/sum);
if(tempo<20000 || tempo>300000)return XTC_ERR_INVALID_ARGUMENT;
*s=copy;*out=tempo;return XTC_OK;
}

View file

@ -36,6 +36,7 @@
#include <cmath>
#include <cerrno>
#include <algorithm>
#include <chrono>
namespace {
class PanelWindow : public Fl_Double_Window {
@ -266,6 +267,9 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
auto *fit = new Fl_Button(790, 82, 100, 28, "Fit song");
fit->tooltip("Choose the largest supported zoom that fits the song (Ctrl+0)");
fit->callback([](Fl_Widget *, void *c) { static_cast<MainWindow *>(c)->fit_song(); }, this);
auto *tap=new Fl_Button(905,82,80,28,"Tap");
tap->tooltip("Tap tempo: averages up to four intervals, 20–300 BPM; pauses over three seconds restart.");
tap->callback([](Fl_Widget *,void *c){auto *w=static_cast<MainWindow *>(c);double now=std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();auto r=w->tap_tempo(now);if(r!=XTC_OK && r!=XTC_ERR_NOT_FOUND && r!=XTC_ERR_INVALID_ARGUMENT)w->report_error("Tap tempo unchanged",r);},this);
loop_button_ = new Fl_Check_Button(1000, 82, 100, 28, "Loop");
loop_button_->callback([](Fl_Widget *widget,void *c){auto *w=static_cast<MainWindow *>(c);
xtc_result r=w->set_loop(static_cast<uint64_t>(w->settings_.loop_start),static_cast<uint64_t>(w->settings_.loop_end),static_cast<Fl_Check_Button *>(widget)->value()!=0);
@ -1409,6 +1413,7 @@ xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_histor
history_.swap(new_history);
for(auto &snapshot:mix_snapshots_)snapshot.reset();
if(track_inserts_)track_inserts_->clear_session();
xtc_tap_tempo_reset(&tap_state_);
}
graph_widget_->reset_layout();
graph_widget_->set_graph(xtc_project_graph(candidate));
@ -2750,3 +2755,12 @@ xtc_result MainWindow::open_track_inserts(uint64_t track)
}
return XTC_ERR_NOT_FOUND;
}
xtc_result MainWindow::tap_tempo(double seconds)
{
if(recorder_)return XTC_ERR_INVALID_STATE;
auto state=tap_state_;unsigned int tempo=0;auto r=xtc_tap_tempo_push(&state,seconds,&tempo);
if(r==XTC_ERR_NOT_FOUND){tap_state_=state;return r;}
if(r!=XTC_OK)return r;
r=set_tempo(tempo);if(r==XTC_OK)tap_state_=state;return r;
}

View file

@ -11,6 +11,7 @@
#include "xtc/xtc_backend.h"
#include "xtc/xtc_mixer.h"
#include "xtc/xtc_plugin.h"
#include "xtc/xtc_tempo.h"
#include "xtc/xtc_control.h"
#include "xtc/xtc_settings.h"
@ -42,6 +43,7 @@ class MainWindow : public Fl_Double_Window {
* the current document. No engine/model changes happen in audio callbacks. */
xtc_result create_track(bool midi);
xtc_result set_tempo(unsigned int milli_bpm);
xtc_result tap_tempo(double seconds);
xtc_result collect_project(const char *directory);
xtc_result navigate_marker(int direction);
xtc_result loop_section();
@ -224,6 +226,7 @@ class MainWindow : public Fl_Double_Window {
private:
friend class TrackInsertsPanel;
xtc_tap_tempo tap_state_{};
TrackInsertsPanel *track_inserts_=nullptr;
using MixSnapshot=std::unique_ptr<xtc_mix_snapshot,decltype(&xtc_mix_snapshot_destroy)>;
MixSnapshot mix_snapshots_[2]{{nullptr,xtc_mix_snapshot_destroy},{nullptr,xtc_mix_snapshot_destroy}};

View file

@ -263,6 +263,9 @@ int creative_workflow_tests()
panel->meter()->do_callback();CHECK(!panel->meter()->clipped());
panel->select_track(first_track);CHECK(panel->meter()->left_db()==-90 && !panel->meter()->clipped());
CHECK(window.new_project()==XTC_OK && !panel->clipboard_ready() && panel->paste_chain()==XTC_ERR_NOT_FOUND);
CHECK(find(&window,"Tap"));CHECK(window.tap_tempo(0)==XTC_ERR_NOT_FOUND && window.tap_tempo(.4)==XTC_OK);
CHECK(xtc_project_tempo(s->project())==150000 && window.undo()==XTC_OK && xtc_project_tempo(s->project())==120000);
CHECK(window.new_project()==XTC_OK && window.tap_tempo(.9)==XTC_ERR_NOT_FOUND);
CHECK(!panel->comparison_ready(0) && !panel->comparison_ready(1));
window.do_callback();puts("Creative workflow GUI checks passed.");return failures ? 1 : 0;
}

View file

@ -1,10 +1,26 @@
#include "xtc/xtc_plugin.h"
#include "xtc/xtc_tempo.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <math.h>
static void tap_tests(void)
{
xtc_tap_tempo s={0},before;unsigned int tempo=999;
assert(xtc_tap_tempo_push(&s,0,&tempo)==XTC_ERR_NOT_FOUND && tempo==999);
assert(xtc_tap_tempo_push(&s,.5,&tempo)==XTC_OK && tempo==120000);
before=s;assert(xtc_tap_tempo_push(&s,.55,&tempo)==XTC_ERR_INVALID_ARGUMENT && !memcmp(&s,&before,sizeof(s)) && tempo==120000);
assert(xtc_tap_tempo_push(&s,NAN,&tempo)==XTC_ERR_INVALID_ARGUMENT && !memcmp(&s,&before,sizeof(s)));
assert(xtc_tap_tempo_push(&s,.4,&tempo)==XTC_ERR_INVALID_ARGUMENT && !memcmp(&s,&before,sizeof(s)));
assert(xtc_tap_tempo_push(&s,1,&tempo)==XTC_OK && xtc_tap_tempo_push(&s,1.5,&tempo)==XTC_OK && xtc_tap_tempo_push(&s,2,&tempo)==XTC_OK);
assert(xtc_tap_tempo_push(&s,2.25,&tempo)==XTC_OK && tempo==137143);
assert(xtc_tap_tempo_push(&s,10,&tempo)==XTC_ERR_NOT_FOUND && tempo==137143 && !s.count);
assert(xtc_tap_tempo_push(&s,10.25,&tempo)==XTC_OK && tempo==240000);
xtc_tap_tempo_reset(&s);assert(!s.started && !s.count);
}
int main(void)
{
tap_tests();
xtc_track_chain c={0},out={0};
c.output_gain=.8f;c.count=1;c.slots[0].enabled=1;c.slots[0].wet=.4f;
assert(xtc_plugin_probe_format(XTC_PLUGIN_BUILTIN,XTC_BUILTIN_PATH,2,48000,&c.slots[0].plugin)==XTC_OK);