`set_math_channels()` rebuilt the store for any change, and a rebuild replaces the buffer:
every channel in the window started again, which is the "modifying one expression invalidates
all the others" report.
The plan is a *partition over the channels*, so it only moves when the set moves. When the
set is unchanged -- same channels, names and enables -- nothing is rebuilt now: the store is
untouched, every other channel keeps its samples and its runtimes, and the surgical path
prepares and recomputes only the affected graphs. The affected set is the edited channels plus
whatever *names* them, because one channel's output is another's input, and the recompute
computes the whole list in order (the inputs have to be there) while writing only those in
the mask -- which is why the step-loop is now `math_apply_locked(sample, write_mask, timed)`.
Adding, removing or renaming a channel still moves the plan, and still rebuilds.
[math-probe] graph edit: stored_slots 800 -> 900 (want the second at least the first, and non-zero)
The count grows because the stream keeps filling while the edit runs; the point is that it
never falls to 0, and that every channel afterwards still reads `N samples vs M time slots`
with N == M.
294 lines
26 KiB
Markdown
294 lines
26 KiB
Markdown
# Invariants: the math channels and the node editor
|
|
|
|
A derived channel is an ordinary channel; the engine runs on the producer thread, so its cost is measured in packets.
|
|
|
|
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
|
|
|
- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic*
|
|
`SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch,
|
|
the plot binding, the measurements and the export all handle a derived channel without knowing it
|
|
is derived -- and another math channel is a valid `Source` for exactly the same reason. The
|
|
*samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the
|
|
slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred
|
|
latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample
|
|
pushed later would be missing from the capture's newest slot). The math therefore runs on the
|
|
*stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() *
|
|
time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at.
|
|
`src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node
|
|
may only read ones *before* it and a cycle is impossible by construction rather than by a check
|
|
(`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style
|
|
checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002;
|
|
BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below
|
|
Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing.
|
|
The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its
|
|
parameters in the form beside it), **drag from an output port to an input port to wire** (the old
|
|
two-click protocol is gone), drag a box to move it; **wheel zooms about the pointer, background
|
|
drag pans, `f` fits, `Ctrl+0` resets**.
|
|
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
|
|
can invalidate a live callback handle -- the same shape the measurements dialog uses.
|
|
`UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
|
|
as much as a state check), and the dump prints each derived channel's extremes beside the
|
|
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
|
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
|
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
|
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
|
(order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is
|
|
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
|
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
|
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
|
|
- **A file view has no producer, so a structural change would leave it empty.** `set_math_channels()`
|
|
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream, whose producer
|
|
refills it from the next packet, and wrong for a file, which has nothing to refill it with. Adding a math
|
|
channel to an open waveform therefore emptied *every* channel -- `samples=0` on the sources too, which a user
|
|
reads as "the plot is broken" -- and the same emptiness follows any structural change in a file view.
|
|
`reload_file_view()` (in `ui.cpp`) re-reads the file after such a change, which is also what computes the
|
|
derived channels over it: the load's own pushes run the math once the set is in place. It fires only when the
|
|
buffer was *really* dropped -- `stored_slots() > 0` means the change found nothing to do -- and it says so
|
|
on stderr (`reloaded <path> after a structural change`), because a second read of a file is worth
|
|
accounting for. `set_math_channels()` in turn treats an identical list as no change at all, so a no-op
|
|
application (the session apply, a re-open) neither rebuilds nor drops nor re-reads; the comparison leaves the
|
|
nodes' `x`/`y` out on purpose, since those are the canvas's own and no op reads them -- a graph that only
|
|
*moved* is not a change, and rebuilding for it would drop the buffer to store two numbers. It is why `--open`
|
|
works at all with a session that defines math channels, since `main()` loads the file *before* the UI (and
|
|
so before the session's graphs) exists.
|
|
- **Every path that fills a store must skip a derived channel, not just the packet path.**
|
|
`ChannelInfo::math` states the rule for the ingest; `push_loaded_slot()` broke it, so loading a file pushed
|
|
its own value into each derived ring and `push_math_sample()` then pushed the computed one: two samples per
|
|
slot, i.e. a trace at double rate. The reading that catches it is the one already in this file --
|
|
`math '<name>': N samples vs M time slots` must be **equal** -- and a file view now has to be read as well
|
|
as a live one (`--open <the file>` with `UDPSCOPE_PROBE_MATH_MS`). An export writes every *plotted* channel,
|
|
so a file carries the derived ones as ordinary columns: those are real channels and are filled normally,
|
|
which is why the fix is in the push and not in the loader.
|
|
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
|
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
|
anywhere in 78--101 ns/slot is one reading of the same thing.
|
|
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
|
|
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
|
|
channel being edited, so a row that stopped being the one the code writes shows there.
|
|
`editor frame:` is the same idea one level down: it reads the canvas's allocation, whether that canvas is
|
|
*mapped*, and whether the canvas and the name entry are inside the dialog at all -- delayed by 500 ms, because
|
|
an allocation only exists after a layout. A canvas that is visible but parentless never maps and stays `0x0`,
|
|
and reading values off the widgets *inside* the editor cannot see that. It is the reading that catches
|
|
`math_body` written as a child of the window instead of the box above it. Each line carries
|
|
`(want ...)` and the reading
|
|
taken from a real run is the reference:
|
|
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
|
|
output square straddles the box edge, so a hit test that recovers the node by a box test misses
|
|
every pixel past it. `past-edge node=-1` is the silent disarm that was "edges cannot be added".
|
|
* `[math-probe] cancel: dialog=cleared (want cleared)` — Cancel must `gtk_window_close()`, not
|
|
`gtk_window_destroy()`. The probe clicks the **button**, because the defect was in the button's
|
|
connection and a direct handler call cannot see it.
|
|
* `[math-probe] view: zoom scale=1.210 anchor kept dx=0.0000 dy=0.0000 (want 0.0000 0.0000)` —
|
|
a wheel zoom that does not keep the graph point under the pointer reads as the canvas sliding
|
|
away from the hand. Both deltas must be `0.0000`.
|
|
* `[math-probe] view: pan+zoom hit node=0 which=-1 (want 0 -1)` — hit tests go through
|
|
`math_to_graph()`. At the identity view a test that forgot the inverse passes anyway, so this
|
|
one is taken after a pan *and* a zoom.
|
|
* `[math-probe] view: fit scale=1.591 all_inside=1 (want 1)` and
|
|
`[math-probe] view: reset scale=1.000 offset=(0.0,0.0) (want 1.000 (0.0,0.0))`.
|
|
* `[math-probe] view: pan mode=3 dview=30.0 dnode=0.0 (want 3 30.0 0.0)` — a background drag
|
|
pans the view and moves no node.
|
|
* `[math-probe] wire: connect mode=2 in_a=0 (want 2 0)`,
|
|
`[math-probe] detach: emptied_at_begin=-1 in_a=-1 (want -1 -1)`,
|
|
`[math-probe] detach: escape restores in_a=0 (want 0)`.
|
|
* `[math-probe] wire: pick node=1 which=0 -> in_a=-1 off_sample=8.4 tol=3.0 (want 1 0 -1,
|
|
off_sample > tol)` — the pick is taken at `t = 0.52`, **between** samples 12 and 13 of the 24 the
|
|
curve is walked at, on a wire whose two boxes are several rows apart. `off_sample` is the measured
|
|
distance from the pick to the nearest sample point and `tol` the pick radius in the same units:
|
|
with `off_sample` the larger, a hit test that measured to the sample *points* rather than to the
|
|
*segments* between them would miss. Taken at `t = 0.5` — exactly sample 12 — the line passed for
|
|
both implementations and asserted nothing, the same mistake as a hit test taken at the identity
|
|
view.
|
|
* `[math-probe] wire: backwards ok=1 order=[0:-1,1:0,2:1] output=2 valid=1 why=[] (want 1 ... 1 [])` —
|
|
the re-sort. `valid=1` is the assertion; the `order=` string is the reading that explains it.
|
|
* `[math-probe] wire: self refused=1 why=[a node cannot read itself]` and
|
|
`[math-probe] wire: cycle refused=1 why=[that wire would make a loop]` — and **nothing is
|
|
written** on either. Both call `math_connect()` directly, so both assert its out-parameter and
|
|
neither says anything about the UI.
|
|
* `[math-probe] wire: self through drag-end node=1 problem=[a node cannot read itself] (want the
|
|
refusal text, not empty)` — the other half, and the one that matters: the refusal has to reach
|
|
the **validation line**. The UI path used to screen the self case out before `math_connect()` and
|
|
then drop its `why` on the floor, so the user drew a wire, nothing appeared and nothing was said
|
|
while the two lines above passed. No synthetic GTK gesture is reachable headlessly, so the probe
|
|
sets `drag_mode`/`wire_from`/`press_cx`/`press_cy` and calls `math_canvas_drag_end()` — which is
|
|
still the handler under test. An empty `problem=[]` here is also how the sync **re-entrancy** was
|
|
found: setting the op dropdown emits `notify::selected`, whose handler syncs, so the form's own
|
|
write re-entered `math_dialog_sync()` and the inner call rewrote the label the outer had just
|
|
written. `MathDialog::syncing` guards it.
|
|
* `[math-probe] validate: broken problem=[..] label=[..] apply=0; fixed problem=[] apply=1 (want non-empty 0; empty 1)`,
|
|
`[math-probe] validate: no-selection label=[..] (want non-empty)` — the label is written
|
|
**above** `math_dialog_sync()`'s early return; a stale message when there is no selection to
|
|
show is the failure mode.
|
|
* `[math-probe] validate: source list=6 acquired=6 signals=7 (want list==acquired)` — the
|
|
dropdown lists exactly the acquired channels, not all signals.
|
|
* `[math-probe] op pick: nodes 3->4 op_index=7 q_row=1 (want 4 7 1)` and
|
|
`[math-probe] op pick: index round trip=1 (want 1)`.
|
|
* `[math-probe] add node: after a delete cells=[(24,20) (24,78) (184,78) (184,20)] overlaps=0
|
|
(want 0)` — the boxes are put back on the grid, the middle one deleted and one added. The new box
|
|
takes cell 1, `(184,20)`, which the deletion freed; placing by `nodes.size()` would have put it on
|
|
`(184,78)`, which is occupied, and an overlapped box is an unclickable one. `overlaps` is the
|
|
assertion, `cells` the reading that explains it.
|
|
* `[math-probe] tidy: xs=[24,24,204,384] overlaps=0 (want 0)` — four nodes in three depth columns
|
|
(the delete above left two roots), so two share the first column and the x values repeat. Only
|
|
`overlaps=0` is the assertion.
|
|
Four rules here were wrong first and are worth keeping:
|
|
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
|
|
dialog's dropdown lists, **and both places that create a node must take one from there**.
|
|
`signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not
|
|
acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's
|
|
default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces
|
|
only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved
|
|
to what the form is showing, rather than the form showing one channel while the node holds
|
|
another, and while a source that is not acquired is in the graph the **live validation line is
|
|
non-empty and Apply is insensitive** (the engine's validator is about the graph -- an output,
|
|
every input wired above it -- and cannot see the acquired list).
|
|
* **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and
|
|
reads each one from the packet, so a math channel with no signal read *zero* and pushed it --
|
|
while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a
|
|
ring whose time ring advances once: the values alternated between the real one and 0, and once
|
|
both rings wrapped the value ring had drifted a whole window off the time ring, so the derived
|
|
trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on
|
|
it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one
|
|
push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly
|
|
this: the arithmetic on the plot can look plausible long after the pairing is wrong.
|
|
* **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared
|
|
by a `close-request` handler, so a Cancel that *destroyed* the window left `app.math_dialog`
|
|
pointing at a dead widget: every later `open_math_dialog()` took the "already open" branch and
|
|
presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel
|
|
again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear
|
|
the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic
|
|
destroy is not a user close, and only one of them runs the handlers.
|
|
* **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a
|
|
132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always
|
|
returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog
|
|
ignoring the pointer. The **add-node grid** places in two columns spaced 160 px apart, rows 58,
|
|
and `math_add_node()` takes the **first free cell** rather than cell `nodes.size()`: placing by
|
|
the count reuses an occupied cell as soon as anything has been deleted — delete the middle of
|
|
three and add one and the new box lands exactly on an existing one. The free-cell search uses the
|
|
same overlap test the tidy probe asserts with, and falls through to cell `nodes.size()` after 64
|
|
cells rather than searching for ever. The **tidy layout** uses a column pitch of `kNodeW + 48` =
|
|
180, which is *wider* than that 160 and is what gives a wire's 40-unit horizontal tread its
|
|
clearance — at 160 the bezier's control points cross and the wire doubles back on itself. The
|
|
probe prints the box the drag *began over* for exactly this reason.
|
|
Offering the whole signal list let a graph name something nobody is acquiring (no data for ever)
|
|
and put the stream's timestamp signal, which has no samples, in it.
|
|
* **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the
|
|
filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path
|
|
is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored*
|
|
interval and the filter's state is the old rate's history, so both are rebuilt together.
|
|
Preparing only on an edit left the cutoff wrong and the state ringing after any window change --
|
|
that is what "I tested a low pass and it was a mess" was.
|
|
* **The graph's output defaults to the *last* node, and a node added to a linear graph becomes
|
|
it.** The output began at index 0 -- the *source* -- so every operation a user built was
|
|
computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its
|
|
input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen
|
|
deliberately is kept; this is the default for a graph that has never been given a choice.
|
|
* **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset
|
|
field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as
|
|
`x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum
|
|
default is never a useful starting point for a gain or a corner frequency.
|
|
* **The form's rows are grouped per *op*, not per field**: gain and offset are one group because
|
|
`gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as
|
|
a property of every node.
|
|
* **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits
|
|
(4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a
|
|
gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began,
|
|
so adding each update's delta to a value the handler then writes back is a runaway box -- it
|
|
must record the position at `drag-begin` and ignore a drag that did not begin on a box.
|
|
* **A port hit must name its own node.** The output square is drawn centred on `n.x + kNodeW`,
|
|
straddling the box's right edge, with a `±kPort` hit radius against a `±kPort*0.5` drawn
|
|
half-width — so recovering the node with `math_node_at()`, which matches only inside the box,
|
|
returns `-1` for every pixel past the edge. `math_port_at()` returns the node directly for
|
|
that reason.
|
|
* **Every hit test goes through `math_to_graph()`, and every tolerance is a screen distance
|
|
divided by `view_scale`.** A tolerance left in graph units makes a port unhittable zoomed out
|
|
and greedy zoomed in. **A probe that tests a hit at the identity view asserts nothing** — the
|
|
check has to be taken after a pan and a zoom.
|
|
* **A drag's mode is decided once, at `drag-begin`, and the precedence is port, box, background.**
|
|
Asking the box first makes the output square unreachable, which is the same geometry as the
|
|
bullet above.
|
|
* **A detach empties the input at `drag-begin`.** That is what makes a release over nothing the
|
|
detach gesture rather than a second verb, and it is why `Escape` has something to restore.
|
|
* **A backwards wire re-sorts the list; it is not refused.** The evaluator walks the nodes in
|
|
order, and until now that made the order the *user's* problem — an earlier node's input was not
|
|
even drawn as connectable. `math_connect()` runs Kahn's algorithm over the **trial** edge set,
|
|
so a cycle is found before anything is written, breaks ties by the current list index so a graph
|
|
already in order is not permuted, and remaps **`output`, `selected`, every `in_a`/`in_b`,
|
|
`drag_node`, `wire_from`, `wire_detach` and `selected_wire.node`** in one block. A missed index
|
|
silently renames an operation or re-points a wire. Its `edges_of` masks each input by the op's
|
|
**arity**: a node changed from binary to unary keeps a stale `in_b`, and counting it makes an
|
|
indegree that never reaches zero and so reports a loop that is not there.
|
|
* **The validation line is written above `math_dialog_sync()`'s early return.** An out-of-range
|
|
selection is exactly the state in which a stale message misleads most: the graph can still be
|
|
broken and there is no form to read instead.
|
|
* **The line is written from `math_dialog_sync()` and from nowhere else, it ellipsizes, and its
|
|
tooltip carries the full text.** Three consequences. (1) A *refusal* — "a node cannot read
|
|
itself", a cycle — is not a state `math_dialog_problem()` can describe, since it is a thing that
|
|
did not happen, so `drag-end` leaves it on `MathDialog::refusal`, which the sync shows in
|
|
preference to the graph's own problem and **consumes as it writes**: the message lasts until the
|
|
next sync, and every mutation syncs. `Apply`'s sensitivity still follows the graph alone, because
|
|
a refused wire wrote nothing. (2) The label **ellipsizes rather than wraps**, and the tooltip is
|
|
set beside the text so the two cannot disagree — layout must not depend on content, and this
|
|
label sits in a dialog whose canvas is `hexpand`. (3) The sync is **not re-entrant**
|
|
(`MathDialog::syncing`): writing the op dropdown emits `notify::selected`, whose handler syncs,
|
|
and the inner call rewrote the label the outer one had just written.
|
|
* **`math_op_index()`, never a cast.** `math_op_at()`'s order is the dialog's — what a dropdown
|
|
lists and what a session file stores — and `static_cast<guint>(op)` worked only because the
|
|
two happen to agree today. The session-file rule in this file already says an op is written as
|
|
its index through `math_op_at()`; this is the other direction of the same rule.
|
|
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
|
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
|
accident; the `delete node` button stays the only way to remove a node.
|
|
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
|
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
|
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
|
not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member
|
|
`math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a
|
|
linear scan with a `std::string` compare per candidate, per node, per slot, behind a
|
|
type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild
|
|
re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the
|
|
measured figure: a `lock xadd` costs about what a two-node graph's whole arithmetic does, so
|
|
counting the slots cost more than the work being counted. The counters are producer-written
|
|
only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and
|
|
friends). Two things about the figure itself:
|
|
* **It is sampled, one slot in `kMathCostSample`, and it is an upper bound.** A slot's work is
|
|
the same order as one `clock_gettime`, so the measured slot carries a clock read. Timing a
|
|
*run* of consecutive slots to amortise that away **is wrong and was tried**: the producer gets
|
|
its slots a packet at a time, so a run long enough to be worth timing straddles a packet
|
|
boundary now and then and charges the measured slots the **wait for the next packet**. At 2000
|
|
samples a packet a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64
|
|
slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet.
|
|
* **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot
|
|
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
|
|
here. Converting `MathRuntime` from eight parallel `std::vector<double>` to one array of
|
|
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
|
|
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
|
|
argument to pay at this size.
|
|
|
|
## A gap is a gap in a filter's output, never the end of its state
|
|
|
|
A reserved hole is NaN in the source channel, and a biquad that integrates it poisons `z1`/`z2`
|
|
for ever: every later sample comes out NaN, so a filtered trace died at the first drop and never
|
|
came back. Relative time made it systematic — a counter gap reserves NaN slots by construction,
|
|
where a stream-time gap is often absorbed by the stamps — but EMI caused it in both modes, and
|
|
the reading was unambiguous: the memoryless channels held 95% finite samples while every
|
|
`LowPass`/`HighPass`/`BandPass` channel held `finite=0`.
|
|
|
|
`biquad_step()` therefore returns NaN **without touching the state words** when its input is not
|
|
finite. The hole stays visible in the filtered trace (the gap is the truth about the data), the
|
|
filter's history remains the last *real* samples it saw, and the trace resumes after the hole
|
|
with at most the transient a re-start costs. The exit dump's `math '<name>': finite=N/M` line is
|
|
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
|
|
`hole_slots` is this defect returned.
|
|
|
|
- **A graph *edit* does not move the plan; adding or removing a channel does.** The plan is a partition over
|
|
the channels, so `set_math_channels()` takes a surgical path whenever the *set* is unchanged -- same channels,
|
|
names and enables: the store is never touched, every other channel keeps its samples **and** its runtimes, and
|
|
only the edited graphs are prepared again. `math_apply_locked()` computes the whole list on a recompute
|
|
(the graphs are walked in order, so a channel's inputs must be there) but writes only the channels in the
|
|
affected mask; the affected set is the edited channels plus whatever *names* them, since one channel's output
|
|
is another's input. Before this, every edit rebuilt the store and every channel in the window started again,
|
|
which is the "modifying one expression invalidates all the others" report.
|
|
`[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling
|
|
while the edit runs -- and never 0.
|