Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667ed0c100 | ||
|
|
749b34a2cb | ||
|
|
c38630643d | ||
|
|
5c2c221777 | ||
|
|
1bde8809dd | ||
|
|
2c0e21fd20 | ||
|
|
84aebf4116 | ||
|
|
380ddb9a77 | ||
|
|
2730707372 | ||
|
|
fec0a1032e | ||
|
|
1583741e03 |
No files matched your search
@@ -208,6 +208,23 @@ to reject noise with, and counting both edges halves the period.
|
|||||||
The split is a share of the **window's own** min..max, not volts: a measurement is about this
|
The split is a share of the **window's own** min..max, not volts: a measurement is about this
|
||||||
signal in this window, and a level in volts would have to be typed for every signal.
|
signal in this window, and a level in volts would have to be typed for every signal.
|
||||||
|
|
||||||
|
Two more options ride on it, both at their old behaviour by default:
|
||||||
|
|
||||||
|
| option | what it is | unit | off |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `noise` | the hysteresis band's half-width, as the next section describes | % of the window's peak-to-peak | `0` disables the band; the default `10` is the band the app has always counted with |
|
||||||
|
| `hf` | a one-pole low-pass over the window, before *any* statistic | Hz corner | `0` is off |
|
||||||
|
|
||||||
|
The band is clamped to half the room between the split and the nearer extreme, so a 50 % band cannot invert
|
||||||
|
the thresholds. `hf` is the **one** place where a readout does not quote a stored sample: with `hf > 0` the
|
||||||
|
extremes, the mean and the levels come from a filtered copy of the window, so a ruler can sit at a level the
|
||||||
|
drawn trace -- which is raw -- never reaches. That is what filtering *is*; it must not be "fixed" by snapping
|
||||||
|
the value back to the nearest sample, and with `hf = 0` the old rule holds exactly.
|
||||||
|
|
||||||
|
Both cost: a filtered window disables the two bucket fast paths, because a bucket is an aggregate of *raw*
|
||||||
|
samples and cannot be mixed into a filtered walk. A filtered window is still walked once and shared by every
|
||||||
|
measurement that asks for it, so a pane of five measurements at two corners costs two walks, not five.
|
||||||
|
|
||||||
### Why a crossing is counted with hysteresis
|
### Why a crossing is counted with hysteresis
|
||||||
|
|
||||||
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
|
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
|
||||||
@@ -993,6 +1010,24 @@ modes are unchanged, and only the question "did an event happen at this sample"
|
|||||||
- **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage:
|
- **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage:
|
||||||
the condition re-arms only when a crossing returns.
|
the condition re-arms only when a crossing returns.
|
||||||
|
|
||||||
|
**Noise rejection on the trigger's own input.** A *band* and a *reject* are offered beside the level.
|
||||||
|
`band` is a share of the channel's own span either side of the level -- a second, undecided threshold, so a
|
||||||
|
sample inside it decides nothing -- and `reject` is a one-pole low-pass corner in Hz, one state per
|
||||||
|
configured condition, recomputed when the corner or the sample interval changes. **Off is `band = 0` and
|
||||||
|
`reject = 0`**, which is bit for bit the trigger that was there before them.
|
||||||
|
|
||||||
|
They apply to **edge** and **window** and *not* to **pulse width** or **dropout**. That is a deliberate
|
||||||
|
deviation from the design spec, which asked for every condition type: a pulse's width is measured from a
|
||||||
|
crossing to its return, so a band there would change the *measured width* rather than reject noise (the
|
||||||
|
documented pulse-width readings move with it), and a dropout is a measured *absence*, which a band cannot
|
||||||
|
describe -- there is no level to compare.
|
||||||
|
|
||||||
|
The band and the reject are verified through `BufferSearch` hit counts rather than through
|
||||||
|
`[condition-probe]` latch times, also deliberately: `probe-readings.md` states that a `t0` is **not** an
|
||||||
|
assertion, being a stream stamp rebased by whichever rebuild last dropped history -- it read `1.7103`,
|
||||||
|
`1.6975` and `1.8719` on three consecutive runs of the same leg. The counts, and what each must read, are in
|
||||||
|
`probe-readings.md`.
|
||||||
|
|
||||||
A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
|
A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
|
||||||
width to a window trigger. The condition is **not** persisted in the session file — the session
|
width to a window trigger. The condition is **not** persisted in the session file — the session
|
||||||
has never persisted the trigger — so a restart comes up on an edge trigger.
|
has never persisted the trigger — so a restart comes up on an edge trigger.
|
||||||
@@ -1112,9 +1147,23 @@ an *ordinary channel*: the model synthesises a signal entry for it, so an enable
|
|||||||
plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and
|
plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and
|
||||||
another math channel can be its source. The operations are `channel` (a source), `gain + offset`,
|
another math channel can be its source. The operations are `channel` (a source), `gain + offset`,
|
||||||
`abs`, `sqrt`, `clamp`, `low pass`, `high pass`, `band pass` (biquads, cutoffs in Hz against the
|
`abs`, `sqrt`, `clamp`, `low pass`, `high pass`, `band pass` (biquads, cutoffs in Hz against the
|
||||||
stored rate), `sum`, `difference`, `product` and `ratio`; the arithmetic ops take two inputs, which
|
stored rate), `de-noise` (a running median followed by a one-pole), `sum`, `difference`, `product`
|
||||||
|
and `ratio`; the arithmetic ops take two inputs, which
|
||||||
is what makes branching — the sum of a raw and a filtered channel — expressible at all.
|
is what makes branching — the sum of a raw and a filtered channel — expressible at all.
|
||||||
|
|
||||||
|
`de-noise` is **last in the dropdown on purpose**: a node's op is stored in the session as its index
|
||||||
|
through `math_op_at()`, so an op inserted among the others would reinterpret every node in every file written
|
||||||
|
before it.
|
||||||
|
|
||||||
|
**`de-noise` is one node, not a median the user chains into a low pass.** Its window is `med_n` samples, odd
|
||||||
|
and 3..9 (the dialog offers only those, and the engine rounds to it anyway, so a hand-edited file loads), and
|
||||||
|
an impulse shorter than half the window cannot move the median at all; the one-pole at `fc` Hz then takes the
|
||||||
|
hash the median leaves behind, and `fc = 0` is the median alone. The order is the design: a pole *before* a
|
||||||
|
median feeds the impulse into the filter's state for its whole settling time, which is the thing the node
|
||||||
|
exists to avoid. It costs a per-sample insertion sort of at most nine doubles into a local, on the producer
|
||||||
|
thread where every other op runs -- `math cost:` reads **103 ns/slot** with a de-noise channel in the probe's
|
||||||
|
three graphs.
|
||||||
|
|
||||||
**A gap is a gap in a filtered trace, never the end of it.** A hole the resequencer reserved is
|
**A gap is a gap in a filtered trace, never the end of it.** A hole the resequencer reserved is
|
||||||
NaN in the source, and the filters deliberately do not integrate it: the hole shows as a hole in
|
NaN in the source, and the filters deliberately do not integrate it: the hole shows as a hole in
|
||||||
the filtered trace, the filter's history stays the last real samples it saw, and the trace picks
|
the filtered trace, the filter's history stays the last real samples it saw, and the trace picks
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
||||||
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
||||||
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
||||||
|
- **`MeasureDialog` and `MathDialog` are brace-initialised *positionally* in `src/ui.cpp`**, so a field
|
||||||
|
added in the middle of either silently shifts every argument after it: add at the end of the widget group
|
||||||
|
and read the initialiser in the same commit. A row array widened by a new control is the same trap from
|
||||||
|
the other side -- a `rows[N]` that was not grown shows a control the sync never touches, and the compiler
|
||||||
|
says so as an out-of-bounds warning on `rows`.
|
||||||
|
- **A control that cannot affect the current type is *hidden*, not disabled**: the trigger's band row is
|
||||||
|
hidden for `dropout`, the measurement's for every non-crossing type, and the node editor's median row for
|
||||||
|
every op but `de-noise`. A greyed row is a question the user can see and cannot answer.
|
||||||
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
||||||
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
||||||
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
drag pans, `f` fits, `Ctrl+0` resets**.
|
drag pans, `f` fits, `Ctrl+0` resets**.
|
||||||
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
|
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
|
||||||
can invalidate a live callback handle -- the same shape the measurements dialog uses.
|
can invalidate a live callback handle -- the same shape the measurements dialog uses.
|
||||||
`UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
|
`UDPSCOPE_PROBE_MATH_MS` defines three graphs -- the third a `Source` of the first channel into a
|
||||||
as much as a state check), and the dump prints each derived channel's extremes beside the
|
`DeNoise` node, so the editor's median row is built and synced on a baseline run -- and opens the
|
||||||
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
editor (a construction crash test as much as a state check), and the dump prints each derived
|
||||||
|
channel's extremes beside the source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
||||||
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
||||||
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
||||||
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
||||||
@@ -61,7 +62,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
which is why the fix is in the push and not in the loader.
|
which is why the fix is in the push and not in the loader.
|
||||||
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
||||||
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
||||||
anywhere in 78--101 ns/slot is one reading of the same thing.
|
anywhere in 78--101 ns/slot is one reading of the same thing; the three-graph run reads **103**, the
|
||||||
|
de-noise channel's per-sample median being the difference.
|
||||||
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
|
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
|
||||||
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
|
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
|
||||||
channel being edited, so a row that stopped being the one the code writes shows there.
|
channel being edited, so a row that stopped being the one the code writes shows there.
|
||||||
@@ -239,6 +241,24 @@ and reading values off the widgets *inside* the editor cannot see that. It is th
|
|||||||
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
||||||
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
||||||
accident; the `delete node` button stays the only way to remove a node.
|
accident; the `delete node` button stays the only way to remove a node.
|
||||||
|
- **`MathOp` is append-only, because `math_op_at()` *is* the serialisation.** A session stores a node's op
|
||||||
|
as its index through that accessor, so an op inserted in the middle reinterprets every node in every file
|
||||||
|
written before it; `DeNoise` is therefore index 12, after `Div`, and sits last in the dropdown.
|
||||||
|
`math_op_uses_filter()` keeps its meaning of *a biquad* -- `math_op_uses_fc()` is the wider question
|
||||||
|
("does this op read `fc` at all") and is what the dialog's corner row asks.
|
||||||
|
- **The de-noise node's median window is *pooled* in `MathRuntime::median`, not held in `MathNodeRt`**, so a
|
||||||
|
node's state stays on one cache line; nine doubles per node would push every node in the graph, filter or
|
||||||
|
not, across two. `MathNodeRt::med_at` / `::med_n` / `::med_pos` / `::med_have` are that pool's slice,
|
||||||
|
length, write position and fill, and the pool is sized in `math_prepare()`.
|
||||||
|
- **Median *then* pole, and the order is the design.** The median kills an impulse outright -- one shorter
|
||||||
|
than half its window cannot move it at all -- and the pole takes the broadband hash it leaves behind. The
|
||||||
|
other order feeds the pole the impulse, which smears it over the pole's whole settling time and leaves the
|
||||||
|
median a wide bump it cannot reject.
|
||||||
|
- **The de-noise pole reuses `b0` and `z1`, deliberately.** `math_prepare()` carries `z1` across a
|
||||||
|
re-prepare, so the pole keeps its history through a decimation change exactly as the biquads do -- while the
|
||||||
|
median window starts from rest, the same choice the biquads' `z2` makes. `median_n()` rounds *up* to odd and
|
||||||
|
caps at 9, and it is what enforces the window rather than the dialog: a session file holding 4 or 40 still
|
||||||
|
loads. The cap is what `double sorted[9]` in the step is sized for.
|
||||||
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
||||||
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
||||||
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
the pyramid for nothing.
|
the pyramid for nothing.
|
||||||
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
||||||
anything.
|
anything.
|
||||||
|
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
|
||||||
|
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
|
||||||
|
sums still split at the bare level, because `Top` on a unit sine must read 2/pi (0.636620).
|
||||||
|
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
|
||||||
|
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
|
||||||
|
the last a different filter state.
|
||||||
|
- **A non-zero `hf_fc` disables *both* bucket fast paths**, with the single
|
||||||
|
`if (!filtered && reader.bucket_range(...))`. An empty `buckets` makes `bulk_from == bulk_to == n` *and*
|
||||||
|
leaves `lo_search` / `hi_search` at 0, so `pin()` becomes a no-op -- and that is the right answer, because a
|
||||||
|
bucket is an aggregate of unfiltered samples and cannot be mixed into a filtered walk in either direction.
|
||||||
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
|
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
|
||||||
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
|
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
|
||||||
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
|
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
`crosshair_interpolated_mode_blends_the_two_samples` and
|
`crosshair_interpolated_mode_blends_the_two_samples` and
|
||||||
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
|
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
|
||||||
y=..` agreeing with the pane's first value.
|
y=..` agreeing with the pane's first value.
|
||||||
|
- **A measurement with a non-zero `hf_fc` is the one exception**, and it is inherent rather than a rounding
|
||||||
|
error. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered
|
||||||
|
copy of the window, so the quoted value need not equal any stored sample -- and with `show_on_plot` its
|
||||||
|
ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering *is*; it must
|
||||||
|
not be "fixed" by snapping the readout to the nearest sample. Every measurement with `hf_fc == 0`, which is
|
||||||
|
the default, still quotes a stored sample exactly as the rule above says. The filter is seeded with the
|
||||||
|
window's **first sample** and never from history before the window, so a measurement never depends on data
|
||||||
|
outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming
|
||||||
|
from earlier samples.
|
||||||
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
|
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
|
||||||
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
|
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
|
||||||
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
|
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
was parsed into the last calibration record and the enable state was lost without a word.
|
was parsed into the last calibration record and the enable state was lost without a word.
|
||||||
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
|
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
|
||||||
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
|
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
|
||||||
|
- **Measurement options persist under *named* keys -- `noise` and `hf` -- so a file written before them
|
||||||
|
loads at the defaults**, which are exactly the behaviour it was written against: `noise = 10` is the
|
||||||
|
hysteresis band the app has always counted with, and `hf = 0` is no filter.
|
||||||
|
- **The math node string is thirteen pipe fields, and the reader accepts twelve *or* thirteen.** `med_n` is
|
||||||
|
the thirteenth, after `y`; a twelve-field node is a file written before the de-noise node existed, and its
|
||||||
|
`med_n` stays at the struct's default -- which is what such a file meant. Field 0 is the op as a
|
||||||
|
`math_op_at()` index, which is why `MathOp` may only be *appended* to.
|
||||||
- **The export is collected at click time, not in the file-dialog callback.** The live
|
- **The export is collected at click time, not in the file-dialog callback.** The live
|
||||||
view keeps scrolling while the chooser is up, so a late collection would write a
|
view keeps scrolling while the chooser is up, so a late collection would write a
|
||||||
window the user never saw. The writer is picked by the chosen path's extension.
|
window the user never saw. The writer is picked by the chosen path's extension.
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
|
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
|
||||||
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
|
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
|
||||||
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
|
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
|
||||||
|
- **`EventCondition` carries the trigger's own rejection: `noise_band` and `hf_fc`.** Both are in the
|
||||||
|
struct's `operator==`, so a change to either *is* a change to the condition. `EventDetector::configure()`
|
||||||
|
takes `dt` and early-returns only when the condition **and** the interval are unchanged -- it runs on
|
||||||
|
*every packet*, so a configure that reset unconditionally would restart the machine before any pulse could
|
||||||
|
close. `dt` is `Partition::slot_dt()`, the *stored* interval and not the CONFIG's nominal rate, and comes
|
||||||
|
from exactly three call sites (`acquisition.cpp`, `ui_trigger.cpp`, `ui_probe.cpp`); a zero there leaves
|
||||||
|
the reject's coefficient at 1, which is a pass-through.
|
||||||
|
- **A crossing is a change between two *decided* sides, never a change from the undecided one.** The band
|
||||||
|
makes each sample decide above / below / undecided, and an edge is counted only when the signal has reached
|
||||||
|
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
|
||||||
|
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
|
||||||
|
trigger is not supposed to see.
|
||||||
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
|
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
|
||||||
While an event is held, a capture is filling, or the view is paused, `tick()` calls
|
While an event is held, a capture is filling, or the view is paused, `tick()` calls
|
||||||
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
|
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
|
||||||
|
|||||||
@@ -851,9 +851,13 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
|
|||||||
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
|
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
|
||||||
[configs] before=default active=default
|
[configs] before=default active=default
|
||||||
[configs] save-as=1 names=probe,default active=probe
|
[configs] save-as=1 names=probe,default active=probe
|
||||||
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0
|
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
|
||||||
[configs] after forget names=default
|
[configs] after forget names=default
|
||||||
```
|
```
|
||||||
|
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
|
||||||
|
sets them to **7.5 and 250** before saving, so a key that never reached the file reads back as the defaults
|
||||||
|
(10 and 0) instead -- which is the only way a round trip can tell a written key from an absent one. They read
|
||||||
|
`-1` when the saved configuration holds no measurement, which is what the run above does.
|
||||||
|
|
||||||
| field | value it must read | the defect a wrong reading names |
|
| field | value it must read | the defect a wrong reading names |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -1165,14 +1169,15 @@ through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form
|
|||||||
## `trigger panel:`
|
## `trigger panel:`
|
||||||
|
|
||||||
```
|
```
|
||||||
[udpscope] trigger panel: boxes=13 controls_inside=13/13 search_widgets=4/4 search_inside=4/4
|
[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
|
||||||
```
|
```
|
||||||
|
|
||||||
The trigger panel's frame, read from the window: `boxes` is the row boxes the `.ui` holds, `controls_inside`
|
The trigger panel's frame, read from the window: `boxes` is the row boxes the `.ui` holds, `controls_inside`
|
||||||
counts the thirteen controls the code built and then appended into those rows, `search_widgets` is the four
|
counts the fifteen controls the code built and then appended into those rows -- the last two being the
|
||||||
|
noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four
|
||||||
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
|
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
|
||||||
opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
|
opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
|
||||||
`controls_inside` below 13 while every other line still looks right, which is the panel that has a field
|
`controls_inside` below 15 while every other line still looks right, which is the panel that has a field
|
||||||
missing.
|
missing.
|
||||||
|
|
||||||
The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
|
The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
|
||||||
@@ -1275,3 +1280,104 @@ which must be untouched:
|
|||||||
channels and an edit does not move it, so the store survives: the count *grows* (the stream keeps filling
|
channels and an edit does not move it, so the store survives: the count *grows* (the stream keeps filling
|
||||||
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
|
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
|
||||||
`N samples vs M time slots` with `N == M`, so no channel was left behind either.
|
`N samples vs M time slots` with `N == M`, so no channel was left behind either.
|
||||||
|
|
||||||
|
## `[noise-probe]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[noise-probe] setup channels=1
|
||||||
|
[noise-probe] trigger band: bare=49/2 band=7/2 ratio=7.00
|
||||||
|
[noise-probe] trigger hf: hf=7/2 band+hf=6/2 band=7
|
||||||
|
[noise-probe] trigger delay: clean=3 filtered=4 delay=0.002206 want=0.002206 ratio=1.000
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_NOISE_MS`, and it needs the mock's **spiked** sine -- `--spike-every 0.005 --spike-amp 0.5
|
||||||
|
--spike-width 2` -- where every other probe uses the plain mock. It runs as **four stages**, and the chain is the point:
|
||||||
|
|
||||||
|
1. At `UDPSCOPE_PROBE_NOISE_MS` the setup installs the de-noise graph through `apply_math_channels()`.
|
||||||
|
That is a structural rebuild that *drops the buffer's history*, so the five seconds to the next stage are
|
||||||
|
what let the ring refill before anything scans it.
|
||||||
|
2. Five seconds later the legs run. They enable the signals they measure *themselves*, because a probe must
|
||||||
|
not depend on another probe's side effects (`enable_all_probe` ends by switching one off again, which is
|
||||||
|
how `sine` went missing from the first run of this one), and wait 2.5 s for the ring to refill after that
|
||||||
|
structural change. The legs then each search the whole retained buffer through `BufferSearch`, one after
|
||||||
|
another **without yielding**, so all six see the same buffer -- and the five measurements come next, over
|
||||||
|
the same retained history.
|
||||||
|
3. Two and a half seconds later the de-noise channel is **enabled**, by name, through the same switch the
|
||||||
|
panel drives. The setup installs it *disabled* and this stage turns it on, because an acquired channel is
|
||||||
|
a share of the ring: a fifth one shortens *every* channel's retained span, and every figure above scales
|
||||||
|
with that span -- the six leg counts, and `pkpk bare`, which is a peak-to-peak *of the retained window*.
|
||||||
|
The readings above are the older feature's proof, so the new channel stays out of the acquisition plan
|
||||||
|
until they have all run.
|
||||||
|
4. Two and a half seconds after that, the de-noise reading. A math channel starts producing when it is
|
||||||
|
applied *and* acquired, so the channel's first sample is the first one after stage 3, and a peak-to-peak
|
||||||
|
needs a window holding a trough *and* a crest: twelve cycles of the 5 Hz source.
|
||||||
|
|
||||||
|
With `UDPSCOPE_PROBE_NOISE_MS=4000` the legs therefore land at about 11.5 s and the last line at 16.5 s,
|
||||||
|
which is why the documented run needs `UDPSCOPE_EXIT_AFTER_MS=18000`. Four seconds and not three: the
|
||||||
|
enable-all probe's own last act, a click on the front channel's tick at `ENABLE_ALL_MS + 2000`, rebuilds
|
||||||
|
nothing itself but *is* rebuilt under when a graph is installed at its instant -- see `verification.md`.
|
||||||
|
|
||||||
|
- **`setup channels=1`** -- one math channel was installed, and it is printed by the *first* stage, before
|
||||||
|
any leg: it is the line that says the graph exists at all. A count above 1 means a session file from an
|
||||||
|
earlier run was loaded on top of it (`$XDG_CONFIG_HOME/udpscope`); those channels load disabled, so they
|
||||||
|
do not change the partition, and a clean run of the documented command prints `1`.
|
||||||
|
- **`trigger band:`** -- `bare` is the bare comparator on the spiked sine (every impulse that lands near zero
|
||||||
|
carries it across, so the count is dominated by spikes), `band` the same with a 0.8 band. **`ratio` must be
|
||||||
|
at or above 5**, and `band` about the number of sine cycles retained. Neither count is a *fixed* number:
|
||||||
|
both scale with the retained buffer's length (7 at a 2.5 s buffer, ~30 at 6 s), so the ratio and the
|
||||||
|
agreement between the legs are the assertions. A `ratio` near 1 means the band is not being applied --
|
||||||
|
`decide()` is not reached from `feed_edge`.
|
||||||
|
- **`trigger hf:`** -- `hf` (no band, 50 Hz reject) and `band+hf` (both) must be **within 2 of `band`**: the
|
||||||
|
reject removes the impulses on its own. `hf` near `bare` means the pole never left `a == 1` (check
|
||||||
|
`configure()` recomputes it, and that `hf_fc` reached the condition); `band+hf` near 0 means the reject is
|
||||||
|
attenuating the sine itself, which would be the Nyquist clamp clamping 50 Hz.
|
||||||
|
- **`trigger delay:`** -- the reject's group delay, measured on **`square`**, which carries no impulses: a
|
||||||
|
one-pole turns a -1 -> +1 step into a zero crossing at exactly `tau*ln2`, so `delay` must read
|
||||||
|
`want = 0.002206 s` and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 is a pass-through; a large one
|
||||||
|
means `a` is far too small, i.e. `dt` never arrived at the detector.
|
||||||
|
- `state` is `2` (Done) or `4` (Capped) on every leg. `3` (Truncated) on more than one leg means the pump loop
|
||||||
|
is being overtaken -- nothing to fix in the feature, but say so.
|
||||||
|
|
||||||
|
The same probe then puts five measurements on the same spiked `sine` as **two windows** -- three legs at
|
||||||
|
`hf=0` and two at `hf=50` -- and reads them back:
|
||||||
|
|
||||||
|
```
|
||||||
|
[noise-probe] measure pkpk bare noise=10.0 hf=0.0 valid=1 value=2.987692
|
||||||
|
[noise-probe] measure pkpk hf noise=10.0 hf=50.0 valid=1 value=1.990075
|
||||||
|
[noise-probe] measure period noband noise=0.0 hf=0.0 valid=1 value=0.021290
|
||||||
|
[noise-probe] measure period band noise=25.0 hf=0.0 valid=1 value=0.200000
|
||||||
|
[noise-probe] measure period hf noise=0.0 hf=50.0 valid=1 value=0.200000
|
||||||
|
[noise-probe] measure windows=2
|
||||||
|
[noise-probe] denoise raw=2.987696/1 denoised=2.000000/1 ratio=0.669 channels=1
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`pkpk bare`** -- about **3.0**: the sine's 2 plus an impulse either way. The impulses dominate the
|
||||||
|
window's extremes, so this is the *stimulus*.
|
||||||
|
- **`pkpk hf`** -- about **1.99** (`2/sqrt(1 + (5/50)^2)`): the *waveform*. This is the reading that says the
|
||||||
|
filter reached **pass one** and not only pass two; a value near 3 means it did not -- check `measure_scan`
|
||||||
|
is handed `w.scan_filter`, and that `filtered` disabled the buckets, since a bucket's aggregate is raw.
|
||||||
|
- **`period noband`** -- about **0.021**, far below the true 0.2. **This one is supposed to read wrong**: it is
|
||||||
|
the control the other two are compared against, the defect the band exists to fix. "Fixing" it removes the
|
||||||
|
check.
|
||||||
|
- **`period band`** -- exactly **0.200000**, the true period, from the band alone. Near `period noband` means
|
||||||
|
`noise_pct` is not reaching `measure_set_level`. The leg asks for **25 %** and not the 10 % default: the band
|
||||||
|
is a share of the *window's* peak-to-peak, which with the impulses in it is about 3, so 10 % is a half-band
|
||||||
|
of 0.3 -- less than the 0.5 an impulse moves the signal by, which is exactly what
|
||||||
|
`MeasureOptions::noise_pct`'s own rationale says (10 % holds to a 0.3 interferer).
|
||||||
|
- **`period hf`** -- exactly **0.200000** with the band off: the reject alone. Near `period noband` means the
|
||||||
|
pole coefficient stayed at 1 -- check `measure_filter_set` was given a non-zero `dt`.
|
||||||
|
- **`denoise`** -- the feature under test. `raw` is the spiked sine's peak-to-peak over the visible window and
|
||||||
|
`denoised` the same reading from the **`math denoise`** channel -- a `Source` of `sine` into `DeNoise` with
|
||||||
|
`med_n=5` and `fc=0`, i.e. the median alone. **`denoised` must read exactly `2.000000`**: that is the clean
|
||||||
|
sine's own peak-to-peak, so *not one impulse survived*, which is the whole claim the node makes (the
|
||||||
|
impulses are 2 samples wide and the median is 5, shorter than half a window). `denoised` near `raw` means the
|
||||||
|
median is passing them: check `med_n` arrived as 5 and not 0, since a `med_n <= 0` node returns NaN and a
|
||||||
|
peak-to-peak over holes would read `--` instead. **`denoised=0.000000` with `valid=0` means the channel never
|
||||||
|
produced** -- it was never enabled, its source `sine` was never re-enabled, or `apply_math_channels()` was
|
||||||
|
never called. `ratio` is `denoised / raw` and follows `raw`, which is whatever the visible window's extremes
|
||||||
|
happen to hold: 0.669 here, and up to about 0.8 in a run whose window misses the spike at one extreme. The
|
||||||
|
assertion is on `denoised` and on `raw` being above it; the ratio is the reading, not the test.
|
||||||
|
`channels=` is the math channel *count* (`a->math.size()`), not whether the channel is acquired.
|
||||||
|
- **`measure windows=2`** -- the five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two
|
||||||
|
distinct windows. **`windows=1` means the corner is not part of the key**: the five collapsed onto one walk
|
||||||
|
and two of them read a signal they did not ask for.
|
||||||
@@ -522,3 +522,54 @@ hold ports.
|
|||||||
|
|
||||||
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
||||||
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
||||||
|
|
||||||
|
## The noise probe needs a spiked mock
|
||||||
|
|
||||||
|
Every other probe runs against the plain mock. The noise probe reads what a *band* and an *HF reject* do to
|
||||||
|
impulsive interference, so its mock must have some: an alternating-sign impulse train of 2 samples at
|
||||||
|
amplitude 0.5 every 5 ms, added to `sine` alone (so a spiked channel can be read against a clean one in the
|
||||||
|
same run).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/martino/Projects/tools/udpscope
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 20 \
|
||||||
|
--spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/noise-mock.log 2>&1 &
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
UDPSCOPE_PROBE_NOISE_MS=4000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_NOISE_MS` is **not the time its readings are taken**. It times the probe's first stage, which
|
||||||
|
installs a math graph through `apply_math_channels()` -- and that is a *structural rebuild* that replaces the
|
||||||
|
buffer and drops its history. Every reading the probe takes is over retained history, so the rebuild has to
|
||||||
|
happen seconds *before* them, and the chain is what the number is really choosing: `4000` puts the graph in at
|
||||||
|
4 s, `noise_probe` at 9 s (it enables the signals it measures itself and waits 2.5 s for the ring to refill
|
||||||
|
after *that* change), the legs and the five measurements at about 11.5 s, the de-noise channel enabled at
|
||||||
|
14 s, and the de-noise reading at 16.5 s -- hence the 18 s exit and the 20 s mock. **Collapsing the two
|
||||||
|
stages into one is the failure mode**: a probe that rebuilt the buffer at 11.5 s and then scanned it
|
||||||
|
immediately would read `bare=0` on every search leg, which looks exactly like a broken comparator.
|
||||||
|
|
||||||
|
**Why 4 s and not 3.** `enable_all_probe`'s last act is a timeout of its own, `ENABLE_ALL_MS + 2000` -- 3 s
|
||||||
|
under this recipe -- which walks `signal_list` for the front channel's acquire tick and clicks it. A probe
|
||||||
|
that installs a math graph at that same instant rebuilds the signal panel under it, and the off-step then
|
||||||
|
emits `active` on a freed `GtkCheckButton`: `GLib-GObject-CRITICAL: instance with invalid (NULL) class
|
||||||
|
pointer`, with the backtrace in `enable_all_probe()::{lambda}`. It is a real fragility of that probe's lookup
|
||||||
|
and not of the feature, and it is why every documented probe time is offset from `ENABLE_ALL_MS + 2000`, not
|
||||||
|
merely from each other.
|
||||||
|
|
||||||
|
The de-noise channel is installed *disabled* and enabled only for its own reading (stage 3). An acquired
|
||||||
|
channel is a share of the ring, so a fifth one shortens every channel's retained span -- and the six leg
|
||||||
|
counts and `pkpk bare` all scale with that span. The readings above the de-noise line belong to the older
|
||||||
|
feature and are the proof it still works, so the new channel stays out of the acquisition plan until they
|
||||||
|
have run. `--spike-every`/`--spike-amp`/
|
||||||
|
`--spike-width` are the mock's own; `--batch` must stay at or below 1000 (2000 samples x 5 signals x f64 is
|
||||||
|
80 kB, past UDP's ~65507-byte payload, and the mock dies on its first `sendto` while the run still exits 0).
|
||||||
|
|
||||||
|
The three lines and what each must read are in `probe-readings.md`.
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS` and `UDPSCOPE_PROBE_MATH_MS` must **not** be scheduled
|
||||||
|
in the same run. The noise and measurement probes both push onto `app.measures`, and the later one would read
|
||||||
|
the earlier one's rows as its own; the noise and math probes both write `app.math`, so the later one's graph
|
||||||
|
lands on top of the earlier one's and both sets of readings describe a state neither probe set up. The noise probe carries
|
||||||
|
its own measurement legs (see `probe-readings.md`), so nothing is lost by keeping them apart.
|
||||||
+26
-5
@@ -366,7 +366,7 @@ std::string math_node_text(const MathNode& node) {
|
|||||||
std::ostringstream o;
|
std::ostringstream o;
|
||||||
o << kind << '|' << node.source << '|' << node.in_a << '|' << node.in_b << '|' << node.gain << '|'
|
o << kind << '|' << node.source << '|' << node.in_a << '|' << node.in_b << '|' << node.gain << '|'
|
||||||
<< node.offset << '|' << node.fc << '|' << node.q << '|' << node.lo << '|' << node.hi << '|'
|
<< node.offset << '|' << node.fc << '|' << node.q << '|' << node.lo << '|' << node.hi << '|'
|
||||||
<< node.x << '|' << node.y;
|
<< node.x << '|' << node.y << '|' << node.med_n;
|
||||||
return o.str();
|
return o.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +382,9 @@ bool math_node_from_text(const std::string& text, MathNode& out) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.push_back(cur);
|
f.push_back(cur);
|
||||||
if (f.size() != 12u) return false;
|
// Twelve fields is a session file written before the de-noise node existed; its `med_n` stays at the
|
||||||
|
// struct's default, which is what that file meant.
|
||||||
|
if (f.size() != 12u && f.size() != 13u) return false;
|
||||||
const auto number = [&](std::size_t i, double& v) {
|
const auto number = [&](std::size_t i, double& v) {
|
||||||
try {
|
try {
|
||||||
v = std::stod(f[i]);
|
v = std::stod(f[i]);
|
||||||
@@ -402,8 +404,16 @@ bool math_node_from_text(const std::string& text, MathNode& out) {
|
|||||||
out.source = f[1];
|
out.source = f[1];
|
||||||
out.in_a = static_cast<int>(in_a);
|
out.in_a = static_cast<int>(in_a);
|
||||||
out.in_b = static_cast<int>(in_b);
|
out.in_b = static_cast<int>(in_b);
|
||||||
return number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) &&
|
if (!(number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) &&
|
||||||
number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y);
|
number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (f.size() == 13u) {
|
||||||
|
double med = 0.0;
|
||||||
|
if (!number(12, med)) return false;
|
||||||
|
out.med_n = static_cast<int>(med);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool load_layout(const std::string& path, LayoutState& out) {
|
bool load_layout(const std::string& path, LayoutState& out) {
|
||||||
@@ -556,6 +566,16 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
m.options.threshold_pct = std::stod(value);
|
m.options.threshold_pct = std::stod(value);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
}
|
}
|
||||||
|
} else if (key == "noise") {
|
||||||
|
try {
|
||||||
|
m.options.noise_pct = std::stod(value);
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
} else if (key == "hf") {
|
||||||
|
try {
|
||||||
|
m.options.hf_fc = std::stod(value);
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
} else if (key == "above") {
|
} else if (key == "above") {
|
||||||
m.options.above = value == "true";
|
m.options.above = value == "true";
|
||||||
} else if (key == "show") {
|
} else if (key == "show") {
|
||||||
@@ -668,7 +688,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
|||||||
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
|
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
|
||||||
<< "\nthreshold = " << m.options.threshold_pct
|
<< "\nthreshold = " << m.options.threshold_pct
|
||||||
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
|
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
|
||||||
<< (m.options.show_on_plot ? "true" : "false") << "\n";
|
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
|
||||||
|
<< "\nhf = " << m.options.hf_fc << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
|
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
|
||||||
|
|||||||
+88
-2
@@ -59,6 +59,28 @@ void biquad(MathOp op, double fc, double q, double dt, double& b0, double& b1, d
|
|||||||
|
|
||||||
/// One biquad step, transposed direct form II -- the form whose state words are the two things
|
/// One biquad step, transposed direct form II -- the form whose state words are the two things
|
||||||
/// that actually need carrying between samples.
|
/// that actually need carrying between samples.
|
||||||
|
/// One pole's coefficient for a corner of `fc` Hz at `dt` seconds a sample: `a = 1 - exp(-2*pi*fc*dt)`,
|
||||||
|
/// clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` returns 1, which is a pass-through -- so
|
||||||
|
/// "no pole" costs the step no branch.
|
||||||
|
double pole_a(double fc, double dt) {
|
||||||
|
if (!(fc > 0.0) || !(dt > 0.0)) return 1.0;
|
||||||
|
const double nyquist = 0.5 / dt;
|
||||||
|
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
|
||||||
|
return 1.0 - std::exp(-2.0 * kPi * use * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The median window's length: odd, and between 3 and 9.
|
||||||
|
///
|
||||||
|
/// Odd because an even window's median is the average of two samples, which is a value the signal never
|
||||||
|
/// took -- and the whole point of a median here is that its output is a sample that was really there.
|
||||||
|
/// Rounded *up*, so a request for 4 gets 5 rather than 3. The ceiling of 9 is what `math_step`'s fixed
|
||||||
|
/// `double sorted[9]` is sized for.
|
||||||
|
int median_n(int want) {
|
||||||
|
int n = want < 3 ? 3 : (want > 9 ? 9 : want);
|
||||||
|
if ((n & 1) == 0) ++n;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1,
|
double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1,
|
||||||
double& z2) {
|
double& z2) {
|
||||||
// A reserved hole is NaN in the source, and a NaN integrated into the state words is
|
// A reserved hole is NaN in the source, and a NaN integrated into the state words is
|
||||||
@@ -90,11 +112,12 @@ const char* math_op_name(MathOp op) {
|
|||||||
case MathOp::Sub: return "difference";
|
case MathOp::Sub: return "difference";
|
||||||
case MathOp::Mul: return "product";
|
case MathOp::Mul: return "product";
|
||||||
case MathOp::Div: return "ratio";
|
case MathOp::Div: return "ratio";
|
||||||
|
case MathOp::DeNoise: return "de-noise";
|
||||||
}
|
}
|
||||||
return "?";
|
return "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
int math_op_count() { return 12; }
|
int math_op_count() { return 13; }
|
||||||
|
|
||||||
MathOp math_op_at(int index) {
|
MathOp math_op_at(int index) {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
@@ -109,7 +132,8 @@ MathOp math_op_at(int index) {
|
|||||||
case 8: return MathOp::Sum;
|
case 8: return MathOp::Sum;
|
||||||
case 9: return MathOp::Sub;
|
case 9: return MathOp::Sub;
|
||||||
case 10: return MathOp::Mul;
|
case 10: return MathOp::Mul;
|
||||||
default: return MathOp::Div;
|
case 11: return MathOp::Div;
|
||||||
|
default: return MathOp::DeNoise;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +160,8 @@ bool math_op_uses_filter(MathOp op) {
|
|||||||
return op == MathOp::LowPass || op == MathOp::HighPass || op == MathOp::BandPass;
|
return op == MathOp::LowPass || op == MathOp::HighPass || op == MathOp::BandPass;
|
||||||
}
|
}
|
||||||
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
|
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
|
||||||
|
bool math_op_uses_fc(MathOp op) { return math_op_uses_filter(op) || op == MathOp::DeNoise; }
|
||||||
|
bool math_op_uses_median(MathOp op) { return op == MathOp::DeNoise; }
|
||||||
|
|
||||||
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
||||||
const std::function<int(const std::string&)>& index_of) {
|
const std::function<int(const std::string&)>& index_of) {
|
||||||
@@ -152,6 +178,17 @@ void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
|||||||
}
|
}
|
||||||
rt.dt = dt;
|
rt.dt = dt;
|
||||||
rt.ready = true;
|
rt.ready = true;
|
||||||
|
// The median windows, pooled: one pass to size the pool, then the node loop hands out the slices. A
|
||||||
|
// window starts from rest after a re-prepare -- the same choice the biquads' `z2` makes -- while the
|
||||||
|
// pole's `z1` is carried, so a decimation change does not make the trace jump.
|
||||||
|
std::size_t med_total = 0;
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
if (channel.nodes[i].op == MathOp::DeNoise) {
|
||||||
|
med_total += static_cast<std::size_t>(median_n(channel.nodes[i].med_n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rt.median.assign(med_total, 0.0);
|
||||||
|
std::size_t med_next = 0;
|
||||||
for (std::size_t i = 0; i < n; ++i) {
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
const MathNode& node = channel.nodes[i];
|
const MathNode& node = channel.nodes[i];
|
||||||
MathNodeRt& out = rt.node[i];
|
MathNodeRt& out = rt.node[i];
|
||||||
@@ -159,6 +196,15 @@ void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
|
|||||||
out.src = index_of ? index_of(node.source) : -1;
|
out.src = index_of ? index_of(node.source) : -1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (node.op == MathOp::DeNoise) {
|
||||||
|
out.med_n = median_n(node.med_n);
|
||||||
|
out.med_at = med_next;
|
||||||
|
out.med_pos = 0;
|
||||||
|
out.med_have = 0;
|
||||||
|
med_next += static_cast<std::size_t>(out.med_n);
|
||||||
|
out.b0 = pole_a(node.fc, dt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!math_op_uses_filter(node.op)) continue;
|
if (!math_op_uses_filter(node.op)) continue;
|
||||||
biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2);
|
biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2);
|
||||||
}
|
}
|
||||||
@@ -221,6 +267,46 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
|||||||
x = std::fabs(d) > 0.0 ? at(node.in_a) / d : 0.0;
|
x = std::fabs(d) > 0.0 ? at(node.in_a) / d : 0.0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case MathOp::DeNoise: {
|
||||||
|
const double in = at(node.in_a);
|
||||||
|
// A hole stays a hole, and the window is left alone: folding a NaN into the median would smear one
|
||||||
|
// gap across `med_n` samples, and the whole trace after it in the pole. `med_n <= 0` is a node the
|
||||||
|
// runtime has not prepared -- the window pointer below would be a read past the pool, so it is
|
||||||
|
// checked rather than assumed.
|
||||||
|
if (!std::isfinite(in) || self.med_n <= 0) {
|
||||||
|
x = kNaN;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const std::size_t len = static_cast<std::size_t>(self.med_n);
|
||||||
|
double* win = rt.median.data() + self.med_at;
|
||||||
|
win[self.med_pos] = in;
|
||||||
|
self.med_pos = self.med_pos + 1 == len ? 0 : self.med_pos + 1;
|
||||||
|
if (self.med_have < len) ++self.med_have;
|
||||||
|
// An insertion sort of at most nine doubles into a local. The window is tiny and fixed, so this
|
||||||
|
// beats anything that allocates, and it leaves the ring itself in arrival order -- which is what
|
||||||
|
// lets the oldest sample be overwritten in place. Before the window has filled, the median is taken
|
||||||
|
// over what there is: a shorter window, deliberately, rather than NaN. A derived trace that reads
|
||||||
|
// "no data" for its first few samples after every rebuild looks like a broken graph.
|
||||||
|
double sorted[9];
|
||||||
|
const std::size_t m = self.med_have;
|
||||||
|
for (std::size_t j = 0; j < m; ++j) {
|
||||||
|
const double v = win[j];
|
||||||
|
std::size_t p = j;
|
||||||
|
while (p > 0 && sorted[p - 1] > v) {
|
||||||
|
sorted[p] = sorted[p - 1];
|
||||||
|
--p;
|
||||||
|
}
|
||||||
|
sorted[p] = v;
|
||||||
|
}
|
||||||
|
const double med = sorted[m / 2];
|
||||||
|
// Median *then* pole, and the order is the design. The median kills an impulse outright; the pole
|
||||||
|
// then takes the broadband hash it leaves behind. The other way round feeds the pole the impulse,
|
||||||
|
// which smears it over the pole's whole settling time and leaves the median a wide bump it cannot
|
||||||
|
// reject.
|
||||||
|
self.z1 += self.b0 * (med - self.z1);
|
||||||
|
x = self.z1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.value = x;
|
self.value = x;
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -25,6 +25,10 @@ enum class MathOp {
|
|||||||
Sub, ///< a - b
|
Sub, ///< a - b
|
||||||
Mul, ///< a * b
|
Mul, ///< a * b
|
||||||
Div, ///< a / b, zero when b is 0 (a scope trace must not go to inf/NAN)
|
Div, ///< a / b, zero when b is 0 (a scope trace must not go to inf/NAN)
|
||||||
|
/// A running median of `med_n` samples, then a one-pole at `fc` Hz. For impulsive interference -- an EM
|
||||||
|
/// peak a few samples wide -- which a linear filter can only smear over its own settling time. `fc = 0`
|
||||||
|
/// is the median alone.
|
||||||
|
DeNoise,
|
||||||
};
|
};
|
||||||
|
|
||||||
const char* math_op_name(MathOp op);
|
const char* math_op_name(MathOp op);
|
||||||
@@ -41,6 +45,11 @@ int math_op_arity(MathOp op);
|
|||||||
bool math_op_uses_gain(MathOp op);
|
bool math_op_uses_gain(MathOp op);
|
||||||
bool math_op_uses_filter(MathOp op);
|
bool math_op_uses_filter(MathOp op);
|
||||||
bool math_op_uses_clamp(MathOp op);
|
bool math_op_uses_clamp(MathOp op);
|
||||||
|
/// Whether an op reads `fc`. Wider than `math_op_uses_filter()`, which keeps its present meaning of *a
|
||||||
|
/// biquad*: `DeNoise` reads `fc` for a one-pole and has no `q`.
|
||||||
|
bool math_op_uses_fc(MathOp op);
|
||||||
|
/// Whether an op reads `med_n`.
|
||||||
|
bool math_op_uses_median(MathOp op);
|
||||||
|
|
||||||
/// One node of a math channel's graph. Node indices are positions in `MathChannel::nodes`, and a
|
/// One node of a math channel's graph. Node indices are positions in `MathChannel::nodes`, and a
|
||||||
/// node may only read ones before it: the evaluator walks the list in order, which is what makes a
|
/// node may only read ones before it: the evaluator walks the list in order, which is what makes a
|
||||||
@@ -56,6 +65,10 @@ struct MathNode {
|
|||||||
double q = 0.707; ///< BP quality factor.
|
double q = 0.707; ///< BP quality factor.
|
||||||
double lo = -1.0; ///< `Clamp` bounds.
|
double lo = -1.0; ///< `Clamp` bounds.
|
||||||
double hi = 1.0;
|
double hi = 1.0;
|
||||||
|
/// `DeNoise`: the median window, in samples. Odd and between 3 and 9 -- `median_n()` in the
|
||||||
|
/// implementation is what enforces that, so a session file holding anything else still loads. An impulse
|
||||||
|
/// up to `med_n / 2` samples wide is rejected outright.
|
||||||
|
int med_n = 5;
|
||||||
double x = 0.0; ///< the editor's box position, logical pixels.
|
double x = 0.0; ///< the editor's box position, logical pixels.
|
||||||
double y = 0.0;
|
double y = 0.0;
|
||||||
|
|
||||||
@@ -67,7 +80,7 @@ struct MathNode {
|
|||||||
bool operator==(const MathNode& other) const {
|
bool operator==(const MathNode& other) const {
|
||||||
return op == other.op && source == other.source && in_a == other.in_a && in_b == other.in_b &&
|
return op == other.op && source == other.source && in_a == other.in_a && in_b == other.in_b &&
|
||||||
gain == other.gain && offset == other.offset && fc == other.fc && q == other.q &&
|
gain == other.gain && offset == other.offset && fc == other.fc && q == other.q &&
|
||||||
lo == other.lo && hi == other.hi;
|
lo == other.lo && hi == other.hi && med_n == other.med_n;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,10 +123,25 @@ struct MathNodeRt {
|
|||||||
/// per source node, per sample, behind a type-erased call. Names cannot change without a
|
/// per source node, per sample, behind a type-erased call. Names cannot change without a
|
||||||
/// rebuild, and every rebuild re-prepares.
|
/// rebuild, and every rebuild re-prepares.
|
||||||
int src = -1;
|
int src = -1;
|
||||||
|
/// `DeNoise`: where this node's median window starts in `MathRuntime::median`, how long it is, the ring's
|
||||||
|
/// write position, and how many samples it holds. The window is *pooled* rather than held here because a
|
||||||
|
/// node's state belongs on one cache line, and nine doubles would push this struct across two for every
|
||||||
|
/// node in the graph, filter or not.
|
||||||
|
///
|
||||||
|
/// The one-pole reuses `b0` for its coefficient and `z1` for its value, which is not a shortcut:
|
||||||
|
/// `math_prepare()` deliberately carries `z1` across a re-prepare, so the pole keeps its history through
|
||||||
|
/// a decimation change exactly as the biquads keep theirs.
|
||||||
|
std::size_t med_at = 0;
|
||||||
|
int med_n = 0;
|
||||||
|
std::size_t med_pos = 0;
|
||||||
|
std::size_t med_have = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct MathRuntime {
|
struct MathRuntime {
|
||||||
std::vector<MathNodeRt> node;
|
std::vector<MathNodeRt> node;
|
||||||
|
/// Every `DeNoise` node's median window, end to end: one allocation for the channel, sized by
|
||||||
|
/// `math_prepare()`, a node's slice starting at its own `med_at`.
|
||||||
|
std::vector<double> median;
|
||||||
/// The rate the coefficients were built for; a change rebuilds them.
|
/// The rate the coefficients were built for; a change rebuilds them.
|
||||||
double dt = 0.0;
|
double dt = 0.0;
|
||||||
bool ready = false;
|
bool ready = false;
|
||||||
|
|||||||
+33
-16
@@ -28,16 +28,13 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
|||||||
acc.open[e] = true;
|
acc.open[e] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
|
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
|
||||||
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
|
// setting now, and a second copy of the number here would be a second thing to keep in step.
|
||||||
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
|
|
||||||
/// on anything less square than a sine.
|
|
||||||
constexpr double kHysteresisFrac = 0.10;
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) {
|
||||||
const double span = s.hi - s.lo;
|
const double span = s.hi - s.lo;
|
||||||
acc.level = s.lo + pct * span;
|
acc.level = s.lo + pct * span;
|
||||||
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
||||||
@@ -46,10 +43,21 @@ void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
|||||||
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
||||||
// fixed band, `enters=4 period=0.200000` with the clamp.
|
// fixed band, `enters=4 period=0.200000` with the clamp.
|
||||||
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
||||||
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
|
const double want = noise_frac > 0.0 ? noise_frac * span : 0.0;
|
||||||
|
acc.hysteresis = std::max(0.0, std::min(want, 0.5 * room));
|
||||||
}
|
}
|
||||||
|
|
||||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
void measure_filter_set(MeasureFilter& f, double fc, double dt) {
|
||||||
|
f.reset();
|
||||||
|
f.a = 1.0;
|
||||||
|
if (fc > 0.0 && dt > 0.0) {
|
||||||
|
const double nyquist = 0.5 / dt;
|
||||||
|
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
|
||||||
|
f.a = 1.0 - std::exp(-2.0 * kPi * use * dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& filter) {
|
||||||
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
||||||
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
||||||
// end. The sum is accumulated per chunk and folded in once, which is also kinder numerically
|
// end. The sum is accumulated per chunk and folded in once, which is also kinder numerically
|
||||||
@@ -62,7 +70,7 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
|||||||
std::size_t at_hi = 0;
|
std::size_t at_hi = 0;
|
||||||
bool any = false;
|
bool any = false;
|
||||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||||
const double v = static_cast<double>(chunk.value[i]);
|
const double v = filter.step(static_cast<double>(chunk.value[i]));
|
||||||
if (!std::isfinite(v)) continue;
|
if (!std::isfinite(v)) continue;
|
||||||
if (!any) {
|
if (!any) {
|
||||||
lo = hi = v;
|
lo = hi = v;
|
||||||
@@ -143,12 +151,16 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
|
|||||||
|
|
||||||
void measure_accumulate(const MeasureChunk& chunk,
|
void measure_accumulate(const MeasureChunk& chunk,
|
||||||
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
||||||
std::size_t level_count) {
|
std::size_t level_count, MeasureFilter& filter) {
|
||||||
for (std::size_t k = 0; k < level_count; ++k) {
|
// Samples outer, levels inner -- the reverse of what reads more naturally, and deliberately. The filter
|
||||||
MeasureLevel& acc = levels[k];
|
// holds one state for the whole walk, so a levels-outer loop would step it once per level per sample and
|
||||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
// every level after the first would read a signal filtered `k` times over. Each level's own state is
|
||||||
const double v = static_cast<double>(chunk.value[i]);
|
// independent of the others, so the swap costs nothing else.
|
||||||
if (!std::isfinite(v)) continue;
|
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||||
|
const double v = filter.step(static_cast<double>(chunk.value[i]));
|
||||||
|
if (!std::isfinite(v)) continue;
|
||||||
|
for (std::size_t k = 0; k < level_count; ++k) {
|
||||||
|
MeasureLevel& acc = levels[k];
|
||||||
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
||||||
// below the threshold, and a band here would move a documented exact reading.
|
// below the threshold, and a band here would move a documented exact reading.
|
||||||
const int s = v > acc.level ? 1 : 0;
|
const int s = v > acc.level ? 1 : 0;
|
||||||
@@ -278,6 +290,11 @@ bool measure_uses_threshold(MeasureType type) {
|
|||||||
|
|
||||||
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
|
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
|
||||||
|
|
||||||
|
bool measure_uses_noise(MeasureType type) {
|
||||||
|
return type == MeasureType::PulseDuration || type == MeasureType::Period ||
|
||||||
|
type == MeasureType::Frequency;
|
||||||
|
}
|
||||||
|
|
||||||
bool measure_can_show_on_plot(MeasureType) { return true; }
|
bool measure_can_show_on_plot(MeasureType) { return true; }
|
||||||
|
|
||||||
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
|
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
|
||||||
|
|||||||
+61
-3
@@ -46,6 +46,23 @@ struct MeasureOptions {
|
|||||||
/// extremes (min/max), the pulse's two edges as vertical markers (pulse duration). The
|
/// extremes (min/max), the pulse's two edges as vertical markers (pulse duration). The
|
||||||
/// markers are readouts -- they follow the window, and the user cannot drag them.
|
/// markers are readouts -- they follow the window, and the user cannot drag them.
|
||||||
bool show_on_plot = false;
|
bool show_on_plot = false;
|
||||||
|
/// `PulseDuration`/`Period`/`Frequency`: the half-width of the hysteresis band about the threshold, as
|
||||||
|
/// a percentage of the window's own peak-to-peak. A crossing counts only once the signal has passed the
|
||||||
|
/// band, so an edge count is a count of the *waveform's* cycles rather than of its noise.
|
||||||
|
///
|
||||||
|
/// The default is measured rather than chosen: 5 % reads the mock's 0.1 interferer correctly and fails
|
||||||
|
/// at 0.2 (`period=0.075` for a true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third
|
||||||
|
/// of the waveform -- and to white noise at sigma=0.05. Above that the band starts eating a real
|
||||||
|
/// waveform's own extremes on anything less square than a sine. 0 reduces the walk exactly to a single
|
||||||
|
/// level.
|
||||||
|
double noise_pct = 10.0;
|
||||||
|
/// Every type: the corner of a one-pole low pass applied to each sample before *either* walk reads it,
|
||||||
|
/// so the extremes, the mean and the crossings all describe the filtered signal. 0 is off. In Hz.
|
||||||
|
///
|
||||||
|
/// It rejects what a band cannot: an impulse large enough to cross the band on its own. It is not free
|
||||||
|
/// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples --
|
||||||
|
/// so a non-zero corner costs a full raw walk.
|
||||||
|
double hf_fc = 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
|
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
|
||||||
@@ -105,6 +122,37 @@ struct MeasureStats {
|
|||||||
bool valid = false;
|
bool valid = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The HF reject, as a walk carries it: one pole, one state word.
|
||||||
|
///
|
||||||
|
/// `a == 1` is a pass-through and is the default, so an unfiltered walk pays one multiply and no branch,
|
||||||
|
/// and every caller can step it unconditionally. A non-finite sample passes through unchanged and leaves
|
||||||
|
/// the state alone -- a dropout is a real reading, and letting it poison `y` would make every sample
|
||||||
|
/// after it non-finite too.
|
||||||
|
struct MeasureFilter {
|
||||||
|
double a = 1.0;
|
||||||
|
double y = 0.0;
|
||||||
|
bool have = false;
|
||||||
|
|
||||||
|
double step(double v) noexcept {
|
||||||
|
if (!std::isfinite(v)) return v;
|
||||||
|
if (!have) {
|
||||||
|
y = v;
|
||||||
|
have = true;
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
y += a * (v - y);
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
void reset() noexcept {
|
||||||
|
y = 0.0;
|
||||||
|
have = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `a = 1 - exp(-2*pi*fc*dt)`, clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` or `dt == 0`
|
||||||
|
/// leaves it at 1, which is the pass-through. Resets the state.
|
||||||
|
void measure_filter_set(MeasureFilter& f, double fc, double dt);
|
||||||
|
|
||||||
/// One chunk of a window's values, in time order, exactly as the rings hold them.
|
/// One chunk of a window's values, in time order, exactly as the rings hold them.
|
||||||
///
|
///
|
||||||
/// The engine is *fed* the window this way rather than handed a materialized copy. A visible
|
/// The engine is *fed* the window this way rather than handed a materialized copy. A visible
|
||||||
@@ -126,6 +174,9 @@ struct MeasureChunk {
|
|||||||
/// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have
|
/// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have
|
||||||
/// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level
|
/// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level
|
||||||
/// are opened.
|
/// are opened.
|
||||||
|
///
|
||||||
|
/// A bucket aggregates **unfiltered** samples, so a walk with an HF reject must not use these at all --
|
||||||
|
/// that is what makes a non-zero `hf_fc` cost a full raw walk.
|
||||||
struct MeasureBucket {
|
struct MeasureBucket {
|
||||||
double lo = 0.0;
|
double lo = 0.0;
|
||||||
double hi = 0.0;
|
double hi = 0.0;
|
||||||
@@ -139,7 +190,7 @@ struct MeasureBucket {
|
|||||||
/// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed
|
/// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed
|
||||||
/// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk
|
/// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk
|
||||||
/// that merely ties it must not move it.
|
/// that merely ties it must not move it.
|
||||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats);
|
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter& filter);
|
||||||
|
|
||||||
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
|
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
|
||||||
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
|
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
|
||||||
@@ -192,14 +243,17 @@ struct MeasureLevel {
|
|||||||
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
|
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
|
||||||
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
|
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
|
||||||
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
|
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
|
||||||
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct);
|
/// `noise_frac` is `MeasureOptions::noise_pct / 100`. It is the caller's because the band is a
|
||||||
|
/// per-measurement setting and this is called once per *level*, which several measurements may share; the
|
||||||
|
/// clamp to half the room to the nearer extreme stays here, so every caller gets it.
|
||||||
|
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct, double noise_frac);
|
||||||
|
|
||||||
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
|
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
|
||||||
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
|
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
|
||||||
/// measurements on one signal costs two walks, not two per measurement.
|
/// measurements on one signal costs two walks, not two per measurement.
|
||||||
void measure_accumulate(const MeasureChunk& chunk,
|
void measure_accumulate(const MeasureChunk& chunk,
|
||||||
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
||||||
std::size_t level_count);
|
std::size_t level_count, MeasureFilter& filter);
|
||||||
|
|
||||||
/// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket
|
/// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket
|
||||||
/// every one of whose samples is on the same side of the level holds no crossing, so its sums
|
/// every one of whose samples is on the same side of the level holds no crossing, so its sums
|
||||||
@@ -243,6 +297,10 @@ bool measure_uses_threshold(MeasureType type);
|
|||||||
/// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
|
/// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
|
||||||
/// the waveform is the pulse).
|
/// the waveform is the pulse).
|
||||||
bool measure_uses_side(MeasureType type);
|
bool measure_uses_side(MeasureType type);
|
||||||
|
/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The band moves
|
||||||
|
/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` split their
|
||||||
|
/// sums at the bare level and read the same answer at any band.
|
||||||
|
bool measure_uses_noise(MeasureType type);
|
||||||
/// Whether a type draws its result on the plot when asked.
|
/// Whether a type draws its result on the plot when asked.
|
||||||
bool measure_can_show_on_plot(MeasureType type);
|
bool measure_can_show_on_plot(MeasureType type);
|
||||||
|
|
||||||
|
|||||||
+40
-5
@@ -1678,15 +1678,32 @@ void open_measure_dialog(App& app, int edit_index) {
|
|||||||
GtkWidget* side_row = labelled_row("pulse above level", above, &side_label);
|
GtkWidget* side_row = labelled_row("pulse above level", above, &side_label);
|
||||||
gtk_box_append(GTK_BOX(box), side_row);
|
gtk_box_append(GTK_BOX(box), side_row);
|
||||||
|
|
||||||
|
// A percentage of the window's own peak-to-peak, which is the unit the engine takes.
|
||||||
|
GtkWidget* noise = spin(0.0, 50.0, 1.0, 1, 10.0);
|
||||||
|
// `labelled_row()` writes the label out unconditionally, and these two never retitle, so the
|
||||||
|
// out-parameters are named and then explicitly unused rather than passed as nullptr.
|
||||||
|
GtkWidget* noise_label = nullptr;
|
||||||
|
GtkWidget* noise_row = labelled_row("noise band %", noise, &noise_label);
|
||||||
|
gtk_box_append(GTK_BOX(box), noise_row);
|
||||||
|
(void)noise_label;
|
||||||
|
|
||||||
|
// 0 is off. The engine clamps to just under the stream's Nyquist, which the dialog cannot know.
|
||||||
|
GtkWidget* hf = spin(0.0, 1.0e7, 1.0, 3, 0.0);
|
||||||
|
GtkWidget* hf_label = nullptr;
|
||||||
|
GtkWidget* hf_row = labelled_row("HF reject Hz", hf, &hf_label);
|
||||||
|
gtk_box_append(GTK_BOX(box), hf_row);
|
||||||
|
(void)hf_label;
|
||||||
|
|
||||||
GtkWidget* show = gtk_check_button_new_with_label("show on plot");
|
GtkWidget* show = gtk_check_button_new_with_label("show on plot");
|
||||||
gtk_widget_set_tooltip_text(show,
|
gtk_widget_set_tooltip_text(show,
|
||||||
"draw the result on the plot: a ruler at the level, a marker "
|
"draw the result on the plot: a ruler at the level, a marker "
|
||||||
"at the time it happened, the pulse's edges as markers");
|
"at the time it happened, the pulse's edges as markers");
|
||||||
gtk_box_append(GTK_BOX(box), show);
|
gtk_box_append(GTK_BOX(box), show);
|
||||||
|
|
||||||
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
|
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
|
||||||
threshold, above, show, window,
|
threshold, above, show, window,
|
||||||
threshold_row, threshold_label, side_row, side_label,
|
threshold_row, threshold_label, side_row, side_label,
|
||||||
|
noise, noise_row, hf, hf_row,
|
||||||
sig_names};
|
sig_names};
|
||||||
app.measure_dialog = window;
|
app.measure_dialog = window;
|
||||||
g_signal_connect(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
|
g_signal_connect(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
|
||||||
@@ -1709,6 +1726,8 @@ void open_measure_dialog(App& app, int edit_index) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), editing->options.threshold_pct);
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), editing->options.threshold_pct);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(noise), editing->options.noise_pct);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc);
|
||||||
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above);
|
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above);
|
||||||
gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot);
|
gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot);
|
||||||
} else {
|
} else {
|
||||||
@@ -2121,6 +2140,14 @@ void open_math_dialog(App& app, int edit) {
|
|||||||
gtk_box_append(GTK_BOX(form), clamp_rows);
|
gtk_box_append(GTK_BOX(form), clamp_rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Odd, 3..9. The engine rounds up to odd anyway, but a step of 2 means the widget never offers an even
|
||||||
|
// one.
|
||||||
|
d->med = gtk_spin_button_new_with_range(3.0, 9.0, 2.0);
|
||||||
|
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->med), 0);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->med), 5.0);
|
||||||
|
g_signal_connect(d->med, "value-changed", G_CALLBACK(on_math_param_changed), d);
|
||||||
|
gtk_box_append(GTK_BOX(form), math_row("median samples", d->med, &d->rows[6]));
|
||||||
|
|
||||||
d->inputs = gtk_label_new("");
|
d->inputs = gtk_label_new("");
|
||||||
gtk_label_set_wrap(GTK_LABEL(d->inputs), true);
|
gtk_label_set_wrap(GTK_LABEL(d->inputs), true);
|
||||||
gtk_widget_add_css_class(d->inputs, "dim-label");
|
gtk_widget_add_css_class(d->inputs, "dim-label");
|
||||||
@@ -2140,7 +2167,7 @@ void open_math_dialog(App& app, int edit) {
|
|||||||
gtk_widget_set_visible(d->problem, FALSE);
|
gtk_widget_set_visible(d->problem, FALSE);
|
||||||
gtk_box_append(GTK_BOX(form), d->problem);
|
gtk_box_append(GTK_BOX(form), d->problem);
|
||||||
|
|
||||||
// A menu of the twelve ops rather than one button that always makes a gain: building anything
|
// A menu of the thirteen ops rather than one button that always makes a gain: building anything
|
||||||
// else was add-then-change-the-dropdown, which is two steps for the common case.
|
// else was add-then-change-the-dropdown, which is two steps for the common case.
|
||||||
GtkWidget* add = gtk_menu_button_new();
|
GtkWidget* add = gtk_menu_button_new();
|
||||||
gtk_menu_button_set_label(GTK_MENU_BUTTON(add), "add node");
|
gtk_menu_button_set_label(GTK_MENU_BUTTON(add), "add node");
|
||||||
@@ -5395,6 +5422,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_DROP_MS")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_DROP_MS")) {
|
||||||
g_timeout_add(static_cast<guint>(std::atoi(env)), drop_probe, &app);
|
g_timeout_add(static_cast<guint>(std::atoi(env)), drop_probe, &app);
|
||||||
}
|
}
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) {
|
||||||
|
const int ms = std::atoi(env);
|
||||||
|
// Times the *setup* stage; `noise_probe` runs 5000 ms after it, so 3000 puts the legs at the same
|
||||||
|
// 8000 ms they were measured at.
|
||||||
|
if (ms > 0) g_timeout_add(static_cast<guint>(ms), noise_probe_setup, &app);
|
||||||
|
}
|
||||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_ROWMENU_MS")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_ROWMENU_MS")) {
|
||||||
rowmenu_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
rowmenu_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||||||
}
|
}
|
||||||
@@ -5812,7 +5845,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
|
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
|
||||||
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
|
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
|
||||||
std::printf(
|
std::printf(
|
||||||
"[udpscope] trigger panel: boxes=%d controls_inside=%d/13 search_widgets=%d/4 "
|
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
|
||||||
"search_inside=%d/4\n",
|
"search_inside=%d/4\n",
|
||||||
boxes,
|
boxes,
|
||||||
inside(app.trigger_signal_drop, app.trigger_panel) +
|
inside(app.trigger_signal_drop, app.trigger_panel) +
|
||||||
@@ -5827,6 +5860,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
inside(app.trigger_level_spin, app.trigger_panel) +
|
inside(app.trigger_level_spin, app.trigger_panel) +
|
||||||
inside(app.trigger_pre_spin, app.trigger_panel) +
|
inside(app.trigger_pre_spin, app.trigger_panel) +
|
||||||
inside(app.trigger_holdoff_spin, app.trigger_panel) +
|
inside(app.trigger_holdoff_spin, app.trigger_panel) +
|
||||||
|
inside(app.trigger_band_spin, app.trigger_panel) +
|
||||||
|
inside(app.trigger_hf_spin, app.trigger_panel) +
|
||||||
inside(app.events_record_switch, app.trigger_panel),
|
inside(app.events_record_switch, app.trigger_panel),
|
||||||
search_present,
|
search_present,
|
||||||
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
|
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
|
||||||
|
|||||||
@@ -162,6 +162,30 @@
|
|||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="band_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">noise band</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="hf_row">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">HF reject Hz</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox" id="mode_row">
|
<object class="GtkBox" id="mode_row">
|
||||||
<property name="orientation">horizontal</property>
|
<property name="orientation">horizontal</property>
|
||||||
|
|||||||
+26
-3
@@ -170,6 +170,8 @@ struct App {
|
|||||||
GtkWidget* trigger_level_spin = nullptr;
|
GtkWidget* trigger_level_spin = nullptr;
|
||||||
GtkWidget* trigger_pre_spin = nullptr;
|
GtkWidget* trigger_pre_spin = nullptr;
|
||||||
GtkWidget* trigger_holdoff_spin = nullptr;
|
GtkWidget* trigger_holdoff_spin = nullptr;
|
||||||
|
GtkWidget* trigger_band_spin = nullptr; ///< EventCondition::noise_band
|
||||||
|
GtkWidget* trigger_hf_spin = nullptr; ///< EventCondition::hf_fc, 0 is off
|
||||||
GtkWidget* signal_list = nullptr;
|
GtkWidget* signal_list = nullptr;
|
||||||
GtkWidget* pause_button = nullptr;
|
GtkWidget* pause_button = nullptr;
|
||||||
/// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time
|
/// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time
|
||||||
@@ -272,13 +274,23 @@ struct App {
|
|||||||
/// them asked for. Several measurements on one signal must cost two walks, not two each -- and
|
/// them asked for. Several measurements on one signal must cost two walks, not two each -- and
|
||||||
/// the window itself is never stored, which is what the chunked walks bought (see
|
/// the window itself is never stored, which is what the chunked walks bought (see
|
||||||
/// `MeasureChunk`).
|
/// `MeasureChunk`).
|
||||||
|
/// One channel's window, at one HF corner, with every level accumulator the pane asked for.
|
||||||
|
///
|
||||||
|
/// Keyed by `(channel, fc)` and not by channel alone: the corner is a per-measurement setting, so two
|
||||||
|
/// measurements on one signal at different corners are reading two different signals and cannot share a
|
||||||
|
/// walk.
|
||||||
struct MeasureWindow {
|
struct MeasureWindow {
|
||||||
|
int channel = -1;
|
||||||
|
double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered
|
||||||
MeasureStats stats;
|
MeasureStats stats;
|
||||||
std::vector<double> pct; ///< the level shares asked for, deduplicated
|
std::vector<double> pct; ///< the level shares asked for, deduplicated
|
||||||
|
std::vector<double> noise; ///< each entry's band, as a percentage; parallel to `pct`
|
||||||
std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct`
|
std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct`
|
||||||
|
MeasureFilter scan_filter; ///< pass one's state
|
||||||
|
MeasureFilter acc_filter; ///< pass two's; a separate walk, so a separate state
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
};
|
};
|
||||||
std::unordered_map<int, MeasureWindow> measure_samples;
|
std::vector<MeasureWindow> measure_samples;
|
||||||
/// The window's LOD buckets, reused across recomputes: a 1.7 M-sample window is 26 k of them,
|
/// The window's LOD buckets, reused across recomputes: a 1.7 M-sample window is 26 k of them,
|
||||||
/// and both passes walk the same list.
|
/// and both passes walk the same list.
|
||||||
std::vector<MeasureBucket> measure_bucket_scratch;
|
std::vector<MeasureBucket> measure_bucket_scratch;
|
||||||
@@ -898,6 +910,12 @@ struct MeasureDialog {
|
|||||||
GtkWidget* threshold_label = nullptr;
|
GtkWidget* threshold_label = nullptr;
|
||||||
GtkWidget* side_row = nullptr;
|
GtkWidget* side_row = nullptr;
|
||||||
GtkWidget* side_label = nullptr;
|
GtkWidget* side_label = nullptr;
|
||||||
|
/// The two noise-rejection rows. `noise` is a percentage of the window's peak-to-peak, `hf` a corner in
|
||||||
|
/// Hz with 0 off; both are shown only where they do something (see `measure_dialog_sync`).
|
||||||
|
GtkWidget* noise = nullptr;
|
||||||
|
GtkWidget* noise_row = nullptr;
|
||||||
|
GtkWidget* hf = nullptr;
|
||||||
|
GtkWidget* hf_row = nullptr;
|
||||||
/// The names the signal dropdown was built from, in order. `apply` resolves an index
|
/// The names the signal dropdown was built from, in order. `apply` resolves an index
|
||||||
/// through *this*, not through the model's signal list: the dropdown lists the plotted
|
/// through *this*, not through the model's signal list: the dropdown lists the plotted
|
||||||
/// traces, so index `i` here is the i-th trace -- indexing the CONFIG with it selected
|
/// traces, so index `i` here is the i-th trace -- indexing the CONFIG with it selected
|
||||||
@@ -960,9 +978,10 @@ struct MathDialog {
|
|||||||
GtkWidget* offset = nullptr;
|
GtkWidget* offset = nullptr;
|
||||||
GtkWidget* fc = nullptr;
|
GtkWidget* fc = nullptr;
|
||||||
GtkWidget* q = nullptr;
|
GtkWidget* q = nullptr;
|
||||||
|
GtkWidget* med = nullptr; ///< MathNode::med_n
|
||||||
GtkWidget* lo = nullptr;
|
GtkWidget* lo = nullptr;
|
||||||
GtkWidget* hi = nullptr;
|
GtkWidget* hi = nullptr;
|
||||||
GtkWidget* rows[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
|
GtkWidget* rows[7] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
|
||||||
GtkWidget* inputs = nullptr;
|
GtkWidget* inputs = nullptr;
|
||||||
/// The validation line and the button it governs. Both are set from `math_dialog_sync()` and from
|
/// The validation line and the button it governs. Both are set from `math_dialog_sync()` and from
|
||||||
/// nowhere else, so what the line says and what the button allows cannot disagree.
|
/// nowhere else, so what the line says and what the button allows cannot disagree.
|
||||||
@@ -1402,6 +1421,10 @@ gboolean events_probe_step(gpointer user);
|
|||||||
gboolean events_group_probe_step(gpointer user);
|
gboolean events_group_probe_step(gpointer user);
|
||||||
void events_group_probe_next(App& a, int step, guint delay_ms);
|
void events_group_probe_next(App& a, int step, guint delay_ms);
|
||||||
gboolean drop_probe(gpointer user);
|
gboolean drop_probe(gpointer user);
|
||||||
|
gboolean noise_probe(gpointer user);
|
||||||
|
gboolean noise_probe_setup(gpointer user);
|
||||||
|
gboolean noise_probe_math(gpointer user);
|
||||||
|
gboolean noise_probe_denoise(gpointer user);
|
||||||
void collect_spins(GtkWidget* w, std::vector<double>* out);
|
void collect_spins(GtkWidget* w, std::vector<double>* out);
|
||||||
gboolean defaults_probe(gpointer user);
|
gboolean defaults_probe(gpointer user);
|
||||||
gboolean keys_probe(gpointer user);
|
gboolean keys_probe(gpointer user);
|
||||||
|
|||||||
+6
-1
@@ -835,6 +835,7 @@ void on_math_param_changed(GtkWidget*, gpointer user) {
|
|||||||
n.offset = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->offset));
|
n.offset = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->offset));
|
||||||
n.fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->fc));
|
n.fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->fc));
|
||||||
n.q = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->q));
|
n.q = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->q));
|
||||||
|
n.med_n = static_cast<int>(gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->med)));
|
||||||
n.lo = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->lo));
|
n.lo = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->lo));
|
||||||
n.hi = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hi));
|
n.hi = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hi));
|
||||||
if (d->source_drop != nullptr) {
|
if (d->source_drop != nullptr) {
|
||||||
@@ -930,14 +931,18 @@ void math_dialog_sync_form(MathDialog* d) {
|
|||||||
const bool source = op == MathOp::Source;
|
const bool source = op == MathOp::Source;
|
||||||
gtk_widget_set_visible(d->rows[0], source); // the source channel
|
gtk_widget_set_visible(d->rows[0], source); // the source channel
|
||||||
gtk_widget_set_visible(d->rows[1], gain);
|
gtk_widget_set_visible(d->rows[1], gain);
|
||||||
gtk_widget_set_visible(d->rows[2], filter);
|
// The corner row is shown for anything that reads `fc`, which is now the three biquads *and* the
|
||||||
|
// de-noise node; `q` stays a band-pass-only row.
|
||||||
|
gtk_widget_set_visible(d->rows[2], math_op_uses_fc(op));
|
||||||
gtk_widget_set_visible(d->rows[3], filter && op == MathOp::BandPass);
|
gtk_widget_set_visible(d->rows[3], filter && op == MathOp::BandPass);
|
||||||
gtk_widget_set_visible(d->rows[4], clamp);
|
gtk_widget_set_visible(d->rows[4], clamp);
|
||||||
gtk_widget_set_visible(d->rows[5], true); // the inputs, always: they say what is wired
|
gtk_widget_set_visible(d->rows[5], true); // the inputs, always: they say what is wired
|
||||||
|
gtk_widget_set_visible(d->rows[6], math_op_uses_median(op));
|
||||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), n.gain);
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), n.gain);
|
||||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), n.offset);
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), n.offset);
|
||||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), n.fc);
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), n.fc);
|
||||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), n.q);
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), n.q);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->med), static_cast<double>(n.med_n));
|
||||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), n.lo);
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), n.lo);
|
||||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), n.hi);
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), n.hi);
|
||||||
if (d->source_drop != nullptr) {
|
if (d->source_drop != nullptr) {
|
||||||
|
|||||||
+63
-15
@@ -94,6 +94,10 @@ void measure_dialog_sync(MeasureDialog* d) {
|
|||||||
const bool side = measure_uses_side(type);
|
const bool side = measure_uses_side(type);
|
||||||
gtk_widget_set_visible(d->threshold_row, threshold);
|
gtk_widget_set_visible(d->threshold_row, threshold);
|
||||||
gtk_widget_set_visible(d->side_row, side);
|
gtk_widget_set_visible(d->side_row, side);
|
||||||
|
// The band moves the edge decision, so it is shown for exactly the crossing types. The HF reject sits
|
||||||
|
// before both walks, so it is shown for all of them.
|
||||||
|
gtk_widget_set_visible(d->noise_row, measure_uses_noise(type));
|
||||||
|
gtk_widget_set_visible(d->hf_row, TRUE);
|
||||||
gtk_widget_set_visible(d->show, measure_can_show_on_plot(type));
|
gtk_widget_set_visible(d->show, measure_can_show_on_plot(type));
|
||||||
|
|
||||||
// The words follow the type: one row, several meanings.
|
// The words follow the type: one row, several meanings.
|
||||||
@@ -166,6 +170,8 @@ void on_measure_dialog_apply(GtkButton*, gpointer user) {
|
|||||||
m.options.threshold_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold));
|
m.options.threshold_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold));
|
||||||
m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above));
|
m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above));
|
||||||
m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show));
|
m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show));
|
||||||
|
m.options.noise_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->noise));
|
||||||
|
m.options.hf_fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hf));
|
||||||
if (d->edit >= 0 && static_cast<std::size_t>(d->edit) < app.measures.size()) {
|
if (d->edit >= 0 && static_cast<std::size_t>(d->edit) < app.measures.size()) {
|
||||||
app.measures[static_cast<std::size_t>(d->edit)] = std::move(m);
|
app.measures[static_cast<std::size_t>(d->edit)] = std::move(m);
|
||||||
} else {
|
} else {
|
||||||
@@ -261,6 +267,13 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
app.measure_stamp_valid = true;
|
app.measure_stamp_valid = true;
|
||||||
const gint64 pass_start_us = g_get_monotonic_time();
|
const gint64 pass_start_us = g_get_monotonic_time();
|
||||||
app.measure_samples.clear();
|
app.measure_samples.clear();
|
||||||
|
// Before any `MeasureWindowReader` exists: the reader holds `model->mutex()` for a live window, and asking
|
||||||
|
// the model for anything while one is alive would deadlock.
|
||||||
|
double slot_dt = 0.0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
slot_dt = app.model->partition().slot_dt();
|
||||||
|
}
|
||||||
|
|
||||||
app.measure_results.assign(app.measures.size(), MeasureResult{});
|
app.measure_results.assign(app.measures.size(), MeasureResult{});
|
||||||
app.measure_last_valid.resize(app.measures.size());
|
app.measure_last_valid.resize(app.measures.size());
|
||||||
@@ -284,18 +297,42 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
// trace costs the same two walks as a single one. The common case is exactly one level: every
|
// trace costs the same two walks as a single one. The common case is exactly one level: every
|
||||||
// crossing type reads the mid level and `Top`/`Bottom` default to it.
|
// crossing type reads the mid level and `Top`/`Bottom` default to it.
|
||||||
std::vector<int> chan(app.measures.size(), -1);
|
std::vector<int> chan(app.measures.size(), -1);
|
||||||
|
std::vector<int> want_window(app.measures.size(), -1);
|
||||||
std::vector<int> want_level(app.measures.size(), -1);
|
std::vector<int> want_level(app.measures.size(), -1);
|
||||||
for (std::size_t i = 0; i < app.measures.size(); ++i) {
|
for (std::size_t i = 0; i < app.measures.size(); ++i) {
|
||||||
const Measure& m = app.measures[i];
|
const Measure& m = app.measures[i];
|
||||||
const int channel = measure_channel(app, m.signal);
|
const int channel = measure_channel(app, m.signal);
|
||||||
chan[i] = channel;
|
chan[i] = channel;
|
||||||
if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list
|
if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list
|
||||||
App::MeasureWindow& w = app.measure_samples[channel];
|
const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0;
|
||||||
|
std::size_t wi = 0;
|
||||||
|
while (wi < app.measure_samples.size() &&
|
||||||
|
(app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc)) {
|
||||||
|
++wi;
|
||||||
|
}
|
||||||
|
if (wi == app.measure_samples.size()) {
|
||||||
|
App::MeasureWindow fresh;
|
||||||
|
fresh.channel = channel;
|
||||||
|
fresh.fc = fc;
|
||||||
|
measure_filter_set(fresh.scan_filter, fc, slot_dt);
|
||||||
|
measure_filter_set(fresh.acc_filter, fc, slot_dt);
|
||||||
|
app.measure_samples.push_back(fresh);
|
||||||
|
}
|
||||||
|
want_window[i] = static_cast<int>(wi);
|
||||||
|
App::MeasureWindow& w = app.measure_samples[wi];
|
||||||
double pct = 0.0;
|
double pct = 0.0;
|
||||||
if (!measure_level_pct(m.type, m.options, pct)) continue;
|
if (!measure_level_pct(m.type, m.options, pct)) continue;
|
||||||
|
// A type that does not read the band must not split the accumulator over it: its sums split at the bare
|
||||||
|
// level, so a `Top` is the same walk whatever the band is. Grouping those at the default is what keeps
|
||||||
|
// the common pane at exactly one level.
|
||||||
|
const double noise =
|
||||||
|
measure_uses_noise(m.type) ? m.options.noise_pct : MeasureOptions{}.noise_pct;
|
||||||
std::size_t k = 0;
|
std::size_t k = 0;
|
||||||
while (k < w.pct.size() && w.pct[k] != pct) ++k;
|
while (k < w.pct.size() && (w.pct[k] != pct || w.noise[k] != noise)) ++k;
|
||||||
if (k == w.pct.size()) w.pct.push_back(pct);
|
if (k == w.pct.size()) {
|
||||||
|
w.pct.push_back(pct);
|
||||||
|
w.noise.push_back(noise);
|
||||||
|
}
|
||||||
want_level[i] = static_cast<int>(k);
|
want_level[i] = static_cast<int>(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,10 +350,9 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
// degrades back to the O(window) cost and nothing else changes.
|
// degrades back to the O(window) cost and nothing else changes.
|
||||||
constexpr std::size_t kChunk = 8192;
|
constexpr std::size_t kChunk = 8192;
|
||||||
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
|
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
|
||||||
for (auto& entry : app.measure_samples) {
|
for (App::MeasureWindow& w : app.measure_samples) {
|
||||||
App::MeasureWindow& w = entry.second;
|
|
||||||
const gint64 fetch_start_us = g_get_monotonic_time();
|
const gint64 fetch_start_us = g_get_monotonic_time();
|
||||||
MeasureWindowReader reader(app, static_cast<std::size_t>(entry.first));
|
MeasureWindowReader reader(app, static_cast<std::size_t>(w.channel));
|
||||||
const std::size_t n = reader.size();
|
const std::size_t n = reader.size();
|
||||||
const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
|
const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
|
||||||
return reader.time_of(i);
|
return reader.time_of(i);
|
||||||
@@ -325,7 +361,12 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
u64 kb = 0;
|
u64 kb = 0;
|
||||||
u64 ke = 0;
|
u64 ke = 0;
|
||||||
buckets.clear();
|
buckets.clear();
|
||||||
if (reader.bucket_range(kb, ke)) {
|
// A filtered window cannot use the pyramid: a bucket aggregates *unfiltered* samples, so its extremes
|
||||||
|
// and its sum describe a different signal. Leaving `buckets` empty is the whole disable -- `bulk_from ==
|
||||||
|
// bulk_to == n` makes the raw walk cover the window, and `lo_search`/`hi_search` stay 0, which makes
|
||||||
|
// `pin()` below a no-op. One `if` turns off both fast paths, and nothing downstream needs to know.
|
||||||
|
const bool filtered = w.fc > 0.0;
|
||||||
|
if (!filtered && reader.bucket_range(kb, ke)) {
|
||||||
buckets.reserve(static_cast<std::size_t>(ke - kb));
|
buckets.reserve(static_cast<std::size_t>(ke - kb));
|
||||||
for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k));
|
for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k));
|
||||||
}
|
}
|
||||||
@@ -342,7 +383,7 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats); };
|
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); };
|
||||||
raw(0, bulk_from, scan);
|
raw(0, bulk_from, scan);
|
||||||
if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats);
|
if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats);
|
||||||
raw(bulk_to, n, scan);
|
raw(bulk_to, n, scan);
|
||||||
@@ -351,8 +392,11 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
if (w.stats.valid) {
|
if (w.stats.valid) {
|
||||||
const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) {
|
const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) {
|
||||||
if (search == 0) return;
|
if (search == 0) return;
|
||||||
|
// Only the bucket path leaves `search` non-zero, and a filtered window has no buckets, so this is
|
||||||
|
// reached pass-through only: a fresh filter here is the *same* filter.
|
||||||
|
MeasureFilter unfiltered;
|
||||||
MeasureStats one;
|
MeasureStats one;
|
||||||
measure_scan(MeasureChunk{reader.values(index, search), search, index}, one);
|
measure_scan(MeasureChunk{reader.values(index, search), search, index}, one, unfiltered);
|
||||||
if (one.valid) index = want_hi ? one.hi_i : one.lo_i;
|
if (one.valid) index = want_hi ? one.hi_i : one.lo_i;
|
||||||
raw_read += search;
|
raw_read += search;
|
||||||
search = 0;
|
search = 0;
|
||||||
@@ -372,10 +416,10 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
// The level *and* its hysteresis band, in the engine: how wide the band is is a property of
|
// The level *and* its hysteresis band, in the engine: how wide the band is is a property of
|
||||||
// how an edge is counted, not of the pane, and two expressions for it is how the walk and
|
// how an edge is counted, not of the pane, and two expressions for it is how the walk and
|
||||||
// the bucket fast path end up disagreeing about where a crossing was.
|
// the bucket fast path end up disagreeing about where a crossing was.
|
||||||
measure_set_level(w.level[k], w.stats, w.pct[k]);
|
measure_set_level(w.level[k], w.stats, w.pct[k], 0.01 * w.noise[k]);
|
||||||
}
|
}
|
||||||
const auto accumulate = [&](const MeasureChunk& c) {
|
const auto accumulate = [&](const MeasureChunk& c) {
|
||||||
measure_accumulate(c, time_of, w.level.data(), w.level.size());
|
measure_accumulate(c, time_of, w.level.data(), w.level.size(), w.acc_filter);
|
||||||
};
|
};
|
||||||
raw(0, bulk_from, accumulate);
|
raw(0, bulk_from, accumulate);
|
||||||
for (const MeasureBucket& b : buckets) {
|
for (const MeasureBucket& b : buckets) {
|
||||||
@@ -388,7 +432,8 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
values = reader.values(b.offset, b.span);
|
values = reader.values(b.offset, b.span);
|
||||||
raw_read += b.span;
|
raw_read += b.span;
|
||||||
}
|
}
|
||||||
measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1);
|
measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1,
|
||||||
|
w.acc_filter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
raw(bulk_to, n, accumulate);
|
raw(bulk_to, n, accumulate);
|
||||||
@@ -402,8 +447,11 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
for (std::size_t i = 0; i < app.measures.size(); ++i) {
|
for (std::size_t i = 0; i < app.measures.size(); ++i) {
|
||||||
const Measure& m = app.measures[i];
|
const Measure& m = app.measures[i];
|
||||||
GtkWidget* label = app.measure_labels[i];
|
GtkWidget* label = app.measure_labels[i];
|
||||||
const auto it = chan[i] < 0 ? app.measure_samples.end() : app.measure_samples.find(chan[i]);
|
const App::MeasureWindow* wp =
|
||||||
if (it == app.measure_samples.end() || !it->second.ok) {
|
want_window[i] >= 0 && static_cast<std::size_t>(want_window[i]) < app.measure_samples.size()
|
||||||
|
? &app.measure_samples[static_cast<std::size_t>(want_window[i])]
|
||||||
|
: nullptr;
|
||||||
|
if (wp == nullptr || !wp->ok) {
|
||||||
// Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing
|
// Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing
|
||||||
// to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks
|
// to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks
|
||||||
// was the other half of the rulers blinking off.
|
// was the other half of the rulers blinking off.
|
||||||
@@ -411,7 +459,7 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
|
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const App::MeasureWindow& w = it->second;
|
const App::MeasureWindow& w = *wp;
|
||||||
const MeasureLevel* level =
|
const MeasureLevel* level =
|
||||||
want_level[i] >= 0 && static_cast<std::size_t>(want_level[i]) < w.level.size()
|
want_level[i] >= 0 && static_cast<std::size_t>(want_level[i]) < w.level.size()
|
||||||
? &w.level[static_cast<std::size_t>(want_level[i])]
|
? &w.level[static_cast<std::size_t>(want_level[i])]
|
||||||
|
|||||||
+307
-3
@@ -281,6 +281,16 @@ gboolean eventzoom_probe(gpointer user) {
|
|||||||
gboolean math_probe(gpointer user) {
|
gboolean math_probe(gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
// The channel the graphs are built from is enabled *here*, for the reason the noise and measurement
|
||||||
|
// probes give: `enable_all_probe` finishes by switching one signal off, and with `sine` gone the graphs
|
||||||
|
// land on `square`, which turns the documented "2 math channels on 'sine' and 'noise'" into a different
|
||||||
|
// line for reasons that have nothing to do with the math.
|
||||||
|
{
|
||||||
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
||||||
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||||
|
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
std::string name;
|
std::string name;
|
||||||
std::string late_name;
|
std::string late_name;
|
||||||
{
|
{
|
||||||
@@ -316,11 +326,28 @@ gboolean math_probe(gpointer user) {
|
|||||||
lp.output = 1;
|
lp.output = 1;
|
||||||
channels.push_back(lp);
|
channels.push_back(lp);
|
||||||
|
|
||||||
|
// A third channel so the editor's new form row is built and synced on a baseline run. Assigned field
|
||||||
|
// by field: a partial brace-initialiser would add a fifth build warning.
|
||||||
|
MathChannel dn;
|
||||||
|
dn.name = "math denoise";
|
||||||
|
dn.enabled = true;
|
||||||
|
MathNode dn_src;
|
||||||
|
dn_src.op = MathOp::Source;
|
||||||
|
dn_src.source = name;
|
||||||
|
MathNode dn_op;
|
||||||
|
dn_op.op = MathOp::DeNoise;
|
||||||
|
dn_op.in_a = 0;
|
||||||
|
dn_op.med_n = 5;
|
||||||
|
dn_op.fc = 500.0;
|
||||||
|
dn.nodes = {dn_src, dn_op};
|
||||||
|
dn.output = 1;
|
||||||
|
channels.push_back(dn);
|
||||||
|
|
||||||
// Through the app's list, which is the pane's source of truth: a probe that wrote the model
|
// Through the app's list, which is the pane's source of truth: a probe that wrote the model
|
||||||
// directly left the two disagreeing, and the next pane edit then silently dropped its channels.
|
// directly left the two disagreeing, and the next pane edit then silently dropped its channels.
|
||||||
a->math = std::move(channels);
|
a->math = std::move(channels);
|
||||||
apply_math_channels(*a);
|
apply_math_channels(*a);
|
||||||
std::fprintf(stderr, "[math-probe] 2 math channels on '%s' and '%s'\n", name.c_str(),
|
std::fprintf(stderr, "[math-probe] 3 math channels on '%s' and '%s'\n", name.c_str(),
|
||||||
late_name.c_str());
|
late_name.c_str());
|
||||||
// And open the editor on the first one: as much a construction crash test as a state check,
|
// And open the editor on the first one: as much a construction crash test as a state check,
|
||||||
// since a modal window is presented and its canvas, form and gestures are all built here.
|
// since a modal window is presented and its canvas, form and gestures are all built here.
|
||||||
@@ -1000,6 +1027,16 @@ gboolean math_probe(gpointer user) {
|
|||||||
gboolean measure_probe(gpointer user) {
|
gboolean measure_probe(gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
// The signal this probe measures is enabled *here*, for the same reason the noise probe enables its own:
|
||||||
|
// a probe must not depend on another probe's side effects. `enable_all_probe` ends by switching a signal
|
||||||
|
// off (it reads what a disabled signal does to the plots), and with `sine` gone the readings below were
|
||||||
|
// taken on `square` -- where `top` is 1.0, not the 2/pi the documented baseline is.
|
||||||
|
{
|
||||||
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
||||||
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||||
|
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
std::string name;
|
std::string name;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
@@ -2943,6 +2980,13 @@ gboolean configs_probe(gpointer user) {
|
|||||||
std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(),
|
std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(),
|
||||||
active_configuration(a->source_host, a->source_port).c_str());
|
active_configuration(a->source_host, a->source_port).c_str());
|
||||||
|
|
||||||
|
// A non-default band and corner before the save, so the round trip below proves the two keys are
|
||||||
|
// *written*: left at their defaults, a key that never made it to the file would read back the same.
|
||||||
|
const bool has_measure = !a->measures.empty();
|
||||||
|
if (has_measure) {
|
||||||
|
a->measures.front().options.noise_pct = 7.5;
|
||||||
|
a->measures.front().options.hf_fc = 250.0;
|
||||||
|
}
|
||||||
const bool saved = save_configuration_as(*a, "probe", "written by the configs probe");
|
const bool saved = save_configuration_as(*a, "probe", "written by the configs probe");
|
||||||
std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s\n", saved ? 1 : 0,
|
std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s\n", saved ? 1 : 0,
|
||||||
joined().c_str(), active_configuration(a->source_host, a->source_port).c_str());
|
joined().c_str(), active_configuration(a->source_host, a->source_port).c_str());
|
||||||
@@ -2957,11 +3001,14 @@ gboolean configs_probe(gpointer user) {
|
|||||||
if (!sig.empty()) ++signals;
|
if (!sig.empty()) ++signals;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const double noise_back = st.measures.empty() ? -1.0 : st.measures.front().options.noise_pct;
|
||||||
|
const double hf_back = st.measures.empty() ? -1.0 : st.measures.front().options.hf_fc;
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
"[configs] round trip read=%d comment='%s' buffer_mb=%.6g relative=%d "
|
"[configs] round trip read=%d comment='%s' buffer_mb=%.6g relative=%d "
|
||||||
"capture=%u plots=%zu signals=%d measures=%zu math=%zu\n",
|
"capture=%u plots=%zu signals=%d measures=%zu math=%zu noise=%.6g hf=%.6g\n",
|
||||||
read ? 1 : 0, st.comment.c_str(), st.buffer_mb, st.relative_time ? 1 : 0,
|
read ? 1 : 0, st.comment.c_str(), st.buffer_mb, st.relative_time ? 1 : 0,
|
||||||
st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size());
|
st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size(),
|
||||||
|
noise_back, hf_back);
|
||||||
|
|
||||||
forget_configuration(a->source_host, a->source_port, "probe");
|
forget_configuration(a->source_host, a->source_port, "probe");
|
||||||
std::fprintf(stderr, "[configs] after forget names=%s\n", joined().c_str());
|
std::fprintf(stderr, "[configs] after forget names=%s\n", joined().c_str());
|
||||||
@@ -3422,6 +3469,263 @@ void events_group_probe_next(App& a, int step, guint delay_ms) {
|
|||||||
g_timeout_add(delay_ms, events_group_probe_step, &a);
|
g_timeout_add(delay_ms, events_group_probe_step, &a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct NoiseLeg {
|
||||||
|
std::size_t hits = 0;
|
||||||
|
double first = std::numeric_limits<double>::quiet_NaN();
|
||||||
|
int state = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One rising-edge search of the whole retained buffer at level 0.
|
||||||
|
///
|
||||||
|
/// Synchronous on purpose. Every leg must see the *same* buffer, and yielding to the main loop between legs
|
||||||
|
/// would let the producer append between them, so the counts would no longer be comparable. The bound is a
|
||||||
|
/// safety net: a chunk is `BufferSearch::kChunk` samples, so 20000 pumps cover 160 M of them, far more than
|
||||||
|
/// the ring holds.
|
||||||
|
NoiseLeg noise_leg(App* a, int channel, double dt, double band, double hf) {
|
||||||
|
EventCondition c;
|
||||||
|
c.type = TriggerType::Edge;
|
||||||
|
c.edge = TriggerEdge::Rising;
|
||||||
|
c.level = 0.0;
|
||||||
|
c.noise_band = band;
|
||||||
|
c.hf_fc = hf;
|
||||||
|
a->search.start(c, channel, dt);
|
||||||
|
for (int i = 0; i < 20000 && a->search.pump(*a->model); ++i) {
|
||||||
|
}
|
||||||
|
NoiseLeg r;
|
||||||
|
r.hits = a->search.hits().size();
|
||||||
|
if (r.hits > 0) r.first = a->search.hits().front();
|
||||||
|
r.state = static_cast<int>(a->search.state());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The difference of two first-hit stamps, folded into +/- half a period, so a pair whose first hits landed
|
||||||
|
/// on different cycles still reads as a delay.
|
||||||
|
double noise_fold(double d, double period) {
|
||||||
|
const double half = 0.5 * period;
|
||||||
|
d = std::fmod(d, period);
|
||||||
|
if (d > half) d -= period;
|
||||||
|
if (d < -half) d += period;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
int noise_channel(App* a, const char* name) {
|
||||||
|
const std::vector<ChannelInfo>& channels = a->model->channels();
|
||||||
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||||
|
if (channels[i].name == name) return static_cast<int>(i);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Stage one of three. `apply_math_channels()` is a structural rebuild that drops the buffer's
|
||||||
|
// history, and every reading in `noise_probe` is taken over retained history -- so the rebuild
|
||||||
|
// happens here, five seconds before the first leg runs, and the buffer refills in between. The
|
||||||
|
// de-noise channel also needs that time: a math channel starts producing when it is applied.
|
||||||
|
//
|
||||||
|
// The channel is installed **disabled**, and `noise_probe_math` enables it two and a half seconds
|
||||||
|
// before its own reading. An acquired channel is a share of the ring, so a fifth one shortens every
|
||||||
|
// channel's retained span -- and that moves every figure above it: the six search legs' counts scale
|
||||||
|
// with the retained buffer, and `pkpk bare` is a peak-to-peak *of that window*. Those readings are
|
||||||
|
// the older feature's proof, so the new channel stays out of the acquisition plan until they have
|
||||||
|
// all run, and only the de-noise comparison is taken with it in.
|
||||||
|
//
|
||||||
|
// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode` is
|
||||||
|
// what the two `-Wmissing-field-initializers` in this file already are, and the build baseline
|
||||||
|
// is exactly four warnings.
|
||||||
|
gboolean noise_probe_setup(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
MathChannel dn;
|
||||||
|
dn.name = "math denoise";
|
||||||
|
dn.enabled = false; // enabled by `noise_probe_math`, so it costs no ring while the legs run
|
||||||
|
MathNode src_node;
|
||||||
|
src_node.op = MathOp::Source;
|
||||||
|
src_node.source = "sine";
|
||||||
|
MathNode dn_node;
|
||||||
|
dn_node.op = MathOp::DeNoise;
|
||||||
|
dn_node.in_a = 0;
|
||||||
|
dn_node.med_n = 5;
|
||||||
|
// The median alone: a 2-sample impulse is shorter than half a 5-sample window, so it is rejected
|
||||||
|
// outright and the pole has nothing left to do. A corner here would attenuate the 5 Hz sine as well
|
||||||
|
// and make the peak-to-peak reading a function of two things.
|
||||||
|
dn_node.fc = 0.0;
|
||||||
|
dn.nodes = {src_node, dn_node};
|
||||||
|
dn.output = 1;
|
||||||
|
a->math.push_back(dn);
|
||||||
|
apply_math_channels(*a);
|
||||||
|
std::fprintf(stderr, "[noise-probe] setup channels=%zu\n", a->math.size());
|
||||||
|
std::fflush(stderr);
|
||||||
|
// 3300 + 4700 = 8000 ms, where Tasks 4 and 8 measured the legs.
|
||||||
|
g_timeout_add(5000, noise_probe, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean noise_probe(gpointer user) {
|
||||||
|
static int phase = 0;
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
if (phase == 0) {
|
||||||
|
// The signals this probe measures are enabled *here*. Not because it is tidier, but because it must not
|
||||||
|
// depend on another probe's side effects: `enable_all_probe` ends by switching one signal off again (it
|
||||||
|
// reads what a disabled signal does to the plots), and a run that arms both would take `sine` away.
|
||||||
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
||||||
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||||
|
if (signals[i].name == "sine" || signals[i].name == "square") {
|
||||||
|
a->model->set_signal_enabled(i, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phase = 1;
|
||||||
|
// The enable is a structural change: it replaces the store, so the ring has to fill again before there
|
||||||
|
// is anything to search.
|
||||||
|
g_timeout_add(2500, noise_probe, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
const int sine = noise_channel(a, "sine");
|
||||||
|
const int square = noise_channel(a, "square");
|
||||||
|
double dt = 0.0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
|
dt = a->model->partition().slot_dt();
|
||||||
|
}
|
||||||
|
if (sine < 0 || square < 0 || !(dt > 0.0)) {
|
||||||
|
// Name the channels that *are* there: an index alone says a probe found nothing, not what it saw.
|
||||||
|
std::string have;
|
||||||
|
for (const ChannelInfo& c : a->model->channels()) have += c.name + " ";
|
||||||
|
std::fprintf(stderr, "[noise-probe] no stream: sine=%d square=%d dt=%g channels=[%s]\n", sine, square,
|
||||||
|
dt, have.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A: the bare comparator on a spiked sine. Every impulse landing where the sine is within the impulse's own
|
||||||
|
// amplitude of zero fires a crossing.
|
||||||
|
const NoiseLeg a_leg = noise_leg(a, sine, dt, 0.0, 0.0);
|
||||||
|
// B: a 0.8 band. A +/-0.5 impulse cannot carry the signal across a 1.6-wide band, so only the sine's own
|
||||||
|
// cycles are left.
|
||||||
|
const NoiseLeg b_leg = noise_leg(a, sine, dt, 0.8, 0.0);
|
||||||
|
// C: no band, a 50 Hz reject. A 2-sample 0.5 impulse moves a pole with a = 1 - exp(-2*pi*50*dt) by about
|
||||||
|
// 5e-4, which cannot reach the level.
|
||||||
|
const NoiseLeg c_leg = noise_leg(a, sine, dt, 0.0, 50.0);
|
||||||
|
// D: both. The reject attenuates a 5 Hz sine by only 1/sqrt(1 + (5/50)^2), so its peaks still clear the band
|
||||||
|
// and the count is unchanged.
|
||||||
|
const NoiseLeg d_leg = noise_leg(a, sine, dt, 0.8, 50.0);
|
||||||
|
// E/F: the reject's group delay, on `square`, which carries no impulses. A one-pole turns a -1 -> +1 step
|
||||||
|
// into -1 + 2*(1 - exp(-t/tau)), which crosses zero at tau*ln2 -- exact, and the same on every edge.
|
||||||
|
const NoiseLeg e_leg = noise_leg(a, square, dt, 0.0, 0.0);
|
||||||
|
const NoiseLeg f_leg = noise_leg(a, square, dt, 0.0, 50.0);
|
||||||
|
|
||||||
|
const double period = 0.2; // the mock's default --freq is 5 Hz.
|
||||||
|
const double delay = noise_fold(f_leg.first - e_leg.first, period);
|
||||||
|
const double want = std::log(2.0) / (2.0 * 3.14159265358979323846 * 50.0);
|
||||||
|
|
||||||
|
std::fprintf(stderr, "[noise-probe] trigger band: bare=%zu/%d band=%zu/%d ratio=%.2f\n", a_leg.hits,
|
||||||
|
a_leg.state, b_leg.hits, b_leg.state,
|
||||||
|
b_leg.hits > 0 ? static_cast<double>(a_leg.hits) / static_cast<double>(b_leg.hits) : 0.0);
|
||||||
|
std::fprintf(stderr, "[noise-probe] trigger hf: hf=%zu/%d band+hf=%zu/%d band=%zu\n", c_leg.hits,
|
||||||
|
c_leg.state, d_leg.hits, d_leg.state, b_leg.hits);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[noise-probe] trigger delay: clean=%zu filtered=%zu delay=%.6f want=%.6f ratio=%.3f\n",
|
||||||
|
e_leg.hits, f_leg.hits, delay, want, want > 0.0 ? delay / want : 0.0);
|
||||||
|
std::fflush(stderr);
|
||||||
|
|
||||||
|
// Five measurements on the spiked sine, as *two* windows: three legs at `hf=0` and two at `hf=50`. The
|
||||||
|
// pane keys its walks by (channel, corner), so this is also what checks that a second corner does not
|
||||||
|
// reuse the first one's walk.
|
||||||
|
std::string name;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
|
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
|
||||||
|
name = a->model->channels().front().name; // `sine`; signal 0 is the timestamp
|
||||||
|
}
|
||||||
|
struct Leg {
|
||||||
|
const char* label;
|
||||||
|
MeasureType type;
|
||||||
|
double noise;
|
||||||
|
double hf;
|
||||||
|
};
|
||||||
|
// The impulses dominate the window's extremes, so `peak to peak` reads the *stimulus* (about 3)
|
||||||
|
// unfiltered and the *waveform* (about 2) filtered -- the one reading that says the filter reached pass
|
||||||
|
// one and not only pass two.
|
||||||
|
const Leg legs[] = {
|
||||||
|
{"pkpk bare", MeasureType::PeakToPeak, 10.0, 0.0},
|
||||||
|
{"pkpk hf", MeasureType::PeakToPeak, 10.0, 50.0},
|
||||||
|
{"period noband", MeasureType::Period, 0.0, 0.0},
|
||||||
|
// 25 % and not the 10 % default: the band is a share of the *window's* peak-to-peak, which with the
|
||||||
|
// impulses in it is about 3, so 10 % is a half-band of 0.3 -- less than the 0.5 an impulse moves the
|
||||||
|
// signal by, and the `MeasureOptions::noise_pct` comment says as much ("10 % holds to an interferer
|
||||||
|
// amplitude of 0.3"). The default is what `period noband` and `period hf` are about; this leg asks for
|
||||||
|
// a band that clears the stimulus, which is what makes the reading legible.
|
||||||
|
{"period band", MeasureType::Period, 25.0, 0.0},
|
||||||
|
{"period hf", MeasureType::Period, 0.0, 50.0},
|
||||||
|
};
|
||||||
|
const std::size_t base = a->measures.size();
|
||||||
|
for (const Leg& leg : legs) {
|
||||||
|
MeasureOptions options;
|
||||||
|
options.noise_pct = leg.noise;
|
||||||
|
options.hf_fc = leg.hf;
|
||||||
|
a->measures.push_back({name, leg.type, options});
|
||||||
|
}
|
||||||
|
rebuild_measures_pane(*a);
|
||||||
|
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
||||||
|
for (std::size_t i = 0; i < std::size(legs); ++i) {
|
||||||
|
const std::size_t at = base + i;
|
||||||
|
// A *value*, not a reference: the conditional's other arm is a temporary.
|
||||||
|
const MeasureResult r = at < a->measure_results.size() ? a->measure_results[at] : MeasureResult{};
|
||||||
|
std::fprintf(stderr, "[noise-probe] measure %-14s noise=%.1f hf=%.1f valid=%d value=%.6f\n",
|
||||||
|
legs[i].label, legs[i].noise, legs[i].hf, int(r.valid), r.primary);
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "[noise-probe] measure windows=%zu\n", a->measure_samples.size());
|
||||||
|
std::fflush(stderr);
|
||||||
|
a->search.clear();
|
||||||
|
update_search_panel(*a);
|
||||||
|
g_timeout_add(2500, noise_probe_math, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean noise_probe_math(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
{
|
||||||
|
// Enabled here for the reason `noise_probe_setup` gives: until this moment the channel must not
|
||||||
|
// be a share of the ring. Enabled *by name*, through the same switch the panel drives, so the
|
||||||
|
// model and the panel agree.
|
||||||
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
||||||
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||||
|
if (signals[i].name == "math denoise") a->model->set_signal_enabled(i, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A fill before the reading: the channel's first sample is the first one after this enable, and a
|
||||||
|
// peak-to-peak needs a window with a sine trough *and* a crest in it. Two and a half seconds is
|
||||||
|
// twelve cycles of the 5 Hz source.
|
||||||
|
g_timeout_add(2500, noise_probe_denoise, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean noise_probe_denoise(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
const std::size_t base = a->measures.size();
|
||||||
|
// Both unfiltered and at no band: the only difference between the two readings is the node.
|
||||||
|
MeasureOptions options;
|
||||||
|
a->measures.push_back({std::string("sine"), MeasureType::PeakToPeak, options});
|
||||||
|
a->measures.push_back({std::string("math denoise"), MeasureType::PeakToPeak, options});
|
||||||
|
rebuild_measures_pane(*a);
|
||||||
|
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
||||||
|
const MeasureResult raw =
|
||||||
|
base < a->measure_results.size() ? a->measure_results[base] : MeasureResult{};
|
||||||
|
const MeasureResult den =
|
||||||
|
base + 1 < a->measure_results.size() ? a->measure_results[base + 1] : MeasureResult{};
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[noise-probe] denoise raw=%.6f/%d denoised=%.6f/%d ratio=%.3f channels=%zu\n",
|
||||||
|
raw.primary, int(raw.valid), den.primary, int(den.valid),
|
||||||
|
raw.primary > 0.0 ? den.primary / raw.primary : 0.0, a->math.size());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
gboolean drop_probe(gpointer user) {
|
gboolean drop_probe(gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
|
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
|
||||||
|
|||||||
+18
-1
@@ -69,6 +69,8 @@ void publish_trigger_fields(App* app) {
|
|||||||
const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin));
|
const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin));
|
||||||
const double level2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level2_spin));
|
const double level2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level2_spin));
|
||||||
const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin));
|
const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin));
|
||||||
|
const double band = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_band_spin));
|
||||||
|
const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_hf_spin));
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(app->model->mutex());
|
std::lock_guard<std::mutex> lock(app->model->mutex());
|
||||||
TriggerConfig& tc = app->model->trigger();
|
TriggerConfig& tc = app->model->trigger();
|
||||||
@@ -85,6 +87,8 @@ void publish_trigger_fields(App* app) {
|
|||||||
tc.condition.width2_s = width2;
|
tc.condition.width2_s = width2;
|
||||||
tc.condition.level2 = level2;
|
tc.condition.level2 = level2;
|
||||||
tc.condition.timeout_s = timeout;
|
tc.condition.timeout_s = timeout;
|
||||||
|
tc.condition.noise_band = band;
|
||||||
|
tc.condition.hf_fc = hf;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +114,11 @@ void sync_trigger_rows(App& app) {
|
|||||||
show(app.trigger_width2_row, pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
|
show(app.trigger_width2_row, pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
|
||||||
show(app.trigger_level2_row, window);
|
show(app.trigger_level2_row, window);
|
||||||
show(app.trigger_timeout_row, type == TriggerType::Dropout);
|
show(app.trigger_timeout_row, type == TriggerType::Dropout);
|
||||||
|
// A band widens the comparator every type but Dropout compares with; a dropout is an absence and a
|
||||||
|
// band cannot describe one. The HF reject sits before every comparison, so it applies to all types.
|
||||||
|
// The rows come from the file and hold their spin, so the spin's parent *is* the row.
|
||||||
|
show(gtk_widget_get_parent(app.trigger_band_spin), type != TriggerType::Dropout);
|
||||||
|
show(gtk_widget_get_parent(app.trigger_hf_spin), true);
|
||||||
// A band of zero width reduces the condition to a float-equality test on a real-valued
|
// A band of zero width reduces the condition to a float-equality test on a real-valued
|
||||||
// signal, which is unusable: it fires on the exact value and nothing else, which in practice
|
// signal, which is unusable: it fires on the exact value and nothing else, which in practice
|
||||||
// is never. The panel is where that is refused: the second level is pushed at least one
|
// is never. The panel is where that is refused: the second level is pushed at least one
|
||||||
@@ -282,6 +291,13 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
|
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
|
||||||
app.model->config().trigger_holdoff_s);
|
app.model->config().trigger_holdoff_s);
|
||||||
row_for(ui, "holdoff_row", app.trigger_holdoff_spin);
|
row_for(ui, "holdoff_row", app.trigger_holdoff_spin);
|
||||||
|
// A band in signal units, on the same scale as the level spins.
|
||||||
|
app.trigger_band_spin = spin(0.0, 1.0e12, 0.01, 4, 0.0);
|
||||||
|
row_for(ui, "band_row", app.trigger_band_spin);
|
||||||
|
// 0 is off. The upper bound is generous; `configure()` clamps to just under the stream's Nyquist,
|
||||||
|
// which the panel cannot know.
|
||||||
|
app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0);
|
||||||
|
row_for(ui, "hf_row", app.trigger_hf_spin);
|
||||||
|
|
||||||
const char* modes[] = {"normal", "single", nullptr};
|
const char* modes[] = {"normal", "single", nullptr};
|
||||||
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
|
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
|
||||||
@@ -314,7 +330,8 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
|
|
||||||
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
|
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
|
||||||
app.trigger_width_spin, app.trigger_width2_spin,
|
app.trigger_width_spin, app.trigger_width2_spin,
|
||||||
app.trigger_level2_spin, app.trigger_timeout_spin}) {
|
app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin,
|
||||||
|
app.trigger_hf_spin}) {
|
||||||
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
|
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
|
||||||
}
|
}
|
||||||
g_signal_connect(app.trigger_edge_drop, "notify::selected",
|
g_signal_connect(app.trigger_edge_drop, "notify::selected",
|
||||||
|
|||||||
Reference in new issue
Block a user