Show Pitch and Mix nodes as one-knob devices in green and blue (#390)

The Gain has been a small device with one big knob since #384. The
Pitch and the Mix are just as much a single number, so they now look
and work the same way. The Mix also gets a lever for its curve, which
until now was only a row in the inspector.

- GainPanel and GainFace become KnobPanel and KnobFace. Each node is
  described by a KnobDevice: its plate, its row, how the knob turns,
  and an optional lever. The Gain looks as before.
- Pitch: a green 160x160 plate with a bipolar knob for Semitones. 0
  stands straight up and taper 2 puts an octave at 43 % of each half,
  so small steps near 0 are easy and the full +-64 is still there.
- Mix: a blue 180x200 plate, the lever on top and the knob below.
  The knob has A and B engraved at the ends of its turn. The lever
  reads "crossfade" down and "both at center" up with its lamp lit.
  A flip is one undo step.
- A wired Semitones or Mix greys the knob, as for the Gain.
- The instrument presets give Pitch and Mix two columns. In the graph
  they are 281 and 261 units wide, more than one column of 240.
- The test hooks __gm.pitch.panel and __gm.mix.panel return the same
  shape as __gm.gain.panel, plus the lever.

Tests:
- Unit hardware-view: 15 tests, 3 new (Pitch turn, lever above the
  knob and ends on the Mix plate). Counter-check: taper 1 turns the
  Pitch test red.
- e2e 34 (melektaus-atx): 134 checks, 1 new.
- Gate (melektaus-atx, together with #391): 287 files, 4397 tests.
- Pictures looked at: Gain, Pitch and Mix in a row, Pitch at -24.5,
  Mix with both curves, the Bell preset without overlaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 17:37:03 +02:00
1 parent e8021b1fd3
commit e70954115c
12 files changed
+468 -184

No files matched your search

+54 -2
View File
@@ -1,7 +1,7 @@
/**
* The Compressor's hardware panel (#379): every part on the plate and none over another, the meter's
* scale and needle, the LEDs, and the grain of the plate the same every time. The Gain's plate
* (#384) and how its one knob turns.
* (#384) and how its one knob turns, the Pitch's knob and the Mix's lever and ends (#390).
*/
import { describe, expect, it } from 'vitest';
import {
@@ -14,6 +14,7 @@ import {
GAIN_TAPER,
gainReduction,
knobBox,
knobEnds,
LED_COUNT,
ledColour,
ledDb,
@@ -22,12 +23,15 @@ import {
METER_LABELS,
METER_RANGE_DB,
meterPosition,
MIX_LAYOUT,
MIX_PANEL,
needleAngle,
needleStep,
PANEL,
PANEL_LAYOUT,
PITCH_TAPER,
} from '../../src/editor/soundfx/hardwareView';
import { GAIN_ROW } from '../../src/core/soundfx/nodes';
import { GAIN_ROW, PITCH_ROW } from '../../src/core/soundfx/nodes';
import { knobPosition } from '../../src/ui/knob';
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
@@ -163,3 +167,51 @@ describe('the Gain (#384)', () => {
expect(knobPosition(-6, range)).toBeGreaterThan(0.4);
});
});
describe('the Pitch and the Mix (#390)', () => {
it('turns the Pitch from 0 straight up, an octave either way alike and well inside the turn', () => {
const range = {
min: PITCH_ROW.min ?? 0,
max: PITCH_ROW.max ?? 0,
step: 0.1,
taper: PITCH_TAPER,
bipolar: true,
};
expect(knobPosition(0, range)).toBeCloseTo(0.5, 5);
const up = knobPosition(12, range);
expect(up - 0.5).toBeCloseTo(0.5 - knobPosition(-12, range), 5);
expect(up).toBeGreaterThan(0.7);
expect(up).toBeLessThan(0.75);
expect(knobPosition(64, range)).toBe(1);
});
it('holds the Mix’s lever above the knob, centred and clear of it and of the screws', () => {
const lever = MIX_LAYOUT.curve;
const spot = MIX_LAYOUT.knob;
const knob = knobBox(spot);
expect(lever.x).toBeGreaterThanOrEqual(0);
expect(lever.x + lever.w).toBeLessThanOrEqual(MIX_PANEL.w);
expect(lever.x + lever.w / 2).toBe(MIX_PANEL.w / 2);
expect(spot.x).toBe(MIX_PANEL.w / 2);
// A lever that is down reaches 8 below its box (`Lever.redraw`).
expect(lever.y + lever.h + 8).toBeLessThanOrEqual(knob.y);
for (const screw of MIX_LAYOUT.screws)
expect(overlap(lever, { x: screw.x - 7, y: screw.y - 7, w: 14, h: 14 })).toBe(false);
// Below the dial as much air as on the Gain.
expect(MIX_PANEL.h - (spot.y + spot.size / 2)).toBe(GAIN_PANEL.h - (GAIN_LAYOUT.knob.y + spot.size / 2));
expect(MIX_PANEL.w).toBeLessThan(MIX_PANEL.h);
});
it('names the ends of the Mix’s turn just outside the dial, left and right below its middle', () => {
const spot = MIX_LAYOUT.knob;
const { a, b } = knobEnds(spot);
for (const end of [a, b]) {
expect(Math.hypot(end.x - spot.x, end.y - spot.y)).toBeGreaterThan(spot.size / 2);
expect(end.y).toBeGreaterThan(spot.y);
expect(end.y).toBeLessThan(MIX_PANEL.h);
}
expect(a.x).toBeLessThan(spot.x);
expect(b.x).toBeGreaterThan(spot.x);
expect(a.y).toBe(b.y);
});
});