Compare commits
No commits in common. "master" and "v1.1.1" have entirely different histories.
26 changed files with 129 additions and 1174 deletions
11
README.md
11
README.md
|
|
@ -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.1/dreamknob-1.1.1.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):
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AdvancedGuide } from './sections/AdvancedGuide';
|
import { AdvancedGuide } from './sections/AdvancedGuide';
|
||||||
import { ApiDocs } from './sections/ApiDocs';
|
import { ApiDocs } from './sections/ApiDocs';
|
||||||
import { Buttons } from './sections/Buttons';
|
|
||||||
import { Console } from './sections/Console';
|
import { Console } from './sections/Console';
|
||||||
import { Gallery } from './sections/Gallery';
|
import { Gallery } from './sections/Gallery';
|
||||||
import { GettingStarted } from './sections/GettingStarted';
|
import { GettingStarted } from './sections/GettingStarted';
|
||||||
|
|
@ -27,7 +26,6 @@ export const App: React.FC = () => (
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<a href="#start">Start</a>
|
<a href="#start">Start</a>
|
||||||
<a href="#gallery">Gallery</a>
|
<a href="#gallery">Gallery</a>
|
||||||
<a href="#buttons">Buttons</a>
|
|
||||||
<a href="#synth">Synth</a>
|
<a href="#synth">Synth</a>
|
||||||
<a href="#playground">Playground</a>
|
<a href="#playground">Playground</a>
|
||||||
<a href="#guide">Guide</a>
|
<a href="#guide">Guide</a>
|
||||||
|
|
@ -38,7 +36,6 @@ export const App: React.FC = () => (
|
||||||
<Hero />
|
<Hero />
|
||||||
<GettingStarted />
|
<GettingStarted />
|
||||||
<Gallery />
|
<Gallery />
|
||||||
<Buttons />
|
|
||||||
<Console />
|
<Console />
|
||||||
<Synth />
|
<Synth />
|
||||||
<Playground />
|
<Playground />
|
||||||
|
|
|
||||||
|
|
@ -128,16 +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." />
|
||||||
<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="<PushButton />" type="switch" desc="Panel button: toggle or momentary, controlled/uncontrolled, aria-pressed. led: 'strip' | 'dot'. leadingIcon slot tinted to the LED." />
|
||||||
<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="<IndicatorLamp />" type="switch · display" desc="Panel LED lamp: pressable toggle (role='switch') or read-only status light, blink, momentary, label either side — 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'." />
|
||||||
<Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: A–Z 0–9 and symbols (now incl. : $ %), chars padding, align, weight, gap, glow, skew. Defaults to theme.ledAmber." />
|
<Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: A–Z 0–9 and symbols (now incl. : $ %), chars padding, align, weight, gap, glow, skew. Defaults to theme.ledAmber." />
|
||||||
|
|
@ -195,7 +193,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 +310,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 & number handling</h3>
|
<h3 className="api-h">Precision & number handling</h3>
|
||||||
|
|
|
||||||
|
|
@ -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 }) => (
|
|
||||||
<span aria-hidden="true" style={{ fontSize: 14, lineHeight: 1 }}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 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 (
|
|
||||||
<section className="block" id="buttons">
|
|
||||||
<div className="container">
|
|
||||||
<div className="section-kicker">Buttons & switches</div>
|
|
||||||
<h2 className="section-title">Press, latch, or pick</h2>
|
|
||||||
<p className="section-sub">
|
|
||||||
Five components cover every panel control that isn't a dial. The
|
|
||||||
only question is what the press <em>means</em>:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="guide-grid">
|
|
||||||
<GuideRow
|
|
||||||
code="<Button>"
|
|
||||||
when="Fire a one-off action"
|
|
||||||
eg="Regenerate · Add · Apply · Refresh"
|
|
||||||
/>
|
|
||||||
<GuideRow
|
|
||||||
code="<PushButton>"
|
|
||||||
when="Latch a boolean state (or hold it)"
|
|
||||||
eg="Mute · Solo · Bypass · push-to-talk"
|
|
||||||
/>
|
|
||||||
<GuideRow
|
|
||||||
code="<TransportButton>"
|
|
||||||
when="Transport / deck control"
|
|
||||||
eg="Play · Stop · Record · Panic"
|
|
||||||
/>
|
|
||||||
<GuideRow
|
|
||||||
code="<ToggleSwitch>"
|
|
||||||
when="An on/off setting that reads as a switch"
|
|
||||||
eg="Fresh seed · Monitor · Loop"
|
|
||||||
/>
|
|
||||||
<GuideRow
|
|
||||||
code="<SegmentSwitch>"
|
|
||||||
when="Pick one of a few modes"
|
|
||||||
eg="Linear · Radial · Conic"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="btn-demo">
|
|
||||||
<Demo label={`<Button> — action, fired ${fired}×`}>
|
|
||||||
<Button
|
|
||||||
color="#4cc2ff"
|
|
||||||
leadingIcon={<Glyph>↻</Glyph>}
|
|
||||||
onClick={() => setFired(n => n + 1)}
|
|
||||||
aria-label="Regenerate"
|
|
||||||
>
|
|
||||||
Regenerate
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="#3df2ad"
|
|
||||||
led="dot"
|
|
||||||
leadingIcon={<Glyph>✦</Glyph>}
|
|
||||||
onClick={() => setFired(n => n + 1)}
|
|
||||||
aria-label="Fresh"
|
|
||||||
>
|
|
||||||
Fresh
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="#ff4d6b"
|
|
||||||
led="strip"
|
|
||||||
leadingIcon={<Glyph>✕</Glyph>}
|
|
||||||
onClick={() => setFired(n => n + 1)}
|
|
||||||
aria-label="Clear"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</Demo>
|
|
||||||
|
|
||||||
<Demo label="<PushButton> — latch / hold">
|
|
||||||
<PushButton
|
|
||||||
pressed={bypass}
|
|
||||||
onChange={setBypass}
|
|
||||||
color="#ffd23e"
|
|
||||||
aria-label="Bypass"
|
|
||||||
>
|
|
||||||
Bypass
|
|
||||||
</PushButton>
|
|
||||||
<PushButton
|
|
||||||
mode="momentary"
|
|
||||||
color="#4cc2ff"
|
|
||||||
led="dot"
|
|
||||||
size={30}
|
|
||||||
aria-label="Talk"
|
|
||||||
>
|
|
||||||
Talk
|
|
||||||
</PushButton>
|
|
||||||
</Demo>
|
|
||||||
|
|
||||||
<Demo label="<TransportButton>">
|
|
||||||
<TransportButton kind="play" active={playing} onClick={() => setPlaying(p => !p)} size={34} />
|
|
||||||
<TransportButton kind="stop" onClick={() => setPlaying(false)} size={34} />
|
|
||||||
<TransportButton kind="record" active={armed} onClick={() => setArmed(a => !a)} size={34} />
|
|
||||||
</Demo>
|
|
||||||
|
|
||||||
<Demo label="<ToggleSwitch> · <SegmentSwitch>">
|
|
||||||
<ToggleSwitch on={fresh} onChange={setFresh} color="#c9a6ff" showStateLabel aria-label="Fresh seed" />
|
|
||||||
<SegmentSwitch
|
|
||||||
options={['LINEAR', 'RADIAL', 'CONIC']}
|
|
||||||
value={mode}
|
|
||||||
onChange={setMode}
|
|
||||||
color="#4cc2ff"
|
|
||||||
aria-label="Gradient kind"
|
|
||||||
/>
|
|
||||||
</Demo>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="section-sub" style={{ marginTop: 20 }}>
|
|
||||||
<code><Button></code> is <code><PushButton mode="action"></code> —
|
|
||||||
it never latches, shows a press-down, and calls <code>onClick</code>. It has{' '}
|
|
||||||
<strong>no LED by default</strong> — an action has no state to indicate, unlike a
|
|
||||||
toggle. Add <code>led="dot"</code> for a corner dot that flashes on
|
|
||||||
press, or <code>led="strip"</code> for the full strip.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GuideRow: React.FC<{ code: string; when: string; eg: string }> = ({ code, when, eg }) => (
|
|
||||||
<div className="guide-row">
|
|
||||||
<code className="guide-code">{code}</code>
|
|
||||||
<span className="guide-when">{when}</span>
|
|
||||||
<span className="guide-eg">{eg}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Demo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
|
||||||
<div className="btn-demo-cell">
|
|
||||||
<div className="btn-demo-label">{label}</div>
|
|
||||||
<div className="btn-demo-row">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -353,76 +353,6 @@ h2.section-title {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons section: decision guide + live demo grid */
|
|
||||||
.guide-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
margin: 8px 0 28px;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--line);
|
|
||||||
}
|
|
||||||
.guide-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 190px 1fr;
|
|
||||||
gap: 4px 18px;
|
|
||||||
align-items: baseline;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #101219;
|
|
||||||
}
|
|
||||||
.guide-code {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
color: #4cc2ff;
|
|
||||||
}
|
|
||||||
.guide-when {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.guide-eg {
|
|
||||||
grid-column: 2;
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.btn-demo {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.btn-demo-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: linear-gradient(180deg, #16181f, #0e0f14);
|
|
||||||
border: 1px solid var(--line-strong);
|
|
||||||
}
|
|
||||||
.btn-demo-label {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.btn-demo-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.guide-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.guide-eg {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- synth panel ---- */
|
/* ---- synth panel ---- */
|
||||||
.synth-rows {
|
.synth-rows {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,101 +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
|
|
||||||
|
|
||||||
Third integration-feedback release — a general action button, responsive
|
|
||||||
faders everywhere, and the label-scaling gap, all surfaced building a
|
|
||||||
resizable streaming studio.
|
|
||||||
|
|
||||||
### New components
|
|
||||||
- `Button` — a normal **click-to-fire** command button in the panel style
|
|
||||||
(Regenerate, Add, Apply, Refresh), the non-latching sibling of `PushButton`.
|
|
||||||
It's `PushButton` locked to the new `mode="action"`: never latches, shows a
|
|
||||||
press-down + a brief LED flash, calls `onClick`. `led="dot"` (default) gives
|
|
||||||
the corner-dot look; `led={false}` a plain button.
|
|
||||||
|
|
||||||
### Buttons
|
|
||||||
- `PushButton` gains **`mode="action"`** (alongside `toggle`/`momentary`) and
|
|
||||||
an `onClick` prop — the stateless command-button behavior `Button` wraps.
|
|
||||||
|
|
||||||
### Responsive faders
|
|
||||||
- **`fill`** on `LEDFader` (matching `Fader`) — the fader stretches along its
|
|
||||||
travel axis to fill the container instead of a fixed `length`, so faders in
|
|
||||||
resizable dock panels size themselves with no wrapper measurement.
|
|
||||||
|
|
||||||
### Label scaling
|
|
||||||
- **`labelSize`** on `IndicatorLamp` — the caption otherwise tracks `size` but
|
|
||||||
is floored at 10px; set this to shrink it with a small lamp (e.g. matching a
|
|
||||||
dense header row). Also on `LampRow` for parity.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
- A dedicated **Buttons & switches** section with a "which do I use?" guide
|
|
||||||
(Button = action · PushButton = latch · TransportButton = transport ·
|
|
||||||
ToggleSwitch = boolean · SegmentSwitch = mode) and a live demo of each.
|
|
||||||
|
|
||||||
## 1.1.1 — 2026-07-13
|
## 1.1.1 — 2026-07-13
|
||||||
|
|
||||||
Second integration-feedback release — the friction that showed up once the
|
Second integration-feedback release — the friction that showed up once the
|
||||||
|
|
|
||||||
|
|
@ -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.1/dreamknob-1.1.1.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`,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dreamknob",
|
"name": "dreamknob",
|
||||||
"version": "1.1.4",
|
"version": "1.1.1",
|
||||||
"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.",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -64,15 +64,13 @@ 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 } from './skins/PushButton';
|
||||||
export { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
|
export { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
|
||||||
export { LampRow, type LampRowProps, type LampRowItem } from './skins/LampRow';
|
export { LampRow, type LampRowProps, type LampRowItem } from './skins/LampRow';
|
||||||
export { ToggleSwitch, type ToggleSwitchProps } from './skins/ToggleSwitch';
|
export { ToggleSwitch, type ToggleSwitchProps } from './skins/ToggleSwitch';
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,6 @@ export interface IndicatorLampProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
/** Caption beside the lamp. */
|
/** Caption beside the lamp. */
|
||||||
label?: string;
|
label?: string;
|
||||||
/**
|
|
||||||
* Caption font size in px, independent of the lamp diameter. Without it the
|
|
||||||
* label tracks `size` but is floored at 10px; set this to shrink the caption
|
|
||||||
* with a small lamp (e.g. matching a dense header row's height).
|
|
||||||
*/
|
|
||||||
labelSize?: number;
|
|
||||||
labelPosition?: 'right' | 'left';
|
labelPosition?: 'right' | 'left';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Render a hidden form input carrying "on"/"off". */
|
/** Render a hidden form input carrying "on"/"off". */
|
||||||
|
|
@ -51,7 +45,6 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
|
||||||
blink = false,
|
blink = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
label,
|
label,
|
||||||
labelSize,
|
|
||||||
labelPosition = 'right',
|
labelPosition = 'right',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
name,
|
name,
|
||||||
|
|
@ -173,7 +166,7 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: theme.fontUI,
|
fontFamily: theme.fontUI,
|
||||||
fontSize: labelSize ?? Math.max(10, size * 0.9),
|
fontSize: Math.max(10, size * 0.9),
|
||||||
letterSpacing: '0.07em',
|
letterSpacing: '0.07em',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: lit ? theme.text : theme.label,
|
color: lit ? theme.text : theme.label,
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,6 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
orientation?: 'vertical' | 'horizontal';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
/** Travel length in px. Default: 160. */
|
/** Travel length in px. Default: 160. */
|
||||||
length?: number;
|
length?: number;
|
||||||
/**
|
|
||||||
* Stretch along the travel axis to fill the container (horizontal fills
|
|
||||||
* width, vertical fills height) instead of a fixed `length` — the same
|
|
||||||
* responsive behavior as `Fader`, so faders in resizable dock panels size
|
|
||||||
* themselves instead of needing wrapper measurement.
|
|
||||||
*/
|
|
||||||
fill?: boolean;
|
|
||||||
/** Cross-axis size in px. Default: 34. */
|
/** Cross-axis size in px. Default: 34. */
|
||||||
breadth?: number;
|
breadth?: number;
|
||||||
/** Number of LED segments. Default: 24. */
|
/** Number of LED segments. Default: 24. */
|
||||||
|
|
@ -64,14 +57,13 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function LEDFader({
|
export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function LEDFader({
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
length = 160,
|
length = 160,
|
||||||
fill = false,
|
|
||||||
breadth = 34,
|
breadth = 34,
|
||||||
segments = 24,
|
segments = 24,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
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 +81,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;
|
||||||
|
|
@ -104,12 +95,6 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
|
|
||||||
const w = vertical ? breadth : length;
|
const w = vertical ? breadth : length;
|
||||||
const h = vertical ? length : breadth;
|
const h = vertical ? length : breadth;
|
||||||
// Fill stretches the travel axis (via preserveAspectRatio='none'); the cross
|
|
||||||
// axis stays at `breadth`. The LED geometry is computed in the fixed viewBox
|
|
||||||
// and scaled to the element — pointer mapping measures the real element, so
|
|
||||||
// interaction stays correct at any rendered width.
|
|
||||||
const ctrlW = fill && !vertical ? '100%' : w;
|
|
||||||
const ctrlH = fill && vertical ? '100%' : h;
|
|
||||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
|
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
|
||||||
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
|
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
|
||||||
if (color) return [{ upTo: 1, color }];
|
if (color) return [{ upTo: 1, color }];
|
||||||
|
|
@ -172,8 +157,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
width: fill && !vertical ? '100%' : w,
|
width: w,
|
||||||
height: fill && vertical ? '100%' : undefined,
|
|
||||||
opacity: core.disabled ? 0.45 : undefined,
|
opacity: core.disabled ? 0.45 : undefined,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
|
|
@ -234,21 +218,14 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
style={{
|
style={{
|
||||||
...knob.bind.style,
|
...knob.bind.style,
|
||||||
opacity: undefined, // the outer wrapper owns disabled dimming
|
opacity: undefined, // the outer wrapper owns disabled dimming
|
||||||
width: ctrlW,
|
width: w,
|
||||||
height: ctrlH,
|
height: h,
|
||||||
display: fill ? 'flex' : 'inline-flex',
|
display: 'inline-flex',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||||
width={ctrlW}
|
|
||||||
height={ctrlH}
|
|
||||||
viewBox={`0 0 ${w} ${h}`}
|
|
||||||
preserveAspectRatio={fill ? 'none' : undefined}
|
|
||||||
style={{ display: 'block' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<defs>
|
<defs>
|
||||||
<filter
|
<filter
|
||||||
id={glowId}
|
id={glowId}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ export interface LampRowProps {
|
||||||
lamps: readonly LampRowItem[];
|
lamps: readonly LampRowItem[];
|
||||||
/** Lamp diameter in px. Default: 9. */
|
/** Lamp diameter in px. Default: 9. */
|
||||||
size?: number;
|
size?: number;
|
||||||
/** Caption font size in px. Default: 9.5. */
|
|
||||||
labelSize?: number;
|
|
||||||
/** Caption placement relative to each lamp. Default: 'right'. */
|
/** Caption placement relative to each lamp. Default: 'right'. */
|
||||||
labelPosition?: 'right' | 'below';
|
labelPosition?: 'right' | 'below';
|
||||||
/** Make lamps clickable (a compact indicator toggle bank). */
|
/** Make lamps clickable (a compact indicator toggle bank). */
|
||||||
|
|
@ -33,7 +31,6 @@ export interface LampRowProps {
|
||||||
export const LampRow: React.FC<LampRowProps> = ({
|
export const LampRow: React.FC<LampRowProps> = ({
|
||||||
lamps,
|
lamps,
|
||||||
size = 9,
|
size = 9,
|
||||||
labelSize,
|
|
||||||
labelPosition = 'right',
|
labelPosition = 'right',
|
||||||
onToggle,
|
onToggle,
|
||||||
gap = 14,
|
gap = 14,
|
||||||
|
|
@ -83,7 +80,7 @@ export const LampRow: React.FC<LampRowProps> = ({
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: theme.fontUI,
|
fontFamily: theme.fontUI,
|
||||||
fontSize: labelSize ?? 9.5,
|
fontSize: 9.5,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: '0.12em',
|
letterSpacing: '0.12em',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
|
|
|
||||||
|
|
@ -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,43 +247,21 @@ export const Meter: React.FC<MeterProps> = ({
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(showValue || (integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated))) && (
|
{showValue && (
|
||||||
// Each readout is its own stack: the value with its unit centered
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'baseline', gap: 4 }}>
|
||||||
// directly underneath — peak above, loudness below.
|
<SegmentDisplay
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
value={hottestShown}
|
||||||
{showValue && (
|
digits={digits}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
decimals={displayDecimals}
|
||||||
<SegmentDisplay
|
height={11}
|
||||||
value={hottestShown}
|
color={zoneFor(Math.max(...ns))}
|
||||||
digits={digits}
|
background="none"
|
||||||
decimals={displayDecimals}
|
ghostOpacity={0.06}
|
||||||
height={11}
|
/>
|
||||||
color={readoutColor}
|
{unit && (
|
||||||
background="none"
|
<span style={{ fontFamily: theme.fontMono, fontSize: 10, color: theme.label, whiteSpace: 'nowrap' }}>
|
||||||
ghostOpacity={0}
|
{unit}
|
||||||
/>
|
</span>
|
||||||
{unit && (
|
|
||||||
<span style={{ fontFamily: theme.fontMono, fontSize: 9, letterSpacing: '0.06em', color: theme.label, whiteSpace: 'nowrap' }}>
|
|
||||||
{unit}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 −50…50
|
|
||||||
* range centered at 0; pass `min`/`max` for other conventions (e.g. −1…1).
|
|
||||||
*/
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,35 +1,19 @@
|
||||||
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;
|
||||||
onChange?: (pressed: boolean) => void;
|
onChange?: (pressed: boolean) => void;
|
||||||
/**
|
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
|
||||||
* 'toggle' latches; 'momentary' is on only while held; 'action' is a normal
|
mode?: 'toggle' | 'momentary';
|
||||||
* click-to-fire command button — it never latches, shows a press-down + a
|
|
||||||
* brief LED flash, and calls `onClick`. Default: 'toggle'.
|
|
||||||
*/
|
|
||||||
mode?: 'toggle' | 'momentary' | 'action';
|
|
||||||
/** Fired on activation in `action` mode (click, Enter, or Space). */
|
|
||||||
onClick?: () => void;
|
|
||||||
/** 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 +29,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. */
|
||||||
|
|
@ -54,10 +39,9 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
pressed,
|
pressed,
|
||||||
defaultPressed = false,
|
defaultPressed = false,
|
||||||
onChange,
|
onChange,
|
||||||
onClick,
|
|
||||||
mode = 'toggle',
|
mode = 'toggle',
|
||||||
color,
|
color,
|
||||||
led: ledProp,
|
led = true,
|
||||||
size = 36,
|
size = 36,
|
||||||
width,
|
width,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
|
@ -66,57 +50,25 @@ 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);
|
||||||
// Action mode is stateless: a transient `held` (pointer down) drives the
|
const on = isControlled ? (pressed as boolean) : internal;
|
||||||
// press-down look and a short `flash` lights the LED on activation.
|
|
||||||
const [held, setHeld] = React.useState(false);
|
|
||||||
const [flash, setFlash] = React.useState(false);
|
|
||||||
const flashTimer = React.useRef<ReturnType<typeof setTimeout>>();
|
|
||||||
React.useEffect(() => () => clearTimeout(flashTimer.current), []);
|
|
||||||
const latched = isControlled ? (pressed as boolean) : internal;
|
|
||||||
const on = mode === 'action' ? held : latched; // press-down / background inset
|
|
||||||
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 === on) return;
|
||||||
if (!isControlled) setInternal(next);
|
if (!isControlled) setInternal(next);
|
||||||
onChange?.(next);
|
onChange?.(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fire = () => {
|
|
||||||
onClick?.();
|
|
||||||
setFlash(true);
|
|
||||||
clearTimeout(flashTimer.current);
|
|
||||||
flashTimer.current = setTimeout(() => setFlash(false), 160);
|
|
||||||
};
|
|
||||||
|
|
||||||
const momentaryProps =
|
const momentaryProps =
|
||||||
mode === 'action'
|
mode === 'momentary'
|
||||||
? {
|
|
||||||
onClick: fire,
|
|
||||||
onPointerDown: (e: React.PointerEvent) => {
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
setHeld(true);
|
|
||||||
},
|
|
||||||
onPointerUp: () => setHeld(false),
|
|
||||||
onPointerCancel: () => setHeld(false),
|
|
||||||
onPointerLeave: () => setHeld(false),
|
|
||||||
}
|
|
||||||
: mode === 'momentary'
|
|
||||||
? {
|
? {
|
||||||
onPointerDown: (e: React.PointerEvent) => {
|
onPointerDown: (e: React.PointerEvent) => {
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
|
@ -131,40 +83,27 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
if (e.key === ' ' || e.key === 'Enter') set(false);
|
if (e.key === ' ' || e.key === 'Enter') set(false);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: { onClick: () => set(!latched) };
|
: { onClick: () => set(!on) };
|
||||||
|
|
||||||
// Merge our own handlers with any consumer/Radix-injected ones of the same
|
|
||||||
// name (from {...rest}), so the button works as a Radix `asChild` trigger:
|
|
||||||
// the injected onPointerDown/onKeyDown/data-state/aria-* all reach the DOM.
|
|
||||||
const restHandlers = rest as Record<string, ((e: unknown) => void) | undefined>;
|
|
||||||
const composedHandlers = Object.fromEntries(
|
|
||||||
Object.entries(momentaryProps).map(([k, fn]) => [
|
|
||||||
k,
|
|
||||||
chain(restHandlers[k], fn as (e: unknown) => void),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const handleFocus = chain(restHandlers.onFocus, (e: React.FocusEvent<HTMLButtonElement>) => {
|
|
||||||
try {
|
|
||||||
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
|
||||||
} catch {
|
|
||||||
setFocusVisible(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const handleBlur = chain(restHandlers.onBlur, () => setFocusVisible(false));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...rest}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-pressed={mode === 'action' ? undefined : latched}
|
aria-pressed={on}
|
||||||
|
aria-label={aria['aria-label']}
|
||||||
data-pressed={on ? '' : undefined}
|
data-pressed={on ? '' : undefined}
|
||||||
data-focus-visible={focusVisible ? '' : undefined}
|
data-focus-visible={focusVisible ? '' : undefined}
|
||||||
className={className}
|
className={className}
|
||||||
onFocus={handleFocus}
|
onFocus={e => {
|
||||||
onBlur={handleBlur}
|
try {
|
||||||
{...composedHandlers}
|
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
||||||
|
} catch {
|
||||||
|
setFocusVisible(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => setFocusVisible(false)}
|
||||||
|
{...momentaryProps}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
|
|
@ -177,12 +116,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,8 +148,8 @@ 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: on ? 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: on ? `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,8 +164,8 @@ 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: on ? 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: on ? `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',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -234,7 +177,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
color: ledOn ? accent : theme.label,
|
color: on ? accent : theme.label,
|
||||||
transition: 'color 80ms',
|
transition: 'color 80ms',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -244,26 +187,8 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{name && <input type="hidden" name={name} value={latched ? 'on' : 'off'} readOnly />}
|
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends Omit<PushButtonProps, 'mode' | 'pressed' | 'defaultPressed' | 'onChange'> {
|
|
||||||
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<HTMLButtonElement, ButtonProps>(
|
|
||||||
function Button({ led, ...rest }, ref) {
|
|
||||||
return <PushButton ref={ref} mode="action" led={led} {...rest} />;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue