@kolkrabbi/kol-hardware 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,17 +11,17 @@ A different TIER from `kol-component`'s app atoms: a `kol-component` Slider is a
11
11
  | value | `Knob` | SVG rotary — sizes sm 24 · md 32 · lg 40 · xl 64, 270° sweep, drag ns (200px = range), ⌥-click resets, touch long-press → `ParamSheet` |
12
12
  | | `Fader` | the rack slider — 2px track + 8px thumb, horizontal (flex-1 + readout) or vertical; long-press → `ParamSheet` |
13
13
  | | `ParamSheet`, `armLongPress` | touch: hold a control 500ms → a full-width bottom sheet with the DS `Slider` |
14
- | | `EnvelopeGenerator` | a value over time — an equation or an ADSR envelope — with the scope, window controls and reference |
14
+ | | `EnvelopeGenerator` | a value over time — an equation or an ADSR envelope — filling its parent: the scope box, its controls and transport (BPM), and the reference panel. `useEnvelopeGenerator()` + `EnvelopeModeToggle` let a host arrange the parts |
15
15
  | switches | `Toggle` | LED-dot toggle, sm 8 · md 12 — momentary, blink, long-press, `forceLit` |
16
16
  | | `FlipToggle` | 2/3-position rocker, horizontal or vertical |
17
17
  | | `RockerSwitch` | I/O rocker, backlit paddle |
18
18
  | | `IconButton` | 1px-bordered icon key, momentary pulse |
19
19
  | indicators | `LED` | 6 / 8px lamp — red · yellow · green · white · blue, optional hit pad |
20
20
  | | `JackSocket`, `LabeledJack` | the 3.5mm jack, presentational: ring, hole, label, a rim that glows with `signalRef`. **Routing stays in the consumer** |
21
- | | `SignalScope` | the oscilloscope — a trace of any `sample(t)`, the knob range dashed, a live playhead |
21
+ | | `SignalScope` | the oscilloscope — a trace of any `sample(t)`, the knob range dashed, a live playhead; zoom X/Y, drag to pan, a clock `rate`, `loop` or one-shot on `trigger`, `height="fill"` |
22
22
  | panel | `PanelLabel` | label wrapper, four positions |
23
23
  | | `ModuleHeader` | Toggle + module name + edit-mode remove dot / bypass dot |
24
- | | `SignalReference` | an expression / envelope reference — `variant` `tabs` · `popover` (EX / REF) · `sheet` |
24
+ | | `SignalReference` | an expression / envelope reference — `variant` `panel` (mirror's box) · `tabs` · `popover` (EX / REF) · `sheet` |
25
25
  | frames | `ModuleFrame` | a module's front panel — header pinned, body below |
26
26
  | | `ChannelStrip` | a mixer channel's face — power · control grid · action column · faders · footer, as slots |
27
27
  | | `FlipCard` | a face that turns over in place to its back |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-hardware",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "KOL hardware — panel controls for instruments (mixers, synths, racks, decks), in five groups: value (knob, fader, the touch ParamSheet), switches (toggle, flip, rocker, icon button), indicators (LED, jacks), panel (label, module header), frames (module frame, channel strip, flip card). Plus the signal engine (./signal): one expression compiler and ADSR for every scope and envelope in the estate. Renamed from @kolkrabbi/kol-controls. Sits above @kolkrabbi/kol-{theme,icons,component}.",
6
6
  "license": "MIT",
@@ -20,9 +20,9 @@
20
20
  "react-dom": "^18.3.0 || ^19.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@kolkrabbi/kol-theme": "^0.152.0",
24
- "@kolkrabbi/kol-icons": "^0.27.1",
25
- "@kolkrabbi/kol-component": "^0.225.0"
23
+ "@kolkrabbi/kol-component": "^0.226.0",
24
+ "@kolkrabbi/kol-icons": "^0.28.0",
25
+ "@kolkrabbi/kol-theme": "^0.153.0"
26
26
  },
