From 6c3998975dfdf541e5055a1dea662dabf582d46b Mon Sep 17 00:00:00 2001 From: Can Kilic Date: Sat, 3 Oct 2026 19:10:29 -0400 Subject: [PATCH 01/14] Add pure playability engine for no-barre voicings Pure TypeScript engine under engine/ (no React, Tone.js, DOM or runtime dependencies), deterministic and seeded: - pitch, tuning presets, mulberry32 PRNG, mm fret geometry - one shapeToMidi conversion for tabs and audio, plus RiffForge and Chord Explorer adapters (string 0 = lowest string everywhere) - personal hand profile with calibration, reach interpolation and pair limits (comfort guidance, not a safety assessment) - exhaustive finger assignment with barre detection, side-by-side finger spacing, reach and pair limits, physical cost breakdown - voicing families (power, sus/add, shells, quartal, clusters, phrygian, triads, drones), legacy recipes from library notes, relaxation ladder - honest naming: degree strings from sounding notes, confident symbols only - playability cost (physical, right hand, musical) and novelty axis - windowed voicing generator with diversification and named empty results - transition cost and exact minimax Viterbi over chord candidates Includes a property test over 12 roots x 20 families x {E Standard, Drop D} (100 percent coverage, every voicing barre-free, inside reach, audio equals tab) and a brute-force completeness check. Co-Authored-By: Claude Opus 5.5 --- engine/README.md | 174 +++++ engine/fingering.test.ts | 760 +++++++++++++++++++ engine/fingering.ts | 836 +++++++++++++++++++++ engine/generateVoicings.property.test.ts | 162 ++++ engine/generateVoicings.test.ts | 726 ++++++++++++++++++ engine/generateVoicings.ts | 892 +++++++++++++++++++++++ engine/geometry.test.ts | 71 ++ engine/geometry.ts | 50 ++ engine/handProfile.test.ts | 133 ++++ engine/handProfile.ts | 204 ++++++ engine/index.test.ts | 32 + engine/index.ts | 18 + engine/naming.test.ts | 368 ++++++++++ engine/naming.ts | 233 ++++++ engine/pitch.test.ts | 96 +++ engine/pitch.ts | 73 ++ engine/playability.test.ts | 460 ++++++++++++ engine/playability.ts | 457 ++++++++++++ engine/random.test.ts | 67 ++ engine/random.ts | 78 ++ engine/shape.test.ts | 94 +++ engine/shape.ts | 97 +++ engine/transition.test.ts | 788 ++++++++++++++++++++ engine/transition.ts | 490 +++++++++++++ engine/tuning.test.ts | 42 ++ engine/tuning.ts | 36 + engine/types.ts | 504 +++++++++++++ engine/voicingSpec.test.ts | 529 ++++++++++++++ engine/voicingSpec.ts | 510 +++++++++++++ engine/weights.ts | 69 ++ 30 files changed, 9049 insertions(+) create mode 100644 engine/README.md create mode 100644 engine/fingering.test.ts create mode 100644 engine/fingering.ts create mode 100644 engine/generateVoicings.property.test.ts create mode 100644 engine/generateVoicings.test.ts create mode 100644 engine/generateVoicings.ts create mode 100644 engine/geometry.test.ts create mode 100644 engine/geometry.ts create mode 100644 engine/handProfile.test.ts create mode 100644 engine/handProfile.ts create mode 100644 engine/index.test.ts create mode 100644 engine/index.ts create mode 100644 engine/naming.test.ts create mode 100644 engine/naming.ts create mode 100644 engine/pitch.test.ts create mode 100644 engine/pitch.ts create mode 100644 engine/playability.test.ts create mode 100644 engine/playability.ts create mode 100644 engine/random.test.ts create mode 100644 engine/random.ts create mode 100644 engine/shape.test.ts create mode 100644 engine/shape.ts create mode 100644 engine/transition.test.ts create mode 100644 engine/transition.ts create mode 100644 engine/tuning.test.ts create mode 100644 engine/tuning.ts create mode 100644 engine/types.ts create mode 100644 engine/voicingSpec.test.ts create mode 100644 engine/voicingSpec.ts create mode 100644 engine/weights.ts diff --git a/engine/README.md b/engine/README.md new file mode 100644 index 0000000..96423b9 --- /dev/null +++ b/engine/README.md @@ -0,0 +1,174 @@ +# RiffForge playability engine + +Pure TypeScript engine that answers two questions for one guitarist's hand: + +1. Which voicings of a chord color can I actually play here, without a barre, and how hard is each one? +2. Which metal rhythm patterns go with them, and how hard are the changes between them? + +No React, no Tone.js, no DOM, no Node APIs and no runtime dependencies. Everything is deterministic: +the same input gives the same output in the same order, and the only randomness is a seeded PRNG. +The engine is developed here and vendored into Chord Explorer (`lib/engine/`) by that repo's +`scripts/sync-engine.mjs`. Bump `ENGINE_VERSION` in `index.ts` on any behaviour change. + +The hand profile is comfort guidance, not a medical or safety assessment. Stop if you feel pain or tension. + +## Conventions + +- **String order:** index 0 is the lowest-pitched string, everywhere in the engine. +- **Tuning:** `{ id, name, openMidi }`, low to high. Presets: `E_STANDARD`, `DROP_D`, `D_STANDARD`, `DROP_C`, + `C_STANDARD`. `validateTuning` accepts 4..8 strictly ascending strings; the UI exposes the first two. +- **Shape:** `(number | null)[]` per string, `null` muted, `0` open. +- **Pitch:** `shapeToMidi(shape, tuning)` is the only shape -> pitch conversion (`openMidi[s] + fret`). Tabs and audio + must both come from it. Note names are spelled with sharps; flats are accepted when parsing (`Bb2`). +- **Adapters:** `toRiffForgeTab` / `fromRiffForgeTab` ("0 2 2 x x x", low -> high) and `toChordExplorerFrets` / + `fromChordExplorerFrets` (high -> low, `-1` muted). +- **Imports:** relative only. Never the `@/` alias (it means something else in Chord Explorer). +- **Narrowing:** the apps' tsconfig is not `strict`, so `if (!result.ok)` does not narrow the result unions. + Write `result.ok === false` / `=== true`. + +## Modules + +| File | Purpose | +|---|---| +| `types.ts` | Shared types (shapes, profiles, fingerings, costs, families, generation, transitions, rhythm). | +| `pitch.ts` | Pitch classes, MIDI <-> names, interval names, degree labels (`1 b2 2 b3 3 4 b5 5 #5 6 b7 7`). | +| `tuning.ts` | Tuning presets and validation. | +| `random.ts` | `mulberry32`, `seedFromString` (FNV-1a), `createRng` (int, chance, pick, weighted, shuffle, fork). | +| `geometry.ts` | Fret wire distance `d(n) = L - L / 2^(n/12)`, fingertip model, spans, reachable frets, scale presets. | +| `shape.ts` | `shapeToMidi` and shape helpers (sounding/fretted/open strings, interior mutes, fret range), adapters. | +| `handProfile.ts` | `HandProfile`, defaults, calibration conversion, `maxReachMm`, pair limits, validation, migration, hash. | +| `weights.ts` | `PLAYABILITY_WEIGHTS`: every cost weight with the reasoning for its default. | +| `fingering.ts` | Exhaustive finger assignment: barre detection, finger order, reach and pair limits, physical cost. | +| `voicingSpec.ts` | Voicing families (interval recipes), drone variants, UI groups, `matchFamily`, legacy recipes, relaxation ladder. | +| `naming.ts` | Honest degree strings, confident-only chord symbols, descriptive names. | +| `playability.ts` | Full cost (physical, right hand, musical), novelty score, human cost explanations. | +| `generateVoicings.ts` | Windowed, pruned search for playable voicings; diversification; empty-result diagnosis. | +| `transition.ts` | Transition cost between fingered shapes; exact minimax Viterbi; `optimizeSequence`. | +| `rhythm/` | Rhythm grid math, style presets, seeded generator, variation, editing helpers, validation, playback and MIDI note events. | +| `index.ts` | Public barrel and `ENGINE_VERSION`. | + +## Main entry points + +```ts +generateVoicings({ root, family, tuning, profile, distortion, context, musical, limit, sort, seed, referenceShapes, diversify }) + -> { voicings: GeneratedVoicing[]; stats; empty?: { constraint, message } } +findClosestVoicing(params) // exact recipe, then the relaxation ladder; { result, relaxed, exactEmpty? } +findBestFingering(shape, profile) // -> { ok: true, fingering } | { ok: false, reason, detail } +classifyShape(shape, profile) // needs-barre classification for curated shapes +fromLegacyNotes(notes, baseRoot) // RiffForge JSON `notes` -> recipe +nameVoicing(shape, tuning, rootPc) // degrees, symbol, name +scoreVoicing / explainCost(breakdown, fingering, profile) / noveltyScore +resolveFamilyId(id) // registered and relaxed ('power5_b2~anyBass') family ids +transitionCost / optimizeSequence(candidates, timing, { profile }) / describeTransition +generateRhythm / mutateRhythm / regenerateRhythm / validateRhythm / setSlotCount / buildGroups +toggleHit / cycleAccent / togglePalmMute / clearHit / eventAtUnit / eventsInUnit +rhythmToPlaybackEvents / rhythmToMidiNotes +profileFromCalibration / calibrationFromProfile / validateHandProfile / migrateHandProfile / handProfileHash +``` + +## How generation works + +1. For every window anchored at its lowest fretted fret `w` (1..maxFret), the window reaches up to + `highestReachableFret(w, maxReachMm(w) * stretchTolerance)`. One extra pass covers open-string-only shapes. +2. Each string can be muted, open (if allowed and its pitch class fits) or fretted inside the window on an allowed + pitch class. +3. Depth-first search with pruning: missing required pitch classes vs strings left, note count, fretted notes vs + available fingers (no-barre), interior mutes, and a structural lower bound on the fingers a shape needs. +4. Leaves go through `matchFamily` (exact pitch classes, nothing outside required + optional, bass by pitch, drone + rule), then `findBestFingering`, then `scoreVoicing`. Above `LAZY_SEARCH_MIN_SHAPES` (2048) feasible shapes the + fingering search is lazy and exact (same voicings, partial fingering-stage counts in `stats`). +5. Candidates are sorted by cost (`easiest`) or novelty (`unusual`), then picked greedily for diversity (+0.6 per + already-picked voicing on the same string set within 2 frets). Results come back in that pick order, so a later + voicing can be cheaper than an earlier one when the earlier one opened a new position or string set. +6. An empty result runs a wider diagnostic search and names the rule that blocks the most nearly-valid shapes + (`REACH`, `NEEDS_BARRE`, `NO_DRONE_STRING`, ...). + +Fingering rules: each fretted note needs a finger (1 index .. 4 pinky; thumb 0 only when allowed, lowest string +only). In no-barre mode every finger presses exactly one string, so more than 4 fretted notes is `NEEDS_BARRE`. +A barre is detected from the assignment (one finger on 2+ strings), never from a name. Lower-numbered fingers may +not sit on higher frets than higher-numbered ones. The fingertip span must fit `maxReachMm(lowestFret) * +stretchTolerance`, and each consecutive finger pair must fit its share of that span. + +## Assumptions + +Defaults chosen where the spec left room. Change them deliberately; most have tests. + +- Scale length defaults to 25.5" (647.7 mm). The fingertip sits 70 percent of the way from the previous fret wire. +- Default reach = fingertip span index fret 1 -> pinky fret 4 (99.0 mm) and index fret 7 -> pinky fret 11 (90.8 mm), + linear in between and constant outside. +- Pair limits as fractions of the allowed span: index-middle 0.65, middle-ring 0.35, ring-pinky 0.40. Non-adjacent + fingers get the sum of the limits between them. The original plan said index-middle 0.45, but that rejected + chord-book no-barre shapes such as `x 3 5 5 5 x` and `x 5 7 7 7 x` (index two frets behind the middle finger), so + it was raised. With these defaults middle (fret 1) and ring (fret 2) are 0.3 mm over their limit, so first-position + shapes sometimes use the pinky instead of the ring finger. +- Fretting fingers sit side by side along the neck in hand order, each inside its own fret space, at least 12.5 mm + apart (`MIN_FINGER_SPACING_MM`). On a 25.5" scale three fingers share one fret only up to fret 7 and four never do; + a shape that needs more is `NEEDS_BARRE` (a barre in disguise). +- The pair-stretch cost measures stretch beyond the relaxed one-fret-per-finger spacing, not the raw ratio to the + limit; the raw ratio would make the ordinary hand frame look maximally hard. +- Thumb (only when `allowThumb`): lowest string only, within one fret of the lowest finger, excluded from reach, pair + limits and finger order. +- Two-string partial barres (only when `allowTwoStringPartialBarre`) need adjacent strings at the same fret. +- Barre mode (only when `noBarre` is false, used to classify curated shapes): the index barres at the shape's lowest + fret over a contiguous range whose ends are at that fret and that contains no open or muted string. Only when no + fingering exists that way, fingers 2-4 may also flatten over 2-3 adjacent strings at one fret (A-shape barres). +- `classifyShape(...).needsBarre` is true only when the shape fails without a barre because it needs one, not when it is + out of reach for other reasons. +- Families: triads, sus2/sus4 and add9/m(add9) allow up to 6 notes so open-string voicings such as `0 2 2 1 0 0` and + `0 2 4 0 0 0` are included; shells, quartal and clusters cap at 4; power5, dyad_b2, tritone and fourth at 4; + power5_b2 at 5; phrygian at 6. +- Drone variants (power5, fourth, sus2, m(add9), quartal, phrygian) require an open string sounding the root or fifth, + and the fifth only counts when the recipe allows it. Without such a string the result is empty with `NO_DRONE_STRING`. +- Legacy recipes (`fromLegacyNotes`): pitch classes of the JSON `notes` relative to `baseRoot`, the lowest parsed note + as the bass interval, note count capped to 6. The curated `fretboard` strings are not trusted (most do not sound + their own `notes`). +- Relaxation ladder (cumulative): any bass note, then no 5th, then no extensions (9/11/13), then any note count. + Musical relaxations only; the hand profile is never relaxed behind the player's back. A step is skipped when it would + leave an invertible dyad over a free bass (inverting a dyad changes its only interval), so power chords, dyads, + sus and triads mostly fall back to "any note count" or stay empty with their binding constraint. +- Chord symbols only for confident template matches. `(no5)` only for a perfect fifth, `(no3)` only for `7` and + `maj7`, `(no root)` only when the caller's root is absent; any omission still needs 3 sounding pitch classes. + Dyads and clusters get descriptive names (`E + F (minor 2nd)`, `E cluster (1 b2 2)`). An inverted dyad names the + interval that sounds from the bass up (`E over A (perfect 5th)`). The words "cluster" and "Quartal" are only used + when the sounding notes really contain a close second or stacked fourths. +- `explainCost(..., profile)` reports reach as a share of the comfortable reach (stretch tolerance excluded) and adds + "(a stretch)" above 100 percent. Without a profile it says "percent of your allowed reach". +- Novelty compares relative fret patterns against common open/CAGED/power shapes (and optional references); moving + shapes match at any position, open-string shapes only as written. It is a ranking axis, never part of difficulty. +- Transition cost = finger travel along the neck, string changes, fingers lifted and placed, slides (discounted) and + hand shift; anchors (same finger, string and fret) discount the whole change by up to 50 percent. It is divided by + the time available (`beats * 60 / bpm`, floored at 0.05 s). Thumbs follow the hand and are ignored per finger. +- `optimizeSequence` minimizes the hardest transition first (exact two-phase minimax), then the sum; it treats the + riff as a loop by default. With `{ profile }` it also chooses each slot's fingering together with its neighbours + (the attached fingering or the cheapest valid one per other finger set) and returns them in `fingerings`. +- Rhythm: 480 PPQ; 16th = 120 ticks, 16th triplet = 80, 32nd = 60. One monophonic event stream; rests are gaps. + Alternate picking follows the 16th grid (even down, odd up) for plain 16ths. A run faster than 16ths (32nds, + sixteenth triplets) follows its own subdivision (tick/60 or tick/80 parity, bar-relative), so runs alternate + strictly, gaps leave ghost strokes and every beat start is a downstroke; isolated eighth-note triplets alternate by + event. `halftimeStomp` defaults to downstrokes. +- Rhythm harmony: slots change only on accented hits, and every slot is used when there are enough accents. + `regenerateRhythm` keeps the locked bars' events and their sound (entry slot, ties of pushes) and changes harmony only + on accents across the merge. Grid edits act on whole cells (a 32nd tremolo cell is one cell). +- Rhythm grouping `even`: one group per beat in x/4; odd x/8 as 2+2+...+3 eighths (7/8 = 4+4+6 sixteenths). + `fiveOverFour` repeats a 5-sixteenth [3,2] cycle across barlines. +- Rhythm playback: base velocity 0.7, accent +0.12 / +0.22, palm mute x0.75 velocity and 35 percent duration, dead + notes 0.3 velocity and at most 30 ms, ties extend the sounding note. Pedal hits use the active slot's pedal note, + falling back to its lowest sounding note. +- `tooFastForProfile` is a warning only: it fires when the fastest attack rate at the given BPM exceeds the profile's + comfortable sixteenth-note tempo. + +## Heuristics that are not research-backed + +- All cost weights in `weights.ts` and `TRANSITION_WEIGHTS` (their relative order follows ISMIR 2023: finger + positioning highest, right-hand skips next, change speed lowest). +- Pair-limit fractions, the 70 percent fingertip position, 10.5 mm string spacing, the 12.5 mm side-by-side finger + spacing, the thumb window, the 0.6 diversity penalty, the low-mud threshold (E3, intervals under a perfect fourth), + the anchor discount cap, and every rhythm style rate (accent, push, variation, dead-note and slot-coverage rules). +- No peer-reviewed millimetre comfort limit was found, which is why reach comes from calibration. + +## Tests + +`npx vitest run engine` covers every module, including a property test over 12 roots x 20 base families x +{E Standard, Drop D} (every voicing matches its recipe, has a no-barre fingering inside reach and pair limits, and +sounds exactly its tab), drone coverage, determinism, a brute-force completeness check for the search, and a +generation time budget. diff --git a/engine/fingering.test.ts b/engine/fingering.test.ts new file mode 100644 index 0000000..27cb808 --- /dev/null +++ b/engine/fingering.test.ts @@ -0,0 +1,760 @@ +import { describe, it, expect } from 'vitest'; +import { + findBestFingering, + enumerateFingerings, + physicalCostBreakdown, + classifyShape, + FINGER_NAMES, + MIN_FINGER_SPACING_MM, + THUMB_FRET_WINDOW +} from './fingering'; +import { DEFAULT_HAND_PROFILE, allowedSpanMm, pairLimitFraction } from './handProfile'; +import { fingertipMm, fretWireMm, spanMm } from './geometry'; +import { fromRiffForgeTab } from './shape'; +import { createRng } from './random'; +import { PHYSICAL_TERMS, PLAYABILITY_WEIGHTS } from './weights'; +import { generateVoicings } from './generateVoicings'; +import { E_STANDARD, DROP_D } from './tuning'; +import type { Fingering, FingeringMetrics, FingeringResult, FingerNumber, HandProfile, Shape } from './types'; + +const P = DEFAULT_HAND_PROFILE; +const L = P.scaleLengthMm; +const BARRE: HandProfile = { ...P, noBarre: false }; +const PARTIAL: HandProfile = { ...P, allowTwoStringPartialBarre: true }; +const THUMB: HandProfile = { ...P, allowThumb: true }; + +const tab = (text: string): Shape => { + const shape = fromRiffForgeTab(text); + if (!shape) throw new Error(`bad tab ${text}`); + return shape; +}; + +const valid = (result: FingeringResult): Fingering => { + if (result.ok === false) throw new Error(`expected a fingering, got ${result.reason}: ${result.detail}`); + return result.fingering; +}; + +const reasonOf = (result: FingeringResult): string => (result.ok === false ? result.reason : 'OK'); + +const best = (text: string, profile: HandProfile = P): Fingering => valid(findBestFingering(tab(text), profile)); + +/** + * Reference for the side-by-side rule: fretting fingers (thumb excluded) in hand order 1..4 each press inside + * their own fret space, at least MIN_FINGER_SPACING_MM apart along the neck. + */ +const fitsSideBySide = (fretsInHandOrder: readonly number[], scaleLengthMm: number = L): boolean => { + let pos = -Infinity; + for (const fret of fretsInHandOrder) { + pos = Math.max(fretWireMm(fret - 1, scaleLengthMm), pos + MIN_FINGER_SPACING_MM); + if (pos > fretWireMm(fret, scaleLengthMm) + 1e-9) return false; + } + return true; +}; + +/** Frets of the distinct fretting fingers 1..4 in hand order, from a fingering. */ +const handOrderFrets = (shape: Shape, fingers: readonly (FingerNumber | null)[]): number[] => { + const fretOf = new Map(); + fingers.forEach((finger, s) => { + if (finger !== null && finger > 0) fretOf.set(finger, shape[s]!); + }); + return [...fretOf.keys()].sort((a, b) => a - b).map((finger) => fretOf.get(finger)!); +}; + +const fretWidth = (fret: number): number => fretWireMm(fret, L) - fretWireMm(fret - 1, L); + +describe('validation order', () => { + it('rejects malformed shapes as INVALID_SHAPE', () => { + expect(reasonOf(findBestFingering([], P))).toBe('INVALID_SHAPE'); + expect(reasonOf(findBestFingering([null, null, null, null, null, null], P))).toBe('INVALID_SHAPE'); + expect(reasonOf(findBestFingering([0, 1.5, 2, null, null, null], P))).toBe('INVALID_SHAPE'); + expect(reasonOf(findBestFingering([0, -1, 2, null, null, null], P))).toBe('INVALID_SHAPE'); + }); + + it('rejects frets above profile.maxFret as FRET_RANGE', () => { + expect(reasonOf(findBestFingering(tab('x 16 x x x x'), P))).toBe('FRET_RANGE'); + expect(reasonOf(findBestFingering(tab('x 16 x x x x'), { ...P, maxFret: 20 }))).toBe('OK'); + expect(reasonOf(findBestFingering(tab('x 15 x x x x'), P))).toBe('OK'); + }); + + it('rejects open strings when the profile disallows them', () => { + const closed = { ...P, allowOpenStrings: false }; + expect(reasonOf(findBestFingering(tab('0 2 2 1 0 0'), closed))).toBe('OPEN_STRINGS_DISALLOWED'); + expect(reasonOf(findBestFingering(tab('x 2 2 1 x x'), closed))).toBe('OK'); + }); + + it('checks INVALID_SHAPE, then FRET_RANGE, then OPEN_STRINGS_DISALLOWED', () => { + const closed = { ...P, allowOpenStrings: false }; + expect(reasonOf(findBestFingering(tab('0 16 x x x x'), closed))).toBe('FRET_RANGE'); + expect(reasonOf(findBestFingering([0, 16, -2, null, null, null], closed))).toBe('INVALID_SHAPE'); + }); + + it('gives a human detail sentence on every rejection', () => { + for (const shape of [tab('x 16 x x x x'), tab('x 1 x x 6 x'), tab('3 5 5 4 3 x'), [] as Shape]) { + const result = findBestFingering(shape, P); + expect(result.ok).toBe(false); + if (result.ok === false) expect(result.detail.length).toBeGreaterThan(5); + } + }); +}); + +describe('all-open shapes', () => { + it('are valid with no fingers, no barres and zero metrics', () => { + const f = best('0 0 0 0 0 0'); + expect(f.fingers).toEqual([null, null, null, null, null, null]); + expect(f.barres).toEqual([]); + expect(f.metrics).toEqual({ + frettedCount: 0, + fingerCount: 0, + spanMm: 0, + allowedSpanMm: 0, + reachRatio: 0, + pairs: [], + maxPairRatio: 0, + maxPairStretch: 0, + usesPinky: false, + usesThumb: false, + contortions: 0, + interiorMutes: 0, + lowestFret: null, + highestFret: null + }); + expect(f.costBreakdown.total).toBe(0); + }); + + it('still counts interior mutes from the shape', () => { + expect(best('0 x 0 x x x').metrics.interiorMutes).toBe(1); + }); +}); + +describe('known chord shapes (no-barre default profile)', () => { + it('open E major 0 2 2 1 0 0: index on G#, middle and ring on the 2nd fret', () => { + const f = best('0 2 2 1 0 0'); + expect(f.fingers).toEqual([null, 2, 3, 1, null, null]); + expect(f.barres).toEqual([]); + expect(f.metrics.fingerCount).toBe(3); + expect(f.metrics.frettedCount).toBe(3); + expect(f.metrics.usesPinky).toBe(false); + expect(f.metrics.contortions).toBe(0); + expect(f.metrics.lowestFret).toBe(1); + expect(f.metrics.highestFret).toBe(2); + }); + + it('C major x 3 2 0 1 0: classic 3-2-1 fingering', () => { + expect(best('x 3 2 0 1 0').fingers).toEqual([null, 3, 2, null, 1, null]); + }); + + it('Em(add9) 0 2 4 0 0 0 is valid without a barre', () => { + const f = best('0 2 4 0 0 0'); + expect(f.barres).toEqual([]); + expect(f.fingers).toEqual([null, 1, 3, null, null, null]); + }); + + it('a single note goes to the index (lexicographic tie-break among equal costs)', () => { + expect(best('x x x 5 x x').fingers).toEqual([null, null, null, 1, null, null]); + const all = enumerateFingerings(tab('x x x 5 x x'), P).map((f) => f.fingers[3]); + expect(all).toEqual([1, 2, 3, 4]); + }); + + it('rejects more than four fretted notes with NEEDS_BARRE', () => { + const result = findBestFingering(tab('3 5 5 4 3 x'), P); + expect(reasonOf(result)).toBe('NEEDS_BARRE'); + }); + + it('rejects the F barre 1 3 3 2 1 1 with NEEDS_BARRE by default', () => { + expect(reasonOf(findBestFingering(tab('1 3 3 2 1 1'), P))).toBe('NEEDS_BARRE'); + }); +}); + +describe('reach', () => { + it('rejects a wide shape x 1 x x 6 x with REACH and names the numbers', () => { + const result = findBestFingering(tab('x 1 x x 6 x'), P); + expect(reasonOf(result)).toBe('REACH'); + if (result.ok === false) expect(result.detail).toMatch(/span \d+ mm > allowed \d+ mm/); + }); + + it('accepts index fret 1 + pinky fret 4 (the calibrated span) and rejects fret 1 + fret 5', () => { + const f = best('x 1 x x 4 x'); + expect(f.fingers).toEqual([null, 1, null, null, 4, null]); + expect(f.metrics.reachRatio).toBeCloseTo(1, 9); + expect(reasonOf(findBestFingering(tab('x 1 x x 5 x'), P))).toBe('REACH'); + }); + + it('scales with the stretch tolerance', () => { + expect(reasonOf(findBestFingering(tab('x 5 x x 9 x'), P))).toBe('REACH'); + expect(reasonOf(findBestFingering(tab('x 5 x x 9 x'), { ...P, stretchTolerance: 1.1 }))).toBe('OK'); + }); + + it('ignores open strings for span and reach', () => { + const f = best('0 x x x 3 0'); + expect(f.metrics.spanMm).toBe(0); + expect(f.metrics.reachRatio).toBe(0); + expect(f.metrics.allowedSpanMm).toBeCloseTo(allowedSpanMm(P, 3), 9); + }); + + it('fills span and allowed span from the fingertip model', () => { + const f = best('0 2 2 1 0 0'); + expect(f.metrics.spanMm).toBeCloseTo(spanMm(1, 2, L), 9); + expect(f.metrics.allowedSpanMm).toBeCloseTo(allowedSpanMm(P, 1), 9); + expect(f.metrics.reachRatio).toBeCloseTo(spanMm(1, 2, L) / allowedSpanMm(P, 1), 9); + }); +}); + +describe('pair limits and stretch', () => { + it('rejects x 1 1 3 3 x with PAIR_STRETCH because the middle-ring pair binds', () => { + const result = findBestFingering(tab('x 1 1 3 3 x'), P); + expect(reasonOf(result)).toBe('PAIR_STRETCH'); + if (result.ok === false) expect(result.detail).toContain('middle-ring'); + expect(enumerateFingerings(tab('x 1 1 3 3 x'), P)).toEqual([]); + }); + + it('fills every pair with distance, limit, ratio, natural spacing and stretch', () => { + const f = best('x 3 2 0 1 0'); + const allowed = allowedSpanMm(P, 1); + expect(f.metrics.pairs.map((p) => [p.from, p.to])).toEqual([ + [1, 2], + [2, 3] + ]); + const [im, mr] = f.metrics.pairs; + expect(im.distanceMm).toBeCloseTo(fingertipMm(2, L) - fingertipMm(1, L), 9); + expect(im.limitMm).toBeCloseTo(pairLimitFraction(P, 1, 2) * allowed, 9); + expect(im.ratio).toBeCloseTo(im.distanceMm / im.limitMm, 9); + expect(im.naturalMm).toBeCloseTo(fingertipMm(2, L) - fingertipMm(1, L), 9); + expect(im.stretch).toBe(0); + expect(mr.limitMm).toBeCloseTo(pairLimitFraction(P, 2, 3) * allowed, 9); + expect(f.metrics.maxPairRatio).toBeCloseTo(Math.max(im.ratio, mr.ratio), 12); + expect(f.metrics.maxPairStretch).toBe(0); + }); + + it('computes stretch beyond the relaxed one-fret-per-finger spacing', () => { + // Forced 1-2-3-4 with the middle finger two frets above the index at fret 11. + const f = best('x 11 13 14 15 x', P); + expect(f.fingers).toEqual([null, 1, 2, 3, 4, null]); + const im = f.metrics.pairs[0]; + const natural = fingertipMm(12, L) - fingertipMm(11, L); + const distance = fingertipMm(13, L) - fingertipMm(11, L); + expect(im.naturalMm).toBeCloseTo(natural, 9); + expect(im.stretch).toBeCloseTo(Math.min(1, (distance - natural) / (im.limitMm - natural)), 9); + expect(im.stretch).toBeGreaterThan(0); + expect(f.metrics.maxPairStretch).toBeCloseTo(Math.max(...f.metrics.pairs.map((p) => p.stretch)), 12); + }); + + it('marks a cramped pair (closer than the relaxed spacing) as zero stretch', () => { + // Index fret 2 and pinky fret 4: natural spacing for 1 -> 4 is three frets, distance only two. + const f = valid(findBestFingering(tab('x 2 x x 4 x'), P)); + for (const pair of f.metrics.pairs) { + if (pair.distanceMm <= pair.naturalMm) expect(pair.stretch).toBe(0); + } + }); + + it('skips fingers by summing adjacent limits (index + pinky at the calibrated span)', () => { + const f = best('x 1 x x 4 x'); + expect(f.metrics.pairs).toHaveLength(1); + expect(f.metrics.pairs[0].limitMm).toBeCloseTo(pairLimitFraction(P, 1, 4) * allowedSpanMm(P, 1), 9); + }); +}); + +describe('contortion', () => { + it('counts a lower-numbered finger on a higher string at the same fret', () => { + const all = enumerateFingerings(tab('0 2 2 1 0 0'), P); + const crossed = all.find((f) => f.fingers.join() === [null, 3, 2, 1, null, null].join()); + expect(crossed).toBeDefined(); + expect(crossed!.metrics.contortions).toBe(1); + const term = crossed!.costBreakdown.terms.find((t) => t.key === 'contortion')!; + expect(term.normalized).toBeCloseTo(0.5, 12); + expect(term.cost).toBeCloseTo(0.5 * PLAYABILITY_WEIGHTS.contortion.weight, 12); + }); + + it('allows a forced contortion in barre mode (index barre on high strings, middle on string 0)', () => { + const f = best('3 4 4 0 3 3', BARRE); + expect(f.fingers).toEqual([2, 3, 4, null, 1, 1]); + expect(f.barres).toEqual([{ finger: 1, fret: 3, fromString: 4, toString: 5 }]); + expect(f.metrics.contortions).toBe(1); + }); +}); + +describe('fingers side by side along the neck', () => { + it('rejects four separate fingers stacked in one fret as NEEDS_BARRE (a barre in disguise)', () => { + for (const text of ['x 1 1 1 1 x', '1 1 1 1 x x', '8 8 8 8 x x', 'x 13 13 13 13 x']) { + const result = findBestFingering(tab(text), P); + expect(reasonOf(result)).toBe('NEEDS_BARRE'); + if (result.ok === false) expect(result.detail).toMatch(/side by side/); + } + }); + + it('fits three fingers in one fret only where the fret is wide enough', () => { + expect(best('x 0 2 2 2 0').fingers).toEqual([null, null, 1, 2, 3, null]); + expect(reasonOf(findBestFingering(tab('x 7 7 7 x x'), P))).toBe('OK'); + for (const text of ['x 10 10 10 x x', '13 13 13 x x x', 'x 15 15 15 x x']) { + expect(reasonOf(findBestFingering(tab(text), P))).toBe('NEEDS_BARRE'); + } + for (let fret = 1; fret <= 15; fret++) { + const fits = fretWidth(fret) >= 2 * MIN_FINGER_SPACING_MM; + expect(reasonOf(findBestFingering(tab(`x ${fret} ${fret} ${fret} x x`), P))).toBe(fits ? 'OK' : 'NEEDS_BARRE'); + } + }); + + it('never fits four fingers into one fret on a 25.5 inch scale', () => { + for (let fret = 1; fret <= 24; fret++) expect(fretWidth(fret)).toBeLessThan(3 * MIN_FINGER_SPACING_MM); + }); + + it('counts neighbouring frets: four fingers do not squeeze into two narrow frets', () => { + expect(reasonOf(findBestFingering(tab('x 14 14 15 15 x'), P))).toBe('NEEDS_BARRE'); + expect(best('x 2 2 3 3 x').fingers).toEqual([null, 1, 2, 3, 4, null]); + expect(best('x 12 13 14 15 x').fingers).toEqual([null, 1, 2, 3, 4, null]); + }); + + it('lets a barre take over where separate fingers do not fit', () => { + expect(best('x 13 13 13 13 x', BARRE).barres).toEqual([{ finger: 1, fret: 13, fromString: 1, toString: 4 }]); + // Drop D one-finger power chord high up the neck. + expect(reasonOf(findBestFingering(tab('10 10 10 x x x'), P))).toBe('NEEDS_BARRE'); + expect(best('10 10 10 x x x', BARRE).barres).toEqual([{ finger: 1, fret: 10, fromString: 0, toString: 2 }]); + }); + + it('does not let the generator stack more fingers in a fret than fit', () => { + const add9 = generateVoicings({ root: 8, family: 'add9', tuning: E_STANDARD, profile: P, limit: 12 }); + const shapes = add9.voicings.map((v) => v.shape.map((f) => (f === null ? 'x' : String(f))).join(' ')); + expect(shapes).not.toContain('x 1 1 1 1 x'); + expect(shapes).not.toContain('x 13 13 13 13 x'); + for (const tuning of [E_STANDARD, DROP_D]) { + for (const family of ['add9', 'quartal', 'major', 'sus2', 'power5']) { + for (let root = 0; root < 12; root++) { + for (const v of generateVoicings({ root, family, tuning, profile: P, limit: 12 }).voicings) { + expect(fitsSideBySide(handOrderFrets(v.shape, v.fingering.fingers))).toBe(true); + } + } + } + } + }); +}); + +describe('barre mode (noBarre = false)', () => { + it('accepts the F barre 1 3 3 2 1 1 with an index barre over strings 0..5', () => { + const f = best('1 3 3 2 1 1', BARRE); + expect(f.barres).toEqual([{ finger: 1, fret: 1, fromString: 0, toString: 5 }]); + expect(f.fingers).toEqual([1, 3, 4, 2, 1, 1]); + expect(f.metrics.fingerCount).toBe(4); + const barreTerm = f.costBreakdown.terms.find((t) => t.key === 'barre')!; + expect(barreTerm.raw).toBe(1); + expect(barreTerm.cost).toBeCloseTo(PLAYABILITY_WEIGHTS.barre.weight, 12); + }); + + it('never lets a barre cover an open or muted string', () => { + for (const text of ['3 4 4 0 3 3', '3 4 4 x 3 3']) { + for (const f of enumerateFingerings(tab(text), BARRE)) { + for (const b of f.barres) { + for (let s = b.fromString; s <= b.toString; s++) { + const fret = tab(text)[s]; + expect(fret).not.toBeNull(); + expect(fret).toBeGreaterThanOrEqual(b.fret); + } + } + } + } + }); + + it('keeps fingers off strings the barre already sounds', () => { + for (const f of enumerateFingerings(tab('1 3 3 2 1 1'), BARRE)) { + for (const b of f.barres) { + for (let s = b.fromString; s <= b.toString; s++) { + if (tab('1 3 3 2 1 1')[s] === b.fret) expect(f.fingers[s]).toBe(b.finger); + } + } + } + }); + + it('still prefers a barre-free fingering when one exists', () => { + expect(best('0 2 2 1 0 0', BARRE)).toEqual(best('0 2 2 1 0 0', P)); + }); + + it('rejects NEEDS_BARRE when neither the index barre nor a mini-barre leaves enough fingers', () => { + // No two adjacent strings share a fret above the index barre, so nothing can flatten. + expect(reasonOf(findBestFingering(tab('1 1 3 4 3 4'), BARRE))).toBe('NEEDS_BARRE'); + }); + + it('plays the A-shape barre at every position (index barre plus ring mini-barre or 2-3-4)', () => { + const ringMini = [null, 1, 3, 3, 3, 1]; + for (let n = 1; n <= 12; n++) { + const f = best(`x ${n} ${n + 2} ${n + 2} ${n + 2} ${n}`, BARRE); + expect(f.barres[0]).toEqual({ finger: 1, fret: n, fromString: 1, toString: 5 }); + expect([ringMini, [null, 1, 2, 3, 4, 1]]).toContainEqual(f.fingers); + // Where three separate fingers do not fit in fret n + 2, the ring must flatten. + if (!fitsSideBySide([n + 2, n + 2, n + 2])) { + expect(f.fingers).toEqual(ringMini); + expect(f.barres[1]).toEqual({ finger: 3, fret: n + 2, fromString: 2, toString: 4 }); + } + } + // Near the nut the two-fret index-middle gap of 2-3-4 is past the default pair limit. + expect(best('x 1 3 3 3 1', BARRE).fingers).toEqual(ringMini); + expect(reasonOf(findBestFingering(tab('x 5 7 7 7 5'), BARRE))).toBe('OK'); + }); + + it('flattens a non-index finger only when separate fingers cannot play the shape', () => { + // F barre: ring and pinky fit, so no mini-barre. + expect(best('1 3 3 2 1 1', BARRE).fingers).toEqual([1, 3, 4, 2, 1, 1]); + // Too many notes for separate fingers above a two-string index barre: the ring flattens. + const f = best('1 1 3 3 3 3', BARRE); + expect(f.barres[0]).toEqual({ finger: 1, fret: 1, fromString: 0, toString: 1 }); + expect(f.barres.slice(1).every((b) => b.finger !== 1)).toBe(true); + // Every enumerated mini-barre covers two or three adjacent strings that share its fret. + for (const shape of [tab('1 1 3 3 3 3'), tab('x 1 3 3 3 3'), tab('x 3 5 5 5 3')]) { + const all = enumerateFingerings(shape, BARRE); + expect(all.length).toBeGreaterThan(0); + for (const g of all) { + for (const b of g.barres.filter((x) => x.finger !== 1)) { + expect(b.toString - b.fromString).toBeGreaterThanOrEqual(1); + expect(b.toString - b.fromString).toBeLessThanOrEqual(2); + for (let s = b.fromString; s <= b.toString; s++) expect(shape[s]).toBe(b.fret); + } + } + } + }); + + it('never flattens a finger in no-barre mode', () => { + expect(reasonOf(findBestFingering(tab('x 3 5 5 5 3'), P))).toBe('NEEDS_BARRE'); + expect(reasonOf(findBestFingering(tab('x 13 13 13 x x'), P))).toBe('NEEDS_BARRE'); + }); + + it('combines an index barre with two-string partial barres when allowed', () => { + const f = best('1 1 3 3 3 3', { ...BARRE, allowTwoStringPartialBarre: true }); + expect(f.fingers).toEqual([1, 1, 3, 3, 4, 4]); + expect(f.barres).toEqual([ + { finger: 1, fret: 1, fromString: 0, toString: 1 }, + { finger: 3, fret: 3, fromString: 2, toString: 3 }, + { finger: 4, fret: 3, fromString: 4, toString: 5 } + ]); + expect(f.costBreakdown.terms.find((t) => t.key === 'barre')!.normalized).toBe(1); + }); + + it('still enforces reach in barre mode', () => { + expect(reasonOf(findBestFingering(tab('1 6 6 x 1 1'), BARRE))).toBe('REACH'); + }); +}); + +describe('two-string partial barre mode', () => { + it('lets one finger cover two adjacent strings at the same fret', () => { + const shape = tab('x 3 5 5 4 3'); + expect(reasonOf(findBestFingering(shape, P))).toBe('NEEDS_BARRE'); + const f = valid(findBestFingering(shape, PARTIAL)); + expect(f.fingers).toEqual([null, 1, 4, 4, 3, 2]); + expect(f.barres).toEqual([{ finger: 4, fret: 5, fromString: 2, toString: 3 }]); + expect(f.metrics.fingerCount).toBe(4); + }); + + it('does not allow non-adjacent strings to share a finger', () => { + expect(reasonOf(findBestFingering(tab('3 5 3 5 4 x'), PARTIAL))).toBe('NEEDS_BARRE'); + }); + + it('does not use a partial barre when separate fingers are cheaper', () => { + expect(best('x 2 2 x x x', PARTIAL).barres).toEqual([]); + }); +}); + +describe('thumb mode', () => { + it('lets the thumb fret string 0 so a fifth note fits (2 x 4 4 3 5)', () => { + const shape = tab('2 x 4 4 3 5'); + expect(reasonOf(findBestFingering(shape, P))).toBe('NEEDS_BARRE'); + const f = valid(findBestFingering(shape, THUMB)); + expect(f.fingers).toEqual([0, null, 2, 3, 1, 4]); + expect(f.metrics.usesThumb).toBe(true); + expect(f.metrics.fingerCount).toBe(5); + // Thumb excluded from reach: span runs from fret 3 to fret 5. + expect(f.metrics.spanMm).toBeCloseTo(spanMm(3, 5, L), 9); + expect(f.metrics.allowedSpanMm).toBeCloseTo(allowedSpanMm(P, 3), 9); + expect(f.metrics.pairs.every((p) => p.from >= 1)).toBe(true); + expect(f.costBreakdown.terms.find((t) => t.key === 'thumb')!.cost).toBeCloseTo(PLAYABILITY_WEIGHTS.thumb.weight, 12); + }); + + it(`keeps the thumb within ${THUMB_FRET_WINDOW} fret of the lowest finger`, () => { + const result = findBestFingering(tab('1 x 4 4 3 5'), THUMB); + expect(reasonOf(result)).toBe('NEEDS_BARRE'); + if (result.ok === false) expect(result.detail).toContain('thumb'); + }); + + it('uses the thumb when it relieves a long reach (2 x x 3 4 5)', () => { + const f = best('2 x x 3 4 5', THUMB); + expect(f.fingers).toEqual([0, null, null, 1, 2, 3]); + expect(best('2 x x 3 4 5', P).fingers).toEqual([1, null, null, 2, 3, 4]); + }); + + it('never puts the thumb on any string but string 0', () => { + for (const f of enumerateFingerings(tab('x 2 x 3 4 5'), THUMB)) { + expect(f.fingers.includes(0)).toBe(false); + } + }); +}); + +describe('physical cost breakdown', () => { + it('always lists the nine physical terms in PHYSICAL_TERMS order with weights.ts weights', () => { + for (const text of ['0 0 0 0 0 0', '0 2 2 1 0 0', 'x 3 2 0 1 0']) { + const { costBreakdown } = best(text); + expect(costBreakdown.terms.map((t) => t.key)).toEqual([...PHYSICAL_TERMS]); + for (const term of costBreakdown.terms) { + expect(term.bucket).toBe('physical'); + expect(term.weight).toBe(PLAYABILITY_WEIGHTS[term.key].weight); + expect(term.cost).toBeCloseTo(term.normalized * term.weight, 12); + expect(term.normalized).toBeGreaterThanOrEqual(0); + expect(term.normalized).toBeLessThanOrEqual(1); + } + const sum = costBreakdown.terms.reduce((acc, t) => acc + t.cost, 0); + expect(costBreakdown.total).toBeCloseTo(sum, 12); + expect(costBreakdown.physical).toBe(costBreakdown.total); + expect(costBreakdown.rightHand).toBe(0); + expect(costBreakdown.musical).toBe(0); + } + }); + + it('normalizes open E major as documented', () => { + const f = best('0 2 2 1 0 0'); + const n = Object.fromEntries(f.costBreakdown.terms.map((t) => [t.key, t.normalized])); + expect(n.reach).toBeCloseTo(f.metrics.reachRatio ** 2, 12); + expect(n.pairStretch).toBe(0); + expect(n.fingerCount).toBeCloseTo(0.75, 12); + expect(n.pinky).toBe(0); + expect(n.contortion).toBe(0); + expect(n.interiorMutes).toBe(0); + expect(n.position).toBeCloseTo(1 / 12, 12); + expect(n.thumb).toBe(0); + expect(n.barre).toBe(0); + }); + + it('maps raw metrics through the documented formulas and clamps to 0..1', () => { + const metrics: FingeringMetrics = { + frettedCount: 5, + fingerCount: 5, + spanMm: 50, + allowedSpanMm: 100, + reachRatio: 0.5, + pairs: [], + maxPairRatio: 0.9, + maxPairStretch: 0.4, + usesPinky: true, + usesThumb: true, + contortions: 3, + interiorMutes: 1, + lowestFret: 18, + highestFret: 20 + }; + const barres = [ + { finger: 1 as const, fret: 18, fromString: 0, toString: 1 }, + { finger: 3 as const, fret: 20, fromString: 2, toString: 3 } + ]; + const fingers: (FingerNumber | null)[] = [0, 1, 3, 3, 2, null]; + const b = physicalCostBreakdown([18, 18, 20, 20, 19, null], fingers, barres, metrics); + const byKey = Object.fromEntries(b.terms.map((t) => [t.key, t])); + expect(byKey.reach.normalized).toBeCloseTo(0.25, 12); + expect(byKey.pairStretch.normalized).toBeCloseTo(0.16, 12); + expect(byKey.fingerCount.raw).toBe(5); + expect(byKey.fingerCount.normalized).toBe(1); + expect(byKey.pinky.normalized).toBe(1); + expect(byKey.contortion.raw).toBe(3); + expect(byKey.contortion.normalized).toBe(1); + expect(byKey.interiorMutes.normalized).toBeCloseTo(0.5, 12); + expect(byKey.position.raw).toBe(18); + expect(byKey.position.normalized).toBe(1); + expect(byKey.thumb.normalized).toBe(1); + expect(byKey.barre.raw).toBe(2); + expect(byKey.barre.normalized).toBe(1); + }); + + it('uses position 0 when there is no fretted note', () => { + const metrics = best('0 0 0 0 0 0').metrics; + const b = physicalCostBreakdown([0, 0, 0, 0, 0, 0], new Array(6).fill(null), [], metrics); + expect(b.terms.find((t) => t.key === 'position')!.raw).toBe(0); + }); + + it('is the cost the best fingering minimizes', () => { + for (const text of ['0 2 2 1 0 0', 'x 3 2 0 1 0', 'x 5 7 7 6 x', '1 3 3 2 1 1']) { + for (const profile of [P, BARRE, PARTIAL, THUMB]) { + const all = enumerateFingerings(tab(text), profile); + if (all.length === 0) continue; + const result = valid(findBestFingering(tab(text), profile)); + expect(result).toEqual(all[0]); + for (let i = 1; i < all.length; i++) { + expect(all[i].costBreakdown.total).toBeGreaterThanOrEqual(all[i - 1].costBreakdown.total); + } + for (const f of all) { + expect(f.costBreakdown).toEqual(physicalCostBreakdown(tab(text), f.fingers, f.barres, f.metrics)); + } + } + } + }); +}); + +describe('classifyShape', () => { + it('classifies the F barre as needing a barre with an index barre fingering', () => { + const c = classifyShape(tab('1 3 3 2 1 1'), P); + expect(c.needsBarre).toBe(true); + expect(reasonOf(c.noBarre)).toBe('NEEDS_BARRE'); + expect(valid(c.withBarre).barres).toEqual([{ finger: 1, fret: 1, fromString: 0, toString: 5 }]); + }); + + it('classifies open shapes as barre-free in both modes', () => { + const c = classifyShape(tab('0 2 2 1 0 0'), BARRE); + expect(c.needsBarre).toBe(false); + expect(valid(c.noBarre).fingers).toEqual([null, 2, 3, 1, null, null]); + expect(valid(c.withBarre).barres).toEqual([]); + }); + + it('flags a shape that only a barre makes playable for this hand', () => { + const c = classifyShape(tab('x 1 1 3 3 x'), P); + expect(reasonOf(c.noBarre)).toBe('PAIR_STRETCH'); + expect(valid(c.withBarre).barres).toEqual([{ finger: 1, fret: 1, fromString: 1, toString: 2 }]); + expect(c.needsBarre).toBe(true); + }); + + it('does not blame a barre when the shape is simply out of reach', () => { + const c = classifyShape(tab('x 1 x x 6 x'), P); + expect(reasonOf(c.noBarre)).toBe('REACH'); + expect(reasonOf(c.withBarre)).toBe('REACH'); + expect(c.needsBarre).toBe(false); + }); + + it('classifies the A-shape barre x 3 5 5 5 3 as playable with a barre', () => { + const c = classifyShape(tab('x 3 5 5 5 3'), P); + expect(c.needsBarre).toBe(true); + expect(reasonOf(c.noBarre)).toBe('NEEDS_BARRE'); + expect(valid(c.withBarre).barres[0]).toEqual({ finger: 1, fret: 3, fromString: 1, toString: 5 }); + }); + + it('applies reach limits in barre mode too', () => { + const c = classifyShape(tab('1 6 6 x 1 1'), P); + expect(c.needsBarre).toBe(true); + expect(reasonOf(c.withBarre)).toBe('REACH'); + }); +}); + +describe('exhaustive search matches a brute-force reference (no-barre default profile)', () => { + // Independent reimplementation of the rules over all 4^k finger functions. + const reference = (shape: Shape, profile: HandProfile): { valid: string[]; reason: string } => { + const notes = shape.flatMap((f, s) => (f !== null && f > 0 ? [{ s, f }] : [])); + if (notes.length > 4) return { valid: [], reason: 'NEEDS_BARRE' }; + const out: string[] = []; + let crossingFree = false; + let fits = false; + let reachOk = false; + const total = 4 ** notes.length; + for (let code = 0; code < total; code++) { + const fingers = notes.map((_, i) => 1 + (Math.floor(code / 4 ** i) % 4)); + if (new Set(fingers).size !== fingers.length) continue; + const fretOf = new Map(fingers.map((finger, i) => [finger, notes[i].f])); + const order = [...fretOf.keys()].sort((a, b) => a - b); + if (order.some((finger, i) => i > 0 && fretOf.get(finger)! < fretOf.get(order[i - 1])!)) continue; + crossingFree = true; + if (!fitsSideBySide(order.map((finger) => fretOf.get(finger)!), profile.scaleLengthMm)) continue; + fits = true; + if (notes.length > 0) { + const lo = Math.min(...notes.map((n) => n.f)); + const hi = Math.max(...notes.map((n) => n.f)); + const allowed = allowedSpanMm(profile, lo); + if (spanMm(lo, hi, profile.scaleLengthMm) > allowed + 1e-9) continue; + reachOk = true; + const pairOk = order.every((finger, i) => { + if (i === 0) return true; + const a = order[i - 1]; + const distance = fingertipMm(fretOf.get(finger)!, profile.scaleLengthMm) - fingertipMm(fretOf.get(a)!, profile.scaleLengthMm); + const limit = pairLimitFraction(profile, a as FingerNumber, finger as FingerNumber) * allowed; + return distance / limit <= 1 + 1e-9; + }); + if (!pairOk) continue; + } else { + reachOk = true; + } + const perString = shape.map(() => 'x'); + notes.forEach((n, i) => (perString[n.s] = String(fingers[i]))); + out.push(perString.join('')); + } + const reason = + out.length > 0 ? 'OK' : !crossingFree ? 'FINGER_CROSSING' : !fits ? 'NEEDS_BARRE' : !reachOk ? 'REACH' : 'PAIR_STRETCH'; + return { valid: out.sort(), reason }; + }; + + const key = (f: Fingering): string => f.fingers.map((x) => (x === null ? 'x' : String(x))).join(''); + + it('finds exactly the legal assignments and the same rejection reason on 400 random shapes', () => { + const rng = createRng('fingering-reference'); + let validCount = 0; + for (let n = 0; n < 400; n++) { + const window = rng.int(1, 10); + const shape: (number | null)[] = Array.from({ length: 6 }, () => { + const r = rng.next(); + if (r < 0.35) return null; + if (r < 0.5) return 0; + return rng.int(window, window + 5); + }); + if (!shape.some((f) => f !== null)) shape[0] = 0; + const ref = reference(shape, P); + const all = enumerateFingerings(shape, P); + expect(all.map(key).sort()).toEqual(ref.valid); + expect(reasonOf(findBestFingering(shape, P))).toBe(ref.reason); + if (ref.reason === 'OK') validCount++; + } + // Make sure the sample exercises both outcomes. + expect(validCount).toBeGreaterThan(50); + expect(validCount).toBeLessThan(380); + }); + + it('never returns a barre, a repeated finger, a crossing or a broken limit in no-barre mode', () => { + const rng = createRng('fingering-invariants'); + for (let n = 0; n < 300; n++) { + const w = rng.int(1, 11); + const shape = Array.from({ length: 6 }, () => (rng.chance(0.4) ? null : rng.int(w, w + 4))); + if (shape.every((f) => f === null)) continue; + const result = findBestFingering(shape, P); + if (!result.ok) continue; + const { fingers, barres, metrics } = result.fingering; + expect(barres).toEqual([]); + const used = fingers.filter((f) => f !== null); + expect(new Set(used).size).toBe(used.length); + expect(used.every((f) => f! >= 1 && f! <= 4)).toBe(true); + const byFinger = fingers + .map((f, s) => ({ f, fret: shape[s] })) + .filter((x) => x.f !== null) + .sort((a, b) => a.f! - b.f!); + for (let i = 1; i < byFinger.length; i++) expect(byFinger[i].fret!).toBeGreaterThanOrEqual(byFinger[i - 1].fret!); + expect(metrics.reachRatio).toBeLessThanOrEqual(1 + 1e-9); + for (const pair of metrics.pairs) expect(pair.ratio).toBeLessThanOrEqual(1 + 1e-9); + expect(fitsSideBySide(byFinger.map((x) => x.fret!))).toBe(true); + } + }); +}); + +describe('determinism and naming', () => { + it('returns deep-equal results for repeated calls', () => { + for (const text of ['0 2 2 1 0 0', '1 3 3 2 1 1', 'x 1 1 3 3 x', 'x 3 5 5 4 3']) { + for (const profile of [P, BARRE, PARTIAL, THUMB]) { + expect(findBestFingering(tab(text), profile)).toEqual(findBestFingering(tab(text), profile)); + expect(enumerateFingerings(tab(text), profile)).toEqual(enumerateFingerings(tab(text), profile)); + } + } + }); + + it('names fingers 0..4', () => { + expect(FINGER_NAMES).toEqual(['thumb', 'index', 'middle', 'ring', 'pinky']); + }); +}); + +describe('performance', () => { + it('runs 10,000 findBestFingering calls on typical 3-4 note shapes in under 500 ms', () => { + const shapes = [ + '0 2 2 1 0 0', + 'x 3 2 0 1 0', + '0 2 4 0 0 0', + 'x 5 7 7 x x', + '3 5 5 x x x', + 'x x 7 9 10 x', + 'x 12 14 14 x x', + '5 x 7 7 5 x', + 'x 1 1 3 3 x', + 'x 7 9 9 8 x', + '3 x 4 5 x x', + 'x x 10 12 13 12' + ].map(tab); + for (let i = 0; i < 2000; i++) findBestFingering(shapes[i % shapes.length], P); + const start = performance.now(); + let valid = 0; + for (let i = 0; i < 10000; i++) if (findBestFingering(shapes[i % shapes.length], P).ok) valid++; + const elapsed = performance.now() - start; + expect(valid).toBeGreaterThan(0); + expect(elapsed).toBeLessThan(500); + }); +}); diff --git a/engine/fingering.ts b/engine/fingering.ts new file mode 100644 index 0000000..5c7bee8 --- /dev/null +++ b/engine/fingering.ts @@ -0,0 +1,836 @@ +// Finger assignment for one chord shape. The search is exhaustive and deterministic: every legal +// assignment of fingers to fretted notes is visited, hard limits come from the hand profile, and the +// cheapest assignment by physical cost wins (ties: lexicographically smallest fingers array). +// In barre mode a second pass lets a non-index finger flatten over 2-3 adjacent strings (a mini-barre, +// e.g. the ring finger in the A-shape barre chord), but only when no fingering exists without one. +import type { + Barre, + CostBreakdown, + CostTerm, + CostTermKey, + Fingering, + FingeringMetrics, + FingeringRejectReason, + FingeringResult, + FingerNumber, + HandProfile, + PairStretch, + Shape +} from './types'; +import { fingertipMm, fretWireMm } from './geometry'; +import { allowedSpanMm, pairLimitFraction } from './handProfile'; +import { interiorMutes } from './shape'; +import { PHYSICAL_TERMS, PLAYABILITY_WEIGHTS } from './weights'; + +export const FINGER_NAMES = ['thumb', 'index', 'middle', 'ring', 'pinky'] as const; + +/** + * Heuristic, not research-backed: a thumb wrapped over the neck frets string 0 at most this many + * frets behind or ahead of the lowest fretting finger. + */ +export const THUMB_FRET_WINDOW = 1; + +/** + * Heuristic, not research-backed: fretting fingertips sit side by side along the neck in hand order + * (index nearest the nut), each inside its own fret space, and two consecutive fingers need at least this + * much neck length between their contact points (adult fingertips are roughly 14-18 mm wide; staggering + * across strings saves a little). On a 25.5" scale three fingers share one fret only up to fret 7 and + * four never do: such stacks are barres in disguise and are rejected as NEEDS_BARRE. + */ +export const MIN_FINGER_SPACING_MM = 12.5; + +/** A flattened non-index finger (barre mode only) covers at most this many adjacent strings. */ +export const MAX_MINI_BARRE_STRINGS = 3; + +const EPS = 1e-9; +const FINGERS = 4; + +// Leaf outcomes, in rejection-priority order. +const OK = 0; +const THUMB_OUT_OF_WINDOW = 1; +const CROSSING = 2; +const CROWDED = 3; +const TOO_WIDE = 4; +const PAIR_OVER = 5; + +// --------------------------------------------------------------------------- +// Physical cost terms +// --------------------------------------------------------------------------- + +type TermInputs = Pick< + FingeringMetrics, + 'reachRatio' | 'maxPairStretch' | 'fingerCount' | 'usesPinky' | 'contortions' | 'interiorMutes' | 'lowestFret' | 'usesThumb' +>; + +const rawTerm = (key: CostTermKey, t: TermInputs, barreCount: number): number => { + switch (key) { + case 'reach': + return t.reachRatio; + case 'pairStretch': + return t.maxPairStretch; + case 'fingerCount': + return t.fingerCount; + case 'pinky': + return t.usesPinky ? 1 : 0; + case 'contortion': + return t.contortions; + case 'interiorMutes': + return t.interiorMutes; + case 'position': + return t.lowestFret ?? 0; + case 'thumb': + return t.usesThumb ? 1 : 0; + case 'barre': + return barreCount; + default: + return 0; + } +}; + +const clamp01 = (x: number): number => (x > 1 ? 1 : x > 0 ? x : 0); + +const normalizeTerm = (key: CostTermKey, raw: number): number => { + switch (key) { + case 'reach': + case 'pairStretch': + return clamp01(raw * raw); + case 'fingerCount': + return clamp01(raw / 4); + case 'contortion': + case 'interiorMutes': + return clamp01(raw / 2); + case 'position': + return clamp01(raw / 12); + default: + return clamp01(raw); + } +}; + +/** Same arithmetic and order as physicalCostBreakdown().total, without allocating terms. */ +const physicalTotal = (t: TermInputs, barreCount: number): number => { + let total = 0; + for (let i = 0; i < PHYSICAL_TERMS.length; i++) { + const key = PHYSICAL_TERMS[i]; + total += normalizeTerm(key, rawTerm(key, t, barreCount)) * PLAYABILITY_WEIGHTS[key].weight; + } + return total; +}; + +export const physicalCostBreakdown = ( + _shape: Shape, + _fingers: (FingerNumber | null)[], + barres: Barre[], + metrics: FingeringMetrics +): CostBreakdown => { + const terms: CostTerm[] = []; + let total = 0; + for (const key of PHYSICAL_TERMS) { + const raw = rawTerm(key, metrics, barres.length); + const normalized = normalizeTerm(key, raw); + const { bucket, weight } = PLAYABILITY_WEIGHTS[key]; + const cost = normalized * weight; + total += cost; + terms.push({ key, bucket, raw, normalized, weight, cost }); + } + return { total, physical: total, rightHand: 0, musical: 0, terms }; +}; + +// --------------------------------------------------------------------------- +// Search context +// --------------------------------------------------------------------------- + +interface SearchContext { + shape: Shape; + profile: HandProfile; + /** Fretted notes in ascending string order. */ + strings: number[]; + frets: number[]; + tip: Float64Array; + /** Fret wire positions in mm from the nut. */ + wire: Float64Array; + /** pairLimitFraction for fingers a < b, at index a * 5 + b. */ + pairFraction: number[]; + lowestFret: number | null; + highestFret: number | null; + interiorMutes: number; + thumbOnString0: boolean; + /** Lowest fretted fret when an index barre is allowed, otherwise -1. */ + indexBarreFret: number; + partialBarres: boolean; + /** Second pass in barre mode: fingers 2..4 may flatten over adjacent strings at their fret. */ + miniBarres: boolean; + /** Fingers that could fret a note: 4, or 5 with the thumb on string 0. */ + available: number; +} + +/** Per finger 0..4; `notes[f] === 0` means unused. */ +interface SearchState { + assign: number[]; + fret: number[]; + notes: number[]; + low: number[]; + high: number[]; +} + +interface Evaluation extends TermInputs { + status: number; + minFret: number; + maxFret: number; + spanMm: number; + allowedSpanMm: number; + maxPairRatio: number; + worstFrom: number; + worstTo: number; + worstDistance: number; + worstLimit: number; + barreCount: number; + /** CROWDED detail: the tightest run of fingers, its frets and the neck length it needs and has. */ + crowdCount: number; + crowdFromFret: number; + crowdToFret: number; + crowdNeedMm: number; + crowdHaveMm: number; +} + +let tableScale = -1; +let tipCache = new Float64Array(0); +let wireCache = new Float64Array(0); + +/** Fingertip and fret wire positions per fret, cached for the last scale length used. */ +const neckTables = (scaleLengthMm: number, highestFret: number): { tip: Float64Array; wire: Float64Array } => { + const size = highestFret + FINGERS + 1; + if (scaleLengthMm !== tableScale || tipCache.length < size) { + const n = Math.max(size, 40); + const tip = new Float64Array(n); + const wire = new Float64Array(n); + for (let f = 0; f < n; f++) { + tip[f] = fingertipMm(f, scaleLengthMm); + wire[f] = fretWireMm(f, scaleLengthMm); + } + tipCache = tip; + wireCache = wire; + tableScale = scaleLengthMm; + } + return { tip: tipCache, wire: wireCache }; +}; + +const reject = (reason: FingeringRejectReason, detail: string): FingeringResult => ({ ok: false, reason, detail }); + +type Prepared = { ok: true; ctx: SearchContext } | { ok: false; result: FingeringResult }; + +const prepare = (shape: Shape, profile: HandProfile): Prepared => { + if (!Array.isArray(shape as unknown) || shape.length < 1) { + return { ok: false, result: reject('INVALID_SHAPE', 'shape has no strings') }; + } + let sounding = 0; + for (let s = 0; s < shape.length; s++) { + const fret = shape[s]; + if (fret === null) continue; + if (typeof fret !== 'number' || !Number.isInteger(fret) || fret < 0) { + return { ok: false, result: reject('INVALID_SHAPE', `string ${s} has an invalid fret value`) }; + } + sounding++; + } + if (sounding === 0) return { ok: false, result: reject('INVALID_SHAPE', 'no string sounds') }; + + const strings: number[] = []; + const frets: number[] = []; + let lowest = Infinity; + let highest = -Infinity; + for (let s = 0; s < shape.length; s++) { + const fret = shape[s]; + if (fret === null || fret === 0) continue; + if (fret > profile.maxFret) { + return { ok: false, result: reject('FRET_RANGE', `fret ${fret} is above the max fret ${profile.maxFret}`) }; + } + strings.push(s); + frets.push(fret); + if (fret < lowest) lowest = fret; + if (fret > highest) highest = fret; + } + if (!profile.allowOpenStrings && shape.some((fret) => fret === 0)) { + return { ok: false, result: reject('OPEN_STRINGS_DISALLOWED', 'open strings are turned off in the hand profile') }; + } + + const pairFraction = new Array(25).fill(0); + for (let a = 1; a < FINGERS; a++) { + for (let b = a + 1; b <= FINGERS; b++) pairFraction[a * 5 + b] = pairLimitFraction(profile, a as FingerNumber, b as FingerNumber); + } + const hasFretted = strings.length > 0; + const string0 = shape[0]; + const thumbOnString0 = profile.allowThumb && string0 !== null && string0 > 0; + const { tip, wire } = neckTables(profile.scaleLengthMm, hasFretted ? highest : 0); + return { + ok: true, + ctx: { + shape, + profile, + strings, + frets, + tip, + wire, + pairFraction, + lowestFret: hasFretted ? lowest : null, + highestFret: hasFretted ? highest : null, + interiorMutes: interiorMutes(shape), + thumbOnString0, + indexBarreFret: !profile.noBarre && hasFretted ? lowest : -1, + partialBarres: profile.allowTwoStringPartialBarre, + miniBarres: false, + available: FINGERS + (thumbOnString0 ? 1 : 0) + } + }; +}; + +// --------------------------------------------------------------------------- +// Evaluation of one complete assignment +// --------------------------------------------------------------------------- + +const safeRatio = (value: number, limit: number): number => (limit > 0 ? value / limit : value > 0 ? Infinity : 0); + +const pairDistanceMm = (ctx: SearchContext, fretA: number, fretB: number): number => ctx.tip[fretB] - ctx.tip[fretA]; + +const pairLimitMm = (ctx: SearchContext, a: number, b: number, allowed: number): number => ctx.pairFraction[a * 5 + b] * allowed; + +/** Relaxed spacing: one fret per finger-number step from finger a's fret. */ +const naturalSpacingMm = (ctx: SearchContext, a: number, b: number, fretA: number): number => + ctx.tip[fretA + (b - a)] - ctx.tip[fretA]; + +const stretchBeyondNatural = (distance: number, natural: number, limit: number): number => { + if (distance <= natural) return 0; + return limit > natural ? Math.min(1, (distance - natural) / (limit - natural)) : 1; +}; + +const createEvaluation = (ctx: SearchContext): Evaluation => ({ + status: OK, + minFret: 0, + maxFret: 0, + spanMm: 0, + allowedSpanMm: 0, + reachRatio: 0, + maxPairRatio: 0, + maxPairStretch: 0, + worstFrom: 0, + worstTo: 0, + worstDistance: 0, + worstLimit: 0, + fingerCount: 0, + usesPinky: false, + usesThumb: false, + contortions: 0, + barreCount: 0, + crowdCount: 0, + crowdFromFret: 0, + crowdToFret: 0, + crowdNeedMm: 0, + crowdHaveMm: 0, + interiorMutes: ctx.interiorMutes, + lowestFret: ctx.lowestFret +}); + +/** + * Packs the fretting fingers (hand order, thumb excluded, frets non-decreasing) as far toward the nut as + * MIN_FINGER_SPACING_MM allows. Returns false and fills the CROWDED detail when one lands past its fret wire. + */ +const fitsSideBySide = (ctx: SearchContext, fret: readonly number[], notes: readonly number[], ev: Evaluation): boolean => { + let pos = -Infinity; + let runStart = 0; + let runCount = 0; + for (let f = 1; f <= FINGERS; f++) { + if (notes[f] === 0) continue; + const back = ctx.wire[fret[f] - 1]; + const packed = pos + MIN_FINGER_SPACING_MM; + if (packed <= back) { + pos = back; + runStart = fret[f]; + runCount = 1; + } else { + pos = packed; + runCount++; + } + if (pos > ctx.wire[fret[f]] + EPS) { + ev.crowdCount = runCount; + ev.crowdFromFret = runStart; + ev.crowdToFret = fret[f]; + ev.crowdNeedMm = (runCount - 1) * MIN_FINGER_SPACING_MM; + ev.crowdHaveMm = ctx.wire[fret[f]] - ctx.wire[runStart - 1]; + return false; + } + } + return true; +}; + +/** Checks the hard rules in rejection-priority order and fills the cost inputs of a legal assignment. */ +const evaluate = (ctx: SearchContext, st: SearchState, ev: Evaluation): void => { + const { fret, notes } = st; + let minFret = Infinity; + let maxFret = -Infinity; + let previous = -Infinity; + let crossing = false; + for (let f = 1; f <= FINGERS; f++) { + if (notes[f] === 0) continue; + const at = fret[f]; + if (at < previous) crossing = true; + previous = at; + if (at < minFret) minFret = at; + if (at > maxFret) maxFret = at; + } + const usesThumb = notes[0] > 0; + if (usesThumb && minFret !== Infinity && Math.abs(fret[0] - minFret) > THUMB_FRET_WINDOW) { + ev.status = THUMB_OUT_OF_WINDOW; + return; + } + if (crossing) { + ev.status = CROSSING; + return; + } + if (!fitsSideBySide(ctx, fret, notes, ev)) { + ev.status = CROWDED; + return; + } + + ev.usesThumb = usesThumb; + if (minFret === Infinity) { + ev.minFret = 0; + ev.maxFret = 0; + ev.spanMm = 0; + ev.allowedSpanMm = usesThumb ? allowedSpanMm(ctx.profile, fret[0]) : 0; + ev.reachRatio = 0; + } else { + ev.minFret = minFret; + ev.maxFret = maxFret; + ev.spanMm = ctx.tip[maxFret] - ctx.tip[minFret]; + ev.allowedSpanMm = allowedSpanMm(ctx.profile, minFret); + ev.reachRatio = safeRatio(ev.spanMm, ev.allowedSpanMm); + if (ev.spanMm > ev.allowedSpanMm + EPS) { + ev.status = TOO_WIDE; + return; + } + } + + let maxRatio = 0; + let maxStretch = 0; + ev.worstFrom = 0; + ev.worstTo = 0; + ev.worstDistance = 0; + ev.worstLimit = 0; + let a = 0; + for (let b = 1; b <= FINGERS; b++) { + if (notes[b] === 0) continue; + if (a !== 0) { + const distance = pairDistanceMm(ctx, fret[a], fret[b]); + const limit = pairLimitMm(ctx, a, b, ev.allowedSpanMm); + const ratio = safeRatio(distance, limit); + const stretch = stretchBeyondNatural(distance, naturalSpacingMm(ctx, a, b, fret[a]), limit); + if (ratio > maxRatio) { + maxRatio = ratio; + ev.worstFrom = a; + ev.worstTo = b; + ev.worstDistance = distance; + ev.worstLimit = limit; + } + if (stretch > maxStretch) maxStretch = stretch; + } + a = b; + } + ev.maxPairRatio = maxRatio; + ev.maxPairStretch = maxStretch; + if (maxRatio > 1 + EPS) { + ev.status = PAIR_OVER; + return; + } + + let fingerCount = 0; + let barreCount = 0; + for (let f = 0; f <= FINGERS; f++) { + if (notes[f] > 0) fingerCount++; + if (notes[f] > 1) barreCount++; + } + let contortions = 0; + for (let x = 1; x < FINGERS; x++) { + if (notes[x] === 0) continue; + for (let y = x + 1; y <= FINGERS; y++) { + if (notes[y] > 0 && fret[x] === fret[y] && st.low[x] > st.low[y]) contortions++; + } + } + ev.fingerCount = fingerCount; + ev.barreCount = barreCount; + ev.contortions = contortions; + ev.usesPinky = notes[FINGERS] > 0; + ev.status = OK; +}; + +const buildFingering = (ctx: SearchContext, st: SearchState, ev: Evaluation): Fingering => { + const { shape } = ctx; + const fingers = new Array(shape.length).fill(null); + for (let i = 0; i < ctx.strings.length; i++) fingers[ctx.strings[i]] = st.assign[i] as FingerNumber; + + const barres: Barre[] = []; + for (let f = 0; f <= FINGERS; f++) { + if (st.notes[f] > 1) barres.push({ finger: f as FingerNumber, fret: st.fret[f], fromString: st.low[f], toString: st.high[f] }); + } + + const pairs: PairStretch[] = []; + let a = 0; + for (let b = 1; b <= FINGERS; b++) { + if (st.notes[b] === 0) continue; + if (a !== 0) { + const distanceMm = pairDistanceMm(ctx, st.fret[a], st.fret[b]); + const limitMm = pairLimitMm(ctx, a, b, ev.allowedSpanMm); + const naturalMm = naturalSpacingMm(ctx, a, b, st.fret[a]); + pairs.push({ + from: a as FingerNumber, + to: b as FingerNumber, + distanceMm, + limitMm, + ratio: safeRatio(distanceMm, limitMm), + naturalMm, + stretch: stretchBeyondNatural(distanceMm, naturalMm, limitMm) + }); + } + a = b; + } + + const metrics: FingeringMetrics = { + frettedCount: ctx.strings.length, + fingerCount: ev.fingerCount, + spanMm: ev.spanMm, + allowedSpanMm: ev.allowedSpanMm, + reachRatio: ev.reachRatio, + pairs, + maxPairRatio: ev.maxPairRatio, + maxPairStretch: ev.maxPairStretch, + usesPinky: ev.usesPinky, + usesThumb: ev.usesThumb, + contortions: ev.contortions, + interiorMutes: ctx.interiorMutes, + lowestFret: ctx.lowestFret, + highestFret: ctx.highestFret + }; + return { fingers, barres, metrics, costBreakdown: physicalCostBreakdown(shape, fingers, barres, metrics) }; +}; + +// --------------------------------------------------------------------------- +// Exhaustive search +// --------------------------------------------------------------------------- + +interface Diagnostics { + /** Some assignment put the thumb outside its fret window. */ + thumbOutOfWindow: boolean; + structural: boolean; + crossingFree: boolean; + /** Some crossing-free assignment fits its fingers side by side. */ + fits: boolean; + /** Mildest overcrowding over all crossing-free assignments (NEEDS_BARRE detail). */ + crowdExcessMm: number; + crowdCount: number; + crowdFromFret: number; + crowdToFret: number; + crowdNeedMm: number; + crowdHaveMm: number; + reachOk: boolean; + /** Narrowest failing span (REACH detail). */ + reachRatio: number; + reachSpan: number; + reachAllowed: number; + reachMinFret: number; + reachMaxFret: number; + /** Mildest failing pair over all reach-legal assignments (PAIR_STRETCH detail). */ + pairRatio: number; + pairFrom: number; + pairTo: number; + pairDistance: number; + pairLimit: number; +} + +const createState = (k: number): SearchState => ({ + assign: new Array(k).fill(0), + fret: [0, 0, 0, 0, 0], + notes: [0, 0, 0, 0, 0], + low: [0, 0, 0, 0, 0], + high: [0, 0, 0, 0, 0] +}); + +/** + * May finger `f` (already placed) also press string `s` at `fret`? Strings are visited in ascending + * order, so st.high[f] is the highest string the finger covers so far. + */ +const canExtend = (ctx: SearchContext, st: SearchState, f: number, s: number, fret: number): boolean => { + if (f === 0 || st.fret[f] !== fret) return false; + if (ctx.partialBarres && st.notes[f] === 1 && st.high[f] === s - 1) return true; + if (ctx.miniBarres && f > 1 && st.notes[f] < MAX_MINI_BARRE_STRINGS && st.high[f] === s - 1) return true; + if (f !== 1 || fret !== ctx.indexBarreFret) return false; + // Index barre: every string it spans must be fretted, and strings at the barre fret are the barre's. + for (let t = st.high[1] + 1; t < s; t++) { + const between = ctx.shape[t]; + if (between === null || between === 0 || between === fret) return false; + } + return true; +}; + +const explore = (ctx: SearchContext, onValid: (st: SearchState, ev: Evaluation) => void): Diagnostics => { + const k = ctx.strings.length; + const st = createState(k); + const ev = createEvaluation(ctx); + const diag: Diagnostics = { + thumbOutOfWindow: false, + structural: false, + crossingFree: false, + fits: false, + crowdExcessMm: Infinity, + crowdCount: 0, + crowdFromFret: 0, + crowdToFret: 0, + crowdNeedMm: 0, + crowdHaveMm: 0, + reachOk: false, + reachRatio: Infinity, + reachSpan: 0, + reachAllowed: 0, + reachMinFret: 0, + reachMaxFret: 0, + pairRatio: Infinity, + pairFrom: 0, + pairTo: 0, + pairDistance: 0, + pairLimit: 0 + }; + + const visit = (): void => { + evaluate(ctx, st, ev); + if (ev.status === THUMB_OUT_OF_WINDOW) { + diag.thumbOutOfWindow = true; + return; + } + diag.structural = true; + if (ev.status === CROSSING) return; + diag.crossingFree = true; + if (ev.status === CROWDED) { + const excess = ev.crowdNeedMm - ev.crowdHaveMm; + if (excess < diag.crowdExcessMm) { + diag.crowdExcessMm = excess; + diag.crowdCount = ev.crowdCount; + diag.crowdFromFret = ev.crowdFromFret; + diag.crowdToFret = ev.crowdToFret; + diag.crowdNeedMm = ev.crowdNeedMm; + diag.crowdHaveMm = ev.crowdHaveMm; + } + return; + } + diag.fits = true; + if (ev.status === TOO_WIDE) { + if (ev.reachRatio < diag.reachRatio) { + diag.reachRatio = ev.reachRatio; + diag.reachSpan = ev.spanMm; + diag.reachAllowed = ev.allowedSpanMm; + diag.reachMinFret = ev.minFret; + diag.reachMaxFret = ev.maxFret; + } + return; + } + diag.reachOk = true; + if (ev.status === PAIR_OVER) { + if (ev.maxPairRatio < diag.pairRatio) { + diag.pairRatio = ev.maxPairRatio; + diag.pairFrom = ev.worstFrom; + diag.pairTo = ev.worstTo; + diag.pairDistance = ev.worstDistance; + diag.pairLimit = ev.worstLimit; + } + return; + } + onValid(st, ev); + }; + + const step = (i: number): void => { + if (i === k) { + visit(); + return; + } + const s = ctx.strings[i]; + const fret = ctx.frets[i]; + for (let f = s === 0 && ctx.thumbOnString0 ? 0 : 1; f <= FINGERS; f++) { + if (st.notes[f] === 0) { + st.fret[f] = fret; + st.notes[f] = 1; + st.low[f] = s; + st.high[f] = s; + st.assign[i] = f; + step(i + 1); + st.notes[f] = 0; + } else if (canExtend(ctx, st, f, s, fret)) { + const previousHigh = st.high[f]; + st.notes[f]++; + st.high[f] = s; + st.assign[i] = f; + step(i + 1); + st.notes[f]--; + st.high[f] = previousHigh; + } + } + }; + + step(0); + return diag; +}; + +/** Without any multi-string finger, more notes than fingers can be rejected before searching. */ +const tooManyNotes = (ctx: SearchContext): FingeringResult | null => { + const k = ctx.strings.length; + if (ctx.profile.noBarre && !ctx.partialBarres && k > ctx.available) { + return reject('NEEDS_BARRE', `${k} fretted notes but only ${ctx.available} fingers without a barre`); + } + return null; +}; + +/** Formats two millimetre values with just enough decimals to tell them apart. */ +const formatMmPair = (value: number, limit: number): [string, string] => { + for (let digits = 0; digits < 2; digits++) { + const a = value.toFixed(digits); + const b = limit.toFixed(digits); + if (a !== b) return [a, b]; + } + return [value.toFixed(2), limit.toFixed(2)]; +}; + +const rejection = (ctx: SearchContext, diag: Diagnostics): FingeringResult => { + const k = ctx.strings.length; + if (!diag.structural) { + if (diag.thumbOutOfWindow) { + return reject( + 'NEEDS_BARRE', + `${k} fretted notes need the thumb, but its fret ${ctx.shape[0]} is more than ${THUMB_FRET_WINDOW} fret from the lowest finger` + ); + } + return reject('NEEDS_BARRE', `${k} fretted notes do not fit ${ctx.available} fingers with the allowed barres`); + } + if (!diag.crossingFree) return reject('FINGER_CROSSING', 'every finger order needs crossed fingers'); + if (!diag.fits) { + const [need, have] = formatMmPair(diag.crowdNeedMm, diag.crowdHaveMm); + const frets = diag.crowdFromFret === diag.crowdToFret ? `fret ${diag.crowdToFret}` : `frets ${diag.crowdFromFret}-${diag.crowdToFret}`; + return reject( + 'NEEDS_BARRE', + `${diag.crowdCount} fingers do not fit side by side in ${frets}: need ${need} mm, have ${have} mm` + ); + } + if (!diag.reachOk) { + const [span, allowed] = formatMmPair(diag.reachSpan, diag.reachAllowed); + return reject('REACH', `span ${span} mm > allowed ${allowed} mm (frets ${diag.reachMinFret}-${diag.reachMaxFret})`); + } + const [distance, limit] = formatMmPair(diag.pairDistance, diag.pairLimit); + const pair = `${FINGER_NAMES[diag.pairFrom]}-${FINGER_NAMES[diag.pairTo]}`; + return reject('PAIR_STRETCH', `${pair} needs ${distance} mm > limit ${limit} mm`); +}; + +const lexLess = (a: readonly number[], b: readonly number[]): boolean => { + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] < b[i]; + return false; +}; + +const compareFingers = (a: readonly (FingerNumber | null)[], b: readonly (FingerNumber | null)[]): number => { + for (let i = 0; i < a.length; i++) { + const x = a[i] ?? -1; + const y = b[i] ?? -1; + if (x !== y) return x - y; + } + return a.length - b.length; +}; + +/** Rebuilds the search state for a recorded assignment (notes in ascending string order). */ +const replay = (ctx: SearchContext, assign: readonly number[]): SearchState => { + const st = createState(assign.length); + for (let i = 0; i < assign.length; i++) { + const f = assign[i]; + const s = ctx.strings[i]; + if (st.notes[f] === 0) { + st.fret[f] = ctx.frets[i]; + st.low[f] = s; + } + st.high[f] = s; + st.notes[f]++; + st.assign[i] = f; + } + return st; +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Runs the search; in barre mode, when nothing is valid, runs it again with mini-barres allowed (a superset + * of the first pass, so its diagnostics cover every assignment). + */ +const exploreWithFallback = (ctx: SearchContext, onValid: (st: SearchState, ev: Evaluation) => void): { diag: Diagnostics; found: boolean } => { + let found = false; + const record = (st: SearchState, ev: Evaluation): void => { + found = true; + onValid(st, ev); + }; + let diag = explore(ctx, record); + if (!found && !ctx.profile.noBarre && ctx.strings.length > 1) { + ctx.miniBarres = true; + diag = explore(ctx, record); + } + return { diag, found }; +}; + +export const findBestFingering = (shape: Shape, profile: HandProfile): FingeringResult => { + const prepared = prepare(shape, profile); + if (prepared.ok === false) return prepared.result; + const { ctx } = prepared; + const early = tooManyNotes(ctx); + if (early) return early; + + let found = false; + let bestCost = Infinity; + const bestAssign = new Array(ctx.strings.length).fill(0); + const { diag } = exploreWithFallback(ctx, (st, ev) => { + const cost = physicalTotal(ev, ev.barreCount); + if (!found || cost < bestCost || (cost === bestCost && lexLess(st.assign, bestAssign))) { + found = true; + bestCost = cost; + for (let i = 0; i < bestAssign.length; i++) bestAssign[i] = st.assign[i]; + } + }); + if (!found) return rejection(ctx, diag); + + const st = replay(ctx, bestAssign); + const ev = createEvaluation(ctx); + evaluate(ctx, st, ev); + return { ok: true, fingering: buildFingering(ctx, st, ev) }; +}; + +/** + * All valid assignments, best first (physical cost asc, then lexicographic fingers array). Mini-barre + * assignments appear only when no assignment without one is valid. + */ +export const enumerateFingerings = (shape: Shape, profile: HandProfile): Fingering[] => { + const prepared = prepare(shape, profile); + if (prepared.ok === false) return []; + const { ctx } = prepared; + if (tooManyNotes(ctx)) return []; + const out: Fingering[] = []; + exploreWithFallback(ctx, (st, ev) => { + out.push(buildFingering(ctx, st, ev)); + }); + return out.sort((x, y) => x.costBreakdown.total - y.costBreakdown.total || compareFingers(x.fingers, y.fingers)); +}; + +/** + * Classification for legacy/curated shapes. `noBarre` uses a copy of the profile with noBarre on, + * `withBarre` a copy with noBarre off (both keep the profile's partial-barre, thumb and reach settings). + * needsBarre is true only when a barre is what is missing: too many notes for one finger per string, + * more fingers in a fret than fit side by side, or no barre-free fingering while a barre fingering + * exists (mini-barres included). Shapes that fail for other reasons + * (out of reach in both modes, fret range, open strings) are not blamed on the barre. + */ +export const classifyShape = ( + shape: Shape, + profile: HandProfile +): { needsBarre: boolean; noBarre: FingeringResult; withBarre: FingeringResult } => { + const noBarre = findBestFingering(shape, { ...profile, noBarre: true }); + const withBarre = findBestFingering(shape, { ...profile, noBarre: false }); + const needsBarre = noBarre.ok === false && (noBarre.reason === 'NEEDS_BARRE' || withBarre.ok); + return { needsBarre, noBarre, withBarre }; +}; diff --git a/engine/generateVoicings.property.test.ts b/engine/generateVoicings.property.test.ts new file mode 100644 index 0000000..630c484 --- /dev/null +++ b/engine/generateVoicings.property.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import { generateVoicings } from './generateVoicings'; +import { findBestFingering } from './fingering'; +import { DEFAULT_HAND_PROFILE } from './handProfile'; +import { intervalFrom, pitchClass, pitchClassName } from './pitch'; +import { fromRiffForgeTab, shapeToMidi, toRiffForgeTab } from './shape'; +import { DROP_D, E_STANDARD } from './tuning'; +import { DRONE_FAMILIES, VOICING_FAMILIES, allowedIntervals, matchFamily } from './voicingSpec'; +import type { GenerateResult, Tuning, VoicingFamily } from './types'; + +const P = DEFAULT_HAND_PROFILE; +const TUNINGS: readonly Tuning[] = [E_STANDARD, DROP_D]; +const ROOTS = Array.from({ length: 12 }, (_, i) => i); +const LIMIT = 12; +const EPS = 1e-9; +const COVERAGE_TARGET = 0.95; +const FILE_BUDGET_MS = 10_000; +const MEDIAN_BUDGET_MS = 30; + +const fileStart = performance.now(); +const summary: Record = {}; + +const label = (tuning: Tuning, family: VoicingFamily, root: number): string => + `${tuning.id} ${family.id} ${pitchClassName(root)}`; + +/** (a) exact pitch classes, (b) barre-free fingering with distinct fingers, (c) reach and pair limits, (d) audio == tab. */ +const checkVoicings = (result: GenerateResult, family: VoicingFamily, root: number, tuning: Tuning): void => { + for (const v of result.voicings) { + const where = `${label(tuning, family, root)} ${toRiffForgeTab(v.shape)}`; + const match = matchFamily(family, root, v.shape, tuning); + expect(match, where).toEqual({ ok: true }); + + const fingering = findBestFingering(v.shape, P); + expect(fingering.ok, where).toBe(true); + if (fingering.ok === false) continue; + expect(fingering.fingering.barres, where).toEqual([]); + const used = fingering.fingering.fingers.filter((f) => f !== null); + expect(new Set(used).size, where).toBe(used.length); + expect(v.fingering).toEqual(fingering.fingering); + + const { metrics } = fingering.fingering; + expect(metrics.reachRatio, where).toBeLessThanOrEqual(1 + EPS); + expect(metrics.spanMm, where).toBeLessThanOrEqual(metrics.allowedSpanMm + EPS); + for (const pair of metrics.pairs) expect(pair.ratio, where).toBeLessThanOrEqual(1 + EPS); + + const viaTab = fromRiffForgeTab(toRiffForgeTab(v.shape)); + expect(viaTab, where).not.toBeNull(); + if (viaTab) expect(v.midi, where).toEqual(shapeToMidi(viaTab, tuning)); + } +}; + +/** An open string can carry the drone: root or 5th, allowed by the recipe, and not a forbidden bass on string 0. */ +const droneable = (family: VoicingFamily, root: number, tuning: Tuning): boolean => { + const allowed = new Set(allowedIntervals(family)); + return tuning.openMidi.some((midi, s) => { + const interval = intervalFrom(root, pitchClass(midi)); + if ((interval !== 0 && interval !== 7) || !allowed.has(interval)) return false; + return s !== 0 || family.bass === 'any' || family.bass.includes(interval); + }); +}; + +describe('generateVoicings property: base families x 12 roots x {E standard, Drop D}', () => { + it('returns only exact, barre-free, reachable voicings whose audio equals the tab, for >= 95 percent of combos', () => { + const empties: string[] = []; + let combos = 0; + let covered = 0; + let voicings = 0; + for (const tuning of TUNINGS) { + for (const family of VOICING_FAMILIES) { + for (const root of ROOTS) { + const result = generateVoicings({ root, family, tuning, profile: P, limit: LIMIT }); + combos++; + expect(result.voicings.length).toBeLessThanOrEqual(LIMIT); + checkVoicings(result, family, root, tuning); + voicings += result.voicings.length; + if (result.voicings.length > 0) { + covered++; + expect(result.empty).toBeUndefined(); + } else { + expect(result.empty?.constraint, label(tuning, family, root)).toBeDefined(); + expect(result.empty?.message.length).toBeGreaterThan(0); + empties.push(`${label(tuning, family, root)}: ${result.empty?.constraint} (${result.empty?.message})`); + } + } + } + } + const coverage = covered / combos; + summary.base = { combos, covered, coverage: Number(coverage.toFixed(4)), voicings, empties }; + expect(combos).toBe(12 * VOICING_FAMILIES.length * 2); + expect(coverage).toBeGreaterThanOrEqual(COVERAGE_TARGET); + }, 20_000); +}); + +describe('generateVoicings property: drone families', () => { + it('returns drones exactly where an open string can ring the root or 5th, else names the constraint', () => { + let combos = 0; + let covered = 0; + let expectedCovered = 0; + const mismatches: string[] = []; + const constraints: Record = {}; + for (const tuning of TUNINGS) { + for (const family of DRONE_FAMILIES) { + for (const root of ROOTS) { + const result = generateVoicings({ root, family, tuning, profile: P, limit: LIMIT }); + combos++; + checkVoicings(result, family, root, tuning); + for (const v of result.voicings) { + expect(v.tags).toContain('drone'); + expect(v.shape).toContain(0); + } + const expected = droneable(family, root, tuning); + if (expected) expectedCovered++; + if (result.voicings.length > 0) covered++; + else { + const constraint = result.empty?.constraint ?? 'MISSING'; + constraints[constraint] = (constraints[constraint] ?? 0) + 1; + if (!expected) expect(constraint, label(tuning, family, root)).toBe('NO_DRONE_STRING'); + } + if (expected !== result.voicings.length > 0) mismatches.push(`${label(tuning, family, root)}: ${result.empty?.constraint}`); + } + } + } + summary.drones = { combos, covered, expectedCovered, coverage: Number((covered / combos).toFixed(4)), emptyConstraints: constraints, mismatches }; + expect(mismatches).toEqual([]); + }, 20_000); +}); + +describe('generateVoicings performance', () => { + it('generates one root x family x tuning in under 30 ms (median)', () => { + for (let i = 0; i < 40; i++) { + generateVoicings({ root: i % 12, family: VOICING_FAMILIES[i % VOICING_FAMILIES.length], tuning: E_STANDARD, profile: P }); + } + const times: number[] = []; + for (const tuning of TUNINGS) { + for (const family of VOICING_FAMILIES) { + for (const root of ROOTS) { + const t0 = performance.now(); + generateVoicings({ root, family, tuning, profile: P, limit: LIMIT }); + times.push(performance.now() - t0); + } + } + } + times.sort((a, b) => a - b); + const median = times[Math.floor(times.length / 2)]; + summary.timingMs = { + median: Number(median.toFixed(3)), + p90: Number(times[Math.floor(times.length * 0.9)].toFixed(3)), + max: Number(times[times.length - 1].toFixed(3)) + }; + expect(median).toBeLessThan(MEDIAN_BUDGET_MS); + }, 20_000); + + it('finishes the whole property file in under 10 s', () => { + const elapsed = performance.now() - fileStart; + summary.fileMs = Math.round(elapsed); + expect(elapsed).toBeLessThan(FILE_BUDGET_MS); + }); +}); + +afterAll(() => { + console.info(`generateVoicings property summary ${JSON.stringify(summary, null, 2)}`); +}); diff --git a/engine/generateVoicings.test.ts b/engine/generateVoicings.test.ts new file mode 100644 index 0000000..ba7b34a --- /dev/null +++ b/engine/generateVoicings.test.ts @@ -0,0 +1,726 @@ +import { describe, it, expect } from 'vitest'; +import { SEED_JITTER, findClosestVoicing, generateVoicings, resolveFamilyId, structurallyUnfingerable } from './generateVoicings'; +import { findBestFingering } from './fingering'; +import { DEFAULT_CALIBRATION, DEFAULT_HAND_PROFILE, allowedSpanMm, profileFromCalibration } from './handProfile'; +import { degreeString } from './naming'; +import { shapeConstraintViolation } from './playability'; +import { createRng, mulberry32, seedFromString } from './random'; +import { fromRiffForgeTab, shapeKey, shapeToMidi, toRiffForgeTab } from './shape'; +import { DROP_D, E_STANDARD } from './tuning'; +import { getFamily, matchFamily, relaxFamily } from './voicingSpec'; +import type { GeneratedVoicing, GenerateParams, HandProfile, VoicingFamily } from './types'; + +const P = DEFAULT_HAND_PROFILE; + +const gen = (over: Partial = {}) => + generateVoicings({ root: 'E', family: 'power5', tuning: E_STANDARD, profile: P, ...over }); + +/** Every playable shape, in plain sort order. */ +const all = (over: Partial = {}) => gen({ limit: 100000, diversify: false, ...over }); + +const keys = (voicings: readonly GeneratedVoicing[]): string[] => voicings.map((v) => toRiffForgeTab(v.shape)); + +const fam = (id: string): VoicingFamily => { + const family = getFamily(id); + if (!family) throw new Error(`missing family ${id}`); + return family; +}; + +const frettedCount = (v: GeneratedVoicing): number => v.shape.filter((f) => f !== null && f > 0).length; + +const stringMask = (v: GeneratedVoicing): number => v.shape.reduce((m, f, s) => (f === null ? m : m | (1 << s)), 0); + +const nearDuplicatePairs = (voicings: readonly GeneratedVoicing[]): number => { + let pairs = 0; + for (let i = 0; i < voicings.length; i++) { + for (let j = i + 1; j < voicings.length; j++) { + const a = voicings[i].fingering.metrics.lowestFret ?? 0; + const b = voicings[j].fingering.metrics.lowestFret ?? 0; + if (Math.abs(a - b) <= 2 && stringMask(voicings[i]) === stringMask(voicings[j])) pairs++; + } + } + return pairs; +}; + +describe('generateVoicings: M1 cases', () => { + it('m_add9 at G never returns the barre 3 5 7 3 3 3 or more than 4 fretted notes', () => { + const result = all({ root: 'G', family: 'm_add9' }); + expect(result.voicings.length).toBeGreaterThan(0); + expect(keys(result.voicings)).not.toContain('3 5 7 3 3 3'); + for (const v of result.voicings) { + expect(frettedCount(v)).toBeLessThanOrEqual(4); + expect(v.fingering.barres).toEqual([]); + } + expect(keys(gen({ root: 'G', family: 'm_add9' }).voicings)).not.toContain('3 5 7 3 3 3'); + }); + + it('Drop D power5 at D starts with 0 0 0 x x x, sounding D2 A2 D3', () => { + const result = gen({ root: 'D', tuning: DROP_D }); + const first = result.voicings[0]; + expect(toRiffForgeTab(first.shape)).toBe('0 0 0 x x x'); + expect(first.midi).toEqual([38, 45, 50]); + expect(first.degrees).toBe('1 5 1'); + expect(first.symbol).toBe('D5'); + expect(first.tags).toEqual(['pedalCompatible', 'usesOpenStrings', 'rootInBass', 'lowStrings']); + expect(first.cost).toBe(0); + }); + + it('includes the all-open window', () => { + expect(keys(all({ root: 'E', family: 'fourth' }).voicings)).toContain('0 0 x x x x'); + }); + + it('Em(add9) 0 2 4 0 0 0 is a valid no-barre m_add9 voicing at E', () => { + const fingering = findBestFingering([0, 2, 4, 0, 0, 0], P); + expect(fingering.ok).toBe(true); + const em = all({ root: 'E', family: 'm_add9' }).voicings.find((v) => toRiffForgeTab(v.shape) === '0 2 4 0 0 0'); + expect(em).toBeDefined(); + expect(em!.fingering.barres).toEqual([]); + expect(em!.degrees).toBe('1 5 2 b3 5 1'); + }); + + it('open E major 0 2 2 1 0 0 is a valid major voicing with three fingers', () => { + const e = all({ root: 'E', family: 'major' }).voicings.find((v) => toRiffForgeTab(v.shape) === '0 2 2 1 0 0'); + expect(e).toBeDefined(); + expect(e!.fingering.fingers).toEqual([null, 2, 3, 1, null, null]); + expect(e!.fingering.metrics.fingerCount).toBe(3); + }); +}); + +describe('generateVoicings: output', () => { + it('fills every field from the engine functions', () => { + for (const [root, family, tuning] of [ + ['E', 'm_add9', E_STANDARD], + ['A', 'phrygian', E_STANDARD], + ['D', 'sus2', DROP_D], + ['C', 'drone_power5', E_STANDARD] + ] as const) { + const result = gen({ root, family, tuning }); + expect(result.voicings.length).toBeGreaterThan(0); + for (const v of result.voicings) { + const rootPc = { E: 4, A: 9, D: 2, C: 0 }[root]; + expect(v.familyId).toBe(family); + expect(v.midi).toEqual(shapeToMidi(v.shape, tuning)); + expect(v.degrees).toBe(degreeString(v.shape, tuning, rootPc)); + expect(v.degreesByString.length).toBe(6); + expect(v.cost).toBe(v.breakdown.total); + expect(v.breakdown.terms.length).toBe(14); + expect(v.novelty).toBeGreaterThanOrEqual(0); + expect(v.novelty).toBeLessThanOrEqual(1); + expect(v.name.length).toBeGreaterThan(0); + if (v.symbol !== null) expect(v.name).toBe(v.symbol); + expect(matchFamily(fam(family), rootPc, v.shape, tuning).ok).toBe(true); + expect(findBestFingering(v.shape, P)).toEqual({ ok: true, fingering: v.fingering }); + } + } + }); + + it('tags pedal-friendly, open, root-bass and string-group shapes', () => { + const tagsOf = (text: string, over: Partial = {}) => { + const v = all(over).voicings.find((x) => toRiffForgeTab(x.shape) === text); + if (!v) throw new Error(`missing ${text}`); + return v.tags; + }; + expect(tagsOf('x 7 9 9 x x', { root: 'E' })).toEqual(['pedalCompatible', 'rootInBass', 'middleStrings']); + expect(tagsOf('0 2 2 x x x', { root: 'E' })).toEqual(['pedalCompatible', 'usesOpenStrings', 'rootInBass', 'lowStrings']); + expect(tagsOf('x x x 9 12 x', { root: 'E' })).toEqual(['pedalCompatible', 'rootInBass', 'middleStrings', 'topStrings']); + expect(tagsOf('x x x x 5 7', { root: 'E' })).toEqual(['pedalCompatible', 'rootInBass', 'topStrings']); + // string 0 fretted: not pedal-compatible + expect(tagsOf('5 7 7 x x x', { root: 'A' })).toEqual(['rootInBass', 'lowStrings']); + expect(tagsOf('x x 2 4 5 0', { root: 'E', family: 'drone_power5' })).toEqual(['pedalCompatible', 'drone', 'usesOpenStrings', 'rootInBass']); + // open low string on the 5th still counts as a pedal string + expect(tagsOf('0 x 2 2 2 x', { root: 'A', family: 'major' })).toEqual(['pedalCompatible', 'usesOpenStrings']); + }); + + it('is deterministic, including the order', () => { + const params: GenerateParams = { root: 'A', family: 'sus2', tuning: E_STANDARD, profile: P }; + expect(generateVoicings(params)).toEqual(generateVoicings(params)); + expect(generateVoicings({ ...params, seed: 'riff' })).toEqual(generateVoicings({ ...params, seed: 'riff' })); + expect(generateVoicings({ ...params, sort: 'unusual' })).toEqual(generateVoicings({ ...params, sort: 'unusual' })); + }); + + it('accepts family objects and ids, root names and pitch classes', () => { + const byId = gen({ root: 'F#', family: 'm7' }); + expect(gen({ root: 6, family: fam('m7') })).toEqual(byId); + expect(gen({ root: 'Gb', family: 'm7' })).toEqual(byId); + expect(() => gen({ family: 'no_such_family' })).toThrow(/no_such_family/); + expect(() => gen({ root: 'H' })).toThrow(); + }); + + it('honours limit', () => { + expect(gen({ root: 'A', family: 'major', limit: 3 }).voicings.length).toBe(3); + expect(gen({ root: 'A', family: 'major' }).voicings.length).toBe(12); + expect(gen({ root: 'A', family: 'major', limit: 0 }).voicings).toEqual([]); + expect(gen({ root: 'A', family: 'major', limit: -3 }).voicings).toEqual([]); + expect(gen({ root: 'A', family: 'major', limit: Number.NaN }).voicings.length).toBe(12); + }); + + it('treats limit Infinity as "every playable voicing"', () => { + const everything = gen({ root: 'E', family: 'add9', limit: Infinity, diversify: false }); + expect(everything.stats.playable).toBeGreaterThan(12); + expect(everything.voicings.length).toBe(everything.stats.playable); + expect(everything).toEqual(gen({ root: 'E', family: 'add9', limit: 1e9, diversify: false })); + const spread = gen({ root: 'E', family: 'add9', limit: Infinity }); + expect(new Set(keys(spread.voicings))).toEqual(new Set(keys(everything.voicings))); + }); + + it('gives extended-range tunings meaningful novelty', () => { + const seven = { id: 'b-standard-7', name: '7-string B standard', openMidi: [35, 40, 45, 50, 55, 59, 64] }; + const { voicings } = gen({ root: 'E', family: 'major', tuning: seven, limit: 1000, diversify: false }); + const openE = voicings.find((v) => toRiffForgeTab(v.shape) === 'x 0 2 2 1 0 0'); + expect(openE?.novelty).toBe(0); + expect(voicings.filter((v) => v.novelty < 1).length).toBeGreaterThan(voicings.length / 2); + }); + + it('reports consistent stats', () => { + const result = gen({ root: 'C', family: 'm_add9' }); + const { stats } = result; + expect(stats.windows).toBe(P.maxFret + 1); + expect(stats.combinations).toBeGreaterThanOrEqual(stats.pitchValid); + expect(stats.pitchValid).toBeGreaterThanOrEqual(stats.playable); + expect(stats.playable).toBeGreaterThanOrEqual(result.voicings.length); + const rejected = Object.values(stats.rejections).reduce((s, n) => s + (n ?? 0), 0); + expect(rejected).toBeGreaterThanOrEqual(stats.combinations - stats.playable); + expect(result.empty).toBeUndefined(); + }); +}); + +describe('generateVoicings: hand profile', () => { + it('a smaller reach removes wide shapes; a larger one adds shapes', () => { + const small = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 }); + const large = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 6, allowStretches: true }); + for (const family of ['m_add9', 'sus2', 'power5']) { + const base = all({ family, root: 'A' }); + const less = all({ family, root: 'A', profile: small }); + const more = all({ family, root: 'A', profile: large }); + const baseKeys = new Set(keys(base.voicings)); + const moreKeys = new Set(keys(more.voicings)); + for (const k of keys(less.voicings)) expect(baseKeys.has(k)).toBe(true); + for (const k of baseKeys) expect(moreKeys.has(k)).toBe(true); + expect(less.voicings.length).toBeLessThan(base.voicings.length); + expect(more.voicings.length).toBeGreaterThan(base.voicings.length); + const removed = base.voicings.filter((v) => !keys(less.voicings).includes(toRiffForgeTab(v.shape))); + expect(removed.some((v) => v.fingering.metrics.spanMm > allowedSpanMm(small, v.fingering.metrics.lowestFret ?? 0))).toBe(true); + } + }); + + it('allowOpenStrings=false yields no open strings', () => { + const closed: HandProfile = { ...P, allowOpenStrings: false }; + for (const family of ['power5', 'm_add9', 'phrygian']) { + const result = all({ family, profile: closed }); + expect(result.voicings.length).toBeGreaterThan(0); + for (const v of result.voicings) expect(v.shape).not.toContain(0); + } + }); + + it('respects maxFret and the interior-mute limit', () => { + const tight: HandProfile = { ...P, maxFret: 7, maxInteriorMutes: 0 }; + const result = all({ family: 'm7', root: 'A', profile: tight }); + expect(result.voicings.length).toBeGreaterThan(0); + expect(result.stats.windows).toBe(8); + for (const v of result.voicings) { + expect(Math.max(...v.shape.map((f) => f ?? 0))).toBeLessThanOrEqual(7); + expect(v.fingering.metrics.interiorMutes).toBe(0); + } + }); +}); + +describe('generateVoicings: ranking', () => { + it("sorts 'easiest' by cost", () => { + const { voicings } = all({ root: 'C', family: 'add9' }); + for (let i = 1; i < voicings.length; i++) expect(voicings[i].cost).toBeGreaterThanOrEqual(voicings[i - 1].cost); + }); + + it("sorts 'unusual' by novelty, then cost", () => { + const { voicings } = all({ root: 'C', family: 'add9', sort: 'unusual' }); + expect(voicings.length).toBeGreaterThan(5); + for (let i = 1; i < voicings.length; i++) { + const [a, b] = [voicings[i - 1], voicings[i]]; + expect(b.novelty).toBeLessThanOrEqual(a.novelty); + if (b.novelty === a.novelty) expect(b.cost).toBeGreaterThanOrEqual(a.cost); + } + expect(voicings[0].novelty).toBeGreaterThan(voicings[voicings.length - 1].novelty); + }); + + it('a seed only reorders near-equal results, deterministically', () => { + const plain = all({ root: 'D', family: 'sus4' }); + const seeded = all({ root: 'D', family: 'sus4', seed: 'take-2' }); + expect(new Set(keys(seeded.voicings))).toEqual(new Set(keys(plain.voicings))); + for (let i = 1; i < seeded.voicings.length; i++) { + expect(seeded.voicings[i].cost).toBeGreaterThanOrEqual(seeded.voicings[i - 1].cost - 0.01); + } + expect(all({ root: 'D', family: 'sus4', seed: 'take-2' })).toEqual(seeded); + }); + + it('diversify spreads results across positions and string sets', () => { + let improved = 0; + for (const [root, family] of [ + ['E', 'power5'], + ['A', 'minor'], + ['G', 'm_add9'], + ['C', 'major'], + ['D', 'sus2'], + ['F', 'm7'], + ['B', 'quartal'], + ['E', 'phrygian'] + ] as const) { + const spread = gen({ root, family }); + const plain = gen({ root, family, diversify: false }); + expect(spread.voicings.length).toBe(plain.voicings.length); + expect(nearDuplicatePairs(spread.voicings)).toBeLessThanOrEqual(nearDuplicatePairs(plain.voicings)); + if (nearDuplicatePairs(spread.voicings) < nearDuplicatePairs(plain.voicings)) improved++; + const full = new Set(keys(all({ root, family }).voicings)); + for (const k of keys(spread.voicings)) expect(full.has(k)).toBe(true); + expect(spread.voicings[0]).toEqual(plain.voicings[0]); + } + expect(improved).toBeGreaterThan(0); + }); + + it('diversify follows the contract: +0.6 per selected voicing on the same strings within 2 frets', () => { + for (const [root, family, sort] of [ + ['E', 'power5', 'easiest'], + ['A', 'minor', 'easiest'], + ['G', 'm_add9', 'easiest'], + ['C', 'add9', 'unusual'] + ] as const) { + const sorted = all({ root, family, sort }).voicings; + const scoreOf = (v: GeneratedVoicing) => (sort === 'unusual' ? -v.novelty : v.cost); + const picked: GeneratedVoicing[] = []; + const left = [...sorted]; + while (picked.length < 12 && left.length > 0) { + let best = 0; + let bestScore = Infinity; + left.forEach((c, i) => { + const near = picked.filter( + (p) => + stringMask(p) === stringMask(c) && + Math.abs((p.fingering.metrics.lowestFret ?? 0) - (c.fingering.metrics.lowestFret ?? 0)) <= 2 + ).length; + const score = scoreOf(c) + 0.6 * near; + if (score < bestScore) { + best = i; + bestScore = score; + } + }); + picked.push(left.splice(best, 1)[0]); + } + expect(keys(gen({ root, family, sort }).voicings), `${root} ${family}`).toEqual(keys(picked)); + } + }); + + it("the 'chug' context favours fewer, lower strings", () => { + const weight = (vs: readonly GeneratedVoicing[]) => + vs.reduce((sum, v) => sum + v.midi.length + v.shape.reduce((a, f, i) => (f === null ? a : a + i), 0) / v.midi.length, 0) / + vs.length; + let lighter = 0; + for (const [root, family] of [['E', 'power5'], ['A', 'major'], ['E', 'minor'], ['D', 'sus4'], ['G', 'fourth']] as const) { + const strum = gen({ root, family, diversify: false, limit: 8 }).voicings; + const chug = gen({ root, family, diversify: false, limit: 8, context: 'chug' }).voicings; + expect(weight(chug)).toBeLessThanOrEqual(weight(strum)); + if (weight(chug) < weight(strum)) lighter++; + for (const v of chug) expect(v.breakdown.terms.find((t) => t.key === 'chugStrings')?.cost).toBeGreaterThan(0); + for (const v of strum) expect(v.breakdown.terms.find((t) => t.key === 'chugStrings')?.cost).toBe(0); + } + expect(lighter).toBeGreaterThan(0); + }); +}); + +describe('generateVoicings: empty results name the binding constraint', () => { + it('no drone string: drone 4th on C in E standard', () => { + const result = gen({ root: 'C', family: 'drone_fourth' }); + expect(result.voicings).toEqual([]); + expect(result.empty?.constraint).toBe('NO_DRONE_STRING'); + expect(result.empty?.message).toMatch(/drone/); + }); + + it('open strings off: a drone needs an open string', () => { + const result = gen({ root: 'E', family: 'drone_power5', profile: { ...P, allowOpenStrings: false } }); + expect(result.voicings).toEqual([]); + expect(result.empty?.constraint).toBe('OPEN_STRINGS'); + expect(result.empty?.message).toMatch(/open strings/); + }); + + it('needs a barre: five distinct fretted notes', () => { + const fiveNotes: VoicingFamily = { + id: 'six_nine', + label: '6/9 five-note', + description: 'test recipe', + group: 'legacy', + required: [0, 2, 4, 7, 9], + optional: [], + forbidden: [], + bass: [0], + minNotes: 5, + maxNotes: 5, + tags: [] + }; + const result = gen({ root: 'C', family: fiveNotes, profile: { ...P, allowOpenStrings: false } }); + expect(result.voicings).toEqual([]); + expect(result.empty?.constraint).toBe('NEEDS_BARRE'); + expect(result.empty?.message).toMatch(/needs a barre/); + }); + + it('reach: a two-note power chord for a tiny hand without open strings', () => { + const tiny: HandProfile = { ...P, reachAtLowMm: 30, reachAtHighMm: 30, allowOpenStrings: false }; + const twoNote: VoicingFamily = { ...fam('power5'), id: 'power5_two', minNotes: 2, maxNotes: 2 }; + const result = gen({ root: 'A', family: twoNote, profile: tiny }); + expect(result.voicings).toEqual([]); + expect(result.empty?.constraint).toBe('REACH'); + expect(result.empty?.message).toMatch(/reach/); + }); + + it('pitch classes: a contradictory recipe', () => { + const broken: VoicingFamily = { ...fam('power5'), id: 'broken', forbidden: [7] }; + const result = gen({ family: broken }); + expect(result.voicings).toEqual([]); + expect(result.empty?.constraint).toBe('PITCH_CLASSES'); + }); + + it('note count: more required notes than the recipe allows', () => { + const broken: VoicingFamily = { ...fam('phrygian'), id: 'broken', maxNotes: 3 }; + expect(gen({ family: broken }).empty?.constraint).toBe('NOTE_COUNT'); + }); + + it('max fret 3 + phrygian: every empty root names a constraint in one line', () => { + const low: HandProfile = { ...P, maxFret: 3 }; + let empties = 0; + for (let root = 0; root < 12; root++) { + const result = gen({ root, family: 'phrygian', profile: low }); + if (result.voicings.length > 0) continue; + empties++; + expect(result.empty).toBeDefined(); + expect(result.empty?.message).not.toContain('\n'); + expect(result.empty?.message.length).toBeGreaterThan(10); + } + expect(empties).toBeGreaterThan(0); + }); +}); + +describe('findClosestVoicing', () => { + it('returns the exact result when it has voicings', () => { + const params: GenerateParams = { root: 'E', family: 'm_add9', tuning: E_STANDARD, profile: P }; + const closest = findClosestVoicing(params); + expect(closest.relaxed).toBeNull(); + expect(closest.result).toEqual(generateVoicings(params)); + }); + + it('walks the relaxation ladder when the exact recipe is empty', () => { + // phrygian needs 4..6 notes with the root in the bass; maxFret 3 without open strings leaves some roots empty + const tight: HandProfile = { ...P, maxFret: 3, allowOpenStrings: false }; + let relaxedCases = 0; + for (let root = 0; root < 12; root++) { + const params: GenerateParams = { root, family: 'phrygian', tuning: E_STANDARD, profile: tight }; + const exact = generateVoicings(params); + const closest = findClosestVoicing(params); + if (exact.voicings.length > 0) { + expect(closest.relaxed).toBeNull(); + continue; + } + if (closest.relaxed === null) { + expect(closest.result).toEqual(exact); + continue; + } + relaxedCases++; + const ladder = relaxFamily(fam('phrygian')); + const step = ladder.find((s) => s.relaxed.join() === closest.relaxed?.join()); + expect(step).toBeDefined(); + expect(closest.result.voicings.length).toBeGreaterThan(0); + for (const v of closest.result.voicings) { + expect(v.familyId).toBe(step?.family.id); + expect(v.familyId).toContain('~'); + } + // earlier ladder steps were empty + for (const earlier of ladder.slice(0, ladder.indexOf(step!))) { + expect(generateVoicings({ ...params, family: earlier.family }).voicings).toEqual([]); + } + } + expect(relaxedCases).toBeGreaterThan(0); + }); + + it('returns the exact empty result when nothing on the ladder works', () => { + const params: GenerateParams = { root: 'C', family: 'drone_fourth', tuning: E_STANDARD, profile: P }; + const closest = findClosestVoicing(params); + expect(closest.relaxed).toBeNull(); + expect(closest.result.empty?.constraint).toBe('NO_DRONE_STRING'); + expect(closest.exactEmpty).toEqual(closest.result.empty); + }); + + it('keeps the exact diagnosis next to a relaxed suggestion', () => { + const params: GenerateParams = { root: 'D#', family: 'power5_b2', tuning: E_STANDARD, profile: { ...P, maxFret: 5, allowOpenStrings: false } }; + const exact = generateVoicings(params); + expect(exact.voicings).toEqual([]); + const closest = findClosestVoicing(params); + expect(closest.relaxed).toEqual(['any bass note']); + expect(closest.result.voicings.length).toBeGreaterThan(0); + expect(closest.result.empty).toBeUndefined(); + expect(closest.exactEmpty).toEqual(exact.empty); + expect(closest.exactEmpty?.constraint).toBe('MAX_FRET'); + expect(findClosestVoicing({ ...params, root: 'E', profile: P }).exactEmpty).toBeUndefined(); + }); + + it('returns relaxed family ids that resolve and regenerate', () => { + const params: GenerateParams = { root: 'D#', family: 'power5_b2', tuning: E_STANDARD, profile: { ...P, maxFret: 5, allowOpenStrings: false } }; + const closest = findClosestVoicing(params); + expect(closest.relaxed).toEqual(['any bass note']); + const id = closest.result.voicings[0].familyId; + expect(id).toBe('power5_b2~anyBass'); + const resolved = resolveFamilyId(id); + expect(resolved).toEqual(relaxFamily(fam('power5_b2'))[0].family); + expect(resolved?.label).toBe(fam('power5_b2').label); + expect(generateVoicings({ ...params, family: id })).toEqual(closest.result); + expect(resolveFamilyId('power5_b2')).toBe(fam('power5_b2')); + expect(resolveFamilyId('power5_b2~noSuchStep')).toBeUndefined(); + expect(resolveFamilyId('no_such~anyBass')).toBeUndefined(); + expect(() => generateVoicings({ ...params, family: 'power5_b2~noSuchStep' })).toThrow(/power5_b2~noSuchStep/); + }); +}); + +/** Every shape over frets 0..maxFret with allowed pitch classes, filtered by the engine's own validators. */ +const bruteForce = (family: VoicingFamily, rootPc: number, tuning: typeof E_STANDARD, profile: HandProfile): Set => { + const allowed = new Set([...family.required, ...family.optional]); + const options = tuning.openMidi.map((open) => { + const list: (number | null)[] = [null]; + for (let f = 0; f <= profile.maxFret; f++) if (allowed.has((((open + f - rootPc) % 12) + 12) % 12)) list.push(f); + return list; + }); + const found = new Set(); + const shape: (number | null)[] = new Array(tuning.openMidi.length).fill(null); + const walk = (s: number): void => { + if (s === shape.length) { + if (matchFamily(family, rootPc, shape, tuning).ok !== true) return; + if (shapeConstraintViolation(shape, profile) !== null) return; + if (findBestFingering(shape, profile).ok !== true) return; + found.add(toRiffForgeTab(shape)); + return; + } + for (const f of options[s]) { + shape[s] = f; + walk(s + 1); + } + shape[s] = null; + }; + walk(0); + return found; +}; + +describe('generateVoicings: completeness against brute force', () => { + const profiles: [string, HandProfile][] = [ + ['default', P], + ['thumb', { ...P, allowThumb: true }], + ['partial barre', { ...P, allowTwoStringPartialBarre: true }], + ['barre mode', { ...P, noBarre: false }], + ['small reach, no mutes', { ...profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 }), maxInteriorMutes: 0 }], + ['stretch, 2 mutes, no open', { ...profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 6, allowStretches: true, allowOpenStrings: false }), maxInteriorMutes: 2 }], + ['max fret 9', { ...P, maxFret: 9 }] + ]; + const combos: [typeof E_STANDARD, string, number][] = [ + [E_STANDARD, 'power5', 4], + [E_STANDARD, 'm_add9', 7], + [DROP_D, 'phrygian', 2], + [E_STANDARD, 'quartal', 9], + [E_STANDARD, 'cluster_m2', 0], + [DROP_D, 'drone_sus2', 2], + [E_STANDARD, 'major', 6], + [DROP_D, 'power5_b2', 11] + ]; + + for (const [name, profile] of profiles) { + it(`finds exactly the valid shapes (${name})`, () => { + for (const [tuning, familyId, root] of combos) { + const family = fam(familyId); + const expected = bruteForce(family, root, tuning, profile); + const actual = keys(all({ root, family, tuning, profile }).voicings); + expect(new Set(actual), `${tuning.id} ${familyId} ${root}`).toEqual(expected); + expect(actual.length).toBe(expected.size); + } + }, 30_000); + } +}); + +describe('structurallyUnfingerable (pre-check before the exhaustive fingering search)', () => { + const wide: HandProfile = { + ...P, + noBarre: false, + allowTwoStringPartialBarre: true, + allowThumb: true, + stretchTolerance: 1.5, + reachAtLowMm: 160, + reachAtHighMm: 160, + maxFret: 24, + maxInteriorMutes: 4 + }; + const profiles: [string, HandProfile][] = [ + ['default', P], + ['thumb', { ...P, allowThumb: true }], + ['partial barre', { ...P, allowTwoStringPartialBarre: true }], + ['partial barre + thumb', { ...P, allowTwoStringPartialBarre: true, allowThumb: true }], + ['barre mode', { ...P, noBarre: false }], + ['barre mode + thumb', { ...P, noBarre: false, allowThumb: true }], + ['wide, every option', wide] + ]; + + it('only flags shapes the fingering search rejects as NEEDS_BARRE, and flags most structural misses', () => { + const rng = createRng('unfingerable'); + for (const [name, profile] of profiles) { + let flagged = 0; + let structuralMisses = 0; + for (let i = 0; i < 4000; i++) { + const base = rng.int(1, 11); + const shape = Array.from({ length: 6 }, () => (rng.chance(0.25) ? null : rng.chance(0.15) ? 0 : base + rng.int(0, 4))); + if (shape.every((f) => f === null)) continue; + const result = findBestFingering(shape, profile); + if (structurallyUnfingerable(shape, profile)) { + flagged++; + expect(result, `${name} ${toRiffForgeTab(shape)}`).toMatchObject({ ok: false, reason: 'NEEDS_BARRE' }); + } else if (result.ok === false && /do not fit \d+ fingers|but only \d+ fingers|need the thumb/.test(result.detail)) { + structuralMisses++; + } + } + expect(flagged, name).toBeGreaterThan(0); + expect(structuralMisses, name).toBeLessThan(flagged); + } + }); + + it('flags the classic barre shapes in no-barre mode only', () => { + for (const text of ['1 3 3 2 1 1', 'x 1 3 3 3 1', '3 5 7 3 3 3']) { + const shape = fromRiffForgeTab(text)!; + expect(structurallyUnfingerable(shape, P), text).toBe(true); + expect(structurallyUnfingerable(shape, { ...P, noBarre: false }), text).toBe(false); + } + expect(structurallyUnfingerable(fromRiffForgeTab('0 2 2 1 0 0')!, P)).toBe(false); + expect(structurallyUnfingerable(fromRiffForgeTab('3 x 0 0 0 3')!, P)).toBe(false); + }); + + it('keeps generation exact for a barre, partial-barre, thumb and wide-stretch profile', () => { + const profile: HandProfile = { ...wide, maxFret: 8 }; + for (const [tuning, familyId, root] of [ + [E_STANDARD, 'm_add9', 7], + [DROP_D, 'add9', 7], + [E_STANDARD, 'major', 0], + [DROP_D, 'phrygian', 2] + ] as const) { + const family = fam(familyId); + const expected = bruteForce(family, root, tuning, profile); + expect(new Set(keys(all({ root, family, tuning, profile }).voicings)), `${tuning.id} ${familyId}`).toEqual(expected); + } + }, 60_000); +}); + +/** The contract's selection, replayed on every playable voicing in plain sort order. */ +const contractPicks = (sorted: readonly GeneratedVoicing[], over: Partial, limit: number): string[] => { + const jitter = (v: GeneratedVoicing): number => + over.seed === undefined ? 0 : mulberry32(seedFromString(over.seed + shapeKey(v.shape)))() * SEED_JITTER; + const primary = (v: GeneratedVoicing): number => (over.sort === 'unusual' ? -v.novelty : v.cost) + jitter(v); + if (over.diversify === false) return keys(sorted.slice(0, limit)); + const picked: GeneratedVoicing[] = []; + const left = [...sorted]; + while (picked.length < limit && left.length > 0) { + let best = 0; + let bestScore = Infinity; + left.forEach((c, i) => { + const near = picked.filter( + (p) => + stringMask(p) === stringMask(c) && + Math.abs((p.fingering.metrics.lowestFret ?? 0) - (c.fingering.metrics.lowestFret ?? 0)) <= 2 + ).length; + const score = primary(c) + 0.6 * near; + if (score < bestScore) { + best = i; + bestScore = score; + } + }); + picked.push(left.splice(best, 1)[0]); + } + return keys(picked); +}; + +describe('generateVoicings: heavy hand profiles (lazy fingering search)', () => { + const heavyCalibration = profileFromCalibration({ + ...DEFAULT_CALIBRATION, + lowPinkyFret: 7, + highPinkyFret: 13, + allowStretches: true, + noBarre: false, + maxFret: 24 + }); + const extreme: HandProfile = { + ...P, + noBarre: false, + allowTwoStringPartialBarre: true, + allowThumb: true, + stretchTolerance: 1.5, + reachAtLowMm: 160, + reachAtHighMm: 160, + maxFret: 24, + maxInteriorMutes: 4 + }; + + it('returns exactly the voicings an exhaustive search selects', () => { + let lazyRuns = 0; + const cases = [ + ['heavy calibration', heavyCalibration, E_STANDARD, 'add9', 7, [{}, { seed: 'take-3' }, { sort: 'unusual' as const }]], + ['heavy calibration', heavyCalibration, DROP_D, 'major', 4, [{}, { sort: 'unusual' as const }]], + ['extreme, max fret 12', { ...extreme, maxFret: 12 }, DROP_D, 'm_add9', 2, [{}, { seed: 'take-3' }, { sort: 'unusual' as const }]], + ['extreme, max fret 12', { ...extreme, maxFret: 12 }, E_STANDARD, 'sus2', 9, [{}]] + ] as const; + for (const [profileName, profile, tuning, family, root, overs] of cases) { + for (const over of overs) { + const base = { root, family, tuning, profile, ...over }; + const every = all(base); + for (const variant of [{}, { diversify: false }, { limit: 1 }, { limit: 30 }]) { + const params = { ...base, ...variant }; + const result = gen(params); + const where = `${profileName} ${tuning.id} ${family} ${root} ${JSON.stringify(over)} ${JSON.stringify(variant)}`; + expect(keys(result.voicings), where).toEqual(contractPicks(every.voicings, params, params.limit ?? 12)); + expect(result.stats.combinations, where).toBe(every.stats.combinations); + expect(result.stats.pitchValid, where).toBe(every.stats.pitchValid); + if (result.stats.playable < every.stats.playable) lazyRuns++; + } + const none = gen({ ...base, limit: 0 }); + expect(none.voicings).toEqual([]); + expect(none.empty).toBeUndefined(); + } + } + expect(lazyRuns).toBeGreaterThan(20); + }, 120_000); + + it('stays deterministic, including the order and the stats', () => { + const params: GenerateParams = { root: 'G', family: 'add9', tuning: DROP_D, profile: extreme, seed: 'riff' }; + expect(generateVoicings(params)).toEqual(generateVoicings(params)); + expect(generateVoicings({ ...params, sort: 'unusual' })).toEqual(generateVoicings({ ...params, sort: 'unusual' })); + }); + + it('meets the time budget on the heaviest profiles', () => { + const timeAll = (profile: HandProfile, families: readonly string[], tunings = [E_STANDARD, DROP_D]): number[] => { + for (let i = 0; i < 8; i++) generateVoicings({ root: i, family: families[i % families.length], tuning: E_STANDARD, profile }); + const times: number[] = []; + for (const tuning of tunings) { + for (const family of families) { + for (let root = 0; root < 12; root++) { + const t0 = performance.now(); + generateVoicings({ root, family, tuning, profile }); + times.push(performance.now() - t0); + } + } + } + return times.sort((a, b) => a - b); + }; + const pct = (times: number[], p: number): number => times[Math.min(times.length - 1, Math.floor(times.length * p))]; + // Every calibration answer at its widest (barres on, stretches on, max fret 24): median about 6 ms, max about 18 ms + const calibrated = timeAll(heavyCalibration, ['add9', 'm_add9', 'sus2', 'sus4', 'major', 'phrygian', 'power5_b2']); + expect(pct(calibrated, 0.5)).toBeLessThan(15); + expect(pct(calibrated, 0.95)).toBeLessThan(30); + // Every option on and the widest hand, heaviest recipes: median about 35 ms, max about 90 ms here (400-700 ms + // before the lazy search); generous bounds so a loaded machine does not fail the run + const widest = timeAll(extreme, ['add9', 'm_add9'], [DROP_D]); + expect(pct(widest, 0.5)).toBeLessThan(120); + expect(pct(widest, 1)).toBeLessThan(400); + }, 60_000); +}); + +describe('generateVoicings: keys', () => { + it('never returns duplicate shapes', () => { + const { voicings } = all({ root: 'B', family: 'minor' }); + const list = voicings.map((v) => shapeKey(v.shape)); + expect(new Set(list).size).toBe(list.length); + }); +}); diff --git a/engine/generateVoicings.ts b/engine/generateVoicings.ts new file mode 100644 index 0000000..a58a8f5 --- /dev/null +++ b/engine/generateVoicings.ts @@ -0,0 +1,892 @@ +// Voicing generation: a pruned, exhaustive search over fret windows anchored at the lowest fretted fret, exact +// recipe validation, the fingering search as the hard gate, then cost ranking and diversification across positions +// and string sets. Large searches (wide, barre or thumb profiles) run the fingering search lazily in lower-bound +// order, with the same results. Deterministic for equal inputs, including the order of the results. +import type { + BindingConstraint, + Fingering, + FingeringRejectReason, + FingeringResult, + GenerateParams, + GenerateResult, + GenerateStats, + GeneratedVoicing, + HandProfile, + Shape, + Tuning, + VoicingFamily, + VoicingTag +} from './types'; +import { MAX_MINI_BARRE_STRINGS, THUMB_FRET_WINDOW, findBestFingering } from './fingering'; +import { fingertipMm, highestReachableFret } from './geometry'; +import { allowedSpanMm } from './handProfile'; +import { nameVoicing } from './naming'; +import { intervalFrom, pitchClass, pitchClassName, toPitchClass } from './pitch'; +import { + countInteriorMutes, + nonPhysicalCost, + noveltyAgainst, + prepareReferences, + scoreTotal, + scoreVoicing, + shapeConstraintViolation, + type ScoreContext +} from './playability'; +import { mulberry32, seedFromString } from './random'; +import { interiorMutes, shapeKey, shapeToMidi } from './shape'; +import { allowedIntervals, getFamily, matchFamily, relaxFamily } from './voicingSpec'; +import { PLAYABILITY_WEIGHTS } from './weights'; + +export const DEFAULT_VOICING_LIMIT = 12; +/** Added to a candidate's sort score per already selected voicing at a nearby position on the same strings. */ +export const DIVERSITY_PENALTY = 0.6; +/** "Nearby": lowest fretted frets (0 when none) at most this far apart. */ +export const DIVERSITY_FRET_DISTANCE = 2; +/** Seeded ordering jitter range: only reorders results whose sort keys are this close. */ +export const SEED_JITTER = 0.01; + +const FINGERS = 4; +const EPS = 1e-9; + +// Empty-result diagnosis searches a wider space than the hand allows, so the constraints that the main search +// enforces by construction (reach windows, max fret, open strings) can be named. Heuristic bounds. +const DIAG_REACH_FACTOR = 1.5; +const DIAG_MIN_WINDOW_FRETS = 4; +const DIAG_EXTRA_FRETS = 12; +const DIAG_FRET_CAP = 24; + +/** Tie order when two constraints explain the same number of near misses. */ +const BINDING_ORDER: readonly BindingConstraint[] = [ + 'NEEDS_BARRE', + 'REACH', + 'PAIR_STRETCH', + 'FINGER_CROSSING', + 'INTERIOR_MUTES', + 'MAX_FRET', + 'OPEN_STRINGS', + 'PITCH_CLASSES', + 'NOTE_COUNT', + 'BASS', + 'NO_DRONE_STRING' +]; + +const FINGERING_TO_BINDING: Readonly> = { + NEEDS_BARRE: 'NEEDS_BARRE', + REACH: 'REACH', + PAIR_STRETCH: 'PAIR_STRETCH', + FINGER_CROSSING: 'FINGER_CROSSING', + FRET_RANGE: 'MAX_FRET', + OPEN_STRINGS_DISALLOWED: 'OPEN_STRINGS', + INVALID_SHAPE: 'NOTE_COUNT' +}; + +type Counts = Partial>; + +const bump = (counts: Counts, key: BindingConstraint, by = 1): void => { + counts[key] = (counts[key] ?? 0) + by; +}; + +const maskOf = (intervals: readonly number[]): number => { + let mask = 0; + for (const i of intervals) mask |= 1 << pitchClass(i); + return mask; +}; + +const popcount = (mask: number): number => { + let count = 0; + for (let m = mask; m; m &= m - 1) count++; + return count; +}; + +/** + * A registered family id, or a relaxed id `~~...` as findClosestVoicing puts in + * GeneratedVoicing.familyId (rebuilt from relaxFamily, so stored ids regenerate the same recipe). + */ +export const resolveFamilyId = (id: string): VoicingFamily | undefined => { + const direct = getFamily(id); + if (direct) return direct; + const cut = id.indexOf('~'); + if (cut < 0) return undefined; + const base = getFamily(id.slice(0, cut)); + return base ? relaxFamily(base).find((step) => step.family.id === id)?.family : undefined; +}; + +const resolveFamily = (family: VoicingFamily | string): VoicingFamily => { + if (typeof family !== 'string') return family; + const found = resolveFamilyId(family); + if (!found) throw new Error(`Unknown voicing family "${family}"`); + return found; +}; + +// --------------------------------------------------------------------------- +// Finger supply pre-check +// --------------------------------------------------------------------------- + +/** + * Lower bound on the fingers 1..4 that the fretted notes from string `from` up need. Each finger holds one fret and + * covers at most `cap` adjacent strings there; the index barre at `barreFret` covers that fret's notes inside one run + * of consecutive fretted strings. + */ +const fingersNeeded = (shape: Shape, from: number, cap: number, barreFret: number): number => { + const n = shape.length; + let total = 0; + let blockSave = 0; + let bestSave = 0; + let s = from; + while (s < n) { + const f = shape[s]; + if (f === null || f === 0) { + blockSave = 0; + s++; + continue; + } + let e = s; + while (e + 1 < n && shape[e + 1] === f) e++; + const groups = Math.ceil((e - s + 1) / cap); + total += groups; + if (f === barreFret) { + blockSave += groups; + if (blockSave > bestSave) bestSave = blockSave; + } + s = e + 1; + } + return bestSave > 0 ? total - bestSave + 1 : total; +}; + +/** Most adjacent strings one finger covers at one fret, outside the index barre (mini-barres in barre mode). */ +const supplyCap = (profile: HandProfile): number => + !profile.noBarre ? MAX_MINI_BARRE_STRINGS : profile.allowTwoStringPartialBarre ? 2 : 1; + +/** + * Cheap necessary condition for a fingering, mirroring fingering.ts's structural rules: one fret per finger, partial + * barres (2 adjacent strings) and mini-barres (3, barre mode) at one fret, the index barre at the lowest fret, and the + * thumb on string 0 within THUMB_FRET_WINDOW of the lowest finger. For a shape inside the profile's fret and + * open-string limits, true means findBestFingering rejects it with NEEDS_BARRE, so the exhaustive search is skipped. + */ +export const structurallyUnfingerable = (shape: Shape, profile: HandProfile): boolean => { + let lowest = Infinity; + let lowestAbove0 = Infinity; + for (let s = 0; s < shape.length; s++) { + const f = shape[s]; + if (typeof f !== 'number' || !(f > 0)) continue; + if (f < lowest) lowest = f; + if (s > 0 && f < lowestAbove0) lowestAbove0 = f; + } + if (lowest === Infinity) return false; + const cap = supplyCap(profile); + const barreFret = profile.noBarre ? -1 : lowest; + if (fingersNeeded(shape, 0, cap, barreFret) <= FINGERS) return false; + const thumbFret = shape[0]; + if (!profile.allowThumb || typeof thumbFret !== 'number' || !(thumbFret > 0)) return true; + // Thumb on string 0: the fingers hold the other notes (there are some, a lone note needs one finger) + if (Math.abs(thumbFret - lowestAbove0) > THUMB_FRET_WINDOW) return true; + return fingersNeeded(shape, 1, cap, barreFret) > FINGERS; +}; + +// --------------------------------------------------------------------------- +// Shape enumeration +// --------------------------------------------------------------------------- + +/** Fretted notes must include `anchor` (the lowest fretted fret) and stay within [anchor, hi]. Anchor 0 = open only. */ +interface FretWindow { + anchor: number; + hi: number; +} + +interface SearchSpace { + windows: readonly FretWindow[]; + allowOpen: boolean; + /** Prune branches with more fretted notes than this. */ + maxFretted: number; + /** Prune branches with more muted strings inside the sounding range than this. */ + maxInteriorMutes: number; + /** Finger-supply rules for partial-barre, barre or thumb profiles (see structurallyUnfingerable), else null. */ + supply: FingerSupply | null; +} + +interface FingerSupply { + /** supplyCap(profile) */ + cap: number; + barre: boolean; + thumb: boolean; +} + +const fingerSupply = (profile: HandProfile): FingerSupply | null => + profile.noBarre && !profile.allowTwoStringPartialBarre && !profile.allowThumb + ? null + : { cap: supplyCap(profile), barre: !profile.noBarre, thumb: profile.allowThumb }; + +/** + * structurallyUnfingerable on a partial shape (higher strings still muted), without the thumb-window rule. The + * finger lower bound never drops as strings are added (the window anchor is the barre fret), so every completion + * of a flagged prefix is unfingerable too. + */ +const prefixOutOfFingers = (prefix: Shape, supply: FingerSupply, anchor: number): boolean => { + const barreFret = supply.barre ? anchor : -1; + if (fingersNeeded(prefix, 0, supply.cap, barreFret) <= FINGERS) return false; + const thumbFret = prefix[0]; + return !(supply.thumb && thumbFret !== null && thumbFret > 0 && fingersNeeded(prefix, 1, supply.cap, barreFret) <= FINGERS); +}; + +/** + * Depth-first search over strings, low to high. Per string: muted, open, or a fret in the window whose pitch class + * the recipe allows. Branches that cannot complete the required pitch classes, the note count, the window anchor + * or the drone string are cut silently; branches cut by a profile limit are reported through `onPrune`. + */ +const enumerateShapes = ( + family: VoicingFamily, + rootPc: number, + tuning: Tuning, + space: SearchSpace, + onPrune: (constraint: 'NEEDS_BARRE' | 'INTERIOR_MUTES') => void, + onLeaf: (shape: (number | null)[]) => void +): void => { + const n = tuning.openMidi.length; + const allowedMask = maskOf(allowedIntervals(family)); + const requiredMask = maskOf(family.required); + const droneMask = family.drone ? maskOf([0, 7]) & allowedMask : 0; + const openInterval = tuning.openMidi.map((midi) => intervalFrom(rootPc, pitchClass(midi))); + const frets = new Array(n).fill(null); + + for (const { anchor, hi } of space.windows) { + const options: number[][] = []; + const bits: number[][] = []; + for (let s = 0; s < n; s++) { + const o = [-1]; + const b = [0]; + const openBit = 1 << openInterval[s]; + if (space.allowOpen && allowedMask & openBit) { + o.push(0); + b.push(openBit); + } + if (anchor > 0) { + for (let f = anchor; f <= hi; f++) { + const bit = 1 << ((openInterval[s] + f) % 12); + if (allowedMask & bit) { + o.push(f); + b.push(bit); + } + } + } + options.push(o); + bits.push(b); + } + const suffixMask = new Array(n + 1).fill(0); + const suffixAnchor = new Array(n + 1).fill(false); + const suffixDrone = new Array(n + 1).fill(false); + for (let s = n - 1; s >= 0; s--) { + let mask = suffixMask[s + 1]; + let hasAnchor = suffixAnchor[s + 1]; + let hasDrone = suffixDrone[s + 1]; + for (let i = 1; i < options[s].length; i++) { + mask |= bits[s][i]; + if (options[s][i] === anchor) hasAnchor = true; + if (options[s][i] === 0 && bits[s][i] & droneMask) hasDrone = true; + } + suffixMask[s] = mask; + suffixAnchor[s] = hasAnchor; + suffixDrone[s] = hasDrone; + } + + const visit = ( + s: number, + present: number, + sounding: number, + fretted: number, + interior: number, + pending: number, + hasAnchor: boolean, + hasDrone: boolean + ): void => { + const remaining = n - s; + if (sounding > family.maxNotes || sounding + remaining < family.minNotes) return; + const missing = requiredMask & ~present; + if (missing & ~suffixMask[s] || popcount(missing) > remaining) return; + if (anchor > 0 && !hasAnchor && !suffixAnchor[s]) return; + if (droneMask && !hasDrone && !suffixDrone[s]) return; + if (fretted > space.maxFretted || (fretted > FINGERS && space.supply !== null && prefixOutOfFingers(frets, space.supply, anchor))) { + onPrune('NEEDS_BARRE'); + return; + } + if (interior > space.maxInteriorMutes) { + onPrune('INTERIOR_MUTES'); + return; + } + if (s === n) { + onLeaf(frets.slice()); + return; + } + const o = options[s]; + const b = bits[s]; + for (let i = 0; i < o.length; i++) { + const f = o[i]; + if (f < 0) { + frets[s] = null; + visit(s + 1, present, sounding, fretted, interior, sounding > 0 ? pending + 1 : 0, hasAnchor, hasDrone); + } else { + frets[s] = f; + visit( + s + 1, + present | b[i], + sounding + 1, + fretted + (f > 0 ? 1 : 0), + interior + pending, + 0, + hasAnchor || f === anchor, + hasDrone || (f === 0 && (b[i] & droneMask) !== 0) + ); + } + } + frets[s] = null; + }; + visit(0, 0, 0, 0, 0, 0, false, false); + } +}; + +const reachHi = (profile: HandProfile, fret: number, factor: number, limit: number): number => + highestReachableFret(fret, allowedSpanMm(profile, fret) * factor, profile.scaleLengthMm, limit); + +/** + * One open-only pass, then one window per anchor fret 1..maxFret reaching as far as the hand allows from it. + * With the thumb allowed, the fingers may start up to THUMB_FRET_WINDOW frets above a thumb on the anchor. + */ +const handWindows = (profile: HandProfile): FretWindow[] => { + const windows: FretWindow[] = [{ anchor: 0, hi: 0 }]; + for (let w = 1; w <= profile.maxFret; w++) { + let hi = reachHi(profile, w, 1, profile.maxFret); + if (profile.allowThumb) { + for (let k = 1; k <= THUMB_FRET_WINDOW; k++) hi = Math.max(hi, reachHi(profile, Math.min(w + k, profile.maxFret), 1, profile.maxFret)); + } + windows.push({ anchor: w, hi }); + } + return windows; +}; + +const maxFrettedNotes = (profile: HandProfile): number => { + if (!profile.noBarre) return Infinity; + const thumb = profile.allowThumb ? 1 : 0; + return (profile.allowTwoStringPartialBarre ? 2 * FINGERS : FINGERS) + thumb; +}; + +// --------------------------------------------------------------------------- +// Empty-result diagnosis +// --------------------------------------------------------------------------- + +/** + * Some open string can ring the root or 5th: allowed by the recipe, and when it is not an allowed bass note, a + * lower string can still sound an allowed bass note below it. + */ +const droneStringExists = (family: VoicingFamily, rootPc: number, tuning: Tuning): boolean => { + const allowed = maskOf(allowedIntervals(family)); + const bassOk = (interval: number): boolean => family.bass === 'any' || family.bass.includes(interval); + const { openMidi } = tuning; + return openMidi.some((midi, s) => { + const interval = intervalFrom(rootPc, pitchClass(midi)); + if ((interval !== 0 && interval !== 7) || !(allowed & (1 << interval))) return false; + if (bassOk(interval)) return true; + for (let t = 0; t < s; t++) { + for (let f = 0; openMidi[t] + f < midi && f <= DIAG_FRET_CAP; f++) { + const below = intervalFrom(rootPc, pitchClass(openMidi[t] + f)); + if (allowed & (1 << below) && bassOk(below)) return true; + } + } + return false; + }); +}; + +const CONSTRAINT_TEXT: Readonly string>> = { + PITCH_CLASSES: () => 'pitch classes (no shape sounds exactly the notes this recipe asks for)', + NOTE_COUNT: () => 'note count (the recipe cannot fit its note limits on these strings)', + BASS: () => 'bass note (no shape puts an allowed note in the bass)', + NO_DRONE_STRING: () => 'no drone string (no open string can ring the root or 5th here)', + NEEDS_BARRE: () => 'needs a barre (the closest shapes need more fingers than fit without one)', + REACH: () => 'reach (the closest shapes need a wider stretch than your hand profile allows)', + PAIR_STRETCH: () => 'finger stretch (the closest shapes pull two neighbouring fingers past their limit)', + FINGER_CROSSING: () => 'finger crossing (the closest shapes need crossed fingers)', + INTERIOR_MUTES: (p) => `interior mutes (the closest shapes mute more than ${p.maxInteriorMutes} inner string${p.maxInteriorMutes === 1 ? '' : 's'})`, + MAX_FRET: (p) => `max fret (the closest shapes sit above fret ${p.maxFret})`, + OPEN_STRINGS: () => 'open strings are off (the closest shapes need open strings)' +}; + +const emptyResult = ( + constraint: BindingConstraint, + family: VoicingFamily, + rootPc: number, + tuning: Tuning, + profile: HandProfile, + onlyThis = 0 +): NonNullable => { + const tail = onlyThis > 0 ? `; ${onlyThis} close shape${onlyThis === 1 ? ' fails' : 's fail'} only on this` : ''; + return { + constraint, + message: `No ${family.label} voicing on ${pitchClassName(rootPc)} in ${tuning.name}: ${CONSTRAINT_TEXT[constraint](profile)}${tail}.` + }; +}; + +/** The profile rules a pitch-valid shape breaks; fingering is checked under a copy without fret/open limits. */ +const profileViolations = (shape: Shape, profile: HandProfile, relaxed: HandProfile): BindingConstraint[] => { + const out: BindingConstraint[] = []; + let lo = Infinity; + let hi = -Infinity; + let open = false; + for (const f of shape) { + if (f === null) continue; + if (f === 0) open = true; + else { + if (f < lo) lo = f; + if (f > hi) hi = f; + } + } + if (hi > profile.maxFret) out.push('MAX_FRET'); + if (open && !profile.allowOpenStrings) out.push('OPEN_STRINGS'); + if (interiorMutes(shape) > profile.maxInteriorMutes) out.push('INTERIOR_MUTES'); + // Span over all fretted notes: the fingering search reports NEEDS_BARRE before reach, so reach is checked here. + // With the thumb allowed its note leaves the span, so the fingering result decides instead. + if (!profile.allowThumb && lo !== Infinity) { + const span = fingertipMm(hi, profile.scaleLengthMm) - fingertipMm(lo, profile.scaleLengthMm); + if (span > allowedSpanMm(profile, lo) + EPS) out.push('REACH'); + } + const fingering: FingeringResult = structurallyUnfingerable(shape, relaxed) + ? { ok: false, reason: 'NEEDS_BARRE', detail: '' } + : findBestFingering(shape, relaxed); + if (fingering.ok === false) { + const constraint = FINGERING_TO_BINDING[fingering.reason]; + if (!out.includes(constraint)) out.push(constraint); + } + return out; +}; + +const mostCounted = (counts: Counts): BindingConstraint | null => { + let best: BindingConstraint | null = null; + for (const key of BINDING_ORDER) { + const n = counts[key] ?? 0; + if (n > 0 && (best === null || n > (counts[best] ?? 0))) best = key; + } + return best; +}; + +/** + * Names the constraint behind an empty result. Searches pitch-valid shapes in a wider space (larger windows, frets + * above maxFret, open strings, no mute or note limits) and picks the constraint that alone rejects the most of them; + * when every near miss breaks several rules, the one breaking the most; with no pitch-valid shape at all, the + * recipe's own failure. + */ +const diagnoseEmpty = ( + family: VoicingFamily, + rootPc: number, + tuning: Tuning, + profile: HandProfile +): NonNullable => { + const strings = tuning.openMidi.length; + const say = (c: BindingConstraint, onlyThis = 0) => emptyResult(c, family, rootPc, tuning, profile, onlyThis); + if (family.minNotes > strings || family.minNotes > family.maxNotes || family.required.length > family.maxNotes) { + return say('NOTE_COUNT'); + } + if (family.drone && !droneStringExists(family, rootPc, tuning)) return say('NO_DRONE_STRING'); + + const diagMax = Math.max(profile.maxFret, Math.min(DIAG_FRET_CAP, profile.maxFret + DIAG_EXTRA_FRETS)); + const windows: FretWindow[] = [{ anchor: 0, hi: 0 }]; + for (let w = 1; w <= diagMax; w++) { + const hi = Math.max(reachHi(profile, w, DIAG_REACH_FACTOR, diagMax), Math.min(diagMax, w + DIAG_MIN_WINDOW_FRETS)); + windows.push({ anchor: w, hi }); + } + const relaxed: HandProfile = { ...profile, maxFret: diagMax, allowOpenStrings: true }; + const single: Counts = {}; + const any: Counts = {}; + const pitch: Counts = {}; + enumerateShapes( + family, + rootPc, + tuning, + { windows, allowOpen: true, maxFretted: Infinity, maxInteriorMutes: Infinity, supply: null }, + () => undefined, + (shape) => { + const match = matchFamily(family, rootPc, shape, tuning); + if (match.ok === false) { + bump(pitch, match.reason); + return; + } + const violations = profileViolations(shape, profile, relaxed); + if (violations.length === 1) bump(single, violations[0]); + for (const v of violations) bump(any, v); + } + ); + const alone = mostCounted(single); + if (alone) return say(alone, single[alone]); + return say(mostCounted(any) ?? mostCounted(pitch) ?? 'PITCH_CLASSES'); +}; + +// --------------------------------------------------------------------------- +// Ranking and output +// --------------------------------------------------------------------------- + +interface Candidate { + shape: Shape; + key: string; + fingering: Fingering; + /** scoreVoicing(...).total; the breakdown is built only for the chosen voicings. */ + total: number; + /** NaN until computed (only needed up front for the 'unusual' sort). */ + novelty: number; + primary: number; + secondary: number; + lowest: number; + stringMask: number; +} + +const compareCandidates = (a: Candidate, b: Candidate): number => + a.primary - b.primary || a.secondary - b.secondary || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0); + +interface Selection { + picked: Candidate[]; + /** Highest score any pick won its round with: a candidate scoring above it can never be picked. */ + worst: number; +} + +/** + * Greedy pick in sort order; a candidate's score grows by DIVERSITY_PENALTY for every selected voicing on the same + * sounding strings within DIVERSITY_FRET_DISTANCE frets. Ties keep the sort order. + */ +const diversified = (sorted: readonly Candidate[], limit: number): Selection => { + const picked: Candidate[] = []; + const taken = new Uint8Array(sorted.length); + let worst = -Infinity; + while (picked.length < limit) { + let best = -1; + let bestScore = Infinity; + for (let i = 0; i < sorted.length; i++) { + if (taken[i]) continue; + const c = sorted[i]; + if (best !== -1 && c.primary >= bestScore) break; + let near = 0; + for (const p of picked) { + if (p.stringMask === c.stringMask && Math.abs(p.lowest - c.lowest) <= DIVERSITY_FRET_DISTANCE) near++; + } + const score = c.primary + DIVERSITY_PENALTY * near; + if (score < bestScore) { + best = i; + bestScore = score; + } + } + if (best === -1) break; + taken[best] = 1; + picked.push(sorted[best]); + if (bestScore > worst) worst = bestScore; + } + return { picked, worst }; +}; + +const select = (sorted: readonly Candidate[], limit: number, diversify: boolean): Selection => { + if (diversify) return diversified(sorted, limit); + const picked = sorted.slice(0, limit); + return { picked, worst: picked.length > 0 ? picked[picked.length - 1].primary : -Infinity }; +}; + +// --------------------------------------------------------------------------- +// Lazy fingering search +// --------------------------------------------------------------------------- + +/** + * Above this many fingering-feasible shapes (wide, barre or thumb profiles) the fingering search runs lazily: shapes + * are searched in order of a lower bound on their sort key, and the search stops once no unsearched shape can enter + * the results. The voicings are identical to an exhaustive search; stats.playable and the fingering rejections then + * count only the searched shapes. Smaller searches stay exhaustive. + */ +export const LAZY_SEARCH_MIN_SHAPES = 2048; + +/** Shapes searched before the first re-selection in the lazy search; doubles each round. */ +const LAZY_FIRST_BATCH = 64; + +/** Margin on lower-bound comparisons; costs are short sums of terms of order 1. */ +const BOUND_EPS = 1e-9; + +/** A shape that passed every check except the fingering search. */ +interface Leaf { + shape: Shape; + /** shapeKey, computed when needed (seeded jitter or ranking). */ + key: string | null; + jitter: number; + /** Lower bound on the candidate's primary sort key (exact for 'unusual'); lazy search only. */ + bound: number; + /** Precomputed for the lazy 'unusual' search, NaN otherwise. */ + novelty: number; +} + +interface NeckTable { + tip: Float64Array; + allowed: Float64Array; +} + +const neckTable = (profile: HandProfile): NeckTable => { + const size = profile.maxFret + 2; + const tip = new Float64Array(size); + const allowed = new Float64Array(size); + for (let f = 0; f < size; f++) { + tip[f] = fingertipMm(f, profile.scaleLengthMm); + allowed[f] = allowedSpanMm(profile, f); + } + return { tip, allowed }; +}; + +const clamp01 = (x: number): number => (x > 1 ? 1 : x > 0 ? x : 0); + +/** + * Lower bound on the reach, finger-count, pinky, thumb and barre terms when fingers 1..4 press exactly the fretted + * notes from string `from` up (string 0 goes to the thumb when `from` is 1). Mirrors fingering.ts's term + * normalization; reach is exact because the fingers sit on exactly those frets. + */ +const fingerTermsBound = (shape: Shape, from: number, cap: number, barreFret: number, neck: NeckTable): number => { + const thumb = from === 1; + let lo = Infinity; + let hi = -Infinity; + let notes = 0; + for (let s = from; s < shape.length; s++) { + const f = shape[s]; + if (f === null || f === 0) continue; + notes++; + if (f < lo) lo = f; + if (f > hi) hi = f; + } + const fingers = notes === 0 ? 0 : fingersNeeded(shape, from, cap, barreFret); + let cost = 0; + if (notes > 0) { + const ratio = (neck.tip[hi] - neck.tip[lo]) / neck.allowed[lo]; + cost += clamp01(ratio * ratio) * PLAYABILITY_WEIGHTS.reach.weight; + } + cost += clamp01((fingers + (thumb ? 1 : 0)) / FINGERS) * PLAYABILITY_WEIGHTS.fingerCount.weight; + if (fingers >= FINGERS) cost += PLAYABILITY_WEIGHTS.pinky.weight; + if (thumb) cost += PLAYABILITY_WEIGHTS.thumb.weight; + // More notes than fingers: some finger covers two or more strings, which the fingering reports as a barre + if (notes > FINGERS) cost += PLAYABILITY_WEIGHTS.barre.weight; + return cost; +}; + +/** Lower bound on scoreVoicing(...).total over every fingering of a shape that passed structurallyUnfingerable. */ +const costLowerBound = (shape: Shape, profile: HandProfile, ctx: ScoreContext, neck: NeckTable): number => { + let lowest = Infinity; + for (const f of shape) if (f !== null && f > 0 && f < lowest) lowest = f; + let physical = clamp01(countInteriorMutes(shape) / 2) * PLAYABILITY_WEIGHTS.interiorMutes.weight; + if (lowest !== Infinity) { + const cap = supplyCap(profile); + const barreFret = profile.noBarre ? -1 : lowest; + let best = fingersNeeded(shape, 0, cap, barreFret) <= FINGERS ? fingerTermsBound(shape, 0, cap, barreFret, neck) : Infinity; + const thumbFret = shape[0]; + if (profile.allowThumb && thumbFret !== null && thumbFret > 0) { + best = Math.min(best, fingerTermsBound(shape, 1, cap, barreFret, neck)); + } + if (best !== Infinity) physical += best; + physical += clamp01(lowest / 12) * PLAYABILITY_WEIGHTS.position.weight; + } + return physical + nonPhysicalCost(shape, ctx); +}; + +const voicingTags = (shape: Shape, tuning: Tuning, rootPc: number, family: VoicingFamily): VoicingTag[] => { + const n = shape.length; + const tags: VoicingTag[] = []; + const intervalOn = (s: number): number => intervalFrom(rootPc, pitchClass(tuning.openMidi[s] + (shape[s] ?? 0))); + const low = shape[0]; + if (low === null || (low === 0 && (intervalOn(0) === 0 || intervalOn(0) === 7))) tags.push('pedalCompatible'); + if (family.drone) tags.push('drone'); + if (shape.some((f) => f === 0)) tags.push('usesOpenStrings'); + let lo = -1; + let hi = -1; + let bass = Infinity; + let bassString = -1; + for (let s = 0; s < n; s++) { + const f = shape[s]; + if (f === null) continue; + if (lo < 0) lo = s; + hi = s; + const midi = tuning.openMidi[s] + f; + if (midi < bass) { + bass = midi; + bassString = s; + } + } + if (bassString >= 0 && intervalOn(bassString) === 0) tags.push('rootInBass'); + if (lo >= 0) { + if (hi <= 2) tags.push('lowStrings'); + if (lo >= 1 && hi <= n - 2) tags.push('middleStrings'); + if (lo >= n - 3) tags.push('topStrings'); + } + return tags; +}; + +const run = (params: GenerateParams, diagnose: boolean): GenerateResult => { + const family = resolveFamily(params.family); + const rootPc = toPitchClass(params.root); + const { tuning, profile } = params; + const sort = params.sort ?? 'easiest'; + const limitRaw = params.limit ?? DEFAULT_VOICING_LIMIT; + // Infinity asks for every playable voicing; only NaN falls back to the default + const limit = Number.isNaN(limitRaw) ? DEFAULT_VOICING_LIMIT : Math.max(0, Math.floor(limitRaw)); + const ctx: ScoreContext = { + tuning, + profile, + rootPc, + family, + distortion: params.distortion ?? true, + context: params.context ?? 'strum', + musical: params.musical ?? true + }; + const references = prepareReferences(params.referenceShapes ?? []); + const { seed } = params; + + const windows = handWindows(profile); + const rejections: Counts = {}; + const stats: GenerateStats = { windows: windows.length, combinations: 0, pitchValid: 0, playable: 0, rejections }; + // Each shape is enumerated once: windows are keyed by the lowest fretted fret, so no deduplication is needed. + const leaves: Leaf[] = []; + + enumerateShapes( + family, + rootPc, + tuning, + { + windows, + allowOpen: profile.allowOpenStrings, + maxFretted: maxFrettedNotes(profile), + maxInteriorMutes: profile.maxInteriorMutes, + supply: fingerSupply(profile) + }, + (constraint) => bump(rejections, constraint), + (shape) => { + stats.combinations++; + const match = matchFamily(family, rootPc, shape, tuning); + if (match.ok === false) { + bump(rejections, match.reason); + return; + } + stats.pitchValid++; + const violation = shapeConstraintViolation(shape, profile); + if (violation !== null) { + bump(rejections, violation); + return; + } + if (structurallyUnfingerable(shape, profile)) { + bump(rejections, 'NEEDS_BARRE'); + return; + } + if (seed === undefined) { + leaves.push({ shape, key: null, jitter: 0, bound: 0, novelty: NaN }); + } else { + const key = shapeKey(shape); + leaves.push({ shape, key, jitter: mulberry32(seedFromString(seed + key))() * SEED_JITTER, bound: 0, novelty: NaN }); + } + } + ); + + const candidates: Candidate[] = []; + const evaluate = (leaf: Leaf): void => { + const { shape } = leaf; + const result = findBestFingering(shape, profile); + if (result.ok === false) { + bump(rejections, FINGERING_TO_BINDING[result.reason]); + return; + } + stats.playable++; + const total = scoreTotal(shape, result.fingering, ctx); + const novelty = sort !== 'unusual' ? NaN : Number.isNaN(leaf.novelty) ? noveltyAgainst(shape, references) : leaf.novelty; + let stringMask = 0; + for (let s = 0; s < shape.length; s++) if (shape[s] !== null) stringMask |= 1 << s; + candidates.push({ + shape, + key: leaf.key ?? shapeKey(shape), + fingering: result.fingering, + total, + novelty, + primary: (sort === 'unusual' ? -novelty : total) + leaf.jitter, + secondary: sort === 'unusual' ? total : 0, + lowest: result.fingering.metrics.lowestFret ?? 0, + stringMask + }); + }; + + const diversify = params.diversify !== false; + let chosen: Candidate[] = []; + if (leaves.length <= LAZY_SEARCH_MIN_SHAPES || limit >= leaves.length) { + for (const leaf of leaves) evaluate(leaf); + candidates.sort(compareCandidates); + chosen = select(candidates, limit, diversify).picked; + } else { + // Exact: once every unsearched shape's bound exceeds the worst winning score, none of them can be picked + // (a pick needs a score <= that, and a score is at least the primary key). + const neck = sort === 'unusual' ? null : neckTable(profile); + for (const leaf of leaves) { + if (neck === null) { + leaf.novelty = noveltyAgainst(leaf.shape, references); + leaf.bound = -leaf.novelty + leaf.jitter; + } else { + leaf.bound = costLowerBound(leaf.shape, profile, ctx, neck) + leaf.jitter; + } + } + // Stable sort: equal bounds keep enumeration order, so the searched set (and the stats) stay deterministic + leaves.sort((a, b) => a.bound - b.bound); + let next = 0; + while (candidates.length < Math.max(1, limit) && next < leaves.length) evaluate(leaves[next++]); + // Growing batches: the first picks often cost far more than their bounds, so re-selecting early tightens the + // stopping score before most shapes are searched. Every stop follows a selection on the current pool. + let batch = LAZY_FIRST_BATCH; + while (limit > 0) { + candidates.sort(compareCandidates); + const { picked, worst } = select(candidates, limit, diversify); + chosen = picked; + const stop = worst + BOUND_EPS; + if (next >= leaves.length || leaves[next].bound > stop) break; + for (let n = 0; n < batch && next < leaves.length && leaves[next].bound <= stop; n++) evaluate(leaves[next++]); + batch *= 2; + } + } + + const voicings: GeneratedVoicing[] = chosen.map((c) => { + const naming = nameVoicing(c.shape, tuning, rootPc, family); + const breakdown = scoreVoicing(c.shape, c.fingering, ctx); + return { + shape: c.shape, + fingering: c.fingering, + midi: shapeToMidi(c.shape, tuning), + degrees: naming.degrees, + degreesByString: naming.degreesByString, + symbol: naming.symbol, + name: naming.name, + familyId: family.id, + cost: breakdown.total, + breakdown, + novelty: Number.isNaN(c.novelty) ? noveltyAgainst(c.shape, references) : c.novelty, + tags: voicingTags(c.shape, tuning, rootPc, family) + }; + }); + + const result: GenerateResult = { voicings, stats }; + if (candidates.length === 0 && diagnose) result.empty = diagnoseEmpty(family, rootPc, tuning, profile); + return result; +}; + +/** + * All playable voicings of `family` on `root` for the hand profile, best first. Hard rules: exact pitch classes, + * the profile's fret/open/mute limits and a valid fingering. An empty result names its binding constraint. + */ +export const generateVoicings = (params: GenerateParams): GenerateResult => run(params, true); + +export interface ClosestVoicingResult { + result: GenerateResult; + /** Relaxation labels used, null when the exact recipe worked or nothing did. */ + relaxed: string[] | null; + /** Why the exact recipe was empty (present whenever it was), so callers need not search it again. */ + exactEmpty?: NonNullable; +} + +/** + * The exact recipe first, then each step of relaxFamily's ladder; returns the first result with voicings and the + * relaxation labels used (null when the exact recipe worked). When nothing works, the exact (empty) result. + */ +export const findClosestVoicing = (params: GenerateParams): ClosestVoicingResult => { + const exact = run(params, true); + if (exact.empty === undefined) return { result: exact, relaxed: null }; + const exactEmpty = exact.empty; + for (const step of relaxFamily(resolveFamily(params.family))) { + const result = run({ ...params, family: step.family }, false); + if (result.voicings.length > 0) return { result, relaxed: step.relaxed, exactEmpty }; + } + return { result: exact, relaxed: null, exactEmpty }; +}; diff --git a/engine/geometry.test.ts b/engine/geometry.test.ts new file mode 100644 index 0000000..009b133 --- /dev/null +++ b/engine/geometry.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_SCALE_LENGTH_MM, + SCALE_LENGTH_PRESETS, + fretWireMm, + fingertipMm, + spanMm, + highestReachableFret +} from './geometry'; + +const L = DEFAULT_SCALE_LENGTH_MM; + +describe('fret wire distance', () => { + it('uses 647.7 mm as the default scale', () => { + expect(L).toBeCloseTo(647.7, 6); + }); + + it('puts the 12th fret at half the scale length', () => { + expect(Math.abs(fretWireMm(12, L) - L / 2)).toBeLessThan(0.01); + expect(Math.abs(fretWireMm(24, L) - (3 * L) / 4)).toBeLessThan(0.01); + expect(fretWireMm(0, L)).toBe(0); + }); + + it('grows monotonically with shrinking fret widths', () => { + for (let n = 1; n < 24; n++) { + const width = fretWireMm(n, L) - fretWireMm(n - 1, L); + const nextWidth = fretWireMm(n + 1, L) - fretWireMm(n, L); + expect(nextWidth).toBeLessThan(width); + } + }); +}); + +describe('fingertip model', () => { + it('is 0 for open strings and sits behind the wire', () => { + expect(fingertipMm(0, L)).toBe(0); + const tip = fingertipMm(5, L); + expect(tip).toBeGreaterThan(fretWireMm(4, L)); + expect(tip).toBeLessThan(fretWireMm(5, L)); + }); + + it('matches the documented reference spans at 25.5"', () => { + expect(spanMm(1, 4, L)).toBeCloseTo(98.98, 1); + expect(spanMm(7, 11, L)).toBeCloseTo(90.77, 1); + }); + + it('makes fret 12 -> 17 shorter than fret 1 -> 4', () => { + expect(spanMm(12, 17, L)).toBeLessThan(spanMm(1, 4, L)); + }); + + it('scales with scale length', () => { + const short = SCALE_LENGTH_PRESETS.find((p) => p.id === '24.75')!.mm; + const long = SCALE_LENGTH_PRESETS.find((p) => p.id === '27')!.mm; + expect(spanMm(1, 4, short)).toBeLessThan(spanMm(1, 4, long)); + }); +}); + +describe('highestReachableFret', () => { + it('reaches fret 4 from fret 1 with the default low reach', () => { + expect(highestReachableFret(1, spanMm(1, 4, L), L)).toBe(4); + expect(highestReachableFret(1, spanMm(1, 4, L) - 1, L)).toBe(3); + }); + + it('covers more frets higher up the neck for the same reach', () => { + const reach = spanMm(1, 4, L); + expect(highestReachableFret(12, reach, L) - 12).toBeGreaterThan(3); + }); + + it('respects the fret limit', () => { + expect(highestReachableFret(20, 500, L, 22)).toBe(22); + }); +}); diff --git a/engine/geometry.ts b/engine/geometry.ts new file mode 100644 index 0000000..4e2dd3c --- /dev/null +++ b/engine/geometry.ts @@ -0,0 +1,50 @@ +// Neck geometry. Distances are millimetres along the neck from the nut. + +export const MM_PER_INCH = 25.4; + +/** Fender-style 25.5" scale. */ +export const DEFAULT_SCALE_LENGTH_MM = 25.5 * MM_PER_INCH; + +export const SCALE_LENGTH_PRESETS: readonly { id: string; label: string; mm: number }[] = [ + { id: '24.75', label: '24.75"', mm: 24.75 * MM_PER_INCH }, + { id: '25.5', label: '25.5"', mm: 25.5 * MM_PER_INCH }, + { id: '26.5', label: '26.5"', mm: 26.5 * MM_PER_INCH }, + { id: '27', label: '27"', mm: 27 * MM_PER_INCH } +]; + +/** Model: the fingertip presses 70 percent of the way from the previous fret wire to this one. */ +export const FINGERTIP_FRACTION = 0.7; + +/** Approximate centre-to-centre string spacing at mid-neck. Used only for minor lateral costs. */ +export const DEFAULT_STRING_SPACING_MM = 10.5; + +/** Distance from the nut to fret wire n: d(n) = L - L / 2^(n/12). */ +export const fretWireMm = (fret: number, scaleLengthMm: number): number => + scaleLengthMm - scaleLengthMm / Math.pow(2, fret / 12); + +/** Fingertip position for a note at `fret`; 0 for open strings. */ +export const fingertipMm = (fret: number, scaleLengthMm: number): number => { + if (fret <= 0) return 0; + const prev = fretWireMm(fret - 1, scaleLengthMm); + return prev + FINGERTIP_FRACTION * (fretWireMm(fret, scaleLengthMm) - prev); +}; + +/** Fingertip distance between two fretted notes (order-independent). */ +export const spanMm = (fretA: number, fretB: number, scaleLengthMm: number): number => + Math.abs(fingertipMm(fretB, scaleLengthMm) - fingertipMm(fretA, scaleLengthMm)); + +/** Highest fret reachable from `fromFret` within `reachMm` of fingertip travel (>= fromFret). */ +export const highestReachableFret = ( + fromFret: number, + reachMm: number, + scaleLengthMm: number, + limitFret = 36 +): number => { + const start = Math.max(1, fromFret); + const origin = fingertipMm(start, scaleLengthMm); + let fret = start; + while (fret + 1 <= limitFret && fingertipMm(fret + 1, scaleLengthMm) - origin <= reachMm + 1e-9) { + fret++; + } + return fret; +}; diff --git a/engine/handProfile.test.ts b/engine/handProfile.test.ts new file mode 100644 index 0000000..40ccb1c --- /dev/null +++ b/engine/handProfile.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_HAND_PROFILE, + DEFAULT_CALIBRATION, + createDefaultHandProfile, + maxReachMm, + allowedSpanMm, + pairLimitFraction, + profileFromCalibration, + calibrationFromProfile, + validateHandProfile, + migrateHandProfile, + handProfileHash +} from './handProfile'; +import { spanMm, DEFAULT_SCALE_LENGTH_MM } from './geometry'; + +const L = DEFAULT_SCALE_LENGTH_MM; + +describe('default profile', () => { + it('uses the documented defaults', () => { + const p = DEFAULT_HAND_PROFILE; + expect(p.scaleLengthMm).toBeCloseTo(647.7, 6); + expect(p.reachAtLowMm).toBeCloseTo(spanMm(1, 4, L), 6); + expect(p.reachAtHighMm).toBeCloseTo(spanMm(7, 11, L), 6); + expect(p.lowRefFret).toBe(1); + expect(p.highRefFret).toBe(7); + expect(p.pairLimits).toEqual({ indexMiddle: 0.65, middleRing: 0.35, ringPinky: 0.4 }); + expect(p.stretchTolerance).toBe(1); + expect(p.noBarre).toBe(true); + expect(p.allowTwoStringPartialBarre).toBe(false); + expect(p.allowThumb).toBe(false); + expect(p.maxFret).toBe(15); + expect(p.allowOpenStrings).toBe(true); + expect(p.maxInteriorMutes).toBe(1); + expect(p.comfortableSixteenthBpm).toBe(110); + }); + + it('matches the default calibration answers', () => { + expect(profileFromCalibration(DEFAULT_CALIBRATION)).toEqual(DEFAULT_HAND_PROFILE); + }); +}); + +describe('maxReachMm', () => { + const p = DEFAULT_HAND_PROFILE; + + it('returns the calibration points exactly', () => { + expect(maxReachMm(p, 1)).toBeCloseTo(p.reachAtLowMm, 9); + expect(maxReachMm(p, 7)).toBeCloseTo(p.reachAtHighMm, 9); + }); + + it('interpolates linearly in between', () => { + expect(maxReachMm(p, 4)).toBeCloseTo((p.reachAtLowMm + p.reachAtHighMm) / 2, 9); + }); + + it('clamps outside the calibration range', () => { + expect(maxReachMm(p, 0)).toBe(p.reachAtLowMm); + expect(maxReachMm(p, 15)).toBe(p.reachAtHighMm); + }); + + it('applies the stretch tolerance to the allowed span', () => { + const stretchy = { ...p, stretchTolerance: 1.1 }; + expect(allowedSpanMm(stretchy, 1)).toBeCloseTo(p.reachAtLowMm * 1.1, 9); + }); +}); + +describe('pairLimitFraction', () => { + it('uses adjacent limits and sums across skipped fingers', () => { + const p = DEFAULT_HAND_PROFILE; + expect(pairLimitFraction(p, 1, 2)).toBeCloseTo(0.65, 9); + expect(pairLimitFraction(p, 2, 3)).toBeCloseTo(0.35, 9); + expect(pairLimitFraction(p, 3, 4)).toBeCloseTo(0.4, 9); + expect(pairLimitFraction(p, 1, 3)).toBeCloseTo(1.0, 9); + expect(pairLimitFraction(p, 4, 1)).toBeCloseTo(1.4, 9); + }); +}); + +describe('calibration', () => { + it('converts pinky answers to mm reach', () => { + const p = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 5, highPinkyFret: 12, allowStretches: true }); + expect(p.reachAtLowMm).toBeCloseTo(spanMm(1, 5, L), 9); + expect(p.reachAtHighMm).toBeCloseTo(spanMm(7, 12, L), 9); + expect(p.stretchTolerance).toBe(1.1); + }); + + it('clamps answers to their documented ranges', () => { + const p = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 1, highPinkyFret: 20 }); + expect(p.reachAtLowMm).toBeCloseTo(spanMm(1, 3, L), 9); + expect(p.reachAtHighMm).toBeCloseTo(spanMm(7, 13, L), 9); + }); + + it('round-trips through calibrationFromProfile', () => { + const answers = { ...DEFAULT_CALIBRATION, lowPinkyFret: 6, highPinkyFret: 10, maxFret: 12 }; + expect(calibrationFromProfile(profileFromCalibration(answers))).toEqual(answers); + }); + + it('scales reach with scale length', () => { + const baritone = profileFromCalibration({ ...DEFAULT_CALIBRATION, scaleLengthMm: 27 * 25.4 }); + expect(baritone.reachAtLowMm).toBeGreaterThan(DEFAULT_HAND_PROFILE.reachAtLowMm); + }); +}); + +describe('validateHandProfile', () => { + it('falls back to defaults for garbage', () => { + expect(validateHandProfile(null)).toEqual(DEFAULT_HAND_PROFILE); + expect(validateHandProfile('nope')).toEqual(DEFAULT_HAND_PROFILE); + expect(validateHandProfile({ reachAtLowMm: 'far', noBarre: 'yes' })).toEqual(DEFAULT_HAND_PROFILE); + }); + + it('clamps out-of-range numbers and keeps valid fields', () => { + const p = validateHandProfile({ ...DEFAULT_HAND_PROFILE, maxFret: 99, maxInteriorMutes: -3, noBarre: false }); + expect(p.maxFret).toBe(24); + expect(p.maxInteriorMutes).toBe(0); + expect(p.noBarre).toBe(false); + }); + + it('repairs inverted reference frets', () => { + const p = validateHandProfile({ ...DEFAULT_HAND_PROFILE, lowRefFret: 9, highRefFret: 3 }); + expect(p.lowRefFret).toBe(1); + expect(p.highRefFret).toBe(7); + }); + + it('migrates version-less stored data', () => { + const { version: _v, ...legacy } = createDefaultHandProfile(); + expect(migrateHandProfile(legacy)).toEqual(DEFAULT_HAND_PROFILE); + }); +}); + +describe('handProfileHash', () => { + it('is stable and changes when a relevant field changes', () => { + expect(handProfileHash(DEFAULT_HAND_PROFILE)).toBe(handProfileHash(createDefaultHandProfile())); + expect(handProfileHash({ ...DEFAULT_HAND_PROFILE, maxFret: 12 })).not.toBe(handProfileHash(DEFAULT_HAND_PROFILE)); + }); +}); diff --git a/engine/handProfile.ts b/engine/handProfile.ts new file mode 100644 index 0000000..56a0c20 --- /dev/null +++ b/engine/handProfile.ts @@ -0,0 +1,204 @@ +// Personal hand profile: comfort guidance for one player's hand, not a medical or safety assessment. +import type { CalibrationAnswers, FingerNumber, HandProfile, PairLimits } from './types'; +import { DEFAULT_SCALE_LENGTH_MM, spanMm } from './geometry'; + +/** Heuristic defaults (not research-backed): middle-ring is the most constrained pair. */ +export const DEFAULT_PAIR_LIMITS: PairLimits = { indexMiddle: 0.65, middleRing: 0.35, ringPinky: 0.4 }; + +export const STRETCH_TOLERANCE_ALLOW = 1.1; + +export const CALIBRATION_LOW_INDEX_FRET = 1; +export const CALIBRATION_HIGH_INDEX_FRET = 7; +export const CALIBRATION_LOW_PINKY_RANGE = [3, 7] as const; +export const CALIBRATION_HIGH_PINKY_RANGE = [9, 13] as const; + +export const PROFILE_LIMITS = { + scaleLengthMm: [550, 800], + reachMm: [30, 220], + refFret: [1, 15], + pairLimit: [0.1, 1], + stretchTolerance: [0.8, 1.5], + maxFret: [3, 24], + maxInteriorMutes: [0, 4], + comfortableSixteenthBpm: [40, 300] +} as const; + +export const createDefaultHandProfile = (scaleLengthMm: number = DEFAULT_SCALE_LENGTH_MM): HandProfile => ({ + version: 1, + scaleLengthMm, + reachAtLowMm: spanMm(CALIBRATION_LOW_INDEX_FRET, 4, scaleLengthMm), + reachAtHighMm: spanMm(CALIBRATION_HIGH_INDEX_FRET, 11, scaleLengthMm), + lowRefFret: CALIBRATION_LOW_INDEX_FRET, + highRefFret: CALIBRATION_HIGH_INDEX_FRET, + pairLimits: { ...DEFAULT_PAIR_LIMITS }, + stretchTolerance: 1, + noBarre: true, + allowTwoStringPartialBarre: false, + allowThumb: false, + maxFret: 15, + allowOpenStrings: true, + maxInteriorMutes: 1, + comfortableSixteenthBpm: 110 +}); + +export const DEFAULT_HAND_PROFILE: HandProfile = createDefaultHandProfile(); + +export const DEFAULT_CALIBRATION: CalibrationAnswers = { + scaleLengthMm: DEFAULT_SCALE_LENGTH_MM, + lowPinkyFret: 4, + highPinkyFret: 11, + allowStretches: false, + noBarre: true, + allowOpenStrings: true, + maxFret: 15, + comfortableSixteenthBpm: 110 +}; + +/** + * Comfortable index -> pinky fingertip reach with the index at `atFret`. + * Linear between the two calibration points, constant outside them. + */ +export const maxReachMm = (profile: HandProfile, atFret: number): number => { + const { lowRefFret, highRefFret, reachAtLowMm, reachAtHighMm } = profile; + if (highRefFret <= lowRefFret) return reachAtLowMm; + if (atFret <= lowRefFret) return reachAtLowMm; + if (atFret >= highRefFret) return reachAtHighMm; + const t = (atFret - lowRefFret) / (highRefFret - lowRefFret); + return reachAtLowMm + t * (reachAtHighMm - reachAtLowMm); +}; + +/** Allowed total span for a shape whose lowest fretted note is at `lowestFret`. */ +export const allowedSpanMm = (profile: HandProfile, lowestFret: number): number => + maxReachMm(profile, lowestFret) * profile.stretchTolerance; + +/** + * Pair limit as a fraction of the allowed span for fingers `a` < `b` (1..4). + * Non-adjacent fingers get the sum of the adjacent limits between them. + */ +export const pairLimitFraction = (profile: HandProfile, a: FingerNumber, b: FingerNumber): number => { + const lo = Math.min(a, b); + const hi = Math.max(a, b); + const steps: number[] = [profile.pairLimits.indexMiddle, profile.pairLimits.middleRing, profile.pairLimits.ringPinky]; + let sum = 0; + for (let f = Math.max(1, lo); f < hi; f++) sum += steps[f - 1]; + return sum; +}; + +const clamp = (value: number, [lo, hi]: readonly [number, number]): number => Math.min(hi, Math.max(lo, value)); + +export const profileFromCalibration = (answers: CalibrationAnswers): HandProfile => { + const L = clamp(answers.scaleLengthMm, PROFILE_LIMITS.scaleLengthMm); + const lowPinky = Math.round(clamp(answers.lowPinkyFret, CALIBRATION_LOW_PINKY_RANGE)); + const highPinky = Math.round(clamp(answers.highPinkyFret, CALIBRATION_HIGH_PINKY_RANGE)); + return { + ...createDefaultHandProfile(L), + reachAtLowMm: spanMm(CALIBRATION_LOW_INDEX_FRET, lowPinky, L), + reachAtHighMm: spanMm(CALIBRATION_HIGH_INDEX_FRET, highPinky, L), + stretchTolerance: answers.allowStretches ? STRETCH_TOLERANCE_ALLOW : 1, + noBarre: answers.noBarre, + allowOpenStrings: answers.allowOpenStrings, + maxFret: Math.round(clamp(answers.maxFret, PROFILE_LIMITS.maxFret)), + comfortableSixteenthBpm: Math.round(clamp(answers.comfortableSixteenthBpm, PROFILE_LIMITS.comfortableSixteenthBpm)) + }; +}; + +/** Nearest calibration answers that reproduce a profile (for pre-filling the calibration form). */ +export const calibrationFromProfile = (profile: HandProfile): CalibrationAnswers => { + const nearestPinky = (indexFret: number, reach: number, [lo, hi]: readonly [number, number]): number => { + let best = lo; + for (let f = lo; f <= hi; f++) { + if (Math.abs(spanMm(indexFret, f, profile.scaleLengthMm) - reach) < Math.abs(spanMm(indexFret, best, profile.scaleLengthMm) - reach)) { + best = f; + } + } + return best; + }; + return { + scaleLengthMm: profile.scaleLengthMm, + lowPinkyFret: nearestPinky(CALIBRATION_LOW_INDEX_FRET, profile.reachAtLowMm, CALIBRATION_LOW_PINKY_RANGE), + highPinkyFret: nearestPinky(CALIBRATION_HIGH_INDEX_FRET, profile.reachAtHighMm, CALIBRATION_HIGH_PINKY_RANGE), + allowStretches: profile.stretchTolerance > 1, + noBarre: profile.noBarre, + allowOpenStrings: profile.allowOpenStrings, + maxFret: profile.maxFret, + comfortableSixteenthBpm: profile.comfortableSixteenthBpm + }; +}; + +const num = (value: unknown, fallback: number, range: readonly [number, number], integer = false): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + const clamped = clamp(value, range); + return integer ? Math.round(clamped) : clamped; +}; + +const bool = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback); + +/** + * Sanitizes untrusted input (localStorage, imported JSON) into a valid profile. + * Missing or invalid fields fall back to defaults; numbers are clamped to sane ranges. + */ +export const validateHandProfile = (input: unknown): HandProfile => { + const raw = (typeof input === 'object' && input !== null ? input : {}) as Record; + const scaleLengthMm = num(raw.scaleLengthMm, DEFAULT_SCALE_LENGTH_MM, PROFILE_LIMITS.scaleLengthMm); + const base = createDefaultHandProfile(scaleLengthMm); + const pairsRaw = (typeof raw.pairLimits === 'object' && raw.pairLimits !== null ? raw.pairLimits : {}) as Record; + + let lowRefFret = num(raw.lowRefFret, base.lowRefFret, PROFILE_LIMITS.refFret, true); + let highRefFret = num(raw.highRefFret, base.highRefFret, PROFILE_LIMITS.refFret, true); + if (highRefFret <= lowRefFret) { + lowRefFret = base.lowRefFret; + highRefFret = base.highRefFret; + } + + return { + version: 1, + scaleLengthMm, + reachAtLowMm: num(raw.reachAtLowMm, base.reachAtLowMm, PROFILE_LIMITS.reachMm), + reachAtHighMm: num(raw.reachAtHighMm, base.reachAtHighMm, PROFILE_LIMITS.reachMm), + lowRefFret, + highRefFret, + pairLimits: { + indexMiddle: num(pairsRaw.indexMiddle, base.pairLimits.indexMiddle, PROFILE_LIMITS.pairLimit), + middleRing: num(pairsRaw.middleRing, base.pairLimits.middleRing, PROFILE_LIMITS.pairLimit), + ringPinky: num(pairsRaw.ringPinky, base.pairLimits.ringPinky, PROFILE_LIMITS.pairLimit) + }, + stretchTolerance: num(raw.stretchTolerance, base.stretchTolerance, PROFILE_LIMITS.stretchTolerance), + noBarre: bool(raw.noBarre, base.noBarre), + allowTwoStringPartialBarre: bool(raw.allowTwoStringPartialBarre, base.allowTwoStringPartialBarre), + allowThumb: bool(raw.allowThumb, base.allowThumb), + maxFret: num(raw.maxFret, base.maxFret, PROFILE_LIMITS.maxFret, true), + allowOpenStrings: bool(raw.allowOpenStrings, base.allowOpenStrings), + maxInteriorMutes: num(raw.maxInteriorMutes, base.maxInteriorMutes, PROFILE_LIMITS.maxInteriorMutes, true), + comfortableSixteenthBpm: num(raw.comfortableSixteenthBpm, base.comfortableSixteenthBpm, PROFILE_LIMITS.comfortableSixteenthBpm, true) + }; +}; + +/** + * Upgrades any stored shape to the current version. Version 1 is the first version, so today this + * only sanitizes; unknown future versions are treated as untrusted input. + */ +export const migrateHandProfile = (raw: unknown): HandProfile => validateHandProfile(raw); + +/** Stable key for memoizing generation per profile. */ +export const handProfileHash = (profile: HandProfile): string => { + const r = (n: number) => Math.round(n * 1000) / 1000; + return [ + profile.version, + r(profile.scaleLengthMm), + r(profile.reachAtLowMm), + r(profile.reachAtHighMm), + profile.lowRefFret, + profile.highRefFret, + r(profile.pairLimits.indexMiddle), + r(profile.pairLimits.middleRing), + r(profile.pairLimits.ringPinky), + r(profile.stretchTolerance), + profile.noBarre ? 1 : 0, + profile.allowTwoStringPartialBarre ? 1 : 0, + profile.allowThumb ? 1 : 0, + profile.maxFret, + profile.allowOpenStrings ? 1 : 0, + profile.maxInteriorMutes, + profile.comfortableSixteenthBpm + ].join('|'); +}; diff --git a/engine/index.test.ts b/engine/index.test.ts new file mode 100644 index 0000000..db0f99c --- /dev/null +++ b/engine/index.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import * as engine from './index'; + +describe('engine barrel', () => { + it('exposes the version and the main entry points', () => { + expect(engine.ENGINE_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + for (const name of [ + 'shapeToMidi', + 'findBestFingering', + 'generateVoicings', + 'findClosestVoicing', + 'optimizeSequence', + 'fromLegacyNotes', + 'nameVoicing', + 'DEFAULT_HAND_PROFILE' + ]) { + expect(engine).toHaveProperty(name); + } + }); + + it('runs one generation end to end', () => { + const result = engine.generateVoicings({ + root: 'E', + family: 'power5', + tuning: engine.E_STANDARD, + profile: engine.DEFAULT_HAND_PROFILE + }); + expect(result.voicings.length).toBeGreaterThan(0); + const first = result.voicings[0]; + expect(first.midi).toEqual(engine.shapeToMidi(first.shape, engine.E_STANDARD)); + }); +}); diff --git a/engine/index.ts b/engine/index.ts new file mode 100644 index 0000000..621e43d --- /dev/null +++ b/engine/index.ts @@ -0,0 +1,18 @@ +// Public API of the playability engine. Pure TypeScript, no runtime dependencies. +// Vendored into Chord Explorer by its scripts/sync-engine.mjs; bump on any behaviour change. +export const ENGINE_VERSION = '0.1.0'; + +export * from './types'; +export * from './pitch'; +export * from './tuning'; +export * from './random'; +export * from './geometry'; +export * from './shape'; +export * from './handProfile'; +export * from './weights'; +export * from './fingering'; +export * from './voicingSpec'; +export * from './naming'; +export * from './playability'; +export * from './generateVoicings'; +export * from './transition'; diff --git a/engine/naming.test.ts b/engine/naming.test.ts new file mode 100644 index 0000000..5d89999 --- /dev/null +++ b/engine/naming.test.ts @@ -0,0 +1,368 @@ +import { describe, it, expect } from 'vitest'; +import { degreesByString, degreeString, chordSymbol, nameVoicing } from './naming'; +import { getFamily, fromLegacyNotes } from './voicingSpec'; +import type { Shape, VoicingFamily } from './types'; +import { E_STANDARD, DROP_D } from './tuning'; +import { createRng } from './random'; +import { pitchClassName, pitchClass, intervalName } from './pitch'; +import { shapeToMidi, bassMidi } from './shape'; + +const x = null; +const C = 0; +const D = 2; +const E = 4; + +const fam = (id: string): VoicingFamily => { + const family = getFamily(id); + if (!family) throw new Error(`missing family ${id}`); + return family; +}; + +describe('degreesByString', () => { + it('labels each string relative to the root, null when muted', () => { + expect(degreesByString([x, 3, 2, 0, 1, 0], E_STANDARD, C)).toEqual([null, '1', '3', '5', '1', '3']); + expect(degreesByString([0, 1, x, x, x, x], E_STANDARD, E)).toEqual(['1', 'b5', null, null, null, null]); + }); + + it('follows the tuning (Drop D low string)', () => { + expect(degreesByString([0, 0, 0, x, x, x], DROP_D, D)).toEqual(['1', '5', '1', null, null, null]); + expect(degreesByString([0, 0, 0, x, x, x], E_STANDARD, D)).toEqual(['2', '5', '1', null, null, null]); + }); +}); + +describe('degreeString', () => { + it('lists sounding notes low -> high using the pitch.ts degree table', () => { + // E2 B2 F3 + expect(degreeString([0, 2, 3, x, x, x], E_STANDARD, E)).toBe('1 5 b2'); + expect(degreeString([0, 2, 2, 1, 0, 0], E_STANDARD, E)).toBe('1 5 1 3 5 1'); + }); + + it('keeps octave doublings', () => { + expect(degreeString([0, 2, 2, x, x, x], E_STANDARD, E)).toBe('1 5 1'); + expect(degreeString([0, 0, 0, x, x, x], DROP_D, D)).toBe('1 5 1'); + }); + + it('sorts by pitch, not by string', () => { + // String 0 fret 7 is B2, above the open A2 on string 1. + expect(degreeString([7, 0, x, x, x, x], E_STANDARD, E)).toBe('4 5'); + expect(degreeString([1, x, 2, x, x, x], E_STANDARD, E)).toBe('b2 1'); + }); + + it('is empty for a fully muted shape', () => { + expect(degreeString([x, x, x, x, x, x], E_STANDARD, E)).toBe(''); + }); +}); + +describe('chordSymbol templates', () => { + const templates: [string, number[]][] = [ + ['5', [0, 7]], + ['5(b9)', [0, 1, 7]], + ['', [0, 4, 7]], + ['m', [0, 3, 7]], + ['dim', [0, 3, 6]], + ['aug', [0, 4, 8]], + ['sus2', [0, 2, 7]], + ['sus4', [0, 5, 7]], + ['7', [0, 4, 7, 10]], + ['m7', [0, 3, 7, 10]], + ['maj7', [0, 4, 7, 11]], + ['mMaj7', [0, 3, 7, 11]], + ['m7b5', [0, 3, 6, 10]], + ['dim7', [0, 3, 6, 9]], + ['7sus4', [0, 5, 7, 10]], + ['6', [0, 4, 7, 9]], + ['m6', [0, 3, 7, 9]], + ['add9', [0, 2, 4, 7]], + ['m(add9)', [0, 2, 3, 7]], + ['(addb9)', [0, 1, 4, 7]], + ['7(b9)', [0, 1, 4, 7, 10]], + ['9', [0, 2, 4, 7, 10]], + ['m9', [0, 2, 3, 7, 10]], + ['maj9', [0, 2, 4, 7, 11]], + ['m11', [0, 2, 3, 5, 7, 10]] + ]; + + it.each(templates)('matches %j exactly in root position', (symbol, intervals) => { + expect(chordSymbol(C, intervals, 0)).toEqual({ symbol, omissions: [] }); + }); + + it('ignores order, duplicates and octave-wrapped intervals', () => { + expect(chordSymbol(C, [7, 4, 0, 7, 12, 16, -5], 0)).toEqual({ symbol: '', omissions: [] }); + expect(chordSymbol(E, [10, 0, 4, 10, 0], null).symbol).toBe('7(no5)'); + }); +}); + +describe('chordSymbol omissions', () => { + const sym = (intervals: number[], bass: number | null = 0): string | null => chordSymbol(C, intervals, bass).symbol; + + it('marks a missing perfect fifth', () => { + expect(chordSymbol(C, [0, 4, 10], 0)).toEqual({ symbol: '7(no5)', omissions: ['(no5)'] }); + expect(sym([0, 5, 10])).toBe('7sus4(no5)'); + expect(sym([0, 3, 10])).toBe('m7(no5)'); + expect(sym([0, 4, 11])).toBe('maj7(no5)'); + expect(sym([0, 2, 4])).toBe('add9(no5)'); + expect(sym([0, 2, 3])).toBe('m(add9)(no5)'); + expect(sym([0, 1, 4])).toBe('(addb9)(no5)'); + expect(sym([0, 1, 4, 10])).toBe('7(b9)(no5)'); + expect(sym([0, 4, 9])).toBe('6(no5)'); + }); + + it('marks a missing third only where the symbol does not claim a quality', () => { + expect(chordSymbol(C, [0, 7, 10], 0)).toEqual({ symbol: '7(no3)', omissions: ['(no3)'] }); + expect(sym([0, 7, 11])).toBe('maj7(no3)'); + // m7b5 without its third would need a quality the notes no longer show. + expect(sym([0, 6, 10])).toBeNull(); + }); + + it('never drops a flat or sharp fifth: that would erase the quality', () => { + expect(sym([0, 3, 9])).toBe('m6(no5)'); + expect(sym([0, 4])).toBeNull(); + }); + + it('marks a missing root without inventing another root', () => { + expect(chordSymbol(C, [4, 7, 10], 4)).toEqual({ symbol: '7(no root)/E', omissions: ['(no root)'] }); + expect(sym([3, 7, 10], 3)).toBe('m7(no root)/D#'); + expect(sym([2, 4, 10], 2)).toBe('9(no5)(no root)/D'); + expect(sym([4, 7, 10], null)).toBe('7(no root)'); + // Two pitch classes are too thin for a rootless symbol. + expect(sym([4, 7], 4)).toBeNull(); + expect(sym([4, 10], 4)).toBeNull(); + }); + + it('needs at least three pitch classes once anything is omitted', () => { + expect(sym([0, 3])).toBeNull(); + expect(sym([0, 10])).toBeNull(); + }); +}); + +describe('chordSymbol slash bass and non-chords', () => { + it('appends the bass when it is not the root, spelled with sharps', () => { + expect(chordSymbol(C, [0, 4, 7], 4)).toEqual({ symbol: '/E', omissions: [] }); + expect(chordSymbol(C, [0, 4, 7], 7).symbol).toBe('/G'); + expect(chordSymbol(E, [0, 7], 7).symbol).toBe('5/B'); + expect(chordSymbol(10, [0, 3, 7], 3).symbol).toBe('m/C#'); + expect(chordSymbol(C, [0, 4, 7], null).symbol).toBe(''); + }); + + it('counts the bass as sounding even if the caller left it out of the intervals', () => { + expect(chordSymbol(C, [0, 4], 7).symbol).toBe('/G'); + }); + + it('gives dyads and clusters no symbol', () => { + for (const intervals of [[0, 1], [0, 6], [0, 5], [0, 2], [0, 3], [0, 4], [0, 11], [0, 1, 2], [0, 1, 2, 3]]) { + expect(chordSymbol(E, intervals, 0), JSON.stringify(intervals)).toEqual({ symbol: null, omissions: [] }); + } + }); + + it('gives nothing for empty or single pitch classes', () => { + expect(chordSymbol(E, [], null)).toEqual({ symbol: null, omissions: [] }); + expect(chordSymbol(E, [0, 0, 12], 0)).toEqual({ symbol: null, omissions: [] }); + expect(chordSymbol(E, [3], 3)).toEqual({ symbol: null, omissions: [] }); + }); + + it('gives no symbol to unknown sets', () => { + expect(chordSymbol(C, [0, 4, 8, 11], 0).symbol).toBeNull(); + expect(chordSymbol(C, [0, 3, 5, 10], 0).symbol).toBeNull(); + expect(chordSymbol(C, [0, 3, 4, 7], 0).symbol).toBeNull(); + }); +}); + +describe('nameVoicing', () => { + it('names open chords with the full symbol', () => { + expect(nameVoicing([0, 2, 2, 1, 0, 0], E_STANDARD, E)).toEqual({ + symbol: 'E', + name: 'E', + degrees: '1 5 1 3 5 1', + degreesByString: ['1', '5', '1', '3', '5', '1'] + }); + expect(nameVoicing([x, 3, 2, 0, 1, 0], E_STANDARD, C).name).toBe('C'); + expect(nameVoicing([0, 2, 4, 0, 0, 0], E_STANDARD, E)).toMatchObject({ + symbol: 'Em(add9)', + degrees: '1 5 2 b3 5 1' + }); + }); + + it('adds the slash bass for inversions', () => { + const v = nameVoicing([0, 3, 2, 0, 1, x], E_STANDARD, C); + expect(v.symbol).toBe('C/E'); + expect(v.degrees).toBe('3 1 3 5 1'); + }); + + it('names power chords, including Drop D', () => { + expect(nameVoicing([0, 2, 2, x, x, x], E_STANDARD, E).symbol).toBe('E5'); + expect(nameVoicing([0, 2, 3, x, x, x], E_STANDARD, E)).toMatchObject({ symbol: 'E5(b9)', degrees: '1 5 b2' }); + expect(nameVoicing([0, 0, 0, x, x, x], DROP_D, D)).toMatchObject({ symbol: 'D5', name: 'D5', degrees: '1 5 1' }); + expect(nameVoicing([3, 3, 3, x, x, x], DROP_D, 5).symbol).toBe('F5'); + }); + + it('describes dyads without a chord symbol', () => { + expect(nameVoicing([0, x, 3, x, x, x], E_STANDARD, E)).toMatchObject({ + symbol: null, + name: 'E + F (minor 2nd)', + degrees: '1 b2' + }); + expect(nameVoicing([0, 1, 2, x, x, x], E_STANDARD, E)).toMatchObject({ + symbol: null, + name: 'E tritone dyad', + degrees: '1 b5 1' + }); + expect(nameVoicing([0, 0, x, x, x, x], E_STANDARD, E).name).toBe('E + A (perfect 4th)'); + }); + + it('describes clusters by their degrees', () => { + // E3 F4 F#4 + expect(nameVoicing([x, x, 2, x, 6, 2], E_STANDARD, E)).toMatchObject({ + symbol: null, + name: 'E cluster (1 b2 2)', + degrees: '1 b2 2' + }); + }); + + it('names the interval that sounds when a dyad is inverted', () => { + // F2 E3: the root sits a major 7th above the bass, not a minor 2nd. + expect(nameVoicing([1, x, 2, x, x, x], E_STANDARD, E)).toMatchObject({ + symbol: null, + name: 'E over F (major 7th)', + degrees: 'b2 1' + }); + // A2 E3 and A2 E3 A3: a perfect 5th up from the bass. + expect(nameVoicing([x, 0, 2, x, x, x], E_STANDARD, E).name).toBe('E over A (perfect 5th)'); + expect(nameVoicing([x, 0, 2, 2, x, x], E_STANDARD, E).name).toBe('E over A (perfect 5th)'); + // C#3 C4 for a b2 dyad on C + expect(nameVoicing([9, x, 10, x, x, x], E_STANDARD, C).name).toBe('C over C# (major 7th)'); + // F#2 D#3 on D# + expect(nameVoicing([2, 6, x, x, x, x], E_STANDARD, 3).name).toBe('D# over F# (major 6th)'); + // Drop D: D#2 B2 D#3 on B + expect(nameVoicing([1, 2, 1, x, x, x], DROP_D, 11).name).toBe('B over D# (minor 6th)'); + // The tritone is its own inversion. + expect(nameVoicing([x, 1, 2, x, x, x], E_STANDARD, E).name).toBe('E tritone dyad over A#'); + }); + + it('names the sounding interval of every two-pitch-class dyad up from its bass', () => { + const rng = createRng('dyad-intervals'); + let checked = 0; + for (let n = 0; n < 3000; n++) { + const tuning = rng.chance(0.5) ? E_STANDARD : DROP_D; + const shape: Shape = tuning.openMidi.map(() => (rng.chance(0.6) ? null : rng.int(0, 12))); + const root = rng.int(0, 11); + const midi = shapeToMidi(shape, tuning); + const pcs = [...new Set(midi.map(pitchClass))]; + if (pcs.length !== 2 || !pcs.includes(root)) continue; + const bass = pitchClass(bassMidi(shape, tuning) as number); + const upper = pcs.find((pc) => pc !== bass) as number; + const sounding = pitchClass(upper - bass); + if (sounding === 6) continue; + const v = nameVoicing(shape, tuning, root); + if (v.symbol !== null) continue; + expect(v.name, `${JSON.stringify(shape)} on ${root}`).toContain(`(${intervalName(sounding)})`); + checked++; + } + expect(checked).toBeGreaterThan(50); + }); + + it('names rootless voicings against the caller root only', () => { + // E3 G3 A#4 over an intended C + expect(nameVoicing([x, x, 2, 0, x, 6], E_STANDARD, C)).toMatchObject({ + symbol: 'C7(no root)/E', + degrees: '3 5 b7' + }); + // F4 B4 over an intended E: no symbol, plain description + expect(nameVoicing([x, x, x, x, 6, 7], E_STANDARD, E)).toMatchObject({ + symbol: null, + name: 'F + B (b2 5 of E, no root)' + }); + }); + + it('handles single notes, octaves and silence', () => { + expect(nameVoicing([0, x, x, x, x, x], E_STANDARD, E).name).toBe('E single note'); + expect(nameVoicing([0, x, 2, x, x, x], E_STANDARD, E).name).toBe('E octaves'); + // E4 on two strings is a unison, not an octave. + expect(nameVoicing([x, x, x, x, 5, 0], E_STANDARD, E).name).toBe('E single note'); + expect(nameVoicing([x, x, x, x, x, 3], E_STANDARD, E).name).toBe('G (b3 of E, no root)'); + expect(nameVoicing([x, x, x, x, x, x], E_STANDARD, E)).toEqual({ + symbol: null, + name: 'No notes', + degrees: '', + degreesByString: [null, null, null, null, null, null] + }); + }); + + it('uses the family label only when the shape really matches the family', () => { + const shape: Shape = [0, 0, 0, 0, x, x]; // E A D G + expect(nameVoicing(shape, E_STANDARD, E).name).toBe('E voicing (1 b3 4 b7)'); + expect(nameVoicing(shape, E_STANDARD, E, fam('quartal'))).toMatchObject({ + symbol: null, + name: 'E Quartal (1 b3 4 b7)' + }); + expect(nameVoicing(shape, E_STANDARD, E, fam('power5')).name).toBe('E voicing (1 b3 4 b7)'); + const legacy = fromLegacyNotes(['E2', 'A2', 'D3', 'G3'], 'E'); + expect(nameVoicing(shape, E_STANDARD, E, legacy).name).toBe('E voicing (1 b3 4 b7)'); + }); + + it('says Quartal only when the notes sound as stacked fourths', () => { + // E2 A3 D4 G4: an 11th then two fourths still stacks fourths. + expect(nameVoicing([0, 12, 12, 12, x, x], E_STANDARD, E, fam('quartal')).name).toBe('E Quartal (1 b3 4 b7)'); + // Drop D G#3 B3 C#4 F#4: the b3 sits right above the root, gaps m3 M2 P4. + const notStacked: Shape = [x, 11, 11, 11, 0, x]; + expect(nameVoicing(notStacked, DROP_D, 8, fam('quartal'))).toMatchObject({ + symbol: null, + name: 'G# voicing (1 b3 4 b7)', + degrees: '1 b3 4 b7' + }); + }); + + it('says cluster only when a close second sounds', () => { + // E3 F4 F#4: the F-F# semitone is packed. + expect(nameVoicing([x, x, 2, x, 6, 2], E_STANDARD, E, fam('cluster_m2')).name).toBe('E m2 cluster (1 b2 2)'); + // E2 F#3 F4: gaps of 14 and 11 semitones, no second anywhere. + const spread: Shape = [0, 9, x, 10, x, x]; + expect(nameVoicing(spread, E_STANDARD, E, fam('cluster_m2'))).toMatchObject({ + symbol: null, + name: 'E voicing (1 b2 2)', + degrees: '1 2 b2' + }); + expect(nameVoicing(spread, E_STANDARD, E).name).toBe('E voicing (1 b2 2)'); + // D3 C4 C#5 on C: gaps 10 and 13. + expect(nameVoicing([x, x, 0, x, 1, 9], E_STANDARD, C, fam('cluster_m2')).name).toBe('C voicing (1 b2 2) over D'); + }); + + it('never lets a family override a confident symbol', () => { + expect(nameVoicing([0, 2, 2, x, x, x], E_STANDARD, E, fam('drone_power5')).name).toBe('E5'); + // E E G# B is a plain E major triad even when the caller asked for Phrygian. + expect(nameVoicing([0, 7, 6, 4, x, x], E_STANDARD, E, fam('phrygian')).symbol).toBe('E'); + expect(nameVoicing([0, 8, 6, 4, x, x], E_STANDARD, E, fam('phrygian')).symbol).toBe('E(addb9)'); + }); + + it('stays honest on random shapes', () => { + const rng = createRng('naming-honesty'); + const majorQuality = new Set(['', '7', 'maj7', '6', 'add9', '(addb9)', '7(b9)', '9', 'maj9']); + for (let n = 0; n < 2000; n++) { + const tuning = rng.chance(0.5) ? E_STANDARD : DROP_D; + const shape: Shape = tuning.openMidi.map(() => (rng.chance(0.4) ? null : rng.int(0, 12))); + const root = rng.int(0, 11); + const v = nameVoicing(shape, tuning, root); + const tokens = v.degrees === '' ? [] : v.degrees.split(' '); + expect(tokens.length).toBe(shapeToMidi(shape, tuning).length); + expect(v.degreesByString.filter((d) => d !== null).length).toBe(tokens.length); + if (v.symbol === null) { + expect(v.name).toMatch(/^(No notes$|[A-G]#? )/); + expect(v.name).not.toMatch(/sus|dim|aug|add|\(no/); + continue; + } + const rootName = pitchClassName(root); + expect(v.name).toBe(v.symbol); + expect(v.symbol.startsWith(rootName)).toBe(true); + const bass = bassMidi(shape, tuning) as number; + expect(v.symbol.includes('/')).toBe(pitchClass(bass) !== root); + expect(v.symbol.includes('(no root)')).toBe(!tokens.includes('1')); + if (v.symbol.includes('(no5)')) expect(tokens).not.toContain('5'); + if (v.symbol.includes('(no3)')) { + expect(tokens).not.toContain('3'); + expect(tokens).not.toContain('b3'); + } + const quality = v.symbol.slice(rootName.length).split('/')[0].replace(/\((no3|no5|no root)\)/g, ''); + if (/^m(?!aj)/.test(quality)) expect(tokens).toContain('b3'); + if (majorQuality.has(quality) && !v.symbol.includes('(no3)')) expect(tokens).toContain('3'); + } + }); +}); diff --git a/engine/naming.ts b/engine/naming.ts new file mode 100644 index 0000000..51ecaf2 --- /dev/null +++ b/engine/naming.ts @@ -0,0 +1,233 @@ +// Honest voicing labels: degrees always come from the sounding notes; a chord symbol is given only for a +// confident template match against the caller's root, otherwise the name only states what sounds. +import type { Shape, Tuning, VoicingFamily } from './types'; +import { degreeLabel, intervalFrom, intervalName, pitchClass, pitchClassName } from './pitch'; +import { shapeToMidiByString } from './shape'; +import { matchFamily } from './voicingSpec'; + +interface ChordTemplate { + /** Suffix after the root letter, e.g. 'm7'. */ + symbol: string; + intervals: readonly number[]; + /** The symbol states no third quality of its own, so '(no3)' stays truthful. */ + allowNo3?: boolean; +} + +const CHORD_TEMPLATES: readonly ChordTemplate[] = [ + { symbol: '5', intervals: [0, 7] }, + { symbol: '5(b9)', intervals: [0, 1, 7] }, + { symbol: '', intervals: [0, 4, 7] }, + { symbol: 'm', intervals: [0, 3, 7] }, + { symbol: 'dim', intervals: [0, 3, 6] }, + { symbol: 'aug', intervals: [0, 4, 8] }, + { symbol: 'sus2', intervals: [0, 2, 7] }, + { symbol: 'sus4', intervals: [0, 5, 7] }, + { symbol: '7', intervals: [0, 4, 7, 10], allowNo3: true }, + { symbol: 'm7', intervals: [0, 3, 7, 10] }, + { symbol: 'maj7', intervals: [0, 4, 7, 11], allowNo3: true }, + { symbol: 'mMaj7', intervals: [0, 3, 7, 11] }, + { symbol: 'm7b5', intervals: [0, 3, 6, 10] }, + { symbol: 'dim7', intervals: [0, 3, 6, 9] }, + { symbol: '7sus4', intervals: [0, 5, 7, 10] }, + { symbol: '6', intervals: [0, 4, 7, 9] }, + { symbol: 'm6', intervals: [0, 3, 7, 9] }, + { symbol: 'add9', intervals: [0, 2, 4, 7] }, + { symbol: 'm(add9)', intervals: [0, 2, 3, 7] }, + { symbol: '(addb9)', intervals: [0, 1, 4, 7] }, + { symbol: '7(b9)', intervals: [0, 1, 4, 7, 10] }, + { symbol: '9', intervals: [0, 2, 4, 7, 10] }, + { symbol: 'm9', intervals: [0, 2, 3, 7, 10] }, + { symbol: 'maj9', intervals: [0, 2, 4, 7, 11] }, + { symbol: 'm11', intervals: [0, 2, 3, 5, 7, 10] } +]; + +const ROOT_BIT = 1 << 0; +const THIRD_BITS = (1 << 3) | (1 << 4); +// Only the perfect fifth may be omitted: dropping a b5 or #5 would erase the chord quality. +const FIFTH_BIT = 1 << 7; + +/** A symbol that omits anything must still rest on at least this many sounding pitch classes. */ +const MIN_PITCH_CLASSES_WITH_OMISSIONS = 3; + +const TEMPLATE_MASKS: readonly { template: ChordTemplate; mask: number }[] = CHORD_TEMPLATES.map((template) => ({ + template, + mask: template.intervals.reduce((mask, i) => mask | (1 << i), 0) +})); + +const popcount = (mask: number): number => { + let count = 0; + for (let m = mask; m; m &= m - 1) count++; + return count; +}; + +const omissionLabels = (missing: number): string[] => { + const labels: string[] = []; + if (missing & THIRD_BITS) labels.push('(no3)'); + if (missing & FIFTH_BIT) labels.push('(no5)'); + if (missing & ROOT_BIT) labels.push('(no root)'); + return labels; +}; + +export interface ChordSymbolResult { + symbol: string | null; + omissions: string[]; +} + +export interface VoicingName { + symbol: string | null; + name: string; + degrees: string; + degreesByString: (string | null)[]; +} + +const noSymbol = (): ChordSymbolResult => ({ symbol: null, omissions: [] }); + +/** + * Chord-symbol suffix relative to `rootPc` (e.g. '', 'm7(no5)', '/E'), or null when no template matches + * confidently. `intervals` may contain duplicates or unreduced values; the bass always counts as sounding. + * Omissions: perfect fifth, the root (only reported, never replaced by another root), and the third only for + * templates whose symbol makes no third claim. Ties between different templates at the same omission count + * give no symbol. + */ +export const chordSymbol = ( + rootPc: number, + intervals: readonly number[], + bassInterval: number | null +): ChordSymbolResult => { + let present = 0; + for (const i of intervals) present |= 1 << pitchClass(i); + if (bassInterval !== null) present |= 1 << pitchClass(bassInterval); + const pitchClasses = popcount(present); + if (pitchClasses < 2) return noSymbol(); + + let best: { template: ChordTemplate; missing: number }[] = []; + let bestOmitted = Infinity; + for (const { template, mask } of TEMPLATE_MASKS) { + if (present & ~mask) continue; + const missing = mask & ~present; + const omitted = popcount(missing); + if (omitted > 0) { + if (pitchClasses < MIN_PITCH_CLASSES_WITH_OMISSIONS) continue; + const omissible = ROOT_BIT | (mask & FIFTH_BIT) | (template.allowNo3 ? mask & THIRD_BITS : 0); + if (missing & ~omissible) continue; + } + if (omitted < bestOmitted) { + best = [{ template, missing }]; + bestOmitted = omitted; + } else if (omitted === bestOmitted) { + best.push({ template, missing }); + } + } + if (best.length !== 1) return noSymbol(); + + const { template, missing } = best[0]; + const omissions = omissionLabels(missing); + const bass = bassInterval === null ? 0 : pitchClass(bassInterval); + const slash = bass === 0 ? '' : `/${pitchClassName(rootPc + bass)}`; + return { symbol: `${template.symbol}${omissions.join('')}${slash}`, omissions }; +}; + +export const degreesByString = (shape: Shape, tuning: Tuning, rootPc: number): (string | null)[] => + shapeToMidiByString(shape, tuning).map((midi) => + midi === null ? null : degreeLabel(intervalFrom(rootPc, pitchClass(midi))) + ); + +/** Sounding notes as [midi, string] sorted by pitch ascending, ties by string. */ +const soundingNotes = (shape: Shape, tuning: Tuning): [number, number][] => + shapeToMidiByString(shape, tuning) + .flatMap((midi, s): [number, number][] => (midi === null ? [] : [[midi, s]])) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); + +const degreesOf = (notes: readonly [number, number][], rootPc: number): string => + notes.map(([midi]) => degreeLabel(intervalFrom(rootPc, pitchClass(midi)))).join(' '); + +/** Degree labels of the sounding notes sorted by pitch ascending (ties by string), space-separated. */ +export const degreeString = (shape: Shape, tuning: Tuning, rootPc: number): string => + degreesOf(soundingNotes(shape, tuning), rootPc); + +const hasChromaticRun = (intervals: readonly number[]): boolean => + intervals.some((i) => intervals.includes((i + 1) % 12) && intervals.includes((i + 2) % 12)); + +/** Some neighbouring sounding notes (ascending, distinct) are a minor or major 2nd apart. */ +const soundsPacked = (pitches: readonly number[]): boolean => + pitches.some((p, i) => i > 0 && p - pitches[i - 1] <= 2); + +/** Every neighbouring pair of sounding notes (ascending, distinct) is a perfect 4th, possibly an octave wider. */ +const soundsStackedFourths = (pitches: readonly number[]): boolean => + pitches.length >= 3 && pitches.every((p, i) => i === 0 || (p - pitches[i - 1]) % 12 === 5); + +/** Words in a family label that claim a voicing structure, not just pitch content, and the check backing each. */ +const STRUCTURAL_WORDS: readonly [RegExp, (pitches: readonly number[]) => boolean][] = [ + [/cluster/i, soundsPacked], + [/quartal/i, soundsStackedFourths] +]; + +const labelSounds = (label: string, pitches: readonly number[]): boolean => + STRUCTURAL_WORDS.every(([word, holds]) => !word.test(label) || holds(pitches)); + +/** + * States only what sounds: root, the other notes or degrees, and a non-root bass. An inverted dyad names the + * interval up from its bass, which is the one that sounds. + */ +const describeNotes = ( + root: number, + intervals: readonly number[], + bassInterval: number, + pitches: readonly number[], + familyLabel: string | null +): string => { + const rootName = pitchClassName(root); + const noteName = (interval: number): string => pitchClassName(root + interval); + const degrees = intervals.map(degreeLabel).join(' '); + const over = bassInterval === 0 ? '' : ` over ${noteName(bassInterval)}`; + + if (!intervals.includes(0)) return `${intervals.map(noteName).join(' + ')} (${degrees} of ${rootName}, no root)`; + if (intervals.length === 1) return pitches.length > 1 ? `${rootName} octaves` : `${rootName} single note`; + + if (intervals.length === 2) { + const other = intervals[1]; + if (other === 6) return `${rootName} tritone dyad${over}`; + if (bassInterval === 0) return `${rootName} + ${noteName(other)} (${intervalName(other)})`; + return `${rootName}${over} (${intervalName(-other)})`; + } + const generic = hasChromaticRun(intervals) && soundsPacked(pitches) ? 'cluster' : 'voicing'; + const kind = familyLabel !== null && labelSounds(familyLabel, pitches) ? familyLabel : generic; + return `${rootName} ${kind} (${degrees})${over}`; +}; + +/** + * `symbol` is the full chord symbol including the root (e.g. 'C/E', 'E5(b9)'), or null. `name` equals the + * symbol when there is one, otherwise a descriptive name. A family label is used in descriptive names only + * when the shape really matches that (non-legacy) family and, for labels that name a structure ('cluster', + * 'Quartal'), when the sounding notes show it. + */ +export const nameVoicing = ( + shape: Shape, + tuning: Tuning, + rootPc: number, + family?: VoicingFamily +): VoicingName => { + const root = pitchClass(rootPc); + const notes = soundingNotes(shape, tuning); + const byString = degreesByString(shape, tuning, root); + const degrees = degreesOf(notes, root); + if (notes.length === 0) return { symbol: null, name: 'No notes', degrees, degreesByString: byString }; + + const intervals = [...new Set(notes.map(([midi]) => intervalFrom(root, pitchClass(midi))))].sort((a, b) => a - b); + const bassInterval = intervalFrom(root, pitchClass(notes[0][0])); + const { symbol: suffix } = chordSymbol(root, intervals, bassInterval); + if (suffix !== null) { + const symbol = `${pitchClassName(root)}${suffix}`; + return { symbol, name: symbol, degrees, degreesByString: byString }; + } + + const familyLabel = + family && family.group !== 'legacy' && matchFamily(family, root, shape, tuning).ok ? family.label : null; + const pitches = [...new Set(notes.map(([midi]) => midi))]; + return { + symbol: null, + name: describeNotes(root, intervals, bassInterval, pitches, familyLabel), + degrees, + degreesByString: byString + }; +}; diff --git a/engine/pitch.test.ts b/engine/pitch.test.ts new file mode 100644 index 0000000..79402b9 --- /dev/null +++ b/engine/pitch.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + pitchClass, + midiToName, + nameToMidi, + parseNoteName, + parsePitchClass, + pitchClassName, + intervalFrom, + degreeLabel, + intervalName, + DEGREE_LABELS +} from './pitch'; + +describe('pitchClass', () => { + it('wraps into 0..11 including negatives', () => { + expect(pitchClass(40)).toBe(4); + expect(pitchClass(60)).toBe(0); + expect(pitchClass(-1)).toBe(11); + expect(pitchClass(-12)).toBe(0); + }); +}); + +describe('midiToName / nameToMidi', () => { + it('uses C4 = 60 and sharp spelling', () => { + expect(midiToName(60)).toBe('C4'); + expect(midiToName(40)).toBe('E2'); + expect(midiToName(38)).toBe('D2'); + expect(midiToName(46)).toBe('A#2'); + expect(midiToName(23)).toBe('B0'); + }); + + it('parses sharps and flats', () => { + expect(nameToMidi('E2')).toBe(40); + expect(nameToMidi('Bb2')).toBe(46); + expect(nameToMidi('A#2')).toBe(46); + expect(nameToMidi('Db3')).toBe(49); + expect(nameToMidi('Cb4')).toBe(59); + expect(nameToMidi('B#3')).toBe(60); + expect(nameToMidi('E1')).toBe(28); + }); + + it('round-trips every MIDI note', () => { + for (let m = 0; m <= 127; m++) { + expect(nameToMidi(midiToName(m))).toBe(m); + } + }); + + it('returns null from parseNoteName and throws from nameToMidi on bad input', () => { + expect(parseNoteName('x')).toBeNull(); + expect(parseNoteName('H2')).toBeNull(); + expect(parseNoteName('E')).toBeNull(); + expect(() => nameToMidi('nope')).toThrow(); + }); +}); + +describe('pitch class names', () => { + it('parses names with flats and normalizes case of the letter', () => { + expect(parsePitchClass('C')).toBe(0); + expect(parsePitchClass('Db')).toBe(1); + expect(parsePitchClass('C#')).toBe(1); + expect(parsePitchClass('e')).toBe(4); + expect(parsePitchClass('Bb')).toBe(10); + expect(parsePitchClass('X')).toBeNull(); + }); + + it('spells pitch classes with sharps', () => { + expect(pitchClassName(1)).toBe('C#'); + expect(pitchClassName(10)).toBe('A#'); + expect(pitchClassName(13)).toBe('C#'); + }); +}); + +describe('intervals and degrees', () => { + it('measures the interval above a root as a pitch class', () => { + expect(intervalFrom(4, 5)).toBe(1); + expect(intervalFrom(4, 4 + 7)).toBe(7); + expect(intervalFrom(9, 4)).toBe(7); + }); + + it('labels degrees with the documented table', () => { + expect(DEGREE_LABELS).toEqual(['1', 'b2', '2', 'b3', '3', '4', 'b5', '5', '#5', '6', 'b7', '7']); + expect(degreeLabel(0)).toBe('1'); + expect(degreeLabel(1)).toBe('b2'); + expect(degreeLabel(8)).toBe('#5'); + expect(degreeLabel(10)).toBe('b7'); + expect(degreeLabel(19)).toBe('5'); + }); + + it('names intervals', () => { + expect(intervalName(0)).toBe('unison'); + expect(intervalName(1)).toBe('minor 2nd'); + expect(intervalName(6)).toBe('tritone'); + expect(intervalName(7)).toBe('perfect 5th'); + }); +}); diff --git a/engine/pitch.ts b/engine/pitch.ts new file mode 100644 index 0000000..f14a561 --- /dev/null +++ b/engine/pitch.ts @@ -0,0 +1,73 @@ +// Pitch classes, MIDI <-> note names, interval and degree labels. +// Spelling is always sharps on output; flats are accepted on input. + +export const PITCH_CLASS_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const; + +export const DEGREE_LABELS = ['1', 'b2', '2', 'b3', '3', '4', 'b5', '5', '#5', '6', 'b7', '7'] as const; + +export const INTERVAL_NAMES = [ + 'unison', + 'minor 2nd', + 'major 2nd', + 'minor 3rd', + 'major 3rd', + 'perfect 4th', + 'tritone', + 'perfect 5th', + 'minor 6th', + 'major 6th', + 'minor 7th', + 'major 7th' +] as const; + +const LETTER_PC: Record = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }; + +export const pitchClass = (midi: number): number => ((midi % 12) + 12) % 12; + +export const pitchClassName = (pc: number): string => PITCH_CLASS_NAMES[pitchClass(pc)]; + +/** Semitone offset of an accidental string: '#', '##', 'b', 'bb', ''. */ +const accidentalOffset = (acc: string): number => { + let offset = 0; + for (const ch of acc) offset += ch === '#' ? 1 : -1; + return offset; +}; + +/** "C#", "Db", "e" -> pitch class; null when unparseable. */ +export const parsePitchClass = (name: string): number | null => { + const match = name.trim().match(/^([A-Ga-g])(#{1,2}|b{1,2})?$/); + if (!match) return null; + return pitchClass(LETTER_PC[match[1].toUpperCase()] + accidentalOffset(match[2] ?? '')); +}; + +/** "E2", "Bb2", "C#4" -> MIDI (C4 = 60); null when unparseable. Octave follows the letter (Cb4 = B3). */ +export const parseNoteName = (name: string): number | null => { + const match = name.trim().match(/^([A-Ga-g])(#{1,2}|b{1,2})?(-?\d+)$/); + if (!match) return null; + const octave = parseInt(match[3], 10); + return (octave + 1) * 12 + LETTER_PC[match[1].toUpperCase()] + accidentalOffset(match[2] ?? ''); +}; + +export const nameToMidi = (name: string): number => { + const midi = parseNoteName(name); + if (midi === null) throw new Error(`Invalid note name: ${name}`); + return midi; +}; + +export const midiToName = (midi: number): string => + `${PITCH_CLASS_NAMES[pitchClass(midi)]}${Math.floor(midi / 12) - 1}`; + +/** Interval of `pc` above `rootPc`, as a pitch class 0..11. */ +export const intervalFrom = (rootPc: number, pc: number): number => pitchClass(pc - rootPc); + +export const degreeLabel = (interval: number): string => DEGREE_LABELS[pitchClass(interval)]; + +export const intervalName = (interval: number): string => INTERVAL_NAMES[pitchClass(interval)]; + +/** Accepts a pitch class number or a note name; throws on invalid names. */ +export const toPitchClass = (root: number | string): number => { + if (typeof root === 'number') return pitchClass(root); + const pc = parsePitchClass(root); + if (pc === null) throw new Error(`Invalid root: ${root}`); + return pc; +}; diff --git a/engine/playability.test.ts b/engine/playability.test.ts new file mode 100644 index 0000000..dcfa2e3 --- /dev/null +++ b/engine/playability.test.ts @@ -0,0 +1,460 @@ +import { describe, it, expect } from 'vitest'; +import { + COMMON_SHAPES, + countInteriorMutes, + explainCost, + nonPhysicalCost, + noveltyScore, + scoreTotal, + scoreVoicing, + shapeConstraintViolation, + type ScoreContext +} from './playability'; +import { findBestFingering } from './fingering'; +import { DEFAULT_CALIBRATION, DEFAULT_HAND_PROFILE, allowedSpanMm, maxReachMm, profileFromCalibration } from './handProfile'; +import { fingertipMm } from './geometry'; +import { createRng } from './random'; +import { fromRiffForgeTab, interiorMutes, shapeKey } from './shape'; +import { E_STANDARD, DROP_D } from './tuning'; +import { fromLegacyNotes, getFamily, relaxFamily } from './voicingSpec'; +import { MUSICAL_TERMS, PHYSICAL_TERMS, PLAYABILITY_WEIGHTS, RIGHT_HAND_TERMS } from './weights'; +import type { CostBreakdown, CostTermKey, Fingering, HandProfile, Shape, VoicingFamily } from './types'; + +const P = DEFAULT_HAND_PROFILE; +const L = P.scaleLengthMm; +const tip = (fret: number): number => fingertipMm(fret, L); + +const tab = (text: string): Shape => { + const shape = fromRiffForgeTab(text); + if (!shape) throw new Error(`bad tab ${text}`); + return shape; +}; + +const fingeringOf = (shape: Shape, profile: HandProfile = P): Fingering => { + const result = findBestFingering(shape, profile); + if (result.ok === false) throw new Error(`expected a fingering for ${shapeKey(shape)}: ${result.reason}`); + return result.fingering; +}; + +const fam = (id: string): VoicingFamily => { + const family = getFamily(id); + if (!family) throw new Error(`missing family ${id}`); + return family; +}; + +const ctxOf = (over: Partial = {}): ScoreContext => ({ + tuning: E_STANDARD, + profile: P, + rootPc: 4, + distortion: true, + context: 'strum', + musical: true, + ...over +}); + +const score = (text: string, over: Partial = {}): CostBreakdown => + scoreVoicing(tab(text), fingeringOf(tab(text)), ctxOf(over)); + +const term = (b: CostBreakdown, key: CostTermKey) => { + const found = b.terms.find((t) => t.key === key); + if (!found) throw new Error(`missing term ${key}`); + return found; +}; + +/** Cost per term, for compact snapshot comparisons. */ +const costs = (b: CostBreakdown): Record => + Object.fromEntries(b.terms.map((t) => [t.key, Math.round(t.cost * 1e6) / 1e6])); + +const ZERO_COSTS: Record = { + reach: 0, + pairStretch: 0, + fingerCount: 0, + pinky: 0, + contortion: 0, + interiorMutes: 0, + position: 0, + thumb: 0, + barre: 0, + stringSkips: 0, + chugStrings: 0, + lowMud: 0, + doubledThird: 0, + rootNotInBass: 0 +}; + +const expectCosts = (b: CostBreakdown, expected: Partial>): void => { + const actual = costs(b); + const want = { ...ZERO_COSTS, ...expected }; + for (const key of Object.keys(want) as CostTermKey[]) { + expect(actual[key], key).toBeCloseTo(want[key], 6); + } +}; + +const reachCost = (minFret: number, maxFret: number): number => { + const ratio = (tip(maxFret) - tip(minFret)) / allowedSpanMm(P, minFret); + return 3 * ratio * ratio; +}; + +describe('scoreVoicing structure', () => { + const shapes = ['0 2 2 1 0 0', '0 2 4 0 0 0', '0 2 2 x x x', '3 x 0 0 0 3', 'x 3 2 0 1 0', '3 0 x x x x']; + + it('lists all 14 terms in weights.ts key order with buckets and weights from weights.ts', () => { + const keys = Object.keys(PLAYABILITY_WEIGHTS); + expect(keys).toEqual([...PHYSICAL_TERMS, ...RIGHT_HAND_TERMS, ...MUSICAL_TERMS]); + for (const text of shapes) { + for (const context of ['strum', 'chug'] as const) { + for (const musical of [true, false]) { + const b = score(text, { context, musical, family: fam('major') }); + expect(b.terms.map((t) => t.key)).toEqual(keys); + for (const t of b.terms) { + expect(t.bucket).toBe(PLAYABILITY_WEIGHTS[t.key].bucket); + expect(t.weight).toBe(PLAYABILITY_WEIGHTS[t.key].weight); + expect(t.normalized).toBeGreaterThanOrEqual(0); + expect(t.normalized).toBeLessThanOrEqual(1); + expect(t.cost).toBeCloseTo(t.normalized * t.weight, 12); + } + } + } + } + }); + + it('sums buckets and total from the terms', () => { + for (const text of shapes) { + const b = score(text, { context: 'chug', family: fam('sus2') }); + const sum = (bucket: string) => b.terms.filter((t) => t.bucket === bucket).reduce((s, t) => s + t.cost, 0); + expect(b.physical).toBeCloseTo(sum('physical'), 12); + expect(b.rightHand).toBeCloseTo(sum('rightHand'), 12); + expect(b.musical).toBeCloseTo(sum('musical'), 12); + expect(b.total).toBeCloseTo(b.physical + b.rightHand + b.musical, 12); + } + }); + + it('keeps the physical bucket identical to the fingering breakdown', () => { + for (const text of shapes) { + const f = fingeringOf(tab(text)); + const b = scoreVoicing(tab(text), f, ctxOf()); + expect(b.physical).toBe(f.costBreakdown.total); + expect(b.terms.slice(0, PHYSICAL_TERMS.length)).toEqual(f.costBreakdown.terms); + } + }); + + it('scoreTotal and nonPhysicalCost match scoreVoicing bit for bit', () => { + const rng = createRng('score-total'); + let checked = 0; + for (let i = 0; i < 3000 && checked < 200; i++) { + const shape = Array.from({ length: 6 }, () => (rng.chance(0.35) ? null : rng.int(0, 9))); + const result = findBestFingering(shape, P); + if (result.ok === false) continue; + checked++; + for (const over of [ + {}, + { context: 'chug' as const }, + { distortion: false }, + { musical: false }, + { rootPc: 0, family: fam('sus2') }, + { rootPc: 7, family: fam('major'), context: 'chug' as const } + ]) { + const ctx = ctxOf(over); + const b = scoreVoicing(shape, result.fingering, ctx); + expect(scoreTotal(shape, result.fingering, ctx)).toBe(b.total); + expect(nonPhysicalCost(shape, ctx)).toBeCloseTo(b.rightHand + b.musical, 12); + } + } + expect(checked).toBe(200); + }); + + it('countInteriorMutes matches shape.ts interiorMutes', () => { + const rng = createRng('mutes'); + for (let i = 0; i < 300; i++) { + const shape = Array.from({ length: rng.int(1, 8) }, () => (rng.chance(0.5) ? null : rng.int(0, 5))); + expect(countInteriorMutes(shape)).toBe(interiorMutes(shape)); + } + }); + + it('does not alias the fingering terms', () => { + const f = fingeringOf(tab('0 2 2 1 0 0')); + const b = scoreVoicing(tab('0 2 2 1 0 0'), f, ctxOf()); + b.terms[0].cost = 99; + expect(f.costBreakdown.terms[0].cost).not.toBe(99); + }); +}); + +describe('scoreVoicing snapshots (E standard, default hand)', () => { + it('open E major 0 2 2 1 0 0', () => { + const base = { reach: reachCost(1, 2), fingerCount: 0.75, position: 0.3 / 12 }; + expectCosts(score('0 2 2 1 0 0'), base); + expectCosts(score('0 2 2 1 0 0', { distortion: false }), base); + // chug: 0.5 * min(1, 5/5) + 0.5 * mean index 2.5 / 5 = 0.75, weight 0.5 + expectCosts(score('0 2 2 1 0 0', { context: 'chug' }), { ...base, chugStrings: 0.375 }); + expect(score('0 2 2 1 0 0').total).toBeCloseTo(reachCost(1, 2) + 0.75 + 0.025, 9); + }); + + it('Em(add9) 0 2 4 0 0 0 with the m(add9) family', () => { + const family = fam('m_add9'); + const f = fingeringOf(tab('0 2 4 0 0 0')); + expect(f.fingers).toEqual([null, 1, 3, null, null, null]); + const base = { reach: reachCost(2, 4), fingerCount: 0.5, position: (0.3 * 2) / 12 }; + expectCosts(score('0 2 4 0 0 0', { family }), base); + expectCosts(score('0 2 4 0 0 0', { family, distortion: false }), base); + expectCosts(score('0 2 4 0 0 0', { family, context: 'chug' }), { ...base, chugStrings: 0.375 }); + }); + + it('power chord 0 2 2 x x x', () => { + const family = fam('power5'); + const base = { fingerCount: 0.5, position: (0.3 * 2) / 12 }; + expectCosts(score('0 2 2 x x x', { family }), base); + expectCosts(score('0 2 2 x x x', { family, distortion: false }), base); + // chug: 0.5 * (2/5) + 0.5 * (mean index 1 / 5) = 0.3, weight 0.5 + expectCosts(score('0 2 2 x x x', { family, context: 'chug' }), { ...base, chugStrings: 0.15 }); + }); + + it('G major with an interior mute 3 x 0 0 0 3', () => { + const ctx = { rootPc: 7, family: fam('major') }; + // interiorMutes 1 -> 0.5 * 1.5; stringSkips 1 -> 0.5 * 2 + const base = { fingerCount: 0.5, interiorMutes: 0.75, position: (0.3 * 3) / 12, stringSkips: 1 }; + expectCosts(score('3 x 0 0 0 3', ctx), base); + expectCosts(score('3 x 0 0 0 3', { ...ctx, distortion: false }), base); + // chug: 0.5 * (4/5) + 0.5 * (mean index 14/5 / 5) = 0.68, weight 0.5 + expectCosts(score('3 x 0 0 0 3', { ...ctx, context: 'chug' }), { ...base, chugStrings: 0.34 }); + }); + + it('low-register mud only counts under distortion', () => { + // G2 (43) and A2 (45): both below E3, 2 semitones apart -> 1 pair -> 0.5 * 2 + const ctx = { rootPc: 7 }; + const muddy = score('3 0 x x x x', ctx); + expect(term(muddy, 'lowMud').raw).toBe(1); + expect(term(muddy, 'lowMud').cost).toBeCloseTo(1, 12); + const clean = score('3 0 x x x x', { ...ctx, distortion: false }); + expect(term(clean, 'lowMud').raw).toBe(1); + expect(term(clean, 'lowMud').cost).toBe(0); + expect(clean.total).toBeCloseTo(muddy.total - 1, 12); + }); + + it('counts muddy pairs below E3 and ignores fifths, octaves and notes from E3 up', () => { + // G2 (43), A#2 (46), D3 (50): 43-46 and 46-50 are 3rds below E3, 43-50 is a 5th -> 2 pairs -> capped at 1 + const b = score('3 1 0 x x x', { rootPc: 7 }); + expect(term(b, 'lowMud').raw).toBe(2); + expect(term(b, 'lowMud').cost).toBeCloseTo(2, 12); + expect(term(score('0 2 2 x x x'), 'lowMud').raw).toBe(0); + // E3 (52) is not below the threshold + expect(term(score('x x 2 1 x x'), 'lowMud').raw).toBe(0); + // Drop D D2 A2 D3: a fifth, an octave and a fourth + const dropD = scoreVoicing(tab('0 0 0 x x x'), fingeringOf(tab('0 0 0 x x x')), ctxOf({ tuning: DROP_D, rootPc: 2 })); + expect(term(dropD, 'lowMud').raw).toBe(0); + }); + + it('costs a doubled third', () => { + // C major x 3 2 0 1 0: E3 and E4 -> one extra third + const b = score('x 3 2 0 1 0', { rootPc: 0, family: fam('major') }); + expect(term(b, 'doubledThird').raw).toBe(1); + expect(term(b, 'doubledThird').cost).toBeCloseTo(0.5, 12); + expect(term(score('0 2 2 1 0 0'), 'doubledThird').raw).toBe(0); + }); + + it('costs a non-root bass only when the family prefers a bass interval', () => { + // Dsus2 over A: x 0 0 2 3 0 -> bass A is the fifth of D + const ctx = { rootPc: 2 }; + expect(term(score('x 0 0 2 3 0', { ...ctx, family: fam('sus2') }), 'rootNotInBass').cost).toBeCloseTo(0.5, 12); + expect(term(score('x x 0 2 3 0', { ...ctx, family: fam('sus2') }), 'rootNotInBass').cost).toBe(0); + expect(term(score('x 0 0 2 3 0', { ...ctx }), 'rootNotInBass').cost).toBe(0); + const triad = score('x 0 0 2 3 2', { ...ctx, family: fam('major') }); + expect(term(triad, 'rootNotInBass').cost).toBe(0); + }); + + it('never costs a root-position voicing as "root not in the bass" when the preferred bass is another degree', () => { + // A legacy C/G row relaxed to any bass keeps the 5th as its preferred bass + const slash = relaxFamily(fromLegacyNotes(['G2', 'C3', 'E3'], 'C'))[0].family; + expect(slash.bass).toBe('any'); + expect(slash.preferredBass).toBe(7); + const ctx = { rootPc: 0, family: slash }; + // x 3 2 0 x x = C3 E3 G3: the root is in the bass + expect(term(score('x 3 2 0 x x', ctx), 'rootNotInBass').cost).toBe(0); + // x x 5 5 5 x = G3 C4 E4: the root is not in the bass, but this family never asked for it there + expect(term(score('x x 5 5 5 x', ctx), 'rootNotInBass').cost).toBe(0); + }); + + it('drops the whole musical bucket when musical is off', () => { + const on = score('x 3 2 0 1 0', { rootPc: 0, family: fam('sus2') }); + const off = score('x 3 2 0 1 0', { rootPc: 0, family: fam('sus2'), musical: false }); + expect(on.musical).toBeGreaterThan(0); + expect(off.musical).toBe(0); + expect(off.total).toBeCloseTo(off.physical + off.rightHand, 12); + for (const key of MUSICAL_TERMS) expect(term(off, key).cost).toBe(0); + }); +}); + +describe('COMMON_SHAPES', () => { + it('holds valid, unique 6-string shapes including the open chords and movable forms', () => { + const keys = COMMON_SHAPES.map(shapeKey); + expect(new Set(keys).size).toBe(keys.length); + for (const shape of COMMON_SHAPES) { + expect(shape.length).toBe(6); + expect(shape.some((f) => f !== null)).toBe(true); + } + for (const text of ['0 2 2 1 0 0', 'x 3 2 0 1 0', '3 2 0 0 0 3', 'x x 0 2 3 2', '1 3 3 x x x', '1 3 3 2 1 1', 'x 1 3 3 2 1']) { + expect(keys).toContain(text); + } + }); +}); + +describe('noveltyScore', () => { + it('is 0 for open E major and every common shape', () => { + expect(noveltyScore(tab('0 2 2 1 0 0'))).toBe(0); + for (const shape of COMMON_SHAPES) expect(noveltyScore(shape)).toBe(0); + }); + + it('matches movable references at any position', () => { + expect(noveltyScore(tab('5 7 7 x x x'))).toBe(0); + expect(noveltyScore(tab('x 7 9 9 8 7'))).toBe(0); + }); + + it('matches open-string references only as written', () => { + expect(noveltyScore(tab('x 3 2 0 1 0'))).toBe(0); + expect(noveltyScore(tab('x 5 4 0 3 0'))).toBeGreaterThan(0); + }); + + it('scores a weird cluster high', () => { + // open strings ringing under a high fretted cluster + expect(noveltyScore(tab('0 x 9 7 0 8'))).toBeGreaterThanOrEqual(0.5); + expect(noveltyScore(tab('x 9 8 x 7 x'))).toBeGreaterThan(0.4); + // close to the root-5 dominant shell x 1 x 1 3 x + expect(noveltyScore(tab('x 9 x 8 10 x'))).toBeCloseTo(0.5 / 6, 12); + }); + + it('uses the signature distance: one string one fret off a reference costs 0.5 / 6', () => { + const weird = tab('x 9 8 x 7 x'); + expect(noveltyScore(tab('x 10 8 x 7 x'), [weird])).toBeCloseTo(0.5 / 6, 12); + expect(noveltyScore(tab('x 10 8 x 7 x'), [tab('x 4 3 x 2 x')])).toBeCloseTo(0.5 / 6, 12); + // two frets off saturates at 1 for that string; a muted vs sounding string is 1 + expect(noveltyScore(tab('x 11 8 x 7 x'), [weird])).toBeCloseTo(1 / 6, 12); + expect(noveltyScore(tab('x 9 8 x 7 7'), [weird])).toBeCloseTo(1 / 6, 12); + // open-string references compare frets as written + expect(noveltyScore(tab('0 x 9 7 0 9'), [tab('0 x 9 7 0 8')])).toBeCloseTo(0.5 / 6, 12); + }); + + it('treats extra reference shapes as known', () => { + const weird = tab('x 9 8 x 7 x'); + expect(noveltyScore(weird, [weird])).toBe(0); + expect(noveltyScore(weird, [tab('x 4 3 x 2 x')])).toBe(0); + expect(noveltyScore(tab('0 x 9 7 0 8'), [tab('0 x 4 2 0 3')])).toBeGreaterThan(0); + }); + + it('compares extended-range shapes against references aligned to the top strings', () => { + // 7-string B E A D G B E: open E major with the low B muted is the open E major shape + expect(noveltyScore([null, 0, 2, 2, 1, 0, 0])).toBe(0); + expect(noveltyScore([null, null, 3, 2, 0, 1, 0])).toBe(0); + // movable references keep matching at any position + expect(noveltyScore([null, 5, 7, 7, null, null, null])).toBe(0); + // a sounding low string the reference does not have counts as one differing string + expect(noveltyScore([0, 0, 2, 2, 1, 0, 0])).toBeCloseTo(1 / 7, 12); + expect(noveltyScore([null, null, 0, 2, 2, 1, 0, 0])).toBe(0); + // extra references of another length are aligned the same way + expect(noveltyScore([null, 9, 8, null, 7, null, null], [tab('9 8 x 7 x x')])).toBe(0); + }); + + it('stays within 0..1 and is deterministic', () => { + const rng = createRng('novelty'); + for (let i = 0; i < 300; i++) { + const shape = Array.from({ length: 6 }, () => (rng.chance(0.3) ? null : rng.int(0, 15))); + if (shape.every((f) => f === null)) continue; + const n = noveltyScore(shape); + expect(n).toBeGreaterThanOrEqual(0); + expect(n).toBeLessThanOrEqual(1); + expect(noveltyScore(shape)).toBe(n); + } + }); +}); + +describe('explainCost', () => { + const explain = (text: string, over: Partial = {}): string[] => { + const profile = over.profile ?? P; + const f = fingeringOf(tab(text), profile); + return explainCost(scoreVoicing(tab(text), f, ctxOf(over)), f, profile); + }; + + it('describes open E major in short phrases', () => { + const lines = explain('0 2 2 1 0 0'); + expect(lines[0]).toBe('3 fingers'); + const ratio = (tip(2) - tip(1)) / allowedSpanMm(P, 1); + expect(lines).toContain(`reach ${Math.round(ratio * 100)} percent of your comfort`); + expect(lines.some((l) => l.includes('pinky'))).toBe(false); + }); + + it('measures reach against the comfortable reach, not the stretch allowance', () => { + const stretchy = profileFromCalibration({ ...DEFAULT_CALIBRATION, allowStretches: true }); + expect(stretchy.stretchTolerance).toBeGreaterThan(1); + // C4 + C#5 (b2 dyad on C): 102 mm at fret 5, inside the 1.1 stretch allowance but beyond the 93.5 mm comfort + const span = tip(9) - tip(5); + expect(span).toBeGreaterThan(maxReachMm(stretchy, 5)); + expect(span).toBeLessThanOrEqual(allowedSpanMm(stretchy, 5)); + const lines = explain('x x x 5 x 9', { profile: stretchy, rootPc: 0 }); + const pct = Math.round((span / maxReachMm(stretchy, 5)) * 100); + expect(pct).toBe(109); + expect(lines).toContain(`reach ${pct} percent of your comfort (a stretch)`); + // 96 mm at fret 6 against 92 mm of comfort: printed 95 percent before, 104 percent now + expect(explain('x x 10 6 x x', { profile: stretchy, rootPc: 0 })).toContain('reach 104 percent of your comfort (a stretch)'); + // inside the comfortable reach the phrase stays plain + expect(explain('x x 7 6 x x', { profile: stretchy, rootPc: 0 }).find((l) => l.startsWith('reach'))).toMatch( + /^reach \d+ percent of your comfort$/ + ); + }); + + it('without a profile, names the allowed reach instead of claiming comfort', () => { + const stretchy = profileFromCalibration({ ...DEFAULT_CALIBRATION, allowStretches: true }); + const f = fingeringOf(tab('x x x 5 x 9'), stretchy); + const lines = explainCost(scoreVoicing(tab('x x x 5 x 9'), f, ctxOf({ profile: stretchy, rootPc: 0 })), f); + expect(lines).toContain(`reach ${Math.round(f.metrics.reachRatio * 100)} percent of your allowed reach`); + expect(lines.some((l) => l.includes('comfort'))).toBe(false); + }); + + it('does not report "root not in the bass" for a root-position voicing of a slash-bass recipe', () => { + const slash = relaxFamily(fromLegacyNotes(['G2', 'C3', 'E3'], 'C'))[0].family; + expect(explain('x 3 2 0 x x', { rootPc: 0, family: slash })).not.toContain('root not in the bass'); + }); + + it('names interior mutes, skipped strings, pinky, mud, doubled thirds and the bass', () => { + expect(explain('3 x 0 0 0 3', { rootPc: 7 })).toEqual(expect.arrayContaining(['2 fingers', '1 interior mute', '1 skipped string for the picking hand'])); + expect(explain('3 0 x x x x', { rootPc: 7 })).toContain('1 muddy low interval under gain'); + expect(explain('x 3 2 0 1 0', { rootPc: 0 })).toContain('doubled third'); + expect(explain('x 0 0 2 3 0', { rootPc: 2, family: fam('sus2') })).toContain('root not in the bass'); + expect(explain('x 2 4 4 x x', { rootPc: 11 })).toEqual(expect.arrayContaining(['3 fingers', 'uses pinky'])); + expect(explain('0 0 0 0 0 0')).toContain('open strings only'); + }); + + it('never claims a voicing is safe', () => { + const rng = createRng('explain'); + let checked = 0; + for (let i = 0; i < 400 && checked < 150; i++) { + const shape = Array.from({ length: 6 }, () => (rng.chance(0.4) ? null : rng.int(0, 7))); + const result = findBestFingering(shape, P); + if (result.ok === false) continue; + for (const context of ['strum', 'chug'] as const) { + const lines = explainCost(scoreVoicing(shape, result.fingering, ctxOf({ context })), result.fingering); + for (const line of lines) { + expect(line.toLowerCase()).not.toContain('safe'); + expect(line.length).toBeGreaterThan(0); + } + } + checked++; + } + expect(checked).toBeGreaterThan(50); + }); +}); + +describe('shapeConstraintViolation', () => { + it('returns null for shapes inside the profile', () => { + expect(shapeConstraintViolation(tab('0 2 2 1 0 0'), P)).toBeNull(); + expect(shapeConstraintViolation(tab('x 15 x x x x'), P)).toBeNull(); + expect(shapeConstraintViolation(tab('3 x 0 0 0 3'), P)).toBeNull(); + }); + + it('flags max fret, open strings and interior mutes, in that order', () => { + const closed = { ...P, allowOpenStrings: false }; + expect(shapeConstraintViolation(tab('x 16 x x x x'), P)).toBe('MAX_FRET'); + expect(shapeConstraintViolation(tab('0 2 2 1 0 0'), closed)).toBe('OPEN_STRINGS'); + expect(shapeConstraintViolation(tab('0 x x 2 x x'), P)).toBe('INTERIOR_MUTES'); + expect(shapeConstraintViolation(tab('0 x x 2 x x'), { ...P, maxInteriorMutes: 2 })).toBeNull(); + expect(shapeConstraintViolation(tab('0 x x 16 x x'), closed)).toBe('MAX_FRET'); + expect(shapeConstraintViolation(tab('0 x x 2 x x'), closed)).toBe('OPEN_STRINGS'); + }); +}); diff --git a/engine/playability.ts b/engine/playability.ts new file mode 100644 index 0000000..9a66996 --- /dev/null +++ b/engine/playability.ts @@ -0,0 +1,457 @@ +// Playability cost of a fingered voicing: the physical bucket comes from the fingering, this module adds the +// right-hand and musical buckets. Novelty is a separate ranking axis and never part of the difficulty. +import type { + CostBreakdown, + CostTerm, + CostTermKey, + Fingering, + HandProfile, + Shape, + Tuning, + VoicingFamily +} from './types'; +import { FINGER_NAMES, physicalCostBreakdown } from './fingering'; +import { intervalFrom, pitchClass } from './pitch'; +import { fromRiffForgeTab, shapeToMidiByString } from './shape'; +import { LOW_MUD_THRESHOLD_MIDI, PLAYABILITY_WEIGHTS } from './weights'; + +export interface ScoreContext { + tuning: Tuning; + profile: HandProfile; + rootPc: number; + family?: VoicingFamily; + /** High-gain context: enables the low-register mud term. */ + distortion: boolean; + /** 'chug' favours fewer, lower strings for palm-muted playing. */ + context: 'strum' | 'chug'; + /** Include the musical bucket. */ + musical: boolean; +} + +/** Semitone distances (1..4) that turn to mud when both notes sit below LOW_MUD_THRESHOLD_MIDI. */ +const MUD_MAX_SEMITONES = 4; + +const TERM_KEYS = Object.keys(PLAYABILITY_WEIGHTS) as CostTermKey[]; + +const clamp01 = (x: number): number => (x > 1 ? 1 : x > 0 ? x : 0); + +/** shape.ts interiorMutes without allocating: muted strings strictly between the lowest and highest sounding ones. */ +export const countInteriorMutes = (shape: Shape): number => { + let lo = -1; + let hi = -1; + for (let s = 0; s < shape.length; s++) { + if (shape[s] === null) continue; + if (lo < 0) lo = s; + hi = s; + } + let count = 0; + for (let s = lo + 1; s < hi; s++) if (shape[s] === null) count++; + return count; +}; + +const makeTerm = (key: CostTermKey, raw: number, normalized: number): CostTerm => { + const { bucket, weight } = PLAYABILITY_WEIGHTS[key]; + return { key, bucket, raw, normalized, weight, cost: normalized * weight }; +}; + +/** Note pairs below the mud threshold that are 1..4 semitones apart. */ +const lowMudPairs = (notes: readonly number[]): number => { + let pairs = 0; + for (let i = 0; i < notes.length; i++) { + if (notes[i] >= LOW_MUD_THRESHOLD_MIDI) continue; + for (let j = i + 1; j < notes.length; j++) { + if (notes[j] >= LOW_MUD_THRESHOLD_MIDI) continue; + const d = Math.abs(notes[i] - notes[j]); + if (d >= 1 && d <= MUD_MAX_SEMITONES) pairs++; + } + } + return pairs; +}; + +/** Extra copies of the most doubled third (minor or major), 0 when no third repeats. */ +const extraThirds = (notes: readonly number[], rootPc: number): number => { + let minor = 0; + let major = 0; + for (const midi of notes) { + const interval = intervalFrom(rootPc, pitchClass(midi)); + if (interval === 3) minor++; + else if (interval === 4) major++; + } + return Math.max(0, Math.max(minor, major) - 1); +}; + +/** Raw and normalized values of the right-hand and musical terms, in weights.ts key order. */ +interface HandAndMusic { + skips: number; + skipsN: number; + chugRaw: number; + chugN: number; + mud: number; + mudN: number; + thirds: number; + thirdsN: number; + wrongBass: number; + wrongBassN: number; +} + +const measureHandAndMusic = (shape: Shape, ctx: ScoreContext): HandAndMusic => { + const byString = shapeToMidiByString(shape, ctx.tuning); + const notes: number[] = []; + let stringIndexSum = 0; + let bass = Infinity; + for (let s = 0; s < byString.length; s++) { + const midi = byString[s]; + if (midi === null) continue; + notes.push(midi); + stringIndexSum += s; + if (midi < bass) bass = midi; + } + const sounding = notes.length; + const strings = shape.length; + + const skips = countInteriorMutes(shape); + const chug = + ctx.context === 'chug' && sounding > 0 + ? 0.5 * clamp01((sounding - 1) / Math.max(1, strings - 1)) + 0.5 * ((stringIndexSum / sounding) / Math.max(1, strings - 1)) + : 0; + + const mud = lowMudPairs(notes); + const thirds = extraThirds(notes, ctx.rootPc); + // Only a family that prefers the root in the bass is charged: a relaxed slash-bass recipe (preferred bass on + // another degree) has no term of its own, and calling its root-position voicings "root not in the bass" is false. + const preferred = ctx.family?.preferredBass; + const wrongBass = + preferred !== undefined && pitchClass(preferred) === 0 && sounding > 0 && intervalFrom(ctx.rootPc, pitchClass(bass)) !== 0 ? 1 : 0; + const musical = ctx.musical; + + return { + skips, + skipsN: clamp01(skips / 2), + chugRaw: ctx.context === 'chug' ? sounding : 0, + chugN: clamp01(chug), + mud, + mudN: musical && ctx.distortion ? clamp01(mud / 2) : 0, + thirds, + thirdsN: musical ? clamp01(thirds) : 0, + wrongBass, + wrongBassN: musical ? wrongBass : 0 + }; +}; + +const rightHandAndMusicalTerms = (shape: Shape, ctx: ScoreContext): CostTerm[] => { + const m = measureHandAndMusic(shape, ctx); + return [ + makeTerm('stringSkips', m.skips, m.skipsN), + makeTerm('chugStrings', m.chugRaw, m.chugN), + makeTerm('lowMud', m.mud, m.mudN), + makeTerm('doubledThird', m.thirds, m.thirdsN), + makeTerm('rootNotInBass', m.wrongBass, m.wrongBassN) + ]; +}; + +const W = PLAYABILITY_WEIGHTS; + +const rightHandCost = (m: HandAndMusic): number => { + let rightHand = 0; + rightHand += m.skipsN * W.stringSkips.weight; + rightHand += m.chugN * W.chugStrings.weight; + return rightHand; +}; + +const musicalCost = (m: HandAndMusic): number => { + let musical = 0; + musical += m.mudN * W.lowMud.weight; + musical += m.thirdsN * W.doubledThird.weight; + musical += m.wrongBassN * W.rootNotInBass.weight; + return musical; +}; + +/** The right-hand and musical buckets of scoreVoicing: the part of the cost that needs no fingering. */ +export const nonPhysicalCost = (shape: Shape, ctx: ScoreContext): number => { + const m = measureHandAndMusic(shape, ctx); + return rightHandCost(m) + musicalCost(m); +}; + +/** + * scoreVoicing(...).total without building the breakdown: the same terms summed in the same order, so the result + * is bit-identical. Used to rank thousands of candidates; only the chosen ones get a full breakdown. + */ +export const scoreTotal = (shape: Shape, fingering: Fingering, ctx: ScoreContext): number => { + const fromFingering = fingering.costBreakdown.terms; + const physicalTerms = + fromFingering.length > 0 + ? fromFingering + : physicalCostBreakdown(shape, fingering.fingers, fingering.barres, fingering.metrics).terms; + let physical = 0; + for (const key of TERM_KEYS) { + if (W[key].bucket !== 'physical') continue; + for (const t of physicalTerms) { + if (t.key === key) { + physical += t.cost; + break; + } + } + } + const m = measureHandAndMusic(shape, ctx); + return physical + rightHandCost(m) + musicalCost(m); +}; + +/** + * Full breakdown: the 9 physical terms copied from the fingering, then right-hand and musical terms, always all + * 14 in weights.ts key order. Musical terms switched off (musical false, or lowMud without distortion) keep their + * measured raw value with normalized 0 and cost 0; chugStrings is measured only in the 'chug' context. + */ +export const scoreVoicing = (shape: Shape, fingering: Fingering, ctx: ScoreContext): CostBreakdown => { + const fromFingering = fingering.costBreakdown.terms; + const physicalTerms = + fromFingering.length > 0 + ? fromFingering + : physicalCostBreakdown(shape, fingering.fingers, fingering.barres, fingering.metrics).terms; + const byKey = new Map(); + for (const t of physicalTerms) byKey.set(t.key, t); + for (const t of rightHandAndMusicalTerms(shape, ctx)) byKey.set(t.key, t); + + const terms: CostTerm[] = []; + let physical = 0; + let rightHand = 0; + let musical = 0; + for (const key of TERM_KEYS) { + const source = byKey.get(key) ?? makeTerm(key, 0, 0); + const t: CostTerm = { ...source }; + terms.push(t); + if (t.bucket === 'physical') physical += t.cost; + else if (t.bucket === 'rightHand') rightHand += t.cost; + else musical += t.cost; + } + return { total: physical + rightHand + musical, physical, rightHand, musical, terms }; +}; + +// --------------------------------------------------------------------------- +// Novelty +// --------------------------------------------------------------------------- + +const COMMON_TABS: readonly string[] = [ + // Open chords, E standard + '0 2 2 1 0 0', // E + '0 2 2 0 0 0', // Em + '0 2 0 1 0 0', // E7 + '0 2 0 0 0 0', // Em7 + '0 2 2 2 0 0', // Esus4 + 'x 0 2 2 2 0', // A + 'x 0 2 2 1 0', // Am + 'x 0 2 0 2 0', // A7 + 'x 0 2 0 1 0', // Am7 + 'x 0 2 1 2 0', // Amaj7 + 'x 0 2 2 0 0', // Asus2 + 'x 0 2 2 3 0', // Asus4 + 'x 3 2 0 1 0', // C + 'x 3 2 0 0 0', // Cmaj7 + 'x 3 2 3 1 0', // C7 + 'x 3 2 0 3 0', // Cadd9 + 'x x 0 2 3 2', // D + 'x x 0 2 3 1', // Dm + 'x x 0 2 1 2', // D7 + 'x x 0 2 2 2', // Dmaj7 + 'x x 0 2 3 0', // Dsus2 + 'x x 0 2 3 3', // Dsus4 + '3 2 0 0 0 3', // G + '3 2 0 0 3 3', // G (rock) + '3 2 0 0 0 1', // G7 + 'x x 3 2 1 1', // small F + 'x x 3 2 1 0', // Fmaj7 + 'x 2 1 2 0 2', // B7 + // Open power chords and drop-tuning one-finger power chord as written + '0 2 2 x x x', // E5 + 'x 0 2 2 x x', // A5 + 'x x 0 2 3 x', // D5 + '0 0 0 x x x', // drop-D 5 + // Movable power chords and octaves + '1 3 3 x x x', + 'x 1 3 3 x x', + 'x x 1 3 3 x', + '1 3 x x x x', + 'x 1 3 x x x', + 'x x 1 3 x x', + '1 1 1 x x x', // drop-tuning one-finger power chord + '1 x 3 x x x', + 'x 1 x 3 x x', + 'x x 1 x 4 x', + 'x x x 1 x 4', + // CAGED barre forms + '1 3 3 2 1 1', // E shape + '1 3 3 1 1 1', // E-shape minor + '1 3 1 2 1 1', // E-shape 7 + '1 3 1 1 1 1', // E-shape m7 + 'x 1 3 3 3 1', // A shape + 'x 1 3 3 2 1', // A-shape minor + 'x 1 3 1 3 1', // A-shape 7 + 'x 1 3 1 2 1', // A-shape m7 + 'x 4 3 1 2 1', // C shape + 'x x 1 3 4 3', // D shape + 'x x 1 3 4 2', // D-shape minor + '4 3 1 1 1 4', // G shape + // Common shells + '1 x 1 2 x x', // 7 shell, root on string 0 + '1 x 1 1 x x', // m7 shell + '1 x 2 2 x x', // maj7 shell + 'x 1 x 1 3 x', // 7 shell, root on string 1 + 'x 1 x 1 2 x', // m7 shell + 'x 1 x 2 3 x' // maj7 shell +]; + +const parseCommon = (text: string): Shape => { + const shape = fromRiffForgeTab(text); + if (!shape) throw new Error(`Invalid common shape ${text}`); + return Object.freeze(shape); +}; + +/** + * E-standard open chords and common movable shapes (power chords, octaves, CAGED forms, shells). Shapes without + * open strings match at any position; shapes with open strings match only as written. + */ +export const COMMON_SHAPES: readonly Shape[] = Object.freeze(COMMON_TABS.map(parseCommon)); + +const MUTED = -2; +const OPEN = -1; + +/** Per string: -2 muted, -1 open, otherwise the fret minus the lowest fretted fret, or the fret itself when absolute. */ +const signatureOf = (shape: Shape, absolute: boolean): Int16Array => { + let min = Infinity; + for (const f of shape) if (f !== null && f > 0 && f < min) min = f; + const offset = absolute || min === Infinity ? 0 : min; + const values = new Int16Array(shape.length); + for (let s = 0; s < shape.length; s++) { + const f = shape[s]; + values[s] = f === null ? MUTED : f === 0 ? OPEN : f - offset; + } + return values; +}; + +/** A reference shape reduced to its signature. References with open strings compare frets as written. */ +export interface PreparedReference { + absolute: boolean; + signature: Int16Array; +} + +const isUsableShape = (shape: unknown): shape is Shape => + Array.isArray(shape) && + shape.some((f) => f !== null) && + shape.every((f) => f === null || (Number.isInteger(f) && (f as number) >= 0)); + +/** Reduces reference shapes once so novelty can be computed for many candidates; malformed or silent shapes are skipped. */ +export const prepareReferences = (shapes: readonly Shape[]): PreparedReference[] => + shapes.filter(isUsableShape).map((shape) => { + const absolute = shape.some((f) => f === 0); + return { absolute, signature: signatureOf(shape, absolute) }; + }); + +const COMMON_REFERENCES: readonly PreparedReference[] = prepareReferences(COMMON_SHAPES); + +/** + * Signatures of different lengths are aligned at their highest string and the shorter one is padded with muted low + * strings: the top six strings of a 7- or 8-string guitar are the strings the 6-string references were written for. + */ +const signatureDistance = (a: Int16Array, b: Int16Array): number => { + const n = a.length > b.length ? a.length : b.length; + const padA = n - a.length; + const padB = n - b.length; + let sum = 0; + for (let s = 0; s < n; s++) { + const x = s < padA ? MUTED : a[s - padA]; + const y = s < padB ? MUTED : b[s - padB]; + if (x === y) continue; + if (x >= 0 && y >= 0) { + const d = Math.abs(x - y) / 2; + sum += d > 1 ? 1 : d; + } else { + sum += 1; + } + } + return n === 0 ? 1 : sum / n; +}; + +/** Novelty against prepared references; COMMON_SHAPES are always included. */ +export const noveltyAgainst = (shape: Shape, extra: readonly PreparedReference[] = []): number => { + const relative = signatureOf(shape, false); + const absolute = signatureOf(shape, true); + let best = 1; + for (const list of [COMMON_REFERENCES, extra]) { + for (const ref of list) { + const d = signatureDistance(ref.absolute ? absolute : relative, ref.signature); + if (d < best) best = d; + if (best === 0) return 0; + } + } + return best; +}; + +/** + * 0..1 distance from the nearest common shape or reference: per string, equal => 0, both fretted => min(1, |d| / 2) + * on frets relative to each shape's lowest fret (as written for references with open strings), otherwise 1; + * averaged over strings. Shapes and references of different string counts are aligned at the highest string. + */ +export const noveltyScore = (shape: Shape, references: readonly Shape[] = []): number => + noveltyAgainst(shape, prepareReferences(references)); + +// --------------------------------------------------------------------------- +// Explanations and profile constraints +// --------------------------------------------------------------------------- + +const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`; + +/** Position is only worth mentioning from this fret up. */ +const POSITION_NOTE_FRET = 5; + +/** + * The span as a share of the comfortable reach (the calibrated index -> pinky reach). The fingering measures it + * against the allowed span, which includes the stretch allowance, so without the profile only that can be named. + */ +const reachPhrase = (metrics: Fingering['metrics'], profile?: HandProfile): string => { + if (!profile || !(profile.stretchTolerance > 0)) { + return `reach ${Math.round(metrics.reachRatio * 100)} percent of your allowed reach`; + } + const comfortMm = metrics.allowedSpanMm / profile.stretchTolerance; + const pct = Math.round((metrics.spanMm / comfortMm) * 100); + return `reach ${pct} percent of your comfort${pct > 100 ? ' (a stretch)' : ''}`; +}; + +/** + * Short human phrases for the UI. Comfort guidance only: phrases describe effort, never safety. Pass the hand + * profile the fingering was found with so reach is reported against the comfortable reach. + */ +export const explainCost = (breakdown: CostBreakdown, fingering: Fingering, profile?: HandProfile): string[] => { + const { metrics } = fingering; + const cost = (key: CostTermKey): number => breakdown.terms.find((t) => t.key === key)?.cost ?? 0; + const raw = (key: CostTermKey): number => breakdown.terms.find((t) => t.key === key)?.raw ?? 0; + const lines: string[] = []; + + lines.push(metrics.fingerCount === 0 ? 'open strings only' : plural(metrics.fingerCount, 'finger')); + if (metrics.reachRatio > 0) lines.push(reachPhrase(metrics, profile)); + if (metrics.maxPairStretch > 0) { + const worst = metrics.pairs.reduce((a, b) => (b.stretch > a.stretch ? b : a)); + lines.push(`${FINGER_NAMES[worst.from]}-${FINGER_NAMES[worst.to]} stretch ${Math.round(worst.stretch * 100)} percent toward your limit`); + } + if (metrics.usesPinky) lines.push('uses pinky'); + if (metrics.usesThumb) lines.push('thumb frets the low string'); + if (fingering.barres.length > 0) lines.push(plural(fingering.barres.length, 'barre')); + if (metrics.contortions > 0) lines.push(plural(metrics.contortions, 'same-fret finger crossing')); + if (metrics.interiorMutes > 0) lines.push(plural(metrics.interiorMutes, 'interior mute')); + if (metrics.lowestFret !== null && metrics.lowestFret >= POSITION_NOTE_FRET) lines.push(`position at fret ${metrics.lowestFret}`); + if (cost('stringSkips') > 0) lines.push(`${plural(raw('stringSkips'), 'skipped string')} for the picking hand`); + if (cost('chugStrings') > 0) lines.push(`${plural(raw('chugStrings'), 'string')} to palm-mute`); + if (cost('lowMud') > 0) lines.push(`${plural(raw('lowMud'), 'muddy low interval')} under gain`); + if (cost('doubledThird') > 0) lines.push('doubled third'); + if (cost('rootNotInBass') > 0) lines.push('root not in the bass'); + return lines; +}; + +/** Profile rules that are not about fingers, checked in this order. */ +export const shapeConstraintViolation = ( + shape: Shape, + profile: HandProfile +): 'MAX_FRET' | 'OPEN_STRINGS' | 'INTERIOR_MUTES' | null => { + if (shape.some((f) => f !== null && f > profile.maxFret)) return 'MAX_FRET'; + if (!profile.allowOpenStrings && shape.some((f) => f === 0)) return 'OPEN_STRINGS'; + if (countInteriorMutes(shape) > profile.maxInteriorMutes) return 'INTERIOR_MUTES'; + return null; +}; diff --git a/engine/random.test.ts b/engine/random.test.ts new file mode 100644 index 0000000..4dc2156 --- /dev/null +++ b/engine/random.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { createRng, mulberry32, seedFromString } from './random'; + +describe('seedFromString', () => { + it('is stable and distinguishes inputs', () => { + expect(seedFromString('riff')).toBe(seedFromString('riff')); + expect(seedFromString('riff')).not.toBe(seedFromString('riffs')); + expect(seedFromString('')).toBe(0x811c9dc5); + }); +}); + +describe('mulberry32', () => { + it('produces the same stream for the same seed, in [0, 1)', () => { + const a = mulberry32(42); + const b = mulberry32(42); + for (let i = 0; i < 1000; i++) { + const x = a(); + expect(x).toBe(b()); + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThan(1); + } + }); +}); + +describe('createRng', () => { + it('is deterministic per seed string', () => { + const seq = (seed: string) => { + const rng = createRng(seed); + return Array.from({ length: 20 }, () => rng.int(0, 9)); + }; + expect(seq('gallop')).toEqual(seq('gallop')); + expect(seq('gallop')).not.toEqual(seq('chug')); + }); + + it('keeps int within inclusive bounds and covers them', () => { + const rng = createRng('bounds'); + const seen = new Set(); + for (let i = 0; i < 500; i++) { + const v = rng.int(1, 4); + expect(v).toBeGreaterThanOrEqual(1); + expect(v).toBeLessThanOrEqual(4); + seen.add(v); + } + expect([...seen].sort()).toEqual([1, 2, 3, 4]); + }); + + it('never picks zero-weight items unless all weights are zero', () => { + const rng = createRng('weights'); + for (let i = 0; i < 200; i++) { + expect(rng.weighted(['a', 'b', 'c'], [0, 1, 0])).toBe('b'); + } + expect(['a', 'b']).toContain(rng.weighted(['a', 'b'], [0, 0])); + }); + + it('shuffles without losing items and forks stable child streams', () => { + const rng = createRng('shuffle'); + expect(rng.shuffle([1, 2, 3, 4, 5]).sort()).toEqual([1, 2, 3, 4, 5]); + const childA = createRng('parent').fork('bar-1'); + const childB = createRng('parent').fork('bar-1'); + expect(childA.next()).toBe(childB.next()); + expect(createRng('parent').fork('bar-1').next()).not.toBe(createRng('parent').fork('bar-2').next()); + }); + + it('throws when picking from an empty list', () => { + expect(() => createRng('x').pick([])).toThrow(); + }); +}); diff --git a/engine/random.ts b/engine/random.ts new file mode 100644 index 0000000..aaf2c55 --- /dev/null +++ b/engine/random.ts @@ -0,0 +1,78 @@ +// Seeded randomness. The engine never calls Math.random. + +/** FNV-1a 32-bit hash of a string, used to derive numeric seeds. */ +export const seedFromString = (input: string): number => { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +}; + +/** mulberry32: small, fast, good enough for musical choices. Returns floats in [0, 1). */ +export const mulberry32 = (seed: number): (() => number) => { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +export interface Rng { + /** Float in [0, 1). */ + next: () => number; + /** Integer in [min, max] inclusive. */ + int: (min: number, max: number) => number; + /** True with probability p. */ + chance: (p: number) => boolean; + pick: (items: readonly T[]) => T; + /** Pick by non-negative weights; falls back to uniform when all weights are 0. */ + weighted: (items: readonly T[], weights: readonly number[]) => T; + shuffle: (items: readonly T[]) => T[]; + /** Derive an independent child stream, stable for a given label. */ + fork: (label: string) => Rng; +} + +export const createRng = (seed: string | number): Rng => { + const numeric = typeof seed === 'number' ? seed >>> 0 : seedFromString(seed); + const next = mulberry32(numeric); + + const rng: Rng = { + next, + int: (min, max) => { + const lo = Math.ceil(Math.min(min, max)); + const hi = Math.floor(Math.max(min, max)); + return lo + Math.floor(next() * (hi - lo + 1)); + }, + chance: (p) => next() < p, + pick: (items) => { + if (items.length === 0) throw new Error('pick from empty list'); + return items[Math.floor(next() * items.length)]; + }, + weighted: (items, weights) => { + if (items.length === 0) throw new Error('weighted pick from empty list'); + const total = weights.reduce((sum, w) => sum + Math.max(0, w || 0), 0); + if (total <= 0) return items[Math.floor(next() * items.length)]; + let r = next() * total; + for (let i = 0; i < items.length; i++) { + r -= Math.max(0, weights[i] || 0); + if (r < 0) return items[i]; + } + return items[items.length - 1]; + }, + shuffle: (items) => { + const out = [...items]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(next() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; + }, + fork: (label) => createRng(seedFromString(`${numeric}:${label}`)) + }; + return rng; +}; diff --git a/engine/shape.test.ts b/engine/shape.test.ts new file mode 100644 index 0000000..56d0998 --- /dev/null +++ b/engine/shape.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { + shapeToMidi, + shapeToMidiByString, + bassMidi, + shapePitchClasses, + soundingStrings, + frettedStrings, + openStrings, + interiorMutes, + lowestFret, + highestFret, + isValidShape, + toRiffForgeTab, + fromRiffForgeTab, + toChordExplorerFrets, + fromChordExplorerFrets +} from './shape'; +import { E_STANDARD, DROP_D } from './tuning'; +import { midiToName } from './pitch'; + +describe('shapeToMidi', () => { + it('derives pitches as openMidi + fret', () => { + expect(shapeToMidi([0, 2, 2, 1, 0, 0], E_STANDARD).map(midiToName)).toEqual(['E2', 'B2', 'E3', 'G#3', 'B3', 'E4']); + }); + + it('sounds D2 A2 D3 for 0 0 0 x x x in Drop D', () => { + expect(shapeToMidi([0, 0, 0, null, null, null], DROP_D).map(midiToName)).toEqual(['D2', 'A2', 'D3']); + }); + + it('keeps muted strings as null per string', () => { + expect(shapeToMidiByString([null, 3, 2, 0, 1, 0], E_STANDARD)).toEqual([null, 48, 52, 55, 60, 64]); + }); + + it('throws when the shape does not fit the tuning', () => { + expect(() => shapeToMidi([0, 2, 2], E_STANDARD)).toThrow(); + expect(() => shapeToMidi([0, -1, 2, 2, 0, 0], E_STANDARD)).toThrow(); + }); + + it('finds the bass by pitch, not by string', () => { + // String 0 at fret 7 (B2) sounds above open string 1 (A2) + expect(bassMidi([7, 0, null, null, null, null], E_STANDARD)).toBe(45); + expect(bassMidi([null, null, null, null, null, null], E_STANDARD)).toBeNull(); + }); + + it('collects sorted unique pitch classes', () => { + expect(shapePitchClasses([0, 2, 2, null, null, null], E_STANDARD)).toEqual([4, 11]); + }); +}); + +describe('shape helpers', () => { + const shape = [0, null, 2, 4, null, 0]; + + it('lists sounding, fretted and open strings', () => { + expect(soundingStrings(shape)).toEqual([0, 2, 3, 5]); + expect(frettedStrings(shape)).toEqual([2, 3]); + expect(openStrings(shape)).toEqual([0, 5]); + }); + + it('counts interior mutes only between sounding strings', () => { + expect(interiorMutes(shape)).toBe(2); + expect(interiorMutes([null, null, 2, 2, null, null])).toBe(0); + expect(interiorMutes([0, null, null, null, null, null])).toBe(0); + }); + + it('finds the fretted range ignoring opens', () => { + expect(lowestFret(shape)).toBe(2); + expect(highestFret(shape)).toBe(4); + expect(lowestFret([0, 0, 0, null, null, null])).toBeNull(); + }); + + it('validates shapes', () => { + expect(isValidShape([0, 2, 2, 1, 0, 0], 6)).toBe(true); + expect(isValidShape([0, 2, 2, 1, 0], 6)).toBe(false); + expect(isValidShape([0, 2.5, 2, 1, 0, 0], 6)).toBe(false); + }); +}); + +describe('adapters', () => { + it('round-trips RiffForge tabs low -> high', () => { + expect(toRiffForgeTab([0, 2, 2, null, null, null])).toBe('0 2 2 x x x'); + expect(fromRiffForgeTab('0 2 2 x x x')).toEqual([0, 2, 2, null, null, null]); + expect(fromRiffForgeTab(' 10 12 X x x x ')).toEqual([10, 12, null, null, null, null]); + expect(fromRiffForgeTab('0 2 a')).toBeNull(); + expect(fromRiffForgeTab('')).toBeNull(); + }); + + it('round-trips Chord Explorer frets high -> low with -1 muted', () => { + // C major open: x 3 2 0 1 0 low -> high + const cOpen = [null, 3, 2, 0, 1, 0]; + expect(toChordExplorerFrets(cOpen)).toEqual([0, 1, 0, 2, 3, -1]); + expect(fromChordExplorerFrets([0, 1, 0, 2, 3, -1])).toEqual(cOpen); + }); +}); diff --git a/engine/shape.ts b/engine/shape.ts new file mode 100644 index 0000000..343e89f --- /dev/null +++ b/engine/shape.ts @@ -0,0 +1,97 @@ +// Shapes and the single shape -> sounding MIDI conversion, plus app adapters. +import type { Shape, Tuning } from './types'; +import { pitchClass } from './pitch'; + +const isFret = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0; + +export const isValidShape = (shape: Shape, strings: number): boolean => + Array.isArray(shape) && shape.length === strings && shape.every((f) => f === null || isFret(f)); + +const assertShape = (shape: Shape, tuning: Tuning): void => { + if (!isValidShape(shape, tuning.openMidi.length)) { + throw new Error(`Shape ${JSON.stringify(shape)} does not fit a ${tuning.openMidi.length}-string tuning`); + } +}; + +/** MIDI per string, low -> high; null for muted strings. */ +export const shapeToMidiByString = (shape: Shape, tuning: Tuning): (number | null)[] => { + assertShape(shape, tuning); + return shape.map((fret, s) => (fret === null ? null : tuning.openMidi[s] + fret)); +}; + +/** + * The only shape -> pitch conversion in the engine. Tabs and audio must both go through it. + * Returns sounding notes in string order (lowest string first), muted strings skipped. + */ +export const shapeToMidi = (shape: Shape, tuning: Tuning): number[] => + shapeToMidiByString(shape, tuning).filter((m): m is number => m !== null); + +export const bassMidi = (shape: Shape, tuning: Tuning): number | null => { + const notes = shapeToMidi(shape, tuning); + return notes.length === 0 ? null : Math.min(...notes); +}; + +/** Sorted unique pitch classes sounding in the shape. */ +export const shapePitchClasses = (shape: Shape, tuning: Tuning): number[] => + [...new Set(shapeToMidi(shape, tuning).map(pitchClass))].sort((a, b) => a - b); + +export const soundingStrings = (shape: Shape): number[] => + shape.flatMap((fret, s) => (fret === null ? [] : [s])); + +export const frettedStrings = (shape: Shape): number[] => + shape.flatMap((fret, s) => (fret !== null && fret > 0 ? [s] : [])); + +export const openStrings = (shape: Shape): number[] => shape.flatMap((fret, s) => (fret === 0 ? [s] : [])); + +/** Muted strings strictly between the lowest and the highest sounding string. */ +export const interiorMutes = (shape: Shape): number => { + const sounding = soundingStrings(shape); + if (sounding.length < 2) return 0; + const lo = sounding[0]; + const hi = sounding[sounding.length - 1]; + let count = 0; + for (let s = lo + 1; s < hi; s++) if (shape[s] === null) count++; + return count; +}; + +export const lowestFret = (shape: Shape): number | null => { + const frets = shape.filter((f): f is number => f !== null && f > 0); + return frets.length === 0 ? null : Math.min(...frets); +}; + +export const highestFret = (shape: Shape): number | null => { + const frets = shape.filter((f): f is number => f !== null && f > 0); + return frets.length === 0 ? null : Math.max(...frets); +}; + +// --------------------------------------------------------------------------- +// Adapters +// --------------------------------------------------------------------------- + +/** RiffForge tab: space-separated, low -> high, "x" = muted. Also the canonical shape key. */ +export const toRiffForgeTab = (shape: Shape): string => shape.map((f) => (f === null ? 'x' : String(f))).join(' '); + +export const shapeKey = toRiffForgeTab; + +/** Parses "0 2 2 x x x" (low -> high). Returns null on malformed input. */ +export const fromRiffForgeTab = (tab: string): Shape | null => { + const tokens = tab.trim().split(/\s+/); + if (tokens.length === 0 || tokens[0] === '') return null; + const shape: (number | null)[] = []; + for (const token of tokens) { + if (token === 'x' || token === 'X') { + shape.push(null); + } else if (/^\d{1,2}$/.test(token)) { + shape.push(parseInt(token, 10)); + } else { + return null; + } + } + return shape; +}; + +/** Chord Explorer frets: high -> low string order, -1 = muted. */ +export const toChordExplorerFrets = (shape: Shape): number[] => [...shape].reverse().map((f) => (f === null ? -1 : f)); + +export const fromChordExplorerFrets = (frets: readonly number[]): Shape => + [...frets].reverse().map((f) => (f < 0 ? null : f)); diff --git a/engine/transition.test.ts b/engine/transition.test.ts new file mode 100644 index 0000000..6f20713 --- /dev/null +++ b/engine/transition.test.ts @@ -0,0 +1,788 @@ +import { describe, it, expect } from 'vitest'; +import { + TRANSITION_WEIGHTS, + transitionCost, + minimaxViterbi, + optimizeSequence, + describeTransition +} from './transition'; +import { DEFAULT_SCALE_LENGTH_MM, fingertipMm } from './geometry'; +import { fromRiffForgeTab } from './shape'; +import { createRng } from './random'; +import { enumerateFingerings, findBestFingering } from './fingering'; +import { DEFAULT_HAND_PROFILE } from './handProfile'; +import type { + Barre, + CostBreakdown, + FingeredShape, + FingerNumber, + FingeringMetrics, + SequenceTransition, + TransitionBreakdown +} from './types'; + +const L = DEFAULT_SCALE_LENGTH_MM; +const W = TRANSITION_WEIGHTS; +const tip = (fret: number) => fingertipMm(fret, L); + +const ZERO_METRICS: FingeringMetrics = { + frettedCount: 0, + fingerCount: 0, + spanMm: 0, + allowedSpanMm: 0, + reachRatio: 0, + pairs: [], + maxPairRatio: 0, + maxPairStretch: 0, + usesPinky: false, + usesThumb: false, + contortions: 0, + interiorMutes: 0, + lowestFret: null, + highestFret: null +}; + +const ZERO_BREAKDOWN: CostBreakdown = { total: 0, physical: 0, rightHand: 0, musical: 0, terms: [] }; + +/** Hand-built fingered shape, independent of fingering.ts: fingered('x 3 5 5 x x', 'x 1 3 4 x x'). */ +const fingered = (tab: string, fingerTab: string, barres: Barre[] = []): FingeredShape => { + const shape = fromRiffForgeTab(tab); + if (shape === null) throw new Error(`bad tab ${tab}`); + const fingers = fingerTab + .trim() + .split(/\s+/) + .map((t) => (t === 'x' ? null : (Number(t) as FingerNumber))); + if (fingers.length !== shape.length) throw new Error(`finger tab ${fingerTab} does not match ${tab}`); + return { shape, fingering: { fingers, barres, metrics: { ...ZERO_METRICS }, costBreakdown: ZERO_BREAKDOWN } }; +}; + +const cost = (a: FingeredShape, b: FingeredShape, timeSec = 1) => transitionCost(a, b, { scaleLengthMm: L, timeSec }); + +const EPS = 1e-9; + +type EdgeFn = (layer: number, from: number, to: number) => number; +type NodeFn = (layer: number, index: number) => number; + +/** Reference: enumerate every path in lexicographic order, keep the first one that is best by (max, sum). */ +const bruteForceMinimax = (sizes: readonly number[], edge: EdgeFn, cyclic: boolean, nodeCost?: NodeFn) => { + const n = sizes.length; + let best: { path: number[]; maxCost: number; sumCost: number } | null = null; + const visit = (path: number[]) => { + if (path.length < n) { + for (let k = 0; k < sizes[path.length]; k++) visit([...path, k]); + return; + } + const edges: number[] = []; + for (let i = 0; i + 1 < n; i++) edges.push(edge(i, path[i], path[i + 1])); + if (cyclic) edges.push(edge(n - 1, path[n - 1], path[0])); + const maxCost = edges.length === 0 ? 0 : Math.max(...edges); + let sumCost = edges.reduce((s, c) => s + c, 0); + if (nodeCost) path.forEach((k, i) => (sumCost += nodeCost(i, k))); + if ( + best === null || + maxCost < best.maxCost - EPS || + (Math.abs(maxCost - best.maxCost) <= EPS && sumCost < best.sumCost - EPS) + ) { + best = { path, maxCost, sumCost }; + } + }; + visit([]); + return best!; +}; + +/** Reference: plain min-sum path (what an ordinary Viterbi would pick), lexicographically first on ties. */ +const bruteForceMinSum = (sizes: readonly number[], edge: EdgeFn) => { + let best: { path: number[]; sumCost: number } | null = null; + const visit = (path: number[]) => { + if (path.length < sizes.length) { + for (let k = 0; k < sizes[path.length]; k++) visit([...path, k]); + return; + } + let sumCost = 0; + for (let i = 0; i + 1 < path.length; i++) sumCost += edge(i, path[i], path[i + 1]); + if (best === null || sumCost < best.sumCost - EPS) best = { path, sumCost }; + }; + visit([]); + return best!; +}; + +/** m[layer][from][to] = edge cost from layer `layer` to the next layer (the last one closes the cycle). */ +const fromMatrices = + (m: number[][][]): EdgeFn => + (layer, from, to) => + m[layer][from][to]; + +// --------------------------------------------------------------------------- +// transitionCost +// --------------------------------------------------------------------------- + +describe('TRANSITION_WEIGHTS', () => { + it('keeps every weight non-negative and the anchor discount below 1', () => { + for (const value of Object.values(W)) expect(value).toBeGreaterThanOrEqual(0); + expect(W.maxAnchorDiscount).toBeLessThan(1); + expect(W.slideTravelFactor).toBeLessThanOrEqual(1); + expect(W.minTimeSec).toBeCloseTo(0.05, 12); + }); +}); + +describe('transitionCost', () => { + it('costs nothing between identical shapes and counts every held finger as an anchor', () => { + const eMajor = fingered('0 2 2 1 0 0', 'x 2 3 1 x x'); + const t = cost(eMajor, eMajor); + expect(t).toEqual({ + fingerTravelMm: 0, + stringChanges: 0, + lifted: 0, + placed: 0, + anchors: 3, + slides: 0, + positionShiftMm: 0, + raw: 0, + timeSec: 1, + cost: 0 + }); + }); + + it('counts a barre finger once (at its lowest string) when the shape is held', () => { + const fBarre = fingered('1 3 3 2 1 1', '1 3 4 2 1 1', [{ finger: 1, fret: 1, fromString: 0, toString: 5 }]); + const t = cost(fBarre, fBarre); + expect(t.anchors).toBe(4); + expect(t.raw).toBe(0); + }); + + it('places a barre finger at its lowest string', () => { + const barre = fingered('x x 3 3 3 x', 'x x 1 1 1 x', [{ finger: 1, fret: 3, fromString: 2, toString: 4 }]); + const single = fingered('x x 3 x x x', 'x x 1 x x x'); + const highSingle = fingered('x x x x 3 x', 'x x x x 1 x'); + expect(cost(barre, single).anchors).toBe(1); + const t = cost(barre, highSingle); + expect(t.anchors).toBe(0); + expect(t.stringChanges).toBe(2); + expect(t.fingerTravelMm).toBe(0); + }); + + it('counts lifted and placed fingers', () => { + const three = fingered('x 3 5 5 x x', 'x 1 3 4 x x'); + const two = fingered('x 3 5 x x x', 'x 1 3 x x x'); + const down = cost(three, two); + expect(down).toMatchObject({ lifted: 1, placed: 0, anchors: 2, slides: 0, stringChanges: 0 }); + const up = cost(two, three); + expect(up).toMatchObject({ lifted: 0, placed: 1, anchors: 2 }); + // Placing a finger is slower than lifting one. + expect(up.raw).toBeGreaterThan(down.raw); + }); + + it('counts string changes for fingers kept across the change', () => { + // Open E major -> open A minor: the same hand frame moved one string over. + const eMajor = fingered('0 2 2 1 0 0', 'x 2 3 1 x x'); + const aMinor = fingered('x 0 2 2 1 0', 'x x 2 3 1 x'); + const t = cost(eMajor, aMinor); + expect(t).toMatchObject({ stringChanges: 3, fingerTravelMm: 0, anchors: 0, slides: 0, lifted: 0, placed: 0, positionShiftMm: 0 }); + expect(t.raw).toBeCloseTo(3 * W.stringChange, 12); + }); + + it('measures slides, finger travel and the hand position shift in mm', () => { + const at3 = fingered('x 3 5 5 x x', 'x 1 3 4 x x'); + const at5 = fingered('x 5 7 7 x x', 'x 1 3 4 x x'); + const t = cost(at3, at5); + const travel = tip(5) - tip(3) + 2 * (tip(7) - tip(5)); + const shift = tip(5) - tip(3); + expect(t.slides).toBe(3); + expect(t.anchors).toBe(0); + expect(t.stringChanges).toBe(0); + expect(t.fingerTravelMm).toBeCloseTo(travel, 9); + expect(t.positionShiftMm).toBeCloseTo(shift, 9); + expect(t.raw).toBeCloseTo(W.fingerTravelPerMm * W.slideTravelFactor * travel + W.positionShiftPerMm * shift, 12); + // Moving down is the same distance. + expect(cost(at5, at3).raw).toBeCloseTo(t.raw, 12); + }); + + it('makes a slide cheaper than lifting and placing fingers for the same notes', () => { + const from = fingered('x 3 5 x x x', 'x 1 3 x x x'); + const slide = cost(from, fingered('x 5 7 x x x', 'x 1 3 x x x')); + const lift = cost(from, fingered('x 5 7 x x x', 'x 2 4 x x x')); + expect(slide).toMatchObject({ slides: 2, lifted: 0, placed: 0 }); + expect(lift).toMatchObject({ slides: 0, lifted: 2, placed: 2 }); + expect(slide.positionShiftMm).toBeCloseTo(lift.positionShiftMm, 12); + expect(slide.raw).toBeLessThan(lift.raw); + }); + + it('discounts the cost of a change that keeps an anchor finger down', () => { + const from = fingered('x 3 5 x x x', 'x 1 3 x x x'); + const to = fingered('x 3 x 6 x x', 'x 1 x 4 x x'); + const t = cost(from, to); + expect(t).toMatchObject({ anchors: 1, lifted: 1, placed: 1, positionShiftMm: 0 }); + expect(t.raw).toBeCloseTo((W.lifted + W.placed) * (1 - W.anchorDiscount), 12); + // Same lift and place without the anchor costs the full amount. + const noAnchor = cost(fingered('x x 5 x x x', 'x x 3 x x x'), fingered('x x x 6 x x', 'x x x 4 x x')); + expect(noAnchor.raw).toBeGreaterThan(t.raw); + }); + + it('caps the anchor discount', () => { + const from = fingered('3 5 5 4 3 3', '1 3 4 2 1 1', [{ finger: 1, fret: 3, fromString: 0, toString: 5 }]); + const to = fingered('3 5 5 4 3 x', '1 3 4 2 1 x', [{ finger: 1, fret: 3, fromString: 0, toString: 4 }]); + const moved = fingered('3 5 5 x x x', '1 3 4 x x x'); + // Three anchors (fingers 1, 3, 4) and one finger lifted (finger 2); three anchors exceed the cap. + expect(3 * W.anchorDiscount).toBeGreaterThan(W.maxAnchorDiscount); + const t = cost(from, moved); + expect(t).toMatchObject({ anchors: 3, lifted: 1 }); + expect(t.raw).toBeCloseTo(W.lifted * (1 - W.maxAnchorDiscount), 12); + expect(cost(from, to).raw).toBe(0); + }); + + it('ignores open strings and reports no position shift when a shape has no fretted note', () => { + const power = fingered('0 2 2 x x x', 'x 1 2 x x x'); + const open = fingered('0 x x x x x', 'x x x x x x'); + const t = cost(power, open); + expect(t).toMatchObject({ lifted: 2, placed: 0, positionShiftMm: 0, fingerTravelMm: 0 }); + expect(t.raw).toBeCloseTo(2 * W.lifted, 12); + expect(cost(open, open).raw).toBe(0); + }); + + it('does not track the thumb as a finger', () => { + const withThumb = fingered('3 x 5 5 4 x', '0 x 3 4 2 x'); + const without = fingered('x x 5 5 4 x', 'x x 3 4 2 x'); + const t = cost(withThumb, without); + expect(t).toMatchObject({ lifted: 0, placed: 0, anchors: 3 }); + }); + + it('keeps raw non-negative', () => { + const shapes = [ + fingered('0 2 2 1 0 0', 'x 2 3 1 x x'), + fingered('x 3 5 5 x x', 'x 1 3 4 x x'), + fingered('x 0 2 2 1 0', 'x x 2 3 1 x'), + fingered('1 3 3 2 1 1', '1 3 4 2 1 1', [{ finger: 1, fret: 1, fromString: 0, toString: 5 }]), + fingered('0 0 0 x x x', 'x x x x x x') + ]; + for (const a of shapes) for (const b of shapes) expect(cost(a, b).raw).toBeGreaterThanOrEqual(0); + }); + + it('scales by the available time: half the time doubles the cost', () => { + const a = fingered('x 3 5 5 x x', 'x 1 3 4 x x'); + const b = fingered('x 5 7 7 x x', 'x 1 3 4 x x'); + const slow = cost(a, b, 1); + const fast = cost(a, b, 0.5); + expect(fast.raw).toBeCloseTo(slow.raw, 12); + expect(fast.cost).toBeCloseTo(2 * slow.cost, 12); + expect(slow.cost).toBeCloseTo(slow.raw, 12); + expect(fast.timeSec).toBe(0.5); + }); + + it('floors the available time at 0.05 s', () => { + const a = fingered('x 3 5 5 x x', 'x 1 3 4 x x'); + const b = fingered('x 5 7 7 x x', 'x 1 3 4 x x'); + for (const timeSec of [0, 0.01, 0.05]) { + const t = cost(a, b, timeSec); + expect(t.timeSec).toBeCloseTo(0.05, 12); + expect(t.cost).toBeCloseTo(t.raw / 0.05, 9); + } + }); + + it('uses the scale length for distances', () => { + const a = fingered('x 3 x x x x', 'x 1 x x x x'); + const b = fingered('x 8 x x x x', 'x 1 x x x x'); + const short = transitionCost(a, b, { scaleLengthMm: 24.75 * 25.4, timeSec: 1 }); + const long = transitionCost(a, b, { scaleLengthMm: 27 * 25.4, timeSec: 1 }); + expect(long.positionShiftMm).toBeGreaterThan(short.positionShiftMm); + }); + + it('rejects invalid timing and scale length', () => { + const a = fingered('x 3 x x x x', 'x 1 x x x x'); + expect(() => transitionCost(a, a, { scaleLengthMm: L, timeSec: Number.NaN })).toThrow(/timeSec/); + expect(() => transitionCost(a, a, { scaleLengthMm: 0, timeSec: 1 })).toThrow(/scaleLengthMm/); + }); + + it('is deterministic', () => { + const a = fingered('0 2 2 1 0 0', 'x 2 3 1 x x'); + const b = fingered('x 5 7 7 x x', 'x 1 3 4 x x'); + expect(cost(a, b, 0.4)).toEqual(cost(a, b, 0.4)); + }); +}); + +// --------------------------------------------------------------------------- +// minimaxViterbi +// --------------------------------------------------------------------------- + +describe('minimaxViterbi', () => { + it('minimizes the hardest edge where the sum-optimal path differs (counterexample)', () => { + // Layer 1 has two candidates: an easy-then-brutal route (1, 10) and an even route (6, 6). + const sizes = [1, 2, 1]; + const edge = fromMatrices([[[1, 6]], [[10], [6]]]); + const sumOptimal = bruteForceMinSum(sizes, edge); + expect(sumOptimal).toEqual({ path: [0, 0, 0], sumCost: 11 }); + const result = minimaxViterbi(sizes, edge); + expect(result).toEqual({ path: [0, 1, 0], maxCost: 6, sumCost: 12 }); + }); + + it('stays exact where keeping one (max, sum) prefix per node would fail', () => { + // At the third layer the prefix via [0, 0] has max 4 (sum 8), the one via [1, 1] max 5 (sum 6). + // The last edge (6) dominates both maxima, so the lower sum must win: [1, 1, 0, 0]. + const sizes = [2, 2, 1, 1]; + const edge = fromMatrices([ + [ + [4, 100], + [100, 1] + ], + [[4], [5]], + [[6]] + ]); + const result = minimaxViterbi(sizes, edge); + expect(result).toEqual({ path: [1, 1, 0, 0], maxCost: 6, sumCost: 12 }); + }); + + it('breaks equal maxima by the sum, even toward a higher index', () => { + const sizes = [1, 3, 1]; + const edge = fromMatrices([[[5, 2, 5]], [[4], [5], [1]]]); + expect(minimaxViterbi(sizes, edge)).toEqual({ path: [0, 2, 0], maxCost: 5, sumCost: 6 }); + }); + + it('breaks equal maxima and sums by the lower index, with a 1e-9 tolerance', () => { + const sizes = [1, 3, 1]; + expect(minimaxViterbi(sizes, fromMatrices([[[2, 2, 2]], [[2], [2], [2]]])).path).toEqual([0, 0, 0]); + const nearTie = fromMatrices([[[2, 2, 2]], [[2 + 5e-10], [2], [2]]]); + expect(minimaxViterbi(sizes, nearTie).path).toEqual([0, 0, 0]); + const realGap = fromMatrices([[[2, 2, 2]], [[2 + 1e-6], [2], [2]]]); + expect(minimaxViterbi(sizes, realGap).path).toEqual([0, 1, 0]); + }); + + it('lets node costs join only the sum tie-break', () => { + const sizes = [1, 2, 1]; + const nodeCost: NodeFn = (layer, index) => (layer === 1 && index === 0 ? 100 : 0); + const maxWins = minimaxViterbi(sizes, fromMatrices([[[3, 4]], [[3], [1]]]), { nodeCost }); + expect(maxWins).toEqual({ path: [0, 0, 0], maxCost: 3, sumCost: 106 }); + const tie = minimaxViterbi(sizes, fromMatrices([[[3, 3]], [[3], [3]]]), { + nodeCost: (layer, index) => (layer === 1 && index === 0 ? 1 : 0) + }); + expect(tie).toEqual({ path: [0, 1, 0], maxCost: 3, sumCost: 6 }); + }); + + it('includes the closing edge in cyclic mode, which can change the path', () => { + const sizes = [2, 1]; + // a0 -> b costs 1, a1 -> b costs 2; closing b -> a0 costs 10, b -> a1 costs 2. + const edge = fromMatrices([[[1], [2]], [[10, 2]]]); + expect(minimaxViterbi(sizes, edge)).toEqual({ path: [0, 0], maxCost: 1, sumCost: 1 }); + expect(minimaxViterbi(sizes, edge, { cyclic: false })).toEqual({ path: [0, 0], maxCost: 1, sumCost: 1 }); + expect(minimaxViterbi(sizes, edge, { cyclic: true })).toEqual({ path: [1, 0], maxCost: 2, sumCost: 4 }); + }); + + it('uses the self edge for a single cyclic layer', () => { + const edge = fromMatrices([ + [ + [3, 0, 0], + [0, 1, 0], + [0, 0, 2] + ] + ]); + expect(minimaxViterbi([3], edge, { cyclic: true })).toEqual({ path: [1], maxCost: 1, sumCost: 1 }); + }); + + it('handles a single acyclic layer and an empty graph', () => { + const never: EdgeFn = () => { + throw new Error('no edges expected'); + }; + expect(minimaxViterbi([3], never)).toEqual({ path: [0], maxCost: 0, sumCost: 0 }); + expect(minimaxViterbi([3], never, { nodeCost: (_, i) => [2, 0, 1][i] })).toEqual({ path: [1], maxCost: 0, sumCost: 0 }); + expect(minimaxViterbi([], never)).toEqual({ path: [], maxCost: 0, sumCost: 0 }); + expect(minimaxViterbi([], never, { cyclic: true })).toEqual({ path: [], maxCost: 0, sumCost: 0 }); + }); + + it('evaluates every edge exactly once', () => { + const sizes = [3, 2, 4]; + for (const cyclic of [false, true]) { + const seen = new Map(); + const edge: EdgeFn = (layer, from, to) => { + const key = `${layer}:${from}:${to}`; + seen.set(key, (seen.get(key) ?? 0) + 1); + return (layer * 7 + from * 3 + to * 5) % 4; + }; + minimaxViterbi(sizes, edge, { cyclic }); + const expected = 3 * 2 + 2 * 4 + (cyclic ? 4 * 3 : 0); + expect(seen.size).toBe(expected); + expect([...seen.values()].every((count) => count === 1)).toBe(true); + } + }); + + it('rejects empty layers and invalid costs', () => { + expect(() => minimaxViterbi([2, 0, 1], () => 0)).toThrow(/layer 1/); + expect(() => minimaxViterbi([2, 2], () => Number.NaN)).toThrow(/NaN/); + expect(() => minimaxViterbi([2, 2], () => -Infinity)).toThrow(/-Infinity/); + expect(() => minimaxViterbi([2], () => 0, { nodeCost: () => Number.NaN })).toThrow(/finite/); + expect(() => minimaxViterbi([2], () => 0, { nodeCost: () => Infinity })).toThrow(/finite/); + }); + + it('treats +Infinity edges as forbidden unless every path needs one', () => { + const avoidable = fromMatrices([[[Infinity, 3]], [[0], [3]]]); + expect(minimaxViterbi([1, 2, 1], avoidable)).toEqual({ path: [0, 1, 0], maxCost: 3, sumCost: 6 }); + const unavoidable = fromMatrices([[[Infinity, Infinity]], [[1], [0]]]); + expect(minimaxViterbi([1, 2, 1], unavoidable)).toEqual({ path: [0, 0, 0], maxCost: Infinity, sumCost: Infinity }); + expect(minimaxViterbi([2], () => Infinity, { cyclic: true })).toEqual({ path: [0], maxCost: Infinity, sumCost: Infinity }); + }); + + it('matches brute force on seeded random graphs (acyclic and cyclic, with and without node costs)', () => { + for (let trial = 0; trial < 400; trial++) { + const rng = createRng(`minimax-${trial}`); + const n = rng.int(1, 5); + const sizes = Array.from({ length: n }, () => rng.int(1, 3)); + const cyclic = trial % 2 === 0; + // Small integer costs force many exact ties, which exercises the tie-break rules. + const matrices = sizes.map((size, i) => + Array.from({ length: size }, () => Array.from({ length: sizes[(i + 1) % n] }, () => rng.int(0, 4))) + ); + const nodes = sizes.map((size) => Array.from({ length: size }, () => rng.int(0, 2))); + const nodeCost: NodeFn | undefined = trial % 4 < 2 ? (layer, index) => nodes[layer][index] : undefined; + const edge = fromMatrices(matrices); + const expected = bruteForceMinimax(sizes, edge, cyclic, nodeCost); + const actual = minimaxViterbi(sizes, edge, { cyclic, nodeCost }); + expect(actual, `trial ${trial}`).toEqual(expected); + } + }); + + it('matches brute force on seeded random real-valued graphs', () => { + for (let trial = 0; trial < 200; trial++) { + const rng = createRng(`minimax-real-${trial}`); + const n = rng.int(2, 5); + const sizes = Array.from({ length: n }, () => rng.int(1, 4)); + const cyclic = trial % 2 === 1; + const matrices = sizes.map((size, i) => + Array.from({ length: size }, () => Array.from({ length: sizes[(i + 1) % n] }, () => rng.next() * 10)) + ); + const edge = fromMatrices(matrices); + const expected = bruteForceMinimax(sizes, edge, cyclic); + const actual = minimaxViterbi(sizes, edge, { cyclic }); + expect(actual.path, `trial ${trial}`).toEqual(expected.path); + expect(actual.maxCost).toBeCloseTo(expected.maxCost, 9); + expect(actual.sumCost).toBeCloseTo(expected.sumCost, 9); + } + }); + + it('is deterministic', () => { + const edge = fromMatrices([ + [ + [1, 2], + [2, 1] + ], + [ + [2, 1], + [1, 2] + ] + ]); + const first = minimaxViterbi([2, 2], edge, { cyclic: true }); + expect(minimaxViterbi([2, 2], edge, { cyclic: true })).toEqual(first); + expect(first.path).toEqual([0, 0]); + }); +}); + +// --------------------------------------------------------------------------- +// optimizeSequence +// --------------------------------------------------------------------------- + +const POWER = (fret: number) => fingered(`x ${fret} ${fret + 2} ${fret + 2} x x`, 'x 1 3 4 x x'); + +describe('optimizeSequence', () => { + const pool = { + eMajor: fingered('0 2 2 1 0 0', 'x 2 3 1 x x'), + aMinor: fingered('x 0 2 2 1 0', 'x x 2 3 1 x'), + power3: POWER(3), + power5: POWER(5), + power8: POWER(8), + dyad7: fingered('x x 7 9 x x', 'x x 1 3 x x'), + cluster: fingered('x 2 4 5 x x', 'x 1 3 4 x x'), + em9: fingered('0 2 4 0 0 0', 'x 1 3 x x x') + }; + + it('matches a brute-force minimax over transitionCost (cyclic by default)', () => { + const slots = [ + [pool.eMajor, pool.power5, pool.cluster], + [pool.aMinor, pool.power8, pool.dyad7], + [pool.em9, pool.power3, pool.power5] + ]; + const timing = { bpm: 120, beatsPerSlot: [2, 1, 1] }; + for (const cyclic of [true, false]) { + const edge: EdgeFn = (layer, from, to) => + transitionCost(slots[layer][from], slots[(layer + 1) % slots.length][to], { + scaleLengthMm: L, + timeSec: (timing.beatsPerSlot[layer] * 60) / timing.bpm + }).cost; + const expected = bruteForceMinimax( + slots.map((s) => s.length), + edge, + cyclic + ); + const result = cyclic ? optimizeSequence(slots, timing) : optimizeSequence(slots, { ...timing, cyclic: false }); + expect(result.path).toEqual(expected.path); + expect(result.maxCost).toBeCloseTo(expected.maxCost, 12); + expect(result.sumCost).toBeCloseTo(expected.sumCost, 12); + } + }); + + it('picks different paths with and without the loop-back change', () => { + // Acyclic: 5 -> 7 is the shorter first move. Looping, 12 -> 5 is the worst jump, so start at 10. + const slots = [[POWER(5), POWER(10)], [POWER(7)], [POWER(12)]]; + const timing = { bpm: 100, beatsPerSlot: [4, 4, 4] }; + const open = optimizeSequence(slots, { ...timing, cyclic: false }); + const loop = optimizeSequence(slots, timing); + expect(open.path).toEqual([0, 0, 0]); + expect(loop.path).toEqual([1, 0, 0]); + expect(open.transitions).toHaveLength(2); + expect(loop.transitions).toHaveLength(3); + expect(loop.transitions[2]).toMatchObject({ from: 2, to: 0 }); + }); + + it('returns transitions in path order, timed by the slot being left, plus the hardest one', () => { + const slots = [[pool.power3], [pool.power8], [pool.eMajor]]; + const result = optimizeSequence(slots, { bpm: 120, beatsPerSlot: [2, 1, 4] }); + expect(result.path).toEqual([0, 0, 0]); + expect(result.transitions.map((t) => [t.from, t.to])).toEqual([ + [0, 1], + [1, 2], + [2, 0] + ]); + expect(result.transitions.map((t) => t.breakdown.timeSec)).toEqual([1, 0.5, 2]); + for (const t of result.transitions) expect(t.cost).toBe(t.breakdown.cost); + const costs = result.transitions.map((t) => t.cost); + expect(result.maxCost).toBe(Math.max(...costs)); + expect(result.sumCost).toBeCloseTo(costs.reduce((s, c) => s + c, 0), 12); + expect(result.hardest).toBe(result.transitions[costs.indexOf(Math.max(...costs))]); + expect(result.hardest).toMatchObject({ from: 1, to: 2 }); + }); + + it('with one cyclic slot picks the cheapest self-transition (the first candidate)', () => { + const result = optimizeSequence([[pool.power5, pool.eMajor]], { bpm: 120, beatsPerSlot: [4] }); + expect(result.path).toEqual([0]); + expect(result.maxCost).toBe(0); + expect(result.transitions).toHaveLength(1); + expect(result.transitions[0]).toMatchObject({ from: 0, to: 0, cost: 0 }); + }); + + it('with one acyclic slot returns the first candidate and no transitions', () => { + const result = optimizeSequence([[pool.power5, pool.eMajor]], { bpm: 120, beatsPerSlot: [4], cyclic: false }); + expect(result).toEqual({ + path: [0], + maxCost: 0, + sumCost: 0, + transitions: [], + hardest: null, + fingerings: [pool.power5.fingering] + }); + }); + + it('returns an empty result for no slots', () => { + expect(optimizeSequence([], { bpm: 120, beatsPerSlot: [] })).toEqual({ + path: [], + maxCost: 0, + sumCost: 0, + transitions: [], + hardest: null, + fingerings: [] + }); + }); + + it('throws a clear error for a slot without candidates and for bad timing', () => { + expect(() => optimizeSequence([[pool.power3], []], { bpm: 120, beatsPerSlot: [1, 1] })).toThrow(/slot 2/); + expect(() => optimizeSequence([[pool.power3]], { bpm: 0, beatsPerSlot: [1] })).toThrow(/bpm/); + expect(() => optimizeSequence([[pool.power3], [pool.power5]], { bpm: 120, beatsPerSlot: [1] })).toThrow(/beatsPerSlot/); + expect(() => optimizeSequence([[pool.power3]], { bpm: 120, beatsPerSlot: [-1] })).toThrow(/beatsPerSlot/); + }); + + it('honours the scale length option', () => { + const slots = [[pool.power3], [pool.power8]]; + const timing = { bpm: 120, beatsPerSlot: [1, 1], cyclic: false }; + const short = optimizeSequence(slots, timing, { scaleLengthMm: 24.75 * 25.4 }); + const long = optimizeSequence(slots, timing, { scaleLengthMm: 27 * 25.4 }); + expect(long.maxCost).toBeGreaterThan(short.maxCost); + }); + + it('is deterministic', () => { + const slots = [ + [pool.eMajor, pool.power5, pool.cluster, pool.power3], + [pool.aMinor, pool.power8, pool.dyad7], + [pool.em9, pool.power3] + ]; + const timing = { bpm: 140, beatsPerSlot: [1, 2, 1] }; + expect(optimizeSequence(slots, timing)).toEqual(optimizeSequence(slots, timing)); + }); + + it('without a profile keeps every candidate fingering as given and reports it per slot', () => { + const slots = [[pool.eMajor, pool.power5], [pool.aMinor, pool.dyad7]]; + const result = optimizeSequence(slots, { bpm: 120, beatsPerSlot: [1, 1] }); + result.path.forEach((k, i) => expect(result.fingerings[i]).toBe(slots[i][k].fingering)); + }); +}); + +describe('optimizeSequence with a hand profile (fingerings chosen with the neighbours)', () => { + const P = DEFAULT_HAND_PROFILE; + /** What the generator attaches: the statically best fingering for the default profile. */ + const best = (tab: string): FingeredShape => { + const shape = fromRiffForgeTab(tab); + if (shape === null) throw new Error(`bad tab ${tab}`); + const result = findBestFingering(shape, P); + if (!result.ok) throw new Error(`no fingering for ${tab}`); + return { shape, fingering: result.fingering }; + }; + const power = best('x 5 7 7 x x'); + const dyad = best('x x 7 7 x x'); + const dyadUp = best('x x 12 12 x x'); + + it('starts from the fingerings the generator attaches (index+middle dyad, index/ring/pinky power chord)', () => { + expect(power.fingering.fingers).toEqual([null, 1, 3, 4, null, null]); + expect(dyad.fingering.fingers).toEqual([null, null, 1, 2, null, null]); + }); + + it('makes adding the root under a held dyad cheaper than sliding the dyad 5 frets', () => { + const timing = { bpm: 60, beatsPerSlot: [1, 1], cyclic: false }; + const addRoot = optimizeSequence([[dyad], [power]], timing, { profile: P }); + const liftRoot = optimizeSequence([[power], [dyad]], timing, { profile: P }); + const jump = optimizeSequence([[dyad], [dyadUp]], timing, { profile: P }); + expect(addRoot.maxCost).toBeLessThan(jump.maxCost); + expect(liftRoot.maxCost).toBeLessThan(jump.maxCost); + // Two fingers stay down; only the index comes and goes (index-middle may span two frets by default). + expect(addRoot.fingerings[0].fingers).toEqual([null, null, 2, 3, null, null]); + expect(addRoot.fingerings[1].fingers).toEqual([null, 1, 2, 3, null, null]); + expect(addRoot.transitions[0].breakdown).toMatchObject({ anchors: 2, placed: 1, lifted: 0, stringChanges: 0 }); + expect(liftRoot.fingerings[1].fingers).toEqual([null, null, 2, 3, null, null]); + expect(liftRoot.transitions[0].breakdown).toMatchObject({ anchors: 2, placed: 0, lifted: 1, stringChanges: 0 }); + // The jump keeps the index+middle grip and slides it. + expect(jump.fingerings.map((f) => f.fingers)).toEqual([dyad.fingering.fingers, dyadUp.fingering.fingers]); + }); + + it('reports the real move as the hardest change of a looping riff', () => { + const result = optimizeSequence([[power], [dyad, best('x 12 12 x x x')]], { bpm: 120, beatsPerSlot: [1, 1] }, { profile: P }); + expect(result.path).toEqual([0, 0]); + expect(result.fingerings.map((f) => f.fingers)).toEqual([ + [null, 1, 2, 3, null, null], + [null, null, 2, 3, null, null] + ]); + const shift = tip(7) - tip(5); + const addIndex = ((W.placed + W.positionShiftPerMm * shift) * (1 - 2 * W.anchorDiscount)) / 0.5; + expect(result.maxCost).toBeCloseTo(addIndex, 9); + expect(describeTransition(result.hardest!)).toBe( + 'Hardest change: slot 2 -> 1, hand shifts 54 mm, 1 finger placed in 0.5 s, 2 fingers stay down' + ); + }); + + it('keeps the attached fingering (same object) when no alternative makes the changes easier', () => { + // Dyad slides: index+pinky (or middle+pinky) on both dyads moves exactly like index+ring, so nothing is gained. + const slots = [[best('x x 7 9 x x')], [best('x x 9 11 x x')], [best('x x 5 7 x x')]]; + const timing = { bpm: 120, beatsPerSlot: [1, 1, 2] }; + const plain = optimizeSequence(slots, timing); + const result = optimizeSequence(slots, timing, { profile: P }); + result.fingerings.forEach((f, i) => expect(f).toBe(slots[i][0].fingering)); + expect(result).toEqual(plain); + }); + + it('never does worse than the attached fingerings and stays consistent with what it reports', () => { + const slots = [ + [best('0 2 2 1 0 0'), best('x 5 7 7 x x'), best('x x 7 7 x x')], + [best('x 0 2 2 1 0'), best('x x 7 9 x x'), best('x 3 5 x x x')], + [best('x x 5 x x x'), best('x 2 4 5 x x'), best('x x 12 12 x x')] + ]; + for (const cyclic of [true, false]) { + const timing = { bpm: 150, beatsPerSlot: [1, 2, 1], cyclic }; + const plain = optimizeSequence(slots, timing); + const result = optimizeSequence(slots, timing, { profile: P }); + expect(result.maxCost).toBeLessThanOrEqual(plain.maxCost + EPS); + result.path.forEach((k, i) => { + const chosen = result.fingerings[i]; + const own = slots[i][k]; + const valid = enumerateFingerings(own.shape, P).some((f) => JSON.stringify(f.fingers) === JSON.stringify(chosen.fingers)); + expect(chosen === own.fingering || valid).toBe(true); + }); + for (const t of result.transitions) { + const a = { shape: slots[t.from][result.path[t.from]].shape, fingering: result.fingerings[t.from] }; + const b = { shape: slots[t.to][result.path[t.to]].shape, fingering: result.fingerings[t.to] }; + expect(t.breakdown).toEqual(transitionCost(a, b, { scaleLengthMm: P.scaleLengthMm, timeSec: (timing.beatsPerSlot[t.from] * 60) / 150 })); + } + const costs = result.transitions.map((t) => t.cost); + expect(result.maxCost).toBe(Math.max(...costs)); + expect(result.sumCost).toBeCloseTo(costs.reduce((s, c) => s + c, 0), 12); + expect(optimizeSequence(slots, timing, { profile: P })).toEqual(result); + } + }); + + it('takes the scale length from the profile unless one is given', () => { + const slots = [[dyad], [dyadUp]]; + const timing = { bpm: 120, beatsPerSlot: [1, 1], cyclic: false }; + const longNeck = { ...P, scaleLengthMm: 27 * 25.4 }; + const fromProfile = optimizeSequence(slots, timing, { profile: longNeck }); + expect(fromProfile.maxCost).toBeCloseTo(optimizeSequence(slots, timing, { scaleLengthMm: 27 * 25.4 }).maxCost, 12); + expect(optimizeSequence(slots, timing, { profile: longNeck, scaleLengthMm: L }).maxCost).toBeCloseTo( + optimizeSequence(slots, timing).maxCost, + 12 + ); + }); +}); + +// --------------------------------------------------------------------------- +// describeTransition +// --------------------------------------------------------------------------- + +const breakdown = (overrides: Partial): TransitionBreakdown => ({ + fingerTravelMm: 0, + stringChanges: 0, + lifted: 0, + placed: 0, + anchors: 0, + slides: 0, + positionShiftMm: 0, + raw: 0, + timeSec: 0.5, + cost: 0, + ...overrides +}); + +const transition = (from: number, to: number, overrides: Partial): SequenceTransition => ({ + from, + to, + cost: 0, + breakdown: breakdown(overrides) +}); + +describe('describeTransition', () => { + it('names the slots 1-based with the hand shift and the time', () => { + expect(describeTransition(transition(1, 2, { positionShiftMm: 41.3 }))).toBe( + 'Hardest change: slot 2 -> 3, hand shifts 41 mm in 0.5 s' + ); + }); + + it('lists placed and lifted fingers, string changes, slides and held fingers', () => { + const t = transition(0, 1, { + positionShiftMm: 12.6, + placed: 2, + lifted: 1, + stringChanges: 1, + slides: 1, + anchors: 2, + timeSec: 0.428571 + }); + expect(describeTransition(t)).toBe( + 'Hardest change: slot 1 -> 2, hand shifts 13 mm, 2 fingers placed, 1 finger lifted, 1 string change, 1 slide in 0.43 s, 2 fingers stay down' + ); + }); + + it('says when nothing moves', () => { + expect(describeTransition(transition(0, 0, { anchors: 1, timeSec: 2 }))).toBe( + 'Hardest change: slot 1 -> 1, no movement in 2 s, 1 finger stays down' + ); + expect(describeTransition(transition(2, 0, { positionShiftMm: 0.2, timeSec: 1 }))).toBe( + 'Hardest change: slot 3 -> 1, no movement in 1 s' + ); + }); + + it('accepts a custom label', () => { + expect(describeTransition(transition(0, 1, { placed: 1, timeSec: 1 }), 'Change')).toBe( + 'Change: slot 1 -> 2, 1 finger placed in 1 s' + ); + }); + + it('describes a real optimized sequence', () => { + const result = optimizeSequence([[POWER(3)], [POWER(8)]], { bpm: 120, beatsPerSlot: [1, 1], cyclic: false }); + const shift = Math.round(tip(8) - tip(3)); + expect(describeTransition(result.hardest!)).toBe(`Hardest change: slot 1 -> 2, hand shifts ${shift} mm, 3 slides in 0.5 s`); + }); +}); + +describe('describeTransition without a change', () => { + it('handles a single-chord sequence that has no hardest change', () => { + expect(describeTransition(null)).toBe('Hardest change: none (one chord)'); + }); +}); diff --git a/engine/transition.ts b/engine/transition.ts new file mode 100644 index 0000000..954d9bc --- /dev/null +++ b/engine/transition.ts @@ -0,0 +1,490 @@ +// Chord-change cost between fingered shapes, and a minimax Viterbi that picks one voicing per slot so the +// hardest change of the riff is as easy as possible (idea after Hori and Sagayama, ISMIR 2016), with the +// total as tie-break. The change-cost model and its weights are heuristic, not research-backed. +import type { + Fingering, + FingeredShape, + FingerNumber, + HandProfile, + SequenceResult, + SequenceTiming, + SequenceTransition, + TransitionBreakdown +} from './types'; +import { DEFAULT_SCALE_LENGTH_MM, fingertipMm } from './geometry'; +import { enumerateFingerings } from './fingering'; +import { lowestFret } from './shape'; + +export interface TransitionWeights { + fingerTravelPerMm: number; + slideTravelFactor: number; + stringChange: number; + lifted: number; + placed: number; + positionShiftPerMm: number; + anchorDiscount: number; + maxAnchorDiscount: number; + minTimeSec: number; +} + +/** + * raw = (fingerTravelPerMm * (moveTravel + slideTravelFactor * slideTravel) + * + stringChange * stringChanges + lifted * lifted + placed * placed + * + positionShiftPerMm * positionShiftMm) * (1 - min(maxAnchorDiscount, anchorDiscount * anchors)) + * cost = raw / max(minTimeSec, timeSec) + * Every term is non-negative and the anchor factor stays positive, so raw >= 0 and identical shapes cost 0. + */ +export const TRANSITION_WEIGHTS: Readonly = { + // Per mm of along-neck fingertip travel, summed over fingers used in both shapes (200 mm total = 1). + // Lower than the hand shift because the fingers mostly ride along with the hand. + fingerTravelPerMm: 0.005, + // Travel of a finger that stays on its string (a guide-finger slide) counts at this fraction: it keeps + // contact and leads the hand, so a short slide stays cheaper than a lift plus a place. + slideTravelFactor: 0.5, + // Per string a kept finger moves across; it has to come off the string and land again. + stringChange: 0.25, + // Per finger released; letting go is quick. + lifted: 0.15, + // Per finger newly placed; aiming a fresh finger is the slow part of most chord changes. + placed: 0.5, + // Per mm the hand position (lowest fretted fingertip) moves (100 mm = 1). The main cost of position jumps. + positionShiftPerMm: 0.01, + // Each anchor (same finger, string and fret held through the change) steadies the hand and cuts the + // whole change cost by this fraction ... + anchorDiscount: 0.2, + // ... up to this total, so a change never becomes free while something still moves. + maxAnchorDiscount: 0.5, + // Available time is floored here (about a 16th note at 300 bpm) so near-zero gaps do not explode the cost. + minTimeSec: 0.05 +}; + +/** The thumb wraps the neck and follows the hand; its movement is covered by positionShiftMm. */ +const TRACKED_FINGERS: readonly FingerNumber[] = [1, 2, 3, 4]; + +interface Placement { + string: number; + fret: number; +} + +/** Where each finger 1..4 presses; a multi-string finger (barre) is placed at its lowest string. */ +const placements = ({ shape, fingering }: FingeredShape): (Placement | null)[] => { + const out: (Placement | null)[] = [null, null, null, null, null]; + const count = Math.min(shape.length, fingering.fingers.length); + for (let s = 0; s < count; s++) { + const finger = fingering.fingers[s]; + const fret = shape[s]; + if (finger === null || finger === 0 || fret === null || fret <= 0) continue; + if (out[finger] === null) out[finger] = { string: s, fret }; + } + return out; +}; + +export const transitionCost = ( + a: FingeredShape, + b: FingeredShape, + opts: { scaleLengthMm: number; timeSec: number } +): TransitionBreakdown => { + const { scaleLengthMm, timeSec } = opts; + if (!(scaleLengthMm > 0) || !Number.isFinite(scaleLengthMm)) { + throw new Error(`transitionCost: scaleLengthMm must be a positive number, got ${scaleLengthMm}`); + } + if (Number.isNaN(timeSec)) throw new Error('transitionCost: timeSec must be a number, got NaN'); + + const w = TRANSITION_WEIGHTS; + const tip = (fret: number) => fingertipMm(fret, scaleLengthMm); + const from = placements(a); + const to = placements(b); + + let moveTravelMm = 0; + let slideTravelMm = 0; + let stringChanges = 0; + let lifted = 0; + let placed = 0; + let anchors = 0; + let slides = 0; + for (const finger of TRACKED_FINGERS) { + const p = from[finger]; + const q = to[finger]; + if (p === null && q === null) continue; + if (q === null) { + lifted++; + continue; + } + if (p === null) { + placed++; + continue; + } + const travel = Math.abs(tip(q.fret) - tip(p.fret)); + stringChanges += Math.abs(q.string - p.string); + if (p.string !== q.string) { + moveTravelMm += travel; + } else if (p.fret === q.fret) { + anchors++; + } else { + slides++; + slideTravelMm += travel; + } + } + + const lowA = lowestFret(a.shape); + const lowB = lowestFret(b.shape); + const positionShiftMm = lowA === null || lowB === null ? 0 : Math.abs(tip(lowB) - tip(lowA)); + + const base = + w.fingerTravelPerMm * (moveTravelMm + w.slideTravelFactor * slideTravelMm) + + w.stringChange * stringChanges + + w.lifted * lifted + + w.placed * placed + + w.positionShiftPerMm * positionShiftMm; + const raw = base * (1 - Math.min(w.maxAnchorDiscount, w.anchorDiscount * anchors)); + // Reported time is the effective (floored) time, so cost === raw / timeSec always holds. + const effectiveTimeSec = Math.max(w.minTimeSec, timeSec); + + return { + fingerTravelMm: moveTravelMm + slideTravelMm, + stringChanges, + lifted, + placed, + anchors, + slides, + positionShiftMm, + raw, + timeSec: effectiveTimeSec, + cost: raw / effectiveTimeSec + }; +}; + +// --------------------------------------------------------------------------- +// Minimax Viterbi +// --------------------------------------------------------------------------- + +const EPS = 1e-9; + +/** Index of the first value within EPS of the minimum (ties -> lower index); -1 when all are +Infinity. */ +const argminFirst = (values: readonly number[]): number => { + let min = Infinity; + for (const v of values) if (v < min) min = v; + if (min === Infinity) return -1; + for (let i = 0; i < values.length; i++) if (values[i] <= min + EPS) return i; + return -1; +}; + +/** + * Generic minimax Viterbi: minimize the max edge cost along the path, tie-break by the sum (epsilon 1e-9). + * sizes[i] = number of candidates in layer i. edgeCost(layer, from, to) is the edge from candidate `from` of + * layer `layer` to candidate `to` of layer (layer + 1) % n; it is called exactly once per edge, so it may be + * expensive. nodeCost (optional) only joins the SUM tie-break, and sumCost includes it. + * cyclic: also include the edge from the last layer back to the first. A single cyclic layer uses its self edge. + * Deterministic: remaining ties -> the lexicographically smallest path (lower index first). + * maxCost is 0 for a path without edges. Edge costs may be +Infinity (forbidden unless unavoidable); + * NaN or -Infinity edges and non-finite node costs throw. + * + * Exact in two phases, because (max, sum) is not decomposable per node (a prefix with a smaller max can + * lose once a later edge dominates both maxima): + * 1. bottleneck DP: M* = the smallest achievable max edge; + * 2. min-sum DP restricted to edges <= M* + epsilon; any such complete path has max == M*. + * Cyclic mode fixes the first-layer candidate s, closes the loop back to s, and keeps the best s. + */ +export const minimaxViterbi = ( + sizes: readonly number[], + edgeCost: (layer: number, from: number, to: number) => number, + opts: { cyclic?: boolean; nodeCost?: (layer: number, index: number) => number } = {} +): { path: number[]; maxCost: number; sumCost: number } => { + const n = sizes.length; + const cyclic = opts.cyclic ?? false; + if (n === 0) return { path: [], maxCost: 0, sumCost: 0 }; + sizes.forEach((size, i) => { + if (!Number.isInteger(size) || size < 1) { + throw new Error(`minimaxViterbi: layer ${i} needs at least one candidate, got size ${size}`); + } + }); + + // edges[i][from][to]: layer i -> layer (i + 1) % n. The closing edge (i = n - 1) exists only when cyclic. + const edgeLayers = cyclic ? n : n - 1; + const edges: number[][][] = []; + for (let i = 0; i < edgeLayers; i++) { + const next = (i + 1) % n; + const matrix: number[][] = []; + for (let from = 0; from < sizes[i]; from++) { + const row: number[] = []; + for (let to = 0; to < sizes[next]; to++) { + const c = edgeCost(i, from, to); + if (Number.isNaN(c) || c === -Infinity) { + throw new Error(`minimaxViterbi: edge cost must be a number or +Infinity, got ${c} (layer ${i}, ${from} -> ${to})`); + } + row.push(c); + } + matrix.push(row); + } + edges.push(matrix); + } + + const nodes: number[][] = sizes.map((size, i) => + Array.from({ length: size }, (_, k) => { + const c = opts.nodeCost ? opts.nodeCost(i, k) : 0; + if (!Number.isFinite(c)) throw new Error(`minimaxViterbi: node cost must be finite, got ${c} (layer ${i}, candidate ${k})`); + return c; + }) + ); + + /** + * Backward DP over layers n-1 .. 0 (cost-to-go per candidate). `terminal(v)` seeds the last layer, + * `combine` merges an edge cost with the cost-to-go of its head (Infinity when the edge is not allowed), + * and `withNode` adds the node term. + */ + const backward = ( + terminal: (v: number) => number, + combine: (edge: number, next: number) => number, + withNode: (layer: number, v: number, value: number) => number + ): number[][] => { + const go: number[][] = new Array(n); + go[n - 1] = Array.from({ length: sizes[n - 1] }, (_, v) => withNode(n - 1, v, terminal(v))); + for (let i = n - 2; i >= 0; i--) { + go[i] = Array.from({ length: sizes[i] }, (_, v) => { + let best = Infinity; + for (let w = 0; w < sizes[i + 1]; w++) { + const value = combine(edges[i][v][w], go[i + 1][w]); + if (value < best) best = value; + } + return withNode(i, v, best); + }); + } + return go; + }; + + // Phase 1: the optimal bottleneck M*. No edges at all (single acyclic layer) => no restriction. + const bottleneckFrom = (start: number | null): number[][] => + backward( + (v) => (start === null ? -Infinity : edges[n - 1][v][start]), + (edge, next) => Math.max(edge, next), + (_layer, _v, value) => value + ); + const starts = cyclic ? Array.from({ length: sizes[0] }, (_, s) => s) : [null]; + let threshold = Infinity; + if (edgeLayers > 0) { + for (const start of starts) { + const go = bottleneckFrom(start); + const value = start === null ? Math.min(...go[0]) : go[0][start]; + if (value < threshold) threshold = value; + } + threshold += EPS; + } + + // Phase 2: min-sum over the edges allowed by the bottleneck. + const allowed = (edge: number) => edge <= threshold; + const sumFrom = (start: number | null): number[][] => + backward( + (v) => { + if (start === null) return 0; + const closing = edges[n - 1][v][start]; + return allowed(closing) ? closing : Infinity; + }, + (edge, next) => (allowed(edge) ? edge + next : Infinity), + (layer, v, value) => value + nodes[layer][v] + ); + + let bestStart = -1; + let bestGo: number[][] | null = null; + if (cyclic) { + const totals: number[] = []; + const gos: number[][][] = []; + for (let s = 0; s < sizes[0]; s++) { + const go = sumFrom(s); + gos.push(go); + totals.push(go[0][s]); + } + bestStart = argminFirst(totals); + if (bestStart >= 0) bestGo = gos[bestStart]; + } else { + bestGo = sumFrom(null); + bestStart = argminFirst(bestGo[0]); + } + if (bestStart < 0) { + // Every path crosses a +Infinity edge (M* = Infinity), so all tie on max and sum: lexicographically first. + bestStart = 0; + bestGo = null; + } + + const path: number[] = [bestStart]; + for (let i = 0; i + 1 < n; i++) { + const v = path[i]; + if (bestGo === null) { + path.push(0); + continue; + } + const go = bestGo; + const options = Array.from({ length: sizes[i + 1] }, (_, w) => + allowed(edges[i][v][w]) ? edges[i][v][w] + go[i + 1][w] : Infinity + ); + const next = argminFirst(options); + path.push(next < 0 ? 0 : next); + } + + let maxCost = -Infinity; + let sumCost = 0; + for (let i = 0; i < edgeLayers; i++) { + const c = edges[i][path[i]][path[(i + 1) % n]]; + if (c > maxCost) maxCost = c; + sumCost += c; + } + for (let i = 0; i < n; i++) sumCost += nodes[i][path[i]]; + return { path, maxCost: edgeLayers === 0 ? 0 : maxCost, sumCost }; +}; + +// --------------------------------------------------------------------------- +// Sequence optimization +// --------------------------------------------------------------------------- + +export interface OptimizedSequence extends SequenceResult { + /** Fingering used in each slot (the transitions are costed with it): the candidate's own unless a profile + * was given and another valid fingering of the same shape makes the changes easier. */ + fingerings: Fingering[]; +} + +/** Which fingers a fingering uses, e.g. "3,4" for ring + pinky. */ +const fingerSetKey = (f: Fingering): string => + [...new Set(f.fingers.filter((x): x is FingerNumber => x !== null))].sort((a, b) => a - b).join(','); + +/** + * Fingerings the optimizer may use for one candidate: its own first, then the cheapest valid fingering (under + * the profile) for every other set of fingers. Which fingers are busy decides what can stay down through a + * change (a dyad held with ring + pinky leaves the index free for the root below), while re-ordering the same + * fingers only adds static cost. So at most 1 + C(5, k) options, e.g. 7 for a dyad. + */ +const fingeringOptions = (candidate: FingeredShape, profile: HandProfile | undefined): Fingering[] => { + const own = candidate.fingering; + if (profile === undefined) return [own]; + const options = [own]; + const seen = new Set([fingerSetKey(own)]); + for (const alt of enumerateFingerings(candidate.shape, profile)) { + const key = fingerSetKey(alt); + if (seen.has(key)) continue; + seen.add(key); + options.push(alt); + } + return options; +}; + +interface SequenceState { + candidate: number; + shape: FingeredShape; + /** Static physical cost above the candidate's own fingering; only joins the sum tie-break. */ + extra: number; +} + +/** + * Picks one candidate per slot minimizing the hardest change, then the total. The change leaving slot i + * gets timing.beatsPerSlot[i] * 60 / bpm seconds. Cyclic by default (riffs loop): the last slot changes + * back to the first. With one cyclic slot the self-transition is used (identical shapes cost 0). + * + * Without `opts.profile` each candidate keeps the fingering it carries. With a profile the fingering is + * chosen together with the neighbours: every candidate may also use the cheapest valid fingering for each + * other set of fingers (see fingeringOptions), so a dyad next to its power chord is held with ring + pinky + * instead of re-gripped. The hardest change still decides first; between equal changes the statically easier + * fingering wins (the candidate's own on a tie). `fingerings` reports the choice; `sumCost` is the sum of the + * transition costs only. scaleLengthMm defaults to the profile's, then to DEFAULT_SCALE_LENGTH_MM. + */ +export const optimizeSequence = ( + candidatesPerSlot: readonly (readonly FingeredShape[])[], + timing: SequenceTiming, + opts: { scaleLengthMm?: number; profile?: HandProfile } = {} +): OptimizedSequence => { + const n = candidatesPerSlot.length; + candidatesPerSlot.forEach((candidates, i) => { + if (candidates.length === 0) throw new Error(`optimizeSequence: slot ${i + 1} has no candidate voicings`); + }); + if (n === 0) return { path: [], maxCost: 0, sumCost: 0, transitions: [], hardest: null, fingerings: [] }; + + const { bpm, beatsPerSlot } = timing; + if (!Number.isFinite(bpm) || bpm <= 0) throw new Error(`optimizeSequence: bpm must be a positive number, got ${bpm}`); + if (beatsPerSlot.length !== n) { + throw new Error(`optimizeSequence: beatsPerSlot has ${beatsPerSlot.length} entries for ${n} slots`); + } + beatsPerSlot.forEach((beats, i) => { + if (!Number.isFinite(beats) || beats < 0) { + throw new Error(`optimizeSequence: beatsPerSlot[${i}] must be a non-negative number, got ${beats}`); + } + }); + + const cyclic = timing.cyclic ?? true; + const { profile } = opts; + const scaleLengthMm = opts.scaleLengthMm ?? profile?.scaleLengthMm ?? DEFAULT_SCALE_LENGTH_MM; + + // One state per (candidate, fingering option), candidates in order and each candidate's own fingering + // first, so lexicographic ties still prefer the lower candidate index and the attached fingering. + const states: SequenceState[][] = candidatesPerSlot.map((candidates) => + candidates.flatMap((candidate, c) => { + const options = fingeringOptions(candidate, profile); + const ownCost = candidate.fingering.costBreakdown.physical; + return options.map((fingering, k) => { + const extra = fingering.costBreakdown.physical - ownCost; + return { + candidate: c, + shape: k === 0 ? candidate : { shape: candidate.shape, fingering }, + extra: k > 0 && Number.isFinite(extra) ? Math.max(0, extra) : 0 + }; + }); + }) + ); + + const change = (slot: number, from: SequenceState, to: SequenceState): TransitionBreakdown => + transitionCost(from.shape, to.shape, { scaleLengthMm, timeSec: (beatsPerSlot[slot] * 60) / bpm }); + + const { path: statePath } = minimaxViterbi( + states.map((layer) => layer.length), + (slot, from, to) => change(slot, states[slot][from], states[(slot + 1) % n][to]).cost, + { cyclic, nodeCost: (slot, index) => states[slot][index].extra } + ); + const chosen = statePath.map((index, slot) => states[slot][index]); + + const transitions: SequenceTransition[] = []; + const count = cyclic ? n : n - 1; + for (let i = 0; i < count; i++) { + const j = (i + 1) % n; + const breakdown = change(i, chosen[i], chosen[j]); + transitions.push({ from: i, to: j, cost: breakdown.cost, breakdown }); + } + let hardest: SequenceTransition | null = null; + let sumCost = 0; + for (const t of transitions) { + if (hardest === null || t.cost > hardest.cost) hardest = t; + sumCost += t.cost; + } + + return { + path: chosen.map((state) => state.candidate), + maxCost: hardest === null ? 0 : hardest.cost, + sumCost, + transitions, + hardest, + fingerings: chosen.map((state) => state.shape.fingering) + }; +}; + +// --------------------------------------------------------------------------- +// UI text +// --------------------------------------------------------------------------- + +const count = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`; + +const formatSeconds = (sec: number): string => String(Number(sec.toFixed(2))); + +/** + * One-line UI text, e.g. "Hardest change: slot 2 -> 3, hand shifts 41 mm in 0.5 s". Slots 1-based in text. + * `label` defaults to "Hardest change" (the usual caller passes SequenceResult.hardest); pass another label + * when describing an arbitrary transition. + */ +export const describeTransition = (t: SequenceTransition | null, label = 'Hardest change'): string => { + if (t === null) return `${label}: none (one chord)`; + const b = t.breakdown; + const moves: string[] = []; + const shift = Math.round(b.positionShiftMm); + if (shift > 0) moves.push(`hand shifts ${shift} mm`); + if (b.placed > 0) moves.push(`${count(b.placed, 'finger')} placed`); + if (b.lifted > 0) moves.push(`${count(b.lifted, 'finger')} lifted`); + if (b.stringChanges > 0) moves.push(count(b.stringChanges, 'string change')); + if (b.slides > 0) moves.push(count(b.slides, 'slide')); + const what = moves.length > 0 ? moves.join(', ') : 'no movement'; + const held = b.anchors > 0 ? `, ${count(b.anchors, 'finger')} stay${b.anchors === 1 ? 's' : ''} down` : ''; + return `${label}: slot ${t.from + 1} -> ${t.to + 1}, ${what} in ${formatSeconds(b.timeSec)} s${held}`; +}; diff --git a/engine/tuning.test.ts b/engine/tuning.test.ts new file mode 100644 index 0000000..0ba826c --- /dev/null +++ b/engine/tuning.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { E_STANDARD, DROP_D, D_STANDARD, DROP_C, C_STANDARD, TUNING_PRESETS, getTuning, validateTuning } from './tuning'; +import { midiToName } from './pitch'; + +describe('tuning presets', () => { + it('defines the documented open strings low -> high', () => { + expect(E_STANDARD.openMidi).toEqual([40, 45, 50, 55, 59, 64]); + expect(DROP_D.openMidi).toEqual([38, 45, 50, 55, 59, 64]); + expect(D_STANDARD.openMidi.map(midiToName)).toEqual(['D2', 'G2', 'C3', 'F3', 'A3', 'D4']); + expect(DROP_C.openMidi.map(midiToName)).toEqual(['C2', 'G2', 'C3', 'F3', 'A3', 'D4']); + expect(C_STANDARD.openMidi.map(midiToName)).toEqual(['C2', 'F2', 'A#2', 'D#3', 'G3', 'C4']); + }); + + it('validates every preset and resolves ids', () => { + for (const t of TUNING_PRESETS) { + expect(validateTuning(t)).toEqual({ ok: true }); + expect(getTuning(t.id)).toBe(t); + } + expect(getTuning('nope')).toBeUndefined(); + }); +}); + +describe('validateTuning', () => { + it('rejects non-ascending, out-of-range and wrong-length tunings', () => { + expect(validateTuning({ id: 'a', name: 'a', openMidi: [40, 40, 50, 55, 59, 64] }).ok).toBe(false); + expect(validateTuning({ id: 'b', name: 'b', openMidi: [40, 45, 50, 55, 59, 128] }).ok).toBe(false); + expect(validateTuning({ id: 'c', name: 'c', openMidi: [40, 45, 50] }).ok).toBe(false); + expect(validateTuning({ id: 'd', name: 'd', openMidi: [40.5, 45, 50, 55, 59, 64] }).ok).toBe(false); + }); + + it('names the problem in the rejection, readable through the ok === false narrowing', () => { + const short = validateTuning({ id: 'c', name: 'c', openMidi: [40, 45, 50] }); + const flat = validateTuning({ id: 'a', name: 'a', openMidi: [40, 40, 50, 55, 59, 64] }); + expect(short.ok === false && short.reason).toBe('expected 4..8 strings, got 3'); + expect(flat.ok === false && flat.reason).toBe('string 1 must be higher than string 0'); + }); + + it('accepts other string counts within 4..8', () => { + expect(validateTuning({ id: 'bass', name: 'Bass', openMidi: [28, 33, 38, 43] }).ok).toBe(true); + expect(validateTuning({ id: '7', name: '7-string', openMidi: [35, 40, 45, 50, 55, 59, 64] }).ok).toBe(true); + }); +}); diff --git a/engine/tuning.ts b/engine/tuning.ts new file mode 100644 index 0000000..361f2f4 --- /dev/null +++ b/engine/tuning.ts @@ -0,0 +1,36 @@ +import type { Tuning } from './types'; + +export const E_STANDARD: Tuning = { id: 'e-standard', name: 'E Standard', openMidi: [40, 45, 50, 55, 59, 64] }; +export const DROP_D: Tuning = { id: 'drop-d', name: 'Drop D', openMidi: [38, 45, 50, 55, 59, 64] }; +export const D_STANDARD: Tuning = { id: 'd-standard', name: 'D Standard', openMidi: [38, 43, 48, 53, 57, 62] }; +export const DROP_C: Tuning = { id: 'drop-c', name: 'Drop C', openMidi: [36, 43, 48, 53, 57, 62] }; +export const C_STANDARD: Tuning = { id: 'c-standard', name: 'C Standard', openMidi: [36, 41, 46, 51, 55, 60] }; + +export const TUNING_PRESETS: readonly Tuning[] = [E_STANDARD, DROP_D, D_STANDARD, DROP_C, C_STANDARD]; + +export const MIN_STRINGS = 4; +export const MAX_STRINGS = 8; + +export const getTuning = (id: string): Tuning | undefined => TUNING_PRESETS.find((t) => t.id === id); + +/** Narrow with `v.ok === false`: without `strictNullChecks` (the apps' setting) `!v.ok` does not narrow. */ +export type TuningValidation = { ok: true } | { ok: false; reason: string }; + +/** Strings must be integer MIDI notes in 0..127, strictly ascending from string 0 (lowest). */ +export const validateTuning = (tuning: Tuning): TuningValidation => { + const { openMidi } = tuning; + if (!Array.isArray(openMidi)) return { ok: false, reason: 'openMidi must be an array' }; + if (openMidi.length < MIN_STRINGS || openMidi.length > MAX_STRINGS) { + return { ok: false, reason: `expected ${MIN_STRINGS}..${MAX_STRINGS} strings, got ${openMidi.length}` }; + } + for (let s = 0; s < openMidi.length; s++) { + const m = openMidi[s]; + if (!Number.isInteger(m) || m < 0 || m > 127) return { ok: false, reason: `string ${s} is not a MIDI note` }; + if (s > 0 && m <= openMidi[s - 1]) { + return { ok: false, reason: `string ${s} must be higher than string ${s - 1}` }; + } + } + return { ok: true }; +}; + +export const stringCount = (tuning: Tuning): number => tuning.openMidi.length; diff --git a/engine/types.ts b/engine/types.ts new file mode 100644 index 0000000..5a78ff1 --- /dev/null +++ b/engine/types.ts @@ -0,0 +1,504 @@ +// Shared types for the playability engine. Pure data, no runtime code. +// Canonical string order everywhere in the engine: index 0 = lowest-pitched string. + +/** Fret per string, low -> high. `null` = muted, `0` = open. */ +export type Shape = readonly (number | null)[]; + +export interface Tuning { + readonly id: string; + readonly name: string; + /** Open-string MIDI notes, index 0 = lowest-pitched string. */ + readonly openMidi: readonly number[]; +} + +/** 0 = thumb (only when the profile allows it, lowest string only), 1 = index ... 4 = pinky. */ +export type FingerNumber = 0 | 1 | 2 | 3 | 4; + +/** One finger pressing two or more strings at the same fret. String range is inclusive, low -> high. */ +export interface Barre { + finger: FingerNumber; + fret: number; + fromString: number; + toString: number; +} + +// --------------------------------------------------------------------------- +// Hand profile +// --------------------------------------------------------------------------- + +/** Adjacent finger-pair limits as fractions of the allowed total reach. Heuristic, not research-backed. */ +export interface PairLimits { + indexMiddle: number; + middleRing: number; + ringPinky: number; +} + +export interface HandProfile { + version: 1; + scaleLengthMm: number; + /** Comfortable index -> pinky fingertip distance with the index at `lowRefFret`. */ + reachAtLowMm: number; + /** Comfortable index -> pinky fingertip distance with the index at `highRefFret`. */ + reachAtHighMm: number; + lowRefFret: number; + highRefFret: number; + pairLimits: PairLimits; + /** Multiplier on the allowed reach. 1.0 default, 1.1 when "allow stretches" is on. */ + stretchTolerance: number; + noBarre: boolean; + allowTwoStringPartialBarre: boolean; + allowThumb: boolean; + maxFret: number; + allowOpenStrings: boolean; + maxInteriorMutes: number; + comfortableSixteenthBpm: number; +} + +/** Answers from the in-app calibration flow. */ +export interface CalibrationAnswers { + scaleLengthMm: number; + /** Index on fret 1: highest fret the pinky reaches comfortably on the same string (3..7). */ + lowPinkyFret: number; + /** Index on fret 7: highest fret the pinky reaches comfortably on the same string (9..13). */ + highPinkyFret: number; + allowStretches: boolean; + noBarre: boolean; + allowOpenStrings: boolean; + maxFret: number; + comfortableSixteenthBpm: number; +} + +// --------------------------------------------------------------------------- +// Fingering and cost +// --------------------------------------------------------------------------- + +export type CostBucket = 'physical' | 'rightHand' | 'musical'; + +export type CostTermKey = + // physical + | 'reach' + | 'pairStretch' + | 'fingerCount' + | 'pinky' + | 'contortion' + | 'interiorMutes' + | 'position' + | 'thumb' + | 'barre' + // right hand + | 'stringSkips' + | 'chugStrings' + // musical + | 'lowMud' + | 'doubledThird' + | 'rootNotInBass'; + +export interface CostTerm { + key: CostTermKey; + bucket: CostBucket; + /** Measured quantity in natural units (ratio, count, fret, ...). */ + raw: number; + /** raw mapped to 0..1. */ + normalized: number; + weight: number; + /** normalized * weight */ + cost: number; +} + +export interface CostBreakdown { + total: number; + physical: number; + rightHand: number; + musical: number; + terms: CostTerm[]; +} + +export interface PairStretch { + from: FingerNumber; + to: FingerNumber; + distanceMm: number; + limitMm: number; + /** distanceMm / limitMm; must stay <= 1 (hard constraint). */ + ratio: number; + /** Relaxed spacing: one fret per finger-number step, measured from `from`'s fret. */ + naturalMm: number; + /** 0..1 stretch beyond the relaxed spacing toward the limit (cost input). Cramped pairs are 0. */ + stretch: number; +} + +export interface FingeringMetrics { + /** Number of fretted (non-open, non-muted) strings. */ + frettedCount: number; + /** Distinct fingers used, thumb included. */ + fingerCount: number; + /** Fingertip distance from the lowest-fret to the highest-fret finger (thumb excluded). */ + spanMm: number; + /** maxReachMm(lowestFret) * stretchTolerance */ + allowedSpanMm: number; + /** spanMm / allowedSpanMm; 0 when fewer than two distinct fretted frets. */ + reachRatio: number; + /** Consecutive used fingers (by finger number, thumb excluded) with their distance and limit. */ + pairs: PairStretch[]; + /** Worst pair ratio, 0 when fewer than two fingers. */ + maxPairRatio: number; + /** Worst pair stretch beyond relaxed spacing, 0..1. */ + maxPairStretch: number; + usesPinky: boolean; + usesThumb: boolean; + /** Same-fret pairs where the lower-numbered finger sits on the higher-pitched string. */ + contortions: number; + /** Muted strings strictly between the lowest and highest sounding strings. */ + interiorMutes: number; + lowestFret: number | null; + highestFret: number | null; +} + +export interface Fingering { + /** Finger per string, low -> high. `null` for open or muted strings. */ + fingers: (FingerNumber | null)[]; + barres: Barre[]; + metrics: FingeringMetrics; + /** Physical bucket only; playability adds right-hand and musical terms. */ + costBreakdown: CostBreakdown; +} + +export type FingeringRejectReason = + /** More fretted notes than available fingers without a barre, or no legal barre in barre mode. */ + | 'NEEDS_BARRE' + /** Total span exceeds maxReachMm(lowestFret) * stretchTolerance. */ + | 'REACH' + /** Every finger assignment breaks an adjacent-pair limit. */ + | 'PAIR_STRETCH' + /** Every finger assignment needs crossed fingers. */ + | 'FINGER_CROSSING' + /** A fret is negative, non-integer, or above profile.maxFret. */ + | 'FRET_RANGE' + /** The shape uses open strings but the profile disallows them. */ + | 'OPEN_STRINGS_DISALLOWED' + /** Wrong string count or no sounding string. */ + | 'INVALID_SHAPE'; + +/** + * Narrow with `result.ok === false` (or `=== true`). The consuming apps compile without `strictNullChecks`, and + * there `!result.ok` and the else branch of `if (result.ok)` do not narrow, so `result.reason` fails to compile. + */ +export type FingeringResult = + | { ok: true; fingering: Fingering } + | { ok: false; reason: FingeringRejectReason; detail: string }; + +// --------------------------------------------------------------------------- +// Voicing families and generation +// --------------------------------------------------------------------------- + +export type FamilyGroup = + | 'power' + | 'susAdd' + | 'shells' + | 'quartalCluster' + | 'darkColors' + | 'triads' + | 'drones' + | 'legacy'; + +export type FamilyTag = 'metal-friendly' | 'color' | 'pedal-compatible' | 'dissonant' | 'triad'; + +/** Interval recipe relative to the chosen root. Intervals are pitch classes 0..11 above the root. */ +export interface VoicingFamily { + id: string; + /** Short UI label, e.g. "5", "m(add9)". */ + label: string; + description: string; + group: FamilyGroup; + required: readonly number[]; + optional: readonly number[]; + forbidden: readonly number[]; + /** Allowed intervals of the lowest sounding note (hard constraint), or 'any'. */ + bass: readonly number[] | 'any'; + /** Soft preference for the bass interval (musical cost when violated). */ + preferredBass?: number; + minNotes: number; + maxNotes: number; + tags: readonly FamilyTag[]; + /** Requires at least one open string sounding the root or the fifth. */ + drone?: boolean; +} + +export type VoicingTag = + | 'pedalCompatible' + | 'drone' + | 'usesOpenStrings' + | 'rootInBass' + | 'lowStrings' + | 'middleStrings' + | 'topStrings'; + +export type StringGroup = 'low3' | 'middle' | 'top3'; + +export interface GeneratedVoicing { + shape: Shape; + fingering: Fingering; + /** Sounding MIDI notes, low -> high string order (muted strings skipped). */ + midi: number[]; + /** Degree labels of sounding notes, low -> high pitch, space-separated, e.g. "1 5 b2 1". */ + degrees: string; + /** Degree label per string, low -> high; null for muted strings. */ + degreesByString: (string | null)[]; + /** Honest chord symbol, or null when the notes do not support a confident symbol. */ + symbol: string | null; + /** Display name: symbol when confident, otherwise an interval description. */ + name: string; + familyId: string; + cost: number; + breakdown: CostBreakdown; + /** 0..1 distance from common open/CAGED shapes and reference shapes. Not part of difficulty. */ + novelty: number; + tags: VoicingTag[]; +} + +export type BindingConstraint = + | 'PITCH_CLASSES' + | 'NOTE_COUNT' + | 'INTERIOR_MUTES' + | 'BASS' + | 'NEEDS_BARRE' + | 'REACH' + | 'PAIR_STRETCH' + | 'FINGER_CROSSING' + | 'MAX_FRET' + | 'OPEN_STRINGS' + | 'NO_DRONE_STRING'; + +export type VoicingSort = 'easiest' | 'unusual'; + +export interface GenerateParams { + /** Pitch class 0..11 or a note name ("E", "C#", "Db"). */ + root: number | string; + /** Family object or a registered family id. */ + family: VoicingFamily | string; + tuning: Tuning; + profile: HandProfile; + /** High-gain context: enables the low-register mud term. Default true. */ + distortion?: boolean; + /** 'chug' favours fewer, lower strings for palm-muted use. Default 'strum'. */ + context?: 'strum' | 'chug'; + /** Include the musical cost bucket. Default true. */ + musical?: boolean; + /** Maximum results. Default 12. */ + limit?: number; + sort?: VoicingSort; + /** Optional seed: only perturbs ordering among near-equal results, deterministically. */ + seed?: string; + /** Extra shapes counted as "known" for novelty (e.g. a curated library). */ + referenceShapes?: readonly Shape[]; + /** Spread results across positions and string sets. Default true. */ + diversify?: boolean; +} + +/** + * Counts from the real search. Above LAZY_SEARCH_MIN_SHAPES fingering-feasible shapes the fingering search is + * lazy: `playable` and the fingering-stage rejections then cover only searched shapes (voicings are identical). + */ +export interface GenerateStats { + windows: number; + combinations: number; + pitchValid: number; + playable: number; + rejections: Partial>; +} + +export interface GenerateResult { + voicings: GeneratedVoicing[]; + stats: GenerateStats; + /** + * Present when `voicings` is empty: the rule that blocks the most nearly-valid shapes in a wider diagnostic + * search (wider windows, frets above maxFret, open strings). It may be absent from `stats.rejections`. + */ + empty?: { constraint: BindingConstraint; message: string }; +} + +// --------------------------------------------------------------------------- +// Transitions +// --------------------------------------------------------------------------- + +export interface FingeredShape { + shape: Shape; + fingering: Fingering; +} + +export interface TransitionBreakdown { + /** Sum of along-the-neck fingertip movement for fingers used in both shapes (mm). */ + fingerTravelMm: number; + /** Sum of |string change| for fingers used in both shapes. */ + stringChanges: number; + lifted: number; + placed: number; + /** Same finger, same string, same fret in both shapes. */ + anchors: number; + /** Same finger, same string, different fret (guide-finger slide). */ + slides: number; + /** Movement of the hand position (lowest fretted fingertip) in mm. */ + positionShiftMm: number; + /** Unscaled cost. */ + raw: number; + /** Seconds available for the change. */ + timeSec: number; + /** raw scaled by available time. */ + cost: number; +} + +export interface SequenceTiming { + bpm: number; + /** Beats each slot lasts before the next change; length = number of slots. */ + beatsPerSlot: readonly number[]; + /** Also count the last -> first transition (looping riff). Default true. */ + cyclic?: boolean; +} + +export interface SequenceTransition { + from: number; + to: number; + cost: number; + breakdown: TransitionBreakdown; +} + +export interface SequenceResult { + /** Chosen candidate index per slot. */ + path: number[]; + /** Hardest transition cost on the path (minimized first). */ + maxCost: number; + /** Sum of transition costs on the path (tie-break). */ + sumCost: number; + transitions: SequenceTransition[]; + /** + * null when the path has no transition (no slots, or one slot with `cyclic: false`). Check it before use: the + * consuming apps compile without `strictNullChecks`, so the compiler does not flag a missing null check. + */ + hardest: SequenceTransition | null; +} + +// --------------------------------------------------------------------------- +// Rhythm +// --------------------------------------------------------------------------- + +export type Meter = { numerator: number; denominator: 4 | 8 }; + +export type HitTarget = + /** Pedal note, usually the lowest string open or the root on it. */ + | { kind: 'pedal' } + /** Index into the harmony slots (voicings chosen by the user or optimizer). */ + | { kind: 'slot'; slot: number } + /** Top two notes of the slot voicing. */ + | { kind: 'dyad'; slot: number } + /** Muted percussive scratch. */ + | { kind: 'dead' }; + +export interface RhythmEvent { + /** Onset from pattern start. */ + tick: number; + /** > 0, no overlap with the next event. */ + durationTicks: number; + target: HitTarget; + palmMute: boolean; + /** 0 normal, 1 accent, 2 strong accent */ + accent: 0 | 1 | 2; + pick: 'down' | 'up'; + /** Sustain from the previous event, no new attack. */ + tie?: boolean; +} + +export type RhythmGrid = '16th' | '16th-triplet'; + +export type GroupingSpec = 'even' | '3-3-2' | '3-3-3-3-4' | 'random-odd' | readonly number[]; + +export type RhythmStyleId = + | 'chugEngine' + | 'gallop' + | 'reverseGallop' + | 'displacedThrees' + | 'halftimeStomp' + | 'tremoloWall' + | 'syncopatedStabs' + | 'sevenEight' + | 'fiveOverFour'; + +export type PickingMode = 'alternate' | 'downstrokes'; + +export interface RhythmParams { + style: RhythmStyleId; + meter: Meter; + bars: number; + grid: RhythmGrid; + grouping: GroupingSpec; + /** Polymeter: accent-cycle length in grid units, looping against the barline. */ + cycleSixteenths?: number; + /** 0..1 */ + density: number; + /** 0..1 */ + syncopation: number; + /** 0..1 share of hits on the pedal. */ + pedalRatio: number; + /** 0..1 share of palm-muted hits. */ + palmMuteRatio: number; + /** 1..4 harmony slots. */ + slotCount: number; + picking: PickingMode; + /** Allow the first beat to be empty (pickup / anticipation feel). */ + anticipation: boolean; +} + +export interface RhythmPattern { + id: string; + name: string; + seed: string; + meter: Meter; + bars: number; + ppq: 480; + /** Polymeter phrase length that loops against the bar. */ + cycleTicks?: number; + /** Sorted by tick; rests are gaps. */ + events: RhythmEvent[]; + tags: string[]; + params: RhythmParams; +} + +export interface RhythmWarning { + code: 'tooFastForProfile'; + message: string; + /** Smallest inter-onset interval in ticks. */ + minIoiTicks: number; + /** BPM above which the pattern exceeds the profile's comfortable sixteenth tempo. */ + bpmLimit: number; +} + +export interface RhythmValidation { + ok: boolean; + errors: string[]; + warnings: RhythmWarning[]; +} + +/** A harmony slot for playback: one voicing plus an optional pedal note. */ +export interface HarmonySlot { + shape: Shape; + /** MIDI note for pedal hits; null falls back to the slot's lowest sounding note. */ + pedalMidi: number | null; +} + +export interface PlaybackEvent { + tick: number; + timeSec: number; + midi: number[]; + /** 0..1 */ + velocity: number; + durationSec: number; + kind: HitTarget['kind']; + palmMute: boolean; + accent: 0 | 1 | 2; +} + +export interface MidiNoteEvent { + tick: number; + durationTicks: number; + pitch: number; + /** 1..127 */ + velocity: number; +} diff --git a/engine/voicingSpec.test.ts b/engine/voicingSpec.test.ts new file mode 100644 index 0000000..3ccb4fc --- /dev/null +++ b/engine/voicingSpec.test.ts @@ -0,0 +1,529 @@ +import { describe, it, expect } from 'vitest'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + VOICING_FAMILIES, + DRONE_FAMILIES, + ALL_FAMILIES, + FAMILY_GROUPS, + getFamily, + withDrone, + allowedIntervals, + matchFamily, + fromLegacyNotes, + relaxFamily +} from './voicingSpec'; +import type { Shape, VoicingFamily } from './types'; +import { E_STANDARD, DROP_D } from './tuning'; +import { parseNoteName, parsePitchClass, pitchClass, intervalFrom } from './pitch'; + +const x = null; +const E = 4; +const A = 9; +const B = 11; +const C = 0; +const D = 2; +const F = 5; + +const fam = (id: string): VoicingFamily => { + const family = getFamily(id); + if (!family) throw new Error(`missing family ${id}`); + return family; +}; + +describe('family recipes', () => { + type Recipe = [ + id: string, + group: string, + required: number[], + optional: number[], + bass: number[] | 'any', + preferredBass: number | undefined, + minNotes: number, + maxNotes: number, + tags: string[] + ]; + const recipes: Recipe[] = [ + ['power5', 'power', [0, 7], [], [0], undefined, 2, 4, ['metal-friendly', 'pedal-compatible']], + ['power5_b2', 'power', [0, 1, 7], [], [0], undefined, 3, 5, ['metal-friendly', 'dissonant']], + ['dyad_b2', 'power', [0, 1], [], [0], undefined, 2, 4, ['metal-friendly', 'dissonant']], + ['tritone', 'power', [0, 6], [], [0], undefined, 2, 4, ['metal-friendly', 'dissonant']], + ['fourth', 'power', [0, 5], [], [0], undefined, 2, 4, ['metal-friendly']], + ['sus2', 'susAdd', [0, 2, 7], [], 'any', 0, 3, 6, []], + ['sus4', 'susAdd', [0, 5, 7], [], 'any', 0, 3, 6, []], + ['add9', 'susAdd', [0, 2, 4], [7], 'any', 0, 3, 6, ['color']], + ['m_add9', 'susAdd', [0, 2, 3], [7], 'any', 0, 3, 6, ['color', 'metal-friendly']], + ['7', 'shells', [0, 4, 10], [], [0], undefined, 3, 4, []], + ['m7', 'shells', [0, 3, 10], [], [0], undefined, 3, 4, []], + ['maj7', 'shells', [0, 4, 11], [], [0], undefined, 3, 4, ['color']], + ['quartal', 'quartalCluster', [0, 5, 10], [3], [0], undefined, 3, 4, ['color']], + ['cluster_m2', 'quartalCluster', [0, 1, 2], [], 'any', 0, 3, 4, ['dissonant', 'color']], + ['cluster_b3', 'quartalCluster', [0, 2, 3], [], 'any', 0, 3, 4, ['color']], + ['phrygian', 'darkColors', [0, 1, 4, 7], [10], [0], undefined, 4, 6, ['metal-friendly', 'dissonant']], + ['major', 'triads', [0, 4, 7], [], 'any', undefined, 3, 6, ['triad']], + ['minor', 'triads', [0, 3, 7], [], 'any', undefined, 3, 6, ['triad']], + ['dim', 'triads', [0, 3, 6], [], 'any', undefined, 3, 6, ['triad']], + ['aug', 'triads', [0, 4, 8], [], 'any', undefined, 3, 6, ['triad']] + ]; + + it('lists exactly the base families in order', () => { + expect(VOICING_FAMILIES.map((f) => f.id)).toEqual(recipes.map((r) => r[0])); + }); + + it.each(recipes)('%s has the contract recipe', (id, group, required, optional, bass, preferredBass, min, max, tags) => { + const f = fam(id); + expect(f.group).toBe(group); + expect([...f.required]).toEqual(required); + expect([...f.optional]).toEqual(optional); + expect(f.bass === 'any' ? 'any' : [...f.bass]).toEqual(bass); + expect(f.preferredBass).toBe(preferredBass); + expect(f.minNotes).toBe(min); + expect(f.maxNotes).toBe(max); + expect([...f.tags].sort()).toEqual([...tags].sort()); + expect(f.drone).toBeFalsy(); + }); + + it('documents the forbidden thirds of power and sus families', () => { + expect([...fam('power5').forbidden]).toEqual([3, 4]); + expect([...fam('sus2').forbidden]).toEqual([3, 4]); + expect([...fam('sus4').forbidden]).toEqual([3, 4]); + }); + + it('keeps every recipe internally consistent', () => { + const labels = new Set(); + for (const f of ALL_FAMILIES) { + expect(f.label.length).toBeGreaterThan(0); + expect(f.description.length).toBeGreaterThan(0); + expect(labels.has(f.label)).toBe(false); + labels.add(f.label); + expect(f.required[0]).toBe(0); + const all = [...f.required, ...f.optional, ...f.forbidden]; + for (const i of all) expect(Number.isInteger(i) && i >= 0 && i < 12).toBe(true); + expect([...f.required]).toEqual([...new Set(f.required)].sort((a, b) => a - b)); + for (const i of f.optional) expect(f.required).not.toContain(i); + for (const i of f.forbidden) expect(allowedIntervals(f)).not.toContain(i); + expect(f.minNotes).toBeLessThanOrEqual(f.maxNotes); + expect(f.minNotes).toBeGreaterThanOrEqual(Math.min(2, f.required.length)); + expect(f.maxNotes).toBeLessThanOrEqual(6); + if (f.bass !== 'any') for (const i of f.bass) expect(allowedIntervals(f)).toContain(i); + if (f.preferredBass !== undefined) expect(allowedIntervals(f)).toContain(f.preferredBass); + } + }); + + it('freezes the shared recipes', () => { + expect(Object.isFrozen(fam('power5'))).toBe(true); + expect(Object.isFrozen(fam('power5').required)).toBe(true); + }); +}); + +describe('drone families and groups', () => { + it('derives the drone variants with withDrone', () => { + expect(DRONE_FAMILIES.map((f) => f.id)).toEqual([ + 'drone_power5', + 'drone_fourth', + 'drone_sus2', + 'drone_m_add9', + 'drone_quartal', + 'drone_phrygian' + ]); + for (const d of DRONE_FAMILIES) { + const base = fam(d.id.replace(/^drone_/, '')); + expect(d.group).toBe('drones'); + expect(d.drone).toBe(true); + expect(d.required).toEqual(base.required); + expect(d.optional).toEqual(base.optional); + expect(d.forbidden).toEqual(base.forbidden); + expect(d.bass).toEqual(base.bass); + expect(d.minNotes).toBe(base.minNotes); + expect(d.maxNotes).toBe(base.maxNotes); + expect(d.label).toBe(`Drone ${base.label}`); + } + }); + + it('withDrone is idempotent and leaves the input untouched', () => { + const power = fam('power5'); + const drone = withDrone(power); + expect(drone.id).toBe('drone_power5'); + expect(withDrone(drone)).toBe(drone); + expect(power.drone).toBeFalsy(); + expect(power.group).toBe('power'); + }); + + it('ALL_FAMILIES is the base list followed by the drones, with unique ids', () => { + expect(ALL_FAMILIES).toEqual([...VOICING_FAMILIES, ...DRONE_FAMILIES]); + expect(new Set(ALL_FAMILIES.map((f) => f.id)).size).toBe(ALL_FAMILIES.length); + }); + + it('getFamily finds base and drone families and returns undefined otherwise', () => { + expect(getFamily('m_add9')?.label).toBe('m(add9)'); + expect(getFamily('drone_quartal')?.drone).toBe(true); + expect(getFamily('nope')).toBeUndefined(); + }); + + it('groups families in UI order and covers each family exactly once', () => { + expect(FAMILY_GROUPS.map((g) => [g.id, g.label])).toEqual([ + ['power', 'Power and dyads'], + ['susAdd', 'Sus and add'], + ['shells', 'Shells'], + ['quartalCluster', 'Quartal and clusters'], + ['darkColors', 'Dark colors'], + ['triads', 'Triads'], + ['drones', 'Drones'] + ]); + const listed = FAMILY_GROUPS.flatMap((g) => g.familyIds); + expect([...listed].sort()).toEqual(ALL_FAMILIES.map((f) => f.id).sort()); + for (const g of FAMILY_GROUPS) for (const id of g.familyIds) expect(fam(id).group).toBe(g.id); + expect(FAMILY_GROUPS[0].familyIds).toEqual(['power5', 'power5_b2', 'dyad_b2', 'tritone', 'fourth']); + }); +}); + +describe('allowedIntervals', () => { + it('returns sorted unique required and optional intervals', () => { + expect(allowedIntervals(fam('phrygian'))).toEqual([0, 1, 4, 7, 10]); + expect(allowedIntervals(fam('quartal'))).toEqual([0, 3, 5, 10]); + expect(allowedIntervals(fam('power5'))).toEqual([0, 7]); + }); +}); + +describe('matchFamily', () => { + const ok = { ok: true }; + // Narrows with `=== true`: the repo tsconfig is non-strict, where `!r.ok` does not narrow the union. + const mismatch = (family: string, root: number, shape: Shape, tuning = E_STANDARD) => { + const r = matchFamily(fam(family), root, shape, tuning); + return r.ok === true ? null : r; + }; + const reason = (family: string, root: number, shape: Shape, tuning = E_STANDARD): string | null => + mismatch(family, root, shape, tuning)?.reason ?? null; + + it('accepts hand-checked power chords and octave doublings', () => { + expect(matchFamily(fam('power5'), E, [0, 2, 2, x, x, x], E_STANDARD)).toEqual(ok); + expect(matchFamily(fam('power5'), E, [0, 2, x, x, x, x], E_STANDARD)).toEqual(ok); + expect(matchFamily(fam('power5'), A, [5, 7, 7, x, x, x], E_STANDARD)).toEqual(ok); + expect(matchFamily(fam('power5'), A, [x, 0, 2, 2, x, x], E_STANDARD)).toEqual(ok); + }); + + it('accepts Drop D one-finger power chords', () => { + expect(matchFamily(fam('power5'), D, [0, 0, 0, x, x, x], DROP_D)).toEqual(ok); + expect(matchFamily(fam('power5'), F, [3, 3, 3, x, x, x], DROP_D)).toEqual(ok); + // The same grip in E standard is E A D: not a power chord. + expect(reason('power5', E, [0, 0, 0, x, x, x])).toBe('PITCH_CLASSES'); + }); + + it('rejects wrong note counts', () => { + expect(mismatch('power5', E, [0, x, x, x, x, x])).toMatchObject({ + reason: 'NOTE_COUNT', + detail: expect.stringContaining('1 note,') + }); + expect(reason('power5', E, [0, 2, 2, 4, 5, 0])).toBe('NOTE_COUNT'); + expect(reason('power5', E, [x, x, x, x, x, x])).toBe('NOTE_COUNT'); + // Shells cap at four notes; the six-string Em(add9) fits m_add9's six-note cap. + expect(reason('7', E, [0, 2, 0, 1, 0, 0])).toBe('NOTE_COUNT'); + expect(matchFamily(fam('m_add9'), E, [0, 2, 4, 0, 0, 0], E_STANDARD)).toEqual(ok); + expect(matchFamily(fam('m_add9'), E, [0, 2, 4, 0, x, x], E_STANDARD)).toEqual(ok); + }); + + it('rejects missing, outside and forbidden pitch classes', () => { + expect(mismatch('power5_b2', E, [0, 2, 2, x, x, x])).toEqual({ + ok: false, + reason: 'PITCH_CLASSES', + detail: 'missing b2' + }); + // Open E major has the major third: forbidden for power5. + expect(mismatch('power5', E, [0, 2, 2, 1, x, x])).toEqual({ + ok: false, + reason: 'PITCH_CLASSES', + detail: '3 is forbidden in 5' + }); + expect(mismatch('power5_b2', E, [0, 2, 2, 1, x, x])?.detail).toBe('missing b2'); + // E A E G#: the major third is simply outside the 4th dyad. + expect(mismatch('fourth', E, [0, 0, 2, 1, x, x])?.detail).toBe('3 is outside 4th'); + // sus2 E B F# B is fine, adding G (b3) breaks it. + expect(matchFamily(fam('sus2'), E, [0, 2, 4, 4, x, x], E_STANDARD)).toEqual(ok); + expect(reason('sus2', E, [0, 2, 4, 0, x, x])).toBe('PITCH_CLASSES'); + }); + + it('accepts optional intervals and both shapes with and without them', () => { + expect(matchFamily(fam('add9'), C, [x, 3, 2, 0, 3, x], E_STANDARD)).toEqual(ok); // C E G D + expect(matchFamily(fam('add9'), C, [x, 3, 2, x, 3, x], E_STANDARD)).toEqual(ok); // C E D + expect(matchFamily(fam('phrygian'), E, [0, 8, 6, 4, x, x], E_STANDARD)).toEqual(ok); // E F G# B + expect(reason('phrygian', E, [0, 7, 6, x, x, x])).toBe('NOTE_COUNT'); + }); + + it('accepts any inversion for triads', () => { + expect(matchFamily(fam('major'), C, [x, 3, 2, 0, 1, 0], E_STANDARD)).toEqual(ok); + expect(matchFamily(fam('major'), C, [0, 3, 2, 0, 1, x], E_STANDARD)).toEqual(ok); // E bass + expect(matchFamily(fam('major'), C, [3, 3, 2, 0, 1, x], E_STANDARD)).toEqual(ok); // G bass + expect(reason('minor', C, [x, 3, 2, 0, 1, 0])).toBe('PITCH_CLASSES'); + }); + + it('enforces the hard bass rule', () => { + expect(mismatch('power5', E, [x, 2, 2, x, x, x])).toEqual({ + ok: false, + reason: 'BASS', + detail: 'bass is 5, 5 needs 1' + }); // B2 E3 + expect(reason('7', C, [x, x, 2, 3, 1, x])).toBe('BASS'); // E A# C: shell with the third in the bass + }); + + it('finds the bass by pitch, not by string', () => { + // String 0 fret 12 is E3, but the open A string (A2) is lower: bass is the 4th of E. + expect(reason('fourth', E, [12, 0, x, x, x, x])).toBe('BASS'); + // String 0 fret 10 is D3 above the open A2: the A root is the real bass. + expect(matchFamily(fam('fourth'), A, [10, 0, x, x, x, x], E_STANDARD)).toEqual(ok); + }); + + it('applies the drone rule to open strings only', () => { + expect(matchFamily(fam('drone_power5'), D, [0, 0, 0, x, x, x], DROP_D)).toEqual(ok); + expect(matchFamily(fam('drone_power5'), D, [x, x, 0, 2, 3, x], E_STANDARD)).toEqual(ok); + // The open high E is the fifth of A. + expect(matchFamily(fam('drone_power5'), A, [x, 12, 14, x, x, 0], E_STANDARD)).toEqual(ok); + expect(mismatch('drone_power5', F, [1, 3, 3, x, x, x])).toEqual({ + ok: false, + reason: 'NO_DRONE_STRING', + detail: 'no open string sounds the root or 5th' + }); + // Open D is the b3 of B: allowed by quartal, but not a drone on the root or fifth. + const bQuartal: Shape = [7, 7, 0, 2, x, x]; + expect(matchFamily(fam('quartal'), B, bQuartal, E_STANDARD)).toEqual(ok); + expect(reason('drone_quartal', B, bQuartal)).toBe('NO_DRONE_STRING'); + }); + + it('keeps pitch-class rules ahead of the drone rule', () => { + // An open fifth is not allowed in a 4th dyad, so drone_fourth needs an open root. + expect(reason('drone_fourth', A, [x, 0, 0, x, x, 0])).toBe('PITCH_CLASSES'); + expect(matchFamily(fam('drone_fourth'), E, [0, 0, x, x, x, x], E_STANDARD)).toEqual(ok); + }); + + it('normalizes the root pitch class', () => { + expect(matchFamily(fam('power5'), E + 12, [0, 2, 2, x, x, x], E_STANDARD)).toEqual(ok); + }); +}); + +describe('fromLegacyNotes', () => { + it('converts "The Omen" (standard-dark, dark-1)', () => { + const f = fromLegacyNotes(['E2', 'F2', 'B2', 'E3'], 'E'); + expect([...f.required]).toEqual([0, 1, 7]); + expect([...f.optional]).toEqual([]); + expect([...f.forbidden]).toEqual([]); + expect(f.bass).toEqual([0]); + expect(f.minNotes).toBe(3); + expect(f.maxNotes).toBe(4); + expect(f.group).toBe('legacy'); + expect(f.id).toBe('legacy:0.1.7/0'); + expect(f.label).toBe('1 b2 5'); + expect([...f.tags]).toEqual([]); + expect(f.preferredBass).toBeUndefined(); + }); + + it('reads flat spellings against a sharp baseRoot (drop-melodic "Drop Bb Maj7")', () => { + const f = fromLegacyNotes(['Bb2', 'D3', 'F3', 'A3'], 'A#'); + expect([...f.required]).toEqual([0, 4, 7, 11]); + expect(f.bass).toEqual([0]); + expect(f.label).toBe('1 3 5 7'); + const sus = fromLegacyNotes(['Bb2', 'C3', 'F3', 'Bb3'], 'A#'); // "Drop Bb sus2" + expect([...sus.required]).toEqual([0, 2, 7]); + }); + + it('keeps pitch classes of notes below the guitar range ("E Low Stack")', () => { + const f = fromLegacyNotes(['E1', 'E2', 'B2'], 'E'); + expect([...f.required]).toEqual([0, 7]); + expect(f.bass).toEqual([0]); + expect(f.minNotes).toBe(2); + expect(f.maxNotes).toBe(3); + expect(f.id).toBe('legacy:0.7/0'); + }); + + it('converts other real rows', () => { + expect([...fromLegacyNotes(['E2', 'G2', 'B2', 'D3', 'F#3', 'A3'], 'E').required]).toEqual([0, 2, 3, 5, 7, 10]); + expect(fromLegacyNotes(['E2', 'G2', 'B2', 'D3', 'F#3', 'A3'], 'E').maxNotes).toBe(6); + expect([...fromLegacyNotes(['F#2', 'A2', 'C3', 'F#3'], 'F#').required]).toEqual([0, 3, 6]); + expect([...fromLegacyNotes(['D2', 'D#2', 'A2', 'D3'], 'D').required]).toEqual([0, 1, 7]); + }); + + it('takes the bass interval from the lowest parsed note, wherever it is listed', () => { + const f = fromLegacyNotes(['C3', 'E3', 'G2'], 'C'); + expect(f.bass).toEqual([7]); + expect(f.id).toBe('legacy:0.4.7/7'); + }); + + it('clamps maxNotes to the guitar and skips unparseable tokens', () => { + expect(fromLegacyNotes(['E2', 'B2', 'E3', 'B3', 'E4', 'B4', 'E5'], 'E').maxNotes).toBe(6); + const f = fromLegacyNotes(['E2', 'foo', 'B2', ''], 'E'); + expect([...f.required]).toEqual([0, 7]); + expect(f.maxNotes).toBe(2); + }); + + it('throws when nothing parses or the root is invalid', () => { + expect(() => fromLegacyNotes(['nope'], 'E')).toThrow(); + expect(() => fromLegacyNotes([], 'E')).toThrow(); + expect(() => fromLegacyNotes(['E2'], 'H')).toThrow(); + }); + + it('produces a family that matches its own notes', () => { + const f = fromLegacyNotes(['E2', 'F2', 'B2', 'E3'], 'E'); + expect(matchFamily(f, E, [0, x, 3, 4, 5, x], E_STANDARD)).toEqual({ ok: true }); // E2 F3 B3 E4 + expect(matchFamily(f, E, [0, 2, 3, x, x, x], E_STANDARD)).toEqual({ ok: true }); // E2 B2 F3 + }); + + const chordsDir = fileURLToPath(new URL('../public/chords/', import.meta.url)); + describe.skipIf(!existsSync(chordsDir))('every row in public/chords', () => { + interface Row { + id: string; + notes: string[]; + baseRoot: string; + relatedChords?: Row[]; + } + const rows: Row[] = readdirSync(chordsDir) + .filter((file) => file.endsWith('.json')) + .sort() + .flatMap((file) => JSON.parse(readFileSync(chordsDir + file, 'utf8')) as Row[]) + .flatMap((row) => [row, ...(row.relatedChords ?? [])]); + + it('loads the real library', () => { + expect(rows.length).toBeGreaterThan(40); + }); + + it('turns each row into the pitch classes of its notes with the lowest note as bass', () => { + for (const row of rows) { + const f = fromLegacyNotes(row.notes, row.baseRoot); + const root = parsePitchClass(row.baseRoot) as number; + const midi = row.notes.map((n) => parseNoteName(n) as number); + const expected = [...new Set(midi.map((m) => intervalFrom(root, pitchClass(m))))].sort((a, b) => a - b); + expect([...f.required], row.id).toEqual(expected); + expect(f.bass, row.id).toEqual([intervalFrom(root, pitchClass(Math.min(...midi)))]); + expect(f.minNotes, row.id).toBe(expected.length); + expect(f.maxNotes, row.id).toBe(Math.min(6, Math.max(expected.length, row.notes.length))); + expect(f.id, row.id).toBe(`legacy:${expected.join('.')}/${f.bass}`); + } + }); + }); +}); + +describe('relaxFamily', () => { + const steps = (id: string): string[][] => relaxFamily(fam(id)).map((s) => s.relaxed); + /** True when no rung of the ladder accepts the shape. */ + const neverAccepted = (family: VoicingFamily, root: number, shape: Shape, tuning = E_STANDARD): boolean => + relaxFamily(family).every((s) => matchFamily(s.family, root, shape, tuning).ok !== true); + + it('relaxes 7 shells: bass first, then note count', () => { + const ladder = relaxFamily(fam('7')); + expect(ladder.map((s) => s.relaxed)).toEqual([['any bass note'], ['any bass note', 'any note count']]); + const [first, second] = ladder; + expect(first.family.bass).toBe('any'); + expect(first.family.preferredBass).toBe(0); + expect(first.family.maxNotes).toBe(4); + expect(second.family.bass).toBe('any'); + expect(second.family.minNotes).toBe(3); + expect(second.family.maxNotes).toBe(6); + }); + + it('never frees the bass of a dyad: inverting it would change its only interval', () => { + for (const id of ['power5', 'dyad_b2', 'fourth']) { + expect(steps(id), id).toEqual([['any note count']]); + for (const step of relaxFamily(fam(id))) expect(step.family.bass, id).toEqual([0]); + } + expect(relaxFamily(fam('power5'))[0].family.maxNotes).toBe(6); + // C#3 C4 is a major 7th, not a b2 dyad on C; B2 E3 is a 4th, not E5; A2 E3 is a 5th, not an E 4th dyad. + expect(neverAccepted(fam('dyad_b2'), C, [9, x, 10, x, x, x])).toBe(true); + expect(neverAccepted(fam('power5'), E, [x, 2, 2, x, x, x])).toBe(true); + expect(neverAccepted(fam('fourth'), E, [x, 0, 2, x, x, x])).toBe(true); + }); + + it('keeps the bass relaxation for the tritone, its own inversion', () => { + expect(steps('tritone')).toEqual([['any bass note'], ['any bass note', 'any note count']]); + expect(matchFamily(relaxFamily(fam('tritone'))[0].family, E, [x, 1, 2, x, x, x], E_STANDARD).ok).toBe(true); // A# E + }); + + it('never decays a family into a dyad free to invert (sus2, sus4, triads)', () => { + for (const id of ['sus2', 'sus4', 'major', 'minor']) expect(steps(id), id).toEqual([]); + // B3 F#4 is a B5 power chord, not F#sus4 without its fifth. + expect(neverAccepted(fam('sus4'), 6, [x, x, x, x, 0, 2])).toBe(true); + }); + + it('never drops the interval a family is named for', () => { + for (const [id, named] of [ + ['add9', 2], + ['m_add9', 2], + ['cluster_b3', 2], + ['quartal', 5] + ] as const) { + for (const step of relaxFamily(fam(id))) expect(step.family.required, `${id} ${step.relaxed}`).toContain(named); + } + // G2 B2 E3 is Em/G, not E m(add9); F#2 D#3 is a 6th dyad, not a b3 cluster on D#. + expect(neverAccepted(fam('m_add9'), E, [3, 2, 2, x, x, x])).toBe(true); + expect(neverAccepted(fam('add9'), C, [x, 3, 2, 0, 1, x])).toBe(true); + expect(neverAccepted(fam('cluster_b3'), 3, [2, 6, x, x, x, x])).toBe(true); + expect(steps('cluster_b3')).toEqual([['any note count']]); + expect(relaxFamily(fam('cluster_b3'))[0].family.minNotes).toBe(3); + expect(steps('quartal')).toEqual([['any bass note'], ['any bass note', 'any note count']]); + }); + + it('moves extensions to optional when a third or seventh is required (library m9 row)', () => { + const m9 = fromLegacyNotes(['E2', 'G2', 'B2', 'D3', 'F#3'], 'E'); + const ladder = relaxFamily(m9); + expect(ladder.map((s) => s.relaxed.at(-1))).toEqual(['any bass note', 'no 5th', 'no extensions', 'any note count']); + expect([...ladder[2].family.required]).toEqual([0, 3, 10]); + expect([...ladder[2].family.optional]).toEqual([2, 7]); + expect(ladder[3].family.minNotes).toBe(3); + }); + + it('leaves sus4 extensions alone (no third, no seventh)', () => { + const custom: VoicingFamily = { ...fam('sus4'), id: 'custom_sus4', bass: [0], preferredBass: undefined }; + expect(relaxFamily(custom).map((s) => s.relaxed.at(-1))).toEqual(['any bass note']); + expect([...relaxFamily(custom)[0].family.required]).toEqual([0, 5, 7]); + }); + + it('walks the full ladder for phrygian and stays cumulative', () => { + const ladder = relaxFamily(fam('phrygian')); + expect(ladder.map((s) => s.relaxed)).toEqual([ + ['any bass note'], + ['any bass note', 'no 5th'], + ['any bass note', 'no 5th', 'any note count'] + ]); + const last = ladder[2].family; + expect(last.bass).toBe('any'); + expect([...last.required]).toEqual([0, 1, 4]); + expect([...last.optional]).toEqual([7, 10]); + expect(last.minNotes).toBe(3); + expect(last.maxNotes).toBe(6); + }); + + it('keeps the drone rule, group and forbidden list, and gives each step a distinct id', () => { + for (const id of ['drone_power5', 'drone_phrygian']) { + const base = fam(id); + const ladder = relaxFamily(base); + expect(ladder.length, id).toBeGreaterThan(0); + for (const step of ladder) { + expect(step.family.drone).toBe(true); + expect(step.family.group).toBe('drones'); + expect(step.family.forbidden).toEqual(base.forbidden); + expect(step.family.id.startsWith(id)).toBe(true); + } + const ids = ladder.map((s) => s.family.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).not.toContain(id); + } + }); + + it('returns nothing for a family that is already fully relaxed', () => { + for (const id of ['power5', 'phrygian']) { + const loose = relaxFamily(fam(id)).at(-1)!.family; + expect(relaxFamily(loose), id).toEqual([]); + } + }); + + it('relaxed families accept shapes the exact recipe rejects', () => { + const inverted: Shape = [x, x, 2, 3, 1, x]; // E A# C: C7 shell with the third in the bass + expect(matchFamily(fam('7'), C, inverted, E_STANDARD).ok).toBe(false); + expect(matchFamily(relaxFamily(fam('7'))[0].family, C, inverted, E_STANDARD).ok).toBe(true); + const noFifth: Shape = [0, 8, 6, x, x, x]; // E F G# + expect(matchFamily(fam('phrygian'), E, noFifth, E_STANDARD).ok).toBe(false); + expect(matchFamily(relaxFamily(fam('phrygian'))[2].family, E, noFifth, E_STANDARD).ok).toBe(true); + }); + + it('does not mutate the input family', () => { + const before = JSON.stringify(fam('phrygian')); + relaxFamily(fam('phrygian')); + expect(JSON.stringify(fam('phrygian'))).toBe(before); + }); +}); diff --git a/engine/voicingSpec.ts b/engine/voicingSpec.ts new file mode 100644 index 0000000..9b9fdcf --- /dev/null +++ b/engine/voicingSpec.ts @@ -0,0 +1,510 @@ +// Voicing families: interval recipes relative to a chosen root, plus exact validation of concrete shapes. +import type { FamilyGroup, FamilyTag, Shape, Tuning, VoicingFamily } from './types'; +import { degreeLabel, intervalFrom, parseNoteName, parsePitchClass, pitchClass } from './pitch'; +import { shapeToMidiByString } from './shape'; + +export type FamilyMismatchReason = 'PITCH_CLASSES' | 'NOTE_COUNT' | 'BASS' | 'NO_DRONE_STRING'; + +/** Narrow with `m.ok === false`: without strictNullChecks (the apps' setting) `!m.ok` does not narrow. */ +export type FamilyMatch = { ok: true } | { ok: false; reason: FamilyMismatchReason; detail: string }; + +/** Guitar string count cap for note-count limits. */ +const MAX_VOICING_NOTES = 6; + +const sortedUnique = (values: readonly number[]): number[] => + [...new Set(values.map(pitchClass))].sort((a, b) => a - b); + +const freezeFamily = (family: VoicingFamily): VoicingFamily => + Object.freeze({ + ...family, + required: Object.freeze([...family.required]), + optional: Object.freeze([...family.optional]), + forbidden: Object.freeze([...family.forbidden]), + bass: family.bass === 'any' ? 'any' : Object.freeze([...family.bass]), + tags: Object.freeze([...family.tags]) + }); + +interface FamilyInput { + id: string; + label: string; + description: string; + group: FamilyGroup; + required: number[]; + optional?: number[]; + forbidden?: number[]; + bass: number[] | 'any'; + preferredBass?: number; + minNotes: number; + maxNotes: number; + tags?: FamilyTag[]; +} + +const define = (input: FamilyInput): VoicingFamily => { + const family: VoicingFamily = { + id: input.id, + label: input.label, + description: input.description, + group: input.group, + required: input.required, + optional: input.optional ?? [], + forbidden: input.forbidden ?? [], + bass: input.bass, + minNotes: input.minNotes, + maxNotes: input.maxNotes, + tags: input.tags ?? [] + }; + if (input.preferredBass !== undefined) family.preferredBass = input.preferredBass; + return freezeFamily(family); +}; + +const triad = (id: string, label: string, required: number[], description: string): VoicingFamily => + define({ id, label, description, group: 'triads', required, bass: 'any', minNotes: 3, maxNotes: 6, tags: ['triad'] }); + +export const VOICING_FAMILIES: readonly VoicingFamily[] = Object.freeze([ + define({ + id: 'power5', + label: '5', + description: 'Root and fifth: the tight, gain-proof metal workhorse.', + group: 'power', + required: [0, 7], + forbidden: [3, 4], + bass: [0], + minNotes: 2, + maxNotes: 4, + tags: ['metal-friendly', 'pedal-compatible'] + }), + define({ + id: 'power5_b2', + label: '5 b2', + description: 'Power chord with a minor-second rub for a darker, menacing edge.', + group: 'power', + required: [0, 1, 7], + bass: [0], + minNotes: 3, + maxNotes: 5, + tags: ['metal-friendly', 'dissonant'] + }), + define({ + id: 'dyad_b2', + label: 'b2 dyad', + description: 'Root against its minor second: a raw, grinding dissonance.', + group: 'power', + required: [0, 1], + bass: [0], + minNotes: 2, + maxNotes: 4, + tags: ['metal-friendly', 'dissonant'] + }), + define({ + id: 'tritone', + label: 'Tritone', + description: 'Root and flat fifth: an unresolved, uneasy dyad.', + group: 'power', + required: [0, 6], + bass: [0], + minNotes: 2, + maxNotes: 4, + tags: ['metal-friendly', 'dissonant'] + }), + define({ + id: 'fourth', + label: '4th', + description: 'Root and fourth: a hollow dyad that stays clear under gain.', + group: 'power', + required: [0, 5], + bass: [0], + minNotes: 2, + maxNotes: 4, + tags: ['metal-friendly'] + }), + define({ + id: 'sus2', + label: 'sus2', + description: 'Root, second and fifth: open and unresolved, no third.', + group: 'susAdd', + required: [0, 2, 7], + forbidden: [3, 4], + bass: 'any', + preferredBass: 0, + minNotes: 3, + maxNotes: 6 + }), + define({ + id: 'sus4', + label: 'sus4', + description: 'Root, fourth and fifth: suspended tension with no third.', + group: 'susAdd', + required: [0, 5, 7], + forbidden: [3, 4], + bass: 'any', + preferredBass: 0, + minNotes: 3, + maxNotes: 6 + }), + define({ + id: 'add9', + label: 'add9', + description: 'Major third with an added ninth; the fifth is optional.', + group: 'susAdd', + required: [0, 2, 4], + optional: [7], + bass: 'any', + preferredBass: 0, + minNotes: 3, + maxNotes: 6, + tags: ['color'] + }), + define({ + id: 'm_add9', + label: 'm(add9)', + description: 'Minor third with an added ninth: dark and wide; the fifth is optional.', + group: 'susAdd', + required: [0, 2, 3], + optional: [7], + bass: 'any', + preferredBass: 0, + minNotes: 3, + maxNotes: 6, + tags: ['color', 'metal-friendly'] + }), + define({ + id: '7', + label: '7 shell', + description: 'Dominant shell: root, major third and flat seventh.', + group: 'shells', + required: [0, 4, 10], + bass: [0], + minNotes: 3, + maxNotes: 4 + }), + define({ + id: 'm7', + label: 'm7 shell', + description: 'Minor seventh shell: root, minor third and flat seventh.', + group: 'shells', + required: [0, 3, 10], + bass: [0], + minNotes: 3, + maxNotes: 4 + }), + define({ + id: 'maj7', + label: 'maj7 shell', + description: 'Major seventh shell: root, major third and major seventh.', + group: 'shells', + required: [0, 4, 11], + bass: [0], + minNotes: 3, + maxNotes: 4, + tags: ['color'] + }), + define({ + id: 'quartal', + label: 'Quartal', + description: 'The notes of stacked fourths (root, fourth, flat seventh, optional minor third) in any spacing.', + group: 'quartalCluster', + required: [0, 5, 10], + optional: [3], + bass: [0], + minNotes: 3, + maxNotes: 4, + tags: ['color'] + }), + define({ + id: 'cluster_m2', + label: 'm2 cluster', + description: 'Root, minor second and major second: chromatic neighbours that grind when voiced close.', + group: 'quartalCluster', + required: [0, 1, 2], + bass: 'any', + preferredBass: 0, + minNotes: 3, + maxNotes: 4, + tags: ['dissonant', 'color'] + }), + define({ + id: 'cluster_b3', + label: 'b3 cluster', + description: 'Root, second and minor third with no fifth: a dark, moody colour.', + group: 'quartalCluster', + required: [0, 2, 3], + bass: 'any', + preferredBass: 0, + minNotes: 3, + maxNotes: 4, + tags: ['color'] + }), + define({ + id: 'phrygian', + label: 'Phrygian', + description: 'Root, flat second, major third and fifth: Phrygian-dominant menace.', + group: 'darkColors', + required: [0, 1, 4, 7], + optional: [10], + bass: [0], + minNotes: 4, + maxNotes: 6, + tags: ['metal-friendly', 'dissonant'] + }), + triad('major', 'Major', [0, 4, 7], 'Major triad in any inversion.'), + triad('minor', 'Minor', [0, 3, 7], 'Minor triad in any inversion.'), + triad('dim', 'Dim', [0, 3, 6], 'Diminished triad in any inversion.'), + triad('aug', 'Aug', [0, 4, 8], 'Augmented triad in any inversion.') +]); + +/** Adds the open-string drone rule; pitch-class, bass and note-count rules stay untouched. Idempotent. */ +export const withDrone = (family: VoicingFamily): VoicingFamily => { + if (family.drone) return family; + return freezeFamily({ + ...family, + id: `drone_${family.id}`, + label: `Drone ${family.label}`, + description: `${family.description} An open string rings the root or fifth.`, + group: 'drones', + drone: true + }); +}; + +const DRONE_BASE_IDS = ['power5', 'fourth', 'sus2', 'm_add9', 'quartal', 'phrygian'] as const; + +export const DRONE_FAMILIES: readonly VoicingFamily[] = Object.freeze( + DRONE_BASE_IDS.map((id) => withDrone(VOICING_FAMILIES.find((f) => f.id === id) as VoicingFamily)) +); + +export const ALL_FAMILIES: readonly VoicingFamily[] = Object.freeze([...VOICING_FAMILIES, ...DRONE_FAMILIES]); + +const GROUP_LABELS: readonly [Exclude, string][] = [ + ['power', 'Power and dyads'], + ['susAdd', 'Sus and add'], + ['shells', 'Shells'], + ['quartalCluster', 'Quartal and clusters'], + ['darkColors', 'Dark colors'], + ['triads', 'Triads'], + ['drones', 'Drones'] +]; + +export const FAMILY_GROUPS: readonly { id: FamilyGroup; label: string; familyIds: readonly string[] }[] = Object.freeze( + GROUP_LABELS.map(([id, label]) => + Object.freeze({ + id, + label, + familyIds: Object.freeze(ALL_FAMILIES.filter((f) => f.group === id).map((f) => f.id)) + }) + ) +); + +const FAMILY_BY_ID: ReadonlyMap = new Map(ALL_FAMILIES.map((f) => [f.id, f])); + +export const getFamily = (id: string): VoicingFamily | undefined => FAMILY_BY_ID.get(id); + +export const allowedIntervals = (family: VoicingFamily): number[] => + sortedUnique([...family.required, ...family.optional]); + +// --------------------------------------------------------------------------- +// Matching concrete shapes +// --------------------------------------------------------------------------- + +const maskOf = (intervals: readonly number[]): number => { + let mask = 0; + for (const i of intervals) mask |= 1 << pitchClass(i); + return mask; +}; + +const degreesOfMask = (mask: number): string => { + const labels: string[] = []; + for (let i = 0; i < 12; i++) if (mask & (1 << i)) labels.push(degreeLabel(i)); + return labels.join(' '); +}; + +const noteCountText = (family: VoicingFamily): string => + family.minNotes === family.maxNotes ? `${family.minNotes}` : `${family.minNotes}..${family.maxNotes}`; + +const DRONE_INTERVALS_MASK = maskOf([0, 7]); + +/** + * Exact validation in a fixed order: note count, pitch classes (required present, nothing outside + * required + optional, nothing forbidden), bass interval of the lowest-pitched note, then the drone rule. + */ +export const matchFamily = (family: VoicingFamily, rootPc: number, shape: Shape, tuning: Tuning): FamilyMatch => { + const root = pitchClass(rootPc); + const byString = shapeToMidiByString(shape, tuning); + + let count = 0; + let bass = Infinity; + let present = 0; + for (const midi of byString) { + if (midi === null) continue; + count++; + if (midi < bass) bass = midi; + present |= 1 << intervalFrom(root, pitchClass(midi)); + } + + if (count < family.minNotes || count > family.maxNotes) { + return { + ok: false, + reason: 'NOTE_COUNT', + detail: `${count} ${count === 1 ? 'note' : 'notes'}, ${family.label} needs ${noteCountText(family)}` + }; + } + + const required = maskOf(family.required); + const allowed = required | maskOf(family.optional); + const missing = required & ~present; + if (missing) { + return { ok: false, reason: 'PITCH_CLASSES', detail: `missing ${degreesOfMask(missing)}` }; + } + const forbidden = present & maskOf(family.forbidden); + if (forbidden) { + return { ok: false, reason: 'PITCH_CLASSES', detail: `${degreesOfMask(forbidden)} is forbidden in ${family.label}` }; + } + const outside = present & ~allowed; + if (outside) { + return { ok: false, reason: 'PITCH_CLASSES', detail: `${degreesOfMask(outside)} is outside ${family.label}` }; + } + + if (family.bass !== 'any') { + const bassInterval = intervalFrom(root, pitchClass(bass)); + if (!family.bass.includes(bassInterval)) { + return { + ok: false, + reason: 'BASS', + detail: `bass is ${degreeLabel(bassInterval)}, ${family.label} needs ${family.bass.map(degreeLabel).join(' or ')}` + }; + } + } + + if (family.drone) { + const hasDrone = byString.some( + (midi, s) => + midi !== null && shape[s] === 0 && (DRONE_INTERVALS_MASK & (1 << intervalFrom(root, pitchClass(midi)))) !== 0 + ); + if (!hasDrone) { + return { ok: false, reason: 'NO_DRONE_STRING', detail: 'no open string sounds the root or 5th' }; + } + } + + return { ok: true }; +}; + +// --------------------------------------------------------------------------- +// Legacy RiffForge chord rows +// --------------------------------------------------------------------------- + +/** + * Converts a RiffForge JSON `notes` array into a recipe: pitch classes relative to `baseRoot` are all + * required, the lowest parsed note fixes the bass. Unparseable tokens are skipped and do not count as notes. + */ +export const fromLegacyNotes = (notes: readonly string[], baseRoot: string): VoicingFamily => { + const root = parsePitchClass(baseRoot); + if (root === null) throw new Error(`Invalid baseRoot: ${baseRoot}`); + const midi = notes.map(parseNoteName).filter((m): m is number => m !== null); + if (midi.length === 0) throw new Error(`No parseable notes in [${notes.join(', ')}]`); + + const required = sortedUnique(midi.map((m) => intervalFrom(root, pitchClass(m)))); + const bass = intervalFrom(root, pitchClass(Math.min(...midi))); + const label = required.map(degreeLabel).join(' '); + return define({ + id: `legacy:${required.join('.')}/${bass}`, + label, + description: `Library chord recipe ${label} with ${degreeLabel(bass)} in the bass.`, + group: 'legacy', + required, + bass: [bass], + minNotes: required.length, + maxNotes: Math.min(MAX_VOICING_NOTES, Math.max(required.length, midi.length)) + }); +}; + +// --------------------------------------------------------------------------- +// Relaxation ladder +// --------------------------------------------------------------------------- + +interface RelaxStep { + key: string; + label: string; + apply: (family: VoicingFamily) => VoicingFamily; +} + +const moveToOptional = (family: VoicingFamily, intervals: readonly number[]): VoicingFamily => ({ + ...family, + required: family.required.filter((i) => !intervals.includes(i)), + optional: sortedUnique([...family.optional, ...family.required.filter((i) => intervals.includes(i))]) +}); + +const EXTENSIONS = [2, 5, 9]; +const THIRDS_AND_SEVENTHS = [3, 4, 10, 11]; + +const RELAX_STEPS: readonly RelaxStep[] = [ + { + key: 'anyBass', + label: 'any bass note', + // Keeps the old bass as a soft preference so root-position results still rank first. + apply: (f) => { + if (f.bass === 'any') return f; + const preferredBass = f.preferredBass ?? f.bass[0]; + return preferredBass === undefined ? { ...f, bass: 'any' } : { ...f, bass: 'any', preferredBass }; + } + }, + { + key: 'no5', + label: 'no 5th', + apply: (f) => (f.required.includes(7) && f.required.length >= 3 ? moveToOptional(f, [7]) : f) + }, + { + key: 'noExt', + label: 'no extensions', + apply: (f) => (f.required.some((i) => THIRDS_AND_SEVENTHS.includes(i)) ? moveToOptional(f, EXTENSIONS) : f) + }, + { + key: 'anyCount', + label: 'any note count', + apply: (f) => ({ ...f, minNotes: Math.max(2, f.required.length), maxNotes: MAX_VOICING_NOTES }) + } +]; + +/** + * Two required pitch classes over a free bass: the dyad may sound inverted, which swaps its only interval for the + * complement (b2 -> 7, 4 -> 5). The tritone is its own inversion. + */ +const invertibleDyad = (f: VoicingFamily): boolean => { + const required = sortedUnique(f.required); + return f.bass === 'any' && required.length === 2 && required[1] - required[0] !== 6; +}; + +const sameRecipe = (a: VoicingFamily, b: VoicingFamily): boolean => + a.minNotes === b.minNotes && + a.maxNotes === b.maxNotes && + a.preferredBass === b.preferredBass && + a.required.join() === b.required.join() && + a.optional.join() === b.optional.join() && + (a.bass === 'any' ? b.bass === 'any' : b.bass !== 'any' && a.bass.join() === b.bass.join()); + +/** + * Ordered, cumulative relaxations for the "closest relaxed suggestion". Steps that change nothing are + * skipped, and so are steps that would leave an invertible dyad: freeing the bass of a b2 dyad, or dropping the + * 5th of sus4 or the 9th of add9, would return a different interval instead of a looser version of the family. + * Each relaxed family gets a derived id (`~~...`) so it never collides with the exact one. + */ +export const relaxFamily = (family: VoicingFamily): { family: VoicingFamily; relaxed: string[] }[] => { + const ladder: { family: VoicingFamily; relaxed: string[] }[] = []; + let current = family; + const keys: string[] = []; + const labels: string[] = []; + for (const step of RELAX_STEPS) { + const next = step.apply(current); + if (sameRecipe(next, current)) continue; + if (invertibleDyad(next) && !invertibleDyad(current)) continue; + keys.push(step.key); + labels.push(step.label); + current = next; + ladder.push({ + family: freezeFamily({ + ...current, + id: `${family.id}~${keys.join('~')}`, + description: `${family.description} Relaxed: ${labels.join(', ')}.` + }), + relaxed: [...labels] + }); + } + return ladder; +}; diff --git a/engine/weights.ts b/engine/weights.ts new file mode 100644 index 0000000..4c844c3 --- /dev/null +++ b/engine/weights.ts @@ -0,0 +1,69 @@ +// Default cost weights. Every term is normalized to 0..1 before weighting, so a weight reads as +// "the most this term can add". Values are heuristic starting points; the ISMIR 2023 fitted +// weights (Velez Vasquez et al.) guide the relative order, they are not reproduced exactly. +import type { CostBucket, CostTermKey } from './types'; + +export interface CostWeight { + bucket: CostBucket; + weight: number; +} + +export const PLAYABILITY_WEIGHTS: Readonly> = { + // normalized = reachRatio^2. ISMIR 2023 gives finger positioning (cramped/wide) its top weight (3); + // squaring keeps comfortable spans cheap and makes the last few millimetres expensive. + reach: { bucket: 'physical', weight: 3.0 }, + // normalized = maxPairStretch^2: how far the worst consecutive finger pair is pulled beyond its relaxed + // one-fret-per-finger spacing toward its hard limit. Plain ratio-to-limit would punish the ordinary + // one-finger-per-fret hand frame (middle-ring sits near its limit there) and pick odd fingerings. + pairStretch: { bucket: 'physical', weight: 1.5 }, + // normalized = fingerCount / 4. ISMIR fingering difficulty (2) also covers barre, which is a hard + // constraint here, so the remaining finger-count share is lower. + fingerCount: { bucket: 'physical', weight: 1.0 }, + // normalized = 1 when the pinky frets a note. Hori and Sagayama rank the pinky least preferred. + pinky: { bucket: 'physical', weight: 0.5 }, + // normalized = min(1, contortions / 2): lower-numbered finger on a higher string at the same fret. + contortion: { bucket: 'physical', weight: 1.0 }, + // normalized = min(1, interiorMutes / 2): each muted string inside the voicing needs a finger to + // damp it, which gets noisy under gain. + interiorMutes: { bucket: 'physical', weight: 1.5 }, + // normalized = min(1, lowestFret / 12). Mild preference for lower positions only. + position: { bucket: 'physical', weight: 0.3 }, + // normalized = 1 when the thumb frets the lowest string (only possible when the profile allows it). + thumb: { bucket: 'physical', weight: 1.0 }, + // normalized = min(1, barres). Only reachable in barre mode; the generator default forbids barres. + barre: { bucket: 'physical', weight: 2.0 }, + + // normalized = min(1, skippedStrings / 2). ISMIR right-hand complexity (skipped strings) weight 2. + stringSkips: { bucket: 'rightHand', weight: 2.0 }, + // Chug context only: 0.5 * min(1, (soundingStrings - 1) / 5) + 0.5 * meanStringIndex / (strings - 1). + // Fewer, lower strings stay tight under palm muting. Always 0 in the 'strum' context. + chugStrings: { bucket: 'rightHand', weight: 0.5 }, + + // Distortion only: normalized = min(1, pairs / 2) over note pairs both below MIDI 52 (E3) that are + // 1..4 semitones apart. Narrow low intervals turn to mud under high gain. + lowMud: { bucket: 'musical', weight: 2.0 }, + // normalized = min(1, thirdsSounding - 1): a doubled third is thick and hard to keep in tune. + doubledThird: { bucket: 'musical', weight: 0.5 }, + // normalized = 1 when the family prefers the root in the bass (preferredBass 0) and the bass is another + // degree. A preferred bass on another degree (a relaxed slash-bass recipe) is not charged here. + rootNotInBass: { bucket: 'musical', weight: 0.5 } +}; + +export const PHYSICAL_TERMS: readonly CostTermKey[] = [ + 'reach', + 'pairStretch', + 'fingerCount', + 'pinky', + 'contortion', + 'interiorMutes', + 'position', + 'thumb', + 'barre' +]; + +export const RIGHT_HAND_TERMS: readonly CostTermKey[] = ['stringSkips', 'chugStrings']; + +export const MUSICAL_TERMS: readonly CostTermKey[] = ['lowMud', 'doubledThird', 'rootNotInBass']; + +/** MIDI note below which narrow intervals count as low-register mud (E3). */ +export const LOW_MUD_THRESHOLD_MIDI = 52; From bcadbe14b1ba8e316c832872142ffa98331243e5 Mon Sep 17 00:00:00 2001 From: Can Kilic Date: Sat, 3 Oct 2026 19:10:29 -0400 Subject: [PATCH 02/14] Add deterministic metal rhythm engine engine/rhythm/: 480 PPQ grid math, nine original style presets (chug engine, gallop, reverse gallop, displaced threes, halftime stomp, tremolo wall, syncopated stabs, 7/8 as 2+2+3, five over four), a seeded generator with accent grouping, polymeter cycles, rests, ties, palm mute, accents and harmony changes only on accents, picking guidance (alternate on the 16th grid, downstrokes mode, fast runs on their own subdivision), validation with a tooFastForProfile warning, mutate and lock-aware regenerate, grid editing helpers, and pure playback and MIDI note events. Built alongside the M1 engine core; the Rhythm Lab UI follows separately. Co-Authored-By: Claude Opus 5.5 --- engine/index.test.ts | 2 + engine/index.ts | 1 + engine/rhythm/edit.test.ts | 265 +++++++++++++++ engine/rhythm/edit.ts | 134 ++++++++ engine/rhythm/events.ts | 123 +++++++ engine/rhythm/generate.test.ts | 264 +++++++++++++++ engine/rhythm/generate.ts | 550 ++++++++++++++++++++++++++++++++ engine/rhythm/grid.test.ts | 160 ++++++++++ engine/rhythm/grid.ts | 164 ++++++++++ engine/rhythm/index.ts | 22 ++ engine/rhythm/picking.test.ts | 115 +++++++ engine/rhythm/picking.ts | 62 ++++ engine/rhythm/playback.test.ts | 147 +++++++++ engine/rhythm/playback.ts | 133 ++++++++ engine/rhythm/styles.test.ts | 124 +++++++ engine/rhythm/styles.ts | 291 +++++++++++++++++ engine/rhythm/validate.test.ts | 122 +++++++ engine/rhythm/validate.ts | 93 ++++++ engine/rhythm/variation.test.ts | 205 ++++++++++++ engine/rhythm/variation.ts | 285 +++++++++++++++++ 20 files changed, 3262 insertions(+) create mode 100644 engine/rhythm/edit.test.ts create mode 100644 engine/rhythm/edit.ts create mode 100644 engine/rhythm/events.ts create mode 100644 engine/rhythm/generate.test.ts create mode 100644 engine/rhythm/generate.ts create mode 100644 engine/rhythm/grid.test.ts create mode 100644 engine/rhythm/grid.ts create mode 100644 engine/rhythm/index.ts create mode 100644 engine/rhythm/picking.test.ts create mode 100644 engine/rhythm/picking.ts create mode 100644 engine/rhythm/playback.test.ts create mode 100644 engine/rhythm/playback.ts create mode 100644 engine/rhythm/styles.test.ts create mode 100644 engine/rhythm/styles.ts create mode 100644 engine/rhythm/validate.test.ts create mode 100644 engine/rhythm/validate.ts create mode 100644 engine/rhythm/variation.test.ts create mode 100644 engine/rhythm/variation.ts diff --git a/engine/index.test.ts b/engine/index.test.ts index db0f99c..4d0a654 100644 --- a/engine/index.test.ts +++ b/engine/index.test.ts @@ -10,6 +10,8 @@ describe('engine barrel', () => { 'generateVoicings', 'findClosestVoicing', 'optimizeSequence', + 'generateRhythm', + 'rhythmToPlaybackEvents', 'fromLegacyNotes', 'nameVoicing', 'DEFAULT_HAND_PROFILE' diff --git a/engine/index.ts b/engine/index.ts index 621e43d..baff5cd 100644 --- a/engine/index.ts +++ b/engine/index.ts @@ -16,3 +16,4 @@ export * from './naming'; export * from './playability'; export * from './generateVoicings'; export * from './transition'; +export * from './rhythm'; diff --git a/engine/rhythm/edit.test.ts b/engine/rhythm/edit.test.ts new file mode 100644 index 0000000..dc0c003 --- /dev/null +++ b/engine/rhythm/edit.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect } from 'vitest'; +import { clearHit, cycleAccent, eventAtUnit, setSlotCount, toggleHit, togglePalmMute } from './edit'; +import { generateRhythm } from './generate'; +import { RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles'; +import { validateRhythm } from './validate'; +import { patternUnitCount } from './grid'; +import { createRng } from '../random'; +import type { HitTarget, RhythmEvent, RhythmParams, RhythmPattern } from '../types'; + +const SLOT0: HitTarget = { kind: 'slot', slot: 0 }; +const SLOT1: HitTarget = { kind: 'slot', slot: 1 }; +const PEDAL: HitTarget = { kind: 'pedal' }; +const DYAD0: HitTarget = { kind: 'dyad', slot: 0 }; + +const ev = (tick: number, durationTicks: number, target: HitTarget, extra: Partial = {}): RhythmEvent => ({ + tick, + durationTicks, + target, + palmMute: false, + accent: 0, + pick: (tick / 120) % 2 === 0 ? 'down' : 'up', + ...extra +}); + +/** One bar of 4/4: accented stab tied over beat 2, a pedal chug, then silence. */ +const sparse = (params: Partial = {}): RhythmPattern => ({ + id: 'sparse', + name: 'Sparse', + seed: 'sparse', + meter: { numerator: 4, denominator: 4 }, + bars: 1, + ppq: 480, + tags: [], + params: { ...defaultRhythmParams('chugEngine'), bars: 1, ...params }, + events: [ + ev(0, 480, SLOT0, { accent: 2 }), + ev(480, 240, SLOT0, { tie: true }), + ev(960, 120, PEDAL, { palmMute: true }) + ] +}); + +const expectValid = (p: RhythmPattern) => expect(validateRhythm(p).errors).toEqual([]); + +/** One beat of 32nd tremolo on dyad 0, then a palm-muted pedal. */ +const tremolo = (): RhythmPattern => ({ + ...sparse(), + id: 'tremolo', + events: [ + ...[0, 60, 120, 180, 240, 300, 360, 420].map((tick, i) => + ev(tick, 60, DYAD0, { accent: i === 0 ? 2 : 0, pick: i % 2 === 0 ? 'down' : 'up' }) + ), + ev(480, 120, PEDAL, { palmMute: true }) + ] +}); + +const inCell = (p: RhythmPattern, unit: number) => p.events.filter((e) => e.tick >= unit * 120 && e.tick < (unit + 1) * 120); + +describe('eventAtUnit', () => { + it('finds events by onset unit on the pattern grid', () => { + const p = sparse(); + expect(eventAtUnit(p, 0)?.target).toEqual(SLOT0); + expect(eventAtUnit(p, 4)?.tie).toBe(true); + expect(eventAtUnit(p, 8)?.target).toEqual(PEDAL); + expect(eventAtUnit(p, 1)).toBeUndefined(); + expect(eventAtUnit(p, 99)).toBeUndefined(); + expect(eventAtUnit(p, -1)).toBeUndefined(); + }); +}); + +describe('toggleHit', () => { + it('adds a hit in an empty unit, ringing until the next onset or one beat', () => { + expectValid(sparse()); + const p = toggleHit(sparse(), 12, SLOT1); + expectValid(p); + expect(eventAtUnit(p, 12)).toMatchObject({ tick: 1440, durationTicks: 480, target: SLOT1, accent: 0 }); + const q = toggleHit(p, 10, PEDAL); + expect(eventAtUnit(q, 10)).toMatchObject({ durationTicks: 240, palmMute: true, pick: 'down' }); + expectValid(q); + }); + + it('removes the hit when toggled again with the same target', () => { + const p = toggleHit(toggleHit(sparse(), 12, SLOT1), 12, SLOT1); + expect(p.events).toEqual(sparse().events); + }); + + it('replaces the target of an existing hit and keeps its tie following it', () => { + const p = toggleHit(sparse(), 0, SLOT1); + expectValid(p); + expect(eventAtUnit(p, 0)).toMatchObject({ target: SLOT1, accent: 2 }); + expect(eventAtUnit(p, 4)).toMatchObject({ target: SLOT1, tie: true }); + }); + + it('truncates a ringing event when a hit lands inside it and drops that event\'s ties', () => { + const p = toggleHit(sparse(), 2, PEDAL); + expectValid(p); + expect(eventAtUnit(p, 0)?.durationTicks).toBe(240); + expect(eventAtUnit(p, 2)).toMatchObject({ tick: 240, durationTicks: 240, palmMute: true }); + expect(eventAtUnit(p, 4)).toBeUndefined(); + expect(p.events.map((e) => e.tick)).toEqual([0, 240, 960]); + }); + + it('never hands a chord\'s sustain to a palm-muted hit inserted inside it', () => { + const base = generateRhythm(defaultRhythmParams('halftimeStomp'), 'a'); + expect(base.events[1]).toMatchObject({ tick: 480, tie: true }); + const p = toggleHit(base, 2, PEDAL); + expectValid(p); + expect(eventAtUnit(p, 2)).toMatchObject({ durationTicks: 240, palmMute: true }); + expect(p.events.some((e) => e.tie && e.palmMute)).toBe(false); + expect(eventAtUnit(p, 4)).toBeUndefined(); + }); + + it('turns a tie into a new attack for a different target and removes it for the same one', () => { + const attack = toggleHit(sparse(), 4, PEDAL); + expectValid(attack); + expect(eventAtUnit(attack, 4)?.tie).toBeUndefined(); + expect(eventAtUnit(attack, 4)?.target).toEqual(PEDAL); + const removed = toggleHit(sparse(), 4, SLOT0); + expect(eventAtUnit(removed, 4)).toBeUndefined(); + expectValid(removed); + }); + + it('ignores invalid units and slots outside slotCount', () => { + const base = sparse(); + expect(toggleHit(base, 16, PEDAL).events).toEqual(base.events); + expect(toggleHit(base, 1.5, PEDAL).events).toEqual(base.events); + expect(toggleHit(base, 3, { kind: 'slot', slot: 2 }).events).toEqual(base.events); + }); +}); + +describe('32nd tremolo cells', () => { + it('reports, clears and toggles every 32nd in a grid cell', () => { + const t = tremolo(); + expectValid(t); + expect(eventAtUnit(t, 1)?.tick).toBe(120); + const cleared = clearHit(t, 1); + expectValid(cleared); + expect(inCell(cleared, 1)).toEqual([]); + expect(eventAtUnit(cleared, 1)).toBeUndefined(); + const toggled = toggleHit(t, 1, DYAD0); + expectValid(toggled); + expect(inCell(toggled, 1)).toEqual([]); + }); + + it('retargets and palm-mutes the whole cell', () => { + const t = tremolo(); + const retargeted = toggleHit(t, 1, SLOT1); + expectValid(retargeted); + expect(inCell(retargeted, 1).map((e) => e.target)).toEqual([SLOT1, SLOT1]); + const muted = togglePalmMute(t, 2); + expectValid(muted); + expect(inCell(muted, 2).map((e) => e.palmMute)).toEqual([true, true]); + }); + + it('finds a 32nd that sits between grid units', () => { + const t = { ...tremolo(), events: tremolo().events.filter((e) => e.tick !== 120) }; + expectValid(t); + expect(eventAtUnit(t, 1)?.tick).toBe(180); + expect(inCell(clearHit(t, 1), 1)).toEqual([]); + expect(inCell(toggleHit(t, 1, DYAD0), 1)).toEqual([]); + }); + + it('keeps the tremolo pendulum after a cell is cleared', () => { + const p = clearHit(generateRhythm(defaultRhythmParams('tremoloWall'), 'riff-1'), 1); + expectValid(p); + expect(eventAtUnit(p, 1)).toBeUndefined(); + expect(inCell(p, 1)).toEqual([]); + expect(p.events.filter((e) => e.tick % 120 === 0 && e.tick < 1920).every((e) => e.tick % 480 !== 0 || e.pick === 'down')).toBe(true); + }); +}); + +describe('setSlotCount', () => { + const base = generateRhythm({ ...defaultRhythmParams('syncopatedStabs'), slotCount: 4 }, 'k1'); + const slots = (p: RhythmPattern) => p.events.map((e) => (e.target.kind === 'slot' || e.target.kind === 'dyad' ? e.target.slot : -1)); + const rhythm = (p: RhythmPattern) => p.events.map((e) => [e.tick, e.durationTicks, e.accent, e.palmMute, !!e.tie, e.target.kind]); + + it('folds slots that no longer exist back into range, keeping the rhythm', () => { + expect(slots(base).some((s) => s >= 2)).toBe(true); + const reduced = setSlotCount(base, 2); + expectValid(reduced); + expect(reduced.params.slotCount).toBe(2); + expect(rhythm(reduced)).toEqual(rhythm(base)); + expect(slots(reduced)).toEqual(slots(base).map((s) => (s < 0 ? s : s % 2))); + expect(setSlotCount(base, 4).events).toEqual(base.events); + expect(setSlotCount(base, 9).params.slotCount).toBe(4); + }); + + it('lets every edit helper recover a pattern whose slotCount was lowered by hand', () => { + const stale: RhythmPattern = { ...base, params: { ...base.params, slotCount: 2 } }; + expect(validateRhythm(stale).ok).toBe(false); + expectValid(toggleHit(stale, 7, PEDAL)); + expectValid(clearHit(stale, 3)); + expectValid(cycleAccent(stale, 0)); + expectValid(togglePalmMute(stale, 5)); + }); +}); + +describe('cycleAccent, togglePalmMute, clearHit', () => { + it('cycles accents 2 -> 0 -> 1 -> 2 and leaves ties alone', () => { + const a = cycleAccent(sparse(), 0); + expect(eventAtUnit(a, 0)?.accent).toBe(0); + expect(eventAtUnit(cycleAccent(a, 0), 0)?.accent).toBe(1); + expect(eventAtUnit(cycleAccent(cycleAccent(a, 0), 0), 0)?.accent).toBe(2); + expect(cycleAccent(sparse(), 4).events).toEqual(sparse().events); + }); + + it('toggles palm mute and leaves dead notes and ties alone', () => { + const p = togglePalmMute(sparse(), 8); + expect(eventAtUnit(p, 8)?.palmMute).toBe(false); + expect(eventAtUnit(togglePalmMute(p, 8), 8)?.palmMute).toBe(true); + const dead = toggleHit(sparse(), 8, { kind: 'dead' }); + expect(eventAtUnit(togglePalmMute(dead, 8), 8)?.palmMute).toBe(false); + expect(togglePalmMute(sparse(), 4).events).toEqual(sparse().events); + }); + + it('clears a hit and drops ties that depended on it', () => { + const p = clearHit(sparse({ anticipation: true }), 0); + expect(p.events.map((e) => e.tick)).toEqual([960]); + expectValid(p); + }); + + it('switches anticipation on when the first beat is emptied', () => { + const p = clearHit(sparse(), 0); + expect(p.params.anticipation).toBe(true); + expectValid(p); + expect(sparse().params.anticipation).toBe(false); + }); + + it('recomputes picks for the mode', () => { + const down = sparse({ picking: 'downstrokes' }); + const p = toggleHit({ ...down, events: down.events.map((e) => ({ ...e, pick: 'down' as const })) }, 3, PEDAL); + expect(p.events.every((e) => e.pick === 'down')).toBe(true); + const alt = toggleHit(sparse(), 3, PEDAL); + expect(eventAtUnit(alt, 3)?.pick).toBe('up'); + }); +}); + +describe('edit helpers on generated patterns', () => { + it('keep every style valid through a random sequence of edits, without mutating inputs', () => { + const targets: HitTarget[] = [PEDAL, SLOT0, SLOT1, { kind: 'dyad', slot: 0 }, { kind: 'dead' }]; + for (const style of RHYTHM_STYLE_IDS) { + for (const grid of ['16th', '16th-triplet'] as const) { + let p = generateRhythm({ ...defaultRhythmParams(style), grid }, `edit-${style}`); + const rng = createRng(`edits-${style}-${grid}`); + const units = patternUnitCount(p); + for (let step = 0; step < 60; step++) { + const before = JSON.stringify(p); + const unit = rng.int(0, units - 1); + const op = rng.int(0, 3); + const next = + op === 0 + ? toggleHit(p, unit, rng.pick(targets)) + : op === 1 + ? cycleAccent(p, unit) + : op === 2 + ? togglePalmMute(p, unit) + : clearHit(p, unit); + expect(JSON.stringify(p)).toBe(before); + expect(next).not.toBe(p); + expectValid(next); + p = next; + } + } + } + }); +}); diff --git a/engine/rhythm/edit.ts b/engine/rhythm/edit.ts new file mode 100644 index 0000000..8686858 --- /dev/null +++ b/engine/rhythm/edit.ts @@ -0,0 +1,134 @@ +// Grid-editing helpers for the UI. `unit` = grid unit index from the pattern start in the pattern's grid. +// Every helper returns a new, valid pattern and never mutates its input; an invalid request returns an +// unchanged copy. +import type { HitTarget, RhythmEvent, RhythmPattern } from '../types'; +import { PPQ, gridUnitTicks, patternLengthTicks, patternUnitCount } from './grid'; +import { + copyEvent, + finalizeEvents, + finalizeOptions, + hasFirstBeatAttack, + isSlotTarget, + makeEvent, + sameTarget +} from './events'; + +const unitTick = (pattern: RhythmPattern, unit: number): number | null => + Number.isInteger(unit) && unit >= 0 && unit < patternUnitCount(pattern) ? unit * gridUnitTicks(pattern.params.grid) : null; + +const isUsableTarget = (pattern: RhythmPattern, target: HitTarget): boolean => { + if (isSlotTarget(target)) return Number.isInteger(target.slot) && target.slot >= 0 && target.slot < pattern.params.slotCount; + return target.kind === 'pedal' || target.kind === 'dead'; +}; + +/** + * Re-derives durations, ties and picks. Emptying the first beat by hand switches params.anticipation on, + * since the player has deliberately made a pickup. + */ +const rebuild = (pattern: RhythmPattern, events: readonly RhythmEvent[]): RhythmPattern => { + const finalized = finalizeEvents(events, finalizeOptions(pattern)); + const anticipation = pattern.params.anticipation || !hasFirstBeatAttack(finalized, pattern); + return { + ...pattern, + events: finalized, + tags: [...pattern.tags], + params: anticipation === pattern.params.anticipation ? pattern.params : { ...pattern.params, anticipation } + }; +}; + +/** + * Events whose onset lies in this grid cell, in pattern order: usually one, two in a 32nd tremolo cell + * (32nds between units belong to the cell they start in). + */ +export const eventsInUnit = (pattern: RhythmPattern, unit: number): RhythmEvent[] => { + const tick = unitTick(pattern, unit); + if (tick === null) return []; + const end = tick + gridUnitTicks(pattern.params.grid); + return pattern.events.filter((e) => e.tick >= tick && e.tick < end); +}; + +/** First event of this grid cell (see eventsInUnit). */ +export const eventAtUnit = (pattern: RhythmPattern, unit: number): RhythmEvent | undefined => eventsInUnit(pattern, unit)[0]; + +/** Pedal hits added by hand follow the style's palm-mute habit. Heuristic. */ +const defaultPalmMute = (pattern: RhythmPattern, target: HitTarget): boolean => + target.kind === 'pedal' && pattern.params.palmMuteRatio >= 0.5; + +/** + * Edits address whole grid cells. Empty cell: adds an unaccented attack ringing until the next event, at + * most one quarter note; ties right after it sustained the event it interrupts and are dropped, so the new + * hit never inherits that sound. Same target as the cell's first event (attack or tie): clears the cell. + * Different target: retargets every attack in the cell (keeping accent, palm mute and length) and turns a + * leading tie into a fresh attack. Ties after a changed event follow its new target; ties left without a + * source are dropped. + */ +export const toggleHit = (pattern: RhythmPattern, unit: number, target: HitTarget): RhythmPattern => { + const tick = unitTick(pattern, unit); + if (tick === null || !isUsableTarget(pattern, target)) return rebuild(pattern, pattern.events); + const cell = new Set(eventsInUnit(pattern, unit)); + const events = pattern.events.map(copyEvent); + const indices = pattern.events.flatMap((e, i) => (cell.has(e) ? [i] : [])); + if (indices.length === 0) { + const nextIndex = pattern.events.findIndex((e) => e.tick > tick); + const length = patternLengthTicks(pattern); + const end = Math.min(nextIndex >= 0 ? pattern.events[nextIndex].tick : length, tick + PPQ, length); + let stop = nextIndex; + while (stop >= 0 && stop < events.length && events[stop].tie) stop++; + const kept = nextIndex < 0 ? events : [...events.slice(0, nextIndex), ...events.slice(stop)]; + return rebuild(pattern, [...kept, makeEvent(tick, end - tick, target, defaultPalmMute(pattern, target), 0)]); + } + if (sameTarget(events[indices[0]].target, target)) { + return rebuild(pattern, events.filter((_, i) => !indices.includes(i))); + } + indices.forEach((i, k) => { + const e = events[i]; + if (!e.tie) { + events[i] = makeEvent(e.tick, e.durationTicks, target, target.kind === 'dead' ? false : e.palmMute, e.accent); + } else if (k === 0) { + events[i] = makeEvent(e.tick, e.durationTicks, target, defaultPalmMute(pattern, target), 0); + } + }); + return rebuild(pattern, events); +}; + +/** 0 -> 1 -> 2 -> 0 on the cell's first event when it is an attack; ties and empty cells are left alone. */ +export const cycleAccent = (pattern: RhythmPattern, unit: number): RhythmPattern => { + const target = eventAtUnit(pattern, unit); + if (!target || target.tie) return rebuild(pattern, pattern.events); + return rebuild( + pattern, + pattern.events.map((e) => (e === target ? { ...copyEvent(e), accent: ((e.accent + 1) % 3) as 0 | 1 | 2 } : copyEvent(e))) + ); +}; + +/** + * Flips palm mute on the cell's first attack and sets every other attack in the cell to match; a cell that + * starts with a dead note or a tie, and an empty cell, is left alone (dead notes are never palm-muted). + */ +export const togglePalmMute = (pattern: RhythmPattern, unit: number): RhythmPattern => { + const first = eventAtUnit(pattern, unit); + if (!first || first.tie || first.target.kind === 'dead') return rebuild(pattern, pattern.events); + const cell = new Set(eventsInUnit(pattern, unit)); + const value = !first.palmMute; + return rebuild( + pattern, + pattern.events.map((e) => + cell.has(e) && !e.tie && e.target.kind !== 'dead' ? { ...copyEvent(e), palmMute: value } : copyEvent(e) + ) + ); +}; + +/** Removes every event of this cell, leaving a rest; ties that sustained them are dropped too. */ +export const clearHit = (pattern: RhythmPattern, unit: number): RhythmPattern => { + const cell = new Set(eventsInUnit(pattern, unit)); + return rebuild(pattern, pattern.events.filter((e) => !cell.has(e))); +}; + +/** + * Sets params.slotCount (rounded and clamped to 1..4; a non-number keeps the current count) and folds slot + * targets that no longer exist back into range with slot % slotCount, e.g. when the user drops voicings. + */ +export const setSlotCount = (pattern: RhythmPattern, slotCount: number): RhythmPattern => { + const count = Number.isFinite(slotCount) ? Math.min(4, Math.max(1, Math.round(slotCount))) : pattern.params.slotCount; + return rebuild({ ...pattern, params: { ...pattern.params, slotCount: count } }, pattern.events); +}; diff --git a/engine/rhythm/events.ts b/engine/rhythm/events.ts new file mode 100644 index 0000000..5853efa --- /dev/null +++ b/engine/rhythm/events.ts @@ -0,0 +1,123 @@ +// Event-list normalisation shared by the generator, the edit helpers and the variation tools. +import type { HitTarget, PickingMode, RhythmEvent, RhythmPattern } from '../types'; +import { assignPicks } from './picking'; +import { barTicks, firstBeatTicks, patternLengthTicks } from './grid'; + +export const isSlotTarget = (target: HitTarget): target is Extract => + target.kind === 'slot' || target.kind === 'dyad'; + +export const slotTarget = (kind: 'slot' | 'dyad', slot: number): HitTarget => + kind === 'slot' ? { kind: 'slot', slot } : { kind: 'dyad', slot }; + +export const copyTarget = (target: HitTarget): HitTarget => ({ ...target }); + +export const sameTarget = (a: HitTarget, b: HitTarget): boolean => + a.kind === b.kind && (!isSlotTarget(a) || (isSlotTarget(b) && a.slot === b.slot)); + +/** Canonical event object: `tie` is present only on ties. */ +export const makeEvent = ( + tick: number, + durationTicks: number, + target: HitTarget, + palmMute: boolean, + accent: 0 | 1 | 2, + pick: 'down' | 'up' = 'down', + tie = false +): RhythmEvent => + tie + ? { tick, durationTicks, target: copyTarget(target), palmMute, accent, pick, tie: true } + : { tick, durationTicks, target: copyTarget(target), palmMute, accent, pick }; + +export const copyEvent = (e: RhythmEvent): RhythmEvent => + makeEvent(e.tick, e.durationTicks, e.target, e.palmMute, e.accent, e.pick, e.tie === true); + +export const sameEvents = (a: readonly RhythmEvent[], b: readonly RhythmEvent[]): boolean => + a.length === b.length && + a.every((e, i) => { + const o = b[i]; + return ( + e.tick === o.tick && + e.durationTicks === o.durationTicks && + sameTarget(e.target, o.target) && + e.palmMute === o.palmMute && + e.accent === o.accent && + e.pick === o.pick && + !!e.tie === !!o.tie + ); + }); + +export interface FinalizeOptions { + lengthTicks: number; + barTicks: number; + picking: PickingMode; + /** When set, slot targets outside 0..slotCount-1 are folded back with slot % slotCount. */ + slotCount?: number; +} + +const slotInRange = (slot: number, slotCount: number): boolean => Number.isInteger(slot) && slot >= 0 && slot < slotCount; + +/** slot % slotCount keeps a lowered slot count's chord changes audible and consistent within each accent. */ +const foldSlot = (target: HitTarget, slotCount: number | undefined): HitTarget => { + if (slotCount === undefined || !isSlotTarget(target) || slotInRange(target.slot, slotCount)) return target; + const slot = Number.isInteger(target.slot) && target.slot >= 0 ? target.slot % Math.max(1, Math.floor(slotCount)) : 0; + return slotTarget(target.kind, slot); +}; + +/** + * Sorts, drops events outside the pattern, keeps the first event per tick, caps durations at the next onset + * and the pattern end (never extends them, so a removed hit leaves a rest), drops ties that no longer continue + * the previous event, makes surviving ties copy their source's target and palm mute, folds out-of-range slots + * (opts.slotCount), then assigns picks. + */ +export const finalizeEvents = (input: readonly RhythmEvent[], opts: FinalizeOptions): RhythmEvent[] => { + const sorted = input + .map((e, i) => ({ e, i })) + .filter(({ e }) => Number.isInteger(e.tick) && e.tick >= 0 && e.tick < opts.lengthTicks && e.durationTicks > 0) + .sort((a, b) => a.e.tick - b.e.tick || a.i - b.i) + .map(({ e }) => e) + .filter((e, i, list) => i === 0 || e.tick !== list[i - 1].tick); + + const out: RhythmEvent[] = []; + sorted.forEach((e, i) => { + const limit = (i + 1 < sorted.length ? sorted[i + 1].tick : opts.lengthTicks) - e.tick; + const duration = Math.min(Math.round(e.durationTicks), limit); + if (e.tie) { + const prev = out[out.length - 1]; + if (!prev || prev.tick + prev.durationTicks !== e.tick) return; + out.push(makeEvent(e.tick, duration, prev.target, prev.palmMute, 0, 'down', true)); + } else { + out.push(makeEvent(e.tick, duration, foldSlot(e.target, opts.slotCount), e.palmMute, e.accent, 'down')); + } + }); + + const picks = assignPicks(out, opts.picking, opts.barTicks); + return out.map((e, i) => ({ ...e, pick: picks[i] })); +}; + +export const finalizeOptions = (pattern: Pick): FinalizeOptions => ({ + lengthTicks: patternLengthTicks(pattern), + barTicks: barTicks(pattern.meter), + picking: pattern.params.picking, + slotCount: pattern.params.slotCount +}); + +/** The pattern itself when every slot target exists, else a copy with out-of-range slots folded back. */ +export const withSlotsInRange = (pattern: RhythmPattern): RhythmPattern => + pattern.events.every((e) => !isSlotTarget(e.target) || slotInRange(e.target.slot, pattern.params.slotCount)) + ? pattern + : { ...pattern, events: finalizeEvents(pattern.events, finalizeOptions(pattern)) }; + +/** Slot/dyad events whose slot differs from the previous chord event without being an accented attack. */ +export const unaccentedSlotChangeCount = (events: readonly RhythmEvent[]): number => { + let current: number | null = null; + let count = 0; + for (const e of events) { + if (!isSlotTarget(e.target)) continue; + if (current !== null && e.target.slot !== current && (e.tie || e.accent === 0)) count++; + current = e.target.slot; + } + return count; +}; + +export const hasFirstBeatAttack = (events: readonly RhythmEvent[], pattern: Pick): boolean => + events.some((e) => !e.tie && e.tick < firstBeatTicks(pattern.meter)); diff --git a/engine/rhythm/generate.test.ts b/engine/rhythm/generate.test.ts new file mode 100644 index 0000000..ef17127 --- /dev/null +++ b/engine/rhythm/generate.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect } from 'vitest'; +import { generateRhythm, sanitizeRhythmParams } from './generate'; +import { RHYTHM_STYLES, RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles'; +import { validateRhythm } from './validate'; +import { patternLengthTicks } from './grid'; +import { createRng } from '../random'; +import type { GroupingSpec, RhythmParams, RhythmPattern } from '../types'; + +const SEEDS = ['a', 'b', 'c', 'riff-1', 'riff-2', 'djent', 'x9', '42', 'night', 'sludge', 'z', 'Q7']; + +const expectValid = (pattern: RhythmPattern) => { + const result = validateRhythm(pattern); + expect(result.errors).toEqual([]); + expect(result.ok).toBe(true); +}; + +/** Slot index may only change on an accented attack; the first accented hit uses slot 0. */ +const expectHarmonyOnAccents = (pattern: RhythmPattern) => { + let current: number | null = null; + let firstAccent = true; + for (const e of pattern.events) { + if (e.target.kind !== 'slot' && e.target.kind !== 'dyad') continue; + if (!e.tie && e.accent > 0 && firstAccent) { + expect(e.target.slot).toBe(0); + firstAccent = false; + } + if (current !== null && e.target.slot !== current) { + expect(e.tie).toBeFalsy(); + expect(e.accent).toBeGreaterThan(0); + } + current = e.target.slot; + } +}; + +describe('RHYTHM_STYLES', () => { + it('defines every style with a label, description and default params', () => { + expect(RHYTHM_STYLE_IDS).toHaveLength(9); + for (const style of RHYTHM_STYLE_IDS) { + const info = RHYTHM_STYLES[style]; + expect(info.label.length).toBeGreaterThan(0); + expect(info.description.length).toBeGreaterThan(10); + expect(info.params.style).toBe(style); + expect(info.params.bars).toBe(2); + expect(info.params.slotCount).toBe(2); + } + }); + + it('hands out independent copies of the defaults', () => { + const a = defaultRhythmParams('fiveOverFour'); + (a.meter as { numerator: number }).numerator = 3; + expect(defaultRhythmParams('fiveOverFour').meter.numerator).toBe(4); + expect(RHYTHM_STYLES.fiveOverFour.params.meter.numerator).toBe(4); + }); + + it('uses downstrokes for the halftime stomp and alternate picking elsewhere', () => { + expect(defaultRhythmParams('halftimeStomp').picking).toBe('downstrokes'); + expect(defaultRhythmParams('gallop').picking).toBe('alternate'); + expect(defaultRhythmParams('tremoloWall').picking).toBe('alternate'); + }); +}); + +describe('generateRhythm validity', () => { + it.each(RHYTHM_STYLE_IDS)('%s: valid for every seed with default params', (style) => { + for (const seed of SEEDS) { + const pattern = generateRhythm(defaultRhythmParams(style), seed); + expectValid(pattern); + expectHarmonyOnAccents(pattern); + expect(pattern.events.length).toBeGreaterThan(0); + expect(pattern.ppq).toBe(480); + expect(pattern.id).toBe(`${style}-${seed}`); + expect(pattern.name.startsWith(RHYTHM_STYLES[style].label)).toBe(true); + expect(pattern.seed).toBe(seed); + } + }); + + it.each(RHYTHM_STYLE_IDS)('%s: valid on the triplet grid, with 1 and 4 bars, 1..4 slots and anticipation', (style) => { + const variants: Partial[] = [ + { grid: '16th-triplet' }, + { bars: 1 }, + { bars: 4 }, + { slotCount: 1 }, + { slotCount: 3 }, + { slotCount: 4, bars: 4 }, + { anticipation: true }, + { picking: 'downstrokes' }, + { density: 0, syncopation: 1, pedalRatio: 0, palmMuteRatio: 0 }, + { density: 1, syncopation: 1, pedalRatio: 1, palmMuteRatio: 1 }, + { grouping: 'random-odd' }, + { grouping: '3-3-2', cycleSixteenths: 7 }, + { meter: { numerator: 7, denominator: 8 } }, + { meter: { numerator: 5, denominator: 4 }, grid: '16th-triplet' } + ]; + for (const variant of variants) { + for (const seed of SEEDS.slice(0, 6)) { + const pattern = generateRhythm({ ...defaultRhythmParams(style), ...variant }, seed); + expectValid(pattern); + expectHarmonyOnAccents(pattern); + } + } + }); + + it('keeps every event inside the pattern and slots below slotCount', () => { + for (const style of RHYTHM_STYLE_IDS) { + for (const slotCount of [1, 2, 3, 4]) { + const pattern = generateRhythm({ ...defaultRhythmParams(style), slotCount, bars: 4 }, `slots-${slotCount}`); + const length = patternLengthTicks(pattern); + for (const e of pattern.events) { + expect(e.tick + e.durationTicks).toBeLessThanOrEqual(length); + if (e.target.kind === 'slot' || e.target.kind === 'dyad') expect(e.target.slot).toBeLessThan(slotCount); + } + } + } + }); + + it('uses more than one slot across a multi-bar pattern for most seeds', () => { + for (const style of RHYTHM_STYLE_IDS) { + const moving = SEEDS.filter((seed) => { + const p = generateRhythm({ ...defaultRhythmParams(style), bars: 4 }, seed); + return new Set(p.events.flatMap((e) => (e.target.kind === 'slot' || e.target.kind === 'dyad' ? [e.target.slot] : []))).size > 1; + }); + expect(moving.length).toBeGreaterThanOrEqual(SEEDS.length / 2); + } + }); + + it('plays every harmony slot whenever there are at least as many accents as slots', () => { + for (const style of RHYTHM_STYLE_IDS) { + for (const slotCount of [2, 3, 4]) { + for (const bars of [1, 2, 4]) { + for (const seed of SEEDS) { + const p = generateRhythm({ ...defaultRhythmParams(style), slotCount, bars }, seed); + const accents = p.events.filter((e) => !e.tie && e.accent > 0).length; + if (accents < slotCount) continue; + const used = new Set(p.events.flatMap((e) => (e.target.kind === 'slot' || e.target.kind === 'dyad' ? [e.target.slot] : []))); + expect(used.size, `${style} ${seed} slots ${slotCount} bars ${bars}`).toBe(slotCount); + } + } + } + } + }); + + it('includes ties, rests, palm mutes and accents somewhere in the vocabulary', () => { + const all = RHYTHM_STYLE_IDS.flatMap((style) => SEEDS.map((seed) => generateRhythm(defaultRhythmParams(style), seed))); + const events = all.flatMap((p) => p.events); + expect(events.some((e) => e.tie)).toBe(true); + expect(events.some((e) => e.palmMute)).toBe(true); + expect(events.some((e) => e.accent === 1)).toBe(true); + expect(events.some((e) => e.accent === 2)).toBe(true); + expect(events.some((e) => e.target.kind === 'dead')).toBe(true); + const hasRest = (p: RhythmPattern) => + p.events.some((e, i) => e.tick + e.durationTicks < (p.events[i + 1]?.tick ?? patternLengthTicks(p))); + expect(all.some(hasRest)).toBe(true); + }); +}); + +describe('generateRhythm determinism', () => { + it('returns deep-equal patterns for equal params and seed', () => { + for (const style of RHYTHM_STYLE_IDS) { + const params = defaultRhythmParams(style); + expect(generateRhythm(params, 'same')).toEqual(generateRhythm(defaultRhythmParams(style), 'same')); + } + }); + + it('usually differs between seeds', () => { + for (const style of RHYTHM_STYLE_IDS) { + const distinct = new Set(SEEDS.slice(0, 6).map((seed) => JSON.stringify(generateRhythm(defaultRhythmParams(style), seed).events))); + expect(distinct.size).toBeGreaterThanOrEqual(4); + } + }); + + it('does not mutate its input params', () => { + const params = defaultRhythmParams('gallop'); + const before = JSON.stringify(params); + generateRhythm(params, 'pure'); + expect(JSON.stringify(params)).toBe(before); + }); +}); + +describe('grids and meters', () => { + it('places 16th-grid events on 120 ticks (60 inside tremolo cells)', () => { + for (const style of RHYTHM_STYLE_IDS) { + for (const seed of SEEDS.slice(0, 4)) { + const pattern = generateRhythm(defaultRhythmParams(style), seed); + const step = style === 'tremoloWall' ? 60 : 120; + expect(pattern.events.every((e) => e.tick % step === 0 && e.durationTicks % step === 0)).toBe(true); + expect(pattern.tags).not.toContain('triplet'); + } + } + }); + + it('places triplet-grid events on 80-tick units and tags them', () => { + for (const style of RHYTHM_STYLE_IDS) { + const pattern = generateRhythm({ ...defaultRhythmParams(style), grid: '16th-triplet' }, 'trip'); + expect(pattern.events.every((e) => e.tick % 80 === 0 && e.durationTicks % 80 === 0)).toBe(true); + expect(pattern.tags).toContain('triplet'); + } + }); + + it('builds 7/8 bars of 1680 ticks grouped 2+2+3', () => { + const pattern = generateRhythm(defaultRhythmParams('sevenEight'), 'odd'); + expect(pattern.meter).toEqual({ numerator: 7, denominator: 8 }); + expect(patternLengthTicks(pattern)).toBe(3360); + expect(pattern.events[pattern.events.length - 1].tick).toBeLessThan(3360); + expect(pattern.cycleTicks).toBeUndefined(); + expect(pattern.tags).toContain('odd-meter'); + }); + + it('sets cycleTicks 600 for the five-over-four polymeter and drifts its accents across the barline', () => { + const pattern = generateRhythm(defaultRhythmParams('fiveOverFour'), 'poly'); + expect(pattern.cycleTicks).toBe(600); + expect(pattern.tags).toContain('polymeter'); + const strong = pattern.events.filter((e) => e.accent === 2).map((e) => e.tick); + expect(strong).toEqual([0, 600, 1200, 1800, 2400, 3000, 3600]); + const barOffsets = (bar: number) => strong.filter((t) => Math.floor(t / 1920) === bar).map((t) => t % 1920); + expect(barOffsets(0)).toEqual([0, 600, 1200, 1800]); + expect(barOffsets(1)).toEqual([480, 1080, 1680]); + }); +}); + +describe('generateRhythm fuzz', () => { + it('stays valid for 300 seeded random parameter combinations', () => { + const rng = createRng('fuzz'); + const groupings: GroupingSpec[] = ['even', '3-3-2', '3-3-3-3-4', 'random-odd', [3, 2], [2, 2, 3], [5]]; + for (let i = 0; i < 300; i++) { + const params: RhythmParams = { + ...defaultRhythmParams(rng.pick(RHYTHM_STYLE_IDS)), + meter: { numerator: rng.int(1, 13), denominator: rng.pick([4, 8] as const) }, + bars: rng.int(1, 5), + grid: rng.pick(['16th', '16th-triplet'] as const), + grouping: rng.pick(groupings), + density: rng.next(), + syncopation: rng.next(), + pedalRatio: rng.next(), + palmMuteRatio: rng.next(), + slotCount: rng.int(1, 4), + picking: rng.pick(['alternate', 'downstrokes'] as const), + anticipation: rng.chance(0.3), + ...(rng.chance(0.3) ? { cycleSixteenths: rng.int(2, 13) } : {}) + }; + const pattern = generateRhythm(params, `fuzz-${i}`); + expectValid(pattern); + expectHarmonyOnAccents(pattern); + } + }); +}); + +describe('sanitizeRhythmParams', () => { + it('clamps out-of-range params into a usable, valid pattern', () => { + const wild = { + ...defaultRhythmParams('chugEngine'), + bars: 0, + slotCount: 9, + density: 3, + syncopation: -1, + pedalRatio: Number.NaN, + cycleSixteenths: 1 + } as RhythmParams; + const clean = sanitizeRhythmParams(wild); + expect(clean).toMatchObject({ bars: 1, slotCount: 4, density: 1, syncopation: 0, pedalRatio: 0 }); + expect(clean.cycleSixteenths).toBeUndefined(); + const pattern = generateRhythm(wild, 'wild'); + expect(pattern.params).toEqual(clean); + expectValid(pattern); + }); +}); diff --git a/engine/rhythm/generate.ts b/engine/rhythm/generate.ts new file mode 100644 index 0000000..95d14c3 --- /dev/null +++ b/engine/rhythm/generate.ts @@ -0,0 +1,550 @@ +// Seeded rhythm generator: accent groups -> cells -> syncopation (offbeats, pushes, tie-overs) -> harmony. +// The first accent cycle is drawn as a motif and repeated with light variation, so riffs repeat like riffs +// and a polymeter figure stays audible against the bar. +import type { GroupingSpec, HitTarget, Meter, RhythmEvent, RhythmGrid, RhythmParams, RhythmPattern } from '../types'; +import { createRng, seedFromString, type Rng } from '../random'; +import { + PPQ, + THIRTY_SECOND_TICKS, + barTicks, + buildGroups, + gridUnitTicks, + isOddMeter, + isPolymeter, + type RhythmGroup +} from './grid'; +import { RHYTHM_STYLES, RHYTHM_STYLE_IDS, STYLE_RECIPES, type CellKind, type StyleRecipe } from './styles'; +import { finalizeEvents, makeEvent, slotTarget } from './events'; + +// --------------------------------------------------------------------------- +// Params +// --------------------------------------------------------------------------- + +const NAMED_GROUPINGS: readonly string[] = ['even', '3-3-2', '3-3-3-3-4', 'random-odd']; +const MAX_BARS = 64; +const MAX_CYCLE_UNITS = 1024; + +const clamp01 = (x: number): number => (Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : 0); +const clampInt = (x: number | undefined, lo: number, hi: number, fallback: number): number => + typeof x === 'number' && Number.isFinite(x) ? Math.min(hi, Math.max(lo, Math.round(x))) : fallback; + +const sanitizeGrouping = (grouping: GroupingSpec): GroupingSpec => { + if (typeof grouping === 'string') return NAMED_GROUPINGS.includes(grouping) ? grouping : 'even'; + const groups = Array.isArray(grouping) ? grouping.filter((g) => Number.isInteger(g) && g > 0) : []; + return groups.length > 0 ? groups : 'even'; +}; + +/** Clamps every param into its documented range; generateRhythm stores the result on the pattern. */ +export const sanitizeRhythmParams = (params: RhythmParams): RhythmParams => { + const meter: Meter = { + numerator: clampInt(params.meter?.numerator, 1, 32, 4), + denominator: params.meter?.denominator === 8 ? 8 : 4 + }; + const grid: RhythmGrid = params.grid === '16th-triplet' ? '16th-triplet' : '16th'; + const out: RhythmParams = { + style: RHYTHM_STYLE_IDS.includes(params.style) ? params.style : 'chugEngine', + meter, + bars: clampInt(params.bars, 1, MAX_BARS, 2), + grid, + grouping: sanitizeGrouping(params.grouping), + density: clamp01(params.density), + syncopation: clamp01(params.syncopation), + pedalRatio: clamp01(params.pedalRatio), + palmMuteRatio: clamp01(params.palmMuteRatio), + slotCount: clampInt(params.slotCount, 1, 4, 2), + picking: params.picking === 'downstrokes' ? 'downstrokes' : 'alternate', + anticipation: params.anticipation === true + }; + const cycle = params.cycleSixteenths; + if (typeof cycle === 'number' && Number.isFinite(cycle) && cycle >= 2) { + out.cycleSixteenths = Math.min(MAX_CYCLE_UNITS, Math.round(cycle)); + } + return out; +}; + +// --------------------------------------------------------------------------- +// Planning +// --------------------------------------------------------------------------- + +export interface GenContext { + params: RhythmParams; + recipe: StyleRecipe; + unitTicks: number; + barTicks: number; + lengthTicks: number; + triplet: boolean; +} + +export const makeContext = (params: RhythmParams): GenContext => { + const bar = barTicks(params.meter); + return { + params, + recipe: STYLE_RECIPES[params.style], + unitTicks: gridUnitTicks(params.grid), + barTicks: bar, + lengthTicks: params.bars * bar, + triplet: params.grid === '16th-triplet' + }; +}; + +/** Decisions for one accent group. Plans of the first cycle are copied to later cycles. */ +export interface GroupPlan { + kind: CellKind; + /** Halftime weak-beat figure, 0..3. */ + variant: number; + /** Rest on the group start; the head moves to the next hit (syncopation). */ + offbeat: boolean; + /** Accent the head (cycle starts, stabs and strong halftime beats are accented regardless). */ + accent: boolean; + /** An accented head advances the harmony to the next slot. */ + move: boolean; + fillPedal: boolean; + palmMute: boolean; + /** Tremolo in 32nds instead of 16ths (16th grid only). */ + fine: boolean; + /** Stab choked after at most two units instead of ringing through the group. */ + choke: boolean; + /** Anticipate the head by this many units; a tie keeps it sounding through the group start. */ + push: number; + /** Let a ringing head sustain into a silent next group through a tie. */ + tieOver: boolean; + deadSeed: number; + /** The style's characteristic figure; neighbours never push into it. */ + signature: boolean; +} + +const CELL_ORDER: readonly CellKind[] = [ + 'chug16', + 'tripletChug', + 'tremolo', + 'gallop', + 'reverseGallop', + 'chug8', + 'halftime', + 'stab', + 'rest' +]; + +/** Approximate hits per grid unit; cells close to params.density are favoured. */ +const CELL_DENSITY: Readonly> = { + chug16: 1, + tripletChug: 1, + tremolo: 1, + gallop: 0.75, + reverseGallop: 0.75, + chug8: 0.5, + halftime: 0.3, + stab: 0.25, + rest: 0 +}; + +const DENSITY_SIGMA_SQ2 = 2 * 0.3 * 0.3; + +const densityFit = (kind: CellKind, density: number): number => + Math.max(0.05, Math.exp(-((CELL_DENSITY[kind] - density) ** 2) / DENSITY_SIGMA_SQ2)); + +const cellWeight = (kind: CellKind, group: RhythmGroup, ctx: GenContext): number => { + const { params, recipe } = ctx; + let weight = (recipe.cells[kind] ?? 0) * densityFit(kind, params.density); + if (kind === 'rest') weight *= group.indexInCycle === 0 ? 0 : 0.5 + params.syncopation; + if ((kind === 'gallop' || kind === 'reverseGallop') && group.length < 3) weight = 0; + return weight; +}; + +export const isHalftimeStrong = (group: RhythmGroup, ctx: GenContext): boolean => { + const offset = (group.startUnit * ctx.unitTicks) % ctx.barTicks; + return offset === 0 || offset * 2 === ctx.barTicks; +}; + +/** Draws one plan. Every random value is drawn unconditionally so a param tweak only changes what it touches. */ +export const drawPlan = (rng: Rng, group: RhythmGroup, ctx: GenContext): GroupPlan => { + const { params, recipe } = ctx; + const cycleStart = group.indexInCycle === 0; + const kinds = CELL_ORDER.filter((k) => (recipe.cells[k] ?? 0) > 0); + const weights = kinds.map((k) => cellWeight(k, group, ctx)); + let kind = rng.weighted(kinds, weights); + if (kind === 'rest' && cycleStart) kind = kinds.find((k) => k !== 'rest') ?? 'chug8'; + if (kind === 'chug16' && ctx.triplet) kind = 'tripletChug'; + + const offbeatRoll = rng.next(); + const accentRoll = rng.next(); + const moveRoll = rng.next(); + const pedalRoll = rng.next(); + const muteRoll = rng.next(); + const fineRoll = rng.next(); + const chokeRoll = rng.next(); + const variant = rng.weighted([0, 1, 2, 3], [1.2 - params.density, 0.4 + params.density, 0.5, 0.4 + 0.5 * params.density]); + const pushRoll = rng.next(); + const pushSizeRoll = rng.next(); + const tieRoll = rng.next(); + const deadSeed = rng.int(0, 0x7fffffff); + + const offbeat = !cycleStart && kind !== 'rest' && group.length >= 2 && offbeatRoll < params.syncopation * recipe.offbeatRate; + const accentRate = offbeat ? Math.min(1, recipe.accentRate + 0.3) : recipe.accentRate; + const choke = kind === 'stab' && chokeRoll < recipe.stabChoke; + const canPush = !offbeat && kind !== 'rest' && kind !== 'tremolo'; + return { + kind, + variant, + offbeat, + accent: cycleStart || accentRoll < accentRate, + move: moveRoll < recipe.harmonyMoveRate, + fillPedal: pedalRoll < params.pedalRatio, + palmMute: muteRoll < params.palmMuteRatio, + fine: fineRoll < 0.3 + 0.6 * params.density, + choke, + push: canPush && pushRoll < params.syncopation * recipe.pushRate ? (pushSizeRoll < 0.5 ? 1 : 2) : 0, + tieOver: ((kind === 'stab' && !choke) || kind === 'halftime') && tieRoll < recipe.tieOverRate, + deadSeed, + signature: false + }; +}; + +const signatureKind = (ctx: GenContext): CellKind | null => { + const kind = ctx.recipe.signature; + return kind === 'chug16' && ctx.triplet ? 'tripletChug' : kind; +}; + +const isChug = (kind: CellKind) => kind === 'chug16' || kind === 'tripletChug'; + +const matchesSignature = (plan: GroupPlan, group: RhythmGroup, kind: CellKind, ctx: GenContext): boolean => { + if (plan.kind !== kind || plan.offbeat || plan.push > 0) return false; + if (kind === 'stab') return plan.choke && group.length >= 2; + if (group.length < 4) return false; + if (isChug(kind)) return plan.fillPedal && plan.palmMute; + if (kind === 'tremolo') return plan.fine || ctx.triplet; + return true; +}; + +/** Guarantees the style's characteristic figure appears in the motif (signature groups also skip dead notes). */ +const enforceSignature = (motif: GroupPlan[], cycleGroups: RhythmGroup[], ctx: GenContext): GroupPlan[] => { + const kind = signatureKind(ctx); + if (!kind) return motif; + const found = motif.findIndex((plan, i) => matchesSignature(plan, cycleGroups[i], kind, ctx)); + if (found >= 0) return motif.map((plan, i) => (i === found ? { ...plan, signature: true } : plan)); + const need = kind === 'stab' ? 2 : 4; + const long = cycleGroups.filter((g) => g.length >= need); + const pool = long.length > 0 ? long : cycleGroups.filter((g) => g.length >= 2); + if (pool.length === 0) return motif; + const target = pool.find((g) => g.indexInCycle > 0) ?? pool[0]; + return motif.map((plan, i) => + i !== target.indexInCycle + ? plan + : { + ...plan, + kind, + offbeat: false, + push: 0, + signature: true, + choke: kind === 'stab' ? true : plan.choke, + tieOver: kind === 'stab' ? false : plan.tieOver, + fillPedal: isChug(kind) ? true : plan.fillPedal, + palmMute: isChug(kind) ? true : plan.palmMute, + fine: kind === 'tremolo' ? true : plan.fine + } + ); +}; + +const planGroups = (groups: RhythmGroup[], ctx: GenContext, rng: Rng): GroupPlan[] => { + const cycle0 = groups.filter((g) => g.cycle === 0); + const motif = enforceSignature( + cycle0.map((g) => drawPlan(rng.fork(`motif-${g.indexInCycle}`), g, ctx)), + cycle0, + ctx + ); + const lastCycle = groups[groups.length - 1].cycle; + const turnIndex = + lastCycle > 0 ? groups.reduce((idx, g) => (g.cycle === lastCycle && !g.truncated ? g.index : idx), -1) : -1; + + let plans = groups.map((g) => { + if (g.cycle === 0) return motif[g.indexInCycle]; + const vary = rng.fork(`vary-${g.index}`); + const redraw = vary.chance(ctx.recipe.variation); + const turn = g.index === turnIndex && vary.chance(ctx.recipe.turnaround); + return redraw || turn ? drawPlan(vary, g, ctx) : { ...motif[g.indexInCycle] }; + }); + + if (ctx.recipe.halftimeStrong) { + plans = plans.map((plan, i) => + isHalftimeStrong(groups[i], ctx) ? { ...plan, kind: 'halftime', offbeat: false, push: 0, accent: true } : plan + ); + } + + // Anticipation: sometimes leave the very first downbeat empty (pickup feel). + const first = plans[0]; + if ( + ctx.params.anticipation && + rng.fork('anticipation').chance(0.5) && + first.kind !== 'rest' && + !first.signature && + groups[0].length >= 2 + ) { + plans[0] = { ...first, offbeat: true, push: 0 }; + } + return plans; +}; + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +export interface RawHit { + tick: number; + dur: number; + role: 'head' | 'fill' | 'tie'; + group: number; + dead: boolean; + tremolo: boolean; +} + +/** [offset, length] in grid units within a group. */ +type Segment = readonly [number, number]; + +const repeatFigure = (figure: readonly number[], n: number): Segment[] => { + const segments: Segment[] = []; + for (let pos = 0, i = 0; pos < n; i++) { + const length = Math.min(figure[i % figure.length], n - pos); + segments.push([pos, length]); + pos += length; + } + return segments; +}; + +const chokeUnits = (n: number): number => Math.max(1, Math.min(2, n - 1)); + +/** Weak beats of a halftime bar: 0 rest, 1 two 16ths into the next beat, 2 one 8th, 3 an 8th pickup. */ +const halftimeWeak = (variant: number, n: number): Segment[] => { + switch (variant) { + case 1: + return n >= 3 ? [[n - 2, 1], [n - 1, 1]] : [[n - 1, 1]]; + case 2: + return [[0, Math.min(2, n)]]; + case 3: + return n >= 3 ? [[n - 2, 2]] : [[0, n]]; + default: + return []; + } +}; + +/** + * Cell figures, adapted to the group length n: + * - chug16 / tripletChug: every unit. chug8: every 2 units (on the triplet grid: 8th-note triplets). + * - gallop: 8th + 16th + 16th repeated (n = 3: 8th + 16th; triplet grid: long-short); n <= 2: plain 16ths. + * - reverseGallop: 16th + 16th + 8th repeated (n = 3: 16th + 8th; triplet grid: short-long); n <= 2: 16ths. + * - stab: one hit ringing through the group, or choked after min(2, n - 1) units. + * - halftime: a ringing chord on beats 1 and 3, a sparse weak-beat figure elsewhere. + */ +const cellSegments = (plan: GroupPlan, group: RhythmGroup, ctx: GenContext): Segment[] => { + const n = group.length; + switch (plan.kind) { + case 'rest': + return []; + case 'stab': + return [[0, plan.choke ? chokeUnits(n) : n]]; + case 'halftime': + return isHalftimeStrong(group, ctx) ? [[0, n]] : halftimeWeak(plan.variant, n); + case 'chug8': + return repeatFigure([2], n); + case 'gallop': + return n <= 2 ? repeatFigure([1], n) : repeatFigure(ctx.triplet ? [2, 1] : [2, 1, 1], n); + case 'reverseGallop': + if (n <= 2) return repeatFigure([1], n); + if (n === 3 && !ctx.triplet) return [[0, 1], [1, 2]]; + return repeatFigure(ctx.triplet ? [1, 2] : [1, 1, 2], n); + default: + return repeatFigure([1], n); + } +}; + +/** Drop the downbeat hit; a single held hit is delayed to the next 8th (or 16th in short groups) instead. */ +const shiftOffbeat = (segments: Segment[], n: number): Segment[] => { + if (segments.length >= 2) return segments.slice(1); + if (segments.length === 1 && n >= 2) { + const delay = n >= 4 ? 2 : 1; + return [[delay, Math.max(1, Math.min(segments[0][1], n - delay))]]; + } + return segments; +}; + +export const renderGroup = (plan: GroupPlan, group: RhythmGroup, ctx: GenContext): RawHit[] => { + const start = group.startUnit * ctx.unitTicks; + if (plan.kind === 'tremolo') { + const step = plan.fine && !ctx.triplet ? THIRTY_SECOND_TICKS : ctx.unitTicks; + const count = (group.length * ctx.unitTicks) / step; + const first = plan.offbeat && group.length >= 2 ? ctx.unitTicks / step : 0; + const hits: RawHit[] = []; + for (let i = first; i < count; i++) { + hits.push({ tick: start + i * step, dur: step, role: i === first ? 'head' : 'fill', group: group.index, dead: false, tremolo: true }); + } + return hits; + } + const segments = plan.offbeat ? shiftOffbeat(cellSegments(plan, group, ctx), group.length) : cellSegments(plan, group, ctx); + const dead = createRng(plan.deadSeed); + return segments.map(([offset, length], i) => ({ + tick: start + offset * ctx.unitTicks, + dur: length * ctx.unitTicks, + role: i === 0 ? 'head' : 'fill', + group: group.index, + dead: i > 0 && !plan.signature && dead.chance(ctx.recipe.deadRate), + tremolo: false + })); +}; + +export const headAccent = (plan: GroupPlan, group: RhythmGroup, ctx: GenContext): 0 | 1 | 2 => { + if (group.indexInCycle === 0) return 2; + if (plan.kind === 'stab' || (plan.kind === 'halftime' && isHalftimeStrong(group, ctx))) return 1; + return plan.accent ? 1 : 0; +}; + +/** Anticipation across the group boundary: the accented head moves earlier and a tie holds it through the beat. */ +const applyPushes = (groups: RhythmGroup[], plans: GroupPlan[], hits: RawHit[][], ctx: GenContext): void => { + for (let i = 1; i < groups.length; i++) { + const plan = plans[i]; + if (plan.push <= 0 || plans[i - 1].signature) continue; + const group = groups[i]; + const start = group.startUnit * ctx.unitTicks; + const head = hits[i][0]; + if (!head || head.role !== 'head' || head.tick !== start || headAccent(plan, group, ctx) === 0) continue; + const pushTicks = plan.push * ctx.unitTicks; + const newTick = start - pushTicks; + const prevHits = hits[i - 1]; + if (newTick <= groups[i - 1].startUnit * ctx.unitTicks || prevHits.length === 0 || prevHits[0].tick >= newTick) continue; + hits[i - 1] = prevHits.filter((h) => h.tick < newTick); + hits[i] = [{ ...head, tick: newTick, dur: pushTicks }, { ...head, role: 'tie', tick: start }, ...hits[i].slice(1)]; + } +}; + +/** A ringing head followed by a group that starts silent keeps sounding into it. */ +const applyTieOvers = (groups: RhythmGroup[], plans: GroupPlan[], hits: RawHit[][], ctx: GenContext): void => { + for (let i = 0; i + 1 < groups.length; i++) { + if (!plans[i].tieOver) continue; + const end = (groups[i].startUnit + groups[i].length) * ctx.unitTicks; + const next = groups[i + 1]; + const last = hits[i][hits[i].length - 1]; + if (!last || last.role !== 'head' || last.tick + last.dur !== end || next.startUnit * ctx.unitTicks !== end) continue; + const firstNext = hits[i + 1].length > 0 ? hits[i + 1][0].tick : (next.startUnit + next.length) * ctx.unitTicks; + if (firstNext <= end) continue; + hits[i].push({ ...last, role: 'tie', tick: end, dur: firstNext - end }); + } +}; + +// --------------------------------------------------------------------------- +// Harmony and assembly +// --------------------------------------------------------------------------- + +/** + * Picks extra harmony moves so that every slot sounds when there are enough accents: moves step the slot by + * one, so slotCount - 1 moves reach them all. Cycle-start accents are preferred, and each forced move goes to + * the candidate farthest (in accents) from any existing change, so the slots get similar shares. Heuristic. + */ +const ensureSlotCoverage = (moves: readonly boolean[], strong: readonly boolean[], slotCount: number): boolean[] => { + const out = [...moves]; + const needed = Math.min(slotCount - 1, out.length - 1); + let count = out.filter(Boolean).length; + while (count < needed) { + const open = out.flatMap((move, i) => (i > 0 && !move ? [i] : [])); + const preferred = open.some((i) => strong[i]) ? open.filter((i) => strong[i]) : open; + const changes = [0, out.length, ...out.flatMap((move, i) => (move ? [i] : []))]; + const spacing = (i: number) => Math.min(...changes.map((c) => Math.abs(c - i))); + const best = preferred.reduce((a, b) => (spacing(b) > spacing(a) ? b : a)); + out[best] = true; + count++; + } + return out; +}; + +/** + * Slot changes happen only on accented heads. The first accent uses slot 0; afterwards a cycle-start accent + * advances to the next slot with recipe.cycleMoveRate and other accents when their plan says `move`, plus + * the moves ensureSlotCoverage adds. Non-accented hits go to the pedal (plan.fillPedal) or stay on the + * current slot/dyad. + */ +const assignTargets = (groups: RhythmGroup[], plans: GroupPlan[], hits: RawHit[][], ctx: GenContext, rng: Rng): RhythmEvent[] => { + const { recipe, params } = ctx; + const lastCycle = groups[groups.length - 1].cycle; + const advance = Array.from({ length: lastCycle + 1 }, (_, k) => k > 0 && rng.fork(`cycle-${k}`).chance(recipe.cycleMoveRate)); + const ordered = hits.flat().sort((a, b) => a.tick - b.tick); + const accentOf = (hit: RawHit): 0 | 1 | 2 => (hit.role === 'head' ? headAccent(plans[hit.group], groups[hit.group], ctx) : 0); + const accented = ordered.filter((hit) => accentOf(hit) > 0); + const strong = accented.map((hit) => accentOf(hit) === 2); + const moves = ensureSlotCoverage( + accented.map((hit, i) => i > 0 && (strong[i] ? advance[groups[hit.group].cycle] : plans[hit.group].move)), + strong, + params.slotCount + ); + + const events: RhythmEvent[] = []; + let cursor = 0; + let accentIndex = 0; + for (const hit of ordered) { + const plan = plans[hit.group]; + const prev = events[events.length - 1]; + if (hit.role === 'tie') { + if (prev) events.push(makeEvent(hit.tick, hit.dur, prev.target, prev.palmMute, 0, 'down', true)); + continue; + } + const accent = accentOf(hit); + let target: HitTarget; + let palmMute = plan.palmMute; + if (accent > 0) { + if (moves[accentIndex++]) cursor = (cursor + 1) % params.slotCount; + target = slotTarget(hit.tremolo ? 'dyad' : recipe.headTarget, cursor); + palmMute = false; + } else if (hit.dead) { + target = { kind: 'dead' }; + palmMute = false; + } else if (hit.tremolo) { + target = slotTarget('dyad', cursor); + } else if (plan.fillPedal) { + target = { kind: 'pedal' }; + } else { + target = slotTarget(recipe.headTarget, cursor); + } + events.push(makeEvent(hit.tick, hit.dur, target, palmMute, accent)); + } + return events; +}; + +export const seedTag = (seed: string): string => seedFromString(seed).toString(36).toUpperCase().padStart(4, '0').slice(-4); + +const patternTags = (params: RhythmParams, ctx: GenContext, plans: GroupPlan[], hits: RawHit[][]): string[] => { + const tags = [...ctx.recipe.tags]; + if (plans.some((p) => p.kind === 'gallop' || p.kind === 'reverseGallop')) tags.push('gallop'); + if (plans.some((p) => p.kind === 'halftime')) tags.push('halftime'); + if (hits.some((list) => list.some((h) => h.tremolo))) tags.push('tremolo'); + if (params.grid === '16th-triplet') tags.push('triplet'); + if (isOddMeter(params.meter)) tags.push('odd-meter'); + if (isPolymeter(params)) tags.push('polymeter'); + if (params.syncopation >= 0.5) tags.push('syncopated'); + return [...new Set(tags)]; +}; + +export const generateRhythm = (input: RhythmParams, seed: string): RhythmPattern => { + const params = sanitizeRhythmParams(input); + const ctx = makeContext(params); + const rng = createRng(seed); + const groups = buildGroups(params, seed); + const plans = planGroups(groups, ctx, rng.fork('plan')); + const hits = groups.map((g) => renderGroup(plans[g.index], g, ctx)); + applyPushes(groups, plans, hits, ctx); + applyTieOvers(groups, plans, hits, ctx); + const events = finalizeEvents(assignTargets(groups, plans, hits, ctx, rng.fork('harmony')), { + lengthTicks: ctx.lengthTicks, + barTicks: ctx.barTicks, + picking: params.picking, + slotCount: params.slotCount + }); + const pattern: RhythmPattern = { + id: `${params.style}-${seed}`, + name: `${RHYTHM_STYLES[params.style].label} ${seedTag(seed)}`, + seed, + meter: { ...params.meter }, + bars: params.bars, + ppq: PPQ, + events, + tags: patternTags(params, ctx, plans, hits), + params + }; + if (params.cycleSixteenths !== undefined) pattern.cycleTicks = params.cycleSixteenths * ctx.unitTicks; + return pattern; +}; diff --git a/engine/rhythm/grid.test.ts b/engine/rhythm/grid.test.ts new file mode 100644 index 0000000..c0b30bb --- /dev/null +++ b/engine/rhythm/grid.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest'; +import { + PPQ, + barTicks, + barUnits, + buildGroups, + cycleGrouping, + firstBeatTicks, + gridUnitTicks, + isOddMeter, + isPolymeter, + patternLengthTicks, + patternUnitCount, + resolveGrouping +} from './grid'; +import { defaultRhythmParams } from './styles'; +import * as rhythm from './index'; +import { createRng } from '../random'; +import type { Meter } from '../types'; + +const m = (numerator: number, denominator: 4 | 8): Meter => ({ numerator, denominator }); +const rng = () => createRng('grid-test'); + +describe('tick math', () => { + it('uses 480 ticks per quarter note', () => { + expect(PPQ).toBe(480); + }); + + it('computes bar lengths as numerator * (4 / denominator) quarters', () => { + expect(barTicks(m(4, 4))).toBe(1920); + expect(barTicks(m(7, 8))).toBe(1680); + expect(barTicks(m(3, 4))).toBe(1440); + expect(barTicks(m(6, 8))).toBe(1440); + expect(barTicks(m(5, 4))).toBe(2400); + }); + + it('maps grids to unit ticks and counts units per bar', () => { + expect(gridUnitTicks('16th')).toBe(120); + expect(gridUnitTicks('16th-triplet')).toBe(80); + expect(barUnits(m(4, 4), '16th')).toBe(16); + expect(barUnits(m(4, 4), '16th-triplet')).toBe(24); + expect(barUnits(m(7, 8), '16th')).toBe(14); + expect(barUnits(m(7, 8), '16th-triplet')).toBe(21); + }); + + it('derives pattern length and unit count from bars and meter', () => { + const params = { ...defaultRhythmParams('sevenEight'), bars: 2 }; + const pattern = { bars: 2, meter: m(7, 8), params }; + expect(patternLengthTicks(pattern)).toBe(3360); + expect(patternUnitCount(pattern)).toBe(28); + expect(patternUnitCount({ ...pattern, params: { ...params, grid: '16th-triplet' as const } })).toBe(42); + }); + + it('treats one quarter (or a whole short bar) as the first beat', () => { + expect(firstBeatTicks(m(4, 4))).toBe(480); + expect(firstBeatTicks(m(7, 8))).toBe(480); + expect(firstBeatTicks(m(1, 8))).toBe(240); + }); + + it('classifies odd meters and polymeter cycles', () => { + expect(isOddMeter(m(7, 8))).toBe(true); + expect(isOddMeter(m(5, 4))).toBe(true); + expect(isOddMeter(m(4, 4))).toBe(false); + expect(isOddMeter(m(3, 4))).toBe(false); + expect(isOddMeter(m(9, 8))).toBe(false); + expect(isPolymeter(defaultRhythmParams('fiveOverFour'))).toBe(true); + expect(isPolymeter(defaultRhythmParams('chugEngine'))).toBe(false); + expect(isPolymeter({ ...defaultRhythmParams('chugEngine'), cycleSixteenths: 8 })).toBe(false); + expect(isPolymeter({ ...defaultRhythmParams('chugEngine'), cycleSixteenths: 32 })).toBe(false); + }); +}); + +describe('resolveGrouping', () => { + it("'even' follows the beat: quarters in x/4, 2+2+3 eighths in 7/8", () => { + expect(resolveGrouping('even', 16, m(4, 4), '16th', rng())).toEqual([4, 4, 4, 4]); + expect(resolveGrouping('even', 14, m(7, 8), '16th', rng())).toEqual([4, 4, 6]); + expect(resolveGrouping('even', 24, m(4, 4), '16th-triplet', rng())).toEqual([6, 6, 6, 6]); + expect(resolveGrouping('even', 21, m(7, 8), '16th-triplet', rng())).toEqual([6, 6, 9]); + expect(resolveGrouping('even', 10, m(5, 8), '16th', rng())).toEqual([4, 6]); + expect(resolveGrouping('even', 12, m(6, 8), '16th', rng())).toEqual([6, 6]); + }); + + it("'even' splits a polymeter cycle without leaving one-unit groups", () => { + expect(resolveGrouping('even', 5, m(4, 4), '16th', rng())).toEqual([3, 2]); + expect(resolveGrouping('even', 9, m(4, 4), '16th', rng())).toEqual([4, 3, 2]); + expect(resolveGrouping('even', 6, m(4, 4), '16th', rng())).toEqual([4, 2]); + expect(resolveGrouping('even', 3, m(4, 4), '16th', rng())).toEqual([3]); + }); + + it('repeats the named groupings to fill the cycle', () => { + expect(resolveGrouping('3-3-2', 16, m(4, 4), '16th', rng())).toEqual([3, 3, 2, 3, 3, 2]); + expect(resolveGrouping('3-3-3-3-4', 16, m(4, 4), '16th', rng())).toEqual([3, 3, 3, 3, 4]); + expect(resolveGrouping('3-3-2', 14, m(7, 8), '16th', rng())).toEqual([3, 3, 2, 3, 3]); + }); + + it('uses explicit groups as-is, repeated, merging a truncated single unit', () => { + expect(resolveGrouping([3, 2], 5, m(4, 4), '16th', rng())).toEqual([3, 2]); + expect(resolveGrouping([3, 2], 16, m(4, 4), '16th', rng())).toEqual([3, 2, 3, 2, 3, 3]); + expect(resolveGrouping([1, 2], 4, m(4, 4), '16th', rng())).toEqual([1, 2, 1]); + expect(resolveGrouping([0, -2], 16, m(4, 4), '16th', rng())).toEqual([4, 4, 4, 4]); + }); + + it("'random-odd' mixes 3s with 2s and 4s, sums to the cycle and is seeded", () => { + for (const seed of ['a', 'b', 'c', 'd', 'e', 'f']) { + for (const cycle of [5, 7, 11, 16, 21]) { + const groups = resolveGrouping('random-odd', cycle, m(4, 4), '16th', createRng(seed)); + expect(groups.reduce((s, g) => s + g, 0)).toBe(cycle); + expect(groups.every((g) => g >= 2 && g <= 4)).toBe(true); + expect(groups).toEqual(resolveGrouping('random-odd', cycle, m(4, 4), '16th', createRng(seed))); + } + } + const withThrees = ['a', 'b', 'c', 'd', 'e', 'f'].filter((seed) => + resolveGrouping('random-odd', 16, m(4, 4), '16th', createRng(seed)).includes(3) + ); + expect(withThrees.length).toBeGreaterThanOrEqual(4); + }); +}); + +describe('buildGroups', () => { + it('keeps one-bar cycles aligned with the bar (7/8 as 2+2+3)', () => { + const params = defaultRhythmParams('sevenEight'); + expect(cycleGrouping(params, 'x')).toEqual([4, 4, 6]); + const groups = buildGroups(params, 'x'); + expect(groups.map((g) => g.startUnit)).toEqual([0, 4, 8, 14, 18, 22]); + expect(groups.map((g) => g.length)).toEqual([4, 4, 6, 4, 4, 6]); + expect(groups.map((g) => g.cycle)).toEqual([0, 0, 0, 1, 1, 1]); + }); + + it('repeats a 5-unit cycle across the barline until the bars are full', () => { + const params = defaultRhythmParams('fiveOverFour'); + const groups = buildGroups(params, 'x'); + const cycleStarts = groups.filter((g) => g.indexInCycle === 0).map((g) => g.startUnit); + expect(cycleStarts).toEqual([0, 5, 10, 15, 20, 25, 30]); + expect(groups.reduce((s, g) => s + g.length, 0)).toBe(32); + const last = groups[groups.length - 1]; + expect(last).toMatchObject({ startUnit: 30, length: 2, truncated: true, indexInCycle: 0 }); + }); + + it('covers every unit exactly once for any grouping', () => { + for (const grouping of ['even', '3-3-2', '3-3-3-3-4', 'random-odd', [5, 3]] as const) { + const params = { ...defaultRhythmParams('chugEngine'), grouping, bars: 3 }; + const groups = buildGroups(params, 'cover'); + let expected = 0; + for (const g of groups) { + expect(g.startUnit).toBe(expected); + expected += g.length; + } + expect(expected).toBe(48); + } + }); +}); + +describe('public rhythm API', () => { + it('exposes the accent groups and the cell and slot-count helpers a grid UI needs', () => { + expect(rhythm.buildGroups).toBe(buildGroups); + for (const name of ['eventsInUnit', 'setSlotCount', 'eventAtUnit']) expect(rhythm).toHaveProperty(name); + const group: rhythm.RhythmGroup = buildGroups(defaultRhythmParams('gallop'), 'api')[0]; + expect(group).toMatchObject({ startUnit: 0, cycle: 0, indexInCycle: 0 }); + }); +}); diff --git a/engine/rhythm/grid.ts b/engine/rhythm/grid.ts new file mode 100644 index 0000000..2b7a163 --- /dev/null +++ b/engine/rhythm/grid.ts @@ -0,0 +1,164 @@ +// Tick math, grid units and accent grouping. 480 PPQ, matching utils/midi.ts. +import type { GroupingSpec, Meter, RhythmGrid, RhythmParams, RhythmPattern } from '../types'; +import { createRng, type Rng } from '../random'; + +export const PPQ = 480; +export const SIXTEENTH_TICKS = 120; +export const TRIPLET_UNIT_TICKS = 80; +export const THIRTY_SECOND_TICKS = 60; + +/** numerator * (4 / denominator) quarter notes: 4/4 = 1920, 7/8 = 1680. */ +export const barTicks = (meter: Meter): number => (meter.numerator * 4 * PPQ) / meter.denominator; + +export const gridUnitTicks = (grid: RhythmGrid): number => (grid === '16th-triplet' ? TRIPLET_UNIT_TICKS : SIXTEENTH_TICKS); + +export const barUnits = (meter: Meter, grid: RhythmGrid): number => barTicks(meter) / gridUnitTicks(grid); + +export const patternLengthTicks = (pattern: Pick): number => + pattern.bars * barTicks(pattern.meter); + +/** Grid cells in the whole pattern; edit helpers address events by this unit index. */ +export const patternUnitCount = (pattern: Pick): number => + patternLengthTicks(pattern) / gridUnitTicks(pattern.params.grid); + +/** Window that must contain an attack unless params.anticipation: one quarter (two eighths in x/8). */ +export const firstBeatTicks = (meter: Meter): number => Math.min(PPQ, barTicks(meter)); + +type CycleParams = Pick; + +/** Accent-cycle length in grid units: params.cycleSixteenths, or one bar. */ +export const cycleUnits = (params: CycleParams): number => params.cycleSixteenths ?? barUnits(params.meter, params.grid); + +/** The accent cycle and the bar drift against each other (neither divides the other). */ +export const isPolymeter = (params: CycleParams): boolean => { + const cycle = cycleUnits(params); + const bar = barUnits(params.meter, params.grid); + return cycle % bar !== 0 && bar % cycle !== 0; +}; + +/** 5/4, 7/8, 11/8 ... Simple triple (3/4) and compound meters (6/8, 9/8, 12/8) are not odd. */ +export const isOddMeter = (meter: Meter): boolean => + meter.numerator % 2 === 1 && meter.numerator >= 5 && !(meter.denominator === 8 && meter.numerator % 3 === 0); + +// --------------------------------------------------------------------------- +// Grouping +// --------------------------------------------------------------------------- + +/** Beat groups of one bar: quarters in x/4; compound x/8 in dotted quarters; other x/8 as 2+2+...+3 eighths. */ +const meterGroups = (meter: Meter, grid: RhythmGrid): number[] => { + const unit = gridUnitTicks(grid); + if (meter.denominator === 4) return Array.from({ length: meter.numerator }, () => PPQ / unit); + const eighth = PPQ / 2 / unit; + const n = meter.numerator; + let eighths: number[]; + if (n % 3 === 0 && n > 3) eighths = Array.from({ length: n / 3 }, () => 3); + else if (n === 1) eighths = [1]; + else if (n % 2 === 0) eighths = Array.from({ length: n / 2 }, () => 2); + else eighths = [...Array.from({ length: (n - 3) / 2 }, () => 2), 3]; + return eighths.map((e) => e * eighth); +}; + +/** Beat-sized groups; a one-unit remainder is folded into the last two groups (5 -> 3 + 2). */ +const splitEvenly = (total: number, size: number): number[] => { + if (total <= size) return [total]; + const groups = Array.from({ length: Math.floor(total / size) }, () => size); + const rest = total - groups.length * size; + if (rest >= 2) groups.push(rest); + else if (rest === 1) { + const merged = (groups.pop() as number) + 1; + groups.push(Math.ceil(merged / 2), Math.floor(merged / 2)); + } + return groups; +}; + +/** Repeats `base` until `total` units are filled; a one-unit leftover made by truncation joins the previous group. */ +const repeatToFill = (base: readonly number[], total: number): number[] => { + const groups: number[] = []; + let sum = 0; + for (let i = 0; sum < total; i++) { + const size = Math.min(base[i % base.length], total - sum); + groups.push(size); + sum += size; + } + const last = groups.length - 1; + if (last > 0 && groups[last] === 1 && base[last % base.length] !== 1) { + groups.pop(); + groups[last - 1] += 1; + } + return groups; +}; + +/** Seeded mix of 3s with 2s and 4s; never leaves a single unit. */ +const randomOddGroups = (total: number, rng: Rng): number[] => { + const groups: number[] = []; + let rest = total; + while (rest > 0) { + if (rest <= 3) { + groups.push(rest); + break; + } + const options = [3, 2, 4].filter((g) => g <= rest && rest - g !== 1); + const size = rng.weighted(options, options.map((g) => (g === 3 ? 0.55 : g === 2 ? 0.25 : 0.2))); + groups.push(size); + rest -= size; + } + return groups; +}; + +/** Partition one accent cycle of `total` grid units into accent groups. */ +export const resolveGrouping = ( + spec: GroupingSpec, + total: number, + meter: Meter, + grid: RhythmGrid, + rng: Rng +): number[] => { + if (typeof spec !== 'string') { + const base = spec.filter((g) => Number.isInteger(g) && g > 0); + return base.length > 0 ? repeatToFill(base, total) : resolveGrouping('even', total, meter, grid, rng); + } + switch (spec) { + case '3-3-2': + return repeatToFill([3, 3, 2], total); + case '3-3-3-3-4': + return repeatToFill([3, 3, 3, 3, 4], total); + case 'random-odd': + return randomOddGroups(total, rng); + default: + return total === barUnits(meter, grid) ? meterGroups(meter, grid) : splitEvenly(total, PPQ / gridUnitTicks(grid)); + } +}; + +type GroupingParams = Pick; + +/** Group lengths of one accent cycle for these params ('random-odd' is drawn from the seed). */ +export const cycleGrouping = (params: GroupingParams, seed: string): number[] => + resolveGrouping(params.grouping, cycleUnits(params), params.meter, params.grid, createRng(seed).fork('grouping')); + +export interface RhythmGroup { + index: number; + startUnit: number; + length: number; + /** Which repetition of the accent cycle this group belongs to. */ + cycle: number; + indexInCycle: number; + /** Cut short by the end of the pattern. */ + truncated: boolean; +} + +/** Lays the cycle's groups end to end, across barlines, until bars * barUnits are filled. */ +export const buildGroups = (params: GroupingParams & Pick, seed: string): RhythmGroup[] => { + const total = params.bars * barUnits(params.meter, params.grid); + const cycle = cycleUnits(params); + const sizes = cycleGrouping(params, seed); + const groups: RhythmGroup[] = []; + for (let c = 0, start = 0; start < total; c++, start += cycle) { + let offset = 0; + for (let i = 0; i < sizes.length && start + offset < total; i++) { + const length = Math.min(sizes[i], total - start - offset); + groups.push({ index: groups.length, startUnit: start + offset, length, cycle: c, indexInCycle: i, truncated: length < sizes[i] }); + offset += sizes[i]; + } + } + return groups; +}; diff --git a/engine/rhythm/index.ts b/engine/rhythm/index.ts new file mode 100644 index 0000000..90b6ccc --- /dev/null +++ b/engine/rhythm/index.ts @@ -0,0 +1,22 @@ +// Public API of the rhythm engine. +export { + PPQ, + barTicks, + barUnits, + gridUnitTicks, + patternLengthTicks, + patternUnitCount, + firstBeatTicks, + cycleGrouping, + buildGroups, + isPolymeter, + isOddMeter, + type RhythmGroup +} from './grid'; +export { RHYTHM_STYLES, RHYTHM_STYLE_IDS, defaultRhythmParams, type RhythmStyleInfo } from './styles'; +export { generateRhythm, sanitizeRhythmParams } from './generate'; +export { mutateRhythm, regenerateRhythm } from './variation'; +export { validateRhythm, type RhythmValidateOptions } from './validate'; +export { assignPicks } from './picking'; +export { toggleHit, cycleAccent, togglePalmMute, clearHit, eventAtUnit, eventsInUnit, setSlotCount } from './edit'; +export { rhythmToPlaybackEvents, rhythmToMidiNotes, hitVelocity } from './playback'; diff --git a/engine/rhythm/picking.test.ts b/engine/rhythm/picking.test.ts new file mode 100644 index 0000000..5b3c488 --- /dev/null +++ b/engine/rhythm/picking.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { assignPicks } from './picking'; +import { generateRhythm } from './generate'; +import { clearHit } from './edit'; +import { RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles'; +import { barTicks, patternUnitCount } from './grid'; +import type { RhythmPattern } from '../types'; + +const at = (...ticks: number[]) => ticks.map((tick) => ({ tick })); +const BAR = 1920; + +describe('assignPicks: alternate', () => { + it('keys picks to the sixteenth grid: even sixteenths down, odd up', () => { + expect(assignPicks(at(0, 120, 240, 360, 480), 'alternate', BAR)).toEqual(['down', 'up', 'down', 'up', 'down']); + }); + + it('picks a gallop down, down, up and keeps the pendulum through rests', () => { + expect(assignPicks(at(0, 240, 360), 'alternate', BAR)).toEqual(['down', 'down', 'up']); + expect(assignPicks(at(0, 360, 600), 'alternate', BAR)).toEqual(['down', 'up', 'up']); + }); + + it('alternates 32nds strictly, including the on-grid ones between them', () => { + expect(assignPicks(at(0, 60, 120, 180, 240, 300), 'alternate', BAR)).toEqual([ + 'down', + 'up', + 'down', + 'up', + 'down', + 'up' + ]); + }); + + it('alternates sixteenth triplets by event order', () => { + expect(assignPicks(at(0, 80, 160, 240, 320, 400, 480), 'alternate', BAR)).toEqual([ + 'down', + 'up', + 'down', + 'up', + 'down', + 'up', + 'down' + ]); + }); + + it('restarts eighth-note triplets on each beat (on-grid pick wins after a 16th or longer gap)', () => { + expect(assignPicks(at(0, 160, 320, 480), 'alternate', BAR)).toEqual(['down', 'up', 'down', 'down']); + }); + + it('places a leading off-grid event by its own subdivision', () => { + expect(assignPicks(at(60), 'alternate', BAR)).toEqual(['up']); + expect(assignPicks(at(80), 'alternate', BAR)).toEqual(['up']); + expect(assignPicks(at(160), 'alternate', BAR)).toEqual(['down']); + expect(assignPicks(at(1860), 'alternate', BAR)).toEqual(['up']); + }); + + it('starts each bar fresh so a bar never depends on the previous one', () => { + // 7/8 on the triplet grid: last triplet of bar 1 is a downstroke, the next downbeat is too. + expect(assignPicks(at(1600, 1680), 'alternate', 1680)).toEqual(['down', 'down']); + }); +}); + +describe('assignPicks: runs faster than sixteenths', () => { + it('keys a sixteenth-triplet run with a gap to its own pendulum, so the next beat is still down', () => { + expect(assignPicks(at(0, 160, 240, 320, 400, 480), 'alternate', BAR)).toEqual(['down', 'down', 'up', 'down', 'up', 'down']); + }); + + it('keys an offbeat 32nd run to the 32nd pendulum, so the beat it reaches is down', () => { + expect(assignPicks(at(480, 600, 660, 720, 780, 840, 900, 960), 'alternate', BAR)).toEqual([ + 'down', + 'down', + 'up', + 'down', + 'up', + 'down', + 'up', + 'down' + ]); + }); + + it('keeps the 32nd pendulum through a missing 32nd', () => { + expect(assignPicks(at(0, 60, 180, 240), 'alternate', BAR)).toEqual(['down', 'up', 'up', 'down']); + }); +}); + +/** Every event on a quarter-note boundary of its bar is a downstroke; notes closer than a 16th alternate. */ +const expectBeatStartsDown = (p: RhythmPattern) => { + const bar = barTicks(p.meter); + p.events.forEach((e, i) => { + if ((e.tick % bar) % 480 === 0) expect(e.pick, `${p.id} tick ${e.tick}`).toBe('down'); + const prev = p.events[i - 1]; + if (prev && !e.tie && !prev.tie && e.tick - prev.tick < 120 && Math.floor(prev.tick / bar) === Math.floor(e.tick / bar)) { + expect(e.pick, `${p.id} run at ${e.tick}`).not.toBe(prev.pick); + } + }); +}; + +describe('assignPicks on generated and edited patterns', () => { + it('puts every beat start on a downstroke, also after a note is removed from a fast run', () => { + for (const style of RHYTHM_STYLE_IDS) { + for (const grid of ['16th', '16th-triplet'] as const) { + for (const seed of ['b0', 'b54', 'xyz', 'riff-1']) { + const p = generateRhythm({ ...defaultRhythmParams(style), grid, picking: 'alternate', bars: 1 }, seed); + expectBeatStartsDown(p); + for (let unit = 0; unit < patternUnitCount(p); unit++) expectBeatStartsDown(clearHit(p, unit)); + } + } + } + }); +}); + +describe('assignPicks: downstrokes', () => { + it('picks everything down', () => { + expect(assignPicks(at(0, 120, 240, 300, 360), 'downstrokes', BAR)).toEqual(['down', 'down', 'down', 'down', 'down']); + }); +}); diff --git a/engine/rhythm/picking.ts b/engine/rhythm/picking.ts new file mode 100644 index 0000000..078e8e2 --- /dev/null +++ b/engine/rhythm/picking.ts @@ -0,0 +1,62 @@ +// Picking guidance. Validation recomputes picks with this same function, so it must stay pure and positional. +import type { PickingMode } from '../types'; +import { SIXTEENTH_TICKS, THIRTY_SECOND_TICKS, TRIPLET_UNIT_TICKS } from './grid'; + +type Pick = 'down' | 'up'; + +const flip = (pick: Pick): Pick => (pick === 'down' ? 'up' : 'down'); +const byParity = (index: number): Pick => (index % 2 === 0 ? 'down' : 'up'); + +/** + * 'downstrokes': every pick down. + * 'alternate' (pendulum keyed to the grid; state resets at every barline, so a bar's picks never depend on + * its neighbours and bar-relative positions are used throughout): + * - a note less than a sixteenth from a neighbour in its bar belongs to a fast run and takes the parity of + * its position on that run's subdivision (sixteenth triplets when the gap is a triplet unit, else 32nds). + * Runs therefore alternate strictly, a missing note leaves a ghost stroke instead of inverting the rest, + * and every beat start (a multiple of both subdivisions) stays a downstroke; + * - any other on-grid note (tick % 120 === 0) is down on even sixteenths and up on odd ones; + * - any other off-grid note (eighth-note triplets) alternates from the previous note's pick, or, first in + * its bar, takes the parity of its own 32nd or triplet position. + */ +export const assignPicks = (events: readonly { tick: number }[], mode: PickingMode, barLengthTicks: number): Pick[] => { + if (mode === 'downstrokes') return events.map(() => 'down'); + const barOf = (tick: number) => Math.floor(tick / barLengthTicks); + const gapTo = (i: number, j: number): number => + j >= 0 && j < events.length && barOf(events[j].tick) === barOf(events[i].tick) + ? Math.abs(events[j].tick - events[i].tick) + : Infinity; + const picks: Pick[] = []; + let prevPick: Pick | null = null; + let prevBar = -1; + events.forEach(({ tick }, i) => { + const bar = barOf(tick); + if (bar !== prevBar) { + prevPick = null; + prevBar = bar; + } + const rel = tick - bar * barLengthTicks; + const gaps = [gapTo(i, i - 1), gapTo(i, i + 1)].filter((g) => g > 0); + const fastGap = Math.min(...gaps); + let pick: Pick; + if (fastGap < SIXTEENTH_TICKS) { + if (fastGap % TRIPLET_UNIT_TICKS === 0 && rel % TRIPLET_UNIT_TICKS === 0) pick = byParity(rel / TRIPLET_UNIT_TICKS); + else if (rel % THIRTY_SECOND_TICKS === 0) pick = byParity(rel / THIRTY_SECOND_TICKS); + else if (rel % TRIPLET_UNIT_TICKS === 0) pick = byParity(rel / TRIPLET_UNIT_TICKS); + else pick = prevPick === null ? 'down' : flip(prevPick); + } else if (rel % SIXTEENTH_TICKS === 0) { + pick = byParity(rel / SIXTEENTH_TICKS); + } else if (prevPick !== null) { + pick = flip(prevPick); + } else if (rel % THIRTY_SECOND_TICKS === 0) { + pick = byParity(rel / THIRTY_SECOND_TICKS); + } else if (rel % TRIPLET_UNIT_TICKS === 0) { + pick = byParity(rel / TRIPLET_UNIT_TICKS); + } else { + pick = flip(byParity(Math.floor(rel / SIXTEENTH_TICKS))); + } + picks.push(pick); + prevPick = pick; + }); + return picks; +}; diff --git a/engine/rhythm/playback.test.ts b/engine/rhythm/playback.test.ts new file mode 100644 index 0000000..f339be7 --- /dev/null +++ b/engine/rhythm/playback.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { rhythmToMidiNotes, rhythmToPlaybackEvents } from './playback'; +import { generateRhythm } from './generate'; +import { RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles'; +import { patternLengthTicks } from './grid'; +import { E_STANDARD } from '../tuning'; +import type { HarmonySlot, HitTarget, RhythmEvent, RhythmPattern } from '../types'; + +// E5 (0 2 2 x x x) and C5 on the A string (x 3 5 5 x x) with an explicit A2 pedal. +const SLOTS: HarmonySlot[] = [ + { shape: [0, 2, 2, null, null, null], pedalMidi: null }, + { shape: [null, 3, 5, 5, null, null], pedalMidi: 45 } +]; + +const ev = (tick: number, durationTicks: number, target: HitTarget, extra: Partial = {}): RhythmEvent => ({ + tick, + durationTicks, + target, + palmMute: false, + accent: 0, + pick: 'down', + ...extra +}); + +const PATTERN: RhythmPattern = { + id: 'p', + name: 'P', + seed: 'p', + meter: { numerator: 4, denominator: 4 }, + bars: 1, + ppq: 480, + tags: [], + params: { ...defaultRhythmParams('chugEngine'), bars: 1 }, + events: [ + ev(0, 240, { kind: 'slot', slot: 0 }, { accent: 2 }), + ev(240, 120, { kind: 'pedal' }, { palmMute: true }), + ev(360, 120, { kind: 'dead' }), + ev(480, 240, { kind: 'slot', slot: 1 }, { accent: 1 }), + ev(720, 240, { kind: 'slot', slot: 1 }, { tie: true }), + ev(960, 120, { kind: 'pedal' }, { palmMute: true, accent: 1 }), + ev(1080, 120, { kind: 'dyad', slot: 0 }), + ev(1200, 120, { kind: 'slot', slot: 3 }), + ev(1320, 120, { kind: 'slot', slot: 3 }, { tie: true }), + ev(1440, 480, { kind: 'pedal' }) + ] +}; + +describe('rhythmToPlaybackEvents', () => { + const out = rhythmToPlaybackEvents(PATTERN, SLOTS, E_STANDARD, 120); + + it('skips ties and events whose slot is missing', () => { + expect(out.map((e) => e.tick)).toEqual([0, 240, 360, 480, 960, 1080, 1440]); + }); + + it('times events as tick / 480 * 60 / bpm', () => { + expect(out.map((e) => e.timeSec)).toEqual([0, 0.25, 0.375, 0.5, 1, 1.125, 1.5]); + const slow = rhythmToPlaybackEvents(PATTERN, SLOTS, E_STANDARD, 60); + expect(slow[3].timeSec).toBeCloseTo(1, 9); + expect(slow[3].durationSec).toBeCloseTo(1, 9); + }); + + it('plays slots through shapeToMidi, dyads as the top two notes, pedals from the active slot', () => { + expect(out[0].midi).toEqual([40, 47, 52]); + expect(out[1].midi).toEqual([40]); + expect(out[3].midi).toEqual([48, 55, 60]); + expect(out[4].midi).toEqual([45]); + expect(out[5].midi).toEqual([47, 52]); + expect(out[6].midi).toEqual([40]); + }); + + it('maps accents, palm mute and dead notes to velocity', () => { + expect(out[0].velocity).toBeCloseTo(0.92, 9); + expect(out[1].velocity).toBeCloseTo(0.525, 9); + expect(out[2].velocity).toBeCloseTo(0.3, 9); + expect(out[3].velocity).toBeCloseTo(0.82, 9); + expect(out[4].velocity).toBeCloseTo(0.615, 9); + expect(out[5].velocity).toBeCloseTo(0.7, 9); + }); + + it('shortens palm mutes to 35 percent, dead notes to 30 ms and extends ties', () => { + expect(out[0].durationSec).toBeCloseTo(0.25, 9); + expect(out[1].durationSec).toBeCloseTo(0.125 * 0.35, 9); + expect(out[2].durationSec).toBeCloseTo(0.03, 9); + expect(out[3].durationSec).toBeCloseTo(0.5, 9); + expect(out[5].durationSec).toBeCloseTo(0.125, 9); + expect(out[6].durationSec).toBeCloseTo(0.5, 9); + }); + + it('reports kind, palm mute and accent for the UI', () => { + expect(out.map((e) => e.kind)).toEqual(['slot', 'pedal', 'dead', 'slot', 'pedal', 'dyad', 'pedal']); + expect(out[1].palmMute).toBe(true); + expect(out[3].accent).toBe(1); + }); + + it('skips slots whose shape does not fit the tuning and returns nothing for a bad bpm', () => { + const broken: HarmonySlot[] = [{ shape: [0, 2, 2], pedalMidi: null }, SLOTS[1]]; + expect(rhythmToPlaybackEvents(PATTERN, broken, E_STANDARD, 120).map((e) => e.tick)).toEqual([240, 360, 480, 960, 1440]); + expect(rhythmToPlaybackEvents(PATTERN, SLOTS, E_STANDARD, 0)).toEqual([]); + }); +}); + +describe('rhythmToMidiNotes', () => { + const notes = rhythmToMidiNotes(PATTERN, SLOTS, E_STANDARD); + + it('emits one note per pitch with tick durations following the same rules', () => { + expect(notes).toEqual([ + { tick: 0, durationTicks: 240, pitch: 40, velocity: 117 }, + { tick: 0, durationTicks: 240, pitch: 47, velocity: 117 }, + { tick: 0, durationTicks: 240, pitch: 52, velocity: 117 }, + { tick: 240, durationTicks: 42, pitch: 40, velocity: 67 }, + { tick: 360, durationTicks: 30, pitch: 40, velocity: 38 }, + { tick: 480, durationTicks: 480, pitch: 48, velocity: 104 }, + { tick: 480, durationTicks: 480, pitch: 55, velocity: 104 }, + { tick: 480, durationTicks: 480, pitch: 60, velocity: 104 }, + { tick: 960, durationTicks: 42, pitch: 45, velocity: 78 }, + { tick: 1080, durationTicks: 120, pitch: 47, velocity: 89 }, + { tick: 1080, durationTicks: 120, pitch: 52, velocity: 89 }, + { tick: 1440, durationTicks: 480, pitch: 40, velocity: 89 } + ]); + }); + + it('keeps every velocity in 1..127 and every note inside the pattern', () => { + for (const style of RHYTHM_STYLE_IDS) { + const pattern = generateRhythm(defaultRhythmParams(style), `midi-${style}`); + const all = rhythmToMidiNotes(pattern, SLOTS, E_STANDARD); + expect(all.length).toBeGreaterThan(0); + for (const n of all) { + expect(n.velocity).toBeGreaterThanOrEqual(1); + expect(n.velocity).toBeLessThanOrEqual(127); + expect(n.durationTicks).toBeGreaterThan(0); + expect(n.tick + n.durationTicks).toBeLessThanOrEqual(patternLengthTicks(pattern)); + } + } + }); +}); + +describe('generated patterns', () => { + it('produce sorted playback with sound for every attack when all slots exist', () => { + for (const style of RHYTHM_STYLE_IDS) { + const pattern = generateRhythm(defaultRhythmParams(style), `play-${style}`); + const out = rhythmToPlaybackEvents(pattern, SLOTS, E_STANDARD, 140); + expect(out).toHaveLength(pattern.events.filter((e) => !e.tie).length); + for (let i = 1; i < out.length; i++) expect(out[i].timeSec).toBeGreaterThan(out[i - 1].timeSec); + expect(out.every((e) => e.midi.length > 0 && e.velocity > 0 && e.velocity <= 1)).toBe(true); + } + }); +}); diff --git a/engine/rhythm/playback.ts b/engine/rhythm/playback.ts new file mode 100644 index 0000000..11ca100 --- /dev/null +++ b/engine/rhythm/playback.ts @@ -0,0 +1,133 @@ +// Pure conversion of a rhythm pattern plus harmony slots into timed notes. No Tone.js, no clock. +import type { HarmonySlot, HitTarget, MidiNoteEvent, PlaybackEvent, RhythmEvent, RhythmPattern, Tuning } from '../types'; +import { isValidShape, shapeToMidi } from '../shape'; +import { PPQ } from './grid'; + +export const BASE_VELOCITY = 0.7; +/** Added to the base velocity for accent 0, 1, 2. */ +export const ACCENT_BOOST: readonly number[] = [0, 0.12, 0.22]; +export const PALM_MUTE_VELOCITY_FACTOR = 0.75; +export const PALM_MUTE_DURATION_FACTOR = 0.35; +export const DEAD_VELOCITY = 0.3; +export const DEAD_NOTE_SEC = 0.03; +/** Dead-note length for MIDI export, where no tempo is known: a 64th, about 0.03 s at 120 BPM. */ +export const DEAD_NOTE_TICKS = 30; + +const slotNotes = (slot: HarmonySlot | undefined, tuning: Tuning): number[] | null => + slot && isValidShape(slot.shape, tuning.openMidi.length) ? shapeToMidi(slot.shape, tuning) : null; + +/** slot.pedalMidi, else the slot's lowest sounding note, else the tuning's lowest open string. */ +const pedalNote = (slot: HarmonySlot | undefined, tuning: Tuning): number | null => { + if (!slot) return null; + if (typeof slot.pedalMidi === 'number') return slot.pedalMidi; + const notes = slotNotes(slot, tuning); + return notes && notes.length > 0 ? Math.min(...notes) : (tuning.openMidi[0] ?? null); +}; + +const targetNotes = (target: HitTarget, slots: readonly HarmonySlot[], tuning: Tuning, activeSlot: number): number[] | null => { + switch (target.kind) { + case 'slot': + return slotNotes(slots[target.slot], tuning); + case 'dyad': { + const notes = slotNotes(slots[target.slot], tuning); + return notes ? [...notes].sort((a, b) => a - b).slice(-2) : null; + } + default: { + const pedal = pedalNote(slots[activeSlot], tuning); + return pedal === null ? null : [pedal]; + } + } +}; + +interface SoundingNote { + event: RhythmEvent; + midi: number[]; + /** Grid duration including any ties that sustain it. */ + gridTicks: number; +} + +/** + * Pedal and dead hits use the active slot: the slot of the most recent slot/dyad hit that sounded (slot 0 + * before any). Hits whose slot is missing (or whose shape does not fit the tuning) are skipped, and a tie + * after a skipped hit extends nothing. + */ +const resolveNotes = (pattern: RhythmPattern, slots: readonly HarmonySlot[], tuning: Tuning): SoundingNote[] => { + const notes: SoundingNote[] = []; + let activeSlot = 0; + let current: SoundingNote | null = null; + for (const event of pattern.events) { + if (event.tie) { + if (current) current.gridTicks += event.durationTicks; + continue; + } + const midi = targetNotes(event.target, slots, tuning, activeSlot); + if (!midi || midi.length === 0) { + current = null; + continue; + } + if (event.target.kind === 'slot' || event.target.kind === 'dyad') activeSlot = event.target.slot; + current = { event, midi, gridTicks: event.durationTicks }; + notes.push(current); + } + return notes; +}; + +const round4 = (x: number): number => Math.round(x * 10000) / 10000; + +/** Base 0.7, accents +0.12 / +0.22, palm mute x0.75, dead notes fixed at 0.3; clamped to 0..1. */ +export const hitVelocity = (event: RhythmEvent): number => { + if (event.target.kind === 'dead') return DEAD_VELOCITY; + const boosted = BASE_VELOCITY + (ACCENT_BOOST[event.accent] ?? 0); + return round4(Math.min(1, Math.max(0, event.palmMute ? boosted * PALM_MUTE_VELOCITY_FACTOR : boosted))); +}; + +/** + * timeSec = tick / 480 * 60 / bpm. Duration = grid duration (plus ties), x0.35 when palm-muted, + * min(0.03 s, grid) for dead notes. Returns [] for a non-positive bpm. + */ +export const rhythmToPlaybackEvents = ( + pattern: RhythmPattern, + slots: readonly HarmonySlot[], + tuning: Tuning, + bpm: number +): PlaybackEvent[] => { + if (!(bpm > 0)) return []; + const secPerTick = 60 / (PPQ * bpm); + return resolveNotes(pattern, slots, tuning).map(({ event, midi, gridTicks }) => { + const gridSec = gridTicks * secPerTick; + const durationSec = + event.target.kind === 'dead' + ? Math.min(DEAD_NOTE_SEC, gridSec) + : event.palmMute + ? gridSec * PALM_MUTE_DURATION_FACTOR + : gridSec; + return { + tick: event.tick, + timeSec: event.tick * secPerTick, + midi: [...midi], + velocity: hitVelocity(event), + durationSec, + kind: event.target.kind, + palmMute: event.palmMute, + accent: event.accent + }; + }); +}; + +/** + * One note per distinct pitch, sorted by tick then pitch. Velocity = round(playback velocity * 127) in 1..127; + * duration in ticks follows the playback rules (palm mute max(1, round(0.35 * d)), dead notes min(30, d)). + */ +export const rhythmToMidiNotes = (pattern: RhythmPattern, slots: readonly HarmonySlot[], tuning: Tuning): MidiNoteEvent[] => + resolveNotes(pattern, slots, tuning).flatMap(({ event, midi, gridTicks }) => { + const durationTicks = + event.target.kind === 'dead' + ? Math.min(DEAD_NOTE_TICKS, gridTicks) + : event.palmMute + ? Math.max(1, Math.round(PALM_MUTE_DURATION_FACTOR * gridTicks)) + : gridTicks; + const velocity = Math.min(127, Math.max(1, Math.round(hitVelocity(event) * 127))); + return [...new Set(midi)] + .sort((a, b) => a - b) + .map((pitch) => ({ tick: event.tick, durationTicks, pitch, velocity })); + }); diff --git a/engine/rhythm/styles.test.ts b/engine/rhythm/styles.test.ts new file mode 100644 index 0000000..3616bb3 --- /dev/null +++ b/engine/rhythm/styles.test.ts @@ -0,0 +1,124 @@ +// Each style must carry its characteristic figure for every seed, not just on average. +import { describe, it, expect } from 'vitest'; +import { generateRhythm } from './generate'; +import { defaultRhythmParams } from './styles'; +import { cycleGrouping } from './grid'; +import type { RhythmEvent, RhythmPattern, RhythmStyleId } from '../types'; + +const SEEDS = ['a', 'b', 'c', 'riff-1', 'riff-2', 'djent', 'x9', '42', 'night', 'sludge', 'z', 'Q7', 'grind', 'doom']; + +const gen = (style: RhythmStyleId, seed: string) => generateRhythm(defaultRhythmParams(style), seed); +const attacks = (p: RhythmPattern): RhythmEvent[] => p.events.filter((e) => !e.tie); +const isSlotHit = (e: RhythmEvent) => e.target.kind === 'slot' || e.target.kind === 'dyad'; + +describe.each(SEEDS)('style characteristics (seed %s)', (seed) => { + it('chugEngine: a run of palm-muted pedal 16ths and accented chord stabs', () => { + const p = gen('chugEngine', seed); + const ev = p.events; + const run = ev.some( + (e, i) => + i + 2 < ev.length && + [e, ev[i + 1], ev[i + 2]].every((x) => !x.tie && x.target.kind === 'pedal' && x.palmMute) && + ev[i + 1].tick - e.tick === 120 && + ev[i + 2].tick - ev[i + 1].tick === 120 + ); + expect(run).toBe(true); + expect(attacks(p).some((e) => e.accent > 0 && isSlotHit(e) && !e.palmMute)).toBe(true); + }); + + it('gallop: an 8th + 16th + 16th figure on the beat', () => { + const ev = gen('gallop', seed).events; + const found = ev.some( + (e, i) => + i + 2 < ev.length && + !e.tie && + !ev[i + 1].tie && + !ev[i + 2].tie && + e.tick % 480 === 0 && + e.durationTicks === 240 && + ev[i + 1].tick - e.tick === 240 && + ev[i + 2].tick - ev[i + 1].tick === 120 + ); + expect(found).toBe(true); + }); + + it('reverseGallop: a 16th + 16th + 8th figure on the beat', () => { + const ev = gen('reverseGallop', seed).events; + const found = ev.some( + (e, i) => + i + 2 < ev.length && + !e.tie && + !ev[i + 1].tie && + !ev[i + 2].tie && + e.tick % 480 === 0 && + ev[i + 1].tick - e.tick === 120 && + ev[i + 2].tick - ev[i + 1].tick === 120 && + ev[i + 2].durationTicks === 240 + ); + expect(found).toBe(true); + }); + + it('displacedThrees: accented chords on units 0, 3, 6, 9 and 12 of every bar', () => { + const p = gen('displacedThrees', seed); + for (let bar = 0; bar < p.bars; bar++) { + const accented = attacks(p) + .filter((e) => e.accent > 0 && Math.floor(e.tick / 1920) === bar) + .map((e) => (e.tick % 1920) / 120); + expect(accented).toEqual([0, 3, 6, 9, 12]); + } + }); + + it('halftimeStomp: accented chords on beats 1 and 3, sparse, all downstrokes', () => { + const p = gen('halftimeStomp', seed); + for (let bar = 0; bar < p.bars; bar++) { + for (const offset of [0, 960]) { + const e = p.events.find((x) => x.tick === bar * 1920 + offset); + expect(e && !e.tie && e.accent > 0 && isSlotHit(e)).toBe(true); + } + expect(attacks(p).filter((e) => Math.floor(e.tick / 1920) === bar).length).toBeLessThanOrEqual(8); + } + expect(p.events.every((e) => e.pick === 'down')).toBe(true); + }); + + it('tremoloWall: 32nd (60-tick) onsets on dyads', () => { + const p = gen('tremoloWall', seed); + expect(p.events.some((e) => !e.tie && e.target.kind === 'dyad' && e.tick % 120 === 60 && e.durationTicks === 60)).toBe(true); + expect(p.tags).toContain('tremolo'); + }); + + it('syncopatedStabs: off-beat accented stabs and rests', () => { + const p = gen('syncopatedStabs', seed); + expect(attacks(p).some((e) => e.accent > 0 && isSlotHit(e) && e.tick % 480 !== 0)).toBe(true); + const rest = p.events.some((e, i) => i + 1 < p.events.length && e.tick + e.durationTicks < p.events[i + 1].tick); + expect(rest).toBe(true); + expect(p.tags).toContain('syncopated'); + }); + + it('sevenEight: 1680-tick bars in 2+2+3 with a strong accent on each downbeat only', () => { + const p = gen('sevenEight', seed); + expect(cycleGrouping(p.params, p.seed)).toEqual([4, 4, 6]); + expect(p.events.filter((e) => e.accent === 2).map((e) => e.tick)).toEqual([0, 1680]); + expect(p.events.every((e) => e.tick < 3360)).toBe(true); + }); + + it('fiveOverFour: strong accents every 5 sixteenths, crossing the barline', () => { + const p = gen('fiveOverFour', seed); + expect(p.cycleTicks).toBe(600); + const strong = p.events.filter((e) => e.accent === 2).map((e) => e.tick / 120); + expect(strong).toEqual([0, 5, 10, 15, 20, 25, 30]); + }); +}); + +describe('style tags', () => { + it('tags patterns with their style vocabulary', () => { + expect(gen('gallop', 't').tags).toContain('gallop'); + expect(gen('reverseGallop', 't').tags).toContain('gallop'); + expect(gen('halftimeStomp', 't').tags).toContain('halftime'); + expect(gen('sevenEight', 't').tags).toContain('odd-meter'); + expect(gen('fiveOverFour', 't').tags).toContain('polymeter'); + expect(gen('tremoloWall', 't').tags).toContain('tremolo'); + expect(gen('syncopatedStabs', 't').tags).toContain('syncopated'); + const tags = gen('chugEngine', 't').tags; + expect(new Set(tags).size).toBe(tags.length); + }); +}); diff --git a/engine/rhythm/styles.ts b/engine/rhythm/styles.ts new file mode 100644 index 0000000..d574238 --- /dev/null +++ b/engine/rhythm/styles.ts @@ -0,0 +1,291 @@ +// Style presets: user-facing defaults (RhythmParams) plus the internal cell recipe each style generates from. +// All figures are generic metal rhythm vocabulary, written from scratch; nothing is transcribed. +// Every rate below is a hand-tuned heuristic, not a research-backed value. +import type { RhythmParams, RhythmStyleId } from '../types'; + +export type CellKind = + | 'chug16' + | 'chug8' + | 'gallop' + | 'reverseGallop' + | 'stab' + | 'rest' + | 'tripletChug' + | 'tremolo' + | 'halftime'; + +export interface StyleRecipe { + /** Base weights of the cells this style draws from (modulated by density and syncopation). */ + cells: Partial>; + /** Chance a non-cycle-start group head is accented. */ + accentRate: number; + /** Times params.syncopation: chance a group starts with a rest so its head lands off the beat. */ + offbeatRate: number; + /** Times params.syncopation: chance an accented head is anticipated and tied over the group start. */ + pushRate: number; + /** Chance an accented (non-cycle-start) head moves the harmony to the next slot. */ + harmonyMoveRate: number; + /** Chance the harmony advances at each new accent cycle. */ + cycleMoveRate: number; + /** Chance a repeated group is redrawn instead of copying the first cycle. */ + variation: number; + /** Chance the last full group of the last cycle is redrawn (turnaround). */ + turnaround: number; + /** Chance a non-head hit becomes a dead (muted scratch) note. */ + deadRate: number; + /** Chance a stab is choked after at most two units instead of ringing through its group. */ + stabChoke: number; + /** Chance a ringing stab sustains into a silent next group through a tie. */ + tieOverRate: number; + /** Target kind of accented heads and non-pedal fills. */ + headTarget: 'slot' | 'dyad'; + /** Groups on beats 1 and 3 always get a sustained, accented halftime chord. */ + halftimeStrong: boolean; + /** The figure every pattern of the style must contain at least once. */ + signature: CellKind | null; + tags: readonly string[]; +} + +export interface RhythmStyleInfo { + label: string; + description: string; + params: RhythmParams; +} + +export const RHYTHM_STYLE_IDS: readonly RhythmStyleId[] = [ + 'chugEngine', + 'gallop', + 'reverseGallop', + 'displacedThrees', + 'halftimeStomp', + 'tremoloWall', + 'syncopatedStabs', + 'sevenEight', + 'fiveOverFour' +]; + +const params = (style: RhythmStyleId, overrides: Partial): RhythmParams => ({ + style, + meter: { numerator: 4, denominator: 4 }, + bars: 2, + grid: '16th', + grouping: 'even', + density: 0.7, + syncopation: 0.2, + pedalRatio: 0.75, + palmMuteRatio: 0.75, + slotCount: 2, + picking: 'alternate', + anticipation: false, + ...overrides +}); + +const freezeInfo = (info: RhythmStyleInfo): RhythmStyleInfo => { + Object.freeze(info.params.meter); + if (typeof info.params.grouping !== 'string') Object.freeze(info.params.grouping); + Object.freeze(info.params); + return Object.freeze(info); +}; + +export const RHYTHM_STYLES: Readonly> = Object.freeze({ + chugEngine: freezeInfo({ + label: 'Chug Engine', + description: 'Dense palm-muted 16th chugs on the pedal; accented open chord stabs move the harmony.', + params: params('chugEngine', { density: 0.8, syncopation: 0.25, pedalRatio: 0.8, palmMuteRatio: 0.85 }) + }), + gallop: freezeInfo({ + label: 'Gallop', + description: 'An 8th and two 16ths on a muted pedal, chords on the accents. Picked down, down-up.', + params: params('gallop', { density: 0.7, syncopation: 0.15, pedalRatio: 0.8, palmMuteRatio: 0.8 }) + }), + reverseGallop: freezeInfo({ + label: 'Reverse Gallop', + description: 'Two 16ths and an 8th: the gallop turned around so each figure lands on the longer note.', + params: params('reverseGallop', { density: 0.7, syncopation: 0.15, pedalRatio: 0.8, palmMuteRatio: 0.8 }) + }), + displacedThrees: freezeInfo({ + label: 'Displaced Threes', + description: 'Accents grouped 3-3-3-3-4 sixteenths: they slide against the beat and realign every bar.', + params: params('displacedThrees', { + grouping: '3-3-3-3-4', + density: 0.75, + syncopation: 0, + pedalRatio: 0.85, + palmMuteRatio: 0.85 + }) + }), + halftimeStomp: freezeInfo({ + label: 'Halftime Stomp', + description: 'Half-speed feel: big ringing chords on beats 1 and 3, sparse pedal pickups between. All downstrokes.', + params: params('halftimeStomp', { + density: 0.35, + syncopation: 0.15, + pedalRatio: 0.75, + palmMuteRatio: 0.6, + picking: 'downstrokes' + }) + }), + tremoloWall: freezeInfo({ + label: 'Tremolo Wall', + description: 'Continuous tremolo-picked dyads in 32nds; the harmony shifts on the accents.', + params: params('tremoloWall', { density: 0.9, syncopation: 0.1, pedalRatio: 0.1, palmMuteRatio: 0.15 }) + }), + syncopatedStabs: freezeInfo({ + label: 'Syncopated Stabs', + description: 'Short chord stabs on a 3-3-2 pulse, with rests and pushes tied over the beat.', + params: params('syncopatedStabs', { + grouping: '3-3-2', + density: 0.45, + syncopation: 0.75, + pedalRatio: 0.6, + palmMuteRatio: 0.6 + }) + }), + sevenEight: freezeInfo({ + label: 'Seven Eight', + description: '7/8 felt as 2+2+3 eighths: two short beats and a long one, accented every group.', + params: params('sevenEight', { + meter: { numerator: 7, denominator: 8 }, + density: 0.7, + syncopation: 0.2, + pedalRatio: 0.75, + palmMuteRatio: 0.8 + }) + }), + fiveOverFour: freezeInfo({ + label: 'Five Over Four', + description: 'A 5-sixteenth figure looping over 4/4: its accent drifts across the barline and realigns after 5 bars.', + params: params('fiveOverFour', { + grouping: [3, 2], + cycleSixteenths: 5, + density: 0.7, + syncopation: 0.1, + pedalRatio: 0.85, + palmMuteRatio: 0.85 + }) + }) +}); + +const recipe = (overrides: Partial & Pick): StyleRecipe => ({ + accentRate: 0.4, + offbeatRate: 0.2, + pushRate: 0.3, + harmonyMoveRate: 0.5, + cycleMoveRate: 0.6, + variation: 0.12, + turnaround: 0.6, + deadRate: 0, + stabChoke: 0.4, + tieOverRate: 0.3, + headTarget: 'slot', + halftimeStrong: false, + signature: null, + ...overrides +}); + +export const STYLE_RECIPES: Readonly> = { + chugEngine: recipe({ + cells: { chug16: 1, gallop: 0.3, chug8: 0.35, stab: 0.25, rest: 0.1 }, + accentRate: 0.45, + offbeatRate: 0.3, + pushRate: 0.35, + variation: 0.15, + deadRate: 0.04, + signature: 'chug16', + tags: ['chug', 'palm-mute'] + }), + gallop: recipe({ + cells: { gallop: 1, chug8: 0.25, chug16: 0.2, stab: 0.2, rest: 0.05 }, + stabChoke: 0.3, + signature: 'gallop', + tags: ['gallop'] + }), + reverseGallop: recipe({ + cells: { reverseGallop: 1, chug8: 0.25, chug16: 0.2, stab: 0.2, rest: 0.05 }, + stabChoke: 0.3, + signature: 'reverseGallop', + tags: ['gallop', 'reverse-gallop'] + }), + // No rests, offbeats or pushes: the 3-3-3-3-4 accent grid itself is the point. + displacedThrees: recipe({ + cells: { chug16: 1, gallop: 0.3, chug8: 0.2, stab: 0.2 }, + accentRate: 1, + offbeatRate: 0, + pushRate: 0, + variation: 0.12, + turnaround: 0.5, + stabChoke: 0.5, + tieOverRate: 0, + tags: ['displaced', 'syncopated'] + }), + halftimeStomp: recipe({ + cells: { halftime: 1, chug8: 0.2, rest: 0.15 }, + accentRate: 0.25, + offbeatRate: 0.3, + pushRate: 0, + harmonyMoveRate: 0.4, + variation: 0.2, + deadRate: 0.05, + stabChoke: 0, + tieOverRate: 0.6, + halftimeStrong: true, + tags: ['halftime', 'breakdown'] + }), + tremoloWall: recipe({ + cells: { tremolo: 1, stab: 0.12, chug16: 0.1 }, + accentRate: 0.3, + pushRate: 0.25, + harmonyMoveRate: 0.6, + cycleMoveRate: 0.7, + variation: 0.1, + turnaround: 0.5, + stabChoke: 0, + tieOverRate: 0.2, + headTarget: 'dyad', + signature: 'tremolo', + tags: ['tremolo'] + }), + syncopatedStabs: recipe({ + cells: { stab: 1, chug8: 0.35, chug16: 0.3, gallop: 0.15, rest: 0.45 }, + accentRate: 0.85, + offbeatRate: 0.5, + pushRate: 0.45, + variation: 0.15, + deadRate: 0.12, + stabChoke: 0.75, + signature: 'stab', + tags: ['syncopated'] + }), + // Every 2+2+3 group accented and no pushes, so the meter stays audible. + sevenEight: recipe({ + cells: { chug16: 0.8, gallop: 0.6, reverseGallop: 0.25, chug8: 0.4, stab: 0.25, rest: 0.05 }, + accentRate: 0.9, + offbeatRate: 0.15, + pushRate: 0, + tieOverRate: 0.2, + tags: ['odd-meter'] + }), + // The 5-unit figure must repeat almost unchanged for the polymeter to be heard. + fiveOverFour: recipe({ + cells: { chug16: 0.6, chug8: 0.6, gallop: 0.4, stab: 0.3, rest: 0.25 }, + accentRate: 0.15, + offbeatRate: 0, + pushRate: 0, + harmonyMoveRate: 0.4, + cycleMoveRate: 0.35, + variation: 0.05, + turnaround: 0.2, + stabChoke: 0.5, + tieOverRate: 0, + tags: ['polymeter'] + }) +}; + +export const defaultRhythmParams = (style: RhythmStyleId): RhythmParams => { + const base = (RHYTHM_STYLES[style] ?? RHYTHM_STYLES.chugEngine).params; + return { + ...base, + meter: { ...base.meter }, + grouping: typeof base.grouping === 'string' ? base.grouping : [...base.grouping] + }; +}; diff --git a/engine/rhythm/validate.test.ts b/engine/rhythm/validate.test.ts new file mode 100644 index 0000000..e061713 --- /dev/null +++ b/engine/rhythm/validate.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { validateRhythm } from './validate'; +import { defaultRhythmParams } from './styles'; +import type { HitTarget, RhythmEvent, RhythmParams, RhythmPattern } from '../types'; + +const PEDAL: HitTarget = { kind: 'pedal' }; + +const hit = (tick: number, durationTicks: number, extra: Partial = {}): RhythmEvent => ({ + tick, + durationTicks, + target: PEDAL, + palmMute: true, + accent: 0, + pick: (tick / 120) % 2 === 0 ? 'down' : 'up', + ...extra +}); + +const pattern = (events: RhythmEvent[], params: Partial = {}): RhythmPattern => ({ + id: 'test', + name: 'Test', + seed: 'test', + meter: { numerator: 4, denominator: 4 }, + bars: 1, + ppq: 480, + events, + tags: [], + params: { ...defaultRhythmParams('chugEngine'), bars: 1, ...params } +}); + +const sixteenths = (count: number) => Array.from({ length: count }, (_, i) => hit(i * 120, 120)); + +describe('validateRhythm errors', () => { + it('accepts a clean pattern', () => { + expect(validateRhythm(pattern(sixteenths(16)))).toEqual({ ok: true, errors: [], warnings: [] }); + }); + + it('flags unsorted ticks, non-positive durations, overlaps and events past the end', () => { + expect(validateRhythm(pattern([hit(0, 120), hit(240, 120), hit(120, 120)])).errors.join(' ')).toMatch(/sorted/); + expect(validateRhythm(pattern([hit(0, 0)])).errors.join(' ')).toMatch(/duration/); + expect(validateRhythm(pattern([hit(0, 240), hit(120, 120)])).errors.join(' ')).toMatch(/overlap/); + expect(validateRhythm(pattern([hit(0, 120), hit(1800, 240)])).errors.join(' ')).toMatch(/pattern length/); + expect(validateRhythm(pattern([hit(0, 120), hit(1920, 120)])).errors.join(' ')).toMatch(/pattern length/); + }); + + it('flags a bar count or meter that disagrees with the params', () => { + expect(validateRhythm({ ...pattern(sixteenths(4)), bars: 2 }).ok).toBe(false); + expect(validateRhythm({ ...pattern(sixteenths(4)), meter: { numerator: 0, denominator: 4 } }).ok).toBe(false); + }); + + it('flags picks that disagree with the picking mode', () => { + expect(validateRhythm(pattern([hit(0, 120), hit(120, 120, { pick: 'down' })])).errors.join(' ')).toMatch(/pick/); + const allDown = sixteenths(4).map((e) => ({ ...e, pick: 'down' as const })); + expect(validateRhythm(pattern(allDown, { picking: 'downstrokes' })).ok).toBe(true); + expect(validateRhythm(pattern(sixteenths(4), { picking: 'downstrokes' })).ok).toBe(false); + }); + + it('requires an attack in the first beat unless anticipation is allowed', () => { + const late = [hit(480, 120)]; + expect(validateRhythm(pattern(late)).errors.join(' ')).toMatch(/first beat/); + expect(validateRhythm(pattern(late, { anticipation: true })).ok).toBe(true); + const tieOnly = [hit(0, 480, { target: { kind: 'slot', slot: 0 }, palmMute: false }), hit(480, 120)]; + expect(validateRhythm(pattern(tieOnly)).ok).toBe(true); + }); + + it('flags slot indices outside slotCount', () => { + const events = [hit(0, 120, { target: { kind: 'slot', slot: 2 } })]; + expect(validateRhythm(pattern(events, { slotCount: 2 })).errors.join(' ')).toMatch(/slot/); + const dyad = [hit(0, 120, { target: { kind: 'dyad', slot: 1 } })]; + expect(validateRhythm(pattern(dyad, { slotCount: 1 })).ok).toBe(false); + expect(validateRhythm(pattern(dyad, { slotCount: 2 })).ok).toBe(true); + }); + + it('flags a tie as first event and ties that do not continue the previous sound', () => { + expect(validateRhythm(pattern([hit(0, 120, { tie: true })], { anticipation: true })).errors.join(' ')).toMatch(/tie/); + const slot0: HitTarget = { kind: 'slot', slot: 0 }; + const good = [hit(0, 240, { target: slot0, palmMute: false }), hit(240, 240, { target: slot0, palmMute: false, tie: true })]; + expect(validateRhythm(pattern(good)).ok).toBe(true); + const gap = [hit(0, 120, { target: slot0 }), hit(240, 240, { target: slot0, tie: true })]; + expect(validateRhythm(pattern(gap)).errors.join(' ')).toMatch(/tie/); + const otherTarget = [hit(0, 240, { target: slot0 }), hit(240, 240, { tie: true })]; + expect(validateRhythm(pattern(otherTarget)).errors.join(' ')).toMatch(/tie/); + }); +}); + +describe('validateRhythm tooFastForProfile', () => { + it('warns above the tempo where the fastest notes exceed the comfortable sixteenth tempo', () => { + const p = pattern(sixteenths(16)); + expect(validateRhythm(p, { bpm: 110, comfortableSixteenthBpm: 110 }).warnings).toEqual([]); + const fast = validateRhythm(p, { bpm: 111, comfortableSixteenthBpm: 110 }); + expect(fast.ok).toBe(true); + expect(fast.warnings).toHaveLength(1); + expect(fast.warnings[0]).toMatchObject({ code: 'tooFastForProfile', minIoiTicks: 120, bpmLimit: 110 }); + }); + + it('scales the limit with the smallest inter-onset interval', () => { + const eighths = pattern(Array.from({ length: 8 }, (_, i) => hit(i * 240, 240))); + expect(validateRhythm(eighths, { bpm: 220, comfortableSixteenthBpm: 110 }).warnings).toEqual([]); + expect(validateRhythm(eighths, { bpm: 221, comfortableSixteenthBpm: 110 }).warnings[0].bpmLimit).toBe(220); + const thirtySeconds = pattern(Array.from({ length: 4 }, (_, i) => hit(i * 60, 60, { pick: i % 2 === 0 ? 'down' : 'up' }))); + const w = validateRhythm(thirtySeconds, { bpm: 60, comfortableSixteenthBpm: 110 }).warnings[0]; + expect(w).toMatchObject({ minIoiTicks: 60, bpmLimit: 55 }); + const triplets = pattern(Array.from({ length: 6 }, (_, i) => hit(i * 80, 80)).map((e, i) => ({ ...e, pick: i % 2 === 0 ? 'down' : 'up' }))); + expect(validateRhythm(triplets, { bpm: 74, comfortableSixteenthBpm: 110 }).warnings[0].bpmLimit).toBe(73); + }); + + it('ignores ties when measuring the inter-onset interval', () => { + const slot0: HitTarget = { kind: 'slot', slot: 0 }; + const events = [ + hit(0, 120, { target: slot0, palmMute: false }), + hit(120, 120, { target: slot0, palmMute: false, tie: true }), + hit(240, 240) + ]; + const w = validateRhythm(pattern(events), { bpm: 300, comfortableSixteenthBpm: 110 }).warnings[0]; + expect(w.minIoiTicks).toBe(240); + }); + + it('defaults to the default hand profile tempo and needs a bpm to warn', () => { + const p = pattern(sixteenths(16)); + expect(validateRhythm(p, { bpm: 115 }).warnings).toHaveLength(1); + expect(validateRhythm(p).warnings).toEqual([]); + }); +}); diff --git a/engine/rhythm/validate.ts b/engine/rhythm/validate.ts new file mode 100644 index 0000000..141f0e6 --- /dev/null +++ b/engine/rhythm/validate.ts @@ -0,0 +1,93 @@ +import type { RhythmPattern, RhythmValidation, RhythmWarning } from '../types'; +import { DEFAULT_HAND_PROFILE } from '../handProfile'; +import { PPQ, barTicks, firstBeatTicks } from './grid'; +import { assignPicks } from './picking'; +import { isSlotTarget, sameTarget } from './events'; + +export interface RhythmValidateOptions { + /** Playback tempo; without it the tooFastForProfile check is skipped. */ + bpm?: number; + /** Defaults to the default hand profile's value. */ + comfortableSixteenthBpm?: number; +} + +const MAX_PICK_ERRORS = 3; + +export const validateRhythm = (pattern: RhythmPattern, opts: RhythmValidateOptions = {}): RhythmValidation => { + const errors: string[] = []; + const { meter, bars, params, events } = pattern; + + if (!Number.isInteger(meter.numerator) || meter.numerator < 1 || (meter.denominator !== 4 && meter.denominator !== 8)) { + return { ok: false, errors: [`invalid meter ${meter.numerator}/${meter.denominator}`], warnings: [] }; + } + if (!Number.isInteger(bars) || bars < 1) return { ok: false, errors: [`invalid bar count ${bars}`], warnings: [] }; + if (pattern.ppq !== PPQ) errors.push(`ppq must be ${PPQ}`); + if (params.bars !== bars || params.meter.numerator !== meter.numerator || params.meter.denominator !== meter.denominator) { + errors.push('pattern length differs from params: bars and meter must match params'); + } + + const bar = barTicks(meter); + const length = bars * bar; + events.forEach((e, i) => { + const at = `event ${i} (tick ${e.tick})`; + if (!Number.isInteger(e.tick) || e.tick < 0) errors.push(`${at}: tick must be a non-negative integer`); + if (!(e.durationTicks > 0)) errors.push(`${at}: duration must be > 0`); + if (e.tick + e.durationTicks > length) errors.push(`${at}: ends beyond the pattern length ${length}`); + if (e.accent !== 0 && e.accent !== 1 && e.accent !== 2) errors.push(`${at}: accent must be 0, 1 or 2`); + if (isSlotTarget(e.target)) { + const { slot } = e.target; + if (!Number.isInteger(slot) || slot < 0 || slot >= params.slotCount) { + errors.push(`${at}: slot ${slot} outside slotCount ${params.slotCount}`); + } + } + if (i > 0) { + const prev = events[i - 1]; + if (e.tick <= prev.tick) errors.push(`${at}: ticks not sorted (previous ${prev.tick})`); + else if (prev.tick + prev.durationTicks > e.tick) errors.push(`${at}: overlaps the previous event`); + } + if (e.tie) { + const prev = events[i - 1]; + if (!prev) errors.push(`${at}: tie as first event`); + else if (prev.tick + prev.durationTicks !== e.tick) errors.push(`${at}: tie does not continue the previous event`); + else if (!sameTarget(prev.target, e.target)) errors.push(`${at}: tie target differs from the previous event`); + } + }); + + const expected = assignPicks(events, params.picking, bar); + const wrongPicks = events.flatMap((e, i) => (e.pick === expected[i] ? [] : [i])); + wrongPicks.slice(0, MAX_PICK_ERRORS).forEach((i) => { + errors.push(`event ${i} (tick ${events[i].tick}): pick ${events[i].pick} inconsistent with ${params.picking} picking (expected ${expected[i]})`); + }); + if (wrongPicks.length > MAX_PICK_ERRORS) errors.push(`${wrongPicks.length - MAX_PICK_ERRORS} more pick errors`); + + if (!params.anticipation && !events.some((e) => !e.tie && e.tick < firstBeatTicks(meter))) { + errors.push('no attack in the first beat (set anticipation to allow a pickup feel)'); + } + + return { ok: errors.length === 0, errors, warnings: speedWarnings(pattern, opts) }; +}; + +/** + * notesPerBeat = 480 / minIoi is compared with the comfortable rate 4 * comfortableSixteenthBpm / bpm. + * Compared without division to avoid float edges: warn when 480 * bpm > 4 * comfortable * minIoi. + */ +const speedWarnings = (pattern: RhythmPattern, opts: RhythmValidateOptions): RhythmWarning[] => { + const { bpm } = opts; + if (bpm === undefined || !(bpm > 0)) return []; + const comfortable = opts.comfortableSixteenthBpm ?? DEFAULT_HAND_PROFILE.comfortableSixteenthBpm; + const onsets = pattern.events.filter((e) => !e.tie).map((e) => e.tick); + if (onsets.length < 2) return []; + let minIoi = Infinity; + for (let i = 1; i < onsets.length; i++) minIoi = Math.min(minIoi, onsets[i] - onsets[i - 1]); + if (!(minIoi > 0) || PPQ * bpm <= 4 * comfortable * minIoi) return []; + const bpmLimit = Math.floor((4 * comfortable * minIoi) / PPQ); + const notesPerBeat = PPQ / minIoi; + return [ + { + code: 'tooFastForProfile', + message: `The fastest notes come ${Number(notesPerBeat.toFixed(2))} per beat; above ${bpmLimit} BPM that is faster than your comfortable 16th tempo of ${comfortable} BPM.`, + minIoiTicks: minIoi, + bpmLimit + } + ]; +}; diff --git a/engine/rhythm/variation.test.ts b/engine/rhythm/variation.test.ts new file mode 100644 index 0000000..d0a25d7 --- /dev/null +++ b/engine/rhythm/variation.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import { mutateRhythm, regenerateRhythm } from './variation'; +import { generateRhythm } from './generate'; +import { RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles'; +import { validateRhythm } from './validate'; +import { barTicks } from './grid'; +import { rhythmToPlaybackEvents } from './playback'; +import { E_STANDARD } from '../tuning'; +import type { HarmonySlot, HitTarget, RhythmEvent, RhythmPattern } from '../types'; + +const SEEDS = ['a', 'b', 'c', 'riff-1', 'djent', '42']; + +const expectValid = (p: RhythmPattern) => expect(validateRhythm(p).errors).toEqual([]); +const eventsInBar = (p: RhythmPattern, bar: number) => + p.events.filter((e) => Math.floor(e.tick / barTicks(p.meter)) === bar); + +/** Ticks where the harmony slot changes on something other than an accented attack. */ +const unaccentedSlotChanges = (p: RhythmPattern): number[] => { + let current: number | null = null; + const bad: number[] = []; + for (const e of p.events) { + if (e.target.kind !== 'slot' && e.target.kind !== 'dyad') continue; + if (current !== null && e.target.slot !== current && (e.tie || e.accent === 0)) bad.push(e.tick); + current = e.target.slot; + } + return bad; +}; + +const SLOTS: HarmonySlot[] = [ + { shape: [0, 2, 2, null, null, null], pedalMidi: 40 }, + { shape: [1, 3, 3, null, null, null], pedalMidi: 41 }, + { shape: [3, 5, 5, null, null, null], pedalMidi: 43 }, + { shape: [5, 7, 7, null, null, null], pedalMidi: 45 } +]; + +/** What the notes that start in these bars sound like: pitches, timing, length and velocity. */ +const soundOfBars = (p: RhythmPattern, bars: readonly number[]) => + rhythmToPlaybackEvents(p, SLOTS, E_STANDARD, 120).filter((e) => bars.includes(Math.floor(e.tick / barTicks(p.meter)))); + +const LOCK_SETS = [[0], [1], [2], [1, 2]]; + +describe('mutateRhythm', () => { + it('keeps validity and changes something for every style', () => { + for (const style of RHYTHM_STYLE_IDS) { + for (const seed of SEEDS) { + const base = generateRhythm(defaultRhythmParams(style), seed); + for (const m of ['m1', 'm2', 'm3', 'm4']) { + const mutated = mutateRhythm(base, m); + expectValid(mutated); + expect(mutated.events).not.toEqual(base.events); + expect(mutated.params).toEqual(base.params); + } + } + } + }); + + it('is deterministic per seed, varies across seeds and leaves the input alone', () => { + const base = generateRhythm(defaultRhythmParams('gallop'), 'mut'); + const snapshot = JSON.stringify(base); + expect(mutateRhythm(base, 'x')).toEqual(mutateRhythm(base, 'x')); + const distinct = new Set(['1', '2', '3', '4', '5', '6'].map((s) => JSON.stringify(mutateRhythm(base, s).events))); + expect(distinct.size).toBeGreaterThanOrEqual(3); + expect(JSON.stringify(base)).toBe(snapshot); + }); + + it('is only a small change', () => { + for (const style of RHYTHM_STYLE_IDS) { + const base = generateRhythm({ ...defaultRhythmParams(style), bars: 4 }, 'small'); + const mutated = mutateRhythm(base, 'small'); + const key = (e: RhythmEvent) => JSON.stringify([e.tick, e.durationTicks, e.target, e.palmMute, e.accent, !!e.tie]); + const before = new Set(base.events.map(key)); + const changed = mutated.events.filter((e) => !before.has(key(e))).length; + expect(changed).toBeLessThanOrEqual(Math.ceil(base.events.length / 3)); + } + }); + + it('never makes the harmony change on an unaccented hit, also when pushes are re-rendered', () => { + for (const style of RHYTHM_STYLE_IDS) { + for (const bars of [2, 4]) { + for (const slotCount of [2, 3, 4]) { + for (let i = 0; i < 40; i++) { + const base = generateRhythm({ ...defaultRhythmParams(style), bars, slotCount, syncopation: 1 }, `s${i}`); + for (const m of ['m1', 'm6']) { + expect(unaccentedSlotChanges(mutateRhythm(base, m)), `${style} ${bars} ${slotCount} s${i} ${m}`).toEqual([]); + } + } + } + } + } + }); + + it('can still mutate a hand-edited pattern with a single slot', () => { + const base = generateRhythm({ ...defaultRhythmParams('chugEngine'), slotCount: 1 }, 'one'); + const mutated = mutateRhythm(base, 'one'); + expectValid(mutated); + expect(mutated.events).not.toEqual(base.events); + }); +}); + +describe('regenerateRhythm', () => { + it('keeps every event of the locked bars exactly and stays valid', () => { + for (const style of RHYTHM_STYLE_IDS) { + for (const seed of SEEDS) { + const base = generateRhythm({ ...defaultRhythmParams(style), bars: 4 }, seed); + for (const locked of [[0], [1], [3], [0, 2], [1, 2]]) { + const next = regenerateRhythm(base, `${seed}-new`, locked); + expectValid(next); + for (const bar of locked) expect(eventsInBar(next, bar)).toEqual(eventsInBar(base, bar)); + expect(next.seed).toBe(`${seed}-new`); + } + } + } + }); + + it('changes the unlocked bars for most seeds', () => { + let changed = 0; + for (const seed of SEEDS) { + const base = generateRhythm(defaultRhythmParams('chugEngine'), seed); + const next = regenerateRhythm(base, `${seed}-other`, [0]); + if (JSON.stringify(eventsInBar(next, 1)) !== JSON.stringify(eventsInBar(base, 1))) changed++; + } + expect(changed).toBeGreaterThanOrEqual(SEEDS.length - 1); + }); + + it('equals a fresh generation when nothing is locked and the original when everything is', () => { + const base = generateRhythm(defaultRhythmParams('gallop'), 'base'); + expect(regenerateRhythm(base, 'fresh', [])).toEqual(generateRhythm(defaultRhythmParams('gallop'), 'fresh')); + expect(regenerateRhythm(base, 'fresh', [0, 1]).events).toEqual(base.events); + expect(regenerateRhythm(base, 'fresh', [7, -1, 0.5])).toEqual(generateRhythm(defaultRhythmParams('gallop'), 'fresh')); + }); + + it('changes the harmony only on accented attacks, also at the edges of locked bars', () => { + const five = regenerateRhythm(generateRhythm(defaultRhythmParams('fiveOverFour'), 'd8'), 'e8', [0]); + expect(unaccentedSlotChanges(five)).toEqual([]); + for (const style of RHYTHM_STYLE_IDS) { + for (const slotCount of [2, 3, 4]) { + for (const i of [0, 1, 2, 3, 4, 5, 6, 7]) { + const base = generateRhythm({ ...defaultRhythmParams(style), bars: 4, slotCount }, `h${i}`); + for (const locked of LOCK_SETS) { + const next = regenerateRhythm(base, `h${i}-new`, locked); + expect(unaccentedSlotChanges(next), `${style} h${i} slots ${slotCount} locked ${locked}`).toEqual([]); + } + } + } + } + }); + + it('turns a fresh push whose head fell in a locked bar back into an accented downbeat', () => { + const base = generateRhythm({ ...defaultRhythmParams('gallop'), bars: 4, slotCount: 3 }, 'q21'); + const next = regenerateRhythm(base, 'w21', [0]); + expectValid(next); + const downbeat = next.events.find((e) => e.tick === 1920); + expect(downbeat?.tie).toBeUndefined(); + expect(downbeat?.accent).toBe(2); + expect(next.events.some((e) => e.tie && e.palmMute)).toBe(false); + }); + + it('keeps the sound of the locked bars: pedal pitch, pushes and their ties', () => { + const push = generateRhythm(defaultRhythmParams('chugEngine'), 'orig2'); + const kept = regenerateRhythm(push, 'new2', [0]); + expect(kept.events.find((e) => e.tick === 1920)).toMatchObject({ tie: true }); + expect(soundOfBars(kept, [0])).toEqual(soundOfBars(push, [0])); + for (const style of RHYTHM_STYLE_IDS) { + for (const i of [0, 1, 2, 3, 4, 5]) { + const base = generateRhythm({ ...defaultRhythmParams(style), bars: 4, slotCount: 4 }, `orig${i}`); + for (const locked of LOCK_SETS) { + const next = regenerateRhythm(base, `new${i}`, locked); + expect(soundOfBars(next, locked), `${style} orig${i} locked ${locked}`).toEqual(soundOfBars(base, locked)); + } + } + } + }); + + it('recovers a pattern whose slotCount was lowered by hand', () => { + const base = generateRhythm({ ...defaultRhythmParams('syncopatedStabs'), slotCount: 4 }, 'k1'); + const stale: RhythmPattern = { ...base, params: { ...base.params, slotCount: 2 } }; + expect(validateRhythm(stale).ok).toBe(false); + expectValid(regenerateRhythm(stale, 'z', [0])); + const folded = regenerateRhythm(stale, 'z', [0, 1]); + expectValid(folded); + const mutated = mutateRhythm(stale, 'z'); + expectValid(mutated); + expect(mutated.events).not.toEqual(folded.events); + }); + + it('keeps the source of a tie that sustains into a locked bar', () => { + const slot1: HitTarget = { kind: 'slot', slot: 1 }; + const base: RhythmPattern = { + ...generateRhythm(defaultRhythmParams('chugEngine'), 'tie'), + events: [ + { tick: 0, durationTicks: 1800, target: { kind: 'slot', slot: 0 }, palmMute: false, accent: 2, pick: 'down' }, + { tick: 1800, durationTicks: 120, target: slot1, palmMute: false, accent: 1, pick: 'up' }, + { tick: 1920, durationTicks: 240, target: slot1, palmMute: false, accent: 0, pick: 'down', tie: true }, + { tick: 2160, durationTicks: 1680, target: { kind: 'pedal' }, palmMute: true, accent: 0, pick: 'down' } + ] + }; + expectValid(base); + for (const seed of SEEDS) { + const next = regenerateRhythm(base, seed, [1]); + expectValid(next); + expect(eventsInBar(next, 1)).toEqual(eventsInBar(base, 1)); + expect(next.events.find((e) => e.tick === 1800)).toMatchObject({ target: slot1, accent: 1 }); + } + }); +}); diff --git a/engine/rhythm/variation.ts b/engine/rhythm/variation.ts new file mode 100644 index 0000000..13b1bc7 --- /dev/null +++ b/engine/rhythm/variation.ts @@ -0,0 +1,285 @@ +// Seeded variations of an existing pattern: small mutations and regeneration around locked bars. +import type { HitTarget, RhythmEvent, RhythmPattern } from '../types'; +import { createRng, type Rng } from '../random'; +import { barTicks, buildGroups, type RhythmGroup } from './grid'; +import { drawPlan, generateRhythm, headAccent, makeContext, renderGroup, seedTag } from './generate'; +import { + copyEvent, + finalizeEvents, + finalizeOptions, + isSlotTarget, + makeEvent, + sameEvents, + slotTarget, + unaccentedSlotChangeCount, + withSlotsInRange +} from './events'; +import { validateRhythm } from './validate'; + +type Mutation = 'cell' | 'accent' | 'palmMute' | 'harmony'; + +const MUTATIONS: readonly Mutation[] = ['cell', 'accent', 'palmMute', 'harmony']; +const ATTEMPTS_PER_MUTATION = 6; + +const slotOf = (target: HitTarget): number => (isSlotTarget(target) ? target.slot : 0); + +const groupTicks = (group: RhythmGroup, unitTicks: number): [number, number] => [ + group.startUnit * unitTicks, + (group.startUnit + group.length) * unitTicks +]; + +/** Re-renders one accent group with a newly drawn cell; an accented chord there keeps its target and accent. */ +const mutateCell = (pattern: RhythmPattern, rng: Rng): RhythmEvent[] => { + const ctx = makeContext(pattern.params); + const group = rng.pick(buildGroups(pattern.params, pattern.seed)); + const [start, end] = groupTicks(group, ctx.unitTicks); + const inside = pattern.events.filter((e) => e.tick >= start && e.tick < end); + const outside = pattern.events.filter((e) => e.tick < start || e.tick >= end); + const accented = inside.find((e) => !e.tie && e.accent > 0 && isSlotTarget(e.target)); + const before = pattern.events.filter((e) => e.tick < start && isSlotTarget(e.target)).pop(); + const slot = accented ? slotOf(accented.target) : before ? slotOf(before.target) : 0; + + let plan = drawPlan(rng, group, ctx); + if (accented) plan = { ...plan, kind: plan.kind === 'rest' ? 'stab' : plan.kind, accent: true }; + const { recipe } = ctx; + const fresh = renderGroup(plan, group, ctx).map((hit) => { + const accent = hit.role === 'head' ? (accented ? accented.accent : headAccent(plan, group, ctx)) : 0; + if (accent > 0) { + const target = accented && !hit.tremolo ? accented.target : slotTarget(hit.tremolo ? 'dyad' : recipe.headTarget, slot); + return makeEvent(hit.tick, hit.dur, target, false, accent); + } + const target: HitTarget = hit.dead + ? { kind: 'dead' } + : hit.tremolo + ? slotTarget('dyad', slot) + : plan.fillPedal + ? { kind: 'pedal' } + : slotTarget(recipe.headTarget, slot); + return makeEvent(hit.tick, hit.dur, target, !hit.dead && plan.palmMute, 0); + }); + return [...outside, ...fresh]; +}; + +/** Moves one accented chord hit onto an adjacent pedal or dead hit (target travels with the accent). */ +const mutateAccent = (pattern: RhythmPattern, rng: Rng): RhythmEvent[] | null => { + const ev = pattern.events; + const isPlainFill = (e: RhythmEvent | undefined) => + !!e && !e.tie && e.accent === 0 && (e.target.kind === 'pedal' || e.target.kind === 'dead'); + const options: [number, number][] = []; + ev.forEach((e, i) => { + if (e.tie || e.accent === 0 || e.tick === 0) return; + for (const j of [i - 1, i + 1]) if (isPlainFill(ev[j])) options.push([i, j]); + }); + if (options.length === 0) return null; + const [i, j] = rng.pick(options); + const out = ev.map(copyEvent); + out[i] = { ...out[i], target: { ...ev[j].target }, palmMute: ev[j].palmMute, accent: 0 }; + out[j] = { ...out[j], target: { ...ev[i].target }, palmMute: false, accent: ev[i].accent }; + // The chord moved away, so ties that sustained it go too. + let k = i + 1; + while (k < out.length && out[k].tie) k++; + return [...out.slice(0, i + 1), ...out.slice(k)]; +}; + +/** Flips palm mute on the unaccented hits of one group, toward whichever state is rarer there. */ +const mutatePalmMute = (pattern: RhythmPattern, rng: Rng): RhythmEvent[] | null => { + const { unitTicks } = makeContext(pattern.params); + const ev = pattern.events; + const eligible = buildGroups(pattern.params, pattern.seed) + .map((g) => { + const [start, end] = groupTicks(g, unitTicks); + return ev.flatMap((e, i) => (e.tick >= start && e.tick < end && !e.tie && e.accent === 0 && e.target.kind !== 'dead' ? [i] : [])); + }) + .filter((list) => list.length > 0); + if (eligible.length === 0) return null; + const indices = rng.pick(eligible); + const muted = indices.filter((i) => ev[i].palmMute).length; + const value = muted * 2 < indices.length; + return ev.map((e, i) => (indices.includes(i) ? { ...copyEvent(e), palmMute: value } : copyEvent(e))); +}; + +/** Re-harmonises one accent (not the first) and the unaccented chord hits that follow it. */ +const mutateHarmony = (pattern: RhythmPattern, rng: Rng): RhythmEvent[] | null => { + const { slotCount } = pattern.params; + const ev = pattern.events; + const accents = ev.flatMap((e, i) => (!e.tie && e.accent > 0 && isSlotTarget(e.target) ? [i] : [])); + if (slotCount < 2 || accents.length < 2) return null; + const start = rng.pick(accents.slice(1)); + const next = (slotOf(ev[start].target) + 1 + rng.int(0, slotCount - 2)) % slotCount; + const out = ev.map(copyEvent); + for (let k = start; k < out.length; k++) { + const e = out[k]; + if (k > start && !e.tie && e.accent > 0 && isSlotTarget(e.target)) break; + if (isSlotTarget(e.target)) out[k] = { ...e, target: slotTarget(e.target.kind, next) }; + } + return out; +}; + +const applyMutation = (kind: Mutation, pattern: RhythmPattern, rng: Rng): RhythmEvent[] | null => { + switch (kind) { + case 'cell': + return mutateCell(pattern, rng); + case 'accent': + return mutateAccent(pattern, rng); + case 'palmMute': + return mutatePalmMute(pattern, rng); + default: + return mutateHarmony(pattern, rng); + } +}; + +/** + * Small seeded change: re-render one group, move one accent, flip palm mute on one group, or re-harmonise one + * accent. Mutations are tried in a seeded order; the first that changes the events, stays valid and adds no + * slot change on an unaccented hit wins (re-rendering a group can drop the next group's pushed accent). + * Slots outside params.slotCount are folded back first. + */ +export const mutateRhythm = (input: RhythmPattern, seed: string): RhythmPattern => { + const pattern = withSlotsInRange(input); + const rng = createRng(`mutate:${seed}`); + const opts = finalizeOptions(pattern); + const base = { ...pattern, tags: [...pattern.tags], id: `${pattern.id.split('~')[0]}~${seedTag(seed)}` }; + const harmonyBreaks = unaccentedSlotChangeCount(pattern.events); + for (const kind of rng.shuffle(MUTATIONS)) { + for (let attempt = 0; attempt < ATTEMPTS_PER_MUTATION; attempt++) { + const events = applyMutation(kind, pattern, rng.fork(`${kind}-${attempt}`)); + if (!events) break; + const candidate: RhythmPattern = { ...base, events: finalizeEvents(events, opts) }; + if ( + !sameEvents(candidate.events, pattern.events) && + validateRhythm(candidate).ok && + unaccentedSlotChangeCount(candidate.events) <= harmonyBreaks + ) { + return candidate; + } + } + } + return { ...base, events: pattern.events.map(copyEvent) }; +}; + +/** Slot of the last slot/dyad event before `tick`, or 0 (playback's active slot before any chord). */ +const activeSlotBefore = (events: readonly RhythmEvent[], tick: number): number => { + let slot = 0; + for (const e of events) { + if (e.tick >= tick) break; + if (isSlotTarget(e.target)) slot = e.target.slot; + } + return slot; +}; + +interface MergedEvent { + e: RhythmEvent; + kept: boolean; +} + +/** + * Re-harmonises the regenerated stretches between kept ones so that the slot changes only on accented attacks + * and each kept stretch is entered on the slot it had in the old pattern (its pedal and dead hits take their + * pitch from that slot). Inside a regenerated stretch, unaccented chord hits follow the current slot; the last + * accent before a kept stretch that depends on it is moved to the slot it needs, or, without one, the first + * chord hit of the stretch is accented to make the change (a stretch with no chord hit at all is left as is). + */ +const isChordAttack = (e: RhythmEvent): boolean => isSlotTarget(e.target) && !e.tie; + +const withSlot = (e: RhythmEvent, slot: number): RhythmEvent => + isSlotTarget(e.target) ? { ...e, target: slotTarget(e.target.kind, slot) } : e; + +const reharmonise = (items: MergedEvent[], old: readonly RhythmEvent[]): void => { + let current: number | null = null; + let i = 0; + while (i < items.length) { + if (items[i].kept) { + const { target } = items[i].e; + if (isSlotTarget(target)) current = target.slot; + i++; + continue; + } + const start = i; + while (i < items.length && !items[i].kept) i++; + const run = items.slice(start, i); + for (const item of run) { + const { e } = item; + if (!isChordAttack(e) || !isSlotTarget(e.target)) continue; + if (e.accent > 0 || current === null) current = e.target.slot; + else if (e.target.slot !== current) item.e = withSlot(e, current); + } + if (i >= items.length) break; + + // The kept stretch depends on the entry slot unless it opens with an accented chord attack. + const opener = items[i].e; + if (isSlotTarget(opener.target) && !opener.tie && opener.accent > 0) continue; + const need = activeSlotBefore(old, opener.tick); + if ((current ?? 0) === need) continue; + let from = run.map(({ e }) => isChordAttack(e) && e.accent > 0).lastIndexOf(true); + if (from < 0) from = run.findIndex(({ e }) => isChordAttack(e)); + if (from < 0) continue; + if (run[from].e.accent === 0) run[from].e = { ...run[from].e, accent: 1, palmMute: false }; + for (const item of run.slice(from)) if (isChordAttack(item.e)) item.e = withSlot(item.e, need); + current = need; + } +}; + +/** + * Generates with a new seed but keeps every event whose onset lies in a locked bar, unchanged. A locked tie + * also keeps the chain back to the attack it sustains, and a kept attack keeps the ties that sustain it past + * the bar (a push into the next bar stays tied over its downbeat); new events that would start inside a kept + * event's sound are dropped. A new tie whose attack was dropped becomes an attack with that attack's sound, + * so a pushed accent that fell into a locked bar still lands on its downbeat. Harmony is then re-planned + * around the kept bars (see reharmonise). Picks reset at each barline, so locked bars keep their picks too. + * Slots outside params.slotCount are folded back first. + */ +export const regenerateRhythm = (input: RhythmPattern, seed: string, lockedBars: readonly number[]): RhythmPattern => { + const pattern = withSlotsInRange(input); + const fresh = generateRhythm(pattern.params, seed); + const locked = [...new Set(lockedBars.filter((b) => Number.isInteger(b) && b >= 0 && b < pattern.bars))].sort((a, b) => a - b); + if (locked.length === 0) return fresh; + + const bar = barTicks(pattern.meter); + const inLocked = (tick: number) => locked.includes(Math.floor(tick / bar)); + const old = pattern.events; + const keep = new Set(old.flatMap((e, i) => (inLocked(e.tick) ? [i] : []))); + for (const i of [...keep]) { + if (!old[i].tie) continue; + for (let j = i - 1; j >= 0 && !keep.has(j); j--) { + keep.add(j); + if (!old[j].tie) break; + } + } + for (const i of [...keep].sort((a, b) => a - b)) { + for (let j = i + 1; j < old.length && old[j].tie && !keep.has(j); j++) keep.add(j); + } + const kept = old.filter((_, i) => keep.has(i)).map(copyEvent); + const covered = (tick: number) => kept.some((k) => tick >= k.tick && tick < k.tick + k.durationTicks); + + const added: RhythmEvent[] = []; + let source: RhythmEvent | null = null; + let previousAdded = false; + for (const e of fresh.events) { + if (!e.tie) source = e; + const dropped = inLocked(e.tick) || covered(e.tick); + if (!dropped) { + added.push( + !e.tie || previousAdded || !source + ? copyEvent(e) + : makeEvent(e.tick, e.durationTicks, source.target, source.palmMute, source.accent) + ); + } + previousAdded = !dropped; + } + + const items: MergedEvent[] = [ + ...kept.map((e) => ({ e, kept: true })), + ...added.map((e) => ({ e, kept: false })) + ].sort((a, b) => a.e.tick - b.e.tick); + reharmonise(items, old); + + return { + ...fresh, + id: `${fresh.id}-keep-${locked.join('.')}`, + events: finalizeEvents( + items.map((item) => item.e), + finalizeOptions(fresh) + ), + tags: [...new Set([...fresh.tags, ...pattern.tags])] + }; +}; From 93af39b6d8cabaf643ee0ee708dae209dda1c829 Mon Sep 17 00:00:00 2001 From: Can Kilic Date: Sat, 3 Oct 2026 19:10:53 -0400 Subject: [PATCH 03/14] Generate library card tabs and audio from engine shapes Library cards no longer transpose the curated fretboard strings. Each card's JSON notes become an interval recipe (fromLegacyNotes); the engine generates a no-barre shape for the selected root, tuning and hand profile, and both the tab and the played notes come from that one shape. The card picks the generated shape closest to the curated idea (bass register, note count, the curated tab moved rigidly) and shows string names, finger numbers, degrees per string and an honest "sounds as" name. Unplayable recipes show the binding constraint instead of a broken tab; relaxed recipes say what changed. Removed transposeTabs and its tests. They codified two bugs: Drop mode always added 2 frets to the lowest string even though the drop JSON is authored in Drop D (wrong bass, E instead of D), and per-string +12 wrapping turned open strings into hidden barres (Em(add9) to G became 3 5 7 3 3 3). Name and subtext transposition stay, now without gluing interval words to the root ("E b2", not "Eb2"), without mistaking "Dim/b2" for a D root, and replacing flat roots completely ("Bb(VI)" -> "E(VI)"). Also: - Chord Explorer deep links send the case-sensitive "Major" type id (it was rejected as "major") and the card shape as gv in E Standard. - Stored v1 riffs with flat spellings such as Bb2 are respelled on load so MIDI export no longer throws. - UPGRADE.md item 4 is marked as superseded by engine/. Co-Authored-By: Claude Opus 5.5 --- App.tsx | 81 +++++------------ UPGRADE.md | 4 + components/ChordCard.tsx | 84 +++++++++++------- components/VoicingTab.tsx | 83 ++++++++++++++++++ stores/riffStore.test.ts | 15 ++++ stores/riffStore.ts | 13 ++- types.ts | 27 +++++- utils/chordExplorer.test.ts | 12 ++- utils/chordExplorer.ts | 25 +++++- utils/libraryVoicing.test.ts | 143 ++++++++++++++++++++++++++++++ utils/libraryVoicing.ts | 165 +++++++++++++++++++++++++++++++++++ utils/musicTheory.test.ts | 145 +++++++++--------------------- utils/musicTheory.ts | 119 +++++-------------------- 13 files changed, 619 insertions(+), 297 deletions(-) create mode 100644 components/VoicingTab.tsx create mode 100644 utils/libraryVoicing.test.ts create mode 100644 utils/libraryVoicing.ts diff --git a/App.tsx b/App.tsx index a3e0837..29eaf2c 100644 --- a/App.tsx +++ b/App.tsx @@ -10,7 +10,8 @@ import { RiffBar } from './components/RiffBar'; import { useChordStore } from './stores/chordStore'; import { useRiffStore, MAX_RIFF_STEPS } from './stores/riffStore'; import { Chord, TuningMode, VibeMode } from './types'; -import { transposeChord } from './utils/musicTheory'; +import { resolveLibraryChord, tuningForMode } from './utils/libraryVoicing'; +import { DEFAULT_HAND_PROFILE } from './engine/handProfile'; import { syncUrlState } from './utils/urlState'; import { getChords } from './constants'; @@ -53,6 +54,8 @@ const App: React.FC = () => { const relatedSectionRef = useRef(null); const [favoriteChords, setFavoriteChords] = useState([]); + const handProfile = DEFAULT_HAND_PROFILE; + const tuning = tuningForMode(tuningMode); const riffSteps = useRiffStore((state) => state.steps); const metronomeOn = useRiffStore((state) => state.metronomeOn); @@ -94,6 +97,8 @@ const App: React.FC = () => { }, [isDistorted, isAudioReady, handleUserInteraction, setIsDistorted]); const playChord = useCallback(async (chord: Chord) => { + // Unplayable cards have no shape, so there is nothing honest to play + if (!chord.voicing) return; await handleUserInteraction(); audioEngine.playChord(chord.notes); setActiveChordId(chord.id); @@ -118,16 +123,10 @@ const App: React.FC = () => { const loadFavoriteChords = async () => { try { const baseChords = await getChords(tuningMode, vibeMode); - const transposed = baseChords + const resolved = baseChords .filter((chord: Chord) => favorites.includes(chord.id)) - .map((chord: Chord) => { - try { - return transposeChord(chord, selectedRoot, tuningMode); - } catch { - return chord; - } - }); - if (!cancelled) setFavoriteChords(transposed); + .map((chord: Chord) => resolveLibraryChord(chord, selectedRoot, tuningMode, handProfile)); + if (!cancelled) setFavoriteChords(resolved); } catch { if (!cancelled) setFavoriteChords([]); } @@ -137,7 +136,7 @@ const App: React.FC = () => { return () => { cancelled = true; }; - }, [showFavoritesOnly, favorites, tuningMode, vibeMode, selectedRoot]); + }, [showFavoritesOnly, favorites, tuningMode, vibeMode, selectedRoot, handProfile]); const handleLockToggle = useCallback(async (chord: Chord) => { if (lockedChordId === chord.id) { @@ -149,16 +148,14 @@ const App: React.FC = () => { const baseChords = await getChords(tuningMode, vibeMode); - const originalParent = baseChords.find((p: Chord) => { - const transposed = transposeChord(p, selectedRoot, tuningMode); - return transposed.id === chord.id; - }); + // Ids are stable across roots, so the displayed id is the base id + const originalParent = baseChords.find((p: Chord) => p.id === chord.id); if (originalParent && originalParent.relatedChords && originalParent.relatedChords.length > 0) { - const transposedRelated = originalParent.relatedChords + const resolvedRelated = originalParent.relatedChords .slice(0, RELATED_CHORDS_COUNT) - .map((relatedChord: Chord) => transposeChord(relatedChord, selectedRoot, tuningMode)); - setRelatedChords(transposedRelated); + .map((relatedChord: Chord) => resolveLibraryChord(relatedChord, selectedRoot, tuningMode, handProfile)); + setRelatedChords(resolvedRelated); setTimeout(() => { relatedSectionRef.current?.scrollIntoView({ @@ -170,7 +167,7 @@ const App: React.FC = () => { setRelatedChords([]); } } - }, [lockedChordId, playChord, selectedRoot, tuningMode, vibeMode, setLockedChordId, setRelatedChords]); + }, [lockedChordId, playChord, selectedRoot, tuningMode, vibeMode, handProfile, setLockedChordId, setRelatedChords]); const isChordLocked = useCallback((chord: Chord) => { if (!lockedChordId) return false; @@ -194,25 +191,10 @@ const App: React.FC = () => { setTotalChordsAvailable(baseChords.length); + // Every tab on screen comes from an engine shape, so cards render already resolved const firstBatch = baseChords.slice(0, CHORDS_PER_BATCH); - const strippedChords = firstBatch.map((chord: Chord) => ({ - ...chord, - relatedChords: undefined - })); - - setDisplayedChords(strippedChords as Chord[]); + setDisplayedChords(firstBatch.map((chord: Chord) => resolveLibraryChord(chord, selectedRoot, tuningMode, handProfile))); setIsLoadingChords(false); - - setTimeout(() => { - const transposed = firstBatch.map((chord: Chord) => { - try { - return transposeChord(chord, selectedRoot, tuningMode); - } catch { - return chord; - } - }); - setDisplayedChords(transposed); - }, 150); } catch { setDisplayedChords([]); setIsLoadingChords(false); @@ -220,7 +202,7 @@ const App: React.FC = () => { }; loadChords(); - }, [selectedRoot, tuningMode, vibeMode, setDisplayedChords, setIsLoadingChords, setChordsToLoad, setTotalChordsAvailable]); + }, [selectedRoot, tuningMode, vibeMode, handProfile, setDisplayedChords, setIsLoadingChords, setChordsToLoad, setTotalChordsAvailable]); const loadMoreChords = useCallback(async () => { if (isLoadingChords) return; @@ -241,31 +223,14 @@ const App: React.FC = () => { return; } - setDisplayedChords([...displayedChords, ...(nextBatch as Chord[])]); + const resolved = nextBatch.map((chord: Chord) => resolveLibraryChord(chord, selectedRoot, tuningMode, handProfile)); + setDisplayedChords([...displayedChords, ...resolved]); incrementChordsToLoad(CHORDS_PER_BATCH); - - setTimeout(() => { - const transposed = nextBatch.map((chord: Chord) => { - try { - return transposeChord(chord, selectedRoot, tuningMode); - } catch { - return chord; - } - }); - - const newChords = [...displayedChords]; - const startIndex = displayedChords.length; - transposed.forEach((chord: Chord, i: number) => { - newChords[startIndex + i] = chord; - }); - setDisplayedChords(newChords); - }, 100); - setIsLoadingChords(false); } catch { setIsLoadingChords(false); } - }, [chordsToLoad, tuningMode, vibeMode, selectedRoot, isLoadingChords, displayedChords, setDisplayedChords, setIsLoadingChords, incrementChordsToLoad]); + }, [chordsToLoad, tuningMode, vibeMode, selectedRoot, handProfile, isLoadingChords, displayedChords, setDisplayedChords, setIsLoadingChords, incrementChordsToLoad]); const gridChords = showFavoritesOnly ? favoriteChords : displayedChords; @@ -441,6 +406,7 @@ const App: React.FC = () => { > { > void; onLockToggle?: (chord: Chord) => void; @@ -19,6 +22,7 @@ interface ChordCardProps { export const ChordCard: React.FC = ({ chord, + tuning, isDistorted, onPlay, onLockToggle, @@ -73,6 +77,7 @@ export const ChordCard: React.FC = ({ : 'rgba(8, 145, 178, 0.4)'; const accentColor = isDistorted ? 'rose' : 'cyan'; + const addable = canAddToRiff && !!chord.voicing; return ( = ({ e.stopPropagation(); return; } - onPlay(chord); + if (chord.voicing) onPlay(chord); }} className="relative z-10 w-full text-left flex-1 flex flex-col cursor-pointer" style={{ pointerEvents: 'auto' }} @@ -217,14 +222,14 @@ export const ChordCard: React.FC = ({ { e.stopPropagation(); - if (canAddToRiff) onAddToRiff(chord); + if (addable) onAddToRiff(chord); }} style={{ zIndex: 10, @@ -398,12 +403,13 @@ export const ChordCard: React.FC = ({ { e.stopPropagation(); - onPlay(chord); + if (chord.voicing) onPlay(chord); }} className={` - shrink-0 w-8 h-8 flex items-center justify-center rounded-full border + shrink-0 w-8 h-8 flex items-center justify-center rounded-full border disabled:opacity-30 disabled:cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black ${isDistorted ? 'border-rose-900 text-rose-500 focus-visible:ring-rose-500' @@ -425,12 +431,18 @@ export const ChordCard: React.FC = ({ - {/* Fretboard Data */} -
+ {/* Fretboard Data: tab and audio both come from chord.voicing.shape */} +
+
TABLATURE = ({
- - {chord.fretboard} - + {chord.voicing && ( + + SOUNDS {chord.voicing.soundsAs} + + )} +
+ {chord.voicing ? ( + <> + + {chord.voicing.relaxed && ( +

+ Closest playable: {chord.voicing.relaxed.join(', ')} +

+ )} + + ) : ( +

+ No playable shape for your hand here.{' '} + {chord.unplayableReason} +

+ )}
diff --git a/components/VoicingTab.tsx b/components/VoicingTab.tsx new file mode 100644 index 0000000..81d254c --- /dev/null +++ b/components/VoicingTab.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import type { FingerNumber, Tuning } from '../engine/types'; +import { pitchClassName } from '../engine/pitch'; + +interface VoicingTabProps { + shape: readonly (number | null)[]; + fingers: readonly (FingerNumber | null)[]; + degreesByString: readonly (string | null)[]; + tuning: Tuning; + isDistorted: boolean; + compact?: boolean; +} + +const FINGER_LABEL: Record = { 0: 'T', 1: '1', 2: '2', 3: '3', 4: '4' }; +const FINGER_NAME: Record = { 0: 'thumb', 1: 'index', 2: 'middle', 3: 'ring', 4: 'pinky' }; + +/** Screen-reader summary, low string first: "E string open, A string fret 2 middle finger, ...". */ +export const describeVoicing = ( + shape: readonly (number | null)[], + fingers: readonly (FingerNumber | null)[], + tuning: Tuning +): string => + shape + .map((fret, s) => { + const string = `${pitchClassName(tuning.openMidi[s])} string`; + if (fret === null) return `${string} muted`; + if (fret === 0) return `${string} open`; + const finger = fingers[s]; + return `${string} fret ${fret}${finger !== null && finger !== undefined ? ` ${FINGER_NAME[finger]} finger` : ''}`; + }) + .join(', '); + +/** Tab grid, low string on the left: string names, frets, finger numbers and degrees per string. */ +export const VoicingTab: React.FC = ({ shape, fingers, degreesByString, tuning, isDistorted, compact = false }) => { + const accent = isDistorted ? 'text-rose-500' : 'text-cyan-500'; + const glow = isDistorted ? '0 0 8px rgba(225, 29, 72, 0.6)' : '0 0 8px rgba(8, 145, 178, 0.6)'; + const cell = compact ? 'w-6' : 'w-7'; + const rowLabel = 'w-10 shrink-0 text-left font-mono text-[9px] uppercase tracking-widest text-neutral-400'; + + return ( +
+

{describeVoicing(shape, fingers, tuning)}

+ +
+ ); +}; diff --git a/stores/riffStore.test.ts b/stores/riffStore.test.ts index 8adbc04..453e6b5 100644 --- a/stores/riffStore.test.ts +++ b/stores/riffStore.test.ts @@ -119,6 +119,21 @@ describe('riffStore', () => { expect(state.currentStep).toBe(-1); }); + it('respells legacy flat note names from stored v1 riffs so MIDI export works', async () => { + localStorage.setItem(RIFF_STORAGE_KEY, JSON.stringify({ + steps: [{ key: 'old-1', baseId: 'drop-melodic-2', name: 'Drop Bb Maj7', subtext: 'Bb(VI)', notes: ['Bb2', 'D3', 'F3', 'A3', '??'] }], + bpm: 100 + })); + const useRiffStore = await freshStore(); + const { steps, bpm } = useRiffStore.getState(); + expect(bpm).toBe(100); + expect(steps).toHaveLength(1); + expect(steps[0].key).toBe('old-1'); + expect(steps[0].notes).toEqual(['A#2', 'D3', 'F3', 'A3']); + const { riffToMidi } = await import('../utils/midi'); + expect(() => riffToMidi(steps, bpm)).not.toThrow(); + }); + it('falls back to defaults on corrupt localStorage payloads', async () => { localStorage.setItem(RIFF_STORAGE_KEY, '{not json'); let useRiffStore = await freshStore(); diff --git a/stores/riffStore.ts b/stores/riffStore.ts index d4a24fd..dc24c8a 100644 --- a/stores/riffStore.ts +++ b/stores/riffStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { midiToName, parseNoteName } from '../engine/pitch'; export const RIFF_STORAGE_KEY = 'riffforge:riff:v1'; export const MAX_RIFF_STEPS = 16; @@ -41,6 +42,16 @@ const isValidStep = (value: unknown): value is Omit & { key?: s ); }; +/** + * Older library cards stored curated spellings such as "Bb2", which the MIDI writer rejects. Respell + * with sharps and drop anything unreadable so a stored v1 riff still plays and exports. + */ +const normalizeNotes = (notes: readonly string[]): string[] => + notes.flatMap((note) => { + const midi = parseNoteName(note); + return midi === null ? [] : [midiToName(midi)]; + }); + const loadRiff = (): PersistedRiff => { const fallback: PersistedRiff = { steps: [], bpm: DEFAULT_BPM }; try { @@ -59,7 +70,7 @@ const loadRiff = (): PersistedRiff => { baseId: step.baseId, name: step.name, subtext: step.subtext, - notes: [...step.notes] + notes: normalizeNotes(step.notes) })) : []; diff --git a/types.ts b/types.ts index 71a559e..c693547 100644 --- a/types.ts +++ b/types.ts @@ -1,12 +1,37 @@ +import type { FingerNumber } from './engine/types'; + +/** An engine-generated shape for a library card. Tab and audio both derive from `shape`. */ +export interface LibraryVoicing { + shape: (number | null)[]; + /** RiffForge tab, low -> high ("0 2 2 x x x"). */ + tab: string; + fingers: (FingerNumber | null)[]; + degreesByString: (string | null)[]; + /** Degrees of the sounding notes, low -> high pitch. */ + degrees: string; + /** Honest name from the sounding notes. */ + soundsAs: string; + /** Sounding note names (sharps), from shapeToMidi. */ + notes: string[]; + /** Musical relaxations applied to fit the hand, or null when the recipe is exact. */ + relaxed: string[] | null; + explain: string[]; +} + export interface Chord { id: string; name: string; subtext: string; - notes: string[]; // Original notes + /** In the JSON: the curated note recipe. On a resolved card: the sounding notes of `voicing`. */ + notes: string[]; description: string; fretboard?: string; baseRoot: string; // The root note of the original voicing (e.g., 'E' for Opeth chord) relatedChords?: Chord[]; // Child chords that harmonically work with this parent + /** Present on resolved cards that have a playable shape. */ + voicing?: LibraryVoicing; + /** Present on resolved cards without a playable shape for the current hand profile. */ + unplayableReason?: string; } export enum TuningMode { diff --git a/utils/chordExplorer.test.ts b/utils/chordExplorer.test.ts index 291f8b6..940ccac 100644 --- a/utils/chordExplorer.test.ts +++ b/utils/chordExplorer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseRoot, mapToExplorerType, buildChordExplorerUrl } from './chordExplorer'; +import { parseRoot, mapToExplorerType, buildChordExplorerUrl, shapeToGvParam } from './chordExplorer'; describe('parseRoot', () => { it('extracts natural and sharp roots from the name', () => { @@ -87,8 +87,14 @@ describe('buildChordExplorerUrl', () => { .toBe('https://chords.thegridbase.com/?root=C%23&type=m7'); }); - it('encodes sharps for major chords too (F#5)', () => { + it('encodes sharps for major chords too, with the case-sensitive Major id (F#5)', () => { expect(buildChordExplorerUrl({ name: 'F#5', subtext: 'F#5' })) - .toBe('https://chords.thegridbase.com/?root=F%23&type=major'); + .toBe('https://chords.thegridbase.com/?root=F%23&type=Major'); + }); + + it('appends the exact shape as gv, low -> high with x for muted', () => { + expect(buildChordExplorerUrl({ name: 'E', subtext: 'E5' }, [0, 2, 2, null, null, null])) + .toBe('https://chords.thegridbase.com/?root=E&type=Major&gv=0-2-2-x-x-x'); + expect(shapeToGvParam([null, 13, 15, null, 10, 0])).toBe('x-13-15-x-10-0'); }); }); diff --git a/utils/chordExplorer.ts b/utils/chordExplorer.ts index 1739277..7783f73 100644 --- a/utils/chordExplorer.ts +++ b/utils/chordExplorer.ts @@ -69,9 +69,26 @@ export const mapToExplorerType = (name: string, subtext: string): ChordExplorerT return minorish ? 'minor' : 'major'; }; -/** Builds the full Chord Explorer URL. Sharps are URL-encoded (C# -> C%23). */ -export const buildChordExplorerUrl = (chord: { name: string; subtext: string }): string => { +/** Chord Explorer's URL ids are case-sensitive and capitalize only Major. */ +const TYPE_PARAM: Record = { + major: 'Major', minor: 'minor', dim: 'dim', aug: 'aug', '7': '7', + m7: 'm7', maj7: 'maj7', dim7: 'dim7', sus2: 'sus2', sus4: 'sus4' +}; + +/** Shape as Chord Explorer's `gv` param: low -> high frets joined by "-", "x" for muted ("0-2-2-x-x-x"). */ +export const shapeToGvParam = (shape: readonly (number | null)[]): string => + shape.map((f) => (f === null ? 'x' : String(f))).join('-'); + +/** + * Builds the full Chord Explorer URL. Sharps are URL-encoded (C# -> C%23). With a shape, Chord Explorer's + * "Playable for me" panel opens on that exact voicing. + */ +export const buildChordExplorerUrl = ( + chord: { name: string; subtext: string }, + shape?: readonly (number | null)[] +): string => { const root = parseRoot(chord.name, chord.subtext); - const type = mapToExplorerType(chord.name, chord.subtext); - return `${CHORD_EXPLORER_BASE_URL}?root=${encodeURIComponent(root)}&type=${encodeURIComponent(type)}`; + const type = TYPE_PARAM[mapToExplorerType(chord.name, chord.subtext)]; + const gv = shape ? `&gv=${shapeToGvParam(shape)}` : ''; + return `${CHORD_EXPLORER_BASE_URL}?root=${encodeURIComponent(root)}&type=${encodeURIComponent(type)}${gv}`; }; diff --git a/utils/libraryVoicing.test.ts b/utils/libraryVoicing.test.ts new file mode 100644 index 0000000..25e4ddc --- /dev/null +++ b/utils/libraryVoicing.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { resolveLibraryChord, resolveLibraryVoicing, moveShape, shapeDistance, tuningForMode } from './libraryVoicing'; +import { DEFAULT_HAND_PROFILE, profileFromCalibration, DEFAULT_CALIBRATION } from '../engine/handProfile'; +import { fromRiffForgeTab, shapeToMidi } from '../engine/shape'; +import { midiToName, pitchClass, parsePitchClass, parseNoteName } from '../engine/pitch'; +import { findBestFingering } from '../engine/fingering'; +import { NOTES } from '../constants'; +import { Chord, TuningMode } from '../types'; + +const CHORD_DIR = join(__dirname, '..', 'public', 'chords'); + +const loadFile = (file: string): Chord[] => JSON.parse(readFileSync(join(CHORD_DIR, file), 'utf8')); + +const allEntries = (): { file: string; mode: TuningMode; chord: Chord }[] => + readdirSync(CHORD_DIR) + .filter((f) => f.endsWith('.json')) + .flatMap((file) => { + const mode = file.startsWith('drop') ? TuningMode.DROP : TuningMode.STANDARD; + return loadFile(file).flatMap((chord) => + [chord, ...(chord.relatedChords ?? [])].map((c) => ({ file, mode, chord: c })) + ); + }); + +const find = (file: string, id: string): Chord => { + const entry = allEntries().find((e) => e.file === file && e.chord.id === id); + if (!entry) throw new Error(`missing ${file} ${id}`); + return entry.chord; +}; + +describe('tuningForMode', () => { + it('maps the two app tunings', () => { + expect(tuningForMode(TuningMode.STANDARD).id).toBe('e-standard'); + expect(tuningForMode(TuningMode.DROP).id).toBe('drop-d'); + }); +}); + +describe('resolveLibraryChord', () => { + it('gives Drop D power chords a D bass in Drop D (no +2 on the low string)', () => { + const power = find('drop-energetic.json', 'drop-energetic-1'); + const resolved = resolveLibraryChord(power, 'D', TuningMode.DROP, DEFAULT_HAND_PROFILE); + expect(resolved.fretboard).toBe('0 0 0 x x x'); + expect(resolved.notes).toEqual(['D2', 'A2', 'D3']); + }); + + it('derives tab and audio from the same shape', () => { + const ghost = find('standard-melodic.json', 'melodic-1'); + const resolved = resolveLibraryChord(ghost, 'G', TuningMode.STANDARD, DEFAULT_HAND_PROFILE); + const shape = fromRiffForgeTab(resolved.fretboard!)!; + expect(resolved.notes).toEqual(shapeToMidi(shape, tuningForMode(TuningMode.STANDARD)).map(midiToName)); + expect(resolved.voicing!.tab).toBe(resolved.fretboard); + expect(resolved.fretboard).not.toBe('3 5 7 3 3 3'); + }); + + it('keeps ids, descriptions and related chords, and moves labels to the root', () => { + const omen = find('standard-dark.json', 'dark-1'); + const resolved = resolveLibraryChord(omen, 'F#', TuningMode.STANDARD, DEFAULT_HAND_PROFILE); + expect(resolved.id).toBe('dark-1'); + expect(resolved.description).toBe(omen.description); + expect(resolved.relatedChords).toBe(omen.relatedChords); + expect(resolved.subtext).toBe('F# m2 Clash'); + }); + + it('honours the recipe: the Omen is root, b2 and 5 with the root in the bass', () => { + const omen = find('standard-dark.json', 'dark-1'); + for (const root of NOTES) { + const { voicing } = resolveLibraryVoicing(omen, root, tuningForMode(TuningMode.STANDARD), DEFAULT_HAND_PROFILE); + expect(voicing).not.toBeNull(); + const midi = voicing!.notes.map((n) => parseNoteName(n)!); + const rootPc = parsePitchClass(root)!; + expect(new Set(midi.map((m) => pitchClass(m - rootPc)))).toEqual(new Set([0, 1, 7])); + expect(pitchClass(Math.min(...midi) - rootPc)).toBe(0); + expect(voicing!.degrees.split(' ')[0]).toBe('1'); + } + }); + + it('resolves every library entry at every root to a no-barre shape whose audio equals its tab', () => { + let unplayable = 0; + let relaxed = 0; + let total = 0; + for (const { mode, chord } of allEntries()) { + for (const root of NOTES) { + total++; + const resolved = resolveLibraryChord(chord, root, mode, DEFAULT_HAND_PROFILE); + if (!resolved.voicing) { + unplayable++; + expect(resolved.unplayableReason).toBeTruthy(); + expect(resolved.notes).toEqual([]); + continue; + } + if (resolved.voicing.relaxed) relaxed++; + const shape = fromRiffForgeTab(resolved.fretboard!)!; + expect(resolved.notes).toEqual(shapeToMidi(shape, tuningForMode(mode)).map(midiToName)); + const fingering = findBestFingering(shape, DEFAULT_HAND_PROFILE); + expect(fingering.ok).toBe(true); + if (fingering.ok === true) expect(fingering.fingering.barres).toEqual([]); + expect(shape.filter((f) => f !== null && f > 0).length).toBeLessThanOrEqual(4); + } + } + expect(total).toBe(252 * 12); + // Every curated recipe must be playable somewhere on the neck for the default hand + expect(unplayable).toBe(0); + console.info(`library: ${total} resolutions, ${relaxed} relaxed, ${unplayable} unplayable`); + }); + + it('follows the hand profile: a tiny reach without open strings can make a card unplayable with a reason', () => { + const tiny = { + ...profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3, highPinkyFret: 9 }), + allowOpenStrings: false, + maxFret: 4 + }; + const phrygian = find('standard-dark.json', 'dark-2'); + const resolved = resolveLibraryChord(phrygian, 'A#', TuningMode.STANDARD, tiny); + if (!resolved.voicing) { + expect(resolved.unplayableReason).toMatch(/\w/); + expect(resolved.fretboard).toBeUndefined(); + } else { + expect(resolved.voicing.shape.every((f) => f !== 0)).toBe(true); + } + }); + + it('is deterministic', () => { + const omen = find('drop-dark.json', 'drop-dark-1'); + const a = resolveLibraryChord(omen, 'C', TuningMode.DROP, DEFAULT_HAND_PROFILE); + const b = resolveLibraryChord({ ...omen }, 'C', TuningMode.DROP, { ...DEFAULT_HAND_PROFILE }); + expect(b).toEqual(a); + }); +}); + +describe('card helpers', () => { + it('moves a curated tab as a rigid block, wrapping up an octave only as a whole', () => { + expect(moveShape([0, 2, 2, null, null, null], 3)).toEqual([3, 5, 5, null, null, null]); + expect(moveShape([0, 2, 2, null, null, null], -2)).toEqual([10, 12, 12, null, null, null]); + expect(moveShape([null, null, null, null, null, null], 3)).toBeNull(); + }); + + it('measures shape distance by string set first, then frets', () => { + expect(shapeDistance([3, 5, 5, null, null, null], [3, 5, 5, null, null, null])).toBe(0); + expect(shapeDistance([3, 5, null, null, null, null], [3, 5, 5, null, null, null])).toBeCloseTo(1 / 6, 9); + expect(shapeDistance([null, null, null, 0, 3, null], [3, 5, 5, null, null, null])).toBeCloseTo(5 / 6, 9); + }); +}); diff --git a/utils/libraryVoicing.ts b/utils/libraryVoicing.ts new file mode 100644 index 0000000..1ef81de --- /dev/null +++ b/utils/libraryVoicing.ts @@ -0,0 +1,165 @@ +// Turns curated library chords into engine-generated shapes. The JSON `notes` are the musical intent +// (an interval recipe relative to `baseRoot`, in a specific register); the JSON `fretboard` is only a +// hint for where on the neck the idea lives, because most curated tabs do not sound their own notes. +import type { GeneratedVoicing, HandProfile, Shape, Tuning } from '../engine/types'; +import { DROP_D, E_STANDARD } from '../engine/tuning'; +import { fromLegacyNotes } from '../engine/voicingSpec'; +import { findClosestVoicing } from '../engine/generateVoicings'; +import { bassMidi, fromRiffForgeTab, shapeToMidi, toRiffForgeTab } from '../engine/shape'; +import { midiToName, parseNoteName, parsePitchClass, pitchClass } from '../engine/pitch'; +import { explainCost } from '../engine/playability'; +import { handProfileHash } from '../engine/handProfile'; +import { Chord, LibraryVoicing, TuningMode } from '../types'; +import { transposeChordLabels } from './musicTheory'; + +/** The JSON drop files are authored in Drop D, the standard files in E Standard. */ +export const tuningForMode = (mode: TuningMode): Tuning => (mode === TuningMode.DROP ? DROP_D : E_STANDARD); + +const CANDIDATES = 200; + +// A card shows the voicing that keeps its curated idea: same register, same density, same place on the +// neck. Engine cost only decides between equally faithful shapes. These preferences are card-only; the +// Voicing Finder ranks by the engine alone. +const REGISTER_WEIGHT = 2; // per octave between the bass and the curated bass moved to the new root +const REGISTER_CAP_OCTAVES = 2; +const NOTE_COUNT_WEIGHT = 0.5; // per note more or fewer than the curated chord +const SHAPE_WEIGHT = 1; // 0..1 distance from the curated tab moved rigidly to the new root +const POSITION_WEIGHT = 0.08; // per fret between the lowest fretted notes of the two shapes +const POSITION_CAP_FRETS = 10; + +const lowestFretOf = (shape: Shape): number => { + const fretted = shape.filter((f): f is number => f !== null && f > 0); + return fretted.length === 0 ? 0 : Math.min(...fretted); +}; + +export interface LibraryResolution { + voicing: LibraryVoicing | null; + unplayableReason: string | null; +} + +/** Semitone move from baseRoot to targetRoot in -6..5, so the idea stays near its register. */ +const rootShift = (baseRoot: string, targetRoot: string): number => { + const from = parsePitchClass(baseRoot); + const to = parsePitchClass(targetRoot); + if (from === null || to === null) return 0; + const up = pitchClass(to - from); + return up > 5 ? up - 12 : up; +}; + +/** The curated tab moved as a rigid block (every sounding string, opens included) by `shift` frets. */ +export const moveShape = (shape: Shape, shift: number): Shape | null => { + const sounding = shape.filter((f): f is number => f !== null); + if (sounding.length === 0) return null; + let delta = shift; + while (Math.min(...sounding) + delta < 0) delta += 12; + return shape.map((f) => (f === null ? null : f + delta)); +}; + +/** 0..1 difference between two absolute shapes: string sets first, then fret distance per string. */ +export const shapeDistance = (a: Shape, b: Shape): number => { + if (a.length !== b.length) return 1; + let sum = 0; + for (let s = 0; s < a.length; s++) { + const x = a[s]; + const y = b[s]; + if (x === null || y === null) sum += x === y ? 0 : 1; + else sum += Math.min(1, Math.abs(x - y) / 3); + } + return sum / a.length; +}; + +const toLibraryVoicing = ( + v: GeneratedVoicing, + tuning: Tuning, + profile: HandProfile, + relaxed: string[] | null +): LibraryVoicing => ({ + shape: [...v.shape], + tab: toRiffForgeTab(v.shape), + fingers: [...v.fingering.fingers], + degreesByString: [...v.degreesByString], + degrees: v.degrees, + soundsAs: v.name, + // Audio comes from the same shape as the tab + notes: shapeToMidi(v.shape, tuning).map(midiToName), + relaxed, + explain: explainCost(v.breakdown, v.fingering, profile) +}); + +const cache = new Map(); + +export const resolveLibraryVoicing = ( + chord: Pick, + targetRoot: string, + tuning: Tuning, + profile: HandProfile +): LibraryResolution => { + const key = [chord.notes.join(','), chord.baseRoot, chord.fretboard ?? '', targetRoot, tuning.id, handProfileHash(profile)].join('|'); + const cached = cache.get(key); + if (cached) return cached; + + let resolution: LibraryResolution; + try { + const family = fromLegacyNotes(chord.notes, chord.baseRoot); + const base = { root: targetRoot, tuning, profile, distortion: true, limit: CANDIDATES, diversify: false }; + // Exact recipe first, then the engine's musical relaxation ladder (the hand profile is never relaxed) + const { result, relaxed, exactEmpty } = findClosestVoicing({ ...base, family }); + + if (result.voicings.length === 0) { + resolution = { + voicing: null, + unplayableReason: exactEmpty?.message ?? result.empty?.message ?? 'No playable shape for your hand profile.' + }; + } else { + const shift = rootShift(chord.baseRoot, targetRoot); + const curatedMidi = chord.notes.map(parseNoteName).filter((m): m is number => m !== null); + let targetBass = Math.min(...curatedMidi) + shift; + while (targetBass < tuning.openMidi[0]) targetBass += 12; + const parsed = chord.fretboard ? fromRiffForgeTab(chord.fretboard) : null; + const moved = parsed && parsed.length === tuning.openMidi.length ? moveShape(parsed, shift) : null; + + const score = (v: GeneratedVoicing) => { + const bass = bassMidi(v.shape, tuning) ?? targetBass; + return ( + v.cost + + REGISTER_WEIGHT * Math.min(REGISTER_CAP_OCTAVES, Math.abs(bass - targetBass) / 12) + + NOTE_COUNT_WEIGHT * Math.abs(v.midi.length - curatedMidi.length) + + (moved + ? SHAPE_WEIGHT * shapeDistance(v.shape, moved) + + POSITION_WEIGHT * Math.min(POSITION_CAP_FRETS, Math.abs(lowestFretOf(v.shape) - lowestFretOf(moved))) + : 0) + ); + }; + // Candidates arrive in a deterministic order; keep the first on ties + const best = result.voicings.reduce((a, b) => (score(b) < score(a) ? b : a)); + resolution = { voicing: toLibraryVoicing(best, tuning, profile, relaxed), unplayableReason: null }; + } + } catch { + resolution = { voicing: null, unplayableReason: 'This library entry has no readable notes.' }; + } + + cache.set(key, resolution); + return resolution; +}; + +/** + * A curated chord as displayed for the selected root and tuning: labels moved to the root, and tab plus + * audio notes from one engine shape. Ids are untouched so favorites keep working. + */ +export const resolveLibraryChord = ( + chord: Chord, + targetRoot: string, + tuningMode: TuningMode, + profile: HandProfile +): Chord => { + const labels = transposeChordLabels(chord, targetRoot); + const { voicing, unplayableReason } = resolveLibraryVoicing(chord, targetRoot, tuningForMode(tuningMode), profile); + return { + ...chord, + ...labels, + notes: voicing ? voicing.notes : [], + fretboard: voicing ? voicing.tab : undefined, + voicing: voicing ?? undefined, + unplayableReason: unplayableReason ?? undefined + }; +}; diff --git a/utils/musicTheory.test.ts b/utils/musicTheory.test.ts index 20e88e0..c2775ed 100644 --- a/utils/musicTheory.test.ts +++ b/utils/musicTheory.test.ts @@ -1,119 +1,56 @@ import { describe, it, expect } from 'vitest'; -import { transposeNote, transposeTabs, transposeChord } from './musicTheory'; -import { Chord, TuningMode } from '../types'; - -describe('transposeNote', () => { - it('transposes within an octave', () => { - expect(transposeNote('E2', 3)).toBe('G2'); - expect(transposeNote('C3', 4)).toBe('E3'); - }); - - it('wraps up an octave when crossing B', () => { - expect(transposeNote('B3', 1)).toBe('C4'); - expect(transposeNote('A#2', 2)).toBe('C3'); - }); - - it('wraps down an octave when transposing below C', () => { - expect(transposeNote('C4', -1)).toBe('B3'); - expect(transposeNote('A2', -10)).toBe('B1'); - }); - - it('shifts a full octave for 12 semitones', () => { - expect(transposeNote('E2', 12)).toBe('E3'); - expect(transposeNote('F#3', -12)).toBe('F#2'); - }); - - it('handles sharps', () => { - expect(transposeNote('G#2', 3)).toBe('B2'); - expect(transposeNote('C#3', 1)).toBe('D3'); - }); - - it('returns unparseable input unchanged', () => { - expect(transposeNote('x', 5)).toBe('x'); - expect(transposeNote('Bb3', 1)).toBe('Bb3'); // flats are not in the note table - }); -}); - -describe('transposeTabs', () => { - it('preserves muted strings', () => { - expect(transposeTabs('x 3 2 0 1 0', 2, TuningMode.STANDARD)).toBe('x 5 4 2 3 2'); - expect(transposeTabs('x x 0 2 3 1', 2, TuningMode.STANDARD)).toBe('x x 2 4 5 3'); - }); - - it('keeps open strings open at zero semitones in standard tuning', () => { - expect(transposeTabs('0 2 4 0 0 0', 0, TuningMode.STANDARD)).toBe('0 2 4 0 0 0'); - }); - - it('adds 2 frets on the lowest string in drop tuning', () => { - expect(transposeTabs('0 2 4 0 0 0', 0, TuningMode.DROP)).toBe('2 2 4 0 0 0'); - expect(transposeTabs('0 2 4 0 0 0', 3, TuningMode.DROP)).toBe('5 5 7 3 3 3'); - }); - - it('wraps negative frets up an octave', () => { - expect(transposeTabs('0 2 2 1 0 0', -2, TuningMode.STANDARD)).toBe('10 0 0 11 10 10'); - }); - - it('handles empty and sentinel input', () => { - expect(transposeTabs(undefined, 2, TuningMode.STANDARD)).toBe(''); - expect(transposeTabs('TRANSPOSED', 2, TuningMode.STANDARD)).toBe('TRANSPOSED'); +import { transposeChordLabels } from './musicTheory'; +import { Chord } from '../types'; + +// Tab and note transposition moved to the engine (utils/libraryVoicing.ts). The removed transposeTabs +// codified two bugs: Drop mode always added 2 frets to the lowest string (wrong bass: E instead of D), and +// per-string +12 wrapping turned open strings into hidden barres (Em(add9) to G became 3 5 7 3 3 3). + +const ghost: Chord = { + id: 'melodic-1', + name: 'Em', + subtext: 'Em(add9)', + notes: ['E2', 'B2', 'F#3', 'G3', 'B3', 'E4'], + description: 'The quintessential melancholy shape.', + fretboard: '0 2 4 0 0 0', + baseRoot: 'E' +}; + +const withSubtext = (subtext: string, baseRoot = 'E'): Chord => ({ ...ghost, id: `t-${subtext}`, subtext, baseRoot }); + +describe('transposeChordLabels', () => { + it('updates name and subtext (Em -> Cm)', () => { + expect(transposeChordLabels(ghost, 'C')).toEqual({ name: 'Cm', subtext: 'Cm(add9)' }); }); -}); - -describe('transposeChord', () => { - const ghost: Chord = { - id: 'melodic-1', - name: 'Em', - subtext: 'Em(add9)', - notes: ['E2', 'B2', 'F#3', 'G3', 'B3', 'E4'], - description: 'The quintessential melancholy shape.', - fretboard: '0 2 4 0 0 0', - baseRoot: 'E' - }; - it('updates name and subtext (Em -> Cm)', () => { - const result = transposeChord(ghost, 'C', TuningMode.STANDARD); - expect(result.name).toBe('Cm'); - expect(result.subtext).toBe('Cm(add9)'); + it('keeps labels when the target equals baseRoot', () => { + expect(transposeChordLabels(ghost, 'E')).toEqual({ name: 'Em', subtext: 'Em(add9)' }); }); - it('shifts all notes by the root distance', () => { - const result = transposeChord(ghost, 'G', TuningMode.STANDARD); - expect(result.notes).toEqual(['G2', 'D3', 'A3', 'A#3', 'D4', 'G4']); - expect(result.fretboard).toBe('3 5 7 3 3 3'); + it('transposes sharp-rooted chords (C#dim -> Ddim)', () => { + const sharpDim: Chord = { ...ghost, id: 'test-sharp', name: 'C#dim', subtext: 'C#dim(vii)', baseRoot: 'C#' }; + expect(transposeChordLabels(sharpDim, 'D')).toEqual({ name: 'Ddim', subtext: 'Ddim(vii)' }); }); - it('keeps id stable and chord unchanged when target equals baseRoot', () => { - const result = transposeChord(ghost, 'E', TuningMode.STANDARD); - expect(result.id).toBe('melodic-1'); - expect(result.name).toBe('Em'); - expect(result.notes).toEqual(ghost.notes); + it('glues chord qualities to the root for generic subtexts', () => { + expect(transposeChordLabels(withSubtext('m(add9)'), 'C').subtext).toBe('Cm(add9)'); + expect(transposeChordLabels(withSubtext('Sus4'), 'A').subtext).toBe('ASus4'); }); - it('transposes sharp-rooted chords (C#dim -> Ddim)', () => { - const sharpDim: Chord = { - id: 'test-sharp', - name: 'C#dim', - subtext: 'C#dim(vii)', - notes: ['C#3', 'E3', 'G3'], - description: 'Leading tone tension.', - fretboard: 'x 4 5 6 x x', - baseRoot: 'C#' - }; - const result = transposeChord(sharpDim, 'D', TuningMode.STANDARD); - expect(result.name).toBe('Ddim'); - expect(result.subtext).toBe('Ddim(vii)'); - expect(result.notes).toEqual(['D3', 'F3', 'G#3']); - expect(result.fretboard).toBe('x 5 6 7 x x'); + it('keeps a space before interval words so they do not read as accidentals', () => { + expect(transposeChordLabels(withSubtext('b2'), 'E').subtext).toBe('E b2'); + expect(transposeChordLabels(withSubtext('b5/m2'), 'F#').subtext).toBe('F# b5/m2'); + expect(transposeChordLabels(withSubtext('m2 Clash'), 'E').subtext).toBe('E m2 Clash'); + expect(transposeChordLabels(withSubtext('5th'), 'G').subtext).toBe('G 5th'); + expect(transposeChordLabels(withSubtext('Octave'), 'G').subtext).toBe('G Octave'); }); - it('prefixes the target root for generic subtexts', () => { - const generic: Chord = { ...ghost, id: 'test-generic', subtext: 'm(add9)' }; - const result = transposeChord(generic, 'C', TuningMode.STANDARD); - expect(result.subtext).toBe('Cm(add9)'); + it('does not mistake words starting with A-G for roots', () => { + expect(transposeChordLabels(withSubtext('Dim/b2'), 'E').subtext).toBe('E Dim/b2'); }); - it('applies the drop-tuning lowest-string rule to the fretboard', () => { - const result = transposeChord(ghost, 'E', TuningMode.DROP); - expect(result.fretboard).toBe('2 2 4 0 0 0'); + it('replaces flat roots completely', () => { + expect(transposeChordLabels(withSubtext('Bb(VI)', 'A#'), 'E').subtext).toBe('E(VI)'); + expect(transposeChordLabels(withSubtext('Bb(VI)sus2', 'A#'), 'C').subtext).toBe('C(VI)sus2'); }); }); diff --git a/utils/musicTheory.ts b/utils/musicTheory.ts index b71b49e..a4864f8 100644 --- a/utils/musicTheory.ts +++ b/utils/musicTheory.ts @@ -1,5 +1,8 @@ import { NOTES } from '../constants'; -import { Chord, TuningMode } from '../types'; +import { Chord } from '../types'; + +const ROOTED_SUBTEXT = /^[A-G][#b]?(?=$|[\s(/\d]|m|maj|min|dim|aug|sus|add|M)/; +const QUALITY_START = /^(m(?!\d)|maj|min|dim|aug|sus|Sus|add|M|\(|7|9|11|13|5(?!th))/; const getSemitoneDistance = (fromNote: string, toNote: string): number => { const fromIndex = NOTES.indexOf(fromNote); @@ -8,111 +11,33 @@ const getSemitoneDistance = (fromNote: string, toNote: string): number => { return toIndex - fromIndex; }; -export const transposeNote = (note: string, semitones: number): string => { - const match = note.match(/^([A-G]#?)(-?\d+)$/); - if (!match) return note; - - const [, name, octaveStr] = match; - const octave = parseInt(octaveStr, 10); - - let currentIndex = NOTES.indexOf(name); - if (currentIndex === -1) return note; - - let newIndex = currentIndex + semitones; - let octaveShift = Math.floor(newIndex / 12); - - newIndex = ((newIndex % 12) + 12) % 12; - - const newName = NOTES[newIndex]; - const newOctave = octave + octaveShift; - - return `${newName}${newOctave}`; -}; - -export const transposeTabs = (originalTabs: string | undefined, semitones: number, tuningMode: TuningMode): string => { - if (!originalTabs) return ''; - if (originalTabs === 'TRANSPOSED') return originalTabs; - - const strings = originalTabs.split(' '); - - interface FretInfo { - fret: number; - isOpen: boolean; - isMuted: boolean; - } - - const transposedStrings: (FretInfo | string)[] = strings.map((val, index) => { - if (val.toLowerCase() === 'x') return 'x'; - - const fret = parseInt(val, 10); - if (isNaN(fret)) return val; - - let newFret = fret + semitones; - - if (tuningMode === TuningMode.DROP && index === 0) { - newFret += 2; - } - - while (newFret < 0) { - newFret += 12; - } - - return { fret: newFret, isOpen: fret === 0, isMuted: false }; - }); - - const allFrets = transposedStrings - .filter((s): s is FretInfo => typeof s === 'object' && !s.isMuted) - .map(s => s.fret); - - if (allFrets.length > 0) { - transposedStrings.forEach((item) => { - if (typeof item === 'object' && !item.isMuted && item.fret < 0) { - item.fret += 12; - } - }); - } - - return transposedStrings.map(item => { - if (typeof item === 'object') { - return item.fret.toString(); - } - return item; - }).join(' '); -}; - -export const transposeChord = (chord: Chord, targetRoot: string, tuningMode: TuningMode): Chord => { +/** + * Moves a curated card's name and subtext to the selected root. Shapes and sounding notes are no + * longer transposed here: they come from the engine (utils/libraryVoicing.ts). + */ +export const transposeChordLabels = (chord: Chord, targetRoot: string): { name: string; subtext: string } => { const distance = getSemitoneDistance(chord.baseRoot, targetRoot); - const newNotes = distance === 0 - ? chord.notes - : chord.notes.map(note => transposeNote(note, distance)); - - const isGenericSubtext = !chord.subtext.match(/^[A-G]/); - const newSubtext = isGenericSubtext - ? `${targetRoot}${chord.subtext}` - : chord.subtext.replace(/^[A-G]#?/, targetRoot); + // A subtext is rooted when a note name is followed by a chord quality ("Em(add9)", "Bb(VI)"), not a + // word that merely starts with A-G ("Dim/b2"). + const isRooted = ROOTED_SUBTEXT.test(chord.subtext); + // Glue only real chord qualities to the root; "b2" glued to E would read as E-flat + const separator = QUALITY_START.test(chord.subtext) ? '' : ' '; + const subtext = isRooted + ? chord.subtext.replace(/^[A-G][#b]?/, targetRoot) + : `${targetRoot}${separator}${chord.subtext}`; - let newName = chord.name; + let name = chord.name; if (distance !== 0) { const noteMatch = chord.name.match(/^([A-G]#?)(\s|$|m|M|Major|Minor|Maj|Min|7|9|add|sus|dim|aug|maj|min)/i); if (noteMatch) { - const originalNote = noteMatch[1]; - const originalIndex = NOTES.indexOf(originalNote); + const originalIndex = NOTES.indexOf(noteMatch[1]); if (originalIndex !== -1) { - const newIndex = ((originalIndex + distance % 12) + 12) % 12; - const newNote = NOTES[newIndex]; - newName = chord.name.replace(/^[A-G]#?/i, newNote); + const newIndex = ((originalIndex + (distance % 12)) + 12) % 12; + name = chord.name.replace(/^[A-G]#?/i, NOTES[newIndex]); } } } - const newTabs = transposeTabs(chord.fretboard, distance, tuningMode); - - return { - ...chord, - name: newName, - subtext: newSubtext, - notes: newNotes, - fretboard: newTabs - }; + return { name, subtext }; }; From 781ef387b69f429eb259f7f4210addfd806e6056 Mon Sep 17 00:00:00 2001 From: Can Kilic Date: Sat, 3 Oct 2026 19:39:25 -0400 Subject: [PATCH 04/14] Add hand profile calibration and no-barre Voicing Finder - Hand profile store persisted at riffforge:hand-profile:v1 with full validation and safe fallback, export/import as JSON for Chord Explorer. - Calibration drawer (modal, focus trapped, Escape closes): scale length presets or custom mm, two pinky-reach checks, no-barre, stretches, open strings, highest fret and comfortable 16th tempo, live derived mm values and the comfort disclaimer ("Stop if you feel pain or tension."). - Voicing Finder view: family chips grouped by sound, Easiest / Most unusual sort, string-group and pedal-compatible filters, result cards with tab, finger numbers, degrees, honest name, cost explanation, play, add to riff and open in Chord Explorer; empty results name the binding constraint and offer one-click profile hints that never suggest barres. - Library cards follow the calibrated profile. - Workbench view switch persisted as ?view=finder; finder and drawer are lazy-loaded chunks. Co-Authored-By: Claude Opus 5.5 --- App.tsx | 177 ++++++++- components/HandProfilePanel.tsx | 618 ++++++++++++++++++++++++++++++++ components/VoicingFinder.tsx | 534 +++++++++++++++++++++++++++ stores/handProfileStore.test.ts | 202 +++++++++++ stores/handProfileStore.ts | 128 +++++++ utils/finder.test.ts | 230 ++++++++++++ utils/finder.ts | 239 ++++++++++++ utils/urlState.test.ts | 79 ++++ utils/urlState.ts | 22 +- 9 files changed, 2213 insertions(+), 16 deletions(-) create mode 100644 components/HandProfilePanel.tsx create mode 100644 components/VoicingFinder.tsx create mode 100644 stores/handProfileStore.test.ts create mode 100644 stores/handProfileStore.ts create mode 100644 utils/finder.test.ts create mode 100644 utils/finder.ts create mode 100644 utils/urlState.test.ts diff --git a/App.tsx b/App.tsx index 29eaf2c..7df075f 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { audioEngine } from './services/audioEngine'; import { DistortionSwitch } from './components/DistortionSwitch'; @@ -11,13 +11,86 @@ import { useChordStore } from './stores/chordStore'; import { useRiffStore, MAX_RIFF_STEPS } from './stores/riffStore'; import { Chord, TuningMode, VibeMode } from './types'; import { resolveLibraryChord, tuningForMode } from './utils/libraryVoicing'; -import { DEFAULT_HAND_PROFILE } from './engine/handProfile'; -import { syncUrlState } from './utils/urlState'; +import { handProfileHash } from './engine/handProfile'; +import { useHandProfileStore } from './stores/handProfileStore'; +import { parseUrlState, syncUrlState, type AppView } from './utils/urlState'; import { getChords } from './constants'; const CHORDS_PER_BATCH = 6; const RELATED_CHORDS_COUNT = 6; +const loadVoicingFinder = () => import('./components/VoicingFinder'); +const loadHandProfilePanel = () => import('./components/HandProfilePanel'); +const VoicingFinder = lazy(() => loadVoicingFinder().then((m) => ({ default: m.VoicingFinder }))); +const HandProfilePanel = lazy(() => loadHandProfilePanel().then((m) => ({ default: m.HandProfilePanel }))); + +// Rhythm Lab slot: the orchestrator flips this flag and renders the lazy RhythmLab in the 'rhythm' view below. +// While it is false the switch hides the option and ?view=rhythm falls back to the library. +const RHYTHM_LAB_ENABLED = false; + +const initialView = (): AppView => { + const view = parseUrlState().view ?? 'library'; + return view === 'rhythm' && !RHYTHM_LAB_ENABLED ? 'library' : view; +}; + +const LazyFallback: React.FC = () => ( +

Loading...

+); + +interface ViewSwitchProps { + view: AppView; + onChange: (view: AppView) => void; + isDistorted: boolean; + showRhythmLab: boolean; +} + +const ViewSwitch: React.FC = ({ view, onChange, isDistorted, showRhythmLab }) => { + const options: { id: AppView; label: string; prefetch?: () => void }[] = [ + { id: 'library', label: 'LIBRARY' }, + { id: 'finder', label: 'VOICING FINDER', prefetch: () => void loadVoicingFinder() }, + ...(showRhythmLab ? [{ id: 'rhythm' as const, label: 'RHYTHM LAB' }] : []) + ]; + + return ( +
+
+ + WORKBENCH + +
+
+ {options.map((option) => { + const active = option.id === view; + return ( + + ); + })} +
+
+ ); +}; + const App: React.FC = () => { const { isDistorted, @@ -54,9 +127,24 @@ const App: React.FC = () => { const relatedSectionRef = useRef(null); const [favoriteChords, setFavoriteChords] = useState([]); - const handProfile = DEFAULT_HAND_PROFILE; + const storedProfile = useHandProfileStore((state) => state.profile); + // Cards depend only on the fretting-hand fields: a tempo change must not reload the grid + const voicingProfileKey = handProfileHash({ ...storedProfile, comfortableSixteenthBpm: 0 }); + // eslint-disable-next-line react-hooks/exhaustive-deps + const handProfile = useMemo(() => storedProfile, [voicingProfileKey]); const tuning = tuningForMode(tuningMode); + const [view, setView] = useState(initialView); + const [handProfileOpen, setHandProfileOpen] = useState(false); + const [handProfileMounted, setHandProfileMounted] = useState(false); + + const openHandProfile = useCallback(() => { + setHandProfileMounted(true); + setHandProfileOpen(true); + }, []); + + const closeHandProfile = useCallback(() => setHandProfileOpen(false), []); + const riffSteps = useRiffStore((state) => state.steps); const metronomeOn = useRiffStore((state) => state.metronomeOn); const addRiffStep = useRiffStore((state) => state.addStep); @@ -77,10 +165,10 @@ const App: React.FC = () => { resetLockState(); }, [tuningMode, vibeMode, resetLockState]); - // Keep the query string shareable: ?root=C&tuning=drop&vibe=dark + // Keep the query string shareable: ?root=C&tuning=drop&vibe=dark&view=finder useEffect(() => { - syncUrlState(selectedRoot, tuningMode, vibeMode); - }, [selectedRoot, tuningMode, vibeMode]); + syncUrlState(selectedRoot, tuningMode, vibeMode, view); + }, [selectedRoot, tuningMode, vibeMode, view]); const handleUserInteraction = useCallback(async () => { if (!isAudioReady) { @@ -333,13 +421,39 @@ const App: React.FC = () => { isDistorted={isDistorted} /> - + {view === 'library' && ( + + )} -
+
+ + {view === 'library' && ( + )}
{ onSelectRoot={setSelectedRoot} isDistorted={isDistorted} /> + +
+ {view === 'finder' && ( +
+ }> + + +
+ )} + + {view === 'rhythm' && RHYTHM_LAB_ENABLED && ( + // Rhythm Lab slot: }> goes here +
+ )} + + {view === 'library' && ( + <>
{showFavoritesOnly && gridChords.length === 0 ? ( { )} + + )} { + + {handProfileMounted && ( + + Loading... +

+ } + > + +
+ )}
); }; diff --git a/components/HandProfilePanel.tsx b/components/HandProfilePanel.tsx new file mode 100644 index 0000000..c36737c --- /dev/null +++ b/components/HandProfilePanel.tsx @@ -0,0 +1,618 @@ +import React, { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { useChordStore } from '../stores/chordStore'; +import { useHandProfileStore } from '../stores/handProfileStore'; +import { SCALE_LENGTH_PRESETS, MM_PER_INCH } from '../engine/geometry'; +import { + CALIBRATION_HIGH_INDEX_FRET, + CALIBRATION_HIGH_PINKY_RANGE, + CALIBRATION_LOW_INDEX_FRET, + CALIBRATION_LOW_PINKY_RANGE, + PROFILE_LIMITS, + STRETCH_TOLERANCE_ALLOW +} from '../engine/handProfile'; + +export const HAND_PROFILE_DISCLAIMER = + 'This is comfort guidance, not a medical or safety assessment. Stop if you feel pain or tension.'; + +const MAX_FRET_RANGE = [5, 24] as const; +const TEMPO_RANGE = [60, 240] as const; +const TEMPO_STEP = 5; +const CUSTOM_SCALE_ID = 'custom'; +const [SCALE_MIN_MM, SCALE_MAX_MM] = PROFILE_LIMITS.scaleLengthMm; + +const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +const range = (lo: number, hi: number): number[] => Array.from({ length: hi - lo + 1 }, (_, i) => lo + i); + +const presetIdFor = (mm: number): string | null => SCALE_LENGTH_PRESETS.find((p) => Math.abs(p.mm - mm) < 0.05)?.id ?? null; + +const useIsDesktop = (): boolean => { + const query = '(min-width: 768px)'; + const [matches, setMatches] = useState(() => typeof window !== 'undefined' && window.matchMedia(query).matches); + useEffect(() => { + const mql = window.matchMedia(query); + const onChange = () => setMatches(mql.matches); + onChange(); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, []); + return matches; +}; + +/** Clipboard API first, then a hidden textarea + execCommand for older or locked-down browsers. */ +const copyText = async (text: string, host: HTMLElement | null): Promise => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Permission denied or insecure context: try the fallback + } + try { + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.top = '0'; + area.style.left = '0'; + area.style.opacity = '0'; + (host ?? document.body).appendChild(area); + area.select(); + const ok = document.execCommand('copy'); + area.remove(); + return ok; + } catch { + return false; + } +}; + +const ringClass = (isDistorted: boolean): string => + `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black ${isDistorted ? 'focus-visible:ring-rose-500' : 'focus-visible:ring-cyan-500'}`; + +const SectionLabel: React.FC<{ id?: string; children: React.ReactNode }> = ({ id, children }) => ( +

+ {children} +

+); + +interface RadioOption { + value: string; + label: string; + ariaLabel?: string; +} + +interface SegmentedRadioProps { + labelledBy: string; + describedBy?: string; + options: readonly RadioOption[]; + value: string; + onChange: (value: string) => void; + isDistorted: boolean; +} + +/** Single-select segmented control with radio semantics: one tab stop, arrow keys move the selection. */ +const SegmentedRadio: React.FC = ({ labelledBy, describedBy, options, value, onChange, isDistorted }) => { + const refs = useRef<(HTMLButtonElement | null)[]>([]); + const selected = options.findIndex((o) => o.value === value); + const tabStop = selected >= 0 ? selected : 0; + + const select = (index: number) => { + const next = (index + options.length) % options.length; + onChange(options[next].value); + refs.current[next]?.focus(); + }; + + const onKeyDown = (e: React.KeyboardEvent, index: number) => { + const moves: Record = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }; + if (e.key in moves) { + e.preventDefault(); + select(index + moves[e.key]); + } else if (e.key === 'Home') { + e.preventDefault(); + select(0); + } else if (e.key === 'End') { + e.preventDefault(); + select(options.length - 1); + } + }; + + return ( +
+ {options.map((option, i) => { + const checked = i === selected; + return ( + + ); + })} +
+ ); +}; + +interface SwitchRowProps { + label: string; + description: string; + checked: boolean; + onChange: (checked: boolean) => void; + isDistorted: boolean; +} + +const SwitchRow: React.FC = ({ label, description, checked, onChange, isDistorted }) => { + const labelId = useId(); + const descriptionId = useId(); + return ( + + ); +}; + +interface StepperRowProps { + label: string; + unit: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + isDistorted: boolean; +} + +const StepperRow: React.FC = ({ label, unit, value, min, max, step, onChange, isDistorted }) => { + const labelId = useId(); + const clamp = (v: number) => Math.min(max, Math.max(min, v)); + const buttonClass = `w-11 h-11 md:w-9 md:h-9 flex items-center justify-center rounded-full border border-white/15 font-mono text-base text-neutral-200 hover:border-white/40 transition-colors duration-200 disabled:opacity-30 disabled:cursor-default ${ringClass(isDistorted)}`; + return ( +
+ {label} +
+ + + {value} + {unit} + + +
+
+ ); +}; + +interface HandProfilePanelProps { + open: boolean; + onClose: () => void; +} + +/** Hand profile drawer: right panel on md+, bottom sheet on mobile. Modal, focus-trapped, Escape closes. */ +export const HandProfilePanel: React.FC = ({ open, onClose }) => { + const isDistorted = useChordStore((s) => s.isDistorted); + const profile = useHandProfileStore((s) => s.profile); + const calibration = useHandProfileStore((s) => s.calibration); + const setCalibration = useHandProfileStore((s) => s.setCalibration); + const resetProfile = useHandProfileStore((s) => s.resetProfile); + const exportProfileJson = useHandProfileStore((s) => s.exportProfileJson); + + const reduceMotion = useReducedMotion(); + const isDesktop = useIsDesktop(); + const titleId = useId(); + const scaleLabelId = useId(); + const lowQuestionId = useId(); + const highQuestionId = useId(); + const customInputId = useId(); + const customErrorId = useId(); + + const panelRef = useRef(null); + const titleRef = useRef(null); + const copyButtonRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const [customMode, setCustomMode] = useState(() => presetIdFor(calibration.scaleLengthMm) === null); + const [customText, setCustomText] = useState(() => String(Math.round(calibration.scaleLengthMm * 10) / 10)); + const [status, setStatus] = useState(''); + + // Focus in on open, trap Tab, close on Escape, and hand focus back to the opener on close + useEffect(() => { + if (!open) return; + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const frame = requestAnimationFrame(() => titleRef.current?.focus()); + + // Tab order only: roving radios (tabindex -1) and the programmatic title focus are not stops + const focusables = (): HTMLElement[] => + panelRef.current ? Array.from(panelRef.current.querySelectorAll(FOCUSABLE)).filter((el) => el.tabIndex >= 0) : []; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCloseRef.current(); + return; + } + if (e.key !== 'Tab') return; + const items = focusables(); + if (items.length === 0) { + e.preventDefault(); + return; + } + const index = items.indexOf(document.activeElement as HTMLElement); + if (e.shiftKey && index <= 0) { + e.preventDefault(); + items[items.length - 1].focus(); + } else if (!e.shiftKey && (index === -1 || index === items.length - 1)) { + e.preventDefault(); + items[0].focus(); + } + }; + // Anything that still lands focus outside (a click elsewhere, assistive tech) is pulled back in + const onFocusIn = (e: FocusEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) titleRef.current?.focus(); + }; + + document.addEventListener('keydown', onKeyDown); + document.addEventListener('focusin', onFocusIn); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('focusin', onFocusIn); + document.body.style.overflow = previousOverflow; + if (opener && opener.isConnected) opener.focus(); + }; + }, [open]); + + useEffect(() => { + if (!status) return; + const timer = setTimeout(() => setStatus(''), 4000); + return () => clearTimeout(timer); + }, [status]); + + const scaleValue = customMode ? CUSTOM_SCALE_ID : presetIdFor(calibration.scaleLengthMm) ?? CUSTOM_SCALE_ID; + const customNumber = Number(customText); + const customValid = customText.trim() !== '' && Number.isFinite(customNumber) && customNumber >= SCALE_MIN_MM && customNumber <= SCALE_MAX_MM; + + const onScaleChange = (id: string) => { + if (id === CUSTOM_SCALE_ID) { + setCustomMode(true); + setCustomText(String(Math.round(calibration.scaleLengthMm * 10) / 10)); + return; + } + const preset = SCALE_LENGTH_PRESETS.find((p) => p.id === id); + if (!preset) return; + setCustomMode(false); + setCalibration({ scaleLengthMm: preset.mm }); + }; + + const onCustomChange = (text: string) => { + setCustomText(text); + const mm = Number(text); + if (text.trim() !== '' && Number.isFinite(mm) && mm >= SCALE_MIN_MM && mm <= SCALE_MAX_MM) setCalibration({ scaleLengthMm: mm }); + }; + + const onCopy = useCallback(async () => { + const ok = await copyText(exportProfileJson(), panelRef.current); + copyButtonRef.current?.focus(); + setStatus(ok ? 'Profile JSON copied. Paste it into Chord Explorer to use the same hand there.' : 'Copy failed: the browser blocked clipboard access.'); + }, [exportProfileJson]); + + const onReset = () => { + resetProfile(); + setCustomMode(false); + setStatus('Hand profile reset to the defaults.'); + }; + + const accentText = isDistorted ? 'text-rose-400' : 'text-cyan-400'; + const pillClass = `flex items-center justify-center gap-1.5 px-4 h-11 md:h-9 rounded-full border font-mono text-[10px] uppercase tracking-widest transition-all duration-200 ${ringClass(isDistorted)}`; + + const transition = reduceMotion ? { duration: 0.12 } : { duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] as const }; + const hidden = reduceMotion ? { opacity: 0 } : isDesktop ? { x: '100%' } : { y: '100%' }; + const shown = reduceMotion ? { opacity: 1 } : isDesktop ? { x: 0 } : { y: 0 }; + + const lowReach = Math.round(profile.reachAtLowMm); + const highReach = Math.round(profile.reachAtHighMm); + const stretchOn = calibration.allowStretches; + + return ( + + {open && ( +
+
+ )} +
+ ); +}; diff --git a/components/VoicingFinder.tsx b/components/VoicingFinder.tsx new file mode 100644 index 0000000..f9683de --- /dev/null +++ b/components/VoicingFinder.tsx @@ -0,0 +1,534 @@ +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; +import type { GeneratedVoicing, HandProfile, Tuning, VoicingFamily, VoicingSort } from '../engine/types'; +import { FAMILY_GROUPS, getFamily } from '../engine/voicingSpec'; +import { shapeToMidi, toRiffForgeTab } from '../engine/shape'; +import { midiToName } from '../engine/pitch'; +import { audioEngine } from '../services/audioEngine'; +import { useChordStore } from '../stores/chordStore'; +import { useHandProfileStore } from '../stores/handProfileStore'; +import { useRiffStore, MAX_RIFF_STEPS } from '../stores/riffStore'; +import { tuningForMode } from '../utils/libraryVoicing'; +import { VoicingTab } from './VoicingTab'; +import { + FINDER_DEFAULT_FAMILY, + FINDER_GENERATE_LIMIT, + FINDER_SHOW_LIMIT, + STRING_GROUP_FILTERS, + explainVoicing, + familyTitle, + filterVoicings, + finderExplorerUrl, + findVoicings, + hintText, + noveltyPercent, + relaxationHints, + resultAnnouncement, + voicingTagLabels, + type FinderQuery, + type RelaxationHint, + type StringGroupFilter +} from '../utils/finder'; + +const SORT_OPTIONS: readonly { id: VoicingSort; label: string }[] = [ + { id: 'easiest', label: 'Easiest' }, + { id: 'unusual', label: 'Most unusual' } +]; + +const NOVELTY_SEGMENTS = 5; + +const ringClass = (isDistorted: boolean): string => + `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black ${isDistorted ? 'focus-visible:ring-rose-500' : 'focus-visible:ring-cyan-500'}`; + +const activePill = (isDistorted: boolean): string => + isDistorted + ? 'bg-rose-500/20 border-rose-500/60 text-rose-400 shadow-[0_0_12px_rgba(244,63,94,0.3)]' + : 'bg-cyan-500/20 border-cyan-500/60 text-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.3)]'; + +const inactivePill = 'bg-neutral-900/50 border-white/10 text-neutral-400 hover:text-neutral-200 hover:border-white/30'; + +const profileSummary = (profile: HandProfile, isDistorted: boolean): string => + [ + profile.noBarre ? 'No barre' : 'Barres allowed', + `reach ${Math.round(profile.reachAtLowMm)} mm${profile.stretchTolerance > 1 ? ' + stretch' : ''}`, + `up to fret ${profile.maxFret}`, + profile.allowOpenStrings ? null : 'no open strings', + isDistorted ? 'ranked for gain' : 'ranked for clean' + ] + .filter(Boolean) + .join(' · '); + +interface SegmentedProps { + label: string; + options: readonly { id: string; label: string }[]; + value: string; + onChange: (value: string) => void; + isDistorted: boolean; +} + +/** The app's segmented selector (aria-pressed buttons), sized for 44 px touch targets on mobile. */ +const Segmented: React.FC = ({ label, options, value, onChange, isDistorted }) => ( +
+ {options.map((option) => { + const active = option.id === value; + return ( + + ); + })} +
+); + +const ControlLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); + +const NoveltyMeter: React.FC<{ novelty: number; isDistorted: boolean }> = ({ novelty, isDistorted }) => { + const percent = noveltyPercent(novelty); + const lit = Math.round((percent / 100) * NOVELTY_SEGMENTS); + return ( +
+ Unusual +
+ ); +}; + +interface FinderCardProps { + voicing: GeneratedVoicing; + index: number; + root: string; + family: VoicingFamily; + tuning: Tuning; + profile: HandProfile; + showNovelty: boolean; + canAdd: boolean; + isDistorted: boolean; + reduceMotion: boolean; + onPlay: (voicing: GeneratedVoicing) => void; + onAdd: (voicing: GeneratedVoicing) => void; +} + +const FinderCard: React.FC = ({ + voicing, + index, + root, + family, + tuning, + profile, + showNovelty, + canAdd, + isDistorted, + reduceMotion, + onPlay, + onAdd +}) => { + const tab = toRiffForgeTab(voicing.shape); + const tags = voicingTagLabels(voicing.tags); + const ring = ringClass(isDistorted); + const roundButton = `shrink-0 w-11 h-11 md:w-9 md:h-9 flex items-center justify-center rounded-full border transition-colors duration-200 ${ring}`; + + return ( + +
+
+

{voicing.name}

+

+ Degrees low to high: + {voicing.degrees} +

+
+ {showNovelty && } +
+ +
+ +
+ +

{explainVoicing(voicing, profile)}

+ + {tags.length > 0 && ( +
    + {tags.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ )} + +
+ + + + + +
+
+ ); +}; + +interface VoicingFinderProps { + onOpenHandProfile: () => void; + handProfileOpen?: boolean; +} + +/** Playable voicings of one family on the selected root and tuning, ranked for the stored hand profile. */ +export const VoicingFinder: React.FC = ({ onOpenHandProfile, handProfileOpen = false }) => { + const isDistorted = useChordStore((s) => s.isDistorted); + const selectedRoot = useChordStore((s) => s.selectedRoot); + const tuningMode = useChordStore((s) => s.tuningMode); + const profile = useHandProfileStore((s) => s.profile); + const setCalibration = useHandProfileStore((s) => s.setCalibration); + const riffLength = useRiffStore((s) => s.steps.length); + const addStep = useRiffStore((s) => s.addStep); + const reduceMotion = useReducedMotion() ?? false; + + const titleId = useId(); + const statusRef = useRef(null); + const [familyId, setFamilyId] = useState(FINDER_DEFAULT_FAMILY); + const [sort, setSort] = useState('easiest'); + const [stringGroup, setStringGroup] = useState('all'); + const [pedalOnly, setPedalOnly] = useState(false); + // "One more muted inner string" is not a calibration answer: it stays a finder-only override for one query + const [extraMuteFor, setExtraMuteFor] = useState(null); + const [actionMessage, setActionMessage] = useState(''); + + const tuning = tuningForMode(tuningMode); + const family = getFamily(familyId) ?? (getFamily(FINDER_DEFAULT_FAMILY) as VoicingFamily); + const overrideKey = `${selectedRoot}|${family.id}|${tuning.id}`; + const extraMute = extraMuteFor === overrideKey; + + const effectiveProfile = useMemo( + () => (extraMute ? { ...profile, maxInteriorMutes: profile.maxInteriorMutes + 1 } : profile), + [profile, extraMute] + ); + + const query: FinderQuery = useMemo( + () => ({ root: selectedRoot, familyId: family.id, tuning, profile: effectiveProfile, distortion: isDistorted, sort }), + [selectedRoot, family.id, tuning, effectiveProfile, isDistorted, sort] + ); + const result = useMemo(() => findVoicings(query), [query]); + const filtered = useMemo(() => filterVoicings(result.voicings, { stringGroup, pedalOnly }), [result, stringGroup, pedalOnly]); + const shown = filtered.slice(0, FINDER_SHOW_LIMIT); + const hints = useMemo(() => (result.voicings.length === 0 ? relaxationHints(query, extraMute) : []), [result, query, extraMute]); + + const filtersActive = stringGroup !== 'all' || pedalOnly; + const capped = !filtersActive && result.voicings.length >= FINDER_GENERATE_LIMIT; + const announcement = resultAnnouncement(filtered.length, selectedRoot, family); + const canAdd = riffLength < MAX_RIFF_STEPS; + + useEffect(() => { + if (!actionMessage) return; + const timer = setTimeout(() => setActionMessage(''), 3000); + return () => clearTimeout(timer); + }, [actionMessage]); + + const playVoicing = useCallback( + async (voicing: GeneratedVoicing) => { + const chord = useChordStore.getState(); + if (!chord.isAudioReady) { + await audioEngine.init(); + audioEngine.setDistortion(chord.isDistorted); + chord.setIsAudioReady(true); + } + // Same shape as the tab on the card + audioEngine.playChord(shapeToMidi(voicing.shape, tuning).map(midiToName)); + }, + [tuning] + ); + + const addVoicing = useCallback( + (voicing: GeneratedVoicing) => { + const tab = toRiffForgeTab(voicing.shape); + const added = addStep({ + id: `finder:${family.id}:${tab}`, + name: voicing.name, + subtext: `${family.label} · ${tab}`, + notes: shapeToMidi(voicing.shape, tuning).map(midiToName) + }); + setActionMessage(added ? `Added ${voicing.name} to the riff.` : `The riff is full (${MAX_RIFF_STEPS} steps).`); + }, + [addStep, family, tuning] + ); + + const applyHint = (hint: RelaxationHint) => { + if (hint.kind === 'calibration') setCalibration(hint.patch); + else setExtraMuteFor(overrideKey); + // The hint button disappears with the empty state; keep keyboard focus on the result status + requestAnimationFrame(() => statusRef.current?.focus()); + }; + + const clearFilters = () => { + setStringGroup('all'); + setPedalOnly(false); + requestAnimationFrame(() => statusRef.current?.focus()); + }; + + const accentText = isDistorted ? 'text-rose-400' : 'text-cyan-400'; + const ring = ringClass(isDistorted); + + return ( +
+
+
+
+

+ Voicing finder +

+

+ {selectedRoot} + + in + {tuning.name} +

+

{profileSummary(effectiveProfile, isDistorted)}

+
+ +
+ +
+ {FAMILY_GROUPS.map((group) => ( +
+ {group.label} +
+ {group.familyIds.map((id) => { + const f = getFamily(id); + if (!f) return null; + const active = id === family.id; + return ( + + ); + })} +
+
+ ))} +
+ +

{family.description}

+ +
+
+ Sort + setSort(v as VoicingSort)} isDistorted={isDistorted} /> +
+
+ Strings + setStringGroup(v as StringGroupFilter)} + isDistorted={isDistorted} + /> +
+
+ Pedal + +
+
+
+ +
+

+ + {filtered.length} + {capped ? '+' : ''} + {' '} + {filtered.length === 1 ? 'voicing' : 'voicings'} for {familyTitle(selectedRoot, family)} + {filtered.length > shown.length ? ` · showing ${shown.length}` : ''} + {filtersActive && result.voicings.length > filtered.length ? ` · ${result.voicings.length - filtered.length} filtered out` : ''} +

+ {extraMute && ( + + )} +
+ +

+ {announcement} +

+

+ {actionMessage} +

+ + {shown.length > 0 ? ( +
+ {shown.map((voicing, index) => ( + + ))} +
+ ) : ( +
+ {result.voicings.length === 0 ? ( + <> +

No playable shape here

+

+ {result.empty?.message ?? 'No voicing fits this recipe and your hand profile.'} +

+ {hints.length > 0 && ( +
+ {hints.map((hint) => ( + + ))} +
+ )} + + ) : ( + <> +

No voicings match these filters

+

+ {result.voicings.length} {result.voicings.length === 1 ? 'voicing fits' : 'voicings fit'} your hand without them. +

+ + + )} +
+ )} +
+ ); +}; + +const HandIcon: React.FC = () => ( + +); diff --git a/stores/handProfileStore.test.ts b/stores/handProfileStore.test.ts new file mode 100644 index 0000000..c8729e1 --- /dev/null +++ b/stores/handProfileStore.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { HAND_PROFILE_STORAGE_KEY, parseCalibration } from './handProfileStore'; +import type { ImportProfileResult } from './handProfileStore'; +import { + DEFAULT_CALIBRATION, + DEFAULT_HAND_PROFILE, + STRETCH_TOLERANCE_ALLOW, + calibrationFromProfile, + profileFromCalibration +} from '../engine/handProfile'; +import { spanMm } from '../engine/geometry'; + +// Minimal in-memory localStorage for the node test environment +const createStorageMock = () => { + let data: Record = {}; + return { + getItem: (key: string) => (key in data ? data[key] : null), + setItem: (key: string, value: string) => { data[key] = String(value); }, + removeItem: (key: string) => { delete data[key]; }, + clear: () => { data = {}; }, + key: (i: number) => Object.keys(data)[i] ?? null, + get length() { return Object.keys(data).length; } + } as Storage; +}; + +const freshStore = async () => { + vi.resetModules(); + const mod = await import('./handProfileStore'); + return mod.useHandProfileStore; +}; + +const stored = () => JSON.parse(localStorage.getItem(HAND_PROFILE_STORAGE_KEY) ?? 'null'); + +beforeEach(() => { + vi.unstubAllGlobals(); + vi.stubGlobal('localStorage', createStorageMock()); +}); + +describe('handProfileStore', () => { + it('starts from the defaults when nothing is stored', async () => { + const store = await freshStore(); + const { profile, calibration } = store.getState(); + expect(profile).toEqual(DEFAULT_HAND_PROFILE); + expect(calibration).toEqual(DEFAULT_CALIBRATION); + // The default answers reproduce the default profile exactly + expect(profileFromCalibration(calibration)).toEqual(DEFAULT_HAND_PROFILE); + }); + + it('falls back to the defaults without localStorage', async () => { + vi.stubGlobal('localStorage', undefined); + const store = await freshStore(); + expect(store.getState().profile).toEqual(DEFAULT_HAND_PROFILE); + // Writes must not throw either + expect(() => store.getState().setCalibration({ lowPinkyFret: 3 })).not.toThrow(); + }); + + it('falls back to the defaults when storage access throws', async () => { + vi.stubGlobal('localStorage', { + getItem: () => { throw new Error('SecurityError'); }, + setItem: () => { throw new Error('QuotaExceededError'); } + }); + const store = await freshStore(); + expect(store.getState().profile).toEqual(DEFAULT_HAND_PROFILE); + expect(() => store.getState().setCalibration({ maxFret: 12 })).not.toThrow(); + expect(store.getState().profile.maxFret).toBe(12); + }); + + it('persists calibration changes and reloads them', async () => { + const store = await freshStore(); + store.getState().setCalibration({ lowPinkyFret: 5, allowStretches: true, maxFret: 12 }); + + const saved = stored(); + expect(saved.version).toBe(1); + expect(saved.calibration.lowPinkyFret).toBe(5); + expect(saved.profile.maxFret).toBe(12); + + const reloaded = await freshStore(); + expect(reloaded.getState().calibration).toEqual(store.getState().calibration); + expect(reloaded.getState().profile).toEqual(store.getState().profile); + }); + + it('setCalibration rebuilds the profile from the merged answers', async () => { + const store = await freshStore(); + store.getState().setCalibration({ lowPinkyFret: 3 }); + const { profile, calibration } = store.getState(); + + expect(calibration).toEqual({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 }); + expect(profile.reachAtLowMm).toBeCloseTo(spanMm(1, 3, DEFAULT_CALIBRATION.scaleLengthMm), 6); + expect(profile.reachAtLowMm).toBeLessThan(DEFAULT_HAND_PROFILE.reachAtLowMm); + // Untouched answers survive the patch + expect(profile.reachAtHighMm).toBeCloseTo(DEFAULT_HAND_PROFILE.reachAtHighMm, 6); + + store.getState().setCalibration({ allowStretches: true, allowOpenStrings: false, noBarre: false }); + expect(store.getState().profile.stretchTolerance).toBe(STRETCH_TOLERANCE_ALLOW); + expect(store.getState().profile.allowOpenStrings).toBe(false); + expect(store.getState().profile.noBarre).toBe(false); + expect(store.getState().calibration.lowPinkyFret).toBe(3); + }); + + it('resetProfile restores and persists the defaults', async () => { + const store = await freshStore(); + store.getState().setCalibration({ lowPinkyFret: 7, highPinkyFret: 13, comfortableSixteenthBpm: 200 }); + store.getState().resetProfile(); + expect(store.getState().profile).toEqual(DEFAULT_HAND_PROFILE); + expect(store.getState().calibration).toEqual(DEFAULT_CALIBRATION); + expect(stored().calibration).toEqual(DEFAULT_CALIBRATION); + }); + + it.each([ + ['invalid JSON', '{not json'], + ['a number', '42'], + ['null', 'null'], + ['an array', '[1,2,3]'], + ['a wrapper without a profile', JSON.stringify({ version: 1, calibration: DEFAULT_CALIBRATION })], + ['a string profile', JSON.stringify({ version: 1, profile: 'wide' })] + ])('falls back to the defaults for corrupt storage: %s', async (_label, raw) => { + localStorage.setItem(HAND_PROFILE_STORAGE_KEY, raw); + const store = await freshStore(); + expect(store.getState().profile).toEqual(DEFAULT_HAND_PROFILE); + expect(store.getState().calibration).toEqual(DEFAULT_CALIBRATION); + }); + + it('sanitizes a stored profile and rebuilds an invalid calibration from it', async () => { + const wide = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 6, highPinkyFret: 12 }); + localStorage.setItem( + HAND_PROFILE_STORAGE_KEY, + JSON.stringify({ + version: 1, + calibration: { ...DEFAULT_CALIBRATION, lowPinkyFret: 'six' }, + profile: { ...wide, maxFret: 999, noBarre: 'yes' } + }) + ); + const store = await freshStore(); + const { profile, calibration } = store.getState(); + expect(profile.maxFret).toBe(24); + expect(profile.noBarre).toBe(true); + expect(profile.reachAtLowMm).toBeCloseTo(wide.reachAtLowMm, 6); + expect(calibration).toEqual(calibrationFromProfile(profile)); + expect(calibration.lowPinkyFret).toBe(6); + expect(calibration.highPinkyFret).toBe(12); + }); + + it('exports a wrapper and imports it back (round trip)', async () => { + const store = await freshStore(); + store.getState().setCalibration({ lowPinkyFret: 6, highPinkyFret: 12, scaleLengthMm: 27 * 25.4, allowStretches: true }); + const exportedProfile = store.getState().profile; + const json = store.getState().exportProfileJson(); + + const parsed = JSON.parse(json); + expect(parsed.app).toBe('riffforge'); + expect(parsed.version).toBe(1); + expect(parsed.profile).toEqual(exportedProfile); + expect(json).toContain('\n '); // pretty printed + + store.getState().resetProfile(); + expect(store.getState().importProfileJson(json)).toEqual({ ok: true }); + expect(store.getState().profile).toEqual(exportedProfile); + expect(store.getState().calibration).toEqual(calibrationFromProfile(exportedProfile)); + expect(stored().profile).toEqual(exportedProfile); + }); + + it('imports a bare profile object', async () => { + const store = await freshStore(); + const narrow = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 }); + expect(store.getState().importProfileJson(JSON.stringify(narrow))).toEqual({ ok: true }); + expect(store.getState().profile).toEqual(narrow); + expect(store.getState().calibration.lowPinkyFret).toBe(3); + }); + + it.each([ + ['empty text', ''], + ['invalid JSON', '{"profile": '], + ['a number', '7'], + ['null', 'null'], + ['an array', '[]'], + ['an unrelated object', JSON.stringify({ hello: 'world' })], + ['a wrapper with garbage', JSON.stringify({ app: 'riffforge', version: 1, profile: { reachAtLowMm: 'far' } })] + ])('import rejects %s without throwing or changing the profile', async (_label, text) => { + const store = await freshStore(); + store.getState().setCalibration({ lowPinkyFret: 5 }); + const before = store.getState().profile; + + const run = (): ImportProfileResult => store.getState().importProfileJson(text); + expect(run).not.toThrow(); + const result = run(); + expect(result.ok).toBe(false); + if (result.ok === false) expect(result.error.length).toBeGreaterThan(0); + expect(store.getState().profile).toBe(before); + }); +}); + +describe('parseCalibration', () => { + it('accepts complete answers and rejects missing or out-of-range ones', () => { + expect(parseCalibration(DEFAULT_CALIBRATION)).toEqual(DEFAULT_CALIBRATION); + expect(parseCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 9 })).toBeNull(); + expect(parseCalibration({ ...DEFAULT_CALIBRATION, highPinkyFret: 10.5 })).toBeNull(); + expect(parseCalibration({ ...DEFAULT_CALIBRATION, scaleLengthMm: 100 })).toBeNull(); + const { noBarre: _omit, ...missing } = DEFAULT_CALIBRATION; + expect(parseCalibration(missing)).toBeNull(); + expect(parseCalibration(null)).toBeNull(); + }); +}); diff --git a/stores/handProfileStore.ts b/stores/handProfileStore.ts new file mode 100644 index 0000000..efe97ef --- /dev/null +++ b/stores/handProfileStore.ts @@ -0,0 +1,128 @@ +import { create } from 'zustand'; +import type { CalibrationAnswers, HandProfile } from '../engine/types'; +import { + CALIBRATION_HIGH_PINKY_RANGE, + CALIBRATION_LOW_PINKY_RANGE, + DEFAULT_CALIBRATION, + PROFILE_LIMITS, + calibrationFromProfile, + createDefaultHandProfile, + migrateHandProfile, + profileFromCalibration, + validateHandProfile +} from '../engine/handProfile'; + +export const HAND_PROFILE_STORAGE_KEY = 'riffforge:hand-profile:v1'; +export const HAND_PROFILE_EXPORT_APP = 'riffforge'; + +export type ImportProfileResult = { ok: true } | { ok: false; error: string }; + +interface HandProfileState { + profile: HandProfile; + calibration: CalibrationAnswers; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isNumberIn = (value: unknown, [lo, hi]: readonly [number, number], integer = false): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= lo && value <= hi && (!integer || Number.isInteger(value)); + +/** Stored calibration answers, or null when any field is missing or out of range. */ +export const parseCalibration = (value: unknown): CalibrationAnswers | null => { + if (!isRecord(value)) return null; + const { scaleLengthMm, lowPinkyFret, highPinkyFret, allowStretches, noBarre, allowOpenStrings, maxFret, comfortableSixteenthBpm } = value; + if ( + !isNumberIn(scaleLengthMm, PROFILE_LIMITS.scaleLengthMm) || + !isNumberIn(lowPinkyFret, CALIBRATION_LOW_PINKY_RANGE, true) || + !isNumberIn(highPinkyFret, CALIBRATION_HIGH_PINKY_RANGE, true) || + typeof allowStretches !== 'boolean' || + typeof noBarre !== 'boolean' || + typeof allowOpenStrings !== 'boolean' || + !isNumberIn(maxFret, PROFILE_LIMITS.maxFret, true) || + !isNumberIn(comfortableSixteenthBpm, PROFILE_LIMITS.comfortableSixteenthBpm, true) + ) { + return null; + } + return { scaleLengthMm, lowPinkyFret, highPinkyFret, allowStretches, noBarre, allowOpenStrings, maxFret, comfortableSixteenthBpm }; +}; + +const defaults = (): HandProfileState => ({ profile: createDefaultHandProfile(), calibration: { ...DEFAULT_CALIBRATION } }); + +/** Reads `{ version, calibration, profile }` from storage; anything unreadable falls back to the defaults. */ +const loadState = (): HandProfileState => { + try { + if (typeof localStorage === 'undefined') return defaults(); + const raw = localStorage.getItem(HAND_PROFILE_STORAGE_KEY); + if (!raw) return defaults(); + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed) || !isRecord(parsed.profile)) return defaults(); + const profile = migrateHandProfile(parsed.profile); + return { profile, calibration: parseCalibration(parsed.calibration) ?? calibrationFromProfile(profile) }; + } catch { + return defaults(); + } +}; + +const saveState = ({ profile, calibration }: HandProfileState): void => { + try { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(HAND_PROFILE_STORAGE_KEY, JSON.stringify({ version: 1, calibration, profile })); + } catch { + // Storage unavailable (private mode, quota): the profile stays in memory only + } +}; + +/** The core fields a hand profile must carry before the rest is sanitized; rejects unrelated JSON. */ +const looksLikeProfile = (value: unknown): value is Record => + isRecord(value) && + typeof value.scaleLengthMm === 'number' && Number.isFinite(value.scaleLengthMm) && + typeof value.reachAtLowMm === 'number' && Number.isFinite(value.reachAtLowMm) && + typeof value.reachAtHighMm === 'number' && Number.isFinite(value.reachAtHighMm); + +interface HandProfileStore extends HandProfileState { + setCalibration: (patch: Partial) => void; + resetProfile: () => void; + exportProfileJson: () => string; + importProfileJson: (text: string) => ImportProfileResult; +} + +const initial = loadState(); + +export const useHandProfileStore = create((set, get) => ({ + profile: initial.profile, + calibration: initial.calibration, + + setCalibration: (patch) => { + const calibration = { ...get().calibration, ...patch }; + const next = { calibration, profile: profileFromCalibration(calibration) }; + saveState(next); + set(next); + }, + + resetProfile: () => { + const next = defaults(); + saveState(next); + set(next); + }, + + exportProfileJson: () => JSON.stringify({ app: HAND_PROFILE_EXPORT_APP, version: 1, profile: get().profile }, null, 2), + + importProfileJson: (text) => { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false, error: 'Not valid JSON.' }; + } + const candidate = isRecord(parsed) && isRecord(parsed.profile) ? parsed.profile : parsed; + if (!looksLikeProfile(candidate)) { + return { ok: false, error: 'This JSON is not a hand profile (scale length and reach values are missing).' }; + } + const profile = validateHandProfile(candidate); + const next = { profile, calibration: calibrationFromProfile(profile) }; + saveState(next); + set(next); + return { ok: true }; + } +})); diff --git a/utils/finder.test.ts b/utils/finder.test.ts new file mode 100644 index 0000000..4df5a14 --- /dev/null +++ b/utils/finder.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest'; +import { + FINDER_GENERATE_LIMIT, + createLruCache, + explainVoicing, + explorerChordFor, + explorerTypeForFamily, + filterVoicings, + finderCacheKey, + finderExplorerUrl, + findVoicings, + hintText, + noveltyPercent, + relaxationHints, + resultAnnouncement, + voicingTagLabels, + type FinderQuery +} from './finder'; +import { mapToExplorerType } from './chordExplorer'; +import { DEFAULT_CALIBRATION, DEFAULT_HAND_PROFILE, profileFromCalibration } from '../engine/handProfile'; +import { DROP_D, E_STANDARD } from '../engine/tuning'; +import { ALL_FAMILIES, getFamily } from '../engine/voicingSpec'; +import { toRiffForgeTab } from '../engine/shape'; +import type { GeneratedVoicing, VoicingTag } from '../engine/types'; + +const query = (overrides: Partial = {}): FinderQuery => ({ + root: 'E', + familyId: 'power5', + tuning: DROP_D, + profile: DEFAULT_HAND_PROFILE, + distortion: false, + sort: 'easiest', + ...overrides +}); + +const fakeVoicing = (tags: VoicingTag[]): GeneratedVoicing => ({ tags } as unknown as GeneratedVoicing); + +describe('createLruCache', () => { + it('evicts the least recently used entry and refreshes entries on read', () => { + const cache = createLruCache(2); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.get('a')).toBe(1); // a is now the most recent + cache.set('c', 3); + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('a')).toBe(1); + expect(cache.get('c')).toBe(3); + expect(cache.size).toBe(2); + }); + + it('overwrites an existing key without growing', () => { + const cache = createLruCache(2); + cache.set('a', 'x'); + cache.set('a', 'y'); + expect(cache.size).toBe(1); + expect(cache.get('a')).toBe('y'); + }); +}); + +describe('findVoicings', () => { + it('memoizes per root, family, tuning, profile hash, distortion and sort', () => { + const first = findVoicings(query()); + expect(findVoicings(query())).toBe(first); + // A structurally equal profile object hits the same entry + expect(findVoicings(query({ profile: { ...DEFAULT_HAND_PROFILE } }))).toBe(first); + expect(findVoicings(query({ distortion: true }))).not.toBe(first); + expect(findVoicings(query({ sort: 'unusual' }))).not.toBe(first); + expect(findVoicings(query({ tuning: E_STANDARD }))).not.toBe(first); + expect(findVoicings(query({ familyId: 'sus2' }))).not.toBe(first); + }); + + it('keys distinguish every memo input', () => { + const keys = new Set([ + finderCacheKey(query()), + finderCacheKey(query({ root: 'F' })), + finderCacheKey(query({ familyId: 'sus2' })), + finderCacheKey(query({ tuning: E_STANDARD })), + finderCacheKey(query({ profile: profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 }) })), + finderCacheKey(query({ distortion: true })), + finderCacheKey(query({ sort: 'unusual' })) + ]); + expect(keys.size).toBe(7); + }); + + it('returns at most 48 diversified, no-barre voicings', () => { + const { voicings } = findVoicings(query({ familyId: 'sus2' })); + expect(voicings.length).toBeGreaterThan(0); + expect(voicings.length).toBeLessThanOrEqual(FINDER_GENERATE_LIMIT); + for (const v of voicings) expect(v.fingering.barres).toEqual([]); + }); + + it('a smaller reach removes wide shapes', () => { + const narrow = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 }); + const wide = findVoicings(query()).voicings.map((v) => toRiffForgeTab(v.shape)); + const tight = findVoicings(query({ profile: narrow })).voicings.map((v) => toRiffForgeTab(v.shape)); + expect(tight.length).toBeLessThan(wide.length); + // Two fingers on fret 2 push the fret 4 note onto ring or pinky, past a fret 1 -> 3 hand + expect(wide).toContain('2 2 x 4 0 x'); + expect(tight).not.toContain('2 2 x 4 0 x'); + for (const tab of tight) expect(wide).toContain(tab); + }); +}); + +describe('filterVoicings', () => { + const voicings = [ + fakeVoicing(['lowStrings', 'pedalCompatible']), + fakeVoicing(['middleStrings']), + fakeVoicing(['topStrings', 'pedalCompatible']), + fakeVoicing(['middleStrings', 'topStrings']) + ]; + + it('keeps everything with no filters', () => { + expect(filterVoicings(voicings, { stringGroup: 'all', pedalOnly: false })).toHaveLength(4); + }); + + it('filters by string group tag', () => { + expect(filterVoicings(voicings, { stringGroup: 'low', pedalOnly: false })).toEqual([voicings[0]]); + expect(filterVoicings(voicings, { stringGroup: 'middle', pedalOnly: false })).toEqual([voicings[1], voicings[3]]); + expect(filterVoicings(voicings, { stringGroup: 'top', pedalOnly: false })).toEqual([voicings[2], voicings[3]]); + }); + + it('combines the pedal filter with the string group', () => { + expect(filterVoicings(voicings, { stringGroup: 'all', pedalOnly: true })).toEqual([voicings[0], voicings[2]]); + expect(filterVoicings(voicings, { stringGroup: 'top', pedalOnly: true })).toEqual([voicings[2]]); + }); +}); + +describe('display helpers', () => { + it('labels the tags worth showing in a stable order', () => { + expect(voicingTagLabels(['rootInBass', 'lowStrings', 'usesOpenStrings', 'pedalCompatible'])).toEqual([ + 'pedal', + 'open strings', + 'root in bass' + ]); + expect(voicingTagLabels(['middleStrings'])).toEqual([]); + }); + + it('explains cost against the comfortable reach', () => { + const v = findVoicings(query({ familyId: 'sus2' })).voicings.find((x) => x.fingering.metrics.reachRatio > 0); + expect(v).toBeDefined(); + const text = explainVoicing(v as GeneratedVoicing, DEFAULT_HAND_PROFILE); + expect(text).toMatch(/^\d finger/); + expect(text).toContain('percent of your comfort'); + expect(text).toContain(' · '); + expect(text.toLowerCase()).not.toContain('safe'); + }); + + it('announces counts with the family label', () => { + expect(resultAnnouncement(7, 'E', getFamily('sus2')!)).toBe('7 voicings for E sus2'); + expect(resultAnnouncement(1, 'C#', getFamily('m_add9')!)).toBe('1 voicing for C# m(add9)'); + }); + + it('clamps novelty to a percent', () => { + expect(noveltyPercent(0.426)).toBe(43); + expect(noveltyPercent(-1)).toBe(0); + expect(noveltyPercent(2)).toBe(100); + }); +}); + +describe('Chord Explorer mapping', () => { + it('maps every family to a type that survives the URL builder', () => { + for (const family of ALL_FAMILIES) { + const chord = explorerChordFor('F#', family.id); + expect(mapToExplorerType(chord.name, chord.subtext)).toBe(explorerTypeForFamily(family.id)); + } + }); + + it('picks the closest Chord Explorer type', () => { + expect(explorerTypeForFamily('power5')).toBe('major'); + expect(explorerTypeForFamily('m_add9')).toBe('minor'); + expect(explorerTypeForFamily('sus2')).toBe('sus2'); + expect(explorerTypeForFamily('fourth')).toBe('sus4'); + expect(explorerTypeForFamily('tritone')).toBe('dim'); + expect(explorerTypeForFamily('drone_quartal')).toBe('sus4'); + expect(explorerTypeForFamily('m7')).toBe('m7'); + }); + + it('sends the shape only in E Standard', () => { + const standard = findVoicings(query({ tuning: E_STANDARD, familyId: 'm_add9' })).voicings[0]; + const drop = findVoicings(query({ familyId: 'm_add9' })).voicings[0]; + const standardUrl = finderExplorerUrl('E', 'm_add9', standard, E_STANDARD); + expect(standardUrl).toContain('root=E&type=minor'); + expect(standardUrl).toContain(`&gv=${standard.shape.map((f) => (f === null ? 'x' : f)).join('-')}`); + expect(finderExplorerUrl('C#', 'm_add9', drop, DROP_D)).toBe('https://chords.thegridbase.com/?root=C%23&type=minor'); + }); +}); + +describe('relaxationHints', () => { + const strict = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3, highPinkyFret: 9, allowOpenStrings: false }); + + it('offers allow stretches when that unlocks shapes', () => { + const q = query({ tuning: E_STANDARD, familyId: 'dyad_b2', profile: strict }); + expect(findVoicings(q).voicings).toHaveLength(0); + const hints = relaxationHints(q); + const stretch = hints.find((h) => h.kind === 'calibration' && h.patch.allowStretches === true); + expect(stretch).toBeDefined(); + expect(stretch!.count).toBeGreaterThan(0); + expect(hintText(stretch!)).toMatch(/^With 'Allow stretches': \d+ shapes?$/); + expect(hintText({ kind: 'extraMute', label: 'With one more muted inner string', count: FINDER_GENERATE_LIMIT })).toBe( + 'With one more muted inner string: 48+ shapes' + ); + }); + + it('offers one more muted inner string as a finder-only override', () => { + const tight = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3, highPinkyFret: 9, allowOpenStrings: false, maxFret: 7 }); + const q = query({ tuning: E_STANDARD, root: 'C', familyId: 'power5_b2', profile: tight }); + expect(findVoicings(q).voicings).toHaveLength(0); + const hints = relaxationHints(q); + expect(hints.some((h) => h.kind === 'extraMute' && h.count > 0)).toBe(true); + expect(relaxationHints(q, true).some((h) => h.kind === 'extraMute')).toBe(false); + }); + + it('never suggests barres and stays silent when nothing helps', () => { + // F has no open string on its root or fifth in Drop D: no relaxation can add a drone string + const q = query({ root: 'F', familyId: 'drone_power5' }); + expect(findVoicings(q).voicings).toHaveLength(0); + expect(relaxationHints(q)).toEqual([]); + for (const family of ['dyad_b2', 'phrygian', 'cluster_m2']) { + for (const hint of relaxationHints(query({ tuning: E_STANDARD, familyId: family, profile: strict }))) { + if (hint.kind === 'calibration') expect(hint.patch).not.toHaveProperty('noBarre'); + } + } + }); + + it('skips options that are already on', () => { + const relaxed = profileFromCalibration({ ...DEFAULT_CALIBRATION, allowStretches: true, allowOpenStrings: true }); + const hints = relaxationHints(query({ root: 'F', familyId: 'drone_power5', profile: relaxed })); + expect(hints.filter((h) => h.kind === 'calibration')).toEqual([]); + }); +}); diff --git a/utils/finder.ts b/utils/finder.ts new file mode 100644 index 0000000..d98913e --- /dev/null +++ b/utils/finder.ts @@ -0,0 +1,239 @@ +// Pure helpers for the Voicing Finder: memoized generation, result filters, display labels, Chord Explorer +// mapping and the empty-state relaxation hints. No React, no audio. +import type { + CalibrationAnswers, + GenerateResult, + GeneratedVoicing, + HandProfile, + Tuning, + VoicingFamily, + VoicingSort, + VoicingTag +} from '../engine/types'; +import { generateVoicings } from '../engine/generateVoicings'; +import { STRETCH_TOLERANCE_ALLOW, handProfileHash } from '../engine/handProfile'; +import { explainCost } from '../engine/playability'; +import { buildChordExplorerUrl, type ChordExplorerType } from './chordExplorer'; + +export const FINDER_DEFAULT_FAMILY = 'power5'; +export const FINDER_GENERATE_LIMIT = 48; +export const FINDER_SHOW_LIMIT = 12; +export const FINDER_CACHE_SIZE = 32; + +// --------------------------------------------------------------------------- +// Small LRU cache +// --------------------------------------------------------------------------- + +export interface LruCache { + get: (key: string) => V | undefined; + set: (key: string, value: V) => void; + readonly size: number; +} + +/** Map-backed LRU: reads refresh an entry, writes past `capacity` evict the least recently used one. */ +export const createLruCache = (capacity: number): LruCache => { + const map = new Map(); + return { + get: (key) => { + if (!map.has(key)) return undefined; + const value = map.get(key) as V; + map.delete(key); + map.set(key, value); + return value; + }, + set: (key, value) => { + map.delete(key); + map.set(key, value); + while (map.size > capacity) map.delete(map.keys().next().value as string); + }, + get size() { + return map.size; + } + }; +}; + +// --------------------------------------------------------------------------- +// Memoized generation +// --------------------------------------------------------------------------- + +export interface FinderQuery { + root: string; + familyId: string; + tuning: Tuning; + profile: HandProfile; + distortion: boolean; + sort: VoicingSort; +} + +export const finderCacheKey = ({ root, familyId, tuning, profile, distortion, sort }: FinderQuery): string => + [root, familyId, tuning.id, handProfileHash(profile), distortion ? 1 : 0, sort].join('|'); + +const resultCache = createLruCache(FINDER_CACHE_SIZE); + +/** generateVoicings for the finder (diversified, 48 candidates), memoized per query in a module-level LRU. */ +export const findVoicings = (query: FinderQuery): GenerateResult => { + const key = finderCacheKey(query); + const cached = resultCache.get(key); + if (cached) return cached; + const result = generateVoicings({ + root: query.root, + family: query.familyId, + tuning: query.tuning, + profile: query.profile, + distortion: query.distortion, + sort: query.sort, + limit: FINDER_GENERATE_LIMIT, + diversify: true + }); + resultCache.set(key, result); + return result; +}; + +// --------------------------------------------------------------------------- +// Filters +// --------------------------------------------------------------------------- + +export type StringGroupFilter = 'all' | 'low' | 'middle' | 'top'; + +export const STRING_GROUP_FILTERS: readonly { id: StringGroupFilter; label: string; tag: VoicingTag | null }[] = [ + { id: 'all', label: 'All', tag: null }, + { id: 'low', label: 'Low 3', tag: 'lowStrings' }, + { id: 'middle', label: 'Middle', tag: 'middleStrings' }, + { id: 'top', label: 'Top 3', tag: 'topStrings' } +]; + +export interface FinderFilters { + stringGroup: StringGroupFilter; + pedalOnly: boolean; +} + +export const filterVoicings = (voicings: readonly GeneratedVoicing[], { stringGroup, pedalOnly }: FinderFilters): GeneratedVoicing[] => { + const groupTag = STRING_GROUP_FILTERS.find((g) => g.id === stringGroup)?.tag ?? null; + return voicings.filter( + (v) => (groupTag === null || v.tags.includes(groupTag)) && (!pedalOnly || v.tags.includes('pedalCompatible')) + ); +}; + +// --------------------------------------------------------------------------- +// Display helpers +// --------------------------------------------------------------------------- + +const TAG_LABELS: Partial> = { + pedalCompatible: 'pedal', + drone: 'drone', + usesOpenStrings: 'open strings', + rootInBass: 'root in bass' +}; + +/** Short chip labels for the tags worth showing on a card, in a stable order. */ +export const voicingTagLabels = (tags: readonly VoicingTag[]): string[] => + (Object.keys(TAG_LABELS) as VoicingTag[]).filter((tag) => tags.includes(tag)).map((tag) => TAG_LABELS[tag] as string); + +/** One-line comfort explanation: "3 fingers · reach 82 percent of your comfort · 1 interior mute". */ +export const explainVoicing = (voicing: GeneratedVoicing, profile: HandProfile): string => + explainCost(voicing.breakdown, voicing.fingering, profile).join(' · '); + +/** "E sus2", as used in headings and the live announcement. */ +export const familyTitle = (root: string, family: VoicingFamily): string => `${root} ${family.label}`; + +export const resultAnnouncement = (count: number, root: string, family: VoicingFamily): string => + `${count} ${count === 1 ? 'voicing' : 'voicings'} for ${familyTitle(root, family)}`; + +/** Novelty 0..1 as a whole percent, for the "unusual" meter. */ +export const noveltyPercent = (novelty: number): number => Math.round(Math.min(1, Math.max(0, novelty)) * 100); + +// --------------------------------------------------------------------------- +// Chord Explorer +// --------------------------------------------------------------------------- + +/** + * Closest Chord Explorer type per base family (CE only knows triads, 7ths and sus chords). Dyads and power chords + * land on the triad that contains their notes; the fourth and quartal stacks on sus4; the b3 cluster on minor. + */ +const EXPLORER_TYPE_BY_FAMILY: Readonly> = { + power5: 'major', + power5_b2: 'major', + dyad_b2: 'major', + tritone: 'dim', + fourth: 'sus4', + sus2: 'sus2', + sus4: 'sus4', + add9: 'major', + m_add9: 'minor', + '7': '7', + m7: 'm7', + maj7: 'maj7', + quartal: 'sus4', + cluster_m2: 'major', + cluster_b3: 'minor', + phrygian: 'major', + major: 'major', + minor: 'minor', + dim: 'dim', + aug: 'aug' +}; + +/** Chord-name suffixes that utils/chordExplorer maps back to each type. */ +const EXPLORER_SUFFIX: Readonly> = { + major: '', + minor: 'm', + dim: 'dim', + aug: 'aug', + '7': '7', + m7: 'm7', + maj7: 'maj7', + dim7: 'dim7', + sus2: 'sus2', + sus4: 'sus4' +}; + +export const explorerTypeForFamily = (familyId: string): ChordExplorerType => + EXPLORER_TYPE_BY_FAMILY[familyId.replace(/^drone_/, '')] ?? 'major'; + +/** A name/subtext pair that buildChordExplorerUrl maps to the family's closest Chord Explorer type. */ +export const explorerChordFor = (root: string, familyId: string): { name: string; subtext: string } => { + const name = `${root}${EXPLORER_SUFFIX[explorerTypeForFamily(familyId)]}`; + return { name, subtext: name }; +}; + +/** Chord Explorer deep link; the exact shape only travels in E Standard, the only tuning Chord Explorer knows. */ +export const finderExplorerUrl = (root: string, familyId: string, voicing: GeneratedVoicing, tuning: Tuning): string => + buildChordExplorerUrl(explorerChordFor(root, familyId), tuning.id === 'e-standard' ? voicing.shape : undefined); + +// --------------------------------------------------------------------------- +// Empty-state relaxation hints +// --------------------------------------------------------------------------- + +export type RelaxationHint = + | { kind: 'calibration'; label: string; count: number; patch: Partial } + /** Interior mutes are not a calibration answer, so this one is a finder-only override. */ + | { kind: 'extraMute'; label: string; count: number }; + +const countFor = (query: FinderQuery, profile: HandProfile): number => findVoicings({ ...query, profile }).voicings.length; + +/** + * One-click ways out of an empty result, each tried alone against the same recipe: allow stretches, allow open + * strings, and one more muted inner string. Only options that produce shapes are returned. Barres are never + * suggested; the hand profile's no-barre rule stays as the player set it. + */ +export const relaxationHints = (query: FinderQuery, extraMuteActive = false): RelaxationHint[] => { + const { profile } = query; + const hints: RelaxationHint[] = []; + if (profile.stretchTolerance < STRETCH_TOLERANCE_ALLOW) { + const count = countFor(query, { ...profile, stretchTolerance: STRETCH_TOLERANCE_ALLOW }); + if (count > 0) hints.push({ kind: 'calibration', label: "With 'Allow stretches'", count, patch: { allowStretches: true } }); + } + if (!profile.allowOpenStrings) { + const count = countFor(query, { ...profile, allowOpenStrings: true }); + if (count > 0) hints.push({ kind: 'calibration', label: "With 'Allow open strings'", count, patch: { allowOpenStrings: true } }); + } + if (!extraMuteActive) { + const count = countFor(query, { ...profile, maxInteriorMutes: profile.maxInteriorMutes + 1 }); + if (count > 0) hints.push({ kind: 'extraMute', label: 'With one more muted inner string', count }); + } + return hints; +}; + +/** "With 'Allow stretches': 4 shapes"; a count at the generation limit is a lower bound ("48+ shapes"). */ +export const hintText = (hint: RelaxationHint): string => + `${hint.label}: ${hint.count}${hint.count >= FINDER_GENERATE_LIMIT ? '+' : ''} ${hint.count === 1 ? 'shape' : 'shapes'}`; diff --git a/utils/urlState.test.ts b/utils/urlState.test.ts new file mode 100644 index 0000000..49f7f7d --- /dev/null +++ b/utils/urlState.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { parseUrlState, syncUrlState } from './urlState'; +import { TuningMode, VibeMode } from '../types'; + +// Minimal window.location + history for the node test environment +const stubWindow = (search: string) => { + const location = { pathname: '/', search }; + const replaceState = vi.fn((_state: unknown, _title: string, url: string) => { + location.search = url.slice(url.indexOf('?')); + }); + vi.stubGlobal('window', { location, history: { replaceState } }); + return { location, replaceState }; +}; + +const queryOf = (search: string) => Object.fromEntries(new URLSearchParams(search)); + +beforeEach(() => { + vi.unstubAllGlobals(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('parseUrlState', () => { + it('returns nothing without a window', () => { + expect(parseUrlState()).toEqual({}); + }); + + it('parses root, tuning, vibe and the finder view', () => { + stubWindow('?root=e&tuning=drop&vibe=dark&view=finder'); + expect(parseUrlState()).toEqual({ root: 'E', tuning: TuningMode.DROP, vibe: VibeMode.DARK, view: 'finder' }); + }); + + it('accepts the rhythm view and ignores case', () => { + stubWindow('?view=Rhythm'); + expect(parseUrlState()).toEqual({ view: 'rhythm' }); + }); + + it('drops invalid values, including unknown views and an explicit library view', () => { + stubWindow('?root=H&tuning=open&vibe=happy&view=settings'); + expect(parseUrlState()).toEqual({}); + stubWindow('?view=library'); + expect(parseUrlState()).toEqual({}); + }); + + it('keeps the old behaviour without a view param', () => { + stubWindow('?root=C%23&tuning=standard&vibe=melodic'); + expect(parseUrlState()).toEqual({ root: 'C#', tuning: TuningMode.STANDARD, vibe: VibeMode.MELODIC }); + }); +}); + +describe('syncUrlState', () => { + it('writes root, tuning and vibe exactly as before and omits the library view', () => { + const { location, replaceState } = stubWindow(''); + syncUrlState('F#', TuningMode.DROP, VibeMode.ENERGETIC); + expect(replaceState).toHaveBeenCalledTimes(1); + expect(queryOf(location.search)).toEqual({ root: 'F#', tuning: 'drop', vibe: 'energetic' }); + }); + + it('writes view=finder for the finder', () => { + const { location } = stubWindow('?root=E&tuning=drop'); + syncUrlState('E', TuningMode.DROP, VibeMode.MELODIC, 'finder'); + expect(queryOf(location.search)).toEqual({ root: 'E', tuning: 'drop', vibe: 'melodic', view: 'finder' }); + }); + + it('removes the view param when switching back to the library', () => { + const { location } = stubWindow('?root=E&tuning=drop&view=finder'); + syncUrlState('E', TuningMode.DROP, VibeMode.MELODIC, 'library'); + expect(queryOf(location.search)).toEqual({ root: 'E', tuning: 'drop', vibe: 'melodic' }); + }); + + it('keeps unrelated params and round-trips through parseUrlState', () => { + const { location } = stubWindow('?utm_source=forum'); + syncUrlState('A#', TuningMode.STANDARD, VibeMode.DARK, 'finder'); + expect(queryOf(location.search).utm_source).toBe('forum'); + expect(parseUrlState()).toEqual({ root: 'A#', tuning: TuningMode.STANDARD, vibe: VibeMode.DARK, view: 'finder' }); + }); +}); diff --git a/utils/urlState.ts b/utils/urlState.ts index 40b719a..db1ec15 100644 --- a/utils/urlState.ts +++ b/utils/urlState.ts @@ -12,13 +12,19 @@ const VIBE_PARAMS: Record = { energetic: VibeMode.ENERGETIC }; +/** Top-level workbench views. 'library' is the default and never written to the URL. */ +export type AppView = 'library' | 'finder' | 'rhythm'; + +const VIEW_PARAMS: readonly AppView[] = ['finder', 'rhythm']; + export interface UrlState { root?: string; tuning?: TuningMode; vibe?: VibeMode; + view?: AppView; } -/** Parses ?root=C&tuning=drop&vibe=dark from the current URL. Invalid values are dropped. */ +/** Parses ?root=C&tuning=drop&vibe=dark&view=finder from the current URL. Invalid values are dropped. */ export const parseUrlState = (): UrlState => { if (typeof window === 'undefined') return {}; @@ -40,17 +46,27 @@ export const parseUrlState = (): UrlState => { state.vibe = VIBE_PARAMS[vibe]; } + const view = params.get('view')?.toLowerCase(); + if (view && (VIEW_PARAMS as readonly string[]).includes(view)) { + state.view = view as AppView; + } + return state; }; -/** Writes the current selection to the query string without a navigation. */ -export const syncUrlState = (root: string, tuning: TuningMode, vibe: VibeMode): void => { +/** Writes the current selection to the query string without a navigation. The library view omits `view`. */ +export const syncUrlState = (root: string, tuning: TuningMode, vibe: VibeMode, view: AppView = 'library'): void => { if (typeof window === 'undefined') return; const params = new URLSearchParams(window.location.search); params.set('root', root); params.set('tuning', tuning === TuningMode.DROP ? 'drop' : 'standard'); params.set('vibe', vibe.toLowerCase()); + if (view === 'library') { + params.delete('view'); + } else { + params.set('view', view); + } window.history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`); }; From 255411d58898238cb83038d47795d81f5d2789a5 Mon Sep 17 00:00:00 2001 From: Can Kilic Date: Sat, 3 Oct 2026 19:41:05 -0400 Subject: [PATCH 05/14] Add Metal Rhythm Lab with seeded patterns, playback and MIDI - Rhythm store persisted at riffforge:rhythm:v1 (params, seed, pattern, up to 4 chord slots, BPM 60-240, locked bars, metronome) with validation and safe fallback. - Rhythm Lab view: nine style presets, density and syncopation, 16th or 16th-triplet grid, 1/2/4 bars, alternate or downstroke picking, New idea, Mutate, per-bar Lock, play/stop with metronome and playhead, MIDI export. - Editable grid with a lane per target (pedal, chord slots, dead), bar lines, accent groups, counting, accent/PM/tie/pick glyphs with text, one tab stop with roving focus (arrows, Space, A, P, Delete) and spoken cell labels; scrolls inside its container on phones with 44 px cells. - Chord slots from the Voicing Finder ("Slot") or the RiffBar ("Use RiffBar chords as slots"); Optimize fingerings runs minimax optimizeSequence with the time before each chord change and shows the hardest transition. - Audio: playRhythm/stopRhythm on Tone.Part through the existing chain; palm mute shortens and softens notes, accents are louder, dead notes are short scratches. The RiffBar and the lab stop each other; RiffBar behaviour is otherwise unchanged. - MIDI: encodeMidiNotes and rhythmToMidi (velocities, palm-mute durations, time signature); the existing writer API and tests are unchanged. - Tempo note when a pattern is faster than the profile's comfortable 16th tempo (a warning, never a block). Co-Authored-By: Claude Opus 5.5 --- App.tsx | 15 +- components/RhythmLab.tsx | 246 ++++++++++ components/VoicingFinder.tsx | 44 +- components/rhythm/RhythmGrid.tsx | 452 ++++++++++++++++++ components/rhythm/RhythmSlotsStrip.tsx | 171 +++++++ components/rhythm/RhythmStylePanel.tsx | 177 ++++++++ components/rhythm/RhythmTransport.tsx | 128 ++++++ components/rhythm/theme.tsx | 88 ++++ services/audioEngine.ts | 149 ++++++ stores/rhythmStore.test.ts | 360 +++++++++++++++ stores/rhythmStore.ts | 448 ++++++++++++++++++ utils/midi.test.ts | 175 +++++++ utils/midi.ts | 74 +++ utils/rhythmSlots.test.ts | 377 +++++++++++++++ utils/rhythmSlots.ts | 604 +++++++++++++++++++++++++ 15 files changed, 3501 insertions(+), 7 deletions(-) create mode 100644 components/RhythmLab.tsx create mode 100644 components/rhythm/RhythmGrid.tsx create mode 100644 components/rhythm/RhythmSlotsStrip.tsx create mode 100644 components/rhythm/RhythmStylePanel.tsx create mode 100644 components/rhythm/RhythmTransport.tsx create mode 100644 components/rhythm/theme.tsx create mode 100644 stores/rhythmStore.test.ts create mode 100644 stores/rhythmStore.ts create mode 100644 utils/rhythmSlots.test.ts create mode 100644 utils/rhythmSlots.ts diff --git a/App.tsx b/App.tsx index 7df075f..cf49ce2 100644 --- a/App.tsx +++ b/App.tsx @@ -21,12 +21,12 @@ const RELATED_CHORDS_COUNT = 6; const loadVoicingFinder = () => import('./components/VoicingFinder'); const loadHandProfilePanel = () => import('./components/HandProfilePanel'); +const loadRhythmLab = () => import('./components/RhythmLab'); const VoicingFinder = lazy(() => loadVoicingFinder().then((m) => ({ default: m.VoicingFinder }))); const HandProfilePanel = lazy(() => loadHandProfilePanel().then((m) => ({ default: m.HandProfilePanel }))); +const RhythmLab = lazy(() => loadRhythmLab().then((m) => ({ default: m.RhythmLab }))); -// Rhythm Lab slot: the orchestrator flips this flag and renders the lazy RhythmLab in the 'rhythm' view below. -// While it is false the switch hides the option and ?view=rhythm falls back to the library. -const RHYTHM_LAB_ENABLED = false; +const RHYTHM_LAB_ENABLED = true; const initialView = (): AppView => { const view = parseUrlState().view ?? 'library'; @@ -48,7 +48,7 @@ const ViewSwitch: React.FC = ({ view, onChange, isDistorted, sh const options: { id: AppView; label: string; prefetch?: () => void }[] = [ { id: 'library', label: 'LIBRARY' }, { id: 'finder', label: 'VOICING FINDER', prefetch: () => void loadVoicingFinder() }, - ...(showRhythmLab ? [{ id: 'rhythm' as const, label: 'RHYTHM LAB' }] : []) + ...(showRhythmLab ? [{ id: 'rhythm' as const, label: 'RHYTHM LAB', prefetch: () => void loadRhythmLab() }] : []) ]; return ( @@ -511,8 +511,11 @@ const App: React.FC = () => { )} {view === 'rhythm' && RHYTHM_LAB_ENABLED && ( - // Rhythm Lab slot: }> goes here -
+
+ }> + + +
)} {view === 'library' && ( diff --git a/components/RhythmLab.tsx b/components/RhythmLab.tsx new file mode 100644 index 0000000..1e28499 --- /dev/null +++ b/components/RhythmLab.tsx @@ -0,0 +1,246 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { audioEngine } from '../services/audioEngine'; +import { useChordStore } from '../stores/chordStore'; +import { useRiffStore } from '../stores/riffStore'; +import { useRhythmHandProfile, useRhythmStore } from '../stores/rhythmStore'; +import { gridUnitTicks } from '../engine/rhythm/grid'; +import { rhythmToPlaybackEvents } from '../engine/rhythm/playback'; +import { validateRhythm } from '../engine/rhythm/validate'; +import type { HarmonySlot } from '../engine/types'; +import { tuningForMode } from '../utils/libraryVoicing'; +import { rhythmToMidi } from '../utils/midi'; +import { clickTimes, implicitPowerSlot, loopSeconds, secondsPerTick, toHarmonySlots } from '../utils/rhythmSlots'; +import { RhythmStylePanel } from './rhythm/RhythmStylePanel'; +import { RhythmTransport } from './rhythm/RhythmTransport'; +import { RhythmSlotsStrip } from './rhythm/RhythmSlotsStrip'; +import { RhythmGrid } from './rhythm/RhythmGrid'; +import { rhythmTheme } from './rhythm/theme'; + +/** Restart debounce while a slider is dragged during playback. */ +const RESTART_DELAY_MS = 60; + +/** + * Metal Rhythm Lab: seeded rhythm patterns over up to four chord slots, with playback, metronome, MIDI + * export, an editable grid and the fingering optimizer. Self-contained: reads the chord, riff and rhythm stores. + */ +export const RhythmLab: React.FC = () => { + const isDistorted = useChordStore((s) => s.isDistorted); + const tuningMode = useChordStore((s) => s.tuningMode); + const selectedRoot = useChordStore((s) => s.selectedRoot); + const riffStepCount = useRiffStore((s) => s.steps.length); + const profile = useRhythmHandProfile(); + + const pattern = useRhythmStore((s) => s.pattern); + const slots = useRhythmStore((s) => s.slots); + const bpm = useRhythmStore((s) => s.bpm); + const isPlaying = useRhythmStore((s) => s.isPlaying); + const metronomeOn = useRhythmStore((s) => s.metronomeOn); + const setBpm = useRhythmStore((s) => s.setBpm); + const setMetronome = useRhythmStore((s) => s.setMetronome); + const setIsPlaying = useRhythmStore((s) => s.setIsPlaying); + const newIdea = useRhythmStore((s) => s.newIdea); + const mutate = useRhythmStore((s) => s.mutate); + + const theme = rhythmTheme(isDistorted); + const tuning = tuningForMode(tuningMode); + const implicitSlot = useMemo(() => implicitPowerSlot(selectedRoot, tuning, profile), [selectedRoot, tuning, profile]); + const harmony: HarmonySlot[] = useMemo( + () => (slots.length > 0 ? toHarmonySlots(slots) : implicitSlot ? toHarmonySlots([implicitSlot]) : []), + [slots, implicitSlot] + ); + const slotLabels = useMemo(() => slots.map((s) => s.label), [slots]); + + const [announcement, setAnnouncement] = useState(''); + const announce = useCallback((message: string) => { + // Clearing first makes screen readers repeat an identical message + setAnnouncement(''); + window.setTimeout(() => setAnnouncement(message), 30); + }, []); + + // Slots follow the app tuning (Standard / Drop): re-voice the ones made for the other tuning + useEffect(() => { + const dropped = useRhythmStore.getState().retuneSlots(tuning, profile); + if (dropped > 0) announce(`${dropped} slot${dropped === 1 ? '' : 's'} had no playable voicing in ${tuning.name} and were removed.`); + }, [tuning, profile, announce]); + + const latest = useRef({ harmony, tuning }); + latest.current = { harmony, tuning }; + + const play = useCallback(() => { + const state = useRhythmStore.getState(); + const { pattern: current, bpm: tempo } = state; + const events = rhythmToPlaybackEvents(current, latest.current.harmony, latest.current.tuning, tempo); + audioEngine.playRhythm(events, { + loopSeconds: loopSeconds(current, tempo), + bpm: tempo, + clickTimes: clickTimes(current, tempo), + metronome: state.metronomeOn, + stepSeconds: gridUnitTicks(current.params.grid) * secondsPerTick(tempo), + onStep: (unit) => useRhythmStore.getState().setPlayheadUnit(unit) + }); + }, []); + + const startPlayback = useCallback(async () => { + const chord = useChordStore.getState(); + if (!chord.isAudioReady || !audioEngine.isReady()) { + await audioEngine.init(); + audioEngine.setDistortion(chord.isDistorted); + chord.setIsAudioReady(true); + } + // The RiffBar shares the Transport: stop it and reset its state before the rhythm takes over + const riff = useRiffStore.getState(); + if (riff.isPlaying || audioEngine.isSequencePlaying()) { + audioEngine.stopSequence(); + riff.setIsPlaying(false); + riff.setCurrentStep(-1); + } + play(); + setIsPlaying(true); + }, [play, setIsPlaying]); + + const stopPlayback = useCallback(() => { + audioEngine.stopRhythm(); + setIsPlaying(false); + }, [setIsPlaying]); + + // The RiffBar (playSequence) stops the rhythm through the engine: mirror it in the store + useEffect( + () => + audioEngine.onRhythmStopped(() => { + useRhythmStore.getState().setIsPlaying(false); + }), + [] + ); + + // Restart cleanly (same loop position) when the pattern, slots, tuning or tempo change mid-playback + useEffect(() => { + if (!useRhythmStore.getState().isPlaying) return; + const timer = window.setTimeout(play, RESTART_DELAY_MS); + return () => window.clearTimeout(timer); + }, [pattern, harmony, tuning, bpm, play]); + + useEffect( + () => () => { + if (audioEngine.isRhythmPlaying()) audioEngine.stopRhythm(); + useRhythmStore.getState().setIsPlaying(false); + }, + [] + ); + + const handleMetronome = () => { + const next = !metronomeOn; + setMetronome(next); + audioEngine.setRhythmMetronome(next); + }; + + const handleNewIdea = () => { + newIdea(); + const { pattern: next, lockedBars } = useRhythmStore.getState(); + announce(`New idea: ${next.name}${lockedBars.length > 0 ? `, locked bars kept: ${lockedBars.map((b) => b + 1).join(', ')}` : ''}.`); + }; + + const handleMutate = () => { + const changed = mutate(); + announce(changed ? 'Mutated: one small change outside the locked bars.' : 'No mutation found outside the locked bars.'); + }; + + const handleExportMidi = () => { + const { pattern: current, bpm: tempo } = useRhythmStore.getState(); + const bytes = rhythmToMidi(current, harmony, tuning, tempo); + const blob = new Blob([bytes], { type: 'audio/midi' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `rhythm-${current.params.style}-${tempo}bpm.mid`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + announce(`Exported ${anchor.download}.`); + }; + + const handleUseRiffChords = () => { + const made = useRhythmStore.getState().useRiffBarChords(tuning, profile); + announce(made > 0 ? `${made} RiffBar chord${made === 1 ? '' : 's'} loaded as slots.` : 'No playable RiffBar chords to load.'); + }; + + const warnings = useMemo( + () => validateRhythm(pattern, { bpm, comfortableSixteenthBpm: profile.comfortableSixteenthBpm }).warnings, + [pattern, bpm, profile.comfortableSixteenthBpm] + ); + + const meter = `${pattern.meter.numerator}/${pattern.meter.denominator}`; + + return ( +
+
+
+

+ Rhythm Lab +

+

+ {pattern.name} // {meter} // {pattern.bars} bar{pattern.bars === 1 ? '' : 's'} // {tuning.name} +

+
+ {pattern.tags.length > 0 && ( +
    + {pattern.tags.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ )} +
+ +
+ + + + + + + + +
+ {warnings.map((w) => ( +

+ Tempo note + {w.message} Comfortable up to about {w.bpmLimit} BPM for this pattern. This is comfort guidance, not a + medical or safety assessment. Stop if you feel pain or tension. +

+ ))} +
+

+ {announcement} +

+
+
+ ); +}; diff --git a/components/VoicingFinder.tsx b/components/VoicingFinder.tsx index f9683de..5c84239 100644 --- a/components/VoicingFinder.tsx +++ b/components/VoicingFinder.tsx @@ -8,6 +8,7 @@ import { audioEngine } from '../services/audioEngine'; import { useChordStore } from '../stores/chordStore'; import { useHandProfileStore } from '../stores/handProfileStore'; import { useRiffStore, MAX_RIFF_STEPS } from '../stores/riffStore'; +import { useRhythmStore, MAX_RHYTHM_SLOTS } from '../stores/rhythmStore'; import { tuningForMode } from '../utils/libraryVoicing'; import { VoicingTab } from './VoicingTab'; import { @@ -122,10 +123,12 @@ interface FinderCardProps { profile: HandProfile; showNovelty: boolean; canAdd: boolean; + canSlot: boolean; isDistorted: boolean; reduceMotion: boolean; onPlay: (voicing: GeneratedVoicing) => void; onAdd: (voicing: GeneratedVoicing) => void; + onSlot: (voicing: GeneratedVoicing) => void; } const FinderCard: React.FC = ({ @@ -137,10 +140,12 @@ const FinderCard: React.FC = ({ profile, showNovelty, canAdd, + canSlot, isDistorted, reduceMotion, onPlay, - onAdd + onAdd, + onSlot }) => { const tab = toRiffForgeTab(voicing.shape); const tags = voicingTagLabels(voicing.tags); @@ -220,6 +225,21 @@ const FinderCard: React.FC = ({ Riff + = ({ onOpenHandProfile, const setCalibration = useHandProfileStore((s) => s.setCalibration); const riffLength = useRiffStore((s) => s.steps.length); const addStep = useRiffStore((s) => s.addStep); + const slotCount = useRhythmStore((s) => s.slots.length); + const addSlot = useRhythmStore((s) => s.addSlot); const reduceMotion = useReducedMotion() ?? false; const titleId = useId(); @@ -323,6 +345,24 @@ export const VoicingFinder: React.FC = ({ onOpenHandProfile, [addStep, family, tuning] ); + const slotVoicing = useCallback( + (voicing: GeneratedVoicing) => { + const added = addSlot({ + label: voicing.name, + root: selectedRoot, + source: { kind: 'family', familyId: voicing.familyId }, + shape: [...voicing.shape], + tuningId: tuning.id + }); + setActionMessage( + added + ? `${voicing.name} is now Rhythm Lab slot ${useRhythmStore.getState().slots.length}.` + : `The Rhythm Lab already has ${MAX_RHYTHM_SLOTS} chord slots.` + ); + }, + [addSlot, selectedRoot, tuning] + ); + const applyHint = (hint: RelaxationHint) => { if (hint.kind === 'calibration') setCalibration(hint.patch); else setExtraMuteFor(overrideKey); @@ -473,10 +513,12 @@ export const VoicingFinder: React.FC = ({ onOpenHandProfile, profile={effectiveProfile} showNovelty={sort === 'unusual'} canAdd={canAdd} + canSlot={slotCount < MAX_RHYTHM_SLOTS} isDistorted={isDistorted} reduceMotion={reduceMotion} onPlay={playVoicing} onAdd={addVoicing} + onSlot={slotVoicing} /> ))} diff --git a/components/rhythm/RhythmGrid.tsx b/components/rhythm/RhythmGrid.tsx new file mode 100644 index 0000000..159a1db --- /dev/null +++ b/components/rhythm/RhythmGrid.tsx @@ -0,0 +1,452 @@ +import React, { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useReducedMotion } from 'framer-motion'; +import type { RhythmPattern } from '../../engine/types'; +import { barUnits, buildGroups, cycleGrouping, isPolymeter } from '../../engine/rhythm/grid'; +import { useRhythmStore } from '../../stores/rhythmStore'; +import { + describeCell, + gridColumns, + laneIdOf, + rhythmLanes, + toggleTargetFor, + unitPosition, + type GridColumn, + type RhythmLane, + type UnitPosition +} from '../../utils/rhythmSlots'; +import { SECTION_LABEL, SEGMENT_GROUP, segmentClass, type RhythmTheme } from './theme'; + +type EditKind = 'hit' | 'accent' | 'palmMute' | 'clear'; + +const TOOLS: { id: EditKind; label: string; key: string }[] = [ + { id: 'hit', label: 'Hit', key: 'Space' }, + { id: 'accent', label: 'Accent', key: 'A' }, + { id: 'palmMute', label: 'PM', key: 'P' }, + { id: 'clear', label: 'Clear', key: 'Del' } +]; + +const TOOL_NAMES: Record = { hit: 'Toggle hit', accent: 'Cycle accent', palmMute: 'Toggle palm mute', clear: 'Clear cell' }; + +interface RhythmGridProps { + theme: RhythmTheme; + slotLabels: readonly string[]; + onAnnounce: (message: string) => void; +} + +const GLYPH: Record = { pedal: '●', slot: '■', dyad: 'dy', dead: 'x' }; + +interface CellProps { + laneIndex: number; + lane: RhythmLane; + column: GridColumn; + position: UnitPosition; + groupStart: boolean; + locked: boolean; + focused: boolean; + label: string; + theme: RhythmTheme; + register: (key: string, el: HTMLDivElement | null) => void; + onActivate: (laneIndex: number, unit: number) => void; + onFocusCell: (laneIndex: number, unit: number) => void; +} + +const Cell: React.FC = memo( + ({ laneIndex, lane, column, position, groupStart, locked, focused, label, theme, register, onActivate, onFocusCell }) => { + const own = column.lane === lane.id ? column.events.filter((e) => laneIdOf(e.target) === lane.id) : []; + const first = own[0]; + const held = !first && column.heldLane === lane.id; + const attack = first && !first.tie; + const kind = first?.target.kind; + const separator = position.isBarStart + ? 'border-l-2 border-l-white/40' + : position.isBeatStart + ? 'border-l border-l-white/20' + : 'border-l border-l-white/[0.05]'; + const hitStyle = !first + ? '' + : first.tie + ? `border border-dashed ${theme.border}` + : kind === 'pedal' + ? 'bg-neutral-700/60 border border-neutral-400/50' + : kind === 'dead' + ? 'border border-dashed border-neutral-400/70' + : `border ${theme.hitChord}`; + + return ( +
register(`${laneIndex}:${column.unit}`, el)} + role="gridcell" + tabIndex={focused ? 0 : -1} + aria-label={label} + onClick={() => onActivate(laneIndex, column.unit)} + onFocus={() => onFocusCell(laneIndex, column.unit)} + className={`relative shrink-0 w-[var(--cell)] h-11 md:h-10 cursor-pointer select-none ${separator} ${ + locked ? theme.lockedCell : '' + } hover:bg-white/[0.04] ${theme.ringInset}`} + > + {groupStart && !position.isBarStart && ( +
+ ); + } +); +Cell.displayName = 'RhythmGridCell'; + +/** Playhead column highlight; subscribes on its own so the grid does not re-render on every step. */ +const Playhead: React.FC<{ theme: RhythmTheme; reduceMotion: boolean }> = ({ theme, reduceMotion }) => { + const unit = useRhythmStore((s) => s.playheadUnit); + const isPlaying = useRhythmStore((s) => s.isPlaying); + const previous = useRef(-1); + const wrapped = unit < previous.current; + useEffect(() => { + previous.current = unit; + }, [unit]); + if (!isPlaying || unit < 0) return null; + return ( + @@ -509,7 +484,7 @@ export const ChordCard: React.FC = ({ )} - + ); }; diff --git a/components/DistortionSwitch.tsx b/components/DistortionSwitch.tsx index a06c3b8..eceedb8 100644 --- a/components/DistortionSwitch.tsx +++ b/components/DistortionSwitch.tsx @@ -90,9 +90,13 @@ const ChannelButton: React.FC = ({ return ( Date: Sat, 3 Oct 2026 20:36:17 -0400 Subject: [PATCH 10/14] Say "hand profile" on cards with no playable shape The hand profile is editable now, so an empty card names the profile rather than the player's hand. Co-Authored-By: Claude Opus 5.5 --- components/ChordCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ChordCard.tsx b/components/ChordCard.tsx index 06bbe3c..6efc56e 100644 --- a/components/ChordCard.tsx +++ b/components/ChordCard.tsx @@ -478,7 +478,7 @@ export const ChordCard: React.FC = ({ ) : (

- No playable shape for your hand here.{' '} + No playable shape for your hand profile here.{' '} {chord.unplayableReason}

)} From f857fc9f6d02cb42f2aba529121d6aa9b8ab1f12 Mon Sep 17 00:00:00 2001 From: Can Kilic Date: Sat, 3 Oct 2026 20:36:17 -0400 Subject: [PATCH 11/14] Use the sound-based Chord Explorer type rule in the Voicing Finder The finder kept its own table that sent power chords as Major, the tritone as dim and fourths or quartal stacks as sus4. It now uses the same rule as library cards: a type only where Chord Explorer has one, none for power chords, dyads, the tritone, fourths, quartal stacks and clusters. Co-Authored-By: Claude Opus 5.5 --- utils/finder.test.ts | 30 +++++++++++------------- utils/finder.ts | 56 ++++---------------------------------------- 2 files changed, 19 insertions(+), 67 deletions(-) diff --git a/utils/finder.test.ts b/utils/finder.test.ts index 4df5a14..8969ca8 100644 --- a/utils/finder.test.ts +++ b/utils/finder.test.ts @@ -3,8 +3,6 @@ import { FINDER_GENERATE_LIMIT, createLruCache, explainVoicing, - explorerChordFor, - explorerTypeForFamily, filterVoicings, finderCacheKey, finderExplorerUrl, @@ -16,10 +14,10 @@ import { voicingTagLabels, type FinderQuery } from './finder'; -import { mapToExplorerType } from './chordExplorer'; +import { explorerTypeForFamily } from './chordExplorer'; import { DEFAULT_CALIBRATION, DEFAULT_HAND_PROFILE, profileFromCalibration } from '../engine/handProfile'; import { DROP_D, E_STANDARD } from '../engine/tuning'; -import { ALL_FAMILIES, getFamily } from '../engine/voicingSpec'; +import { getFamily } from '../engine/voicingSpec'; import { toRiffForgeTab } from '../engine/shape'; import type { GeneratedVoicing, VoicingTag } from '../engine/types'; @@ -158,21 +156,21 @@ describe('display helpers', () => { }); describe('Chord Explorer mapping', () => { - it('maps every family to a type that survives the URL builder', () => { - for (const family of ALL_FAMILIES) { - const chord = explorerChordFor('F#', family.id); - expect(mapToExplorerType(chord.name, chord.subtext)).toBe(explorerTypeForFamily(family.id)); - } - }); - - it('picks the closest Chord Explorer type', () => { - expect(explorerTypeForFamily('power5')).toBe('major'); + it('maps families to Chord Explorer types and omits the type where Chord Explorer has none', () => { expect(explorerTypeForFamily('m_add9')).toBe('minor'); expect(explorerTypeForFamily('sus2')).toBe('sus2'); - expect(explorerTypeForFamily('fourth')).toBe('sus4'); - expect(explorerTypeForFamily('tritone')).toBe('dim'); - expect(explorerTypeForFamily('drone_quartal')).toBe('sus4'); expect(explorerTypeForFamily('m7')).toBe('m7'); + expect(explorerTypeForFamily('drone_sus2')).toBe('sus2'); + for (const id of ['power5', 'power5_b2', 'dyad_b2', 'tritone', 'fourth', 'quartal', 'cluster_m2', 'cluster_b3', 'drone_power5']) { + expect(explorerTypeForFamily(id)).toBeUndefined(); + } + }); + + it('builds power chord links without a type', () => { + const power = findVoicings(query({ tuning: E_STANDARD, familyId: 'power5' })).voicings[0]; + const url = finderExplorerUrl('E', 'power5', power, E_STANDARD); + expect(url).toMatch(/^https:\/\/chords\.thegridbase\.com\/\?root=E&gv=/); + expect(url).not.toContain('type='); }); it('sends the shape only in E Standard', () => { diff --git a/utils/finder.ts b/utils/finder.ts index d98913e..4d5bef6 100644 --- a/utils/finder.ts +++ b/utils/finder.ts @@ -13,7 +13,7 @@ import type { import { generateVoicings } from '../engine/generateVoicings'; import { STRETCH_TOLERANCE_ALLOW, handProfileHash } from '../engine/handProfile'; import { explainCost } from '../engine/playability'; -import { buildChordExplorerUrl, type ChordExplorerType } from './chordExplorer'; +import { buildExplorerUrl, explorerTypeForFamily } from './chordExplorer'; export const FINDER_DEFAULT_FAMILY = 'power5'; export const FINDER_GENERATE_LIMIT = 48; @@ -147,58 +147,12 @@ export const noveltyPercent = (novelty: number): number => Math.round(Math.min(1 // --------------------------------------------------------------------------- /** - * Closest Chord Explorer type per base family (CE only knows triads, 7ths and sus chords). Dyads and power chords - * land on the triad that contains their notes; the fourth and quartal stacks on sus4; the b3 cluster on minor. + * Chord Explorer deep link for a finder result: the selected root, the family's Chord Explorer type when it has + * one (no type for power chords, dyads, the tritone, fourths, quartal stacks and clusters, which Chord Explorer + * cannot show), and the exact shape only in E Standard, the only tuning Chord Explorer knows. */ -const EXPLORER_TYPE_BY_FAMILY: Readonly> = { - power5: 'major', - power5_b2: 'major', - dyad_b2: 'major', - tritone: 'dim', - fourth: 'sus4', - sus2: 'sus2', - sus4: 'sus4', - add9: 'major', - m_add9: 'minor', - '7': '7', - m7: 'm7', - maj7: 'maj7', - quartal: 'sus4', - cluster_m2: 'major', - cluster_b3: 'minor', - phrygian: 'major', - major: 'major', - minor: 'minor', - dim: 'dim', - aug: 'aug' -}; - -/** Chord-name suffixes that utils/chordExplorer maps back to each type. */ -const EXPLORER_SUFFIX: Readonly> = { - major: '', - minor: 'm', - dim: 'dim', - aug: 'aug', - '7': '7', - m7: 'm7', - maj7: 'maj7', - dim7: 'dim7', - sus2: 'sus2', - sus4: 'sus4' -}; - -export const explorerTypeForFamily = (familyId: string): ChordExplorerType => - EXPLORER_TYPE_BY_FAMILY[familyId.replace(/^drone_/, '')] ?? 'major'; - -/** A name/subtext pair that buildChordExplorerUrl maps to the family's closest Chord Explorer type. */ -export const explorerChordFor = (root: string, familyId: string): { name: string; subtext: string } => { - const name = `${root}${EXPLORER_SUFFIX[explorerTypeForFamily(familyId)]}`; - return { name, subtext: name }; -}; - -/** Chord Explorer deep link; the exact shape only travels in E Standard, the only tuning Chord Explorer knows. */ export const finderExplorerUrl = (root: string, familyId: string, voicing: GeneratedVoicing, tuning: Tuning): string => - buildChordExplorerUrl(explorerChordFor(root, familyId), tuning.id === 'e-standard' ? voicing.shape : undefined); + buildExplorerUrl({ root, type: explorerTypeForFamily(familyId) }, tuning.id === 'e-standard' ? voicing.shape : undefined); // --------------------------------------------------------------------------- // Empty-state relaxation hints From f1332602aff12cc2cef77f4f3a3bf5b0be93f350 Mon Sep 17 00:00:00 2001 From: Can Kilic Date: Sat, 3 Oct 2026 20:36:17 -0400 Subject: [PATCH 12/14] Raise voicing tab text to 11px and muted string contrast Row labels, string names and degrees were 9-10px; they are now 11px. Muted "x" cells use neutral-400 so they reach 4.5:1 on the card background. Co-Authored-By: Claude Opus 5.5 --- components/VoicingTab.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/VoicingTab.tsx b/components/VoicingTab.tsx index 81d254c..6c40479 100644 --- a/components/VoicingTab.tsx +++ b/components/VoicingTab.tsx @@ -35,7 +35,7 @@ export const VoicingTab: React.FC = ({ shape, fingers, degreesB const accent = isDistorted ? 'text-rose-500' : 'text-cyan-500'; const glow = isDistorted ? '0 0 8px rgba(225, 29, 72, 0.6)' : '0 0 8px rgba(8, 145, 178, 0.6)'; const cell = compact ? 'w-6' : 'w-7'; - const rowLabel = 'w-10 shrink-0 text-left font-mono text-[9px] uppercase tracking-widest text-neutral-400'; + const rowLabel = 'w-11 shrink-0 text-left font-mono text-[11px] uppercase tracking-wider text-neutral-400'; return (
@@ -44,7 +44,7 @@ export const VoicingTab: React.FC = ({ shape, fingers, degreesB
Str {shape.map((_, s) => ( - + {pitchClassName(tuning.openMidi[s])} ))} @@ -54,7 +54,7 @@ export const VoicingTab: React.FC = ({ shape, fingers, degreesB {shape.map((fret, s) => ( {fret === null ? 'x' : fret} @@ -72,7 +72,7 @@ export const VoicingTab: React.FC = ({ shape, fingers, degreesB
Deg {degreesByString.map((degree, s) => ( - + {degree ?? ''} ))} From 6ed27b97171b397529fc1734798f8f2e1a62fc60 Mon Sep 17 00:00:00 2001 From: Can Kilic Date: Sat, 3 Oct 2026 20:40:54 -0400 Subject: [PATCH 13/14] Keep chord symbols in their own case in the riff bar and related header Card titles are now sounded chord symbols, and CSS uppercase turned "Gm(add9)" into "GM(ADD9)" (read as G major) in the RiffBar chips and the Related Variations header. Only the chord name keeps its case; the RiffBar layout and the rest of its styling are unchanged. The decorative letters behind each card are hidden from screen readers. Co-Authored-By: Claude Opus 5.5 --- App.tsx | 2 +- components/ChordCard.tsx | 1 + components/RiffBar.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/App.tsx b/App.tsx index 025edc3..414d725 100644 --- a/App.tsx +++ b/App.tsx @@ -656,7 +656,7 @@ const App: React.FC = () => {
- + {lockedChord.name} diff --git a/components/ChordCard.tsx b/components/ChordCard.tsx index 6efc56e..fef7085 100644 --- a/components/ChordCard.tsx +++ b/components/ChordCard.tsx @@ -147,6 +147,7 @@ export const ChordCard: React.FC = ({ {/* Background Text Faded - First 2 letters of chord name */} - - + {explorerUrl && ( + + + + )}
); diff --git a/stores/rhythmStore.test.ts b/stores/rhythmStore.test.ts index 590cca3..4935a69 100644 --- a/stores/rhythmStore.test.ts +++ b/stores/rhythmStore.test.ts @@ -270,6 +270,51 @@ describe('rhythmStore slots', () => { expect(store.getState().pattern.events.map((e) => [e.tick, e.durationTicks, e.accent, e.palmMute])).toEqual(ticks); }); + it('keeps a hand edit in an unlocked bar when a slot is added', async () => { + const store = await freshStore(); + store.getState().addSlot(E5); + const before = store.getState().pattern.events; + store.getState().toggleHitAt(3, { kind: 'dead' }); + const edited = store.getState().pattern; + expect(edited.events).not.toEqual(before); + expect(edited.events.some((e) => e.target.kind === 'dead')).toBe(true); + expect(store.getState().lockedBars).toEqual([]); + + expect(store.getState().addSlot(G5)).toBe(true); + const after = store.getState().pattern; + expect(store.getState().params.slotCount).toBe(2); + expect(after.params.slotCount).toBe(2); + expect(after.events).toEqual(edited.events); + expect(validateRhythm(after).ok).toBe(true); + }); + + it('keeps a hand edit and folds slots into range when a slot is removed', async () => { + const store = await freshStore(); + store.getState().addSlot(E5); + store.getState().addSlot(G5); + store.getState().addSlot(A5); + store.getState().toggleHitAt(3, { kind: 'dead' }); + const deadTicks = store.getState().pattern.events.filter((e) => e.target.kind === 'dead').map((e) => e.tick); + expect(deadTicks.length).toBeGreaterThan(0); + + store.getState().removeSlot(store.getState().slots[2].id); + const after = store.getState().pattern; + expect(after.params.slotCount).toBe(2); + expect(after.events.filter((e) => e.target.kind === 'dead').map((e) => e.tick)).toEqual(deadTicks); + expect(Math.max(...slotsUsed(after))).toBeLessThan(2); + expect(validateRhythm(after).ok).toBe(true); + }); + + it('keeps a mutated pattern when the slot count changes', async () => { + const store = await freshStore(); + store.getState().addSlot(E5); + store.getState().addSlot(G5); + expect(store.getState().mutate()).toBe(true); + const mutated = store.getState().pattern.events; + store.getState().addSlot(A5); + expect(store.getState().pattern.events).toEqual(mutated); + }); + it('rejects slots that do not fit their tuning', async () => { const store = await freshStore(); expect(store.getState().addSlot({ ...E5, shape: [0, 2, 2] })).toBe(false); @@ -358,3 +403,33 @@ describe('rhythmStore style and params', () => { expect(store.getState().params.bars).toBe(4); }); }); + +describe('scheduleRhythmRestart', () => { + it('restarts while playing, but never after Stop or cancel', async () => { + vi.useFakeTimers(); + try { + vi.resetModules(); + const { scheduleRhythmRestart, useRhythmStore } = await import('./rhythmStore'); + const restart = vi.fn(); + + useRhythmStore.getState().setIsPlaying(true); + scheduleRhythmRestart(restart, 60); + vi.advanceTimersByTime(60); + expect(restart).toHaveBeenCalledTimes(1); + + scheduleRhythmRestart(restart, 60); + vi.advanceTimersByTime(30); + useRhythmStore.getState().setIsPlaying(false); + vi.advanceTimersByTime(30); + expect(restart).toHaveBeenCalledTimes(1); + + useRhythmStore.getState().setIsPlaying(true); + const cancel = scheduleRhythmRestart(restart, 60); + cancel(); + vi.advanceTimersByTime(60); + expect(restart).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/stores/rhythmStore.ts b/stores/rhythmStore.ts index de2cec9..501d922 100644 --- a/stores/rhythmStore.ts +++ b/stores/rhythmStore.ts @@ -4,7 +4,7 @@ import { generateRhythm, sanitizeRhythmParams } from '../engine/rhythm/generate' import { RHYTHM_STYLE_IDS, defaultRhythmParams } from '../engine/rhythm/styles'; import { mutateRhythm, regenerateRhythm } from '../engine/rhythm/variation'; import { validateRhythm } from '../engine/rhythm/validate'; -import { clearHit, cycleAccent, togglePalmMute, toggleHit } from '../engine/rhythm/edit'; +import { clearHit, cycleAccent, setSlotCount, togglePalmMute, toggleHit } from '../engine/rhythm/edit'; import { barTicks } from '../engine/rhythm/grid'; import { getTuning } from '../engine/tuning'; import { isValidShape } from '../engine/shape'; @@ -151,6 +151,22 @@ const eventsInBars = (pattern: RhythmPattern, bars: readonly number[]): string = return JSON.stringify(pattern.events.filter((e) => bars.includes(Math.floor(e.tick / bar)))); }; +/** + * True when an unlocked bar no longer matches what params + seed generate, i.e. the player edited or mutated it. + * Locked bars are the player's by definition and are kept by regenerateKeepingLocks anyway. + */ +export const hasUnlockedChanges = ( + pattern: RhythmPattern, + params: RhythmParams, + seed: string, + lockedBars: readonly number[] +): boolean => { + const fresh = generateRhythm(params, seed); + if (fresh.bars !== pattern.bars || !sameMeter(fresh.meter, pattern.meter) || fresh.params.grid !== pattern.params.grid) return true; + const unlocked = Array.from({ length: pattern.bars }, (_, bar) => bar).filter((bar) => !lockedBars.includes(bar)); + return eventsInBars(pattern, unlocked) !== eventsInBars(fresh, unlocked); +}; + const MUTATE_ATTEMPTS = 8; /** mutateRhythm with seeds derived from the pattern seed; a result that touches a locked bar is retried. */ @@ -305,12 +321,19 @@ const initial = loadRhythm(); let mutationRound = 0; export const useRhythmStore = create((set, get) => { - /** New slots: keeps params.slotCount in sync and regenerates (same seed, locks kept) when the count changes. */ + /** + * New slots: keeps params.slotCount in sync when the count changes. An untouched pattern is regenerated (same + * seed, locks kept) so every slot gets its chord changes; edited or mutated events are kept, with slot targets + * folded into range. + */ const withSlots = (slots: RhythmSlot[]): Partial => { const state = get(); const count = slotCountFor(slots); if (count === state.params.slotCount) return { slots, optimization: null }; const params = { ...state.params, slotCount: count }; + if (hasUnlockedChanges(state.pattern, state.params, state.seed, state.lockedBars)) { + return { slots, params, pattern: setSlotCount(state.pattern, count), optimization: null }; + } const { pattern, lockedBars } = regenerateKeepingLocks(state.pattern, params, state.seed, state.lockedBars); return { slots, params, pattern, lockedBars, optimization: null }; }; @@ -446,3 +469,14 @@ export const useRhythmStore = create((set, get) => { useRhythmStore.subscribe((state, prev) => { if (PERSISTED_KEYS.some((key) => state[key] !== prev[key])) saveRhythm(state); }); + +/** + * Restarts playback after `delayMs` (pattern, slot, tuning or tempo changes mid-playback), but only while the lab + * is still playing when the timer fires, so a Stop pressed in between is never undone. Returns a cancel function. + */ +export const scheduleRhythmRestart = (restart: () => void, delayMs: number): (() => void) => { + const timer = setTimeout(() => { + if (useRhythmStore.getState().isPlaying) restart(); + }, delayMs); + return () => clearTimeout(timer); +}; diff --git a/types.ts b/types.ts index b7c78e5..b26c36a 100644 --- a/types.ts +++ b/types.ts @@ -35,6 +35,8 @@ export interface Chord { voicing?: LibraryVoicing; /** Present on resolved cards without a playable shape for the current hand profile. */ unplayableReason?: string; + /** Chord Explorer target for a card without a playable shape, from its curated notes. */ + explorer?: { root: string; type?: ChordExplorerType }; } export enum TuningMode { diff --git a/utils/chordExplorer.test.ts b/utils/chordExplorer.test.ts index 3648ca4..ecd7caa 100644 --- a/utils/chordExplorer.test.ts +++ b/utils/chordExplorer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseRoot, mapToExplorerType, buildChordExplorerUrl, shapeToGvParam } from './chordExplorer'; +import { parseRoot, mapToExplorerType, buildChordExplorerUrl, buildExplorerUrl, explorerTypeForIntervals, shapeToGvParam } from './chordExplorer'; describe('parseRoot', () => { it('extracts natural and sharp roots from the name', () => { @@ -110,3 +110,26 @@ describe('parseRoot fallback', () => { expect(parseRoot('Ebm7', '')).toBe('D#'); }); }); + +describe('buildExplorerUrl', () => { + it('keeps links for sounds Chord Explorer has a type for', () => { + expect(buildExplorerUrl({ root: 'E', type: 'major' })).toBe('https://chords.thegridbase.com/?root=E&type=Major'); + expect(buildExplorerUrl({ root: 'A', type: 'minor' }, [null, 0, 2, 2, 1, 0])).toBe( + 'https://chords.thegridbase.com/?root=A&type=minor&gv=x-0-2-2-1-0' + ); + expect(buildExplorerUrl({ root: 'C#', type: 'm7' })).toBe('https://chords.thegridbase.com/?root=C%23&type=m7'); + }); + + it('builds no link without a type, so Chord Explorer never shows E5 as its default minor', () => { + expect(buildExplorerUrl({ root: 'E' }, [0, 2, 2, null, null, null])).toBeNull(); + expect(buildExplorerUrl({ root: 'E', type: undefined })).toBeNull(); + }); + + it('types power chords, dyads, quartal stacks and clusters as nothing', () => { + expect(explorerTypeForIntervals([0, 7, 0])).toBeUndefined(); + expect(explorerTypeForIntervals([0, 1])).toBeUndefined(); + expect(explorerTypeForIntervals([0, 5, 10])).toBeUndefined(); + expect(explorerTypeForIntervals([0, 1, 2])).toBeUndefined(); + expect(explorerTypeForIntervals([0, 3, 7, 0])).toBe('minor'); + }); +}); diff --git a/utils/chordExplorer.ts b/utils/chordExplorer.ts index f4c93e3..d0ffe72 100644 --- a/utils/chordExplorer.ts +++ b/utils/chordExplorer.ts @@ -149,10 +149,14 @@ export const explorerTypeForIntervals = (intervals: readonly number[]): ChordExp return undefined; }; -/** Chord Explorer URL for a target; the exact shape travels as gv (callers send it only in E Standard). */ -export const buildExplorerUrl = (target: ExplorerTarget, shape?: readonly (number | null)[]): string => { - const params = [`root=${encodeURIComponent(target.root)}`]; - if (target.type) params.push(`type=${encodeURIComponent(TYPE_PARAM[target.type])}`); +/** + * Chord Explorer URL for a target; the exact shape travels as gv (callers send it only in E Standard). Null when + * Chord Explorer has no type for the sound (power chords, dyads, quartal stacks, clusters): a link without a type + * would open its default chord, a label the shape does not sound. + */ +export const buildExplorerUrl = (target: ExplorerTarget, shape?: readonly (number | null)[]): string | null => { + if (!target.type) return null; + const params = [`root=${encodeURIComponent(target.root)}`, `type=${encodeURIComponent(TYPE_PARAM[target.type])}`]; if (shape) params.push(`gv=${shapeToGvParam(shape)}`); return `${CHORD_EXPLORER_BASE_URL}?${params.join('&')}`; }; diff --git a/utils/finder.test.ts b/utils/finder.test.ts index 8969ca8..3968fec 100644 --- a/utils/finder.test.ts +++ b/utils/finder.test.ts @@ -166,11 +166,12 @@ describe('Chord Explorer mapping', () => { } }); - it('builds power chord links without a type', () => { - const power = findVoicings(query({ tuning: E_STANDARD, familyId: 'power5' })).voicings[0]; - const url = finderExplorerUrl('E', 'power5', power, E_STANDARD); - expect(url).toMatch(/^https:\/\/chords\.thegridbase\.com\/\?root=E&gv=/); - expect(url).not.toContain('type='); + it('gives no link for families Chord Explorer has no type for, rather than a typeless one it would open as minor', () => { + for (const familyId of ['power5', 'dyad_b2', 'tritone', 'quartal', 'cluster_m2']) { + const voicing = findVoicings(query({ tuning: E_STANDARD, familyId })).voicings[0]; + expect(voicing, familyId).toBeDefined(); + expect(finderExplorerUrl('E', familyId, voicing, E_STANDARD), familyId).toBeNull(); + } }); it('sends the shape only in E Standard', () => { diff --git a/utils/finder.ts b/utils/finder.ts index 4d5bef6..0be0f39 100644 --- a/utils/finder.ts +++ b/utils/finder.ts @@ -151,7 +151,7 @@ export const noveltyPercent = (novelty: number): number => Math.round(Math.min(1 * one (no type for power chords, dyads, the tritone, fourths, quartal stacks and clusters, which Chord Explorer * cannot show), and the exact shape only in E Standard, the only tuning Chord Explorer knows. */ -export const finderExplorerUrl = (root: string, familyId: string, voicing: GeneratedVoicing, tuning: Tuning): string => +export const finderExplorerUrl = (root: string, familyId: string, voicing: GeneratedVoicing, tuning: Tuning): string | null => buildExplorerUrl({ root, type: explorerTypeForFamily(familyId) }, tuning.id === 'e-standard' ? voicing.shape : undefined); // --------------------------------------------------------------------------- diff --git a/utils/libraryVoicing.test.ts b/utils/libraryVoicing.test.ts index 562bb06..3b6c0d3 100644 --- a/utils/libraryVoicing.test.ts +++ b/utils/libraryVoicing.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; -import { resolveLibraryChord, resolveLibraryVoicing, moveShape, shapeDistance, tuningForMode } from './libraryVoicing'; +import { curatedExplorerTarget, resolveLibraryChord, resolveLibraryVoicing, moveShape, shapeDistance, tuningForMode } from './libraryVoicing'; import { DEFAULT_HAND_PROFILE, profileFromCalibration, DEFAULT_CALIBRATION } from '../engine/handProfile'; import { fromRiffForgeTab, shapeToMidi } from '../engine/shape'; import { midiToName, pitchClass, parsePitchClass, parseNoteName } from '../engine/pitch'; @@ -198,13 +198,20 @@ describe('Chord Explorer target', () => { expect(sus2).toBeGreaterThan(0); }); - it('omits the type for sounds Chord Explorer has no type for', () => { + it('gives no link for sounds Chord Explorer has no type for', () => { const omen = find('standard-dark.json', 'dark-1'); const resolved = resolveLibraryChord(omen, 'E', TuningMode.STANDARD, DEFAULT_HAND_PROFILE); expect(resolved.voicing!.soundsAs).toBe('E5(b9)'); expect(resolved.voicing!.explorer).toEqual({ root: 'E', type: undefined }); - expect(buildExplorerUrl(resolved.voicing!.explorer, resolved.voicing!.shape)).toBe( - `https://chords.thegridbase.com/?root=E&gv=${resolved.voicing!.shape.map((f) => (f === null ? 'x' : f)).join('-')}` - ); + expect(buildExplorerUrl(resolved.voicing!.explorer, resolved.voicing!.shape)).toBeNull(); + expect(resolved.explorer).toBeUndefined(); + }); + + it('links a card without a playable shape by its curated sound at the selected root, not its nickname', () => { + const ghost = find('drop-melodic.json', 'drop-melodic-1'); + expect(curatedExplorerTarget(ghost, 'C')).toEqual({ root: 'C', type: 'minor' }); + const omen = find('standard-dark.json', 'dark-1'); + expect(curatedExplorerTarget(omen, 'G')).toEqual({ root: 'G', type: undefined }); + expect(curatedExplorerTarget({ notes: ['??'], baseRoot: 'E' }, 'A')).toEqual({ root: 'A', type: undefined }); }); }); diff --git a/utils/libraryVoicing.ts b/utils/libraryVoicing.ts index 463d65d..d21be36 100644 --- a/utils/libraryVoicing.ts +++ b/utils/libraryVoicing.ts @@ -93,6 +93,13 @@ const toLibraryVoicing = ( } }); +/** Chord Explorer target from a curated recipe (notes relative to baseRoot), moved to the selected root. */ +export const curatedExplorerTarget = (chord: Pick, targetRoot: string): LibraryVoicing['explorer'] => { + const base = parsePitchClass(chord.baseRoot) ?? 0; + const midi = chord.notes.map(parseNoteName).filter((m): m is number => m !== null); + return { root: targetRoot, type: midi.length > 0 ? explorerTypeForIntervals(midi.map((m) => pitchClass(m - base))) : undefined }; +}; + const cache = new Map(); export const resolveLibraryVoicing = ( @@ -169,6 +176,8 @@ export const resolveLibraryChord = ( notes: voicing ? voicing.notes : [], fretboard: voicing ? voicing.tab : undefined, voicing: voicing ?? undefined, - unplayableReason: unplayableReason ?? undefined + unplayableReason: unplayableReason ?? undefined, + // Without a shape, link by the curated sound at the selected root, never by the nickname + explorer: voicing ? undefined : curatedExplorerTarget(chord, targetRoot) }; };