@kolkrabbi/kol-hardware 0.1.0 → 0.2.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-hardware",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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-
|
|
23
|
+
"@kolkrabbi/kol-theme": "^0.152.0",
|
|
24
24
|
"@kolkrabbi/kol-icons": "^0.27.1",
|
|
25
|
-
"@kolkrabbi/kol-
|
|
25
|
+
"@kolkrabbi/kol-component": "^0.225.0"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"src",
|
|
@@ -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
|
)}
|
|
@@ -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,11 +1,11 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react'
|
|
1
|
+
import { useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { Button, Input, SegmentedToggle } 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, 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
11
|
/**
|
|
@@ -21,11 +21,74 @@ import { EXPRESSION_SECTIONS, EXPRESSION_TABS, ADSR_SECTIONS, ADSR_TABS, ADSR_PR
|
|
|
21
21
|
* @param {string} defaultExpr
|
|
22
22
|
* @param {object} defaultAdsr knob values 0…100 (`ADSR_DEFAULTS`)
|
|
23
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)
|
|
24
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.
|
|
25
30
|
*/
|
|
26
31
|
const MODES = [{ value: 'equation', label: 'Equation' }, { value: 'adsr', label: 'ADSR' }]
|
|
27
32
|
const num = (s, fallback) => { const n = parseFloat(s); return Number.isFinite(n) ? n : fallback }
|
|
28
33
|
|
|
34
|
+
/* 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)}`
|
|
36
|
+
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
|
|
39
|
+
}
|
|
40
|
+
/* seconds → the knob value that gives them (inverse of stageSeconds) */
|
|
41
|
+
const knobFor = (seconds) => Math.max(0, Math.min(100, ((seconds - 0.005) / 1.995) * 100))
|
|
42
|
+
|
|
43
|
+
const PAD = 12 // SignalScope's inner padding — the handles sit on its grid
|
|
44
|
+
const HEIGHT = 320
|
|
45
|
+
|
|
46
|
+
function EnvelopeHandles({ adsr, sec, onDragStart, onDrag, onDragEnd }) {
|
|
47
|
+
const ref = useRef(null)
|
|
48
|
+
const a = stageSeconds(adsr.attack)
|
|
49
|
+
const d = stageSeconds(adsr.decay)
|
|
50
|
+
const gate = a + d + 1
|
|
51
|
+
const r = stageSeconds(adsr.release)
|
|
52
|
+
const y = (level) => PAD + (HEIGHT - PAD * 2) * (1 - level / 100)
|
|
53
|
+
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' },
|
|
57
|
+
]
|
|
58
|
+
const drag = (id) => (e) => {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
61
|
+
onDragStart()
|
|
62
|
+
const move = (ev) => {
|
|
63
|
+
const box = ref.current.getBoundingClientRect()
|
|
64
|
+
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))
|
|
66
|
+
if (id === 'a') onDrag({ attack: knobFor(t) })
|
|
67
|
+
else if (id === 'ds') onDrag({ decay: knobFor(t - a), sustain: level })
|
|
68
|
+
else onDrag({ release: knobFor(t - gate) })
|
|
69
|
+
}
|
|
70
|
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); onDragEnd() }
|
|
71
|
+
window.addEventListener('pointermove', move)
|
|
72
|
+
window.addEventListener('pointerup', up)
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<div ref={ref} className="pointer-events-none absolute inset-x-0 top-0" style={{ height: HEIGHT }}>
|
|
76
|
+
{pts.map((pt) => (
|
|
77
|
+
<button
|
|
78
|
+
key={pt.id}
|
|
79
|
+
type="button"
|
|
80
|
+
aria-label={`Drag ${pt.label}`}
|
|
81
|
+
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 }}
|
|
84
|
+
>
|
|
85
|
+
<span className="block h-2.5 w-2.5 rounded-full border border-oq-64 bg-surface-primary" />
|
|
86
|
+
</button>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
29
92
|
function Field({ label, value, onCommit }) {
|
|
30
93
|
return (
|
|
31
94
|
<label className="flex items-center gap-2">
|
|
@@ -37,20 +100,21 @@ function Field({ label, value, onCommit }) {
|
|
|
37
100
|
|
|
38
101
|
export default function EnvelopeGenerator({
|
|
39
102
|
defaultMode = 'equation', defaultExpr = 'wave(t)', defaultAdsr = ADSR_DEFAULTS,
|
|
40
|
-
saved, savedAdsr, reference = true, onChange, className = '',
|
|
103
|
+
saved, savedAdsr, onSave, reference = true, onChange, className = '',
|
|
41
104
|
}) {
|
|
42
105
|
const [mode, setMode] = useState(defaultMode)
|
|
43
106
|
const [expr, setExpr] = useState(defaultExpr)
|
|
44
107
|
const [adsr, setAdsr] = useState({ ...ADSR_DEFAULTS, ...defaultAdsr })
|
|
45
108
|
const [cycle, setCycle] = useState(true)
|
|
46
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
|
|
47
111
|
|
|
48
112
|
const compiled = useMemo(() => compileExpression(expr), [expr])
|
|
49
113
|
const adsrSec = envelopeLength(adsr)
|
|
50
114
|
const sample = mode === 'equation'
|
|
51
115
|
? (t) => compiled.fn(t, Math.round(t * 60), 0, 100)
|
|
52
116
|
: (t) => envelopeAt(t, adsr, { cycle })
|
|
53
|
-
const sec = mode === 'equation' ? view.sec : adsrSec
|
|
117
|
+
const sec = mode === 'equation' ? view.sec : (heldSec ?? adsrSec)
|
|
54
118
|
const ofs = mode === 'equation' ? view.ofs : 0
|
|
55
119
|
|
|
56
120
|
const emit = (patch) => onChange?.({ mode, expr, adsr, ...view, ...patch })
|
|
@@ -64,8 +128,8 @@ export default function EnvelopeGenerator({
|
|
|
64
128
|
const reset = () => change.view({ min: 0, max: 100, sec: 5, ofs: 0 })
|
|
65
129
|
const pick = (code) => {
|
|
66
130
|
if (mode === 'equation') { change.expr(code); return }
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
131
|
+
const params = ADSR_PRESETS.find((p) => p.id === code)?.params ?? parseAdsr(code)
|
|
132
|
+
if (params) change.adsr(params)
|
|
69
133
|
}
|
|
70
134
|
|
|
71
135
|
return (
|
|
@@ -81,11 +145,18 @@ export default function EnvelopeGenerator({
|
|
|
81
145
|
className={compiled.ok ? '' : 'text-[var(--kol-ctl-led-red)]'}
|
|
82
146
|
/>
|
|
83
147
|
)}
|
|
84
|
-
<
|
|
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>
|
|
85
155
|
{mode === 'equation' ? (
|
|
86
156
|
<>
|
|
87
157
|
<div className="flex items-center justify-between">
|
|
88
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>}
|
|
89
160
|
<Button variant="ghost" size="sm" iconLeft="refresh" onClick={reset}>Reset</Button>
|
|
90
161
|
</div>
|
|
91
162
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
@@ -102,6 +173,7 @@ export default function EnvelopeGenerator({
|
|
|
102
173
|
))}
|
|
103
174
|
<Toggle value={cycle} onChange={setCycle} label="cycle" />
|
|
104
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>}
|
|
105
177
|
</div>
|
|
106
178
|
)}
|
|
107
179
|
</div>
|