Extract CSS and JS into separate files
Split the monolithic index.html (3,363 lines) into modular files: CSS (css/): - styles.css: 1,112 lines of styling JavaScript (js/): - utils.js: Chromatic scale constants and note utility functions - tunings.js: TUNINGS_DATA for all 24 banjo tunings - chords.js: Chord data, voicing algorithm, diagram generation - fretboard.js: Note formatting, inversions, fretboard SVG generator - app.js: State management, UI event handlers, initialization index.html reduced from 3,363 to 343 lines (HTML structure only) Still works with file:// protocol - no web server required. https://claude.ai/code/session_01VSHtwaMJKL1ZgH33JDcwbY
+3037 −3027 · 7 files changed
-
css/styles.css+1112 −0 -
index.html+7 −3027 -
js/app.js+528 −0 -
js/chords.js+386 −0 -
js/fretboard.js+633 −0 -
js/tunings.js+306 −0 -
js/utils.js+65 −0
@@ -0,0 +1,1112 @@ +/* ============================================ + CSS VARIABLES & BASE STYLES + ============================================ */ +:root { + --c-family: #C74242; + --c-family-light: #F8E3E3; + --d-family: #2D7A4F; + --d-family-light: #E3F5E9; + --g-family: #2E5C8A; + --g-family-light: #E3EEF8; + --other-family: #7B5BA6; + --other-family-light: #F0E8F5; + --text-primary: #1a1a1a; + --text-secondary: #444; + --text-muted: #666; + --bg-dark: #1a1a1a; + --bg-light: #f5f5f5; + --border-color: #ddd; +} + +* { + box-sizing: border-box; +} + +body { + font-family: 'Helvetica Neue', Arial, sans-serif; + margin: 0; + padding: 0; + background: #f0f0f0; + color: var(--text-primary); + line-height: 1.5; +} + +/* ============================================ + PRINT STYLES + ============================================ */ +@page { + size: A4 portrait; + margin: 12mm; +} + +/* Landscape orientation for chord grid view */ +@page chord-grid-landscape { + size: A4 landscape; + margin: 10mm; +} + +@media print { + body { + background: white; + } + + .no-print { + display: none !important; + } + + .print-only { + display: block !important; + } + + .page-break { + page-break-after: always; + } + + .page-break-before { + page-break-before: always; + } + + .tuning-card { + break-inside: avoid; + } + + .app-container { + max-width: 100%; + padding: 0; + } + + .tunings-grid { + grid-template-columns: 1fr !important; + } + + /* Chord grid landscape printing */ + .chord-keys-grid-container { + page: chord-grid-landscape; + } + + /* Table header repetition on each page */ + .chord-keys-grid thead { + display: table-header-group; + } + + .chord-keys-grid tbody { + display: table-row-group; + } + + /* Force page break before F#/Gb row */ + .chord-keys-grid tr.page-break-before { + page-break-before: always; + } + + /* Keep key rows together */ + .chord-keys-grid tr { + page-break-inside: avoid; + } +} + +.print-only { + display: none; +} + +/* ============================================ + LAYOUT + ============================================ */ +.app-container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +/* ============================================ + HEADER + ============================================ */ +.app-header { + text-align: center; + border: 3px solid #000; + padding: 20px; + margin-bottom: 20px; + background: var(--bg-light); + border-radius: 8px; +} + +.app-header h1 { + margin: 0 0 10px 0; + font-size: 28px; + font-weight: bold; + letter-spacing: 0.5px; +} + +.app-header .subtitle { + font-size: 14px; + color: var(--text-secondary); + margin: 5px 0; +} + +/* ============================================ + CONTROL PANEL + ============================================ */ +.control-panel { + background: var(--bg-dark); + color: white; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: center; + justify-content: space-between; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.control-group label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #aaa; +} + +.control-group select, +.control-group input[type="number"] { + padding: 8px 12px; + border: 2px solid #444; + border-radius: 4px; + background: #333; + color: white; + font-size: 14px; + min-width: 150px; +} + +.control-group select:focus, +.control-group input:focus { + outline: none; + border-color: var(--g-family); +} + +/* Toggle Switch */ +.toggle-container { + display: flex; + align-items: center; + gap: 10px; +} + +.toggle-switch { + position: relative; + width: 60px; + height: 30px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #444; + transition: 0.3s; + border-radius: 30px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 22px; + width: 22px; + left: 4px; + bottom: 4px; + background-color: white; + transition: 0.3s; + border-radius: 50%; +} + +.toggle-switch input:checked + .toggle-slider { + background-color: var(--g-family); +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(30px); +} + +.toggle-label { + font-size: 14px; + font-weight: bold; + min-width: 50px; +} + +/* Checkbox toggle for notes */ +.checkbox-container { + display: flex; + align-items: center; + gap: 8px; +} + +.checkbox-container input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.checkbox-container span { + font-size: 14px; +} + +/* Buttons */ +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: var(--g-family); + color: white; +} + +.btn-primary:hover { + background: #1e4a6e; +} + +.btn-secondary { + background: #444; + color: white; +} + +.btn-secondary:hover { + background: #555; +} + +/* ============================================ + PAGE TITLE + ============================================ */ +.page-title { + font-size: 18px; + font-weight: bold; + text-align: center; + padding: 12px; + background: #333; + color: white; + margin-bottom: 20px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 1px; +} + +/* ============================================ + FAMILY INTRO + ============================================ */ +.family-intro { + background: #f0f0f0; + border-left: 5px solid; + padding: 15px; + margin-bottom: 15px; + font-size: 14px; + line-height: 1.6; + border-radius: 0 4px 4px 0; +} + +.family-intro.c-family-intro { border-color: var(--c-family); } +.family-intro.d-family-intro { border-color: var(--d-family); } +.family-intro.g-family-intro { border-color: var(--g-family); } +.family-intro.other-family-intro { border-color: var(--other-family); } + +/* ============================================ + TUNINGS GRID + ============================================ */ +.tunings-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + gap: 20px; + margin-bottom: 20px; +} + +/* ============================================ + TUNING CARD + ============================================ */ +.tuning-card { + border: 3px solid; + border-radius: 8px; + padding: 16px; + background: white; + box-shadow: 0 3px 6px rgba(0,0,0,0.15); + transition: transform 0.2s, box-shadow 0.2s; +} + +.tuning-card:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(0,0,0,0.2); +} + +.tuning-card.c-family { + border-color: var(--c-family); + background: linear-gradient(to bottom, var(--c-family-light) 0%, #FEF8F8 100%); +} + +.tuning-card.d-family { + border-color: var(--d-family); + background: linear-gradient(to bottom, var(--d-family-light) 0%, #F8FEF9 100%); +} + +.tuning-card.g-family { + border-color: var(--g-family); + background: linear-gradient(to bottom, var(--g-family-light) 0%, #F8FBFE 100%); +} + +.tuning-card.other-family { + border-color: var(--other-family); + background: linear-gradient(to bottom, var(--other-family-light) 0%, #FDFBFE 100%); +} + +.tuning-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 8px; + border-bottom: 3px solid; + padding-bottom: 8px; +} + +.c-family .tuning-header { border-color: var(--c-family); } +.d-family .tuning-header { border-color: var(--d-family); } +.g-family .tuning-header { border-color: var(--g-family); } +.other-family .tuning-header { border-color: var(--other-family); } + +.tuning-name { + font-size: 16px; + font-weight: bold; + text-transform: uppercase; +} + +.genre-tag { + font-size: 12px; + font-style: italic; + color: var(--text-muted); +} + +.string-count-badge { + display: inline-block; + background: #666; + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + margin-left: 8px; +} + +.note-sequence { + font-family: 'Courier New', Consolas, monospace; + font-size: 20px; + font-weight: bold; + letter-spacing: 4px; + margin: 12px 0; + text-align: center; + background: white; + padding: 12px 8px; + border-radius: 4px; + border: 2px solid var(--border-color); +} + +.note-sequence sub { + font-size: 0.6em; + vertical-align: sub; +} + +/* Nut Visual */ +.nut-visual { + display: flex; + justify-content: center; + gap: 12px; + margin: 12px 0; +} + +.string-circle { + width: 28px; + height: 28px; + border: 3px solid #333; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: bold; + background: white; +} + +.string-circle.changed { + background: #333; + color: white; +} + +/* Tuning Details */ +.tuning-details { + font-size: 13px; + line-height: 1.7; + color: #333; + margin-top: 10px; +} + +.tuning-details div { + margin: 5px 0; +} + +.delta-line { + font-weight: bold; +} + +.capo-line { + font-weight: bold; + color: #0066cc; +} + +.songs-line { + font-style: italic; + color: #555; +} + +/* Effective Tuning (with capo) */ +.effective-tuning { + background: #fff8e1; + border: 2px solid #ff9800; + padding: 8px; + border-radius: 4px; + margin-top: 10px; + font-size: 12px; +} + +.effective-tuning strong { + color: #e65100; +} + +/* ============================================ + FRETBOARD DIAGRAM + ============================================ */ +.fretboard-container { + margin: 15px 0; + overflow-x: auto; +} + +.fretboard-svg { + display: block; + margin: 0 auto; +} + +/* ============================================ + FRETBOARD PAGE LAYOUT (Side-by-side) + ============================================ */ +.fretboard-layout { + display: flex; + gap: 20px; + align-items: flex-start; +} + +.tuning-selector { + width: 280px; + flex-shrink: 0; + background: white; + border: 2px solid var(--border-color); + border-radius: 8px; + overflow: hidden; +} + +.tuning-selector-header { + background: var(--bg-dark); + color: white; + padding: 12px 15px; + font-weight: bold; + font-size: 14px; +} + +.tuning-selector-filter { + padding: 10px; + border-bottom: 1px solid var(--border-color); + background: #f9f9f9; +} + +.tuning-selector-filter select { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 13px; +} + +.tuning-list { + max-height: 500px; + overflow-y: auto; +} + +.tuning-list-item { + padding: 10px 15px; + cursor: pointer; + border-bottom: 1px solid #eee; + transition: background 0.15s; + font-size: 13px; +} + +.tuning-list-item:hover { + background: #f5f5f5; +} + +.tuning-list-item.selected { + background: var(--g-family-light); + border-left: 4px solid var(--g-family); + font-weight: bold; +} + +.tuning-list-item.c-family.selected { + background: var(--c-family-light); + border-left-color: var(--c-family); +} + +.tuning-list-item.d-family.selected { + background: var(--d-family-light); + border-left-color: var(--d-family); +} + +.tuning-list-item.other-family.selected { + background: var(--other-family-light); + border-left-color: var(--other-family); +} + +.tuning-list-item .tuning-item-name { + display: block; + font-weight: 600; +} + +.tuning-list-item .tuning-item-notes { + display: block; + font-size: 11px; + color: var(--text-muted); + font-family: 'Courier New', monospace; + margin-top: 2px; +} + +.tuning-list-group { + background: #f0f0f0; + padding: 8px 15px; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); +} + +.fretboard-display { + flex: 1; + min-width: 0; +} + +@media print { + .tuning-selector { + display: none !important; + } + + .fretboard-layout { + display: block; + } + + .fretboard-display { + width: 100%; + } +} + +@media (max-width: 900px) { + .fretboard-layout { + flex-direction: column; + } + + .tuning-selector { + width: 100%; + } + + .tuning-list { + max-height: 200px; + } +} + +/* ============================================ + REFERENCE BOX + ============================================ */ +.reference-box { + background: var(--bg-dark); + color: white; + padding: 15px; + border-radius: 4px; + font-size: 13px; + line-height: 1.8; + margin-bottom: 20px; +} + +.reference-box strong { + font-size: 14px; + display: block; + margin-bottom: 8px; +} + +/* ============================================ + REFERENCE SECTIONS + ============================================ */ +.reference-section { + background: white; + border: 2px solid var(--border-color); + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; +} + +.reference-section h3 { + margin: 0 0 15px 0; + padding-bottom: 10px; + border-bottom: 2px solid var(--border-color); +} + +.fret-reference-box { + padding: 15px; + margin-bottom: 15px; + border-radius: 6px; +} + +.fret-reference-box.blue { + background: var(--g-family-light); + border: 3px solid var(--g-family); +} + +.fret-reference-box.red { + background: var(--c-family-light); + border: 3px solid var(--c-family); +} + +.fret-reference-box.green { + background: var(--d-family-light); + border: 3px solid var(--d-family); +} + +.fret-reference-box h4 { + margin: 0 0 10px 0; + font-size: 14px; +} + +.fret-reference-box ul { + margin: 0; + padding-left: 20px; + line-height: 1.8; +} + +/* ============================================ + WARNING BOX + ============================================ */ +.warning-box { + background: #fff8e1; + border: 3px solid #ff9800; + padding: 15px; + margin: 15px 0; + border-radius: 6px; +} + +.warning-box h4 { + margin: 0 0 8px 0; + color: #e65100; +} + +/* ============================================ + LEGEND + ============================================ */ +.legend { + font-size: 12px; + color: var(--text-muted); + text-align: center; + margin-top: 20px; + padding-top: 15px; + border-top: 2px solid var(--border-color); +} + +/* ============================================ + TABS + ============================================ */ +.tabs { + display: flex; + gap: 5px; + margin-bottom: 20px; + border-bottom: 3px solid var(--border-color); + padding-bottom: 0; +} + +.tab { + padding: 12px 24px; + background: #e0e0e0; + border: none; + border-radius: 8px 8px 0 0; + cursor: pointer; + font-size: 14px; + font-weight: bold; + transition: all 0.2s; + margin-bottom: -3px; + border: 3px solid transparent; + border-bottom: none; +} + +.tab:hover { + background: #d0d0d0; +} + +.tab.active { + background: white; + border-color: var(--border-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* ============================================ + CAPO CHART + ============================================ */ +.capo-chart { + width: 100%; + border-collapse: collapse; + margin: 15px 0; +} + +.capo-chart th, +.capo-chart td { + padding: 10px; + text-align: center; + border: 1px solid var(--border-color); +} + +.capo-chart th { + background: var(--bg-dark); + color: white; +} + +.capo-chart tr:nth-child(even) { + background: #f9f9f9; +} + +/* ============================================ + RESPONSIVE + ============================================ */ +@media (max-width: 768px) { + .control-panel { + flex-direction: column; + align-items: stretch; + } + + .tunings-grid { + grid-template-columns: 1fr; + } + + .app-header h1 { + font-size: 22px; + } +} + +/* ============================================ + CHORD DIAGRAM STYLES + ============================================ */ +.chord-controls { + display: flex; + flex-wrap: wrap; + gap: 15px; + padding: 15px; + background: white; + border: 2px solid var(--border-color); + margin-bottom: 20px; + align-items: flex-end; +} + +.chord-control-group { + display: flex; + flex-direction: column; + gap: 5px; +} + +.chord-control-group label { + font-size: 12px; + font-weight: bold; + color: var(--text-secondary); +} + +.key-buttons { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.key-btn { + padding: 8px 12px; + border: 2px solid var(--border-color); + background: white; + cursor: pointer; + font-size: 13px; + font-weight: bold; + border-radius: 4px; + transition: all 0.2s; + min-width: 45px; + text-align: center; +} + +.key-btn:hover { + background: #f0f0f0; +} + +.key-btn.active { + background: var(--bg-dark); + color: white; + border-color: var(--bg-dark); +} + +.chord-type-buttons { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.chord-type-btn { + padding: 6px 10px; + border: 2px solid var(--border-color); + background: white; + cursor: pointer; + font-size: 12px; + border-radius: 4px; + transition: all 0.2s; +} + +.chord-type-btn:hover { + background: #f0f0f0; +} + +.chord-type-btn.active { + background: var(--g-family); + color: white; + border-color: var(--g-family); +} + +.view-mode-buttons { + display: flex; + gap: 5px; +} + +.view-mode-btn { + padding: 6px 12px; + border: 2px solid var(--border-color); + background: white; + cursor: pointer; + font-size: 12px; + border-radius: 4px; + transition: all 0.2s; +} + +.view-mode-btn:hover { + background: #f0f0f0; +} + +.view-mode-btn.active { + background: var(--d-family); + color: white; + border-color: var(--d-family); +} + +/* All Keys Grid View */ +.chord-keys-grid-container { + overflow-x: auto; + padding: 10px; +} + +.chord-keys-grid { + border-collapse: collapse; + width: 100%; + background: white; + border: 2px solid var(--border-color); +} + +.chord-keys-grid th, +.chord-keys-grid td { + border: 1px solid var(--border-color); + padding: 8px; + text-align: center; + vertical-align: top; +} + +.chord-keys-grid th { + background: var(--bg-dark); + color: white; + font-size: 13px; + font-weight: bold; + position: sticky; + top: 0; + z-index: 1; +} + +.chord-keys-grid th.key-header { + background: var(--g-family); + min-width: 60px; +} + +.chord-keys-grid td.key-cell { + background: var(--g-family-light); + font-weight: bold; + font-size: 16px; + vertical-align: middle; + min-width: 50px; +} + +.chord-keys-grid .grid-chord-cell { + min-width: 150px; + padding: 5px; +} + +.chord-keys-grid .grid-chord-diagram { + background: #fafafa; + border-radius: 4px; + padding: 5px; +} + +.chord-keys-grid .grid-chord-title { + font-size: 14px; + font-weight: bold; + margin-bottom: 3px; +} + +.chord-keys-grid .grid-chord-notes { + font-size: 10px; + color: var(--text-muted); + margin-top: 3px; +} + +.chord-keys-grid .grid-no-voicing { + color: var(--text-muted); + font-style: italic; + font-size: 11px; + padding: 20px 5px; +} + +.chord-keys-grid .group-separator { + background: #1a1a1a; + width: 4px; + padding: 0; + border: none; +} + +.chord-keys-grid th.group-separator { + background: #1a1a1a; +} + +@media print { + .chord-keys-grid-container { + overflow: visible; + } + + .chord-keys-grid { + font-size: 10px; + } + + .chord-keys-grid th, + .chord-keys-grid td { + padding: 4px; + } + + .chord-keys-grid .grid-chord-cell { + min-width: 100px; + } +} + +.chord-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 20px; + padding: 10px; +} + +.chord-diagram-container { + background: white; + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 15px; + text-align: center; +} + +.chord-diagram-title { + font-size: 18px; + font-weight: bold; + margin-bottom: 10px; + color: var(--text-primary); +} + +.chord-diagram-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.chord-notes-display { + font-size: 11px; + color: var(--text-secondary); + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); +} + +.finger-toggle { + display: flex; + gap: 10px; + align-items: center; +} + +.finger-toggle label { + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; + font-size: 13px; +} + +.chord-info-box { + background: #f9f9f9; + border: 1px solid var(--border-color); + padding: 10px 15px; + margin-bottom: 20px; + font-size: 13px; +} + +.no-chord-message { + padding: 40px; + text-align: center; + color: var(--text-muted); + font-style: italic; +} + +@media print { + .chord-controls { + display: none; + } + + .chord-grid { + grid-template-columns: repeat(4, 1fr); + } + + .chord-diagram-container { + break-inside: avoid; + page-break-inside: avoid; + } +} @@ -4,1120 +4,7 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Interactive Banjo Tunings Reference</title> - <style> - /* ============================================ - CSS VARIABLES & BASE STYLES - ============================================ */ - :root { - --c-family: #C74242; - --c-family-light: #F8E3E3; - --d-family: #2D7A4F; - --d-family-light: #E3F5E9; - --g-family: #2E5C8A; - --g-family-light: #E3EEF8; - --other-family: #7B5BA6; - --other-family-light: #F0E8F5; - --text-primary: #1a1a1a; - --text-secondary: #444; - --text-muted: #666; - --bg-dark: #1a1a1a; - --bg-light: #f5f5f5; - --border-color: #ddd; - } - - * { - box-sizing: border-box; - } - - body { - font-family: 'Helvetica Neue', Arial, sans-serif; - margin: 0; - padding: 0; - background: #f0f0f0; - color: var(--text-primary); - line-height: 1.5; - } - - /* ============================================ - PRINT STYLES - ============================================ */ - @page { - size: A4 portrait; - margin: 12mm; - } - - /* Landscape orientation for chord grid view */ - @page chord-grid-landscape { - size: A4 landscape; - margin: 10mm; - } - - @media print { - body { - background: white; - } - - .no-print { - display: none !important; - } - - .print-only { - display: block !important; - } - - .page-break { - page-break-after: always; - } - - .page-break-before { - page-break-before: always; - } - - .tuning-card { - break-inside: avoid; - } - - .app-container { - max-width: 100%; - padding: 0; - } - - .tunings-grid { - grid-template-columns: 1fr !important; - } - - /* Chord grid landscape printing */ - .chord-keys-grid-container { - page: chord-grid-landscape; - } - - /* Table header repetition on each page */ - .chord-keys-grid thead { - display: table-header-group; - } - - .chord-keys-grid tbody { - display: table-row-group; - } - - /* Force page break before F#/Gb row */ - .chord-keys-grid tr.page-break-before { - page-break-before: always; - } - - /* Keep key rows together */ - .chord-keys-grid tr { - page-break-inside: avoid; - } - } - - .print-only { - display: none; - } - - /* ============================================ - LAYOUT - ============================================ */ - .app-container { - max-width: 1400px; - margin: 0 auto; - padding: 20px; - } - - /* ============================================ - HEADER - ============================================ */ - .app-header { - text-align: center; - border: 3px solid #000; - padding: 20px; - margin-bottom: 20px; - background: var(--bg-light); - border-radius: 8px; - } - - .app-header h1 { - margin: 0 0 10px 0; - font-size: 28px; - font-weight: bold; - letter-spacing: 0.5px; - } - - .app-header .subtitle { - font-size: 14px; - color: var(--text-secondary); - margin: 5px 0; - } - - /* ============================================ - CONTROL PANEL - ============================================ */ - .control-panel { - background: var(--bg-dark); - color: white; - padding: 20px; - border-radius: 8px; - margin-bottom: 20px; - display: flex; - flex-wrap: wrap; - gap: 20px; - align-items: center; - justify-content: space-between; - } - - .control-group { - display: flex; - flex-direction: column; - gap: 6px; - } - - .control-group label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1px; - color: #aaa; - } - - .control-group select, - .control-group input[type="number"] { - padding: 8px 12px; - border: 2px solid #444; - border-radius: 4px; - background: #333; - color: white; - font-size: 14px; - min-width: 150px; - } - - .control-group select:focus, - .control-group input:focus { - outline: none; - border-color: var(--g-family); - } - - /* Toggle Switch */ - .toggle-container { - display: flex; - align-items: center; - gap: 10px; - } - - .toggle-switch { - position: relative; - width: 60px; - height: 30px; - } - - .toggle-switch input { - opacity: 0; - width: 0; - height: 0; - } - - .toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #444; - transition: 0.3s; - border-radius: 30px; - } - - .toggle-slider:before { - position: absolute; - content: ""; - height: 22px; - width: 22px; - left: 4px; - bottom: 4px; - background-color: white; - transition: 0.3s; - border-radius: 50%; - } - - .toggle-switch input:checked + .toggle-slider { - background-color: var(--g-family); - } - - .toggle-switch input:checked + .toggle-slider:before { - transform: translateX(30px); - } - - .toggle-label { - font-size: 14px; - font-weight: bold; - min-width: 50px; - } - - /* Checkbox toggle for notes */ - .checkbox-container { - display: flex; - align-items: center; - gap: 8px; - } - - .checkbox-container input[type="checkbox"] { - width: 18px; - height: 18px; - cursor: pointer; - } - - .checkbox-container span { - font-size: 14px; - } - - /* Buttons */ - .btn { - padding: 10px 20px; - border: none; - border-radius: 4px; - font-size: 14px; - font-weight: bold; - cursor: pointer; - transition: all 0.2s; - } - - .btn-primary { - background: var(--g-family); - color: white; - } - - .btn-primary:hover { - background: #1e4a6e; - } - - .btn-secondary { - background: #444; - color: white; - } - - .btn-secondary:hover { - background: #555; - } - - /* ============================================ - PAGE TITLE - ============================================ */ - .page-title { - font-size: 18px; - font-weight: bold; - text-align: center; - padding: 12px; - background: #333; - color: white; - margin-bottom: 20px; - border-radius: 4px; - text-transform: uppercase; - letter-spacing: 1px; - } - - /* ============================================ - FAMILY INTRO - ============================================ */ - .family-intro { - background: #f0f0f0; - border-left: 5px solid; - padding: 15px; - margin-bottom: 15px; - font-size: 14px; - line-height: 1.6; - border-radius: 0 4px 4px 0; - } - - .family-intro.c-family-intro { border-color: var(--c-family); } - .family-intro.d-family-intro { border-color: var(--d-family); } - .family-intro.g-family-intro { border-color: var(--g-family); } - .family-intro.other-family-intro { border-color: var(--other-family); } - - /* ============================================ - TUNINGS GRID - ============================================ */ - .tunings-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); - gap: 20px; - margin-bottom: 20px; - } - - /* ============================================ - TUNING CARD - ============================================ */ - .tuning-card { - border: 3px solid; - border-radius: 8px; - padding: 16px; - background: white; - box-shadow: 0 3px 6px rgba(0,0,0,0.15); - transition: transform 0.2s, box-shadow 0.2s; - } - - .tuning-card:hover { - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); - } - - .tuning-card.c-family { - border-color: var(--c-family); - background: linear-gradient(to bottom, var(--c-family-light) 0%, #FEF8F8 100%); - } - - .tuning-card.d-family { - border-color: var(--d-family); - background: linear-gradient(to bottom, var(--d-family-light) 0%, #F8FEF9 100%); - } - - .tuning-card.g-family { - border-color: var(--g-family); - background: linear-gradient(to bottom, var(--g-family-light) 0%, #F8FBFE 100%); - } - - .tuning-card.other-family { - border-color: var(--other-family); - background: linear-gradient(to bottom, var(--other-family-light) 0%, #FDFBFE 100%); - } - - .tuning-header { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: 8px; - border-bottom: 3px solid; - padding-bottom: 8px; - } - - .c-family .tuning-header { border-color: var(--c-family); } - .d-family .tuning-header { border-color: var(--d-family); } - .g-family .tuning-header { border-color: var(--g-family); } - .other-family .tuning-header { border-color: var(--other-family); } - - .tuning-name { - font-size: 16px; - font-weight: bold; - text-transform: uppercase; - } - - .genre-tag { - font-size: 12px; - font-style: italic; - color: var(--text-muted); - } - - .string-count-badge { - display: inline-block; - background: #666; - color: white; - font-size: 10px; - padding: 2px 6px; - border-radius: 3px; - margin-left: 8px; - } - - .note-sequence { - font-family: 'Courier New', Consolas, monospace; - font-size: 20px; - font-weight: bold; - letter-spacing: 4px; - margin: 12px 0; - text-align: center; - background: white; - padding: 12px 8px; - border-radius: 4px; - border: 2px solid var(--border-color); - } - - .note-sequence sub { - font-size: 0.6em; - vertical-align: sub; - } - - /* Nut Visual */ - .nut-visual { - display: flex; - justify-content: center; - gap: 12px; - margin: 12px 0; - } - - .string-circle { - width: 28px; - height: 28px; - border: 3px solid #333; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: bold; - background: white; - } - - .string-circle.changed { - background: #333; - color: white; - } - - /* Tuning Details */ - .tuning-details { - font-size: 13px; - line-height: 1.7; - color: #333; - margin-top: 10px; - } - - .tuning-details div { - margin: 5px 0; - } - - .delta-line { - font-weight: bold; - } - - .capo-line { - font-weight: bold; - color: #0066cc; - } - - .songs-line { - font-style: italic; - color: #555; - } - - /* Effective Tuning (with capo) */ - .effective-tuning { - background: #fff8e1; - border: 2px solid #ff9800; - padding: 8px; - border-radius: 4px; - margin-top: 10px; - font-size: 12px; - } - - .effective-tuning strong { - color: #e65100; - } - - /* ============================================ - FRETBOARD DIAGRAM - ============================================ */ - .fretboard-container { - margin: 15px 0; - overflow-x: auto; - } - - .fretboard-svg { - display: block; - margin: 0 auto; - } - - /* ============================================ - FRETBOARD PAGE LAYOUT (Side-by-side) - ============================================ */ - .fretboard-layout { - display: flex; - gap: 20px; - align-items: flex-start; - } - - .tuning-selector { - width: 280px; - flex-shrink: 0; - background: white; - border: 2px solid var(--border-color); - border-radius: 8px; - overflow: hidden; - } - - .tuning-selector-header { - background: var(--bg-dark); - color: white; - padding: 12px 15px; - font-weight: bold; - font-size: 14px; - } - - .tuning-selector-filter { - padding: 10px; - border-bottom: 1px solid var(--border-color); - background: #f9f9f9; - } - - .tuning-selector-filter select { - width: 100%; - padding: 8px; - border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 13px; - } - - .tuning-list { - max-height: 500px; - overflow-y: auto; - } - - .tuning-list-item { - padding: 10px 15px; - cursor: pointer; - border-bottom: 1px solid #eee; - transition: background 0.15s; - font-size: 13px; - } - - .tuning-list-item:hover { - background: #f5f5f5; - } - - .tuning-list-item.selected { - background: var(--g-family-light); - border-left: 4px solid var(--g-family); - font-weight: bold; - } - - .tuning-list-item.c-family.selected { - background: var(--c-family-light); - border-left-color: var(--c-family); - } - - .tuning-list-item.d-family.selected { - background: var(--d-family-light); - border-left-color: var(--d-family); - } - - .tuning-list-item.other-family.selected { - background: var(--other-family-light); - border-left-color: var(--other-family); - } - - .tuning-list-item .tuning-item-name { - display: block; - font-weight: 600; - } - - .tuning-list-item .tuning-item-notes { - display: block; - font-size: 11px; - color: var(--text-muted); - font-family: 'Courier New', monospace; - margin-top: 2px; - } - - .tuning-list-group { - background: #f0f0f0; - padding: 8px 15px; - font-size: 11px; - font-weight: bold; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-secondary); - border-bottom: 1px solid var(--border-color); - } - - .fretboard-display { - flex: 1; - min-width: 0; - } - - @media print { - .tuning-selector { - display: none !important; - } - - .fretboard-layout { - display: block; - } - - .fretboard-display { - width: 100%; - } - } - - @media (max-width: 900px) { - .fretboard-layout { - flex-direction: column; - } - - .tuning-selector { - width: 100%; - } - - .tuning-list { - max-height: 200px; - } - } - - /* ============================================ - REFERENCE BOX - ============================================ */ - .reference-box { - background: var(--bg-dark); - color: white; - padding: 15px; - border-radius: 4px; - font-size: 13px; - line-height: 1.8; - margin-bottom: 20px; - } - - .reference-box strong { - font-size: 14px; - display: block; - margin-bottom: 8px; - } - - /* ============================================ - REFERENCE SECTIONS - ============================================ */ - .reference-section { - background: white; - border: 2px solid var(--border-color); - padding: 20px; - border-radius: 8px; - margin-bottom: 20px; - } - - .reference-section h3 { - margin: 0 0 15px 0; - padding-bottom: 10px; - border-bottom: 2px solid var(--border-color); - } - - .fret-reference-box { - padding: 15px; - margin-bottom: 15px; - border-radius: 6px; - } - - .fret-reference-box.blue { - background: var(--g-family-light); - border: 3px solid var(--g-family); - } - - .fret-reference-box.red { - background: var(--c-family-light); - border: 3px solid var(--c-family); - } - - .fret-reference-box.green { - background: var(--d-family-light); - border: 3px solid var(--d-family); - } - - .fret-reference-box h4 { - margin: 0 0 10px 0; - font-size: 14px; - } - - .fret-reference-box ul { - margin: 0; - padding-left: 20px; - line-height: 1.8; - } - - /* ============================================ - WARNING BOX - ============================================ */ - .warning-box { - background: #fff8e1; - border: 3px solid #ff9800; - padding: 15px; - margin: 15px 0; - border-radius: 6px; - } - - .warning-box h4 { - margin: 0 0 8px 0; - color: #e65100; - } - - /* ============================================ - LEGEND - ============================================ */ - .legend { - font-size: 12px; - color: var(--text-muted); - text-align: center; - margin-top: 20px; - padding-top: 15px; - border-top: 2px solid var(--border-color); - } - - /* ============================================ - TABS - ============================================ */ - .tabs { - display: flex; - gap: 5px; - margin-bottom: 20px; - border-bottom: 3px solid var(--border-color); - padding-bottom: 0; - } - - .tab { - padding: 12px 24px; - background: #e0e0e0; - border: none; - border-radius: 8px 8px 0 0; - cursor: pointer; - font-size: 14px; - font-weight: bold; - transition: all 0.2s; - margin-bottom: -3px; - border: 3px solid transparent; - border-bottom: none; - } - - .tab:hover { - background: #d0d0d0; - } - - .tab.active { - background: white; - border-color: var(--border-color); - } - - .tab-content { - display: none; - } - - .tab-content.active { - display: block; - } - - /* ============================================ - CAPO CHART - ============================================ */ - .capo-chart { - width: 100%; - border-collapse: collapse; - margin: 15px 0; - } - - .capo-chart th, - .capo-chart td { - padding: 10px; - text-align: center; - border: 1px solid var(--border-color); - } - - .capo-chart th { - background: var(--bg-dark); - color: white; - } - - .capo-chart tr:nth-child(even) { - background: #f9f9f9; - } - - /* ============================================ - RESPONSIVE - ============================================ */ - @media (max-width: 768px) { - .control-panel { - flex-direction: column; - align-items: stretch; - } - - .tunings-grid { - grid-template-columns: 1fr; - } - - .app-header h1 { - font-size: 22px; - } - } - - /* ============================================ - CHORD DIAGRAM STYLES - ============================================ */ - .chord-controls { - display: flex; - flex-wrap: wrap; - gap: 15px; - padding: 15px; - background: white; - border: 2px solid var(--border-color); - margin-bottom: 20px; - align-items: flex-end; - } - - .chord-control-group { - display: flex; - flex-direction: column; - gap: 5px; - } - - .chord-control-group label { - font-size: 12px; - font-weight: bold; - color: var(--text-secondary); - } - - .key-buttons { - display: flex; - flex-wrap: wrap; - gap: 5px; - } - - .key-btn { - padding: 8px 12px; - border: 2px solid var(--border-color); - background: white; - cursor: pointer; - font-size: 13px; - font-weight: bold; - border-radius: 4px; - transition: all 0.2s; - min-width: 45px; - text-align: center; - } - - .key-btn:hover { - background: #f0f0f0; - } - - .key-btn.active { - background: var(--bg-dark); - color: white; - border-color: var(--bg-dark); - } - - .chord-type-buttons { - display: flex; - flex-wrap: wrap; - gap: 5px; - } - - .chord-type-btn { - padding: 6px 10px; - border: 2px solid var(--border-color); - background: white; - cursor: pointer; - font-size: 12px; - border-radius: 4px; - transition: all 0.2s; - } - - .chord-type-btn:hover { - background: #f0f0f0; - } - - .chord-type-btn.active { - background: var(--g-family); - color: white; - border-color: var(--g-family); - } - - .view-mode-buttons { - display: flex; - gap: 5px; - } - - .view-mode-btn { - padding: 6px 12px; - border: 2px solid var(--border-color); - background: white; - cursor: pointer; - font-size: 12px; - border-radius: 4px; - transition: all 0.2s; - } - - .view-mode-btn:hover { - background: #f0f0f0; - } - - .view-mode-btn.active { - background: var(--d-family); - color: white; - border-color: var(--d-family); - } - - /* All Keys Grid View */ - .chord-keys-grid-container { - overflow-x: auto; - padding: 10px; - } - - .chord-keys-grid { - border-collapse: collapse; - width: 100%; - background: white; - border: 2px solid var(--border-color); - } - - .chord-keys-grid th, - .chord-keys-grid td { - border: 1px solid var(--border-color); - padding: 8px; - text-align: center; - vertical-align: top; - } - - .chord-keys-grid th { - background: var(--bg-dark); - color: white; - font-size: 13px; - font-weight: bold; - position: sticky; - top: 0; - z-index: 1; - } - - .chord-keys-grid th.key-header { - background: var(--g-family); - min-width: 60px; - } - - .chord-keys-grid td.key-cell { - background: var(--g-family-light); - font-weight: bold; - font-size: 16px; - vertical-align: middle; - min-width: 50px; - } - - .chord-keys-grid .grid-chord-cell { - min-width: 150px; - padding: 5px; - } - - .chord-keys-grid .grid-chord-diagram { - background: #fafafa; - border-radius: 4px; - padding: 5px; - } - - .chord-keys-grid .grid-chord-title { - font-size: 14px; - font-weight: bold; - margin-bottom: 3px; - } - - .chord-keys-grid .grid-chord-notes { - font-size: 10px; - color: var(--text-muted); - margin-top: 3px; - } - - .chord-keys-grid .grid-no-voicing { - color: var(--text-muted); - font-style: italic; - font-size: 11px; - padding: 20px 5px; - } - - .chord-keys-grid .group-separator { - background: #1a1a1a; - width: 4px; - padding: 0; - border: none; - } - - .chord-keys-grid th.group-separator { - background: #1a1a1a; - } - - @media print { - .chord-keys-grid-container { - overflow: visible; - } - - .chord-keys-grid { - font-size: 10px; - } - - .chord-keys-grid th, - .chord-keys-grid td { - padding: 4px; - } - - .chord-keys-grid .grid-chord-cell { - min-width: 100px; - } - } - - .chord-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 20px; - padding: 10px; - } - - .chord-diagram-container { - background: white; - border: 2px solid var(--border-color); - border-radius: 8px; - padding: 15px; - text-align: center; - } - - .chord-diagram-title { - font-size: 18px; - font-weight: bold; - margin-bottom: 10px; - color: var(--text-primary); - } - - .chord-diagram-subtitle { - font-size: 12px; - color: var(--text-muted); - margin-bottom: 10px; - } - - .chord-notes-display { - font-size: 11px; - color: var(--text-secondary); - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-color); - } - - .finger-toggle { - display: flex; - gap: 10px; - align-items: center; - } - - .finger-toggle label { - display: flex; - align-items: center; - gap: 5px; - cursor: pointer; - font-size: 13px; - } - - .chord-info-box { - background: #f9f9f9; - border: 1px solid var(--border-color); - padding: 10px 15px; - margin-bottom: 20px; - font-size: 13px; - } - - .no-chord-message { - padding: 40px; - text-align: center; - color: var(--text-muted); - font-style: italic; - } - - @media print { - .chord-controls { - display: none; - } - - .chord-grid { - grid-template-columns: repeat(4, 1fr); - } - - .chord-diagram-container { - break-inside: avoid; - page-break-inside: avoid; - } - } - </style> + <link rel="stylesheet" href="css/styles.css"> </head> <body> <div class="app-container"> @@ -1445,1919 +332,12 @@ </div> </div> - <script> - // ============================================ - // CHROMATIC SCALE & NOTE UTILITIES - // ============================================ - const CHROMATIC_SCALE = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; - - // Normalize note names (flats to sharps, handle case) - const NOTE_MAP = { - 'c': 'C', 'c#': 'C#', 'db': 'C#', 'Db': 'C#', - 'd': 'D', 'd#': 'D#', 'eb': 'D#', 'Eb': 'D#', - 'e': 'E', 'fb': 'E', 'Fb': 'E', - 'f': 'F', 'e#': 'F', 'E#': 'F', 'f#': 'F#', 'gb': 'F#', 'Gb': 'F#', - 'g': 'G', 'g#': 'G#', 'ab': 'G#', 'Ab': 'G#', - 'a': 'A', 'a#': 'A#', 'bb': 'A#', 'Bb': 'A#', - 'b': 'B', 'cb': 'B', 'Cb': 'B', 'b#': 'C', 'B#': 'C', - 'C': 'C', 'C#': 'C#', 'D': 'D', 'D#': 'D#', 'E': 'E', - 'F': 'F', 'F#': 'F#', 'G': 'G', 'G#': 'G#', 'A': 'A', 'A#': 'A#', 'B': 'B' - }; - - // Display note mapping - prefer flats for G# and A# (as per user's Python script) - const DISPLAY_NOTE_MAP = { - 'G#': 'Ab', - 'A#': 'Bb' - // Keep D#, C#, F# as sharps - }; - - // Solfège mapping: C=Do, D=Re, E=Mi, F=Fa, G=Sol, A=La, B=Si - const SOLFEGE_MAP = { - 'C': 'Do', 'D': 'Re', 'E': 'Mi', 'F': 'Fa', - 'G': 'Sol', 'A': 'La', 'B': 'Si' - }; - - function normalizeNote(note) { - return NOTE_MAP[note] || note.toUpperCase(); - } - - // Check if a note is natural (no sharps or flats) - function isNaturalNote(note) { - const normalized = normalizeNote(note); - return ['C', 'D', 'E', 'F', 'G', 'A', 'B'].includes(normalized); - } - - function displayNote(note) { - // Convert internal sharp note to preferred display (mix of flats and sharps) - if (DISPLAY_NOTE_MAP[note]) { - return DISPLAY_NOTE_MAP[note]; - } - return note; - } - - function toSolfege(note) { - // Convert a note to solfège notation - // Handle sharps/flats: C# -> Do#, Db -> Reb, etc. - const baseNote = note.charAt(0).toUpperCase(); - const accidental = note.slice(1); // # or b or empty - const solfege = SOLFEGE_MAP[baseNote] || baseNote; - return solfege + accidental; - } - - function getNoteAtFret(openNote, fret) { - const normalized = normalizeNote(openNote); - const startIndex = CHROMATIC_SCALE.indexOf(normalized); - if (startIndex === -1) return openNote; - const newIndex = (startIndex + fret) % 12; - return CHROMATIC_SCALE[newIndex]; - } - - // ============================================ - // CHORD DATA & FUNCTIONS - // ============================================ - - // 12 keys with display names (using slash for enharmonics) - const CHORD_KEYS = [ - { root: 'C', display: 'C' }, - { root: 'C#', display: 'C#/Db' }, - { root: 'D', display: 'D' }, - { root: 'D#', display: 'D#/Eb' }, - { root: 'E', display: 'E' }, - { root: 'F', display: 'F' }, - { root: 'F#', display: 'F#/Gb' }, - { root: 'G', display: 'G' }, - { root: 'G#', display: 'G#/Ab' }, - { root: 'A', display: 'A' }, - { root: 'A#', display: 'A#/Bb' }, - { root: 'B', display: 'B' } - ]; - - // 8 chord types with intervals (semitones from root) - const CHORD_TYPES = { - 'major': { symbol: '', name: 'Major', intervals: [0, 4, 7], degrees: ['1', '3', '5'] }, - 'minor': { symbol: 'm', name: 'Minor', intervals: [0, 3, 7], degrees: ['1', '♭3', '5'] }, - 'm7': { symbol: 'm7', name: 'Minor 7', intervals: [0, 3, 7, 10], degrees: ['1', '♭3', '5', '♭7'] }, - '6': { symbol: '6', name: 'Sixth', intervals: [0, 4, 7, 9], degrees: ['1', '3', '5', '6'] }, - '7': { symbol: '7', name: 'Seventh', intervals: [0, 4, 7, 10], degrees: ['1', '3', '5', '♭7'] }, - 'dim': { symbol: 'dim', name: 'Diminished', intervals: [0, 3, 6], degrees: ['1', '♭3', '♭5'] }, - 'sus4': { symbol: 'sus4', name: 'Sus4', intervals: [0, 5, 7], degrees: ['1', '4', '5'] }, - 'aug': { symbol: 'aug', name: 'Augmented', intervals: [0, 4, 8], degrees: ['1', '3', '#5'] } - }; - - // Chord types grouped for display with separators between groups - const CHORD_TYPE_GROUPS = [ - ['major', 'minor', 'm7'], // Group 1 - ['6', '7'], // Group 2 - ['dim'], // Group 3 - ['sus4'], // Group 4 - ['aug'] // Group 5 - ]; - - // Ordered list of chord types (flattened groups) - const CHORD_TYPE_ORDER = CHORD_TYPE_GROUPS.flat(); - - // Finger notation mapping - const FINGER_NOTATION = { - numbers: { 1: '1', 2: '2', 3: '3', 4: '4', T: 'T' }, - pima: { 1: 'I', 2: 'M', 3: 'A', 4: 'P', T: 'P' } - }; - - // 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 rootPitch = noteToPitchClass(rootNote); - if (rootPitch === -1) return []; - - return type.intervals.map(interval => (rootPitch + interval) % 12); - } - - // 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]); - } - - // 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 null; - - 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; - - // 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; - - function explore(stringIdx, currentVoicing, usedTones) { - const fingersUsed = currentVoicing.filter(v => v && v.fret > 0).length; - if (fingersUsed > maxFingers) return; - - 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; - } - - if (stringIdx === numStrings) { - if (usedTones.size === chordPitchClasses.length) { - results.push([...currentVoicing]); - } - return; - } - - const stringPitch = stringPitchClasses[stringIdx]; - - // Option 1: Mute this string - explore(stringIdx + 1, [...currentVoicing, null], new Set(usedTones)); - - // 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); - - 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()); - - 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; - } - - 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 { - stringNum: stringNum, - fret: null, - muted: true, - note: null, - degree: null, - isRoot: false - }; - } - - const degree = type.degrees[pos.toneIndex]; - const isRoot = pos.pitchClass === rootPitch; - - return { - stringNum: stringNum, - fret: pos.fret, - muted: false, - note: pos.note, - degree: degree, - isRoot: isRoot - }; - }); - - // 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 - const frettedPositions = positions.filter(p => p.fret > 0); - const minFret = frettedPositions.length > 0 ? Math.min(...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, - barre, - chordNotes: chordNotes.map(n => displayNote(n)), - numStrings: numStrings - }; - } - - function assignFingers(positions) { - // Get fretted positions sorted by fret (ascending), then by string number (descending) - // This ensures: lowest fret first, and at same fret, higher string numbers first - const fretted = positions - .filter(p => p.fret > 0) - .sort((a, b) => { - if (a.fret !== b.fret) return a.fret - b.fret; - return b.stringNum - a.stringNum; // Higher string number first at same fret - }); - - if (fretted.length === 0) return; - - // Assign unique finger to each position in sequence - fretted.forEach((p, idx) => { - p.finger = Math.min(idx + 1, 4); - }); - } - - function detectBarre(positions) { - const fretGroups = {}; - positions.forEach((p, idx) => { - if (p.fret > 0) { - if (!fretGroups[p.fret]) fretGroups[p.fret] = []; - fretGroups[p.fret].push({ pos: p, idx: idx }); - } - }); - - 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 (maxStr - minStr <= 3) { - return { - fret: parseInt(fret), - fromString: minStr, - toString: maxStr - }; - } - } - } - - return null; - } - - // Generate SVG chord diagram for 4 main strings - function generateChordDiagramSVG(voicing, chordName, tuningName, fingerNotation = 'numbers') { - const width = 140; - const height = 200; - const marginLeft = 35; - const marginTop = 35; - const marginRight = 15; - const marginBottom = 25; - - const positions = voicing.positions; - const numStrings = 4; // Always 4 main strings for chord diagrams - const numFrets = 6; - const baseFret = voicing.baseFret; - - const stringSpacing = (width - marginLeft - marginRight) / (numStrings - 1); - const fretSpacing = (height - marginTop - marginBottom) / numFrets; - - let svg = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">`; - - // Background - svg += `<rect width="${width}" height="${height}" fill="white"/>`; - - // Nut (thick line at top) - only show if baseFret is 1 - if (baseFret === 1) { - 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 - 15}" y="${marginTop + fretSpacing / 2 + 4}" font-size="11" font-weight="bold" text-anchor="middle">${baseFret}fr</text>`; - } - - // Fret lines - for (let i = 0; i <= numFrets; i++) { - const y = marginTop + i * fretSpacing; - svg += `<line x1="${marginLeft}" y1="${y}" x2="${marginLeft + (numStrings - 1) * stringSpacing}" y2="${y}" stroke="#333" stroke-width="${i === 0 ? 2 : 1}"/>`; - } - - // 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"/>`; - } - - // 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>`; - } - - // 5th string box (same size cell as main grid, flush with it, at fret 5-6) - // Only show if baseFret is 1 (low position chords) - if (baseFret === 1) { - const fret5Y = marginTop + 5 * fretSpacing; // Fret 5 wire - const fret6Y = marginTop + 6 * fretSpacing; // Fret 6 wire - const boxLeft = marginLeft - stringSpacing; // One cell width to the left - - // Draw the box - same size as grid cells, flush with main grid - // Top line (fret 5) - svg += `<line x1="${boxLeft}" y1="${fret5Y}" x2="${marginLeft}" y2="${fret5Y}" stroke="#333" stroke-width="1"/>`; - // Bottom line (fret 6) - svg += `<line x1="${boxLeft}" y1="${fret6Y}" x2="${marginLeft}" y2="${fret6Y}" stroke="#333" stroke-width="1"/>`; - // Left edge - svg += `<line x1="${boxLeft}" y1="${fret5Y}" x2="${boxLeft}" y2="${fret6Y}" stroke="#333" stroke-width="1"/>`; - - // Label for 5th string (centered in the box) - const boxCenterX = boxLeft + stringSpacing / 2; - svg += `<text x="${boxCenterX}" y="${fret5Y - 8}" font-size="8" text-anchor="middle" fill="#666">5</text>`; - } - - // Finger positions and open/muted indicators - positions.forEach((pos, idx) => { - // 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 - 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 - 10}" r="5" fill="none" stroke="#333" stroke-width="1.5"/>`; - } else { - // 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="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 + 3}" font-size="10" font-weight="bold" text-anchor="middle" fill="white">${fingerLabel}</text>`; - } - } else { - 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 + 3}" font-size="10" font-weight="bold" text-anchor="middle">${fingerLabel}</text>`; - } - } - } - } - }); - - // Degree labels below strings - positions.forEach((pos, idx) => { - const x = marginLeft + idx * stringSpacing; - const y = marginTop + numFrets * fretSpacing + 15; - - if (pos.degree && !pos.muted) { - 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="9" text-anchor="middle" fill="#999">-</text>`; - } - }); - - svg += '</svg>'; - return svg; - } - - // ============================================ - // TUNINGS DATA (24 tunings including 4-string) - // ============================================ - const TUNINGS_DATA = { - // C-Based Tunings - "Minstrel Tuning": { - strings: ["f", "C", "F", "A", "c"], - family: "c-family", - description: "Historical", - referenceTuning: "Standard F", - deltaChanges: "From Standard F: 4th↓C", - capoPositions: { 0: "Not commonly capoed" }, - songs: "Briggs manual, Coon Hunt", - changedStrings: [true, true, true, true, true], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Naomi Weiss": { - strings: ["g", "C", "G", "A", "d"], - family: "c-family", - description: "Song-Specific", - referenceTuning: "Minstrel", - deltaChanges: "From Minstrel: 5th↑G, 3rd↑G, 2nd→A (same), 1st↑D", - capoPositions: { 2: "Key of D" }, - songs: '"Naomi Weiss"', - changedStrings: [false, false, true, false, true], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Little Birdie": { - strings: ["e", "C", "G", "A", "d"], - family: "c-family", - description: "Appalachian", - referenceTuning: "Naomi Weiss", - deltaChanges: "From Naomi Weiss: 5th↓E", - capoPositions: { 2: "Key of F# (uncommon)" }, - songs: '"Little Birdie"', - changedStrings: [true, false, false, false, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Hook-and-Line": { - strings: ["g", "C", "G", "c", "d"], - family: "c-family", - description: "Open C Variant", - referenceTuning: "Little Birdie", - deltaChanges: "From Little Birdie: 5th↑G, 2nd↑C", - capoPositions: { 2: "Key of D", 5: "Key of F" }, - songs: '"Hook and Line"', - changedStrings: [true, false, false, true, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Cackling Hen": { - strings: ["g", "C", "G", "c", "e"], - family: "c-family", - description: "Open C Major", - referenceTuning: "Hook-and-Line", - deltaChanges: "From Hook-and-Line: 1st↑E", - capoPositions: { 0: "Key of C", 2: "Key of D" }, - songs: "Open C tuning, Irish/Celtic", - changedStrings: [false, false, false, false, true], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Darling Cora": { - strings: ["g", "C", "G", "c", "c"], - family: "c-family", - description: "Drone Heavy (Triple C)", - referenceTuning: "Cackling Hen", - deltaChanges: "From Cackling Hen: 1st↓C", - capoPositions: { 2: "Key of D", 5: "Key of F" }, - songs: '"Darling Cora"', - changedStrings: [false, false, false, false, true], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Double C": { - strings: ["g", "C", "G", "c", "d"], - family: "c-family", - description: "Old-Time Standard", - referenceTuning: "Open G", - deltaChanges: "From Open G: 4th↓C, 2nd↑C", - capoPositions: { 2: "Key of D", 5: "Key of F" }, - songs: "Wildwood Flower, Billy in the Lowground", - changedStrings: [false, true, false, true, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Standard C": { - strings: ["g", "C", "G", "B", "d"], - family: "c-family", - description: "C Tuning for Old-Time", - referenceTuning: "Open G", - deltaChanges: "From Open G: 4th↓C", - capoPositions: { 2: "Key of D", 5: "Key of F" }, - songs: "Old-time repertoire", - changedStrings: [false, true, false, false, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - - // D-Based Tunings - "Moonshiner": { - strings: ["g", "D", "G", "A", "d"], - family: "d-family", - description: "Modal/Dark", - referenceTuning: "Standard F", - deltaChanges: "From Standard F: 4th↑D, 3rd↑G, 2nd→A (same), 1st↑D", - capoPositions: { 2: "Key of A", 5: "Key of C" }, - songs: '"Moonshiner"', - changedStrings: [false, true, false, true, true], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "German War": { - strings: ["g", "D", "G", "A", "f#"], - family: "d-family", - description: "Historical", - referenceTuning: "Moonshiner", - deltaChanges: "From Moonshiner: 1st↓F#", - capoPositions: { 2: "Key of A (modal)" }, - songs: "German war songs", - changedStrings: [false, false, false, false, true], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Reuben": { - strings: ["g", "D", "F#", "A", "f#"], - family: "d-family", - description: "Song-Specific", - referenceTuning: "German War", - deltaChanges: "From German War: 3rd↓F#", - capoPositions: { 2: "Key of A (modal)" }, - songs: '"Reuben"', - changedStrings: [false, false, true, false, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "F Tuning": { - strings: ["f", "D", "G", "c", "d"], - family: "d-family", - description: "Cumberland Gap", - referenceTuning: "Reuben", - deltaChanges: "From Reuben: 5th↓F, 3rd↑G, 2nd↑C, 1st↑D", - capoPositions: { 2: "Key of G", 5: "Key of Bb" }, - songs: "Cumberland Gap, Drunkard's Doom", - changedStrings: [true, false, true, true, true], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "East Virginia": { - strings: ["f", "F", "G", "c", "d"], - family: "d-family", - description: "George Gibson Style", - referenceTuning: "F Tuning", - deltaChanges: "From F Tuning: 4th↑F", - capoPositions: { 2: "Key of G", 5: "Key of Bb" }, - songs: "George Gibson style", - changedStrings: [false, true, false, false, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - - // G-Based Tunings - "Standard (F)": { - strings: ["f", "C", "F", "A", "c"], - family: "g-family", - description: "Historical Reference", - referenceTuning: null, - deltaChanges: "(Reference tuning for historical context)", - capoPositions: { 2: "Key of G", 5: "Key of Bb" }, - songs: "General playing, historical reference", - changedStrings: [false, false, false, false, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Open G": { - strings: ["g", "D", "G", "B", "d"], - family: "g-family", - description: "Bluegrass Standard", - referenceTuning: null, - deltaChanges: "(Modern standard reference - most common)", - capoPositions: { 2: "Key of A", 5: "Key of C", 7: "Key of D" }, - songs: "Bill Cheatum, Old Joe Clark, Cripple Creek", - changedStrings: [true, true, true, true, true], - featured: true, - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Open G (Low G)": { - strings: ["G", "D", "G", "B", "d"], - family: "g-family", - description: "Open G with Low 5th", - referenceTuning: "Open G", - deltaChanges: "From Open G: 5th↓G (one octave lower)", - capoPositions: { 2: "Key of A", 5: "Key of C" }, - songs: "Low drone sound", - changedStrings: [true, false, false, false, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Open A": { - strings: ["a", "E", "A", "C#", "e"], - family: "g-family", - description: "High Tension", - referenceTuning: "Open G", - deltaChanges: "From Open G: all strings ↑2 frets (whole step)", - capoPositions: { 2: "Key of B", 5: "Key of D" }, - songs: "Tunes in A key - requires light gauge strings!", - changedStrings: [true, true, true, true, true], - warning: true, - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "G Variant": { - strings: ["g", "D", "G", "A", "d"], - family: "g-family", - description: "Modal G", - referenceTuning: "Open G", - deltaChanges: "From Open G: 2nd↓A", - capoPositions: { 2: "Key of A", 5: "Key of C" }, - songs: "Leather Britches, Texas, Frosty Morning", - changedStrings: [false, false, false, true, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "G Modal (Sawmill)": { - strings: ["g", "D", "G", "c", "d"], - family: "g-family", - description: "Mountain/Modal", - referenceTuning: "Open G", - deltaChanges: "From Open G: 2nd↑C", - capoPositions: { 2: "Key of A", 5: "Key of C" }, - songs: "Modal tunes, F key songs", - changedStrings: [false, false, false, true, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "Open D": { - strings: ["a", "D", "F#", "A", "d"], - family: "g-family", - description: "Blues/Folk (Graveyard)", - referenceTuning: "Open G", - deltaChanges: "From Open G: all strings adjusted for D major", - capoPositions: { 2: "Key of E", 3: "Key of F", 5: "Key of G" }, - songs: 'Most D key tunes, "Graveyard Tuning" - use light strings!', - changedStrings: [true, true, true, true, true], - warning: true, - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "D Modal": { - strings: ["a", "D", "G", "A", "d"], - family: "g-family", - description: "Celtic/Medieval", - referenceTuning: "Open D", - deltaChanges: "From Open D: 3rd↓G", - capoPositions: { 2: "Key of E (modal)", 5: "Key of G (modal)" }, - songs: "D modal tunes, medieval tonality", - changedStrings: [false, false, true, false, false], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - "D Tuning (Reno)": { - strings: ["a", "D", "G", "B", "e"], - family: "g-family", - description: "Don Reno Style", - referenceTuning: "Open G", - deltaChanges: "From Open G: 5th↑A, 1st↑E", - capoPositions: { 2: "Key of E", 5: "Key of G" }, - songs: "Don Reno repertoire", - changedStrings: [true, false, false, false, true], - shortStringIndex: 0, - shortStringStartFret: 6 - }, - - // Other / 4-String Tunings - "Plectrum C": { - strings: ["C", "G", "B", "d"], - family: "other-family", - description: "4-String Plectrum", - referenceTuning: null, - deltaChanges: "(4-string plectrum standard)", - capoPositions: { 0: "Key of C" }, - songs: "Jazz, Dixieland", - changedStrings: [false, false, false, false], - shortStringIndex: null, - shortStringStartFret: null, - stringCount: 4 - }, - "Tenor (Standard)": { - strings: ["C", "G", "d", "a"], - family: "other-family", - description: "4-String Tenor CGDA", - referenceTuning: null, - deltaChanges: "(4-string tenor standard - fifths tuning)", - capoPositions: { 0: "Key of C/G" }, - songs: "Irish, Jazz, Traditional", - changedStrings: [false, false, false, false], - shortStringIndex: null, - shortStringStartFret: null, - stringCount: 4 - } - }; - - // ============================================ - // STATE MANAGEMENT - // ============================================ - let state = { - familyFilter: 'all', - capoPosition: 0, - fretCount: 15, - showNotes: true, - selectedFretboardTuning: 'Open G', - fretboardFamilyFilter: 'all', - useSolfege: false, - // Chord inversion state - showInversions: false, - inversionRootNote: 'G', - // Chord tab state - chordTuning: 'Open G', - chordKey: 'G', - chordTypeFilter: 'all', - fingerNotation: 'numbers', - chordViewMode: 'single' // 'single' or 'grid' (all keys) - }; - - function loadState() { - const saved = localStorage.getItem('banjoTuningsState'); - if (saved) { - try { - const parsed = JSON.parse(saved); - state = { ...state, ...parsed }; - } catch (e) { - console.error('Failed to load state:', e); - } - } - } - - function saveState() { - localStorage.setItem('banjoTuningsState', JSON.stringify(state)); - } - - // ============================================ - // NOTE FORMATTING - // ============================================ - function parseNoteWithOctave(noteStr) { - // Handle format like "G4", "C#3", "g" (lowercase = high octave) - const match = noteStr.match(/^([A-Ga-g][#b]?)(\d)?$/); - if (!match) return null; - - let note = match[1]; - let octave = match[2] ? parseInt(match[2]) : null; - - // Lowercase note = high octave (convention from Python script) - if (note === note.toLowerCase() && !octave) { - octave = 4; // High octave for 5th string - } else if (!octave) { - octave = 3; // Default octave - } - - return { note: normalizeNote(note), octave }; - } - - function formatNoteForDisplay(noteStr) { - const parsed = parseNoteWithOctave(noteStr); - if (!parsed) return noteStr; - - let note = displayNote(parsed.note); - // Convert to solfège if enabled - if (state.useSolfege) { - note = toSolfege(note); - } - // Replace # with sharp symbol, b with flat symbol - note = note.replace('#', '\u266F').replace('b', '\u266D'); - - return `${note}<sub>${parsed.octave}</sub>`; - } - - function formatSimpleNote(note) { - // Format a simple note (no octave) for fretboard display - let displayed = displayNote(normalizeNote(note)); - // Convert to solfège if enabled - if (state.useSolfege) { - displayed = toSolfege(displayed); - } - return displayed.replace('#', '\u266F').replace('b', '\u266D'); - } - - // ============================================ - // RENDERING FUNCTIONS - // ============================================ - function renderNoteSequence(strings) { - return strings.map(s => formatNoteForDisplay(s)).join(' '); - } - - function renderCapoInfo(capoPositions) { - return Object.entries(capoPositions) - .map(([pos, key]) => pos === '0' ? key : `Capo ${pos} → ${key}`) - .join(' | '); - } - - function renderTuningCard(name, tuning) { - const featuredBadge = tuning.featured ? ' ⭐' : ''; - const warningBadge = tuning.warning ? ' ⚠' : ''; - const stringCount = tuning.stringCount || 5; - const stringBadge = stringCount === 4 ? '<span class="string-count-badge">4-string</span>' : ''; - - // Render nut visual - const nutVisual = tuning.changedStrings.map((changed, i) => { - const stringNum = stringCount - i; - return `<div class="string-circle ${changed ? 'changed' : ''}">${stringNum}</div>`; - }).join(''); - - return ` - <div class="tuning-card ${tuning.family}"> - <div class="tuning-header"> - <span class="tuning-name">${name}${featuredBadge}${warningBadge}${stringBadge}</span> - <span class="genre-tag">${tuning.description}</span> - </div> - <div class="note-sequence">${renderNoteSequence(tuning.strings)}</div> - <div class="nut-visual"> - ${nutVisual} - </div> - <div class="tuning-details"> - <div class="delta-line">Δ: ${tuning.deltaChanges}</div> - <div class="capo-line">${renderCapoInfo(tuning.capoPositions)}</div> - <div class="songs-line">${tuning.songs}</div> - </div> - </div> - `; - } - - function renderTunings() { - const container = document.getElementById('tuningsContainer'); - const families = { - 'c-family': { name: 'C-Based Tunings', intro: 'These tunings center around C tonality, ranging from historical minstrel styles to modern old-time favorites. They create bright, modal sounds ideal for melodic playing.' }, - 'd-family': { name: 'D-Based Tunings', intro: 'D-based tunings create darker, more mysterious tonal landscapes. These tunings are favored for modal old-time pieces and offer rich harmonic possibilities.' }, - 'g-family': { name: 'G-Based Tunings', intro: 'The most common family including the standard bluegrass tuning. These tunings range from bright major chords to modal sounds, offering the widest versatility for different playing styles.' }, - 'other-family': { name: 'Other Tunings (4-String)', intro: '4-string banjo tunings for plectrum and tenor banjos. These instruments are commonly used in jazz, Dixieland, and Irish traditional music.' } - }; - - let html = ''; - - for (const [familyKey, familyInfo] of Object.entries(families)) { - if (state.familyFilter !== 'all' && state.familyFilter !== familyKey) continue; - - const tuningsInFamily = Object.entries(TUNINGS_DATA) - .filter(([_, t]) => t.family === familyKey); - - if (tuningsInFamily.length === 0) continue; - - html += ` - <div class="page-title">${familyInfo.name}</div> - <div class="family-intro ${familyKey}-intro"> - <strong>${familyKey.toUpperCase().replace('-', ' ')}:</strong> ${familyInfo.intro} - </div> - <div class="tunings-grid"> - ${tuningsInFamily.map(([name, tuning]) => renderTuningCard(name, tuning)).join('')} - </div> - `; - } - - container.innerHTML = html; - } - - // ============================================ - // CHORD INVERSION CALCULATION - // ============================================ - - // Calculate all major chord inversions for a root note on strings 4-3-2-1 - // Returns array of inversion objects with fret positions - function calculateMajorInversions(rootNote, tuningStrings, maxFret = 15) { - // Major chord intervals: Root=0, 3rd=4, 5th=7 - const ROOT = 0, THIRD = 4, FIFTH = 7; - - // Get pitch class of root note - const rootPitch = noteToPitchClass(rootNote); - const thirdPitch = (rootPitch + THIRD) % 12; - const fifthPitch = (rootPitch + FIFTH) % 12; - - // For Open G tuning, strings 4-3-2-1 are indices 1,2,3,4 (D,G,B,D) - // We use the 4 main strings for chord shapes - const mainStrings = tuningStrings.slice(1, 5); // D, G, B, D - const stringOpenPitches = mainStrings.map(s => noteToPitchClass(normalizeNote(s))); - - // Find fret for a target pitch on a given string - function findFret(stringPitch, targetPitch, startFret = 0) { - const diff = (targetPitch - stringPitch + 12) % 12; - let fret = diff; - if (fret < startFret) fret += 12; - return fret; - } - - // Three inversion patterns (intervals on strings 4-3-2-1): - // Root Form: 1-3-5-1 (root on string 4) - // 1st Inversion: 3-5-1-3 (3rd on string 4) - // 2nd Inversion: 5-1-3-5 (5th on string 4) - const inversionPatterns = [ - { name: 'Root Form', type: 'root', intervals: [ROOT, THIRD, FIFTH, ROOT], bassNote: 'Root', color: '#3366cc', label: '1 - 3 - 5 - 1' }, - { name: '1st Inversion', type: 'first', intervals: [THIRD, FIFTH, ROOT, THIRD], bassNote: '3rd', color: '#cc3333', label: '3 - 5 - 1 - 3' }, - { name: '2nd Inversion', type: 'second', intervals: [FIFTH, ROOT, THIRD, FIFTH], bassNote: '5th', color: '#cc9900', label: '5 - 1 - 3 - 5' } - ]; - - const inversions = []; - - // Find all occurrences of each inversion pattern - for (const pattern of inversionPatterns) { - // Calculate target pitches for each string - const targetPitches = pattern.intervals.map(interval => (rootPitch + interval) % 12); - - // Find base fret positions (lowest occurrence) - const baseFrets = []; - for (let i = 0; i < 4; i++) { - baseFrets.push(findFret(stringOpenPitches[i], targetPitches[i], 0)); - } - - // Find the minimum fret to normalize the shape - const minFret = Math.min(...baseFrets.filter(f => f > 0)); - const maxFretInShape = Math.max(...baseFrets); - - // Find all occurrences within the fret range - for (let offset = 0; offset <= maxFret; offset += 12) { - const frets = baseFrets.map(f => f === 0 ? 0 : f + offset - (minFret > 0 ? minFret - baseFrets.find(bf => bf === minFret) : 0)); - const adjustedFrets = baseFrets.map(f => { - let adjusted = f + offset; - // Handle open strings (fret 0) - if (f === 0 && offset === 0) return 0; - if (f === 0 && offset > 0) adjusted = 12 + offset; - while (adjusted > maxFret + 5) adjusted -= 12; - return adjusted; - }); - - // Check if this shape is within the displayable range - const shapeFrets = adjustedFrets.filter(f => f >= 0); - if (shapeFrets.length > 0 && Math.min(...shapeFrets) <= maxFret) { - inversions.push({ - ...pattern, - frets: adjustedFrets, - baseFret: Math.min(...adjustedFrets.filter(f => f > 0)) || 0 - }); - } - } - } - - // Sort by base fret position - inversions.sort((a, b) => a.baseFret - b.baseFret); - - return inversions; - } - - // Render chord inversions on fretboard SVG - function renderInversionsOnFretboard(svg, rootNote, tuning, fbLeft, fbTop, stringSpacing, fretHeight, numFrets, svgWidth) { - const inversions = calculateMajorInversions(rootNote, tuning.strings, numFrets); - const displayedNote = displayNote(rootNote); - - // Filter inversions that are fully visible - const visibleInversions = inversions.filter(inv => { - const maxFretInShape = Math.max(...inv.frets); - return maxFretInShape <= numFrets && inv.baseFret >= 1; - }); - - // Render each inversion - visibleInversions.forEach((inv, idx) => { - // Calculate positions (strings 4-3-2-1 are at indices 1,2,3,4 in the display) - const positions = []; - for (let i = 0; i < 4; i++) { - const stringIndex = i + 1; // Skip 5th string (index 0) - const x = fbLeft + stringIndex * stringSpacing; - const fret = inv.frets[i]; - const y = fbTop + (fret - 0.5) * fretHeight; - positions.push({ x, y, fret, stringIndex }); - } - - // Draw colored overlay connecting the notes (triangle or polygon) - const validPositions = positions.filter(p => p.fret > 0 && p.fret <= numFrets); - if (validPositions.length >= 3) { - // Create polygon points - const points = validPositions.map(p => `${p.x},${p.y}`).join(' '); - svg += `<polygon points="${points}" fill="${inv.color}" fill-opacity="0.25" stroke="${inv.color}" stroke-width="2"/>`; - } - - // Draw dots on chord positions - positions.forEach(pos => { - if (pos.fret > 0 && pos.fret <= numFrets) { - svg += `<circle cx="${pos.x}" cy="${pos.y}" r="12" fill="${inv.color}" fill-opacity="0.8" stroke="white" stroke-width="2"/>`; - } - }); - - // Annotation box on the right side - const annotationX = fbLeft + 5 * stringSpacing + 30; - const annotationY = fbTop + (inv.baseFret - 0.5) * fretHeight; - - if (inv.baseFret <= numFrets - 2) { - // Background for annotation - svg += `<rect x="${annotationX}" y="${annotationY - 30}" width="140" height="70" fill="white" stroke="${inv.color}" stroke-width="2" rx="5"/>`; - - // Chord name and inversion type - svg += `<text x="${annotationX + 70}" y="${annotationY - 12}" text-anchor="middle" font-size="12" font-weight="bold" fill="${inv.color}">"${displayedNote}" CHORD</text>`; - svg += `<text x="${annotationX + 70}" y="${annotationY + 5}" text-anchor="middle" font-size="11" font-weight="bold" fill="#333">${inv.name.toUpperCase()}</text>`; - - // Bass note info - svg += `<text x="${annotationX + 70}" y="${annotationY + 20}" text-anchor="middle" font-size="10" fill="#666">${inv.bassNote} is bass note</text>`; - - // Interval structure - svg += `<text x="${annotationX + 70}" y="${annotationY + 35}" text-anchor="middle" font-size="10" fill="#666">[${inv.label}]</text>`; - } - }); - - // Draw 5-fret rule connectors between consecutive inversions - for (let i = 0; i < visibleInversions.length - 1; i++) { - const current = visibleInversions[i]; - const next = visibleInversions[i + 1]; - - // The rule: 3rd string of current -> 4th string of next (5 frets higher) - // String 3 is at index 2 (0-indexed in our positions), String 4 is at index 1 - const fromStringIdx = 2; // 3rd string (G string) - const toStringIdx = 1; // 4th string (D string) - - const fromX = fbLeft + (fromStringIdx + 1) * stringSpacing; - const fromY = fbTop + (current.frets[fromStringIdx] - 0.5) * fretHeight; - const toX = fbLeft + (toStringIdx + 1) * stringSpacing; - const toY = fbTop + (next.frets[toStringIdx] - 0.5) * fretHeight; - - if (current.frets[fromStringIdx] <= numFrets && next.frets[toStringIdx] <= numFrets) { - // Draw arrow - svg += `<line x1="${fromX}" y1="${fromY}" x2="${toX}" y2="${toY}" stroke="#cc0000" stroke-width="3" marker-end="url(#arrowhead)"/>`; - - // Label showing "5 frets" - const midX = (fromX + toX) / 2 - 25; - const midY = (fromY + toY) / 2; - svg += `<text x="${midX}" y="${midY}" font-size="10" font-weight="bold" fill="#cc0000">5 frets</text>`; - } - } - - // Add cycling indicator at bottom - if (visibleInversions.length > 0) { - const bottomY = fbTop + numFrets * fretHeight + 10; - svg += `<text x="${fbLeft + 2.5 * stringSpacing}" y="${bottomY}" text-anchor="middle" font-size="11" fill="#666">↓ Inversions keep cycling ↓</text>`; - } - - return svg; - } - - // ============================================ - // FRETBOARD SVG GENERATOR (Enhanced from Python) - // ============================================ - function generateFretboardSVG(tuningName) { - const tuning = TUNINGS_DATA[tuningName]; - if (!tuning) return ''; - - const numFrets = state.fretCount; - const capo = state.capoPosition; - const showNotes = state.showNotes; - - const strings = tuning.strings; - const numStrings = strings.length; - const shortStringIdx = tuning.shortStringIndex; - const shortStringStart = tuning.shortStringStartFret; - - // Determine which strings should be lowercase (first occurrence of duplicate notes) - // e.g., for tuning with notes F C F A C, the first F and first C should be lowercase: f c F A C - const lowercaseIndices = new Set(); - const seenNotes = new Map(); // normalized note -> first index - for (let i = 0; i < strings.length; i++) { - const normalized = normalizeNote(strings[i]).toUpperCase(); - if (!seenNotes.has(normalized)) { - seenNotes.set(normalized, i); - } else { - // This note has appeared before, mark the first occurrence as lowercase - lowercaseIndices.add(seenNotes.get(normalized)); - } - } - - // SVG dimensions - const marginLeft = 100; - const marginRight = 150; - const marginTop = capo > 0 ? 160 : 140; // Extra space for effective tuning display - const marginBottom = 80; - const stringSpacing = 55; - const fretHeight = 50; - - const fretboardWidth = (numStrings - 1) * stringSpacing; - const fretboardHeight = numFrets * fretHeight; - const svgWidth = marginLeft + fretboardWidth + marginRight; - const svgHeight = marginTop + fretboardHeight + marginBottom; - - const fbLeft = marginLeft; - const fbTop = marginTop; - const fbRight = fbLeft + fretboardWidth; - const bottomY = fbTop + fretboardHeight; - - // Fret markers - const markerFrets = [3, 5, 7, 10, 12, 15, 17]; - const doubleMarkerFrets = [12]; - - let svg = `<svg class="fretboard-svg" width="${svgWidth}" height="${svgHeight}" xmlns="http://www.w3.org/2000/svg">`; - - // Styles and definitions - svg += ` - <defs> - <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"> - <polygon points="0 0, 10 3.5, 0 7" fill="#cc0000"/> - </marker> - </defs> - <style> - .title { font-family: Georgia, serif; font-size: 24px; font-weight: bold; fill: #000; } - .subtitle { font-family: Georgia, serif; font-size: 16px; font-style: italic; fill: #333; } - .string-label { font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold; fill: #000; } - .fret-number { font-family: Arial, sans-serif; font-size: 16px; font-weight: bold; fill: #333; } - .note { font-family: Arial, sans-serif; font-size: 12px; font-weight: bold; fill: #000; } - .note-circle { fill: white; stroke: #000; stroke-width: 1.5; } - .note-circle-natural { fill: white; stroke: #000; stroke-width: 4; } - .fret-line { stroke: #000; stroke-width: 2; } - .string-line { stroke: #444; stroke-width: 1.5; } - .nut { stroke: #000; stroke-width: 6; } - .fret-marker { fill: #ddd; stroke: none; } - .peg { fill: #333; stroke: #000; stroke-width: 1; } - .ghost-string { stroke: #999; stroke-width: 1; stroke-dasharray: 3,3; } - .fret-marker-symbol { fill: #000; stroke: none; } - </style>`; - - // Background - svg += `<rect width="100%" height="100%" fill="white"/>`; - - // Title - use lowercase for first occurrence of duplicate notes (e.g., fCFAC -> fcFAC) - const tuningStr = strings.map((s, idx) => { - let note = displayNote(normalizeNote(s)); - // Convert to solfège if enabled - if (state.useSolfege) { - note = toSolfege(note); - } - // Use lowercase if this index is marked as first occurrence of a duplicate - return lowercaseIndices.has(idx) ? note.toLowerCase() : note; - }).join(''); - - // Calculate effective tuning with capo - let effectiveTuningStr = ''; - if (capo > 0) { - effectiveTuningStr = strings.map((s, idx) => { - // For short string, calculate from its actual starting point - let effectiveCapo = capo; - if (idx === shortStringIdx && shortStringStart !== null) { - // Short string: capo only affects it if capo >= shortStringStart - if (capo < shortStringStart) { - effectiveCapo = 0; // Capo doesn't affect short string - } else { - effectiveCapo = capo - (shortStringStart - 1); - } - } - const transposedNote = getNoteAtFret(s, effectiveCapo); - let note = displayNote(normalizeNote(transposedNote)); - if (state.useSolfege) { - note = toSolfege(note); - } - return lowercaseIndices.has(idx) ? note.toLowerCase() : note; - }).join(''); - } - - svg += `<text x="${svgWidth / 2}" y="45" class="title" text-anchor="middle">Banjo Fretboard: ${tuningName}</text>`; - - // Subtitle with effective tuning when capo is applied - if (capo > 0) { - svg += `<text x="${svgWidth / 2}" y="70" class="subtitle" text-anchor="middle">Open Tuning: ${tuningStr} — ${tuning.description}</text>`; - svg += `<text x="${svgWidth / 2}" y="90" class="subtitle" text-anchor="middle" style="fill: #c00; font-weight: bold;">Capo ${capo}: Effective Tuning: ${effectiveTuningStr}</text>`; - } else { - svg += `<text x="${svgWidth / 2}" y="70" class="subtitle" text-anchor="middle">Tuning: ${tuningStr} — ${tuning.description}</text>`; - } - - // Fret markers (on fretboard face) - for (const fret of markerFrets) { - if (fret <= numFrets) { - const y = fbTop + (fret - 0.5) * fretHeight; - if (doubleMarkerFrets.includes(fret)) { - svg += `<circle cx="${fbLeft + fretboardWidth/2 - 15}" cy="${y}" r="7" class="fret-marker"/>`; - svg += `<circle cx="${fbLeft + fretboardWidth/2 + 15}" cy="${y}" r="7" class="fret-marker"/>`; - } else { - svg += `<circle cx="${fbLeft + fretboardWidth/2}" cy="${y}" r="7" class="fret-marker"/>`; - } - } - } - - // Nut - if (shortStringIdx !== null && shortStringStart !== null) { - const nutStartX = fbLeft + stringSpacing - 10; - svg += `<line x1="${nutStartX}" y1="${fbTop}" x2="${fbRight + 10}" y2="${fbTop}" class="nut"/>`; - } else { - svg += `<line x1="${fbLeft - 10}" y1="${fbTop}" x2="${fbRight + 10}" y2="${fbTop}" class="nut"/>`; - } - - // Fret lines with ordinal labels on right - for (let fret = 1; fret <= numFrets; fret++) { - const y = fbTop + fret * fretHeight; - - let lineStartX, lineEndX; - if (shortStringIdx !== null && shortStringStart !== null && fret < shortStringStart) { - lineStartX = fbLeft + stringSpacing - 10; - lineEndX = fbRight + 10; - } else { - lineStartX = fbLeft - 10; - lineEndX = fbRight + 10; - } - - // Extend line for marker frets - if (markerFrets.includes(fret)) { - lineEndX += 20; - if (fret === 5) { - // 5th fret extends to the left edge (where 5th string starts) - lineStartX = 90; - } - } - - svg += `<line x1="${lineStartX}" y1="${y}" x2="${lineEndX}" y2="${y}" class="fret-line"/>`; - - // Ordinal fret labels on right - if (markerFrets.includes(fret)) { - const ordinal = fret + (fret === 1 ? 'st' : fret === 2 ? 'nd' : fret === 3 ? 'rd' : 'th') + ' fret'; - const textX = fbRight + 70; - svg += `<text x="${textX}" y="${y}" class="fret-number" text-anchor="middle" dominant-baseline="middle">${ordinal}</text>`; - - // Marker symbols - const symbolX = textX + 50; - if (fret === 5) { - // Star for 5th fret - const starPoints = `${symbolX},${y-7} ${symbolX+2},${y-2} ${symbolX+7},${y-2} ${symbolX+3},${y+1} ${symbolX+5},${y+6} ${symbolX},${y+3} ${symbolX-5},${y+6} ${symbolX-3},${y+1} ${symbolX-7},${y-2} ${symbolX-2},${y-2}`; - svg += `<polygon points="${starPoints}" class="fret-marker-symbol"/>`; - } else if (fret === 12) { - // Double circles - svg += `<circle cx="${symbolX - 8}" cy="${y}" r="6" class="fret-marker-symbol"/>`; - svg += `<circle cx="${symbolX + 8}" cy="${y}" r="6" class="fret-marker-symbol"/>`; - } else { - // Single circle - svg += `<circle cx="${symbolX}" cy="${y}" r="6" class="fret-marker-symbol"/>`; - } - } - } - - // Neck edges with diagonal for short string - if (shortStringIdx !== null && shortStringStart !== null) { - const nutLeftX = fbLeft + stringSpacing - 10; - const fret4Y = fbTop + 4 * fretHeight; - const fret5Y = fbTop + 5 * fretHeight; - const leftEdgeX = fbLeft - 10; - - // Ghost string line (dotted) - svg += `<line x1="${fbLeft}" y1="${fbTop}" x2="${fbLeft}" y2="${fret5Y - fretHeight}" class="ghost-string"/>`; - - // Polyline neck edge - svg += `<polyline points="${nutLeftX},${fbTop} ${nutLeftX},${fret4Y} ${leftEdgeX},${fret5Y} ${leftEdgeX},${bottomY}" style="stroke:#000; stroke-width:3; fill:none;"/>`; - svg += `<line x1="${fbRight + 10}" y1="${fbTop}" x2="${fbRight + 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; - svg += `<line x1="${leftEdgeX}" y1="${bottomY}" x2="${fbRight + 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; - } else { - // 4-string: rectangular edges - svg += `<line x1="${fbLeft - 10}" y1="${fbTop}" x2="${fbLeft - 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; - svg += `<line x1="${fbRight + 10}" y1="${fbTop}" x2="${fbRight + 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; - svg += `<line x1="${fbLeft - 10}" y1="${bottomY}" x2="${fbRight + 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; - } - - // String lines - for (let i = 0; i < numStrings; i++) { - const x = fbLeft + i * stringSpacing; - - if (i === shortStringIdx && shortStringStart !== null) { - const startY = fbTop + (shortStringStart - 1) * fretHeight; - svg += `<line x1="${x}" y1="${startY}" x2="${x}" y2="${bottomY}" class="string-line" style="stroke-width:2;"/>`; - - // Peg indicator - const pegY = startY - 50; - const pegHeadX = fbLeft - 10; - const pegShaftStartX = fbLeft + 30; - svg += `<rect x="${pegHeadX}" y="${pegY - 5}" width="${pegShaftStartX - pegHeadX}" height="8" class="peg" rx="2"/>`; - svg += `<circle cx="${pegHeadX}" cy="${pegY}" r="10" class="peg"/>`; - svg += `<circle cx="${pegHeadX}" cy="${pegY}" r="4" fill="white"/>`; - } else { - svg += `<line x1="${x}" y1="${fbTop}" x2="${x}" y2="${bottomY}" class="string-line"/>`; - } - } - - // String labels at top (lowercase for first occurrence of duplicate notes) - for (let i = 0; i < numStrings; i++) { - const x = fbLeft + i * stringSpacing; - const stringNumber = numStrings - i; - const note = strings[i]; - - // Use lowercase if this is the first occurrence of a duplicate note - const baseNote = normalizeNote(note); - let displayedBase = displayNote(baseNote); - // Convert to solfège if enabled - if (state.useSolfege) { - displayedBase = toSolfege(displayedBase); - } - // Apply lowercase if marked, then replace symbols - let displayed = lowercaseIndices.has(i) ? displayedBase.toLowerCase() : displayedBase; - displayed = displayed.replace('#', '\u266F').replace('b', '\u266D'); - - const numberY = fbTop - 35; - const labelY = fbTop - 12; - - svg += `<text x="${x}" y="${numberY}" class="fret-number" text-anchor="middle">${stringNumber}</text>`; - svg += `<text x="${x}" y="${labelY}" class="string-label" text-anchor="middle">${displayed}</text>`; - } - - // Notes at each fret position - if (showNotes) { - for (let fret = 1; fret <= numFrets; fret++) { - const y = fbTop + (fret - 0.5) * fretHeight; - - // Check if this fret is above (behind) the capo - const isBehindCapo = capo > 0 && fret < capo; - - for (let i = 0; i < numStrings; i++) { - const x = fbLeft + i * stringSpacing; - const openNote = strings[i]; - - // Handle short string - if (i === shortStringIdx && shortStringStart !== null) { - if (fret < shortStringStart) { - continue; // No notes in empty space - } - // Calculate effective fret for short string - const effectiveFret = fret - (shortStringStart - 1); - const note = getNoteAtFret(openNote, effectiveFret); - const displayedNote = formatSimpleNote(note); - - // Gray out notes behind capo, use 8-ball style for natural notes - const isNatural = isNaturalNote(note); - const circleClass = isNatural ? 'note-circle-natural' : 'note-circle'; - const circleStyle = isBehindCapo ? 'fill: #e0e0e0; stroke: #bbb;' : ''; - const textStyle = isBehindCapo ? 'fill: #aaa;' : ''; - - svg += `<circle cx="${x}" cy="${y}" r="16" class="${circleClass}" style="${circleStyle}"/>`; - svg += `<text x="${x}" y="${y + 5}" class="note" text-anchor="middle" style="${textStyle}">${displayedNote}</text>`; - } else { - const note = getNoteAtFret(openNote, fret); - const displayedNote = formatSimpleNote(note); - - // Gray out notes behind capo, use 8-ball style for natural notes - const isNatural = isNaturalNote(note); - const circleClass = isNatural ? 'note-circle-natural' : 'note-circle'; - const circleStyle = isBehindCapo ? 'fill: #e0e0e0; stroke: #bbb;' : ''; - const textStyle = isBehindCapo ? 'fill: #aaa;' : ''; - - svg += `<circle cx="${x}" cy="${y}" r="16" class="${circleClass}" style="${circleStyle}"/>`; - svg += `<text x="${x}" y="${y + 5}" class="note" text-anchor="middle" style="${textStyle}">${displayedNote}</text>`; - } - } - } - } - - // Capo indicator - if (capo > 0) { - const capoY = fbTop + (capo - 0.5) * fretHeight; - svg += `<rect x="${fbLeft - 15}" y="${capoY - 8}" width="${fretboardWidth + 30}" height="16" fill="#333" rx="4" opacity="0.9"/>`; - svg += `<text x="${fbLeft + fretboardWidth/2}" y="${capoY + 4}" text-anchor="middle" font-size="10" fill="white" font-weight="bold">CAPO ${capo}</text>`; - } - - // Chord inversions overlay (if enabled) - if (state.showInversions && numStrings >= 5) { - svg = renderInversionsOnFretboard(svg, state.inversionRootNote, tuning, fbLeft, fbTop, stringSpacing, fretHeight, numFrets, svgWidth); - } - - // Footer - const footerY = fbTop + fretboardHeight + 30; - svg += `<rect x="${marginLeft-50}" y="${footerY}" width="${fretboardWidth+150}" height="30" rx="5" fill="#f5f5f5" stroke="#ccc"/>`; - const footerLine1 = shortStringStart - ? `String 1 (far right) = 1st string • 5th string starts at fret ${shortStringStart}` - : `String 1 (far right) = 1st string • ${numStrings}-string banjo`; - svg += `<text x="${svgWidth/2}" y="${footerY + 12}" text-anchor="middle" font-size="10" fill="#555">${footerLine1}</text>`; - svg += `<text x="${svgWidth/2}" y="${footerY + 24}" text-anchor="middle" font-size="10" fill="#555">Fret markers: 3,5,7,10,12,15,17 • Peg indicates 5th string tuning point</text>`; - - svg += '</svg>'; - return svg; - } - - function renderFretboard() { - const container = document.getElementById('fretboardContainer'); - container.innerHTML = generateFretboardSVG(state.selectedFretboardTuning); - } - - function populateTuningList() { - const list = document.getElementById('tuningList'); - const familyFilter = state.fretboardFamilyFilter || 'all'; - - const families = { - 'c-family': 'C-Based Tunings', - 'd-family': 'D-Based Tunings', - 'g-family': 'G-Based Tunings', - 'other-family': 'Other (4-String)' - }; - - let html = ''; - - for (const [familyKey, familyName] of Object.entries(families)) { - if (familyFilter !== 'all' && familyFilter !== familyKey) continue; - - const tuningsInFamily = Object.entries(TUNINGS_DATA) - .filter(([_, t]) => t.family === familyKey); - - if (tuningsInFamily.length === 0) continue; - - // Add group header if showing all families - if (familyFilter === 'all') { - html += `<div class="tuning-list-group">${familyName}</div>`; - } - - for (const [name, tuning] of tuningsInFamily) { - const isSelected = name === state.selectedFretboardTuning; - - // Determine which indices should be lowercase (first occurrence of duplicates) - const lowercaseIdx = new Set(); - const seenNotes = new Map(); - for (let i = 0; i < tuning.strings.length; i++) { - const normalized = normalizeNote(tuning.strings[i]).toUpperCase(); - if (!seenNotes.has(normalized)) { - seenNotes.set(normalized, i); - } else { - lowercaseIdx.add(seenNotes.get(normalized)); - } - } - - const tuningStr = tuning.strings.map((s, idx) => { - let note = displayNote(normalizeNote(s)); - // Convert to solfège if enabled - if (state.useSolfege) { - note = toSolfege(note); - } - return lowercaseIdx.has(idx) ? note.toLowerCase() : note; - }).join(' '); - - html += ` - <div class="tuning-list-item ${tuning.family} ${isSelected ? 'selected' : ''}" - data-tuning="${name}"> - <span class="tuning-item-name">${name}</span> - <span class="tuning-item-notes">${tuningStr}</span> - </div> - `; - } - } - - list.innerHTML = html; - - // Add click handlers - list.querySelectorAll('.tuning-list-item').forEach(item => { - item.addEventListener('click', () => { - const tuningName = item.dataset.tuning; - state.selectedFretboardTuning = tuningName; - saveState(); - populateTuningList(); // Re-render to update selection - renderFretboard(); - }); - }); - } - - // ============================================ - // EVENT HANDLERS - // ============================================ - function setupEventListeners() { - document.getElementById('familyFilter').addEventListener('change', (e) => { - state.familyFilter = e.target.value; - saveState(); - renderTunings(); - }); - - document.getElementById('capoPosition').addEventListener('change', (e) => { - state.capoPosition = parseInt(e.target.value) || 0; - saveState(); - renderTunings(); - renderFretboard(); - }); - - document.getElementById('fretCount').addEventListener('change', (e) => { - state.fretCount = parseInt(e.target.value); - saveState(); - renderFretboard(); - }); - - document.getElementById('showNotesToggle').addEventListener('change', (e) => { - state.showNotes = e.target.checked; - saveState(); - renderFretboard(); - }); - - document.getElementById('solfegeToggle').addEventListener('change', (e) => { - state.useSolfege = e.target.checked; - saveState(); - renderTunings(); - renderFretboard(); - populateTuningList(); - }); - - // Chord inversion controls - document.getElementById('showInversionsToggle').addEventListener('change', (e) => { - state.showInversions = e.target.checked; - saveState(); - renderFretboard(); - }); - - document.getElementById('inversionRootNote').addEventListener('change', (e) => { - state.inversionRootNote = e.target.value; - saveState(); - renderFretboard(); - }); - - // Fretboard family filter - document.getElementById('fretboardFamilyFilter').addEventListener('change', (e) => { - state.fretboardFamilyFilter = e.target.value; - saveState(); - populateTuningList(); - }); - - document.querySelectorAll('.tab').forEach(tab => { - tab.addEventListener('click', (e) => { - const tabName = e.target.dataset.tab; - document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - e.target.classList.add('active'); - document.getElementById(`${tabName}-tab`).classList.add('active'); - }); - }); - } - - function resetSettings() { - state = { - familyFilter: 'all', - capoPosition: 0, - fretCount: 15, - showNotes: true, - selectedFretboardTuning: 'Open G', - fretboardFamilyFilter: 'all', - useSolfege: false, - showInversions: false, - inversionRootNote: 'G', - chordTuning: 'Open G', - chordKey: 'G', - chordTypeFilter: 'all', - fingerNotation: 'numbers', - chordViewMode: 'single' - }; - saveState(); - applyStateToUI(); - populateTuningList(); - renderTunings(); - renderFretboard(); - initChordTab(); - } - - function applyStateToUI() { - document.getElementById('familyFilter').value = state.familyFilter; - document.getElementById('capoPosition').value = state.capoPosition; - document.getElementById('fretCount').value = state.fretCount; - document.getElementById('showNotesToggle').checked = state.showNotes; - document.getElementById('solfegeToggle').checked = state.useSolfege || false; - document.getElementById('fretboardFamilyFilter').value = state.fretboardFamilyFilter || 'all'; - document.getElementById('showInversionsToggle').checked = state.showInversions || false; - document.getElementById('inversionRootNote').value = state.inversionRootNote || 'G'; - - // Chord tab UI - const chordTuningSelect = document.getElementById('chordTuningSelect'); - if (chordTuningSelect) { - chordTuningSelect.value = state.chordTuning || 'Open G'; - } - - // Finger notation radio - const fingerRadio = document.querySelector(`input[name="fingerNotation"][value="${state.fingerNotation || 'numbers'}"]`); - if (fingerRadio) { - fingerRadio.checked = true; - } - } - - // ============================================ - // CHORD TAB FUNCTIONS - // ============================================ - function populateChordTuningSelect() { - const select = document.getElementById('chordTuningSelect'); - if (!select) return; - - select.innerHTML = ''; - - // Group tunings by family - const families = { - 'g-family': 'G-Based', - 'c-family': 'C-Based', - 'd-family': 'D-Based', - 'other-family': 'Other (4-String)' - }; - - for (const [familyKey, familyName] of Object.entries(families)) { - const group = document.createElement('optgroup'); - group.label = familyName; - - for (const [name, data] of Object.entries(TUNINGS_DATA)) { - if (data.family === familyKey) { - const option = document.createElement('option'); - option.value = name; - option.textContent = name; - if (name === state.chordTuning) { - option.selected = true; - } - group.appendChild(option); - } - } - - select.appendChild(group); - } - } - - function populateKeyButtons() { - const container = document.getElementById('keyButtons'); - if (!container) return; - - container.innerHTML = CHORD_KEYS.map(key => { - const isActive = state.chordKey === key.root ? 'active' : ''; - return `<button class="key-btn ${isActive}" data-key="${key.root}">${key.display}</button>`; - }).join(''); - - // Add click handlers - container.querySelectorAll('.key-btn').forEach(btn => { - btn.addEventListener('click', () => { - state.chordKey = btn.dataset.key; - saveState(); - populateKeyButtons(); - renderChords(); - }); - }); - } - - function populateChordTypeButtons() { - const container = document.getElementById('chordTypeButtons'); - if (!container) return; - - container.innerHTML = ` - <button class="chord-type-btn ${state.chordTypeFilter === 'all' ? 'active' : ''}" data-type="all">All</button> - ${CHORD_TYPE_ORDER.map(key => { - const type = CHORD_TYPES[key]; - const isActive = state.chordTypeFilter === key ? 'active' : ''; - return `<button class="chord-type-btn ${isActive}" data-type="${key}">${type.name}</button>`; - }).join('')} - `; - - // Add click handlers - container.querySelectorAll('.chord-type-btn').forEach(btn => { - btn.addEventListener('click', () => { - state.chordTypeFilter = btn.dataset.type; - saveState(); - populateChordTypeButtons(); - renderChords(); - }); - }); - } - - function populateViewModeButtons() { - const container = document.getElementById('viewModeButtons'); - if (!container) return; - - container.innerHTML = ` - <button class="view-mode-btn ${state.chordViewMode === 'single' ? 'active' : ''}" data-mode="single">Single Key</button> - <button class="view-mode-btn ${state.chordViewMode === 'grid' ? 'active' : ''}" data-mode="grid">All Keys Grid</button> - `; - - // Add click handlers - container.querySelectorAll('.view-mode-btn').forEach(btn => { - btn.addEventListener('click', () => { - state.chordViewMode = btn.dataset.mode; - saveState(); - populateViewModeButtons(); - updateKeyButtonsVisibility(); - renderChords(); - }); - }); - } - - function updateKeyButtonsVisibility() { - const keyButtonsContainer = document.getElementById('keyButtonsContainer'); - if (keyButtonsContainer) { - // Hide key buttons in grid mode (since all keys are shown) - keyButtonsContainer.style.display = state.chordViewMode === 'grid' ? 'none' : 'flex'; - } - } - - function renderChords() { - const container = document.getElementById('chordContainer'); - if (!container) return; - - const tuningData = TUNINGS_DATA[state.chordTuning]; - if (!tuningData) { - container.innerHTML = '<div class="no-chord-message">Select a tuning to view chords</div>'; - return; - } - - const fingerNotation = document.querySelector('input[name="fingerNotation"]:checked')?.value || 'numbers'; - - // Check view mode - if (state.chordViewMode === 'grid') { - renderChordsGrid(container, tuningData, fingerNotation); - } else { - renderChordsSingle(container, tuningData, fingerNotation); - } - } - - function renderChordsSingle(container, tuningData, fingerNotation) { - // Determine which chord types to show (use ordered list) - const typesToShow = state.chordTypeFilter === 'all' - ? CHORD_TYPE_ORDER - : [state.chordTypeFilter]; - - let html = ''; - - typesToShow.forEach(chordType => { - const type = CHORD_TYPES[chordType]; - const chordName = `${state.chordKey}${type.symbol}`; - - // Find voicing for this chord - const voicing = findChordVoicing(tuningData, state.chordKey, chordType, 12); - - if (voicing) { - const svg = generateChordDiagramSVG(voicing, chordName, state.chordTuning, fingerNotation); - - html += ` - <div class="chord-diagram-container"> - <div class="chord-diagram-title">${displayNote(state.chordKey)}${type.symbol}</div> - <div class="chord-diagram-subtitle">${type.name}</div> - ${svg} - <div class="chord-notes-display"> - Notes: ${voicing.chordNotes.join(' - ')} - </div> - </div> - `; - } else { - html += ` - <div class="chord-diagram-container"> - <div class="chord-diagram-title">${displayNote(state.chordKey)}${type.symbol}</div> - <div class="chord-diagram-subtitle">${type.name}</div> - <div class="no-chord-message">Voicing not available for this tuning</div> - </div> - `; - } - }); - - container.className = 'chord-grid'; - container.innerHTML = html; - } - - function renderChordsGrid(container, tuningData, fingerNotation) { - // Determine which chord types to show as columns (use ordered groups) - const typesToShow = state.chordTypeFilter === 'all' - ? CHORD_TYPE_ORDER - : [state.chordTypeFilter]; - - // Helper to check if we need a separator after this chord type - function needsSeparatorAfter(chordType) { - if (state.chordTypeFilter !== 'all') return false; - for (let i = 0; i < CHORD_TYPE_GROUPS.length - 1; i++) { - const group = CHORD_TYPE_GROUPS[i]; - if (group[group.length - 1] === chordType) return true; - } - return false; - } - - // Build table header with chord types and separators - let headerHtml = '<tr><th class="key-header">Key</th>'; - typesToShow.forEach(chordType => { - const type = CHORD_TYPES[chordType]; - headerHtml += `<th>${type.name}</th>`; - if (needsSeparatorAfter(chordType)) { - headerHtml += '<th class="group-separator"></th>'; - } - }); - headerHtml += '</tr>'; - - // Build table rows for each key - let rowsHtml = ''; - CHORD_KEYS.forEach((key, keyIndex) => { - // Add page break class before F#/Gb (index 6) for print - const pageBreakClass = key.root === 'F#' ? ' class="page-break-before"' : ''; - rowsHtml += `<tr${pageBreakClass}><td class="key-cell">${key.display}</td>`; - - typesToShow.forEach(chordType => { - const type = CHORD_TYPES[chordType]; - const voicing = findChordVoicing(tuningData, key.root, chordType, 12); - - if (voicing) { - const svg = generateChordDiagramSVG(voicing, `${key.root}${type.symbol}`, state.chordTuning, fingerNotation); - rowsHtml += ` - <td class="grid-chord-cell"> - <div class="grid-chord-diagram"> - <div class="grid-chord-title">${displayNote(key.root)}${type.symbol}</div> - ${svg} - <div class="grid-chord-notes">${voicing.chordNotes.join(' - ')}</div> - </div> - </td> - `; - } else { - rowsHtml += ` - <td class="grid-chord-cell"> - <div class="grid-no-voicing">Not available</div> - </td> - `; - } - - if (needsSeparatorAfter(chordType)) { - rowsHtml += '<td class="group-separator"></td>'; - } - }); - - rowsHtml += '</tr>'; - }); - - container.className = 'chord-keys-grid-container'; - container.innerHTML = ` - <table class="chord-keys-grid"> - <thead>${headerHtml}</thead> - <tbody>${rowsHtml}</tbody> - </table> - `; - } - - function setupChordEventListeners() { - // Tuning selector - const tuningSelect = document.getElementById('chordTuningSelect'); - if (tuningSelect) { - tuningSelect.addEventListener('change', (e) => { - state.chordTuning = e.target.value; - saveState(); - renderChords(); - }); - } - - // Finger notation toggle - document.querySelectorAll('input[name="fingerNotation"]').forEach(radio => { - radio.addEventListener('change', () => { - state.fingerNotation = document.querySelector('input[name="fingerNotation"]:checked')?.value || 'numbers'; - saveState(); - renderChords(); - }); - }); - } - - function initChordTab() { - populateChordTuningSelect(); - populateViewModeButtons(); - populateKeyButtons(); - populateChordTypeButtons(); - setupChordEventListeners(); - updateKeyButtonsVisibility(); - renderChords(); - } - - // ============================================ - // INITIALIZATION - // ============================================ - function init() { - loadState(); - applyStateToUI(); - populateTuningList(); - setupEventListeners(); - renderTunings(); - renderFretboard(); - initChordTab(); - } - document.addEventListener('DOMContentLoaded', init); - </script> + <!-- JavaScript files (order matters) --> + <script src="js/utils.js"></script> + <script src="js/tunings.js"></script> + <script src="js/chords.js"></script> + <script src="js/fretboard.js"></script> + <script src="js/app.js"></script> </body> </html> @@ -0,0 +1,528 @@ +// Application State and UI Logic + +// ============================================ +// STATE MANAGEMENT +// ============================================ +let state = { + familyFilter: 'all', + capoPosition: 0, + fretCount: 15, + showNotes: true, + selectedFretboardTuning: 'Open G', + fretboardFamilyFilter: 'all', + useSolfege: false, + // Chord inversion state + showInversions: false, + inversionRootNote: 'G', + // Chord tab state + chordTuning: 'Open G', + chordKey: 'G', + chordTypeFilter: 'all', + fingerNotation: 'numbers', + chordViewMode: 'single' // 'single' or 'grid' (all keys) +}; + +function loadState() { + const saved = localStorage.getItem('banjoTuningsState'); + if (saved) { + try { + const parsed = JSON.parse(saved); + state = { ...state, ...parsed }; + } catch (e) { + console.error('Failed to load state:', e); + } + } +} + +function saveState() { + localStorage.setItem('banjoTuningsState', JSON.stringify(state)); +} + +function renderFretboard() { + const container = document.getElementById('fretboardContainer'); + container.innerHTML = generateFretboardSVG(state.selectedFretboardTuning); +} + +function populateTuningList() { + const list = document.getElementById('tuningList'); + const familyFilter = state.fretboardFamilyFilter || 'all'; + + const families = { + 'c-family': 'C-Based Tunings', + 'd-family': 'D-Based Tunings', + 'g-family': 'G-Based Tunings', + 'other-family': 'Other (4-String)' + }; + + let html = ''; + + for (const [familyKey, familyName] of Object.entries(families)) { + if (familyFilter !== 'all' && familyFilter !== familyKey) continue; + + const tuningsInFamily = Object.entries(TUNINGS_DATA) + .filter(([_, t]) => t.family === familyKey); + + if (tuningsInFamily.length === 0) continue; + + // Add group header if showing all families + if (familyFilter === 'all') { + html += `<div class="tuning-list-group">${familyName}</div>`; + } + + for (const [name, tuning] of tuningsInFamily) { + const isSelected = name === state.selectedFretboardTuning; + + // Determine which indices should be lowercase (first occurrence of duplicates) + const lowercaseIdx = new Set(); + const seenNotes = new Map(); + for (let i = 0; i < tuning.strings.length; i++) { + const normalized = normalizeNote(tuning.strings[i]).toUpperCase(); + if (!seenNotes.has(normalized)) { + seenNotes.set(normalized, i); + } else { + lowercaseIdx.add(seenNotes.get(normalized)); + } + } + + const tuningStr = tuning.strings.map((s, idx) => { + let note = displayNote(normalizeNote(s)); + // Convert to solfège if enabled + if (state.useSolfege) { + note = toSolfege(note); + } + return lowercaseIdx.has(idx) ? note.toLowerCase() : note; + }).join(' '); + + html += ` + <div class="tuning-list-item ${tuning.family} ${isSelected ? 'selected' : ''}" + data-tuning="${name}"> + <span class="tuning-item-name">${name}</span> + <span class="tuning-item-notes">${tuningStr}</span> + </div> + `; + } + } + + list.innerHTML = html; + + // Add click handlers + list.querySelectorAll('.tuning-list-item').forEach(item => { + item.addEventListener('click', () => { + const tuningName = item.dataset.tuning; + state.selectedFretboardTuning = tuningName; + saveState(); + populateTuningList(); // Re-render to update selection + renderFretboard(); + }); + }); +} + +// ============================================ +// EVENT HANDLERS +// ============================================ +function setupEventListeners() { + document.getElementById('familyFilter').addEventListener('change', (e) => { + state.familyFilter = e.target.value; + saveState(); + renderTunings(); + }); + + document.getElementById('capoPosition').addEventListener('change', (e) => { + state.capoPosition = parseInt(e.target.value) || 0; + saveState(); + renderTunings(); + renderFretboard(); + }); + + document.getElementById('fretCount').addEventListener('change', (e) => { + state.fretCount = parseInt(e.target.value); + saveState(); + renderFretboard(); + }); + + document.getElementById('showNotesToggle').addEventListener('change', (e) => { + state.showNotes = e.target.checked; + saveState(); + renderFretboard(); + }); + + document.getElementById('solfegeToggle').addEventListener('change', (e) => { + state.useSolfege = e.target.checked; + saveState(); + renderTunings(); + renderFretboard(); + populateTuningList(); + }); + + // Chord inversion controls + document.getElementById('showInversionsToggle').addEventListener('change', (e) => { + state.showInversions = e.target.checked; + saveState(); + renderFretboard(); + }); + + document.getElementById('inversionRootNote').addEventListener('change', (e) => { + state.inversionRootNote = e.target.value; + saveState(); + renderFretboard(); + }); + + // Fretboard family filter + document.getElementById('fretboardFamilyFilter').addEventListener('change', (e) => { + state.fretboardFamilyFilter = e.target.value; + saveState(); + populateTuningList(); + }); + + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', (e) => { + const tabName = e.target.dataset.tab; + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + e.target.classList.add('active'); + document.getElementById(`${tabName}-tab`).classList.add('active'); + }); + }); +} + +function resetSettings() { + state = { + familyFilter: 'all', + capoPosition: 0, + fretCount: 15, + showNotes: true, + selectedFretboardTuning: 'Open G', + fretboardFamilyFilter: 'all', + useSolfege: false, + showInversions: false, + inversionRootNote: 'G', + chordTuning: 'Open G', + chordKey: 'G', + chordTypeFilter: 'all', + fingerNotation: 'numbers', + chordViewMode: 'single' + }; + saveState(); + applyStateToUI(); + populateTuningList(); + renderTunings(); + renderFretboard(); + initChordTab(); +} + +function applyStateToUI() { + document.getElementById('familyFilter').value = state.familyFilter; + document.getElementById('capoPosition').value = state.capoPosition; + document.getElementById('fretCount').value = state.fretCount; + document.getElementById('showNotesToggle').checked = state.showNotes; + document.getElementById('solfegeToggle').checked = state.useSolfege || false; + document.getElementById('fretboardFamilyFilter').value = state.fretboardFamilyFilter || 'all'; + document.getElementById('showInversionsToggle').checked = state.showInversions || false; + document.getElementById('inversionRootNote').value = state.inversionRootNote || 'G'; + + // Chord tab UI + const chordTuningSelect = document.getElementById('chordTuningSelect'); + if (chordTuningSelect) { + chordTuningSelect.value = state.chordTuning || 'Open G'; + } + + // Finger notation radio + const fingerRadio = document.querySelector(`input[name="fingerNotation"][value="${state.fingerNotation || 'numbers'}"]`); + if (fingerRadio) { + fingerRadio.checked = true; + } +} + +// ============================================ +// CHORD TAB FUNCTIONS +// ============================================ +function populateChordTuningSelect() { + const select = document.getElementById('chordTuningSelect'); + if (!select) return; + + select.innerHTML = ''; + + // Group tunings by family + const families = { + 'g-family': 'G-Based', + 'c-family': 'C-Based', + 'd-family': 'D-Based', + 'other-family': 'Other (4-String)' + }; + + for (const [familyKey, familyName] of Object.entries(families)) { + const group = document.createElement('optgroup'); + group.label = familyName; + + for (const [name, data] of Object.entries(TUNINGS_DATA)) { + if (data.family === familyKey) { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + if (name === state.chordTuning) { + option.selected = true; + } + group.appendChild(option); + } + } + + select.appendChild(group); + } +} + +function populateKeyButtons() { + const container = document.getElementById('keyButtons'); + if (!container) return; + + container.innerHTML = CHORD_KEYS.map(key => { + const isActive = state.chordKey === key.root ? 'active' : ''; + return `<button class="key-btn ${isActive}" data-key="${key.root}">${key.display}</button>`; + }).join(''); + + // Add click handlers + container.querySelectorAll('.key-btn').forEach(btn => { + btn.addEventListener('click', () => { + state.chordKey = btn.dataset.key; + saveState(); + populateKeyButtons(); + renderChords(); + }); + }); +} + +function populateChordTypeButtons() { + const container = document.getElementById('chordTypeButtons'); + if (!container) return; + + container.innerHTML = ` + <button class="chord-type-btn ${state.chordTypeFilter === 'all' ? 'active' : ''}" data-type="all">All</button> + ${CHORD_TYPE_ORDER.map(key => { + const type = CHORD_TYPES[key]; + const isActive = state.chordTypeFilter === key ? 'active' : ''; + return `<button class="chord-type-btn ${isActive}" data-type="${key}">${type.name}</button>`; + }).join('')} + `; + + // Add click handlers + container.querySelectorAll('.chord-type-btn').forEach(btn => { + btn.addEventListener('click', () => { + state.chordTypeFilter = btn.dataset.type; + saveState(); + populateChordTypeButtons(); + renderChords(); + }); + }); +} + +function populateViewModeButtons() { + const container = document.getElementById('viewModeButtons'); + if (!container) return; + + container.innerHTML = ` + <button class="view-mode-btn ${state.chordViewMode === 'single' ? 'active' : ''}" data-mode="single">Single Key</button> + <button class="view-mode-btn ${state.chordViewMode === 'grid' ? 'active' : ''}" data-mode="grid">All Keys Grid</button> + `; + + // Add click handlers + container.querySelectorAll('.view-mode-btn').forEach(btn => { + btn.addEventListener('click', () => { + state.chordViewMode = btn.dataset.mode; + saveState(); + populateViewModeButtons(); + updateKeyButtonsVisibility(); + renderChords(); + }); + }); +} + +function updateKeyButtonsVisibility() { + const keyButtonsContainer = document.getElementById('keyButtonsContainer'); + if (keyButtonsContainer) { + // Hide key buttons in grid mode (since all keys are shown) + keyButtonsContainer.style.display = state.chordViewMode === 'grid' ? 'none' : 'flex'; + } +} + +function renderChords() { + const container = document.getElementById('chordContainer'); + if (!container) return; + + const tuningData = TUNINGS_DATA[state.chordTuning]; + if (!tuningData) { + container.innerHTML = '<div class="no-chord-message">Select a tuning to view chords</div>'; + return; + } + + const fingerNotation = document.querySelector('input[name="fingerNotation"]:checked')?.value || 'numbers'; + + // Check view mode + if (state.chordViewMode === 'grid') { + renderChordsGrid(container, tuningData, fingerNotation); + } else { + renderChordsSingle(container, tuningData, fingerNotation); + } +} + +function renderChordsSingle(container, tuningData, fingerNotation) { + // Determine which chord types to show (use ordered list) + const typesToShow = state.chordTypeFilter === 'all' + ? CHORD_TYPE_ORDER + : [state.chordTypeFilter]; + + let html = ''; + + typesToShow.forEach(chordType => { + const type = CHORD_TYPES[chordType]; + const chordName = `${state.chordKey}${type.symbol}`; + + // Find voicing for this chord + const voicing = findChordVoicing(tuningData, state.chordKey, chordType, 12); + + if (voicing) { + const svg = generateChordDiagramSVG(voicing, chordName, state.chordTuning, fingerNotation); + + html += ` + <div class="chord-diagram-container"> + <div class="chord-diagram-title">${displayNote(state.chordKey)}${type.symbol}</div> + <div class="chord-diagram-subtitle">${type.name}</div> + ${svg} + <div class="chord-notes-display"> + Notes: ${voicing.chordNotes.join(' - ')} + </div> + </div> + `; + } else { + html += ` + <div class="chord-diagram-container"> + <div class="chord-diagram-title">${displayNote(state.chordKey)}${type.symbol}</div> + <div class="chord-diagram-subtitle">${type.name}</div> + <div class="no-chord-message">Voicing not available for this tuning</div> + </div> + `; + } + }); + + container.className = 'chord-grid'; + container.innerHTML = html; +} + +function renderChordsGrid(container, tuningData, fingerNotation) { + // Determine which chord types to show as columns (use ordered groups) + const typesToShow = state.chordTypeFilter === 'all' + ? CHORD_TYPE_ORDER + : [state.chordTypeFilter]; + + // Helper to check if we need a separator after this chord type + function needsSeparatorAfter(chordType) { + if (state.chordTypeFilter !== 'all') return false; + for (let i = 0; i < CHORD_TYPE_GROUPS.length - 1; i++) { + const group = CHORD_TYPE_GROUPS[i]; + if (group[group.length - 1] === chordType) return true; + } + return false; + } + + // Build table header with chord types and separators + let headerHtml = '<tr><th class="key-header">Key</th>'; + typesToShow.forEach(chordType => { + const type = CHORD_TYPES[chordType]; + headerHtml += `<th>${type.name}</th>`; + if (needsSeparatorAfter(chordType)) { + headerHtml += '<th class="group-separator"></th>'; + } + }); + headerHtml += '</tr>'; + + // Build table rows for each key + let rowsHtml = ''; + CHORD_KEYS.forEach((key, keyIndex) => { + // Add page break class before F#/Gb (index 6) for print + const pageBreakClass = key.root === 'F#' ? ' class="page-break-before"' : ''; + rowsHtml += `<tr${pageBreakClass}><td class="key-cell">${key.display}</td>`; + + typesToShow.forEach(chordType => { + const type = CHORD_TYPES[chordType]; + const voicing = findChordVoicing(tuningData, key.root, chordType, 12); + + if (voicing) { + const svg = generateChordDiagramSVG(voicing, `${key.root}${type.symbol}`, state.chordTuning, fingerNotation); + rowsHtml += ` + <td class="grid-chord-cell"> + <div class="grid-chord-diagram"> + <div class="grid-chord-title">${displayNote(key.root)}${type.symbol}</div> + ${svg} + <div class="grid-chord-notes">${voicing.chordNotes.join(' - ')}</div> + </div> + </td> + `; + } else { + rowsHtml += ` + <td class="grid-chord-cell"> + <div class="grid-no-voicing">Not available</div> + </td> + `; + } + + if (needsSeparatorAfter(chordType)) { + rowsHtml += '<td class="group-separator"></td>'; + } + }); + + rowsHtml += '</tr>'; + }); + + container.className = 'chord-keys-grid-container'; + container.innerHTML = ` + <table class="chord-keys-grid"> + <thead>${headerHtml}</thead> + <tbody>${rowsHtml}</tbody> + </table> + `; +} + +function setupChordEventListeners() { + // Tuning selector + const tuningSelect = document.getElementById('chordTuningSelect'); + if (tuningSelect) { + tuningSelect.addEventListener('change', (e) => { + state.chordTuning = e.target.value; + saveState(); + renderChords(); + }); + } + + // Finger notation toggle + document.querySelectorAll('input[name="fingerNotation"]').forEach(radio => { + radio.addEventListener('change', () => { + state.fingerNotation = document.querySelector('input[name="fingerNotation"]:checked')?.value || 'numbers'; + saveState(); + renderChords(); + }); + }); +} + +function initChordTab() { + populateChordTuningSelect(); + populateViewModeButtons(); + populateKeyButtons(); + populateChordTypeButtons(); + setupChordEventListeners(); + updateKeyButtonsVisibility(); + renderChords(); +} + +// ============================================ +// INITIALIZATION +// ============================================ +function init() { + loadState(); + applyStateToUI(); + populateTuningList(); + setupEventListeners(); + renderTunings(); + renderFretboard(); + initChordTab(); +} + +document.addEventListener('DOMContentLoaded', init); + @@ -0,0 +1,386 @@ +// Chord Data & Functions + +// ============================================ +// CHORD DATA & FUNCTIONS +// ============================================ + +// 12 keys with display names (using slash for enharmonics) +const CHORD_KEYS = [ + { root: 'C', display: 'C' }, + { root: 'C#', display: 'C#/Db' }, + { root: 'D', display: 'D' }, + { root: 'D#', display: 'D#/Eb' }, + { root: 'E', display: 'E' }, + { root: 'F', display: 'F' }, + { root: 'F#', display: 'F#/Gb' }, + { root: 'G', display: 'G' }, + { root: 'G#', display: 'G#/Ab' }, + { root: 'A', display: 'A' }, + { root: 'A#', display: 'A#/Bb' }, + { root: 'B', display: 'B' } +]; + +// 8 chord types with intervals (semitones from root) +const CHORD_TYPES = { + 'major': { symbol: '', name: 'Major', intervals: [0, 4, 7], degrees: ['1', '3', '5'] }, + 'minor': { symbol: 'm', name: 'Minor', intervals: [0, 3, 7], degrees: ['1', '♭3', '5'] }, + 'm7': { symbol: 'm7', name: 'Minor 7', intervals: [0, 3, 7, 10], degrees: ['1', '♭3', '5', '♭7'] }, + '6': { symbol: '6', name: 'Sixth', intervals: [0, 4, 7, 9], degrees: ['1', '3', '5', '6'] }, + '7': { symbol: '7', name: 'Seventh', intervals: [0, 4, 7, 10], degrees: ['1', '3', '5', '♭7'] }, + 'dim': { symbol: 'dim', name: 'Diminished', intervals: [0, 3, 6], degrees: ['1', '♭3', '♭5'] }, + 'sus4': { symbol: 'sus4', name: 'Sus4', intervals: [0, 5, 7], degrees: ['1', '4', '5'] }, + 'aug': { symbol: 'aug', name: 'Augmented', intervals: [0, 4, 8], degrees: ['1', '3', '#5'] } +}; + +// Chord types grouped for display with separators between groups +const CHORD_TYPE_GROUPS = [ + ['major', 'minor', 'm7'], // Group 1 + ['6', '7'], // Group 2 + ['dim'], // Group 3 + ['sus4'], // Group 4 + ['aug'] // Group 5 +]; + +// Ordered list of chord types (flattened groups) +const CHORD_TYPE_ORDER = CHORD_TYPE_GROUPS.flat(); + +// Finger notation mapping +const FINGER_NOTATION = { + numbers: { 1: '1', 2: '2', 3: '3', 4: '4', T: 'T' }, + pima: { 1: 'I', 2: 'M', 3: 'A', 4: 'P', T: 'P' } +}; + +// 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 rootPitch = noteToPitchClass(rootNote); + if (rootPitch === -1) return []; + + return type.intervals.map(interval => (rootPitch + interval) % 12); +} + +// 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]); +} + +// 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 null; + + 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; + + // 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; + + function explore(stringIdx, currentVoicing, usedTones) { + const fingersUsed = currentVoicing.filter(v => v && v.fret > 0).length; + if (fingersUsed > maxFingers) return; + + 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; + } + + if (stringIdx === numStrings) { + if (usedTones.size === chordPitchClasses.length) { + results.push([...currentVoicing]); + } + return; + } + + const stringPitch = stringPitchClasses[stringIdx]; + + // Option 1: Mute this string + explore(stringIdx + 1, [...currentVoicing, null], new Set(usedTones)); + + // 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); + + 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()); + + 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; + } + + 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 { + stringNum: stringNum, + fret: null, + muted: true, + note: null, + degree: null, + isRoot: false + }; + } + + const degree = type.degrees[pos.toneIndex]; + const isRoot = pos.pitchClass === rootPitch; + + return { + stringNum: stringNum, + fret: pos.fret, + muted: false, + note: pos.note, + degree: degree, + isRoot: isRoot + }; + }); + + // 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 + const frettedPositions = positions.filter(p => p.fret > 0); + const minFret = frettedPositions.length > 0 ? Math.min(...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, + barre, + chordNotes: chordNotes.map(n => displayNote(n)), + numStrings: numStrings + }; +} + +function assignFingers(positions) { + // Get fretted positions sorted by fret (ascending), then by string number (descending) + // This ensures: lowest fret first, and at same fret, higher string numbers first + const fretted = positions + .filter(p => p.fret > 0) + .sort((a, b) => { + if (a.fret !== b.fret) return a.fret - b.fret; + return b.stringNum - a.stringNum; // Higher string number first at same fret + }); + + if (fretted.length === 0) return; + + // Assign unique finger to each position in sequence + fretted.forEach((p, idx) => { + p.finger = Math.min(idx + 1, 4); + }); +} + +function detectBarre(positions) { + const fretGroups = {}; + positions.forEach((p, idx) => { + if (p.fret > 0) { + if (!fretGroups[p.fret]) fretGroups[p.fret] = []; + fretGroups[p.fret].push({ pos: p, idx: idx }); + } + }); + + 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 (maxStr - minStr <= 3) { + return { + fret: parseInt(fret), + fromString: minStr, + toString: maxStr + }; + } + } + } + + return null; +} + +// Generate SVG chord diagram for 4 main strings +function generateChordDiagramSVG(voicing, chordName, tuningName, fingerNotation = 'numbers') { + const width = 140; + const height = 200; + const marginLeft = 35; + const marginTop = 35; + const marginRight = 15; + const marginBottom = 25; + + const positions = voicing.positions; + const numStrings = 4; // Always 4 main strings for chord diagrams + const numFrets = 6; + const baseFret = voicing.baseFret; + + const stringSpacing = (width - marginLeft - marginRight) / (numStrings - 1); + const fretSpacing = (height - marginTop - marginBottom) / numFrets; + + let svg = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">`; + + // Background + svg += `<rect width="${width}" height="${height}" fill="white"/>`; + + // Nut (thick line at top) - only show if baseFret is 1 + if (baseFret === 1) { + 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 - 15}" y="${marginTop + fretSpacing / 2 + 4}" font-size="11" font-weight="bold" text-anchor="middle">${baseFret}fr</text>`; + } + + // Fret lines + for (let i = 0; i <= numFrets; i++) { + const y = marginTop + i * fretSpacing; + svg += `<line x1="${marginLeft}" y1="${y}" x2="${marginLeft + (numStrings - 1) * stringSpacing}" y2="${y}" stroke="#333" stroke-width="${i === 0 ? 2 : 1}"/>`; + } + + // 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"/>`; + } + + // 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>`; + } + + // 5th string box (same size cell as main grid, flush with it, at fret 5-6) + // Only show if baseFret is 1 (low position chords) + if (baseFret === 1) { + const fret5Y = marginTop + 5 * fretSpacing; // Fret 5 wire + const fret6Y = marginTop + 6 * fretSpacing; // Fret 6 wire + const boxLeft = marginLeft - stringSpacing; // One cell width to the left + + // Draw the box - same size as grid cells, flush with main grid + // Top line (fret 5) + svg += `<line x1="${boxLeft}" y1="${fret5Y}" x2="${marginLeft}" y2="${fret5Y}" stroke="#333" stroke-width="1"/>`; + // Bottom line (fret 6) + svg += `<line x1="${boxLeft}" y1="${fret6Y}" x2="${marginLeft}" y2="${fret6Y}" stroke="#333" stroke-width="1"/>`; + // Left edge + svg += `<line x1="${boxLeft}" y1="${fret5Y}" x2="${boxLeft}" y2="${fret6Y}" stroke="#333" stroke-width="1"/>`; + + // Label for 5th string (centered in the box) + const boxCenterX = boxLeft + stringSpacing / 2; + svg += `<text x="${boxCenterX}" y="${fret5Y - 8}" font-size="8" text-anchor="middle" fill="#666">5</text>`; + } + + // Finger positions and open/muted indicators + positions.forEach((pos, idx) => { + // 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 - 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 - 10}" r="5" fill="none" stroke="#333" stroke-width="1.5"/>`; + } else { + // 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="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 + 3}" font-size="10" font-weight="bold" text-anchor="middle" fill="white">${fingerLabel}</text>`; + } + } else { + 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 + 3}" font-size="10" font-weight="bold" text-anchor="middle">${fingerLabel}</text>`; + } + } + } + } + }); + + // Degree labels below strings + positions.forEach((pos, idx) => { + const x = marginLeft + idx * stringSpacing; + const y = marginTop + numFrets * fretSpacing + 15; + + if (pos.degree && !pos.muted) { + 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="9" text-anchor="middle" fill="#999">-</text>`; + } + }); + + svg += '</svg>'; + return svg; +} + @@ -0,0 +1,633 @@ +// Fretboard Rendering Functions + +// ============================================ +// NOTE FORMATTING +// ============================================ +function parseNoteWithOctave(noteStr) { + // Handle format like "G4", "C#3", "g" (lowercase = high octave) + const match = noteStr.match(/^([A-Ga-g][#b]?)(\d)?$/); + if (!match) return null; + + let note = match[1]; + let octave = match[2] ? parseInt(match[2]) : null; + + // Lowercase note = high octave (convention from Python script) + if (note === note.toLowerCase() && !octave) { + octave = 4; // High octave for 5th string + } else if (!octave) { + octave = 3; // Default octave + } + + return { note: normalizeNote(note), octave }; +} + +function formatNoteForDisplay(noteStr) { + const parsed = parseNoteWithOctave(noteStr); + if (!parsed) return noteStr; + + let note = displayNote(parsed.note); + // Convert to solfège if enabled + if (state.useSolfege) { + note = toSolfege(note); + } + // Replace # with sharp symbol, b with flat symbol + note = note.replace('#', '\u266F').replace('b', '\u266D'); + + return `${note}<sub>${parsed.octave}</sub>`; +} + +function formatSimpleNote(note) { + // Format a simple note (no octave) for fretboard display + let displayed = displayNote(normalizeNote(note)); + // Convert to solfège if enabled + if (state.useSolfege) { + displayed = toSolfege(displayed); + } + return displayed.replace('#', '\u266F').replace('b', '\u266D'); +} + +// ============================================ +// RENDERING FUNCTIONS +// ============================================ +function renderNoteSequence(strings) { + return strings.map(s => formatNoteForDisplay(s)).join(' '); +} + +function renderCapoInfo(capoPositions) { + return Object.entries(capoPositions) + .map(([pos, key]) => pos === '0' ? key : `Capo ${pos} → ${key}`) + .join(' | '); +} + +function renderTuningCard(name, tuning) { + const featuredBadge = tuning.featured ? ' ⭐' : ''; + const warningBadge = tuning.warning ? ' ⚠' : ''; + const stringCount = tuning.stringCount || 5; + const stringBadge = stringCount === 4 ? '<span class="string-count-badge">4-string</span>' : ''; + + // Render nut visual + const nutVisual = tuning.changedStrings.map((changed, i) => { + const stringNum = stringCount - i; + return `<div class="string-circle ${changed ? 'changed' : ''}">${stringNum}</div>`; + }).join(''); + + return ` + <div class="tuning-card ${tuning.family}"> + <div class="tuning-header"> + <span class="tuning-name">${name}${featuredBadge}${warningBadge}${stringBadge}</span> + <span class="genre-tag">${tuning.description}</span> + </div> + <div class="note-sequence">${renderNoteSequence(tuning.strings)}</div> + <div class="nut-visual"> + ${nutVisual} + </div> + <div class="tuning-details"> + <div class="delta-line">Δ: ${tuning.deltaChanges}</div> + <div class="capo-line">${renderCapoInfo(tuning.capoPositions)}</div> + <div class="songs-line">${tuning.songs}</div> + </div> + </div> + `; +} + +function renderTunings() { + const container = document.getElementById('tuningsContainer'); + const families = { + 'c-family': { name: 'C-Based Tunings', intro: 'These tunings center around C tonality, ranging from historical minstrel styles to modern old-time favorites. They create bright, modal sounds ideal for melodic playing.' }, + 'd-family': { name: 'D-Based Tunings', intro: 'D-based tunings create darker, more mysterious tonal landscapes. These tunings are favored for modal old-time pieces and offer rich harmonic possibilities.' }, + 'g-family': { name: 'G-Based Tunings', intro: 'The most common family including the standard bluegrass tuning. These tunings range from bright major chords to modal sounds, offering the widest versatility for different playing styles.' }, + 'other-family': { name: 'Other Tunings (4-String)', intro: '4-string banjo tunings for plectrum and tenor banjos. These instruments are commonly used in jazz, Dixieland, and Irish traditional music.' } + }; + + let html = ''; + + for (const [familyKey, familyInfo] of Object.entries(families)) { + if (state.familyFilter !== 'all' && state.familyFilter !== familyKey) continue; + + const tuningsInFamily = Object.entries(TUNINGS_DATA) + .filter(([_, t]) => t.family === familyKey); + + if (tuningsInFamily.length === 0) continue; + + html += ` + <div class="page-title">${familyInfo.name}</div> + <div class="family-intro ${familyKey}-intro"> + <strong>${familyKey.toUpperCase().replace('-', ' ')}:</strong> ${familyInfo.intro} + </div> + <div class="tunings-grid"> + ${tuningsInFamily.map(([name, tuning]) => renderTuningCard(name, tuning)).join('')} + </div> + `; + } + + container.innerHTML = html; +} + +// ============================================ +// CHORD INVERSION CALCULATION +// ============================================ + +// Calculate all major chord inversions for a root note on strings 4-3-2-1 +// Returns array of inversion objects with fret positions +function calculateMajorInversions(rootNote, tuningStrings, maxFret = 15) { + // Major chord intervals: Root=0, 3rd=4, 5th=7 + const ROOT = 0, THIRD = 4, FIFTH = 7; + + // Get pitch class of root note + const rootPitch = noteToPitchClass(rootNote); + const thirdPitch = (rootPitch + THIRD) % 12; + const fifthPitch = (rootPitch + FIFTH) % 12; + + // For Open G tuning, strings 4-3-2-1 are indices 1,2,3,4 (D,G,B,D) + // We use the 4 main strings for chord shapes + const mainStrings = tuningStrings.slice(1, 5); // D, G, B, D + const stringOpenPitches = mainStrings.map(s => noteToPitchClass(normalizeNote(s))); + + // Find fret for a target pitch on a given string + function findFret(stringPitch, targetPitch, startFret = 0) { + const diff = (targetPitch - stringPitch + 12) % 12; + let fret = diff; + if (fret < startFret) fret += 12; + return fret; + } + + // Three inversion patterns (intervals on strings 4-3-2-1): + // Root Form: 1-3-5-1 (root on string 4) + // 1st Inversion: 3-5-1-3 (3rd on string 4) + // 2nd Inversion: 5-1-3-5 (5th on string 4) + const inversionPatterns = [ + { name: 'Root Form', type: 'root', intervals: [ROOT, THIRD, FIFTH, ROOT], bassNote: 'Root', color: '#3366cc', label: '1 - 3 - 5 - 1' }, + { name: '1st Inversion', type: 'first', intervals: [THIRD, FIFTH, ROOT, THIRD], bassNote: '3rd', color: '#cc3333', label: '3 - 5 - 1 - 3' }, + { name: '2nd Inversion', type: 'second', intervals: [FIFTH, ROOT, THIRD, FIFTH], bassNote: '5th', color: '#cc9900', label: '5 - 1 - 3 - 5' } + ]; + + const inversions = []; + + // Find all occurrences of each inversion pattern + for (const pattern of inversionPatterns) { + // Calculate target pitches for each string + const targetPitches = pattern.intervals.map(interval => (rootPitch + interval) % 12); + + // Find base fret positions (lowest occurrence) + const baseFrets = []; + for (let i = 0; i < 4; i++) { + baseFrets.push(findFret(stringOpenPitches[i], targetPitches[i], 0)); + } + + // Find the minimum fret to normalize the shape + const minFret = Math.min(...baseFrets.filter(f => f > 0)); + const maxFretInShape = Math.max(...baseFrets); + + // Find all occurrences within the fret range + for (let offset = 0; offset <= maxFret; offset += 12) { + const frets = baseFrets.map(f => f === 0 ? 0 : f + offset - (minFret > 0 ? minFret - baseFrets.find(bf => bf === minFret) : 0)); + const adjustedFrets = baseFrets.map(f => { + let adjusted = f + offset; + // Handle open strings (fret 0) + if (f === 0 && offset === 0) return 0; + if (f === 0 && offset > 0) adjusted = 12 + offset; + while (adjusted > maxFret + 5) adjusted -= 12; + return adjusted; + }); + + // Check if this shape is within the displayable range + const shapeFrets = adjustedFrets.filter(f => f >= 0); + if (shapeFrets.length > 0 && Math.min(...shapeFrets) <= maxFret) { + inversions.push({ + ...pattern, + frets: adjustedFrets, + baseFret: Math.min(...adjustedFrets.filter(f => f > 0)) || 0 + }); + } + } + } + + // Sort by base fret position + inversions.sort((a, b) => a.baseFret - b.baseFret); + + return inversions; +} + +// Render chord inversions on fretboard SVG +function renderInversionsOnFretboard(svg, rootNote, tuning, fbLeft, fbTop, stringSpacing, fretHeight, numFrets, svgWidth) { + const inversions = calculateMajorInversions(rootNote, tuning.strings, numFrets); + const displayedNote = displayNote(rootNote); + + // Filter inversions that are fully visible + const visibleInversions = inversions.filter(inv => { + const maxFretInShape = Math.max(...inv.frets); + return maxFretInShape <= numFrets && inv.baseFret >= 1; + }); + + // Render each inversion + visibleInversions.forEach((inv, idx) => { + // Calculate positions (strings 4-3-2-1 are at indices 1,2,3,4 in the display) + const positions = []; + for (let i = 0; i < 4; i++) { + const stringIndex = i + 1; // Skip 5th string (index 0) + const x = fbLeft + stringIndex * stringSpacing; + const fret = inv.frets[i]; + const y = fbTop + (fret - 0.5) * fretHeight; + positions.push({ x, y, fret, stringIndex }); + } + + // Draw colored overlay connecting the notes (triangle or polygon) + const validPositions = positions.filter(p => p.fret > 0 && p.fret <= numFrets); + if (validPositions.length >= 3) { + // Create polygon points + const points = validPositions.map(p => `${p.x},${p.y}`).join(' '); + svg += `<polygon points="${points}" fill="${inv.color}" fill-opacity="0.25" stroke="${inv.color}" stroke-width="2"/>`; + } + + // Draw dots on chord positions + positions.forEach(pos => { + if (pos.fret > 0 && pos.fret <= numFrets) { + svg += `<circle cx="${pos.x}" cy="${pos.y}" r="12" fill="${inv.color}" fill-opacity="0.8" stroke="white" stroke-width="2"/>`; + } + }); + + // Annotation box on the right side + const annotationX = fbLeft + 5 * stringSpacing + 30; + const annotationY = fbTop + (inv.baseFret - 0.5) * fretHeight; + + if (inv.baseFret <= numFrets - 2) { + // Background for annotation + svg += `<rect x="${annotationX}" y="${annotationY - 30}" width="140" height="70" fill="white" stroke="${inv.color}" stroke-width="2" rx="5"/>`; + + // Chord name and inversion type + svg += `<text x="${annotationX + 70}" y="${annotationY - 12}" text-anchor="middle" font-size="12" font-weight="bold" fill="${inv.color}">"${displayedNote}" CHORD</text>`; + svg += `<text x="${annotationX + 70}" y="${annotationY + 5}" text-anchor="middle" font-size="11" font-weight="bold" fill="#333">${inv.name.toUpperCase()}</text>`; + + // Bass note info + svg += `<text x="${annotationX + 70}" y="${annotationY + 20}" text-anchor="middle" font-size="10" fill="#666">${inv.bassNote} is bass note</text>`; + + // Interval structure + svg += `<text x="${annotationX + 70}" y="${annotationY + 35}" text-anchor="middle" font-size="10" fill="#666">[${inv.label}]</text>`; + } + }); + + // Draw 5-fret rule connectors between consecutive inversions + for (let i = 0; i < visibleInversions.length - 1; i++) { + const current = visibleInversions[i]; + const next = visibleInversions[i + 1]; + + // The rule: 3rd string of current -> 4th string of next (5 frets higher) + // String 3 is at index 2 (0-indexed in our positions), String 4 is at index 1 + const fromStringIdx = 2; // 3rd string (G string) + const toStringIdx = 1; // 4th string (D string) + + const fromX = fbLeft + (fromStringIdx + 1) * stringSpacing; + const fromY = fbTop + (current.frets[fromStringIdx] - 0.5) * fretHeight; + const toX = fbLeft + (toStringIdx + 1) * stringSpacing; + const toY = fbTop + (next.frets[toStringIdx] - 0.5) * fretHeight; + + if (current.frets[fromStringIdx] <= numFrets && next.frets[toStringIdx] <= numFrets) { + // Draw arrow + svg += `<line x1="${fromX}" y1="${fromY}" x2="${toX}" y2="${toY}" stroke="#cc0000" stroke-width="3" marker-end="url(#arrowhead)"/>`; + + // Label showing "5 frets" + const midX = (fromX + toX) / 2 - 25; + const midY = (fromY + toY) / 2; + svg += `<text x="${midX}" y="${midY}" font-size="10" font-weight="bold" fill="#cc0000">5 frets</text>`; + } + } + + // Add cycling indicator at bottom + if (visibleInversions.length > 0) { + const bottomY = fbTop + numFrets * fretHeight + 10; + svg += `<text x="${fbLeft + 2.5 * stringSpacing}" y="${bottomY}" text-anchor="middle" font-size="11" fill="#666">↓ Inversions keep cycling ↓</text>`; + } + + return svg; +} + +// ============================================ +// FRETBOARD SVG GENERATOR (Enhanced from Python) +// ============================================ +function generateFretboardSVG(tuningName) { + const tuning = TUNINGS_DATA[tuningName]; + if (!tuning) return ''; + + const numFrets = state.fretCount; + const capo = state.capoPosition; + const showNotes = state.showNotes; + + const strings = tuning.strings; + const numStrings = strings.length; + const shortStringIdx = tuning.shortStringIndex; + const shortStringStart = tuning.shortStringStartFret; + + // Determine which strings should be lowercase (first occurrence of duplicate notes) + // e.g., for tuning with notes F C F A C, the first F and first C should be lowercase: f c F A C + const lowercaseIndices = new Set(); + const seenNotes = new Map(); // normalized note -> first index + for (let i = 0; i < strings.length; i++) { + const normalized = normalizeNote(strings[i]).toUpperCase(); + if (!seenNotes.has(normalized)) { + seenNotes.set(normalized, i); + } else { + // This note has appeared before, mark the first occurrence as lowercase + lowercaseIndices.add(seenNotes.get(normalized)); + } + } + + // SVG dimensions + const marginLeft = 100; + const marginRight = 150; + const marginTop = capo > 0 ? 160 : 140; // Extra space for effective tuning display + const marginBottom = 80; + const stringSpacing = 55; + const fretHeight = 50; + + const fretboardWidth = (numStrings - 1) * stringSpacing; + const fretboardHeight = numFrets * fretHeight; + const svgWidth = marginLeft + fretboardWidth + marginRight; + const svgHeight = marginTop + fretboardHeight + marginBottom; + + const fbLeft = marginLeft; + const fbTop = marginTop; + const fbRight = fbLeft + fretboardWidth; + const bottomY = fbTop + fretboardHeight; + + // Fret markers + const markerFrets = [3, 5, 7, 10, 12, 15, 17]; + const doubleMarkerFrets = [12]; + + let svg = `<svg class="fretboard-svg" width="${svgWidth}" height="${svgHeight}" xmlns="http://www.w3.org/2000/svg">`; + + // Styles and definitions + svg += ` + <defs> + <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"> + <polygon points="0 0, 10 3.5, 0 7" fill="#cc0000"/> + </marker> + </defs> + <style> + .title { font-family: Georgia, serif; font-size: 24px; font-weight: bold; fill: #000; } + .subtitle { font-family: Georgia, serif; font-size: 16px; font-style: italic; fill: #333; } + .string-label { font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold; fill: #000; } + .fret-number { font-family: Arial, sans-serif; font-size: 16px; font-weight: bold; fill: #333; } + .note { font-family: Arial, sans-serif; font-size: 12px; font-weight: bold; fill: #000; } + .note-circle { fill: white; stroke: #000; stroke-width: 1.5; } + .note-circle-natural { fill: white; stroke: #000; stroke-width: 4; } + .fret-line { stroke: #000; stroke-width: 2; } + .string-line { stroke: #444; stroke-width: 1.5; } + .nut { stroke: #000; stroke-width: 6; } + .fret-marker { fill: #ddd; stroke: none; } + .peg { fill: #333; stroke: #000; stroke-width: 1; } + .ghost-string { stroke: #999; stroke-width: 1; stroke-dasharray: 3,3; } + .fret-marker-symbol { fill: #000; stroke: none; } + </style>`; + + // Background + svg += `<rect width="100%" height="100%" fill="white"/>`; + + // Title - use lowercase for first occurrence of duplicate notes (e.g., fCFAC -> fcFAC) + const tuningStr = strings.map((s, idx) => { + let note = displayNote(normalizeNote(s)); + // Convert to solfège if enabled + if (state.useSolfege) { + note = toSolfege(note); + } + // Use lowercase if this index is marked as first occurrence of a duplicate + return lowercaseIndices.has(idx) ? note.toLowerCase() : note; + }).join(''); + + // Calculate effective tuning with capo + let effectiveTuningStr = ''; + if (capo > 0) { + effectiveTuningStr = strings.map((s, idx) => { + // For short string, calculate from its actual starting point + let effectiveCapo = capo; + if (idx === shortStringIdx && shortStringStart !== null) { + // Short string: capo only affects it if capo >= shortStringStart + if (capo < shortStringStart) { + effectiveCapo = 0; // Capo doesn't affect short string + } else { + effectiveCapo = capo - (shortStringStart - 1); + } + } + const transposedNote = getNoteAtFret(s, effectiveCapo); + let note = displayNote(normalizeNote(transposedNote)); + if (state.useSolfege) { + note = toSolfege(note); + } + return lowercaseIndices.has(idx) ? note.toLowerCase() : note; + }).join(''); + } + + svg += `<text x="${svgWidth / 2}" y="45" class="title" text-anchor="middle">Banjo Fretboard: ${tuningName}</text>`; + + // Subtitle with effective tuning when capo is applied + if (capo > 0) { + svg += `<text x="${svgWidth / 2}" y="70" class="subtitle" text-anchor="middle">Open Tuning: ${tuningStr} — ${tuning.description}</text>`; + svg += `<text x="${svgWidth / 2}" y="90" class="subtitle" text-anchor="middle" style="fill: #c00; font-weight: bold;">Capo ${capo}: Effective Tuning: ${effectiveTuningStr}</text>`; + } else { + svg += `<text x="${svgWidth / 2}" y="70" class="subtitle" text-anchor="middle">Tuning: ${tuningStr} — ${tuning.description}</text>`; + } + + // Fret markers (on fretboard face) + for (const fret of markerFrets) { + if (fret <= numFrets) { + const y = fbTop + (fret - 0.5) * fretHeight; + if (doubleMarkerFrets.includes(fret)) { + svg += `<circle cx="${fbLeft + fretboardWidth/2 - 15}" cy="${y}" r="7" class="fret-marker"/>`; + svg += `<circle cx="${fbLeft + fretboardWidth/2 + 15}" cy="${y}" r="7" class="fret-marker"/>`; + } else { + svg += `<circle cx="${fbLeft + fretboardWidth/2}" cy="${y}" r="7" class="fret-marker"/>`; + } + } + } + + // Nut + if (shortStringIdx !== null && shortStringStart !== null) { + const nutStartX = fbLeft + stringSpacing - 10; + svg += `<line x1="${nutStartX}" y1="${fbTop}" x2="${fbRight + 10}" y2="${fbTop}" class="nut"/>`; + } else { + svg += `<line x1="${fbLeft - 10}" y1="${fbTop}" x2="${fbRight + 10}" y2="${fbTop}" class="nut"/>`; + } + + // Fret lines with ordinal labels on right + for (let fret = 1; fret <= numFrets; fret++) { + const y = fbTop + fret * fretHeight; + + let lineStartX, lineEndX; + if (shortStringIdx !== null && shortStringStart !== null && fret < shortStringStart) { + lineStartX = fbLeft + stringSpacing - 10; + lineEndX = fbRight + 10; + } else { + lineStartX = fbLeft - 10; + lineEndX = fbRight + 10; + } + + // Extend line for marker frets + if (markerFrets.includes(fret)) { + lineEndX += 20; + if (fret === 5) { + // 5th fret extends to the left edge (where 5th string starts) + lineStartX = 90; + } + } + + svg += `<line x1="${lineStartX}" y1="${y}" x2="${lineEndX}" y2="${y}" class="fret-line"/>`; + + // Ordinal fret labels on right + if (markerFrets.includes(fret)) { + const ordinal = fret + (fret === 1 ? 'st' : fret === 2 ? 'nd' : fret === 3 ? 'rd' : 'th') + ' fret'; + const textX = fbRight + 70; + svg += `<text x="${textX}" y="${y}" class="fret-number" text-anchor="middle" dominant-baseline="middle">${ordinal}</text>`; + + // Marker symbols + const symbolX = textX + 50; + if (fret === 5) { + // Star for 5th fret + const starPoints = `${symbolX},${y-7} ${symbolX+2},${y-2} ${symbolX+7},${y-2} ${symbolX+3},${y+1} ${symbolX+5},${y+6} ${symbolX},${y+3} ${symbolX-5},${y+6} ${symbolX-3},${y+1} ${symbolX-7},${y-2} ${symbolX-2},${y-2}`; + svg += `<polygon points="${starPoints}" class="fret-marker-symbol"/>`; + } else if (fret === 12) { + // Double circles + svg += `<circle cx="${symbolX - 8}" cy="${y}" r="6" class="fret-marker-symbol"/>`; + svg += `<circle cx="${symbolX + 8}" cy="${y}" r="6" class="fret-marker-symbol"/>`; + } else { + // Single circle + svg += `<circle cx="${symbolX}" cy="${y}" r="6" class="fret-marker-symbol"/>`; + } + } + } + + // Neck edges with diagonal for short string + if (shortStringIdx !== null && shortStringStart !== null) { + const nutLeftX = fbLeft + stringSpacing - 10; + const fret4Y = fbTop + 4 * fretHeight; + const fret5Y = fbTop + 5 * fretHeight; + const leftEdgeX = fbLeft - 10; + + // Ghost string line (dotted) + svg += `<line x1="${fbLeft}" y1="${fbTop}" x2="${fbLeft}" y2="${fret5Y - fretHeight}" class="ghost-string"/>`; + + // Polyline neck edge + svg += `<polyline points="${nutLeftX},${fbTop} ${nutLeftX},${fret4Y} ${leftEdgeX},${fret5Y} ${leftEdgeX},${bottomY}" style="stroke:#000; stroke-width:3; fill:none;"/>`; + svg += `<line x1="${fbRight + 10}" y1="${fbTop}" x2="${fbRight + 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; + svg += `<line x1="${leftEdgeX}" y1="${bottomY}" x2="${fbRight + 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; + } else { + // 4-string: rectangular edges + svg += `<line x1="${fbLeft - 10}" y1="${fbTop}" x2="${fbLeft - 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; + svg += `<line x1="${fbRight + 10}" y1="${fbTop}" x2="${fbRight + 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; + svg += `<line x1="${fbLeft - 10}" y1="${bottomY}" x2="${fbRight + 10}" y2="${bottomY}" style="stroke:#000; stroke-width:3;"/>`; + } + + // String lines + for (let i = 0; i < numStrings; i++) { + const x = fbLeft + i * stringSpacing; + + if (i === shortStringIdx && shortStringStart !== null) { + const startY = fbTop + (shortStringStart - 1) * fretHeight; + svg += `<line x1="${x}" y1="${startY}" x2="${x}" y2="${bottomY}" class="string-line" style="stroke-width:2;"/>`; + + // Peg indicator + const pegY = startY - 50; + const pegHeadX = fbLeft - 10; + const pegShaftStartX = fbLeft + 30; + svg += `<rect x="${pegHeadX}" y="${pegY - 5}" width="${pegShaftStartX - pegHeadX}" height="8" class="peg" rx="2"/>`; + svg += `<circle cx="${pegHeadX}" cy="${pegY}" r="10" class="peg"/>`; + svg += `<circle cx="${pegHeadX}" cy="${pegY}" r="4" fill="white"/>`; + } else { + svg += `<line x1="${x}" y1="${fbTop}" x2="${x}" y2="${bottomY}" class="string-line"/>`; + } + } + + // String labels at top (lowercase for first occurrence of duplicate notes) + for (let i = 0; i < numStrings; i++) { + const x = fbLeft + i * stringSpacing; + const stringNumber = numStrings - i; + const note = strings[i]; + + // Use lowercase if this is the first occurrence of a duplicate note + const baseNote = normalizeNote(note); + let displayedBase = displayNote(baseNote); + // Convert to solfège if enabled + if (state.useSolfege) { + displayedBase = toSolfege(displayedBase); + } + // Apply lowercase if marked, then replace symbols + let displayed = lowercaseIndices.has(i) ? displayedBase.toLowerCase() : displayedBase; + displayed = displayed.replace('#', '\u266F').replace('b', '\u266D'); + + const numberY = fbTop - 35; + const labelY = fbTop - 12; + + svg += `<text x="${x}" y="${numberY}" class="fret-number" text-anchor="middle">${stringNumber}</text>`; + svg += `<text x="${x}" y="${labelY}" class="string-label" text-anchor="middle">${displayed}</text>`; + } + + // Notes at each fret position + if (showNotes) { + for (let fret = 1; fret <= numFrets; fret++) { + const y = fbTop + (fret - 0.5) * fretHeight; + + // Check if this fret is above (behind) the capo + const isBehindCapo = capo > 0 && fret < capo; + + for (let i = 0; i < numStrings; i++) { + const x = fbLeft + i * stringSpacing; + const openNote = strings[i]; + + // Handle short string + if (i === shortStringIdx && shortStringStart !== null) { + if (fret < shortStringStart) { + continue; // No notes in empty space + } + // Calculate effective fret for short string + const effectiveFret = fret - (shortStringStart - 1); + const note = getNoteAtFret(openNote, effectiveFret); + const displayedNote = formatSimpleNote(note); + + // Gray out notes behind capo, use 8-ball style for natural notes + const isNatural = isNaturalNote(note); + const circleClass = isNatural ? 'note-circle-natural' : 'note-circle'; + const circleStyle = isBehindCapo ? 'fill: #e0e0e0; stroke: #bbb;' : ''; + const textStyle = isBehindCapo ? 'fill: #aaa;' : ''; + + svg += `<circle cx="${x}" cy="${y}" r="16" class="${circleClass}" style="${circleStyle}"/>`; + svg += `<text x="${x}" y="${y + 5}" class="note" text-anchor="middle" style="${textStyle}">${displayedNote}</text>`; + } else { + const note = getNoteAtFret(openNote, fret); + const displayedNote = formatSimpleNote(note); + + // Gray out notes behind capo, use 8-ball style for natural notes + const isNatural = isNaturalNote(note); + const circleClass = isNatural ? 'note-circle-natural' : 'note-circle'; + const circleStyle = isBehindCapo ? 'fill: #e0e0e0; stroke: #bbb;' : ''; + const textStyle = isBehindCapo ? 'fill: #aaa;' : ''; + + svg += `<circle cx="${x}" cy="${y}" r="16" class="${circleClass}" style="${circleStyle}"/>`; + svg += `<text x="${x}" y="${y + 5}" class="note" text-anchor="middle" style="${textStyle}">${displayedNote}</text>`; + } + } + } + } + + // Capo indicator + if (capo > 0) { + const capoY = fbTop + (capo - 0.5) * fretHeight; + svg += `<rect x="${fbLeft - 15}" y="${capoY - 8}" width="${fretboardWidth + 30}" height="16" fill="#333" rx="4" opacity="0.9"/>`; + svg += `<text x="${fbLeft + fretboardWidth/2}" y="${capoY + 4}" text-anchor="middle" font-size="10" fill="white" font-weight="bold">CAPO ${capo}</text>`; + } + + // Chord inversions overlay (if enabled) + if (state.showInversions && numStrings >= 5) { + svg = renderInversionsOnFretboard(svg, state.inversionRootNote, tuning, fbLeft, fbTop, stringSpacing, fretHeight, numFrets, svgWidth); + } + + // Footer + const footerY = fbTop + fretboardHeight + 30; + svg += `<rect x="${marginLeft-50}" y="${footerY}" width="${fretboardWidth+150}" height="30" rx="5" fill="#f5f5f5" stroke="#ccc"/>`; + const footerLine1 = shortStringStart + ? `String 1 (far right) = 1st string • 5th string starts at fret ${shortStringStart}` + : `String 1 (far right) = 1st string • ${numStrings}-string banjo`; + svg += `<text x="${svgWidth/2}" y="${footerY + 12}" text-anchor="middle" font-size="10" fill="#555">${footerLine1}</text>`; + svg += `<text x="${svgWidth/2}" y="${footerY + 24}" text-anchor="middle" font-size="10" fill="#555">Fret markers: 3,5,7,10,12,15,17 • Peg indicates 5th string tuning point</text>`; + + svg += '</svg>'; + return svg; +} + @@ -0,0 +1,306 @@ +// Banjo Tunings Data +// 24 tunings including 4-string + +const TUNINGS_DATA = { + // C-Based Tunings + "Minstrel Tuning": { + strings: ["f", "C", "F", "A", "c"], + family: "c-family", + description: "Historical", + referenceTuning: "Standard F", + deltaChanges: "From Standard F: 4th↓C", + capoPositions: { 0: "Not commonly capoed" }, + songs: "Briggs manual, Coon Hunt", + changedStrings: [true, true, true, true, true], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Naomi Weiss": { + strings: ["g", "C", "G", "A", "d"], + family: "c-family", + description: "Song-Specific", + referenceTuning: "Minstrel", + deltaChanges: "From Minstrel: 5th↑G, 3rd↑G, 2nd→A (same), 1st↑D", + capoPositions: { 2: "Key of D" }, + songs: '"Naomi Weiss"', + changedStrings: [false, false, true, false, true], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Little Birdie": { + strings: ["e", "C", "G", "A", "d"], + family: "c-family", + description: "Appalachian", + referenceTuning: "Naomi Weiss", + deltaChanges: "From Naomi Weiss: 5th↓E", + capoPositions: { 2: "Key of F# (uncommon)" }, + songs: '"Little Birdie"', + changedStrings: [true, false, false, false, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Hook-and-Line": { + strings: ["g", "C", "G", "c", "d"], + family: "c-family", + description: "Open C Variant", + referenceTuning: "Little Birdie", + deltaChanges: "From Little Birdie: 5th↑G, 2nd↑C", + capoPositions: { 2: "Key of D", 5: "Key of F" }, + songs: '"Hook and Line"', + changedStrings: [true, false, false, true, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Cackling Hen": { + strings: ["g", "C", "G", "c", "e"], + family: "c-family", + description: "Open C Major", + referenceTuning: "Hook-and-Line", + deltaChanges: "From Hook-and-Line: 1st↑E", + capoPositions: { 0: "Key of C", 2: "Key of D" }, + songs: "Open C tuning, Irish/Celtic", + changedStrings: [false, false, false, false, true], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Darling Cora": { + strings: ["g", "C", "G", "c", "c"], + family: "c-family", + description: "Drone Heavy (Triple C)", + referenceTuning: "Cackling Hen", + deltaChanges: "From Cackling Hen: 1st↓C", + capoPositions: { 2: "Key of D", 5: "Key of F" }, + songs: '"Darling Cora"', + changedStrings: [false, false, false, false, true], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Double C": { + strings: ["g", "C", "G", "c", "d"], + family: "c-family", + description: "Old-Time Standard", + referenceTuning: "Open G", + deltaChanges: "From Open G: 4th↓C, 2nd↑C", + capoPositions: { 2: "Key of D", 5: "Key of F" }, + songs: "Wildwood Flower, Billy in the Lowground", + changedStrings: [false, true, false, true, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Standard C": { + strings: ["g", "C", "G", "B", "d"], + family: "c-family", + description: "C Tuning for Old-Time", + referenceTuning: "Open G", + deltaChanges: "From Open G: 4th↓C", + capoPositions: { 2: "Key of D", 5: "Key of F" }, + songs: "Old-time repertoire", + changedStrings: [false, true, false, false, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + + // D-Based Tunings + "Moonshiner": { + strings: ["g", "D", "G", "A", "d"], + family: "d-family", + description: "Modal/Dark", + referenceTuning: "Standard F", + deltaChanges: "From Standard F: 4th↑D, 3rd↑G, 2nd→A (same), 1st↑D", + capoPositions: { 2: "Key of A", 5: "Key of C" }, + songs: '"Moonshiner"', + changedStrings: [false, true, false, true, true], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "German War": { + strings: ["g", "D", "G", "A", "f#"], + family: "d-family", + description: "Historical", + referenceTuning: "Moonshiner", + deltaChanges: "From Moonshiner: 1st↓F#", + capoPositions: { 2: "Key of A (modal)" }, + songs: "German war songs", + changedStrings: [false, false, false, false, true], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Reuben": { + strings: ["g", "D", "F#", "A", "f#"], + family: "d-family", + description: "Song-Specific", + referenceTuning: "German War", + deltaChanges: "From German War: 3rd↓F#", + capoPositions: { 2: "Key of A (modal)" }, + songs: '"Reuben"', + changedStrings: [false, false, true, false, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "F Tuning": { + strings: ["f", "D", "G", "c", "d"], + family: "d-family", + description: "Cumberland Gap", + referenceTuning: "Reuben", + deltaChanges: "From Reuben: 5th↓F, 3rd↑G, 2nd↑C, 1st↑D", + capoPositions: { 2: "Key of G", 5: "Key of Bb" }, + songs: "Cumberland Gap, Drunkard's Doom", + changedStrings: [true, false, true, true, true], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "East Virginia": { + strings: ["f", "F", "G", "c", "d"], + family: "d-family", + description: "George Gibson Style", + referenceTuning: "F Tuning", + deltaChanges: "From F Tuning: 4th↑F", + capoPositions: { 2: "Key of G", 5: "Key of Bb" }, + songs: "George Gibson style", + changedStrings: [false, true, false, false, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + + // G-Based Tunings + "Standard (F)": { + strings: ["f", "C", "F", "A", "c"], + family: "g-family", + description: "Historical Reference", + referenceTuning: null, + deltaChanges: "(Reference tuning for historical context)", + capoPositions: { 2: "Key of G", 5: "Key of Bb" }, + songs: "General playing, historical reference", + changedStrings: [false, false, false, false, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Open G": { + strings: ["g", "D", "G", "B", "d"], + family: "g-family", + description: "Bluegrass Standard", + referenceTuning: null, + deltaChanges: "(Modern standard reference - most common)", + capoPositions: { 2: "Key of A", 5: "Key of C", 7: "Key of D" }, + songs: "Bill Cheatum, Old Joe Clark, Cripple Creek", + changedStrings: [true, true, true, true, true], + featured: true, + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Open G (Low G)": { + strings: ["G", "D", "G", "B", "d"], + family: "g-family", + description: "Open G with Low 5th", + referenceTuning: "Open G", + deltaChanges: "From Open G: 5th↓G (one octave lower)", + capoPositions: { 2: "Key of A", 5: "Key of C" }, + songs: "Low drone sound", + changedStrings: [true, false, false, false, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Open A": { + strings: ["a", "E", "A", "C#", "e"], + family: "g-family", + description: "High Tension", + referenceTuning: "Open G", + deltaChanges: "From Open G: all strings ↑2 frets (whole step)", + capoPositions: { 2: "Key of B", 5: "Key of D" }, + songs: "Tunes in A key - requires light gauge strings!", + changedStrings: [true, true, true, true, true], + warning: true, + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "G Variant": { + strings: ["g", "D", "G", "A", "d"], + family: "g-family", + description: "Modal G", + referenceTuning: "Open G", + deltaChanges: "From Open G: 2nd↓A", + capoPositions: { 2: "Key of A", 5: "Key of C" }, + songs: "Leather Britches, Texas, Frosty Morning", + changedStrings: [false, false, false, true, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "G Modal (Sawmill)": { + strings: ["g", "D", "G", "c", "d"], + family: "g-family", + description: "Mountain/Modal", + referenceTuning: "Open G", + deltaChanges: "From Open G: 2nd↑C", + capoPositions: { 2: "Key of A", 5: "Key of C" }, + songs: "Modal tunes, F key songs", + changedStrings: [false, false, false, true, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "Open D": { + strings: ["a", "D", "F#", "A", "d"], + family: "g-family", + description: "Blues/Folk (Graveyard)", + referenceTuning: "Open G", + deltaChanges: "From Open G: all strings adjusted for D major", + capoPositions: { 2: "Key of E", 3: "Key of F", 5: "Key of G" }, + songs: 'Most D key tunes, "Graveyard Tuning" - use light strings!', + changedStrings: [true, true, true, true, true], + warning: true, + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "D Modal": { + strings: ["a", "D", "G", "A", "d"], + family: "g-family", + description: "Celtic/Medieval", + referenceTuning: "Open D", + deltaChanges: "From Open D: 3rd↓G", + capoPositions: { 2: "Key of E (modal)", 5: "Key of G (modal)" }, + songs: "D modal tunes, medieval tonality", + changedStrings: [false, false, true, false, false], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + "D Tuning (Reno)": { + strings: ["a", "D", "G", "B", "e"], + family: "g-family", + description: "Don Reno Style", + referenceTuning: "Open G", + deltaChanges: "From Open G: 5th↑A, 1st↑E", + capoPositions: { 2: "Key of E", 5: "Key of G" }, + songs: "Don Reno repertoire", + changedStrings: [true, false, false, false, true], + shortStringIndex: 0, + shortStringStartFret: 6 + }, + + // Other / 4-String Tunings + "Plectrum C": { + strings: ["C", "G", "B", "d"], + family: "other-family", + description: "4-String Plectrum", + referenceTuning: null, + deltaChanges: "(4-string plectrum standard)", + capoPositions: { 0: "Key of C" }, + songs: "Jazz, Dixieland", + changedStrings: [false, false, false, false], + shortStringIndex: null, + shortStringStartFret: null, + stringCount: 4 + }, + "Tenor (Standard)": { + strings: ["C", "G", "d", "a"], + family: "other-family", + description: "4-String Tenor CGDA", + referenceTuning: null, + deltaChanges: "(4-string tenor standard - fifths tuning)", + capoPositions: { 0: "Key of C/G" }, + songs: "Irish, Jazz, Traditional", + changedStrings: [false, false, false, false], + shortStringIndex: null, + shortStringStartFret: null, + stringCount: 4 + } +}; + @@ -0,0 +1,65 @@ +// Chromatic Scale & Note Utilities + +const CHROMATIC_SCALE = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + +// Normalize note names (flats to sharps, handle case) +const NOTE_MAP = { + 'c': 'C', 'c#': 'C#', 'db': 'C#', 'Db': 'C#', + 'd': 'D', 'd#': 'D#', 'eb': 'D#', 'Eb': 'D#', + 'e': 'E', 'fb': 'E', 'Fb': 'E', + 'f': 'F', 'e#': 'F', 'E#': 'F', 'f#': 'F#', 'gb': 'F#', 'Gb': 'F#', + 'g': 'G', 'g#': 'G#', 'ab': 'G#', 'Ab': 'G#', + 'a': 'A', 'a#': 'A#', 'bb': 'A#', 'Bb': 'A#', + 'b': 'B', 'cb': 'B', 'Cb': 'B', 'b#': 'C', 'B#': 'C', + 'C': 'C', 'C#': 'C#', 'D': 'D', 'D#': 'D#', 'E': 'E', + 'F': 'F', 'F#': 'F#', 'G': 'G', 'G#': 'G#', 'A': 'A', 'A#': 'A#', 'B': 'B' +}; + +// Display note mapping - prefer flats for G# and A# (as per user's Python script) +const DISPLAY_NOTE_MAP = { + 'G#': 'Ab', + 'A#': 'Bb' + // Keep D#, C#, F# as sharps +}; + +// Solfège mapping: C=Do, D=Re, E=Mi, F=Fa, G=Sol, A=La, B=Si +const SOLFEGE_MAP = { + 'C': 'Do', 'D': 'Re', 'E': 'Mi', 'F': 'Fa', + 'G': 'Sol', 'A': 'La', 'B': 'Si' +}; + +function normalizeNote(note) { + return NOTE_MAP[note] || note.toUpperCase(); +} + +// Check if a note is natural (no sharps or flats) +function isNaturalNote(note) { + const normalized = normalizeNote(note); + return ['C', 'D', 'E', 'F', 'G', 'A', 'B'].includes(normalized); +} + +function displayNote(note) { + // Convert internal sharp note to preferred display (mix of flats and sharps) + if (DISPLAY_NOTE_MAP[note]) { + return DISPLAY_NOTE_MAP[note]; + } + return note; +} + +function toSolfege(note) { + // Convert a note to solfège notation + // Handle sharps/flats: C# -> Do#, Db -> Reb, etc. + const baseNote = note.charAt(0).toUpperCase(); + const accidental = note.slice(1); // # or b or empty + const solfege = SOLFEGE_MAP[baseNote] || baseNote; + return solfege + accidental; +} + +function getNoteAtFret(openNote, fret) { + const normalized = normalizeNote(openNote); + const startIndex = CHROMATIC_SCALE.indexOf(normalized); + if (startIndex === -1) return openNote; + const newIndex = (startIndex + fret) % 12; + return CHROMATIC_SCALE[newIndex]; +} +