@kolkrabbi/kol-hardware 0.1.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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * PanelLabel — wraps any control with the panel's text label (kol-monitor's
3
+ * rack `LabeledControl`, lifted 2026-09-01; named `PanelLabel` because
4
+ * `LabeledControl` is kol-component's settings-row control). Matches Knob /
5
+ * Toggle: `kol-helper-8`, `text-fg-32`, uppercase.
6
+ *
7
+ * @param {string} label
8
+ * @param {boolean} horizontal shorthand for a row
9
+ * @param {'top'|'bottom'|'left'|'right'} labelPosition (default bottom)
10
+ * @param {string} labelClass type class (default `kol-helper-8`)
11
+ * @param {number} gap
12
+ * @param {ReactNode} children
13
+ */
14
+ export default function PanelLabel({ label, horizontal = false, labelPosition = 'bottom', labelClass = 'kol-helper-8', gap = 0, children }) {
15
+ const isRow = horizontal || labelPosition === 'left' || labelPosition === 'right'
16
+ const labelEl = label && (
17
+ <span className={`${labelClass} text-fg-32`} style={{ textTransform: 'uppercase', lineHeight: 1 }}>
18
+ {label}
19
+ </span>
20
+ )
21
+ return (
22
+ <div style={{ display: 'inline-flex', flexDirection: isRow ? 'row' : 'column', alignItems: 'center', gap }}>
23
+ {(labelPosition === 'top' || labelPosition === 'left') && labelEl}
24
+ {children}
25
+ {(labelPosition === 'bottom' || labelPosition === 'right') && labelEl}
26
+ </div>
27
+ )
28
+ }
@@ -0,0 +1,126 @@
1
+ import { useState } from 'react'
2
+ import { TabsRow, usePopover, PopoverPanel } from '@kolkrabbi/kol-component'
3
+
4
+ /**
5
+ * SignalReference — the reference an expression or envelope tool is explained with (signal
6
+ * engine, 2026-09-27). ONE component over the reference DATA in `@kolkrabbi/kol-hardware/signal`,
7
+ * in the three shapes the estate already used:
8
+ *
9
+ * variant="tabs" kol-mirror's /expressions panel — Examples · Saved · Language · Usage
10
+ * 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
12
+ *
13
+ * Clicking a code loads it (`onPick`); ⌘/Ctrl-click appends it (`onAppend`) — mirror's gestures.
14
+ *
15
+ * @param {object} sections `{ id: { title, rows | tips, grid } }` (EXPRESSION_SECTIONS, ADSR_SECTIONS)
16
+ * @param {Array} tabs `[{ id, label, sections }]` (EXPRESSION_TABS, ADSR_TABS); a tab with
17
+ * id `saved` renders the host's `saved` rows, and is hidden without them
18
+ * @param {Array} saved `[code, label]` rows the host keeps (its library)
19
+ */
20
+ function Code({ text, onPick, onAppend }) {
21
+ return (
22
+ <button
23
+ type="button"
24
+ className="kol-helper-10 min-w-0 max-w-full truncate rounded-[2px] bg-surface-tertiary px-2 py-1 text-left text-fg-96"
25
+ onClick={(e) => ((e.metaKey || e.ctrlKey) ? onAppend : onPick)?.(text)}
26
+ >
27
+ {text}
28
+ </button>
29
+ )
30
+ }
31
+
32
+ function Section({ section, onPick, onAppend, showTitle }) {
33
+ return (
34
+ <div className="flex flex-col gap-1">
35
+ {showTitle && <div className="kol-helper-10 uppercase text-fg-48">{section.title}</div>}
36
+ {section.tips
37
+ ? section.tips.map((tip) => <div key={tip} className="kol-helper-10 text-fg-48" style={{ lineHeight: '150%' }}>{tip}</div>)
38
+ : (
39
+ <div className={section.grid ? 'grid grid-cols-2 gap-x-4' : 'flex flex-col'}>
40
+ {section.rows.map((row) => {
41
+ const codes = Array.isArray(row) ? [row[0]] : row.codes
42
+ const label = Array.isArray(row) ? row[1] : row.label
43
+ 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">
46
+ {codes.map((c) => <Code key={c} text={c} onPick={onPick} onAppend={onAppend} />)}
47
+ </span>
48
+ <span className="kol-helper-10 shrink-0 text-fg-48">{label}</span>
49
+ </div>
50
+ )
51
+ })}
52
+ </div>
53
+ )}
54
+ </div>
55
+ )
56
+ }
57
+
58
+ const sectionsOf = (tab, sections, saved = []) =>
59
+ tab.id === 'saved' ? [{ title: 'Saved', rows: saved }] : tab.sections.map((id) => sections[id]).filter(Boolean)
60
+
61
+ function Tabs({ sections, tabs, saved, onPick, onAppend }) {
62
+ const visible = tabs.filter((t) => t.id !== 'saved' || saved)
63
+ const [tab, setTab] = useState(visible[0]?.id)
64
+ const current = visible.find((t) => t.id === tab) ?? visible[0]
65
+ const list = current ? sectionsOf(current, sections, saved) : []
66
+ return (
67
+ <div className="flex min-h-0 flex-col gap-4">
68
+ <TabsRow tabs={visible} value={current?.id} onChange={setTab} />
69
+ <div className="flex min-h-0 flex-col gap-4 overflow-y-auto">
70
+ {current?.id === 'saved' && !saved?.length
71
+ ? <div className="kol-helper-10 text-fg-48">Nothing saved yet.</div>
72
+ : list.map((s) => <Section key={s.title} section={s} onPick={onPick} onAppend={onAppend} showTitle={list.length > 1} />)}
73
+ </div>
74
+ </div>
75
+ )
76
+ }
77
+
78
+ function PopButton({ label, list, onPick, onAppend }) {
79
+ const [open, setOpen] = useState(false)
80
+ const popover = usePopover({ open, onOpenChange: setOpen, placement: 'bottom-end' })
81
+ return (
82
+ <>
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>
91
+ <PopoverPanel popover={popover} className="w-80 max-h-96 overflow-y-auto p-3">
92
+ <div className="flex flex-col gap-3">
93
+ {list.map((s) => (
94
+ <Section key={s.title} section={s} showTitle
95
+ onPick={(c) => { onPick?.(c); setOpen(false) }} onAppend={onAppend} />
96
+ ))}
97
+ </div>
98
+ </PopoverPanel>
99
+ </>
100
+ )
101
+ }
102
+
103
+ export default function SignalReference({ sections, tabs, variant = 'tabs', saved, onPick, onAppend, className = '' }) {
104
+ if (variant === 'popover') {
105
+ const [first, ...rest] = tabs.filter((t) => t.id !== 'saved')
106
+ return (
107
+ <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} />}
110
+ </div>
111
+ )
112
+ }
113
+ if (variant === 'sheet') {
114
+ const all = tabs.filter((t) => t.id !== 'saved').flatMap((t) => sectionsOf(t, sections))
115
+ return (
116
+ <div className={`grid gap-6 sm:grid-cols-2 ${className}`}>
117
+ {all.map((s) => <Section key={s.title} section={s} onPick={onPick} onAppend={onAppend} showTitle />)}
118
+ </div>
119
+ )
120
+ }
121
+ return (
122
+ <div className={className}>
123
+ <Tabs sections={sections} tabs={tabs} saved={saved} onPick={onPick} onAppend={onAppend} />
124
+ </div>
125
+ )
126
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * ADSR — the envelope engine (signal engine, 2026-09-27), ported from kol-monitor's
3
+ * `modules/control/EnvelopeModule.jsx` so the rack module, the envelope generator and any scope
4
+ * share one curve. Levels are 0…100 like every other signal here; map to a range with `toRange`.
5
+ *
6
+ * Params are the knob values, 0…100:
7
+ * attack · decay · release → seconds via `stageSeconds` (0 → 5 ms, 100 → 2 s, linear — monitor's)
8
+ * sustain → the held level, 0…100
9
+ */
10
+
11
+ export const ADSR_DEFAULTS = Object.freeze({ attack: 10, decay: 30, sustain: 70, release: 50 })
12
+
13
+ /** knob 0…100 → seconds, 5 ms … 2 s */
14
+ export const stageSeconds = (v) => 0.005 + (Math.max(0, Math.min(100, v)) / 100) * 1.995
15
+
16
+ /** a 0…100 level → [min, max] */
17
+ export const toRange = (level, min = 0, max = 100) => min + (level / 100) * (max - min)
18
+
19
+ /**
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.
23
+ */
24
+ export function envelopeAt(t, params = ADSR_DEFAULTS, { hold, cycle = false } = {}) {
25
+ const { attack, decay, sustain, release } = { ...ADSR_DEFAULTS, ...params }
26
+ const a = stageSeconds(attack)
27
+ const d = stageSeconds(decay)
28
+ const r = stageSeconds(release)
29
+ const s = Math.max(0, Math.min(100, sustain))
30
+ const gate = hold ?? a + d + 1
31
+ const length = gate + r
32
+ let u = t
33
+ if (cycle) u = ((t % length) + length) % length
34
+ if (u < 0) return 0
35
+ const held = (x) => (x < a ? (x / a) * 100 : x < a + d ? 100 - ((x - a) / d) * (100 - s) : s)
36
+ if (u < gate) return held(u)
37
+ const from = held(gate)
38
+ const k = (u - gate) / r
39
+ return k >= 1 ? 0 : from * (1 - k)
40
+ }
41
+
42
+ /** Total seconds of one envelope pass — for sizing a scope window. */
43
+ export const envelopeLength = (params = ADSR_DEFAULTS, { hold } = {}) => {
44
+ const p = { ...ADSR_DEFAULTS, ...params }
45
+ return (hold ?? stageSeconds(p.attack) + stageSeconds(p.decay) + 1) + stageSeconds(p.release)
46
+ }
47
+
48
+ /**
49
+ * createEnvelope() — the stateful form a rack module runs per frame (monitor's stage machine,
50
+ * verbatim in behaviour): `step(dt, { gate, trig }, params)` → level 0…100. A rising `gate`
51
+ * attacks and holds sustain until it drops; a rising `trig` is a one-shot A→D→R (with sustain 0,
52
+ * D and R fold into one long decay); `cycle` loops.
53
+ */
54
+ export function createEnvelope() {
55
+ let stage = 'idle'
56
+ let level = 0
57
+ let releaseFrom = 0
58
+ let oneShot = false
59
+ let prevGate = false
60
+ let prevTrig = false
61
+ return {
62
+ get stage() { return stage },
63
+ get level() { return level },
64
+ step(dt, { gate = false, trig = false } = {}, params = ADSR_DEFAULTS) {
65
+ const { attack, decay, sustain, release, cycle = false } = { ...ADSR_DEFAULTS, ...params }
66
+ const aT = stageSeconds(attack)
67
+ const dT = stageSeconds(decay)
68
+ const rT = stageSeconds(release)
69
+ const sL = Math.max(0, Math.min(100, sustain))
70
+ if (trig && !prevTrig) { stage = 'attack'; oneShot = true }
71
+ prevTrig = trig
72
+ if (cycle && stage === 'idle') stage = 'attack'
73
+ if (gate && !prevGate) { stage = 'attack'; oneShot = false }
74
+ if (!gate && prevGate && stage !== 'idle') { releaseFrom = level; stage = 'release' }
75
+ prevGate = gate
76
+ if (stage === 'attack') {
77
+ level += (dt / aT) * 100
78
+ if (level >= 100) { level = 100; stage = 'decay' }
79
+ } else if (stage === 'decay') {
80
+ const once = oneShot || (cycle && !gate)
81
+ const dTime = once && sL <= 0 ? dT + rT : dT
82
+ level -= (dt / dTime) * (100 - sL)
83
+ if (level <= sL) {
84
+ level = sL
85
+ if (once) {
86
+ if (sL > 0) { releaseFrom = level; stage = 'release' } else { stage = cycle ? 'attack' : 'idle'; oneShot = false }
87
+ } else stage = 'sustain'
88
+ }
89
+ } else if (stage === 'sustain') {
90
+ level = sL
91
+ if (cycle && !gate) { releaseFrom = level; stage = 'release' }
92
+ } else if (stage === 'release') {
93
+ level -= (dt / rT) * (releaseFrom || 1)
94
+ if (level <= 0) { level = 0; stage = cycle ? 'attack' : 'idle'; oneShot = false }
95
+ }
96
+ return level
97
+ },
98
+ reset() { stage = 'idle'; level = 0; releaseFrom = 0; oneShot = false; prevGate = false; prevTrig = false },
99
+ }
100
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The expression engine — ONE compiler for every scope, expression module and animated value in
3
+ * the estate (signal engine, 2026-09-27). Before it there were four: kol-monitor's and kol-mirror's
4
+ * `hooks/useExpressionValue.js`, the design editor's Oscilloscope (`loops/math/expression.js`) and
5
+ * its modulation DSL (`params/expr.js`) — already drifted (monitor's helpers span [min, max],
6
+ * mirror's always started at 0), and two of them handed user text straight to `new Function`.
7
+ *
8
+ * SEMANTICS — monitor's, the newest: every helper spans the caller's [min, max], so `wave(t)` is
9
+ * min…max. With min = 0 that is exactly mirror's and the editor's `0…max`; with min 0 max 1 it is
10
+ * labs' normalized "animate any value". `sin`/`cos` stay raw (−1…1).
11
+ *
12
+ * SAFETY — the design editor's two gates, in front of the Function sink: math-only characters,
13
+ * and an identifier allowlist (the helpers, `t f min max`, and the caller's bus names), so
14
+ * `sin.constructor(…)` dies at the gate. Browser globals are shadowed inside the body. A compiled
15
+ * fn never throws and never returns a non-finite value (last good value, labs behaviour).
16
+ *
17
+ * const { ok, fn } = compileExpression('wave(t*2)')
18
+ * fn(t, frame, min, max, bus) // → number
19
+ */
20
+
21
+ const HELPERS = `
22
+ "use strict";
23
+ var sin=Math.sin,cos=Math.cos,tan=Math.tan,atan2=Math.atan2,abs=Math.abs,floor=Math.floor,
24
+ ceil=Math.ceil,round=Math.round,sqrt=Math.sqrt,pow=Math.pow,
25
+ PI=Math.PI,TAU=Math.PI*2,PHI=1.618033988749895,E=Math.E,
26
+ __span=max-min,
27
+ __map=function(u){return min+u*__span},
28
+ __ph=function(x){return((x%1)+1)%1},
29
+ wave=function(x){return __map(sin(x)*0.5+0.5)},
30
+ saw=function(x){return __map(__ph(x))},
31
+ pulse=function(x,w){w=w||0.5;return __ph(x)<w?max:min},
32
+ rand=function(){return __map(Math.random())},
33
+ tri=function(x){var p=__ph(x);return __map(p<0.5?p*2:(1-p)*2)},
34
+ ease=function(x,c){c=c||2;var p=__ph(x);var v=p<0.5?p*2:(1-p)*2;return __map(pow(v,c))},
35
+ bell=function(x){var p=__ph(x);return __map(Math.exp(-pow((p-0.5)*6,2)))},
36
+ exp=function(x){var p=__ph(x);return __map((Math.exp(p*3)-1)/(Math.exp(3)-1))},
37
+ log=function(x){var p=__ph(x);return __map(Math.log(1+p*9)/Math.log(10))},
38
+ step=function(x,n){n=n||4;return __map(floor(__ph(x)*n)/n)},
39
+ clamp=function(x,a,b){return x<a?a:x>b?b:x},
40
+ lerp=function(a,b,u){return a+(b-a)*u},
41
+ mod=function(a,b){return((a%b)+b)%b},
42
+ frac=function(x){return x-floor(x)},
43
+ smooth=function(x){x=clamp(x,0,1);return x*x*(3-2*x)};
44
+ `
45
+
46
+ export const EXPRESSION_HELPERS = [
47
+ 'sin', 'cos', 'tan', 'atan2', 'abs', 'floor', 'ceil', 'round', 'sqrt', 'pow',
48
+ 'PI', 'TAU', 'PHI', 'E',
49
+ 'wave', 'saw', 'pulse', 'rand', 'tri', 'ease', 'bell', 'exp', 'log', 'step',
50
+ 'clamp', 'lerp', 'mod', 'frac', 'smooth',
51
+ ]
52
+ const BASE = new Set([...EXPRESSION_HELPERS, 't', 'f', 'min', 'max'])
53
+ const CHARS = /^[\w\s+\-*/%(),.?:<>=!&|]+$/
54
+ const SHADOWED = [
55
+ 'globalThis', 'window', 'self', 'document', 'fetch', 'XMLHttpRequest', 'localStorage',
56
+ 'sessionStorage', 'indexedDB', 'navigator', 'location', 'top', 'parent', 'frames', 'opener',
57
+ 'Function', 'WebSocket', 'Worker', 'importScripts',
58
+ ]
59
+ const IDENT = /^[A-Za-z_$][\w$]*$/
60
+ const FAIL = Object.freeze({ ok: false, fn: () => 0 })
61
+
62
+ const cache = new Map()
63
+
64
+ /**
65
+ * compileExpression(expr, { bus }) → { ok, fn(t, f, min, max, busValues) }
66
+ * `bus` names extra variables the expression may read (a module's CV inputs: `['a', 'b']`);
67
+ * their values arrive as the fifth argument, an object. Never throws.
68
+ */
69
+ export function compileExpression(expr, { bus = [] } = {}) {
70
+ const src = String(expr ?? '').trim()
71
+ if (!src) return FAIL
72
+ const names = bus.filter((b) => IDENT.test(b) && !BASE.has(b))
73
+ const key = `${names.join(',')}|${src}`
74
+ const hit = cache.get(key)
75
+ if (hit) return hit
76
+ let entry = FAIL
77
+ try {
78
+ if (!CHARS.test(src)) throw new Error('chars')
79
+ const allowed = new Set([...BASE, ...names])
80
+ for (const m of src.matchAll(/[A-Za-z_$][\w$]*/g)) {
81
+ if (!allowed.has(m[0])) throw new Error(`ident ${m[0]}`)
82
+ }
83
+ const busVars = names.length ? `var ${names.map((n) => `${n}=+(__bus&&__bus.${n})||0`).join(',')};` : ''
84
+ // eslint-disable-next-line no-new-func
85
+ const raw = new Function('t', 'f', 'min', 'max', '__bus', ...SHADOWED, `${HELPERS}${busVars}return (${src});`)
86
+ if (typeof raw(1, 60, 0, 100, {}) !== 'number') throw new Error('not numeric')
87
+ let last = 0
88
+ entry = {
89
+ ok: true,
90
+ fn: (t, f = 0, min = 0, max = 100, busValues) => {
91
+ try {
92
+ const v = raw(t, f, min, max, busValues)
93
+ if (typeof v === 'number' && Number.isFinite(v)) { last = v; return v }
94
+ return last
95
+ } catch { return last }
96
+ },
97
+ }
98
+ } catch { /* FAIL */ }
99
+ cache.set(key, entry)
100
+ return entry
101
+ }
102
+
103
+ /** A typed string is an expression (not a plain number) when it has any letter but `e`
104
+ * (so `1e3` stays a number) — the rule monitor's and mirror's slider boxes use. */
105
+ export const isExpression = (str) => /[a-df-zA-DF-Z]/.test(String(str))
106
+
107
+ /**
108
+ * Fit — the [min, max] a curve actually covers over a window, with 10% headroom on Y (labs /
109
+ * mirror's Fit button). Evaluates at min 0 max 100, the helpers' reference scale, so fitting the
110
+ * view never feeds back into the curve.
111
+ */
112
+ export function fitRange(expr, { sec = 5, ofs = 0, bus } = {}) {
113
+ const { ok, fn } = compileExpression(expr, { bus })
114
+ if (!ok) return null
115
+ const N = Math.min(20000, Math.max(300, Math.round(sec * 240)))
116
+ let lo = Infinity
117
+ let hi = -Infinity
118
+ for (let i = 0; i <= N; i++) {
119
+ const t = ofs + (i / N) * sec
120
+ const v = fn(t, Math.round(t * 60), 0, 100)
121
+ if (v < lo) lo = v
122
+ if (v > hi) hi = v
123
+ }
124
+ if (!(hi > lo)) { lo -= 1; hi += 1 }
125
+ const m = (hi - lo) * 0.1
126
+ return { min: Math.floor(lo - m), max: Math.ceil(hi + m) }
127
+ }
@@ -0,0 +1,6 @@
1
+ // @kolkrabbi/kol-hardware/signal — the signal engine: plain JS, no React. One expression compiler
2
+ // and one ADSR for every scope, envelope and animated value in the estate, plus the reference
3
+ // they are explained with.
4
+ export { compileExpression, isExpression, fitRange, EXPRESSION_HELPERS } from './expression.js'
5
+ export { envelopeAt, envelopeLength, createEnvelope, stageSeconds, toRange, ADSR_DEFAULTS } from './adsr.js'
6
+ export { EXPRESSION_SECTIONS, EXPRESSION_TABS, ADSR_PRESETS, ADSR_SECTIONS, ADSR_TABS } from './reference.js'
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The reference — examples, the language and usage notes for the expression engine, and the
3
+ * presets and usage for ADSR (signal engine, 2026-09-27). DATA, one copy: before this the same
4
+ * lists lived in kol-mirror's ExpressionReference, kol-monitor's Scope+ EX/REF popovers and the
5
+ * design editor's labs shortcuts sheet, and had already drifted (mirror 20 examples, labs 9).
6
+ * The expression sections are kol-mirror's `SECTIONS`, verbatim; the Functions rows gained the
7
+ * helpers the engine took from labs (tan · atan2 · clamp · lerp · mod · frac · smooth · TAU · E).
8
+ *
9
+ * A row is `[code, label]`, or `{ codes, label }` for a multi-code row. `SignalReference` renders
10
+ * any of these as tabs, popovers or a sheet.
11
+ */
12
+
13
+ export const EXPRESSION_SECTIONS = {
14
+ examples: { title: 'Examples', rows: [
15
+ ['wave(t*2)', 'Fast sine'], ['saw(t)*0.8', 'Ramp to 80'], ['tri(t*0.5)', 'Slow bounce'], ['ease(t*2, 4)', 'Fast + punchy'],
16
+ ['pulse(t*3)', 'Fast toggle'], ['pulse(t, 0.3)', 'PWM 30%'], ['sin(t)*30+50', 'Sine 20–80'], ['abs(sin(t*3))*max', 'Bouncing'],
17
+ ['rand()', 'Noise'], ['t*20 % max', 'Linear ramp'], ['exp(t)', 'Exponential'], ['log(t)', 'Logarithmic'], ['bell(t)', 'Bell curve'],
18
+ ['step(t, 4)', '4 steps'], ['step(t, 8)', '8 steps'], ['exp(t)*0.5+25', 'Exp 25–75'], ['bell(t*2)', 'Fast bell'],
19
+ ['wave(t)+saw(t*2)*0.3', 'Layered'], ['tri(t)*pulse(t*4)', 'Gated bounce'], ['step(t, 6)*0.8+10', 'Steps 10–90'], ['ease(t*0.3, 3)*0.6+20', 'Slow dramatic'],
20
+ ] },
21
+ waves: { title: 'Waves', rows: [
22
+ ['wave(t)', 'Smooth up and down'], ['saw(t)', 'Ramp up, jump back'], ['tri(t)', 'Ramp up, ramp down'], ['pulse(t)', 'Snap on/off'],
23
+ ['rand()', 'Random every frame'], ['bell(t)', 'Bell curve'], ['exp(t)', 'Exponential ramp'], ['log(t)', 'Logarithmic ramp'], ['step(t, 4)', 'Staircase'],
24
+ ] },
25
+ functions: { title: 'Functions', grid: true, rows: [
26
+ ['sin', '-1 to 1'], ['cos', '-1 to 1'], ['tan', 'Tangent'], ['atan2', 'Angle of y, x'], ['abs', 'Absolute'],
27
+ ['floor', '↓ Round'], ['ceil', '↑ Round'], ['round', 'Nearest'], ['sqrt', '√'], ['pow', 'Power'],
28
+ ['clamp', 'Hold in a, b'], ['lerp', 'a → b by u'], ['mod', 'Wrap, never negative'], ['frac', 'Fractional part'], ['smooth', 'Smoothstep 0…1'],
29
+ ['PI', '3.14159'], ['TAU', '6.28318'], ['PHI', '1.61803'], ['E', '2.71828'],
30
+ ] },
31
+ variables: { title: 'Variables', rows: [
32
+ { codes: ['t', 'f'], label: 'Second / Frame count' }, ['min', 'Knob minimum'], ['max', 'Knob maximum'],
33
+ ] },
34
+ curves: { title: 'Curves', rows: [
35
+ ['ease(t)', 'Gentle breath'], ['ease(t, 1)', 'Linear, no curve'], ['ease(t, 5)', 'Dramatic punch'], ['ease(t, 0.5)', 'Quick flick'],
36
+ ] },
37
+ range: { title: 'Range', rows: [
38
+ ['saw(t)*0.8', '0 to 80'], ['wave(t)*0.5+25', '25 to 75'], ['tri(t)*0.3+70', '70 to 100'], ['ease(t)*0.2', '0 to 20'],
39
+ ] },
40
+ speed: { title: 'Speed', rows: [
41
+ ['wave(t*0.5)', 'Half speed'], ['saw(t*2)', 'Double speed'], ['tri(t*3)', '3x faster'], ['ease(t*5)', '5x faster'], ['pulse(t*0.1)', 'Very slow'],
42
+ ] },
43
+ tips: { title: 'Tips', tips: [
44
+ '→ Click numbers for expression input',
45
+ '→ Alt+click number to reset',
46
+ '→ Cmd+click expression to append to oscilloscope',
47
+ ] },
48
+ }
49
+
50
+ /* kol-mirror's fold: Examples alone; Language = what the language has; Usage = how to shape it.
51
+ * `saved` is the host's own list (a library) — the component renders what it is handed. */
52
+ export const EXPRESSION_TABS = [
53
+ { id: 'examples', label: 'Examples', sections: ['examples'] },
54
+ { id: 'saved', label: 'Saved', sections: [] },
55
+ { id: 'language', label: 'Language', sections: ['waves', 'functions', 'variables'] },
56
+ { id: 'usage', label: 'Usage', sections: ['curves', 'range', 'speed', 'tips'] },
57
+ ]
58
+
59
+ /* ADSR — a preset is `{ id, label, params }`, knob values 0…100. */
60
+ export const ADSR_PRESETS = [
61
+ { id: 'pluck', label: 'Pluck', params: { attack: 0, decay: 12, sustain: 0, release: 10 } },
62
+ { id: 'pad', label: 'Pad', params: { attack: 60, decay: 40, sustain: 80, release: 70 } },
63
+ { id: 'swell', label: 'Swell', params: { attack: 85, decay: 10, sustain: 100, release: 40 } },
64
+ { id: 'gate', label: 'Gate', params: { attack: 0, decay: 0, sustain: 100, release: 0 } },
65
+ { id: 'stab', label: 'Stab', params: { attack: 2, decay: 25, sustain: 30, release: 15 } },
66
+ { id: 'fade', label: 'Slow fade', params: { attack: 5, decay: 90, sustain: 0, release: 90 } },
67
+ ]
68
+
69
+ export const ADSR_SECTIONS = {
70
+ presets: { title: 'Presets', rows: ADSR_PRESETS.map((p) => [p.id, p.label]) },
71
+ stages: { title: 'Stages', rows: [
72
+ ['A', 'Attack — rise to full'], ['D', 'Decay — fall to sustain'], ['S', 'Sustain — held while the gate is open'], ['R', 'Release — fall to zero after the gate'],
73
+ ] },
74
+ timing: { title: 'Timing', rows: [
75
+ ['0', '5 ms'], ['50', '≈ 1 s'], ['100', '2 s'],
76
+ ] },
77
+ tips: { title: 'Tips', tips: [
78
+ '→ Sustain is a level, not a time',
79
+ '→ Gate holds sustain; a trigger runs A → D → R once',
80
+ '→ With sustain 0, a trigger folds D and R into one long decay',
81
+ '→ Cycle loops the whole envelope',
82
+ ] },
83
+ }
84
+
85
+ export const ADSR_TABS = [
86
+ { id: 'presets', label: 'Presets', sections: ['presets'] },
87
+ { id: 'saved', label: 'Saved', sections: [] },
88
+ { id: 'usage', label: 'Usage', sections: ['stages', 'timing', 'tips'] },
89
+ ]
@@ -0,0 +1,46 @@
1
+ // The signal engine's check: `node src/signal/signal.test.mjs`
2
+ import assert from 'node:assert/strict'
3
+ import { compileExpression, isExpression, fitRange, envelopeAt, createEnvelope, stageSeconds, EXPRESSION_SECTIONS } from './index.js'
4
+
5
+ // helpers span [min, max] — monitor's semantics; min 0 is mirror's / the editor's
6
+ const w = compileExpression('wave(t)')
7
+ assert.ok(w.ok)
8
+ assert.equal(Math.round(w.fn(0, 0, 0, 100)), 50)
9
+ assert.equal(Math.round(w.fn(0, 0, -100, 100)), 0)
10
+ assert.equal(compileExpression('pulse(0.25)').fn(0, 0, -100, 100), 100)
11
+ assert.equal(compileExpression('pulse(0.75)').fn(0, 0, -100, 100), -100)
12
+ assert.equal(compileExpression('saw(-0.25)').fn(0, 0, 0, 100), 75) // wraps, never negative
13
+ // every shipped example compiles
14
+ for (const [code] of EXPRESSION_SECTIONS.examples.rows) assert.ok(compileExpression(code).ok, code)
15
+ // the gates
16
+ for (const bad of ['sin.constructor("return 1")()', 'window.x', 'Math.random()', 'fetch(1)', 'a+1', '"x"'])
17
+ assert.equal(compileExpression(bad).ok, false, bad)
18
+ // bus names are allowed only when declared
19
+ const b = compileExpression('a*2+b', { bus: ['a', 'b'] })
20
+ assert.ok(b.ok)
21
+ assert.equal(b.fn(0, 0, 0, 100, { a: 3, b: 1 }), 7)
22
+ // never non-finite: last good value
23
+ const inf = compileExpression('1/(t-1)')
24
+ assert.equal(inf.fn(0), -1)
25
+ assert.equal(inf.fn(1), -1)
26
+ assert.ok(isExpression('wave(t)') && !isExpression('1e3') && !isExpression('42'))
27
+ assert.deepEqual(fitRange('wave(t)', { sec: 10 }), { min: -10, max: 110 })
28
+
29
+ // ADSR
30
+ assert.equal(stageSeconds(0), 0.005)
31
+ assert.equal(stageSeconds(100), 2)
32
+ const p = { attack: 50, decay: 50, sustain: 40, release: 50 }
33
+ const a = stageSeconds(50)
34
+ assert.equal(Math.round(envelopeAt(a, p)), 100) // top of attack
35
+ assert.equal(Math.round(envelopeAt(a * 2 + 0.5, p)), 40) // holding sustain
36
+ assert.equal(envelopeAt(100, p), 0) // long after release
37
+ const env = createEnvelope()
38
+ let lvl = 0
39
+ for (let i = 0; i < 600; i++) lvl = env.step(1 / 60, { gate: true }, p)
40
+ assert.equal(Math.round(lvl), 40)
41
+ assert.equal(env.stage, 'sustain')
42
+ for (let i = 0; i < 600; i++) lvl = env.step(1 / 60, { gate: false }, p)
43
+ assert.equal(lvl, 0)
44
+ assert.equal(env.stage, 'idle')
45
+
46
+ console.log('signal: ok')
@@ -0,0 +1,80 @@
1
+ /**
2
+ * FlipToggle — the eurorack flip switch, two or three positions (kol-monitor's
3
+ * rack, lifted 2026-09-01). `positions=2`: `value` is a boolean; `positions=3`:
4
+ * `value` is 0 · 1 · 2 (top, centre, bottom / left, centre, right).
5
+ *
6
+ * @param {boolean|number} value · onChange
7
+ * @param {string} labelA · labelB · labelC up to three labels along the switch
8
+ * @param {'vertical'|'horizontal'} variant (default vertical)
9
+ * @param {2|3} positions
10
+ */
11
+ export default function FlipToggle({ value, onChange, labelA, labelB, labelC, variant = 'vertical', positions = 2 }) {
12
+ const isHorizontal = variant === 'horizontal'
13
+
14
+ const handleClick = () => {
15
+ if (positions === 3) {
16
+ onChange((value + 1) % 3)
17
+ } else {
18
+ onChange(!value)
19
+ }
20
+ }
21
+
22
+ const trackW = isHorizontal ? (positions === 3 ? 24 : 16) : 8
23
+ const trackH = isHorizontal ? 8 : (positions === 3 ? 24 : 16)
24
+ const thumbW = isHorizontal ? 8 : 6
25
+ const thumbH = isHorizontal ? 6 : 8
26
+
27
+ let thumbPos
28
+ if (positions === 3) {
29
+ const offsets = [0, 8, 15]
30
+ thumbPos = isHorizontal
31
+ ? { top: 0, left: offsets[value], transition: 'left 0.1s' }
32
+ : { left: 0, top: offsets[value], transition: 'top 0.1s' }
33
+ } else {
34
+ thumbPos = isHorizontal
35
+ ? { top: 0, left: value ? 0 : 7, transition: 'left 0.1s' }
36
+ : { left: 0, top: value ? 0 : 7, transition: 'top 0.1s' }
37
+ }
38
+
39
+ return (
40
+ <div
41
+ onClick={handleClick}
42
+ style={{ display: 'flex', flexDirection: isHorizontal ? 'row' : 'column', alignItems: 'center', gap: 2, cursor: 'pointer', userSelect: 'none' }}
43
+ >
44
+ {labelA && (
45
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
46
+ {labelA}
47
+ </span>
48
+ )}
49
+ <div style={{
50
+ width: trackW,
51
+ height: trackH,
52
+ borderRadius: 4,
53
+ backgroundColor: 'var(--kol-ctl-hw-cap)',
54
+ border: '1px solid var(--kol-ctl-hw-cap-edge)',
55
+ position: 'relative',
56
+ boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.5)',
57
+ }}>
58
+ <div style={{
59
+ width: thumbW,
60
+ height: thumbH,
61
+ borderRadius: 3,
62
+ backgroundColor: 'var(--kol-ctl-hw-on-cap)',
63
+ position: 'absolute',
64
+ ...thumbPos,
65
+ boxShadow: '0 1px 2px rgba(0,0,0,0.3)',
66
+ }} />
67
+ </div>
68
+ {labelB && (
69
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
70
+ {labelB}
71
+ </span>
72
+ )}
73
+ {labelC && (
74
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
75
+ {labelC}
76
+ </span>
77
+ )}
78
+ </div>
79
+ )
80
+ }
@@ -0,0 +1,58 @@
1
+ import { useState, useRef } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+
4
+ const PULSE_MS = 100
5
+
6
+ /**
7
+ * IconButton — the icon-only panel key (kol-monitor's rack, lifted 2026-09-01):
8
+ * 1px border, radius 3, `active` lights the LED-red border and a 15% fill;
9
+ * `momentary` flashes the active style for ~100ms on click — trigger-style
10
+ * actions. Use with `PanelLabel` for a text label.
11
+ *
12
+ * @param {string} icon icon name
13
+ * @param {boolean} active · disabled · momentary
14
+ * @param {Function} onClick
15
+ * @param {string} title
16
+ * @param {number} iconSize (default 10)
17
+ * @param {ElementType} iconComponent icon renderer seam (default kol-icons `Icon`)
18
+ */
19
+ export default function IconButton({ icon, active, onClick, title, momentary, disabled, iconSize = 10, iconComponent: IconCmp = Icon }) {
20
+ const [pulse, setPulse] = useState(false)
21
+ const timeoutRef = useRef(null)
22
+ const isActive = active || pulse
23
+
24
+ const handleClick = (e) => {
25
+ if (disabled) return
26
+ if (momentary) {
27
+ setPulse(true)
28
+ if (timeoutRef.current) clearTimeout(timeoutRef.current)
29
+ timeoutRef.current = setTimeout(() => setPulse(false), PULSE_MS)
30
+ }
31
+ onClick?.(e)
32
+ }
33
+
34
+ return (
35
+ <button
36
+ type="button"
37
+ title={title}
38
+ onClick={handleClick}
39
+ disabled={disabled}
40
+ style={{
41
+ display: 'flex',
42
+ alignItems: 'center',
43
+ justifyContent: 'center',
44
+ padding: 3,
45
+ borderRadius: 3,
46
+ border: '1px solid',
47
+ borderColor: isActive ? 'var(--kol-ctl-led-red)' : 'var(--kol-fg-08)',
48
+ backgroundColor: isActive ? 'color-mix(in srgb, var(--kol-ctl-led-red) 15%, transparent)' : 'transparent',
49
+ cursor: disabled ? 'not-allowed' : 'pointer',
50
+ color: isActive ? 'var(--kol-fg-88)' : 'var(--kol-fg-32)',
51
+ opacity: disabled ? 0.3 : 1,
52
+ transition: pulse ? 'none' : `border-color ${PULSE_MS}ms, background-color ${PULSE_MS}ms, color ${PULSE_MS}ms, opacity ${PULSE_MS}ms`,
53
+ }}
54
+ >
55
+ <IconCmp name={icon} size={iconSize} />
56
+ </button>
57
+ )
58
+ }