feat: chord voicing theory layer
This commit is contained in:
65
app/app/lib/chord-voicing.test.ts
Normal file
65
app/app/lib/chord-voicing.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
52
app/app/lib/chord-voicing.ts
Normal file
52
app/app/lib/chord-voicing.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user