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.
1088 lines
31 KiB
1088 lines
31 KiB
<!doctype html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8" /> |
|
<!-- SVG favicon: crisp at any size --> |
|
<!-- <link --> |
|
<!-- rel="icon" --> |
|
<!-- type="image/svg+xml" --> |
|
<!-- href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+..." --> |
|
<!-- /> --> |
|
<title>Rob's Metronome</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> |
|
|
|
<hr class="divider" /> |
|
<h2>Links</h2> |
|
<div class="add-row" style="margin-top: 8px"> |
|
<a |
|
style="flex: 1" |
|
href="https://www.oolimo.com/en/guitar-chords/find" |
|
target="_blank" |
|
> |
|
<button class="tune-btn" style="width: 100%">Oolimo</button> |
|
</a> |
|
<a |
|
style="flex: 1" |
|
href="https://www.onemotion.com/chord-player/" |
|
target="_blank" |
|
> |
|
<button class="tune-btn" style="width: 100%">Jam Track</button> |
|
</a> |
|
<a |
|
style="flex: 1" |
|
href="https://www.oolimo.com/en/guitar-chords/find" |
|
target="_blank" |
|
> |
|
<button class="tune-btn" style="width: 100%">Oolimo</button> |
|
</a> |
|
</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>
|
|
|