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

Installs straight from the{' '} - v1.1.4 release + v1.1.1 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..f71020d 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.1/dreamknob-1.1.1.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 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(null); - const chrome = panelButtonChrome(theme.scheme, active); const accent = color ?? @@ -146,11 +145,17 @@ 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 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; -};