diff --git a/MANUAL.md b/MANUAL.md index d5540ae..f3e5a18 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -29,8 +29,8 @@ no installation. This manual is also built into the app (Setup > Manual). Snap, Setup. **Tab bar:** BUS 1–4 and IN (focus view per bus), PLAY (playing surfaces), -PTN (patterns, autopilot, looper). The LEDs in the bus tabs always show which -effects are on. +PTN (patterns, autopilot, looper), JAM (your own pinned controls). The LEDs +in the bus tabs always show which effects are on. ## Focus view: one bus, six faders @@ -47,6 +47,14 @@ effects are on. - **‹ / ›** step through favorites (falling back to recent/all). - **RND** randomizes the continuous parameters (LEVEL/SEND/GAIN are spared); right afterwards the button becomes **UNDO** for 6 seconds. +- **SAVE** captures this bus's current effect + all six values + on/off as a + JAM bus-scene pad in one tap — opens the JAM editor pre-filled (label + already set, mode defaults to Momentary) so you just confirm or tweak it. +- **Scene sidebar:** any bus-scene pads captured for the bus you're looking + at appear as small buttons on the left of the faders — hold to preview, + release to return to exactly what was playing before (including the + effect itself, not just its values), no need to leave Focus. A small + **✎** on each one opens its editor (rename, re-capture, delete). ## Overview @@ -58,11 +66,19 @@ view. ## Choosing effects Tap the effect name to open the picker: "Recent", "Favorites" (star an entry -by **holding** it), then categories with a jump bar. Note: **the three bus -types have different effect sets** (Roland's spec) — e.g. Sync Delay, -Isolator and Resonator only exist on BUS 3/4, the voice effects only on +by **holding** it), then categories with a jump bar — RECENT and FAVORITES +get their own jump-bar shortcuts too. Note: **the three bus types have +different effect sets** (Roland's spec) — e.g. Sync Delay, Isolator, +Resonator and Filter+Drive only exist on BUS 3/4, the voice effects only on INPUT. +Favorited effects for the bus you're looking at also show as small chips +right under the header in Focus view — a one-tap shortcut that skips the +picker entirely. Scrolls sideways if you have more than fit. Tapping a chip +always lands with EFX off, even if it was on before — the point is loading +an effect to dial it in, not punching it in live; hit EFX yourself once +you're happy with it. + **Direct FX (BUS 1/2):** the five DFX slots are freely assignable on the device. Mirror the assignment in Setup ("Direct FX assignment") — the faders then show real parameter names (e.g. "DFX3 · Sync Delay") and values are @@ -79,8 +95,35 @@ Eight slots for the full state of all five buses (effect, values, on/off). **Tap loads** (an empty slot saves), **hold saves** and lets you type the name right in the slot, ✕ deletes. The panel shows what would be saved; each slot lists its content ("1● Tape Echo · 3○ Lo-fi": ● on, ○ off). -**Export/Import:** JSON for backups and for moving snapshots between -devices or sharing them. + +**Recipes:** the same full 5-bus shape as a snapshot, but *typed in* +instead of captured from live state — and not capped at 8. Meant for +transcribing chains from recipe cards/packs (the kind that list an effect +and six control values per bus) straight into the app, no fader-dragging +required. Open via **Library** in the top status bar (visible from any +view) or **"Open Recipes"** below the snapshot grid — same panel either +way. **+ New Recipe** (or ✎ on an existing one) opens the editor: pick an +effect per bus from a dropdown, and the value fields that appear are +labeled with that effect's real parameter names and units — type what's +printed on the card (dB, ms, %, a note value, "L12"/"R12"/"CTR" for pan, +"80:20" or "80/20" for a balance split) and it converts to the right +underlying value automatically, the same math the app uses to display it. +Tap a saved recipe to load it onto all 5 buses, same as a snapshot. Ships +with six factory recipes transcribed from +[electronoir](https://electronoir.gumroad.com) (@noirtdc) FX Recipe +cards — House Chord Synth, "Quack" Bass, Risers, Bass Destroyer, Techno +Kick Fattener, and Top Loops Sauce. A few of the source cards give a +*range* for a field ("10 to 20") rather than one fixed number, meant as a +live-tweak/automation target rather than a strict setting; those load +with the range's midpoint as a starting value, same as everything else +in a recipe — fully editable afterward. + +**Export/Import:** JSON for backups and for moving your whole setup — +snapshots, JAM, XY combos, *and* recipes — between devices, e.g. editing +on a laptop browser and bringing it to your phone: Export, copy/share the +JSON, paste it into the same field on the other device, Import. Import +overwrites matching snapshot slot numbers and replaces JAM/XY +combos/recipes entirely; tap Import again within 8 seconds to undo. ## XY pad and LFOs @@ -88,6 +131,16 @@ devices or sharing them. behavior: position = value. **MOMENTARY** springs both values back to the pre-touch state on release. +**PRESETS:** one-tap axis assignments. **Templates** are curated pairs for +specific effects (e.g. "Isolator — LOW × HIGH", "Tape Echo — TIME × +FEEDBACK"), built from their real parameter names — picking one asks which +bus (only buses that actually have that effect are offered), switches that +bus to the effect if it isn't already loaded, and assigns both axes. **Your +combos** are your own saved X/Y pairs: dial in an assignment, "Save current +X/Y as…", rename with the ✎, tap the name to recall it later, ✕ to delete. +Unlike templates these just re-point to whatever bus+slot you saved, no +effect-switching involved. + **LFO row:** four modulator slots (chips 1–4; orange dot = running). Per slot: target, waveform (SIN/TRI/SQR/SAW/**RND** = drift between random anchors), rate in note values (4/1 to 1/16), depth. The time source shows on @@ -124,6 +177,89 @@ at the center. Slot 4 comes preconfigured as a slow drift. DEL, UNDO/REDO, STOP ALL, tempo reset, BPM fader). The device must be in looper mode itself. +## JAM: pinned controls + +A tab of your own pads and faders, independent of whatever effect happens to +be loaded. Two kinds of control per slot: + +- **SP-404 bus param:** pins one of a bus's six control slots (e.g. "BUS 1 · + CTRL 1") under a name you choose, so it stays visible and adjustable no + matter which effect is currently selected on that bus — this is how a + Faderfox/TouchOSC-style fixed layout (HPF, LPF, Stopper, ...) works: the + slot always sends the same CC, its effect depends on whatever's loaded. +- **Raw MIDI:** any channel/CC/Note/Program Change to any currently connected + MIDI output — not just the SP-404. Use this to reach a second device (e.g. + an MC-101) from the same page. CC controls are two-way: turning the actual + hardware knob on that channel/CC updates the app too, from *any* connected + MIDI input (not just the one selected as the primary SP-404 input) — so a + second device just needs to reach the browser somehow, either connected + directly (e.g. the MC-101 over its own USB) or passed through another + device, whichever your setup allows. +- **Bus scene:** captures one bus's full state (effect + all six values + + on/off) into a pad. Pressing always switches EFX **on** regardless of + what was captured (the point is to hear it), even if the bus was off when + you captured it. **Momentary** mode holds the captured scene while + pressed and, on release, always restores exactly what that bus was doing + the instant before you pressed — including the effect itself, not just + its values, and not back to some fixed old state but to wherever you'd + since moved it, so it works mid-transition. Anything you tweak while + holding is just a preview; release always discards it and goes back. + **Toggle** mode applies on the first tap, restores on the second. Use it + to punch in a prepared sound (e.g. an isolator with only the mids up) for + a bar, then let go. To set one up: dial in the sound live on the bus, + open the control's editor, pick **Bus scene**, choose the bus, tap + **Capture** — or just tap **SAVE** in the Focus header to do all of that + for the bus you're currently looking at in one step. + +**CLEAR** (top right) removes every control in JAM immediately — no dialog +(some Web MIDI wrapper browsers don't support them reliably); the button +becomes **UNDO** for 6 seconds instead. + +**EDIT** (top right) turns the grid into an editor: tiles show a pencil +badge, tap one to change its settings or delete it, or drag it to a new +position to reorder. **Swipe a tile left** past about half its width to +arm delete (turns red with a 🗑 icon) — release to remove it, same +instant+UNDO behavior as CLEAR below. Tap **+** (outside edit mode) to add +a new control. Fader values and pad on/off state are remembered per slot, +same as everywhere else in the app (`localStorage`, nothing is ever sent +on load). + +**SELECT** (top right) turns the grid into a multi-select picker: tap +tiles to check them (a bar appears above the grid showing how many are +selected), then either type a **channel** number and tap **Apply** to +set that channel on every selected raw-MIDI control in one go (bus params +and bus scenes don't carry a channel, so they're skipped), or tap +**Delete** to remove all selected tiles at once — again instant with a +6-second **UNDO** via CLEAR. Handy for retuning a whole preset (e.g. an +MC-101 template) to different track channels without opening each tile. + +**PRESETS:** factory templates that add a set of controls in one tap, built +from the target device's actual MIDI implementation chart rather than +guesswork. Currently ships one: **MC-101 — 4 tracks × Sound/Filter/Mod/FX** +(CC 80–83, the four hardware knobs, one row per track, in the device's own +left-to-right knob order). Picking a preset +asks which connected output to route it to (so it doesn't silently reuse +whatever the SP-404 is on), then appends the controls to your existing JAM +grid — nothing is removed. Track channels default to 1–4; verify against +the device's own Track MIDI Channel setting (SHIFT + TRACK SEL on the +MC-101) since every control's channel is still editable afterward if it +doesn't match. + +## Display: theme and fader orientation + +Setup has two display toggles: + +- **Theme:** dark (default) or light. +- **Horizontal faders:** switches the Focus-view faders from a column of + vertical bars to full-width horizontal strips stacked top to bottom — + useful on a wide/landscape tablet where a sideways drag has more travel to + work with than a narrow vertical one. +- **JAM style:** bars (default) or rotary knobs for every fader-type control + in the JAM tab — same drag behavior (relative drag, fine-tune pull, + double-tap default, wheel ±1), just drawn as a balance-style dial: pointer + straight up at the center value (64), tilts right and fills orange toward + 127, tilts left toward 0. + ## Tempo and Ableton Link The BPM display reads MIDI clock from **any** connected port — AUM, Ableton diff --git a/README.md b/README.md index b4326c4..db294f9 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,34 @@ HTML file, no installation, no dependencies, no account. with real parameter names from the reference manual, six touch faders with relative drag and fine-tune pull-out, bidirectional sync with the hardware encoders. -- **Snapshots** (8 slots) with JSON export/import. -- **XY pad** (Kaoss-style, freely mappable, momentary spring-back) and - **four beat-synced LFOs** (incl. random drift), clocked by incoming MIDI - clock or tap tempo. +- **Snapshots** (8 slots) plus an unlimited **Recipes** library (Library + button, top bar) — type in a full 5-bus chain directly (e.g. from an + SP-404 recipe card), with each field labeled in the effect's own real + units so a card's printed values go straight in. Ships with six factory + recipes from [electronoir](https://electronoir.gumroad.com)'s FX Recipe + cards. JSON export/import for both. +- **XY pad** (Kaoss-style, freely mappable, momentary spring-back) with + **presets** — curated axis pairs for specific effects (built from real + parameter names, auto-switches the bus's effect if needed) plus your own + saved combos — and **four beat-synced LFOs** (incl. random drift), clocked + by incoming MIDI clock or tap tempo. - **Play surfaces:** chromatic keyboard (CH 16), scale pads with isometric Move-style layout, vocoder keyboard with pitch bend (CH 11), and a pad board incl. a freely assignable custom layout. - **Pattern grid** with a quantized markov autopilot, plus a looper remote. +- **JAM tab:** your own pinned pads/faders — SP-404 bus parameters that stay + put regardless of the loaded effect, raw MIDI (any channel/CC/Note/PC, + two-way for CC controls) to any connected device (e.g. a second synth), + or a captured **bus scene** (a whole bus's state momentarily recalled on + hold, reverting to whatever was playing on release). Drag to reorder or + swipe-to-delete in edit mode, multi-select to batch-set a MIDI channel or + delete several at once, CLEAR to start over. **Presets** add a whole set + of controls in one tap, built from real MIDI implementation charts (ships + with an MC-101 4-track × Sound/Filter/Mod/FX template, in the device's own + knob order). +- **Light/dark theme**, an optional horizontal fader layout for wide + tablets, and balance-style rotary knobs as an alternative to bars for JAM + controls, all in Setup. ## Requirements diff --git a/docs/pr-assets/focus-favorites.png b/docs/pr-assets/focus-favorites.png new file mode 100644 index 0000000..b592571 Binary files /dev/null and b/docs/pr-assets/focus-favorites.png differ diff --git a/docs/pr-assets/focus-scenes-horizontal.png b/docs/pr-assets/focus-scenes-horizontal.png new file mode 100644 index 0000000..7d548f0 Binary files /dev/null and b/docs/pr-assets/focus-scenes-horizontal.png differ diff --git a/docs/pr-assets/jam-grid-iphone.png b/docs/pr-assets/jam-grid-iphone.png new file mode 100644 index 0000000..0ab491f Binary files /dev/null and b/docs/pr-assets/jam-grid-iphone.png differ diff --git a/docs/pr-assets/jam-knobs.png b/docs/pr-assets/jam-knobs.png new file mode 100644 index 0000000..916a213 Binary files /dev/null and b/docs/pr-assets/jam-knobs.png differ diff --git a/docs/pr-assets/light-theme-jam.png b/docs/pr-assets/light-theme-jam.png new file mode 100644 index 0000000..2a0a808 Binary files /dev/null and b/docs/pr-assets/light-theme-jam.png differ diff --git a/docs/pr-assets/xy-presets.png b/docs/pr-assets/xy-presets.png new file mode 100644 index 0000000..36ab5ba Binary files /dev/null and b/docs/pr-assets/xy-presets.png differ diff --git a/index.html b/index.html index a2af47a..cddead5 100644 --- a/index.html +++ b/index.html @@ -4,8 +4,8 @@ - - + + 404 Busdriver @@ -20,10 +20,26 @@ --text: #e8e6e1; --muted: #8a8880; --accent: #ff7a1a; --accent-dim: rgba(255,122,26,.25); --glow: rgba(255,122,26,.35); --ok: #4cd07d; --warn: #ff4545; + /* the numeric LCD readouts (fader value, bpm, ...) are a physical dark + screen with light digits - fixed regardless of theme, like a real + device's display never flips to "light mode" */ + --lcd-bg: #000; --lcd-text: #e8e6e1; + /* fader track backdrop: a separate pair from --panel/--raised because it + needs to read as "recessed" (darker than its surroundings) in dark + mode but "recessed" (also darker, just a lighter absolute value) in + light mode - inverting is wrong, it must stay relatively darker */ + --track-bg: #0d0d0f; --track-line: #2c2c31; --statusbar-h: 46px; --tabs-h: 60px; --font-label: system-ui, -apple-system, "Segoe UI", sans-serif; --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; } + :root[data-theme="light"] { + --bg: #f3f2ee; --panel: #ffffff; --raised: #ebe9e4; --line: #d9d6cf; + --text: #201e1a; --muted: #726e64; + --accent: #ff7a1a; --accent-dim: rgba(255,122,26,.16); --glow: rgba(255,122,26,.32); + --ok: #1c9a54; --warn: #d8352f; + --track-bg: #e4e1d9; --track-line: #cac6bb; + } /* ============ 02 reset + shell ============ */ * { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; overscroll-behavior: none; } @@ -49,8 +65,8 @@ .label { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; font-weight: 600; color: var(--muted); } .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } .display { - background: #000; border-radius: 4px; box-shadow: inset 0 0 0 1px #2a2a2e; - font-family: var(--font-mono); color: var(--text); + background: var(--lcd-bg); border-radius: 4px; box-shadow: inset 0 0 0 1px #2a2a2e; + font-family: var(--font-mono); color: var(--lcd-text); text-shadow: 0 0 6px rgba(232,230,225,.4); background-image: repeating-linear-gradient(0deg, transparent 0 2px, rgba(255,255,255,.02) 2px 3px); } @@ -76,7 +92,7 @@ height: 34px; padding: 0 12px; border-radius: 7px; flex: none; font-size: 11px; letter-spacing: .07em; font-weight: 600; } - #alloff { color: #ff9a9a; box-shadow: inset 0 1px 0 rgba(255,255,255,.06), inset 0 -1px 0 rgba(0,0,0,.4), 0 0 0 1px #5a3535; } + #alloff { color: var(--warn); box-shadow: inset 0 1px 0 rgba(255,255,255,.06), inset 0 -1px 0 rgba(0,0,0,.4), 0 0 0 1px var(--warn); } #alloff.armed { background: var(--warn); color: #fff; box-shadow: 0 0 12px rgba(255,69,69,.5); } /* ============ 04 bus tabs ============ */ #bustabs { @@ -110,24 +126,71 @@ } #fx-name .fx-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; } #fx-name .fx-sub { font-size: 9px; letter-spacing: .12em; color: var(--muted); text-shadow: none; } - #faders { flex: 1; display: flex; gap: 8px; min-height: 0; } + /* one-tap favorite-effect chips for the active bus, right under the + header - saves a trip through the picker for effects you use a lot. + Collapses away entirely when the active bus's table has no favorites. */ + #focus-favs { display: none; flex: none; gap: 5px; overflow-x: auto; -webkit-overflow-scrolling: touch; touch-action: pan-x; } + #focus-favs.show { display: flex; } + #focus-favs .focus-fav-chip { + /* no star icon here - every chip in this strip is a favorite by + definition, so it'd just be redundant weight eating width */ + flex: none; max-width: 84px; height: 26px; padding: 0 10px; border-radius: 13px; background: var(--raised); + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 0 0 1px var(--line); + font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + #focus-favs .focus-fav-chip.sel { background: var(--accent); color: #1a1105; box-shadow: 0 0 10px var(--glow); } + #focus-body { flex: 1; display: flex; gap: 10px; min-height: 0; min-width: 0; } + #faders { flex: 1; display: flex; gap: 10px; min-height: 0; min-width: 0; } + /* saved bus-scene pads for the active bus, recalled without leaving Focus. + Sits to the left of the fader area in both orientations (horiz-faders + only flips #faders' own internal direction, not #focus-body's) - width + collapses to 0 when the active bus has no captured scenes. */ + #focus-scenes { flex: none; display: flex; flex-direction: column; gap: 6px; width: 0; overflow: hidden; min-height: 0; transition: width .15s; } + #focus-scenes.has-scenes { width: 80px; overflow-y: auto; touch-action: pan-y; } + #focus-scenes .focus-scene-item { flex: none; position: relative; } + #focus-scenes .focus-scene-btn { width: 100%; min-height: 48px; border-radius: 8px; font-size: 10px; font-weight: 700; letter-spacing: .01em; padding: 5px 4px; line-height: 1.3; overflow-wrap: break-word; hyphens: auto; } + #focus-scenes .focus-scene-edit { + position: absolute; top: -5px; right: -5px; width: 20px; height: 20px; border-radius: 5px; + background: var(--raised); box-shadow: 0 0 0 1px var(--line); font-size: 10px; + display: flex; align-items: center; justify-content: center; color: var(--muted); z-index: 2; + } /* ============ 06 fader component ============ */ .slot { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; min-height: 0; } .slot.empty { opacity: .22; pointer-events: none; } .fader { display: flex; flex-direction: column; align-items: center; gap: 6px; flex: 1; width: 100%; min-height: 0; position: relative; touch-action: none; } .fader-val { font-size: 12px; padding: 3px 4px; min-width: 52px; max-width: 100%; text-align: center; flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .fader-track-wrap { flex: 1; width: 100%; display: flex; justify-content: center; min-height: 0; position: relative; } - .fader-track { width: 10px; height: 100%; border-radius: 5px; background: #0d0d0f; box-shadow: inset 0 0 0 1px #2c2c31; position: relative; } - .fader-fill { position: absolute; left: 0; right: 0; bottom: 0; border-radius: 5px; background: var(--accent-dim); } - .fader-thumb { - --thumb-h: 26px; - position: absolute; left: 50%; width: 44px; height: var(--thumb-h); border-radius: 6px; - transform: translateX(-50%); - background: var(--raised); - box-shadow: inset 0 1px 0 rgba(255,255,255,.09), inset 0 -1px 0 rgba(0,0,0,.5), 0 2px 5px rgba(0,0,0,.5), 0 0 0 1px var(--line); - } - .fader-thumb::after { content: ""; position: absolute; left: 6px; right: 6px; top: 50%; height: 2px; transform: translateY(-50%); background: var(--accent); border-radius: 1px; } - .fader.drag .fader-thumb { background: #3a3126; box-shadow: 0 0 12px var(--glow), inset 0 1px 0 rgba(255,255,255,.15), 0 0 0 1px var(--accent); } + .fader-track-wrap { flex: 1; width: 100%; min-height: 0; position: relative; } + /* fat filled-bar fader (TouchOSC-style): the whole slot width is both the + visual bar and the drag surface - no thin track behind a small floating + pill, bigger to read and more forgiving to grab on phone/tablet. + JS only ever sets --pct; body.horiz-faders remaps which axis it drives, + so the same update() code drives both orientations. */ + .fader-track { position: absolute; inset: 0; border-radius: 10px; background: var(--track-bg); box-shadow: inset 0 0 0 1px var(--track-line); overflow: hidden; } + .fader-fill { position: absolute; left: 0; right: 0; bottom: 0; height: var(--pct, 0%); background: var(--accent); } + /* the cap line needs a dark ring, not just the glow, so it stays visible + against a light-theme unfilled track and not just the dark-fill side */ + .fader-thumb { position: absolute; left: 0; right: 0; bottom: var(--pct, 0%); height: 3px; background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.4), 0 0 6px var(--glow); transform: translateY(50%); border-radius: 2px; } + .fader.drag .fader-fill { filter: brightness(1.3); } + .fader.drag .fader-thumb { height: 4px; box-shadow: 0 0 10px var(--glow); } + /* horizontal orientation: full-width strips stacked instead of narrow + columns, so a sideways drag actually has travel to work with. Scoped to + #faders (Focus view) only - the JAM grid's tiles are narrow columns by + design and a sideways fader has no room to work with there regardless + of this toggle, so JAM always stays vertical. */ + body.horiz-faders #faders { flex-direction: column; } + body.horiz-faders #faders .slot { flex-direction: row; align-items: stretch; gap: 10px; } + body.horiz-faders #faders .slot-label { order: -1; align-self: center; width: 68px; flex: none; text-align: left; } + body.horiz-faders #faders .fader { flex-direction: row; align-items: stretch; } + /* value chip overlaid on the bar itself instead of its own flex column - + otherwise label + value together eat a third of the width before the + bar (the part that actually needs the room) even gets a look-in */ + body.horiz-faders #faders .fader-val { + position: absolute; left: 6px; top: 50%; transform: translateY(-50%); z-index: 3; + min-width: 0; padding: 2px 7px; font-size: 10.5px; + } + body.horiz-faders #faders .fader-fill { left: 0; right: auto; top: 0; bottom: 0; height: 100%; width: var(--pct, 0%); } + body.horiz-faders #faders .fader-thumb { left: var(--pct, 0%); right: auto; top: 0; bottom: 0; height: 100%; width: 3px; transform: translateX(-50%); } .slot-label { font-size: 10.5px; letter-spacing: .07em; text-transform: uppercase; font-weight: 600; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; flex: none; } .bigval { position: absolute; top: -6px; left: 50%; transform: translate(-50%, -100%); @@ -140,6 +203,11 @@ .seg { flex: 1; width: 100%; max-width: 74px; display: flex; flex-direction: column-reverse; gap: 4px; min-height: 0; } .seg button { flex: 1; border-radius: 6px; background: var(--raised); box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 0 0 1px var(--line); font-size: 10px; font-weight: 600; color: var(--muted); min-height: 0; overflow: hidden; } .seg button.on { background: var(--accent); color: #1a1105; box-shadow: 0 0 10px var(--glow); } + /* horizontal orientation: these two control kinds aren't faders, but they + sit in the same row-of-slots and must not stay capped to a narrow + vertical-mode column width (Focus view only, see above) */ + body.horiz-faders #faders .slot-toggle { max-width: none; } + body.horiz-faders #faders .seg { max-width: none; flex-direction: row; } /* stepper popover */ #stepper { position: fixed; z-index: 40; display: none; gap: 8px; padding: 8px; border-radius: 10px; background: var(--panel); box-shadow: 0 6px 24px rgba(0,0,0,.6), 0 0 0 1px var(--line); } #stepper.open { display: flex; } @@ -157,7 +225,6 @@ .strip-faders { flex: 1; display: flex; gap: 5px; min-height: 0; } .strip-faders .fader-val { display: none; } .strip-faders .slot-label { font-size: 8.5px; } - .strip-faders .fader-thumb { --thumb-h: 16px; width: 26px; } /* ============ 08 picker ============ */ .overlay { position: fixed; inset: 0; z-index: 20; display: none; background: rgba(8,8,10,.72); } .overlay.open { display: flex; } @@ -193,15 +260,20 @@ #jumpbar { position: absolute; right: calc(6px + env(safe-area-inset-right)); top: calc(70px + env(safe-area-inset-top)); display: flex; flex-direction: column; gap: 4px; z-index: 3; } #jumpbar button { width: 36px; height: 40px; font-size: 9px; font-weight: 700; color: var(--muted); border-radius: 8px; background: var(--raised); box-shadow: 0 0 0 1px var(--line); } /* ============ 09 setup ============ */ - #setup-panel, #snaps-panel, #xypick-panel, #padpick-panel, #help-panel { + #setup-panel, #snaps-panel, #xypick-panel, #padpick-panel, #help-panel, #jamedit-panel, #jampreset-panel, #xypreset-panel, #recipes-panel, #recipe-edit-panel { margin: auto; width: min(480px, calc(100% - 24px)); max-height: calc(100dvh - 40px); background: var(--panel); border-radius: 14px; box-shadow: 0 10px 40px rgba(0,0,0,.6), 0 0 0 1px var(--line); display: flex; flex-direction: column; overflow: hidden; } - #setup-head, #snaps-head, #xypick-head, #padpick-head, #help-head { display: flex; align-items: center; padding: 14px 16px; border-bottom: 1px solid var(--line); flex: none; } - #setup-head h2, #snaps-head h2, #xypick-head h2, #padpick-head h2, #help-head h2 { flex: 1; font-size: 14px; letter-spacing: .08em; font-weight: 700; } - #setup-body, #snaps-body, #xypick-body, #padpick-body, #help-body { overflow-y: auto; touch-action: pan-y; overscroll-behavior: contain; padding: 14px 16px calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 14px; } - #setup-body h4, #snaps-body h4, #xypick-body h4, #padpick-body h4 { font-size: 10px; letter-spacing: .1em; color: var(--muted); font-weight: 700; text-transform: uppercase; } + #setup-head, #snaps-head, #xypick-head, #padpick-head, #help-head, #jamedit-head, #jampreset-head, #xypreset-head, #recipes-head, #recipe-edit-head { display: flex; align-items: center; padding: 14px 16px; border-bottom: 1px solid var(--line); flex: none; } + #setup-head h2, #snaps-head h2, #xypick-head h2, #padpick-head h2, #help-head h2, #jamedit-head h2, #jampreset-head h2, #xypreset-head h2, #recipes-head h2, #recipe-edit-head h2 { flex: 1; font-size: 14px; letter-spacing: .08em; font-weight: 700; } + #setup-body, #snaps-body, #xypick-body, #padpick-body, #help-body, #jamedit-body, #jampreset-body, #xypreset-body, #recipes-body, #recipe-edit-body { overflow-y: auto; touch-action: pan-y; overscroll-behavior: contain; padding: 14px 16px calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 14px; } + #setup-body h4, #snaps-body h4, #xypick-body h4, #padpick-body h4, #jamedit-body h4, #jampreset-body h4, #xypreset-body h4, #recipes-body h4, #recipe-edit-body h4 { font-size: 10px; letter-spacing: .1em; color: var(--muted); font-weight: 700; text-transform: uppercase; } + #jamedit-body > div > h4:not(:first-child) { margin-top: 4px; } + .je-field { display: flex; flex-direction: column; gap: 4px; font-size: 10px; letter-spacing: .08em; color: var(--muted); text-transform: uppercase; font-weight: 700; flex: 1; min-width: 90px; } + .je-field input, .je-field-wide { font: inherit; color: var(--text); background: #000; border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px; min-height: 44px; } + .je-field-wide { width: 100%; text-transform: none; letter-spacing: normal; font-weight: 400; font-size: 14px; } + .hide { display: none !important; } #help-panel { width: min(560px, calc(100% - 24px)); } #help-body { gap: 6px; } #help-body h3 { font-size: 11px; letter-spacing: .1em; color: var(--accent); font-weight: 700; margin-top: 10px; text-transform: uppercase; } @@ -303,6 +375,23 @@ #snap-current { padding: 8px 10px; font-size: 11.5px; line-height: 1.65; white-space: pre-line; } .snap-slot .sn-del { position: absolute; top: 2px; right: 2px; width: 30px; height: 30px; border-radius: 7px; color: var(--muted); font-size: 12px; } #sync-status { font-size: 11px; color: var(--muted); letter-spacing: .05em; } + /* XY presets: curated templates + user-saved axis combos */ + #xy-presets-btn { width: 84px; flex: none; font-size: 10px; font-weight: 700; letter-spacing: .05em; border-radius: 8px; } + .xy-custom-row { display: flex; align-items: stretch; gap: 6px; margin-bottom: 6px; } + .xy-custom-row .xy-custom-name { flex: 1; min-width: 0; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .xy-custom-icon { width: 40px; flex: none; border-radius: 8px; background: var(--raised); box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 0 0 1px var(--line); color: var(--muted); font-size: 13px; } + .xy-custom-edit { flex: 1; min-width: 0; background: #000; border: 1px solid var(--accent); color: var(--text); + border-radius: 8px; font: inherit; font-size: 12.5px; padding: 0 10px; text-align: left; + user-select: text; -webkit-user-select: text; } + /* Recipe editor: one block per bus, each with an effect select, an + EFX on/off toggle, and however many labeled value fields that + effect actually has (reuses .je-field from the JAM control editor) */ + .re-bus-block { padding: 10px; border-radius: 10px; background: var(--raised); box-shadow: 0 0 0 1px var(--line); display: flex; flex-direction: column; gap: 8px; } + .re-bus-block h4 { margin: 0; } + .re-fx-select { width: 100%; min-height: 44px; padding: 8px 10px; border-radius: 8px; background: #000; color: var(--text); border: 1px solid var(--line); font: inherit; font-size: 13px; } + .re-onoff { display: flex; align-items: center; gap: 8px; font-size: 11px; letter-spacing: .05em; color: var(--muted); font-weight: 700; text-transform: uppercase; } + .re-onoff input { width: 20px; height: 20px; } + .re-fields { display: flex; flex-wrap: wrap; gap: 8px; } #exchange { width: 100%; min-height: 90px; background: #000; color: var(--text); font-family: var(--font-mono); font-size: 11px; border-radius: 6px; border: 1px solid var(--line); padding: 8px; resize: vertical; margin-bottom: 8px; } @@ -310,6 +399,10 @@ box-shadow: inset 0 1px 0 rgba(255,255,255,.06), 0 0 0 1px var(--line); font-size: 10px; font-weight: 700; letter-spacing: .05em; color: var(--muted); } #rnd-btn.undo { color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } + #jam-clear-btn.undo { color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } + #bus-save-btn { width: 46px; flex: none; border-radius: 8px; background: var(--raised); + box-shadow: inset 0 1px 0 rgba(255,255,255,.06), 0 0 0 1px var(--line); + font-size: 9px; font-weight: 700; letter-spacing: .03em; color: var(--muted); } .setup-row { display: flex; flex-wrap: wrap; gap: 8px; } .setup-btn { min-height: 44px; padding: 8px 14px; border-radius: 8px; background: var(--raised); box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 0 0 1px var(--line); font-size: 12.5px; } .setup-btn.primary { background: var(--accent); color: #1a1105; font-weight: 700; } @@ -323,6 +416,101 @@ #clock-diag { font-size: 12px; color: var(--muted); } .infobox { border-left: 3px solid var(--accent); background: rgba(255,122,26,.06); border-radius: 0 8px 8px 0; padding: 10px 12px; font-size: 12.5px; line-height: 1.55; color: var(--text); } .infobox .label { display: block; margin-bottom: 4px; color: var(--accent); } + /* ============ 09b jam view (programmable pinned MIDI controls) ============ */ + #view-jam { display: none; height: 100%; padding: 10px calc(10px + env(safe-area-inset-right)) calc(10px + env(safe-area-inset-bottom)) calc(10px + env(safe-area-inset-left)); flex-direction: column; gap: 10px; } + body.jam #view-jam { display: flex; } + body.jam #view-focus, body.jam #view-overview, body.jam #view-xy, body.jam #view-pattern, body.jam #view-play { display: none; } + #jam-head { display: flex; align-items: center; gap: 10px; flex: none; height: 40px; } + #jam-head .spacer { flex: 1; } + #jam-edit-btn { height: 100%; padding: 0 16px; border-radius: 8px; background: var(--raised); box-shadow: inset 0 1px 0 rgba(255,255,255,.06), 0 0 0 1px var(--line); font-size: 11px; font-weight: 700; letter-spacing: .06em; color: var(--muted); } + #jam-edit-btn.on { background: var(--accent); color: #1a1105; box-shadow: 0 0 12px var(--glow); } + #jam-grid { + flex: 1; min-height: 0; overflow-y: auto; touch-action: pan-y; overscroll-behavior: contain; + /* min-width tuned to land on exactly 4 columns across common phone + viewports (360-412px CSS width, e.g. iPhone 16 at 393, Moto G31 at + 412) rather than 2 (too few) or 5 (cramped) */ + display: grid; grid-template-columns: repeat(auto-fill, minmax(75px, 1fr)); grid-auto-rows: 128px; gap: 8px; + align-content: start; + } + @media (min-width: 700px) { #jam-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } } + .jam-slot { + border-radius: 10px; background: var(--panel); box-shadow: 0 0 0 1px var(--line); + display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px 6px; + position: relative; min-width: 0; min-height: 0; touch-action: none; + } + .jam-slot .slot-label { max-width: 100%; } + .jam-add { + border-radius: 10px; background: transparent; box-shadow: 0 0 0 1px dashed var(--line); + border: 1px dashed var(--line); display: flex; align-items: center; justify-content: center; + font-size: 28px; color: var(--muted); touch-action: manipulation; + } + .jam-pad-btn { flex: 1; width: 100%; border-radius: 8px; display: flex; align-items: center; justify-content: center; + text-align: center; font-size: 12.5px; font-weight: 700; letter-spacing: .04em; padding: 4px; } + .jam-slot.jam-pad .fader-val { display: none; } /* pads don't show a numeric readout */ + /* value chip overlaid on the bar itself rather than its own row above it - + JAM tiles are small, that row was a meaningful chunk of a tile that's + already fighting for height */ + .jam-slot .fader-val { + position: absolute; top: 5px; left: 50%; transform: translateX(-50%); z-index: 3; + min-width: 0; padding: 2px 8px; font-size: 11px; + } + /* edit mode: tiles lift slightly, gain a delete badge, and become drag handles */ + body.jam-editing .jam-slot { box-shadow: 0 0 0 1px var(--line), inset 0 0 0 1px rgba(255,122,26,.35); } + body.jam-editing .jam-slot::before { + content: "✎"; position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 5px; + background: var(--raised); box-shadow: 0 0 0 1px var(--line); font-size: 11px; display: flex; + align-items: center; justify-content: center; color: var(--muted); pointer-events: none; z-index: 2; + } + body.jam-editing .jam-slot.jam-lift { transform: scale(1.05); box-shadow: 0 10px 26px rgba(0,0,0,.55), 0 0 0 2px var(--accent); z-index: 6; opacity: .95; } + body.jam-editing .jam-slot.jam-drop-before { box-shadow: inset 3px 0 0 var(--accent), 0 0 0 1px var(--line); } + body.jam-editing .jam-add { opacity: .5; pointer-events: none; } + /* swipe-to-delete: a big, mostly-horizontal swipe overrides the drop + target and tints the tile red as a "let go now and this is gone" cue - + grids can't rely on direction alone (dragging left is also "reorder + onto the tile to my left"), so this only arms past a real distance */ + body.jam-editing .jam-slot.jam-delete-hint { background: var(--warn); box-shadow: 0 0 0 2px var(--warn); } + body.jam-editing .jam-slot.jam-delete-hint::before { content: "🗑"; background: transparent; box-shadow: none; } + /* select mode: mutually exclusive with edit/reorder so a tap is never + ambiguous between "select this" and "start dragging this" */ + body.jam-selecting .jam-slot { box-shadow: 0 0 0 1px var(--line); } + body.jam-selecting .jam-slot::before { + content: ""; position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; border-radius: 50%; + background: var(--raised); box-shadow: 0 0 0 1px var(--line); z-index: 2; + } + body.jam-selecting .jam-slot.jam-selected { box-shadow: 0 0 0 2px var(--accent); } + body.jam-selecting .jam-slot.jam-selected::before { content: "✓"; background: var(--accent); color: #1a1105; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; } + body.jam-selecting .jam-add { opacity: .5; pointer-events: none; } + #jam-select-btn.on { background: var(--accent); color: #1a1105; box-shadow: 0 0 12px var(--glow); } + #jam-batch-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; flex: none; padding: 8px; margin-bottom: 8px; border-radius: 8px; background: var(--panel); box-shadow: 0 0 0 1px var(--line); } + #jam-batch-bar .je-field { flex: none; min-width: 70px; } + /* rotary-knob alternative to the bar fader (Setup > JAM style). Both DOM + trees always exist so update() can paint whichever is hidden without a + branch; CSS just swaps which one is visible, scoped to #jam-grid so it + can never affect the Focus-view faders (they have no knob elements + anyway, but this keeps the same defensive scoping as horiz-faders). */ + .jam-knob-wrap { display: none; flex: 1; width: 100%; min-height: 0; align-items: center; justify-content: center; } + body.jam-knobs #jam-grid .jam-knob-wrap { display: flex; } + body.jam-knobs #jam-grid .fader-track-wrap { display: none; } + .jam-knob-dial { + position: relative; width: 100%; aspect-ratio: 1; max-width: 74px; max-height: 100%; + border-radius: 50%; box-shadow: inset 0 0 0 1px var(--track-line); + } + .jam-knob-dial::after { + /* center cap sits over the conic-gradient background, leaving only its + outer edge visible as a ring - reads as a knob body, not a filled pie */ + content: ""; position: absolute; inset: 7px; border-radius: 50%; + background: var(--raised); box-shadow: inset 0 0 0 1px var(--line), 0 1px 3px rgba(0,0,0,.35); + } + .jam-knob-pointer { + /* rests pointing straight up (12 o'clock, matching angle 0) so its + rotation reference agrees with the conic-gradient's - bottom:50% + + transform-origin:50% 100% pivots at the dial center with the bar + extending upward before any rotate() is applied */ + position: absolute; left: 50%; bottom: 50%; width: 3px; height: 32%; + background: #fff; border-radius: 2px; transform-origin: 50% 100%; z-index: 1; + box-shadow: 0 0 0 1px rgba(0,0,0,.4), 0 0 6px var(--glow); + } + .fader.drag .jam-knob-pointer { box-shadow: 0 0 10px var(--glow); } /* ============ 10 no-midi hint ============ */ #nomidi { position: fixed; inset: 0; z-index: 30; display: none; background: var(--bg); padding: max(30px, env(safe-area-inset-top)) 24px 24px; overflow-y: auto; touch-action: pan-y; } #nomidi.open { display: block; } @@ -347,6 +535,12 @@ .strip-faders { pointer-events: none; } .strip-faders .fader-thumb { display: none; } #efx-pad { width: 96px; } + /* fx-select now carries 4 fixed-width buttons (prev/next/RND/SAVE) - at + 46px each + gaps they'd starve the fx-name display of room to show + the effect name on a phone, so shrink them and the gap here rather + than let fx-name wrap/garble */ + #fx-select { gap: 4px; } + #fx-prev, #fx-next, #rnd-btn, #bus-save-btn { width: 34px; } #statusbar { gap: 6px; } #statusbar .sbtn { padding: 0 6px; font-size: 10px; letter-spacing: .03em; } .strip-efx { width: 100%; height: 44px; } @@ -375,7 +569,7 @@ #focus-head { flex-direction: column; height: auto; width: 120px; flex: none; } #efx-pad { width: 100%; height: 58px; flex: none; } #fx-select { flex-direction: column; } - #fx-prev, #fx-next, #rnd-btn { width: 100%; height: 40px; flex: none; } + #fx-prev, #fx-next, #rnd-btn, #bus-save-btn { width: 100%; height: 40px; flex: none; } #fx-name { flex: 1; } #view-overview { overflow-x: auto; } #view-pattern { overflow-y: auto; touch-action: pan-y; overscroll-behavior: contain; } @@ -401,6 +595,7 @@ + @@ -416,9 +611,14 @@ + -
+
+
+
+
+
@@ -426,6 +626,7 @@ +
@@ -493,6 +694,23 @@

