Skip to content

Commit e3b09c7

Browse files
committed
Test the window without anyone standing at the machine
§9 said four things about the window could only be checked by a person: that the redraw timer actually runs, that the trace scrolls rather than sits, that picking a device repopulates the channel list, and that Start opens a device and Stop closes it. Three of the four are now tests, and the fourth is a test up to its last line. takt4_ui_tests is a second binary rather than more files in takt4_tests. That one links takt4_core and nothing else, which is what proves the engine stands up with no Slint anywhere near it (§4.2); linking the UI into it would quietly retire the proof. Nine tests, and they run with no display: aa9151a's headless platform comes out of shot.cpp into ui/headless.cpp so the tests install the same one, and a window created under it never reaches a screen. Slint's own slint::testing::init() would do that too, and would additionally let a test find the Button element and click it. It is behind SLINT_FEATURE_EXPERIMENTAL — "no backward compatibility guarantees" — which this build leaves off, and turning it on globally to cover one line of markup is the wrong trade. So the run test drives the window's toggle-run callback rather than the button that calls it, and the untested remainder is exactly that one binding. The button is named `run-button` in the markup so the better test can be written the day the feature stabilises. What the tests hold: * an engine value reaches the property it should, and stopping clears it rather than freezing the last set's tempo on screen * the octave-fold window arrives intact (§7 deviation 4 wants it visible) * the input meter's dBFS mapping, including that silence reads "-inf dB" * the device list is the tracker's, in order, and a channel is described by the driver's name for it where ASIO or CoreAudio supplies one * the redraw timer fires on its own, driven through slint::platform::update_timers_and_animations * a device picked from the window repopulates the channel list, and an index the list does not have is ignored rather than crashing * (hardware) toggle-run opens a real device and closes it again * (hardware) the trace scrolls: after two seconds the newest end carries the network's output and the oldest end is still the zero it started as, which a fill rather than a rolling window would have overwritten WindowController comes out of app.cpp's anonymous namespace into a header of its own so a test can hold one. app.cpp is now the entry point and nothing else. The window's data properties are `in-out` rather than `in`: Slint generates a getter only for those, and without one a test could set a property but never see what the window made of it. Nothing in the markup writes to them. 167 tests with the UI on, 158 with it off — the second number is the engine on its own and is the one that has not changed.
1 parent aa9151a commit e3b09c7

11 files changed

Lines changed: 825 additions & 330 deletions

‎src/ui/CMakeLists.txt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,25 @@ target_link_libraries(takt4_ui_slint PUBLIC Slint::Slint)
2626
add_library(takt4_ui STATIC
2727
app.cpp
2828
app.hpp
29+
headless.cpp
30+
headless.hpp
2931
shot.cpp
32+
window_controller.cpp
33+
window_controller.hpp
3034
window_state.cpp
3135
window_state.hpp
3236
)
3337
add_library(takt4::ui ALIAS takt4_ui)
3438

3539
target_include_directories(takt4_ui PUBLIC "${PROJECT_SOURCE_DIR}/src")
3640

41+
# takt4_ui_slint is PUBLIC because window_controller.hpp — a header consumers include —
42+
# includes the generated main_window.h. It arrives as a system include (SYSTEM ON above),
43+
# so our warning flags still do not apply to generated code in a consumer either.
3744
target_link_libraries(takt4_ui
3845
PUBLIC
3946
takt4::core
40-
PRIVATE
4147
takt4_ui_slint
48+
PRIVATE
4249
takt4::warnings
4350
)

‎src/ui/app.cpp‎

Lines changed: 1 addition & 265 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,16 @@
11
#include "ui/app.hpp"
22

3-
#include "core/audio/channel_picker.hpp"
4-
#include "core/audio/devices.hpp"
5-
#include "core/audio/hop_meter.hpp"
6-
#include "core/audio/input_stream.hpp"
7-
#include "core/audio/portaudio_session.hpp"
8-
#include "core/audio/rates.hpp"
9-
#include "core/build_info.hpp"
10-
#include "core/engine/beat_engine.hpp"
113
#include "core/engine/live_tracker.hpp"
12-
#include "core/tracking/tempo_tracker.hpp"
13-
#include "ui/window_state.hpp"
4+
#include "ui/window_controller.hpp"
145

