2222#include " ScriptUtilClass.h"
2323#include " TerminalColor.h"
2424
25+ #include < algorithm>
26+ #include < cmath>
2527#include < sstream>
2628
2729static const int DEFAULT_OCTAVE_MIDDLE_C = 3 ;
@@ -123,6 +125,7 @@ ApplicationState::ApplicationState()
123125 commands_.add ({" ch" , " channel" , CHANNEL , 1 , {" number" }, {" Set MIDI channel for the commands (0-16), defaults to 0" }});
124126 commands_.add ({" ts" , " timestamp" , TIMESTAMP , 0 , {" " }, {" Output a timestamp for each received MIDI message" }});
125127 commands_.add ({" nn" , " note-numbers" , NOTE_NUMBERS , 0 , {" " }, {" Output notes as numbers instead of names" }});
128+ commands_.add ({" bpm" , " beats-per-minute" , BEATS_PER_MINUTE , 0 , {" " }, {" Show tempo of incoming MIDI clock instead of the ticks" }});
126129 commands_.add ({" omc" , " octave-middle-c" , OCTAVE_MIDDLE_C , 1 , {" number" }, {" Set octave for middle C, defaults to 3" }});
127130 commands_.add ({" voice" , " " , VOICE , 0 , {" " }, {" Show all Channel Voice messages" }});
128131 commands_.add ({" note" , " " , NOTE , 0 , {" " }, {" Show all Note messages" }});
@@ -172,6 +175,15 @@ ApplicationState::ApplicationState()
172175 useHexadecimalsByDefault_ = false ;
173176 quiet_ = false ;
174177 rawdump_ = false ;
178+ bpmDisplay_ = false ;
179+ bpmInteractive_ = false ;
180+ lastBpmTime_ = 0.0 ;
181+ lastPrintedBpm_ = 0.0 ;
182+ avgBpm_ = 0.0 ;
183+ lastAvgTime_ = 0.0 ;
184+ clockJumpTime_ = 0.0 ;
185+ crossingSince_ = 0.0 ;
186+ bpmLineActive_ = false ;
175187 currentCommand_ = ApplicationCommand::Dummy ();
176188
177189 mpeProfile_ = std::make_unique<MpeProfileNegotiation>();
@@ -497,15 +509,20 @@ void ApplicationState::configureLine(const String& line)
497509
498510String ApplicationState::receive (const MidiMessage& msg)
499511{
500- // capture what the normal receive path prints for this one message
512+ // capture what the normal receive path prints for this one message; a
513+ // capture is not a terminal, so the tempo readout always uses its line
514+ // mode here, keeping tests stable
515+ bool wasInteractive = bpmInteractive_;
516+ bpmInteractive_ = false ;
501517 std::ostringstream captured;
502518 auto * previous = std::cout.rdbuf (captured.rdbuf ());
503519 handleIncomingMidiMessage (nullptr , msg);
504520 std::cout.rdbuf (previous);
521+ bpmInteractive_ = wasInteractive;
505522 return captured.str ();
506523}
507524
508- void ApplicationState::outputMessage ( const MidiMessage& msg, DisplayState& display ) const
525+ void ApplicationState::outputTimestamp ( ) const
509526{
510527 if (timestampOutput_)
511528 {
@@ -515,7 +532,178 @@ void ApplicationState::outputMessage(const MidiMessage& msg, DisplayState& displ
515532 << String (t.getSeconds ()).paddedLeft (' 0' , 2 ) << " ."
516533 << String (t.getMilliseconds ()).paddedLeft (' 0' , 3 ) << " " ;
517534 }
518-
535+ }
536+
537+ // how many MIDI clock timestamps are kept, and the tempo range reported
538+ static constexpr int TIMESTAMP_QUEUE_SIZE = 48 ;
539+ static constexpr double BPM_MIN = 20.0 ;
540+ static constexpr double BPM_MAX = 360.0 ;
541+
542+ // measures the tempo of the incoming MIDI clock from the queued tick
543+ // timestamps; returns 0 while there aren't enough clean intervals yet
544+ double ApplicationState::measureClockBpm (double now) const
545+ {
546+ // keep a queue of MIDI clock timestamps, never exceeding TIMESTAMP_QUEUE_SIZE
547+ clockTimes_.push_front (now);
548+ while (clockTimes_.size () > TIMESTAMP_QUEUE_SIZE )
549+ {
550+ clockTimes_.pop_back ();
551+ }
552+ if (clockTimes_.size () < 2 )
553+ {
554+ return 0.0 ;
555+ }
556+
557+ // the average tick interval: the timestamps in between cancel out, so
558+ // only the newest and the oldest matter
559+ double avg = (clockTimes_.front () - clockTimes_.back ()) / (double )(clockTimes_.size () - 1 );
560+
561+ // only count intervals close to the average, keeping the normal timing
562+ // differences between ticks but dropping stalls and bunched-up late ones
563+ double sum = 0.0 ;
564+ int kept = 0 ;
565+ for (size_t i = 0 ; i + 1 < clockTimes_.size (); i++)
566+ {
567+ double interval = clockTimes_[i] - clockTimes_[i + 1 ];
568+ if (std::abs (avg - interval) < avg * 0.15 )
569+ {
570+ sum += interval;
571+ ++kept;
572+ }
573+ }
574+ // wait for a mostly full window: fewer intervals give a rough reading,
575+ // while requiring even more starves the display at high tempos, where
576+ // ticks are closer together and more of them get dropped above
577+ if (kept < TIMESTAMP_QUEUE_SIZE * 3 / 4 )
578+ {
579+ return 0.0 ;
580+ }
581+
582+ double bpm = int ((600.0 / (sum / kept) / 24.0 ) + 0.5 ) / 10.0 ;
583+ return std::min (std::max (bpm, BPM_MIN ), BPM_MAX );
584+ }
585+
586+ // turns the readings into the whole BPM to display and reports whether it
587+ // changed: single readings wobble, so a running average smooths them and the
588+ // shown value only moves once the average clearly settles on another number
589+ bool ApplicationState::updateShownClockBpm (double bpm, double now) const
590+ {
591+ double dt = now - lastAvgTime_;
592+ if (lastAvgTime_ <= 0.0 || dt > 2.0 )
593+ {
594+ avgBpm_ = bpm;
595+ }
596+ else
597+ {
598+ // after a tempo jump the average starts from a single reading that
599+ // may round to the wrong number: while it disagrees with the display
600+ // it catches up faster, and calms down again once they match
601+ bool correcting = now - clockJumpTime_ < 1.5
602+ && std::abs (avgBpm_ - lastPrintedBpm_) >= 0.35 ;
603+ avgBpm_ += std::min (1.0 , dt / (correcting ? 0.35 : 1.0 )) * (bpm - avgBpm_);
604+ }
605+ lastAvgTime_ = now;
606+
607+ // only a big change counts as a tempo jump, since the wobble at high
608+ // tempos can span a few BPM; small real changes reach the display
609+ // through the average anyway
610+ if (std::abs (bpm - lastPrintedBpm_) >= std::max (4.0 , bpm * 0.03 ))
611+ {
612+ avgBpm_ = bpm;
613+ clockJumpTime_ = now;
614+ }
615+
616+ if (std::abs (avgBpm_ - lastPrintedBpm_) < 0.75 )
617+ {
618+ crossingSince_ = 0.0 ;
619+ return false ;
620+ }
621+
622+ // a move of several BPM shows immediately, but a step to the next
623+ // number has to stick around for a moment first, so a short wobble
624+ // can't flip the display back and forth
625+ double target = (double )((int )(avgBpm_ + 0.5 ));
626+ if (std::abs (target - lastPrintedBpm_) < 2.0 )
627+ {
628+ if (crossingSince_ <= 0.0 )
629+ {
630+ crossingSince_ = now;
631+ return false ;
632+ }
633+ if (now - crossingSince_ < 0.6 )
634+ {
635+ return false ;
636+ }
637+ }
638+ crossingSince_ = 0.0 ;
639+ lastPrintedBpm_ = target;
640+ return true ;
641+ }
642+
643+ void ApplicationState::outputClockTempo (double now) const
644+ {
645+ double bpm = measureClockBpm (now);
646+ if (bpm <= 0.0 )
647+ {
648+ return ;
649+ }
650+ bool changed = updateShownClockBpm (bpm, now);
651+
652+ if (bpmInteractive_)
653+ {
654+ // an interactive terminal gets a single line that is redrawn in
655+ // place when the tempo changes, like an instrument display
656+ if ((changed || !bpmLineActive_) && now - lastBpmTime_ > 0.25 )
657+ {
658+ lastBpmTime_ = now;
659+ std::cout << ' \r ' ;
660+ outputTimestamp ();
661+ // the spaces erase leftovers of a longer previous value, the
662+ // backspaces put the cursor right back after the digits
663+ std::cout << " bpm " << (int )lastPrintedBpm_ << " \b\b " << std::flush;
664+ bpmLineActive_ = true ;
665+ }
666+ }
667+ else
668+ {
669+ // piped or redirected output gets a line right away when the tempo
670+ // changes, and a repeat at a steady pace in between
671+ if (changed || now - lastBpmTime_ > 2.0 )
672+ {
673+ lastBpmTime_ = now;
674+ outputTimestamp ();
675+ std::cout << " bpm " << (int )lastPrintedBpm_ << std::endl;
676+ }
677+ }
678+ }
679+
680+ void ApplicationState::outputMessage (const MidiMessage& msg, DisplayState& display) const
681+ {
682+ if (bpmDisplay_)
683+ {
684+ if (msg.isMidiClock ())
685+ {
686+ // the tempo readout replaces the individual midi-clock lines
687+ outputClockTempo (msg.getTimeStamp ());
688+ return ;
689+ }
690+ if (msg.isMidiStart () || msg.isMidiContinue () || msg.isMidiStop ())
691+ {
692+ // transport changes restart the measurement; the message itself
693+ // still prints below
694+ clockTimes_.clear ();
695+ lastAvgTime_ = 0.0 ;
696+ }
697+ if (bpmLineActive_)
698+ {
699+ // finish the in-place tempo line before a regular line prints
700+ std::cout << std::endl;
701+ bpmLineActive_ = false ;
702+ }
703+ }
704+
705+ outputTimestamp ();
706+
519707 if (msg.isNoteOn ())
520708 {
521709 std::cout << " channel " << outputChannel (msg) << " " <<
@@ -827,6 +1015,10 @@ void ApplicationState::executeCommand(ApplicationCommand& cmd)
8271015 case NOTE_NUMBERS :
8281016 noteNumbersOutput_ = true ;
8291017 break ;
1018+ case BEATS_PER_MINUTE :
1019+ bpmDisplay_ = true ;
1020+ bpmInteractive_ = ansi::terminalIsInteractive ();
1021+ break ;
8301022 case OCTAVE_MIDDLE_C :
8311023 octaveMiddleC_ = asDecOrHex7BitValue (cmd.opts_ [0 ]);
8321024 break ;
0 commit comments