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

+3 -1
View File
@@ -395,10 +395,12 @@ const COLUMN = 240;
const ROW = 340;
/**
* Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide,
* a Chip's device (#386) wider, an Envelope's (#388) a little less.
* a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two.
*/
const COLUMNS_OF: Readonly<Record<string, number>> = {
[SoundFxFilter.type]: 4,
[SoundFxPitch.type]: 2,
[SoundFxMix.type]: 2,
[INSTRUMENT_CHIP]: 5,
[INSTRUMENT_ENVELOPE]: 3,
};
+9 -9
View File
@@ -560,7 +560,7 @@ export const SoundFxGain: SoundFxNodeDef = effect(
'blend',
);
const SEMITONES = numberRow('semitones', 'Semitones', -64, 64, 0.1);
export const PITCH_ROW = numberRow('semitones', 'Semitones', -64, 64, 0.1);
/** The semitones of a Pitch taken by its Wet (#354): a typed-in share is worked in at once. */
function pitchTimes(semitones: FxAmount, wet: FxAmount): FxAmount {
@@ -577,11 +577,11 @@ export const SoundFxPitch: SoundFxNodeDef = effect(
category: 'sound',
description:
'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length, also when the pitch changes while it plays. The pitch is made where the sound is read, so an effect before the Pitch acts as if it came after it. On a Chip it moves the frequency of the Chip itself. "Bypass" leaves the node out as if it were not there. "wet" takes the semitones from 0 up to all of them: at 0.5 a Pitch of 12 plays 6 semitones higher.',
inputs: [audioIn, followingIn(SEMITONES), numberIn(WET)],
inputs: [audioIn, followingIn(PITCH_ROW), numberIn(WET)],
outputs: [audioOut],
params: [],
emit: (sounds, amounts) => {
const semitones = pitchTimes(held(amounts.semitones, SEMITONES), held(amounts.wet, WET));
const semitones = pitchTimes(held(amounts.semitones, PITCH_ROW), held(amounts.wet, WET));
return isNone(semitones) ? arriving(sounds) : withPitch(arriving(sounds), semitones);
},
},
@@ -762,8 +762,8 @@ export function filterBandOf(params: PropertyBag): PassBand {
};
}
const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
const MIX_CURVE = {
export const MIX_ROW = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
export const MIX_CURVE_ROW = {
key: 'curve',
label: 'Curve',
kind: 'enum' as const,
@@ -783,17 +783,17 @@ export const SoundFxMix: SoundFxNodeDef = {
inputs: [
{ key: 'a', label: 'a', type: 'audio' },
{ key: 'b', label: 'b', type: 'audio' },
followingIn(MIX),
followingIn(MIX_ROW),
],
outputs: [audioOut],
params: [MIX_CURVE],
params: [MIX_CURVE_ROW],
emit: (sounds, amounts, settings) => {
const mix = held(amounts.mix, MIX);
const mix = held(amounts.mix, MIX_ROW);
const a = sounds.a ?? EMPTY_PLAN;
const b = sounds.b ?? EMPTY_PLAN;
if (isFixed(mix) && mix.value <= 0) return a;
if (isFixed(mix) && mix.value >= 1) return b;
return settings[MIX_CURVE.key] === 'both'
return settings[MIX_CURVE_ROW.key] === 'both'
? { mix, curve: 'both', a, b, stages: [] }
: { mix, a, b, stages: [] };
},