Skip to content

Commit 080e038

Browse files
committed
Give the window an outputs row, and something to send with
§5.9's last unbuilt row, and the one Phase 5's exit criterion turns on: "a new user can go from launch to synced output without documentation, a text editor, or a restart". Until now the window could watch a tempo perfectly and nothing left the machine — Link, OSC and MIDI clock existed only in takt4-cli. The window owns §4.2's output thread now, so **it stopped draining the beat ring**. `rt::SpscRing` allows one consumer; with two, the window and the runner would take half the beats each and the transports would send every other one. `tick()` drains frames, which is a different ring with a different consumer — §5.9's trace. The ordering §4.3 needs has moved into `LiveTracker` rather than staying a rule for callers to remember. The host time stamp is taken on the audio thread, so the clock has to be installed before the stream is opened; `setHostTimeSource` now hands the tracker a clock and `start()` installs it at the right moment. It also resets the regression there, which nothing did before and nothing needed to: the console opens one stream and exits, but a window opens a new one every time Start is pressed, and a regression fitted to the last run maps a sample counter that has restarted at zero to a time long past. `HostTimeSource` grew `resetHostTimeFilter` as a default no-op for that — a restarted sample clock is the interface's business, not Link's alone. The row itself is the sketch: Link with its peer count, an OSC target field, a MIDI port picker, and what has actually been sent. The tick and the reading are deliberately different things — a box says what the operator asked for, the peer count and the beat counter say what is happening, and "on, with nobody at the other end" is exactly the failure somebody needs to see at a gig. The MIDI picker's first entry is empty, so switching the clock off is a choice in the same list rather than a second control. Two small decisions in the OSC field. It applies on Enter rather than on every keystroke, because `edited` fires per character and each one would tear the sockets down and rebuild them halfway through an address. And a part that will not parse is named on the status line while the rest are still applied: an operator typing a second address must not lose the first one that was working. Commas separate, since the window offers a single line. Seven tests. Two hold `LiveTracker` to the §4.3 ordering with a fake `HostTimeSource` — that the clock is not touched before a run, that it is forgotten *before* the stream rather than after, and that a second run forgets the first. Five drive the row: that the window comes up sending nothing, that the Link tick reaches the transports without putting takt4 in front of peers before Start, that a bad OSC entry keeps the good ones, that a MIDI port which will not open is said out loud, and — with a real device — that nothing is dropped, which is what proves exactly one consumer is draining that ring. 176 tests with the UI off, 197 with it on. The excerpt still ends "499 frames, 21 beats (5 downbeats), ending at 127.7 BPM in 4/4, locked".
1 parent db7eb99 commit 080e038

10 files changed

Lines changed: 497 additions & 16 deletions

File tree

