commit
81b57df47d
2 changed files with 562 additions and 0 deletions
@ -0,0 +1,555 @@
|
||||
<!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; |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<div class="page"> |
||||
<h1>Session Tempo</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>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); |
||||
}); |
||||
|
||||
// ---------- 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(); |
||||
</script> |
||||
</body> |
||||
</html> |
||||
Loading…
Reference in new issue