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>
248 lines
8.4 KiB
C++
248 lines
8.4 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "math_channel.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <limits>
|
|
|
|
namespace udpscope {
|
|
namespace {
|
|
|
|
constexpr double kPi = 3.14159265358979323846;
|
|
/// A value the step can return without a library call: `std::nan("")` parses its argument, and this
|
|
/// runs once per stored slot per node.
|
|
constexpr double kNaN = std::numeric_limits<double>::quiet_NaN();
|
|
|
|
/// A biquad's coefficients for one node, from the RBJ audio-EQ cookbook. `q` is 1/sqrt(2)
|
|
/// (Butterworth) for the plain low/high pass, and the node's own for the band pass -- which is
|
|
/// built here as the constant-0-dB-peak form, because a derived trace wants the band itself and
|
|
/// not whatever gain its width happened to produce.
|
|
void biquad(MathOp op, double fc, double q, double dt, double& b0, double& b1, double& b2,
|
|
double& a1, double& a2) {
|
|
// Two Nyquist guards and a floor: a cutoff at or above Nyquist has no meaning to give, and one
|
|
// at zero would give coefficients of zero -- a trace that is flat because the filter is broken
|
|
// rather than because the signal is.
|
|
const double nyquist = 0.5 / std::max(dt, 1e-12);
|
|
fc = std::clamp(fc, nyquist * 1e-6, nyquist * 0.99);
|
|
q = std::clamp(q, 1e-3, 100.0);
|
|
const double w0 = 2.0 * kPi * fc * dt;
|
|
const double cos_w0 = std::cos(w0);
|
|
const double alpha = std::sin(w0) / (2.0 * q);
|
|
const double a0 = 1.0 + alpha;
|
|
switch (op) {
|
|
case MathOp::LowPass:
|
|
b0 = (1.0 - cos_w0) * 0.5;
|
|
b1 = 1.0 - cos_w0;
|
|
b2 = b0;
|
|
break;
|
|
case MathOp::HighPass:
|
|
b0 = (1.0 + cos_w0) * 0.5;
|
|
b1 = -(1.0 + cos_w0);
|
|
b2 = b0;
|
|
break;
|
|
default: // BandPass
|
|
b0 = alpha;
|
|
b1 = 0.0;
|
|
b2 = -alpha;
|
|
break;
|
|
}
|
|
a1 = -2.0 * cos_w0;
|
|
a2 = 1.0 - alpha;
|
|
b0 /= a0;
|
|
b1 /= a0;
|
|
b2 /= a0;
|
|
a1 /= a0;
|
|
a2 /= a0;
|
|
}
|
|
|
|
/// One biquad step, transposed direct form II -- the form whose state words are the two things
|
|
/// that actually need carrying between samples.
|
|
double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1,
|
|
double& z2) {
|
|
const double y = b0 * x + z1;
|
|
z1 = b1 * x - a1 * y + z2;
|
|
z2 = b2 * x - a2 * y;
|
|
return y;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
const char* math_op_name(MathOp op) {
|
|
switch (op) {
|
|
case MathOp::Source: return "channel";
|
|
case MathOp::Gain: return "gain + offset";
|
|
case MathOp::Abs: return "abs";
|
|
case MathOp::Sqrt: return "sqrt";
|
|
case MathOp::Clamp: return "clamp";
|
|
case MathOp::LowPass: return "low pass";
|
|
case MathOp::HighPass: return "high pass";
|
|
case MathOp::BandPass: return "band pass";
|
|
case MathOp::Sum: return "sum";
|
|
case MathOp::Sub: return "difference";
|
|
case MathOp::Mul: return "product";
|
|
case MathOp::Div: return "ratio";
|
|
}
|
|
return "?";
|
|
}
|
|
|
|
int math_op_count() { return 12; }
|
|
|
|
MathOp math_op_at(int index) {
|
|
switch (index) {
|
|
case 0: return MathOp::Source;
|
|
case 1: return MathOp::Gain;
|
|
case 2: return MathOp::Abs;
|
|
case 3: return MathOp::Sqrt;
|
|
case 4: return MathOp::Clamp;
|
|
case 5: return MathOp::LowPass;
|
|
case 6: return MathOp::HighPass;
|
|
case 7: return MathOp::BandPass;
|
|
case 8: return MathOp::Sum;
|
|
case 9: return MathOp::Sub;
|
|
case 10: return MathOp::Mul;
|
|
default: return MathOp::Div;
|
|
}
|
|
}
|
|
|
|
int math_op_arity(MathOp op) {
|
|
switch (op) {
|
|
case MathOp::Source: return 0;
|
|
case MathOp::Sum:
|
|
case MathOp::Sub:
|
|
case MathOp::Mul:
|
|
case MathOp::Div: return 2;
|
|
default: return 1;
|
|
}
|
|
}
|
|
|
|
bool math_op_uses_gain(MathOp op) { return op == MathOp::Gain; }
|
|
bool math_op_uses_filter(MathOp op) {
|
|
return op == MathOp::LowPass || op == MathOp::HighPass || op == MathOp::BandPass;
|
|
}
|
|
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
|
|
|
|
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
|
const std::function<int(const std::string&)>& index_of) {
|
|
const std::size_t n = channel.nodes.size();
|
|
// A rate change *keeps* the filter state: the state words are in the signal's units, not in
|
|
// time, so a decimation change moves the cutoffs (which is why the coefficients are rebuilt)
|
|
// without making the trace jump. A node that is new gets zeros, which is a filter starting from
|
|
// rest -- the only honest starting point there is.
|
|
const std::vector<MathNodeRt> was = std::move(rt.node);
|
|
rt.node.assign(n, MathNodeRt{});
|
|
for (std::size_t i = 0; i < n && i < was.size(); ++i) {
|
|
rt.node[i].z1 = was[i].z1;
|
|
rt.node[i].z2 = was[i].z2;
|
|
}
|
|
rt.dt = dt;
|
|
rt.ready = true;
|
|
for (std::size_t i = 0; i < n; ++i) {
|
|
const MathNode& node = channel.nodes[i];
|
|
MathNodeRt& out = rt.node[i];
|
|
if (node.op == MathOp::Source) {
|
|
out.src = index_of ? index_of(node.source) : -1;
|
|
continue;
|
|
}
|
|
if (!math_op_uses_filter(node.op)) continue;
|
|
biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2);
|
|
}
|
|
}
|
|
|
|
double math_step(const MathChannel& channel, MathRuntime& rt, const float* values,
|
|
std::size_t count) {
|
|
const std::size_t n = channel.nodes.size();
|
|
if (n == 0 || rt.node.size() != n) return kNaN;
|
|
MathNodeRt* rts = rt.node.data();
|
|
const auto at = [&](int index) -> double {
|
|
if (index < 0 || static_cast<std::size_t>(index) >= n) return kNaN;
|
|
return rts[static_cast<std::size_t>(index)].value;
|
|
};
|
|
for (std::size_t i = 0; i < n; ++i) {
|
|
const MathNode& node = channel.nodes[i];
|
|
MathNodeRt& self = rts[i];
|
|
double x = 0.0;
|
|
switch (node.op) {
|
|
case MathOp::Source: {
|
|
const int c = self.src;
|
|
x = c >= 0 && static_cast<std::size_t>(c) < count
|
|
? static_cast<double>(values[static_cast<std::size_t>(c)])
|
|
: kNaN;
|
|
break;
|
|
}
|
|
case MathOp::Gain:
|
|
x = at(node.in_a) * node.gain + node.offset;
|
|
break;
|
|
case MathOp::Abs:
|
|
x = std::fabs(at(node.in_a));
|
|
break;
|
|
case MathOp::Sqrt:
|
|
// Of the magnitude: the square root of a negative sample has no real answer, and a derived
|
|
// trace that turns into NaN for half a cycle is not a useful one.
|
|
x = std::sqrt(std::fabs(at(node.in_a)));
|
|
break;
|
|
case MathOp::Clamp:
|
|
x = std::clamp(at(node.in_a), std::min(node.lo, node.hi), std::max(node.lo, node.hi));
|
|
break;
|
|
case MathOp::LowPass:
|
|
case MathOp::HighPass:
|
|
case MathOp::BandPass:
|
|
x = biquad_step(at(node.in_a), self.b0, self.b1, self.b2, self.a1, self.a2, self.z1,
|
|
self.z2);
|
|
break;
|
|
case MathOp::Sum:
|
|
x = at(node.in_a) + at(node.in_b);
|
|
break;
|
|
case MathOp::Sub:
|
|
x = at(node.in_a) - at(node.in_b);
|
|
break;
|
|
case MathOp::Mul:
|
|
x = at(node.in_a) * at(node.in_b);
|
|
break;
|
|
case MathOp::Div: {
|
|
const double d = at(node.in_b);
|
|
// A scope trace must not go to infinity or NaN: a division by zero reads as zero, which is
|
|
// visibly wrong on the plot instead of invisibly wrong in the number.
|
|
x = std::fabs(d) > 0.0 ? at(node.in_a) / d : 0.0;
|
|
break;
|
|
}
|
|
}
|
|
self.value = x;
|
|
}
|
|
const int out = channel.output;
|
|
if (out < 0 || static_cast<std::size_t>(out) >= n) return kNaN;
|
|
return rts[static_cast<std::size_t>(out)].value;
|
|
}
|
|
|
|
bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
|
const std::size_t n = channel.nodes.size();
|
|
const auto fail = [&](const std::string& text) {
|
|
if (why != nullptr) *why = text;
|
|
return false;
|
|
};
|
|
if (n == 0) return fail("empty graph");
|
|
for (std::size_t i = 0; i < n; ++i) {
|
|
const MathNode& node = channel.nodes[i];
|
|
if (node.op == MathOp::Source && node.source.empty()) {
|
|
return fail("node " + std::to_string(i + 1) + ": no channel chosen");
|
|
}
|
|
const int arity = math_op_arity(node.op);
|
|
const int ins[2] = {node.in_a, node.in_b};
|
|
for (int k = 0; k < arity; ++k) {
|
|
// Only nodes *before* this one: the evaluator walks the list in order, so a forward or
|
|
// self reference is not a cycle to detect, it is a node reading a value that is not there.
|
|
if (ins[k] < 0 || ins[k] >= static_cast<int>(i)) {
|
|
return fail("node " + std::to_string(i + 1) + ": input " + std::to_string(k + 1) +
|
|
" is not connected");
|
|
}
|
|
}
|
|
}
|
|
if (channel.output < 0 || static_cast<std::size_t>(channel.output) >= n) {
|
|
return fail("no output chosen");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
} // namespace udpscope
|