Compare commits
7
Commits
b02419dee3
...
d34bff3f03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d34bff3f03 | ||
|
|
d5a9b3c5d5 | ||
|
|
be71a1d4bb | ||
|
|
84c158ff21 | ||
|
|
ee660c14fd | ||
|
|
a989935bbd | ||
|
|
0106aca69b |
No files matched your search
@@ -344,6 +344,43 @@ array of per-node structs was measured and bought **nothing** — at six nodes t
|
|||||||
L1-resident either way. It was kept because a node's state belonging on one cache line is the
|
L1-resident either way. It was kept because a node's state belonging on one cache line is the
|
||||||
right shape for a graph that grows, not because it showed up.
|
right shape for a graph that grows, not because it showed up.
|
||||||
|
|
||||||
|
### What the ingest filter and a still frame cost
|
||||||
|
|
||||||
|
Two costs that are paid once per *wire sample* and once per *tick* respectively, and so are the
|
||||||
|
largest two the application has that are not proportional to anything the user asked for.
|
||||||
|
|
||||||
|
**The ingest filter's group test.** Decimation at ingest is an average, never a stride, so the
|
||||||
|
filter reads every sample by design — and the test for "is this sample the last of its group"
|
||||||
|
was a modulo: `tick % period == period - 1`, once per channel per sample, plus once more for the
|
||||||
|
shared clock. `Partition::time_stride` is chosen at *runtime* (`plan_partition()` doubles it from
|
||||||
|
1 until the window fits), so that modulo is not a compile-time strength reduction but a real
|
||||||
|
64-bit `div` — and the code's own comment claimed the opposite. Both factors are powers of two
|
||||||
|
(every per-channel `stride[i]` is 1 by design), so the period minus one is a **mask** and the test
|
||||||
|
is `(tick & mask) == mask`. The masks are built where the accumulators are sized, keyed on the
|
||||||
|
partition, so a re-plan cannot leave them addressing the previous partition's groups; the
|
||||||
|
reserved-hole path got the same treatment, because a hole's samples go through the same
|
||||||
|
accumulators and the two phases have to agree sample for sample. The shared clock's test is now
|
||||||
|
computed once per sample and reused by the time ring, the channel loop and the math push — it was
|
||||||
|
three tests of the same thing.
|
||||||
|
|
||||||
|
**A frozen frame.** The tick queued a GL render unconditionally, sixty times a second, for the
|
||||||
|
whole life of the process. In `Held` and `Paused` that is the same picture again: the rows are
|
||||||
|
bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring, not the
|
||||||
|
series on screen. On llvmpipe, or on a battery, re-drawing a frozen event at 60 Hz is the entire
|
||||||
|
cost of the application. The gate is now two parts, and the second is what makes the first safe to
|
||||||
|
keep coarse: a *signature* of what a frozen view can be re-framed by — a new snapshot, a pan or
|
||||||
|
zoom (the group's range, where both the gestures and the pin write), a row added or removed —
|
||||||
|
which renders the moment it changes, and a **floor** that renders anyway every four ticks (~15 Hz),
|
||||||
|
so anything the signature does not name is late rather than lost. A gate that can only be opened by
|
||||||
|
a list of state is a gate that goes wrong the first time the list is one item short. A change opens
|
||||||
|
the gate for three frames rather than one, because the figure lays itself out per *render* and its
|
||||||
|
left gutter is a monotone floor that takes two passes to converge. The layout editor is excluded
|
||||||
|
outright: its buttons are placed from the rects the last frame was laid out with, so there a
|
||||||
|
skipped frame is a stale *widget* and not just a stale picture. Interaction needs none of this —
|
||||||
|
`PlotArea` queues its own render from the pointer, scroll and drag handlers, so a crosshair and a
|
||||||
|
cursor drag stay immediate whatever the gate decides. `[pause-probe]`'s `skips=` field reports it,
|
||||||
|
cumulative: 0 on the live line, non-zero on the paused one.
|
||||||
|
|
||||||
### Statistics
|
### Statistics
|
||||||
|
|
||||||
**Statistics** under the measurements list adds a second line to every measurement: `n`, the
|
**Statistics** under the measurements list adds a second line to every measurement: `n`, the
|
||||||
|
|||||||
@@ -60,6 +60,25 @@ the value is required, and the popover button uses 1000 when the flag was absent
|
|||||||
The flag is **not** persisted in the session file: persistence of the enable state
|
The flag is **not** persisted in the session file: persistence of the enable state
|
||||||
belongs with `todo.md` item 3, alongside the relative-time toggle.
|
belongs with `todo.md` item 3, alongside the relative-time toggle.
|
||||||
|
|
||||||
|
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
|
||||||
|
|
||||||
|
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the
|
||||||
|
start of every square period from stream second `S` onwards — a runt pulse of a known
|
||||||
|
width, which is what a `<` pulse-width trigger and a glitch search are checked against.
|
||||||
|
- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it
|
||||||
|
stops crossing zero: the dropout trigger's input.
|
||||||
|
- `--spike-every S` / `--spike-amp A` / `--spike-width N` inject an alternating-sign
|
||||||
|
impulse train onto `sine` only — the other four signals (`time`, `square`, `ramp`,
|
||||||
|
`noise`) are untouched, which is what lets a probe compare a spiked channel against a
|
||||||
|
clean one in the same run. Every `S` seconds a spike of `N` samples at amplitude `A` is
|
||||||
|
added to `sine`; the sign alternates per period because a one-signed train is a DC offset
|
||||||
|
a median would partly track rather than reject. `N` is in **samples**, not seconds, so its
|
||||||
|
duration follows `--rate`. `0` (the default for both `S` and `A`) injects nothing, so
|
||||||
|
every existing invocation is unchanged. **`--batch` must stay at or below 1000** with or
|
||||||
|
without these options: 2000 samples × 5 signals × `f64` is 80 kB and exceeds UDP's
|
||||||
|
~65507-byte payload limit, and the mock dies on its first `sendto` with
|
||||||
|
`OSError: [Errno 90] Message too long` while the run still exits 0.
|
||||||
|
|
||||||
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
|
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
|
||||||
form for a new host. Every probe below passes `--server`, so none of them sees it.
|
form for a new host. Every probe below passes `--server`, so none of them sees it.
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ precedent, not an exception.
|
|||||||
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||||
measured on a still picture. It now also asserts what a pause *is*
|
measured on a still picture. It now also asserts what a pause *is*
|
||||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
||||||
glance=[..]` on stderr):
|
skips=N glance=[..]` on stderr):
|
||||||
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
||||||
state as well as an acquisition one and the view snapshot and axis pin live in the
|
state as well as an acquisition one and the view snapshot and axis pin live in the
|
||||||
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
||||||
@@ -168,6 +168,17 @@ precedent, not an exception.
|
|||||||
stops while paused and "how long have I been looking at this" is a question about the user, not
|
stops while paused and "how long have I been looking at this" is a question about the user, not
|
||||||
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
||||||
instead of growing digits (see the layout rule).
|
instead of growing digits (see the layout rule).
|
||||||
|
**`skips=` is the assertion for the render gate** (the end of `tick()`), cumulative over the
|
||||||
|
session: the `live` line must read **0** and the `paused` line must read **more than 0**. A
|
||||||
|
frozen view is bound to an immutable snapshot on a pinned axis, so a frame whose picture cannot
|
||||||
|
have changed is not drawn again — and this is the only line that can say whether that happened,
|
||||||
|
since a skipped frame is invisible in every other reading by construction. Non-zero on the
|
||||||
|
`live` line is the gate's condition inverted, which would cost dropped samples' worth of
|
||||||
|
picture; 0 on the `paused` line is the gate not engaging and the saving not being made.
|
||||||
|
The gate cannot stall the picture (`kStaticRenderEvery` renders every few ticks regardless),
|
||||||
|
so the renderer's own baselines below are unchanged by it: a measured run read `skips=0`,
|
||||||
|
then 33, then 45, with `columns == graded`, `render blank frames: 0`, and the layout floor
|
||||||
|
still converged (`y-gutter 104..104`).
|
||||||
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
|
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
|
||||||
trace and the chip, round-trip it through the session writer, then clear it. Schedule
|
trace and the chip, round-trip it through the session writer, then clear it. Schedule
|
||||||
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
|
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Project review — 2026-10-13
|
||||||
|
|
||||||
|
A status review for performance, defects and gaps. Every claim below was read back against the
|
||||||
|
current tree; where a candidate finding turned out to be wrong it is listed as dismissed, with the
|
||||||
|
reason, rather than dropped — a false alarm that is not written down gets re-raised.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The project is in good shape. All ten `todo.md` items are implemented, every deviation from
|
||||||
|
`SPECS.md` is documented in README's *Review findings not (yet) implemented* and *Limitations*, there
|
||||||
|
is no `TODO`/`FIXME`/`HACK` anywhere in `src/`, no dead command-line option, and no settings field
|
||||||
|
that is written but never read. The tree builds clean.
|
||||||
|
|
||||||
|
- **Defects: none confirmed.** Four candidates were investigated and all four are correct as
|
||||||
|
written (§2).
|
||||||
|
- **Performance: one real win on the ingest hot path** (§1.1) — a 64-bit `div` per sample per
|
||||||
|
channel that the code's own comment believes is already cheap. Then one real win on the idle
|
||||||
|
frame path (§1.2), and three small ones. **§1.1 and §1.2 have since been implemented**; the three
|
||||||
|
small ones (§1.3-§1.5) have not.
|
||||||
|
- **Gaps: no missing requirement.** What is missing is only what was never asked for (§4), plus one
|
||||||
|
stale sentence in README (§3).
|
||||||
|
|
||||||
|
## 1. Performance
|
||||||
|
|
||||||
|
### 1.1 A 64-bit division per sample per channel on the producer hot path
|
||||||
|
|
||||||
|
> **Implemented** (2026-10-13), as the fix below describes: `Acquisition::acc_mask_` holds the
|
||||||
|
> per-channel masks, built where the accumulators are sized; `reserve_hole()` moved with it; the
|
||||||
|
> misleading comment at `src/model.cpp:120-122` now says what the mask actually buys. Written up in
|
||||||
|
> README under *What the ingest filter and a still frame cost*.
|
||||||
|
|
||||||
|
`src/acquisition.cpp:856-857`, in the innermost loop of `on_data()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const u64 period = g * part.stride[i];
|
||||||
|
if (tick % period != period - 1) continue;
|
||||||
|
```
|
||||||
|
|
||||||
|
Three facts make this the most expensive avoidable thing in the application:
|
||||||
|
|
||||||
|
1. **`part.stride[i]` is always 1.** `plan_partition()` assigns it (`src/model.cpp:49`) and then
|
||||||
|
explicitly re-asserts it (`src/model.cpp:70`), with the comment above it explaining why
|
||||||
|
per-channel strides were abandoned. So `period == g` for every channel, always — the load and
|
||||||
|
the multiply are provably redundant.
|
||||||
|
2. **`tick % period` is a real `div`.** `g` is a runtime `u64`, so the compiler cannot strength-
|
||||||
|
reduce it, whatever its value. A 64-bit integer division is ~20-40 cycles of latency on x86.
|
||||||
|
The intent was otherwise: `src/model.cpp:120-122` says a power-of-two stride "keeps `tick %
|
||||||
|
period` cheap", which would be true of a compile-time constant and is not true of a variable.
|
||||||
|
**`time_stride` being a power of two currently buys nothing here.**
|
||||||
|
3. **The same condition is already computed.** `tick % g == g - 1` is evaluated at
|
||||||
|
`src/acquisition.cpp:811` for the time ring, and again at `:881` for the math push — and
|
||||||
|
`:857` is the same test once more per channel.
|
||||||
|
|
||||||
|
Cost model: `(n_channels + 2)` divisions per **raw** sample, i.e. per wire sample, not per stored
|
||||||
|
one — the decimation filter deliberately reads every sample (`:850-852`). At the fast mock's
|
||||||
|
1.3 MS/s with 4 channels that is ~7.8 M divisions a second on the producer thread, which is the
|
||||||
|
thread whose budget overrun costs packets rather than frames.
|
||||||
|
|
||||||
|
Fix: compute the group boundary **once per sample** and reuse it. `g` is a power of two, so
|
||||||
|
`const u64 mask = g - 1;` hoisted out of the packet loop turns all three sites into
|
||||||
|
`(tick & mask) == mask`. To keep the per-channel generality the type still carries, precompute a
|
||||||
|
per-channel mask vector when the partition is planned rather than per sample.
|
||||||
|
|
||||||
|
The same expression appears in the hole path at `src/acquisition.cpp:966` (inside
|
||||||
|
`reserve_hole()`'s double loop). That one is cosmetic — it runs only on packet loss — but it should
|
||||||
|
move with its twin so the two paths keep matching.
|
||||||
|
|
||||||
|
How to verify: the fast mock (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), reading the
|
||||||
|
exit dump's `gaps=` and the producer's CPU share before and after. Expect the win to show as
|
||||||
|
headroom rather than as a changed reading on a stream the producer already keeps up with.
|
||||||
|
|
||||||
|
### 1.2 The figure is re-rendered 60 times a second even when the picture cannot change
|
||||||
|
|
||||||
|
> **Implemented** (2026-10-13), conservatively: a signature of what a frozen view can be re-framed
|
||||||
|
> by, opening the gate for `kSettleRenders` frames, plus a `kStaticRenderEvery` floor that renders
|
||||||
|
> anyway every four ticks, and the layout editor excluded outright. `[pause-probe]`'s new `skips=`
|
||||||
|
> field reports it: a measured run read 0 live, 33 paused, with `columns == graded`, `render blank
|
||||||
|
> frames: 0` and the layout floor still converged.
|
||||||
|
|
||||||
|
`src/ui.cpp:3709` queues a GL render unconditionally at the end of every tick, and the tick is a
|
||||||
|
fixed 16 ms timeout (`src/ui_app.hpp:1023`). In `Held` and `Paused` the bound data is an immutable
|
||||||
|
snapshot and the axis is pinned, so with no pointer interaction the frame is provably identical to
|
||||||
|
the last one — and it is still fully re-rendered. On llvmpipe, or on a laptop looking at a latched
|
||||||
|
event, that is the whole GPU cost of the application for nothing.
|
||||||
|
|
||||||
|
Fix: queue the render only when something that can change the picture did — new samples acquired,
|
||||||
|
a mode/epoch/pin change, a mark/cursor/label move, an interaction, a resize. Keep it conservative:
|
||||||
|
render unconditionally while the layout is still settling, since the layout editor's buttons are
|
||||||
|
placed from the *previous* frame's rects and several probes read a value one frame after the ask.
|
||||||
|
|
||||||
|
How to verify: the exit dump's per-plot painted-frame counts and `layout over N frames` are exactly
|
||||||
|
the two readings that would catch an over-eager skip.
|
||||||
|
|
||||||
|
### 1.3 The event publish blocks the UI tick for 69-115 ms
|
||||||
|
|
||||||
|
Already documented, with measurements, at `README.md:2189-2191` — listed here only because the
|
||||||
|
README prices exactly one remedy (a second live ring) and declines it for good reasons. Two
|
||||||
|
options it does not price, both cheaper than a second budget's worth of memory:
|
||||||
|
|
||||||
|
- do the snapshot copy on the **producer** side of the handoff, so the stall lands where a stall is
|
||||||
|
already tolerated at a latch (the pause publish already blocks the producer, 22-86 ms);
|
||||||
|
- chunk the copy across frames behind a sequence number, publishing only when complete.
|
||||||
|
|
||||||
|
Worth a design note before any code — the README's own A/B (`gaps=0` either way) says this buys a
|
||||||
|
smoother frame at a latch, not recovered packets, so it is a polish item, not a correctness one.
|
||||||
|
|
||||||
|
### 1.4 The status detail string is rebuilt and re-applied every frame
|
||||||
|
|
||||||
|
`src/ui.cpp:4063` calls `format_detail(snap)` every tick and `:4082` pushes the result into
|
||||||
|
`gtk_widget_set_tooltip_text()` every tick. The popover is already guarded by its own visibility
|
||||||
|
(`:4083-4087`); the tooltip is not, and the string is long enough that the code tracks its maximum
|
||||||
|
length. Nothing reads it unless the pointer rests on the status label.
|
||||||
|
|
||||||
|
Fix: keep the last applied detail string and skip both the format and the setter when it is
|
||||||
|
unchanged. Low risk, no behaviour change.
|
||||||
|
|
||||||
|
### 1.5 Search marks are re-filtered and re-sorted every frame
|
||||||
|
|
||||||
|
`src/ui.cpp:1760-1768`. Two heap vectors, a pass over every hit and an `std::sort` per frame for as
|
||||||
|
long as a search result set exists. Correctly short-circuited when there are no hits
|
||||||
|
(`:1753`), and being recomputed every frame is load-bearing for a *live* view (the marks are stored
|
||||||
|
in stream seconds and the window rolls under them) — but in `Held`/`Paused` neither the hits nor
|
||||||
|
the view range move, so the whole result can be cached and invalidated on a view-range or hit-set
|
||||||
|
change.
|
||||||
|
|
||||||
|
### 1.6 Noted, not recommended: the model mutex spans all of `on_data()`
|
||||||
|
|
||||||
|
`src/acquisition.cpp:558` takes the lock and holds it to the end of the function (~line 913),
|
||||||
|
covering the sample loop, the math evaluation, the trigger engine, and hole reserve/backfill. The
|
||||||
|
UI tick takes the same mutex (`src/ui.cpp:3285`). This is the documented design ("taken once per
|
||||||
|
DATA frame", `docs/claude/architecture.md:15-16`) and it is the right call at today's rates, but it
|
||||||
|
is the coupling by which math-channel cost on the producer turns into UI frame jitter. The existing
|
||||||
|
`math_timed` per-slot instrumentation is the thing to watch if math graphs get heavier.
|
||||||
|
|
||||||
|
## 2. Defect candidates investigated and dismissed
|
||||||
|
|
||||||
|
Recorded so they are not re-raised.
|
||||||
|
|
||||||
|
- **`capture_edge()` loading `memory_order_relaxed`** (`src/model.hpp:492`) while its partner
|
||||||
|
`trigger_pending()` carries release/acquire. **Correct as written.** The consumer reaches
|
||||||
|
`capture_edge()` at `src/ui.cpp:3275` only *after* `display_mode()` at `:3273` has done the
|
||||||
|
acquire load of `trigger_pending_`; the acquire establishes the happens-before, so a relaxed load
|
||||||
|
afterwards cannot see the stale edge, and an acquire also forbids the later load being reordered
|
||||||
|
ahead of it. The probe path (`src/ui_probe.cpp:1129-1130`) reads the flag first too.
|
||||||
|
*One fragility worth a line of doc:* the correctness is a property of **read order at the call
|
||||||
|
site**, and `src/model.hpp:489-491` says a consumer that sees the flag sees the edge without
|
||||||
|
saying that the consumer must therefore read the flag first. Tightening that comment is the whole
|
||||||
|
of the work.
|
||||||
|
- **`math_values_.data()` passed to `math_step()` across a possible reallocation**
|
||||||
|
(`src/model.cpp:409-422`). **Safe.** `push_math_sample()` is only reached from `on_data()`
|
||||||
|
(`src/acquisition.cpp:881`, `:978`), which holds the model mutex for its whole body, and
|
||||||
|
`set_math_channels()` takes the same mutex.
|
||||||
|
- **`model_.signals()` indexed by `channels[0].signal` after the lock was released**
|
||||||
|
(`src/acquisition.cpp:593-595`). **Safe** — same reason: the lock is held from `:558` to the end
|
||||||
|
of the function, so no CONFIG can re-shape the signal list underneath it.
|
||||||
|
- **`events_thumb` left dangling across a list rebuild** (`src/ui_events.cpp:632`). **Already
|
||||||
|
handled** — nulled before the rows are removed and rebound after, with the old texture unreffed.
|
||||||
|
|
||||||
|
Nothing in the trigger/pause/file-view/replay seams, the math-channel dependency handling, the
|
||||||
|
measurement and cursor paths, or the export/session writers reproduced a wrong number or a lost
|
||||||
|
action on inspection. That is a statement about a code read, not about a test run: README's own
|
||||||
|
`Limitations` is right that these paths are checked headless, and the seams between two *user*
|
||||||
|
actions are where the probes are thinnest.
|
||||||
|
|
||||||
|
## 3. One documentation defect
|
||||||
|
|
||||||
|
`README.md:2304-2307` says the interactive paths — "drag and drop, the dialogs, the right-click
|
||||||
|
menu" — "were not driven by an automated test". That is now stale in all three particulars:
|
||||||
|
`UDPSCOPE_PROBE_DROP_MS` drives the drop handler, `UDPSCOPE_PROBE_ROWMENU_MS` walks the
|
||||||
|
right-click menu and clicks through its submenu, and `UDPSCOPE_PROBE_DEFAULTS_MS`,
|
||||||
|
`UDPSCOPE_PROBE_CONFIGS_MS`, `UDPSCOPE_PROBE_PREFS_MS` and `UDPSCOPE_PROBE_HELP_MS` all open and
|
||||||
|
read back dialogs. What remains genuinely undriven is the *gesture* layer (a real pointer drag, a
|
||||||
|
real popover dismissal) rather than the handlers behind it, which is a much narrower claim and the
|
||||||
|
one worth making.
|
||||||
|
|
||||||
|
## 4. Gaps
|
||||||
|
|
||||||
|
### Requirements: complete
|
||||||
|
|
||||||
|
`SPECS.md` was walked bullet by bullet. Everything is implemented, and the five places where the
|
||||||
|
implementation differs are each documented with a reason at `README.md:2182-2240`: one live buffer
|
||||||
|
plus an immutable `EventSnapshot` rather than two live rings; allocation at the first CONFIG rather
|
||||||
|
than at startup; no per-channel decimation (the spec's formula aliased a 2 kHz sine on a 1 MHz clock
|
||||||
|
to a flat line); pre-trigger % as a share of the event window; and a capture drawn as it fills.
|
||||||
|
`todo.md`'s ten items are all really there, including item 9's full preference list — the
|
||||||
|
light/dark/auto theme, the reset-to-defaults and the cache controls are all in `src/settings.hpp`
|
||||||
|
and `src/ui_prefs.cpp`.
|
||||||
|
|
||||||
|
### Not asked for, worth considering
|
||||||
|
|
||||||
|
Ranked by value against cost, not by ambition:
|
||||||
|
|
||||||
|
1. **Save the current view as an image.** The cheapest of these by a wide margin: the event cache
|
||||||
|
already has a sample-driven image writer (`write_preview()`, `src/event_store.cpp:266`), so a
|
||||||
|
menu item and a file chooser is most of the work. A scope user screenshots constantly.
|
||||||
|
2. **A draggable trigger level on the plot.** The trigger marker exists but is a vertical cursor
|
||||||
|
and deliberately non-selectable (`src/plot_host.cpp:250`); the level is reachable only through
|
||||||
|
the panel's spin button. A horizontal handle on the trigger channel's row is the standard
|
||||||
|
gesture on every bench scope.
|
||||||
|
3. **Per-trace vertical offset and scale.** Up to eight signals share one row's y axis, so two
|
||||||
|
signals of different magnitudes cannot both be read. This is the gap a user hits soonest.
|
||||||
|
4. **An FFT / spectrum row.** The largest of these, and the one a UDPStreamer user is most likely
|
||||||
|
to ask for next; it fits the figure's row model (one row, one derived series) rather than
|
||||||
|
fighting it.
|
||||||
|
5. **XY mode**, and **undo for layout edits** — both real scope features, both a poor fit for the
|
||||||
|
current one-x-window figure and the layout file respectively. Mentioned for completeness.
|
||||||
|
|
||||||
|
## 5. Suggested order
|
||||||
|
|
||||||
|
1. §1.1, the ingest-path division — small, local, the clearest win, and it corrects a comment that
|
||||||
|
is currently misleading about why the power of two matters.
|
||||||
|
2. §3, the stale README sentence — minutes, and it is wrong today.
|
||||||
|
3. §1.4 and §1.5, the per-frame string and sort — small and safe.
|
||||||
|
4. §2's doc tightening on `capture_edge()`.
|
||||||
|
5. §1.2, the conditional render — the bigger win of the two performance items, but the one that
|
||||||
|
needs care against the layout-settling probes, so it deserves its own change.
|
||||||
|
6. §4.1, the screenshot — the cheapest real feature on the list.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,342 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# Noise rejection: design
|
||||||
|
|
||||||
|
Noise rejection in three places — the measurements pane, the trigger, and a new math node — aimed
|
||||||
|
at one problem: **electromagnetic peaks**. EM interference is *impulsive*. It arrives as a few
|
||||||
|
samples of large excursion, not as a raised noise floor, and that distinction decides every choice
|
||||||
|
below. An impulse recrosses a threshold several times per true crossing, so it inflates an edge
|
||||||
|
count; it moves a `min`, a `max` and a peak-to-peak outright; and it is the one kind of noise a
|
||||||
|
low-pass filter handles *badly*, smearing the spike across its settling time instead of removing
|
||||||
|
it while rounding the real edge beside it.
|
||||||
|
|
||||||
|
Each of the three places therefore gets two independent, optional controls:
|
||||||
|
|
||||||
|
| | amplitude qualifier | frequency qualifier |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Trigger** | hysteresis band about the level, in signal units | one-pole HF reject on the comparator's input, `fc` in Hz |
|
||||||
|
| **Measurements** | hysteresis band, % of the window's peak-to-peak | one-pole HF reject over the measured window, `fc` in Hz |
|
||||||
|
| **Math** | — | the `de-noise` node: median over N samples, then a one-pole at `fc` |
|
||||||
|
|
||||||
|
Nothing here changes a default. Every control is off, or set to the value the code already uses,
|
||||||
|
so the whole documented reading set must come out of this work unchanged. That is the first
|
||||||
|
verification assertion, not an afterthought.
|
||||||
|
|
||||||
|
## The decisions taken, and what each one rules out
|
||||||
|
|
||||||
|
| Decision | Alternative rejected |
|
||||||
|
| --- | --- |
|
||||||
|
| The trigger band is **absolute, in the channel's own units** | A percentage of a running peak-to-peak: the producer has no measured span, so it would need per-channel sliding-window state in the hot loop *and* a warm-up during which the band is wrong — which shows up as a missed or early latch, the one failure a trigger must not have |
|
||||||
|
| `band == 0` takes today's crossing test **verbatim**, with the latched-side comparator used only when the band is non-zero | One unified latched-side comparator: at `v == level` exactly the two are *not* equivalent (today it fires, a zero-width band decides nothing), and `feed_window`'s own comment records a ±1 square against a `[1, 1]` band firing 19 times on exact float equality. `probe-readings.md`'s `[condition-probe]` table of four exact latch times is a regression suite in disguise; unifying would require re-measuring all of it to prove a change nobody asked for |
|
||||||
|
| The band and the filter live **inside `EventDetector`** | Filtering in `Acquisition` before `engine.process()`: the buffer search deliberately runs the same evaluator (`condition.hpp:46-49`), so anything upstream of the detector has to be replicated there, and the live trigger and the search would drift apart |
|
||||||
|
| No shared hysteresis primitive extracted | A common `side_of()` header used by `measure.cpp` and `condition.hpp`: the genuinely shared part is about five lines, and the two call sites want different things from it — `measure.cpp` `continue`s on an in-band sample while counting enters, widths and folded buckets, the detector needs a side to feed four state machines. Three similar lines beat a premature abstraction; the *invariant* is stated once and pointed at |
|
||||||
|
| The measurement filter is a **real one-pole at a user `fc`**, and it disables the pyramid fast path | Measuring the pyramid's per-bucket means, which would be free and keep the fast path — but its corner is wherever the bucket size puts it (`fs/64`), so it is HF reject at one fixed, rate-dependent frequency rather than a cutoff the user can aim at the interference they actually have |
|
||||||
|
| The filter runs **inside each of the two raw walks** | Materializing a filtered copy of the window: `MeasureChunk`'s comment (`measure.hpp:108-115`) records that widening the window cost 60 % of the pane's time before a single statistic had been computed, and 17 MB at a 2.2 M-sample window |
|
||||||
|
| One combined `de-noise` node (median **then** pole) | A plain median node chained to the existing `LowPass`: two boxes for one idea, and it lets the user wire them in the wrong order — a pole *before* a median lets one spike contaminate the filter state for its whole settling time, which defeats the purpose |
|
||||||
|
| The median window is **N samples, fixed** | A duration converted to N from `dt`: N would then grow with decimation, so a modest duration at a long window becomes a large sort in the producer's hot loop and needs a hidden cap the user did not set |
|
||||||
|
| `MathOp::DeNoise` is **appended**, at index 12 | Inserting it beside the filters in `math_op_at()`, where it belongs in the dropdown: a node persists as its *index through that accessor* (`layout_store.cpp:358-365`), so inserting would silently reinterpret every node in every existing session file |
|
||||||
|
| The median's ring is **pooled in `MathRuntime`** | A `double[9]` member of `MathNodeRt`: 72 bytes added to *every* node, doubling a struct whose shape README's math-cost section records as a deliberate choice, to serve the one op whose state size is a parameter |
|
||||||
|
| A new **impulsive** mock source | Verifying against `--noise` (Gaussian): a median is specifically the wrong tool for a raised noise floor and the right one for impulses, so a Gaussian test would prove nothing about the feature |
|
||||||
|
|
||||||
|
## 1. The trigger
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
`EventCondition` (`condition.hpp:27`) gains two fields:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double noise_band = 0.0; ///< hysteresis half-width about `level`, signal units. 0 = off.
|
||||||
|
double hf_fc = 0.0; ///< HF reject corner on the comparator's input, Hz. 0 = off.
|
||||||
|
```
|
||||||
|
|
||||||
|
Both **must** join `operator==` (`condition.hpp:37-41`). This is not bookkeeping:
|
||||||
|
`EventDetector::configure()` returns early when the condition compares equal
|
||||||
|
(`condition.cpp:9`) and the producer calls it on every packet, so a field omitted from the
|
||||||
|
comparison is a control that silently never takes effect.
|
||||||
|
|
||||||
|
The filter needs the sample interval to turn `fc` into a coefficient, and the detector is fed
|
||||||
|
`(value, t)` pairs rather than a rate. `configure()` therefore takes `dt` as a second argument and
|
||||||
|
keeps it as a `dt_` member, and the early return compares the new `dt` against `dt_` as well as the
|
||||||
|
condition against the stored one — a re-plan changes `dt`, and a coefficient left over from the
|
||||||
|
previous partition is a filter at the wrong corner.
|
||||||
|
|
||||||
|
### Comparator
|
||||||
|
|
||||||
|
`EventDetector` gains `int side_ = 0` (0 undecided, ±1 above/below) and one pole state word.
|
||||||
|
`crossed_up`/`crossed_down` (`condition.hpp:77-82`) stop being pure functions of `prev_` and
|
||||||
|
become flags computed **once per sample** in `feed()`:
|
||||||
|
|
||||||
|
- `noise_band == 0` → `prev_ < level && v >= level` and its mirror, bit for bit as today.
|
||||||
|
- `noise_band > 0` → `v >= level + band` latches `+1`, `v <= level - band` latches `-1`, anything
|
||||||
|
between decides nothing; a crossing *is* a change of latch.
|
||||||
|
|
||||||
|
Computing the pair once also removes a duplicated computation: `feed_edge`, `feed_pulse` and
|
||||||
|
`feed_dropout` each call both predicates today.
|
||||||
|
|
||||||
|
`feed_window` (`condition.cpp:115`) does not use those predicates, so it takes the band
|
||||||
|
explicitly on each edge of its own band: becoming *inside* requires `[lo+band, hi-band]`,
|
||||||
|
becoming *outside* requires leaving `[lo-band, hi+band]`. A band wide enough to close that inner
|
||||||
|
region is clamped to just under half the window's width — the same shape of clamp
|
||||||
|
`measure_set_level()` already applies for the same reason, and it keeps the panel's existing
|
||||||
|
refusal of a zero-width window meaningful.
|
||||||
|
|
||||||
|
`reset()` (`condition.cpp:14`) clears `side_` and the pole state.
|
||||||
|
|
||||||
|
### The filter
|
||||||
|
|
||||||
|
One pole on the comparator's input, `y += a·(x − y)`, with `a = 1 − exp(−2π·fc·dt)` built in
|
||||||
|
`configure()` and clamped below Nyquist as `biquad()` does (`math_channel.cpp:28`). `fc = 0`
|
||||||
|
gives `a = 1`, i.e. `y = x`, so "off" needs no branch in the inner loop.
|
||||||
|
|
||||||
|
Two consequences, both stated rather than discovered:
|
||||||
|
|
||||||
|
- **The latch moves later** by roughly `1/(2π·fc)` — the pole's group delay. This is inherent,
|
||||||
|
it is what a bench scope's HF reject also does, and it is why the control is off by default.
|
||||||
|
- **A NaN stays benign.** `feed()`'s comment (`condition.hpp:68-70`) records that a NaN sample
|
||||||
|
cannot fire because every comparison against it is false, and that *rejecting* one would leave
|
||||||
|
`prev_` stale and let the next sample fire a crossing that today cannot happen. The filter
|
||||||
|
preserves this: a non-finite input returns NaN (which flows into the existing comparisons, all
|
||||||
|
false) and leaves the pole state alone.
|
||||||
|
|
||||||
|
`last_value()` (`condition.hpp:73`) comes to report the comparator's input rather than the raw
|
||||||
|
sample. Its only consumer is the `v=%.4g` field of the trig-engine diagnostic
|
||||||
|
(`acquisition.cpp:918`), whose purpose is to say what the comparator saw.
|
||||||
|
|
||||||
|
### Panel
|
||||||
|
|
||||||
|
A `trigger_band_spin` and a `trigger_hf_spin` in `build_trigger_panel()` (`ui_trigger.cpp:219`),
|
||||||
|
read in `publish_trigger_fields()` (`:56`), shown for all four condition types in
|
||||||
|
`sync_trigger_rows()` (`:94`). The band takes the level spin's unit, digits and range, floored at
|
||||||
|
0.
|
||||||
|
|
||||||
|
### The search gets both for free
|
||||||
|
|
||||||
|
`EventDetector` holds no arming state precisely so the buffer search can run the same evaluator
|
||||||
|
over retained samples (`condition.hpp:46-49`). A band or a filter set in the panel therefore
|
||||||
|
changes what a search finds, consistently, with no second code path and no second thing to keep
|
||||||
|
in step.
|
||||||
|
|
||||||
|
### Not persisted
|
||||||
|
|
||||||
|
The trigger has never been persisted, so neither control is either; a restart comes up with both
|
||||||
|
off. That is also what makes every current `[condition-probe]` latch time hold unchanged.
|
||||||
|
|
||||||
|
## 2. The measurements
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
`MeasureOptions` (`measure.hpp:37`) gains two fields:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double noise_pct = 10.0; ///< hysteresis half-width, % of the window's peak-to-peak. 0 = off.
|
||||||
|
double hf_fc = 0.0; ///< HF reject corner, Hz. 0 = off.
|
||||||
|
```
|
||||||
|
|
||||||
|
`noise_pct = 10.0` reproduces today's `kHysteresisFrac = 0.10` (`measure.cpp:36`) exactly, so
|
||||||
|
every current reading is identical. `measure_set_level()` (`measure.hpp:195`,
|
||||||
|
`measure.cpp:40`) already takes a `pct` — the *threshold* percent — so it gains a second,
|
||||||
|
separately named `noise_frac` argument for the band, replacing its read of the file-scope
|
||||||
|
`kHysteresisFrac`; the constant stays as `MeasureOptions::noise_pct`'s default so its
|
||||||
|
measured-value rationale — 5 % reads a 0.1 interferer correctly and fails at
|
||||||
|
0.2, 10 % holds to 0.3 and to white noise at σ=0.05 — does not become an orphan comment. The
|
||||||
|
clamp to half the room between the level and the nearer extreme survives untouched: it is what
|
||||||
|
keeps a threshold at 90 % measurable instead of reading `enters=0`.
|
||||||
|
|
||||||
|
### The band reaches the edge types only
|
||||||
|
|
||||||
|
`MeasureLevel::hysteresis`'s comment (`measure.hpp:165-169`) is explicit that the band moves the
|
||||||
|
*edge decision* only, and that the `sum`/`count` split stays at the bare level — because that
|
||||||
|
split **is** `Top`/`Bottom`, the mean of the samples above or below the threshold, and banding it
|
||||||
|
would change the documented exact `top = 2/π` on a unit sine.
|
||||||
|
|
||||||
|
So `noise_pct` is meaningful for `PulseDuration`, `Period` and `Frequency`, and does nothing for
|
||||||
|
`Top`/`Bottom`. The file's own rule (`measure.hpp:34-36`) is that an option which does nothing
|
||||||
|
for a type is left out of the dialog rather than stored and ignored, so this adds a
|
||||||
|
`measure_uses_noise()` beside `measure_uses_threshold()` and `measure_uses_side()`
|
||||||
|
(`measure.hpp:242-245`), and `measure_dialog_sync()` (`ui_measure.cpp:90`) shows the field for
|
||||||
|
those three types only.
|
||||||
|
|
||||||
|
A *raised* band also costs reads: `measure_accumulate_bucket()` already refuses a bucket where
|
||||||
|
the signal passes `level ± hysteresis` (`measure.hpp:211-216`), so a wider band opens more
|
||||||
|
buckets. The existing `measure cost:` line is where that shows up.
|
||||||
|
|
||||||
|
### HF reject reaches every type
|
||||||
|
|
||||||
|
The filter changes the samples rather than a decision, so `min`, `max`, `average`,
|
||||||
|
`peak to peak`, `amplitude`, `top`, `bottom` and every crossing type all read the filtered
|
||||||
|
signal. The control is therefore shown for all types.
|
||||||
|
|
||||||
|
It is seeded with the window's first sample, so the step transient at the left edge does not read
|
||||||
|
as a measurement. (Priming from samples *before* the window is possible — the ring holds the
|
||||||
|
history — and is deliberately not done: it would make a measurement depend on data outside the
|
||||||
|
window it reports on.)
|
||||||
|
|
||||||
|
### What it costs
|
||||||
|
|
||||||
|
`hf_fc > 0` disables **both** bucket fast paths, `measure_scan_buckets()` (`measure.hpp:147`) and
|
||||||
|
`measure_accumulate_bucket()` (`:217`): a bucket's `lo`/`hi`/`sum` describe raw samples and cannot
|
||||||
|
answer for a filtered signal. Both passes walk raw, which is the 1.2–1.6 ms → ~20 ms step at a
|
||||||
|
2.2 M-sample window that README's measurement-cost section was written about. Against the pane's
|
||||||
|
250 ms cadence that is a few percent of a core and a visible frame hitch.
|
||||||
|
|
||||||
|
This is accepted deliberately: the control is per-measurement and off by default, so nothing gets
|
||||||
|
slower unasked, and `measure cost:`'s `covered`/`buckets`/`raw` makes the cost a *reading* rather
|
||||||
|
than a mystery. `raw ≈ covered` is the signature of a filtered measurement.
|
||||||
|
|
||||||
|
The filter runs inside each of the two raw walks — three flops on a sample the walk is already
|
||||||
|
reading — rather than materializing a filtered window once. Filtering twice is deterministic and
|
||||||
|
free compared with the alternative measured in `MeasureChunk`'s comment.
|
||||||
|
|
||||||
|
NaN keeps the existing rule: the walks already skip a non-finite sample, and the filter state is
|
||||||
|
left alone, so a hole stays a hole and the filter resumes on real samples.
|
||||||
|
|
||||||
|
### Two documented deviations
|
||||||
|
|
||||||
|
1. `docs/claude/invariants-readouts-and-cursors.md` holds that a readout quotes a stored sample
|
||||||
|
and never a value between two. A filtered `max` is a value that was never stored, and with
|
||||||
|
`show_on_plot` its ruler sits at a level the drawn — raw — trace never reaches. This is
|
||||||
|
inherent to filtering, so it becomes a stated exception in that invariant rather than a silent
|
||||||
|
violation.
|
||||||
|
2. The documented exact baselines (`peak to peak = 2`, `top = 0.636620`) hold with the filter
|
||||||
|
off. With it on they move, correctly; the probe reads both.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
Free. A measurement is written as keyed TOML (`layout_store.cpp:659-672`) and read key by key
|
||||||
|
(`:544-556`), so two new keys load in an old session at their defaults.
|
||||||
|
|
||||||
|
## 3. The `de-noise` math node
|
||||||
|
|
||||||
|
### The op, appended
|
||||||
|
|
||||||
|
`MathOp::DeNoise` goes last in the enum (`math_channel.hpp:15-28`) and at index 12 in
|
||||||
|
`math_op_at()` (`math_channel.cpp:99-114`), with `Div` given an explicit `case 11` so the current
|
||||||
|
`default` no longer swallows it; `math_op_count()` becomes 13 and `math_op_name()` gains
|
||||||
|
`"de-noise"`.
|
||||||
|
|
||||||
|
Indices 0–11 keeping their meaning is exactly what leaves every existing session file reading
|
||||||
|
correctly, because a node persists as its index through that accessor. The price is that
|
||||||
|
de-noise sits last in the dialog's dropdown rather than beside the filters, which is the right
|
||||||
|
side of that trade.
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
`MathNode` (`math_channel.hpp:48-72`) gains `int med_n = 5;` — odd, 3 to 9, rounded to odd and
|
||||||
|
clamped. `fc` is reused for the HF reject; `q` is unused. `fc = 0` means median only, and it
|
||||||
|
expresses "off" without a branch: the pole's coefficient `a = 1 − exp(−2π·fc·dt)` is exactly 1 at
|
||||||
|
`fc = 0`.
|
||||||
|
|
||||||
|
The coefficient is built in `math_prepare()` (`math_channel.cpp:140-165`) where `dt` is known and
|
||||||
|
rebuilt on a `dt` change, exactly as the biquads are, with the same sub-Nyquist clamp
|
||||||
|
(`:28`).
|
||||||
|
|
||||||
|
### Predicates
|
||||||
|
|
||||||
|
`math_op_uses_filter()` (`math_channel.cpp:135`) keeps meaning **biquad** — it gates coefficient
|
||||||
|
construction at `:162`, and a de-noise node must not be given biquad coefficients. The dialog
|
||||||
|
gets a new `math_op_uses_fc()` (filter ‖ de-noise) and a `math_op_uses_median()` for the new spin
|
||||||
|
row. `math_dialog_sync_form()` (`ui_math.cpp:889-981`) already shows and hides one widget set per
|
||||||
|
op, and `q`'s row being visible for `BandPass` alone is the precedent for a per-op subset of the
|
||||||
|
filter row.
|
||||||
|
|
||||||
|
### Node text
|
||||||
|
|
||||||
|
`math_node_text()` (`layout_store.cpp:358`) emits a 13th pipe field. `math_node_from_text()`
|
||||||
|
accepts **12 or 13** fields, defaulting `med_n` when it reads 12, so existing sessions load
|
||||||
|
unchanged. A file written by this build and read by an older one is rejected by that build's
|
||||||
|
`f.size() != 12u` check (`:385`); there is no forward-compatibility requirement here, but it is
|
||||||
|
recorded rather than left as a surprise.
|
||||||
|
|
||||||
|
### Runtime state, pooled
|
||||||
|
|
||||||
|
`MathRuntime` (`math_channel.hpp:115-120`) gains one `std::vector<double>` holding the median
|
||||||
|
rings of whichever nodes need them; `MathNodeRt` gains an offset into it, a ring position and the
|
||||||
|
effective N. Not a fixed array member, for the reason in the decisions table. One indirection on
|
||||||
|
a path already doing N compares is free, and the choice is reversible if measurement disagrees.
|
||||||
|
|
||||||
|
### Evaluation
|
||||||
|
|
||||||
|
In `math_step()` (`math_channel.cpp:167-230`), the new case is: push the input into the ring,
|
||||||
|
take the median, then one pole step. **Median first** — de-spiking before smoothing is the whole
|
||||||
|
point, and the reverse lets one spike contaminate the filter state for its entire settling time.
|
||||||
|
|
||||||
|
The median is an insertion sort of at most nine doubles in a local array: ~20 compares worst
|
||||||
|
case, ~4 at N=3, against a documented 80–100 ns per slot for a whole three-graph runtime. That
|
||||||
|
ratio is measured and reported through the existing math-cost line, not assumed.
|
||||||
|
|
||||||
|
**Warm-up:** before the ring holds `med_n` samples the node outputs the median of what it holds,
|
||||||
|
which is a correct median of a shorter window. Deliberately not NaN — in this application a NaN
|
||||||
|
output *means a hole in the data* (`math_channel.cpp:64-70`), and spending that meaning on a
|
||||||
|
start-up transient would make a gap ambiguous.
|
||||||
|
|
||||||
|
**NaN** matches `biquad_step` exactly: a non-finite input returns NaN and leaves both the median
|
||||||
|
ring and the pole state alone, so the hole stays visible and the node resumes on real samples.
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
There is no unit test suite; the check is the application's own headless self-check under
|
||||||
|
`xvfb-run` against `tools/mock_udpstreamer.py`, read against two baselines (an exact warning
|
||||||
|
count, and no `Gtk-CRITICAL`).
|
||||||
|
|
||||||
|
### The regression assertion, first
|
||||||
|
|
||||||
|
Every default is today's behaviour, so the documented reading set must be **unchanged**:
|
||||||
|
|
||||||
|
- `[condition-probe]`'s four exact latch times (`t0=1.710000`, `2.160100`, `1.706700`,
|
||||||
|
`4.120200`);
|
||||||
|
- `peak to peak=2`, `amplitude=1`, `average=0`, `top=0.636620`;
|
||||||
|
- `measure cost:` with `raw ≪ covered`;
|
||||||
|
- the build's warning count — adding a field to `MathNode` leaves
|
||||||
|
`ui_probe.cpp:304` and `:313` at one `-Wmissing-field-initializers` each, so four on a clean
|
||||||
|
build and two on an incremental one still holds.
|
||||||
|
|
||||||
|
### The mock needs an impulsive source
|
||||||
|
|
||||||
|
`tools/mock_udpstreamer.py` offers `--noise` (Gaussian), `--glitch-at`/`--glitch-width` (a single
|
||||||
|
glitch at one time) and `--clock-jitter`. None of them is a *stream* of EM-like peaks, and a
|
||||||
|
median is the wrong tool for Gaussian noise, so none of the three pieces could be shown to work
|
||||||
|
against the mock as it stands.
|
||||||
|
|
||||||
|
Add `--spike-every S --spike-amp A --spike-width N`: a recurring impulse of N samples at
|
||||||
|
amplitude A, every S seconds. One flag set, and it is what every reading below is measured on.
|
||||||
|
|
||||||
|
### The four readings
|
||||||
|
|
||||||
|
1. **Trigger band** — a new `[condition-probe]` leg on a slow sine plus spikes. With
|
||||||
|
`noise_band = 0` the latch lands on a spike; with the band above the spike amplitude it lands
|
||||||
|
on the sine's own crossing time. The latched `t0` is the assertion, and the four existing
|
||||||
|
shapes are re-read unchanged in the same run.
|
||||||
|
2. **Trigger HF reject** — the same leg with the band off and `hf_fc` set. The latch moves
|
||||||
|
*later* by roughly `1/(2π·fc)`, reported beside the prediction. A latch that does not move is
|
||||||
|
the filter not being in the comparator's path.
|
||||||
|
3. **Measurements** — `Frequency` on the spiky mock read three ways: `noise_pct = 0` (counts
|
||||||
|
spikes, frequency far too high), `= 10` (the true frequency), and `hf_fc` set (the true
|
||||||
|
frequency *and* `raw ≈ covered`, which is the fast-path loss as a reading). Plus `peak to
|
||||||
|
peak` with the filter off and on, since that is the number the filter is supposed to move.
|
||||||
|
4. **The de-noise node** — `math_probe` (`ui_probe.cpp:304`) gains a de-noise graph on the spiky
|
||||||
|
channel. Its output's peak-to-peak must fall to the clean waveform's while a `LowPass` at the
|
||||||
|
same `fc` does not: that contrast is the entire justification for the node existing. Plus the
|
||||||
|
per-slot cost through the existing math-cost line.
|
||||||
|
|
||||||
|
## 5. Documentation
|
||||||
|
|
||||||
|
| File | What it gains |
|
||||||
|
| --- | --- |
|
||||||
|
| `README.md` | A noise-rejection section covering the three places, and the filtered-readout deviation stated rather than quietly taken |
|
||||||
|
| `docs/claude/invariants-readouts-and-cursors.md` | The stated exception: a filtered readout is not a stored sample, and its on-plot ruler can sit where the raw trace never goes |
|
||||||
|
| `docs/claude/invariants-measurements.md` | A filter on a measurement disables both bucket fast paths, and `raw ≈ covered` is its signature |
|
||||||
|
| `docs/claude/invariants-trigger-and-pinning.md` | The band, the `band == 0` verbatim path and why, and the latch delay a filter adds |
|
||||||
|
| `docs/claude/invariants-math-channels.md` | Op indices are append-only because the session format stores them; the pooled median state |
|
||||||
|
| `docs/claude/verification.md`, `docs/claude/probe-readings.md` | The four readings above and what a wrong value names |
|
||||||
|
| `docs/claude/build-and-run.md` | The new mock flags |
|
||||||
|
|
||||||
|
## 6. Out of scope
|
||||||
|
|
||||||
|
- Persisting the trigger. The trigger has never been persisted; the band and the filter follow
|
||||||
|
that, rather than this change being the one that introduces trigger persistence.
|
||||||
|
- Any averaging acquisition mode. Noise rejection here is per-measurement, per-trigger and
|
||||||
|
per-node; nothing changes what the ingest stores, so a raw trace stays raw and every reading
|
||||||
|
remains traceable to stored samples.
|
||||||
|
- Noise rejection on the cursors or the search's own UI. The search inherits the trigger's
|
||||||
|
condition and so inherits both controls; nothing further is added.
|
||||||
+29
-9
@@ -626,6 +626,11 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// event window out into an immutable `EventSnapshot`.
|
// event window out into an immutable `EventSnapshot`.
|
||||||
BufferStore& store = model_.live();
|
BufferStore& store = model_.live();
|
||||||
const u64 g = part.time_stride;
|
const u64 g = part.time_stride;
|
||||||
|
// `tick % g == g - 1` as a mask. `plan_partition()` doubles `time_stride` from 1, so `g` is
|
||||||
|
// a power of two -- but it is a *runtime* value, so the modulo compiled to a real 64-bit
|
||||||
|
// division rather than the cheap test its comment there claims. See `acc_mask_` for the
|
||||||
|
// per-channel form and for what that cost on the hot loop.
|
||||||
|
const u64 g_mask = g - 1;
|
||||||
|
|
||||||
// A re-plan replaced the store, so every ring's `produced()` restarted at 0 while `tick_`
|
// A re-plan replaced the store, so every ring's `produced()` restarted at 0 while `tick_`
|
||||||
// kept counting. `backfill_from()` derives a slot index from a hole's `tick_begin`, which is
|
// kept counting. `backfill_from()` derives a slot index from a hole's `tick_begin`, which is
|
||||||
@@ -655,6 +660,13 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
value_acc_.assign(channels.size(), 0.0);
|
value_acc_.assign(channels.size(), 0.0);
|
||||||
value_acc_n_.assign(channels.size(), 0);
|
value_acc_n_.assign(channels.size(), 0);
|
||||||
math_sample_.assign(channels.size(), std::nanf(""));
|
math_sample_.assign(channels.size(), std::nanf(""));
|
||||||
|
// The group boundaries, as masks, for the per-sample loops below. Here rather than in
|
||||||
|
// them: `g` and the strides are properties of the partition, and a partition change is
|
||||||
|
// exactly what this block already reacts to.
|
||||||
|
acc_mask_.resize(channels.size());
|
||||||
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||||
|
acc_mask_[i] = g * std::max<u64>(1, part.stride[i]) - 1;
|
||||||
|
}
|
||||||
time_acc_ = 0.0;
|
time_acc_ = 0.0;
|
||||||
abs_time_acc_ = 0.0;
|
abs_time_acc_ = 0.0;
|
||||||
time_acc_n_ = 0;
|
time_acc_n_ = 0;
|
||||||
@@ -716,7 +728,8 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
trig_channel >= 0 && trig_channel < static_cast<int>(channels.size())
|
trig_channel >= 0 && trig_channel < static_cast<int>(channels.size())
|
||||||
? channels[static_cast<std::size_t>(trig_channel)].name
|
? channels[static_cast<std::size_t>(trig_channel)].name
|
||||||
: kNoTriggerChannel;
|
: kNoTriggerChannel;
|
||||||
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s);
|
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s,
|
||||||
|
model_.partition().slot_dt());
|
||||||
// The engine's armed state follows the panel's, both ways: without the disarm
|
// The engine's armed state follows the panel's, both ways: without the disarm
|
||||||
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
|
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
|
||||||
// re-entering trigger mode kept the stale prev/edge/hold-off state.
|
// re-entering trigger mode kept the stale prev/edge/hold-off state.
|
||||||
@@ -797,6 +810,9 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
|
|
||||||
for (std::uint32_t k = 0; k < n; ++k) {
|
for (std::uint32_t k = 0; k < n; ++k) {
|
||||||
const u64 tick = tick_++;
|
const u64 tick = tick_++;
|
||||||
|
// The shared clock's group boundary, read once and reused by the time ring, the channel
|
||||||
|
// loop's own per-channel test and the math push: three tests of the same thing.
|
||||||
|
const bool group_end = (tick & g_mask) == g_mask;
|
||||||
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
|
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
|
||||||
last_stamp_used = rel;
|
last_stamp_used = rel;
|
||||||
last_stamp_ = rel;
|
last_stamp_ = rel;
|
||||||
@@ -808,7 +824,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
last_abs_stamp_ = abs_stamps_[k] - epoch;
|
last_abs_stamp_ = abs_stamps_[k] - epoch;
|
||||||
abs_time_acc_ += last_abs_stamp_;
|
abs_time_acc_ += last_abs_stamp_;
|
||||||
++time_acc_n_;
|
++time_acc_n_;
|
||||||
if (tick % g == g - 1) {
|
if (group_end) {
|
||||||
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
||||||
time_acc_ = 0.0;
|
time_acc_ = 0.0;
|
||||||
// The absolute clock on the same slot and under the same group mean, so an export can
|
// The absolute clock on the same slot and under the same group mean, so an export can
|
||||||
@@ -853,8 +869,8 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
value_acc_[i] += raw * ch.gain + ch.offset;
|
value_acc_[i] += raw * ch.gain + ch.offset;
|
||||||
++value_acc_n_[i];
|
++value_acc_n_[i];
|
||||||
|
|
||||||
const u64 period = g * part.stride[i];
|
const u64 mask = acc_mask_[i];
|
||||||
if (tick % period != period - 1) continue;
|
if ((tick & mask) != mask) continue;
|
||||||
const f32 value =
|
const f32 value =
|
||||||
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
|
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
|
||||||
value_acc_[i] = 0.0;
|
value_acc_[i] = 0.0;
|
||||||
@@ -878,7 +894,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
// `publish_event()` clamps its snapshot to every value ring's `produced()`, so a derived
|
// `publish_event()` clamps its snapshot to every value ring's `produced()`, so a derived
|
||||||
// channel pushed after it would be missing its newest sample from the capture -- the same
|
// channel pushed after it would be missing its newest sample from the capture -- the same
|
||||||
// one-sample clamp the deferred latch above exists for.
|
// one-sample clamp the deferred latch above exists for.
|
||||||
if (tick % g == g - 1) model_.push_math_sample(math_sample_);
|
if (group_end) model_.push_math_sample(math_sample_);
|
||||||
if (latch_here) latch(latch_edge);
|
if (latch_here) latch(latch_edge);
|
||||||
}
|
}
|
||||||
last_stamp_ = last_stamp_used;
|
last_stamp_ = last_stamp_used;
|
||||||
@@ -929,8 +945,12 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
|
|||||||
|
|
||||||
double dt = part.base_dt;
|
double dt = part.base_dt;
|
||||||
if (!(dt > 0.0)) dt = 1.0e-3;
|
if (!(dt > 0.0)) dt = 1.0e-3;
|
||||||
|
// The same group tests as the live loop, written the same way: a hole's samples go through
|
||||||
|
// the same accumulators, so the two phases have to agree sample for sample.
|
||||||
|
const u64 g_mask = g - 1;
|
||||||
for (u64 j = 0; j < raw; ++j) {
|
for (u64 j = 0; j < raw; ++j) {
|
||||||
const u64 tick = tick_++;
|
const u64 tick = tick_++;
|
||||||
|
const bool group_end = (tick & g_mask) == g_mask;
|
||||||
// The hole is stamped at the declared cadence from the last good stamp -- exact in
|
// The hole is stamped at the declared cadence from the last good stamp -- exact in
|
||||||
// relative mode, and in stream mode the same continuation the glitch bridge makes.
|
// relative mode, and in stream mode the same continuation the glitch bridge makes.
|
||||||
// It must be *stamped* rather than left short: the time ring is binary-searched by
|
// It must be *stamped* rather than left short: the time ring is binary-searched by
|
||||||
@@ -945,7 +965,7 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
|
|||||||
time_acc_ += rel;
|
time_acc_ += rel;
|
||||||
abs_time_acc_ += last_abs_stamp_;
|
abs_time_acc_ += last_abs_stamp_;
|
||||||
++time_acc_n_;
|
++time_acc_n_;
|
||||||
if (tick % g == g - 1) {
|
if (group_end) {
|
||||||
store.time().push(time_acc_ / double(time_acc_n_));
|
store.time().push(time_acc_ / double(time_acc_n_));
|
||||||
// Null in stream mode, where `time()` already is the absolute clock.
|
// Null in stream mode, where `time()` already is the absolute clock.
|
||||||
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
||||||
@@ -963,8 +983,8 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
|
|||||||
// pretend to. One slot per edge, against a hole thousands of slots wide.
|
// pretend to. One slot per edge, against a hole thousands of slots wide.
|
||||||
value_acc_[i] += std::numeric_limits<double>::quiet_NaN();
|
value_acc_[i] += std::numeric_limits<double>::quiet_NaN();
|
||||||
++value_acc_n_[i];
|
++value_acc_n_[i];
|
||||||
const u64 period = g * part.stride[i];
|
const u64 mask = acc_mask_[i];
|
||||||
if (tick % period != period - 1) continue;
|
if ((tick & mask) != mask) continue;
|
||||||
store.values(i).push(static_cast<f32>(value_acc_[i] / double(value_acc_n_[i])));
|
store.values(i).push(static_cast<f32>(value_acc_[i] / double(value_acc_n_[i])));
|
||||||
value_acc_[i] = 0.0;
|
value_acc_[i] = 0.0;
|
||||||
value_acc_n_[i] = 0;
|
value_acc_n_[i] = 0;
|
||||||
@@ -973,7 +993,7 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
|
|||||||
// its source had. The trigger is *not* fed: a NaN comparison is false, so the engine
|
// its source had. The trigger is *not* fed: a NaN comparison is false, so the engine
|
||||||
// could not latch here anyway, and feeding it would advance its hold-off clock over
|
// could not latch here anyway, and feeding it would advance its hold-off clock over
|
||||||
// time nothing was sampled in.
|
// time nothing was sampled in.
|
||||||
if (tick % g == g - 1) {
|
if (group_end) {
|
||||||
std::fill(math_sample_.begin(), math_sample_.end(), std::nanf(""));
|
std::fill(math_sample_.begin(), math_sample_.end(), std::nanf(""));
|
||||||
model_.push_math_sample(math_sample_);
|
model_.push_math_sample(math_sample_);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,15 @@ class Acquisition {
|
|||||||
/// once the slot's samples have all been pushed. One entry per channel, refilled every slot.
|
/// once the slot's samples have all been pushed. One entry per channel, refilled every slot.
|
||||||
std::vector<f32> math_sample_;
|
std::vector<f32> math_sample_;
|
||||||
std::vector<std::uint64_t> value_acc_n_;
|
std::vector<std::uint64_t> value_acc_n_;
|
||||||
|
/// Per channel, `time_stride * stride[i] - 1`: the group boundary as a **mask**, so the
|
||||||
|
/// innermost loop tests `(tick & mask) == mask` instead of `tick % period == period - 1`.
|
||||||
|
/// The divisor is a runtime value, so that modulo compiled to a real 64-bit `div` once per
|
||||||
|
/// channel per *wire* sample -- the filter reads every sample by design, so it was the
|
||||||
|
/// producer's largest avoidable cost. Both factors are powers of two (`plan_partition()`
|
||||||
|
/// doubles `time_stride` from 1, and every `stride[i]` is 1), which is what makes the mask
|
||||||
|
/// equivalent; it is sized and filled with the accumulators below, so a re-plan cannot
|
||||||
|
/// leave it addressing the previous partition's groups.
|
||||||
|
std::vector<std::uint64_t> acc_mask_;
|
||||||
double time_acc_ = 0.0;
|
double time_acc_ = 0.0;
|
||||||
double abs_time_acc_ = 0.0;
|
double abs_time_acc_ = 0.0;
|
||||||
std::uint64_t time_acc_n_ = 0;
|
std::uint64_t time_acc_n_ = 0;
|
||||||
|
|||||||
+59
-5
@@ -3,11 +3,22 @@
|
|||||||
|
|
||||||
#include "condition.hpp"
|
#include "condition.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
void EventDetector::configure(const EventCondition& c) noexcept {
|
void EventDetector::configure(const EventCondition& c, double dt) noexcept {
|
||||||
if (c == cond_) return;
|
if (c == cond_ && dt == dt_) return;
|
||||||
cond_ = c;
|
cond_ = c;
|
||||||
|
dt_ = dt;
|
||||||
|
// a = 1 - exp(-2*pi*fc*dt), clamped just under Nyquist so a never exceeds 1.
|
||||||
|
// fc == 0 leaves a == 1, which is a pass-through.
|
||||||
|
pole_a_ = 1.0;
|
||||||
|
if (cond_.hf_fc > 0.0 && dt > 0.0) {
|
||||||
|
const double nyquist = 0.5 / dt;
|
||||||
|
const double fc = cond_.hf_fc < nyquist * 0.99 ? cond_.hf_fc : nyquist * 0.99;
|
||||||
|
pole_a_ = 1.0 - std::exp(-2.0 * 3.14159265358979323846 * fc * dt);
|
||||||
|
}
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,9 +31,45 @@ void EventDetector::reset() noexcept {
|
|||||||
inside_ = false;
|
inside_ = false;
|
||||||
last_cross_s_ = 0.0;
|
last_cross_s_ = 0.0;
|
||||||
dropped_ = false;
|
dropped_ = false;
|
||||||
|
pole_y_ = 0.0;
|
||||||
|
pole_have_ = false;
|
||||||
|
side_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::filter(double v) noexcept {
|
||||||
|
// A NaN must stay a NaN and must not poison the state: a dropout is a real
|
||||||
|
// reading here, not an error.
|
||||||
|
if (!std::isfinite(v)) return v;
|
||||||
|
if (!pole_have_) {
|
||||||
|
pole_y_ = v;
|
||||||
|
pole_have_ = true;
|
||||||
|
return pole_y_;
|
||||||
|
}
|
||||||
|
pole_y_ += pole_a_ * (v - pole_y_);
|
||||||
|
return pole_y_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EventDetector::decide(double v, bool& up, bool& down) noexcept {
|
||||||
|
up = false;
|
||||||
|
down = false;
|
||||||
|
if (cond_.noise_band <= 0.0) {
|
||||||
|
up = crossed_up(v);
|
||||||
|
down = crossed_down(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A Schmitt trigger: a sample inside the band decides nothing, and a crossing
|
||||||
|
// is a change between two *decided* sides. Seeding from the undecided side
|
||||||
|
// would fire on a signal that starts inside the band and leaves it without
|
||||||
|
// ever reaching `level`.
|
||||||
|
const int was = side_;
|
||||||
|
if (v >= cond_.level + cond_.noise_band) side_ = 1;
|
||||||
|
else if (v <= cond_.level - cond_.noise_band) side_ = -1;
|
||||||
|
up = was == -1 && side_ == 1;
|
||||||
|
down = was == 1 && side_ == -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
double EventDetector::feed(double value, double t) noexcept {
|
double EventDetector::feed(double value, double t) noexcept {
|
||||||
|
value = filter(value);
|
||||||
if (!have_prev_) {
|
if (!have_prev_) {
|
||||||
prev_ = value;
|
prev_ = value;
|
||||||
have_prev_ = true;
|
have_prev_ = true;
|
||||||
@@ -57,8 +104,9 @@ double EventDetector::feed(double value, double t) noexcept {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double EventDetector::feed_edge(double value, double t) noexcept {
|
double EventDetector::feed_edge(double value, double t) noexcept {
|
||||||
const bool up = crossed_up(value);
|
bool up = false;
|
||||||
const bool down = crossed_down(value);
|
bool down = false;
|
||||||
|
decide(value, up, down);
|
||||||
const bool fired = (cond_.edge == TriggerEdge::Rising && up) ||
|
const bool fired = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||||
(cond_.edge == TriggerEdge::Falling && down) ||
|
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||||
(cond_.edge == TriggerEdge::Both && (up || down));
|
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||||
@@ -115,7 +163,13 @@ double EventDetector::feed_pulse(double value, double t) noexcept {
|
|||||||
double EventDetector::feed_window(double value, double t) noexcept {
|
double EventDetector::feed_window(double value, double t) noexcept {
|
||||||
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||||||
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||||||
const bool inside = value >= lo && value <= hi;
|
// The band is applied on the edge the signal must *leave*, and clamped to just
|
||||||
|
// under half the window so `lo + band` can never pass `hi - band`.
|
||||||
|
double band = cond_.noise_band > 0.0 ? cond_.noise_band : 0.0;
|
||||||
|
const double room = 0.49 * (hi - lo);
|
||||||
|
if (band > room) band = room;
|
||||||
|
const bool inside = inside_ ? (value >= lo - band && value <= hi + band)
|
||||||
|
: (value >= lo + band && value <= hi - band);
|
||||||
if (inside == inside_) return kNoEvent;
|
if (inside == inside_) return kNoEvent;
|
||||||
inside_ = inside;
|
inside_ = inside;
|
||||||
// The event is the *change of side*, so `Rising` reads as "into the band" and `Falling`
|
// The event is the *change of side*, so `Rising` reads as "into the band" and `Falling`
|
||||||
|
|||||||
+24
-2
@@ -33,11 +33,17 @@ struct EventCondition {
|
|||||||
double width_s = 1.0e-3; ///< PulseWidth only.
|
double width_s = 1.0e-3; ///< PulseWidth only.
|
||||||
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
||||||
double timeout_s = 1.0e-3; ///< Dropout only.
|
double timeout_s = 1.0e-3; ///< Dropout only.
|
||||||
|
/// Half-width of the amplitude band around `level` a sample must leave before
|
||||||
|
/// a crossing counts. 0 is the bare comparator. In signal units.
|
||||||
|
double noise_band = 0.0;
|
||||||
|
/// Corner of a one-pole low pass applied to the sample before any comparison.
|
||||||
|
/// 0 is off. In Hz.
|
||||||
|
double hf_fc = 0.0;
|
||||||
|
|
||||||
bool operator==(const EventCondition& o) const noexcept {
|
bool operator==(const EventCondition& o) const noexcept {
|
||||||
return type == o.type && level == o.level && level2 == o.level2 && edge == o.edge &&
|
return type == o.type && level == o.level && level2 == o.level2 && edge == o.edge &&
|
||||||
cmp == o.cmp && width_s == o.width_s && width2_s == o.width2_s &&
|
cmp == o.cmp && width_s == o.width_s && width2_s == o.width2_s &&
|
||||||
timeout_s == o.timeout_s;
|
timeout_s == o.timeout_s && noise_band == o.noise_band && hf_fc == o.hf_fc;
|
||||||
}
|
}
|
||||||
bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); }
|
bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); }
|
||||||
};
|
};
|
||||||
@@ -60,7 +66,10 @@ class EventDetector {
|
|||||||
/// Re-applying the same condition is a no-op, deliberately: the producer calls this on
|
/// Re-applying the same condition is a no-op, deliberately: the producer calls this on
|
||||||
/// every packet, and resetting there would restart the machine before any pulse could
|
/// every packet, and resetting there would restart the machine before any pulse could
|
||||||
/// close. A genuinely different condition resets, because its state means nothing.
|
/// close. A genuinely different condition resets, because its state means nothing.
|
||||||
void configure(const EventCondition& c) noexcept;
|
///
|
||||||
|
/// `dt` is the stored sample interval (`Partition::slot_dt()`), needed for the
|
||||||
|
/// HF reject's pole. A no-op unless the condition or the interval changed.
|
||||||
|
void configure(const EventCondition& c, double dt) noexcept;
|
||||||
void reset() noexcept;
|
void reset() noexcept;
|
||||||
|
|
||||||
/// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`.
|
/// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`.
|
||||||
@@ -80,6 +89,10 @@ class EventDetector {
|
|||||||
bool crossed_down(double v) const noexcept {
|
bool crossed_down(double v) const noexcept {
|
||||||
return prev_ > cond_.level && v <= cond_.level;
|
return prev_ > cond_.level && v <= cond_.level;
|
||||||
}
|
}
|
||||||
|
/// The HF reject, applied to every sample before any comparison.
|
||||||
|
double filter(double v) noexcept;
|
||||||
|
/// `up`/`down` for the edge condition, through the band if there is one.
|
||||||
|
void decide(double v, bool& up, bool& down) noexcept;
|
||||||
double feed_edge(double value, double t) noexcept;
|
double feed_edge(double value, double t) noexcept;
|
||||||
double feed_pulse(double value, double t) noexcept;
|
double feed_pulse(double value, double t) noexcept;
|
||||||
double feed_window(double value, double t) noexcept;
|
double feed_window(double value, double t) noexcept;
|
||||||
@@ -98,6 +111,15 @@ class EventDetector {
|
|||||||
bool inside_ = false; ///< Window: which side of the band the previous sample was.
|
bool inside_ = false; ///< Window: which side of the band the previous sample was.
|
||||||
double last_cross_s_ = 0.0; ///< Dropout: when the signal was last seen crossing.
|
double last_cross_s_ = 0.0; ///< Dropout: when the signal was last seen crossing.
|
||||||
bool dropped_ = false; ///< Dropout: the timeout has already been reported.
|
bool dropped_ = false; ///< Dropout: the timeout has already been reported.
|
||||||
|
/// One-pole prefilter. `pole_a_ == 1` passes the input through, so "off"
|
||||||
|
/// costs no branch. `pole_y_` is the filtered value.
|
||||||
|
double dt_ = 0.0;
|
||||||
|
double pole_a_ = 1.0;
|
||||||
|
double pole_y_ = 0.0;
|
||||||
|
bool pole_have_ = false;
|
||||||
|
/// Which side of the band the signal was last *decided* to be on: +1 above,
|
||||||
|
/// -1 below, 0 undecided. A sample inside the band decides nothing.
|
||||||
|
int side_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
+5
-2
@@ -118,8 +118,11 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
// Only now, with the ring as long as the budget can buy, decimate the shared clock
|
// Only now, with the ring as long as the budget can buy, decimate the shared clock
|
||||||
// enough for the requested window to fit in it: deciding this earlier would over-
|
// enough for the requested window to fit in it: deciding this earlier would over-
|
||||||
// decimate by whatever factor the growth loop was about to add. A power of two keeps
|
// decimate by whatever factor the growth loop was about to add. A power of two keeps
|
||||||
// the ring capacities (themselves powers of two) in step and keeps `tick % period`
|
// the ring capacities (themselves powers of two) in step and lets the producer's group
|
||||||
// cheap. The cap stops a nonsense request -- a window far past `--max-window` -- from
|
// test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride
|
||||||
|
// this function chose at runtime is a real 64-bit division, which the test *was* paying
|
||||||
|
// once per channel per wire sample.
|
||||||
|
// The cap stops a nonsense request -- a window far past `--max-window` -- from
|
||||||
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
|
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
|
||||||
// picture is averages of averages anyway.
|
// picture is averages of averages anyway.
|
||||||
u64 m = 1;
|
u64 m = 1;
|
||||||
|
|||||||
+2
-2
@@ -16,11 +16,11 @@ double ms_since(const std::chrono::steady_clock::time_point& t0) {
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void BufferSearch::start(const EventCondition& c, int channel) {
|
void BufferSearch::start(const EventCondition& c, int channel, double dt) {
|
||||||
clear();
|
clear();
|
||||||
channel_ = channel;
|
channel_ = channel;
|
||||||
detector_.reset();
|
detector_.reset();
|
||||||
detector_.configure(c);
|
detector_.configure(c, dt);
|
||||||
// `configure()` is a no-op for an unchanged condition, so the explicit reset above is what
|
// `configure()` is a no-op for an unchanged condition, so the explicit reset above is what
|
||||||
// makes a re-run of the *same* search start from a clean machine.
|
// makes a re-run of the *same* search start from a clean machine.
|
||||||
detector_.reset();
|
detector_.reset();
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ class BufferSearch {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Begins a scan of the channel for the condition. Discards any previous result.
|
/// Begins a scan of the channel for the condition. Discards any previous result.
|
||||||
void start(const EventCondition& c, int channel);
|
void start(const EventCondition& c, int channel, double dt);
|
||||||
void cancel() noexcept;
|
void cancel() noexcept;
|
||||||
void clear() noexcept;
|
void clear() noexcept;
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -14,7 +14,8 @@ double TriggerConfig::post_span_s(double buffer_span_s) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& channel_name,
|
void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& channel_name,
|
||||||
double post_seconds, double pre_seconds, double oldest_s) {
|
double post_seconds, double pre_seconds, double oldest_s,
|
||||||
|
double dt) {
|
||||||
const bool channel_changed = channel_name != channel_name_;
|
const bool channel_changed = channel_name != channel_name_;
|
||||||
if (channel_changed) channel_name_ = channel_name;
|
if (channel_changed) channel_name_ = channel_name;
|
||||||
cfg_ = cfg;
|
cfg_ = cfg;
|
||||||
@@ -22,7 +23,7 @@ void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& chann
|
|||||||
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
|
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
|
||||||
oldest_s_ = oldest_s;
|
oldest_s_ = oldest_s;
|
||||||
// A no-op unless the condition actually changed: this runs on every packet.
|
// A no-op unless the condition actually changed: this runs on every packet.
|
||||||
detector_.configure(cfg_.condition);
|
detector_.configure(cfg_.condition, dt);
|
||||||
if (channel_changed) {
|
if (channel_changed) {
|
||||||
detector_.reset();
|
detector_.reset();
|
||||||
pending_ = false;
|
pending_ = false;
|
||||||
|
|||||||
+2
-1
@@ -69,7 +69,8 @@ class TriggerEngine {
|
|||||||
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
|
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
|
||||||
/// off, so the history is usually already there and waiting would be wrong.
|
/// off, so the history is usually already there and waiting would be wrong.
|
||||||
void configure(const TriggerConfig& cfg, const std::string& channel_name,
|
void configure(const TriggerConfig& cfg, const std::string& channel_name,
|
||||||
double post_seconds, double pre_seconds, double oldest_s);
|
double post_seconds, double pre_seconds, double oldest_s,
|
||||||
|
double dt);
|
||||||
/// Disarms without forgetting the configuration.
|
/// Disarms without forgetting the configuration.
|
||||||
void disarm() noexcept { armed_ = false; pending_ = false; }
|
void disarm() noexcept { armed_ = false; pending_ = false; }
|
||||||
void arm(double now_s) noexcept;
|
void arm(double now_s) noexcept;
|
||||||
|
|||||||
+45
-1
@@ -3706,7 +3706,51 @@ gboolean tick(gpointer user) {
|
|||||||
window_w < kSidebarRoom);
|
window_w < kSidebarRoom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (app->area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget));
|
// The render, queued once a frame -- but not unconditionally. In `Held` and `Paused` the rows
|
||||||
|
// are bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring,
|
||||||
|
// not the series on screen, so a frame in which none of the picture's own terms moved is the
|
||||||
|
// same frame again. Every other mode renders: `Live` and `Capturing` roll new samples under the
|
||||||
|
// axis, and `Waiting` moves the live epoch, so in all three the picture differs every frame.
|
||||||
|
//
|
||||||
|
// The gate is two parts, and the second is what makes the first safe to keep coarse:
|
||||||
|
// * a signature of what a frozen view *can* be re-framed by -- a new snapshot, a pan or zoom
|
||||||
|
// (the group's range, which is where the gestures and the pin both write), a row added or
|
||||||
|
// removed -- which renders the moment it changes;
|
||||||
|
// * `kStaticRenderEvery`, a floor that renders anyway every few ticks, so anything not in the
|
||||||
|
// signature is late rather than lost. A gate that can only be opened by a list of state is
|
||||||
|
// a gate that goes wrong the first time the list is one item short.
|
||||||
|
// Interaction needs neither: `PlotArea` queues its own render from the pointer, scroll and drag
|
||||||
|
// handlers, so a crosshair and a cursor drag are immediate whatever this decides.
|
||||||
|
//
|
||||||
|
// The layout editor is excluded outright: its buttons are placed from the rects the last frame
|
||||||
|
// was laid out with (`sync_layout_editor()` above), so it is the one state where a skipped frame
|
||||||
|
// is a stale *widget* and not just a stale picture.
|
||||||
|
if (app->area_widget != nullptr) {
|
||||||
|
const bool frozen = (display == DisplayMode::Held || display == DisplayMode::Paused) &&
|
||||||
|
!app->layout_editing;
|
||||||
|
const plot::Range view = app->rows->view_range();
|
||||||
|
const std::uint64_t seq = model.event_seq();
|
||||||
|
const int rows = app->rows->row_count();
|
||||||
|
const bool same = frozen && seq == app->render_event_seq && view.min == app->render_x_min &&
|
||||||
|
view.max == app->render_x_max && rows == app->render_rows;
|
||||||
|
// A change opens the gate for `kSettleRenders` frames, not one: the figure's left gutter is a
|
||||||
|
// monotone floor that converges over two layout passes (see the `layout over N frames`
|
||||||
|
// reading), and a layout pass happens per *render* -- so a single frame after a re-frame would
|
||||||
|
// leave the gutter one pass short until the floor below fired, 64 ms later.
|
||||||
|
if (!same) app->render_open = kSettleRenders;
|
||||||
|
if (app->render_open > 0 || app->render_skipped + 1 >= kStaticRenderEvery) {
|
||||||
|
if (app->render_open > 0) --app->render_open;
|
||||||
|
app->render_skipped = 0;
|
||||||
|
gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget));
|
||||||
|
} else {
|
||||||
|
++app->render_skipped;
|
||||||
|
++app->dbg_render_skips;
|
||||||
|
}
|
||||||
|
app->render_event_seq = seq;
|
||||||
|
app->render_x_min = view.min;
|
||||||
|
app->render_x_max = view.max;
|
||||||
|
app->render_rows = rows;
|
||||||
|
}
|
||||||
// What the side column *demands*, not what it got. A GTK box's minimum width is its
|
// What the side column *demands*, not what it got. A GTK box's minimum width is its
|
||||||
// children's, a `GtkLabel`'s minimum is its text, and a minimum larger than the screen is
|
// children's, a `GtkLabel`'s minimum is its text, and a minimum larger than the screen is
|
||||||
// honoured by growing the toplevel -- which hands the plot an allocation wider than the window
|
// honoured by growing the toplevel -- which hands the plot an allocation wider than the window
|
||||||
|
|||||||
@@ -503,6 +503,23 @@ struct App {
|
|||||||
/// 0 until the first event of the session.
|
/// 0 until the first event of the session.
|
||||||
gint64 last_latch_us = 0;
|
gint64 last_latch_us = 0;
|
||||||
double timed_edge = 0.0;
|
double timed_edge = 0.0;
|
||||||
|
/// The render gate's memory (see the end of `tick()`). A `Held` or `Paused` view is bound to
|
||||||
|
/// an immutable snapshot on a pinned axis, so a frame whose picture cannot have changed does
|
||||||
|
/// not need to be drawn again -- and on llvmpipe, or on a battery, re-drawing a frozen event
|
||||||
|
/// sixty times a second is the whole cost of the application. The first four are the coarse
|
||||||
|
/// signature of what a frozen picture can be re-framed by; a change to any of them opens
|
||||||
|
/// `render_open` for `kSettleRenders` frames, so the figure's layout floor has the passes it
|
||||||
|
/// needs to converge. `render_skipped` is the floor that makes everything the signature does
|
||||||
|
/// *not* name render soon anyway (within `kStaticRenderEvery` ticks), which is what keeps the
|
||||||
|
/// gate from being able to stall the picture.
|
||||||
|
std::uint64_t render_event_seq = 0;
|
||||||
|
double render_x_min = 0.0;
|
||||||
|
double render_x_max = 0.0;
|
||||||
|
int render_rows = -1;
|
||||||
|
int render_open = 0;
|
||||||
|
int render_skipped = 0;
|
||||||
|
/// Self-check: ticks that queued no render, so the gate is a number rather than a claim.
|
||||||
|
int dbg_render_skips = 0;
|
||||||
double window_limit_s = -1.0; ///< the cap the window control was last clamped against
|
double window_limit_s = -1.0; ///< the cap the window control was last clamped against
|
||||||
/// The rate each channel actually *stored* over the last second, and the declared rate
|
/// The rate each channel actually *stored* over the last second, and the declared rate
|
||||||
/// beside it. The two differ for two reasons now and both are worth seeing: the declared
|
/// beside it. The two differ for two reasons now and both are worth seeing: the declared
|
||||||
@@ -1021,6 +1038,18 @@ struct RowMenuProbeState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
constexpr int kTickMs = 16;
|
constexpr int kTickMs = 16;
|
||||||
|
/// How often a frozen view is re-drawn anyway, in ticks: the render gate's floor (see `tick()`
|
||||||
|
/// and `App::render_skipped`). Four is ~15 Hz, so anything the gate's signature does not name --
|
||||||
|
/// a recolour, a measurement's mark, a y range a fit changed -- reaches the screen within ~64 ms
|
||||||
|
/// of a still picture, which is below noticing. It is deliberately a small number: the gate's
|
||||||
|
/// purpose is to stop spending a GPU on an unchanging frame, not to shave the last frame off.
|
||||||
|
constexpr int kStaticRenderEvery = 4;
|
||||||
|
/// How many frames one change to the gate's signature is worth (see `tick()` and
|
||||||
|
/// `App::render_open`). More than one because the figure lays itself out *per render* and its left
|
||||||
|
/// gutter is a monotone floor that takes two passes to converge (the `layout over N frames`
|
||||||
|
/// reading): a re-framed frozen view drawn once would sit a pass short of its final gutter until
|
||||||
|
/// the floor above fired. Three leaves a pass in hand without being a frame budget.
|
||||||
|
constexpr int kSettleRenders = 3;
|
||||||
/// Below this window width the sidebar has no room beside the plot, and the split view overlays it
|
/// Below this window width the sidebar has no room beside the plot, and the split view overlays it
|
||||||
/// instead of squeezing it. See `tick()`.
|
/// instead of squeezing it. See `tick()`.
|
||||||
constexpr int kSidebarRoom = 900;
|
constexpr int kSidebarRoom = 900;
|
||||||
|
|||||||
+10
-3
@@ -1421,12 +1421,14 @@ gboolean search_probe_run(gpointer user) {
|
|||||||
g_search_t0 = a->model->trigger_time();
|
g_search_t0 = a->model->trigger_time();
|
||||||
EventCondition c;
|
EventCondition c;
|
||||||
int channel = -1;
|
int channel = -1;
|
||||||
|
double dt = 0.0;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
c = a->model->trigger().condition;
|
c = a->model->trigger().condition;
|
||||||
channel = a->model->trigger().channel;
|
channel = a->model->trigger().channel;
|
||||||
|
dt = a->model->partition().slot_dt();
|
||||||
}
|
}
|
||||||
a->search.start(c, channel);
|
a->search.start(c, channel, dt);
|
||||||
std::fprintf(stderr, "[search-probe] started on channel %d, armed t0=%.6f\n", channel,
|
std::fprintf(stderr, "[search-probe] started on channel %d, armed t0=%.6f\n", channel,
|
||||||
g_search_t0);
|
g_search_t0);
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
@@ -2030,11 +2032,16 @@ void pause_probe_report(App& a, const char* what) {
|
|||||||
// The bar's own string, because "how long have I been paused" is a *status* field and the
|
// The bar's own string, because "how long have I been paused" is a *status* field and the
|
||||||
// elapsed figure is the one part of it that no other line can show: the exit dump's
|
// elapsed figure is the one part of it that no other line can show: the exit dump's
|
||||||
// `status bar:` is read live and the two `mode ...` lines are the live and triggered states.
|
// `status bar:` is read live and the two `mode ...` lines are the live and triggered states.
|
||||||
|
// `skips` is the render gate (see the end of `tick()`), cumulative over the session: it must be
|
||||||
|
// 0 on the `live` report and have grown by the `paused` one, which is the gate being a number
|
||||||
|
// rather than a claim. A live view that skips frames is the gate's condition inverted; a paused
|
||||||
|
// view that skips none is the gate not engaging at all.
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
|
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
|
||||||
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] glance=[%s]\n",
|
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] skips=%d glance=[%s]\n",
|
||||||
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
|
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
|
||||||
double(axis.min), double(axis.max), a.dbg_glance_last.c_str());
|
double(axis.min), double(axis.max), a.dbg_render_skips,
|
||||||
|
a.dbg_glance_last.c_str());
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -341,12 +341,14 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
publish_trigger_fields(a);
|
publish_trigger_fields(a);
|
||||||
EventCondition c;
|
EventCondition c;
|
||||||
int channel = -1;
|
int channel = -1;
|
||||||
|
double dt = 0.0;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
c = a->model->trigger().condition;
|
c = a->model->trigger().condition;
|
||||||
channel = a->model->trigger().channel;
|
channel = a->model->trigger().channel;
|
||||||
|
dt = a->model->partition().slot_dt();
|
||||||
}
|
}
|
||||||
a->search.start(c, channel);
|
a->search.start(c, channel, dt);
|
||||||
update_search_panel(*a);
|
update_search_panel(*a);
|
||||||
}),
|
}),
|
||||||
&app);
|
&app);
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ def build_data(
|
|||||||
glitch_at=-1.0,
|
glitch_at=-1.0,
|
||||||
glitch_width=0.0,
|
glitch_width=0.0,
|
||||||
quiet_at=-1.0,
|
quiet_at=-1.0,
|
||||||
|
spike_every=0.0,
|
||||||
|
spike_amp=0.0,
|
||||||
|
spike_width=0,
|
||||||
):
|
):
|
||||||
stamps = [t0 + i * dt for i in range(batch)]
|
stamps = [t0 + i * dt for i in range(batch)]
|
||||||
# `time_spike` models a device whose clock glitches for one packet: the stamps jump
|
# `time_spike` models a device whose clock glitches for one packet: the stamps jump
|
||||||
@@ -103,6 +106,16 @@ def build_data(
|
|||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
||||||
|
if spike_every > 0.0 and spike_amp != 0.0 and spike_width > 0:
|
||||||
|
# An impulse of `spike_width` samples at the start of every `spike_every`
|
||||||
|
# window, the sign alternating window to window so the train carries no
|
||||||
|
# DC a median could track. `stamps` are absolute stream seconds.
|
||||||
|
stamps_arr = np.array(stamps)
|
||||||
|
period = np.floor(stamps_arr / spike_every)
|
||||||
|
into = stamps_arr - period * spike_every
|
||||||
|
hit = into < (spike_width * dt)
|
||||||
|
sign = np.where((period.astype(np.int64) & 1) == 0, 1.0, -1.0)
|
||||||
|
sine = np.array(sine) + np.where(hit, sign * spike_amp, 0.0)
|
||||||
square = [
|
square = [
|
||||||
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps
|
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps
|
||||||
]
|
]
|
||||||
@@ -240,6 +253,12 @@ def main():
|
|||||||
"opposite side of zero, so --quiet-at is its last zero crossing: the shape a "
|
"opposite side of zero, so --quiet-at is its last zero crossing: the shape a "
|
||||||
"dropout trigger is read against. -1 = never",
|
"dropout trigger is read against. -1 = never",
|
||||||
)
|
)
|
||||||
|
ap.add_argument("--spike-every", type=float, default=0.0,
|
||||||
|
help="seconds between impulses on sine (0 disables)")
|
||||||
|
ap.add_argument("--spike-amp", type=float, default=0.0,
|
||||||
|
help="impulse amplitude, alternating sign window to window")
|
||||||
|
ap.add_argument("--spike-width", type=int, default=0,
|
||||||
|
help="impulse length in samples")
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--blackout-at",
|
"--blackout-at",
|
||||||
type=float,
|
type=float,
|
||||||
@@ -435,6 +454,9 @@ def main():
|
|||||||
args.glitch_at,
|
args.glitch_at,
|
||||||
args.glitch_width,
|
args.glitch_width,
|
||||||
args.quiet_at,
|
args.quiet_at,
|
||||||
|
args.spike_every,
|
||||||
|
args.spike_amp,
|
||||||
|
args.spike_width,
|
||||||
)
|
)
|
||||||
burst = max(1, args.drop_burst)
|
burst = max(1, args.drop_burst)
|
||||||
if args.drop_every > 0 and drop_left == 0 and counter % args.drop_every == 0:
|
if args.drop_every > 0 and drop_left == 0 and counter % args.drop_every == 0:
|
||||||
|
|||||||
Reference in new issue
Block a user