diff --git a/README.md b/README.md index 17c3eed..d40beb9 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,9 @@ 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`, `MeterBridge`, + `XYPad`, `PushButton`, `IndicatorLamp`, `SegmentSwitch`, `ScrubField`, + `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 +49,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.1.0/dreamknob-1.1.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..20ff93c 100644 --- a/apps/docs/src/sections/ApiDocs.tsx +++ b/apps/docs/src/sections/ApiDocs.tsx @@ -128,34 +128,24 @@ export const ApiDocs: React.FC = () => ( - - - + + - + - - - + + - - - - - - - - + +

- 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{' '} @@ -190,12 +180,8 @@ export const ApiDocs: React.FC = () => ( 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) + zoneWarn: '#ffd23e', // used by Meter, MeterBridge and + zoneHot: '#ff4d6b', // LEDFader when no zones are given }} > @@ -248,18 +234,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. @@ -291,47 +265,7 @@ function MyKnob() { editable unit="px" aria-label="Radius" /> // 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

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..eebd097 100644 --- a/apps/docs/src/sections/Gallery.tsx +++ b/apps/docs/src/sections/Gallery.tsx @@ -2,35 +2,27 @@ 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, @@ -205,189 +197,6 @@ const CommitModeDemo: React.FC = () => { ); }; -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,18 +410,10 @@ export const Gallery: React.FC = () => (
- - - - - - - - @@ -628,22 +429,6 @@ 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.0/dreamknob-1.1.0.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`} />

Installs straight from the{' '} - v1.1.4 release + v1.1.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..bac156d 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.1.0/dreamknob-1.1.0.tgz - ) : ( - {inner} - ); - })} -
- ); -}; diff --git a/packages/dreamknob/src/skins/MetalKnob.tsx b/packages/dreamknob/src/skins/MetalKnob.tsx index e45afa9..26690e0 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, @@ -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..d088b48 100644 --- a/packages/dreamknob/src/skins/Meter.tsx +++ b/packages/dreamknob/src/skins/Meter.tsx @@ -4,36 +4,19 @@ 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. + * meter (e.g. `[l, r]` for stereo). The meter is read-only. */ 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; - /** - * 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. */ @@ -44,42 +27,16 @@ export interface MeterProps { 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. */ 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 @@ -113,45 +70,31 @@ export interface MeterProps { 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). */ 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, 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, @@ -163,51 +106,27 @@ export const Meter: React.FC = ({ ...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 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)); + const ns = values.map(v => clamp(taper.toNormalized(clamp(v, 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); + // Peaks: per channel, falling back together after `peakHold` ms of no rise. + const [peaks, setPeaks] = React.useState(ns); 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)); + if (peakHold === false) return; + setPeaks(prev => { + const rose = ns.map((n, i) => n >= (prev[i] ?? 0)); + if (rose.every(Boolean) || prev.length !== ns.length) return ns.map((n, i) => Math.max(n, prev[i] ?? 0)); + return prev.map((p, i) => Math.max(p, ns[i] ?? 0)); }); - const t = setTimeout(() => setLegacyPeaks(targetNs), peakHold); + const t = setTimeout(() => setPeaks(ns), peakHold); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(targetNs), peakHold, animated]); - const peaks = animated && engine ? engine.peaks : legacyPeaks; + }, [JSON.stringify(ns), 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 clipSignals = clipValue !== undefined ? asArray(clipValue) : values; const hottest = Math.max(...clipSignals); const [clipped, setClipped] = React.useState(false); const clipTimer = React.useRef>(); @@ -253,20 +172,14 @@ export const Meter: React.FC = ({ const clipLed = 7; const clipSpan = showClip ? clipLed + 3 : 0; - const innerLen = along - pad * 2 - clipSpan; - const slot = Math.max(1, innerLen / segments); + const slot = Math.max(1, (along - pad * 2 - clipSpan) / segments); const gap = Math.min(2.5, slot * 0.35); const segSize = Math.max(0.5, slot - gap); - // 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 lit = Math.round((ns[c] ?? 0) * segments); const peakIdx = peakHold !== false && (peaks[c] ?? 0) > 0 ? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1) @@ -275,8 +188,8 @@ export const Meter: React.FC = ({ 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 }; + const fill = isPeak ? (peakColor ?? zone) : zone; + const common = { rx: 1.2, fill, 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)); + const hottestShown = Math.max(...values.map(v => clamp(v, min, max))); return (
= ({ aria-valuenow={hottestShown} aria-label={aria['aria-label']} data-clipped={clipped ? '' : undefined} - data-disabled={disabled ? '' : undefined} style={{ - display: fill ? 'flex' : 'inline-flex', + display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 5, - width: fill && !vertical ? '100%' : w, - height: fill && vertical ? '100%' : undefined, - opacity: disabled ? 0.45 : undefined, + width: w, ...style, }} > - {(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 && ( +
+ + {unit && ( + + {unit} + )}
)} - - {leadingIcon && ( - - )} - {children} - - )} - {name && } + {children} + {name && } ); }, ); - -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..92b2cfa 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, @@ -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/SegmentSwitch.tsx b/packages/dreamknob/src/skins/SegmentSwitch.tsx index 153bc0a..ae89925 100644 --- a/packages/dreamknob/src/skins/SegmentSwitch.tsx +++ b/packages/dreamknob/src/skins/SegmentSwitch.tsx @@ -46,7 +46,6 @@ export const SegmentSwitch = React.forwardRef(fu textColor, labelColor, label, - sublabel, - labelSize, showValue = true, format, focusRing, @@ -108,8 +106,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/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", "%". */