Fine-W+: experimental thorough param tuning Proposed

Adaptive multi-mode sampling for 1D parameter search. Coexists with the existing Fine-W / Size / WH binary-search modes.
Date: 2026-04-27 Scope: libmodal + modal_viewer/{app,ui} Item: #10 (1D first) Plan: 5 commits

Goal

Replace the current 1D bisection — which often fails because it assumes a monotonic param → freq map — with an adaptive sampler that (a) records every (param, freq, mode-amplitude) triple it has ever evaluated for the current note, (b) chooses each next sample by intermediate-value bracketing or unexplored-region density, and (c) matches against any mode whose strike amplitude clears an audibility threshold rather than only the fundamental.

The existing Fine-W / Fine-Size / Fine-WH modes are kept unchanged. The new tuner is exposed as Fine-W+ alongside them.

Theoretical framing

The 1D tuning problem:

Find x ∈ [lo, hi] such that some mode k has freq_k(x) ≈ target and |amp_k(x)| is large enough to be heard, where (freq_k, amp_k) come from the eigensolve at parameter x.

The current code reduces this to "find x such that freq[0](x) = target" via bisection. Bisection converges only when freq[0](x) is monotonic in x, which fails for our shapes because:

  1. Mode reordering. As x grows, mode 1 may drop below mode 2; the identity of "the lowest mode" switches at a crossing. The lower-envelope freq[0] has a kink there.
  2. Differential scaling. Bending modes scale ~1/L², longitudinal modes scale ~1/L. Different modes dominate at different sizes.
  3. Localised modes. In shapes with sub-features (legs, strings, rings), some modes are governed by the sub-feature's geometry, not the overall scale parameter.

Each individual freq_k(x) IS continuous and locally monotonic within a "mode regime" — by the Intermediate Value Theorem, a root exists in any interval where freq_k brackets the target. The right approach is to sample the function densely enough to detect brackets across all relevant modes, then refine.

Design

Five components, in implementation order. Each commits independently.

1. modal_compute_strike_amplitudes (libmodal)

Expose the boundary-normal projection that modal_strike already does internally, so the viewer can query "what would the per-mode strike amplitudes be at this position?" without mutating audio state.

// Pure read; no mode_states_, vis_impulses_, or strike_buf side effects.
// Writes up to n_modes_max amplitudes (or fewer if fewer modes exist).
// Returns the actual count written.
int modal_compute_strike_amplitudes(
    ModalResult* res, float x, float y,
    float* out_amps, int n_modes_max);

