@kolkrabbi/kol-hardware 0.1.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 +3 -3
- package/package.json +4 -4
- package/src/index.js +1 -1
- package/src/indicators/JackSocket.jsx +4 -3
- package/src/indicators/SignalScope.jsx +86 -24
- package/src/panel/SignalReference.jsx +49 -21
- package/src/signal/adsr.js +16 -6
- package/src/signal/index.js +1 -1
- package/src/signal/signal.test.mjs +7 -1
- package/src/switches/RockerSwitch.jsx +2 -2
- package/src/value/EnvelopeGenerator.jsx +298 -96
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 —
|
|
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.
|
|
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-component": "^0.
|
|
24
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
25
|
-
"@kolkrabbi/kol-theme": "^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'
|
|
@@ -39,7 +39,8 @@ function stopJackLoop() {
|
|
|
39
39
|
*
|
|
40
40
|
* @param {'in'|'out'} type
|
|
41
41
|
* @param {'sm'|'md'} size 12 · 16 (default md)
|
|
42
|
-
* @param {string} label · {string} labelSize (`kol-helper-${labelSize}
|
|
42
|
+
* @param {string} label · {string|number} labelSize (`kol-helper-${labelSize}`: 8 · 10 · 12 …, default 8;
|
|
43
|
+
* a retired t-shirt name — `xxs` — still resolves, to 8)
|
|
43
44
|
* @param {boolean} active a cable is connected here
|
|
44
45
|
* @param {boolean} pending this output is the pending cable's source
|
|
45
46
|
* @param {boolean} dimPending a cable is pending elsewhere (an input shows a dim ring)
|
|
@@ -57,7 +58,7 @@ export default function JackSocket({
|
|
|
57
58
|
type = 'out',
|
|
58
59
|
size = 'md',
|
|
59
60
|
label,
|
|
60
|
-
labelSize =
|
|
61
|
+
labelSize = 8,
|
|
61
62
|
active = false,
|
|
62
63
|
pending = false,
|
|
63
64
|
dimPending = false,
|
|
@@ -152,7 +153,7 @@ export default function JackSocket({
|
|
|
152
153
|
</div>
|
|
153
154
|
</div>
|
|
154
155
|
{label && (
|
|
155
|
-
<span className={`kol-helper-${labelSize} text-fg-32`} style={{ textTransform: 'uppercase', lineHeight: 1 }}>
|
|
156
|
+
<span className={`kol-helper-${/^\d+$/.test(String(labelSize)) ? labelSize : 8} text-fg-32`} style={{ textTransform: 'uppercase', lineHeight: 1 }}>
|
|
156
157
|
{label}
|
|
157
158
|
</span>
|
|
158
159
|
)}
|
|
@@ -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`
|
|
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
|
|
16
|
-
* @param {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
clock.current.last = performance.now()
|
|
33
56
|
let raf = 0
|
|
34
57
|
const draw = () => {
|
|
35
|
-
const
|
|
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 =
|
|
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
|
|
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(
|
|
70
|
-
ctx.fillText(
|
|
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(
|
|
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 (
|
|
83
|
-
const
|
|
84
|
-
const px =
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 (
|
|
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
|
-
|
|
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"
|
|
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,
|
|
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
|
|
35
|
-
{showTitle && <div className="kol-helper-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
)
|
package/src/signal/adsr.js
CHANGED
|
@@ -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
|
|
21
|
-
* open for `hold`
|
|
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 ??
|
|
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 ??
|
|
55
|
+
return (hold ?? gateSeconds(p)) + stageSeconds(p.release)
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
/**
|
package/src/signal/index.js
CHANGED
|
@@ -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)
|
|
@@ -46,8 +46,8 @@ export default function RockerSwitch({ on, onToggle }) {
|
|
|
46
46
|
</div>
|
|
47
47
|
|
|
48
48
|
{/* Static labels on housing */}
|
|
49
|
-
<span className="kol-helper-
|
|
50
|
-
<span className="kol-helper-
|
|
49
|
+
<span className="kol-helper-8" style={{ position: 'absolute', top: 3, left: 0, right: 0, textAlign: 'center', color: on ? 'transparent' : 'var(--kol-ctl-hw-cap-edge)', pointerEvents: 'none' }}>I</span>
|
|
50
|
+
<span className="kol-helper-8" style={{ position: 'absolute', bottom: 3, left: 0, right: 0, textAlign: 'center', color: on ? 'var(--kol-ctl-hw-cap-edge)' : 'transparent', pointerEvents: 'none' }}>O</span>
|
|
51
51
|
</div>
|
|
52
52
|
)
|
|
53
53
|
}
|
|
@@ -1,122 +1,324 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react'
|
|
2
|
-
import { Button, Input, SegmentedToggle } from '@kolkrabbi/kol-component'
|
|
1
|
+
import { useMemo, useRef, useState } from 'react'
|
|
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, 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 {boolean} reference show the reference panel (default true)
|
|
25
|
-
*/
|
|
26
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 }
|
|
27
13
|
const num = (s, fallback) => { const n = parseFloat(s); return Number.isFinite(n) ? n : fallback }
|
|
14
|
+
const r2 = (v) => Math.round(v * 100) / 100
|
|
28
15
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
16
|
+
/* an envelope as text and back — what the Saved rows carry */
|
|
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)}`
|
|
19
|
+
const parseAdsr = (code) => {
|
|
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
|
|
36
22
|
}
|
|
23
|
+
/* seconds → the knob value that gives them (inverse of stageSeconds) */
|
|
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
|
|
37
27
|
|
|
38
|
-
|
|
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({
|
|
39
33
|
defaultMode = 'equation', defaultExpr = 'wave(t)', defaultAdsr = ADSR_DEFAULTS,
|
|
40
|
-
saved, savedAdsr,
|
|
41
|
-
}) {
|
|
42
|
-
const [mode,
|
|
43
|
-
const [expr,
|
|
44
|
-
const [adsr,
|
|
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)
|
|
45
40
|
const [cycle, setCycle] = useState(true)
|
|
46
|
-
const [
|
|
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
|
|
47
45
|
|
|
48
46
|
const compiled = useMemo(() => compileExpression(expr), [expr])
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
mode: (m) => { setMode(m); emit({ mode: m }) },
|
|
59
|
-
expr: (e) => { setExpr(e); emit({ expr: e }) },
|
|
60
|
-
adsr: (p) => { const next = { ...adsr, ...p }; setAdsr(next); emit({ adsr: next }) },
|
|
61
|
-
view: (p) => { const next = { ...view, ...p }; setView(next); emit(next) },
|
|
62
|
-
}
|
|
63
|
-
const fit = () => { const r = fitRange(expr, { sec: view.sec, ofs: view.ofs }); if (r) change.view(r) }
|
|
64
|
-
const reset = () => change.view({ min: 0, max: 100, sec: 5, ofs: 0 })
|
|
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 */
|
|
65
56
|
const pick = (code) => {
|
|
66
|
-
if (mode === 'equation') {
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
},
|
|
69
82
|
}
|
|
83
|
+
}
|
|
70
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 }) {
|
|
71
92
|
return (
|
|
72
|
-
<div className={`flex flex-col
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
<div className="
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|
|
120
|
+
|
|
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})`
|
|
124
|
+
|
|
125
|
+
function EnvelopeHandles({ adsr, sec, onDragStart, onDrag, onDragEnd }) {
|
|
126
|
+
const ref = useRef(null)
|
|
127
|
+
const a = stageSeconds(adsr.attack)
|
|
128
|
+
const d = stageSeconds(adsr.decay)
|
|
129
|
+
const gate = gateSeconds(adsr)
|
|
130
|
+
const r = stageSeconds(adsr.release)
|
|
131
|
+
const pts = [
|
|
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' },
|
|
136
|
+
]
|
|
137
|
+
const drag = (id) => (e) => {
|
|
138
|
+
e.preventDefault()
|
|
139
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
140
|
+
onDragStart()
|
|
141
|
+
const move = (ev) => {
|
|
142
|
+
const box = ref.current.getBoundingClientRect()
|
|
143
|
+
const t = Math.max(0, ((ev.clientX - box.left) / box.width) * sec)
|
|
144
|
+
const level = Math.max(0, Math.min(100, (1 - (ev.clientY - box.top - PAD) / (box.height - PAD * 2)) * 100))
|
|
145
|
+
if (id === 'a') onDrag({ attack: knobFor(t) })
|
|
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 })
|
|
148
|
+
else onDrag({ release: knobFor(t - gate) })
|
|
149
|
+
}
|
|
150
|
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); onDragEnd() }
|
|
151
|
+
window.addEventListener('pointermove', move)
|
|
152
|
+
window.addEventListener('pointerup', up)
|
|
153
|
+
}
|
|
154
|
+
return (
|
|
155
|
+
<div ref={ref} className="pointer-events-none absolute inset-0">
|
|
156
|
+
{pts.map((pt) => (
|
|
157
|
+
<button
|
|
158
|
+
key={pt.id}
|
|
159
|
+
type="button"
|
|
160
|
+
aria-label={`Drag ${pt.label}`}
|
|
161
|
+
onPointerDown={drag(pt.id)}
|
|
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) }}
|
|
164
|
+
>
|
|
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>
|
|
167
|
+
</button>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
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
|
|
216
|
+
|
|
217
|
+
const sample = mode === 'equation'
|
|
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
|
+
)
|
|
237
|
+
|
|
238
|
+
return (
|
|
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)} />
|
|
106
276
|
)}
|
|
107
277
|
</div>
|
|
108
|
-
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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>
|
|
118
306
|
)}
|
|
119
|
-
|
|
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
|
+
)}
|
|
120
322
|
</div>
|
|
121
323
|
)
|
|
122
324
|
}
|