Files
udpscope/src/math_channel.hpp
T
Martino Ferrari 1bde8809dd feat: a de-noise math node rejects impulses with a median and a one-pole
One op, not two: a running median followed by a one-pole at `fc`. The median kills an impulse
outright -- a peak shorter than half its window cannot move it at all -- and the pole then
takes the broadband hash the median leaves behind. Chaining the existing `LowPass` after a
median would let the user wire them the wrong way round, and a pole *before* a median feeds
the impulse into the filter's state for its whole settling time, which is the thing the node
exists to avoid.

`DeNoise` is **appended** at `math_op_at(12)` (with `Div` given its explicit `case 11`, so the
`default` no longer swallows it): a session stores an op as its index through that accessor,
so inserting it beside the filters in the dropdown would reinterpret every node in every
existing session file. It sits last in the dropdown instead, which is the right side of that
trade.

The median window is `MathNode::med_n`, odd and 3..9, enforced by `median_n()` rather than by
the dialog -- a file holding anything else still loads -- and the ring is *pooled* in
`MathRuntime::median`, sized in `math_prepare()`, because nine doubles as a member would push
`MathNodeRt` across two cache lines for every node, filter or not. The pole reuses `b0`/`z1`,
which `math_prepare()` already carries across a re-prepare, so a decimation change does not
make its trace jump.

Warm-up takes the median of what the window holds rather than NaN: in this application NaN
means *a hole in the data*, and spending that meaning on a start-up transient would make a gap
ambiguous. A non-finite input stays a hole and leaves both the window and the pole alone.
2026-10-13 15:48:27 +02:00

174 lines
8.7 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)
/// 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<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;
/// `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<MathNodeRt> 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<double> 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<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