diff --git a/Makefile b/Makefile index 7bf6b6b..f5b63f2 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/docs/CREATIVE_WORKFLOWS.md b/docs/CREATIVE_WORKFLOWS.md index 2b08928..489394f 100644 --- a/docs/CREATIVE_WORKFLOWS.md +++ b/docs/CREATIVE_WORKFLOWS.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c45630c..bb20ee5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. --- diff --git a/include/xtc/xtc_tempo.h b/include/xtc/xtc_tempo.h new file mode 100644 index 0000000..69108e9 --- /dev/null +++ b/include/xtc/xtc_tempo.h @@ -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 diff --git a/src/control/tempo.c b/src/control/tempo.c new file mode 100644 index 0000000..4e87fcf --- /dev/null +++ b/src/control/tempo.c @@ -0,0 +1,23 @@ +#include "xtc/xtc_tempo.h" +#include +#include +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;icount;++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;i300000)return XTC_ERR_INVALID_ARGUMENT; + *s=copy;*out=tempo;return XTC_OK; +} diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index f78f287..84db1ff 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -36,6 +36,7 @@ #include #include #include +#include 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(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(c);double now=std::chrono::duration(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(c); xtc_result r=w->set_loop(static_cast(w->settings_.loop_start),static_cast(w->settings_.loop_end),static_cast(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; +} diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index da420f5..bd6858f 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -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; MixSnapshot mix_snapshots_[2]{{nullptr,xtc_mix_snapshot_destroy},{nullptr,xtc_mix_snapshot_destroy}}; diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index eaf066b..5e47a43 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -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; } diff --git a/tests/unit/test_creative.c b/tests/unit/test_creative.c index 00befbb..c8ae222 100644 --- a/tests/unit/test_creative.c +++ b/tests/unit/test_creative.c @@ -1,10 +1,26 @@ #include "xtc/xtc_plugin.h" +#include "xtc/xtc_tempo.h" #include #include #include #include +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);