Skip to content

Commit b450914

Browse files
committed
Added a bpm command that shows the tempo of incoming MIDI clock
1 parent 1f12368 commit b450914

9 files changed

Lines changed: 304 additions & 17 deletions

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ These are all the supported commands:
5151
ch number Set MIDI channel for the commands (0-16), defaults to 0
5252
ts Output a timestamp for each received MIDI message
5353
nn Output notes as numbers instead of names
54+
bpm Show tempo of incoming MIDI clock instead of the ticks
5455
omc number Set octave for middle C, defaults to 3
5556
voice Show all Channel Voice messages
5657
note Show all Note messages
@@ -102,12 +103,12 @@ Options:
102103
Alternatively, you can use the following long versions of the commands:
103104
```
104105
device virtual pass-through decimal hexadecimal channel timestamp
105-
note-numbers octave-middle-c note-on note-off poly-pressure control-change
106-
control-change-14 nrpn-full rpn-full program-change channel-pressure
107-
pitch-bend system-realtime continue active-sensing reset system-common
108-
system-exclusive system-exclusive-file time-code song-position song-select
109-
tune-request quiet javascript javascript-file mpe-profile mpe-channel-reponse
110-
mpe-pitch-bend mpe-channel-pressure mpe-3rd-dimension
106+
note-numbers beats-per-minute octave-middle-c note-on note-off poly-pressure
107+
control-change control-change-14 nrpn-full rpn-full program-change
108+
channel-pressure pitch-bend system-realtime continue active-sensing reset
109+
system-common system-exclusive system-exclusive-file time-code song-position
110+
song-select tune-request quiet javascript javascript-file mpe-profile
111+
mpe-channel-reponse mpe-pitch-bend mpe-channel-pressure mpe-3rd-dimension
111112
```
112113

113114
By default, numbers are interpreted in the decimal system, this can be changed to hexadecimal by sending the "hex" command.

Source/ApplicationCommand.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ enum CommandIndex
3535
CHANNEL,
3636
TIMESTAMP,
3737
NOTE_NUMBERS,
38+
BEATS_PER_MINUTE,
3839
OCTAVE_MIDDLE_C,
3940
VOICE,
4041
NOTE,

Source/ApplicationState.cpp

Lines changed: 195 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
#include "ScriptUtilClass.h"
2323
#include "TerminalColor.h"
2424

25+
#include <algorithm>
26+
#include <cmath>
2527
#include <sstream>
2628

2729
static 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

498510
String 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;

Source/ApplicationState.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
#pragma once
2020

21+
#include <deque>
22+
2123
#include "JuceHeader.h"
2224

2325
#include "ApplicationCommand.h"
@@ -65,6 +67,10 @@ class ApplicationState : public MidiInputCallback, public Timer
6567
void parseFile(File file);
6668
void handleIncomingMidiMessage(MidiInput*, const MidiMessage& msg) override;
6769
void dumpMessage(const MidiMessage& msg) const;
70+
void outputTimestamp() const;
71+
double measureClockBpm(double now) const;
72+
bool updateShownClockBpm(double bpm, double now) const;
73+
void outputClockTempo(double now) const;
6874
String output7BitAsHex(int v) const;
6975
String output7Bit(int v) const;
7076
String output14BitAsHex(int v) const;
@@ -93,6 +99,20 @@ class ApplicationState : public MidiInputCallback, public Timer
9399
bool useHexadecimalsByDefault_;
94100
bool quiet_;
95101
bool rawdump_;
102+
103+
// tempo readout for the bpm option: a single line updated in place on an
104+
// interactive terminal, separate lines otherwise; mutable because the
105+
// const output path keeps the measurement up to date
106+
bool bpmDisplay_;
107+
bool bpmInteractive_;
108+
mutable std::deque<double> clockTimes_;
109+
mutable double lastBpmTime_;
110+
mutable double lastPrintedBpm_;
111+
mutable double avgBpm_;
112+
mutable double lastAvgTime_;
113+
mutable double clockJumpTime_;
114+
mutable double crossingSince_;
115+
mutable bool bpmLineActive_;
96116

97117
String midiInName_;
98118
std::unique_ptr<MidiInput> midiIn_;

Source/MpeProfileNegotation.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
* This file is part of ReceiveMIDI.
33
* Copyright (command) 2017-2024 Uwyn LLC. https://www.uwyn.com
44
*
5-
* SendMIDI is free software: you can redistribute it and/or modify
5+
* ReceiveMIDI is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU General Public License as published by
77
* the Free Software Foundation, either version 3 of the License, or
88
* (at your option) any later version.
99
*
10-
* SendMIDI is distributed in the hope that it will be useful,
10+
* ReceiveMIDI is distributed in the hope that it will be useful,
1111
* but WITHOUT ANY WARRANTY; without even the implied warranty of
1212
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1313
* GNU General Public License for more details.

Source/MpeProfileNegotiation.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
* This file is part of ReceiveMIDI.
33
* Copyright (command) 2017-2024 Uwyn LLC. https://www.uwyn.com
44
*
5-
* SendMIDI is free software: you can redistribute it and/or modify
5+
* ReceiveMIDI is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU General Public License as published by
77
* the Free Software Foundation, either version 3 of the License, or
88
* (at your option) any later version.
99
*
10-
* SendMIDI is distributed in the hope that it will be useful,
10+
* ReceiveMIDI is distributed in the hope that it will be useful,
1111
* but WITHOUT ANY WARRANTY; without even the implied warranty of
1212
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1313
* GNU General Public License for more details.

Source/TerminalColor.cpp

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
* This file is part of ReceiveMIDI.
33
* Copyright (command) 2017-2026 Uwyn LLC. https://www.uwyn.com
44
*
5-
* RouteMIDI is free software: you can redistribute it and/or modify
5+
* ReceiveMIDI is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU General Public License as published by
77
* the Free Software Foundation, either version 3 of the License, or
88
* (at your option) any later version.
99
*
10-
* RouteMIDI is distributed in the hope that it will be useful,
10+
* ReceiveMIDI is distributed in the hope that it will be useful,
1111
* but WITHOUT ANY WARRANTY; without even the implied warranty of
1212
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1313
* GNU General Public License for more details.
@@ -110,4 +110,13 @@ bool terminalSupportsTrueColor()
110110
#endif
111111
}
112112

113+
bool terminalIsInteractive()
114+
{
115+
#if JUCE_WINDOWS
116+
return _isatty(_fileno(stdout)) != 0;
117+
#else
118+
return isatty(fileno(stdout)) != 0;
119+
#endif
120+
}
121+
113122
} // namespace ansi

0 commit comments

Comments
 (0)