// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include 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) /// A running median of `med_n` samples, then a one-pole at `fc` Hz. For impulsive interference -- an EM /// peak a few samples wide -- which a linear filter can only smear over its own settling time. `fc = 0` /// is the median alone. DeNoise, }; 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); /// Whether an op reads `fc`. Wider than `math_op_uses_filter()`, which keeps its present meaning of *a /// biquad*: `DeNoise` reads `fc` for a one-pole and has no `q`. bool math_op_uses_fc(MathOp op); /// Whether an op reads `med_n`. bool math_op_uses_median(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; /// `DeNoise`: the median window, in samples. Odd and between 3 and 9 -- `median_n()` in the /// implementation is what enforces that, so a session file holding anything else still loads. An impulse /// up to `med_n / 2` samples wide is rejected outright. int med_n = 5; 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 && med_n == other.med_n; } }; /// 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 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` 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; /// `DeNoise`: where this node's median window starts in `MathRuntime::median`, how long it is, the ring's /// write position, and how many samples it holds. The window is *pooled* rather than held here because a /// node's state belongs on one cache line, and nine doubles would push this struct across two for every /// node in the graph, filter or not. /// /// The one-pole reuses `b0` for its coefficient and `z1` for its value, which is not a shortcut: /// `math_prepare()` deliberately carries `z1` across a re-prepare, so the pole keeps its history through /// a decimation change exactly as the biquads keep theirs. std::size_t med_at = 0; int med_n = 0; std::size_t med_pos = 0; std::size_t med_have = 0; }; struct MathRuntime { std::vector node; /// Every `DeNoise` node's median window, end to end: one allocation for the channel, sized by /// `math_prepare()`, a node's slice starting at its own `med_at`. std::vector median; /// 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& 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& nodes, int output, std::string* why = nullptr); } // namespace udpscope