27
27
  "files": [
28
28
  "src",
package/src/index.js CHANGED
@@ -35,4 +35,4 @@ export { default as FlipCard } from './frames/FlipCard.jsx'
35
35
  // signal — the components over the signal engine (the engine itself, React-free, is `./signal`)
36
36
  export { default as SignalScope } from './indicators/SignalScope.jsx'
37
37
  export { default as SignalReference } from './panel/SignalReference.jsx'
38
- export { default as EnvelopeGenerator } from './value/EnvelopeGenerator.jsx'
38
+ export { default as EnvelopeGenerator, useEnvelopeGenerator, EnvelopeModeToggle, adsrCode } from './value/EnvelopeGenerator.jsx'
@@ -8,31 +8,59 @@ import { useEffect, useRef } from 'react'
8
8
  *
9
9
  * Presentational: pass any `sample(t) → number` — a compiled expression, an envelope, a bus.
10
10
  *
11
+ * THE VIEW IS MIRROR'S (app frame and curves, 2026-09-27): `zoomX` narrows the window to
12
+ * `sec / zoomX` seconds starting at `ofs`; `zoomY` narrows the value span around the centre of
13
+ * `min…max`, shifted by `panY`. `onPan` makes the trace draggable — it reports the new
14
+ * `{ ofs, panY }` and the host keeps them, as mirror's Oscilloscope did with its own state.
15
+ *
16
+ * THE CLOCK is the scope's: `rate` is time units per second (BPM / 60 for a beat clock), and
17
+ * `playing` stops it where it is. `loop` (default) wraps the playhead over `sec`; off, the
18
+ * playhead runs `0 → sec` once and holds at the end, and every change of `trigger` fires it
19
+ * again — a one-shot envelope's gate.
20
+ *
11
21
  * @param {(t:number)=>number} sample
12
22
  * @param {number} min · max the view (default 0…100)
13
- * @param {number} sec · ofs the window: `sec` seconds starting at `ofs`
23
+ * @param {number} sec · ofs the window: `sec` time units starting at `ofs`
24
+ * @param {number} zoomX · zoomY view zoom (default 1)
25
+ * @param {number} panY value pan (default 0)
26
+ * @param {Function} onPan `({ ofs, panY }) => void` — drag the trace to pan
14
27
  * @param {number[]} refLines values drawn dashed (default the knob range 0 and 100)
15
- * @param {boolean} live sweep a playhead and redraw every frame (default true)
16
- * @param {number|string} height (default 320)
28
+ * @param {boolean} live redraw every frame and sweep a playhead (default true)
29
+ * @param {boolean} playing the clock runs (default true)
30
+ * @param {number} rate time units per second (default 1)
31
+ * @param {boolean} loop wrap the playhead (default true); off = one pass per `trigger`
32
+ * @param {number} trigger a counter — each change restarts the pass
33
+ * @param {number|'fill'} height px (default 320), or `fill` to take the parent's height
17
34
  */
18
35
  const css = (el, name, fallback) => getComputedStyle(el).getPropertyValue(name).trim() || fallback
19
36
 
20
37
  export default function SignalScope({
21
- sample, min = 0, max = 100, sec = 5, ofs = 0, refLines = [0, 100], live = true, height = 320, className = '',
38
+ sample, min = 0, max = 100, sec = 5, ofs = 0, zoomX = 1, zoomY = 1, panY = 0, onPan,
39
+ refLines = [0, 100], live = true, playing = true, rate = 1, loop = true, trigger = 0,
40
+ height = 320, className = '',
22
41
  }) {
23
42
  const ref = useRef(null)
24
43
  const args = useRef({})
25
44
  const drawRef = useRef(null)
26
- args.current = { sample, min, max, sec, ofs, refLines, live }
45
+ const clock = useRef({ t: 0, last: 0 })
46
+ args.current = { sample, min, max, sec, ofs, zoomX, zoomY, panY, refLines, live, playing, rate, loop }
47
+
48
+ // a trigger restarts the pass
49
+ useEffect(() => { clock.current.t = 0 }, [trigger])
27
50
 
28
51
  useEffect(() => {
29
52
  const canvas = ref.current
30
53
  if (!canvas) return undefined
31
54
  const ctx = canvas.getContext('2d')
32
- const start = performance.now()
55
+ clock.current.last = performance.now()
33
56
  let raf = 0
34
57
  const draw = () => {
35
- const { sample: fn, min: lo, max: hi, sec: s, ofs: o, refLines: lines, live: on } = args.current
58
+ const a = args.current
59
+ const now = performance.now()
60
+ const dt = (now - clock.current.last) / 1000
61
+ clock.current.last = now
62
+ if (a.live && a.playing) clock.current.t += dt * a.rate
63
+ const fn = a.sample
36
64
  const dpr = window.devicePixelRatio || 1
37
65
  const { width: w, height: h } = canvas.getBoundingClientRect()
38
66
  if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
@@ -44,8 +72,13 @@ export default function SignalScope({
44
72
  const ink = css(canvas, '--kol-surface-on-primary', '#e8e4dc')
45
73
  const red = css(canvas, '--kol-ctl-led-red', '#e74c3c')
46
74
  const pad = 12
47
- const span = hi - lo || 1
75
+ const span = (a.max - a.min) / (a.zoomY || 1) || 1
76
+ const lo = (a.min + a.max) / 2 + a.panY - span / 2
77
+ const hi = lo + span
78
+ const dur = a.sec / (a.zoomX || 1)
48
79
  const toY = (v) => pad + (h - pad * 2) * (1 - (v - lo) / span)
80
+ const toX = (t) => ((t - a.ofs) / dur) * w
81
+ const fmt = (v) => String(Math.round(v * 10) / 10)
49
82
  ctx.font = '9px var(--kol-font-family-mono), monospace'
50
83
  // midline
51
84
  ctx.globalAlpha = 0.12
@@ -57,7 +90,7 @@ export default function SignalScope({
57
90
  ctx.strokeStyle = red
58
91
  ctx.fillStyle = red
59
92
  ctx.setLineDash([4, 4])
60
- for (const v of lines) {
93
+ for (const v of a.refLines) {
61
94
  const y = toY(v)
62
95
  ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke()
63
96
  ctx.fillText(String(v), w - 4 - ctx.measureText(String(v)).width, y - 3)
@@ -66,30 +99,32 @@ export default function SignalScope({
66
99
  // axis labels
67
100
  ctx.globalAlpha = 0.4
68
101
  ctx.fillStyle = ink
69
- ctx.fillText(String(hi), 4, pad + 9)
70
- ctx.fillText(String(lo), 4, h - pad - 3)
102
+ ctx.fillText(fmt(hi), 4, pad + 9)
103
+ ctx.fillText(fmt(lo), 4, h - pad - 3)
71
104
  // trace
72
105
  ctx.globalAlpha = 1
73
106
  ctx.strokeStyle = ink
74
107
  ctx.lineWidth = 1.5
75
108
  ctx.beginPath()
76
109
  for (let x = 0; x <= w; x++) {
77
- const y = toY(fn ? fn(o + (x / w) * s) : lo)
110
+ const y = toY(fn ? fn(a.ofs + (x / w) * dur) : lo)
78
111
  if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y)
79
112
  }
80
113
  ctx.stroke()
81
- // playhead
82
- if (on && fn) {
83
- const u = (((performance.now() - start) / 1000) % s) / s
84
- const px = u * w
85
- const py = toY(fn(o + u * s))
86
- ctx.globalAlpha = 0.25
87
- ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.stroke()
88
- ctx.globalAlpha = 1
89
- ctx.fillStyle = ink
90
- ctx.beginPath(); ctx.arc(px, py, 3, 0, Math.PI * 2); ctx.fill()
114
+ // playhead — over the base window, whatever the zoom (mirror's)
115
+ if (a.live && fn) {
116
+ const t = a.loop ? clock.current.t % a.sec : Math.min(clock.current.t, a.sec)
117
+ const px = toX(t)
118
+ if (px >= 0 && px <= w) {
119
+ const py = toY(fn(t))
120
+ ctx.globalAlpha = 0.25
121
+ ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.stroke()
122
+ ctx.globalAlpha = 1
123
+ ctx.fillStyle = ink
124
+ ctx.beginPath(); ctx.arc(px, py, 3, 0, Math.PI * 2); ctx.fill()
125
+ }
91
126
  }
92
- if (on) raf = requestAnimationFrame(draw)
127
+ if (a.live) raf = requestAnimationFrame(draw)
93
128
  }
94
129
  drawRef.current = draw
95
130
  draw()
@@ -101,5 +136,32 @@ export default function SignalScope({
101
136
  // a static scope redraws when its inputs change; a live one is already redrawing every frame
102
137
  useEffect(() => { if (!live) drawRef.current?.() })
103
138
 
104
- return <canvas ref={ref} className={`block w-full rounded-[4px] bg-surface-primary ${className}`} style={{ height }} />
139
+ /* drag to pan — mirror's gesture, reported rather than kept */
140
+ const pan = onPan ? {
141
+ onPointerDown: (e) => {
142
+ const canvas = ref.current
143
+ const box = canvas.getBoundingClientRect()
144
+ const start = { x: e.clientX, y: e.clientY, ofs, panY }
145
+ const dur = sec / (zoomX || 1)
146
+ const span = (max - min) / (zoomY || 1)
147
+ canvas.setPointerCapture(e.pointerId)
148
+ const move = (ev) => onPan({
149
+ ofs: start.ofs - ((ev.clientX - start.x) / box.width) * dur,
150
+ panY: start.panY + ((ev.clientY - start.y) / box.height) * span,
151
+ })
152
+ const up = () => { canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up) }
153
+ canvas.addEventListener('pointermove', move)
154
+ canvas.addEventListener('pointerup', up)
155
+ },
156
+ } : {}
157
+
158
+ const fill = height === 'fill'
159
+ return (
160
+ <canvas
161
+ ref={ref}
162
+ {...pan}
163
+ className={`block w-full rounded-[4px] bg-surface-primary ${fill ? 'h-full min-h-0' : ''} ${onPan ? 'cursor-grab touch-none active:cursor-grabbing' : ''} ${className}`}
164
+ style={fill ? undefined : { height }}
165
+ />
166
+ )
105
167
  }
@@ -1,14 +1,17 @@
1
1
  import { useState } from 'react'
2
- import { TabsRow, usePopover, PopoverPanel } from '@kolkrabbi/kol-component'
2
+ import { Button, TabsRow, usePopover, PopoverPanel } from '@kolkrabbi/kol-component'
3
3
 
4
4
  /**
5
5
  * SignalReference — the reference an expression or envelope tool is explained with (signal
6
6
  * engine, 2026-09-27). ONE component over the reference DATA in `@kolkrabbi/kol-hardware/signal`,
7
7
  * in the three shapes the estate already used:
8
8
  *
9
- * variant="tabs" kol-mirror's /expressions panel — Examples · Saved · Language · Usage
9
+ * variant="tabs" the tabs and their sections, bare — for a host that draws its own box
10
+ * variant="panel" kol-mirror's /expressions fold, class for class: a box the height of its
11
+ * parent, TabsRow in its head (`px-4 border-b`), the body `p-4` scrolling
12
+ * inside, sub-eyebrows only when a tab holds more than one section
10
13
  * variant="popover" kol-monitor's Scope+ face — an EX and a REF button, each a popover
11
- * variant="sheet" the design editor's labs shortcuts sheet — every section, stacked
14
+ * variant="sheet" the design editor's labs shortcuts sheet — every section, in columns
12
15
  *
13
16
  * Clicking a code loads it (`onPick`); ⌘/Ctrl-click appends it (`onAppend`) — mirror's gestures.
14
17
  *
@@ -16,6 +19,7 @@ import { TabsRow, usePopover, PopoverPanel } from '@kolkrabbi/kol-component'
16
19
  * @param {Array} tabs `[{ id, label, sections }]` (EXPRESSION_TABS, ADSR_TABS); a tab with
17
20
  * id `saved` renders the host's `saved` rows, and is hidden without them
18
21
  * @param {Array} saved `[code, label]` rows the host keeps (its library)
22
+ * @param {'xs'|'sm'|'md'} size the popover buttons' rung (default sm) — match the row they sit in
19
23
  */
20
24
  function Code({ text, onPick, onAppend }) {
21
25
  return (
@@ -31,8 +35,8 @@ function Code({ text, onPick, onAppend }) {
31
35
 
32
36
  function Section({ section, onPick, onAppend, showTitle }) {
33
37
  return (
34
- <div className="flex flex-col gap-1">
35
- {showTitle && <div className="kol-helper-10 uppercase text-fg-48">{section.title}</div>}
38
+ <div className="flex shrink-0 flex-col">
39
+ {showTitle && <div className="kol-helper-12 flex h-6 shrink-0 items-center uppercase text-fg-48">{section.title}</div>}
36
40
  {section.tips
37
41
  ? section.tips.map((tip) => <div key={tip} className="kol-helper-10 text-fg-48" style={{ lineHeight: '150%' }}>{tip}</div>)
38
42
  : (
@@ -41,11 +45,12 @@ function Section({ section, onPick, onAppend, showTitle }) {
41
45
  const codes = Array.isArray(row) ? [row[0]] : row.codes
42
46
  const label = Array.isArray(row) ? row[1] : row.label
43
47
  return (
44
- <div key={codes.join()} className="flex h-6 items-center justify-between gap-3">
45
- <span className="flex min-w-0 items-center gap-2">
48
+ <div key={codes.join()} className="flex h-6 min-w-0 items-center justify-between gap-3">
49
+ {/* in the two-column grid the CODE is the thing to read — the label yields */}
50
+ <span className={`flex items-center gap-2 ${section.grid ? 'shrink-0' : 'min-w-0'}`}>
46
51
  {codes.map((c) => <Code key={c} text={c} onPick={onPick} onAppend={onAppend} />)}
47
52
  </span>
48
- <span className="kol-helper-10 shrink-0 text-fg-48">{label}</span>
53
+ <span className={`kol-helper-10 text-fg-48 ${section.grid ? 'min-w-0 truncate' : 'shrink-0'}`}>{label}</span>
49
54
  </div>
50
55
  )
51
56
  })}
@@ -75,19 +80,35 @@ function Tabs({ sections, tabs, saved, onPick, onAppend }) {
75
80
  )
76
81
  }
77
82
 
78
- function PopButton({ label, list, onPick, onAppend }) {
83
+ /* THE FOLD — kol-mirror's /expressions reference box (ExpressionReference, `scopeFill`) */
84
+ function Panel({ sections, tabs, saved, onPick, onAppend }) {
85
+ const visible = tabs.filter((t) => t.id !== 'saved' || saved)
86
+ const [tab, setTab] = useState(visible[0]?.id)
87
+ const current = visible.find((t) => t.id === tab) ?? visible[0]
88
+ const list = current ? sectionsOf(current, sections, saved) : []
89
+ return (
90
+ <div className="kol-helper-12 flex h-full min-h-0 flex-col overflow-hidden rounded-[4px] border border-oq-08 bg-surface-secondary">
91
+ <div className="shrink-0 border-b border-oq-08 px-4">
92
+ <TabsRow tabs={visible} value={current?.id} onChange={setTab} />
93
+ </div>
94
+ <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4" style={{ scrollbarWidth: 'none' }}>
95
+ {current?.id === 'saved' && !saved?.length
96
+ ? <div className="kol-helper-10 text-fg-48">Nothing saved yet.</div>
97
+ : list.map((s) => <Section key={s.title} section={s} onPick={onPick} onAppend={onAppend} showTitle={list.length > 1} />)}
98
+ </div>
99
+ </div>
100
+ )
101
+ }
102
+
103
+ function PopButton({ label, list, onPick, onAppend, size }) {
79
104
  const [open, setOpen] = useState(false)
80
105
  const popover = usePopover({ open, onOpenChange: setOpen, placement: 'bottom-end' })
81
106
  return (
82
107
  <>
83
- <button
84
- type="button"
85
- ref={popover.refs.setReference}
86
- {...popover.getReferenceProps()}
87
- className="kol-helper-10 h-7 rounded-[2px] bg-surface-tertiary px-2 text-fg-96"
88
- >
89
- {label}
90
- </button>
108
+ {/* Button takes no ref — the wrapper is the anchor, and the click reaches it by bubbling */}
109
+ <span ref={popover.refs.setReference} {...popover.getReferenceProps()} className="inline-flex">
110
+ <Button variant="grey" size={size} pressed={open}>{label}</Button>
111
+ </span>
91
112
  <PopoverPanel popover={popover} className="w-80 max-h-96 overflow-y-auto p-3">
92
113
  <div className="flex flex-col gap-3">
93
114
  {list.map((s) => (
@@ -100,20 +121,27 @@ function PopButton({ label, list, onPick, onAppend }) {
100
121
  )
101
122
  }
102
123
 
103
- export default function SignalReference({ sections, tabs, variant = 'tabs', saved, onPick, onAppend, className = '' }) {
124
+ export default function SignalReference({ sections, tabs, variant = 'tabs', saved, onPick, onAppend, size = 'sm', className = '' }) {
125
+ if (variant === 'panel') {
126
+ return (
127
+ <div className={`h-full min-h-0 ${className}`}>
128
+ <Panel sections={sections} tabs={tabs} saved={saved} onPick={onPick} onAppend={onAppend} />
129
+ </div>
130
+ )
131
+ }
104
132
  if (variant === 'popover') {
105
133
  const [first, ...rest] = tabs.filter((t) => t.id !== 'saved')
106
134
  return (
107
135
  <div className={`flex gap-1 ${className}`}>
108
- {first && <PopButton label="EX" list={sectionsOf(first, sections)} onPick={onPick} onAppend={onAppend} />}
109
- {rest.length > 0 && <PopButton label="REF" list={rest.flatMap((t) => sectionsOf(t, sections))} onPick={onPick} onAppend={onAppend} />}
136
+ {first && <PopButton label="EX" size={size} list={sectionsOf(first, sections)} onPick={onPick} onAppend={onAppend} />}
137
+ {rest.length > 0 && <PopButton label="REF" size={size} list={rest.flatMap((t) => sectionsOf(t, sections))} onPick={onPick} onAppend={onAppend} />}
110
138
  </div>
111
139
  )
112
140
  }
113
141
  if (variant === 'sheet') {
114
142
  const all = tabs.filter((t) => t.id !== 'saved').flatMap((t) => sectionsOf(t, sections))
115
143
  return (
116
- <div className={`grid gap-6 sm:grid-cols-2 ${className}`}>
144
+ <div className={`grid content-start gap-x-6 gap-y-4 ${className}`} style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
117
145
  {all.map((s) => <Section key={s.title} section={s} onPick={onPick} onAppend={onAppend} showTitle />)}
118
146
  </div>
119
147
  )
@@ -6,9 +6,13 @@
6
6
  * Params are the knob values, 0…100:
7
7
  * attack · decay · release → seconds via `stageSeconds` (0 → 5 ms, 100 → 2 s, linear — monitor's)
8
8
  * sustain → the held level, 0…100
9
+ *
10
+ * `hold` is SECONDS, not a knob (app frame and curves, 2026-09-27): how long the gate stays open
11
+ * once the envelope has reached sustain. Default 1 — the gate the engine always assumed
12
+ * (`a + d + 1`), so an envelope without it draws exactly as before.
9
13
  */
10
14
 
11
- export const ADSR_DEFAULTS = Object.freeze({ attack: 10, decay: 30, sustain: 70, release: 50 })
15
+ export const ADSR_DEFAULTS = Object.freeze({ attack: 10, decay: 30, sustain: 70, release: 50, hold: 1 })
12
16
 
13
17
  /** knob 0…100 → seconds, 5 ms … 2 s */
14
18
  export const stageSeconds = (v) => 0.005 + (Math.max(0, Math.min(100, v)) / 100) * 1.995
@@ -16,10 +20,16 @@ export const stageSeconds = (v) => 0.005 + (Math.max(0, Math.min(100, v)) / 100)
16
20
  /** a 0…100 level → [min, max] */
17
21
  export const toRange = (level, min = 0, max = 100) => min + (level / 100) * (max - min)
18
22
 
23
+ /** Seconds the gate stays open: attack + decay + the sustain hold. */
24
+ export const gateSeconds = (params = ADSR_DEFAULTS) => {
25
+ const p = { ...ADSR_DEFAULTS, ...params }
26
+ return stageSeconds(p.attack) + stageSeconds(p.decay) + Math.max(0, p.hold)
27
+ }
28
+
19
29
  /**
20
- * envelopeAt(t, params, { hold, cycle }) — the level at `t` seconds after a gate that stays
21
- * open for `hold` seconds (default: until sustain is reached, plus 1 s). Pure, so a scope can
22
- * draw it and a timeline can scrub it. `cycle` repeats the whole envelope.
30
+ * envelopeAt(t, params, { hold, cycle }) — the level at `t` seconds after the gate opens. The
31
+ * gate stays open for `gateSeconds(params)`; the `hold` option (total gate seconds) overrides it.
32
+ * Pure, so a scope can draw it and a timeline can scrub it. `cycle` repeats the whole envelope.
23
33
  */
24
34
  export function envelopeAt(t, params = ADSR_DEFAULTS, { hold, cycle = false } = {}) {
25
35
  const { attack, decay, sustain, release } = { ...ADSR_DEFAULTS, ...params }
@@ -27,7 +37,7 @@ export function envelopeAt(t, params = ADSR_DEFAULTS, { hold, cycle = false } =
27
37
  const d = stageSeconds(decay)
28
38
  const r = stageSeconds(release)
29
39
  const s = Math.max(0, Math.min(100, sustain))
30
- const gate = hold ?? a + d + 1
40
+ const gate = hold ?? gateSeconds(params)
31
41
  const length = gate + r
32
42
  let u = t
33
43
  if (cycle) u = ((t % length) + length) % length
@@ -42,7 +52,7 @@ export function envelopeAt(t, params = ADSR_DEFAULTS, { hold, cycle = false } =
42
52
  /** Total seconds of one envelope pass — for sizing a scope window. */
43
53
  export const envelopeLength = (params = ADSR_DEFAULTS, { hold } = {}) => {
44
54
  const p = { ...ADSR_DEFAULTS, ...params }
45
- return (hold ?? stageSeconds(p.attack) + stageSeconds(p.decay) + 1) + stageSeconds(p.release)
55
+ return (hold ?? gateSeconds(p)) + stageSeconds(p.release)
46
56
  }
47
57
 
48
58
  /**
@@ -2,5 +2,5 @@
2
2
  // and one ADSR for every scope, envelope and animated value in the estate, plus the reference
3
3
  // they are explained with.
4
4
  export { compileExpression, isExpression, fitRange, EXPRESSION_HELPERS } from './expression.js'
5
- export { envelopeAt, envelopeLength, createEnvelope, stageSeconds, toRange, ADSR_DEFAULTS } from './adsr.js'
5
+ export { envelopeAt, envelopeLength, gateSeconds, createEnvelope, stageSeconds, toRange, ADSR_DEFAULTS } from './adsr.js'
6
6
  export { EXPRESSION_SECTIONS, EXPRESSION_TABS, ADSR_PRESETS, ADSR_SECTIONS, ADSR_TABS } from './reference.js'
@@ -1,6 +1,6 @@
1
1
  // The signal engine's check: `node src/signal/signal.test.mjs`
2
2
  import assert from 'node:assert/strict'
3
- import { compileExpression, isExpression, fitRange, envelopeAt, createEnvelope, stageSeconds, EXPRESSION_SECTIONS } from './index.js'
3
+ import { compileExpression, isExpression, fitRange, envelopeAt, envelopeLength, gateSeconds, createEnvelope, stageSeconds, EXPRESSION_SECTIONS } from './index.js'
4
4
 
5
5
  // helpers span [min, max] — monitor's semantics; min 0 is mirror's / the editor's
6
6
  const w = compileExpression('wave(t)')
@@ -34,6 +34,12 @@ const a = stageSeconds(50)
34
34
  assert.equal(Math.round(envelopeAt(a, p)), 100) // top of attack
35
35
  assert.equal(Math.round(envelopeAt(a * 2 + 0.5, p)), 40) // holding sustain
36
36
  assert.equal(envelopeAt(100, p), 0) // long after release
37
+ // hold — seconds at sustain; 1 by default, so the old gate (a + d + 1) is unchanged
38
+ assert.equal(gateSeconds(p), a * 2 + 1)
39
+ const held = { ...p, hold: 3 }
40
+ assert.equal(Math.round(envelopeAt(a * 2 + 2.5, held)), 40) // still holding at 2.5 s in
41
+ assert.ok(envelopeAt(a * 2 + 3 + a / 2, held) < 40) // released after 3 s
42
+ assert.equal(envelopeLength(held), a * 2 + 3 + a)
37
43
  const env = createEnvelope()
38
44
  let lvl = 0
39
45
  for (let i = 0; i < 600; i++) lvl = env.step(1 / 60, { gate: true }, p)
@@ -1,59 +1,138 @@
1
1
  import { useMemo, useRef, useState } from 'react'
2
- import { Button, Input, SegmentedToggle } from '@kolkrabbi/kol-component'
2
+ import { Button, Divider, Input, SegmentedToggle, Slider } from '@kolkrabbi/kol-component'
3
3
  import Knob from './Knob.jsx'
4
4
  import Toggle from '../switches/Toggle.jsx'
5
5
  import SignalScope from '../indicators/SignalScope.jsx'
6
6
  import SignalReference from '../panel/SignalReference.jsx'
7
7
  import { compileExpression, fitRange } from '../signal/expression.js'
8
- import { envelopeAt, envelopeLength, stageSeconds, ADSR_DEFAULTS } from '../signal/adsr.js'
8
+ import { envelopeAt, envelopeLength, gateSeconds, stageSeconds, ADSR_DEFAULTS } from '../signal/adsr.js'
9
9
  import { EXPRESSION_SECTIONS, EXPRESSION_TABS, ADSR_SECTIONS, ADSR_TABS, ADSR_PRESETS } from '../signal/reference.js'
10
10
 
11
- /**
12
- * EnvelopeGenerator — a value over time, typed as an equation or shaped as an ADSR envelope
13
- * (signal engine, 2026-09-27). One tool for what kol-mirror's /expressions page, kol-monitor's
14
- * Scope+ and Env modules and the design editor's Oscilloscope each built alone: the scope in the
15
- * middle, the window controls under it, the reference beside it — mirror's layout.
16
- *
17
- * Uncontrolled: `default*` seeds it, `onChange` reports every change as
18
- * `{ mode, expr, adsr, min, max, sec, ofs }` — hand it to a store, a module, a layer.
19
- *
20
- * @param {'equation'|'adsr'} defaultMode
21
- * @param {string} defaultExpr
22
- * @param {object} defaultAdsr knob values 0…100 (`ADSR_DEFAULTS`)
23
- * @param {Array} saved · savedAdsr the host's own rows for each mode's Saved tab
24
- * @param {Function} onSave `({ mode, code }) => void` — shows a Save button; `code` is the
25
- * expression, or the envelope as `a10 d30 s70 r50` (pick loads it back)
26
- * @param {boolean} reference show the reference panel (default true)
27
- *
28
- * In ADSR mode the envelope's three corners drag on the scope — attack peak (A), decay → sustain
29
- * (D and S), release end (R) — and the knobs follow; the window holds still while you drag.
30
- */
31
11
  const MODES = [{ value: 'equation', label: 'Equation' }, { value: 'adsr', label: 'ADSR' }]
12
+ const VIEW = { min: 0, max: 100, sec: 5, ofs: 0, zoomX: 1, zoomY: 1, panY: 0 }
32
13
  const num = (s, fallback) => { const n = parseFloat(s); return Number.isFinite(n) ? n : fallback }
14
+ const r2 = (v) => Math.round(v * 100) / 100
33
15
 
34
16
  /* an envelope as text and back — what the Saved rows carry */
35
- export const adsrCode = (p) => `a${Math.round(p.attack)} d${Math.round(p.decay)} s${Math.round(p.sustain)} r${Math.round(p.release)}`
17
+ export const adsrCode = (p) =>
18
+ `a${Math.round(p.attack)} d${Math.round(p.decay)} s${Math.round(p.sustain)} r${Math.round(p.release)} h${r2(p.hold ?? ADSR_DEFAULTS.hold)}`
36
19
  const parseAdsr = (code) => {
37
- const m = /^a(\d+) d(\d+) s(\d+) r(\d+)$/.exec(String(code))
38
- return m ? { attack: +m[1], decay: +m[2], sustain: +m[3], release: +m[4] } : null
20
+ const m = /^a(\d+) d(\d+) s(\d+) r(\d+)(?: h([\d.]+))?$/.exec(String(code))
21
+ return m ? { attack: +m[1], decay: +m[2], sustain: +m[3], release: +m[4], hold: m[5] ? +m[5] : ADSR_DEFAULTS.hold } : null
39
22
  }
40
23
  /* seconds → the knob value that gives them (inverse of stageSeconds) */
41
24
  const knobFor = (seconds) => Math.max(0, Math.min(100, ((seconds - 0.005) / 1.995) * 100))
25
+ /* the ADSR window: one pass, and room past it to drag R into */
26
+ const adsrWindow = (adsr) => envelopeLength(adsr) * 1.15
27
+
28
+ /**
29
+ * useEnvelopeGenerator(options) — the generator's state, for a host that arranges the parts.
30
+ * Takes the component's own options (`default*`, `saved`, `savedAdsr`, `onSave`, `onChange`).
31
+ */
32
+ export function useEnvelopeGenerator({
33
+ defaultMode = 'equation', defaultExpr = 'wave(t)', defaultAdsr = ADSR_DEFAULTS,
34
+ saved, savedAdsr, onSave, onChange,
35
+ } = {}) {
36
+ const [mode, setModeState] = useState(defaultMode)
37
+ const [expr, setExprState] = useState(defaultExpr)
38
+ const [adsr, setAdsrState] = useState({ ...ADSR_DEFAULTS, ...defaultAdsr })
39
+ const [view, setViewState] = useState(VIEW)
40
+ const [cycle, setCycle] = useState(true)
41
+ const [playing, setPlaying] = useState(true)
42
+ const [bpm, setBpm] = useState(60)
43
+ const [trigger, setTrigger] = useState(0)
44
+ const [heldSec, setHeldSec] = useState(null) // the ADSR window, frozen while a handle drags
45
+
46
+ const compiled = useMemo(() => compileExpression(expr), [expr])
47
+ const emit = (patch) => onChange?.({ mode, expr, adsr, min: view.min, max: view.max, sec: view.sec, ofs: view.ofs, ...patch })
48
+ const setMode = (m) => { setModeState(m); emit({ mode: m }) }
49
+ const setExpr = (e) => { setExprState(e); emit({ expr: e }) }
50
+ const setAdsr = (p) => { const next = { ...adsr, ...p }; setAdsrState(next); emit({ adsr: next }) }
51
+ const setView = (p) => { const next = { ...view, ...p }; setViewState(next); emit(next) }
52
+
53
+ const fit = () => { const r = fitRange(expr, { sec: view.sec, ofs: view.ofs }); if (r) setView({ ...r, zoomY: 1, panY: 0 }) }
54
+ const reset = () => setView(VIEW)
55
+ /* a load replaces the expression and fits the scope to it — mirror's */
56
+ const pick = (code) => {
57
+ if (mode === 'equation') {
58
+ setExpr(code)
59
+ const r = fitRange(code, { sec: view.sec, ofs: view.ofs })
60
+ if (r) setView({ ...r, zoomY: 1, panY: 0 })
61
+ return
62
+ }
63
+ const params = ADSR_PRESETS.find((p) => p.id === code)?.params ?? parseAdsr(code)
64
+ if (params) setAdsr(params)
65
+ }
66
+ const append = mode === 'equation' ? (code) => setExpr(`${expr}+${code}`) : undefined
67
+ const save = onSave ? () => onSave({ mode, code: mode === 'equation' ? expr : adsrCode(adsr) }) : undefined
68
+
69
+ return {
70
+ mode, setMode, expr, setExpr, compiled, adsr, setAdsr, view, setView, fit, reset,
71
+ cycle, setCycle, playing, setPlaying, bpm, setBpm, trigger, fire: () => setTrigger((n) => n + 1),
72
+ heldSec, setHeldSec, save,
73
+ /* spread into <SignalReference> — the current mode's data, whichever shape it takes. Key the
74
+ * element on `mode` so its open tab resets when the data changes under it. */
75
+ reference: {
76
+ sections: mode === 'equation' ? EXPRESSION_SECTIONS : ADSR_SECTIONS,
77
+ tabs: mode === 'equation' ? EXPRESSION_TABS : ADSR_TABS,
78
+ saved: mode === 'equation' ? saved : savedAdsr,
79
+ onPick: pick,
80
+ onAppend: append,
81
+ },
82
+ }
83
+ }
84
+
85
+ /** The Equation | ADSR toggle, for a host that puts it in its own masthead. */
86
+ export function EnvelopeModeToggle({ generator, size = 'sm', className = '' }) {
87
+ return <SegmentedToggle value={generator.mode} onChange={generator.setMode} options={MODES} size={size} ariaLabel="Mode" className={className} />
88
+ }
89
+
90
+ /* mirror's Eyebrow — the box's name above it. `bare` when the child draws its own box. */
91
+ function Box({ title, className = '', bodyClass = '', bare = false, children }) {
92
+ return (
93
+ <div className={`flex min-h-0 flex-col ${className}`}>
94
+ <div className="kol-helper-12 flex h-6 shrink-0 items-start uppercase text-fg-48">{title}</div>
95
+ {bare
96
+ ? <div className="min-h-0 flex-1">{children}</div>
97
+ : (
98
+ <div className={`kol-helper-12 flex min-h-0 flex-1 flex-col overflow-hidden rounded-[4px] border border-oq-08 bg-surface-secondary p-4 ${bodyClass}`}>
99
+ {children}
100
+ </div>
101
+ )}
102
+ </div>
103
+ )
104
+ }
105
+
106
+ function Field({ label, value, onCommit, chars = 4 }) {
107
+ return (
108
+ <label className="flex items-center gap-2">
109
+ <span className="uppercase text-fg-48">{label}</span>
110
+ <Input size="sm" chars={chars} value={String(value)} onCommit={onCommit} />
111
+ </label>
112
+ )
113
+ }
114
+
115
+ function ZoomRow({ label, value, onChange }) {
116
+ return (
117
+ <Slider label={label} min={0.1} max={10} step={0.1} value={value} onChange={onChange} defaultValue={1} displayWidth={4} className="h-6" />
118
+ )
119
+ }
42
120
 
43
- const PAD = 12 // SignalScope's inner padding — the handles sit on its grid
44
- const HEIGHT = 320
121
+ /* the handles sit on SignalScope's grid: 12px inner padding, the level across the rest */
122
+ const PAD = 12
123
+ const top = (level) => `calc(${PAD}px + (100% - ${PAD * 2}px) * ${1 - level / 100})`
45
124
 
46
125
  function EnvelopeHandles({ adsr, sec, onDragStart, onDrag, onDragEnd }) {
47
126
  const ref = useRef(null)
48
127
  const a = stageSeconds(adsr.attack)
49
128
  const d = stageSeconds(adsr.decay)
50
- const gate = a + d + 1
129
+ const gate = gateSeconds(adsr)
51
130
  const r = stageSeconds(adsr.release)
52
- const y = (level) => PAD + (HEIGHT - PAD * 2) * (1 - level / 100)
53
131
  const pts = [
54
- { id: 'a', x: a / sec, y: y(100), label: 'A' },
55
- { id: 'ds', x: (a + d) / sec, y: y(adsr.sustain), label: 'D · S' },
56
- { id: 'r', x: (gate + r) / sec, y: y(0), label: 'R' },
132
+ { id: 'a', t: a, level: 100, label: 'A' },
133
+ { id: 'd', t: a + d, level: adsr.sustain, label: 'D' },
134
+ { id: 's', t: gate, level: adsr.sustain, label: 'S' },
135
+ { id: 'r', t: gate + r, level: 0, label: 'R' },
57
136
  ]
58
137
  const drag = (id) => (e) => {
59
138
  e.preventDefault()
@@ -62,9 +141,10 @@ function EnvelopeHandles({ adsr, sec, onDragStart, onDrag, onDragEnd }) {
62
141
  const move = (ev) => {
63
142
  const box = ref.current.getBoundingClientRect()
64
143
  const t = Math.max(0, ((ev.clientX - box.left) / box.width) * sec)
65
- const level = Math.max(0, Math.min(100, (1 - (ev.clientY - box.top - PAD) / (HEIGHT - PAD * 2)) * 100))
144
+ const level = Math.max(0, Math.min(100, (1 - (ev.clientY - box.top - PAD) / (box.height - PAD * 2)) * 100))
66
145
  if (id === 'a') onDrag({ attack: knobFor(t) })
67
- else if (id === 'ds') onDrag({ decay: knobFor(t - a), sustain: level })
146
+ else if (id === 'd') onDrag({ decay: knobFor(t - a) })
147
+ else if (id === 's') onDrag({ hold: r2(Math.max(0, t - a - d)), sustain: level })
68
148
  else onDrag({ release: knobFor(t - gate) })
69
149
  }
70
150
  const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); onDragEnd() }
@@ -72,123 +152,173 @@ function EnvelopeHandles({ adsr, sec, onDragStart, onDrag, onDragEnd }) {
72
152
  window.addEventListener('pointerup', up)
73
153
  }
74
154
  return (
75
- <div ref={ref} className="pointer-events-none absolute inset-x-0 top-0" style={{ height: HEIGHT }}>
155
+ <div ref={ref} className="pointer-events-none absolute inset-0">
76
156
  {pts.map((pt) => (
77
157
  <button
78
158
  key={pt.id}
79
159
  type="button"
80
160
  aria-label={`Drag ${pt.label}`}
81
161
  onPointerDown={drag(pt.id)}
82
- className="pointer-events-auto absolute flex h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none items-center justify-center rounded-full"
83
- style={{ left: `${Math.min(1, pt.x) * 100}%`, top: pt.y }}
162
+ className="pointer-events-auto absolute flex h-6 w-6 -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none items-center justify-center rounded-full"
163
+ style={{ left: `${Math.min(1, pt.t / sec) * 100}%`, top: top(pt.level) }}
84
164
  >
85
165
  <span className="block h-2.5 w-2.5 rounded-full border border-oq-64 bg-surface-primary" />
166
+ <span className="kol-helper-10 absolute -top-3 text-fg-48">{pt.label}</span>
86
167
  </button>
87
168
  ))}
88
169
  </div>
89
170
  )
90
171
  }
91
172
 
92
- function Field({ label, value, onCommit }) {
93
- return (
94
- <label className="flex items-center gap-2">
95
- <span className="kol-helper-10 uppercase text-fg-48">{label}</span>
96
- <Input size="xs" chars={4} value={String(value)} onCommit={onCommit} />
97
- </label>
98
- )
99
- }
100
-
101
- export default function EnvelopeGenerator({
102
- defaultMode = 'equation', defaultExpr = 'wave(t)', defaultAdsr = ADSR_DEFAULTS,
103
- saved, savedAdsr, onSave, reference = true, onChange, className = '',
104
- }) {
105
- const [mode, setMode] = useState(defaultMode)
106
- const [expr, setExpr] = useState(defaultExpr)
107
- const [adsr, setAdsr] = useState({ ...ADSR_DEFAULTS, ...defaultAdsr })
108
- const [cycle, setCycle] = useState(true)
109
- const [view, setView] = useState({ min: 0, max: 100, sec: 5, ofs: 0 })
110
- const [heldSec, setHeldSec] = useState(null) // the ADSR window, frozen while a handle drags
173
+ /**
174
+ * EnvelopeGenerator — a value over time, typed as an equation or shaped as an ADSR envelope
175
+ * (signal engine, 2026-09-27). One tool for what kol-mirror's /expressions page, kol-monitor's
176
+ * Scope+ and Env modules and the design editor's Oscilloscope each built alone.
177
+ *
178
+ * MIRROR'S PAGE, CLASS FOR CLASS (app frame and curves, 2026-09-27): it fills its parent — the
179
+ * OSCILLOSCOPE box takes every pixel the REFERENCE panel (320px) leaves, the scope takes every
180
+ * pixel its controls leave. Each box is an eyebrow over `bg-surface-secondary border-oq-08
181
+ * rounded-4`; the scope box is `kol-tone-sunken`, so its fields sit in mirror's dark wells. Collapse narrows the scope to 320px and the reference spreads into the rest as the
182
+ * desk (every section at once) — mirror's Collapse.
183
+ *
184
+ * THE PARTS ARE THE HOST'S TO ARRANGE. `useEnvelopeGenerator()` holds the state; pass it as
185
+ * `generator` and the host can put the mode toggle (`EnvelopeModeToggle`) and the reference
186
+ * (`generator.reference`, spread into `SignalReference`) wherever its page wants them — a tool
187
+ * frame's masthead, say. Without `generator` the component keeps its own and draws the toggle
188
+ * itself.
189
+ *
190
+ * TIME is the scope's clock: `bpm` sets its speed (60 BPM = one unit a second, so `t` reads as
191
+ * seconds at the default and as beats at any other). Play / pause stops it where it is.
192
+ *
193
+ * ADSR: four handles on the scope — A (attack time), D (decay time), S (at the end of the hold:
194
+ * sideways = how long the gate holds, up/down = the sustain level), R (release time). The scope
195
+ * draws one pass and never wraps; the window holds still while a handle drags and refits on
196
+ * release. Cycle on loops the playhead; off, it runs once and Trigger fires it again.
197
+ *
198
+ * `onChange` reports every change as `{ mode, expr, adsr, min, max, sec, ofs }`.
199
+ *
200
+ * @param {object} generator state from `useEnvelopeGenerator()` (else it keeps its own)
201
+ * @param {'panel'|'none'} reference the reference panel beside the scope (default panel; `false` = none)
202
+ *
203
+ * It FILLS its parent's height — give it one (a tool frame's body, or a fixed-height box).
204
+ * @param {'equation'|'adsr'} defaultMode
205
+ * @param {string} defaultExpr
206
+ * @param {object} defaultAdsr knob values 0…100, `hold` in seconds (`ADSR_DEFAULTS`)
207
+ * @param {Array} saved · savedAdsr the host's own rows for each mode's Saved tab
208
+ * @param {Function} onSave `({ mode, code }) => void` — shows a Save button; `code` is the
209
+ * expression, or the envelope as `a10 d30 s70 r50 h1`
210
+ */
211
+ export default function EnvelopeGenerator({ generator, reference = 'panel', className = '', ...options }) {
212
+ const own = useEnvelopeGenerator(options)
213
+ const g = generator ?? own
214
+ const [collapsed, setCollapsed] = useState(false)
215
+ const { mode, adsr, view } = g
111
216
 
112
- const compiled = useMemo(() => compileExpression(expr), [expr])
113
- const adsrSec = envelopeLength(adsr)
114
217
  const sample = mode === 'equation'
115
- ? (t) => compiled.fn(t, Math.round(t * 60), 0, 100)
116
- : (t) => envelopeAt(t, adsr, { cycle })
117
- const sec = mode === 'equation' ? view.sec : (heldSec ?? adsrSec)
118
- const ofs = mode === 'equation' ? view.ofs : 0
119
-
120
- const emit = (patch) => onChange?.({ mode, expr, adsr, ...view, ...patch })
121
- const change = {
122
- mode: (m) => { setMode(m); emit({ mode: m }) },
123
- expr: (e) => { setExpr(e); emit({ expr: e }) },
124
- adsr: (p) => { const next = { ...adsr, ...p }; setAdsr(next); emit({ adsr: next }) },
125
- view: (p) => { const next = { ...view, ...p }; setView(next); emit(next) },
126
- }
127
- const fit = () => { const r = fitRange(expr, { sec: view.sec, ofs: view.ofs }); if (r) change.view(r) }
128
- const reset = () => change.view({ min: 0, max: 100, sec: 5, ofs: 0 })
129
- const pick = (code) => {
130
- if (mode === 'equation') { change.expr(code); return }
131
- const params = ADSR_PRESETS.find((p) => p.id === code)?.params ?? parseAdsr(code)
132
- if (params) change.adsr(params)
133
- }
218
+ ? (t) => g.compiled.fn(t, Math.round(t * 60), 0, 100)
219
+ : (t) => envelopeAt(t, adsr)
220
+ const sec = mode === 'equation' ? view.sec : (g.heldSec ?? adsrWindow(adsr))
221
+ const passSec = mode === 'equation' ? sec : envelopeLength(adsr)
222
+ const panel = reference === true || reference === 'panel'
223
+
224
+ const transport = (
225
+ <div className="flex h-6 shrink-0 items-center gap-3">
226
+ <Button variant="ghost" size="sm" iconOnly={g.playing ? 'pause' : 'play'} aria-label={g.playing ? 'Pause' : 'Play'} onClick={() => g.setPlaying(!g.playing)} />
227
+ <Field label="BPM" value={g.bpm} chars={3} onCommit={(v) => g.setBpm(Math.max(1, Math.min(999, num(v, g.bpm))))} />
228
+ {mode === 'adsr' && (
229
+ <>
230
+ <Toggle value={g.cycle} onChange={g.setCycle} label="cycle" />
231
+ <Button variant="ghost" size="sm" onClick={g.fire}>Trigger</Button>
232
+ </>
233
+ )}
234
+ <span className="ml-auto text-fg-48">{mode === 'adsr' ? `${passSec.toFixed(2)} s` : ''}</span>
235
+ </div>
236
+ )
134
237
 
135
238
  return (
136
- <div className={`flex flex-col gap-4 ${className}`}>
137
- <SegmentedToggle value={mode} onChange={change.mode} options={MODES} size="sm" />
138
- <div className={`grid gap-6 ${reference ? 'lg:grid-cols-[minmax(0,1fr)_320px]' : ''}`}>
139
- <div className="flex min-w-0 flex-col gap-3">
140
- {mode === 'equation' && (
141
- <Input
142
- value={expr}
143
- onCommit={(v) => change.expr(v || 'wave(t)')}
144
- aria-label="Expression"
145
- className={compiled.ok ? '' : 'text-[var(--kol-ctl-led-red)]'}
146
- />
147
- )}
148
- <div className="relative">
149
- <SignalScope sample={sample} min={mode === 'equation' ? view.min : 0} max={mode === 'equation' ? view.max : 100} sec={sec} ofs={ofs} height={HEIGHT} />
150
- {mode === 'adsr' && (
151
- <EnvelopeHandles adsr={adsr} sec={sec}
152
- onDragStart={() => setHeldSec(adsrSec)} onDrag={change.adsr} onDragEnd={() => setHeldSec(null)} />
153
- )}
154
- </div>
155
- {mode === 'equation' ? (
156
- <>
157
- <div className="flex items-center justify-between">
158
- <Button variant="ghost" size="sm" iconLeft="maximize" onClick={fit}>Fit</Button>
159
- {onSave && <Button variant="ghost" size="sm" iconLeft="save" onClick={() => onSave({ mode, code: expr })}>Save</Button>}
160
- <Button variant="ghost" size="sm" iconLeft="refresh" onClick={reset}>Reset</Button>
161
- </div>
162
- <div className="flex flex-wrap items-center justify-between gap-3">
163
- <Field label="Min" value={view.min} onCommit={(v) => change.view({ min: num(v, view.min) })} />
164
- <Field label="Max" value={view.max} onCommit={(v) => change.view({ max: num(v, view.max) })} />
165
- <Field label="Sec" value={view.sec} onCommit={(v) => change.view({ sec: Math.max(0.1, num(v, view.sec)) })} />
166
- <Field label="Ofs" value={view.ofs} onCommit={(v) => change.view({ ofs: num(v, view.ofs) })} />
167
- </div>
168
- </>
169
- ) : (
170
- <div className="flex flex-wrap items-end justify-center gap-6 py-2">
171
- {[['attack', 'A'], ['decay', 'D'], ['sustain', 'S'], ['release', 'R']].map(([key, label]) => (
172
- <Knob key={key} value={adsr[key]} onChange={(v) => change.adsr({ [key]: v })} label={label} size="lg" defaultValue={ADSR_DEFAULTS[key]} />
173
- ))}
174
- <Toggle value={cycle} onChange={setCycle} label="cycle" />
175
- <span className="kol-helper-10 text-fg-48">{adsrSec.toFixed(2)} s</span>
176
- {onSave && <Button variant="ghost" size="sm" iconLeft="save" onClick={() => onSave({ mode, code: adsrCode(adsr) })}>Save</Button>}
177
- </div>
239
+ <div className={`flex h-full min-h-0 flex-col gap-4 lg:flex-row ${className}`}>
240
+ <Box
241
+ title="Oscilloscope"
242
+ className={collapsed && panel ? 'shrink-0 lg:w-[320px]' : 'min-w-0 flex-1'}
243
+ bodyClass="kol-tone-sunken gap-2"
244
+ >
245
+ {!generator && <EnvelopeModeToggle generator={g} className="self-start" />}
246
+ {mode === 'equation' && (
247
+ <Input
248
+ size="sm"
249
+ value={g.expr}
250
+ onCommit={(v) => g.setExpr(v || 'wave(t)')}
251
+ aria-label="Expression"
252
+ className={`shrink-0 ${g.compiled.ok ? '' : 'text-[var(--kol-ctl-led-red)]'}`}
253
+ />
254
+ )}
255
+ <div className="relative min-h-[160px] flex-1">
256
+ <SignalScope
257
+ sample={sample}
258
+ height="fill"
259
+ min={mode === 'equation' ? view.min : 0}
260
+ max={mode === 'equation' ? view.max : 100}
261
+ sec={mode === 'equation' ? view.sec : sec}
262
+ ofs={mode === 'equation' ? view.ofs : 0}
263
+ zoomX={mode === 'equation' ? view.zoomX : 1}
264
+ zoomY={mode === 'equation' ? view.zoomY : 1}
265
+ panY={mode === 'equation' ? view.panY : 0}
266
+ onPan={mode === 'equation' ? g.setView : undefined}
267
+ playing={g.playing}
268
+ rate={g.bpm / 60}
269
+ loop={mode === 'equation' || g.cycle}
270
+ trigger={g.trigger}
271
+ className="absolute inset-0"
272
+ />
273
+ {mode === 'adsr' && (
274
+ <EnvelopeHandles adsr={adsr} sec={sec}
275
+ onDragStart={() => g.setHeldSec(adsrWindow(adsr))} onDrag={g.setAdsr} onDragEnd={() => g.setHeldSec(null)} />
178
276
  )}
179
277
  </div>
180
- {reference && (
181
- <SignalReference
182
- className="rounded-[4px] border border-oq-08 p-4"
183
- sections={mode === 'equation' ? EXPRESSION_SECTIONS : ADSR_SECTIONS}
184
- tabs={mode === 'equation' ? EXPRESSION_TABS : ADSR_TABS}
185
- saved={mode === 'equation' ? saved : savedAdsr}
186
- onPick={pick}
187
- onAppend={mode === 'equation' ? (code) => change.expr(`${expr}+${code}`) : undefined}
188
- key={mode}
189
- />
278
+ <div className="flex min-h-6 shrink-0 flex-wrap items-center justify-between gap-x-2">
279
+ {mode === 'equation' && <Button variant="ghost" size="sm" iconLeft="maximize" onClick={g.fit}>Fit</Button>}
280
+ {panel && <Button variant="ghost" size="sm" iconLeft="columns" onClick={() => setCollapsed(!collapsed)} className="hidden lg:inline-flex">{collapsed ? 'Expand' : 'Collapse'}</Button>}
281
+ {g.save && <Button variant="ghost" size="sm" iconLeft="save" onClick={g.save}>Save</Button>}
282
+ <Button variant="ghost" size="sm" iconLeft="refresh" onClick={mode === 'equation' ? g.reset : () => g.setAdsr(ADSR_DEFAULTS)}>Reset</Button>
283
+ </div>
284
+ <Divider className="shrink-0 py-1" />
285
+ {mode === 'equation' ? (
286
+ <>
287
+ <div className="flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
288
+ <Field label="Min" value={view.min} onCommit={(v) => g.setView({ min: num(v, view.min) })} />
289
+ <Field label="Max" value={view.max} onCommit={(v) => g.setView({ max: num(v, view.max) })} />
290
+ <Field label="Sec" value={view.sec} onCommit={(v) => g.setView({ sec: Math.max(0.1, num(v, view.sec)) })} />
291
+ <Field label="Ofs" value={r2(view.ofs)} onCommit={(v) => g.setView({ ofs: num(v, view.ofs) })} />
292
+ </div>
293
+ <div className="flex shrink-0 flex-col">
294
+ <ZoomRow label="X" value={view.zoomX} onChange={(v) => g.setView({ zoomX: v })} />
295
+ <ZoomRow label="Y" value={view.zoomY} onChange={(v) => g.setView({ zoomY: v })} />
296
+ <ZoomRow label="Scale" value={r2((view.zoomX + view.zoomY) / 2)} onChange={(v) => g.setView({ zoomX: v, zoomY: v })} />
297
+ </div>
298
+ </>
299
+ ) : (
300
+ <div className="flex shrink-0 flex-wrap items-end justify-between gap-4">
301
+ {[['attack', 'A'], ['decay', 'D'], ['sustain', 'S'], ['release', 'R']].map(([key, label]) => (
302
+ <Knob key={key} value={adsr[key]} onChange={(v) => g.setAdsr({ [key]: v })} label={label} size="md" defaultValue={ADSR_DEFAULTS[key]} />
303
+ ))}
304
+ <Field label="Hold" value={r2(adsr.hold)} onCommit={(v) => g.setAdsr({ hold: Math.max(0, num(v, adsr.hold)) })} />
305
+ </div>
190
306
  )}
191
- </div>
307
+ <Divider className="shrink-0 py-1" />
308
+ {transport}
309
+ </Box>
310
+ {panel && (
311
+ <Box
312
+ title="Reference"
313
+ className={`hidden lg:flex ${collapsed ? 'min-w-0 flex-1' : 'w-[320px] shrink-0'}`}
314
+ bodyClass="overflow-y-auto"
315
+ bare={!collapsed}
316
+ >
317
+ {collapsed
318
+ ? <SignalReference key={mode} {...g.reference} variant="sheet" />
319
+ : <SignalReference key={mode} {...g.reference} variant="panel" />}
320
+ </Box>
321
+ )}
192
322
  </div>
193
323
  )
194
324
  }