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.
The 1D tuning problem:
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:
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.~1/L², longitudinal modes scale ~1/L. Different modes dominate at different sizes.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.
Five components, in implementation order. Each commits independently.
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.
Sample and the sample databasestruct 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.
Per-session state on App:
| Field | Meaning | Default |
|---|---|---|
fine_budget_ | Max samples per session | 64 |
fine_min_audible_ | Mode amplitude threshold (fraction of loudest) | 0.1 |
param_db_ | Sorted (param, ..) samples | {} |
init_seeded_ | True after the initial 8-sample sweep | false |
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.
fine_mode_ gains a new value 4 (Fine-W+). Existing values 1/2/3 keep their behaviour.
pick_next_sample_for_adaptive(). For modes 1/2/3, unchanged.param_db_, run the adaptive sampler step.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.
Toolbar Row 3 gains one button + two sliders:
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:
(param, freqs[best_mode]), coloured by |best_cents|: green ≤ threshold, yellow ≤ 4 × threshold, red beyond.!quiet), hollow circle for quiet.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).
| Variable | Type | Default | Range | UI |
|---|---|---|---|---|
fine_budget_ | int | 64 | 16..4096 | Drag slider in Row 3 |
fine_min_audible_ | float | 0.1 | 0.01..0.5 | Drag slider in Row 3 |
tune_threshold_cents_ | float | 5.0 | unchanged | (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).
[lo, hi] (currently hardcoded at 0.3 × initial → 3 × initial; could be exposed later).freqs[0] discontinuity. The multi-mode match handles this: we don't track mode identity, only "any matching mode."[lo, hi]. User controls it via re-running with manual width adjustment.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.[lo, hi]), Fine-W+ finds an audible match where bisection cannot. Verified manually on at least one such shape.suboptimal=true.suboptimal=true. Note shows orange.modal_compute_strike_amplitudes (refactor + new function). ~30 LOC.Sample struct, param_db_, sample-insertion helper, best-mode selection. ~80 LOC.pick_next_param_adaptive(), region scoring, termination checks. ~150 LOC.fine_mode_ = 4 dispatch in Phase 2 and Phase 4; Auto-mode integration. ~50 LOC.[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.