diff --git a/src/frets/chordFingerings.enharmonics.test.js b/src/frets/chordFingerings.enharmonics.test.js new file mode 100644 index 0000000..ae79fb5 --- /dev/null +++ b/src/frets/chordFingerings.enharmonics.test.js @@ -0,0 +1,250 @@ +import { describe, it, expect } from "vitest"; +import { Mode, Note, Scale } from "tonal"; +import { + CHORDS_DB_TUNING, + FRET_WINDOW, + getChordVariations, +} from "./chordFingerings.js"; + +// Every scale the UI offers in ScaleSelector, and every key in KeySelector. +// Mode.triads over this matrix is exactly what ScaleChords renders, so any +// combination that fails to resolve is a blank page in production. +const KEYS = [ + "C", + "C#", + "D", + "Eb", + "E", + "F", + "F#", + "G", + "Ab", + "A", + "Bb", + "B", +]; + +const SCALES = [ + "major", + "minor", + "dorian", + "phrygian", + "lydian", + "mixolydian", + "locrian", + "harmonic minor", + "melodic minor", +]; + +const TUNINGS = { + Standard: ["E2", "A2", "D3", "G3", "B3", "E4"], + "Half Step Down": ["Eb2", "Ab2", "Db3", "Gb3", "Bb3", "Eb4"], + "Drop D": ["D2", "A2", "D3", "G3", "B3", "E4"], + "Drop C": ["C2", "G2", "C3", "F3", "A3", "D4"], +}; + +/** Every triad the app can ask for, as [key, scale, chordName] tuples. */ +function allTriads() { + const out = []; + for (const key of KEYS) { + for (const scaleName of SCALES) { + const scale = Scale.get(`${key} ${scaleName}`); + let triads = []; + try { + triads = Mode.triads(scale.type, scale.tonic); + } catch { + continue; + } + for (const chord of triads) out.push([key, scaleName, chord]); + } + } + return out; +} + +/** Absolute fret of each string in an svguitar position, or null if muted. */ +function absoluteFrets(position) { + // svguitar numbers strings 1..6 high-to-low; return them low-to-high. + const byString = new Map(position.fingers.map(([str, fret]) => [str, fret])); + return [6, 5, 4, 3, 2, 1].map((str) => { + const fret = byString.get(str); + if (fret === "x" || fret === undefined) return null; + if (fret === 0) return 0; + return fret + position.position - 1; + }); +} + +describe("chord lookup across every key and scale the UI offers", () => { + const triads = allTriads(); + + it("covers the full 12 keys x 9 scales matrix", () => { + expect(triads.length).toBe(588); + }); + + // Regression for the crash on keys like Eb minor and Ab minor: Mode.triads + // spells these with double accidentals (Cb, Fb, Bbb, Ebb, F##), which a + // note-name-keyed lookup misses. getChordVariations returned null and the + // render sites dereferenced it, blanking the whole page. + it.each(triads)("resolves %s %s -> %s", (_key, _scale, chordName) => { + const result = getChordVariations(chordName); + expect(result).not.toBeNull(); + expect(result.positions.length).toBeGreaterThan(0); + }); +}); + +describe("enharmonic spellings resolve to the same fingerings", () => { + // Left side is what Mode.triads produces; right side is the spelling + // chords-db actually stores. + it.each([ + ["Cb", "B"], + ["Fb", "E"], + ["Bbb", "A"], + ["Ebb", "D"], + ["F##", "G"], + ["E#", "F"], + ["B#", "C"], + ["Cbm", "Bm"], + ["F##dim", "Gdim"], + ])("%s resolves identically to %s", (spelled, canonical) => { + const a = getChordVariations(spelled); + const b = getChordVariations(canonical); + + expect(a).not.toBeNull(); + expect(b).not.toBeNull(); + expect(a.positions).toEqual(b.positions); + }); + + it("still returns null for a chord that is not a chord", () => { + expect(getChordVariations("InvalidChord")).toBeNull(); + }); +}); + +describe("tuning-aware fingerings", () => { + it("returns the database fingerings unchanged for standard tuning", () => { + expect(getChordVariations("C", CHORDS_DB_TUNING)).toEqual( + getChordVariations("C") + ); + }); + + it("frets the low string higher in Drop D", () => { + const standard = getChordVariations("G", TUNINGS.Standard).positions[0]; + const dropD = getChordVariations("G", TUNINGS["Drop D"]).positions[0]; + + // Open low E in standard G; two frets up in Drop D to sound the same G. + expect(absoluteFrets(standard)[0]).toBe(3); + expect(absoluteFrets(dropD)[0]).toBe(5); + }); + + it("shifts every string up one fret in Half Step Down", () => { + const standard = getChordVariations("C", TUNINGS.Standard).positions[0]; + const halfStep = getChordVariations( + "C", + TUNINGS["Half Step Down"] + ).positions[0]; + + const before = absoluteFrets(standard); + const after = absoluteFrets(halfStep); + + before.forEach((fret, i) => { + if (fret === null) expect(after[i]).toBeNull(); + else expect(after[i]).toBe(fret + 1); + }); + }); + + it("preserves the sounding pitches in every supported tuning", () => { + for (const chordName of ["C", "G", "Am", "F", "Bdim", "Dm7", "Emaj7"]) { + const reference = getChordVariations( + chordName, + TUNINGS.Standard + ).positions.map((p) => JSON.stringify(p.midi)); + + for (const [label, tuning] of Object.entries(TUNINGS)) { + const positions = getChordVariations(chordName, tuning).positions; + expect(positions.length, `${chordName} in ${label}`).toBeGreaterThan(0); + + for (const position of positions) { + expect( + reference, + `${chordName} in ${label} changed the voicing` + ).toContain(JSON.stringify(position.midi)); + } + } + } + }); + + it("midi matches what the diagram actually shows", () => { + for (const [, tuning] of Object.entries(TUNINGS)) { + for (const chordName of ["C", "G", "F", "Am"]) { + for (const position of getChordVariations(chordName, tuning) + .positions) { + const sounded = absoluteFrets(position) + .map((fret, i) => + fret === null ? null : Note.midi(tuning[i]) + fret + ) + .filter((note) => note !== null); + expect(sounded).toEqual(position.midi); + } + } + } + }); + + it("never emits a fret behind the nut", () => { + for (const [, tuning] of Object.entries(TUNINGS)) { + for (const [, , chordName] of allTriads()) { + const result = getChordVariations(chordName, tuning); + for (const position of result.positions) { + expect(position.position).toBeGreaterThan(0); + for (const [, fret] of position.fingers) { + if (typeof fret === "number") expect(fret).toBeGreaterThanOrEqual(0); + } + } + } + } + }); + + // Retuning can stretch a shape past the window Chord.svelte draws, which + // renders fingers off the end of the neck. Worst observed before the fix was + // a C sus voicing spanning frets 1-9 in Half Step Down, because the open G + // string it leaned on had to be fretted once the tuning dropped. + it("never emits a fret past the diagram window", () => { + for (const [, tuning] of Object.entries(TUNINGS)) { + for (const [, , chordName] of allTriads()) { + for (const position of getChordVariations(chordName, tuning) + .positions) { + for (const [, fret] of position.fingers) { + if (typeof fret === "number") { + expect(fret).toBeLessThanOrEqual(FRET_WINDOW); + } + } + } + } + } + }); + + it("keeps every retuned shape within a hand's span", () => { + // Open strings ride above the nut and cost no reach, so only fretted + // notes count toward the span. + for (const [, tuning] of Object.entries(TUNINGS)) { + for (const [, , chordName] of allTriads()) { + for (const position of getChordVariations(chordName, tuning) + .positions) { + const fretted = position.fingers + .map(([, fret]) => fret) + .filter((fret) => typeof fret === "number" && fret > 0); + if (fretted.length < 2) continue; + const span = Math.max(...fretted) - Math.min(...fretted) + 1; + expect(span).toBeLessThanOrEqual(FRET_WINDOW); + } + } + } + }); + + // Seven-string support is a known gap: convertToSVGuitarFormat hardcodes a + // six-string reversal (6 - i). Rather than emit wrong shapes we fall back to + // the standard-tuning fingerings. + it("falls back to standard fingerings for a seven-string tuning", () => { + const sevenString = ["B2", "E2", "A2", "D3", "G3", "B3", "E4"]; + expect(getChordVariations("C", sevenString)).toEqual( + getChordVariations("C") + ); + }); +}); diff --git a/src/frets/chordFingerings.js b/src/frets/chordFingerings.js index 8f05c8e..1ec3d63 100644 --- a/src/frets/chordFingerings.js +++ b/src/frets/chordFingerings.js @@ -1,6 +1,47 @@ -import { Chord } from 'tonal'; +import { Chord, Note } from 'tonal'; import guitar from '@tombatossals/chords-db/lib/guitar.json'; +/** + * The tuning that every fingering in chords-db is defined against, low string + * first. Fingerings for any other tuning are derived from these by + * `retunePosition` below. + * + * Deliberately a local constant rather than an import from `$lib`: this module + * is unit-tested by vitest, which does not load the SvelteKit alias config. + */ +export const CHORDS_DB_TUNING = ['E2', 'A2', 'D3', 'G3', 'B3', 'E4']; + +/** + * chords-db keys its chord table by pitch class, using one fixed spelling per + * chroma. Indexing by chroma (rather than by note name) means every enharmonic + * spelling resolves, including double accidentals like Cb, Fb, Bbb and F##, + * which a name-keyed lookup table misses. + */ +const CHROMA_TO_DB_KEY = [ + 'C', + 'Csharp', + 'D', + 'Eb', + 'E', + 'F', + 'Fsharp', + 'G', + 'Ab', + 'A', + 'Bb', + 'B' +]; + +/** Highest fret we consider a fingering playable at. */ +const MAX_FRET = 24; + +/** + * How many frets a chord diagram spans. Exported so `Chord.svelte` draws the + * same window that `retunePosition` fits shapes into — if the two drift, the + * chart renders fingers past the end of the neck it drew. + */ +export const FRET_WINDOW = 4; + /** * Get fingering positions for a chord using the chords-db library * @@ -36,37 +77,19 @@ export function getChordFingerings(chordName) { const tonic = chord.tonic; const suffix = chord.aliases?.[0] || chord.type; - // Normalize the tonic for the database - // The database uses keys like "C", "Csharp", "D", "Eb", etc. - const keyMap = { - 'C': 'C', - 'C#': 'Csharp', - 'Db': 'Csharp', - 'D': 'D', - 'D#': 'Eb', - 'Eb': 'Eb', - 'E': 'E', - 'E#': 'F', // Enharmonic: E# = F - 'F': 'F', - 'F#': 'Fsharp', - 'Gb': 'Fsharp', - 'G': 'G', - 'G#': 'Ab', - 'Ab': 'Ab', - 'A': 'A', - 'A#': 'Bb', - 'Bb': 'Bb', - 'B': 'B', - 'B#': 'C' // Enharmonic: B# = C - }; - - const dbKey = keyMap[tonic]; + // Normalise the tonic to a pitch class index, then to the spelling chords-db + // uses for that pitch. Going via chroma handles every enharmonic spelling — + // including the double accidentals (Cb, Fb, Bbb, Ebb, F##) that Mode.triads + // produces for keys like Eb minor and Ab minor. + const { chroma } = Note.get(tonic); - if (!dbKey) { + if (chroma === undefined) { console.warn(`Unknown tonic: ${tonic}`); return null; } + const dbKey = CHROMA_TO_DB_KEY[chroma]; + // Find the chord in the database const chordData = guitar.chords[dbKey]; @@ -98,6 +121,117 @@ export function getChordFingerings(chordName) { return null; } +/** + * Semitone offset per string between the chords-db reference tuning and a + * target tuning. A positive value means the target string is tuned *lower*, so + * every note on it must be fretted that many frets higher to sound the same + * pitch. + * + * @param {string[]} tuning - Target tuning, low string first + * @returns {number[]|null} Per-string offsets, or null if the tuning is not + * comparable (wrong string count, or unparseable note names) + */ +function tuningOffsets(tuning) { + if (!Array.isArray(tuning) || tuning.length !== CHORDS_DB_TUNING.length) { + return null; + } + + const offsets = tuning.map((note, i) => { + const target = Note.midi(note); + const reference = Note.midi(CHORDS_DB_TUNING[i]); + if (target === null || reference === null) return null; + return reference - target; + }); + + return offsets.some((o) => o === null) ? null : offsets; +} + +/** + * Re-fret a chords-db position so it sounds the same pitches in a different + * tuning. + * + * The voicing is preserved exactly — only the fret each string is stopped at + * changes. In Drop D, for example, a shape that used the low E open now needs + * that string at the 2nd fret. Notes that would fall behind the nut are muted, + * and shapes that run off the end of the neck are dropped entirely. + * + * @param {Object} position - A chords-db position + * @param {number[]} offsets - Per-string semitone offsets from `tuningOffsets` + * @param {string[]} tuning - Target tuning, low string first + * @returns {Object|null} A chords-db-shaped position, or null if unplayable + */ +function retunePosition(position, offsets, tuning) { + const { frets, fingers, barres, baseFret = 1, capo } = position; + + // Work in absolute fret numbers: chords-db stores frets relative to baseFret, + // with 0 meaning an open string and -1 a muted one. + const toAbsolute = (fret) => (fret <= 0 ? fret : fret + baseFret - 1); + + const absolute = frets.map((fret, i) => { + if (fret === -1 || fret === 'x') return -1; + const shifted = toAbsolute(fret) + offsets[i]; + // Behind the nut: this string cannot sound the required pitch here. + if (shifted < 0) return -1; + return shifted; + }); + + if (absolute.every((fret) => fret === -1)) return null; + if (absolute.some((fret) => fret > MAX_FRET)) return null; + + // A barre only survives if every string it covers moved by the same amount; + // otherwise the strings no longer line up and we render individual dots. + const absoluteBarres = []; + const barresList = Array.isArray(barres) ? barres : barres ? [barres] : []; + + for (const barreFret of barresList) { + const members = frets + .map((fret, i) => (fret === barreFret ? i : -1)) + .filter((i) => i !== -1); + + if (members.length < 2) continue; + if (members.some((i) => absolute[i] === -1)) continue; + + const shifts = new Set(members.map((i) => offsets[i])); + if (shifts.size !== 1) continue; + + absoluteBarres.push(toAbsolute(barreFret) + offsets[members[0]]); + } + + // Choose a display window: sit at the nut when the shape fits there, + // otherwise start at the lowest fretted note. + const fretted = absolute.filter((fret) => fret > 0); + const highest = fretted.length ? Math.max(...fretted) : 0; + const lowest = fretted.length ? Math.min(...fretted) : 0; + + // Retuning can stretch a shape past the diagram. Uneven string shifts pull + // the grip apart, and a voicing that leaned on an open string high up the + // neck now has to fret it — chords-db's open G in Drop C spans frets 2-7, + // and C sus voicings can span nine. Those are unplayable as a single grip, + // not merely badly framed, so drop them the way out-of-range shapes are + // dropped above. Open strings ride above the nut and cost no reach, so only + // fretted notes count toward the span. + if (fretted.length && highest - lowest + 1 > FRET_WINDOW) return null; + + const newBaseFret = highest <= FRET_WINDOW || !fretted.length ? 1 : lowest; + + const toRelative = (fret) => (fret <= 0 ? fret : fret - newBaseFret + 1); + + // Recompute the sounding pitches from the target tuning rather than reusing + // the database's midi array, so playback matches what is drawn. + const midi = absolute + .map((fret, i) => (fret === -1 ? null : Note.midi(tuning[i]) + fret)) + .filter((note) => note !== null); + + return { + frets: absolute.map(toRelative), + fingers, + barres: absoluteBarres.map(toRelative), + baseFret: newBaseFret, + capo: capo && absoluteBarres.length > 0, + midi + }; +} + /** * Convert chords-db format to svguitar format * @@ -191,24 +325,44 @@ export function convertToSVGuitarFormat(position) { /** * Get all fingering variations for a chord in svguitar format * + * When a tuning is supplied, fingerings are re-fretted so they sound the same + * pitches in that tuning (see `retunePosition`). Tunings with a string count + * other than six fall back to the standard-tuning fingerings — see the note on + * seven-string support in the README. + * * @param {string} chordName - The chord name (e.g., "Amin7", "C", "Gmaj7") - * @returns {Array|null} Array of chord positions formatted for svguitar + * @param {string[]} [tuning] - Target tuning, low string first. Defaults to standard. + * @returns {Object|null} `{ name, positions }` for svguitar, or null if not found * * @example * ```js * const variations = getChordVariations("C"); * // Returns multiple fingering options, each ready to use with svguitar + * + * const dropD = getChordVariations("G", ["D2", "A2", "D3", "G3", "B3", "E4"]); + * // Same G major voicings, with the low string fretted two frets higher * ``` */ -export function getChordVariations(chordName) { +export function getChordVariations(chordName, tuning = CHORDS_DB_TUNING) { const result = getChordFingerings(chordName); if (!result) { return null; } + const offsets = tuningOffsets(tuning); + + // No usable offsets (unsupported string count, unparseable notes) or nothing + // to change: use the database fingerings as they stand. + const positions = + offsets && offsets.some((offset) => offset !== 0) + ? result.positions + .map((pos) => retunePosition(pos, offsets, tuning)) + .filter((pos) => pos !== null) + : result.positions; + return { name: result.name, - positions: result.positions.map(pos => convertToSVGuitarFormat(pos)) - }; + positions: positions.map((pos) => convertToSVGuitarFormat(pos)) + }; } diff --git a/src/lib/Chord.svelte b/src/lib/Chord.svelte index 99dad71..aa67841 100644 --- a/src/lib/Chord.svelte +++ b/src/lib/Chord.svelte @@ -1,5 +1,5 @@
- {variations.positions.length} position{variations.positions.length !== 1 - ? "s" - : ""} available + {positions.length} position{positions.length !== 1 ? "s" : ""} available