Looper (device in looper mode, CH 1)

Chromatic: plays the current sample (CH 16, C2–C4).

+
+
+ JAM + + + + + +
+
+ 0 selected + + + +
+
+
@@ -525,6 +743,9 @@

404 BUSDRIVER – SETUP

+ + +
@@ -585,6 +806,13 @@

Current state (what gets saved)

Tap loads the slot onto all 5 buses (empty slot: saves). Hold saves into the slot. After saving, type the name right in the slot.

+
+

Recipes

+
+

A full 5-bus preset (effect + all 6 values + on/off per bus) you + type in directly, not capped at 8 like the snapshot slots above - handy for + transcribing chains from recipe cards/packs without touching a single fader.

+

Export / Import (JSON)

@@ -592,6 +820,9 @@

Export / Import (JSON)

+

Covers both snapshots and JAM – paste the same + JSON on another device to bring your whole setup over. Import replaces + JAM entirely and overwrites matching snapshot slot numbers.

@@ -613,6 +844,128 @@

Pad

+
+
+
+

JAM PRESETS

+ +
+
+
+

Adds the preset's controls to your JAM grid – + existing controls are kept. Every added control keeps its own editable + channel/CC/label afterward, same as anything else in JAM.

+
+
+
+ +
+
+
+

XY PRESETS

