diff --git a/README.md b/README.md index 6f3f7a5..17c3eed 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,11 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob' rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log / power / custom tapers. - **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`, - `SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `XYPad`, - `PushButton`, `SegmentDisplay`, `AlphaDisplay` — every color themeable per instance. + `SteppedKnob`, `PanKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter` + (PPM ballistics · dBFS · loudness), `MeterBridge`, `Gauge`, `XYPad`, `PushButton`, + `Button`, `IndicatorLamp`, `LampRow`, `ToggleSwitch`, `SegmentSwitch`, + `TransportButton`, `ScrubField`, `SegmentDisplay`, `AlphaDisplay`, plus + `LabeledField`/`Rack` layout — every color themeable per instance. - **Composable**: `` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`, `KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with `useKnob`. @@ -48,7 +51,7 @@ See `packages/dreamknob/README.md` and the docs app for the full API. The easiest way is the published release on our Forgejo: ```bash -pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz +pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz ``` Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types): diff --git a/apps/docs/src/App.tsx b/apps/docs/src/App.tsx index 09bca6d..1d02c03 100644 --- a/apps/docs/src/App.tsx +++ b/apps/docs/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { AdvancedGuide } from './sections/AdvancedGuide'; import { ApiDocs } from './sections/ApiDocs'; +import { Buttons } from './sections/Buttons'; import { Console } from './sections/Console'; import { Gallery } from './sections/Gallery'; import { GettingStarted } from './sections/GettingStarted'; @@ -26,6 +27,7 @@ export const App: React.FC = () => (
Start Gallery + Buttons Synth Playground Guide @@ -36,6 +38,7 @@ export const App: React.FC = () => ( + diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx index a7664eb..3cffec2 100644 --- a/apps/docs/src/sections/ApiDocs.tsx +++ b/apps/docs/src/sections/ApiDocs.tsx @@ -67,8 +67,9 @@ export const ApiDocs: React.FC = () => ( - - + + + @@ -87,29 +88,74 @@ export const ApiDocs: React.FC = () => ( via pointer capture · every control shows a keyboard-only focus ring.

+

The change contract

+

+ Every way a value can move maps to a ChangeMeta.source, and every + user gesture is bracketed by exactly one onChangeStart /{' '} + onChangeEnd pair — wire undo snapshots to Start and history commits + to End and the ledger always balances. +

+ + + + + + + + + + + + + + + + + +
sourceEmitted byStart/End?onChange cadence
'drag'pointer drags (rotary, linear, track, XY, scrub)yes — one pair per drageach snapped value ('live') or once on release ('release')
'wheel'scroll wheel / trackpadyes — one pair per burst (150–250 ms settle)each notch
'keyboard'arrows, PageUp/Down, Home/Endyes — one pair per presseach step
'reset'double-click resetyesonce
'edit'type-in editors (editable, ScrubField)yesonce, on commit
'api'setValue() from your codeno — programmatic moves are not gestureseach call that lands on a new value
+

+ A cancelled gesture (Esc, or disabled flipping mid-drag) restores the + start value and still fires onChangeEnd, so Start/End counts always + match. onChange never repeats a value within a gesture. +

+

Prebuilt skins

- + - - + + + - - - - - + + + + + + + + + + + + + + + +

- Every knob skin and fader also takes label, showValue,{' '} - unit, format, className,{' '} - style, name plus every core prop above, and forwards a - ref to its root element (VintageKnob has no readout, so it omits{' '} + Every knob skin and fader also takes label, sublabel{' '} + (a second caption line — SEED over world #7),{' '} + labelSize (caption size independent of the dial diameter),{' '} + showValue, unit, format,{' '} + className, style, name plus every core prop + above, and forwards a ref to its root element (VintageKnob has no readout, so it omits{' '} showValue/unit/format; the display components Meter/SegmentDisplay/AlphaDisplay{' '} have their own smaller prop sets). Primitive-drawn parts carry{' '} @@ -118,13 +164,19 @@ export const ApiDocs: React.FC = () => ( label) and every interactive root exposes{' '} data-dragging/data-disabled/data-focus-visible{' '} — style any state or part with plain CSS. <Arc> additionally - accepts gradient (position-anchored color stops). + accepts gradient (position-anchored color stops). Geometry is + self-defending: Meter/LEDFader clamp their internal + padding below breadth 14 and never emit negative dimensions, so + 4-px status slivers render fine.

Theming

Every skin resolves its color and font defaults from the active theme; per-instance - props always win. Use base="light" for light backgrounds. + props always win. Use base="light" for light backgrounds. Any CSS + color works — hex, rgb(), oklch(), even{' '} + var(--accent): gradient and glow math uses hex fast-paths where it + can and falls back to CSS color-mix() everywhere else.

( text: '#fff', label: 'rgba(255,255,255,0.5)', fontMono: '"IBM Plex Mono", monospace', + zoneGood: '#3df2ad', // meter zones: green / amber / red + zoneWarn: '#ffd23e', // used by Meter, MeterBridge, Gauge + zoneHot: '#ff4d6b', // and LEDFader when no zones given + ledGreen: '#3df2ad', // SegmentDisplay default lit color + ledAmber: '#ffb84d', // AlphaDisplay default lit color + panel: 'rgba(255,255,255,0.03)', // Rack / panel chrome + ledWell: '#0b0d0e', // LED display well (LEDKnob/LEDFader/Meter) }} > @@ -189,6 +248,92 @@ function MyKnob() { }`} /> +

Small readouts

+

+ Segment displays keep their proportions down to about{' '} + 11 px tall; below that, raise weight to thicken + the strokes rather than shrinking further. The decimal point and colon render as + bold cells with their own width (not hairlines between digits), so{' '} + 17.5 and 1:23:45 stay readable in a status bar. Widen{' '} + gap when packed tokens like 18.1/24 crowd. Colons and{' '} + $/% are in the charset, so a whole token can live in one + display instead of text beside it. +

+ +

Recipes

+

+ The prop combinations we reach for in real consoles and inspectors. +

+ + +// Angle dial — endless, wraps 0↔360, full-circle travel + + +// Expensive parameter — drag previews, engine hears one change on release + rebuildReverbTail(v)} aria-label="Room size" /> + +// Wide-range filter cutoff — log taper puts the musical range under your thumb + v >= 1000 ? (v / 1000).toFixed(1) + ' kHz' : v + ' Hz'} + aria-label="Cutoff" /> + +// Inspector knob — relative drag (no grab-jump), flick fast / drag slow + + +// One-line inspector row — fader with the readout beside it + + +// Stereo meter — one component, two channels, shared clip LED + + +// Resource gauge — colors itself green → amber → red as load climbs + + +// Self-coloring resource readout — no app-side color math + + +// Aligned inspector rows — share labelWidth so controls line up + + + + + + + +// A declarative channel strip — now with a pan knob + + + + + + + +// A pro dBFS meter — feed amplitude, get ballistics + peak-decay + loudness + + +// A Button as a Radix dropdown trigger (it forwards + composes handlers) + + + + + {/* … */} +`} + /> +

Precision & number handling

Values are snapped to step (anchored at min) and rounded diff --git a/apps/docs/src/sections/Buttons.tsx b/apps/docs/src/sections/Buttons.tsx new file mode 100644 index 0000000..1cf6753 --- /dev/null +++ b/apps/docs/src/sections/Buttons.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { + Button, + PushButton, + TransportButton, + ToggleSwitch, + SegmentSwitch, +} from 'dreamknob'; + +/** Small glyph for a button's leadingIcon (the docs app has no icon dep). */ +const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + +); + +/** The button/switch family, side by side, with a "which do I use?" guide. */ +export const Buttons: React.FC = () => { + const [playing, setPlaying] = React.useState(false); + const [armed, setArmed] = React.useState(false); + const [bypass, setBypass] = React.useState(false); + const [fresh, setFresh] = React.useState(true); + const [mode, setMode] = React.useState(0); + const [fired, setFired] = React.useState(0); + + return ( +

+
+
Buttons & switches
+

Press, latch, or pick

+

+ Five components cover every panel control that isn't a dial. The + only question is what the press means: +

+ +
+ + + + + +
+ +
+ — action, fired ${fired}×`}> + + + + + + + + Bypass + + + Talk + + + + + setPlaying(p => !p)} size={34} /> + setPlaying(false)} size={34} /> + setArmed(a => !a)} size={34} /> + + + + + + +
+ +

+ <Button> is <PushButton mode="action"> — + it never latches, shows a press-down, and calls onClick. It has{' '} + no LED by default — an action has no state to indicate, unlike a + toggle. Add led="dot" for a corner dot that flashes on + press, or led="strip" for the full strip. +

+
+
+ ); +}; + +const GuideRow: React.FC<{ code: string; when: string; eg: string }> = ({ code, when, eg }) => ( +
+ {code} + {when} + {eg} +
+); + +const Demo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+
{label}
+
{children}
+
+); diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx index 774e08e..2158ee2 100644 --- a/apps/docs/src/sections/Gallery.tsx +++ b/apps/docs/src/sections/Gallery.tsx @@ -2,23 +2,35 @@ import React from 'react'; import { AlphaDisplay, Arc, + Button, DreamknobProvider, Fader, FlatKnob, + Gauge, ImageKnob, Knob, KnobValue, + IndicatorLamp, + LabeledField, + LampRow, LEDFader, LEDKnob, MetalKnob, Meter, + MeterBridge, NeonKnob, + PanKnob, Pointer, PushButton, + Rack, RubberKnob, + ScrubField, + SegmentSwitch, SegmentDisplay, SteppedKnob, Ticks, + ToggleSwitch, + TransportButton, VintageKnob, XYPad, logTaper, @@ -117,19 +129,265 @@ const MeterDemo: React.FC = () => { }, 90); return () => clearInterval(id); }, []); - const zones = [ - { upTo: 0.72, color: '#3df2ad' }, - { upTo: 0.9, color: '#ffd23e' }, - { upTo: 1, color: '#ff4d6b' }, - ]; return ( -
- - +
+ +
); }; +const LampSwitchDemo: React.FC = () => { + const [mode, setMode] = React.useState(1); + const [armed, setArmed] = React.useState(true); + return ( +
+ +
+ + + + ⌗ + +
+
+ ); +}; + +const CommitModeDemo: React.FC = () => { + const [commits, setCommits] = React.useState(0); + return ( +
+ setCommits(n => n + 1)} + aria-label="Release commit demo" + /> +
+ + + onChange calls + +
+
+ ); +}; + +const RESOURCE_ZONES = [ + { upTo: 0.75, color: '#3df2ad' }, + { upTo: 0.9, color: '#ffd23e' }, + { upTo: 1, color: '#ff4d6b' }, +]; + +const GaugeDemo: React.FC = () => { + const [load, setLoad] = React.useState<[number, number]>([38, 61]); + React.useEffect(() => { + let t = 0; + const id = setInterval(() => { + t += 0.12; + setLoad([ + Math.max(2, Math.min(100, 45 + Math.sin(t) * 30 + Math.random() * 12)), + Math.max(2, Math.min(100, 62 + Math.sin(t * 0.7 + 2) * 26 + Math.random() * 10)), + ]); + }, 140); + return () => clearInterval(id); + }, []); + return ( +
+ + +
+ ); +}; + +const SmallReadoutDemo: React.FC = () => { + const [t, setT] = React.useState(0); + React.useEffect(() => { + const id = setInterval(() => setT(v => v + 1), 1000); + return () => clearInterval(id); + }, []); + const hh = String(Math.floor(t / 3600) % 100).padStart(2, '0'); + const mm = String(Math.floor(t / 60) % 60).padStart(2, '0'); + const ss = String(t % 60).padStart(2, '0'); + return ( +
+
+ + +
+
+ + + +
+
+ ); +}; + +const PanelDemo: React.FC = () => { + const [fresh, setFresh] = React.useState(true); + const [mode, setMode] = React.useState(0); + const [playing, setPlaying] = React.useState(false); + const [armed, setArmed] = React.useState(false); + return ( + +
+ + +
+
+ setPlaying(p => !p)} size={34} /> + setPlaying(false)} size={34} /> + setArmed(a => !a)} size={34} /> + { setPlaying(false); setArmed(false); }} size={34} /> +
+ + + +
+ + +
+ +
+ ); +}; + +const DbMeterDemo: React.FC = () => { + // Drive with linear amplitude; the meter converts to dBFS internally. + const [amp, setAmp] = React.useState<[number, number]>([0.3, 0.28]); + React.useEffect(() => { + let t = 0; + const id = setInterval(() => { + t += 0.11; + const spike = Math.random() < 0.05 ? 0.4 : 0; + setAmp([ + Math.max(0.001, Math.min(1.2, 0.34 + Math.sin(t) * 0.22 + Math.random() * 0.16 + spike)), + Math.max(0.001, Math.min(1.2, 0.32 + Math.sin(t * 1.25 + 1) * 0.22 + Math.random() * 0.16 + spike)), + ]); + }, 90); + return () => clearInterval(id); + }, []); + return ( +
+ + +
+ ); +}; + +const PanDemo: React.FC = () => ( +
+ + +
+); + +const LightThemeDemo: React.FC = () => { + const [play, setPlay] = React.useState(false); + const [mute, setMute] = React.useState(true); + return ( + +
+
+ + + +
+
+ + Mute + + + setPlay(p => !p)} size={32} /> + +
+
+
+ ); +}; + const ImageKnobDemo: React.FC = () => { const src = useGeneratedStrip(); if (!src) return null; @@ -343,10 +601,49 @@ export const Gallery: React.FC = () => (
- + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + ( ) · peer deps: react >= 18, react-dom >= 18`} + code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`} />

Installs straight from the{' '} - v1.0.0 release + v1.1.4 release {' '} on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No CSS file to import — everything is SVG and inline styles. diff --git a/apps/docs/src/sections/Hero.tsx b/apps/docs/src/sections/Hero.tsx index f1a37a0..4669d40 100644 --- a/apps/docs/src/sections/Hero.tsx +++ b/apps/docs/src/sections/Hero.tsx @@ -25,10 +25,10 @@ export const Hero: React.FC = () => { range, any decimal precision, and colors you fully control.

- pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz + pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz + ); + }, +); diff --git a/packages/dreamknob/src/skins/LEDFader.tsx b/packages/dreamknob/src/skins/LEDFader.tsx index a57f78a..6b68c4e 100644 --- a/packages/dreamknob/src/skins/LEDFader.tsx +++ b/packages/dreamknob/src/skins/LEDFader.tsx @@ -15,6 +15,13 @@ export interface LEDFaderProps extends Omit { orientation?: 'vertical' | 'horizontal'; /** Travel length in px. Default: 160. */ length?: number; + /** + * Stretch along the travel axis to fill the container (horizontal fills + * width, vertical fills height) instead of a fixed `length` — the same + * responsive behavior as `Fader`, so faders in resizable dock panels size + * themselves instead of needing wrapper measurement. + */ + fill?: boolean; /** Cross-axis size in px. Default: 34. */ breadth?: number; /** Number of LED segments. Default: 24. */ @@ -38,8 +45,10 @@ export interface LEDFaderProps extends Omit { showValue?: boolean; /** Digit cells in the readout. Default: 4. */ digits?: number; - /** Decimals in the readout (defaults to the control's decimals, capped at 1). */ + /** Decimals in the readout. Defaults to the control's decimals — size `digits` to fit. */ displayDecimals?: number; + /** Unit suffix beside the readout, e.g. "dB". */ + unit?: string; labelColor?: string; /** Click the readout to type an exact value. */ editable?: boolean; @@ -55,18 +64,20 @@ export interface LEDFaderProps extends Omit { export const LEDFader = React.forwardRef(function LEDFader({ orientation = 'vertical', length = 160, + fill = false, breadth = 34, segments = 24, color: colorProp, zones, offOpacity = 0.13, glow = true, - faceColor = '#0b0d0e', + faceColor: faceColorProp, focusRing, label, showValue = true, digits = 4, displayDecimals, + unit, labelColor, editable = false, parseValue, @@ -77,7 +88,8 @@ export const LEDFader = React.forwardRef(function }, ref) { const [editing, setEditing] = React.useState(false); const theme = useKnobTheme(); - const color = colorProp ?? theme.accent ?? '#3df2ad'; + const color = colorProp ?? theme.accent; + const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e'; const ring = focusRing ?? theme.focusRing; const vertical = orientation === 'vertical'; const pad = 5; @@ -85,16 +97,28 @@ export const LEDFader = React.forwardRef(function ...core, interaction: vertical ? 'track-vertical' : 'track-horizontal', trackInset: pad, + getAriaValueText: core.getAriaValueText ?? (unit ? v => `${v} ${unit}` : undefined), }); const id = React.useId(); const glowId = `${id}-glow`; const w = vertical ? breadth : length; const h = vertical ? length : breadth; - const zoneList: readonly LEDFaderZone[] = React.useMemo( - () => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]), - [zones, color], - ); + // Fill stretches the travel axis (via preserveAspectRatio='none'); the cross + // axis stays at `breadth`. The LED geometry is computed in the fixed viewBox + // and scaled to the element — pointer mapping measures the real element, so + // interaction stays correct at any rendered width. + const ctrlW = fill && !vertical ? '100%' : w; + const ctrlH = fill && vertical ? '100%' : h; + const zoneList: readonly LEDFaderZone[] = React.useMemo(() => { + if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo); + if (color) return [{ upTo: 1, color }]; + return [ + { upTo: 0.72, color: theme.zoneGood }, + { upTo: 0.9, color: theme.zoneWarn }, + { upTo: 1, color: theme.zoneHot }, + ]; + }, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]); const zoneFor = (t: number): string => (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; @@ -142,12 +166,15 @@ export const LEDFader = React.forwardRef(function return (
@@ -171,22 +198,29 @@ export const LEDFader = React.forwardRef(function initial={String(knob.value)} onCommit={raw => { const parsed = (parseValue ?? defaultParseValue)(raw); - if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed); + if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed); setEditing(false); }} onCancel={() => setEditing(false)} style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }} /> ) : ( - + + + {unit && ( + + {unit} + + )} + )}
)} @@ -199,14 +233,22 @@ export const LEDFader = React.forwardRef(function }} style={{ ...knob.bind.style, - width: w, - height: h, - display: 'inline-flex', + opacity: undefined, // the outer wrapper owns disabled dimming + width: ctrlW, + height: ctrlH, + display: fill ? 'flex' : 'inline-flex', borderRadius: 6, ...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined), }} > -
+ {lamps.map((lamp, i) => { + const accent = lamp.color ?? theme.accent ?? theme.zoneGood; + const dot = ( +
+ ); +}; diff --git a/packages/dreamknob/src/skins/MetalKnob.tsx b/packages/dreamknob/src/skins/MetalKnob.tsx index 46ff198..e45afa9 100644 --- a/packages/dreamknob/src/skins/MetalKnob.tsx +++ b/packages/dreamknob/src/skins/MetalKnob.tsx @@ -33,6 +33,8 @@ export const MetalKnob = React.forwardRef(functi showArc = true, tickCount = 21, label, + sublabel, + labelSize, showValue = false, unit, format, @@ -61,7 +63,7 @@ export const MetalKnob = React.forwardRef(functi const indicator = indicatorColor ?? (silver ? '#1b1c21' : color); return ( - + format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}> @@ -137,7 +139,8 @@ export const MetalKnob = React.forwardRef(functi color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.44} - fontSize={size * 0.1} + fontSize={labelSize ?? size * 0.1} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/Meter.tsx b/packages/dreamknob/src/skins/Meter.tsx index 2ccf9a8..ba348be 100644 --- a/packages/dreamknob/src/skins/Meter.tsx +++ b/packages/dreamknob/src/skins/Meter.tsx @@ -4,116 +4,211 @@ import type { Taper } from '../core/math'; import { useKnobTheme } from '../core/theme'; import type { LEDFaderZone } from './LEDFader'; import { SegmentDisplay } from '../digital/SegmentDisplay'; +import { ampToDb, useMeterEngine, type Ballistics } from './meterEngine'; export interface MeterProps { - /** Level to display. The meter is read-only — always controlled. */ - value: number; + /** + * Level to display — a single number, or an array for a multi-channel + * meter (e.g. `[l, r]` for stereo). The meter is read-only. With + * `scale="db"` these are linear amplitudes (0..1); otherwise they are in the + * `min`..`max` domain directly. + */ + value: number | readonly number[]; min?: number; max?: number; taper?: Taper; + /** + * 'linear' (default) shows `value` in the `min`..`max` domain. 'db' treats + * `value`/`clipValue` as linear amplitude and displays dBFS; `min`/`max` + * then default to -60 / 0 dB. + */ + scale?: 'linear' | 'db'; orientation?: 'vertical' | 'horizontal'; /** Travel length in px. Default: 160. */ length?: number; - /** Cross-axis size in px. Default: 16. */ + /** + * Scale to fill the container instead of a fixed px footprint. The SVG keeps + * its `length`×`breadth` aspect ratio; a horizontal meter fills width, a + * vertical one fills height. Great for resizable dock panels. + */ + fill?: boolean; + /** Dim the meter uniformly (matches disabled controls). */ + disabled?: boolean; + /** Cross-axis size in px PER CHANNEL. Minimum useful: ~6. Default: 16. */ breadth?: number; /** Number of LED segments. Default: 28. */ segments?: number; - /** LED color when no zones are given. */ + /** LED color when no zones are given. Defaults to the theme's zone colors. */ color?: string; - /** Meter zones, e.g. green / yellow / red by normalized position. */ + /** Meter zones by normalized position. Defaults to theme zoneGood/Warn/Hot. */ zones?: readonly LEDFaderZone[]; /** Opacity of unlit segments. Default: 0.1. */ offOpacity?: number; + /** + * Attack/decay ballistics — smooth the displayed level like a PPM meter. + * `true` uses ~5 ms attack / ~350 ms decay; pass `{ attack, decay }` (ms) to + * tune. Default: off (the level follows the value directly). + */ + ballistics?: boolean | Ballistics; /** * Peak hold time in ms — the highest recent segment stays lit and falls * back after this long. `false` disables. Default: 1200. */ peakHold?: number | false; + /** + * After `peakHold`, let the peak marker fall at this many normalized units + * per second (a decay tail) instead of snapping back. Requires the rAF + * engine (also enabled by `ballistics`/`integrated`). + */ + peakDecay?: number; /** Peak indicator color. Defaults to the peak's zone color. */ peakColor?: string; - /** Dedicated clip LED at the hot end of the meter. Default: true. */ + /** + * Show a time-windowed loudness estimate (LUFS-ish on the 'db' scale — a + * mean of channel power over `integrationWindow`, not a full BS.1770 + * measurement). Rendered as a small secondary readout + a marker line. + */ + integrated?: boolean; + /** Loudness integration window in ms. Default: 3000. */ + integrationWindow?: number; + /** Color for the integrated-loudness readout and marker line. */ + loudnessColor?: string; + /** Faint reference gridlines across the meter. Default: on for scale='db'. */ + showScale?: boolean; + /** Reference marks in the value domain. Defaults to dBFS marks for 'db'. */ + referenceTicks?: readonly number[]; + /** Dedicated clip LED at the hot end (shared across channels). Default: true. */ showClip?: boolean; - /** Value at/above which the clip LED lights. Default: `max`. */ + /** Value (in the display domain — dB for scale='db') at/above which it lights. Default: `max`. */ clipThreshold?: number; /** - * Signal used for clip detection, when it differs from the displayed - * level — e.g. show RMS on the bar but clip on sample peaks. Defaults - * to `value`. + * Signal(s) used for clip detection when different from the displayed + * level — e.g. show RMS on the bars but clip on sample peaks. Same shape + * as `value`. Defaults to `value`. */ - clipValue?: number; + clipValue?: number | readonly number[]; /** * How long the clip LED stays lit in ms, or 'latch' to stay lit until * clicked. Default: 1500. */ clipHold?: number | 'latch'; clipColor?: string; - /** Fired when the signal first crosses the clip threshold. */ + /** Fired when any channel first crosses the clip threshold. */ onClip?: (value: number) => void; /** Panel color behind the LEDs. */ faceColor?: string; label?: string; - /** Show a seven-segment readout. Default: false. */ + /** Show a seven-segment readout (of the hottest channel). Default: false. */ showValue?: boolean; digits?: number; displayDecimals?: number; + /** Unit suffix beside the readout, e.g. "dB". */ + unit?: string; labelColor?: string; className?: string; style?: React.CSSProperties; 'aria-label'?: string; } +const asArray = (v: number | readonly number[]): readonly number[] => + Array.isArray(v) ? v : [v as number]; + +const DEFAULT_DB_TICKS = [0, -6, -12, -18, -24, -36, -48]; + /** - * Read-only LED level meter with peak hold — the display-side sibling of - * LEDFader for VU/output metering. + * Read-only LED level meter with peak hold and a latching clip LED — pass an + * array of values for stereo/multi-channel bars sharing one clip indicator. + * Optional PPM ballistics, a peak-decay tail, a dBFS scale and a windowed + * loudness readout (all opt-in). */ export const Meter: React.FC = ({ value, - min = 0, - max = 100, + min: minProp, + max: maxProp, taper = linearTaper, + scale = 'linear', orientation = 'vertical', length = 160, + fill = false, + disabled = false, breadth = 16, segments = 28, - color = '#3df2ad', + color, zones, offOpacity = 0.1, + ballistics, peakHold = 1200, + peakDecay, peakColor, + integrated = false, + integrationWindow = 3000, + loudnessColor, + showScale, + referenceTicks, showClip = true, clipThreshold, clipValue, clipHold = 1500, clipColor = '#ff2b39', onClip, - faceColor = '#0b0d0e', + faceColor: faceColorProp, label, showValue = false, digits = 4, displayDecimals = 1, + unit, labelColor, className, style, ...aria }) => { const theme = useKnobTheme(); + const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e'; const vertical = orientation === 'vertical'; - const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1); + const dbScale = scale === 'db'; + const min = minProp ?? (dbScale ? -60 : 0); + const max = maxProp ?? (dbScale ? 0 : 100); - // Peak: track the highest recent level, fall back after `peakHold` ms. - const [peak, setPeak] = React.useState(n); + const values = asArray(value); + const channels = values.length; + // Value-domain per channel (dB for scale='db', else the value itself). + const domain = values.map(v => (dbScale ? ampToDb(v) : v)); + const targetNs = domain.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1)); + + // rAF engine (ballistics / peak-decay / loudness). Null on the legacy path. + const animated = !!ballistics || peakDecay != null || integrated; + const engine = useMeterEngine(targetNs, domain, { + enabled: animated, + ballistics, + peakHold, + peakDecay: peakDecay ?? null, + integrated, + integrationWindow, + scale, + }); + const levels = animated && engine ? engine.levels : targetNs; + + // Legacy timer-based peaks (only when the engine is off). + const [legacyPeaks, setLegacyPeaks] = React.useState(targetNs); React.useEffect(() => { - if (peakHold === false) return; - if (n >= peak) { - setPeak(n); - return; - } - const t = setTimeout(() => setPeak(n), peakHold); + if (animated || peakHold === false) return; + setLegacyPeaks(prev => { + const rose = targetNs.map((n, i) => n >= (prev[i] ?? 0)); + if (rose.every(Boolean) || prev.length !== targetNs.length) + return targetNs.map((n, i) => Math.max(n, prev[i] ?? 0)); + return prev.map((p, i) => Math.max(p, targetNs[i] ?? 0)); + }); + const t = setTimeout(() => setLegacyPeaks(targetNs), peakHold); return () => clearTimeout(t); - }, [n, peak, peakHold]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(targetNs), peakHold, animated]); + const peaks = animated && engine ? engine.peaks : legacyPeaks; - // Clip: latch a dedicated LED when the raw (unclamped) signal reaches the - // threshold. Hold for `clipHold` ms, or until clicked in 'latch' mode. + // Clip: latch when any channel's raw (unclamped) signal reaches threshold. + const clipSignals = (clipValue !== undefined ? asArray(clipValue) : values).map(v => + dbScale ? ampToDb(v) : v, + ); + const hottest = Math.max(...clipSignals); const [clipped, setClipped] = React.useState(false); const clipTimer = React.useRef>(); const wasOver = React.useRef(false); @@ -121,10 +216,9 @@ export const Meter: React.FC = ({ onClipRef.current = onClip; React.useEffect(() => { if (!showClip) return; - const signal = clipValue ?? value; - const over = signal >= (clipThreshold ?? max) - 1e-9; + const over = hottest >= (clipThreshold ?? max) - 1e-9; if (over) { - if (!wasOver.current) onClipRef.current?.(signal); + if (!wasOver.current) onClipRef.current?.(hottest); setClipped(true); if (clipHold !== 'latch') { clearTimeout(clipTimer.current); @@ -132,93 +226,199 @@ export const Meter: React.FC = ({ } } wasOver.current = over; - }, [value, clipValue, showClip, clipThreshold, max, clipHold]); + }, [hottest, showClip, clipThreshold, max, clipHold]); React.useEffect(() => () => clearTimeout(clipTimer.current), []); - const pad = 4; - const w = vertical ? breadth : length; - const h = vertical ? length : breadth; - const zoneList: readonly LEDFaderZone[] = React.useMemo( - () => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]), - [zones, color], - ); + const zoneList: readonly LEDFaderZone[] = React.useMemo(() => { + if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo); + if (color) return [{ upTo: 1, color }]; + return [ + { upTo: 0.72, color: theme.zoneGood }, + { upTo: 0.9, color: theme.zoneWarn }, + { upTo: 1, color: theme.zoneHot }, + ]; + }, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]); const zoneFor = (t: number): string => (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; - const lit = Math.round(n * segments); - const peakIdx = - peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1; - // Reserve room at the hot end for the clip LED (7px LED + 3px gap). + // Geometry — clamped so thin meters degrade gracefully instead of + // producing negative SVG rects. pad shrinks below breadth 14. + const pad = breadth < 14 ? 2 : 4; + const cross = Math.max(1, breadth - pad * 2); + const chGap = channels > 1 ? 3 : 0; + const along = length; // travel axis + const across = channels * breadth + (channels - 1) * chGap; + const w = vertical ? across : along; + const h = vertical ? along : across; + const clipLed = 7; const clipSpan = showClip ? clipLed + 3 : 0; - const slot = (length - pad * 2 - clipSpan) / segments; + const innerLen = along - pad * 2 - clipSpan; + const slot = Math.max(1, innerLen / segments); const gap = Math.min(2.5, slot * 0.35); - const cross = breadth - pad * 2; + const segSize = Math.max(0.5, slot - gap); - const leds: React.ReactNode[] = []; - for (let i = 0; i < segments; i++) { - const zone = zoneFor((i + 1) / segments); - const isPeak = i === peakIdx && i >= lit; - const on = i < lit || isPeak; - const fill = isPeak ? (peakColor ?? zone) : zone; - const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity }; - leds.push( - vertical ? ( - - ) : ( - - ), - ); + // Position (px along the travel axis) of a normalized value, measured from + // the cold end. + const posOf = (nrm: number) => + vertical ? along - pad - nrm * innerLen : pad + nrm * innerLen; + + const bars: React.ReactNode[] = []; + for (let c = 0; c < channels; c++) { + const offset = c * (breadth + chGap) + pad; + const lit = Math.round((levels[c] ?? 0) * segments); + const peakIdx = + peakHold !== false && (peaks[c] ?? 0) > 0 + ? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1) + : -1; + for (let i = 0; i < segments; i++) { + const zone = zoneFor((i + 1) / segments); + const isPeak = i === peakIdx && i >= lit; + const on = i < lit || isPeak; + const barFill = isPeak ? (peakColor ?? zone) : zone; + const common = { rx: 1.2, fill: barFill, opacity: on ? 1 : offOpacity }; + bars.push( + vertical ? ( + + ) : ( + + ), + ); + } } + // Reference gridlines (value domain -> position across the bar area). + const ticks = referenceTicks ?? (dbScale ? DEFAULT_DB_TICKS : []); + const drawScale = (showScale ?? dbScale) && ticks.length > 0; + const scaleLines: React.ReactNode[] = []; + if (drawScale) { + for (const tv of ticks) { + if (tv < min || tv > max) continue; + const nrm = clamp(taper.toNormalized(clamp(tv, min, max), min, max), 0, 1); + const p = posOf(nrm); + scaleLines.push( + vertical ? ( + + ) : ( + + ), + ); + } + } + + // Integrated-loudness marker line. + const loud = loudnessColor ?? theme.zoneWarn; + let loudnessLine: React.ReactNode = null; + if (integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated)) { + const nrm = clamp(taper.toNormalized(clamp(engine.integrated, min, max), min, max), 0, 1); + if (nrm > 0.001) { + const p = posOf(nrm); + loudnessLine = vertical ? ( + + ) : ( + + ); + } + } + + const shownDomain = domain.length ? domain.map(d => clamp(d, min, max)) : [min]; + const hottestShown = Math.max(...shownDomain); + const readoutColor = zoneFor(Math.max(...levels, 0)); + return (
- {showValue && ( -
- + {(showValue || (integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated))) && ( + // Each readout is its own stack: the value with its unit centered + // directly underneath — peak above, loudness below. +
+ {showValue && ( +
+ + {unit && ( + + {unit} + + )} +
+ )} + {integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated) && ( +
+ + + {dbScale ? 'LUFS' : 'AVG'} + +
+ )}
)} -
+ {showPeakText && ( +
+ {peaks.map((p, i) => ( + + {Math.max(p, min) <= min ? '—' : Math.min(p, max).toFixed(peakTextDecimals)} + + ))} +
+ )} + +
+ {channels.map(c => ( + + {c.label} + + ))} +
+ {label && ( + + {label} + + )} +
+ ); +}; diff --git a/packages/dreamknob/src/skins/NeonKnob.tsx b/packages/dreamknob/src/skins/NeonKnob.tsx index fb522cb..4223635 100644 --- a/packages/dreamknob/src/skins/NeonKnob.tsx +++ b/packages/dreamknob/src/skins/NeonKnob.tsx @@ -23,6 +23,8 @@ export const NeonKnob = React.forwardRef(function labelColor, arcFrom = 'min', label, + sublabel, + labelSize, showValue = true, unit, format, @@ -36,7 +38,7 @@ export const NeonKnob = React.forwardRef(function const id = React.useId(); const glowId = `${id}-glow`; return ( - + format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}> (function color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.45} - fontSize={size * 0.1} + fontSize={labelSize ?? size * 0.1} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/PanKnob.tsx b/packages/dreamknob/src/skins/PanKnob.tsx new file mode 100644 index 0000000..3274329 --- /dev/null +++ b/packages/dreamknob/src/skins/PanKnob.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { FlatKnob, type FlatKnobProps } from './FlatKnob'; + +export interface PanKnobProps extends Omit { + /** + * Readout precision for the L/R amount. Default: 0 (whole numbers). + * The formatter shows `C` at center, `L` left, `R` right. + */ + panDecimals?: number; +} + +/** + * A stereo pan control — a bipolar knob that fills from the center, snaps to a + * center detent, and reads out `L` / `C` / `R`. Defaults to a −50…50 + * range centered at 0; pass `min`/`max` for other conventions (e.g. −1…1). + */ +export const PanKnob = React.forwardRef(function PanKnob( + { + min = -50, + max = 50, + defaultValue, + origin, + detents, + detentSize, + format, + label = 'Pan', + panDecimals = 0, + ...rest + }, + ref, +) { + const center = (min + max) / 2; + const panFormat = React.useMemo( + () => + format ?? + ((v: number) => { + const amount = Math.abs(v - center); + if (amount < Math.pow(10, -panDecimals) / 2) return 'C'; + const n = amount.toFixed(panDecimals); + return v < center ? `L${n}` : `R${n}`; + }), + [format, center, panDecimals], + ); + + return ( + + ); +}); diff --git a/packages/dreamknob/src/skins/PushButton.tsx b/packages/dreamknob/src/skins/PushButton.tsx index 005bacc..a3cf560 100644 --- a/packages/dreamknob/src/skins/PushButton.tsx +++ b/packages/dreamknob/src/skins/PushButton.tsx @@ -1,17 +1,37 @@ import * as React from 'react'; -import { useKnobTheme } from '../core/theme'; +import { panelButtonChrome, useKnobTheme } from '../core/theme'; -export interface PushButtonProps { +/** Call every handler in order (skipping undefined) — for merging our own + * handlers with consumer/Radix-injected ones on the same event. */ +const chain = + (...fns: Array<((e: E) => void) | undefined>) => + (e: E) => { + for (const fn of fns) if (typeof fn === 'function') fn(e); + }; + +export interface PushButtonProps + extends Omit, 'onChange' | 'onClick'> { /** Controlled pressed state. Pair with `onChange`. */ pressed?: boolean; defaultPressed?: boolean; onChange?: (pressed: boolean) => void; - /** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */ - mode?: 'toggle' | 'momentary'; + /** + * 'toggle' latches; 'momentary' is on only while held; 'action' is a normal + * click-to-fire command button — it never latches, shows a press-down + a + * brief LED flash, and calls `onClick`. Default: 'toggle'. + */ + mode?: 'toggle' | 'momentary' | 'action'; + /** Fired on activation in `action` mode (click, Enter, or Space). */ + onClick?: () => void; /** LED / active color. */ color?: string; - /** Show the LED strip. Default: true. */ - led?: boolean; + /** + * LED style: 'strip' (above the caption), 'dot' (small corner dot — scales + * down better for icon-only toggles), or false for none. `true` is an alias + * for 'strip'. Defaults to 'strip' for toggle/momentary (they indicate a + * state) and to false for `mode="action"` (a command has no state to show). + */ + led?: boolean | 'strip' | 'dot'; /** Button height in px. Default: 36. */ size?: number; /** Explicit width; defaults to content. */ @@ -19,11 +39,12 @@ export interface PushButtonProps { disabled?: boolean; /** Render a hidden form input carrying "on"/"off". */ name?: string; + /** Icon rendered before the caption (e.g. a lucide glyph), tinted to the LED. */ + leadingIcon?: React.ReactNode; /** Caption, e.g. "MUTE". */ children?: React.ReactNode; className?: string; style?: React.CSSProperties; - 'aria-label'?: string; } /** Studio panel button with an LED strip — mute/solo/bypass in matching style. */ @@ -33,35 +54,69 @@ export const PushButton = React.forwardRef( pressed, defaultPressed = false, onChange, + onClick, mode = 'toggle', color, - led = true, + led: ledProp, size = 36, width, disabled = false, name, + leadingIcon, children, className, style, - ...aria + ...rest }, ref, ) { const theme = useKnobTheme(); const accent = color ?? theme.accent ?? '#4cc2ff'; + // The LED indicates a persistent/held state, so it defaults on for + // toggle/momentary but off for `action` (a command has no state). + const led = ledProp ?? (mode === 'action' ? false : true); const isControlled = pressed !== undefined; const [internal, setInternal] = React.useState(defaultPressed); const [focusVisible, setFocusVisible] = React.useState(false); - const on = isControlled ? (pressed as boolean) : internal; + // Action mode is stateless: a transient `held` (pointer down) drives the + // press-down look and a short `flash` lights the LED on activation. + const [held, setHeld] = React.useState(false); + const [flash, setFlash] = React.useState(false); + const flashTimer = React.useRef>(); + React.useEffect(() => () => clearTimeout(flashTimer.current), []); + const latched = isControlled ? (pressed as boolean) : internal; + const on = mode === 'action' ? held : latched; // press-down / background inset + const ledOn = mode === 'action' ? held || flash : latched; + const chrome = panelButtonChrome(theme.scheme, on); + // Unlit LED tint: faint white on dark chrome, faint black on light. + const ledOff = theme.scheme === 'light' ? 'rgba(0,0,0,0.13)' : 'rgba(255,255,255,0.09)'; const set = (next: boolean) => { - if (next === on) return; + if (next === latched) return; if (!isControlled) setInternal(next); onChange?.(next); }; + const fire = () => { + onClick?.(); + setFlash(true); + clearTimeout(flashTimer.current); + flashTimer.current = setTimeout(() => setFlash(false), 160); + }; + const momentaryProps = - mode === 'momentary' + mode === 'action' + ? { + onClick: fire, + onPointerDown: (e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + setHeld(true); + }, + onPointerUp: () => setHeld(false), + onPointerCancel: () => setHeld(false), + onPointerLeave: () => setHeld(false), + } + : mode === 'momentary' ? { onPointerDown: (e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); @@ -76,27 +131,40 @@ export const PushButton = React.forwardRef( if (e.key === ' ' || e.key === 'Enter') set(false); }, } - : { onClick: () => set(!on) }; + : { onClick: () => set(!latched) }; + + // Merge our own handlers with any consumer/Radix-injected ones of the same + // name (from {...rest}), so the button works as a Radix `asChild` trigger: + // the injected onPointerDown/onKeyDown/data-state/aria-* all reach the DOM. + const restHandlers = rest as Record void) | undefined>; + const composedHandlers = Object.fromEntries( + Object.entries(momentaryProps).map(([k, fn]) => [ + k, + chain(restHandlers[k], fn as (e: unknown) => void), + ]), + ); + const handleFocus = chain(restHandlers.onFocus, (e: React.FocusEvent) => { + try { + setFocusVisible(e.currentTarget.matches(':focus-visible')); + } catch { + setFocusVisible(true); + } + }); + const handleBlur = chain(restHandlers.onBlur, () => setFocusVisible(false)); return ( ); }, ); + +export interface ButtonProps + extends Omit { + onClick?: () => void; +} + +/** + * A normal click-to-fire command button in the studio-panel style — the + * non-latching sibling of `PushButton`. Use it for actions (Regenerate, + * Add, Apply, Refresh). It's `PushButton` locked to `mode="action"`. Plain by + * default (no LED — an action has no state); pass `led="dot"` for a corner dot + * that flashes on press, or `led="strip"` for the full strip. + */ +export const Button = React.forwardRef( + function Button({ led, ...rest }, ref) { + return ; + }, +); diff --git a/packages/dreamknob/src/skins/RubberKnob.tsx b/packages/dreamknob/src/skins/RubberKnob.tsx index bd70acc..1f1cc09 100644 --- a/packages/dreamknob/src/skins/RubberKnob.tsx +++ b/packages/dreamknob/src/skins/RubberKnob.tsx @@ -25,6 +25,8 @@ export const RubberKnob = React.forwardRef(func labelColor, arcFrom = 'min', label, + sublabel, + labelSize, showValue = false, unit, format, @@ -42,7 +44,7 @@ export const RubberKnob = React.forwardRef(func const bodyR = size / 2 - size * 0.17; return ( - + format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}> @@ -103,7 +105,8 @@ export const RubberKnob = React.forwardRef(func color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.44} - fontSize={size * 0.1} + fontSize={labelSize ?? size * 0.1} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/ScrubField.tsx b/packages/dreamknob/src/skins/ScrubField.tsx new file mode 100644 index 0000000..8bc2fc3 --- /dev/null +++ b/packages/dreamknob/src/skins/ScrubField.tsx @@ -0,0 +1,294 @@ +import * as React from 'react'; +import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math'; +import { useKnobTheme } from '../core/theme'; +import type { ChangeMeta, ChangeSource } from '../core/types'; +import { defaultParseValue } from '../components/ValueInput'; + +export interface ScrubFieldProps { + /** Controlled value. Pair with `onChange`. */ + value?: number; + defaultValue?: number; + min?: number; + max?: number; + /** Increment per scrub pixel-step / wheel notch / arrow key. Default: 1. */ + step?: number; + /** Decimal places of emitted values. Defaults to the precision of `step`. */ + decimals?: number; + onChange?: (value: number, meta: ChangeMeta) => void; + onChangeStart?: (value: number, meta: ChangeMeta) => void; + onChangeEnd?: (value: number, meta: ChangeMeta) => void; + /** The scrub handle text — drag it horizontally to adjust. */ + label?: string; + /** Unit suffix shown after the field, e.g. "px". */ + unit?: string; + /** Horizontal pixels per step while scrubbing. Default: 2. */ + scrubSensitivity?: number; + /** Step multiplier while Shift is held (scrub + wheel). Default: 0.1. */ + fineMultiplier?: number; + /** Custom parser for typed input. Default accepts "1.2k", commas, units. */ + parseValue?: (text: string) => number | null; + /** Input width in ch. Default: 6. */ + widthCh?: number; + accentColor?: string; + disabled?: boolean; + /** Render a hidden form input carrying the committed value. */ + name?: string; + className?: string; + style?: React.CSSProperties; + 'aria-label'?: string; +} + +/** + * A compact numeric field with After-Effects-style label scrubbing: drag the + * label for relative adjustment, click the field to type (draft held until + * Enter/blur, Escape reverts). The spinbox replacement for values where + * typing dominates but scrubbing should still work. + */ +export const ScrubField = React.forwardRef( + function ScrubField( + { + value, + defaultValue, + min = -Infinity, + max = Infinity, + step = 1, + decimals, + onChange, + onChangeStart, + onChangeEnd, + label, + unit, + scrubSensitivity = 2, + fineMultiplier = 0.1, + parseValue, + widthCh = 6, + accentColor, + disabled = false, + name, + className, + style, + ...aria + }, + ref, + ) { + const theme = useKnobTheme(); + const accent = accentColor ?? theme.accent ?? '#4cc2ff'; + const dec = decimals ?? (step > 0 ? decimalsFromStep(step) : 3); + + const constrain = React.useCallback( + (raw: number) => { + let v = clamp(raw, min, max); + if (step > 0 && Number.isFinite(min)) v = clamp(snapToStep(v, step, min), min, max); + else if (step > 0) v = Math.round(v / step) * step; + return roundTo(v, dec); + }, + [min, max, step, dec], + ); + + const isControlled = value !== undefined; + const [internal, setInternal] = React.useState(() => constrain(defaultValue ?? (Number.isFinite(min) ? min : 0))); + const committed = constrain(isControlled ? (value as number) : internal); + const committedRef = React.useRef(committed); + committedRef.current = committed; + + const emit = React.useCallback( + (raw: number, source: ChangeSource) => { + const next = constrain(raw); + if (next === committedRef.current) return; + if (!isControlled) { + committedRef.current = next; + setInternal(next); + } + onChange?.(next, { source }); + }, + [constrain, isControlled, onChange], + ); + + // Draft typing: the input owns its text between focus and commit. + const [draft, setDraft] = React.useState(null); + // Enter/Escape settle the draft themselves, then blur — the blur handler + // must not commit again off its stale closure. + const skipBlurCommit = React.useRef(false); + const trim = (v: number) => { + const s = v.toFixed(dec); + return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s; + }; + const shown = draft ?? trim(committed); + + const commitDraft = (text: string) => { + const parsed = (parseValue ?? defaultParseValue)(text); + if (parsed !== null && Number.isFinite(parsed)) { + const target = constrain(parsed); + onChangeStart?.(committedRef.current, { source: 'edit' }); + emit(target, 'edit'); + onChangeEnd?.(target, { source: 'edit' }); + } + setDraft(null); + }; + + // Label scrubbing. + const scrub = React.useRef<{ pointerId: number; lastX: number; acc: number } | null>(null); + const [scrubbing, setScrubbing] = React.useState(false); + const onScrubDown = (e: React.PointerEvent) => { + if (disabled) return; + if (e.pointerType === 'mouse' && e.button !== 0) return; + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + scrub.current = { pointerId: e.pointerId, lastX: e.clientX, acc: 0 }; + setScrubbing(true); + onChangeStart?.(committedRef.current, { source: 'drag' }); + }; + const onScrubMove = (e: React.PointerEvent) => { + const s = scrub.current; + if (!s || e.pointerId !== s.pointerId) return; + s.acc += e.clientX - s.lastX; + s.lastX = e.clientX; + const stepsMoved = Math.trunc(s.acc / scrubSensitivity); + if (stepsMoved !== 0) { + s.acc -= stepsMoved * scrubSensitivity; + const mult = e.shiftKey ? fineMultiplier : 1; + emit(committedRef.current + stepsMoved * step * mult, 'drag'); + } + }; + const onScrubUp = (e: React.PointerEvent) => { + if (!scrub.current || e.pointerId !== scrub.current.pointerId) return; + scrub.current = null; + setScrubbing(false); + onChangeEnd?.(committedRef.current, { source: 'drag' }); + }; + + // Wheel over the field (non-passive so the page doesn't scroll). + const wrapRef = React.useRef(null); + const wheelState = React.useRef({ active: false, timer: undefined as ReturnType | undefined }); + const emitRef = React.useRef(emit); + emitRef.current = emit; + const cbRef = React.useRef({ onChangeStart, onChangeEnd }); + cbRef.current = { onChangeStart, onChangeEnd }; + React.useEffect(() => { + const el = wrapRef.current; + if (!el || disabled) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + if (!wheelState.current.active) { + wheelState.current.active = true; + cbRef.current.onChangeStart?.(committedRef.current, { source: 'wheel' }); + } + const dir = e.deltaY < 0 ? 1 : -1; + const mult = e.shiftKey ? fineMultiplier : 1; + emitRef.current(committedRef.current + dir * step * mult, 'wheel'); + clearTimeout(wheelState.current.timer); + wheelState.current.timer = setTimeout(() => { + wheelState.current.active = false; + cbRef.current.onChangeEnd?.(committedRef.current, { source: 'wheel' }); + }, 250); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => { + el.removeEventListener('wheel', onWheel); + clearTimeout(wheelState.current.timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [disabled, step, fineMultiplier]); + + return ( +
+ {label && ( + + {label} + + )} + { + setDraft(trim(committed)); + e.currentTarget.select(); + }} + onChange={e => setDraft(e.target.value)} + onBlur={e => { + if (skipBlurCommit.current) { + skipBlurCommit.current = false; + return; + } + if (draft !== null) commitDraft(e.currentTarget.value); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + commitDraft(e.currentTarget.value); + skipBlurCommit.current = true; + e.currentTarget.blur(); + } else if (e.key === 'Escape') { + setDraft(null); + skipBlurCommit.current = true; + e.currentTarget.blur(); + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + const dir = e.key === 'ArrowUp' ? 1 : -1; + const mult = e.shiftKey ? 10 : 1; + emit(committedRef.current + dir * step * mult, 'keyboard'); + setDraft(null); + } + }} + style={{ + width: `${widthCh}ch`, + background: 'rgba(0,0,0,0.35)', + border: `1px solid ${scrubbing ? accent : 'rgba(255,255,255,0.14)'}`, + borderRadius: 6, + padding: '4px 7px', + fontFamily: theme.fontMono, + fontSize: 12.5, + color: theme.text, + textAlign: 'right', + outline: 'none', + transition: 'border-color 80ms', + }} + /> + {unit && ( + + {unit} + + )} + {name && } +
+ ); + }, +); diff --git a/packages/dreamknob/src/skins/SegmentSwitch.tsx b/packages/dreamknob/src/skins/SegmentSwitch.tsx new file mode 100644 index 0000000..153bc0a --- /dev/null +++ b/packages/dreamknob/src/skins/SegmentSwitch.tsx @@ -0,0 +1,176 @@ +import * as React from 'react'; +import { useKnobTheme } from '../core/theme'; + +export interface SegmentSwitchProps { + /** Position labels, e.g. ['LINEAR', 'RADIAL', 'CONIC']. */ + options: readonly string[]; + /** Controlled selected index. Pair with `onChange`. */ + value?: number; + defaultValue?: number; + onChange?: (index: number) => void; + /** Accent for the active cell's LED tick and text. */ + color?: string; + /** Control height in px. Default: 30. */ + size?: number; + /** Show the LED tick on the active cell. Default: true. */ + led?: boolean; + disabled?: boolean; + /** Render a hidden form input carrying the selected option text. */ + name?: string; + className?: string; + style?: React.CSSProperties; + 'aria-label'?: string; +} + +/** + * Hardware-styled segmented selector — the horizontal sibling of + * SteppedKnob. Renders a radiogroup; arrow keys move the selection. + */ +export const SegmentSwitch = React.forwardRef( + function SegmentSwitch( + { + options, + value, + defaultValue = 0, + onChange, + color, + size = 30, + led = true, + disabled = false, + name, + className, + style, + ...aria + }, + ref, + ) { + const theme = useKnobTheme(); + const accent = color ?? theme.accent ?? '#4cc2ff'; + const light = theme.scheme === 'light'; + const isControlled = value !== undefined; + const [internal, setInternal] = React.useState(defaultValue); + const active = Math.max(0, Math.min(options.length - 1, isControlled ? (value as number) : internal)); + const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]); + + const select = (i: number, focus = false) => { + const next = Math.max(0, Math.min(options.length - 1, i)); + if (focus) btnRefs.current[next]?.focus(); + if (next === active) return; + if (!isControlled) setInternal(next); + onChange?.(next); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (disabled) return; + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + select(active + 1, true); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + select(active - 1, true); + } else if (e.key === 'Home') { + e.preventDefault(); + select(0, true); + } else if (e.key === 'End') { + e.preventDefault(); + select(options.length - 1, true); + } + }; + + return ( +
+ {options.map((opt, i) => { + const isActive = i === active; + return ( + + ); + })} + {name && } +
+ ); + }, +); diff --git a/packages/dreamknob/src/skins/SteppedKnob.tsx b/packages/dreamknob/src/skins/SteppedKnob.tsx index c8d7dc6..91dfa50 100644 --- a/packages/dreamknob/src/skins/SteppedKnob.tsx +++ b/packages/dreamknob/src/skins/SteppedKnob.tsx @@ -29,6 +29,8 @@ export const SteppedKnob = React.forwardRef(fu textColor, labelColor, label, + sublabel, + labelSize, showValue = true, format, focusRing, @@ -53,6 +55,7 @@ export const SteppedKnob = React.forwardRef(fu min={min} max={max} steps={detents} + getAriaValueText={core.getAriaValueText ?? (positions ? (v: number) => positions[Math.round(((v - min) / (max - min || 1)) * (detents - 1))] ?? String(v) : format ? (v: number) => format(v) : undefined)} > {ctx => { const index = Math.round(ctx.normalized * (detents - 1)); @@ -105,7 +108,8 @@ export const SteppedKnob = React.forwardRef(fu color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.45} - fontSize={size * 0.095} + fontSize={labelSize ?? size * 0.095} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/ToggleSwitch.tsx b/packages/dreamknob/src/skins/ToggleSwitch.tsx new file mode 100644 index 0000000..e6ffa8d --- /dev/null +++ b/packages/dreamknob/src/skins/ToggleSwitch.tsx @@ -0,0 +1,195 @@ +import * as React from 'react'; +import { useKnobTheme } from '../core/theme'; + +export interface ToggleSwitchProps { + /** Controlled on-state. Pair with `onChange`. */ + on?: boolean; + defaultOn?: boolean; + onChange?: (on: boolean) => void; + /** Accent color when on. Defaults to the theme accent. */ + color?: string; + /** Track height in px (the knob is sized from it). Default: 22. */ + size?: number; + /** Caption beside the switch. */ + label?: string; + labelPosition?: 'right' | 'left'; + /** Tiny OFF/ON legend printed on the track. Default: false. */ + showStateLabel?: boolean; + /** Text for the on/off legend when `showStateLabel`. Default: 'ON'/'OFF'. */ + onLabel?: string; + offLabel?: string; + disabled?: boolean; + /** Render a hidden form input carrying "on"/"off". */ + name?: string; + className?: string; + style?: React.CSSProperties; + 'aria-label'?: string; +} + +/** + * A rocker/slider switch — the most literal boolean idiom. Renders + * `role="switch"`; the knob slides between OFF and ON. Space/Enter toggles, + * ←/→ set off/on. + */ +export const ToggleSwitch = React.forwardRef( + function ToggleSwitch( + { + on, + defaultOn = false, + onChange, + color, + size = 22, + label, + labelPosition = 'right', + showStateLabel = false, + onLabel = 'ON', + offLabel = 'OFF', + disabled = false, + name, + className, + style, + ...aria + }, + ref, + ) { + const theme = useKnobTheme(); + const accent = color ?? theme.accent ?? '#4cc2ff'; + const isControlled = on !== undefined; + const [internal, setInternal] = React.useState(defaultOn); + const [focusVisible, setFocusVisible] = React.useState(false); + const lit = isControlled ? (on as boolean) : internal; + + const set = (next: boolean) => { + if (next === lit) return; + if (!isControlled) setInternal(next); + onChange?.(next); + }; + + const trackW = size * 1.85; + const trackH = size; + const knobD = size - 6; + const pad = 3; + const knobX = lit ? trackW - knobD - pad : pad; + + const legend = (side: 'on' | 'off') => ( + + ); + + return ( + + ); + }, +); diff --git a/packages/dreamknob/src/skins/TransportButton.tsx b/packages/dreamknob/src/skins/TransportButton.tsx new file mode 100644 index 0000000..b6763ab --- /dev/null +++ b/packages/dreamknob/src/skins/TransportButton.tsx @@ -0,0 +1,180 @@ +import * as React from 'react'; +import { panelButtonChrome, useKnobTheme } from '../core/theme'; + +export type TransportKind = + | 'play' + | 'pause' + | 'stop' + | 'record' + | 'rewind' + | 'forward' + | 'panic'; + +export interface TransportButtonProps { + kind: TransportKind; + /** Engaged/lit state (playing, armed, recording). Reflects app state. */ + active?: boolean; + onClick?: () => void; + /** Pulse the glyph while active (auto-on for `record`). Reduced-motion aware. */ + blink?: boolean; + /** Override the glyph/LED color. Defaults per kind (play green, record red…). */ + color?: string; + /** Button size in px (square). Default: 38. */ + size?: number; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + 'aria-label'?: string; +} + +const LABEL: Record = { + play: 'Play', + pause: 'Pause', + stop: 'Stop', + record: 'Record', + rewind: 'Rewind', + forward: 'Forward', + panic: 'Panic — all notes off', +}; + +/** Glyph in a 24×24 box, filled with `currentColor`. */ +const Glyph: React.FC<{ kind: TransportKind }> = ({ kind }) => { + switch (kind) { + case 'play': + return ; + case 'pause': + return ( + <> + + + + ); + case 'stop': + return ; + case 'record': + return ; + case 'rewind': + return ( + <> + + + + ); + case 'forward': + return ( + <> + + + + ); + case 'panic': + return ( + <> + + + + ); + } +}; + +/** + * A record / play / stop / pause / panic transport button, styled to sit + * alongside dreamknob controls. `active` lights it; `record` blinks while + * armed. Reduced-motion aware. + */ +export const TransportButton = React.forwardRef( + function TransportButton( + { kind, active = false, onClick, blink, color, size = 38, disabled = false, className, style, ...aria }, + ref, + ) { + const theme = useKnobTheme(); + const [focusVisible, setFocusVisible] = React.useState(false); + const glyphRef = React.useRef(null); + const chrome = panelButtonChrome(theme.scheme, active); + + const accent = + color ?? + (kind === 'record' || kind === 'panic' + ? theme.zoneHot + : kind === 'play' + ? theme.zoneGood + : kind === 'pause' + ? theme.zoneWarn + : theme.text); + const lit = active ? accent : theme.label; + const shouldBlink = (blink ?? kind === 'record') && active; + + React.useEffect(() => { + const el = glyphRef.current; + if (!el || !shouldBlink) return; + if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) + return; + const anim = el.animate([{ opacity: 1 }, { opacity: 0.3 }], { + duration: 560, + iterations: Infinity, + direction: 'alternate', + easing: 'ease-in-out', + }); + return () => anim.cancel(); + }, [shouldBlink]); + + return ( + + ); + }, +); diff --git a/packages/dreamknob/src/skins/VintageKnob.tsx b/packages/dreamknob/src/skins/VintageKnob.tsx index 123a1f4..8c97c64 100644 --- a/packages/dreamknob/src/skins/VintageKnob.tsx +++ b/packages/dreamknob/src/skins/VintageKnob.tsx @@ -103,6 +103,8 @@ export const VintageKnob = React.forwardRef(fu scaleLabels, indicatorColor, label, + sublabel, + labelSize, focusRing, className, style, @@ -132,7 +134,8 @@ export const VintageKnob = React.forwardRef(fu color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.46} - fontSize={size * 0.095} + fontSize={labelSize ?? size * 0.095} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/XYPad.tsx b/packages/dreamknob/src/skins/XYPad.tsx index 71988a9..2ce37d8 100644 --- a/packages/dreamknob/src/skins/XYPad.tsx +++ b/packages/dreamknob/src/skins/XYPad.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math'; +import { applyDetents, clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math'; import { useKnobTheme } from '../core/theme'; import type { ChangeMeta } from '../core/types'; @@ -8,6 +8,14 @@ export interface XYPadAxis { max?: number; step?: number; defaultValue?: number; + /** + * Flip the axis direction. Y is bottom-up by default (audio convention); + * `invert: true` makes it top-down (screen convention). X is left-right; + * inverted is right-left. + */ + invert?: boolean; + /** Magnetic snap values for this axis while dragging (like knob detents). */ + detents?: readonly number[]; } export interface XYPadValue { @@ -32,6 +40,8 @@ export interface XYPadProps { faceColor?: string; /** Grid divisions per axis (0 disables). Default: 4. */ grid?: number; + /** Detent capture radius as a fraction of travel. Default: 0.025. */ + detentSize?: number; /** Show the "x · y" readout above the pad. Default: true. */ showValue?: boolean; formatX?: (v: number) => string; @@ -66,6 +76,7 @@ export const XYPad = React.forwardRef(function XYPad color, faceColor = 'rgba(255,255,255,0.04)', grid = 4, + detentSize = 0.025, showValue = true, formatX, formatY, @@ -124,16 +135,39 @@ export const XYPad = React.forwardRef(function XYPad onChange?.(c, { source: 'drag' }); }; + // Normalized travel <-> value per axis, honoring `invert`. + const valueFromN = (n: number, a: typeof ax) => { + const span = a.max - a.min; + return a.invert ? a.max - n * span : a.min + n * span; + }; + const nFromValue = (v: number, a: typeof ax) => { + const span = a.max - a.min; + if (span === 0) return 0; + return clamp(a.invert ? (a.max - v) / span : (v - a.min) / span, 0, 1); + }; + const detentsNX = React.useMemo( + () => ax.detents?.map(d => nFromValue(d, ax)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ax.detents, ax.min, ax.max, ax.invert], + ); + const detentsNY = React.useMemo( + () => ay.detents?.map(d => nFromValue(d, ay)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ay.detents, ay.min, ay.max, ay.invert], + ); + const applyPointer = (clientX: number, clientY: number) => { const s = session.current; if (!s || s.rect.width <= 0 || s.rect.height <= 0) return; - const nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1); - const ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1); - emit({ x: ax.min + nx * (ax.max - ax.min), y: ay.min + ny * (ay.max - ay.min) }); + let nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1); + let ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1); + if (detentsNX) nx = applyDetents(nx, detentsNX, detentSize); + if (detentsNY) ny = applyDetents(ny, detentsNY, detentSize); + emit({ x: valueFromN(nx, ax), y: valueFromN(ny, ay) }); }; - const nx = ax.max === ax.min ? 0 : (pos.x - ax.min) / (ax.max - ax.min); - const ny = ay.max === ay.min ? 0 : (pos.y - ay.min) / (ay.max - ay.min); + const nx = nFromValue(pos.x, ax); + const ny = nFromValue(pos.y, ay); const hx = nx * width; const hy = (1 - ny) * height; @@ -153,12 +187,14 @@ export const XYPad = React.forwardRef(function XYPad return (
@@ -238,10 +274,11 @@ export const XYPad = React.forwardRef(function XYPad let handled = true; const p = posRef.current; switch (e.key) { - case 'ArrowLeft': emit({ ...p, x: p.x - stepX }); break; - case 'ArrowRight': emit({ ...p, x: p.x + stepX }); break; - case 'ArrowUp': emit({ ...p, y: p.y + stepY }); break; - case 'ArrowDown': emit({ ...p, y: p.y - stepY }); break; + // Arrows follow the VISUAL direction, so inverted axes negate. + case 'ArrowLeft': emit({ ...p, x: p.x - (ax.invert ? -stepX : stepX) }); break; + case 'ArrowRight': emit({ ...p, x: p.x + (ax.invert ? -stepX : stepX) }); break; + case 'ArrowUp': emit({ ...p, y: p.y + (ay.invert ? -stepY : stepY) }); break; + case 'ArrowDown': emit({ ...p, y: p.y - (ay.invert ? -stepY : stepY) }); break; default: handled = false; } if (handled) e.preventDefault(); @@ -262,7 +299,6 @@ export const XYPad = React.forwardRef(function XYPad userSelect: 'none', WebkitUserSelect: 'none', cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'crosshair', - opacity: disabled ? 0.45 : undefined, outline: 'none', borderRadius: 10, ...(isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined), diff --git a/packages/dreamknob/src/skins/meterEngine.test.ts b/packages/dreamknob/src/skins/meterEngine.test.ts new file mode 100644 index 0000000..3b655fe --- /dev/null +++ b/packages/dreamknob/src/skins/meterEngine.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { ampToDb, dbToAmp, smoothingCoeff } from './meterEngine'; + +describe('ampToDb', () => { + it('maps unity amplitude to 0 dBFS', () => { + expect(ampToDb(1)).toBeCloseTo(0, 6); + }); + + it('halving amplitude is about -6 dB', () => { + expect(ampToDb(0.5)).toBeCloseTo(-6.0206, 3); + }); + + it('a tenth is -20 dB', () => { + expect(ampToDb(0.1)).toBeCloseTo(-20, 6); + }); + + it('silence is -Infinity', () => { + expect(ampToDb(0)).toBe(-Infinity); + expect(ampToDb(-0.2)).toBe(-Infinity); + }); + + it('round-trips through dbToAmp', () => { + for (const amp of [1, 0.5, 0.25, 0.031]) { + expect(dbToAmp(ampToDb(amp))).toBeCloseTo(amp, 6); + } + }); +}); + +describe('smoothingCoeff', () => { + it('is in (0,1) and rises with dt', () => { + const a = smoothingCoeff(5, 100); + const b = smoothingCoeff(50, 100); + expect(a).toBeGreaterThan(0); + expect(b).toBeLessThan(1); + expect(b).toBeGreaterThan(a); + }); + + it('reaches ~63% of the way after one time constant', () => { + expect(smoothingCoeff(100, 100)).toBeCloseTo(1 - Math.exp(-1), 6); + }); + + it('a shorter tau (faster attack) moves further per step', () => { + expect(smoothingCoeff(10, 5)).toBeGreaterThan(smoothingCoeff(10, 350)); + }); +}); diff --git a/packages/dreamknob/src/skins/meterEngine.ts b/packages/dreamknob/src/skins/meterEngine.ts new file mode 100644 index 0000000..909fe93 --- /dev/null +++ b/packages/dreamknob/src/skins/meterEngine.ts @@ -0,0 +1,167 @@ +import * as React from 'react'; + +/** Linear amplitude (0..1+) to dBFS. 0 (or below) maps to -Infinity. */ +export const ampToDb = (amp: number): number => (amp <= 0 ? -Infinity : 20 * Math.log10(amp)); + +/** dBFS back to linear amplitude. */ +export const dbToAmp = (db: number): number => Math.pow(10, db / 20); + +/** One-pole smoothing coefficient for a time constant `tau` (ms) over `dt` (ms). */ +export const smoothingCoeff = (dt: number, tau: number): number => + 1 - Math.exp(-dt / Math.max(1, tau)); + +export interface Ballistics { + /** Rise time constant in ms (fast). */ + attack?: number; + /** Fall time constant in ms (slow). */ + decay?: number; +} + +export interface MeterEngineOptions { + /** Run the rAF engine at all. When false the hook returns null (legacy path). */ + enabled: boolean; + /** Smooth the displayed level with attack/decay ballistics. */ + ballistics?: boolean | Ballistics; + /** Peak-hold time in ms before the peak marker starts to fall. */ + peakHold?: number | false; + /** Peak fall rate in normalized units per second once the hold expires. */ + peakDecay?: number | null; + /** Compute a time-windowed loudness (LUFS-ish) from `rawDomain`. */ + integrated?: boolean; + /** Loudness integration window in ms. */ + integrationWindow?: number; + /** 'db' integrates power in the log domain; 'linear' uses RMS. */ + scale?: 'linear' | 'db'; +} + +export interface MeterEngineState { + /** Ballistics-smoothed (or raw) normalized levels, per channel. */ + levels: readonly number[]; + /** Peak-hold markers, per channel, in normalized [0,1]. */ + peaks: readonly number[]; + /** Windowed loudness in the value domain (dB for 'db' scale), or null. */ + integrated: number | null; +} + +const DEFAULT_ATTACK = 5; +const DEFAULT_DECAY = 350; + +/** + * A requestAnimationFrame meter engine: attack/decay ballistics, peak-hold with + * a decay tail, and an optional time-windowed loudness estimate. Returns null + * when disabled so the caller can keep its cheap legacy (timer) path. + * + * `targetNs` are the instantaneous normalized levels; `rawDomain` are the same + * channels in value space (dB or linear) for loudness integration. + */ +export const useMeterEngine = ( + targetNs: readonly number[], + rawDomain: readonly number[], + opts: MeterEngineOptions, +): MeterEngineState | null => { + const { + enabled, + ballistics, + peakHold = 1200, + peakDecay = null, + integrated = false, + integrationWindow = 3000, + scale = 'linear', + } = opts; + + const useBallistics = !!ballistics; + const attack = (typeof ballistics === 'object' && ballistics?.attack) || DEFAULT_ATTACK; + const decay = (typeof ballistics === 'object' && ballistics?.decay) || DEFAULT_DECAY; + + const targetRef = React.useRef(targetNs); + targetRef.current = targetNs; + const rawRef = React.useRef(rawDomain); + rawRef.current = rawDomain; + + const [state, setState] = React.useState(() => ({ + levels: targetNs, + peaks: targetNs, + integrated: null, + })); + + // Per-channel timestamp of the last time the peak was refreshed. + const holdAt = React.useRef([]); + // Ring buffer of {t, power} for loudness integration. + const powerBuf = React.useRef<{ t: number; p: number }[]>([]); + + React.useEffect(() => { + if (!enabled) return; + let raf = 0; + let last = + typeof performance !== 'undefined' && performance.now ? performance.now() : 0; + + const tick = (now: number) => { + const dt = Math.min(100, now - last); + last = now; + const tgt = targetRef.current; + const raw = rawRef.current; + + setState(prev => { + const n = tgt.length; + const prevLevels = prev.levels.length === n ? prev.levels : tgt; + const prevPeaks = prev.peaks.length === n ? prev.peaks : tgt; + + const levels = tgt.map((t, i) => { + const l = prevLevels[i] ?? 0; + if (!useBallistics) return t; + const tau = t >= l ? attack : decay; + return l + (t - l) * smoothingCoeff(dt, tau); + }); + + const holdMs = peakHold === false ? Infinity : peakHold; + const peaks = levels.map((lvl, i) => { + const p = prevPeaks[i] ?? 0; + if (lvl >= p) { + holdAt.current[i] = now; + return lvl; + } + const heldFor = now - (holdAt.current[i] ?? now); + if (heldFor < holdMs) return p; + if (peakDecay == null) return lvl; // no decay tail — track the level + return Math.max(lvl, p - (peakDecay / 1000) * dt); + }); + + let integratedOut = prev.integrated; + if (integrated) { + const power = + raw.length > 0 + ? raw.reduce( + (s, d) => s + (scale === 'db' ? Math.pow(10, d / 10) : d * d), + 0, + ) / raw.length + : 0; + const buf = powerBuf.current; + buf.push({ t: now, p: power }); + while (buf.length > 1 && now - buf[0].t > integrationWindow) buf.shift(); + const meanP = buf.reduce((s, b) => s + b.p, 0) / (buf.length || 1); + integratedOut = + scale === 'db' ? 10 * Math.log10(Math.max(meanP, 1e-12)) : Math.sqrt(meanP); + } + + return { levels, peaks, integrated: integratedOut }; + }); + + raf = requestAnimationFrame(tick); + }; + + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [ + enabled, + useBallistics, + attack, + decay, + peakHold, + peakDecay, + integrated, + integrationWindow, + scale, + ]); + + return enabled ? state : null; +}; diff --git a/packages/dreamknob/src/skins/shared.ts b/packages/dreamknob/src/skins/shared.ts index ddadc36..5eb989a 100644 --- a/packages/dreamknob/src/skins/shared.ts +++ b/packages/dreamknob/src/skins/shared.ts @@ -9,6 +9,13 @@ export interface SkinProps extends KnobCoreProps { size?: number; /** Caption drawn in the travel gap at the bottom of the knob. */ label?: string; + /** + * Secondary caption line under the label — a unit or live descriptor + * (`SEED` over `world #7`). Rendered smaller and un-uppercased. + */ + sublabel?: React.ReactNode; + /** Label font size in px, independent of `size` (small knobs, readable labels). */ + labelSize?: number; /** Show the numeric readout. Default varies per skin. */ showValue?: boolean; /** Unit suffix for the readout, e.g. "dB", "Hz", "%". */