pm-mobile: practice sessions, richer readout, BPM-tap, lane/feature detail

Bigger rework of the mobile player around a new "practice session" concept,
plus a second page to review sessions.

Transport / sessions:
- Practice now starts a continuous SESSION clock and begins practicing the
  current track. While practicing, the Play button becomes Stop and Practice
  becomes Pause, so Practice starts/stops individual tracks while the session
  clock keeps running. Stop (the Play button) ends the session and records it.
- Plain Play still runs the metronome with no session/recording.
- Each track-practice is one segment {name, at, sec, bpm}; sub-3s blips are
  skipped. A session = {at, endedAt, clockSec, note, segments[]} stored under
  metronome.sessions (replaces the old per-track metronome.logs sheet).
- Switching track / set list mid-session rolls the current segment over.

Display:
- Removed the Tap Tempo button; the BPM display now does it: tap = tap tempo,
  hold = type an exact value, vertical drag = scrub.
- Detail panel shows every lane (canonical share-token chips, disabled lanes
  struck through) and the active features: bar count, end behavior, ramp, and
  gaps (trainer play/mute).
- Meter line shows live bar count with total (e.g. "bar 4 / 16") and elapsed
  play time; the bottom bar shows live session time + track count while
  recording, and links to the sessions page otherwise.

New page mobile-sessions.html: lists saved sessions, each with an editable note
(autosaved) and an aggregate table of tracks practiced in that session
(track - time - plays - bpm range), with per-session delete. PWA scope widened
to /mobile so both pages stay in the installed app + offline (SW v2).

Engine untouched; conformance suite unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-07 09:14:41 -05:00
parent dca2a405f7
commit 5b11363520
6 changed files with 322 additions and 167 deletions

View file

