Skip to content

Commit aa9151a

Browse files
committed
Render the window where there is no screen to show it on
takt4 is worked on from sessions that have a window station but no display, and §5.9's window could not be looked at from one. Measured, both ways: a screen DC does not exist at all — CreateDC("DISPLAY", …) returns null and BitBlt from GetDC(NULL) fails — and PrintWindow with PW_RENDERFULLCONTENT hands back the frame with a white client area, on the Skia/OpenGL renderer and under SLINT_BACKEND=winit-skia-software alike. So the window built, launched, sized itself correctly and closed cleanly, and nobody could say whether it looked right. takt4-shot does not need a screen. A slint::platform::Platform of our own hands the runtime a window adapter whose SoftwareRenderer draws into a buffer we own, and that goes out as a BMP. It is the real component — real layout, real fonts, real colours — not a mock-up of it. The readouts are filled by running the tracker over tests/data/features/synthetic.wav, so the picture holds numbers the engine actually produced and can be checked against what `takt4-cli track` prints for the same file: 127.66 BPM, locked, 4/4. The pickers are filled from whatever audio hardware is present. Looking at the first render found four things immediately, all fixed here: * A y binding on a child of a layout is silently ignored — the layout owns its children's positions. "BPM" floated above the number instead of sitting on its baseline; vertical-alignment is what that wanted. The bar dots carried the same dead binding. * A VerticalLayout's default alignment spreads leftover height *between* children that cannot stretch, so the tempo readouts were pushed forty pixels apart with the meter left stranded halfway down the window. * "folded from N BPM" tested the difference between the raw and published tempo, so it fired on the beat-spacing refinement — 126.8 against 127.66 — and called it an octave fold. A fold is a halving or a doubling, so the test is the ratio. * The activation trace was pinned at 88 px while the readouts took every spare pixel. That is backwards: the trace is the one thing here worth more when it is bigger, and §5.9 says it is what carries this UI. ui/window_state.cpp comes out of app.cpp so there is one place that knows how an engine value becomes a window property, and both the live window and takt4-shot use it. That is what lets the shot tool fill the window with no tracker, no timer and no audio device behind it. 158 tests, unchanged: this adds no engine code. Builds clean with the UI on and with it off.
1 parent 24cf222 commit aa9151a

9 files changed

Lines changed: 556 additions & 90 deletions

File tree

‎src/CMakeLists.txt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,19 @@ if(TAKT4_BUILD_UI)
2222
target_link_libraries(takt4 PRIVATE takt4::ui takt4::core takt4::warnings)
2323
takt4_enable_rt_guard(takt4 DEBUG_ONLY)
2424

25+
# takt4-shot: renders the window to an image with no display involved (src/ui/shot.cpp).
26+
# A bench tool like takt4-cli, never packaged.
27+
add_executable(takt4-shot shot_main.cpp)
28+
target_link_libraries(takt4-shot PRIVATE takt4::ui takt4::core takt4::warnings)
29+
2530
# The same weight set and state space the console reads, from the same place in the
2631
# source tree, for the same reason: there is no installer yet. Phase 7 replaces this
2732
# with paths resolved next to the executable — see HANDOFF §8 Phase 7.
33+
# TAKT4_TEST_DATA_DIR is takt4-shot's: it fills the readouts by running the tracker over
34+
# the committed synthetic excerpt, so the picture holds numbers the engine really made.
2835
target_compile_definitions(takt4_ui PRIVATE
2936
"TAKT4_WEIGHTS_DIR=\"${PROJECT_SOURCE_DIR}/assets/weights\""
3037
"TAKT4_STATESPACE_DIR=\"${PROJECT_SOURCE_DIR}/assets/statespace\""
38+
"TAKT4_TEST_DATA_DIR=\"${PROJECT_SOURCE_DIR}/tests/data\""
3139
)
3240
endif()

