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_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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user