metronome/mobile.html
Me Here 582118abf9 pm-mobile: per-lane gain + Save & Library (create/save-as/reorder tracks)
- Per-lane gain: a volume slider (-18..+6 dB) in the lane dialog → m.gainDb,
  encoded in the lane token (e.g. kick:4@-6) and saved with the track.
- Save & Library sheet (💾): writes to the SAME store/format as the editor
  (metronome.setlists), so tracks made on the phone show up in the editor too.
    * Save current track: name + target set list (or "+ New set list"),
      "Save as new track" (always) and "Update <name>" for your own tracks —
      Update confirms the overwrite (the iterate-and-resave path is one tap, but
      a named confirm prevents accidentally clobbering the original when you
      meant to save a new track).
    * Manage library: reorder (up/down), rename and delete your set lists and
      their tracks; built-ins stay read-only (Save-as copies edits out of them).
- Built-in/transient set lists can't be overwritten — saving promotes the live
  working copy into one of your own set lists.

Engine untouched; conformance suite unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:35:57 -05:00

801 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<title>VARASYS PolyMeter — Mobile</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="mobile-web-app-capable" content="yes" />
<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/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
<script>
window.EMBED = /[?&]embed=1/.test(location.search);
if (window.EMBED) document.documentElement.dataset.embed = "1";
</script>
<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;
--led-off:#1b2330; --ring:#2a3340; --glow:rgba(10,179,247,.55); --aglow:rgba(255,209,102,.55);
--btn1:#2b323d; --btn2:#1b212a; --btn-bd:#39424f; --chip-bg:#1b2230; --chip-bd:#2c3545;
}
: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;
--led-off:#c4cedb; --ring:#c9d4e1; --glow:rgba(10,179,247,.40); --aglow:rgba(230,160,30,.45);
--btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de; --chip-bg:#eef2f7; --chip-bd:#d3dbe5;
}
html,body{ height:100%; }
body{ margin:0; overflow:hidden; color:var(--txt);
background:radial-gradient(circle at 50% -12%, var(--bg1), var(--bg2));
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-user-select:none; user-select:none; -webkit-tap-highlight-color:transparent;
touch-action:manipulation; overscroll-behavior:none; }
#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))
max(8px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); }
/* ---- top ---- */
#top{ flex:0 0 auto; display:flex; flex-direction:column; gap:8px; }
.sels{ display:flex; gap:8px; }
.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 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; }
.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 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;
font-size:18px; line-height:1; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
.icon:active{ background:rgba(127,139,154,.30); }
/* ---- middle: pulse + lanes centered as one block, transport pinned below ---- */
#mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; gap:10px; }
#center{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:clamp(14px,3.5vmin,34px); }
#stage{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:clamp(8px,2vmin,18px); }
#pulse{ position:relative; width:clamp(186px,46vmin,360px); height:clamp(186px,46vmin,360px); border-radius:50%;
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%);
transition:transform .12s ease-out, box-shadow .12s ease-out, border-color .12s ease-out; touch-action:none; cursor:pointer; }
#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); }
#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; }
#bpmIn{ display:none; width:64%; text-align:center; font:inherit; font-size:clamp(42px,13vmin,120px); 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; }
#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; }
/* ---- editable lanes + track-settings button ---- */
#detail{ flex:0 1 auto; width:100%; max-width:560px; max-height:34vh; overflow-y:auto; display:flex; flex-direction:column; gap:8px; padding:2px 0; }
#lanes{ display:flex; flex-direction:column; gap:6px; }
#trackBtn{ align-self:center; background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--muted);
border-radius:9px; padding:7px 14px; font-size:clamp(11px,1.9vmin,13px); cursor:pointer; max-width:100%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
#trackBtn:active{ background:rgba(127,139,154,.22); }
.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; }
.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; }
.chip.feat.r{ border-color:var(--cyan); color:var(--cyan); } .chip.feat.g{ border-color:var(--amber); color:var(--amber); }
/* ---- transport ---- */
#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.6vmin,13px);
grid-template-columns:1fr 1.5fr 1.5fr 1fr; 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);
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); 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: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}
#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.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.on{ background:linear-gradient(180deg,#c8922a,#9c6f12); border-color:#e0a93a; color:#fff; }
/* landscape phones: pulse left, lanes right (centered), transport full-width below */
@media (orientation:landscape) and (max-height:600px){
#center{ flex-direction:row; align-items:center; justify-content:center; gap:4vw; }
#stage{ flex:0 1 44%; gap:clamp(6px,1.6vmin,12px); }
#detail{ flex:0 1 52%; max-width:560px; max-height:64vh; }
#pulse{ width:clamp(132px,40vmin,300px); height:clamp(132px,40vmin,300px); }
#bpmlab{ display:none; }
.tbtn{ height:clamp(40px,13vmin,58px); }
#transport .tgrid{ max-width:640px; }
}
/* ---- session bar ---- */
#sessbar{ flex:0 0 auto; display:flex; align-items:center; gap:8px; width:100%; margin-top:6px; 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; }
#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.rec .dotrec{ display:block; } #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, #trackSheet, #saveSheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:88vh; 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, #trackSheet.open, #saveSheet.open{ transform:none; }
#laneSheet .grab, #trackSheet .grab, #saveSheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
#laneSheet h2, #trackSheet h2, #saveSheet h2{ margin:0 0 10px; font-size:16px; }
#laneSheet label, #trackSheet label, #saveSheet label{ display:block; font-size:12px; color:var(--muted); margin:10px 0 5px; }
#laneSheet select, #trackSheet select, #saveSheet select,
#laneSheet input[type=text], #trackSheet input[type=text], #trackSheet input[type=number], #saveSheet 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; }
#laneSheet .lrow, #trackSheet .lrow, #saveSheet .lrow{ display:flex; gap:12px; margin-top:10px; flex-wrap:wrap; align-items:center; }
#laneSheet .half, #trackSheet .half{ display:block; flex:1 1 120px; margin:0; }
#laneSheet .chk, #trackSheet .chk{ display:flex; align-items:center; gap:8px; font-size:14px; color:var(--txt); margin-top:16px; }
#laneSheet .chk input, #trackSheet .chk input{ width:20px; height:20px; accent-color:var(--cyan); flex:0 0 auto; }
#trackSheet .lrow.off{ display:none; }
.lfoot{ display:flex; justify-content:space-between; align-items:center; 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 16px; font-size:14px; }
.lbtn.danger{ background:transparent; color:#ff7a7a; border-color:#ff7a7a; }
.seclbl{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin:4px 0 8px; }
.savemsg{ font-size:12px; color:#5fd08a; align-self:center; }
.liblbl{ font-size:12px; color:var(--muted); margin:14px 0 4px; }
.libhint{ font-size:12px; color:var(--muted); padding:6px 2px; line-height:1.4; }
.librow{ display:flex; align-items:center; gap:4px; padding:4px 0; border-bottom:1px solid var(--panel-bd); }
.librow.active .libname{ color:var(--cyan); font-weight:600; }
.libname{ flex:1 1 auto; min-width:0; text-align:left; background:transparent; border:none; color:var(--txt); font-size:14px; padding:7px 2px; cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ibtn{ flex:0 0 auto; width:32px; height:32px; border-radius:7px; background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); font-size:13px; cursor:pointer; }
.ibtn:disabled{ opacity:.3; }
/* ---- 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>
</head>
<body>
<div id="app">
<div id="top">
<div class="sels">
<label class="sel"><span>Set list</span><select id="slSel"></select></label>
<label class="sel"><span>Track</span><select id="trkSel"></select></label>
</div>
<div class="trow">
<div class="vol">🔈<input id="vol" type="range" min="0" max="100" value="85" />🔊</div>
<div class="icon" id="saveBtn" title="Save &amp; library" aria-label="Save and library">💾</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="fsBtn" title="Full screen" aria-label="Full screen"></div>
</div>
</div>
<div id="mid">
<div id="center">
<div id="stage">
<div id="pulse">
<div id="bpm">120</div>
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
<div id="bpmlab">BPM</div>
</div>
<div id="meterline"></div>
</div>
<div id="detail">
<div id="lanes"></div>
<button id="trackBtn" title="Track settings — bars, loop/end, ramp, gaps, share string"></button>
</div>
</div>
<div id="transport">
<div class="tgrid">
<button class="tbtn" id="bDn10" title="Tempo 10">10</button>
<button class="tbtn" id="bPrev" title="Previous 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="bDown" title="Tempo 1"></button>
<button class="tbtn play" id="bPlay" title="Play / Stop"><small>PLAY</small></button>
<button class="tbtn prac" id="bPrac" title="Practice — logs your time to the practice log">⦿<small>PRACTICE</small></button>
<button class="tbtn" id="bUp" title="Tempo +1">+</button>
</div>
</div>
</div>
<a id="sessbar" href="/mobile-sessions.html"><span class="dotrec"></span><span id="sessText">Practice sessions →</span></a>
</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>
<label for="lsGain">Lane volume <span id="lsGainVal" style="color:var(--txt)">0 dB</span></label>
<input id="lsGain" type="range" min="-18" max="6" step="1" style="width:100%;accent-color:var(--cyan)" />
<div class="lfoot"><button id="lsDel" class="lbtn danger">Delete lane</button><button id="lsDone" class="lbtn">Done</button></div>
</div>
<!-- track settings sheet -->
<div id="trackSheet">
<div class="grab"></div>
<h2>Track settings</h2>
<div class="lrow">
<label class="half">Bars per repeat (0 = unlimited)<input id="tsBars" type="number" inputmode="numeric" min="0" max="999" /></label>
<label class="half">At end<select id="tsEnd"><option value="loop">Loop</option><option value="stop">Stop</option><option value="next">Next track</option></select></label>
</div>
<label class="chk"><input type="checkbox" id="tsRamp" /> Tempo ramp (speed up while you play)</label>
<div class="lrow" id="tsRampRow">
<label class="half">Start BPM<input id="tsRampStart" type="number" inputmode="numeric" min="30" max="300" /></label>
<label class="half">+BPM<input id="tsRampAmt" type="number" inputmode="numeric" min="1" max="50" /></label>
<label class="half">every N bars<input id="tsRampEvery" type="number" inputmode="numeric" min="1" max="64" /></label>
</div>
<label class="chk"><input type="checkbox" id="tsGap" /> Practice gaps (mute bars to test your inner clock)</label>
<div class="lrow" id="tsGapRow">
<label class="half">Play bars<input id="tsGapPlay" type="number" inputmode="numeric" min="1" max="32" /></label>
<label class="half">Mute bars<input id="tsGapMute" type="number" inputmode="numeric" min="1" max="32" /></label>
</div>
<label for="tsStr">Track string (share / copy / paste)</label>
<textarea id="tsStr" spellcheck="false" style="width:100%;background:var(--field-bg);color:var(--txt);border:1px solid var(--field-bd);border-radius:10px;padding:10px;font-family:'Courier New',monospace;font-size:12px;resize:vertical;min-height:54px"></textarea>
<div class="lrow">
<button id="tsCopy" class="lbtn">Copy</button>
<button id="tsApply" class="lbtn">Apply pasted string</button>
<span id="tsMsg" style="font-size:12px;color:var(--muted);align-self:center"></span>
</div>
<div class="lfoot"><span></span><button id="tsDone" class="lbtn">Done</button></div>
</div>
<!-- save & library sheet -->
<div id="saveSheet">
<div class="grab"></div>
<h2>Save &amp; library</h2>
<div class="seclbl">Save current track</div>
<label for="saveName">Track name</label>
<input id="saveName" type="text" autocomplete="off" />
<label for="saveTo">Save to set list</label>
<select id="saveTo"></select>
<input id="saveNewName" type="text" autocomplete="off" placeholder="New set list name" style="display:none;margin-top:8px" />
<div class="lrow">
<button id="saveUpd" class="lbtn">Update</button>
<button id="saveNew" class="lbtn">Save as new track</button>
<span id="saveMsg" class="savemsg"></span>
</div>
<div class="seclbl" style="margin-top:20px">Manage library</div>
<div id="libBody"></div>
<div class="lfoot"><span></span><button id="saveDone" 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>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
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 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;
return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
/* ========================= ENGINE ============================================ */
const SAMPLES = {};
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const state={ bpm:120, volume:0.85, running:false };
let meters=[];
let ramp={on:false,startBpm:80,amount:5,everyBars:4}, trainer={on:false,playBars:2,muteBars:2};
let segBars=0, segBarCount=0, pendingAdvance=false, curEnd=null, curRep=null;
let masterBeat=0, masterBeatTime=0, muteWindows=[];
function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); }
function advanceMaster(ahead){
const mbpb=masterBeatsPerBar();
while(masterBeatTime<ahead){
if(masterBeat%mbpb===0){
const barIndex=Math.floor(masterBeat/mbpb);
if(barIndex>0&&ramp.on&&(barIndex%ramp.everyBars===0)) setBpm(state.bpm+ramp.amount);
if(trainer.on){ const cyc=trainer.playBars+trainer.muteBars; if(cyc>0&&(barIndex%cyc)>=trainer.playBars) muteWindows.push({start:masterBeatTime,end:masterBeatTime+mbpb*(60/state.bpm)}); }
segBarCount=barIndex;
if(segBars>0&&barIndex>=segBars&&!pendingAdvance&&curEnd!=null){ pendingAdvance=true; } // loop (null) keeps playing
}
masterBeat++; masterBeatTime+=60/state.bpm;
}
if(audioCtx) muteWindows=muteWindows.filter(w=>w.end>audioCtx.currentTime-1);
}
function scheduler(){
const ahead=audioCtx.currentTime+SCHEDULE_AHEAD;
advanceMaster(ahead);
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(handleEnd,0); }
}
function handleEnd(){
if(curEnd==="stop"){ if(sessionActive&&state.running) pauseTrack(); else stopAudio(); } // stop at end of the bar count
else gotoItem(idx+1,true); // "next" → advance the set list
}
/* ========================= PLAYER ============================================= */
let setlist=null, idx=0, slKey="", transientTitle=null, savedLists=[];
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 buildMeters(lanes){
return (lanes||[]).map(c=>{
const p=parseGroups(c.groupsStr);
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,
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){
ramp=s.ramp?{...s.ramp}:{on:false,startBpm:80,amount:5,everyBars:4};
trainer=s.trainer?{...s.trainer}:{on:false,playBars:2,muteBars:2};
segBars=s.bars||0; segBarCount=0; curEnd=s.end; curRep=s.rep;
setBpm(s.bpm||120);
meters=buildMeters(s.lanes); laneSig=null;
}
function unlockAudio(){
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){}
}
let runStartAt=0;
function startAudio(){
ensureAudio(); unlockAudio(); audioCtx.resume(); state.running=true; runStartAt=Date.now();
if(ramp.on) setBpm(ramp.startBpm);
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; }
masterBeat=0; masterBeatTime=t0; muteWindows=[]; pendingAdvance=false; lastBeatKey=-1;
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
requestWake();
}
function stopMetronome(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters){ m.currentStep=-1; } }
function stopAudio(){ stopMetronome(); releaseWake(); renderAll(); }
function startRun(){ startAudio(); renderAll(); }
/* sessions */
let sessionActive=false, session=null, trackSegStart=null, lastSaved=false;
function startTrack(){
if(!sessionActive){ sessionActive=true; session={at:Date.now(),segments:[]}; lastSaved=false; }
startAudio(); 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}); 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 gotoItem(i,keepPlaying){
if(!setlist||!setlist.items.length) return;
const n=setlist.items.length; idx=((i%n)+n)%n;
const wasRunning=state.running||keepPlaying;
if(state.running){ if(sessionActive) recordSegment(); stopMetronome(); }
loadSetup(setlist.items[idx]);
if(wasRunning){ startAudio(); if(sessionActive) trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; }
buildSetlistOptions(); buildTrackOptions(); renderAll(); renderSessionBar();
}
function loadSetlistObj(sl){
if(state.running&&sessionActive) recordSegment();
const wasRunning=state.running; if(wasRunning) stopMetronome();
setlist=sl; idx=0; loadSetup(sl.items[0]);
if(wasRunning){ startAudio(); if(sessionActive) trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; }
buildSetlistOptions(); buildTrackOptions(); renderAll(); renderSessionBar();
}
let taps=[];
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now);
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(); }
/* ========================= 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 gainLabel(db){ return (db>0?"+":"")+db+" dB"; }
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;
$("lsGain").value=m.gainDb||0; $("lsGainVal").textContent=gainLabel(m.gainDb||0);
$("trackSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); $("scrim").classList.add("open"); $("laneSheet").classList.add("open"); }
function closeSheets(){ $("scrim").classList.remove("open"); $("laneSheet").classList.remove("open"); $("trackSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); }
const closeLaneSheet=closeSheets;
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:parseInt($("lsGain").value,10)||0,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice()});
laneSig=null; renderAll(); saveState(); }
["lsSound","lsSub","lsSwing","lsPoly","lsMute"].forEach(id=>$(id).addEventListener("change",applyLane));
$("lsGain").addEventListener("input",()=>{ const m=meters[editLaneIdx]; if(!m) return; m.gainDb=parseInt($("lsGain").value,10)||0; $("lsGainVal").textContent=gainLabel(m.gainDb); saveState(); }); // live, no rebuild
$("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=closeSheets;
/* ---- track settings sheet: bars, end/loop, ramp, gaps, copy/paste string ---- */
function clampInt(v,lo,hi,def){ v=parseInt(v,10); if(isNaN(v)) return def; return Math.max(lo,Math.min(hi,v)); }
function openTrackSheet(){
$("tsBars").value=segBars||0;
$("tsEnd").value = curEnd==null?"loop":(curEnd==="stop"?"stop":"next");
$("tsRamp").checked=ramp.on; $("tsRampStart").value=ramp.startBpm||80; $("tsRampAmt").value=ramp.amount||5; $("tsRampEvery").value=ramp.everyBars||4;
$("tsGap").checked=trainer.on; $("tsGapPlay").value=trainer.playBars||2; $("tsGapMute").value=trainer.muteBars||2;
$("tsRampRow").classList.toggle("off",!ramp.on); $("tsGapRow").classList.toggle("off",!trainer.on);
$("tsStr").value=currentPatch(); $("tsMsg").textContent="";
$("laneSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); $("scrim").classList.add("open"); $("trackSheet").classList.add("open");
}
function applyTrackSettings(){
segBars=Math.max(0,parseInt($("tsBars").value,10)||0);
const e=$("tsEnd").value; curEnd = e==="loop"?null:(e==="stop"?"stop":1);
ramp.on=$("tsRamp").checked; ramp.startBpm=clampInt($("tsRampStart").value,30,300,80); ramp.amount=clampInt($("tsRampAmt").value,1,50,5); ramp.everyBars=clampInt($("tsRampEvery").value,1,64,4);
trainer.on=$("tsGap").checked; trainer.playBars=clampInt($("tsGapPlay").value,1,32,2); trainer.muteBars=clampInt($("tsGapMute").value,1,32,2);
$("tsRampRow").classList.toggle("off",!ramp.on); $("tsGapRow").classList.toggle("off",!trainer.on);
$("tsStr").value=currentPatch(); renderAll(); saveState();
}
["tsBars","tsEnd","tsRamp","tsRampStart","tsRampAmt","tsRampEvery","tsGap","tsGapPlay","tsGapMute"].forEach(id=>$(id).addEventListener("change",applyTrackSettings));
$("tsCopy").onclick=()=>{ const s=currentPatch(); const ok=()=>{ $("tsMsg").textContent="Copied ✓"; setTimeout(()=>$("tsMsg").textContent="",1600); };
if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(s).then(ok,()=>{ $("tsStr").select(); }); } else { $("tsStr").select(); try{ document.execCommand("copy"); ok(); }catch(e){} } };
$("tsApply").onclick=()=>{ const txt=($("tsStr").value||"").trim().replace(/^[#?&]*p=/,"");
try{ const setup=patchToSetup(txt); if(!setup.lanes.length) throw 0; loadSetup(setup); laneSig=null; renderAll(); saveState();
$("tsStr").value=currentPatch(); $("tsMsg").textContent="Applied ✓"; setTimeout(()=>$("tsMsg").textContent="",1600); }
catch(e){ $("tsMsg").textContent="✗ not a valid track string"; } };
$("tsDone").onclick=closeSheets;
$("trackBtn").onclick=openTrackSheet;
/* ---- save & library: user set lists/tracks (same store + format as the editor) ---- */
function userSetlists(){ return lsGet(LS_SETLISTS,[]); }
function saveUserSetlists(a){ lsSet(LS_SETLISTS,a); savedLists=a; }
function curSetupObj(){ return { bpm:state.bpm, lanes:snapshotLanes(), trainer:{...trainer}, ramp:{...ramp}, countMs:0, bars:segBars, rep:curRep, end:curEnd }; }
function flashSave(msg){ $("saveMsg").textContent=msg; setTimeout(()=>{ if($("saveMsg").textContent===msg) $("saveMsg").textContent=""; },1800); }
function el(tag,cls,txt){ const e=document.createElement(tag); if(cls) e.className=cls; if(txt!=null) e.textContent=txt; return e; }
function ibtn(label,fn,dis){ const b=el("button","ibtn",label); b.disabled=!!dis; b.onclick=(e)=>{ e.stopPropagation(); fn(); }; return b; }
function selectUserList(i){ const arr=userSetlists(); if(!arr[i]) return; slKey="s"+i; transientTitle=null;
loadSetlistObj({title:arr[i].title,items:(arr[i].items||[]).map(it=>({...it}))}); renderLibrary(); }
function selectUserTrack(i,j){ slKey="s"+i; transientTitle=null; savedLists=userSetlists();
setlist={title:savedLists[i].title,items:savedLists[i].items.map(it=>({...it}))}; idx=Math.max(0,Math.min(j,setlist.items.length-1));
loadSetup(setlist.items[idx]); buildSetlistOptions(); buildTrackOptions(); renderAll(); saveState(); }
function doSaveAsNew(){
const name=($("saveName").value||"My track").trim(); const arr=userSetlists(); let i;
if($("saveTo").value==="__new"){ const t=($("saveNewName").value||"My set list").trim(); arr.push({title:t,description:"",items:[]}); i=arr.length-1; }
else { i=+$("saveTo").value.slice(1); if(!arr[i]) return; }
arr[i].items.push({name, ...curSetupObj()}); saveUserSetlists(arr);
selectUserTrack(i, arr[i].items.length-1); $("saveNewName").value=""; buildSaveTo(); renderLibrary(); flashSave("Saved ✓");
}
function doUpdate(){
if(slKey[0]!=="s") return; const arr=userSetlists(), i=+slKey.slice(1); if(!arr[i]||!arr[i].items[idx]) return;
const oldName=arr[i].items[idx].name||"this track"; const nm=($("saveName").value||oldName).trim();
if(!confirm('Overwrite "'+oldName+'" with the current settings?')) return;
arr[i].items[idx]={name:nm, ...curSetupObj()}; saveUserSetlists(arr);
setlist.items[idx]={name:nm, ...curSetupObj()}; lastCur=null; buildTrackOptions(); renderInfo(); renderLibrary(); flashSave("Updated ✓");
}
function moveList(i,dir){ const arr=userSetlists(), j=i+dir; if(j<0||j>=arr.length) return; const t=arr[i]; arr[i]=arr[j]; arr[j]=t; saveUserSetlists(arr);
if(slKey==="s"+i) slKey="s"+j; else if(slKey==="s"+j) slKey="s"+i; buildSetlistOptions(); renderLibrary(); saveState(); }
function moveTrack(i,j,dir){ const arr=userSetlists(), sl=arr[i], k=j+dir; if(!sl||k<0||k>=sl.items.length) return; const t=sl.items[j]; sl.items[j]=sl.items[k]; sl.items[k]=t; saveUserSetlists(arr);
if(slKey==="s"+i){ if(idx===j) idx=k; else if(idx===k) idx=j; setlist.items=sl.items.map(it=>({...it})); buildTrackOptions(); renderInfo(); } renderLibrary(); saveState(); }
function renameList(i){ const arr=userSetlists(); if(!arr[i]) return; const n=prompt("Rename set list:",arr[i].title||""); if(n==null) return; arr[i].title=n.trim()||arr[i].title; saveUserSetlists(arr);
if(slKey==="s"+i&&setlist) setlist.title=arr[i].title; buildSetlistOptions(); renderLibrary(); saveState(); }
function renameTrack(i,j){ const arr=userSetlists(); if(!arr[i]||!arr[i].items[j]) return; const n=prompt("Rename track:",arr[i].items[j].name||""); if(n==null) return; arr[i].items[j].name=n.trim()||arr[i].items[j].name; saveUserSetlists(arr);
if(slKey==="s"+i){ setlist.items[j].name=arr[i].items[j].name; if(idx===j){ lastCur=null; } buildTrackOptions(); renderInfo(); } renderLibrary(); saveState(); }
function deleteTrack(i,j){ const arr=userSetlists(), sl=arr[i]; if(!sl||!sl.items[j]) return; if(!confirm('Delete track "'+(sl.items[j].name||"")+'"?')) return; sl.items.splice(j,1); saveUserSetlists(arr);
if(slKey==="s"+i){ if(!sl.items.length){ deleteListResolved(i); return; } if(idx>=sl.items.length) idx=sl.items.length-1; else if(idx>j) idx--; selectUserTrack(i,idx); } renderLibrary(); }
function deleteList(i){ const arr=userSetlists(); if(!arr[i]) return; if(!confirm('Delete set list "'+(arr[i].title||"")+'" and all its tracks?')) return; deleteListResolved(i); }
function deleteListResolved(i){ const arr=userSetlists(); arr.splice(i,1); saveUserSetlists(arr);
if(slKey==="s"+i){ slKey="b0"; setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); buildSetlistOptions(); buildTrackOptions(); renderAll(); saveState(); }
else if(slKey[0]==="s"){ const k=+slKey.slice(1); if(k>i) slKey="s"+(k-1); buildSetlistOptions(); }
buildSaveTo(); renderLibrary(); }
function newList(){ const n=prompt("New set list name:","My set list"); if(n==null) return; const arr=userSetlists(); arr.push({title:n.trim()||"My set list",description:"",items:[]}); saveUserSetlists(arr); buildSaveTo(); renderLibrary(); }
function buildSaveTo(){ savedLists=userSetlists(); const sel=$("saveTo"); sel.innerHTML="";
savedLists.forEach((sl,i)=>sel.appendChild(opt("s"+i,(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")")));
sel.appendChild(opt("__new"," New set list…"));
sel.value = slKey[0]==="s" ? slKey : (savedLists.length?"s0":"__new");
$("saveNewName").style.display = sel.value==="__new" ? "block":"none"; }
$("saveTo").onchange=()=>{ $("saveNewName").style.display = $("saveTo").value==="__new" ? "block":"none"; };
function renderLibrary(){ savedLists=userSetlists(); const box=$("libBody"); box.innerHTML="";
box.appendChild(el("div","liblbl","Your set lists"));
if(!savedLists.length) box.appendChild(el("div","libhint","None yet — “Save as new track” creates one."));
savedLists.forEach((sl,i)=>{ const row=el("div","librow"+(slKey==="s"+i?" active":""));
const nm=el("button","libname",(sl.title||"set list")+" ("+(sl.items?sl.items.length:0)+")"); nm.onclick=()=>selectUserList(i); row.appendChild(nm);
row.appendChild(ibtn("↑",()=>moveList(i,-1),i===0)); row.appendChild(ibtn("↓",()=>moveList(i,1),i===savedLists.length-1));
row.appendChild(ibtn("✎",()=>renameList(i))); row.appendChild(ibtn("🗑",()=>deleteList(i))); box.appendChild(row); });
const addL=el("button","addlane"," New set list"); addL.onclick=newList; box.appendChild(addL);
if(slKey[0]==="s"){ const i=+slKey.slice(1), sl=savedLists[i]; if(sl){
box.appendChild(el("div","liblbl","Tracks in “"+(sl.title||"set list")+"”"));
sl.items.forEach((it,j)=>{ const row=el("div","librow"+(idx===j?" active":""));
const nm=el("button","libname",(j+1)+". "+(it.name||"track")); nm.onclick=()=>{ gotoItem(j,state.running); renderLibrary(); }; row.appendChild(nm);
row.appendChild(ibtn("↑",()=>moveTrack(i,j,-1),j===0)); row.appendChild(ibtn("↓",()=>moveTrack(i,j,1),j===sl.items.length-1));
row.appendChild(ibtn("✎",()=>renameTrack(i,j))); row.appendChild(ibtn("🗑",()=>deleteTrack(i,j))); box.appendChild(row); });
const addT=el("button","addlane"," Add current track here"); addT.onclick=()=>{ $("saveTo").value="s"+i; doSaveAsNew(); }; box.appendChild(addT);
}} else { box.appendChild(el("div","libhint","This set list is built-in (read-only). “Save as new track” copies your edits into one of your own set lists.")); }
}
function openSaveSheet(){
$("saveName").value=currentName()||"My track"; buildSaveTo();
const upd=$("saveUpd"); if(slKey[0]==="s"){ upd.style.display=""; upd.textContent='Update “'+(currentName()||"track")+'”'; } else upd.style.display="none";
$("saveMsg").textContent=""; renderLibrary();
$("laneSheet").classList.remove("open"); $("trackSheet").classList.remove("open"); $("scrim").classList.add("open"); $("saveSheet").classList.add("open"); }
$("saveNew").onclick=doSaveAsNew; $("saveUpd").onclick=doUpdate; $("saveDone").onclick=closeSheets; $("saveBtn").onclick=openSaveSheet;
/* ========================= SET-LIST / TRACK DROPDOWNS ======================== */
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 buildSetlistOptions(){
savedLists=lsGet(LS_SETLISTS,[]);
const sel=$("slSel"); sel.innerHTML="";
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);
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;
}
function buildTrackOptions(){ 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); }
$("slSel").onchange=(e)=>{ const v=e.target.value; let sl=null;
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}))}; }
if(sl){ slKey=v; transientTitle=null; loadSetlistObj(sl); } };
$("trkSel").onchange=(e)=>{ gotoItem(+e.target.value, state.running); };
/* ========================= RENDER ============================================ */
let lastCur=null;
function renderInfo(){
if(!editingBpm) $("bpm").textContent=state.bpm;
const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx);
const nm=currentName(); if(nm!==lastCur){ lastCur=nm; lsSet(LS_CURTRACK,nm); }
const sig=laneSignature(); if(sig!==laneSig){ laneSig=sig; buildLanes(); } else renderPadLevels();
buildTrackBtn(); updateStatus();
}
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 buildTrackBtn(){ const parts=[];
if(segBars>0) parts.push(segBars+" bars"); parts.push("→ "+endLabel());
if(ramp.on) parts.push("ramp"); if(trainer.on) parts.push("gaps");
$("trackBtn").textContent="⚙ "+parts.join(" · "); }
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){ const bar=segBars>0?((m.currentBar|0)%segBars+1):((m.currentBar|0)+1);
s+=" · bar "+bar+(segBars>0?(" / "+segBars):"")+" · "+fmt((Date.now()-runStartAt)/1000); }
else if(segBars>0){ s+=" · "+segBars+" bars"; }
$("meterline").textContent=s; }
function renderTransport(){
const onAny=sessionActive||state.running;
$("bPlay").innerHTML=(onAny?"■<small>STOP</small>":"▶<small>PLAY</small>"); $("bPlay").classList.toggle("on",onAny);
const pr=state.running&&sessionActive;
$("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="Practising · 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(); renderTransport(); saveState(); }
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");
clearTimeout(pulseTimer); pulseTimer=setTimeout(()=>p.classList.remove("hit","acc"),130); }
let lastTimeUpd=0;
function draw(){
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++; } } }
renderPadPlayheads();
const m=meters[0];
if(state.running&&m&&m.currentStep>=0){ 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)); } } }
const t=performance.now();
if(t-lastTimeUpd>200){ lastTimeUpd=t; if(state.running) updateStatus(); if(sessionActive) renderSessionBar(); }
requestAnimationFrame(draw);
}
/* ========================= BPM: tap=tap-tempo · hold=type · drag=scrub ======== */
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 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("blur",()=>{ if(editingBpm) closeBpmEdit(true); });
(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.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("pointerup",(e)=>{ if(!dragging) return; dragging=false; clearTimeout(lpTimer); try{p.releasePointerCapture(e.pointerId);}catch(_){} if(!moved && !lpFired) tapTempo(); });
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 =========================== */
function loadFromHash(text){
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){}
try{
if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){ const sl=codeToSetlist(payload); if(!sl.items.length) throw 0;
slKey=""; transientTitle=sl.title||"Shared set list"; loadSetlistObj(sl); return true; }
const setup=patchToSetup(payload); if(!setup.lanes.length) throw 0;
slKey=""; transientTitle="Shared patch"; loadSetlistObj({title:"Shared patch",items:[{name:"Patch",...setup}]}); return true;
}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:"Just runs the metronome — nothing is added to your practice log."},
{sel:"#bPrac", title:"Practice = a timed session", text:"Practice times your playing and logs it to your practice log (it does NOT record audio). It starts a session clock; Play turns into Stop. Use Practice to start/pause each track, then tap Stop when you're done to save the session."},
{sel:"#trackBtn", title:"Track settings", text:"Set this track's bar count, what happens at the end (loop / stop / next), tempo ramp and practice gaps — and copy or paste the track's share string."},
{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 ============================================ */
$("bPlay").onclick=play; $("bPrac").onclick=practice;
$("bPrev").onclick=()=>gotoItem(idx-1,state.running); $("bNext").onclick=()=>gotoItem(idx+1,state.running);
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
$("bUp10").onclick=()=>nudge(+10); $("bDn10").onclick=()=>nudge(-10);
$("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(); });
/*@BUILD:include:src/chrome.js@*/
/* ========================= FULLSCREEN + WAKE LOCK ============================ */
const docEl=document.documentElement;
const reqFS=docEl.requestFullscreen||docEl.webkitRequestFullscreen, exitFS=document.exitFullscreen||document.webkitExitFullscreen;
const fsEl=()=>document.fullscreenElement||document.webkitFullscreenElement;
let wakeLock=null;
async function requestWake(){ try{ if(navigator.wakeLock) wakeLock=await navigator.wakeLock.request("screen"); }catch(e){} }
function releaseWake(){ try{ wakeLock&&wakeLock.release(); }catch(e){} wakeLock=null; }
function toggleFS(){ if(fsEl()){ if(exitFS) try{ exitFS.call(document); }catch(e){} } else if(reqFS){ try{ reqFS.call(docEl); }catch(e){} } }
$("fsBtn").onclick=toggleFS;
if(window.EMBED) $("fsBtn").style.display="none";
document.addEventListener("visibilitychange",()=>{ if(document.visibilityState==="visible"&&state.running) requestWake(); });
/* PWA */
let deferredPrompt=null;
addEventListener("beforeinstallprompt",(e)=>{ e.preventDefault(); deferredPrompt=e; });
if(!window.EMBED && "serviceWorker" in navigator){ addEventListener("load",()=>{ navigator.serviceWorker.register("/mobile-sw.js").catch(()=>{}); }); }
addEventListener("beforeunload",(e)=>{ if(sessionActive){ e.preventDefault(); e.returnValue=""; } });
/* keyboard (desktop testing) */
addEventListener("keydown",(e)=>{ const tag=(e.target&&e.target.tagName)||""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
const k=e.key;
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==="ArrowLeft"){ e.preventDefault(); gotoItem(idx-1,state.running); }
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==="f"||k==="F") toggleFS();
});
/* ========================= INIT ============================================== */
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]); }
buildSetlistOptions(); buildTrackOptions();
$("vol").value=Math.round(state.volume*100); if(masterGain) masterGain.gain.value=state.volume;
renderAll(); renderSessionBar();
requestAnimationFrame(draw);
if(!window.EMBED && !lsGet(LS_TOURED,0)) setTimeout(()=>{ if(!$("tour").classList.contains("open")) startTour(); }, 700);
</script>
</body>
</html>