diff --git a/README.md b/README.md index a8feec4..9e0ac30 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,14 @@ Tokens are joined with `;`. `tr` and `rmp` are omitted when off. `jamblock` (unknown → `beep`). - **grouping** — beats per bar, optionally grouped for odd meters: `4`, `3`, `2+2+3`. The first beat of each group is accented. -- **`/sub`** — subdivision (clicks per beat): `1` quarter (default), `2` eighth, - `3` triplet, `4` sixteenth, `6` sextuplet. Omit for quarter. -- **`=pattern`** — per‑beat on/off as `x`/`.`, length = beats per bar. Omit = all on. - e.g. `=.x.x` puts a backbeat on 2 & 4. +- **`/sub`** — subdivision: `1` quarter (default), `2` eighth, `3` triplet, + `4` sixteenth, `6` sextuplet. This also sets how many **pads** each beat splits + into (a beat becomes `sub` individually‑toggleable steps). Omit for quarter. +- **`=pattern`** — per‑**step** on/off as `x`/`.`, length = beats per bar × `sub` + (one char per pad). Omit = all on. e.g. `4=.x.x` is a backbeat on 2 & 4; + `4/4=x..x..x.x...x...` is a sixteenth‑grid pattern. A short pattern whose length + equals just the beat count is still accepted and expanded across each beat's + subdivisions (back‑compat). - **`~`** — polyrhythm: fit this lane's beats evenly into **lane 1's** bar. - **`!`** — mute the lane. diff --git a/index.html b/index.html index 8a254bc..31a5d02 100644 --- a/index.html +++ b/index.html @@ -83,6 +83,8 @@ .led.accent { box-shadow:0 0 4px #fff, 0 0 14px var(--lc); } .led.accent::after { content:"▲"; position:absolute; top:-1px; font-size:7px; color:#fff; } .led.playhead { outline:2px solid var(--ring); outline-offset:1px; } + .led.sub { width:20px; height:20px; opacity:.9; } /* subdivision step — smaller than a downbeat */ + .led.beatstart { margin-left:11px; } /* extra gap between beats within a group */ .led.groupstart { margin-left:16px; } .led.groupstart::before { content:""; position:absolute; left:-9px; top:4px; bottom:4px; width:2px; background:var(--muted); } /* meter lanes — compact single-row controls + strip */ @@ -96,6 +98,7 @@ .bar { font-family:"Courier New",monospace; font-size:12px; color:var(--hot); min-width:46px; text-align:right; } .cmp { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:7px 8px; font-size:12px; } .meter-card .led { width:26px; height:26px; border-radius:6px; } + .meter-card .led.sub { width:17px; height:17px; } .x { background:transparent; border:1px solid var(--edge); color:var(--muted); padding:4px 9px; border-radius:7px; margin-left:auto; } .x:hover { color:#ff9a8a; border-color:#c0392b; } .hint { font-size:11px; color:var(--muted); margin-top:8px; } @@ -166,6 +169,10 @@ .ext-banner { font-size:11px; color:#3a2f10; background:#ffe2a8; border:1px solid #d9a441; border-radius:8px; padding:8px 10px; margin-top:10px; line-height:1.35; } .ext-banner[hidden] { display:none; } #shareUrl { width:100%; resize:vertical; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-family:"Courier New",monospace; font-size:12px; } + .help-about { margin-top:14px; padding-top:12px; border-top:1px solid var(--edge); font-size:12px; color:var(--muted); line-height:1.45; } + .help-about p { margin:0 0 8px; } + .help-about p:last-child { margin-bottom:0; } + .help-about a { color:#6cb6ff; } .kbd-table { width:100%; border-collapse:collapse; font-size:13px; } .kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); } .kbd-table tr:last-child td { border-bottom:none; } @@ -254,7 +261,7 @@

Meter lanes

- Click a beat pad to toggle it (rest) — e.g. snare on 2 & 4 + Each beat splits into subdivision pads — click any pad to toggle it (rest). e.g. snare on 2 & 4
@@ -318,6 +325,10 @@ ?This help EscClose tray / help +
+

Source: git.varasys.io/VARASYS/metronome

+

This is a single-page app — save this page (Ctrl/⌘+S) and open the file to run it fully offline, no server needed. One catch when running from a local file://: it won't auto-save your set list between sessions, so export a backup (set-list menu → Export all) to keep your work.

