Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 98 additions & 2 deletions src/components/pianoroll/SynthInstrumentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ function renderSubtractiveEditor(
instrument: SubtractiveTrackInstrument,
onInstrumentChange: (instrument: SubtractiveTrackInstrument) => void,
) {
const { oscillator, ampEnvelope, filter, lfo, unison, glideTime, outputGain } = instrument.settings;
const { oscillator, ampEnvelope, filter, filterEnvelope, lfo, unison, glideTime, outputGain } = instrument.settings;

const updateSettings = (settings: SubtractiveTrackInstrument['settings']) => {
onInstrumentChange({
Expand Down Expand Up @@ -311,7 +311,7 @@ function renderSubtractiveEditor(
</Section>

<Section
title="Filter, Modulation, and Width"
title="Filter, Envelopes, and Motion"
eyebrow="Motion"
action={(
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -486,6 +486,101 @@ function renderSubtractiveEditor(
/>
</div>

<div className="mt-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-400">Filter Envelope</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-zinc-500">Per-note cutoff contour</div>
</div>
<div className={`mt-2 grid grid-cols-3 gap-3 ${filter.enabled ? '' : 'opacity-45'}`}>
<EditorKnob
value={filterEnvelope.amount}
min={0}
max={1}
defaultValue={0}
step={0.01}
label="Filter Env Amt"
disabled={!filter.enabled}
onChange={(value) => updateSettings({
...instrument.settings,
filterEnvelope: {
...filterEnvelope,
amount: value,
},
})}
/>
<EditorKnob
value={filterEnvelope.attack}
min={0}
max={2}
defaultValue={0.01}
step={0.01}
label="Filt Env Attack"
unit="s"
disabled={!filter.enabled}
onChange={(value) => updateSettings({
...instrument.settings,
filterEnvelope: {
...filterEnvelope,
attack: value,
},
})}
/>
<EditorKnob
value={filterEnvelope.decay}
min={0}
max={2}
defaultValue={0.2}
step={0.01}
label="Filt Env Decay"
unit="s"
disabled={!filter.enabled}
onChange={(value) => updateSettings({
...instrument.settings,
filterEnvelope: {
...filterEnvelope,
decay: value,
},
})}
/>
</div>
<div className={`mt-3 grid grid-cols-2 gap-3 ${filter.enabled ? '' : 'opacity-45'}`}>
<EditorKnob
value={filterEnvelope.sustain}
min={0}
max={1}
defaultValue={0.5}
step={0.01}
label="Filt Env Sustain"
disabled={!filter.enabled}
onChange={(value) => updateSettings({
...instrument.settings,
filterEnvelope: {
...filterEnvelope,
sustain: value,
},
})}
/>
<EditorKnob
value={filterEnvelope.release}
min={0}
max={5}
defaultValue={0.5}
step={0.01}
label="Filt Env Release"
unit="s"
disabled={!filter.enabled}
onChange={(value) => updateSettings({
...instrument.settings,
filterEnvelope: {
...filterEnvelope,
release: value,
},
})}
/>
</div>
</div>

<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-400">LFO and Glide</div>
<div className="mt-4 grid grid-cols-4 gap-3">
<EditorKnob
value={lfo.rateHz}
Expand Down Expand Up @@ -551,6 +646,7 @@ function renderSubtractiveEditor(
/>
</div>

<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-400">Unison</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<SelectField
ariaLabel="Instrument unison voices"
Expand Down
24 changes: 23 additions & 1 deletion tests/unit/SynthInstrumentEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SynthInstrumentEditor } from '../../src/components/pianoroll/SynthInstr
import { createDefaultFmInstrument, createDefaultSubtractiveInstrument } from '../../src/utils/trackInstrument';

describe('SynthInstrumentEditor', () => {
it('updates subtractive oscillator waveform and filter cutoff through canonical instrument state', () => {
it('updates subtractive oscillator waveform, filter cutoff, and filter envelope through canonical instrument state', () => {
const onInstrumentChange = vi.fn();

render(
Expand Down Expand Up @@ -32,6 +32,28 @@ describe('SynthInstrumentEditor', () => {
filter: expect.objectContaining({ cutoffHz: 3200 }),
}),
}));

fireEvent.contextMenu(screen.getByLabelText('Filter Env Amt knob'));
fireEvent.change(screen.getByLabelText('Filter Env Amt exact value'), { target: { value: '0.42' } });
fireEvent.keyDown(screen.getByLabelText('Filter Env Amt exact value'), { key: 'Enter' });

expect(onInstrumentChange).toHaveBeenLastCalledWith(expect.objectContaining({
kind: 'subtractive',
settings: expect.objectContaining({
filterEnvelope: expect.objectContaining({ amount: 0.42 }),
}),
}));

fireEvent.contextMenu(screen.getByLabelText('Filt Env Release knob'));
fireEvent.change(screen.getByLabelText('Filt Env Release exact value'), { target: { value: '1.8' } });
fireEvent.keyDown(screen.getByLabelText('Filt Env Release exact value'), { key: 'Enter' });

expect(onInstrumentChange).toHaveBeenLastCalledWith(expect.objectContaining({
kind: 'subtractive',
settings: expect.objectContaining({
filterEnvelope: expect.objectContaining({ release: 1.8 }),
}),
}));
});

it('updates FM fallback preset and modulation index through canonical instrument state', () => {
Expand Down