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.
This commit is contained in:
1 parent
2c0e21fd20
commit
1bde8809dd
2 files changed
+117
-3
No files matched your search
+29
-1
@@ -25,6 +25,10 @@ enum class MathOp {
|
||||
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);
|
||||
@@ -41,6 +45,11 @@ int math_op_arity(MathOp op);
|
||||
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
|
||||
@@ -56,6 +65,10 @@ struct MathNode {
|
||||
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;
|
||||
|
||||
@@ -67,7 +80,7 @@ struct MathNode {
|
||||
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;
|
||||
lo == other.lo && hi == other.hi && med_n == other.med_n;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,10 +123,25 @@ struct MathNodeRt {
|
||||
/// 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;
|
||||
|
||||
Reference in new issue
Block a user