diff --git a/scripts/debug-patterns b/scripts/debug-patterns index 3be2fd8..4190b7a 100755 --- a/scripts/debug-patterns +++ b/scripts/debug-patterns @@ -3,6 +3,21 @@ import { Scale } from "tonal"; import { diatonicPatterns, pentatonicPatterns } from "../src/frets/system/patterns.js"; +/** + * Build a mapping from diatonic scale degree (1-7) to array index + * For 7-note scales: degree 1 -> 0, degree 2 -> 1, etc. + * For 5-note scales: uses intervals to map (e.g., major pent 1,2,3,5,6 -> 0,1,2,3,4) + */ +function buildDegreeToIndexMap(intervals) { + const map = {}; + intervals.forEach((interval, index) => { + // Extract the degree number from interval like "1P", "2M", "3m", "5P", "6M" + const degree = parseInt(interval); + map[degree] = index; + }); + return map; +} + /** * Debug utility to visualize pattern indexes as actual note names or intervals */ @@ -33,17 +48,28 @@ function debugPattern(scaleName, shape, useIntervals = false) { const displayMode = useIntervals ? "Intervals" : "Notes"; const displayValues = useIntervals ? intervals : notes; + // Build degree-to-index mapping for pentatonic scales + const degreeToIndex = isPentatonic ? buildDegreeToIndexMap(intervals) : null; + console.log(`\n${"=".repeat(60)}`); console.log(`Scale: ${scaleName}`); console.log(`Shape: ${shape}`); console.log(`${displayMode}: ${displayValues.join(", ")}`); + if (isPentatonic) { + console.log(`Degree mapping: ${Object.entries(degreeToIndex).map(([d, i]) => `${d}→${i}`).join(", ")}`); + } console.log(`${"=".repeat(60)}\n`); pattern.forEach((stringPattern, stringIndex) => { const stringName = stringNames[stringIndex].padEnd(6); const indexes = stringPattern.map(i => String(i).padStart(2)).join(", "); - // Scale degrees are 1-indexed (1-7), so subtract 1 to get array index (0-6) - const actualValues = stringPattern.map(i => displayValues[i - 1].padEnd(3)).join(", "); + + // For diatonic: degree - 1 gives array index + // For pentatonic: use the degree-to-index mapping + const actualValues = stringPattern.map(degree => { + const index = isPentatonic ? degreeToIndex[degree] : degree - 1; + return displayValues[index].padEnd(3); + }).join(", "); console.log(`${stringName}: [${indexes}] → [${actualValues}]`); }); diff --git a/src/frets/system/caged.js b/src/frets/system/caged.js index d871a2f..82d1446 100644 --- a/src/frets/system/caged.js +++ b/src/frets/system/caged.js @@ -18,23 +18,38 @@ export default function caged(strings, scale) { "CAGED system only works with 5-note pentatonic or 7-note scales" ); + // Determine if this is a minor scale (has minor 3rd) + const isMinor = scale.intervals.includes("3m"); + // Build a map from chroma to scale degree and interval + // Both pentatonic and diatonic patterns use 7-degree format const chromaToScaleDegree = new Map(); const chromaToInterval = new Map(); - scale.notes.forEach((noteName, index) => { - const noteObj = Note.get(noteName); - // Scale degrees are 1-indexed (1-5 for pentatonic, 1-7 for diatonic) - const degree = index + 1; - chromaToScaleDegree.set(noteObj.chroma, degree); - chromaToInterval.set(noteObj.chroma, scale.intervals[index]); - }); - - // Determine if this is a minor scale (has minor 3rd) - const isMinor = scale.intervals.includes("3m"); + if (noteCount === 5) { + // Pentatonic: map notes to their 7-degree equivalents + // Major pentatonic uses degrees: 1, 2, 3, 5, 6 + // Minor pentatonic uses degrees: 1, 3, 4, 5, 7 + const majorDegrees = [1, 2, 3, 5, 6]; + const minorDegrees = [1, 3, 4, 5, 7]; + const degreeMapping = isMinor ? minorDegrees : majorDegrees; + + scale.notes.forEach((noteName, index) => { + const noteObj = Note.get(noteName); + chromaToScaleDegree.set(noteObj.chroma, degreeMapping[index]); + chromaToInterval.set(noteObj.chroma, scale.intervals[index]); + }); + } else { + // Diatonic: degrees are 1-7 + scale.notes.forEach((noteName, index) => { + const noteObj = Note.get(noteName); + chromaToScaleDegree.set(noteObj.chroma, index + 1); + chromaToInterval.set(noteObj.chroma, scale.intervals[index]); + }); + } // Build CAGED shapes with their patterns - // For minor scales, rotate the diatonic pattern degrees by +2 + // For minor scales, rotate the pattern degrees by +2 // This shifts the shapes so that G shape root aligns at fret 8 instead of fret 5 const basePatterns = noteCount === 5 ? pentatonicPatterns : diatonicPatterns; @@ -42,8 +57,8 @@ export default function caged(strings, scale) { ([pos, shape]) => { let pattern = basePatterns[shape]; - // For 7-note minor scales, rotate degrees by +2 - if (noteCount === 7 && isMinor) { + // For minor scales (both pentatonic and diatonic), rotate degrees by +2 + if (isMinor) { pattern = pattern.map((stringDegrees) => stringDegrees.map((degree) => rotateDegree(degree, 2)) ); diff --git a/src/frets/system/caged.test.js b/src/frets/system/caged.test.js index 33ce9a5..ba79977 100644 --- a/src/frets/system/caged.test.js +++ b/src/frets/system/caged.test.js @@ -145,4 +145,169 @@ describe("CAGED system", () => { expect(lowE[0].positions.CAGED).toContain(5); }); }); + + describe("C major pentatonic", () => { + it("assigns positions using 7-degree mapping (1,2,3,5,6)", () => { + const scale = Scale.get("C major pentatonic"); + const fb = frets(["E2", "A2", "D3", "G3", "B3", "E4"], 16, scale); + const lowE = fb.strings[5]; + + // C major pentatonic: C(1), D(2), E(3), G(5), A(6) + // On low E: E at fret 0, G at fret 3, A at fret 5, C at fret 8, D at fret 10 + expect(lowE[0].note.pc).toBe("E"); + expect(lowE[0].positions.CAGED).toBeDefined(); + expect(lowE[0].positions.CAGED.length).toBeGreaterThan(0); + + expect(lowE[3].note.pc).toBe("G"); + expect(lowE[3].positions.CAGED).toBeDefined(); + + expect(lowE[5].note.pc).toBe("A"); + expect(lowE[5].positions.CAGED).toBeDefined(); + + expect(lowE[8].note.pc).toBe("C"); + expect(lowE[8].positions.CAGED).toBeDefined(); + + expect(lowE[10].note.pc).toBe("D"); + expect(lowE[10].positions.CAGED).toBeDefined(); + }); + + it("assigns all 5 positions across the fretboard", () => { + const scale = Scale.get("C major pentatonic"); + const fb = frets(["E2", "A2", "D3", "G3", "B3", "E4"], 16, scale); + + const allPositions = new Set(); + for (const string of fb.strings) { + for (const note of string) { + if (note.positions.CAGED) { + note.positions.CAGED.forEach((p) => allPositions.add(p)); + } + } + } + + expect(allPositions.size).toBe(5); + expect([...allPositions].sort()).toEqual([1, 2, 3, 4, 5]); + }); + + it("assigns correct intervals to scale notes", () => { + const scale = Scale.get("C major pentatonic"); + const fb = frets(["E2", "A2", "D3", "G3", "B3", "E4"], 16, scale); + + // C major pentatonic intervals: 1P, 2M, 3M, 5P, 6M + const expectedIntervals = ["1P", "2M", "3M", "5P", "6M"]; + + for (const string of fb.strings) { + for (const note of string) { + if (note.interval) { + expect(expectedIntervals).toContain(note.interval); + } + } + } + }); + }); + + describe("C minor pentatonic", () => { + it("assigns positions with rotation for minor scale", () => { + const scale = Scale.get("C minor pentatonic"); + const fb = frets(["E2", "A2", "D3", "G3", "B3", "E4"], 16, scale); + const lowE = fb.strings[5]; + + // C minor pentatonic: C(1), Eb(3), F(4), G(5), Bb(7) + // On low E: F at fret 1, G at fret 3, Bb at fret 6, C at fret 8 + expect(lowE[1].note.pc).toBe("F"); + expect(lowE[1].positions.CAGED).toBeDefined(); + expect(lowE[1].positions.CAGED.length).toBeGreaterThan(0); + + expect(lowE[3].note.pc).toBe("G"); + expect(lowE[3].positions.CAGED).toBeDefined(); + + expect(lowE[6].note.pc).toBe("Bb"); + expect(lowE[6].positions.CAGED).toBeDefined(); + + expect(lowE[8].note.pc).toBe("C"); + expect(lowE[8].positions.CAGED).toBeDefined(); + }); + + it("assigns all 5 positions across the fretboard", () => { + const scale = Scale.get("C minor pentatonic"); + const fb = frets(["E2", "A2", "D3", "G3", "B3", "E4"], 16, scale); + + const allPositions = new Set(); + for (const string of fb.strings) { + for (const note of string) { + if (note.positions.CAGED) { + note.positions.CAGED.forEach((p) => allPositions.add(p)); + } + } + } + + expect(allPositions.size).toBe(5); + expect([...allPositions].sort()).toEqual([1, 2, 3, 4, 5]); + }); + + it("assigns correct intervals to scale notes", () => { + const scale = Scale.get("C minor pentatonic"); + const fb = frets(["E2", "A2", "D3", "G3", "B3", "E4"], 16, scale); + + // C minor pentatonic intervals: 1P, 3m, 4P, 5P, 7m + const expectedIntervals = ["1P", "3m", "4P", "5P", "7m"]; + + for (const string of fb.strings) { + for (const note of string) { + if (note.interval) { + expect(expectedIntervals).toContain(note.interval); + } + } + } + }); + }); + + describe("pentatonic position assignment", () => { + it("assigns positions to all pentatonic scale notes", () => { + const scale = Scale.get("A minor pentatonic"); + const fb = frets(["E2", "A2", "D3", "G3", "B3", "E4"], 16, scale); + + for (const string of fb.strings) { + for (const note of string) { + if (note.interval) { + expect(note.positions.CAGED).toBeDefined(); + expect(Array.isArray(note.positions.CAGED)).toBe(true); + expect(note.positions.CAGED.length).toBeGreaterThan(0); + } + } + } + }); + + it("assigns positions between 1 and 5 for pentatonic", () => { + const scale = Scale.get("G major pentatonic"); + const fb = frets(["E2", "A2", "D3", "G3", "B3", "E4"], 16, scale); + + for (const string of fb.strings) { + for (const note of string) { + if (note.positions.CAGED?.length > 0) { + for (const pos of note.positions.CAGED) { + expect(pos).toBeGreaterThanOrEqual(1); + expect(pos).toBeLessThanOrEqual(5); + } + } + } + } + }); + + it("does not assign positions to non-scale notes for pentatonic", () => { + const scale = Scale.get("E minor pentatonic"); + const fb = frets(["E2", "A2", "D3", "G3", "B3", "E4"], 16, scale); + const scaleNotes = scale.notes.map((n) => n.replace(/[0-9]/g, "")); + + for (const string of fb.strings) { + for (const note of string) { + if (!scaleNotes.includes(note.note.pc)) { + expect( + note.positions.CAGED === undefined || + note.positions.CAGED.length === 0 + ).toBe(true); + } + } + } + }); + }); }); diff --git a/src/frets/system/patterns.js b/src/frets/system/patterns.js index 1ab6087..4ba0da0 100644 --- a/src/frets/system/patterns.js +++ b/src/frets/system/patterns.js @@ -6,46 +6,47 @@ // Pentatonic patterns (2 notes per string) // Used by both Pentatonic and CAGED position systems for 5-note scales +// Scale degrees are 1-6 export const pentatonicPatterns = { C: [ - [4, 5], // High E string: 5th, m7/6th - [2, 3], // B string: 2nd, 4th - [5, 1], // G string: m7/6th, root - [3, 4], // D string: 4th, 5th - [1, 2], // A string: root, 2nd - [4, 5], // Low E string: 5th, m7/6th + [3, 5], + [1, 2], + [5, 6], + [2, 3], + [6, 1], + [3, 5], ], A: [ - [5, 1], // High E string: m7/6th, root - [3, 4], // B string: 4th, 5th - [1, 2], // G string: root, 2nd - [4, 5], // D string: 5th, m7/6th - [2, 3], // A string: 2nd, 4th - [5, 1], // Low E string: m7/6th, root + [5, 6], + [2, 3], + [6, 1], + [3, 5], + [1, 2], + [5, 6], ], G: [ - [1, 2], // High E string: root, 2nd - [4, 5], // B string: 5th, m7/6th - [2, 3], // G string: 2nd, 4th - [5, 1], // D string: m7/6th, root - [3, 4], // A string: 4th, 5th - [1, 2], // Low E string: root, 2nd + [6, 1], + [3, 5], + [1, 2], + [5, 6], + [2, 3], + [6, 1], ], E: [ - [2, 3], // High E string: 2nd, 4th - [5, 1], // B string: m7/6th, root - [3, 4], // G string: 4th, 5th - [1, 2], // D string: root, 2nd - [4, 5], // A string: 5th, m7/6th - [2, 3], // Low E string: 2nd, 4th + [1, 2], + [5, 6], + [2, 3], + [6, 1], + [3, 5], + [1, 2], ], D: [ - [3, 4], // High E string: 4th, 5th - [1, 2], // B string: root, 2nd - [4, 5], // G string: 5th, m7/6th - [2, 3], // D string: 2nd, 4th - [5, 1], // A string: m7/6th, root - [3, 4], // Low E string: 4th, 5th + [2, 3], + [6, 1], + [3, 5], + [1, 2], + [5, 6], + [2, 3], ], }; diff --git a/src/frets/system/pentatonic.js b/src/frets/system/pentatonic.js index 5227ad9..83ae0d6 100644 --- a/src/frets/system/pentatonic.js +++ b/src/frets/system/pentatonic.js @@ -2,58 +2,56 @@ import { pentatonicPatterns, pentatonicPositionMapping } from "./patterns.js"; import { Note } from "tonal"; +// Helper to rotate 1-indexed degrees (1-7) in mod 7 +function rotateDegree(degree, offset) { + return ((degree - 1 + offset) % 7) + 1; +} + export default function pentatonic(strings, scale) { if (scale.intervals.length !== 5) throw new Error("Does not appear to be a pentatonic scale"); const intervals = scale.intervals; const snotes = scale.notes; - const scaleNotes = snotes.map((noteName, i) => { - const noteObj = Note.get(noteName); - return { - note: noteObj, - interval: intervals[i], - name: noteName, - }; - }); - - // Pentatonic positions use CAGED shape naming: Position 1=G, 2=E, 3=D, 4=C, 5=A - // - // For minor pentatonic (e.g., C minor): - // Position 1 (G shape) starts on the root (C at fret 8) - // - // For major pentatonic (e.g., C major): - // Position 1 (G shape) starts on the 6th (A at fret 5) - // - // This means C major and A minor (which are relative and share notes) - // have their Position 1 patterns at different fret locations // Determine if this is a major or minor pentatonic scale // Minor pentatonic: 1P 3m 4P 5P 7m // Major pentatonic: 1P 2M 3M 5P 6M - const isMajor = intervals.includes("3M"); + const isMinor = intervals.includes("3m"); + + // Map pentatonic scale notes to their 7-degree equivalents + // Major pentatonic uses degrees: 1, 2, 3, 5, 6 + // Minor pentatonic uses degrees: 1, 3, 4, 5, 7 + const majorDegrees = [1, 2, 3, 5, 6]; + const minorDegrees = [1, 3, 4, 5, 7]; + const degreeMapping = isMinor ? minorDegrees : majorDegrees; - // For major pentatonic, we need to rotate the pattern degrees - // This is because C major Position 1 should start at the 6th degree (A), - // while C minor Position 1 starts at the root (C) - // The offset is 4 positions forward (or 1 position backward) in the pentatonic scale - const patternOffset = isMajor ? 4 : 0; + // Build a map from note chroma to its 7-degree scale degree + const chromaToScaleDegree = new Map(); + const chromaToInterval = new Map(); - // Helper to rotate 1-indexed degrees (1-5) - const rotateDegree = (degree, offset) => ((degree - 1 + offset) % 5) + 1; + snotes.forEach((noteName, index) => { + const noteObj = Note.get(noteName); + chromaToScaleDegree.set(noteObj.chroma, degreeMapping[index]); + chromaToInterval.set(noteObj.chroma, intervals[index]); + }); + // For minor pentatonic, rotate the pattern degrees by +2 (same as diatonic) + // This aligns the minor patterns correctly with the major-based pattern definitions const pentatonicShapes = Object.entries(pentatonicPositionMapping).map( ([pos, shape]) => { - const basePattern = pentatonicPatterns[shape]; - // Rotate the pattern degrees for major scales - const rotatedPattern = basePattern.map((degreeArr) => - degreeArr.map((degree) => rotateDegree(degree, patternOffset)) - ); + let pattern = pentatonicPatterns[shape]; + + if (isMinor) { + pattern = pattern.map((stringDegrees) => + stringDegrees.map((degree) => rotateDegree(degree, 2)) + ); + } return { position: parseInt(pos), shape: shape, - pattern: rotatedPattern, + pattern: pattern, }; } ); @@ -62,29 +60,23 @@ export default function pentatonic(strings, scale) { const str = strings[stringIndex]; for (const semitone of str) { - const scaleNote = scaleNotes.find( - (sn) => sn.note.chroma === semitone.note.chroma - ); - - if (scaleNote) { - const positions = []; - // Scale degree is 1-indexed (1-5) - const scaleDegree = - scaleNotes.findIndex( - (sn) => sn.note.chroma === semitone.note.chroma - ) + 1; - - for (const shape of pentatonicShapes) { - const stringPattern = shape.pattern[stringIndex]; - - if (stringPattern.includes(scaleDegree)) { - positions.push(shape.position); - } - } + const chroma = semitone.note.chroma; + + if (!chromaToScaleDegree.has(chroma)) continue; - semitone.positions.Pentatonic = [...new Set(positions)].sort(); - semitone.interval = scaleNote.interval; + const scaleDegree = chromaToScaleDegree.get(chroma); + const positions = []; + + for (const shape of pentatonicShapes) { + const stringPattern = shape.pattern[stringIndex]; + + if (stringPattern.includes(scaleDegree)) { + positions.push(shape.position); + } } + + semitone.positions.Pentatonic = [...new Set(positions)].sort(); + semitone.interval = chromaToInterval.get(chroma); } }