15-
#include "main_window.h" // generated from main_window.slint
16-
17-
#include <algorithm>
18-
#include <chrono>
19-
#include <cstddef>
206
#include <exception>
217
#include <filesystem>
228
#include <iostream>
239
#include <memory>
24-
#include <string>
25-
#include <tuple>
26-
#include <vector>
2710

2811
namespace takt4::ui {
2912
namespace {
3013

31-
using namespace std::chrono_literals;
32-
33-
/// How often the window drains the engine's rings and redraws. HANDOFF §7.5: "Push
34-
/// audio-side results through a lock-free ring and drain on a UI timer — do not queue one
35-
/// closure per audio callback." 30 Hz is slower than the 50 Hz the frames arrive at, so
36-
/// every tick finds one or two waiting and none is ever missed; the ring holds ten
37-
/// seconds of them, so even a stalled event loop loses nothing.
38-
constexpr auto kRedrawInterval = 33ms;
39-
40-
/// How far the peak indicator falls back each tick — slow enough that a moment of
41-
/// clipping is still on screen when the operator looks up.
42-
constexpr float kPeakDecay = 0.88f;
43-
4414
std::filesystem::path weightsPath() {
4515
return std::filesystem::path(TAKT4_WEIGHTS_DIR) / "generic.bin";
4616
}
@@ -49,240 +19,6 @@ std::filesystem::path stateSpacePath() {
4919
return std::filesystem::path(TAKT4_STATESPACE_DIR) / "default.bin";
5020
}
5121

52-
slint::SharedString shared(const std::string& text) {
53-
return slint::SharedString(text);
54-
}
55-
56-
/// Everything the window needs that is not the window: the tracker behind it, the model
57-
/// behind its trace, and the one timer that drains the engine's rings.
58-
///
59-
/// Nothing here is ever reached from the audio thread, which is the whole of §7.5's
60-
/// rule. The engine writes into lock-free rings from its own threads; this reads them on
61-
/// the UI thread, on a timer, and touches a Slint property nowhere else.
62-
class WindowController {
63-
public:
64-
explicit WindowController(engine::LiveTracker& tracker)
65-
: tracker_(tracker), window_(MainWindow::create()), trace_(kTraceLength),
66-
traceModel_(std::make_shared<slint::VectorModel<TracePoint>>(
67-
std::vector<TracePoint>(kTraceLength))) {
68-
window_->set_trace(traceModel_);
69-
70-
window_->on_device_picked([this](int index) { pickDevice(index); });
71-
window_->on_channel_picked([this](int index) { channel_ = index; });
72-
window_->on_toggle_run([this] { toggleRun(); });
73-
74-
refreshDevices();
75-
publishStopped();
76-
77-
timer_.start(slint::TimerMode::Repeated, kRedrawInterval, [this] { tick(); });
78-
}
79-
80-
void run() { window_->run(); }
81-
82-
private:
83-
void refreshDevices() {
84-
devices_ = tracker_.devices();
85-
auto names = std::make_shared<slint::VectorModel<slint::SharedString>>();
86-
for (const audio::InputDevice& device : devices_) {
87-
names->push_back(shared(describeDevice(device)));
88-
}
89-
window_->set_devices(names);
90-
91-
if (devices_.empty()) {
92-
device_ = -1;
93-
window_->set_channels(std::make_shared<slint::VectorModel<slint::SharedString>>());
94-
setStatus("No input device. Connect an interface and start takt4 again.", true);
95-
return;
96-
}
97-
98-
// The most useful device to land on: a real input before a loopback of the
99-
// speakers (Q3 offers loopback as a way round a busy interface, not as the
100-
// normal path), then a host API that can pick channels natively, then the
101-
// machine's own default input, then the one with the most inputs.
102-
const auto rank = [](const audio::InputDevice& device) {
103-
return std::make_tuple(!device.isLoopback,
104-
audio::hasNativeChannelSelection(device.hostApi),
105-
device.isDefaultInput, device.maxInputChannels);
106-
};
107-
std::size_t best = 0;
108-
for (std::size_t i = 1; i < devices_.size(); ++i) {
109-
if (rank(devices_[i]) > rank(devices_[best])) {
110-
best = i;
111-
}
112-
}
113-
window_->set_device_index(static_cast<int>(best));
114-
pickDevice(static_cast<int>(best));
115-
}
116-
117-
void pickDevice(int index) {
118-
if (index < 0 || static_cast<std::size_t>(index) >= devices_.size()) {
119-
return;
120-
}
121-
device_ = index;
122-
channel_ = 0;
123-
const audio::InputDevice& device = devices_[static_cast<std::size_t>(index)];
124-
auto names = std::make_shared<slint::VectorModel<slint::SharedString>>();
125-
for (int c = 0; c < device.maxInputChannels; ++c) {
126-
names->push_back(shared(describeChannel(device, c)));
127-
}
128-
window_->set_channels(names);
129-
window_->set_channel_index(0);
130-
if (statusIsError_) {
131-
// Whatever went wrong was about the device that is no longer selected.
132-
setStatus("Pick an input and press Start.", false);
133-
}
134-
}
135-
136-
void toggleRun() {
137-
if (tracker_.running()) {
138-
tracker_.stop();
139-
publishStopped();
140-
return;
141-
}
142-
if (device_ < 0) {
143-
return;
144-
}
145-
const audio::InputDevice& device = devices_[static_cast<std::size_t>(device_)];
146-
try {
147-
tracker_.start(device, audio::ChannelSelection::single(channel_));
148-
} catch (const audio::PortAudioError& e) {
149-
// HANDOFF Q3 and R2: most ASIO drivers are single-client, and a busy
150-
// interface is the exact situation takt4 exists for. A PortAudio error code
151-
// is no use to anyone standing at a laptop, so say what it means and what the
152-
// ways round it are.
153-
setStatus("Cannot open " + device.name + ": " + e.what() +
154-
". If another application already has this interface, most ASIO "
155-
"drivers will not share it — use the same interface's WASAPI "
156-
"loopback, a virtual audio cable, or a spare physical input.",
157-
true);
158-
publishStopped();
159-
return;
160-
} catch (const std::exception& e) {
161-
setStatus("Cannot open " + device.name + ": " + e.what(), true);
162-
publishStopped();
163-
return;
164-
}
165-
std::fill(trace_.begin(), trace_.end(), TracePoint{});
166-
publishTrace();
167-
peak_ = 0.0f;
168-
window_->set_running(true);
169-
publishOpenStream();
170-
}
171-
172-
void publishStopped() {
173-
window_->set_running(false);
174-
publishIdleReadouts(*window_);
175-
publishOptions();
176-
if (!devices_.empty() && !statusIsError_) {
177-
setStatus("takt4 " + buildInfo().version + " — pick an input and press Start.", false);
178-
}
179-
}
180-
181-
/// What the signal goes through before a beat can be called: the number §5.5's
182-
/// latency offset exists to cancel, and the first thing to look at when downstream is
183-
/// late. Only knowable once a stream is actually open.
184-
void publishOpenStream() {
185-
const audio::InputStream* stream = tracker_.stream();
186-
if (stream == nullptr || !tracker_.current()) {
187-
return;
188-
}
189-
const double resamplerMs =
190-
1000.0 * static_cast<double>(stream->resamplerDelayFrames()) / stream->sampleRate();
191-
const engine::LiveTracker::Running& running = *tracker_.current();
192-
setStatus("In " + std::to_string(running.selection.channels[0] + 1) + " of " +
193-
running.device.name + " · " + fixed(stream->sampleRate(), 0) + " Hz -> " +
194-
fixed(audio::kInternalSampleRate, 0) + " Hz · " +
195-
audio::toString(stream->picker().mode()) + " pick · latency " +
196-
fixed(stream->inputLatencySeconds() * 1000.0, 1) + " ms input + " +
197-
fixed(resamplerMs, 1) + " ms resampler + 40.0 ms centred framing",
198-
false);
199-
}
200-
201-
/// §5.5's settings as the tracker actually holds them. Read from the engine every
202-
/// time and never from a copy: a tap moves the fold window underneath anyone keeping
203-
/// one (§7 deviation 8), and this window exists to show that window honestly.
204-
void publishOptions() { publishTempoOptions(*window_, tracker_.engine().tempoOptions()); }
205-
206-
void setStatus(const std::string& text, bool error) {
207-
statusIsError_ = error;
208-
window_->set_status(shared(text));
209-
window_->set_status_is_error(error);
210-
}
211-
212-
void tick() {
213-
if (!tracker_.running()) {
214-
return;
215-
}
216-
217-
// Every frame since the last tick joins the trace, newest at the right.
218-
bool moved = false;
219-
engine::EngineFrame frame;
220-
while (tracker_.engine().popFrame(frame)) {
221-
std::rotate(trace_.begin(), trace_.begin() + 1, trace_.end());
222-
trace_.back() = tracePoint(frame);
223-
moved = true;
224-
}
225-
if (moved) {
226-
publishTrace();
227-
}
228-
229-
// The beats are drained and dropped. This window says nothing a beat carries that
230-
// the published state does not already have — but a ring nobody drains fills up,
231-
// and the engine would rightly start counting beats lost. When the outputs arrive
232-
// this is where they are driven from, or from the output thread that replaces this
233-
// loop (§8 item 2).
234-
engine::EngineBeat beat;
235-
while (tracker_.engine().popBeat(beat)) {
236-
}
237-
238-
publishState();
239-
publishLevels();
240-
}
241-
242-
void publishTrace() {
243-
for (std::size_t i = 0; i < trace_.size(); ++i) {
244-
traceModel_->set_row_data(i, trace_[i]);
245-
}
246-
}
247-
248-
void publishState() {
249-
publishTempoState(*window_, tracker_.engine().state());
250-
publishOptions();
251-
}
252-
253-
void publishLevels() {
254-
float loudest = -1.0f; // negative: no hop arrived this tick
255-
float peak = 0.0f;
256-
audio::HopLevel level;
257-
while (tracker_.popLevel(level)) {
258-
loudest = std::max(loudest, level.rms);
259-
peak = std::max(peak, level.peak);
260-
}
261-
peak_ = std::max(peak, peak_ * kPeakDecay);
262-
if (loudest < 0.0f) {
263-
// Nothing arrived this tick: let the peak fall, leave the reading where it
264-
// was rather than flashing to silence.
265-
window_->set_input_peak(peak_);
266-
return;
267-
}
268-
publishInput(*window_, loudest, peak_);
269-
}
270-
271-
engine::LiveTracker& tracker_;
272-
slint::ComponentHandle<MainWindow> window_;
273-
slint::Timer timer_;
274-
275-
std::vector<audio::InputDevice> devices_;
276-
int device_ = -1;
277-
int channel_ = 0;
278-
279-
/// The trace as a plain buffer, oldest first, mirrored into the model each tick.
280-
std::vector<TracePoint> trace_;
281-
std::shared_ptr<slint::VectorModel<TracePoint>> traceModel_;
282-
float peak_ = 0.0f;
283-
bool statusIsError_ = false;
284-
};
285-
28622
} // namespace
28723

28824
int run() {

‎src/ui/headless.cpp‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#include "ui/headless.hpp"
2+
3+
#include <memory>
4+
5+
namespace takt4::ui {
6+
namespace {
7+
8+
/// The platform the runtime is given instead of winit. It never runs an event loop:
9+
/// whoever installed it either renders on demand (`takt4-shot`) or steps the timers
10+
/// itself (`takt4_ui_tests`).
11+
class HeadlessPlatform final : public slint::platform::Platform {
12+
public:
13+
explicit HeadlessPlatform(slint::PhysicalSize size) : size_(size) {}
14+
15+
std::unique_ptr<slint::platform::WindowAdapter> create_window_adapter() override {
16+
auto adapter = std::make_unique<HeadlessWindow>(size_);
17+
window = adapter.get();
18+
return adapter;
19+
}
20+
21+
/// The most recent adapter the runtime asked for. Owned by the runtime, not by this.
22+
HeadlessWindow* window = nullptr;
23+
24+
private:
25+
slint::PhysicalSize size_;
26+
};
27+
28+
} // namespace
29+
30+
HeadlessWindow* const* installHeadlessPlatform(std::uint32_t width, std::uint32_t height) {
31+
auto platform = std::make_unique<HeadlessPlatform>(slint::PhysicalSize({width, height}));
32+
// The address of the member outlives this function: the platform is handed to the
33+
// runtime, which keeps it for the life of the process. A pointer-to-pointer rather
34+
// than a pointer because the adapter does not exist yet — the runtime only asks for
35+
// one when the first component is created.
36+
HeadlessWindow* const* latest = &platform->window;
37+
slint::platform::set_platform(std::move(platform));
38+
return latest;
39+
}
40+
41+
} // namespace takt4::ui

0 commit comments

Comments
 (0)