test: the noise probe reads the de-noise node, and the math probe builds one

The probe gains a staged chain rather than one phase, and the schedule moves with it:
`UDPSCOPE_PROBE_NOISE_MS` now times the *setup* stage, which installs the de-noise graph through
`apply_math_channels()` -- a structural rebuild that replaces the buffer and drops its history --
five seconds before anything scans it. The legs and the five measurements then run at about
10.5 s, and the reading that says the median rejected every impulse at 15.5 s, so the documented
run needs a 20 s mock and an 18 s exit.

The de-noise channel is installed **disabled** and enabled only at 13 s, for its own reading. An
acquired channel is a share of the ring, so a fifth one shortens every channel's retained span --
and the six `BufferSearch` leg counts and `pkpk bare` are all peaks and counts *of that window*.
Installing it enabled (as the plan had it) moved `bare` from 49 to 21, `ratio` from 7.00 to 3.00
and `pkpk bare` from 2.988 to 2.488, i.e. it would have rewritten the older feature's documented
proof to record a regression that is not one. With it enabled only at the end, every reading from
Tasks 4 and 8 is bit-for-bit what the doc records, and the new line reads
`denoise raw=2.987696/1 denoised=2.000000/1 ratio=0.669 channels=1` -- `denoised` exactly the
clean sine's 2.0, which is the claim the node makes.

Two other findings, both folded in: a derived channel whose source is switched off through the
panel's checkbox follows it off and does *not* come back when the source returns (the enable-all
probe's last act switches `sine` off at ~3 s), and the probes reload leftover math channels from
`$XDG_CONFIG_HOME/udpscope`, which is why a `setup channels=` count above 1 means an earlier run's
session, not a defect.

`math_probe` gains a third channel with a `DeNoise` node so the editor's median row is built and
synced on a baseline run: `3 math channels on 'sine' and 'noise'`, every other line as before,
`math cost: 103.3 ns/slot` (one reading of the same path).

verification.md: the recipe, and why the two stages may not be collapsed -- a probe that rebuilt
the buffer under its own legs reads `bare=0` and looks like a broken comparator. Also that the
noise and math probes may not share a run: both write `app.math`.
This commit is contained in:
Martino Ferrari committed 2026-10-13 16:01:44 +02:00
1 parent 5c2c221777
commit c38630643d
6 files changed
+181 -21

No files matched your search

+6 -4
View File
@@ -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.
+43 -8
View File
@@ -1284,19 +1284,41 @@ while the edit runs) and never falls to 0, which is what a rebuild would do. Eve
## `[noise-probe]` ## `[noise-probe]`
``` ```
[noise-probe] setup channels=1
[noise-probe] trigger band: bare=49/2 band=7/2 ratio=7.00 [noise-probe] trigger band: bare=49/2 band=7/2 ratio=7.00
[noise-probe] trigger hf: hf=7/2 band+hf=7/2 band=7 [noise-probe] trigger hf: hf=7/2 band+hf=6/2 band=7
[noise-probe] trigger delay: clean=4 filtered=4 delay=0.002206 want=0.002206 ratio=1.000 [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 `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 arms in two phases: the first enables the --spike-width 2` -- where every other probe uses the plain mock. It runs as **four stages**, and the chain is the point:
signals it measures *itself*, 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 waits 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.
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=3000` the legs therefore land at about 10.5 s and the last line at 15.5 s,
which is why the documented run needs `UDPSCOPE_EXIT_AFTER_MS=18000`.
- **`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 - **`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 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: at or above 5**, and `band` about the number of sine cycles retained. Neither count is a *fixed* number:
@@ -1324,6 +1346,7 @@ The same probe then puts five measurements on the same spiked `sine` as **two wi
[noise-probe] measure period band noise=25.0 hf=0.0 valid=1 value=0.200000 [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 period hf noise=0.0 hf=50.0 valid=1 value=0.200000
[noise-probe] measure windows=2 [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 - **`pkpk bare`** -- about **3.0**: the sine's 2 plus an impulse either way. The impulses dominate the
@@ -1341,6 +1364,18 @@ The same probe then puts five measurements on the same spiked `sine` as **two wi
`MeasureOptions::noise_pct`'s own rationale says (10 % holds to a 0.3 interferer). `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 - **`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`. 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 - **`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 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. and two of them read a signal they did not ask for.
+22 -7
View File
@@ -532,21 +532,36 @@ same run).
```bash ```bash
cd /home/martino/Projects/tools/udpscope cd /home/martino/Projects/tools/udpscope
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 16 \ 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 & --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/noise-mock.log 2>&1 &
sleep 1.5 sleep 1.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_ENABLE_ALL_MS=1000 \ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
UDPSCOPE_PROBE_NOISE_MS=6000 \ UDPSCOPE_PROBE_NOISE_MS=3000 \
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL' ./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
``` ```
The probe arms at 6 s rather than the 8 s a single-phase probe would use: its first phase enables the signals `UDPSCOPE_PROBE_NOISE_MS` is **not the time its readings are taken**. It times the probe's first stage, which
it measures and waits 2.5 s for the ring to refill after that structural change. `--spike-every`/`--spike-amp`/ 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: `3000` puts the graph in at
3 s, `noise_probe` at 8 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 10.5 s, the de-noise channel enabled at
13 s, and the de-noise reading at 15.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 10.5 s and then scanned it
immediately would read `bare=0` on every search leg, which looks exactly like a broken comparator.
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 `--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). 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`. The three lines and what each must read are in `probe-readings.md`.
`UDPSCOPE_PROBE_NOISE_MS` and `UDPSCOPE_PROBE_MEASURE_MS` must **not** be scheduled in the same run: both push `UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS` and `UDPSCOPE_PROBE_MATH_MS` must **not** be scheduled
onto `app.measures`, and the later one would read the earlier one's rows as its own. The noise probe carries 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. its own measurement legs (see `probe-readings.md`), so nothing is lost by keeping them apart.
+4 -1
View File
@@ -5423,7 +5423,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
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")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), noise_probe, &app); 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)));
+3
View File
@@ -1422,6 +1422,9 @@ 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(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);
+103 -1
View File
@@ -326,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.
@@ -3503,6 +3520,49 @@ int noise_channel(App* a, const char* name) {
} // namespace } // 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) { gboolean noise_probe(gpointer user) {
static int phase = 0; static int phase = 0;
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
@@ -3621,6 +3681,48 @@ gboolean noise_probe(gpointer user) {
std::fflush(stderr); std::fflush(stderr);
a->search.clear(); a->search.clear();
update_search_panel(*a); 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; return G_SOURCE_REMOVE;
} }