fix: piano readability + note labels, open chord voicings for guitar
This commit is contained in:
@@ -12,10 +12,10 @@ const NOTE_CHROMA: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const WHITE_KEYS = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
|
const WHITE_KEYS = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
|
||||||
const WHITE_KEY_W = 14; // px
|
const WHITE_KEY_W = 16; // px
|
||||||
const WHITE_KEY_H = 56; // px
|
const WHITE_KEY_H = 62; // px
|
||||||
const BLACK_KEY_W = 9; // px
|
const BLACK_KEY_W = 10; // px
|
||||||
const BLACK_KEY_H = 34; // px
|
const BLACK_KEY_H = 38; // px
|
||||||
|
|
||||||
/** Left offset (px) of each black key from the left edge of the keyboard */
|
/** Left offset (px) of each black key from the left edge of the keyboard */
|
||||||
const BLACK_KEY_LEFT: Record<string, number> = {
|
const BLACK_KEY_LEFT: Record<string, number> = {
|
||||||
@@ -43,7 +43,7 @@ export function PianoKeys({ notes }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center text-muted-foreground text-xs"
|
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>
|
</div>
|
||||||
@@ -51,69 +51,93 @@ export function PianoKeys({ notes }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-block" style={{ width: totalWidth, height: WHITE_KEY_H }}>
|
<div style={{ width: totalWidth }}>
|
||||||
{/* White keys */}
|
{/* Keyboard — light container so keys are visible in any theme */}
|
||||||
{WHITE_KEYS.map((note, i) => {
|
<div
|
||||||
const active = activeChroma.has(NOTE_CHROMA[note]);
|
className="relative rounded-sm"
|
||||||
return (
|
style={{ width: totalWidth, height: WHITE_KEY_H, background: '#d0d0d0' }}
|
||||||
<div
|
>
|
||||||
key={note}
|
{/* White keys */}
|
||||||
className="absolute border border-border rounded-b-sm"
|
{WHITE_KEYS.map((note, i) => {
|
||||||
style={{
|
const active = activeChroma.has(NOTE_CHROMA[note]);
|
||||||
left: i * WHITE_KEY_W,
|
return (
|
||||||
top: 0,
|
<div
|
||||||
width: WHITE_KEY_W - 1,
|
key={note}
|
||||||
height: WHITE_KEY_H,
|
className="absolute rounded-b-sm"
|
||||||
background: active ? 'hsl(var(--primary) / 0.15)' : 'white',
|
style={{
|
||||||
}}
|
left: i * WHITE_KEY_W + 1,
|
||||||
>
|
top: 0,
|
||||||
{active && (
|
width: WHITE_KEY_W - 2,
|
||||||
<div
|
height: WHITE_KEY_H - 1,
|
||||||
className="absolute rounded-full bg-primary"
|
background: active ? 'hsl(var(--primary) / 0.2)' : 'white',
|
||||||
style={{
|
borderBottom: active
|
||||||
width: 8,
|
? '3px solid hsl(var(--primary))'
|
||||||
height: 8,
|
: '1px solid #bbb',
|
||||||
bottom: 6,
|
}}
|
||||||
left: '50%',
|
>
|
||||||
transform: 'translateX(-50%)',
|
{active && (
|
||||||
}}
|
<div
|
||||||
/>
|
className="absolute rounded-full bg-primary"
|
||||||
)}
|
style={{
|
||||||
</div>
|
width: 10,
|
||||||
);
|
height: 10,
|
||||||
})}
|
bottom: 10,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Black keys (rendered on top) */}
|
{/* Black keys (rendered on top) */}
|
||||||
{BLACK_KEY_NAMES.map((note) => {
|
{BLACK_KEY_NAMES.map((note) => {
|
||||||
const chroma = NOTE_CHROMA[note];
|
const active = activeChroma.has(NOTE_CHROMA[note]);
|
||||||
const active = activeChroma.has(chroma);
|
return (
|
||||||
return (
|
<div
|
||||||
<div
|
key={note}
|
||||||
key={note}
|
className="absolute z-10 rounded-b-sm"
|
||||||
className="absolute z-10 rounded-b-sm"
|
style={{
|
||||||
style={{
|
left: BLACK_KEY_LEFT[note],
|
||||||
left: BLACK_KEY_LEFT[note],
|
top: 0,
|
||||||
top: 0,
|
width: BLACK_KEY_W,
|
||||||
width: BLACK_KEY_W,
|
height: BLACK_KEY_H,
|
||||||
height: BLACK_KEY_H,
|
background: active ? 'hsl(var(--primary))' : '#1c1c1c',
|
||||||
background: active ? 'hsl(var(--primary))' : '#1a1a1a',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{active && (
|
||||||
{active && (
|
<div
|
||||||
<div
|
className="absolute rounded-full bg-white"
|
||||||
className="absolute rounded-full bg-primary-foreground"
|
style={{
|
||||||
style={{
|
width: 6,
|
||||||
width: 6,
|
height: 6,
|
||||||
height: 6,
|
bottom: 5,
|
||||||
bottom: 4,
|
left: '50%',
|
||||||
left: '50%',
|
transform: 'translateX(-50%)',
|
||||||
transform: 'translateX(-50%)',
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,20 @@ describe('getGuitarVoicing', () => {
|
|||||||
expect(v!.frets).toEqual([null, 2, 4, 4, 3, 2]);
|
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');
|
const v = getGuitarVoicing('G');
|
||||||
expect(v).not.toBeNull();
|
expect(v).not.toBeNull();
|
||||||
// E major shifted up 3: [3,5,5,4,3,3], baseFret=3
|
// Named open G: [3,2,0,0,0,3], baseFret=0
|
||||||
expect(v!.baseFret).toBe(3);
|
expect(v!.baseFret).toBe(0);
|
||||||
expect(v!.frets).toEqual([3, 5, 5, 4, 3, 3]);
|
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', () => {
|
it('returns null for unknown quality', () => {
|
||||||
|
|||||||
@@ -10,6 +10,69 @@ export interface GuitarVoicing {
|
|||||||
barre: number | null;
|
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> = {
|
const ROOT_STRING_CHROMA: Record<'E' | 'A', number> = {
|
||||||
E: Note.chroma('E')!, // 4
|
E: Note.chroma('E')!, // 4
|
||||||
A: Note.chroma('A')!, // 9
|
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
|
* Returns a GuitarVoicing for a chord string, or null if unavailable.
|
||||||
* chord quality has no template or the chord cannot be parsed.
|
* Checks named open-chord voicings first, then falls back to algorithmic
|
||||||
|
* barre-chord transposition via quality templates.
|
||||||
*/
|
*/
|
||||||
export function getGuitarVoicing(chord: string): GuitarVoicing | null {
|
export function getGuitarVoicing(chord: string): GuitarVoicing | null {
|
||||||
if (!chord) return 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);
|
const parsed = Chord.get(chord);
|
||||||
if (!parsed.tonic || parsed.empty) return null;
|
if (!parsed.tonic || parsed.empty) return null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user