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

- Every knob skin and fader also takes label, showValue,{' '} - unit, format, className,{' '} - style, name plus every core prop above, and forwards a - ref to its root element (VintageKnob has no readout, so it omits{' '} + Every knob skin and fader also takes label, sublabel{' '} + (a second caption line — SEED over world #7),{' '} + labelSize (caption size independent of the dial diameter),{' '} + showValue, unit, format,{' '} + className, style, name plus every core prop + above, and forwards a ref to its root element (VintageKnob has no readout, so it omits{' '} showValue/unit/format; the display components Meter/SegmentDisplay/AlphaDisplay{' '} have their own smaller prop sets). Primitive-drawn parts carry{' '} @@ -180,8 +190,12 @@ 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 and - zoneHot: '#ff4d6b', // LEDFader when no zones are given + 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) }} > @@ -234,6 +248,18 @@ 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. @@ -265,7 +291,47 @@ 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 new file mode 100644 index 0000000..1cf6753 --- /dev/null +++ b/apps/docs/src/sections/Buttons.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { + Button, + PushButton, + TransportButton, + ToggleSwitch, + SegmentSwitch, +} from 'dreamknob'; + +/** Small glyph for a button's leadingIcon (the docs app has no icon dep). */ +const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + +); + +/** The button/switch family, side by side, with a "which do I use?" guide. */ +export const Buttons: React.FC = () => { + const [playing, setPlaying] = React.useState(false); + const [armed, setArmed] = React.useState(false); + const [bypass, setBypass] = React.useState(false); + const [fresh, setFresh] = React.useState(true); + const [mode, setMode] = React.useState(0); + const [fired, setFired] = React.useState(0); + + return ( +
+
+
Buttons & switches
+

Press, latch, or pick

+

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

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

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

+
+
+ ); +}; + +const GuideRow: React.FC<{ code: string; when: string; eg: string }> = ({ code, when, eg }) => ( +
+ {code} + {when} + {eg} +
+); + +const Demo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+
{label}
+
{children}
+
+); diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx index eebd097..2158ee2 100644 --- a/apps/docs/src/sections/Gallery.tsx +++ b/apps/docs/src/sections/Gallery.tsx @@ -2,27 +2,35 @@ import React from 'react'; import { AlphaDisplay, Arc, + Button, DreamknobProvider, Fader, FlatKnob, + Gauge, ImageKnob, Knob, KnobValue, IndicatorLamp, + LabeledField, + LampRow, LEDFader, LEDKnob, MetalKnob, Meter, MeterBridge, NeonKnob, + PanKnob, Pointer, PushButton, + Rack, RubberKnob, ScrubField, SegmentSwitch, SegmentDisplay, SteppedKnob, Ticks, + ToggleSwitch, + TransportButton, VintageKnob, XYPad, logTaper, @@ -197,6 +205,189 @@ 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; @@ -410,10 +601,18 @@ export const Gallery: React.FC = () => (
+ + + + + + + + @@ -429,6 +628,22 @@ export const Gallery: React.FC = () => ( + + + + + + + + + + + + + + + + ( ) · peer deps: react >= 18, react-dom >= 18`} + code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`} />

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

- pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz + pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz + ) : ( + {inner} + ); + })} +
+ ); +}; diff --git a/packages/dreamknob/src/skins/MetalKnob.tsx b/packages/dreamknob/src/skins/MetalKnob.tsx index 26690e0..e45afa9 100644 --- a/packages/dreamknob/src/skins/MetalKnob.tsx +++ b/packages/dreamknob/src/skins/MetalKnob.tsx @@ -33,6 +33,8 @@ export const MetalKnob = React.forwardRef(functi showArc = true, tickCount = 21, label, + sublabel, + labelSize, showValue = false, unit, format, @@ -137,7 +139,8 @@ export const MetalKnob = React.forwardRef(functi color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.44} - fontSize={size * 0.1} + fontSize={labelSize ?? size * 0.1} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/Meter.tsx b/packages/dreamknob/src/skins/Meter.tsx index d088b48..ba348be 100644 --- a/packages/dreamknob/src/skins/Meter.tsx +++ b/packages/dreamknob/src/skins/Meter.tsx @@ -4,19 +4,36 @@ 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. + * meter (e.g. `[l, r]` for stereo). The meter is read-only. With + * `scale="db"` these are linear amplitudes (0..1); otherwise they are in the + * `min`..`max` domain directly. */ value: number | readonly number[]; min?: number; max?: number; taper?: Taper; + /** + * 'linear' (default) shows `value` in the `min`..`max` domain. 'db' treats + * `value`/`clipValue` as linear amplitude and displays dBFS; `min`/`max` + * then default to -60 / 0 dB. + */ + scale?: 'linear' | 'db'; orientation?: 'vertical' | 'horizontal'; /** Travel length in px. Default: 160. */ length?: number; + /** + * 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. */ @@ -27,16 +44,42 @@ 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 at/above which the clip LED lights. Default: `max`. */ + /** Value (in the display domain — dB for scale='db') at/above which it lights. Default: `max`. */ clipThreshold?: number; /** * Signal(s) used for clip detection when different from the displayed @@ -70,31 +113,45 @@ 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 = 0, - max = 100, + min: minProp, + max: maxProp, 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 = '#0b0d0e', + faceColor: faceColorProp, label, showValue = false, digits = 4, @@ -106,27 +163,51 @@ 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; - const ns = values.map(v => clamp(taper.toNormalized(clamp(v, min, max), min, max), 0, 1)); + // 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)); - // Peaks: per channel, falling back together after `peakHold` ms of no rise. - const [peaks, setPeaks] = React.useState(ns); + // rAF engine (ballistics / peak-decay / loudness). Null on the legacy path. + const animated = !!ballistics || peakDecay != null || integrated; + const engine = useMeterEngine(targetNs, domain, { + enabled: animated, + ballistics, + peakHold, + peakDecay: peakDecay ?? null, + integrated, + integrationWindow, + scale, + }); + const levels = animated && engine ? engine.levels : targetNs; + + // Legacy timer-based peaks (only when the engine is off). + const [legacyPeaks, setLegacyPeaks] = React.useState(targetNs); React.useEffect(() => { - if (peakHold === false) return; - 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)); + 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(() => setPeaks(ns), peakHold); + const t = setTimeout(() => setLegacyPeaks(targetNs), peakHold); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(ns), peakHold]); + }, [JSON.stringify(targetNs), peakHold, animated]); + const peaks = animated && engine ? engine.peaks : legacyPeaks; // Clip: latch when any channel's raw (unclamped) signal reaches threshold. - const clipSignals = clipValue !== undefined ? asArray(clipValue) : values; + const clipSignals = (clipValue !== undefined ? asArray(clipValue) : values).map(v => + dbScale ? ampToDb(v) : v, + ); const hottest = Math.max(...clipSignals); const [clipped, setClipped] = React.useState(false); const clipTimer = React.useRef>(); @@ -172,14 +253,20 @@ export const Meter: React.FC = ({ const clipLed = 7; const clipSpan = showClip ? clipLed + 3 : 0; - const slot = Math.max(1, (along - pad * 2 - clipSpan) / segments); + const innerLen = along - pad * 2 - clipSpan; + const slot = Math.max(1, innerLen / segments); const gap = Math.min(2.5, slot * 0.35); const 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((ns[c] ?? 0) * segments); + 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) @@ -188,8 +275,8 @@ export const Meter: React.FC = ({ 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 }; + const barFill = isPeak ? (peakColor ?? zone) : zone; + const common = { rx: 1.2, fill: barFill, opacity: on ? 1 : offOpacity }; bars.push( vertical ? ( = ({ } } - const hottestShown = Math.max(...values.map(v => clamp(v, min, max))); + // Reference gridlines (value domain -> position across the bar area). + const ticks = referenceTicks ?? (dbScale ? DEFAULT_DB_TICKS : []); + const drawScale = (showScale ?? dbScale) && ticks.length > 0; + const scaleLines: React.ReactNode[] = []; + if (drawScale) { + for (const tv of ticks) { + if (tv < min || tv > max) continue; + const nrm = clamp(taper.toNormalized(clamp(tv, min, max), min, max), 0, 1); + const p = posOf(nrm); + scaleLines.push( + vertical ? ( + + ) : ( + + ), + ); + } + } + + // Integrated-loudness marker line. + const loud = loudnessColor ?? theme.zoneWarn; + let loudnessLine: React.ReactNode = null; + if (integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated)) { + const nrm = clamp(taper.toNormalized(clamp(engine.integrated, min, max), min, max), 0, 1); + if (nrm > 0.001) { + const p = posOf(nrm); + loudnessLine = vertical ? ( + + ) : ( + + ); + } + } + + const shownDomain = domain.length ? domain.map(d => clamp(d, min, max)) : [min]; + const hottestShown = Math.max(...shownDomain); + const readoutColor = zoneFor(Math.max(...levels, 0)); return (
= ({ aria-valuenow={hottestShown} aria-label={aria['aria-label']} data-clipped={clipped ? '' : undefined} + data-disabled={disabled ? '' : undefined} style={{ - display: 'inline-flex', + display: fill ? 'flex' : 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 5, - width: w, + width: fill && !vertical ? '100%' : w, + height: fill && vertical ? '100%' : undefined, + opacity: disabled ? 0.45 : undefined, ...style, }} > - {showValue && ( -
- - {unit && ( - - {unit} - + {(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'} + +
)}
)} - + {leadingIcon && ( + + )} + {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 92b2cfa..1f1cc09 100644 --- a/packages/dreamknob/src/skins/RubberKnob.tsx +++ b/packages/dreamknob/src/skins/RubberKnob.tsx @@ -25,6 +25,8 @@ export const RubberKnob = React.forwardRef(func labelColor, arcFrom = 'min', label, + sublabel, + labelSize, showValue = false, unit, format, @@ -103,7 +105,8 @@ export const RubberKnob = React.forwardRef(func color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.44} - fontSize={size * 0.1} + fontSize={labelSize ?? size * 0.1} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/SegmentSwitch.tsx b/packages/dreamknob/src/skins/SegmentSwitch.tsx index ae89925..153bc0a 100644 --- a/packages/dreamknob/src/skins/SegmentSwitch.tsx +++ b/packages/dreamknob/src/skins/SegmentSwitch.tsx @@ -46,6 +46,7 @@ export const SegmentSwitch = React.forwardRef(fu textColor, labelColor, label, + sublabel, + labelSize, showValue = true, format, focusRing, @@ -106,7 +108,8 @@ export const SteppedKnob = React.forwardRef(fu color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.45} - fontSize={size * 0.095} + fontSize={labelSize ?? size * 0.095} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/ToggleSwitch.tsx b/packages/dreamknob/src/skins/ToggleSwitch.tsx new file mode 100644 index 0000000..e6ffa8d --- /dev/null +++ b/packages/dreamknob/src/skins/ToggleSwitch.tsx @@ -0,0 +1,195 @@ +import * as React from 'react'; +import { useKnobTheme } from '../core/theme'; + +export interface ToggleSwitchProps { + /** Controlled on-state. Pair with `onChange`. */ + on?: boolean; + defaultOn?: boolean; + onChange?: (on: boolean) => void; + /** Accent color when on. Defaults to the theme accent. */ + color?: string; + /** Track height in px (the knob is sized from it). Default: 22. */ + size?: number; + /** Caption beside the switch. */ + label?: string; + labelPosition?: 'right' | 'left'; + /** Tiny OFF/ON legend printed on the track. Default: false. */ + showStateLabel?: boolean; + /** Text for the on/off legend when `showStateLabel`. Default: 'ON'/'OFF'. */ + onLabel?: string; + offLabel?: string; + disabled?: boolean; + /** Render a hidden form input carrying "on"/"off". */ + name?: string; + className?: string; + style?: React.CSSProperties; + 'aria-label'?: string; +} + +/** + * A rocker/slider switch — the most literal boolean idiom. Renders + * `role="switch"`; the knob slides between OFF and ON. Space/Enter toggles, + * ←/→ set off/on. + */ +export const ToggleSwitch = React.forwardRef( + function ToggleSwitch( + { + on, + defaultOn = false, + onChange, + color, + size = 22, + label, + labelPosition = 'right', + showStateLabel = false, + onLabel = 'ON', + offLabel = 'OFF', + disabled = false, + name, + className, + style, + ...aria + }, + ref, + ) { + const theme = useKnobTheme(); + const accent = color ?? theme.accent ?? '#4cc2ff'; + const isControlled = on !== undefined; + const [internal, setInternal] = React.useState(defaultOn); + const [focusVisible, setFocusVisible] = React.useState(false); + const lit = isControlled ? (on as boolean) : internal; + + const set = (next: boolean) => { + if (next === lit) return; + if (!isControlled) setInternal(next); + onChange?.(next); + }; + + const trackW = size * 1.85; + const trackH = size; + const knobD = size - 6; + const pad = 3; + const knobX = lit ? trackW - knobD - pad : pad; + + const legend = (side: 'on' | 'off') => ( + + ); + + return ( + + ); + }, +); diff --git a/packages/dreamknob/src/skins/TransportButton.tsx b/packages/dreamknob/src/skins/TransportButton.tsx new file mode 100644 index 0000000..b6763ab --- /dev/null +++ b/packages/dreamknob/src/skins/TransportButton.tsx @@ -0,0 +1,180 @@ +import * as React from 'react'; +import { panelButtonChrome, useKnobTheme } from '../core/theme'; + +export type TransportKind = + | 'play' + | 'pause' + | 'stop' + | 'record' + | 'rewind' + | 'forward' + | 'panic'; + +export interface TransportButtonProps { + kind: TransportKind; + /** Engaged/lit state (playing, armed, recording). Reflects app state. */ + active?: boolean; + onClick?: () => void; + /** Pulse the glyph while active (auto-on for `record`). Reduced-motion aware. */ + blink?: boolean; + /** Override the glyph/LED color. Defaults per kind (play green, record red…). */ + color?: string; + /** Button size in px (square). Default: 38. */ + size?: number; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + 'aria-label'?: string; +} + +const LABEL: Record = { + play: 'Play', + pause: 'Pause', + stop: 'Stop', + record: 'Record', + rewind: 'Rewind', + forward: 'Forward', + panic: 'Panic — all notes off', +}; + +/** Glyph in a 24×24 box, filled with `currentColor`. */ +const Glyph: React.FC<{ kind: TransportKind }> = ({ kind }) => { + switch (kind) { + case 'play': + return ; + case 'pause': + return ( + <> + + + + ); + case 'stop': + return ; + case 'record': + return ; + case 'rewind': + return ( + <> + + + + ); + case 'forward': + return ( + <> + + + + ); + case 'panic': + return ( + <> + + + + ); + } +}; + +/** + * A record / play / stop / pause / panic transport button, styled to sit + * alongside dreamknob controls. `active` lights it; `record` blinks while + * armed. Reduced-motion aware. + */ +export const TransportButton = React.forwardRef( + function TransportButton( + { kind, active = false, onClick, blink, color, size = 38, disabled = false, className, style, ...aria }, + ref, + ) { + const theme = useKnobTheme(); + const [focusVisible, setFocusVisible] = React.useState(false); + const glyphRef = React.useRef(null); + const chrome = panelButtonChrome(theme.scheme, active); + + const accent = + color ?? + (kind === 'record' || kind === 'panic' + ? theme.zoneHot + : kind === 'play' + ? theme.zoneGood + : kind === 'pause' + ? theme.zoneWarn + : theme.text); + const lit = active ? accent : theme.label; + const shouldBlink = (blink ?? kind === 'record') && active; + + React.useEffect(() => { + const el = glyphRef.current; + if (!el || !shouldBlink) return; + if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) + return; + const anim = el.animate([{ opacity: 1 }, { opacity: 0.3 }], { + duration: 560, + iterations: Infinity, + direction: 'alternate', + easing: 'ease-in-out', + }); + return () => anim.cancel(); + }, [shouldBlink]); + + return ( + + ); + }, +); diff --git a/packages/dreamknob/src/skins/VintageKnob.tsx b/packages/dreamknob/src/skins/VintageKnob.tsx index 123a1f4..8c97c64 100644 --- a/packages/dreamknob/src/skins/VintageKnob.tsx +++ b/packages/dreamknob/src/skins/VintageKnob.tsx @@ -103,6 +103,8 @@ export const VintageKnob = React.forwardRef(fu scaleLabels, indicatorColor, label, + sublabel, + labelSize, focusRing, className, style, @@ -132,7 +134,8 @@ export const VintageKnob = React.forwardRef(fu color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.46} - fontSize={size * 0.095} + fontSize={labelSize ?? size * 0.095} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/meterEngine.test.ts b/packages/dreamknob/src/skins/meterEngine.test.ts new file mode 100644 index 0000000..3b655fe --- /dev/null +++ b/packages/dreamknob/src/skins/meterEngine.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { ampToDb, dbToAmp, smoothingCoeff } from './meterEngine'; + +describe('ampToDb', () => { + it('maps unity amplitude to 0 dBFS', () => { + expect(ampToDb(1)).toBeCloseTo(0, 6); + }); + + it('halving amplitude is about -6 dB', () => { + expect(ampToDb(0.5)).toBeCloseTo(-6.0206, 3); + }); + + it('a tenth is -20 dB', () => { + expect(ampToDb(0.1)).toBeCloseTo(-20, 6); + }); + + it('silence is -Infinity', () => { + expect(ampToDb(0)).toBe(-Infinity); + expect(ampToDb(-0.2)).toBe(-Infinity); + }); + + it('round-trips through dbToAmp', () => { + for (const amp of [1, 0.5, 0.25, 0.031]) { + expect(dbToAmp(ampToDb(amp))).toBeCloseTo(amp, 6); + } + }); +}); + +describe('smoothingCoeff', () => { + it('is in (0,1) and rises with dt', () => { + const a = smoothingCoeff(5, 100); + const b = smoothingCoeff(50, 100); + expect(a).toBeGreaterThan(0); + expect(b).toBeLessThan(1); + expect(b).toBeGreaterThan(a); + }); + + it('reaches ~63% of the way after one time constant', () => { + expect(smoothingCoeff(100, 100)).toBeCloseTo(1 - Math.exp(-1), 6); + }); + + it('a shorter tau (faster attack) moves further per step', () => { + expect(smoothingCoeff(10, 5)).toBeGreaterThan(smoothingCoeff(10, 350)); + }); +}); diff --git a/packages/dreamknob/src/skins/meterEngine.ts b/packages/dreamknob/src/skins/meterEngine.ts new file mode 100644 index 0000000..909fe93 --- /dev/null +++ b/packages/dreamknob/src/skins/meterEngine.ts @@ -0,0 +1,167 @@ +import * as React from 'react'; + +/** Linear amplitude (0..1+) to dBFS. 0 (or below) maps to -Infinity. */ +export const ampToDb = (amp: number): number => (amp <= 0 ? -Infinity : 20 * Math.log10(amp)); + +/** dBFS back to linear amplitude. */ +export const dbToAmp = (db: number): number => Math.pow(10, db / 20); + +/** One-pole smoothing coefficient for a time constant `tau` (ms) over `dt` (ms). */ +export const smoothingCoeff = (dt: number, tau: number): number => + 1 - Math.exp(-dt / Math.max(1, tau)); + +export interface Ballistics { + /** Rise time constant in ms (fast). */ + attack?: number; + /** Fall time constant in ms (slow). */ + decay?: number; +} + +export interface MeterEngineOptions { + /** Run the rAF engine at all. When false the hook returns null (legacy path). */ + enabled: boolean; + /** Smooth the displayed level with attack/decay ballistics. */ + ballistics?: boolean | Ballistics; + /** Peak-hold time in ms before the peak marker starts to fall. */ + peakHold?: number | false; + /** Peak fall rate in normalized units per second once the hold expires. */ + peakDecay?: number | null; + /** Compute a time-windowed loudness (LUFS-ish) from `rawDomain`. */ + integrated?: boolean; + /** Loudness integration window in ms. */ + integrationWindow?: number; + /** 'db' integrates power in the log domain; 'linear' uses RMS. */ + scale?: 'linear' | 'db'; +} + +export interface MeterEngineState { + /** Ballistics-smoothed (or raw) normalized levels, per channel. */ + levels: readonly number[]; + /** Peak-hold markers, per channel, in normalized [0,1]. */ + peaks: readonly number[]; + /** Windowed loudness in the value domain (dB for 'db' scale), or null. */ + integrated: number | null; +} + +const DEFAULT_ATTACK = 5; +const DEFAULT_DECAY = 350; + +/** + * A requestAnimationFrame meter engine: attack/decay ballistics, peak-hold with + * a decay tail, and an optional time-windowed loudness estimate. Returns null + * when disabled so the caller can keep its cheap legacy (timer) path. + * + * `targetNs` are the instantaneous normalized levels; `rawDomain` are the same + * channels in value space (dB or linear) for loudness integration. + */ +export const useMeterEngine = ( + targetNs: readonly number[], + rawDomain: readonly number[], + opts: MeterEngineOptions, +): MeterEngineState | null => { + const { + enabled, + ballistics, + peakHold = 1200, + peakDecay = null, + integrated = false, + integrationWindow = 3000, + scale = 'linear', + } = opts; + + const useBallistics = !!ballistics; + const attack = (typeof ballistics === 'object' && ballistics?.attack) || DEFAULT_ATTACK; + const decay = (typeof ballistics === 'object' && ballistics?.decay) || DEFAULT_DECAY; + + const targetRef = React.useRef(targetNs); + targetRef.current = targetNs; + const rawRef = React.useRef(rawDomain); + rawRef.current = rawDomain; + + const [state, setState] = React.useState(() => ({ + levels: targetNs, + peaks: targetNs, + integrated: null, + })); + + // Per-channel timestamp of the last time the peak was refreshed. + const holdAt = React.useRef([]); + // Ring buffer of {t, power} for loudness integration. + const powerBuf = React.useRef<{ t: number; p: number }[]>([]); + + React.useEffect(() => { + if (!enabled) return; + let raf = 0; + let last = + typeof performance !== 'undefined' && performance.now ? performance.now() : 0; + + const tick = (now: number) => { + const dt = Math.min(100, now - last); + last = now; + const tgt = targetRef.current; + const raw = rawRef.current; + + setState(prev => { + const n = tgt.length; + const prevLevels = prev.levels.length === n ? prev.levels : tgt; + const prevPeaks = prev.peaks.length === n ? prev.peaks : tgt; + + const levels = tgt.map((t, i) => { + const l = prevLevels[i] ?? 0; + if (!useBallistics) return t; + const tau = t >= l ? attack : decay; + return l + (t - l) * smoothingCoeff(dt, tau); + }); + + const holdMs = peakHold === false ? Infinity : peakHold; + const peaks = levels.map((lvl, i) => { + const p = prevPeaks[i] ?? 0; + if (lvl >= p) { + holdAt.current[i] = now; + return lvl; + } + const heldFor = now - (holdAt.current[i] ?? now); + if (heldFor < holdMs) return p; + if (peakDecay == null) return lvl; // no decay tail — track the level + return Math.max(lvl, p - (peakDecay / 1000) * dt); + }); + + let integratedOut = prev.integrated; + if (integrated) { + const power = + raw.length > 0 + ? raw.reduce( + (s, d) => s + (scale === 'db' ? Math.pow(10, d / 10) : d * d), + 0, + ) / raw.length + : 0; + const buf = powerBuf.current; + buf.push({ t: now, p: power }); + while (buf.length > 1 && now - buf[0].t > integrationWindow) buf.shift(); + const meanP = buf.reduce((s, b) => s + b.p, 0) / (buf.length || 1); + integratedOut = + scale === 'db' ? 10 * Math.log10(Math.max(meanP, 1e-12)) : Math.sqrt(meanP); + } + + return { levels, peaks, integrated: integratedOut }; + }); + + raf = requestAnimationFrame(tick); + }; + + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [ + enabled, + useBallistics, + attack, + decay, + peakHold, + peakDecay, + integrated, + integrationWindow, + scale, + ]); + + return enabled ? state : null; +}; diff --git a/packages/dreamknob/src/skins/shared.ts b/packages/dreamknob/src/skins/shared.ts index ddadc36..5eb989a 100644 --- a/packages/dreamknob/src/skins/shared.ts +++ b/packages/dreamknob/src/skins/shared.ts @@ -9,6 +9,13 @@ export interface SkinProps extends KnobCoreProps { size?: number; /** Caption drawn in the travel gap at the bottom of the knob. */ label?: string; + /** + * Secondary caption line under the label — a unit or live descriptor + * (`SEED` over `world #7`). Rendered smaller and un-uppercased. + */ + sublabel?: React.ReactNode; + /** Label font size in px, independent of `size` (small knobs, readable labels). */ + labelSize?: number; /** Show the numeric readout. Default varies per skin. */ showValue?: boolean; /** Unit suffix for the readout, e.g. "dB", "Hz", "%". */