Clean, dependency-light front page. Only three things ship here: - index.html — two-button landing: Mobile -> mobile.html, Desktop -> pm_e-2.html - mobile.html — touch-first PWA (+ mobile-sessions.html practice journal) - pm_e-2.html — engraved-notation editor build.sh/deploy.sh trimmed to just these; deploy mirrors dist/ to the web root with rsync --delete. README/CLAUDE.md rewritten for the slim scope. The full project (PM_E-1 editor, embeddable widget, all hardware form-factor pages, Pico firmware editions, the Rust port, and the KiCad/SPICE hardware design) is preserved on the `concepts` branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
951 lines
74 KiB
HTML
951 lines
74 KiB
HTML
<!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); --poly:#bb8cff; --staff:rgba(199,208,219,.17);
|
||
--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); --poly:#7a3df0; --staff:rgba(28,40,63,.15);
|
||
--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; }
|
||
/* Content is capped to --maxw and centered, so phone→tablet is the SAME layout,
|
||
just larger (no flex re-flow). Generous margins; even more in full-screen. */
|
||
#app{ position:fixed; inset:0; overflow:hidden; display:flex; flex-direction:column; align-items:center; --maxw:600px; --pad:14px;
|
||
padding:max(12px,env(safe-area-inset-top)) max(var(--pad),env(safe-area-inset-right))
|
||
max(12px,env(safe-area-inset-bottom)) max(var(--pad),env(safe-area-inset-left)); }
|
||
#top, #mid{ width:100%; max-width:var(--maxw); }
|
||
:fullscreen #app, html:fullscreen #app{ --pad:30px; padding-top:max(24px,env(safe-area-inset-top)); padding-bottom:max(18px,env(safe-area-inset-bottom)); }
|
||
@media (display-mode:standalone){ #app{ --pad:26px; padding-top:max(20px,env(safe-area-inset-top)); padding-bottom:max(16px,env(safe-area-inset-bottom)); } }
|
||
|
||
/* ---- top ---- */
|
||
/* the logo + header-icon row is always a full-width bar at the very top, in
|
||
both orientations — it never joins the side-by-side landscape header flow */
|
||
#top{ flex:0 0 auto; display:flex; flex-direction:column; gap:11px; }
|
||
#brandrow{ flex:0 0 auto; display:flex; align-items:center; gap:10px; width:100%; }
|
||
#logoLink{ display:inline-flex; opacity:.9; }
|
||
.brandlogo{ height:clamp(21px,4.2vmin,30px); width:auto; display:block; }
|
||
.hicons{ display:flex; align-items:center; gap:8px; margin-left:auto; }
|
||
.hicons .icon{ width:36px; height:36px; font-size:16px; }
|
||
.sels{ width:100%; display:flex; gap:8px; align-items:flex-end; }
|
||
.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{ width:100%; display:flex; align-items:center; gap:12px; color:var(--muted); min-width:0; }
|
||
.vol input{ flex:1 1 auto; min-width:0; accent-color:var(--cyan); }
|
||
.dyn{ flex:0 0 auto; font-family:Georgia,"Times New Roman",serif; font-style:italic; font-weight:700; font-size:17px; color:var(--muted); line-height:1; }
|
||
.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 + track panel + lanes + transport, centered as one block ---- */
|
||
#mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:clamp(18px,4.2vmin,34px); padding-top:clamp(8px,1.8vmin,16px); }
|
||
#stage{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; width:100%; }
|
||
/* tempo plate: [TAP] [big BPM] [thumbwheel] — flashes on the beat */
|
||
#pulse{ position:relative; width:100%; padding:clamp(8px,1.8vmin,14px); border-radius:16px;
|
||
display:flex; flex-direction:row; align-items:stretch; gap:clamp(8px,2vmin,16px);
|
||
border:1px solid var(--ring); background:linear-gradient(180deg, rgba(127,139,154,.06), transparent);
|
||
transition:box-shadow .12s ease-out, border-color .12s ease-out, background .12s ease-out; }
|
||
#pulse.hit{ border-color:var(--cyan); box-shadow:0 0 22px var(--glow); background:rgba(10,179,247,.07); }
|
||
#pulse.hit.acc{ border-color:var(--amber); box-shadow:0 0 26px var(--aglow); background:rgba(255,209,102,.08); }
|
||
.tapbtn{ flex:0 0 auto; align-self:stretch; min-width:clamp(58px,16vmin,100px); border-radius:12px;
|
||
background:linear-gradient(180deg,var(--btn1),var(--btn2)); border:1px solid var(--btn-bd); color:var(--txt);
|
||
font-size:clamp(13px,2.8vmin,18px); font-weight:600; letter-spacing:.14em; cursor:pointer;
|
||
transition:box-shadow .1s ease-out, border-color .1s ease-out, color .1s ease-out;
|
||
box-shadow:0 3px 0 rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.06); }
|
||
/* TAP button glows in time with the beat (rides the #pulse flash) */
|
||
#pulse.hit .tapbtn{ border-color:var(--cyan); color:var(--cyan);
|
||
box-shadow:0 3px 0 rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.06), 0 0 16px var(--glow), inset 0 0 12px var(--glow); }
|
||
#pulse.hit.acc .tapbtn{ border-color:var(--amber); color:var(--amber);
|
||
box-shadow:0 3px 0 rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.06), 0 0 18px var(--aglow), inset 0 0 14px var(--aglow); }
|
||
.tapbtn:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.06); }
|
||
#bpm{ flex:1 1 auto; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:.82; cursor:pointer; min-width:0; }
|
||
#bpmNum{ font-size:clamp(44px,15vmin,120px); font-weight:800; font-variant-numeric:tabular-nums; letter-spacing:-.01em; }
|
||
#bpmlab{ font-size:clamp(9px,1.8vmin,13px); letter-spacing:.2em; text-transform:uppercase; color:var(--muted); margin-top:.5em; opacity:.85; }
|
||
#bpmIn{ display:none; flex:1 1 auto; min-width:0; text-align:center; font:inherit; font-size:clamp(40px,13vmin,108px); 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; }
|
||
/* thumbwheel encoder — drag up/down to scrape the tempo */
|
||
#wheel{ flex:0 0 auto; align-self:stretch; width:clamp(30px,7.5vmin,46px); border-radius:11px; cursor:ns-resize; touch-action:none; overflow:hidden;
|
||
border:1px solid var(--btn-bd);
|
||
background:repeating-linear-gradient(to bottom, rgba(0,0,0,.22) 0 1px, transparent 1px 5px),
|
||
linear-gradient(to bottom, rgba(0,0,0,.55), rgba(0,0,0,0) 18%, rgba(255,255,255,.16) 50%, rgba(0,0,0,0) 82%, rgba(0,0,0,.55)),
|
||
linear-gradient(to right, rgba(0,0,0,.18), rgba(255,255,255,.10) 50%, rgba(0,0,0,.18)),
|
||
var(--field-bg);
|
||
box-shadow:inset 0 10px 9px -8px rgba(0,0,0,.75), inset 0 -10px 9px -8px rgba(0,0,0,.75), inset 0 0 4px rgba(0,0,0,.3); }
|
||
#wheel:active{ border-color:var(--cyan); }
|
||
#meterline{ font-size:clamp(12px,2.1vmin,16px); color:var(--muted); text-align:center; min-height:1.2em; letter-spacing:.02em; }
|
||
|
||
/* ---- editable lanes (scroll if many) + track panel below ---- */
|
||
#detail{ flex:0 1 auto; width:100%; display:flex; flex-direction:column; gap:8px; padding:2px 0; min-height:0; }
|
||
#lanes{ display:flex; flex-direction:column; gap:6px; max-height:34vh; overflow-y:auto; }
|
||
#trackpanel{ width:100%; background:var(--chip-bg); border:1px solid var(--chip-bd); border-radius:10px; padding:8px 11px; display:flex; flex-direction:column; gap:8px; font-size:12px; color:var(--muted); }
|
||
#trackpanel .tp-row{ display:flex; align-items:center; gap:8px 18px; flex-wrap:nowrap; }
|
||
#trackpanel label{ display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||
#trackpanel .tp-chk{ color:var(--txt); }
|
||
#trackpanel .tp-chk input{ width:18px; height:18px; accent-color:var(--cyan); flex:0 0 auto; }
|
||
#trackpanel .tp-loop{ color:var(--muted); }
|
||
#trackpanel input[type=number]{ width:46px; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:7px; padding:5px 6px; font-size:13px; -moz-appearance:textfield; }
|
||
#trackpanel input[type=number]::-webkit-outer-spin-button, #trackpanel input[type=number]::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; }
|
||
#trackpanel select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:7px; padding:5px 6px; font-size:13px; }
|
||
#trackpanel .tp-sub{ display:flex; align-items:center; gap:6px; flex-wrap:nowrap; white-space:nowrap; color:var(--muted); }
|
||
#trackpanel .tp-sub.off{ display:none; }
|
||
#trackpanel .tp-str{ display:flex; gap:6px; }
|
||
#trackpanel .tp-str input{ flex:1 1 auto; min-width:0; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:7px; padding:6px 8px; font-family:"Courier New",monospace; font-size:11px; }
|
||
#trackpanel .tp-btn{ flex:0 0 auto; background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd); border-radius:7px; padding:0 12px; font-size:12px; cursor:pointer; }
|
||
#trackpanel .tp-msg{ color:#5fd08a; margin-left:auto; }
|
||
.lane{ display:flex; align-items:center; gap:8px; }
|
||
/* dim only the label + pads of a muted lane, so the mute toggle stays crisp */
|
||
.lane.off .lmeta, .lane.off .pads{ opacity:.45; }
|
||
.lmute{ flex:0 0 auto; width:clamp(28px,5.4vmin,36px); height:clamp(28px,5.4vmin,36px); border-radius:8px; padding:0;
|
||
display:flex; align-items:center; justify-content:center; cursor:pointer;
|
||
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--cyan); }
|
||
.lmute:active{ background:rgba(127,139,154,.22); }
|
||
.lmute.muted{ color:var(--muted); background:transparent; }
|
||
.lmute svg{ width:62%; height:62%; }
|
||
.lane.poly .lmute{ color:var(--poly); }
|
||
.lane.poly .lmute.muted{ color:var(--muted); }
|
||
.lmeta{ flex:0 0 auto; width:36%; max-width:168px; min-width:94px; display:flex; align-items:center; gap:5px; text-align:left;
|
||
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:5px 8px;
|
||
font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; cursor:pointer; }
|
||
.lmeta .ln-name{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.lmeta .lg{ flex:0 0 auto; color:var(--muted); }
|
||
.lmeta .rhythm{ flex:0 0 auto; color:var(--txt); display:block; }
|
||
.lmeta .polybadge{ flex:0 0 auto; color:var(--poly); font-weight:700; letter-spacing:.01em; }
|
||
.lane.poly .lmeta{ border-left:3px solid var(--poly); padding-left:6px; }
|
||
.lmeta .rh-host{ flex:0 0 auto; display:flex; align-items:center; padding:2px 3px; margin:-2px -1px; border-radius:5px; cursor:pointer; }
|
||
.lmeta .rh-host:active{ background:rgba(127,139,154,.22); }
|
||
/* graphic note-value picker */
|
||
.noterow{ display:flex; gap:8px; flex-wrap:wrap; }
|
||
.noterow .notebtn{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:4px; min-width:56px; padding:9px 6px;
|
||
background:var(--field-bg); border:1px solid var(--field-bd); border-radius:10px; color:var(--txt); cursor:pointer; }
|
||
.noterow .notebtn.active{ border-color:var(--cyan); box-shadow:0 0 0 1px var(--cyan); }
|
||
.noterow .notebtn .rhythm{ height:22px; width:auto; }
|
||
.noterow .notebtn small{ font-size:10px; color:var(--muted); letter-spacing:.02em; }
|
||
/* beats line up in columns across lanes; sub-beats sit inside the beat cell, smaller */
|
||
.pads{ flex:1 1 auto; display:flex; gap:7px; overflow-x:auto; padding-bottom:2px; min-width:0; align-items:center; }
|
||
.beatcell{ flex:1 1 0; min-width:0; display:flex; gap:2px; align-items:center; }
|
||
.pad{ flex:1 1 0; min-width:5px; border:1px solid var(--chip-bd); background:var(--led-off); cursor:pointer; padding:0; }
|
||
.pad.beat{ height:clamp(20px,3.8vmin,28px); border-radius:5px; }
|
||
.pad.sub{ height:clamp(11px,2.3vmin,16px); border-radius:3px; }
|
||
.pad.gs{ border-color:var(--amber); }
|
||
.pad.on{ background:var(--cyan); }
|
||
.pad.acc{ background:var(--amber); }
|
||
.pad.ghost{ background:var(--cyan); opacity:.42; }
|
||
.lane.poly .pad.on{ background:var(--poly); }
|
||
.lane.poly .pad.ghost{ background:var(--poly); 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: tempo row (−10/−/+/+10) then nav+play row (prev/play/practice/next) ---- */
|
||
/* grows to fill freed space; SHRINKS (rather than the page scrolling) when the panel expands */
|
||
#transport{ flex:0 1 auto; min-height:0; max-height:clamp(200px,46vh,310px); margin-top:auto; display:flex; flex-direction:column; align-items:stretch; width:100%; padding-top:6px; gap:clamp(5px,1.2vmin,9px); }
|
||
.tgrid{ display:grid; width:100%; flex:1 1 auto; min-height:0; gap:clamp(7px,1.7vmin,14px);
|
||
grid-template-columns:1fr 1.5fr 1.5fr 1fr; grid-template-rows:1fr 1fr; grid-template-areas:"dn10 dn up up10" "prev play prac next"; }
|
||
.tbtn{ background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
|
||
border-radius:13px; height:auto; min-height:66px; font-size:clamp(16px,4vmin,27px); 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:3px; }
|
||
.journal{ flex:0 0 auto; width:100%; height:clamp(30px,6vmin,42px); border-radius:11px; cursor:pointer;
|
||
background:rgba(127,139,154,.10); border:1px solid var(--panel-bd); color:var(--muted); font-size:13px;
|
||
display:flex; align-items:center; justify-content:center; gap:8px; }
|
||
.journal.rec{ background:rgba(192,57,43,.12); border-color:#c0392b; color:var(--txt); cursor:default; }
|
||
.journal .dotrec{ width:9px; height:9px; border-radius:50%; background:#e0493a; box-shadow:0 0 8px #e0493a; display:none; }
|
||
.journal.rec .dotrec{ display:inline-block; }
|
||
.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 (phone AND tablet): header stays a full-width column (logo+icons
|
||
row, then volume row); the body becomes a 2-column grid — tempo + transport
|
||
on the left, selector + settings + lanes on the right */
|
||
@media (orientation:landscape){
|
||
#app{ --maxw:1060px; }
|
||
/* fr columns (not 40%/60%) so the column gap is subtracted before sizing —
|
||
percentages + gap summed past 100% and overhung the right edge */
|
||
#mid{ display:grid; align-items:stretch; gap:clamp(12px,2.6vh,22px) clamp(16px,3vw,38px); padding-top:0;
|
||
grid-template-columns:minmax(0,2fr) minmax(0,3fr); grid-template-rows:auto auto 1fr auto;
|
||
grid-template-areas:"stage sels" "stage panel" "stage detail" "transport detail"; }
|
||
#stage, .sels, #trackpanel, #detail, #transport{ min-width:0; }
|
||
#stage{ grid-area:stage; align-self:center; justify-content:center; }
|
||
.sels{ grid-area:sels; align-self:start; }
|
||
#trackpanel{ grid-area:panel; align-self:start; }
|
||
#detail{ grid-area:detail; align-self:stretch; width:auto; max-width:none; max-height:none; min-height:0; overflow-y:auto; }
|
||
#transport{ grid-area:transport; align-self:end; max-height:none; margin-top:0; }
|
||
.tbtn{ min-height:0; height:clamp(34px,10vmin,54px); }
|
||
}
|
||
|
||
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
|
||
|
||
/* ---- 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, #noteSheet, #shareSheet{ 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, #noteSheet.open, #shareSheet.open{ transform:none; }
|
||
#laneSheet .grab, #trackSheet .grab, #saveSheet .grab, #noteSheet .grab, #shareSheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
|
||
#laneSheet h2, #trackSheet h2, #saveSheet h2, #noteSheet h2, #shareSheet h2{ margin:0 0 10px; font-size:16px; }
|
||
#laneSheet label, #trackSheet label, #saveSheet label, #shareSheet 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], #shareSheet 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, #shareSheet .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; }
|
||
.seg{ display:flex; gap:8px; margin-bottom:6px; }
|
||
.seg button{ flex:1 1 0; padding:9px; border:1px solid var(--field-bd); background:var(--field-bg); color:var(--muted); border-radius:9px; font-size:14px; cursor:pointer; }
|
||
.seg button.active{ border-color:var(--cyan); color:var(--txt); }
|
||
.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 id="brandrow">
|
||
<a id="logoLink" href="https://codeberg.org/VARASYS/metronome" target="_blank" rel="noopener" title="VARASYS PolyMeter — source on Codeberg"><img class="brandlogo logo-dark" src="data:image/png;base64,@BUILD:logo-side-dark@" alt="VARASYS PolyMeter" /><img class="brandlogo logo-light" src="data:image/png;base64,@BUILD:logo-side-light@" alt="VARASYS PolyMeter" /></a>
|
||
<div class="hicons" id="utilrow">
|
||
<div class="icon" id="shareBtn" title="Share / paste" aria-label="Share or paste"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M8 7l4-4 4 4"/><path d="M5 12v8h14v-8"/></svg></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 class="vol" id="volrow"><span class="dyn" aria-hidden="true">p</span><input id="vol" type="range" min="0" max="100" value="85" aria-label="Volume" /><span class="dyn" aria-hidden="true">f</span></div>
|
||
</div>
|
||
|
||
<div id="mid">
|
||
<div id="stage">
|
||
<div id="pulse">
|
||
<button id="bTapBtn" class="tapbtn" title="Tap tempo">TAP</button>
|
||
<div id="bpm"><span id="bpmNum">120</span><span id="bpmlab">BPM</span></div>
|
||
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
|
||
<div id="wheel" title="Drag to set tempo" aria-label="Tempo wheel"></div>
|
||
</div>
|
||
<div id="meterline"></div>
|
||
</div>
|
||
<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 class="icon" id="saveBtn" title="Save & library" aria-label="Save and library"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M5 4 H15 L19 8 V20 H5 Z"/><path d="M8.5 4 V8 H13.5 V4"/><rect x="8" y="13" width="8" height="7"/></svg></div>
|
||
</div>
|
||
<div id="trackpanel">
|
||
<div class="tp-row">
|
||
<label class="tp-chk"><input type="checkbox" id="ipRepeat" /> Repeat</label>
|
||
<label class="tp-chk"><input type="checkbox" id="ipRamp" /> Tempo ramp</label>
|
||
<label class="tp-chk"><input type="checkbox" id="ipGap" /> Practice gaps</label>
|
||
</div>
|
||
<div class="tp-sub off" id="ipRepeatRow">Play <input id="ipBars" type="number" inputmode="numeric" min="1" max="999" /> bars, then <select id="ipEnd"><option value="stop">stop</option><option value="next">next track</option><option value="prev">prev track</option></select></div>
|
||
<div class="tp-sub off" id="ipRampRow"><input id="ipRampStart" type="number" min="30" max="300" /> → +<input id="ipRampAmt" type="number" min="1" max="50" /> bpm / <input id="ipRampEvery" type="number" min="1" max="64" /> bars</div>
|
||
<div class="tp-sub off" id="ipGapRow"><input id="ipGapPlay" type="number" min="1" max="32" /> play / <input id="ipGapMute" type="number" min="1" max="32" /> mute bars</div>
|
||
</div>
|
||
<div id="detail">
|
||
<div id="lanes"></div>
|
||
</div>
|
||
<div id="transport">
|
||
<div class="tgrid">
|
||
<button class="tbtn" id="bDn10" title="Tempo −10">−10</button>
|
||
<button class="tbtn" id="bDown" title="Tempo −1">−</button>
|
||
<button class="tbtn" id="bUp" title="Tempo +1">+</button>
|
||
<button class="tbtn" id="bUp10" title="Tempo +10">+10</button>
|
||
<button class="tbtn" id="bPrev" title="Previous track">⏮</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="bNext" title="Next track">⏭</button>
|
||
</div>
|
||
<button id="bJournal" class="journal"><span class="dotrec"></span><span id="jText">Journal →</span></button>
|
||
</div>
|
||
</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" />
|
||
<label>Note value</label><div id="lsNotes" class="noterow"></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>
|
||
|
||
<!-- share sheet: share a track or set list as a link, or paste a string to load -->
|
||
<div id="shareSheet">
|
||
<div class="grab"></div>
|
||
<h2>Share</h2>
|
||
<div class="seg" id="shareSeg"><button data-k="p" class="active">This track</button><button data-k="sl">This set list</button></div>
|
||
<label>Shareable link</label>
|
||
<input id="shareLink" type="text" readonly onfocus="this.select()" />
|
||
<div class="lrow"><button id="shareCopy" class="lbtn">Copy link</button><button id="shareCopyT" class="lbtn">Copy text</button><span id="shareMsg" class="savemsg"></span></div>
|
||
<label for="sharePaste">Or paste a track string / link to load</label>
|
||
<input id="sharePaste" type="text" autocomplete="off" placeholder="v1;t120;kick:4;… or a #p=/#sl= link" />
|
||
<div class="lfoot"><button id="shareLoad" class="lbtn">Load</button><button id="shareDone" class="lbtn">Done</button></div>
|
||
</div>
|
||
|
||
<!-- save & library sheet -->
|
||
<div id="saveSheet">
|
||
<div class="grab"></div>
|
||
<h2>Save & 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)=>({"&":"&","<":"<",">":">",'"':"""}[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(){ // fires after segBars bars when an end action is set (loop = no action)
|
||
if(curEnd==="stop"){ if(sessionActive&&state.running) pauseTrack(); else stopAudio(); }
|
||
else if(typeof curEnd==="number") gotoItem(idx+curEnd,true); // +1 next track, -1 prev track
|
||
}
|
||
|
||
/* ========================= 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 padClass(m,k){ const spb=m.stepsPerBeat, isBeat=(k%spb===0), gs=isBeat&&m.groupStarts.has(k/spb), lvl=m.beatsOn[k]|0;
|
||
return "pad "+(isBeat?"beat":"sub")+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); }
|
||
// Effective note value a lane actually plays: reduce the subdivision grid to the
|
||
// largest note that lands on every active hit (so a triplet grid that only plays
|
||
// the beat shows a quarter, not a triplet). gcd(stepsPerBeat, all active offsets).
|
||
function gcd(a,b){ a=Math.abs(a); b=Math.abs(b); while(b){ const t=a%b; a=b; b=t; } return a; }
|
||
function laneNoteValue(m){
|
||
const spb=m.stepsPerBeat; let g=spb, any=false;
|
||
for(let k=0;k<m.beatsOn.length;k++){ if((m.beatsOn[k]|0)>0){ any=true; g=gcd(g,k); } }
|
||
if(!any) return 1;
|
||
return Math.max(1, spb/g);
|
||
}
|
||
// Small engraved rhythm figure (1=quarter, 2=8ths, 3=triplet, 4=16ths, 5/6/7=tuplets). SVG.
|
||
function rhythmSVG(n){
|
||
n=Math.max(1,n|0);
|
||
const baseY=15, topY=6, stemH=(baseY-topY-0.5).toFixed(1);
|
||
const head=(cx)=>'<ellipse cx="'+cx.toFixed(1)+'" cy="'+baseY+'" rx="2.4" ry="1.8" transform="rotate(-20 '+cx.toFixed(1)+' '+baseY+')"/>';
|
||
const stem=(sx)=>'<rect x="'+(sx-0.45).toFixed(2)+'" y="'+topY+'" width="0.9" height="'+stemH+'"/>';
|
||
const beam=(x0,x1,y)=>'<rect x="'+x0.toFixed(2)+'" y="'+y+'" width="'+(x1-x0).toFixed(2)+'" height="1.7"/>';
|
||
const beams = n===1?0 : (n<=3?1:2), tup = (n===3||n>=5)?n:null;
|
||
const LEFT=3, GAP = n<=2?8 : n<=4?6.5 : 5.5, W=Math.round(LEFT*2 + (n-1)*GAP + 4);
|
||
let g="", first=0, last=0;
|
||
for(let i=0;i<n;i++){ const cx=LEFT+i*GAP, sx=cx+2.0; if(i===0) first=sx; last=sx; g+=head(cx)+stem(sx); }
|
||
if(beams>=1) g+=beam(first-0.45,last+0.45,topY);
|
||
if(beams>=2) g+=beam(first-0.45,last+0.45,topY+2.6);
|
||
if(tup) g+='<text x="'+(W/2).toFixed(1)+'" y="3.6" font-size="6" text-anchor="middle" font-style="italic" stroke="none">'+tup+'</text>';
|
||
return '<svg class="rhythm" viewBox="0 0 '+W+' 18" width="'+W+'" height="16" fill="currentColor" aria-hidden="true">'+g+'</svg>';
|
||
}
|
||
function laneMetaHTML(m){ const eff=laneNoteValue(m);
|
||
const ref=(meters[0]?meters[0].beatsPerBar:m.beatsPerBar);
|
||
const poly=m.poly?"<span class='polybadge' title='polyrhythm — "+m.beatsPerBar+" over "+ref+"'>↻"+m.beatsPerBar+":"+ref+"</span>":"";
|
||
return "<span class='ln-name'>"+esc(m.sound)+"</span><span class='rh-host' title='Note value'>"+rhythmSVG(eff)+"</span><span class='lg'>"+esc(m.groupsStr)+"</span>"+poly; }
|
||
function setLaneMeta(m){ if(!m._meta) return; m._meta.innerHTML=laneMetaHTML(m); }
|
||
// speaker glyphs for the inline per-lane mute toggle
|
||
const SPK_ON='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M16.5 8.5a5 5 0 0 1 0 7"/></svg>';
|
||
const SPK_OFF='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M17 9.5l4 5M21 9.5l-4 5"/></svg>';
|
||
function toggleLaneMute(i){ const m=meters[i]; if(!m) return; m.enabled=!m.enabled; laneSig=null; renderAll(); saveState(); }
|
||
function buildLanes(){
|
||
const box=$("lanes"); box.innerHTML="";
|
||
meters.forEach((m,i)=>{
|
||
const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off")+(m.poly?" poly":"");
|
||
const mute=document.createElement("button"); mute.className="lmute"+(m.enabled?"":" muted");
|
||
mute.title=m.enabled?"Mute lane":"Unmute lane"; mute.setAttribute("aria-label",mute.title);
|
||
mute.innerHTML=m.enabled?SPK_ON:SPK_OFF; mute.onclick=(e)=>{ e.stopPropagation(); toggleLaneMute(i); };
|
||
const meta=document.createElement("button"); meta.className="lmeta"; m._meta=meta; m._idx=i; setLaneMeta(m);
|
||
meta.onclick=()=>openLaneSheet(i);
|
||
const pads=document.createElement("div"); pads.className="pads";
|
||
const spb=m.stepsPerBeat; m._padEls=new Array(m.beatsPerBar*spb); m._lastPad=-1;
|
||
for(let b=0;b<m.beatsPerBar;b++){ const cell=document.createElement("div"); cell.className="beatcell";
|
||
for(let s=0;s<spb;s++){ const k=b*spb+s; const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); cell.appendChild(p); m._padEls[k]=p; }
|
||
pads.appendChild(cell); }
|
||
lane.appendChild(mute); 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;
|
||
m._padEls.forEach((p,k)=>{ if(p) p.className=padClass(m,k); }); }); }
|
||
function cyclePad(m,k,p){ m.beatsOn[k]=((m.beatsOn[k]|0)+1)%4; if(m.orns) m.orns[k]=0; p.className=padClass(m,k);
|
||
setLaneMeta(m); // note value can change as hits are added/removed
|
||
saveState(); }
|
||
|
||
/* ---- graphic note-value picker (tap a lane's rhythm icon, or use it in the lane sheet) ---- */
|
||
const NOTE_OPTS=[1,2,3,4,6];
|
||
function noteName(n){ return {1:"quarter",2:"eighth",3:"triplet",4:"sixteenth",6:"sextuplet"}[n]||(n+"-tuplet"); }
|
||
function renderNoteOpts(box, cur, pick){ if(!box) return; box.innerHTML="";
|
||
NOTE_OPTS.forEach(n=>{ const b=el("button","notebtn"+(n===cur?" active":"")); b.innerHTML=rhythmSVG(n)+"<small>"+noteName(n)+"</small>"; b.onclick=()=>pick(n); box.appendChild(b); }); }
|
||
function setLaneSub(i,n){ const m=meters[i]; if(!m) return;
|
||
rebuildLane(i,{groupsStr:m.groupsStr,stepsPerBeat:n,swing:m.swing,poly:m.poly,enabled:m.enabled,sound:m.sound,gainDb:m.gainDb,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice()});
|
||
laneSig=null; renderAll(); saveState(); }
|
||
function refreshLaneSheetNotes(){ const m=meters[editLaneIdx]; if(!m) return;
|
||
renderNoteOpts($("lsNotes"), m.stepsPerBeat, (n)=>{ setLaneSub(editLaneIdx,n); refreshLaneSheetNotes(); }); }
|
||
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; $("lsPoly").checked=!!m.poly; $("lsMute").checked=!m.enabled;
|
||
$("lsGain").value=m.gainDb||0; $("lsGainVal").textContent=gainLabel(m.gainDb||0); refreshLaneSheetNotes();
|
||
$("saveSheet").classList.remove("open"); $("shareSheet").classList.remove("open"); $("scrim").classList.add("open"); $("laneSheet").classList.add("open"); }
|
||
function closeSheets(){ ["laneSheet","saveSheet","shareSheet","scrim"].forEach(id=>$(id).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:m.stepsPerBeat,swing:m.swing,
|
||
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(); refreshLaneSheetNotes(); }
|
||
["lsSound","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;
|
||
|
||
/* ---- inline track panel: repeat/end, ramp, gap, copy/paste string (above lanes) ---- */
|
||
function clampInt(v,lo,hi,def){ v=parseInt(v,10); if(isNaN(v)) return def; return Math.max(lo,Math.min(hi,v)); }
|
||
function flashIp(msg){ $("ipMsg").textContent=msg; setTimeout(()=>{ if($("ipMsg").textContent===msg) $("ipMsg").textContent=""; },1600); }
|
||
function buildTrackPanel(){
|
||
if(document.activeElement&&document.activeElement.closest&&document.activeElement.closest("#trackpanel")) return; // don't fight the user mid-edit
|
||
const rep=segBars>0;
|
||
$("ipRepeat").checked=rep; $("ipRepeatRow").classList.toggle("off",!rep);
|
||
$("ipBars").value=segBars||4;
|
||
$("ipEnd").value = curEnd==="stop"?"stop":(curEnd===-1?"prev":"next");
|
||
$("ipRamp").checked=ramp.on; $("ipGap").checked=trainer.on;
|
||
$("ipRampStart").value=ramp.startBpm||80; $("ipRampAmt").value=ramp.amount||5; $("ipRampEvery").value=ramp.everyBars||4;
|
||
$("ipGapPlay").value=trainer.playBars||2; $("ipGapMute").value=trainer.muteBars||2;
|
||
$("ipRampRow").classList.toggle("off",!ramp.on); $("ipGapRow").classList.toggle("off",!trainer.on);
|
||
}
|
||
function applyTrackPanel(){
|
||
if($("ipRepeat").checked){ segBars=Math.max(1,parseInt($("ipBars").value,10)||4); const e=$("ipEnd").value; curEnd = e==="stop"?"stop":(e==="prev"?-1:1); }
|
||
else { segBars=0; curEnd=null; } // no Repeat = loop forever
|
||
$("ipRepeatRow").classList.toggle("off",!$("ipRepeat").checked);
|
||
ramp.on=$("ipRamp").checked; ramp.startBpm=clampInt($("ipRampStart").value,30,300,80); ramp.amount=clampInt($("ipRampAmt").value,1,50,5); ramp.everyBars=clampInt($("ipRampEvery").value,1,64,4);
|
||
trainer.on=$("ipGap").checked; trainer.playBars=clampInt($("ipGapPlay").value,1,32,2); trainer.muteBars=clampInt($("ipGapMute").value,1,32,2);
|
||
$("ipRampRow").classList.toggle("off",!ramp.on); $("ipGapRow").classList.toggle("off",!trainer.on);
|
||
saveState();
|
||
}
|
||
["ipRepeat","ipBars","ipEnd","ipRamp","ipGap","ipRampStart","ipRampAmt","ipRampEvery","ipGapPlay","ipGapMute"].forEach(id=>$(id).addEventListener("change",applyTrackPanel));
|
||
|
||
/* ---- 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"); $("shareSheet").classList.remove("open"); $("scrim").classList.add("open"); $("saveSheet").classList.add("open"); }
|
||
$("saveNew").onclick=doSaveAsNew; $("saveUpd").onclick=doUpdate; $("saveDone").onclick=closeSheets; $("saveBtn").onclick=openSaveSheet;
|
||
|
||
/* ---- share: a track or set list as a link, or paste a string to load ---- */
|
||
function setlistToCode(sl){ return b64u(JSON.stringify({t:sl.title||"",d:sl.description||"",i:(sl.items||[]).map(it=>({n:it.name,p:setupToPatch(it)}))})); }
|
||
function shareSetlistObj(){ return {title:setlist?setlist.title:"Set list", description:"", items:(setlist?setlist.items:[]).map((it,i)=> i===idx?{name:it.name, ...curSetupObj()}:it)}; }
|
||
let shareKind="p";
|
||
function shareText(){ return shareKind==="p" ? currentPatch() : setlistToCode(shareSetlistObj()); }
|
||
function shareUrl(){ return location.origin+location.pathname+"#"+shareKind+"="+(shareKind==="p"?encodeURIComponent(currentPatch()):setlistToCode(shareSetlistObj())); }
|
||
function refreshShare(){ $("shareLink").value=shareUrl(); $("shareSeg").querySelectorAll("button").forEach(b=>b.classList.toggle("active",b.dataset.k===shareKind)); }
|
||
function flashShare(m){ $("shareMsg").textContent=m; setTimeout(()=>{ if($("shareMsg").textContent===m) $("shareMsg").textContent=""; },1600); }
|
||
function copyText(s, ok){ if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(s).then(ok,()=>{}); } else { const t=$("shareLink"); t.value=s; t.select(); try{ document.execCommand("copy"); ok(); }catch(e){} refreshShare(); } }
|
||
function openShareSheet(){ shareKind="p"; refreshShare(); $("sharePaste").value=""; $("shareMsg").textContent="";
|
||
["laneSheet","saveSheet"].forEach(id=>$(id).classList.remove("open")); $("scrim").classList.add("open"); $("shareSheet").classList.add("open"); }
|
||
$("shareSeg").querySelectorAll("button").forEach(b=>b.onclick=()=>{ shareKind=b.dataset.k; refreshShare(); });
|
||
$("shareCopy").onclick=()=>copyText(shareUrl(),()=>flashShare("Link copied ✓"));
|
||
$("shareCopyT").onclick=()=>copyText(shareText(),()=>flashShare("Copied ✓"));
|
||
$("shareLoad").onclick=()=>{ const v=($("sharePaste").value||"").trim(); if(!v){ flashShare("Paste a string or link"); return; }
|
||
if(loadFromHash(/[#?&](p|sl)=/.test(v)?v:("#p="+v))){ closeSheets(); } else flashShare("✗ not valid"); };
|
||
$("shareDone").onclick=closeSheets;
|
||
$("shareBtn").onclick=openShareSheet;
|
||
|
||
/* ========================= 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) $("bpmNum").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();
|
||
buildTrackPanel(); 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 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=$("bJournal"), n=lsGet(LS_SESSIONS,[]).length; // the Journal button doubles as live session status
|
||
if(sessionActive){ bar.classList.add("rec"); const segs=session.segments.length+(trackSegStart?1:0);
|
||
$("jText").textContent="Recording "+fmt((Date.now()-session.at)/1000)+" · "+segs+" track"+(segs===1?"":"s"); }
|
||
else { bar.classList.remove("rec"); $("jText").textContent=(lastSaved?"✓ saved · ":"")+"Journal"+(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=""; 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); });
|
||
$("bTapBtn").onclick=tapTempo; // TAP button (left of the BPM) — tap to set
|
||
$("bpm").onclick=()=>{ if(!editingBpm) openBpmEdit(); }; // tap the number to type
|
||
(function(){ const w=$("wheel"); let dragging=false, startY=0, startBpm=120; // thumbwheel (right) — drag to scrub
|
||
w.addEventListener("pointerdown",(e)=>{ dragging=true; startY=e.clientY; startBpm=state.bpm; w.setPointerCapture(e.pointerId); e.preventDefault(); });
|
||
w.addEventListener("pointermove",(e)=>{ if(!dragging) return; ramp.on=false; setBpm(startBpm+(startY-e.clientY)*0.5); renderAll(); });
|
||
w.addEventListener("pointerup",(e)=>{ dragging=false; try{w.releasePointerCapture(e.pointerId);}catch(_){} });
|
||
w.addEventListener("pointercancel",()=>{ dragging=false; });
|
||
})();
|
||
|
||
/* ========================= 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:"#brandrow,#volrow", title:"Controls", text:"The ↑ share menu, ? to replay this tour, ◐ light/dark theme, ⛶ full screen, and the full-width volume slider (soft p → loud f)."},
|
||
{sel:"#pulse", title:"Tempo", text:"Tap the BPM to tap-tempo, press-and-hold to type an exact value, the TAP button to tap it out, or drag the wheel up/down to scrub."},
|
||
{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:"#saveBtn", title:"Save & library", text:"Save the current track — “Save as new”, or “Update” one of yours. The same sheet is your library: make set lists and rename / reorder / delete tracks. It all lives with the full editor too."},
|
||
{sel:"#trackpanel", title:"Track settings", text:"Optional per-track extras: Repeat for N bars then stop / next / prev track, a tempo ramp, and practice gaps."},
|
||
{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. The speaker button at the left of each lane mutes/unmutes it. Tap a lane's label to set its note value (eighths, triplets, sixteenths…), sound, grouping or polymeter. “+ Add lane” for more."},
|
||
{sel:"#bDn10,#bDown,#bUp,#bUp10", title:"Nudge the tempo", text:"Step the BPM up or down while it keeps playing: −10 / −1 / +1 / +10. Great for settling on a comfortable speed or pushing it faster as you improve."},
|
||
{sel:"#bPrev,#bNext", title:"Previous / next track", text:"⏮ and ⏭ move to the previous or next track in the current set list. If the metronome is running it carries straight on into the new track."},
|
||
{sel:"#bPrac", title:"Practice = a timed session", text:"Play just runs the metronome. Practice times your playing and logs it (not audio): it starts a session clock and Play becomes Stop — start/pause each track, then Stop to save the session."},
|
||
{sel:"#bJournal", title:"Practice journal", text:"Opens your saved practice sessions — notes and a per-track breakdown across days. While Practice is recording, this shows the live session timer."},
|
||
];
|
||
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], pad=6, hole=$("tourHole");
|
||
// sel may match several elements (e.g. a row of buttons) — highlight their union
|
||
let r=null; document.querySelectorAll(s.sel).forEach(el=>{ const b=el.getBoundingClientRect();
|
||
r = r ? {left:Math.min(r.left,b.left),top:Math.min(r.top,b.top),right:Math.max(r.right,b.right),bottom:Math.max(r.bottom,b.bottom)} : {left:b.left,top:b.top,right:b.right,bottom:b.bottom}; });
|
||
r.width=r.right-r.left; r.height=r.bottom-r.top;
|
||
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(); };
|
||
$("bJournal").addEventListener("click",()=>{ if(!sessionActive) location.href="/mobile-sessions.html"; }); // mid-session: just shows the live timer
|
||
|
||
/*@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>
|