A feedBack plugin that keeps a song's difficulty matched to how well you're actually playing it, and shows upcoming sections as a compact, accessible tier rail (lit segments for the current tier, an outlined segment for the full-detail tier).
Difficulty Ladder supports the v1 player-context model used for concurrent
play. Each player has independent profile, song, arrangement, instrument/role,
skill, current difficulty, best mastery, and phrase-attempt state. The model is
designed for at least four Split Screen players and normalizes karaoke players
to role: "karaoke" / instrument: "voice"; vocal state never shares a
fretted-instrument record.
Saved state is separated as profile → player → song → arrangement → instrument → role → skill. player_id remains part of persistence identity even when two
local players select the same profile. overall is the default skill and a missing skill may fall back to it;
skill-specific values never overwrite overall. Profile-aware Hosts are gated
until identity is ready, so a pending profile cannot accidentally read or write
another player's progress. See PLAYER_CONTEXT.md for
the event, capability, note_detect, karaoke, Split Screen, and Section Map
contract.
Generate missing difficulty ladders
- Many charts (GP imports, plain single-level sloppaks) have no phrase-level
Easy/Medium/Hard data at all —
highway.hasPhraseData()isfalseand the mastery slider has nothing to filter. The "⚙️ Generate Difficulties" button (shown automatically whenever the current song lacks phrase data) analyzes every supported arrangement in the song independently (see instrument coverage below). It uses the matching fretted or keys heuristic for that arrangement, writes fresh multi-tier phrase ladders directly into the sloppak on disk (routes.py's/generateroute), and explicitly skips anything it doesn't support (drums, or another instrument type it doesn't recognize) rather than guessing — after which the button reconnects the highway so the new data streams in immediately. - A
/generate-libraryroute does the same as a best-effort sweep over the sloppaks in the DLC folder, up to amax_songscap (default 500, maximum 2000, via the request body) — it stops there rather than sweeping every song in an arbitrarily large library in one call. - Never touches an arrangement that already has phrase data unless
forceis set. Every ladder this plugin writes is stamped withx_difficulty_ladder, including the marker schema version, the Difficulty Ladder plugin version, level count, and generation time. Existing markers from before plugin-version tracking still identify the generator, but do not identify its release. - Each library card's menu offers Regenerate difficulty ladder
(
difficulty_ladder.regenerate,placement: 'menu', destructive). Before any write, it previews the song's existing ladders and confirms the overwrite. The prompt identifies Difficulty Ladder-generated ladders and their recorded plugin version. A missing or invalid marker is shown as unknown—possibly handmade or generated before provenance tracking—and requires explicit confirmation (overwrite_authored: true) before/generatecan replace it. Drums and other unsupported arrangements are skipped, and the highway reconnects only if the regenerated song is currently open in the player. - Both routes report
generated/unsupported/skipped/failedcounts in their response, so a caller can tell "nothing to do" (already had phrases, too little content) apart from "this generator doesn't support that instrument" apart from an outright failure. - One difficulty scale per song. Every phrase is laid out on the same
levels-tier scale (default 4), so a slider position means the same difficulty in the verse as in the solo. A note group enters a tier when it is easy for this song and on a fixed score scale, or when it's among its own phrase's easiest (so a hard phrase always keeps a playable skeleton). An easy phrase is therefore complete a tier or two in and stops changing, while a hard phrase differs at every tier. Tiers where a phrase didn't change are dropped from the file, and the remaining levels keep their tier numbers (e.g.difficulty0, 1, 3 withmax_difficulty3). feedBack core maps the slider through those numbers; an older core still plays the ladder, just scaled per phrase. - Ladder depth and mechanical difficulty are reported separately. Each
generated phrase carries
max_difficulty(how many tiers the ladder has — purely about how much this phrase's content gets thinned) alongsidedifficulty_cost(the mean of the internal, purely-mechanicalcostscore — fretting/technique/density/sustain/hand-shift, see_score_groups— across the phrase's full, untiered content). The two can diverge: a short phrase built from one hard chord and a long, easy phrase can both collapse tomax_difficulty: 0(nothing to thin) while having very differentdifficulty_cost.difficulty_costis unclamped, like the internalcostfield it averages — a phrase stacking several hand-shift/posture penalties can read above1.0. It's additive: an older reader that doesn't know the key simply ignores it. Weights feeding intocostremain heuristic (see the fretting-movement note below), so treatdifficulty_costas a relative ranking signal within one song, not a calibrated absolute score. - Simplifications keep the pitch the note is struck at. A pre-bend or release (struck already bent) is simplified to a fretted note at the bent pitch rather than an unbent one; a natural harmonic is only turned into a fretted note where that sounds the same pitch (frets 12, 19, 24). Chords reduce toward their bass note (the lowest string; string 0 is the lowest), which in standard open and barre shapes is usually the root.
- Fretting movement is time-aware. The generator applies a bounded
log2(distance / width + 1)shift cost, discounted by the time between onsets, and adds a modest posture cost for wide shapes low on the neck. The two-fret target width, time scale and weights are heuristics, not measured player thresholds. Bar/phrase join priority is implemented in the path-refinement helper but that helper remains disabled in normal generation until arrangement-wide refinement can preserve the shared tier scale. - Technique difficulty counts coordination demand, not only the hardest
single technique.
_tech_score's max-over-notes term still picks out the single hardest technique in a group, but two things it can't see are scored on top of it: a group using more than one distinct technique at once (a chord mixing a bend and a palm mute, rather than a single technique repeated) and a group whose technique differs from the last technique-bearing group before it — not necessarily the physically adjacent group, so switching from palm-muting into a slide still counts as a switch even with a plain-picked note in between (switching costs more than repeating the same technique). Both bonuses are 0 for the common case — a group using at most one technique, unchanged from the last technique-bearing group — so single-technique passages score exactly as before. The coordination bonus is deliberately not re-clamped against_tech_score's own per-note 0–1 range:_tech_scorealready saturates at 1.0 the moment a single note stacks enough techniques (e.g. a tapped, round-trip bend), so re-clamping the combined technique score would silently swallow the coordination bonus in exactly the peak-demand passages it exists to score. The per-extra-technique and per-switch weights themselves are heuristics, not measured player thresholds, and stay modest relative to_tech_score's range so coordination compounds an existing demand rather than dominating it. - Beat strength is graded, not on/off, and syncopation is metrically
aware (#103/B2). A downbeat outranks the mid-bar strong beat (beat 3 of
a 4-beat bar), which outranks other beats, then eighth- and
sixteenth-note subdivisions, then off the grid entirely — derived purely
from the arrangement's own
beats[]spacing andmeasureflag, no new pack data. This ranking feeds the retentionvalueterm (a downbeat is discounted more than a weak subdivision, rather than either getting the same flat discount an on-beat note used to), the tie-break when two groups land at the same score, and bridge-note selection. Syncopation is a Longuet-Higgins & Lee (1984) style measure: a note on a weak position is more syncopated when a stronger position before the next onset goes silent, rather than simply measuring distance to the nearest beat. Without a usable downbeat grid (nomeasuredata, or no trustworthy tempo), both fall back to the pre-#103 on/off behavior exactly — this is additive on top of real chart data, not a requirement for it. - Phrase starts and endings get retention value (#103/B3). The first and last note group of each authored phrase (from the caller's section timeline, or the arrangement's own sections) gets a modest push toward being kept at low tiers, whenever the rest of the tier's content allows it — listeners split music into phrases and remember their boundaries, though that keeping boundary notes specifically aids learning is inferred, not tested (moderate evidence). Phrases generated from 8-bar/30s fallback windows (no authored section data) don't get this at their internal window edges, since those aren't real musical phrases — except the very first window's start and the very last window's end, which are always genuine boundaries (the song's own beginning and end) regardless of how the windows in between were generated: both generated-window builders clamp their final window's end to the song's actual duration, so unlike an internal edge, that one really is the song's end.
- A single-note line's turning points get retention value (#103/B5).
Beginners remember a melody's rising-and-falling shape before its exact
intervals (Dowling, 1978) — strong as a perception finding, though that
keeping the shape specifically aids learning is inferred, not tested
(moderate evidence). Each note that is a strict local high or low among
the arrangement's single-note groups, no further than
tempo.fret_jump_window_secondsfrom its nearest single-note neighbor on either side, gets the same modest retention push a downbeat or phrase boundary gets, so a thinned tier still traces the melody's contour instead of collapsing to whichever notes happened to score hardest. Chord and multi-note cluster groups never participate — chord-heavy passages are unaffected by construction, not by a special case. Pitch direction is approximated from string/fret using standard tuning intervals, rather than an exact MIDI pitch, since only the rise/fall direction between neighboring notes matters for finding a turning point, not its precise size. Only the 5-string row is instrument- dependent, matching feedBack core's ownbase_open_string_midiscontract: a 5-string bass is all perfect fourths, while a 5-string non-bass borrows the 6-string guitar's low strings instead (one major third higher up) — a name/type sniff for "bass" picks the right row, mirroringlib/song.py'sarrangement_is_bass()(a case-insensitive "bass" substring in the name, or an exacttype == "bass"). The sniff runs against the EFFECTIVE name/type — a manifest entry's ownname/type, when it declares one, takes precedence over the embedded arrangement JSON's, same astuningbelow — so a manifest entry authored as a bass part still gets the bass row even when the embedded arrangement's own name/type doesn't say so. The offset added on top is the EFFECTIVE tuning — a manifest entry's owntuning, when the pack's manifest declares one (and is actually a list; a malformed override is ignored rather than raising), takes precedence over the embedded arrangement JSON's, mirroringlib/sloppak.py'sload_song(); this is resolved for scoring only, on a copy, and is never written back into the arrangement file. Two single-note groups that shouldn't be compared at all — either side of an intervening chord section, or the end of one authored phrase and the start of an unrelated one — are excluded from each other's neighbor comparison when they're more thantempo.fret_jump_window_secondsapart (the same tempo-relative "long enough that this isn't one continuous passage" threshold the fret-jump bonus already uses). This is a time-gap heuristic, not true phrase awareness: two genuinely separate phrases close enough in time to fall inside that window can still be compared as if they were one continuous line. A real fix needs the same phrase/section boundaries the generator's windowing already has threaded into this scan, which is a larger change than this item's effort-S scope covers. - Key and chord awareness (#103/B7). Each phrase gets its own key
estimate — the classic Krumhansl-Schmuckler algorithm: a duration-weighted
pitch-class histogram (reusing the same tuning/instrument-aware pitch
approximation as the B5 melody-turning-point signal on the fretted path;
reading the note's real MIDI pitch directly on the keys path, which needs
none of the fretted version's pitch-approximation) correlated against
all 24 rotations of the Krumhansl & Kessler (1982) major/minor key
profiles, keeping the best-fitting rotation. Notes are then ranked by
tonal stability — tonic > a chord/triad tone > another scale tone >
chromatic — with chord tones ranked above passing tones of the same
category when a chord is sounding (looked up from the arrangement's own
chord track for non-chord groups — a single note or run that lands
under a sustained chord picks up its notes' pitch classes, not just a
chord group's own self-evidently-chord-tone constituents), and the
group containing the most stable note gets a modest retention push.
That push is deliberately weighted below beat/metrical strength's
(#103/B2) and the melody-shape bonus's (#103/B5) weight — a heuristic
key estimate is a weaker signal than measured beat position, so it
shouldn't be able to outrank it — and is applied per section BEFORE the
shared tier scale is frozen (the same point B2's beat-value term and
B5's melody bonus already apply at), so it participates in tier-cutoff
construction instead of re-labeling an already-frozen scale and
silently collapsing a tier on ordinary tonal material. A section whose
best-fit correlation falls below a fixed threshold skips the weighting
entirely rather than confidently ranking notes against a key estimate
the data doesn't support — despite the "poor tonal fit" framing, that
threshold mainly rejects near-uniform/atonal pitch-class content
(whole-tone, fully chromatic); ordinary diatonic, modal, and blues
material all correlate well above it and get the weighting like any
other tonal section. Separately, chord and arpeggio
reduction (the bottom-tier "pick one note to represent this chord" step)
now try a real harmonic root — parsed from the matched authored
ChordTemplate'sname(e.g. "Am7" → A, "G/B" → G; only the root letter before any slash is used, since a slash chord's bass isn't its root) — before falling back to the pre-existing lowest-string-index heuristic when the name doesn't parse or no template matched. - Keys/piano generator parity (#103/B8).
_score_groups_keysnow applies the same graded beat-strength retention term (#103/B2's_beat_value), melody-turning-point retention (#103/B5), and per-section key-stability discount (#103/B7, #179) the fretted path already had — the keys path needs none of the fretted version's tuning/string pitch-approximation, since a keys note already carries a real MIDI pitch (_note_midi_keys). The terms use keys-SPECIFIC coefficients (_KEYS_BEAT_VALUE_COEF/_KEYS_MELODY_TURNING_BONUS, 0.025 each,_KEYS_KEY_STABILITY_RETENTION_BONUS, 0.018), not the fretted path's 0.12/0.12/0.08 — keys'costmodel has a much narrower dynamic range for melodic content (measured spread ~0.10 across an entire passage vs. the fretted path's typical 0.3-0.6), so reusing the fretted weight verbatim would let one discount alone outweigh the whole cost signal it's supposed to merely nudge. Phrase- boundary retention (#103/B3) already applied to keys automatically, since it's computed on sharedphrase_groupscode outside the fretted/keys branch. Separately,_notes_for_level_keys's chord-voicing reduction now grows by a smooth per-tier budget for chords wider than 3 notes — a fixed voice-add order (outer voices first, then alternating inward from both ends) is truncated to a count that rises with tier level, instead of the old fixed three-step jump (outer only → outer + one middle voice → everything) regardless of how many tiers the ladder has. This is a note-COUNT budget, not the mechanical-cost budget Nakamura & Yoshii (2018) frame piano reduction around (a group's cost terms — polyphony, span, density, speed, sustain — aren't attributed per note), a simplification declared in the code rather than left implicit. A 2-3 note chord has no real room for a graded budget and keeps its pre-#103/B8 behavior exactly (outer only at the bottom tier, everything above it). Octave-duplicate collapsing (_collapse_octave_duplicates) runs in the fixed voice-ADD order, not pitch-sorted order — sorting by pitch first would make collision priority pitch order instead of add order, letting a harder tier's newly-added lower voice win an octave collision and drop an upper voice an easier tier had already exposed, which is neither a superset nor a subset and breaks the #99 nesting guarantee this function exists to provide (caught in PR #126 review on a 7-voice voicing with an octave-adjacent interior pair). Every REDUCED tier (level < max_level) is still pitch-sorted for presentation, after the collapse decides which voices survive — the top tier skips reduction entirely and returns the chord's own authored note order unchanged, same as always. - Keys/piano hand movement is charged per hand (#177).
_score_groups_keysapplies the keys counterpart of the fretted path's time-aware fret-jump cost (_fitts_shift_bonus, #19): a boundedlog2(distance / 5 + 1)index, discounted by the time available, capped at 0.05 and zeroed pasttempo.fret_jump_window_seconds— the same tempo-relative window the fretted shift bonus and the melody-turning-point term already use, rather than a second one. Each group is anchored at the MEAN of its MIDI pitches (keys notes carry exact pitch, so a hand's position is best read as the centre of what it is holding), and measured against the previous group of the SAME hand:_group_notes_keystags each notes-bearing group"lower"/"upper", taken from_split_keys_handsfor a split onset and from the same skyline register judgement that picks the melody for an unsplit one, so scoring the two halves of one onset against each other cannot charge a phantom leap. That label is a register guess, so a group with no same-hand predecessor falls back to the group immediately before it (the fallback is off for the halves of a split onset, where having no predecessor really does mean the hand has not played yet); without it a single melodic line whose contour straddles the median would charge 0.0000 for every note. A 5-semitone target width (a hand shifts for free within about a fourth), a 19-semitone reference (a twelfth, where it saturates) and a 0.05 cap — about half the fretted 0.10, for the narrower keyscostrange, but still twice_KEYS_BEAT_VALUE_COEF— are heuristics, not measured player thresholds. The term is keys-only; the fretted path's own shift bonus is unchanged. - Keys/piano black-key content costs more (#178, item 2 of the keys roadmap
#175).
_score_groups_keysnow charges each group its black-key share (black notes / total notes over pitch classes 1/3/6/8/10), scaled to a_KEYS_BLACK_KEY_MAX_BONUScap of 0.02 — a single black melody note pays the full cap, a 4-voice chord with one black key pays a quarter of it. Mechanical like the leap term above, so it raisescostandretention_scoretogether. The cap sits below the keys metrical_KEYS_BEAT_VALUE_COEF(0.025) and well under the fretted 0.12 ceiling, so a fully-black passage scores at most 0.02/group above its all-white transposition (measured +0.015/group mean on a C-major vs Db-major pair). No separate white→black transition term — movement between onsets is already priced by the leap term. Evidence is weak (beginner-method precedent, 🔴 per #103's convention); the term compounds with, but never disables, #179's key-stability discount. - Keys/piano bottom tier has a minimum musical density (#181, item 6 of
the keys roadmap #175). The proportional tier floor in
_assign_tiersguarantees tier 0 a share of the phrase's GROUPS (~15%), but a keys group is one onset — often a whole chord — so on a dense chordal passage that share materialized as only 1-2 NOTES against 20+ at the top tier: a learner at the easiest setting had almost nothing to play (measured on a 2-bar phrase of quarter-note 4-voice chords: tier 0 held 2-3 notes while the top tier held 32). The keys path now applies a per-phrase floor after tier assignment: a strong-beat skeleton (one group at tier 0 covering every grid position graded at least_STRENGTH_STRONG_BEAT— a downbeat, or the mid-bar strong beat of a 4/4 measure), plus a note-density backstop (tier 0 materializes at least the bottom tier's equal share of the phrase's notes, 1/n_levels, capped at what a full demotion of the phrase could emit at tier 0 — a voicing whose outer voices are an octave apart collapses to one note at tier 0 however many voices it has, so the raw share can be unreachable even with every group at tier 0; the emitted count is tracked incrementally — a group's tier-0 materialization is independent of every group's level, so the per-group sizes are computed once and each demotion adds its group's size — instead of rematerializing the whole phrase after every demotion, which is quadratic in the phrase's group count on dense passages), topping up with the cheapest remaining groups when the skeleton alone is thin — in 3/4 or 6/8, or when no graded beat grid exists at all). Measured on the same fixture: tier 0 now holds 7 notes (the bass voice of nearly every chord); on 2 bars of sixteenth notes, 9 of 32 with every strong beat covered. The floor only relabels levels downwards within a phrase (the arrangement-wide thresholds are untouched and every tier's group set only grows), but a demotion can make an ADJACENT pair of tiers byte-identical: demoting every group that sat at level k+1 equalizes the pair's group sets, and when their per-tier voice budgets then agree (always for two-voice groups, and for octave voicings whose extra voices duplicate exposed outer voices)_collapse_identical_levelswould silently merge the pair, costing the ladder a tier. The anti-collapse guard therefore never lets the floor create a new identical pair: a pair already identical beforehand is a pre-existing collapse (issue #70's own territory), and the surgical fix restores the most recently demoted group of the level the boundary lost — one restoration per threatened boundary, every other demotion intact, sacrificing backstop additions before strong-beat skeleton positions. Where the guard and the floor conflict, the ladder without the collapsed tier wins: on a passage of nothing but two-voice chords, any demotion at all would create a new identical pair, so the floor leaves the tier assignment untouched. The floor is keys-only; the fretted path is untouched. - Keys/piano reduction is voice-aware (#180, item 5 of the keys roadmap
#175). Three gaps in the hand-aware thinning above, each measured on a
synthetic fixture. (1) Bass root on downbeats. The #181 strong-beat
skeleton keeps ONE group per strong position — whichever sits nearest it —
and on a two-hand onset that is usually the melody, which tends to score
cheaper than the accompaniment; the downbeat bass root was then left out of
the bottom tier while still sounding (measured: an Alberti-bass phrase's
tier 0 held the melody but not the downbeat root under it). The skeleton now
also keeps the lowest-pitch group of the same onset. Only groups sounding at
that onset qualify, so a position with no bass note under it — a rest, or a
right-hand-only bar — forces nothing into a sparse window. (2) Per-hand
floor. The skeleton and the note-density backstop both reason about groups
GLOBALLY, so on a texture where one hand plays a dense run and the other
only a few widely spaced notes, the dense hand's many cheap groups satisfy
both on their own and the sparse hand is absent from every reduced tier but
the top (measured: right-hand sixteenths under a left-hand bass sounding
only on two off-beats put the left hand at the top tier alone). Tier 0 now
keeps one group from each hand, so one hand's density cannot starve the
other. Hands come from the
hand_splitgroups when the phrase has any; otherwise from_keys_phrase_hands, which bands the phrase's own pitches at the widest gap between consecutive distinct pitches, so hands that are staggered (never sounding together, hence never split) are covered too. (3) Crossed / interleaved hands._split_keys_handssplit at the widest internal gap, but with several equal gaps — the crossed case — the first is not necessarily the seam:[36, 48, 60, 72]has three equal 12-semitone gaps, and taking the first returned a lower part spanning 24 semitones — two octaves, impossible for one hand. Candidate seams are now ranked by widest gap and then by balance (the split whose larger part spans least), so ties resolve to the most compact split ([36, 48] | [60, 72]here, each part within a hand); among seams that leave both parts within a hand's reach (_KEYS_HAND_SPAN_SEMITONES, a 9th) that ranking is applied, and a genuine split is never dropped for being wide — the widest gap is still the fallback. Fixtures for Alberti bass, stride, and crossed-hand passages pin tiers 0-2 and nesting intests/test_dd_generation.py. The bass-root and per-hand demotions only relabel levels downwards; the anti-collapse guard prefers restoring a non-required demotion, so a required group normally keeps its tier-0 place, and restores a required one only when nothing else can separate an otherwise-identical pair — keeping the ladder's full tier count, which outranks the guarantee when the two conflict (leaving the pair would let the floor create 22 new identical tier pairs across these fixtures). The split change is upstream of tiering — it affects which notes share a group and their hand/melody labels, and therefore scoring — and is covered by the nesting tests plus the_split_keys_handsunit tests. - This is a fresh implementation against feedBack's own arrangement wire
format (
lib/song.py) — it does not port code from, or share a runtime with, the Slopsmith arrangement editor's differently-scoped difficulty feature; only the general "score note groups, bucket into tiers" heuristic approach was used as design inspiration.
Instrument coverage
| Instrument | Supported? | Notes |
|---|---|---|
| Guitar / bass (fretted) | ✅ | Fret complexity, low-position stretch posture, time-aware hand shifts, string-skip/hand-shape distance, tempo/syncopation-aware density, sustain-ease. Technique scoring covers bend (base + pre-bend/round-trip/shaped-curve difficulty, bt/bnv), slide, hammer-on/pull-off, tremolo, natural vs. pinch harmonic (scored independently), palm/string mute, vibrato, fret-hand mute, and bass slap/pop (scored independently, slap weighted harder), plus a coordination bonus for a group using more than one distinct technique at once or switching technique from the group before (a chord mixing a bend and a palm mute scores above either alone; see "Technique difficulty counts coordination demand" below). Timing thresholds (grouping window, beat tolerance, movement time scale) scale with the song's own tempo instead of fixed wall-clock constants. |
| Keys / piano | ✅ | Separate pitch-based heuristic (polyphony, hand-span, density, sustain-ease, per-hand position shift, black-key share) — keys notes encode midi = string*24 + fret, so the fretted heuristic doesn't apply and never runs against them. No fret anchors/hand-shapes generated (the piano renderer doesn't consume them). |
| Drums | ❌ | Drum parts are a drum_tab.json pointer, not a notes/chords file — outside this generator's data model entirely. Detected and skipped cleanly (unsupported-instrument-drums), never mis-scored. |
| Anything else (vocals, harmony, notation-only, …) | ❌ | An arrangement whose type is a specific, non-empty value this generator doesn't recognize is rejected explicitly (unsupported-instrument-type) rather than silently treated as fretted. |
Arrangement type is detected via an explicit allowlist, the same convention
core uses: the manifest/arrangement's type field — "piano"/"keys" (or
"drums"/"drum", detected and skipped before this generator runs) for
keys, "lead"/"rhythm"/"bass"/"combo"/"chord"/"humstrum" for
fretted — falling back to the same /^(keys|piano|keyboard|synth)/i name
match the piano-roll chart mode uses when type doesn't say. An absent or
blank type still defaults to fretted (unchanged from before this fix,
and required for compatibility: the GP importer never sets type on
fretted/keys arrangements at all); a present but unrecognized type is
the case that's now rejected explicitly instead of guessed at.
Per-song difficulty memory
- Core persists master-difficulty as a single global value (whatever the
mastery slider was last set to, for any song). This plugin additionally
remembers each song's own last-used difficulty (keyed by filename +
arrangement, in
localStorage) and restores it whenever you come back to that song — so switching between a song you've mastered and one you're still working through no longer carries one song's difficulty into the other. Captures both manual slider moves and this plugin's own auto-adjustments, for songs with phrase-level difficulty data only. - Warm-up start (Adaptive only). The first time you resume a song in a session, the song starts slightly below its remembered difficulty — half of one auto-adjust ramp step, i.e. at most one full step and normally less: 5% at Sensitivity 1, 8% at 2, 10% at 3 (see Sensitivity in the settings table for what a "step" is). Your first sections of a session tend to dip below the level you settled on, and starting at the peak makes early misses likely enough to provoke a step-down you didn't need. The ramp climbs back once your rolling accuracy clears its up-adjustment threshold — a session that settles mid-band plays out at the slightly lower start, which is the intent — and WARMUP_PHRASES still keeps the ramp quiet until it has real evidence (the two compose — neither replaces the other). It applies once per remembered difficulty (a song/arrangement/instrument/role/skill combination) per session: a re-restore of the record already in play puts you back exactly where the start left you, while a different song mid-session gets its own warm-up start. Never in Standard mode (nothing would climb back, so it would just strand you below your own value), and never below your Min % floor — if the song's remembered value already sits at or under it, there is no room to give and it starts as it is. The remembered value itself is untouched: the start is a live-only value, and the ramp's first adjustment — or you moving the slider — is what re-records it.
The legacy single-player storage is migrated conservatively into the
difficulty_ladder.progress.v2 and difficulty_ladder.phraseAttempts.v2
stores under skill: "overall"; unscoped legacy data is not claimed by
concurrent profiles. A legacy record can be claimed by only one player, and its
claim marker prevents another player sharing that profile from reading it.
Best mastery — alongside the remembered difficulty, each song/arrangement
keeps a long-term best: difficulty × that phrase's hit rate, rounded to 2 decimals,
stored per player/song/arrangement/skill and monotonic — a weaker session never
lowers it, and it is never touched by difficulty writes or the legacy migration. Each
NEW best emits one difficulty:mastery-updated event (schema
difficulty_ladder.mastery-updated.v1) with the player context, the new best_mastery
and the previous_best it surpassed (null for a first best), so an integration can
track the long-term score from the event stream without reading the store. It rides the
same discrete phrase-commit write as the record — never a per-frame path — and a host
without the event bus drops it silently.
The same best is surfaced in two places: as a small chip on the song's library card (see
Library card badge below), and — at the end of a scored play — as one ★ N% mastery
row on the Note Detect results card. That row is stamped first play when the session
establishes the node's first best, otherwise it reports the standing best — it never
duplicates the co-measured phrase commits or a second row. Only a first session may write
from the whole-session accuracy, because that is the one case where per-phrase evidence
can't contradict it; after a best exists, the record advances only on phrase-finalization
commits, which measure difficulty and accuracy together. It is injected from Note Detect's
synchronous notedetect:session event, so no MutationObserver or polling is involved.
Live auto-adjustment
- Reads live per-note hit/miss judgments from whichever note-detection scorer
is active (e.g. the
note_detectplugin) viahighway.getNoteStateProvider()— this plugin doesn't score notes itself, it observes an existing scorer. - Tracks a rolling accuracy average per song section (phrase) and nudges the
master-difficulty slider (
window.setMastery) up after a run of clean sections, or down after a rough one. With the Level up only setting on, the downward nudge never happens — step-ups, warm-up counting, bounds, and the manual-override stand-down below are all unchanged. - Won't act on a song's very first section at all, and starts a song's first session slightly below its remembered difficulty (see Per-song difficulty memory above) — so a rusty first section is neither acted on nor provoked.
- Records monotonic best mastery at phrase finalization as the live difficulty percentage multiplied by the phrase hit rate. This never changes the separate current-difficulty target.
- Only ever changes difficulty at section boundaries — never mid-phrase.
- Stands down when the legacy, originless Host API reports an unexpected
difficulty change. This conservatively protects manual slider changes, but
Host restoration or another plugin can look identical because
window.setMasterysupplies no source metadata. Auto-adjust must be explicitly re-enabled afterward. - No-ops entirely for songs without a phrase-level difficulty ladder
(
highway.hasPhraseData() === false— GP imports, legacy sloppak).
Difficulty guide
- Renders upcoming phrases in the player as a compact, event-driven segmented
tier rail — one cell per visible phrase, the first
currentTier + 1segments lit, and an outlined segment markingtopTier(the full-detail threshold). Difficulty is never encoded by changing a component's size (#157). - At the configured maximum mastery, three consecutive phrases at 95% accuracy
or better build a mastery streak, tracked internally and exposed through the
plugin API (
masteryStreakStatus()). Pausing or entering or leaving a split-screen session resets it. - Purely a visualization; can be toggled independently of auto-adjust.
- With Section Map installed the standalone overlay is suppressed and the guide renders in Section Map's own section bar instead. The Show difficulty guide setting only gates this plugin's overlay; it is not wired to Section Map's section bar.
- Target Host: feedBack core implementing
plugin-spec-v1.mdwith the v3 player chrome (window.feedBack.ui.playerControlSlot()) — the only chrome feedBack core ships as of v0.3.0. The player-controls buttons (Auto-Difficulty, Generate Difficulties) mount via awindow.feedBack.uiVersion === 'v3'guard, which is vacuously satisfied on any current Host; seeCOMPLIANCE.mdfor why this is no longer tracked as a gap. The difficulty guide itself never depended onuiVersionand renders regardless. - feedBack core with the
note-detectioncapability /setNoteStateProvidercontract (spec 009) and phrase-level difficulty data (feedBack#48). - A note-detection scorer plugin installed and active for auto-adjust to have any signal to react to. Without one, the tier rail still renders on song load (using only authored difficulty + the manual mastery slider) but has no phrase-transition events to advance it, and auto-adjust stays idle.
plugin.json has never declared a minHost — see
#129.
Loading/generating ladders and getting fully correct live adaptive
behavior have different core requirements, and conflating them into one
number would either be too strict (blocking generation on hosts that
support it fine) or too lax (silently mis-rendering phrase tiers on hosts
that don't). feedBack core has no tags or releases (it versions itself
via a root VERSION file, currently 0.3.0-alpha.2), so until a
release-qualified audit exists against a version core actually ships,
this documents the known source-level floors instead:
| Capability | Core requirement | Evidence |
|---|---|---|
Loading / generating ladders (routes.py) |
dlc_paths._resolve_dlc_path (core 0dcc913, Jul 10) and sloppak.read_member_bytes (core d876ded, Jul 13) |
Direct imports in routes.py; feedBack core has no tags/releases and versions itself via a root VERSION file (currently 0.3.0-alpha.2) — both commits postdate that version line, which is the load-bearing fact until a release-qualified audit exists |
Correct phrase-tier semantics (mastery slider ↔ getPhrases().top_difficulty) |
Core e5339c0 (Sep 23) — switched mastery selection to tier-number semantics and exposed top_difficulty |
screen.js consumes top_difficulty when present but only falls back when it's absent — that doesn't prove older-core rendering agrees with the tier-rail/attempt records |
Full concurrent-player support (playerContexts, player-difficulty.v1) |
Core 7633211 (Sep 17) |
Older hosts fall back to a legacy per-player-unaware compatibility path — see PLAYER_CONTEXT.md |
These are source/history findings (inspected against plugin revision
d6e60f6), not an end-to-end certification of any specific older host —
treat e5339c0 (or a tested descendant) as the candidate baseline for
full adaptive-difficulty behavior, and the two Jul commits as the floor
for generation working at all, until a release-qualified audit lets
minHost be set honestly. Related: #87
and #88
implement the player-context features this table only versions.
The plugin cannot declare minHost (no release to pin to), so it ships an
actionable runtime diagnostic instead: contributeDiagnostics publishes
host_reports_phrase_tiers — true when every phrase the host reports
carries top_difficulty (core e5339c0+), false when at least one phrase
lacks it (tiers are then derived from max_difficulty alone on those
phrases, which cannot tell a collapsed single-level phrase from a full
one — a mixed host is still a legacy host for those phrases), and null
when there is no phrase data to judge. The three phrase-tier consumers
(the rendered DOM tier rail — the standalone guide since #157 — the
difficulty_ladder.sections.v3 event, and the
phrase-attempt log) and their one intentional divergence on a collapsed ladder
(top_difficulty < max_difficulty: the rail and attempt log report the
slider's tier, which keeps climbing to max_tier, while v3 caps at full
detail) are pinned by tests/screen.test.js's host-contract tests.
Peer plugin requirements (see also CLAUDE.md → Plugin dependencies,
#130).
None of these block the plugin from loading or generating ordinary
ladders — each is a feature-scoped optional dependency, not a hard one:
| Peer | Feature it enables | Required / optional | Lowest auditable version | Degraded behavior when absent/older |
|---|---|---|---|---|
| Chordr | The read-only chord-grouping preview at POST /api/plugins/difficulty_ladder/analyze-chords (routes.py:460), which calls app.state.chordr_analyze_chart_chords_v1 (routes.py:496) |
Required, for that route only | v0.5.0 (first version registering app.state.chordr_analyze_chart_chords_v1) |
Route returns HTTP 503 when Chordr is absent. staged_chords (the chord-landmark generator stage, GenerateIn.staged_chords) is not Chordr-gated — it's a plugin-local option that re-derives chord identity from the arrangement's own templates and works identically with Chordr absent or missing |
| Note Detect | Split-screen adaptive scoring (window.createNoteDetector) |
Optional | v1.15.2 for the factory/ownSource split-panel registration path at all; v1.33.0 for stable player_context propagation |
Below 1.15.2: split-panel instances aren't registered for adaptive scoring. 1.15.2–1.33.0: works, but panel identity is unpersisted and per-highway rather than stable |
| Split Screen | Per-panel difficulty state (window.feedBackSplitscreen/window.slopsmithSplitscreen) |
Optional | v1.10.6 — the earliest auditable Split Screen version (the repo's history begins at a Clean release snapshot tagged 1.10.6) that already exposes the consumed window.slopsmithSplitscreen with isActive(). The window.feedBackSplitscreen alias is newer (v1.10.8), but the integration reads window.feedBackSplitscreen || window.slopsmithSplitscreen, so 1.10.6 is sufficient at the source level |
Feature-detected via a bare typeof ss.isActive === 'function' presence check — that catches a missing global or a missing isActive method, but not a present isActive whose contract moved underneath it, which is exactly the silent-death case splitscreen#47 (below) describes |
| Section Map | Section-difficulty indicators via the difficulty:sections-updated-v3 event, consuming its sections[].top_tier/.current_tier fields |
Consumer of this plugin's event, not the other way around | Difficulty Ladder v0.41.0 — where the render-neutral difficulty_ladder.sections.v3 payload replaced the retired v2 fill-percentage contract (sectionmap#14 migrated Section Map to v3) |
See INTEGRATION.md for the full contract |
A typeof check alone doesn't catch a downstream contract change on an
otherwise-present global — see
feedback-plugin-splitscreen#47,
which documents two other plugins' integrations going silently dead this
way (the Split Screen row above is exactly this kind of gap). Capability
probing (rather than a bare presence check) remains a follow-up; the
missing/minimum/current degraded behavior of the Note Detect and Split
Screen surfaces is now pinned by tests/peer_compat.test.js, and the
Chordr absent/failing path by
test_chord_preview_reports_missing_service_and_service_failure.
These peer floors are a snapshot inspected against plugin revision
d6e60f6 (same as the core table above), not a continuously-verified
contract — CLAUDE.md → Plugin dependencies carries the same four
numbers and is this repo's own warning that they're moving targets under
active audit in #129/#130; treat this README section as that snapshot for
the same facts, not an independent source.
This plugin absorbed the Slopsmith-era Song Mastery plugin's per-song
difficulty memory, long-term best mastery, and library-card badge. The
consolidation record — what was absorbed, the exact storage/migration contract
(the migrations.songMasteryV1 cutover marker and its "one-time window"
caveat, the runtime-rewritten legacy songMastery key, and the best-effort
rollback path), an end-to-end compatibility matrix (including the unhandled
concurrent-tab clobber), the both-installed conflict decision, and the gated
removal timeline — is in MIGRATION.md.
The two plugins coexist in read-only compatibility mode. On first run,
Difficulty Ladder reads the separate Song Mastery plugin's Dynamic Difficulty
settings blob (song_mastery_dd) — never modifying it — and initializes the
Difficulty Ladder settings it can express exactly, in its own namespaced keys:
allowDecrease:false → Level up only, defaultDifficulty → Starting
difficulty for new songs, and maxStepsUp/maxStepsDown × stepSize → the
per-play movement limits — applied only where Difficulty Ladder has no
preference of its own (the full mapping table, including the values deliberately
not imported, is in MIGRATION.md §6). It also imports the
plugin's own score/difficulty store (song_mastery_v1 /
song_mastery_v1_<profile-hash prefix>) — again never modifying it — mapping
each saved difficulty to this plugin's remembered current difficulty and each
best mastery to the migrated mastery value, keeping the two strictly separate.
Arrangement names in that store are resolved against Host metadata before
anything is written, and any record that cannot be resolved (an unknown or
duplicate name, or a legacy flat record) is deferred into a reviewable list
rather than guessed; see
MIGRATION.md §11. It registers its own card action under
a distinct id. The separate Song Mastery plugin is neither declared obsolete
nor removed by this consolidation (a public deprecation notice is not
required); retiring it remains a later breaking step. While both are
installed, run a single adaptive controller and a
single library badge (disable the peer's in the Host's plugin/library
settings); Difficulty Ladder never writes or deletes the peer's keys — see
MIGRATION.md §5.
Best mastery vs adaptive difficulty. Best mastery is a per-arrangement,
monotonic long-term score (phrase difficulty × hit rate) persisted with the
profile; adaptive difficulty is the live, per-play controller that moves the
slider. They are independent: the controller never reads or writes best mastery,
and this settings import touches only the settings above, never a score or a
mastery value.
Exposed via Settings → Plugins → Difficulty Ladder:
| Setting | Effect |
|---|---|
| Difficulty mode | Standard (default) keeps difficulty fixed — no automatic movement. Adaptive enables today's live auto-adjust (setMastery() calls driven by accuracy). |
| Resist isolated difficulty drops | Require two consecutive below-threshold sections before a downward adjustment; upward adjustments remain immediate. Off by default. Inert while Level up only is on. |
| Level up only (#111) | Opt-in comfort switch: auto-adjust raises difficulty as usual but never lowers it, at any accuracy. The manual slider still works, and moving it still stands auto-adjust down entirely — both directions — exactly as without this setting. Off by default, and deliberately presented as a comfort option rather than a learning aid (a 2022 meta-analysis, McKay et al., found the self-controlled-practice benefit close to zero after bias correction). |
| Show difficulty guide | Show/hide this plugin's standalone difficulty guide: a compact, event-driven segmented tier rail. One cell per visible phrase; the first currentTier + 1 segments are lit and an outlined segment marks topTier (the full-detail threshold) — difficulty is never encoded by changing a component's size. Hover, focus and an aria-label give the exact Tier N of M · full detail at K (a single focus stop reads the current + upcoming phrases). It updates on song load, phrase commit, mastery change and settings change — never from a requestAnimationFrame loop. With Section Map installed the overlay is suppressed and the guide renders in Section Map's section bar instead; this setting does not control that surface. |
| Sensitivity (1-3) | How confident auto-adjust must be (hit-rate thresholds) before it moves the slider, and how big a step it takes. A step is 10 / 15 / 20 percentage points; the per-song warm-up start is a fraction of one (see above). |
| Reaction speed (1-3) | How much weight a single section's result carries in the rolling accuracy average (EMA_ALPHA) — independent of Sensitivity. Default (2) reproduces this plugin's original, pre-#5 behavior. |
| Difficulty drop speed (1×-2×) | Multiplies only the downward auto-adjust target so difficulty can ease off faster than it climbs. The default 1× preserves symmetric behavior. Inert while Level up only is on. |
| Min / Max % | Hard bounds auto-adjust will never cross. |
| Starting difficulty for new songs (#216) | Optional first-play difficulty (percent; blank = off) for an arrangement that has no saved difficulty yet. It seeds once, only after song/arrangement/profile identity is ready and phrase data exists, clamped to Min/Max, and is remembered as that arrangement's baseline (it is not a #112 warm-up concession). A saved/migrated difficulty or a manual slider move always takes precedence, and the seed is applied per player context so it never crosses profiles. Off by default, so existing behavior is unchanged. Presented as a comfort/control option, not a claimed learning optimum. |
| Per-play movement limits (#217) | Optional Max rise / Max fall (percentage points; blank = unlimited) capping how far auto-adjust may move the difficulty from where the current play started. The limits are positional — always measured against the play's starting difficulty, never accumulated — so going down and back up does not spend an allowance, and saturation banks no delayed jump. They intersect the Min/Max bounds, apply only at the usual phrase boundaries and only to auto-adjust (a manual move still stands it down), and never write best mastery or alter estimator evidence. Unlimited by default, so the controller's output is unchanged. Comfort/control option, not a claimed learning improvement. The reference resets with the scoring state (song/arrangement change); seeking/looping within a play keeps its allowance. |
| Generate ladder depth cap (2-8) | Maximum difficulty tiers "⚙️ Generate Difficulties" can give a phrase when building a ladder for a song that doesn't have one yet — threaded into /generate's existing levels parameter. |
staged_chords (#103/B10) is an additional opt-in /generate and
/generate-library request field — false by default — not yet exposed
as a settings.html checkbox. Pass it explicitly in the request body to
enable the chord-landmark bottom tier (see "Possible Upgrades" below for
what it does); the acceptance criteria for a full UI toggle is broader
validation against real (not just one) arrangements first. Like
levels, it only takes effect while generating: it has no effect on an
arrangement that already has phrases unless the request also sets
force, matching /generate's existing regenerate-only-on-request
behavior.
preview: true on /generate returns per-arrangement provenance without
writing the pack. A ladder with a valid x_difficulty_ladder marker reports
its recorded Difficulty Ladder plugin version; an older marker without that
field reports the generator but not its release. A ladder without a valid
marker has unknown provenance: it may be handmade or generated before marker
tracking existed, so the plugin cannot reliably identify it as handmade.
overwrite_authored is an additional /generate and /generate-library
request field — false by default — required before force may overwrite a
ladder with unknown provenance. The library-card action first previews all
arrangements, shows the available provenance in its confirmation, then sends
overwrite_authored: true only if an unknown ladder was included and the user
confirmed. This flag never overrides the skip for drums/unsupported arrangements.
Library card badge — each arrangement's best mastery (see above) shows as a small chip on
the song's library card via window.feedBack.libraryCardActions (placement: 'overlay', never a
MutationObserver). The chip's visible text is the compact best (★ 80%); its title/aria label
lists every scored arrangement (Lead 80% · Rhythm 55%), because the capability renders one element
per card rather than one per arrangement. Mastery, not the remembered difficulty, is what is shown:
a song whose node holds only a live difficulty target and no score stays unbadged, while a score
migrated off the old Song Mastery plugin is surfaced from the migratedMastery the cutover preserved
(never from the mutable currentDifficulty, which live difficulty changes rewrite).
label/icon are (song) -> string readers, so each card reflects its own song — the capability
passes functions through (_normalize) and calls them per card.
Profile baseline card — the v3 Profile screen shows the average and median remembered difficulty for fretted and keys arrangements. The card is read-only, appears only after at least one classified arrangement has a saved mastery, and does not change the starting difficulty for new songs.
Where auto-adjust settles (roadmap C2, #55) — Sensitivity and Reaction
speed don't map to a target accuracy directly; where the controller
actually settles was measured by simulation (tools/settle_points.js
drives the real commitPhraseResult, not a re-implementation), since the
controller is an EMA with a dead band rather than the simple weighted
up-down rule the roadmap originally assumed a settle-point formula for.
20 seeds × 400 phrases per setting, 16 notes/phrase, drop resistance off,
reaction speed 2 unless noted. Reproduce with
node tools/settle_points.js --sens=1,2,3 --react=2 --notes=16 --drop=0:
| Sensitivity | Mean accuracy | Settles across runs | Slider moves / 100 phrases |
|---|---|---|---|
| 1 (lenient) | 0.80 | 0.77–0.84 | 0.4–0.5 |
| 2 (default) | 0.78–0.79 | 0.76–0.81 | 5–12 |
| 3 (strict) | 0.77 | 0.76–0.78 | 25–35 |
This table only varies Sensitivity. Reaction speed also moves the
movement column a lot, but its effect on the settle range differs by
setting (--sens=1,2 --react=1,2,3 --notes=16 --drop=0): at sensitivity
2, moves/100 goes from 1–6 (reaction 1) to 5–12 (reaction 2) to 11–19
(reaction 3), while the settle range barely shifts (0.75–0.82 / 0.76–0.81
/ 0.77–0.80). Sensitivity 1 is not similarly stable — its mean (union
across slopes) rises to 0.79–0.83 and its per-run spread widens to
0.76–0.86 at reaction 1, against 0.80 and 0.77–0.84 at reaction 2 (see
the burn-in caveat below for why sensitivity 1's spread is less
trustworthy than 2/3's to begin with).
Roadmap C2 (#103) calls for a table across both dimensions; this
section documents only Sensitivity at a fixed Reaction speed — the
Reaction-speed dimension stays open.
Findings, in order of confidence:
- All three settings land slightly below the ~0.80–0.85 figure roadmap
C2 (#103) cites — a target the plugin itself doesn't declare anywhere
in its settings or code; the controller's only actual targets are the
hit-rate thresholds in
thresholds(). That figure traces to Wilson, Shenhav, Straccia & Cohen's "Eighty Five Percent Rule" (Nature Communications 10:4646, 2019), which derives the training accuracy that maximizes the rate of learning under gradient-descent rules for a broad class of classifiers — about 85% for Gaussian label noise, less for heavier-tailed noise (82% Laplacian, 75% Cauchy) — not a level a controller should settle at during ordinary play. Comparing it to this section's steady-state settle points is a category mismatch on top of the motor-learning extrapolation: the paper prescribes a difficulty to hold while learning, and the simulated player here explicitly has no learning process to hold it for (constant skill, no fatigue — see Confidence below). Roadmap C1 (#54, reviewing this rule and its limits) is still open; treat every comparison against 0.80–0.85 in this section as indicative at best, not a target the controller is failing. Sensitivity 1 reads closest to it, but see the burn-in caveat below for why that row's own settle point is the least trustworthy of the three. - "Strict" has the tightest settle point of the three, not the
loosest — despite moving the slider far more often. The settle
ordering (0.80 / 0.79 / 0.77) sits within 0.01 of the sensitivity
thresholds' own dead-band midpoints (
thresholds()inscreen.js), so it follows directly from those thresholds — no simulation needed to explain that part. What the simulation adds is the movement column: the frequent moves are mostly per-phrase noise (a single miss swings a short phrase's hit rate a lot) amplified by strict's bigger step and narrower dead band, not evidence the setting is unstable in the sense its name implies. - Short phrases are the dominant source of that noise. At every
sensitivity, the hit rate the controller actually observes spans wider
with fewer notes per phrase — roughly 0.50–1.00 (10th–90th percentile)
at 6 notes, 0.56–0.94 at 16, 0.68–0.90 at 40
(
--notes=6,16,40 --react=2 --slopes=0.1 --drop=0); the per-sensitivity spans at 16 notes are 0.69–0.94 / 0.63–0.94 / 0.56–0.94, so "independent of sensitivity" describes the conclusion (fewer notes always widens it), not identical numbers across sensitivities.
Confidence: moderate that the direction of each finding holds — it's
consistent across every slope, seed set, and note count tested. Low
confidence in the exact numbers — the simulated player is a smooth
logistic curve with constant skill (no learning/fatigue), every phrase has
the same note count, and the slider maps straight to success probability
rather than the chart's real discrete tiers. No real player data was used,
and none of this is a claim about actual players. Every run above
discards the first 100 (of 400) phrases as burn-in. A --start sweep
(10/30/50/70) confirms this fully for sensitivities 2 and 3 — byte-
identical settle ranges at every start — but only partially for
sensitivity 1: at 0.4–0.5 moves per 100 phrases, its post-burn-in window
sees roughly one slider move across the 300 measured phrases, so it
rarely re-equilibrates after burn-in ends. Its reported range (0.77–0.84)
is the union across starts, not evidence of a single stable equilibrium
the way 2/3's identical-across-starts numbers are — the per-run settle
itself varies by start/slope combination too (0.77–0.83 at slope 0.20
regardless of start; as wide as 0.78–0.84 at slope 0.05, start 10).
Finding 1's "sensitivity 1 is closest" ranking rests on this least-settled
row; take it as directional, not as precise as the table formatting
implies.
Adaptive research simulation (#55, parent #31):
ADAPTIVE_SIMULATION.md documents the seeded dynamic-player/item
simulation, its held-out Elo/Kalman/LLTM comparison and a bounded Phase 3 shortlist
(Kalman-Elo primary, fixed Elo reference, LLTM exploratory). Run
node tools/adaptive_simulation.js for the EMA baseline summary JSON,
node tools/adaptive_simulation.js --mode=compare for the held-out candidate comparison,
or node tools/adaptive_simulation.js --trace=fatigue --seed=1000 for an auditable
synthetic trace. This complements the constant-skill settle-point study; the ranking is
synthetic forecast evidence, and real-player validation (#56) and the adopt/defer
decision (#57) remain open. It does not change the live controller.
Not done here: no setting has been retuned as a result of these
numbers (e.g. weighting the EMA by note count, or adjusting strict's step
size/dead band) — that's a separate decision pending review of the data
above, not something this measurement does on its own authority. The
Reaction-speed half of C2's table remains open under roadmap #103.
The dynamic synthetic-player/item simulation is now documented in
ADAPTIVE_SIMULATION.md; it supplies a held-out candidate ranking rather than
only EMA baseline traces.
All settings persist in localStorage, prefixed difficulty_ladder..
| Field | Value |
|---|---|
| id | difficulty_ladder |
| version | see plugin.json — bumped on every user-visible release, kept out of this table so it can't drift out of sync |
| category | practice |
Chordr now supplies a read-only grouping preview at
POST /api/plugins/difficulty_ladder/analyze-chords with body
{"filename":"Song.feedpak","arrangement_index":0}. It reports each chord
event's Chordr identity and parent group; a played subset of the preceding
full chord stays in that chord's group. Chordr must be active for this route.
The preview does not rewrite the pack or change generated tiers. Inspect its
grouping on real arrangements before enabling a chord-led generator stage.
Design notes for the general generator. The chord-landmark stage below is
now implemented (#103/B10, staged_chords — see the Settings section),
off by default; everything else in this section (strum-onset/voicing/
technique staging beyond what the existing continuous difficulty curve
already does, and the "enable a chord-led stage by default" step — see
issue #121's acceptance criteria) remains a design note, not implemented.
The one-off Bring Me to Life preview in the library tests Chordr grouping
on that song only — not the broad, multi-arrangement validation #121
requires before any of this could default to on. Each general feature
should ship as an independent, opt-in setting so existing behavior doesn't
change unless a user turns it on.
Musically staged fretted ladders:
- Add meaningful stages to the current density, voicing, and technique progression rather than replacing the existing generator or silently changing existing packs. More tiers (within the supported depth cap), or intermediate tiers between current stages, are appropriate when each tier makes a distinct, playable change. Preserve the full authored chart at the highest tier; do not preserve a defective generated tier merely to keep its number.
- Implemented (#103/B10, opt-in via
staged_chords): in a chord-led passage, the bottom tier now retains the harmonic path: one full chord landmark per identified chord identity, dropping its repeated strumming pattern entirely — not just thinning each repeat's voicing, which the pre-existing per-group reduction already did on its own._staged_chord_drop_idsprefers the identity's longest-sustained occurrence, among the occurrences already at the bottom tier, as the landmark (usually the first, but not assumed to be — measured, not guessed) — scoped tolevel == 0rather than every occurrence in the whole phrase, since picking a landmark from a higher-tier occurrence could drop the only bottom-tier occurrence of that identity with nothing to replace it there (caught in PR #127 review). A chord group that resolves to a named identity but carries no notes (anotes: []chord, reachable from a GP import with an out-of-range chord id or a fully-muted template) is excluded from landmark/resolution candidacy entirely, so it can never silently occupy an identity's kept slot while contributing nothing to the tier (also caught in review). Separately, always protects the phrase's own final, note-bearing bottom-tier chord group from being dropped regardless of duration (the resolution- protection rule below). Unnamed/unidentified partial voicings (no matchedChordTemplate, a template with noname, or a non-stringnamefrom a hand-edited pack) are never collapsed —_resolvable_chord_identityrequires a positively-identified parent chord before a group is even a drop candidate. A short, difficult passing/transition chord (the brief Bmadd11 in So Far Away Rhythm is the example) is a drop candidate whenever another bottom-tier occurrence of the same identity sustains as long or longer — including when the short chord is the LATER of the two, and including an exact sustain tie, which the landmark rule gives to the earlier occurrence. The only group besides that landmark that is never dropped is the phrase's own final, note-bearing bottom-tier chord group. This lands only the bottom tier's group selection; every tier above it shows every occurrence, going through the same voicing/technique reduction as when the setting is off. Known approximation: resolution protection is positional (the phrase's last bottom-tier chord group), not harmonic — a short mid- phrase cadence resolution elsewhere in the phrase isn't specially protected, since the wire data has no way to express "this is a resolution." Flagged as a residual risk to settle before this ever gets a settings.html checkbox, not a defect in this PR's own scope. - In a rhythm-led passage such as Bring Me to Life guitar, use the staged order: chord landmarks; then every authored strum onset played as one note; then existing easy/intermediate voicing material; then complete rhythm and fingering without bends, vibrato, or harmonic effects; then vibrato; then bends; then the unchanged authored performance (which adds harmonics). Omit a technique stage when no notes in that phrase use it. The one-note rhythm stage must preserve onset timing; it must not thin away the groove. Keep full landmark chords at their selected onsets in later rhythm stages so the ladder does not discard earlier content.
- Bend-free practice must preserve the pitch struck at note onset: ordinary bend-ups/round trips can lose the bend, while a pre-bend or release struck at the peak needs an equivalent higher fret when one exists. If no fret can produce that onset pitch, keep the authored bend instead of making a wrong-pitched easy note. Vibrato may be removed independently without changing the onset pitch. In the Bring Me to Life preview, Lead has vibrato but no bends; Rhythm has both, so only Rhythm receives a distinct bend-introduction tier.
- The harmonic-fingering stage removes only natural/pinch harmonic effects
(
hm/hp), retaining their authored string, fret, and onset. A different sounding pitch is acceptable at this specific practice stage so the player can learn the fingering before the effect. This does not relax the pitch-preserving rule for bend, slide, or other simplifications, and the highest tier always retains the original harmonic effects. - Whether silent/muted
Xstrums should form their own stage remains open; do not automatically drop them from an existing ladder on this proposal's authority. Chordr output, group boundaries, resolution protection, and the audible/playable result need fixtures and review before implementation.
Also considered:
- Per-section custom difficulty override — let a section being looped in Section Practice carry its own difficulty %, independent of the song-wide master-difficulty slider. A genuinely new capability, not something the plugin does today; would need to interact cleanly with auto-adjust and with any step-practice plugin active for the same section, rather than duplicating it. (Measure-aligned fallback phrase windows, added for songs with no authored sections, make this more practical than it used to be — those songs previously only had blind 30s chunks to hang a per-section override on.)
- Per-technique player profile driving adaptive difficulty — go beyond a
passive per-instrument baseline (above) to a persisted, per-technique
proficiency profile (bends, pinch harmonics, slap/pop, vibrato, etc. —
the same vocabulary
_tech_score/_TECH_GATE_FRACnow model at generation time) built from live hit/miss judgments, then have auto-adjust weight a phrase's accuracy signal by how much that phrase leans on techniques the player is specifically weak or strong at, instead of today's flat hit-rate. A rough pre-bend streak wouldn't need to drag down a phrase's difficulty as much as an equally rough streak on a technique the player has never struggled with, and vice versa. Belongs entirely on the live side (auto-adjust's phrase-scoring weight inscreen.js), not as a per-player fork of/generate's output — generated ladders are written once into the shared sloppak file, not regenerated per player, so the generation heuristic itself should stay player-agnostic. The technique data needed to attribute a miss already exists on every note the highway streams (getFilteredNotes()/getChords()carry the samebn/ho/hp/slp/etc. flags_tech_scorereads), so no new capability contract would be required. Open questions: cold-start (a new song or a technique never seen before has no signal yet, so needs a neutral default weighting rather than an assumed weakness), and how per-technique weighting composes with the existing Sensitivity/Reaction speed settings and Resist isolated difficulty drops — replacing them, scaling them, or applying only as a tiebreaker. Meaningfully larger than the passive baseline above (its own persisted store beyond the existingsongMasterymap, plus new live-scoring logic) — closer in scope to the per-technique skill profile ruled out below, but aimed at adapting difficulty rather than only displaying it.
Not planned:
- Forced note-fading or any step-through/note-reveal UI — that's the
separate
step_modeplugin's job. - Real-time (sub-second) adjustment — would require a live per-note event stream; no such channel exists today short of reimplementing detection judgment.
- Confidence/pitch/timing-weighted scoring — the note-state provider
contract is hard-capped to
hit/active/miss; not something a consuming plugin can add unilaterally. - Generating dozens of levels per phrase — the cap is trivial to raise, but the thinning heuristic needs enough distinct note groups per phrase to populate that many meaningfully different tiers. (Richer per- technique scoring gives whatever depth is chosen a more honest score spread to work from, but doesn't touch this group-count constraint, so the verdict here is unchanged.)
- A full cross-song, per-technique skill profile — a materially larger
feature than the scoped per-instrument baseline above, and lower value
for a tool where users pick what to practice. (
_tech_score/_TECH_GATE_FRACnow cover the note wire format's full technique vocabulary — bend shape, both harmonic types, all four mute/pop/slap variants — instead of roughly half of it, so the data-layer cost of ever revisiting this decision is lower than it used to be. Still not something this plugin builds today.)
This plugin is a fresh, feedBack-native implementation. It does not port code or assumptions from the Slopsmith arrangement editor's own (differently scoped) difficulty-generation feature — Slopsmith and feedBack are separate apps with separate plugin contracts, and the two shouldn't be assumed compatible just because they share a similar plugin-loader lineage.