rdrew 1 day ago
commit
81b57df47d
  1. 555
      Robs Metronome.html
  2. 7
      tunes.json

555
Robs Metronome.html

@ -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>

7
tunes.json

@ -0,0 +1,7 @@
[
{
"id": "mu2qen69",
"name": "Cant we be friends",
"bpm": 111
}
]
Loading…
Cancel
Save