fix: piano readability + note labels, open chord voicings for guitar

This commit is contained in:
2026-04-09 01:00:34 +02:00
parent 544474c074
commit 2964475df9
3 changed files with 175 additions and 73 deletions

View File

@@ -12,10 +12,10 @@ const NOTE_CHROMA: Record<string, number> = {
};
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<string, number> = {
@@ -43,7 +43,7 @@ export function PianoKeys({ notes }: Props) {
return (
<div
className="flex items-center justify-center text-muted-foreground text-xs"
style={{ width: totalWidth, height: WHITE_KEY_H }}
style={{ width: totalWidth, height: WHITE_KEY_H + 14 }}
>
?
</div>
@@ -51,29 +51,37 @@ export function PianoKeys({ notes }: Props) {
}
return (
<div className="relative inline-block" style={{ width: totalWidth, height: WHITE_KEY_H }}>
<div style={{ width: totalWidth }}>
{/* Keyboard — light container so keys are visible in any theme */}
<div
className="relative rounded-sm"
style={{ width: totalWidth, height: WHITE_KEY_H, background: '#d0d0d0' }}
>
{/* White keys */}
{WHITE_KEYS.map((note, i) => {
const active = activeChroma.has(NOTE_CHROMA[note]);
return (
<div
key={note}
className="absolute border border-border rounded-b-sm"
className="absolute rounded-b-sm"
style={{
left: i * WHITE_KEY_W,
left: i * WHITE_KEY_W + 1,
top: 0,
width: WHITE_KEY_W - 1,
height: WHITE_KEY_H,
background: active ? 'hsl(var(--primary) / 0.15)' : 'white',
width: WHITE_KEY_W - 2,
height: WHITE_KEY_H - 1,
background: active ? 'hsl(var(--primary) / 0.2)' : 'white',
borderBottom: active
? '3px solid hsl(var(--primary))'
: '1px solid #bbb',
}}
>
{active && (
<div
className="absolute rounded-full bg-primary"
style={{
width: 8,
height: 8,
bottom: 6,
width: 10,
height: 10,
bottom: 10,
left: '50%',
transform: 'translateX(-50%)',
}}
@@ -85,8 +93,7 @@ export function PianoKeys({ notes }: Props) {
{/* Black keys (rendered on top) */}
{BLACK_KEY_NAMES.map((note) => {
const chroma = NOTE_CHROMA[note];
const active = activeChroma.has(chroma);
const active = activeChroma.has(NOTE_CHROMA[note]);
return (
<div
key={note}
@@ -96,16 +103,16 @@ export function PianoKeys({ notes }: Props) {
top: 0,
width: BLACK_KEY_W,
height: BLACK_KEY_H,
background: active ? 'hsl(var(--primary))' : '#1a1a1a',
background: active ? 'hsl(var(--primary))' : '#1c1c1c',
}}
>
{active && (
<div
className="absolute rounded-full bg-primary-foreground"
className="absolute rounded-full bg-white"
style={{
width: 6,
height: 6,
bottom: 4,
bottom: 5,
left: '50%',
transform: 'translateX(-50%)',
}}
@@ -115,5 +122,22 @@ export function PianoKeys({ notes }: Props) {
);
})}
</div>
{/* Note labels below keyboard */}
<div className="flex" style={{ width: totalWidth, marginTop: 2 }}>
{WHITE_KEYS.map((note) => {
const active = activeChroma.has(NOTE_CHROMA[note]);
return (
<div
key={note}
className={`text-center font-mono select-none ${active ? 'text-primary font-bold' : 'text-muted-foreground'}`}
style={{ width: WHITE_KEY_W, fontSize: 8, lineHeight: '12px' }}
>
{note}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -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', () => {

View File

@@ -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<string, GuitarVoicing> = {
// ── 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;