@kolkrabbi/kol-hardware 0.5.0 → 0.6.1

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
@@ -18,7 +18,7 @@ A different TIER from `kol-component`'s app atoms: a `kol-component` Slider is a
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; zoom X/Y, drag to pan, a clock `rate`, `loop` or one-shot on `trigger`, `height="fill"` |
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` or a host's `now`, `loop` or one-shot on `trigger`, the played part in the accent, `height="fill"` |
22
22
  | panel | `PanelLabel` | label wrapper, four positions |
23
23
  | | `ModuleHeader` | Toggle + module name + edit-mode remove dot / bypass dot |
24
24
  | | `SignalReference` | an expression / envelope reference — `variant` `panel` (mirror's box) · `tabs` · `popover` (EX / REF) · `sheet` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-hardware",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "description": "Panel controls for instruments",
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.262.0",
24
- "@kolkrabbi/kol-icons": "^0.38.0",
25
- "@kolkrabbi/kol-theme": "^0.174.0"
23
+ "@kolkrabbi/kol-component": "^0.290.0",
24
+ "@kolkrabbi/kol-icons": "^0.41.0",
25
+ "@kolkrabbi/kol-theme": "^0.183.0"
26
26
  },
27
27
  "files": [
28
28
  "src",
@@ -16,7 +16,13 @@ import { useEffect, useRef } from 'react'
16
16
  * THE CLOCK is the scope's: `rate` is time units per second (BPM / 60 for a beat clock), and
17
17
  * `playing` stops it where it is. `loop` (default) wraps the playhead over `sec`; off, the
18
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.
19
+ * again — a one-shot envelope's gate. `now` hands the clock to the host instead: a
20
+ * `() => t` read every frame (mirror's desk transport), and `rate` / `playing` stop applying.
21
+ *
22
+ * THE PLAYED PART IS THE ACCENT (SignalScopePlayedTraceAndClock, kol-mirror): while live, the
23
+ * curve is faint and the trace up to the playhead, the playhead and its dot take
24
+ * `--kol-slider-playhead` (the accent unless a host overrides it) — where the position is in the
25
+ * signal, at a glance.
20
26
  *
21
27
  * @param {(t:number)=>number} sample
22
28
  * @param {number} min · max the view (default 0…100)
@@ -30,20 +36,21 @@ import { useEffect, useRef } from 'react'
30
36
  * @param {number} rate time units per second (default 1)
31
37
  * @param {boolean} loop wrap the playhead (default true); off = one pass per `trigger`
32
38
  * @param {number} trigger a counter — each change restarts the pass
39
+ * @param {()=>number} now an external clock (time units); overrides the scope's own
33
40
  * @param {number|'fill'} height px (default 320), or `fill` to take the parent's height
34
41
  */
35
42
  const css = (el, name, fallback) => getComputedStyle(el).getPropertyValue(name).trim() || fallback
36
43
 
37
44
  export default function SignalScope({
38
45
  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,
46
+ refLines = [0, 100], live = true, playing = true, rate = 1, loop = true, trigger = 0, now,
40
47
  height = 320, className = '',
41
48
  }) {
42
49
  const ref = useRef(null)
43
50
  const args = useRef({})
44
51
  const drawRef = useRef(null)
45
52
  const clock = useRef({ t: 0, last: 0 })
46
- args.current = { sample, min, max, sec, ofs, zoomX, zoomY, panY, refLines, live, playing, rate, loop }
53
+ args.current = { sample, min, max, sec, ofs, zoomX, zoomY, panY, refLines, live, playing, rate, loop, now }
47
54
 
48
55
  // a trigger restarts the pass
49
56
  useEffect(() => { clock.current.t = 0 }, [trigger])
@@ -59,7 +66,8 @@ export default function SignalScope({
59
66
  const now = performance.now()
60
67
  const dt = (now - clock.current.last) / 1000
61
68
  clock.current.last = now
62
- if (a.live && a.playing) clock.current.t += dt * a.rate
69
+ if (a.now) clock.current.t = a.now()
70
+ else if (a.live && a.playing) clock.current.t += dt * a.rate
63
71
  const fn = a.sample
64
72
  const dpr = window.devicePixelRatio || 1
65
73
  const { width: w, height: h } = canvas.getBoundingClientRect()
@@ -71,6 +79,7 @@ export default function SignalScope({
71
79
  ctx.clearRect(0, 0, w, h)
72
80
  const ink = css(canvas, '--kol-surface-on-primary', '#e8e4dc')
73
81
  const red = css(canvas, '--kol-ctl-led-red', '#e74c3c')
82
+ const accent = css(canvas, '--kol-slider-playhead', css(canvas, '--kol-accent-primary', ink))
74
83
  const pad = 12
75
84
  const span = (a.max - a.min) / (a.zoomY || 1) || 1
76
85
  const lo = (a.min + a.max) / 2 + a.panY - span / 2
@@ -101,26 +110,35 @@ export default function SignalScope({
101
110
  ctx.fillStyle = ink
102
111
  ctx.fillText(fmt(hi), 4, pad + 9)
103
112
  ctx.fillText(fmt(lo), 4, h - pad - 3)
104
- // trace
105
- ctx.globalAlpha = 1
106
- ctx.strokeStyle = ink
107
- ctx.lineWidth = 1.5
108
- ctx.beginPath()
109
- for (let x = 0; x <= w; x++) {
110
- const y = toY(fn ? fn(a.ofs + (x / w) * dur) : lo)
111
- if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y)
113
+ const trace = (to) => {
114
+ ctx.beginPath()
115
+ for (let x = 0; x <= to; x++) {
116
+ const y = toY(fn ? fn(a.ofs + (x / w) * dur) : lo)
117
+ if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y)
118
+ }
119
+ ctx.stroke()
112
120
  }
113
- ctx.stroke()
121
+ const played = a.live && fn
122
+ // trace — faint under a playhead, the played part drawn over it
123
+ ctx.globalAlpha = played ? 0.15 : 1
124
+ ctx.strokeStyle = ink
125
+ ctx.lineWidth = played ? 1 : 1.5
126
+ trace(w)
127
+ ctx.globalAlpha = 1
114
128
  // 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)
129
+ if (played) {
130
+ const t = a.loop ? ((clock.current.t % a.sec) + a.sec) % a.sec : Math.max(0, Math.min(clock.current.t, a.sec))
117
131
  const px = toX(t)
132
+ ctx.strokeStyle = accent
133
+ ctx.lineWidth = 2
134
+ if (px > 0) trace(Math.min(px, w))
118
135
  if (px >= 0 && px <= w) {
119
136
  const py = toY(fn(t))
120
- ctx.globalAlpha = 0.25
137
+ ctx.globalAlpha = 0.4
138
+ ctx.lineWidth = 1
121
139
  ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.stroke()
122
140
  ctx.globalAlpha = 1
123
- ctx.fillStyle = ink
141
+ ctx.fillStyle = accent
124
142
  ctx.beginPath(); ctx.arc(px, py, 3, 0, Math.PI * 2); ctx.fill()
125
143
  }
126
144
  }
@@ -27,11 +27,11 @@ const adsrWindow = (adsr) => envelopeLength(adsr) * 1.15
27
27
 
28
28
  /**
29
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`).
30
+ * Takes the component's own options (`default*`, `saved`, `savedAdsr`, `onSave`, `onChange`, `now`).
31
31
  */
