Three tightenings, all measured with the reloads now reported on stderr.
`set_math_channels()` treats an identical list as no change at all. It rebuilt
unconditionally, and a rebuild replaces the buffer and drops its history -- which a file
view then has to re-read from disk. The comparison is the graphs', and it leaves the nodes'
`x`/`y` out deliberately: those are the canvas's own, no op reads them, and a graph that
merely *moved* should not cost the buffer.
`reload_file_view()` fires only when the store is actually empty. A change that really
happened leaves it so in a file view (the rebuild replaces it, and there is no producer to
refill it); one that found nothing to do leaves it full, and reading a 200 MB file again for
that is the waste. `ScopeModel::stored_slots()` is the one-line accessor it needed.
Every reload now says so, because a second read of a file is worth accounting for:
[udpscope] reloaded <path> after a structural change: 402 samples back
Measured, `--open` a file:
session with no graphs 0 reloads
the math probe (3 real changes) 3 reloads, and all the readings stay equal
(`math x2 402 samples vs 402 time slots`)
I had offered this as "one extra read at `--open` startup", and that was wrong: with no
session there is no `apply_math_channels()` call at all, so there was never an extra read
to remove. What the tightenings buy is that a no-op application is now free, and that a
reload cannot happen unless the buffer was really lost. Each *genuine* change still costs
one re-read -- a rebuild drops the history by design, and preserving it across a rebuild is
a model change rather than this one.
146 lines
7.0 KiB
C++
146 lines
7.0 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
#pragma once
|
|
|
|
#include <functional>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace udpscope {
|
|
|
|
/// A math channel's operations. `Source` reads another channel by name; `Sum`/`Sub`/`Mul`/`Div`
|
|
/// combine two node values; the rest take one. `Sqrt` and `Clamp` are here because a scope's
|
|
/// derived traces want them constantly (an envelope, a rectified-and-squared RMS input, a
|
|
/// bounded control signal) and they cost the same to carry as the ones that were asked for.
|
|
enum class MathOp {
|
|
Source,
|
|
Gain, ///< y = x * gain + offset
|
|
Abs,
|
|
Sqrt,
|
|
Clamp, ///< y = min(max(x, lo), hi)
|
|
LowPass, ///< biquad, corner `fc` Hz
|
|
HighPass,
|
|
BandPass, ///< `fc` Hz centre, `q` wide
|
|
Sum, ///< a + b
|
|
Sub, ///< a - b
|
|
Mul, ///< a * b
|
|
Div, ///< a / b, zero when b is 0 (a scope trace must not go to inf/NAN)
|
|
};
|
|
|
|
const char* math_op_name(MathOp op);
|
|
int math_op_count();
|
|
MathOp math_op_at(int index);
|
|
/// An op's position in `math_op_at()`'s order. That order is the *dialog's* -- what a dropdown or a
|
|
/// menu lists, and what a session file stores -- and it is deliberately not promised to be the
|
|
/// enum's declaration order, so a caller that needs a position must ask rather than cast.
|
|
int math_op_index(MathOp op);
|
|
/// Inputs the op takes: 0 for `Source`, 1 for the unary ones, 2 for the arithmetic.
|
|
int math_op_arity(MathOp op);
|
|
/// Which of the per-node parameters an op actually reads, so a dialog can show only those rows --
|
|
/// an option that does nothing for an op is left out rather than stored and ignored.
|
|
bool math_op_uses_gain(MathOp op);
|
|
bool math_op_uses_filter(MathOp op);
|
|
bool math_op_uses_clamp(MathOp op);
|
|
|
|
/// One node of a math channel's graph. Node indices are positions in `MathChannel::nodes`, and a
|
|
/// node may only read ones before it: the evaluator walks the list in order, which is what makes a
|
|
/// cycle impossible by construction rather than by a check.
|
|
struct MathNode {
|
|
MathOp op = MathOp::Source;
|
|
std::string source; ///< `Source`: the channel to read, by name.
|
|
int in_a = -1; ///< other ops: node indices, -1 while unconnected.
|
|
int in_b = -1;
|
|
double gain = 1.0;
|
|
double offset = 0.0;
|
|
double fc = 100.0; ///< LP/HP corner, BP centre, Hz.
|
|
double q = 0.707; ///< BP quality factor.
|
|
double lo = -1.0; ///< `Clamp` bounds.
|
|
double hi = 1.0;
|
|
double x = 0.0; ///< the editor's box position, logical pixels.
|
|
double y = 0.0;
|
|
|
|
/// Whether two nodes are the same node for the *engine's* purposes.
|
|
///
|
|
/// `x`/`y` are the canvas's own and no op reads them, so a graph that only moved is not a change:
|
|
/// rebuilding for it replaces the buffer and drops its history -- and in a file view that means
|
|
/// re-reading the file -- to store two numbers nothing but the editor draws with.
|
|
bool operator==(const MathNode& other) const {
|
|
return op == other.op && source == other.source && in_a == other.in_a && in_b == other.in_b &&
|
|
gain == other.gain && offset == other.offset && fc == other.fc && q == other.q &&
|
|
lo == other.lo && hi == other.hi;
|
|
}
|
|
};
|
|
|
|
/// A derived channel: a graph of nodes and which node is the answer.
|
|
struct MathChannel {
|
|
std::string name; ///< user-facing; also how another graph names it as a `Source`.
|
|
std::string unit; ///< optional. When empty, the first `Source` node's unit is used.
|
|
bool enabled = false;
|
|
std::vector<MathNode> nodes;
|
|
int output = -1; ///< the node whose value is the channel's.
|
|
|
|
bool operator==(const MathChannel& other) const {
|
|
return name == other.name && unit == other.unit && enabled == other.enabled &&
|
|
output == other.output && nodes == other.nodes;
|
|
}
|
|
};
|
|
|
|
/// 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<MathNodeRt> node;
|
|
/// The rate the coefficients were built for; a change rebuilds them.
|
|
double dt = 0.0;
|
|
bool ready = false;
|
|
};
|
|
|
|
/// 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. `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).
|
|
bool math_channel_valid(const MathChannel& channel, std::string* why = nullptr);
|
|
|
|
/// The same check over a bare node list and output index, for a caller that has a graph but not yet
|
|
/// a `MathChannel` -- the editor, and the headless probe that drives it.
|
|
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output,
|
|
std::string* why = nullptr);
|
|
|
|
} // namespace udpscope
|