Compare commits

..

No commits in common. "master" and "v1.1.2" have entirely different histories.

22 changed files with 117 additions and 817 deletions

View file

@ -35,11 +35,10 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log / rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
power / custom tapers. power / custom tapers.
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`, - **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
`SteppedKnob`, `PanKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter` `SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `MeterBridge`,
(PPM ballistics · dBFS · loudness), `MeterBridge`, `Gauge`, `XYPad`, `PushButton`, `Gauge`, `XYPad`, `PushButton`, `IndicatorLamp`, `LampRow`, `ToggleSwitch`,
`Button`, `IndicatorLamp`, `LampRow`, `ToggleSwitch`, `SegmentSwitch`, `SegmentSwitch`, `TransportButton`, `ScrubField`, `SegmentDisplay`, `AlphaDisplay`,
`TransportButton`, `ScrubField`, `SegmentDisplay`, `AlphaDisplay`, plus plus `LabeledField`/`Rack` layout — every color themeable per instance.
`LabeledField`/`Rack` layout — every color themeable per instance.
- **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`, - **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
`KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with `KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
`useKnob`. `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: The easiest way is the published release on our Forgejo:
```bash ```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.2/dreamknob-1.1.2.tgz
``` ```
Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types): Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types):

View file

@ -128,15 +128,14 @@ export const ApiDocs: React.FC = () => (
<Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals (any precision), unit label under the digits." /> <Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals (any precision), unit label under the digits." />
<Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." /> <Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." />
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." /> <Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
<Row name="<PanKnob />" type="2D · bipolar" desc="Stereo pan: fills from center, snaps to a center detent, reads out L<n>/C/R<n>. Defaults to 50…50 centered at 0; pass min/max for other conventions." />
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format, valuePosition ('top' | 'end' | 'none'), fill (stretch to container)." /> <Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format, valuePosition ('top' | 'end' | 'none'), fill (stretch to container)." />
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}] — defaults to the theme's green/amber/red), glow, digits, unit, fill (stretch to container)." /> <Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}] — defaults to the theme's green/amber/red), glow, digits, unit, fill (stretch to container)." />
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." /> <Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
<Row name="<Meter />" type="display" desc="LED level meter: value (number or [l, r, ...]), zones, peakHold, peakDecay (fall tail), ballistics (PPM attack/decay), scale='db' (feed amplitude → dBFS + reference lines), integrated (windowed loudness), fill, disabled, seven-seg readout, clip LED (showClip/clipThreshold/clipHold 'latch'/clipColor/onClip). All the new metering is opt-in." /> <Row name="<Meter />" type="display" desc="Read-only LED level meter: value (number or [l, r, ...]), zones (theme green/amber/red), peakHold, peakColor, orientation, unit, fill (stretch to container), disabled, seven-segment readout. Clip LED: showClip, clipThreshold, clipHold (ms or 'latch'), clipColor, onClip." />
<Row name="<MeterBridge />" type="display" desc="Labeled multi-strip meter block: channels ([{label, value, clipValue?}]), per-channel peak-hold text, shared clip LED — the mixer meter row, prebuilt." /> <Row name="<MeterBridge />" type="display" desc="Labeled multi-strip meter block: channels ([{label, value, clipValue?}]), per-channel peak-hold text, shared clip LED — the mixer meter row, prebuilt." />
<Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs (each with min/max/step/invert/detents), drag + arrow keys (arrows follow the visual direction), grid, crosshair, Escape cancel, double-click reset." /> <Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs (each with min/max/step/invert/detents), drag + arrow keys (arrows follow the visual direction), grid, crosshair, Escape cancel, double-click reset." />
<Row name="<Button />" type="action" desc="Click-to-fire command button in the panel style (Regenerate, Add, Apply). PushButton locked to mode='action' — never latches, press-down, onClick. No LED by default (an action has no state); opt in with led='dot' | 'strip'. Forwards native button props → usable as a Radix asChild trigger." /> <Row name="<Button />" type="action" desc="Click-to-fire command button in the panel style (Regenerate, Add, Apply). PushButton locked to mode='action' — never latches, press-down + LED flash, onClick. led: 'dot' (default) | 'strip' | false." />
<Row name="<PushButton />" type="switch" desc="Panel button: mode 'toggle' | 'momentary' | 'action', controlled/uncontrolled, aria-pressed. led defaults to 'strip' for toggle/momentary (state) and off for action; also 'dot'. leadingIcon slot. Forwards ...rest and composes handlers, so it works as a dropdown/tooltip/popover trigger." /> <Row name="<PushButton />" type="switch" desc="Panel button: mode 'toggle' | 'momentary' | 'action', controlled/uncontrolled, aria-pressed. led: 'strip' | 'dot'. leadingIcon slot tinted to the LED." />
<Row name="<IndicatorLamp />" type="switch · display" desc="Panel LED lamp: pressable toggle (role='switch') or read-only status light, blink, momentary, label either side (labelSize to shrink the caption below its 10px floor) — the checkbox, in hardware form." /> <Row name="<IndicatorLamp />" type="switch · display" desc="Panel LED lamp: pressable toggle (role='switch') or read-only status light, blink, momentary, label either side (labelSize to shrink the caption below its 10px floor) — the checkbox, in hardware form." />
<Row name="<SegmentSwitch />" type="selector" desc="Segmented option switch (radiogroup): options list, LED tick on the active cell, arrow/Home/End keys, controlled/uncontrolled index." /> <Row name="<SegmentSwitch />" type="selector" desc="Segmented option switch (radiogroup): options list, LED tick on the active cell, arrow/Home/End keys, controlled/uncontrolled index." />
<Row name="<ScrubField />" type="numeric field" desc="After-Effects-style number field: drag the label to scrub (Shift = fine), click to type (Enter commits, Esc reverts), arrows step, wheel nudges. role='spinbutton'." /> <Row name="<ScrubField />" type="numeric field" desc="After-Effects-style number field: drag the label to scrub (Shift = fine), click to type (Enter commits, Esc reverts), arrows step, wheel nudges. role='spinbutton'." />
@ -195,7 +194,6 @@ export const ApiDocs: React.FC = () => (
ledGreen: '#3df2ad', // SegmentDisplay default lit color ledGreen: '#3df2ad', // SegmentDisplay default lit color
ledAmber: '#ffb84d', // AlphaDisplay default lit color ledAmber: '#ffb84d', // AlphaDisplay default lit color
panel: 'rgba(255,255,255,0.03)', // Rack / panel chrome panel: 'rgba(255,255,255,0.03)', // Rack / panel chrome
ledWell: '#0b0d0e', // LED display well (LEDKnob/LEDFader/Meter)
}} }}
> >
<FlatKnob ... /> <Fader ... /> <LEDKnob ... /> <FlatKnob ... /> <Fader ... /> <LEDKnob ... />
@ -313,25 +311,12 @@ function MyKnob() {
<ToggleSwitch defaultOn showStateLabel /> <ToggleSwitch defaultOn showStateLabel />
</LabeledField> </LabeledField>
// A declarative channel strip — now with a pan knob // A declarative channel strip
<Rack orientation="column" title="Channel 1"> <Rack orientation="column" title="Channel 1">
<FlatKnob label="Gain" sublabel="dB" /> <FlatKnob label="Gain" sublabel="dB" />
<PanKnob size={56} defaultValue={0} />
<Fader orientation="horizontal" fill valuePosition="end" /> <Fader orientation="horizontal" fill valuePosition="end" />
<Meter value={level} min={-60} max={6} fill orientation="horizontal" /> <Meter value={level} min={-60} max={6} fill orientation="horizontal" />
</Rack> </Rack>`}
// A pro dBFS meter — feed amplitude, get ballistics + peak-decay + loudness
<Meter value={[ampL, ampR]} scale="db" ballistics
peakHold={1500} peakDecay={0.25} integrated showValue unit="dB" />
// A Button as a Radix dropdown trigger (it forwards + composes handlers)
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button led={false}>Add channel</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>{/* … */}</DropdownMenuContent>
</DropdownMenu>`}
/> />
<h3 className="api-h">Precision &amp; number handling</h3> <h3 className="api-h">Precision &amp; number handling</h3>

