commit e79260231f20eedc346fa9942f4ceae0588e017c Author: Tobias Nauen Date: Wed Apr 29 15:57:37 2026 +0200 first commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee04545 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tobias Nauen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5348128 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Guitar Fretboard Trainer + +![No build step](https://img.shields.io/badge/build-none-brightgreen?style=flat-square) +![Vanilla JS](https://img.shields.io/badge/vanilla-JS-f7df1e?style=flat-square&logo=javascript&logoColor=000) +![ES Modules](https://img.shields.io/badge/ES-modules-blue?style=flat-square) +![PWA ready](https://img.shields.io/badge/PWA-ready-5A0FC8?style=flat-square&logo=pwa) +![License MIT](https://img.shields.io/badge/license-MIT-lightgrey?style=flat-square) + +A browser app to drill guitar fretboard note recognition. No framework, no build step — just plain HTML, CSS, and ES modules. + +## Usage + +Serve with any HTTP server and open in a browser (ES modules require a server — `file://` won't work): + +```bash +python3 -m http.server 8080 +# or +npx serve . +``` + +Then open http://localhost:8080. + +## Quiz Modes + +### Mode 1 — Name the Note +A position (string + fret) is highlighted on the fretboard. Choose the correct note from 9 buttons — by clicking, pressing `1`–`9`, or typing the note name on your keyboard. + +### Mode 2 — Find All Occurrences +A target note and a 4–5 fret range are shown. Click every position in the range where that note appears, then hit **Submit**. Green = correct, red = wrong selection, faded green = missed. + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `Space` / `Enter` | Next question (or submit in mode 2) | +| `1`–`9` | Select answer button by position (mode 1) | +| `A`–`G` | Type a note name directly (mode 1) | +| `A` then `#` | Sharp — e.g. `A#` | +| `A` then `b` | Flat — e.g. `Ab` (resolved to `G#` internally) | +| `Tab` | Cycle between modes | + +## Settings + +- **6-string / 7-string** — standard tuning +- **Sharps only** — show only sharp names (no `D#/Eb` dual labels) +- **Natural notes only** — restrict to C D E F G A B + +## Stats + +A lifetime stats screen tracks accuracy per mode, accuracy per note, total questions answered, and best streak. Stats persist in `localStorage`. + +## Project Structure + +``` +guitar-trainer/ +├── index.html # Markup only +├── site.webmanifest # PWA manifest +├── css/ +│ └── style.css # All styles, CSS custom properties for theming +└── js/ + ├── music.js # Music theory: notes, tunings, display helpers + ├── fretboard.js # SVG fretboard builder + ├── quiz.js # Question generation and answer evaluation + └── app.js # State, rendering, DOM wiring, keyboard shortcuts +``` diff --git a/android-chrome-192x192.png b/android-chrome-192x192.png new file mode 100644 index 0000000..bf5c4a6 Binary files /dev/null and b/android-chrome-192x192.png differ diff --git a/android-chrome-512x512.png b/android-chrome-512x512.png new file mode 100644 index 0000000..31ee9f2 Binary files /dev/null and b/android-chrome-512x512.png differ diff --git a/apple-touch-icon.png b/apple-touch-icon.png new file mode 100644 index 0000000..4978e28 Binary files /dev/null and b/apple-touch-icon.png differ diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..86b7648 --- /dev/null +++ b/css/style.css @@ -0,0 +1,406 @@ +:root { + --bg: #1a1a2e; + --surface: #16213e; + --surface2: #0f3460; + --accent: #e94560; + --accent2: #f5a623; + --green: #4caf50; + --text: #eaeaea; + --text-dim: #888; + --nut: #c8a96e; + --string: #d4c4a0; + --fret: #aaa; + --marker: #555; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; +} + +h1 { + font-size: 1.6rem; + letter-spacing: 2px; + color: var(--accent); + margin-bottom: 4px; + text-transform: uppercase; +} + +.subtitle { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 20px; } + +/* Mode selector */ +.mode-tabs { + display: flex; + gap: 8px; + margin-bottom: 24px; +} + +.mode-tab { + padding: 8px 20px; + border-radius: 20px; + border: 2px solid var(--surface2); + background: var(--surface); + color: var(--text-dim); + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.mode-tab.active { + border-color: var(--accent); + color: var(--accent); + background: rgba(233, 69, 96, 0.1); +} + +/* Quiz area */ +.quiz-container { + width: 100%; + max-width: 900px; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +/* Prompt */ +.prompt-card { + background: var(--surface); + border-radius: 16px; + padding: 20px 32px; + text-align: center; + border: 1px solid var(--surface2); + width: 100%; +} + +.prompt-label { + font-size: 0.8rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 6px; +} + +.prompt-text { font-size: 1.4rem; font-weight: 600; } +.prompt-note { font-size: 2.5rem; font-weight: 700; color: var(--accent2); } + +/* Fretboard */ +.fretboard-wrap { + width: 100%; + overflow-x: auto; + padding: 12px 0; +} + +svg.fretboard-svg { + display: block; + margin: 0 auto; +} + +/* Answer buttons for mode 1 */ +.note-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + margin-top: 4px; +} + +.note-btn { + min-width: 56px; + height: 56px; + padding: 0 10px; + border-radius: 28px; + border: 2px solid var(--surface2); + background: var(--surface); + color: var(--text); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; +} + +.note-btn:hover { border-color: var(--accent); color: var(--accent); transform: scale(1.08); } +.note-btn.correct { background: var(--green); border-color: var(--green); color: #fff; } +.note-btn.wrong { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* Feedback */ +.feedback { + font-size: 1.2rem; + font-weight: 600; + height: 32px; + transition: all 0.2s; +} + +.feedback.correct { color: var(--green); } +.feedback.wrong { color: var(--accent); } + +/* Next button */ +.next-btn { + padding: 12px 36px; + border-radius: 24px; + border: none; + background: var(--accent); + color: #fff; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + letter-spacing: 1px; +} + +.next-btn:hover { background: #c73652; transform: scale(1.04); } +.next-btn:disabled { opacity: 0.3; cursor: default; transform: none; } + +/* Submit / clear buttons for mode 2 */ +.submit-btn { + padding: 12px 36px; + border-radius: 24px; + border: 2px solid var(--accent2); + background: transparent; + color: var(--accent2); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + letter-spacing: 1px; +} + +.submit-btn:hover { background: rgba(245, 166, 35, 0.15); } + +.clear-btn { + padding: 12px 36px; + border-radius: 24px; + border: 2px solid var(--text-dim); + background: transparent; + color: var(--text-dim); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + letter-spacing: 1px; +} + +.clear-btn:hover { background: rgba(136, 136, 136, 0.1); } + +/* Stats */ +.stats { + display: flex; + gap: 24px; + font-size: 0.85rem; + color: var(--text-dim); +} + +.stats span { color: var(--text); font-weight: 600; } + +/* Keyboard hint */ +.kbd-hint { + font-size: 0.75rem; + color: var(--text-dim); + margin-top: -8px; +} + +kbd { + background: var(--surface2); + border-radius: 4px; + padding: 1px 5px; + font-family: monospace; +} + +/* Settings row */ +.settings-row { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + justify-content: center; + font-size: 0.85rem; + color: var(--text-dim); +} + +.settings-row label { display: flex; align-items: center; gap: 6px; cursor: pointer; } + +.settings-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.settings-row select { + background: var(--surface); + border: 1px solid var(--surface2); + color: var(--text); + padding: 4px 8px; + border-radius: 6px; + font-size: 0.85rem; +} + +/* Stats screen */ +.stats-page { + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; + width: 100%; + padding: 8px 0 32px; +} + +.stats-overview { + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; +} + +.stat-card { + background: var(--surface); + border: 1px solid var(--surface2); + border-radius: 16px; + padding: 24px 36px; + text-align: center; + min-width: 120px; +} + +.stat-value { + font-size: 2.2rem; + font-weight: 700; + color: var(--accent2); +} + +.stat-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim); + margin-top: 4px; +} + +.stats-modes, .stats-notes { + width: 100%; + background: var(--surface); + border: 1px solid var(--surface2); + border-radius: 16px; + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.stats-section-title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim); + margin-bottom: 4px; +} + +.mode-stat-row { + display: grid; + grid-template-columns: 1fr auto 200px; + align-items: center; + gap: 16px; + font-size: 0.9rem; +} + +.mode-stat-label { color: var(--text); } +.mode-stat-nums { color: var(--text-dim); font-variant-numeric: tabular-nums; white-space: nowrap; } + +.acc-bar-wrap { + display: flex; + align-items: center; + gap: 10px; + height: 10px; + position: relative; + background: var(--surface2); + border-radius: 5px; + overflow: visible; +} + +.acc-bar { + height: 100%; + border-radius: 5px; + transition: width 0.4s ease; + min-width: 2px; +} + +.acc-bar-wrap span { + position: absolute; + right: -40px; + font-size: 0.8rem; + color: var(--text-dim); + white-space: nowrap; +} + +/* Note table */ +.note-table { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} + +.note-table th { + text-align: left; + color: var(--text-dim); + font-weight: 500; + padding: 4px 8px 10px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.note-table td { + padding: 7px 8px; + border-top: 1px solid rgba(255,255,255,0.04); + vertical-align: middle; +} + +.note-table td:nth-child(2), +.note-table td:nth-child(3) { + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.note-table .acc-bar-wrap { + width: 160px; + margin-right: 48px; +} + +.note-cell { + font-weight: 600; + color: var(--accent2); + min-width: 56px; +} + +.stats-empty { + color: var(--text-dim); + font-size: 0.95rem; + padding: 16px 0; +} + +.reset-stats-btn { + padding: 10px 28px; + border-radius: 20px; + border: 2px solid var(--text-dim); + background: transparent; + color: var(--text-dim); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + letter-spacing: 1px; +} + +.reset-stats-btn:hover { + border-color: var(--accent); + color: var(--accent); +} diff --git a/favicon-16x16.png b/favicon-16x16.png new file mode 100644 index 0000000..78ea8ea Binary files /dev/null and b/favicon-16x16.png differ diff --git a/favicon-32x32.png b/favicon-32x32.png new file mode 100644 index 0000000..b57a017 Binary files /dev/null and b/favicon-32x32.png differ diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..667a5ee Binary files /dev/null and b/favicon.ico differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..3a7291d --- /dev/null +++ b/index.html @@ -0,0 +1,101 @@ + + + + + + Fretboard Trainer + + + + + + + +

Fretboard Trainer

+

Learn the notes on your guitar

+ +
+ + + +
+ +
+
+ + + +
+ +
+
String & Fret
+
Loading…
+
+ +
+
+
+ + +
+ + +
+
+ Click all positions where the note appears, then submit +
+
+ + +
+
+ + + +
+ Space / Enter next  •  19 + answer  •  Tab cycle tabs +
+ +
+ Correct: 0  /  Total: + 0  |  Streak: + 0 +
+
+ +
+ + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..2cda068 --- /dev/null +++ b/js/app.js @@ -0,0 +1,393 @@ +import { noteDisplay, CHROMATIC, ENHARMONIC } from './music.js'; +import { buildFretboard } from './fretboard.js'; +import { genMode1Question, genMode2Question, evaluateMode2 } from './quiz.js'; + +// ─── Persistent stats ───────────────────────────────────────────────────────── +const STORAGE_KEY = 'guitarTrainerStats'; + +function defaultLifetimeStats() { + return { total: 0, correct: 0, bestStreak: 0, mode1: { total: 0, correct: 0 }, mode2: { total: 0, correct: 0 }, notes: {} }; +} + +function loadLifetimeStats() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return { ...defaultLifetimeStats(), ...JSON.parse(raw) }; + } catch {} + return defaultLifetimeStats(); +} + +function saveLifetimeStats() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(lifetimeStats)); +} + +let lifetimeStats = loadLifetimeStats(); + +// ─── Note keyboard input ────────────────────────────────────────────────────── +const NOTE_LETTERS = new Set(['a','b','c','d','e','f','g']); +const FLAT_TO_SHARP = Object.fromEntries( + Object.entries(ENHARMONIC).map(([sharp, flat]) => [flat, sharp]) +); +let noteBuffer = ''; +let noteTimer = null; + +function resolveNoteInput(raw) { + const letter = raw[0].toUpperCase(); + const mod = raw[1] || ''; + if (mod === '#') { const n = letter + '#'; return CHROMATIC.includes(n) ? n : null; } + if (mod === 'b') return FLAT_TO_SHARP[letter + 'b'] || null; + return CHROMATIC.includes(letter) ? letter : null; +} + +function submitNoteBuffer() { + clearTimeout(noteTimer); + noteTimer = null; + const raw = noteBuffer; + noteBuffer = ''; + if (!raw || mode !== 1 || answered) return; + const note = resolveNoteInput(raw); + if (note) checkMode1(note); +} + +// ─── State ──────────────────────────────────────────────────────────────────── +let mode = 1; // 1, 2, or 'stats' +let currentQ = null; +let answered = false; +let mode2Selection = new Set(); +let stats = { correct: 0, total: 0, streak: 0 }; + +// ─── Settings helpers ───────────────────────────────────────────────────────── +const getNumStrings = () => parseInt(document.getElementById('stringCount').value); +const getSharpsOnly = () => document.getElementById('sharpsOnly').checked; +const getNaturalOnly = () => document.getElementById('naturalOnly').checked; + +// ─── Rendering ──────────────────────────────────────────────────────────────── +function renderQuestion() { + answered = false; + mode2Selection.clear(); + clearTimeout(noteTimer); noteBuffer = ''; + + const fb = document.getElementById('feedback'); + fb.textContent = ''; + fb.className = 'feedback'; + document.getElementById('nextBtn').disabled = true; + + if (mode === 1) renderMode1(); + else renderMode2(); +} + +function renderMode1() { + currentQ = genMode1Question(getNumStrings(), getNaturalOnly()); + + const numStrings = getNumStrings(); + const stringNames = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th']; + const stringNumber = numStrings - currentQ.stringIdx; + document.getElementById('promptLabel').textContent = 'What note is this?'; + document.getElementById('promptText').innerHTML = + `${stringNames[stringNumber - 1]} string, ` + + `fret ${currentQ.fret}`; + + document.getElementById('fretboardContainer').innerHTML = buildFretboard({ + numStrings, + highlightPos: { stringIdx: currentQ.stringIdx, fret: currentQ.fret }, + }); + + const btns = document.getElementById('noteButtons'); + btns.innerHTML = ''; + currentQ.choices.forEach((note, i) => { + const btn = document.createElement('button'); + btn.className = 'note-btn'; + btn.textContent = noteDisplay(note, getSharpsOnly()); + btn.dataset.note = note; + if (i < 9) btn.title = `Press ${i + 1}`; + btns.appendChild(btn); + }); + + document.getElementById('noteButtons').style.display = 'flex'; + document.getElementById('mode2Controls').style.display = 'none'; +} + +function renderMode2() { + currentQ = genMode2Question(getNumStrings(), getNaturalOnly()); + + document.getElementById('promptLabel').textContent = 'Find all occurrences of:'; + document.getElementById('promptText').innerHTML = + `${noteDisplay(currentQ.targetNote, getSharpsOnly())}` + + `in frets ${currentQ.fretStart}–${currentQ.fretEnd}`; + + refreshMode2Fretboard(); + + document.getElementById('noteButtons').style.display = 'none'; + document.getElementById('mode2Controls').style.display = 'flex'; +} + +function refreshMode2Fretboard(showCorrect = false) { + document.getElementById('fretboardContainer').innerHTML = buildFretboard({ + numStrings: getNumStrings(), + interactive: true, + fretRange: [currentQ.fretStart, currentQ.fretEnd], + mode2Sel: mode2Selection, + mode2Correct: showCorrect ? currentQ.correctSet : null, + showLabels: showCorrect, + sharpsOnly: getSharpsOnly(), + }); +} + +// ─── Answer checking ────────────────────────────────────────────────────────── +function recordAnswer(isCorrect, note) { + lifetimeStats.total++; + if (isCorrect) lifetimeStats.correct++; + + const modeKey = mode === 1 ? 'mode1' : 'mode2'; + lifetimeStats[modeKey].total++; + if (isCorrect) lifetimeStats[modeKey].correct++; + + if (!lifetimeStats.notes[note]) lifetimeStats.notes[note] = { total: 0, correct: 0 }; + lifetimeStats.notes[note].total++; + if (isCorrect) lifetimeStats.notes[note].correct++; + + if (stats.streak > lifetimeStats.bestStreak) lifetimeStats.bestStreak = stats.streak; + saveLifetimeStats(); +} + +function checkMode1(note) { + if (answered) return; + answered = true; + stats.total++; + + const fb = document.getElementById('feedback'); + document.querySelectorAll('.note-btn').forEach(b => { + if (b.dataset.note === currentQ.note) b.classList.add('correct'); + }); + + const clickedBtn = document.querySelector(`.note-btn[data-note="${note}"]`); + const isCorrect = note === currentQ.note; + + if (isCorrect) { + fb.textContent = '✓ Correct!'; + fb.className = 'feedback correct'; + stats.correct++; + stats.streak++; + } else { + if (clickedBtn) clickedBtn.classList.add('wrong'); + fb.textContent = `✗ It's ${currentQ.note}`; + fb.className = 'feedback wrong'; + stats.streak = 0; + } + + recordAnswer(isCorrect, currentQ.note); + updateStats(); + document.getElementById('nextBtn').disabled = false; + + document.getElementById('fretboardContainer').innerHTML = buildFretboard({ + numStrings: getNumStrings(), + highlightPos: { stringIdx: currentQ.stringIdx, fret: currentQ.fret }, + showLabels: true, + sharpsOnly: getSharpsOnly(), + }); +} + +function checkMode2() { + if (answered) return; + answered = true; + stats.total++; + + const { allCorrect, missed, extra } = evaluateMode2(mode2Selection, currentQ.correctSet); + refreshMode2Fretboard(true); + + const fb = document.getElementById('feedback'); + if (allCorrect) { + const n = currentQ.correctSet.size; + fb.textContent = `✓ Perfect! Found all ${n} position${n !== 1 ? 's' : ''}`; + fb.className = 'feedback correct'; + stats.correct++; + stats.streak++; + } else { + fb.textContent = `✗ Missed ${missed}, extra ${extra} — green = correct positions`; + fb.className = 'feedback wrong'; + stats.streak = 0; + } + + recordAnswer(allCorrect, currentQ.targetNote); + updateStats(); + document.getElementById('nextBtn').disabled = false; +} + +function updateStats() { + document.getElementById('statCorrect').textContent = stats.correct; + document.getElementById('statTotal').textContent = stats.total; + document.getElementById('statStreak').textContent = stats.streak; +} + +// ─── Stats screen ───────────────────────────────────────────────────────────── +function accColor(acc) { + if (acc >= 80) return 'var(--green)'; + if (acc >= 50) return 'var(--accent2)'; + return 'var(--accent)'; +} + +function modeRow(label, mStat) { + const acc = mStat.total ? Math.round(mStat.correct / mStat.total * 100) : 0; + return ` +
+
${label}
+
${mStat.correct}/${mStat.total}
+
+
+ ${mStat.total ? acc + '%' : '—'} +
+
`; +} + +function renderStats() { + const screen = document.getElementById('statsScreen'); + const ls = lifetimeStats; + const overallAcc = ls.total ? Math.round(ls.correct / ls.total * 100) : 0; + + const noteRows = Object.entries(ls.notes) + .map(([note, { correct, total }]) => ({ note, correct, total, acc: Math.round(correct / total * 100) })) + .sort((a, b) => a.acc - b.acc || b.total - a.total); + + screen.innerHTML = ` +
+
+
+
${overallAcc}%
+
Accuracy
+
+
+
${ls.total}
+
Questions
+
+
+
${ls.bestStreak}
+
Best Streak
+
+
+ +
+
By Mode
+ ${modeRow('Name the Note', ls.mode1)} + ${modeRow('Find Occurrences', ls.mode2)} +
+ + ${noteRows.length ? ` +
+
Accuracy by Note
+ + + + ${noteRows.map(r => ` + + + + + + `).join('')} + +
NoteCorrectSeenAccuracy
${noteDisplay(r.note, false)}${r.correct}${r.total} +
+
+ ${r.acc}% +
+
+
` : '
No questions answered yet — start practising!
'} + + +
+ `; + + document.getElementById('resetStatsBtn').addEventListener('click', () => { + if (confirm('Reset all lifetime stats? This cannot be undone.')) { + lifetimeStats = defaultLifetimeStats(); + saveLifetimeStats(); + renderStats(); + } + }); +} + +// ─── Controls ───────────────────────────────────────────────────────────────── +function setMode(m) { + mode = m; + document.getElementById('tab1').classList.toggle('active', m === 1); + document.getElementById('tab2').classList.toggle('active', m === 2); + document.getElementById('tabStats').classList.toggle('active', m === 'stats'); + + const isStats = m === 'stats'; + document.getElementById('quizArea').style.display = isStats ? 'none' : ''; + document.getElementById('statsScreen').style.display = isStats ? 'flex' : 'none'; + + if (isStats) renderStats(); + else renderQuestion(); +} + +function resetQuiz() { + stats = { correct: 0, total: 0, streak: 0 }; + updateStats(); + if (mode !== 'stats') renderQuestion(); +} + +// ─── Event wiring ───────────────────────────────────────────────────────────── +document.getElementById('tab1').addEventListener('click', () => setMode(1)); +document.getElementById('tab2').addEventListener('click', () => setMode(2)); +document.getElementById('tabStats').addEventListener('click', () => setMode('stats')); +document.getElementById('nextBtn').addEventListener('click', renderQuestion); +document.getElementById('submitBtn').addEventListener('click', checkMode2); +document.getElementById('clearBtn').addEventListener('click', () => { + if (!answered) { mode2Selection.clear(); refreshMode2Fretboard(); } +}); +document.getElementById('stringCount').addEventListener('change', resetQuiz); +document.getElementById('sharpsOnly').addEventListener('change', resetQuiz); +document.getElementById('naturalOnly').addEventListener('change', resetQuiz); + +document.getElementById('noteButtons').addEventListener('click', e => { + const btn = e.target.closest('.note-btn'); + if (btn) checkMode1(btn.dataset.note); +}); + +document.getElementById('fretboardContainer').addEventListener('click', e => { + if (mode !== 2 || answered) return; + const cell = e.target.closest('[data-s]'); + if (!cell) return; + const s = parseInt(cell.dataset.s); + const f = parseInt(cell.dataset.f); + const key = `${s},${f}`; + if (mode2Selection.has(key)) mode2Selection.delete(key); + else mode2Selection.add(key); + refreshMode2Fretboard(); +}); + +document.addEventListener('keydown', e => { + if (e.target.tagName === 'SELECT') return; + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + if (mode === 'stats') return; + if (!document.getElementById('nextBtn').disabled) renderQuestion(); + else if (mode === 2 && !answered) checkMode2(); + } + if (mode === 1 && !answered && e.key >= '1' && e.key <= '9') { + const btns = document.querySelectorAll('.note-btn'); + btns[parseInt(e.key) - 1]?.click(); + } + if (mode === 1 && !answered) { + const k = e.key.toLowerCase(); + if (noteBuffer && k === 'b') { + noteBuffer += 'b'; submitNoteBuffer(); + } else if (noteBuffer && k === '#') { + noteBuffer += '#'; submitNoteBuffer(); + } else if (NOTE_LETTERS.has(k)) { + clearTimeout(noteTimer); + noteBuffer = k; + noteTimer = setTimeout(submitNoteBuffer, 600); + } + } + if (e.key === 'Tab' && !e.shiftKey) { + e.preventDefault(); + if (mode === 1) setMode(2); + else if (mode === 2) setMode('stats'); + else setMode(1); + } +}); + +// ─── Init ───────────────────────────────────────────────────────────────────── +renderQuestion(); diff --git a/js/fretboard.js b/js/fretboard.js new file mode 100644 index 0000000..f2bdf8a --- /dev/null +++ b/js/fretboard.js @@ -0,0 +1,154 @@ +import { OPEN_NOTES, getNoteAt, noteDisplay } from './music.js'; + +export const FRET_COUNT = 12; +const STRING_SPACING = 28; +// Width of each playable fret slot (index = fret number, index 0 unused) +const FRET_WIDTHS = [0, 52, 50, 47, 45, 43, 41, 39, 37, 35, 34, 32, 31]; +const MARKER_FRETS = [3, 5, 7, 9, 12]; + +// Returns cumulative x positions of fret wires 0..FRET_COUNT. +// fretXs[f] is the right edge of the f-th fret slot (= left edge of slot f+1). +function computeFretXs() { + const xs = [0]; + for (let i = 1; i <= FRET_COUNT; i++) xs.push(xs[i - 1] + FRET_WIDTHS[i]); + return xs; +} + +const FRET_XS = computeFretXs(); + +/** + * Builds an SVG fretboard string. + * + * opts: + * numStrings {number} 6 or 7 + * highlightPos {stringIdx, fret} | null — mode 1 position dot + * mode2Sel {Set<"s,f">} — user's current selections + * mode2Correct {Set<"s,f">} | null — revealed after submission + * showLabels {boolean} — show note names on dots + * interactive {boolean} — add data-* click targets + * fretRange [start, end] | null — highlighted fret window + * sharpsOnly {boolean} + */ +export function buildFretboard({ + numStrings = 6, + highlightPos = null, + mode2Sel = new Set(), + mode2Correct = null, + showLabels = false, + interactive = false, + fretRange = null, + sharpsOnly = false, +} = {}) { + const ns = numStrings; + const PAD_LEFT = 30; + const PAD_RIGHT = 16; + const PAD_TOP = 20; + const PAD_BOTTOM = 20; + const totalW = FRET_XS[FRET_COUNT]; + const svgW = totalW + PAD_LEFT + PAD_RIGHT; + const svgH = (ns - 1) * STRING_SPACING + PAD_TOP + PAD_BOTTOM; + + const parts = []; + const p = s => parts.push(s); + + p(``); + + // Fret-range highlight band + if (fretRange) { + const [fr1, fr2] = fretRange; + const x1 = PAD_LEFT + (fr1 > 0 ? FRET_XS[fr1 - 1] : 0); + const x2 = PAD_LEFT + FRET_XS[fr2]; + p(``); + } + + // Nut + p(``); + + // Fret wires + for (let f = 1; f <= FRET_COUNT; f++) { + const x = PAD_LEFT + FRET_XS[f]; + p(``); + } + + // Fret numbers + for (let f = 1; f <= FRET_COUNT; f++) { + const x = PAD_LEFT + (FRET_XS[f - 1] + FRET_XS[f]) / 2; + const inRange = !fretRange || (f >= fretRange[0] && f <= fretRange[1]); + p(`${f}`); + } + + // Position marker dots + for (const mf of MARKER_FRETS) { + const x = PAD_LEFT + (FRET_XS[mf - 1] + FRET_XS[mf]) / 2; + const midY = PAD_TOP + (ns - 1) * STRING_SPACING / 2; + if (mf === 12) { + p(``); + p(``); + } else { + p(``); + } + } + + // Strings + const opens = OPEN_NOTES[ns]; + for (let s = 0; s < ns; s++) { + const y = PAD_TOP + (ns - 1 - s) * STRING_SPACING; + const thickness = 1 + (ns - 1 - s) * 0.22; + p(``); + p(`${opens[s]}`); + } + + // Dots and click targets + for (let s = 0; s < ns; s++) { + const y = PAD_TOP + (ns - 1 - s) * STRING_SPACING; + + for (let f = 0; f <= FRET_COUNT; f++) { + const x = f === 0 ? PAD_LEFT - 22 : PAD_LEFT + (FRET_XS[f - 1] + FRET_XS[f]) / 2; + const key = `${s},${f}`; + const inRange = !fretRange || (f >= fretRange[0] && f <= fretRange[1]); + + const dotColor = resolveDotColor({ key, s, f, highlightPos, mode2Sel, mode2Correct, interactive, inRange }); + + if (dotColor) { + p(``); + if (showLabels) { + const label = noteDisplay(getNoteAt(s, f, ns), sharpsOnly); + p(`${label}`); + } + } + + if (interactive && inRange) { + const cellX = f === 0 ? PAD_LEFT - 36 : PAD_LEFT + FRET_XS[f - 1]; + const cellW = f === 0 ? 30 : FRET_XS[f] - FRET_XS[f - 1]; + p(``); + } + } + } + + p(''); + return parts.join(''); +} + +function resolveDotColor({ key, s, f, highlightPos, mode2Sel, mode2Correct, interactive, inRange }) { + // Mode 1 highlight + if (highlightPos && highlightPos.stringIdx === s && highlightPos.fret === f) { + return { fill: '#e94560', stroke: 'none' }; + } + + if (!interactive || !inRange) return null; + + if (mode2Correct) { + // Post-submission state + const isCorrect = mode2Correct.has(key); + const isSelected = mode2Sel.has(key); + if (isCorrect && isSelected) return { fill: '#4caf50', stroke: '#4caf50' }; + if (isCorrect && !isSelected) return { fill: 'rgba(76,175,80,0.5)', stroke: '#4caf50' }; + if (!isCorrect && isSelected) return { fill: 'rgba(233,69,96,0.7)', stroke: '#e94560' }; + return null; + } + + // Pre-submission: show selected positions + if (mode2Sel.has(key)) return { fill: 'var(--accent2)', stroke: 'none' }; + + return null; +} diff --git a/js/music.js b/js/music.js new file mode 100644 index 0000000..67e4944 --- /dev/null +++ b/js/music.js @@ -0,0 +1,31 @@ +// Canonical pitch representation: sharps only. +// Flats are derived via ENHARMONIC for display purposes only. +export const CHROMATIC = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; +export const ENHARMONIC = { 'C#': 'Db', 'D#': 'Eb', 'F#': 'Gb', 'G#': 'Ab', 'A#': 'Bb' }; +export const NATURAL = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; + +// Standard tuning, index 0 = lowest string. +// 6-string: strings 6→1 (low E → high e) +// 7-string: strings 7→1 (low B → high e) +export const OPEN_NOTES = { + 6: ['E', 'A', 'D', 'G', 'B', 'E'], + 7: ['B', 'E', 'A', 'D', 'G', 'B', 'E'], +}; + +export function getNoteAt(stringIdx, fret, numStrings) { + const openIdx = CHROMATIC.indexOf(OPEN_NOTES[numStrings][stringIdx]); + return CHROMATIC[(openIdx + fret) % 12]; +} + +// Returns "D#/Eb" for accidentals unless sharpsOnly is set. +export function noteDisplay(note, sharpsOnly) { + if (sharpsOnly || !ENHARMONIC[note]) return note; + return `${note}/${ENHARMONIC[note]}`; +} + +// Returns 9 answer choices including the correct note (all in sharp form). +export function getAnswerChoices(correct, naturalOnly) { + const pool = naturalOnly ? NATURAL : CHROMATIC; + const others = pool.filter(n => n !== correct).sort(() => Math.random() - 0.5).slice(0, 8); + return [...others, correct].sort(() => Math.random() - 0.5); +} diff --git a/js/quiz.js b/js/quiz.js new file mode 100644 index 0000000..08ade69 --- /dev/null +++ b/js/quiz.js @@ -0,0 +1,44 @@ +import { CHROMATIC, NATURAL, getNoteAt, getAnswerChoices } from './music.js'; +import { FRET_COUNT } from './fretboard.js'; + +export function genMode1Question(numStrings, naturalOnly) { + let stringIdx, fret, note; + let attempts = 0; + do { + stringIdx = Math.floor(Math.random() * numStrings); + fret = Math.floor(Math.random() * (FRET_COUNT + 1)); + note = getNoteAt(stringIdx, fret, numStrings); + attempts++; + } while (naturalOnly && !NATURAL.includes(note) && attempts < 100); + + const choices = getAnswerChoices(note, naturalOnly); + return { stringIdx, fret, note, choices }; +} + +export function genMode2Question(numStrings, naturalOnly) { + const pool = naturalOnly ? NATURAL : CHROMATIC; + const targetNote = pool[Math.floor(Math.random() * pool.length)]; + + const rangeLen = Math.random() < 0.5 ? 4 : 5; + const fretStart = Math.floor(Math.random() * (FRET_COUNT - rangeLen + 1)); + const fretEnd = fretStart + rangeLen; + + const correctSet = new Set(); + for (let s = 0; s < numStrings; s++) { + for (let f = fretStart; f <= fretEnd; f++) { + if (getNoteAt(s, f, numStrings) === targetNote) { + correctSet.add(`${s},${f}`); + } + } + } + + return { targetNote, fretStart, fretEnd, correctSet }; +} + +export function evaluateMode2(selection, correctSet) { + const allCorrect = [...correctSet].every(k => selection.has(k)) && + [...selection].every(k => correctSet.has(k)); + const missed = [...correctSet].filter(k => !selection.has(k)).length; + const extra = [...selection].filter(k => !correctSet.has(k)).length; + return { allCorrect, missed, extra }; +} diff --git a/site.webmanifest b/site.webmanifest new file mode 100644 index 0000000..a1414c8 --- /dev/null +++ b/site.webmanifest @@ -0,0 +1 @@ +{"name":"Guitar Fretboard Trainer","short_name":"Fretboard","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file