diff --git a/README.md b/README.md index 17c3eed..6f3f7a5 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,8 @@ 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`, `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. + `SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `XYPad`, + `PushButton`, `SegmentDisplay`, `AlphaDisplay` — every color themeable per instance. - **Composable**: `` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`, `KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with `useKnob`. @@ -51,7 +48,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.1.4/dreamknob-1.1.4.tgz +pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.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 1d02c03..09bca6d 100644 --- a/apps/docs/src/App.tsx +++ b/apps/docs/src/App.tsx @@ -1,7 +1,6 @@ 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'; @@ -27,7 +26,6 @@ export const App: React.FC = () => (
Start Gallery - Buttons Synth Playground Guide @@ -38,7 +36,6 @@ export const App: React.FC = () => ( - diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx index 3cffec2..a7664eb 100644 --- a/apps/docs/src/sections/ApiDocs.tsx +++ b/apps/docs/src/sections/ApiDocs.tsx @@ -67,9 +67,8 @@ export const ApiDocs: React.FC = () => ( - - - + + @@ -88,74 +87,29 @@ 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, 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{' '} + 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{' '} showValue/unit/format; the display components Meter/SegmentDisplay/AlphaDisplay{' '} have their own smaller prop sets). Primitive-drawn parts carry{' '} @@ -164,19 +118,13 @@ 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). 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. + accepts gradient (position-anchored color stops).

Theming

Every skin resolves its color and font defaults from the active theme; per-instance - 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. + props always win. Use base="light" for light backgrounds.

( 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) }} > @@ -248,92 +189,6 @@ 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 deleted file mode 100644 index 1cf6753..0000000 --- a/apps/docs/src/sections/Buttons.tsx +++ /dev/null @@ -1,157 +0,0 @@ -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 2158ee2..774e08e 100644 --- a/apps/docs/src/sections/Gallery.tsx +++ b/apps/docs/src/sections/Gallery.tsx @@ -2,35 +2,23 @@ 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, @@ -129,265 +117,19 @@ 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; @@ -601,49 +343,10 @@ export const Gallery: React.FC = () => (
- - - - - + - - - - - - - - - -
- - -
-
- - - - - - - - - - - - - - - - - - - - - ( ) · peer deps: react >= 18, react-dom >= 18`} + code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`} />

Installs straight from the{' '} - v1.1.4 release + v1.0.0 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 4669d40..f1a37a0 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.1.4/dreamknob-1.1.4.tgz + pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz - ); - }, -); diff --git a/packages/dreamknob/src/skins/LEDFader.tsx b/packages/dreamknob/src/skins/LEDFader.tsx index 6b68c4e..a57f78a 100644 --- a/packages/dreamknob/src/skins/LEDFader.tsx +++ b/packages/dreamknob/src/skins/LEDFader.tsx @@ -15,13 +15,6 @@ 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. */ @@ -45,10 +38,8 @@ 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 — size `digits` to fit. */ + /** Decimals in the readout (defaults to the control's decimals, capped at 1). */ displayDecimals?: number; - /** Unit suffix beside the readout, e.g. "dB". */ - unit?: string; labelColor?: string; /** Click the readout to type an exact value. */ editable?: boolean; @@ -64,20 +55,18 @@ 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: faceColorProp, + faceColor = '#0b0d0e', focusRing, label, showValue = true, digits = 4, displayDecimals, - unit, labelColor, editable = false, parseValue, @@ -88,8 +77,7 @@ export const LEDFader = React.forwardRef(function }, ref) { const [editing, setEditing] = React.useState(false); const theme = useKnobTheme(); - const color = colorProp ?? theme.accent; - const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e'; + const color = colorProp ?? theme.accent ?? '#3df2ad'; const ring = focusRing ?? theme.focusRing; const vertical = orientation === 'vertical'; const pad = 5; @@ -97,28 +85,16 @@ 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; - // 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 zoneList: readonly LEDFaderZone[] = React.useMemo( + () => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]), + [zones, color], + ); const zoneFor = (t: number): string => (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; @@ -166,15 +142,12 @@ export const LEDFader = React.forwardRef(function return (
@@ -198,29 +171,22 @@ export const LEDFader = React.forwardRef(function initial={String(knob.value)} onCommit={raw => { const parsed = (parseValue ?? defaultParseValue)(raw); - if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed); + if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed); setEditing(false); }} onCancel={() => setEditing(false)} style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }} /> ) : ( - - - {unit && ( - - {unit} - - )} - + )}
)} @@ -233,22 +199,14 @@ export const LEDFader = React.forwardRef(function }} style={{ ...knob.bind.style, - opacity: undefined, // the outer wrapper owns disabled dimming - width: ctrlW, - height: ctrlH, - display: fill ? 'flex' : 'inline-flex', + width: w, + height: h, + display: '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 e45afa9..46ff198 100644 --- a/packages/dreamknob/src/skins/MetalKnob.tsx +++ b/packages/dreamknob/src/skins/MetalKnob.tsx @@ -33,8 +33,6 @@ export const MetalKnob = React.forwardRef(functi showArc = true, tickCount = 21, label, - sublabel, - labelSize, showValue = false, unit, format, @@ -63,7 +61,7 @@ export const MetalKnob = React.forwardRef(functi const indicator = indicatorColor ?? (silver ? '#1b1c21' : color); return ( - format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}> + @@ -139,8 +137,7 @@ export const MetalKnob = React.forwardRef(functi color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.44} - fontSize={labelSize ?? size * 0.1} - sublabel={sublabel} + fontSize={size * 0.1} > {label} diff --git a/packages/dreamknob/src/skins/Meter.tsx b/packages/dreamknob/src/skins/Meter.tsx index ba348be..2ccf9a8 100644 --- a/packages/dreamknob/src/skins/Meter.tsx +++ b/packages/dreamknob/src/skins/Meter.tsx @@ -4,211 +4,116 @@ 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 — 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[]; + /** Level to display. The meter is read-only — always controlled. */ + value: 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; - /** - * 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. */ + /** Cross-axis size in px. Default: 16. */ breadth?: number; /** Number of LED segments. Default: 28. */ segments?: number; - /** LED color when no zones are given. Defaults to the theme's zone colors. */ + /** LED color when no zones are given. */ color?: string; - /** Meter zones by normalized position. Defaults to theme zoneGood/Warn/Hot. */ + /** Meter zones, e.g. green / yellow / red by normalized position. */ 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; - /** - * 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. */ + /** Dedicated clip LED at the hot end of the meter. Default: true. */ showClip?: boolean; - /** Value (in the display domain — dB for scale='db') at/above which it lights. Default: `max`. */ + /** Value at/above which the clip LED lights. Default: `max`. */ clipThreshold?: number; /** - * 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`. + * 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`. */ - clipValue?: number | readonly number[]; + clipValue?: 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 any channel first crosses the clip threshold. */ + /** Fired when the signal first crosses the clip threshold. */ onClip?: (value: number) => void; /** Panel color behind the LEDs. */ faceColor?: string; label?: string; - /** Show a seven-segment readout (of the hottest channel). Default: false. */ + /** Show a seven-segment readout. 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 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). + * Read-only LED level meter with peak hold — the display-side sibling of + * LEDFader for VU/output metering. */ export const Meter: React.FC = ({ value, - min: minProp, - max: maxProp, + min = 0, + max = 100, taper = linearTaper, - scale = 'linear', orientation = 'vertical', length = 160, - fill = false, - disabled = false, breadth = 16, segments = 28, - color, + color = '#3df2ad', 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: faceColorProp, + faceColor = '#0b0d0e', 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 dbScale = scale === 'db'; - const min = minProp ?? (dbScale ? -60 : 0); - const max = maxProp ?? (dbScale ? 0 : 100); + const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1); - 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); + // Peak: track the highest recent level, fall back after `peakHold` ms. + const [peak, setPeak] = React.useState(n); React.useEffect(() => { - 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); + if (peakHold === false) return; + if (n >= peak) { + setPeak(n); + return; + } + const t = setTimeout(() => setPeak(n), peakHold); return () => clearTimeout(t); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(targetNs), peakHold, animated]); - const peaks = animated && engine ? engine.peaks : legacyPeaks; + }, [n, peak, peakHold]); - // 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); + // Clip: latch a dedicated LED when the raw (unclamped) signal reaches the + // threshold. Hold for `clipHold` ms, or until clicked in 'latch' mode. const [clipped, setClipped] = React.useState(false); const clipTimer = React.useRef>(); const wasOver = React.useRef(false); @@ -216,9 +121,10 @@ export const Meter: React.FC = ({ onClipRef.current = onClip; React.useEffect(() => { if (!showClip) return; - const over = hottest >= (clipThreshold ?? max) - 1e-9; + const signal = clipValue ?? value; + const over = signal >= (clipThreshold ?? max) - 1e-9; if (over) { - if (!wasOver.current) onClipRef.current?.(hottest); + if (!wasOver.current) onClipRef.current?.(signal); setClipped(true); if (clipHold !== 'latch') { clearTimeout(clipTimer.current); @@ -226,199 +132,93 @@ export const Meter: React.FC = ({ } } wasOver.current = over; - }, [hottest, showClip, clipThreshold, max, clipHold]); + }, [value, clipValue, showClip, clipThreshold, max, clipHold]); React.useEffect(() => () => clearTimeout(clipTimer.current), []); - 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 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 zoneFor = (t: number): string => (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; - // 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 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). const clipLed = 7; const clipSpan = showClip ? clipLed + 3 : 0; - const innerLen = along - pad * 2 - clipSpan; - const slot = Math.max(1, innerLen / segments); + const slot = (length - pad * 2 - clipSpan) / segments; const gap = Math.min(2.5, slot * 0.35); - const segSize = Math.max(0.5, slot - gap); + const cross = breadth - pad * 2; - // 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 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 ? ( + ) : ( - - ); - } + + ), + ); } - 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 || (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'} - -
- )} + {showValue && ( +
+
)} -
- {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 4223635..fb522cb 100644 --- a/packages/dreamknob/src/skins/NeonKnob.tsx +++ b/packages/dreamknob/src/skins/NeonKnob.tsx @@ -23,8 +23,6 @@ export const NeonKnob = React.forwardRef(function labelColor, arcFrom = 'min', label, - sublabel, - labelSize, showValue = true, unit, format, @@ -38,7 +36,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={labelSize ?? size * 0.1} - sublabel={sublabel} + fontSize={size * 0.1} > {label} diff --git a/packages/dreamknob/src/skins/PanKnob.tsx b/packages/dreamknob/src/skins/PanKnob.tsx deleted file mode 100644 index 3274329..0000000 --- a/packages/dreamknob/src/skins/PanKnob.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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 a3cf560..005bacc 100644 --- a/packages/dreamknob/src/skins/PushButton.tsx +++ b/packages/dreamknob/src/skins/PushButton.tsx @@ -1,37 +1,17 @@ import * as React from 'react'; -import { panelButtonChrome, useKnobTheme } from '../core/theme'; +import { useKnobTheme } from '../core/theme'; -/** 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'> { +export interface PushButtonProps { /** Controlled pressed state. Pair with `onChange`. */ pressed?: boolean; defaultPressed?: boolean; onChange?: (pressed: boolean) => void; - /** - * '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; + /** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */ + mode?: 'toggle' | 'momentary'; /** LED / active color. */ color?: string; - /** - * 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'; + /** Show the LED strip. Default: true. */ + led?: boolean; /** Button height in px. Default: 36. */ size?: number; /** Explicit width; defaults to content. */ @@ -39,12 +19,11 @@ 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. */ @@ -54,69 +33,35 @@ export const PushButton = React.forwardRef( pressed, defaultPressed = false, onChange, - onClick, mode = 'toggle', color, - led: ledProp, + led = true, size = 36, width, disabled = false, name, - leadingIcon, children, className, style, - ...rest + ...aria }, 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); - // 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 on = isControlled ? (pressed as boolean) : internal; const set = (next: boolean) => { - if (next === latched) return; + if (next === on) return; if (!isControlled) setInternal(next); onChange?.(next); }; - const fire = () => { - onClick?.(); - setFlash(true); - clearTimeout(flashTimer.current); - flashTimer.current = setTimeout(() => setFlash(false), 160); - }; - const momentaryProps = - 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' + mode === 'momentary' ? { onPointerDown: (e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); @@ -131,40 +76,27 @@ export const PushButton = React.forwardRef( if (e.key === ' ' || e.key === 'Enter') set(false); }, } - : { 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)); + : { onClick: () => set(!on) }; 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 1f1cc09..bd70acc 100644 --- a/packages/dreamknob/src/skins/RubberKnob.tsx +++ b/packages/dreamknob/src/skins/RubberKnob.tsx @@ -25,8 +25,6 @@ export const RubberKnob = React.forwardRef(func labelColor, arcFrom = 'min', label, - sublabel, - labelSize, showValue = false, unit, format, @@ -44,7 +42,7 @@ export const RubberKnob = React.forwardRef(func const bodyR = size / 2 - size * 0.17; return ( - format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}> + @@ -105,8 +103,7 @@ export const RubberKnob = React.forwardRef(func color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.44} - fontSize={labelSize ?? size * 0.1} - sublabel={sublabel} + fontSize={size * 0.1} > {label} diff --git a/packages/dreamknob/src/skins/ScrubField.tsx b/packages/dreamknob/src/skins/ScrubField.tsx deleted file mode 100644 index 8bc2fc3..0000000 --- a/packages/dreamknob/src/skins/ScrubField.tsx +++ /dev/null @@ -1,294 +0,0 @@ -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 deleted file mode 100644 index 153bc0a..0000000 --- a/packages/dreamknob/src/skins/SegmentSwitch.tsx +++ /dev/null @@ -1,176 +0,0 @@ -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 91dfa50..c8d7dc6 100644 --- a/packages/dreamknob/src/skins/SteppedKnob.tsx +++ b/packages/dreamknob/src/skins/SteppedKnob.tsx @@ -29,8 +29,6 @@ export const SteppedKnob = React.forwardRef(fu textColor, labelColor, label, - sublabel, - labelSize, showValue = true, format, focusRing, @@ -55,7 +53,6 @@ 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)); @@ -108,8 +105,7 @@ export const SteppedKnob = React.forwardRef(fu color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.45} - fontSize={labelSize ?? size * 0.095} - sublabel={sublabel} + fontSize={size * 0.095} > {label} diff --git a/packages/dreamknob/src/skins/ToggleSwitch.tsx b/packages/dreamknob/src/skins/ToggleSwitch.tsx deleted file mode 100644 index e6ffa8d..0000000 --- a/packages/dreamknob/src/skins/ToggleSwitch.tsx +++ /dev/null @@ -1,195 +0,0 @@ -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 deleted file mode 100644 index b6763ab..0000000 --- a/packages/dreamknob/src/skins/TransportButton.tsx +++ /dev/null @@ -1,180 +0,0 @@ -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 8c97c64..123a1f4 100644 --- a/packages/dreamknob/src/skins/VintageKnob.tsx +++ b/packages/dreamknob/src/skins/VintageKnob.tsx @@ -103,8 +103,6 @@ export const VintageKnob = React.forwardRef(fu scaleLabels, indicatorColor, label, - sublabel, - labelSize, focusRing, className, style, @@ -134,8 +132,7 @@ export const VintageKnob = React.forwardRef(fu color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.46} - fontSize={labelSize ?? size * 0.095} - sublabel={sublabel} + fontSize={size * 0.095} > {label} diff --git a/packages/dreamknob/src/skins/XYPad.tsx b/packages/dreamknob/src/skins/XYPad.tsx index 2ce37d8..71988a9 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 { applyDetents, clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math'; +import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math'; import { useKnobTheme } from '../core/theme'; import type { ChangeMeta } from '../core/types'; @@ -8,14 +8,6 @@ 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 { @@ -40,8 +32,6 @@ 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; @@ -76,7 +66,6 @@ export const XYPad = React.forwardRef(function XYPad color, faceColor = 'rgba(255,255,255,0.04)', grid = 4, - detentSize = 0.025, showValue = true, formatX, formatY, @@ -135,39 +124,16 @@ 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; - 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 = 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) }); }; - const nx = nFromValue(pos.x, ax); - const ny = nFromValue(pos.y, 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 hx = nx * width; const hy = (1 - ny) * height; @@ -187,14 +153,12 @@ export const XYPad = React.forwardRef(function XYPad return (
@@ -274,11 +238,10 @@ export const XYPad = React.forwardRef(function XYPad let handled = true; const p = posRef.current; switch (e.key) { - // 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; + 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; default: handled = false; } if (handled) e.preventDefault(); @@ -299,6 +262,7 @@ 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 deleted file mode 100644 index 3b655fe..0000000 --- a/packages/dreamknob/src/skins/meterEngine.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 909fe93..0000000 --- a/packages/dreamknob/src/skins/meterEngine.ts +++ /dev/null @@ -1,167 +0,0 @@ -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 5eb989a..ddadc36 100644 --- a/packages/dreamknob/src/skins/shared.ts +++ b/packages/dreamknob/src/skins/shared.ts @@ -9,13 +9,6 @@ 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", "%". */