View file

@ -82,7 +82,7 @@ export const Buttons: React.FC = () => {
</Button> </Button>
<Button <Button
color="#ff4d6b" color="#ff4d6b"
led="strip" led={false}
leadingIcon={<Glyph></Glyph>} leadingIcon={<Glyph></Glyph>}
onClick={() => setFired(n => n + 1)} onClick={() => setFired(n => n + 1)}
aria-label="Clear" aria-label="Clear"
@ -131,10 +131,9 @@ export const Buttons: React.FC = () => {
<p className="section-sub" style={{ marginTop: 20 }}> <p className="section-sub" style={{ marginTop: 20 }}>
<code>&lt;Button&gt;</code> is <code>&lt;PushButton mode=&quot;action&quot;&gt;</code> <code>&lt;Button&gt;</code> is <code>&lt;PushButton mode=&quot;action&quot;&gt;</code>
it never latches, shows a press-down, and calls <code>onClick</code>. It has{' '} it never latches, shows a press-down and a brief LED flash, and calls{' '}
<strong>no LED by default</strong> an action has no state to indicate, unlike a <code>onClick</code>. <code>led=&quot;dot&quot;</code> gives the corner-dot
toggle. Add <code>led=&quot;dot&quot;</code> for a corner dot that flashes on look; <code>led=&#123;false&#125;</code> a plain button.
press, or <code>led=&quot;strip&quot;</code> for the full strip.
</p> </p>
</div> </div>
</section> </section>

View file

@ -2,7 +2,6 @@ import React from 'react';
import { import {
AlphaDisplay, AlphaDisplay,
Arc, Arc,
Button,
DreamknobProvider, DreamknobProvider,
Fader, Fader,
FlatKnob, FlatKnob,
@ -19,7 +18,6 @@ import {
Meter, Meter,
MeterBridge, MeterBridge,
NeonKnob, NeonKnob,
PanKnob,
Pointer, Pointer,
PushButton, PushButton,
Rack, Rack,
@ -282,10 +280,7 @@ const PanelDemo: React.FC = () => {
<LabeledField label="Fresh seed" labelWidth={78}> <LabeledField label="Fresh seed" labelWidth={78}>
<ToggleSwitch on={fresh} onChange={setFresh} color="#c9a6ff" showStateLabel aria-label="Fresh seed" /> <ToggleSwitch on={fresh} onChange={setFresh} color="#c9a6ff" showStateLabel aria-label="Fresh seed" />
</LabeledField> </LabeledField>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<SegmentSwitch options={['SOLID', 'GRAD', 'NOISE']} value={mode} onChange={setMode} color="#c9a6ff" aria-label="Fill mode" /> <SegmentSwitch options={['SOLID', 'GRAD', 'NOISE']} value={mode} onChange={setMode} color="#c9a6ff" aria-label="Fill mode" />
<Button color="#c9a6ff" size={30} title="Regenerate the world" aria-label="Regenerate"></Button>
</div>
<LampRow <LampRow
lamps={[ lamps={[
{ label: 'Cue', on: playing, color: '#3df2ad' }, { label: 'Cue', on: playing, color: '#3df2ad' },
@ -297,97 +292,6 @@ 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, alignItems: 'center' }}>
<Meter
value={amp}
scale="db"
ballistics
peakHold={1400}
peakDecay={0.22}
integrated
clipThreshold={0}
length={140}
showValue
unit="dB"
label="Vertical"
aria-label="dBFS meter"
/>
<Meter
value={amp}
scale="db"
ballistics
peakHold={1400}
peakDecay={0.22}
clipThreshold={0}
orientation="horizontal"
length={190}
breadth={14}
label="Horizontal"
aria-label="dBFS meter horizontal"
/>
</div>
);
};
const PanDemo: React.FC = () => (
<div style={{ display: 'flex', gap: 22, alignItems: 'center' }}>
<PanKnob size={84} defaultValue={0} color="#4cc2ff" aria-label="Pan center" />
<PanKnob size={84} defaultValue={-30} color="#3df2ad" label="Track 2" aria-label="Pan left" />
</div>
);
const LightThemeDemo: React.FC = () => {
const [play, setPlay] = React.useState(false);
const [mute, setMute] = React.useState(true);
return (
<DreamknobProvider base="light" theme={{ accent: '#0868c8' }}>
<div
style={{
background: '#eceef2',
borderRadius: 12,
padding: 18,
display: 'flex',
flexDirection: 'column',
gap: 16,
alignItems: 'center',
}}
>
<div style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
<LEDKnob size={74} min={20} max={2000} defaultValue={440} taper={logTaper} label="Freq" unit="Hz" aria-label="Light freq" />
<SegmentDisplay value={128.4} digits={4} decimals={1} height={22} />
<Meter value={[62, 44]} min={0} max={100} length={92} breadth={14} aria-label="Light meter" />
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<PushButton pressed={mute} onChange={setMute} color="#d92546" size={32} aria-label="Mute">
Mute
</PushButton>
<Button color="#0868c8" size={32} onClick={() => undefined} aria-label="Apply">
Apply
</Button>
<TransportButton kind="play" active={play} onClick={() => setPlay(p => !p)} size={32} />
<SegmentSwitch options={['LP', 'BP', 'HP']} defaultValue={1} aria-label="Light filter" />
</div>
</div>
</DreamknobProvider>
);
};
const ImageKnobDemo: React.FC = () => { const ImageKnobDemo: React.FC = () => {
const src = useGeneratedStrip(); const src = useGeneratedStrip();
if (!src) return null; if (!src) return null;
@ -601,18 +505,10 @@ export const Gallery: React.FC = () => (
</div> </div>
</Card> </Card>
<Card title="Light theme" desc="base='light' adapts the whole panel — LED wells go transparent so readouts and meters read as flat segments, and the buttons/switches become light raised chrome instead of dark chips." tag="<DreamknobProvider base='light' />">
<LightThemeDemo />
</Card>
<Card title="Meters" desc="Stereo metering from one component (value={[l, r]}), or a full labeled bridge with per-channel peak text. Click the clip LED to clear it." tag="<Meter /> · <MeterBridge />"> <Card title="Meters" desc="Stereo metering from one component (value={[l, r]}), or a full labeled bridge with per-channel peak text. Click the clip LED to clear it." tag="<Meter /> · <MeterBridge />">
<MeterDemo /> <MeterDemo />
</Card> </Card>
<Card title="dBFS meter" desc="The same <Meter>, in dB mode: feed linear amplitude and it shows dBFS with PPM ballistics, a peak-decay tail, reference lines and a windowed loudness readout." tag="<Meter scale='db' ballistics integrated />">
<DbMeterDemo />
</Card>
<Card title="Lamps & switches" desc="A segmented option switch, pressable indicator lamps (blink included) and a corner-dot button — the whole panel family." tag="<SegmentSwitch /> · <IndicatorLamp />"> <Card title="Lamps & switches" desc="A segmented option switch, pressable indicator lamps (blink included) and a corner-dot button — the whole panel family." tag="<SegmentSwitch /> · <IndicatorLamp />">
<LampSwitchDemo /> <LampSwitchDemo />
</Card> </Card>
@ -640,10 +536,6 @@ export const Gallery: React.FC = () => (
<PanelDemo /> <PanelDemo />
</Card> </Card>
<Card title="Pan" desc="Bipolar stereo pan: fills from center, snaps to a center detent, and reads out L/C/R." tag="<PanKnob />">
<PanDemo />
</Card>
<Card title="Gradient arc" desc="Position-anchored gradient — the red zone stays at the top of travel." tag="<Arc gradient={...} />"> <Card title="Gradient arc" desc="Position-anchored gradient — the red zone stays at the top of travel." tag="<Arc gradient={...} />">
<Knob size={96} min={-60} max={6} step={0.5} defaultValue={-8} aria-label="Gradient arc demo"> <Knob size={96} min={-60} max={6} step={0.5} defaultValue={-8} aria-label="Gradient arc demo">
<Arc <Arc

View file

@ -30,12 +30,12 @@ export const GettingStarted: React.FC = () => (
<Step n={1} title="Install"> <Step n={1} title="Install">
<CodeBlock <CodeBlock
code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz\n# (or npm install <same url>) · 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 <same url>) · peer deps: react >= 18, react-dom >= 18`}
/> />
<p className="api-note" style={{ marginTop: 10 }}> <p className="api-note" style={{ marginTop: 10 }}>
Installs straight from the{' '} Installs straight from the{' '}
<a href="https://git.dreamodus.software/Dreamodus/DreamKnob/releases"> <a href="https://git.dreamodus.software/Dreamodus/DreamKnob/releases">
v1.1.4 release v1.1.1 release
</a>{' '} </a>{' '}
on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No
CSS file to import everything is SVG and inline styles. CSS file to import everything is SVG and inline styles.

View file

@ -25,10 +25,10 @@ export const Hero: React.FC = () => {
range, any decimal precision, and colors you fully control. range, any decimal precision, and colors you fully control.
</p> </p>
<div className="install"> <div className="install">
<span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz</span> <span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz</span>
<button <button
onClick={() => { onClick={() => {
navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz'); navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz');
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1400); setTimeout(() => setCopied(false), 1400);
}} }}

View file

@ -1,69 +1,5 @@
# Changelog # Changelog
## 1.1.4 — 2026-07-14
Light-theme support — the controls whose chrome was hardcoded dark now adapt.
### Themeable LED well
- **`ledWell` theme token** — the recessed "screen" fill behind the LED
segments of `LEDKnob`, `LEDFader`, `Meter`, and the default well of
`SegmentDisplay` / `AlphaDisplay`, now comes from the theme instead of a
hardcoded near-black. Dark base keeps the classic dark well (`#0b0d0e`); the
**light base is `transparent`**, so LED controls and readouts render as clean
flat segment rings / bar meters / digits on the light panel (still clearly
"LED" from the lit segments) instead of dark blobs. The `faceColor` /
`background` props still override per-instance.
### Light-theme panel chrome
- **`scheme` theme token** (`'dark' | 'light'`, set by the `base`) drives the
raised gradient/border/shadow of `PushButton` (and `Button`),
`TransportButton` and `SegmentSwitch`. On a light base they render as light
raised buttons instead of dark chips; unlit LED pips flip to a faint dark so
they still read. New `panelButtonChrome(scheme, pressed)` helper.
- Fixes the whole panel-button/switch family looking like dark chips on light
themes; the flat knobs and faders already adapted (their face is the `face`
token).
## 1.1.3 — 2026-07-14
Fourth integration-feedback release — a composability fix for buttons, a much
smarter meter, and the pan control the mixer was missing.
### Buttons
- **Action buttons no longer show an LED by default.** `Button` (and
`PushButton mode="action"`) are plain by default — an action has no state to
indicate. `led` defaults to `'strip'` for `toggle`/`momentary` (which do have
a state) and to `false` for `action`; pass `led="dot"` / `led="strip"` to opt
a command button back in.
### Buttons compose as Radix triggers
- `PushButton` / `Button` now forward unknown props (`...rest`) to the
underlying `<button>` and **compose** their own event handlers with any the
consumer (or a Radix `asChild` slot) injects. A `Button` can now be a
dropdown / tooltip / popover trigger: `onPointerDown`, `onKeyDown`,
`data-state`, `aria-expanded` etc. all reach the DOM and fire alongside the
button's own press behavior. The prop types now extend the native button
attributes.
### Meter — ballistics, dBFS, loudness
- **`ballistics`** — PPM-style attack/decay smoothing of the displayed level
(`true` ≈ 5 ms attack / 350 ms decay, or `{ attack, decay }` in ms).
- **`peakDecay`** — the peak-hold marker falls with a decay tail (normalized
units/second) after `peakHold` instead of snapping back.
- **`scale="db"`** — feed linear amplitude (0..1) and the meter displays dBFS;
`min`/`max` default to -60 / 0 dB and faint reference gridlines are drawn
(`showScale` / `referenceTicks` to tune). New `ampToDb` / `dbToAmp` helpers.
- **`integrated`** — a time-windowed loudness estimate (LUFS-ish on the dB
scale — a windowed mean of channel power, not a full BS.1770 measurement),
shown as a secondary readout and a marker line (`integrationWindow`,
`loudnessColor`).
- All of the above are opt-in; the default meter behaves exactly as before.
### New component
- **`PanKnob`** — a bipolar stereo-pan knob: fills from center, snaps to a
center detent, reads out `L<n>` / `C` / `R<n>`. Defaults to a 50…50 range
centered at 0.
## 1.1.2 — 2026-07-14 ## 1.1.2 — 2026-07-14
Third integration-feedback release — a general action button, responsive Third integration-feedback release — a general action button, responsive

View file

@ -5,7 +5,7 @@ headless core, any number range, any decimal precision, and colors you fully con
Zero runtime dependencies. Zero runtime dependencies.
``` ```
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.2/dreamknob-1.1.2.tgz
``` ```
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases) (Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)
@ -95,16 +95,14 @@ import {
LEDKnob, // segmented LED ring + true seven-segment readout LEDKnob, // segmented LED ring + true seven-segment readout
NeonKnob, // glowing arc for dark UIs NeonKnob, // glowing arc for dark UIs
SteppedKnob, // detented selector (positions={['LP','BP','HP']}) SteppedKnob, // detented selector (positions={['LP','BP','HP']})
PanKnob, // bipolar stereo pan — center detent, L<n>/C/R<n> readout
Fader, // linear channel fader, vertical or horizontal Fader, // linear channel fader, vertical or horizontal
LEDFader, // segmented LED meter-fader with color zones LEDFader, // segmented LED meter-fader with color zones
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips) ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
Meter, // LED level meter — multi-channel, PPM ballistics, dBFS scale, loudness Meter, // read-only LED level meter — mono or multi-channel (value={[l, r]})
MeterBridge, // labeled multi-strip meter block with per-channel peak text MeterBridge, // labeled multi-strip meter block with per-channel peak text
Gauge, // read-only radial arc meter (round sibling of Meter) for CPU/RAM/load Gauge, // read-only radial arc meter (round sibling of Meter) for CPU/RAM/load
XYPad, // two-parameter pad (cutoff/resonance, vector mixing) XYPad, // two-parameter pad (cutoff/resonance, vector mixing)
PushButton, // latch/hold panel button — LED strip or corner dot (toggle/momentary) PushButton, // panel button with LED strip or corner dot (toggle or momentary)
Button, // click-to-fire command button (no LED by default; led="dot" opt-in)
IndicatorLamp, // pressable LED lamp — the hardware checkbox (blink, momentary) IndicatorLamp, // pressable LED lamp — the hardware checkbox (blink, momentary)
LampRow, // row of indicator lamps with console-print captions LampRow, // row of indicator lamps with console-print captions
ToggleSwitch, // rocker/slider boolean — the knob slides OFF↔ON ToggleSwitch, // rocker/slider boolean — the knob slides OFF↔ON
@ -120,10 +118,7 @@ import {
Knob skins also take `sublabel` (a second caption line) and `labelSize` (caption Knob skins also take `sublabel` (a second caption line) and `labelSize` (caption
size independent of the dial); `Meter` and `Fader` take `fill` to stretch to their size independent of the dial); `Meter` and `Fader` take `fill` to stretch to their
container. `Meter` adds `ballistics` (PPM attack/decay), `peakDecay`, `scale="db"` container.
(feed amplitude, show dBFS) and `integrated` (windowed loudness) — all opt-in.
`PushButton`/`Button` forward native button props and compose handlers, so a
`Button` works as a Radix `asChild` trigger.
## Theming ## Theming
@ -137,9 +132,7 @@ import { DreamknobProvider } from 'dreamknob'
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`, `base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
`text`, `label`, `ticks`, `focusRing`, `zoneGood`/`zoneWarn`/`zoneHot` (meter zones), `text`, `label`, `ticks`, `focusRing`, `zoneGood`/`zoneWarn`/`zoneHot` (meter zones),
`ledGreen`/`ledAmber` (display defaults), `panel` (rack chrome), `ledWell` (LED display `ledGreen`/`ledAmber` (display defaults), `panel` (rack chrome), `fontMono`, `fontUI`.
well — transparent on the light base), `fontMono`, `fontUI`. `scheme` (`'dark'|'light'`)
is set by `base` and drives the panel-button/switch chrome; you rarely set it directly.
Every knob skin and fader takes the core props plus `size`, `label`, `showValue`, Every knob skin and fader takes the core props plus `size`, `label`, `showValue`,
`unit`, `format`, and per-part color props (`color`, `trackColor`, `faceColor`, `unit`, `format`, and per-part color props (`color`, `trackColor`, `faceColor`,

View file

@ -1,6 +1,6 @@
{ {
"name": "dreamknob", "name": "dreamknob",
"version": "1.1.4", "version": "1.1.2",
"description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.", "description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
"license": "MIT", "license": "MIT",
"author": "Dreamodus Software Inc.", "author": "Dreamodus Software Inc.",

View file

@ -40,58 +40,10 @@ export interface KnobTheme {
ledAmber: string; ledAmber: string;
/** Panel/rack chrome background (`Rack`, panel containers). */ /** Panel/rack chrome background (`Rack`, panel containers). */
panel: string; panel: string;
/**
* The "well" behind LED displays the recessed screen of `LEDKnob`,
* `LEDFader`, `Meter` and the default panel of `SegmentDisplay` /
* `AlphaDisplay`. Dark base keeps the classic near-black well (`#0b0d0e`) so
* lit segments read as glowing; the light base is `transparent`, so LED
* controls and readouts render as clean flat segment rings / bar meters /
* digits on the light panel instead of dark blobs. Per-instance
* `faceColor` / `background` props still override it.
*/
ledWell: string;
/**
* Which base this theme derives from. Panel chrome that can't be expressed
* as a single token the raised gradient/border/shadow of `PushButton`,
* `TransportButton`, `SegmentSwitch` switches between dark and light
* treatments on this. Set automatically by the `base`; overrides rarely
* need to touch it.
*/
scheme: 'dark' | 'light';
fontMono: string; fontMono: string;
fontUI: string; fontUI: string;
} }
/** Raised panel-button chrome (background gradient, border, inset/drop shadow)
* for the current scheme shared by PushButton, TransportButton and the
* SegmentSwitch container so they stay consistent and adapt to light themes
* instead of being hardcoded dark chips. */
export function panelButtonChrome(
scheme: 'dark' | 'light',
pressed: boolean,
): { background: string; border: string; boxShadow: string } {
if (scheme === 'light') {
return {
border: '1px solid rgba(0,0,0,0.16)',
background: pressed
? 'linear-gradient(180deg, #d9dbe0, #e9eaee)'
: 'linear-gradient(180deg, #ffffff, #e7e8ec)',
boxShadow: pressed
? 'inset 0 2px 4px rgba(0,0,0,0.16)'
: 'inset 0 1px 0 rgba(255,255,255,0.9), 0 1px 2px rgba(0,0,0,0.13)',
};
}
return {
border: '1px solid rgba(0,0,0,0.7)',
background: pressed
? 'linear-gradient(180deg, #17181d, #232429)'
: 'linear-gradient(180deg, #35363d, #1d1e24)',
boxShadow: pressed
? 'inset 0 2px 5px rgba(0,0,0,0.65)'
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)',
};
}
export const darkTheme: KnobTheme = { export const darkTheme: KnobTheme = {
track: 'rgba(255,255,255,0.12)', track: 'rgba(255,255,255,0.12)',
face: 'rgba(255,255,255,0.05)', face: 'rgba(255,255,255,0.05)',
@ -105,8 +57,6 @@ export const darkTheme: KnobTheme = {
ledGreen: '#3df2ad', ledGreen: '#3df2ad',
ledAmber: '#ffb84d', ledAmber: '#ffb84d',
panel: 'rgba(255,255,255,0.03)', panel: 'rgba(255,255,255,0.03)',
ledWell: '#0b0d0e',
scheme: 'dark',
fontMono: MONO_FONT, fontMono: MONO_FONT,
fontUI: UI_FONT, fontUI: UI_FONT,
}; };
@ -124,8 +74,6 @@ export const lightTheme: KnobTheme = {
ledGreen: '#0fa571', ledGreen: '#0fa571',
ledAmber: '#b26a00', ledAmber: '#b26a00',
panel: 'rgba(0,0,0,0.04)', panel: 'rgba(0,0,0,0.04)',
ledWell: 'transparent',
scheme: 'light',
fontMono: MONO_FONT, fontMono: MONO_FONT,
fontUI: UI_FONT, fontUI: UI_FONT,
}; };

View file

@ -145,7 +145,7 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
color, color,
weight = 1, weight = 1,
gap = DEFAULT_GAP, gap = DEFAULT_GAP,
background: backgroundProp, background = '#0a0d0c',
ghostOpacity = 0.08, ghostOpacity = 0.08,
skew = 6, skew = 6,
glow = 1.4, glow = 1.4,
@ -156,9 +156,6 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
}) => { }) => {
const theme = useKnobTheme(); const theme = useKnobTheme();
const lit = color ?? theme.ledAmber; const lit = color ?? theme.ledAmber;
// Default the display well to the theme's ledWell (transparent on the light
// base) so readouts adapt to light themes.
const background = backgroundProp ?? theme.ledWell ?? '#0a0d0c';
const filterId = React.useId(); const filterId = React.useId();
const half = BASE_HALF * weight; const half = BASE_HALF * weight;
const POLY = buildPoly(half); const POLY = buildPoly(half);

View file

@ -57,7 +57,7 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
zones, zones,
weight = 1, weight = 1,
gap, gap,
background: backgroundProp, background = '#0a0d0c',
ghostOpacity = 0.09, ghostOpacity = 0.09,
skew = 6, skew = 6,
glow = 1.6, glow = 1.6,
@ -67,9 +67,6 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
style, style,
}) => { }) => {
const theme = useKnobTheme(); const theme = useKnobTheme();
// Default the display well to the theme's ledWell (dark base = dark well,
// light base = transparent) so readouts adapt to light themes.
const background = backgroundProp ?? theme.ledWell ?? '#0a0d0c';
const filterId = React.useId(); const filterId = React.useId();
const numeric = typeof value === 'number' ? value : NaN; const numeric = typeof value === 'number' ? value : NaN;
const resolved = const resolved =

View file

@ -64,12 +64,10 @@ export { VintageKnob, type VintageKnobProps } from './skins/VintageKnob';
export { LEDKnob, type LEDKnobProps } from './skins/LEDKnob'; export { LEDKnob, type LEDKnobProps } from './skins/LEDKnob';
export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob'; export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob';
export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob'; export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
export { PanKnob, type PanKnobProps } from './skins/PanKnob';
export { Fader, type FaderProps } from './skins/Fader'; export { Fader, type FaderProps } from './skins/Fader';
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader'; export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob'; export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
export { Meter, type MeterProps } from './skins/Meter'; export { Meter, type MeterProps } from './skins/Meter';
export { ampToDb, dbToAmp, type Ballistics } from './skins/meterEngine';
export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge'; export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
export { Gauge, type GaugeProps } from './skins/Gauge'; export { Gauge, type GaugeProps } from './skins/Gauge';
export { PushButton, type PushButtonProps, Button, type ButtonProps } from './skins/PushButton'; export { PushButton, type PushButtonProps, Button, type ButtonProps } from './skins/PushButton';

View file

@ -71,7 +71,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
zones, zones,
offOpacity = 0.13, offOpacity = 0.13,
glow = true, glow = true,
faceColor: faceColorProp, faceColor = '#0b0d0e',
focusRing, focusRing,
label, label,
showValue = true, showValue = true,
@ -89,7 +89,6 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
const [editing, setEditing] = React.useState(false); const [editing, setEditing] = React.useState(false);
const theme = useKnobTheme(); const theme = useKnobTheme();
const color = colorProp ?? theme.accent; const color = colorProp ?? theme.accent;
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const ring = focusRing ?? theme.focusRing; const ring = focusRing ?? theme.focusRing;
const vertical = orientation === 'vertical'; const vertical = orientation === 'vertical';
const pad = 5; const pad = 5;

View file

@ -90,7 +90,7 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
digits = 3, digits = 3,
displayDecimals, displayDecimals,
labelColor, labelColor,
faceColor: faceColorProp, faceColor = '#0b0d0e',
label, label,
sublabel, sublabel,
labelSize, labelSize,
@ -102,7 +102,6 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
}, ref) { }, ref) {
const theme = useKnobTheme(); const theme = useKnobTheme();
const color = colorProp ?? theme.accent ?? '#3df2ad'; const color = colorProp ?? theme.accent ?? '#3df2ad';
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const id = React.useId(); const id = React.useId();
const glowId = `${id}-glow`; const glowId = `${id}-glow`;
return ( return (

View file

@ -4,25 +4,16 @@ import type { Taper } from '../core/math';
import { useKnobTheme } from '../core/theme'; import { useKnobTheme } from '../core/theme';
import type { LEDFaderZone } from './LEDFader'; import type { LEDFaderZone } from './LEDFader';
import { SegmentDisplay } from '../digital/SegmentDisplay'; import { SegmentDisplay } from '../digital/SegmentDisplay';
import { ampToDb, useMeterEngine, type Ballistics } from './meterEngine';
export interface MeterProps { export interface MeterProps {
/** /**
* Level to display a single number, or an array for a multi-channel * 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 * meter (e.g. `[l, r]` for stereo). The meter is read-only.
* `scale="db"` these are linear amplitudes (0..1); otherwise they are in the
* `min`..`max` domain directly.
*/ */
value: number | readonly number[]; value: number | readonly number[];
min?: number; min?: number;
max?: number; max?: number;
taper?: Taper; 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'; orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */ /** Travel length in px. Default: 160. */
length?: number; length?: number;
@ -44,42 +35,16 @@ export interface MeterProps {
zones?: readonly LEDFaderZone[]; zones?: readonly LEDFaderZone[];
/** Opacity of unlit segments. Default: 0.1. */ /** Opacity of unlit segments. Default: 0.1. */
offOpacity?: number; 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 * Peak hold time in ms the highest recent segment stays lit and falls
* back after this long. `false` disables. Default: 1200. * back after this long. `false` disables. Default: 1200.
*/ */
peakHold?: number | false; 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. */ /** Peak indicator color. Defaults to the peak's zone color. */
peakColor?: string; peakColor?: string;
/**
* Show a time-windowed loudness estimate (LUFS-ish on the 'db' scale a
* mean of channel power over `integrationWindow`, not a full BS.1770
* measurement). Rendered as a small secondary readout + a marker line.
*/
integrated?: boolean;
/** Loudness integration window in ms. Default: 3000. */
integrationWindow?: number;
/** Color for the integrated-loudness readout and marker line. */
loudnessColor?: string;
/** Faint reference gridlines across the meter. Default: on for scale='db'. */
showScale?: boolean;
/** Reference marks in the value domain. Defaults to dBFS marks for 'db'. */
referenceTicks?: readonly number[];
/** Dedicated clip LED at the hot end (shared across channels). Default: true. */ /** Dedicated clip LED at the hot end (shared across channels). Default: true. */
showClip?: boolean; 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; clipThreshold?: number;
/** /**
* Signal(s) used for clip detection when different from the displayed * Signal(s) used for clip detection when different from the displayed
@ -113,20 +78,15 @@ export interface MeterProps {
const asArray = (v: number | readonly number[]): readonly number[] => const asArray = (v: number | readonly number[]): readonly number[] =>
Array.isArray(v) ? v : [v as 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 * 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. * 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<MeterProps> = ({ export const Meter: React.FC<MeterProps> = ({
value, value,
min: minProp, min = 0,
max: maxProp, max = 100,
taper = linearTaper, taper = linearTaper,
scale = 'linear',
orientation = 'vertical', orientation = 'vertical',
length = 160, length = 160,
fill = false, fill = false,
@ -136,22 +96,15 @@ export const Meter: React.FC<MeterProps> = ({
color, color,
zones, zones,
offOpacity = 0.1, offOpacity = 0.1,
ballistics,
peakHold = 1200, peakHold = 1200,
peakDecay,
peakColor, peakColor,
integrated = false,
integrationWindow = 3000,
loudnessColor,
showScale,
referenceTicks,
showClip = true, showClip = true,
clipThreshold, clipThreshold,
clipValue, clipValue,
clipHold = 1500, clipHold = 1500,
clipColor = '#ff2b39', clipColor = '#ff2b39',
onClip, onClip,
faceColor: faceColorProp, faceColor = '#0b0d0e',
label, label,
showValue = false, showValue = false,
digits = 4, digits = 4,
@ -163,51 +116,27 @@ export const Meter: React.FC<MeterProps> = ({
...aria ...aria
}) => { }) => {
const theme = useKnobTheme(); const theme = useKnobTheme();
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const vertical = orientation === 'vertical'; 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 values = asArray(value);
const channels = values.length; const channels = values.length;
// Value-domain per channel (dB for scale='db', else the value itself). const ns = values.map(v => clamp(taper.toNormalized(clamp(v, min, max), min, max), 0, 1));
const domain = values.map(v => (dbScale ? ampToDb(v) : v));
const targetNs = domain.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1));
// rAF engine (ballistics / peak-decay / loudness). Null on the legacy path. // Peaks: per channel, falling back together after `peakHold` ms of no rise.
const animated = !!ballistics || peakDecay != null || integrated; const [peaks, setPeaks] = React.useState<readonly number[]>(ns);
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<readonly number[]>(targetNs);
React.useEffect(() => { React.useEffect(() => {
if (animated || peakHold === false) return; if (peakHold === false) return;
setLegacyPeaks(prev => { setPeaks(prev => {
const rose = targetNs.map((n, i) => n >= (prev[i] ?? 0)); const rose = ns.map((n, i) => n >= (prev[i] ?? 0));
if (rose.every(Boolean) || prev.length !== targetNs.length) if (rose.every(Boolean) || prev.length !== ns.length) return ns.map((n, i) => Math.max(n, prev[i] ?? 0));
return targetNs.map((n, i) => Math.max(n, prev[i] ?? 0)); return prev.map((p, i) => Math.max(p, ns[i] ?? 0));
return prev.map((p, i) => Math.max(p, targetNs[i] ?? 0));
}); });
const t = setTimeout(() => setLegacyPeaks(targetNs), peakHold); const t = setTimeout(() => setPeaks(ns), peakHold);
return () => clearTimeout(t); return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(targetNs), peakHold, animated]); }, [JSON.stringify(ns), peakHold]);
const peaks = animated && engine ? engine.peaks : legacyPeaks;
// Clip: latch when any channel's raw (unclamped) signal reaches threshold. // Clip: latch when any channel's raw (unclamped) signal reaches threshold.
const clipSignals = (clipValue !== undefined ? asArray(clipValue) : values).map(v => const clipSignals = clipValue !== undefined ? asArray(clipValue) : values;
dbScale ? ampToDb(v) : v,
);
const hottest = Math.max(...clipSignals); const hottest = Math.max(...clipSignals);
const [clipped, setClipped] = React.useState(false); const [clipped, setClipped] = React.useState(false);
const clipTimer = React.useRef<ReturnType<typeof setTimeout>>(); const clipTimer = React.useRef<ReturnType<typeof setTimeout>>();
@ -253,20 +182,14 @@ export const Meter: React.FC<MeterProps> = ({
const clipLed = 7; const clipLed = 7;
const clipSpan = showClip ? clipLed + 3 : 0; const clipSpan = showClip ? clipLed + 3 : 0;
const innerLen = along - pad * 2 - clipSpan; const slot = Math.max(1, (along - pad * 2 - clipSpan) / segments);
const slot = Math.max(1, innerLen / segments);
const gap = Math.min(2.5, slot * 0.35); const gap = Math.min(2.5, slot * 0.35);
const segSize = Math.max(0.5, slot - gap); 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[] = []; const bars: React.ReactNode[] = [];
for (let c = 0; c < channels; c++) { for (let c = 0; c < channels; c++) {
const offset = c * (breadth + chGap) + pad; const offset = c * (breadth + chGap) + pad;
const lit = Math.round((levels[c] ?? 0) * segments); const lit = Math.round((ns[c] ?? 0) * segments);
const peakIdx = const peakIdx =
peakHold !== false && (peaks[c] ?? 0) > 0 peakHold !== false && (peaks[c] ?? 0) > 0
? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1) ? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1)
@ -275,8 +198,8 @@ export const Meter: React.FC<MeterProps> = ({
const zone = zoneFor((i + 1) / segments); const zone = zoneFor((i + 1) / segments);
const isPeak = i === peakIdx && i >= lit; const isPeak = i === peakIdx && i >= lit;
const on = i < lit || isPeak; const on = i < lit || isPeak;
const barFill = isPeak ? (peakColor ?? zone) : zone; const fill = isPeak ? (peakColor ?? zone) : zone;
const common = { rx: 1.2, fill: barFill, opacity: on ? 1 : offOpacity }; const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
bars.push( bars.push(
vertical ? ( vertical ? (
<rect <rect
@ -301,43 +224,7 @@ export const Meter: React.FC<MeterProps> = ({
} }
} }
// Reference gridlines (value domain -> position across the bar area). const hottestShown = Math.max(...values.map(v => clamp(v, min, max)));
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 ? (
<line key={`s${tv}`} x1={pad} y1={p} x2={w - pad} y2={p} stroke="rgba(255,255,255,0.14)" strokeWidth={0.6} strokeDasharray="2 2" />
) : (
<line key={`s${tv}`} x1={p} y1={pad} x2={p} y2={h - pad} stroke="rgba(255,255,255,0.14)" strokeWidth={0.6} strokeDasharray="2 2" />
),
);
}
}
// 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 ? (
<line x1={pad} y1={p} x2={w - pad} y2={p} stroke={loud} strokeWidth={1.4} />
) : (
<line x1={p} y1={pad} x2={p} y2={h - pad} stroke={loud} strokeWidth={1.4} />
);
}
}
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 ( return (
<div <div
@ -360,46 +247,24 @@ export const Meter: React.FC<MeterProps> = ({
...style, ...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.
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
{showValue && ( {showValue && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}> <div style={{ width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'baseline', gap: 4 }}>
<SegmentDisplay <SegmentDisplay
value={hottestShown} value={hottestShown}
digits={digits} digits={digits}
decimals={displayDecimals} decimals={displayDecimals}
height={11} height={11}
color={readoutColor} color={zoneFor(Math.max(...ns))}
background="none" background="none"
ghostOpacity={0} ghostOpacity={0.06}
/> />
{unit && ( {unit && (
<span style={{ fontFamily: theme.fontMono, fontSize: 9, letterSpacing: '0.06em', color: theme.label, whiteSpace: 'nowrap' }}> <span style={{ fontFamily: theme.fontMono, fontSize: 10, color: theme.label, whiteSpace: 'nowrap' }}>
{unit} {unit}
</span> </span>
)} )}
</div> </div>
)} )}
{integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated) && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<SegmentDisplay
value={clamp(engine.integrated, min, max)}
digits={digits}
decimals={displayDecimals}
height={9}
color={loud}
background="none"
ghostOpacity={0}
/>
<span style={{ fontFamily: theme.fontMono, fontSize: 9, letterSpacing: '0.06em', color: theme.label, whiteSpace: 'nowrap' }}>
{dbScale ? 'LUFS' : 'AVG'}
</span>
</div>
)}
</div>
)}
<svg <svg
width={fill && !vertical ? '100%' : w} width={fill && !vertical ? '100%' : w}
height={fill && vertical ? '100%' : h} height={fill && vertical ? '100%' : h}
@ -409,9 +274,7 @@ export const Meter: React.FC<MeterProps> = ({
aria-hidden="true" aria-hidden="true"
> >
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" /> <rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
{scaleLines}
{bars} {bars}
{loudnessLine}
{showClip && ( {showClip && (
<rect <rect
data-part="clip" data-part="clip"

View file

@ -1,60 +0,0 @@
import * as React from 'react';
import { FlatKnob, type FlatKnobProps } from './FlatKnob';
export interface PanKnobProps extends Omit<FlatKnobProps, 'arcFrom'> {
/**
* Readout precision for the L/R amount. Default: 0 (whole numbers).
* The formatter shows `C` at center, `L<n>` left, `R<n>` right.
*/
panDecimals?: number;
}
/**
* A stereo pan control a bipolar knob that fills from the center, snaps to a
* center detent, and reads out `L<n>` / `C` / `R<n>`. Defaults to a 5050
* range centered at 0; pass `min`/`max` for other conventions (e.g. 11).
*/
export const PanKnob = React.forwardRef<HTMLDivElement, PanKnobProps>(function PanKnob(
{
min = -50,
max = 50,
defaultValue,
origin,
detents,
detentSize,
format,
label = 'Pan',
panDecimals = 0,
...rest
},
ref,
) {
const center = (min + max) / 2;
const panFormat = React.useMemo(
() =>
format ??
((v: number) => {
const amount = Math.abs(v - center);
if (amount < Math.pow(10, -panDecimals) / 2) return 'C';
const n = amount.toFixed(panDecimals);
return v < center ? `L${n}` : `R${n}`;
}),
[format, center, panDecimals],
);
return (
<FlatKnob
ref={ref}
arcFrom="center"
min={min}
max={max}
defaultValue={defaultValue ?? center}
origin={origin ?? center}
detents={detents ?? [center]}
detentSize={detentSize ?? 0.04}
format={panFormat}
label={label}
{...rest}
/>
);
});

View file

@ -1,16 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { panelButtonChrome, useKnobTheme } from '../core/theme'; import { useKnobTheme } from '../core/theme';
/** Call every handler in order (skipping undefined) for merging our own export interface PushButtonProps {
* handlers with consumer/Radix-injected ones on the same event. */
const chain =
<E,>(...fns: Array<((e: E) => void) | undefined>) =>
(e: E) => {
for (const fn of fns) if (typeof fn === 'function') fn(e);
};
export interface PushButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'onClick'> {
/** Controlled pressed state. Pair with `onChange`. */ /** Controlled pressed state. Pair with `onChange`. */
pressed?: boolean; pressed?: boolean;
defaultPressed?: boolean; defaultPressed?: boolean;
@ -26,10 +17,9 @@ export interface PushButtonProps
/** LED / active color. */ /** LED / active color. */
color?: string; color?: string;
/** /**
* LED style: 'strip' (above the caption), 'dot' (small corner dot scales * LED style: 'strip' (default, above the caption), 'dot' (small corner
* down better for icon-only toggles), or false for none. `true` is an alias * dot scales down better for icon-only toggles), or false for none.
* for 'strip'. Defaults to 'strip' for toggle/momentary (they indicate a * `true` is an alias for 'strip'.
* state) and to false for `mode="action"` (a command has no state to show).
*/ */
led?: boolean | 'strip' | 'dot'; led?: boolean | 'strip' | 'dot';
/** Button height in px. Default: 36. */ /** Button height in px. Default: 36. */
@ -45,6 +35,7 @@ export interface PushButtonProps
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
'aria-label'?: string;
} }
/** Studio panel button with an LED strip — mute/solo/bypass in matching style. */ /** Studio panel button with an LED strip — mute/solo/bypass in matching style. */
@ -57,7 +48,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
onClick, onClick,
mode = 'toggle', mode = 'toggle',
color, color,
led: ledProp, led = true,
size = 36, size = 36,
width, width,
disabled = false, disabled = false,
@ -66,15 +57,12 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
children, children,
className, className,
style, style,
...rest ...aria
}, },
ref, ref,
) { ) {
const theme = useKnobTheme(); const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff'; const accent = color ?? theme.accent ?? '#4cc2ff';
// The LED indicates a persistent/held state, so it defaults on for
// toggle/momentary but off for `action` (a command has no state).
const led = ledProp ?? (mode === 'action' ? false : true);
const isControlled = pressed !== undefined; const isControlled = pressed !== undefined;
const [internal, setInternal] = React.useState(defaultPressed); const [internal, setInternal] = React.useState(defaultPressed);
const [focusVisible, setFocusVisible] = React.useState(false); const [focusVisible, setFocusVisible] = React.useState(false);
@ -87,9 +75,6 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
const latched = isControlled ? (pressed as boolean) : internal; const latched = isControlled ? (pressed as boolean) : internal;
const on = mode === 'action' ? held : latched; // press-down / background inset const on = mode === 'action' ? held : latched; // press-down / background inset
const ledOn = mode === 'action' ? held || flash : latched; const ledOn = mode === 'action' ? held || flash : latched;
const chrome = panelButtonChrome(theme.scheme, on);
// Unlit LED tint: faint white on dark chrome, faint black on light.
const ledOff = theme.scheme === 'light' ? 'rgba(0,0,0,0.13)' : 'rgba(255,255,255,0.09)';
const set = (next: boolean) => { const set = (next: boolean) => {
if (next === latched) return; if (next === latched) return;
@ -133,38 +118,25 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
} }
: { onClick: () => set(!latched) }; : { onClick: () => set(!latched) };
// Merge our own handlers with any consumer/Radix-injected ones of the same return (
// name (from {...rest}), so the button works as a Radix `asChild` trigger: <button
// the injected onPointerDown/onKeyDown/data-state/aria-* all reach the DOM. ref={ref}
const restHandlers = rest as Record<string, ((e: unknown) => void) | undefined>; type="button"
const composedHandlers = Object.fromEntries( disabled={disabled}
Object.entries(momentaryProps).map(([k, fn]) => [ aria-pressed={mode === 'action' ? undefined : latched}
k, aria-label={aria['aria-label']}
chain(restHandlers[k], fn as (e: unknown) => void), data-pressed={on ? '' : undefined}
]), data-focus-visible={focusVisible ? '' : undefined}
); className={className}
const handleFocus = chain(restHandlers.onFocus, (e: React.FocusEvent<HTMLButtonElement>) => { onFocus={e => {
try { try {
setFocusVisible(e.currentTarget.matches(':focus-visible')); setFocusVisible(e.currentTarget.matches(':focus-visible'));
} catch { } catch {
setFocusVisible(true); setFocusVisible(true);
} }
}); }}
const handleBlur = chain(restHandlers.onBlur, () => setFocusVisible(false)); onBlur={() => setFocusVisible(false)}
{...momentaryProps}
return (
<button
{...rest}
ref={ref}
type="button"
disabled={disabled}
aria-pressed={mode === 'action' ? undefined : latched}
data-pressed={on ? '' : undefined}
data-focus-visible={focusVisible ? '' : undefined}
className={className}
onFocus={handleFocus}
onBlur={handleBlur}
{...composedHandlers}
style={{ style={{
position: 'relative', position: 'relative',
display: 'inline-flex', display: 'inline-flex',
@ -177,12 +149,16 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
minWidth: size * 1.4, minWidth: size * 1.4,
padding: '0 12px', padding: '0 12px',
borderRadius: 7, borderRadius: 7,
border: chrome.border, border: '1px solid rgba(0,0,0,0.7)',
background: chrome.background, background: on
? 'linear-gradient(180deg, #17181d, #232429)'
: 'linear-gradient(180deg, #35363d, #1d1e24)',
outline: 'none', outline: 'none',
boxShadow: `${chrome.boxShadow}${ boxShadow: `${
focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : '' on
}`, ? 'inset 0 2px 5px rgba(0,0,0,0.65)'
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)'
}${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`,
color: on ? theme.text : theme.label, color: on ? theme.text : theme.label,
fontFamily: theme.fontUI, fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.28), fontSize: Math.max(10, size * 0.28),
@ -205,7 +181,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
width: '55%', width: '55%',
height: Math.max(3, size * 0.09), height: Math.max(3, size * 0.09),
borderRadius: 3, borderRadius: 3,
background: ledOn ? accent : ledOff, background: ledOn ? accent : 'rgba(255,255,255,0.09)',
boxShadow: ledOn ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)', boxShadow: ledOn ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
transition: 'background 80ms, box-shadow 80ms', transition: 'background 80ms, box-shadow 80ms',
}} }}
@ -221,7 +197,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
width: Math.max(4, size * 0.14), width: Math.max(4, size * 0.14),
height: Math.max(4, size * 0.14), height: Math.max(4, size * 0.14),
borderRadius: '50%', borderRadius: '50%',
background: ledOn ? accent : ledOff, background: ledOn ? accent : 'rgba(255,255,255,0.09)',
boxShadow: ledOn ? `0 0 ${size * 0.25}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)', boxShadow: ledOn ? `0 0 ${size * 0.25}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
transition: 'background 80ms, box-shadow 80ms', transition: 'background 80ms, box-shadow 80ms',
}} }}
@ -258,12 +234,12 @@ export interface ButtonProps
/** /**
* A normal click-to-fire command button in the studio-panel style the * A normal click-to-fire command button in the studio-panel style the
* non-latching sibling of `PushButton`. Use it for actions (Regenerate, * non-latching sibling of `PushButton`. Use it for actions (Regenerate,
* Add, Apply, Refresh). It's `PushButton` locked to `mode="action"`. Plain by * Add, Apply, Refresh). It's `PushButton` locked to `mode="action"`; pass
* default (no LED an action has no state); pass `led="dot"` for a corner dot * `led="dot"` for the corner-dot look (like a Fresh Seed button) or
* that flashes on press, or `led="strip"` for the full strip. * `led={false}` for a plain button.
*/ */
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button({ led, ...rest }, ref) { function Button({ led = 'dot', ...rest }, ref) {
return <PushButton ref={ref} mode="action" led={led} {...rest} />; return <PushButton ref={ref} mode="action" led={led} {...rest} />;
}, },
); );

