Compare commits
No commits in common. "main" and "v0.0.64" have entirely different histories.
61 changed files with 5783 additions and 2466 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,7 +1,2 @@
|
|||
# Build output — assembled from index.html + assets/ by build.sh
|
||||
dist/
|
||||
tools/
|
||||
|
||||
# Python build artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
48
CLAUDE.md
48
CLAUDE.md
|
|
@ -1,48 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file guides Claude Code (claude.ai/code) when working in this repository.
|
||||
|
||||
This is the **slim `main` branch** of VARASYS PolyMeter — a polymetric groove-trainer /
|
||||
metronome. Only three things ship here:
|
||||
|
||||
- `index.html` — the landing chooser: two buttons, **Mobile** → `mobile.html` and
|
||||
**Desktop** → `pm_e-2.html`.
|
||||
- `mobile.html` — the touch-first phone/tablet **PWA** (+ `mobile-sessions.html`, its practice
|
||||
journal). Installable, works offline via `mobile-sw.js` + `manifest.webmanifest`.
|
||||
- `pm_e-2.html` — the engraved-notation editor.
|
||||
|
||||
The **full project** (the PM_E-1 editor, the embeddable widget, every hardware form-factor page,
|
||||
the Pico **firmware** editions, the **Rust** port, and the **KiCad/SPICE hardware** design) lives
|
||||
on the **`concepts`** branch. Pull anything back from there if it needs to return to the front page.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
./build.sh # assemble the self-contained pages into dist/ (git-ignored)
|
||||
./deploy.sh # build, stamp version, mirror dist/ to the Caddy web root, smoke-test
|
||||
```
|
||||
|
||||
There's no test suite on this branch (the track-format conformance suite lives on `concepts`).
|
||||
|
||||
## Build system
|
||||
|
||||
Every deployed page is **one self-contained `.html` file, zero runtime dependencies** — no
|
||||
framework, no CDN, no audio samples (all voices are synthesized in Web Audio). Pages share code
|
||||
through build markers that `build.sh` resolves:
|
||||
|
||||
- `/*@BUILD:include:src/<file>@*/` inlines a shared partial (`engine.js`, `setlists.js`,
|
||||
`base.css`, `chrome.js`, `header.html`/`footer.html`, `notation.js`, `midiout.js`).
|
||||
- `@BUILD:favicon@` / `@BUILD:logo-*@` / `@BUILD:bravura@` inline base64 blobs from `assets/`.
|
||||
|
||||
`build.sh` asserts no `@BUILD:` markers survive. **`dist/` is generated and git-ignored — never
|
||||
edit it by hand.** Edit the source `*.html` in the repo root and the partials in `src/`;
|
||||
`deploy.sh` always builds first, then mirrors `dist/` to the web root with `rsync --delete`
|
||||
(so anything no longer built is removed from the live site).
|
||||
|
||||
State (set lists, practice log, theme) lives in `localStorage`; nothing is uploaded — share
|
||||
links encode everything in the URL hash (`#p=` patch, `#sl=` base64url set list). Source files
|
||||
keep an `APP_VERSION` placeholder; only the deployed copy is stamped (from `VERSION`).
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3 (`LICENSE`).
|
||||
303
README.md
303
README.md
|
|
@ -1,43 +1,292 @@
|
|||
# VARASYS PolyMeter
|
||||
|
||||
A **polymetric groove trainer & metronome**. Stack as many "meter lanes" as you like —
|
||||
each its own little metronome with a grouping, subdivision, drum voice and a per‑step
|
||||
pattern with accents. Layering lanes produces polymeter and true ratio polyrhythm.
|
||||
A small **website** built around one **polymetric groove trainer / metronome** engine.
|
||||
A landing page is the front door; the main app is the **PM_E‑1 PolyMeter Editor** — a full
|
||||
web app where you stack as many "meter lanes" as you like, each its own little metronome
|
||||
with a grouping, subdivision, drum voice and a per‑step pattern with accents. Layering lanes
|
||||
produces polymeter and true ratio polyrhythm. The same engine drives an ever‑expanding library
|
||||
of **form‑factor concepts** (idealized and buildable hardware mockups), ships as an
|
||||
**embeddable widget** anyone can drop into their own page, and even runs as **firmware** on a
|
||||
real Raspberry Pi Pico build (the **PM_K‑1 Kit**).
|
||||
|
||||
**Live:** https://metronome.varasys.io · **Source:** https://codeberg.org/VARASYS/metronome
|
||||
|
||||
## What ships here
|
||||
Every **deployed page is a single, self‑contained `.html` file** — **zero dependencies**:
|
||||
no framework, no CDN libraries, nothing fetched at runtime. They're assembled by a small
|
||||
build step (`build.sh`) that inlines a shared engine, the seed set lists, base styling and
|
||||
the brand assets (kept in `assets/`) into each page, so the sources stay lean. Every voice is
|
||||
**synthesized** in Web Audio — there are no audio samples to load. State (set lists, the
|
||||
practice log, theme and UI preferences) lives in `localStorage`.
|
||||
|
||||
The landing page (`/`) is a simple chooser with two doors:
|
||||
## Pages
|
||||
|
||||
- **Mobile** → `mobile.html` — the touch‑first phone/tablet app (tap a beat, set the tempo,
|
||||
practice). It's an installable **PWA** that works fully offline, with a practice journal
|
||||
(`mobile-sessions.html`).
|
||||
- **Desktop** → `pm_e-2.html` — the engraved‑notation editor: build rhythms on a staff with
|
||||
full keyboard control. Best on a large screen.
|
||||
The site is **one editor + a gallery of form factors**, and each form factor is split into a
|
||||
**lean widget page** and a **separate info page**:
|
||||
|
||||
Every **deployed page is a single, self‑contained `.html` file** with **zero runtime
|
||||
dependencies** — no framework, no CDN, nothing fetched at runtime. `build.sh` inlines a shared
|
||||
engine, the seed set lists, base styling and the brand assets (`assets/`) into each page. Every
|
||||
voice is **synthesized** in Web Audio (no audio samples). State (set lists, the practice log,
|
||||
theme) lives in `localStorage`; share links encode everything in the URL hash — nothing is
|
||||
uploaded.
|
||||
- **`<device>.html`** — just the live widget (front view, controls, program box). This is what
|
||||
`?embed=1` serves and what the landing embeds; it never ships the BOM/narrative.
|
||||
- **`info-<device>.html`** — the spec page: it embeds the live widget at the top, then the
|
||||
description, dimensioned drawings and a priced **Bill of Materials** (for the buildable hardware).
|
||||
|
||||
## Build & deploy
|
||||
| URL | What |
|
||||
|-----|------|
|
||||
| [`/`](https://metronome.varasys.io/) `index.html` | **Concepts** — the landing / form‑factor gallery; each box embeds the live widget (Open ↗ / Specs & info ⓘ) |
|
||||
| `/editor.html` · `/info-editor.html` | **PM_E‑1 — PolyMeter Editor** (the main app) + its overview |
|
||||
| `/kit.html` · `/info-kit.html` | **PM_K‑1 Kit** — buildable Raspberry Pi Pico touchscreen unit (52Pi EP‑0172); info page has the wiring, parts and firmware |
|
||||
| `/player.html` · `/info-player.html` | **PM_C‑1 Concept** — idealized concept device (full display + set‑list nav, theme, fullscreen "stage" view) |
|
||||
| `/teacher.html` · `/info-teacher.html` | **PM_T‑1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument pass‑through with analog click injection) |
|
||||
| `/stage.html` · `/info-stage.html` | **PM_S‑1 Stage** — foot‑pedal stompbox (two footswitches, expression‑pedal in, RGB beat light, instrument pass‑through) |
|
||||
| `/micro.html` · `/info-micro.html` | **PM_P‑1 Practice** — inline practice bar (instrument in / out pass‑through, clickable thumb‑roller, 14‑segment display) |
|
||||
| `/showcase.html` · `/info-showcase.html` | **PM_D‑1 Display** — pyramid display piece; the pendulum is an RGB light bar combining every lane's subdivisions/accents |
|
||||
| `/embed.html` · `/embed.js` | embed docs and the drop‑in loader |
|
||||
| `/pico-main.py` | the PM_K‑1 MicroPython firmware (download) |
|
||||
|
||||
```sh
|
||||
./build.sh # assemble the self-contained pages into dist/ (git-ignored)
|
||||
./deploy.sh # build, stamp version, mirror dist/ to the Caddy web root, smoke-test
|
||||
The buildable units (Teacher, Stage, Practice, Display, Kit) carry a priced BOM on their info
|
||||
page; the Editor (web app) and Concept have none. Every page shares the same VARASYS header
|
||||
(official logo with baked‑in tagline, nav, theme toggle). The editor also shows a subtle live
|
||||
**program string** of what's loaded — editable, with copy/paste — under the app (press `Enter`
|
||||
or paste to apply; see [the share language](#the-share-language)).
|
||||
|
||||
Because nothing loads from the network, you can save a page (`Ctrl`/`⌘`+`S`) and open it
|
||||
straight from disk to run fully offline. One catch from a local `file://`: the browser may not
|
||||
persist `localStorage` between sessions, so use **Export all** (set‑list **⋯** menu) to back up.
|
||||
|
||||
## Features
|
||||
|
||||
- **Meter lanes** — grouping (odd meters), subdivision (incl. swing), a drum/percussion
|
||||
voice, per‑**step dynamics** (accent / normal / ghost / mute), mute, live measure counter.
|
||||
- **Sounds** — every voice is **synthesized** in Web Audio: a friendly drum kit
|
||||
(`kick`, `snare`, `hatClosed`, …) rendered with the **808 / 909** voices, the 808/909 voices
|
||||
by name, and electronic/percussion tones. No samples are loaded.
|
||||
- **Per‑lane gain** — a dB trim knob per lane (`@<db>` in the share language), applied at
|
||||
schedule time so changing it never stutters playback.
|
||||
- **Polyrhythm** — a per‑lane *poly* toggle fits a lane's beats evenly into lane 1's
|
||||
bar (e.g. 5‑over‑4, 3‑over‑2).
|
||||
- **Euclidean rhythms** — spread *k* hits evenly across *n* steps with `(k,n[,rot])`.
|
||||
- **Practice** — gap/mute trainer (play N / mute M bars) and a tempo ramp with a
|
||||
start BPM and signed step.
|
||||
- **Set lists** — named, ordered lists of saved setups; **cue** across lists and commit
|
||||
on a bar/beat boundary with no audible gap (see **Live performance**); each play is logged.
|
||||
- **Sharing** — copy a link to your current settings or a whole set list.
|
||||
- **Theming** — System / Light / Dark.
|
||||
|
||||
## The share language
|
||||
|
||||
A compact, human‑readable text encodes a full configuration (a *patch*). It's what
|
||||
goes in a share link, the editor's program box, and a device's program list. You can
|
||||
hand‑write or edit it.
|
||||
|
||||
### Patch grammar
|
||||
|
||||
```
|
||||
v1 ; t<bpm> [; vol<pct>] [; cd<sec>] [; b<bars>] ; <lane> … [; tr<play>/<mute>] [; rmp<start>/<step>/<every>]
|
||||
```
|
||||
|
||||
## The `concepts` branch
|
||||
| Token | Meaning | Example |
|
||||
|-------|---------|---------|
|
||||
| `v1` | format version (always first) | `v1` |
|
||||
| `t<bpm>` | tempo | `t120` |
|
||||
| `vol<pct>` | master volume 0–100 | `vol70` |
|
||||
| `cd<sec>` | time countdown, seconds (auto-advance with Continue) | `cd60` |
|
||||
| `b<bars>` | segment length in bars (auto-advance with Continue) | `b16` |
|
||||
| `tr<play>/<mute>` | gap trainer: play N bars, mute M | `tr2/2` |
|
||||
| `rmp<start>/<step>/<every>` | tempo ramp: start BPM, ±step, every N bars | `rmp80/5/4` |
|
||||
| `<lane>` | a meter lane (see below) | `kick:4` |
|
||||
|
||||
This `main` branch is intentionally lean. The **full project** — the PM_E‑1 editor, the
|
||||
embeddable widget, the whole gallery of hardware **form‑factor concepts**, the Raspberry Pi
|
||||
Pico **firmware** editions, the **Rust** port, and the **KiCad/SPICE hardware** design — lives
|
||||
on the [`concepts`](https://codeberg.org/VARASYS/metronome/src/branch/concepts) branch. It can
|
||||
be promoted back to the front page at any time.
|
||||
Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
|
||||
|
||||
### Lane grammar
|
||||
|
||||
```
|
||||
<sound> : <grouping> [ / <sub> ] [ (<k>,<n>[,<rot>]) ] [ = <pattern> ] [ @ <db> ] [ ~ ] [ ! ]
|
||||
```
|
||||
|
||||
- **sound** — a synthesized voice. Friendly kit names (rendered with 808/909):
|
||||
`beep`, `kick`, `snare`, `rim`, `clap`, `hatClosed`, `hatOpen`, `ride`, `crash`, `tomLow`,
|
||||
`tomMid`, `tomHigh`, `tambourine`, `cowbell`, `woodblock`, `claves`; the drum‑machine voices
|
||||
by name — `kick808 snare808 clap808 hat808 openHat808 cowbell808 tom808` and
|
||||
`kick909 snare909 clap909 hat909 ride909 crash909`; or a **General‑MIDI note number**
|
||||
(`36`→kick, `38`→snare, `42`→closed hat, …). Unknown → `beep`.
|
||||
- **grouping** — beats per bar, optionally grouped for odd meters: `4`, `3`, `2+2+3`.
|
||||
Groups get a visual divider; accents are per‑step (see `=pattern`).
|
||||
- **`/sub`** — subdivision: `1` quarter (default), `2` eighth, `3` triplet, `4` sixteenth,
|
||||
`6` sextuplet — also sets how many **pads** each beat splits into. Append **`s`** for **swing**
|
||||
on even subdivisions (`2s`, `4s`) to delay the off‑beats to a 2:1 triplet feel.
|
||||
- **`(k,n[,rot])`** — **Euclidean** fill: place `k` hits as evenly as possible across `n`
|
||||
steps, optionally rotated by `rot`. e.g. `kick:4(3,8)`.
|
||||
- **`=pattern`** — per‑**step dynamics**, one char per pad: **`X`** accent, **`x`** normal,
|
||||
**`g`** ghost (soft), **`.`** `-` `_` mute (rest). Length = beats × `sub`. Omit to get the
|
||||
default — first step of each beat accented, the rest normal. e.g. `4=.X.X` accents 2 & 4.
|
||||
- **`@<db>`** — per‑lane gain trim in decibels, e.g. `@-3` or `@+2`.
|
||||
- **`~`** — polyrhythm: fit this lane's beats evenly into **lane 1's** bar.
|
||||
- **`!`** — mute the lane.
|
||||
|
||||
### Examples
|
||||
|
||||
| Patch / lane | What it is |
|
||||
|---|---|
|
||||
| `kick:4` | kick on 4 quarter beats |
|
||||
| `snare:4=.X.X` | accented snare backbeat (2 & 4) |
|
||||
| `hatClosed:4/2` | eighth‑note hi‑hats (downbeat of each beat accented) |
|
||||
| `ride:4/2s` | **swung** eighth‑note ride |
|
||||
| `kick:4(3,8)` | a 3‑over‑8 Euclidean kick |
|
||||
| `claves:5~` | 5 evenly across lane 1's bar (5‑over‑4 if lane 1 is `4`) |
|
||||
| `hat909:4/2@-4` | eighth 909 hats, trimmed −4 dB |
|
||||
| `kick:2+2+3=x..x..x` | 7/8, kick on each group start |
|
||||
| **Full:** `v1;t120;kick:4;snare:4=.x.x;hatClosed:4/2;tr2/2` | backbeat groove with gap trainer |
|
||||
|
||||
### In URLs
|
||||
|
||||
- **Settings:** `…/#p=<patch>` — readable, e.g. `…/#p=v1;t120;kick:4;claves:5~`
|
||||
- **Set list:** `…/#sl=<base64url>` — a JSON `{title, description, items[]}` where each
|
||||
item's config is a patch string.
|
||||
|
||||
Opening such a link applies the settings (or imports the set list) on load, then clears
|
||||
the hash so a refresh won't re‑import.
|
||||
|
||||
## Sharing
|
||||
|
||||
In the set‑list panel's **⋯** menu:
|
||||
- **Share settings link** / **Share set‑list link** open a dialog with the link to **Copy**
|
||||
or **Open**. The link encodes everything in the URL — nothing is uploaded.
|
||||
- **Export all / Import file** back up your set lists and practice log as a JSON file.
|
||||
|
||||
## Embedding
|
||||
|
||||
Any form factor can be embedded in another page as a self‑sizing widget. Drop in a
|
||||
container and the loader script — it builds an `<iframe>` to the chrome‑stripped
|
||||
(`?embed=1`) page, preloads your config string, and auto‑resizes to the content:
|
||||
|
||||
```html
|
||||
<div data-varasys-metronome="micro"
|
||||
data-patch="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2"></div>
|
||||
<script src="https://metronome.varasys.io/embed.js"></script>
|
||||
```
|
||||
|
||||
- `data-varasys-metronome` — variant: `editor` · `kit` · `initial` · `teacher` · `stage` · `micro` · `showcase`.
|
||||
- `data-patch` — a [patch string](#patch-grammar) (maps to `#p=`); or `data-setlist`
|
||||
for a set‑list code (maps to `#sl=`).
|
||||
- `data-width` / `data-height` — optional initial size (default `100%` × `300px`;
|
||||
height then tracks the widget, which posts `{type:'varasys-h', h}` to the parent).
|
||||
|
||||
Prefer your own iframe? `…/<variant>.html?embed=1#p=<patch>` works directly. The
|
||||
[Concepts landing](index.html) and every `info-*.html` page dogfood this exact mechanism.
|
||||
See `/embed.html`.
|
||||
|
||||
## Build it (hardware) — PM_K‑1 "Kit"
|
||||
|
||||
The **PM_K‑1 Kit** runs the same engine and program strings on a real device you can build today:
|
||||
a **Raspberry Pi Pico** on the **52Pi EP‑0172 "Pico Breadboard Kit Plus"** — a 3.5″ ST7796
|
||||
320×480 capacitive‑touch screen (GT911), a PSP joystick, a WS2812 RGB LED, a buzzer and two
|
||||
buttons, all pre‑wired. See **`/info-kit.html`** for the pinout, parts (~$45 incl. Pico) and
|
||||
flashing steps. Firmware lives in **`pico/`**:
|
||||
|
||||
- **`pico/main.py`** — single‑file **MicroPython** firmware: an ST7796 driver, GT911 touch,
|
||||
WS2812 RGB, PWM buzzer, ADC joystick, baked anti‑aliased fonts, and the polymeter engine.
|
||||
It parses the same program strings as the web editor. Flash MicroPython, copy `main.py`,
|
||||
edit the `PROGRAMS` list to change grooves. Download: `/pico-main.py`.
|
||||
- **`pico/gen_font.py`** — generates the baked anti‑aliased fonts (used by both firmwares).
|
||||
- **`pico-cp/`** — a **CircuitPython** edition (download `/pm_k1_circuitpy.zip`): the Pico mounts as a
|
||||
USB drive carrying the firmware + your `programs.json` + a copy of the editor, with a full lanes/pads
|
||||
touchscreen display. Design grooves on the web and **Save to device** straight onto the drive (the
|
||||
editor's ⋯ menu), and play it **out your computer's speakers over USB‑MIDI** (the editor's
|
||||
**🎹 Device audio** button). The MicroPython build stays the simple, no‑computer option.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Space` | play / stop (works everywhere except while typing in a text field) |
|
||||
| `T` | tap tempo |
|
||||
| `←` / `→` | tempo ±1 (`Shift` = ±10) |
|
||||
| `A` | add meter lane |
|
||||
| `↑` / `↓` / `Home` / `End` | move the **cue** cursor (crosses set lists) |
|
||||
| `PgUp` / `PgDn` | cue the previous / next set list |
|
||||
| `Enter` | commit the cued item — switches on the next **bar** (smooth) |
|
||||
| `Shift`+`Enter` | commit now — switches on the next **beat** (rude) |
|
||||
| `N` / `P` | load next / previous immediately (rude quick‑step) |
|
||||
| `Alt`+`↑` / `Alt`+`↓` | reorder the cued item |
|
||||
| `1`–`9` | enable / silence lane 1–9 |
|
||||
| `?` | shortcuts help |
|
||||
| `Esc` | close the help / share dialog · cancel an armed switch |
|
||||
|
||||
(Arrow / navigation keys are left alone while a slider or dropdown is focused, so they still adjust it.)
|
||||
|
||||
## Live performance
|
||||
|
||||
The set list is performance-ready: you can line up where you're going next without
|
||||
disturbing what's playing, then commit on a musical boundary — no audible gap.
|
||||
|
||||
- **Cue, then commit.** The arrows / `Home` / `End` / `PgUp` / `PgDn` move a *cue cursor*
|
||||
(amber outline) through items — across set lists, without loading anything. **`Enter`**
|
||||
commits with a **smooth** cutover at the next **bar**; **`Shift`+`Enter`** is a **rude**
|
||||
cutover at the next **beat**. `N` / `P` are immediate rude quick‑steps. `Esc` cancels.
|
||||
- **Bar‑length segments.** Give an item a **bar** count (Timers box, or `b<n>`) and a bar
|
||||
countdown (▦) shows bars remaining. With **Continue** on, it auto‑advances at the bar
|
||||
boundary — so a *song* is just a set list of segments that hand off seamlessly.
|
||||
- All transitions keep the clock continuous; the loaded item can live in a set list you're
|
||||
not currently viewing (the player names it).
|
||||
|
||||
## Build
|
||||
|
||||
Every page is a source that shares code through `@BUILD:*` markers, so they all stay in sync:
|
||||
|
||||
- `/*@BUILD:include:src/…@*/` inlines a **shared partial** — the audio/scheduler engine
|
||||
(`src/engine.js`), the seed set lists (`src/setlists.js`, so every page ships the **same
|
||||
default set lists**), base styling (`src/base.css`), the site **header/footer/chrome**
|
||||
(`src/header.html`, `src/footer.html`, `src/chrome.js`), the per‑device **program box**
|
||||
(`src/progbox.{html,js}`) and the info‑page **live‑widget embed** (`src/infoembed.{html,js}`).
|
||||
- `@BUILD:favicon@`, `@BUILD:logo-dark@`, `@BUILD:logo-light@` inline the base64 assets from
|
||||
`assets/` (the official logos already include the tagline).
|
||||
|
||||
`./build.sh` resolves every marker into a self‑contained page in `dist/` (the Concepts landing,
|
||||
the editor, the device/form‑factor pages and their `info-*.html`), copies `embed.js` through
|
||||
as‑is, and copies the Pico firmware to `dist/pico-main.py`. `dist/` is generated, git‑ignored —
|
||||
don't edit it by hand. `deploy.sh` runs the build first, so a deploy always serves freshly
|
||||
assembled pages.
|
||||
|
||||
## Versioning
|
||||
|
||||
`VERSION` holds the formal version. `deploy.sh` builds, then stamps the served page:
|
||||
|
||||
- **Formal** — a clean commit tagged `v<VERSION>` → `X.Y.Z`.
|
||||
- **Dev** — anything else → `X.Y.Z-dev.<utc-timestamp>.<short-sha>[.dirty]`.
|
||||
|
||||
Cut a release with `./release.sh [X.Y.Z]` — the optional arg bumps & commits `VERSION`; it then
|
||||
tags the current commit `v<VERSION>` (requires a clean tree). Push the tag, then deploy.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.html` | the **Concepts** landing / gallery (embeds each widget live) |
|
||||
| `editor.html` | the **PM_E‑1 editor** app (source, with `@BUILD:*` markers) |
|
||||
| `kit.html` · `player.html` · `teacher.html` · `stage.html` · `micro.html` · `showcase.html` | the device widget pages (PM_K‑1 Kit, PM_C‑1 Concept, Teacher, Stage, PM_P‑1 Practice, PM_D‑1 Display) |
|
||||
| `info-*.html` | per‑form‑factor spec pages (embed the live widget + description + dimensions + BOM) |
|
||||
| `embed.html` · `embed.js` | embed docs and the drop‑in widget loader |
|
||||
| `src/` | shared partials inlined into every page: `engine.js`, `setlists.js`, `base.css`, `header.html`, `footer.html`, `chrome.js`, `progbox.{html,js}`, `infoembed.{html,js}` |
|
||||
| `assets/` | base64 blobs inlined at build (`favicon`, `logo-dark`, `logo-light`) |
|
||||
| `pico/` | PM_K‑1 MicroPython firmware: `main.py`, `gen_font.py` (font generator), `README.md` |
|
||||
| `pico-cp/` | PM_K‑1 CircuitPython edition: `code.py`, `programs.json`, `font_*.bin`, `README.md` (bundled + served as `/pm_k1_circuitpy.zip`) |
|
||||
| `build.sh` | resolve markers → self‑contained `dist/` pages (+ `pico-main.py`) |
|
||||
| `deploy.sh` | build, then publish to the Caddy web root |
|
||||
| `release.sh` | tag a formal version |
|
||||
| `VERSION` | formal version string |
|
||||
| `LICENSE` | GNU AGPL v3 license text |
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL v3](./LICENSE) © VARASYS.
|
||||
Copyright (C) 2026 Varasys.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the
|
||||
**GNU Affero General Public License** as published by the Free Software Foundation, either
|
||||
version 3 of the License, or (at your option) any later version. See [`LICENSE`](LICENSE).
|
||||
|
||||
Because the app is served over a network, the AGPL's §13 applies: anyone interacting with a
|
||||
hosted instance must be able to get its source — the public repository is
|
||||
**<https://codeberg.org/VARASYS/metronome>** (also linked from the in‑app **?** help).
|
||||
|
||||
### Credits
|
||||
|
||||
All drum and percussion voices are **synthesized in Web Audio** (808/909‑style and electronic) —
|
||||
there are no audio samples. The on‑device fonts (PM_K‑1) are rendered from **DejaVu Sans**.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.0.68
|
||||
0.0.64
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
41
build.sh
41
build.sh
|
|
@ -1,23 +1,18 @@
|
|||
#!/usr/bin/env bash
|
||||
# Assemble the deployed single-file pages from source + shared partials + assets/.
|
||||
#
|
||||
# Each page is a source that shares code via markers:
|
||||
# Every page (the Concepts landing, the editor app, and the device/form-factor
|
||||
# pages) is a source that shares code via markers:
|
||||
# /*@BUILD:include:src/<file>@*/ inlines a shared partial (engine, seed lists, base CSS, header/footer/chrome)
|
||||
# @BUILD:favicon@ / @BUILD:logo-*@ / @BUILD:bravura@ inline base64 assets
|
||||
# @BUILD:favicon@ / @BUILD:logo-*@ inline base64 assets (voices are all synthesized — no samples)
|
||||
# This resolves them so each built page in dist/ is one self-contained file
|
||||
# (zero deps, works fully offline). deploy.sh runs this first. dist/ is generated —
|
||||
# don't edit or commit it.
|
||||
#
|
||||
# NOTE: this is the slim `main` branch — only the landing chooser, the mobile app
|
||||
# (+ its practice journal) and the pm_e-2 notation editor ship. The full
|
||||
# multi-form-factor project (all device pages, firmware, Rust, hardware) lives on
|
||||
# the `concepts` branch.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
rm -rf dist && mkdir -p dist # start clean so no stale artifact survives into deploy's rsync --delete
|
||||
|
||||
mkdir -p dist
|
||||
python3 - <<'PY'
|
||||
import pathlib, re
|
||||
import os, pathlib, re
|
||||
A = pathlib.Path("assets")
|
||||
|
||||
def build(name):
|
||||
|
|
@ -25,25 +20,27 @@ def build(name):
|
|||
# 1) inline shared partials (function-replacement: no backslash/group interpretation)
|
||||
src = re.sub(r"/\*@BUILD:include:([^@]+)@\*/",
|
||||
lambda m: pathlib.Path(m.group(1)).read_text().rstrip("\n"), src)
|
||||
# 2) inline base64 assets (all voices are synthesized — no audio samples)
|
||||
# 2) inline base64 assets (voices are all synthesized now — no samples)
|
||||
src = src.replace("@BUILD:favicon@", (A / "favicon.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-dark@", (A / "logo-dark.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-light@", (A / "logo-light.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-side-dark@", (A / "logo-side-dark.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-side-light@", (A / "logo-side-light.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:bravura@", (A / "bravura.woff2.b64").read_text().strip()) # SMuFL music font subset (PM_E-2 notation)
|
||||
assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}"
|
||||
out = pathlib.Path("dist") / name
|
||||
out.write_text(src)
|
||||
return out.stat().st_size
|
||||
|
||||
for name in ("index.html", "mobile.html", "mobile-sessions.html", "pm_e-2.html"):
|
||||
for name in ("index.html","editor.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html",
|
||||
"embed.html",
|
||||
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html"):
|
||||
print("built %s (%dKB)" % (name, build(name) // 1024))
|
||||
|
||||
# PWA support files for mobile.html (the phone/tablet app): manifest, service worker, icons.
|
||||
for f in ("manifest.webmanifest", "mobile-sw.js"):
|
||||
pathlib.Path("dist/" + f).write_text(pathlib.Path(f).read_text())
|
||||
for f in ("icon-192.png", "icon-512.png", "icon-180.png"):
|
||||
pathlib.Path("dist/" + f).write_bytes((A / f).read_bytes())
|
||||
print("copied PWA files (manifest.webmanifest, mobile-sw.js, icon-{192,512,180}.png)")
|
||||
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
|
||||
print("copied embed.js")
|
||||
pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_text()) # PM_K-1 firmware, downloadable
|
||||
print("copied pico-main.py")
|
||||
import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY)
|
||||
with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", "README.md"):
|
||||
z.write("pico-cp/" + f, f)
|
||||
z.write("dist/editor.html", "editor.html") # offline copy of the editor, on the drive
|
||||
print("zipped pm_k1_circuitpy.zip")
|
||||
PY
|
||||
|
|
|
|||
37
deploy.sh
37
deploy.sh
|
|
@ -5,10 +5,9 @@
|
|||
# Caddy config: /var/lib/caddy/Caddyfile (metronome.varasys.io:8443 block)
|
||||
# Bind-mount: /etc/containers/systemd/caddy.container
|
||||
#
|
||||
# The web root is bind-mounted read-only into the Caddy container and served by
|
||||
# file_server, which picks up changes immediately — so a plain file copy is all
|
||||
# that's needed (no container restart). The web root is mirrored from dist/ with
|
||||
# `rsync --delete`, so anything no longer built is removed from the live site.
|
||||
# The web root is bind-mounted read-only into the Caddy container and
|
||||
# served by file_server, which picks up changes immediately — so a plain
|
||||
# file copy is all that's needed (no container restart).
|
||||
set -euo pipefail
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
|
@ -39,20 +38,28 @@ else
|
|||
BUILD="$VER-dev.$(date -u +%Y%m%dT%H%M%SZ)" # not a git checkout
|
||||
fi
|
||||
|
||||
# stamp the version into the built copies only (source keeps the APP_VERSION placeholder)
|
||||
for f in index.html mobile.html mobile-sessions.html pm_e-2.html; do
|
||||
[[ -f "$DIST_DIR/$f" ]] || continue
|
||||
tmp="$(mktemp)"
|
||||
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$tmp"
|
||||
mv "$tmp" "$DIST_DIR/$f"
|
||||
done
|
||||
|
||||
# Mirror dist/ -> web root, deleting anything that's no longer built (old pages, firmware, …)
|
||||
rsync -a --delete "$DIST_DIR/" "$DEST_DIR/"
|
||||
# stamp the version into the built copy only (source stays clean)
|
||||
echo "deployed v$BUILD -> $DEST_DIR"
|
||||
for f in index.html mobile.html mobile-sessions.html pm_e-2.html; do
|
||||
for f in index.html editor.html player.html teacher.html stage.html micro.html showcase.html kit.html \
|
||||
embed.html \
|
||||
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html; do
|
||||
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
|
||||
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
||||
done
|
||||
cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)"
|
||||
cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download
|
||||
cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle
|
||||
rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html
|
||||
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/)
|
||||
# info-*.html are first-class pages again: each form factor has a lean widget page
|
||||
# (<device>.html) + a separate spec/BOM page (info-<device>.html that embeds it).
|
||||
|
||||
# If real audio samples are added later (see the plan's GM-sample note),
|
||||
# sync that directory too.
|
||||
if [[ -d "$SRC_DIR/samples" ]]; then
|
||||
rsync -a --delete "$SRC_DIR/samples/" "$DEST_DIR/samples/"
|
||||
echo "synced samples/ -> $DEST_DIR/samples"
|
||||
fi
|
||||
|
||||
# Smoke test: Caddy serves on :8443 with tls internal; resolve the host
|
||||
# to localhost so SNI matches the site block.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PM_E‑2 — PolyMeter Editor (Notation)</title>
|
||||
<title>PM_E‑1 — PolyMeter Editor</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
/* ?embed=1 → strip site chrome (base.css [data-embed]) + auto-size to the host */
|
||||
|
|
@ -72,33 +72,18 @@
|
|||
.kbd-legend { color:var(--muted); font-size:13px; font-family:"Courier New",monospace; text-align:left; line-height:1.75; }
|
||||
.kbd-legend span { white-space:nowrap; } /* wrap only between shortcut groups, never mid-token */
|
||||
.appheader-ctrls { margin-left:auto; } /* push controls right on wide; narrow media query resets to left */
|
||||
#app { display:flex; gap:18px; max-width:none; margin:0 auto; align-items:flex-start; justify-content:center; }
|
||||
/* Logo lives in the device's top-left corner (not the header); header fills + keeps just nav. */
|
||||
.site-head, .site-foot { max-width:none; }
|
||||
.site-head .brand { display:none; }
|
||||
.site-head { justify-content:flex-end; }
|
||||
.devbrand { line-height:0; }
|
||||
.devbrand .brand-logo { height:30px; }
|
||||
.device { flex:1 1 auto; min-width:0; max-width:none; background:linear-gradient(180deg, var(--panel), var(--bg));
|
||||
#app { display:flex; gap:18px; max-width:1400px; margin:0 auto; align-items:flex-start; justify-content:center; }
|
||||
.device { flex:1 1 auto; min-width:0; max-width:1000px; background:linear-gradient(180deg, var(--panel), var(--bg));
|
||||
border:1px solid var(--edge); border-radius:16px; padding:18px; box-shadow:0 18px 50px rgba(0,0,0,.5); }
|
||||
.row { display:flex; gap:18px; flex-wrap:wrap; }
|
||||
.card { background:var(--panel-2); border:1px solid var(--edge); border-radius:12px; padding:13px; flex:1; min-width:240px; }
|
||||
.card h2 { font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0 0 14px; }
|
||||
/* Top display panel: compact, evenly spaced. A flex column with a small,
|
||||
uniform gap replaces the old per-element margins (which stacked into big gaps). */
|
||||
.display { background:#0a0d11; border:1px solid #000; border-radius:8px; padding:10px 14px; text-align:center; box-shadow:inset 0 2px 10px rgba(0,0,0,.7);
|
||||
display:flex; flex-direction:column; align-items:center; gap:6px; }
|
||||
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:72px; color:#ffd166; letter-spacing:2px; line-height:1.0; text-shadow:0 0 12px rgba(255,209,102,.5); }
|
||||
.display .dtimers { font-family:"Courier New",monospace; font-size:24px; color:#4dd0e1; margin:0; display:flex; gap:8px 16px; justify-content:center; flex-wrap:wrap; line-height:1.1; }
|
||||
.display { background:#0a0d11; border:1px solid #000; border-radius:8px; padding:8px 14px; text-align:center; box-shadow:inset 0 2px 10px rgba(0,0,0,.7); }
|
||||
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:80px; color:#ffd166; letter-spacing:2px; line-height:1.0; text-shadow:0 0 12px rgba(255,209,102,.5); }
|
||||
.display .dtimers { font-family:"Courier New",monospace; font-size:26px; color:#4dd0e1; margin:6px 0; display:flex; gap:18px; justify-content:center; flex-wrap:wrap; }
|
||||
.display .dtimers[hidden] { display:none; }
|
||||
.display .ctx { font-family:"Courier New",monospace; font-size:18px; color:#4dd0e1; min-height:20px; line-height:1.2; }
|
||||
.display .ctx { font-family:"Courier New",monospace; font-size:19px; color:#4dd0e1; min-height:22px; line-height:1.25; }
|
||||
.display .ctx.muted-cue { color:#ffb454; }
|
||||
/* Gap-trainer indicator in the display: persistent green "GAP p/m" while armed;
|
||||
turns amber while the current bars are muted (count-along cue). */
|
||||
.display .gap-ind { color:#5fd08a; font-size:20px; }
|
||||
.display .gap-ind.muting { color:#ffb454; }
|
||||
/* PM_E-2: inactive function slots stay visible but dimmed (always-on status row) */
|
||||
.display .dtimers .off { opacity:.28; }
|
||||
.knob { margin-bottom:10px; }
|
||||
.knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; }
|
||||
.knob label b { color:#fff; font-variant-numeric:tabular-nums; }
|
||||
|
|
@ -257,28 +242,6 @@
|
|||
.menu button { text-align:left; }
|
||||
/* embed mode: drop the header + legend, keep the editor */
|
||||
[data-embed] .appheader, [data-embed] .kbd-legend { display:none !important; }
|
||||
/* --- PM_E-2 notation surface --- */
|
||||
@font-face { font-family:"Bravura"; src:url("data:font/woff2;base64,@BUILD:bravura@") format("woff2"); font-display:block; }
|
||||
/* engraved like paper: white sheet, dark ink (theme-independent) */
|
||||
.staffwrap { background:#ffffff; border:1px solid var(--edge); border-radius:12px; padding:6px 10px;
|
||||
box-shadow:0 1px 6px rgba(0,0,0,.28); margin:2px 0; }
|
||||
.staffwrap canvas { width:100%; display:block; cursor:pointer; }
|
||||
.staffwrap.konn canvas { cursor:default; } /* konnakol is read-only */
|
||||
#konnakolCanvas { height:84px; }
|
||||
#staffCanvas { height:172px; }
|
||||
#tubsCanvas { height:168px; }
|
||||
/* collapsible sections */
|
||||
.sect { margin:8px 0; }
|
||||
.sect > summary { cursor:pointer; list-style:none; font-size:11px; text-transform:uppercase; letter-spacing:1.2px; color:var(--muted); padding:4px 0; user-select:none; }
|
||||
.sect > summary::-webkit-details-marker { display:none; }
|
||||
.sect > summary::before { content:"\25be\00a0"; } /* ▾ open */
|
||||
.sect:not([open]) > summary::before { content:"\25b8\00a0"; } /* ▸ closed */
|
||||
.sect > summary .shint { text-transform:none; letter-spacing:normal; font-size:11px; opacity:.7; }
|
||||
/* the two device controls in the header — matching pills (state shown via colour/border in JS) */
|
||||
.devctrl{ font:inherit; font-size:12px; line-height:1.5; padding:3px 10px; border-radius:7px;
|
||||
border:1px solid var(--edge); background:transparent; color:var(--muted);
|
||||
white-space:nowrap; cursor:pointer; }
|
||||
.devctrl:hover{ border-color:var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -288,21 +251,8 @@
|
|||
<div id="app">
|
||||
<div class="device">
|
||||
<div class="row appheader" style="align-items:center; flex-wrap:wrap; gap:6px 14px; margin-bottom:8px">
|
||||
<a class="brand devbrand" href="/" title="VARASYS — Simplifying Complexity" style="flex:0 0 auto">
|
||||
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
|
||||
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
|
||||
</a>
|
||||
<h1 style="margin:0">PM_E‑2 <span style="font-weight:400; opacity:.75">PolyMeter Editor</span> <span style="font-weight:400; opacity:.5; font-size:12px">Notation</span></h1>
|
||||
<div class="appheader-ctrls" style="display:flex; align-items:center; gap:8px">
|
||||
<span id="devBadge" class="devctrl"
|
||||
title="Device link (USB-MIDI). Click to connect — Chrome, Edge or Firefox. Turns green and shows the name while a PM device (PM_K-1 / PM_X-1 / PM_G-1) is plugged in; ◎ means none is detected. This only reports the connection — it does not make sound on its own.">◎ connect device</span>
|
||||
<button id="midiBtn" class="devctrl"
|
||||
title="Device audio — on/off switch. When ON, the notes a connected device sends over USB-MIDI are played through THIS computer's speakers (the device drives the sound, locked to its clock). You can switch it on before plugging in — it doesn't need a device to toggle; plug one in while it's on and it sounds through the computer.">🎹 Device audio</button>
|
||||
<button id="midiOutBtn" class="devctrl"
|
||||
title="MIDI out — on/off switch. When ON, the groove is sent as MIDI notes to external gear (a drum module / e-kit) on the output port chosen at right: GM drum notes on channel 10, scheduled tightly in sync with playback. Independent of the local synth and of Device audio. Web MIDI · Chrome/Edge/Firefox.">🎛 MIDI out</button>
|
||||
<select id="midiOutSel" class="devctrl" title="MIDI output port to drive (your drum module / e-kit)" hidden></select>
|
||||
<label id="midiClkWrap" class="devctrl" hidden style="display:inline-flex; align-items:center; gap:5px"
|
||||
title="Also send 24-PPQN MIDI clock + Start/Stop on this port, so the gear's tempo/sequencer locks to the editor."><input type="checkbox" id="midiClkChk" checked style="margin:0"> clock</label>
|
||||
<h1 style="margin:0">PM_E‑1 <span style="font-weight:400; opacity:.75">PolyMeter Editor</span></h1>
|
||||
<div class="appheader-ctrls" style="display:flex; align-items:center; gap:10px">
|
||||
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -317,14 +267,12 @@
|
|||
<div class="big" id="bpmDisplay">120</div>
|
||||
<div class="dtimers" id="dtimers">
|
||||
<span title="elapsed (stopwatch)">⏱ <span id="elapsedVal">0:00</span></span>
|
||||
<span id="rampWrap" class="tval" title="tempo ramp" hidden></span>
|
||||
<span id="gapWrap" class="tval gap-ind" title="gap / mute trainer — plays N bars, mutes M bars" hidden></span>
|
||||
<span id="countWrap" title="time countdown" hidden>⏳ <span id="countVal" class="tval">0:00</span></span>
|
||||
<span id="barWrap" title="bars remaining in this segment" hidden>▦ <span id="barVal" class="tval">0</span></span>
|
||||
</div>
|
||||
<div class="ctx" id="ctxDisplay"> </div>
|
||||
</div>
|
||||
<div class="btnrow" style="margin-top:10px"><button class="primary" id="startBtn">▶ Start</button><button id="tapBtn">Tap</button><span id="saveItemWrap" style="display:inline-flex" title="Select a set-list item to enable Save"><button id="saveItemBtn" disabled>💾 Save</button></span></div>
|
||||
<div class="btnrow" style="margin-top:10px"><button class="primary" id="startBtn">▶ Start</button><button id="tapBtn">Tap</button><span id="saveItemWrap" style="display:inline-flex" title="Select a set-list item to enable Save"><button id="saveItemBtn" disabled>💾 Save</button></span><button id="midiBtn" title="Play a connected PM_K-1 device through this computer's speakers (Web MIDI · Chrome/Edge)">🎹 Device audio</button></div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; min-width:200px">
|
||||
|
|
@ -369,18 +317,6 @@
|
|||
<label style="font-size:12px">Bars <input type="number" class="num" id="segBarsIn" min="0" max="999" value="0" title="segment length in bars (0 = manual). With Continue on, auto-advances to the next item at the bar boundary." style="width:64px"></label>
|
||||
<span class="hint" style="margin:0">0 = manual</span>
|
||||
</div>
|
||||
<div class="row" style="gap:10px; align-items:center; margin-top:6px">
|
||||
<label style="font-size:12px">At end
|
||||
<select class="txt" id="endAction" title="What this track does after 'rep' cycles (a cycle = Bars above, else one bar). Loop = the metronome default; explicit choices override the global Continue toggle." style="width:108px">
|
||||
<option value="loop">loop forever</option>
|
||||
<option value="next">next track</option>
|
||||
<option value="stop">stop</option>
|
||||
<option value="goto">goto ±</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="font-size:12px" id="gotoWrap" hidden>by <input type="number" class="num" id="endGoto" min="-99" max="99" value="-1" title="relative track offset: -2 = back two (D.S.), +1 = next" style="width:52px"></label>
|
||||
<label style="font-size:12px" id="repWrap">× <input type="number" class="num" id="endRep" min="1" max="99" value="1" title="cycles before the end-action fires" style="width:52px"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -388,27 +324,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PM_E-2: Lanes config first, then three collapsible notation lenses (beats aligned across all). -->
|
||||
<details class="sect" open>
|
||||
<summary>Lanes <span class="shint">— add voices; set grouping, subdivision, swing, polyrhythm, gain</span></summary>
|
||||
<div id="meters"></div>
|
||||
<div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></div>
|
||||
</details>
|
||||
|
||||
<div class="hint" style="margin:12px 0 2px">Click a step on the <b>Staff</b> or <b>TUBS</b> grid to cycle <b>accent → normal → ghost → rest</b>; <b>Shift-click</b> cycles <b>flam → drag → roll</b>.</div>
|
||||
|
||||
<details class="sect" open>
|
||||
<summary>Konnakol</summary>
|
||||
<div class="staffwrap konn"><canvas id="konnakolCanvas"></canvas></div>
|
||||
</details>
|
||||
<details class="sect" open>
|
||||
<summary>Staff</summary>
|
||||
<div class="staffwrap"><canvas id="staffCanvas"></canvas></div>
|
||||
</details>
|
||||
<details class="sect" open>
|
||||
<summary>TUBS</summary>
|
||||
<div class="staffwrap tubs"><canvas id="tubsCanvas"></canvas></div>
|
||||
</details>
|
||||
<div class="row" style="align-items:center; gap:12px; margin:14px 0 8px">
|
||||
<h2 style="font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0">Meter lanes</h2>
|
||||
<span class="hint" style="margin:0; flex:1">Each beat splits into <i>subdivision</i> pads — click a pad to cycle <b>accent → normal → ghost → mute</b>. Pick a <i>swing</i> subdivision for a triplet feel.</span>
|
||||
</div>
|
||||
<div id="meters"></div>
|
||||
<div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></div>
|
||||
|
||||
<!-- (presets moved into the Transport card; set lists live in the slide-out tray;
|
||||
status now shows under the BPM in the display) -->
|
||||
|
|
@ -426,9 +347,8 @@
|
|||
<button id="exportBtn">⭳ Export all (file)</button>
|
||||
<button id="importBtn">⭱ Import file…</button>
|
||||
<input type="file" id="importFile" accept="application/json" style="display:none">
|
||||
<button id="saveDeviceBtn" title="Sync your own set lists to the PM_K-1 (the built-in playlists are baked into its firmware)">📟 Save to device</button>
|
||||
<button id="saveDeviceBtn" title="Write this set list to a PM_K-1 device drive (programs.json)">📟 Save to device</button>
|
||||
<button id="loadDeviceBtn" title="Load a device's programs.json into a new set list">📥 Load from device</button>
|
||||
<button id="updateFwBtn" title="Check & update the PM_K-1 firmware over USB-MIDI (A/B with auto-rollback)">⬆ Update firmware…</button>
|
||||
<button id="clearLogBtn">🗑 Clear log</button>
|
||||
<button id="resetAllBtn" style="color:#ff7b6b">♻ Reset everything…</button>
|
||||
</div>
|
||||
|
|
@ -453,18 +373,7 @@
|
|||
</div>
|
||||
<div class="hint" style="margin-top:6px"><kbd>Alt+↑/↓</kbd> reorder · 💾 save settings to an item.</div>
|
||||
|
||||
<!-- Practice-log header + "log sessions" toggle. When OFF, played sessions are NOT
|
||||
appended to the practice log (for noodling / adjusting); existing entries are kept.
|
||||
Persisted in localStorage (LS.logging); default ON. -->
|
||||
<div class="practice" style="margin-top:18px">
|
||||
<div class="tray-head" style="margin-bottom:8px">
|
||||
<h2 style="margin:0">Practice log</h2>
|
||||
<label class="mini-check" id="loggingToggle" title="When off, sessions you play are NOT recorded to the practice log — handy while adjusting settings or just playing around. Existing history is kept.">
|
||||
<input type="checkbox" id="logSessions" checked> log sessions
|
||||
</label>
|
||||
</div>
|
||||
<div id="logView"></div>
|
||||
</div>
|
||||
<div id="logView" style="margin-top:18px"></div>
|
||||
</aside>
|
||||
</div><!-- /#app -->
|
||||
|
||||
|
|
@ -526,7 +435,7 @@ const APP_VERSION = "0.0.1-dev";
|
|||
/* =========================================================================
|
||||
STATE
|
||||
========================================================================= */
|
||||
const state = { bpm: 120, volume: 0.7, running: false, rep: null, end: null };
|
||||
const state = { bpm: 120, volume: 0.7, running: false };
|
||||
const trainer = { on: false, playBars: 2, muteBars: 2 };
|
||||
const ramp = { on: false, startBpm: 80, amount: 5, everyBars: 4 };
|
||||
|
||||
|
|
@ -538,7 +447,6 @@ let meterSeq = 0; // id counter
|
|||
Inlined from src/engine.js by build.sh; identical in player.html. ---- */
|
||||
const SAMPLES = {}; // all voices are synthesized (samples removed; 808/909 renders are the kit)
|
||||
/*@BUILD:include:src/engine.js@*/
|
||||
/*@BUILD:include:src/notation.js@*/
|
||||
|
||||
/* =========================================================================
|
||||
SCHEDULER (PORTS TO FIRMWARE)
|
||||
|
|
@ -589,7 +497,6 @@ function scheduler() {
|
|||
}
|
||||
// Boundary reached → swap to the new segment seamlessly (scheduler keeps running).
|
||||
if (pendingSwitch && masterBeatTime >= pendingSwitch.atTime - 1e-9) performCutover(pendingSwitch);
|
||||
midiOutClock(ahead); // schedule 24-PPQN MIDI clock ticks across the same look-ahead window
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
|
|
@ -602,12 +509,10 @@ function start() {
|
|||
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 = [];
|
||||
midiOutStart(t0); // MIDI clock Start (if MIDI out + clock on)
|
||||
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
|
||||
scheduler(); syncStartBtn();
|
||||
}
|
||||
function stop() {
|
||||
midiOutStop(); // MIDI clock Stop (if MIDI out + clock on)
|
||||
state.running = false;
|
||||
clearInterval(schedulerTimer); schedulerTimer = null;
|
||||
pendingSwitch = null; segBarCount = 0; // drop any armed switch so it can't fire on next start
|
||||
|
|
@ -658,14 +563,13 @@ function setBpm(v) {
|
|||
========================================================================= */
|
||||
function laneColor(id) { return `hsl(${(id * 67) % 360} 70% 62%)`; }
|
||||
|
||||
function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = null, poly = false, swing = false, gainDb = 0, orns = null) {
|
||||
function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = null, poly = false, swing = false, gainDb = 0) {
|
||||
const id = ++meterSeq;
|
||||
const p = parseGroups(groupsStr);
|
||||
const m = {
|
||||
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts,
|
||||
stepsPerBeat, sound, enabled: true, poly: !!poly, swing: !!swing, gainDb: gainDb || 0, color: laneColor(id),
|
||||
beatsOn: beatsOn ? beatsOn.slice() : [], // per-STEP dynamics mask (one entry per pad: 0 mute / 1 normal / 2 accent)
|
||||
orns: orns ? orns.slice() : [], // per-STEP ornament (0 none / 1 flam / 2 drag / 3 roll), parallel to beatsOn
|
||||
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentStep: -1, currentBar: 0,
|
||||
el: null, stripEl: null, barEl: null,
|
||||
};
|
||||
|
|
@ -714,11 +618,15 @@ function buildLaneCard(m) {
|
|||
</select>
|
||||
<select class="cmp" id="m${m.id}_sound" title="sound">${VOICES.map(([v, n]) => `<option value="${v}">${n}</option>`).join("")}</select>
|
||||
<span class="gain" id="m${m.id}_gain" title="lane gain (dB) — drag up/down · scroll · double‑click resets to 0">0 dB</span>
|
||||
<div class="strip" id="m${m.id}_strip"></div>
|
||||
<span class="bar" id="m${m.id}_bar">—</span>
|
||||
<label class="mini-check" title="polyrhythm: fit these beats evenly into lane 1's bar"><input type="checkbox" id="m${m.id}_poly"> poly</label>
|
||||
<button class="x" id="m${m.id}_remove" title="remove lane">✕</button>
|
||||
</div>`;
|
||||
document.getElementById("meters").appendChild(card);
|
||||
m.el = card;
|
||||
m.stripEl = card.querySelector(`#m${m.id}_strip`);
|
||||
m.barEl = card.querySelector(`#m${m.id}_bar`);
|
||||
m.titleEl = card.querySelector(`#m${m.id}_title`);
|
||||
|
||||
// wire controls
|
||||
|
|
@ -769,34 +677,70 @@ function recomputeLane(m) {
|
|||
// Remap the dynamics mask to step resolution (beats × subdivision = one pad each),
|
||||
// preserving levels where they line up and defaulting new pads (first-of-beat accent, rest normal).
|
||||
const spb = m.stepsPerBeat;
|
||||
const prev = m.beatsOn || [], prevOrn = m.orns || [], oldBpb = m._maskBpb || 0, oldSpb = m._maskSpb || 1;
|
||||
const next = [], ornNext = [];
|
||||
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 = stepDefault(s), orn = 0;
|
||||
let val = stepDefault(s);
|
||||
if (b < oldBpb) { // this beat existed before
|
||||
if (oldSpb === spb) { val = normLevel(prev[b * oldSpb + s], stepDefault(s)); orn = prevOrn[b * oldSpb + s] | 0; } // same resolution → step-for-step
|
||||
else if (s === 0) { val = normLevel(prev[b * oldSpb], 2); orn = prevOrn[b * oldSpb] | 0; } // resolution changed → keep the downbeat; new subs default
|
||||
if (oldSpb === spb) val = normLevel(prev[b * oldSpb + s], stepDefault(s)); // same resolution → step-for-step
|
||||
else if (s === 0) val = normLevel(prev[b * oldSpb], 2); // resolution changed → keep the downbeat; new subs default
|
||||
}
|
||||
next.push(val); ornNext.push(orn);
|
||||
next.push(val);
|
||||
}
|
||||
}
|
||||
m.beatsOn = next; m.orns = ornNext; m._maskBpb = m.beatsPerBar; m._maskSpb = spb;
|
||||
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) { // one pad per STEP (beats × subdivision)
|
||||
m.stripEl.innerHTML = "";
|
||||
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 = (s === 0) ? (b + 1) : ""; // label downbeats; subdivisions blank
|
||||
cell.style.cursor = "pointer";
|
||||
cell.title = "click: accent → normal → ghost → mute · beat " + (b + 1) + (s ? " · sub " + (s + 1) : "");
|
||||
cell.addEventListener("click", () => { m.beatsOn[i] = NEXT_LEVEL[m.beatsOn[i] | 0]; renderLaneStrip(m); });
|
||||
m.stripEl.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLaneStrip(m) {
|
||||
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 lvl = m.beatsOn[i] | 0, 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 (lvl >= 1) cls += " on"; // normal / accent / ghost → lit
|
||||
if (gs) cls += " groupstart"; // group divider (layout only)
|
||||
if (lvl === 2) cls += " accent"; // accented step (▲)
|
||||
else if (lvl === 3) cls += " ghost"; // ghost note (faint ·)
|
||||
cell.className = cls;
|
||||
cell.style.setProperty("--lc", m.color);
|
||||
if (state.running && i === m.currentStep) cell.classList.add("playhead");
|
||||
}
|
||||
if (m.barEl) m.barEl.textContent = state.running ? "bar " + (m.currentBar + 1) : "—";
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
PRESETS (localStorage)
|
||||
========================================================================= */
|
||||
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue", timers: "metronome.timers", logging: "metronome.logging" };
|
||||
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue", timers: "metronome.timers" };
|
||||
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)); return true; } catch (e) { console.warn("localStorage unavailable", e); return false; } }
|
||||
|
||||
function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, enabled: m.enabled, poly: m.poly, swing: !!m.swing, gainDb: m.gainDb || 0, beatsOn: m.beatsOn.slice(), orns: (m.orns || []).slice() })); }
|
||||
function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, enabled: m.enabled, poly: m.poly, swing: !!m.swing, gainDb: m.gainDb || 0, beatsOn: m.beatsOn.slice() })); }
|
||||
function applyLanes(lanes) {
|
||||
while (meters.length) removeMeter(meters[0].id);
|
||||
for (const c of lanes) {
|
||||
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly, c.swing, c.gainDb, c.orns);
|
||||
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly, c.swing, c.gainDb);
|
||||
const m = meters[meters.length - 1];
|
||||
setLaneEnabled(m, c.enabled !== undefined ? !!c.enabled : (c.mute === undefined ? true : !c.mute)); // back-compat with old "mute"
|
||||
}
|
||||
|
|
@ -821,16 +765,14 @@ let nowPlaying = null; // { at, name } for duration logging
|
|||
let historyName = null; // item whose past-session history is shown
|
||||
let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends
|
||||
let timersOn = lsGet(LS.timers, true); // master switch for the elapsed/countdown timers
|
||||
let loggingOn = lsGet(LS.logging, true); // master switch for recording practice-log sessions (default ON)
|
||||
|
||||
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars, rep: state.rep, end: state.end }; }
|
||||
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars }; }
|
||||
function applySetup(s) {
|
||||
setBpm(s.bpm); applyLanes(s.lanes);
|
||||
if (s.trainer) Object.assign(trainer, s.trainer);
|
||||
if (s.ramp) Object.assign(ramp, s.ramp);
|
||||
timers.totalMs = s.countMs || 0; timers.remainingMs = timers.totalMs; // per-item time countdown
|
||||
segBars = s.bars || 0; segBarCount = 0; // per-item bar-length + counter
|
||||
state.rep = s.rep != null ? s.rep : null; state.end = s.end != null ? s.end : null; // per-track playback flow (preserved on round-trip)
|
||||
syncPracticeUI(); updateCtx();
|
||||
}
|
||||
function syncPracticeUI() {
|
||||
|
|
@ -838,30 +780,8 @@ function syncPracticeUI() {
|
|||
$("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars;
|
||||
$("countTime").value = timers.totalMs > 0 ? fmtClock(timers.totalMs) : "";
|
||||
$("segBarsIn").value = segBars || 0;
|
||||
syncEndActionUI();
|
||||
refreshFeatureBoxes(); renderTimers();
|
||||
}
|
||||
// Per-track playback flow control: state.end (null | 'stop' | int offset) + state.rep (cycles).
|
||||
function syncEndActionUI() {
|
||||
const end = state.end;
|
||||
const action = end == null ? "loop" : end === "stop" ? "stop" : end === 1 ? "next" : "goto";
|
||||
$("endAction").value = action;
|
||||
$("gotoWrap").hidden = action !== "goto";
|
||||
$("repWrap").hidden = action === "loop";
|
||||
if (action === "goto") $("endGoto").value = typeof end === "number" ? end : -1;
|
||||
$("endRep").value = state.rep && state.rep > 1 ? state.rep : 1;
|
||||
}
|
||||
function readEndActionUI() {
|
||||
const action = $("endAction").value;
|
||||
$("gotoWrap").hidden = action !== "goto";
|
||||
$("repWrap").hidden = action === "loop";
|
||||
const rep = Math.max(1, parseInt($("endRep").value, 10) || 1);
|
||||
if (action === "loop") { state.end = null; state.rep = null; }
|
||||
else if (action === "next") { state.end = 1; state.rep = rep; }
|
||||
else if (action === "stop") { state.end = "stop"; state.rep = rep; }
|
||||
else { state.end = parseInt($("endGoto").value, 10) || 0; state.rep = rep; }
|
||||
updateCtx();
|
||||
}
|
||||
function refreshFeatureBoxes() {
|
||||
$("trainerBox").classList.toggle("on", trainer.on);
|
||||
$("rampBox").classList.toggle("on", ramp.on);
|
||||
|
|
@ -1047,7 +967,6 @@ function renderItems() {
|
|||
// --- practice log (flat entries, one per played item) ---
|
||||
function logFinalize() {
|
||||
if (!nowPlaying) return;
|
||||
if (!loggingOn) { nowPlaying = null; return; } // logging off → discard this session, keep existing history
|
||||
const logs = lsGet(LS.logs, []);
|
||||
logs.unshift({ at: nowPlaying.at, name: nowPlaying.name, durationSec: (Date.now() - nowPlaying.at) / 1000, bpm: state.bpm, lanes: snapshotLanes() });
|
||||
lsSet(LS.logs, logs); nowPlaying = null; renderLog();
|
||||
|
|
@ -1129,7 +1048,7 @@ function importAll(file) {
|
|||
Patch: v1;t<bpm>;vol<pct>;<lane>;…[;tr<play>/<mute>][;rmp<start>/<step>/<every>]
|
||||
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! disabled]
|
||||
========================================================================= */
|
||||
function currentPatch() { return setupToPatch({ bpm: state.bpm, volume: state.volume, lanes: snapshotLanes(), trainer, ramp, rep: state.rep, end: state.end }); }
|
||||
function currentPatch() { return setupToPatch({ bpm: state.bpm, volume: state.volume, lanes: snapshotLanes(), trainer, ramp }); }
|
||||
function setVolume(pct) {
|
||||
state.volume = Math.max(0, Math.min(1, pct / 100));
|
||||
$("vol").value = Math.round(state.volume * 100); volVal.textContent = Math.round(state.volume * 100) + "%";
|
||||
|
|
@ -1155,59 +1074,33 @@ function shareSetlist() {
|
|||
/* Device (PM_K-1) programs.json — the same grooves the firmware reads.
|
||||
Save: writes the active set list straight onto the CIRCUITPY drive (File System Access,
|
||||
Chrome/Edge) or downloads it to drag on. Load: reads a programs.json into a new set list. */
|
||||
// The PM_K-1's built-in playlists are baked into its firmware (they update with firmware and are
|
||||
// read-only). "Save to device" syncs only YOUR set lists — i.e. the ones that aren't the built-in demos.
|
||||
function userSetlists() {
|
||||
const seed = new Set(SEED_SETLISTS.map((s) => s.title));
|
||||
return setlists.filter((sl) => !seed.has(sl.title));
|
||||
}
|
||||
function programsJSON() {
|
||||
return JSON.stringify({ setlists: userSetlists().map((sl) => ({
|
||||
title: sl.title || "My set list",
|
||||
programs: sl.items.map((it) => ({ name: it.name, prog: setupToPatch(it) })) })) }, null, 2);
|
||||
const sl = getSL(); if (!sl) return null;
|
||||
return JSON.stringify({ title: sl.title || "PolyMeter", programs: sl.items.map((it) => ({ name: it.name, prog: setupToPatch(it) })) }, null, 2);
|
||||
}
|
||||
function _downloadPrograms(json) {
|
||||
async function saveToDevice() {
|
||||
const json = programsJSON(); if (!json) return alert("No set list selected to save.");
|
||||
if (window.showSaveFilePicker) {
|
||||
try {
|
||||
const h = await showSaveFilePicker({ suggestedName: "programs.json",
|
||||
types: [{ description: "PolyMeter programs", accept: { "application/json": [".json"] } }] });
|
||||
const w = await h.createWritable(); await w.write(json); await w.close();
|
||||
return alert("Saved programs.json — pick your CIRCUITPY drive and the device auto-reloads with these grooves.");
|
||||
} catch (e) { if (e.name === "AbortError") return; } // cancelled or unsupported → fall through to download
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([json], { type: "application/json" }));
|
||||
a.download = "programs.json"; a.click(); URL.revokeObjectURL(a.href);
|
||||
}
|
||||
async function saveToDevice() {
|
||||
const uls = userSetlists();
|
||||
if (!uls.length) return alert("No custom set lists to save.\n\nThe built-in playlists (Styles / Practice / Song) are baked into the device firmware — they're always there, update with firmware, and can't be changed. Create your own set list and it'll save here.");
|
||||
const json = programsJSON();
|
||||
// Primary: push to the device over USB-MIDI SysEx (Chromium/Firefox); the firmware writes programs.json.
|
||||
if (await _ensureMidi() && _midiOutputs().length) {
|
||||
const ascii = json.replace(/[\u0080-\uFFFF]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")); // 7-bit-safe JSON
|
||||
const bytes = [0xF0, 0x7D, 0x10];
|
||||
for (let i = 0; i < ascii.length; i++) bytes.push(ascii.charCodeAt(i) & 0x7F);
|
||||
bytes.push(0xF7);
|
||||
_send(_clockSysex()); _send(bytes);
|
||||
const ok = await new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, 2500); });
|
||||
if (ok === true) { alert("Saved to device ✓ — " + uls.length + " set list(s) synced (alongside the built-ins)."); return; }
|
||||
_downloadPrograms(json);
|
||||
alert(ok === false
|
||||
? "The device is in editor mode (drive writable by the computer), so I downloaded programs.json — drag it onto the CIRCUITPY drive."
|
||||
: "No device answered over USB-MIDI — downloaded programs.json. Connect the device (Chromium/Firefox) and try again, or boot it holding button A and drag the file onto the CIRCUITPY drive.");
|
||||
return;
|
||||
}
|
||||
// Universal fallback (any browser / OS): download + drag onto the drive in editor mode (hold A at power-on).
|
||||
_downloadPrograms(json);
|
||||
alert("Downloaded programs.json — boot the device holding button A (editor mode) and drag it onto the CIRCUITPY drive.");
|
||||
alert("Downloaded programs.json — drag it onto the device's CIRCUITPY drive (it auto-reloads).");
|
||||
}
|
||||
function importPrograms(text) {
|
||||
try {
|
||||
const d = JSON.parse(text);
|
||||
const lists = Array.isArray(d.setlists) ? d.setlists : [{ title: d.title, programs: d.programs || [] }];
|
||||
let added = 0;
|
||||
for (const sl of lists) {
|
||||
const items = (sl.programs || []).map((p) => ({ name: p.name || "Item", ...patchToSetup(p.prog) }));
|
||||
if (!items.length) continue;
|
||||
setlists.push({ title: sl.title || "Device", description: "", items }); added++;
|
||||
}
|
||||
if (!added) return alert("No programs found in that file.");
|
||||
activeSL = setlists.length - 1; activeItem = 0;
|
||||
saveSetlists(); renderSetlists(); applySetup(setlists[activeSL].items[0]);
|
||||
alert("Loaded " + added + " set list(s) from the device.");
|
||||
const items = (d.programs || []).map((p) => ({ name: p.name || "Item", ...patchToSetup(p.prog) }));
|
||||
if (!items.length) return alert("No programs found in that file.");
|
||||
setlists.push({ title: d.title || "Device", description: "", items }); activeSL = setlists.length - 1; activeItem = 0;
|
||||
saveSetlists(); renderSetlists(); applySetup(items[0]);
|
||||
alert("Loaded " + items.length + " grooves from the device into a new set list.");
|
||||
} catch (e) { alert("Load failed: " + e.message); }
|
||||
}
|
||||
async function loadFromDevice() {
|
||||
|
|
@ -1224,187 +1117,35 @@ async function loadFromDevice() {
|
|||
|
||||
/* Device audio (Phase 3): a connected PM_K-1 sends a USB-MIDI note per click; we voice it through
|
||||
this page's synth, so the device drives sound out the computer's speakers, locked to its clock. */
|
||||
let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null, _verCb = null;
|
||||
let _midiAccess = null, _midiOn = false, _midiFlash = 0;
|
||||
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
|
||||
function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; }
|
||||
function _isDevicePort(p) { // recognise the PM devices' USB-MIDI ports by name;
|
||||
const n = (p.name || "").toLowerCase(); // anything unrecognised triggers a broadcast to all outputs - bad for ACK routing.
|
||||
return n.includes("pico") || n.includes("circuitpython") || n.includes("usb_midi") ||
|
||||
n.includes("pimoroni") || n.includes("explorer") || n.includes("rp2350") || n.includes("varasys") ||
|
||||
// native Rust firmware enumerates as e.g. "PM_G-1 Grid" / "PM_K-1" / "PM_X-1" (VARASYS / PolyMeter)
|
||||
n.includes("pm_g") || n.includes("pm_k") || n.includes("pm_x") ||
|
||||
n.includes("pm-g") || n.includes("pm-k") || n.includes("pm-x") ||
|
||||
n.includes("grid") || n.includes("polymeter");
|
||||
}
|
||||
function _send(bytes) { // send only to the PM_K-1 (not loopback ports like "Midi Through", which just echo)
|
||||
const outs = _midiOutputs(), dev = outs.filter(_isDevicePort);
|
||||
for (const o of (dev.length ? dev : outs)) { try { o.send(bytes); } catch (_) {} }
|
||||
}
|
||||
function _clockSysex() { const d = new Date(); // F0 7D 01 yr-2000 mo dd hh mm ss F7 -> sets the device RTC
|
||||
return [0xF0, 0x7D, 0x01, d.getFullYear() - 2000, d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), 0xF7]; }
|
||||
async function _ensureMidi() { // MIDI access WITH SysEx (needed to send/receive SysEx); cached
|
||||
if (_midiAccess) return true;
|
||||
if (!navigator.requestMIDIAccess) return false;
|
||||
try { _midiAccess = await navigator.requestMIDIAccess({ sysex: true }); }
|
||||
catch (e) { return false; }
|
||||
_midiAccess.onstatechange = _wireMidi; _wireMidi();
|
||||
return true;
|
||||
}
|
||||
function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); updateDevBadge(); populateMidiOutPorts(); }
|
||||
function updateDevBadge() { // header badge: lights green while a PM_K-1 / PM_X-1 is connected over USB-MIDI
|
||||
const el = $("devBadge"); if (!el) return;
|
||||
const dev = _midiAccess ? [..._midiOutputs(), ..._midiInputs()].filter(_isDevicePort) : [];
|
||||
if (dev.length) { el.textContent = "● " + (dev[0].name || "device").slice(0, 18); el.style.color = "#2fe07a"; el.style.borderColor = "#2fe07a"; }
|
||||
else { el.textContent = _midiAccess ? "◎ no device" : "◎ connect device"; el.style.color = "var(--muted)"; el.style.borderColor = "var(--edge)"; }
|
||||
}
|
||||
function onDeviceMidi(e) {
|
||||
const d = e.data; if (!d) return;
|
||||
if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply
|
||||
const cmd = d[2];
|
||||
if (cmd === 0x03 && _verCb) { const cb = _verCb; _verCb = null; cb(String.fromCharCode(...d.slice(3, d.length - 1))); } // version
|
||||
else if ((cmd === 0x7F || cmd === 0x7E) && _saveCb) { const cb = _saveCb; _saveCb = null; cb(cmd === 0x7F); } // ACK/NAK
|
||||
return;
|
||||
}
|
||||
if (_midiOn && (d[0] & 0xf0) === 0x90 && d.length >= 3 && d[2] > 0) { // Note On -> voice it
|
||||
const v = d[2], gain = v >= 110 ? 1.0 : v >= 70 ? 0.6 : 0.25; // accent / normal / ghost
|
||||
const d = e.data; if (!d || d.length < 3) return;
|
||||
if ((d[0] & 0xf0) === 0x90 && d[2] > 0) { // Note On
|
||||
const v = d[2], gain = v >= 110 ? 1.0 : v >= 70 ? 0.6 : 0.25; // accent / normal / ghost
|
||||
try { ensureAudio(); playInstrument(GM_NUM[d[1]] || "beep", audioCtx.currentTime, gain); } catch (_) {}
|
||||
const b = $("midiBtn"); if (b) { b.style.boxShadow = "0 0 0 2px #2fe07a"; clearTimeout(_midiFlash); _midiFlash = setTimeout(() => b.style.boxShadow = "", 90); }
|
||||
}
|
||||
}
|
||||
function _heartbeat(on) { // while Device audio is on: tell the device a host listens + keep its clock synced
|
||||
clearInterval(_midiBeat); _midiBeat = 0;
|
||||
if (on) { let k = 0; _midiBeat = setInterval(() => {
|
||||
_send([0xFE]); // Active Sensing -> device shows "MIDI" + mutes buzzer
|
||||
if ((k++ % 12) === 0) _send(_clockSysex()); // resync the clock ~every 3s
|
||||
}, 250); }
|
||||
}
|
||||
function _bindMidi() { for (const inp of _midiInputs()) inp.onmidimessage = _midiOn ? onDeviceMidi : null; }
|
||||
function updateMidiBtn() {
|
||||
const b = $("midiBtn"); if (!b) return;
|
||||
if (!_midiOn) { // off: muted pill, matching the device badge's idle look
|
||||
b.textContent = "🎹 Device audio";
|
||||
b.style.color = "var(--muted)"; b.style.borderColor = "var(--edge)"; b.style.boxShadow = "";
|
||||
return;
|
||||
}
|
||||
const names = _midiInputs().map((i) => i.name || "MIDI"); // on: green, like the connected badge
|
||||
b.textContent = "🎹 " + (names.length ? names[0].slice(0, 16) : "audio on");
|
||||
b.style.color = "#2fe07a"; b.style.borderColor = "#2fe07a";
|
||||
if (!_midiOn) { b.textContent = "🎹 Device audio"; b.classList.remove("primary"); b.style.boxShadow = ""; return; }
|
||||
const names = _midiInputs().map((i) => i.name || "MIDI");
|
||||
b.textContent = names.length ? "🎹 " + names[0].slice(0, 16) : "🎹 no device"; // shows the connected MIDI device
|
||||
b.classList.add("primary");
|
||||
}
|
||||
async function toggleDeviceAudio() {
|
||||
if (_midiOn) { _midiOn = false; _heartbeat(false); updateMidiBtn(); return; } // inputs stay bound (for Save ACKs)
|
||||
if (!(await _ensureMidi())) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome, Edge, or Firefox.");
|
||||
if (_midiOn) { _midiOn = false; _bindMidi(); updateMidiBtn(); return; }
|
||||
if (!navigator.requestMIDIAccess) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome or Edge.");
|
||||
try { if (!_midiAccess) { _midiAccess = await navigator.requestMIDIAccess(); _midiAccess.onstatechange = () => { _bindMidi(); updateMidiBtn(); }; } }
|
||||
catch (e) { return alert("MIDI access was denied."); }
|
||||
ensureAudio(); if (audioCtx && audioCtx.state === "suspended") audioCtx.resume();
|
||||
_midiOn = true; _heartbeat(true); updateMidiBtn();
|
||||
_midiOn = true; _bindMidi(); updateMidiBtn();
|
||||
const names = _midiInputs().map((i) => i.name || "MIDI");
|
||||
alert(names.length
|
||||
? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green per note."
|
||||
: "Armed, but no MIDI input yet. Plug in the PM_K-1 (CircuitPython firmware) — it connects automatically.");
|
||||
}
|
||||
/*@BUILD:include:src/midiout.js@*/
|
||||
function _queryDeviceVersion() { // ask the device its firmware version (SysEx 0x02 -> reply 0x03)
|
||||
return new Promise((res) => { _verCb = res; _send([0xF0, 0x7D, 0x02, 0xF7]); setTimeout(() => { if (_verCb) { _verCb = null; res(null); } }, 1500); });
|
||||
}
|
||||
// 0.0.23+ devices reply "<id>;<version>" (e.g. "K;0.0.23", "X;0.0.1"); pre-0.0.23 send bare version -> assume K.
|
||||
function _parseDeviceReply(s) {
|
||||
if (!s) return { id: null, version: null };
|
||||
const i = s.indexOf(";");
|
||||
return i >= 0 ? { id: s.slice(0, i), version: s.slice(i + 1) } : { id: "K", version: s };
|
||||
}
|
||||
const FW_PATHS = { K: { py: "/pico-cp-app.py", mpy: "/pico-cp-app.mpy", label: "PM_K-1 Kit" },
|
||||
X: { py: "/pico-explorer-app.py", mpy: "/pico-explorer-app.mpy", label: "PM_X-1 Explorer" },
|
||||
G: { py: "/pico-scroll-app.py", mpy: "/pico-scroll-app.mpy", label: "PM_G-1 Grid" } };
|
||||
async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy
|
||||
console.log("[fw] update start");
|
||||
if (!(await _ensureMidi()) || !_midiOutputs().length) {
|
||||
console.log("[fw] no MIDI output (outputs:", _midiOutputs().length, ")");
|
||||
return alert("Connect the device (Chrome/Edge/Firefox), then try again.");
|
||||
}
|
||||
console.log("[fw] MIDI ok; outputs:", _midiOutputs().map((o) => o.name));
|
||||
const reply = await _queryDeviceVersion();
|
||||
const { id: devId, version: dev } = _parseDeviceReply(reply);
|
||||
const paths = FW_PATHS[devId] || FW_PATHS.K; // unknown id -> assume Kit (pre-0.0.23 firmware)
|
||||
console.log("[fw] device reply:", reply, "-> id:", devId || "(none)", "version:", dev, "paths:", paths.label);
|
||||
let latest = null, b64 = null;
|
||||
for (const base of ["", "https://metronome.varasys.io"]) {
|
||||
try { const t = await (await fetch(base + paths.py, { cache: "no-store" })).text();
|
||||
const m = t.match(/APP_VERSION\s*=\s*["']([^"']+)["']/); if (m) latest = m[1]; } catch (_) {}
|
||||
try { const r = await fetch(base + paths.mpy, { cache: "no-store" });
|
||||
if (r.ok) { b64 = _b64(new Uint8Array(await r.arrayBuffer())); break; } } catch (_) {}
|
||||
}
|
||||
if (!b64) { // offline: let the user pick app.mpy
|
||||
alert("Can't reach the site.\n\nPick the firmware file (app.mpy) to flash — download it from\n" +
|
||||
"metronome.varasys.io" + paths.mpy + ", or use the online editor at metronome.varasys.io/editor.html.");
|
||||
const u8 = await _pickBinary(); if (!u8) return;
|
||||
b64 = _b64(u8); if (!latest) latest = "(picked .mpy)";
|
||||
}
|
||||
if (!latest) latest = "?";
|
||||
console.log("[fw] latest:", latest, "| .mpy base64 length:", b64 && b64.length);
|
||||
const upToDate = dev && dev === latest;
|
||||
if (!confirm(paths.label + " firmware: " + (dev || "unknown") + "\nNew build: " + latest +
|
||||
(upToDate ? "\n\nSame version. Re-install anyway?"
|
||||
: "\n\nUpdate now? The device reboots, runs the new build, and auto-rolls-back if it fails to start."))) {
|
||||
console.log("[fw] confirm returned false (cancelled, OR the browser is suppressing dialogs -> reload the page)");
|
||||
return;
|
||||
}
|
||||
console.log("[fw] pushing", b64.length, "base64 chars...");
|
||||
clearInterval(_midiBeat); _midiBeat = 0; // pause the Device-audio heartbeat (its MIDI traffic stalls the push)
|
||||
const err = await _pushFirmware(b64);
|
||||
if (_midiOn) _heartbeat(true); // resume it afterwards
|
||||
console.log("[fw] push result:", err || "OK");
|
||||
if (err) return alert("Update didn't complete (" + err + ").\n\nThe device kept its working firmware — nothing was installed. " +
|
||||
"Make sure it's plugged in and NOT in editor mode (don't hold A), then retry.");
|
||||
alert("Update sent ✓ — the device verified v" + latest + " and is rebooting into it. It auto-confirms after a few seconds, or rolls back if it won't start.");
|
||||
}
|
||||
// One ACK/NAK await (the device replies 0x7F=ok / 0x7E=rejected after each SysEx); resolves true/false/null(timeout).
|
||||
function _ack(timeout) {
|
||||
return new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, timeout); });
|
||||
}
|
||||
function _b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); }
|
||||
// Push the base64-encoded .mpy in small, flow-controlled chunks: begin(0x21) -> data(0x22)* -> commit(0x23),
|
||||
// waiting for each ACK. The device base64-decodes each chunk to /app.new, verifies the .mpy header, then
|
||||
// A/B-installs + reboots. CH is small (and a multiple of 4) so a chunk fits the Pico's USB-MIDI RX buffer.
|
||||
async function _pushFirmware(b64) {
|
||||
// Diagnostic: list every MIDI output the editor sees + which pass _isDevicePort
|
||||
console.log("[fw] outputs:", _midiOutputs().map(o => ({ name: o.name || "?", match: _isDevicePort(o) })));
|
||||
console.log("[fw] inputs:", _midiInputs() .map(i => ({ name: i.name || "?", match: _isDevicePort(i) })));
|
||||
console.log("[fw] sending BEGIN (0x21)");
|
||||
_send([0xF0, 0x7D, 0x21, 0xF7]);
|
||||
const beginA = await _ack(5000);
|
||||
console.log("[fw] BEGIN ack:", beginA);
|
||||
if (beginA !== true) return "handshake";
|
||||
const CH = 64, total = Math.ceil(b64.length / CH); let done = 0;
|
||||
const t0 = Date.now();
|
||||
for (let o = 0; o < b64.length; o += CH) {
|
||||
const part = b64.slice(o, o + CH); const msg = [0xF0, 0x7D, 0x22];
|
||||
for (let i = 0; i < part.length; i++) msg.push(part.charCodeAt(i)); // base64 is ASCII
|
||||
msg.push(0xF7);
|
||||
const sendT = Date.now(); _send(msg);
|
||||
const a = await _ack(10000);
|
||||
const ackT = Date.now();
|
||||
if (done < 3 || a !== true) { // log the first few chunks + any failure
|
||||
console.log("[fw] chunk " + (done + 1) + "/" + total + " sent (" + msg.length + " bytes) ack=" + a + " after " + (ackT - sendT) + "ms");
|
||||
}
|
||||
if (a !== true) return "chunk " + (done + 1) + "/" + total + (a === false ? " rejected" : " no-ack");
|
||||
if (++done % 25 === 0) {
|
||||
const el = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
console.log("[fw] pushed " + done + " / " + total + " chunks (" + el + " s)");
|
||||
}
|
||||
}
|
||||
console.log("[fw] all " + total + " chunks sent; committing...");
|
||||
_send([0xF0, 0x7D, 0x23, 0xF7]);
|
||||
return (await _ack(10000)) === true ? null : "verify";
|
||||
}
|
||||
function _pickBinary() { // offline fallback: choose an app.mpy -> Uint8Array (or null if cancelled)
|
||||
return new Promise(async (res) => {
|
||||
if (window.showOpenFilePicker) {
|
||||
try { const [h] = await showOpenFilePicker({ types: [{ description: "PM_K-1 firmware (app.mpy)", accept: { "application/octet-stream": [".mpy"] } }] });
|
||||
return res(new Uint8Array(await (await h.getFile()).arrayBuffer())); }
|
||||
catch (e) { if (e.name === "AbortError") return res(null); }
|
||||
}
|
||||
const inp = document.createElement("input"); inp.type = "file"; inp.accept = ".mpy,application/octet-stream";
|
||||
inp.onchange = async () => { inp.files[0] ? res(new Uint8Array(await inp.files[0].arrayBuffer())) : res(null); };
|
||||
inp.oncancel = () => res(null);
|
||||
inp.click();
|
||||
});
|
||||
? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green on each note received."
|
||||
: "Device audio is armed, but NO MIDI input is connected.\nPlug in the PM_K-1 running the CircuitPython firmware — it should appear here as a MIDI device. (New devices connect automatically.)");
|
||||
}
|
||||
|
||||
// Apply a shared link on load. Returns true if it set the metronome state.
|
||||
|
|
@ -1429,90 +1170,18 @@ function applyHashShare() {
|
|||
/* =========================================================================
|
||||
VISUALS
|
||||
========================================================================= */
|
||||
// Render all three notation lenses (Konnakol / Staff / TUBS) from the same groove model each frame.
|
||||
function renderViews() {
|
||||
if (typeof NOTATION === "undefined") return;
|
||||
// Continuous master-bar fraction (0..1): interpolate within the current step using its real
|
||||
// onset/next-onset times so the playhead tracks time smoothly (and lands on the beat at onset).
|
||||
let phase = 0;
|
||||
const m0 = meters[0];
|
||||
if (state.running && m0 && m0.currentStep >= 0 && audioCtx) {
|
||||
const now = audioCtx.currentTime - (audioCtx.outputLatency || audioCtx.baseLatency || 0);
|
||||
const steps = (m0.beatsPerBar * m0.stepsPerBeat) || 1;
|
||||
const t0 = (m0.curStepTime != null) ? m0.curStepTime : now;
|
||||
const nx = m0.vq[m0.vqPtr];
|
||||
const t1 = nx ? nx.time : t0 + (60 / state.bpm) / (m0.stepsPerBeat || 1);
|
||||
const g = t1 > t0 ? Math.max(0, Math.min(1, (now - t0) / (t1 - t0))) : 0;
|
||||
phase = (m0.currentStep + g) / steps;
|
||||
}
|
||||
const lanes = meters.map((m, i) => ({
|
||||
idx: i, sound: m.sound, groups: (m.groups || []).slice(), sub: m.stepsPerBeat,
|
||||
swing: !!m.swing, poly: !!m.poly, muted: !m.enabled,
|
||||
levels: (m.beatsOn || []).slice(), orns: (m.orns || []),
|
||||
}));
|
||||
// Shared left gutter so the bar (and every beat) lines up vertically across all three views.
|
||||
// Width = whatever the staff's clef+time-signature needs, vs the TUBS voice labels (whichever is wider).
|
||||
const vis = lanes.filter((l) => !l.muted);
|
||||
const EM = 44, clefW = EM * 0.6, tsDigit = EM * 0.4; // staff metrics (staffSpace 11)
|
||||
const master = vis.find((l) => !l.poly) || vis[0] || { groups: [4] };
|
||||
const mg = master.groups && master.groups.length ? master.groups : [4];
|
||||
const mbeats = mg.reduce((a, b) => a + b, 0) || 4;
|
||||
const numParts = mg.length > 1 ? mg : [mbeats];
|
||||
const numGlyphs = numParts.reduce((a, n) => a + String(n).length, 0) + (numParts.length - 1);
|
||||
const staffX0 = 14 + clefW + numGlyphs * tsDigit + 14;
|
||||
const labelChars = vis.length ? Math.max(...vis.map((l) => (l.sound || "").length + (l.poly ? 2 : 0) + (/^clave/.test(l.sound || "") ? 7 : 0))) : 6;
|
||||
const tubsX0 = 18 + labelChars * 7 + 8;
|
||||
const gutter = Math.max(96, Math.ceil(staffX0), Math.ceil(tubsX0));
|
||||
const base = { name: (window.npName && npName.textContent) || "", bpm: state.bpm, playing: state.running, phase, lanes, gutter };
|
||||
const k = document.getElementById("konnakolCanvas");
|
||||
const s = document.getElementById("staffCanvas");
|
||||
const t = document.getElementById("tubsCanvas");
|
||||
if (k) NOTATION.draw(k, Object.assign({ view: "konnakol" }, base));
|
||||
if (s) NOTATION.draw(s, Object.assign({ view: "staff" }, base));
|
||||
if (t) NOTATION.draw(t, Object.assign({ view: "tubs" }, base));
|
||||
}
|
||||
|
||||
// Editing on the Staff or TUBS canvas: click a step to cycle its dynamic (accent→normal→ghost→rest);
|
||||
// Shift-click cycles its ornament (none→flam→drag→roll). Each view publishes a hit map (cv._hit).
|
||||
function editFromHit(e) {
|
||||
const cv = e.currentTarget, h = cv._hit; if (!h) return;
|
||||
const r = cv.getBoundingClientRect(), cx = e.clientX - r.left, cy = e.clientY - r.top;
|
||||
let idx = -1, step = -1;
|
||||
if (h.kind === "staff") {
|
||||
let best = null, bestD = 1e9; // nearest voice row by vertical position
|
||||
for (const L of h.lanes) { const d = Math.abs((h.staffTop + L.p * (h.S / 2)) - cy); if (d < bestD) { bestD = d; best = L; } }
|
||||
if (!best || bestD > h.S * 2.2) return;
|
||||
const sIdx = Math.floor((cx - h.x0) / (h.barW / best.steps));
|
||||
if (sIdx < 0 || sIdx >= best.steps) return;
|
||||
idx = best.idx; step = sIdx;
|
||||
} else if (h.kind === "tubs") {
|
||||
const ri = Math.floor((cy - h.top) / h.rowH); if (ri < 0 || ri >= h.rows.length) return;
|
||||
const row = h.rows[ri], cell = (cx - h.x0) / h.cw, sIdx = Math.round((cell - 0.5) / row.span);
|
||||
if (sIdx < 0 || sIdx >= row.steps) return;
|
||||
if (Math.abs(cell - (sIdx * row.span + 0.5)) > Math.max(0.6, row.span * 0.55)) return; // clicked a gap
|
||||
idx = row.idx; step = sIdx;
|
||||
}
|
||||
if (idx < 0) return;
|
||||
const m = meters[idx]; if (!m) return;
|
||||
if (e.shiftKey) { while (m.orns.length < m.beatsOn.length) m.orns.push(0); m.orns[step] = ((m.orns[step] | 0) + 1) % 4; }
|
||||
else m.beatsOn[step] = NEXT_LEVEL[m.beatsOn[step] | 0];
|
||||
renderViews();
|
||||
if (typeof refreshPatchField === "function") refreshPatchField();
|
||||
}
|
||||
["staffCanvas", "tubsCanvas"].forEach((id) => { const cv = document.getElementById(id); if (cv) cv.addEventListener("click", editFromHit); });
|
||||
|
||||
function drawLoop() {
|
||||
if (audioCtx) {
|
||||
const raw = audioCtx.currentTime;
|
||||
// playhead follows when the click is HEARD (compensate output latency); timers keep the true clock
|
||||
const now = raw - (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.curStepTime = m.vq[m.vqPtr].time; 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(raw);
|
||||
}
|
||||
renderViews();
|
||||
for (const m of meters) renderLaneStrip(m);
|
||||
tickTimers();
|
||||
requestAnimationFrame(drawLoop);
|
||||
}
|
||||
|
|
@ -1536,9 +1205,9 @@ function tickTimers() {
|
|||
const now = Date.now();
|
||||
const dt = timers.last ? Math.min(now - timers.last, 1000) : 0; // clamp so backgrounded gaps don't jump
|
||||
timers.last = now;
|
||||
if (state.running) {
|
||||
timers.elapsedMs += dt; // elapsed stopwatch always runs while playing (like the device)
|
||||
if (timersOn && timers.totalMs > 0) {
|
||||
if (timersOn && state.running) {
|
||||
timers.elapsedMs += dt;
|
||||
if (timers.totalMs > 0) {
|
||||
const before = timers.remainingMs;
|
||||
timers.remainingMs -= dt;
|
||||
// time countdown hit 0 → auto-advance (smooth). Bar-length segments use the
|
||||
|
|
@ -1552,45 +1221,27 @@ function tickTimers() {
|
|||
}
|
||||
renderTimers();
|
||||
}
|
||||
// PM_E-2 divergence from PM_E-1: every function slot stays in the display row, dimmed (".off")
|
||||
// when inactive and lit when on — so all functions are visible at a glance, space-consciously.
|
||||
function renderTimers() {
|
||||
$("dtimers").hidden = false;
|
||||
$("dtimers").hidden = !timersOn;
|
||||
if (!timersOn) return;
|
||||
$("elapsedVal").textContent = fmtClock(timers.elapsedMs);
|
||||
// Tempo ramp
|
||||
const rw = $("rampWrap"); rw.hidden = false;
|
||||
if (ramp.on) { rw.classList.remove("off"); rw.textContent = (ramp.amount < 0 ? "↘ " : "↗ ") + (ramp.amount >= 0 ? "+" : "") + ramp.amount + "/" + ramp.everyBars + "b"; }
|
||||
else { rw.classList.add("off"); rw.textContent = "↗ ramp"; }
|
||||
// Gap / mute trainer — "GAP play/mute" when armed; amber while a muted window is active.
|
||||
const gw = $("gapWrap"); gw.hidden = false;
|
||||
if (trainer.on && trainer.muteBars > 0) {
|
||||
gw.classList.remove("off");
|
||||
gw.textContent = "GAP " + trainer.playBars + "/" + trainer.muteBars;
|
||||
const muting = state.running && typeof isMutedAt === "function" && audioCtx && isMutedAt(audioCtx.currentTime);
|
||||
gw.classList.toggle("muting", !!muting);
|
||||
} else { gw.classList.add("off"); gw.classList.remove("muting"); gw.textContent = "GAP off"; }
|
||||
// Time countdown
|
||||
const cw = $("countWrap"), cd = $("countVal"); cw.hidden = false;
|
||||
const cdOff = !timersOn || timers.totalMs <= 0;
|
||||
if (cdOff) { cw.classList.add("off"); cd.textContent = "--"; cd.classList.remove("over", "low"); }
|
||||
else {
|
||||
cw.classList.remove("off");
|
||||
const off = timers.totalMs <= 0;
|
||||
$("countWrap").hidden = off; // hide time countdown when off
|
||||
if (!off) {
|
||||
const cd = $("countVal");
|
||||
cd.textContent = fmtClock(timers.remainingMs);
|
||||
cd.classList.toggle("over", timers.remainingMs <= 0); // overtime
|
||||
cd.classList.toggle("low", timers.remainingMs > 0 && timers.remainingMs <= 10000); // almost up
|
||||
}
|
||||
// Bars in segment — remaining while playing (lit); the configured length when stopped (dimmed).
|
||||
const bw = $("barWrap"), bv = $("barVal"); bw.hidden = false;
|
||||
// bar countdown — bars remaining in the current segment (audible bar from lane 1, not the look-ahead master clock)
|
||||
const showBars = state.running && segBars > 0;
|
||||
$("barWrap").hidden = !showBars;
|
||||
if (showBars) {
|
||||
bw.classList.remove("off");
|
||||
const elapsed = meters.length ? meters[0].currentBar : segBarCount;
|
||||
const remaining = Math.max(0, segBars - elapsed);
|
||||
const bv = $("barVal");
|
||||
bv.textContent = remaining;
|
||||
bv.classList.toggle("low", remaining <= 1);
|
||||
} else {
|
||||
bw.classList.add("off"); bv.classList.remove("low");
|
||||
bv.textContent = segBars > 0 ? segBars : "--";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1651,7 +1302,7 @@ function commitPatchField() {
|
|||
setPatchMsg("✓ decoded set list “" + sl.title + "” (" + sl.items.length + " item" + (sl.items.length > 1 ? "s" : "") + ") — loaded item 1", true);
|
||||
} else {
|
||||
const s = patchToSetup(payload); // plain-text patch
|
||||
if (!payload.includes(":")) throw new Error("no lanes — try e.g. kick:4"); // raw-input check (patchToSetup itself defaults to beep:4)
|
||||
if (!s.lanes.length) throw new Error("no lanes — try e.g. kick:4");
|
||||
applyPatch(payload);
|
||||
setPatchMsg("✓ " + s.lanes.length + " lane" + (s.lanes.length > 1 ? "s" : "") + " · " + s.bpm + " BPM", true);
|
||||
}
|
||||
|
|
@ -1692,9 +1343,9 @@ $("vol").addEventListener("input", (e) => {
|
|||
state.volume = +e.target.value / 100; volVal.textContent = e.target.value + "%";
|
||||
if (masterGain) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01);
|
||||
});
|
||||
$("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); renderTimers(); });
|
||||
$("playBars").addEventListener("input", (e) => { trainer.playBars = +e.target.value; renderTimers(); });
|
||||
$("muteBars").addEventListener("input", (e) => { trainer.muteBars = +e.target.value; renderTimers(); });
|
||||
$("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); });
|
||||
$("playBars").addEventListener("input", (e) => trainer.playBars = +e.target.value);
|
||||
$("muteBars").addEventListener("input", (e) => trainer.muteBars = +e.target.value);
|
||||
$("rampOn").addEventListener("change", (e) => { ramp.on = e.target.checked; refreshFeatureBoxes(); });
|
||||
$("rampStart").addEventListener("input", (e) => ramp.startBpm = +e.target.value);
|
||||
$("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value);
|
||||
|
|
@ -1704,12 +1355,8 @@ $("countTime").addEventListener("input", (e) => { timers.totalMs = parseTime(e.t
|
|||
$("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); });
|
||||
$("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); });
|
||||
$("segBarsIn").addEventListener("input", (e) => { segBars = Math.max(0, parseInt(e.target.value, 10) || 0); renderTimers(); });
|
||||
$("endAction").addEventListener("change", readEndActionUI);
|
||||
$("endGoto").addEventListener("input", readEndActionUI);
|
||||
$("endRep").addEventListener("input", readEndActionUI);
|
||||
$("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); });
|
||||
$("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); });
|
||||
$("logSessions").addEventListener("change", (e) => { loggingOn = e.target.checked; lsSet(LS.logging, loggingOn); }); // practice-log on/off (persisted)
|
||||
$("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; });
|
||||
document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; });
|
||||
$("delSetlistBtn").addEventListener("click", deleteSetlist);
|
||||
|
|
@ -1726,10 +1373,7 @@ $("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $(
|
|||
$("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; });
|
||||
$("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); });
|
||||
$("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); });
|
||||
$("updateFwBtn").addEventListener("click", () => { $("trayMenu").hidden = true; updateFirmware(); });
|
||||
$("midiBtn").addEventListener("click", toggleDeviceAudio);
|
||||
$("midiOutBtn").addEventListener("click", toggleMidiOut);
|
||||
$("devBadge").addEventListener("click", () => { _ensureMidi().then(updateDevBadge); });
|
||||
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
|
||||
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });
|
||||
$("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); });
|
||||
|
|
@ -1795,7 +1439,7 @@ window.addEventListener("keydown", (e) => {
|
|||
// Seed the demo set lists. Versioned + additive: a newer SEED_VERSION adds any
|
||||
// seed list whose title isn't already present, without clobbering the user's lists
|
||||
// (and won't re-add one they've deleted at the same version).
|
||||
const SEED_VERSION = 4;
|
||||
const SEED_VERSION = 3;
|
||||
if ((lsGet(LS.seeded, 0) | 0) < SEED_VERSION) {
|
||||
for (const s of SEED_SETLISTS) {
|
||||
if (!setlists.some((x) => x.title === s.title)) {
|
||||
|
|
@ -1817,15 +1461,6 @@ updateCtx();
|
|||
refreshFeatureBoxes();
|
||||
$("continueMode").checked = continueMode;
|
||||
$("timersOn").checked = timersOn;
|
||||
$("logSessions").checked = loggingOn;
|
||||
// Never prompt for Web MIDI on load. Only auto-reconnect if the user ALREADY granted it (querying
|
||||
// the permission does NOT prompt); otherwise just show the badge — the "connect device" badge and
|
||||
// the Device-audio button request access on an explicit click.
|
||||
if (navigator.requestMIDIAccess && navigator.permissions && navigator.permissions.query) {
|
||||
navigator.permissions.query({ name: "midi", sysex: true })
|
||||
.then(p => { if (p.state === "granted") _ensureMidi().then(updateDevBadge).catch(() => updateDevBadge()); else updateDevBadge(); })
|
||||
.catch(() => updateDevBadge());
|
||||
} else updateDevBadge();
|
||||
requestAnimationFrame(drawLoop);
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
135
embed.html
Normal file
135
embed.html
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embed a PolyMeter widget — VARASYS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<!-- Docs for the embeddable PolyMeter widget. Dogfoods embed.js with a live example. -->
|
||||
<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:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; }
|
||||
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
|
||||
a{ color:var(--link); }
|
||||
main{ width:100%; max-width:760px; margin:26px auto 0; }
|
||||
h1{ font-size:24px; margin:0 0 4px; } h2{ font-size:16px; margin:26px 0 8px; }
|
||||
p{ color:var(--muted); font-size:14px; line-height:1.6; } p.lead{ max-width:60ch; }
|
||||
pre{ background:var(--field-bg); border:1px solid var(--panel-bd); border-radius:9px; padding:12px 14px;
|
||||
overflow:auto; font-size:12.5px; line-height:1.5; color:var(--txt); }
|
||||
code{ background:var(--field-bg); border:1px solid var(--panel-bd); border-radius:4px; padding:1px 5px; font-size:12px; }
|
||||
table{ border-collapse:collapse; font-size:13px; width:100%; }
|
||||
th,td{ text-align:left; padding:5px 8px; border-bottom:1px solid var(--panel-bd); vertical-align:top; }
|
||||
th{ color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.04em; }
|
||||
td.k{ white-space:nowrap; color:var(--cyan); font-family:"Courier New",monospace; }
|
||||
.demo{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; margin-top:8px; }
|
||||
.pick{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-top:14px; }
|
||||
.pick label{ font-size:13px; color:var(--txt); }
|
||||
.pick select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--panel-bd); border-radius:8px; padding:7px 10px; font-size:13px; }
|
||||
.ff-name{ color:var(--cyan); font-weight:600; font-size:13px; }
|
||||
.site-foot{ max-width:760px; margin:40px auto 0; font-size:12px; color:var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<h1>Embed a PolyMeter widget</h1>
|
||||
<p class="lead">Every PolyMeter form factor doubles as an embeddable widget. Drop one into any page with
|
||||
a placeholder + one script tag — no build step, no dependencies. It loads in an iframe and is preloaded
|
||||
with whatever <b>program / settings string</b> you give it.</p>
|
||||
|
||||
<p class="pick"><label for="ffSel">Show snippets for:</label>
|
||||
<select id="ffSel">
|
||||
<option value="editor">PM_E‑1 Editor</option>
|
||||
<option value="teacher">PM_T‑1 Teacher</option>
|
||||
<option value="stage">PM_S‑1 Stage</option>
|
||||
<option value="micro" selected>PM_P‑1 Practice</option>
|
||||
<option value="showcase">PM_D‑1 Display</option>
|
||||
<option value="initial">PM_C‑1 Concept</option>
|
||||
</select>
|
||||
<span class="ff-name"></span></p>
|
||||
|
||||
<h2>Drop-in (recommended)</h2>
|
||||
<pre id="snipDrop"></pre>
|
||||
<p>The script replaces the <code><div></code> with an auto-sizing iframe. Here's the <span class="ff-name"></span>, live on this page:</p>
|
||||
<div class="demo"><iframe id="demoFrame" title="live embed preview" allow="autoplay" style="border:0;display:block;width:100%;height:300px"></iframe></div>
|
||||
|
||||
<h2>Or a plain iframe</h2>
|
||||
<pre id="snipIframe"></pre>
|
||||
|
||||
<h2>Form factors — <code>data-varasys-metronome</code></h2>
|
||||
<table>
|
||||
<thead><tr><th>value</th><th>widget</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="k">editor</td><td>PM_E‑1 PolyMeter Editor (full web app)</td></tr>
|
||||
<tr><td class="k">teacher</td><td>PM_T‑1 Teacher (studio / lesson console)</td></tr>
|
||||
<tr><td class="k">stage</td><td>PM_S‑1 Stage (foot‑pedal stompbox)</td></tr>
|
||||
<tr><td class="k">micro</td><td>PM_P‑1 Practice (inline practice bar)</td></tr>
|
||||
<tr><td class="k">showcase</td><td>PM_D‑1 Display (RGB pendulum showpiece)</td></tr>
|
||||
<tr><td class="k">initial</td><td>PM_C‑1 Concept (idealized render)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Configuration / settings string</h2>
|
||||
<table>
|
||||
<thead><tr><th>attribute</th><th>what</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="k">data-patch</td><td>A PolyMeter program string, e.g. <code>v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2</code>. Copy it from the editor's program field.</td></tr>
|
||||
<tr><td class="k">data-setlist</td><td>A base64url set‑list code (a whole set list) — share it from the editor.</td></tr>
|
||||
<tr><td class="k">data-width / data-height</td><td>iframe size (default <code>100% × 300</code>; height auto‑grows to the widget).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Under the hood the loader builds <code><page>?embed=1#p=<patch></code>; the page's <code>?embed=1</code> mode strips the
|
||||
site chrome so only the widget shows. That's the same way our own Concept & Info pages embed it.</p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id)=>document.getElementById(id);
|
||||
|
||||
/* Form-factor picker: updates every snippet + the live demo for the chosen version. */
|
||||
const ORIGIN = "https://metronome.varasys.io";
|
||||
const DEMO_PATCH = "v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2";
|
||||
const FF = [
|
||||
{ k:"editor", name:"PM_E‑1 Editor", file:"editor.html", h:560 },
|
||||
{ k:"kit", name:"PM_K‑1 Kit", file:"kit.html", h:560 },
|
||||
{ k:"teacher", name:"PM_T‑1 Teacher", file:"teacher.html", h:440 },
|
||||
{ k:"stage", name:"PM_S‑1 Stage", file:"stage.html", h:430 },
|
||||
{ k:"micro", name:"PM_P‑1 Practice", file:"micro.html", h:240 },
|
||||
{ k:"showcase", name:"PM_D‑1 Display", file:"showcase.html",h:540 },
|
||||
{ k:"initial", name:"PM_C‑1 Concept", file:"player.html", h:440 },
|
||||
];
|
||||
function updateFF(k){
|
||||
const v = FF.find(x => x.k === k) || FF[0];
|
||||
$("snipDrop").textContent =
|
||||
'<div data-varasys-metronome="' + v.k + '"\n data-patch="' + DEMO_PATCH + '"></div>\n' +
|
||||
'<script src="' + ORIGIN + '/embed.js"><\/script>';
|
||||
$("snipIframe").textContent =
|
||||
'<iframe src="' + ORIGIN + '/' + v.file + '?embed=1#p=' + DEMO_PATCH + '"\n' +
|
||||
' width="360" height="' + v.h + '" style="border:0"><\/iframe>';
|
||||
const f = $("demoFrame"); f.style.height = v.h + "px"; f.src = "/" + v.file + "?embed=1#p=" + encodeURIComponent(DEMO_PATCH);
|
||||
document.querySelectorAll(".ff-name").forEach(el => el.textContent = v.name);
|
||||
}
|
||||
$("ffSel").addEventListener("change", (e) => updateFF(e.target.value));
|
||||
addEventListener("message", (e) => {
|
||||
if (e.data && e.data.type === "varasys-h" && typeof e.data.h === "number" && e.source === $("demoFrame").contentWindow)
|
||||
$("demoFrame").style.height = e.data.h + "px";
|
||||
});
|
||||
updateFF($("ffSel").value || "micro");
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
</body>
|
||||
</html>
|
||||
55
embed.js
Normal file
55
embed.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/* VARASYS PolyMeter — embed loader (zero-dependency, ~1 KB).
|
||||
*
|
||||
* Drop a placeholder + this script into any page:
|
||||
* <div data-varasys-metronome="micro" data-patch="v1;t120;kick:4;snare:4=.X.X"></div>
|
||||
* <script src="https://metronome.varasys.io/embed.js"></script>
|
||||
*
|
||||
* Attributes:
|
||||
* data-varasys-metronome editor | kit | initial | teacher | stage | micro | showcase (which form factor)
|
||||
* data-patch a PolyMeter program/settings string (preloads it)
|
||||
* data-setlist a base64url set-list code (alternative to data-patch)
|
||||
* data-width / data-height iframe size (default 100% × 300; height auto-grows)
|
||||
*
|
||||
* Each placeholder becomes an <iframe src=".../<page>?embed=1#p=<patch>"> (the page's
|
||||
* own ?embed=1 mode strips the site chrome) and auto-resizes to the widget's content.
|
||||
*/
|
||||
(function () {
|
||||
var PAGES = { editor: "editor.html", kit: "kit.html", initial: "player.html", teacher: "teacher.html",
|
||||
stage: "stage.html", micro: "micro.html", showcase: "showcase.html" };
|
||||
var me = document.currentScript;
|
||||
var ORIGIN = me ? me.src.replace(/\/embed\.js(\?.*)?$/, "") : location.origin;
|
||||
|
||||
function build(el) {
|
||||
var v = (el.getAttribute("data-varasys-metronome") || "micro").toLowerCase();
|
||||
var page = PAGES[v] || "micro.html";
|
||||
var patch = el.getAttribute("data-patch");
|
||||
var sl = el.getAttribute("data-setlist");
|
||||
var hash = patch ? "#p=" + encodeURIComponent(patch)
|
||||
: sl ? "#sl=" + encodeURIComponent(sl) : "";
|
||||
var f = document.createElement("iframe");
|
||||
f.src = ORIGIN + "/" + page + "?embed=1" + hash;
|
||||
f.title = "VARASYS PolyMeter — " + v;
|
||||
f.loading = "lazy";
|
||||
f.setAttribute("allow", "autoplay");
|
||||
f.setAttribute("data-vmeter", v);
|
||||
f.style.cssText = "border:0;display:block;max-width:100%;width:" +
|
||||
(el.getAttribute("data-width") || "100%") + ";height:" +
|
||||
(el.getAttribute("data-height") || "300") + "px";
|
||||
(el.replaceWith ? el.replaceWith(f) : el.parentNode.replaceChild(f, el));
|
||||
}
|
||||
|
||||
function init() {
|
||||
var els = document.querySelectorAll("[data-varasys-metronome]");
|
||||
for (var i = 0; i < els.length; i++) build(els[i]);
|
||||
}
|
||||
|
||||
// auto-resize: the widget posts { type:'varasys-h', h } on load/resize
|
||||
window.addEventListener("message", function (e) {
|
||||
if (!e.data || e.data.type !== "varasys-h" || typeof e.data.h !== "number") return;
|
||||
var f = document.querySelectorAll("iframe[data-vmeter]");
|
||||
for (var i = 0; i < f.length; i++) if (f[i].contentWindow === e.source) f[i].style.height = e.data.h + "px";
|
||||
});
|
||||
|
||||
if (document.readyState !== "loading") init();
|
||||
else document.addEventListener("DOMContentLoaded", init);
|
||||
})();
|
||||
262
index.html
262
index.html
|
|
@ -2,68 +2,224 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PolyMeter</title>
|
||||
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VARASYS PolyMeter — Concepts (polymetric groove trainer & metronome)</title>
|
||||
<meta name="description" content="PolyMeter — a polymetric groove trainer and metronome. Design grooves in the editor and play them on any form factor; one engine, one program string." />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
(function(){ try{
|
||||
var p = localStorage.getItem("metronome.theme");
|
||||
(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>
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
|
||||
--card:#161b22; --card-bd:#2a313c; --cyan:#0AB3F7; }
|
||||
:root[data-theme="light"]{ --bg1:#eef3f9; --bg2:#cfd9e6; --txt:#10202f; --muted:#5c6776; --link:#1769c4;
|
||||
--card:#ffffff; --card-bd:#d2dae4; --cyan:#0AB3F7; }
|
||||
html,body{ height:100%; }
|
||||
body{ margin:0; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -10%, var(--bg1), var(--bg2));
|
||||
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-text-size-adjust:100%; }
|
||||
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
|
||||
main{ min-height:100%; box-sizing:border-box; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:clamp(20px,4vmin,40px); padding:max(24px,env(safe-area-inset-top)) 22px max(24px,env(safe-area-inset-bottom)); text-align:center; }
|
||||
.logo{ height:clamp(34px,7vmin,56px); width:auto; }
|
||||
.tagline{ color:var(--muted); font-size:clamp(13px,2.4vmin,17px); letter-spacing:.01em; margin:-6px 0 0; max-width:30ch; line-height:1.4; }
|
||||
.choices{ display:flex; flex-wrap:wrap; gap:clamp(16px,3vmin,28px); justify-content:center; width:100%; max-width:620px; }
|
||||
.choice{ flex:1 1 240px; min-width:0; max-width:300px; text-decoration:none; color:var(--txt);
|
||||
background:linear-gradient(180deg, rgba(127,139,154,.07), transparent), var(--card);
|
||||
border:1px solid var(--card-bd); border-radius:18px; padding:clamp(22px,4vmin,34px) 20px;
|
||||
display:flex; flex-direction:column; align-items:center; gap:10px;
|
||||
box-shadow:0 6px 22px rgba(0,0,0,.18); transition:transform .12s ease, border-color .12s ease, box-shadow .12s ease; }
|
||||
.choice:hover, .choice:focus-visible{ border-color:var(--cyan); transform:translateY(-3px); box-shadow:0 12px 30px rgba(10,179,247,.18); outline:none; }
|
||||
.choice:active{ transform:translateY(0); }
|
||||
.choice .ic{ font-size:clamp(34px,7vmin,52px); line-height:1; }
|
||||
.choice .lbl{ font-size:clamp(19px,3.4vmin,26px); font-weight:700; letter-spacing:.01em; }
|
||||
.choice .sub{ font-size:clamp(11px,2vmin,13px); color:var(--muted); line-height:1.4; }
|
||||
footer{ color:var(--muted); font-size:12px; line-height:1.7; }
|
||||
footer a{ color:var(--link); text-decoration:none; }
|
||||
footer a:hover{ text-decoration:underline; }
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
|
||||
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
|
||||
a{ color:var(--link); }
|
||||
main{ width:100%; max-width:1040px; margin:0 auto; }
|
||||
|
||||
.intro{ text-align:center; padding:34px 12px 18px; }
|
||||
.intro h1{ font-size:clamp(32px, 6.5vw, 54px); margin:0; letter-spacing:-.02em; line-height:1;
|
||||
background:linear-gradient(90deg, var(--cyan), #6cb6ff); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
.intro .tagline{ margin:13px auto 0; font-size:clamp(15px,2.4vw,19px); color:var(--txt); font-weight:600; }
|
||||
.intro p{ margin:11px auto 0; max-width:66ch; color:var(--muted); font-size:14.5px; line-height:1.6; }
|
||||
|
||||
.section-label{ text-align:center; font-size:11px; text-transform:uppercase; letter-spacing:.12em; color:var(--muted); margin:26px 0 12px; }
|
||||
/* summary panes — click to load that version into the viewport */
|
||||
.panes{ display:grid; grid-template-columns:repeat(auto-fit, minmax(225px, 1fr)); gap:12px; }
|
||||
.pane{ text-align:left; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:12px; padding:12px 13px;
|
||||
cursor:pointer; display:flex; flex-direction:column; gap:6px; transition:border-color .14s, box-shadow .14s; }
|
||||
.pane:hover{ border-color:var(--cyan); }
|
||||
.pane.active{ border-color:var(--cyan); box-shadow:0 0 0 1px var(--cyan) inset; }
|
||||
.pane .ph{ display:flex; align-items:center; gap:8px; }
|
||||
.pane h3{ margin:0; font-size:14px; }
|
||||
.pane .chip{ font-size:9px; text-transform:uppercase; letter-spacing:.07em; padding:2px 7px; border-radius:999px;
|
||||
border:1px solid var(--panel-bd); color:var(--muted); }
|
||||
.pane .chip.hw{ color:var(--cyan); border-color:rgba(10,179,247,.45); }
|
||||
.pane .chip.app{ color:#2fe07a; border-color:rgba(47,224,160,.45); }
|
||||
.pane p{ margin:0; font-size:12px; color:var(--muted); line-height:1.45; flex:1; }
|
||||
.pane .links{ display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
|
||||
.pane .open{ font-size:11px; color:var(--link); text-decoration:none; font-weight:600; }
|
||||
|
||||
/* viewport: the live, selected device */
|
||||
.viewport{ margin-top:16px; border:1px solid var(--panel-bd); border-radius:14px; overflow:hidden; background:var(--field-bg); }
|
||||
.vp-bar{ display:flex; align-items:center; justify-content:space-between; gap:10px; padding:8px 12px; border-bottom:1px solid var(--panel-bd); font-size:12px; color:var(--muted); }
|
||||
.vp-bar b{ color:var(--txt); }
|
||||
.vp-bar a{ font-size:12px; }
|
||||
#vp{ display:block; width:100%; height:620px; border:0; background:var(--field-bg); transition:height .15s; }
|
||||
|
||||
/* program I/O — decoded program string in/out (plain text or base64), linted */
|
||||
.prog{ display:flex; align-items:center; gap:9px; flex-wrap:wrap; margin-top:12px; padding:9px 12px;
|
||||
border:1px solid var(--panel-bd); border-radius:11px; background:var(--panel-bg); }
|
||||
.prog > label{ flex:0 0 auto; font-size:10px; text-transform:uppercase; letter-spacing:.09em; color:var(--muted); }
|
||||
.prog input{ flex:1; min-width:180px; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd);
|
||||
border-radius:8px; padding:8px 10px; font-family:"Courier New",monospace; font-size:12.5px; }
|
||||
.prog input.err{ border-color:#c0392b; }
|
||||
.prog button{ flex:0 0 auto; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:8px;
|
||||
padding:8px 13px; font-size:13px; cursor:pointer; }
|
||||
.prog button.primary{ background:linear-gradient(180deg,#34c6ff,var(--cyan)); color:#04121b; border-color:transparent; font-weight:600; }
|
||||
.prog button:hover{ border-color:var(--cyan); }
|
||||
.prog-msg{ flex:1 1 100%; font-size:11.5px; color:var(--muted); min-height:1.1em; }
|
||||
.prog-msg.ok{ color:#5fd08a; } .prog-msg.bad{ color:#ff8a7a; }
|
||||
.prog-hint{ flex:1 1 100%; font-size:11px; color:var(--muted); }
|
||||
|
||||
.philosophy{ margin-top:34px; }
|
||||
.phil-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:16px; }
|
||||
.phil{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:18px 18px 16px; }
|
||||
.phil h3{ margin:0 0 8px; font-size:15px; display:flex; align-items:center; gap:8px; }
|
||||
.phil p{ margin:0; font-size:13.5px; color:var(--muted); line-height:1.62; }
|
||||
.phil p b{ color:var(--txt); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img class="logo logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS PolyMeter" />
|
||||
<img class="logo logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS PolyMeter" />
|
||||
<p class="tagline">A polymetric groove trainer & metronome. Pick how you want to play.</p>
|
||||
<div class="choices">
|
||||
<a class="choice" href="/mobile.html">
|
||||
<span class="ic" aria-hidden="true">📱</span>
|
||||
<span class="lbl">Mobile</span>
|
||||
<span class="sub">Touch-first phone & tablet app — tap a beat, set the tempo, practice. Installable, works offline.</span>
|
||||
</a>
|
||||
<a class="choice" href="/pm_e-2.html">
|
||||
<span class="ic" aria-hidden="true">🎼</span>
|
||||
<span class="lbl">Desktop</span>
|
||||
<span class="sub">Engraved-notation editor — build rhythms on a staff with full keyboard control. Best on a big screen.</span>
|
||||
</a>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<section class="intro">
|
||||
<h1>PolyMeter</h1>
|
||||
<p class="tagline">Polymetric grooves — one engine, one program string, every form factor.</p>
|
||||
<p>Stack independent meter lanes — each with its own subdivision, drum voice and per‑step accents — to build
|
||||
true polymeter and ratio polyrhythm. Design a groove once; it saves to a compact <b>program string</b> that
|
||||
plays back identically on the web editor, the hardware concepts, or an embedded widget. The editor is open
|
||||
below — or pick any form factor to load and play the same groove on it.</p>
|
||||
</section>
|
||||
|
||||
<section class="philosophy">
|
||||
<div class="section-label">Philosophy</div>
|
||||
<div class="phil-grid">
|
||||
<div class="phil">
|
||||
<h3>🛠️ Program on the web, play on any device</h3>
|
||||
<p>The website is the workbench: design in the <a href="/editor.html">editor</a>, and the same
|
||||
<b>program string</b> loads into whichever form factor fits the moment. One engine, one language.</p>
|
||||
</div>
|
||||
<div class="phil">
|
||||
<h3>🔌 USB‑C power everywhere — no batteries</h3>
|
||||
<p>Every device runs over a single <b>USB‑C</b> port (the larger ones add a pass‑through to daisy‑chain).
|
||||
No internal battery to wear out; bring a power bank. One connector keeps it all <b>future‑proof</b>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
VARASYS PolyMeter · <a href="https://codeberg.org/VARASYS/metronome" target="_blank" rel="noopener">source on Codeberg</a>
|
||||
</footer>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<div class="section-label">Pick a form factor — it loads live below</div>
|
||||
<div class="panes" id="panes"></div>
|
||||
|
||||
<div class="viewport">
|
||||
<div class="vp-bar"><span id="vpName"><b>PM_E‑1 Editor</b></span><span><a id="vpInfo" href="/info-editor.html" target="_blank" rel="noopener">Specs & info ⓘ</a> · <a id="vpOpen" href="/editor.html" target="_blank" rel="noopener">Open full page ↗</a></span></div>
|
||||
<iframe id="vp" title="PolyMeter — live viewport" allow="autoplay"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="prog">
|
||||
<label for="prog">program</label>
|
||||
<input id="prog" spellcheck="false" autocomplete="off" autocapitalize="off"
|
||||
placeholder="v1;t120;kick:4;snare:4=.X.X;hat:4/2 — or paste a base64 set-list code">
|
||||
<button id="progLoad" class="primary" title="Decode, check, and load into the viewport">Load ▸</button>
|
||||
<button id="progCopy" title="Copy the program string">Copy</button>
|
||||
<div class="prog-msg" id="progMsg"></div>
|
||||
<div class="prog-hint">The current program, decoded (not base64). Paste a patch <i>or</i> a base64 set‑list code; it's checked, then loaded.
|
||||
Conventions: GM names or numbers (<code>kick</code> / <code>36</code>), <code>=X.x-</code> steps, <code>/2</code> subdivision, <code>(3,8)</code> euclidean, <code>@-3</code> dB, <code>~</code> polymeter.</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
/* engine codec (for decode + lint) + seed set lists (default program). No audio here — the viewport plays. */
|
||||
const SAMPLES = {}; let state = { bpm:120, volume:0.85 }, meters = [], muteWindows = [];
|
||||
/*@BUILD:include:src/engine.js@*/
|
||||
/*@BUILD:include:src/setlists.js@*/
|
||||
|
||||
const VERSIONS = [
|
||||
{ key:"editor", file:"/editor.html", name:"PM_E‑1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, per‑step accents/ghosts/mutes, swing & polyrhythm, set lists, per‑lane dB gain." },
|
||||
{ key:"kit", file:"/kit.html", name:"PM_K‑1 Kit", chip:"hw", h:560, sum:"Build it today — a Raspberry Pi Pico on the 52Pi touchscreen kit; tap the 3.5″ screen, joystick tempo, RGB beat light, buzzer. MicroPython firmware included." },
|
||||
{ key:"teacher", file:"/teacher.html", name:"PM_T‑1 Teacher", chip:"hw", h:440, sum:"Studio / lesson desk console — colour TFT of every lane, arcade buttons, instrument pass‑through." },
|
||||
{ key:"stage", file:"/stage.html", name:"PM_S‑1 Stage", chip:"hw", h:430, sum:"Live foot pedal — two footswitches, expression‑pedal tempo, a big floor‑readable RGB beat light." },
|
||||
{ key:"micro", file:"/micro.html", name:"PM_P‑1 Practice", chip:"hw", h:240, sum:"Inline practice bar — clickable thumb‑roller, amber 14‑segment, instrument in/out pass‑through." },
|
||||
{ key:"showcase", file:"/showcase.html",name:"PM_D‑1 Display", chip:"hw", h:540, sum:"Pyramid display piece — an RGB‑light pendulum combining every lane's subdivisions & accents." },
|
||||
{ key:"initial", file:"/player.html", name:"PM_C‑1 Concept", chip:"", h:440, sum:"The idealized concept render — full multi‑lane display and set‑list navigation." },
|
||||
];
|
||||
const DEFAULT_PROG = (typeof SEED_SETLISTS !== "undefined" && SEED_SETLISTS[0] && SEED_SETLISTS[0].items[0] && SEED_SETLISTS[0].items[0][1]) || "v1;t120;kick:4;snare:4=.X.X;hat:4/2";
|
||||
|
||||
let cur = "editor", userEditing = false;
|
||||
const vp = $("vp"), box = $("prog");
|
||||
const verOf = (k) => VERSIONS.find((v) => v.key === k);
|
||||
|
||||
function renderPanes() {
|
||||
$("panes").innerHTML = VERSIONS.map((v) => `
|
||||
<div class="pane" data-key="${v.key}" role="button" tabindex="0">
|
||||
<div class="ph"><span class="chip ${v.chip}">${v.chip === "app" ? "Web app" : v.chip === "hw" ? "Hardware" : "Concept"}</span><h3>${v.name}</h3></div>
|
||||
<p>${v.sum}</p>
|
||||
<div class="links">
|
||||
<a class="open" href="${v.file}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Open ↗</a>
|
||||
<a class="open" href="${v.file.replace("/", "/info-")}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Specs & info ⓘ</a>
|
||||
</div>
|
||||
</div>`).join("");
|
||||
$("panes").querySelectorAll(".pane").forEach((el) => {
|
||||
const k = el.dataset.key;
|
||||
el.addEventListener("click", () => loadVersion(k));
|
||||
el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); loadVersion(k); } });
|
||||
});
|
||||
}
|
||||
function loadVersion(key, prog) {
|
||||
cur = key; const v = verOf(key);
|
||||
$("panes").querySelectorAll(".pane").forEach((p) => p.classList.toggle("active", p.dataset.key === key));
|
||||
$("vpName").innerHTML = "<b>" + v.name + "</b>";
|
||||
$("vpOpen").href = v.file;
|
||||
$("vpInfo").href = v.file.replace("/", "/info-");
|
||||
vp.style.height = (v.h || 440) + "px";
|
||||
vp.src = v.file + "?embed=1" + (prog ? "#p=" + encodeURIComponent(prog) : "");
|
||||
if (prog && !userEditing) box.value = prog;
|
||||
}
|
||||
|
||||
/* program I/O: lint a patch OR a base64 set-list code, return canonical plain text */
|
||||
function lintProgram(text) {
|
||||
text = (text || "").trim(); if (!text) return { ok:false, msg:"empty — type or paste a program" };
|
||||
const m = text.match(/[#?&](p|sl)=([^&\s]+)/); let kind = null, payload = text;
|
||||
if (m) { kind = m[1]; try { payload = decodeURIComponent(m[2]); } catch (e) { payload = m[2]; } }
|
||||
const b64 = /^[A-Za-z0-9_-]{12,}$/.test(payload) && !/[;:]/.test(payload);
|
||||
try {
|
||||
if (kind === "sl" || (kind !== "p" && b64)) {
|
||||
const sl = codeToSetlist(payload);
|
||||
if (!sl.items || !sl.items.length) throw new Error("set-list code has no items");
|
||||
return { ok:true, plain:setupToPatch(sl.items[0]), msg:"decoded set list “" + sl.title + "” (" + sl.items.length + " item" + (sl.items.length>1?"s":"") + ") — loading item 1" };
|
||||
}
|
||||
const s = patchToSetup(payload);
|
||||
if (!s.lanes.length) throw new Error("no lanes — try e.g. kick:4");
|
||||
return { ok:true, plain:setupToPatch(s), msg:s.lanes.length + " lane" + (s.lanes.length>1?"s":"") + " · " + s.bpm + " BPM" };
|
||||
} catch (e) { return { ok:false, msg:"✗ " + e.message }; }
|
||||
}
|
||||
function setMsg(t, ok) { const m = $("progMsg"); m.textContent = t || ""; m.classList.toggle("ok", !!ok && !!t); m.classList.toggle("bad", !ok && !!t); }
|
||||
function doLoad() {
|
||||
const r = lintProgram(box.value);
|
||||
if (!r.ok) { box.classList.add("err"); setMsg(r.msg, false); return; }
|
||||
box.classList.remove("err"); box.value = r.plain; setMsg("✓ " + r.msg, true);
|
||||
loadVersion(cur, r.plain);
|
||||
}
|
||||
$("progLoad").addEventListener("click", doLoad);
|
||||
$("prog").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); doLoad(); } });
|
||||
$("prog").addEventListener("focus", () => userEditing = true);
|
||||
$("prog").addEventListener("blur", () => userEditing = false);
|
||||
$("prog").addEventListener("input", () => box.classList.remove("err"));
|
||||
$("progCopy").addEventListener("click", async () => { try { await navigator.clipboard.writeText(box.value); const b = $("progCopy"); b.textContent = "Copied!"; setTimeout(() => b.textContent = "Copy", 1200); } catch (e) { box.select(); } });
|
||||
|
||||
/* the viewport reports its height + current program back to us */
|
||||
addEventListener("message", (e) => {
|
||||
if (!e.data || e.source !== vp.contentWindow) return;
|
||||
if (e.data.type === "varasys-h" && typeof e.data.h === "number") vp.style.height = e.data.h + "px";
|
||||
else if (e.data.type === "varasys-prog" && typeof e.data.patch === "string" && !userEditing) { box.value = e.data.patch; box.classList.remove("err"); }
|
||||
});
|
||||
|
||||
renderPanes();
|
||||
// default = each device's built-in set lists (no forced program); the box fills from what the device reports
|
||||
loadVersion("editor");
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
68
info-editor.html
Normal file
68
info-editor.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VARASYS PM_E‑1 PolyMeter Editor — what it is</title>
|
||||
<meta name="description" content="PM_E‑1 PolyMeter Editor — the web workbench for the family: stack independent meter lanes with their own subdivision, drum voice, per‑step accents/ghosts/mutes, swing, polyrhythm, set lists and per‑lane dB gain. Design once; it plays on any form factor." />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
(function(){ try{ var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; --silk:#aab2bc; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
|
||||
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
|
||||
a{ color:var(--link); }
|
||||
main{ width:100%; max-width:980px; margin:0 auto; }
|
||||
.info-hero{ text-align:center; padding:16px 8px 2px; }
|
||||
.info-hero h1{ font-size:clamp(24px,5vw,36px); margin:0; letter-spacing:-.01em; }
|
||||
.info-hero .sub{ margin:9px auto 0; max-width:64ch; font-size:14.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<section class="info-hero">
|
||||
<h1>PM_E‑1 PolyMeter Editor</h1>
|
||||
<p class="sub">The web workbench for the whole family — design grooves here, and the same program string plays identically on every form factor.</p>
|
||||
</section>
|
||||
|
||||
/*@BUILD:include:src/infoembed.html@*/
|
||||
|
||||
<section class="about">
|
||||
<h2>What it is</h2>
|
||||
<div class="ff-tags"><span>Web app</span><span>The workbench</span><span>Runs in any browser</span></div>
|
||||
<p>The editor is where you build a groove: stack independent <b>meter lanes</b>, each with its own subdivision,
|
||||
drum voice and per‑step <b>accents, ghosts and mutes</b>, plus swing, ratio polyrhythm, set lists and a
|
||||
per‑lane <b>dB gain</b>. It's zero‑install — it runs in any modern browser and works fully offline.</p>
|
||||
<p>Everything you design saves to a compact <b>program string</b> in the shared share‑language. That same string
|
||||
loads into any of the hardware concepts or an embedded widget, so a groove built here plays back identically
|
||||
everywhere. There's no bill of materials — it's the software workbench, not a buildable device; the buildable
|
||||
realizations are the <a href="/info-teacher.html">Teacher</a>, <a href="/info-stage.html">Stage</a>,
|
||||
<a href="/info-micro.html">Practice</a> and <a href="/info-showcase.html">Display</a>.</p>
|
||||
</section>
|
||||
|
||||
<p class="sub" style="max-width:760px;margin:14px auto 0">Embed the editor (or any device) elsewhere with one <code><div></code> + a script —
|
||||
see <a href="/embed.html">the embed docs</a>.</p>
|
||||
</main>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
window.INFO_DEVICE = { file:"/editor.html", name:"PM_E‑1 PolyMeter Editor" };
|
||||
/*@BUILD:include:src/infoembed.js@*/
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
172
info-kit.html
Normal file
172
info-kit.html
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VARASYS PM_K‑1 Kit — wiring, parts & firmware (Raspberry Pi Pico build)</title>
|
||||
<meta name="description" content="PM_K‑1 Kit — build a touchscreen polymeter metronome from a Raspberry Pi Pico on the 52Pi EP‑0172 breadboard kit (3.5in ST7796 cap‑touch, joystick, RGB, buzzer). Pinout, parts list, and the MicroPython firmware to flash." />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
(function(){ try{ var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; --silk:#aab2bc; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
|
||||
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
|
||||
a{ color:var(--link); }
|
||||
main{ width:100%; max-width:980px; margin:0 auto; }
|
||||
.info-hero{ text-align:center; padding:16px 8px 2px; }
|
||||
.info-hero h1{ font-size:clamp(24px,5vw,36px); margin:0; letter-spacing:-.01em; }
|
||||
.info-hero .sub{ margin:9px auto 0; max-width:64ch; font-size:14.5px; }
|
||||
.steps{ width:100%; max-width:760px; margin:8px auto 0; color:var(--muted); font-size:14px; line-height:1.6; }
|
||||
.steps li{ margin:5px 0; }
|
||||
.steps code, .about code, .sub code { background:var(--field-bg); border:1px solid var(--field-bd); border-radius:5px; padding:1px 5px; font-size:12.5px; }
|
||||
.dl{ display:inline-flex; align-items:center; gap:7px; margin:4px 10px 4px 0; padding:9px 14px; border-radius:10px;
|
||||
background:linear-gradient(180deg,#34c6ff,var(--cyan)); color:#04121b; font-weight:700; text-decoration:none; font-size:13.5px; }
|
||||
.dl.alt{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); font-weight:600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<section class="info-hero">
|
||||
<h1>PM_K‑1 Kit</h1>
|
||||
<p class="sub">Build it yourself: a Raspberry Pi Pico on the 52Pi breadboard kit becomes a touchscreen polymeter metronome — same engine, same program strings, with MicroPython firmware you flash in two minutes.</p>
|
||||
</section>
|
||||
|
||||
/*@BUILD:include:src/infoembed.html@*/
|
||||
|
||||
<section class="about">
|
||||
<h2>What it is</h2>
|
||||
<div class="ff-tags"><span class="hw">Buildable now</span><span>Raspberry Pi Pico</span><span>52Pi EP‑0172 kit</span><span>~$45 incl. Pico</span></div>
|
||||
<p>This is the first member of the family you can actually build today from off‑the‑shelf parts: a
|
||||
<b>Raspberry Pi Pico</b> seated on the <b>52Pi EP‑0172 "Pico Breadboard Kit Plus"</b>, which carries a
|
||||
3.5″ <b>ST7796</b> 320×480 capacitive‑touch screen (<b>GT911</b>), a PSP <b>joystick</b>, a <b>WS2812 RGB</b>
|
||||
LED, a <b>buzzer</b> and two buttons — all pre‑wired, so you don't solder anything; you just seat the Pico
|
||||
and copy one file onto it.</p>
|
||||
<p>It runs the same <b>polymeter engine</b> and the same <b>program strings</b> as the web editor: design a
|
||||
groove on the site, copy its program string into the firmware's <code>PROGRAMS</code> list, and it plays on
|
||||
the device. Tap the screen, nudge tempo with the joystick; the RGB flashes each beat (amber accent / cyan
|
||||
normal / violet ghost) and the buzzer clicks. Powered over the Pico's USB.</p>
|
||||
</section>
|
||||
|
||||
<details class="spec" open>
|
||||
<summary>Wiring — the EP‑0172 fixed pinout (Raspberry Pi Pico)</summary>
|
||||
<div class="spec-body">
|
||||
<p class="sub">Everything is wired on the board; this is just what the firmware drives. No breadboarding required.</p>
|
||||
<table class="bom">
|
||||
<thead><tr><th>Component</th><th>Raspberry Pi Pico pins</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="2">Display — 3.5″ ST7796, 320×480 (SPI0)</td></tr>
|
||||
<tr><td class="part">SCK / MOSI</td><td>GP2 / GP3</td></tr>
|
||||
<tr><td class="part">CS / DC / RST</td><td>GP5 / GP6 / GP7</td></tr>
|
||||
<tr class="grp"><td colspan="2">Touch — GT911 capacitive (I2C0)</td></tr>
|
||||
<tr><td class="part">SDA / SCL <span class="spec">— addr 0x5D</span></td><td>GP8 / GP9</td></tr>
|
||||
<tr class="grp"><td colspan="2">Controls & feedback</td></tr>
|
||||
<tr><td class="part">PSP joystick X / Y</td><td>ADC0 (GP26) / ADC1 (GP27)</td></tr>
|
||||
<tr><td class="part">Button A (play/stop) / Button B (tap)</td><td>GP15 / GP14</td></tr>
|
||||
<tr><td class="part">WS2812 RGB LED</td><td>GP12</td></tr>
|
||||
<tr><td class="part">Buzzer</td><td>GP13</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="spec" open>
|
||||
<summary>Parts</summary>
|
||||
<div class="spec-body">
|
||||
<p class="sub">An off‑the‑shelf kit, not a custom board — ballpark one‑off prices (USD).</p>
|
||||
<table class="bom">
|
||||
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="part">Raspberry Pi Pico (or Pico W / Pico 2) <span class="spec">— the brain</span></td><td class="q">1</td><td class="c">5</td></tr>
|
||||
<tr><td class="part">52Pi EP‑0172 "Pico Breadboard Kit Plus" <span class="spec">— 3.5″ ST7796 cap‑touch, GT911, PSP joystick, WS2812 RGB, buzzer, 2 buttons, breadboard, acrylic panel</span></td><td class="q">1</td><td class="c">38</td></tr>
|
||||
<tr><td class="part">USB cable <span class="spec">— power + flashing</span></td><td class="q">1</td><td class="c">2</td></tr>
|
||||
<tr class="total"><td>Total (one‑off)</td><td class="q"></td><td class="c">≈ $45</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="sub" style="margin-top:10px">Reference: <a href="https://wiki.52pi.com/index.php?title=EP-0172" target="_blank" rel="noopener">52Pi EP‑0172 wiki</a>
|
||||
· <a href="https://github.com/geeekpi/pico_breakboard_kit" target="_blank" rel="noopener">vendor code</a>. Lots may ship the screen as ST7796 (320×480) — this build targets that.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="spec" open>
|
||||
<summary>Firmware — flash it in two minutes</summary>
|
||||
<div class="spec-body">
|
||||
<p>
|
||||
<a class="dl" href="/pico-main.py" download="main.py">Download main.py ↓</a>
|
||||
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico" target="_blank" rel="noopener">Source + README ↗</a>
|
||||
</p>
|
||||
<p class="sub"><b>Two separate steps</b> — and <b><code>main.py</code> is not a drag‑and‑drop file.</b> The
|
||||
<code>RPI‑RP2</code> drive only accepts a <code>.uf2</code> firmware file; a <code>.py</code> copied there is
|
||||
discarded on reboot. You drag‑and‑drop the firmware once, then copy <code>main.py</code> over USB serial.</p>
|
||||
<ol class="steps">
|
||||
<li><b>Install MicroPython</b> (drag‑and‑drop, one time): hold <b>BOOTSEL</b>, plug the Pico into USB, and drop
|
||||
the MicroPython <code>.uf2</code> onto the <code>RPI‑RP2</code> drive
|
||||
(<a href="https://micropython.org/download/RPI_PICO/" target="_blank" rel="noopener">Pico</a> /
|
||||
<a href="https://micropython.org/download/RPI_PICO2/" target="_blank" rel="noopener">Pico 2</a>). It reboots
|
||||
on its own and the drive disappears — that's correct.</li>
|
||||
<li><b>Copy <code>main.py</code></b> (the Pico is no longer a USB drive, so use a serial tool):
|
||||
in <a href="https://thonny.org" target="_blank" rel="noopener">Thonny</a> pick the interpreter
|
||||
<i>MicroPython (Raspberry Pi Pico)</i>, then <i>File ▸ Save as ▸ Raspberry Pi Pico</i> as <code>main.py</code>;
|
||||
or <code>mpremote cp main.py :main.py</code>.</li>
|
||||
<li>Reset — it boots straight into the metronome.</li>
|
||||
<li>Add your own grooves by pasting program strings from the editor into the <code>PROGRAMS</code> list at the
|
||||
top of <code>main.py</code>. If colours, touch, or the joystick look off, flip a flag in the
|
||||
<code>CONFIG</code> block (see the README's calibration notes).</li>
|
||||
</ol>
|
||||
<p class="sub">It's one self‑contained file — the ST7796 driver, GT911 touch, WS2812 RGB, buzzer and the
|
||||
polymeter engine, no external libraries.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="spec">
|
||||
<summary>CircuitPython edition — USB drive + editor (experimental)</summary>
|
||||
<div class="spec-body">
|
||||
<p class="sub">An alternative firmware that makes the Pico mount as a <b>USB drive</b> carrying the
|
||||
firmware, your tracks (<code>programs.json</code>) and a copy of this editor — design grooves on the
|
||||
web and write them straight to the device, no Thonny. A full lanes/pads display with anti‑aliased
|
||||
text drives the touchscreen, and it plays out your <b>computer's speakers over USB‑MIDI</b> (the
|
||||
editor's <b>🎹 Device audio</b> button voices it, locked to the device clock). The MicroPython
|
||||
firmware above stays the simple, rock‑solid option.</p>
|
||||
<p>
|
||||
<a class="dl" href="/pm_k1_circuitpy.zip" download>Download CircuitPython bundle ↓</a>
|
||||
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-cp" target="_blank" rel="noopener">Source + README ↗</a>
|
||||
</p>
|
||||
<ol class="steps">
|
||||
<li>Flash <b>CircuitPython</b> (<a href="https://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">raspberry_pi_pico</a>)
|
||||
via BOOTSEL → the <code>CIRCUITPY</code> drive appears.</li>
|
||||
<li>Unzip the bundle onto <code>CIRCUITPY</code> (it's a normal drive — just drag everything on). It runs on boot.</li>
|
||||
<li><b>Reprogram it from the web:</b> build a set list in the <a href="/editor.html">editor</a>, then the
|
||||
set‑list <b>⋯</b> menu → <b>📟 Save to device</b> → pick the <code>CIRCUITPY</code> drive. The Pico
|
||||
auto‑reloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it
|
||||
downloads <code>programs.json</code> to drag on.) <b>📥 Load from device</b> reads it back.</li>
|
||||
<li><b>Play through your computer:</b> in the editor (Chrome/Edge) click <b>🎹 Device audio</b>, then
|
||||
press play on the device — its full groove sounds through your speakers over USB‑MIDI, in sync.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="sub" style="max-width:760px;margin:14px auto 0">Embed this widget elsewhere with one <code><div></code> + a script —
|
||||
see <a href="/embed.html">the embed docs</a>.</p>
|
||||
</main>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
window.INFO_DEVICE = { file:"/kit.html", name:"PM_K‑1 Kit" };
|
||||
/*@BUILD:include:src/infoembed.js@*/
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
129
info-micro.html
Normal file
129
info-micro.html
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VARASYS PM_P‑1 Practice — purpose, dimensions & bill of materials</title>
|
||||
<meta name="description" content="PM_P‑1 Practice — a long, narrow inline practice bar: one clickable thumb‑roller, amber 14‑segment display, analog instrument pass‑through with the click mixed in. Dimensions and a priced BOM." />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
(function(){ try{ var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; --silk:#aab2bc; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
|
||||
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
|
||||
a{ color:var(--link); }
|
||||
main{ width:100%; max-width:980px; margin:0 auto; }
|
||||
.info-hero{ text-align:center; padding:16px 8px 2px; }
|
||||
.info-hero h1{ font-size:clamp(24px,5vw,36px); margin:0; letter-spacing:-.01em; }
|
||||
.info-hero .sub{ margin:9px auto 0; max-width:64ch; font-size:14.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<section class="info-hero">
|
||||
<h1>PM_P‑1 Practice</h1>
|
||||
<p class="sub">A long, narrow inline practice bar — patch it into your signal, drive everything from one clickable thumb‑roller, and read tempo and track names off an amber 14‑segment display.</p>
|
||||
</section>
|
||||
|
||||
/*@BUILD:include:src/infoembed.html@*/
|
||||
|
||||
<section class="about">
|
||||
<h2>What it is</h2>
|
||||
<div class="ff-tags"><span class="hw">Hardware</span><span>Inline practice bar</span><span>~$35 one‑off</span></div>
|
||||
<p>A long, narrow practice bar you patch <i>into</i> your signal: instrument in one end, amp or headphones out
|
||||
the other, the click mixed in. One clickable thumb‑roller does everything (roll = tempo, press = start/stop,
|
||||
hold + roll = switch track), and an amber 14‑segment display shows tempo and track names.</p>
|
||||
<p>The click is summed into your signal in the <b>analog domain</b> (plus a small monitor speaker). Powered over
|
||||
USB‑C — a wall adapter for a permanent practice‑space install, or a pocket power bank when you're mobile (no
|
||||
internal battery to wear out); ships with the editor's grooves built in.</p>
|
||||
</section>
|
||||
|
||||
<div class="dview">
|
||||
<p class="cap">Dimensions & layout — ≈ 6.3 × 1.4 × 1.0 in (160 × 36 × 26 mm), an extruded bar</p>
|
||||
<div class="drow">
|
||||
<div class="dvy">↕ 1.4 in<br>(36 mm)</div>
|
||||
<div class="dschem" style="height:70px">
|
||||
<span class="scap">Front (top face)</span>
|
||||
<div class="scr" style="left:9%; top:24px; width:42%; height:30px"></div>
|
||||
<div class="ctl" style="left:60%; top:21px; width:36px; height:36px; border-radius:8px"></div>
|
||||
<div class="jl" style="left:9%; bottom:5px">14‑seg display</div>
|
||||
<div class="jl" style="left:58%; bottom:5px">thumb‑roller</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dvx">↔ 6.3 in (160 mm) long</div>
|
||||
<div class="drow" style="margin-top:12px; gap:14px">
|
||||
<div style="flex:1">
|
||||
<div class="dschem" style="height:64px">
|
||||
<span class="scap">Left end</span>
|
||||
<div class="jk" style="left:calc(50% - 6px); top:26px"></div>
|
||||
<div class="jl" style="left:0; right:0; bottom:5px">TRS In (instrument)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<div class="dschem" style="height:64px">
|
||||
<span class="scap">Right end</span>
|
||||
<div class="jk u" style="left:30%; top:29px"></div>
|
||||
<div class="jk" style="left:58%; top:26px"></div>
|
||||
<div class="jl" style="left:0; right:0; bottom:5px">USB‑C · TRS Out</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dvx" style="margin-left:0">↕ ends ≈ 1.0 in (26 mm) deep</div>
|
||||
</div>
|
||||
|
||||
<details class="spec" open>
|
||||
<summary>Spec & bill of materials</summary>
|
||||
<div class="spec-body">
|
||||
<p class="sub">Rough parts list — a USB‑C‑powered RP2040 inline bar with analog click injection.
|
||||
Ballpark one‑off prices (USD); cheaper at volume.</p>
|
||||
<table class="bom">
|
||||
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Brain & display</td></tr>
|
||||
<tr><td class="part">RP2040 board, USB‑C <span class="spec">— e.g. Waveshare RP2040‑Zero</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr><td class="part">4‑char 14‑segment alphanumeric LED + I²C driver <span class="spec">— amber; HT16K33. Shows BPM & track names</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr class="grp"><td colspan="3">Control</td></tr>
|
||||
<tr><td class="part">Clickable thumb‑roller <span class="spec">— EC11 encoder + roller wheel · roll / press / hold‑roll</span></td><td class="q">1</td><td class="c">2</td></tr>
|
||||
<tr class="grp"><td colspan="3">Audio — analog click injection</td></tr>
|
||||
<tr><td class="part">PCM5102A I²S DAC <span class="spec">— line‑level click</span></td><td class="q">1</td><td class="c">3</td></tr>
|
||||
<tr><td class="part">Dual op‑amp, NE5532 / OPA2134 <span class="spec">— hi‑Z instrument buffer + summing mixer</span></td><td class="q">1</td><td class="c">1</td></tr>
|
||||
<tr><td class="part">PAM8302A mono Class‑D + 8 Ω speaker <span class="spec">— monitor</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr class="grp"><td colspan="3">Connectors & power</td></tr>
|
||||
<tr><td class="part">1/4″ jack <span class="spec">— Inst In (TS) · Out (TRS)</span></td><td class="q">2</td><td class="c">2</td></tr>
|
||||
<tr><td class="part">USB‑C bus power (5 V) + PWR LED <span class="spec">— wall adapter or power bank; also carries config</span></td><td class="q">1</td><td class="c">1</td></tr>
|
||||
<tr class="grp"><td colspan="3">Build</td></tr>
|
||||
<tr><td class="part">Custom PCB (or perfboard)</td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr><td class="part">Passives, headers, wire <span class="spec">— R/C for the analog stage + decoupling</span></td><td class="q">—</td><td class="c">2</td></tr>
|
||||
<tr><td class="part">Extruded aluminium bar enclosure + end caps <span class="spec">— bead‑blasted, matte‑black anodised</span></td><td class="q">1</td><td class="c">8</td></tr>
|
||||
<tr class="total"><td>Total (one‑off)</td><td class="q"></td><td class="c">≈ $35</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="sub" style="max-width:760px;margin:14px auto 0">Embed this widget elsewhere with one <code><div></code> + a script —
|
||||
see <a href="/embed.html">the embed docs</a>.</p>
|
||||
</main>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
window.INFO_DEVICE = { file:"/micro.html", name:"PM_P‑1 Practice" };
|
||||
/*@BUILD:include:src/infoembed.js@*/
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
68
info-player.html
Normal file
68
info-player.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VARASYS PM_C‑1 Concept — the idealized player</title>
|
||||
<meta name="description" content="PM_C‑1 Concept — the idealized, screen‑first player render: full set‑list navigation, a colour beat display of every lane, theming and a fullscreen stage view. The buildable realizations are the Teacher and Practice units." />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
(function(){ try{ var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; --silk:#aab2bc; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
|
||||
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
|
||||
a{ color:var(--link); }
|
||||
main{ width:100%; max-width:980px; margin:0 auto; }
|
||||
.info-hero{ text-align:center; padding:16px 8px 2px; }
|
||||
.info-hero h1{ font-size:clamp(24px,5vw,36px); margin:0; letter-spacing:-.01em; }
|
||||
.info-hero .sub{ margin:9px auto 0; max-width:64ch; font-size:14.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<section class="info-hero">
|
||||
<h1>PM_C‑1 Concept</h1>
|
||||
<p class="sub">The idealized concept render — a clean, screen‑first player with a colour beat display, set‑list navigation, theming and a fullscreen landscape view.</p>
|
||||
</section>
|
||||
|
||||
/*@BUILD:include:src/infoembed.html@*/
|
||||
|
||||
<section class="about">
|
||||
<h2>What it is</h2>
|
||||
<div class="ff-tags"><span>Concept</span><span>Idealized device</span><span>Not buildable as drawn</span></div>
|
||||
<p>The idealized concept (PM_C‑1): the player as a clean, screen‑first device with no concession to mechanical parts yet.
|
||||
It's the look we design <i>toward</i> — full set‑list navigation, a colour beat display showing every lane,
|
||||
light/dark theming, and a fullscreen landscape "stage" view. It runs the same engine and program strings as
|
||||
everything else in the family, but as an <i>idealized</i> object, before deciding which buttons, encoders,
|
||||
jacks and enclosure actually make it real.</p>
|
||||
<p>Because it's a concept, there's <b>no bill of materials</b> — there's nothing to source for a render. The
|
||||
buildable realization of this idea is the <a href="/info-teacher.html">PM_T‑1 Teacher</a> (full priced BOM there);
|
||||
for the smallest practical unit, see the <a href="/info-micro.html">PM_P‑1 Practice</a>.</p>
|
||||
</section>
|
||||
|
||||
<p class="sub" style="max-width:760px;margin:14px auto 0">Embed this widget elsewhere with one <code><div></code> + a script —
|
||||
see <a href="/embed.html">the embed docs</a>.</p>
|
||||
</main>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
window.INFO_DEVICE = { file:"/player.html", name:"PM_C‑1 Concept" };
|
||||
/*@BUILD:include:src/infoembed.js@*/
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
123
info-showcase.html
Normal file
123
info-showcase.html
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VARASYS PM_D‑1 Display — purpose, dimensions & bill of materials</title>
|
||||
<meta name="description" content="PM_D‑1 Display — a pyramid display‑piece metronome whose pendulum is an RGB light bar combining every lane's subdivisions and accents, with a printed tempo scale and sliding weight. Dimensions and a priced BOM." />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
(function(){ try{ var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; --silk:#aab2bc; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
|
||||
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
|
||||
a{ color:var(--link); }
|
||||
main{ width:100%; max-width:980px; margin:0 auto; }
|
||||
.info-hero{ text-align:center; padding:16px 8px 2px; }
|
||||
.info-hero h1{ font-size:clamp(24px,5vw,36px); margin:0; letter-spacing:-.01em; }
|
||||
.info-hero .sub{ margin:9px auto 0; max-width:64ch; font-size:14.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<section class="info-hero">
|
||||
<h1>PM_D‑1 Display</h1>
|
||||
<p class="sub">A display‑piece metronome — the pendulum is an RGB light bar that combines every lane's subdivisions & accents; a printed tempo scale with a sliding weight sets the tempo.</p>
|
||||
</section>
|
||||
|
||||
/*@BUILD:include:src/infoembed.html@*/
|
||||
|
||||
<section class="about">
|
||||
<h2>What it is</h2>
|
||||
<div class="ff-tags"><span class="hw">Hardware</span><span>Display piece</span><span>~$41 one‑off</span></div>
|
||||
<p>A metronome as an object: the silhouette of a classic pyramid wind‑up unit, but the swinging pendulum is
|
||||
pure <b>RGB light</b>. The whole bar is the display — every lane's subdivisions & accents ride along its
|
||||
length as moving points of light (all meters combined), a printed tempo scale runs up the vertical axis,
|
||||
and a sliding <b>weight</b> sets the tempo just like the mechanical original.</p>
|
||||
<p>It's a beautiful, glanceable tempo reference for the shelf, the studio, or a shop window: accents glow
|
||||
amber, normal steps cyan, ghosts soft violet, and the pendulum eases to each beat exactly as a weighted rod
|
||||
would. It runs the same grooves as everything else (load any program string), plays the click through a
|
||||
small speaker, and is powered over USB‑C with a second "thru" port to daisy‑chain. There's no power switch —
|
||||
the real unit starts when you lift it from its holder / set it swinging. No instrument I/O; it's a showpiece.</p>
|
||||
</section>
|
||||
|
||||
<div class="dview">
|
||||
<p class="cap">Dimensions & profile — ≈ 4.7 × 7.1 × 3.1 in (120 × 180 × 80 mm), a truncated‑pyramid plinth</p>
|
||||
<div class="drow">
|
||||
<div class="dvy">↕ 7.1 in (180 mm)</div>
|
||||
<div class="dschem" style="height:184px">
|
||||
<span class="scap">Front</span>
|
||||
<div style="position:absolute; inset:6px; clip-path:polygon(34% 2%, 66% 2%, 93% 98%, 7% 98%);
|
||||
background:linear-gradient(180deg,#2c2e34,#15171b); border:1px solid #33363c;"></div>
|
||||
<div style="position:absolute; left:calc(50% - 4px); top:24px; width:8px; height:120px; border-radius:4px;
|
||||
background:linear-gradient(180deg,#33d0ff,#178fb0); box-shadow:0 0 10px #33d0ff;"></div>
|
||||
<div class="jl" style="left:0; right:0; bottom:5px">RGB‑light pendulum bar + tempo scale</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dvx">↔ 4.7 in (120 mm) base</div>
|
||||
<div class="drow" style="margin-top:12px">
|
||||
<div class="dvy">↕ 7.1 in (180 mm)</div>
|
||||
<div class="dschem" style="height:184px; max-width:200px">
|
||||
<span class="scap">Side</span>
|
||||
<div style="position:absolute; inset:6px; clip-path:polygon(40% 2%, 60% 2%, 86% 98%, 14% 98%);
|
||||
background:linear-gradient(180deg,#26282d,#141519); border:1px solid #33363c;"></div>
|
||||
<div class="jk u" style="left:calc(50% - 7px); bottom:14px; top:auto"></div>
|
||||
<div class="jl" style="left:0; right:0; bottom:1px">USB‑C in base</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dvx">↔ 3.1 in (80 mm) deep</div>
|
||||
</div>
|
||||
|
||||
<details class="spec" open>
|
||||
<summary>Spec & bill of materials</summary>
|
||||
<div class="spec-body">
|
||||
<p class="sub">Rough parts list — a USB‑C‑powered RP2040 display piece driving addressable RGB light.
|
||||
Ballpark one‑off prices (USD); cheaper at volume.</p>
|
||||
<table class="bom">
|
||||
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Brain</td></tr>
|
||||
<tr><td class="part">RP2040 board, USB‑C <span class="spec">— e.g. Waveshare RP2040‑Zero</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr class="grp"><td colspan="3">RGB light</td></tr>
|
||||
<tr><td class="part">Addressable RGB LEDs (WS2812B) <span class="spec">— a strip down the pendulum bar, ~40 px</span></td><td class="q">1</td><td class="c">5</td></tr>
|
||||
<tr><td class="part">Frosted acrylic diffuser / light‑guide <span class="spec">— the glowing pendulum bar</span></td><td class="q">1</td><td class="c">3</td></tr>
|
||||
<tr class="grp"><td colspan="3">Audio</td></tr>
|
||||
<tr><td class="part">MAX98357A I²S amp + small speaker <span class="spec">— the click</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr class="grp"><td colspan="3">Power & build</td></tr>
|
||||
<tr><td class="part">2× USB‑C (data+power & power‑thru) + PWR LED <span class="spec">— daisy‑chain</span></td><td class="q">1</td><td class="c">3</td></tr>
|
||||
<tr><td class="part">Tilt / lift sensor (accelerometer) <span class="spec">— starts when lifted from its holder</span></td><td class="q">1</td><td class="c">2</td></tr>
|
||||
<tr><td class="part">Custom PCB (or perfboard)</td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr><td class="part">Passives, wire</td><td class="q">—</td><td class="c">2</td></tr>
|
||||
<tr><td class="part">Pyramid enclosure <span class="spec">— cast/CNC aluminium or hardwood, frosted front panel</span></td><td class="q">1</td><td class="c">14</td></tr>
|
||||
<tr class="total"><td>Total (one‑off)</td><td class="q"></td><td class="c">≈ $41</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="sub" style="max-width:760px;margin:14px auto 0">Embed this widget elsewhere with one <code><div></code> + a script —
|
||||
see <a href="/embed.html">the embed docs</a>.</p>
|
||||
</main>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
window.INFO_DEVICE = { file:"/showcase.html", name:"PM_D‑1 Display" };
|
||||
/*@BUILD:include:src/infoembed.js@*/
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
130
info-stage.html
Normal file
130
info-stage.html
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VARASYS PM_S‑1 Stage — purpose, dimensions & bill of materials</title>
|
||||
<meta name="description" content="PM_S‑1 Stage — a foot‑pedal polymeter stompbox: hands‑free footswitches, expression‑pedal tempo, a floor‑readable RGB beat light, analog instrument pass‑through. Dimensions and a priced BOM." />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
(function(){ try{ var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; --silk:#aab2bc; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
|
||||
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
|
||||
a{ color:var(--link); }
|
||||
main{ width:100%; max-width:980px; margin:0 auto; }
|
||||
.info-hero{ text-align:center; padding:16px 8px 2px; }
|
||||
.info-hero h1{ font-size:clamp(24px,5vw,36px); margin:0; letter-spacing:-.01em; }
|
||||
.info-hero .sub{ margin:9px auto 0; max-width:64ch; font-size:14.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<section class="info-hero">
|
||||
<h1>PM_S‑1 Stage</h1>
|
||||
<p class="sub">A foot‑pedal polymeter stompbox — hands‑free footswitches, expression‑pedal tempo, a floor‑readable RGB beat light, and analog instrument pass‑through with the click mixed in.</p>
|
||||
</section>
|
||||
|
||||
/*@BUILD:include:src/infoembed.html@*/
|
||||
|
||||
<section class="about">
|
||||
<h2>What it is</h2>
|
||||
<div class="ff-tags"><span class="hw">Hardware</span><span>Foot‑pedal stompbox</span><span>~$52 one‑off</span></div>
|
||||
<p>A foot‑operated polymeter stompbox for the stage: drive it hands‑free with two heavy footswitches and an
|
||||
expression pedal, read it off the floor from the big RGB beat light, and run your instrument through it with
|
||||
the click mixed in. (For a desk/lesson unit with a full screen, see the <a href="/info-teacher.html">Teacher</a>.)</p>
|
||||
<p>The controls are built for feet: the <b>left footswitch</b> taps tempo (hold to start/stop), the <b>right</b>
|
||||
steps through your set list (hold for previous), and a <b>1/4″ expression‑pedal input</b> sweeps tempo on the
|
||||
fly. Your instrument passes through (1/4″ in) with the click summed in the <b>analog domain</b> and sent to a
|
||||
balanced 1/4″ TRS out. Powered over USB‑C — with a second USB‑C <b>"thru"</b> port so several pedals
|
||||
daisy‑chain off one charger or power bank.</p>
|
||||
</section>
|
||||
|
||||
<div class="dview">
|
||||
<p class="cap">Dimensions & layout — ≈ 4.7 × 3.7 × 1.5 in (120 × 93 × 38 mm), a 1590BB‑style stompbox</p>
|
||||
<div class="drow">
|
||||
<div class="dvy">↕ 3.7 in (93 mm)</div>
|
||||
<div class="dschem" style="height:150px">
|
||||
<span class="scap">Front</span>
|
||||
<div class="scr" style="left:18%; right:18%; top:22px; height:34px"></div>
|
||||
<div class="ctl" style="left:calc(50% - 15px); top:64px; width:30px; height:30px"></div>
|
||||
<div class="ctl" style="left:20%; top:100px; width:34px; height:34px"></div>
|
||||
<div class="ctl" style="left:calc(80% - 34px); top:100px; width:34px; height:34px"></div>
|
||||
<div class="jl" style="left:0; right:0; bottom:5px">angled TFT · RGB beat light · Tap + Next footswitches</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dvx">↔ 4.7 in (120 mm) wide</div>
|
||||
<div class="drow" style="margin-top:12px">
|
||||
<div class="dvy">↕ 1.5 in (38 mm)</div>
|
||||
<div class="dschem" style="height:56px">
|
||||
<span class="scap">Top edge — I/O</span>
|
||||
<div class="jk" style="left:7%; top:18px"></div><div class="jk" style="left:22%; top:18px"></div>
|
||||
<div class="jk" style="left:37%; top:18px"></div><div class="jk" style="left:52%; top:18px"></div>
|
||||
<div class="jk u" style="left:68%; top:21px"></div><div class="jk u" style="left:83%; top:21px"></div>
|
||||
<div class="jl" style="left:0; right:0; bottom:4px">Trig · Inst In · Out TRS · Exp · USB‑C · USB‑C thru</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dvx">↔ 4.7 in (120 mm)</div>
|
||||
</div>
|
||||
|
||||
<details class="spec" open>
|
||||
<summary>Spec & bill of materials</summary>
|
||||
<div class="spec-body">
|
||||
<p class="sub">Rough parts list — a foot‑operated RP2040 stompbox (USB‑C, dual‑port) with analog click injection.
|
||||
Ballpark one‑off prices (USD); cheaper at volume.</p>
|
||||
<table class="bom">
|
||||
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Brain & display</td></tr>
|
||||
<tr><td class="part">RP2040 board, USB‑C <span class="spec">— e.g. Waveshare RP2040‑Zero</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr><td class="part">1.3″ IPS TFT, ST7789 <span class="spec">— SPI; angled BPM / item readout</span></td><td class="q">1</td><td class="c">6</td></tr>
|
||||
<tr><td class="part">High‑bright diffused RGB beat indicator <span class="spec">— floor‑readable</span></td><td class="q">1</td><td class="c">1</td></tr>
|
||||
<tr class="grp"><td colspan="3">Controls</td></tr>
|
||||
<tr><td class="part">Heavy‑duty momentary footswitch (soft‑touch) <span class="spec">— Tap · Next</span></td><td class="q">2</td><td class="c">6</td></tr>
|
||||
<tr><td class="part">1/4″ expression‑pedal input jack (TRS) <span class="spec">— tempo sweep</span></td><td class="q">1</td><td class="c">1</td></tr>
|
||||
<tr class="grp"><td colspan="3">Audio — analog click injection</td></tr>
|
||||
<tr><td class="part">PCM5102A I²S DAC <span class="spec">— line‑level click</span></td><td class="q">1</td><td class="c">3</td></tr>
|
||||
<tr><td class="part">Dual op‑amp, NE5532 / OPA2134 <span class="spec">— hi‑Z instrument buffer + summing mixer</span></td><td class="q">1</td><td class="c">1</td></tr>
|
||||
<tr><td class="part">Balanced line driver, DRV134 <span class="spec">— → 1/4″ TRS out</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr class="grp"><td colspan="3">Connectors & power</td></tr>
|
||||
<tr><td class="part">1/4″ jack <span class="spec">— Inst In (TS) · Out (TRS) · Trig In (TS)</span></td><td class="q">3</td><td class="c">3</td></tr>
|
||||
<tr><td class="part">2× USB‑C (data+power & power‑thru) + power‑path/protection + PWR LED <span class="spec">— daisy‑chain pedals</span></td><td class="q">1</td><td class="c">3</td></tr>
|
||||
<tr class="grp"><td colspan="3">Build</td></tr>
|
||||
<tr><td class="part">Custom PCB (or perfboard)</td><td class="q">1</td><td class="c">5</td></tr>
|
||||
<tr><td class="part">Passives, headers, wire <span class="spec">— R/C for the analog stage + decoupling</span></td><td class="q">—</td><td class="c">3</td></tr>
|
||||
<tr><td class="part">Die‑cast aluminium stompbox (Hammond 1590BB‑style) <span class="spec">— bead‑blasted, matte‑black Type II anodise, laser‑etched</span></td><td class="q">1</td><td class="c">12</td></tr>
|
||||
<tr class="total"><td>Total (one‑off)</td><td class="q"></td><td class="c">≈ $52</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="sub" style="margin-top:12px">No built‑in speaker — the Stage feeds your amp / PA. The click is summed in
|
||||
the <b>analog domain</b> (hi‑Z instrument buffer + DAC → balanced line driver), so your instrument is never
|
||||
re‑digitised (no added latency).</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="sub" style="max-width:760px;margin:14px auto 0">Embed this widget elsewhere with one <code><div></code> + a script —
|
||||
see <a href="/embed.html">the embed docs</a>.</p>
|
||||
</main>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
window.INFO_DEVICE = { file:"/stage.html", name:"PM_S‑1 Stage" };
|
||||
/*@BUILD:include:src/infoembed.js@*/
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
105
info-teacher.html
Normal file
105
info-teacher.html
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VARASYS PM_T‑1 Teacher — purpose, spec & bill of materials</title>
|
||||
<meta name="description" content="PM_T‑1 Teacher — a full‑feature studio / lesson desk console: a colour TFT of every lane, arcade buttons, a thumb‑roller, and analog instrument pass‑through with the click mixed in. Spec and a priced BOM." />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
(function(){ try{ var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; --silk:#aab2bc; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
|
||||
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
|
||||
a{ color:var(--link); }
|
||||
main{ width:100%; max-width:980px; margin:0 auto; }
|
||||
.info-hero{ text-align:center; padding:16px 8px 2px; }
|
||||
.info-hero h1{ font-size:clamp(24px,5vw,36px); margin:0; letter-spacing:-.01em; }
|
||||
.info-hero .sub{ margin:9px auto 0; max-width:64ch; font-size:14.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<section class="info-hero">
|
||||
<h1>PM_T‑1 Teacher</h1>
|
||||
<p class="sub">The full‑feature studio / lesson desk console — a colour TFT showing every lane, arcade buttons and a thumb‑roller, with your instrument running through and the click mixed in.</p>
|
||||
</section>
|
||||
|
||||
/*@BUILD:include:src/infoembed.html@*/
|
||||
|
||||
<section class="about">
|
||||
<h2>What it is</h2>
|
||||
<div class="ff-tags"><span class="hw">Hardware</span><span>Studio / lesson console</span><span>~$59 one‑off</span></div>
|
||||
<p>The full‑feature desktop console: a colour readout of every lane, fast set‑list navigation, and your
|
||||
instrument running straight through with the click mixed in — the hands‑on unit for a studio desk or a
|
||||
teaching room, on a non‑reflective matte‑black case. (For hands‑free live use, see the foot‑operated
|
||||
<a href="/info-stage.html">Stage</a> stompbox.)</p>
|
||||
<p>Top‑mounted 1/4″ jacks keep cabling tidy; the metronome click is summed into the signal in the
|
||||
<b>analog domain</b> (no re‑digitising, no added latency) and sent to a balanced 1/4″ TRS output for the
|
||||
desk or interface, plus a small monitor speaker. Powered over USB‑C — a wall adapter or a power bank. The
|
||||
colour TFT shows tempo, the item name and all lane patterns; arcade buttons + a recessed thumb‑roller make
|
||||
it quick to drive while you teach or track.</p>
|
||||
</section>
|
||||
|
||||
<details class="spec" open>
|
||||
<summary>Spec & bill of materials</summary>
|
||||
<div class="spec-body">
|
||||
<p class="sub">Rough parts list — a desk/studio RP2040 build (USB‑C powered) with analog click injection.
|
||||
Ballpark one‑off prices (USD); cheaper at volume.</p>
|
||||
<table class="bom">
|
||||
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Brain & display</td></tr>
|
||||
<tr><td class="part">RP2040 board, USB‑C <span class="spec">— e.g. Waveshare RP2040‑Zero / Pico‑clone</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr><td class="part">2.0″ 320×240 IPS TFT, ST7789 <span class="spec">— SPI</span></td><td class="q">1</td><td class="c">8</td></tr>
|
||||
<tr class="grp"><td colspan="3">Controls</td></tr>
|
||||
<tr><td class="part">Arcade pushbutton, 24 mm <span class="spec">— Prev · Next · Tap</span></td><td class="q">3</td><td class="c">4</td></tr>
|
||||
<tr><td class="part">Arcade pushbutton, 30 mm <span class="spec">— Play</span></td><td class="q">1</td><td class="c">2</td></tr>
|
||||
<tr><td class="part">Detented encoder (EC11 / PEC12) + side‑mount thumb‑roller <span class="spec">— recessed; nothing to snap off</span></td><td class="q">1</td><td class="c">2</td></tr>
|
||||
<tr class="grp"><td colspan="3">Audio — analog click injection</td></tr>
|
||||
<tr><td class="part">PCM5102A I²S DAC <span class="spec">— line‑level click</span></td><td class="q">1</td><td class="c">3</td></tr>
|
||||
<tr><td class="part">Dual op‑amp, NE5532 / OPA2134 <span class="spec">— hi‑Z instrument buffer + summing mixer</span></td><td class="q">1</td><td class="c">1</td></tr>
|
||||
<tr><td class="part">Balanced line driver, DRV134 <span class="spec">— (or cross‑coupled op‑amp) → 1/4″ TRS out</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr><td class="part">PAM8302A mono Class‑D + 8 Ω 2 W speaker <span class="spec">— monitor</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||||
<tr class="grp"><td colspan="3">Connectors & power</td></tr>
|
||||
<tr><td class="part">1/4″ jack <span class="spec">— Inst In (TS) · Out (TRS) · Trig In (TS)</span></td><td class="q">3</td><td class="c">3</td></tr>
|
||||
<tr><td class="part">USB‑C bus power (5 V) + PWR LED <span class="spec">— wall adapter or power bank; same port carries config; no battery</span></td><td class="q">1</td><td class="c">1</td></tr>
|
||||
<tr class="grp"><td colspan="3">Build</td></tr>
|
||||
<tr><td class="part">Custom PCB (or perfboard)</td><td class="q">1</td><td class="c">5</td></tr>
|
||||
<tr><td class="part">Passives, headers, wire <span class="spec">— R/C for the analog stage + decoupling</span></td><td class="q">—</td><td class="c">3</td></tr>
|
||||
<tr><td class="part">Die‑cast aluminium enclosure (Hammond 1590‑style) <span class="spec">— bead‑blasted, matte‑black Type II anodise, laser‑etched legends</span></td><td class="q">1</td><td class="c">12</td></tr>
|
||||
<tr class="total"><td>Total (one‑off)</td><td class="q"></td><td class="c">≈ $56</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="sub" style="margin-top:12px">Audio is summed in the <b>analog domain</b>: the DAC's click is mixed with a
|
||||
high‑impedance buffer of the 1/4″ instrument input, then fed to the balanced line driver (1/4″ TRS out) and the
|
||||
monitor amp — so your instrument is never re‑digitised (no added latency).</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="sub" style="max-width:760px;margin:14px auto 0">Embed this widget elsewhere with one <code><div></code> + a script —
|
||||
see <a href="/embed.html">the embed docs</a>.</p>
|
||||
</main>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
window.INFO_DEVICE = { file:"/teacher.html", name:"PM_T‑1 Teacher" };
|
||||
/*@BUILD:include:src/infoembed.js@*/
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
307
kit.html
Normal file
307
kit.html
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PM_K‑1 — Kit (Raspberry Pi Pico touchscreen build)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
/* ?embed=1 → strip site chrome + auto-size to the host */
|
||||
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
|
||||
document.documentElement.dataset.embed="1";
|
||||
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
|
||||
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
|
||||
})();
|
||||
</script>
|
||||
<!--
|
||||
PM_K-1 "Kit" — the buildable touchscreen unit: a Raspberry Pi Pico on the 52Pi EP-0172
|
||||
breadboard kit (3.5" ST7796 320x480 cap-touch, GT911 touch, PSP joystick on ADC0/1,
|
||||
WS2812 RGB on GP12, buzzer GP13, buttons GP14/15). This page mirrors the MicroPython
|
||||
firmware's on-screen UI so the web simulator looks like the real device. Shares src/engine.js.
|
||||
-->
|
||||
<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:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bd:#2a313c; --device-bd:#33363c; --silk:#aab2bc; --cyan:#0AB3F7;
|
||||
--panel-bg:#161b22; --field-bg:#0e1116; --field-bd:#2a313c; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bd:#d2dae4; --panel-bg:#ffffff; --field-bg:#f1f4f8; --field-bd:#d2dae4 }
|
||||
body{ margin:0; min-height:100vh; padding:26px 14px 46px;
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); color:var(--txt);
|
||||
display:flex; flex-direction:column; align-items:center; gap:16px }
|
||||
a{ color:var(--link) }
|
||||
|
||||
/* the kit: a Pico carrier board with the screen + joystick + RGB + buzzer + buttons */
|
||||
.device{ width:100%; max-width:330px; position:relative; border-radius:16px; padding:14px 14px 18px;
|
||||
background:
|
||||
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px,
|
||||
linear-gradient(180deg, #1f3a2e, #0c2a20); /* 52Pi green PCB vibe */
|
||||
border:1px solid #2d5c47;
|
||||
box-shadow:0 26px 52px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05) }
|
||||
.pcbrow{ display:flex; align-items:center; justify-content:space-between; margin:0 2px 10px }
|
||||
.dev-logo{ height:18px }
|
||||
.silk{ display:flex; align-items:center; gap:7px; color:#bfe6d4 }
|
||||
.silk .model{ font-size:8.5px; text-transform:uppercase; letter-spacing:.16em; opacity:.9 }
|
||||
.pin{ font-size:7px; color:#9fd2bd; letter-spacing:.1em; text-transform:uppercase; opacity:.8 }
|
||||
|
||||
.screen-wrap{ padding:8px; border-radius:10px; background:linear-gradient(180deg,#05070a,#020406);
|
||||
border:1px solid #04060a; box-shadow:inset 0 2px 10px rgba(0,0,0,.8) }
|
||||
#screen{ display:block; width:100%; height:auto; border-radius:5px; background:#06080c; touch-action:manipulation; cursor:pointer }
|
||||
|
||||
.hw{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin:14px 4px 0 }
|
||||
/* PSP joystick */
|
||||
.joy{ width:74px; height:74px; border-radius:50%; position:relative; flex:0 0 auto; touch-action:none; cursor:grab;
|
||||
background:radial-gradient(circle at 40% 34%, #2a2f37, #0c0f13 72%); border:2px solid #0a3a2a;
|
||||
box-shadow:inset 0 2px 6px rgba(0,0,0,.6) }
|
||||
.joy .nub{ position:absolute; left:50%; top:50%; width:34px; height:34px; margin:-17px 0 0 -17px; border-radius:50%;
|
||||
background:radial-gradient(circle at 38% 32%, #e9eef3, #aab2bc 46%, #6c7480 72%, #3b424c);
|
||||
box-shadow:0 3px 6px rgba(0,0,0,.5); transition:transform .04s }
|
||||
.joy .cap{ position:absolute; left:0; right:0; bottom:-15px; text-align:center; font-size:7px; color:#9fd2bd; letter-spacing:.08em; text-transform:uppercase; opacity:.85 }
|
||||
.mids{ display:flex; flex-direction:column; align-items:center; gap:8px; flex:1 }
|
||||
.led{ width:30px; height:30px; border-radius:50%; background:#0c0f14;
|
||||
box-shadow:0 0 4px #000 inset; transition:none }
|
||||
.led-cap, .buz-cap{ font-size:7px; color:#9fd2bd; letter-spacing:.1em; text-transform:uppercase; opacity:.85 }
|
||||
.buz{ width:26px; height:26px; border-radius:50%; background:radial-gradient(circle at 50% 40%, #2a2f37, #0c0f13);
|
||||
border:2px solid #0a3a2a; position:relative }
|
||||
.buz::after{ content:""; position:absolute; left:50%; top:50%; width:6px; height:6px; margin:-3px 0 0 -3px; border-radius:50%; background:#05070a }
|
||||
.btns{ display:flex; flex-direction:column; gap:9px; flex:0 0 auto }
|
||||
.pbtn{ width:60px; padding:9px 0; border-radius:9px; border:1px solid #0a3a2a; cursor:pointer;
|
||||
background:radial-gradient(circle at 40% 30%, #d7dde3, #8b939e 70%, #5a626c); color:#0c1116;
|
||||
font-size:9px; font-weight:800; letter-spacing:.06em; text-transform:uppercase;
|
||||
box-shadow:0 3px 5px rgba(0,0,0,.4) }
|
||||
.pbtn:active{ transform:translateY(2px) }
|
||||
.pbtn small{ display:block; font-size:6.5px; font-weight:600; opacity:.7 }
|
||||
|
||||
.hint{ max-width:330px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
|
||||
[data-embed] .hint{ display:none !important }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<h1 class="ff-title">PM_K‑1 Kit</h1>
|
||||
<p class="ff-sum">The build‑it‑yourself touchscreen unit — a Raspberry Pi Pico on the 52Pi breadboard kit (3.5″ cap‑touch, joystick, RGB, buzzer). Tap the screen, nudge tempo with the stick; runs the same program strings, with MicroPython firmware you flash yourself.</p>
|
||||
|
||||
<div class="device">
|
||||
<div class="pcbrow">
|
||||
<div class="silk"><span class="dev-lock"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" /></span><span class="model">PM_K‑1 Kit</span></div>
|
||||
<span class="pin">Pico · USB‑C</span>
|
||||
</div>
|
||||
|
||||
<div class="screen-wrap"><canvas id="screen" width="320" height="480" aria-label="touchscreen metronome"></canvas></div>
|
||||
|
||||
<div class="hw">
|
||||
<div class="joy" id="joy" title="Joystick — up/down tempo · left/right groove"><div class="nub" id="nub"></div><span class="cap">Joystick</span></div>
|
||||
<div class="mids">
|
||||
<div class="led" id="led"></div><span class="led-cap">RGB</span>
|
||||
<div class="buz"></div><span class="buz-cap">Buzzer</span>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<button class="pbtn" id="btnA">A<small>play</small></button>
|
||||
<button class="pbtn" id="btnB">B<small>tap</small></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint">Tap the on‑screen buttons (the real unit is capacitive touch). The <b>joystick</b> sets tempo (up/down)
|
||||
and switches grooves (left/right); <b>A</b> = play/stop, <b>B</b> = tap. The RGB LED flashes each beat and the buzzer clicks.</div>
|
||||
|
||||
/*@BUILD:include:src/progbox.html@*/
|
||||
|
||||
<p class="ff-link pageonly"><a href="/info-kit.html">Wiring, parts & firmware to flash →</a></p>
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* ========================= ENGINE (shared; synth voices only) ================= */
|
||||
const SAMPLES = {};
|
||||
/*@BUILD:include:src/engine.js@*/
|
||||
/*@BUILD:include:src/setlists.js@*/
|
||||
const state = { bpm:120, volume:0.85, running:false };
|
||||
let meters = [], muteWindows = [];
|
||||
|
||||
function setBpm(v){ state.bpm = Math.max(30, Math.min(300, Math.round(v))); if(window.progRefresh) progRefresh(); }
|
||||
function scheduler(){
|
||||
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
|
||||
for(const m of meters){ while(m.nextTime < ahead){ scheduleMeterTick(m, m.nextTime); m.nextTime += laneStepDur(m, m.tick); m.tick++; } }
|
||||
}
|
||||
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(),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}; });
|
||||
}
|
||||
function startAudio(){
|
||||
ensureAudio(); audioCtx.resume(); state.running=true;
|
||||
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; }
|
||||
muteWindows=[];
|
||||
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
|
||||
}
|
||||
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; for(const m of meters) m.currentStep=-1; }
|
||||
function toggle(){ state.running ? stopAudio() : startAudio(); }
|
||||
|
||||
/* ========================= TRACKS (seed grooves, flattened) =================== */
|
||||
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
|
||||
let trackIdx = 0;
|
||||
function tracksFromHash(){
|
||||
const m=(location.hash||"").match(/[#&](p|sl)=([^&]+)/); if(!m) return null;
|
||||
let payload=m[2]; try{ payload=decodeURIComponent(payload); }catch(e){}
|
||||
try{
|
||||
if(m[1]==="sl"){ const sl=codeToSetlist(payload); return sl.items.length ? sl.items.map(it=>({...it})) : null; }
|
||||
const s=patchToSetup(payload); return s.lanes.length ? [{name:"Patch",...s}] : null;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
function loadTrack(i){
|
||||
const n=tracks.length; if(!n) return; trackIdx=((i%n)+n)%n;
|
||||
const t=tracks[trackIdx]; setBpm(t.bpm||120); meters=buildMeters(t.lanes);
|
||||
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
|
||||
if(was) startAudio();
|
||||
}
|
||||
|
||||
/* ========================= SCREEN (canvas mirrors the firmware UI) ============ */
|
||||
const cv=$("screen"), g=cv.getContext("2d"), SW=320, SH=480;
|
||||
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); cv.width=SW*dpr; cv.height=SH*dpr; g.scale(dpr,dpr); })();
|
||||
const COL={ bg:"#06090e", txt:"#c7d0db", mute:"#6e7a8a", cyan:"#0AB3F7", amber:"#ff9b2e",
|
||||
violet:"#967bff", green:"#2fe07a", dim:"#243240", btn:"#1c222c", panel:"#12161e" };
|
||||
const PRIO={2:3,1:2,3:1};
|
||||
const LEDCOL={2:[255,110,0],1:[0,150,255],3:[130,70,255]};
|
||||
let flash=0, flashLevel=1, beatIdx=-1, btnRects=[];
|
||||
|
||||
function rrect(x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
|
||||
|
||||
function layoutButtons(){
|
||||
btnRects=[]; const bw=96, bh=54, gap=(SW-3*bw)/4, xs=[gap, gap*2+bw, gap*3+bw*2];
|
||||
[["prev",300],["play",300],["next",300]].forEach((b,i)=>btnRects.push({x:xs[i],y:300,w:bw,h:bh,key:["prev","play","next"][i]}));
|
||||
["minus","tap","plus"].forEach((k,i)=>btnRects.push({x:xs[i],y:370,w:bw,h:bh,key:k}));
|
||||
}
|
||||
layoutButtons();
|
||||
|
||||
function drawScreen(){
|
||||
g.fillStyle=COL.bg; g.fillRect(0,0,SW,SH);
|
||||
// header (the VARASYS logo lives on the case, not the screen)
|
||||
g.textBaseline="alphabetic"; g.textAlign="left";
|
||||
g.fillStyle=COL.cyan; g.font="700 18px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("PM_K‑1 KIT",12,26);
|
||||
g.fillStyle=COL.panel; g.fillRect(0,34,SW,2);
|
||||
// BPM
|
||||
g.textAlign="left"; g.fillStyle=COL.mute; g.font="700 20px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("BPM",12,150);
|
||||
g.textAlign="right"; g.fillStyle=COL.txt; g.font="800 92px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(String(state.bpm),SW-12,176);
|
||||
// beat dots
|
||||
const m=meters[0]; if(m){ const bpb=m.beatsPerBar, sz=18, sp=26, x0=Math.max(12,SW-12-bpb*sp), y=196;
|
||||
for(let i=0;i<bpb;i++){ const accent=m.groupStarts.has(i)||(m.beatsOn[i*m.stepsPerBeat]|0)>=2;
|
||||
const on=state.running && i===beatIdx; g.fillStyle = on ? (accent?COL.amber:COL.cyan) : COL.dim;
|
||||
g.fillRect(x0+i*sp,y,sz,sz); } }
|
||||
// status
|
||||
g.textAlign="left"; g.fillStyle=state.running?COL.green:COL.mute; g.font="700 16px 'Segoe UI',Roboto,Arial,sans-serif";
|
||||
g.fillText(state.running?"▶ RUN":"■ STOP",12,256);
|
||||
const nm=(tracks[trackIdx]&&tracks[trackIdx].name||"—").slice(0,18);
|
||||
g.textAlign="right"; g.fillStyle=COL.txt; g.fillText(nm,SW-12,256);
|
||||
g.textAlign="left"; g.fillStyle=COL.mute; g.font="600 11px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText((trackIdx+1)+"/"+tracks.length,12,276);
|
||||
// touch buttons
|
||||
const lbl={prev:"‹‹",play:state.running?"▮▮":"▶",next:"››",minus:"–",tap:"TAP",plus:"+"};
|
||||
for(const b of btnRects){ g.fillStyle=COL.btn; rrect(b.x,b.y,b.w,b.h,9); g.fill();
|
||||
g.fillStyle = b.key==="play" ? COL.green : COL.txt;
|
||||
g.font = (b.key==="minus"||b.key==="plus") ? "800 30px 'Segoe UI',Roboto,Arial,sans-serif" : "800 22px 'Segoe UI',Roboto,Arial,sans-serif";
|
||||
g.textAlign="center"; g.textBaseline="middle"; g.fillText(lbl[b.key], b.x+b.w/2, b.y+b.h/2+2); g.textBaseline="alphabetic"; }
|
||||
g.textAlign="left"; g.fillStyle=COL.mute; g.font="600 10px 'Segoe UI',Roboto,Arial,sans-serif";
|
||||
g.fillText("joystick: tempo / groove · A play B tap", 12, SH-14);
|
||||
}
|
||||
|
||||
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
|
||||
function frame(){
|
||||
const now = audioCtx ? audioCtx.currentTime - audioLatency() : 0;
|
||||
if(audioCtx && state.running){
|
||||
let fired=[];
|
||||
for(const m of meters){
|
||||
while(m.vqPtr<m.vq.length && m.vq[m.vqPtr].time<=now){
|
||||
const e=m.vq[m.vqPtr]; m.currentStep=e.step; m.currentBar=e.bar;
|
||||
const lvl=m.beatsOn[e.step]|0; if(lvl>0) fired.push(lvl);
|
||||
if(m===meters[0] && e.step % m.stepsPerBeat===0) beatIdx = e.step/m.stepsPerBeat;
|
||||
m.vqPtr++;
|
||||
}
|
||||
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
|
||||
}
|
||||
if(fired.length){ flashLevel = fired.sort((a,b)=>PRIO[b]-PRIO[a])[0]; flash=1; }
|
||||
}
|
||||
flash=Math.max(0,flash-0.08);
|
||||
// RGB LED element
|
||||
const c=LEDCOL[flashLevel]||LEDCOL[1], lit=flash;
|
||||
const led=$("led");
|
||||
if(lit>0.02){ const rgb="rgb("+c.map(v=>Math.round(v*lit)).join(",")+")";
|
||||
led.style.background=rgb; led.style.boxShadow="0 0 "+(8+lit*22)+"px rgb("+c.join(",")+")"; }
|
||||
else { led.style.background="#0c0f14"; led.style.boxShadow="0 0 4px #000 inset"; }
|
||||
drawScreen();
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
/* ========================= INPUTS ============================================ */
|
||||
function dispatch(key){
|
||||
if(key==="play") toggle();
|
||||
else if(key==="prev") loadTrack(trackIdx-1);
|
||||
else if(key==="next") loadTrack(trackIdx+1);
|
||||
else if(key==="minus") setBpm(state.bpm-1);
|
||||
else if(key==="plus") setBpm(state.bpm+1);
|
||||
else if(key==="tap") tapTempo();
|
||||
}
|
||||
cv.addEventListener("pointerdown",(e)=>{
|
||||
const r=cv.getBoundingClientRect(); const x=(e.clientX-r.left)*SW/r.width, y=(e.clientY-r.top)*SH/r.height;
|
||||
for(const b of btnRects){ if(x>=b.x&&x<=b.x+b.w&&y>=b.y&&y<=b.y+b.h){ dispatch(b.key); return; } }
|
||||
});
|
||||
|
||||
let taps=[];
|
||||
function tapTempo(){ const now=performance.now(); taps.push(now); taps=taps.filter(x=>now-x<2400);
|
||||
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1];
|
||||
const bpm=Math.round(60000/(s/(taps.length-1))); if(bpm>=30&&bpm<=300) setBpm(bpm); } }
|
||||
$("btnA").addEventListener("click",()=>toggle());
|
||||
$("btnB").addEventListener("click",()=>tapTempo());
|
||||
|
||||
/* joystick: drag up/down = tempo, left/right = groove */
|
||||
(function(){
|
||||
const joy=$("joy"), nub=$("nub"); let dragging=false, R=26, last=0, itemLatch=0;
|
||||
function at(e){ const r=joy.getBoundingClientRect(); return {x:e.clientX-r.left-r.width/2, y:e.clientY-r.top-r.height/2}; }
|
||||
function move(e){ if(!dragging) return; e.preventDefault();
|
||||
let {x,y}=at(e); const d=Math.hypot(x,y)||1, k=Math.min(1,R/d); let nx=x*k, ny=y*k;
|
||||
nub.style.transform="translate("+nx+"px,"+ny+"px)";
|
||||
const fy=-ny/R, fx=nx/R, now=performance.now();
|
||||
if(Math.abs(fy)>0.45 && now-last>80){ setBpm(state.bpm+(fy>0?1:-1)*(Math.abs(fy)>0.8?5:1)); last=now; }
|
||||
if(Math.abs(fx)>0.6){ if(now-itemLatch>320){ loadTrack(trackIdx+(fx>0?1:-1)); itemLatch=now; } }
|
||||
}
|
||||
function up(){ dragging=false; nub.style.transform="translate(0,0)"; }
|
||||
joy.addEventListener("pointerdown",(e)=>{ dragging=true; try{joy.setPointerCapture(e.pointerId);}catch(_){ } move(e); });
|
||||
joy.addEventListener("pointermove",move);
|
||||
joy.addEventListener("pointerup",up); joy.addEventListener("pointercancel",up);
|
||||
})();
|
||||
|
||||
/* theme toggle + version */
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
|
||||
addEventListener("keydown",(e)=>{
|
||||
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
|
||||
if(e.key===" "){ e.preventDefault(); toggle(); }
|
||||
else if(e.key==="t"||e.key==="T"){ tapTempo(); }
|
||||
else if(e.key==="ArrowUp"){ e.preventDefault(); setBpm(state.bpm+1); }
|
||||
else if(e.key==="ArrowDown"){ e.preventDefault(); setBpm(state.bpm-1); }
|
||||
else if(e.key==="ArrowRight"){ loadTrack(trackIdx+1); }
|
||||
else if(e.key==="ArrowLeft"){ loadTrack(trackIdx-1); }
|
||||
});
|
||||
|
||||
/* ========================= INIT ============================================== */
|
||||
{ const ht=tracksFromHash(); if(ht) tracks=ht; }
|
||||
loadTrack(0);
|
||||
requestAnimationFrame(frame);
|
||||
window.currentProgramString = function(){ var t=tracks[trackIdx]||{}; return setupToPatch({bpm:state.bpm, volume:state.volume, lanes:t.lanes||[]}); };
|
||||
window.loadProgramString = function(plain){ var s=patchToSetup(plain); tracks=[{name:"Program", ...s}]; trackIdx=0; loadTrack(0); };
|
||||
/*@BUILD:include:src/progbox.js@*/
|
||||
</script>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"name": "VARASYS PolyMeter",
|
||||
"short_name": "PolyMeter",
|
||||
"description": "Polymetric groove-trainer & metronome — touch-first, full-screen.",
|
||||
"id": "/mobile.html",
|
||||
"start_url": "/mobile.html?standalone=1",
|
||||
"scope": "/mobile",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "fullscreen"],
|
||||
"orientation": "any",
|
||||
"background_color": "#05070a",
|
||||
"theme_color": "#0b0d11",
|
||||
"categories": ["music", "productivity", "utilities"],
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
353
micro.html
Normal file
353
micro.html
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PM_P‑1 — Practice (inline bar)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
/* ?embed=1 → strip site chrome (base.css [data-embed]) + auto-size to the host */
|
||||
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
|
||||
document.documentElement.dataset.embed="1";
|
||||
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
|
||||
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
|
||||
})();
|
||||
</script>
|
||||
<!--
|
||||
"Micro" — a long, narrow INLINE practice bar on the same RP2040 firmware.
|
||||
Patch your instrument through it: 1/4" TRS in on one end; USB-C + 1/4" TRS out
|
||||
on the other; powered over USB-C (wall adapter or power bank — no internal
|
||||
battery). The click is summed into your signal in the ANALOG domain (and a
|
||||
small speaker). Display is a 4-char amber 14-segment
|
||||
(shows BPM *and* short track names). One control — a clickable thumb-ROLLER:
|
||||
• roll → tempo
|
||||
• press (click) → start / stop
|
||||
• hold + roll → switch track (the display shows the track name)
|
||||
Built-in tracks are the editor's seed grooves, flattened. Shares src/engine.js.
|
||||
-->
|
||||
<script>
|
||||
// Set theme before first paint (shared "metronome.theme" with the other pages).
|
||||
(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:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bd:#2a313c;
|
||||
--device-bd:#33363c; --silk:#aab2bc; --dmuted:#5a626c;
|
||||
--cyan:#0AB3F7;
|
||||
}
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4; --panel-bd:#d2dae4 }
|
||||
body{ margin:0; min-height:100vh; padding:30px 14px 44px;
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); color:var(--txt);
|
||||
display:flex; flex-direction:column; align-items:center; gap:16px }
|
||||
a{ color:var(--link) }
|
||||
.topbar{ width:100%; max-width:330px; display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; color:var(--muted); flex-wrap:wrap }
|
||||
.topbar b{ color:var(--txt) }
|
||||
.topbar-right{ display:flex; align-items:center; gap:10px }
|
||||
.tbtn{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px; padding:3px 9px; font-size:14px; line-height:1; cursor:pointer }
|
||||
.tbtn:hover{ color:var(--txt) }
|
||||
|
||||
/* ---- the micro device: a long, narrow brushed-aluminium bar ----
|
||||
The main body and the two end caps are SEPARATE pieces with a gap between
|
||||
them, so the jacks read as being on the ENDS of the bar (not the front). */
|
||||
.device{ width:100%; max-width:660px; display:flex; align-items:center; gap:15px; position:relative }
|
||||
|
||||
/* end caps — the extrusion's end faces, stood apart from the body; jacks exit here.
|
||||
A touch shorter than the body (align-self:stretch + margin) so they look set-back. */
|
||||
.endcap{ flex:0 0 auto; width:66px; align-self:stretch; margin:9px 0;
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:11px;
|
||||
padding:12px 7px; border-radius:10px; border:1px solid #292b30;
|
||||
background:linear-gradient(180deg,#1b1d21,#0a0b0e); /* darker end-grain */
|
||||
box-shadow:0 12px 24px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04), inset 0 -2px 6px rgba(0,0,0,.55) }
|
||||
.endcap.left{ border-right-color:#3b3e45 } /* chamfer highlight on the edge facing the body */
|
||||
.endcap.right{ border-left-color:#3b3e45 }
|
||||
.endlbl{ font-size:7px; color:var(--silk); text-transform:uppercase; letter-spacing:.1em; text-align:center; line-height:1.35; opacity:.8 }
|
||||
.jk{ display:flex; flex-direction:column; align-items:center; gap:4px }
|
||||
.jk i{ width:23px; height:23px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #333a44, #07090c 72%);
|
||||
border:2px solid #5b6470; box-shadow:inset 0 0 5px #000 }
|
||||
.jk.usb i{ width:25px; height:10px; border-radius:4px; border:2px solid #5b6470; background:#07090c }
|
||||
.jk b{ font-size:7px; font-weight:700; color:var(--silk); letter-spacing:.05em; text-transform:uppercase; opacity:.9; text-align:center; line-height:1.25 }
|
||||
|
||||
/* the main body / top face (between the end caps) */
|
||||
.face{ flex:1; min-width:0; display:flex; flex-direction:column; padding:11px 16px; gap:8px;
|
||||
border-radius:16px; border:1px solid var(--device-bd);
|
||||
background:
|
||||
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px, /* bead-blast micro-texture */
|
||||
linear-gradient(180deg, #2b2d33, #161719); /* matte anodised graphite */
|
||||
box-shadow:0 24px 50px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -2px 8px rgba(0,0,0,.5) }
|
||||
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:0 }
|
||||
.dev-logo{ height:22px }
|
||||
.silk{ display:flex; align-items:center; gap:7px; color:var(--silk) }
|
||||
.silk .model{ font-size:8.5px; text-transform:uppercase; letter-spacing:.16em; opacity:.85 }
|
||||
.meta{ display:flex; align-items:center; gap:12px }
|
||||
.pwr{ display:flex; align-items:center; gap:5px; font-size:7.5px; color:var(--silk); text-transform:uppercase; letter-spacing:.12em; opacity:.85 }
|
||||
.pwr .dot{ width:6px; height:6px; border-radius:50%; background:#2fe07a; box-shadow:0 0 6px #2fe07a }
|
||||
|
||||
.facemain{ display:flex; align-items:center; gap:14px }
|
||||
/* ---- amber 14-segment alphanumeric display ---- */
|
||||
.led-win{ flex:1; min-width:0; background:#140a02; border:2px solid #050100; border-radius:8px; padding:6px 12px;
|
||||
box-shadow:inset 0 0 16px rgba(0,0,0,.85), inset 0 0 8px rgba(255,150,30,.10), 0 1px 0 rgba(255,255,255,.25) }
|
||||
#led{ display:block; width:100%; max-width:236px; height:58px; margin:0 auto }
|
||||
/* ---- recessed clickable thumb-roller (tempo) ---- */
|
||||
.rollwrap{ display:flex; flex-direction:column; align-items:center; gap:3px }
|
||||
.roller{ width:92px; height:46px; border-radius:9px; position:relative; cursor:ew-resize; overflow:hidden; touch-action:none;
|
||||
background:#0a0d12; border:1px solid #04060a; box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 2px 5px rgba(0,0,0,.5) }
|
||||
.roller::before{ content:""; position:absolute; inset:4px 3px; border-radius:5px; /* ribbed cylinder, scrolls via --rib */
|
||||
background:repeating-linear-gradient(90deg, rgba(255,255,255,.12) 0 1px, rgba(0,0,0,.5) 1px 5px); background-position:var(--rib,0px) 0 }
|
||||
.roller::after{ content:""; position:absolute; inset:0; border-radius:9px; pointer-events:none; /* cylinder sheen: bright centre, dark edges */
|
||||
background:linear-gradient(90deg, rgba(0,0,0,.72) 0%, rgba(255,255,255,.13) 49%, rgba(255,255,255,.13) 51%, rgba(0,0,0,.72) 100%) }
|
||||
.roller.press{ filter:brightness(.9); box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 1px 2px rgba(0,0,0,.6) }
|
||||
.roll-cap{ font-size:7px; color:var(--silk); letter-spacing:.16em; text-transform:uppercase; opacity:.8 }
|
||||
|
||||
/* speaker grille + status indicators along the bottom of the face */
|
||||
.facebot{ display:flex; align-items:center; gap:12px }
|
||||
.grille{ flex:1; height:9px; border-radius:5px; background:radial-gradient(circle, #000 1px, transparent 1.3px) 0 0/7px 7px; opacity:.45 }
|
||||
.inds{ display:flex; gap:11px }
|
||||
.ind{ display:flex; align-items:center; gap:4px; font-size:7.5px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.85 }
|
||||
.ind .d{ width:6px; height:6px; border-radius:50%; background:#3a2306; box-shadow:inset 0 0 2px #000; transition:background .08s, box-shadow .08s }
|
||||
.ind.on .d{ background:#ff8a1e; box-shadow:0 0 6px #ff8a1e }
|
||||
.ind.play.on .d{ background:#2fe07a; box-shadow:0 0 6px #2fe07a }
|
||||
|
||||
.hint{ max-width:560px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
|
||||
/* embed mode: just the device */
|
||||
[data-embed] .hint { display:none !important; }
|
||||
/* stack the bar's end caps under the face on very narrow screens */
|
||||
@media (max-width:460px){ .device{ flex-wrap:wrap; gap:10px }
|
||||
.endcap{ width:calc(50% - 5px); align-self:auto; margin:0; flex-direction:row; gap:18px; justify-content:center }
|
||||
.face{ flex-basis:100%; order:-1 } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<h1 class="ff-title">PM_P‑1 Practice</h1>
|
||||
<p class="ff-sum">Inline practice bar — patch your instrument through it (in one end, amp/headphones out the other) with the click mixed in. One clickable thumb‑roller, an amber 14‑segment display.</p>
|
||||
|
||||
<div class="device">
|
||||
<!-- LEFT END: instrument / aux in -->
|
||||
<div class="endcap left">
|
||||
<div class="jk" title="1/4" TRS input — plug your instrument (or an aux source) in; the click is mixed into it"><i></i><b>TRS In</b></div>
|
||||
<div class="endlbl">Inst /<br>aux in</div>
|
||||
</div>
|
||||
|
||||
<!-- TOP FACE: display + roller + speaker -->
|
||||
<div class="face">
|
||||
<div class="brandrow">
|
||||
<div class="silk"><span class="dev-lock"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" /></span><span class="model">PM_P‑1 Practice</span></div>
|
||||
<div class="meta">
|
||||
<div class="pwr" title="Powered over USB‑C — wall adapter or power bank"><span class="dot"></span>USB‑C PWR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="facemain">
|
||||
<div class="led-win"><canvas id="led" width="236" height="58" aria-label="14-segment tempo / track display"></canvas></div>
|
||||
<div class="rollwrap">
|
||||
<div class="roller" id="enc" title="Roll = tempo · press = start/stop · hold + roll = switch track"></div>
|
||||
<div class="roll-cap">Tempo</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="facebot">
|
||||
<div class="grille" title="monitor speaker"></div>
|
||||
<div class="inds">
|
||||
<div class="ind on" id="indBpm"><span class="d"></span>BPM</div>
|
||||
<div class="ind" id="indTrk"><span class="d"></span>Trk</div>
|
||||
<div class="ind play" id="indPlay"><span class="d"></span>▶</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT END: power + output -->
|
||||
<div class="endcap right">
|
||||
<div class="jk usb" title="USB‑C — power (5 V) & set-list transfer"><i></i><b>USB‑C</b></div>
|
||||
<div class="jk" title="1/4" TRS output — instrument + click, to your amp, headphones or the desk"><i></i><b>TRS Out</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint">Roll = <b>tempo</b> · press = <b>start / stop</b> · hold & roll = <b>switch track</b>.</div>
|
||||
|
||||
/*@BUILD:include:src/progbox.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* ========================= ENGINE (shared; synth voices only) ================= */
|
||||
const SAMPLES = {}; // synth-only
|
||||
/*@BUILD:include:src/engine.js@*/
|
||||
/*@BUILD:include:src/setlists.js@*/
|
||||
const state = { bpm:120, volume:0.85, running:false };
|
||||
let meters = [], muteWindows = [];
|
||||
|
||||
function setBpm(v){ state.bpm = Math.max(30, Math.min(300, Math.round(v))); }
|
||||
function scheduler(){
|
||||
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
|
||||
for(const m of meters){ while(m.nextTime < ahead){ scheduleMeterTick(m, m.nextTime); m.nextTime += laneStepDur(m, m.tick); m.tick++; } }
|
||||
}
|
||||
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(),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}; });
|
||||
}
|
||||
function startAudio(){
|
||||
ensureAudio(); audioCtx.resume(); state.running=true;
|
||||
const t0=audioCtx.currentTime+0.08;
|
||||
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; }
|
||||
muteWindows=[]; beatFlash=0; schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); render();
|
||||
}
|
||||
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; render(); }
|
||||
function toggle(){ state.running ? stopAudio() : startAudio(); }
|
||||
|
||||
/* ========================= TRACKS (built-in seed grooves, flattened) ========= */
|
||||
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
|
||||
let trackIdx=0, previewIdx=0;
|
||||
// preload from a share link / embed config string (#p=<patch> or #sl=<set-list code>)
|
||||
function tracksFromHash(){
|
||||
const m=(location.hash||"").match(/[#&](p|sl)=([^&]+)/); if(!m) return null;
|
||||
let payload=m[2]; try{ payload=decodeURIComponent(payload); }catch(e){}
|
||||
try{
|
||||
if(m[1]==="sl"){ const sl=codeToSetlist(payload); return sl.items.length ? sl.items.map(it=>({...it})) : null; }
|
||||
const s=patchToSetup(payload); return s.lanes.length ? [{name:"Patch",...s}] : null;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
function loadTrack(i){
|
||||
const n=tracks.length; if(!n) return; trackIdx=((i%n)+n)%n; previewIdx=trackIdx;
|
||||
const t=tracks[trackIdx]; setBpm(t.bpm||120); meters=buildMeters(t.lanes); // ramps/bars ignored — a steady practice loop
|
||||
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
|
||||
if(was) startAudio(); else render();
|
||||
}
|
||||
|
||||
/* ========================= 14-SEGMENT DISPLAY ================================ */
|
||||
const led=$("led"), lc=led.getContext("2d"), NCH=4, LW=236, LH=58;
|
||||
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); led.width=LW*dpr; led.height=LH*dpr; lc.scale(dpr,dpr); })();
|
||||
const LED_ON="#ff8a1e", LED_OFF="#1d1004", LED_BG="#120802"; // OFF kept very dim so the lit digits read clearly
|
||||
// 14-seg font (Adafruit bit order): 0=A 1=B 2=C 3=D 4=E 5=F 6=G1 7=G2 8=H 9=I 10=J 11=K 12=L 13=M
|
||||
const SEG14={ " ":0x0000,"-":0x00C0,
|
||||
"0":0x0C3F,"1":0x0006,"2":0x00DB,"3":0x008F,"4":0x00E6,"5":0x2069,"6":0x00FD,"7":0x0007,"8":0x00FF,"9":0x00EF,
|
||||
"A":0x00F7,"B":0x128F,"C":0x0039,"D":0x120F,"E":0x00F9,"F":0x0071,"G":0x00BD,"H":0x00F6,"I":0x1209,"J":0x001E,
|
||||
"K":0x2470,"L":0x0038,"M":0x0536,"N":0x2136,"O":0x003F,"P":0x00F3,"Q":0x203F,"R":0x20F3,"S":0x00ED,"T":0x1201,
|
||||
"U":0x003E,"V":0x0C30,"W":0x2836,"X":0x2D00,"Y":0x1500,"Z":0x0C09 };
|
||||
let displayMode="bpm";
|
||||
function trackName(i){ const raw=(tracks[i]&&tracks[i].name)||("TR"+(i+1));
|
||||
return (raw.replace(/[^A-Za-z0-9]/g,"").toUpperCase().slice(0,NCH)) || ("T"+(i+1)); }
|
||||
function ledText(){ return (displayMode==="track" ? trackName(previewIdx) : String(state.bpm)).padStart(NCH," "); }
|
||||
function drawChar(dx,dy,w,h,ch){
|
||||
const m=SEG14[ch]!=null?SEG14[ch]:0, t=Math.max(2.5,w*0.13), g=Math.max(1.5,t*0.5),
|
||||
cx=dx+w/2, midY=dy+h/2, vH=h/2-t-g;
|
||||
const bar=(b,x,y,ww,hh)=>{ if((m>>b)&1){ lc.fillStyle=LED_ON; lc.shadowColor=LED_ON; lc.shadowBlur=6; } else { lc.fillStyle=LED_OFF; lc.shadowBlur=0; }
|
||||
lc.fillRect(x,y,ww,hh); lc.shadowBlur=0; };
|
||||
const diag=(b,x1,y1,x2,y2)=>{ lc.lineCap="round"; lc.lineWidth=t*0.82;
|
||||
if((m>>b)&1){ lc.strokeStyle=LED_ON; lc.shadowColor=LED_ON; lc.shadowBlur=6; } else { lc.strokeStyle=LED_OFF; lc.shadowBlur=0; }
|
||||
lc.beginPath(); lc.moveTo(x1,y1); lc.lineTo(x2,y2); lc.stroke(); lc.shadowBlur=0; };
|
||||
bar(0, dx+t, dy, w-2*t, t); // A top
|
||||
bar(5, dx, dy+t, t, vH); // F upper-left
|
||||
bar(1, dx+w-t, dy+t, t, vH); // B upper-right
|
||||
bar(9, cx-t/2, dy+t, t, vH); // I centre-upper
|
||||
bar(6, dx+t, midY-t/2, w/2-t-g, t); // G1 mid-left
|
||||
bar(7, cx+g, midY-t/2, w/2-t-g, t); // G2 mid-right
|
||||
bar(4, dx, midY+g, t, vH); // E lower-left
|
||||
bar(2, dx+w-t, midY+g, t, vH); // C lower-right
|
||||
bar(12, cx-t/2, midY+g, t, vH); // L centre-lower
|
||||
bar(3, dx+t, dy+h-t, w-2*t, t); // D bottom
|
||||
diag(8, dx+t+1, dy+t+1, cx-t*0.6, midY-t*0.6); // H top-left
|
||||
diag(10, dx+w-t-1, dy+t+1, cx+t*0.6, midY-t*0.6); // J top-right
|
||||
diag(11, dx+t+1, dy+h-t-1, cx-t*0.6, midY+t*0.6); // K bottom-left
|
||||
diag(13, dx+w-t-1, dy+h-t-1, cx+t*0.6, midY+t*0.6); // M bottom-right
|
||||
}
|
||||
function drawLED(flash, level){
|
||||
flash = flash || 0; level = level || 0;
|
||||
lc.fillStyle=LED_BG; lc.fillRect(0,0,LW,LH);
|
||||
// whole-display beat flash: a wash over the window — subtle on sub-beats, bright on the "1"
|
||||
if(flash>0){ const a = flash * (level>=3 ? 0.55 : level>=2 ? 0.30 : 0.13);
|
||||
lc.fillStyle = (level>=3 ? "rgba(255,184,74," : "rgba(255,138,30,") + a.toFixed(3) + ")"; lc.fillRect(0,0,LW,LH); }
|
||||
const txt=ledText(), pad=10, gap=9, n=NCH, dw=(LW-2*pad-(n-1)*gap)/n, dh=LH-2*pad;
|
||||
for(let i=0;i<n;i++) drawChar(pad+i*(dw+gap), pad, dw, dh, txt[i]||" ");
|
||||
}
|
||||
// beat / sub-beat flash, driven off the master lane (latency-compensated, like the other devices)
|
||||
let beatFlash=0, beatLevel=0;
|
||||
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
|
||||
function frame(){
|
||||
if(audioCtx && state.running){
|
||||
const now = audioCtx.currentTime - audioLatency();
|
||||
for(const m of meters){
|
||||
while(m.vqPtr<m.vq.length && m.vq[m.vqPtr].time<=now){
|
||||
const e=m.vq[m.vqPtr]; m.currentStep=e.step;
|
||||
if(m===meters[0]){ const spb=m.stepsPerBeat;
|
||||
beatLevel = (e.step===0) ? 3 : (e.step % spb === 0) ? 2 : 1; // downbeat ("1") · beat · sub-beat
|
||||
if((m.beatsOn[e.step]|0)!==0 || e.step % spb === 0) beatFlash=1; }
|
||||
m.vqPtr++;
|
||||
}
|
||||
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
|
||||
}
|
||||
beatFlash = Math.max(0, beatFlash - 0.10);
|
||||
drawLED(beatFlash, beatLevel);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
function render(){
|
||||
drawLED();
|
||||
$("indBpm").classList.toggle("on", displayMode==="bpm");
|
||||
$("indTrk").classList.toggle("on", displayMode==="track");
|
||||
$("indPlay").classList.toggle("on", state.running);
|
||||
if(window.progRefresh) progRefresh();
|
||||
}
|
||||
|
||||
/* ========================= ROLLER (the only control) ========================= */
|
||||
let rollPos=0;
|
||||
function nudge(d){ setBpm(state.bpm+d); displayMode="bpm"; rollPos+=d*5; $("enc").style.setProperty("--rib", rollPos+"px"); render(); }
|
||||
let revertT=null;
|
||||
function previewTrack(d){ previewIdx=((previewIdx+d)%tracks.length+tracks.length)%tracks.length; displayMode="track"; render(); }
|
||||
function commitTrack(){ loadTrack(previewIdx); displayMode="track"; render(); clearTimeout(revertT); revertT=setTimeout(()=>{ displayMode="bpm"; render(); }, 1100); }
|
||||
|
||||
/* roll = tempo · quick press = start/stop · hold (~350ms) then roll = switch track */
|
||||
(function(){
|
||||
const k=$("enc"); let down=false, moved=false, held=false, lastX=0, acc=0, holdT=null;
|
||||
k.addEventListener("wheel",(e)=>{ e.preventDefault(); nudge(e.deltaY<0 ? (e.shiftKey?5:1) : (e.shiftKey?-5:-1)); }, {passive:false});
|
||||
k.addEventListener("pointerdown",(e)=>{ down=true; moved=false; held=false; lastX=e.clientX; acc=0; previewIdx=trackIdx;
|
||||
k.classList.add("press"); k.setPointerCapture(e.pointerId);
|
||||
holdT=setTimeout(()=>{ if(down && !moved){ held=true; displayMode="track"; render(); } }, 350); });
|
||||
k.addEventListener("pointermove",(e)=>{ if(!down) return; acc += e.clientX - lastX; lastX=e.clientX;
|
||||
if(held){ while(Math.abs(acc)>=12){ const d=acc>0?1:-1; acc-=d*12; moved=true; previewTrack(d); } } // hold + roll → track
|
||||
else { while(Math.abs(acc)>=6){ const d=acc>0?1:-1; acc-=d*6; moved=true; clearTimeout(holdT); nudge(d); } } }); // roll → tempo
|
||||
k.addEventListener("pointerup",()=>{ if(!down) return; down=false; clearTimeout(holdT); k.classList.remove("press");
|
||||
if(held){ if(moved) commitTrack(); else { displayMode="bpm"; render(); } }
|
||||
else if(!moved){ toggle(); } }); // quick press → start/stop
|
||||
k.addEventListener("pointercancel",()=>{ down=false; clearTimeout(holdT); k.classList.remove("press"); });
|
||||
})();
|
||||
|
||||
/* theme toggle — cycles system → light → dark; shares the "metronome.theme" key */
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
|
||||
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(); toggle(); }
|
||||
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==="ArrowRight"){ e.preventDefault(); previewIdx=trackIdx+1; commitTrack(); }
|
||||
else if(k==="ArrowLeft"){ e.preventDefault(); previewIdx=trackIdx-1; commitTrack(); }
|
||||
});
|
||||
|
||||
/* ========================= INIT ============================================== */
|
||||
{ const ht=tracksFromHash(); if(ht) tracks=ht; } // a #p=/#sl= link (or embed config) overrides the built-ins
|
||||
loadTrack(0);
|
||||
render();
|
||||
requestAnimationFrame(frame);
|
||||
window.currentProgramString = function(){ var t=tracks[trackIdx]||{}; return setupToPatch({bpm:state.bpm, volume:state.volume, lanes:t.lanes||[]}); };
|
||||
window.loadProgramString = function(plain){ var s=patchToSetup(plain); tracks=[{name:"Program", ...s}]; trackIdx=0; loadTrack(0); };
|
||||
/*@BUILD:include:src/progbox.js@*/
|
||||
</script>
|
||||
|
||||
<p class="ff-link pageonly"><a href="/info-micro.html">Purpose, dimensions & bill of materials →</a></p>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PolyMeter — Practice sessions</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="PolyMeter" />
|
||||
<link rel="apple-touch-icon" href="/icon-180.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
|
||||
<script>
|
||||
(function(){ try{
|
||||
var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{
|
||||
--bg1:#12151c; --bg2:#05070a; --txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
|
||||
--cyan:#0AB3F7; --amber:#ffd166; --row:#0e1218;
|
||||
}
|
||||
:root[data-theme="light"]{
|
||||
--bg1:#eef3f9; --bg2:#cfd9e6; --txt:#10202f; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0; --row:#f4f7fb;
|
||||
}
|
||||
html,body{ height:100%; }
|
||||
body{ margin:0; min-height:100%; color:var(--txt); background:radial-gradient(circle at 50% -10%, var(--bg1), var(--bg2));
|
||||
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-text-size-adjust:100%; overscroll-behavior-y:none; }
|
||||
.wrap{ max-width:760px; margin:0 auto;
|
||||
padding:max(10px,env(safe-area-inset-top)) max(14px,env(safe-area-inset-right)) max(28px,env(safe-area-inset-bottom)) max(14px,env(safe-area-inset-left)); }
|
||||
header{ display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:5; padding:8px 0 10px;
|
||||
background:linear-gradient(180deg, var(--bg1) 70%, transparent); }
|
||||
.back{ flex:0 0 auto; display:flex; align-items:center; gap:6px; text-decoration:none; color:var(--txt);
|
||||
background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); border-radius:10px; padding:9px 13px; font-size:15px; }
|
||||
header h1{ flex:1 1 auto; font-size:18px; margin:0; }
|
||||
.icon{ flex:0 0 auto; width:40px; height:40px; border-radius:50%; display:flex; align-items:center; justify-content:center;
|
||||
font-size:17px; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
|
||||
|
||||
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; margin-bottom:14px; }
|
||||
.seclabel{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin:6px 2px 8px; }
|
||||
|
||||
/* current-track aggregate (compare across sessions) */
|
||||
#trackAgg .ta-head{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:4px; }
|
||||
#taSel{ flex:1 1 auto; min-width:0; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd);
|
||||
border-radius:10px; padding:9px 8px; font-size:16px; font-weight:600; }
|
||||
.stat{ font-size:13px; color:var(--muted); margin:4px 0 12px; }
|
||||
.stat b{ color:var(--txt); }
|
||||
|
||||
table{ width:100%; border-collapse:collapse; font-size:14px; }
|
||||
th,td{ text-align:left; padding:8px 8px; border-bottom:1px solid var(--panel-bd); }
|
||||
th{ font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); font-weight:600; }
|
||||
td.num,th.num{ text-align:right; font-variant-numeric:tabular-nums; }
|
||||
tr:nth-child(even) td{ background:var(--row); }
|
||||
tfoot td{ font-weight:700; border-top:2px solid var(--panel-bd); border-bottom:none; }
|
||||
|
||||
/* session list — collapsible */
|
||||
details.sess{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; margin-bottom:12px; overflow:hidden; }
|
||||
details.sess > summary{ list-style:none; cursor:pointer; padding:13px 14px; display:flex; flex-direction:column; gap:3px; }
|
||||
details.sess > summary::-webkit-details-marker{ display:none; }
|
||||
summary .when{ font-size:15px; font-weight:600; display:flex; align-items:center; gap:9px; }
|
||||
summary .when::before{ content:"▸"; color:var(--muted); font-size:13px; transition:transform .15s; }
|
||||
details[open] > summary .when::before{ transform:rotate(90deg); }
|
||||
summary .sstat{ font-size:12px; color:var(--muted); padding-left:22px; }
|
||||
summary .sstat b{ color:var(--txt); }
|
||||
.sbody{ padding:2px 14px 14px; }
|
||||
.sbody .brow{ display:flex; justify-content:flex-end; margin-bottom:10px; }
|
||||
.del{ background:transparent; border:1px solid var(--panel-bd); color:var(--muted); border-radius:9px; padding:6px 11px; font-size:12px; cursor:pointer; }
|
||||
.del:hover{ color:#ff7a7a; border-color:#ff7a7a; }
|
||||
textarea.note{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px;
|
||||
padding:10px; font-family:inherit; font-size:14px; resize:vertical; min-height:46px; margin-bottom:12px; }
|
||||
|
||||
.empty{ text-align:center; color:var(--muted); padding:60px 16px; }
|
||||
.empty .big{ font-size:46px; opacity:.5; }
|
||||
.foot{ text-align:center; color:var(--muted); font-size:12px; margin-top:24px; }
|
||||
.foot img{ height:16px; vertical-align:middle; opacity:.85; }
|
||||
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<a class="back" href="/mobile.html">‹ Metronome</a>
|
||||
<h1>Practice sessions</h1>
|
||||
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme">◐</div>
|
||||
</header>
|
||||
|
||||
<div id="trackAgg" class="card" style="display:none">
|
||||
<div class="seclabel">This track — across all sessions</div>
|
||||
<div class="ta-head"><select id="taSel"></select></div>
|
||||
<div class="stat" id="taStat"></div>
|
||||
<table id="taTable"></table>
|
||||
</div>
|
||||
|
||||
<div class="seclabel" id="listLabel" style="display:none">Sessions</div>
|
||||
<div id="list"></div>
|
||||
|
||||
<div class="foot">
|
||||
<img class="logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><img class="logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS" />
|
||||
PolyMeter <span id="appVersion"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const LS_SESSIONS = "metronome.sessions", 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 lsGetRaw(k){ try{ return localStorage.getItem(k)||""; }catch(e){ return ""; } }
|
||||
function fmt(sec){ sec=Math.max(0,Math.round(sec)); const h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
|
||||
return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
|
||||
function esc(s){ return String(s).replace(/[&<>"]/g,(c)=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
||||
function whenLong(ms){ const d=new Date(ms);
|
||||
const wd=d.toLocaleDateString(undefined,{weekday:"short"}), mo=d.toLocaleDateString(undefined,{month:"short"});
|
||||
const t=d.toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"});
|
||||
return wd+" "+mo+" "+d.getDate()+" at "+t; } // "Fri Jun 16 at 2:46 PM"
|
||||
function whenShort(ms){ const d=new Date(ms);
|
||||
return d.toLocaleDateString(undefined,{month:"short",day:"numeric"})+" · "+d.toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"}); }
|
||||
function bpmRange(b){ if(!b.length) return "—"; const lo=Math.min(...b), hi=Math.max(...b); return lo===hi?String(lo):lo+"–"+hi; }
|
||||
|
||||
/* per-track aggregate of one session's segments → one row per track */
|
||||
function aggregate(seg){
|
||||
const by={};
|
||||
(seg||[]).forEach((s)=>{ const k=s.name||"(unnamed)"; const a=by[k]||(by[k]={name:k,sec:0,plays:0,bpms:[]}); a.sec+=s.sec; a.plays++; if(s.bpm) a.bpms.push(s.bpm); });
|
||||
return Object.values(by).sort((x,y)=>y.sec-x.sec);
|
||||
}
|
||||
/* one track across all sessions → one row per session that included it (newest first) */
|
||||
function trackRows(name){
|
||||
const out=[];
|
||||
lsGet(LS_SESSIONS,[]).forEach((s)=>{
|
||||
let sec=0, plays=0, bpms=[];
|
||||
(s.segments||[]).forEach((g)=>{ if((g.name||"(unnamed)")===name){ sec+=g.sec; plays++; if(g.bpm) bpms.push(g.bpm); } });
|
||||
if(sec>0) out.push({ at:s.at, sec, plays, bpms });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function allTrackNames(){ const set=new Set(); lsGet(LS_SESSIONS,[]).forEach((s)=>(s.segments||[]).forEach((g)=>set.add(g.name||"(unnamed)"))); return [...set].sort((a,b)=>a.localeCompare(b)); }
|
||||
|
||||
/* ----- top: current-track comparison across sessions ----- */
|
||||
let selTrack=null;
|
||||
function renderTrackAgg(){
|
||||
const names=allTrackNames();
|
||||
if(!names.length){ $("trackAgg").style.display="none"; return; }
|
||||
$("trackAgg").style.display="";
|
||||
const cur=lsGetRaw(LS_CURTRACK).replace(/^"|"$/g,""); // stored JSON-encoded string
|
||||
if(selTrack===null) selTrack = names.includes(cur) ? cur : names[0];
|
||||
if(!names.includes(selTrack)) selTrack=names[0];
|
||||
// selector
|
||||
const sel=$("taSel"); sel.innerHTML="";
|
||||
names.forEach((n)=>{ const o=document.createElement("option"); o.value=n; o.textContent=n+(n===cur?" (current)":""); sel.appendChild(o); });
|
||||
sel.value=selTrack;
|
||||
// stats + per-session table
|
||||
const rows=trackRows(selTrack);
|
||||
const totSec=rows.reduce((a,r)=>a+r.sec,0), totPlays=rows.reduce((a,r)=>a+r.plays,0), allb=rows.flatMap((r)=>r.bpms);
|
||||
$("taStat").innerHTML = rows.length+" session"+(rows.length===1?"":"s")+" · total <b>"+fmt(totSec)+"</b> · "+totPlays+" play"+(totPlays===1?"":"s")+" · "+bpmRange(allb)+" bpm";
|
||||
$("taTable").innerHTML =
|
||||
'<thead><tr><th>Session</th><th class="num">Time</th><th class="num">Plays</th><th class="num">BPM</th></tr></thead><tbody>'+
|
||||
(rows.length ? rows.map((r)=>'<tr><td>'+esc(whenShort(r.at))+'</td><td class="num">'+fmt(r.sec)+'</td><td class="num">'+r.plays+'</td><td class="num">'+bpmRange(r.bpms)+'</td></tr>').join("")
|
||||
: '<tr><td colspan="4" style="color:var(--muted)">No sessions for this track yet.</td></tr>')+
|
||||
'</tbody>';
|
||||
}
|
||||
$("taSel").addEventListener("change",(e)=>{ selTrack=e.target.value; renderTrackAgg(); });
|
||||
|
||||
/* ----- session list (collapsible) ----- */
|
||||
function renderList(){
|
||||
const sessions=lsGet(LS_SESSIONS,[]);
|
||||
const list=$("list"); list.innerHTML="";
|
||||
if(!sessions.length){
|
||||
$("listLabel").style.display="none";
|
||||
list.innerHTML='<div class="empty"><div class="big">𝄞</div><p>No practice sessions yet.<br>On the metronome, press <b>Practice</b> to start a session, then <b>Stop</b> when you\'re done — it\'ll be saved here.</p></div>';
|
||||
return;
|
||||
}
|
||||
$("listLabel").style.display="";
|
||||
sessions.forEach((s)=>{
|
||||
const rows=aggregate(s.segments), practiced=rows.reduce((a,r)=>a+r.sec,0);
|
||||
const d=document.createElement("details"); d.className="sess";
|
||||
d.innerHTML =
|
||||
'<summary><span class="when">'+esc(whenLong(s.at))+'</span>'+
|
||||
'<span class="sstat">Total <b>'+fmt(s.clockSec)+'</b> · practiced <b>'+fmt(practiced)+'</b> · '+rows.length+' track'+(rows.length===1?"":"s")+'</span></summary>'+
|
||||
'<div class="sbody">'+
|
||||
'<div class="brow"><button class="del">Delete session</button></div>'+
|
||||
'<textarea class="note" placeholder="Add a note about this session — what you worked on, how it felt…">'+esc(s.note||"")+'</textarea>'+
|
||||
'<table><thead><tr><th>Track</th><th class="num">Time</th><th class="num">Plays</th><th class="num">BPM</th></tr></thead><tbody>'+
|
||||
rows.map((r)=>'<tr><td>'+esc(r.name)+'</td><td class="num">'+fmt(r.sec)+'</td><td class="num">'+r.plays+'</td><td class="num">'+bpmRange(r.bpms)+'</td></tr>').join("")+
|
||||
'</tbody><tfoot><tr><td>Total</td><td class="num">'+fmt(practiced)+'</td><td class="num">'+rows.reduce((a,r)=>a+r.plays,0)+'</td><td class="num"></td></tr></tfoot></table>'+
|
||||
'</div>';
|
||||
d.querySelector(".note").addEventListener("input",(e)=>saveNote(s.at, e.target.value));
|
||||
d.querySelector(".del").addEventListener("click",()=>{
|
||||
if(!confirm("Delete this practice session? This can't be undone.")) return;
|
||||
lsSet(LS_SESSIONS, lsGet(LS_SESSIONS,[]).filter((x)=>x.at!==s.at)); render();
|
||||
});
|
||||
list.appendChild(d);
|
||||
});
|
||||
}
|
||||
let saveTimer=null;
|
||||
function saveNote(at, text){
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer=setTimeout(()=>{ const arr=lsGet(LS_SESSIONS,[]); const it=arr.find((x)=>x.at===at); if(it){ it.note=text; lsSet(LS_SESSIONS,arr); } }, 300);
|
||||
}
|
||||
|
||||
function render(){ renderTrackAgg(); renderList(); }
|
||||
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
52
mobile-sw.js
52
mobile-sw.js
|
|
@ -1,52 +0,0 @@
|
|||
/* Service worker for the PolyMeter mobile app (mobile.html).
|
||||
*
|
||||
* Deliberately minimal and non-intrusive: it only manages its OWN app-shell URLs
|
||||
* (the page, manifest, icons). For every other request it does NOT call
|
||||
* respondWith(), so the rest of the site behaves exactly as if no SW existed.
|
||||
*
|
||||
* Strategy for the shell: network-first, fall back to cache. The page is a single
|
||||
* self-contained file that is version-stamped on deploy, so when the device is
|
||||
* online it always gets the freshest build; offline it still launches from cache.
|
||||
*/
|
||||
const CACHE = "polymeter-mobile-v2";
|
||||
const SHELL = [
|
||||
"/mobile.html",
|
||||
"/mobile-sessions.html",
|
||||
"/manifest.webmanifest",
|
||||
"/icon-192.png",
|
||||
"/icon-512.png",
|
||||
"/icon-180.png",
|
||||
];
|
||||
const SHELL_PATHS = new Set(SHELL);
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
self.skipWaiting();
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).catch(() => {}));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
const req = e.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
// Treat any navigation to /mobile.html (with or without ?standalone=1 etc.) as the shell.
|
||||
const path = url.pathname;
|
||||
if (!SHELL_PATHS.has(path)) return; // not ours — let the browser handle it
|
||||
|
||||
e.respondWith(
|
||||
fetch(req)
|
||||
.then((res) => {
|
||||
if (res && res.ok) { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(path, copy)); }
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(path).then((hit) => hit || caches.match("/mobile.html")))
|
||||
);
|
||||
});
|
||||
951
mobile.html
951
mobile.html
|
|
@ -1,951 +0,0 @@
|
|||
<!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>
|
||||
79
pico-cp/README.md
Normal file
79
pico-cp/README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# PM_K‑1 "Kit" — CircuitPython edition (USB drive + editor)
|
||||
|
||||
The **CircuitPython** firmware for the 52Pi EP‑0172 Pico kit. Unlike the MicroPython version
|
||||
(`../pico/main.py`), this makes the Pico mount as a **USB drive (`CIRCUITPY`)** that carries the
|
||||
firmware and your tracks — so you can edit on the web and reprogram it without Thonny. It runs the
|
||||
same program‑string language as <https://metronome.varasys.io>.
|
||||
|
||||
> **Status: experimental, phase 1.** This drives the screen/touch/joystick/buzzer and reads your
|
||||
> grooves from `programs.json`. The editor's one‑click "Save to device" and USB‑MIDI audio‑to‑computer
|
||||
> are landing in later phases. The simpler **MicroPython** firmware (`../pico/main.py`) remains the
|
||||
> rock‑solid fallback — and the Pico can't be bricked (BOOTSEL → drag a MicroPython `.uf2` back).
|
||||
|
||||
## Install
|
||||
|
||||
1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, and drop the CircuitPython `.uf2` for your board
|
||||
onto the `RPI‑RP2` drive (<https://circuitpython.org/board/raspberry_pi_pico/> — or the Pico 2 / W
|
||||
build). It reboots and a **`CIRCUITPY`** drive appears.
|
||||
2. **Copy everything from the bundle** onto `CIRCUITPY` (drag‑and‑drop — it's a normal drive now):
|
||||
- `code.py` (this firmware — runs on boot)
|
||||
- `programs.json` (your grooves)
|
||||
- `font_s.bin`, `font_m.bin`, `font_l.bin` (the anti‑aliased fonts — kept as files to save RAM)
|
||||
- `editor.html` (an offline copy of the web editor, so the drive carries its own programmer)
|
||||
3. It starts immediately. Editing `programs.json` (or re‑saving it from the editor) makes CircuitPython
|
||||
**auto‑reload** with the new tracks.
|
||||
|
||||
## Play through the computer's speakers (USB-MIDI)
|
||||
|
||||
The board also shows up as a **USB-MIDI** device and sends a note on every click (a GM drum note per
|
||||
lane, velocity by accent). Open the [editor](https://metronome.varasys.io/editor.html) in **Chrome/Edge**,
|
||||
click **🎹 Device audio**, grant MIDI access, then press play *on the device* — the editor voices the
|
||||
groove through its full synth, out your computer's speakers, locked to the device's clock. The button
|
||||
shows the connected device's name and pulses green on each note; set `MUTE_BUZZER = True` in `code.py`
|
||||
if you'd rather silence the on-board buzzer while doing this.
|
||||
|
||||
If the editor says **no MIDI input is connected**, copy **`boot.py`** onto `CIRCUITPY` too and
|
||||
**power-cycle** the Pico (`boot.py` only runs on a full reset). It frees a USB endpoint (drops the
|
||||
unused HID interface) so the MIDI port is guaranteed to appear alongside the drive.
|
||||
|
||||
## Controls (same as the MicroPython build)
|
||||
|
||||
- **Touch:** on‑screen `◀◀ / ▶ / ▶▶` (prev · play/stop · next) and `− / TAP / +`.
|
||||
- **Joystick:** up/down = tempo, left/right = previous/next groove.
|
||||
- **Button A (GP15)** play/stop · **Button B (GP14)** tap tempo.
|
||||
- **RGB LED** flashes each beat; **buzzer** clicks (accent/normal/ghost).
|
||||
|
||||
## programs.json
|
||||
|
||||
```json
|
||||
{ "title": "PolyMeter",
|
||||
"programs": [ { "name": "Four on the floor", "prog": "t120;kick:4;snare:4=.x.x;hatClosed:4/2" } ] }
|
||||
```
|
||||
|
||||
Each `prog` is a program string from the web editor. Add/replace entries and save — the device reloads.
|
||||
|
||||
**Easiest way to (re)program it:** in the editor (the web app, or the `editor.html` on the drive), build a
|
||||
set list, then the set‑list **⋯** menu → **📟 Save to device** → pick the `CIRCUITPY` drive. In Chrome/Edge it
|
||||
writes `programs.json` straight onto the drive (the Pico auto‑reloads); elsewhere it downloads the file to drag
|
||||
on. **📥 Load from device** reads a `programs.json` back into a new set list.
|
||||
|
||||
## Calibration (flip flags at the top of `code.py`)
|
||||
|
||||
- **Red/blue swapped:** flip `MADCTL` between `0x48` (default) and `0x40`.
|
||||
- **Colours look negative:** toggle `INVERT_COLORS`.
|
||||
- **Taps land wrong:** set `TOUCH_DEBUG = True`, watch the serial output, then set
|
||||
`TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y`.
|
||||
- **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`.
|
||||
- **Computer audio:** `MIDI_ENABLED` (default on) sends the MIDI notes; `MUTE_BUZZER` silences the buzzer.
|
||||
- **LED too bright / too dim:** change `LED_BRIGHTNESS` (0..1, default 0.15).
|
||||
- **Screen tearing:** the SPI panel has no tearing-effect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast
|
||||
to minimise it — lower it only if the display is unstable.
|
||||
- **Screen blank / garbled:** the panel lot may differ; drop `SPI_BAUD`, and if it's a 240×320 ILI9341
|
||||
instead of the 320×480 ST7796, the init/size need changing (this targets the 320×480 you have).
|
||||
- **RGB LED** is driven by the core `neopixel_write` module — no library to install. If it stays dark,
|
||||
your CircuitPython build is unusually missing that module (everything else still works).
|
||||
|
||||
If `code.py` ever errors, CircuitPython prints the traceback **on the screen and over USB serial** —
|
||||
copy that to me and I'll fix it.
|
||||
|
||||
The fonts are the same baked anti‑aliased blobs as the MicroPython build (see `../pico/gen_font.py`).
|
||||
BIN
pico-cp/__pycache__/code.cpython-312.pyc
Normal file
BIN
pico-cp/__pycache__/code.cpython-312.pyc
Normal file
Binary file not shown.
10
pico-cp/boot.py
Normal file
10
pico-cp/boot.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# boot.py — runs once before USB connects (hard reset / power-cycle to apply).
|
||||
# Guarantees the device shows up as a USB-MIDI port so the web editor's "Device audio"
|
||||
# can hear it. We don't use HID, so disabling it frees a USB endpoint for MIDI on the
|
||||
# Pico (which also exposes the CIRCUITPY drive + serial at the same time).
|
||||
import usb_hid, usb_midi
|
||||
try:
|
||||
usb_hid.disable()
|
||||
except Exception:
|
||||
pass
|
||||
usb_midi.enable()
|
||||
531
pico-cp/code.py
Normal file
531
pico-cp/code.py
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
# VARASYS PolyMeter — PM_K-1 "Kit" firmware (CircuitPython edition)
|
||||
# Raspberry Pi Pico (Pico / Pico W / Pico 2) on the 52Pi EP-0172 "Pico Breadboard Kit Plus":
|
||||
# 3.5" ST7796 320x480 cap-touch (GT911), PSP joystick, WS2812 RGB, buzzer, 2 buttons.
|
||||
#
|
||||
# WHY CIRCUITPYTHON: the board then mounts as a USB drive (CIRCUITPY) carrying this code, your
|
||||
# tracks (programs.json) and a copy of the editor — edit on the web, "Save to device" writes
|
||||
# programs.json here, and CircuitPython auto-reloads with the new grooves. It also sends USB-MIDI
|
||||
# (a note per click) so the web editor can play it out the computer's speakers ("Device audio").
|
||||
# Runs the SAME program strings as metronome.varasys.io.
|
||||
#
|
||||
# INSTALL: flash CircuitPython (https://circuitpython.org/board/raspberry_pi_pico/), then copy
|
||||
# this file as code.py plus programs.json onto the CIRCUITPY drive. It runs on boot.
|
||||
#
|
||||
# Fallback: the simpler MicroPython firmware (pico/main.py) is always available — BOOTSEL +
|
||||
# drag a MicroPython .uf2 to go back. The Pico cannot be bricked.
|
||||
#
|
||||
# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md.
|
||||
|
||||
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc
|
||||
try: # CircuitPython 9.x
|
||||
from fourwire import FourWire
|
||||
from busdisplay import BusDisplay
|
||||
except ImportError: # CircuitPython 8.x
|
||||
from displayio import FourWire
|
||||
from displayio import Display as BusDisplay
|
||||
try:
|
||||
import neopixel_write # core module on RP2040 — drives WS2812 with no external library
|
||||
except ImportError:
|
||||
neopixel_write = None
|
||||
try:
|
||||
import usb_midi # default-enabled on RP2040 — sends a MIDI note per click to the computer
|
||||
except ImportError:
|
||||
usb_midi = None
|
||||
|
||||
# ============================== CONFIG (tweak if needed) ==============================
|
||||
SPI_BAUD = 62_500_000 # faster SPI = smaller tearing window; drop to 40_000_000 if unstable
|
||||
LED_BRIGHTNESS = 0.15 # WS2812 sits right next to you — keep it dim (0..1)
|
||||
MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web editor's "Device audio")
|
||||
MUTE_BUZZER = False # silence the on-board buzzer (e.g. when using computer audio)
|
||||
WIDTH, HEIGHT = 320, 480
|
||||
MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed.
|
||||
INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative
|
||||
# Touch (GT911) — flip if taps land wrong:
|
||||
TOUCH_SWAP_XY = False
|
||||
TOUCH_INVERT_X = False
|
||||
TOUCH_INVERT_Y = False
|
||||
TOUCH_DEBUG = False
|
||||
# Joystick:
|
||||
JOY_INVERT_X = False
|
||||
JOY_INVERT_Y = False
|
||||
JOY_DEADZONE = 9000
|
||||
|
||||
# ----- pins (fixed by the EP-0172 board) -----
|
||||
P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7
|
||||
P_SDA, P_SCL = board.GP8, board.GP9
|
||||
P_RGB, P_BUZ, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14
|
||||
P_JOYX, P_JOYY = board.GP26, board.GP27
|
||||
|
||||
# ----- baked default grooves (used only if programs.json is missing/bad) -----
|
||||
DEFAULT_PROGRAMS = [
|
||||
("Four on the floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"),
|
||||
("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"),
|
||||
("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"),
|
||||
("5 over 4", "t100;kick:4;claves:5~"),
|
||||
("Straight click", "t120;beep:4"),
|
||||
]
|
||||
|
||||
# ============================== COLOURS (0xRRGGBB; displayio handles 565) ==============================
|
||||
C_BG, C_PANEL, C_TXT, C_MUTE = 0x06090E, 0x1C222C, 0xC7D0DB, 0x788494
|
||||
C_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240
|
||||
C_BTN = 0x1C222C
|
||||
LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)}
|
||||
# voice -> General-MIDI note (USB-MIDI bridge), and level -> MIDI velocity
|
||||
SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare909":38,
|
||||
"clap":39,"clap808":39,"clap909":39, "rim":37, "hatClosed":42,"hat808":42,"hat909":42,
|
||||
"hatOpen":46,"openHat808":46, "ride":51,"ride909":51, "crash":49,"crash909":49,
|
||||
"tomLow":41,"tom808":45,"tomMid":45,"tomHigh":48, "tambourine":54,
|
||||
"cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":37}
|
||||
GM_DEFAULT = 37
|
||||
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
|
||||
MAXLANES = 5 # lanes shown on the pad grid (extras still play)
|
||||
PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost
|
||||
PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost
|
||||
|
||||
# WS2812 RGB LED — self-contained via the core neopixel_write module (no external library)
|
||||
class RGB:
|
||||
def __init__(self, pin):
|
||||
self.ok = neopixel_write is not None
|
||||
if self.ok:
|
||||
self.io = digitalio.DigitalInOut(pin); self.io.direction = digitalio.Direction.OUTPUT
|
||||
self.buf = bytearray(3)
|
||||
def set(self, r, g, b):
|
||||
if not self.ok: return
|
||||
# WS2812 wants GRB order; scale down so it isn't blinding
|
||||
self.buf[0] = int(g * LED_BRIGHTNESS); self.buf[1] = int(r * LED_BRIGHTNESS); self.buf[2] = int(b * LED_BRIGHTNESS)
|
||||
try: neopixel_write.neopixel_write(self.io, self.buf)
|
||||
except Exception: self.ok = False
|
||||
|
||||
# ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ==============================
|
||||
def load_font(path):
|
||||
with open(path, "rb") as f:
|
||||
blob = f.read()
|
||||
count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {}
|
||||
for _ in range(count):
|
||||
cp = (blob[p] << 8) | blob[p+1]; w = blob[p+2]; h = blob[p+3]
|
||||
xoff = blob[p+4]; xoff = xoff - 256 if xoff > 127 else xoff
|
||||
top = blob[p+5]; adv = blob[p+6]; p += 7
|
||||
glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2
|
||||
return (glyphs, blob)
|
||||
|
||||
FONT_S = load_font("/font_s.bin") # small — pad-grid lane labels
|
||||
FONT_M = load_font("/font_m.bin") # labels / buttons
|
||||
FONT_L = load_font("/font_l.bin") # big BPM
|
||||
gc.collect()
|
||||
|
||||
def _blend(bg, fg, i):
|
||||
t = i * 17
|
||||
r = (((bg >> 16) & 0xFF)*(255-t) + ((fg >> 16) & 0xFF)*t) // 255
|
||||
g = (((bg >> 8) & 0xFF)*(255-t) + ((fg >> 8) & 0xFF)*t) // 255
|
||||
b = ((bg & 0xFF)*(255-t) + (fg & 0xFF)*t) // 255
|
||||
return (r << 16) | (g << 8) | b
|
||||
|
||||
def make_text(s, font, fg, bg):
|
||||
"""Render a string into a displayio TileGrid (anti-aliased via a 16-step blend palette)."""
|
||||
glyphs, blob = font
|
||||
w = 0; top0 = 999; bot = 0
|
||||
for c in s:
|
||||
g = glyphs.get(ord(c))
|
||||
if not g: continue
|
||||
w += g[4]
|
||||
if g[1]:
|
||||
if g[3] < top0: top0 = g[3]
|
||||
if g[3] + g[1] > bot: bot = g[3] + g[1]
|
||||
if top0 == 999: top0 = 0
|
||||
w = max(1, w); h = max(1, bot - top0)
|
||||
gc.collect()
|
||||
bmp = displayio.Bitmap(w, h, 16)
|
||||
pal = displayio.Palette(16)
|
||||
for i in range(16): pal[i] = _blend(bg, fg, i)
|
||||
pen = 0
|
||||
for c in s:
|
||||
g = glyphs.get(ord(c))
|
||||
if not g: continue
|
||||
gw, gh, xoff, gtop, adv, off = g
|
||||
for j in range(gh):
|
||||
row = (gtop - top0) + j
|
||||
for i in range(gw):
|
||||
k = j * gw + i
|
||||
byte = blob[off + (k >> 1)]
|
||||
nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF)
|
||||
if nib:
|
||||
x = pen + xoff + i
|
||||
if 0 <= x < w and 0 <= row < h: bmp[x, row] = nib
|
||||
pen += adv
|
||||
return displayio.TileGrid(bmp, pixel_shader=pal), w, h
|
||||
|
||||
# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ==============================
|
||||
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
|
||||
PRIO = {2: 3, 1: 2, 3: 1}
|
||||
|
||||
def parse_program(s):
|
||||
bpm = 120; lanes = []
|
||||
for tok in s.strip().split(';'):
|
||||
tok = tok.strip()
|
||||
if not tok: continue
|
||||
if tok[0] == 't' and tok[1:].isdigit():
|
||||
bpm = int(tok[1:]); continue
|
||||
if ':' not in tok: continue
|
||||
lane = _parse_lane(tok)
|
||||
if lane: lanes.append(lane)
|
||||
if not lanes: lanes = [_parse_lane("beep:4")]
|
||||
return max(30, min(300, bpm)), lanes
|
||||
|
||||
def _parse_lane(tok):
|
||||
poly = '~' in tok; mute = '!' in tok
|
||||
tok = tok.replace('~', '').replace('!', '')
|
||||
if '@' in tok: tok = tok.split('@')[0]
|
||||
sound, _, rest = tok.partition(':')
|
||||
pattern = None
|
||||
if '=' in rest: rest, _, pattern = rest.partition('=')
|
||||
sub = 1
|
||||
if '/' in rest:
|
||||
rest, _, sd = rest.partition('/'); sd = sd.rstrip('s')
|
||||
sub = int(sd) if sd.isdigit() else 1
|
||||
groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4]
|
||||
beats = sum(groups); starts = set(); acc = 0
|
||||
for gp in groups: starts.add(acc); acc += gp
|
||||
steps = beats * sub
|
||||
if pattern:
|
||||
levels = [PAT.get(ch, 0) for ch in pattern]
|
||||
if len(levels) < steps: levels += [0] * (steps - len(levels))
|
||||
steps = len(levels)
|
||||
else:
|
||||
levels = []
|
||||
for i in range(steps):
|
||||
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1)
|
||||
else: levels.append(0)
|
||||
return {'sound': sound, 'sub': sub, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute}
|
||||
|
||||
def load_programs():
|
||||
try:
|
||||
with open("/programs.json") as f:
|
||||
d = json.load(f)
|
||||
progs = [(p["name"], p["prog"]) for p in d["programs"]]
|
||||
if progs: return progs
|
||||
except Exception as e:
|
||||
print("programs.json:", e)
|
||||
return DEFAULT_PROGRAMS
|
||||
|
||||
# ============================== GT911 TOUCH ==============================
|
||||
class GT911:
|
||||
def __init__(self, i2c):
|
||||
self.i2c = i2c; self.addr = None
|
||||
while not i2c.try_lock(): pass
|
||||
try: found = i2c.scan()
|
||||
finally: i2c.unlock()
|
||||
for a in (0x5D, 0x14):
|
||||
if a in found: self.addr = a; break
|
||||
if self.addr is None and found: self.addr = found[0]
|
||||
def _rd(self, reg, n):
|
||||
b = bytearray(n)
|
||||
while not self.i2c.try_lock(): pass
|
||||
try:
|
||||
self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF]))
|
||||
self.i2c.readfrom_into(self.addr, b)
|
||||
finally: self.i2c.unlock()
|
||||
return b
|
||||
def _wr(self, reg, val):
|
||||
while not self.i2c.try_lock(): pass
|
||||
try: self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF, val]))
|
||||
finally: self.i2c.unlock()
|
||||
def read(self):
|
||||
if self.addr is None: return None
|
||||
try: st = self._rd(0x814E, 1)[0]
|
||||
except OSError: return None
|
||||
if not (st & 0x80): return None
|
||||
n = st & 0x0F; pt = None
|
||||
if n >= 1:
|
||||
b = self._rd(0x8150, 4); tx = b[0] | (b[1] << 8); ty = b[2] | (b[3] << 8)
|
||||
pt = self._map(tx, ty)
|
||||
try: self._wr(0x814E, 0)
|
||||
except OSError: pass
|
||||
return pt
|
||||
def _map(self, tx, ty):
|
||||
if TOUCH_DEBUG: print("touch raw", tx, ty)
|
||||
if TOUCH_SWAP_XY: tx, ty = ty, tx
|
||||
if TOUCH_INVERT_X: tx = WIDTH - 1 - tx
|
||||
if TOUCH_INVERT_Y: ty = HEIGHT - 1 - ty
|
||||
if 0 <= tx < WIDTH and 0 <= ty < HEIGHT: return (tx, ty)
|
||||
return None
|
||||
|
||||
# ============================== DISPLAY SETUP ==============================
|
||||
def st7796_init():
|
||||
inv = b'\x21\x00' if INVERT_COLORS else b'\x20\x00'
|
||||
return (
|
||||
b'\x01\x80\x78' # SWRESET + 120ms
|
||||
b'\x11\x80\x78' # SLPOUT + 120ms
|
||||
b'\xF0\x01\xC3' b'\xF0\x01\x96' # command-set unlock
|
||||
+ bytes([0x36, 0x01, MADCTL]) +
|
||||
b'\x3A\x01\x55' # 16bpp
|
||||
b'\xB4\x01\x01'
|
||||
b'\xB6\x03\x80\x02\x3B'
|
||||
b'\xE8\x08\x40\x8A\x00\x00\x29\x19\xA5\x33'
|
||||
b'\xC1\x01\x06' b'\xC2\x01\xA7'
|
||||
b'\xC5\x81\x18\x78' # VCOM + 120ms
|
||||
b'\xE0\x0E\xF0\x09\x0B\x06\x04\x15\x2F\x54\x42\x3C\x17\x14\x18\x1B'
|
||||
b'\xE1\x0E\xE0\x09\x0B\x06\x04\x03\x2B\x43\x42\x3B\x16\x14\x17\x1B'
|
||||
b'\xF0\x01\x3C' b'\xF0\x81\x69\x78' # lock + 120ms
|
||||
+ inv +
|
||||
b'\x29\x80\x32' # DISPON + 50ms
|
||||
)
|
||||
|
||||
def make_display():
|
||||
displayio.release_displays()
|
||||
spi = busio.SPI(clock=P_SCK, MOSI=P_MOSI)
|
||||
bus = FourWire(spi, command=P_DC, chip_select=P_CS, reset=P_RST, baudrate=SPI_BAUD)
|
||||
return BusDisplay(bus, st7796_init(), width=WIDTH, height=HEIGHT, auto_refresh=False)
|
||||
|
||||
def solid(color):
|
||||
p = displayio.Palette(1); p[0] = color; return p
|
||||
|
||||
def rect(x, y, w, h, color):
|
||||
return vectorio.Rectangle(pixel_shader=solid(color), width=w, height=h, x=x, y=y)
|
||||
|
||||
# ============================== APP ==============================
|
||||
class App:
|
||||
def __init__(self):
|
||||
self.display = make_display()
|
||||
self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000)
|
||||
self.touch = GT911(self.i2c)
|
||||
self.midi = usb_midi.ports[1] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 1) else None
|
||||
self.led = RGB(P_RGB)
|
||||
self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0)
|
||||
self.buz_off = 0
|
||||
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
|
||||
self._aPrev = True; self._bPrev = True
|
||||
self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY)
|
||||
self._joyNext = 0
|
||||
self._touchDown = False; self._touchSeen = 0
|
||||
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0)
|
||||
self.programs = load_programs()
|
||||
self.buttons = []
|
||||
self.dirty = True
|
||||
self.pad_pal = displayio.Palette(8)
|
||||
for i in range(4): self.pad_pal[i] = PAD_DIM[i]; self.pad_pal[i + 4] = PAD_LIT[i]
|
||||
self.lane_pads = []; self.lane_lit = []
|
||||
self._build_scene()
|
||||
self.load(0)
|
||||
|
||||
def _btn(self, pin):
|
||||
d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP
|
||||
return d
|
||||
|
||||
# ---------- scene graph ----------
|
||||
def _build_scene(self):
|
||||
root = displayio.Group(); self.display.root_group = root
|
||||
root.append(rect(0, 0, WIDTH, HEIGHT, C_BG))
|
||||
tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 8; root.append(tg)
|
||||
root.append(rect(0, 38, WIDTH, 2, C_PANEL))
|
||||
# dynamic groups
|
||||
self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right)
|
||||
self.g_run = displayio.Group(); root.append(self.g_run) # RUN / STOP (left)
|
||||
self.g_name = displayio.Group(); root.append(self.g_name) # item index + name
|
||||
self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes × step pads
|
||||
# buttons (rects static; labels in per-button groups so play can toggle)
|
||||
bw, bh = 96, 56; gap = (WIDTH - 3*bw)//4; xs = [gap, gap*2+bw, gap*3+bw*2]
|
||||
self.btn_lbl = {}
|
||||
rows = [(300, ("prev", "play", "next")), (372, ("minus", "tap", "plus"))]
|
||||
for y, keys in rows:
|
||||
for x, key in zip(xs, keys):
|
||||
root.append(rect(x, y, bw, bh, C_BTN))
|
||||
root.append(rect(x, y, bw, 2, C_PANEL)); root.append(rect(x, y+bh-2, bw, 2, C_PANEL))
|
||||
lg = displayio.Group(); root.append(lg); self.btn_lbl[key] = (lg, x+bw//2, y+bh//2)
|
||||
self.buttons.append((x, y, bw, bh, key))
|
||||
self._label(key)
|
||||
|
||||
def _place(self, group, s, x, y, fg, bg, font, right_edge=None):
|
||||
while len(group): group.pop()
|
||||
self.dirty = True
|
||||
if not s: return
|
||||
tg, w, h = make_text(s, font, fg, bg)
|
||||
tg.x = (right_edge - w) if right_edge is not None else x; tg.y = y; group.append(tg)
|
||||
def _center(self, group, s, cx, cy, fg, bg, font):
|
||||
while len(group): group.pop()
|
||||
tg, w, h = make_text(s, font, fg, bg); tg.x = cx - w//2; tg.y = cy - h//2; group.append(tg)
|
||||
self.dirty = True
|
||||
def _label(self, key):
|
||||
sym = {"prev": "◀◀", "next": "▶▶", "minus": "-", "plus": "+", "tap": "TAP",
|
||||
"play": "■" if self.running else "▶"}[key]
|
||||
lg, cx, cy = self.btn_lbl[key]
|
||||
self._center(lg, sym, cx, cy, C_GREEN if key == "play" else C_TXT, C_BTN, FONT_M)
|
||||
|
||||
# ---------- program ----------
|
||||
def load(self, i):
|
||||
n = len(self.programs); self.idx = i % n
|
||||
self.name, prog = self.programs[self.idx]
|
||||
self.bpm, self.lanes = parse_program(prog)
|
||||
self.master = self.lanes[0]
|
||||
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid()
|
||||
def _lane_dur(self, L):
|
||||
beat = 60_000_000_000 / self.bpm
|
||||
if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar
|
||||
m = self.lanes[0]; master_bar = beat * (m['steps'] // m['sub'])
|
||||
return int(master_bar / L['steps'])
|
||||
return int(beat / L['sub']) # straight: a step = one beat / subdivision
|
||||
def _reset_clock(self):
|
||||
now = time.monotonic_ns()
|
||||
for L in self.lanes:
|
||||
L['next'] = now; L['step'] = -1; L['dur'] = self._lane_dur(L)
|
||||
|
||||
# ---------- audio + light ----------
|
||||
def click(self, level):
|
||||
self.buz.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
|
||||
self.buz.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000)
|
||||
self.buz_off = time.monotonic_ns() + 22_000_000
|
||||
def flash(self, level):
|
||||
self.rgb = LEVEL_RGB.get(level, (0, 150, 255))
|
||||
self.led.set(*self.rgb)
|
||||
def led_off(self):
|
||||
self.rgb = (0, 0, 0)
|
||||
self.led.set(0, 0, 0)
|
||||
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
|
||||
if self.midi is None: return
|
||||
try: self.midi.write(bytes([0x90, note, vel])) # Note On (percussive — no Note Off needed)
|
||||
except Exception: pass
|
||||
|
||||
# ---------- transport ----------
|
||||
def toggle(self):
|
||||
self.running = not self.running
|
||||
if self.running: self._reset_clock()
|
||||
else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads()
|
||||
self.draw_status(); self._label("play")
|
||||
def set_bpm(self, v):
|
||||
v = max(30, min(300, v))
|
||||
if v != self.bpm:
|
||||
self.bpm = v
|
||||
for L in self.lanes: L['dur'] = self._lane_dur(L)
|
||||
self.draw_bpm()
|
||||
def goto(self, i):
|
||||
was = self.running; self.load(i); self._label("play")
|
||||
if was: self.running = True; self._reset_clock()
|
||||
def tap(self):
|
||||
now = time.monotonic()
|
||||
if not hasattr(self, '_taps'): self._taps = []
|
||||
self._taps = [t for t in self._taps if now - t < 2.4]
|
||||
self._taps.append(now)
|
||||
if len(self._taps) >= 2:
|
||||
span = (self._taps[-1] - self._taps[0]) / (len(self._taps) - 1)
|
||||
if span > 0: self.set_bpm(round(60 / span))
|
||||
|
||||
# ---------- scheduler ----------
|
||||
def tick(self):
|
||||
now = time.monotonic_ns()
|
||||
if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0
|
||||
if self.running:
|
||||
fired = []
|
||||
for li, L in enumerate(self.lanes):
|
||||
adv = False
|
||||
while now >= L['next']:
|
||||
L['step'] = (L['step'] + 1) % L['steps']
|
||||
lvl = 0 if L['mute'] else L['levels'][L['step']]
|
||||
if lvl > 0:
|
||||
fired.append(lvl)
|
||||
self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane
|
||||
L['next'] += L['dur']; adv = True
|
||||
if adv and li < len(self.lane_pads): self._move_playhead(li, L['step'])
|
||||
if fired:
|
||||
best = max(fired, key=lambda l: PRIO.get(l, 0))
|
||||
if not MUTE_BUZZER: self.click(best)
|
||||
self.flash(best)
|
||||
if self.rgb != (0, 0, 0):
|
||||
r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10
|
||||
self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0)
|
||||
self.led.set(*self.rgb)
|
||||
|
||||
# ---------- inputs ----------
|
||||
def poll(self):
|
||||
a = self.btnA.value
|
||||
if (not a) and self._aPrev: self.toggle()
|
||||
self._aPrev = a
|
||||
b = self.btnB.value
|
||||
if (not b) and self._bPrev: self.tap()
|
||||
self._bPrev = b
|
||||
now = time.monotonic_ns()
|
||||
if now >= self._joyNext:
|
||||
x = self.jx.value - 32768; y = self.jy.value - 32768
|
||||
if JOY_INVERT_X: x = -x
|
||||
if JOY_INVERT_Y: y = -y
|
||||
if abs(y) > JOY_DEADZONE:
|
||||
self.set_bpm(self.bpm + (1 if y > 0 else -1) * (5 if abs(y) > 26000 else 1))
|
||||
self._joyNext = now + 70_000_000
|
||||
elif abs(x) > JOY_DEADZONE:
|
||||
self.goto(self.idx + (1 if x > 0 else -1)); self._joyNext = now + 350_000_000; return
|
||||
else:
|
||||
self._joyNext = now + 20_000_000
|
||||
pt = self.touch.read()
|
||||
nowms = time.monotonic()
|
||||
if pt:
|
||||
self._touchSeen = nowms
|
||||
if not self._touchDown:
|
||||
self._touchDown = True; self.hit(pt[0], pt[1])
|
||||
elif self._touchDown and (nowms - self._touchSeen) > 0.14:
|
||||
self._touchDown = False
|
||||
|
||||
def hit(self, x, y):
|
||||
for bx, by, bw, bh, key in self.buttons:
|
||||
if bx <= x <= bx+bw and by <= y <= by+bh:
|
||||
if key == 'play': self.toggle()
|
||||
elif key == 'prev': self.goto(self.idx - 1)
|
||||
elif key == 'next': self.goto(self.idx + 1)
|
||||
elif key == 'minus': self.set_bpm(self.bpm - 1)
|
||||
elif key == 'plus': self.set_bpm(self.bpm + 1)
|
||||
elif key == 'tap': self.tap()
|
||||
return
|
||||
|
||||
# ---------- drawing ----------
|
||||
def draw_bpm(self):
|
||||
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
|
||||
def draw_status(self):
|
||||
self._place(self.g_run, "RUN" if self.running else "STOP", 12, 48,
|
||||
C_GREEN if self.running else C_MUTE, C_BG, FONT_M)
|
||||
self._place(self.g_name, "%d/%d %s" % (self.idx+1, len(self.programs), self.name[:18]),
|
||||
12, 112, C_TXT, C_BG, FONT_M)
|
||||
|
||||
# ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ----------
|
||||
def _padbase(self, L, s):
|
||||
return 0 if L['mute'] else L['levels'][s]
|
||||
def build_grid(self):
|
||||
while len(self.g_grid): self.g_grid.pop()
|
||||
self.lane_pads = []; self.lane_lit = []
|
||||
n = min(len(self.lanes), MAXLANES)
|
||||
top = 140; rowh = min(40, (296 - top) // max(1, n))
|
||||
for li in range(n):
|
||||
L = self.lanes[li]; y = top + li * rowh; cy = y + rowh // 2
|
||||
tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG)
|
||||
tg.x = 8; tg.y = cy - h // 2; self.g_grid.append(tg)
|
||||
steps = L['steps']; sub = L['sub']; px0 = 60
|
||||
usable = WIDTH - 8 - px0 - 12; stepw = max(1, usable // steps)
|
||||
r_big = max(2, min(6, stepw // 2, (rowh - 8) // 2)); r_sml = max(2, r_big - 2)
|
||||
pads = []
|
||||
for s in range(steps):
|
||||
rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision
|
||||
cxp = px0 + 6 + (s * usable) // steps # proportional → beats line up across lanes
|
||||
c = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=cxp, y=cy)
|
||||
c.color_index = self._padbase(L, s); self.g_grid.append(c); pads.append(c)
|
||||
self.lane_pads.append(pads); self.lane_lit.append(-1)
|
||||
self.dirty = True
|
||||
def _move_playhead(self, li, step):
|
||||
pads = self.lane_pads[li]; prev = self.lane_lit[li]
|
||||
if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev)
|
||||
if step < len(pads): pads[step].color_index = self._padbase(self.lanes[li], step) + 4
|
||||
self.lane_lit[li] = step; self.dirty = True
|
||||
def reset_playheads(self):
|
||||
for li, pads in enumerate(self.lane_pads):
|
||||
prev = self.lane_lit[li]
|
||||
if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev)
|
||||
self.lane_lit[li] = -1
|
||||
self.dirty = True
|
||||
|
||||
def run(self):
|
||||
if self.touch.addr is None:
|
||||
print("GT911 touch not found")
|
||||
while True:
|
||||
self.tick(); self.poll()
|
||||
# push a complete frame only when something changed (no mid-update tearing);
|
||||
# capped at the display's refresh rate, so dirty regions stay small and quick
|
||||
if self.dirty and self.display.refresh():
|
||||
self.dirty = False
|
||||
time.sleep(0.0005)
|
||||
|
||||
App().run()
|
||||
BIN
pico-cp/font_l.bin
Normal file
BIN
pico-cp/font_l.bin
Normal file
Binary file not shown.
BIN
pico-cp/font_m.bin
Normal file
BIN
pico-cp/font_m.bin
Normal file
Binary file not shown.
BIN
pico-cp/font_s.bin
Normal file
BIN
pico-cp/font_s.bin
Normal file
Binary file not shown.
97
pico-cp/programs.json
Normal file
97
pico-cp/programs.json
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"title": "PolyMeter",
|
||||
"programs": [
|
||||
{
|
||||
"name": "Four on the floor",
|
||||
"prog": "t120;kick:4;snare:4=.x.x;hatClosed:4/2"
|
||||
},
|
||||
{
|
||||
"name": "Swing ride",
|
||||
"prog": "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"
|
||||
},
|
||||
{
|
||||
"name": "Purdie half shuffle",
|
||||
"prog": "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"
|
||||
},
|
||||
{
|
||||
"name": "Samba (2/4)",
|
||||
"prog": "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."
|
||||
},
|
||||
{
|
||||
"name": "Nanigo (6/8 bembe)",
|
||||
"prog": "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"
|
||||
},
|
||||
{
|
||||
"name": "6/8 groove",
|
||||
"prog": "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"
|
||||
},
|
||||
{
|
||||
"name": "7/8 (2+2+3)",
|
||||
"prog": "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"
|
||||
},
|
||||
{
|
||||
"name": "5/4 (3+2)",
|
||||
"prog": "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"
|
||||
},
|
||||
{
|
||||
"name": "5 over 4 polyrhythm",
|
||||
"prog": "t100;kick:4;claves:5~"
|
||||
},
|
||||
{
|
||||
"name": "3 over 2 hemiola",
|
||||
"prog": "t96;woodblock:2;cowbell:3~"
|
||||
},
|
||||
{
|
||||
"name": "2 & 4 & 3 per bar",
|
||||
"prog": "t100;kick:3;cowbell:2~;claves:4~"
|
||||
},
|
||||
{
|
||||
"name": "Triplet hats",
|
||||
"prog": "t100;kick:4;snare:4=.x.x;hatClosed:4/3"
|
||||
},
|
||||
{
|
||||
"name": "Accents",
|
||||
"prog": "t92;kick:4=X..X;snare:4=.X.X;hatClosed:4/2"
|
||||
},
|
||||
{
|
||||
"name": "Tempo builder 80+",
|
||||
"prog": "t80;woodblock:4;rmp80/4/4"
|
||||
},
|
||||
{
|
||||
"name": "Gap trainer 2/2",
|
||||
"prog": "t100;kick:4;hatClosed:4/2;tr2/2"
|
||||
},
|
||||
{
|
||||
"name": "Intro - hats+kick",
|
||||
"prog": "t88;kick:4=X.x.;hatClosed:4/2=gggggggg"
|
||||
},
|
||||
{
|
||||
"name": "Groove in",
|
||||
"prog": "t88;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"
|
||||
},
|
||||
{
|
||||
"name": "Half-time shuffle",
|
||||
"prog": "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"
|
||||
},
|
||||
{
|
||||
"name": "Build 92 to 120",
|
||||
"prog": "t92;kick:4;snare:4=.X.X;hatClosed:4/2"
|
||||
},
|
||||
{
|
||||
"name": "Four-floor (909)",
|
||||
"prog": "t124;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"
|
||||
},
|
||||
{
|
||||
"name": "Samba break",
|
||||
"prog": "t116;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."
|
||||
},
|
||||
{
|
||||
"name": "Peak - 16ths",
|
||||
"prog": "t132;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"
|
||||
},
|
||||
{
|
||||
"name": "Outro",
|
||||
"prog": "t132;kick:4=X..x;hatClosed:4/2=gggggggg"
|
||||
}
|
||||
]
|
||||
}
|
||||
94
pico/README.md
Normal file
94
pico/README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# PM_K‑1 "Kit" — VARASYS PolyMeter firmware for the Raspberry Pi Pico
|
||||
|
||||
MicroPython firmware that turns a **Raspberry Pi Pico** on the **52Pi EP‑0172 "Pico
|
||||
Breadboard Kit Plus"** into a touchscreen polymeter metronome. It runs the *same program
|
||||
strings* as <https://metronome.varasys.io> — design a groove in the web editor, copy its
|
||||
program string, paste it into `PROGRAMS` in `main.py`, and it plays here.
|
||||
|
||||
Everything is in one file: `main.py` (ST7796 display driver, GT911 touch, WS2812 RGB,
|
||||
buzzer, joystick, the polymeter engine — no external libraries).
|
||||
|
||||
## The board (EP‑0172) — fixed pinout
|
||||
|
||||
| Component | Pico pins |
|
||||
|---|---|
|
||||
| 3.5″ ST7796 320×480 display | SPI0 — SCK `GP2`, MOSI `GP3`, CS `GP5`, DC `GP6`, RST `GP7` |
|
||||
| GT911 capacitive touch | I2C0 — SDA `GP8`, SCL `GP9` (addr 0x5D) |
|
||||
| WS2812 RGB LED | `GP12` |
|
||||
| Buzzer | `GP13` |
|
||||
| Button A / Button B | `GP15` / `GP14` |
|
||||
| PSP joystick | X = `ADC0`/`GP26`, Y = `ADC1`/`GP27` |
|
||||
|
||||
The components are wired on the board — you don't breadboard anything; just seat the Pico.
|
||||
|
||||
## Flash it — TWO separate steps
|
||||
|
||||
> ⚠️ **`main.py` is NOT a drag-and-drop file.** The `RPI-RP2` drive that appears in BOOTSEL mode
|
||||
> is the bootloader, and it *only* accepts a `.uf2` firmware file — anything else (like `main.py`)
|
||||
> is silently discarded on the next reboot. You first flash MicroPython with a `.uf2` (drag-and-drop),
|
||||
> and *then* copy `main.py` over the USB serial link with Thonny or mpremote. Two different steps.
|
||||
|
||||
### Step 1 — install MicroPython (drag-and-drop a `.uf2`, one time)
|
||||
|
||||
1. Download the MicroPython firmware **`.uf2`** for your board:
|
||||
- Pico / Pico W → <https://micropython.org/download/RPI_PICO/> (or `RPI_PICO_W`)
|
||||
- Pico 2 / Pico 2 W → <https://micropython.org/download/RPI_PICO2/>
|
||||
2. Hold **BOOTSEL**, plug into USB → the `RPI-RP2` drive appears.
|
||||
3. **Drag the `.uf2` file onto that drive.** It copies, the Pico reboots on its own, and the drive
|
||||
**disappears** — that's correct and means MicroPython is installed. (Don't use BOOTSEL again unless
|
||||
you're reinstalling the firmware.)
|
||||
|
||||
### Step 2 — copy `main.py` (over USB serial, NOT to a drive)
|
||||
|
||||
After step 1 the Pico runs MicroPython and **no longer shows up as a USB drive** — so you can't drag
|
||||
files to it. Use a tool that talks to it over USB serial:
|
||||
|
||||
- **Thonny (easiest):** install [Thonny](https://thonny.org), plug the Pico in normally, then
|
||||
bottom-right click the interpreter selector → **MicroPython (Raspberry Pi Pico)** (you should see
|
||||
a `>>>` prompt in the Shell). Open `main.py`, then **File ▸ Save as… ▸ Raspberry Pi Pico** and save
|
||||
it as exactly **`main.py`**.
|
||||
- **mpremote (command line):** `pip install mpremote` then `mpremote cp main.py :main.py`
|
||||
|
||||
Reset (replug) and it boots straight into the metronome.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Touch:** on‑screen `<<` / `>||` / `>>` (prev · play/stop · next) and `−` / `TAP` / `+`.
|
||||
- **Joystick:** up/down = tempo (push far for ±5), left/right = previous/next groove.
|
||||
- **Button A (GP15):** play / stop. **Button B (GP14):** tap tempo.
|
||||
- **RGB LED** flashes each beat (amber = accent, cyan = normal, violet = ghost); the
|
||||
**buzzer** clicks with matching pitch.
|
||||
|
||||
## Add your own grooves
|
||||
|
||||
Edit the `PROGRAMS` list near the top of `main.py` — each entry is `("Name", "program string")`.
|
||||
Get program strings from the web editor's program box (e.g. `v1;t120;kick:4;snare:4=.X.X;hat:4/2`).
|
||||
Supported: tempo `t<bpm>`, lanes `sound:grouping[/sub][=pattern][~][!]`, pattern chars
|
||||
`X` accent · `x` normal · `g` ghost · `.` `-` `_` rest, grouped meters like `3+3+2`, polymeter `~`.
|
||||
(Per‑lane dB gain `@n` is parsed but ignored — the buzzer is mono.)
|
||||
|
||||
## If something looks off — calibration
|
||||
|
||||
All the knobs are flags in the `CONFIG` block at the top of `main.py`:
|
||||
|
||||
- **Colours look negative / washed out:** toggle `INVERT_COLORS`.
|
||||
- **Red and blue swapped:** set `SWAP_RB = True`.
|
||||
- **Taps land in the wrong place:** set `TOUCH_DEBUG = True`, reset, watch the raw
|
||||
coordinates over the USB serial (Thonny shell) as you tap the corners, then set
|
||||
`TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y` to match.
|
||||
- **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`; widen `JOY_DEADZONE` if it drifts.
|
||||
- **Screen stays black:** the backlight is hardwired on, so this usually means the SPI init
|
||||
didn't take — drop `SPI_BAUD` to `24_000_000` and retry.
|
||||
- **Garbled / wrong size image:** your panel lot may be a 240×320 ILI9341 instead of the
|
||||
320×480 ST7796. This firmware targets the ST7796 you have (you said 320×480); if a unit
|
||||
ever ships ILI9341, set `WIDTH,HEIGHT = 240,320` and use an ILI9341 init sequence.
|
||||
|
||||
## Notes
|
||||
|
||||
- Audio is a single passive buzzer, so coincident lane hits play one click at the highest
|
||||
priority (accent > normal > ghost); the RGB + screen still show the combined activity.
|
||||
- The scheduler is non‑blocking and timed off `time.ticks_us()`, so tempo stays steady while
|
||||
the screen and inputs update.
|
||||
|
||||
Hardware reference: [52Pi EP‑0172 wiki](https://wiki.52pi.com/index.php?title=EP-0172) ·
|
||||
[vendor code](https://github.com/geeekpi/pico_breakboard_kit). VARASYS — Simplifying Complexity.
|
||||
BIN
pico/__pycache__/main.cpython-312.pyc
Normal file
BIN
pico/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
1
pico/_font_l.b64
Normal file
1
pico/_font_l.b64
Normal file
File diff suppressed because one or more lines are too long
1
pico/_font_m.b64
Normal file
1
pico/_font_m.b64
Normal file
File diff suppressed because one or more lines are too long
119
pico/gen_font.py
Normal file
119
pico/gen_font.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env python3
|
||||
# Generate the anti-aliased bitmap fonts baked into main.py.
|
||||
#
|
||||
# Each font is one base64 blob: a small metrics table + 4-bit-alpha glyph pixels.
|
||||
# main.py decodes it at boot (binascii.a2b_base64) and renders text by blending the
|
||||
# foreground over the background per pixel via a 16-entry LUT (smooth, no upscaling).
|
||||
#
|
||||
# Re-run after changing sizes/charset: python3 pico/gen_font.py
|
||||
# It writes pico/_font_m.b64 + pico/_font_l.b64 and /tmp/font_verify.png (eyeball it). Then
|
||||
# inject the two base64 strings into main.py's FONT_M_B64 / FONT_L_B64 (replace the existing
|
||||
# values, or re-add the @@FONT_M@@ / @@FONT_L@@ placeholders first and substitute them in).
|
||||
|
||||
import base64, pathlib
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
|
||||
ASCII = "".join(chr(c) for c in range(0x20, 0x7F))
|
||||
SYMBOLS = "◀▶■" # ◀ ▶ ■ (prev / play / stop, anti-aliased)
|
||||
M_CHARS = ASCII + SYMBOLS
|
||||
L_CHARS = "0123456789 "
|
||||
S_CHARS = ASCII # small font for the pad-grid lane labels (CircuitPython)
|
||||
|
||||
def build(size, chars):
|
||||
font = ImageFont.truetype(FONT, size)
|
||||
recs = [] # (cp, w, h, xoff, top, adv)
|
||||
pixels = bytearray()
|
||||
for ch in chars:
|
||||
cp = ord(ch)
|
||||
adv = round(font.getlength(ch))
|
||||
bbox = font.getbbox(ch)
|
||||
if not bbox or bbox[2] - bbox[0] <= 0 or bbox[3] - bbox[1] <= 0:
|
||||
recs.append((cp, 0, 0, 0, 0, adv)); continue
|
||||
l, t, r, b = bbox
|
||||
w, h = r - l, b - t
|
||||
img = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(img).text((-l, -t), ch, fill=255, font=font)
|
||||
px = img.load()
|
||||
# pack 4-bit alpha, row-major, two pixels per byte (first = high nibble)
|
||||
nib = []
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
nib.append(px[x, y] >> 4)
|
||||
if len(nib) % 2: nib.append(0)
|
||||
for i in range(0, len(nib), 2):
|
||||
pixels.append((nib[i] << 4) | nib[i + 1])
|
||||
recs.append((cp, w, h, l & 0xFF, t & 0xFF, adv & 0xFF))
|
||||
header = bytearray([len(recs)])
|
||||
for cp, w, h, xoff, top, adv in recs:
|
||||
header += bytes([cp >> 8, cp & 0xFF, w, h, xoff, top, adv])
|
||||
return bytes(header) + bytes(pixels)
|
||||
|
||||
# ---- reference decoder + renderer (must match main.py exactly) ----
|
||||
def load_font(blob):
|
||||
count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {}
|
||||
for _ in range(count):
|
||||
cp = (blob[p] << 8) | blob[p + 1]; w = blob[p + 2]; h = blob[p + 3]
|
||||
xoff = blob[p + 4]; xoff = xoff - 256 if xoff > 127 else xoff
|
||||
top = blob[p + 5]; adv = blob[p + 6]; p += 7
|
||||
glyphs[cp] = (w, h, xoff, top, adv, pixoff)
|
||||
pixoff += (w * h + 1) // 2
|
||||
return glyphs, blob
|
||||
|
||||
def lut(fg, bg):
|
||||
def unp(c): return ((c >> 11) & 0x1F, (c >> 5) & 0x3F, c & 0x1F)
|
||||
fr, fgc, fb = unp(fg); br, bgc, bb = unp(bg); out = []
|
||||
for a in range(16):
|
||||
t = a * 17
|
||||
r = (br * (255 - t) + fr * t) // 255
|
||||
g = (bgc * (255 - t) + fgc * t) // 255
|
||||
b = (bb * (255 - t) + fb * t) // 255
|
||||
out.append((r << 11) | (g << 5) | b)
|
||||
return out
|
||||
|
||||
def render(draw_img, font, s, x, y, fg, bg):
|
||||
glyphs, blob = font; L = lut(fg, bg); pen = x
|
||||
for ch in s:
|
||||
g = glyphs.get(ord(ch))
|
||||
if not g: continue
|
||||
w, h, xoff, top, adv, off = g
|
||||
for j in range(h):
|
||||
for i in range(w):
|
||||
k = j * w + i; byte = blob[off + (k >> 1)]
|
||||
nibv = (byte >> 4) if (k & 1) == 0 else (byte & 0xF)
|
||||
col = L[nibv]
|
||||
r = (col >> 11) & 0x1F; gg = (col >> 5) & 0x3F; b = col & 0x1F
|
||||
draw_img.putpixel((pen + xoff + i, y + top + j), (r << 3, gg << 2, b << 3))
|
||||
pen += adv
|
||||
return pen
|
||||
|
||||
blob_s = build(12, S_CHARS)
|
||||
blob_m = build(22, M_CHARS)
|
||||
blob_l = build(78, L_CHARS)
|
||||
# base64 (baked into the MicroPython firmware, pico/main.py — FONT_M / FONT_L)
|
||||
(HERE / "_font_m.b64").write_text(base64.b64encode(blob_m).decode())
|
||||
(HERE / "_font_l.b64").write_text(base64.b64encode(blob_l).decode())
|
||||
print("FONT_M %d bytes -> %d b64" % (len(blob_m), len(base64.b64encode(blob_m))))
|
||||
print("FONT_L %d bytes -> %d b64" % (len(blob_l), len(base64.b64encode(blob_l))))
|
||||
# binary blobs (read at boot by the CircuitPython firmware, pico-cp/code.py)
|
||||
CP = HERE.parent / "pico-cp"
|
||||
(CP / "font_s.bin").write_bytes(blob_s)
|
||||
(CP / "font_m.bin").write_bytes(blob_m)
|
||||
(CP / "font_l.bin").write_bytes(blob_l)
|
||||
print("wrote pico-cp/font_{s,m,l}.bin (%d / %d / %d bytes)" % (len(blob_s), len(blob_m), len(blob_l)))
|
||||
|
||||
# verification image on a dark bg (565 colours like the firmware)
|
||||
def c565(r, g, b): return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
|
||||
BG = c565(6, 9, 14); CYAN = c565(10, 179, 247); TXT = c565(199, 208, 219); GREEN = c565(47, 224, 122)
|
||||
img = Image.new("RGB", (320, 220), (6, 9, 14))
|
||||
fm = load_font(blob_m); fl = load_font(blob_l)
|
||||
render(img, fm, "PM_K-1 KIT", 12, 10, CYAN, BG)
|
||||
render(img, fl, "120", 150, 40, TXT, BG)
|
||||
render(img, fm, "BPM", 12, 70, TXT, BG)
|
||||
render(img, fm, "▶ RUN", 12, 120, GREEN, BG)
|
||||
render(img, fm, "Four-on-the-floor", 12, 150, TXT, BG)
|
||||
render(img, fm, "◀◀ ▶ ▶▶ - TAP +", 12, 185, TXT, BG)
|
||||
img.resize((640, 440), Image.NEAREST).save("/tmp/font_verify.png")
|
||||
print("wrote /tmp/font_verify.png")
|
||||
511
pico/main.py
Normal file
511
pico/main.py
Normal file
File diff suppressed because one or more lines are too long
499
player.html
Normal file
499
player.html
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PM_C‑1 — Concept (idealized render)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
/* ?embed=1 → strip site chrome (base.css [data-embed]) + auto-size to the host */
|
||||
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
|
||||
document.documentElement.dataset.embed="1";
|
||||
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
|
||||
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
|
||||
})();
|
||||
</script>
|
||||
<!--
|
||||
Hardware-device MOCKUP / simulator for the Pi Pico (RP2040) build of the
|
||||
Stackable Metronome. The physical unit can't show the multi-lane editor or
|
||||
manage set lists — it just *plays* a configuration expressed in the share
|
||||
language. So this page is the device front panel: load a patch or set-list
|
||||
(paste one, open a #p=/#sl= link, or pick a saved one) and it plays it,
|
||||
driving the OLED + beat LEDs exactly as the firmware would.
|
||||
|
||||
Audio here is the synthesized voice set (the firmware uses the same scheduler;
|
||||
on hardware the voices map to CC0 samples on the I2S DAC). One file, no deps.
|
||||
-->
|
||||
<script>
|
||||
// Set theme before first paint (avoids a flash). Shares the editor's "metronome.theme".
|
||||
(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{
|
||||
/* environment (themed): room background + page/panel chrome */
|
||||
--bg1:#12151c; --bg2:#05070a;
|
||||
--txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#171b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
|
||||
/* the device case — kept a clear step lighter than the room so it reads as an object */
|
||||
--case:#232a36; --case2:#151a22; --device-bd:#39434f;
|
||||
--device-shadow:0 0 0 1px rgba(120,150,190,.06), 0 30px 70px rgba(0,0,0,.62), 0 0 46px rgba(10,179,247,.05);
|
||||
/* device internals — fixed dark-hardware colours in BOTH themes */
|
||||
--dtxt:#c7d0db; --dmuted:#7f8b9a;
|
||||
--cyan:#0AB3F7; --amber:#ffd166; --edge:#0b0d11; --bezel:#0a0c10;
|
||||
--screen:#04140f; --phos:#34e0a0; --phos-dim:#1f6e54; --led-off:#1b2a23;
|
||||
}
|
||||
:root[data-theme="light"]{
|
||||
/* light "desk": the dark device sits on a bright surface → strong contrast */
|
||||
--bg1:#f5f8fc; --bg2:#dde4ec;
|
||||
--txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
|
||||
--case:#2b3340; --case2:#1b212b; --device-bd:#0f141b;
|
||||
--device-shadow:0 0 0 1px rgba(0,0,0,.05), 0 26px 50px rgba(20,30,50,.30);
|
||||
}
|
||||
body{
|
||||
margin:0; min-height:100vh; padding:28px 16px 48px;
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2));
|
||||
color:var(--txt);
|
||||
display:flex; flex-direction:column; align-items:center; gap:20px;
|
||||
}
|
||||
a{color:var(--link)}
|
||||
.topbar{width:100%; max-width:560px; display:flex; align-items:center; justify-content:space-between; font-size:13px; color:var(--muted)}
|
||||
.topbar b{color:var(--txt)}
|
||||
.topbar-right{ display:flex; align-items:center; gap:12px }
|
||||
.tbtn{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px;
|
||||
padding:3px 9px; font-size:14px; line-height:1; cursor:pointer }
|
||||
.tbtn:hover{ color:var(--txt) }
|
||||
|
||||
/* ---- the device ---- */
|
||||
.device{
|
||||
width:100%; max-width:560px; position:relative;
|
||||
background:linear-gradient(180deg,var(--case),var(--case2));
|
||||
border:1px solid var(--device-bd); border-radius:22px; padding:22px 22px 26px;
|
||||
box-shadow:var(--device-shadow), inset 0 1px 0 rgba(255,255,255,.06), inset 0 -2px 8px rgba(0,0,0,.55);
|
||||
}
|
||||
.device::before, .device::after,
|
||||
.device .screw{ content:""; position:absolute; width:9px; height:9px; border-radius:50%;
|
||||
background:radial-gradient(circle at 35% 30%, #5a626e, #20252d 70%); box-shadow:inset 0 0 2px #000; }
|
||||
.device::before{ top:11px; left:11px } .device::after{ top:11px; right:11px }
|
||||
.screw.bl{ bottom:11px; left:11px } .screw.br{ bottom:11px; right:11px }
|
||||
|
||||
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:2px 6px 16px; }
|
||||
.logo{ display:flex; align-items:center; gap:11px }
|
||||
.logo .dev-logo{ height:26px }
|
||||
.logo .model{ color:var(--dmuted); font-size:12px; letter-spacing:.04em }
|
||||
.pwr{ display:flex; align-items:center; gap:7px; font-size:10px; color:var(--dmuted); text-transform:uppercase; letter-spacing:.12em }
|
||||
.pwr .dot{ width:8px; height:8px; border-radius:50%; background:#2fe07a; box-shadow:0 0 8px #2fe07a }
|
||||
|
||||
/* ---- OLED ---- */
|
||||
.screen{
|
||||
background:linear-gradient(180deg,#06181244,var(--screen)); border:2px solid var(--bezel);
|
||||
border-radius:10px; padding:14px 16px; margin:0 4px;
|
||||
box-shadow:inset 0 0 24px rgba(0,0,0,.8), inset 0 0 6px rgba(52,224,160,.12), 0 1px 0 rgba(255,255,255,.04);
|
||||
font-family:"Courier New",ui-monospace,monospace; color:var(--phos);
|
||||
text-shadow:0 0 6px rgba(52,224,160,.55); position:relative; overflow:hidden;
|
||||
}
|
||||
.screen::after{ content:""; position:absolute; inset:0; pointer-events:none;
|
||||
background:repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 1px, transparent 1px 3px); opacity:.5 }
|
||||
.scr-top{ display:flex; justify-content:space-between; align-items:baseline; font-size:13px; color:var(--phos-dim) }
|
||||
.scr-top .pos{ letter-spacing:.06em }
|
||||
.scr-top .tempo{ color:var(--phos); font-size:16px }
|
||||
.scr-top .tempo b{ font-size:22px; font-weight:700 }
|
||||
.scr-name{ font-size:20px; margin:7px 0 8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
|
||||
.scr-bot{ display:flex; justify-content:space-between; align-items:baseline; font-size:13px; color:var(--phos-dim) }
|
||||
.scr-bot .st{ color:var(--phos) }
|
||||
.scr-bot .st.stopped{ color:var(--phos-dim) }
|
||||
.scr-bot .bars{ color:var(--amber); text-shadow:0 0 6px rgba(255,209,102,.5) }
|
||||
|
||||
/* ---- beat LEDs ---- */
|
||||
.leds{ display:flex; gap:8px; justify-content:center; flex-wrap:wrap; margin:16px 4px 6px; min-height:18px }
|
||||
.led{ width:16px; height:16px; border-radius:50%; background:var(--led-off);
|
||||
border:1px solid #000; box-shadow:inset 0 1px 2px rgba(0,0,0,.7); transition:background .04s, box-shadow .04s }
|
||||
.led.group{ outline:1px solid #3a4754; outline-offset:2px }
|
||||
.led.on{ background:var(--cyan); box-shadow:0 0 10px var(--cyan), 0 0 4px #fff inset }
|
||||
.led.on.group{ background:var(--amber); box-shadow:0 0 12px var(--amber), 0 0 4px #fff inset }
|
||||
|
||||
/* ---- controls ---- */
|
||||
.controls{ display:flex; align-items:center; justify-content:center; gap:12px; margin:14px 4px 4px; flex-wrap:wrap }
|
||||
.btn{ background:linear-gradient(180deg,#2b323d,#1b212a); color:var(--dtxt); border:1px solid #39424f;
|
||||
border-radius:11px; padding:12px 14px; font-size:15px; cursor:pointer; min-width:48px;
|
||||
box-shadow:0 3px 0 #0c0f14, inset 0 1px 0 rgba(255,255,255,.06); user-select:none; transition:transform .04s, box-shadow .04s }
|
||||
.btn:active{ transform:translateY(2px); box-shadow:0 1px 0 #0c0f14, inset 0 1px 0 rgba(255,255,255,.06) }
|
||||
.btn small{ display:block; font-size:9px; color:var(--dmuted); letter-spacing:.08em; margin-top:2px }
|
||||
.btn.play{ min-width:74px; font-size:20px; background:linear-gradient(180deg,#1f7a4d,#155f3b); border-color:#2e7d32; color:#eafff3 }
|
||||
.btn.play.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b }
|
||||
.knob{ width:52px; height:52px; border-radius:50%; background:radial-gradient(circle at 38% 32%,#3a424e,#171b22 72%);
|
||||
border:1px solid #444c58; box-shadow:0 3px 8px rgba(0,0,0,.5), inset 0 1px 1px rgba(255,255,255,.08); position:relative; margin-left:4px }
|
||||
.knob::after{ content:""; position:absolute; left:50%; top:6px; width:2px; height:13px; background:var(--cyan);
|
||||
border-radius:2px; transform-origin:50% 20px; transform:rotate(var(--a,0deg)); box-shadow:0 0 5px var(--cyan) }
|
||||
.knob-wrap{ display:flex; flex-direction:column; align-items:center; gap:4px; font-size:9px; color:var(--dmuted); letter-spacing:.1em }
|
||||
|
||||
/* ---- speaker grille ---- */
|
||||
.grille{ height:14px; margin:18px 6px 2px; border-radius:6px;
|
||||
background:radial-gradient(circle, #000 1.1px, transparent 1.4px) 0 0/9px 9px; opacity:.5 }
|
||||
|
||||
/* ---- load panel ---- */
|
||||
.panel{ width:100%; max-width:560px; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:16px }
|
||||
.panel h2{ margin:0 0 4px; font-size:15px }
|
||||
.panel p.sub{ margin:0 0 12px; font-size:12px; color:var(--muted); line-height:1.45 }
|
||||
.panel label{ font-size:12px; color:var(--muted); display:block; margin:10px 0 5px }
|
||||
textarea{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:9px;
|
||||
padding:10px; font-family:"Courier New",monospace; font-size:12px; resize:vertical; min-height:58px }
|
||||
select, .ld{ border-radius:9px; padding:8px 10px; font-size:13px }
|
||||
select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd) }
|
||||
.ld{ cursor:pointer; color:var(--dtxt); background:linear-gradient(180deg,#2b323d,#1b212a); border:1px solid #39424f }
|
||||
.row{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px }
|
||||
.status{ margin-top:10px; font-size:12px; min-height:1.2em; font-family:"Courier New",monospace }
|
||||
.status.ok{ color:#5fd08a } .status.err{ color:#ff7a7a }
|
||||
.hint{ font-size:11px; color:var(--muted) }
|
||||
code{ background:var(--field-bg); border:1px solid var(--field-bd); border-radius:4px; padding:1px 5px; font-size:11px }
|
||||
|
||||
/* ---- full-screen "stage" mode: edge-to-edge, follows light/dark/system ---- */
|
||||
.fs-ctrl{ display:none; position:fixed; top:max(12px,env(safe-area-inset-top)); z-index:80;
|
||||
background:rgba(127,139,154,.16); color:var(--txt); border:1px solid rgba(127,139,154,.5);
|
||||
border-radius:50%; width:40px; height:40px; font-size:17px; line-height:1; cursor:pointer;
|
||||
backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px) }
|
||||
.fs-ctrl:hover{ background:rgba(127,139,154,.32) }
|
||||
#fsExit{ right:max(12px,env(safe-area-inset-right)) }
|
||||
#fsThemeBtn{ right:calc(max(12px,env(safe-area-inset-right)) + 50px) }
|
||||
.rotate-hint{ display:none }
|
||||
|
||||
/* the device frame goes transparent → the themed page background IS the full-screen
|
||||
skin (light in light mode, dark in dark mode); children flex to fill the screen */
|
||||
body.stage{ position:fixed; inset:0; padding:0; gap:0; overflow:hidden }
|
||||
body.stage .topbar, body.stage .panel, body.stage .grille{ display:none }
|
||||
body.stage .fs-ctrl{ display:block }
|
||||
body.stage .device{ position:absolute; inset:0; width:auto; max-width:none; margin:0;
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); border:none; border-radius:0; box-shadow:none;
|
||||
display:flex; flex-direction:column;
|
||||
padding:max(2.2vh,env(safe-area-inset-top)) max(4vw,env(safe-area-inset-right))
|
||||
max(2.4vh,env(safe-area-inset-bottom)) max(4vw,env(safe-area-inset-left)) }
|
||||
body.stage .device::before, body.stage .device::after, body.stage .screw{ display:none }
|
||||
body.stage .brandrow{ flex:0 0 auto; margin:0 0 2vh }
|
||||
body.stage .pwr{ display:none } /* declutter the corner the floating controls sit in */
|
||||
body.stage .logo .model, body.stage .knob-wrap{ color:var(--muted) }
|
||||
body.stage .screen{ flex:1 1 auto; display:flex; flex-direction:column; justify-content:space-between; padding:2.4vh 3vw }
|
||||
body.stage .scr-top{ font-size:2.8vh }
|
||||
body.stage .scr-top .tempo{ font-size:3.6vh }
|
||||
body.stage .scr-top .tempo b{ font-size:9vh }
|
||||
body.stage .scr-name{ font-size:7vh; margin:0 }
|
||||
body.stage .scr-bot{ font-size:2.8vh }
|
||||
body.stage .leds{ flex:0 0 auto; gap:1.8vmin; margin:2.4vh 0 0 }
|
||||
body.stage .led{ width:4.6vmin; height:4.6vmin }
|
||||
body.stage .controls{ flex:0 0 auto; gap:1.8vmin; margin-top:2.2vh }
|
||||
body.stage .controls .btn{ font-size:2.6vh; padding:1.6vh 2vw; min-width:8vw }
|
||||
body.stage .controls .btn.play{ min-width:12vw; font-size:3.6vh }
|
||||
body.stage .controls .btn small{ font-size:1.3vh }
|
||||
|
||||
/* portrait while staged (mainly iPhone, which can't lock) → prompt to rotate */
|
||||
@media (orientation: portrait){
|
||||
body.stage .device{ filter:blur(3px) brightness(.6); pointer-events:none }
|
||||
body.stage .rotate-hint{ display:flex; position:fixed; inset:0; z-index:90;
|
||||
flex-direction:column; align-items:center; justify-content:center; gap:18px;
|
||||
background:var(--bg1); color:var(--txt); font-size:20px; text-align:center; padding:24px }
|
||||
body.stage .rotate-hint .rh-icon{ font-size:64px; line-height:1; color:var(--cyan) }
|
||||
}
|
||||
/* fullscreen toggle (relocated out of the shared header) */
|
||||
.fs-float{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px;
|
||||
padding:4px 11px; font-size:15px; line-height:1; cursor:pointer; }
|
||||
.fs-float:hover{ color:var(--txt); }
|
||||
body.stage .fs-float{ display:none; }
|
||||
/* embed mode: just the device */
|
||||
[data-embed] .panel, [data-embed] .fs-float { display:none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<h1 class="ff-title">PM_C‑1 Concept</h1>
|
||||
<p class="ff-sum">The idealized concept render — a clean screen‑first player with a colour beat display, set‑list navigation, theming and a fullscreen landscape view.</p>
|
||||
|
||||
<!-- fullscreen "stage mode" toggle — floats over the page (was a header button before the shared header) -->
|
||||
<button id="fsBtn" class="fs-float" title="Full screen (landscape)">⛶</button>
|
||||
|
||||
<!-- ===================== THE DEVICE ===================== -->
|
||||
<div class="device">
|
||||
<span class="screw bl"></span><span class="screw br"></span>
|
||||
|
||||
<div class="brandrow">
|
||||
<div class="logo"><span class="dev-lock"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" /></span><span class="model">PM_C‑1 · Concept</span></div>
|
||||
<div class="pwr"><span class="dot"></span>PWR</div>
|
||||
</div>
|
||||
|
||||
<div class="screen">
|
||||
<div class="scr-top"><span class="pos" id="sPos">–/–</span><span class="tempo">♩=<b id="sBpm">120</b></span></div>
|
||||
<div class="scr-name" id="sName">—</div>
|
||||
<div class="scr-bot"><span class="st stopped" id="sState">⏸ STOP</span><span id="sBar">bar — · beat —</span><span class="bars" id="sBars"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="leds" id="leds"></div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" id="bPrev" title="previous item">⏮<small>PREV</small></button>
|
||||
<button class="btn" id="bDown" title="tempo −">−<small>TEMPO</small></button>
|
||||
<button class="btn play" id="bPlay" title="play / stop (Space)">▶<small> </small></button>
|
||||
<button class="btn" id="bUp" title="tempo +">+<small>TEMPO</small></button>
|
||||
<button class="btn" id="bNext" title="next item">⏭<small>NEXT</small></button>
|
||||
<button class="btn" id="bTap" title="tap tempo (T)">TAP<small> </small></button>
|
||||
<div class="knob-wrap"><div class="knob" id="knob"></div>TEMPO</div>
|
||||
</div>
|
||||
|
||||
<div class="grille"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== LOAD CONFIG ===================== -->
|
||||
<div class="panel">
|
||||
<h2>Load a configuration onto the device</h2>
|
||||
<p class="sub">On the real unit you'd transfer this over USB / WiFi. Here, paste a <b>patch</b>
|
||||
(e.g. <code>v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2</code>), a <b>set‑list code</b>, or a full
|
||||
<code>#p=…</code>/<code>#sl=…</code> share link — it's validated before loading.</p>
|
||||
|
||||
<label for="cfg">Patch / set‑list code / share link</label>
|
||||
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2/ …or a #sl=… link / base64 set-list code"></textarea>
|
||||
<div class="row">
|
||||
<button class="ld" id="bLoad">Load onto device</button>
|
||||
<span class="hint">or pick a built-in or saved set list:</span>
|
||||
<select id="storedSel"><option value="">— choose a set list —</option></select>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<!-- stage-mode overlays (only visible in full-screen "stage" mode) -->
|
||||
<button id="fsThemeBtn" class="fs-ctrl" title="Theme (system / light / dark)" aria-label="Toggle theme">☀</button>
|
||||
<button id="fsExit" class="fs-ctrl" title="Exit full screen (Esc)" aria-label="Exit full screen">✕</button>
|
||||
<div id="rotateHint" class="rotate-hint">
|
||||
<span class="rh-icon">⟳</span>
|
||||
<span>Rotate your device to landscape</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* ========================= ENGINE (mirrors index.html; synth voices only) ===== */
|
||||
const SAMPLES = {}; // synth-only device — no acoustic samples; the engine uses its synth voices
|
||||
/*@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;
|
||||
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){ pendingAdvance=true; } // bar-count → auto-advance (player always continues)
|
||||
}
|
||||
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(()=>gotoItem(idx+1,true),0); } // loops at end (gotoItem wraps)
|
||||
}
|
||||
|
||||
|
||||
/* ========================= PLAYER ============================================= */
|
||||
let setlist=null, idx=0;
|
||||
// Built-in set lists = the editor's seed lists (shared via src/setlists.js).
|
||||
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
|
||||
|
||||
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(),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};
|
||||
});
|
||||
}
|
||||
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;
|
||||
setBpm(s.bpm||120);
|
||||
meters=buildMeters(s.lanes);
|
||||
rebuildLeds();
|
||||
}
|
||||
function startAudio(){
|
||||
ensureAudio(); audioCtx.resume(); state.running=true;
|
||||
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;
|
||||
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll();
|
||||
}
|
||||
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); }
|
||||
function toggle(){ state.running?stopAudio():startAudio(); }
|
||||
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){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
|
||||
loadSetup(setlist.items[idx]);
|
||||
if(wasRunning) startAudio(); else renderAll();
|
||||
}
|
||||
function loadSetlistObj(sl){ setlist=sl; idx=0; const wasRunning=state.running; if(wasRunning){clearInterval(schedulerTimer);schedulerTimer=null;state.running=false;} loadSetup(sl.items[0]); if(wasRunning) startAudio(); else renderAll(); }
|
||||
|
||||
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(); }
|
||||
|
||||
/* ========================= RENDER ============================================ */
|
||||
function rebuildLeds(){
|
||||
const box=$("leds"); box.innerHTML="";
|
||||
const m=meters[0]; const beats=m?m.beatsPerBar:0;
|
||||
for(let i=0;i<beats;i++){ const d=document.createElement("div"); d.className="led"+(m.groupStarts.has(i)?" group":""); box.appendChild(d); }
|
||||
}
|
||||
function renderLeds(){
|
||||
const m=meters[0]; if(!m) return;
|
||||
const cur=state.running? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
|
||||
const els=$("leds").children;
|
||||
for(let i=0;i<els.length;i++) els[i].classList.toggle("on", i===cur);
|
||||
}
|
||||
function fmtPos(){ return setlist? (idx+1)+"/"+setlist.items.length : "–/–"; }
|
||||
function renderScreen(){
|
||||
$("sPos").textContent="♪ "+fmtPos();
|
||||
$("sBpm").textContent=state.bpm;
|
||||
$("sName").textContent=setlist? (setlist.items[idx].name||"—") : "—";
|
||||
const st=$("sState");
|
||||
st.textContent=state.running?"▶ PLAY":"⏸ STOP"; st.classList.toggle("stopped",!state.running);
|
||||
const m=meters[0];
|
||||
if(state.running&&m){ const beat=Math.floor(m.currentStep/m.stepsPerBeat); $("sBar").textContent="bar "+(m.currentBar+1)+" · beat "+(beat>=0?beat+1:"—"); }
|
||||
else $("sBar").textContent="bar — · beat —";
|
||||
if(segBars>0){ const rem=Math.max(0,segBars-(m?m.currentBar:0)); $("sBars").textContent=(state.running?rem:segBars)+" bars"; }
|
||||
else $("sBars").textContent="";
|
||||
// knob angle ~ tempo (30..300 → -135..135deg)
|
||||
const ang=-135+(Math.max(30,Math.min(300,state.bpm))-30)/270*270; $("knob").style.setProperty("--a",ang+"deg");
|
||||
}
|
||||
function renderAll(){ renderScreen(); renderLeds(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running); }
|
||||
function draw(){
|
||||
// latency-compensated clock so the visual playhead lands when the click is HEARD
|
||||
// (not when it's queued) — see the sync note; avoids the visual leading the audio.
|
||||
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++; } } }
|
||||
renderScreen(); renderLeds();
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
/* ========================= LOAD / VALIDATE =================================== */
|
||||
function setStatus(msg,ok){ const s=$("status"); s.textContent=msg; s.className="status "+(ok?"ok":"err"); }
|
||||
function loadConfig(text,quiet){
|
||||
text=(text||"").trim(); if(!text){ if(!quiet) setStatus("Paste a patch or set-list code first.",false); return false; }
|
||||
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 new Error("set list has no items");
|
||||
loadSetlistObj(sl);
|
||||
setStatus("✓ Loaded set list “"+sl.title+"” — "+sl.items.length+" item(s).",true); return true;
|
||||
}
|
||||
const setup=patchToSetup(payload);
|
||||
if(!setup.lanes.length) throw new Error("no valid lanes (need e.g. kick:4)");
|
||||
loadSetlistObj({title:"Pasted patch",items:[{name:"Patch",...setup}]});
|
||||
setStatus("✓ Loaded patch — "+setup.lanes.length+" lane(s), "+setup.bpm+" BPM"+(setup.bars?", "+setup.bars+" bars":"")+(setup.ramp.on?", ramp":"")+".",true); return true;
|
||||
}catch(e){ if(!quiet) setStatus("✗ Invalid configuration: "+e.message,false); return false; }
|
||||
}
|
||||
function loadStored(){
|
||||
let lists=[]; try{ lists=JSON.parse(localStorage.getItem("metronome.setlists")||"[]"); }catch(e){}
|
||||
const sel=$("storedSel"); sel.innerHTML='<option value="">— choose a set list —</option>';
|
||||
const og1=document.createElement("optgroup"); og1.label="Built-in";
|
||||
BUILTIN.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="b"+i; o.textContent=sl.title+" ("+sl.items.length+")"; og1.appendChild(o); });
|
||||
sel.appendChild(og1);
|
||||
if(lists.length){ const og2=document.createElement("optgroup"); og2.label="Your saved set lists";
|
||||
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="s"+i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; og2.appendChild(o); });
|
||||
sel.appendChild(og2); }
|
||||
sel._lists=lists; sel._builtin=BUILTIN;
|
||||
}
|
||||
|
||||
/* ========================= WIRING ============================================ */
|
||||
$("bPlay").onclick=toggle;
|
||||
$("bPrev").onclick=()=>gotoItem(idx-1,state.running);
|
||||
$("bNext").onclick=()=>gotoItem(idx+1,state.running);
|
||||
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
|
||||
$("bTap").onclick=tapTempo;
|
||||
$("bLoad").onclick=()=>loadConfig($("cfg").value);
|
||||
$("storedSel").onchange=(e)=>{ const v=e.target.value; if(!v) return;
|
||||
const sl = v[0]==="b" ? e.target._builtin[+v.slice(1)] : e.target._lists[+v.slice(1)]; if(!sl) return;
|
||||
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded set list “"+(sl.title||"set list")+"”.",true); };
|
||||
/* theme toggle — cycles system → light → dark; shares the editor's "metronome.theme" key */
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
|
||||
/* full-screen "stage" mode — real fullscreen + landscape lock where supported (Android/desktop),
|
||||
CSS pseudo-fullscreen + a rotate hint where not (iPhone). body.stage drives the layout. */
|
||||
const docEl = document.documentElement;
|
||||
const reqFS = docEl.requestFullscreen || docEl.webkitRequestFullscreen;
|
||||
const 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 isStage(){ return document.body.classList.contains("stage"); }
|
||||
function syncFsBtn(){ $("fsBtn").title = isStage() ? "Exit full screen" : "Full screen (landscape)"; }
|
||||
async function enterStage(){
|
||||
document.body.classList.add("stage");
|
||||
if(reqFS){
|
||||
try{ await reqFS.call(docEl); }catch(e){}
|
||||
try{ await screen.orientation.lock("landscape"); }catch(e){} // Android only; rejects on desktop/iOS — harmless
|
||||
}
|
||||
requestWake(); syncFsBtn();
|
||||
}
|
||||
function exitStage(){
|
||||
try{ if(screen.orientation && screen.orientation.unlock) screen.orientation.unlock(); }catch(e){}
|
||||
if(fsEl() && exitFS){ try{ exitFS.call(document); }catch(e){} }
|
||||
document.body.classList.remove("stage");
|
||||
releaseWake(); syncFsBtn();
|
||||
}
|
||||
function toggleStage(){ isStage() ? exitStage() : enterStage(); }
|
||||
$("fsBtn").onclick = toggleStage;
|
||||
$("fsExit").onclick = exitStage;
|
||||
function onFsChange(){ if(reqFS && !fsEl() && isStage()){ document.body.classList.remove("stage"); releaseWake(); syncFsBtn(); } }
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
document.addEventListener("webkitfullscreenchange", onFsChange);
|
||||
document.addEventListener("visibilitychange", ()=>{ if(document.visibilityState==="visible" && isStage()) requestWake(); });
|
||||
|
||||
addEventListener("keydown",(e)=>{
|
||||
const t=e.target, tag=t?t.tagName:""; if(tag==="TEXTAREA"||tag==="INPUT"||tag==="SELECT") return;
|
||||
const k=e.key;
|
||||
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); }
|
||||
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==="t"||k==="T") tapTempo();
|
||||
else if(k==="f"||k==="F"){ e.preventDefault(); toggleStage(); }
|
||||
});
|
||||
|
||||
/* ========================= INIT ============================================== */
|
||||
loadStored();
|
||||
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
|
||||
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
|
||||
renderAll();
|
||||
requestAnimationFrame(draw);
|
||||
/*@BUILD:include:src/progbox.js@*/
|
||||
</script>
|
||||
|
||||
<p class="ff-link pageonly"><a href="/info-player.html">About this concept →</a></p>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
</body>
|
||||
</html>
|
||||
30
release.sh
Executable file
30
release.sh
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env bash
|
||||
# Cut a formal release: set VERSION (optional arg) and tag the current commit
|
||||
# as v<VERSION>. A clean checkout on that tag makes deploy.sh stamp the formal
|
||||
# "X.Y.Z" instead of a dev build.
|
||||
#
|
||||
# ./release.sh # tag v<current VERSION>
|
||||
# ./release.sh 0.1.0 # bump VERSION to 0.1.0, then tag v0.1.0
|
||||
#
|
||||
# Pushing the tag is left to you: git push origin "v<VERSION>"
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
if [[ $# -ge 1 ]]; then
|
||||
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "error: version must be X.Y.Z (got '$1')" >&2; exit 1; }
|
||||
echo "$1" > VERSION
|
||||
git add VERSION
|
||||
git commit -m "Bump version to $1" >/dev/null
|
||||
echo "VERSION -> $1 (committed)"
|
||||
fi
|
||||
|
||||
VER="$(cat VERSION)"
|
||||
[[ -z "$(git status --porcelain)" ]] || { echo "error: working tree dirty — commit before releasing" >&2; exit 1; }
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/v$VER" >/dev/null; then
|
||||
echo "error: tag v$VER already exists" >&2; exit 1
|
||||
fi
|
||||
|
||||
git tag -a "v$VER" -m "Release v$VER"
|
||||
echo "tagged v$VER"
|
||||
echo "next: git push origin v$VER && ./deploy.sh (will now stamp the formal v$VER)"
|
||||
263
showcase.html
Normal file
263
showcase.html
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PM_D‑1 — Display (RGB pendulum metronome)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
|
||||
document.documentElement.dataset.embed="1";
|
||||
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
|
||||
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
|
||||
})();
|
||||
</script>
|
||||
<!--
|
||||
PM-S "Showcase" — a display-piece metronome shaped like a classic pyramid wind-up
|
||||
unit. The pendulum is the whole show: a single RGB LED bar where every lane's
|
||||
subdivisions / accents are combined ALONG its length (each lane is a moving point
|
||||
of light), it carries a printed TEMPO scale on the vertical axis, and a sliding
|
||||
WEIGHT sets the tempo (drag it — up = slower, like the real thing). The canvas is
|
||||
transparent outside the body. There's no on-screen power switch: the real unit
|
||||
starts when you lift it from its holder / set it swinging — here that's an external
|
||||
button. Latency-compensated visuals. Shares src/engine.js.
|
||||
-->
|
||||
<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:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bd:#2a313c; --panel-bg:#161b22; --field-bg:#0e1116; --field-bd:#2a313c; --cyan:#0AB3F7; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4; --panel-bd:#d2dae4; --panel-bg:#fff; --field-bg:#f1f4f8; --field-bd:#d2dae4 }
|
||||
body{ margin:0; min-height:100vh; padding:24px 14px 44px;
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); color:var(--txt);
|
||||
display:flex; flex-direction:column; align-items:center; gap:16px }
|
||||
a{ color:var(--link) }
|
||||
main{ display:flex; flex-direction:column; align-items:center; gap:16px; width:100% }
|
||||
|
||||
.device{ width:100%; max-width:300px; filter:drop-shadow(0 22px 34px rgba(0,0,0,.55)); }
|
||||
#stage{ display:block; width:100%; height:auto; touch-action:none; cursor:ns-resize }
|
||||
|
||||
.ctrls{ display:flex; align-items:center; gap:14px; flex-wrap:wrap; justify-content:center }
|
||||
.ctrls button{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:9px;
|
||||
padding:7px 12px; font-size:14px; line-height:1; cursor:pointer }
|
||||
.ctrls button:hover{ border-color:var(--cyan) }
|
||||
#play{ min-width:64px; font-size:14px }
|
||||
.trk{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted) }
|
||||
.trk b{ color:var(--txt); min-width:34px; text-align:center; display:inline-block; font-variant-numeric:tabular-nums }
|
||||
|
||||
.hint{ max-width:340px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
|
||||
[data-embed] .hint{ display:none !important }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<main>
|
||||
<h1 class="ff-title">PM_D‑1 Display</h1>
|
||||
<p class="ff-sum">A display‑piece metronome — the pendulum is an RGB light bar that combines every lane's subdivisions & accents; a printed tempo scale with a sliding weight sets the tempo.</p>
|
||||
|
||||
<div class="device"><canvas id="stage" width="300" height="470" aria-label="RGB pendulum metronome"></canvas></div>
|
||||
|
||||
<div class="ctrls">
|
||||
<button id="play" title="Start / stop (Space) — the real unit starts when lifted from its holder">▶ Start</button>
|
||||
<div class="trk"><button id="prev" title="Previous">‹</button><b id="trkLbl">—</b><button id="next" title="Next">›</button></div>
|
||||
</div>
|
||||
|
||||
<div class="hint">The pendulum <b>is</b> the display: every lane's subdivisions & accents ride along the bar as
|
||||
moving RGB light. Drag the <b>weight</b> up/down (or scroll) to set tempo — the scale is printed on the bar,
|
||||
just like a wind‑up metronome. (No power switch: the real one starts when you lift it from its holder.)</div>
|
||||
|
||||
/*@BUILD:include:src/progbox.html@*/
|
||||
|
||||
<p class="ff-link pageonly"><a href="/info-showcase.html">Purpose, dimensions & bill of materials →</a></p>
|
||||
</main>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* ========================= ENGINE (shared; synth voices only) ================= */
|
||||
const SAMPLES = {};
|
||||
/*@BUILD:include:src/engine.js@*/
|
||||
/*@BUILD:include:src/setlists.js@*/
|
||||
const state = { bpm:120, volume:0.85, running:false };
|
||||
let meters = [], muteWindows = [];
|
||||
|
||||
function setBpm(v){ state.bpm = Math.max(30, Math.min(300, Math.round(v))); if(window.progRefresh) progRefresh(); }
|
||||
function scheduler(){
|
||||
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
|
||||
for(const m of meters){ while(m.nextTime < ahead){ scheduleMeterTick(m, m.nextTime); m.nextTime += laneStepDur(m, m.tick); m.tick++; } }
|
||||
}
|
||||
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(),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}; });
|
||||
}
|
||||
function startAudio(){
|
||||
ensureAudio(); audioCtx.resume(); state.running=true;
|
||||
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; }
|
||||
beatCount=-1; lastBeatTime=t0; muteWindows=[];
|
||||
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); syncBtns();
|
||||
}
|
||||
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; for(const m of meters) m.currentStep=-1; syncBtns(); }
|
||||
function toggle(){ state.running ? stopAudio() : startAudio(); }
|
||||
|
||||
/* ========================= TRACKS ============================================ */
|
||||
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
|
||||
let trackIdx = 0;
|
||||
function tracksFromHash(){
|
||||
const m=(location.hash||"").match(/[#&](p|sl)=([^&]+)/); if(!m) return null;
|
||||
let payload=m[2]; try{ payload=decodeURIComponent(payload); }catch(e){}
|
||||
try{
|
||||
if(m[1]==="sl"){ const sl=codeToSetlist(payload); return sl.items.length ? sl.items.map(it=>({...it})) : null; }
|
||||
const s=patchToSetup(payload); return s.lanes.length ? [{name:"Patch",...s}] : null;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
function loadTrack(i){
|
||||
const n=tracks.length; if(!n) return; trackIdx=((i%n)+n)%n;
|
||||
const t=tracks[trackIdx]; setBpm(t.bpm||120); meters=buildMeters(t.lanes);
|
||||
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
|
||||
if(was) startAudio(); syncBtns();
|
||||
}
|
||||
function syncBtns(){ $("play").textContent = state.running ? "■ Stop" : "▶ Start";
|
||||
$("trkLbl").textContent = (trackIdx+1)+"/"+tracks.length; }
|
||||
|
||||
/* ========================= RGB PENDULUM (canvas) ============================= */
|
||||
const cv=$("stage"), g=cv.getContext("2d"), CW=300, CH=470;
|
||||
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); cv.width=CW*dpr; cv.height=CH*dpr; g.scale(dpr,dpr); })();
|
||||
const MAXANG=0.40;
|
||||
const PIVX=150, PIVY=380, ROD=292; // pivot near the base; rod points up
|
||||
const F_FAST=0.30, F_SLOW=0.94; // weight fraction along rod at 240 / 40 BPM (top=slow)
|
||||
let beatCount=-1, lastBeatTime=0, pend=0, flash=0, flashAccent=false;
|
||||
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
|
||||
const LEVELCOL = { 2:[255,155,46], 1:[51,208,255], 3:[155,123,255], 0:[70,80,95] }; // accent / normal / ghost / mute (rgb)
|
||||
const LOGO = new Image(); let logoReady=false; LOGO.onload=function(){ logoReady=true; }; LOGO.src = "data:image/png;base64,@BUILD:logo-dark@";
|
||||
|
||||
function bpmToFrac(b){ return F_SLOW - (Math.max(40,Math.min(240,b))-40)/200*(F_SLOW-F_FAST); }
|
||||
function fracToBpm(f){ return Math.round(240 - (Math.max(F_FAST,Math.min(F_SLOW,f))-F_FAST)/(F_SLOW-F_FAST)*200); }
|
||||
|
||||
function drawBody(){
|
||||
g.clearRect(0,0,CW,CH); // transparent everywhere outside the body
|
||||
const tlx=98,trx=202,topY=18, blx=20,brx=280,botY=440;
|
||||
const grd=g.createLinearGradient(0,0,0,botY); grd.addColorStop(0,"#2c2e34"); grd.addColorStop(1,"#131419");
|
||||
g.beginPath(); g.moveTo(tlx,topY); g.lineTo(trx,topY); g.lineTo(brx,botY); g.lineTo(blx,botY); g.closePath();
|
||||
g.fillStyle=grd; g.fill(); g.lineWidth=1.5; g.strokeStyle="rgba(255,255,255,.06)"; g.stroke();
|
||||
g.beginPath(); g.moveTo(tlx,topY); g.lineTo(blx,botY); g.lineWidth=2; g.strokeStyle="rgba(255,255,255,.05)"; g.stroke();
|
||||
// official VARASYS logo (the "Simplifying Complexity" tagline is baked into the image) + model
|
||||
const lw=92, lh=Math.round(lw*82/304), lx=CW/2-lw/2, ly=14;
|
||||
if(logoReady) g.drawImage(LOGO, lx, ly, lw, lh);
|
||||
g.textAlign="center";
|
||||
g.fillStyle="#aab2bc"; g.font="600 7px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("PM_D‑1 DISPLAY", CW/2, ly+lh+11);
|
||||
}
|
||||
|
||||
function drawPendulum(){
|
||||
g.save(); g.translate(PIVX,PIVY); g.rotate(pend); // rod frame: up = -y, tilts with the swing
|
||||
// rod
|
||||
g.strokeStyle="rgba(150,160,176,.45)"; g.lineWidth=3.5; g.lineCap="round";
|
||||
g.beginPath(); g.moveTo(0,0); g.lineTo(0,-ROD); g.stroke();
|
||||
// printed tempo scale (numbers along the bar)
|
||||
g.textAlign="right"; g.font="600 8px 'Segoe UI',Roboto,Arial,sans-serif";
|
||||
[40,60,80,100,120,160,200,240].forEach(function(b){ const y=-bpmToFrac(b)*ROD;
|
||||
g.strokeStyle="rgba(180,190,205,.5)"; g.lineWidth=1; g.beginPath(); g.moveTo(4,y); g.lineTo(10,y); g.stroke();
|
||||
g.fillStyle="rgba(180,190,205,.6)"; g.fillText(String(b), 2, y+3); });
|
||||
// fixed bob (drives the swing) near the bottom of the rod
|
||||
g.fillStyle="#2a2f37"; roundRectP(-9,-58,18,30,4); g.fill();
|
||||
g.fillStyle="rgba(255,255,255,.06)"; roundRectP(-9,-58,18,5,2); g.fill();
|
||||
// sliding WEIGHT = tempo — STATIC (no flash) and drawn BEHIND the lights so it never hides a beat flash
|
||||
const wy=-bpmToFrac(state.bpm)*ROD;
|
||||
g.fillStyle="#3a4049"; roundRectP(-15,wy-11,30,22,4); g.fill();
|
||||
g.fillStyle="rgba(150,160,176,.5)"; roundRectP(-13,wy-3.5,26,7,2); g.fill(); // index mark (static)
|
||||
g.fillStyle="rgba(255,255,255,.10)"; roundRectP(-15,wy-11,30,5,2); g.fill(); // top sheen
|
||||
// combined-meter LEDs: each lane is a moving point of light along the bar (its current step position).
|
||||
// Drawn LAST so the flashes sit on top of the weight.
|
||||
for(const m of meters){ if(m.currentStep<0 || !state.running) continue;
|
||||
const steps=m.beatsPerBar*m.stepsPerBeat, fr=steps?((m.currentStep%steps)/steps):0;
|
||||
const y=-(0.16 + fr*(0.96-0.16))*ROD, lvl=m.beatsOn[m.currentStep]|0; if(lvl===0) continue;
|
||||
const c=LEVELCOL[lvl]||LEVELCOL[1], rgb="rgb("+c[0]+","+c[1]+","+c[2]+")";
|
||||
g.shadowColor=rgb; g.shadowBlur=14; g.fillStyle=rgb;
|
||||
g.beginPath(); g.arc(0,y, lvl>=2?6:4.5, 0,7); g.fill(); g.shadowBlur=0;
|
||||
}
|
||||
// pivot hub
|
||||
g.restore();
|
||||
g.beginPath(); g.arc(PIVX,PIVY,6,0,7); g.fillStyle="#2a2f37"; g.fill();
|
||||
g.beginPath(); g.arc(PIVX,PIVY,2.5,0,7); g.fillStyle="#aab2bc"; g.fill();
|
||||
}
|
||||
function roundRectP(x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
|
||||
|
||||
function drawReadout(){
|
||||
g.textAlign="center";
|
||||
g.fillStyle="#c7d0db"; g.font="700 20px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(String(state.bpm), CW/2-2, 420);
|
||||
g.fillStyle="#7f8b9a"; g.font="600 8px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("BPM", CW/2+38, 420);
|
||||
const nm=(tracks[trackIdx]&&tracks[trackIdx].name)||"—";
|
||||
g.fillStyle="#8f9aa6"; g.font="600 8.5px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(nm.length>32?nm.slice(0,31)+"…":nm, CW/2, 432);
|
||||
}
|
||||
|
||||
function draw(){
|
||||
const now = audioCtx ? audioCtx.currentTime - audioLatency() : 0;
|
||||
if(audioCtx && state.running){
|
||||
for(const m of meters){
|
||||
while(m.vqPtr<m.vq.length && m.vq[m.vqPtr].time<=now){
|
||||
const e=m.vq[m.vqPtr]; m.currentStep=e.step; m.currentBar=e.bar;
|
||||
if(m===meters[0] && e.step % m.stepsPerBeat === 0){
|
||||
beatCount++; lastBeatTime=e.time;
|
||||
const lvl=m.beatsOn[e.step]|0; flashAccent = lvl>=2 || m.groupStarts.has(e.step/m.stepsPerBeat);
|
||||
if(lvl!==0) flash=1;
|
||||
}
|
||||
m.vqPtr++;
|
||||
}
|
||||
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
|
||||
}
|
||||
}
|
||||
let tgt=0;
|
||||
if(state.running && beatCount>=0){ let fr=(now-lastBeatTime)/(60/state.bpm); if(fr<0)fr=0; if(fr>1.2)fr=1.2;
|
||||
tgt = MAXANG*Math.cos(Math.PI*(beatCount+fr)); }
|
||||
pend += (tgt-pend)*(state.running?1:0.12);
|
||||
flash = Math.max(0, flash-0.08);
|
||||
drawBody(); drawPendulum(); drawReadout();
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
/* ========================= CONTROLS ========================================== */
|
||||
// drag anywhere on the piece to set tempo via the weight (vertical axis); scroll too.
|
||||
let dragging=false;
|
||||
function tempoFromY(clientY){ const r=cv.getBoundingClientRect(); const yy=(clientY-r.top)/r.height*CH;
|
||||
const f=(PIVY-yy)/ROD; setBpm(fracToBpm(f)); syncBtns(); }
|
||||
cv.addEventListener("pointerdown",(e)=>{ dragging=true; try{cv.setPointerCapture(e.pointerId);}catch(_){ } tempoFromY(e.clientY); });
|
||||
cv.addEventListener("pointermove",(e)=>{ if(dragging) tempoFromY(e.clientY); });
|
||||
cv.addEventListener("pointerup",()=>{ dragging=false; });
|
||||
cv.addEventListener("pointercancel",()=>{ dragging=false; });
|
||||
cv.addEventListener("wheel",(e)=>{ e.preventDefault(); setBpm(state.bpm+(e.deltaY<0?(e.shiftKey?5:1):(e.shiftKey?-5:-1))); syncBtns(); }, {passive:false});
|
||||
$("play").onclick = ()=>toggle();
|
||||
$("prev").onclick = ()=>loadTrack(trackIdx-1);
|
||||
$("next").onclick = ()=>loadTrack(trackIdx+1);
|
||||
|
||||
addEventListener("keydown",(e)=>{
|
||||
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
|
||||
if(e.key===" "){ e.preventDefault(); toggle(); }
|
||||
else if(e.key==="ArrowUp"){ e.preventDefault(); setBpm(state.bpm+(e.shiftKey?10:1)); syncBtns(); }
|
||||
else if(e.key==="ArrowDown"){ e.preventDefault(); setBpm(state.bpm-(e.shiftKey?10:1)); syncBtns(); }
|
||||
else if(e.key==="ArrowRight"){ loadTrack(trackIdx+1); }
|
||||
else if(e.key==="ArrowLeft"){ loadTrack(trackIdx-1); }
|
||||
});
|
||||
|
||||
/* ========================= INIT ============================================== */
|
||||
{ const ht=tracksFromHash(); if(ht) tracks=ht; }
|
||||
loadTrack(0); syncBtns();
|
||||
requestAnimationFrame(draw);
|
||||
window.currentProgramString = function(){ var t=tracks[trackIdx]||{}; return setupToPatch({bpm:state.bpm, volume:state.volume, lanes:t.lanes||[]}); };
|
||||
window.loadProgramString = function(plain){ var s=patchToSetup(plain); tracks=[{name:"Program", ...s}]; trackIdx=0; loadTrack(0); syncBtns(); };
|
||||
/*@BUILD:include:src/progbox.js@*/
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -157,9 +157,6 @@ function scheduleMeterTick(m, time) {
|
|||
if (!lvl) return;
|
||||
const lin = m.gainDb ? Math.pow(10, m.gainDb / 20) : 1; // per-lane dB gain → linear, applied at schedule time (no stutter)
|
||||
playInstrument(m.sound, time, (lvl === 2 ? 1.0 : lvl === 3 ? 0.25 : 0.6) * lin);
|
||||
// opt-in per-hit hook (a page may define onMeterHit to e.g. emit MIDI out to external gear);
|
||||
// (sound name, audio-context time of the hit, dynamic level 1/2/3). No-op on pages that don't set it.
|
||||
if (typeof onMeterHit === "function") onMeterHit(m.sound, time, lvl);
|
||||
}
|
||||
|
||||
function refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); }
|
||||
|
|
@ -175,39 +172,14 @@ function laneStepDur(m, tick) {
|
|||
return beat / m.stepsPerBeat; // straight: shared even grid
|
||||
}
|
||||
|
||||
// --- pattern cell codec: char ⇄ (level, ornament) ---
|
||||
// level: 0 rest / 1 normal / 2 accent / 3 ghost. ornament: 0 none / 1 flam / 2 drag / 3 roll.
|
||||
// Ornaments use new letters, UPPER-case = accented hit, lower-case = normal hit (case carries the
|
||||
// dynamic so it stays orthogonal): f/F flam · d/D drag · z/Z roll. Ghosted ornaments aren't expressible.
|
||||
function patCell(ch) {
|
||||
switch (ch) {
|
||||
case "X": return [2, 0];
|
||||
case "x": case "1": return [1, 0];
|
||||
case "g": return [3, 0];
|
||||
case "f": return [1, 1]; case "F": return [2, 1];
|
||||
case "d": return [1, 2]; case "D": return [2, 2];
|
||||
case "z": return [1, 3]; case "Z": return [2, 3];
|
||||
default: return [0, 0]; // . - _ / anything else = rest
|
||||
}
|
||||
}
|
||||
function cellCh(lvl, orn) {
|
||||
if (orn === 1) return lvl >= 2 ? "F" : "f";
|
||||
if (orn === 2) return lvl >= 2 ? "D" : "d";
|
||||
if (orn === 3) return lvl >= 2 ? "Z" : "z";
|
||||
return lvl === 3 ? "g" : lvl >= 2 ? "X" : lvl >= 1 ? "x" : ".";
|
||||
}
|
||||
|
||||
// --- share-language codec: config ⇄ lane token ---
|
||||
function laneCfgToStr(c) {
|
||||
let s = c.sound + ":" + c.groupsStr;
|
||||
const spb = c.stepsPerBeat || 1;
|
||||
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
|
||||
const on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / g ghost / . mute)
|
||||
const orn = c.orns || []; // per-step ornament (flam/drag/roll), parallel to beatsOn
|
||||
const gs = parseGroups(c.groupsStr).groupStarts; // default = accent group starts only; everything else sounds at normal
|
||||
const anyOrn = orn.some((v) => (v | 0) !== 0); // any ornament → not the implicit default; must write the pattern
|
||||
const isDefault = !anyOrn && on.length && on.every((v, i) => (v | 0) === (((i % spb) === 0 && gs.has(i / spb)) ? 2 : 1));
|
||||
if (on.length && !isDefault) s += "=" + on.map((v, i) => cellCh(v | 0, orn[i] | 0)).join("");
|
||||
const isDefault = on.length && on.every((v, i) => (v | 0) === ((i % spb) === 0 ? 2 : 1));
|
||||
if (on.length && !isDefault) s += "=" + on.map((v) => (v === 3 ? "g" : v >= 2 ? "X" : v >= 1 ? "x" : ".")).join("");
|
||||
if (c.gainDb) s += "@" + c.gainDb; // per-lane gain in dB (e.g. @-3, @2)
|
||||
if (c.poly) s += "~";
|
||||
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||||
|
|
@ -227,27 +199,20 @@ function laneStrToCfg(tok) {
|
|||
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
|
||||
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
|
||||
if (sl >= 0) { groupsStr = rest.slice(0, sl); const sp = rest.slice(sl + 1); swing = /s$/i.test(sp); sub = parseInt(sp, 10) || 1; }
|
||||
let { beatsPerBar: bpb, groupStarts } = parseGroups(groupsStr);
|
||||
let beatsOn, orns;
|
||||
let bpb = parseGroups(groupsStr).beatsPerBar;
|
||||
let beatsOn;
|
||||
if (eucK != null) { // k hits spread evenly; first hit accented
|
||||
let n = eucN || (bpb * sub);
|
||||
if (eucN) { if (n % bpb === 0) sub = n / bpb; else { bpb = n; sub = 1; groupsStr = String(n); } }
|
||||
let first = true;
|
||||
beatsOn = euclid(eucK, n, eucRot).map((h) => h ? (first ? (first = false, 2) : 1) : 0);
|
||||
orns = beatsOn.map(() => 0); // euclid hits carry no ornament
|
||||
} else if (pattern != null) {
|
||||
// pattern cells: per-step (level, ornament) — X accent, x/1 normal, g ghost, f/F flam, d/D drag,
|
||||
// z/Z roll, . - _ / anything else = rest. See patCell().
|
||||
const cells = pattern.split("").map(patCell);
|
||||
beatsOn = cells.map((c) => c[0]);
|
||||
orns = cells.map((c) => c[1]);
|
||||
} else {
|
||||
// no pattern → every subdivision sounds at normal, accent on group starts (the grouping IS the accent map)
|
||||
beatsOn = Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
|
||||
orns = beatsOn.map(() => 0);
|
||||
// pattern levels: X=accent(2), g=ghost(3), x/1=normal(1), . - _ / anything else = mute(0); no pattern → first of each beat accented
|
||||
beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : ch === "g" ? 3 : (ch === "x" || ch === "1") ? 1 : 0)
|
||||
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 ? 2 : 1));
|
||||
}
|
||||
if (!DRUMS[sound]) sound = "beep";
|
||||
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, orns, poly, swing, enabled: !disabled, gainDb };
|
||||
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, swing, enabled: !disabled, gainDb };
|
||||
}
|
||||
|
||||
// --- share-language codec: patch ⇄ setup ---
|
||||
|
|
@ -259,27 +224,20 @@ function setupToPatch(s) {
|
|||
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
|
||||
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
|
||||
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
|
||||
if (s.end != null) { // per-track playback flow (default = loop forever)
|
||||
if (s.rep != null && s.rep > 1) parts.push("rep=" + s.rep); // cycles before end fires (1 = default, omitted)
|
||||
parts.push("end=" + (s.end === "stop" ? "stop" : s.end === 1 ? "next" : s.end > 0 ? "+" + s.end : String(s.end)));
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
function patchToSetup(str) {
|
||||
const s = { bpm: 120, volume: null, countMs: 0, bars: 0, lanes: [], rep: null, end: null, trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
|
||||
const s = { bpm: 120, volume: null, countMs: 0, bars: 0, lanes: [], trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
|
||||
for (let tok of String(str).split(";")) {
|
||||
tok = tok.trim(); if (!tok || tok === "v1") continue;
|
||||
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); } // lanes contain ":" → matched first
|
||||
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
|
||||
else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000;
|
||||
else if (tok.startsWith("rep=")) s.rep = parseInt(tok.slice(4), 10) || 1; // playback flow: cycles before end fires
|
||||
else if (tok.startsWith("end=")) { const v = tok.slice(4); s.end = v === "stop" ? "stop" : v === "next" ? 1 : (parseInt(v, 10) || 0); } // stop | next(+1) | relative goto ±N
|
||||
else if (tok.startsWith("b")) s.bars = parseInt(tok.slice(1), 10) || 0; // segment bar-length
|
||||
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
|
||||
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
|
||||
else if (tok.startsWith("b")) s.bars = parseInt(tok.slice(1), 10) || 0; // segment bar-length
|
||||
else if (tok.startsWith("t")) s.bpm = Math.max(5, Math.min(300, parseInt(tok.slice(1), 10) || 120)); // clamp like the firmware
|
||||
else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
|
||||
}
|
||||
if (!s.lanes.length) s.lanes.push(laneStrToCfg("beep:4")); // a patch always has >=1 lane (match the firmware default)
|
||||
return s;
|
||||
}
|
||||
|
||||
|
|
|
|||
7
src/infoembed.html
Normal file
7
src/infoembed.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<!-- Shared info-page live widget — assembled into each info-<device>.html by build.sh.
|
||||
The host page sets window.INFO_DEVICE = {file, name}; infoembed.js wires the iframe
|
||||
to <device>.html?embed=1 (default set lists) and auto-sizes it to the widget. -->
|
||||
<div class="infoview">
|
||||
<div class="iv-bar"><span class="iv-name" id="ivName"></span><a class="iv-open" id="ivOpen" target="_blank" rel="noopener">Open the full device ↗</a></div>
|
||||
<iframe id="ifr" title="VARASYS PolyMeter — live widget" allow="autoplay"></iframe>
|
||||
</div>
|
||||
21
src/infoembed.js
Normal file
21
src/infoembed.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/* Info-page live widget loader — assembled into each info-<device>.html by build.sh.
|
||||
The host page sets window.INFO_DEVICE = {file, name}. This builds the embedded widget
|
||||
(<device>.html?embed=1, which loads the default set lists) and auto-sizes the iframe
|
||||
from the {type:'varasys-h'} height the widget posts back. Defers to DOM-ready. */
|
||||
(function () {
|
||||
function init() {
|
||||
var d = window.INFO_DEVICE || { file: "/editor.html", name: "PolyMeter" };
|
||||
var ifr = document.getElementById("ifr"),
|
||||
nm = document.getElementById("ivName"),
|
||||
op = document.getElementById("ivOpen");
|
||||
if (nm) nm.innerHTML = "<b>" + d.name + "</b>";
|
||||
if (op) op.href = d.file;
|
||||
if (ifr) ifr.src = d.file + "?embed=1";
|
||||
}
|
||||
addEventListener("message", function (e) {
|
||||
var ifr = document.getElementById("ifr");
|
||||
if (!e.data || !ifr || e.source !== ifr.contentWindow) return;
|
||||
if (e.data.type === "varasys-h" && typeof e.data.h === "number") ifr.style.height = e.data.h + "px";
|
||||
});
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init();
|
||||
})();
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
/* MIDI OUT — send the groove as MIDI to external gear (a drum module / e-kit), in sync with
|
||||
playback, plus an optional 24-PPQN MIDI clock + Start/Stop so the gear's tempo locks to the editor.
|
||||
Shared by editor.html + pm_e-2.html (one copy → no drift).
|
||||
|
||||
Relies on page globals: $, audioCtx, state, _midiOutputs(), _ensureMidi(), _isDevicePort().
|
||||
The page wires three transport hooks: midiOutStart(t0) in start(), midiOutStop() in stop(),
|
||||
midiOutClock(aheadTime) at the end of scheduler(); engine.js calls onMeterHit() per scheduled hit. */
|
||||
const SOUND_GM = {
|
||||
kick: 36, kick808: 36, kick909: 36, snare: 38, snare808: 38, snare909: 38,
|
||||
clap: 39, clap808: 39, clap909: 39, rim: 37,
|
||||
hatClosed: 42, hat808: 42, hat909: 42, hatOpen: 46, openHat808: 46,
|
||||
ride: 51, ride909: 51, crash: 49, crash909: 49,
|
||||
tomLow: 41, tom808: 45, tomMid: 45, tomHigh: 48, tambourine: 54,
|
||||
cowbell: 56, cowbell808: 56, woodblock: 76, jamblock: 76, claves: 75, beep: 37,
|
||||
};
|
||||
let _midiOutOn = false, _midiClkNext = 0;
|
||||
|
||||
function _midiOutTarget() {
|
||||
const sel = $("midiOutSel"); if (!sel) return null;
|
||||
return _midiOutputs().find((o) => o.id === sel.value) || null;
|
||||
}
|
||||
function populateMidiOutPorts() {
|
||||
const sel = $("midiOutSel"); if (!sel) return;
|
||||
const outs = _midiOutputs(), prev = sel.value;
|
||||
sel.innerHTML = outs.length
|
||||
? outs.map((o) => `<option value="${o.id}">${(o.name || "output").slice(0, 22)}</option>`).join("")
|
||||
: `<option value="">(no MIDI outputs)</option>`;
|
||||
if (outs.some((o) => o.id === prev)) sel.value = prev; // keep prior choice
|
||||
else { const ext = outs.find((o) => !_isDevicePort(o)); sel.value = ((ext || outs[0] || {}).id) || ""; } // prefer external gear
|
||||
}
|
||||
// schedule `bytes` at audio-context time `audioTime`, converted to the Web-MIDI (performance.now) clock
|
||||
function _midiOutSendAt(bytes, audioTime) {
|
||||
const out = _midiOutTarget(); if (!out) return;
|
||||
const ts = (typeof audioCtx !== "undefined" && audioCtx)
|
||||
? performance.now() + (audioTime - audioCtx.currentTime) * 1000 : performance.now();
|
||||
try { out.send(bytes, ts); } catch (_) {}
|
||||
}
|
||||
// engine.js per-hit hook: GM drum Note-On (ch10) at the hit's audio time, then a Note-Off 60 ms later
|
||||
function onMeterHit(sound, time, lvl) {
|
||||
if (!_midiOutOn) return;
|
||||
const note = SOUND_GM[sound]; if (note == null) return;
|
||||
const vel = lvl === 2 ? 112 : lvl === 3 ? 45 : 90; // accent / ghost / normal
|
||||
_midiOutSendAt([0x99, note, vel], time);
|
||||
_midiOutSendAt([0x89, note, 0], time + 0.06);
|
||||
}
|
||||
function _clockOn() { const c = $("midiClkChk"); return _midiOutOn && !!c && c.checked; }
|
||||
function midiOutStart(t0) { if (_clockOn()) { _midiOutSendAt([0xFA], t0); _midiClkNext = t0; } } // MIDI Start
|
||||
function midiOutStop() {
|
||||
if (_clockOn() && typeof audioCtx !== "undefined" && audioCtx) _midiOutSendAt([0xFC], audioCtx.currentTime); // MIDI Stop
|
||||
}
|
||||
// schedule 24-PPQN clock ticks up to `aheadTime` (called from the page's look-ahead scheduler)
|
||||
function midiOutClock(aheadTime) {
|
||||
if (!_clockOn() || !state.running || typeof audioCtx === "undefined" || !audioCtx) return;
|
||||
const tickDur = (60 / state.bpm) / 24;
|
||||
let guard = 0;
|
||||
while (_midiClkNext < aheadTime && guard++ < 512) { _midiOutSendAt([0xF8], _midiClkNext); _midiClkNext += tickDur; }
|
||||
if (_midiClkNext < aheadTime) _midiClkNext = aheadTime; // never starve-loop if tickDur is tiny
|
||||
}
|
||||
function updateMidiOutBtn() {
|
||||
const b = $("midiOutBtn"), sel = $("midiOutSel"), clk = $("midiClkWrap"); if (!b) return;
|
||||
b.style.color = _midiOutOn ? "#7ab8ff" : "var(--muted)";
|
||||
b.style.borderColor = _midiOutOn ? "#7ab8ff" : "var(--edge)";
|
||||
if (sel) sel.hidden = !_midiOutOn;
|
||||
if (clk) clk.hidden = !_midiOutOn;
|
||||
}
|
||||
async function toggleMidiOut() {
|
||||
if (_midiOutOn) { midiOutStop(); _midiOutOn = false; updateMidiOutBtn(); return; }
|
||||
if (!(await _ensureMidi())) return alert("Driving external MIDI gear needs the Web MIDI API — use Chrome, Edge, or Firefox.");
|
||||
populateMidiOutPorts();
|
||||
if (!_midiOutputs().length) return alert("No MIDI output ports found. Connect your drum module / e-kit (USB-MIDI) and try again.");
|
||||
_midiOutOn = true; updateMidiOutBtn();
|
||||
if (state.running && typeof audioCtx !== "undefined" && audioCtx) midiOutStart(audioCtx.currentTime + 0.05); // armed mid-play → Start now
|
||||
}
|
||||
403
src/notation.js
403
src/notation.js
|
|
@ -1,403 +0,0 @@
|
|||
/* =========================================================================
|
||||
PM_E-2 NOTATION ENGINE — inlined into pm_e-2.html by build.sh.
|
||||
Engraves a groove as a 5-line drum staff onto a 2D <canvas>, using the
|
||||
Bravura SMuFL music font (subset inlined via @BUILD:bravura@). The draw API
|
||||
is deliberately IMMEDIATE-MODE and mirrors embedded-graphics (drawGlyph /
|
||||
line / rect) so the layout math ports near-mechanically to the device
|
||||
(rust/pm-ui). Pure view over a normalized model — no engine.js internals.
|
||||
|
||||
model = {
|
||||
name, bpm, playing, phase, // phase 0..1 across the master bar (playhead)
|
||||
lanes: [ { sound, groups:[Int], sub, swing, poly, muted,
|
||||
levels:[0..3], orns:[0..3] } ] // levels: rest/normal/accent/ghost
|
||||
}
|
||||
========================================================================= */
|
||||
const NOTATION = (() => {
|
||||
// SMuFL codepoints (resolved from glyphnames.json by tools/bravura/subset.py — keep in sync).
|
||||
const GLYPH = {
|
||||
clef: 0xe069,
|
||||
black: 0xe0a4, x: 0xe0a9, circleX: 0xe0b3, half: 0xe0a3, whole: 0xe0a2,
|
||||
parenL: 0xe0f5, parenR: 0xe0f6,
|
||||
flag8U: 0xe240, flag8D: 0xe241, flag16U: 0xe242, flag16D: 0xe243,
|
||||
restW: 0xe4e3, restH: 0xe4e4, restQ: 0xe4e5, rest8: 0xe4e6, rest16: 0xe4e7,
|
||||
accentA: 0xe4a0, accentB: 0xe4a1, dot: 0xe1e7,
|
||||
sig: [0xe080, 0xe081, 0xe082, 0xe083, 0xe084, 0xe085, 0xe086, 0xe087, 0xe088, 0xe089],
|
||||
sigPlus: 0xe08c, sigCommon: 0xe08a, sigCut: 0xe08b,
|
||||
graceAcc: 0xe560, graceSlash: 0xe564,
|
||||
trem1: 0xe220, trem2: 0xe221, trem3: 0xe222, buzz: 0xe22a,
|
||||
};
|
||||
const chr = (cp) => String.fromCodePoint(cp);
|
||||
|
||||
// Voice -> staff position. `p` = half-staff-spaces below the TOP line (top line p=0, each line/space
|
||||
// step = 1; bottom line p=8). `head` = notehead glyph; `up` = stem direction (hands up / feet down).
|
||||
// PAS-style drum key; refined visually in the browser.
|
||||
function voice(name) {
|
||||
const s = name || "";
|
||||
const F = (p, head, up) => ({ p, head, up });
|
||||
if (s.startsWith("kick")) return F(7, "black", false); // bass drum (feet, stem down)
|
||||
if (s.startsWith("snare") || s.startsWith("clap") || s.startsWith("rim")) return F(3, "black", true);
|
||||
if (s.startsWith("openHat") || s.startsWith("hatOpen") || s.startsWith("hat")) return F(-1, "x", true);
|
||||
if (s.startsWith("ride")) return F(0, "x", true);
|
||||
if (s.startsWith("crash")) return F(-3, "x", true);
|
||||
if (s.startsWith("tomHigh")) return F(1, "black", true);
|
||||
if (s.startsWith("tomMid") || s === "tom808") return F(2, "black", true);
|
||||
if (s.startsWith("tomLow") || s.startsWith("tom")) return F(5, "black", true);
|
||||
if (s.startsWith("cowbell")) return F(-1, "circleX", true);
|
||||
if (s.startsWith("claves") || s.startsWith("woodblock") || s.startsWith("jamblock")) return F(1, "x", true);
|
||||
if (s.startsWith("tambourine")) return F(-2, "x", true);
|
||||
return F(3, "black", true);
|
||||
}
|
||||
|
||||
function gcd(a, b) { while (b) { [a, b] = [b, a % b]; } return a || 1; }
|
||||
function lcm(a, b) { return a / gcd(a, b) * b; }
|
||||
|
||||
// --- palette ---
|
||||
// The notation panel is engraved like paper: dark ink on a WHITE background, theme-independent
|
||||
// (matches print sheet music and reads in both page themes).
|
||||
function palette() {
|
||||
return {
|
||||
ink: "#161a1f", // staff lines, noteheads, stems, beams
|
||||
faint: "#aeb6c0", // box outlines / gridlines / poly-row baseline
|
||||
accent: "#c0392b", // accents (notehead tint + > mark) — reads on white
|
||||
ghost: "#7c8794", // ghost notes
|
||||
play: "#2b7fff", // playhead
|
||||
bg: "#ffffff",
|
||||
};
|
||||
}
|
||||
|
||||
function draw(canvas, model, opts) {
|
||||
const view = (model && model.view) || "staff";
|
||||
if (view === "tubs") return drawTUBS(canvas, model);
|
||||
if (view === "konnakol") return drawKonnakol(canvas, model);
|
||||
opts = opts || {};
|
||||
const ctx = canvas.getContext("2d");
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth, H = canvas.clientHeight;
|
||||
if (canvas.width !== W * dpr || canvas.height !== H * dpr) {
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const pal = palette();
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const S = opts.staffSpace || 11; // staff space in px
|
||||
const em = 4 * S; // SMuFL: 1 em = 4 staff spaces
|
||||
const y0 = (p) => staffTop + p * (S / 2); // staff-position -> y
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
|
||||
const glyph = (name, x, p, color, scale) => {
|
||||
const cp = typeof name === "number" ? name : GLYPH[name];
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = (em * (scale || 1)) + "px Bravura";
|
||||
// SMuFL glyphs sit on the baseline = the reference staff line; fillText baseline aligns there.
|
||||
ctx.fillText(chr(cp), x, y0(p));
|
||||
};
|
||||
const line = (x1, yy1, x2, yy2, color, w) => {
|
||||
ctx.strokeStyle = color; ctx.lineWidth = w || 1;
|
||||
ctx.beginPath(); ctx.moveTo(x1, yy1); ctx.lineTo(x2, yy2); ctx.stroke();
|
||||
};
|
||||
|
||||
// geometry + model
|
||||
const m = 14;
|
||||
const clefW = em * 0.6;
|
||||
const x1 = W - m;
|
||||
const staffTop = 56;
|
||||
const lanes = (model.lanes || []).filter((l) => !l.muted && l.levels && l.levels.length);
|
||||
const onStaff = lanes.filter((l) => !l.poly); // first non-poly lane = master (defines the meter)
|
||||
|
||||
const groups = (onStaff[0] && onStaff[0].groups && onStaff[0].groups.length) ? onStaff[0].groups : [4];
|
||||
const beats = groups.reduce((a, b) => a + b, 0) || 4;
|
||||
// time signature: additive numerator (2+2+3) for grouped meters; PM's beat is the quarter -> denom 4.
|
||||
const tsDigit = em * 0.4;
|
||||
const numParts = groups.length > 1 ? groups : [beats];
|
||||
const numGlyphs = numParts.reduce((a, n) => a + String(n).length, 0) + (numParts.length - 1);
|
||||
const tsX = m + clefW;
|
||||
const tsW = Math.max(numGlyphs, 1) * tsDigit;
|
||||
// notes start clear of the time signature, or at a shared gutter (so all views' beats line up)
|
||||
const x0 = model.gutter != null ? model.gutter : tsX + tsW + 14;
|
||||
const barW = Math.max(1, x1 - x0);
|
||||
|
||||
// ---- header: name + BPM ----
|
||||
ctx.textAlign = "left";
|
||||
ctx.font = "600 15px system-ui, sans-serif";
|
||||
ctx.fillStyle = pal.ink;
|
||||
ctx.fillText(model.name || "", m, 26);
|
||||
ctx.textAlign = "right";
|
||||
ctx.font = "700 18px 'Courier New', monospace";
|
||||
ctx.fillStyle = pal.accent;
|
||||
ctx.fillText((model.bpm | 0) + " BPM", x1, 26);
|
||||
ctx.textAlign = "center";
|
||||
|
||||
// ---- staff + barlines + clef + time signature ----
|
||||
for (let i = 0; i < 5; i++) line(m, staffTop + i * S, x1, staffTop + i * S, pal.ink, 1);
|
||||
line(m, staffTop, m, staffTop + 4 * S, pal.ink, 1.5);
|
||||
line(x1, staffTop, x1, staffTop + 4 * S, pal.ink, 1.5);
|
||||
glyph("clef", m + clefW * 0.5, 4, pal.ink); // percussion clef centered on middle line (p=4)
|
||||
|
||||
drawTimeSig(tsX, tsW, numParts, tsDigit);
|
||||
|
||||
// ---- time grid: lcm of ALL lanes' step counts (incl. polyrhythm `~` lanes) → the right common
|
||||
// time scale so every voice, including cross-rhythms, sits on ONE staff at aligned columns ----
|
||||
let res = 1;
|
||||
for (const l of lanes) res = lcm(res, l.levels.length);
|
||||
res = Math.max(res, 1);
|
||||
const beamable = res / Math.max(beats, 1) >= 2; // bar has subdivisions → beam within beats
|
||||
ctx.font = em + "px Bravura";
|
||||
const headHalf = ctx.measureText(chr(GLYPH.black)).width / 2; // real notehead half-width → stems touch it
|
||||
|
||||
// beaming state per stem direction (carry previous column's stem x within a beat)
|
||||
let upPrev = null, dnPrev = null;
|
||||
const upTip = staffTop - S * 2.6, dnTip = staffTop + 4 * S + S * 2.6;
|
||||
|
||||
for (let c = 0; c < res; c++) {
|
||||
const cx = x0 + (c + 0.5) * barW / res;
|
||||
const beat = Math.floor(c * beats / res);
|
||||
let up = null, dn = null; // {loP, hiP, sub2} accumulators per direction
|
||||
|
||||
for (const l of lanes) {
|
||||
const steps = l.levels.length;
|
||||
if ((c * steps) % res !== 0) continue; // no note for this lane at this column
|
||||
const si = (c * steps / res) | 0;
|
||||
const lvl = l.levels[si] | 0;
|
||||
if (!lvl) continue;
|
||||
const orn = (l.orns && l.orns[si]) | 0;
|
||||
const vc = voice(l.sound);
|
||||
const color = lvl === 2 ? pal.accent : lvl === 3 ? pal.ghost : pal.ink;
|
||||
|
||||
// ghost = parenthesized notehead
|
||||
if (lvl === 3) glyph("parenL", cx - S * 0.85, vc.p, color);
|
||||
const head = vc.head === "x" ? "x" : vc.head === "circleX" ? "circleX" : "black";
|
||||
glyph(head, cx, vc.p, color);
|
||||
if (lvl === 3) glyph("parenR", cx + S * 0.85, vc.p, color);
|
||||
|
||||
// accent mark above/below the staff edge
|
||||
if (lvl === 2) glyph(vc.up ? "accentA" : "accentB", cx, vc.up ? -2 : 10, color, 0.8);
|
||||
|
||||
// ornaments: flam = slashed grace note up-left; roll = tremolo strokes on the stem
|
||||
if (orn === 1) glyph("graceSlash", cx - S * 1.4, vc.p - 0.5, color, 0.7);
|
||||
else if (orn === 2) { glyph("graceSlash", cx - S * 1.9, vc.p - 0.5, color, 0.7); glyph("graceSlash", cx - S * 1.1, vc.p - 0.5, color, 0.7); }
|
||||
else if (orn === 3) glyph("trem3", cx + (vc.up ? S * 0.55 : -S * 0.55), vc.p + (vc.up ? -2 : 2), color, 0.8);
|
||||
|
||||
// ledger lines
|
||||
for (let lp = -2; lp >= vc.p; lp -= 2) line(cx - S * 0.9, y0(lp), cx + S * 0.9, y0(lp), pal.ink, 1);
|
||||
for (let lp = 10; lp <= vc.p; lp += 2) line(cx - S * 0.9, y0(lp), cx + S * 0.9, y0(lp), pal.ink, 1);
|
||||
|
||||
const sub2 = beamable;
|
||||
const acc = vc.up ? (up = up || { loP: -99, hiP: 99, sub2: false }) : (dn = dn || { loP: -99, hiP: 99, sub2: false });
|
||||
acc.loP = Math.max(acc.loP, vc.p); // lowest (largest p)
|
||||
acc.hiP = Math.min(acc.hiP, vc.p); // highest (smallest p)
|
||||
acc.sub2 = acc.sub2 || sub2;
|
||||
}
|
||||
|
||||
// shared up-stem (hands): right side of head, from lowest head up past the highest
|
||||
if (up) {
|
||||
const sx = cx + headHalf;
|
||||
const top = Math.min(upTip, y0(up.hiP) - S * 1.2);
|
||||
line(sx, y0(up.loP), sx, top, pal.ink, 1.4);
|
||||
if (up.sub2 && upPrev && upPrev.beat === beat) line(upPrev.x, top, sx, top, pal.ink, 3);
|
||||
else if (up.sub2) {} // first of a beam group; flag drawn only if it stays solo (handled below)
|
||||
upPrev = up.sub2 ? { x: sx, beat, y: top } : null;
|
||||
} else upPrev = null;
|
||||
// shared down-stem (feet): left side
|
||||
if (dn) {
|
||||
const sx = cx - headHalf;
|
||||
const bot = Math.max(dnTip, y0(dn.loP) + S * 1.2);
|
||||
line(sx, y0(dn.hiP), sx, bot, pal.ink, 1.4);
|
||||
if (dn.sub2 && dnPrev && dnPrev.beat === beat) line(dnPrev.x, bot, sx, bot, pal.ink, 3);
|
||||
dnPrev = dn.sub2 ? { x: sx, beat, y: bot } : null;
|
||||
} else dnPrev = null;
|
||||
}
|
||||
|
||||
// ---- tuplet number: the common subdivision per beat (3=triplet, 6=sextuplet, 5, 7…). For beamed
|
||||
// groups the modern convention is just the numeral over the beam (no bracket). ----
|
||||
const isPow2 = (n) => n > 0 && (n & (n - 1)) === 0;
|
||||
const tupN = Math.round(res / Math.max(1, beats));
|
||||
if (tupN >= 3 && !isPow2(tupN)) {
|
||||
ctx.fillStyle = pal.ink; ctx.textAlign = "center";
|
||||
ctx.font = "italic 600 13px Georgia, 'Times New Roman', serif";
|
||||
const ty = upTip - S * 0.7;
|
||||
for (let b = 0; b < beats; b++) ctx.fillText(String(tupN), x0 + (b + 0.5) * barW / beats, ty);
|
||||
}
|
||||
|
||||
// ---- playhead ----
|
||||
// `phase` is the master-bar fraction (0..1). Noteheads sit at column CENTERS ((c+0.5)/res), so
|
||||
// shift the line by half a cell to land exactly on the note at its onset instead of leading it.
|
||||
if (model.playing && model.phase != null) {
|
||||
const pf = Math.max(0, Math.min(1, model.phase + 0.5 / res));
|
||||
const px = x0 + pf * barW;
|
||||
ctx.save(); ctx.globalAlpha = 0.55;
|
||||
line(px, staffTop - S, px, staffTop + 4 * S + S, pal.play, 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---- hit map for on-staff editing (all in CSS px) ----
|
||||
// Each on-staff lane exposes its staff row (p) + column geometry; the page maps a click to the
|
||||
// nearest voice row (y) and the column (x) → (laneIndex, step). `idx` indexes model.lanes/meters.
|
||||
canvas._hit = {
|
||||
kind: "staff", staffTop, S, x0, barW,
|
||||
lanes: lanes.map((l) => ({ idx: l.idx, p: voice(l.sound).p, steps: l.levels.length })),
|
||||
};
|
||||
|
||||
// time signature: numerator (additive parts joined by timeSigPlus) over a quarter-note denominator,
|
||||
// each row centered within the reserved width `tw`. Defined here so it closes over glyph()/em/pal.
|
||||
function drawTimeSig(tx, tw, parts, dw) {
|
||||
drawSigRow(tx, tw, 2, parts, dw); // numerator in the upper half of the staff
|
||||
drawSigRow(tx, tw, 6, [4], dw); // denominator (PM beat = quarter) in the lower half
|
||||
}
|
||||
function drawSigRow(tx, tw, p, parts, dw) {
|
||||
const seq = [];
|
||||
parts.forEach((n, i) => {
|
||||
if (i) seq.push(GLYPH.sigPlus);
|
||||
String(n).split("").forEach((d) => seq.push(GLYPH.sig[+d]));
|
||||
});
|
||||
let xx = tx + (tw - seq.length * dw) / 2 + dw / 2;
|
||||
for (const cp of seq) { glyph(cp, xx, p, pal.ink, 0.92); xx += dw; }
|
||||
}
|
||||
}
|
||||
|
||||
function drawPolyRow(ctx, glyph, line, pal, l, x0, x1, py, S) {
|
||||
ctx.textAlign = "left"; ctx.font = "11px system-ui, sans-serif"; ctx.fillStyle = pal.ghost;
|
||||
ctx.fillText(l.sound + " ~", x0, py - S * 1.6); ctx.textAlign = "center";
|
||||
line(x0, py, x1, py, pal.faint, 1);
|
||||
const steps = l.levels.length, barW = x1 - x0;
|
||||
const vc = voice(l.sound);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const lvl = l.levels[i] | 0; if (!lvl) continue;
|
||||
const cx = x0 + (i + 0.5) * barW / steps;
|
||||
const color = lvl === 2 ? pal.accent : lvl === 3 ? pal.ghost : pal.ink;
|
||||
const head = vc.head === "x" ? "x" : "black";
|
||||
ctx.fillStyle = color; ctx.font = (4 * S) + "px Bravura";
|
||||
ctx.fillText(chr(vc.head === "x" ? GLYPH.x : GLYPH.black), cx, py);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared setup for the alternate views ----
|
||||
function begin(canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth, H = canvas.clientHeight;
|
||||
if (canvas.width !== W * dpr || canvas.height !== H * dpr) { canvas.width = W * dpr; canvas.height = H * dpr; }
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
return { ctx, W, H, pal: palette() };
|
||||
}
|
||||
function header(ctx, W, model, pal) {
|
||||
ctx.textAlign = "left"; ctx.font = "600 15px system-ui, sans-serif"; ctx.fillStyle = pal.ink;
|
||||
ctx.fillText(model.name || "", 14, 26);
|
||||
ctx.textAlign = "right"; ctx.font = "700 18px 'Courier New', monospace"; ctx.fillStyle = pal.accent;
|
||||
ctx.fillText((model.bpm | 0) + " BPM", W - 14, 26);
|
||||
ctx.textAlign = "center";
|
||||
}
|
||||
// Son/rumba clave fingerprint: split the bar in half, count hits each side → 2-3 or 3-2.
|
||||
function claveLabel(l) {
|
||||
if (!/^clave/.test(l.sound || "")) return "";
|
||||
const n = l.levels.length, h = n >> 1;
|
||||
if (!h) return "(clave)";
|
||||
const a = l.levels.slice(0, h).filter((v) => v > 0).length;
|
||||
const b = l.levels.slice(h).filter((v) => v > 0).length;
|
||||
return a === 2 && b === 3 ? "(2-3)" : a === 3 && b === 2 ? "(3-2)" : "(clave)";
|
||||
}
|
||||
|
||||
// ---- TUBS (Time Unit Box System): rows = voices, columns = time units, filled boxes = hits ----
|
||||
// All bar-sharing lanes are drawn on ONE common time grid (lcm of their step counts) so every
|
||||
// column lines up vertically across rows; a coarser lane just fills every Nth cell. Polymeter
|
||||
// lanes keep their own spacing across the full width (that IS the cross-rhythm).
|
||||
function drawTUBS(canvas, model) {
|
||||
const { ctx, W, H, pal } = begin(canvas);
|
||||
header(ctx, W, model, pal);
|
||||
const lanes = (model.lanes || []).filter((l) => !l.muted && l.levels && l.levels.length);
|
||||
if (!lanes.length) { canvas._hit = null; return; }
|
||||
const m = 14, x0 = model.gutter != null ? model.gutter : m + 96, x1 = W - m, gw = Math.max(1, x1 - x0);
|
||||
const top = 42, bot = H - 12;
|
||||
const rowH = Math.min(40, Math.max(20, (bot - top) / lanes.length));
|
||||
|
||||
// common grid = lcm of ALL lanes' step counts so columns line up; each lane draws ONE box per
|
||||
// REAL step at its grid column → aligned AND each box is a clickable step.
|
||||
let res = 1; for (const l of lanes) res = lcm(res, l.levels.length);
|
||||
res = Math.max(res, 1);
|
||||
const cw = gw / res, bs = Math.max(11, Math.min(rowH - 8, cw - 3, 30));
|
||||
const master = lanes.find((l) => !l.poly) || lanes[0];
|
||||
const mg = master.groups && master.groups.length ? master.groups : [4];
|
||||
const mbeats = mg.reduce((a, b) => a + b, 0) || 1;
|
||||
const starts = new Set(); let acc = 0; for (const g of mg) { starts.add(acc); acc += g; }
|
||||
const yA = top - 4, yB = top + lanes.length * rowH;
|
||||
|
||||
// beat / group dividers (group starts brighter)
|
||||
for (let b = 0; b <= mbeats; b++) {
|
||||
const lx = x0 + (b * res / mbeats) * cw;
|
||||
ctx.strokeStyle = (b < mbeats && starts.has(b)) || b === 0 || b === mbeats ? pal.ghost : pal.faint;
|
||||
ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lx, yA); ctx.lineTo(lx, yB); ctx.stroke();
|
||||
}
|
||||
// playhead column (aligned to box centers like the staff)
|
||||
if (model.playing && model.phase != null) {
|
||||
const px = x0 + Math.max(0, Math.min(1, model.phase + 0.5 / res)) * gw;
|
||||
ctx.save(); ctx.globalAlpha = 0.5; ctx.strokeStyle = pal.play; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(px, yA); ctx.lineTo(px, yB); ctx.stroke(); ctx.restore();
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
lanes.forEach((l, r) => {
|
||||
const cy = top + r * rowH + rowH / 2;
|
||||
ctx.textAlign = "left"; ctx.font = "12px system-ui, sans-serif"; ctx.fillStyle = pal.ink;
|
||||
ctx.fillText(l.sound + (l.poly ? " ~" : "") + (claveLabel(l) ? " " + claveLabel(l) : ""), m, cy + 4);
|
||||
const steps = l.levels.length, span = res / steps;
|
||||
rows.push({ idx: l.idx, steps, span });
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const cx = x0 + (i * span + 0.5) * cw, lvl = l.levels[i] | 0;
|
||||
ctx.strokeStyle = pal.faint; ctx.lineWidth = 1; ctx.strokeRect(cx - bs / 2, cy - bs / 2, bs, bs);
|
||||
if (lvl > 0) {
|
||||
ctx.fillStyle = lvl === 2 ? pal.accent : lvl === 3 ? pal.ghost : pal.ink;
|
||||
const p = bs * 0.2; ctx.fillRect(cx - bs / 2 + p, cy - bs / 2 + p, bs - 2 * p, bs - 2 * p);
|
||||
const orn = (l.orns && l.orns[i]) | 0; // flam/drag/roll marker inside the box
|
||||
if (orn) { ctx.fillStyle = pal.bg; ctx.font = "700 9px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText(orn === 1 ? "f" : orn === 2 ? "d" : "z", cx, cy + 3.5); ctx.textAlign = "left"; }
|
||||
}
|
||||
}
|
||||
});
|
||||
// hit map for editing: click a box to cycle dynamic; Shift-click cycles ornament
|
||||
canvas._hit = { kind: "tubs", x0, cw, top, rowH, rows };
|
||||
}
|
||||
|
||||
// ---- Konnakol: spoken-rhythm syllables (solkattu) for the master lane's subdivision ----
|
||||
const SOLKATTU = {
|
||||
1: ["ta"], 2: ["ta", "ka"], 3: ["ta", "ki", "ta"], 4: ["ta", "ka", "di", "mi"],
|
||||
5: ["ta", "ka", "ta", "ki", "ta"], 6: ["ta", "ki", "ta", "ta", "ki", "ta"],
|
||||
7: ["ta", "ka", "ta", "ki", "ta", "ki", "ta"], 8: ["ta", "ka", "di", "mi", "ta", "ka", "ju", "nu"],
|
||||
};
|
||||
function drawKonnakol(canvas, model) {
|
||||
const { ctx, W, H, pal } = begin(canvas);
|
||||
canvas._hit = null;
|
||||
header(ctx, W, model, pal);
|
||||
const lanes = (model.lanes || []).filter((l) => !l.muted && l.levels && l.levels.length);
|
||||
const m0 = lanes.find((l) => !l.poly) || lanes[0];
|
||||
if (!m0) return;
|
||||
const groups = m0.groups && m0.groups.length ? m0.groups : [4];
|
||||
const beats = groups.reduce((a, b) => a + b, 0) || 1;
|
||||
let res = 1; for (const l of lanes) res = lcm(res, l.levels.length); // common (finest) grid
|
||||
const sub = Math.max(1, Math.round(res / beats)); // nadai = subdivisions per beat
|
||||
const bols = SOLKATTU[sub] || SOLKATTU[4];
|
||||
const m = 14, x0 = model.gutter != null ? model.gutter : m, x1 = W - m, gw = x1 - x0, colW = gw / (beats * sub), cy = H / 2;
|
||||
const starts = new Set(); let acc = 0; for (const g of groups) { starts.add(acc); acc += g; }
|
||||
ctx.textAlign = "center";
|
||||
for (let b = 0; b < beats; b++) {
|
||||
const gs = starts.has(b), sam = b === 0;
|
||||
ctx.font = "10px system-ui, sans-serif"; ctx.fillStyle = pal.faint;
|
||||
ctx.fillText(sam ? "X" : gs ? "O" : "·", x0 + (b * sub + 0.5) * colW, cy - 22); // sam / anga / beat
|
||||
for (let s = 0; s < sub; s++) {
|
||||
const idx = b * sub + s, cx = x0 + (idx + 0.5) * colW;
|
||||
ctx.font = (s === 0 ? "600 " : "") + "16px system-ui, sans-serif";
|
||||
ctx.fillStyle = sam && s === 0 ? pal.accent : s === 0 ? pal.ink : pal.ghost;
|
||||
ctx.fillText(bols[s % bols.length], cx, cy + 6);
|
||||
}
|
||||
}
|
||||
for (let b = 0; b <= beats; b++) { // beat/group dividers
|
||||
const lx = x0 + b * sub * colW;
|
||||
ctx.strokeStyle = starts.has(b) || b === 0 || b === beats ? pal.ghost : pal.faint;
|
||||
ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lx, cy - 30); ctx.lineTo(lx, cy + 22); ctx.stroke();
|
||||
}
|
||||
ctx.textAlign = "left"; ctx.font = "11px system-ui, sans-serif"; ctx.fillStyle = pal.ghost;
|
||||
ctx.fillText("tala " + groups.join("+") + " · nadai " + sub + " · X=sam O=anga", m, H - 12);
|
||||
}
|
||||
|
||||
return { draw, GLYPH, voice };
|
||||
})();
|
||||
11
src/progbox.html
Normal file
11
src/progbox.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!-- Per-device program I/O — assembled into each form-factor page by build.sh.
|
||||
Hidden in embed mode (the landing supplies its own box). The host page defines
|
||||
window.currentProgramString() and window.loadProgramString(plain); progbox.js wires this. -->
|
||||
<div class="progbox" title="The program for what's loaded — paste a patch or a base64 set-list code, then Load">
|
||||
<label for="dProg">program</label>
|
||||
<input id="dProg" spellcheck="false" autocomplete="off" autocapitalize="off"
|
||||
placeholder="v1;t120;kick:4;snare:4=.X.X;hat:4/2 — or a base64 set-list code">
|
||||
<span id="dProgMsg" class="progbox-msg"></span>
|
||||
<button id="dProgLoad" class="primary">Load ▸</button>
|
||||
<button id="dProgCopy">Copy</button>
|
||||
</div>
|
||||
55
src/progbox.js
Normal file
55
src/progbox.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/* Per-device program I/O — assembled into each form-factor page (the thin widget).
|
||||
Uses the engine codec (patchToSetup / setupToPatch / codeToSetlist) for decode + lint.
|
||||
Host page provides: window.currentProgramString() and window.loadProgramString(plain).
|
||||
Exposes window.progRefresh() — call it after the device's program changes. When the
|
||||
page is embedded it posts {type:'varasys-prog'} to the parent instead of touching the box.
|
||||
(Specs / dimensions / BOM live on the separate info-<device>.html page, not here.)
|
||||
Defers to DOM-ready so the box is found wherever it sits on the page. */
|
||||
(function () {
|
||||
var embedded = document.documentElement.dataset.embed === "1";
|
||||
var box, msg, editing = false;
|
||||
function setMsg(t, ok) { if (!msg) return; msg.textContent = t || ""; msg.classList.toggle("ok", !!ok && !!t); msg.classList.toggle("bad", !ok && !!t); }
|
||||
function lint(text) {
|
||||
text = (text || "").trim(); if (!text) return { ok: false, msg: "empty" };
|
||||
var m = text.match(/[#?&](p|sl)=([^&\s]+)/), kind = null, payload = text;
|
||||
if (m) { kind = m[1]; try { payload = decodeURIComponent(m[2]); } catch (e) { payload = m[2]; } }
|
||||
var b64 = /^[A-Za-z0-9_-]{12,}$/.test(payload) && !/[;:]/.test(payload);
|
||||
try {
|
||||
if (kind === "sl" || (kind !== "p" && b64)) {
|
||||
var sl = codeToSetlist(payload); if (!sl.items || !sl.items.length) throw new Error("set-list code has no items");
|
||||
return { ok: true, plain: setupToPatch(sl.items[0]), msg: "decoded set list “" + sl.title + "” — item 1" };
|
||||
}
|
||||
var s = patchToSetup(payload); if (!s.lanes.length) throw new Error("no lanes — try e.g. kick:4");
|
||||
return { ok: true, plain: setupToPatch(s), msg: s.lanes.length + " lane" + (s.lanes.length > 1 ? "s" : "") + " · " + s.bpm + " BPM" };
|
||||
} catch (e) { return { ok: false, msg: "✗ " + e.message }; }
|
||||
}
|
||||
function doLoad() {
|
||||
var r = lint(box.value);
|
||||
if (!r.ok) { box.classList.add("err"); setMsg(r.msg, false); return; }
|
||||
box.classList.remove("err"); box.value = r.plain; setMsg("✓ " + r.msg, true);
|
||||
try { if (window.loadProgramString) window.loadProgramString(r.plain); } catch (e) { setMsg("✗ " + e.message, false); }
|
||||
}
|
||||
// report the current program: to the parent when embedded, else into the box.
|
||||
// No-op on pages without a program hook (e.g. panel-based Teacher/Player).
|
||||
window.progRefresh = function () {
|
||||
if (!window.currentProgramString) return;
|
||||
var p = ""; try { p = window.currentProgramString(); } catch (e) {}
|
||||
if (embedded) { try { parent.postMessage({ type: "varasys-prog", patch: p }, "*"); } catch (e) {} return; }
|
||||
if (box && !editing) { box.value = p; setMsg("", true); }
|
||||
};
|
||||
function init() {
|
||||
box = document.getElementById("dProg"); msg = document.getElementById("dProgMsg");
|
||||
if (box) {
|
||||
box.addEventListener("focus", function () { editing = true; });
|
||||
box.addEventListener("blur", function () { editing = false; });
|
||||
box.addEventListener("input", function () { box.classList.remove("err"); });
|
||||
box.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); doLoad(); } });
|
||||
var loadBtn = document.getElementById("dProgLoad"); if (loadBtn) loadBtn.addEventListener("click", doLoad);
|
||||
var copyBtn = document.getElementById("dProgCopy"); if (copyBtn) copyBtn.addEventListener("click", function () {
|
||||
try { navigator.clipboard.writeText(box.value); copyBtn.textContent = "Copied!"; setTimeout(function () { copyBtn.textContent = "Copy"; }, 1200); } catch (e) { box.select(); }
|
||||
});
|
||||
}
|
||||
window.progRefresh();
|
||||
}
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init();
|
||||
})();
|
||||
|
|
@ -1,42 +1,37 @@
|
|||
// Seed set lists — baked into every page; each item is authored in the share language
|
||||
// (so this also exercises the parser). Two curated lists:
|
||||
// Styles — full, genre-true grooves; good as backing tracks to jam/solo over.
|
||||
// Practice — drills for learning to PLAY those styles (isolations, independence,
|
||||
// dynamics, polyrhythms, tempo/gap tools).
|
||||
// Demo set list (each item authored in the share language — also exercises the parser).
|
||||
const SEED_SETLISTS = [
|
||||
{ title: "Styles", description: "Full genre grooves — load one, press play, and jam over it. Tap pads to reshape the feel.", items: [
|
||||
["Rock", "t116;kick:4/2=X...Xx..;snare:4/2=..X...X.;hatClosed:4/2=Xxxxxxxx"],
|
||||
["Pop (16ths)", "t104;kick:4/4=X.....x...X.....;snare:4/4=....X.......X...;hatClosed:4/4=xxxxxxxxxxxxxxxx"],
|
||||
["Funk", "t100;kick:4/4=X..x..X...x.X...;snare:4/4=....X..g.g..X.g.;hatClosed:4/4=xxxxxxxxxxxxxxxx"],
|
||||
["Disco", "t120;kick:4;clap:4=.X.X;hatClosed:4/2=X.X.X.X.;hatOpen:4/2=.x.x.x.x"],
|
||||
["Motown backbeat", "t132;kick:4/2=X..x.X..;snare:4/2=..X...X.;tambourine:4/2=xxxxxxxx"],
|
||||
["Blues shuffle (12/8)", "t84;ride:4/3=X.xX.xX.xX.x;snare:4/3=...X.....X..;kick:4/3=X.....X....."],
|
||||
["Jazz swing", "t150;ride:4/3=X.xX.xX.xX.x;hatClosed:4=.x.x;kick:4=x.x.@-9"],
|
||||
["Bossa nova", "t128;rim:4/4=..x.x...x..x..x.;kick:4/2=X..X.X..;hatClosed:4/2=xxxxxxxx"],
|
||||
["Samba (2/4)", "t102;tomLow:2/4=x...X...;hatClosed:2/4=xxxxxxxx;woodblock:2/4=X.xx.xX."],
|
||||
["Reggae one-drop", "t74;kick:4=..X.;rim:4=..X.;hatClosed:4/2=.x.x.x.x"],
|
||||
["Afrobeat", "t108;cowbell:4/4=x.xx.xx.x.xx.xx.;kick:4/4=X..x..X..x..X...;snare:4/4=....g..X..g..X.g;hatClosed:4/4=xxxxxxxxxxxxxxxx"],
|
||||
["Hip-hop (boom bap)", "t88;kick808:4/4=X.....x....X....;snare808:4/4=....X.......X...;hat808:4/4=x.x.x.x.x.x.x.x."],
|
||||
["Metal driving", "t168;kick:4/2=XxXxXxXx;snare:4/2=..X...X.;hatClosed:4/2=Xxxxxxxx;crash:4=X..."],
|
||||
["6/8 ballad", "t66;kick:3+3=X..X..;snare:3+3=...X..;hatClosed:3+3/2=xxxxxxxxxxxx"],
|
||||
["7/8 (2+2+3)", "t130;kick:2+2+3=X..X..X;snare:2+2+3=..X..X.;hatClosed:2+2+3/2"],
|
||||
["5/4 (3+2)", "t112;kick:3+2=X..X.;snare:3+2=..X..;hatClosed:3+2/2"],
|
||||
{ title: "🥁 Styles", description: "Grooves & feels — load one, press Space, and click pads to shape the accents.", items: [
|
||||
["Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"],
|
||||
["Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"],
|
||||
// Purdie half-time shuffle: triplet grid, backbeat on 3, snare ghosts (normal) around it
|
||||
["Purdie half-time shuffle", "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"],
|
||||
// Samba in 2/4 (16ths): surdo strong on beat 2, steady ganzá, tamborim teleco-teco
|
||||
["Samba (2/4)", "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."],
|
||||
// Nañigo / 6/8 bembé bell over a 12/8 grid, low drum on the two main pulses
|
||||
["Nañigo (6/8 bembé)", "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"],
|
||||
["6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"],
|
||||
["7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"],
|
||||
["5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"],
|
||||
] },
|
||||
{ title: "Practice", description: "Drills to learn the styles - isolations, independence, dynamics, polyrhythms, and tempo / gap tools.", items: [
|
||||
["Rock beat - quarter hats", "t80;kick:4=X.X.;snare:4=.X.X;hatClosed:4=xxxx"],
|
||||
["Rock beat - 8th hats", "t96;kick:4/2=X...Xx..;snare:4/2=..X...X.;hatClosed:4/2=xxxxxxxx"],
|
||||
["Backbeat + ghost notes", "t90;kick:4/4=X.....x...X.....;snare:4/4=..g.X.g.g.g.X.g.;hatClosed:4/2=xxxxxxxx"],
|
||||
["16th-note hand control", "t84;kick:4=X..X;snare:4/4=x.xxX.xxx.xxX.xx"],
|
||||
["Shuffle feel (triplets)", "t92;hatClosed:4/3=X.xX.xX.xX.x;snare:4/3=...X.....X..;kick:4/3=X.....X....."],
|
||||
["Jazz ride - spang-a-lang", "t140;ride:4/3=X.xX.xX.xX.x;hatClosed:4=.x.x;kick:4=x.x.@-9"],
|
||||
["Bossa independence", "t120;rim:4/4=..x.x...x..x..x.;kick:4/2=X..X.X..;hatClosed:4=.x.x"],
|
||||
["Linear funk", "t96;kick:4/4=X..x..X.....x...;snare:4/4=....X......X..g.;hatClosed:4/4=x.x.x.xxx.x.x.x."],
|
||||
["Accent / ghost dynamics", "t88;snare:4/4=X.g.x.g.X.g.x.g.;hatClosed:4/2=xxxxxxxx;kick:4=X..X"],
|
||||
["Double-bass workout", "t120;kick:4/4=xxxxxxxxxxxxxxxx;snare:4=.X.X;crash:4=X..."],
|
||||
["Independence: 3 over 4", "t96;kick:4;hatClosed:4/2=xxxxxxxx;cowbell:3~"],
|
||||
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
|
||||
["5 over 4 polyrhythm", "t100;kick:4;claves:5~"],
|
||||
["Tempo builder 80 up", "t80;kick:4=X.X.;snare:4=.X.X;hatClosed:4/2=xxxxxxxx;rmp80/4/4"],
|
||||
["Gap trainer (play 2 / rest 2)", "t100;kick:4/2=X...Xx..;snare:4/2=..X...X.;hatClosed:4/2=xxxxxxxx;tr2/2"],
|
||||
{ title: "🎯 Practice", description: "Polyrhythms, independence and tempo / gap tools.", items: [
|
||||
["5 over 4 polyrhythm", "t100;kick:4;claves:5~"],
|
||||
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
|
||||
["2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"],
|
||||
["Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"],
|
||||
["Accents — cycle the pads", "t92;kick:4=X..X;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Tempo builder 80↑", "t80;woodblock:4;rmp80/4/4"],
|
||||
["Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"],
|
||||
] },
|
||||
// A continuous ~4:00 song: each item has a bar length (b<n>) so it auto-advances (with
|
||||
// Continue on) through tempo ramps and shifting styles. Durations ≈ bars × beats × 60/bpm.
|
||||
{ title: "🎵 Song — continuous (~4:00)", description: "A full song: turn on Continue, press ▶ on “Intro”, and it plays straight through (~4 min) — segments auto-advance on their bar counts, through tempo ramps and shifting styles.", items: [
|
||||
["Intro — hats & kick", "t88;b8;kick:4=X.x.;hatClosed:4/2=gggggggg"],
|
||||
["Groove in — backbeat", "t88;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Half-time shuffle", "t92;b12;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"],
|
||||
["Build — ramp 92→120", "t92;b16;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Four-on-the-floor (909)", "t124;b18;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"],
|
||||
["Samba break (2/4)", "t116;b24;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."],
|
||||
["Peak — 16ths", "t132;b16;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"],
|
||||
["Outro — ramp down", "t132;b8;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"],
|
||||
] },
|
||||
];
|
||||
|
|
|
|||
313
stage.html
Normal file
313
stage.html
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PM_S‑1 — Stage (foot‑pedal stompbox)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
/* ?embed=1 → strip site chrome + auto-size to the host */
|
||||
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
|
||||
document.documentElement.dataset.embed="1";
|
||||
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
|
||||
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
|
||||
})();
|
||||
</script>
|
||||
<!--
|
||||
PM-1 "Stage" — the foot-operated live stompbox (the hands-free sibling of the
|
||||
desktop /teacher.html). Same RP2040 firmware/engine. Floor-driven controls:
|
||||
• LEFT footswitch = TAP tempo (tap to set BPM) ; hold = start / stop
|
||||
• RIGHT footswitch = NEXT set-list item ; hold = previous
|
||||
• 1/4" expression-pedal input = sweep tempo with your foot
|
||||
A big floor-readable RGB BEAT light + a small angled TFT (BPM, item, beats).
|
||||
Analog click injection (Inst in -> summed -> balanced TRS out) like the Teacher.
|
||||
Power: TWO USB-C ports — one data+power, one power-thru, so pedals daisy-chain
|
||||
off a single charger / power bank. Shares src/engine.js.
|
||||
-->
|
||||
<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:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bd:#2a313c; --device-bd:#33363c; --silk:#aab2bc; --cyan:#0AB3F7; }
|
||||
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4; --panel-bd:#d2dae4 }
|
||||
body{ margin:0; min-height:100vh; padding:26px 14px 46px;
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); color:var(--txt);
|
||||
display:flex; flex-direction:column; align-items:center; gap:16px }
|
||||
a{ color:var(--link) }
|
||||
|
||||
/* ---- the stompbox ---- */
|
||||
.device{ width:100%; max-width:340px; position:relative; border-radius:16px; padding:0 0 20px;
|
||||
background:
|
||||
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px,
|
||||
linear-gradient(180deg, #2b2d33, #141518);
|
||||
border:1px solid var(--device-bd);
|
||||
box-shadow:0 26px 52px rgba(0,0,0,.62), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -3px 10px rgba(0,0,0,.55) }
|
||||
|
||||
/* top edge — all the jacks (cables exit upward off the board) */
|
||||
.edge{ display:flex; align-items:flex-start; justify-content:space-between; gap:4px;
|
||||
padding:11px 12px 12px; border-radius:16px 16px 0 0; background:linear-gradient(180deg,#1c1e22,#0d0e11);
|
||||
border-bottom:1px solid #04060a; box-shadow:inset 0 -6px 12px rgba(0,0,0,.5) }
|
||||
.jk{ flex:1; display:flex; flex-direction:column; align-items:center; gap:4px }
|
||||
.jk i{ width:17px; height:17px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #333a44, #07090c 72%);
|
||||
border:2px solid #5b6470; box-shadow:inset 0 0 4px #000 }
|
||||
.jk.usb i{ width:19px; height:8px; border-radius:3px; border:2px solid #5b6470; background:#07090c }
|
||||
.jk b{ font-size:6.5px; font-weight:700; color:var(--silk); letter-spacing:.03em; text-transform:uppercase; opacity:.9; text-align:center; line-height:1.2 }
|
||||
|
||||
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:13px 16px 10px }
|
||||
.dev-logo{ height:22px }
|
||||
.silk{ display:flex; align-items:center; gap:7px; color:var(--silk) }
|
||||
.silk .model{ font-size:8.5px; text-transform:uppercase; letter-spacing:.16em; opacity:.85 }
|
||||
.pwr{ display:flex; align-items:center; gap:5px; font-size:7.5px; color:var(--silk); text-transform:uppercase; letter-spacing:.12em; opacity:.85 }
|
||||
.pwr .dot{ width:6px; height:6px; border-radius:50%; background:#2fe07a; box-shadow:0 0 6px #2fe07a }
|
||||
|
||||
/* small angled TFT */
|
||||
.tft-wrap{ margin:0 16px; padding:7px; border-radius:9px; background:linear-gradient(180deg,#0b0d11,#05070a);
|
||||
border:1px solid #04060a; box-shadow:inset 0 2px 8px rgba(0,0,0,.7);
|
||||
transform:perspective(440px) rotateX(7deg) }
|
||||
#tft{ display:block; width:100%; height:96px; border-radius:5px; background:#06080c }
|
||||
|
||||
/* big floor-readable RGB beat light (dome LED) */
|
||||
.beat-wrap{ display:flex; flex-direction:column; align-items:center; gap:5px; margin:16px 0 4px }
|
||||
.beatlight{ width:74px; height:74px; border-radius:50%; position:relative;
|
||||
background:radial-gradient(circle at 42% 36%, #20242b, #0a0c10 72%);
|
||||
border:3px solid #2a2f37; box-shadow:0 3px 8px rgba(0,0,0,.55), inset 0 2px 5px rgba(255,255,255,.06) }
|
||||
.beatlight::after{ content:""; position:absolute; inset:11px; border-radius:50%;
|
||||
background:var(--bc,#0c0f14); box-shadow:0 0 var(--bg-glow,0) var(--bc,#0c0f14); transition:none }
|
||||
.beat-cap{ font-size:7.5px; color:var(--silk); letter-spacing:.16em; text-transform:uppercase; opacity:.8 }
|
||||
|
||||
/* expression-pedal stand-in: a rocker that sweeps tempo */
|
||||
.exp{ margin:14px 18px 4px; display:flex; align-items:center; gap:10px }
|
||||
.exp label{ font-size:7.5px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.85; white-space:nowrap }
|
||||
.exp input[type=range]{ flex:1; -webkit-appearance:none; appearance:none; height:8px; border-radius:5px; outline:none;
|
||||
background:linear-gradient(90deg,#1b2733,#33424f); border:1px solid #04060a }
|
||||
.exp input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; width:26px; height:18px; border-radius:4px; cursor:ew-resize;
|
||||
background:linear-gradient(180deg,#444c57,#222730); border:1px solid #565f6c; box-shadow:0 2px 4px rgba(0,0,0,.5) }
|
||||
.exp input[type=range]::-moz-range-thumb{ width:26px; height:18px; border-radius:4px; cursor:ew-resize;
|
||||
background:linear-gradient(180deg,#444c57,#222730); border:1px solid #565f6c }
|
||||
|
||||
/* two heavy footswitches */
|
||||
.switches{ display:flex; justify-content:space-around; gap:18px; margin:14px 14px 2px }
|
||||
.fsw{ flex:1; display:flex; flex-direction:column; align-items:center; gap:8px }
|
||||
.stomp{ width:78px; height:78px; border-radius:50%; cursor:pointer; position:relative; border:0; padding:0; touch-action:none;
|
||||
background:radial-gradient(circle at 38% 30%, #eef2f6, #aab2bc 40%, #6c7480 70%, #3b424c 100%);
|
||||
box-shadow:0 6px 10px rgba(0,0,0,.55), inset 0 -3px 6px rgba(0,0,0,.4), inset 0 3px 5px rgba(255,255,255,.5) }
|
||||
.stomp::after{ content:""; position:absolute; inset:18px; border-radius:50%;
|
||||
background:radial-gradient(circle at 40% 34%, #d7dde3, #8b939e 70%, #5a626c 100%);
|
||||
box-shadow:inset 0 2px 4px rgba(255,255,255,.5), inset 0 -3px 6px rgba(0,0,0,.4) }
|
||||
.stomp.down{ transform:translateY(3px); box-shadow:0 2px 4px rgba(0,0,0,.55), inset 0 -2px 4px rgba(0,0,0,.4), inset 0 2px 4px rgba(255,255,255,.4) }
|
||||
.fsw b{ font-size:9px; font-weight:800; color:var(--silk); letter-spacing:.12em; text-transform:uppercase }
|
||||
.fsw small{ font-size:7px; color:var(--muted); letter-spacing:.04em; text-align:center; line-height:1.3 }
|
||||
|
||||
.hint{ max-width:340px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
|
||||
[data-embed] .hint{ display:none !important }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<h1 class="ff-title">PM_S‑1 Stage</h1>
|
||||
<p class="ff-sum">Foot‑pedal stompbox for the stage — hands‑free with two footswitches and an expression pedal, a big floor‑readable RGB beat light, instrument pass‑through with the click mixed in.</p>
|
||||
|
||||
<div class="device">
|
||||
<!-- top edge: all jacks, including dual USB-C daisy-chain power -->
|
||||
<div class="edge">
|
||||
<div class="jk" title="External trigger / aux footswitch in"><i></i><b>Trig</b></div>
|
||||
<div class="jk" title="Instrument in — 1/4" pass-through; the click is mixed into your signal"><i></i><b>Inst In</b></div>
|
||||
<div class="jk" title="Main out — 1/4" balanced TRS (instrument + click)"><i></i><b>Out TRS</b></div>
|
||||
<div class="jk" title="Expression-pedal input — sweep tempo with your foot"><i></i><b>Exp</b></div>
|
||||
<div class="jk usb" title="USB-C — power + data (config / firmware)"><i></i><b>USB‑C</b></div>
|
||||
<div class="jk usb" title="USB-C — power thru (daisy-chain the next pedal)"><i></i><b>USB‑C thru</b></div>
|
||||
</div>
|
||||
|
||||
<div class="brandrow">
|
||||
<div class="silk"><span class="dev-lock"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" /></span><span class="model">PM_S‑1 Stage</span></div>
|
||||
<div class="pwr"><span class="dot"></span>USB‑C PWR</div>
|
||||
</div>
|
||||
|
||||
<div class="tft-wrap"><canvas id="tft" width="300" height="96" aria-label="tempo / item display"></canvas></div>
|
||||
|
||||
<div class="beat-wrap">
|
||||
<div class="beatlight" id="beat"></div>
|
||||
<div class="beat-cap">Beat</div>
|
||||
</div>
|
||||
|
||||
<div class="exp">
|
||||
<label for="expPedal">Exp pedal<br>(tempo)</label>
|
||||
<input type="range" id="expPedal" min="40" max="240" value="120" title="External expression pedal → tempo sweep">
|
||||
</div>
|
||||
|
||||
<div class="switches">
|
||||
<div class="fsw">
|
||||
<button class="stomp" id="swTap" title="Tap to set tempo · hold to start/stop"></button>
|
||||
<b>Tap</b><small>tap = tempo<br>hold = start/stop</small>
|
||||
</div>
|
||||
<div class="fsw">
|
||||
<button class="stomp" id="swNext" title="Next item · hold for previous"></button>
|
||||
<b>Next</b><small>tap = next<br>hold = previous</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint">Stomp <b>Tap</b> to set tempo (hold = start/stop) · <b>Next</b> to change item (hold = previous) ·
|
||||
an expression pedal sweeps tempo. Instrument passes through with the click mixed in (analog).</div>
|
||||
|
||||
/*@BUILD:include:src/progbox.html@*/
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* ========================= ENGINE (shared; synth voices only) ================= */
|
||||
const SAMPLES = {};
|
||||
/*@BUILD:include:src/engine.js@*/
|
||||
/*@BUILD:include:src/setlists.js@*/
|
||||
const state = { bpm:120, volume:0.85, running:false };
|
||||
let meters = [], muteWindows = [];
|
||||
|
||||
function setBpm(v){ state.bpm = Math.max(30, Math.min(300, Math.round(v))); if(window.progRefresh) progRefresh(); }
|
||||
function scheduler(){
|
||||
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
|
||||
for(const m of meters){ while(m.nextTime < ahead){ scheduleMeterTick(m, m.nextTime); m.nextTime += laneStepDur(m, m.tick); m.tick++; } }
|
||||
}
|
||||
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(),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}; });
|
||||
}
|
||||
function startAudio(){
|
||||
ensureAudio(); audioCtx.resume(); state.running=true;
|
||||
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; }
|
||||
beatCount=-1; lastBeatTime=t0; muteWindows=[];
|
||||
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
|
||||
}
|
||||
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; }
|
||||
function toggle(){ state.running ? stopAudio() : startAudio(); }
|
||||
|
||||
/* ========================= TRACKS (seed grooves, flattened) =================== */
|
||||
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
|
||||
let trackIdx = 0;
|
||||
function tracksFromHash(){
|
||||
const m=(location.hash||"").match(/[#&](p|sl)=([^&]+)/); if(!m) return null;
|
||||
let payload=m[2]; try{ payload=decodeURIComponent(payload); }catch(e){}
|
||||
try{
|
||||
if(m[1]==="sl"){ const sl=codeToSetlist(payload); return sl.items.length ? sl.items.map(it=>({...it})) : null; }
|
||||
const s=patchToSetup(payload); return s.lanes.length ? [{name:"Patch",...s}] : null;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
function loadTrack(i){
|
||||
const n=tracks.length; if(!n) return; trackIdx=((i%n)+n)%n;
|
||||
const t=tracks[trackIdx]; setBpm(t.bpm||120); $("expPedal").value=state.bpm; meters=buildMeters(t.lanes);
|
||||
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
|
||||
if(was) startAudio();
|
||||
flashName=performance.now();
|
||||
}
|
||||
|
||||
/* ========================= DISPLAY (TFT) + BEAT LIGHT ======================== */
|
||||
const tft=$("tft"), tc=tft.getContext("2d"), TW=300, TH=96;
|
||||
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); tft.width=TW*dpr; tft.height=TH*dpr; tc.scale(dpr,dpr); })();
|
||||
let beatCount=-1, lastBeatTime=0, flash=0, flashAccent=false, flashName=0;
|
||||
|
||||
function masterLane(){ return meters[0]; }
|
||||
function drawTFT(){
|
||||
tc.fillStyle="#06080c"; tc.fillRect(0,0,TW,TH);
|
||||
// tempo
|
||||
tc.fillStyle="#eaf6ff"; tc.font="700 40px 'Segoe UI',Roboto,Arial,sans-serif"; tc.textBaseline="alphabetic";
|
||||
tc.fillText(String(state.bpm), 12, 50);
|
||||
tc.fillStyle="#5b86a3"; tc.font="600 11px 'Segoe UI',Roboto,Arial,sans-serif"; tc.fillText("BPM", 14, 64);
|
||||
// running state
|
||||
tc.fillStyle=state.running?"#2fe07a":"#7f8b9a"; tc.font="700 11px 'Segoe UI',Roboto,Arial,sans-serif";
|
||||
tc.textAlign="right"; tc.fillText(state.running?"▶ RUN":"■ STOP", TW-12, 22); tc.textAlign="left";
|
||||
// item name
|
||||
const nm=(tracks[trackIdx]&&tracks[trackIdx].name)||"—";
|
||||
tc.fillStyle="#c7d0db"; tc.font="600 13px 'Segoe UI',Roboto,Arial,sans-serif";
|
||||
tc.fillText(nm.length>22?nm.slice(0,21)+"…":nm, 12, 84);
|
||||
// beat dots (master lane)
|
||||
const m=masterLane();
|
||||
if(m){ const bpb=m.beatsPerBar, r=4, gap=13, x0=TW-12-(bpb-1)*gap, y=46;
|
||||
const curBeat = m.currentStep>=0 ? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
|
||||
for(let i=0;i<bpb;i++){ const on = state.running && i===curBeat;
|
||||
tc.beginPath(); tc.arc(x0+i*gap, y, r, 0, 7);
|
||||
tc.fillStyle = on ? (m.groupStarts.has(i)?"#ff9b2e":"#33d0ff") : "#243240"; tc.fill(); } }
|
||||
}
|
||||
function setBeatLight(){
|
||||
const el=$("beat"), c=flashAccent?"#ff9b2e":"#33d0ff";
|
||||
const lit = Math.max(0, flash);
|
||||
el.style.setProperty("--bc", lit>0.02 ? c : "#0c0f14");
|
||||
el.style.setProperty("--bg-glow", (10+lit*34).toFixed(0)+"px");
|
||||
el.style.filter = "brightness("+(1+lit*1.3)+")";
|
||||
}
|
||||
// Visuals follow the SAME clock the audio is scheduled on, but compensated for
|
||||
// output latency so the on-screen pulse lands when the click is *heard* (not when
|
||||
// it's queued). Without this the visual leads the sound by the output buffer /
|
||||
// Bluetooth latency — up to a full subdivision on high-latency outputs.
|
||||
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
|
||||
function draw(){
|
||||
const now = audioCtx ? audioCtx.currentTime - audioLatency() : 0;
|
||||
if(audioCtx && state.running){
|
||||
for(const m of meters){ while(m.vqPtr<m.vq.length && m.vq[m.vqPtr].time<=now){
|
||||
const e=m.vq[m.vqPtr]; m.currentStep=e.step; m.currentBar=e.bar;
|
||||
if(m===meters[0] && e.step % m.stepsPerBeat === 0){ // a beat on the master lane
|
||||
beatCount++; lastBeatTime=e.time;
|
||||
const lvl=m.beatsOn[e.step]|0; flashAccent = lvl>=2 || m.groupStarts.has(e.step/m.stepsPerBeat);
|
||||
if(lvl!==0) flash=1; // don't flash on a muted beat
|
||||
}
|
||||
m.vqPtr++;
|
||||
}
|
||||
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; } // bound the visual queue
|
||||
}
|
||||
}
|
||||
flash = Math.max(0, flash - 0.085); // decay
|
||||
drawTFT(); setBeatLight();
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
/* ========================= FOOTSWITCHES + EXP PEDAL ========================== */
|
||||
function holdSwitch(el, onTap, onHold){
|
||||
let t=null, held=false;
|
||||
el.addEventListener("pointerdown",(e)=>{ e.preventDefault(); try{el.setPointerCapture(e.pointerId);}catch(_){}
|
||||
held=false; el.classList.add("down"); t=setTimeout(()=>{ held=true; onHold&&onHold(); }, 480); });
|
||||
const end=(fire)=>{ if(t){clearTimeout(t); t=null;} el.classList.remove("down"); if(fire && !held) onTap&&onTap(); held=false; };
|
||||
el.addEventListener("pointerup",()=>end(true));
|
||||
el.addEventListener("pointercancel",()=>end(false));
|
||||
}
|
||||
let taps=[];
|
||||
function tapTempo(){ const now=performance.now(); taps.push(now); taps=taps.filter(x=>now-x<2400);
|
||||
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1];
|
||||
const bpm=Math.round(60000/(s/(taps.length-1))); if(bpm>=30&&bpm<=300){ setBpm(bpm); $("expPedal").value=state.bpm; } } }
|
||||
holdSwitch($("swTap"), ()=>tapTempo(), ()=>toggle());
|
||||
holdSwitch($("swNext"), ()=>loadTrack(trackIdx+1), ()=>loadTrack(trackIdx-1));
|
||||
$("expPedal").addEventListener("input", (e)=>{ setBpm(+e.target.value); });
|
||||
|
||||
/* theme toggle (shared "metronome.theme") */
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
|
||||
addEventListener("keydown",(e)=>{
|
||||
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
|
||||
if(e.key===" "){ e.preventDefault(); toggle(); }
|
||||
else if(e.key==="t"||e.key==="T"){ tapTempo(); }
|
||||
else if(e.key==="ArrowRight"){ loadTrack(trackIdx+1); }
|
||||
else if(e.key==="ArrowLeft"){ loadTrack(trackIdx-1); }
|
||||
});
|
||||
|
||||
/* ========================= INIT ============================================== */
|
||||
{ const ht=tracksFromHash(); if(ht) tracks=ht; }
|
||||
loadTrack(0);
|
||||
requestAnimationFrame(draw);
|
||||
window.currentProgramString = function(){ var t=tracks[trackIdx]||{}; return setupToPatch({bpm:state.bpm, volume:state.volume, lanes:t.lanes||[]}); };
|
||||
window.loadProgramString = function(plain){ var s=patchToSetup(plain); tracks=[{name:"Program", ...s}]; trackIdx=0; loadTrack(0); };
|
||||
/*@BUILD:include:src/progbox.js@*/
|
||||
</script>
|
||||
|
||||
<p class="ff-link pageonly"><a href="/info-stage.html">Purpose, dimensions & bill of materials →</a></p>
|
||||
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
</body>
|
||||
</html>
|
||||
515
teacher.html
Normal file
515
teacher.html
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PM_T‑1 — Teacher (studio / lesson console)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<script>
|
||||
/* ?embed=1 → strip site chrome (base.css [data-embed]) + auto-size to the host */
|
||||
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
|
||||
document.documentElement.dataset.embed="1";
|
||||
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
|
||||
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
|
||||
})();
|
||||
</script>
|
||||
<!--
|
||||
PM-1 "Teacher" — the full-feature desktop console (formerly "Stage"): the same
|
||||
firmware/engine, drawn with the parts you'd actually solder for an RP2040 build.
|
||||
It's the hands-on unit for a studio desk or a lesson — a big screen showing every
|
||||
lane, real buttons, and an instrument pass-through. (The foot-operated live unit
|
||||
is the separate /stage.html stompbox.) —
|
||||
• a 2.0″ 320×240 colour IPS TFT (ST7789, e.g. Pimoroni Pico Display 2.0):
|
||||
the upgrade from the cramped 128×64 mono OLED — full colour, smooth type,
|
||||
hi-DPI. It also draws all lane patterns (each lane's steps — accent /
|
||||
normal / ghost / mute — with the playhead), so there's no separate LED bar;
|
||||
• controls: a big PLAY up top (a plain arcade button — it does NOT change
|
||||
while playing; the screen shows transport state; an illuminated/RGB arcade
|
||||
button could reflect it), then a row of PREV (far left), a recessed
|
||||
thumb-roller for tempo + TAP (centre), and NEXT (far right);
|
||||
• top-edge I/O (all jacks on the top edge — cables run up off a pedalboard;
|
||||
the top view shows the ~45 mm total thickness): external trigger in
|
||||
(footswitch), a 1/4" instrument pass-through
|
||||
with the click mixed in the ANALOG domain (DAC → summing op-amp → balanced
|
||||
line driver), a shared 1/4" balanced-TRS main out, plus an analog monitor
|
||||
amp + speaker. Powered over USB-C (a wall adapter or a power bank; also
|
||||
carries config), in a bead-blasted matte-black anodised aluminium
|
||||
enclosure (no glare on stage).
|
||||
Beside the device: a top-edge view and a bill of materials. The front and top
|
||||
views carry inch dimensions (≈ 4.7 × 5.5 × 1.8 in / 120 × 140 × 45 mm).
|
||||
Compare with the initial /player.html. One file, no deps; shares src/engine.js.
|
||||
-->
|
||||
<script>
|
||||
// Set theme before first paint (shared "metronome.theme" with the editor / player).
|
||||
(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{
|
||||
/* environment (themed) — the page around the device */
|
||||
--bg1:#12151c; --bg2:#05070a;
|
||||
--txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
|
||||
--panel-bg:#171b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
|
||||
/* device — bead-blasted matte-black anodised aluminium (fixed in both themes);
|
||||
--silk is the light laser-etched legend colour on the dark case */
|
||||
--case:#26282d; --case2:#15161a; --device-bd:#33363c; --silk:#aab2bc;
|
||||
--pcb:#0d2620; --oled-bezel:#04060a; --metal:#3a424e;
|
||||
}
|
||||
:root[data-theme="light"]{
|
||||
--bg1:#f5f8fc; --bg2:#dde4ec;
|
||||
--txt:#1e2630; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
|
||||
}
|
||||
body{
|
||||
margin:0; min-height:100vh; padding:22px 12px 40px;
|
||||
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2));
|
||||
color:var(--txt);
|
||||
display:flex; flex-direction:column; align-items:center; gap:14px;
|
||||
}
|
||||
a{color:var(--link)}
|
||||
.topbar{width:100%; max-width:778px; display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:13px; color:var(--muted); flex-wrap:wrap}
|
||||
.topbar b{color:var(--txt)}
|
||||
.topbar-right{ display:flex; align-items:center; gap:12px }
|
||||
.tbtn{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px;
|
||||
padding:3px 9px; font-size:14px; line-height:1; cursor:pointer }
|
||||
.tbtn:hover{ color:var(--txt) }
|
||||
|
||||
/* ---- the device: bead-blasted matte-black anodised aluminium ---- */
|
||||
.device{
|
||||
width:100%; max-width:380px; position:relative;
|
||||
background:
|
||||
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px, /* bead-blast micro-texture */
|
||||
linear-gradient(160deg, var(--case), var(--case2)); /* matte anodised graphite */
|
||||
border:1px solid var(--device-bd); border-radius:13px; padding:16px 14px 14px;
|
||||
box-shadow:0 22px 50px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -2px 8px rgba(0,0,0,.5);
|
||||
}
|
||||
.brandrow{ display:flex; align-items:flex-end; justify-content:space-between; margin:0 2px 12px; }
|
||||
.silk{ display:flex; align-items:center; gap:8px; color:var(--silk); letter-spacing:.04em }
|
||||
.dev-logo{ height:24px }
|
||||
.silk .model{ font-size:10px; text-transform:uppercase; letter-spacing:.18em; opacity:.8 }
|
||||
.pwr{ display:flex; align-items:center; gap:6px; font-size:9px; color:var(--silk); text-transform:uppercase; letter-spacing:.14em; opacity:.85 }
|
||||
.pwr .dot{ width:7px; height:7px; border-radius:50%; background:#2fe07a; box-shadow:0 0 7px #2fe07a }
|
||||
|
||||
/* ---- 2.0″ 320×240 colour IPS TFT (the OLED upgrade) ---- */
|
||||
.tft-wrap{ display:flex; justify-content:center; margin:0 4px }
|
||||
.tft-mod{ background:#000; border-radius:10px; padding:9px;
|
||||
box-shadow:inset 0 0 0 2px #15181d, inset 0 0 0 3px #000, 0 3px 9px rgba(0,0,0,.55) }
|
||||
#tft{ display:block; width:320px; height:240px; max-width:100%; border-radius:4px; background:#06080c;
|
||||
box-shadow:0 0 0 1px #000, inset 0 0 18px rgba(0,0,0,.45) }
|
||||
.tft-cap{ text-align:center; font-size:10px; color:var(--silk); opacity:.8; margin-top:7px; letter-spacing:.02em }
|
||||
|
||||
/* small caption under the screen / I/O (the beat indicator lives on the TFT now) */
|
||||
.ledbar-cap{ text-align:center; font-size:10px; color:var(--muted); margin:2px 0 0; letter-spacing:.02em }
|
||||
|
||||
/* device column (centred; the priced BOM now lives on the info page) */
|
||||
.cols{ display:flex; flex-wrap:wrap; align-items:flex-start; justify-content:center; gap:18px; width:100% }
|
||||
.col-left{ display:flex; flex-direction:column; align-items:center; gap:14px; width:380px; max-width:100% }
|
||||
|
||||
/* ---- top-edge view: all connectors on the top + total thickness ---- */
|
||||
.topview{ width:380px; max-width:100%; display:flex; flex-direction:column; gap:5px }
|
||||
.tv-cap{ text-align:center; font-size:10px; color:var(--muted); letter-spacing:.02em }
|
||||
.tv-row{ display:flex; align-items:stretch; gap:10px }
|
||||
.tv-edge{ flex:1; border-radius:8px; padding:11px 10px 9px;
|
||||
background:
|
||||
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px,
|
||||
linear-gradient(160deg, var(--case), var(--case2));
|
||||
border:1px solid var(--device-bd); box-shadow:inset 0 1px 0 rgba(255,255,255,.05), 0 12px 24px rgba(0,0,0,.5);
|
||||
display:flex; align-items:flex-start; justify-content:space-between; gap:5px }
|
||||
.tv-jack{ flex:1; display:flex; flex-direction:column; align-items:center; gap:5px }
|
||||
.tv-jack i{ width:19px; height:19px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #2b313a, #05070a 72%);
|
||||
border:2px solid #4a525c; box-shadow:inset 0 0 4px #000 }
|
||||
.tv-jack.dc i{ background:radial-gradient(circle, #6b7480 0 2.5px, #07090c 3.5px 72%) }
|
||||
.tv-jack.usb i{ width:23px; height:9px; border-radius:4px; border:2px solid #4a525c; background:#05070a; margin-top:5px }
|
||||
.tv-jack b{ font-size:7px; font-weight:700; color:var(--silk); letter-spacing:.04em; text-transform:uppercase; text-align:center; line-height:1.25; opacity:.9 }
|
||||
.tv-dim{ display:flex; flex-direction:column; align-items:center; justify-content:center; font-size:9px; color:var(--muted); letter-spacing:.03em; line-height:1.05; white-space:nowrap }
|
||||
.tv-dim .ar{ font-size:40px; line-height:.55; opacity:.5 }
|
||||
|
||||
/* ---- dimensioned views: top edge + front share a left gutter, so they're the
|
||||
same width and aligned; inch dims (thickness/height on the left, width below) ---- */
|
||||
.dim-row{ display:flex; align-items:stretch; gap:6px; width:100% }
|
||||
.dim-y{ flex:0 0 13px; writing-mode:vertical-rl; transform:rotate(180deg);
|
||||
display:flex; align-items:center; justify-content:center; text-align:center;
|
||||
font-size:8.5px; color:var(--muted); letter-spacing:.04em; white-space:nowrap; border-right:1px solid var(--panel-bd) }
|
||||
.dim-row > .device, .dim-row > .tv-edge{ flex:1 1 0; min-width:0; max-width:none; width:auto }
|
||||
.dim-x{ text-align:center; font-size:8.5px; color:var(--muted); letter-spacing:.04em;
|
||||
border-top:1px solid var(--panel-bd); padding-top:3px; margin:3px 0 0 19px }
|
||||
|
||||
/* ---- controls: encoder above (under the screen), arcade buttons spread below ----
|
||||
wheel never hides the readout · PREV far-left / NEXT far-right · big central PLAY */
|
||||
.controls{ display:flex; flex-direction:column; align-items:center; gap:13px; margin:14px 0 2px }
|
||||
.enc-wrap{ display:flex; flex-direction:column; align-items:center; gap:5px }
|
||||
/* recessed thumb-roller (side-mount encoder wheel) — only the edge is exposed */
|
||||
.roller{ width:28px; height:50px; border-radius:7px; cursor:ns-resize; position:relative; touch-action:none; overflow:hidden;
|
||||
background:#0a0d12; border:1px solid #04060a; box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 2px 4px rgba(0,0,0,.5) }
|
||||
.roller::before{ content:""; position:absolute; inset:2px 4px; border-radius:4px; /* ribbed wheel surface, scrolls via --rib */
|
||||
background:repeating-linear-gradient(0deg, rgba(255,255,255,.11) 0 1px, rgba(0,0,0,.5) 1px 4px);
|
||||
background-position:0 var(--rib,0px) }
|
||||
.roller::after{ content:""; position:absolute; inset:0; border-radius:7px; pointer-events:none; /* cylinder sheen: bright centre, curving dark at top/bottom */
|
||||
background:linear-gradient(180deg, rgba(0,0,0,.72) 0%, rgba(255,255,255,.12) 48%, rgba(255,255,255,.12) 52%, rgba(0,0,0,.72) 100%) }
|
||||
.enc-wrap small{ font-size:8px; color:var(--silk); letter-spacing:.12em; opacity:.85 }
|
||||
.keys{ display:flex; align-items:flex-end; justify-content:space-between; width:100%; padding:0 2px }
|
||||
.key-mid{ display:flex; align-items:flex-end; gap:20px }
|
||||
.key{ display:flex; flex-direction:column; align-items:center; gap:6px }
|
||||
.abtn{ width:50px; height:50px; border-radius:50%; border:0; padding:0; cursor:pointer; position:relative; color:#fff; font-size:18px; line-height:1;
|
||||
background:radial-gradient(circle at 36% 30%, rgba(255,255,255,.6), rgba(255,255,255,0) 42%), radial-gradient(circle at 50% 64%, var(--c1,#33d0ff), var(--c2,#0a7fb0));
|
||||
box-shadow:0 0 0 3px #0b0d11, 0 0 0 4px #363c46, 0 5px 7px rgba(0,0,0,.5), inset 0 -3px 6px rgba(0,0,0,.35), inset 0 2px 4px rgba(255,255,255,.28);
|
||||
text-shadow:0 1px 2px rgba(0,0,0,.45); user-select:none; transition:transform .05s, box-shadow .05s, filter .05s }
|
||||
.abtn:active{ transform:translateY(2px); filter:brightness(.92);
|
||||
box-shadow:0 0 0 3px #0b0d11, 0 0 0 4px #363c46, 0 2px 3px rgba(0,0,0,.5), inset 0 -2px 4px rgba(0,0,0,.4), inset 0 2px 4px rgba(255,255,255,.2) }
|
||||
.abtn.nav{ --c1:#33d0ff; --c2:#0a7fb0 }
|
||||
.abtn.tap{ --c1:#ffd56a; --c2:#c98a1f; color:#3a2a00; text-shadow:0 1px 1px rgba(255,255,255,.35); font-size:12px; font-weight:800; letter-spacing:.04em }
|
||||
.abtn.play{ --c1:#4ce08e; --c2:#178f49; width:66px; height:66px; font-size:26px } /* static: no play/stop change (plain arcade button; screen shows state) */
|
||||
.key small{ font-size:8px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.85 }
|
||||
|
||||
/* ---- monitor speaker + rear I/O (1/4" jacks + USB-C) ---- */
|
||||
.grille{ height:11px; margin:13px 8px 9px; border-radius:5px;
|
||||
background:radial-gradient(circle, #000 1.1px, transparent 1.4px) 0 0/8px 8px; opacity:.5 }
|
||||
.io{ display:flex; align-items:flex-start; justify-content:space-between; gap:6px; margin:0 2px;
|
||||
padding:9px 8px 8px; border-radius:9px; background:#0c0f14; border:1px solid #05070a; box-shadow:inset 0 1px 3px rgba(0,0,0,.6) }
|
||||
.jack{ flex:1; display:flex; flex-direction:column; align-items:center; gap:5px }
|
||||
.jack i{ width:20px; height:20px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #333a44, #07090c 72%);
|
||||
border:2px solid #5b6470; box-shadow:inset 0 0 4px #000 }
|
||||
.jack b{ font-size:7.5px; font-weight:700; color:#8f9aa6; letter-spacing:.05em; text-transform:uppercase; opacity:.9; text-align:center; line-height:1.3 }
|
||||
.jack.usb i{ width:24px; height:10px; border-radius:4px; border:2px solid #5b6470; background:#07090c; margin-top:5px }
|
||||
.jack.dc i{ background:radial-gradient(circle, #6b7480 0 2.5px, #0a0d11 3.5px 72%) } /* DC barrel: centre pin */
|
||||
|
||||
/* ---- load panel (same as the other pages) ---- */
|
||||
.panel{ width:100%; max-width:380px; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:16px }
|
||||
.panel h2{ margin:0 0 4px; font-size:15px }
|
||||
.panel p.sub{ margin:0 0 12px; font-size:12px; color:var(--muted); line-height:1.45 }
|
||||
textarea{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:9px;
|
||||
padding:10px; font-family:"Courier New",monospace; font-size:12px; resize:vertical; min-height:54px }
|
||||
select, .ld{ border-radius:9px; padding:8px 10px; font-size:13px }
|
||||
select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd) }
|
||||
.ld{ cursor:pointer; color:#d4dbe4; background:linear-gradient(180deg,#2b323d,#1b212a); border:1px solid #39424f }
|
||||
.row{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px }
|
||||
.status{ margin-top:10px; font-size:12px; min-height:1.2em; font-family:"Courier New",monospace }
|
||||
.status.ok{ color:#5fd08a } .status.err{ color:#ff7a7a }
|
||||
.hint{ font-size:11px; color:var(--muted) }
|
||||
code{ background:var(--field-bg); border:1px solid var(--field-bd); border-radius:4px; padding:1px 5px; font-size:11px }
|
||||
|
||||
/* link out to the spec & BOM info page (the priced BOM lives there now) */
|
||||
.speclink{ width:100%; max-width:380px; margin:12px 0 0; font-size:12px; color:var(--muted); line-height:1.5 }
|
||||
.speclink a{ color:var(--link); font-weight:600 }
|
||||
/* embed mode: just the device (drop the top-edge view, dims, loader, spec link) */
|
||||
[data-embed] .topview, [data-embed] .panel, [data-embed] .speclink,
|
||||
[data-embed] .dim-y, [data-embed] .dim-x { display:none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
/*@BUILD:include:src/header.html@*/
|
||||
|
||||
<h1 class="ff-title">PM_T‑1 Teacher</h1>
|
||||
<p class="ff-sum">Full‑feature studio / lesson desk console — a colour TFT showing every lane, arcade buttons and a thumb‑roller, with your instrument running through and the click mixed in.</p>
|
||||
|
||||
<div class="cols">
|
||||
<div class="col-left">
|
||||
|
||||
<!-- ===================== TOP EDGE (connectors) — technical view, hidden until "Show info" ===================== -->
|
||||
<div class="topview tech" hidden>
|
||||
<div class="tv-cap">Top edge — all connectors (cables exit upward; pedalboard-friendly)</div>
|
||||
<div class="dim-row">
|
||||
<div class="dim-y">↕ 1.8 in (45 mm)</div>
|
||||
<div class="tv-edge">
|
||||
<div class="tv-jack" title="External trigger in — footswitch to start/stop or tap tempo"><i></i><b>Trig In</b></div>
|
||||
<div class="tv-jack" title="Instrument in — 1/4" pass-through; the click is mixed into your signal"><i></i><b>Inst In</b></div>
|
||||
<div class="tv-jack" title="Main out — 1/4" balanced TRS (instrument + click); the shared output plug"><i></i><b>Out TRS</b></div>
|
||||
<div class="tv-jack usb" title="USB-C — power (wall adapter or power bank) & set-list transfer"><i></i><b>USB-C</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ledbar-cap">Trig in · 1/4″ inst pass‑through (click injected) · shared 1/4″ balanced‑TRS out · USB‑C power</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== THE DEVICE (front view) ===================== -->
|
||||
<div class="dim-row">
|
||||
<div class="dim-y tech" hidden>↕ 5.5 in (140 mm)</div>
|
||||
<div class="device">
|
||||
|
||||
<div class="brandrow">
|
||||
<div class="silk"><span class="dev-lock"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" /></span><span class="model">PM_T‑1 Teacher</span></div>
|
||||
<div class="pwr"><span class="dot"></span>PWR</div>
|
||||
</div>
|
||||
|
||||
<div class="tft-wrap">
|
||||
<div class="tft-mod">
|
||||
<canvas id="tft" width="320" height="240" aria-label="2 inch 320 by 240 colour IPS display"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tft-cap">2.0″ 320×240 IPS TFT (ST7789) — tempo, name & all lane patterns</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="key"><button class="abtn play" id="bPlay" title="play / stop (Space)">▶</button><small>Play / Stop</small></div>
|
||||
<div class="keys">
|
||||
<div class="key"><button class="abtn nav" id="bPrev" title="previous item">⏮</button><small>Prev</small></div>
|
||||
<div class="key-mid">
|
||||
<div class="enc-wrap"><div class="roller" id="enc" title="Tempo — roll it (scroll or drag)"></div><small>TEMPO</small></div>
|
||||
<div class="key"><button class="abtn tap" id="bTap" title="tap tempo (T)">TAP</button><small>Tap</small></div>
|
||||
</div>
|
||||
<div class="key"><button class="abtn nav" id="bNext" title="next item">⏭</button><small>Next</small></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grille"></div>
|
||||
</div><!-- /device -->
|
||||
</div><!-- /dim-row -->
|
||||
<div class="dim-x tech" hidden>↔ 4.7 in (120 mm) wide</div>
|
||||
|
||||
<!-- ===================== LOAD CONFIG ===================== -->
|
||||
<div class="panel">
|
||||
<h2>Load a configuration onto the device</h2>
|
||||
<p class="sub">Same firmware as the initial unit — only the panel hardware differs. Paste a <b>patch</b>
|
||||
(e.g. <code>v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2</code>), a <b>set‑list code</b>, or a
|
||||
<code>#p=…</code>/<code>#sl=…</code> link.</p>
|
||||
<label for="cfg" class="hint">Patch / set‑list code / share link</label>
|
||||
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2 …or a #sl=… link / base64 set-list code"></textarea>
|
||||
<div class="row">
|
||||
<button class="ld" id="bLoad">Load onto device</button>
|
||||
<span class="hint">or pick a built-in or saved set list:</span>
|
||||
<select id="storedSel"><option value="">— choose a set list —</option></select>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
</div><!-- /col-left -->
|
||||
</div><!-- /cols -->
|
||||
|
||||
<p class="ff-link pageonly"><a href="/info-teacher.html">Purpose, spec & bill of materials →</a></p>
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* ========================= ENGINE (shared; synth voices only) ================= */
|
||||
const SAMPLES = {}; // synth-only device — no acoustic samples; the engine uses its synth voices
|
||||
/*@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;
|
||||
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){ pendingAdvance=true; }
|
||||
}
|
||||
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(()=>gotoItem(idx+1,true),0); }
|
||||
}
|
||||
|
||||
/* ========================= PLAYER ============================================= */
|
||||
let setlist=null, idx=0;
|
||||
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
|
||||
|
||||
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(),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};
|
||||
});
|
||||
}
|
||||
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;
|
||||
setBpm(s.bpm||120);
|
||||
meters=buildMeters(s.lanes);
|
||||
drawTFT();
|
||||
}
|
||||
function startAudio(){
|
||||
ensureAudio(); audioCtx.resume(); state.running=true;
|
||||
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;
|
||||
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll();
|
||||
}
|
||||
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); }
|
||||
function toggle(){ state.running?stopAudio():startAudio(); }
|
||||
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){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
|
||||
loadSetup(setlist.items[idx]);
|
||||
if(wasRunning) startAudio(); else renderAll();
|
||||
}
|
||||
function loadSetlistObj(sl){ setlist=sl; idx=0; const wasRunning=state.running; if(wasRunning){clearInterval(schedulerTimer);schedulerTimer=null;state.running=false;} loadSetup(sl.items[0]); if(wasRunning) startAudio(); else renderAll(); }
|
||||
|
||||
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(); } }
|
||||
let rollPos=0;
|
||||
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); rollPos+=d*2.5; $("enc").style.setProperty("--rib",rollPos+"px"); renderAll(); }
|
||||
|
||||
/* ===================== RENDER: 320×240 colour IPS TFT (ST7789) =============== */
|
||||
const TFT_W=320, TFT_H=240;
|
||||
const tft=$("tft"), tc=tft.getContext("2d");
|
||||
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1));
|
||||
tft.width=TFT_W*dpr; tft.height=TFT_H*dpr; tc.scale(dpr,dpr); })(); // hi-DPI backing → crisp type
|
||||
const SND_ABBR={kick:"KICK",snare:"SNR",rim:"RIM",clap:"CLAP",hatClosed:"HAT",hatOpen:"OHAT",ride:"RIDE",crash:"CRSH",
|
||||
tomLow:"TOM↓",tomMid:"TOM",tomHigh:"TOM↑",tambourine:"TAMB",cowbell:"CBL",woodblock:"WOOD",claves:"CLAV",jamblock:"JAM",beep:"BEEP"};
|
||||
function soundLabel(s){ if(SND_ABBR[s]) return SND_ABBR[s];
|
||||
const m=String(s).match(/^([a-zA-Z]+)(\d+)?$/);
|
||||
return m ? (m[1].slice(0,3).toUpperCase()+(m[2]||"")) : String(s).slice(0,5).toUpperCase(); }
|
||||
|
||||
function drawTFT(){
|
||||
const ref=meters[0];
|
||||
const g=tc.createLinearGradient(0,0,0,TFT_H); g.addColorStop(0,"#0b0f18"); g.addColorStop(1,"#05070c");
|
||||
tc.fillStyle=g; tc.fillRect(0,0,TFT_W,TFT_H);
|
||||
// header
|
||||
tc.textBaseline="middle"; tc.textAlign="left";
|
||||
tc.font='600 12px "Segoe UI",system-ui,sans-serif'; tc.fillStyle="#5b7a93";
|
||||
tc.fillText("♪ "+(setlist?((idx+1)+"/"+setlist.items.length):"–/–"), 12, 13);
|
||||
tc.textAlign="right"; tc.fillStyle = state.running ? "#2fe07a" : "#6b7787";
|
||||
tc.fillText(state.running?"▶ PLAY":"■ STOP", TFT_W-12, 13);
|
||||
tc.fillStyle="#13283c"; tc.fillRect(12,25,TFT_W-24,1);
|
||||
// tempo (left) + name (right) — uses the wide upper area
|
||||
tc.textBaseline="alphabetic"; tc.textAlign="left";
|
||||
tc.fillStyle="#1fb6f0"; tc.font='800 50px "Segoe UI",system-ui,sans-serif';
|
||||
const bpm=String(state.bpm); tc.fillText(bpm, 12, 74);
|
||||
const bx=12+tc.measureText(bpm).width+8;
|
||||
tc.fillStyle="#5b7a93"; tc.font='700 13px "Segoe UI",system-ui,sans-serif'; tc.fillText("BPM", bx, 48);
|
||||
tc.font='600 13px "Segoe UI",system-ui,sans-serif'; tc.fillText("♩ "+(ref?(ref.groupsStr.replace(/[^0-9+]/g,"")||ref.beatsPerBar):"–"), bx, 67);
|
||||
// item name, right-aligned, ellipsised to the space right of the tempo labels
|
||||
tc.textAlign="right"; tc.fillStyle="#e9eff7"; tc.font='600 16px "Segoe UI",system-ui,sans-serif';
|
||||
let nm=setlist?(setlist.items[idx].name||"—"):"—"; const nmMax=(TFT_W-12)-(bx+42);
|
||||
if(tc.measureText(nm).width>nmMax){ while(nm.length>1 && tc.measureText(nm+"…").width>nmMax) nm=nm.slice(0,-1); nm+="…"; }
|
||||
tc.fillText(nm, TFT_W-12, 58);
|
||||
tc.fillStyle="#13283c"; tc.fillRect(12,84,TFT_W-24,1);
|
||||
// ---- all meter lanes: each a row of step pads (subdivisions · accent/normal/ghost/mute · playhead) ----
|
||||
const lanes=meters||[], gx0=48, gx1=TFT_W-12, gw=gx1-gx0, top=90, bot=204;
|
||||
const rowH = lanes.length ? Math.min(20,(bot-top)/lanes.length) : 0;
|
||||
tc.textBaseline="middle";
|
||||
lanes.forEach((m,li)=>{
|
||||
const cy=top+li*rowH+rowH/2, en=m.enabled;
|
||||
tc.textAlign="left"; tc.font='700 8px "Segoe UI",system-ui,sans-serif'; tc.fillStyle=en?"#9fb4c4":"#4a5560";
|
||||
tc.fillText(soundLabel(m.sound), 6, cy+0.5);
|
||||
const nsteps=Math.max(1,m.beatsPerBar*m.stepsPerBeat), cw=gw/nsteps, ch=Math.min(rowH-4,13), cyTop=cy-ch/2;
|
||||
for(let s=0;s<nsteps;s++){
|
||||
const cx=gx0+s*cw, lvl=m.beatsOn[s]|0, beatStart=(s%m.stepsPerBeat)===0, cur=state.running&&m.currentStep===s;
|
||||
const x=cx+0.6, w=Math.max(1,cw-1.4);
|
||||
let fill = !en ? (lvl?"rgba(120,140,160,.18)":null)
|
||||
: lvl===2?"#1fb6f0" : lvl===1?"rgba(31,182,240,.62)" : lvl===3?"rgba(31,182,240,.30)" : null;
|
||||
if(fill){ tc.fillStyle=fill; tc.fillRect(x,cyTop,w,ch); }
|
||||
else { tc.strokeStyle="rgba(120,140,160,.22)"; tc.lineWidth=1; tc.strokeRect(x+0.5,cyTop+0.5,w-1,ch-1); } // mute = outline
|
||||
if(beatStart){ tc.fillStyle="rgba(255,209,102,.55)"; tc.fillRect(cx,cyTop-1,1,ch+2); } // beat tick
|
||||
if(cur){ tc.strokeStyle="#fff"; tc.lineWidth=1.4; tc.strokeRect(x-0.6,cyTop-0.6,w+1.2,ch+1.2); } // playhead
|
||||
}
|
||||
});
|
||||
// bottom strip
|
||||
tc.fillStyle="#13283c"; tc.fillRect(12,208,TFT_W-24,1);
|
||||
tc.textBaseline="alphabetic"; tc.textAlign="left"; tc.fillStyle="#7f8b9a"; tc.font='600 13px "Segoe UI",system-ui,sans-serif';
|
||||
tc.fillText(state.running&&ref ? ("BAR "+(ref.currentBar+1)+" · BEAT "+(Math.floor(ref.currentStep/ref.stepsPerBeat)+1||"–")) : "READY", 12, 226);
|
||||
if(segBars>0){ const rem=Math.max(0,segBars-(ref?ref.currentBar:0));
|
||||
tc.textAlign="right"; tc.fillStyle="#ffd166"; tc.font='700 14px "Segoe UI",system-ui,sans-serif';
|
||||
tc.fillText((state.running?rem:segBars)+" BARS", TFT_W-12, 226); }
|
||||
}
|
||||
|
||||
function renderAll(){ drawTFT(); /* PLAY button is static hardware — transport state is shown on the screen */
|
||||
$("enc").style.setProperty("--rib", rollPos+"px"); }
|
||||
function draw(){
|
||||
// latency-compensated clock so the visual playhead lands when the click is HEARD
|
||||
// (not when it's queued) — avoids the visual leading the audio by the output buffer.
|
||||
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++; } } }
|
||||
drawTFT();
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
/* ========================= LOAD / VALIDATE =================================== */
|
||||
function setStatus(msg,ok){ const s=$("status"); s.textContent=msg; s.className="status "+(ok?"ok":"err"); }
|
||||
function loadConfig(text,quiet){
|
||||
text=(text||"").trim(); if(!text){ if(!quiet) setStatus("Paste a patch or set-list code first.",false); return false; }
|
||||
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 new Error("set list has no items");
|
||||
loadSetlistObj(sl);
|
||||
setStatus("✓ Loaded set list “"+sl.title+"” — "+sl.items.length+" item(s).",true); return true;
|
||||
}
|
||||
const setup=patchToSetup(payload);
|
||||
if(!setup.lanes.length) throw new Error("no valid lanes (need e.g. kick:4)");
|
||||
loadSetlistObj({title:"Pasted patch",items:[{name:"Patch",...setup}]});
|
||||
setStatus("✓ Loaded patch — "+setup.lanes.length+" lane(s), "+setup.bpm+" BPM"+(setup.bars?", "+setup.bars+" bars":"")+(setup.ramp.on?", ramp":"")+".",true); return true;
|
||||
}catch(e){ if(!quiet) setStatus("✗ Invalid configuration: "+e.message,false); return false; }
|
||||
}
|
||||
function loadStored(){
|
||||
let lists=[]; try{ lists=JSON.parse(localStorage.getItem("metronome.setlists")||"[]"); }catch(e){}
|
||||
const sel=$("storedSel"); sel.innerHTML='<option value="">— choose a set list —</option>';
|
||||
const og1=document.createElement("optgroup"); og1.label="Built-in";
|
||||
BUILTIN.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="b"+i; o.textContent=sl.title+" ("+sl.items.length+")"; og1.appendChild(o); });
|
||||
sel.appendChild(og1);
|
||||
if(lists.length){ const og2=document.createElement("optgroup"); og2.label="Your saved set lists";
|
||||
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="s"+i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; og2.appendChild(o); });
|
||||
sel.appendChild(og2); }
|
||||
sel._lists=lists; sel._builtin=BUILTIN;
|
||||
}
|
||||
|
||||
/* ========================= WIRING ============================================ */
|
||||
$("bPlay").onclick=toggle;
|
||||
$("bPrev").onclick=()=>gotoItem(idx-1,state.running);
|
||||
$("bNext").onclick=()=>gotoItem(idx+1,state.running);
|
||||
$("bTap").onclick=tapTempo;
|
||||
$("bLoad").onclick=()=>loadConfig($("cfg").value);
|
||||
$("storedSel").onchange=(e)=>{ const v=e.target.value; if(!v) return;
|
||||
const sl = v[0]==="b" ? e.target._builtin[+v.slice(1)] : e.target._lists[+v.slice(1)]; if(!sl) return;
|
||||
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded set list “"+(sl.title||"set list")+"”.",true); };
|
||||
|
||||
/* EC11 rotary encoder — turn it (mouse wheel or vertical drag) for tempo */
|
||||
(function(){ const k=$("enc"); let drag=false, lastY=0, acc=0;
|
||||
k.addEventListener("wheel",(e)=>{ e.preventDefault(); nudge(e.deltaY<0 ? (e.shiftKey?5:1) : (e.shiftKey?-5:-1)); }, {passive:false});
|
||||
k.addEventListener("pointerdown",(e)=>{ drag=true; lastY=e.clientY; acc=0; k.setPointerCapture(e.pointerId); });
|
||||
k.addEventListener("pointermove",(e)=>{ if(!drag) return; acc+=lastY-e.clientY; lastY=e.clientY; while(Math.abs(acc)>=5){ nudge(acc>0?1:-1); acc+=acc>0?-5:5; } });
|
||||
k.addEventListener("pointerup",()=>{ drag=false; }); k.addEventListener("pointercancel",()=>{ drag=false; });
|
||||
})();
|
||||
|
||||
/* theme toggle — cycles system → light → dark; shares the editor's "metronome.theme" key */
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
|
||||
addEventListener("keydown",(e)=>{
|
||||
const t=e.target, tag=t?t.tagName:""; if(tag==="TEXTAREA"||tag==="INPUT"||tag==="SELECT") return;
|
||||
const k=e.key;
|
||||
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); }
|
||||
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==="t"||k==="T") tapTempo();
|
||||
});
|
||||
|
||||
/* ========================= INIT ============================================== */
|
||||
loadStored();
|
||||
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
|
||||
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
|
||||
renderAll();
|
||||
requestAnimationFrame(draw);
|
||||
/*@BUILD:include:src/progbox.js@*/
|
||||
</script>
|
||||
/*@BUILD:include:src/footer.html@*/
|
||||
</body>
|
||||
</html>
|
||||
40
wokwi/README.md
Normal file
40
wokwi/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Wokwi simulation — PM‑µ Micro (Raspberry Pi Pico)
|
||||
|
||||
A runnable [Wokwi](https://wokwi.com) simulation of the **PM‑µ Micro** metronome on a
|
||||
Raspberry Pi Pico (RP2040), in MicroPython. It's a *functional* stand‑in — Wokwi has no
|
||||
14‑segment display or analog audio path, so we approximate:
|
||||
|
||||
| Real device | Simulated with |
|
||||
|---|---|
|
||||
| Clickable thumb‑roller | **KY‑040 rotary encoder** (rotate / press / hold+rotate) |
|
||||
| Amber 14‑segment display | **SSD1306 OLED** (shows BPM and track names) |
|
||||
| Analog click + speaker | **Piezo buzzer** (accent beat = higher, longer beep) |
|
||||
|
||||
### Controls
|
||||
- **Rotate** the encoder → tempo (BPM)
|
||||
- **Press** (the encoder's button) → start / stop
|
||||
- **Hold the button + rotate** → switch track (release to load it)
|
||||
|
||||
## Run it (you do this part — I can't operate your Wokwi account)
|
||||
I can't log into wokwi.com or create the project on the site for you. Use these files:
|
||||
|
||||
1. Open **https://wokwi.com/pi-pico** — it starts a new Pi Pico **MicroPython** project.
|
||||
2. Click the **`diagram.json`** tab and replace its contents with this folder's `diagram.json`.
|
||||
3. Replace **`main.py`** with this folder's `main.py`.
|
||||
4. Add a new file named **`ssd1306.py`** (the **+** next to the file tabs) and paste this folder's `ssd1306.py`.
|
||||
5. Press **▶ (play)**. Rotate / click the encoder; you'll hear the click and see the OLED update.
|
||||
|
||||
> If you use the Wokwi VS Code extension instead, keep these three files together and add a
|
||||
> `diagram.json` reference as usual.
|
||||
|
||||
## Pin map (Pico GPIO)
|
||||
| Function | Pin |
|
||||
|---|---|
|
||||
| OLED SDA / SCL (I²C0) | GP0 / GP1 |
|
||||
| Encoder CLK / DT / SW | GP2 / GP3 / GP4 |
|
||||
| Buzzer | GP5 |
|
||||
| OLED + encoder power | 3V3 / GND |
|
||||
|
||||
The real firmware ("PORTS TO FIRMWARE" in the web app) drives a 14‑segment display over
|
||||
I²C and injects the click into the analog signal path; this sim keeps the same control
|
||||
model and beat scheduling so the *feel* matches.
|
||||
BIN
wokwi/__pycache__/ssd1306.cpython-312.pyc
Normal file
BIN
wokwi/__pycache__/ssd1306.cpython-312.pyc
Normal file
Binary file not shown.
25
wokwi/diagram.json
Normal file
25
wokwi/diagram.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"version": 1,
|
||||
"author": "VARASYS PolyMeter",
|
||||
"editor": "wokwi",
|
||||
"parts": [
|
||||
{ "type": "wokwi-pi-pico", "id": "pico", "top": 0, "left": 0, "attrs": {} },
|
||||
{ "type": "board-ssd1306", "id": "oled", "top": -118, "left": 200, "attrs": {} },
|
||||
{ "type": "wokwi-ky-040", "id": "enc", "top": 70, "left": 250, "attrs": {} },
|
||||
{ "type": "wokwi-buzzer", "id": "bz", "top": 165, "left": 130, "attrs": { "volume": "0.1" } }
|
||||
],
|
||||
"connections": [
|
||||
[ "pico:GP0", "oled:SDA", "green", [] ],
|
||||
[ "pico:GP1", "oled:SCL", "green", [] ],
|
||||
[ "pico:3V3", "oled:VCC", "red", [] ],
|
||||
[ "pico:GND", "oled:GND", "black", [] ],
|
||||
[ "pico:GP2", "enc:CLK", "blue", [] ],
|
||||
[ "pico:GP3", "enc:DT", "blue", [] ],
|
||||
[ "pico:GP4", "enc:SW", "yellow",[] ],
|
||||
[ "pico:3V3", "enc:VCC", "red", [] ],
|
||||
[ "pico:GND", "enc:GND", "black", [] ],
|
||||
[ "pico:GP5", "bz:1", "green", [] ],
|
||||
[ "pico:GND", "bz:2", "black", [] ]
|
||||
],
|
||||
"dependencies": {}
|
||||
}
|
||||
119
wokwi/main.py
Normal file
119
wokwi/main.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# VARASYS PM-u "Micro" — metronome, simulated on a Raspberry Pi Pico (RP2040).
|
||||
#
|
||||
# A functional stand-in for the real inline practice bar, using parts Wokwi has:
|
||||
# * KY-040 rotary encoder -> the clickable thumb-roller
|
||||
# rotate = tempo
|
||||
# press (SW) = start / stop
|
||||
# hold SW + rotate = switch track
|
||||
# * SSD1306 OLED -> the amber 14-segment display (shows BPM / track name)
|
||||
# * Piezo buzzer -> the click (accent beat = higher, longer beep)
|
||||
#
|
||||
# Run it at https://wokwi.com/pi-pico (MicroPython).
|
||||
# Files in this project: diagram.json, main.py, ssd1306.py (see README.md).
|
||||
|
||||
from machine import Pin, I2C, PWM
|
||||
import ssd1306, time
|
||||
|
||||
# ---- display: SSD1306 128x64 on I2C0 (SDA=GP0, SCL=GP1) ----
|
||||
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400_000)
|
||||
oled = ssd1306.SSD1306_I2C(128, 64, i2c)
|
||||
|
||||
# ---- KY-040 rotary encoder (CLK=GP2, DT=GP3, SW=GP4) ----
|
||||
clk = Pin(2, Pin.IN, Pin.PULL_UP)
|
||||
dt = Pin(3, Pin.IN, Pin.PULL_UP)
|
||||
sw = Pin(4, Pin.IN, Pin.PULL_UP)
|
||||
|
||||
# ---- piezo buzzer (GP5) ----
|
||||
buz = PWM(Pin(5)); buz.duty_u16(0)
|
||||
|
||||
# ---- built-in "tracks": (name, bpm, accent pattern over the bar) ; 2 = accent, 1 = normal ----
|
||||
TRACKS = [
|
||||
("ROCK", 120, (2, 1, 1, 1)),
|
||||
("FUNK", 100, (2, 1, 2, 1)),
|
||||
("SWING", 140, (2, 1, 1, 2)),
|
||||
("WALTZ", 90, (2, 1, 1)), # 3/4
|
||||
("BCKBT", 96, (1, 2, 1, 2)), # backbeat
|
||||
]
|
||||
|
||||
ti = 0
|
||||
bpm = TRACKS[0][1]
|
||||
pat = TRACKS[0][2]
|
||||
running = False
|
||||
mode = "bpm" # "bpm" | "track"
|
||||
preview = 0
|
||||
beat = 0
|
||||
next_beat = time.ticks_ms()
|
||||
|
||||
def load(i):
|
||||
global ti, bpm, pat, beat
|
||||
ti = i % len(TRACKS)
|
||||
bpm = TRACKS[ti][1]
|
||||
pat = TRACKS[ti][2]
|
||||
beat = 0
|
||||
|
||||
def show():
|
||||
oled.fill(0)
|
||||
oled.text("PM-u MICRO", 0, 0)
|
||||
oled.text("RUN" if running else "stop", 96, 0)
|
||||
oled.hline(0, 12, 128, 1)
|
||||
if mode == "track":
|
||||
oled.text("TRACK", 0, 24)
|
||||
oled.text(TRACKS[preview][0], 0, 42)
|
||||
oled.text("#%d/%d" % (preview + 1, len(TRACKS)), 70, 42)
|
||||
else:
|
||||
oled.text("TEMPO (BPM)", 0, 24)
|
||||
oled.text("%d" % bpm, 0, 42)
|
||||
oled.text(TRACKS[ti][0], 64, 42)
|
||||
oled.show()
|
||||
|
||||
def click(accent):
|
||||
buz.freq(2000 if accent else 1200)
|
||||
buz.duty_u16(22000)
|
||||
time.sleep_ms(18 if accent else 11)
|
||||
buz.duty_u16(0)
|
||||
|
||||
load(0); show()
|
||||
|
||||
last_clk = clk.value()
|
||||
sw_down = None
|
||||
held = False
|
||||
|
||||
while True:
|
||||
now = time.ticks_ms()
|
||||
|
||||
# --- metronome beat ---
|
||||
if running and time.ticks_diff(now, next_beat) >= 0:
|
||||
click(pat[beat % len(pat)] >= 2)
|
||||
beat = (beat + 1) % len(pat)
|
||||
next_beat = time.ticks_add(next_beat, int(60000 / bpm))
|
||||
|
||||
# --- encoder rotation: one detent per CLK falling edge ---
|
||||
c = clk.value()
|
||||
if c == 0 and last_clk == 1:
|
||||
step = 1 if dt.value() else -1
|
||||
if held: # hold + rotate -> preview track
|
||||
mode = "track"
|
||||
preview = (preview + step) % len(TRACKS)
|
||||
else: # rotate -> tempo
|
||||
mode = "bpm"
|
||||
bpm = max(30, min(300, bpm + step))
|
||||
show()
|
||||
last_clk = c
|
||||
|
||||
# --- button: quick press = start/stop ; hold (~350 ms) = enter track mode ---
|
||||
if sw.value() == 0 and sw_down is None:
|
||||
sw_down = now; held = False; preview = ti
|
||||
if sw_down is not None and not held and time.ticks_diff(now, sw_down) > 350:
|
||||
held = True; mode = "track"; show()
|
||||
if sw.value() == 1 and sw_down is not None:
|
||||
if held: # release after hold+rotate -> commit track
|
||||
load(preview); mode = "track"; show()
|
||||
time.sleep_ms(800); mode = "bpm"; show()
|
||||
else: # quick tap -> start / stop
|
||||
running = not running
|
||||
if running:
|
||||
beat = 0; next_beat = time.ticks_ms()
|
||||
show()
|
||||
sw_down = None; held = False
|
||||
|
||||
time.sleep_ms(2)
|
||||
101
wokwi/ssd1306.py
Normal file
101
wokwi/ssd1306.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# MicroPython SSD1306 OLED driver, I2C interface (from micropython-lib, MIT licence).
|
||||
# Bundled here so the project runs with no extra installs.
|
||||
from micropython import const
|
||||
import framebuf
|
||||
|
||||
SET_CONTRAST = const(0x81)
|
||||
SET_ENTIRE_ON = const(0xA4)
|
||||
SET_NORM_INV = const(0xA6)
|
||||
SET_DISP = const(0xAE)
|
||||
SET_MEM_ADDR = const(0x20)
|
||||
SET_COL_ADDR = const(0x21)
|
||||
SET_PAGE_ADDR = const(0x22)
|
||||
SET_DISP_START_LINE = const(0x40)
|
||||
SET_SEG_REMAP = const(0xA0)
|
||||
SET_MUX_RATIO = const(0xA8)
|
||||
SET_COM_OUT_DIR = const(0xC0)
|
||||
SET_DISP_OFFSET = const(0xD3)
|
||||
SET_COM_PIN_CFG = const(0xDA)
|
||||
SET_DISP_CLK_DIV = const(0xD5)
|
||||
SET_PRECHARGE = const(0xD9)
|
||||
SET_VCOM_DESEL = const(0xDB)
|
||||
SET_CHARGE_PUMP = const(0x8D)
|
||||
|
||||
|
||||
class SSD1306(framebuf.FrameBuffer):
|
||||
def __init__(self, width, height, external_vcc):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.external_vcc = external_vcc
|
||||
self.pages = self.height // 8
|
||||
self.buffer = bytearray(self.pages * self.width)
|
||||
super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
|
||||
self.init_display()
|
||||
|
||||
def init_display(self):
|
||||
for cmd in (
|
||||
SET_DISP | 0x00,
|
||||
SET_MEM_ADDR, 0x00,
|
||||
SET_DISP_START_LINE | 0x00,
|
||||
SET_SEG_REMAP | 0x01,
|
||||
SET_MUX_RATIO, self.height - 1,
|
||||
SET_COM_OUT_DIR | 0x08,
|
||||
SET_DISP_OFFSET, 0x00,
|
||||
SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12,
|
||||
SET_DISP_CLK_DIV, 0x80,
|
||||
SET_PRECHARGE, 0x22 if self.external_vcc else 0xF1,
|
||||
SET_VCOM_DESEL, 0x30,
|
||||
SET_CONTRAST, 0xFF,
|
||||
SET_ENTIRE_ON,
|
||||
SET_NORM_INV,
|
||||
SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14,
|
||||
SET_DISP | 0x01,
|
||||
):
|
||||
self.write_cmd(cmd)
|
||||
self.fill(0)
|
||||
self.show()
|
||||
|
||||
def poweroff(self):
|
||||
self.write_cmd(SET_DISP | 0x00)
|
||||
|
||||
def poweron(self):
|
||||
self.write_cmd(SET_DISP | 0x01)
|
||||
|
||||
def contrast(self, contrast):
|
||||
self.write_cmd(SET_CONTRAST)
|
||||
self.write_cmd(contrast)
|
||||
|
||||
def invert(self, invert):
|
||||
self.write_cmd(SET_NORM_INV | (invert & 1))
|
||||
|
||||
def show(self):
|
||||
x0 = 0
|
||||
x1 = self.width - 1
|
||||
if self.width == 64:
|
||||
x0 += 32
|
||||
x1 += 32
|
||||
self.write_cmd(SET_COL_ADDR)
|
||||
self.write_cmd(x0)
|
||||
self.write_cmd(x1)
|
||||
self.write_cmd(SET_PAGE_ADDR)
|
||||
self.write_cmd(0)
|
||||
self.write_cmd(self.pages - 1)
|
||||
self.write_data(self.buffer)
|
||||
|
||||
|
||||
class SSD1306_I2C(SSD1306):
|
||||
def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
|
||||
self.i2c = i2c
|
||||
self.addr = addr
|
||||
self.temp = bytearray(2)
|
||||
self.write_list = [b"\x40", None]
|
||||
super().__init__(width, height, external_vcc)
|
||||
|
||||
def write_cmd(self, cmd):
|
||||
self.temp[0] = 0x80
|
||||
self.temp[1] = cmd
|
||||
self.i2c.writeto(self.addr, self.temp)
|
||||
|
||||
def write_data(self, buf):
|
||||
self.write_list[1] = buf
|
||||
self.i2c.writevto(self.addr, self.write_list)
|
||||
Loading…
Reference in a new issue