‎src/core/audio/host_time.hpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ class HostTimeSource {
2727
/// source promises to call it from exactly one thread.
2828
virtual std::int64_t hostMicrosForSample(double sampleTime) noexcept = 0;
2929

30+
/// Forgets whatever was learned from the sample clock so far.
31+
///
32+
/// A restarted stream counts from zero again, and a regression fitted to the previous
33+
/// run maps that to a time long past — so whoever opens a stream calls this first.
34+
/// `engine::LiveTracker::start` does. The default does nothing, for a source with no
35+
/// state to forget.
36+
virtual void resetHostTimeFilter() noexcept {}
37+
3038
protected:
3139
HostTimeSource() = default;
3240
HostTimeSource(const HostTimeSource&) = default;

‎src/core/engine/live_tracker.cpp‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ void LiveTracker::start(const audio::InputDevice& device,
4343
// driver that will not give up the interface (PortAudioError, HANDOFF R2).
4444
auto stream =
4545
std::make_unique<audio::InputStream>(session_, device, selection, fanout_, options_.stream);
46+
// §4.3's stamp is taken on the audio thread, so the clock has to be in place before
47+
// there is one. A restarted stream begins its sample counter again, and the filter's
48+
// regression is fitted to the old one, so it is forgotten with the run it belonged to.
49+
if (hostTime_ != nullptr) {
50+
hostTime_->resetHostTimeFilter();
51+
}
52+
engine_->setHostTimeSource(hostTime_);
4653
// Before the stream, so the workers are draining by the time the first hop lands.
4754
engine_->start();
4855
try {

‎src/core/engine/live_tracker.hpp‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ class LiveTracker {
6262
LiveTracker(const LiveTracker&) = delete;
6363
LiveTracker& operator=(const LiveTracker&) = delete;
6464

65+
/// The clock that stamps each hop with the host time its audio arrived at (§4.3), or
66+
/// null for none. Ableton Link is the one that exists; `output::OutputRunner` hands
67+
/// its session over as `hostTimeClock()`.
68+
///
69+
/// Set it once, on a tracker that is stopped, and leave it: `start()` installs it
70+
/// before the stream is opened and `stop()` clears it afterwards, which is the
71+
/// ordering §4.3 requires and the reason this is here rather than left to a caller
72+
/// to remember. It must outlive every run it is set for.
73+
void setHostTimeSource(audio::HostTimeSource* source) noexcept { hostTime_ = source; }
74+
audio::HostTimeSource* hostTimeSource() const noexcept { return hostTime_; }
75+
6576
/// Every input device PortAudio can see, freshly enumerated each call — a device list
6677
/// goes stale the moment somebody plugs something in. The indices in it are only
6778
/// meaningful while this object lives.
@@ -112,6 +123,8 @@ class LiveTracker {
112123
audio::HopMeter meter_;
113124
/// The tracker first: a late meter is a late meter, a late tracker is a dropout.
114125
audio::HopFanout fanout_;
126+
/// Not owned, and null unless a caller supplied one. See `setHostTimeSource`.
127+
audio::HostTimeSource* hostTime_ = nullptr;
115128
/// Declared after everything it points at, so it is destroyed before them.
116129
std::unique_ptr<audio::InputStream> stream_;
117130
std::optional<Running> current_;

‎src/core/output/link_session.hpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class LinkSession final : public audio::HostTimeSource {
5050
/// **Audio thread.** HANDOFF §4.3's regression; see HostTimeSource.
5151
std::int64_t hostMicrosForSample(double sampleTime) noexcept override;
5252
/// Forgets the regression, for a stream that has been restarted.
53-
void resetHostTimeFilter() noexcept;
53+
void resetHostTimeFilter() noexcept override;
5454

5555
/// Publishes the tracker's tempo, effective at `at`.
5656
void setTempo(double bpm, std::chrono::microseconds at);

‎src/ui/main_window.slint‎

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
// Nothing in this file assumes a meter of four. `beats-per-bar` comes from the particle
1919
// filter's downbeat stage and the bar indicator is drawn from it, as §5.5 requires.
2020

21-
import { CheckBox, ComboBox, Palette, Slider } from "std-widgets.slint";
21+
import { CheckBox, ComboBox, LineEdit, Palette, Slider } from "std-widgets.slint";
2222

2323
/// One 50 Hz frame of the activation trace. The network's two probabilities, and
2424
/// whether the tracker actually called a beat on this frame — which is the difference
@@ -277,6 +277,19 @@ export component MainWindow inherits Window {
277277
in-out property <float> input-peak;
278278
in-out property <string> input-reading;
279279

280+
// --- §5.9's OUTPUTS row --------------------------------------------------------
281+
// What is being sent, and what it is being sent to. `*-on` is what the operator
282+
// asked for; `link-peers` and `beats-sent` are what actually happened, which is the
283+
// difference between a tick box and a working rig.
284+
in-out property <bool> link-on;
285+
in-out property <int> link-peers;
286+
in-out property <bool> osc-on;
287+
in-out property <string> osc-targets; // one host:port per line
288+
in-out property <bool> midi-on;
289+
in-out property <string> midi-port;
290+
in-out property <[string]> midi-ports;
291+
in-out property <int> beats-sent;
292+
280293
in-out property <string> status;
281294
in-out property <bool> status-is-error;
282295

@@ -295,6 +308,10 @@ export component MainWindow inherits Window {
295308
callback fold-max-changed(float);
296309
callback latency-changed(float);
297310

311+
callback link-toggled(bool);
312+
callback osc-targets-edited(string);
313+
callback midi-port-picked(string);
314+
298315
VerticalLayout {
299316
padding: 0;
300317
spacing: 0;
@@ -763,6 +780,113 @@ export component MainWindow inherits Window {
763780
vertical-stretch: 0;
764781
}
765782

783+
// --- §5.9's OUTPUTS row ---------------------------------------------------------
784+
// "Link ✓ · OSC 192.168.1.40:7000 ✓ · MIDI clock ✓", and settable rather than only
785+
// shown: Phase 5's exit is a new user going from launch to synced output without a
786+
// text editor or a restart, so this is where that happens.
787+
//
788+
// The tick and the reading are deliberately different things. A box says what the
789+
// operator asked for; the peer count and the beat counter say what is actually
790+
// happening, and an output that is on with nothing at the other end is exactly the
791+
// failure somebody needs to see at a gig.
792+
Rectangle {
793+
background: Theme.panel;
794+
vertical-stretch: 0;
795+
796+
HorizontalLayout {
797+
padding-left: 24px;
798+
padding-right: 24px;
799+
padding-top: 12px;
800+
padding-bottom: 12px;
801+
spacing: 10px;
802+
803+
Text {
804+
text: "outputs";
805+
color: Theme.dim;
806+
font-size: 12px;
807+
width: 74px;
808+
vertical-alignment: center;
809+
}
810+
811+
CheckBox {
812+
text: "Link";
813+
checked <=> root.link-on;
814+
width: 74px;
815+
toggled => {
816+
root.link-toggled(self.checked);
817+
}
818+
}
819+
820+
Text {
821+
// Peers are the whole point of Link, and "on with nobody there" is the state
822+
// worth being able to see.
823+
text: !root.link-on ? ""
824+
: root.link-peers == 0 ? "no peers"
825+
: root.link-peers == 1 ? "1 peer"
826+
: root.link-peers + " peers";
827+
color: root.link-peers > 0 ? Theme.locked : Theme.dim;
828+
font-size: 12px;
829+
width: 72px;
830+
vertical-alignment: center;
831+
}
832+
833+
Text {
834+
text: "OSC";
835+
color: root.osc-on ? Theme.text : Theme.dim;
836+
font-size: 12px;
837+
width: 32px;
838+
vertical-alignment: center;
839+
}
840+
841+
osc-field := LineEdit {
842+
text <=> root.osc-targets;
843+
placeholder-text: "host:port — Enter to apply";
844+
font-size: 12px;
845+
horizontal-stretch: 1;
846+
// `accepted`, not `edited`: `edited` fires on every keystroke, and each one
847+
// would tear the sockets down and build them again halfway through an address.
848+
accepted(text) => {
849+
root.osc-targets-edited(text);
850+
}
851+
}
852+
853+
Text {
854+
text: "MIDI clock";
855+
color: root.midi-on ? Theme.text : Theme.dim;
856+
font-size: 12px;
857+
width: 72px;
858+
vertical-alignment: center;
859+
}
860+
861+
midi-picker := ComboBox {
862+
model: root.midi-ports;
863+
current-value <=> root.midi-port;
864+
horizontal-stretch: 1;
865+
selected => {
866+
root.midi-port-picked(self.current-value);
867+
}
868+
}
869+
870+
Text {
871+
// What has actually gone out. Nothing else on screen distinguishes a transport
872+
// that is configured from one that is working.
873+
text: root.beats-sent > 0 ? root.beats-sent + " beats sent" : "";
874+
color: Theme.dim;
875+
font-size: 12px;
876+
font-family: "Consolas";
877+
width: 104px;
878+
horizontal-alignment: right;
879+
vertical-alignment: center;
880+
}
881+
}
882+
}
883+
884+
Rectangle {
885+
height: 1px;
886+
background: Theme.line;
887+
vertical-stretch: 0;
888+
}
889+
766890
// --- the status line ------------------------------------------------------------
767891
Rectangle {
768892
background: Theme.panel;

‎src/ui/shot.cpp‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "core/engine/beat_engine.hpp"
2323
#include "core/io/wav_file.hpp"
2424
#include "core/model/weights.hpp"
25+
#include "core/output/midi_ports.hpp"
2526
#include "core/tracking/state_space.hpp"
2627
#include "core/tracking/tempo_tracker.hpp"
2728
#include "ui/app.hpp"
@@ -223,11 +224,29 @@ int renderShot(const std::filesystem::path& out, const ShotOptions& options) {
223224
publishControlLimits(*window);
224225
window->set_tap_needs(3);
225226
fillPickers(*window);
227+
228+
// The machine's real MIDI outputs, as the window offers them; the empty first entry
229+
// is "none", exactly as WindowController builds it.
230+
auto midiPorts = std::make_shared<slint::VectorModel<slint::SharedString>>();
231+
midiPorts->push_back(slint::SharedString(""));
232+
for (const std::string& port : output::listMidiOutputPorts()) {
233+
midiPorts->push_back(slint::SharedString(port));
234+
}
235+
window->set_midi_ports(midiPorts);
226236
auto traceModel =
227237
std::make_shared<slint::VectorModel<TracePoint>>(std::vector<TracePoint>(kTraceLength));
228238
window->set_trace(traceModel);
229239
if (options.running) {
230240
fillFromSyntheticRun(*window, traceModel);
241+
// §5.9's own example row, so the picture shows what a configured rig looks like
242+
// rather than an empty one. There is no output thread behind a shot, so these are
243+
// illustrative in the way the status line below already is; the beat count is the
244+
// excerpt's real 21.
245+
window->set_link_on(true);
246+
window->set_link_peers(2);
247+
window->set_osc_on(true);
248+
window->set_osc_targets(slint::SharedString("192.168.1.40:7000"));
249+
window->set_beats_sent(21);
231250
window->set_status(slint::SharedString(
232251
"In 7 of MOTU Pro Audio · 48000 Hz -> 22050 Hz · native pick · "
233252
"latency 12.0 ms input + 16.4 ms resampler + 40.0 ms centred framing"));

0 commit comments

Comments
 (0)