diff --git a/src/ui_app.hpp b/src/ui_app.hpp index cb290c1..4d7bee4 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -274,13 +274,23 @@ struct App { /// 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 /// `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 { + int channel = -1; + double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered MeasureStats stats; - std::vector pct; ///< the level shares asked for, deduplicated + std::vector pct; ///< the level shares asked for, deduplicated + std::vector noise; ///< each entry's band, as a percentage; parallel to `pct` std::vector 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; }; - std::unordered_map measure_samples; + std::vector measure_samples; /// 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. std::vector measure_bucket_scratch; diff --git a/src/ui_measure.cpp b/src/ui_measure.cpp index b087e45..7c9009c 100644 --- a/src/ui_measure.cpp +++ b/src/ui_measure.cpp @@ -261,6 +261,13 @@ void update_measures(App& app, bool force, bool accumulate) { app.measure_stamp_valid = true; const gint64 pass_start_us = g_get_monotonic_time(); 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 lock(app.model->mutex()); + slot_dt = app.model->partition().slot_dt(); + } app.measure_results.assign(app.measures.size(), MeasureResult{}); app.measure_last_valid.resize(app.measures.size()); @@ -284,18 +291,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 // crossing type reads the mid level and `Top`/`Bottom` default to it. std::vector chan(app.measures.size(), -1); + std::vector want_window(app.measures.size(), -1); std::vector want_level(app.measures.size(), -1); for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; const int channel = measure_channel(app, m.signal); chan[i] = channel; 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(wi); + App::MeasureWindow& w = app.measure_samples[wi]; double pct = 0.0; 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; - while (k < w.pct.size() && w.pct[k] != pct) ++k; - if (k == w.pct.size()) w.pct.push_back(pct); + while (k < w.pct.size() && (w.pct[k] != pct || w.noise[k] != noise)) ++k; + if (k == w.pct.size()) { + w.pct.push_back(pct); + w.noise.push_back(noise); + } want_level[i] = static_cast(k); } @@ -313,10 +344,9 @@ void update_measures(App& app, bool force, bool accumulate) { // degrades back to the O(window) cost and nothing else changes. constexpr std::size_t kChunk = 8192; std::vector& buckets = app.measure_bucket_scratch; - for (auto& entry : app.measure_samples) { - App::MeasureWindow& w = entry.second; + for (App::MeasureWindow& w : app.measure_samples) { const gint64 fetch_start_us = g_get_monotonic_time(); - MeasureWindowReader reader(app, static_cast(entry.first)); + MeasureWindowReader reader(app, static_cast(w.channel)); const std::size_t n = reader.size(); const std::function time_of = [&reader](std::size_t i) { return reader.time_of(i); @@ -325,7 +355,12 @@ void update_measures(App& app, bool force, bool accumulate) { u64 kb = 0; u64 ke = 0; 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(ke - kb)); for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k)); } @@ -342,7 +377,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); if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats); raw(bulk_to, n, scan); @@ -351,8 +386,11 @@ void update_measures(App& app, bool force, bool accumulate) { if (w.stats.valid) { const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) { 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; - 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; raw_read += search; search = 0; @@ -372,10 +410,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 // 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. - 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) { - 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); for (const MeasureBucket& b : buckets) { @@ -388,7 +426,8 @@ void update_measures(App& app, bool force, bool accumulate) { values = reader.values(b.offset, 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); @@ -402,8 +441,11 @@ void update_measures(App& app, bool force, bool accumulate) { for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; GtkWidget* label = app.measure_labels[i]; - const auto it = chan[i] < 0 ? app.measure_samples.end() : app.measure_samples.find(chan[i]); - if (it == app.measure_samples.end() || !it->second.ok) { + const App::MeasureWindow* wp = + want_window[i] >= 0 && static_cast(want_window[i]) < app.measure_samples.size() + ? &app.measure_samples[static_cast(want_window[i])] + : nullptr; + if (wp == nullptr || !wp->ok) { // 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 // was the other half of the rulers blinking off. @@ -411,7 +453,7 @@ void update_measures(App& app, bool force, bool accumulate) { if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); continue; } - const App::MeasureWindow& w = it->second; + const App::MeasureWindow& w = *wp; const MeasureLevel* level = want_level[i] >= 0 && static_cast(want_level[i]) < w.level.size() ? &w.level[static_cast(want_level[i])] diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 118995d..76e0f30 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1000,6 +1000,16 @@ gboolean math_probe(gpointer user) { gboolean measure_probe(gpointer user) { App* a = static_cast(user); 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& 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::lock_guard lock(a->model->mutex());