+ +
+
+
+

Templates

+
+
+
+

Your combos

+
+
+
+

Templates switch the bus to that effect if it isn't + already loaded, then assign both axes from its real parameter names. Your own combos just + re-point X/Y to wherever they were pointed when you saved them.

+
+
+
+ +
+
+
+

RECIPES

+ +
+
+
+
+

Tap a recipe to load it onto all 5 buses, same as a Snapshot. ✎ + edits, ✕ deletes.

+
+
+
+ +
+
+
+

NEW RECIPE

+ +
+
+
+
+
+
+
+
+ +
+
+
+

EDIT CONTROL

+ +
+
+

Kind

+
+

Source

+
+
+

Bus

+
+

Control slot

+
+
+
+

Output

+
+

Message

+
+
+ + +
+
+
+

Bus

+
+
+ +
+

Not captured yet – dial in the sound + you want live, then tap Capture.

+
+
+

Pad mode

+
+
+ + + +
+
+

Label

+
+
+ + +
+
+
+
+
@@ -1146,12 +1499,175 @@

404 Busdriver

if (def) def.p.forEach((p, i) => { if (i < 6) out[i] = ccFromDefault(p); }); return out; } +/* inverse of dispFromCc - lets the Recipe editor accept the same units a + recipe card prints (dB, ms, %, note names, ...) instead of raw 0-127, + which is the entire point of typing a card in directly. Mirrors + dispFromCc's branches; returns null for anything unparseable so the + caller can leave the field alone rather than write garbage. */ +function ccFromDisp(p, v, syncOn) { + switch (p.t) { + case "b": return /^(on|1|true|yes)$/i.test(String(v).trim()) ? 127 : 0; + case "e": { + const s = String(v).trim().toLowerCase(); + const i = p.v.findIndex((o) => o.toLowerCase() === s); + if (i >= 0) return ccFromEnum(i, p.v.length); + const n = Number(v); + return Number.isFinite(n) ? ccFromEnum(clamp(Math.round(n), 0, p.v.length - 1), p.v.length) : null; + } + case "bal": { + /* accepts the app's own "50:50" display format (right side is what + matters) as well as a recipe card's likely "80/20" and a bare + number, so whatever's printed on a card round-trips */ + const s = String(v).trim(); + const m = s.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/); + const n = m ? Number(m[2]) : Number(s); + return Number.isFinite(n) ? clamp(Math.round(n * 127 / 100), 0, 127) : null; + } + case "pan": { + /* accepts the app's own "L12"/"R12"/"CTR" display format as well as + a bare signed number */ + const s = String(v).trim().toUpperCase(); + let n; + if (s === "CTR" || s === "C" || s === "0") n = 0; + else if (s.startsWith("L")) n = -Number(s.slice(1)); + else if (s.startsWith("R")) n = Number(s.slice(1)); + else n = Number(s); + return Number.isFinite(n) ? clamp(Math.round((n + 50) * 127 / 100), 0, 127) : null; + } + case "nt": { const n = Number(v); return Number.isFinite(n) ? clamp(Math.round(n), 0, 127) : null; } + case "c": default: { + if (p.sv && syncOn) { + if (Array.isArray(p.sv)) { + const s = String(v).trim().toLowerCase(); + const i = p.sv.findIndex((o) => o.toLowerCase() === s); + if (i >= 0) return ccFromEnum(i, p.sv.length); + } else { + /* continuous {hi,lo,u} bar-multiplier range (Phaser/Flanger/Wah/ + Tremolo RATE when synced) - inverse of dispFromCc's own v = + hi + (lo-hi)*cc/127 */ + const n = Number(v); + if (Number.isFinite(n)) return clamp(Math.round((n - p.sv.hi) / (p.sv.lo - p.sv.hi) * 127), 0, 127); + } + } + const n = Number(v); + return Number.isFinite(n) ? clamp(Math.round((n - p.a) / (p.b - p.a) * 127), 0, 127) : null; + } + } +} function syncOnFor(bus, def) { /* is the SYNC bool of this effect currently ON? */ if (!def) return false; const i = def.p.findIndex((p) => p.n === "SYNC" && p.t === "b"); return i >= 0 && state.buses[bus].cc[i] >= 64; } +/* same question, but for an offline cc array (the Recipe editor's draft + isn't in state.buses yet) */ +function syncOnForCc(def, cc) { + if (!def) return false; + const i = def.p.findIndex((p) => p.n === "SYNC" && p.t === "b"); + return i >= 0 && cc[i] >= 64; +} + +/* factory-shipped recipes: SP-404 "FX Recipe" cards from + electronoir.gumroad.com (@noirtdc), transcribed straight from their own + printed values. Each bus spec lists only the fields the card actually + gives; everything else keeps that effect's own real default (via + defaultCcs), never a generic 64. Values go through ccFromDisp so they + land on exactly what the card intends (dB, Hz, note values, "L10"/"R10", + ratio splits, ...) instead of being hand-rounded here. A few cards give + a *range* for a field ("10 to 20") rather than one number - those are + meant as a live-tweak/automation range, not a fixed setting, so the + midpoint is used as a starting value; still fully editable afterward + like any recipe. The blank INPUT bus at the end of every chain is left + alone since none of these use it. */ +function buildFactoryBus(table, fxName, vals) { + const num = BUS_TABLES[table].indexOf(fxName); + const def = fxDefFor(table, num); + const cc = defaultCcs(def); + Object.keys(vals).forEach((n) => { + const i = def.p.findIndex((p) => p.n === n); + if (i < 0) return; + const v = ccFromDisp(def.p[i], vals[n], syncOnForCc(def, cc)); + if (v != null) cc[i] = v; + }); + return { fx: num, cc, on: true }; +} +const BLANK_BUS = { fx: 0, cc: [64, 64, 64, 64, 64, 64], on: false }; +const FACTORY_RECIPES = [ + { + id: "rc-house-chord-synth", + name: "House Chord Synth (noir)", + buses: () => [ + /* Overdrive -> Chorus -> Chromatic PS -> SX Reverb. Chromatic PS + does the actual "chord": +12/+7 semitones (octave + fifth) panned + apart, turning one held note into a chord-like stack. */ + buildFactoryBus("bus12", "Overdrive", { DRIVE: 32, TONE: 67, BALANCE: "80/20", LEVEL: 80 }), + buildFactoryBus("bus12", "Chorus", { DEPTH: 35, RATE: 1.20, "EQ LOW": -15, "EQ HIGH": 3, BALANCE: "80/20", LEVEL: 100 }), + buildFactoryBus("bus34", "Chromatic PS", { PITCH1: 12, PITCH2: 7, PAN1: "L10", PAN2: "R10", BALANCE: "70/30" }), + buildFactoryBus("bus34", "SX Reverb", { TIME: 100, TONE: 100, BALANCE: "70/30" }), + BLANK_BUS, + ], + }, + { + id: "rc-quack-bass", + name: "\"Quack\" Bass (noir)", + buses: () => [ + buildFactoryBus("bus12", "Super Filter", { CUTOFF: 36, RESONANCE: 32, "FLT TYPE": "LPF", DEPTH: 100, SYNC: "ON", RATE: "1/2" }), + buildFactoryBus("bus12", "Phaser", { DEPTH: 29, RESONANCE: 24, MANUAL: 59, SYNC: "ON", RATE: 0.016, BALANCE: "50/50" }), + buildFactoryBus("bus34", "Compressor", { SUSTAIN: 12, ATTACK: 61, RATIO: 15, LEVEL: 100 }), /* RATIO: card says "10 to 20" */ + buildFactoryBus("bus34", "Reverb", { TYPE: "HALL2", TIME: 60, LEVEL: 47, "LOW CUT": "315", "HIGH CUT": "5000" }), + BLANK_BUS, + ], + }, + { + id: "rc-risers", + name: "Risers (noir)", + buses: () => [ + buildFactoryBus("bus12", "Super Filter", { CUTOFF: 59, RESONANCE: 0, "FLT TYPE": "HPF", DEPTH: 0, SYNC: "OFF", RATE: 0 }), + buildFactoryBus("bus12", "Tremolo/Pan", { DEPTH: 36, WAVE: "TRI", TYPE: "PAN", SYNC: "ON", RATE: 0.300 }), + buildFactoryBus("bus34", "Overdrive", { DRIVE: 70, TONE: 40, BALANCE: "90/10", LEVEL: 100 }), + buildFactoryBus("bus34", "Ha-Dou", { "MOD DEPTH": 18, TIME: 100, LEVEL: 90, "LOW CUT": "250", "HIGH CUT": "5000" }), + BLANK_BUS, + ], + }, + { + id: "rc-bass-destroyer", + name: "Bass Destroyer (noir)", + buses: () => [ + buildFactoryBus("bus12", "Super Filter", { CUTOFF: 5, RESONANCE: 30, "FLT TYPE": "HPF" }), /* CUTOFF: card says "1 to 10" */ + buildFactoryBus("bus12", "Distortion", { DRIVE: 48, TONE: -100, BALANCE: "90/10", LEVEL: 100 }), + buildFactoryBus("bus34", "JUNO Chorus", { MODE: "JX-1 2", NOISE: 0, BALANCE: "90/10" }), + buildFactoryBus("bus34", "Compressor", { SUSTAIN: 10, ATTACK: 47, RATIO: 8, LEVEL: 100 }), + BLANK_BUS, + ], + }, + { + id: "rc-techno-kick-fattener", + name: "Techno Kick Fattener (noir)", + buses: () => [ + /* CUTOFF/RESONANCE: card says "5 to 10" / "15 to 25" */ + buildFactoryBus("bus12", "Super Filter", { CUTOFF: 8, RESONANCE: 20, "FLT TYPE": "HPF" }), + buildFactoryBus("bus12", "Equalizer", { "LOW GAIN": 4, "LOW FREQ": "100", "MID GAIN": -3, "MID FREQ": "200", "HIGH GAIN": 2, "HIGH FREQ": "2000" }), + buildFactoryBus("bus34", "Lo-fi", { "PRE FILT": 1, "LOFI TYPE": 9, TONE: 50, CUTOFF: "8000", BALANCE: "45/55", LEVEL: 100 }), + /* SUSTAIN/ATTACK/RATIO: card says "5 to 10" / "50 to 70" / "2 to 4" */ + buildFactoryBus("bus34", "Compressor", { SUSTAIN: 8, ATTACK: 60, RATIO: 3 }), + BLANK_BUS, + ], + }, + { + id: "rc-top-loops-sauce", + name: "Top Loops Sauce (noir)", + buses: () => [ + buildFactoryBus("bus12", "Super Filter", { CUTOFF: 50, RESONANCE: 0, "FLT TYPE": "HPF", DEPTH: 0 }), + buildFactoryBus("bus12", "Tremolo/Pan", { DEPTH: 76, WAVE: "TRI", TYPE: "PAN", SYNC: "ON", RATE: 0.175 }), /* RATE: card says "0.150 to 0.200" */ + buildFactoryBus("bus34", "Crusher", { FILTER: 9000, RATE: 0, BALANCE: "70/30" }), /* FILTER: card says "6000 to 12000" */ + /* L/H DAMP F: card gives a range too wide for exact enum entries - + nearest available option within it */ + buildFactoryBus("bus34", "Sync Delay", { TIME: "1/8D", FEEDBACK: 30, LEVEL: 30, "L DAMP F": "630", "H DAMP F": "6300" }), + BLANK_BUS, + ], + }, +]; /* ===== 04 STORE ===== */ const LS_KEY = "busdriver.v1"; @@ -1178,6 +1694,12 @@

