notetoself -

2026-09-06
SYNTHI — 16-step poly synth
111
120BPM
SCENE
1
EDIT
SEQ
0OCT
DRM
OSC
MIX
LFO
SONGS

Saved songs live in this browser at this address only — a song saved from a file is not there when you open the app over the network, and clearing browsing data takes them. The text below is the copy you can keep. LOAD and IMPORT both replace everything you have.

Page source <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="theme-color" content="#161d23"> <meta name="color-scheme" content="dark"> <meta name="description" content="A 16-step polyphonic synthesizer in a single self-contained HTML file."> <meta name="mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 0 60 60'%3E%3Cg transform='rotate(90 26 30)'%3E%3Cpath d='M26 0 51.96 15 51.96 45 26 60 .04 45 .04 15Z' fill='%2313d389'/%3E%3Cpath d='M8.97 21.84V31.81L21.18 23.91V44.46L25.99 47.24L30.8 44.46V23.92L43.01 31.82V21.86L25.99 11.18Z' fill='%23161d23'/%3E%3C/g%3E%3C/svg%3E"> <title>SYNTHI &mdash; 16-step poly synth</title> <style> :root{ /* Checkmk palette, sampled from the product's own stylesheet: brand green #15d1a0 on the slate ramp #1a2228 -> #38444e. */ --bg:#161d23; --panel:#232d36; --panel-hi:#2c3843; --edge:#38444e; --ink:#edf0f2; --dim:#b6c0c9; --faint:#78868f; --accent:#15d1a0; --accent2:#7e96f3; --off:#1a222a; --glow:rgba(21,209,160,.50); --accent2-glow:rgba(126,150,243,.55); --led-off:#33436b; /* the playhead, in the red-orange the very first layout used */ --now:#ff6a2b; --now-glow:rgba(255,106,43,.55); /* OK / WARN / CRIT, used only where a value can actually bite */ --warn:#ffa042; --warn-glow:rgba(255,160,66,.50); --crit:#ff3232; --crit-glow:rgba(255,50,50,.50); /* Four modulation colours, one per source. Deliberately clear of --accent (the normal state), --warn and --crit (which already mean "this control is running away" on RES and FBK), so a glowing knob never says two things. */ --src1:#ff4fd8; --src1-glow:rgba(255,79,216,.55); --src2:#ffd23f; --src2-glow:rgba(255,210,63,.55); --src3:#3fe0ff; --src3-glow:rgba(63,224,255,.55); --src4:#a97cff; --src4-glow:rgba(169,124,255,.55); /* knob shading: specular, underside shade, cast shadow */ --dome-hi:rgba(255,255,255,.13); --dome-rim:rgba(255,255,255,.10); --dome-lo:rgba(0,0,0,.42); --dome-drop:rgba(0,0,0,.50); --mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace; /* One scale for the whole UI. 1x up to ~530px vmin (every phone), then it grows with the smaller viewport edge and caps at 2.4x, so a 2000x2000 window gets proportionally sized type AND controls, not a phone UI in a big box. Every fixed size below is expressed as N * var(--u). */ /* vmin alone was wrong for a wide, short window: a 1440x900 desktop scaled everything up off its 900px side and then had no vertical room for the rows it had just enlarged -- the banks were clipping by ~9px before the drum row existed. The vh term makes the height a first-class constraint; it binds only in that shape and leaves phones and big square windows untouched. */ --u:clamp(1px, min(0.19vmin, 0.125vh), 2.4px); /* The sequencer row and the drum row must share their side padding exactly or their columns stop lining up. One variable, changed per tier in one place. */ --seqpad:calc(4 * var(--u)); /* panel side padding */ --seqgap:calc(6 * var(--u)); /* label -> grid */ --steppad:0px; /* inside the grid */ --drmpadv:calc(3 * var(--u)); /* drum row, top & bottom */ --stepgap:calc(2 * var(--u)); /* between the columns */ --ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; /* Dark only. The light palette read badly -- glowing values and knob domes are built for a dark ground -- so the scheme is pinned rather than following the OS. This also darkens what the UA draws for us: the native <select> popup, the caret, focus rings and any scrollbar. */ color-scheme:dark; /* Instrument tint: which instrument the SEQ, ADSR and OSC panels are showing, told at a glance and never loudly enough to look like another app. FM shifts the grounds a few points toward --accent2, the blue this app already uses as its second colour. VA goes warm -- oiled wood and aged brass rather than walnut and polished brass, which is where this started and was too much: it read as a second app rather than as the same one in a different mood. It reaches the whole control, not just the panel behind it, and that part IS new: `--panel-ins-hi` used to be declared here and referenced NOWHERE, so a tinted panel drew slate knob domes on a tinted ground and any tint could only ever be a wash. The inherited names are wired through now, so the domes, rims, arcs and readouts follow the instrument too -- which is also why the colours can be this quiet and still register. Warm rather than cream for the lit colour: a value readout is an EMITTER everywhere else in this app and cream reads as plain text. Held well clear of --now (#ff6a2b), the moving playhead on the same tinted ground. The tint stops at the glass: see --viz-* below. */ --panel-ins:#2b2620; --edge-ins:#463c31; --ins-hi:#38312a; --ins-edge:#463c31; --ins-off:#221e19; --ins-ac:#cdac70; --ins-glow:rgba(205,172,112,.38); /* A piano's black keys are BLACK. They drew on --off and --edge, and both of those are overridden inside the instrument panels, so the in-scale ones turned walnut and the keyboard stopped reading as a keyboard. These two are the values those names have at :root and are deliberately NOT part of the instrument override below, so the keys look the same on either engine. */ --kb-black:#1a222a; --kb-black-ins:#38444e; --kb-edge:#38444e; /* Every graph in the app draws on the same near-black and in the same green, whichever panel it is in and whichever instrument is live. They used to take the panel's own ground (--plate-*, per instrument) and the instrument's lit colour, so the OSC wave was brass on walnut while the spectrum below it was mint on slate -- two graphs, two visual languages, and no information in the difference. A graph is an instrument display: black glass, green trace. Deliberately NOT part of the per-instrument override. */ --viz-hi:#0b1110; --viz-lo:#060a09; --viz-edge:#2c3a41; } /* FM keeps precisely what it had, to the byte: the blue grounds it has worn since the tint arrived, and the app's own mint for everything inside. The values are spelled out rather than aliased to --edge / --off / --accent so that a later change to the neutral palette cannot move FM by accident. */ :root.ins1{ --panel-ins:#191f2e; --edge-ins:#2f3a55; --ins-hi:#2c3843; --ins-edge:#38444e; --ins-off:#1a222a; --ins-ac:#15d1a0; --ins-glow:rgba(21,209,160,.50); } /* the three per-instrument panels, and nothing else: FLT, DLY, REV, DRM and the top panel are shared, so they keep the neutral ground */ #bank-seq, #bank-env, #bank-osc{ background:var(--panel-ins); border-color:var(--edge-ins); /* The five inherited names every control inside these panels already reads: the dome (--panel-hi), the rim and the unfilled arc (--edge), the dome's underside (--off), and the lit colour with its bloom (--accent, --glow). Overriding them HERE rather than at :root is the whole reason the top panel, MIX, LFO, FLT, DLY and REV stay neutral -- they are outside this selector, so the instrument's colour cannot leak into shared controls. */ --panel-hi:var(--ins-hi); --edge:var(--ins-edge); --off:var(--ins-off); --accent:var(--ins-ac); --glow:var(--ins-glow); } /* TWO FINGERS AT ONCE. Every drag in here already keeps its state per pointerId -- measured, three knobs move independently under three fingers -- so two knobs were never the problem. The browser is: a second finger landing somewhere that still allows a pinch lets the browser claim the gesture, and claiming it fires pointercancel at the drag already in flight, which stops the knob under the FIRST finger dead. It has to be the UNIVERSAL selector, and that is the whole lesson here: touch-action is NOT INHERITED. `touch-action:none` on body leaves the panel your second finger actually lands on computing `auto` -- verified by reading the computed style off the gap between two knobs, before and after. Nothing in the instrument scrolls or zooms, so none is right everywhere -- but an element that DOES scroll cannot simply ask for pan-y on its own box, which is what this used to assume. The effective value is the INTERSECTION along the chain from the finger up to the scroller, so a `none` on any box in between VETOES the pan. Measured: with only `.io button, .io select, .io label` relaxed, a synthesized touch swipe anywhere in the songs dialog -- including on one of those buttons -- moved it zero pixels, because .iobar, .slots and .iohint above them still said none. A scroller has to relax its whole subtree, hence `.io *` below. And touch-action never blocks a tap or a click -- only panning and zooming -- so buttons, selects and every gesture in here are untouched by it. */ *{box-sizing:border-box; touch-action:none} html,body{ height:100%; margin:0; overflow:hidden; overscroll-behavior:none; background:var(--bg); color:var(--ink); font-family:var(--ui); -webkit-tap-highlight-color:transparent; -webkit-touch-callout:none; -webkit-text-size-adjust:100%; } button,select{font-family:inherit;color:inherit} button{touch-action:none} .io, .io *{touch-action:pan-y} .app{ height:100vh; height:100dvh; display:grid; gap:calc(5 * var(--u)); padding:calc(5 * var(--u) + env(safe-area-inset-top)) calc(6 * var(--u) + env(safe-area-inset-right)) calc(5 * var(--u) + env(safe-area-inset-bottom)) calc(6 * var(--u) + env(safe-area-inset-left)); /* Eight rows became six: the sequencer swallowed the footer, and DLY/REV share a row. That is where the vertical space comes from. Then the mixer made it eight. ONE row is elastic and it is SEQ. Every bank is content-sized. That inverts what this was. OSC, ENV, FLT and DLY were each `minmax(54px,1fr)`, so the height freed by folding a panel went to whichever banks were still open: measured at 412x915, folding DRM, REV and DLY grew OSC, ENV and FLT from 111px to 142px each while SEQ sat unchanged at 208. The sequencer is the thing you look at, so it takes the slack now. A whole layer went with it. There used to be five --r-* custom properties, a row-minimum floor, and a layoutRows() that wrote the properties from JS on every fold -- all of it there because `1fr` distributes by SHARE and not by content, so a folded 1fr bank would hide its contents and still hold its 54px. With no bank on 1fr, none of that has anything to decide: each tier writes its own row list literally, and the fold class does the rest. */ /* Height-aware rather than one number: 7.5vh is ~68px on a 915-tall phone and ~42px on a 568 one, which is what those two screens can each afford. The floor is what a graph needs to be worth drawing; the cap stops it overtaking the knob column beside it. It must be a DEFINITE number, which is the other half of this change -- see the comment over .viz. */ --vizh:clamp(calc(40 * var(--u)), 7.5vh, calc(72 * var(--u))); --bankh:calc(74 * var(--u)); /* see #bank-osc..#bank-rev below */ /* SEQ's floor. Measured need with the modifier rows folded away: 115px at 320x568 and 134px in short landscape (see the tier below). 116 cleared the first by ONE pixel, which is not a margin -- a font that renders two pixels taller would clip the footer. 124 costs one more fold on the tightest screen, which is exactly what fitCol is for. */ --seqmin:calc(124 * var(--u)); /* ONE height for everything in the top panel. It was three rules and two tiers disagreed with each other: the short-landscape tier shrank .play to 40u and left .save at 50u, so the transport and the save button -- which the CSS two blocks down says "borrow the same visual language" -- were measurably different squares at 720x400. Every tier now moves this one number, and the key, scene and instrument groups take it as a FLOOR so the bar's wrapped second line is as tall as its first. */ --barh:calc(50 * var(--u)); /* The side padding of a bank AND of a folded strip, one number, so a section LED cannot sit at a different x in the two states. */ --labpad:calc(4 * var(--u)); /* What SEQ spends its room on, and the ceiling for each. It has to be these three: the piano roll is the obvious candidate and it CANNOT grow -- measureCanvases writes the canvas ATTRIBUTE height from the box it just measured, so a canvas on height:auto feeds itself and runs away. The comment over .viz.roll is the scar. Steps, keys and modifier cells have no canvas in them, so they can flex freely. */ /* A step cell is 21px WIDE at 412, so its ceiling is set by aspect, not by appetite: 112u was tried and looked wrong -- 1:5.3, the note name stranded in the middle of an empty column. 76u is about 1:3.6, which still reads as a cell. The keyboard has the opposite shape and takes the rest. */ --stepmax:calc(76 * var(--u)); /* a note cell: bigger target */ --keysmax:calc(132 * var(--u)); /* a 28px piano key is tiny on a phone */ --modsmax:calc(14 * var(--u)); /* per modifier ROW, four of them */ grid-template-columns:minmax(0,1fr) minmax(0,1fr); /* DRM sits below ENV: the drums are their own instrument, and the three panels above them -- SEQ, OSC, ENV -- are the one you are editing. Its row is `auto` either way, so moving it costs nothing. */ /* bar mix SEQ osc env lfo drm flt dly -- LFO sits under ENV now, which puts the three panels belonging to ONE instrument together (SEQ, OSC, ENV) and the modulation that drives them directly beneath, instead of parking the sources two rows above the knobs you drag them onto. Row 3 is the only 1fr, so every pixel any fold frees lands there by construction -- and it moved up one with SEQ. */ grid-template-rows:auto auto minmax(0,1fr) auto auto auto auto auto auto; grid-template-areas: "bar bar" "mix mix" "seq seq" "osc osc" "env env" "lfo lfo" "drm drm" "flt flt" "dly rev"; } /* ---- shared panel look ---- */ .panel{ background:var(--panel); border:1px solid var(--edge); border-radius:8px; min-width:0; min-height:0; } .lab{ font:600 9px/1 var(--mono); font-size:calc(9.5 * var(--u)); letter-spacing:.14em; color:var(--dim); writing-mode:vertical-rl; align-self:stretch; display:flex; align-items:center; justify-content:flex-start; /* 14u around 11u of ink: 1.5u of air each side, which is as tight as this goes without the letters touching the panel edge. The rest of the gap was never the label at all -- it was the panel's own 7u side padding, trimmed to 4u below. */ /* 18u, not 14: the fold grip lives in this column and a 14u one was a fingernail target. SEQ and DRM share this single rule, so widening it keeps their two grids aligned with each other -- the one thing here that must not drift. The bar's own label pins itself back to 14u. */ flex:0 0 calc(18 * var(--u)); user-select:none; padding:calc(3 * var(--u)) 0 0; background:none; border:0; -webkit-appearance:none; appearance:none; } .lab.sw{cursor:pointer; color:var(--faint)} .lab.sq{cursor:pointer} /* The amber tilt lamp lived here, on the OSC label, for as long as the sensor drove one hard-coded knob. The sensor is two sources in the LFO panel now and its state shows on those -- a spot is grey until a reading arrives -- so the lamp had nothing left to indicate. */ .lab.sw::before{ /* LED, sits at the top-left corner */ content:""; flex:0 0 auto; width:calc(7 * var(--u)); height:calc(7 * var(--u)); border-radius:50%; background:var(--off); border:1px solid var(--edge); margin-bottom:calc(6 * var(--u)); } .lab.sw[aria-pressed="true"]{color:var(--accent)} .lab.sw[aria-pressed="true"]::before{ /* Lit the way the parameter-lock LEDs are: a hot core bleeding into the accent, then a real outward bloom -- same treatment, own colour. */ border-color:var(--accent); background: radial-gradient(closest-side, #eafff8 0 22%, var(--accent) 60%, transparent 92%) 50% 50% / 100% 100% no-repeat, var(--accent); box-shadow:0 0 calc(3 * var(--u)) var(--accent), 0 0 calc(7 * var(--u)) var(--glow), 0 0 calc(13 * var(--u)) var(--glow); } .lab.sw:focus-visible{outline:2px solid var(--accent2); outline-offset:1px} .bank.off .knobs{opacity:.32} /* ---- transport ---- */ .bar{grid-area:bar; display:flex; align-items:center; gap:calc(8 * var(--u)); padding:calc(5 * var(--u)) calc(4 * var(--u)); container-type:inline-size; /* Four controls arrived here and this row held 294px of 300 at 320px before any of them. Wrapping is the deliberate failure mode, the same choice .seqfoot already documents: a second line is legible, and a row that refuses to wrap clips a control off the panel edge instead. The transport shrinking at this tier pays for most of the line it costs. */ /* STRETCH, not center. Centring left the scene stepper and the instrument buttons as 26u controls floating in a 54u line with 14u of dead air above and below each -- which is what "use the available vertical space" was asking about. Anything with a height of its own (the transport, the save button, the tempo) is unaffected; everything else now fills its line. */ flex-wrap:wrap; align-items:stretch; row-gap:calc(4 * var(--u))} /* ---- captioned control groups in the top panel ------------------------ A caption is what says two controls sitting next to each other are one idea -- KEY is a note and a scale, SCENE is a length and which one you are on. 9u of type, the same floor every label in this app uses, above a row that had to grow anyway to be worth pressing with a thumb. */ .grp{flex:0 0 auto; display:flex; flex-direction:column; gap:calc(2 * var(--u)); align-items:stretch} /* the control under a caption takes whatever height the caption did not */ .grp > .barsel, .grp > .scnbox{flex:1 1 auto} .cap{ font:600 9px/1 var(--mono); font-size:calc(9 * var(--u)); letter-spacing:.14em; color:var(--faint); text-align:center; user-select:none; } /* ---- the instrument selector, as an option box ------------------------- Both options on show, so the other one does not have to be discovered by tapping. The live one is an emitter, not a highlight: the same hot-core gradient and outward bloom the section LEDs and the parameter-lock LEDs wear, in the instrument's own colour -- so VA glows accent and FM glows accent2, matching the panels the selector controls. */ .insbox{ flex:0 0 auto; margin-left:auto; display:flex; flex-direction:column; gap:calc(2 * var(--u)); min-height:var(--barh); order:3; } .insopts{ flex:1 1 auto; /* the caption's leftover height */ display:flex; align-items:stretch; overflow:hidden; border:1px solid var(--edge); border-radius:6px; background:var(--off); } .insopt{ flex:0 0 auto; min-width:calc(26 * var(--u)); min-height:calc(26 * var(--u)); border:0; background:none; padding:0 calc(3 * var(--u)); font:700 11px/1 var(--mono); font-size:calc(10.5 * var(--u)); letter-spacing:.1em; color:var(--faint); cursor:pointer; touch-action:none; -webkit-appearance:none; appearance:none; } .insopt + .insopt{border-left:1px solid var(--edge)} .insopt:active{background:var(--edge)} .insopt:focus-visible{outline:2px solid var(--accent2); outline-offset:-2px} .insopt[aria-pressed="true"]{ color:#04120d; background:var(--accent); box-shadow:0 0 calc(6 * var(--u)) var(--glow), inset 0 0 calc(8 * var(--u)) rgba(255,255,255,.35); text-shadow:none; } /* The selector says which instrument you are editing, so it wears that instrument's own lit colour: brass for the wooden one, blue for the other. --ins-ac is brass exactly when VA is the live instrument, which is exactly when this rule can match, so the variable needs no second definition. */ #insva[aria-pressed="true"]{ color:#231e16; background:var(--ins-ac); box-shadow:0 0 calc(6 * var(--u)) var(--ins-glow), inset 0 0 calc(8 * var(--u)) rgba(255,255,255,.35); } #insfm[aria-pressed="true"]{ color:#060b1c; background:var(--accent2); box-shadow:0 0 calc(6 * var(--u)) var(--accent2-glow), inset 0 0 calc(8 * var(--u)) rgba(255,255,255,.35); } #build{flex:0 0 calc(14 * var(--u)); align-self:stretch} /* The song's own controls -- key, scale, scene chain -- as one block, so that when the bar wraps they move together instead of one of them dangling. */ .songctl{flex:0 0 auto; display:flex; align-items:stretch; gap:calc(6 * var(--u)); min-height:var(--barh); order:2} /* Key and scale side by side, and the scene chain likewise: stacked in pairs they made the bar's wrapped line two controls tall, which cost more height than the whole move saved. One row is 21u; two were 44. They were two <select>s. A select is a fine way to CONFIGURE a song and a poor way to play one -- it opens a modal list, wants two taps and a hit on a small row, and cannot be nudged at all. They are dials now, and they are the same widget as every other list-picker in the instrument. COMPACT, because the markup two blocks down says in as many words that "a knob is 42u wide and this bar does not have 42u" -- which is why the scene count is a stepper. So these borrow the sizes #bank-dly already uses at narrow widths: a 32u knob around a 26u dial, which is 10u narrower than a panel knob and still a 26u touch target. Everything inside the dial is the generic .dial, so the LED, the arc, the dome and the pointer all come for free and every responsive tier resizes them along with the rest. */ .barsel{display:flex; align-items:stretch; gap:calc(3 * var(--u))} .barsel .knob{width:calc(32 * var(--u)); padding:0; gap:0; justify-content:center} /* The face grows into whatever the label and the readout leave, instead of being a fixed 26u square with air above it. 26u stays as the FLOOR so the narrowest tier -- where the line is 40u and the two lines of type cost 17 of it -- cannot shrink the face below what it draws today. */ .barsel .dial{ flex:1 1 auto; width:auto; height:auto; aspect-ratio:1; align-self:center; /* Clamp the HEIGHT, never the width. Flex sets the height and aspect-ratio derives the width from it, so a max-WIDTH clamps the derived side only and the face comes out 32x33 -- an oval under border-radius:50%, with an oval arc on it. Clamping the driving side keeps it square by construction. 26u floor is what the fixed square drew before, so the narrow and short tiers are unchanged; 32u ceiling is the knob's own width, so the face can grow into the freed caption row without spilling out of its knob. */ min-height:calc(26 * var(--u)); max-height:calc(32 * var(--u)); } .barsel .dial::before{inset:calc(3 * var(--u))} .barsel .dial::after{ top:calc(4 * var(--u)); height:calc(6 * var(--u)); transform-origin:calc(1 * var(--u)) calc(9 * var(--u)); } /* 9u, not the knob's 12u: SCALE's widest label is four characters (HIJZ) and the note names two, in 26u of dial. */ .barsel .knob output{font-size:calc(9 * var(--u))} .barsel .knob label{font-size:calc(8 * var(--u)); letter-spacing:.04em} /* The scene chain: the count stepper, then the hold-and-number block, all on one line. .hold stacks its two buttons by default -- which is right in a 40u-tall footer and wrong in a 21u-tall bar row, where it gave each of them ten pixels. Here it runs across instead, so both keep the full height. */ .scnbox{flex:0 0 auto; display:flex; align-items:stretch; gap:calc(3 * var(--u))} .scnbox .oct.scnc{align-items:stretch} .scnbox .hold{ height:auto; width:auto; flex:0 0 auto; flex-direction:row; align-items:stretch; min-height:calc(26 * var(--u)); } .scnbox .hold .lk, .scnbox .hold .num{min-width:calc(21 * var(--u)); padding:0 calc(1 * var(--u))} .scnbox .hold .lk{font-size:calc(13 * var(--u))} .scnbox .hold .num{font-size:calc(11 * var(--u))} .oct.scnc button{min-height:calc(26 * var(--u)); width:calc(19 * var(--u)); align-self:stretch; font-size:calc(15 * var(--u))} /* The <b> stretches with the stepper now, and a stretched <b> is a block with its text at the TOP -- the number sat above the -- and + beside it. */ .oct.scnc b{min-width:calc(17 * var(--u)); font-size:calc(11 * var(--u)); display:flex; align-items:center; justify-content:center} .play{ display:flex; align-items:center; justify-content:center; flex:0 0 auto; width:var(--barh); height:var(--barh); padding:0; border:1px solid var(--edge); border-radius:7px; cursor:pointer; /* not the `manipulation` every other button gets: a swipe up or down toggles fullscreen, and the browser must not claim that gesture as a pan and answer with pointercancel halfway through it. */ touch-action:none; /* Knurled like a machined grip: the long press that opens IMPORT / EXPORT is otherwise undiscoverable, and a texture says "there is more here" without spending a label on it. Cross-hatched at low contrast so it reads as material rather than as decoration. */ background: repeating-linear-gradient(45deg, rgba(255,255,255,.055) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(4 * var(--u))), repeating-linear-gradient(-45deg, rgba(0,0,0,.16) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(4 * var(--u))), var(--panel-hi); font:700 12px/1 var(--mono); font-size:calc(12 * var(--u)); letter-spacing:.13em; } /* SAVE sits beside the transport and borrows its whole visual language: same square, same border radius, same lit state. Dark until there is a take, because a button that looks pressable with nothing behind it is a lie. */ .save{ display:flex; align-items:center; justify-content:center; flex:0 0 auto; width:var(--barh); height:var(--barh); padding:0; border:1px solid var(--edge); border-radius:7px; cursor:pointer; /* The same knurl the transport wears, and for the same reason: this button now carries a second action on a long press, and the texture is the vocabulary this app already uses for "there is more here". */ background: repeating-linear-gradient(45deg, rgba(255,255,255,.055) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(4 * var(--u))), repeating-linear-gradient(-45deg, rgba(0,0,0,.16) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(4 * var(--u))), var(--panel-hi); touch-action:none; user-select:none; -webkit-user-select:none; } .save svg{width:calc(26 * var(--u)); height:calc(26 * var(--u)); display:block; overflow:visible} .save .sv-a, .save .sv-t{ fill:none; stroke:var(--faint); stroke-width:2; stroke-linecap:round; stroke-linejoin:round; transition:stroke .12s linear; } .save[disabled]{cursor:default; opacity:.55} /* Armed: the same border + bloom the running transport wears, so the two read as one instrument rather than two unrelated widgets. */ .save.on{border-color:var(--accent); box-shadow:0 0 calc(9 * var(--u)) var(--glow)} .save.on .sv-a, .save.on .sv-t{stroke:var(--accent)} .save.on svg{ filter:drop-shadow(0 0 calc(2 * var(--u)) var(--accent)) drop-shadow(0 0 calc(6 * var(--u)) var(--glow)); } .save:not([disabled]):active{background:var(--edge)} .save:focus-visible{outline:2px solid var(--accent2); outline-offset:1px} /* Two sync states, the pair entry 66 settled: blue LISTEN, orange SEND. They went away in revision 87 with the roles, and came back with them -- orange is the playhead colour, already meaning "this is the thing that is moving", and blue is the parameter-lock accent, already meaning "armed and waiting". Both are worn by the transport because that is the control the gesture lives on. THE COLOUR IS THE ROLE, and it no longer changes when the transport starts. It used to: a rule here read "playing still wins" and repainted the hexagon green, so a sending device went orange, then green the moment you pressed play -- the one time you most want to see which device is driving. Two channels instead of one, which is what makes them able to coexist: HUE says who you are -- orange sending, blue listening, green alone MOTION and BLOOM say running -- the logo turns once per scene, and the drop-shadow below only exists while playing Done with a variable rather than a :not() chain on each rule: --pc/--pg default to the running green, so an instance with no role behaves exactly as it did, and a role overrides both -- after which the border, the bloom, the hexagon and the drop-shadow all follow without knowing why. */ .play{--pc:var(--accent); --pg:var(--glow)} .play.listen{--pc:var(--accent2); --pg:var(--accent2-glow)} .play.send{--pc:var(--now); --pg:var(--now-glow)} .play.listen, .play.send{ border-color:var(--pc); box-shadow:0 0 calc(9 * var(--u)) var(--pg); } .play.listen .cmk-hex, .play.send .cmk-hex{fill:var(--pc); stroke:var(--pc)} /* Running: the mark itself lights up rather than the button flooding with colour -- the glow has to come from the logo, not from behind it. In --pc, so a role keeps its hue while it plays. */ .play[aria-pressed="true"]{ border-color:var(--pc); box-shadow:0 0 calc(9 * var(--u)) var(--pg); } .play .cmk{height:calc(36 * var(--u)); width:auto; display:block; overflow:visible} /* the stroke is what rounds the corners, so it must match the fill */ .play .cmk-hex{ fill:var(--faint); stroke:var(--faint); stroke-width:3.5; stroke-linejoin:round; transition:fill .12s linear, stroke .12s linear; } /* Painted in the button's own colour rather than masked out: a real hole lets the drop-shadow below shine through it and washes the shape away. */ .play .cmk-mark{fill:var(--panel-hi); transition:fill .09s linear} /* Sync activity. The mark is the one part of the logo no other state paints, so it can blink over idle, blue, orange AND playing without a collision -- the hexagon, the border and the bloom are all already spoken for. Lighting it from the inside also reads as "signal arriving" rather than as another mode. No transition on the way in: the onset is the thing being timed. */ .play.flash .cmk-mark{fill:var(--text); transition:none} /* One turn per scene. The angle is written from draw() off the playhead rather than run as a CSS animation, because an animation drifts from the audio clock and resumes wherever it was paused -- it has to READ the sequencer to be in sync with it. transform-box:fill-box puts the origin at the hexagon centre. */ .cmk-spin{transform-box:fill-box; transform-origin:center; transform:rotate(90deg)} .play[aria-pressed="true"] .cmk-hex{fill:var(--pc); stroke:var(--pc)} /* The bloom goes on the <svg> box: an SVG filter region is clipped to the filtered element's bounds, which drew the glow as a hard-edged square. */ /* This filter is the one thing that exists ONLY while playing, which is what leaves brightness free to mean "running" while hue means "who". */ .play[aria-pressed="true"] .cmk{ filter:drop-shadow(0 0 calc(2 * var(--u)) var(--pc)) drop-shadow(0 0 calc(6 * var(--u)) var(--pg)) drop-shadow(0 0 calc(12 * var(--u)) var(--pg)); } .tempo{ height:var(--barh); /* matches the square transport */ /* 0 0, not the default 0 1: this row gained four controls and the tempo was the only flexible thing in it, so it absorbed the whole deficit and measured TWO PIXELS wide at 320px. A row that overflows is a bug you can see; a control silently crushed to nothing is one you cannot. */ flex:0 0 auto; display:flex; align-items:center; gap:2px; border:1px solid var(--edge); border-radius:7px; background:var(--panel-hi); overflow:hidden; } .nudge{ width:calc(28 * var(--u)); align-self:stretch; border:0; background:transparent; font:400 18px/1 var(--ui); font-size:calc(18 * var(--u)); color:var(--dim); cursor:pointer; } .nudge:active{background:var(--edge); color:var(--ink)} #bpm{ /* trimmed from 56u to hand the width to the piano roll */ min-width:calc(40 * var(--u)); text-align:center; cursor:ns-resize; touch-action:none; user-select:none; padding:2px 0; /* the readout drew 36u inside a 48u box; now it owns the whole height, so the drag target is the box you can see rather than the type inside it */ align-self:stretch; display:flex; flex-direction:column; align-items:center; justify-content:center; } #bpm b{font:700 15px/1.05 var(--mono); font-size:calc(15 * var(--u)); display:block} #bpm small{font:600 9px/1 var(--mono); font-size:calc(9 * var(--u)); letter-spacing:.16em; color:var(--faint)} /* ---- sequencer ---- */ .seqrow{ grid-area:seq; display:flex; align-items:stretch; gap:var(--seqgap); padding:calc(7 * var(--u)) var(--seqpad); } /* These knobs are the same size as every other knob in the app: .arpk carries no size rules any more, so they inherit .knob/.dial and every responsive tier resizes them along with the panel knobs. */ .hold{height:calc(40 * var(--u))} .seqbody{flex:1 1 auto; min-width:0; display:flex; flex-direction:column; gap:calc(5 * var(--u))} /* ---- drum pads ---------------------------------------------------------- Geometry copied from .seqrow so the columns line up with the steps: 4u of side padding, a 14u label column, a 6u gap, then sixteen 1fr columns with the same 2u gutter. Change one of those five numbers here and it must change there too, or the two grids drift apart. */ .drmrow{ grid-area:drm; display:flex; align-items:stretch; gap:var(--seqgap); padding:var(--drmpadv) var(--seqpad); } .drmbody{flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:calc(2 * var(--u))} .drms{ flex:1 1 0; min-width:0; display:grid; grid-template-columns:repeat(16, minmax(0,1fr)); /* Height-aware, because this row is pure cost to every panel below it: on a short screen the pads shrink rather than squeezing the knobs off their banks, and on a tall one they take their full 11u. 8px is the floor -- a pad below that stops being tappable. */ grid-template-rows:repeat(4, clamp(10px, 2vh, calc(16 * var(--u)))); /* the inline geometry is shared with .seq through the four variables above; only the row gutter is this grid's own business */ padding-inline:var(--steppad); column-gap:var(--stepgap); row-gap:calc(2 * var(--u)); } .pad{ position:relative; min-width:0; padding:0; border:1px solid var(--edge); border-radius:3px; background:var(--off); cursor:pointer; /* the lane gestures are drags, so the browser must not claim them */ touch-action:none; -webkit-appearance:none; appearance:none; overflow:hidden; } /* the downbeats, so you can count without reading the sequencer above */ .pad[data-beat="1"]{background:var(--panel-hi); border-color:var(--dim)} /* One hue per lane, so a glance says which line you are on without spending width on labels inside the pads. */ .drms .r0{--lane:#ff6a2b} /* kick: the playhead orange */ .drms .r1{--lane:#7e96f3} /* snare */ .drms .r2{--lane:#15d1a0} /* closed hat */ .drms .r3{--lane:#ffa042} /* open hat */ /* The mark is a layer INSIDE the cell, not the cell itself. That is what lets a lane's groove show without moving anything: the cell stays locked to the sequencer column above while the mark slides within it. With the lane names gone this is the only thing left that says what a lane is doing, so it has to carry all of it -- nudge as a shift, ratchets as slices, and a divided lane by dimming the pads that will not sound. */ .pad::after{ content:""; position:absolute; inset:calc(1 * var(--u)); border-radius:2px; background:transparent; } /* A nudged lane draws a narrower mark and slides it within the cell, rather than sliding a full-width one and having the cell clip it away -- at 3/4 of a step the colour would have been almost entirely cut off. Narrow IS the signal that a lane is off the grid; 9% a quarter puts the extreme exactly on the cell edge and no further. */ .pad.nud::after{ inset-inline:auto; width:46%; left:calc(50% + var(--nudge, 0) * 9%); transform:translateX(-50%); } .pad.on::after{background:var(--lane)} .pad.on{box-shadow:0 0 calc(4 * var(--u)) var(--lane)} /* two and three slices, so a ratchet reads as "this hits twice" */ .pad.x2.on::after{ background:repeating-linear-gradient(90deg, var(--lane) 0 44%, transparent 44% 56%, var(--lane) 56% 100%); } .pad.x3.on::after{ background:repeating-linear-gradient(90deg, var(--lane) 0 28%, transparent 28% 39%, var(--lane) 39% 61%, transparent 61% 72%, var(--lane) 72% 100%); } /* an intermittent step: dashed edge and a paler mark, so it reads as "not every time round" without depending on which lap you are looking at */ .pad.div{border-style:dashed} .pad.div.on::after{opacity:.55} .pad.cur{box-shadow:inset 0 0 0 1px var(--now)} .pad.on.cur{box-shadow:inset 0 0 0 1px var(--ink), 0 0 calc(5 * var(--u)) var(--lane)} .pad:focus-visible{outline:2px solid var(--accent2); outline-offset:1px} /* ---- preset row --------------------------------------------------------- */ .drmbar{display:flex; gap:calc(3 * var(--u)); align-items:stretch} .drmbar button{ flex:1 1 0; min-width:0; padding:0; border:1px solid var(--edge); border-radius:4px; background:var(--off); color:var(--dim); cursor:pointer; touch-action:none; font:700 9px/1 var(--mono); font-size:calc(9 * var(--u)); letter-spacing:.06em; height:calc(15 * var(--u)); min-height:15px; -webkit-appearance:none; appearance:none; } .drmbar button:active{background:var(--edge); color:var(--ink)} .drmbar .danger{color:var(--warn); border-color:var(--edge)} .drmbar .danger:active{background:var(--warn); color:#1a1207; border-color:var(--warn)} /* ---- note keyboard ------------------------------------------------------ White keys in a flex row, black keys laid over the seams -- the standard construction, so the white widths stay honest whatever the panel width. Two octaves anchored to C, C..C, whatever the root is: 15 white keys always, and a black key can never land on either edge. A step stores offsets from the root rather than absolute pitches, so the reachable offsets run as low as -23 -- which is what SEMI_MAX is for. */ .keys{ /* 28u, not 34: the drum row's pads and its preset strip had to come from somewhere, and this is the least-pressed element in the panel. */ /* Grows with the panel now, from the 28u floor up to --keysmax: a key you can actually hit is worth more than any other use of this space. */ position:relative; flex:1 1 auto; min-height:calc(28 * var(--u)); max-height:var(--keysmax); user-select:none; touch-action:none; } /* no gap: the black keys' left is `before / whites` of the row width, which ignores gaps, so 1px between whites drifted them up to 14px off their seams by the right-hand end. Each key carries its own 1px border regardless. */ .keys .wrow{display:flex; height:100%} /* --kb-edge, not --edge: the seams between the keys were following the instrument too, which drew a walnut hairline down every white key. A keyboard is a keyboard on either engine -- the only thing on it that carries the instrument's colour is the root bar, which is an app marker and not a piano part. */ .keys .k{ position:relative; border:1px solid var(--kb-edge); box-sizing:border-box; cursor:pointer; padding:0; -webkit-appearance:none; appearance:none; } /* The octave digit sits bottom-left: the note dot is centred and the root bar spans the middle 76%, so the corner is the one place nothing else claims. */ .keys .wk{ flex:1 1 0; min-width:0; background:var(--dim); border-radius:0 0 3px 3px; display:flex; align-items:flex-end; justify-content:flex-start; padding:0 0 calc(3 * var(--u)) calc(1.5 * var(--u)); font:700 7px/1 var(--mono); font-size:calc(7 * var(--u)); color:var(--kb-black); opacity:.75; /* the octave digit, same reason */ } .keys .bk{ position:absolute; top:0; height:62%; /* 62% of one white key: the row holds --w of them, so a white is 100%/--w */ width:calc(62% / var(--w, 15)); transform:translateX(-50%); background:var(--kb-black); border-color:#0c1116; border-radius:0 0 3px 3px; z-index:2; } /* in the current scale: a guide, not a restriction -- every key stays live */ .keys .wk.ins{background:var(--ink)} /* still black, just not the blackest: this says "in the scale", not "white" */ .keys .bk.ins{background:var(--kb-black-ins)} /* Where the scale starts. The keyboard is anchored to C now, so without this the root is invisible -- and the root is what every stored note is measured from. A bar across the very bottom, clear of the note dot above it. */ .keys .k.rt::before{ content:""; position:absolute; left:12%; right:12%; bottom:0; height:calc(2 * var(--u)); background:var(--accent); border-radius:1px; } /* A note the shown step plays. Black on the white keys and white on the black ones: the accent green sat too close to both key colours to read at a glance. */ .keys .k.hit::after{ content:""; position:absolute; left:50%; transform:translateX(-50%); bottom:calc(3 * var(--u)); width:calc(9 * var(--u)); height:calc(9 * var(--u)); border-radius:50%; } .keys .wk.hit::after{background:#0c1116} .keys .bk.hit::after{ background:#fff; bottom:calc(2 * var(--u)); width:calc(8 * var(--u)); height:calc(8 * var(--u)); } /* While the transport runs the keyboard reports rather than edits -- but a press still sounds the note, so the keys are never actually inert. */ .keys .k:focus-visible{outline:2px solid var(--accent2); outline-offset:-2px} /* Steps on top, scene controls bottom left, note controls bottom right. The 2x2 block below is what stops this line wrapping -- it takes ~89px out of the note controls, which is more than the deficit at any width down to 320. wrap is deliberately KEPT as the failure mode: nowrap would clip an ARP knob off the panel edge, and a control you cannot reach is worse than a row that grew. (The hint used to be named here as the thing that gives first. It is not in this row any more -- it lives in the bar's dead space, and it wraps rather than ellipsising.) */ .seqfoot{display:flex; align-items:center; gap:calc(5 * var(--u)); flex-wrap:wrap; container-type:inline-size} .notes{display:flex; flex:0 0 auto; align-items:center; gap:calc(3 * var(--u)); margin-left:auto; min-width:0} .seq{ /* Grows into whatever SEQ was given, and is the reason SEQ asks for it: a taller step cell is a bigger target, which is the whole mobile brief. NO min-height:0 here, and no plain 1fr track. Both were tried: a flex item that may shrink below its content, holding a track that may be zero, put the whole step row at 9px on a 915-tall screen and 0px on a 568 one. The track's floor has to be the cells' own min-content. */ flex:2 1 auto; min-width:0; container-type:inline-size; max-height:calc(var(--stepmax) + 2 * var(--steppad)); display:grid; grid-template-columns:repeat(16,minmax(0,1fr)); grid-template-rows:minmax(min-content,1fr); padding:var(--steppad); gap:var(--stepgap); } /* ---- per-step modifiers ------------------------------------------------- Four rows ABOVE the steps, in the same flex parent as .seq and using the same two variables for their inline geometry -- so the columns line up with the step cells by construction, not by a copied constant. That is the exact hazard the .drms comment warns about, and this grid avoids it by living inside .seqbody instead of being a sibling section. NO ROW LABELS: there is no width for four of them and the SEQ label column is already taken. Each row has its own hue and an ACTIVE cell draws its own glyph, so a row in use says what it is and an unused one costs seven pixels of nothing. The names live in title and aria-label. */ .mods{ flex:0 0 auto; min-width:0; display:grid; grid-template-columns:repeat(16, minmax(0,1fr)); /* Height-aware for the same reason .drms is: these rows are pure cost to every panel below them, so on a short screen they thin out before they take a knob bank off the screen. 7px is the floor for a tappable cell. */ /* the floor is what stays tappable; the 1fr lets a row take its share when SEQ has space, up to --modsmax */ grid-template-rows:repeat(4, minmax(clamp(7px, 1.4vh, calc(11 * var(--u))), var(--modsmax))); flex:1 1 auto; max-height:calc(4 * var(--modsmax) + 3px); padding-inline:var(--steppad); column-gap:var(--stepgap); row-gap:1px; } .mcell{ position:relative; min-width:0; padding:0; border:1px solid var(--edge); border-radius:2px; background:var(--off); color:transparent; cursor:pointer; overflow:hidden; /* An 11px cell has no room for a shading gradient, so the only thing separating it from the panel is this border -- it has to be the same one on all sixteen or the row stops looking like a row. */ font:700 8px/1 var(--mono); font-size:clamp(6px, 1.1vh, calc(9 * var(--u))); display:flex; align-items:center; justify-content:center; touch-action:none; -webkit-appearance:none; appearance:none; } /* The downbeats, so you can count along the row without reading the steps -- but by FILL only. The step cells mark theirs with a --dim border as well, which at 44px tall reads as emphasis; at 11px it reads as "these four are the only cells here", and the other forty-eight disappear into the panel. Measured off a screenshot, which is the only way that shows up. */ .mcell[data-beat="1"]{background:var(--panel-hi)} .mcell[aria-pressed="true"]{ background:var(--mod); border-color:var(--mod); color:#0b0f12; box-shadow:0 0 calc(4 * var(--u)) var(--modg); } .mcell:focus-visible{outline:2px solid var(--accent2); outline-offset:1px} .mods .m0{--mod:#ff6a2b; --modg:rgba(255,106,43,.50)} /* accent */ .mods .m1{--mod:#15d1a0; --modg:rgba(21,209,160,.50)} /* slide */ .mods .m2{--mod:#7e96f3; --modg:rgba(126,150,243,.50)} /* oct up */ .mods .m3{--mod:#a97cff; --modg:rgba(169,124,255,.50)} /* oct dn */ /* The first thing this layout drops when it runs out of room, and the only thing that hides without folding a whole panel: four rows the pattern still plays without, against a bank of controls that it does not. */ .app.nomods .mods{display:none} /* A lock over the scene number. It stopped sitting beside the step cells in the layout rework, so its height is now the plain .hold rule further up rather than a measurement taken from a cell in JS. */ .hold{ flex:0 0 auto; width:calc(30 * var(--u)); display:flex; flex-direction:column; overflow:hidden; border:1px solid var(--edge); border-radius:5px; background:var(--off); } .hold .lk, .hold .num{ flex:1 1 0; min-height:0; display:flex; align-items:center; justify-content:center; border:0; background:none; padding:0; cursor:pointer; touch-action:none; -webkit-appearance:none; appearance:none; } .hold .lk{font-size:calc(12 * var(--u)); line-height:1; filter:grayscale(1) opacity(.5)} .hold .num{font:700 10px/1 var(--mono); font-size:calc(10 * var(--u)); color:var(--dim)} .hold .num:active{background:var(--edge)} .hold.on{border-color:var(--accent); background:var(--panel-hi)} .hold.on .lk{filter:none} .hold.on .num{color:var(--accent); text-shadow:0 0 calc(6 * var(--u)) var(--glow)} .hold .lk:focus-visible, .hold .num:focus-visible{outline:2px solid var(--accent2); outline-offset:-2px} .step{ position:relative; min-width:0; /* height from the sequencer's own width (.seq is an inline-size container), so cells stay 3:5 on phones but stop growing on wide screens. aspect-ratio + max-height would instead shrink the WIDTH to preserve the ratio. */ /* The width-derived size is now a FLOOR rather than the height: a cell is never smaller than it was, and grows when the row above it hands SEQ space that used to be swallowed by the banks. Capped, because a 150px note cell is not a better note cell. */ height:auto; min-height:clamp(calc(26 * var(--u)), 9.5cqi, calc(44 * var(--u))); max-height:var(--stepmax); border:1px solid var(--edge); border-radius:4px; background:var(--off); color:var(--faint); font:600 9px/1 var(--mono); letter-spacing:0; font-size:clamp(calc(9 * var(--u)), 2.3cqi, 34px); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:calc(1 * var(--u)); cursor:ns-resize; touch-action:none; user-select:none; overflow:hidden; } /* downbeats: steps 1, 5, 9, 13 */ .step[data-beat="1"]{background:var(--panel-hi); border-color:var(--dim); color:var(--dim)} .step[data-beat="1"]::after{ content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--dim); opacity:.9; /* top bar: never collides with the centred note */ } .step[data-on="1"]{background:var(--panel-hi); color:var(--ink); border-color:var(--accent)} .step[data-on="1"]::before{ content:""; position:absolute; left:0; right:0; bottom:0; height:2px; background:var(--accent); } .step.cur{box-shadow:inset 0 0 0 2px var(--now), 0 0 calc(6 * var(--u)) var(--now-glow)} /* the step the keyboard is editing -- deliberately a different colour from the playhead above, which is the other thing that outlines a cell */ .step.sel{box-shadow:inset 0 0 0 2px var(--accent2)} .step.sel.cur{box-shadow:inset 0 0 0 2px var(--now), inset 0 0 0 4px var(--accent2)} .step .pl2{font-style:normal; opacity:.75; font-size:.8em; margin-left:.15em} /* a step carrying parameter locks. The left edge, because the top edge is already the downbeat bar and the bottom edge the on-state bar. Declared after the data-on/data-beat shorthands so its background-image survives. */ .step.plk{ background-image:linear-gradient(var(--accent2), var(--accent2)); background-repeat:no-repeat; background-position:left top; background-size:max(2px, calc(2 * var(--u))) 100%; /* still reads on a 105px cell */ } /* The scene controls take ~67px out of the sequencer, so cells narrow from 19.1px to 14.6px at a 360px viewport while "A#2" needs 16.3px. Names shrink where they still fit and give way to the duration glyph where they cannot. */ @container (max-width: 340px){ .step{font-size:calc(8 * var(--u))} } @container (max-width: 300px){ .step .nm{display:none} } .step .gl{flex:0 0 auto; height:clamp(calc(16 * var(--u)), 3.2cqi, 46px); width:auto; max-height:50%} .step .nm{flex:0 0 auto} /* ---- octave stepper (discrete: +/- beats a drag on a phone) ---- */ .oct{display:flex; flex:0 0 auto; align-items:center; gap:1px; border:1px solid var(--edge); border-radius:6px; overflow:hidden} .oct button{width:calc(30 * var(--u)); min-height:calc(40 * var(--u)); border:0; background:var(--off); color:var(--dim); font:400 16px/1 var(--ui); font-size:calc(16 * var(--u)); cursor:pointer} .oct button:active{background:var(--edge)} .oct b{min-width:calc(24 * var(--u)); text-align:center; font:700 11px/1 var(--mono); font-size:calc(11 * var(--u))} .oct b small{display:block; font:600 9px/1 var(--mono); font-size:calc(9 * var(--u)); letter-spacing:.14em; color:var(--faint); margin-top:2px} /* ---- knob banks ---- */ /* --labpad is shared with .panel.col below, so a folded panel's LED lands at the same x as an open one's. The gap is 3u, not 6: the only place it shows is between the knobs and the glass, and the glass is what wants the room. */ .bank{display:flex; align-items:center; gap:calc(3 * var(--u)); padding:calc(4 * var(--u)) var(--labpad); contain:layout paint} /* One floor for the five content-sized banks, because content alone leaves them ragged: ENV, FLT, DLY and REV wear a section SWITCH whose LED makes the vertical label ~13px taller than OSC's plain span, which the old 1fr rows hid. Measured at 320x568 with rows content-sized: OSC 61px against ENV and FLT at 74. A FLOOR rather than a height, so nothing can ever clip -- a bank whose content needs more still takes more. */ :not(.col)#bank-osc, :not(.col)#bank-env, :not(.col)#bank-flt, :not(.col)#bank-dly, :not(.col)#bank-rev{min-height:var(--bankh)} /* And SEQ needs a floor of its own for the opposite reason: it is the only 1fr row, so on a screen where the fixed banks and the bar already fill the height it is the one that gets squeezed. Measured without this: 19px at 320x568 and 11px in short landscape -- the sequencer gone while five folded strips sat above it. With the floor the grid overflows instead, which is what fitCol measures and folds against. */ #bank-seq:not(.col){min-height:var(--seqmin)} #bank-mix{grid-area:mix} #bank-lfo{grid-area:lfo} #bank-osc{grid-area:osc} #bank-env{grid-area:env} #bank-flt{grid-area:flt} #bank-dly{grid-area:dly} #bank-rev{grid-area:rev} /* panels without a graph let their knobs spread across the full width */ .bank .knobs:last-child{flex:1 1 auto; justify-content:space-around} /* DLY and REV share one row, so each has about half the width. Both hold exactly three knobs, so they drop the empty fourth column. */ #bank-dly .knobs, #bank-rev .knobs{ grid-template-columns:repeat(3, minmax(0, calc(42 * var(--u)))); gap:calc(4 * var(--u)); } /* AS MANY COLUMNS AS THERE ARE KNOBS. This was `repeat(4, 42u)` -- a fixed four whatever the panel held, which lined every canvas in the app up at one x and paid for it in dead space: FLT has three knobs and OSC shows two, so they reserved one and two empty 42u columns, and the spectrum started 50px right of where it could. DLY and REV already carried a hand-written rule below to escape it, which is the tell. A hidden knob creates no track, so the OSC panel now narrows to the engine you are actually looking at. */ .knobs{ display:grid; grid-auto-flow:column; grid-auto-columns:calc(42 * var(--u)); gap:calc(8 * var(--u)); justify-content:start; align-items:center; flex:0 0 auto; min-width:0; } /* ---- the mixer ------------------------------------------------------- A fader is the KNOB widget with different CSS and nothing else. paintKnob already publishes the normalised value as --v and .dial turns that into a conic sweep; here the same --v becomes a thumb position instead. So buildKnob, bindKnob, paintKnob and markKnob are reused untouched, and the fader inherits the Y drag, shift-for-fine, hold-to-reset, the <output> readout, the WARN/CRIT colours and the aria-valuenow for free. */ .strips{ flex:1 1 auto; min-width:0; display:flex; align-items:flex-end; /* spread rather than bunched at the label end: four narrow strips against the left edge of a 380px panel read as an accident, and the separation makes them easier to hit as well */ justify-content:space-around; gap:calc(6 * var(--u)); } /* ---- collapsing ------------------------------------------------------ A panel folds down TO ITS LABEL: the strip has to stay or there would be nothing left to tap to get it back. Which means the label has to lie down -- a vertical-rl label is unreadable in an 18px strip -- and the caret says which way it will go. */ .panel.col{min-height:0} .panel.col > *:not(.labcol){display:none} .panel.col .lab{ writing-mode:horizontal-tb; letter-spacing:.16em; position:relative; /* 18u of LED slot on EVERY folded label, lit or not. The LED was a flex child with a margin, so a panel that HAS one pushed its name 13px right of a panel that does not -- MIX, LFO, DRM, SEQ and OSC started at x 7 and ENV, FLT, DLY and REV at 20, each dragging its fold grip along behind it. Reserving the slot as PADDING rather than as a transparent pseudo-element is what keeps this to two rules: the LED is positioned inside the padding, and the lit styles further up are reused untouched. 18u is the width of .labcol, which is where the LED sits when the panel is open. */ padding:calc(2 * var(--u)) 0 calc(2 * var(--u)) calc(18 * var(--u)); } /* The LED keeps its meaning while folded, so a bypassed FLT still reads as bypassed without unfolding it. Centred ON the slot's midpoint rather than offset into it by a computed margin: the dot is 7u of background inside a 1px border, so its box is 9px and not 7u, and offsetting by "half of 7u" put it one pixel right of the open panel's. 9u is half of 18u and the translate does the rest, so the border width cannot enter into it. */ .panel.col .lab::before{ position:absolute; left:calc(9 * var(--u)); top:50%; transform:translate(-50%, -50%); margin:0; } .panel.col{ display:flex; align-items:center; gap:calc(6 * var(--u)); /* 1u: a folded strip is full-panel-width, so it is an easy target even at ~16px tall, and every pixel here is one the unfolded panels below want. --labpad, shared with .bank, is the other half of the LED alignment: the slot has to START where an open panel's label column does. */ padding:calc(1 * var(--u)) var(--labpad); } /* ---- the label column: the name, and the fold button under it --------- The fold grip was an ::after on the label for two revisions, and a pseudo-element cannot receive its own events -- every press on it was a press on the LABEL, which on five of these panels is the section switch. Hence "pressing the collapse icon turns ENV on and off", which no amount of touch-action or click-swallowing was ever going to fix, because the two were the same target. It is a real button now, a sibling of the label, and the two never share a press. The column takes the geometry the label used to own; SEQ and DRM both go through it, so their two grids still line up -- the one alignment here that must not drift. */ .labcol{ flex:0 0 calc(18 * var(--u)); align-self:stretch; min-width:0; display:flex; flex-direction:column; align-items:center; gap:calc(2 * var(--u)); } .labcol .lab{flex:1 1 auto; align-self:stretch} /* THE app's knurl, not a rib pattern of its own. The comment here used to say it was ribbed "because this app already uses a knurl to mean there is more here, on the transport and on SAVE" -- which named the right idea and then drew something else: 1px parallel lines, where the transport, SAVE and all four LFO source tiles wear a 45/-45 cross-hatch. The same three layers as .spot and .play now, so the app has one texture and it means one thing. It deletes a rule as well: a cross-hatch is unchanged by the rotation, so the folded grip no longer needs its own turned copy of the background. Shrinkable, and that is not cosmetic: at a fixed 22u the column's own content grew taller than a 54px bank row and pushed the fit check into folding extra panels. */ .fold{ flex:0 1 calc(22 * var(--u)); min-height:calc(12 * var(--u)); width:calc(14 * var(--u)); padding:0; margin-bottom:calc(1 * var(--u)); border:1px solid var(--edge); border-radius:calc(3 * var(--u)); background: repeating-linear-gradient(45deg, rgba(255,255,255,.055) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(4 * var(--u))), repeating-linear-gradient(-45deg, rgba(0,0,0,.16) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(4 * var(--u))), var(--panel-hi); cursor:pointer; touch-action:none; -webkit-appearance:none; appearance:none; } .fold:active{background-color:var(--edge); border-color:var(--accent2)} .fold:focus-visible{outline:2px solid var(--accent2); outline-offset:1px} /* folded: the column lies down, so the button turns with it -- and only its BOX does, because the knurl above is unchanged by that rotation */ .panel.col .labcol{ flex-direction:row; align-self:center; flex:0 0 auto; gap:calc(6 * var(--u)); } .panel.col .labcol .lab{flex:0 0 auto; align-self:center} .panel.col .fold{ flex:0 0 auto; margin:0; width:calc(22 * var(--u)); height:calc(14 * var(--u)); min-height:0; } .strip{display:flex; flex-direction:column; align-items:center; gap:calc(2 * var(--u))} /* the fader itself: same .knob element, so the drag and the readout are the knob's own. Only the .dial inside it is re-drawn. */ .knob.fader{ width:calc(42 * var(--u)); padding:0; gap:1px; } .knob.fader .dial{ /* 44u, down from 62: this row is folded by default now, so the expanded view is a deliberate visit and does not have to justify itself against six other panels. Still more travel than any knob has. */ width:calc(15 * var(--u)); height:calc(44 * var(--u)); border-radius:calc(7 * var(--u)); /* the travelled part lit from the bottom, which is what a fader shows */ background-image:linear-gradient(to top, var(--arc, var(--accent)) 0 calc(var(--v) * 100%), var(--edge) calc(var(--v) * 100%) 100%); box-shadow:inset 0 0 calc(4 * var(--u)) rgba(0,0,0,.55); } /* the dome and the pointer are a knob's anatomy, not a fader's */ .knob.fader .dial::before{content:none} .knob.fader .dial::after{ content:""; position:absolute; left:calc(-5 * var(--u)); right:calc(-5 * var(--u)); /* width:auto is load-bearing. The knob pointer this inherits from sets an explicit `width:2u` at `left:50%`, and an explicit width beats `right` -- so without this the fader thumb rendered as a 2px tab hanging off the left edge of the track instead of straddling it. */ width:auto; height:calc(14 * var(--u)); border-radius:calc(3 * var(--u)); /* --v is 0 at the bottom, and the thumb's own height has to come out of the travel or it would hang off both ends */ top:calc((1 - var(--v)) * (100% - 14 * var(--u))); transform:none; transform-origin:initial; background:linear-gradient(to bottom, var(--panel-hi), var(--off)); border:1px solid var(--edge); box-shadow:0 calc(1 * var(--u)) calc(3 * var(--u)) rgba(0,0,0,.5); } .knob.fader label{font-size:calc(9 * var(--u))} .knob.fader output{font-size:calc(11 * var(--u))} /* ---- the folded mixer ------------------------------------------------- Every other panel folds to a bare strip. This one keeps its four controls, because a master level you cannot reach without unfolding is not a mixer -- and it costs nothing to keep: paintCol drops the `fader` class and the ordinary .knob rules draw them, same widget, same --v, same drag. Only the mute buttons go; four knobs AND four mutes is not smaller than the faders they replaced. */ /* ---- modulation sources ------------------------------------------------ A spot is the same widget vocabulary as everything else here: a round face with an arc that fills from --v, so an LFO visibly turns and a tilt axis visibly leans. It is NOT a knob -- it has no drag-to-set -- which is why it carries its own class and no role="slider". */ /* The mixer can outgrow a short screen on its own, and did before any of this existed -- 47px of OSC, ENV and FLT at 720x400. It keeps the cap and scrolls inside itself rather than pushing them off the bottom: the same backstop .io documents for the dialogs, and the only panel that gets it. */ #bank-mix:not(.col){max-height:18vh; overflow-y:auto; overscroll-behavior:contain; touch-action:pan-y} /* And the LFO panel gets the same, for the same reason: opened at 320x568 its sources wrap to three rows and want 94px on a screen that can spare about fifty. It is a panel you open deliberately, on the smallest screen there is, so it scrolls inside itself rather than pushing four other panels off the bottom. */ #bank-lfo:not(.col){max-height:11vh; overflow-y:auto; overscroll-behavior:contain; touch-action:pan-y} .srcs{ flex:1 1 auto; min-width:0; display:flex; align-items:center; justify-content:space-around; gap:calc(5 * var(--u)); flex-wrap:wrap; } .srcgrp{display:flex; align-items:center; gap:calc(4 * var(--u))} /* The same frame and the same knurl the transport and SAVE wear. That texture is already this app's word for "there is more here than a press" -- it is what says the logo can be held and swiped -- so a spot that can be picked up and carried to a knob should be wearing it too. Nothing else on the panel has a border, which is the point: the four things you can drag look like the two things you can drag, and like nothing else. */ .spot{ display:flex; flex-direction:column; align-items:center; gap:1px; width:calc(38 * var(--u)); padding:calc(3 * var(--u)) 0 calc(2 * var(--u)); border:1px solid var(--edge); border-radius:7px; background: repeating-linear-gradient(45deg, rgba(255,255,255,.055) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(4 * var(--u))), repeating-linear-gradient(-45deg, rgba(0,0,0,.16) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(4 * var(--u))), var(--panel-hi); cursor:grab; touch-action:none; user-select:none; } .spot:active{cursor:grabbing; border-color:var(--sc)} .spot:focus-visible{outline:2px solid var(--accent2); outline-offset:1px} .spot .face{ --v:0.5; position:relative; width:calc(26 * var(--u)); height:calc(26 * var(--u)); border-radius:50%; background-image:conic-gradient(from 225deg, var(--sc) 0 calc(var(--v) * 270deg), var(--edge) calc(var(--v) * 270deg) 270deg, transparent 270deg 360deg); } .spot .face::before{ content:""; position:absolute; inset:calc(4 * var(--u)); border-radius:50%; background:radial-gradient(circle at 34% 28%, var(--panel-hi), var(--off) 74%); box-shadow:0 0 calc(7 * var(--u)) var(--scg), inset 0 0 calc(4 * var(--u)) rgba(0,0,0,.5); } .spot label{ font:600 8px/1 var(--mono); font-size:calc(8 * var(--u)); letter-spacing:.08em; color:var(--sc); white-space:nowrap; } /* a source with no sensor behind it says so instead of sitting at half */ .spot.dead .face{background-image:none; background:var(--off); border:1px solid var(--edge)} .spot.dead label{color:var(--faint)} /* the knob the finger is over during a drag */ .knob.drop .dial{outline:2px solid var(--ink); outline-offset:calc(2 * var(--u))} body.cording .knob{cursor:copy} .panel.col#bank-mix > .strips{display:flex} .panel.col#bank-mix .strips{gap:calc(8 * var(--u)); padding:0} .panel.col#bank-mix .mutebtn{display:none} /* Folded, the LFO panel keeps its four SPOTS and loses only the two LFOs' own shape and rate knobs -- those are settings, the spots are the thing you drag, and a drag source behind a fold is a drag source nobody finds. */ .panel.col#bank-lfo > .srcs{display:flex; flex-wrap:nowrap; gap:calc(4 * var(--u))} .panel.col#bank-lfo .srcs .knob{display:none} .panel.col#bank-lfo .spot{width:calc(28 * var(--u)); padding:calc(1 * var(--u)) 0} .panel.col#bank-lfo .spot .face{width:calc(18 * var(--u)); height:calc(18 * var(--u))} .panel.col#bank-lfo .spot label{font-size:calc(7 * var(--u)); letter-spacing:0} /* No numeric readout in the strip. The arc IS the level, which is how a mixer has always worked, and 12u of type per channel is a line this row cannot afford -- keeping it made the folded panel 48px, which is 30px more than the bare strip it replaced and came straight out of OSC, ENV and FLT in short landscape. The LABEL stays: four identical unnamed knobs would be worse than no mixer at all. */ .panel.col#bank-mix output{display:none} .panel.col#bank-mix .knob{width:calc(34 * var(--u)); padding:0} .panel.col#bank-mix .strip{gap:0} /* 24u of dial under an 8u label is 34px of strip including the panel's own padding, and 34 is what the row has: at 26u it was 42 against a 38px row and the mix row is the one a fixed-height grid squeezes first. The DRAG TARGET is the .knob, 34u wide by the full strip -- the dial is only the paint. */ .panel.col#bank-mix .dial{width:calc(24 * var(--u)); height:calc(24 * var(--u))} .panel.col#bank-mix label{font-size:calc(8 * var(--u))} /* Mute: the same emitter vocabulary as everything else that lights up here, in the CRIT colour because a muted channel is a channel you are not hearing and that is worth spotting across a room. */ .mutebtn{ min-width:calc(28 * var(--u)); min-height:calc(20 * var(--u)); border:1px solid var(--edge); border-radius:5px; background:var(--off); color:var(--faint); font:700 10px/1 var(--mono); font-size:calc(10 * var(--u)); letter-spacing:.1em; cursor:pointer; touch-action:none; } .mutebtn:active{background:var(--edge)} .mutebtn:focus-visible{outline:2px solid var(--accent2); outline-offset:-2px} .mutebtn[aria-pressed="true"]{ color:#1a0606; background:var(--crit); border-color:var(--crit); box-shadow:0 0 calc(6 * var(--u)) var(--crit-glow), inset 0 0 calc(8 * var(--u)) rgba(255,255,255,.3); } /* Takes exactly the space the controls leave. The container query -- not the min-width -- does the hiding, so the controls are never squeezed. The height MUST be pinned: .viz sets height:auto, a canvas then falls back to its intrinsic height, and measureCanvases writes that back -- a feedback loop that grew the bar to 237px. .viz.roll outranks .viz regardless of order. */ .viz.roll{ flex:1 1 0; min-width:calc(40 * var(--u)); margin-left:calc(2 * var(--u)); align-self:center; height:calc(48 * var(--u)); min-height:0; } /* Footer content box. 250, not the 350 this had while it lived in the bar: the ARP knobs and the RND/CLR pair need ~200px of a 280px footer at 320px, which leaves the roll ~80 -- narrow, but a 16-step view at 5px a step is worth having, and hiding it at the one width the app is built for would mean the visualiser had nowhere to live at all. */ @container (max-width: 250px){ .viz.roll{display:none} } .arpk{display:flex; flex:0 0 auto; align-items:center; gap:calc(2 * var(--u))} /* ---- visualisers ---- */ .viz{ flex:1 1 0; min-width:calc(44 * var(--u)); display:block; /* PINNED, for the reason spelled out over .viz.roll: this used to be height:auto + align-self:stretch, which worked only because the grid row was 1fr and handed it a definite height from outside. With the row content- sized there is nothing to stretch to, and a canvas with height:auto reports its attribute height -- the feedback loop measureCanvases closes. */ align-self:center; height:var(--vizh); min-height:0; /* --viz-*, not --edge / --off: a graph does not belong to the panel it sits in, so neither its glass nor its bezel follows the instrument tint. */ border:1px solid var(--viz-edge); border-radius:5px; background:var(--viz-lo); } .knob.dim{opacity:.38} /* .knob sets display:flex, which outranks the UA [hidden] rule -- so the engine that is not selected needs this to disappear. Using the attribute rather than a class takes it out of the tab order and the a11y tree too. */ .knob[hidden]{display:none} .knob{ --v:0; display:flex; flex-direction:column; align-items:center; gap:1px; width:calc(42 * var(--u)); cursor:ns-resize; touch-action:none; user-select:none; padding:calc(2 * var(--u)) 0; border-radius:6px; } /* It lived in a four-column knob grid and spanned two of them, at 30u a button. In the SEQ footer it is a plain flex item competing with RND/CLR, three ARP knobs and the piano roll for one row, so it takes the narrow sizing -- 84u of stepper in a 280px footer is what pushed that row onto a second line and made the whole panel overflow. */ .notes .oct{flex:0 0 auto} .notes .oct button{width:calc(17 * var(--u)); min-height:calc(34 * var(--u))} .notes .oct b{min-width:calc(18 * var(--u))} .knob:focus-visible{outline:2px solid var(--accent2); outline-offset:1px} /* ---- parameter-lock LED ------------------------------------------------ Every lockable knob carries an LED at all times so the feature is discoverable; it simply sits dark until the parameter is armed. Knobs that cannot be locked get none, rather than an LED that does nothing. */ .knob.pl .dial{--led:var(--led-off); --led2:var(--led-off); --leds:calc(7 * var(--u))} /* Armed: a hot core bleeding into saturated blue, then a halo layer, then a real outward bloom spilling past the dome -- the box-shadow is what makes it read as an emitter rather than a painted dot. */ /* locks recorded, lane switched off: the ring says "there is automation here and it is not playing", in the same blue the armed LED uses */ .knob.lkq .dial{ box-shadow:inset 0 0 0 1px var(--led-off), 0 0 calc(3 * var(--u)) rgba(63,92,255,.35); } .knob.arm .dial{ --led:#c3d3ff; --led2:#3f5cff; --leds:calc(14 * var(--u)); --ledh:#2a45e8; --ledhs:calc(34 * var(--u)); --ledsh:0 0 calc(8 * var(--u)) #3f5cff, 0 0 calc(18 * var(--u)) #2a45e8, 0 0 calc(34 * var(--u)) var(--accent2-glow); } .dial{ position:relative; width:calc(40 * var(--u)); height:calc(40 * var(--u)); border-radius:50%; background-image:conic-gradient(from 225deg, var(--arc, var(--accent)) 0 calc(var(--v) * 270deg), var(--edge) calc(var(--v) * 270deg) 270deg, transparent 270deg 360deg); } .dial::before{ content:""; position:absolute; inset:calc(5 * var(--u)); border-radius:50%; /* light from the upper left: specular blob, then a fade to an underside shade. Layered over --panel-hi rather than a literal colour. */ /* Two extra layers ahead of the dome, transparent until the knob is armed for parameter lock: a centred LED and a soft halo. Declared here rather than in an .arm rule so the three dome layers are never duplicated. */ background: radial-gradient(closest-side, var(--led, transparent) 0 10%, var(--led2, transparent) 46%, transparent 90%) 50% 50% / var(--leds, 0px) var(--leds, 0px) no-repeat, radial-gradient(closest-side, var(--ledh, transparent), transparent 100%) 50% 50% / var(--ledhs, 0px) var(--ledhs, 0px) no-repeat, radial-gradient(78% 78% at 32% 27%, var(--dome-hi), transparent 62%), linear-gradient(176deg, transparent 40%, var(--dome-lo) 100%), var(--panel-hi); border:1px solid var(--edge); box-shadow: inset 0 calc(1 * var(--u)) calc(1.5 * var(--u)) var(--dome-rim), inset 0 calc(-1.5 * var(--u)) calc(2.5 * var(--u)) var(--dome-lo), 0 calc(1 * var(--u)) calc(2 * var(--u)) var(--dome-drop), var(--ledsh, 0 0 0 rgba(0,0,0,0)); /* LED bloom, empty unless armed */ } /* The pointer, in PERCENTAGES of the dial rather than in units of a 40u one. 6u/10u/14u were exactly 15%/25%/140%-of-its-own-height on a 40u dial, so this is identical where it always was -- but the folded mixer draws a 24u dial, and the hard-coded origin put the pivot BELOW its centre, which swung the needle out past the rim at both ends of the travel. Percentages scale to any dial the app ever draws. */ .dial::after{ content:""; position:absolute; left:calc(50% - 1 * var(--u)); top:15%; width:calc(2 * var(--u)); height:25%; background:var(--ink); border-radius:calc(1 * var(--u)); box-shadow:0 calc(0.5 * var(--u)) calc(1 * var(--u)) rgba(0,0,0,.55); transform-origin:calc(1 * var(--u)) 140%; transform:rotate(calc(-135deg + var(--v) * 270deg)); } .knob label{font:600 9px/1 var(--mono); font-size:calc(9 * var(--u)); letter-spacing:.09em; color:var(--dim)} .knob output{ font:700 12px/1 var(--mono); font-size:calc(12 * var(--u)); letter-spacing:0; color:var(--arc, var(--accent)); text-shadow:0 0 calc(8 * var(--u)) var(--glow); } .knob.dim output{color:var(--faint); text-shadow:none} /* ---- footer ---- */ .notes select{ min-height:calc(34 * var(--u)); padding:calc(2 * var(--u)) calc(4 * var(--u)); border:1px solid var(--edge); border-radius:5px; background:var(--off); font:600 10px/1 var(--mono); font-size:calc(10 * var(--u)); min-width:0; } .notes .act{ /* 4u, not 9u: full-size knobs leave little room on this line, so the buttons beside them give up padding rather than the knobs giving up size. */ min-height:calc(34 * var(--u)); padding:0 calc(4 * var(--u)); border:1px solid var(--edge); border-radius:5px; background:var(--off); color:var(--dim); font:600 10px/1 var(--mono); font-size:calc(10 * var(--u)); letter-spacing:.1em; cursor:pointer; } .notes .act:active{background:var(--accent); color:#fff; border-color:var(--accent)} /* ---- the note controls, two by two -------------------------------------- Four short controls in a row cost ~157px of a ~308px footer, which is what pushed the ARP knobs onto a second line. Stacked 2x2 they cost ~68px, and the whole footer still ends up 62px shorter than the wrapped version was. The rows keep their own 34u height rather than being stretched to half the knobs': halving the block would have shrunk every one of these four touch targets, and the 4u the pair overhangs a knob by is cheap next to that. Specificity is deliberate: `.n2 select` would tie with `.notes select` at (0,1,1) and lose to the responsive tiers further down the file purely on source order -- which has caught this file three times already. */ .notes .n2{ display:grid; grid-template-columns:auto auto; grid-template-rows:auto auto; gap:calc(3 * var(--u)); flex:0 0 auto; } /* The native dropdown arrow is ~27px of chrome on a 24px word -- more than the text it decorates. Dropped for a 5px caret drawn in the background, which is most of what makes the block fit at 320px without shrinking any knob. */ .notes .n2 select{ -webkit-appearance:none; appearance:none; text-align:center; padding:0 calc(9 * var(--u)) 0 calc(3 * var(--u)); background-image:linear-gradient(45deg, transparent 50%, var(--faint) 50%), linear-gradient(135deg, var(--faint) 50%, transparent 50%); background-size:calc(3 * var(--u)) calc(3 * var(--u)), calc(3 * var(--u)) calc(3 * var(--u)); background-position:right calc(4 * var(--u)) center, right calc(1 * var(--u)) center; background-repeat:no-repeat; } /* The inline hint is back, and this time it does not compete for space. Its first life was `flex:1 1 0` in this row, which halved the piano roll the moment the roll moved in here, and display:none below 350px -- so it went to a popup instead. Now it takes a whole line of a row that already wraps. */ .hint{ /* It used to take a whole line of its own -- `flex:0 0 100%` and `order:99` -- and RESERVE that line whether or not there was a message, so the top panel carried a permanently empty 19px band. Both halves of that are now unnecessary, because there is a large hole to put it in: the transport, the save button and the tempo end at 251px of a 390px line at 412x915, and the 150px to their right was dead. `flex:1 1 0` is the load-bearing part. A zero BASIS means an empty hint has no hypothetical size, so it can never be the item that forces the key and scene groups onto a second line -- and a long message grows the text downward INSIDE its box instead of breaking to a flex line of its own, which is why the reservation existed. Line one is already 40-50u tall for the transport, which is three lines of this type, so almost every message in the app now costs exactly nothing in height. `order` puts it after the tempo and before the key group without moving it in the markup, where it stays last so a screen reader reaches the live region after the controls. */ flex:1 1 0; min-width:0; order:1; align-self:center; font:500 9px/1.3 var(--mono); font-size:calc(9.5 * var(--u)); color:var(--faint); text-align:left; border-left:1px solid var(--edge); padding-left:calc(5 * var(--u)); } /* no rule and no padding when there is nothing to say */ .hint:empty{border-left:0; padding-left:0} /* a message about something going wrong should not read like a receipt */ .hint.bad{color:var(--warn)} /* The empty head row cost 47px above the first feature. Lifting the close button out of the flow puts the list at the top of the dialog; the right-hand column is short enough at every width that nothing runs under the x -- tested as a rectangle intersection, not by eye. */ #help .iohead{ position:absolute; top:calc(10 * var(--u)); right:calc(10 * var(--u)); margin:0; z-index:1; } /* display:contents on the rows puts every b/span straight into the one grid, so the two columns align across all of them and nothing can wrap out of line. */ .feat{ display:grid; grid-template-columns:auto 1fr; gap:calc(3 * var(--u)) calc(7 * var(--u)); margin:0; padding:0; list-style:none; font:600 10px/1.2 var(--mono); font-size:calc(10 * var(--u)); } .feat li{display:contents} .feat b{color:var(--accent); white-space:nowrap} .feat span{color:var(--dim)} /* the one tappable line in an otherwise read-only list, so it has to look it */ .feat span.tapme{color:var(--accent2); cursor:pointer; text-decoration:underline dotted} /* ---- messages ---------------------------------------------------------- The floating toast is gone. It sat over the transport and the tempo for five seconds after every message and had to be tapped away, and a box that covers the controls to tell you about them is the wrong shape of thing. This is the same channel in the layout instead: .bar already wraps, so `flex:0 0 100%` gives the line its own row under the transport, always present, empty when there is nothing to say. Nothing can be covered and nothing needs dismissing. */ /* ---- song data dialog --------------------------------------------------- The app's first modal. <dialog> brings Escape-to-close and a focus trap for free and is older than the container queries the layout already relies on. display lives on [open] because a bare display:flex would defeat the UA's display:none and leave the dialog permanently on screen. */ .io{ width:min(94vw, 720px); box-sizing:border-box; /* no absolute height cap: --u scales to 2.4x on a large display, so the dialog's own chrome grows past what a fixed 900px could hold */ max-height:88vh; padding:calc(10 * var(--u)); padding-bottom:max(calc(10 * var(--u)), env(safe-area-inset-bottom)); border:1px solid var(--edge); border-radius:10px; background:var(--panel); color:var(--ink); box-shadow:0 calc(6 * var(--u)) calc(26 * var(--u)) rgba(0,0,0,.55); } .io[open]{display:flex; flex-direction:column; gap:calc(6 * var(--u))} /* Backstop: if a screen I have not measured still cannot fit the content, it scrolls. Previously the overflow simply painted the code over the buttons. */ .io{overflow-y:auto; overscroll-behavior:contain; touch-action:pan-y} .io::backdrop{background:rgba(10,14,18,.74)} .iohead{display:flex; align-items:center; gap:calc(8 * var(--u)); flex:0 0 auto} .iohead b{ font:700 12px/1 var(--mono); font-size:calc(12 * var(--u)); letter-spacing:.13em; color:var(--ink); } .iohint{ margin:0; flex:0 0 auto; color:var(--faint); font:500 10px/1.45 var(--mono); font-size:calc(10 * var(--u)); } .iohint b{color:var(--warn)} .io textarea{ flex:1 1 auto; width:100%; box-sizing:border-box; /* a preferred height that shrinks under pressure, down to a floor of about two lines, so the QR below always gets the room it needs */ flex:0 1 auto; height:calc(120 * var(--u)); min-height:calc(36 * var(--u)); resize:none; overflow:auto; touch-action:pan-y; /* pre-wrap + anywhere so 76-column base64 soft-wraps on a phone instead of scrolling off the side; JSON lines are short enough to be unaffected, and soft wrapping inserts no characters so the content is untouched. */ white-space:pre-wrap; overflow-wrap:anywhere; border:1px solid var(--edge); border-radius:6px; background:var(--off); color:var(--dim); font:500 11px/1.45 var(--mono); font-size:calc(11 * var(--u)); padding:calc(7 * var(--u)); -webkit-appearance:none; appearance:none; } .io textarea:focus{outline:none; border-color:var(--accent)} .io textarea:focus-visible{outline:1px solid var(--accent); outline-offset:-2px} .iobar{display:flex; gap:calc(6 * var(--u)); flex:0 0 auto; flex-wrap:wrap} /* ---- named songs ---------------------------------------------------- */ .savebar{display:flex; gap:calc(6 * var(--u)); flex:0 0 auto; align-items:stretch} /* Same trap the .knob rule above documents: display:flex here outranks the UA's [hidden]{display:none}, so the sync-server field was never actually hidden by setting .hidden -- it sat in the help dialog on every screen, 34px of a dialog that is already over its height at 720x400. Found by measuring the dialog, not by looking at it. */ .savebar[hidden]{display:none} .savebar input{ flex:1 1 auto; min-width:0; min-height:calc(30 * var(--u)); padding:0 calc(6 * var(--u)); border:1px solid var(--edge); border-radius:5px; background:var(--off); color:var(--ink); font:600 11px/1 var(--mono); font-size:calc(11 * var(--u)); } .savebar input:focus-visible{outline:none; border-color:var(--accent)} .slots{ flex:0 0 auto; display:flex; flex-direction:column; gap:calc(3 * var(--u)); /* No scroller of its own. The dialog already scrolls, and a nested scroll region inside a scrolling dialog is a trap on touch -- it also clipped a row in half at exactly four demos, which is the state every new user opens this in. A long list makes a long dialog, which is honest. */ } .slot{ display:flex; align-items:center; gap:calc(5 * var(--u)); padding:calc(2 * var(--u)) calc(4 * var(--u)); border:1px solid var(--edge); border-radius:5px; background:var(--off); } .slot .nm{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--ink); font:600 11px/1.3 var(--mono); font-size:calc(11 * var(--u)); } /* a demo is marked rather than merely un-deletable, so it is obvious which rows are yours and which came with the app */ .slot.demo .nm{color:var(--accent2)} .slot.demo .nm::before{content:"\266a\00a0\00a0"; color:var(--accent2)} .slot .act{min-height:calc(24 * var(--u)); padding:0 calc(5 * var(--u))} .slot .drop{ flex:0 0 auto; min-width:calc(24 * var(--u)); min-height:calc(24 * var(--u)); border:1px solid var(--edge); border-radius:5px; background:none; color:var(--faint); font:400 14px/1 var(--ui); font-size:calc(14 * var(--u)); cursor:pointer; touch-action:pan-y; } .slot .drop:active{background:var(--crit); border-color:var(--crit); color:#fff} .slotnone{ color:var(--faint); font:500 10px/1.4 var(--mono); font-size:calc(10 * var(--u)); padding:calc(2 * var(--u)) 0; } /* The QR keeps its size and the text field gives way -- the field is a scrolling box, the code is not useful shrunk. Sizing them the other way round is what let the code overflow its row and paint over the buttons. */ .ioqr{ flex:0 0 auto; width:100%; align-self:stretch; display:flex; align-items:center; justify-content:center; text-align:center; border:1px solid var(--edge); border-radius:6px; background:var(--off); padding:calc(4 * var(--u)); box-sizing:border-box; } /* Black on white on purpose: a themed QR on a dark panel does not scan. This is a functional element, not a decorative one. pixelated keeps the modules crisp when the canvas -- which is exactly module-sized -- is scaled up by CSS. */ .ioqr canvas{ /* The box matches the text field's width. The code inside is square, so it is capped by the available HEIGHT instead and letterboxed against the panel -- otherwise a square as wide as the dialog would push a short landscape screen off the bottom. */ width:100%; height:auto; display:block; cursor:zoom-in; max-height:40vh; object-fit:contain; background:none; image-rendering:pixelated; image-rendering:crisp-edges; } .ioqr .ioqrn{display:none; color:var(--faint); font:500 9px/1.4 var(--mono); font-size:calc(9 * var(--u)); padding:calc(10 * var(--u)) calc(4 * var(--u))} .ioqr.off canvas{display:none} .ioqr.off .ioqrn{display:block} /* enlarged: the small one is the affordance, this is the one you scan. With the field hidden there is far more height, so the cap is raised rather than removed -- unbounded would overflow the dialog. */ .io.qrbig textarea{display:none} .io.qrbig .ioqr canvas{max-height:62vh; cursor:zoom-out} .io .act{ min-height:calc(34 * var(--u)); padding:0 calc(12 * var(--u)); border:1px solid var(--edge); border-radius:5px; background:var(--off); color:var(--dim); font:600 10px/1 var(--mono); font-size:calc(10 * var(--u)); letter-spacing:.1em; cursor:pointer; } /* TRUST is an <a>, for the reason the markup gives. An anchor is not a button, so it needs the three things a button gets for free -- centring, no underline, and a dead state when there is nothing to link to. */ .io a.act{ display:flex; align-items:center; justify-content:center; text-decoration:none; } .io a.act:not([href]){opacity:.45; pointer-events:none} .io .act:active{background:var(--accent); color:#fff; border-color:var(--accent)} .io .act.danger{color:var(--warn); border-color:var(--warn)} /* subtle, square, and still a real touch target */ .iox{ margin-left:auto; flex:0 0 auto; width:calc(30 * var(--u)); height:calc(30 * var(--u)); display:flex; align-items:center; justify-content:center; border:0; background:none; padding:0; cursor:pointer; border-radius:5px; color:var(--faint); font:400 20px/1 var(--mono); font-size:calc(20 * var(--u)); } .iox:hover, .iox:active{color:var(--ink); background:var(--off)} .iox:focus-visible{outline:2px solid var(--accent2); outline-offset:1px} /* camera preview takes the QR slot, so nothing in the row moves */ .ioqr video{ display:none; width:100%; aspect-ratio:1/1; object-fit:cover; border-radius:4px; background:#000; } .io.scanning .ioqr video{display:block} .io.scanning .ioqr canvas, .io.scanning .ioqr .ioqrn{display:none} /* The sync address has its own camera slot, and unlike the song dialog's it has no QR canvas to borrow -- so the whole box exists only while scanning. An empty bordered rectangle sitting in the help list the rest of the time would be a control that does nothing. */ #wsqr{display:none} .io.scanning #wsqr{display:flex} /* Four controls now, and at 320px they do not fit beside the field: it keeps 140px with three and would be down to about sixty with four. So the row is allowed to wrap -- but only WHEN it must. `flex:1 1 100%` wrapped it at every width, which cost 36px on screens that had room for one line; a 150px basis wraps at 320 and stays on one line from 412 up. */ .savebar{flex-wrap:wrap} .savebar input{flex:1 1 150px; min-width:calc(120 * var(--u))} /* dimmed but still clickable: a disabled button cannot say why it is disabled */ .io .act.off{opacity:.45} .iomsg{ flex:0 0 auto; min-height:calc(14 * var(--u)); color:var(--accent); font:600 10px/1.35 var(--mono); font-size:calc(10 * var(--u)); } .iomsg.bad{color:var(--crit)} /* ---- landscape: two columns ---- */ @media (orientation:landscape) and (max-height:620px){ .app{ /* Short landscape affords less per bank -- its content measures 63px -- but MORE for the sequencer, not less. The modifier rows are folded away here, and what is left still measures ~132px (steps 44, keys 28, footer 48, plus gaps and padding); a 100u floor squeezed SEQ to 100 and clipped its own footer, which .app's overflow check cannot see because the GRID fitted perfectly. Overflowing the grid instead is the right failure: that is what fitCol and fitTo measure and fold against. */ --bankh:calc(63 * var(--u)); --seqmin:calc(140 * var(--u)); grid-template-columns:minmax(0,1fr) minmax(0,1fr); /* MIX and LFO SIDE BY SIDE here, not stacked. Both are folded strips on a screen this short, and stacking them cost a whole 34px row that OSC, ENV and FLT then had to find between them -- which they could not. Short landscape is the tier with horizontal room to spare and none at all vertically, so this is the one place two strips share a line. */ /* bar | mix | SEQ | osc+env | lfo+drm | flt | dly+rev LFO moves below ENV here too, and it pairs with DRM rather than taking a row of its own: this tier's whole point is that it has width to spare and no height at all, so the row COUNT must not change. MIX takes the full width LFO gives up, which costs nothing -- all three of these start folded, so they are strips either way. */ grid-template-rows:auto auto minmax(0,1fr) auto auto auto auto; grid-template-areas: "bar bar" "mix mix" "seq seq" "osc env" "lfo drm" "flt flt" "dly rev"; } /* short landscape: give the row back. One number, and it takes .save with it -- which it did not before, so this tier drew a 40u transport beside a 50u save button. */ .app{--barh:calc(40 * var(--u))} .play .cmk{height:calc(30 * var(--u))} /* no room for a square as tall as 44vh here; the code letterboxes instead */ .ioqr canvas{max-height:30vh} /* The vertical labels set the floor for a bank's height: three letters plus their tracking and the 3u top padding needed 39px of a 38px box, which is the same shape of bug FILT had. */ .lab{padding-top:0; letter-spacing:.06em} /* Nor can the folded mixer. This tier is where every row is fought over -- it already shrinks the transport, the drum lanes, the step cells and the label padding -- and four knobs at 40u put OSC, ENV and FLT two pixels under their own content. Measured, then trimmed until nothing clipped. */ /* and the expanded one gets less again here: three bank rows, a bar, a sequencer and a drum strip already share a screen under 620px tall, so 18vh of it still pushed OSC, ENV and FLT 32px past their content. Measured. */ #bank-mix:not(.col){max-height:15vh} .panel.col#bank-mix .dial{width:calc(18 * var(--u)); height:calc(18 * var(--u))} .panel.col#bank-mix label{font-size:calc(8 * var(--u))} .hint{min-height:calc(9 * var(--u)); font-size:calc(8.5 * var(--u))} /* The drum row cannot have its full height here: three bank rows, a bar, a sequencer and a preset strip already share a screen under 620px tall. */ .drms{grid-template-rows:repeat(4, clamp(8px, 1.7vh, calc(12 * var(--u))))} .drmbody{gap:calc(2 * var(--u))} .drmbar button{height:calc(11 * var(--u)); min-height:11px; font-size:calc(8 * var(--u))} /* A 44u step cell is what .seq's width asks for, and it is out of proportion on a screen this short -- especially now the drum row wants its own strip. Capping it here is the cheapest height in the whole layout. */ .step{height:clamp(calc(22 * var(--u)), 6cqi, calc(30 * var(--u)))} .step{font-size:9px} .keys{height:calc(18 * var(--u))} /* the sequencer knobs cannot keep their portrait size here: this tier has five panels in three rows and every pixel is already spoken for */ :root{--seqpad:calc(5 * var(--u)); --drmpadv:calc(2 * var(--u))} .seqrow{padding:calc(4 * var(--u)) var(--seqpad)} /* two columns halves the bank width, so the knob grid must shrink too -- otherwise a fixed 174px grid squeezes the graph to its min-width. */ .bank{padding:1px var(--seqpad)} .knobs{grid-auto-columns:36px} .knob{width:36px} /* 24, not 30: three bank rows now share this screen with a drum row, and the dial is the only part of a knob that can give without hitting the 9px readability floor on the label and the value. */ .dial{width:20px; height:20px} .dial::after{top:4px; height:7px; transform-origin:1px 8px} /* the knob's own padding and gaps are the last non-text height here */ .knob{padding:0; gap:0} .knob label{font-size:9px} .knob output{font-size:11px} .oct button{width:22px} .oct b{min-width:24px; font-size:10px} } @media (orientation:landscape) and (max-height:360px){ /* five panels in three rows here: every pixel of vertical rhythm is spent */ .app{gap:3px} .keys{height:calc(14 * var(--u))} :root{--seqpad:calc(3 * var(--u)); --seqgap:calc(4 * var(--u))} .seqrow{padding:calc(3 * var(--u)) var(--seqpad)} .hold{height:calc(30 * var(--u))} .hold{width:calc(26 * var(--u))} .hold .lk{font-size:calc(10 * var(--u))} .hold .num{font-size:9px} :root{--steppad:4px; --stepgap:3px; --drmpadv:1px} .bank{padding:1px 5px} .bar{padding:3px 6px} .seqfoot{gap:4px} .step{font-size:9px; height:calc(22 * var(--u))} .drms{grid-template-rows:repeat(4, 8px)} .drmbar button{height:10px; min-height:10px} .drmbody{gap:1px} .viz{min-width:40px; min-height:26px} /* bank rows are only ~42px tall here, so the dial pays for the 9px floor */ .knobs{grid-auto-columns:34px} .knob{width:34px; padding:0; gap:0} .dial{width:17px; height:17px} .dial::after{top:3px; height:5px; transform-origin:1px 6px} .knob label{font-size:9px} .knob output{font-size:10px} .oct button{width:20px; min-height:24px} .oct b{min-width:24px; font-size:10px} /* the last two pixels this tier needed for the drum row; the transport is still a 30px target, which is the same as the SCENES lock beside it */ .app{--barh:calc(30 * var(--u))} .play .cmk{height:calc(23 * var(--u))} .notes select,.notes .act{min-height:20px} } /* Phone widths: the sequencer row cannot hold full-size knobs AND the note controls on one line. Its own threshold, well above 400, so a 412px screen does not wrap where a 360px one does not -- which is what happened when this lived in the tier below. */ @media (max-width:520px){ .notes{gap:calc(4 * var(--u))} .hold{height:calc(34 * var(--u))} } /* Below this the two halves of the DLY/REV row, and the note controls, no longer fit at full size. The dial pointer is geometry, not decoration: for a dial of D units the pointer sits at 0.15D with its origin 0.35D below, so a 26u dial needs its own numbers rather than inheriting the 40u ones. */ @media (max-width:400px){ /* The top panel wraps to two lines from here down -- it gained the instrument selector, key, scale and the scene chain -- so the bank rows have to give the 6px a knob's own padding and gaps cost. And the mixer's own row, even folded to a strip, costs 18px plus a 5px gap on top of that -- so the dial gives here too now, the same 28px the tier below already uses. */ .knob{padding:0; gap:0} .dial{width:28px; height:28px} .dial::after{top:4px; height:6px; transform-origin:1px 7px} #bank-dly .knobs, #bank-rev .knobs{ grid-template-columns:repeat(3, calc(32 * var(--u))); gap:calc(2 * var(--u)); } #bank-dly .knob, #bank-rev .knob{width:calc(32 * var(--u))} #bank-dly .dial, #bank-rev .dial{width:calc(26 * var(--u)); height:calc(26 * var(--u))} #bank-dly .dial::before, #bank-rev .dial::before{inset:calc(3 * var(--u))} #bank-dly .dial::after, #bank-rev .dial::after{ top:calc(4 * var(--u)); height:calc(6 * var(--u)); transform-origin:calc(1 * var(--u)) calc(9 * var(--u)); } #bank-dly .knob output, #bank-rev .knob output{font-size:calc(10 * var(--u))} /* the note row: two selects, two buttons and the ARP pair on one line */ .notes{gap:calc(3 * var(--u))} .notes .act{padding:0 calc(3 * var(--u))} .notes select{padding:calc(2 * var(--u)) calc(2 * var(--u))} .hold{width:calc(26 * var(--u))} } @media (max-width:349px){ /* The hint keeps its line at every width now: it is the only channel the permission failures and the drum nudge readout have. */ /* The top panel gained the instrument selector, key, scale and the scene chain. At this width it held 294px of 300 BEFORE any of that, so every item in it gives a little rather than one of them giving everything. The transport shrinking is the only change that buys both axes: it sets the bar's own height as well as its width. */ .bar{gap:calc(4 * var(--u))} /* The footer now carries the octave stepper as well as RND/CLR, the ARP trio and the piano roll -- about seven pixels more than a 280px row has. It comes out of padding, not out of stacking RND over CLR: stacked, that pair is 71px tall against the ARP knobs' 51, so it would trade seven pixels of width for nineteen of height, and height is the axis that is short. */ .notes .act{padding:0 calc(2 * var(--u))} .notes .oct button{width:calc(16 * var(--u))} .notes .oct b{min-width:calc(16 * var(--u))} /* And the roll's own floor, which was one pixel from forcing the wrap it was meant to survive: at 40u the line needed 279 of 280 and broke anyway on a sub-pixel. 30u leaves real slack and still draws ~46px of song. */ .viz.roll{min-width:calc(30 * var(--u))} /* The captions in the top panel cost 11px of height at every width, which is what a caption is worth and is not negotiable. This is where it comes from: the SEQ panel's own vertical padding and the gaps between its three rows -- the same 4u the landscape tier already takes -- plus two pixels off the keyboard, still the least-pressed element in the panel. */ .seqrow{padding:calc(4 * var(--u)) var(--seqpad)} .seqbody{gap:calc(3 * var(--u))} .keys{height:calc(19 * var(--u))} /* The bar's wrapped line has to be paid for. 28, not 32: the dial is the only part of a knob that can give without crossing the 9px readability floor on its label and its value -- the same trade this file made when the drum row arrived. And the keyboard is still the least-pressed element in the SEQ panel, so it gives the last five pixels. */ .dial{width:28px; height:28px} .dial::after{top:4px; height:6px; transform-origin:1px 7px} .keys{height:calc(23 * var(--u))} /* the knob's own padding and gaps, which this tier had never claimed: 6px a row, which is three of the four rows' whole deficit */ .knob{padding:0; gap:0} .app{--barh:calc(40 * var(--u))} .play .cmk{height:calc(30 * var(--u))} .save svg{width:calc(21 * var(--u)); height:calc(21 * var(--u))} .nudge{width:calc(21 * var(--u)); font-size:calc(15 * var(--u))} #bpm{min-width:calc(30 * var(--u))} #bpm b{font-size:calc(13 * var(--u))} .songctl{gap:calc(4 * var(--u))} .notes select{min-width:0; padding-left:calc(1 * var(--u)); padding-right:calc(1 * var(--u))} /* the row carries two selects, two buttons and the ARP pair at this width. the sequencer knobs now inherit the generic .knob rules, so they shrink with every other knob here rather than needing their own sizes. */ .notes{gap:calc(2 * var(--u))} /* 9px is the readability floor; the step font already takes an exception at this width and the scale names are the widest thing in the row */ .notes select{font-size:calc(9 * var(--u))} /* 4x38 + 3x8 needs 12px more than this width has; 4px gap fits exactly */ .knobs{grid-auto-columns:38px; gap:calc(4 * var(--u))} /* the feature list is two columns of mono text; 10u wraps the longer ones into the 280px this width leaves inside the dialog */ .feat{font-size:calc(9 * var(--u)); gap:calc(3 * var(--u)) calc(5 * var(--u))} /* the footer clears one line here by ~5px; these give it ~10 */ :root{--seqpad:calc(3 * var(--u))} .notes .n2{gap:calc(2 * var(--u))} .knob{width:38px} .knob label{font-size:9px} .knob output{font-size:10px} /* 16 square cells at this width give a 14.6px inner cell; "G#2" needs 16.2px at 9px, so this is the one place the floor cannot be met. */ .step{font-size:8px} .dial{width:32px; height:32px} .dial::after{top:5px; height:9px; transform-origin:1px 12px} .oct button{width:24px} .oct b{min-width:26px} } </style> </head> <body> <div class="app"> <header class="bar panel"> <!-- Revision, not a build: it counts prompts. Styled by the plain .lab rule so it reads as one of the panel labels rather than a new kind of thing. --> <span class="lab" id="build" title="Revision: bumped once per prompt">111</span> <!-- The transport is the Checkmk mark, drawn as geometry rather than an image so it inherits the theme, glows, and can turn. A regular pointy-top hexagon: circumradius 30, so the flanks sit at 30*cos(30) = 25.98 from centre, and the arrow is painted over it in the panel colour. The viewBox is 60 wide because that is exactly the circle the corners sweep when the group rotates -- rotating the <svg> element instead would swing the mark out over the tempo control. --> <button class="play" id="play" aria-pressed="false" aria-label="Play" title="Tap: play/stop · Hold: song data · Swipe up/down: fullscreen"> <svg class="cmk" viewBox="-4 0 60 60" aria-hidden="true"> <!-- Turned a quarter so the arrow points right: the mark IS a play button on its side. The spin adds on top of that 90. --> <g class="cmk-spin"> <path class="cmk-hex" d="M26 0 51.96 15 51.96 45 26 60 .04 45 .04 15Z"></path> <path class="cmk-mark" d="M8.97 21.84V31.81L21.18 23.91V44.46L25.99 47.24L30.8 44.46V23.92L43.01 31.82V21.86L25.99 11.18Z"></path> </g> </svg> </button> <!-- Drawn, not an image, for the same reason the logo is: it inherits the theme and the glow. An arrow into a tray -- the one save glyph that is not a floppy disk nobody under 30 has held. --> <button class="save" id="save" disabled="" aria-label="Save take" title="No take yet"> <svg viewBox="0 0 24 24" aria-hidden="true"> <path class="sv-a" d="M12 3V14M12 14 7.5 9.5M12 14 16.5 9.5"></path> <path class="sv-t" d="M4 16.5V19A1.5 1.5 0 0 0 5.5 20.5H18.5A1.5 1.5 0 0 0 20 19V16.5"></path> </svg> </button> <div class="tempo"> <button class="nudge" id="bpmdn" aria-label="Tempo down" title="Tempo down: slower, one BPM a tap, hold to run">&minus;</button> <div id="bpm" role="slider" tabindex="0" aria-label="Tempo" aria-valuemin="40" aria-valuemax="220" aria-valuenow="120" title="Tempo: how fast the sequencer runs, in beats per minute"><b>120</b><small>BPM</small></div> <button class="nudge" id="bpmup" aria-label="Tempo up" title="Tempo up: faster, one BPM a tap, hold to run">+</button> </div> <!-- Key/scale and the scene chain belong to the SONG, not to either instrument, which is why they are up here rather than in the SEQ panel. Each pair is one captioned group: the caption is what says the two controls next to each other are one idea, and it costs 9u of type above a row that had to grow anyway to be worth pressing. --> <div class="songctl"> <div class="grp"> <!-- No caption. There WAS one reading "KEY", directly above two dials whose own labels read "KEY" and "SCALE" -- the word twice in eleven pixels of height, and the caption was the half that named only one of the two controls under it. The dials' labels are the ones that survive, because they are per-control. Empty, like the selects it replaces: buildDials() fills it, so the note names and the scale list have exactly one definition each. --> <div class="barsel" id="barsel"></div> </div> <div class="grp"> <span class="cap">SCENE</span> <div class="scnbox"> <!-- the chain length is a stepper rather than a knob: a knob is 42u wide and this bar does not have 42u --> <div class="oct scnc"> <button id="scndn" aria-label="Fewer scenes" title="Scenes: how many 16-step patterns play in the chain">&minus;</button> <b id="scnv">1</b> <button id="scnup" aria-label="More scenes" title="Scenes: how many 16-step patterns play in the chain">+</button> </div> <div class="hold" id="hold"> <button class="lk" id="holdbtn" aria-pressed="false" title="Hold: loop this one scene instead of running the whole chain" aria-label="Hold current scene">&#128275;</button> <button class="num" id="scnbtn" title="Scene: which 16-step pattern you are editing; tap for the next">1</button> </div> </div> </div> </div> <!-- The instrument selector, at the right-hand end. Both options are on show rather than one label you have to tap to discover the other, and the live one glows in its own instrument colour -- the same emitter treatment the section LEDs and the parameter locks wear, so "lit" means the same thing here as everywhere else in the app. --> <div class="insbox" id="inssel" role="group" aria-label="Instrument"> <!-- EDIT, not INS: the buttons do not choose an instrument to play -- both engines always sound -- they choose which one the SEQ, ENV and OSC panels are showing you. The help row already said "which one you edit"; the caption now agrees with it. --> <span class="cap">EDIT</span> <div class="insopts"> <button class="insopt" id="insva" data-ins="0" aria-pressed="true" title="VA: the virtual-analogue instrument — show its sequence, ADSR and oscillator">VA</button> <button class="insopt" id="insfm" data-ins="1" aria-pressed="false" title="FM: the frequency-modulation instrument — show its sequence, ADSR and oscillator">FM</button> </div> </div> <!-- Every message in the app lands here: permission failures, the drum nudge and rate readouts, load and import results. Last child and `flex:0 0 100%`, so it takes its own line of a row that already wraps rather than covering the controls it is talking about. --> <div class="hint" id="hint" role="status" aria-live="polite"></div> </header> <section class="seqrow panel" id="bank-seq"> <div class="labcol"> <span class="lab sq" id="seqlab" role="button" tabindex="0" aria-pressed="true" title="SEQ: tap to show or hide the four per-step modifier rows">SEQ</span> <button class="fold" data-col="bank-seq" aria-label="Fold the sequencer away" title="Fold this panel away"></button> </div> <div class="seqbody"> <!-- Above the steps rather than inside them: a step cell already carries a note name, a duration glyph, a lock LED and four gestures of its own, and there is nothing left to put in it. --> <div class="mods" id="mods" aria-label="Per-step modifiers: accent, slide, octave up, octave down"></div> <div class="seq" id="seq" aria-label="16 step sequencer"></div> <div class="keys" id="keys" aria-label="Note keyboard for the selected step"> <div class="wrow"></div> </div> <div class="seqfoot"> <!-- The piano roll lives here. It took the flexible slot the hint used to fight it for in the bar; the hint has since come back as a full line of that row instead, so the two no longer overlap at all. --> <canvas class="viz roll" id="vzRoll" aria-label="Piano roll of both instruments" role="img"></canvas> <div class="notes"> <!-- The octave stepper sits with RND and CLR because all three act on THIS instrument's pattern, which is what this panel is. It used to live in the OSC panel's knob row, where it spanned two of the four columns -- so moving it frees those two, and a third FM knob is affordable for the first time. --> <div class="oct"> <button id="octdn" aria-label="Octave down" title="Octave down: drops every note twelve semitones">&minus;</button> <b id="octv">0<small>OCT</small></b> <button id="octup" aria-label="Octave up" title="Octave up: lifts every note twelve semitones">+</button> </div> <div class="n2"> <button class="act" id="rnd" title="Randomise: throws new notes and lengths into this instrument's pattern">RND</button> <button class="act" id="clr" title="Clear: silences every step of this instrument's pattern">CLR</button> </div> <div class="arpk" id="bank-arp"></div> </div> </div> </div> </section> <!-- Deliberately NOT inside the SEQ panel: it is its own instrument. The column geometry is copied from .seqrow instead -- same side padding, same 14u label column, same 6u gap, same 2u grid gap -- so the pads land under the sequencer steps to the pixel. The lane names live in the label column so they cost nothing horizontally. --> <section class="drmrow panel" id="bank-drm"> <div class="labcol"> <span class="lab">DRM</span> <button class="fold" data-col="bank-drm" aria-label="Fold the drums away" title="Fold this panel away"></button> </div> <div class="drmbody"> <div class="drms" id="drms" aria-label="Drum pattern, four lanes of 16 steps"></div> <div class="drmbar" id="drmbar"></div> </div> </section> <section class="bank panel" id="bank-osc"> <!-- The one panel label that was not already a button. An oscillator cannot be switched off, so this switch means something else: it hands this instrument's character knob -- DUTY on VA, INDEX on FM -- to the accelerometer. Amber LED, never the green one the section switches use, and it never dims the bank. Back to a single gesture: the tap/hold split existed only while this label doubled as the engine selector. --> <div class="labcol"> <!-- A plain label again. It carried the tilt switch only because the sensor drove one hard-coded knob and needed somewhere to live; the sensor is two sources in the LFO panel now, and the thing that turns it on is a tap on one of them. --> <span class="lab">OSC</span> <button class="fold" data-col="bank-osc" aria-label="Fold the oscillator away" title="Fold this panel away"></button> </div> <div class="knobs"></div> <canvas class="viz" id="vzWave" aria-label="Oscillator waveform" role="img"></canvas> </section> <!-- The mixer, on screen rather than in a dialog. Every panel below the top one folds down to its label, so what is visible at once is a choice instead of a fixed budget -- which is what made a visible mixer possible at all. --> <section class="bank panel" id="bank-mix"> <div class="labcol"> <span class="lab" title="Mixer: levels and mutes">MIX</span> <button class="fold" data-col="bank-mix" aria-label="Fold the mixer away" title="Fold this panel away"></button> </div> <div class="strips" id="strips"></div> </section> <!-- Its own panel, because the mixer never had the room: four faders with mutes plus four sources plus the LFO controls measured 188px, and a 320x568 phone showing eight panels has about a hundred to give. Folded by default -- and like the mixer, the SPOTS survive into the folded strip, because a drag source you have to unfold to reach is one nobody finds. --> <section class="bank panel" id="bank-lfo"> <div class="labcol"> <span class="lab" title="Modulation: two LFOs and the two tilt axes. Drag a spot onto any knob to have it driven.">LFO</span> <button class="fold" data-col="bank-lfo" aria-label="Fold the modulation sources away" title="Fold this panel away"></button> </div> <div class="srcs" id="srcs"></div> </section> <section class="bank panel" id="bank-env"><div class="labcol"><button class="lab sw" id="swEnv" aria-pressed="false" title="Envelope: shapes each note's level over time; off holds a flat tone">ENV</button><button class="fold" data-col="bank-env" aria-label="Fold the envelope away" title="Fold this panel away"></button></div><div class="knobs"></div><canvas class="viz" id="vzEnv" aria-label="ADSR envelope" role="img"></canvas></section> <section class="bank panel" id="bank-flt"><div class="labcol"><button class="lab sw" id="swFlt" aria-pressed="false" title="Filter: removes harmonics above the cutoff; off passes everything">FLT</button><button class="fold" data-col="bank-flt" aria-label="Fold the filter away" title="Fold this panel away"></button></div><div class="knobs"></div><canvas class="viz" id="vzSpec" aria-label="Output spectrum" role="img"></canvas><canvas class="viz" id="vzFilt" aria-label="Filter frequency response" role="img"></canvas></section> <section class="bank panel" id="bank-dly"><div class="labcol"><button class="lab sw" id="swFx" aria-pressed="false" title="Delay: tempo-locked echoes of what you play">DLY</button><button class="fold" data-col="bank-dly" aria-label="Fold the delay away" title="Fold this panel away"></button></div><div class="knobs"></div></section> <section class="bank panel" id="bank-rev"><div class="labcol"><button class="lab sw" id="swRev" aria-pressed="false" title="Reverb: a room around the sound; off is bone dry">REV</button><button class="fold" data-col="bank-rev" aria-label="Fold the reverb away" title="Fold this panel away"></button></div><div class="knobs"></div></section> </div> <!-- Song data: export and import in one editable field. Reached by a long press on the logo, or shift-click for anyone not using a pointer. --> <!-- Ordered by how much of the instrument each one unlocks, not by where the control happens to sit. Two columns so the gestures line up and every feature stays on one row. --> <dialog class="io" id="help" aria-label="What this can do"> <!-- no heading: fifteen labelled gestures do not need to be told what they are, and .iox already carries margin-left:auto so the x stays right --> <div class="iohead"> <button class="iox" id="helpx" aria-label="Close" title="Close">&#215;</button> </div> <ul class="feat"> <li><b>tap logo</b><span>play / stop</span></li> <li><b>tap step</b><span>pick it for the keys</span></li> <li><b>drag step</b><span>pitch and note length</span></li> <li><b>tap key</b><span>add / remove a note</span></li> <li><b>tap pad</b><span>drum on / off, 4 lanes</span></li> <li><b>swipe pad L/R</b><span>nudge that step a quarter</span></li> <li><b>swipe pad U/D</b><span>ratchet, or every 2nd lap</span></li> <li><b>logo right</b><span>sync: listen, send, off</span></li> <li><b>drag knob</b><span>value; shift = fine</span></li> <!-- Two knob gestures on one line, because the list is at its ceiling and folding panels away earns a line more than a second sentence about the same widget does. The ceiling is 24 lines, re-measured when the step modifiers took the 24th: the dialog draws 383px inside 568 with room to spare, where an older comment here claimed 23 was the limit. In SHORT LANDSCAPE (720x400) the list already scrolled at 23 -- 12px of it -- and now scrolls 27px, which is what .io's overflow-y:auto is for and has been the case since long before this row. --> <li><b>dbl-tap/hold knob</b><span>lock step &middot; default</span></li> <li><b>tap fold grip</b><span>fold that panel away</span></li> <li><b>SCENES/lock</b><span>chain or loop patterns</span></li> <li><b>tap a TILT spot</b><span>motion sensor off / on</span></li> <li><b>rows over the steps</b><span>accent &middot; slide &middot; octave</span></li> <li><b>ARP knobs</b><span>run chords as arpeggios</span></li> <li><b>hold logo</b><span>copy, QR, scan, import</span></li> <!-- one line for two gestures: the dialog is at its 24-line ceiling on a phone, and the origin readout below earns its place more than a second line about the same button does --> <li><b>SAVE tap/hold</b><span>share &middot; download</span></li> <li><b>logo up/down</b><span>fullscreen on / off</span></li> <li><b>logo left</b><span>this list</span></li> <li><b>VA/FM top right</b><span>which one you edit</span></li> <li><b>take format</b><span class="tapme" id="tkstate" title="Take format: MP4 and WebM are lossy Opus, WAV is lossless and about ten times the size">&mdash;</span></li> <!-- The marker-tone chooser went with the audio sync it configured: a control that picks a tone nothing emits is a control that lies. Its line goes to the connection state, so the list keeps its count -- it is at its 24-line ceiling on a phone and two comments above say so. --> <li><b>sync</b><span class="tapme" id="syncstate" title="Device sync. Swipe right on the logo to join the room, again to leave. Every device is equal: any of them can set the tempo or start and stop. One joining a room that is already playing waits for the top of the pattern. Swipe right ONCE to LISTEN (blue), TWICE to SEND (orange), again for off. The SEND instance drives: its tempo, start and stop reach every listener. The address comes filled in with a server that is already up, so there is usually nothing to set up. To run your own instead, start sync_server.py on either of the machines you play on &mdash; every instance connects to it, including the one on that machine, and nothing connects device-to-device. Tap here to set its address.">&mdash;</span></li> <li><b>screen</b><span id="wlstate">&mdash;</span></li> <li><b>origin</b><span id="secstate" title="Secure context: the microphone, the motion sensors, the camera and the wake lock are all gated on it — serve over https, or over http://localhost">&mdash;</span></li> </ul> <!-- The page is served from somewhere else than the sync server, so this is the NORMAL way in rather than a fallback: it holds sync_server.py's own address. Shown whenever sync is on, whenever nothing is stored yet, and whenever the sync row above is tapped. `hidden` here is only the state before the first paint. SCAN the code the server prints, or type it. SET applies it and closes. TRUST is the step that used to have no button at all: run yourself, sync_server.py serves itself over TLS with a certificate it signed itself, and a browser will not open a wss:// socket to a certificate it has not been asked about. It can only be ASKED by a navigation -- which is why the server answers `GET /` with a page whose only job is to be that navigation. The DEFAULT address needs none of this: its certificate verifies on its own, so TRUST there only shows you the server is up. TRUST is a real link and not a script-opened window on purpose: a link gets the browser's own new-tab handling and its long-press menu, and it cannot be eaten by a popup blocker. --> <div class="savebar" id="wsrow" hidden=""> <input id="wsurl" type="text" maxlength="120" placeholder="wss://host/ws" aria-label="Sync server address" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"> <button class="act" id="wsscan" title="Scan the code sync_server.py prints">SCAN</button> <a class="act" id="wstrust" target="_blank" rel="noopener" title="Open this server's own page in a new tab, over https instead of wss. If the server signed its own certificate -- sync_server.py run on a LAN does -- that navigation is the only thing that can offer it for approval: accept it there, come back, and sync will connect. For the address this field starts with, and on a plain http server, there is nothing to approve and this just shows you the server is reachable.">TRUST</a> <button class="act" id="wsset" title="Use this address and close">SET</button> </div> <!-- The camera, shown only while scanning. It is not inside .ioqr like the song dialog's: there is no QR canvas here for it to take the place of. --> <div class="ioqr" id="wsqr"> <video id="wsqrv" playsinline="" muted="" autoplay="" aria-label="Camera preview"></video> </div> </dialog> <dialog class="io" id="io" aria-label="Songs"> <div class="iohead"> <b>SONGS</b> <button class="iox" id="ioclose" aria-label="Close" title="Close">&#215;</button> </div> <!-- Named songs, kept in this browser. Above the text box because this is what the dialog is mostly for now; the text and the QR code are the way a song LEAVES the browser, and they stay below. --> <div class="savebar"> <input id="songname" type="text" maxlength="24" placeholder="name this song" aria-label="Song name" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"> <button class="act" id="songsave" title="Save: keeps this song in this browser under that name">SAVE</button> </div> <div class="slots" id="slots"></div> <p class="iohint">Saved songs live in <b>this browser at this address</b> only &mdash; a song saved from a file is not there when you open the app over the network, and clearing browsing data takes them. The text below is the copy you can keep. <b>LOAD and IMPORT both replace everything you have.</b></p> <textarea id="iotext" aria-label="Song data" spellcheck="false" inputmode="none" autocomplete="off" autocapitalize="off" autocorrect="off"></textarea> <div class="iobar"> <button class="act" id="iomode" title="Format: readable JSON, or compressed for sharing">AS JSON</button> <button class="act" id="iocopy" title="Copy: puts the song text on the clipboard">COPY</button> <button class="act" id="iopaste" title="Paste: replaces the text with the clipboard">PASTE</button> <button class="act" id="ioscan" title="Scan: read a song from someone else's QR code">SCAN</button> <button class="act danger" id="ioimp" title="Import: replaces every scene with this song">IMPORT</button> <!-- Next to IMPORT and styled like it, because it does the same thing: replaces everything you have. It does NOT touch the saved songs. --> <button class="act danger" id="ioreset" title="Reset: back to the startup track &mdash; saved songs are kept">RESET</button> </div> <div class="ioqr" id="ioqr"> <canvas id="ioqrc" width="1" height="1" role="img" aria-label="QR code of the song data, tap to enlarge"></canvas> <video id="ioqrv" playsinline="" muted="" autoplay="" aria-label="Camera preview"></video> <span class="ioqrn" id="ioqrn"></span> </div> <div class="iomsg" id="iomsg" role="status"></div> </dialog> <!-- The third dialog, and it exists because a browser cannot listen: with no relay anywhere, the two ends have to swap about 240 bytes of offer and answer BEFORE any connection can exist, and nothing in a page can make that happen by itself. So it happens here, by camera or by clipboard. --> <script> "use strict"; /* ============================================================ parameters == One registry drives knob construction, display, reset and persistence. [min, max, default, unit] -- unit also selects the normalisation curve. */ var P = { 'vol' : [0, 1, 0.75, 'pct'], /* Channel faders. The defaults are not taste, they are exactly what each channel hit the master at before this mixer existed -- both instruments at unity and the drum bus at the 0.85 that used to be hard-coded into the node -- so an existing song sounds identical until you move something. */ 'mix.va' : [0, 1, 1, 'pct'], 'mix.fm' : [0, 1, 1, 'pct'], 'mix.drm' : [0, 1, 0.85, 'pct'], 'osc.wave' : [0, 2, 1, 'wave'], /* 0-50 only: a 60% pulse is a 40% pulse inverted, and for the ramp -- where duty positions the peak -- the upper half gives mirror-image saws. Same harmonic amplitudes either way, so the top half was a second copy of the bottom one. The DEFAULT is 0.35 and not 0.5, and that is not cosmetic: halving the range left the old default sitting exactly on the ceiling, so turning this knob UP from a fresh app did nothing at all -- and recording a parameter lock by dragging up wrote nine locks all equal to the base. A knob whose default has travel in only one direction reads as broken, and it read as "param lock stopped working". */ 'osc.duty' : [0, 0.5, 0.35, 'pct'], 'fm.ratio' : [0, 8, 1, 'fmr'], 'fm.idx' : [0, 10, 3, 'idx'], /* Tightened to the useful decade and a bit. The old tops -- 1.5s of attack, 2s of decay, 3s of release -- put nine tenths of every one of these knobs in territory a sixteenth-note sequencer cannot use. */ 'env.a' : [0.002, 0.8, 0.004, 'sec'], 'env.d' : [0.020, 0.8, 0.16, 'sec'], 'env.s' : [0, 1, 0.30, 'pct'], 'env.r' : [0.010, 1.0, 0.20, 'sec'], 'flt.cut' : [60, 16000, 1100, 'hz' ], 'flt.res' : [-3, 24, 6, 'q' ], 'flt.env' : [0, 1, 0.55, 'pct'], 'dly.div' : [0, 7, 4, 'div'], 'dly.fb' : [0, 0.85, 0.36, 'pct'], 'dly.mix' : [0, 1, 0.26, 'pct'], 'rev.size' : [0.2, 4, 1.6, 'sec'], 'rev.damp' : [500, 16000, 4200, 'hz' ], 'rev.mix' : [0, 1, 0.28, 'pct'], 'arp.mode' : [0, 4, 0, 'arp'], 'arp.rng' : [1, 4, 2, 'rng'], 'arp.len' : [0, 6, 2, 'alen'], 'seq.scn' : [1, 8, 1, 'scn'], /* Two LFOs. Ordinary parameters, so they get knobs, formatting, persistence and export with no new code -- 'hz' is already logarithmic and already prints Hz. Deliberately NOT in LOCKABLE: an LFO rate that can itself be step-locked is a knot, and rev.size is already excluded on the same grounds. */ 'lfo1.wave': [0, 3, 0, 'lfo'], 'lfo1.hz' : [0.1, 10, 2, 'lhz'], 'lfo2.wave': [0, 3, 1, 'lfo'], 'lfo2.hz' : [0.1, 10, 0.5, 'lhz'] }; /* Four characters is the ceiling: RANDOM would clip a 42px knob, and so would UP-DOWN. RAND stays at index 3 because arpPick and noteEvents both test for it by number -- UPDN goes on the end. */ var ARPLAB = ['OFF','UP','DOWN','RAND','UPDN']; /* Four shapes, four characters each, same ceiling the ARP labels live under. */ var LFOLAB = ['SINE','TRI','SQR','RND']; /* One line per control: the proper term, then what it does to the sound. These are `title`s, so they only surface on hover -- the feature list behind a left swipe on the logo is the touch-side answer. */ var TIP = { 'vol' :'Master: the level everything leaves at, after the limiter', 'mix.va' :'VA level: the virtual-analogue instrument against the rest', 'mix.fm' :'FM level: the frequency-modulation instrument against the rest', 'mix.drm' :'Drums level: the four percussion lanes against the rest', 'osc.wave':'Waveform: sine, ramp or pulse \u2014 the raw harmonic content', 'osc.duty':'Duty cycle: pulse width and ramp skew \u2014 thin and reedy at 0, square at 50', 'fm.ratio':'Ratio: modulator pitch as a multiple of the note \u2014 whole numbers ring, fractions clang', 'fm.idx' :'Index: how far the modulator bends the carrier \u2014 the FM brightness control', 'env.a' :'Attack: time a note takes to reach full level', 'env.d' :'Decay: time to fall from the peak down to the sustain level', 'env.s' :'Sustain: level held for the rest of the note', 'env.r' :'Release: time to fade away after the note ends', 'flt.cut' :'Cutoff: frequency above which the low-pass filter removes harmonics', 'flt.res' :'Resonance: emphasis right at the cutoff; whistles when pushed', 'flt.env' :'Filter envelope: how far each note sweeps the cutoff open', 'dly.div' :'Delay time: spacing of the echoes, locked to the tempo', 'dly.fb' :'Feedback: how much of each echo returns, so how many repeats', 'dly.mix' :'Delay mix: how loud the echoes sit against the dry sound', 'rev.size':'Room size: how long the reverb tail rings on', 'rev.damp':'Damping: how quickly the reverb loses its treble', 'rev.mix' :'Reverb mix: how much room against the dry sound', 'arp.mode':'Arpeggiator: off, or run the chord up, down, at random, or up then back down', 'lfo1.wave':'LFO 1 shape: sine, triangle, square, or a new random level each cycle', 'lfo1.hz' :'LFO 1 rate, 0.1 to 10 cycles a second', 'lfo2.wave':'LFO 2 shape: sine, triangle, square, or a new random level each cycle', 'lfo2.hz' :'LFO 2 rate, 0.1 to 10 cycles a second', 'arp.rng' :'Range: how many octaves the arpeggio climbs through', 'arp.len' :'Note length: how long each arpeggiated note sounds', 'seq.scn' :'Scenes: how many 16-step patterns play in the chain' }; var LABEL = { 'vol':'MASTER','mix.va':'VA','mix.fm':'FM','mix.drm':'DRUMS', 'osc.wave':'WAVE','osc.duty':'DUTY','fm.ratio':'RATIO','fm.idx':'INDEX', 'env.a':'A','env.d':'D','env.s':'S','env.r':'R', 'flt.cut':'CUT','flt.res':'RES','flt.env':'ENV', 'dly.div':'TIME','dly.fb':'FBK','dly.mix':'MIX', 'rev.size':'SIZE','rev.damp':'DAMP','rev.mix':'MIX', 'arp.mode':'ARP','arp.rng':'RANGE','arp.len':'LEN','seq.scn':'SCENES', 'lfo1.wave':'SHAPE','lfo1.hz':'RATE','lfo2.wave':'SHAPE','lfo2.hz':'RATE' }; /* arpeggiator note lengths, in beats -- same shape and naming as DIVS below so the two read alike. 1/16 is the default because it is one note per sixteenth, which is exactly what the arp did before the knob existed. */ var ARPL = [ {n:'1/32', b:0.125}, {n:'16T', b:1/6}, {n:'1/16', b:0.25}, {n:'8T', b:1/3}, {n:'1/8', b:0.5}, {n:'4T', b:2/3}, {n:'1/4', b:1} ]; /* delay divisions, in beats */ var DIVS = [ {n:'1/32', b:0.125}, {n:'16T', b:1/6}, {n:'1/16', b:0.25}, {n:'8T', b:1/3}, {n:'1/8', b:0.5}, {n:'3/16',b:0.75}, {n:'1/4', b:1}, {n:'1/2', b:2} ]; var V = {}; /* current parameter values */ for (var k in P) V[k] = P[k][2]; /* RAMP folds triangle and sawtooth into one shape: duty moves the peak, so 0.5 is a triangle, ->1 a rising saw, ->0 a falling saw. */ var WLAB = ['SIN','RAMP','PULSE']; /* ---- two instruments, sounding at once --------------------------------- They are not interchangeable: instrument 0 IS the VA engine and instrument 1 IS FM. There is no third, and no way to have two of either. VA -- virtual analogue: one geometric wave, shaped by the filter. FM -- frequency modulation: one sine bent by another, so the timbre comes from sidebands instead of from harmonics that were already there. Strictly that is phase modulation, which is what every "FM" synth since the DX7 has actually done; the field calls it FM and so does this. INS is "the instrument whose data is in V and STEPS". At rest it is whatever the selector in the top panel shows; the scheduler overrides it for the length of one scheduleStep call. ONE variable rather than two, because a "selected" index and a "scheduled" index that are equal 99% of the time is a bug farm -- and the sound must never depend on what is on screen. */ var INSLAB = ['VA','FM']; var INSK = [['osc.wave','osc.duty'], ['fm.ratio','fm.idx']]; var INS = 0; /* The parameters each instrument keeps a private copy of. Everything else in P is either shared by both (vol, flt.*, dly.*, rev.*) or global (seq.scn), and that split is exactly the one the panel tints describe: SEQ, ADSR and OSC are per instrument, FLT / DLY / REV / VOL are not. */ var INSP = ['env.a','env.d','env.s','env.r', 'osc.wave','osc.duty','fm.ratio','fm.idx', 'arp.mode','arp.rng','arp.len']; /* Modulator pitch as a multiple of the note. Whole numbers put every sideband on a harmonic of the note, so the result is pitched and reedy; the fractions put them between harmonics, which is where bells and metal live. */ var FMR = [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 7]; var FMRLAB = ['.5','1','1.5','2','2.5','3','4','5','7']; var NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; /* dorian and hijaz are here because the demos needed them and the app could not spell them. Oxygene IV is C DORIAN -- minor with a major 6th, so the IV chord comes out MAJOR, which is the whole floating quality of the piece. The Knight Rider theme is F# PHRYGIAN DOMINANT (freygish, hijaz): a flat 2nd over a MAJOR 3rd, which on F# spells F# G A# B C# D E exactly. Neither is reachable by transposing minor or major, so neither riff could have been written in tune before. This table is only a guide: a step stores semitones, so SCALE drives the keyboard highlight, the RND generator and the legacy `deg` export field, and nothing you have already programmed. */ var SCALES = { minor:[0,2,3,5,7,8,10], major:[0,2,4,5,7,9,11], dorian:[0,2,3,5,7,9,10], hijaz:[0,1,4,5,7,8,10], penta:[0,3,5,7,10], chrom:[0,1,2,3,4,5,6,7,8,9,10,11] }; /* four characters at most: the CSS at :1335 notes these are already the widest thing in the note row, so a fifth character would cost layout */ var SCALE_LAB = {minor:'MIN', major:'MAJ', dorian:'DOR', hijaz:'HIJZ', penta:'PENT', chrom:'CHRM'}; /* OCT and ENV_ON belong to the SELECTED instrument and move with it; ROOT and SCALE are the song's, which is why they moved up to the top panel. */ /* Mutes are GLOBAL, not per scene: a mute is something you do while playing, not part of the song, so it must not change when the chain reaches the next scene. The faders opposite them are per scene, because a balance IS part of the arrangement. Keyed by channel, which is also the order the mixer shows them in. */ /* MASTER, DRUMS, then the two instruments: the drums are what you ride against everything else, so they sit next to the master rather than at the far end. */ var MIXCH = ['vol', 'mix.drm', 'mix.va', 'mix.fm']; var MUTE = {}; /* What a fresh app is, in one place, so RESET and the first boot cannot disagree about it. Everything else a fresh app is comes out of P[id][2], freshSteps() and DRUM_SEED. */ var SEED = {bpm:120, root:0, scale:'minor'}; var OCT = 0, ROOT = 0, SCALE = SEED.scale, bpm = SEED.bpm; var IOS = /iPad|iPhone|iPod/.test(navigator.userAgent); /* silent-switch note */ var ENV_ON = false, FLT_ON = false, FX_ON = false, REV_ON = false; /* all off by default */ /* Scenes chain into a song. The live state POINTS AT the current scene rather than copying it -- V and STEPS are reassigned to the scene's own objects, so every edit lands in the right scene with no capture step. Only the scalars below need writing back when leaving a scene. 'seq.scn' is the one parameter that must stay global: it is the chain length, not a per-scene value, so it is carried across on every switch. */ var SCENES = [], scn = 0, schedScn = -1, HOLD = false, gIdx = 0; function nScn(){ return Math.round(V['seq.scn']); } /* ---- instruments, done the way scenes already are ---------------------- STEPS is REPOINTED at the instrument's own array, so every step editor, paintAllSteps, buildKeys, drawKeys and padHead follow the selection for free. The parameter subset has to be COPIED rather than repointed, because V stays one flat map and the shared keys must not move with the instrument. */ /* C D E F on the four downbeats: a fresh app plays a scale fragment rather than one note four times, so the very first press of PLAY shows that the sequencer sequences. Literal semitones, not scale degrees -- which is why the E reads as off-scale against the default MIN guide. */ var SEED_SEMIS = [0, 2, 4, 5]; /* ---- per-step modifiers -------------------------------------------------- The four things a step can be beyond its pitch and its length, named after the TB-303's because that is where the vocabulary comes from and where anyone reaching for them has met them before. UP and DOWN are ONE field, not two flags. A step cannot be an octave up and an octave down at once, so `shift` is a tri-state and the two rows interlock through it: two rows because two rows are tappable, one value because one value is the truth. modGet/modSet are the only things that know that. They live on the STEP, which means they are per instrument and per scene for free -- the same as the notes and the durations, and nothing has to be taught it. An absent field is zero everywhere, so every song, demo and stored payload written before this loads unchanged. */ var MODROW = [ {key:'acc', glyph:'\u203a', lab:'accent', tip:'Accent: this step plays louder and brighter'}, {key:'sld', glyph:'\u223c', lab:'slide', tip:'Slide: glide into this step from the note before it'}, {key:'up', glyph:'\u25b2', lab:'octave up', tip:'Octave up: this step only, twelve semitones higher'}, {key:'dn', glyph:'\u25bc', lab:'octave down', tip:'Octave down: this step only, twelve semitones lower'} ]; /* An accent is louder AND brighter. Loudness alone reads as a mixing mistake; it is the filter moving with it that makes the ear hear an accent. */ var ACC_GAIN = 1.5; /* peak 0.22 -> 0.33; the limiter has it */ var ACC_ENV = 0.35; /* and the sweep goes this much further */ var SLIDE = 0.6; /* fraction of a step the glide takes */ function modGet(st, r){ if (r === 2) return (st.shift | 0) > 0 ? 1 : 0; if (r === 3) return (st.shift | 0) < 0 ? 1 : 0; return st[MODROW[r].key] ? 1 : 0; } function modSet(st, r, on){ /* The interlock is not a rule enforced anywhere -- it falls out of one field holding both answers. Writing UP makes DOWN read false by arithmetic. */ if (r === 2) st.shift = on ? 1 : 0; else if (r === 3) st.shift = on ? -1 : 0; else st[MODROW[r].key] = on ? 1 : 0; } function freshSteps(){ var out = [], i; for (i = 0; i < 16; i++) out.push({on: i % 4 === 0, semis: [SEED_SEMIS[(i / 4) | 0]], dur: 0}); return out; } function blankIns(quiet){ var v = {}, i, st = freshSteps(); for (i = 0; i < INSP.length; i++) v[INSP[i]] = P[INSP[i]][2]; /* the second instrument starts silent: two identical patterns playing the same notes an engine apart is a chorus, not a starting point */ if (quiet) for (i = 0; i < 16; i++) st[i].on = 0; return {v: v, steps: st, oct: 0, envOn: false}; } function cloneIns(iv){ var v = {}, k; for (k in iv.v) v[k] = iv.v[k]; return {v: v, oct: iv.oct | 0, envOn: !!iv.envOn, steps: iv.steps.map(function(st){ return {on:st.on, semis:st.semis.slice(), dur:st.dur, lock:copyLock(st.lock)}; })}; } /* write the live state back into the instrument it came from */ function stashIns(){ var sc = SCENES[scn]; if (!sc || !sc.ins) return; var iv = sc.ins[INS], i; /* baseOf, not V: a knob under an LFO must store the value YOU set, not the one the LFO is passing through. */ for (i = 0; i < INSP.length; i++) iv.v[INSP[i]] = baseOf(INSP[i]); iv.steps = STEPS; iv.oct = OCT; iv.envOn = ENV_ON; } /* and point it at instrument n of the current scene */ function loadIns(n){ var sc = SCENES[scn]; if (!sc || !sc.ins) return; var iv = sc.ins[n], i; for (i = 0; i < INSP.length; i++) V[INSP[i]] = iv.v[INSP[i]]; STEPS = iv.steps; OCT = iv.oct; ENV_ON = iv.envOn; INS = n; } /* Substituting one instrument into the live state for the length of a call -- the same trick pushLock uses, and safe for the same reason: tick() is synchronous, so nothing can observe V mid-swap. */ function pushIns(n){ if (n === INS) return null; var o = {n: INS, steps: STEPS, oct: OCT, env: ENV_ON, v: {}}, i; for (i = 0; i < INSP.length; i++) o.v[INSP[i]] = V[INSP[i]]; stashIns(); /* so the swap cannot lose a knob turn */ loadIns(n); return o; } function popIns(o){ if (!o) return; for (var i = 0; i < INSP.length; i++) V[INSP[i]] = o.v[INSP[i]]; STEPS = o.steps; OCT = o.oct; ENV_ON = o.env; INS = o.n; } /* the UI switch: it changes what is DISPLAYED and nothing else */ function selectIns(n){ if (n === INS) return; stashIns(); loadIns(n); paintIns(); markSceneDirty(); /* every knob, graph and step at once */ save(); } function cloneScene(o){ var v = {}, k; for (k in o.v) v[k] = o.v[k]; return { v: v, ins: o.ins.map(cloneIns), drums: (o.drums || []).map(function(lane){ return lane.slice(); }), dnudge: cloneGrid(o.dnudge), drate: cloneGrid(o.drate), fltOn:o.fltOn, fxOn:o.fxOn, revOn:o.revOn, root:o.root, scale:o.scale }; } /* the live state wrapped as a scene. The instrument the selector is on takes the live V subset and STEPS; the other one comes from the scene we are already in, or starts blank if there is no scene yet. */ function liveScene(){ var sc = SCENES[scn], mine = {v:{}, steps:STEPS, oct:OCT, envOn:ENV_ON}, i; for (i = 0; i < INSP.length; i++) mine.v[INSP[i]] = V[INSP[i]]; var other = (sc && sc.ins && sc.ins[1 - INS]) || blankIns(1); return {v:V, ins: INS === 0 ? [mine, other] : [other, mine], drums:DRUMS, dnudge:DNUDGE, drate:DRATE, fltOn:FLT_ON, fxOn:FX_ON, revOn:REV_ON, root:ROOT, scale:SCALE}; } function stashScalars(){ var o = SCENES[scn]; if (!o) return; /* DRUMS is normally the same array object as o.drums, but anything that REASSIGNS it (a load, an import) would otherwise leave the scene pointing at the old grid. Writing it back here makes the alias self-healing. */ o.drums = DRUMS; o.dnudge = DNUDGE; o.drate = DRATE; o.fltOn=FLT_ON; o.fxOn=FX_ON; o.revOn=REV_ON; o.root=ROOT; o.scale=SCALE; stashIns(); /* OCT, ENV_ON and STEPS belong to one */ } /* atTime schedules the parameter changes ON the boundary. Without it the next scene's volume, delay and reverb would arrive up to a lookahead early, over the previous scene's tail. */ function switchTo(k, atTime, fresh){ if (k === scn && SCENES[k] && V === SCENES[k].v) return; /* `fresh` means SCENES was just rebuilt from stored data, in which case scn still indexes the OLD array and stashing would overwrite a loaded scene's switches and root/scale with whatever happened to be live. */ if (!fresh) stashScalars(); scn = k; var sc = SCENES[k], keep = V['seq.scn']; V = sc.v; V['seq.scn'] = keep; DRUMS = sc.drums || (sc.drums = drumsFrom(DRUM_SEED)); DNUDGE = sc.dnudge || (sc.dnudge = zeroGrid()); DRATE = sc.drate || (sc.drate = zeroGrid()); FLT_ON=sc.fltOn; FX_ON=sc.fxOn; REV_ON=sc.revOn; /* A scene carries its OWN key, so crossing a boundary moves the key -- and this assignment is the only place in the file where the key changes without setKey being involved, which is exactly why a scene-driven change never reached the sync channel. Noted here, published at the bottom of the function once the switch has actually completed. */ var kmoved = (ROOT !== sc.root) || (SCALE !== sc.scale); ROOT=sc.root; SCALE=sc.scale; /* AFTER V was repointed: the new scene's map still carries stale copies of the per-instrument keys, and this is what overwrites them. STEPS, OCT and ENV_ON come from here too. */ loadIns(INS); buildIR(); /* cache lookup, not a rebuild */ scanGlock(); /* locks belong to the scene, so rescan */ applyLive(atTime); /* deliberately NOT refreshLive(): a boundary must not retro-apply the new scene's filter to notes still sounding from the previous one, least of all a lookahead early. New notes already get the new scene's parameters. */ paintHold(); /* the switch doubles as the scene readout */ /* Publish it. Every OTHER route to a new key goes through setKey, which sends; a scene boundary did not, so a listener kept playing the key the song had two scenes ago and only a hand on the dial could fix it. Last, and guarded on kmoved, for three reasons: a switch that failed part way through must not announce a key the device is not actually in; the common case is a chain whose scenes share a key, and a frame per bar that says nothing is traffic for nothing; and syncSend is a no-op unless this device is the SEND one, so a listener advancing its own chain stays quiet. ONLY for a manual switch, and `atTime` is the discriminator that already existed: the scene button, ensureScenes and applyState all call switchTo without one, and the scheduler is the only caller that passes it. A scheduled switch is announced ahead of time by tick() instead, with the instant it applies -- see there. This comment used to say "there is nowhere better to put it: a socket frame cannot be scheduled the way applyLive(atTime) schedules a parameter", and the conclusion was wrong. You cannot schedule the FRAME; you can timestamp it and let the receiver schedule the APPLICATION, and revision 107 built the shared clock that makes that possible. */ if (kmoved && !SYNC.quiet && atTime === undefined) syncSend({t:'key', root:ROOT, scale:SCALE}); } /* Grow to match the knob by copying the current scene -- four identical bars to pull apart beats three silent ones. Never truncates: turning the knob down and back up must not destroy work. */ function ensureScenes(){ if (!SCENES.length) SCENES.push(liveScene()); var want = nScn(); while (SCENES.length < want) SCENES.push(cloneScene(SCENES[scn])); if (scn >= want) switchTo(0); gIdx = gIdx % (16 * want); warmIR(); } function wsel(){ return Math.round(V['osc.wave']); } /* semis: semitone offsets from the root, -12..12, at most POLY of them. It replaces the old scalar `deg`, which was an index into the current SCALE -- see loadSemis() for how stored songs are carried across. */ var POLY = 4; /* freshSteps(), not a second copy of it: the seed pattern was written out twice and the two would have disagreed the moment one changed. */ var STEPS = freshSteps(); /* ---- percussion --------------------------------------------------------- Four lanes, because these four are what a beat is actually made of: the pulse, the backbeat, the subdivision, and the one that breathes. Everything else on a drum machine is seasoning. */ var DRUMKIT = [ {id:'BD', name:'Kick', tip:'Kick: the pulse \u2014 a sine falling to 62Hz and ringing there, with a beater click on top'}, {id:'SD', name:'Snare', tip:'Snare: the backbeat \u2014 noise over a short tuned body'}, {id:'CH', name:'Closed hat', tip:'Closed hat: the subdivision \u2014 a clipped burst of noise'}, {id:'OH', name:'Open hat', tip:'Open hat: the long one \u2014 rings until a closed hat cuts it'} ]; /* Four on the floor, a backbeat and eighth hats: the pattern everyone recognises, so a fresh app makes a beat the moment you press play. A save that carries an explicitly empty grid keeps it -- only a MISSING grid gets this. */ /* Five beats everyone can name, so the panel is playable before you have programmed anything. They replace the grid only -- a groove you dialled in with the lane gestures survives trying another one. */ var DRUM_KITS = [ {id:'FOUR', tip:'Four on the floor: house \u2014 kick every beat, hats between', rows:['x...x...x...x...','....x.......x...','x.x.x.x.x.x.x.x.','..x...x...x...x.']}, {id:'BOOM', tip:'Boom bap: hip-hop \u2014 the kick answers the snare', rows:['x.....x...x.....','....x.......x...','x.x.x.x.x.x.x.x.','................']}, {id:'BRK', tip:'Breakbeat: the kick moves off the beat and the hats stay straight', rows:['x.......x.x.....','....x.......x...','x.x.x.x.x.x.x.x.','..........x.....']}, {id:'TRAP', tip:'Trap: a half-time snare under rolling hats', rows:['x.....x...x.....','........x.......','x.xxx.x.xxx.x.xx','................']}, {id:'ROCK', tip:'Rock: kick, snare, kick, snare \u2014 eighths on top', rows:['x.......x.......','....x.......x...','x.x.x.x.x.x.x.x.','................']} ]; /* Empty. This used to be four-on-the-floor so a fresh app made a beat the moment you pressed play; a fresh app is now a bare synth and the drums are something you switch on. It is also the fallback for an imported song that carries NO drum grid, and inventing a beat for one of those was always the more surprising half of the behaviour. */ var DRUM_SEED = ['', '', '', '']; function drumsFrom(rows){ var out = [], r, c; for (r = 0; r < DRUMKIT.length; r++){ /* A lane is sixteen characters in the DOCUMENT shape and a sixteen-element 0/1 array in the IN-MEMORY one -- and save() wrote the in-memory one straight into localStorage up to revision 104, so the second shape is sitting in real browsers holding real beats. Both, therefore. This test used to be `typeof rows[r] === 'string'` alone and everything else fell through to '', so an array lane loaded as an EMPTY lane and said nothing. That SILENCE, not the wrong encoding, is what let the drum loss live undetected: the grid came back empty and no error, hint or console line marked it. Anything that is neither shape is still repaired to silence rather than thrown on -- a hand-edited song must load. */ var raw = rows && rows[r], arr = Array.isArray(raw); var line = (arr || typeof raw === 'string') ? raw : ''; var lane = []; for (c = 0; c < 16; c++) lane.push((arr ? line[c] : line.charAt(c) === 'x') ? 1 : 0); out.push(lane); } return out; } function drumsToRows(d){ var out = [], r, c; for (r = 0; r < DRUMKIT.length; r++){ var line = ''; for (c = 0; c < 16; c++) line += (d && d[r] && d[r][c]) ? 'x' : '.'; out.push(line); } return out; } /* absent -> the seed; present -> exactly what was written, empty included */ function loadDrums(raw){ return drumsFrom(Array.isArray(raw) ? raw : DRUM_SEED); } var DRUMS = drumsFrom(DRUM_SEED); /* Per STEP, not per lane: two numbers on each of the 64 cells. nudge: quarter-steps of groove, -3..+3. Capped there because a whole step backwards is further than the scheduler looks ahead. rate: -3..-1 -> this step sounds every 4th, 3rd or 2nd time the sequence comes round; 0 -> every time; +1/+2 -> it hits twice or three times within its own step. The divider counts PASSES over the pattern, not step positions, so "skipped every second time" means every second lap. */ var DNUDGE = null, DRATE = null; var NUDGE_MAX = 3, RATE_LO = -3, RATE_HI = 2; var PASS = []; /* laps completed, per scene */ function zeroGrid(){ var g = [], r, c; for (r = 0; r < DRUMKIT.length; r++){ var row = []; for (c = 0; c < 16; c++) row.push(0); g.push(row); } return g; } DNUDGE = zeroGrid(); DRATE = zeroGrid(); function rateLabel(v){ if (v > 0) return '\u00d7' + (v + 1); if (v < 0) return 'every ' + (1 - v) + (v === -1 ? 'nd' : v === -2 ? 'rd' : 'th') + ' lap'; return 'every lap'; } function cloneGrid(g){ var out = [], r, c; for (r = 0; r < DRUMKIT.length; r++){ var row = []; for (c = 0; c < 16; c++) row.push(g && g[r] && g[r][c] ? g[r][c] | 0 : 0); out.push(row); } return out; } /* Sparse on the way out and in: a default grid is 64 zeros, and writing them all would bloat the song text and the QR for nothing. REAL ARRAYS, not the pre-formatted '[0, 4, 1, -1]' strings this used to return. That was a text formatter hiding inside an encoder, and it is the whole reason save() could not reuse it -- so save() reached past it and wrote the in-memory grids straight into localStorage instead, which is the bug this pair can no longer have. exportText puts the spaces back at its own call site, and sparseToMods below already expected arrays, so the two halves finally speak one language. */ function modsToSparse(nud, rat){ var out = [], r, c; for (r = 0; r < DRUMKIT.length; r++) for (c = 0; c < 16; c++){ var a = (nud && nud[r] && nud[r][c]) | 0, b = (rat && rat[r] && rat[r][c]) | 0; if (a || b) out.push([r, c, a, b]); } return out; } function sparseToMods(raw){ var nud = zeroGrid(), rat = zeroGrid(), i; if (Array.isArray(raw)) for (i = 0; i < raw.length; i++){ var e = raw[i]; if (!Array.isArray(e) || e.length < 4) continue; var r = e[0] | 0, c = e[1] | 0; if (r < 0 || r >= DRUMKIT.length || c < 0 || c > 15) continue; nud[r][c] = clamp(e[2] | 0, -NUDGE_MAX, NUDGE_MAX); rat[r][c] = clamp(e[3] | 0, RATE_LO, RATE_HI); } return {nud:nud, rat:rat}; } /* Both stored shapes in one place, so no caller has to know there are two. `dmod` -- the sparse list -- is checked FIRST, because it is the only thing anything writes now. The other branch is a MIGRATION and nothing more: up to revision 104 save() wrote the live scene verbatim, which put two full 4x16 grids under `dnudge`/`drate` and no `dmod` at all. Reading that is what brings an existing groove back instead of quietly flattening it. Clamped the same way the sparse path clamps, or the two shapes would not be the same data. cloneGrid already does `|0` and tolerates a short or missing row, so a mangled grid repairs rather than throws. */ function loadMods(raw){ if (!raw || Array.isArray(raw.dmod) || !(raw.dnudge || raw.drate)) return sparseToMods(raw && raw.dmod); var nud = cloneGrid(raw.dnudge), rat = cloneGrid(raw.drate), r, c; for (r = 0; r < DRUMKIT.length; r++) for (c = 0; c < 16; c++){ nud[r][c] = clamp(nud[r][c], -NUDGE_MAX, NUDGE_MAX); rat[r][c] = clamp(rat[r][c], RATE_LO, RATE_HI); } return {nud:nud, rat:rat}; } /* A step IS a 16th note, so durations are whole numbers of steps. Index 0 is one step, which reproduces the fixed gate this replaces. */ /* A step stores semitone offsets from the root. C-anchored, the keyboard can reach -12-ROOT .. 12-ROOT, so -23 at root B -- the old +-12 would have made the left half of the keyboard unstorable. */ var SEMI_MAX = 24; var DURS = [1, 2, 4, 8, 16]; var DURLAB = ['1/16', '1/8', '1/4', '1/2', 'whole']; /* ================================================================ maths == */ function clamp(x, lo, hi){ return x < lo ? lo : x > hi ? hi : x; } function isLog(u){ return u === 'hz' || u === 'lhz' || u === 'sec'; } /* 'q' is dB: already log */ function isStepped(id){ var u = P[id][3]; return u === 'div' || u === 'wave' || u === 'arp' || u === 'rng' || u === 'scn' || u === 'alen' || u === 'fmr' || u === 'lfo'; } /* ================================================== parameter lock ====== A lock is one value, for one parameter, on one step. It is stored sparsely inside the step object itself, so it rides the scene model for free: a scene already owns its steps array and switchTo() just repoints STEPS at it. Arming is a bypass switch rather than a record-enable -- disarming keeps the locks and plays the base value, which gives a free A/B of the automation. */ var LOCKABLE = {'env.a':1, 'env.d':1, 'env.s':1, 'env.r':1, 'flt.cut':1, 'flt.res':1, 'flt.env':1, 'osc.wave':1, 'osc.duty':1, 'fm.ratio':1, 'fm.idx':1, 'dly.div':1, 'dly.fb':1, 'dly.mix':1, 'rev.damp':1, 'rev.mix':1}; /* These are not read per voice -- they live on shared nodes written by applyLive(), so locking them needs a scheduled write instead. rev.size is excluded from LOCKABLE entirely: it rebuilds the impulse response. */ var GLOBALP = {'dly.div':1, 'dly.fb':1, 'dly.mix':1, 'rev.damp':1, 'rev.mix':1}; var ARM = {}; /* armed parameters */ var GLOCK = 0; /* does the live scene hold any global lock? */ var REC = {}; /* parameters currently being dragged with arm on */ /* Substituting into the live V is what lets spawnVoice(), wsel(), delaySec() and noteOf() stay completely unaware that locks exist. */ function pushLock(lk){ if (!lk) return null; var o = null, id; for (id in lk){ if (!ARM[id]) continue; /* disarmed: kept on disk, ignored in sound */ if (!o) o = {}; o[id] = V[id]; V[id] = lk[id]; } return o; } function popLock(o){ if (o) for (var id in o) V[id] = o[id]; } function emptyObj(o){ for (var k in o) return false; return true; } /* The value a knob should display: the audible step's lock while armed and playing, the base value otherwise. SCENES[curScn] rather than the live STEPS, because the scheduler runs a lookahead ahead and may already have switched STEPS to the next scene while cur is still in the previous one. */ /* the locks of the step being HEARD, for whichever instrument INS currently names -- so autoFollow gets the right lane just by pushing an instrument */ function stepAt(k){ var sc = SCENES[k], iv = sc && sc.ins && sc.ins[INS]; return iv && iv.steps[cur]; } /* ============================================== external control ========= Four sources -- two LFOs and the two axes of the one tilt sensor -- any of which can be attached to any knob by dragging its spot onto it. THREE STATES, not two: a knob is set by hand, or step-locked, or driven from outside, and never two at once. Attaching clears the arm and its locks; arming clears the attachment. One source per knob falls out of ROUTE[id] being a single value; one source drives as many knobs as you like. NO DEPTH: a source sweeps its knob across the parameter's WHOLE range, so a routed knob's own value stops mattering. That is what the glow is for. This hangs off two things that already existed. eff() was already the app's answer to "what is this parameter really worth right now", and paintKnob and vizFollow both read it -- so the knobs and every visualiser follow a source for free. pushLock/popLock was already the app's answer to "run the audio path with substituted values", and pushMod is the same shape, which is why applyLive and refreshLive need no changes at all. */ var SRC = { lfo1: {key:'lfo1', kind:'lfo', lab:'LFO 1', col:'--src1', v:0.5, live:1, ph:0, rnd:0.5, cyc:-1}, lfo2: {key:'lfo2', kind:'lfo', lab:'LFO 2', col:'--src2', v:0.5, live:1, ph:0, rnd:0.5, cyc:-1}, /* Named after the gesture. "X" and "Y" say nothing about which way to move a phone, and a device whose axes do not match the label leaves no way to tell a wrong label from a wrong wiring. Left-right is the roll (gamma), forward-back is the pitch (beta). */ tiltx: {kind:'tilt', lab:'TILT \u2194', col:'--src3', v:0.5, live:0, ax:'gamma'}, tilty: {kind:'tilt', lab:'TILT \u2195', col:'--src4', v:0.5, live:0, ax:'beta'} }; var SRCORD = ['lfo1', 'lfo2', 'tiltx', 'tilty']; var ROUTE = {}; /* param id -> source key */ /* An LFO whose own rate is modulated is a feedback loop with no off switch. */ function routable(id){ return !!P[id] && id.indexOf('lfo') !== 0; } function srcOf(id){ var k = ROUTE[id], s = k && SRC[k]; return (s && s.live) ? s : null; } /* What a source will be worth `ahead` SECONDS from now. The scheduler runs a lookahead -- up to a tenth of a second, which at 10Hz is most of a cycle -- so a note has to be spawned with the value the LFO will have WHEN IT SOUNDS, not the one it had when the note was queued. `peek` keeps a sample-and-hold from advancing its own state just because someone asked about the future. */ function srcAt(s, ahead){ if (!s.key) return s.v; /* a tilt axis has no future */ var tot = (s.tot || 0) + Math.max(0, ahead) * V[s.key + '.hz']; return clamp(lfoShape(V[s.key + '.wave'], tot % 1, s, 1), 0, 1); } /* While a substitution is in force, MODB holds what the patch really says. Anything that WRITES V back to storage has to ask it -- because tick() pushes the modulation and then pushIns() calls stashIns(), which copied the live V into the instrument's saved parameters. An LFO on DUTY therefore rewrote the stored patch sixty times a second: measured drifting from 0.500 to 0.145 in a second and a half, and switching VA/FM then loaded whatever the LFO happened to be doing. */ var MODB = null; function baseOf(id){ return (MODB && id in MODB) ? MODB[id] : V[id]; } function pushModAt(ahead){ var o = null, id; for (id in ROUTE){ var s = srcOf(id); if (!s) continue; if (!o) o = {}; o[id] = V[id]; V[id] = fromNorm(id, ahead ? srcAt(s, ahead) : s.v); } MODB = o; return o; } function pushMod(){ return pushModAt(0); } function popMod(o){ if (o) for (var id in o) V[id] = o[id]; MODB = null; } function eff(id){ var s = srcOf(id); if (s) return fromNorm(id, s.v); /* outside control wins outright */ if (!playing || !ARM[id] || cur < 0) return V[id]; var st = stepAt(curScn), lk = st && st.lock; return (lk && id in lk) ? lk[id] : V[id]; } /* Pure -- safe to call from the scheduler tick, which must not touch the DOM. */ /* Phase from the WALL CLOCK, never from a frame count: rAF stops dead when the page is hidden, which is a mistake this app has already made once and written down. So the same elapsed time produces the same phase whether it is sampled at 60Hz by draw() or at the scheduler's rate by tick(). */ var MODT = 0; function lfoShape(w, ph, s, peek){ w = Math.round(w); if (w === 1) return ph < 0.5 ? ph * 2 : 2 - ph * 2; /* TRI */ if (w === 2) return ph < 0.5 ? 1 : 0; /* SQR */ if (w === 3){ /* RND */ if (peek) return s.rnd; /* asking about the future must not roll the die */ var c = Math.floor(s.tot); if (c !== s.cyc){ s.cyc = c; s.rnd = Math.random(); } return s.rnd; } return 0.5 - 0.5 * Math.cos(ph * 6.283185307); /* SINE */ } /* Its OWN clock, not draw()'s. draw() is armed only by start() and returns at once when the transport is stopped, so hanging the modulator off it meant an LFO that only turned while something was playing -- and you set an LFO up before you press play, not after. A plain interval also survives a browser that is not running rAF at all. It runs only when it has something to do: something is routed, or the mixer is unfolded and the spots are on screen turning. Otherwise a phone would pay for a 30Hz timer to animate nothing. */ var MODI = 0, MODN = 0; /* 16ms, not 33: a knob drag reaches the audio through rAF at 60Hz, and a source that updated at half that sounded coarse next to the same knob under a finger. The PAINTING stays at half rate -- canvases are the expensive part and no eye needs a filter curve redrawn sixty times a second. */ var MOD_MS = 16; /* Also while the SENSOR is on, and that was the regression: with the LFO panel folded and nothing routed yet, MODI was off, so nothing ever repainted the spots -- a phone streaming orientation at 60Hz sat there showing two grey circles labelled "no sensor". The old code wrote straight into a knob and repainted itself, so it visibly worked. */ function modWanted(){ return !emptyObj(ROUTE) || !COL['bank-lfo'] || !!TILT.on; } function modSync(){ var want = modWanted(); if (want && !MODI){ MODT = 0; MODI = setInterval(modTick, MOD_MS); } else if (!want && MODI){ clearInterval(MODI); MODI = 0; } } /* Advance every source, then -- only if something is actually routed -- run the existing apply path with the modulated values substituted in. */ function modTick(){ var now = performance.now(); var dt = MODT ? Math.min(0.5, (now - MODT) / 1000) : 0; MODT = now; var i, k, s; for (i = 0; i < 2; i++){ k = 'lfo' + (i + 1); s = SRC[k]; s.tot = (s.tot || 0) + dt * V[k + '.hz']; s.ph = s.tot % 1; s.v = clamp(lfoShape(V[k + '.wave'], s.ph, s), 0, 1); } var show = (++MODN & 1) === 0; if (show) paintSrcs(); if (TILT.on && TILT.diag && (MODN % 60) === 0) tiltDiag(); if (emptyObj(ROUTE)) return; /* BOTH instruments, each with its own parameters swapped in -- the same loop autoFollow uses, and for the same reason. refreshLive only reaches voices of the SELECTED instrument for the per-instrument groups (its `mine` test), so driving it once from the live state left the other instrument's sounding notes frozen at whatever the source was worth when they were born. The value jumped at each note-on and then sat there, which is exactly what a manually-set knob sounds like. NOT the `held` path either: that one skips any voice whose t0 is not yet behind the audible step, which protects a note's attack from being rewritten -- right for step automation, wrong here, because with sixteenth notes every voice is "just started". Measured at 1.5 waveform rewrites per note against a hand's 9.6. The cost is real and it is the right trade: a routed flt.cut overwrites its own filter envelope sweep. You attached a source to that knob. */ var wav = (MODN % WAVEVERY) === 0; var nn, io, o; for (nn = 0; nn < 2; nn++){ io = pushIns(nn); o = pushModAt(0); if (o){ applyLive(); refreshLive(modGroups(o, wav) || {}); } popMod(o); popIns(io); } /* the display half, once, from the live state and OUTSIDE the substitution -- the painters go through dv() and are right wherever they are called from */ if (show){ o = pushMod(); popMod(o); for (k in (o || {})) if (KNOBS[k]) paintKnob(k); vizFollow(); } } /* Which groups a source may refresh on a note that is already sounding. flt.*, fm.*, env.s and env.r are AudioParams written with setTargetAtTime: they glide, they cannot click, and they run every pass. osc.wave and osc.duty are a PeriodicWave, and the only way to change one is to swap the oscillator's whole table under a preserved phase -- discontinuous by construction, measured up to 0.492 of full scale between ADJACENT tables at the thin end. At 62 passes a second that was a continuous crackle; a hand never did it because a drag stops and a source does not. So they are THROTTLED rather than dropped: one pass in WAVEVERY, which is about twelve swaps a second instead of fifty-eight. Enough to hear a slow LFO move inside one long note, a fifth of the clicks, and one constant to turn if the balance is wrong in the room. */ var WAVEVERY = 5; function modGroups(o, wav){ var g = null, id; for (id in o){ if (!wav && id.indexOf('osc.') === 0) continue; g = groupOf(id, g); } return g; } function scanGlock(){ GLOCK = 0; var sc = SCENES[scn], n, i, id, steps; for (n = 0; n < 2; n++){ /* BOTH lanes: a global lock on either instrument has to arm the scheduled write, and the un-selected one is not in STEPS */ steps = (n === INS) ? STEPS : (sc && sc.ins ? sc.ins[n].steps : null); if (!steps) continue; for (i = 0; i < 16; i++){ var lk = steps[i].lock; if (!lk) continue; for (id in lk) if (GLOBALP[id] && ARM[id]){ GLOCK = 1; return; } } } } /* Whether any step in the LIVE pattern holds a lock for this parameter. */ function hasLocks(id){ for (var i = 0; i < 16; i++){ var lk = STEPS[i].lock; if (lk && id in lk) return 1; } return 0; } function markKnob(id){ var el = KNOBS[id]; if (!el) return; el.classList.toggle('pl', !!LOCKABLE[id]); el.classList.toggle('arm', !!ARM[id]); el.classList.toggle('mod', !!ROUTE[id]); /* Locks stored on a lane that is switched OFF are kept on disk and ignored in sound -- deliberate, it is how you A/B a lane -- but nothing said so. The step cells mark a locked step whatever the lane is doing, and eff() shows the base, so "I recorded values, the steps show them, and nothing happens" was a state the app could reach without ever explaining itself. */ el.classList.toggle('lkq', !ARM[id] && !ROUTE[id] && hasLocks(id)); } function markAllKnobs(){ for (var id in P) markKnob(id); } function lockWrite(id, v){ var sc = SCENES[curScn]; if (!sc || !sc.ins) return; var st = sc.ins[INS].steps[cur]; if (!st) return; var fresh = !st.lock; (st.lock || (st.lock = {}))[id] = v; if (GLOBALP[id] && ARM[id]) GLOCK = 1; /* paintStep redraws the piano roll, so it must not run on every pointermove -- only the first lock on a step changes the cell's marker. */ if (fresh && sc === SCENES[scn]) paintStep(cur); } function clearLocks(id){ for (var i = 0; i < 16; i++){ var lk = STEPS[i].lock; if (!lk || !(id in lk)) continue; delete lk[id]; if (emptyObj(lk)) delete STEPS[i].lock; paintStep(i); } scanGlock(); markKnob(id); } function clearAllLocks(){ for (var i = 0; i < 16; i++) delete STEPS[i].lock; scanGlock(); } /* Shared by cloneScene and load: a lock map must never be aliased between two scenes, or editing one would silently edit the other. */ function copyLock(lk){ if (!lk) return undefined; var o = null, id; for (id in lk) (o || (o = {}))[id] = lk[id]; return o || undefined; } function parseLock(raw){ if (!raw || typeof raw !== 'object') return undefined; var o = null, id, x; for (id in raw){ if (!LOCKABLE[id] || !P[id]) continue; x = raw[id]; if (typeof x !== 'number' || !isFinite(x)) continue; (o || (o = {}))[id] = clamp(x, P[id][0], P[id][1]); } return o || undefined; } function fromNorm(id, n){ var d = P[id], lo = d[0], hi = d[1], u = d[3]; n = clamp(n, 0, 1); if (isLog(u)) return lo * Math.pow(hi / lo, n); /* was a verbatim copy of isStepped's unit list, which is one list too many: a new stepped unit added to one and forgotten in the other lands a knob that displays discrete values and returns continuous ones. */ if (isStepped(id)) return Math.round(lo + n * (hi - lo)); return lo + n * (hi - lo); } function toNorm(id, v){ var d = P[id], lo = d[0], hi = d[1], u = d[3]; if (isLog(u)) return clamp(Math.log(v / lo) / Math.log(hi / lo), 0, 1); return clamp((v - lo) / (hi - lo), 0, 1); } function fmt(id, v){ var u = P[id][3]; if (u === 'pct') return Math.round(v * 100) + ''; if (u === 'hz') return v >= 1000 ? (v / 1000).toFixed(1) + 'k' : Math.round(v) + ''; /* An LFO's own unit, because 'hz' rounds to whole numbers -- fine for a cutoff in the thousands, useless from 0.1 to 10: the bottom fifth of the knob printed "0", which reads as OFF, and 1.0 through 1.4 all printed "1". A rate knob that says 0 while a ten-second sweep runs is a knob nobody waits for. */ if (u === 'lhz'){ var t = (v < 1 ? v.toFixed(2) : v.toFixed(1)).replace(/\.?0+$/, ''); return t || '0.1'; } if (u === 'sec') return v < 1 ? Math.round(v * 1000) + 'm' : v.toFixed(2) + 's'; if (u === 'q') return Math.round(v) + 'dB'; if (u === 'div') return DIVS[v].n; if (u === 'alen') return ARPL[Math.round(v)].n; if (u === 'wave') return WLAB[Math.round(v)]; if (u === 'fmr') return FMRLAB[Math.round(v)]; if (u === 'idx') return v.toFixed(1); if (u === 'arp') return ARPLAB[Math.round(v)]; if (u === 'lfo') return LFOLAB[Math.round(v)]; if (u === 'rng' || u === 'scn') return String(Math.round(v)); return '' + v; } /* pitch: scale degree -> MIDI note */ function degToSemi(deg, sc){ var s = SCALES[sc], n = s.length; var oct = Math.floor(deg / n), idx = ((deg % n) + n) % n; return oct * 12 + s[idx]; } /* All the notes of a step, lowest first. */ /* One instrument of one scene, flattened for the read-only users -- the piano roll is the only one. `null` still means the live state, exactly as the scene argument used to. */ function insView(sc, nn){ if (!sc || !sc.ins) return null; var iv = sc.ins[nn]; return {v: iv.v, steps: iv.steps, oct: iv.oct, root: sc.root, scale: sc.scale}; } function mtof(midi){ return 440 * Math.pow(2, (midi - 69) / 12); } /* The step's own octave shift is applied HERE and nowhere else. This is the one function the step label, the keyboard dots, the piano roll and the scheduler all read, so putting it here is what makes them agree -- four separate additions would be four chances to disagree. */ function notesOf(i, iv){ var st = iv ? iv.steps[i] : STEPS[i]; var base = 48 + (iv ? iv.root : ROOT) + (iv ? iv.oct : OCT) * 12 + (st.shift | 0) * 12; var out = [], v = st.semis && st.semis.length ? st.semis : [0]; for (var k = 0; k < v.length; k++) out.push(base + v[k]); return out; } function noteOf(i, iv){ return notesOf(i, iv)[0]; } /* Normalises whatever a step carries into a sorted, capped, de-duplicated semitone list -- from `semis`, or from a pre-chord song's scale degree. */ function loadSemis(raw, scale){ var out = [], k, x; if (raw && Object.prototype.toString.call(raw.semis) === '[object Array]'){ for (k = 0; k < raw.semis.length; k++){ x = raw.semis[k]; if (typeof x !== 'number' || !isFinite(x)) continue; x = clamp(Math.round(x), -SEMI_MAX, SEMI_MAX); if (out.indexOf(x) < 0) out.push(x); } } else if (raw && typeof raw.deg === 'number' && isFinite(raw.deg)){ /* the pre-chord shape: one SCALE degree */ out.push(clamp(degToSemi(clamp(raw.deg | 0, -12, 12), SCALES[scale] ? scale : 'minor'), -SEMI_MAX, SEMI_MAX)); } if (!out.length) out.push(0); out.sort(function(a, b){ return a - b; }); return out.slice(0, POLY); } /* The nearest SCALE degree to a semitone offset, so an export can still carry a `deg` a pre-chord build understands. */ function semiToDeg(semi, scale){ var tab = SCALES[scale] || SCALES.minor, best = 0, bd = 1e9; for (var d = -12; d <= 12; d++){ var diff = Math.abs(degToSemi(d, scale) - semi); if (diff < bd){ bd = diff; best = d; } } return best; } function noteName(m){ return NAMES[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1); } function stepDur(){ return (60 / bpm) / 4; } function delaySec(){ return clamp(DIVS[V['dly.div']].b * 60 / bpm, 0.01, 3.9); } /* ================================================================ audio == */ var A = null; var LOOKAHEAD_MS = 25, HORIZON = 0.12; /* Backgrounded, setInterval is throttled to once per second, and a 1s tick filling a 0.12s horizon plays 120ms of audio and 880ms of silence. Schedule far enough ahead that the throttled tick still lands inside the last note. The price is that a knob move takes up to 2s to be heard -- while the app is off screen and nobody is touching it. */ var HIDDEN_HORIZON = 2.0; /* OscillatorNode offers no duty control: 'square' is fixed at 50% and there is no triangle/saw morph at all. Both shapes are built from their Fourier series instead, cached per 0.5% step because PeriodicWave objects are costly. PULSE: high for a fraction d of the cycle. At d = 0.5 the even terms vanish and it reduces exactly to a square wave. RAMP: rises to the peak at d, falls back over the rest. Its derivative is a two-level pulse, so integrating that pulse's series term by term gives a[k] = -q[k]/w, b[k] = p[k]/w. At d = 0.5 it is a triangle; as d -> 1 the coefficients tend to 2/(pi*k), i.e. a sawtooth. */ /* The grid stays FINE, and that is a measurement rather than a preference. I coarsened it first, reasoning that fewer table swaps meant fewer clicks -- and rendering the neighbouring tables offline showed the opposite: the worst step between neighbours is 0.492 of full scale at 100 steps, 1.150 at 32 and 1.490 at 16. Coarsening trades many small clicks for fewer big ones, which is worse, not better. So: finest grid, and do not swap on a sounding note at all -- see modGroups. */ var PWQ = 200; function oscWave(ctx, type, duty){ var q = Math.round(duty * PWQ); var key = type + ':' + q; if (A && A.pw && A.pw[key]) return A.pw[key]; duty = q / PWQ; var N = 64, re = new Float32Array(N), im = new Float32Array(N), k, w, wd; re[0] = 0; im[0] = 0; /* kill DC: neither shape is centred */ if (type === 1){ var d = clamp(duty, 0.002, 0.998), sc = d * (1 - d); for (k = 1; k < N; k++){ w = 2*Math.PI*k; wd = w*d; re[k] = -4 * (1 - Math.cos(wd)) / (w*w*sc); im[k] = 4 * Math.sin(wd) / (w*w*sc); } } else { var dp = clamp(duty, 0.02, 0.98); for (k = 1; k < N; k++){ w = 2*Math.PI*k; wd = w*dp; re[k] = 2 * Math.sin(wd) / (Math.PI*k); im[k] = 2 * (1 - Math.cos(wd)) / (Math.PI*k); } } var pw = ctx.createPeriodicWave(re, im); if (A && A.pw) A.pw[key] = pw; return pw; } /* There is no reverb node in Web Audio -- ConvolverNode just convolves with a buffer you supply. Decaying noise is the standard cheap impulse response; two independent channels give it stereo width. Rebuilding is milliseconds of work and allocates seconds of audio, so SIZE changes are debounced rather than run on every pointermove. */ function makeIR(ctx, seconds, decay){ var rate = ctx.sampleRate; var len = Math.max(1, Math.floor(rate * clamp(seconds, 0.05, 4))); var buf = ctx.createBuffer(2, len, rate); for (var ch = 0; ch < 2; ch++){ var d = buf.getChannelData(ch); for (var i = 0; i < len; i++){ d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); } } return buf; } var irTimer = 0; /* Cached per 0.05 s so a scene boundary is a lookup, never a multi-megabyte allocation. Capped because eight scenes plus live edits would otherwise accumulate; 16 entries is a few MB at most. */ function irFor(sz){ if (!A) return null; if (!A.ir) A.ir = {}; var key = Math.round(clamp(sz, 0.05, 4) * 20); if (!A.ir[key]){ var keys = Object.keys(A.ir); if (keys.length >= 16) delete A.ir[keys[0]]; A.ir[key] = makeIR(A.ctx, key / 20, 2.6); } return A.ir[key]; } function buildIR(){ if (A) A.conv.buffer = irFor(V['rev.size']); } function warmIR(){ for (var i = 0; i < SCENES.length && i < 8; i++) irFor(SCENES[i].v['rev.size']); } function scheduleIR(){ clearTimeout(irTimer); irTimer = setTimeout(buildIR, 150); } function softClipCurve(){ var n = 2048, c = new Float32Array(n), k = 1.05; for (var i = 0; i < n; i++){ var x = (i / (n - 1)) * 2 - 1; c[i] = Math.tanh(k * x) / k; } return c; } function initAudio(){ var Ctor = window.AudioContext || window.webkitAudioContext; if (!Ctor) { setHint('no Web Audio'); return; } var ctx = new Ctor(); var master = ctx.createGain(); var comp = ctx.createDynamicsCompressor(); /* safety limiter */ comp.threshold.value = -8; comp.knee.value = 3; comp.ratio.value = 20; comp.attack.value = 0.002; comp.release.value = 0.10; /* Final guarantee: the WaveShaper clamps anything beyond +-1 to the curve endpoint, so the output can never exceed tanh(k)/k regardless of what resonance + delay feedback pile up upstream. Unity slope at the origin keeps normal levels untouched. */ var shaper = ctx.createWaveShaper(); shaper.curve = softClipCurve(); shaper.oversample = '2x'; var send = ctx.createGain(); send.gain.value = 0; var delay = ctx.createDelay(4); var damp = ctx.createBiquadFilter(); /* tame the repeats */ damp.type = 'lowpass'; damp.frequency.value = 3400; var fb = ctx.createGain(); fb.gain.value = 0; var revSend = ctx.createGain(); revSend.gain.value = 0; var revDamp = ctx.createBiquadFilter(); revDamp.type = 'lowpass'; revDamp.frequency.value = 4200; var conv = ctx.createConvolver(); send.connect(delay); delay.connect(damp); damp.connect(fb); fb.connect(delay); delay.connect(master); revSend.connect(revDamp); revDamp.connect(conv); conv.connect(master); /* `out` used to sit here: a post-limiter trim that only the SENDING role touched, to make room for ultrasonic markers that bypassed the limiter. With the markers gone nothing reads it, so the chain ends at the shaper. The recorder tapped shaper UPSTREAM of it, which is why takes are unchanged by this. */ master.connect(comp); comp.connect(shaper); shaper.connect(ctx.destination); /* One bus per instrument, so each has a fader and a mute of its own. Each fans out to the master AND to both sends, which makes the sends POST-fader: pull VA down and its reverb goes with it. Wiring the fader before `master` only would have left the tail ringing at full while the channel went quiet -- fine for dub, wrong for a mute. A voice now makes ONE connection instead of three. */ var ins = [ctx.createGain(), ctx.createGain()]; for (var ii = 0; ii < 2; ii++){ ins[ii].connect(master); ins[ii].connect(send); ins[ii].connect(revSend); } /* Percussion has its own trim into the master, ahead of the limiter but past everything the synth voice does to itself. Dry, as it has always been: giving it sends would change the sound of every existing song. */ var drum = ctx.createGain(); drum.gain.value = P['mix.drm'][2]; drum.connect(master); A = {ctx:ctx, master:master, comp:comp, shaper:shaper, send:send, delay:delay, fb:fb, revSend:revSend, revDamp:revDamp, conv:conv, ins:ins, drum:drum, oh:null, pw:{}}; recLoad(); /* compile the capture worklet while it is free */ buildIR(); applyLive(); } /* Kept from the block that stood here, because it is not about sync: it reports the secure-context verdict for the motion sensors, the camera and the wake lock too, and it only feature-TESTS getUserMedia -- nothing in this file calls it for audio any more. */ /* One cause, three symptoms. The microphone, the motion sensors, the camera and the wake lock are every one of them gated on the page being a SECURE CONTEXT, and each of them currently explains itself only at the moment you poke it, on the status line, one at a time -- so "half the features are broken" looks like four faults instead of one address. Measured, desktop Chrome 152: https://anything secure, microphone granted http://localhost secure, microphone granted -- no certificate needed http://192.168.x.y NOT secure, and navigator.mediaDevices does not even EXIST, which is why the mic code has to test for the API and not just call it file:// secure, microphone granted on DESKTOP Chrome. Android is stricter with file:// and this is where a phone and a laptop stop agreeing. So this reads out the origin's verdict up front, rather than after four failed taps. */ function paintSecure(){ var e = document.getElementById('secstate'); if (!e) return; var p = (location.protocol || '').replace(':', '') || '?'; var ok = window.isSecureContext !== false; var mic = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); /* file:// says "secure" and then behaves differently per platform, so it is named rather than folded into a yes */ if (p === 'file') e.textContent = 'file \u00b7 ' + (mic ? 'mic ok' : 'no mic'); else if (ok && mic) e.textContent = p + ' \u00b7 secure'; else e.textContent = p + ' \u00b7 insecure: no mic, no sensors, may dim'; } /* ========================================================== percussion == Synthesised, not sampled: a sample table would dwarf the rest of the file. Each hit is built from the two ingredients a drum machine has always used -- a pitch-swept oscillator for the tuned parts and filtered noise for the rest. They bypass the synth's filter and envelope entirely and land on `master`, so the ADSR you dialled in for the lead cannot swallow the kick. */ function noiseBuf(ctx){ if (A && A.noise) return A.noise; var len = Math.floor(ctx.sampleRate * 0.5); var b = ctx.createBuffer(1, len, ctx.sampleRate), d = b.getChannelData(0); for (var i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; if (A) A.noise = b; return b; } function drumNoise(t, dur, type, freq, q, peak){ var ctx = A.ctx; var src = ctx.createBufferSource(); src.buffer = noiseBuf(ctx); /* a random offset into the buffer, so repeated hits are not identical */ var off = Math.random() * 0.4; var f = ctx.createBiquadFilter(); f.type = type; f.frequency.value = freq; f.Q.value = q; var g = ctx.createGain(); g.gain.setValueAtTime(peak, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); src.connect(f); f.connect(g); g.connect(A.drum); src.start(t, off, dur + 0.02); src.stop(t + dur + 0.02); return g; } function drumHit(row, t){ if (!A) return; var ctx = A.ctx, osc, g; if (row === 0){ /* kick */ /* MEASURED against the 80s records in music/, with the same Goertzel on both sides -- Axel F, Mammagamma, Chase -- rather than tuned by ear: body 20-70ms body 70-150ms -20dB decay Axel F 68 Hz 78 Hz 90 ms Mammagamma 55 Hz 60 Hz 115 ms this kick, before 70 Hz 45 Hz 100 ms The first window and the decay were already right. What was wrong is the THIRD column: the references SIT on their body note while this one kept falling, out of the bottom of every speaker it was played through. A drum machine kick is a note, not a glide. So the ramp lands at 62 Hz -- the middle of the measured 55-78 -- and lands EARLY, at 40ms, after which an AudioParam holds its last value and the note simply rings. Starting higher, at 190Hz, buys attack pitch and some midrange on the way down; that number is the textbook shape rather than a measurement, because resolving 60 Hz needs a window longer than the sweep itself. */ osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(190, t); osc.frequency.exponentialRampToValueAtTime(62, t + 0.04); g = ctx.createGain(); /* TWO STAGES, and the reason is the master limiter: this lane is the only one that arrives above the -8dB threshold, so peak is a currency it cannot spend. A transient down to half in 40ms and then a long ring to silence puts far more energy under the curve at the same peak, which is the only kind of "louder" available here. Tuned to keep the -20dB point near the measured 90-115ms rather than shortening it. */ g.gain.setValueAtTime(0.9, t); g.gain.exponentialRampToValueAtTime(0.45, t + 0.04); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.36); osc.connect(g); g.connect(A.drum); osc.start(t); osc.stop(t + 0.38); /* THE BEATER. A sine has no harmonics at all, and a phone speaker reproduces nothing at 62 Hz -- so on the device you are most likely holding, the body of this drum is inaudible and the attack is all there is. Before this there was only the click of the gain step itself, measured 12 dB under the body. drumNoise is the same helper the hats and the snare already use. Reasoned from physics, not measured: the references certainly have a beater, but it sits under their hats and snare in a full mix and cannot be isolated. */ drumNoise(t, 0.008, 'bandpass', 1800, 1.1, 0.34); } else if (row === 1){ /* snare: noise plus a little tuned body */ drumNoise(t, 0.17, 'highpass', 1400, 0.7, 0.5); osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(190, t); g = ctx.createGain(); g.gain.setValueAtTime(0.3, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.11); osc.connect(g); g.connect(A.drum); osc.start(t); osc.stop(t + 0.13); } else if (row === 2){ /* closed hat -- chokes any open one */ if (A.oh){ try { A.oh.gain.cancelScheduledValues(t); A.oh.gain.setTargetAtTime(0.0001, t, 0.005); } catch (e) {} A.oh = null; } drumNoise(t, 0.045, 'highpass', 7000, 0.8, 0.34); } else { /* open hat */ A.oh = drumNoise(t, 0.42, 'highpass', 6200, 0.8, 0.3); } } /* Does this cell sound on THIS lap? A divided step counts laps over the pattern, so "every 2nd" means it is heard on alternate times round, not on alternate step positions. */ function cellFires(r, i, pass){ if (!DRUMS[r] || !DRUMS[r][i]) return false; var rate = (DRATE[r] && DRATE[r][i]) | 0; if (rate >= 0) return true; return (((pass | 0) % (1 - rate)) + (1 - rate)) % (1 - rate) === 0; } function scheduleDrums(i, t){ if (!A || !DRUMS) return; var sd = stepDur(), now = A.ctx.currentTime, pass = PASS[scn] | 0; for (var r = 0; r < DRUMKIT.length; r++){ if (!cellFires(r, i, pass)) continue; var rate = (DRATE[r] && DRATE[r][i]) | 0; var mult = rate > 0 ? rate + 1 : 1; var base = t + ((DNUDGE[r] && DNUDGE[r][i]) | 0) * sd / 4; for (var k = 0; k < mult; k++){ var tt = base + k * sd / mult; /* A negative nudge can land before the scheduler got here -- the loop only runs to now+HORIZON. Clamping keeps the hit audible instead of dropping it silently; it can only bite on the step right after a clock resync. */ drumHit(r, tt < now ? now + 0.002 : tt); } } } /* ============================================================= recorder == A tap on the master chain, not a second render: what lands in the file is what came out of the speakers, live knob moves and all. MP3 is not on the table -- no browser ships an MP3 encoder, and embedding one would cost more than the rest of this file put together. MEASURED, rather than assumed, on Chrome 152 / Linux -- and the previous version of this comment was wrong about it: MediaRecorder audio/mp4 YES and it contains OPUS audio/mp4;codecs=mp4a.40.2 no -- no AAC encoder at all audio/webm;codecs=opus YES audio/webm;codecs=pcm YES lossless, but still a .webm audio/flac, audio/wav no WebCodecs AudioEncoder: opus YES -- and nothing else. flac, mp4a.40.2, pcm-s16 no So there is no AAC anywhere in Chromium, by either route: the `.m4a` this makes is Opus in an MP4, which most players take and some older ones do not. Firefox refuses `audio/mp4` outright, which is why the same code hands a desktop user a .webm there and an .m4a here. And FLAC cannot be encoded by any route the platform exposes -- it would need a hand-written encoder, roughly 250 lines of CRC-sensitive bit-packing, to save 40% over a WAV. Which leaves the PCM tap below as the one lossless option, and it is no longer only a fallback: it is a choice you can pick. It writes a WAV by hand, works through an AudioWorklet or a ScriptProcessor, and therefore runs anywhere at all. */ var TAPE = {on:0, ch:2, rate:44100, frames:0, buf:[], take:null, tailT:0, mode:'', t0:0, capT:0}; var TAPE_MAX_SEC = 300; /* a forgotten transport would otherwise grow until the tab dies */ /* Ordered best first. The bare `audio/mp4` is what Chrome answers to, and Android's own MIME table maps it -- which matters because a blob download from a file:// page can lose its filename and keep only the extension the type implies. The labels say lossy or lossless rather than naming a codec on purpose: the codec inside `audio/mp4` is AAC on Safari and Opus on Chromium, so naming one would be a lie on the other. Container and fidelity are what you are actually choosing between. */ var TAKES = { m4a: {lab:'MP4 \u00b7 lossy', ext:'m4a', mime:'audio/mp4', type:'audio/mp4'}, webm: {lab:'WebM \u00b7 lossy', ext:'webm', mime:'audio/webm;codecs=opus', type:'audio/webm'}, wav: {lab:'WAV \u00b7 lossless', ext:'wav'} /* the PCM tap: no MediaRecorder */ }; var TAKEORD = ['m4a', 'webm', 'wav']; /* the tap cycles in this order */ var TAKEKEY = 'synthi.take'; /* '' means "whatever is best and available", which is what every version before this one did -- so an existing user's takes do not change format under them until they ask for something else. */ var TKEY = ''; try { var _t = localStorage.getItem(TAKEKEY); if (TAKES[_t]) TKEY = _t; } catch (e) {} /* A route is only offered if the browser will really encode it. The PCM tap needs no encoder, so it is the one entry that is always available -- which is also what makes it the honest bottom of the fallback chain. */ function takeOK(k){ var t = TAKES[k]; if (!t) return false; if (!t.mime) return true; return !!(window.MediaRecorder && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t.mime)); } function takeRoute(){ if (TKEY && takeOK(TKEY)) return TKEY; for (var i = 0; i < TAKEORD.length; i++) if (takeOK(TAKEORD[i])) return TAKEORD[i]; return 'wav'; } function setTake(k){ if (!TAKES[k] || k === TKEY) return; TKEY = k; try { localStorage.setItem(TAKEKEY, k); } catch (e) {} paintTake(); } /* Reads out what you will actually get, not just what you asked for: a choice the browser cannot honour says so here rather than at the moment you press SAVE and find the wrong extension on the file. */ function paintTake(){ var e = document.getElementById('tkstate'); if (!e) return; var want = TKEY || takeRoute(), got = takeRoute(); var s = TAKES[got].lab; if (want !== got) s += ' (no ' + want + ' here)'; e.textContent = s; } /* ---- MediaRecorder path ------------------------------------------------- */ function mrStart(pick){ try { if (!A.mrDest){ A.mrDest = A.ctx.createMediaStreamDestination(); A.shaper.connect(A.mrDest); /* a tap; the audible path is untouched */ } TAPE.buf = []; TAPE.pick = pick; var mr = new MediaRecorder(A.mrDest.stream, {mimeType: pick.mime, audioBitsPerSecond: 160000}); mr.ondataavailable = function(e){ if (e.data && e.data.size) TAPE.buf.push(e.data); }; mr.onstop = function(){ mrDone(); }; A.mr = mr; mr.start(); return true; } catch (e) { return false; } } function mrDone(){ var blob = TAPE.buf.length ? new Blob(TAPE.buf, {type: TAPE.pick.type}) : null; TAPE.buf = []; takeReady(blob && blob.size ? {kind:'blob', blob:blob, ext:TAPE.pick.ext, sec:TAPE.dur, bytes:blob.size} : null); } /* ---- PCM tap: the fallback ---------------------------------------------- */ /* The worklet converts to Int16 on the audio thread and posts a block every 8192 frames, transferring the buffer rather than copying it. -32768..32767 is not symmetric, so the two signs scale differently. */ var TAP_SRC = 'class T extends AudioWorkletProcessor{' + 'constructor(){super();this.on=0;this.n=0;this.b=new Int16Array(16384);' + 'this.port.onmessage=function(e){this.on=e.data|0;' + 'if(!this.on){this.f();this.port.postMessage(0);}}.bind(this);}' + 'f(){if(!this.n)return;var o=this.b.slice(0,this.n*2);' + 'this.port.postMessage(o,[o.buffer]);this.n=0;}' + 'process(ip){var a=ip[0];if(this.on&&a&&a.length){' + 'var L=a[0],R=a[1]||a[0],i,l,r;' + 'for(i=0;i<L.length;i++){l=L[i];r=R[i];' + 'l=l<-1?-1:l>1?1:l;r=r<-1?-1:r>1?1:r;' + 'this.b[this.n*2]=l<0?l*32768:l*32767;' + 'this.b[this.n*2+1]=r<0?r*32768:r*32767;' + 'if(++this.n>=8192)this.f();}}return true;}}' + 'registerProcessor("synthi-tap",T);'; function tapPush(pcm){ if (!TAPE.on || !pcm || !pcm.length) return; TAPE.buf.push(pcm); TAPE.frames += pcm.length / TAPE.ch; if (TAPE.frames >= TAPE.rate * TAPE_MAX_SEC) recStop(1); } /* Kicked off from initAudio so the module is compiled long before anyone can press play. It used to skip this whenever MediaRecorder could encode something, on the grounds that the PCM path was only a fallback. WAV is a choice now, so the tap has to be ready whichever route is picked -- and it costs one Blob URL and a module compile to have it. */ function recLoad(){ if (!A || A.tapP) return; if (!A.ctx.audioWorklet || !window.AudioWorkletNode) return; var url = URL.createObjectURL(new Blob([TAP_SRC], {type:'text/javascript'})); A.tapP = A.ctx.audioWorklet.addModule(url).then(function(){ URL.revokeObjectURL(url); return 1; }, function(){ URL.revokeObjectURL(url); return 0; }); } function recTap(){ if (!A || A.recNode) return; var ctx = A.ctx; var mute = ctx.createGain(); mute.gain.value = 0; /* a tap, never a sound */ mute.connect(ctx.destination); A.recMute = mute; if (A.tapP){ A.tapP.then(function(good){ if (!good || A.recNode) { if (!good) recTapSP(); return; } var node = new AudioWorkletNode(ctx, 'synthi-tap', {numberOfInputs:1, numberOfOutputs:1, outputChannelCount:[2]}); /* a number rather than a block means "flushed, nothing more coming" -- a plain timeout lost the race with the worklet's round trip and threw away the last partial block of every take */ node.port.onmessage = function(e){ if (typeof e.data === 'number') tapFinish(); else tapPush(e.data); }; A.shaper.connect(node); node.connect(mute); A.recNode = node; node.port.postMessage(TAPE.on ? 1 : 0); }); return; } recTapSP(); } /* Because the node feeds the muted sink rather than the destination, a late callback costs a buffer of the recording and nothing of the sound. */ function recTapSP(){ var ctx = A.ctx; var proc = ctx.createScriptProcessor(4096, 2, 2); proc.onaudioprocess = function(e){ if (!TAPE.on) return; var ib = e.inputBuffer, nf = ib.length, c, i; var out = new Int16Array(nf * TAPE.ch); for (c = 0; c < TAPE.ch; c++){ var d = ib.getChannelData(Math.min(c, ib.numberOfChannels - 1)); for (i = 0; i < nf; i++){ var v = d[i]; v = v < -1 ? -1 : v > 1 ? 1 : v; out[i * TAPE.ch + c] = v < 0 ? v * 32768 : v * 32767; } } tapPush(out); }; A.shaper.connect(proc); proc.connect(A.recMute); A.recNode = proc; } function tapArm(on){ if (A && A.recNode && A.recNode.port) A.recNode.port.postMessage(on ? 1 : 0); } /* ---- transport hooks ---------------------------------------------------- */ function recStart(){ if (!A) return; if (TAPE.tailT){ clearTimeout(TAPE.tailT); TAPE.tailT = 0; } if (TAPE.capT){ clearTimeout(TAPE.capT); TAPE.capT = 0; } TAPE.rate = A.ctx.sampleRate; TAPE.frames = 0; TAPE.buf = []; TAPE.take = null; /* a new run replaces the old take, visibly */ TAPE.t0 = A.ctx.currentTime; TAPE.dur = 0; /* The route is a choice now, not just "MediaRecorder or bust". A choice the browser cannot honour falls down the same order it always did, and paintTake says so on the line you picked it from. */ var k = takeRoute(), pick = TAKES[k]; if (pick.mime && mrStart(pick)){ TAPE.mode = 'mr'; /* no frame counter here, so the cap is a timer */ TAPE.capT = setTimeout(function(){ TAPE.capT = 0; recStop(1); }, TAPE_MAX_SEC * 1000); } else { TAPE.mode = 'pcm'; recTap(); tapArm(1); } TAPE.on = 1; paintSave(); } /* The tail is the point of the delay: stopping the tap on the same tick as the transport guillotines the reverb. Keep it open for as long as the tail can ring, capped so a 4s room does not hold SAVE hostage. */ function recTail(){ return Math.min(2.5, (FX_ON ? delaySec() * 2 : 0) + (REV_ON ? V['rev.size'] : 0) + 0.15); } function recStop(capped){ if (!TAPE.on || TAPE.tailT) return; if (TAPE.capT){ clearTimeout(TAPE.capT); TAPE.capT = 0; } var finish = function(){ TAPE.tailT = 0; TAPE.dur = A.ctx.currentTime - TAPE.t0; if (TAPE.mode === 'mr'){ TAPE.on = 0; if (A.mr && A.mr.state !== 'inactive') A.mr.stop(); /* onstop -> mrDone */ else mrDone(); return; } /* Flush the worklet and finalise only once it says it is done: tapPush drops anything arriving after TAPE.on clears, so finalising on a timer threw away the last partial block of every take. */ tapArm(0); TAPE.finT = setTimeout(tapFinish, 150); /* net, if there is no port to answer */ if (!(A.recNode && A.recNode.port)) { clearTimeout(TAPE.finT); tapFinish(); } }; TAPE.tailT = setTimeout(finish, capped ? 0 : Math.round(recTail() * 1000)); paintSave(); /* still busy: SAVE stays dark until done */ } /* Idempotent: whichever of the acknowledgement and the safety timer arrives first wins, and the other finds nothing left to do. */ function tapFinish(){ if (!TAPE.on || TAPE.mode !== 'pcm') return; if (TAPE.finT){ clearTimeout(TAPE.finT); TAPE.finT = 0; } var t = TAPE.frames ? {kind:'wav', rate:TAPE.rate, ch:TAPE.ch, frames:TAPE.frames, buf:TAPE.buf, ext:'wav', sec:TAPE.frames / TAPE.rate, bytes:44 + TAPE.frames * TAPE.ch * 2} : null; TAPE.buf = []; /* the take owns the chunks now */ takeReady(t); } function takeReady(t){ TAPE.on = 0; TAPE.take = t; /* No message: SAVE lighting up is the announcement, and recLabel still puts the length and size in its tooltip for anyone who wants the numbers. */ paintSave(); } function recLabel(t){ var sec = t.sec || 0, b = t.bytes || 0; /* a compressed take is often under a megabyte, where "0.0 MB" says nothing */ var size = b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.round(b / 1024) + ' KB'; return Math.floor(sec / 60) + ':' + ('0' + Math.floor(sec % 60)).slice(-2) + ', ' + size + ' ' + t.ext; } /* RIFF/WAVE, 16-bit PCM interleaved -- only reached on the fallback path, and the one audio container every tool on every platform opens. */ function wavBlob(t){ var bytes = t.frames * t.ch * 2, hdr = new ArrayBuffer(44); var v = new DataView(hdr), o = 0; var str = function(x){ for (var i = 0; i < x.length; i++) v.setUint8(o++, x.charCodeAt(i)); }; var u32 = function(x){ v.setUint32(o, x, true); o += 4; }; var u16 = function(x){ v.setUint16(o, x, true); o += 2; }; str('RIFF'); u32(36 + bytes); str('WAVE'); str('fmt '); u32(16); u16(1); u16(t.ch); u32(t.rate); u32(t.rate * t.ch * 2); u16(t.ch * 2); u16(16); str('data'); u32(bytes); /* the chunks go in as-is: Blob concatenates without a copy through JS */ return new Blob([hdr].concat(t.buf), {type:'audio/wav'}); } /* parameters that live on persistent nodes and follow the knobs immediately */ /* A muted channel is a gain of zero, not a disconnected node: the fader keeps its position, so unmuting returns to where you were rather than to unity. */ function mixLevel(id){ return MUTE[id] ? 0 : V[id]; } function applyLive(at){ if (!A) return; var t = (at === undefined) ? A.ctx.currentTime : Math.max(at, A.ctx.currentTime); A.master.gain.setTargetAtTime(mixLevel('vol'), t, 0.01); if (A.ins){ A.ins[0].gain.setTargetAtTime(mixLevel('mix.va'), t, 0.01); A.ins[1].gain.setTargetAtTime(mixLevel('mix.fm'), t, 0.01); } A.drum.gain.setTargetAtTime(mixLevel('mix.drm'), t, 0.01); A.send.gain .setTargetAtTime(FX_ON ? V['dly.mix'] : 0, t, 0.01); A.fb.gain .setTargetAtTime(V['dly.fb'], t, 0.01); A.delay.delayTime.setTargetAtTime(delaySec(), t, 0.05); A.revSend.gain.setTargetAtTime(REV_ON ? V['rev.mix'] : 0, t, 0.02); A.revDamp.frequency.setTargetAtTime(V['rev.damp'], t, 0.02); } /* octave offset of the k-th sub-note, walking the selected range */ /* The chord laid out across the octave range, lowest first -- the list an arpeggiator walks. With one note this is [n, n+12, ...], so UP yields n, n+12, n, ... which is exactly what the old octave-only arp produced. */ function arpNotes(notes, rng){ var out = [], o, m; for (o = 0; o < rng; o++) for (m = 0; m < notes.length; m++) out.push(notes[m] + 12 * o); return out; } function arpPick(seq, k, mode){ var L = seq.length; if (mode === 2) return seq[(L - 1) - (k % L)]; /* DOWN */ if (mode === 3) return seq[Math.floor(Math.random() * L)];/* RAND */ if (mode === 4){ /* UPDN */ /* Up and back down WITHOUT repeating the turning points, so the period is 2L-2 rather than 2L: 1 2 3 4 5 6 5 4 3 2, then round again. A one-note chord at range 1 makes that zero, hence the floor -- and % 0 is NaN, which would index the array with undefined and spawn a silent voice. */ var per = 2 * L - 2; if (per < 1) per = 1; var j = k % per; return seq[j < L ? j : per - j]; } return seq[k % L]; /* UP */ } /* seconds per arpeggiated note */ function arpLen(vv){ return (ARPL[Math.round((vv || V)['arp.len'])] || ARPL[2]).b * 60 / bpm; } /* A step fires one voice, or -- with ARP on -- one per 16th for as long as its duration lasts. The sub-notes TILE the duration, so total sounding time is unchanged and instantaneous polyphony stays about one voice per active step. */ /* The last chord each instrument actually spawned, which is what SLIDE glides from. tick() schedules in time order, so "the last thing this instrument spawned" IS the note before this one -- and that definition gets "the previous step was off" and "it was in the previous scene" for free, where looking at STEPS[i-1] would get both wrong. Cleared on start and stop, so step 1 of a fresh run has no predecessor and plays plainly. auditionNote deliberately does not write it: tapping the keyboard must not change what the sequencer slides from. */ var LASTN = [null, null]; function slideFrom(s, prev, m){ if (!s.sld || !prev || !prev.length) return 0; /* a chord slides note-for-note by index, falling back to its lowest -- any other rule needs voice-leading, which is a different feature */ var p = (prev[m] === undefined) ? prev[0] : prev[m]; return mtof(p); } function scheduleStep(i, t){ var s = STEPS[i]; if (!s.on || !A) return; var gate = Math.max(0.02, DURS[s.dur | 0] * stepDur() - 0.01); var mode = Math.round(V['arp.mode']); var notes = notesOf(i), m; var sm = {acc: !!s.acc, sl: SLIDE * stepDur()}; if (mode === 0){ var prev = LASTN[INS]; for (m = 0; m < notes.length; m++){ sm.from = slideFrom(s, prev, m); spawnVoice(notes[m], t, gate, sm); } LASTN[INS] = notes; return; } /* SLIDE is ignored while the ARP is on. The arp already walks the chord inside the step; gliding into every sub-note is a different feature and a worse one. Accent still applies -- to every sub-note, which is what an accented arpeggiated step should be. */ sm.from = 0; /* One note at a time, walking the chord. The loop is driven by time rather than by a sub-step count, so a triplet length that does not divide the step evenly is truncated at the boundary instead of overrunning it. */ var seq = arpNotes(notes, Math.round(V['arp.rng'])); var len = arpLen(V); var span = DURS[s.dur | 0] * stepDur(); var ng = Math.max(0.02, len - 0.01); for (var k = 0; k * len < span - 1e-6 && k < 256; k++) spawnVoice(arpPick(seq, k, mode), t + k * len, ng, sm); LASTN[INS] = notes; } /* Voices whose parameters can still be changed. A knob turn used to reach only the NEXT note, because everything below is baked in at note-on; these records let a change reach notes already sounding, and notes already scheduled inside the ~120ms lookahead but not yet started. */ var LIVE = []; var PEAK = 0.22; function reFilter(v, now){ if (!v.flt) return; var cut = Math.max(30, V['flt.cut']), Q = V['flt.res']; var pk = clamp(cut * Math.pow(2, V['flt.env'] * 4), 30, 18000); var f = v.flt.frequency; try { v.flt.Q.cancelScheduledValues(now); v.flt.Q.setTargetAtTime(Q, now, 0.01); f.cancelScheduledValues(now); if (v.t0 > now){ /* not started yet: re-lay the whole sweep, which is what removes the lookahead staleness on freshly scheduled notes */ f.setValueAtTime(cut, v.t0); f.exponentialRampToValueAtTime(pk, v.aEnd); f.exponentialRampToValueAtTime(cut, v.dEnd); } else { /* sounding: glide. Any remaining envelope sweep is abandoned. */ f.setTargetAtTime(cut, now, 0.02); } } catch (e) {} } /* Only when the table ACTUALLY CHANGES. setPeriodicWave on a running oscillator swaps its wave table under a preserved phase, so every call is a potential step in the output -- and it was being called for the same table over and over: measured 467 calls in three seconds under a slow LFO on a held note, 295 of them handing an oscillator the table it already had. That is the crackle, and it is why a hand on the knob never did it: a drag stops, an LFO does not. oscWave is cached, so identity is the whole test. */ function reWave(v){ if (v.mod) return; /* FM: the carrier is a sine, reFM has the job */ try { var wv = wsel(); if (wv === 0){ if (v.pw !== 'sine'){ v.osc.type = 'sine'; v.pw = 'sine'; } return; } var pw = oscWave(A.ctx, wv, V['osc.duty']); if (v.pw === pw) return; v.pw = pw; v.osc.setPeriodicWave(pw); } catch (e) {} } /* ---- frequency modulation --------------------------------------------- The index is defined as I = df/fm, so the peak deviation in Hz is the index times the MODULATOR's own frequency, not the note's. */ function fmRatio(){ return FMR[Math.round(V['fm.ratio'])] || 1; } function fmDev(freq, ratio){ return V['fm.idx'] * freq * ratio; } /* The VCA's envelope, rescaled to a deviation instead of a level: one envelope, two destinations, so brightness decays with loudness. That decaying index is the whole difference between a bell and an organ. Bypassed ENV gives a flat index, exactly as it gives a flat level. */ function fmEnv(g, dev, t, aEnd, dEnd, gEnd, rel, sus, env){ g.cancelScheduledValues(t); if (!env){ g.setValueAtTime(dev, t); return; } g.setValueAtTime(0, t); g.linearRampToValueAtTime(dev, aEnd); g.linearRampToValueAtTime(dev * sus, dEnd); g.setValueAtTime(dev * sus, gEnd); g.linearRampToValueAtTime(0, gEnd + rel); } /* Same three-way caution as reSustain: re-lay everything for a note still sitting in the lookahead, re-level one that is in its sustain, and for a note mid-attack or mid-decay only retune -- cancelling there would eat the attack. Retuning is safe at any moment; it is a pitch, not an envelope. */ function reFM(v, now){ if (!v.mod) return; var ratio = fmRatio(), dev = fmDev(v.freq, ratio), sus = V['env.s']; try { v.mod.frequency.cancelScheduledValues(now); if (v.t0 > now){ v.mod.frequency.setValueAtTime(v.freq * ratio, v.t0); fmEnv(v.mg.gain, dev, v.t0, v.aEnd, v.dEnd, v.gEnd, v.rel, sus, v.env); return; } v.mod.frequency.setTargetAtTime(v.freq * ratio, now, 0.01); if (!v.env){ v.mg.gain.cancelScheduledValues(now); v.mg.gain.setTargetAtTime(dev, now, 0.02); } else if (now >= v.dEnd && now < v.gEnd){ v.mg.gain.cancelScheduledValues(now); v.mg.gain.setTargetAtTime(dev * sus, now, 0.02); v.mg.gain.setValueAtTime(dev * sus, v.gEnd); v.mg.gain.linearRampToValueAtTime(0, v.gEnd + v.rel); } } catch (e) {} } function reSustain(v, now){ if (!v.env) return; /* gate mode has no sustain */ var susV = Math.max(0.0001, PEAK * V['env.s']); try { if (v.t0 > now){ v.vca.gain.cancelScheduledValues(v.t0); v.vca.gain.setValueAtTime(0, v.t0); v.vca.gain.linearRampToValueAtTime(PEAK, v.aEnd); v.vca.gain.linearRampToValueAtTime(susV, v.dEnd); v.vca.gain.setValueAtTime(susV, v.gEnd); v.vca.gain.linearRampToValueAtTime(0, v.gEnd + v.rel); } else if (now >= v.dEnd && now < v.gEnd){ /* in the sustain phase: re-level, then keep the tail intact */ v.vca.gain.cancelScheduledValues(now); v.vca.gain.setTargetAtTime(susV, now, 0.02); v.vca.gain.setValueAtTime(susV, v.gEnd); v.vca.gain.linearRampToValueAtTime(0, v.gEnd + v.rel); } /* during attack or decay: skipped -- cancelling would break the attack */ } catch (e) {} } function reRelease(v, now){ if (!v.env || now >= v.gEnd) return; /* tail already running */ var rel = V['env.r']; var susV = Math.max(0.0001, PEAK * V['env.s']); try { v.vca.gain.cancelScheduledValues(v.gEnd); /* keeps attack+decay intact */ v.vca.gain.setValueAtTime(susV, v.gEnd); v.vca.gain.linearRampToValueAtTime(0, v.gEnd + rel); v.rel = rel; v.stopT = v.gEnd + rel + 0.02; v.osc.stop(v.stopT); if (v.mod){ var dv = fmDev(v.freq, fmRatio()) * V['env.s']; v.mg.gain.cancelScheduledValues(v.gEnd); v.mg.gain.setValueAtTime(dv, v.gEnd); v.mg.gain.linearRampToValueAtTime(0, v.gEnd + rel); v.mod.stop(v.stopT); } } catch (e) {} } /* one bounded pass per animation frame, driven by which group of knobs moved. `held` narrows the pass to notes that are SOUNDING and still gated, which is what a step lock must reach and nothing else. LIVE holds three kinds of voice and only one of them is a legitimate target: held over from an earlier step t0 < curT <= now < gEnd -- the target started on THIS step t0 >= curT -- skip: it was already built from this step's lock, and reFilter would flatten the filter envelope sweep it is in the middle of in its release now >= gEnd -- leave it: the note has ENDED, and its tail keeps the character it died with in the lookahead t0 > now -- never touch: it already carries its OWN step's lock, and the re* family would re-lay it here the other instrument's v.ins !== INS -- never touch: its lane is its own, and autoFollow comes round for it separately The last one is the trap. tick() schedules up to HORIZON ahead, so one or two future notes are always in LIVE with their locks already resolved; a blanket refresh would stamp the current step's values over them and silently break every lock in the lane. The second is the subtler one, and measured: without it a lane of locks rewrote every note 16 ms after its own onset. Together the two mean the ordinary case -- 1/16 stabs under a full lane -- costs exactly nothing: each note is born correct and has passed its gate before the next step lands. A knob drag passes no `held` and so still reaches both the lookahead and the current note, which is right there: you turned the knob, you want it now. */ function refreshLive(groups, held){ if (!A || !LIVE.length) return; var now = A.ctx.currentTime, keep = []; for (var i = 0; i < LIVE.length; i++){ var v = LIVE[i]; if (v.stopT <= now) continue; /* prune, in case onended was missed */ keep.push(v); if (held && (v.ins !== INS || !(v.t0 < curT && now < v.gEnd))) continue; /* Which voices a change may reach depends on which parameter changed, and `groups` is the only thing that knows. flt.* is SHARED between the two instruments -- one filter, one setting -- so it reaches every voice. The rest are per-instrument, and may only ever reach their own. Without that split, a knob drag reached every live voice from whichever instrument happened to be selected: turning FM's SUSTAIN pulled a sounding VA note from 0.22 down to 0.022, and turning VA's WAVE re-shaped FM's carrier. That is what "changing FM makes VA forget its parameters" was -- not the stored values, which were always right, but the notes already in the air. The `held` path above has already narrowed to one instrument, so `mine` is always true there and automation behaves exactly as before. */ var mine = (v.ins === INS); if (groups.flt) reFilter(v, now); if (groups.osc && mine){ reWave(v); reFM(v, now); } /* waveform alone: what autoFollow has left to do now that everything schedulable is written from tick() instead */ if (groups.wav && mine) reWave(v); if (groups.sus && mine) reSustain(v, now); if (groups.rel && mine) reRelease(v, now); } LIVE = keep; } /* one disposable voice: osc -> filter -> VCA -> (dry, delay, reverb) */ /* `sm` is the STEP's modifiers -- {acc, from, sl}. It cannot be called `mod`: that name is already the FM modulator oscillator further down. */ function spawnVoice(midi, t, gate, sm){ if (!A) return; var ctx = A.ctx; var freq = mtof(midi); var acc = !!(sm && sm.acc); /* A glide needs somewhere to come FROM and somewhere to go: identical endpoints, or a missing predecessor, is a plain note rather than a zero-length ramp. exponentialRamp also refuses a zero endpoint. */ var from = (sm && sm.from > 0 && sm.from !== freq) ? sm.from : 0; var slT = (sm && sm.sl) || 0; var atk = V['env.a'], dec = V['env.d'], sus = V['env.s'], rel = V['env.r']; var aEnd = t + atk, dEnd = aEnd + dec; var gEnd = Math.max(dEnd, t + gate); var peak = 0.22 * (acc ? ACC_GAIN : 1); var susV = Math.max(0.0001, peak * sus); var osc = ctx.createOscillator(); var fm = (INS === 1), wv = dwsel(); if (fm || wv === 0) osc.type = 'sine'; else osc.setPeriodicWave(oscWave(ctx, wv, V['osc.duty'])); if (from){ osc.frequency.setValueAtTime(from, t); osc.frequency.exponentialRampToValueAtTime(freq, t + slT); } else osc.frequency.setValueAtTime(freq, t); var vca = ctx.createGain(), g = vca.gain, stopT; if (ENV_ON){ g.setValueAtTime(0, t); g.linearRampToValueAtTime(peak, aEnd); g.linearRampToValueAtTime(susV, dEnd); g.setValueAtTime(susV, gEnd); g.linearRampToValueAtTime(0, gEnd + rel); stopT = gEnd + rel + 0.02; } else { /* Bypassed: an envelope has no pass-through state, so the note becomes a plain gate. The 2 ms edges exist only to stop it clicking. */ g.setValueAtTime(0, t); g.linearRampToValueAtTime(peak, t + 0.002); g.setValueAtTime(peak, t + gate); g.linearRampToValueAtTime(0, t + gate + 0.004); stopT = t + gate + 0.03; } /* Web Audio does FM natively: an oscillator connected to a frequency AudioParam SUMS with the value already set on it, so the modulator's output IS a deviation in Hz. Nothing downstream changes -- the carrier goes into the same VCA, filter, delay and reverb the VA engine uses. */ var mod = null, mg = null, ratio = 1; if (fm){ ratio = fmRatio(); mod = ctx.createOscillator(); mod.type = 'sine'; /* The modulator has to travel WITH the carrier or the ratio breaks in the middle of the glide and the timbre lurches on every slide. */ if (from){ mod.frequency.setValueAtTime(from * ratio, t); mod.frequency.exponentialRampToValueAtTime(freq * ratio, t + slT); } else mod.frequency.setValueAtTime(freq * ratio, t); mg = ctx.createGain(); fmEnv(mg.gain, fmDev(freq, ratio), t, aEnd, dEnd, gEnd, rel, sus, ENV_ON); mod.connect(mg); mg.connect(osc.frequency); mod.start(t); mod.stop(stopT); } var chain = [osc, vca]; if (fm){ chain.push(mod, mg); } if (FLT_ON){ var flt = ctx.createBiquadFilter(); flt.type = 'lowpass'; flt.Q.setValueAtTime(V['flt.res'], t); var cut = Math.max(30, V['flt.cut']); /* the other half of an accent: the sweep goes further up, which is what the ear actually hears as emphasis */ var pk = clamp(cut * Math.pow(2, (V['flt.env'] + (acc ? ACC_ENV : 0)) * 4), 30, 18000); flt.frequency.setValueAtTime(cut, t); flt.frequency.exponentialRampToValueAtTime(pk, aEnd); flt.frequency.exponentialRampToValueAtTime(cut, dEnd); osc.connect(flt); flt.connect(vca); chain.push(flt); } else { osc.connect(vca); } /* Just the instrument's own bus: it is what fans out to the master and the two sends, so the sends are post-fader and the mute reaches them. */ vca.connect(A.ins[INS]); osc.start(t); osc.stop(stopT); var rec = {osc:osc, flt:(FLT_ON ? flt : null), vca:vca, t0:t, aEnd:aEnd, dEnd:dEnd, gEnd:gEnd, rel:rel, env:ENV_ON, stopT:stopT, mod:mod, mg:mg, freq:freq, ins:INS}; LIVE.push(rec); osc.onended = function(){ var k = LIVE.indexOf(rec); if (k >= 0) LIVE.splice(k, 1); for (var c = 0; c < chain.length; c++){ try { chain[c].disconnect(); } catch (e) {} } }; } /* ============================================================ transport == Two clocks: setInterval only *schedules* ahead of ctx.currentTime; the playhead is drawn from rAF against the same audio clock. */ var playing = false, timer = null, nextT = 0, dq = [], cur = -1, curScn = 0; var curT = 0; /* audio time the audible step began */ var SPIN_OK = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); function setSpin(deg){ var g = document.querySelector('.cmk-spin'); /* +90 keeps the arrow pointing right at rest, where it reads as play */ if (g) g.style.transform = 'rotate(' + (90 + deg).toFixed(2) + 'deg)'; } function tick(){ if (!A) return; /* Also from here, not only from draw(): rAF stops when the page is hidden and an LFO that froze on a backgrounded phone would be the same bug entry 89 already recorded for the parameter locks. */ if (document.hidden) modTick(); var now = A.ctx.currentTime; /* A leader used to broadcast its marker grid from here even while stopped. Nothing broadcasts a clock any more -- `sync` is a pulse, not a grid -- so `playing` is once again the only reason this function does anything. */ if (!playing) return; if (nextT < now - 0.5) { nextT = now + 0.02; } /* clock ran away: resync */ /* draw() drains dq and rAF does not run while hidden, so without this the queue grows for as long as the app is backgrounded. Keep the step that is currently sounding: draw() reads it to find the playhead on return. */ while (dq.length > 1 && dq[1].t <= now) dq.shift(); var horizon = now + (document.hidden ? HIDDEN_HORIZON : HORIZON); var guard = 0; var total = 16 * nScn(); while (nextT < horizon && guard++ < 64){ var want = HOLD ? scn : Math.floor(gIdx / 16) % nScn(); if (want !== schedScn){ switchTo(want, nextT); schedScn = want; markSceneDirty(); } /* DUE? Apply before this step is scheduled, so the boundary step itself gets the new key. Not setKey(): that paints, and the rule over repaintScene is that a scene boundary "must go through the same rAF batching as knob drags -- never fire synchronously from tick()". So the globals here and the repaint through markSceneDirty, which is exactly how the scheduler already announces a scene change. */ /* NEAREST step, not "at or after". The announced instant and this device's own boundary agree only to within the phase error and the clock conversion -- a few milliseconds -- and requiring `nextT >= ctx` turned a one-millisecond shortfall into a whole step of lateness: measured 9 of 9 boundaries one step late on loopback with the strict test. Half a step of tolerance snaps to the step the announcement meant. */ if (SYNC.keyQ && nextT + stepDur() / 2 >= SYNC.keyQ.ctx){ ROOT = SYNC.keyQ.root; if (SCALES[SYNC.keyQ.scale]) SCALE = SYNC.keyQ.scale; SYNC.keyQ = null; stashScalars(); save(); markSceneDirty(); } /* ANNOUNCE the key LOOK steps before it applies, and say when. The margin has to cover the listener's own lookahead, not just the network: both devices schedule about HORIZON ahead on a 25ms tick, so a frame sent at the master's boundary loses the race to the listener's own scheduler most of the time whatever the latency. Two steps, or 250ms, whichever is more -- long enough to win, short enough that a tempo change in between cannot move the real boundary far from the `at` predicted here. */ if (SYNC.role === 'lead' && !SYNC.quiet){ var look = Math.max(2, Math.ceil(0.25 / stepDur())); var fsc = HOLD ? scn : Math.floor(((gIdx + look) % total) / 16); var fk = SCENES[fsc]; if (fk){ var tag = fk.root + '/' + fk.scale; if (tag !== SYNC.keySent){ SYNC.keySent = tag; syncSend({t:'key', root:fk.root, scale:fk.scale, at: atServer(nextT + look * stepDur())}); } } } var st = gIdx % 16; /* THE pulse. gIdx runs over the whole chain, so zero is the top of the pattern -- the one instant that means the same thing on every device, and therefore the only one worth aligning on. A scene message is not needed for the same reason: aligning here aligns the scene too. Sent when step 0 SOUNDS, not when it is scheduled, and that distinction is the whole accuracy of this feature. tick() runs up to HORIZON (120ms) ahead of the audio clock, so the first cut -- which sent the pulse from right here -- put a joining device up to three steps early: measured at 120bpm, the room on step 4 while the device that had just joined was on step 1. A timer and not draw(), even though draw() already knows when a step becomes audible: it rides rAF, which stops dead on a backgrounded phone, and that is exactly when the rest of the room still needs the pulse. */ if (gIdx === 0){ var lead = (nextT - now) * 1000; /* WHEN it will sound, on the shared clock, computed here rather than observed in the timeout below -- a setTimeout fires when it fires, and its jitter would go straight into the phase error. Every device stores this, not just the one that sends: a listener needs its own top to have anything to compare the master's against. */ SYNC.top = atServer(nextT); var frame = {t:'sync', at: SYNC.top, n: total}; /* The deferred send STAYS. A device that is armed and not yet playing starts when this frame arrives, and the comment above is the scar from sending it early. The stamp is exact either way. */ if (lead > 4) setTimeout(function(){ syncSend(frame); }, lead); else syncSend(frame); } /* how far ahead of the audio clock this step is, for srcAt() below */ var mAhead = (nextT - now) + (performance.now() - MODT) / 1000; /* Locks are swapped into V for the duration of the schedule call, so the voice code below needs no knowledge of them. The global ones are not read per voice, so they need a scheduled write -- run on EVERY step, not just locked ones, because that is what restores the base value again. */ /* Both instruments, every step. pushIns swaps one instrument's pattern and parameters into the live state for the length of the call, so everything below -- scheduleStep, spawnVoice, notesOf, arpNotes -- stays unaware that instruments exist, exactly as pushLock keeps it unaware of locks. The GLOBAL locks are written twice here, once per instrument, and the second wins: the delay and the reverb are shared, so two lanes automating them on the same step is a fight with no correct answer. */ for (var ii = 0; ii < 2; ii++){ var io = pushIns(ii); var lkO = pushLock(STEPS[st].lock); /* INSIDE pushIns, and that is the whole point. spawnVoice reads V and runs from here, so the modulation has to be in V when it does -- but pushed OUTSIDE this loop it was immediately overwritten by pushIns for every instrument except the selected one, because osc.* and env.* are per-instrument. Measured: the visible instrument's notes took 15 distinct duties from an LFO and the hidden one's took 1. It also has to come after pushLock, so a lock still wins over a source -- though the two can never meet, since attaching clears the arm. */ var mo = pushModAt(mAhead); if (GLOCK) applyLive(nextT); scheduleStep(st, nextT); autoSched(nextT); /* held notes follow the lane, AT nextT */ popMod(mo); popLock(lkO); popIns(io); } scheduleDrums(st, nextT); /* outside: drums belong to no instrument */ /* a lap is one pass over this scene's sixteen steps, counted per scene so a chained scene divides against its own visits rather than the chain's */ if (st === 15) PASS[want] = (PASS[want] | 0) + 1; dq.push({i:st, t:nextT, sc:want}); /* THE one line the phase loop owns. nextT is a pure accumulator -- nothing in the file recomputes it from bpm * gIdx -- so scaling its increment IS a rate change, and it needs no coordination with anything. Not stepDur() itself, which six other callers read for gate, slide, arp span, drum nudge and ratchet, the spinner and the audition; and not bpm, which setBpm rounds to an integer and clamps 40..220 and therefore cannot hold a 0.3% trim. The displayed tempo stays the master's. */ nextT += stepDur() * SYNC.rate; gIdx = (gIdx + 1) % total; } } function start(){ if (playing) return; if (!A) initAudio(); if (!A) return; var go = function(){ playing = true; gIdx = 0; schedScn = -1; dq.length = 0; /* Same hazard the change memories two lines down document: a key announced or queued by the previous run would otherwise fire into this one, or suppress the first announcement of it. */ SYNC.keyQ = null; SYNC.keySent = ''; /* both change memories, or the first step of a new run is compared against the last value the previous run scheduled and silently skipped */ ASLAST = [{}, {}]; AULAST = [{}, {}]; LASTN = [null, null]; /* nothing to slide from on step one */ PASS = []; /* every run starts on lap zero */ curT = 0; setSpin(0); /* every run starts upright, in phase */ recStart(); /* every run is a take */ wakeOn(); /* a no-op if the lock is already held */ drawKeys(); ensureScenes(); warmIR(); var now0 = A.ctx.currentTime; /* One start time, the same for everybody, with no clock arithmetic behind it. What used to be here was three branches -- join your own marker grid, wait out a 0.30s lead-in so followers could schedule the same downbeat, or just start -- plus a follower path that derived its step index and its scene from the leader's measured downbeat. None of it is needed. Alignment is not computed any more, it is WAITED FOR: a device that joins a running room arms itself and starts on the next `sync`, which is somebody's step 0. So starting is just starting. */ nextT = now0 + 0.06; SYNC.armed = 0; /* starting by any route disarms the wait */ /* !quiet: an incoming `start` arrives through this same function */ if (!SYNC.quiet) syncSend({t:'start'}); if (timer) { clearInterval(timer); timer = null; } tick(); timer = setInterval(tick, LOOKAHEAD_MS); var b = document.getElementById('play'); b.setAttribute('aria-pressed', 'true'); b.setAttribute('aria-label', 'Stop'); /* the mark carries no text */ requestAnimationFrame(draw); }; /* iOS/Android both require resume() from inside the gesture */ var r = A.ctx.resume(); if (r && r.then) r.then(go, go); else go(); } function stop(){ playing = false; LASTN = [null, null]; recStop(0); /* finalises after the tail has rung out */ if (timer) { clearInterval(timer); timer = null; } SYNC.armed = 0; SYNC.keyQ = null; SYNC.keySent = ''; if (!SYNC.quiet) syncSend({t:'stop'}); /* `timer` used to be restarted here so a stopped leader kept broadcasting its marker grid. With nothing to broadcast it means only one thing again: the transport is running. */ dq.length = 0; curT = 0; setSpin(0); /* back to upright */ drawKeys(); /* back to showing the selected step */ padHead(-1); if (cur >= 0 && CELLS[cur]) CELLS[cur].classList.remove('cur'); cur = -1; vizFollow(); /* back to the base patch */ /* drawRoll only paints the playhead while `playing`, but nothing was repainting the roll on the way out -- so the bar stayed frozen on the canvas wherever the transport happened to stop. */ drawRoll(); var b = document.getElementById('play'); b.setAttribute('aria-pressed', 'false'); b.setAttribute('aria-label', 'Play'); } function draw(){ /* modTick has its own interval and tick() covers the hidden case, so calling it from here as well only ran it twice: MODN advanced at 120Hz instead of 62, which doubled the throttled wave-swap rate behind WAVEVERY's back. */ if (!playing) return; var now = A.ctx.currentTime, n = cur, sc = curScn, e; while (dq.length && dq[0].t <= now){ e = dq.shift(); n = e.i; sc = e.sc; curT = e.t; } if (n !== cur || sc !== curScn){ if (cur >= 0 && CELLS[cur]) CELLS[cur].classList.remove('cur'); cur = n; curScn = sc; if (cur >= 0 && CELLS[cur]) CELLS[cur].classList.add('cur'); /* A held knob latches every step it crosses, not only the one it was grabbed on -- otherwise holding it still would record a single step. */ for (var rid in REC) lockWrite(rid, V[rid]); for (var aid in ARM) paintKnob(aid); drawKeys(); /* the keyboard follows the playhead */ padHead(cur); /* and so do the pads */ vizFollow(); /* graphs track the step being heard */ autoFollow(); /* and BOTH instruments' notes track their lanes */ drawRoll(); /* step change only, not per frame */ } /* One turn per scene, taken straight from the playhead: step index plus the fraction elapsed within it. Immune to bpm changes and to being stopped and restarted, which a CSS animation was not. Must come AFTER cur is updated above -- pairing the new step's start time with the previous index makes the angle lag a whole step on every boundary frame. */ if (SPIN_OK && cur >= 0){ var sd = stepDur(); var f = (now - curT) / sd; setSpin(((cur + (f < 0 ? 0 : f > 1 ? 1 : f)) / 16) * 360); } requestAnimationFrame(draw); } /* ================================================================= knobs == Vertical drag with pointer capture; shift = fine; dblclick = default. */ var KNOBS = {}; /* Checkmk states on the two controls that can actually run away: filter resonance into self-oscillation, delay feedback into runaway. */ var WARNK = {'flt.res':1, 'dly.fb':1}; function paintKnob(id){ var el = KNOBS[id]; if (!el) return; var val = eff(id); /* follows the locked value during playback */ var nv = toNorm(id, val); el.style.setProperty('--v', nv.toFixed(4)); /* Driven from outside: the knob says which source has it, in that source's own colour. This branch used to be a special case for the one hard-coded tilt target; the router made it the general one. Ahead of the WARN colours on purpose: which hand is on a control matters more than the level it happens to have reached, and a knob whose colour kept changing under a sweep would be unreadable. */ var msrc = ROUTE[id] && SRC[ROUTE[id]]; if (msrc){ el.style.setProperty('--arc', 'var(' + msrc.col + ')'); el.style.setProperty('--glow', 'var(' + msrc.col + '-glow)'); } else if (WARNK[id] && nv >= 0.62){ var crit = nv >= 0.85; el.style.setProperty('--arc', crit ? 'var(--crit)' : 'var(--warn)'); el.style.setProperty('--glow', crit ? 'var(--crit-glow)' : 'var(--warn-glow)'); } else { el.style.removeProperty('--arc'); el.style.removeProperty('--glow'); } var txt = fmt(id, val); el.querySelector('output').textContent = txt; el.setAttribute('aria-valuenow', txt); } /* Screen wake lock, held for as long as the page is on screen -- not only while the transport runs, which was the first cut and meant the screen dimmed on anyone who had not pressed play. It costs battery, and that is the point of asking for it. No manual release: the browser drops it when the page hides and visibilitychange takes it straight back. Requires neither a user gesture nor an audible page -- measured, not assumed. The failure is reported ONCE per load, because a browser that refuses it will refuse every time and the alternative was the silence that hid this. */ var WL = null, wlWhy = '', wlTold = 0; function wakeWhy(){ return wlWhy || (WL && WL.released !== true ? 'held' : 'asking'); } function wakeFail(why){ wlWhy = why; if (!wlTold){ wlTold = 1; setHint('screen may dim \u2014 ' + why, 1); } } function wakeOn(){ /* `released` rather than only the release event: the browser drops the lock by itself when the page hides, and relying on the event alone left a stale sentinel that made the re-acquire a no-op. */ if (WL && WL.released !== true) return; WL = null; if (!navigator.wakeLock || !navigator.wakeLock.request){ wakeFail(window.isSecureContext === false ? 'not a secure context' : 'no wake lock in this browser'); return; } try { var p = navigator.wakeLock.request('screen'); if (p && p.then) p.then(function(s){ WL = s; wlWhy = ''; if (s && s.addEventListener) s.addEventListener('release', function(){ WL = null; }); }, function(e){ wakeFail('wake lock ' + ((e && e.name) || 'refused')); }); } catch (e) { wakeFail('wake lock threw ' + ((e && e.name) || '')); } } /* ================================================================= tilt == An accelerometer measures acceleration; at rest that is gravity, and what makes a usable continuous controller is its DIRECTION -- the tilt. Raw acceleration is an impulse signal (shakes), not a knob. `deviceorientation` hands that direction over as a bounded angle from the same sensor, so there is no trig, no gravity to separate and no high-pass filter to tune. `devicemotion` below is the literal accelerometer, kept for devices that never synthesise orientation. */ /* n/g/b/src are a live readout, not state: when a sensor does nothing on a phone I cannot hold, the difference between "no events at all", "events but the angle never moves" and "the angle moves but nothing follows it" is the whole diagnosis, and there is no other way to see it from here. */ var TILT = {on:0, diag:0, free:0, seen:0, waitT:0, askT:0, gen:0, perm:'', n:0, g:0, b:0, src:''}; var TILT_SPAN = 40; /* degrees of tilt for the whole sweep */ var TILT_EASE = 0.15; /* one pole, ~70ms: sensor data is jittery */ /* The sensor no longer drives one hard-coded knob. It drives two SOURCES -- the two axes it actually has -- and the router decides where they go. There is still only ONE sensor and one permission behind them, so if it is refused both spots are dead together, which is why they share TILT.on. */ function tiltFeed(ax, deg){ if (!TILT.on) return; TILT.seen = 1; /* An event got here, and the quiet start never asked for one -- so on THIS browser the permission call is demonstrably unnecessary and a later tap must not pay for one. Chrome is exactly that case: it has requestPermission() and does not need it. */ TILT.free = 1; var s = SRC[ax === 'beta' ? 'tilty' : 'tiltx']; /* BETA is referenced to wherever you were holding the device when the sensor came on; GAMMA is absolute. That is not an inconsistency, it is the geometry: gamma is the left-right roll and reads zero both flat on a table and held upright, so absolute is what you want. Beta is the front-back pitch and reads about NINETY on a phone held normally -- against a 40 degree span it pins at maximum and never moves, which is the other half of "the sensor does not work". */ if (ax === 'beta'){ if (s.ref === undefined) s.ref = deg; deg -= s.ref; } var g = clamp(deg, -TILT_SPAN, TILT_SPAN) / TILT_SPAN; /* -1 .. 1 */ s.v += ((g + 1) / 2 - s.v) * TILT_EASE; /* one pole, ~70ms */ if (!s.live){ s.live = 1; paintSrcs(); } /* clear "no sensor" at once */ } /* Back to the shape that was working before any of this: GAMMA is read exactly as it was, and the second axis is strictly additional -- if beta is missing or useless, TILT X behaves precisely as the old single-axis version did. */ function tiltOri(e){ if (e.gamma === null || e.gamma === undefined) return; /* the old guard, verbatim */ TILT.n++; TILT.g = e.gamma; TILT.src = 'orientation'; tiltFeed('gamma', e.gamma); if (e.beta !== null && e.beta !== undefined){ TILT.b = e.beta; tiltFeed('beta', e.beta); } } function tiltMotion(e){ var a = e.accelerationIncludingGravity; if (!a || a.x === null || a.x === undefined) return; /* gamma from gravity: how far the device's x axis has tipped out of level -- the old computation, unchanged */ var g = Math.atan2(a.x, Math.sqrt(a.y * a.y + a.z * a.z)) * 180 / Math.PI; TILT.n++; TILT.g = g; TILT.src = 'motion'; tiltFeed('gamma', g); if (a.y !== null && a.y !== undefined){ var b = Math.atan2(a.y, Math.sqrt(a.x * a.x + a.z * a.z)) * 180 / Math.PI; TILT.b = b; tiltFeed('beta', b); } } function tiltPerm(){ var D = window.DeviceOrientationEvent, M = window.DeviceMotionEvent; /* Not iOS-only, as it turns out: Chrome ships requestPermission too, and it wants transient user activation. Without one it leaves the promise PENDING rather than rejecting -- which is why this needs the timeout below. */ if (D && typeof D.requestPermission === 'function'){ return D.requestPermission().then(function(r){ if (M && typeof M.requestPermission === 'function') return M.requestPermission().then(function(){ return r; }, function(){ return r; }); return r; }); } return Promise.resolve('granted'); } /* Everything about the sensor that is observable from here, short enough for one line. When tilt does nothing, this is the difference between "the browser blocked it", "the page is not a secure context" and "the API is missing" -- guessing between those from the other end of a phone is hopeless. */ function tiltWhy(){ var D = window.DeviceOrientationEvent, M = window.DeviceMotionEvent; return 'sec:' + (window.isSecureContext ? 'y' : 'N') + ' ori:' + (D ? 'y' : 'N') + ' mot:' + (M ? 'y' : 'N') + ' ask:' + (D && typeof D.requestPermission === 'function' ? 'y' : 'n') + ' perm:' + (TILT.perm || '-'); } function tiltStop(quiet){ TILT.gen++; /* a late permission answer must not arm */ if (TILT.waitT){ clearTimeout(TILT.waitT); TILT.waitT = 0; } if (TILT.askT){ clearTimeout(TILT.askT); TILT.askT = 0; } window.removeEventListener('deviceorientation', tiltOri); window.removeEventListener('devicemotion', tiltMotion); TILT.on = 0; paintTilt(); if (!quiet) save(); } /* Why these two spots are not simply always live the way the LFOs are, and how close to it they can get. The obstacle is DeviceOrientationEvent.requestPermission(). Chrome ships it as well as Safari -- measured, not assumed -- and it wants transient user activation: called at load it does not fail cleanly, it can leave its promise pending forever, and the spots would sit there saying "asking". But asking is only necessary if LISTENING does not work, and on Chrome it does: a secure origin gets `deviceorientation` for free. So there are two ways in. `quiet` (the automatic start at load) adds the listener and never asks: if events arrive, the spots are live before you touch anything and behave exactly like the two LFOs. If none arrive it gives up in 2.4s without a word -- a laptop has the API and no sensor behind it and must not greet every reload with an error. A TAP on a spot is the loud path, and the only one that may prompt, because a tap is the gesture the prompt requires. */ function tiltStart(quiet){ var say = function(m, bad){ if (!quiet) setHint(m, bad); }; if (!window.DeviceOrientationEvent && !window.DeviceMotionEvent){ say('no motion sensor API \u2014 ' + tiltWhy(), 1); return; } /* Sensors are gated on a secure context. Chrome counts file:// as one; a hardened fork may not, and then nothing will ever arrive however many permissions are granted. */ if (window.isSecureContext === false){ say('not a secure context \u2014 serve over https or localhost', 1); return; } /* Say something immediately: the permission call can take as long as a human takes to answer a prompt, and a button that does nothing meanwhile reads as broken. The generation counter stops an answer that arrives after you have given up and pressed it again from arming behind your back. */ var gen = ++TILT.gen; say('tilt: asking\u2026'); if (TILT.askT) clearTimeout(TILT.askT); TILT.askT = setTimeout(function(){ TILT.askT = 0; if (gen === TILT.gen && !TILT.on) say('tilt: no answer to the permission ask \u2014 tap again', 1); }, 15000); /* Stage zero: do not ask when nobody asked us to, and do not ask again once listening has been seen to work on its own. See above. */ (quiet || TILT.free ? Promise.resolve('granted') : tiltPerm()).then(function(r){ if (gen !== TILT.gen) return; /* toggled again while we were waiting */ if (TILT.askT){ clearTimeout(TILT.askT); TILT.askT = 0; } if (!quiet && !TILT.free) TILT.perm = r; if (r !== 'granted'){ say('tilt: permission ' + r + ' \u2014 ' + tiltWhy(), 1); paintTilt(); return; } TILT.on = 1; TILT.seen = 0; TILT.n = 0; TILT.src = ''; /* The running readout is for someone who ASKED -- it answers "which way is which" and "is anything arriving". A sensor that started on its own must not keep writing to the status line nobody pointed at it. */ TILT.diag = !quiet; /* a fresh reference every time it is switched on, so "hold it how you like and that is the middle" survives putting the phone down */ SRC.tilty.ref = undefined; modSync(); window.addEventListener('deviceorientation', tiltOri); /* Orientation is the better signal but not every device produces it. Give it a moment, then bring the raw accelerometer in behind it; if neither has spoken by then, say so rather than sitting there looking armed. */ TILT.waitT = setTimeout(function(){ TILT.waitT = 0; if (TILT.seen) return; window.addEventListener('devicemotion', tiltMotion); TILT.waitT = setTimeout(function(){ TILT.waitT = 0; if (!TILT.seen){ /* the interesting case: armed, permitted, and the browser still sends nothing -- which is what a privacy-hardened build does. The facts go in the message because they cannot be read off a phone otherwise. */ say('armed but no sensor events \u2014 check the browser\u2019s motion-sensor ' + 'permission. ' + tiltWhy(), 1); tiltStop(1); } }, 1200); }, 1200); paintTilt(); say('tilt on \u2014 drag a TILT spot onto any knob'); }, function(e){ if (gen !== TILT.gen) return; if (TILT.askT){ clearTimeout(TILT.askT); TILT.askT = 0; } say('tilt refused: ' + ((e && e.name) || 'error') + ' \u2014 ' + tiltWhy(), 1); }); } function tiltToggle(){ if (TILT.on){ tiltStop(0); setHint('tilt off'); } else tiltStart(0); } function tiltAuto(){ tiltStart(1); } /* listen; never ask */ /* Every twenty seconds' worth of ticks, and only over an empty line or its own previous reading -- it must not stamp on "loaded Chase". */ function tiltDiag(){ var h = document.getElementById('hint'); /* anything the TILT flow itself wrote is fair game -- the switch-on line included -- and nothing else is. */ if (h && h.textContent && h.textContent.indexOf('tilt') !== 0) return; setHint('tilt: ' + TILT.n + ' ' + (TILT.src || 'no') + ' events \u00b7 raw x ' + Math.round(TILT.g) + '\u00b0 y ' + Math.round(TILT.b) + '\u00b0 \u00b7 spots ' + Math.round(SRC.tiltx.v * 100) + '/' + Math.round(SRC.tilty.v * 100) + '%', !TILT.n); } function paintTilt(){ /* A refused or missing sensor leaves its two spots dead rather than sitting at half pretending, and a knob attached to a dead source keeps the value you set -- srcOf() checks `live`, so nothing has to be unrouted. */ if (!TILT.on){ SRC.tiltx.live = 0; SRC.tilty.live = 0; SRC.tilty.ref = undefined; } modSync(); paintSrcs(); } /* Pointer events arrive faster than the display refreshes (120 Hz+ on modern phones) and each one used to repaint a knob, write five AudioParams and redraw two canvases synchronously. Now they only mark work and one rAF does it once per frame. One frame of latency on a knob is imperceptible. */ var DIRTY = null, dirtyRAF = 0; /* Which live voices a parameter may reach. ONE copy, because the modulator has to do exactly what a hand does -- when it had its own copy, osc.duty went to the `wav` group instead of `osc`, which is a difference nobody would ever notice until it mattered. */ function groupOf(id, g){ if (id.indexOf('flt.') === 0) (g = g || {}).flt = 1; else if (id.indexOf('osc.') === 0) (g = g || {}).osc = 1; else if (id.indexOf('fm.') === 0) (g = g || {}).osc = 1; else if (id === 'env.s') (g = g || {}).sus = 1; else if (id === 'env.r') (g = g || {}).rel = 1; return g; } function flushDirty(){ dirtyRAF = 0; var ids = DIRTY; DIRTY = null; var groups = null; for (var id in ids){ paintKnob(id); vizFor(id); groups = groupOf(id, groups); } applyLive(); if (groups) refreshLive(groups); /* reach notes already sounding */ } /* A scene boundary changes every visible thing at once. It must go through the same rAF batching as knob drags -- never fire synchronously from tick(). */ var sceneRAF = 0; function repaintScene(){ sceneRAF = 0; for (var id in P){ paintKnob(id); markKnob(id); } paintIns(); paintSwitches(); paintOct(); paintHold(); paintScn(); paintDials(); paintAllSteps(); paintDrums(); /* the pattern belongs to the scene too */ buildKeys(); /* ROOT may have moved with the scene */ drawAll(); } function paintScn(){ var e = document.getElementById('scnv'); if (e) e.textContent = String(nScn()); } function paintHold(){ var hb = document.getElementById('hold'); if (!hb) return; var lk = hb.querySelector('.lk'), nu = hb.querySelector('.num'); hb.classList.toggle('on', HOLD); lk.setAttribute('aria-pressed', HOLD ? 'true' : 'false'); lk.textContent = HOLD ? '\uD83D\uDD12' : '\uD83D\uDD13'; lk.setAttribute('aria-label', HOLD ? 'Hold on' : 'Hold off'); nu.textContent = String(scn + 1); nu.setAttribute('aria-label', 'Scene ' + (scn + 1) + ' of ' + nScn() + ', tap for next'); } /* Tapping the number walks the chain. gIdx is realigned so the step position inside the bar is kept and the scheduler does not immediately switch back. */ function nextScene(){ var k = (scn + 1) % nScn(); switchTo(k); gIdx = k * 16 + (gIdx % 16); schedScn = k; markSceneDirty(); save(); } /* cells size off .seq's width, so match by measurement rather than by cqi */ function markSceneDirty(){ if (!sceneRAF) sceneRAF = requestAnimationFrame(repaintScene); } function touchParam(id){ if (!DIRTY) DIRTY = {}; DIRTY[id] = 1; if (!dirtyRAF) dirtyRAF = requestAnimationFrame(flushDirty); } function setNorm(id, n){ V[id] = fromNorm(id, n); touchParam(id); } function bindKnob(el, id){ var drag = false, lastY = 0, startY = 0, moved = false, held = false; var pid = null, holdT = 0; var HOLD = 500; /* ms to reset -- touch has no dblclick */ var rec = false, baseV = 0; /* latch recording state */ var lastTap = 0, tapV = 0; /* double-tap detection */ var TAP = 320; function resetToDefault(){ V[id] = P[id][2]; /* "put this knob back how it was" reasonably includes the automation. ARM is deliberately left alone, so you can re-record straight away. */ if (LOCKABLE[id]) clearLocks(id); paintKnob(id); applyLive(); vizFor(id); save(); } function cancelHold(){ if (holdT){ clearTimeout(holdT); holdT = 0; } } el.addEventListener('pointerdown', function(e){ if (pid !== null) return; /* a second finger must not hijack the drag */ pid = e.pointerId; drag = true; moved = false; held = false; sacc = 0; /* Recording seeds the drag from the value the knob is DISPLAYING, which during playback is the locked one, so grabbing it does not jump. */ if (ROUTE[id]) setHint(LABEL[id] + ' is driven by ' + SRC[ROUTE[id]].lab + ' \u2014 double-tap to take it back'); rec = !!(ARM[id] && playing && LOCKABLE[id]); if (rec){ baseV = V[id]; V[id] = eff(id); } lastY = startY = e.clientY; try { el.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); cancelHold(); holdT = setTimeout(function(){ holdT = 0; held = true; resetToDefault(); }, HOLD); }); var sacc = 0; var SPX = 18; /* px per step for discrete knobs */ el.addEventListener('pointermove', function(e){ if (!drag || e.pointerId !== pid) return; if (!moved && Math.abs(e.clientY - startY) > 8){ moved = true; cancelHold(); /* only a real drag records -- a still tap must not leave stray locks behind just because a step boundary passed under it */ if (rec) REC[id] = 1; } var dy = lastY - e.clientY; lastY = e.clientY; if (isStepped(id)){ /* Deriving discrete sensitivity from the normalised range meant the fewer values a knob had, the further you had to drag: WAVE has three, so it needed 83px per step. Fixed px per step instead. */ sacc += dy; var did = false; while (Math.abs(sacc) >= SPX){ var dir = sacc > 0 ? 1 : -1; sacc -= dir * SPX; V[id] = clamp(Math.round(V[id]) + dir, P[id][0], P[id][1]); did = true; } if (did) touchParam(id); } else { setNorm(id, toNorm(id, V[id]) + dy * (e.shiftKey ? 0.0012 : 0.006)); } if (rec && moved) lockWrite(id, V[id]); }); var up = function(e, tap){ if (!drag || e.pointerId !== pid) return; drag = false; pid = null; cancelHold(); try { el.releasePointerCapture(e.pointerId); } catch (x) {} if (rec){ delete REC[id]; V[id] = baseV; /* a recorded sweep must not move the patch */ rec = false; touchParam(id); scanGlock(); } if (tap && !moved && !held) tapped(); save(); }; function advance(){ /* wrapping, because WAVE has only three values and would otherwise dead-end after two taps */ var lo = P[id][0], hi = P[id][1]; V[id] = lo + ((Math.round(V[id]) - lo + 1) % (hi - lo + 1)); paintKnob(id); applyLive(); vizFor(id); } /* A double-tap arms or disarms parameter lock. The gesture was free: reset was bound twice, to dblclick and to long-press, and long-press is the one that also works on touch. On a stepped knob the first tap has already advanced the value, so it is put back when the second tap arrives. */ function tapped(){ var now = performance.now(); /* A routed knob's double-tap DETACHES rather than arming: taking a control back is what you want first, and arming from there is one more tap. */ if (ROUTE[id] && now - lastTap < TAP){ lastTap = 0; if (isStepped(id)){ V[id] = tapV; touchParam(id); } routeClear(id); setHint(LABEL[id] + ': back under your hand'); return; } if (LOCKABLE[id] && now - lastTap < TAP){ lastTap = 0; if (isStepped(id)){ V[id] = tapV; touchParam(id); } if (ARM[id]) delete ARM[id]; else { ARM[id] = 1; routeClear(id); } /* never both at once */ scanGlock(); /* arming gates playback, so GLOCK moves */ markKnob(id); paintKnob(id); vizFollow(); save(); /* The one thing this gesture never said: which way it went, and what that means for the locks already on the steps. */ setHint(LABEL[id] + (ARM[id] ? ': lane on \u2014 drag while it plays to record, locks will sound' : (hasLocks(id) ? ': lane off \u2014 its locks are kept but SILENT' : ': lane off')), !ARM[id] && hasLocks(id)); return; } lastTap = now; tapV = V[id]; if (isStepped(id)) advance(); } el.addEventListener('pointerup', function(e){ up(e, true); }); el.addEventListener('pointercancel', function(e){ up(e, false); }); /* wheel: one notch = one step for discrete params, 4% of range otherwise. Deltas are normalised because trackpads emit many small ones. */ var wacc = 0; el.addEventListener('wheel', function(e){ e.preventDefault(); var unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 120 : 1; var notches = clamp((e.deltaY * unit) / 100, -3, 3); if (isStepped(id)){ wacc -= notches; while (Math.abs(wacc) >= 1){ var dir = wacc > 0 ? 1 : -1; wacc -= dir; V[id] = clamp(Math.round(V[id]) + dir, P[id][0], P[id][1]); } touchParam(id); } else { setNorm(id, toNorm(id, V[id]) - notches * (e.shiftKey ? 0.01 : 0.04)); } save(); }, {passive:false}); el.addEventListener('keydown', function(e){ var st = e.shiftKey ? 0.002 : 0.02, n = toNorm(id, V[id]); if (e.key === 'ArrowUp' || e.key === 'ArrowRight') n += st; else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') n -= st; else if (e.key === 'Home') n = 0; else if (e.key === 'End') n = 1; else return; e.preventDefault(); setNorm(id, n); save(); }); } /* ============================================================ the two dials == KEY and SCALE, as knobs. They are NOT `P` parameters, and that is the whole design decision here. The obvious route is a `P` entry each, which would hand over buildKnob, fmt, save, export and clamping for nothing. But `P` values live in `V`, and `V` is swapped per scene and per instrument by pushIns / pushLock / pushMod. ROOT and SCALE are the SONG's, held as their own per-scene scalars and already in the export format as `root` and `scale`. Giving them a second home in `V` means two sources of truth for one fact, and this file has already paid that bill once -- MODB and baseOf exist because an LFO was writing the patch it was modulating (entry 103). Moving them INTO `P` as the single source instead would touch nineteen reads of ROOT and seventeen of SCALE plus the file format, which is a lot of risk for a widget. So: a small adapter over the knob's own CSS. A spec says how to count, read, write and name a value; the dial does the rest. `.knob` and `.dial` are the generic classes, so these look and glow exactly like their neighbours. They are deliberately not lockable and not routable: a parameter lock on the key would fight the notes it is transposing, and an LFO on it is a different feature with a different name. */ var DIALS = {}; /* Insertion order of SCALES is the dial's order; there was no explicit list. */ var SCALEORD = Object.keys(SCALES); /* One setter for both, because the key IS the pair -- and because everything downstream of a change is the same either way. The old select handlers had drifted apart: root's called drawSpec and buildKeys, scale's called neither, and neither told the scene it had changed. */ function setKey(root, scale, quiet){ ROOT = (((root | 0) % 12) + 12) % 12; if (SCALES[scale]) SCALE = scale; paintDials(); paintAllSteps(); /* step labels, keyboard highlight, roll */ buildKeys(); /* the keyboard is anchored to ROOT */ drawSpec(); /* The scene's stored copy used to lag by up to the 300ms save debounce, so anything reading SCENES[scn].root in that window saw the old key. */ stashScalars(); if (!quiet && !SYNC.quiet) syncSend({t:'key', root:ROOT, scale:SCALE}); save(); } var DIALSPEC = [ {key:'root', lab:'KEY', aria:'Key, root note', tip:'Key: the note every step is measured from, and it transposes the ' + 'pattern. Tap for the next note up, double-tap for the one below; drag ' + 'or use the arrow keys. It wraps -- there is no top or bottom.', count: function(){ return 12; }, get: function(){ return (((ROOT | 0) % 12) + 12) % 12; }, set: function(i){ setKey(i, SCALE); }, text: function(i){ return NAMES[i]; }, /* spoken, not printed: a screen reader saying "C hash" is not a note */ say: function(i){ return NAMES[i].replace('#', ' sharp'); }}, {key:'scale', lab:'SCALE', aria:'Scale', tip:'Scale: which keys light up as in-key. It never limits what you can ' + 'play. Tap for the next scale, double-tap for the previous one. It wraps.', count: function(){ return SCALEORD.length; }, get: function(){ var i = SCALEORD.indexOf(SCALE); return i < 0 ? 0 : i; }, set: function(i){ setKey(ROOT, SCALEORD[i]); }, text: function(i){ return SCALE_LAB[SCALEORD[i]] || SCALEORD[i]; }, say: function(i){ return SCALEORD[i]; }} ]; function dialStep(sp, dir){ var n = sp.count(); /* The wrap. A stepped KNOB wraps on a tap (advance) and CLAMPS on a drag, a wheel and an arrow key -- three separate clamp sites. A dial has no ends at all, which is what makes twelve semitones usable as a circle. */ sp.set((((sp.get() + dir) % n) + n) % n); } function paintDial(key){ var d = DIALS[key]; if (!d) return; var sp = d.spec, i = sp.get(), n = sp.count(); /* the same 0..1 the conic-gradient arc and the pointer both read */ d.el.style.setProperty('--v', (n > 1 ? i / (n - 1) : 0).toFixed(4)); d.el.querySelector('output').textContent = sp.text(i); d.el.setAttribute('aria-valuenow', i); /* aria-valuetext, which nothing else in this file sets and which is the price of taking a native <select> away: a slider that announces "6" where a listbox announced "F#" is a worse control, not a prettier one. */ d.el.setAttribute('aria-valuetext', sp.say(i)); } function paintDials(){ for (var k in DIALS) paintDial(k); } function bindDial(el, sp){ var pid = null, drag = false, moved = false, held = false, holdT = 0; var lastY = 0, startY = 0, sacc = 0, lastTap = 0; var HOLDMS = 500, TAPMS = 320, SPXD = 18; var cancelHold = function(){ if (holdT){ clearTimeout(holdT); holdT = 0; } }; el.addEventListener('pointerdown', function(e){ if (pid !== null) return; pid = e.pointerId; drag = true; moved = false; held = false; sacc = 0; lastY = startY = e.clientY; try { el.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); cancelHold(); holdT = setTimeout(function(){ holdT = 0; held = true; setKey(SEED.root, SEED.scale); /* the default, as a knob resets */ setHint('key: back to ' + NAMES[SEED.root] + ' ' + SEED.scale); }, HOLDMS); }); el.addEventListener('pointermove', function(e){ if (!drag || e.pointerId !== pid) return; if (!moved && Math.abs(e.clientY - startY) > 8){ moved = true; cancelHold(); } var dy = lastY - e.clientY; lastY = e.clientY; sacc += dy; while (Math.abs(sacc) >= SPXD){ var dir = sacc > 0 ? 1 : -1; sacc -= dir * SPXD; dialStep(sp, dir); } }); var up = function(e, tap){ if (!drag || e.pointerId !== pid) return; drag = false; pid = null; cancelHold(); try { el.releasePointerCapture(e.pointerId); } catch (x) {} if (!tap || moved || held) return; /* Tap UP, double-tap DOWN, and nothing waits 320ms to find out which. The first tap steps up immediately; if a second arrives inside TAPMS it steps down TWICE -- once to undo the first, once to mean itself. Net effect: exactly one down. The knobs use the same trick in reverse, restoring a saved value before arming a lock lane. */ var now = performance.now(); if (now - lastTap < TAPMS){ lastTap = 0; dialStep(sp, -2); return; } lastTap = now; dialStep(sp, 1); }; el.addEventListener('pointerup', function(e){ up(e, true); }); el.addEventListener('pointercancel', function(e){ up(e, false); }); var wacc = 0; el.addEventListener('wheel', function(e){ e.preventDefault(); var unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 120 : 1; wacc -= clamp((e.deltaY * unit) / 100, -3, 3); while (Math.abs(wacc) >= 1){ var dir = wacc > 0 ? 1 : -1; wacc -= dir; dialStep(sp, dir); } }, {passive:false}); el.addEventListener('keydown', function(e){ /* ONE press, ONE step. The knobs' keydown works in normalised space at 0.02 a press with no stepped branch, so an arrow key on WAVE needs thirteen presses to cross a value. A dial cannot afford that. */ var d = 0; if (e.key === 'ArrowUp' || e.key === 'ArrowRight') d = 1; else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') d = -1; else if (e.key === 'Home'){ e.preventDefault(); sp.set(0); return; } else if (e.key === 'End'){ e.preventDefault(); sp.set(sp.count() - 1); return; } else return; e.preventDefault(); dialStep(sp, d); }); } function buildDials(){ var host = document.getElementById('barsel'); if (!host) return; host.innerHTML = ''; DIALS = {}; DIALSPEC.forEach(function(sp){ var el = document.createElement('div'); el.className = 'knob'; el.setAttribute('role', 'slider'); el.setAttribute('aria-label', sp.aria); el.setAttribute('aria-valuemin', 0); el.setAttribute('aria-valuemax', sp.count() - 1); el.tabIndex = 0; el.title = sp.tip; el.innerHTML = '<label>' + sp.lab + '</label><div class="dial"></div><output></output>'; host.appendChild(el); DIALS[sp.key] = {el:el, spec:sp}; bindDial(el, sp); }); paintDials(); } function buildKnob(host, id){ var el = document.createElement('div'); el.className = 'knob'; el.setAttribute('data-id', id); /* the cord hit-tests for this */ el.setAttribute('role', 'slider'); el.setAttribute('aria-label', LABEL[id] + ' (' + id + ')'); el.tabIndex = 0; el.title = TIP[id] || LABEL[id]; el.innerHTML = '<label>' + LABEL[id] + '</label><div class="dial"></div><output></output>'; host.appendChild(el); KNOBS[id] = el; bindKnob(el, id); paintKnob(id); } /* What a display should show, for every parameter it draws. eff() already answers that -- the step lock while armed and playing, the source's value while one is attached, the base otherwise -- and paintKnob has used it all along. The CANVASES read V directly, which is only correct inside the modulator's substitution window, so a redraw from anywhere else drew the base value: 13 of 171 filter redraws came out at 1100Hz while the knob showed 16kHz, from draw(), from repaintScene, from stop() and from routeSet. That is the graphs jumping while the knobs sit still. So the painters read this instead and are correct from every caller. Deliberately NOT wsel(), which the AUDIO path uses: inside a pushLock window eff() would answer for the AUDIBLE step rather than the one being scheduled, which is the trap autoSched already documents. */ function dv(id){ return eff(id); } function dwsel(){ return Math.round(eff('osc.wave')); } function dfmRatio(){ return FMR[clamp(Math.round(eff('fm.ratio')), 0, FMR.length - 1)]; } /* ======================================================== visualisers == Four computed displays -- no FFT, no analyser, no animation loop. They redraw only when a parameter changes, so they never compete with the playhead rAF and cost nothing while the sequencer runs. */ var THEME = {}; /* viz-hi and viz-lo are the canvas ground, and they are the SAME everywhere -- see the note over them in :root. They replaced plate-hi/plate-lo, which followed the instrument tint on the theory that a graph inside a tinted panel would otherwise look pasted on. It looked worse: five graphs in three panels wearing two grounds and two trace colours, with nothing to learn from the difference. `ins-ac` went with them, so the instrument's lit colour now reaches its knobs and its readouts and stops at the glass. */ var VZKEYS = ['accent','accent2','now','edge','off','panel-hi','dim','faint','ink', 'viz-hi','viz-lo']; function readTheme(){ var cs = getComputedStyle(document.documentElement); for (var i = 0; i < VZKEYS.length; i++) THEME[VZKEYS[i]] = cs.getPropertyValue('--' + VZKEYS[i]).trim() || '#888'; } function rgba(hex, a){ var h = String(hex).replace('#',''); if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2]; var v = parseInt(h, 16); if (isNaN(v)) return 'rgba(136,136,136,' + a + ')'; return 'rgba(' + ((v>>16)&255) + ',' + ((v>>8)&255) + ',' + (v&255) + ',' + a + ')'; } /* size the backing store to device pixels so curves are crisp on phones */ /* Measuring here forced a synchronous layout on every single draw, i.e. twice per pointermove. Sizes now come from a cache refreshed only when the box actually changes (ResizeObserver, resize, orientationchange, drawAll). */ var CSIZE = {}; function measureCanvases(){ var dpr = Math.min(window.devicePixelRatio || 1, 2); var ids = ['vzWave','vzEnv','vzFilt','vzSpec','vzRoll']; for (var i = 0; i < ids.length; i++){ var c = document.getElementById(ids[i]); if (!c || !c.getContext) continue; var r = c.getBoundingClientRect(); if (r.width < 6 || r.height < 6){ CSIZE[ids[i]] = null; continue; } var w = Math.round(r.width * dpr), h = Math.round(r.height * dpr); if (c.width !== w) c.width = w; if (c.height !== h) c.height = h; CSIZE[ids[i]] = {g:c.getContext('2d'), w:r.width, h:r.height, dpr:dpr}; } } function fit(id){ var m = CSIZE[id]; if (!m) return null; m.g.setTransform(m.dpr, 0, 0, m.dpr, 0, 0); m.g.clearRect(0, 0, m.w, m.h); return m; } /* caption size tracks the canvas so it stays legible on a large display, with a 9px floor for phones */ function capFont(h){ return Math.max(9, Math.min(h * 0.10, 18)); } function capTop(h){ return capFont(h) + 5; } function plate(g, w, h, cap){ var bg = g.createLinearGradient(0, 0, 0, h); bg.addColorStop(0, rgba(THEME['viz-hi'], 0.95)); bg.addColorStop(1, rgba(THEME['viz-lo'], 0.98)); g.fillStyle = bg; g.fillRect(0, 0, w, h); var fs = capFont(h); g.font = '600 ' + fs.toFixed(1) + 'px ui-monospace,monospace'; g.fillStyle = rgba(THEME.faint, 0.95); g.fillText(cap, 4, fs + 2.5); } /* A wide translucent pass under a narrow bright one. Canvas shadowBlur is the single most expensive op here and mobile GPUs handle it badly; this looks near-identical with a slightly tighter falloff. */ function glow(g, color, lw){ g.lineJoin = 'round'; g.lineCap = 'round'; g.strokeStyle = rgba(color, 0.20); g.lineWidth = lw + 3.5; g.stroke(); g.strokeStyle = rgba(color, 0.35); g.lineWidth = lw + 1.6; g.stroke(); g.strokeStyle = color; g.lineWidth = lw; g.stroke(); } /* ---- 1. oscillator waveform ---- */ function waveAt(t, ph, duty){ ph = (ph % 1 + 1) % 1; if (t === 0) return Math.sin(ph * 2 * Math.PI); if (t === 1){ /* ramp: peak sits at duty */ var d = clamp(duty, 0.002, 0.998); return ph < d ? (-1 + 2*ph/d) : (1 - 2*(ph - d)/(1 - d)); } return ph < clamp(duty, 0.02, 0.98) ? 1 : -1; /* pulse */ } /* The FM carrier, written the way it is actually generated: phase modulation. Its instantaneous frequency reaches fc*(1 + I*r), which at the top of both knobs is far past what one pixel per sample can draw -- so the FM trace is sampled at four points per pixel, the way a vector scope overdraws. It renders as a dense band rather than as a lie. */ function fmAt(ph, r, I){ return Math.sin(2 * Math.PI * ph + I * Math.sin(2 * Math.PI * r * ph)); } function drawWave(){ var f = fit('vzWave'); if (!f) return; var g = f.g, w = f.w, h = f.h; var top = capTop(h), bot = h - 4, mid = (top + bot) / 2, amp = (bot - top) * 0.42; var fm = (INS === 1); plate(g, w, h, fm ? 'FM' : 'WAVE'); g.strokeStyle = rgba(THEME.edge, 0.9); g.lineWidth = 1; g.beginPath(); g.moveTo(0, mid); g.lineTo(w, mid); g.stroke(); var d = dv('osc.duty'), r = dfmRatio(), I = dv('fm.idx'); var dx = fm ? 0.25 : 1, wv = dwsel(); g.beginPath(); for (var x = 0; x <= w; x += dx){ var ph = (x / w) * 3; var y = mid - (fm ? fmAt(ph, r, I) : waveAt(wv, ph, d)) * amp; if (x === 0) g.moveTo(x, y); else g.lineTo(x, y); } glow(g, THEME.accent, 1.6); } /* ---- 2. ADSR envelope ---- */ function drawEnv(){ var f = fit('vzEnv'); if (!f) return; var g = f.g, w = f.w, h = f.h; plate(g, w, h, ENV_ON ? 'ADSR' : 'ADSR \u00b7 GATE'); var x0 = 3, x1 = w - 3, yb = h - 3.5, yt = capTop(h); if (!ENV_ON){ /* bypassed: a flat gate, not a curve */ var gx0 = x0 + (x1 - x0) * 0.06, gx1 = x0 + (x1 - x0) * 0.72; g.beginPath(); g.moveTo(x0, yb); g.lineTo(gx0, yb); g.lineTo(gx0, yt); g.lineTo(gx1, yt); g.lineTo(gx1, yb); g.lineTo(x1, yb); g.setLineDash([4, 3]); glow(g, THEME.faint, 1.4); g.setLineDash([]); return; } var a = dv('env.a'), d = dv('env.d'), su = dv('env.s'), r = dv('env.r'); var Y = function(v){ return yb - v * (yb - yt); }; /* FIXED QUARTERS, not real time. The x axis used to be proportional to a + d + hold + r, so a one-second release squashed a two-millisecond attack to nothing and three of the four knobs moved a picture you could not read. Each of A, D and R now owns AT MOST a quarter of the plot and fills its quarter at full range, scaled through toNorm() -- the knob's own LOGARITHMIC mapping -- so the short end of each range still travels and the picture moves the way the knob feels. Whatever the three leave over is the gate, which is therefore never narrower than a quarter. */ var W = x1 - x0, Q = W / 4, FLOOR = 1.5; var qa = Math.max(FLOOR, toNorm('env.a', a) * Q), qd = Math.max(FLOOR, toNorm('env.d', d) * Q), qr = Math.max(FLOOR, toNorm('env.r', r) * Q); var qh = W - qa - qd - qr; var xa = x0 + qa, xd = xa + qd, xh = xd + qh; /* D and R HANG, because that is what the sound does: both are setTargetAtTime, which is exponential, so the straight lines this drew were the lie. K = 3.5 puts the curve ~97% of the way there at the segment's end. Attack stays straight -- it is a linear ramp in the voice too. */ var K = 3.5, NS = 12, i; var seg = function(px, pw, v0, v1, hang, out){ for (var j = 1; j <= NS; j++){ var u = j / NS; out.push([px + pw * u, Y(hang ? v1 + (v0 - v1) * Math.exp(-K * u) : v0 + (v1 - v0) * u)]); } }; var pts = [[x0, Y(0)]]; seg(x0, qa, 0, 1, 0, pts); seg(xa, qd, 1, su, 1, pts); pts.push([xh, Y(su)]); seg(xh, qr, su, 0, 1, pts); g.beginPath(); g.moveTo(pts[0][0], yb); for (i = 0; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]); g.lineTo(pts[pts.length-1][0], yb); g.closePath(); var fg = g.createLinearGradient(0, yt, 0, yb); fg.addColorStop(0, rgba(THEME.accent, 0.36)); fg.addColorStop(1, rgba(THEME.accent, 0)); g.fillStyle = fg; g.fill(); g.strokeStyle = rgba(THEME.edge, 0.85); g.lineWidth = 1; var ticks = [xa, xd, xh]; for (i = 0; i < 3; i++){ g.beginPath(); g.moveTo(ticks[i], yt-3); g.lineTo(ticks[i], yb); g.stroke(); } g.beginPath(); g.moveTo(pts[0][0], pts[0][1]); for (i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]); glow(g, THEME.accent, 1.6); /* the four corners only -- the sampled points in between are the curve, not places where anything happens */ g.fillStyle = THEME.ink; [[xa, Y(1)], [xd, Y(su)], [xh, Y(su)]].forEach(function(pt){ g.beginPath(); g.arc(pt[0], pt[1], 1.4, 0, 6.2832); g.fill(); }); } /* What the sequencer will actually play, derived from the same noteOf(), DURS and arpPick() the scheduler uses so the picture cannot drift from the sound. RAND is the exception: arpPick() rolls a die per note at schedule time, so the octave does not exist yet -- those are flagged and drawn as a range band. */ function noteEvents(iv, off, out){ var vv = iv ? iv.v : V, steps = iv ? iv.steps : STEPS; var mode = Math.round(vv['arp.mode']), rng = Math.round(vv['arp.rng']); off = off || 0; out = out || []; for (var i = 0; i < 16; i++){ var st = steps[i]; if (!st.on) continue; var nsub = DURS[st.dur | 0], notes = notesOf(i, iv), m; if (mode === 0){ for (m = 0; m < notes.length; m++) out.push({step:off + i, len:nsub, midi:notes[m], rand:0}); continue; } /* mirrors scheduleStep exactly, in sixteenths rather than seconds */ var seq = arpNotes(notes, rng); var lenS = (ARPL[Math.round(vv['arp.len'])] || ARPL[2]).b * 4; /* in 16ths */ for (var k = 0; k * lenS < nsub - 1e-6 && k < 256; k++){ out.push(mode === 3 ? {step:off + i + k * lenS, len:lenS, midi:seq[0], rand:rng} : {step:off + i + k * lenS, len:lenS, midi:arpPick(seq, k, mode), rand:0}); } } return out; } /* the whole chain, so the roll is a song view once there is more than one scene */ /* One instrument's events across the whole chain. The live scene's SELECTED instrument reads from the live state (null); everything else reads from storage, including the other instrument of the scene being edited. */ function chainEvents(want){ var nn = nScn(), out = []; for (var k = 0; k < nn; k++){ var sc = SCENES[k]; if (k === scn && want === INS){ noteEvents(null, k * 16, out); continue; } if (!sc || !sc.ins) continue; noteEvents(insView(sc, want), k * 16, out); } return out; } /* ---- shared frequency-axis maths ---- */ var FMIN = 20, FMAX = 20000, LOGR = Math.log(FMAX / FMIN), FS = 44100; function xOfF(fr, w){ return (Math.log(fr / FMIN) / LOGR) * w; } function dB(m){ return 20 * Math.log(Math.max(1e-7, m)) / Math.LN10; } /* RBJ cookbook lowpass -- the exact transfer function BiquadFilterNode uses, evaluated analytically so the curve exists before any AudioContext does. */ /* The coefficients depend only on f0 and Q, so they are computed once per curve rather than once per pixel -- that alone was 3 transcendentals per sample. lpAt() then needs only cos(w) and sin(w): the double-angle identities give cos 2w and sin 2w for free. */ function lpCoef(f0, Q){ var w0 = 2*Math.PI*Math.min(f0, FS*0.49)/FS; var cw0 = Math.cos(w0), alpha = Math.sin(w0)/(2*Math.pow(10, Q/20)); return {b0:(1-cw0)/2, b1:1-cw0, b2:(1-cw0)/2, a0:1+alpha, a1:-2*cw0, a2:1-alpha}; } function lpAt(c, fr){ var w = 2*Math.PI*fr/FS; var cw = Math.cos(w), sw = Math.sin(w); var c2 = 2*cw*cw - 1, s2 = 2*sw*cw; /* double angle, no extra trig */ var nr = c.b0 + c.b1*cw + c.b2*c2, ni = -(c.b1*sw + c.b2*s2); var dr = c.a0 + c.a1*cw + c.a2*c2, di = -(c.a1*sw + c.a2*s2); var den = dr*dr + di*di; return den > 0 ? Math.sqrt((nr*nr + ni*ni) / den) : 0; } function lpMag(fr, f0, Q){ return lpAt(lpCoef(f0, Q), fr); } /* kept for tests */ function freqGrid(g, w, top, bot){ g.strokeStyle = rgba(THEME.edge, 0.6); g.lineWidth = 1; var marks = [100, 1000, 10000]; for (var i = 0; i < 3; i++){ var x = xOfF(marks[i], w); g.beginPath(); g.moveTo(x, top - 2); g.lineTo(x, bot); g.stroke(); } } /* ---- 3. filter frequency response ---- */ function drawFilt(){ var f = fit('vzFilt'); if (!f) return; var g = f.g, w = f.w, h = f.h; plate(g, w, h, FLT_ON ? 'FILTER' : 'FILTER \u00b7 BYPASS'); var top = capTop(h), bot = h - 3, HI = 18, LO = -48; var Y = function(v){ return top + (HI - v) / (HI - LO) * (bot - top); }; freqGrid(g, w, top, bot); g.strokeStyle = rgba(THEME.edge, 0.45); g.beginPath(); g.moveTo(0, Y(0)); g.lineTo(w, Y(0)); g.stroke(); if (!FLT_ON){ /* bypassed = flat, unity across the band */ g.beginPath(); g.moveTo(0, Y(0)); g.lineTo(w, Y(0)); g.setLineDash([4, 3]); glow(g, THEME.faint, 1.4); g.setLineDash([]); return; } var cut = dv('flt.cut'), Q = dv('flt.res'), env = dv('flt.env'); var pk = clamp(cut * Math.pow(2, env * 4), 30, 18000); /* sweep once into a buffer, then replay it for fill and stroke */ var nx = Math.max(2, Math.round(w)) + 1; if (!drawFilt.buf || drawFilt.buf.length < nx) drawFilt.buf = new Float32Array(nx + 16); var ys = drawFilt.buf, cc = lpCoef(cut, Q), sx = w / (nx - 1), xi; for (xi = 0; xi < nx; xi++){ var fr = FMIN * Math.exp((xi * sx / w) * LOGR); ys[xi] = clamp(Y(dB(lpAt(cc, fr))), top - 8, bot + 8); } var replay = function(){ g.beginPath(); for (var i = 0; i < nx; i++){ var px = i * sx; if (i === 0) g.moveTo(px, ys[i]); else g.lineTo(px, ys[i]); } }; /* ghost curve = where the filter envelope pushes the cutoff at its peak */ if (env > 0.01){ var gc = lpCoef(pk, Q); g.beginPath(); for (xi = 0; xi < nx; xi++){ var gfr = FMIN * Math.exp((xi * sx / w) * LOGR); var gy = clamp(Y(dB(lpAt(gc, gfr))), top - 8, bot + 8); if (xi === 0) g.moveTo(xi * sx, gy); else g.lineTo(xi * sx, gy); } g.setLineDash([2.5, 2.5]); g.strokeStyle = rgba(THEME.accent2, 0.8); g.lineWidth = 1; g.stroke(); g.setLineDash([]); } replay(); g.lineTo(w, bot); g.lineTo(0, bot); g.closePath(); var fg = g.createLinearGradient(0, top, 0, bot); fg.addColorStop(0, rgba(THEME.accent, 0.30)); fg.addColorStop(1, rgba(THEME.accent, 0)); g.fillStyle = fg; g.fill(); replay(); glow(g, THEME.accent, 1.6); } /* ---- 4. output spectrum ---- */ /* physical harmonic magnitudes for a unit-peak wave, so the three families are plotted on a comparable scale (square 4/pi, triangle 8/pi^2, saw 2/pi) */ function harmAmp(t, k, duty){ if (t === 0) return k === 1 ? 1 : 0; if (t === 1){ var d = clamp(duty, 0.002, 0.998); return 2 * Math.abs(Math.sin(Math.PI*k*d)) / (Math.PI*Math.PI*k*k*d*(1-d)); } var dp = clamp(duty, 0.02, 0.98); return 2 * Math.abs(Math.sin(Math.PI*k*dp)) / (Math.PI*k); } /* Bessel functions of the first kind, which is what an FM spectrum is made of: sin(wc*t + I*sin(wm*t)) has a partial at fc + k*fm of amplitude J_k(I), for every integer k. Computed from the ascending series, but as a term RECURRENCE so there are no factorials to overflow: t_0 = (x/2)^n / n! t_{m+1} = -t_m * (x/2)^2 / ((m+1)*(m+n+1)) J_0(0) = 1 and J_k(0) = 0, so at index zero this collapses to exactly the single partial a plain sine carrier has -- which is the right answer. */ function besselJ(nn, x){ nn = Math.abs(nn); /* J_-n = (-1)^n J_n; magnitude only */ var hx = x / 2, t = 1, m; for (m = 1; m <= nn; m++) t *= hx / m; var sum = t; for (m = 0; m < 40; m++){ t *= -hx * hx / ((m + 1) * (m + nn + 1)); sum += t; if (Math.abs(t) < 1e-12) break; } return sum; } function drawSpec(){ var f = fit('vzSpec'); if (!f) return; var g = f.g, w = f.w, h = f.h; plate(g, w, h, 'SPECTRUM'); var top = capTop(h), bot = h - 3, LO = -54, HI = 6; freqGrid(g, w, top, bot); var base = 440 * Math.pow(2, ((48 + ROOT + OCT*12) - 69) / 12); var cut = dv('flt.cut'), Q = dv('flt.res'), wv = dwsel(), dty = dv('osc.duty'); var pts = [], k, fr; var sc = FLT_ON ? lpCoef(cut, Q) : null; var push = function(a, fr){ if (!a || fr < FMIN || fr > FMAX) return; var vv = dB(a * (sc ? lpAt(sc, fr) : 1)); if (vv < LO) return; pts.push([xOfF(fr, w), clamp(top + (HI - vv) / (HI - LO) * (bot - top), top, bot)]); }; if (INS === 1){ /* Sidebands, not harmonics: they sit at |fc +- k*fm|, they are not ordered by k, and the ones that fall through zero fold back up -- hence the absolute value and the sort. Beyond k ~ I+5 they are inaudible. */ var r = dfmRatio(), I = dv('fm.idx'); var K = Math.min(64, Math.ceil(I) + 6); for (k = -K; k <= K; k++) push(Math.abs(besselJ(k, I)), Math.abs(base * (1 + k * r))); pts.sort(function(p, q){ return p[0] - q[0]; }); } else { for (k = 1; k <= 96; k++){ fr = base * k; if (fr > FMAX) break; push(harmAmp(wv, k, dty), fr); } } var bg = g.createLinearGradient(0, top, 0, bot); bg.addColorStop(0, rgba(THEME.accent, 1)); bg.addColorStop(1, rgba(THEME.accent, 0.3)); g.lineCap = 'round'; g.beginPath(); for (var i = 0; i < pts.length; i++){ g.moveTo(pts[i][0], bot); g.lineTo(pts[i][0], pts[i][1]); } g.strokeStyle = rgba(THEME.accent, 0.22); g.lineWidth = 4; g.stroke(); g.strokeStyle = bg; g.lineWidth = 1.4; g.stroke(); if (pts.length > 1){ g.beginPath(); g.moveTo(pts[0][0], pts[0][1]); for (var j = 1; j < pts.length; j++) g.lineTo(pts[j][0], pts[j][1]); g.strokeStyle = rgba(THEME.accent2, 0.6); g.lineWidth = 1; g.stroke(); } } /* ---- 5. piano roll: the pattern as music ------------------------------- */ function drawRoll(){ var f = fit('vzRoll'); if (!f) return; var g = f.g, w = f.w, h = f.h; var bgc = g.createLinearGradient(0, 0, 0, h); bgc.addColorStop(0, rgba(THEME['viz-hi'], 0.95)); bgc.addColorStop(1, rgba(THEME['viz-lo'], 0.98)); g.fillStyle = bgc; g.fillRect(0, 0, w, h); var nn = nScn(), nSteps = 16 * nn; /* BOTH instruments, because they play together and the point of a song view is seeing how they sit against each other. The one being edited is drawn in the accent; the other is behind it in the second colour at a third of the opacity, present but never mistakable for the one you are editing. The pitch range fits over both, or switching instruments would rescale the axis under you. */ var evs = [chainEvents(0), chainEvents(1)]; var ev = evs[0].concat(evs[1]), i, e; var pad = 2, top = pad, bot = h - pad; /* vertical range auto-fits, with a 12-semitone floor so a single-pitch pattern does not stretch to fill the height */ var lo = 127, hi = 0; for (i = 0; i < ev.length; i++){ e = ev[i]; if (e.midi < lo) lo = e.midi; if (e.midi + 12 * e.rand > hi) hi = e.midi + 12 * e.rand; } if (!ev.length){ lo = 48 + ROOT; hi = lo + 12; } var span = Math.max(12, hi - lo); var mid = (lo + hi) / 2; lo = mid - span / 2 - 1; hi = mid + span / 2 + 1; span = hi - lo; var yOf = function(m){ return bot - ((m - lo) / span) * (bot - top); }; var sw = w / nSteps; var rowH = Math.max(3, (bot - top) / span); /* readable at ~3px minimum */ /* octave guides off the root, so the pitch axis is readable */ g.strokeStyle = rgba(THEME.edge, 0.55); g.lineWidth = 1; var oct0 = Math.ceil((lo - (48 + ROOT)) / 12); for (var o = oct0; ; o++){ var m = 48 + ROOT + o * 12; if (m > hi) break; var gy = Math.round(yOf(m)) + 0.5; g.beginPath(); g.moveTo(0, gy); g.lineTo(w, gy); g.stroke(); } /* scene boundaries read stronger than the 4-step beat lines */ for (var b = 4; b < nSteps; b += 4){ var bx = Math.round(b * sw) + 0.5; g.strokeStyle = rgba(THEME.edge, (b % 16 === 0) ? 0.9 : 0.4); g.beginPath(); g.moveTo(bx, top); g.lineTo(bx, bot); g.stroke(); } /* the scene being edited */ if (nn > 1){ g.fillStyle = rgba(THEME.accent2, 0.07); g.fillRect(scn * 16 * sw, top, 16 * sw, bot - top); } /* notes: the other instrument first, so the selected one lands on top */ var order = [1 - INS, INS]; for (var q = 0; q < 2; q++){ var which = order[q], mine = (which === INS); var col = mine ? THEME.accent : THEME.accent2, al = mine ? 1 : 0.34; var list = evs[which]; for (i = 0; i < list.length; i++){ e = list[i]; var x = e.step * sw, ww = Math.max(1.5, e.len * sw - 1); if (e.rand){ /* octave decided at playback time -- show the band it lands in */ var yTop = yOf(e.midi + 12 * e.rand), yBot = yOf(e.midi); g.fillStyle = rgba(col, 0.20 * al); g.fillRect(x, yTop, ww, Math.max(2, yBot - yTop + rowH)); g.fillStyle = rgba(col, 0.75 * al); g.fillRect(x, yOf(e.midi) - rowH, ww, Math.max(1.5, rowH)); } else { g.fillStyle = rgba(col, al); g.fillRect(x, yOf(e.midi) - rowH, ww, Math.max(2, rowH)); } } } /* playhead */ if (playing && cur >= 0){ g.fillStyle = rgba(THEME.now, 0.9); /* same playhead colour as the steps */ g.fillRect(Math.round((scn * 16 + cur) * sw), top, Math.max(1.5, sw), bot - top); } } /* ---- redraw routing: only the displays a parameter actually affects ---- */ var SWITCHES = [ {id:'swEnv', bank:'bank-env', get:function(){return ENV_ON;}, set:function(v){ENV_ON=v;}, after:function(){drawEnv();}}, {id:'swFlt', bank:'bank-flt', get:function(){return FLT_ON;}, set:function(v){FLT_ON=v;}, after:function(){drawFilt(); drawSpec();}}, {id:'swFx', bank:'bank-dly', get:function(){return FX_ON;}, set:function(v){FX_ON=v;}, after:function(){applyLive();}}, {id:'swRev', bank:'bank-rev', get:function(){return REV_ON;}, set:function(v){REV_ON=v;}, after:function(){applyLive();}} ]; function paintSwitches(){ for (var i = 0; i < SWITCHES.length; i++){ var w = SWITCHES[i], on = w.get(); document.getElementById(w.id).setAttribute('aria-pressed', on ? 'true' : 'false'); document.getElementById(w.bank).classList.toggle('off', !on); } } function paintOscDim(){ var k = KNOBS['osc.duty']; if (k) k.classList.toggle('dim', dwsel() === 0); /* sine has nothing to shape */ } /* The OSC panel has exactly four knob columns -- two engine knobs and the octave stepper spanning the other two -- and at 320px there is no fifth. So the instruments do not add knobs, they SWAP the first two; the hidden pair is display:none, and grid auto-placement skips it, which is what keeps the panel one row tall. The tint is set on <html> rather than on the panels so readTheme keeps reading from documentElement -- the canvases INSIDE those panels have to follow their panel or they look pasted on. */ function paintIns(){ var i, id, opt = document.querySelectorAll('.insopt'); for (i = 0; i < opt.length; i++) opt[i].setAttribute('aria-pressed', (+opt[i].getAttribute('data-ins') === INS) ? 'true' : 'false'); for (i = 0; i < INSK.length; i++){ for (var j = 0; j < INSK[i].length; j++){ id = INSK[i][j]; if (KNOBS[id]) KNOBS[id].hidden = (i !== INS); } } document.documentElement.classList.toggle('ins1', INS === 1); /* tilt drives whichever knob is this instrument's character control */ paintOscDim(); } function vizFor(id){ if (id.indexOf('env.') === 0) drawEnv(); else if (id.indexOf('flt.') === 0){ drawFilt(); drawSpec(); } else if (id.indexOf('osc.') === 0){ paintOscDim(); drawWave(); drawSpec(); } else if (id.indexOf('fm.') === 0){ drawWave(); drawSpec(); } else if (id === 'rev.size') scheduleIR(); else if (id.indexOf('arp.') === 0) drawRoll(); else if (id === 'seq.scn'){ ensureScenes(); drawRoll(); } } function drawAll(){ readTheme(); measureCanvases(); drawWave(); drawEnv(); drawFilt(); drawSpec(); drawRoll(); } /* ---- visualisations follow the locked values -------------------------- A curve that disagrees with what you are hearing is worse than no curve, so while a locked step is sounding the graphs render ITS values, not the base patch. Done by swapping into V around each draw -- the same trick the scheduler uses -- so none of the draw code above needs to know locks exist. drawRoll is deliberately excluded: it shows the whole chain, not one step. */ function pushDisp(){ if (!playing || cur < 0) return null; var st = stepAt(curScn); return pushLock(st && st.lock); } function vizWrap(fn){ return function(){ var o = pushDisp(); try { fn(); } finally { popLock(o); } }; } drawWave = vizWrap(drawWave); drawEnv = vizWrap(drawEnv); drawFilt = vizWrap(drawFilt); drawSpec = vizWrap(drawSpec); /* Redraw on a step change only when a value a graph actually reads has moved, otherwise every locked pattern would repaint four canvases 8 times a second for nothing. VZIDS is exactly LOCKABLE minus GLOBALP -- every per-voice lockable parameter -- which is why this is also the right place to make the SOUND follow the playhead. A lock used to be read once, at note-on, and never again, so a note held across several steps kept the patch it was born with while the lane underneath it did nothing and these graphs drew the automation anyway. A curve that disagrees with what you are hearing is worse than no curve, so the same change detection now drives refreshLive too. */ var VZIDS = ['osc.wave','osc.duty','fm.ratio','fm.idx','env.a','env.d','env.s','env.r', 'flt.cut','flt.res','flt.env']; var VZLAST = {}; function vizFollow(){ var dw = 0, de = 0, df = 0, ds = 0, i, id, v; for (i = 0; i < VZIDS.length; i++){ id = VZIDS[i]; v = eff(id); if (VZLAST[id] === v) continue; VZLAST[id] = v; if (id.indexOf('osc.') === 0 || id.indexOf('fm.') === 0){ dw = 1; ds = 1; } else if (id.indexOf('env.') === 0) de = 1; else { df = 1; ds = 1; } } if (dw) drawWave(); if (de) drawEnv(); if (df) drawFilt(); if (ds) drawSpec(); } /* ---- step automation, for BOTH instruments ----------------------------- This used to ride along inside vizFollow, which was fine while there was one instrument and wrong the moment there were two: vizFollow reads the SELECTED instrument, so instrument 1's lane would only have automated its notes while instrument 1 happened to be on screen. The sound must not depend on what is displayed, so the audio half lives here, loops over both instruments, and keeps its own change memory per instrument. vizFollow went back to being about drawing, which is what it should always have been. A lock is otherwise read once, at note-on, and never again -- so a note held across several steps would keep the patch it was born with while the lane underneath it did nothing and the graphs drew the automation anyway. */ var AULAST = [{}, {}]; /* Everything the automation can SCHEDULE is scheduled, not observed. autoFollow below rides draw(), which is requestAnimationFrame -- and rAF stops dead the moment the page is hidden. So a lock lane stopped reaching held notes as soon as you switched window or app: measured, an fm.idx lane moved a held note's deviation 523 -> 1984 with rAF running and left it flat at 523 with rAF frozen. tick() already visits every step boundary, ahead of time, with that step's locks pushed into V and the step's own audio time in hand -- and it runs off setInterval, which keeps going when hidden (that is what HIDDEN_HORIZON is for). So this writes the change AT the step's time instead of noticing it afterwards: sample-accurate rather than rAF-accurate, and indifferent to focus. The one thing that cannot be scheduled is the waveform -- setPeriodicWave takes effect the instant it is called, with no time argument -- so osc.wave and osc.duty stay on the rAF path below and stay frozen while hidden. That is a real remaining limit and it is named rather than hidden. Its own change memory, because it runs ahead of the clock: comparing against what was last SCHEDULED is a different question from what is last HEARD. */ var ASLAST = [{}, {}]; function autoSched(t){ if (!A || !LIVE.length) return; var last = ASLAST[INS], g = null, i, id, val; for (i = 0; i < VZIDS.length; i++){ id = VZIDS[i]; /* immediate-only, and env.a/env.d cannot change a note already begun */ if (id.indexOf('osc.') === 0 || id === 'env.a' || id === 'env.d') continue; /* a routed parameter already has a driver; two writers on one AudioParam would cancel each other's ramps every tick */ if (ROUTE[id]) continue; /* pushLock has already substituted this step's locks into V, so V IS the effective value here -- eff() would answer for the AUDIBLE step, which is not the one being scheduled */ val = V[id]; if (last[id] === val) continue; last[id] = val; if (id.indexOf('fm.') === 0) (g = g || {}).fm = 1; else if (id === 'env.s') (g = g || {}).sus = 1; else if (id === 'env.r') (g = g || {}).rel = 1; else (g = g || {}).flt = 1; } if (!g) return; var now = A.ctx.currentTime; for (i = 0; i < LIVE.length; i++){ var v = LIVE[i]; if (v.stopT <= now || v.ins !== INS) continue; /* held ACROSS this step: born before it and still gated at it. `<` matters -- a voice spawned FOR this step has t0 === t and was already built from this step's lock, and one further up the lookahead has t0 > t. */ if (!(v.t0 < t && t < v.gEnd)) continue; if (g.flt) reFilter(v, t); if (g.fm) reFM(v, t); if (g.sus) reSustain(v, t); if (g.rel) reRelease(v, t); } } /* What is left for rAF: the waveform, which has no scheduled form. */ function autoFollow(){ if (!playing || cur < 0) return; for (var nn = 0; nn < 2; nn++){ var io = pushIns(nn), last = AULAST[nn], hit = 0, i, id, v; for (i = 0; i < VZIDS.length; i++){ id = VZIDS[i]; if (id.indexOf('osc.') !== 0) continue; /* A routed parameter belongs to its source, and this function was FIGHTING it: eff() returns the source's value, so the change detector fired on every frame, and then pushDisp() substituted the step LOCKS -- not the source -- so the value actually written was the base. Measured: 19 re-waves in two and a half seconds, every one of them 0.35, while the source sat at 0.969. That is "it plays at the manually set duty and only jumps to the LFO value when the note starts". */ if (ROUTE[id]) continue; v = eff(id); if (last[id] === v) continue; last[id] = v; hit = 1; } /* the audible step's locks substituted into V, the same trick vizWrap uses for the graphs -- so the whole re* family stays unaware locks exist */ if (hit){ var o = pushDisp(); try { refreshLive({wav:1}, 1); } finally { popLock(o); } } popIns(io); } } /* ---- note symbols ------------------------------------------------------- Drawn rather than typed: the half note (U+1D15E) and whole note (U+1D15D) sit outside the BMP and are missing from the default iOS/Android font stacks. currentColor makes them inherit each cell's on/off/downbeat colour for free. Built once; paintStep runs on every edit. */ var GLYPH = []; function buildGlyphs(){ function head(filled, wide){ var rx = wide ? 4.1 : 3.5, rot = wide ? -8 : -22; return '<ellipse cx="4.7" cy="13.4" rx="' + rx + '" ry="2.7"' + ' transform="rotate(' + rot + ' 4.7 13.4)"' + (filled ? ' fill="currentColor"' : ' fill="none" stroke="currentColor" stroke-width="1.7"') + '/>'; } var stem = '<rect x="7.5" y="2.2" width="1.6" height="11.3" fill="currentColor"/>'; function flag(y){ return '<path d="M8.9 ' + y + ' L12 ' + (y + 1.9) + ' L12 ' + (y + 3.9) + ' L8.9 ' + (y + 2.0) + ' z" fill="currentColor"/>'; } GLYPH[0] = head(1,0) + stem + flag(2.2) + flag(6.4); /* 1/16: two bars */ GLYPH[1] = head(1,0) + stem + flag(2.2); /* 1/8 : one bar */ GLYPH[2] = head(1,0) + stem; /* 1/4 : filled */ GLYPH[3] = head(0,0) + stem; /* 1/2 : hollow */ GLYPH[4] = head(0,1); /* whole: no stem */ } /* ============================================================= sequencer == */ var CELLS = []; function paintStep(i){ var el = CELLS[i], s = STEPS[i]; el.dataset.on = s.on ? '1' : '0'; el.dataset.dur = s.dur; el.classList.toggle('plk', !!(s.lock && !emptyObj(s.lock))); el.classList.toggle('sel', i === selStep); var nts = notesOf(i), extra = nts.length > 1 ? '<i class="pl2">+' + (nts.length - 1) + '</i>' : ''; el.innerHTML = s.on ? '<svg class="gl" viewBox="0 0 12 18" aria-hidden="true">' + GLYPH[s.dur] + '</svg>' + '<span class="nm">' + noteName(nts[0]) + extra + '</span>' : '<span class="nm">\u00b7</span>'; el.setAttribute('aria-label', s.on ? 'step ' + (i + 1) + ' ' + nts.map(noteName).join(' ') + ' ' + DURLAB[s.dur] : 'step ' + (i + 1) + ' off'); el.title = 'Step ' + (i + 1) + ': drag up-down for pitch, sideways for length'; if (!rollHold) drawRoll(); } var rollHold = 0; function paintAllSteps(){ rollHold = 1; for (var i = 0; i < 16; i++) paintStep(i); rollHold = 0; paintAllMods(); /* they can never be out of step this way */ drawKeys(); drawRoll(); /* once, not once per step */ } /* Moves every note of a step by one semitone, refusing the move rather than letting the chord collapse when an edge note is already at the clamp. */ function transposeStep(i, dir){ var v = STEPS[i].semis, k; for (k = 0; k < v.length; k++) if (v[k] + dir < -SEMI_MAX || v[k] + dir > SEMI_MAX) return false; for (k = 0; k < v.length; k++) v[k] += dir; return true; } function bindStep(el, i){ var drag = false, moved = false, axis = 0; /* 0 undecided, 1 pitch, 2 duration */ var lastX = 0, lastY = 0, startX = 0, startY = 0, accY = 0, accX = 0, pid = null; var PX = 10; /* pixels of drag per semitone */ var DX = 18; /* pixels of drag per duration notch */ el.addEventListener('pointerdown', function(e){ if (pid !== null) return; /* ignore a second finger on the same step */ pid = e.pointerId; drag = true; moved = false; axis = 0; accY = accX = 0; lastX = startX = e.clientX; lastY = startY = e.clientY; try { el.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); }); el.addEventListener('pointermove', function(e){ if (!drag || e.pointerId !== pid) return; if (axis === 0){ /* first axis past the threshold wins the drag */ var dx = Math.abs(e.clientX - startX), dy = Math.abs(e.clientY - startY); if (dx <= 8 && dy <= 8){ lastX = e.clientX; lastY = e.clientY; return; } axis = dx > dy ? 2 : 1; moved = true; } var changed = false, dir; if (axis === 1){ accY += (lastY - e.clientY); while (Math.abs(accY) >= PX){ dir = accY > 0 ? 1 : -1; accY -= dir * PX; transposeStep(i, dir); STEPS[i].on = true; changed = true; } } else { accX += (e.clientX - lastX); while (Math.abs(accX) >= DX){ dir = accX > 0 ? 1 : -1; accX -= dir * DX; STEPS[i].dur = clamp(STEPS[i].dur + dir, 0, DURS.length - 1); STEPS[i].on = true; changed = true; } } lastX = e.clientX; lastY = e.clientY; if (changed) paintStep(i); }); var wacc = 0; el.addEventListener('wheel', function(e){ e.preventDefault(); var unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 120 : 1; wacc -= clamp((e.deltaY * unit) / 100, -3, 3); var moved2 = false; while (Math.abs(wacc) >= 1){ var dir = wacc > 0 ? 1 : -1; wacc -= dir; if (e.shiftKey) STEPS[i].dur = clamp(STEPS[i].dur + dir, 0, DURS.length - 1); else transposeStep(i, dir); STEPS[i].on = true; moved2 = true; } if (moved2){ paintStep(i); save(); } }, {passive:false}); var end = function(e, toggle){ if (!drag || e.pointerId !== pid) return; drag = false; pid = null; try { el.releasePointerCapture(e.pointerId); } catch (x) {} if (toggle && !moved){ /* Selecting wins over toggling: tapping a cell to look at it should not also silence it. A second tap on the cell already selected toggles. */ if (!STEPS[i].on) { STEPS[i].on = true; } else if (selStep === i) { STEPS[i].on = false; } selStep = i; paintAllSteps(); } save(); }; el.addEventListener('pointerup', function(e){ end(e, true); }); el.addEventListener('pointercancel', function(e){ end(e, false); }); } /* ---- note keyboard ------------------------------------------------------ Built in absolute pitch so the white/black pattern is a real piano's: the leftmost key is the root an octave down, and which keys are black follows midi % 12, not the root. So it is rebuilt whenever ROOT moves. */ var selStep = 0; /* the step the keyboard edits while paused */ var KEYS = []; /* {semi, el} lowest first */ var BLACK = {1:1, 3:1, 6:1, 8:1, 10:1}; function keyBase(){ return 48 + ROOT + OCT * 12; } /* The keyboard is anchored to C, not to the root. Anchoring it to the root silently transposed the whole instrument: at root D the leftmost key was D, so pressing where C belongs on any keyboard gave you a D. */ function keyLo(){ return 36 + OCT * 12; } function buildKeys(){ var host = document.getElementById('keys'); if (!host) return; var row = host.querySelector('.wrow'); row.innerHTML = ''; var b, i; for (i = host.children.length - 1; i >= 0; i--) if (host.children[i] !== row) host.removeChild(host.children[i]); KEYS = []; /* j counts absolute semitones up from a C, so it IS the pitch class and the span always opens and closes on a white C -- no root can put a half-width black key at either edge any more. The stored value is still an offset from the root, so nothing downstream changes. */ var lo = keyLo(), base = keyBase(), whites = 0, plan = []; /* counted below, then published so the black keys can size off it */ for (var j = 0; j <= 24; j++){ var isBlack = !!BLACK[j % 12]; plan.push({semi:lo + j - base, abs:lo + j, black:isBlack, before:whites}); if (!isBlack) whites++; } /* a black key sits on the seam between the whites it falls between */ host.style.setProperty('--w', whites); for (i = 0; i < plan.length; i++){ var pl = plan[i]; b = document.createElement('button'); b.type = 'button'; b.className = 'k ' + (pl.black ? 'bk' : 'wk'); b.dataset.semi = pl.semi; var nm = noteName(pl.abs); b.setAttribute('aria-label', nm); b.title = nm + ': add or remove this note on the selected step'; /* Every C carries its octave, the way a keyboard is labelled. Real text rather than a pseudo-element: ::after is the note dot and ::before is the root bar, so both are already spoken for. */ if (pl.abs % 12 === 0) b.textContent = String(Math.floor(pl.abs / 12) - 1); if (pl.black){ b.style.left = (pl.before / whites * 100) + '%'; host.appendChild(b); } else { row.appendChild(b); } KEYS.push({semi:pl.semi, el:b}); } drawKeys(); } /* The step the keyboard is showing: what is sounding while it runs, what you selected while it is paused. */ function shownStep(){ return (playing && cur >= 0) ? cur : selStep; } function drawKeys(){ var host = document.getElementById('keys'); if (!host || !KEYS.length) return; var st = STEPS[shownStep()]; var on = (st && st.on) ? st.semis : []; var tab = SCALES[SCALE] || SCALES.minor; for (var i = 0; i < KEYS.length; i++){ var sm = KEYS[i].semi, pc = ((sm % 12) + 12) % 12; KEYS[i].el.classList.toggle('ins', tab.indexOf(pc) >= 0); KEYS[i].el.classList.toggle('rt', pc === 0); /* where the scale starts */ KEYS[i].el.classList.toggle('hit', on.indexOf(sm) >= 0); } } /* Add or remove one note on the selected step. The last note removed switches the step off rather than leaving it pitchless -- the value stays, so toggling the cell back on restores it. */ /* Returns what it did, not merely whether it did anything: +1 added, -1 removed, 0 unchanged. The caller needs the sign, because a removal is the one press that must stay silent. */ function toggleNote(sm){ if (playing) return 0; /* running, the keyboard only reports */ var st = STEPS[selStep]; /* An off step draws no dots (drawKeys shows semis only while st.on), so no key on it can be a removal. Without this guard, clicking the key matching the semitone an off step had retained hit the remove branch and did nothing at all -- a dead press that still made a sound. */ var at = st.on ? st.semis.indexOf(sm) : -1; if (at >= 0){ if (st.semis.length === 1) st.on = false; else st.semis.splice(at, 1); paintStep(selStep); drawKeys(); save(); return -1; } if (!st.on){ st.on = true; st.semis = [sm]; } else { if (st.semis.length >= POLY) return 0; /* the cap, refused visibly */ st.semis.push(sm); st.semis.sort(function(a, b){ return a - b; }); } paintStep(selStep); drawKeys(); save(); return 1; } /* Sounds the key you pressed, through the selected step's own parameter locks so the audition matches what that step will actually play. Every press except a removal: adding, re-arming an off step, a press refused by the cap and a press while the transport runs all sound, because each of those is you asking to hear a pitch. Taking a note away is not. */ function auditionNote(sm){ if (!A) initAudio(); if (!A) return; /* a click is a user gesture, which is what iOS and Android need to start it */ try { var r = A.ctx.resume(); if (r && r.then) r.then(function(){}, function(){}); } catch (e) {} var lk = pushLock(STEPS[selStep] && STEPS[selStep].lock); try { spawnVoice(keyBase() + sm, A.ctx.currentTime + 0.005, Math.max(0.18, stepDur())); } finally { popLock(lk); } } function bindKeys(){ var host = document.getElementById('keys'); if (!host) return; host.addEventListener('click', function(e){ var k = e.target.closest ? e.target.closest('.k') : null; if (!k || !host.contains(k)) return; var sm = +k.dataset.semi; var act = toggleNote(sm); if (act >= 0) auditionNote(sm); /* -1 is a removal, and removals are silent */ if (!act && !playing && STEPS[selStep].semis.length >= POLY) ioFlash(k); }); } /* the cap is silent otherwise, and a tap that does nothing reads as a bug */ function ioFlash(el){ el.style.transition = 'none'; el.style.background = 'var(--crit)'; setTimeout(function(){ el.style.transition = 'background .25s'; el.style.background = ''; }, 90); } /* ---------------------------------------------------------- drum pads ---- */ var PADS = []; /* PADS[row][col] */ function buildDrums(){ var host = document.getElementById('drms'), bar = document.getElementById('drmbar'); if (!host || !bar) return; host.innerHTML = ''; PADS = []; for (var r = 0; r < DRUMKIT.length; r++){ var lane = []; for (var c = 0; c < 16; c++){ var b = document.createElement('button'); b.type = 'button'; b.className = 'pad r' + r; if (c % 4 === 0) b.dataset.beat = '1'; b.dataset.row = r; b.dataset.col = c; b.setAttribute('aria-label', DRUMKIT[r].name + ' step ' + (c + 1)); host.appendChild(b); lane.push(b); } PADS.push(lane); } bar.innerHTML = ''; for (var k = 0; k < DRUM_KITS.length; k++){ var pb = document.createElement('button'); pb.type = 'button'; pb.textContent = DRUM_KITS[k].id; pb.title = DRUM_KITS[k].tip; pb.dataset.kit = k; bar.appendChild(pb); } var cb = document.createElement('button'); cb.type = 'button'; cb.className = 'danger'; cb.textContent = 'CLR'; cb.title = 'Clear: empties the drum grid only \u2014 the note sequencer keeps its pattern'; cb.dataset.kit = 'clr'; bar.appendChild(cb); paintDrums(); } /* Everything a lane's cells have to say: on/off, whether the divider will skip this one, how many times a hit repeats, and how far the groove is pushed. */ function paintDrums(){ for (var r = 0; r < PADS.length; r++){ for (var c = 0; c < 16; c++){ var p = PADS[r][c], on = !!(DRUMS[r] && DRUMS[r][c]); var nud = (DNUDGE[r] && DNUDGE[r][c]) | 0, rate = (DRATE[r] && DRATE[r][c]) | 0; p.classList.toggle('on', on); /* dashed = intermittent. It is a property of the step, not of where the playhead happens to be, so it does not flicker with the lap counter. */ p.classList.toggle('div', rate < 0); p.classList.toggle('x2', rate === 1); p.classList.toggle('x3', rate === 2); p.classList.toggle('nud', nud !== 0); p.style.setProperty('--nudge', nud); p.title = DRUMKIT[r].name + ', step ' + (c + 1) + ' \u2014 ' + rateLabel(rate) + (nud ? ', nudge ' + (nud > 0 ? '+' : '') + nud + '/4 step' : '') + ': ' + DRUMKIT[r].tip.split(': ')[1]; } } } /* the playhead, four cells at a time */ var padCol = -1; function padHead(col){ if (col === padCol) return; var r; if (padCol >= 0 && PADS.length) for (r = 0; r < PADS.length; r++) PADS[r][padCol].classList.remove('cur'); padCol = col; if (padCol >= 0 && PADS.length) for (r = 0; r < PADS.length; r++) PADS[r][padCol].classList.add('cur'); } function auditionDrum(r){ if (playing) return; if (!A) initAudio(); if (!A) return; try { var q = A.ctx.resume(); if (q && q.then) q.then(function(){}, function(){}); } catch (x) {} drumHit(r, A.ctx.currentTime + 0.005); } function stepNudge(r, c, d){ DNUDGE[r][c] = clamp((DNUDGE[r][c] | 0) + d, -NUDGE_MAX, NUDGE_MAX); paintDrums(); save(); var v = DNUDGE[r][c]; setHint(DRUMKIT[r].name + ' ' + (c + 1) + ': nudge ' + (v > 0 ? '+' : '') + v + '/4 step' + (v === 0 ? ' (on the grid)' : '')); auditionDrum(r); } function stepRate(r, c, d){ DRATE[r][c] = clamp((DRATE[r][c] | 0) + d, RATE_LO, RATE_HI); paintDrums(); save(); setHint(DRUMKIT[r].name + ' ' + (c + 1) + ': ' + rateLabel(DRATE[r][c])); auditionDrum(r); } /* Tap toggles a step; a drag on the lane sets that lane's groove. One swipe is one increment, so a quarter step is a flick rather than a measured distance, and the larger delta wins the axis the way the logo's gestures do. */ function bindDrums(){ var host = document.getElementById('drms'); if (!host) return; /* One gesture PER POINTER, not one per grid. The knobs and the steps have always kept their state in a per-element closure, which is per-pointer by construction; this one is bound to the host, so it needed a map. Two lanes can be swiped at once now, which is the whole point of a grid this wide. */ var SW = 20, G = {}; host.addEventListener('pointerdown', function(e){ var p = e.target.closest ? e.target.closest('.pad') : null; if (!p || !host.contains(p) || G[e.pointerId]) return; G[e.pointerId] = {el:p, sx:e.clientX, sy:e.clientY, moved:false, r:+p.dataset.row, c:+p.dataset.col}; /* the step you touched */ try { p.setPointerCapture(e.pointerId); } catch (x) {} }); host.addEventListener('pointermove', function(e){ var g = G[e.pointerId]; if (!g || g.moved) return; var dx = e.clientX - g.sx, dy = e.clientY - g.sy; if (Math.max(Math.abs(dx), Math.abs(dy)) < SW) return; g.moved = true; /* one gesture, one increment */ /* the swallow flag goes on the PAD, so two simultaneous swipes each eat their own click instead of sharing one flag and eating the wrong one */ g.el.dataset.sw = '1'; if (Math.abs(dx) > Math.abs(dy)) stepNudge(g.r, g.c, dx > 0 ? 1 : -1); else stepRate(g.r, g.c, dy < 0 ? 1 : -1); /* up multiplies, down thins out */ }); var end = function(e){ delete G[e.pointerId]; }; host.addEventListener('pointerup', end); host.addEventListener('pointercancel', end); host.addEventListener('click', function(e){ var p = e.target.closest ? e.target.closest('.pad') : null; if (!p || !host.contains(p)) return; if (p.dataset.sw){ delete p.dataset.sw; return; } /* a swipe, not a tap */ var r = +p.dataset.row, c = +p.dataset.col; if (!DRUMS[r]) return; DRUMS[r][c] = DRUMS[r][c] ? 0 : 1; paintDrums(); /* hear what you just switched on, the way the keyboard auditions a note -- and only when switching ON, the way a removal there stays silent */ if (DRUMS[r][c]) auditionDrum(r); save(); }); var bar = document.getElementById('drmbar'); if (!bar) return; bar.addEventListener('click', function(e){ var t = e.target.closest ? e.target.closest('button') : null; if (!t || !bar.contains(t)) return; var k = t.dataset.kit; if (k === 'clr'){ DRUMS = drumsFrom(['','','','']); DNUDGE = zeroGrid(); DRATE = zeroGrid(); /* nothing left to modify */ setHint('drums cleared'); } else { var kit = DRUM_KITS[+k]; if (!kit) return; DRUMS = drumsFrom(kit.rows); /* the grid only: groove survives */ setHint(kit.id + ' \u2014 ' + kit.tip.split(':')[0]); } stashScalars(); /* DRUMS was reassigned, not mutated */ paintDrums(); save(); }); } var MCELL = []; /* MCELL[row][step] */ /* One button per cell, not a div: these are the only cells in the sequencer that a keyboard can reach, which is the point -- the step cells themselves are unfocusable divs and that is a separate finding. */ function buildMods(){ var host = document.getElementById('mods'); if (!host) return; host.innerHTML = ''; MCELL = []; for (var r = 0; r < MODROW.length; r++){ var row = []; for (var i = 0; i < 16; i++){ var b = document.createElement('button'); b.type = 'button'; b.className = 'mcell m' + r; if (i % 4 === 0) b.dataset.beat = '1'; b.dataset.row = r; b.dataset.col = i; host.appendChild(b); row.push(b); } MCELL[r] = row; } bindMods(); paintAllMods(); } function paintMod(i){ var st = STEPS[i]; for (var r = 0; r < MODROW.length; r++){ var b = MCELL[r] && MCELL[r][i]; if (!b) continue; var on = modGet(st, r); b.setAttribute('aria-pressed', on ? 'true' : 'false'); /* the glyph only exists while the cell is on: an unused row is meant to read as empty space rather than as sixteen switched-off controls */ b.textContent = on ? MODROW[r].glyph : ''; b.setAttribute('aria-label', MODROW[r].lab + ' step ' + (i + 1) + (on ? ' on' : ' off')); b.title = MODROW[r].tip; } } function paintAllMods(){ for (var i = 0; i < 16; i++) paintMod(i); } function modTap(b){ var r = +b.dataset.row, i = +b.dataset.col, st = STEPS[i]; modSet(st, r, !modGet(st, r)); /* a modifier on a silent step would be invisible state, so switching one on switches the step on -- the same rule the drum lane modifiers use */ if (modGet(st, r) && !st.on) st.on = true; paintMod(i); paintStep(i); /* the octave shift moves the note name */ setHint(MODROW[r].lab + ' step ' + (i + 1) + ': ' + (modGet(st, r) ? 'on' : 'off')); save(); } /* POINTER events with one entry per pointerId, not `click`. Measured, and it is the reason this is not three lines shorter: with two fingers down at once Chrome synthesises a click for the PRIMARY pointer only, so a click handler here toggles one cell out of two and the second finger does nothing at all. The drum grid's tap has the same shape and the same exposure -- its multitouch test exercised two SWIPES, which go through pointermove and were never affected. `keydown` is separate because a button reached by keyboard fires no pointer events at all, and these cells are the only focusable thing in the row. */ function bindMods(){ var host = document.getElementById('mods'); if (!host || host.dataset.bound) return; host.dataset.bound = '1'; var G = {}; var cellOf = function(e){ var b = e.target.closest ? e.target.closest('.mcell') : null; return (b && host.contains(b)) ? b : null; }; host.addEventListener('pointerdown', function(e){ var b = cellOf(e); if (!b || G[e.pointerId]) return; G[e.pointerId] = b; try { b.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); /* no synthetic click on top of ours */ }); host.addEventListener('pointerup', function(e){ var b = G[e.pointerId]; delete G[e.pointerId]; /* only if the finger came up over the cell it went down on: a stray drag across the row must not flip four modifiers on the way past */ if (b && document.elementFromPoint(e.clientX, e.clientY) === b) modTap(b); }); host.addEventListener('pointercancel', function(e){ delete G[e.pointerId]; }); host.addEventListener('keydown', function(e){ if (e.key !== 'Enter' && e.key !== ' ') return; var b = cellOf(e); if (!b) return; e.preventDefault(); modTap(b); }); } function buildSeq(){ var seq = document.getElementById('seq'); seq.innerHTML = ''; for (var i = 0; i < 16; i++){ var el = document.createElement('div'); el.className = 'step'; if (i % 4 === 0) el.dataset.beat = '1'; seq.appendChild(el); CELLS[i] = el; bindStep(el, i); } buildMods(); /* before the paint, so it has cells */ paintAllSteps(); } /* =============================================================== chrome == */ /* Fullscreen, entered and left by swiping the logo. Direction is the action, not a toggle: up enters, down leaves, whatever the current state is. iOS Safari has no Fullscreen API for anything but <video>, so on an iPhone there is nothing to call -- and the honest substitute is the home screen, which really is fullscreen and is already declared in the head. */ function fsSet(on){ var d = document.documentElement; var f = on ? (d.requestFullscreen || d.webkitRequestFullscreen) : (document.exitFullscreen || document.webkitExitFullscreen); if (!f) return false; /* rejects when already in the requested state, or when the gesture has been spent -- neither is worth an unhandled rejection in the console */ try { var p = f.call(on ? d : document); if (p && p['catch']) p['catch'](function(){}); } catch (e) { return false; } return true; } function fsSwipe(up){ if (!fsSet(up) && up) setHint('fullscreen: add to home screen'); } /* SAVE is dark with no take and dark while one is being built, so the moment it lights is the moment there is something to keep -- and it going dark again as play starts is what makes replacing an unsaved take visible rather than silent. */ function paintSave(){ var b = document.getElementById('save'); if (!b) return; var ready = !!TAPE.take; b.disabled = !ready; b.classList.toggle('on', ready); b.setAttribute('title', ready ? 'Take ' + recLabel(TAPE.take) + ' \u2014 tap: share \u00b7 hold: download' : TAPE.on ? 'Recording\u2026' : 'No take yet'); } /* A file:// page has no server to POST to, so the two ways out are a download anchor and the share sheet. BOTH are offered, chosen by gesture: the first cut picked one with `navigator.canShare`, which is true on Android and iOS alike, so no phone could ever simply download the file. */ function takeBlob(){ var t = TAPE.take; if (!t) return null; var blob = t.kind === 'wav' ? wavBlob(t) : t.blob; return {blob: blob, name: takeName(t.ext), type: blob.type}; } function takeDownload(){ var d = takeBlob(); if (!d) return; var a = document.createElement('a'); a.href = URL.createObjectURL(d.blob); a.download = d.name; /* in the document: some Android builds ignore a click on a detached anchor */ document.body.appendChild(a); a.click(); a.remove(); setTimeout(function(){ URL.revokeObjectURL(a.href); }, 30000); setHint('saved ' + d.name); ioFlash(document.getElementById('save')); } /* The rejection used to be swallowed, which is why "Signal opens but composes nothing" left nothing to look at. AbortError is you dismissing the sheet and stays quiet; anything else names itself and points at the other route. */ function takeShare(){ var d = takeBlob(); if (!d) return; var f; try { f = new File([d.blob], d.name, {type:d.type}); } catch (e) { f = null; } /* no sheet here (a desktop Linux browser, say): downloading is the only honest answer, and takeDownload reports it */ if (!f || !navigator.canShare || !navigator.canShare({files:[f]}) || !navigator.share){ takeDownload(); return; } navigator.share({files:[f], title:d.name}).then( function(){ setHint('shared ' + d.name); }, function(e){ var nm = (e && e.name) || 'unknown'; if (nm === 'AbortError') return; setHint('share failed: ' + nm + ' \u2014 use the other gesture'); }); ioFlash(document.getElementById('save')); } /* Tap shares, hold downloads -- the same on every platform. It was the other way round for one round; sharing is the common case and the download is the escape hatch for when a receiving app does nothing useful with the file. */ function saveTake(hold){ if (!TAPE.take) return; if (hold) takeDownload(); else takeShare(); } function takeName(ext){ var d = new Date(), p = function(x){ return ('0' + x).slice(-2); }; return 'synthi-' + bpm + 'bpm-' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + '.' + (ext || 'wav'); } /* One funnel for every message in the app, and it writes to a line in the layout. Guarded: the element has been removed from the markup once already and the unguarded call threw at boot on every iPhone until it was noticed. No timer. A status line that clears itself is a status line that is blank exactly when you look up at it, and there is nothing underneath for it to get out of the way of any more. */ function setHint(t, bad){ var h = document.getElementById('hint'); if (!h) return; h.textContent = t || ''; h.classList.toggle('bad', !!(t && bad)); } function paintBpm(){ document.querySelector('#bpm b').textContent = bpm; } function setBpm(v){ bpm = clamp(Math.round(v), 40, 220); /* !quiet, because this same function is how an INCOMING bpm is applied */ if (!SYNC.quiet) syncSend({t:'bpm', v:bpm}); paintBpm(); document.getElementById('bpm').setAttribute('aria-valuenow', bpm); applyLive(); save(); } function paintOct(){ document.getElementById('octv').innerHTML = (OCT > 0 ? '+' : '') + OCT + '<small>OCT</small>'; } /* One strip per channel: the fader is a plain buildKnob -- same element, same binding, same painter -- with .fader added so the CSS draws it upright. */ function buildMixer(){ var host = document.getElementById('strips'); MIXCH.forEach(function(id){ var st = document.createElement('div'); st.className = 'strip'; host.appendChild(st); buildKnob(st, id); KNOBS[id].classList.add('fader'); var m = document.createElement('button'); m.className = 'mutebtn'; m.id = 'mute-' + id.replace('.', '-'); m.textContent = 'M'; m.setAttribute('aria-pressed', 'false'); m.title = 'Mute ' + LABEL[id] + (id === 'vol' ? ' \u2014 silences the music, but not the sync markers' : ''); m.addEventListener('click', function(){ if (MUTE[id]) delete MUTE[id]; else MUTE[id] = 1; paintMutes(); applyLive(); save(); }); st.appendChild(m); }); paintMutes(); } /* One spot per source, and the two LFOs get their shape and rate beside them. buildKnob is reused verbatim: an LFO's controls are ordinary parameters. */ function buildSrcs(){ var host = document.getElementById('srcs'); if (!host) return; SRCORD.forEach(function(k){ var s = SRC[k]; var g = document.createElement('div'); g.className = 'srcgrp'; host.appendChild(g); var sp = document.createElement('div'); sp.className = 'spot'; sp.id = 'src-' + k; sp.style.setProperty('--sc', 'var(' + s.col + ')'); sp.style.setProperty('--scg', 'var(' + s.col + '-glow)'); sp.title = s.kind === 'lfo' ? s.lab + ': drag it onto any knob to have it swept. Long press to detach everything.' : s.lab + (k === 'tiltx' ? ': roll the device left and right. ' : ': tip it away from you and back. ') + 'Drag it onto any knob. Tap to switch the sensor off and on.'; sp.innerHTML = '<div class="face"></div><label>' + s.lab + '</label>'; g.appendChild(sp); bindSrc(sp, k); if (s.kind === 'lfo'){ buildKnob(g, k + '.wave'); buildKnob(g, k + '.hz'); } }); paintSrcs(); } /* ---- collapsing panels ------------------------------------------------ Every panel but the top one folds down to its label, which is what makes a visible mixer possible: what is on screen at once is now a choice rather than a fixed budget. The row list has to be written from here rather than left to CSS. Four of the grid's rows are `minmax(54px, 1fr)`, and `1fr` distributes space by SHARE, not by content -- so a class on a collapsed panel would hide its contents and still hold its 54px, and collapsing would free nothing at all. COL is global and persisted: a layout preference, not song data, so it must not rearrange the screen when the scene chain moves on. */ var COLKEY = 'synthi.col'; var COL = {}; /* id and grid row. There is no per-panel gesture any more: every panel folds from its own button, so the five that carry a section switch need nothing special. */ var PANELS = [ {id:'bank-mix', row:'mix'}, {id:'bank-lfo', row:'lfo'}, {id:'bank-seq', row:'seq'}, {id:'bank-drm', row:'drm'}, {id:'bank-osc', row:'osc'}, {id:'bank-env', row:'env'}, {id:'bank-flt', row:'flt'}, {id:'bank-dly', row:'dly'}, {id:'bank-rev', row:'rev'} ]; function paintCol(){ var app = document.querySelector('.app'); if (app) app.classList.toggle('nomods', !!COL.nomods); var sl = document.getElementById('seqlab'); if (sl) sl.setAttribute('aria-pressed', COL.nomods ? 'false' : 'true'); PANELS.forEach(function(pn){ var e = document.getElementById(pn.id); if (e) e.classList.toggle('col', !!COL[pn.id]); var b = document.querySelector('.fold[data-col="' + pn.id + '"]'); if (b) b.setAttribute('aria-expanded', COL[pn.id] ? 'false' : 'true'); }); /* The folded mixer keeps its four controls and shows them as KNOBS. That is the whole implementation: the fader and the knob are one widget with two stylesheets, both reading the same --v, so dropping the class re-renders it. No second build, no second binding, no second painter. */ MIXCH.forEach(function(id){ if (KNOBS[id]) KNOBS[id].classList.toggle('fader', !COL['bank-mix']); }); modSync(); /* the spots only animate while on screen */ /* the canvases inside a panel that just changed size have to be re-measured or they keep drawing at the old backing-store dimensions */ drawAll(); } function toggleCol(id){ if (COL[id]) delete COL[id]; else COL[id] = 1; paintCol(); /* Unfolding can cost more height than the screen has -- the mixer's source row is 95px at 320 -- and this layout has never clipped a panel. So the same MEASURED fold-until-it-fits that runs on a fresh install runs on every unfold too, protecting the panel you just asked for and folding the ones FITORDER already says are least missed. It was always possible to overflow by unfolding; the sources are just the first thing big enough to do it. */ if (!COL[id]) fitTo(id); saveCol(); } /* MODSET is the difference between "you hid the modifier rows" and "the fit pass hid them to make something fit". Both set COL.nomods, and only the first is a preference -- so only the first is written down. Without this the rows were gone for good: fitTo() hid them on the next unfold, toggleCol() then persisted the whole of COL, loadCol() obeyed a stored layout on every boot after that, and no amount of freed space brought them back. Which is exactly what happened. */ var MODSET = 0; /* The stored token is 'mods-off', NOT the in-memory 'nomods'. That is a migration disguised as a rename: every 'nomods' already written to a browser's synthi.col was written by the buggy path above, so the new reader does not recognise it, the PANELS loop in loadCol drops it as an unknown id, and the rows come back by themselves. Nobody has to clear anything. */ var MODKEY = 'mods-off'; function saveCol(){ var keys = Object.keys(COL).filter(function(k){ return k !== 'nomods'; }); if (COL.nomods && MODSET) keys.push(MODKEY); try { localStorage.setItem(COLKEY, JSON.stringify(keys)); } catch (e) {} } function fitTo(keep){ for (var i = 0; i < FITORDER.length && overflowing(); i++){ var k = FITORDER[i]; if (k === keep || COL[k]) continue; COL[k] = 1; paintCol(); } } function loadCol(){ try { var a = JSON.parse(localStorage.getItem(COLKEY) || 'null'); if (Array.isArray(a)) a.forEach(function(k){ /* stored at all means it was chosen, per saveCol. An older build's 'nomods' is deliberately not recognised here -- see MODKEY. */ if (k === MODKEY){ COL.nomods = 1; MODSET = 1; return; } for (var i = 0; i < PANELS.length; i++) if (PANELS[i].id === k) COL[k] = 1; }); return Array.isArray(a); } catch (e) { return false; } } /* Folded on a fresh install: the mixer because it keeps working folded (four knobs instead of four faders), and the drums because a fresh app has no drum pattern to look at any more. A browser that has ALREADY saved a fold list keeps exactly what it saved -- loadCol()'s return value is that distinction. */ var COL_SEED = ['bank-mix', 'bank-lfo', 'bank-drm']; /* First run only, and MEASURED rather than guessed at from a viewport threshold: fold panels, in this order, until nothing is clipped. Adding a row to a layout that had nine spare pixels costs a row -- there is no arithmetic that avoids it. So the default is whatever fits, and you rearrange from there; the alternative is a clipped bottom row, which this layout has never done. The order protects what is hardest to reach any other way: the mixer folds first because its strip still shows the master level's own label, and the effects fold next because they are set once and left. */ /* 'nomods' is not a panel and goes LAST -- it was first, and that was wrong twice over. A folded panel is one tap on its own strip away, which is the full width of the screen; hidden modifier rows come back only if you know that the SEQ label is a switch. And fitTo() runs on every UNFOLD, so with nomods first, opening the mixer silently took the modifier rows away. It is a last resort now: reached only when folding every panel in this list still is not enough. It rides the same COL object as the folds, so fitCol, fitTo and toggleCol handle it without knowing it is different -- the persistence is the one place it must NOT be treated the same. See saveCol. */ var FITORDER = ['bank-mix', 'bank-lfo', 'bank-rev', 'bank-dly', 'bank-drm', 'nomods']; function overflowing(){ var app = document.querySelector('.app'), bad = 0; [].slice.call(document.querySelectorAll('.panel')).forEach(function(e){ if (e.scrollHeight - e.clientHeight > 1) bad++; }); return bad || (app.scrollHeight - app.clientHeight > 1); } function fitCol(){ COL_SEED.forEach(function(k){ COL[k] = 1; }); paintCol(); for (var i = 0; i < FITORDER.length && overflowing(); i++){ COL[FITORDER[i]] = 1; paintCol(); } } /* One button, one job. The fold button is a real sibling of the label, so a press on it is never a press on the switch -- which is what two revisions of touch-action, pointercancel handling and click-swallowing were all trying and failing to arrange while the two shared a target. All of that is gone. */ function bindCol(){ PANELS.forEach(function(pn){ var b = document.querySelector('.fold[data-col="' + pn.id + '"]'); if (b) b.addEventListener('click', function(e){ e.stopPropagation(); /* not also the strip handler below */ toggleCol(pn.id); }); /* A folded panel also unfolds from anywhere on its strip: the strip is the full width of the screen and the button in it is 22px. Nothing can hide under this -- the panel's content is display:none while folded. */ var pe = document.getElementById(pn.id); if (pe) pe.addEventListener('click', function(){ if (COL[pn.id]) toggleCol(pn.id); }); }); } function paintMutes(){ MIXCH.forEach(function(id){ var m = document.getElementById('mute-' + id.replace('.', '-')); if (m) m.setAttribute('aria-pressed', MUTE[id] ? 'true' : 'false'); var k = KNOBS[id]; if (k) k.classList.toggle('dim', !!MUTE[id]); }); } function buildChrome(){ /* knob banks */ buildMixer(); buildSrcs(); tiltAuto(); modSync(); var oscHost = document.querySelector('#bank-osc .knobs'); /* Both engines' knobs are built once and one pair is hidden, rather than torn down and rebuilt on every switch: a knob carries its binding, its lock LED and its ARM state, and rebuilding would drop all three. */ INSK[0].concat(INSK[1]).forEach(function(id){ buildKnob(oscHost, id); }); paintIns(); ['env.a','env.d','env.s','env.r'].forEach(function(id){ buildKnob(document.querySelector('#bank-env .knobs'), id); }); ['flt.cut','flt.res','flt.env'].forEach(function(id){ buildKnob(document.querySelector('#bank-flt .knobs'), id); }); ['dly.div','dly.fb','dly.mix'].forEach(function(id){ buildKnob(document.querySelector('#bank-dly .knobs'), id); }); ['rev.size','rev.damp','rev.mix'].forEach(function(id){ buildKnob(document.querySelector('#bank-rev .knobs'), id); }); ['arp.mode','arp.rng','arp.len'].forEach(function(id){ buildKnob(document.getElementById('bank-arp'), id); }); /* seq.scn keeps its P entry -- that is what gives it save, load, export and clamping for free -- but builds no knob: a knob is 42u wide and the bar it moved into does not have 42u. paintKnob and markKnob both already guard on a missing element, so nothing else needs to know. */ (function(){ var step = function(d){ var was = nScn(); V['seq.scn'] = clamp(was + d, P['seq.scn'][0], P['seq.scn'][1]); if (nScn() === was) return; ensureScenes(); /* ensureScenes can call switchTo(0) when the count drops below the scene you are on, and switchTo REPLACES ROOT and SCALE. Without this the key changed under you while the dials, the keyboard and every step label still showed the old one -- the roll alone re-read ROOT, so the two disagreed on screen. */ markSceneDirty(); paintScn(); paintHold(); drawRoll(); save(); }; document.getElementById('scndn').addEventListener('click', function(){ step(-1); }); document.getElementById('scnup').addEventListener('click', function(){ step(1); }); })(); paintScn(); buildDials(); /* key and scale, see the block above */ /* HOLD locks the chain to whatever scene is playing, so it can be edited */ document.getElementById('holdbtn').addEventListener('click', function(){ HOLD = !HOLD; paintHold(); save(); }); document.getElementById('scnbtn').addEventListener('click', nextScene); paintHold(); /* section on/off switches */ SWITCHES.forEach(function(w){ document.getElementById(w.id).addEventListener('click', function(){ w.set(!w.get()); paintSwitches(); w.after(); save(); }); }); paintSwitches(); /* transport + tempo */ /* Transport: a click plays, a long press opens the song data, a vertical swipe toggles fullscreen. Same 500ms and same 8px move cancel as the knobs, so the gesture feels consistent -- and that 8px cancel is also what keeps a swipe from opening the dialog on its way past. Any gesture that is not a plain tap has to swallow the click, or releasing would toggle playback too. */ (function(){ var b = document.getElementById('play'); var SWIPE = 28; /* well clear of the 8px long-press cancel */ var t = 0, swallow = false, sy = 0, sx = 0, pid = null; var cancel = function(){ if (t){ clearTimeout(t); t = 0; } }; b.addEventListener('pointerdown', function(e){ if (pid !== null) return; pid = e.pointerId; swallow = false; sy = e.clientY; sx = e.clientX; /* without capture a swipe leaving the 50u button stops delivering moves and never reports its pointerup here at all */ try { b.setPointerCapture(e.pointerId); } catch (x) {} cancel(); t = setTimeout(function(){ t = 0; swallow = true; openIO(); }, 500); }); b.addEventListener('pointermove', function(e){ if (e.pointerId !== pid) return; if (Math.abs(e.clientY - sy) > 8 || Math.abs(e.clientX - sx) > 8) cancel(); }); var end = function(e){ if (e.pointerId === pid){ pid = null; cancel(); } }; b.addEventListener('pointerup', function(e){ if (e.pointerId !== pid) return; var dy = sy - e.clientY; /* screen y grows downward */ var dx = e.clientX - sx; /* !swallow: a press held past 500ms has already spent the gesture on the song dialog, and swiping away from it must not also do something else. The larger delta wins the axis, so a sloppy diagonal still does one thing rather than two. */ if (!swallow && Math.max(Math.abs(dx), Math.abs(dy)) >= SWIPE){ swallow = true; if (Math.abs(dx) > Math.abs(dy)){ if (dx < 0) openHelp(); else syncCycle(); } else fsSwipe(dy > 0); } end(e); }); b.addEventListener('pointercancel', end); b.addEventListener('click', function(e){ if (swallow){ swallow = false; return; } /* long press has no keyboard equivalent, so give it a second door */ if (e.shiftKey){ openIO(); return; } playing ? stop() : start(); }); })(); /* Back to one gesture. The tap/hold split existed only because the engine selector had nowhere else to live; it lives in the top panel now, and since revision 80 the OSC label is a plain label with no switch on it at all. */ /* The SEQ label was the one panel label with no job. A tap on it shows or hides the modifier rows -- and stopPropagation because the panel element itself carries the unfold-from-anywhere-on-the-strip handler. */ (function(){ var sl = document.getElementById('seqlab'); if (!sl) return; /* Asking for them is not the same as having room for them. toggleCol's fitTo() folds what it can, but on a short screen there is nothing left to fold and the rows then overflow SEQ's own box -- measured at 320x568 and 720x400, where the sequencer clipped while .app's overflow check read zero because the GRID still fitted. So the attempt is measured and, if it does not fit, taken back. A refusal that says why beats a clipped sequencer, and this layout has never clipped a panel. */ var seqClipped = function(){ var b = document.getElementById('bank-seq'); return !!b && b.scrollHeight - b.clientHeight > 1; }; var couldFit = function(){ var undo = [], i; var ks = ['bank-osc', 'bank-env', 'bank-flt', 'bank-dly', 'bank-rev', 'bank-drm', 'bank-mix', 'bank-lfo']; for (i = 0; i < ks.length; i++) if (!COL[ks[i]]){ COL[ks[i]] = 1; undo.push(ks[i]); } paintCol(); var fits = !seqClipped(); for (i = 0; i < undo.length; i++) delete COL[undo[i]]; paintCol(); return fits; }; var hit = function(e){ e.stopPropagation(); var was = MODSET; MODSET = 1; /* from here on it is a preference */ toggleCol('nomods'); if (!COL.nomods && seqClipped()){ /* "fold a panel first" is only worth saying if folding would actually help. At 320x568 it does -- folding ENV and FLT makes room. In short landscape it does not: with every bank folded the rows still need more height than the screen has, so telling someone to fold something would be a lie. So it is measured: fold everything, look, put it all back. Three paints on a gesture nobody spams. */ var help = couldFit(); MODSET = was; /* a refusal is not a preference either */ COL.nomods = 1; paintCol(); saveCol(); setHint(help ? 'no room right now \u2014 fold a bank to make some' : 'not enough height on this screen for the modifier rows', 1); return; } setHint(COL.nomods ? 'step modifiers hidden \u2014 tap SEQ to bring them back' : 'step modifiers: accent, slide, octave up, octave down'); }; sl.addEventListener('click', hit); sl.addEventListener('keydown', function(e){ if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); hit(e); } }); })(); document.getElementById('inssel').addEventListener('click', function(e){ var b = e.target.closest('.insopt'); if (b) selectIns(+b.getAttribute('data-ins')); }); /* Same shape as the transport's long press (500ms, an 8px move cancel, and a swallowed click), so the two buttons behave alike under the finger. */ (function(){ var b = document.getElementById('save'); var t = 0, swallow = false, sy = 0, pid = null; var cancel = function(){ if (t){ clearTimeout(t); t = 0; } }; b.addEventListener('pointerdown', function(e){ if (pid !== null || b.disabled) return; pid = e.pointerId; swallow = false; sy = e.clientY; cancel(); t = setTimeout(function(){ t = 0; swallow = true; saveTake(1); }, 500); }); b.addEventListener('pointermove', function(e){ if (e.pointerId !== pid) return; if (Math.abs(e.clientY - sy) > 8) cancel(); }); var end = function(e){ if (e.pointerId === pid){ pid = null; cancel(); } }; b.addEventListener('pointerup', end); b.addEventListener('pointercancel', end); b.addEventListener('click', function(e){ if (swallow){ swallow = false; return; } /* the hold has no keyboard equivalent, so give it a second door */ saveTake(e.shiftKey ? 1 : 0); }); })(); paintSave(); document.getElementById('iomode').addEventListener('click', ioToggle); document.getElementById('iocopy').addEventListener('click', ioCopy); document.getElementById('iopaste').addEventListener('click', ioPaste); document.getElementById('ioscan').addEventListener('click', function(){ scanStart(SCAN_SONG); }); document.getElementById('iotext').addEventListener('input', qrSoon); document.getElementById('ioqrc').addEventListener('click', function(){ var box = document.getElementById('ioqr'); if (box.classList.contains('off')) return; document.getElementById('io').classList.toggle('qrbig'); }); document.getElementById('ioimp').addEventListener('click', ioImport); document.getElementById('ioreset').addEventListener('click', ioReset); document.getElementById('songsave').addEventListener('click', songSaveNow); document.getElementById('songname').addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); songSaveNow(); } }); document.getElementById('ioclose').addEventListener('click', function(){ document.getElementById('io').close(); }); document.getElementById('helpx').addEventListener('click', function(){ document.getElementById('help').close(); }); /* fires for Escape and for a backdrop tap too, so the timer always stops */ document.getElementById('help').addEventListener('close', function(){ if (HELPT){ clearInterval(HELPT); HELPT = 0; } SYNC.row = 0; /* asked for while it was open, not for ever */ /* Escape and a backdrop tap come through here too, so this is the one place that reliably releases the camera -- a preview left running behind a closed dialog is a light on the phone and nothing to turn it off with. */ if (SCAN) scanStop(null); }); /* Both devices have to be on the same pair to hear each other, which is why the line reads out what it is rather than just offering a switch. */ /* Same idiom as the sync tone below: the line reads out what it is, and a tap cycles it. Anything the browser will not encode is skipped rather than offered and then quietly substituted. */ document.getElementById('tkstate').addEventListener('click', function(e){ e.stopPropagation(); /* the backdrop-close listener is above */ var i = -1, j; for (j = 0; j < TAKEORD.length; j++) if (TAKEORD[j] === (TKEY || takeRoute())) i = j; for (j = 1; j <= TAKEORD.length; j++){ var k = TAKEORD[(i + j) % TAKEORD.length]; if (takeOK(k)){ setTake(k); break; } } setHint('take format: ' + TAKES[takeRoute()].lab); }); paintTake(); /* There is nothing to pair any more, so the tap goes to the only thing about sync you might still have to type: which server. */ document.getElementById('syncstate').addEventListener('click', function(e){ e.stopPropagation(); SYNC.row = 1; /* asked for: paintSyncState obeys it */ paintSyncState(); /* NOT focused, and openIO already says why in its own words: focusing a text field from script does not reliably raise the on-screen keyboard, and on Android a later tap on an ALREADY-FOCUSED input fires no focus change -- so the keyboard never comes up at all and the field sits there looking focused and refusing to take a character. Revealing it and letting the finger do the focusing is the gesture that works. */ setHint('tap the field to type the address, SCAN the code, or TRUST first'); }); /* Typing keeps TRUST pointing at the address in the box rather than at the one 300ms ago: paintSyncState below owns the href, but it runs on an interval, and tapping TRUST straight after editing would otherwise open the previous host. Same lesson as the row's own visibility -- the painter owns it, and this only makes the painter run sooner. */ document.getElementById('wsurl').addEventListener('input', paintSyncState); document.getElementById('wsscan').addEventListener('click', function(e){ e.stopPropagation(); scanStart(SCAN_ADDR); }); document.getElementById('wsset').addEventListener('click', function(e){ e.stopPropagation(); /* the backdrop-close listener is above */ var v = document.getElementById('wsurl').value.trim(); syncStore(v); if (!syncLive()) setHint(v ? 'sync server: ' + v : 'sync server: from this page'); else { syncClose(1); syncOpen(SYNC.role); } /* live: move to the new address */ paintSyncState(); /* SET is the end of the errand, so it closes the dialog. The close handler stops any running camera and clears the asked-for flag. */ scanStop(null); document.getElementById('help').close(); }); backdropClose(document.getElementById('io')); backdropClose(document.getElementById('help')); /* fires for Escape too, so every way out of the dialog releases the camera */ document.getElementById('io').addEventListener('close', function(){ scanStop(null); document.getElementById('io').classList.remove('qrbig'); }); /* Hold to repeat: a single tap is +/-1, so 120 -> 160 would otherwise be 40 separate taps on a phone. */ function nudge(el2, step){ var t0 = 0, iv = 0; var stop = function(){ clearTimeout(t0); clearInterval(iv); t0 = iv = 0; }; el2.addEventListener('pointerdown', function(e){ e.preventDefault(); setBpm(bpm + step); stop(); t0 = setTimeout(function(){ iv = setInterval(function(){ setBpm(bpm + step); }, 60); }, 400); }); ['pointerup','pointercancel','pointerleave'].forEach(function(ev){ el2.addEventListener(ev, stop); }); } nudge(document.getElementById('bpmdn'), -1); nudge(document.getElementById('bpmup'), 1); var bp = document.getElementById('bpm'), bdrag = false, bly = 0, bacc = 0; bp.addEventListener('pointerdown', function(e){ bdrag = true; bly = e.clientY; bacc = 0; try { bp.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); }); bp.addEventListener('pointermove', function(e){ if (!bdrag) return; bacc += (bly - e.clientY) * 0.5; bly = e.clientY; if (Math.abs(bacc) >= 1){ setBpm(bpm + Math.trunc(bacc)); bacc -= Math.trunc(bacc); } }); var bup = function(e){ bdrag = false; try { bp.releasePointerCapture(e.pointerId); } catch (x) {} }; bp.addEventListener('pointerup', bup); bp.addEventListener('pointercancel', bup); bp.addEventListener('wheel', function(e){ e.preventDefault(); var unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 120 : 1; setBpm(bpm - clamp((e.deltaY * unit) / 100, -3, 3) * (e.shiftKey ? 1 : 4)); }, {passive:false}); bp.addEventListener('keydown', function(e){ if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { e.preventDefault(); setBpm(bpm + (e.shiftKey ? 10 : 1)); } else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft'){ e.preventDefault(); setBpm(bpm - (e.shiftKey ? 10 : 1)); } }); /* octave */ var bumpOct = function(d){ return function(){ OCT = clamp(OCT + d, -2, 2); paintOct(); paintAllSteps(); buildKeys(); drawSpec(); save(); }; }; document.getElementById('octdn').addEventListener('click', bumpOct(-1)); document.getElementById('octup').addEventListener('click', bumpOct(1)); paintOct(); /* pattern actions */ document.getElementById('rnd').addEventListener('click', function(){ for (var i = 0; i < 16; i++){ STEPS[i].on = (i % 4 === 0) || Math.random() < 0.35; /* still drawn from the scale so it stays musical, but stored as semitones */ STEPS[i].semis = [clamp(degToSemi(Math.round((Math.random() * 2 - 1) * 7), SCALE), -12, 12)]; STEPS[i].dur = Math.random() < 0.72 ? 0 : (Math.random() < 0.7 ? 1 : 2); } paintAllSteps(); save(); }); document.getElementById('clr').addEventListener('click', function(){ for (var i = 0; i < 16; i++){ STEPS[i].on = false; STEPS[i].semis = [0]; STEPS[i].dur = 0; } clearAllLocks(); markAllKnobs(); paintAllSteps(); save(); }); /* Backgrounding no longer stops the sequencer -- tick() widens its horizon instead, which is what the throttled timer actually needed. The camera is a different matter and is still dropped on the way out. */ document.addEventListener('visibilitychange', function(){ /* FIRST, ahead of anything that could throw. save() debounces 300ms, and a backgrounded tab can be killed inside that window -- on iOS this is the last moment the page is guaranteed, because beforeunload is effectively never fired there. saveNow() no-ops when nothing is pending, so an ordinary tab switch costs nothing. */ if (document.hidden){ saveNow(); /* Hand the rate back before the horizon widens, so the trim in flight does not get applied to a bar and a half of audio at once. syncLock refuses to run hidden; this is the other half of that. */ SYNC.rate = 1; scanStop(null); return; /* never hold the camera in the background */ } /* iOS interrupts the AudioContext on the way out and does not always restart it on the way back in. Harmless when it is already running. */ wakeOn(); /* the browser dropped it while hidden */ if (playing && A && A.ctx.state !== 'running'){ var r = A.ctx.resume(); if (r && r.then) r.then(function(){}, function(){}); } }); /* The other half of the flush. Safari does not reliably fire visibilitychange on the way out of a navigation, so a RELOAD would still drop a pending save; pagehide is fired for a reload, a navigation and a bfcache eviction on every browser. Both call saveNow(), which writes at most once -- see there. Deliberately NOT `unload`: that disqualifies the page from the back/forward cache, and iOS never fires it anyway. */ window.addEventListener('pagehide', function(){ saveNow(); /* CLEAN only here. Not on visibilitychange -- a hidden tab may still come back, and a tab killed while hidden has to read as killed or the whole distinction is worthless. */ sessWrite(1); if (SESST){ clearInterval(SESST); SESST = 0; } }); window.addEventListener('keydown', function(e){ if (e.code === 'Space' && e.target === document.body){ e.preventDefault(); playing ? stop() : start(); } }); } /* ========================================================== persistence == */ var KEY = 'synthi.v2', OLDKEY = 'synthi.v1', saveT = 0; /* SCENES in the DOCUMENT shape, which is the only shape this file's readers understand. save() used to hand `JSON.stringify(SCENES)` straight to localStorage, and that was the entire drum bug: a live scene carries `drums` as a 4x16 array of 0/1 and two full 4x16 grids under `dnudge`/`drate`, while the reader wants four 'x'/'.' strings and a sparse `dmod`. Everything ELSE a scene holds already reads as a document by luck -- loadOneIns wants exactly the v/steps/oct/envOn that blankIns writes, and a step's `semis` array is the same array either way -- which is precisely why the notes always survived a reload and only the beat did not. So this is the whole conversion, and after it save() and exportText() write ONE encoding and cannot drift again. Smaller, too: four 16-character strings and a sparse list against 64 plus 128 numbers, about 330 bytes a scene, which the quota path in songPut cares about more than anything else here does. Every access below is guarded, and that matters more than it looks: save()'s own `catch (e) {}` would swallow a throw in here and kill the autosave silently and permanently -- the same failure mode this is fixing. */ function sceneDocs(){ return SCENES.map(function(sc){ var o = {}, k; sc = sc || {}; for (k in sc) if (k !== 'dnudge' && k !== 'drate') o[k] = sc[k]; o.drums = drumsToRows(sc.drums); /* four lanes of 16 characters */ o.dmod = modsToSparse(sc.dnudge, sc.drate); /* [lane, step, nudge, rate] */ return o; }); } function save(){ clearTimeout(saveT); saveT = setTimeout(saveNow, 300); } /* The write itself, split out so the hide hooks can force it. A PENDING TIMER is what "there is something to write" means, so this returns at once when there is not -- which is what lets pagehide AND visibilitychange both call it without ever writing the same bytes twice, and needs no new flag to stay consistent. */ function saveNow(){ if (!saveT) return; clearTimeout(saveT); saveT = 0; try { stashScalars(); /* MUST precede sceneDocs: it is what writes the live DRUMS/DNUDGE/DRATE back into SCENES[scn] */ localStorage.setItem(KEY, JSON.stringify({ scenes:sceneDocs(), nScn:nScn(), scn:scn, hold:HOLD, bpm:bpm, arm:ARM, /* legacy shape too, so an older build still reads the first scene */ v:V, steps:STEPS, oct:OCT, root:ROOT, scale:SCALE, ins:INS, envOn:ENV_ON, fltOn:FLT_ON, fxOn:FX_ON, revOn:REV_ON, /* the beat, in a legacy slot it never had one in. applyState reads these into DRUMS/DNUDGE/DRATE before it looks at `scenes`, so they only ever win when `scenes` is missing or every entry is unreadable -- and for about eighty bytes the legacy block above stops lying by omission. */ drums:drumsToRows(DRUMS), dmod:modsToSparse(DNUDGE, DRATE), /* global, not per scene: a mute is a performance action, and so is deciding which knob an LFO has hold of -- a routing that changed under you when the chain advanced would be a surprise, not a feature */ mute:MUTE, route:ROUTE })); } catch (e) {} /* iOS private browsing throws on write */ } /* v1 stored waves as SIN/TRI/SAW/PLS (0..3); v2 folds TRI and SAW into RAMP, so an old index must be translated rather than clamped into range. */ function migrateWave(ow){ ow = Math.round(ow); if (ow === 0) { V['osc.wave'] = 0; } else if (ow === 1) { V['osc.wave'] = 1; V['osc.duty'] = 0.5; } /* triangle */ else if (ow === 2) { V['osc.wave'] = 1; V['osc.duty'] = 1; } /* sawtooth */ else { V['osc.wave'] = 2; } /* pulse */ } function load(){ var d = null, legacy = false; try { d = JSON.parse(localStorage.getItem(KEY) || 'null'); if (!d){ d = JSON.parse(localStorage.getItem(OLDKEY) || 'null'); legacy = !!d; } } catch (e) { return; } if (!d || typeof d !== 'object') return; applyState(d, legacy); } /* One scene's pair of instruments. MIGRATION. Revision 48 and 49 scenes had one pattern and an `eng` flag saying which engine played it. Those go into the instrument the flag names, with the other left silent -- so a song built on the FM engine comes back as an FM instrument with its pattern intact rather than as a VA one playing the wrong timbre. Anything older has no flag at all and lands in instrument 0. Repair rather than reject throughout: a hand-edited file that lost a step line or a whole instrument block should still load. */ function loadOneIns(raw, sv, scl, quiet){ var iv = blankIns(quiet), i, k, rs = Array.isArray(raw && raw.steps) ? raw.steps : null; for (i = 0; i < INSP.length; i++){ k = INSP[i]; var x = raw && raw.v && raw.v[k]; if (typeof x === 'number' && isFinite(x)) iv.v[k] = clamp(x, P[k][0], P[k][1]); else if (sv && typeof sv[k] === 'number') iv.v[k] = sv[k]; } if (rs){ iv.steps = []; for (i = 0; i < 16; i++){ var st = (rs[i] && typeof rs[i] === 'object') ? rs[i] : {}; iv.steps.push({on:!!st.on, semis:loadSemis(st, scl), dur:clamp(st.dur|0,0,DURS.length-1), /* absent is zero, so anything written before these existed loads with them all off */ acc:st.acc ? 1 : 0, sld:st.sld ? 1 : 0, shift:clamp(st.shift|0, -1, 1), lock:parseLock(st.lock)}); } } if (raw && typeof raw.oct === 'number') iv.oct = clamp(raw.oct | 0, -2, 2); if (raw && typeof raw.envOn === 'boolean') iv.envOn = raw.envOn; return iv; } function insPairFrom(raw, sv, scl){ if (Array.isArray(raw.ins) && raw.ins.length === 2) return [loadOneIns(raw.ins[0], sv, scl, 0), loadOneIns(raw.ins[1], sv, scl, 1)]; /* one pattern, revision 49 or older: place it by the engine flag */ var one = loadOneIns({steps:raw.steps, oct:raw.oct, envOn:raw.envOn, v:sv}, sv, scl, 0); var mt = blankIns(1); return (raw.eng ? 1 : 0) ? [mt, one] : [one, mt]; } /* Everything below used to be the body of load(). It is the single validation path: import goes through it too, so the two can never drift apart. */ function applyState(d, legacy){ if (d.v) for (var k in P) if (typeof d.v[k] === 'number' && isFinite(d.v[k])) V[k] = clamp(d.v[k], P[k][0], P[k][1]); if (Array.isArray(d.steps) && d.steps.length === 16){ for (var i = 0; i < 16; i++){ var s = d.steps[i] || {}; STEPS[i].on = !!s.on; STEPS[i].semis = loadSemis(s, SCALE); STEPS[i].dur = clamp(s.dur | 0, 0, DURS.length - 1); /* pre-duration saves -> 1/16 */ STEPS[i].lock = parseLock(s.lock); /* pre-lock saves -> none */ } } if (legacy){ if (d.v && typeof d.v['osc.wave'] === 'number') migrateWave(d.v['osc.wave']); else if (typeof d.wave === 'number') migrateWave(d.wave); } DRUMS = loadDrums(d.drums); /* absent -> the seed beat; present -> as written */ var dm = loadMods(d); /* `dmod`, or the in-memory grids -- see loadMods */ DNUDGE = dm.nud; DRATE = dm.rat; if (typeof d.oct === 'number') OCT = clamp(d.oct | 0, -2, 2); if (typeof d.root === 'number') ROOT = clamp(d.root | 0, 0, 11); if (SCALES[d.scale]) SCALE = d.scale; if (typeof d.bpm === 'number') bpm = clamp(d.bpm | 0, 40, 220); if (typeof d.envOn === 'boolean') ENV_ON = d.envOn; if (typeof d.fltOn === 'boolean') FLT_ON = d.fltOn; if (typeof d.fxOn === 'boolean') FX_ON = d.fxOn; if (typeof d.revOn === 'boolean') REV_ON = d.revOn; /* Mutes survive a reload but are not part of any scene. TWO shapes have to be accepted: save() writes the object it holds in memory, and exportText writes a sorted array because that is what reads well in a file. Taking only one of them would have silently dropped the mutes on whichever path was not tested. Only the four known channels are taken, so a hand-edited file cannot inject keys. */ ROUTE = {}; if (d.route && typeof d.route === 'object') for (var rk in d.route){ var rv = d.route[rk]; if (routable(rk) && SRC[rv]) ROUTE[rk] = rv; /* only ids and sources we know */ } MUTE = {}; if (Array.isArray(d.mute)) d.mute.forEach(function(k){ if (MIXCH.indexOf(k) >= 0) MUTE[k] = 1; }); else if (d.mute && typeof d.mute === 'object') MIXCH.forEach(function(k){ if (d.mute[k]) MUTE[k] = 1; }); /* which instrument the selector was left on. Not song data, but restoring it means a reload puts you back where you were editing. */ if (typeof d.ins === 'number') INS = d.ins ? 1 : 0; else if (typeof d.eng === 'number') INS = d.eng ? 1 : 0; /* revision 48/49 */ /* Saves predating scenes carry one pattern at top level; everything above has already loaded it, so it simply becomes scene 0. SCENES is emptied FIRST: liveScene() reads the current scene to find the instrument the selector is not on, and at this moment that is still the OUTGOING song's scene. */ SCENES = []; scn = 0; SCENES = [liveScene()]; if (Array.isArray(d.scenes) && d.scenes.length){ var built = []; for (var si = 0; si < d.scenes.length && si < 8; si++){ var raw = d.scenes[si]; if (!raw || typeof raw !== 'object') continue; /* Repair rather than reject: a hand-edited file that lost its v block or a step line should still load, or one deleted line costs a whole scene. */ var rv = (raw.v && typeof raw.v === 'object') ? raw.v : {}; var sv = {}, pk; for (pk in P) sv[pk] = (typeof rv[pk] === 'number' && isFinite(rv[pk])) ? clamp(rv[pk], P[pk][0], P[pk][1]) : P[pk][2]; var scl = SCALES[raw.scale] ? raw.scale : 'minor'; /* ONE call. This was sparseToMods(raw.dmod) twice, building four 4x16 grids per scene and throwing two of them away. */ var sdm = loadMods(raw); built.push({ v: sv, ins: insPairFrom(raw, sv, scl), drums: loadDrums(raw.drums), dnudge: sdm.nud, drate: sdm.rat, fltOn:!!raw.fltOn, fxOn:!!raw.fxOn, revOn:!!raw.revOn, root:clamp(raw.root|0,0,11), scale:scl }); } if (built.length){ SCENES = built; scn = clamp(d.scn | 0, 0, SCENES.length - 1); switchTo(scn, undefined, true); } } if (typeof d.hold === 'boolean') HOLD = d.hold; /* A document that does not say how long the chain is means "as long as the scenes you can see" -- without this the previous song's count survives and ensureScenes() clones the shortfall straight back. */ V['seq.scn'] = clamp((typeof d.nScn === 'number' ? d.nScn | 0 : SCENES.length), 1, 8); if (d.arm && typeof d.arm === 'object'){ ARM = {}; if (Array.isArray(d.arm)){ /* "arm": ["flt.cut"] -- the natural hand-edit */ for (var ai = 0; ai < d.arm.length; ai++) if (LOCKABLE[d.arm[ai]]) ARM[d.arm[ai]] = 1; } else { for (var ak in d.arm) if (LOCKABLE[ak] && d.arm[ak]) ARM[ak] = 1; } } scanGlock(); } /* ==================================================== demo songs ======= Four riffs that come with the app, so it plays something recognisable before you have programmed anything. THESE ARE EVOCATIONS, NOT TRANSCRIPTIONS. All four are famous synth records and all four are somebody's copyright; what fits in a 16-step grid is the ostinato that makes each one recognisable, written by ear-in-the-head rather than checked against the recording. Named for the reference, not offered as accurate. If one sounds wrong it is wrong, and the notation below is one line to edit. Authored compactly rather than stored as song JSON: four exported songs would be 40-80KB of a file that has to stay one self-contained page, and this is a few hundred bytes each AND readable. One string per scene, sixteen space-separated steps, each either `.` for silence or semitones-from-the-root with an optional duration index: `0`, `-12`, `0,3,7` for a chord, `7:2` for a quarter note. Expanded into the documented export shape at load time and handed to importText, so the demos go through exactly the same validation as a pasted song. */ /* Four demos, and the point of naming them after records is that they have to actually evoke those records. Revision 60's did not: I wrote every note from memory, and the keys, modes and harmony were all simply wrong. These are rebuilt from published analysis -- key, mode, tempo and chords cited per song -- and each part is marked for where it came from, because I cannot hear and "sounds right" is not something a test of mine can establish. Two of them are better than sourced: this folder holds recordings of Chase and Oxygene, so their key, mode, tempo and chord-per-bar progression are MEASURED off the audio by spectral analysis. Knight Rider and Blinding Lights have no recording here, so they rest on published analysis alone. Four bars each (Blinding Lights needs eight; see there). Notation: `.` is silence, otherwise semitones from the root with an optional duration index, so `0`, `-12`, `0,3,7` for a chord, `7:2` for a quarter. */ var DEMOS = [ /* F# PHRYGIAN DOMINANT, 116, two harmonic poles a whole tone apart shuttling F# <-> Em -- twochords.substack.com/p/knight-rider-1982, corroborated by the bass tab at bigbasstabs.com, which is the same shuttle transposed to A <-> G. The mode is the sound: a flat 2nd over a MAJOR 3rd, which no amount of natural minor gets you. Bars alternate F# | E | F# | E. */ { name:'Knight Rider', bpm:116, root:6, scale:'hijaz', /* F# freygish */ /* SOURCED. The engine: relentless sixteenths on the pole root and nothing else. The letter-note transcription is literally a run of one repeated note per bar, and that IS the hook -- a car, not a tune. */ va:{oct:-1, wave:2, duty:0.35, env:[0.002,0.06,0.00,0.04], pat:['0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0', '-2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2', '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0', '-2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2']}, /* bars 1-2 SOURCED: the plucked ostinato "half-steps dancing around the fifth of each chord", bringing in non-diatonic pitches. Over F# the fifth is C#, so C# D C# C -- and that C is deliberately outside the scale, which is the whole point of the description. Over Em the fifth is B: B C B A#. bars 3-4 RECONSTRUCTED: the horn fanfare, my reading of the analysis's solfege shorthand "m f t r m' f'" = A# B E G A#' B'. The least certain thing in these four songs. */ fm:{oct:0, ratio:3, idx:4, env:[0.002,0.10,0.00,0.06], pat:['7 8 7 6 7 8 7 6 7 8 7 6 7 8 7 6', '5 6 5 4 5 6 5 4 5 6 5 4 5 6 5 4', '4:1 . 5:1 . 10:1 . 13:1 . 16:2 . . . . . . .', '17:4 . . . . . . . . . . . . . . .']}, drm:['x.......x.......', '....x.......x...', 'x.x.x.x.x.x.x.x.', '..............x.'], flt:{cut:2000, res:8, env:0.40}, mix:{va:1, fm:0.8, drm:0.8} }, /* C MINOR, 112, and the first demo built AROUND the step modifiers rather than before them. WHAT IS SOURCED, and it is all production fact rather than notes: 112 BPM and a minor mode on C (getsongbpm / tunebat / songbpm all agree); the duo wrote it after listening to American G-funk records; and the bass was played on a ROLAND TB-303 -- which is the whole reason this demo exists here, because accent, slide and octave-shift ARE the 303's three per-step controls and this file now has them. WHAT IS NOT SOURCED: the line. The lead hook of that record is the copyrighted, distinctive part of the composition and it is not transcribed here. What this plays is an ORIGINAL line in the same idiom -- root-centred, syncopated, slid into and accented off the beat, through a resonant low-pass with a fast envelope -- which is what makes a 303 lead sound like a 303 lead. Treat it as a patch-and-groove demonstration named after its influence, the way the other four are evocations rather than transcriptions. The lead enters a beat in, on step 5, so the first bar has a run-up. */ { name:'Da Funk', bpm:112, root:0, scale:'minor', /* Every flag in the third field of a token is one of the new modifiers: a = accent, s = slide, u/d = an octave up or down for that step alone. */ va:{oct:0, wave:1, duty:0.5, env:[0.002, 0.12, 0.05, 0.10], pat:['. . . . 0:0:a . 0 3:0:s . 0 . 7:0:a 5:0:s 0 . 10:0:d', '0:0:a . 3 . 5:0:s . 7:0:a . 0:0:u . 10 . 7:0:s . 3 .', '7:0:a . 7 . 5:0:s . 3 . 2:0:s . 0:0:a . 0 . 10:0:d .', '0:0:a . 0 . 3:0:s . 5 . 7:0:a . 8:0:s . 7 . 5:0:s .']}, /* silent: you asked for the lead, so the FM lane stays out of the way */ fm:{oct:0, ratio:1, idx:0, env:[0.002, 0.10, 0.00, 0.05], pat:['. . . . . . . . . . . . . . . .']}, /* A house record is unlistenable without the floor, so: four-on-the-floor, a clap on 2 and 4, offbeat closed hat. Mute DRUMS in the mixer for the lead on its own. */ drm:['x...x...x...x...', '....x.......x...', '..x...x...x...x.', '................'], /* the squelch: low corner, high resonance, and the envelope doing the talking -- the same three numbers a 303's cutoff/resonance/env-mod are */ flt:{cut:700, res:14, env:0.55}, mix:{va:1, fm:0, drm:0.7} }, /* C DORIAN, 126, and the melody is now the REAL one: Hooktheory clipboard data for the tune, supplied verbatim, whose own key field says dorian on C -- so the scale this revision added is confirmed by the source of the notes as well as by the audio. 480 ticks to the beat, 23 notes, and bars 7-8 duplicate bars 1-2, which means the clip runs one phrase plus the head of its repeat: the PHRASE IS SIX BARS. Hence six scenes, not four. And it corrects a claim of mine. My chord-per-bar fit of the recording read Dm | F | Cm | Cm, but the melody's held notes ARE D (bars 3-4) and F (bars 5-6) -- the fit was tracking the tune, not the harmony. The low-band measurement was the honest one: the bass sits on C with Bb under it. So the harmony here is a C pedal and the movement is all in the melody. */ { name:'Oxygene Pt 4', bpm:126, root:0, scale:'dorian', /* C dorian */ /* MEASURED. The low band of the recording is C2 and C3 loudest with Bb next, so: an octave pulse on C with the flat 7th on the last eighth of the bar. A pedal, which is what the piece actually does underneath. */ va:{oct:-1, wave:1, duty:0.5, env:[0.004,0.30,0.55,0.30], pat:['0 . 12 . 0 . 12 . 0 . 12 . 0 . 10 .', '0 . 12 . 0 . 12 . 0 . 12 . 0 . 10 .', '0 . 12 . 0 . 12 . 0 . 12 . 0 . 10 .', '0 . 12 . 0 . 12 . 0 . 12 . 0 . 10 .', '0 . 12 . 0 . 12 . 0 . 12 . 0 . 10 .', '0 . 12 . 0 . 12 . 0 . 12 . 0 . 10 .']}, /* THE ACTUAL MELODY, note for note and onset for onset. C5 G Eb G, C held / Bb A G A, D held / A G F C / A G F C, and then round again. Only the LENGTHS are approximate: DURS is powers of two, so the 1.5-beat notes ring for 1 beat, the 2.5-beat ones for 2 and the 4.5-beat ones for 4. Every pitch and every onset is exact. */ fm:{oct:1, ratio:1, idx:2, env:[0.03,0.35,0.60,0.40], pat:['12:2 . . . . . 7:1 . 3:1 . 7:2 . . . 0:4 .', '. . . . . . . . . . . . . . . .', '10:2 . . . . . 9:1 . 7:1 . 9:2 . . . 2:4 .', '. . . . . . . . . . . . . . . .', '9:1 . 7:1 . 5:1 . 0:3 . . . . . . . . .', '9:1 . 7:1 . 5:1 . 0:3 . . . . . . . . .']}, drm:['x.......x.......', '................', '..x...x...x...x.', '................'], flt:{cut:2400, res:5, env:0.50}, mix:{va:0.8, fm:0.95, drm:0.35} }, /* C minor, 126 -- MEASURED. A chord-per-bar fit over the recording in this folder reads Cm | Gm | Cm | Gm | Cm | Gm | Cm | Gm, dead on a 1.905s bar for eight bars running, which fixes the tempo as well as the harmony. So the documented "master sequence Cm, Gm and Cm" is literally all it is: a one-bar i <-> v shuttle, static for minutes at a time. */ { name:'Chase', bpm:126, root:0, scale:'minor', /* C minor */ /* MEASURED, and this is the identity of the record. The low band of a Cm bar is C2 loudest, then C3, then G; a Gm bar is G1 loudest, then G2 and D. So the bubble is root, OCTAVE, fifth, octave in unbroken sixteenths -- the octave jump is what makes it bubble. My first attempt put a minor third in there; the recording has almost none of it in the bass. */ va:{oct:-1, wave:1, duty:0.5, env:[0.002,0.07,0.00,0.05], pat:['0 12 7 12 0 12 7 12 0 12 7 12 0 12 7 12', '-5 7 2 7 -5 7 2 7 -5 7 2 7 -5 7 2 7', '0 12 7 12 0 12 7 12 0 12 7 12 0 12 7 12', '-5 7 2 7 -5 7 2 7 -5 7 2 7 -5 7 2 7']}, /* RECONSTRUCTED contour, MEASURED pitches: the SH-2000 lead register of the recording holds C5, Eb5, G5, D5, Bb4 -- chord tones of the shuttle, on the downbeats only. */ fm:{oct:1, ratio:2, idx:3, env:[0.006,0.20,0.35,0.18], pat:['7:2 . . . . . . . 15:2 . . . . . . .', '14:2 . . . . . . . 10:2 . . . . . . .', '12:2 . . . . . . . 15:2 . . . . . . .', '19:2 . . . . . . . 14:3 . . . . . . .']}, drm:['x...x...x...x...', '....x.......x...', 'x.x.x.x.x.x.x.x.', '..x...x...x...x.'], flt:{cut:1600, res:12, env:0.60}, mix:{va:1, fm:0.6, drm:0.8} }, /* F minor in Eb, 171, Fm - Cm - Eb - Bb at TWO BARS EACH, and the hook is root, b7, root, 9, 5, b7 = F Eb F G C Eb -- spytunes.com/blinding-lights- chords, degree by degree. Eight bars rather than four because the chord rhythm is two bars per chord: at four the progression would run at double speed and the hook, itself a two-bar phrase, would no longer sit over Fm. At 171 that is still only eleven seconds. */ { name:'Blinding Lights', bpm:171, root:5, scale:'minor', /* F minor */ /* SOURCED (the progression). A driving eighth pulse on each chord root. */ va:{oct:-1, wave:2, duty:0.5, env:[0.002,0.08,0.00,0.05], pat:['0 . 0 . 0 . 0 . 0 . 0 . 0 . 0 .', '0 . 0 . 0 . 0 . 0 . 0 . 0 . 0 .', '-5 . -5 . -5 . -5 . -5 . -5 . -5 . -5 .', '-5 . -5 . -5 . -5 . -5 . -5 . -5 . -5 .', '-2 . -2 . -2 . -2 . -2 . -2 . -2 . -2 .', '-2 . -2 . -2 . -2 . -2 . -2 . -2 . -2 .', '-7 . -7 . -7 . -7 . -7 . -7 . -7 . -7 .', '-7 . -7 . -7 . -7 . -7 . -7 . -7 . -7 .']}, /* SOURCED, note for note: F Eb F G C Eb, held on the last. The riff STAYS PUT while the chords move under it, which is how the intro actually works -- so the same two bars repeat all the way through. */ fm:{oct:0, ratio:1, idx:6, env:[0.002,0.12,0.35,0.10], pat:['12:1 . 10:1 . 12:1 . 14:1 . 19:2 . . . . . . .', '22:4 . . . . . . . . . . . . . . .', '12:1 . 10:1 . 12:1 . 14:1 . 19:2 . . . . . . .', '22:4 . . . . . . . . . . . . . . .', '12:1 . 10:1 . 12:1 . 14:1 . 19:2 . . . . . . .', '22:4 . . . . . . . . . . . . . . .', '12:1 . 10:1 . 12:1 . 14:1 . 19:2 . . . . . . .', '22:4 . . . . . . . . . . . . . . .']}, drm:['x...x...x...x...', '....x.......x...', 'x.x.x.x.x.x.x.x.', '..............x.'], flt:{cut:3400, res:4, env:0.30}, mix:{va:0.85, fm:1, drm:0.75} } ]; /* one step token -> the shape stepLine writes and loadSemis reads */ function demoStep(tok){ if (!tok || tok === '.') return {on:false, semis:[0], dur:0}; var bits = String(tok).split(':'); var semis = bits[0].split(',').map(Number).filter(function(x){ return isFinite(x); }); if (!semis.length) semis = [0]; /* An optional third field of letters carries the step modifiers, so a demo can use them: "0,4:2:as" is that chord, a half note, accented and sliding. No existing demo string has a third field, so none of them change. */ var f = String(bits[2] || ''); return {on:true, semis:semis, dur: bits[1] ? (+bits[1] | 0) : 0, acc: /a/.test(f) ? 1 : 0, sld: /s/.test(f) ? 1 : 0, shift: /u/.test(f) ? 1 : (/d/.test(f) ? -1 : 0)}; } function demoRow(str){ var toks = String(str || '').trim().split(/\s+/), out = [], i; for (i = 0; i < 16; i++) out.push(demoStep(toks[i])); return out; } function demoSong(d){ var nsc = Math.max(d.va.pat.length, d.fm.pat.length), scenes = [], k; var ins = function(o, k2){ var v = {}; if (o.env){ v['env.a'] = o.env[0]; v['env.d'] = o.env[1]; v['env.s'] = o.env[2]; v['env.r'] = o.env[3]; } if (o.wave !== undefined) v['osc.wave'] = o.wave; if (o.duty !== undefined) v['osc.duty'] = o.duty; /* ratio is an INDEX into FMR, so it is named by its value here and looked up -- writing 2 and meaning FMR[2] would be a trap for whoever edits it */ if (o.ratio !== undefined) v['fm.ratio'] = Math.max(0, FMR.indexOf(o.ratio)); if (o.idx !== undefined) v['fm.idx'] = o.idx; return {engine: o === d.va ? 'VA' : 'FM', oct: o.oct | 0, envOn: true, v: v, steps: demoRow(o.pat[Math.min(k2, o.pat.length - 1)])}; }; for (k = 0; k < nsc; k++){ var sv = {}; if (d.flt){ sv['flt.cut'] = d.flt.cut; sv['flt.res'] = d.flt.res; sv['flt.env'] = d.flt.env; } if (d.mix){ sv['mix.va'] = d.mix.va; sv['mix.fm'] = d.mix.fm; sv['mix.drm'] = d.mix.drm; } scenes.push({root: d.root, scale: d.scale, fltOn: !!d.flt, fxOn: false, revOn: true, v: sv, ins: [ins(d.va, k), ins(d.fm, k)], drums: d.drm.slice(), dmod: []}); } return {synthshop: FMT, bpm: d.bpm, nScn: nsc, scn: 0, hold: false, mute: [], arm: [], scenes: scenes}; } /* The startup track as a song document. Built FROM the defaults rather than beside them -- blankIns() already fills the per-instrument keys from P[id][2] and lays out freshSteps(), and applyState fills the scene's own `v` from P[id][2] for every key a document omits, which is why `v` here is empty. So this cannot drift when a default changes. RESET hands it to importText(), the same single validated path the demos and a pasted song use: same parser, same clamping, same repaint, same messages. */ function factorySong(){ var mk = function(quiet){ var iv = blankIns(quiet); return {oct: iv.oct, envOn: iv.envOn, v: iv.v, steps: iv.steps}; }; return {synthshop: FMT, bpm: SEED.bpm, nScn: 1, scn: 0, hold: false, mute: [], arm: [], scenes: [{root: SEED.root, scale: SEED.scale, fltOn: false, fxOn: false, revOn: false, v: {}, ins: [mk(0), mk(1)], drums: DRUM_SEED.slice(), dmod: []}]}; } async function ioReset(){ var r = await importText(JSON.stringify(factorySong())); if (!r.ok){ ioMsg(r.msg, 1); return; } /* cannot happen; says so if it does */ paintSongs(); /* the slot list is untouched, but repaint it */ ioMsg('Back to the startup track. Your saved songs are still here.'); } /* ==================================================== named songs ======= Slots in localStorage, kept apart from the working state so autosave can never touch them. One index object, {name: payload}, where the payload is exactly what the COPY button produces -- gzip+base64 of exportText() -- so loading a slot is importText() and nothing else: same parser, same validation, same repaint, same error messages. PER ORIGIN, which is stronger than "per browser": entry 83 established that this app runs both from file:// and from https://<lan-ip>, and localStorage is keyed by origin. A song saved from the file is simply not there when the same phone opens the app over the network. The dialog says so, because otherwise it reads as data loss. */ var SONGKEY = 'synthi.songs'; function songAll(){ try { var o = JSON.parse(localStorage.getItem(SONGKEY) || 'null'); return (o && typeof o === 'object' && !Array.isArray(o)) ? o : {}; } catch (e) { return {}; } } function songNames(){ var a = [], k; for (k in songAll()) a.push(k); return a.sort(); } function isDemo(nm){ for (var i = 0; i < DEMOS.length; i++) if (DEMOS[i].name === nm) return 1; return 0; } /* Returns a message rather than throwing: a quota failure has to be VISIBLE. save() swallows its exception on purpose -- losing one autosave is better than a broken app -- but a slot that silently did not save would be a lie. */ function songPut(nm, payload){ nm = String(nm == null ? '' : nm).trim().slice(0, 24); if (!nm) return 'Give it a name first.'; if (isDemo(nm)) return '“' + nm + '” is one of the demos — pick another name.'; var all = songAll(); var had = Object.prototype.hasOwnProperty.call(all, nm); all[nm] = payload; try { localStorage.setItem(SONGKEY, JSON.stringify(all)); } catch (e) { return 'No room left in this browser’s storage — delete a song, or keep this one as text.'; } return {ok: nm, replaced: had}; } function songDrop(nm){ if (isDemo(nm)) return; var all = songAll(); delete all[nm]; try { localStorage.setItem(SONGKEY, JSON.stringify(all)); } catch (e) {} } /* The demos first and always, then whatever you have saved. Built rather than templated because a song name is user text and must never reach innerHTML. */ function paintSongs(){ var host = document.getElementById('slots'); if (!host) return; host.textContent = ''; var rows = [], i; for (i = 0; i < DEMOS.length; i++) rows.push({nm: DEMOS[i].name, demo: 1}); songNames().forEach(function(nm){ rows.push({nm: nm, demo: 0}); }); rows.forEach(function(r){ var el = document.createElement('div'); el.className = 'slot' + (r.demo ? ' demo' : ''); var nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = r.nm; nm.title = r.nm; el.appendChild(nm); var ld = document.createElement('button'); ld.className = 'act'; ld.textContent = 'LOAD'; ld.title = 'Load “' + r.nm + '” — replaces everything you have'; ld.addEventListener('click', function(){ songLoad(r.nm, r.demo); }); el.appendChild(ld); if (!r.demo){ var rm = document.createElement('button'); rm.className = 'drop'; rm.textContent = '\u00d7'; rm.setAttribute('aria-label', 'Delete ' + r.nm); rm.title = 'Delete “' + r.nm + '”'; rm.addEventListener('click', function(){ songDrop(r.nm); paintSongs(); ioMsg('Deleted “' + r.nm + '”.'); }); el.appendChild(rm); } host.appendChild(el); }); if (!songNames().length){ var p2 = document.createElement('div'); p2.className = 'slotnone'; p2.textContent = 'Nothing saved yet — name it above and press SAVE.'; host.appendChild(p2); } } /* Both kinds go through importText: same parser, same validation, same repaint, same error messages. A demo is expanded to song JSON first; a saved one is already the gzip+base64 the COPY button makes. */ async function songLoad(nm, demo){ var txt; if (demo){ var d = null, i; for (i = 0; i < DEMOS.length; i++) if (DEMOS[i].name === nm) d = DEMOS[i]; if (!d) return; txt = JSON.stringify(demoSong(d)); } else { txt = songAll()[nm]; if (txt === undefined){ ioMsg('“' + nm + '” is not there any more.', 1); return; } } var r = await importText(txt); if (!r.ok){ ioMsg('“' + nm + '” would not load — ' + r.msg, 1); return; } setHint('loaded “' + nm + '”'); document.getElementById('io').close(); } async function songSaveNow(){ var inp = document.getElementById('songname'); var payload; try { payload = await toB64(exportText()); } catch (e){ ioMsg('Could not package the song: ' + e.message, 1); return; } var r = songPut(inp.value, payload); if (typeof r === 'string'){ ioMsg(r, 1); return; } inp.value = ''; paintSongs(); ioMsg((r.replaced ? 'Replaced “' : 'Saved “') + r.ok + '”.'); } /* ================================================== QR encoder ========== Byte mode, error correction level L, versions 1-40. Written out because the app is one self-contained file with no network access, so a library is not an option. Verified module-for-module against libqrencode. */ /* GF(256) with the QR primitive polynomial 0x11d */ var GF_EXP = new Uint8Array(512), GF_LOG = new Uint8Array(256); (function(){ var x = 1, i; for (i = 0; i < 255; i++){ GF_EXP[i] = x; GF_LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } for (i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255]; })(); function gfMul(a, b){ return (a === 0 || b === 0) ? 0 : GF_EXP[GF_LOG[a] + GF_LOG[b]]; } /* generator polynomial for n error-correction codewords */ var GEN_CACHE = {}; function rsGen(n){ if (GEN_CACHE[n]) return GEN_CACHE[n]; var p = [1], i, j; for (i = 0; i < n; i++){ var q = new Array(p.length + 1); for (j = 0; j < q.length; j++) q[j] = 0; for (j = 0; j < p.length; j++){ q[j] ^= p[j]; /* * x */ q[j + 1] ^= gfMul(p[j], GF_EXP[i]); /* * a^i */ } p = q; } return (GEN_CACHE[n] = p); } function rsRemainder(data, n){ var gen = rsGen(n), buf = new Uint8Array(data.length + n), i, j; buf.set(data); for (i = 0; i < data.length; i++){ var c = buf[i]; if (!c) continue; for (j = 0; j < gen.length; j++) buf[i + j] ^= gfMul(gen[j], c); } return buf.subarray(data.length); } /* The only hard-coded table: [ec codewords per block, block count] at level L. Everything else is derived -- total codewords by counting free modules, and the short/long group split from totalData and blockCount. */ var ECL = [ [7,1],[10,1],[15,1],[20,1],[26,1],[18,2],[20,2],[24,2],[30,2],[18,4], [20,4],[24,4],[26,4],[30,4],[22,6],[24,6],[28,6],[30,6],[28,7],[28,8], [28,8],[28,9],[30,9],[30,10],[26,12],[28,12],[30,12],[30,13],[30,14],[30,15], [30,16],[30,17],[30,18],[30,19],[30,19],[30,20],[30,21],[30,22],[30,24],[30,25] ]; function alignPositions(v){ if (v < 2) return []; var n = Math.floor(v / 7) + 2, last = 4 * v + 10; /* version 32 is the documented exception to the even-spacing rule */ var step = (v === 32) ? 26 : Math.ceil((last - 6) / (n - 1) / 2) * 2; var pos = [], i; for (i = 0; i < n - 1; i++) pos.push(last - i * step); pos.push(6); return pos.reverse(); } /* reserved[y][x] = true where a function pattern lives, so the data zigzag knows to skip it and so free modules can simply be counted */ function functionMap(v){ var size = 4 * v + 17, i, j, x, y; var res = [], mod = []; for (y = 0; y < size; y++){ res.push(new Uint8Array(size)); mod.push(new Uint8Array(size)); } function box(ox, oy, w, h, fill){ for (y = 0; y < h; y++) for (x = 0; x < w; x++){ var px = ox + x, py = oy + y; if (px < 0 || py < 0 || px >= size || py >= size) continue; res[py][px] = 1; if (fill) mod[py][px] = fill(x, y); } } var finder = function(x, y){ return ((x >= 0 && x <= 6 && (y === 0 || y === 6)) || (y >= 0 && y <= 6 && (x === 0 || x === 6)) || (x >= 2 && x <= 4 && y >= 2 && y <= 4)) ? 1 : 0; }; /* finder + separator: the 8x8 box covers both */ box(0, 0, 8, 8, function(x, y){ return finder(x, y); }); box(size - 8, 0, 8, 8, function(x, y){ return finder(x - 1, y); }); box(0, size - 8, 8, 8, function(x, y){ return finder(x, y - 1); }); /* timing */ for (i = 8; i < size - 8; i++){ res[6][i] = 1; mod[6][i] = (i % 2 === 0) ? 1 : 0; res[i][6] = 1; mod[i][6] = (i % 2 === 0) ? 1 : 0; } /* alignment, skipping the three that collide with finders */ var ap = alignPositions(v); for (i = 0; i < ap.length; i++) for (j = 0; j < ap.length; j++){ var cx = ap[j], cy = ap[i]; if ((cx === 6 && cy === 6) || (cx === 6 && cy === size - 7) || (cx === size - 7 && cy === 6)) continue; box(cx - 2, cy - 2, 5, 5, function(x, y){ return (x === 0 || x === 4 || y === 0 || y === 4 || (x === 2 && y === 2)) ? 1 : 0; }); } /* format info areas + the always-dark module */ for (i = 0; i < 9; i++){ res[8][i] = 1; res[i][8] = 1; } for (i = 0; i < 8; i++){ res[8][size - 1 - i] = 1; res[size - 1 - i][8] = 1; } mod[size - 8][8] = 1; res[size - 8][8] = 1; /* version info blocks */ if (v >= 7){ for (i = 0; i < 6; i++) for (j = 0; j < 3; j++){ res[i][size - 11 + j] = 1; res[size - 11 + j][i] = 1; } } return {size:size, res:res, mod:mod}; } function totalCodewords(v){ var f = functionMap(v), free = 0, y, x; for (y = 0; y < f.size; y++) for (x = 0; x < f.size; x++) if (!f.res[y][x]) free++; return Math.floor(free / 8); } var CAP_CACHE = null; function capacities(){ if (CAP_CACHE) return CAP_CACHE; CAP_CACHE = []; for (var v = 1; v <= 40; v++){ var ec = ECL[v - 1][0] * ECL[v - 1][1]; var data = totalCodewords(v) - ec; /* mode indicator 4 bits + count indicator (8 bits below v10, else 16) */ var head = 4 + (v < 10 ? 8 : 16); CAP_CACHE.push(data - Math.ceil(head / 8) >= 0 ? Math.floor((data * 8 - head) / 8) : 0); } return CAP_CACHE; } function QR_MAX(){ return capacities()[39]; } function maskFn(m, x, y){ switch (m){ case 0: return ((y + x) % 2) === 0; case 1: return (y % 2) === 0; case 2: return (x % 3) === 0; case 3: return ((y + x) % 3) === 0; case 4: return ((Math.floor(y / 2) + Math.floor(x / 3)) % 2) === 0; case 5: return (((y * x) % 2) + ((y * x) % 3)) === 0; case 6: return ((((y * x) % 2) + ((y * x) % 3)) % 2) === 0; default: return ((((y + x) % 2) + ((y * x) % 3)) % 2) === 0; } } function formatInfo(mask){ var data = (1 << 3) | mask; /* level L = 0b01 */ var rem = data, i; for (i = 0; i < 10; i++) rem = (rem << 1) ^ (((rem >>> 9) & 1) * 0x537); return (((data << 10) | (rem & 0x3ff)) ^ 0x5412) & 0x7fff; } function versionInfo(v){ var rem = v, i; for (i = 0; i < 12; i++) rem = (rem << 1) ^ (((rem >>> 11) & 1) * 0x1f25); return ((v << 12) | (rem & 0xfff)) >>> 0; } /* Penalty rules N1-N4 from the spec. N3 matches the 1:1:3:1:1 RATIO at any scale rather than a fixed 11-module pattern, and a run index of -1 marks the symbol edge, which counts as light. Verified against libqrencode: forcing my encoder to the mask libqrencode picks reproduces its symbol exactly, so the only thing this heuristic affects is which of 8 equally valid masks wins. */ function runLengths(get, width){ var rl = [], head = 0, i; if (get(0) === 1){ rl[0] = -1; head = 1; } /* -1 = the edge, i.e. light */ rl[head] = 1; var prev = get(0); for (i = 1; i < width; i++){ var c = get(i); if (c !== prev){ head++; rl[head] = 1; prev = c; } else rl[head]++; } return {rl:rl, len:head + 1}; } function calcN1N3(rl, len){ var d = 0, i; for (i = 0; i < len; i++){ if (rl[i] >= 5) d += 3 + (rl[i] - 5); /* N1 */ if ((i & 1) && i >= 3 && i < len - 2 && rl[i] % 3 === 0){ var f = rl[i] / 3; /* N3 */ if (rl[i-2] === f && rl[i-1] === f && rl[i+1] === f && rl[i+2] === f){ if (rl[i-3] < 0 || rl[i-3] >= 4 * f) d += 40; else if (i + 3 >= len || rl[i+3] >= 4 * f) d += 40; } } } return d; } function penalty(m, size){ var p = 0, x, y, r; for (y = 0; y < size; y++){ r = runLengths(function(i){ return m[y][i]; }, size); p += calcN1N3(r.rl, r.len); } for (x = 0; x < size; x++){ r = runLengths(function(i){ return m[i][x]; }, size); p += calcN1N3(r.rl, r.len); } for (y = 1; y < size; y++) for (x = 1; x < size; x++){ /* N2 */ var b = m[y][x] & m[y][x-1] & m[y-1][x] & m[y-1][x-1]; var w = m[y][x] | m[y][x-1] | m[y-1][x] | m[y-1][x-1]; if ((b | (w ^ 1)) & 1) p += 3; } var dark = 0; /* N4 */ for (y = 0; y < size; y++) for (x = 0; x < size; x++) dark += m[y][x]; p += Math.floor(Math.abs(dark * 100 / (size * size) - 50) / 5) * 10; return p; } /* bytes -> {size, modules[y][x]} or null when it does not fit */ function qrEncode(bytes){ var caps = capacities(), v = 0, i, j, x, y; for (i = 0; i < 40; i++) if (bytes.length <= caps[i]){ v = i + 1; break; } if (!v) return null; var ecPer = ECL[v - 1][0], nBlocks = ECL[v - 1][1]; var total = totalCodewords(v), nData = total - ecPer * nBlocks; var countBits = v < 10 ? 8 : 16; /* bit stream: mode 0100, length, payload, terminator, pad */ var bits = []; function put(val, n){ for (var k = n - 1; k >= 0; k--) bits.push((val >>> k) & 1); } put(4, 4); put(bytes.length, countBits); for (i = 0; i < bytes.length; i++) put(bytes[i], 8); for (i = 0; i < 4 && bits.length < nData * 8; i++) bits.push(0); while (bits.length % 8) bits.push(0); var pad = [0xec, 0x11], pi = 0; while (bits.length < nData * 8){ put(pad[pi & 1], 8); pi++; } var dataCw = new Uint8Array(nData); for (i = 0; i < nData; i++){ var b = 0; for (j = 0; j < 8; j++) b = (b << 1) | bits[i * 8 + j]; dataCw[i] = b; } /* short/long split follows from the totals -- no second table needed */ var shortLen = Math.floor(nData / nBlocks), nLong = nData % nBlocks; var dBlocks = [], eBlocks = [], off = 0; for (i = 0; i < nBlocks; i++){ var len = shortLen + (i >= nBlocks - nLong ? 1 : 0); var blk = dataCw.subarray(off, off + len); off += len; dBlocks.push(blk); eBlocks.push(rsRemainder(blk, ecPer)); } /* interleave */ var out = []; for (i = 0; i < shortLen + 1; i++) for (j = 0; j < nBlocks; j++) if (i < dBlocks[j].length) out.push(dBlocks[j][i]); for (i = 0; i < ecPer; i++) for (j = 0; j < nBlocks; j++) out.push(eBlocks[j][i]); var allBits = []; for (i = 0; i < out.length; i++) for (j = 7; j >= 0; j--) allBits.push((out[i] >>> j) & 1); var fm = functionMap(v), size = fm.size; var best = null, bestP = Infinity, bestMask = -1; for (var mask = 0; mask < 8; mask++){ var m = []; for (y = 0; y < size; y++) m.push(Uint8Array.from(fm.mod[y])); /* format info, both copies */ var fi = formatInfo(mask); for (i = 0; i < 15; i++){ var bit = (fi >>> i) & 1; /* copy 1 runs DOWN column 8 then LEFT along row 8, skipping the timing module at 6. Indices are [row][col], so it is easy to write this transposed -- which is exactly the bug the reference comparison found. */ if (i < 6) m[i][8] = bit; else if (i === 6) m[7][8] = bit; else if (i === 7) m[8][8] = bit; else if (i === 8) m[8][7] = bit; else m[8][14 - i] = bit; /* copy 2: row 8 from the right, then column 8 from the bottom */ if (i < 8) m[8][size - 1 - i] = bit; else m[size - 15 + i][8] = bit; } if (v >= 7){ var vi = versionInfo(v); for (i = 0; i < 18; i++){ var vb = (vi >>> i) & 1; m[Math.floor(i / 3)][size - 11 + (i % 3)] = vb; m[size - 11 + (i % 3)][Math.floor(i / 3)] = vb; } } /* data in the two-column zigzag from the bottom right */ var bi = 0; for (var right = size - 1; right >= 1; right -= 2){ if (right === 6) right = 5; for (var vert = 0; vert < size; vert++){ for (j = 0; j < 2; j++){ x = right - j; var up = (((right + 1) & 2) === 0); y = up ? size - 1 - vert : vert; if (fm.res[y][x]) continue; var dark = (bi < allBits.length) ? allBits[bi++] : 0; if (maskFn(mask, x, y)) dark ^= 1; m[y][x] = dark; } } } var pen = penalty(m, size); if (pen < bestP){ bestP = pen; best = m; bestMask = mask; } } return {size:size, version:v, modules:best, penalty:bestP, mask:bestMask}; } /* ================================================ export / import ======== One editable text document holding the whole song. The rules it keeps, so that a file survives the app changing shape around it in both directions: - "synthshop" versions the FORMAT, independently of the app - unknown keys are ignored -> a newer file loads in an older build - missing keys take their defaults -> an older file loads in a newer build - out-of-range numbers are clamped, never rejected - parameters are addressed by NAME, never by index, so adding one later cannot shift the meaning of the others - nothing is ever fatal: a bad field is dropped, the rest still loads Reading is applyState() above -- the same code the saved session goes through -- so there is exactly one validator. */ var FMT = 1; var TAG_GZ = 'synthshop2:'; /* gzip then base64 */ var TAG_RAW = 'synthshop1:'; /* base64 only -- the fallback, and old strings */ /* Base64 travels through chat windows and text fields that would mangle raw JSON, so it is what the dialog shows by default -- but base64 costs a third on top, which took a full 8-scene song to ~14,700 characters. Gzipping first turns that into ~1,300: the document is mostly repeated parameter names and near-identical step lines, which is exactly what deflate eats. gzip rather than deflate-raw despite an 18-byte header, because the payload is then self-identifying by its 1f 8b magic and anyone can read it with `base64 -d | gunzip` without this app. The 76-column wrap survives clients that re-wrap long lines, since decoding strips whitespace anyway. */ function bytesToB64(bytes){ /* one byte at a time: String.fromCharCode.apply has an argument limit, and a big song is exactly the input that would hit it */ var bin = '', i; for (i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } function b64ToBytes(b64){ var bin = atob(b64), bytes = new Uint8Array(bin.length), i; for (i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } function canGzip(){ return typeof CompressionStream === 'function' && typeof DecompressionStream === 'function'; } async function gzipBytes(str){ var st = new Blob([str]).stream().pipeThrough(new CompressionStream('gzip')); return new Uint8Array(await new Response(st).arrayBuffer()); } async function gunzipBytes(bytes){ var st = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); return await new Response(st).text(); } function wrap76(s2){ var out = [], i; for (i = 0; i < s2.length; i += 76) out.push(s2.substr(i, 76)); return out.join('\n'); } async function toB64(jsonText){ if (canGzip()){ try { return TAG_GZ + '\n' + wrap76(bytesToB64(await gzipBytes(jsonText))); } catch (e) { /* fall through to plain */ } } return TAG_RAW + '\n' + wrap76(bytesToB64(new TextEncoder().encode(jsonText))); } /* Returns {text, gz} or null -- null rather than throwing, so callers decide what to say about it. The gzip magic is sniffed rather than the tag trusted, because a pasted string routinely arrives with its tag stripped or its lines re-wrapped, and the bytes do not lie. */ async function fromB64(t){ var b = t.replace(/^\s*synthshop\d*\s*:/i, '').replace(/\s+/g, ''); if (!b) return null; var bytes; try { bytes = b64ToBytes(b); } catch (e) { return null; } if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b){ if (!canGzip()) return null; try { return {text: await gunzipBytes(bytes), gz: true}; } catch (e) { return null; } } try { return {text: new TextDecoder().decode(bytes), gz: false}; } catch (e) { return null; } } /* Knob drags produce values like 0.30000000000000004. Rounding at 1e-9 kills that noise while staying far finer than any parameter's resolution. */ function jnum(x){ if (typeof x !== 'number' || !isFinite(x)) return '0'; return String(Math.round(x * 1e9) / 1e9); } function stepLine(st, scale){ var s2 = '{"on": ' + (st.on ? 'true, ' : 'false,') + ' "semis": [' + st.semis.join(', ') + ']' + ', "deg": ' + semiToDeg(st.semis[0], scale) + /* pre-chord readers */ ', "dur": ' + (st.dur | 0); /* Only when NON-DEFAULT, so a song written before these existed exports byte-identical to the way it did -- which the tests assert. */ if (st.acc) s2 += ', "acc": true'; if (st.sld) s2 += ', "sld": true'; if (st.shift | 0) s2 += ', "shift": ' + (st.shift | 0); if (st.lock){ var ks = [], k, parts = []; for (k in st.lock) if (LOCKABLE[k]) ks.push(k); ks.sort(); for (var i = 0; i < ks.length; i++) parts.push(JSON.stringify(ks[i]) + ': ' + jnum(st.lock[ks[i]])); if (parts.length) s2 += ', "lock": {' + parts.join(', ') + '}'; } return s2 + '}'; } function exportText(){ stashScalars(); /* the live scene's scalars are not in SCENES yet */ var L = [], i, j, k, sc, keys = []; /* seq.scn is the scene COUNT, which is global -- carrying a copy of it in every scene would just invite editing the one that does not win. And the per-instrument keys live in the instrument blocks, so the scene's own v carries only what the two instruments SHARE. */ var shared = []; for (k in P) if (k !== 'seq.scn') keys.push(k); for (k in P) if (k !== 'seq.scn' && INSP.indexOf(k) < 0) shared.push(k); L.push('{'); L.push(' "synthshop": ' + FMT + ','); L.push(' "bpm": ' + (bpm | 0) + ','); L.push(' "nScn": ' + nScn() + ','); L.push(' "scn": ' + (scn | 0) + ','); L.push(' "hold": ' + (HOLD ? 'true' : 'false') + ','); var mu = []; for (k in MUTE) if (MUTE[k]) mu.push(JSON.stringify(k)); mu.sort(); L.push(' "mute": [' + mu.join(', ') + '],'); var arm = []; for (k in ARM) if (ARM[k]) arm.push(JSON.stringify(k)); arm.sort(); L.push(' "arm": [' + arm.join(', ') + '],'); /* which source has hold of which knob. Sorted, like the two above, so two exports of the same patch are the same bytes. */ var rt = []; for (k in ROUTE) if (SRC[ROUTE[k]]) rt.push(JSON.stringify(k) + ': ' + JSON.stringify(ROUTE[k])); rt.sort(); L.push(' "route": {' + rt.join(', ') + '},'); L.push(' "scenes": ['); for (i = 0; i < SCENES.length; i++){ sc = SCENES[i]; L.push(' {'); L.push(' "root": ' + (sc.root | 0) + ', "scale": ' + JSON.stringify(sc.scale) + ','); L.push(' "fltOn": ' + !!sc.fltOn + ', "fxOn": ' + !!sc.fxOn + ', "revOn": ' + !!sc.revOn + ','); L.push(' "v": {'); for (j = 0; j < shared.length; j++) L.push(' ' + JSON.stringify(shared[j]) + ': ' + jnum(sc.v[shared[j]]) + (j < shared.length - 1 ? ',' : '')); L.push(' },'); /* One block per instrument, named, in INSLAB order -- the file has to say which engine each pattern belongs to or it is unreadable by eye. */ L.push(' "ins": ['); for (var q = 0; q < 2; q++){ var iv = sc.ins[q]; L.push(' {'); L.push(' "engine": ' + JSON.stringify(INSLAB[q]) + ', "oct": ' + (iv.oct | 0) + ', "envOn": ' + !!iv.envOn + ','); L.push(' "v": {'); for (j = 0; j < INSP.length; j++) L.push(' ' + JSON.stringify(INSP[j]) + ': ' + jnum(iv.v[INSP[j]]) + (j < INSP.length - 1 ? ',' : '')); L.push(' },'); L.push(' "steps": ['); for (j = 0; j < 16; j++) L.push(' ' + stepLine(iv.steps[j] || {}, sc.scale) + (j < 15 ? ',' : '')); L.push(' ]'); L.push(' }' + (q ? '' : ',')); } L.push(' ],'); /* One line per lane, sixteen characters wide, in DRUMKIT order (BD SD CH OH): you can read the beat straight off the file and edit it in a text editor, which is what this format is for. No comments -- it has to stay parseable as JSON. */ var dr = drumsToRows(sc.drums); L.push(' "drums": ['); for (j = 0; j < dr.length; j++) L.push(' ' + JSON.stringify(dr[j]) + (j < dr.length - 1 ? ',' : '')); L.push(' ],'); /* [lane, step, nudge, rate] for the cells that differ from the default, so an untouched pattern costs one empty array */ var mods = modsToSparse(sc.dnudge, sc.drate); L.push(' "dmod": [' + (mods.length ? '' : ']')); /* join(', '), not JSON.stringify: that writes '[0,4,1,-1]' and this export is asserted byte-identical, so the spaces are load-bearing. */ for (j = 0; j < mods.length; j++) L.push(' [' + mods[j].join(', ') + ']' + (j < mods.length - 1 ? ',' : '')); if (mods.length) L.push(' ]'); L.push(' }' + (i < SCENES.length - 1 ? ',' : '')); } L.push(' ]'); L.push('}'); return L.join('\n'); } async function importText(txt){ var t = String(txt == null ? '' : txt).trim(), d, form; if (!t) return {ok:false, msg:'Nothing to import.'}; /* A leading brace is the only thing that distinguishes the two forms, and base64 can never start with one. */ if (t.charAt(0) === '{'){ form = 'JSON'; try { d = JSON.parse(t); } catch (e){ return {ok:false, msg:'Not valid JSON \u2014 ' + e.message}; } } else { var dec = await fromB64(t); if (dec === null) return {ok:false, msg:'Not JSON, and not a payload I can decode.'}; form = dec.gz ? 'gzip+base64' : 'base64'; try { d = JSON.parse(dec.text); } catch (e){ return {ok:false, msg:'That decoded, but not to valid JSON.'}; } } if (!d || typeof d !== 'object' || Array.isArray(d)) return {ok:false, msg:'Expected a JSON object at the top level.'}; var warn = ''; /* A newer format still loads: refusing it would break the very rule that makes this thing forward compatible. */ if (typeof d.synthshop === 'number' && d.synthshop > FMT) warn = ' \u2014 written by format v' + d.synthshop + ', anything newer than v' + FMT + ' was ignored'; if (playing) stop(); gIdx = 0; schedScn = -1; dq.length = 0; SYNC.keyQ = null; SYNC.keySent = ''; /* a loaded song is a new song */ ARM = {}; /* an import that says nothing arms nothing */ try { applyState(d, false); } catch (e){ return {ok:false, msg:'Could not read that: ' + e.message}; } ensureScenes(); warmIR(); scanGlock(); paintBpm(); markSceneDirty(); save(); return {ok:true, msg:'Imported ' + SCENES.length + ' scene' + (SCENES.length === 1 ? '' : 's') + ', ' + nScn() + ' in the chain (' + form + ')' + warn}; } /* ---- the dialog ---- */ function ioMsg(t, bad){ var m = document.getElementById('iomsg'); m.textContent = t || ''; m.classList.toggle('bad', !!bad); } var ioMode = 'b64'; /* the paste-safe form is the default */ function ioPaint(){ document.getElementById('iomode').textContent = ioMode === 'b64' ? 'AS JSON' : 'AS BASE64'; /* No keyboard for the base64 blob -- nobody hand-edits one, and suppressing it here is what keeps a tap from covering the dialog. JSON is the view where typing is the point, so it gets a keyboard back. Not `readonly`: that would also block pasting, and the paste fallback is the only import path when the clipboard API is blocked. */ var ta = document.getElementById('iotext'); if (ta) ta.inputMode = (ioMode === 'json') ? 'text' : 'none'; } /* Tap the backdrop to dismiss. A modal <dialog> reports clicks on its backdrop as clicks on the dialog element itself, so `target === d` is the test -- but it is also true of the dialog's own padding, hence the hit test against the box. And both the press AND the release have to be out there: without that, selecting text in the song field and letting go past the edge would close the dialog and throw the selection away. The x stays exactly as it was. */ function outsideBox(d, e){ var r = d.getBoundingClientRect(); return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom; } function backdropClose(d){ if (!d) return; var armed = false; d.addEventListener('pointerdown', function(e){ armed = (e.target === d) && outsideBox(d, e); }); d.addEventListener('click', function(e){ var go = armed && e.target === d && e.detail > 0 && outsideBox(d, e); armed = false; if (go) d.close(); }); } /* ================================================ device sync, one socket == Three mechanisms stood here and all three are gone: ultrasonic markers heard through the microphone (entries 65-77), WebRTC data channels paired by two QR scans (95-97), and a relay riding on the page's own server (94). With them went the clock-offset estimation, the RTT weighting, the 0.30s lead-in and the off|lead|follow role cycle that sat on top of all three. What is here instead has no roles and no clock. Every client talks to sync_server.py, which relays each instruction to every OTHER client and remembers exactly two things, bpm and running: bpm {t:'bpm', v:128} the tempo moved key {t:'key', root:6, scale:'hijaz'[, at]} the key moved. With `at` -- the server-clock instant it applies -- the change was ANNOUNCED ahead of a scene boundary and a listener defers it to that instant; without, it came from a finger and applies at once. The announcement exists because a step's pitches freeze when the scheduler reaches it, which is up to HORIZON before it sounds -- so a frame that is merely prompt still misses the first note. start {t:'start'} transport on stop {t:'stop'} transport off sync {t:'sync', at, n} the top of the pattern, and WHEN it sounds: `at` in the server's milliseconds, `n` the chain length in steps. A listener that is already playing compares `at` against its own top and trims its rate -- see syncLock. An older instance sends the bare frame and a listener simply does not correct. ping {t:'ping', c} liveness AND the shared clock, answered by the server alone. The pong carries `s`, the server's own milliseconds, which is what makes `at` above comparable across two devices. `sync` is the whole of the alignment, and it is why none of the arithmetic came back. A client joining a running room is handed running:true, arms itself, and starts on the next `sync` -- which is by definition the top of somebody's pattern, so it lands in step without ever knowing anybody's clock. The echo those roles existed to prevent is the server's problem now: it never sends an instruction back to whoever sent it, so a room of equals cannot feed itself. The one guard left on this side is SYNC.quiet, because applying a received instruction goes through the same setBpm/start/stop that everyone else calls -- and those send. */ var SYNCKEY = 'synthi.ws'; /* ROLE, not a boolean. Revision 87 removed off|lead|follow on the grounds that a room of equals needs no authority -- which is true of the transport and false of the music. What was wanted is one instance driving and the others following, and the swipe already had three positions for it. So: swipe once LISTEN, twice SEND, again OFF. Every instance still connects to sync_server.py -- that part has not changed and cannot, since a browser cannot accept a connection -- but only a SEND instance puts instructions on the wire, and only a LISTEN instance acts on them. */ var SYNC = {role:'off', ws:null, peers:0, rtt:0, armed:0, tries:0, quiet:0, tmr:0, pmr:0, row:0, /* the address field was asked for by hand */ /* THE PHASE LOOP. Alignment used to be a one-shot: a device armed itself, started on somebody's pattern top, and then ran on its own clock forever. Entry 115 measured what that leaves -- a stable 58-68ms, half a step -- named the cause, and said a running device "could nudge its phase when the error exceeds, say, 15ms". This is that, as a rate trim rather than a nudge. off server-clock minus our performance.now(), so two devices can talk about the same instant without knowing each other orr the round trip of the sample `off` came from: the lowest one wins, because it is the one least distorted by a queue top the server-clock instant our own step 0 next sounds rate what the scheduler multiplies its step by. 1 means locked err the last measured phase error, ms, for the status line */ off:0, orr:0, top:0, rate:1, err:0, /* A key change that has been ANNOUNCED but is not due yet, and the last key this device announced. A scene owns its key, so crossing a boundary moves it -- and a step's pitches are frozen the moment the scheduler reaches it, up to HORIZON before it sounds, because notesOf() reads ROOT right there inside scheduleStep. So a frame that merely arrives promptly is still too late for the first note of the new scene: measured on revision 107, the listener's key changed one step late at seven of nine boundaries on loopback and nine of ten with 30ms of delay. keyQ is {root, scale, ctx} with ctx on OUR audio clock; keySent is the tag of the last announcement, so a chain whose scenes share a key stays silent. */ keyQ:null, keySent:''}; function syncLive(){ return SYNC.role !== 'off'; } var SYNC_PING = 15000; /* liveness; the server answers it alone */ /* The loop. DEAD is a deadband -- inside it the rate goes back to exactly 1, because chasing measurement noise is how a loop starts to ring. GAIN removes half the error per pulse. MAX caps the trim at 2%, which at 120bpm is 20ms of authority a second: the 60ms constant glides out in three seconds. The ultrasonic loop of entries 65-77 had 2ms/s and still held sub-millisecond, so this is ten times the authority for a tenth the sample rate. It is tempo and not pitch, so 2% is inaudible. */ var LOCK_DEAD = 6, LOCK_GAIN = 0.5, LOCK_MAX = 0.02; /* The address the field starts with, and what a swipe uses when nothing has been typed. A CONSTANT because the page is served from somewhere else than the server: deriving this from the page's own origin, which is what it used to do, was almost always the wrong guess -- that host is not the server's. It was a bare IP and a port for as long as the server ran on a laptop on the LAN, and the comment here used to argue for an address over a name because resolution could not be relied on. The server is hosted now: a named host on 443 behind a certificate browsers already trust. Hence no port -- the scheme implies it -- and hence the default needs no TRUST step, there being nothing to approve. A server you run yourself still does; see the TRUST markup. */ var SYNC_DEFAULT = 'wss://sync.om-office.de/ws'; var SYNC_CEIL = 4000; /* reconnect backoff ceiling */ /* Where the server is. The page is served from somewhere else entirely, so the STORED address is the normal answer rather than a fallback: it is typed into the help dialog once and every swipe uses it from then on. The derivation from the page origin is kept for the case where the two do sit together. The scheme follows the PAGE, because a plain ws:// from an https page is mixed content -- with ONE exception that turns out to matter more than the rule, and which I had been asserting the rule without: LOCALHOST IS EXEMPT. `http://127.0.0.1` and `ws://127.0.0.1` are "potentially trustworthy" origins, so an https page may open a plain socket to them. Measured. That is also, in retrospect, the whole reason --adb works: it turns the server into localhost, and the exemption does the rest. What is measured here: page on http -> ws:// to a LAN address CONNECTS page on file:// -> ws:// to a LAN address CONNECTS page on https -> ws:// to 127.0.0.1 CONNECTS (the exemption) What is NOT: whether a page on a GENUINELY TRUSTED https origin can open ws:// to a LAN address. It should not, by the rule -- but the only https this machine can serve is self-signed, and an origin with a bypassed certificate is not a secure context, so the test proves nothing either way. Hence syncWhy() below, which explains a failure rather than predicting one. */ function syncURL(){ var s = ''; try { s = (localStorage.getItem(SYNCKEY) || '').trim(); } catch (e) {} if (!s) return SYNC_DEFAULT; /* A bare host:port takes the PAGE's scheme, because that is the pairing the browser will accept -- see the mixed-content note above. Anything typed with a scheme of its own is left as typed. */ var sch = (location.protocol === 'https:') ? 'wss://' : 'ws://'; if (!/^wss?:\/\//i.test(s)) s = sch + s; s = s.replace(/\/+$/, ''); return /\/ws$/.test(s) ? s : s + '/ws'; } function syncStore(v){ try { localStorage.setItem(SYNCKEY, String(v || '').trim()); } catch (e) {} } /* Why a socket that never opened might not have. Called only on a failure, and deliberately not used to refuse the attempt: plain ws from https is allowed to localhost and I cannot prove it is refused elsewhere, so predicting would be guessing where explaining is not. The trap it names is real though -- the server prints `ws://<lan>:8765/ws` when run with --http, and pasting that into a page served over https is a mismatch nothing else would mention. */ function syncWhy(u){ if (location.protocol !== 'https:' || !/^ws:\/\//i.test(u)) return ''; var h = u.replace(/^ws:\/\//i, '').split(/[:/]/)[0]; if (h === 'localhost' || h === '127.0.0.1' || h === '[::1]') return ''; return ' — this page is https and that address is plain ws://. ' + 'Use wss:// (run sync_server.py without --http), or reach it as ' + 'localhost via --adb.'; } /* wss://host/ws -> https://host/ , and ws:// -> http:// . String surgery rather than `new URL(s)` with `u.protocol = 'https:'`: swapping the scheme between two special schemes is legal per spec and Chrome allows it, but it is a corner nobody needs -- and this also has to cope with a bare `host:8765` typed into the box, which the URL constructor rejects outright. The PATH goes: the stub is served from the root, and it is the navigation that carries the certificate exception, not the file at the end of it. */ function trustURL(v){ var s = String(v || '').trim(); /* No address contains a space, and without this check a sentence typed into the box came out as a live-looking link: "not a url at all" became ws://not a url at all, and the host pattern below stopped at the first space and handed back http://not/ . */ if (!s || /\s/.test(s)) return ''; if (!/^wss?:\/\//i.test(s)) s = (location.protocol === 'https:' ? 'wss://' : 'ws://') + s; s = s.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://'); /* host[:port] only, then a bare root. Refuses anything with no host left. */ var m = /^(https?:\/\/[^\/?#\s]+)/i.exec(s); return m ? m[1] + '/' : ''; } function syncStored(){ try { return !!(localStorage.getItem(SYNCKEY) || '').trim(); } catch (e) { return false; } } /* Two doors, because the two things that go out have different rules. `ping` is this device asking the SERVER whether it is still there, so it goes whatever the role -- a listener needs its liveness too. An instruction is a musical statement and only SEND is entitled to make one. */ /* performance.now() and A.ctx.currentTime, as ONE reading where the browser offers one. getOutputTimestamp hands back contextTime and performanceTime together; sampling the two separately races between them by about a millisecond, which entry 94 measured and cared about for exactly this reason. Returns ms for the wall clock and seconds for the audio clock, matching what each caller already works in. */ function clockPair(){ if (A && A.ctx && A.ctx.getOutputTimestamp){ try { var q = A.ctx.getOutputTimestamp(); if (q && typeof q.contextTime === 'number' && typeof q.performanceTime === 'number' && q.contextTime > 0) return {perf: q.performanceTime, ctx: q.contextTime}; } catch (e) {} } return {perf: performance.now(), ctx: (A && A.ctx) ? A.ctx.currentTime : 0}; } /* An audio-clock instant, in the server's milliseconds. Both ends compute it the same way, which is what lets the pulse name an instant instead of describing a delay -- and it is why no rtt/2 guess appears anywhere here: network latency cancels rather than being estimated. */ function atServer(ctxT){ var c = clockPair(); return c.perf + (ctxT - c.ctx) * 1000 + SYNC.off; } /* And back again. Revision 107 only ever needed to NAME an instant; a listener that has to act on one needs to put it on its own audio clock, which is the same algebra read the other way. */ function ctxAt(serverMs){ var c = clockPair(); return c.ctx + (serverMs - SYNC.off - c.perf) / 1000; } function syncRaw(o){ if (SYNC.ws && SYNC.ws.readyState === 1){ try { SYNC.ws.send(JSON.stringify(o)); } catch (e) {} } } function syncSend(o){ if (SYNC.role === 'lead') syncRaw(o); } /* Becoming the master is a claim, and a claim nobody hears is not one. The room only ever learns a tempo because somebody SENT one -- the server has no clock of its own -- so the instant this device starts driving it has to publish what it is driving at. Without this, promotion by a second swipe put nothing on the wire at all: measured on a fresh room, the master sat at 88, the listener stayed at its own 120, and the server reported bpm null with ZERO messages relayed. Connecting straight into SEND happened to work, because the `state` frame that arrives on connect triggered the same push. Promotion has no `state`, which is why this is a function and not a line in one branch. */ function syncAssert(){ if (SYNC.role !== 'lead') return; syncRaw({t:'bpm', v:bpm}); syncRaw({t:'key', root:ROOT, scale:SCALE}); /* And if it is ALREADY playing, the room's `running` is still false, so every device joining later would be told the room is stopped. A listener that is already playing ignores a start, so this disturbs nobody. */ if (playing) syncRaw({t:'start'}); } /* The phase loop, driven by the pulse that was already arriving and already being ignored by anyone who was playing. Both instants are in the server's milliseconds, so the difference is a real phase error and not a network delay: nothing here estimates latency, because `at` names an instant that both ends compute the same way. Positive `e` means OUR top is later than theirs -- we are behind -- and the answer is to shorten our steps, which is rate BELOW one. Getting that backwards is not hypothetical: entry 68 shipped it inverted, drove the follower away from the leader, and the error grew to 62ms before a test caught it. One character. Corrected by rate ONLY. Writing nextT directly would be quicker and is a trap: the runaway guard at the top of tick() snaps any nextT that falls behind now - 0.5, which would silently destroy the phase it was trying to fix. */ function syncLock(m){ if (SYNC.role !== 'follow' || !playing || typeof m.at !== 'number'){ return; } /* An older SEND instance sends a bare {t:'sync'} with no `at`. Nothing to measure, so hold the rate at 1 rather than inventing an error. */ if (!SYNC.top || !SYNC.off){ SYNC.rate = 1; return; } /* Parked while hidden. The scheduling horizon is HORIZON (120ms, one or two steps) with the tab visible and HIDDEN_HORIZON (2.0s, about sixteen steps) without it, so the loop's dead time jumps seventeenfold the moment the phone is pocketed -- and a loop whose dead time exceeds its sample interval rings instead of settling. */ if (document.hidden){ SYNC.rate = 1; return; } var cycle = 16 * nScn() * stepDur() * 1000; /* Their chain and ours have to be the same length for "the top of the pattern" to mean the same thing. When they differ, fall back to locking on the nearest STEP -- still grid-locked, still audibly together, just with no claim about which bar. */ var span = ((m.n | 0) === 16 * nScn()) ? cycle : stepDur() * 1000; var e = SYNC.top - m.at; e -= Math.round(e / span) * span; /* into +/- half a span */ SYNC.err = e; SYNC.rate = (Math.abs(e) < LOCK_DEAD) ? 1 : clamp(1 - LOCK_GAIN * e / cycle, 1 - LOCK_MAX, 1 + LOCK_MAX); } /* The logo blinks on every pulse that arrives. It is the only evidence a device in the room has that the room is alive, and the CSS for it was already here -- it used to blink on an ultrasonic marker. */ function syncFlash(){ var b = document.getElementById('play'); /* SPIN_OK is this file's word for "motion is allowed"; the state text in the help list carries the same information for anyone who has turned it off. */ if (!b || !SPIN_OK) return; b.classList.remove('flash'); void b.offsetWidth; /* reflow, or the animation will not restart */ b.classList.add('flash'); } function syncMsg(m){ var t = m && m.t; if (t === 'pong'){ if (typeof m.c === 'number'){ var nowP = performance.now(); SYNC.rtt = Math.max(0, nowP - m.c); /* The offset the server has been sending all along and nobody read: `s` is its wall clock at the moment it answered, so the midpoint of our own send and receive is the same instant on our clock. `m.s` had ZERO occurrences in this file before now, while sync_server.py's own comment said "one exchange yields both the round trip and, if anyone wants it, the offset". Somebody wanted it. LOWEST round trip wins, not the latest sample: a queue in either direction skews the midpoint, and the quickest exchange is the one least skewed. Entry 94 used this same rule and measured two clients 1.0ms apart. `orr` decays so a better path can win it back after a network change rather than being locked out by one lucky early probe. */ if (typeof m.s === 'number'){ var cand = m.s - (m.c + nowP) / 2; if (!SYNC.orr || SYNC.rtt <= SYNC.orr){ SYNC.off = cand; SYNC.orr = SYNC.rtt; } else SYNC.orr += (SYNC.rtt - SYNC.orr) * 0.05; } } paintSyncState(); return; } if (t === 'peers'){ SYNC.peers = m.n | 0; paintSyncState(); return; } /* The server allows one SEND at a time and tells the loser. Taking the demotion silently would leave two instances both believing they drive. */ if (t === 'role'){ if (m.v === 'follow' && SYNC.role === 'lead'){ SYNC.role = 'follow'; paintSync(); paintSyncState(); setHint('another device took SEND — this one is listening now', 1); } return; } if (t === 'state'){ SYNC.peers = m.peers | 0; /* A SEND instance is the authority and does not adopt the room's tempo -- it publishes its own, so whatever was already listening lines up with the device that is now driving. A LISTEN instance does the opposite. */ if (SYNC.role === 'lead'){ syncAssert(); setHint('sync: SEND — ' + SYNC.peers + (SYNC.peers === 1 ? ' device' : ' devices')); } else { SYNC.quiet = 1; try { if (typeof m.bpm === 'number') setBpm(m.bpm); /* The initial state setup for the key: a device joining a room is handed it exactly the way it is handed the tempo. A fresh room has both as null, so both halves are guarded rather than assumed. */ if (typeof m.root === 'number' || SCALES[m.scale]) setKey(typeof m.root === 'number' ? m.root : ROOT, m.scale); /* The join rule, and the only reason `sync` exists: a room that is already running is not joined mid-bar. Arm, wait for the pulse. */ SYNC.armed = (m.running && !playing) ? 1 : 0; } finally { SYNC.quiet = 0; } if (SYNC.armed) syncWarm(); setHint(SYNC.armed ? 'sync: running — waiting for the top of the pattern' : 'sync: LISTEN — ' + SYNC.peers + (SYNC.peers === 1 ? ' device' : ' devices')); } paintSyncState(); return; } /* An instruction. Only a LISTEN instance acts on one -- which is what stops a SEND instance from following another one, and is the socket equivalent of entry 65's "make sure the master doesn't accidentally listen, too". `quiet` is still needed inside it, because applying an instruction goes through the same setBpm/start/stop a finger uses, and those send. */ if (SYNC.role !== 'follow') return; SYNC.quiet = 1; try { if (t === 'bpm' && typeof m.v === 'number') setBpm(m.v); /* setKey validates both halves itself -- root is taken mod 12, and a scale is accepted only if it is a key of SCALES. It has to: degToSemi reads SCALES[sc] with no fallback and throws on anything else, which would take an export down with it through semiToDeg. */ else if (t === 'key'){ /* Timestamped and we are running: QUEUE it, converted once here so the scheduling loop compares two numbers instead of pairing clocks per step. Anything else -- a bare frame from an older SEND instance, a stopped transport, no shared clock yet, or an instant already gone -- applies now, which is what this branch always did. */ var kq = (typeof m.at === 'number' && playing && SYNC.off) ? ctxAt(m.at) : 0; if (kq && A && kq > A.ctx.currentTime) SYNC.keyQ = {root: (typeof m.root === 'number' ? m.root : ROOT), scale: m.scale, ctx: kq}; else setKey(typeof m.root === 'number' ? m.root : ROOT, m.scale); } else if (t === 'start'){ SYNC.armed = 0; if (!playing) start(); } else if (t === 'stop'){ SYNC.armed = 0; if (playing) stop(); } else if (t === 'sync'){ syncFlash(); if (SYNC.armed && !playing){ SYNC.armed = 0; start(); } else if (playing) syncLock(m); } } finally { SYNC.quiet = 0; } paintSyncState(); } /* Everything expensive that start() would otherwise do ON the pulse, done while we are waiting for it instead. Measured, and it was the whole of the inaccuracy: start() called from inside the message handler spent 126ms of straight-line javascript before the transport moved -- against a 125ms step at 120bpm. So a device that joined a running room landed a full step and a bit late, every time. warmIR caches, so the call start() makes afterwards costs nothing the second time. */ function syncWarm(){ try { if (!A) initAudio(); if (A && A.ctx.resume) A.ctx.resume(); ensureScenes(); warmIR(); } catch (e) {} } /* `role` is what this device wants to be; the socket is opened for it. */ function syncOpen(role){ var url = syncURL(); if (!url){ setHint('no sync address — set one in the help list (swipe the logo left)', 1); SYNC.role = 'off'; paintSync(); paintSyncState(); return; } var was = SYNC.ws && SYNC.ws.readyState === 1 && SYNC.role !== 'off'; SYNC.role = role || 'follow'; /* Changing role on a live socket needs no reconnect: it is one `hello`. That matters for the swipe, which goes LISTEN -> SEND without leaving the room. */ if (was){ /* hello FIRST: the server refuses to relay from a client it does not yet know is the master, and tcp keeps them in that order. */ syncRaw({t:'hello', role: SYNC.role}); syncAssert(); paintSync(); paintSyncState(); return; } syncClose(1); /* keep the role: this is a reconnect */ var ws; try { ws = new WebSocket(url); } catch (e){ setHint('not a usable sync address: ' + url, 1); SYNC.role = 'off'; paintSync(); paintSyncState(); return; } SYNC.ws = ws; paintSync(); paintSyncState(); var opened = 0; ws.onopen = function(){ opened = 1; SYNC.tries = 0; /* First thing out, so the server knows whether to let this one drive -- and so it can demote whoever was driving before. */ syncRaw({t:'hello', role: SYNC.role}); if (SYNC.pmr) clearInterval(SYNC.pmr); SYNC.pmr = setInterval(function(){ syncRaw({t:'ping', c: performance.now()}); }, SYNC_PING); /* A BURST, not one. The offset below keeps the lowest-round-trip sample, and one probe gives it nothing to choose from -- the first exchange on a fresh socket is also the most likely to be slow. Four, 120ms apart, which is what makes the first `sync` after joining already useful. */ for (var pi = 0; pi < 4; pi++) setTimeout(function(){ syncRaw({t:'ping', c: performance.now()}); }, pi * 120); paintSyncState(); }; ws.onmessage = function(e){ var m = null; try { m = JSON.parse(e.data); } catch (x) { return; } if (m && typeof m === 'object') syncMsg(m); }; ws.onclose = function(){ if (SYNC.pmr){ clearInterval(SYNC.pmr); SYNC.pmr = 0; } SYNC.ws = null; SYNC.peers = 0; SYNC.armed = 0; paintSyncState(); /* Only on the FIRST attempt, and only if it never opened: a socket that worked and then dropped is a network event, not a configuration one, and saying this every four seconds would bury it. */ if (!opened && SYNC.tries === 0){ setHint('sync: could not reach ' + url + syncWhy(url), 1); } if (syncLive()) syncRetry(); /* still wanted: keep trying */ }; ws.onerror = function(){}; /* onclose does the work either way */ } /* Exponential to a 4s ceiling: a phone carried out of range should keep trying without spending its battery doing it. */ function syncRetry(){ if (SYNC.tmr) clearTimeout(SYNC.tmr); var d = Math.min(SYNC_CEIL, 250 * Math.pow(2, Math.min(4, SYNC.tries++))); SYNC.tmr = setTimeout(function(){ SYNC.tmr = 0; if (syncLive()) syncOpen(SYNC.role); }, d); } function syncClose(keep){ if (SYNC.tmr){ clearTimeout(SYNC.tmr); SYNC.tmr = 0; } if (SYNC.pmr){ clearInterval(SYNC.pmr); SYNC.pmr = 0; } var ws = SYNC.ws; SYNC.ws = null; /* drop onclose first, or closing by hand schedules a reconnect */ if (ws){ try { ws.onclose = null; ws.close(); } catch (e) {} } SYNC.peers = 0; SYNC.armed = 0; SYNC.rtt = 0; if (!keep){ SYNC.role = 'off'; SYNC.tries = 0; } paintSync(); paintSyncState(); } /* Three positions, which is what the gesture had before revision 87 and what the request restores: once LISTEN, twice SEND, again OFF. */ function syncCycle(){ if (SYNC.role === 'off'){ syncOpen('follow'); setHint('sync: LISTEN'); } else if (SYNC.role === 'follow'){ syncOpen('lead'); setHint('sync: SEND — this device drives'); } else { syncClose(0); setHint('sync off'); } } function paintSync(){ var b = document.getElementById('play'); if (!b) return; b.classList.toggle('listen', SYNC.role === 'follow'); b.classList.toggle('send', SYNC.role === 'lead'); } function paintSyncState(){ var e = document.getElementById('syncstate'); var f = document.getElementById('wsrow'); /* STATE, not a DOM poke. This read `!SYNC.on` while the tap on #syncstate set f.hidden = false directly -- and openHelp repaints this every 300ms so the peer count moves, which put the row straight back and took the keyboard focus with it. Measured: hidden:false focus:wsurl at 50ms, hidden:true focus:'' at 450ms; with the interval stopped it stayed open. `!syncStored()` is the other half, and the more important one: on a fresh install nobody has typed an address, and that field is where it goes. Hiding it until you happened to tap the status line was the whole of "I cannot add the server". It still shows then -- now carrying the default, so the first thing you see is a working address rather than an empty box. */ if (f) f.hidden = !(syncLive() || SYNC.row || !syncStored()); /* The href, from whatever is in the box right now. Absent rather than empty when the address is unusable, because `.io a.act:not([href])` is what makes the link go dead -- an href of "" would navigate to this page instead. */ var t = document.getElementById('wstrust'); if (t){ var h = trustURL(document.getElementById('wsurl').value); if (h) t.href = h; else t.removeAttribute('href'); } if (!e) return; var s; if (!syncLive()) s = 'off — swipe the logo right to listen, twice to send'; else if (!SYNC.ws || SYNC.ws.readyState !== 1) s = (SYNC.role === 'lead' ? 'SEND' : 'LISTEN') + ' · connecting…'; else { s = (SYNC.role === 'lead' ? 'SEND' : 'LISTEN') + ' · ' + SYNC.peers + (SYNC.peers === 1 ? ' device' : ' devices'); if (SYNC.rtt) s += ' · ±' + Math.round(SYNC.rtt / 2) + 'ms'; /* The one number that makes this debuggable on a phone. Only a listener that is playing has one, and `locked` is worth saying out loud because the absence of a drift figure would otherwise be ambiguous. */ if (SYNC.role === 'follow' && playing && SYNC.off) s += ' · ' + (SYNC.rate === 1 ? 'locked' : 'drift ' + Math.round(SYNC.err) + 'ms'); if (SYNC.armed) s += ' · waiting for the pattern top'; } e.textContent = s; } /* ---------------------------------------------------------- the patch cord -- NOT HTML5 drag-and-drop: dragstart and drop do not fire for touch, which is the platform this app is for. Pointer capture plus elementFromPoint, which is the same conclusion the fold gesture reached after three revisions of fighting the alternative. */ var CORD = null; function paintSrcs(){ var i, k, s, el; for (i = 0; i < SRCORD.length; i++){ k = SRCORD[i]; s = SRC[k]; el = document.getElementById('src-' + k); if (!el) continue; el.style.setProperty('--v', (s.live ? s.v : 0.5).toFixed(4)); el.classList.toggle('dead', !s.live); el.setAttribute('aria-label', s.lab + (s.live ? '' : ' (no sensor)') + ', drag onto a knob'); } } function routeSet(id, k){ if (!routable(id)) return 'that one cannot be driven'; if (ROUTE[id] === k){ routeClear(id); return 'detached ' + LABEL[id]; } ROUTE[id] = k; /* three states, and never two at once */ if (ARM[id]){ delete ARM[id]; if (LOCKABLE[id]) clearLocks(id); scanGlock(); } markKnob(id); paintKnob(id); applyLive(); vizFor(id); save(); modSync(); return SRC[k].lab + ' \u2192 ' + (LABEL[id] || id); } function routeClear(id){ if (!(id in ROUTE)) return; delete ROUTE[id]; markKnob(id); paintKnob(id); applyLive(); vizFor(id); save(); modSync(); } function knobUnder(x, y){ var e = document.elementFromPoint(x, y); var k = e && e.closest ? e.closest('.knob') : null; if (!k) return null; var id = k.getAttribute('data-id'); return (id && routable(id)) ? {el:k, id:id} : null; } function cordEnd(ok){ if (!CORD) return; var t = CORD.over; if (t && t.el) t.el.classList.remove('drop'); document.body.classList.remove('cording'); var c = CORD; CORD = null; if (ok && t){ setHint(routeSet(t.id, c.k)); return; } /* A TAP on a tilt spot -- picked it up, dropped it nowhere -- TOGGLES the sensor. It belongs on the thing that represents the sensor rather than on a panel label that had to borrow the job, and it goes both ways: a switch you can only press once is not a switch. */ if (ok && !t && SRC[c.k] && SRC[c.k].kind === 'tilt') tiltToggle(); } function bindSrc(el, k){ el.addEventListener('pointerdown', function(e){ if (CORD) return; CORD = {k:k, over:null, pid:e.pointerId}; document.body.classList.add('cording'); try { el.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); setHint('drop ' + SRC[k].lab + ' on a knob'); }); el.addEventListener('pointermove', function(e){ if (!CORD || e.pointerId !== CORD.pid) return; var t = knobUnder(e.clientX, e.clientY); if ((t && t.id) === (CORD.over && CORD.over.id)) return; if (CORD.over && CORD.over.el) CORD.over.el.classList.remove('drop'); CORD.over = t; if (t) t.el.classList.add('drop'); }); el.addEventListener('pointerup', function(e){ if (!CORD || e.pointerId !== CORD.pid) return; try { el.releasePointerCapture(e.pointerId); } catch (x) {} cordEnd(1); }); el.addEventListener('pointercancel', function(e){ if (!CORD || e.pointerId !== CORD.pid) return; cordEnd(0); }); /* long press clears everything this source drives, which is the only way out that does not need you to remember which knobs you attached */ var hT = 0; el.addEventListener('pointerdown', function(){ hT = setTimeout(function(){ hT = 0; var n = 0, id; for (id in ROUTE) if (ROUTE[id] === k){ routeClear(id); n++; } cordEnd(0); setHint(n ? SRC[k].lab + ': detached from ' + n + (n === 1 ? ' knob' : ' knobs') : SRC[k].lab + ' drives nothing'); }, 600); }); ['pointerup','pointercancel','pointermove'].forEach(function(t){ el.addEventListener(t, function(){ if (hT){ clearTimeout(hT); hT = 0; } }); }); } function openHelp(){ var d = document.getElementById('help'); if (!d) return; /* live, because whether the screen stays awake is a property of the browser you happen to be holding and there is no other way to see it on a phone */ var w = document.getElementById('wlstate'); if (w) w.textContent = wakeWhy() === 'held' ? 'kept awake' : ('dims \u2014 ' + wakeWhy()); paintSecure(); var wu = document.getElementById('wsurl'); /* Shows the default rather than an empty box with a placeholder: an address you can read and edit is worth more than one you have to know is there. */ if (wu && !wu.value){ var st = ''; try { st = (localStorage.getItem(SYNCKEY) || '').trim(); } catch (e) {} wu.value = st || SYNC_DEFAULT; } paintSyncState(); /* live while it is open: the whole point is watching the count move */ if (!HELPT) HELPT = setInterval(paintSyncState, 300); if (!d.open) d.showModal(); } var HELPT = 0; async function openIO(){ var dlg = document.getElementById('io'), ta = document.getElementById('iotext'); ioMode = 'b64'; ioPaint(); paintSongs(); /* the list is live: a slot may have gone */ document.getElementById('ioscan').classList.toggle('off', !scanOk()); /* Opened FIRST and filled when the compression resolves, so a long press never feels like it hung. */ if (!dlg.open) dlg.showModal(); ta.value = ''; ioMsg('Encoding\u2026'); var json = exportText(); ta.value = await toB64(json); qrRefresh(); ioMsg(ta.value.length + ' characters \u2014 AS JSON to read or edit it.'); /* If a previous SCAN never came back, the step it died on is still on disk. */ var stale = lastCrumb(); if (stale){ crumb(null); ioMsg('Last SCAN stopped at \u201c' + stale + '\u201d \u2014 that call never returned.', 1); } /* Deliberately NOT focused: focusing a textarea raises the on-screen keyboard, which covers half a phone. showModal() puts focus on the first focusable child (the close button) instead, which raises nothing. */ try { ta.setSelectionRange(0, 0); ta.scrollTop = 0; } catch (e) {} } /* Converts what is IN the box rather than re-exporting, so switching views never silently discards an edit. */ async function ioToggle(){ var ta = document.getElementById('iotext'), t = ta.value.trim(); if (ioMode === 'b64'){ var json; if (t.charAt(0) === '{') json = t; else { var dec = await fromB64(t); if (dec === null){ ioMsg('That is not a payload I can decode.', 1); return; } json = dec.text; } ta.value = json; ioMode = 'json'; } else { if (t.charAt(0) !== '{'){ ta.value = t; ioMode = 'b64'; } else { try { JSON.parse(t); } catch (e){ ioMsg('Fix the JSON first \u2014 ' + e.message, 1); return; } ta.value = await toB64(t); ioMode = 'b64'; } } ioPaint(); qrRefresh(); ioMsg(ta.value.length + ' characters.'); try { ta.setSelectionRange(0, 0); ta.scrollTop = 0; } catch (e) {} } /* Draws whatever is in the editor, or explains why it cannot. Purely length-driven: "the string currently in the box" is the rule, so a short JSON that happens to fit gets a code just the same. */ var QRPAD = 4; /* quiet zone, in modules -- the spec's minimum */ function qrRefresh(){ qrPaint(document.getElementById('ioqr'), document.getElementById('ioqrc'), document.getElementById('ioqrn'), document.getElementById('iotext').value.trim()); } /* Takes its elements rather than looking them up. There were two QR surfaces for a while -- the song and a WebRTC pairing code -- and only the song's is left, but a painter that is handed its canvas is the better shape anyway and there is nothing to gain by hard-coding one back in. */ function qrPaint(box, cv, note, txt){ var off = function(why){ box.classList.add('off'); var d = box.closest('dialog'); if (d) d.classList.remove('qrbig'); note.textContent = why; cv.width = cv.height = 1; }; if (!box || !cv || !note) return; if (!txt) return off('nothing to encode'); var bytes = new TextEncoder().encode(txt); if (bytes.length > QR_MAX()) return off(bytes.length.toLocaleString() + ' characters \u2014 a QR code holds ' + QR_MAX().toLocaleString() + '. Switch to AS BASE64.'); var q; try { q = qrEncode(bytes); } catch (e) { return off('could not encode that'); } if (!q) return off('too long for a QR code'); var n = q.size, side = n + QRPAD * 2; box.classList.remove('off'); cv.width = cv.height = side; var g = cv.getContext('2d'); g.fillStyle = '#fff'; g.fillRect(0, 0, side, side); g.fillStyle = '#000'; for (var y = 0; y < n; y++) for (var x = 0; x < n; x++) if (q.modules[y][x]) g.fillRect(x + QRPAD, y + QRPAD, 1, 1); cv.setAttribute('aria-label', 'QR code, version ' + q.version + ', ' + n + ' modules. Tap to enlarge.'); return q.version; } var qrT = 0; function qrSoon(){ clearTimeout(qrT); qrT = setTimeout(qrRefresh, 250); } /* ---- camera scanning ------------------------------------------------- Uses the browser's own BarcodeDetector; there is no hand-written decoder. The ORDER here is the important part. A WebView cannot open a camera by itself -- the host app must hold android.permission.CAMERA and implement onPermissionRequest(). Where it does not, the Android framework throws a SecurityException inside the app's own process and the whole app dies. No try/catch on this side can catch that, because the failure is on the Java side of the boundary. So every cheap, safe question is asked FIRST, and the camera is the last thing touched -- on a device that cannot decode anyway, getUserMedia is never reached. */ var SCANKEY = 'synthi.scanstep'; /* A process crash takes the console with it, but localStorage survives. Each risky step is written down before it runs and erased when it returns, so the next launch can name the call that never came back. */ function crumb(step){ try { if (step) localStorage.setItem(SCANKEY, step); else localStorage.removeItem(SCANKEY); } catch (e) {} } function lastCrumb(){ try { return localStorage.getItem(SCANKEY); } catch (e) { return null; } } /* ---- did the last session END, or was it KILLED? ------------------------ Same trick as the breadcrumb above, aimed at the page's own lifetime. The complaint this answers is "sometimes the page reloads by itself", and nothing in this file can reload it -- there is no location.reload, no meta refresh, no form, no service worker anywhere. So the cause is outside, and there are two of those with completely different fixes: a deliberate reload -- a dev server with live reload, or a gesture. It fires `pagehide`, so it is recorded CLEAN. the browser dropping the tab -- memory pressure on Android or iOS. It fires NOTHING, so the record stays UNCLEAN. That asymmetry is the whole diagnosis, and the page could not see it before because it had no unload handler of any kind. `up` is how long the session lasted, which matters too: this app starts a take on every single run and caps it at 300s, so a death clustering around five minutes would point somewhere very specific. */ var SESSKEY = 'synthi.sess', SESS0 = Date.now(), SESST = 0, LASTSESS = null; function sessRead(){ try { return JSON.parse(localStorage.getItem(SESSKEY) || 'null'); } catch (e) { return null; } } function sessWrite(clean){ try { localStorage.setItem(SESSKEY, JSON.stringify({ t: Date.now(), up: Math.round((Date.now() - SESS0) / 1000), playing: playing ? 1 : 0, clean: clean ? 1 : 0 })); } catch (e) {} } /* Read the PREVIOUS record before overwriting it, then keep the current one warm. 5s, because the only thing the refresh buys is `up` -- knowing the session was alive at all is the boot write's job. */ function sessStart(){ LASTSESS = sessRead(); sessWrite(0); if (!SESST) SESST = setInterval(function(){ sessWrite(0); }, 5000); } /* Only speaks when the answer is interesting. A clean end is an ordinary reload and needs no announcement; an unclean one is the browser having thrown the tab away, and that is worth a red line in the hint. */ function sessSay(){ var s = LASTSESS; if (!s || s.clean || typeof s.up !== 'number') return; setHint('last session was KILLED after ' + s.up + 's' + (s.playing ? ' while playing' : '') + ' \u2014 not reloaded, dropped: the browser reclaimed the tab', 1); } var SCAN = null; /* {stream, video, timer, det, first} while open */ function scanOk(){ return typeof BarcodeDetector === 'function'; } /* '' when scanning is possible, otherwise the reason it is not. */ async function scanWhyNot(){ crumb('probe-typeof'); if (!scanOk()) return 'This browser has no QR reader \u2014 scan with the phone\u2019s own camera app, then paste it into the field.'; crumb('probe-formats'); var fmts = null; try { fmts = await BarcodeDetector.getSupportedFormats(); } catch (e){ return 'QR reader unavailable (' + (e.name || 'error') + ').'; } /* On a de-Googled Android this is empty: the detector exists but its backing module is part of Play Services, which is not installed. */ if (!fmts || fmts.indexOf('qr_code') < 0) return 'No QR support on this device \u2014 the barcode module is missing.'; crumb('enumerate'); if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return 'This browser cannot open a camera.'; var cams = 0; try { var devs = await navigator.mediaDevices.enumerateDevices(); for (var i = 0; i < devs.length; i++) if (devs[i].kind === 'videoinput') cams++; } catch (e){ cams = 1; } /* unknown -- let getUserMedia decide */ if (!cams) return 'No camera on this device.'; return ''; } function scanStop(msg, bad){ if (SCAN){ clearTimeout(SCAN.timer); try { SCAN.stream.getTracks().forEach(function(t){ t.stop(); }); } catch (e) {} try { SCAN.video.srcObject = null; } catch (e) {} SCAN = null; } crumb(null); var t = (SCAN && SCAN.t) || scanLast || SCAN_SONG; scanLast = null; /* Both dialogs and both buttons: whichever scan was running, the class and the label have to come back off, and knowing which one it was is exactly the state that goes missing when a scan dies in an unexpected place. */ ['io', 'help'].forEach(function(id){ var d = document.getElementById(id); if (d) d.classList.remove('scanning'); }); ['ioscan', 'wsscan'].forEach(function(id){ var b = document.getElementById(id); if (b) b.textContent = 'SCAN'; }); if (msg) t.msg(msg, bad); } var scanLast = null; /* Where a scan goes. There is one target again: the pairing code that used to need its own -- so that an SDP could never land in the song textarea and have IMPORT offer to replace your song with it -- has no code to carry any more. `hit:null` is what says "put the text in #iotext"; the field stays because it is the one thing that distinguished the two. */ var SCAN_SONG = {dlg:'io', video:'ioqrv', btn:'ioscan', msg:ioMsg, hit:null}; /* The second target: the sync server's own code, straight into the address field. `hit` is what keeps an address out of the song textarea and a song out of the address field -- the same separation the pairing target needed. */ function addrFromScan(txt){ /* What the server prints is its STUB url, https://ip:port/, because that is the code a camera app can act on and the only way to accept a certificate (entry 118). The field wants host:port, so the scheme and the path come off -- which also takes wss://ip:port/ws, and leaves a bare host:port that arrived some other way exactly as it is. */ return String(txt || '').trim() .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') .replace(/\/.*$/, ''); } var SCAN_ADDR = {dlg:'help', video:'wsqrv', btn:'wsscan', msg:setHint, hit:function(txt){ var a = addrFromScan(txt); var u = document.getElementById('wsurl'); if (!a){ setHint('that code carried no address', 1); return; } if (u) u.value = a; SYNC.row = 1; paintSyncState(); setHint('scanned ' + a + ' \u2014 press SET to use it'); }}; async function scanStart(t){ t = t || SCAN_SONG; var say = t.msg; if (SCAN){ scanStop('Scanning stopped.'); return; } var why = await scanWhyNot(); if (why){ crumb(null); say(why, 1); return; } var v = document.getElementById(t.video), stream = null, det; say('Starting the camera\u2026'); crumb('getusermedia'); try { /* 640x480 is plenty for a QR and far lighter than whatever a phone camera would otherwise hand back, which is often 1080p or more. */ stream = await navigator.mediaDevices.getUserMedia({video:{ facingMode:{ideal:'environment'}, width:{ideal:640}, height:{ideal:480}}}); } catch (e){ crumb(null); say('No camera: ' + (e.name || e.message), 1); return; } crumb('construct'); try { det = new BarcodeDetector({formats:['qr_code']}); } catch (e){ stream.getTracks().forEach(function(x){ x.stop(); }); crumb(null); say('QR reader unavailable: ' + (e.message || e.name), 1); return; } crumb('play'); v.srcObject = stream; try { await v.play(); } catch (e) {} document.getElementById(t.dlg).classList.add('scanning'); var b = document.getElementById(t.btn); if (b) b.textContent = 'STOP'; say('Point the camera at a QR code.'); SCAN = {stream:stream, video:v, timer:0, det:det, first:true, t:t}; scanTick(); } /* Self-scheduling rather than setInterval: the next poll is armed only once the previous detect() has settled, so calls can never pile up each holding a frame -- which is an unbounded memory ramp and an OOM kill on a phone. */ function scanTick(){ if (!SCAN) return; var s = SCAN, v = s.video; var again = function(){ if (SCAN === s) s.timer = setTimeout(scanTick, 150); }; /* play() resolving does not mean a frame exists; a 0x0 video into a native detector is exactly the kind of thing that faults rather than returns. */ if (!v.videoWidth || !v.videoHeight || v.readyState < 2){ again(); return; } if (s.first) crumb('first-detect'); var p; try { p = s.det.detect(v); } catch (e){ if (s.first){ s.first = false; crumb('loop'); } again(); return; } p.then(function(hits){ if (s.first){ s.first = false; crumb('loop'); } if (SCAN !== s) return; var txt = (hits && hits.length) ? hits[0].rawValue : ''; if (!txt){ again(); return; } var tgt = s.t || SCAN_SONG; scanLast = tgt; scanStop(null); if (tgt.hit){ tgt.hit(txt); return; } var ta = document.getElementById('iotext'); ta.value = txt; /* same rule PASTE uses, or the toggle label would lie */ ioMode = txt.trim().charAt(0) === '{' ? 'json' : 'b64'; ioPaint(); qrRefresh(); /* deliberately does NOT import -- IMPORT wipes the current song */ ioMsg('Scanned ' + txt.length + ' characters \u2014 press IMPORT to apply.'); }, function(){ if (s.first){ s.first = false; crumb('loop'); } again(); /* a frame that will not decode is normal */ }); } function ioPaste(){ var ta = document.getElementById('iotext'); var took = function(t){ if (typeof t !== 'string' || !t){ manual('clipboard was empty'); return; } ta.value = t; /* re-derive the view from what actually arrived, or the toggle label lies */ ioMode = t.trim().charAt(0) === '{' ? 'json' : 'b64'; ioPaint(); qrRefresh(); ioMsg('Pasted ' + t.length + ' characters \u2014 press IMPORT to apply.'); }; /* Under file:// clipboard-read is not granted, so this is the path that usually runs: select everything so one keystroke replaces it. */ var manual = function(why){ ta.focus(); ta.select(); ioMsg((why ? why + ' \u2014 ' : '') + 'press Ctrl+V (\u2318V) to replace the text.', 1); }; if (navigator.clipboard && navigator.clipboard.readText){ try { navigator.clipboard.readText().then(took, function(){ manual('clipboard blocked'); }); } catch (e) { manual('clipboard blocked'); } } else manual('no clipboard access'); } function ioCopy(){ var ta = document.getElementById('iotext'); var done = function(){ ioMsg('Copied ' + ta.value.length + ' characters.'); }; var manual = function(){ var okc = false; ta.focus(); ta.select(); try { okc = document.execCommand('copy'); } catch (e) {} if (okc) done(); else ioMsg('Copy was blocked \u2014 the text is selected, copy it by hand.', 1); }; /* Opened from file://, the async clipboard API is routinely unavailable, so the fallback is the path that actually runs rather than a formality. */ if (navigator.clipboard && navigator.clipboard.writeText){ try { navigator.clipboard.writeText(ta.value).then(done, manual); } catch (e) { manual(); } } else manual(); } async function ioImport(){ var ta = document.getElementById('iotext'); var r = await importText(ta.value); if (!r.ok){ ioMsg(r.msg, true); return; } /* a rejected paste stays put to be fixed */ /* A successful import closes: the song underneath is the result, and there is nothing left to do in here. The message moves to the hint rather than being shown for the split second before the dialog goes -- and the text field is not re-rendered, because openIO() rebuilds it from live state anyway. */ setHint(r.msg); document.getElementById('io').close(); } /* ================================================================= boot == */ sessStart(); /* before load(), so nothing can pre-empt reading the previous record */ load(); if (!SCENES.length) SCENES = [liveScene()]; ensureScenes(); buildGlyphs(); buildSeq(); buildDrums(); bindDrums(); buildKeys(); bindKeys(); buildChrome(); markAllKnobs(); /* KNOBS only exists once buildChrome ran */ paintBpm(); readTheme(); bindCol(); /* A stored layout is yours and is obeyed. Without one, the fit check decides -- but only once the layout has SETTLED: measured at boot, before the fonts and the canvas backing stores have their final sizes, a transient overflow folded the mixer on a 412x915 screen that fits it perfectly well. fitCol only ever folds, so running it late is safe; running it early was not. */ var hadCol = loadCol(); paintCol(); if (!hadCol) requestAnimationFrame(function(){ requestAnimationFrame(function(){ setTimeout(fitCol, 250); }); }); wakeOn(); /* on screen from now on means awake */ requestAnimationFrame(drawAll); var vizT = 0; function reflow(d){ clearTimeout(vizT); vizT = setTimeout(drawAll, d); } /* drawAll re-measures */ window.addEventListener('resize', function(){ reflow(120); }); window.addEventListener('orientationchange', function(){ reflow(260); }); /* the canvases are flex-sized, so their box can change without a window resize (font load, keyboard, rotation). Re-fit whenever it actually does. */ if (window.ResizeObserver){ var ro = new ResizeObserver(function(){ reflow(50); }); var vs = document.querySelectorAll('canvas.viz'); for (var vi = 0; vi < vs.length; vi++) ro.observe(vs[vi]); } if (IOS) setHint('silent switch off'); sessSay(); /* last, so it outranks the line above: a tab that was thrown away is the more urgent thing to know */ </script> </body> </html>