@ -46,7 +46,7 @@ def build(name):
out.write_text(src) out.write_text(src)
return out.stat().st_size return out.stat().st_size
for name in ("index.html","editor.html","editor-beta.html","pm_e-2.html","player.html","mobile.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html", for name in ("index.html","editor.html","editor-beta.html","pm_e-2.html","player.html","mobile.html","mobile-sessions.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html",
"embed.html", "embed.html",
"info-editor.html","info-pm_e-2.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"): "info-editor.html","info-pm_e-2.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"):
print("built %s (%dKB)" % (name, build(name) // 1024)) print("built %s (%dKB)" % (name, build(name) // 1024))

View file

@ -40,7 +40,7 @@ fi
# stamp the version into the built copy only (source stays clean) # stamp the version into the built copy only (source stays clean)
echo "deployed v$BUILD -> $DEST_DIR" echo "deployed v$BUILD -> $DEST_DIR"
for f in index.html editor.html editor-beta.html pm_e-2.html player.html mobile.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \ for f in index.html editor.html editor-beta.html pm_e-2.html player.html mobile.html mobile-sessions.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \
embed.html \ embed.html \
info-editor.html info-pm_e-2.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do info-editor.html info-pm_e-2.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f" sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"

View file

@ -4,7 +4,7 @@
"description": "Polymetric groove-trainer & metronome — touch-first, full-screen.", "description": "Polymetric groove-trainer & metronome — touch-first, full-screen.",
"id": "/mobile.html", "id": "/mobile.html",
"start_url": "/mobile.html?standalone=1", "start_url": "/mobile.html?standalone=1",
"scope": "/mobile.html", "scope": "/mobile",
"display": "standalone", "display": "standalone",
"display_override": ["standalone", "fullscreen"], "display_override": ["standalone", "fullscreen"],
"orientation": "any", "orientation": "any",

144
mobile-sessions.html Normal file
View file

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VARASYS PolyMeter — Practice sessions</title>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PolyMeter" />
<link rel="apple-touch-icon" href="/icon-180.png" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
<script>
(function(){ try{
var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
</script>
<style>
/*@BUILD:include:src/base.css@*/
:root{
--bg1:#12151c; --bg2:#05070a; --txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
--cyan:#0AB3F7; --amber:#ffd166; --row:#0e1218;
}
:root[data-theme="light"]{
--bg1:#eef3f9; --bg2:#cfd9e6; --txt:#10202f; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0; --row:#f4f7fb;
}
html,body{ height:100%; }
body{ margin:0; min-height:100%; color:var(--txt); background:radial-gradient(circle at 50% -10%, var(--bg1), var(--bg2));
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-text-size-adjust:100%; overscroll-behavior-y:none; }
.wrap{ max-width:760px; margin:0 auto;
padding:max(10px,env(safe-area-inset-top)) max(14px,env(safe-area-inset-right)) max(28px,env(safe-area-inset-bottom)) max(14px,env(safe-area-inset-left)); }
header{ display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:5; padding:8px 0 10px;
background:linear-gradient(180deg, var(--bg1) 70%, transparent); }
.back{ flex:0 0 auto; display:flex; align-items:center; gap:6px; text-decoration:none; color:var(--txt);
background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); border-radius:10px; padding:9px 13px; font-size:15px; }
header h1{ flex:1 1 auto; font-size:18px; margin:0; }
.icon{ flex:0 0 auto; width:40px; height:40px; border-radius:50%; display:flex; align-items:center; justify-content:center;
font-size:17px; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
.summary{ color:var(--muted); font-size:13px; margin:2px 2px 14px; }
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; margin-bottom:14px; }
.chead{ display:flex; align-items:baseline; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.chead .when{ font-size:15px; font-weight:600; }
.chead .del{ background:transparent; border:1px solid var(--panel-bd); color:var(--muted); border-radius:9px; padding:6px 11px; font-size:12px; cursor:pointer; }
.chead .del:hover{ color:#ff7a7a; border-color:#ff7a7a; }
.stat{ font-size:13px; color:var(--muted); margin:4px 0 12px; }
.stat b{ color:var(--txt); }
textarea.note{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px;
padding:10px; font-family:inherit; font-size:14px; resize:vertical; min-height:46px; margin-bottom:12px; }
table{ width:100%; border-collapse:collapse; font-size:14px; }
th,td{ text-align:left; padding:8px 8px; border-bottom:1px solid var(--panel-bd); }
th{ font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); font-weight:600; }
td.num,th.num{ text-align:right; font-variant-numeric:tabular-nums; }
tr:nth-child(even) td{ background:var(--row); }
tfoot td{ font-weight:700; border-top:2px solid var(--panel-bd); border-bottom:none; }
.empty{ text-align:center; color:var(--muted); padding:60px 16px; }
.empty .big{ font-size:46px; opacity:.5; }
.foot{ text-align:center; color:var(--muted); font-size:12px; margin-top:24px; }
.foot img{ height:16px; vertical-align:middle; opacity:.85; }
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
</style>
</head>
<body>
<div class="wrap">
<header>
<a class="back" href="/mobile.html"> Metronome</a>
<h1>Practice sessions</h1>
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme"></div>
</header>
<div class="summary" id="summary"></div>
<div id="list"></div>
<div class="foot">
<img class="logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><img class="logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS" />
&nbsp;PolyMeter <span id="appVersion"></span>
</div>
</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
const LS_SESSIONS = "metronome.sessions";
function lsGet(k,fb){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):fb; }catch(e){ return fb; } }
function lsSet(k,v){ try{ localStorage.setItem(k,JSON.stringify(v)); }catch(e){} }
function fmt(sec){ sec=Math.max(0,Math.round(sec)); const h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
function esc(s){ return String(s).replace(/[&<>"]/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
/* aggregate a session's segments → one row per track */
function aggregate(seg){
const by={};
(seg||[]).forEach((s)=>{ const k=s.name||"(unnamed)"; const a=by[k]||(by[k]={name:k,sec:0,plays:0,bpms:[]}); a.sec+=s.sec; a.plays++; if(s.bpm) a.bpms.push(s.bpm); });
return Object.values(by).sort((x,y)=>y.sec-x.sec);
}
function bpmRange(b){ if(!b.length) return "—"; const lo=Math.min(...b), hi=Math.max(...b); return lo===hi?String(lo):lo+""+hi; }
function render(){
const sessions = lsGet(LS_SESSIONS,[]);
const list=$("list"); list.innerHTML="";
if(!sessions.length){
$("summary").textContent="";
list.innerHTML='<div class="empty"><div class="big">🎼</div><p>No practice sessions yet.<br>On the metronome, press <b>Practice</b> to start a session, then <b>Stop</b> when you\'re done — it\'ll be saved here.</p></div>';
return;
}
const totalClock = sessions.reduce((a,s)=>a+(s.clockSec||0),0);
$("summary").innerHTML = sessions.length+" session"+(sessions.length>1?"s":"")+" · "+fmt(totalClock)+" total practice time";
sessions.forEach((s)=>{
const rows=aggregate(s.segments);
const practiced=rows.reduce((a,r)=>a+r.sec,0);
const card=document.createElement("div"); card.className="card";
const when=new Date(s.at).toLocaleString();
card.innerHTML =
'<div class="chead"><span class="when">'+esc(when)+'</span><button class="del">Delete</button></div>'+
'<div class="stat">Total <b>'+fmt(s.clockSec)+'</b> · practiced <b>'+fmt(practiced)+'</b> · '+rows.length+' track'+(rows.length===1?"":"s")+'</div>'+
'<textarea class="note" placeholder="Add a note about this session — what you worked on, how it felt…">'+esc(s.note||"")+'</textarea>'+
'<table><thead><tr><th>Track</th><th class="num">Time</th><th class="num">Plays</th><th class="num">BPM</th></tr></thead><tbody>'+
rows.map((r)=>'<tr><td>'+esc(r.name)+'</td><td class="num">'+fmt(r.sec)+'</td><td class="num">'+r.plays+'</td><td class="num">'+bpmRange(r.bpms)+'</td></tr>').join("")+
'</tbody><tfoot><tr><td>Total</td><td class="num">'+fmt(practiced)+'</td><td class="num">'+rows.reduce((a,r)=>a+r.plays,0)+'</td><td class="num"></td></tr></tfoot></table>';
card.querySelector(".note").addEventListener("input",(e)=>{ saveNote(s.at, e.target.value); });
card.querySelector(".del").addEventListener("click",()=>{
if(!confirm("Delete this practice session? This can't be undone.")) return;
lsSet(LS_SESSIONS, lsGet(LS_SESSIONS,[]).filter((x)=>x.at!==s.at)); render();
});
list.appendChild(card);
});
}
let saveTimer=null;
function saveNote(at, text){
clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{ const arr=lsGet(LS_SESSIONS,[]); const it=arr.find((x)=>x.at===at); if(it){ it.note=text; lsSet(LS_SESSIONS,arr); } }, 300);
}
/*@BUILD:include:src/chrome.js@*/
render();
</script>
</body>
</html>

View file

@ -8,9 +8,10 @@
* self-contained file that is version-stamped on deploy, so when the device is * self-contained file that is version-stamped on deploy, so when the device is
* online it always gets the freshest build; offline it still launches from cache. * online it always gets the freshest build; offline it still launches from cache.
*/ */
const CACHE = "polymeter-mobile-v1"; const CACHE = "polymeter-mobile-v2";
const SHELL = [ const SHELL = [
"/mobile.html", "/mobile.html",
"/mobile-sessions.html",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icon-192.png", "/icon-192.png",
"/icon-512.png", "/icon-512.png",

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<title>VARASYS PolyMeter — Mobile</title> <title>VARASYS PolyMeter — Mobile</title>
<!-- PWA / Add-to-Home-Screen: makes it launch full-screen & chrome-less from the home screen --> <!-- PWA / Add-to-Home-Screen: launches full-screen & chrome-less from the home screen -->
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
@ -19,12 +19,10 @@
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
<script> <script>
/* ?embed=1 → running inside the form-factor gallery iframe: skip PWA/fullscreen/wake-lock. */
window.EMBED = /[?&]embed=1/.test(location.search); window.EMBED = /[?&]embed=1/.test(location.search);
if (window.EMBED) document.documentElement.dataset.embed = "1"; if (window.EMBED) document.documentElement.dataset.embed = "1";
</script> </script>
<script> <script>
// Set theme before first paint (no flash). Shares the editor's "metronome.theme" key.
(function(){ try{ (function(){ try{
var p = localStorage.getItem("metronome.theme"); var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system"; if (p!=="light" && p!=="dark" && p!=="system") p = "system";
@ -39,14 +37,14 @@
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36; --panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
--cyan:#0AB3F7; --amber:#ffd166; --cyan:#0AB3F7; --amber:#ffd166;
--led-off:#1b2330; --ring:#2a3340; --glow:rgba(10,179,247,.55); --aglow:rgba(255,209,102,.55); --led-off:#1b2330; --ring:#2a3340; --glow:rgba(10,179,247,.55); --aglow:rgba(255,209,102,.55);
--btn1:#2b323d; --btn2:#1b212a; --btn-bd:#39424f; --btn1:#2b323d; --btn2:#1b212a; --btn-bd:#39424f; --chip-bg:#1b2230; --chip-bd:#2c3545;
} }
:root[data-theme="light"]{ :root[data-theme="light"]{
--bg1:#eef3f9; --bg2:#cfd9e6; --bg1:#eef3f9; --bg2:#cfd9e6;
--txt:#10202f; --muted:#5c6776; --link:#1769c4; --txt:#10202f; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0; --panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
--led-off:#c4cedb; --ring:#c9d4e1; --glow:rgba(10,179,247,.40); --aglow:rgba(230,160,30,.45); --led-off:#c4cedb; --ring:#c9d4e1; --glow:rgba(10,179,247,.40); --aglow:rgba(230,160,30,.45);
--btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de; --btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de; --chip-bg:#eef2f7; --chip-bd:#d3dbe5;
} }
html,body{ height:100%; } html,body{ height:100%; }
body{ body{
@ -56,10 +54,9 @@
-webkit-user-select:none; user-select:none; -webkit-tap-highlight-color:transparent; -webkit-user-select:none; user-select:none; -webkit-tap-highlight-color:transparent;
touch-action:manipulation; overscroll-behavior:none; touch-action:manipulation; overscroll-behavior:none;
} }
#app{ position:fixed; inset:0; overflow:hidden; display:flex; flex-direction:column; #app{ position:fixed; inset:0; overflow:hidden; display:flex; flex-direction:column;
padding:max(8px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right)) padding:max(8px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right))
max(10px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); } max(8px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); }
/* ---- top: set-list + track dropdowns, volume, theme/fullscreen ---- */ /* ---- top: set-list + track dropdowns, volume, theme/fullscreen ---- */
#top{ flex:0 0 auto; display:flex; flex-direction:column; gap:8px; } #top{ flex:0 0 auto; display:flex; flex-direction:column; gap:8px; }
@ -76,94 +73,81 @@
background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); } background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
.icon:active{ background:rgba(127,139,154,.30); } .icon:active{ background:rgba(127,139,154,.30); }
/* ---- middle: beats + pulse, then transport (row in landscape) ---- */ /* ---- middle: stage (beats+pulse) + right column (detail + transport) ---- */
#mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; } #mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; gap:6px; }
#stage{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; #stage{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:clamp(12px,3vmin,36px); } gap:clamp(10px,2.4vmin,30px); }
#beats{ display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:clamp(8px,1.7vmin,16px); max-width:92%; } #beats{ display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:clamp(8px,1.7vmin,16px); max-width:92%; }
.dot{ width:clamp(13px,3vmin,30px); height:clamp(13px,3vmin,30px); border-radius:50%; .dot{ width:clamp(12px,2.8vmin,28px); height:clamp(12px,2.8vmin,28px); border-radius:50%;
background:var(--led-off); border:1px solid rgba(0,0,0,.35); transition:background .05s, box-shadow .05s, transform .05s; } background:var(--led-off); border:1px solid rgba(0,0,0,.35); transition:background .05s, box-shadow .05s, transform .05s; }
.dot.group{ outline:1.5px solid var(--amber); outline-offset:3px; opacity:.95; } .dot.group{ outline:1.5px solid var(--amber); outline-offset:3px; opacity:.95; }
.dot.on{ background:var(--cyan); box-shadow:0 0 12px var(--glow); transform:scale(1.12); } .dot.on{ background:var(--cyan); box-shadow:0 0 12px var(--glow); transform:scale(1.12); }
.dot.on.group{ background:var(--amber); box-shadow:0 0 14px var(--aglow); } .dot.on.group{ background:var(--amber); box-shadow:0 0 14px var(--aglow); }
#pulse{ position:relative; width:clamp(180px,46vmin,440px); height:clamp(180px,46vmin,440px); border-radius:50%; #pulse{ position:relative; width:clamp(160px,40vmin,380px); height:clamp(160px,40vmin,380px); border-radius:50%;
display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center;
border:2px solid var(--ring); background:radial-gradient(circle at 50% 40%, rgba(127,139,154,.07), transparent 70%); border:2px solid var(--ring); background:radial-gradient(circle at 50% 40%, rgba(127,139,154,.07), transparent 70%);
transition:transform .12s ease-out, box-shadow .12s ease-out, border-color .12s ease-out; transition:transform .12s ease-out, box-shadow .12s ease-out, border-color .12s ease-out;
touch-action:none; cursor:ns-resize; } touch-action:none; cursor:pointer; }
#pulse.hit{ transform:scale(1.045); border-color:var(--cyan); box-shadow:0 0 60px var(--glow); } #pulse.hit{ transform:scale(1.045); border-color:var(--cyan); box-shadow:0 0 60px var(--glow); }
#pulse.hit.acc{ border-color:var(--amber); box-shadow:0 0 72px var(--aglow); } #pulse.hit.acc{ border-color:var(--amber); box-shadow:0 0 72px var(--aglow); }
#bpm{ font-size:clamp(54px,18vmin,170px); font-weight:800; line-height:.85; font-variant-numeric:tabular-nums; letter-spacing:-.01em; } #bpm{ font-size:clamp(50px,16vmin,150px); font-weight:800; line-height:.85; font-variant-numeric:tabular-nums; letter-spacing:-.01em; }
#bpmlab{ font-size:clamp(11px,2.3vmin,18px); letter-spacing:.34em; color:var(--muted); margin-top:.7em; } #bpmlab{ font-size:clamp(10px,2vmin,16px); letter-spacing:.3em; color:var(--muted); margin-top:.6em; }
#bpmIn{ display:none; width:62%; text-align:center; font:inherit; font-size:clamp(48px,15vmin,140px); font-weight:800; #bpmhint{ font-size:clamp(9px,1.6vmin,12px); color:var(--muted); opacity:.7; margin-top:.5em; }
#bpmIn{ display:none; width:64%; text-align:center; font:inherit; font-size:clamp(46px,14vmin,130px); font-weight:800;
background:transparent; color:var(--txt); border:none; border-bottom:2px solid var(--cyan); outline:none; background:transparent; color:var(--txt); border:none; border-bottom:2px solid var(--cyan); outline:none;
font-variant-numeric:tabular-nums; -moz-appearance:textfield; } font-variant-numeric:tabular-nums; -moz-appearance:textfield; }
#bpmIn::-webkit-outer-spin-button, #bpmIn::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; } #bpmIn::-webkit-outer-spin-button, #bpmIn::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; }
#meterline{ font-size:clamp(12px,2.1vmin,17px); color:var(--muted); min-height:1.2em; letter-spacing:.02em; } #meterline{ font-size:clamp(12px,2.1vmin,16px); color:var(--muted); text-align:center; min-height:1.2em; letter-spacing:.02em; }
/* ---- transport: tempo grid (10/ +/+10) + prev/next + play/practice + tap ---- */ /* ---- detail: lanes + features as chips ---- */
#transport{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:clamp(7px,1.4vmin,12px); #detail{ flex:0 0 auto; max-height:18vh; overflow-y:auto; display:flex; flex-direction:column; gap:5px; padding:2px 0; }
padding-top:clamp(6px,1.2vmin,12px); width:100%; } .chips{ display:flex; flex-wrap:wrap; gap:6px; justify-content:center; }
.chip{ font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; background:var(--chip-bg); border:1px solid var(--chip-bd);
color:var(--txt); border-radius:7px; padding:3px 8px; white-space:nowrap; }
.chip.off{ opacity:.45; text-decoration:line-through; }
.chip.feat{ font-family:inherit; color:var(--muted); }
.chip.feat.r{ border-color:var(--cyan); color:var(--cyan); }
.chip.feat.g{ border-color:var(--amber); color:var(--amber); }
/* ---- transport: tempo grid (10/ +/+10) + prev/next + play/practice ---- */
#transport{ flex:0 0 auto; display:flex; justify-content:center; padding-top:4px; }
.tgrid{ display:grid; width:100%; max-width:560px; gap:clamp(6px,1.7vmin,13px); .tgrid{ display:grid; width:100%; max-width:560px; gap:clamp(6px,1.7vmin,13px);
grid-template-columns:1fr 1.5fr 1.5fr 1fr; grid-template-columns:1fr 1.5fr 1.5fr 1fr;
grid-template-areas:"dn10 prev next up10" "dn play prac up"; } grid-template-areas:"dn10 prev next up10" "dn play prac up"; }
.tbtn{ background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd); .tbtn{ background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
border-radius:14px; height:clamp(50px,12.5vmin,76px); font-size:clamp(18px,4.6vmin,28px); cursor:pointer; border-radius:14px; height:clamp(48px,12vmin,74px); font-size:clamp(18px,4.6vmin,28px); cursor:pointer;
box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06);
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; } display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; }
.tbtn small{ font-size:clamp(8px,1.5vmin,11px); letter-spacing:.1em; color:inherit; opacity:.8; } .tbtn small{ font-size:clamp(8px,1.5vmin,11px); letter-spacing:.1em; color:inherit; opacity:.85; }
.tbtn:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); } .tbtn:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); }
.tbtn:disabled{ opacity:.42; pointer-events:none; }
#bDn10{grid-area:dn10} #bPrev{grid-area:prev} #bNext{grid-area:next} #bUp10{grid-area:up10} #bDn10{grid-area:dn10} #bPrev{grid-area:prev} #bNext{grid-area:next} #bUp10{grid-area:up10}
#bDown{grid-area:dn} #bPlay{grid-area:play} #bPrac{grid-area:prac} #bUp{grid-area:up} #bDown{grid-area:dn} #bPlay{grid-area:play} #bPrac{grid-area:prac} #bUp{grid-area:up}
.tbtn.play{ background:linear-gradient(180deg,#1f7a4d,#155f3b); border-color:#2e7d32; color:#eafff3; } .tbtn.play{ background:linear-gradient(180deg,#1f7a4d,#155f3b); border-color:#2e7d32; color:#eafff3; }
.tbtn.play.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b; color:#fff; } .tbtn.play.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b; color:#fff; }
.tbtn.prac{ background:linear-gradient(180deg,#1c87b8,#136488); border-color:#1f9bd0; color:#eaf8ff; } .tbtn.prac{ background:linear-gradient(180deg,#1c87b8,#136488); border-color:#1f9bd0; color:#eaf8ff; }
.tbtn.prac.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b; color:#fff; } .tbtn.prac.on{ background:linear-gradient(180deg,#c8922a,#9c6f12); border-color:#e0a93a; color:#fff; }
.tap{ width:100%; max-width:560px; height:clamp(44px,9vmin,58px); border-radius:13px;
background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
font-size:clamp(13px,2.4vmin,16px); letter-spacing:.16em; cursor:pointer;
box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); }
.tap:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); }
/* landscape phones: pulse on the left, transport on the right (use width, not height) */ /* landscape phones: pulse left, right column = detail + transport */
@media (orientation:landscape) and (max-height:600px){ @media (orientation:landscape) and (max-height:600px){
#mid{ flex-direction:row; align-items:center; gap:3vw; } #mid{ flex-direction:row; align-items:stretch; gap:3vw; }
#stage{ flex:1 1 52%; gap:clamp(8px,2vmin,18px); } #stage{ flex:1 1 50%; gap:clamp(6px,1.6vmin,14px); }
#transport{ flex:1 1 48%; max-width:560px; } #rightcol{ flex:1 1 50%; display:flex; flex-direction:column; justify-content:center; gap:6px; min-width:0; }
#pulse{ width:clamp(150px,40vmin,300px); height:clamp(150px,40vmin,300px); } #pulse{ width:clamp(140px,38vmin,280px); height:clamp(140px,38vmin,280px); }
#bpmlab{ display:none; } #bpmlab,#bpmhint{ display:none; }
.tgrid > .tbtn, .tap{ height:clamp(42px,13vmin,62px); } #detail{ max-height:30vh; }
.tbtn{ height:clamp(40px,12vmin,60px); }
} }
#rightcol{ display:contents; } /* portrait: detail + transport flow normally in #mid */
/* ---- collapsible practice log (thin bar → bottom-sheet overlay) ---- */ /* ---- session bar (bottom) ---- */
#logbar{ flex:0 0 auto; display:flex; align-items:center; gap:8px; width:100%; #sessbar{ flex:0 0 auto; display:flex; align-items:center; gap:8px; width:100%; margin-top:6px;
margin-top:6px; padding:9px 12px; border-radius:11px; cursor:pointer; padding:9px 12px; border-radius:11px; cursor:pointer; text-decoration:none;
background:rgba(127,139,154,.10); border:1px solid var(--panel-bd); color:var(--muted); font-size:13px; } background:rgba(127,139,154,.10); border:1px solid var(--panel-bd); color:var(--muted); font-size:13px; }
#logbar .grow{ flex:1 1 auto; } #sessbar.rec{ background:rgba(192,57,43,.12); border-color:#c0392b; color:var(--txt); cursor:default; }
#logbar b{ color:var(--txt); } #sessbar .dotrec{ width:9px; height:9px; border-radius:50%; background:#e0493a; box-shadow:0 0 8px #e0493a; flex:0 0 auto; display:none; }
#scrim{ position:fixed; inset:0; background:rgba(0,0,0,.55); opacity:0; pointer-events:none; transition:opacity .2s; z-index:40; } #sessbar.rec .dotrec{ display:block; }
#scrim.open{ opacity:1; pointer-events:auto; } #sessText{ flex:1 1 auto; }
#logsheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:74vh; overflow-y:auto;
background:var(--panel-bg); border-top:1px solid var(--panel-bd); border-radius:18px 18px 0 0;
transform:translateY(110%); transition:transform .26s cubic-bezier(.2,.8,.2,1);
padding:14px max(16px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left)); }
#logsheet.open{ transform:none; }
#logsheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
#logsheet .lhead{ display:flex; align-items:baseline; justify-content:space-between; gap:10px; margin-bottom:4px; }
#logsheet h2{ margin:0; font-size:15px; }
#logsheet .sub{ font-size:12px; color:var(--muted); }
.hist-row{ display:flex; align-items:center; gap:10px; padding:9px 2px; border-bottom:1px solid var(--panel-bd); font-size:13px; }
.hist-row .t{ flex:1 1 auto; font-variant-numeric:tabular-nums; }
.hist-del{ flex:0 0 auto; background:transparent; border:1px solid var(--panel-bd); color:var(--muted);
border-radius:8px; width:30px; height:30px; cursor:pointer; }
.lempty{ font-size:13px; color:var(--muted); padding:10px 2px; }
.lfoot{ display:flex; align-items:center; justify-content:space-between; margin-top:14px; padding-top:12px;
border-top:1px solid var(--panel-bd); font-size:12px; color:var(--muted); }
.lbtn{ cursor:pointer; color:var(--txt); background:transparent; border:1px solid var(--panel-bd); border-radius:9px; padding:7px 13px; font-size:13px; }
.dev-logo{ height:16px; opacity:.85; vertical-align:middle; }
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
</style> </style>
</head> </head>
<body> <body>
@ -188,10 +172,16 @@
<div id="bpm">120</div> <div id="bpm">120</div>
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" /> <input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
<div id="bpmlab">BPM</div> <div id="bpmlab">BPM</div>
<div id="bpmhint">tap = tap tempo · hold = type · drag = scrub</div>
</div> </div>
<div id="meterline"></div> <div id="meterline"></div>
</div> </div>
<div id="rightcol">
<div id="detail">
<div class="chips" id="lanes"></div>
<div class="chips" id="feats"></div>
</div>
<div id="transport"> <div id="transport">
<div class="tgrid"> <div class="tgrid">
<button class="tbtn" id="bDn10" title="Tempo 10">10</button> <button class="tbtn" id="bDn10" title="Tempo 10">10</button>
@ -199,35 +189,25 @@
<button class="tbtn" id="bNext" title="Next track"></button> <button class="tbtn" id="bNext" title="Next track"></button>
<button class="tbtn" id="bUp10" title="Tempo +10">+10</button> <button class="tbtn" id="bUp10" title="Tempo +10">+10</button>
<button class="tbtn" id="bDown" title="Tempo 1"></button> <button class="tbtn" id="bDown" title="Tempo 1"></button>
<button class="tbtn play" id="bPlay" title="Play (no logging)"><small>PLAY</small></button> <button class="tbtn play" id="bPlay" title="Play / Stop"><small>PLAY</small></button>
<button class="tbtn prac" id="bPrac" title="Practice (logs the session)">⦿<small>PRACTICE</small></button> <button class="tbtn prac" id="bPrac" title="Practice (start/stop a track within a session)">⦿<small>PRACTICE</small></button>
<button class="tbtn" id="bUp" title="Tempo +1">+</button> <button class="tbtn" id="bUp" title="Tempo +1">+</button>
</div> </div>
<button class="tap" id="bTap" title="Tap tempo">TAP TEMPO</button> </div>
</div> </div>
</div> </div>
<div id="logbar"><span class="grow">▴ Practice log <span id="logCount"></span></span></div> <a id="sessbar" href="/mobile-sessions.html"><span class="dotrec"></span><span id="sessText">Practice sessions →</span></a>
</div>
<!-- practice-log bottom sheet -->
<div id="scrim"></div>
<div id="logsheet">
<div class="grab"></div>
<div class="lhead"><h2>Practice log</h2><span class="sub" id="logFor"></span></div>
<div id="logBody"></div>
<div class="lfoot">
<span><img class="dev-logo logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><img class="dev-logo logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS" /> &nbsp;PolyMeter <span id="appVersion"></span></span>
<button class="lbtn" id="logClear">Clear this track</button>
</div>
</div> </div>
<script> <script>
const APP_VERSION = "v0.0.1-dev"; const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const LS_LOGS = "metronome.logs", LS_SETLISTS = "metronome.setlists"; const LS_SESSIONS = "metronome.sessions", LS_SETLISTS = "metronome.setlists";
function lsGet(k,fb){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):fb; }catch(e){ return fb; } } function lsGet(k,fb){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):fb; }catch(e){ return fb; } }
function lsSet(k,v){ try{ localStorage.setItem(k,JSON.stringify(v)); }catch(e){} } function lsSet(k,v){ try{ localStorage.setItem(k,JSON.stringify(v)); }catch(e){} }
function fmt(sec){ sec=Math.max(0,Math.round(sec)); const h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
/* ========================= ENGINE (synth voices only; shared scheduler) ======= */ /* ========================= ENGINE (synth voices only; shared scheduler) ======= */
const SAMPLES = {}; const SAMPLES = {};
@ -236,7 +216,7 @@ const SAMPLES = {};
const state={ bpm:120, volume:0.85, running:false }; const state={ bpm:120, volume:0.85, running:false };
let meters=[]; let meters=[];
let ramp={on:false,startBpm:80,amount:5,everyBars:4}, trainer={on:false,playBars:2,muteBars:2}; let ramp={on:false,startBpm:80,amount:5,everyBars:4}, trainer={on:false,playBars:2,muteBars:2};
let segBars=0, segBarCount=0, pendingAdvance=false; let segBars=0, segBarCount=0, pendingAdvance=false, curEnd=null, curRep=null;
let masterBeat=0, masterBeatTime=0, muteWindows=[]; let masterBeat=0, masterBeatTime=0, muteWindows=[];
function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); } function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); }
@ -271,14 +251,14 @@ function buildMeters(lanes){
return (lanes||[]).map(c=>{ return (lanes||[]).map(c=>{
const p=parseGroups(c.groupsStr); const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts, return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0, stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),orns:(c.orns||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0};
}); });
} }
function loadSetup(s){ function loadSetup(s){
ramp=s.ramp?{...s.ramp}:{on:false,startBpm:80,amount:5,everyBars:4}; ramp=s.ramp?{...s.ramp}:{on:false,startBpm:80,amount:5,everyBars:4};
trainer=s.trainer?{...s.trainer}:{on:false,playBars:2,muteBars:2}; trainer=s.trainer?{...s.trainer}:{on:false,playBars:2,muteBars:2};
segBars=s.bars||0; segBarCount=0; segBars=s.bars||0; segBarCount=0; curEnd=s.end; curRep=s.rep;
setBpm(s.bpm||120); setBpm(s.bpm||120);
meters=buildMeters(s.lanes); meters=buildMeters(s.lanes);
rebuildBeats(); rebuildBeats();
@ -289,39 +269,65 @@ function unlockAudio(){
try{ if(navigator.audioSession) navigator.audioSession.type="playback"; }catch(e){} try{ if(navigator.audioSession) navigator.audioSession.type="playback"; }catch(e){}
try{ const b=audioCtx.createBuffer(1,1,audioCtx.sampleRate), s=audioCtx.createBufferSource(); s.buffer=b; s.connect(audioCtx.destination); s.start(0); }catch(e){} try{ const b=audioCtx.createBuffer(1,1,audioCtx.sampleRate), s=audioCtx.createBufferSource(); s.buffer=b; s.connect(audioCtx.destination); s.start(0); }catch(e){}
} }
let runStartAt=0;
function startAudio(){ function startAudio(){
ensureAudio(); unlockAudio(); audioCtx.resume(); state.running=true; ensureAudio(); unlockAudio(); audioCtx.resume(); state.running=true; runStartAt=Date.now();
if(ramp.on) setBpm(ramp.startBpm); if(ramp.on) setBpm(ramp.startBpm);
const t0=audioCtx.currentTime+0.08; const t0=audioCtx.currentTime+0.08;
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; m.currentBar=0; } for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; m.currentBar=0; }
masterBeat=0; masterBeatTime=t0; muteWindows=[]; pendingAdvance=false; lastBeatKey=-1; masterBeat=0; masterBeatTime=t0; muteWindows=[]; pendingAdvance=false; lastBeatKey=-1;
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll(); schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
requestWake(); requestWake();
} }
function stopAudio(){ logFinalize(); state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); releaseWake(); } function stopMetronome(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; }
function stopAudio(){ stopMetronome(); releaseWake(); renderAll(); } // plain stop (no session)
function startRun(){ startAudio(); renderAll(); } // plain play (no session)
/* separate transports: Play = run only · Practice = run AND record to the practice log */ /* ---- sessions: Practice records track segments under one continuous session clock ---- */
let practicing=false, nowPlaying=null; let sessionActive=false, session=null, trackSegStart=null, lastSaved=false;
function startRun(log){ practicing=log; startAudio(); if(log) nowPlaying={at:Date.now(),name:currentName()}; renderAll(); } function startTrack(){
function playBtn(){ if(state.running){ if(!practicing) stopAudio(); } else startRun(false); } if(!sessionActive){ sessionActive=true; session={at:Date.now(),segments:[]}; lastSaved=false; }
function practiceBtn(){ if(state.running){ if(practicing) stopAudio(); } else startRun(true); } startAudio();
function toggle(){ state.running ? stopAudio() : startRun(false); } // keyboard/space = plain play trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm};
renderAll(); renderSessionBar();
}
function recordSegment(){
if(!trackSegStart||!session) return;
const sec=(Date.now()-trackSegStart.at)/1000;
if(sec>=3) session.segments.push({name:trackSegStart.name,at:trackSegStart.at,sec,bpm:state.bpm}); // skip sub-3s blips
trackSegStart=null;
}
function pauseTrack(){ recordSegment(); stopMetronome(); releaseWake(); renderAll(); renderSessionBar(); }
function endSession(){
if(state.running){ recordSegment(); stopMetronome(); releaseWake(); }
if(session){
const endedAt=Date.now(), clockSec=(endedAt-session.at)/1000;
if(session.segments.length && clockSec>=5){
const arr=lsGet(LS_SESSIONS,[]); arr.unshift({at:session.at,endedAt,clockSec,note:"",segments:session.segments});
lsSet(LS_SESSIONS,arr); lastSaved=true;
}
}
session=null; sessionActive=false; trackSegStart=null; renderAll(); renderSessionBar();
}
function play(){ if(sessionActive) endSession(); else if(state.running) stopAudio(); else startRun(); }
function practice(){ if(sessionActive&&state.running){ pauseTrack(); } else { if(state.running&&!sessionActive) stopMetronome(); startTrack(); } }
function toggle(){ play(); } // keyboard space
function gotoItem(i,keepPlaying){ function gotoItem(i,keepPlaying){
if(!setlist||!setlist.items.length) return; if(!setlist||!setlist.items.length) return;
const n=setlist.items.length; idx=((i%n)+n)%n; const n=setlist.items.length; idx=((i%n)+n)%n;
const wasRunning=state.running||keepPlaying; const wasRunning=state.running||keepPlaying;
if(state.running){ if(practicing) logFinalize(); clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; } if(state.running){ if(sessionActive) recordSegment(); stopMetronome(); }
loadSetup(setlist.items[idx]); loadSetup(setlist.items[idx]);
if(wasRunning){ startAudio(); if(practicing) nowPlaying={at:Date.now(),name:currentName()}; } else renderAll(); if(wasRunning){ startAudio(); if(sessionActive) trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; }
buildSetlistOptions(); buildTrackOptions(); renderAll(); renderSessionBar();
} }
function loadSetlistObj(sl){ function loadSetlistObj(sl){
if(state.running&&practicing) logFinalize(); if(state.running&&sessionActive) recordSegment();
const wasRunning=state.running; const wasRunning=state.running; if(wasRunning) stopMetronome();
if(wasRunning){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
setlist=sl; idx=0; loadSetup(sl.items[0]); setlist=sl; idx=0; loadSetup(sl.items[0]);
buildSetlistOptions(); buildTrackOptions(); if(wasRunning){ startAudio(); if(sessionActive) trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; }
if(wasRunning){ startAudio(); if(practicing) nowPlaying={at:Date.now(),name:currentName()}; } else renderAll(); buildSetlistOptions(); buildTrackOptions(); renderAll(); renderSessionBar();
} }
let taps=[]; let taps=[];
@ -329,43 +335,13 @@ function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } } if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } }
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); } function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); }
/* ========================= PRACTICE LOG ====================================== */
let historyName=null;
function snapshotLanes(){ return meters.map(m=>({groupsStr:m.groupsStr,stepsPerBeat:m.stepsPerBeat,sound:m.sound,enabled:m.enabled,poly:m.poly,swing:!!m.swing,gainDb:m.gainDb||0,beatsOn:m.beatsOn.slice()})); }
function fmtDur(sec){ sec=Math.round(sec); const m=Math.floor(sec/60); return m+":"+String(sec%60).padStart(2,"0"); }
function logFinalize(){
if(!nowPlaying) return;
const dur=(Date.now()-nowPlaying.at)/1000;
if(dur>=3){ const logs=lsGet(LS_LOGS,[]); logs.unshift({at:nowPlaying.at,name:nowPlaying.name,durationSec:dur,bpm:state.bpm,lanes:snapshotLanes()}); lsSet(LS_LOGS,logs); } // skip sub-3s blips
nowPlaying=null; renderLog();
}
function renderLog(){
const entries=lsGet(LS_LOGS,[]).filter(e=>e.name===historyName);
$("logCount").innerHTML = entries.length ? ("· <b>"+entries.length+"</b> for this track") : "";
$("logFor").textContent = historyName ? ("“"+historyName+"”") : "";
const box=$("logBody"); box.innerHTML="";
if(!entries.length){ box.innerHTML='<div class="lempty">No sessions for this track yet. Hit <b>Practice</b> to record one — past sessions show here so you can compare BPM &amp; duration over time.</div>'; return; }
entries.forEach(e=>{
const row=document.createElement("div"); row.className="hist-row";
const t=document.createElement("span"); t.className="t"; t.textContent=new Date(e.at).toLocaleString()+" · "+fmtDur(e.durationSec)+" @ "+e.bpm+" bpm";
const del=document.createElement("button"); del.className="hist-del"; del.textContent="✕"; del.title="delete this entry";
del.onclick=()=>{ const logs=lsGet(LS_LOGS,[]).filter(x=>!(x.at===e.at&&x.name===e.name)); lsSet(LS_LOGS,logs); renderLog(); };
row.appendChild(t); row.appendChild(del); box.appendChild(row);
});
}
$("logClear").onclick=()=>{ if(!historyName) return; if(!confirm("Clear all practice-log sessions for “"+historyName+"”?")) return;
lsSet(LS_LOGS, lsGet(LS_LOGS,[]).filter(e=>e.name!==historyName)); renderLog(); };
function openLog(){ renderLog(); $("scrim").classList.add("open"); $("logsheet").classList.add("open"); }
function closeLog(){ $("scrim").classList.remove("open"); $("logsheet").classList.remove("open"); }
$("logbar").onclick=openLog; $("scrim").onclick=closeLog;
/* ========================= SET-LIST / TRACK DROPDOWNS ======================== */ /* ========================= SET-LIST / TRACK DROPDOWNS ======================== */
function opt(v,t){ const o=document.createElement("option"); o.value=v; o.textContent=t; return o; } function opt(v,t){ const o=document.createElement("option"); o.value=v; o.textContent=t; return o; }
function og(label){ const g=document.createElement("optgroup"); g.label=label; return g; } function og(label){ const g=document.createElement("optgroup"); g.label=label; return g; }
function buildSetlistOptions(){ function buildSetlistOptions(){
savedLists=lsGet(LS_SETLISTS,[]); savedLists=lsGet(LS_SETLISTS,[]);
const sel=$("slSel"); sel.innerHTML=""; const sel=$("slSel"); sel.innerHTML="";
if(slKey===""){ sel.appendChild(opt("", transientTitle||"Loaded")); } // a hash/share-loaded list not in the menus if(slKey===""){ sel.appendChild(opt("", transientTitle||"Loaded")); }
const g1=og("Built-in"); BUILTIN.forEach((sl,i)=>g1.appendChild(opt("b"+i, sl.title+" ("+sl.items.length+")"))); sel.appendChild(g1); const g1=og("Built-in"); BUILTIN.forEach((sl,i)=>g1.appendChild(opt("b"+i, sl.title+" ("+sl.items.length+")"))); sel.appendChild(g1);
if(savedLists.length){ const g2=og("Your set lists"); savedLists.forEach((sl,i)=>g2.appendChild(opt("s"+i,(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"))); sel.appendChild(g2); } if(savedLists.length){ const g2=og("Your set lists"); savedLists.forEach((sl,i)=>g2.appendChild(opt("s"+i,(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"))); sel.appendChild(g2); }
sel.value=slKey; sel.value=slKey;
@ -393,51 +369,82 @@ function renderBeats(){
const els=$("beats").children; const els=$("beats").children;
for(let i=0;i<els.length;i++) els[i].classList.toggle("on", i===cur); for(let i=0;i<els.length;i++) els[i].classList.toggle("on", i===cur);
} }
function endLabel(){ if(curEnd==null) return "loop"; if(curEnd==="stop") return "stop"; if(curEnd===1) return "next";
if(typeof curEnd==="number"&&curEnd>0) return "+"+curEnd; return String(curEnd); }
function buildDetail(){
const ln=$("lanes"); ln.innerHTML="";
meters.forEach(m=>{ const c=document.createElement("span"); c.className="chip"+(m.enabled?"":" off"); c.textContent=laneCfgToStr(m); ln.appendChild(c); });
const ft=$("feats"); ft.innerHTML="";
const add=(t,cls)=>{ const c=document.createElement("span"); c.className="chip feat"+(cls?" "+cls:""); c.textContent=t; ft.appendChild(c); };
if(segBars>0) add(segBars+" bars");
add("→ "+endLabel());
if(curRep>1) add("× "+curRep);
if(ramp.on) add("ramp "+ramp.startBpm+"→ +"+ramp.amount+"/"+ramp.everyBars+"bar","r");
if(trainer.on) add("gap "+trainer.playBars+"/"+trainer.muteBars+" play/mute","g");
}
function renderInfo(){ function renderInfo(){
if(!editingBpm) $("bpm").textContent=state.bpm; if(!editingBpm) $("bpm").textContent=state.bpm;
const m=meters[0];
$("meterline").textContent = m ? (m.beatsPerBar + " beats" + (m.groups.length>1 ? " · " + m.groups.join("+") : "") + (m.swing?" · swing":"")) : "";
const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx); const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx);
const name=currentName(); buildDetail(); updateStatus();
if(name!==historyName){ historyName=name; renderLog(); } }
function updateStatus(){
const m=meters[0]; let s="";
if(m){ s=m.beatsPerBar+" beats"+(m.groups.length>1?" · "+m.groups.join("+"):"")+(m.swing?" · swing":""); }
if(state.running&&m){
s+=" · bar "+((m.currentBar|0)+1)+(segBars>0?(" / "+segBars):"")+" · "+fmt((Date.now()-runStartAt)/1000);
} else if(segBars>0){ s+=" · "+segBars+" bars"; }
$("meterline").textContent=s;
} }
function renderTransport(){ function renderTransport(){
const r=state.running; const onAny=sessionActive||state.running;
$("bPlay").classList.toggle("on", r&&!practicing); $("bPrac").classList.toggle("on", r&&practicing); $("bPlay").innerHTML=(onAny?"■<small>STOP</small>":"▶<small>PLAY</small>");
$("bPlay").innerHTML = (r&&!practicing) ? "■<small>STOP</small>" : "▶<small>PLAY</small>"; $("bPlay").classList.toggle("on",onAny);
$("bPrac").innerHTML = (r&&practicing) ? "■<small>STOP</small>" : "⦿<small>PRACTICE</small>"; const pr=state.running&&sessionActive;
$("bPlay").disabled = r&&practicing; $("bPrac").disabled = r&&!practicing; $("bPrac").innerHTML=(pr?"❚❚<small>PAUSE</small>":"⦿<small>PRACTICE</small>");
$("bPrac").classList.toggle("on",pr);
}
function renderSessionBar(){
const bar=$("sessbar"), n=lsGet(LS_SESSIONS,[]).length;
if(sessionActive){ bar.classList.add("rec");
const segs=session.segments.length+(trackSegStart?1:0);
$("sessText").textContent="Recording · session "+fmt((Date.now()-session.at)/1000)+" · "+segs+" track"+(segs===1?"":"s");
} else { bar.classList.remove("rec");
$("sessText").textContent=(lastSaved?"✓ Session saved · ":"")+"Practice sessions"+(n?(" ("+n+")"):"")+" →";
}
} }
function renderAll(){ renderInfo(); renderBeats(); renderTransport(); } function renderAll(){ renderInfo(); renderBeats(); renderTransport(); }
let lastBeatKey=-1, pulseTimer=null; let lastBeatKey=-1, pulseTimer=null;
function pulseHit(acc){ const p=$("pulse"); p.classList.remove("hit","acc"); void p.offsetWidth; p.classList.add("hit"); if(acc) p.classList.add("acc"); function pulseHit(acc){ const p=$("pulse"); p.classList.remove("hit","acc"); void p.offsetWidth; p.classList.add("hit"); if(acc) p.classList.add("acc");
clearTimeout(pulseTimer); pulseTimer=setTimeout(()=>p.classList.remove("hit","acc"),130); } clearTimeout(pulseTimer); pulseTimer=setTimeout(()=>p.classList.remove("hit","acc"),130); }
let lastTimeUpd=0;
function draw(){ function draw(){
// latency-compensated playhead so visuals land when the click is HEARD (see engine note)
if(audioCtx&&state.running){ const now=audioCtx.currentTime-(audioCtx.outputLatency||audioCtx.baseLatency||0); if(audioCtx&&state.running){ const now=audioCtx.currentTime-(audioCtx.outputLatency||audioCtx.baseLatency||0);
for(const m of meters){ while(m.vqPtr<m.vq.length&&m.vq[m.vqPtr].time<=now){ m.currentStep=m.vq[m.vqPtr].step; m.currentBar=m.vq[m.vqPtr].bar; m.vqPtr++; } } } for(const m of meters){ while(m.vqPtr<m.vq.length&&m.vq[m.vqPtr].time<=now){ m.currentStep=m.vq[m.vqPtr].step; m.currentBar=m.vq[m.vqPtr].bar; m.vqPtr++; } } }
renderBeats(); renderBeats();
const m=meters[0]; const m=meters[0];
if(state.running&&m&&m.currentStep>=0){ if(state.running&&m&&m.currentStep>=0){
const beat=Math.floor(m.currentStep/m.stepsPerBeat); const beat=Math.floor(m.currentStep/m.stepsPerBeat);
if(m.currentStep%m.stepsPerBeat===0){ const key=m.currentBar*1000+beat; if(m.currentStep%m.stepsPerBeat===0){ const key=m.currentBar*1000+beat; if(key!==lastBeatKey){ lastBeatKey=key; pulseHit(m.groupStarts.has(beat)); } }
if(key!==lastBeatKey){ lastBeatKey=key; pulseHit(m.groupStarts.has(beat)); } }
} }
const t=performance.now();
if(t-lastTimeUpd>200){ lastTimeUpd=t; if(state.running) updateStatus(); if(sessionActive) renderSessionBar(); }
requestAnimationFrame(draw); requestAnimationFrame(draw);
} }
/* ========================= BPM tap-to-edit + drag-to-scrub ==================== */ /* ========================= BPM: tap=tap-tempo · hold=type · drag=scrub ======== */
let editingBpm=false; let editingBpm=false;
function openBpmEdit(){ editingBpm=true; const i=$("bpmIn"); $("bpm").style.display="none"; i.style.display="block"; i.value=state.bpm; i.focus(); i.select(); } function openBpmEdit(){ editingBpm=true; const i=$("bpmIn"); $("bpm").style.display="none"; i.style.display="block"; i.value=state.bpm; i.focus(); i.select(); }
function closeBpmEdit(commit){ const i=$("bpmIn"); if(commit){ const v=parseInt(i.value,10); if(v){ ramp.on=false; setBpm(v); } } editingBpm=false; i.style.display="none"; $("bpm").style.display="block"; renderAll(); } function closeBpmEdit(commit){ const i=$("bpmIn"); if(commit){ const v=parseInt(i.value,10); if(v){ ramp.on=false; setBpm(v); } } editingBpm=false; i.style.display="none"; $("bpm").style.display="block"; renderAll(); }
$("bpmIn").addEventListener("keydown",(e)=>{ if(e.key==="Enter"){ closeBpmEdit(true); } else if(e.key==="Escape"){ closeBpmEdit(false); } }); $("bpmIn").addEventListener("keydown",(e)=>{ if(e.key==="Enter"){ closeBpmEdit(true); } else if(e.key==="Escape"){ closeBpmEdit(false); } });
$("bpmIn").addEventListener("blur",()=>{ if(editingBpm) closeBpmEdit(true); }); $("bpmIn").addEventListener("blur",()=>{ if(editingBpm) closeBpmEdit(true); });
(function(){ const p=$("pulse"); let dragging=false, moved=false, startY=0, startBpm=120; (function(){ const p=$("pulse"); let dragging=false, moved=false, lpFired=false, startY=0, startBpm=120, lpTimer=null;
p.addEventListener("pointerdown",(e)=>{ if(editingBpm) return; dragging=true; moved=false; startY=e.clientY; startBpm=state.bpm; p.setPointerCapture(e.pointerId); }); p.addEventListener("pointerdown",(e)=>{ if(editingBpm) return; dragging=true; moved=false; lpFired=false; startY=e.clientY; startBpm=state.bpm;
p.addEventListener("pointermove",(e)=>{ if(!dragging) return; const dy=startY-e.clientY; if(Math.abs(dy)>6) moved=true; if(moved){ ramp.on=false; setBpm(startBpm+dy*0.5); renderAll(); } }); p.setPointerCapture(e.pointerId); lpTimer=setTimeout(()=>{ lpFired=true; openBpmEdit(); },450); }); // hold → type
p.addEventListener("pointerup",(e)=>{ if(!dragging) return; dragging=false; try{p.releasePointerCapture(e.pointerId);}catch(_){} if(!moved) openBpmEdit(); }); p.addEventListener("pointermove",(e)=>{ if(!dragging) return; const dy=startY-e.clientY; if(Math.abs(dy)>6){ moved=true; clearTimeout(lpTimer); ramp.on=false; setBpm(startBpm+dy*0.5); renderAll(); } });
p.addEventListener("pointercancel",()=>{ dragging=false; }); p.addEventListener("pointerup",(e)=>{ if(!dragging) return; dragging=false; clearTimeout(lpTimer); try{p.releasePointerCapture(e.pointerId);}catch(_){}
if(!moved && !lpFired) tapTempo(); }); // clean tap → tap tempo
p.addEventListener("pointercancel",()=>{ dragging=false; clearTimeout(lpTimer); });
})(); })();
/* ========================= HASH SHARE-LINK LOADING =========================== */ /* ========================= HASH SHARE-LINK LOADING =========================== */
@ -455,12 +462,12 @@ function loadFromHash(text){
} }
/* ========================= WIRING ============================================ */ /* ========================= WIRING ============================================ */
$("bPlay").onclick=playBtn; $("bPrac").onclick=practiceBtn; $("bPlay").onclick=play; $("bPrac").onclick=practice;
$("bPrev").onclick=()=>gotoItem(idx-1,state.running); $("bNext").onclick=()=>gotoItem(idx+1,state.running); $("bPrev").onclick=()=>gotoItem(idx-1,state.running); $("bNext").onclick=()=>gotoItem(idx+1,state.running);
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1); $("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
$("bUp10").onclick=()=>nudge(+10); $("bDn10").onclick=()=>nudge(-10); $("bUp10").onclick=()=>nudge(+10); $("bDn10").onclick=()=>nudge(-10);
$("bTap").onclick=tapTempo;
$("vol").oninput=(e)=>{ state.volume=(+e.target.value)/100; if(masterGain) masterGain.gain.value=state.volume; }; $("vol").oninput=(e)=>{ state.volume=(+e.target.value)/100; if(masterGain) masterGain.gain.value=state.volume; };
$("sessbar").addEventListener("click",(e)=>{ if(sessionActive) e.preventDefault(); }); // don't lose an in-progress session
/* theme toggle (shared "metronome.theme") + version stamp */ /* theme toggle (shared "metronome.theme") + version stamp */
/*@BUILD:include:src/chrome.js@*/ /*@BUILD:include:src/chrome.js@*/
@ -483,25 +490,28 @@ let deferredPrompt=null;
addEventListener("beforeinstallprompt",(e)=>{ e.preventDefault(); deferredPrompt=e; }); addEventListener("beforeinstallprompt",(e)=>{ e.preventDefault(); deferredPrompt=e; });
if(!window.EMBED && "serviceWorker" in navigator){ addEventListener("load",()=>{ navigator.serviceWorker.register("/mobile-sw.js").catch(()=>{}); }); } if(!window.EMBED && "serviceWorker" in navigator){ addEventListener("load",()=>{ navigator.serviceWorker.register("/mobile-sw.js").catch(()=>{}); }); }
/* warn if the user tries to leave mid-session (in-memory session would be lost) */
addEventListener("beforeunload",(e)=>{ if(sessionActive){ e.preventDefault(); e.returnValue=""; } });
/* ========================= KEYBOARD (desktop testing) ======================== */ /* ========================= KEYBOARD (desktop testing) ======================== */
addEventListener("keydown",(e)=>{ addEventListener("keydown",(e)=>{
const tag=(e.target&&e.target.tagName)||""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return; const tag=(e.target&&e.target.tagName)||""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
const k=e.key; const k=e.key;
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); } if(k===" "||e.code==="Space"){ e.preventDefault(); play(); }
else if(k==="p"||k==="P"){ e.preventDefault(); practice(); }
else if(k==="ArrowRight"){ e.preventDefault(); gotoItem(idx+1,state.running); } else if(k==="ArrowRight"){ e.preventDefault(); gotoItem(idx+1,state.running); }
else if(k==="ArrowLeft"){ e.preventDefault(); gotoItem(idx-1,state.running); } else if(k==="ArrowLeft"){ e.preventDefault(); gotoItem(idx-1,state.running); }
else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); } else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); }
else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); } else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); }
else if(k==="t"||k==="T") tapTempo();
else if(k==="f"||k==="F") toggleFS(); else if(k==="f"||k==="F") toggleFS();
}); });
/* ========================= INIT ============================================== */ /* ========================= INIT ============================================== */
let loadedFromHash=false; if(location.hash && /(p|sl)=/.test(location.hash)) loadFromHash(location.hash);
if(location.hash && /(p|sl)=/.test(location.hash)) loadedFromHash=loadFromHash(location.hash); if(!setlist){ slKey="b0"; setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
if(!setlist){ slKey="b0"; setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); buildSetlistOptions(); buildTrackOptions(); } buildSetlistOptions(); buildTrackOptions();
$("vol").value=Math.round(state.volume*100); $("vol").value=Math.round(state.volume*100);
renderAll(); renderAll(); renderSessionBar();
requestAnimationFrame(draw); requestAnimationFrame(draw);
</script> </script>
</body> </body>