← banjotuna

Fix chord voicing algorithm with proper recursive backtracking

345f197af6fc9449bcdea3ab96f91ecd74c76906 · Claude · Feb 1, 2026 18:33

Based on analysis of gromgull/chord-finder reference implementation:
- Replace greedy algorithm with recursive backtracking
- Use pitch class calculation: (string_pitch + fret) % 12
- Find all valid voicings, then score by playability
- Properly handle 5th string (can only play open at tuned pitch)
- Score prioritizes: lower frets, open strings, more strings sounding

C major in Open G now correctly shows:
- 4th string fret 2 (E/3rd) - finger 2
- 3rd string open (G/5th)
- 2nd string fret 1 (C/root) - finger 1
- 1st string fret 2 (E/3rd) - finger 3
Intervals: 3-5-1-3

https://claude.ai/code/session_017ri8N4m6j39ajt5xUaz65n

+142 −148 · 1 file changed

  • index.html +142 −148
diff --git a/index.html b/index.html
index fcde5ab..338d19c 100644
--- a/index.html
+++ b/index.html
@@ -1349,96 +1349,176 @@
             pima: { 1: 'I', 2: 'M', 3: 'A', 4: 'P', T: 'P' }
         };
 
-        // Get all notes in a chord
-        function getChordNotes(rootNote, chordType) {
+        // Convert note name to pitch class (0-11)
+        function noteToPitchClass(note) {
+            const normalized = normalizeNote(note);
+            return CHROMATIC_SCALE.indexOf(normalized);
+        }
+
+        // Get chord pitch classes from root and type
+        function getChordPitchClasses(rootNote, chordType) {
             const type = CHORD_TYPES[chordType];
             if (!type) return [];
 
-            const rootIndex = CHROMATIC_SCALE.indexOf(normalizeNote(rootNote));
-            if (rootIndex === -1) return [];
+            const rootPitch = noteToPitchClass(rootNote);
+            if (rootPitch === -1) return [];
 
-            return type.intervals.map(interval => {
-                return CHROMATIC_SCALE[(rootIndex + interval) % 12];
-            });
+            return type.intervals.map(interval => (rootPitch + interval) % 12);
         }
 
-        // Check if a note is in a chord
-        function isNoteInChord(note, chordNotes) {
-            const normalizedNote = normalizeNote(note);
-            return chordNotes.includes(normalizedNote);
+        // Get all notes in a chord (as note names)
+        function getChordNotes(rootNote, chordType) {
+            const pitchClasses = getChordPitchClasses(rootNote, chordType);
+            return pitchClasses.map(pc => CHROMATIC_SCALE[pc]);
         }
 
-        // Get the degree of a note in a chord
-        function getNoteDegree(note, rootNote, chordType) {
-            const type = CHORD_TYPES[chordType];
-            if (!type) return null;
-
-            const normalizedNote = normalizeNote(note);
-            const rootIndex = CHROMATIC_SCALE.indexOf(normalizeNote(rootNote));
-            const noteIndex = CHROMATIC_SCALE.indexOf(normalizedNote);
-
-            if (rootIndex === -1 || noteIndex === -1) return null;
+        // Recursive backtracking algorithm to find all valid chord voicings
+        function findAllVoicings(tuningData, rootNote, chordType, options = {}) {
+            const { maxFret = 12, maxReach = 4, maxFingers = 4 } = options;
 
-            const interval = (noteIndex - rootIndex + 12) % 12;
-            const degreeIndex = type.intervals.indexOf(interval);
-
-            if (degreeIndex === -1) return null;
-            return type.degrees[degreeIndex];
-        }
-
-        // Find chord voicing for a given tuning
-        function findChordVoicing(tuningData, rootNote, chordType, maxFret = 5) {
-            const chordNotes = getChordNotes(rootNote, chordType);
-            if (chordNotes.length === 0) return null;
+            const chordPitchClasses = getChordPitchClasses(rootNote, chordType);
+            if (chordPitchClasses.length === 0) return [];
 
             const strings = tuningData.strings;
             const numStrings = strings.length;
             const shortStringIdx = tuningData.shortStringIndex;
             const shortStringStart = tuningData.shortStringStartFret || 6;
 
-            // Find playable positions for each string
-            const stringOptions = strings.map((openNote, stringIdx) => {
-                const options = [];
-                const normalizedOpen = normalizeNote(openNote);
+            // Convert string notes to pitch classes
+            const stringPitchClasses = strings.map(note => noteToPitchClass(note));
+
+            const results = [];
+
+            // Recursive function to explore voicings
+            function explore(stringIdx, currentVoicing, usedTones) {
+                // Count non-open fretted positions (fingers needed)
+                const fingersUsed = currentVoicing.filter(v => v && v.fret > 0).length;
+                if (fingersUsed > maxFingers) return;
+
+                // Check reach constraint
+                const frettedFrets = currentVoicing.filter(v => v && v.fret > 0).map(v => v.fret);
+                if (frettedFrets.length > 1) {
+                    const reach = Math.max(...frettedFrets) - Math.min(...frettedFrets);
+                    if (reach > maxReach) return;
+                }
 
-                // Check open string
-                if (isNoteInChord(normalizedOpen, chordNotes)) {
-                    // For short string (5th string), it can only be played open if chord position allows
-                    if (stringIdx !== shortStringIdx || shortStringIdx === null) {
-                        options.push({ fret: 0, note: normalizedOpen });
+                // Base case: all strings processed
+                if (stringIdx === numStrings) {
+                    // Validate: all chord tones must be present
+                    if (usedTones.size === chordPitchClasses.length) {
+                        results.push([...currentVoicing]);
                     }
+                    return;
                 }
 
-                // Check fretted positions
-                for (let fret = 1; fret <= maxFret; fret++) {
-                    // Skip frets before short string starts
-                    if (stringIdx === shortStringIdx && fret < shortStringStart) {
-                        continue;
+                const stringPitch = stringPitchClasses[stringIdx];
+
+                // Option 1: Mute this string (don't play it)
+                explore(stringIdx + 1, [...currentVoicing, null], new Set(usedTones));
+
+                // Determine fret range for this string
+                let startFret = 0;
+                let endFret = maxFret;
+
+                // Handle 5th string (short string) - can only play open or from startFret onward
+                if (stringIdx === shortStringIdx && shortStringStart !== null) {
+                    // For simplicity, treat the 5th string as only playable open at its tuned pitch
+                    // (since it starts at fret 5/6, low fret voicings can't use it)
+                    const openPitch = stringPitch;
+                    const toneIndex = chordPitchClasses.indexOf(openPitch);
+                    if (toneIndex !== -1) {
+                        const newUsedTones = new Set(usedTones);
+                        newUsedTones.add(openPitch);
+                        explore(stringIdx + 1, [...currentVoicing, {
+                            fret: 0,
+                            pitchClass: openPitch,
+                            toneIndex: toneIndex,
+                            note: CHROMATIC_SCALE[openPitch]
+                        }], newUsedTones);
                     }
+                    // Also try muting (already handled above)
+                    return;
+                }
 
-                    const frettedNote = getNoteAtFret(openNote, fret);
-                    if (isNoteInChord(frettedNote, chordNotes)) {
-                        options.push({ fret, note: frettedNote });
+                // Try each fret position
+                for (let fret = startFret; fret <= endFret; fret++) {
+                    const pitchAtFret = (stringPitch + fret) % 12;
+                    const toneIndex = chordPitchClasses.indexOf(pitchAtFret);
+
+                    // Only use this fret if it produces a chord tone
+                    if (toneIndex !== -1) {
+                        const newUsedTones = new Set(usedTones);
+                        newUsedTones.add(pitchAtFret);
+
+                        explore(stringIdx + 1, [...currentVoicing, {
+                            fret: fret,
+                            pitchClass: pitchAtFret,
+                            toneIndex: toneIndex,
+                            note: CHROMATIC_SCALE[pitchAtFret]
+                        }], newUsedTones);
                     }
                 }
+            }
+
+            explore(0, [], new Set());
+
+            return results;
+        }
+
+        // Score and sort voicings by playability
+        function scoreVoicing(voicing) {
+            const sounding = voicing.filter(v => v !== null);
+            const fretted = sounding.filter(v => v.fret > 0);
+
+            // Prefer voicings with:
+            // 1. Lower minimum fret (more accessible)
+            // 2. More open strings (easier to play)
+            // 3. More strings sounding (fuller sound)
+            // 4. Smaller fret span (easier reach)
+
+            const minFret = fretted.length > 0 ? Math.min(...fretted.map(v => v.fret)) : 0;
+            const maxFret = fretted.length > 0 ? Math.max(...fretted.map(v => v.fret)) : 0;
+            const span = maxFret - minFret;
+            const openCount = sounding.filter(v => v.fret === 0).length;
+            const soundingCount = sounding.length;
 
-                return options;
+            // Lower score is better
+            return minFret * 100 + span * 50 - openCount * 30 - soundingCount * 10;
+        }
+
+        // Find the best chord voicing for a given tuning
+        function findChordVoicing(tuningData, rootNote, chordType, maxFret = 12) {
+            const allVoicings = findAllVoicings(tuningData, rootNote, chordType, {
+                maxFret: maxFret,
+                maxReach: 4,
+                maxFingers: 4
             });
 
-            // Find the best voicing using a simple algorithm
-            // Prioritize: 1) Lower frets, 2) More open strings, 3) Smaller stretch
-            const voicing = findBestVoicing(stringOptions, chordNotes, rootNote, numStrings, shortStringIdx);
+            if (allVoicings.length === 0) return null;
 
-            if (!voicing) return null;
+            // Sort by score and pick the best
+            allVoicings.sort((a, b) => scoreVoicing(a) - scoreVoicing(b));
+
+            const bestVoicing = allVoicings[0];
+            const numStrings = bestVoicing.length;
+            const type = CHORD_TYPES[chordType];
+            const rootPitch = noteToPitchClass(rootNote);
 
-            // Assign fingers based on fret positions
-            const positions = voicing.map((pos, idx) => {
+            // Convert to position format for rendering
+            const positions = bestVoicing.map((pos, idx) => {
                 if (pos === null) {
-                    return { string: numStrings - idx, fret: null, muted: true, note: null, degree: null };
+                    return {
+                        string: numStrings - idx,
+                        fret: null,
+                        muted: true,
+                        note: null,
+                        degree: null,
+                        isRoot: false
+                    };
                 }
 
-                const degree = getNoteDegree(pos.note, rootNote, chordType);
-                const isRoot = normalizeNote(pos.note) === normalizeNote(rootNote);
+                const degree = type.degrees[pos.toneIndex];
+                const isRoot = pos.pitchClass === rootPitch;
 
                 return {
                     string: numStrings - idx,
@@ -1456,12 +1536,14 @@
             // Determine base fret for display
             const frettedPositions = positions.filter(p => p.fret > 0);
             const minFret = frettedPositions.length > 0 ? Math.min(...frettedPositions.map(p => p.fret)) : 1;
-            const maxFretUsed = frettedPositions.length > 0 ? Math.max(...frettedPositions.map(p => p.fret)) : 1;
             const baseFret = minFret > 4 ? minFret : 1;
 
             // Check for barre chord
             const barre = detectBarre(positions);
 
+            // Get chord notes for display
+            const chordNotes = getChordNotes(rootNote, chordType);
+
             return {
                 positions,
                 baseFret,
@@ -1470,94 +1552,6 @@
             };
         }
 
-        function findBestVoicing(stringOptions, chordNotes, rootNote, numStrings, shortStringIdx) {
-            // Simple greedy algorithm to find a playable voicing
-            // Try to include all chord notes while minimizing stretch
-
-            const voicing = new Array(numStrings).fill(null);
-            const usedNotes = new Set();
-
-            // First pass: find positions for each string preferring open strings
-            for (let i = 0; i < numStrings; i++) {
-                const options = stringOptions[i];
-                if (options.length === 0) {
-                    voicing[i] = null; // Will be muted
-                    continue;
-                }
-
-                // Prefer open strings, then lower frets
-                const sorted = [...options].sort((a, b) => a.fret - b.fret);
-
-                // Prefer root note if available
-                const rootOption = sorted.find(opt => normalizeNote(opt.note) === normalizeNote(rootNote));
-                if (rootOption && !usedNotes.has(rootOption.note + '-' + i)) {
-                    voicing[i] = rootOption;
-                    usedNotes.add(rootOption.note);
-                } else {
-                    voicing[i] = sorted[0];
-                    usedNotes.add(sorted[0].note);
-                }
-            }
-
-            // Validate: check if all chord notes are present
-            const presentNotes = new Set(voicing.filter(v => v !== null).map(v => v.note));
-            const allNotesPresent = chordNotes.every(note => presentNotes.has(note));
-
-            // Check stretch (max fret distance between fingers)
-            const frettedFrets = voicing.filter(v => v !== null && v.fret > 0).map(v => v.fret);
-            const stretch = frettedFrets.length > 1 ? Math.max(...frettedFrets) - Math.min(...frettedFrets) : 0;
-
-            // If stretch is too large (> 4 frets) or not all notes present, try alternative
-            if (stretch > 4 || !allNotesPresent) {
-                // Try to find a better voicing with barres or different positions
-                return findAlternativeVoicing(stringOptions, chordNotes, rootNote, numStrings);
-            }
-
-            return voicing;
-        }
-
-        function findAlternativeVoicing(stringOptions, chordNotes, rootNote, numStrings) {
-            // Try different starting frets for barre chords
-            for (let startFret = 1; startFret <= 7; startFret++) {
-                const voicing = new Array(numStrings).fill(null);
-
-                for (let i = 0; i < numStrings; i++) {
-                    const options = stringOptions[i].filter(opt =>
-                        opt.fret === 0 || (opt.fret >= startFret && opt.fret <= startFret + 4)
-                    );
-
-                    if (options.length > 0) {
-                        // Prefer options closer to startFret
-                        const sorted = [...options].sort((a, b) => {
-                            if (a.fret === 0 && b.fret === 0) return 0;
-                            if (a.fret === 0) return 1; // Open strings less preferred in barre position
-                            if (b.fret === 0) return -1;
-                            return Math.abs(a.fret - startFret) - Math.abs(b.fret - startFret);
-                        });
-                        voicing[i] = sorted[0];
-                    }
-                }
-
-                // Validate
-                const presentNotes = new Set(voicing.filter(v => v !== null).map(v => v.note));
-                const hasRoot = voicing.some(v => v !== null && normalizeNote(v.note) === normalizeNote(rootNote));
-                const hasEnoughNotes = presentNotes.size >= Math.min(3, chordNotes.length);
-
-                if (hasRoot && hasEnoughNotes) {
-                    return voicing;
-                }
-            }
-
-            // Fallback: return whatever we can find
-            const voicing = new Array(numStrings).fill(null);
-            for (let i = 0; i < numStrings; i++) {
-                if (stringOptions[i].length > 0) {
-                    voicing[i] = stringOptions[i][0];
-                }
-            }
-            return voicing;
-        }
-
         function assignFingers(positions) {
             // Assign finger numbers based on fret positions
             const fretted = positions.filter(p => p.fret > 0).sort((a, b) => a.fret - b.fret);