From 90df2b36f275b460182ec7d6e66122d5a384b7e0 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 9 Apr 2026 00:36:08 +0200 Subject: [PATCH] feat: chord voicing theory layer --- app/app/lib/chord-voicing.test.ts | 65 +++++++++++++++++++++++++++++++ app/app/lib/chord-voicing.ts | 52 +++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 app/app/lib/chord-voicing.test.ts create mode 100644 app/app/lib/chord-voicing.ts diff --git a/app/app/lib/chord-voicing.test.ts b/app/app/lib/chord-voicing.test.ts new file mode 100644 index 0000000..ac648a7 --- /dev/null +++ b/app/app/lib/chord-voicing.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { getPianoNotes, getGuitarVoicing } from './chord-voicing'; + +describe('getPianoNotes', () => { + it('returns note names for a major chord', () => { + expect(getPianoNotes('C')).toEqual(['C', 'E', 'G']); + }); + + it('returns note names for Cmaj7', () => { + expect(getPianoNotes('Cmaj7')).toEqual(['C', 'E', 'G', 'B']); + }); + + it('returns note names for Am', () => { + expect(getPianoNotes('Am')).toEqual(['A', 'C', 'E']); + }); + + it('returns [] for unparseable chord', () => { + expect(getPianoNotes('???')).toEqual([]); + }); + + it('returns [] for empty string', () => { + expect(getPianoNotes('')).toEqual([]); + }); +}); + +describe('getGuitarVoicing', () => { + it('returns voicing for E major (open position, baseFret=0)', () => { + const v = getGuitarVoicing('E'); + expect(v).not.toBeNull(); + expect(v!.baseFret).toBe(0); + expect(v!.frets).toEqual([0, 2, 2, 1, 0, 0]); + }); + + it('returns voicing for Am (open position, baseFret=0)', () => { + const v = getGuitarVoicing('Am'); + expect(v).not.toBeNull(); + expect(v!.baseFret).toBe(0); + expect(v!.frets).toEqual([null, 0, 2, 2, 1, 0]); + }); + + it('transposes Bm correctly (A-shape, shift=2)', () => { + const v = getGuitarVoicing('Bm'); + expect(v).not.toBeNull(); + // Am shifted up 2: [null,2,4,4,3,2], baseFret=2 + expect(v!.baseFret).toBe(2); + expect(v!.frets).toEqual([null, 2, 4, 4, 3, 2]); + }); + + it('transposes G major correctly (E-shape, shift=3)', () => { + 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]); + }); + + it('returns null for unknown quality', () => { + // 'add9' is not in the voicing map + expect(getGuitarVoicing('Cadd9')).toBeNull(); + }); + + it('returns null for unparseable chord', () => { + expect(getGuitarVoicing('???')).toBeNull(); + }); +}); diff --git a/app/app/lib/chord-voicing.ts b/app/app/lib/chord-voicing.ts new file mode 100644 index 0000000..8aa6d98 --- /dev/null +++ b/app/app/lib/chord-voicing.ts @@ -0,0 +1,52 @@ +import { Chord, Note } from 'tonal'; +import { GUITAR_VOICINGS } from './guitar-voicings'; + +export interface GuitarVoicing { + /** Absolute fret numbers per string (low→high); null = muted, 0 = open */ + frets: (number | null)[]; + /** Lowest fret displayed on the diagram (0 = show nut) */ + baseFret: number; + /** Absolute fret to draw a barre bar across, or null */ + barre: number | null; +} + +const ROOT_STRING_CHROMA: Record<'E' | 'A', number> = { + E: Note.chroma('E')!, // 4 + A: Note.chroma('A')!, // 9 +}; + +/** + * Returns the note names (e.g. ["C","E","G"]) for a chord string. + * Returns [] if the chord cannot be parsed. + */ +export function getPianoNotes(chord: string): string[] { + if (!chord) return []; + const parsed = Chord.get(chord); + if (!parsed.tonic || parsed.empty) return []; + return parsed.notes; +} + +/** + * Returns a transposed GuitarVoicing for a chord string, or null if the + * chord quality has no template or the chord cannot be parsed. + */ +export function getGuitarVoicing(chord: string): GuitarVoicing | null { + if (!chord) return null; + const parsed = Chord.get(chord); + if (!parsed.tonic || parsed.empty) return null; + + const template = GUITAR_VOICINGS[parsed.type]; + if (!template) return null; + + const rootChroma = ROOT_STRING_CHROMA[template.rootString]; + const tonicChroma = Note.chroma(parsed.tonic); + if (tonicChroma === undefined) return null; + + const shift = (tonicChroma - rootChroma + 12) % 12; + + return { + frets: template.frets.map((f) => (f === null ? null : f + shift)), + baseFret: shift, + barre: template.barre === null ? null : template.barre + shift, + }; +}