404 Busdriver

auto: { bars: 4, scope: "bank" }, midi: { outName: null, inName: null }, wake: false, + horizFaders: false, /* Setup toggle: sideways-drag full-width fader strips */ + jamKnobs: false, /* Setup toggle: rotary knobs instead of bars for JAM faders */ + theme: "dark", /* Setup toggle: "dark" | "light" */ + jam: { slots: [] }, /* JAM tab: user-programmable pinned bus params + raw MIDI */ + xyPresets: [], /* XY tab: user-saved axis combos, { id, name, x:{bus,slot}, y:{bus,slot}, mom } */ + recipes: FACTORY_RECIPES.map((r) => ({ id: r.id, name: r.name, buses: r.buses() })), /* full 5-bus presets typed in from recipe cards, { id, name, buses:[{fx,cc[6],on} x5] } */ }; function directName(num) { /* num = CC#83 value 1..5 in the bus12 table */ const r = state.directFx[num - 1]; @@ -1209,6 +1731,51 @@

404 Busdriver

} let allOffSaved = null; /* [bool x5] while ALL OFF armed */ +const JAM_MAX = 24; +/* validates + clamps a JAM slot loaded from storage/import; returns null to + drop anything malformed rather than let a corrupt slot crash rendering */ +function sanitizeJamSlot(c) { + if (!c || typeof c !== "object") return null; + if (c.kind !== "pad" && c.kind !== "fader") return null; + if (c.source !== "bus" && c.source !== "raw" && c.source !== "scene") return null; + const o = { + id: (typeof c.id === "string" && c.id) ? c.id : "j" + Date.now() + Math.random().toString(36).slice(2, 7), + kind: c.kind, source: c.source, + label: (typeof c.label === "string" ? c.label : "").slice(0, 16), + }; + if (o.source === "scene") { + /* momentary/toggle recall of one bus's full state (fx + 6 cc + on/off) - + always a pad, a captured effect can't be smoothly dragged like a + single CC can */ + if (!(c.bus >= 0 && c.bus < 5)) return null; + o.kind = "pad"; + o.bus = c.bus | 0; + o.fx = clamp(c.fx | 0, 0, 127); + o.cc = Array.isArray(c.cc) && c.cc.length === 6 ? c.cc.map((v) => clamp(v | 0, 0, 127)) : [64, 64, 64, 64, 64, 64]; + o.on = !!c.on; + o.mode = c.mode === "toggle" ? "toggle" : "momentary"; + return o; + } + if (o.source === "bus") { + if (!(c.bus >= 0 && c.bus < 5)) return null; + if (!(c.ctrl >= 0 && c.ctrl < 6)) return null; + o.bus = c.bus | 0; o.ctrl = c.ctrl | 0; + } else { + o.port = (typeof c.port === "string" && c.port) ? c.port : null; + o.ch = clamp(c.ch | 0, 0, 15); + o.type = ["cc", "note", "pc"].indexOf(c.type) >= 0 ? c.type : "cc"; + if (o.kind === "fader") o.type = "cc"; /* only CC is continuous */ + o.num = clamp(c.num | 0, 0, 127); + o.val = clamp((c.val != null ? c.val : 64) | 0, 0, 127); + } + if (o.kind === "pad") { + o.mode = (c.mode === "momentary" || o.type === "pc") ? "momentary" : "toggle"; + o.onVal = clamp((c.onVal != null ? c.onVal : 127) | 0, 0, 127); + o.offVal = clamp((c.offVal != null ? c.offVal : 0) | 0, 0, 127); + o.vel = clamp((c.vel != null ? c.vel : 110) | 0, 1, 127); + } + return o; +} function loadState() { try { const raw = localStorage.getItem(LS_KEY); @@ -1286,12 +1853,35 @@

404 Busdriver

if (s.recents) state.recents = Object.assign(state.recents, s.recents); if (s.midi) state.midi = Object.assign(state.midi, s.midi); state.wake = !!s.wake; + state.horizFaders = !!s.horizFaders; + state.jamKnobs = !!s.jamKnobs; + if (s.theme === "light" || s.theme === "dark") state.theme = s.theme; + if (s.jam && Array.isArray(s.jam.slots)) { + state.jam.slots = s.jam.slots.map(sanitizeJamSlot).filter(Boolean).slice(0, JAM_MAX); + } + if (Array.isArray(s.xyPresets)) { + state.xyPresets = s.xyPresets.map(sanitizeXyPreset).filter(Boolean).slice(0, XY_PRESETS_MAX); + } + if (Array.isArray(s.recipes)) { + state.recipes = s.recipes.map(sanitizeRecipe).filter(Boolean).slice(0, RECIPE_MAX); + } + /* one-time backfill: storage saved before a given factory recipe + existed (even just an empty recipes:[] from opening the panel once) + would otherwise silently override the in-code default above and + that card would never actually show up. Runs per-recipe so cards + added in a later update still get backfilled without duplicating + ones the user already has. */ + const missing = FACTORY_RECIPES.filter((r) => !state.recipes.some((sr) => sr.id === r.id)); + if (missing.length) { + state.recipes = [...missing.map((r) => ({ id: r.id, name: r.name, buses: r.buses() })), ...state.recipes].slice(0, RECIPE_MAX); + saveState(); /* don't depend on some later, unrelated action to persist this */ + } } catch (e) { /* corrupt storage: start fresh */ } } let saveTimer = null; function writeState() { try { - localStorage.setItem(LS_KEY, JSON.stringify({ v: 1, buses: state.buses, mem: state.mem, favs: state.favs, recents: state.recents, snapshots: state.snapshots, directFx: state.directFx, xy: state.xy, lfos: state.lfos, tapBpm: state.tapBpm, ptn: state.ptn, play: state.play, auto: state.auto, midi: state.midi, wake: state.wake })); + localStorage.setItem(LS_KEY, JSON.stringify({ v: 1, buses: state.buses, mem: state.mem, favs: state.favs, recents: state.recents, snapshots: state.snapshots, directFx: state.directFx, xy: state.xy, lfos: state.lfos, tapBpm: state.tapBpm, ptn: state.ptn, play: state.play, auto: state.auto, midi: state.midi, wake: state.wake, horizFaders: state.horizFaders, jamKnobs: state.jamKnobs, jam: state.jam, xyPresets: state.xyPresets, recipes: state.recipes, theme: state.theme })); } catch (e) { /* quota/private mode: ignore */ } } function saveState() { @@ -1322,12 +1912,22 @@

404 Busdriver

this.access.inputs.forEach((p) => { if (!found && p.name === state.midi.inName && this.live(p)) found = p; }); return found; }, - sendBytes(bytes) { + /* any currently-connected output by name - lets custom controls (JAM tab) + target a second device (e.g. an MC-101) simultaneously with the SP-404, + which stays bound to state.midi.outName via out() as before */ + outByName(name) { + if (!this.access) return null; + let found = null; + this.access.outputs.forEach((p) => { if (!found && p.name === name && this.live(p)) found = p; }); + return found; + }, + sendBytesTo(portName, bytes) { if (this.demo) return true; - const o = this.out(); + const o = portName ? this.outByName(portName) : this.out(); if (!o) return false; try { o.send(bytes); return true; } catch (e) { return false; } }, + sendBytes(bytes) { return this.sendBytesTo(null, bytes); }, sendPc(ch, pc) { if (this.sendBytes([0xC0 | ch, pc & 0x7F])) blinkTx(); }, @@ -1346,6 +1946,22 @@

404 Busdriver

this.echoLog.set(key, { val, t: performance.now() }); if (this.sendBytes([0xB0 | ch, ccNum, val])) blinkTx(); }, + /* -To variants: same messages, but routed to an explicit port name + (null = primary SP-404 output) instead of always the primary port */ + sendToPort(portName, ch, ccNum, val) { + val = clamp(val | 0, 0, 127); + /* log to echoLog same as send() - lets applyRawCC recognize this value + bouncing back from a device that echoes its own incoming CCs, instead + of misreading it as an external change a moment later */ + this.echoLog.set(ch << 8 | ccNum, { val, t: performance.now() }); + if (this.sendBytesTo(portName, [0xB0 | ch, ccNum, val])) blinkTx(); + }, + sendNoteTo(portName, ch, note, on, vel) { + if (this.sendBytesTo(portName, on ? [0x90 | ch, note & 0x7F, vel || 110] : [0x80 | ch, note & 0x7F, 0])) blinkTx(); + }, + sendPcTo(portName, ch, pc) { + if (this.sendBytesTo(portName, [0xC0 | ch, pc & 0x7F])) blinkTx(); + }, async connect(interactive) { if (!navigator.requestMIDIAccess) return; try { @@ -1400,6 +2016,10 @@

404 Busdriver

if (rt) msg = Array.from(d).filter((b) => b < 0xF8); if (msg.length < 3) return; const type = msg[0] & 0xF0, ch = msg[0] & 0x0F; + /* JAM raw controls can be pinned to any channel (e.g. a second device), + so this check runs independently of the ch<=4/RECV_CCS gate below, + which is specific to the SP-404's own 5-bus CC set */ + if (type === 0xB0) applyRawCC(ch, msg[1], msg[2]); if (type !== 0xB0 || ch > 4 || !RECV_CCS.has(msg[1])) return; handleBytes(ch, msg[1], msg[2]); } @@ -1439,6 +2059,43 @@

404 Busdriver

} } +/* incoming CC from ANY connected input, matched against JAM raw controls by + channel+CC number - applyCC/handleBytes above is the SP-404-specific + pipeline (bus-indexed, only looks at the primary input's ch 0-4), so a + second device pinned in JAM (e.g. an MC-101's own hardware knobs on ch + 11-14) needs its own path that scans every connected port instead of + just the one selected as the primary SP-404 input. */ +const jamRawRenderQueue = new Set(); /* cfg.id values needing repaint */ +let jamRawFlushReq = 0; +function queueJamRawRender(id) { + jamRawRenderQueue.add(id); + if (jamRawFlushReq) return; + jamRawFlushReq = requestAnimationFrame(() => { + jamRawFlushReq = 0; + const q = [...jamRawRenderQueue]; + jamRawRenderQueue.clear(); + q.forEach((rid) => { + const fns = jamRawUpdateFns.get(rid); + if (fns) fns.forEach((fn) => { try { fn(); } catch (e) { /* one broken control must never freeze the render loop */ } }); + }); + }); +} +function applyRawCC(ch, ccNum, val) { + const key = ch << 8 | ccNum; + if (Midi.activeCCs.has(key)) return; /* a finger is dragging this control */ + const e = Midi.echoLog.get(key); + if (e && e.val === val && performance.now() - e.t < 400) return; /* our own send bouncing back */ + let touched = false; + state.jam.slots.forEach((cfg) => { + if (cfg.source === "raw" && cfg.type === "cc" && cfg.ch === ch && cfg.num === ccNum) { + cfg.val = clamp(val, 0, 127); + touched = true; + queueJamRawRender(cfg.id); + } + }); + if (touched) saveState(); +} + /* ===== effect change sequence (the one deterministic sync point) ===== */ function memKey(table, num) { if (table === "bus12" && num >= 1 && num <= 5) { @@ -1519,6 +2176,14 @@

404 Busdriver

/* drags must survive DOM rebuilds (device fx change, bus switch mid-drag): otherwise activeCCs leaks and the CC goes deaf for device input */ const liveDrags = new Map(); /* key ch<<8|cc -> { node, finish } */ +/* JAM tiles pinned to a bus/ctrl slot need repainting on the same events as + the focus/overview faders for that slot (hardware encoder input, effect + switch, snapshot load) - keyed the same as renderQueue (bus*8+slot) */ +const jamByBusSlot = new Map(); /* key -> Set<() => void> */ +/* same idea for JAM raw controls pinned to a second device (e.g. an MC-101's + own hardware knobs) - keyed by the JAM slot's own id since raw controls + aren't tied to a bus/slot pair the way jamByBusSlot's keys are */ +const jamRawUpdateFns = new Map(); /* cfg.id -> Set<() => void> */ function finishDragsIn(host) { for (const [k, d] of [...liveDrags]) { if (host.contains(d.node)) d.finish(); @@ -1541,6 +2206,8 @@

404 Busdriver

try { if (controls[bus][slot]) controls[bus][slot].update(); if (stripControls[bus][slot]) stripControls[bus][slot].update(); + const jc = jamByBusSlot.get(k); + if (jc) jc.forEach((fn) => fn()); } catch (e) { /* one broken control must never freeze the whole render loop */ } } }); @@ -1569,6 +2236,119 @@

404 Busdriver

} function closeStepper() { $("stepper").classList.remove("open"); } +/* paints a fader's fill/thumb from a single 0-100 percent - orientation + (vertical vs. body.horiz-faders) is resolved entirely in CSS via --pct */ +function paintFader(fill, thumb, pct) { + fill.style.setProperty("--pct", pct + "%"); + thumb.style.setProperty("--pct", pct + "%"); +} + +/* JAM's alternate "rotary knob" style (Setup > JAM style). Same 0-100 pct + as paintFader, remapped as a left/right tilt from straight-up (12 + o'clock = the 0-127 midpoint, ~64): drag right of center to increase + toward 127, left of center to decrease toward 0, same balance-pot + convention as .fader's "bal" display. The arc fills from the 12 o'clock + reference out toward whichever side the pointer has tilted, so a + centered value shows an empty ring. */ +/* 135deg off vertical = the classic 270deg-total hardware-pot sweep (7:30 + to 4:30), just re-centered so 12 o'clock is the value's midpoint instead + of the sweep's start - at +-60deg the fill barely showed at either + extreme, this reads as "maxed out" the way a real knob does. */ +const KNOB_MAX_TILT = 135; +function paintKnob(dial, pointer, pct) { + const angle = (pct - 50) / 50 * KNOB_MAX_TILT; + pointer.style.transform = "rotate(" + angle + "deg)"; + const lo = Math.min(0, angle), hi = Math.max(0, angle); + dial.style.background = + "conic-gradient(from " + lo + "deg, var(--accent) 0deg " + (hi - lo) + "deg, var(--track-bg) " + (hi - lo) + "deg 360deg)"; +} + +/* generic relative-drag fader engine, shared by bus focus faders and + custom MIDI faders (index.html custom tab). Orientation-aware: reads + body.horiz-faders so the same code drives vertical and sideways drag, + swapping which axis is "value" vs. "fine-tune pull". */ +function attachFaderDrag(fader, dom, opts) { + let drag = null, hideT = 0, lastTap = 0; + /* horiz-faders is scoped off for callers that pass allowHoriz:false (the + JAM grid: its tiles are narrow columns, a sideways fader has no travel + to work with there regardless of the Focus-view toggle) */ + const horiz = () => opts.allowHoriz !== false && document.body.classList.contains("horiz-faders"); + function finishDrag() { + if (!drag) return; + drag = null; + if (opts.key != null) liveDrags.delete(opts.key); + if (opts.echoKey != null) Midi.activeCCs.delete(opts.echoKey); + fader.classList.remove("drag"); + hideT = setTimeout(() => { dom.big.classList.remove("show"); dom.big.style.marginLeft = ""; }, 800); + if (opts.trailingSend) opts.trailingSend(); + } + fader.addEventListener("pointerdown", (e) => { + e.preventDefault(); + fader.setPointerCapture(e.pointerId); + const h = horiz(); + drag = { + id: e.pointerId, startX: e.clientX, startY: e.clientY, + lastPos: h ? e.clientX : e.clientY, horiz: h, + acc: opts.get(), moved: 0, + /* sensitivity (finger travel for full 0-127 sweep) is normally derived + from the visible track size, floored at 60px - but a small tile + (JAM grid) would make that floor the operative value, giving a + twitchy few-cm sweep. opts.sensitivity overrides it with a fixed, + comfortable travel distance decoupled from how small the control + is drawn - the same principle as pointer capture already letting a + drag continue past the tile's edges. */ + h: opts.sensitivity || Math.max(h ? dom.track.clientWidth : dom.track.clientHeight, 60), + }; + if (opts.key != null) liveDrags.set(opts.key, { node: fader, finish: finishDrag }); + if (opts.echoKey != null) Midi.activeCCs.add(opts.echoKey); + fader.classList.add("drag"); + clearTimeout(hideT); + dom.big.classList.add("show"); + /* clamp the big readout to the viewport (edge slots) */ + const r = dom.big.getBoundingClientRect(); + dom.big.style.marginLeft = (Math.max(0, 8 - r.left) - Math.max(0, r.right - window.innerWidth + 8)) + "px"; + }); + fader.addEventListener("pointermove", (e) => { + if (!drag || e.pointerId !== drag.id) return; + const pos = drag.horiz ? e.clientX : e.clientY; + const perp = drag.horiz ? Math.abs(e.clientY - drag.startY) : Math.abs(e.clientX - drag.startX); + const fine = perp > 120 ? 0.1 : perp > 60 ? 0.25 : 1; + const dv = drag.horiz ? (pos - drag.lastPos) : (drag.lastPos - pos); + drag.lastPos = pos; + drag.moved = Math.max(drag.moved, Math.abs(e.clientY - drag.startY), Math.abs(e.clientX - drag.startX)); + drag.acc = clamp(drag.acc + dv * fine * 127 / drag.h, 0, 127); + const target = opts.snapN ? ccFromEnum(enumFromCc(Math.round(drag.acc), opts.snapN), opts.snapN) : Math.round(drag.acc); + if (target !== opts.get()) opts.set(target, {}); + }); + function endDrag(e) { + if (!drag || e.pointerId !== drag.id) return; + const moved = drag.moved; + finishDrag(); + if (moved < 6) { + const now = performance.now(); + if (now - lastTap < 300) { /* double tap -> default */ + if (opts.onDefault) opts.set(opts.onDefault(), { force: true }); + lastTap = 0; + } else lastTap = now; + } + } + fader.addEventListener("pointerup", endDrag); + fader.addEventListener("pointercancel", endDrag); + fader.addEventListener("wheel", (e) => { + e.preventDefault(); + const step = (e.shiftKey ? 10 : 1) * (e.deltaY < 0 ? 1 : -1); + opts.set(opts.get() + step, { force: true }); + }, { passive: false }); + if (dom.val && opts.onStepperOpen) { + dom.val.addEventListener("pointerdown", (e) => { + e.stopPropagation(); e.preventDefault(); + const r = dom.val.getBoundingClientRect(); + opts.onStepperOpen(r.left + r.width / 2, r.top); + }); + } + return { finishDrag }; +} + /* one control in a slot; kind: "fader" | "snap" | "seg" | "toggle" */ function buildControl(slotEl, bus, slot, mini) { const B = BUSES[bus]; @@ -1646,10 +2426,8 @@

404 Busdriver

function curCc() { return state.buses[bus].cc[slot]; } function disp(cc) { return dispFromCc(p, cc, syncOnFor(bus, def)); } function update() { - const cc = curCc(), pct = cc / 127 * 100; - fill.style.height = pct + "%"; - /* travel limited to track minus thumb height so the thumb never leaves the track */ - thumb.style.bottom = "calc(" + (cc / 127) + " * (100% - var(--thumb-h)))"; + const cc = curCc(); + paintFader(fill, thumb, cc / 127 * 100); const d = disp(cc); val.textContent = d; big.textContent = d; @@ -1658,69 +2436,14 @@

404 Busdriver

update(); const key = B.ch << 8 | CC_CTRL[slot]; - let drag = null, hideT = 0, lastTap = 0; - function finishDrag() { - /* idempotent cleanup; also runs when a rebuild removes this fader mid-drag */ - if (!drag) return; - drag = null; - liveDrags.delete(key); - Midi.activeCCs.delete(key); - fader.classList.remove("drag"); - hideT = setTimeout(() => { big.classList.remove("show"); big.style.marginLeft = ""; }, 800); - /* trailing send: guarantee final value reaches the device */ - Midi.send(B.ch, CC_CTRL[slot], curCc(), true); - } - fader.addEventListener("pointerdown", (e) => { - e.preventDefault(); - fader.setPointerCapture(e.pointerId); - drag = { id: e.pointerId, lastY: e.clientY, startX: e.clientX, startY: e.clientY, - acc: curCc(), moved: 0, h: Math.max(track.clientHeight, 60) }; - Midi.activeCCs.add(key); - liveDrags.set(key, { node: fader, finish: finishDrag }); - fader.classList.add("drag"); - clearTimeout(hideT); - big.classList.add("show"); - /* clamp the big readout to the viewport (edge slots) */ - const r = big.getBoundingClientRect(); - big.style.marginLeft = (Math.max(0, 8 - r.left) - Math.max(0, r.right - window.innerWidth + 8)) + "px"; - }); - fader.addEventListener("pointermove", (e) => { - if (!drag || e.pointerId !== drag.id) return; - const dx = Math.abs(e.clientX - drag.startX); - const fine = dx > 120 ? 0.1 : dx > 60 ? 0.25 : 1; - const dy = drag.lastY - e.clientY; - drag.lastY = e.clientY; - drag.moved = Math.max(drag.moved, Math.abs(e.clientY - drag.startY), dx); - drag.acc = clamp(drag.acc + dy * fine * 127 / drag.h, 0, 127); - const target = snapN ? ccFromEnum(enumFromCc(Math.round(drag.acc), snapN), snapN) : Math.round(drag.acc); - if (target !== curCc()) setBusCc(bus, slot, target, {}); + attachFaderDrag(fader, { val: mini ? null : val, big, track }, { + key, echoKey: key, snapN, + get: curCc, + set: (v, o) => setBusCc(bus, slot, v, o), + onDefault: () => ccFromDefault(p), + trailingSend: () => Midi.send(B.ch, CC_CTRL[slot], curCc(), true), + onStepperOpen: mini ? null : (x, y) => openStepper(x, y, bus, slot), }); - function endDrag(e) { - if (!drag || e.pointerId !== drag.id) return; - const moved = drag.moved; - finishDrag(); - if (moved < 6) { - const now = performance.now(); - if (now - lastTap < 300) { /* double tap -> default */ - setBusCc(bus, slot, ccFromDefault(p), { force: true }); - lastTap = 0; - } else lastTap = now; - } - } - fader.addEventListener("pointerup", endDrag); - fader.addEventListener("pointercancel", endDrag); - fader.addEventListener("wheel", (e) => { - e.preventDefault(); - const step = (e.shiftKey ? 10 : 1) * (e.deltaY < 0 ? 1 : -1); - setBusCc(bus, slot, curCc() + step, { force: true }); - }, { passive: false }); - if (!mini) { - val.addEventListener("pointerdown", (e) => { - e.stopPropagation(); e.preventDefault(); - const r = val.getBoundingClientRect(); - openStepper(r.left + r.width / 2, r.top, bus, slot); - }); - } return api; } @@ -1737,6 +2460,43 @@

404 Busdriver

} /* other buses' focus controls are stale references now */ BUSES.forEach((_, b) => { if (b !== bus) controls[b] = [null, null, null, null, null, null]; }); + renderFocusScenes(); +} +/* saved bus-scene JAM pads for the active bus, recalled right here instead + of needing a trip to the JAM tab - same cfg objects, same attachJamPad + interaction (hold/toggle), just a second place they're rendered. + Pressing one of these applies a full bus state -> renderBus() -> (since + it's the active bus) renderFocusSlots() -> this function again, all while + the finger is still down. A naive rebuild here would tear down the very + button mid-press, breaking its pointer capture before release ever fires + (release lands on a fresh button whose "down" never went true). The + signature check makes this a no-op unless the actual scene *set* (which + bus, which scenes, their labels) changed, so applying a scene - which + only changes bus values, not the JAM list - never touches the DOM. */ +let lastFocusScenesSig = null; +function renderFocusScenes() { + const host = $("focus-scenes"); + if (!host) return; + const scenes = state.jam.slots.filter((cfg) => cfg.source === "scene" && cfg.bus === state.activeBus); + const sig = state.activeBus + ":" + scenes.map((c) => c.id + "=" + c.label).join(","); + if (sig === lastFocusScenesSig) return; + lastFocusScenesSig = sig; + host.textContent = ""; + host.classList.toggle("has-scenes", scenes.length > 0); + scenes.forEach((cfg) => { + const item = el("div", "focus-scene-item"); + const btn = el("button", "pad focus-scene-btn", jamLabel(cfg)); + item.appendChild(btn); + const editBtn = el("button", "focus-scene-edit", "✎"); + editBtn.addEventListener("pointerdown", (e) => { + e.preventDefault(); e.stopPropagation(); + const idx = state.jam.slots.indexOf(cfg); + if (idx >= 0) openJamEditor(idx); + }); + item.appendChild(editBtn); + host.appendChild(item); + attachJamPad(btn, cfg); + }); } function updateFocusHead() { const bus = state.activeBus, b = state.buses[bus], B = BUSES[bus]; @@ -1744,6 +2504,33 @@

404 Busdriver

$("efx-pad").textContent = b.on ? "EFX ON" : "EFX"; $("fx-name").querySelector(".fx-title").textContent = fxDisplayName(B.table, b.fx); $("fx-name").querySelector(".fx-sub").textContent = B.label + " · choose effect"; + renderFocusFavs(); +} +/* one-tap favorite-effect chips for the bus currently shown in Focus - the + same favorites the picker's FAVORITES section and its "FA" jump button + surface, just reachable without opening the picker at all. Re-rendered + from updateFocusHead() (covers setActiveBus/renderBus/bus switches) and + from the picker's star-hold handler (favoriting doesn't change fx, so + nothing else would refresh this strip). */ +function renderFocusFavs() { + const host = $("focus-favs"); + if (!host) return; + const bus = state.activeBus, B = BUSES[bus], table = B.table; + const nums = favList(table).filter((n) => n < BUS_TABLES[table].length); + host.classList.toggle("show", nums.length > 0); + host.textContent = ""; + nums.forEach((n) => { + const chip = el("button", "focus-fav-chip" + (n === state.buses[bus].fx ? " sel" : ""), fxNameFor(table, n)); + chip.addEventListener("click", () => { + fxChange(bus, n, "user"); + /* a favorite chip is for loading an effect to go tweak it, not for + punching it in live - always land with EFX off so nothing is heard + until you deliberately hit EFX, unlike the picker (which is a more + deliberate action to begin with) */ + sendSw(bus, false); + }); + host.appendChild(chip); + }); } function updateBusChrome(bus) { const b = state.buses[bus]; @@ -1768,19 +2555,21 @@

404 Busdriver

} function currentView() { const b = document.body.classList; - return b.contains("xy") ? "xy" : b.contains("pattern") ? "pattern" : b.contains("play") ? "play" : b.contains("overview") ? "overview" : "focus"; + return b.contains("xy") ? "xy" : b.contains("pattern") ? "pattern" : b.contains("play") ? "play" : b.contains("jam") ? "jam" : b.contains("overview") ? "overview" : "focus"; } function setView(v) { const b = document.body.classList; - b.remove("overview", "xy", "pattern", "play"); + b.remove("overview", "xy", "pattern", "play", "jam"); if (v !== "focus") b.add(v); $("xy-btn").classList.toggle("on", v === "xy"); if (patternTab) patternTab.classList.toggle("active", v === "pattern"); if (playTab) playTab.classList.toggle("active", v === "play"); + if (jamTab) jamTab.classList.toggle("active", v === "jam"); $("view-toggle").textContent = v === "overview" ? "Focus" : "Overview"; if (v === "xy") renderXy(); if (v === "pattern") renderPattern(); if (v === "play") renderPlay(); + if (v === "jam") buildJam(); } function gotoFocus(bus) { setActiveBus(bus); @@ -1792,7 +2581,7 @@

404 Busdriver

/* bus tabs */ const tabs = [], tabLeds = []; -let patternTab = null, playTab = null; +let patternTab = null, playTab = null, jamTab = null; function buildTabs() { const host = $("bustabs"); BUSES.forEach((B, i) => { @@ -1826,6 +2615,14 @@

404 Busdriver

}); host.appendChild(pt); patternTab = pt; + const jm = el("button", "bustab"); + jm.appendChild(el("span", "", "JAM")); + jm.addEventListener("pointerdown", (e) => { + e.preventDefault(); + setView(currentView() === "jam" ? "focus" : "jam"); + }); + host.appendChild(jm); + jamTab = jm; } /* overview strips */ @@ -1953,6 +2750,25 @@

404 Busdriver

}); saveState(); } +/* per-bus instant apply (fx + 6 cc + on/off) - same staggered-send shape as + applyFullState but scoped to one bus and its own timing (kept separate + from applyFullState rather than refactored into it, so the already-relied + -on multi-bus snapshot load path stays untouched). Used by JAM scene pads + to jump into a captured bus state on press and back out on release. */ +function applyBusState(bus, src) { + const B = BUSES[bus], cur = state.buses[bus]; + fxTimers[bus].forEach(clearTimeout); fxTimers[bus] = []; + if (cur.fx > 0) state.mem[memKey(B.table, cur.fx)] = cur.cc.slice(); + cur.fx = src.fx | 0; + cur.cc = src.cc.map((v) => clamp(v | 0, 0, 127)); + cur.on = !!src.on; + let d = 0; + fxTimers[bus].push(setTimeout(() => Midi.send(B.ch, CC_FX, cur.fx, true), d)); d += 6; + cur.cc.forEach((v, j) => { fxTimers[bus].push(setTimeout(() => Midi.send(B.ch, CC_CTRL[j], v, true), d)); d += 6; }); + fxTimers[bus].push(setTimeout(() => Midi.send(B.ch, CC_SW, cur.on ? 127 : 0, true), d)); d += 6; + renderBus(bus); + saveState(); +} function busesSummary(buses, compact) { /* compact: "1● Tape Echo · 3○ Lo-fi" (● an, ○ gesetzt aber aus) */ const parts = []; @@ -2016,6 +2832,7 @@

404 Busdriver

} let snapEls = []; function renderSnaps() { + updateImportBtn(); $("snap-current").textContent = busesSummary(state.buses, false); const grid = $("snap-grid"); grid.textContent = ""; @@ -2064,7 +2881,7 @@

404 Busdriver

} function doExport() { const ta = $("exchange"); - ta.value = JSON.stringify({ busdriver: 1, snapshots: state.snapshots }, null, 1); + ta.value = JSON.stringify({ busdriver: 1, snapshots: state.snapshots, jam: state.jam.slots, xyPresets: state.xyPresets, recipes: state.recipes }, null, 1); ta.focus(); ta.select(); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(ta.value) @@ -2072,26 +2889,237 @@

404 Busdriver

.catch(() => {}); } } +/* same window.confirm() unreliability as clearJam() (see its comment) - + act immediately and allow undo instead of gating on a dialog that some + Web MIDI wrapper browsers silently stub to false */ +let importUndo = null; /* { snapshots, jam, xyPresets, recipes, t } */ function doImport() { + if (importUndo && performance.now() - importUndo.t < 8000) { + state.snapshots = importUndo.snapshots; + state.jam.slots = importUndo.jam; + state.xyPresets = importUndo.xyPresets; + state.recipes = importUndo.recipes; + importUndo = null; + saveState(); scheduleSync(); renderSnaps(); buildJam(); + updateImportBtn(); + showBanner("Import undone", "info", 2000); + return; + } let d = null; try { d = JSON.parse($("exchange").value); } catch (e) {} - if (!d || d.busdriver !== 1 || !Array.isArray(d.snapshots)) { + if (!d || d.busdriver !== 1 || (!Array.isArray(d.snapshots) && !Array.isArray(d.jam) && !Array.isArray(d.xyPresets) && !Array.isArray(d.recipes))) { showBanner("No valid Busdriver JSON in the text field", "warn", 3000); return; } - let ok = true; - try { ok = window.confirm("Import overwrites matching slot numbers from the JSON. Continue?"); } catch (e) {} - if (!ok) return; + importUndo = { snapshots: state.snapshots.slice(), jam: state.jam.slots.slice(), xyPresets: state.xyPresets.slice(), recipes: state.recipes.slice(), t: performance.now() }; let n = 0; - for (let i = 0; i < 8; i++) { - const r = d.snapshots[i]; - if (r && !r.del && Array.isArray(r.buses)) { - state.snapshots[i] = { t: Date.now(), name: String(r.name || "Import " + (i + 1)).slice(0, 24), buses: r.buses }; - n++; + if (Array.isArray(d.snapshots)) { + for (let i = 0; i < 8; i++) { + const r = d.snapshots[i]; + if (r && !r.del && Array.isArray(r.buses)) { + state.snapshots[i] = { t: Date.now(), name: String(r.name || "Import " + (i + 1)).slice(0, 24), buses: r.buses }; + n++; + } } } + let jn = 0; + if (Array.isArray(d.jam)) { + state.jam.slots = d.jam.map(sanitizeJamSlot).filter(Boolean).slice(0, JAM_MAX); + jn = state.jam.slots.length; + buildJam(); + } + let xn = 0; + if (Array.isArray(d.xyPresets)) { + state.xyPresets = d.xyPresets.map(sanitizeXyPreset).filter(Boolean).slice(0, XY_PRESETS_MAX); + xn = state.xyPresets.length; + renderXyCustomList(); + } + let rn = 0; + if (Array.isArray(d.recipes)) { + state.recipes = d.recipes.map(sanitizeRecipe).filter(Boolean).slice(0, RECIPE_MAX); + rn = state.recipes.length; + renderRecipesList(); + } saveState(); scheduleSync(); renderSnaps(); - showBanner(n + " snapshot(s) imported", "info", 2500); + showBanner(n + " snapshot(s), " + jn + " JAM control(s), " + xn + " XY combo(s), " + rn + " recipe(s) imported – tap Import again within 8s to undo", "info", 3500); + updateImportBtn(); + setTimeout(updateImportBtn, 8100); +} +function updateImportBtn() { + const btn = $("imp-btn"); + if (!btn) return; + const active = importUndo && performance.now() - importUndo.t < 8000; + btn.textContent = active ? "Undo Import" : "Import"; +} + +/* ===== Recipes: full 5-bus presets typed in directly (e.g. from an + SP-404 "recipe card" pack) rather than captured from live state like + Snapshots. Same {fx, cc[6], on} per-bus shape as a Snapshot's buses + array and loaded the same way (applyFullState), just entered by hand - + the whole point is letting a card's own printed values (dB, ms, %, note + names) go straight into the app via ccFromDisp instead of eyeballing + knob positions. Not capped at 8 like the Snapshot slots. ===== */ +const RECIPE_MAX = 60; +function sanitizeRecipe(r) { + if (!r || typeof r !== "object" || !Array.isArray(r.buses) || r.buses.length !== 5) return null; + const buses = r.buses.map((b) => ({ + fx: clamp((b && b.fx | 0) || 0, 0, 127), + cc: (b && Array.isArray(b.cc) && b.cc.length === 6) ? b.cc.map((v) => clamp(v | 0, 0, 127)) : [64, 64, 64, 64, 64, 64], + on: !!(b && b.on), + })); + return { + id: (typeof r.id === "string" && r.id) ? r.id : "rc" + Date.now() + Math.random().toString(36).slice(2, 7), + name: (typeof r.name === "string" && r.name.trim()) ? r.name.slice(0, 30) : "Recipe", + buses, + }; +} +function blankRecipeBuses() { + return BUSES.map(() => ({ fx: 0, cc: [64, 64, 64, 64, 64, 64], on: false })); +} +function openRecipes() { + renderRecipesList(); + $("recipes").classList.add("open"); +} +function renderRecipesList() { + const host = $("recipes-list"); + if (!host) return; + host.textContent = ""; + if (!state.recipes.length) { + host.appendChild(el("p", "pick-note", "No recipes yet — tap “+ New Recipe” below and type one in from a card.")); + return; + } + state.recipes.forEach((r, i) => { + const row = el("div", "xy-custom-row"); + const nameBtn = el("button", "setup-btn xy-custom-name", r.name); + nameBtn.addEventListener("click", () => loadRecipe(r)); + const ren = el("button", "xy-custom-icon", "✎"); + ren.addEventListener("click", (e) => { e.stopPropagation(); openRecipeEditor(i); }); + const del = el("button", "xy-custom-icon", "✕"); + del.addEventListener("click", (e) => { e.stopPropagation(); deleteRecipe(r.id); }); + row.appendChild(nameBtn); row.appendChild(ren); row.appendChild(del); + host.appendChild(row); + }); +} +function loadRecipe(r) { + applyFullState(r.buses); + $("recipes").classList.remove("open"); + showBanner("\"" + r.name + "\" loaded", "info", 2000); +} +function deleteRecipe(id) { + state.recipes = state.recipes.filter((r) => r.id !== id); + saveState(); + renderRecipesList(); +} +let recipeDraft = null; /* { id, name, buses[5] } while the editor is open */ +let recipeEditIndex = null; +function openRecipeEditor(index) { + recipeEditIndex = index; + const r = (index != null) ? state.recipes[index] : null; + recipeDraft = r + ? { id: r.id, name: r.name, buses: r.buses.map((b) => ({ fx: b.fx, cc: b.cc.slice(), on: b.on })) } + : { id: null, name: "Recipe " + (state.recipes.length + 1), buses: blankRecipeBuses() }; + $("recipe-edit-title").textContent = (index != null) ? "EDIT RECIPE" : "NEW RECIPE"; + $("re-name").value = recipeDraft.name; + renderRecipeBuses(); + $("recipes").classList.remove("open"); + $("recipe-edit").classList.add("open"); +} +function renderRecipeBuses() { + const host = $("re-buses"); + host.textContent = ""; + BUSES.forEach((B, bus) => { + const draft = recipeDraft.buses[bus]; + const table = BUS_TABLES[B.table]; + const def = fxDefFor(B.table, draft.fx); + const block = el("div", "re-bus-block"); + block.appendChild(el("h4", "", B.label)); + const sel = el("select", "re-fx-select"); + table.forEach((name, num) => { + const o = document.createElement("option"); + o.value = String(num); o.textContent = name; + if (num === draft.fx) o.selected = true; + sel.appendChild(o); + }); + sel.addEventListener("change", () => { + draft.fx = +sel.value; + /* start from that effect's own defaults - a leftover cc set from + whatever the previous effect was here means nothing on the new one */ + draft.cc = defaultCcs(fxDefFor(B.table, draft.fx)); + renderRecipeBuses(); + }); + block.appendChild(sel); + const onRow = el("label", "re-onoff"); + const onCb = document.createElement("input"); + onCb.type = "checkbox"; onCb.checked = draft.on; + onCb.addEventListener("change", () => { draft.on = onCb.checked; }); + onRow.appendChild(onCb); + onRow.appendChild(document.createTextNode("EFX on")); + block.appendChild(onRow); + if (def) { + const fields = el("div", "re-fields"); + def.p.forEach((p, i) => { + if (i >= 6) return; + const syncOn = syncOnForCc(def, draft.cc); + const field = el("label", "je-field", p.n + (p.u ? " (" + p.u + ")" : "")); + if (p.t === "e") { + const s = document.createElement("select"); + s.className = "re-fx-select"; + p.v.forEach((opt, oi) => { + const o = document.createElement("option"); + o.value = String(oi); o.textContent = opt; + if (oi === enumFromCc(draft.cc[i], p.v.length)) o.selected = true; + s.appendChild(o); + }); + s.addEventListener("change", () => { draft.cc[i] = ccFromEnum(+s.value, p.v.length); }); + field.appendChild(s); + } else if (p.t === "b") { + const cb = document.createElement("input"); + cb.type = "checkbox"; cb.checked = draft.cc[i] >= 64; + cb.addEventListener("change", () => { + draft.cc[i] = cb.checked ? 127 : 0; + if (p.n === "SYNC") renderRecipeBuses(); /* flips sibling fields between ms/note display */ + }); + field.appendChild(cb); + } else if (p.t === "c" && p.sv && syncOn && Array.isArray(p.sv)) { + const s = document.createElement("select"); + s.className = "re-fx-select"; + p.sv.forEach((opt, oi) => { + const o = document.createElement("option"); + o.value = String(oi); o.textContent = opt; + if (oi === enumFromCc(draft.cc[i], p.sv.length)) o.selected = true; + s.appendChild(o); + }); + s.addEventListener("change", () => { draft.cc[i] = ccFromEnum(+s.value, p.sv.length); }); + field.appendChild(s); + } else { + const inp = document.createElement("input"); + inp.type = "text"; inp.inputMode = "decimal"; + inp.value = dispFromCc(p, draft.cc[i], syncOn); + inp.addEventListener("change", () => { + const v = ccFromDisp(p, inp.value, syncOn); + if (v != null) draft.cc[i] = v; + }); + field.appendChild(inp); + } + fields.appendChild(field); + }); + block.appendChild(fields); + } + host.appendChild(block); + }); +} +function saveRecipeDraft() { + const name = $("re-name").value.trim().slice(0, 30) || recipeDraft.name; + const saved = { id: recipeDraft.id || ("rc" + Date.now() + Math.random().toString(36).slice(2, 7)), name, buses: recipeDraft.buses }; + if (recipeEditIndex != null) state.recipes[recipeEditIndex] = saved; + else { + if (state.recipes.length >= RECIPE_MAX) { showBanner("Recipe list is full (" + RECIPE_MAX + ") — delete one first", "warn", 3000); return; } + state.recipes.push(saved); + } + saveState(); + $("recipe-edit").classList.remove("open"); + openRecipes(); + showBanner("\"" + name + "\" saved", "info", 2000); } /* ===== randomizer (one-step undo via second tap within 6 s) ===== */ @@ -2202,6 +3230,7 @@

404 Busdriver

pressFired = true; state.favs[B.table][num] = { on: !isFav(B.table, num), t: Date.now() }; saveState(); scheduleSync(); openPicker(bus); /* re-render, keep open */ + renderFocusFavs(); /* favoriting doesn't change fx, nothing else would refresh this */ }, 420); } }); @@ -2234,17 +3263,36 @@