+
@@ -473,9 +484,9 @@ function scheduleMeterTick(m, time) { const tickInBar = ((m.tick % barLen) + barLen) % barLen; const onBeat = (tickInBar % spb) === 0; const beatIndex = Math.floor(tickInBar / spb); - if (onBeat) m.vq.push({ time, beat: beatIndex, bar: Math.floor(m.tick / barLen) }); // playhead + measure (advance even when muted) + m.vq.push({ time, step: tickInBar, bar: Math.floor(m.tick / barLen) }); // playhead (per step) + measure (advance even when muted) if (!m.enabled || isMutedAt(time)) return; - if (!m.beatsOn[beatIndex]) return; // beat toggled off → rest (incl. its subdivisions) + if (!m.beatsOn[tickInBar]) return; // this step toggled off → rest if (onBeat) { const groupStart = m.groupStarts.has(beatIndex); playInstrument(m.sound, time, groupStart ? 1.0 : 0.7); // beat — louder on group start @@ -511,7 +522,7 @@ function start() { state.running = true; if (ramp.on) setBpm(ramp.startBpm); // ramp begins from its start BPM const t0 = audioCtx.currentTime + 0.08; - for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentBeat = -1; m.currentBar = 0; } + for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; } masterBeat = 0; masterBeatTime = t0; muteWindows = []; schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS); scheduler(); syncStartBtn(); @@ -519,7 +530,7 @@ function start() { function stop() { state.running = false; clearInterval(schedulerTimer); schedulerTimer = null; - for (const m of meters) m.currentBeat = -1; + for (const m of meters) m.currentStep = -1; syncStartBtn(); } function setBpm(v) { @@ -538,10 +549,15 @@ function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = n const m = { id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts, stepsPerBeat, sound, enabled: true, poly: !!poly, color: laneColor(id), - beatsOn: beatsOn ? beatsOn.slice() : [], // per-beat on/off mask (rests) - tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentBeat: -1, currentBar: 0, + beatsOn: beatsOn ? beatsOn.slice() : [], // per-STEP on/off mask (one entry per pad = beats × subdivision) + tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentStep: -1, currentBar: 0, el: null, stripEl: null, barEl: null, }; + // Tell recomputeLane the resolution the incoming mask was authored at, so it can + // remap/expand it: matches steps → per-step (new), matches beats → legacy per-beat. + if (m.beatsOn.length === p.beatsPerBar * stepsPerBeat) { m._maskBpb = p.beatsPerBar; m._maskSpb = stepsPerBeat; } + else if (m.beatsOn.length === p.beatsPerBar) { m._maskBpb = p.beatsPerBar; m._maskSpb = 1; } + else { m._maskBpb = 0; m._maskSpb = 1; } // empty/unknown → recompute fills all-on if (state.running) { m.nextTime = audioCtx.currentTime + 0.05; } meters.push(m); buildLaneCard(m); @@ -575,12 +591,7 @@ function buildLaneCard(m) { - - @@ -599,11 +610,6 @@ function buildLaneCard(m) { // wire controls const $c = (sel) => card.querySelector(sel); $c(`#m${m.id}_group`).addEventListener("input", (e) => { m.groupsStr = e.target.value; recomputeLane(m); }); - const preset = $c(`#m${m.id}_preset`); - preset.addEventListener("change", (e) => { - if (!e.target.value) return; - m.groupsStr = e.target.value; $c(`#m${m.id}_group`).value = e.target.value; e.target.value = ""; recomputeLane(m); - }); const sub = $c(`#m${m.id}_sub`); sub.value = String(m.stepsPerBeat); sub.addEventListener("change", (e) => { m.stepsPerBeat = +e.target.value; recomputeLane(m); }); const sel = $c(`#m${m.id}_sound`); sel.value = m.sound; @@ -621,33 +627,57 @@ function buildLaneCard(m) { function recomputeLane(m) { const p = parseGroups(m.groupsStr); m.groups = p.groups; m.beatsPerBar = p.beatsPerBar; m.groupStarts = p.groupStarts; - const prev = m.beatsOn || []; // resize mask, preserve, default new beats on - m.beatsOn = []; - for (let b = 0; b < m.beatsPerBar; b++) m.beatsOn[b] = (b < prev.length) ? !!prev[b] : true; + // Remap the on/off mask to step resolution (beats × subdivision = one entry per pad), + // preserving the old pattern where it lines up and defaulting new pads to ON. + const spb = m.stepsPerBeat; + const prev = m.beatsOn || [], oldBpb = m._maskBpb || 0, oldSpb = m._maskSpb || 1; + const next = []; + for (let b = 0; b < m.beatsPerBar; b++) { + for (let s = 0; s < spb; s++) { + let val = true; + if (b < oldBpb) { // this beat existed before + const oi = (oldSpb === spb) ? b * oldSpb + s // same resolution → step-for-step + : b * oldSpb; // resolution changed → use the beat's downbeat + if (oi < prev.length) val = !!prev[oi]; + } + next.push(val); + } + } + m.beatsOn = next; m._maskBpb = m.beatsPerBar; m._maskSpb = spb; m.el.querySelector(`#m${m.id}_sum`).textContent = "=" + m.beatsPerBar; buildLaneStrip(m); } -function buildLaneStrip(m) { +function buildLaneStrip(m) { // one pad per STEP (beats × subdivision) m.stripEl.innerHTML = ""; - for (let b = 0; b < m.beatsPerBar; b++) { + const spb = m.stepsPerBeat, total = m.beatsPerBar * spb; + for (let i = 0; i < total; i++) { + const b = Math.floor(i / spb), s = i % spb; const cell = document.createElement("div"); - cell.className = "led"; cell.textContent = b + 1; - cell.style.cursor = "pointer"; cell.title = "toggle beat " + (b + 1); - cell.addEventListener("click", () => { m.beatsOn[b] = !m.beatsOn[b]; renderLaneStrip(m); }); + cell.className = "led"; + cell.textContent = (s === 0) ? (b + 1) : ""; // label downbeats; subdivisions blank + cell.style.cursor = "pointer"; + cell.title = (s === 0) ? ("toggle beat " + (b + 1)) : ("toggle beat " + (b + 1) + " · sub " + (s + 1)); + cell.addEventListener("click", () => { m.beatsOn[i] = !m.beatsOn[i]; renderLaneStrip(m); }); m.stripEl.appendChild(cell); } } function renderLaneStrip(m) { - const cells = m.stripEl.children; - for (let b = 0; b < cells.length; b++) { - const cell = cells[b]; - const on = m.beatsOn[b], gs = m.groupStarts.has(b); - let cls = "led"; if (on) cls += " on"; if (gs) cls += " groupstart"; if (on && gs) cls += " accent"; + const cells = m.stripEl.children, spb = m.stepsPerBeat; + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + const b = Math.floor(i / spb), s = i % spb, onBeat = (s === 0); + const on = m.beatsOn[i], gs = onBeat && m.groupStarts.has(b); + let cls = "led"; + if (!onBeat) cls += " sub"; // subdivision pad (smaller/dimmer) + else if (i > 0 && !gs) cls += " beatstart"; // gap between beats within a group + if (on) cls += " on"; + if (gs) cls += " groupstart"; + if (on && gs) cls += " accent"; cell.className = cls; cell.style.setProperty("--lc", m.color); - if (state.running && b === m.currentBeat) cell.classList.add("playhead"); + if (state.running && i === m.currentStep) cell.classList.add("playhead"); } if (m.barEl) m.barEl.textContent = state.running ? "bar " + (m.currentBar + 1) : "—"; } @@ -890,11 +920,10 @@ function importAll(file) { Lane: :[/][=][~ poly][! disabled] ========================================================================= */ function laneCfgToStr(c) { - const bpb = parseGroups(c.groupsStr).beatsPerBar; let s = c.sound + ":" + c.groupsStr; if ((c.stepsPerBeat || 1) !== 1) s += "/" + c.stepsPerBeat; - const on = (c.beatsOn || []).slice(0, bpb); - if (on.length && !on.every(Boolean)) s += "=" + Array.from({ length: bpb }, (_, i) => (on[i] ? "x" : ".")).join(""); + const on = c.beatsOn || []; // per-step mask; one char per pad + if (on.length && !on.every(Boolean)) s += "=" + on.map((v) => (v ? "x" : ".")).join(""); if (c.poly) s += "~"; if (c.enabled === false) s += "!"; // "!" = silenced / disabled return s; @@ -1004,7 +1033,7 @@ function drawLoop() { if (audioCtx) { const now = audioCtx.currentTime; for (const m of meters) { - while (m.vqPtr < m.vq.length && m.vq[m.vqPtr].time <= now) { m.currentBeat = m.vq[m.vqPtr].beat; m.currentBar = m.vq[m.vqPtr].bar; m.vqPtr++; } + 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++; } if (m.vqPtr > 512) { m.vq = m.vq.slice(m.vqPtr); m.vqPtr = 0; } } updateStatus(now);