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