32
32
  export function useEnvelopeGenerator({
33
33
  defaultMode = 'equation', defaultExpr = 'wave(t)', defaultAdsr = ADSR_DEFAULTS,
34
- saved, savedAdsr, onSave, onChange,
34
+ saved, savedAdsr, onSave, onChange, now,
35
35
  } = {}) {
36
36
  const [mode, setModeState] = useState(defaultMode)
37
37
  const [expr, setExprState] = useState(defaultExpr)
@@ -69,7 +69,7 @@ export function useEnvelopeGenerator({
69
69
  return {
70
70
  mode, setMode, expr, setExpr, compiled, adsr, setAdsr, view, setView, fit, reset,
71
71
  cycle, setCycle, playing, setPlaying, bpm, setBpm, trigger, fire: () => setTrigger((n) => n + 1),
72
- heldSec, setHeldSec, save,
72
+ heldSec, setHeldSec, save, now,
73
73
  /* spread into <SignalReference> — the current mode's data, whichever shape it takes. Key the
74
74
  * element on `mode` so its open tab resets when the data changes under it. */
75
75
  reference: {
@@ -188,7 +188,9 @@ function EnvelopeHandles({ adsr, sec, onDragStart, onDrag, onDragEnd }) {
188
188
  * itself.
189
189
  *
190
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.
191
+ * seconds at the default and as beats at any other). Play / pause stops it where it is. `now`
192
+ * (`() => t`) hands the clock to the host — a desk transport — and the scope reads it instead;
193
+ * Play/Pause, BPM and Trigger are then not drawn, since the host's transport does their jobs.
192
194
  *
193
195
  * ADSR: four handles on the scope — A (attack time), D (decay time), S (at the end of the hold:
194
196
  * sideways = how long the gate holds, up/down = the sustain level), R (release time). The scope
@@ -205,6 +207,7 @@ function EnvelopeHandles({ adsr, sec, onDragStart, onDrag, onDragEnd }) {
205
207
  * @param {string} defaultExpr
206
208
  * @param {object} defaultAdsr knob values 0…100, `hold` in seconds (`ADSR_DEFAULTS`)
207
209
  * @param {Array} saved · savedAdsr the host's own rows for each mode's Saved tab
210
+ * @param {()=>number} now an external clock for the scope (overrides play / BPM)
208
211
  * @param {Function} onSave `({ mode, code }) => void` — shows a Save button; `code` is the
209
212
  * expression, or the envelope as `a10 d30 s70 r50 h1`
210
213
  */
@@ -223,14 +226,18 @@ export default function EnvelopeGenerator({ generator, reference = 'panel', clas
223
226
  const passSec = mode === 'equation' ? sec : envelopeLength(adsr)
224
227
  const panel = reference === true || reference === 'panel'
225
228
 
229
+ /* a host clock (`now`) owns play, speed and restart — the controls for them would do nothing, so
230
+ * they are not drawn (EnvelopeGeneratorHostClockControls, kol-mirror 2026-10-22). Cycle still
231
+ * acts: it decides whether the host's time wraps over the pass or holds at its end. */
232
+ const ownClock = !g.now
226
233
  const transport = (
227
234
  <div className="flex h-6 shrink-0 items-center gap-3">
228
- <Button tone="ghost" size="sm" iconOnly={g.playing ? 'pause' : 'play'} aria-label={g.playing ? 'Pause' : 'Play'} onClick={() => g.setPlaying(!g.playing)} />
229
- <Field label="BPM" value={g.bpm} chars={3} onCommit={(v) => g.setBpm(Math.max(1, Math.min(999, num(v, g.bpm))))} />
235
+ {ownClock && <Button tone="ghost" size="sm" iconOnly={g.playing ? 'pause' : 'play'} aria-label={g.playing ? 'Pause' : 'Play'} onClick={() => g.setPlaying(!g.playing)} />}
236
+ {ownClock && <Field label="BPM" value={g.bpm} chars={3} onCommit={(v) => g.setBpm(Math.max(1, Math.min(999, num(v, g.bpm))))} />}
230
237
  {mode === 'adsr' && (
231
238
  <>
232
239
  <Toggle value={g.cycle} onChange={g.setCycle} label="cycle" />
233
- <Button tone="ghost" size="sm" onClick={g.fire}>Trigger</Button>
240
+ {ownClock && <Button tone="ghost" size="sm" onClick={g.fire}>Trigger</Button>}
234
241
  </>
235
242
  )}
236
243
  <span className="ml-auto text-fg-48">{mode === 'adsr' ? `${passSec.toFixed(2)} s` : ''}</span>
@@ -270,6 +277,7 @@ export default function EnvelopeGenerator({ generator, reference = 'panel', clas
270
277
  rate={g.bpm / 60}
271
278
  loop={mode === 'equation' || g.cycle}
272
279
  trigger={g.trigger}
280
+ now={g.now}
273
281
  className="absolute inset-0"
274
282
  />
275
283
  {mode === 'adsr' && (