From 2964475df90f7c8ddcfec9bb1602b47c4b5bc334 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 9 Apr 2026 01:00:34 +0200 Subject: [PATCH] fix: piano readability + note labels, open chord voicings for guitar --- .../components/chord-diagram/piano-keys.tsx | 158 ++++++++++-------- app/app/lib/chord-voicing.test.ts | 16 +- app/app/lib/chord-voicing.ts | 74 +++++++- 3 files changed, 175 insertions(+), 73 deletions(-) diff --git a/app/app/components/chord-diagram/piano-keys.tsx b/app/app/components/chord-diagram/piano-keys.tsx index 5b34720..ac67eb0 100644 --- a/app/app/components/chord-diagram/piano-keys.tsx +++ b/app/app/components/chord-diagram/piano-keys.tsx @@ -12,10 +12,10 @@ const NOTE_CHROMA: Record = { }; const WHITE_KEYS = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; -const WHITE_KEY_W = 14; // px -const WHITE_KEY_H = 56; // px -const BLACK_KEY_W = 9; // px -const BLACK_KEY_H = 34; // px +const WHITE_KEY_W = 16; // px +const WHITE_KEY_H = 62; // px +const BLACK_KEY_W = 10; // px +const BLACK_KEY_H = 38; // px /** Left offset (px) of each black key from the left edge of the keyboard */ const BLACK_KEY_LEFT: Record = { @@ -43,7 +43,7 @@ export function PianoKeys({ notes }: Props) { return (
?
@@ -51,69 +51,93 @@ export function PianoKeys({ notes }: Props) { } return ( -
- {/* White keys */} - {WHITE_KEYS.map((note, i) => { - const active = activeChroma.has(NOTE_CHROMA[note]); - return ( -
- {active && ( -
- )} -
- ); - })} +
+ {/* Keyboard — light container so keys are visible in any theme */} +
+ {/* White keys */} + {WHITE_KEYS.map((note, i) => { + const active = activeChroma.has(NOTE_CHROMA[note]); + return ( +
+ {active && ( +
+ )} +
+ ); + })} - {/* Black keys (rendered on top) */} - {BLACK_KEY_NAMES.map((note) => { - const chroma = NOTE_CHROMA[note]; - const active = activeChroma.has(chroma); - return ( -
- {active && ( -
- )} -
- ); - })} + {/* Black keys (rendered on top) */} + {BLACK_KEY_NAMES.map((note) => { + const active = activeChroma.has(NOTE_CHROMA[note]); + return ( +
+ {active && ( +
+ )} +
+ ); + })} +
+ + {/* Note labels below keyboard */} +
+ {WHITE_KEYS.map((note) => { + const active = activeChroma.has(NOTE_CHROMA[note]); + return ( +
+ {note} +
+ ); + })} +
); } diff --git a/app/app/lib/chord-voicing.test.ts b/app/app/lib/chord-voicing.test.ts index ac648a7..afdf0df 100644 --- a/app/app/lib/chord-voicing.test.ts +++ b/app/app/lib/chord-voicing.test.ts @@ -46,12 +46,20 @@ describe('getGuitarVoicing', () => { expect(v!.frets).toEqual([null, 2, 4, 4, 3, 2]); }); - it('transposes G major correctly (E-shape, shift=3)', () => { + it('returns open G major voicing from named table', () => { const v = getGuitarVoicing('G'); expect(v).not.toBeNull(); - // E major shifted up 3: [3,5,5,4,3,3], baseFret=3 - expect(v!.baseFret).toBe(3); - expect(v!.frets).toEqual([3, 5, 5, 4, 3, 3]); + // Named open G: [3,2,0,0,0,3], baseFret=0 + expect(v!.baseFret).toBe(0); + expect(v!.frets).toEqual([3, 2, 0, 0, 0, 3]); + }); + + it('falls back to transposition for F# major (not in named table)', () => { + const v = getGuitarVoicing('F#'); + expect(v).not.toBeNull(); + // E-shape shift=2: [2,4,4,3,2,2], baseFret=2 + expect(v!.baseFret).toBe(2); + expect(v!.frets).toEqual([2, 4, 4, 3, 2, 2]); }); it('returns null for unknown quality', () => { diff --git a/app/app/lib/chord-voicing.ts b/app/app/lib/chord-voicing.ts index 8aa6d98..350aa42 100644 --- a/app/app/lib/chord-voicing.ts +++ b/app/app/lib/chord-voicing.ts @@ -10,6 +10,69 @@ export interface GuitarVoicing { barre: number | null; } +/** + * Named open (and common barre) chord voicings for specific chord strings. + * Checked before the algorithmic transposition, so G major shows the open + * G chord [3,2,0,0,0,3] instead of a barre at fret 3. + */ +const GUITAR_NAMED_VOICINGS: Record = { + // ── Major ────────────────────────────────────────────────────────────── + 'E': { frets: [0, 2, 2, 1, 0, 0], baseFret: 0, barre: null }, + 'A': { frets: [null, 0, 2, 2, 2, 0], baseFret: 0, barre: null }, + 'D': { frets: [null, null, 0, 2, 3, 2], baseFret: 0, barre: null }, + 'G': { frets: [3, 2, 0, 0, 0, 3], baseFret: 0, barre: null }, + 'C': { frets: [null, 3, 2, 0, 1, 0], baseFret: 0, barre: null }, + 'F': { frets: [1, 3, 3, 2, 1, 1], baseFret: 1, barre: 1 }, + 'B': { frets: [null, 2, 4, 4, 4, 2], baseFret: 2, barre: 2 }, + 'Bb': { frets: [null, 1, 3, 3, 3, 1], baseFret: 1, barre: 1 }, + 'Ab': { frets: [null, null, 6, 5, 4, 4], baseFret: 4, barre: null }, + + // ── Minor ─────────────────────────────────────────────────────────────── + 'Em': { frets: [0, 2, 2, 0, 0, 0], baseFret: 0, barre: null }, + 'Am': { frets: [null, 0, 2, 2, 1, 0], baseFret: 0, barre: null }, + 'Dm': { frets: [null, null, 0, 2, 3, 1], baseFret: 0, barre: null }, + 'Gm': { frets: [3, 5, 5, 3, 3, 3], baseFret: 3, barre: 3 }, + 'Cm': { frets: [null, 3, 5, 5, 4, 3], baseFret: 3, barre: 3 }, + 'Fm': { frets: [1, 3, 3, 1, 1, 1], baseFret: 1, barre: 1 }, + 'Bm': { frets: [null, 2, 4, 4, 3, 2], baseFret: 2, barre: 2 }, + 'Bbm': { frets: [null, 1, 3, 3, 2, 1], baseFret: 1, barre: 1 }, + + // ── Dominant 7th ──────────────────────────────────────────────────────── + 'E7': { frets: [0, 2, 0, 1, 0, 0], baseFret: 0, barre: null }, + 'A7': { frets: [null, 0, 2, 0, 2, 0], baseFret: 0, barre: null }, + 'D7': { frets: [null, null, 0, 2, 1, 2], baseFret: 0, barre: null }, + 'G7': { frets: [3, 2, 0, 0, 0, 1], baseFret: 0, barre: null }, + 'C7': { frets: [null, 3, 2, 3, 1, 0], baseFret: 0, barre: null }, + 'B7': { frets: [null, 2, 1, 2, 0, 2], baseFret: 0, barre: null }, + 'F7': { frets: [1, 3, 1, 2, 1, 1], baseFret: 1, barre: 1 }, + + // ── Major 7th ──────────────────────────────────────────────────────────── + 'Emaj7': { frets: [0, 2, 1, 1, 0, 0], baseFret: 0, barre: null }, + 'Amaj7': { frets: [null, 0, 2, 1, 2, 0], baseFret: 0, barre: null }, + 'Dmaj7': { frets: [null, null, 0, 2, 2, 2], baseFret: 0, barre: null }, + 'Gmaj7': { frets: [3, 2, 0, 0, 0, 2], baseFret: 0, barre: null }, + 'Cmaj7': { frets: [null, 3, 2, 0, 0, 0], baseFret: 0, barre: null }, + 'Fmaj7': { frets: [null, null, 3, 2, 1, 0], baseFret: 0, barre: null }, + + // ── Minor 7th ──────────────────────────────────────────────────────────── + 'Em7': { frets: [0, 2, 0, 0, 0, 0], baseFret: 0, barre: null }, + 'Am7': { frets: [null, 0, 2, 0, 1, 0], baseFret: 0, barre: null }, + 'Dm7': { frets: [null, null, 0, 2, 1, 1], baseFret: 0, barre: null }, + 'Gm7': { frets: [3, 5, 3, 3, 3, 3], baseFret: 3, barre: 3 }, + 'Bm7': { frets: [null, 2, 4, 2, 3, 2], baseFret: 2, barre: 2 }, + + // ── Suspended 4th ──────────────────────────────────────────────────────── + 'Esus4': { frets: [0, 2, 2, 2, 0, 0], baseFret: 0, barre: null }, + 'Asus4': { frets: [null, 0, 2, 2, 3, 0], baseFret: 0, barre: null }, + 'Dsus4': { frets: [null, null, 0, 2, 3, 3], baseFret: 0, barre: null }, + 'Gsus4': { frets: [3, 3, 0, 0, 1, 3], baseFret: 0, barre: null }, + + // ── Suspended 2nd ──────────────────────────────────────────────────────── + 'Esus2': { frets: [0, 2, 4, 4, 0, 0], baseFret: 0, barre: null }, + 'Asus2': { frets: [null, 0, 2, 2, 0, 0], baseFret: 0, barre: null }, + 'Dsus2': { frets: [null, null, 0, 2, 3, 0], baseFret: 0, barre: null }, +}; + const ROOT_STRING_CHROMA: Record<'E' | 'A', number> = { E: Note.chroma('E')!, // 4 A: Note.chroma('A')!, // 9 @@ -27,11 +90,18 @@ export function getPianoNotes(chord: string): string[] { } /** - * Returns a transposed GuitarVoicing for a chord string, or null if the - * chord quality has no template or the chord cannot be parsed. + * Returns a GuitarVoicing for a chord string, or null if unavailable. + * Checks named open-chord voicings first, then falls back to algorithmic + * barre-chord transposition via quality templates. */ export function getGuitarVoicing(chord: string): GuitarVoicing | null { if (!chord) return null; + + // 1. Named lookup — preferred open-position voicings for common chords + const named = GUITAR_NAMED_VOICINGS[chord]; + if (named) return named; + + // 2. Algorithmic fallback — transpose quality template by chord root const parsed = Chord.get(chord); if (!parsed.tonic || parsed.empty) return null;