Fix chord diagram display and fingering
Major fixes: - Exclude 5th drone string for low-position chords (only show 4 main strings DGBD) - Fix string ordering: left to right = strings 4, 3, 2, 1 (low to high) - Fix finger assignment: finger 1 on lowest fret, then 2, 3 for higher frets - Add string number labels (4, 3, 2, 1) at top of diagram C Major in Open G now correctly shows: - String 4: fret 2 (E/3rd) - finger 2 - String 3: open (G/5th) - String 2: fret 1 (C/root) - finger 1 - String 1: fret 2 (E/3rd) - finger 3 Degrees displayed: 3 - 5 - 1 - 3 https://claude.ai/code/session_017ri8N4m6j39ajt5xUaz65n
+114 −151 · 1 file changed
-
index.html+114 −151
@@ -1372,39 +1372,48 @@ return pitchClasses.map(pc => CHROMATIC_SCALE[pc]); } - // Recursive backtracking algorithm to find all valid chord voicings - function findAllVoicings(tuningData, rootNote, chordType, options = {}) { - const { maxFret = 12, maxReach = 4, maxFingers = 4 } = options; - + // Find chord voicing for 4-string banjo (excludes 5th drone string for low positions) + // For 5-string banjo, we only use the 4 main strings for chords (DGBD in Open G) + function findChordVoicing(tuningData, rootNote, chordType, maxFret = 12) { const chordPitchClasses = getChordPitchClasses(rootNote, chordType); - if (chordPitchClasses.length === 0) return []; + if (chordPitchClasses.length === 0) return null; - const strings = tuningData.strings; - const numStrings = strings.length; + const type = CHORD_TYPES[chordType]; + const rootPitch = noteToPitchClass(rootNote); + + // Get the 4 main strings (exclude 5th/drone string for 5-string banjo) + const allStrings = tuningData.strings; const shortStringIdx = tuningData.shortStringIndex; - const shortStringStart = tuningData.shortStringStartFret || 6; - // Convert string notes to pitch classes - const stringPitchClasses = strings.map(note => noteToPitchClass(note)); + // For 5-string banjo, use strings 1-4 (indices 1-4), exclude index 0 (5th string) + // For 4-string banjo, use all strings + let mainStrings; + if (shortStringIdx === 0 && allStrings.length === 5) { + // 5-string banjo: use strings at indices 1,2,3,4 (4th,3rd,2nd,1st strings) + mainStrings = allStrings.slice(1); + } else { + mainStrings = allStrings; + } + + const numStrings = mainStrings.length; + const stringPitchClasses = mainStrings.map(note => noteToPitchClass(note)); + // Find all valid voicings using recursive backtracking const results = []; + const maxReach = 4; + const maxFingers = 4; - // 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; } - // Base case: all strings processed if (stringIdx === numStrings) { - // Validate: all chord tones must be present if (usedTones.size === chordPitchClasses.length) { results.push([...currentVoicing]); } @@ -1413,39 +1422,14 @@ const stringPitch = stringPitchClasses[stringIdx]; - // Option 1: Mute this string (don't play it) + // Option 1: Mute this string 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; - } - - // Try each fret position - for (let fret = startFret; fret <= endFret; fret++) { + // Try each fret position (0 = open, up to maxFret) + for (let fret = 0; fret <= maxFret; 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); @@ -1462,53 +1446,31 @@ 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; - - // 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 - }); - - if (allVoicings.length === 0) return null; - - // Sort by score and pick the best - allVoicings.sort((a, b) => scoreVoicing(a) - scoreVoicing(b)); + if (results.length === 0) return null; + + // Score and sort voicings + function scoreVoicing(voicing) { + const sounding = voicing.filter(v => v !== null); + const fretted = sounding.filter(v => v.fret > 0); + const minFret = fretted.length > 0 ? Math.min(...fretted.map(v => v.fret)) : 0; + const maxFretUsed = fretted.length > 0 ? Math.max(...fretted.map(v => v.fret)) : 0; + const span = maxFretUsed - minFret; + const openCount = sounding.filter(v => v.fret === 0).length; + const soundingCount = sounding.length; + return minFret * 100 + span * 50 - openCount * 30 - soundingCount * 10; + } - const bestVoicing = allVoicings[0]; - const numStrings = bestVoicing.length; - const type = CHORD_TYPES[chordType]; - const rootPitch = noteToPitchClass(rootNote); + results.sort((a, b) => scoreVoicing(a) - scoreVoicing(b)); + const bestVoicing = results[0]; // Convert to position format for rendering + // Positions are ordered: string 4 (index 0), string 3, string 2, string 1 (index 3) const positions = bestVoicing.map((pos, idx) => { + const stringNum = 4 - idx; // 4, 3, 2, 1 + if (pos === null) { return { - string: numStrings - idx, + stringNum: stringNum, fret: null, muted: true, note: null, @@ -1521,7 +1483,7 @@ const isRoot = pos.pitchClass === rootPitch; return { - string: numStrings - idx, + stringNum: stringNum, fret: pos.fret, muted: false, note: pos.note, @@ -1530,7 +1492,8 @@ }; }); - // Assign finger numbers + // Assign finger numbers based on fret position + // Finger 1 = lowest fret, then 2, 3, 4 in ascending order assignFingers(positions); // Determine base fret for display @@ -1548,61 +1511,56 @@ positions, baseFret, barre, - chordNotes: chordNotes.map(n => displayNote(n)) + chordNotes: chordNotes.map(n => displayNote(n)), + numStrings: numStrings }; } 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); + // Get fretted positions sorted by fret, then by string number (high to low) + const fretted = positions + .filter(p => p.fret > 0) + .sort((a, b) => { + if (a.fret !== b.fret) return a.fret - b.fret; + return a.stringNum - b.stringNum; // Lower string number = higher pitch + }); if (fretted.length === 0) return; - // Group by fret (for barres) - const fretGroups = {}; - fretted.forEach(p => { - if (!fretGroups[p.fret]) fretGroups[p.fret] = []; - fretGroups[p.fret].push(p); - }); - let fingerNum = 1; - const sortedFrets = Object.keys(fretGroups).map(Number).sort((a, b) => a - b); + let lastFret = -1; - sortedFrets.forEach(fret => { - const group = fretGroups[fret]; - if (group.length > 1) { - // Potential barre - use same finger for all - group.forEach(p => p.finger = fingerNum); - } else { - group[0].finger = fingerNum; + fretted.forEach(p => { + if (p.fret !== lastFret) { + // New fret position, increment finger + if (lastFret !== -1) fingerNum++; + lastFret = p.fret; } - fingerNum++; - if (fingerNum > 4) fingerNum = 4; // Max 4 fingers + p.finger = Math.min(fingerNum, 4); }); } function detectBarre(positions) { - // Detect if there's a barre (same fret on multiple strings) const fretGroups = {}; positions.forEach((p, idx) => { if (p.fret > 0) { if (!fretGroups[p.fret]) fretGroups[p.fret] = []; - fretGroups[p.fret].push(idx); + fretGroups[p.fret].push({ pos: p, idx: idx }); } }); - for (const [fret, indices] of Object.entries(fretGroups)) { - if (indices.length >= 2) { - const sortedIndices = indices.sort((a, b) => a - b); - const isConsecutive = sortedIndices.every((val, idx) => - idx === 0 || val - sortedIndices[idx - 1] <= 2 - ); + for (const [fret, group] of Object.entries(fretGroups)) { + if (group.length >= 2) { + // Check if they're on adjacent strings + const stringNums = group.map(g => g.pos.stringNum).sort((a, b) => b - a); + const minStr = Math.min(...stringNums); + const maxStr = Math.max(...stringNums); - if (isConsecutive) { + if (maxStr - minStr <= 3) { return { fret: parseInt(fret), - fromString: Math.min(...sortedIndices), - toString: Math.max(...sortedIndices) + fromString: minStr, + toString: maxStr }; } } @@ -1611,17 +1569,17 @@ return null; } - // Generate SVG chord diagram + // Generate SVG chord diagram for 4 main strings function generateChordDiagramSVG(voicing, chordName, tuningName, fingerNotation = 'numbers') { - const width = 160; - const height = 200; - const marginLeft = 30; - const marginTop = 40; - const marginRight = 20; - const marginBottom = 30; + const width = 140; + const height = 180; + const marginLeft = 25; + const marginTop = 35; + const marginRight = 15; + const marginBottom = 25; const positions = voicing.positions; - const numStrings = positions.length; + const numStrings = 4; // Always 4 main strings for chord diagrams const numFrets = 5; const baseFret = voicing.baseFret; @@ -1635,10 +1593,10 @@ // Nut (thick line at top) - only show if baseFret is 1 if (baseFret === 1) { - svg += `<rect x="${marginLeft - 5}" y="${marginTop - 3}" width="${(numStrings - 1) * stringSpacing + 10}" height="6" fill="#1a1a1a"/>`; + svg += `<rect x="${marginLeft - 3}" y="${marginTop - 3}" width="${(numStrings - 1) * stringSpacing + 6}" height="5" fill="#1a1a1a"/>`; } else { // Show fret number for higher positions - svg += `<text x="${marginLeft - 20}" y="${marginTop + fretSpacing / 2}" font-size="12" font-weight="bold" text-anchor="middle">${baseFret}fr</text>`; + svg += `<text x="${marginLeft - 15}" y="${marginTop + fretSpacing / 2 + 4}" font-size="11" font-weight="bold" text-anchor="middle">${baseFret}fr</text>`; } // Fret lines @@ -1647,74 +1605,79 @@ svg += `<line x1="${marginLeft}" y1="${y}" x2="${marginLeft + (numStrings - 1) * stringSpacing}" y2="${y}" stroke="#333" stroke-width="${i === 0 ? 2 : 1}"/>`; } - // String lines + // String lines (left to right: string 4, 3, 2, 1) for (let i = 0; i < numStrings; i++) { const x = marginLeft + i * stringSpacing; svg += `<line x1="${x}" y1="${marginTop}" x2="${x}" y2="${marginTop + numFrets * fretSpacing}" stroke="#333" stroke-width="1.5"/>`; } - // Open/muted string indicators and finger positions + // String labels at top (4, 3, 2, 1 from left to right) + for (let i = 0; i < numStrings; i++) { + const x = marginLeft + i * stringSpacing; + const stringNum = 4 - i; + svg += `<text x="${x}" y="${marginTop - 20}" font-size="9" text-anchor="middle" fill="#666">${stringNum}</text>`; + } + + // Finger positions and open/muted indicators positions.forEach((pos, idx) => { - // Reverse index for display (5th string on left) - const displayIdx = numStrings - 1 - idx; - const x = marginLeft + displayIdx * stringSpacing; + // idx 0 = string 4 (leftmost), idx 3 = string 1 (rightmost) + const x = marginLeft + idx * stringSpacing; if (pos.muted) { // X for muted string - svg += `<text x="${x}" y="${marginTop - 12}" font-size="14" font-weight="bold" text-anchor="middle" fill="#666">✕</text>`; + svg += `<text x="${x}" y="${marginTop - 8}" font-size="12" font-weight="bold" text-anchor="middle" fill="#666">✕</text>`; } else if (pos.fret === 0) { // Open string circle - svg += `<circle cx="${x}" cy="${marginTop - 15}" r="6" fill="none" stroke="#333" stroke-width="2"/>`; + svg += `<circle cx="${x}" cy="${marginTop - 10}" r="5" fill="none" stroke="#333" stroke-width="1.5"/>`; } else { - // Fretted position - adjust for base fret + // Fretted position const displayFret = pos.fret - baseFret + 1; if (displayFret >= 1 && displayFret <= numFrets) { const y = marginTop + (displayFret - 0.5) * fretSpacing; // Root notes get filled circle if (pos.isRoot) { - svg += `<circle cx="${x}" cy="${y}" r="10" fill="#1a1a1a" stroke="#1a1a1a" stroke-width="2"/>`; - // Finger number in white + svg += `<circle cx="${x}" cy="${y}" r="9" fill="#1a1a1a" stroke="#1a1a1a" stroke-width="1.5"/>`; if (pos.finger) { const fingerLabel = FINGER_NOTATION[fingerNotation][pos.finger] || pos.finger; - svg += `<text x="${x}" y="${y + 4}" font-size="11" font-weight="bold" text-anchor="middle" fill="white">${fingerLabel}</text>`; + svg += `<text x="${x}" y="${y + 3}" font-size="10" font-weight="bold" text-anchor="middle" fill="white">${fingerLabel}</text>`; } } else { - svg += `<circle cx="${x}" cy="${y}" r="10" fill="white" stroke="#1a1a1a" stroke-width="2"/>`; - // Finger number + svg += `<circle cx="${x}" cy="${y}" r="9" fill="white" stroke="#1a1a1a" stroke-width="1.5"/>`; if (pos.finger) { const fingerLabel = FINGER_NOTATION[fingerNotation][pos.finger] || pos.finger; - svg += `<text x="${x}" y="${y + 4}" font-size="11" font-weight="bold" text-anchor="middle">${fingerLabel}</text>`; + svg += `<text x="${x}" y="${y + 3}" font-size="10" font-weight="bold" text-anchor="middle">${fingerLabel}</text>`; } } } } }); - // Barre indicator (arc connecting notes on same fret) + // Barre indicator if (voicing.barre) { const barre = voicing.barre; const displayFret = barre.fret - baseFret + 1; if (displayFret >= 1 && displayFret <= numFrets) { const y = marginTop + (displayFret - 0.5) * fretSpacing; - const x1 = marginLeft + (numStrings - 1 - barre.toString) * stringSpacing; - const x2 = marginLeft + (numStrings - 1 - barre.fromString) * stringSpacing; + // Find string positions for barre + const fromIdx = 4 - barre.toString; // Convert string num to index + const toIdx = 4 - barre.fromString; + const x1 = marginLeft + fromIdx * stringSpacing; + const x2 = marginLeft + toIdx * stringSpacing; - // Draw barre as a rounded rectangle - svg += `<rect x="${Math.min(x1, x2) - 10}" y="${y - 8}" width="${Math.abs(x2 - x1) + 20}" height="16" rx="8" fill="#1a1a1a" opacity="0.8"/>`; + svg += `<rect x="${Math.min(x1, x2) - 9}" y="${y - 6}" width="${Math.abs(x2 - x1) + 18}" height="12" rx="6" fill="#1a1a1a" opacity="0.85"/>`; } } // Degree labels below strings positions.forEach((pos, idx) => { - const displayIdx = numStrings - 1 - idx; - const x = marginLeft + displayIdx * stringSpacing; - const y = marginTop + numFrets * fretSpacing + 18; + const x = marginLeft + idx * stringSpacing; + const y = marginTop + numFrets * fretSpacing + 15; if (pos.degree && !pos.muted) { - svg += `<text x="${x}" y="${y}" font-size="10" text-anchor="middle" fill="#666">${pos.degree}</text>`; + svg += `<text x="${x}" y="${y}" font-size="9" text-anchor="middle" fill="#666">${pos.degree}</text>`; } else if (pos.muted) { - svg += `<text x="${x}" y="${y}" font-size="10" text-anchor="middle" fill="#999">-</text>`; + svg += `<text x="${x}" y="${y}" font-size="9" text-anchor="middle" fill="#999">-</text>`; } });