fixed plot flicker, measurement cost and toolbar icons

The flicker ("no data" at no constant frequency, worse with the measurements
and cursors panes up) was a torn read of the window's first stamp: acquire()
pins the window to the ring's oldest slot, and reading it at paint time races
the producer lapping it, returning a stamp one ring span too new. x0() is the
origin every trace point is reconstructed from, so one torn read produced a
refused neighbours(), a filter that dropped three quarters of the points, and
a polyline shifted off its axis. It is cached by acquire() under the lock now.

EventSeries::neighbours() answered from the (x - x0)/dx grid, whose drift from
the real arrival stamps arrives as a constant offset in seconds -- a doubling
share of the screen per zoom step, which is the "zooming into a held event
clips it more from the left" report. Both series binary-search the stamps now.

The measurements pane went through the LOD pyramid rather than reading its
window raw (19.7 ms per recompute to 1.2 ms, 0.1% of a 2 M-sample window) and
recomputes on a cadence; the math engine lost a per-slot heap allocation, a
per-sample source lookup by name and a fetch_add that cost more than the
arithmetic it counted.

The cursors pane's mix mode drew a line per *named* slot, so a slot holding a
signal that can never produce a channel -- the stream's timestamp -- got a row
that could only read "--", and such names were re-saved every session. It
counts bound traces now and apply_layout() refuses them.

Four toolbar icons are drawn for this app and compiled in as a GResource: the
stock ones were a record dot for the trigger and three magnifiers for cursors,
measurements and math.

New probes and dump lines for all of it (trace ink, measure cost, math cost,
event zoom, toolbar icons); README and CLAUDE.md record what they cure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-03 23:36:07 +02:00
1 parent 714d042809
commit a92a6b5bf0
20 files changed
+1945 -243

No files matched your search

+37 -12
View File
@@ -68,25 +68,50 @@ struct MathChannel {
/// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the
/// `MathChannel` because it is *state* (one sample of history per biquad) while the channel is
/// *configuration* -- so the pane can edit one while the producer streams through the other.
/// One node's runtime: its value this sample, its biquad coefficients and state, and its resolved
/// source. **One struct per node, not one vector per field**: the step touches all of them for the
/// node it is on and none of them for any other, so a node's state belongs on one cache line.
/// Eight parallel `std::vector<double>` put a filter node's eight words on eight separate lines,
/// which at the stored rate is eight misses per node per sample.
struct MathNodeRt {
double value = 0.0; ///< this node's output for the sample being evaluated
/// Biquad coefficients (transposed direct form II) and the two state words.
double b0 = 1.0;
double b1 = 0.0;
double b2 = 0.0;
double a1 = 0.0;
double a2 = 0.0;
double z1 = 0.0;
double z2 = 0.0;
/// The channel index a `Source` node reads, -1 when its name names nothing acquired (and for
/// every other op). Resolved **once**, by `math_prepare()`, rather than per sample: the step runs
/// at the stored rate -- a million times a second on a fast stream -- and reading a source by
/// name there was a linear scan of the channel list with a `std::string` compare per candidate,
/// per source node, per sample, behind a type-erased call. Names cannot change without a
/// rebuild, and every rebuild re-prepares.
int src = -1;
};
struct MathRuntime {
std::vector<double> value; ///< one per node, this sample.
/// Biquad coefficients per node (transposed direct form II), and its two state words.
std::vector<double> b0, b1, b2, a1, a2, z1, z2;
std::vector<MathNodeRt> node;
/// The rate the coefficients were built for; a change rebuilds them.
double dt = 0.0;
bool ready = false;
};
/// Fills `rt`'s size and the biquad coefficients for `dt` seconds per sample. Call it whenever the
/// graph or the stored rate changes -- cheap, and it is what keeps the filters' cutoffs honest
/// across a decimation change, where `dt` is not what it was when the user typed a frequency.
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt);
/// Fills `rt`'s size, the biquad coefficients for `dt` seconds per sample, and the resolved source
/// indices -- `index_of(name)` is the channel a `Source` node names, or -1. Call it whenever the
/// graph, the channel list or the stored rate changes -- cheap, and it is what keeps the filters'
/// cutoffs honest across a decimation change, where `dt` is not what it was when the user typed a
/// frequency.
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
const std::function<int(const std::string&)>& index_of);
/// One sample through the graph. `source(name)` returns that channel's current value, and NAN when
/// it is not acquired -- which propagates to the output, so a math channel whose source is disabled
/// reads as "no data" rather than as whatever the last good sample was.
double math_step(const MathChannel& channel, MathRuntime& rt,
const std::function<double(const std::string&)>& source);
/// One sample through the graph. `values[c]` is channel `c`'s value for this slot; a `Source` node
/// whose channel is not among them reads NAN -- which propagates to the output, so a math channel
/// whose source is disabled reads as "no data" rather than as whatever the last good sample was.
double math_step(const MathChannel& channel, MathRuntime& rt, const float* values,
std::size_t count);
/// Whether the graph is complete enough to produce a value: an output node that exists, and every
/// input it needs connected to a node that exists (with `Source` nodes naming something).