diff --git a/README.md b/README.md index 546f3ec..17c3eed 100644 --- a/README.md +++ b/README.md @@ -35,10 +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`, - `Gauge`, `XYPad`, `PushButton`, `IndicatorLamp`, `LampRow`, `ToggleSwitch`, - `SegmentSwitch`, `TransportButton`, `ScrubField`, `SegmentDisplay`, `AlphaDisplay`, - plus `LabeledField`/`Rack` layout — 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`. @@ -50,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.1/dreamknob-1.1.1.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 2d4f086..3cffec2 100644 --- a/apps/docs/src/sections/ApiDocs.tsx +++ b/apps/docs/src/sections/ApiDocs.tsx @@ -128,14 +128,16 @@ export const ApiDocs: React.FC = () => ( + - + - + - - + + + @@ -193,6 +195,7 @@ export const ApiDocs: React.FC = () => ( 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) }} > @@ -310,12 +313,25 @@ function MyKnob() { -// A declarative channel strip +// 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 050b87f..2158ee2 100644 --- a/apps/docs/src/sections/Gallery.tsx +++ b/apps/docs/src/sections/Gallery.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { AlphaDisplay, Arc, + Button, DreamknobProvider, Fader, FlatKnob, @@ -18,6 +19,7 @@ import { Meter, MeterBridge, NeonKnob, + PanKnob, Pointer, PushButton, Rack, @@ -280,7 +282,10 @@ const PanelDemo: React.FC = () => { - +
+ + +
{ ); }; +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; @@ -505,10 +601,18 @@ export const Gallery: React.FC = () => (
+ + + + + + + + @@ -536,6 +640,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.1.4/dreamknob-1.1.4.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`} />

Installs straight from the{' '} - v1.1.1 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 f71020d..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.1/dreamknob-1.1.1.tgz + pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz ); }, ); + +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/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(null); + const chrome = panelButtonChrome(theme.scheme, active); const accent = color ?? @@ -145,17 +146,11 @@ export const TransportButton = React.forwardRef { + 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; +};