daw-ui

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:

OptionPast the endsFor
(none)The value stops.Most parameters.
wrapThe 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.
endlessThe 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 half

Controls 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

ScaleUseTravel
scales.linearMost parametersProportional to the value.
scales.logFrequencies, timesEqual travel for equal ratios: every octave gets the same distance. Needs min > 0.
scales.power(n)Anything that needs more room at one endn > 1 gives the low values more travel.
scales.decibelVolume in dBA 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
};
FormatShowsParses
formats.decibel()-6.0 dB, -∞ dB-6, -6,5 dB, -inf
formats.frequency()440 Hz, 1.50 kHz440, 1k, 1.5 kHz
formats.time()250 ms, 1.50 s250, 1.5 s
formats.percent()50% (50 % in German)50, 50%
formats.pan({ left, right, center })50L, C, 50R, with your letters50L, -50, C
formats.number({ digits, unit })120.00 BPM128, 128 BPM
formats.position({ beatsPerBar, divisions })beats as 12.3.2: bars, beats, sixteenths12.3.2, 12
formats.timecode({ fps })seconds as 01:02:03:12: hours to frames01:02:03:12, 3:12, 1500
formats.pitch({ names, middleC })a MIDI note as C♯4, with your note namesC#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"
  • reference is the frequency of A4, 440 Hz by default.
  • targets are 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. target tunes to one note alone.
  • Pass the last reading's target as previous: the note then stays until the pitch is hysteresis cents (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.

On this page