View file

@ -46,7 +46,6 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
) { ) {
const theme = useKnobTheme(); const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff'; const accent = color ?? theme.accent ?? '#4cc2ff';
const light = theme.scheme === 'light';
const isControlled = value !== undefined; const isControlled = value !== undefined;
const [internal, setInternal] = React.useState(defaultValue); const [internal, setInternal] = React.useState(defaultValue);
const active = Math.max(0, Math.min(options.length - 1, isControlled ? (value as number) : internal)); const active = Math.max(0, Math.min(options.length - 1, isControlled ? (value as number) : internal));
@ -89,13 +88,9 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
display: 'inline-flex', display: 'inline-flex',
height: size, height: size,
borderRadius: 8, borderRadius: 8,
border: light ? '1px solid rgba(0,0,0,0.14)' : '1px solid rgba(0,0,0,0.7)', border: '1px solid rgba(0,0,0,0.7)',
background: light background: 'linear-gradient(180deg, #16171c, #101116)',
? 'linear-gradient(180deg, #e3e4e8, #edeef1)' boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06), 0 2px 4px rgba(0,0,0,0.4)',
: 'linear-gradient(180deg, #16171c, #101116)',
boxShadow: light
? 'inset 0 1px 2px rgba(0,0,0,0.1)'
: 'inset 0 1px 0 rgba(255,255,255,0.06), 0 2px 4px rgba(0,0,0,0.4)',
overflow: 'hidden', overflow: 'hidden',
opacity: disabled ? 0.45 : 1, opacity: disabled ? 0.45 : 1,
userSelect: 'none', userSelect: 'none',
@ -127,20 +122,11 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
padding: '0 12px', padding: '0 12px',
minWidth: size * 1.6, minWidth: size * 1.6,
border: 'none', border: 'none',
borderRight: borderRight: i < options.length - 1 ? '1px solid rgba(0,0,0,0.55)' : 'none',
i < options.length - 1
? `1px solid ${light ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.55)'}`
: 'none',
background: isActive background: isActive
? light ? 'linear-gradient(180deg, rgba(0,0,0,0.5), rgba(0,0,0,0.25))'
? 'linear-gradient(180deg, #ffffff, #f4f5f7)'
: 'linear-gradient(180deg, rgba(0,0,0,0.5), rgba(0,0,0,0.25))'
: 'transparent', : 'transparent',
boxShadow: isActive boxShadow: isActive ? 'inset 0 2px 4px rgba(0,0,0,0.55)' : undefined,
? light
? '0 1px 2px rgba(0,0,0,0.16)'
: 'inset 0 2px 4px rgba(0,0,0,0.55)'
: undefined,
color: isActive ? theme.text : theme.label, color: isActive ? theme.text : theme.label,
fontFamily: theme.fontUI, fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.34), fontSize: Math.max(10, size * 0.34),
@ -159,7 +145,7 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
width: '55%', width: '55%',
height: Math.max(2, size * 0.08), height: Math.max(2, size * 0.08),
borderRadius: 2, borderRadius: 2,
background: isActive ? accent : light ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.08)', background: isActive ? accent : 'rgba(255,255,255,0.08)',
boxShadow: isActive ? `0 0 ${size * 0.2}px ${accent}` : undefined, boxShadow: isActive ? `0 0 ${size * 0.2}px ${accent}` : undefined,
transition: 'background 80ms, box-shadow 80ms', transition: 'background 80ms, box-shadow 80ms',
}} }}

