Files
feuerwerk/src/ui/knob.ts
T
adminandClaude Opus 5.5 1df38bd430 Edit a Compressor in a tab of its own, with knobs and levels (#378)
A Compressor had only its number rows in the node. Its numbers are
easier to set by feel on a dial, and what it does is easier to judge
when the level going in and the level going out are seen side by side.

- Knob is a new widget in the toolkit: a slider bent into a circle,
  its name above and a number field below. A drag up or down turns
  it, Shift a tenth as far, a notch of the wheel a fiftieth of the
  turn, a double-click sets the default, the number takes a typed
  value. A taper gives the low end more of the turn.
- The Compressor's tab opens on a double-click, one per node, like
  the Parametric EQ's. It has a knob for each of the seven numbers,
  greyed while its input is linked.
- Its curve shows level in to level out from -60 to 0 dB. The point
  where it bends sets the threshold, the end of the curve sets the
  ratio that ends there.
- Its levels show the last 4 s on both sides of the node. Where it
  takes more than 0.5 dB away, the stretch below the level coming in
  is tinted. The makeup is left out of that.
- The Compressor is tapped like the EQ. SpectrumTaps listens to more
  than one node at once and reads the loudest sample of the last
  25 ms on each side.
- The node tabs share one map in the editor (nodeTabs), so a third
  kind needs no new plumbing.

Findings:

- CI task 614 for 23922ee was red on the memory check of the stream:
  the tab's renderer grew by 94 MB with nothing decoded. Play mode
  made garbage of its own and was read before a collection. Both
  readings in play now come after one, and the check compares the
  growth streamed with the growth decoded (3.7 MB against 59.8 MB).
- The ratio handle sat on the right edge of the picture, where a
  widget's hit area ends. The curve is inset by the grab radius.

Tests:

- Gate (melektaus-atx): 282 files, 4338 tests (17 new)
- e2e 34 (melektaus-atx): ALL PASSED, 116 checks (6 new)
- Counter-checks: an inverted ratio search, stop() letting go of
  every node and a drag without its least step each fail exactly
  their test.
- Looked at the tab in a screenshot: a blip at -4.4 dB came out at
  -16.7 dB, the gap tinted, the threshold line at -24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:40:12 +02:00

57 lines
2.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The rules of a Knob (#378): where on its turn a value sits, and what a drag or a wheel makes of
* it. A turn is a position from 0 to 1; `taper` bends it, so a range whose useful part is small –
* an attack of a few milliseconds out of a second – gets most of the turn.
*/
import { clamp, snapToStep } from './math';
export interface KnobRange {
min: number;
max: number;
/** The grid a value snaps to. */
step: number;
/** 1 turns evenly; above it the low end gets more of the turn. */
taper?: number;
}
/** Pixels of a vertical drag that take the knob from one end to the other. */
export const KNOB_TRAVEL = 200;
/** How much of the turn Shift leaves to a drag. */
export const KNOB_FINE = 0.1;
/** How far one notch of the wheel turns the knob. */
export const KNOB_NOTCH = 0.02;
/** The turn as an angle: from down-left round over the top to down-right, in radians. */
export const KNOB_START = Math.PI * 0.75;
export const KNOB_SWEEP = Math.PI * 1.5;
export function knobPosition(value: number, range: KnobRange): number {
const share = clamp((value - range.min) / (range.max - range.min), 0, 1);
return share ** (1 / (range.taper ?? 1));
}
export function knobValue(position: number, range: KnobRange): number {
const share = clamp(position, 0, 1) ** (range.taper ?? 1);
return clamp(snapToStep(range.min + share * (range.max - range.min), range.step), range.min, range.max);
}
/**
* The value a vertical drag reaches from `start`: up turns it up. A drag too short to reach the
* next step still moves it by one, so a fine drag over a coarse range is never stuck.
*/
export function draggedValue(start: number, dy: number, fine: boolean, range: KnobRange): number {
const travel = (-dy / KNOB_TRAVEL) * (fine ? KNOB_FINE : 1);
const value = knobValue(knobPosition(start, range) + travel, range);
if (value !== start || travel === 0) return value;
return clamp(snapToStep(start + Math.sign(travel) * range.step, range.step), range.min, range.max);
}
/** The value one notch of the wheel reaches: up turns it up, by at least one step. */
export function wheeledValue(value: number, deltaY: number, range: KnobRange): number {
return draggedValue(value, Math.sign(deltaY) * KNOB_NOTCH * KNOB_TRAVEL, false, range);
}
/** Where the filled arc starts: at 0 when the range has it inside, else at the low end. */
export function knobOrigin(range: KnobRange): number {
return range.min < 0 && range.max > 0 ? knobPosition(0, range) : 0;
}