404 Busdriver

grid0.appendChild(mkItem(0)); body.appendChild(grid0); + /* jump bar built up front so RECENT/FAVORITES get a shortcut too, same as + the categories below - favorites in particular can end up far down a + long list (e.g. bus34's 40 effects), so it needs the same one-tap + jump the categories already get, not just a scroll */ + const oldJump = $("jumpbar"); + if (oldJump) oldJump.remove(); + const jump = el("div", ""); jump.id = "jumpbar"; + const addJumpBtn = (label, sec) => { + const jb = el("button", "", label); + jb.addEventListener("pointerdown", (e) => { + e.preventDefault(); + sec.scrollIntoView({ block: "start", behavior: "smooth" }); + }); + jump.appendChild(jb); + }; + const addRow = (title, nums) => { - if (!nums.length) return; + if (!nums.length) return null; const cat = el("div", "pick-cat"); cat.appendChild(el("h3", "", title)); const g = el("div", "pick-grid"); nums.forEach((n) => g.appendChild(mkItem(n))); cat.appendChild(g); body.appendChild(cat); + return cat; }; - addRow("RECENT", state.recents[B.table].filter((n) => n < table.length)); - addRow("FAVORITES", favList(B.table).filter((n) => n < table.length)); + const recentSec = addRow("RECENT", state.recents[B.table].filter((n) => n < table.length)); + const favSec = addRow("FAVORITES", favList(B.table).filter((n) => n < table.length)); + if (recentSec) addJumpBtn("RE", recentSec); + if (favSec) addJumpBtn("FA", favSec); const catSecs = {}; CAT_ORDER.forEach((c) => { catSecs[c] = []; }); @@ -2253,9 +3301,6 @@

