pm-mobile: editable lane pads, guided help tour, persisted state

- Editable lanes (no notation/konnakol — just pads): each lane is a row of pads
  that blink on the beat; tap a pad to cycle rest → beat → accent → ghost. A
  lane's label opens a sheet to set sound, grouping (e.g. 2+2+3), subdivision,
  swing, mute and polymeter; plus "+ Add lane" / delete. Edits are live and feed
  straight into the scheduler. (Replaces the read-only lane chips; the global
  feature chips — bars/end/ramp/gaps — stay.)
- Help: a "?" runs a 7-step guided coachmark tour (spotlight + tooltip), shown
  once on first run and re-runnable anytime. Removed the instruction hint under
  the BPM (the tour covers it). Tour also frames tracks as named practice items.
- Persist + restore: the working state (set list / track / tempo / volume / lane
  edits) is saved to metronome.mobile.state and restored on reload.
- Dropped the separate beat-dot row — the pulse flash + per-lane pad playhead
  cover it, freeing room for the editable lanes.

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:44:31 -05:00
parent 812a69942f
commit 8b795d4107

View file

@ -2,11 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- viewport-fit=cover → draw under the notch/home-indicator; no user zoom (app, not a document) -->
<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: 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)" />
@ -47,78 +45,72 @@
--btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de; --chip-bg:#eef2f7; --chip-bd:#d3dbe5; --btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de; --chip-bg:#eef2f7; --chip-bd:#d3dbe5;
} }
html,body{ height:100%; } html,body{ height:100%; }
body{ body{ margin:0; overflow:hidden; color:var(--txt);
margin:0; overflow:hidden; color:var(--txt);
background:radial-gradient(circle at 50% -12%, var(--bg1), var(--bg2)); background:radial-gradient(circle at 50% -12%, var(--bg1), var(--bg2));
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-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(8px,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 ---- */
#top{ flex:0 0 auto; display:flex; flex-direction:column; gap:8px; } #top{ flex:0 0 auto; display:flex; flex-direction:column; gap:8px; }
.sels{ display:flex; gap:8px; } .sels{ display:flex; gap:8px; }
.sel{ flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:3px; } .sel{ flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:3px; }
.sel > span{ font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); padding-left:3px; } .sel > span{ font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); padding-left:3px; }
.sel select{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); .sel select{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:10px 8px; font-size:15px; }
border-radius:10px; padding:10px 8px; font-size:15px; }
.trow{ display:flex; align-items:center; gap:10px; } .trow{ display:flex; align-items:center; gap:10px; }
.vol{ flex:1 1 auto; display:flex; align-items:center; gap:9px; color:var(--muted); font-size:15px; min-width:0; } .vol{ flex:1 1 auto; display:flex; align-items:center; gap:9px; color:var(--muted); font-size:15px; min-width:0; }
.vol input{ flex:1 1 auto; min-width:0; accent-color:var(--cyan); } .vol input{ flex:1 1 auto; min-width:0; accent-color:var(--cyan); }
.icon{ flex:0 0 auto; width:42px; height:42px; border-radius:50%; display:flex; align-items:center; justify-content:center; .icon{ flex:0 0 auto; width:42px; height:42px; border-radius:50%; display:flex; align-items:center; justify-content:center;
font-size:18px; line-height:1; cursor:pointer; color:var(--txt); font-size:18px; line-height:1; cursor:pointer; color:var(--txt); 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: stage (beats+pulse) + right column (detail + transport) ---- */ /* ---- middle: stage (pulse) + right column (lanes/features + transport) ---- */
#mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; gap:6px; } #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(8px,2vmin,22px); }
gap:clamp(10px,2.4vmin,30px); } #pulse{ position:relative; width:clamp(150px,36vmin,340px); height:clamp(150px,36vmin,340px); border-radius:50%;
#beats{ display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:clamp(8px,1.7vmin,16px); max-width:92%; }
.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; }
.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.group{ background:var(--amber); box-shadow:0 0 14px var(--aglow); }
#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:pointer; }
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(50px,16vmin,150px); font-weight:800; line-height:.85; font-variant-numeric:tabular-nums; letter-spacing:-.01em; } #bpm{ font-size:clamp(46px,15vmin,140px); font-weight:800; line-height:.85; font-variant-numeric:tabular-nums; letter-spacing:-.01em; }
#bpmlab{ font-size:clamp(10px,2vmin,16px); letter-spacing:.3em; color:var(--muted); margin-top:.6em; } #bpmlab{ font-size:clamp(10px,2vmin,16px); letter-spacing:.3em; color:var(--muted); margin-top:.6em; }
#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(42px,13vmin,120px); font-weight:800;
#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; font-variant-numeric:tabular-nums; -moz-appearance:textfield; }
background:transparent; color:var(--txt); border:none; border-bottom:2px solid var(--cyan); outline:none;
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,16px); color:var(--muted); text-align:center; 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; }
/* ---- detail: lanes + features as chips ---- */ /* ---- editable lanes + feature chips ---- */
#detail{ flex:0 0 auto; max-height:18vh; overflow-y:auto; display:flex; flex-direction:column; gap:5px; padding:2px 0; } #detail{ flex:0 0 auto; max-height:30vh; overflow-y:auto; display:flex; flex-direction:column; gap:6px; padding:2px 0; }
#lanes{ display:flex; flex-direction:column; gap:6px; }
.lane{ display:flex; align-items:center; gap:8px; }
.lane.off{ opacity:.5; }
.lmeta{ flex:0 0 auto; width:30%; max-width:130px; min-width:64px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left;
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:6px 8px;
font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; cursor:pointer; }
.lmeta .lg{ color:var(--muted); }
.pads{ flex:1 1 auto; display:flex; gap:3px; overflow-x:auto; padding-bottom:2px; min-width:0; }
.pad{ flex:1 0 14px; min-width:14px; height:clamp(20px,3.6vmin,28px); border-radius:5px; border:1px solid var(--chip-bd); background:var(--led-off); cursor:pointer; padding:0; }
.pad.gs{ border-color:var(--amber); }
.pad.on{ background:var(--cyan); }
.pad.acc{ background:var(--amber); }
.pad.ghost{ background:var(--cyan); opacity:.42; }
.pad.cur{ outline:2px solid var(--txt); outline-offset:-1px; box-shadow:0 0 8px var(--glow); }
.addlane{ align-self:flex-start; background:transparent; border:1px dashed var(--chip-bd); color:var(--muted); border-radius:8px; padding:5px 11px; font-size:12px; cursor:pointer; }
.chips{ display:flex; flex-wrap:wrap; gap:6px; justify-content:center; } .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); .chip.feat{ font-size:clamp(10px,1.8vmin,13px); color:var(--muted); background:var(--chip-bg); border:1px solid var(--chip-bd); border-radius:7px; padding:3px 8px; white-space:nowrap; }
color:var(--txt); border-radius:7px; padding:3px 8px; white-space:nowrap; } .chip.feat.r{ border-color:var(--cyan); color:var(--cyan); } .chip.feat.g{ border-color:var(--amber); color:var(--amber); }
.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 ---- */
#transport{ flex:0 0 auto; display:flex; justify-content:center; padding-top:4px; } #transport{ flex:0 0 auto; display:flex; justify-content:center; padding-top:2px; }
.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.6vmin,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(48px,12vmin,74px); font-size:clamp(18px,4.6vmin,28px); cursor:pointer; border-radius:14px; height:clamp(46px,11vmin,72px); font-size:clamp(18px,4.4vmin,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:.85; } .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); }
#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}
@ -128,26 +120,53 @@
.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,#c8922a,#9c6f12); border-color:#e0a93a; color:#fff; } .tbtn.prac.on{ background:linear-gradient(180deg,#c8922a,#9c6f12); border-color:#e0a93a; color:#fff; }
/* 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:stretch; gap:3vw; } #mid{ flex-direction:row; align-items:stretch; gap:3vw; }
#stage{ flex:1 1 50%; gap:clamp(6px,1.6vmin,14px); } #stage{ flex:1 1 46%; gap:clamp(6px,1.6vmin,14px); }
#rightcol{ flex:1 1 50%; display:flex; flex-direction:column; justify-content:center; gap:6px; min-width:0; } #rightcol{ flex:1 1 54%; display:flex; flex-direction:column; justify-content:center; gap:6px; min-width:0; }
#pulse{ width:clamp(140px,38vmin,280px); height:clamp(140px,38vmin,280px); } #pulse{ width:clamp(130px,34vmin,260px); height:clamp(130px,34vmin,260px); }
#bpmlab,#bpmhint{ display:none; } #bpmlab{ display:none; }
#detail{ max-height:30vh; } #detail{ max-height:42vh; }
.tbtn{ height:clamp(40px,12vmin,60px); } .tbtn{ height:clamp(40px,12vmin,60px); }
} }
#rightcol{ display:contents; } /* portrait: detail + transport flow normally in #mid */ #rightcol{ display:contents; }
/* ---- session bar (bottom) ---- */ /* ---- session bar ---- */
#sessbar{ flex:0 0 auto; display:flex; align-items:center; gap:8px; width:100%; margin-top:6px; #sessbar{ flex:0 0 auto; display:flex; align-items:center; gap:8px; width:100%; margin-top:6px; padding:9px 12px; border-radius:11px;
padding:9px 12px; border-radius:11px; cursor:pointer; text-decoration:none; 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; }
#sessbar.rec{ background:rgba(192,57,43,.12); border-color:#c0392b; color:var(--txt); cursor:default; } #sessbar.rec{ background:rgba(192,57,43,.12); border-color:#c0392b; color:var(--txt); cursor:default; }
#sessbar .dotrec{ width:9px; height:9px; border-radius:50%; background:#e0493a; box-shadow:0 0 8px #e0493a; flex:0 0 auto; display:none; } #sessbar .dotrec{ width:9px; height:9px; border-radius:50%; background:#e0493a; box-shadow:0 0 8px #e0493a; flex:0 0 auto; display:none; }
#sessbar.rec .dotrec{ display:block; } #sessbar.rec .dotrec{ display:block; } #sessText{ flex:1 1 auto; }
#sessText{ flex:1 1 auto; }
/* ---- bottom sheet (lane editor) ---- */
#scrim{ position:fixed; inset:0; background:rgba(0,0,0,.55); opacity:0; pointer-events:none; transition:opacity .2s; z-index:40; }
#scrim.open{ opacity:1; pointer-events:auto; }
#laneSheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:84vh; 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:12px max(16px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left)); }
#laneSheet.open{ transform:none; }
#laneSheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
#laneSheet h2{ margin:0 0 10px; font-size:16px; }
#laneSheet label{ display:block; font-size:12px; color:var(--muted); margin:10px 0 5px; }
#laneSheet select, #laneSheet input[type=text]{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px; font-size:15px; }
.lrow{ display:flex; gap:14px; margin-top:10px; flex-wrap:wrap; }
.lrow .half{ flex:1 1 120px; margin:0; }
.chk{ display:flex; align-items:center; gap:8px; font-size:14px; color:var(--txt); margin-top:18px; }
.chk input{ width:20px; height:20px; accent-color:var(--cyan); }
.lfoot{ display:flex; justify-content:space-between; margin-top:18px; }
.lbtn{ cursor:pointer; color:var(--txt); background:linear-gradient(180deg,var(--btn1),var(--btn2)); border:1px solid var(--btn-bd); border-radius:10px; padding:10px 18px; font-size:14px; }
.lbtn.danger{ background:transparent; color:#ff7a7a; border-color:#ff7a7a; }
/* ---- help tour (coachmarks) ---- */
#tour{ position:fixed; inset:0; z-index:200; display:none; }
#tour.open{ display:block; }
#tourHole{ position:absolute; border-radius:12px; box-shadow:0 0 0 9999px rgba(0,0,0,.66); border:2px solid var(--cyan); transition:all .2s ease; pointer-events:none; }
#tourBox{ position:absolute; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:12px; padding:14px; box-shadow:0 14px 44px rgba(0,0,0,.5); }
#tourBox h3{ margin:0 0 6px; font-size:15px; }
#tourBox p{ margin:0 0 12px; font-size:13px; color:var(--muted); line-height:1.45; }
#tourBox .trow{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
.tdots{ font-size:12px; color:var(--muted); }
</style> </style>
</head> </head>
<body> <body>
@ -160,6 +179,7 @@
</div> </div>
<div class="trow"> <div class="trow">
<div class="vol">🔈<input id="vol" type="range" min="0" max="100" value="85" />🔊</div> <div class="vol">🔈<input id="vol" type="range" min="0" max="100" value="85" />🔊</div>
<div class="icon" id="helpBtn" title="Help" aria-label="Help">?</div>
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme"></div> <div class="icon" id="themeBtn" title="Theme" aria-label="Theme"></div>
<div class="icon" id="fsBtn" title="Full screen" aria-label="Full screen"></div> <div class="icon" id="fsBtn" title="Full screen" aria-label="Full screen"></div>
</div> </div>
@ -167,19 +187,17 @@
<div id="mid"> <div id="mid">
<div id="stage"> <div id="stage">
<div id="beats"></div>
<div id="pulse"> <div id="pulse">
<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="rightcol">
<div id="detail"> <div id="detail">
<div class="chips" id="lanes"></div> <div id="lanes"></div>
<div class="chips" id="feats"></div> <div class="chips" id="feats"></div>
</div> </div>
<div id="transport"> <div id="transport">
@ -190,7 +208,7 @@
<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 / Stop"><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 (start/stop a track within a session)">⦿<small>PRACTICE</small></button> <button class="tbtn prac" id="bPrac" title="Practice (start/stop tracks 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>
</div> </div>
@ -200,16 +218,47 @@
<a id="sessbar" href="/mobile-sessions.html"><span class="dotrec"></span><span id="sessText">Practice sessions →</span></a> <a id="sessbar" href="/mobile-sessions.html"><span class="dotrec"></span><span id="sessText">Practice sessions →</span></a>
</div> </div>
<!-- lane editor sheet -->
<div id="scrim"></div>
<div id="laneSheet">
<div class="grab"></div>
<h2>Edit lane</h2>
<label for="lsSound">Sound</label><select id="lsSound"></select>
<label for="lsGroup">Grouping — beats per bar (e.g. 4, or 2+2+3)</label><input id="lsGroup" type="text" inputmode="text" autocomplete="off" />
<div class="lrow">
<label class="half">Subdivision<select id="lsSub"><option>1</option><option>2</option><option>3</option><option>4</option></select></label>
<label class="chk"><input type="checkbox" id="lsSwing" /> Swing</label>
</div>
<div class="lrow">
<label class="chk"><input type="checkbox" id="lsPoly" /> Polymeter</label>
<label class="chk"><input type="checkbox" id="lsMute" /> Mute lane</label>
</div>
<div class="lfoot"><button id="lsDel" class="lbtn danger">Delete lane</button><button id="lsDone" class="lbtn">Done</button></div>
</div>
<!-- guided help tour -->
<div id="tour">
<div id="tourHole"></div>
<div id="tourBox">
<h3 id="tourTitle"></h3><p id="tourText"></p>
<div class="trow"><span class="tdots" id="tourDots"></span>
<span><button id="tourSkip" class="lbtn" style="padding:8px 12px">Skip</button>
<button id="tourPrev" class="lbtn" style="padding:8px 12px">Back</button>
<button id="tourNext" class="lbtn" style="padding:8px 14px">Next</button></span></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_SESSIONS = "metronome.sessions", LS_SETLISTS = "metronome.setlists"; const LS_SESSIONS="metronome.sessions", LS_SETLISTS="metronome.setlists", LS_STATE="metronome.mobile.state", LS_TOURED="metronome.mobile.toured", LS_CURTRACK="metronome.curtrack";
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 esc(s){ return String(s).replace(/[&<>"]/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
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; 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"); } return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
/* ========================= ENGINE (synth voices only; shared scheduler) ======= */ /* ========================= ENGINE ============================================ */
const SAMPLES = {}; const SAMPLES = {};
/*@BUILD:include:src/engine.js@*/ /*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/ /*@BUILD:include:src/setlists.js@*/
@ -238,12 +287,11 @@ function scheduler(){
const ahead=audioCtx.currentTime+SCHEDULE_AHEAD; const ahead=audioCtx.currentTime+SCHEDULE_AHEAD;
advanceMaster(ahead); advanceMaster(ahead);
for(const m of meters){ while(m.nextTime<ahead){ scheduleMeterTick(m,m.nextTime); m.nextTime+=laneStepDur(m,m.tick); m.tick++; } } for(const m of meters){ while(m.nextTime<ahead){ scheduleMeterTick(m,m.nextTime); m.nextTime+=laneStepDur(m,m.tick); m.tick++; } }
if(pendingAdvance){ pendingAdvance=false; setTimeout(()=>gotoItem(idx+1,true),0); } // loop set list at end if(pendingAdvance){ pendingAdvance=false; setTimeout(()=>gotoItem(idx+1,true),0); }
} }
/* ========================= PLAYER ============================================= */ /* ========================= PLAYER ============================================= */
let setlist=null, idx=0; let setlist=null, idx=0, slKey="", transientTitle=null, savedLists=[];
let slKey="", transientTitle=null, savedLists=[];
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) })); const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
function currentName(){ return setlist ? (setlist.items[idx].name||"") : ""; } function currentName(){ return setlist ? (setlist.items[idx].name||"") : ""; }
@ -252,19 +300,19 @@ function buildMeters(lanes){
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(),orns:(c.orns||[]).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,_padEls:null,_lastPad:-1};
}); });
} }
function snapshotLanes(){ return meters.map(m=>({groupsStr:m.groupsStr,stepsPerBeat:m.stepsPerBeat,sound:m.sound,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice(),poly:m.poly,swing:m.swing,enabled:m.enabled,gainDb:m.gainDb})); }
function currentPatch(){ return setupToPatch({bpm:state.bpm,volume:state.volume,lanes:snapshotLanes(),trainer,ramp,bars:segBars,end:curEnd,rep:curRep}); }
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; curEnd=s.end; curRep=s.rep; 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); laneSig=null;
rebuildBeats();
} }
/* iOS: ignore the ring/silent hardware switch + warm up the context inside the play gesture. */
function unlockAudio(){ 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){}
@ -279,39 +327,27 @@ function startAudio(){
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
requestWake(); requestWake();
} }
function stopMetronome(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; } 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 stopAudio(){ stopMetronome(); releaseWake(); renderAll(); }
function startRun(){ startAudio(); renderAll(); } // plain play (no session) function startRun(){ startAudio(); renderAll(); }
/* ---- sessions: Practice records track segments under one continuous session clock ---- */ /* sessions */
let sessionActive=false, session=null, trackSegStart=null, lastSaved=false; let sessionActive=false, session=null, trackSegStart=null, lastSaved=false;
function startTrack(){ function startTrack(){
if(!sessionActive){ sessionActive=true; session={at:Date.now(),segments:[]}; lastSaved=false; } if(!sessionActive){ sessionActive=true; session={at:Date.now(),segments:[]}; lastSaved=false; }
startAudio(); startAudio(); trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; renderAll(); renderSessionBar();
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 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}); trackSegStart=null; }
function pauseTrack(){ recordSegment(); stopMetronome(); releaseWake(); renderAll(); renderSessionBar(); } function pauseTrack(){ recordSegment(); stopMetronome(); releaseWake(); renderAll(); renderSessionBar(); }
function endSession(){ function endSession(){
if(state.running){ recordSegment(); stopMetronome(); releaseWake(); } if(state.running){ recordSegment(); stopMetronome(); releaseWake(); }
if(session){ if(session){ const endedAt=Date.now(), clockSec=(endedAt-session.at)/1000;
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; } }
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(); session=null; sessionActive=false; trackSegStart=null; renderAll(); renderSessionBar();
} }
function play(){ if(sessionActive) endSession(); else if(state.running) stopAudio(); else startRun(); } 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 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;
@ -335,6 +371,59 @@ 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(); }
/* ========================= EDITABLE LANES ==================================== */
let laneSig=null, editLaneIdx=0;
function laneSignature(){ return meters.map(m=>m.sound+":"+m.groupsStr+"/"+m.stepsPerBeat+(m.swing?"s":"")+(m.enabled?"":"!")+(m.poly?"~":"")).join("|"); }
function lvlClass(l){ return l===2?"acc":l===3?"ghost":l===1?"on":""; }
function buildLanes(){
const box=$("lanes"); box.innerHTML="";
meters.forEach((m,i)=>{
const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off");
const meta=document.createElement("button"); meta.className="lmeta";
meta.innerHTML=esc(m.sound)+" <span class='lg'>"+esc(m.groupsStr)+(m.stepsPerBeat>1?"/"+m.stepsPerBeat:"")+(m.poly?"~":"")+"</span>";
meta.onclick=()=>openLaneSheet(i);
const pads=document.createElement("div"); pads.className="pads";
const steps=m.beatsPerBar*m.stepsPerBeat; m._padEls=[]; m._lastPad=-1;
for(let k=0;k<steps;k++){ const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); pads.appendChild(p); m._padEls.push(p); }
lane.appendChild(meta); lane.appendChild(pads); box.appendChild(lane);
});
const add=document.createElement("button"); add.className="addlane"; add.textContent="+ Add lane"; add.onclick=addLane; box.appendChild(add);
renderPadLevels();
}
function renderPadLevels(){ meters.forEach(m=>{ if(!m._padEls) return; const spb=m.stepsPerBeat;
m._padEls.forEach((p,k)=>{ const gs=(k%spb===0)&&m.groupStarts.has(k/spb); const lvl=m.beatsOn[k]|0; p.className="pad"+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); }); }); }
function cyclePad(m,k,p){ m.beatsOn[k]=((m.beatsOn[k]|0)+1)%4; if(m.orns) m.orns[k]=0; const spb=m.stepsPerBeat; const gs=(k%spb===0)&&m.groupStarts.has(k/spb); const lvl=m.beatsOn[k];
p.className="pad"+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); saveState(); }
function renderPadPlayheads(){ meters.forEach(m=>{ if(!m._padEls) return; const cur=state.running?m.currentStep:-1;
if(m._lastPad!==cur){ if(m._lastPad>=0&&m._padEls[m._lastPad]) m._padEls[m._lastPad].classList.remove("cur"); if(cur>=0&&m._padEls[cur]) m._padEls[cur].classList.add("cur"); m._lastPad=cur; } }); }
function rebuildLane(i,cfg){
const p=parseGroups(cfg.groupsStr), spb=Math.max(1,cfg.stepsPerBeat||1), steps=p.beatsPerBar*spb;
let on=cfg.beatsOn, orns=cfg.orns||[];
if(!on||on.length!==steps){ on=Array.from({length:steps},(_,k)=>((k%spb)===0&&p.groupStarts.has(k/spb))?2:1); orns=on.map(()=>0); }
const old=meters[i]||{};
meters[i]=Object.assign(old,{groupsStr:cfg.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
stepsPerBeat:spb,sound:cfg.sound,beatsOn:on,orns:orns,poly:!!cfg.poly,swing:!!cfg.swing,enabled:cfg.enabled!==false,gainDb:cfg.gainDb||0,
currentStep:-1,_padEls:null,_lastPad:-1});
}
function addLane(){ meters.push(buildMeters([{groupsStr:"4",stepsPerBeat:1,sound:"beep",beatsOn:[2,1,1,1],orns:[0,0,0,0],poly:false,swing:false,enabled:true,gainDb:0}])[0]); laneSig=null; renderAll(); saveState(); }
/* lane settings sheet */
(function(){ const sel=$("lsSound"); VOICES.forEach(([k,lab])=>{ const o=document.createElement("option"); o.value=k; o.textContent=lab; sel.appendChild(o); }); })();
function openLaneSheet(i){ editLaneIdx=i; const m=meters[i]; if(!m) return;
$("lsSound").value=m.sound; $("lsGroup").value=m.groupsStr; $("lsSub").value=String(m.stepsPerBeat); $("lsSwing").checked=!!m.swing; $("lsPoly").checked=!!m.poly; $("lsMute").checked=!m.enabled;
$("scrim").classList.add("open"); $("laneSheet").classList.add("open"); }
function closeLaneSheet(){ $("scrim").classList.remove("open"); $("laneSheet").classList.remove("open"); }
function applyLane(){ const m=meters[editLaneIdx]; if(!m) return;
let grp=($("lsGroup").value||"").trim()||"4";
rebuildLane(editLaneIdx,{groupsStr:grp,stepsPerBeat:Math.max(1,Math.min(4,parseInt($("lsSub").value,10)||1)),swing:$("lsSwing").checked,
poly:$("lsPoly").checked,enabled:!$("lsMute").checked,sound:$("lsSound").value,gainDb:m.gainDb||0,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice()});
laneSig=null; renderAll(); saveState(); }
["lsSound","lsSub","lsSwing","lsPoly","lsMute"].forEach(id=>$(id).addEventListener("change",applyLane));
$("lsGroup").addEventListener("change",applyLane);
$("lsDone").onclick=closeLaneSheet;
$("lsDel").onclick=()=>{ if(meters.length<=1){ closeLaneSheet(); return; } meters.splice(editLaneIdx,1); laneSig=null; renderAll(); saveState(); closeLaneSheet(); };
$("scrim").onclick=closeLaneSheet;
/* ========================= 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; }
@ -346,75 +435,44 @@ function buildSetlistOptions(){
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;
} }
function buildTrackOptions(){ function buildTrackOptions(){ const sel=$("trkSel"); sel.innerHTML="";
const sel=$("trkSel"); sel.innerHTML=""; if(setlist) setlist.items.forEach((it,i)=>sel.appendChild(opt(String(i),(i+1)+". "+(it.name||("Track "+(i+1)))))); sel.value=String(idx); }
if(setlist) setlist.items.forEach((it,i)=>sel.appendChild(opt(String(i),(i+1)+". "+(it.name||("Track "+(i+1))))));
sel.value=String(idx);
}
$("slSel").onchange=(e)=>{ const v=e.target.value; let sl=null; $("slSel").onchange=(e)=>{ const v=e.target.value; let sl=null;
if(v[0]==="b") sl=BUILTIN[+v.slice(1)]; if(v[0]==="b") sl=BUILTIN[+v.slice(1)]; else if(v[0]==="s"){ const s=savedLists[+v.slice(1)]; if(s) sl={title:s.title,items:(s.items||[]).map(it=>({...it}))}; }
else if(v[0]==="s"){ const s=savedLists[+v.slice(1)]; if(s) sl={title:s.title,items:(s.items||[]).map(it=>({...it}))}; }
if(sl){ slKey=v; transientTitle=null; loadSetlistObj(sl); } }; if(sl){ slKey=v; transientTitle=null; loadSetlistObj(sl); } };
$("trkSel").onchange=(e)=>{ gotoItem(+e.target.value, state.running); }; $("trkSel").onchange=(e)=>{ gotoItem(+e.target.value, state.running); };
/* ========================= RENDER ============================================ */ /* ========================= RENDER ============================================ */
function rebuildBeats(){
const box=$("beats"); box.innerHTML="";
const m=meters[0]; const beats=m?m.beatsPerBar:0;
for(let i=0;i<beats;i++){ const d=document.createElement("div"); d.className="dot"+(m.groupStarts.has(i)?" group":""); box.appendChild(d); }
}
function renderBeats(){
const m=meters[0]; if(!m) return;
const cur=state.running? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
const els=$("beats").children;
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");
}
let lastCur=null; let lastCur=null;
function renderInfo(){ function renderInfo(){
if(!editingBpm) $("bpm").textContent=state.bpm; if(!editingBpm) $("bpm").textContent=state.bpm;
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 nm=currentName(); if(nm!==lastCur){ lastCur=nm; lsSet("metronome.curtrack",nm); } // sessions page reads this const nm=currentName(); if(nm!==lastCur){ lastCur=nm; lsSet(LS_CURTRACK,nm); }
buildDetail(); updateStatus(); const sig=laneSignature(); if(sig!==laneSig){ laneSig=sig; buildLanes(); } else renderPadLevels();
buildFeats(); updateStatus();
} }
function updateStatus(){ function endLabel(){ if(curEnd==null) return "loop"; if(curEnd==="stop") return "stop"; if(curEnd===1) return "next";
const m=meters[0]; let s=""; if(typeof curEnd==="number"&&curEnd>0) return "+"+curEnd; return String(curEnd); }
function buildFeats(){ 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 updateStatus(){ const m=meters[0]; let s="";
if(m){ s=m.beatsPerBar+" beats"+(m.groups.length>1?" · "+m.groups.join("+"):"")+(m.swing?" · swing":""); } if(m){ s=m.beatsPerBar+" beats"+(m.groups.length>1?" · "+m.groups.join("+"):"")+(m.swing?" · swing":""); }
if(state.running&&m){ if(state.running&&m){ s+=" · bar "+((m.currentBar|0)+1)+(segBars>0?(" / "+segBars):"")+" · "+fmt((Date.now()-runStartAt)/1000); }
s+=" · bar "+((m.currentBar|0)+1)+(segBars>0?(" / "+segBars):"")+" · "+fmt((Date.now()-runStartAt)/1000); else if(segBars>0){ s+=" · "+segBars+" bars"; }
} else if(segBars>0){ s+=" · "+segBars+" bars"; } $("meterline").textContent=s; }
$("meterline").textContent=s;
}
function renderTransport(){ function renderTransport(){
const onAny=sessionActive||state.running; const onAny=sessionActive||state.running;
$("bPlay").innerHTML=(onAny?"■<small>STOP</small>":"▶<small>PLAY</small>"); $("bPlay").innerHTML=(onAny?"■<small>STOP</small>":"▶<small>PLAY</small>"); $("bPlay").classList.toggle("on",onAny);
$("bPlay").classList.toggle("on",onAny);
const pr=state.running&&sessionActive; const pr=state.running&&sessionActive;
$("bPrac").innerHTML=(pr?"❚❚<small>PAUSE</small>":"⦿<small>PRACTICE</small>"); $("bPrac").innerHTML=(pr?"❚❚<small>PAUSE</small>":"⦿<small>PRACTICE</small>"); $("bPrac").classList.toggle("on",pr); }
$("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);
function renderSessionBar(){ $("sessText").textContent="Recording · session "+fmt((Date.now()-session.at)/1000)+" · "+segs+" track"+(segs===1?"":"s"); }
const bar=$("sessbar"), n=lsGet(LS_SESSIONS,[]).length; else { bar.classList.remove("rec"); $("sessText").textContent=(lastSaved?"✓ Session saved · ":"")+"Practice sessions"+(n?(" ("+n+")"):"")+" →"; } }
if(sessionActive){ bar.classList.add("rec"); function renderAll(){ renderInfo(); renderTransport(); saveState(); }
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(); }
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");
@ -423,12 +481,10 @@ let lastTimeUpd=0;
function draw(){ function draw(){
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(); renderPadPlayheads();
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(key!==lastBeatKey){ lastBeatKey=key; pulseHit(m.groupStarts.has(beat)); } } }
if(m.currentStep%m.stepsPerBeat===0){ const key=m.currentBar*1000+beat; if(key!==lastBeatKey){ lastBeatKey=key; pulseHit(m.groupStarts.has(beat)); } }
}
const t=performance.now(); const t=performance.now();
if(t-lastTimeUpd>200){ lastTimeUpd=t; if(state.running) updateStatus(); if(sessionActive) renderSessionBar(); } if(t-lastTimeUpd>200){ lastTimeUpd=t; if(state.running) updateStatus(); if(sessionActive) renderSessionBar(); }
requestAnimationFrame(draw); requestAnimationFrame(draw);
@ -442,42 +498,87 @@ $("bpmIn").addEventListener("keydown",(e)=>{ if(e.key==="Enter"){ closeBpmEdit(t
$("bpmIn").addEventListener("blur",()=>{ if(editingBpm) closeBpmEdit(true); }); $("bpmIn").addEventListener("blur",()=>{ if(editingBpm) closeBpmEdit(true); });
(function(){ const p=$("pulse"); let dragging=false, moved=false, lpFired=false, startY=0, startBpm=120, lpTimer=null; (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; lpFired=false; startY=e.clientY; startBpm=state.bpm; p.addEventListener("pointerdown",(e)=>{ if(editingBpm) return; dragging=true; moved=false; lpFired=false; startY=e.clientY; startBpm=state.bpm;
p.setPointerCapture(e.pointerId); lpTimer=setTimeout(()=>{ lpFired=true; openBpmEdit(); },450); }); // hold → type p.setPointerCapture(e.pointerId); lpTimer=setTimeout(()=>{ lpFired=true; openBpmEdit(); },450); });
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("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("pointerup",(e)=>{ if(!dragging) return; dragging=false; clearTimeout(lpTimer); try{p.releasePointerCapture(e.pointerId);}catch(_){} p.addEventListener("pointerup",(e)=>{ if(!dragging) return; dragging=false; clearTimeout(lpTimer); try{p.releasePointerCapture(e.pointerId);}catch(_){} if(!moved && !lpFired) tapTempo(); });
if(!moved && !lpFired) tapTempo(); }); // clean tap → tap tempo
p.addEventListener("pointercancel",()=>{ dragging=false; clearTimeout(lpTimer); }); p.addEventListener("pointercancel",()=>{ dragging=false; clearTimeout(lpTimer); });
})(); })();
/* ========================= PERSIST / RESTORE STATE =========================== */
let saveTimer=null;
function saveState(){ clearTimeout(saveTimer); saveTimer=setTimeout(()=>{ try{
lsSet(LS_STATE,{slKey,transientTitle,idx,name:currentName(),patch:currentPatch(),volume:state.volume}); }catch(e){} },350); }
function restoreState(){
const st=lsGet(LS_STATE,null); if(!st||!st.patch) return false;
try{
if(st.volume!=null) state.volume=st.volume;
savedLists=lsGet(LS_SETLISTS,[]); let sl=null, key=st.slKey;
if(key&&key[0]==="b") sl=BUILTIN[+key.slice(1)];
else if(key&&key[0]==="s"){ const s=savedLists[+key.slice(1)]; if(s) sl={title:s.title,items:(s.items||[]).map(it=>({...it}))}; }
if(sl){ slKey=key; transientTitle=null; setlist=sl; idx=Math.max(0,Math.min(st.idx||0, sl.items.length-1)); }
else { slKey=""; transientTitle=st.transientTitle||st.name||"Restored"; setlist={title:transientTitle,items:[{name:st.name||"Track",...patchToSetup(st.patch)}]}; idx=0; }
loadSetup(patchToSetup(st.patch)); // active setup carries the user's edits + tempo
return true;
}catch(e){ return false; }
}
/* ========================= HASH SHARE-LINK LOADING =========================== */ /* ========================= HASH SHARE-LINK LOADING =========================== */
function loadFromHash(text){ function loadFromHash(text){
let payload=text, kind=null; const m=text.match(/[#?&](p|sl)=([^&\s]+)/); let payload=text, kind=null; const m=text.match(/[#?&](p|sl)=([^&\s]+)/);
if(m){ kind=m[1]; payload=m[2]; } try{ payload=decodeURIComponent(payload); }catch(e){} if(m){ kind=m[1]; payload=m[2]; } try{ payload=decodeURIComponent(payload); }catch(e){}
try{ try{
if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){ if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){ const sl=codeToSetlist(payload); if(!sl.items.length) throw 0;
const sl=codeToSetlist(payload); if(!sl.items.length) throw 0; slKey=""; transientTitle=sl.title||"Shared set list"; loadSetlistObj(sl); return true; }
slKey=""; transientTitle=sl.title||"Shared set list"; loadSetlistObj(sl); return true;
}
const setup=patchToSetup(payload); if(!setup.lanes.length) throw 0; const setup=patchToSetup(payload); if(!setup.lanes.length) throw 0;
slKey=""; transientTitle="Shared patch"; loadSetlistObj({title:"Shared patch",items:[{name:"Patch",...setup}]}); return true; slKey=""; transientTitle="Shared patch"; loadSetlistObj({title:"Shared patch",items:[{name:"Patch",...setup}]}); return true;
}catch(e){ return false; } }catch(e){ return false; }
} }
/* ========================= HELP TOUR ========================================= */
const TOUR=[
{sel:".sels", title:"Pick what to play", text:"Choose a set list and the track within it. Tracks are your practice items — name them for whatever you're working on, even if two share the same beat."},
{sel:"#pulse", title:"Tempo", text:"Tap the BPM to tap-tempo, press-and-hold to type an exact value, or drag up/down to scrub."},
{sel:"#bDn10", title:"Nudge tempo", text:"10 / +10 for big jumps, / + for fine adjustments."},
{sel:"#lanes", title:"Edit the beat", text:"Each lane is a row of pads that blink on the beat — tap a pad to cycle rest → beat → accent → ghost. Tap a lane's label to change its sound, grouping, subdivision, mute or polymeter. “+ Add lane” for more."},
{sel:"#bPlay", title:"Play", text:"Runs the metronome without recording anything."},
{sel:"#bPrac", title:"Practice = a timed session", text:"Practice starts a session clock and records what you play; Play turns into Stop. Use Practice to start/stop individual tracks, then hit Stop when you're done to save the whole session."},
{sel:"#sessbar", title:"Your practice log", text:"Review past sessions, add notes, and compare a track across days."},
];
let tstep=0;
function startTour(){ tstep=0; $("tour").classList.add("open"); showTour(); }
function endTour(){ $("tour").classList.remove("open"); lsSet(LS_TOURED,1); }
function showTour(){
while(tstep<TOUR.length && !document.querySelector(TOUR[tstep].sel)) tstep++;
if(tstep>=TOUR.length){ endTour(); return; }
const s=TOUR[tstep], el=document.querySelector(s.sel), r=el.getBoundingClientRect(), pad=6, hole=$("tourHole");
hole.style.left=(r.left-pad)+"px"; hole.style.top=(r.top-pad)+"px"; hole.style.width=(r.width+2*pad)+"px"; hole.style.height=(r.height+2*pad)+"px";
$("tourTitle").textContent=s.title; $("tourText").textContent=s.text; $("tourDots").textContent=(tstep+1)+" / "+TOUR.length;
$("tourPrev").style.visibility=tstep?"visible":"hidden"; $("tourNext").textContent=(tstep===TOUR.length-1)?"Done":"Next";
const box=$("tourBox"), bw=Math.min(290, innerWidth-24); box.style.width=bw+"px"; box.style.left="0px"; box.style.top="-9999px";
const bh=box.offsetHeight;
const left=Math.max(12, Math.min(r.left, innerWidth-bw-12));
const top=(r.bottom+12+bh < innerHeight) ? r.bottom+12 : Math.max(12, r.top-12-bh);
box.style.left=left+"px"; box.style.top=top+"px";
}
$("tourNext").onclick=()=>{ if(tstep>=TOUR.length-1) endTour(); else { tstep++; showTour(); } };
$("tourPrev").onclick=()=>{ if(tstep>0){ tstep--; showTour(); } };
$("tourSkip").onclick=endTour;
$("helpBtn").onclick=startTour;
addEventListener("resize",()=>{ if($("tour").classList.contains("open")) showTour(); });
/* ========================= WIRING ============================================ */ /* ========================= WIRING ============================================ */
$("bPlay").onclick=play; $("bPrac").onclick=practice; $("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);
$("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; saveState(); };
$("sessbar").addEventListener("click",(e)=>{ if(sessionActive) e.preventDefault(); }); // don't lose an in-progress session $("sessbar").addEventListener("click",(e)=>{ if(sessionActive) e.preventDefault(); });
/* theme toggle (shared "metronome.theme") + version stamp */
/*@BUILD:include:src/chrome.js@*/ /*@BUILD:include:src/chrome.js@*/
/* ========================= FULLSCREEN + WAKE LOCK ============================ */ /* ========================= FULLSCREEN + WAKE LOCK ============================ */
const docEl=document.documentElement; const docEl=document.documentElement;
const reqFS=docEl.requestFullscreen||docEl.webkitRequestFullscreen; const reqFS=docEl.requestFullscreen||docEl.webkitRequestFullscreen, exitFS=document.exitFullscreen||document.webkitExitFullscreen;
const exitFS=document.exitFullscreen||document.webkitExitFullscreen;
const fsEl=()=>document.fullscreenElement||document.webkitFullscreenElement; const fsEl=()=>document.fullscreenElement||document.webkitFullscreenElement;
let wakeLock=null; let wakeLock=null;
async function requestWake(){ try{ if(navigator.wakeLock) wakeLock=await navigator.wakeLock.request("screen"); }catch(e){} } async function requestWake(){ try{ if(navigator.wakeLock) wakeLock=await navigator.wakeLock.request("screen"); }catch(e){} }
@ -487,17 +588,14 @@ $("fsBtn").onclick=toggleFS;
if(window.EMBED) $("fsBtn").style.display="none"; if(window.EMBED) $("fsBtn").style.display="none";
document.addEventListener("visibilitychange",()=>{ if(document.visibilityState==="visible"&&state.running) requestWake(); }); document.addEventListener("visibilitychange",()=>{ if(document.visibilityState==="visible"&&state.running) requestWake(); });
/* ========================= PWA: install + service worker ===================== */ /* PWA */
let deferredPrompt=null; 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=""; } }); 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(); play(); } if(k===" "||e.code==="Space"){ e.preventDefault(); play(); }
else if(k==="p"||k==="P"){ e.preventDefault(); practice(); } else if(k==="p"||k==="P"){ e.preventDefault(); practice(); }
@ -510,11 +608,13 @@ addEventListener("keydown",(e)=>{
/* ========================= INIT ============================================== */ /* ========================= INIT ============================================== */
if(location.hash && /(p|sl)=/.test(location.hash)) loadFromHash(location.hash); if(location.hash && /(p|sl)=/.test(location.hash)) loadFromHash(location.hash);
else restoreState();
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); if(masterGain) masterGain.gain.value=state.volume;
renderAll(); renderSessionBar(); renderAll(); renderSessionBar();
requestAnimationFrame(draw); requestAnimationFrame(draw);
if(!window.EMBED && !lsGet(LS_TOURED,0)) setTimeout(()=>{ if(!$("tour").classList.contains("open")) startTour(); }, 700);
</script> </script>
</body> </body>
</html> </html>