‎src/shot_main.cpp‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// takt4-shot — render the main window to an image without a display. See src/ui/shot.cpp
2+
// for why this exists. A bench tool, like takt4-cli; never packaged.
3+
4+
#include "core/build_info.hpp"
5+
#include "ui/app.hpp"
6+
7+
#include <exception>
8+
#include <filesystem>
9+
#include <iostream>
10+
#include <string>
11+
#include <string_view>
12+
13+
namespace {
14+
15+
void printUsage(std::ostream& out) {
16+
out << "takt4-shot " << takt4::buildInfo().version
17+
<< " — render the main window to a BMP\n"
18+
"\n"
19+
" takt4-shot OUT.bmp [--size WxH]\n"
20+
"\n"
21+
" --size WxH the window size to render, default 900x520\n"
22+
"\n"
23+
"Draws the real component with Slint's software renderer, so it needs no\n"
24+
"display: the readouts are filled by running the tracker over\n"
25+
"tests/data/features/synthetic.wav, which `takt4-cli track` prints as\n"
26+
"\"499 frames, 21 beats (5 downbeats), ending at 127.7 BPM in 4/4, locked\".\n";
27+
}
28+
29+
} // namespace
30+
31+
int main(int argc, char** argv) {
32+
std::filesystem::path out;
33+
int width = 900;
34+
int height = 520;
35+
36+
for (int i = 1; i < argc; ++i) {
37+
const std::string_view arg = argv[i];
38+
if (arg == "--help" || arg == "-h") {
39+
printUsage(std::cout);
40+
return 0;
41+
}
42+
if (arg == "--size") {
43+
if (i + 1 >= argc) {
44+
std::cerr << "takt4-shot: --size needs WxH\n";
45+
return 2;
46+
}
47+
const std::string value = argv[++i];
48+
const std::size_t cross = value.find('x');
49+
if (cross == std::string::npos) {
50+
std::cerr << "takt4-shot: --size expects WxH, got " << value << '\n';
51+
return 2;
52+
}
53+
try {
54+
width = std::stoi(value.substr(0, cross));
55+
height = std::stoi(value.substr(cross + 1));
56+
} catch (const std::exception&) {
57+
std::cerr << "takt4-shot: --size expects two numbers, got " << value << '\n';
58+
return 2;
59+
}
60+
} else if (arg.starts_with("-")) {
61+
std::cerr << "takt4-shot: unknown option '" << arg << "'\n";
62+
printUsage(std::cerr);
63+
return 2;
64+
} else if (out.empty()) {
65+
out = std::filesystem::path(arg);
66+
} else {
67+
std::cerr << "takt4-shot: one output file, not two\n";
68+
return 2;
69+
}
70+
}
71+
72+
if (out.empty()) {
73+
printUsage(std::cerr);
74+
return 2;
75+
}
76+
77+
try {
78+
return takt4::ui::renderShot(out, width, height);
79+
} catch (const std::exception& e) {
80+
std::cerr << "takt4-shot: " << e.what() << '\n';
81+
return 1;
82+
}
83+
}

‎src/ui/CMakeLists.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ target_link_libraries(takt4_ui_slint PUBLIC Slint::Slint)
2626
add_library(takt4_ui STATIC
2727
app.cpp
2828
app.hpp
29+
shot.cpp
30+
window_state.cpp
31+
window_state.hpp
2932
)
3033
add_library(takt4::ui ALIAS takt4_ui)
3134

‎src/ui/app.cpp‎

Lines changed: 10 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include "core/engine/beat_engine.hpp"
1111
#include "core/engine/live_tracker.hpp"
1212
#include "core/tracking/tempo_tracker.hpp"
13+
#include "ui/window_state.hpp"
1314

1415
#include "main_window.h" // generated from main_window.slint
1516

