first commit

This commit is contained in:
Tobias Nauen
2026-04-29 15:57:37 +02:00
commit e79260231f
15 changed files with 1216 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -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.

65
README.md Normal file
View File

@@ -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 45 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
```

BIN
android-chrome-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
android-chrome-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

406
css/style.css Normal file
View File

@@ -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);
}

BIN
favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

BIN
favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

101
index.html Normal file
View File

@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fretboard Trainer</title>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<h1>Fretboard Trainer</h1>
<p class="subtitle">Learn the notes on your guitar</p>
<div class="mode-tabs">
<button class="mode-tab active" id="tab1">Name the Note</button>
<button class="mode-tab" id="tab2">Find All Occurrences</button>
<button class="mode-tab" id="tabStats">Stats</button>
</div>
<div class="quiz-container" id="quizArea">
<div class="settings-row">
<label>
Strings:
<select id="stringCount">
<option value="6" selected>6-string</option>
<option value="7">7-string</option>
</select>
</label>
<label
><input type="checkbox" id="sharpsOnly" /> Sharps only (no
flats)</label
>
<label
><input type="checkbox" id="naturalOnly" /> Natural notes only</label
>
</div>
<div class="prompt-card">
<div class="prompt-label" id="promptLabel">String &amp; Fret</div>
<div class="prompt-text" id="promptText">Loading…</div>
</div>
<div class="fretboard-wrap">
<div id="fretboardContainer"></div>
</div>
<!-- Mode 1: note answer buttons -->
<div class="note-buttons" id="noteButtons"></div>
<!-- Mode 2: submit / clear -->
<div
id="mode2Controls"
style="
display: none;
flex-direction: column;
align-items: center;
gap: 12px;
"
>
<div style="color: var(--text-dim); font-size: 0.9rem">
Click all positions where the note appears, then submit
</div>
<div style="display: flex; gap: 12px">
<button class="submit-btn" id="submitBtn">Submit Answer</button>
<button class="clear-btn" id="clearBtn">Clear</button>
</div>
</div>
<div class="feedback" id="feedback"></div>
<button class="next-btn" id="nextBtn" disabled>Next →</button>
<div class="kbd-hint">
<kbd>Space</kbd> / <kbd>Enter</kbd> next &nbsp;&nbsp; <kbd>1</kbd><kbd
>9</kbd
>
answer &nbsp;&nbsp; <kbd>Tab</kbd> cycle tabs
</div>
<div class="stats">
Correct: <span id="statCorrect">0</span> &nbsp;/&nbsp; Total:
<span id="statTotal">0</span> &nbsp;|&nbsp; Streak:
<span id="statStreak">0</span>
</div>
</div>
<div
id="statsScreen"
style="
display: none;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 900px;
"
></div>
<script type="module" src="js/app.js"></script>
</body>
</html>

393
js/app.js Normal file
View File

@@ -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 =
`<span style="color:var(--accent2)">${stringNames[stringNumber - 1]} string</span>, ` +
`fret <span style="color:var(--accent2)">${currentQ.fret}</span>`;
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 =
`<span class="prompt-note">${noteDisplay(currentQ.targetNote, getSharpsOnly())}</span>` +
`<span style="font-size:1rem; color:var(--text-dim); margin-left:12px;">in frets ${currentQ.fretStart}${currentQ.fretEnd}</span>`;
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 `
<div class="mode-stat-row">
<div class="mode-stat-label">${label}</div>
<div class="mode-stat-nums">${mStat.correct}/${mStat.total}</div>
<div class="acc-bar-wrap">
<div class="acc-bar" style="width:${acc}%; background:${accColor(acc)}"></div>
<span>${mStat.total ? acc + '%' : '—'}</span>
</div>
</div>`;
}
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 = `
<div class="stats-page">
<div class="stats-overview">
<div class="stat-card">
<div class="stat-value">${overallAcc}%</div>
<div class="stat-label">Accuracy</div>
</div>
<div class="stat-card">
<div class="stat-value">${ls.total}</div>
<div class="stat-label">Questions</div>
</div>
<div class="stat-card">
<div class="stat-value">${ls.bestStreak}</div>
<div class="stat-label">Best Streak</div>
</div>
</div>
<div class="stats-modes">
<div class="stats-section-title">By Mode</div>
${modeRow('Name the Note', ls.mode1)}
${modeRow('Find Occurrences', ls.mode2)}
</div>
${noteRows.length ? `
<div class="stats-notes">
<div class="stats-section-title">Accuracy by Note</div>
<table class="note-table">
<thead><tr><th>Note</th><th>Correct</th><th>Seen</th><th>Accuracy</th></tr></thead>
<tbody>
${noteRows.map(r => `
<tr>
<td class="note-cell">${noteDisplay(r.note, false)}</td>
<td>${r.correct}</td>
<td>${r.total}</td>
<td>
<div class="acc-bar-wrap">
<div class="acc-bar" style="width:${r.acc}%; background:${accColor(r.acc)}"></div>
<span>${r.acc}%</span>
</div>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : '<div class="stats-empty">No questions answered yet — start practising!</div>'}
<button class="reset-stats-btn" id="resetStatsBtn">Reset All Stats</button>
</div>
`;
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();

154
js/fretboard.js Normal file
View File

@@ -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(`<svg class="fretboard-svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}" xmlns="http://www.w3.org/2000/svg">`);
// 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(`<rect x="${x1}" y="${PAD_TOP - 8}" width="${x2 - x1}" height="${(ns - 1) * STRING_SPACING + 16}" fill="rgba(245,166,35,0.08)" rx="6"/>`);
}
// Nut
p(`<rect x="${PAD_LEFT}" y="${PAD_TOP - 4}" width="5" height="${(ns - 1) * STRING_SPACING + 8}" fill="var(--nut)" rx="2"/>`);
// Fret wires
for (let f = 1; f <= FRET_COUNT; f++) {
const x = PAD_LEFT + FRET_XS[f];
p(`<line x1="${x}" y1="${PAD_TOP}" x2="${x}" y2="${PAD_TOP + (ns - 1) * STRING_SPACING}" stroke="var(--fret)" stroke-width="${f === 12 ? 3 : 1.5}"/>`);
}
// 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(`<text x="${x}" y="${PAD_TOP - 6}" text-anchor="middle" font-size="9" fill="${inRange ? '#aaa' : '#444'}">${f}</text>`);
}
// 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(`<circle cx="${x}" cy="${midY - 8}" r="4" fill="var(--marker)"/>`);
p(`<circle cx="${x}" cy="${midY + 8}" r="4" fill="var(--marker)"/>`);
} else {
p(`<circle cx="${x}" cy="${midY}" r="4" fill="var(--marker)"/>`);
}
}
// 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(`<line x1="${PAD_LEFT}" y1="${y}" x2="${PAD_LEFT + totalW}" y2="${y}" stroke="var(--string)" stroke-width="${thickness}"/>`);
p(`<text x="${PAD_LEFT - 8}" y="${y + 4}" text-anchor="end" font-size="11" fill="var(--text-dim)">${opens[s]}</text>`);
}
// 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(`<circle cx="${x}" cy="${y}" r="10" fill="${dotColor.fill}" stroke="${dotColor.stroke}" stroke-width="2"/>`);
if (showLabels) {
const label = noteDisplay(getNoteAt(s, f, ns), sharpsOnly);
p(`<text x="${x}" y="${y + 4}" text-anchor="middle" font-size="9" font-weight="700" fill="#fff">${label}</text>`);
}
}
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(`<rect x="${cellX}" y="${y - STRING_SPACING / 2}" width="${cellW}" height="${STRING_SPACING}" fill="transparent" style="cursor:pointer" data-s="${s}" data-f="${f}"/>`);
}
}
}
p('</svg>');
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;
}

31
js/music.js Normal file
View File

@@ -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);
}

44
js/quiz.js Normal file
View File

@@ -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 };
}

1
site.webmanifest Normal file
View File

@@ -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"}