View file

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { panelButtonChrome, useKnobTheme } from '../core/theme'; import { useKnobTheme } from '../core/theme';
export type TransportKind = export type TransportKind =
| 'play' | 'play'
@ -90,7 +90,6 @@ export const TransportButton = React.forwardRef<HTMLButtonElement, TransportButt
const theme = useKnobTheme(); const theme = useKnobTheme();
const [focusVisible, setFocusVisible] = React.useState(false); const [focusVisible, setFocusVisible] = React.useState(false);
const glyphRef = React.useRef<SVGSVGElement>(null); const glyphRef = React.useRef<SVGSVGElement>(null);
const chrome = panelButtonChrome(theme.scheme, active);
const accent = const accent =
color ?? color ??
@ -146,11 +145,17 @@ export const TransportButton = React.forwardRef<HTMLButtonElement, TransportButt
height: size, height: size,
padding: 0, padding: 0,
borderRadius: 8, borderRadius: 8,
border: chrome.border, border: '1px solid rgba(0,0,0,0.6)',
background: chrome.background, background: active
boxShadow: `${chrome.boxShadow}${ ? 'linear-gradient(180deg, #17181d, #232429)'
active ? `, 0 0 ${size * 0.28}px ${accent}55` : '' : 'linear-gradient(180deg, #35363d, #1d1e24)',
}${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`, boxShadow: `${
active
? 'inset 0 2px 5px rgba(0,0,0,0.6)'
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)'
}${active ? `, 0 0 ${size * 0.28}px ${accent}55` : ''}${
focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''
}`,
outline: 'none', outline: 'none',
cursor: disabled ? 'not-allowed' : 'pointer', cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.45 : 1, opacity: disabled ? 0.45 : 1,

View file

@ -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));
});
});

View file

@ -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<MeterEngineState>(() => ({
levels: targetNs,
peaks: targetNs,
integrated: null,
}));
// Per-channel timestamp of the last time the peak was refreshed.
const holdAt = React.useRef<number[]>([]);
// 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;
};