@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.1.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-component": "^0.225.0",
23
+ "@kolkrabbi/kol-theme": "^0.152.0",
24
24
  "@kolkrabbi/kol-icons": "^0.27.1",
25
- "@kolkrabbi/kol-theme": "^0.152.0"
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}`, default `xxs`)
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 = 'xxs',
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-xxxxs" 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-xxxxs" style={{ position: 'absolute', bottom: 3, left: 0, right: 0, textAlign: 'center', color: on ? 'var(--kol-ctl-hw-cap-edge)' : 'transparent', pointerEvents: 'none' }}>O</span>
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 preset = ADSR_PRESETS.find((p) => p.id === code)
68
- if (preset) change.adsr(preset.params)
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
- <SignalScope sample={sample} min={mode === 'equation' ? view.min : 0} max={mode === 'equation' ? view.max : 100} sec={sec} ofs={ofs} />
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>