You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1056 lines
30 KiB

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Session Tempo</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300..700&family=Work+Sans:wght@400;500;600&display=swap");
:root {
--parchment: #ede3d3;
--panel: #f7f1e3;
--ink: #2e2418;
--ink-soft: #5a4b39;
--walnut: #4a3222;
--brass: #a9793c;
--brass-light: #c9a16a;
--moss: #5f6f52;
--rule: rgba(46, 36, 24, 0.18);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--parchment);
color: var(--ink);
font-family: "Work Sans", sans-serif;
display: flex;
justify-content: center;
padding: 32px 16px 64px;
}
.page {
width: 100%;
max-width: 560px;
}
h1 {
font-family: "Fraunces", serif;
font-weight: 500;
font-size: 1.6rem;
text-align: center;
margin: 0 0 4px;
letter-spacing: 0.01em;
}
.subtitle {
text-align: center;
color: var(--ink-soft);
font-size: 0.9rem;
margin: 0 0 28px;
}
.hero {
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 4px;
padding: 32px 24px 28px;
text-align: center;
}
.bpm-num {
font-family: "Fraunces", serif;
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 5rem;
line-height: 1;
color: var(--walnut);
user-select: none;
}
.bpm-label {
font-size: 0.8rem;
color: var(--ink-soft);
margin-top: 2px;
margin-bottom: 20px;
}
.tap-pad {
width: 120px;
height: 120px;
margin: 0 auto 24px;
border-radius: 50%;
border: 2px solid var(--brass);
color: var(--walnut);
font-family: "Work Sans";
font-size: 0.85rem;
background: transparent;
cursor: pointer;
transition:
transform 0.06s ease,
background 0.15s ease;
}
.tap-pad.pulse {
background: var(--brass-light);
transform: scale(0.96);
}
.row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
margin-bottom: 22px;
}
.stepper {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--rule);
background: var(--parchment);
color: var(--walnut);
font-size: 1.2rem;
cursor: pointer;
}
.stepper:active {
background: var(--brass-light);
}
input[type="range"] {
flex: 1;
max-width: 220px;
accent-color: var(--brass);
}
.meta-row {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 22px;
font-size: 0.85rem;
color: var(--ink-soft);
}
.meta-row label {
display: flex;
align-items: center;
gap: 6px;
}
.meta-row input[type="number"] {
width: 46px;
text-align: center;
border: 1px solid var(--rule);
background: var(--parchment);
color: var(--ink);
border-radius: 3px;
padding: 3px;
font-family: inherit;
}
.start-btn {
font-family: "Work Sans";
font-weight: 600;
font-size: 0.95rem;
color: var(--parchment);
background: var(--walnut);
border: none;
border-radius: 4px;
padding: 12px 36px;
cursor: pointer;
letter-spacing: 0.02em;
}
.start-btn.playing {
background: var(--moss);
}
.divider {
border: none;
border-top: 1px solid var(--rule);
margin: 36px 0 18px;
}
h2 {
font-family: "Fraunces", serif;
font-weight: 500;
font-size: 1.1rem;
margin: 0 0 14px;
}
.tune-list {
display: flex;
flex-direction: column;
}
.tune-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--rule);
}
.tune-row:last-child {
border-bottom: none;
}
.tune-name {
flex: 1;
font-size: 0.95rem;
}
.tune-bpm {
font-variant-numeric: tabular-nums;
color: var(--ink-soft);
font-size: 0.9rem;
min-width: 60px;
text-align: right;
}
.tune-btn {
background: none;
border: 1px solid var(--rule);
color: var(--ink);
font-size: 0.78rem;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
font-family: "Work Sans";
}
.tune-btn:hover {
border-color: var(--brass);
}
.tune-del {
background: none;
border: none;
color: var(--ink-soft);
font-size: 1rem;
cursor: pointer;
line-height: 1;
padding: 0 4px;
}
.add-row {
display: flex;
gap: 8px;
margin-top: 16px;
}
.add-row input[type="text"] {
flex: 1;
border: 1px solid var(--rule);
background: var(--panel);
padding: 9px 10px;
border-radius: 3px;
font-family: inherit;
font-size: 0.9rem;
color: var(--ink);
}
.add-row button {
background: var(--brass);
color: var(--panel);
border: none;
border-radius: 3px;
padding: 0 16px;
font-family: "Work Sans";
font-weight: 500;
font-size: 0.85rem;
cursor: pointer;
}
.empty {
color: var(--ink-soft);
font-size: 0.88rem;
padding: 8px 0;
}
.status {
text-align: center;
font-size: 0.78rem;
color: var(--ink-soft);
margin-top: 10px;
min-height: 1em;
}
.tuner-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
}
.string-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 12px 4px;
cursor: pointer;
font-family: "Work Sans";
color: var(--ink);
transition: background 0.12s ease, border-color 0.12s ease;
}
.string-btn .note {
font-family: "Fraunces", serif;
font-size: 1.3rem;
font-weight: 600;
color: var(--walnut);
}
.string-btn .hz {
font-size: 0.68rem;
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.string-btn:hover {
border-color: var(--brass);
}
.string-btn.active {
background: var(--brass-light);
border-color: var(--brass);
}
.string-btn.active .note {
color: var(--walnut);
}
.tuner-hint {
text-align: center;
font-size: 0.8rem;
color: var(--ink-soft);
margin-top: 12px;
}
.chord-controls {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.chord-controls label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.78rem;
color: var(--ink-soft);
}
.chord-controls select {
font-family: "Work Sans";
font-size: 0.9rem;
color: var(--ink);
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 4px;
padding: 6px 8px;
}
.chord-block-label {
font-size: 0.78rem;
color: var(--ink-soft);
margin: 0 0 8px;
text-align: center;
}
.chord-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin-bottom: 8px;
}
.chord-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 10px 2px;
}
.chord-box .chord-name {
font-family: "Fraunces", serif;
font-weight: 600;
font-size: 1.05rem;
color: var(--walnut);
}
.chord-box .chord-roman {
font-size: 0.68rem;
color: var(--ink-soft);
}
.chord-row.transposed .chord-box {
background: var(--brass-light);
border-color: var(--brass);
}
.transpose-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin: 18px 0 20px;
}
.transpose-amount {
font-family: "Fraunces", serif;
font-weight: 600;
font-size: 1.1rem;
color: var(--walnut);
min-width: 72px;
text-align: center;
font-variant-numeric: tabular-nums;
}
</style>
</head>
<body>
<div class="page">
<h1>Rob's Metronome</h1>
<p class="subtitle">A metronome that remembers the tune</p>
<div class="hero">
<div class="bpm-num" id="bpmNum">120</div>
<div class="bpm-label">beats per minute</div>
<button class="tap-pad" id="tapPad">tap tempo</button>
<div class="row">
<button class="stepper" id="bpmDown">–</button>
<input type="range" id="bpmSlider" min="30" max="300" value="120" />
<button class="stepper" id="bpmUp">+</button>
</div>
<div class="meta-row">
<label
>beats per bar
<input type="number" id="beatsPerBar" min="1" max="12" value="4"
/></label>
<label
>volume
<input
type="range"
id="volumeSlider"
min="0"
max="100"
value="70"
style="width: 80px"
/></label>
</div>
<button class="start-btn" id="startBtn">Start</button>
</div>
<hr class="divider" />
<h2>Tuner</h2>
<div class="tuner-grid" id="tunerGrid"></div>
<p class="tuner-hint">Tap a string to play its tone. Tap again to stop.</p>
<hr class="divider" />
<h2>Chords</h2>
<div class="chord-controls">
<label
>Key
<select id="keyRoot"></select>
</label>
<label
>Scale
<select id="scaleType"></select>
</label>
<label
>Spelling
<select id="spellingMode">
<option value="sharp">Sharps (♯)</option>
<option value="flat">Flats (♭)</option>
</select>
</label>
</div>
<p class="chord-block-label" id="originalKeyLabel"></p>
<div class="chord-row" id="originalChordRow"></div>
<div class="transpose-controls">
<button class="stepper" id="transposeDown">–</button>
<div class="transpose-amount" id="transposeAmount">0 st</div>
<button class="stepper" id="transposeUp">+</button>
</div>
<p class="chord-block-label" id="transposedKeyLabel"></p>
<div class="chord-row transposed" id="transposedChordRow"></div>
<hr class="divider" />
<h2>Your tunes</h2>
<div class="tune-list" id="tuneList">
<div class="empty" id="emptyMsg">Loading…</div>
</div>
<div class="add-row">
<input
type="text"
id="newTuneName"
placeholder="Tune name (e.g. Drowsy Maggie)"
/>
<button id="saveTuneBtn">Save at current tempo</button>
</div>
<div class="add-row" style="margin-top: 8px">
<button class="tune-btn" id="exportBtn" style="flex: 1">
Export tunes.json
</button>
<button class="tune-btn" id="importBtn" style="flex: 1">
Import tunes.json
</button>
<input
type="file"
id="importFile"
accept="application/json"
style="display: none"
/>
</div>
<div class="status" id="statusMsg"></div>
</div>
<script>
// ---------- Metronome engine ----------
let audioCtx = null;
let isPlaying = false;
let bpm = 120;
let beatsPerBar = 4;
let volume = 0.7;
let currentBeat = 0;
let nextNoteTime = 0;
let schedulerTimer = null;
const lookahead = 25; // ms
const scheduleAheadTime = 0.1; // seconds
function ensureAudio() {
if (!audioCtx)
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function playClick(time, accented) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.frequency.value = accented ? 1500 : 1000;
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0002, (accented ? 0.5 : 0.3) * volume),
time + 0.002,
);
gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.05);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(time);
osc.stop(time + 0.06);
}
function scheduler() {
while (nextNoteTime < audioCtx.currentTime + scheduleAheadTime) {
playClick(nextNoteTime, currentBeat === 0);
const beatToFlash = currentBeat;
const delay = (nextNoteTime - audioCtx.currentTime) * 1000;
setTimeout(() => flashPad(beatToFlash === 0), Math.max(0, delay));
nextNoteTime += 60.0 / bpm;
currentBeat = (currentBeat + 1) % beatsPerBar;
}
schedulerTimer = setTimeout(scheduler, lookahead);
}
function flashPad(accented) {
tapPad.classList.add("pulse");
setTimeout(() => tapPad.classList.remove("pulse"), accented ? 110 : 70);
}
function startMetronome() {
ensureAudio();
if (audioCtx.state === "suspended") audioCtx.resume();
currentBeat = 0;
nextNoteTime = audioCtx.currentTime + 0.05;
scheduler();
isPlaying = true;
startBtn.textContent = "Stop";
startBtn.classList.add("playing");
}
function stopMetronome() {
clearTimeout(schedulerTimer);
isPlaying = false;
startBtn.textContent = "Start";
startBtn.classList.remove("playing");
}
// ---------- UI wiring ----------
const bpmNum = document.getElementById("bpmNum");
const bpmSlider = document.getElementById("bpmSlider");
const bpmDown = document.getElementById("bpmDown");
const bpmUp = document.getElementById("bpmUp");
const beatsPerBarInput = document.getElementById("beatsPerBar");
const startBtn = document.getElementById("startBtn");
const tapPad = document.getElementById("tapPad");
const statusMsg = document.getElementById("statusMsg");
function setBpm(val) {
bpm = Math.max(30, Math.min(300, Math.round(val)));
bpmNum.textContent = bpm;
bpmSlider.value = bpm;
}
bpmSlider.addEventListener("input", (e) => setBpm(e.target.value));
bpmDown.addEventListener("click", () => setBpm(bpm - 1));
bpmUp.addEventListener("click", () => setBpm(bpm + 1));
beatsPerBarInput.addEventListener("change", (e) => {
beatsPerBar = Math.max(1, Math.min(12, parseInt(e.target.value) || 4));
});
const volumeSlider = document.getElementById("volumeSlider");
volumeSlider.addEventListener("input", (e) => {
volume = parseInt(e.target.value) / 100;
});
startBtn.addEventListener("click", () => {
if (isPlaying) stopMetronome();
else startMetronome();
});
// Tap tempo
let tapTimes = [];
tapPad.addEventListener("click", () => {
const now = performance.now();
tapTimes.push(now);
tapTimes = tapTimes.filter((t) => now - t < 2500);
if (tapTimes.length > 1) {
const intervals = [];
for (let i = 1; i < tapTimes.length; i++)
intervals.push(tapTimes[i] - tapTimes[i - 1]);
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length;
setBpm(60000 / avg);
}
flashPad(false);
});
// ---------- Guitar tuner ----------
const GUITAR_STRINGS = [
{ note: "E", octave: 2, freq: 82.41 },
{ note: "A", octave: 2, freq: 110.0 },
{ note: "D", octave: 3, freq: 146.83 },
{ note: "G", octave: 3, freq: 196.0 },
{ note: "B", octave: 3, freq: 246.94 },
{ note: "E", octave: 4, freq: 329.63 },
];
const tunerGrid = document.getElementById("tunerGrid");
let tuneOsc = null;
let tuneGain = null;
let activeStringBtn = null;
function stopTunerTone() {
if (tuneOsc) {
const g = tuneGain;
const now = audioCtx.currentTime;
g.gain.cancelScheduledValues(now);
g.gain.setValueAtTime(g.gain.value, now);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.06);
tuneOsc.stop(now + 0.08);
}
if (activeStringBtn) activeStringBtn.classList.remove("active");
tuneOsc = null;
tuneGain = null;
activeStringBtn = null;
}
function playTunerTone(freq, btn) {
ensureAudio();
if (audioCtx.state === "suspended") audioCtx.resume();
// Toggle off if this string is already sounding
if (activeStringBtn === btn) {
stopTunerTone();
return;
}
stopTunerTone();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "sine";
osc.frequency.value = freq;
const now = audioCtx.currentTime;
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.28, now + 0.03);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
tuneOsc = osc;
tuneGain = gain;
activeStringBtn = btn;
btn.classList.add("active");
}
GUITAR_STRINGS.forEach((s) => {
const btn = document.createElement("button");
btn.className = "string-btn";
btn.innerHTML =
'<span class="note">' +
s.note +
s.octave +
'</span><span class="hz">' +
s.freq.toFixed(2) +
" Hz</span>";
btn.addEventListener("click", () => playTunerTone(s.freq, btn));
tunerGrid.appendChild(btn);
});
// ---------- Diatonic chords / transposer ----------
const NOTE_NAMES = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const FLAT_NAMES = [
"C",
"Db",
"D",
"Eb",
"E",
"F",
"Gb",
"G",
"Ab",
"A",
"Bb",
"B",
];
const SCALES = {
Ionian: { label: "Major (Ionian)", steps: [0, 2, 4, 5, 7, 9, 11] },
Dorian: { label: "Dorian", steps: [0, 2, 3, 5, 7, 9, 10] },
Phrygian: { label: "Phrygian", steps: [0, 1, 3, 5, 7, 8, 10] },
Lydian: { label: "Lydian", steps: [0, 2, 4, 6, 7, 9, 11] },
Mixolydian: { label: "Mixolydian", steps: [0, 2, 4, 5, 7, 9, 10] },
Aeolian: { label: "Minor (Aeolian)", steps: [0, 2, 3, 5, 7, 8, 10] },
Locrian: { label: "Locrian", steps: [0, 1, 3, 5, 6, 8, 10] },
};
let spellingMode = "sharp";
function noteName(index) {
const table = spellingMode === "flat" ? FLAT_NAMES : NOTE_NAMES;
return table[((index % 12) + 12) % 12];
}
// Build the 7 diatonic triads for a given root index (0-11) and scale key.
function buildDiatonicChords(rootIndex, scaleKey) {
const steps = SCALES[scaleKey].steps;
const scaleNotes = steps.map((s) => (rootIndex + s) % 12);
return scaleNotes.map((deg, i) => {
const root = scaleNotes[i];
const third = scaleNotes[(i + 2) % 7];
const fifth = scaleNotes[(i + 4) % 7];
const rootToThird = ((third - root + 12) % 12);
const thirdToFifth = ((fifth - third + 12) % 12);
let quality, romanBase;
if (rootToThird === 4 && thirdToFifth === 3) {
quality = "";
romanBase = "maj";
} else if (rootToThird === 3 && thirdToFifth === 4) {
quality = "m";
romanBase = "min";
} else if (rootToThird === 3 && thirdToFifth === 3) {
quality = "dim";
romanBase = "dim";
} else if (rootToThird === 4 && thirdToFifth === 4) {
quality = "aug";
romanBase = "aug";
} else {
quality = "";
romanBase = "maj";
}
const numerals = ["I", "II", "III", "IV", "V", "VI", "VII"];
let roman = numerals[i];
if (romanBase === "min") roman = roman.toLowerCase();
if (romanBase === "dim") roman = roman.toLowerCase() + "°";
if (romanBase === "aug") roman = roman + "+";
return {
name: noteName(root) + quality,
roman: roman,
};
});
}
const keyRootSelect = document.getElementById("keyRoot");
const scaleTypeSelect = document.getElementById("scaleType");
const spellingModeSelect = document.getElementById("spellingMode");
const originalChordRow = document.getElementById("originalChordRow");
const transposedChordRow = document.getElementById(
"transposedChordRow",
);
const originalKeyLabel = document.getElementById("originalKeyLabel");
const transposedKeyLabel = document.getElementById(
"transposedKeyLabel",
);
const transposeAmountEl = document.getElementById("transposeAmount");
const transposeDownBtn = document.getElementById("transposeDown");
const transposeUpBtn = document.getElementById("transposeUp");
function populateKeyOptions() {
const prevValue = keyRootSelect.value;
keyRootSelect.innerHTML = "";
NOTE_NAMES.forEach((_, i) => {
const opt = document.createElement("option");
opt.value = i;
opt.textContent = noteName(i);
keyRootSelect.appendChild(opt);
});
if (prevValue !== "") keyRootSelect.value = prevValue;
}
populateKeyOptions();
Object.keys(SCALES).forEach((key) => {
const opt = document.createElement("option");
opt.value = key;
opt.textContent = SCALES[key].label;
scaleTypeSelect.appendChild(opt);
});
let chordRootIndex = 0; // C
let chordScaleKey = "Ionian";
let transposeSemitones = 0;
function renderChordRow(container, chords) {
container.innerHTML = "";
chords.forEach((c) => {
const box = document.createElement("div");
box.className = "chord-box";
const name = document.createElement("div");
name.className = "chord-name";
name.textContent = c.name;
const roman = document.createElement("div");
roman.className = "chord-roman";
roman.textContent = c.roman;
box.appendChild(name);
box.appendChild(roman);
container.appendChild(box);
});
}
function renderChords() {
const scaleLabel = SCALES[chordScaleKey].label;
const originalChords = buildDiatonicChords(
chordRootIndex,
chordScaleKey,
);
originalKeyLabel.textContent =
noteName(chordRootIndex) + " " + scaleLabel;
renderChordRow(originalChordRow, originalChords);
const transposedRoot = chordRootIndex + transposeSemitones;
const transposedChords = buildDiatonicChords(
transposedRoot,
chordScaleKey,
);
transposedKeyLabel.textContent =
noteName(transposedRoot) +
" " +
scaleLabel +
" (" +
(transposeSemitones > 0 ? "+" : "") +
transposeSemitones +
" semitones)";
renderChordRow(transposedChordRow, transposedChords);
transposeAmountEl.textContent =
(transposeSemitones > 0 ? "+" : "") + transposeSemitones + " st";
}
keyRootSelect.addEventListener("change", (e) => {
chordRootIndex = parseInt(e.target.value, 10);
renderChords();
});
scaleTypeSelect.addEventListener("change", (e) => {
chordScaleKey = e.target.value;
renderChords();
});
spellingModeSelect.addEventListener("change", (e) => {
spellingMode = e.target.value;
populateKeyOptions();
keyRootSelect.value = chordRootIndex;
renderChords();
});
transposeDownBtn.addEventListener("click", () => {
transposeSemitones = Math.max(-24, transposeSemitones - 1);
renderChords();
});
transposeUpBtn.addEventListener("click", () => {
transposeSemitones = Math.min(24, transposeSemitones + 1);
renderChords();
});
// ---------- Tune storage (browser-local cache + JSON export/import) ----------
const tuneList = document.getElementById("tuneList");
const emptyMsg = document.getElementById("emptyMsg");
const newTuneName = document.getElementById("newTuneName");
const saveTuneBtn = document.getElementById("saveTuneBtn");
const exportBtn = document.getElementById("exportBtn");
const importBtn = document.getElementById("importBtn");
const importFile = document.getElementById("importFile");
const STORAGE_KEY = "session-tempo-tunes";
let tunes = [];
function loadTunes() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
tunes = raw ? JSON.parse(raw) : [];
} catch (err) {
tunes = [];
}
renderTunes();
}
function persistTunes() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tunes));
} catch (err) {
showStatus("Could not save locally — try again.");
}
}
exportBtn.addEventListener("click", () => {
const blob = new Blob([JSON.stringify(tunes, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "tunes.json";
a.click();
URL.revokeObjectURL(url);
showStatus("Exported tunes.json — move it into your repo and commit.");
});
importBtn.addEventListener("click", () => importFile.click());
importFile.addEventListener("change", () => {
const file = importFile.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(reader.result);
if (!Array.isArray(parsed)) throw new Error("not an array");
tunes = parsed;
persistTunes();
renderTunes();
showStatus(
"Imported " +
tunes.length +
" tune" +
(tunes.length === 1 ? "" : "s") +
".",
);
} catch (err) {
showStatus("That file didn't look like a valid tunes.json.");
}
};
reader.readAsText(file);
importFile.value = "";
});
function showStatus(msg) {
statusMsg.textContent = msg;
setTimeout(() => {
if (statusMsg.textContent === msg) statusMsg.textContent = "";
}, 2500);
}
function renderTunes() {
tuneList.innerHTML = "";
if (tunes.length === 0) {
const p = document.createElement("div");
p.className = "empty";
p.textContent =
"No tunes saved yet — set a tempo above and save it below.";
tuneList.appendChild(p);
return;
}
tunes.forEach((tune) => {
const row = document.createElement("div");
row.className = "tune-row";
const name = document.createElement("div");
name.className = "tune-name";
name.textContent = tune.name;
const bpmSpan = document.createElement("div");
bpmSpan.className = "tune-bpm";
bpmSpan.textContent = tune.bpm + " bpm";
const loadBtn = document.createElement("button");
loadBtn.className = "tune-btn";
loadBtn.textContent = "Load";
loadBtn.addEventListener("click", () => setBpm(tune.bpm));
const updateBtn = document.createElement("button");
updateBtn.className = "tune-btn";
updateBtn.textContent = "Update";
updateBtn.addEventListener("click", () => {
tune.bpm = bpm;
persistTunes();
renderTunes();
showStatus(tune.name + " updated to " + bpm + " bpm.");
});
const delBtn = document.createElement("button");
delBtn.className = "tune-del";
delBtn.textContent = "×";
delBtn.title = "Remove";
delBtn.addEventListener("click", () => {
tunes = tunes.filter((t) => t.id !== tune.id);
persistTunes();
renderTunes();
});
row.appendChild(name);
row.appendChild(bpmSpan);
row.appendChild(loadBtn);
row.appendChild(updateBtn);
row.appendChild(delBtn);
tuneList.appendChild(row);
});
}
saveTuneBtn.addEventListener("click", () => {
const name = newTuneName.value.trim();
if (!name) {
showStatus("Give the tune a name first.");
return;
}
tunes.push({ id: Date.now().toString(36), name, bpm });
persistTunes();
renderTunes();
newTuneName.value = "";
showStatus(name + " saved at " + bpm + " bpm.");
});
newTuneName.addEventListener("keydown", (e) => {
if (e.key === "Enter") saveTuneBtn.click();
});
// init
setBpm(120);
loadTunes();
renderChords();
</script>
</body>
</html>