Values and formats
Ranges, scales that map travel to value, and formats that print values in the user's locale and parse what they type.
Knobs, faders and number boxes share one value model. Values are in natural units (Hz, dB, ms, −1 … 1 for pan); the position along the control, its travel, runs from 0 to 1 and is derived through a scale.
Ranges
min, max and step on a control are a Range, which you can also use on its own, for example to map MIDI controllers or draw automation:
import { createRange, scales } from '@addstack/daw-ui';
const cutoff = createRange({ min: 20, max: 20_000, scale: scales.log });
cutoff.normalize(632); // 0.5
cutoff.denormalize(0.5); // 632.5…constrain clamps a value and snaps it to the step; denormalize does so after mapping.
Ends
A range stops at min and max unless you say otherwise:
| Option | Past the ends | For |
|---|---|---|
| (none) | The value stops. | Most parameters. |
wrap | The value comes around from the other end: max is the same point as min, so values stay in [min, max). | Phase, hue, the angle of a surround panner, a list that loops. |
endless | The value keeps counting, and travel counts on past 0 and 1: min … max only sets the scale. | Encoders that step through presets, jog wheels, relative controls. |
const phase = createRange({ min: -180, max: 180, wrap: true });
phase.constrain(190); // -170
const detents = createRange({ min: 0, max: 24, step: 1, endless: true });
detents.normalize(36); // 1.5: a turn and a halfControls take the same options as props. A wrapping or endless knob turns a full circle, and an endless one reports details.delta for relative uses.
Values that change on their own
A knob or fader takes its value from value (React state), defaultValue (uncontrolled), or read, a function called once per animation frame for values that change by themselves: automation playback, modulation, a control surface. read shows the value without rendering, and the user's drag wins while they hold the control. See performance for what the difference costs.
Scales
| Scale | Use | Travel |
|---|---|---|
scales.linear | Most parameters | Proportional to the value. |
scales.log | Frequencies, times | Equal travel for equal ratios: every octave gets the same distance. Needs min > 0. |
scales.power(n) | Anything that needs more room at one end | n > 1 gives the low values more travel. |
scales.decibel | Volume in dB | A console fader law: 0 dB at about 80% of a −70 … +6 dB fader. min may be -Infinity. |
A scale is an object with toNormalized(value, min, max) and fromNormalized(travel, min, max), so you can write your own.
Formats
A format prints the value for the readout and for aria-valuetext, and parses what a user types into a number box:
type ValueFormat = {
format(value: number): string;
parse(text: string): number | null; // null: not understood, keep the value
};| Format | Shows | Parses |
|---|---|---|
formats.decibel() | -6.0 dB, -∞ dB | -6, -6,5 dB, -inf |
formats.frequency() | 440 Hz, 1.50 kHz | 440, 1k, 1.5 kHz |
formats.time() | 250 ms, 1.50 s | 250, 1.5 s |
formats.percent() | 50% (50 % in German) | 50, 50% |
formats.pan({ left, right, center }) | 50L, C, 50R, with your letters | 50L, -50, C |
formats.number({ digits, unit }) | 120.00 BPM | 128, 128 BPM |
formats.position({ beatsPerBar, divisions }) | beats as 12.3.2: bars, beats, sixteenths | 12.3.2, 12 |
formats.timecode({ fps }) | seconds as 01:02:03:12: hours to frames | 01:02:03:12, 3:12, 1500 |
formats.pitch({ names, middleC }) | a MIDI note as C♯4, with your note names | C#4, c♯4, Bb3 |
Numbers are printed with Intl.NumberFormat in the runtime's locale, or the locale you pass: formats.frequency({ locale: 'pl' }) prints 1,50 kHz. Parsing accepts either decimal separator. See internationalization.
Segments
A format can also split its value into fields that a number box changes one at a time: number, position and timecode do. segments lists the fields and the text between them:
type ValueSegment =
| { type: 'field'; name: string; step: number; get(value: number): number; format(value: number): string; min?: number; max?: number }
| { type: 'literal'; text: string };A field's step is how much one step of it changes the value, in the value's units: for a position in beats, a bar is 4 and a sixteenth 0.25. get is the field's number in a value and format its text. The fields are views of the one value, so stepping one past its end carries into the next, and format(value) can be the fields' texts joined.
position counts from 1, as DAWs do, and shows the division a position is in. timecode takes whole frame rates (24, 25, 30); drop-frame timecode is not supported. Both parse what they show, position a bar alone (12), and timecode fields from the right (3:12 is 3 seconds and 12 frames) or digits alone in pairs (1500 is 15 seconds), as editing systems do.
Reading a pitch
readPitch reads a frequency, in hertz, as a tuner does: the note it is, as a MIDI note number with a fraction, the note it is tuned to, and how many cents it is off. null is silence.
import { formats, readPitch } from '@addstack/daw-ui';
readPitch(446); // { note: 69.23, target: 69, cents: 23.4 }: A4, 23 cents sharp
readPitch(83, { targets: [40, 45, 50, 55, 59, 64] }); // tuned to E2, the nearest string
formats.pitch({ names }).format(69); // "A4"referenceis the frequency of A4, 440 Hz by default.targetsare the notes a pitch is tuned to instead of every note: the strings of an instrument, or the notes of a key, so that a voice snaps to it.targettunes to one note alone.- Pass the last reading's target as
previous: the note then stays until the pitch ishysteresiscents (10) nearer another, so that a pitch between two notes does not make the name flicker.
The Tuners block shows four tuners built on it, which write each reading to the page once per frame without rendering.