Refactor the existing compute_strike_impulse (added in #9, commit a05bdbc) to share the projection code with this new function. ~30 LOC net.

2. Sample and the sample database

struct Sample {
    float param;                  // x value (width for Fine-W+)
    std::vector<float> freqs;     // top-K mode frequencies
    std::vector<float> amps;      // strike amplitude per mode
    int   best_mode;              // index of best-matching audible mode (or -1)
    float best_cents;             // signed cents from target for best_mode
    float best_amp_norm;          // |amps[best_mode]| / max_i|amps[i]|
    bool  quiet;                  // best_amp_norm < min_audible_ratio
};

Database lives on App as std::vector<Sample> param_db_, kept sorted by param. Cleared on session start (Fine-W+ click) and on Reset.

K defaults to 24 modes per sample. Eigensolve already computes them; storing freq/amp arrays adds ~200 bytes per sample.

Selecting best_mode: the closest-to-target mode whose |amps[i]| / max|amps| ≥ min_audible_ratio. If none qualifies, fall back to closest-overall and set quiet = true. This keeps the algorithm prefering audible matches but doesn't crash when no audible mode exists.

3. The adaptive sampler

Per-session state on App:

FieldMeaningDefault
fine_budget_Max samples per session64
fine_min_audible_Mode amplitude threshold (fraction of loudest)0.1
param_db_Sorted (param, ..) samples{}
init_seeded_True after the initial 8-sample sweepfalse

Algorithm (per Phase-4 invocation):

1. Insert just-completed Sample into param_db_ (sorted).
2. If !init_seeded_:
       Pick next of 8 evenly-spaced params in [fine_lo_, fine_hi_].
       If all 8 done: init_seeded_ = true. Else hand to Phase 2 and stop.
3. best_sample = sample with min |best_cents| where !quiet.
4. Termination checks:
     a. CONVERGED:           best_cents < threshold AND !quiet → apply.
     b. BUDGET_EXHAUSTED:    param_db_.size() ≥ fine_budget_ → apply suboptimal.
     c. DENSITY_EXHAUSTED:   min adjacent gap < (hi-lo)/10000
                             AND best_cents > 2 × threshold → apply suboptimal,
                             status: "1D search exhausted — try Fine-WH+ later."
5. Score every region (consecutive sample pair):
     BRACKET (priority 0): sign change in best_cents → must contain a root.
                           score = 1 / (|ci| + |cj| + 1e-3),  pick = midpoint.
     NEAR_BEST (1):        adjacent to current best.
                           score = 1 / (|best_cents| · gap),  pick = best ± 0.3·gap.
     GAP (2):               otherwise.  score = gap / total_range,  pick = midpoint.
6. Choose highest-scoring region (tie-break by priority).
7. Hand pick.param to Phase 2 (build_with_param). Loop.

"Running in circles" detection is structural: every iteration produces a new sample at a previously-unsampled param. The minimum-gap density check catches the case where the algorithm has refined as much as 1D can ever express — that's the signal to suggest Fine-WH+ later.

4. State-machine integration

fine_mode_ gains a new value 4 (Fine-W+). Existing values 1/2/3 keep their behaviour.

The Auto-mode glue from #6C already takes a mode number in start_fine_tune_for_target(int mode); passing 4 re-arms Fine-W+ for the next note transparently.

5. UI changes

Toolbar Row 3 gains one button + two sliders:

[Fine-W][Fine-Size][Fine-WH][Fine-W+][Auto] | [Threshold|3c] [Budget|64] [MinAud|10%] | …

The new [Fine-W+] button starts mode 4. The two new sliders share Row 3's adaptive-wrap behaviour from #1.

Chart augmentation: when fine_mode == 4, the existing "binary search segment" view at the top of the Tuning Chart is replaced by a true histogram:

The "Frequency vs attempt" plot stays unchanged below the histogram — gives time-ordered context. So the chart shows both views in Fine-W+ mode (per your request).

New configuration

VariableTypeDefaultRangeUI
fine_budget_int6416..4096Drag slider in Row 3
fine_min_audible_float0.10.01..0.5Drag slider in Row 3
tune_threshold_cents_float5.0unchanged(existing)

Budget = 64 means up to 64 evaluations per note × ~10 ms per eval ≈ 0.6 s wall time on the current machine. Budget = 4096 ≈ 40 s — useful for difficult shapes when run unattended (Auto mode).

Failure modes & edge cases

Out of scope

Acceptance criteria

  1. With Fine-W+ selected and an arbitrary 1D-tunable shape struck off-centre, the algorithm reaches |cents| < threshold for the audible best mode in ≤ 64 evaluations on shapes where any of Fine-W/Fine-WH would also converge.
  2. On shapes where bisection fails (e.g. a shape with a known mode crossing in [lo, hi]), Fine-W+ finds an audible match where bisection cannot. Verified manually on at least one such shape.
  3. Density-exhaustion path produces the "try Fine-WH+ for this note" status message and accepts suboptimal=true.
  4. Budget-exhaustion accepts suboptimal=true. Note shows orange.
  5. Auto mode (#6C) drives Fine-W+ end-to-end across all 8 notes when Fine-W+ is the chosen mode.
  6. The histogram chart renders correctly with at least 1 sample, with proper colour coding for in-threshold / close / far / quiet.
  7. Existing modes (Fine-W, Fine-Size, Fine-WH) remain byte-identical in behaviour.

Implementation plan

  1. libmodal — modal_compute_strike_amplitudes (refactor + new function). ~30 LOC.
  2. Sample DB — Sample struct, param_db_, sample-insertion helper, best-mode selection. ~80 LOC.
  3. Adaptive sampler — pick_next_param_adaptive(), region scoring, termination checks. ~150 LOC.
  4. State machine integration — fine_mode_ = 4 dispatch in Phase 2 and Phase 4; Auto-mode integration. ~50 LOC.
  5. UI — [Fine-W+] button, Budget + MinAudible sliders, histogram chart view (replaces "binary search segment" when fine_mode == 4). ~80 LOC.

Total ~390 LOC across libmodal/src/modal_api.cpp, modal_viewer/src/app/app.{cpp,h}, modal_viewer/src/ui/ui_manager.{cpp,h}. No new files.