@@ -18,10 +19,8 @@
1819
#include <cstddef>
1920
#include <exception>
2021
#include <filesystem>
21-
#include <iomanip>
2222
#include <iostream>
2323
#include <memory>
24-
#include <sstream>
2524
#include <string>
2625
#include <tuple>
2726
#include <vector>
@@ -31,20 +30,13 @@ namespace {
3130

3231
using namespace std::chrono_literals;
3332

34-
/// Frames of activation the trace holds: four seconds at the 50 Hz frame rate, which is
35-
/// two bars at 120 BPM — long enough to see the pattern the network is responding to.
36-
constexpr std::size_t kTraceLength = 200;
37-
3833
/// How often the window drains the engine's rings and redraws. HANDOFF §7.5: "Push
3934
/// audio-side results through a lock-free ring and drain on a UI timer — do not queue one
4035
/// closure per audio callback." 30 Hz is slower than the 50 Hz the frames arrive at, so
4136
/// every tick finds one or two waiting and none is ever missed; the ring holds ten
4237
/// seconds of them, so even a stalled event loop loses nothing.
4338
constexpr auto kRedrawInterval = 33ms;
4439

45-
/// The bottom of the input meter. Quieter than this is not a level anyone is setting.
46-
constexpr float kMeterFloorDb = -60.0f;
47-
4840
/// How far the peak indicator falls back each tick — slow enough that a moment of
4941
/// clipping is still on screen when the operator looks up.
5042
constexpr float kPeakDecay = 0.88f;
@@ -57,39 +49,10 @@ std::filesystem::path stateSpacePath() {
5749
return std::filesystem::path(TAKT4_STATESPACE_DIR) / "default.bin";
5850
}
5951

60-
std::string fixed(double value, int places) {
61-
std::ostringstream out;
62-
out << std::fixed << std::setprecision(places) << value;
63-
return out.str();
64-
}
65-
6652
slint::SharedString shared(const std::string& text) {
6753
return slint::SharedString(text);
6854
}
6955

70-
/// What the device picker shows. The host API leads, because on Windows one interface
71-
/// appears under both ASIO and WASAPI and the two are not interchangeable (§5.1): only
72-
/// the ASIO entry picks channels natively, and only the WASAPI one offers loopback.
73-
std::string describeDevice(const audio::InputDevice& device) {
74-
std::string text = device.hostApiName + " / " + device.name + " (" +
75-
std::to_string(device.maxInputChannels) + " in";
76-
if (device.isLoopback) {
77-
text += ", loopback";
78-
}
79-
return text + ")";
80-
}
81-
82-
/// What the channel picker shows, numbered from 1 as it is printed on the interface.
83-
/// ASIO and CoreAudio hand over the driver's own names for them; nothing else does.
84-
std::string describeChannel(const audio::InputDevice& device, int channel) {
85-
const std::string number = std::to_string(channel + 1);
86-
const auto index = static_cast<std::size_t>(channel);
87-
if (index < device.channelNames.size() && !device.channelNames[index].empty()) {
88-
return "In " + number + " — " + device.channelNames[index];
89-
}
90-
return "In " + number;
91-
}
92-
9356
/// Everything the window needs that is not the window: the tracker behind it, the model
9457
/// behind its trace, and the one timer that drains the engine's rings.
9558
///
@@ -206,22 +169,9 @@ class WindowController {
206169
publishOpenStream();
207170
}
208171

209-
/// Everything the readouts say while nothing is running, so a stopped window does not
210-
/// leave the last set's tempo sitting there looking live.
211172
void publishStopped() {
212173
window_->set_running(false);
213-
window_->set_bpm(0.0f);
214-
window_->set_raw_bpm(0.0f);
215-
window_->set_locked(false);
216-
window_->set_holding(false);
217-
window_->set_refined(false);
218-
window_->set_confidence(0.0f);
219-
window_->set_beats_per_bar(0);
220-
window_->set_beat_in_bar(0);
221-
window_->set_bars(0);
222-
window_->set_input_level(0.0f);
223-
window_->set_input_peak(0.0f);
224-
window_->set_input_reading(shared(""));
174+
publishIdleReadouts(*window_);
225175
publishOptions();
226176
if (!devices_.empty() && !statusIsError_) {
227177
setStatus("takt4 " + buildInfo().version + " — pick an input and press Start.", false);
@@ -251,12 +201,7 @@ class WindowController {
251201
/// §5.5's settings as the tracker actually holds them. Read from the engine every
252202
/// time and never from a copy: a tap moves the fold window underneath anyone keeping
253203
/// one (§7 deviation 8), and this window exists to show that window honestly.
254-
void publishOptions() {
255-
const tracking::TempoTracker::Options options = tracker_.engine().tempoOptions();
256-
window_->set_fold_on(options.octaveFold);
257-
window_->set_fold_min(static_cast<float>(options.minBpm));
258-
window_->set_fold_max(static_cast<float>(options.maxBpm));
259-
}
204+
void publishOptions() { publishTempoOptions(*window_, tracker_.engine().tempoOptions()); }
260205

261206
void setStatus(const std::string& text, bool error) {
262207
statusIsError_ = error;
@@ -273,12 +218,8 @@ class WindowController {
273218
bool moved = false;
274219
engine::EngineFrame frame;
275220
while (tracker_.engine().popFrame(frame)) {
276-
TracePoint point;
277-
point.beat = frame.activation.beat;
278-
point.downbeat = frame.activation.downbeat;
279-
point.called = frame.beat;
280221
std::rotate(trace_.begin(), trace_.begin() + 1, trace_.end());
281-
trace_.back() = point;
222+
trace_.back() = tracePoint(frame);
282223
moved = true;
283224
}
284225
if (moved) {
@@ -305,16 +246,7 @@ class WindowController {
305246
}
306247

307248
void publishState() {
308-
const tracking::TempoState state = tracker_.engine().state();
309-
window_->set_bpm(static_cast<float>(state.bpm));
310-
window_->set_raw_bpm(static_cast<float>(state.rawBpm));
311-
window_->set_locked(state.locked);
312-
window_->set_holding(state.holding);
313-
window_->set_refined(state.refined);
314-
window_->set_confidence(static_cast<float>(state.confidence));
315-
window_->set_beats_per_bar(static_cast<int>(state.beatsPerBar));
316-
window_->set_beat_in_bar(static_cast<int>(state.beatInBar));
317-
window_->set_bars(static_cast<int>(state.bars));
249+
publishTempoState(*window_, tracker_.engine().state());
318250
publishOptions();
319251
}
320252

@@ -327,14 +259,13 @@ class WindowController {
327259
peak = std::max(peak, level.peak);
328260
}
329261
peak_ = std::max(peak, peak_ * kPeakDecay);
330-
window_->set_input_peak(peak_);
331262
if (loudest < 0.0f) {
332-
return; // leave the reading where it was rather than flashing to silence
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;
333267
}
334-
const float db = audio::toDbfs(loudest);
335-
window_->set_input_level(std::clamp((db - kMeterFloorDb) / -kMeterFloorDb, 0.0f, 1.0f));
336-
window_->set_input_reading(
337-
shared(db <= kMeterFloorDb ? "-inf dB" : fixed(static_cast<double>(db), 1) + " dB"));
268+
publishInput(*window_, loudest, peak_);
338269
}
339270

340271
engine::LiveTracker& tracker_;

‎src/ui/app.hpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
#pragma once
22

3+
#include <filesystem>
4+
35
namespace takt4::ui {
46

57
/// Shows the main window and runs the event loop on the calling thread.
68
/// Returns when the window is closed.
79
int run();
810

11+
/// Renders the main window to a 24-bit BMP at `out`, with no window system involved.
12+
///
13+
/// HANDOFF §5.9's window is worked on from sessions that have no display, where every
14+
/// screen-capture route fails; this one does not need one. See `shot.cpp`.
15+
int renderShot(const std::filesystem::path& out, int width, int height);
16+
917
} // namespace takt4::ui

‎src/ui/main_window.slint‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -185,15 +185,20 @@ export component MainWindow inherits Window {
185185
}
186186

187187
// --- the readouts ------------------------------------------------------------
188+
// The readouts take the height they need; the trace below gets the rest, because
189+
// it is the one thing here that is worth more when it is bigger.
188190
HorizontalLayout {
189191
padding: 24px;
190192
spacing: 24px;
191-
vertical-stretch: 1;
193+
vertical-stretch: 0;
192194

193-
// tempo, and the bar
195+
// tempo, and the bar. Centred rather than stretched: a VerticalLayout's
196+
// default alignment spreads the leftover height *between* children that
197+
// cannot stretch, which pushed these forty pixels apart.
194198
VerticalLayout {
195199
spacing: 4px;
196200
horizontal-stretch: 1;
201+
alignment: center;
197202

198203
HorizontalLayout {
199204
spacing: 8px;
@@ -211,10 +216,13 @@ export component MainWindow inherits Window {
211216
}
212217

213218
Text {
219+
// Sat on the baseline of the number beside it. A layout owns its
220+
// children's y, so this is vertical-alignment and not a y binding —
221+
// that one was silently ignored and left the label up in the air.
214222
text: "BPM";
215223
color: Theme.dim;
216224
font-size: 14px;
217-
y: parent.height - self.height - 14px;
225+
vertical-alignment: bottom;
218226
}
219227
}
220228

@@ -243,7 +251,6 @@ export component MainWindow inherits Window {
243251
for beat in root.beats-per-bar : Rectangle {
244252
width: 16px;
245253
height: 16px;
246-
y: 2px;
247254
border-radius: 8px;
248255
border-width: 2px;
249256
border-color: beat == 0 ? Theme.text : Theme.dim;
@@ -275,7 +282,7 @@ export component MainWindow inherits Window {
275282
VerticalLayout {
276283
spacing: 10px;
277284
horizontal-stretch: 1;
278-
alignment: start;
285+
alignment: center;
279286

280287
HorizontalLayout {
281288
spacing: 8px;
@@ -331,9 +338,16 @@ export component MainWindow inherits Window {
331338
}
332339

333340
Text {
334-
// Only worth saying when folding actually moved the number, which is
335-
// exactly when an operator would otherwise mistrust the readout.
336-
text: (root.fold-on && root.raw-bpm > 0 && abs(root.raw-bpm - root.bpm) > 0.5)
341+
// Only when folding actually moved the number, which is exactly when an
342+
// operator would otherwise mistrust the readout.
343+
//
344+
// The test is the *ratio*, not the difference. A fold is a halving or a
345+
// doubling, so it leaves the two numbers a factor of two apart; a plain
346+
// difference also catches the tenth-of-a-BPM gap between the cloud's
347+
// tempo interval and the refined tempo (§6: 5.4 BPM apart at 130), which
348+
// is not a fold and must not be announced as one.
349+
text: (root.fold-on && root.raw-bpm > 0 && root.bpm > 0
350+
&& (root.raw-bpm > root.bpm * 1.5 || root.raw-bpm < root.bpm * 0.67))
337351
? "folded from " + round(root.raw-bpm * 10) / 10 + " BPM"
338352
: "";
339353
color: Theme.holding;
@@ -350,11 +364,11 @@ export component MainWindow inherits Window {
350364
padding-right: 24px;
351365
padding-bottom: 8px;
352366
spacing: 8px;
353-
vertical-stretch: 0;
367+
vertical-stretch: 1;
354368

355369
HorizontalLayout {
356370
spacing: 10px;
357-
vertical-stretch: 0;
371+
vertical-stretch: 1;
358372

359373
Text {
360374
text: "activation";
@@ -367,7 +381,7 @@ export component MainWindow inherits Window {
367381
Rectangle {
368382
background: Theme.panel;
369383
border-radius: 3px;
370-
height: 88px;
384+
min-height: 88px;
371385
clip: true;
372386

373387
// One column per 50 Hz frame, oldest at the left. One repeater, not two,

0 commit comments

Comments
 (0)