404 Busdriver

const c = def ? def.c : "weird"; (catSecs[c] || catSecs.weird).push(n); } - const oldJump = $("jumpbar"); - if (oldJump) oldJump.remove(); - const jump = el("div", ""); jump.id = "jumpbar"; CAT_ORDER.forEach((c) => { if (!catSecs[c].length) return; const sec = el("div", "pick-cat"); @@ -2265,12 +3310,7 @@

404 Busdriver

catSecs[c].forEach((n) => g.appendChild(mkItem(n))); sec.appendChild(g); body.appendChild(sec); - const jb = el("button", "", CAT_LABELS[c].slice(0, 2)); - jb.addEventListener("pointerdown", (e) => { - e.preventDefault(); - sec.scrollIntoView({ block: "start", behavior: "smooth" }); - }); - jump.appendChild(jb); + addJumpBtn(CAT_LABELS[c].slice(0, 2), sec); }); $("picker-panel").appendChild(jump); /* outside the scroller so it stays put */ $("picker").classList.add("open"); @@ -2306,6 +3346,9 @@

404 Busdriver

mkPortBtns($("out-list"), outs, state.midi.outName, (n) => { state.midi.outName = n; }); mkPortBtns($("in-list"), ins, state.midi.inName, (n) => { state.midi.inName = n; }); $("wake-toggle").textContent = "Keep screen awake: " + (state.wake ? "on" : "off"); + $("horiz-toggle").textContent = "Horizontal faders: " + (state.horizFaders ? "on" : "off"); + $("jam-knob-toggle").textContent = "JAM style: " + (state.jamKnobs ? "knobs" : "bars"); + $("theme-toggle").textContent = "Theme: " + state.theme; const dl = $("dfx-list"); dl.textContent = ""; for (let i = 0; i < 5; i++) { @@ -2343,6 +3386,12 @@

404 Busdriver

} catch (e) { /* not granted (battery, platform): ignore */ } } +function applyTheme() { + document.documentElement.dataset.theme = state.theme; + const meta = $("theme-color-meta"); + if (meta) meta.content = state.theme === "light" ? "#f3f2ee" : "#121214"; +} + /* ===== MIDI clock -> BPM (Link route: any connected port may carry clock) ===== */ const Clock = { src: null, last: 0, ticks: [], count: 0 }; function onClockByte(st, srcName) { @@ -2365,7 +3414,14 @@

404 Busdriver

if (window.busdriver && window.busdriver.monitor && d[0] !== 0xF8 && d[0] !== 0xFE) console.log("MIDI in (" + src + "):", Array.from(d).map((b) => b.toString(16).padStart(2, "0")).join(" ")); - for (let i = 0; i < d.length; i++) if (d[i] >= 0xF8) onClockByte(d[i], src); + let msg = d, rt = false; + for (let i = 0; i < d.length; i++) if (d[i] >= 0xF8) { rt = true; onClockByte(d[i], src); } + /* despite the name, this port isn't ONLY for clock - a second device + pinned in JAM (e.g. an MC-101) is very likely connected as one of + these non-primary ports, not as the one selected SP-404 input */ + if (rt) msg = Array.from(d).filter((b) => b < 0xF8); + if (msg.length < 3) return; + if ((msg[0] & 0xF0) === 0xB0) applyRawCC(msg[0] & 0x0F, msg[1], msg[2]); } setInterval(() => { const bpmEl = $("bpm"); @@ -2486,6 +3542,154 @@

404 Busdriver

pad.addEventListener("pointerup", end); pad.addEventListener("pointercancel", end); } + +/* ===== XY presets: curated templates (real parameter names, like the JAM + presets) plus the user's own saved axis combos ===== */ +const XY_TEMPLATES = [ + { id: "isolator", name: "Isolator — LOW × HIGH", fx: "Isolator", x: "LOW", y: "HIGH", + note: "Cut the lows on one side, the highs on the other — park in a corner to isolate the mids." }, + { id: "super-filter", name: "Super Filter — CUTOFF × RESONANCE", fx: "Super Filter", x: "CUTOFF", y: "RESONANCE", + note: "Classic filter sweep, with resonance piling on as you go." }, + { id: "tape-echo", name: "Tape Echo — TIME × FEEDBACK", fx: "Tape Echo", x: "TIME", y: "FEEDBACK", + note: "Drag right for longer echoes, up for more repeats." }, + { id: "phaser", name: "Phaser — RATE × DEPTH", fx: "Phaser", x: "RATE", y: "DEPTH", + note: "How fast it sweeps vs. how far — anywhere from a subtle swirl to full jet-flange." }, + { id: "wah", name: "Wah — MANUAL × PEAK", fx: "Wah", x: "MANUAL", y: "PEAK", + note: "Pedal position and resonance peak in one gesture, DJ-scratch style." }, + { id: "resonator", name: "Resonator — BRIGHT × FEEDBACK", fx: "Resonator", x: "BRIGHT", y: "FEEDBACK", + note: "Tone and ring-out length of the pitched resonance." }, +]; +function xyTemplateBuses(t) { + return BUSES.map((B, i) => (BUS_TABLES[B.table].includes(t.fx) ? i : null)).filter((i) => i !== null); +} +const XY_PRESETS_MAX = 16; +function sanitizeXyPreset(p) { + if (!p || typeof p !== "object") return null; + const axis = (a) => (a && a.bus >= 0 && a.bus < 5 && a.slot >= 0 && a.slot < 6) ? { bus: a.bus | 0, slot: a.slot | 0 } : null; + const x = axis(p.x), y = axis(p.y); + if (!x || !y) return null; + return { + id: (typeof p.id === "string" && p.id) ? p.id : "xy" + Date.now() + Math.random().toString(36).slice(2, 7), + name: (typeof p.name === "string" && p.name.trim()) ? p.name.slice(0, 24) : "Combo", + x, y, mom: !!p.mom, + }; +} +function openXyPresets() { + $("xypreset-title").textContent = "XY PRESETS"; + $("xypreset-note").textContent = "Templates switch the bus to that effect if it isn't already " + + "loaded, then assign both axes from its real parameter names. Your own combos just re-point " + + "X/Y to wherever they were pointed when you saved them."; + renderXyTemplateList(); + renderXyCustomList(); + $("xypreset").classList.add("open"); +} +function renderXyTemplateList() { + const host = $("xypreset-templates"); + host.textContent = ""; + XY_TEMPLATES.forEach((t) => { + const b = el("button", "setup-btn", t.name); + b.addEventListener("click", () => openXyTemplateBusPick(t)); + host.appendChild(b); + }); +} +function openXyTemplateBusPick(t) { + const host = $("xypreset-templates"); + host.textContent = ""; + const back = el("button", "setup-btn", "‹ Back"); + back.addEventListener("click", renderXyTemplateList); + host.appendChild(back); + xyTemplateBuses(t).forEach((i) => { + const b = el("button", "setup-btn", BUSES[i].label); + b.addEventListener("click", () => applyXyTemplate(t, i)); + host.appendChild(b); + }); + $("xypreset-note").textContent = t.note + " Pick which bus."; +} +function applyXyTemplate(t, bus) { + const table = BUSES[bus].table; + const fxNum = BUS_TABLES[table].indexOf(t.fx); + if (fxNum < 0) { showBanner("That effect isn't on this bus", "warn", 2500); return; } + if (state.buses[bus].fx !== fxNum) fxChange(bus, fxNum, "user"); + const def = fxDefFor(table, fxNum); + const xi = def.p.findIndex((p) => p.n === t.x); + const yi = def.p.findIndex((p) => p.n === t.y); + if (xi < 0 || yi < 0) { showBanner("Couldn't find those parameters", "warn", 2500); return; } + state.xy.x = { bus, slot: xi }; + state.xy.y = { bus, slot: yi }; + saveState(); + setActiveBus(bus); + renderXy(); + $("xypreset").classList.remove("open"); + showBanner("\"" + t.name + "\" applied to " + BUSES[bus].label, "info", 2500); +} +function renderXyCustomList() { + const host = $("xypreset-custom"); + host.textContent = ""; + if (!state.xyPresets.length) { + host.appendChild(el("p", "pick-note", "No saved combos yet — dial in an X/Y pair below, then “Save current”.")); + return; + } + state.xyPresets.forEach((p) => { + const row = el("div", "xy-custom-row"); + const nameBtn = el("button", "setup-btn xy-custom-name", p.name); + nameBtn.addEventListener("click", () => applyXyCustom(p)); + const ren = el("button", "xy-custom-icon", "✎"); + ren.addEventListener("click", (e) => { e.stopPropagation(); renameXyPreset(row, p); }); + const del = el("button", "xy-custom-icon", "✕"); + del.addEventListener("click", (e) => { e.stopPropagation(); deleteXyPreset(p.id); }); + row.appendChild(nameBtn); row.appendChild(ren); row.appendChild(del); + host.appendChild(row); + }); +} +function renameXyPreset(row, p) { + const nameBtn = row.querySelector(".xy-custom-name"); + const input = el("input", "xy-custom-edit"); + input.type = "text"; input.maxLength = 24; input.value = p.name; + nameBtn.replaceWith(input); + input.addEventListener("pointerdown", (e) => e.stopPropagation()); + input.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") input.blur(); + if (e.key === "Escape") { input.value = p.name; input.blur(); } + }); + input.addEventListener("blur", () => { + const v = input.value.trim(); + if (v) p.name = v.slice(0, 24); + saveState(); + renderXyCustomList(); + }); + setTimeout(() => { input.focus(); input.select(); }, 30); +} +function applyXyCustom(p) { + state.xy.x = { bus: p.x.bus, slot: p.x.slot }; + state.xy.y = { bus: p.y.bus, slot: p.y.slot }; + state.xy.mom = !!p.mom; + saveState(); + renderXy(); + $("xypreset").classList.remove("open"); + showBanner("\"" + p.name + "\" applied", "info", 2000); +} +function deleteXyPreset(id) { + state.xyPresets = state.xyPresets.filter((p) => p.id !== id); + saveState(); + renderXyCustomList(); +} +function saveCurrentXyPreset() { + const ax = xyAssign("x"), ay = xyAssign("y"); + if (!ax || !ay) { showBanner("Assign both X and Y first", "info", 2500); return; } + if (state.xyPresets.length >= XY_PRESETS_MAX) { showBanner("Combo list is full — delete one first", "warn", 2500); return; } + const p = { + id: "xy" + Date.now() + Math.random().toString(36).slice(2, 7), + name: "Combo " + (state.xyPresets.length + 1), + x: { bus: ax.bus, slot: ax.slot }, y: { bus: ay.bus, slot: ay.slot }, mom: !!state.xy.mom, + }; + state.xyPresets.push(p); + saveState(); + renderXyCustomList(); + const row = [...$("xypreset-custom").children].find((r) => r.querySelector(".xy-custom-name")?.textContent === p.name); + if (row) renameXyPreset(row, p); +} + let xyPickAxis = "x", xyPickBus = 0; function openXyPick(axis) { xyPickAxis = axis; @@ -2946,9 +4150,550 @@

404 Busdriver

fxChange(bus, next, "user"); } +/* ===== 11b JAM: user-programmable pinned controls ===== + Two independent value sources per slot: + - "bus": pins a real SP-404 bus/ctrl slot (same CC the currently loaded + effect on that bus uses) - reuses setBusCc, so it stays in sync with the + Focus view and hardware encoder input for that slot automatically. + - "raw": arbitrary channel/CC/Note/PC to any connected MIDI output, + independent of the SP-404 (e.g. an MC-101 on a second port). */ +function jamGet(cfg) { return cfg.source === "bus" ? state.buses[cfg.bus].cc[cfg.ctrl] : cfg.val; } +function jamSet(cfg, v, opts) { + v = clamp(v | 0, 0, 127); + if (cfg.source === "bus") { setBusCc(cfg.bus, cfg.ctrl, v, opts); return; } + cfg.val = v; + Midi.sendToPort(cfg.port, cfg.ch, cfg.num, v); + saveState(); +} +/* JAM scene pads (bus-scene recall): kept live so a backgrounded tab or a + rebuild mid-hold can force-release them rather than leaving a bus stuck */ +const jamSceneActive = new Set(); +function jamPadFire(cfg, on) { + if (cfg.source === "scene") { + if (on) { + cfg._revert = { fx: state.buses[cfg.bus].fx, cc: state.buses[cfg.bus].cc.slice(), on: state.buses[cfg.bus].on }; + /* force EFX on regardless of what was captured - the whole point of + pressing this is to hear the effect; a bus that happened to be off + at capture time shouldn't silently stay inaudible while held */ + applyBusState(cfg.bus, { fx: cfg.fx, cc: cfg.cc, on: true }); + cfg._active = true; + jamSceneActive.add(cfg); + } else if (cfg._revert) { + /* always restores exactly what was captured on press - including the + effect itself, not just its values - regardless of anything tweaked + while held. Predictable momentary behavior: release never silently + rewrites the saved scene, a stray touch mid-hold can't cost you + a carefully-set-up scene. */ + applyBusState(cfg.bus, cfg._revert); + cfg._revert = null; + cfg._active = false; + jamSceneActive.delete(cfg); + } + return; + } + if (cfg.source === "bus") { setBusCc(cfg.bus, cfg.ctrl, on ? cfg.onVal : cfg.offVal, { force: true }); return; } + if (cfg.type === "pc") { if (on) Midi.sendPcTo(cfg.port, cfg.ch, cfg.num); return; } + if (cfg.type === "note") { Midi.sendNoteTo(cfg.port, cfg.ch, cfg.num, on, cfg.vel); return; } + cfg.val = on ? cfg.onVal : cfg.offVal; + Midi.sendToPort(cfg.port, cfg.ch, cfg.num, cfg.val); + saveState(); +} +function releaseAllJamScenes() { + [...jamSceneActive].forEach((cfg) => jamPadFire(cfg, false)); +} +function jamLabel(cfg) { + if (cfg.label) return cfg.label; + if (cfg.source === "scene") return BUSES[cfg.bus].short + " SCENE"; + return cfg.source === "bus" ? BUSES[cfg.bus].short + " · CTRL" + (cfg.ctrl + 1) : (cfg.type === "note" ? "N" : cfg.type === "pc" ? "PC" : "CC") + cfg.num; +} +function attachJamPad(btn, cfg) { + let down = false; + const isOn = () => cfg.source === "scene" ? !!cfg._active : (cfg.type !== "note" && jamGet(cfg) >= 64); + function paint() { if (cfg.mode === "toggle") btn.classList.toggle("on", isOn()); } + btn.addEventListener("pointerdown", (e) => { + e.preventDefault(); + btn.setPointerCapture(e.pointerId); + down = true; + if (cfg.mode === "toggle") { jamPadFire(cfg, !isOn()); paint(); } + else { jamPadFire(cfg, true); btn.classList.add("on"); } + }); + function release(e) { + if (!down) return; + down = false; + if (cfg.mode !== "toggle") { jamPadFire(cfg, false); btn.classList.remove("on"); } + } + btn.addEventListener("pointerup", release); + btn.addEventListener("pointercancel", release); + paint(); + return { update: paint }; +} +function buildJamFaderDom(cfg) { + const fader = el("div", "fader"); + const val = el("button", "fader-val display mono"); + const trackWrap = el("div", "fader-track-wrap"); + const track = el("div", "fader-track"); + const fill = el("div", "fader-fill"); + const thumb = el("div", "fader-thumb"); + const knobWrap = el("div", "jam-knob-wrap"); + const knobDial = el("div", "jam-knob-dial"); + const knobPointer = el("div", "jam-knob-pointer"); + const big = el("div", "bigval display mono"); + track.appendChild(fill); track.appendChild(thumb); + trackWrap.appendChild(track); + knobDial.appendChild(knobPointer); + knobWrap.appendChild(knobDial); + fader.appendChild(val); fader.appendChild(trackWrap); fader.appendChild(knobWrap); fader.appendChild(big); + function update() { + const v = jamGet(cfg); + const pct = v / 127 * 100; + paintFader(fill, thumb, pct); + paintKnob(knobDial, knobPointer, pct); + val.textContent = String(v); + big.textContent = String(v); + } + update(); + const echoKey = cfg.source === "bus" ? (BUSES[cfg.bus].ch << 8 | CC_CTRL[cfg.ctrl]) + : (cfg.source === "raw" && cfg.type === "cc") ? (cfg.ch << 8 | cfg.num) : null; + attachFaderDrag(fader, { val, big, track }, { + key: "jam:" + cfg.id, echoKey, allowHoriz: false, sensitivity: 180, + get: () => jamGet(cfg), + /* bus-source repaints async via queueSlotRender -> jamByBusSlot; raw + source has no such hook, so paint synchronously here for both - + harmless extra paint for bus (RAF would've done it a moment later), + required for raw (nothing else ever repaints it) */ + set: (v, o) => { jamSet(cfg, v, o); update(); }, + onDefault: () => 64, + trailingSend: () => { + if (cfg.source === "bus") Midi.send(BUSES[cfg.bus].ch, CC_CTRL[cfg.ctrl], jamGet(cfg), true); + else Midi.sendToPort(cfg.port, cfg.ch, cfg.num, jamGet(cfg)); + }, + }); + return { fader, update }; +} +function registerJamUpdate(cfg, fn) { + if (cfg.source === "bus") { + const k = cfg.bus * 8 + cfg.ctrl; + if (!jamByBusSlot.has(k)) jamByBusSlot.set(k, new Set()); + jamByBusSlot.get(k).add(fn); + } else if (cfg.source === "raw" && cfg.type === "cc") { + if (!jamRawUpdateFns.has(cfg.id)) jamRawUpdateFns.set(cfg.id, new Set()); + jamRawUpdateFns.get(cfg.id).add(fn); + } +} +let jamEditing = false; +/* select mode: mutually exclusive with edit mode so a tap on a tile is + never ambiguous between "toggle selection" and "start a reorder drag" */ +let jamSelectMode = false; +const jamSelected = new Set(); /* cfg.id values */ +let jamDropIndex = null; +function updateJamDropTarget(dragTile, x, y) { + const kids = [...$("jam-grid").querySelectorAll(".jam-slot")]; + let best = null, bestDist = Infinity; + kids.forEach((k) => { + if (k === dragTile) return; + const r = k.getBoundingClientRect(); + const d = Math.hypot(x - (r.left + r.width / 2), y - (r.top + r.height / 2)); + if (d < bestDist) { bestDist = d; best = k; } + }); + kids.forEach((k) => k.classList.remove("jam-drop-before")); + if (best) { best.classList.add("jam-drop-before"); jamDropIndex = +best.dataset.jamIndex; } + else jamDropIndex = null; +} +function consumeJamDropTarget() { + const v = jamDropIndex; + jamDropIndex = null; + [...$("jam-grid").querySelectorAll(".jam-slot")].forEach((k) => k.classList.remove("jam-drop-before")); + return v; +} +function attachJamEditGestures(tile, index) { + let startX = 0, startY = 0, moved = 0, lifting = false, pid = null, curDx = 0, curDy = 0, deleteArmed = false; + tile.addEventListener("pointerdown", (e) => { + e.preventDefault(); + tile.setPointerCapture(e.pointerId); + pid = e.pointerId; startX = e.clientX; startY = e.clientY; moved = 0; lifting = false; deleteArmed = false; + }); + tile.addEventListener("pointermove", (e) => { + if (e.pointerId !== pid) return; + curDx = e.clientX - startX; curDy = e.clientY - startY; + moved = Math.max(moved, Math.abs(curDx), Math.abs(curDy)); + if (!lifting && moved > 10) { lifting = true; tile.classList.add("jam-lift"); } + if (lifting) { + tile.style.transform = "translate(" + curDx + "px," + curDy + "px)"; + const r = tile.getBoundingClientRect(); + /* armed once the swipe has gone leftward past half the tile's own + width while staying mostly horizontal - far enough that it can't + be mistaken for "drag onto the tile to my left" (reorder) */ + deleteArmed = curDx < -r.width * 0.55 && Math.abs(curDy) < r.height * 0.45; + tile.classList.toggle("jam-delete-hint", deleteArmed); + if (deleteArmed) consumeJamDropTarget(); + else updateJamDropTarget(tile, e.clientX, e.clientY); + } + }); + function end(e) { + if (e.pointerId !== pid) return; + pid = null; + if (lifting) { + tile.classList.remove("jam-lift", "jam-delete-hint"); + tile.style.transform = ""; + if (deleteArmed) { deleteJamSlotAt(index); return; } + const dropIdx = consumeJamDropTarget(); + if (dropIdx != null && dropIdx !== index) { + /* insert at the target's ORIGINAL index (not -1'd) - removing the + dragged item shifts everything after it down by one, which is + exactly what lands it in the target's old slot either direction */ + const [item] = state.jam.slots.splice(index, 1); + state.jam.slots.splice(dropIdx, 0, item); + saveState(); + } + buildJam(); + } else if (moved < 8) { + openJamEditor(index); + } + } + tile.addEventListener("pointerup", end); + tile.addEventListener("pointercancel", end); +} +/* single-tile delete via swipe, reusing the same undo mechanism as + clearJam() (the CLEAR button becomes UNDO regardless of which of the two + emptied the grid, or partially emptied it) */ +function deleteJamSlotAt(index) { + const cfg = state.jam.slots[index]; + if (!cfg) return; + if (cfg.source === "scene") jamSceneActive.delete(cfg); + jamClearUndo = { slots: state.jam.slots.slice(), t: performance.now() }; + state.jam.slots.splice(index, 1); + saveState(); + buildJam(); + showBanner("Removed \"" + jamLabel(cfg) + "\" – tap CLEAR to undo", "info", 3000); + setTimeout(updateJamClearBtn, 6100); +} +function buildJamTile(cfg, index) { + const tile = el("div", "jam-slot jam-" + cfg.kind + (jamSelected.has(cfg.id) ? " jam-selected" : "")); + tile.dataset.jamIndex = index; + if (cfg.kind === "pad") { + /* the pad button carries its own label - no separate slot-label needed */ + const btn = el("button", "jam-pad-btn pad", jamLabel(cfg)); + tile.appendChild(btn); + if (!jamEditing && !jamSelectMode) { + const api = attachJamPad(btn, cfg); + registerJamUpdate(cfg, api.update); + } + } else { + const dom = buildJamFaderDom(cfg); + tile.appendChild(dom.fader); + tile.appendChild(el("div", "slot-label", jamLabel(cfg))); + if (!jamEditing && !jamSelectMode) registerJamUpdate(cfg, dom.update); + } + if (jamEditing) attachJamEditGestures(tile, index); + if (jamSelectMode) attachJamSelectGesture(tile, cfg); + return tile; +} +/* select mode: a plain tap toggles selection - no drag/swipe to disambiguate + here, so it wins over the tile's own pad/fader listeners via the same + bubble-then-capture-override behavior attachJamEditGestures relies on */ +function attachJamSelectGesture(tile, cfg) { + let sx = 0, sy = 0, moved = 0, pid = null; + tile.addEventListener("pointerdown", (e) => { + e.preventDefault(); + tile.setPointerCapture(e.pointerId); + pid = e.pointerId; sx = e.clientX; sy = e.clientY; moved = 0; + }); + tile.addEventListener("pointermove", (e) => { + if (e.pointerId !== pid) return; + moved = Math.max(moved, Math.abs(e.clientX - sx), Math.abs(e.clientY - sy)); + }); + function end(e) { + if (e.pointerId !== pid) return; + pid = null; + if (moved < 8) { + if (jamSelected.has(cfg.id)) jamSelected.delete(cfg.id); + else jamSelected.add(cfg.id); + buildJam(); + } + } + tile.addEventListener("pointerup", end); + tile.addEventListener("pointercancel", end); +} +function buildJamAddTile() { + const btn = el("button", "jam-add", "+"); + btn.addEventListener("pointerdown", (e) => { e.preventDefault(); openJamEditor(null); }); + return btn; +} +function buildJam() { + const host = $("jam-grid"); + finishDragsIn(host); + releaseAllJamScenes(); /* a rebuild mid-hold must not leave a bus stuck on the captured scene */ + /* prune selections for tiles that no longer exist (deleted elsewhere) */ + const liveIds = new Set(state.jam.slots.map((c) => c.id)); + [...jamSelected].forEach((id) => { if (!liveIds.has(id)) jamSelected.delete(id); }); + host.textContent = ""; + jamByBusSlot.clear(); + jamRawUpdateFns.clear(); + document.body.classList.toggle("jam-editing", jamEditing); + document.body.classList.toggle("jam-selecting", jamSelectMode); + $("jam-edit-btn").classList.toggle("on", jamEditing); + $("jam-edit-btn").textContent = jamEditing ? "DONE" : "EDIT"; + $("jam-select-btn").classList.toggle("on", jamSelectMode); + $("jam-select-btn").textContent = jamSelectMode ? "DONE" : "SELECT"; + $("jam-batch-bar").classList.toggle("hide", !jamSelectMode); + $("jam-batch-count").textContent = jamSelected.size + " selected"; + state.jam.slots.forEach((cfg, i) => host.appendChild(buildJamTile(cfg, i))); + if (!jamEditing && !jamSelectMode && state.jam.slots.length < JAM_MAX) host.appendChild(buildJamAddTile()); + renderFocusScenes(); /* keep the Focus-view sidebar in sync with any add/edit/delete/reorder here */ + updateJamClearBtn(); +} +/* window.confirm() is unreliable on this app's actual target platform (iOS + Web MIDI wrapper browsers like MIDIWeb Browser often stub it to return + false without ever prompting - see snapSave()'s comment re: prompt() for + the same class of bug) so this mirrors the RND button's proven pattern + instead: act immediately, become UNDO for a few seconds rather than ask + first. Safer in practice too - a stray tap is recoverable either way. */ +let jamClearUndo = null; /* { slots, t } */ +function clearJam() { + if (jamClearUndo && performance.now() - jamClearUndo.t < 6000) { + state.jam.slots = jamClearUndo.slots; + jamClearUndo = null; + saveState(); + buildJam(); + showBanner("JAM restored", "info", 2000); + return; + } + if (!state.jam.slots.length) { showBanner("JAM is already empty", "info", 2000); return; } + releaseAllJamScenes(); + jamClearUndo = { slots: state.jam.slots, t: performance.now() }; + state.jam.slots = []; + saveState(); + buildJam(); + showBanner("JAM cleared - tap CLEAR again within 6s to undo", "info", 3000); + setTimeout(updateJamClearBtn, 6100); +} +function updateJamClearBtn() { + const btn = $("jam-clear-btn"); + if (!btn) return; + const active = jamClearUndo && performance.now() - jamClearUndo.t < 6000; + btn.textContent = active ? "UNDO" : "CLEAR"; + btn.classList.toggle("undo", !!active); +} + +/* ===== JAM control editor overlay ===== */ +let jamDraft = null, jamDraftIndex = null; +function defaultJamSlot() { + return { + id: "j" + Date.now() + Math.random().toString(36).slice(2, 7), + kind: "pad", source: "bus", label: "", + bus: state.activeBus || 0, ctrl: 0, + port: null, ch: 0, type: "cc", num: 1, val: 64, + mode: "toggle", onVal: 127, offVal: 0, vel: 110, + fx: 0, cc: [64, 64, 64, 64, 64, 64], on: false, /* scene source */ + }; +} +function openJamEditor(index) { + jamDraftIndex = index; + jamDraft = index == null ? defaultJamSlot() : Object.assign({}, state.jam.slots[index]); + /* an existing scene slot already has valid captured data (fx/cc/on were + sanitized in) - _captured is a draft-only flag, never persisted, so it + must be re-derived here or re-editing one would look "not captured yet" */ + if (jamDraft.source === "scene") jamDraft._captured = true; + renderJamEditor(); + $("jamedit").classList.add("open"); +} +/* Focus view SAVE button: one tap from wherever you're dialing a sound in, + rather than the multi-step "go to JAM > + > pick Bus scene > pick bus > + Capture" - pre-fills a scene draft already captured from the active bus + and opens the same editor so a label/mode still has to be confirmed. */ +function quickSaveBusScene() { + const bus = state.activeBus, b = state.buses[bus]; + jamDraftIndex = null; + jamDraft = defaultJamSlot(); + jamDraft.source = "scene"; + jamDraft.bus = bus; + jamDraft.fx = b.fx; + jamDraft.cc = b.cc.slice(); + jamDraft.on = b.on; + jamDraft._captured = true; + jamDraft.mode = "momentary"; + jamDraft.label = BUSES[bus].short + " " + (b.fx > 0 ? fxDisplayName(BUSES[bus].table, b.fx) : "SCENE"); + renderJamEditor(); + $("jamedit").classList.add("open"); +} +function jamOptRow(hostId, options, isSel, onPick) { + const host = $(hostId); + host.textContent = ""; + options.forEach((opt) => { + const b = el("button", "setup-btn" + (isSel(opt.v) ? " sel" : ""), opt.t); + b.addEventListener("click", () => onPick(opt.v)); + host.appendChild(b); + }); +} +function renderJamEditor() { + const d = jamDraft; + $("jamedit-title").textContent = jamDraftIndex == null ? "NEW CONTROL" : "EDIT CONTROL"; + jamOptRow("je-kind", [{ v: "pad", t: "Pad" }, { v: "fader", t: "Fader" }], (v) => v === d.kind, (v) => { + if (d.source === "scene" && v === "fader") return; /* scenes can't be a fader */ + d.kind = v; + if (v === "fader") d.type = "cc"; + renderJamEditor(); + }); + jamOptRow("je-source", [{ v: "bus", t: "SP-404 bus param" }, { v: "raw", t: "Raw MIDI" }, { v: "scene", t: "Bus scene" }], (v) => v === d.source, (v) => { + d.source = v; + if (v === "scene") d.kind = "pad"; /* a captured scene can't be dragged like a single CC */ + renderJamEditor(); + }); + $("je-bus-fields").classList.toggle("hide", d.source !== "bus"); + $("je-raw-fields").classList.toggle("hide", d.source !== "raw"); + $("je-scene-fields").classList.toggle("hide", d.source !== "scene"); + $("je-pad-fields").classList.toggle("hide", d.kind !== "pad"); + if (d.source === "bus") { + jamOptRow("je-bus", BUSES.map((B, i) => ({ v: i, t: B.label })), (v) => v === d.bus, (v) => { d.bus = v; renderJamEditor(); }); + jamOptRow("je-ctrl", [0, 1, 2, 3, 4, 5].map((i) => ({ v: i, t: "CTRL " + (i + 1) })), (v) => v === d.ctrl, (v) => { d.ctrl = v; renderJamEditor(); }); + } else if (d.source === "scene") { + jamOptRow("je-scene-bus", BUSES.map((B, i) => ({ v: i, t: B.label })), (v) => v === d.bus, (v) => { d.bus = v; renderJamEditor(); }); + const B = BUSES[d.bus]; + $("je-scene-status").textContent = d._captured + ? "Captured: " + fxDisplayName(B.table, d.fx) + " on " + B.label + (d.on ? " (on)" : " (off)") + : "Not captured yet – dial in the sound you want live, then tap Capture."; + } else { + const outs = []; + if (Midi.access) Midi.access.outputs.forEach((p) => { if (Midi.live(p)) outs.push(p.name); }); + const portOpts = [{ v: null, t: "Default (SP-404 output)" }].concat(outs.map((n) => ({ v: n, t: n }))); + jamOptRow("je-port", portOpts, (v) => v === d.port, (v) => { d.port = v; renderJamEditor(); }); + const typeOpts = d.kind === "fader" ? [{ v: "cc", t: "CC" }] : [{ v: "cc", t: "CC" }, { v: "note", t: "Note" }, { v: "pc", t: "Program Change" }]; + jamOptRow("je-type", typeOpts, (v) => v === d.type, (v) => { d.type = v; if (v !== "cc") d.mode = "momentary"; renderJamEditor(); }); + $("je-ch").value = d.ch + 1; + $("je-num-label").textContent = d.type === "note" ? "Note #" : d.type === "pc" ? "Pattern/PC #" : "CC #"; + $("je-num").value = d.num; + } + if (d.kind === "pad") { + const modeOpts = (d.source === "bus" || d.source === "scene" || d.type === "cc") ? [{ v: "toggle", t: "Toggle" }, { v: "momentary", t: "Momentary" }] : [{ v: "momentary", t: "Momentary (fixed)" }]; + jamOptRow("je-mode", modeOpts, (v) => v === d.mode, (v) => { d.mode = v; renderJamEditor(); }); + const ccLike = d.source === "bus" || d.type === "cc"; + $("je-onval-field").classList.toggle("hide", !ccLike); + $("je-offval-field").classList.toggle("hide", !ccLike); + $("je-vel-field").classList.toggle("hide", !(d.source === "raw" && d.type === "note")); + $("je-onval").value = d.onVal; + $("je-offval").value = d.offVal; + $("je-vel").value = d.vel; + } + $("je-label").value = d.label || ""; + $("je-delete").style.display = jamDraftIndex == null ? "none" : ""; +} +function readJamEditorInputs() { + const d = jamDraft; + if (d.source === "raw") { + d.ch = clamp((+$("je-ch").value || 1) - 1, 0, 15); + d.num = clamp(+$("je-num").value || 0, 0, 127); + } + if (d.kind === "pad") { + d.onVal = clamp(+$("je-onval").value, 0, 127); + d.offVal = clamp(+$("je-offval").value, 0, 127); + d.vel = clamp(+$("je-vel").value || 110, 1, 127); + } + d.label = $("je-label").value.trim().slice(0, 16); +} +function saveJamEditor() { + readJamEditorInputs(); + if (jamDraft.source === "scene" && !jamDraft._captured) { + showBanner("Capture a bus state first", "warn", 2500); + return; + } + const clean = sanitizeJamSlot(jamDraft); + if (!clean) return; + if (jamDraftIndex == null) state.jam.slots.push(clean); + else state.jam.slots[jamDraftIndex] = clean; + saveState(); + $("jamedit").classList.remove("open"); + buildJam(); +} +function deleteJamEditor() { + if (jamDraftIndex != null) { + state.jam.slots.splice(jamDraftIndex, 1); + saveState(); + } + $("jamedit").classList.remove("open"); + buildJam(); +} + +/* ===== JAM presets: factory templates for common gear, from verified + MIDI implementation charts (not guessed) - each control still gets its + own editable channel/CC/label after adding, so a wrong assumption here + (e.g. this device's default per-track channel, which is user-configurable + on the MC-101 itself) is a one-tap fix, not a dead end. ===== */ +const JAM_PRESETS = [ + { + id: "mc101-knobs", + name: "MC-101 — 4 tracks × Sound/Filter/Mod/FX", + note: "CC 80–83 (SOUND/FILTER/MOD/FX knobs, in the device's own left-to-right " + + "order) per track, from Roland's MC-101 " + + "MIDI implementation chart — both transmit and receive, so these mirror the " + + "hardware knobs live. Track channels default to 1–4; check yours against " + + "SHIFT + TRACK SEL on the device if it differs.", + build() { + /* physical knob order on the MC-101 itself: CTRL1 SOUND, CTRL2 FILTER, + CTRL3 MOD, CTRL4 FX - matches left-to-right so the JAM grid mirrors + the hardware layout */ + const knobs = [["SOUND", 83], ["FILTER", 80], ["MOD", 81], ["FX", 82]]; + const slots = []; + for (let t = 0; t < 4; t++) { + knobs.forEach(([label, num]) => { + slots.push({ kind: "fader", source: "raw", ch: t, type: "cc", num, label: "T" + (t + 1) + " " + label, val: 64 }); + }); + } + return slots; + }, + }, +]; +let jamPresetPending = null; +function openJamPresets() { + jamPresetPending = null; + $("jampreset-title").textContent = "JAM PRESETS"; + $("jampreset-note").textContent = "Adds the preset's controls to your JAM grid – " + + "existing controls are kept. Every added control keeps its own editable " + + "channel/CC/label afterward, same as anything else in JAM."; + const host = $("jampreset-list"); + host.textContent = ""; + JAM_PRESETS.forEach((preset) => { + const b = el("button", "setup-btn", preset.name); + b.addEventListener("click", () => openJamPresetPortPick(preset)); + host.appendChild(b); + }); + $("jampreset").classList.add("open"); +} +function openJamPresetPortPick(preset) { + jamPresetPending = preset; + $("jampreset-title").textContent = preset.name; + $("jampreset-note").textContent = preset.note + " Pick which connected output this preset's controls should target."; + const host = $("jampreset-list"); + host.textContent = ""; + const back = el("button", "setup-btn", "‹ Back"); + back.addEventListener("click", openJamPresets); + host.appendChild(back); + const mkPort = (name, label) => { + const b = el("button", "setup-btn", label); + b.addEventListener("click", () => applyJamPreset(preset, name)); + host.appendChild(b); + }; + mkPort(null, "Default (SP-404 output)"); + if (Midi.access) Midi.access.outputs.forEach((p) => { if (Midi.live(p)) mkPort(p.name, p.name); }); +} +function applyJamPreset(preset, port) { + const room = JAM_MAX - state.jam.slots.length; + if (room <= 0) { showBanner("JAM grid is full – delete some controls first", "warn", 3000); return; } + const built = preset.build().map((c) => Object.assign({ port }, c)); + const clean = built.slice(0, room).map(sanitizeJamSlot).filter(Boolean); + state.jam.slots.push(...clean); + saveState(); + $("jampreset").classList.remove("open"); + buildJam(); + const dropped = built.length - clean.length; + showBanner("Added " + clean.length + " controls from \"" + preset.name + "\"" + (dropped ? " (" + dropped + " skipped, grid full)" : ""), "info", 3000); +} + /* ===== 12 BOOT ===== */ function boot() { loadState(); + document.body.classList.toggle("horiz-faders", state.horizFaders); + document.body.classList.toggle("jam-knobs", state.jamKnobs); + applyTheme(); buildTabs(); buildOverview(); BUSES.forEach((_, i) => renderBus(i)); @@ -2965,6 +4710,18 @@

404 Busdriver

$("xy-xa").addEventListener("click", () => openXyPick("x")); $("xy-ya").addEventListener("click", () => openXyPick("y")); $("xy-mom").addEventListener("click", () => { state.xy.mom = !state.xy.mom; saveState(); renderXy(); }); + $("xy-presets-btn").addEventListener("click", openXyPresets); + $("xypreset-close").addEventListener("click", () => $("xypreset").classList.remove("open")); + $("xypreset").addEventListener("pointerdown", (e) => { if (e.target === $("xypreset")) $("xypreset").classList.remove("open"); }); + $("xypreset-save").addEventListener("click", saveCurrentXyPreset); + $("recipes-btn").addEventListener("click", openRecipes); + $("lib-btn").addEventListener("click", openRecipes); + $("recipes-close").addEventListener("click", () => $("recipes").classList.remove("open")); + $("recipes").addEventListener("pointerdown", (e) => { if (e.target === $("recipes")) $("recipes").classList.remove("open"); }); + $("recipe-new-btn").addEventListener("click", () => openRecipeEditor(null)); + $("recipe-edit-close").addEventListener("click", () => $("recipe-edit").classList.remove("open")); + $("recipe-edit").addEventListener("pointerdown", (e) => { if (e.target === $("recipe-edit")) $("recipe-edit").classList.remove("open"); }); + $("re-save").addEventListener("click", saveRecipeDraft); $("xypick-close").addEventListener("click", () => $("xypick").classList.remove("open")); $("xypick").addEventListener("pointerdown", (e) => { if (e.target === $("xypick")) $("xypick").classList.remove("open"); }); $("xypick-none").addEventListener("click", () => { @@ -3022,6 +4779,58 @@

404 Busdriver

$("padpick").classList.remove("open"); buildPads(); }); + /* jam tab */ + $("jam-edit-btn").addEventListener("click", () => { + jamEditing = !jamEditing; + if (jamEditing) { jamSelectMode = false; jamSelected.clear(); } + buildJam(); + }); + $("jam-select-btn").addEventListener("click", () => { + jamSelectMode = !jamSelectMode; + jamSelected.clear(); + if (jamSelectMode) jamEditing = false; + buildJam(); + }); + $("jam-batch-apply-ch").addEventListener("click", () => { + if (!jamSelected.size) { showBanner("Select some controls first", "info", 2000); return; } + const ch = clamp((+$("jam-batch-ch").value || 1) - 1, 0, 15); + let n = 0; + state.jam.slots.forEach((cfg) => { + if (jamSelected.has(cfg.id) && cfg.source === "raw") { cfg.ch = ch; n++; } + }); + saveState(); + buildJam(); + showBanner(n + ? "Channel " + (ch + 1) + " applied to " + n + " control(s)" + : "None of the selected controls use a MIDI channel (bus/scene sources don't)", "info", 3000); + }); + $("jam-batch-delete").addEventListener("click", () => { + if (!jamSelected.size) { showBanner("Select some controls first", "info", 2000); return; } + jamClearUndo = { slots: state.jam.slots.slice(), t: performance.now() }; + const removed = state.jam.slots.filter((c) => jamSelected.has(c.id)); + removed.forEach((cfg) => { if (cfg.source === "scene") jamSceneActive.delete(cfg); }); + state.jam.slots = state.jam.slots.filter((c) => !jamSelected.has(c.id)); + jamSelected.clear(); + jamSelectMode = false; + saveState(); + buildJam(); + showBanner(removed.length + " control(s) removed – tap CLEAR to undo", "info", 3000); + setTimeout(updateJamClearBtn, 6100); + }); + $("jamedit-close").addEventListener("click", () => $("jamedit").classList.remove("open")); + $("jamedit").addEventListener("pointerdown", (e) => { if (e.target === $("jamedit")) $("jamedit").classList.remove("open"); }); + $("je-save").addEventListener("click", saveJamEditor); + $("je-delete").addEventListener("click", deleteJamEditor); + $("je-capture").addEventListener("click", () => { + const d = jamDraft, b = state.buses[d.bus]; + d.fx = b.fx; d.cc = b.cc.slice(); d.on = b.on; d._captured = true; + renderJamEditor(); + showBanner("Captured", "info", 1500); + }); + $("jam-preset-btn").addEventListener("click", openJamPresets); + $("jampreset-close").addEventListener("click", () => $("jampreset").classList.remove("open")); + $("jampreset").addEventListener("pointerdown", (e) => { if (e.target === $("jampreset")) $("jampreset").classList.remove("open"); }); + $("jam-clear-btn").addEventListener("click", clearJam); /* looper (device in looper mode, CH 1) */ let lpRec = false; $("lp-rec").addEventListener("click", () => { lpRec = !lpRec; Midi.send(0, 88, lpRec ? 127 : 0, true); $("lp-rec").classList.toggle("on", lpRec); }); @@ -3043,6 +4852,7 @@

404 Busdriver

$("exp-btn").addEventListener("click", doExport); $("imp-btn").addEventListener("click", doImport); $("rnd-btn").addEventListener("click", () => randomize(state.activeBus)); + $("bus-save-btn").addEventListener("click", quickSaveBusScene); $("setup-btn").addEventListener("click", openSetup); $("conn").addEventListener("click", openSetup); $("banner").addEventListener("click", () => { @@ -3058,6 +4868,21 @@

404 Busdriver

$("midi-rescan").addEventListener("click", () => Midi.connect(true)); $("push-state").addEventListener("click", pushStateToDevice); $("wake-toggle").addEventListener("click", () => { state.wake = !state.wake; saveState(); applyWake(); renderSetup(); }); + $("horiz-toggle").addEventListener("click", () => { + state.horizFaders = !state.horizFaders; + document.body.classList.toggle("horiz-faders", state.horizFaders); + saveState(); renderSetup(); + }); + $("jam-knob-toggle").addEventListener("click", () => { + state.jamKnobs = !state.jamKnobs; + document.body.classList.toggle("jam-knobs", state.jamKnobs); + saveState(); renderSetup(); + }); + $("theme-toggle").addEventListener("click", () => { + state.theme = state.theme === "light" ? "dark" : "light"; + applyTheme(); + saveState(); renderSetup(); + }); $("help-btn").addEventListener("click", () => $("help").classList.add("open")); $("help-close").addEventListener("click", () => $("help").classList.remove("open")); $("help").addEventListener("pointerdown", (e) => { if (e.target === $("help")) $("help").classList.remove("open"); }); @@ -3068,7 +4893,7 @@

404 Busdriver

}, true); document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { closePicker(); $("setup").classList.remove("open"); $("snaps").classList.remove("open"); $("xypick").classList.remove("open"); $("padpick").classList.remove("open"); $("help").classList.remove("open"); closeStepper(); return; } + if (e.key === "Escape") { closePicker(); $("setup").classList.remove("open"); $("snaps").classList.remove("open"); $("xypick").classList.remove("open"); $("padpick").classList.remove("open"); $("jamedit").classList.remove("open"); $("jampreset").classList.remove("open"); $("xypreset").classList.remove("open"); $("recipes").classList.remove("open"); $("recipe-edit").classList.remove("open"); $("help").classList.remove("open"); closeStepper(); return; } if (e.target.tagName === "INPUT") return; if (e.key >= "1" && e.key <= "5") gotoFocus(+e.key - 1); else if (e.key === " ") { e.preventDefault(); const b = state.activeBus; sendSw(b, !state.buses[b].on); } @@ -3088,6 +4913,7 @@

404 Busdriver

flushState(); clearTimeout(syncTimer); pushSync(); flushNotesIn(null); /* no stuck notes */ flushBend(); + releaseAllJamScenes(); /* a backgrounded tab must not leave a bus stuck mid-hold */ } }); window.addEventListener("pagehide", () => { flushState(); clearTimeout(syncTimer); pushSync(); });