first commit
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal 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
65
README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Guitar Fretboard Trainer
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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
|
||||
```
|
||||
BIN
android-chrome-192x192.png
Normal file
BIN
android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
android-chrome-512x512.png
Normal file
BIN
android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
apple-touch-icon.png
Normal file
BIN
apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
406
css/style.css
Normal file
406
css/style.css
Normal 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
BIN
favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 620 B |
BIN
favicon-32x32.png
Normal file
BIN
favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
101
index.html
Normal file
101
index.html
Normal 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 & 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 • <kbd>1</kbd>–<kbd
|
||||
>9</kbd
|
||||
>
|
||||
answer • <kbd>Tab</kbd> cycle tabs
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
Correct: <span id="statCorrect">0</span> / Total:
|
||||
<span id="statTotal">0</span> | 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
393
js/app.js
Normal 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
154
js/fretboard.js
Normal 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
31
js/music.js
Normal 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
44
js/quiz.js
Normal 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
1
site.webmanifest
Normal 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"}
|
||||
Reference in New Issue
Block a user