feat: run the per-voice filter between the pitch and amp stages, with its own deck
Params ride the one parameter set; payload v8 -> v9, off by default. Deck composition moves to a pure deck_groups module in pitch -> filter -> amp order.
This commit is contained in:
@@ -81,7 +81,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
|
|||||||
| `src/core/audio/` | pure audio-data math |
|
| `src/core/audio/` | pure audio-data math |
|
||||||
| `src/core/capture/` | pure logic behind the capture pillar |
|
| `src/core/capture/` | pure logic behind the capture pillar |
|
||||||
| `src/core/instrument/` | pure VST3-instrument core (engine / map / ui) |
|
| `src/core/instrument/` | pure VST3-instrument core (engine / map / ui) |
|
||||||
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage; no call site yet) |
|
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
|
||||||
| `src/core/json/` | the hand-rolled JSON lexical layer |
|
| `src/core/json/` | the hand-rolled JSON lexical layer |
|
||||||
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
|
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
|
||||||
| `src/core/reclaim/` | pure prune orphan computation |
|
| `src/core/reclaim/` | pure prune orphan computation |
|
||||||
|
|||||||
@@ -198,9 +198,9 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
|||||||
### `engine/`
|
### `engine/`
|
||||||
|
|
||||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes.
|
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions.
|
||||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample.
|
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class.
|
||||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU.
|
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`.
|
||||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||||
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
||||||
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
|
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
|
||||||
@@ -209,7 +209,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
|||||||
### `map/`
|
### `map/`
|
||||||
|
|
||||||
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v8), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id).
|
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v9), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default.
|
||||||
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
|
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
|
||||||
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
|
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
|
||||||
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
|
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
|
||||||
@@ -227,6 +227,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
|||||||
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
|
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
|
||||||
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
|
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
|
||||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
|
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
|
||||||
|
- `deck_groups` — WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
|
||||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||||
- `envelope_overlay` — pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
- `envelope_overlay` — pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge.
|
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge.
|
||||||
|
|||||||
@@ -13,14 +13,19 @@ reasampler_test(velocity_curve LINK velocity_curve)
|
|||||||
reasampler_pure_library(master_gain SOURCES master_gain.cpp)
|
reasampler_pure_library(master_gain SOURCES master_gain.cpp)
|
||||||
reasampler_test(master_gain LINK master_gain)
|
reasampler_test(master_gain LINK master_gain)
|
||||||
|
|
||||||
|
# Declared before sampler_core because the voice now runs one per sounding note.
|
||||||
|
add_subdirectory(filter)
|
||||||
|
|
||||||
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
|
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
|
||||||
# block render). The per-sample render half stays inline in voice.h precisely so this TU
|
# block render). The per-sample render half stays inline in voice.h precisely so this TU
|
||||||
# boundary costs the hot path nothing.
|
# boundary costs the hot path nothing.
|
||||||
reasampler_pure_library(sampler_core
|
reasampler_pure_library(sampler_core
|
||||||
SOURCES voice.cpp voice_engine.cpp
|
SOURCES voice.cpp voice_engine.cpp
|
||||||
LINK PUBLIC peaks pitch_shift velocity_curve)
|
LINK PUBLIC peaks pitch_shift velocity_curve filter)
|
||||||
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
|
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
|
||||||
# or REAPER type reaching the core would fail to compile or link here.
|
# or REAPER type reaching the core would fail to compile or link here.
|
||||||
reasampler_test(sampler_core LINK sampler_core)
|
reasampler_test(sampler_core LINK sampler_core)
|
||||||
|
|
||||||
add_subdirectory(filter)
|
# The filter's own seams are covered by the four targets in filter/; this one covers the
|
||||||
|
# integration: pipeline order, per-voice independence, and the off-by-default bit-identity.
|
||||||
|
reasampler_test(sampler_filter LINK sampler_core)
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a con
|
|||||||
morph under one of two laws — HP→BP→LP or HP→notch→LP — and a drive stage. No REAPER, no
|
morph under one of two laws — HP→BP→LP or HP→notch→LP — and a drive stage. No REAPER, no
|
||||||
VST3, no allocation, no I/O. Everything
|
VST3, no allocation, no I/O. Everything
|
||||||
here lives in `reasampler::instrument::engine::filter`, nested per the
|
here lives in `reasampler::instrument::engine::filter`, nested per the
|
||||||
directory-mirrors-namespace convention — this keeps `FilterSettings` and friends out of
|
directory-mirrors-namespace convention, which keeps `FilterSettings` and friends out of
|
||||||
`reasampler::instrument::engine` proper, where `zone_params.h` lives, since this module has
|
`reasampler::instrument::engine` proper where `play_params.h` lives — and `play_params.h`
|
||||||
no call site yet to force a collision into the open at compile time. Five files, one
|
now stores a `FilterSettings` by value, so that separation is load-bearing rather than
|
||||||
responsibility each:
|
merely tidy. Five files, one responsibility each:
|
||||||
|
|
||||||
- `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and
|
- `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and
|
||||||
drive depth, plus the exact inverses for cutoff and Q.
|
drive depth, plus the exact inverses for cutoff and Q.
|
||||||
@@ -224,8 +224,10 @@ topology.
|
|||||||
band-pass, `HighNotchLow` on pure high-pass, since it has no band tap to land on.
|
band-pass, `HighNotchLow` on pure high-pass, since it has no band tap to land on.
|
||||||
- **Measuring a null needs a ring-time-adequate settle window.** At `Q = 10` the leftover
|
- **Measuring a null needs a ring-time-adequate settle window.** At `Q = 10` the leftover
|
||||||
transient alone reads as −52 dB after 0.15 s and would be mistaken for the noise floor.
|
transient alone reads as −52 dB after 0.15 s and would be mistaken for the noise floor.
|
||||||
- **No call site yet.** Wiring the filter into the voice path is a separate track; nothing
|
- **The call site is `Voice::advanceFrame`**, between the pitch stage and the amp multiply.
|
||||||
in `sampler_core` references this module today.
|
It re-`prepare()`s only when the modulated cutoff crosses one step of a 2048-step
|
||||||
|
quantization of the sweep, because a solve costs a `tan` plus the morph's `cos`/`sin` — an
|
||||||
|
unmodulated voice must not pay for them per frame (see `voice.h`'s `kFilterModSteps`).
|
||||||
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
|
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
|
||||||
budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
|
budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
|
||||||
is ample at 48k and expires mid-decay at 96k and above.
|
is ample at 48k and expires mid-decay at 96k and above.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/audio/peaks.h"
|
#include "core/audio/peaks.h"
|
||||||
|
#include "core/instrument/engine/filter/voice_filter.h"
|
||||||
#include "core/instrument/engine/velocity_curve.h"
|
#include "core/instrument/engine/velocity_curve.h"
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
@@ -90,15 +91,38 @@ struct PitchEnvParams {
|
|||||||
double peakSemitones = 0.0; // signed depth at the peak
|
double peakSemitones = 0.0; // signed depth at the peak
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Per-voice resonant filter, off by default (enabled=false -> the render path skips it
|
||||||
|
// entirely -> bit-identical to the un-filtered engine). Holds the filter module's OWN
|
||||||
|
// normalized control positions verbatim rather than a parallel set, so no control range is
|
||||||
|
// re-derived here; `filter_params.h` owns every law that maps them to Hz/Q/depth.
|
||||||
|
//
|
||||||
|
// The three modulation depths all land in that same normalized cutoff domain and sum before a
|
||||||
|
// single clamp: `modAmount` scales the per-frame filter envelope, `velAmount` scales the
|
||||||
|
// note-on velocity through `velocityCurve`, and `keyTrack` moves cutoff by octaves per octave
|
||||||
|
// above the root. All three are zero/neutral by default.
|
||||||
|
struct FilterParams {
|
||||||
|
bool enabled = false;
|
||||||
|
instrument::engine::filter::FilterSettings settings;
|
||||||
|
double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff
|
||||||
|
double velAmount = 0.0; // bipolar [-1,+1], velocity -> cutoff
|
||||||
|
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
|
||||||
|
AdsrParams env; // the same staged AHDSR the amp runs; frames
|
||||||
|
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
|
||||||
|
// because a flat curve under a depth control would make every velocity the same offset;
|
||||||
|
// the no-op at rest is velAmount == 0, not the curve.
|
||||||
|
VelocityCurve velocityCurve = VelocityCurve::linear();
|
||||||
|
};
|
||||||
|
|
||||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||||
// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
|
// Varispeed, pitch envelope off, filter off) — core regression tests rely on this; the
|
||||||
// default is layered on at (de)serialization, see kDefaultPitchEngine.
|
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||||
struct PlayParams {
|
struct PlayParams {
|
||||||
PlayMode playMode = PlayMode::Gate;
|
PlayMode playMode = PlayMode::Gate;
|
||||||
AdsrParams adsr;
|
AdsrParams adsr;
|
||||||
TriggerParams trigger;
|
TriggerParams trigger;
|
||||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||||
PitchEnvParams pitchEnv;
|
PitchEnvParams pitchEnv;
|
||||||
|
FilterParams filter;
|
||||||
};
|
};
|
||||||
|
|
||||||
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
||||||
|
|||||||
@@ -92,6 +92,24 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
|||||||
pitchEnv_.configure(p.pitchEnv);
|
pitchEnv_.configure(p.pitchEnv);
|
||||||
pitchEnv_.noteOn();
|
pitchEnv_.noteOn();
|
||||||
|
|
||||||
|
// Filter: fresh integrators per note (prepare() preserves state on purpose, so a note-on
|
||||||
|
// is the one place that must clear it). Velocity maps through the curve once here, off the
|
||||||
|
// per-frame path, exactly as the amp's velocityGain_ does.
|
||||||
|
filterOn_ = p.filter.enabled;
|
||||||
|
if (filterOn_) {
|
||||||
|
filterSettings_ = p.filter.settings;
|
||||||
|
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
|
||||||
|
filterModAmount_ = p.filter.modAmount;
|
||||||
|
filterKeyTrack_ = p.filter.keyTrack;
|
||||||
|
filterVelOffset_ =
|
||||||
|
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
||||||
|
filterRate_ = static_cast<double>(sample.sampleRate);
|
||||||
|
filterEnv_.configure(p.filter.env);
|
||||||
|
filterEnv_.noteOn();
|
||||||
|
filter_.reset();
|
||||||
|
updateFilterCutoffBase(note);
|
||||||
|
}
|
||||||
|
|
||||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||||
@@ -162,6 +180,9 @@ void Voice::retune(int note) {
|
|||||||
if (!active_ || sample_ == nullptr) return;
|
if (!active_ || sample_ == nullptr) return;
|
||||||
note_ = note;
|
note_ = note;
|
||||||
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
|
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
|
||||||
|
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
|
||||||
|
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
|
||||||
|
if (filterOn_) updateFilterCutoffBase(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Voice::release() {
|
void Voice::release() {
|
||||||
@@ -169,6 +190,7 @@ void Voice::release() {
|
|||||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||||
releasing_ = true;
|
releasing_ = true;
|
||||||
env_.noteOff();
|
env_.noteOff();
|
||||||
|
filterEnv_.noteOff();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
#include "core/audio/peaks.h"
|
#include "core/audio/peaks.h"
|
||||||
#include "core/instrument/engine/envelopes.h"
|
#include "core/instrument/engine/envelopes.h"
|
||||||
|
#include "core/instrument/engine/filter/filter_params.h"
|
||||||
|
#include "core/instrument/engine/filter/voice_filter.h"
|
||||||
#include "core/instrument/engine/pitch_shift.h"
|
#include "core/instrument/engine/pitch_shift.h"
|
||||||
#include "core/instrument/engine/play_params.h"
|
#include "core/instrument/engine/play_params.h"
|
||||||
#include "core/instrument/engine/velocity_curve.h"
|
#include "core/instrument/engine/velocity_curve.h"
|
||||||
@@ -40,6 +42,22 @@ inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
|||||||
return std::pow(2.0, semis / 12.0);
|
return std::pow(2.0, semis / 12.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One octave expressed in the cutoff control's normalized domain, read out of the filter
|
||||||
|
// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to
|
||||||
|
// filter_params, and a second copy here could drift from it. Evaluated at note-on only.
|
||||||
|
inline double filterNormPerOctave() {
|
||||||
|
namespace flt = instrument::engine::filter;
|
||||||
|
return static_cast<double>(flt::filterNormFromCutoffHz(2.0f * flt::kFilterCutoffMinHz) -
|
||||||
|
flt::filterNormFromCutoffHz(flt::kFilterCutoffMinHz));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A modulated cutoff re-solves the SVF coefficients, which costs a tan() plus the morph's
|
||||||
|
// cos/sin — so the solve is gated on the modulated position crossing one step of this
|
||||||
|
// quantization of the sweep. 2048 steps over three decades is ~0.06 semitone, far under the
|
||||||
|
// ear's resolution for a filter corner, and it collapses the solve to nothing across a static
|
||||||
|
// envelope stage: an unmodulated voice pays one integer compare per frame.
|
||||||
|
inline constexpr int kFilterModSteps = 2048;
|
||||||
|
|
||||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
|
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
|
||||||
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
|
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
|
||||||
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
|
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
|
||||||
@@ -159,6 +177,37 @@ private:
|
|||||||
return amp;
|
return amp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advances the filter envelope and re-solves the filter's coefficients when the modulated
|
||||||
|
// cutoff has moved a whole quantization step (see kFilterModSteps). prepare() deliberately
|
||||||
|
// preserves integrator state, so a moving cutoff glides rather than clicking.
|
||||||
|
void tickFilterCutoff() {
|
||||||
|
double cut = static_cast<double>(filterBaseCutoff_) +
|
||||||
|
filterModAmount_ * filterEnv_.tick();
|
||||||
|
if (cut < 0.0) cut = 0.0;
|
||||||
|
if (cut > 1.0) cut = 1.0;
|
||||||
|
const int step = static_cast<int>(cut * kFilterModSteps + 0.5);
|
||||||
|
if (step == filterModStep_) return;
|
||||||
|
filterModStep_ = step;
|
||||||
|
filterSettings_.cutoffNorm = static_cast<float>(cut);
|
||||||
|
filter_.prepare(filterSettings_, filterRate_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cutoff position before the envelope: the stored knob position plus this note's
|
||||||
|
// velocity offset and key-tracking. Recomputed at note-on and at a legato retune (both
|
||||||
|
// move the note), never per frame.
|
||||||
|
void updateFilterCutoffBase(int note) {
|
||||||
|
double base = filterCutoffNorm_ + filterVelOffset_;
|
||||||
|
if (filterKeyTrack_ != 0.0 && sample_ != nullptr) {
|
||||||
|
base += filterKeyTrack_ *
|
||||||
|
(static_cast<double>(note - sample_->rootNote) / 12.0) *
|
||||||
|
filterNormPerOctave();
|
||||||
|
}
|
||||||
|
if (base < 0.0) base = 0.0;
|
||||||
|
if (base > 1.0) base = 1.0;
|
||||||
|
filterBaseCutoff_ = static_cast<float>(base);
|
||||||
|
filterModStep_ = -1; // forces the next frame to solve
|
||||||
|
}
|
||||||
|
|
||||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||||
@@ -252,6 +301,9 @@ private:
|
|||||||
const double envFactor =
|
const double envFactor =
|
||||||
(pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
(pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||||
|
|
||||||
|
// Both pitch branches leave the UNENVELOPED post-pitch signal here; the filter acts on
|
||||||
|
// it and the amp gain is applied afterwards, so the pipeline is pitch -> filter -> amp
|
||||||
|
// and the amp envelope shapes the filtered result (drive included).
|
||||||
double outL, outRlocal = 0.0;
|
double outL, outRlocal = 0.0;
|
||||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||||
// Feed the shifters the source stream at unity rate (duration held) and transpose
|
// Feed the shifters the source stream at unity rate (duration held) and transpose
|
||||||
@@ -282,7 +334,7 @@ private:
|
|||||||
const double shift = baseRatio_ * envFactor;
|
const double shift = baseRatio_ * envFactor;
|
||||||
shiftL_.setShiftRatio(shift);
|
shiftL_.setShiftRatio(shift);
|
||||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||||
outL = shiftedL * gain;
|
outL = shiftedL;
|
||||||
if (stereo) {
|
if (stereo) {
|
||||||
if (haveR && shiftR_.configured()) {
|
if (haveR && shiftR_.configured()) {
|
||||||
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
|
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
|
||||||
@@ -300,13 +352,12 @@ private:
|
|||||||
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||||
shiftR_.setShiftRatio(shift);
|
shiftR_.setShiftRatio(shift);
|
||||||
outRlocal =
|
outRlocal =
|
||||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
|
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
|
||||||
gain;
|
|
||||||
} else {
|
} else {
|
||||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
|
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
|
||||||
// shifted value from the mono feed; mirror it to R. Do NOT call
|
// shifted value from the mono feed; mirror it to R. Do NOT call
|
||||||
// shiftL_.process again this frame.
|
// shiftL_.process again this frame.
|
||||||
outRlocal = shiftedL * gain;
|
outRlocal = shiftedL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
++feedPos_;
|
++feedPos_;
|
||||||
@@ -330,16 +381,34 @@ private:
|
|||||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||||
outL = srcL * gain;
|
outL = srcL;
|
||||||
if (stereo) {
|
if (stereo) {
|
||||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||||
outRlocal = srcR * gain;
|
outRlocal = srcR;
|
||||||
}
|
}
|
||||||
ratio_ = baseRatio_ * envFactor;
|
ratio_ = baseRatio_ * envFactor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skipped whole when disengaged (the default), so an un-filtered render stays
|
||||||
|
// bit-identical to the pre-filter engine.
|
||||||
|
if (filterOn_) {
|
||||||
|
tickFilterCutoff();
|
||||||
|
outL = static_cast<double>(filter_.process(0, static_cast<float>(outL)));
|
||||||
|
// Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the
|
||||||
|
// filtered result is exactly what a second identical filter would produce — one
|
||||||
|
// less kernel pass per frame for the same samples.
|
||||||
|
if (stereo) {
|
||||||
|
outRlocal = haveR
|
||||||
|
? static_cast<double>(filter_.process(1, static_cast<float>(outRlocal)))
|
||||||
|
: outL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outL *= gain;
|
||||||
|
if (stereo) outRlocal *= gain;
|
||||||
|
|
||||||
// Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
|
// Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
|
||||||
// restart, seed the blend weight at 1.0 so this frame's output is
|
// restart, seed the blend weight at 1.0 so this frame's output is
|
||||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||||
@@ -401,6 +470,23 @@ private:
|
|||||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||||
|
|
||||||
|
// The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at
|
||||||
|
// different envelope phases are filtered independently. filterSettings_ is this note's
|
||||||
|
// copy of the control positions with cutoffNorm overwritten per solve; filterCutoffNorm_
|
||||||
|
// keeps the unmodulated knob position the base is rebuilt from. filterRate_ <= 0 makes
|
||||||
|
// prepare() bypass rather than invent a rate.
|
||||||
|
instrument::engine::filter::VoiceFilter filter_;
|
||||||
|
AdsrEnvelope filterEnv_;
|
||||||
|
instrument::engine::filter::FilterSettings filterSettings_;
|
||||||
|
bool filterOn_ = false;
|
||||||
|
double filterRate_ = 0.0;
|
||||||
|
double filterCutoffNorm_ = 1.0;
|
||||||
|
double filterModAmount_ = 0.0;
|
||||||
|
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
|
||||||
|
double filterKeyTrack_ = 0.0;
|
||||||
|
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
|
||||||
|
int filterModStep_ = -1; // last solved cutoff step; -1 forces a solve
|
||||||
|
|
||||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// component_state_io — the ComponentState envelope + params-payload binary codec. See
|
// component_state_io — the ComponentState envelope + params-payload binary codec. See
|
||||||
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v8).
|
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v9).
|
||||||
// Every wire format is FROZEN — byte-identical across revisions.
|
// Every wire format is FROZEN — byte-identical across revisions.
|
||||||
|
|
||||||
#include "core/instrument/map/component_state_io.h"
|
#include "core/instrument/map/component_state_io.h"
|
||||||
@@ -51,6 +51,17 @@ void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
|||||||
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
|
||||||
|
// The amp curve (v7) and the filter's own curve (v9) share this shape.
|
||||||
|
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||||
|
const std::vector<VelocityPoint>& pts = curve.points();
|
||||||
|
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||||
|
for (const VelocityPoint& pt : pts) {
|
||||||
|
putLE(out, doubleToBits(pt.velocity));
|
||||||
|
putLE(out, doubleToBits(pt.amp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||||
// shape independent of the envelope version (see component_state_io.h).
|
// shape independent of the envelope version (see component_state_io.h).
|
||||||
@@ -79,14 +90,27 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
|
|||||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||||
// Key-tracking scalar (1.0 = 100% ET).
|
// Key-tracking scalar (1.0 = 100% ET).
|
||||||
putLE(out, doubleToBits(p.keyTrack));
|
putLE(out, doubleToBits(p.keyTrack));
|
||||||
// The velocity->amp transfer curve, appended last: 4-byte LE control-point count, then
|
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
|
||||||
// per point velocity + amp as doubles (endpoints included, so N >= 2).
|
// velocity + amp as doubles (endpoints included, so N >= 2).
|
||||||
const std::vector<VelocityPoint>& pts = p.velocityCurve.points();
|
putCurve(out, p.velocityCurve);
|
||||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
|
||||||
for (const VelocityPoint& pt : pts) {
|
// whole payload stays one numeric shape.
|
||||||
putLE(out, doubleToBits(pt.velocity));
|
const FilterSeconds& f = pp.filter;
|
||||||
putLE(out, doubleToBits(pt.amp));
|
out.push_back(f.enabled ? 1 : 0);
|
||||||
}
|
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
|
||||||
|
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
|
||||||
|
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
|
||||||
|
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
|
||||||
|
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
|
||||||
|
putLE(out, doubleToBits(f.modAmount));
|
||||||
|
putLE(out, doubleToBits(f.velAmount));
|
||||||
|
putLE(out, doubleToBits(f.keyTrack));
|
||||||
|
putLE(out, doubleToBits(f.env.attackSeconds));
|
||||||
|
putLE(out, doubleToBits(f.env.holdSeconds));
|
||||||
|
putLE(out, doubleToBits(f.env.decaySeconds));
|
||||||
|
putLE(out, doubleToBits(f.env.sustainLevel));
|
||||||
|
putLE(out, doubleToBits(f.env.releaseSeconds));
|
||||||
|
putCurve(out, f.velocityCurve);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||||
@@ -108,9 +132,9 @@ void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
|
|||||||
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the velocity->amp curve tail into `p`. fromPoints repairs the X-order/endpoint
|
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
|
||||||
// invariant defensively; a truncated read leaves the flat default.
|
// defensively; a truncated read leaves `curve` at whatever default it came in with.
|
||||||
void readCurveTail(ByteReader& r, InstrumentParams& p) {
|
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
|
||||||
const std::uint32_t ptCount = r.u32();
|
const std::uint32_t ptCount = r.u32();
|
||||||
std::vector<VelocityPoint> pts;
|
std::vector<VelocityPoint> pts;
|
||||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
||||||
@@ -123,10 +147,32 @@ void readCurveTail(ByteReader& r, InstrumentParams& p) {
|
|||||||
pts.push_back(VelocityPoint{vel, amp});
|
pts.push_back(VelocityPoint{vel, amp});
|
||||||
}
|
}
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
p.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
|
||||||
|
// which is what makes a v8 blob play bit-identically under the new codec.
|
||||||
|
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||||
|
FilterSeconds& f = p.play.filter;
|
||||||
|
f.enabled = (r.u8() != 0);
|
||||||
|
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
|
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
|
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
|
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
|
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
|
||||||
|
: engine::filter::MorphLaw::HighBandLow;
|
||||||
|
f.modAmount = bitsToDouble(r.u64());
|
||||||
|
f.velAmount = bitsToDouble(r.u64());
|
||||||
|
f.keyTrack = bitsToDouble(r.u64());
|
||||||
|
f.env.attackSeconds = bitsToDouble(r.u64());
|
||||||
|
f.env.holdSeconds = bitsToDouble(r.u64());
|
||||||
|
f.env.decaySeconds = bitsToDouble(r.u64());
|
||||||
|
f.env.sustainLevel = bitsToDouble(r.u64());
|
||||||
|
f.env.releaseSeconds = bitsToDouble(r.u64());
|
||||||
|
readCurveTail(r, f.velocityCurve);
|
||||||
|
}
|
||||||
|
|
||||||
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
||||||
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
||||||
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
||||||
@@ -188,7 +234,7 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
|
|||||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||||
if (curveTail) readCurveTail(r, p);
|
if (curveTail) readCurveTail(r, p.velocityCurve);
|
||||||
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||||
// leave the seconds product defaults on p.play.
|
// leave the seconds product defaults on p.play.
|
||||||
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||||
@@ -201,15 +247,16 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read whichever payload shape follows: the CURRENT v8 single record, or a retired v1..v7
|
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||||
// zone list (adopting zone one). An absent marker means v1 (a plain small zone count).
|
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
|
||||||
|
// v1 (a plain small zone count).
|
||||||
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||||
std::uint32_t pv = 0; // 0 = v1, no marker
|
std::uint32_t pv = 0; // 0 = v1, no marker
|
||||||
if (r.peekU32() == kParamsFormatMarker) {
|
if (r.peekU32() == kParamsFormatMarker) {
|
||||||
r.u32(); // consume the marker
|
r.u32(); // consume the marker
|
||||||
pv = r.u32(); // payload version
|
pv = r.u32(); // payload version
|
||||||
}
|
}
|
||||||
if (pv < kParamsPayloadVersion) return readLegacyZonePayload(r, pv, projectRate);
|
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
|
||||||
|
|
||||||
PayloadRead out;
|
PayloadRead out;
|
||||||
InstrumentParams& p = out.params;
|
InstrumentParams& p = out.params;
|
||||||
@@ -227,7 +274,8 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
|||||||
if (hasStart) p.startPoint = r.i64();
|
if (hasStart) p.startPoint = r.i64();
|
||||||
readSecondsPlayTail(r, p);
|
readSecondsPlayTail(r, p);
|
||||||
p.keyTrack = bitsToDouble(r.u64());
|
p.keyTrack = bitsToDouble(r.u64());
|
||||||
readCurveTail(r, p);
|
readCurveTail(r, p.velocityCurve);
|
||||||
|
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||||
// the same degrade-don't-throw contract the zone ladder always had.
|
// the same degrade-don't-throw contract the zone ladder always had.
|
||||||
if (!r.ok) return PayloadRead{};
|
if (!r.ok) return PayloadRead{};
|
||||||
|
|||||||
@@ -63,12 +63,20 @@ namespace reasampler::instrument::map {
|
|||||||
// payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
|
// payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
|
||||||
// (soft hits play louder than under the old linear velocity/127 map).
|
// (soft hits play louder than under the old linear velocity/127 map).
|
||||||
//
|
//
|
||||||
// v8 (CURRENT WRITE FORMAT) is the one-parameter-set record: marker + version (== 8), then a
|
// v8 is the first one-parameter-set record: marker + version (== 8), then a SINGLE record
|
||||||
// SINGLE record with no count, no key range and no sample id (the envelope's selection id is
|
// with no count, no key range and no sample id (the envelope's selection id is the capture):
|
||||||
// the capture): 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte
|
// 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte hasLoopOverride + [1 byte
|
||||||
// hasLoopOverride + [1 byte loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set);
|
// loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set); 1 byte hasStartPoint + 8-byte LE
|
||||||
// 1 byte hasStartPoint + 8-byte LE startPoint (iff set); the v5 play tail verbatim
|
// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the
|
||||||
// (SECONDS); 8-byte LE keyTrack; then the velocity curve (count + points) as in v7.
|
// velocity curve (count + points) as in v7.
|
||||||
|
//
|
||||||
|
// v9 (CURRENT WRITE FORMAT) is v8 PLUS the per-voice filter tail, appended after the velocity
|
||||||
|
// curve: 1 byte enabled; 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles,
|
||||||
|
// widened from the module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte
|
||||||
|
// LE modAmount, velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release
|
||||||
|
// SECONDS; then the filter's OWN velocity curve (count + points, same shape as v7's). A v8
|
||||||
|
// blob is a strict prefix, so it lifts to the off/neutral filter default and plays
|
||||||
|
// bit-identically.
|
||||||
//
|
//
|
||||||
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
||||||
|
|
||||||
@@ -77,9 +85,19 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
|||||||
// The params-payload format version and its detection marker. The marker is a high sentinel
|
// The params-payload format version and its detection marker. The marker is a high sentinel
|
||||||
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
||||||
// a reader detects record shape independent of the envelope version.
|
// a reader detects record shape independent of the envelope version.
|
||||||
inline constexpr std::uint32_t kParamsPayloadVersion = 8; // one parameter set, no zones
|
inline constexpr std::uint32_t kParamsPayloadVersion = 9; // v8 + the per-voice filter tail
|
||||||
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
||||||
|
|
||||||
|
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
|
||||||
|
// reads through the legacy walk; everything at or above it shares the v8 record shape and
|
||||||
|
// grows by appending. The reader branches on this, never on kParamsPayloadVersion, so a
|
||||||
|
// future bump does not silently push the previous format back into the zone reader.
|
||||||
|
inline constexpr std::uint32_t kParamsSingleRecordVersion = 8;
|
||||||
|
|
||||||
|
// v8 + the per-voice filter tail. Named so the filter branch in readParamsPayload is
|
||||||
|
// self-describing, mirroring the envelope's version constants.
|
||||||
|
inline constexpr std::uint32_t kParamsFilterVersion = 9;
|
||||||
|
|
||||||
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
|
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
|
||||||
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
|
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
|
||||||
// (frames / projectRate = seconds) — the same rate the build already receives, so the
|
// (frames / projectRate = seconds) — the same rate the build already receives, so the
|
||||||
|
|||||||
@@ -225,6 +225,19 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
|||||||
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
||||||
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
||||||
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
||||||
|
// Filter: the control positions are already rate-free and carry through untouched; only
|
||||||
|
// its envelope resolves to frames.
|
||||||
|
out.filter.enabled = stored.filter.enabled;
|
||||||
|
out.filter.settings = stored.filter.settings;
|
||||||
|
out.filter.modAmount = stored.filter.modAmount;
|
||||||
|
out.filter.velAmount = stored.filter.velAmount;
|
||||||
|
out.filter.keyTrack = stored.filter.keyTrack;
|
||||||
|
out.filter.velocityCurve = stored.filter.velocityCurve;
|
||||||
|
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
|
||||||
|
out.filter.env.holdFrames = secToFrames(stored.filter.env.holdSeconds);
|
||||||
|
out.filter.env.decayFrames = secToFrames(stored.filter.env.decaySeconds);
|
||||||
|
out.filter.env.sustainLevel = stored.filter.env.sustainLevel;
|
||||||
|
out.filter.env.releaseFrames = secToFrames(stored.filter.env.releaseSeconds);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,21 @@ struct PitchEnvSeconds {
|
|||||||
double peakSemitones = 0.0; // signed depth at the peak
|
double peakSemitones = 0.0; // signed depth at the peak
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field
|
||||||
|
// MEANS). Only the envelope differs between the two: the control positions and depths are
|
||||||
|
// rate-free already, so this block is a seconds/frames split of one field, not of the whole
|
||||||
|
// struct. The env default is a flat unity, so `enabled` is the only thing standing between a
|
||||||
|
// loaded blob and the pre-filter sound.
|
||||||
|
struct FilterSeconds {
|
||||||
|
bool enabled = false;
|
||||||
|
engine::filter::FilterSettings settings;
|
||||||
|
double modAmount = 0.0;
|
||||||
|
double velAmount = 0.0;
|
||||||
|
double keyTrack = 0.0;
|
||||||
|
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0};
|
||||||
|
VelocityCurve velocityCurve = VelocityCurve::linear();
|
||||||
|
};
|
||||||
|
|
||||||
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||||
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
|
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
|
||||||
// from the engine-facing PlayParams (frames).
|
// from the engine-facing PlayParams (frames).
|
||||||
@@ -169,6 +184,7 @@ struct PlaySeconds {
|
|||||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
||||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||||
|
FilterSeconds filter; // per-voice filter, off by default
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
|
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
|
||||||
|
|||||||
@@ -44,5 +44,12 @@ reasampler_test(envelope_edit LINK envelope_edit)
|
|||||||
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
|
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
|
||||||
reasampler_test(knob_deck LINK knob_deck)
|
reasampler_test(knob_deck LINK knob_deck)
|
||||||
|
|
||||||
|
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free, while this
|
||||||
|
# names the controls and so reads PlayMode (velocity_curve comes along with play_params.h).
|
||||||
|
reasampler_pure_library(deck_groups
|
||||||
|
SOURCES deck_groups.cpp
|
||||||
|
LINK PUBLIC knob_deck velocity_curve peaks)
|
||||||
|
reasampler_test(deck_groups LINK deck_groups)
|
||||||
|
|
||||||
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
|
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
|
||||||
reasampler_test(curve_popup LINK curve_popup)
|
reasampler_test(curve_popup LINK curve_popup)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// deck_groups.cpp — see deck_groups.h. Pure data; no host types.
|
||||||
|
|
||||||
|
#include "core/instrument/ui/deck_groups.h"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
int id(DeckParam p) { return static_cast<int>(p); }
|
||||||
|
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
|
||||||
|
double deckNormFromBipolar(double value) { return clamp(value, -1.0, 1.0) * 0.5 + 0.5; }
|
||||||
|
|
||||||
|
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||||
|
std::vector<DeckGroupDesc> out;
|
||||||
|
{
|
||||||
|
DeckGroupDesc pitch;
|
||||||
|
pitch.id = kGroupPitch;
|
||||||
|
pitch.captionWidth = 38;
|
||||||
|
pitch.captionToggle = {id(DeckParam::kPitchEngine), 48};
|
||||||
|
pitch.cellIds = {id(DeckParam::kKeyTrack)};
|
||||||
|
out.push_back(std::move(pitch));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc penv;
|
||||||
|
penv.id = kGroupPitchEnv;
|
||||||
|
penv.captionWidth = 58;
|
||||||
|
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
|
||||||
|
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
|
||||||
|
id(DeckParam::kPitchEnvDecay),
|
||||||
|
id(DeckParam::kPitchEnvDepth)};
|
||||||
|
out.push_back(std::move(penv));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Tone shaping left-to-right, then the three modulation depths that all target cutoff.
|
||||||
|
DeckGroupDesc filter;
|
||||||
|
filter.id = kGroupFilter;
|
||||||
|
filter.captionWidth = 46;
|
||||||
|
filter.captionToggle = {id(DeckParam::kFilterEnable), 32};
|
||||||
|
filter.cellIds = {id(DeckParam::kFilterMorph),
|
||||||
|
id(DeckParam::kFilterCutoff),
|
||||||
|
id(DeckParam::kFilterQ),
|
||||||
|
id(DeckParam::kFilterDrive),
|
||||||
|
id(DeckParam::kFilterModAmt),
|
||||||
|
id(DeckParam::kFilterVel),
|
||||||
|
id(DeckParam::kFilterKeyTrack)};
|
||||||
|
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
|
||||||
|
out.push_back(std::move(filter));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc fenv;
|
||||||
|
fenv.id = kGroupFilterEnv;
|
||||||
|
fenv.captionWidth = 66;
|
||||||
|
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
|
||||||
|
id(DeckParam::kFilterEnvHold),
|
||||||
|
id(DeckParam::kFilterEnvDecay),
|
||||||
|
id(DeckParam::kFilterEnvSustain),
|
||||||
|
id(DeckParam::kFilterEnvRelease)};
|
||||||
|
out.push_back(std::move(fenv));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc amp;
|
||||||
|
amp.id = kGroupAmpEnv;
|
||||||
|
amp.captionWidth = 78;
|
||||||
|
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
|
||||||
|
if (playMode == PlayMode::Gate) {
|
||||||
|
amp.cellIds = {id(DeckParam::kAttack), id(DeckParam::kHold),
|
||||||
|
id(DeckParam::kDecay), id(DeckParam::kSustain),
|
||||||
|
id(DeckParam::kRelease)};
|
||||||
|
} else {
|
||||||
|
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
|
||||||
|
// the drawn envelope), plus the two reserved blanks that hold the Gate width.
|
||||||
|
amp.cellIds = {id(DeckParam::kTrigFadeIn), id(DeckParam::kTrigLength),
|
||||||
|
id(DeckParam::kTrigFadeOut), -1, -1};
|
||||||
|
}
|
||||||
|
out.push_back(std::move(amp));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc voice;
|
||||||
|
voice.id = kGroupVoice;
|
||||||
|
voice.captionWidth = 38;
|
||||||
|
voice.captionToggle = {id(DeckParam::kVoiceMode), 40};
|
||||||
|
voice.cellIds = {id(DeckParam::kVoiceCount)};
|
||||||
|
voice.rowToggle = {id(DeckParam::kMonoTrigger), 44};
|
||||||
|
out.push_back(std::move(voice));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc master;
|
||||||
|
master.id = kGroupMaster;
|
||||||
|
master.captionWidth = 46;
|
||||||
|
master.cellIds = {id(DeckParam::kMasterGain)};
|
||||||
|
out.push_back(std::move(master));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// deck_groups.h — WHICH groups the Sample face's knob deck carries and in what order, plus
|
||||||
|
// the control-id space they are built from. Pure data: knob_deck lays out whatever descriptors
|
||||||
|
// it is handed, and this module decides what those descriptors are, so the deck's signal-flow
|
||||||
|
// ordering is provable without a host.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/instrument/engine/play_params.h" // PlayMode (the AMP group's Gate/Trigger face)
|
||||||
|
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
// Deck control ids. Opaque to knob_deck, resolved by the shell's hit-test and value binding.
|
||||||
|
// Runtime-only — nothing persists them, so the ordering here is free to change.
|
||||||
|
enum class DeckParam {
|
||||||
|
kPlayMode = 0, // Gate | Trigger toggle
|
||||||
|
kPitchEngine, // Varispeed | Preserve toggle
|
||||||
|
kAttack, // AHDSR attack (Gate) / —
|
||||||
|
kHold, // AHDSR hold (Gate)
|
||||||
|
kDecay, // AHDSR decay (Gate)
|
||||||
|
kSustain, // AHDSR sustain (Gate)
|
||||||
|
kRelease, // AHDSR release (Gate)
|
||||||
|
kTrigLength, // Trigger %-length
|
||||||
|
kTrigFadeIn, // Trigger fade-in
|
||||||
|
kTrigFadeOut, // Trigger fade-out
|
||||||
|
kPitchEnvEnable, // AD pitch envelope on|off
|
||||||
|
kPitchEnvAttack, // AD pitch attack
|
||||||
|
kPitchEnvDecay, // AD pitch decay
|
||||||
|
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
||||||
|
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
|
||||||
|
// Filter. The four control positions map through filter_params' own laws; the three
|
||||||
|
// depths are bipolar and centred at zero.
|
||||||
|
kFilterEnable, // filter on|off caption toggle
|
||||||
|
kFilterMorph, // morph position: high-pass .. low-pass
|
||||||
|
kFilterCutoff, // cutoff, log across the audio band
|
||||||
|
kFilterQ, // resonance
|
||||||
|
kFilterDrive, // in-loop drive depth
|
||||||
|
kFilterModAmt, // filter envelope -> cutoff, +/-100%
|
||||||
|
kFilterVel, // velocity -> cutoff, +/-100%
|
||||||
|
kFilterKeyTrack, // note -> cutoff, 0..200%
|
||||||
|
kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP
|
||||||
|
kFilterEnvAttack,
|
||||||
|
kFilterEnvHold,
|
||||||
|
kFilterEnvDecay,
|
||||||
|
kFilterEnvSustain,
|
||||||
|
kFilterEnvRelease,
|
||||||
|
// Deck-only controls: processor-side per-instance params — routed to the processor
|
||||||
|
// setters, never through the parameter set.
|
||||||
|
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||||
|
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
|
||||||
|
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
|
||||||
|
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
|
||||||
|
kCount
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deck group ids. Unscoped so the shell's caption switch reads against the plain `id` int
|
||||||
|
// knob_deck carries.
|
||||||
|
enum DeckGroupId {
|
||||||
|
kGroupPitch = 0,
|
||||||
|
kGroupPitchEnv,
|
||||||
|
kGroupFilter,
|
||||||
|
kGroupFilterEnv,
|
||||||
|
kGroupAmpEnv,
|
||||||
|
kGroupVoice,
|
||||||
|
kGroupMaster,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
|
||||||
|
// two instance-wide groups. `playMode` picks the AMP group's face; its width is
|
||||||
|
// mode-independent (Trigger leaves two blank cells) so a mode flip never reflows the
|
||||||
|
// neighbouring groups.
|
||||||
|
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
|
||||||
|
|
||||||
|
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
|
||||||
|
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
|
||||||
|
// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
|
||||||
|
double deckBipolarFromNorm(double norm);
|
||||||
|
double deckNormFromBipolar(double value);
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
@@ -11,7 +11,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
|
|||||||
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
||||||
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
||||||
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
||||||
`curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
`deck_groups`, `curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
||||||
`core/wire` and is documented there — this directory consumes it but does not own it.
|
`core/wire` and is documented there — this directory consumes it but does not own it.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
@@ -90,7 +90,7 @@ scattered `#ifdef`s in the VST shell, except the one described below).
|
|||||||
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
||||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, deck group ids, and the velocity-curve box derivation — the helpers more than one band TU needs. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
||||||
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
|||||||
capture_browser keyboard_strip sample_bands sample_chrome
|
capture_browser keyboard_strip sample_bands sample_chrome
|
||||||
waveform_view bank_sync browser_scroll param_slider tooltip
|
waveform_view bank_sync browser_scroll param_slider tooltip
|
||||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
||||||
knob_deck curve_popup master_gain sample_usage file_bytes)
|
knob_deck deck_groups curve_popup master_gain sample_usage file_bytes)
|
||||||
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
|
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
|
||||||
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
|
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
|
||||||
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
|
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
|
||||||
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
||||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
|
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
|
||||||
|
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
|
||||||
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
|
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
|
||||||
#include "core/util/clamp01.h"
|
#include "core/util/clamp01.h"
|
||||||
#include "shell/instrument/editor_internal.h" // DeckGroup ids
|
#include "shell/instrument/editor_internal.h"
|
||||||
#include "shell/instrument/reasampler_processor.h"
|
#include "shell/instrument/reasampler_processor.h"
|
||||||
|
|
||||||
namespace reasampler::vst {
|
namespace reasampler::vst {
|
||||||
@@ -28,9 +30,16 @@ using instrument::ui::chromeRects;
|
|||||||
using instrument::ui::deckHeight;
|
using instrument::ui::deckHeight;
|
||||||
using instrument::ui::kDeckKnobSize;
|
using instrument::ui::kDeckKnobSize;
|
||||||
using instrument::ui::kPad;
|
using instrument::ui::kPad;
|
||||||
|
using instrument::ui::deckBipolarFromNorm;
|
||||||
|
using instrument::ui::deckNormFromBipolar;
|
||||||
|
using instrument::ui::sampleDeckGroups;
|
||||||
using instrument::engine::formatMasterGainLabel;
|
using instrument::engine::formatMasterGainLabel;
|
||||||
using instrument::engine::masterGainLinearFromNorm;
|
using instrument::engine::masterGainLinearFromNorm;
|
||||||
using instrument::engine::masterGainNormFromLinear;
|
using instrument::engine::masterGainNormFromLinear;
|
||||||
|
using instrument::engine::filter::MorphLaw;
|
||||||
|
using instrument::engine::filter::filterCutoffHzFromNorm;
|
||||||
|
using instrument::engine::filter::filterDriveDepthFromNorm;
|
||||||
|
using instrument::engine::filter::filterQFromNorm;
|
||||||
using util::clamp01;
|
using util::clamp01;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -53,7 +62,7 @@ ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
|
|||||||
// chrome interior, and the deck descriptors can never be derived three different ways.
|
// chrome interior, and the deck descriptors can never be derived three different ways.
|
||||||
// The deck's own wrapped height is the only interior measurement the allocator needs.
|
// The deck's own wrapped height is the only interior measurement the allocator needs.
|
||||||
FaceLayout fl;
|
FaceLayout fl;
|
||||||
fl.deckDescs = deckGroupDescs(params_.play);
|
fl.deckDescs = sampleDeckGroups(params_.play.playMode);
|
||||||
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad));
|
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad));
|
||||||
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
|
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
|
||||||
return fl;
|
return fl;
|
||||||
@@ -87,6 +96,23 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
|
|||||||
case ParamControl::kPitchEnvDepth:
|
case ParamControl::kPitchEnvDepth:
|
||||||
// Signed depth centered at 0.5 (0.5 == 0 semitones).
|
// Signed depth centered at 0.5 (0.5 == 0 semitones).
|
||||||
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
||||||
|
// Filter. The four tone controls ARE the module's normalized positions — stored and
|
||||||
|
// shown as-is, so the knob travel is exactly filter_params' own law.
|
||||||
|
case ParamControl::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
|
||||||
|
case ParamControl::kFilterLaw:
|
||||||
|
return play.filter.settings.morphLaw == MorphLaw::HighNotchLow ? 1.0 : 0.0;
|
||||||
|
case ParamControl::kFilterMorph: return clamp01(play.filter.settings.morphNorm);
|
||||||
|
case ParamControl::kFilterCutoff: return clamp01(play.filter.settings.cutoffNorm);
|
||||||
|
case ParamControl::kFilterQ: return clamp01(play.filter.settings.resonanceNorm);
|
||||||
|
case ParamControl::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
|
||||||
|
case ParamControl::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
||||||
|
case ParamControl::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
|
||||||
|
case ParamControl::kFilterKeyTrack:return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
||||||
|
case ParamControl::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
|
||||||
|
case ParamControl::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
|
||||||
|
case ParamControl::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
|
||||||
|
case ParamControl::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
|
||||||
|
case ParamControl::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
|
||||||
default: return 0.0;
|
default: return 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +152,33 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
|||||||
case ParamControl::kPitchEnvDepth:
|
case ParamControl::kPitchEnvDepth:
|
||||||
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
||||||
break;
|
break;
|
||||||
|
case ParamControl::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
||||||
|
case ParamControl::kFilterLaw:
|
||||||
|
play.filter.settings.morphLaw =
|
||||||
|
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
|
||||||
|
break;
|
||||||
|
case ParamControl::kFilterMorph:
|
||||||
|
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
|
||||||
|
case ParamControl::kFilterCutoff:
|
||||||
|
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
|
||||||
|
case ParamControl::kFilterQ:
|
||||||
|
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
|
||||||
|
case ParamControl::kFilterDrive:
|
||||||
|
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
|
||||||
|
case ParamControl::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
|
||||||
|
case ParamControl::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
|
||||||
|
case ParamControl::kFilterKeyTrack:
|
||||||
|
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
||||||
|
case ParamControl::kFilterEnvAttack:
|
||||||
|
play.filter.env.attackSeconds = normToSec(value); break;
|
||||||
|
case ParamControl::kFilterEnvHold:
|
||||||
|
play.filter.env.holdSeconds = normToSec(value); break;
|
||||||
|
case ParamControl::kFilterEnvDecay:
|
||||||
|
play.filter.env.decaySeconds = normToSec(value); break;
|
||||||
|
case ParamControl::kFilterEnvSustain:
|
||||||
|
play.filter.env.sustainLevel = clamp01(value); break;
|
||||||
|
case ParamControl::kFilterEnvRelease:
|
||||||
|
play.filter.env.releaseSeconds = normToSec(value); break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,68 +204,6 @@ double ReaSamplerEditor::previewVelocity01() const {
|
|||||||
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const PlaySeconds& play) const {
|
|
||||||
// The deck band's groups, left to right. Group widths are mode-independent: AMP ENVELOPE
|
|
||||||
// reserves its 5-cell Gate width (Trigger leaves two blank cells), so a Gate<->Trigger
|
|
||||||
// flip repopulates in place and never reflows the neighbouring groups.
|
|
||||||
std::vector<DeckGroupDesc> out;
|
|
||||||
{
|
|
||||||
DeckGroupDesc amp;
|
|
||||||
amp.id = kGroupAmpEnv;
|
|
||||||
amp.captionWidth = 78;
|
|
||||||
amp.captionToggle = {static_cast<int>(ParamControl::kPlayMode), 44};
|
|
||||||
if (play.playMode == PlayMode::Gate) {
|
|
||||||
amp.cellIds = {static_cast<int>(ParamControl::kAttack),
|
|
||||||
static_cast<int>(ParamControl::kHold),
|
|
||||||
static_cast<int>(ParamControl::kDecay),
|
|
||||||
static_cast<int>(ParamControl::kSustain),
|
|
||||||
static_cast<int>(ParamControl::kRelease)};
|
|
||||||
} else {
|
|
||||||
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
|
|
||||||
// the drawn envelope), plus the two reserved blanks.
|
|
||||||
amp.cellIds = {static_cast<int>(ParamControl::kTrigFadeIn),
|
|
||||||
static_cast<int>(ParamControl::kTrigLength),
|
|
||||||
static_cast<int>(ParamControl::kTrigFadeOut), -1, -1};
|
|
||||||
}
|
|
||||||
out.push_back(std::move(amp));
|
|
||||||
}
|
|
||||||
{
|
|
||||||
DeckGroupDesc pitch;
|
|
||||||
pitch.id = kGroupPitch;
|
|
||||||
pitch.captionWidth = 38;
|
|
||||||
pitch.captionToggle = {static_cast<int>(ParamControl::kPitchEngine), 48};
|
|
||||||
pitch.cellIds = {static_cast<int>(ParamControl::kKeyTrack)};
|
|
||||||
out.push_back(std::move(pitch));
|
|
||||||
}
|
|
||||||
{
|
|
||||||
DeckGroupDesc penv;
|
|
||||||
penv.id = kGroupPitchEnv;
|
|
||||||
penv.captionWidth = 58;
|
|
||||||
penv.captionToggle = {static_cast<int>(ParamControl::kPitchEnvEnable), 32};
|
|
||||||
penv.cellIds = {static_cast<int>(ParamControl::kPitchEnvAttack),
|
|
||||||
static_cast<int>(ParamControl::kPitchEnvDecay),
|
|
||||||
static_cast<int>(ParamControl::kPitchEnvDepth)};
|
|
||||||
out.push_back(std::move(penv));
|
|
||||||
}
|
|
||||||
{
|
|
||||||
DeckGroupDesc voice;
|
|
||||||
voice.id = kGroupVoice;
|
|
||||||
voice.captionWidth = 38;
|
|
||||||
voice.captionToggle = {static_cast<int>(ParamControl::kVoiceMode), 40};
|
|
||||||
voice.cellIds = {static_cast<int>(ParamControl::kVoiceCount)};
|
|
||||||
voice.rowToggle = {static_cast<int>(ParamControl::kMonoTrigger), 44};
|
|
||||||
out.push_back(std::move(voice));
|
|
||||||
}
|
|
||||||
{
|
|
||||||
DeckGroupDesc master;
|
|
||||||
master.id = kGroupMaster;
|
|
||||||
master.captionWidth = 46;
|
|
||||||
master.cellIds = {static_cast<int>(ParamControl::kMasterGain)};
|
|
||||||
out.push_back(std::move(master));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
double ReaSamplerEditor::deckControlNorm(int id) const {
|
double ReaSamplerEditor::deckControlNorm(int id) const {
|
||||||
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
|
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
|
||||||
switch (static_cast<ParamControl>(id)) {
|
switch (static_cast<ParamControl>(id)) {
|
||||||
@@ -294,6 +285,43 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
|||||||
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
|
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
|
||||||
case ParamControl::kMasterGain:
|
case ParamControl::kMasterGain:
|
||||||
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
|
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
|
||||||
|
// Filter readouts run the stored normalized positions back through the module's OWN
|
||||||
|
// laws, so what the label says is what the kernel is solved for.
|
||||||
|
case ParamControl::kFilterMorph: {
|
||||||
|
const double m = play.filter.settings.morphNorm;
|
||||||
|
snprintf(buf, sizeof(buf), "%.0f%%", m * 100.0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ParamControl::kFilterCutoff: {
|
||||||
|
const float hz = filterCutoffHzFromNorm(play.filter.settings.cutoffNorm);
|
||||||
|
if (hz >= 1000.0f) snprintf(buf, sizeof(buf), "%.2fk", hz / 1000.0f);
|
||||||
|
else snprintf(buf, sizeof(buf), "%.0fHz", hz);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ParamControl::kFilterQ:
|
||||||
|
snprintf(buf, sizeof(buf), "%.2f",
|
||||||
|
static_cast<double>(filterQFromNorm(play.filter.settings.resonanceNorm)));
|
||||||
|
break;
|
||||||
|
case ParamControl::kFilterDrive:
|
||||||
|
snprintf(buf, sizeof(buf), "%.2f",
|
||||||
|
static_cast<double>(filterDriveDepthFromNorm(play.filter.settings.driveNorm)));
|
||||||
|
break;
|
||||||
|
case ParamControl::kFilterModAmt:
|
||||||
|
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break;
|
||||||
|
case ParamControl::kFilterVel:
|
||||||
|
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break;
|
||||||
|
case ParamControl::kFilterKeyTrack:
|
||||||
|
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break;
|
||||||
|
case ParamControl::kFilterEnvAttack:
|
||||||
|
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.attackSeconds); break;
|
||||||
|
case ParamControl::kFilterEnvHold:
|
||||||
|
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.holdSeconds); break;
|
||||||
|
case ParamControl::kFilterEnvDecay:
|
||||||
|
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.decaySeconds); break;
|
||||||
|
case ParamControl::kFilterEnvSustain:
|
||||||
|
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break;
|
||||||
|
case ParamControl::kFilterEnvRelease:
|
||||||
|
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.releaseSeconds); break;
|
||||||
default:
|
default:
|
||||||
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
|
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -16,6 +16,30 @@ namespace reasampler::vst {
|
|||||||
using namespace reasampler::ui;
|
using namespace reasampler::ui;
|
||||||
using namespace reasampler::instrument::ui;
|
using namespace reasampler::instrument::ui;
|
||||||
|
|
||||||
|
bool ReaSamplerEditor::deckKnobDisabled(int id) const {
|
||||||
|
switch (static_cast<ParamControl>(id)) {
|
||||||
|
case ParamControl::kPitchEnvAttack:
|
||||||
|
case ParamControl::kPitchEnvDecay:
|
||||||
|
case ParamControl::kPitchEnvDepth:
|
||||||
|
return !params_.play.pitchEnv.enabled;
|
||||||
|
case ParamControl::kFilterMorph:
|
||||||
|
case ParamControl::kFilterCutoff:
|
||||||
|
case ParamControl::kFilterQ:
|
||||||
|
case ParamControl::kFilterDrive:
|
||||||
|
case ParamControl::kFilterModAmt:
|
||||||
|
case ParamControl::kFilterVel:
|
||||||
|
case ParamControl::kFilterKeyTrack:
|
||||||
|
case ParamControl::kFilterEnvAttack:
|
||||||
|
case ParamControl::kFilterEnvHold:
|
||||||
|
case ParamControl::kFilterEnvDecay:
|
||||||
|
case ParamControl::kFilterEnvSustain:
|
||||||
|
case ParamControl::kFilterEnvRelease:
|
||||||
|
return !params_.play.filter.enabled;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||||
const Rect& band = fl.bands.decks;
|
const Rect& band = fl.bands.decks;
|
||||||
if (!contains(band, x, y)) return false;
|
if (!contains(band, x, y)) return false;
|
||||||
@@ -46,8 +70,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
|||||||
invalidate();
|
invalidate();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case ParamControl::kFilterLaw:
|
||||||
|
// Inert while the filter is off, matching its Disabled paint.
|
||||||
|
if (!params_.play.filter.enabled) break;
|
||||||
|
applyParamControl(hit.id, 0.0, hit.segment);
|
||||||
|
commitAndReload();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Parameter-set toggles (play mode / pitch engine / pitch-env enable).
|
// Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable).
|
||||||
applyParamControl(hit.id, 0.0, hit.segment);
|
applyParamControl(hit.id, 0.0, hit.segment);
|
||||||
commitAndReload();
|
commitAndReload();
|
||||||
break;
|
break;
|
||||||
@@ -55,12 +85,8 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (hit.kind == DeckHitKind::Knob) {
|
if (hit.kind == DeckHitKind::Knob) {
|
||||||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off.
|
// Knobs of a disabled group are drawn but inert.
|
||||||
const bool pitchEnvKnob =
|
if (deckKnobDisabled(hit.id)) return true;
|
||||||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
|
||||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
|
||||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
|
||||||
if (pitchEnvKnob && !params_.play.pitchEnv.enabled) return true;
|
|
||||||
drag_ = DragKind::kDeckKnob;
|
drag_ = DragKind::kDeckKnob;
|
||||||
dragParamId_ = hit.id;
|
dragParamId_ = hit.id;
|
||||||
dragKnobStartValue_ = deckControlNorm(hit.id);
|
dragKnobStartValue_ = deckControlNorm(hit.id);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// editor_internal.h — shared helpers for the ReaSamplerEditor TU family. Included ONLY by
|
// editor_internal.h — shared helpers for the ReaSamplerEditor TU family. Included ONLY by
|
||||||
// the editor's own shell TUs (editor_session / editor_controls / editor_paint_* /
|
// the editor's own shell TUs (editor_session / editor_controls / editor_paint_* /
|
||||||
// editor_input_* / editor_platform) — never a public seam. Holds the Rect<->kit adapters,
|
// editor_input_* / editor_platform) — never a public seam. Holds the Rect<->kit adapters,
|
||||||
// small draw primitives (knob face / title band), label helpers, deck group ids, and the
|
// small draw primitives (knob face / title band), label helpers, and the velocity-curve box
|
||||||
// velocity-curve box derivation. All inline.
|
// derivation. All inline.
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -30,15 +30,6 @@
|
|||||||
|
|
||||||
namespace reasampler::vst {
|
namespace reasampler::vst {
|
||||||
|
|
||||||
// Deck group ids (shell-owned; knob_deck treats them opaquely), left-to-right order.
|
|
||||||
enum DeckGroup {
|
|
||||||
kGroupAmpEnv = 0,
|
|
||||||
kGroupPitch,
|
|
||||||
kGroupPitchEnv,
|
|
||||||
kGroupVoice,
|
|
||||||
kGroupMaster,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Velocity-curve editor box metrics. The inset keeps node handles + the pick radius
|
// Velocity-curve editor box metrics. The inset keeps node handles + the pick radius
|
||||||
// inside the border so an endpoint at amp 0/1 stays grabbable; drag-off beyond
|
// inside the border so an endpoint at amp 0/1 stays grabbable; drag-off beyond
|
||||||
// box+margin deletes the dragged node.
|
// box+margin deletes the dragged node.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// editor_paint_deck.cpp — the DECKS band's painter: the fenced control groups (AMP
|
// editor_paint_deck.cpp — the DECKS band's painter: the fenced control groups, their
|
||||||
// ENVELOPE / PITCH / PITCH ENV / VOICE / MASTER), their captions, the compact caption and
|
// captions, the compact caption and row toggles, and the radial knobs with the label<->value
|
||||||
// row toggles, and the radial knobs with the label<->value swap on hover/drag.
|
// swap on hover/drag. Windows-only; the deck's cell geometry is the pure knob_deck layout and
|
||||||
// Windows-only; the deck's cell geometry is the pure knob_deck layout.
|
// its group composition the pure deck_groups list.
|
||||||
|
|
||||||
#include "shell/instrument/reasampler_editor.h"
|
#include "shell/instrument/reasampler_editor.h"
|
||||||
|
|
||||||
@@ -10,8 +10,9 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state)
|
||||||
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
|
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
|
||||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face + DeckGroup ids
|
#include "shell/instrument/editor_internal.h" // kit adapters + knob face
|
||||||
#include "shell/instrument/reasampler_processor.h"
|
#include "shell/instrument/reasampler_processor.h"
|
||||||
|
|
||||||
namespace reasampler::vst {
|
namespace reasampler::vst {
|
||||||
@@ -68,6 +69,18 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
|||||||
case ParamControl::kPitchEnvDepth: return "P.Depth";
|
case ParamControl::kPitchEnvDepth: return "P.Depth";
|
||||||
case ParamControl::kVoiceCount: return "Voices";
|
case ParamControl::kVoiceCount: return "Voices";
|
||||||
case ParamControl::kMasterGain: return "Gain";
|
case ParamControl::kMasterGain: return "Gain";
|
||||||
|
case ParamControl::kFilterMorph: return "Mode";
|
||||||
|
case ParamControl::kFilterCutoff: return "Cutoff";
|
||||||
|
case ParamControl::kFilterQ: return "Res";
|
||||||
|
case ParamControl::kFilterDrive: return "Drive";
|
||||||
|
case ParamControl::kFilterModAmt: return "Mod";
|
||||||
|
case ParamControl::kFilterVel: return "Vel";
|
||||||
|
case ParamControl::kFilterKeyTrack: return "Key Trk";
|
||||||
|
case ParamControl::kFilterEnvAttack: return "F.Att";
|
||||||
|
case ParamControl::kFilterEnvHold: return "F.Hold";
|
||||||
|
case ParamControl::kFilterEnvDecay: return "F.Dec";
|
||||||
|
case ParamControl::kFilterEnvSustain: return "F.Sus";
|
||||||
|
case ParamControl::kFilterEnvRelease: return "F.Rel";
|
||||||
default: return "";
|
default: return "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -82,6 +95,8 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
|||||||
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
|
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
|
||||||
case kGroupPitch: caption = "PITCH"; break;
|
case kGroupPitch: caption = "PITCH"; break;
|
||||||
case kGroupPitchEnv: caption = "PITCH ENV"; break;
|
case kGroupPitchEnv: caption = "PITCH ENV"; break;
|
||||||
|
case kGroupFilter: caption = "FILTER"; break;
|
||||||
|
case kGroupFilterEnv: caption = "FILTER ENV"; break;
|
||||||
case kGroupVoice: caption = "VOICE"; break;
|
case kGroupVoice: caption = "VOICE"; break;
|
||||||
case kGroupMaster: caption = "MASTER"; break;
|
case kGroupMaster: caption = "MASTER"; break;
|
||||||
default: break;
|
default: break;
|
||||||
@@ -105,20 +120,30 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
|||||||
case ParamControl::kVoiceMode:
|
case ParamControl::kVoiceMode:
|
||||||
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
|
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
|
||||||
break;
|
break;
|
||||||
|
case ParamControl::kFilterEnable:
|
||||||
|
drawToggle(g.captionToggle, "Off", "On", play.filter.enabled, false);
|
||||||
|
break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The row toggle (VOICE group's Retrig|Legato) — live only in Mono.
|
// Row toggles: VOICE's Retrig|Legato (live only in Mono) and FILTER's morph law.
|
||||||
if (g.rowToggle.id >= 0) {
|
if (g.rowToggle.id >= 0) {
|
||||||
|
if (static_cast<ParamControl>(g.rowToggle.id) == ParamControl::kFilterLaw) {
|
||||||
|
drawToggle(g.rowToggle, "Band", "Notch",
|
||||||
|
play.filter.settings.morphLaw ==
|
||||||
|
instrument::engine::filter::MorphLaw::HighNotchLow,
|
||||||
|
!play.filter.enabled);
|
||||||
|
} else {
|
||||||
drawToggle(g.rowToggle, "Retrig", "Legato",
|
drawToggle(g.rowToggle, "Retrig", "Legato",
|
||||||
monoTrigger_ == MonoTrigger::Legato, !isMono);
|
monoTrigger_ == MonoTrigger::Legato, !isMono);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
|
// The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry.
|
||||||
// stable geometry.
|
// The predicate is the input side's, so the drawn state and the inert grab agree.
|
||||||
for (const DeckCellLayout& c : g.cells) {
|
for (const DeckCellLayout& c : g.cells) {
|
||||||
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
|
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
|
||||||
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
|
const bool disabled = deckKnobDisabled(c.id);
|
||||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
|
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
|
||||||
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
|
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
|
||||||
const InteractionState st =
|
const InteractionState st =
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
#include "public.sdk/source/common/pluginview.h"
|
#include "public.sdk/source/common/pluginview.h"
|
||||||
|
|
||||||
|
#include "core/instrument/ui/deck_groups.h" // DeckParam / DeckGroupId / sampleDeckGroups
|
||||||
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type)
|
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type)
|
||||||
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
|
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
|
||||||
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam)
|
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam)
|
||||||
@@ -82,31 +83,9 @@ private:
|
|||||||
|
|
||||||
// Controls on the setup surface. The int value is the opaque control id the pure
|
// Controls on the setup surface. The int value is the opaque control id the pure
|
||||||
// knob_deck hit-test returns; the shell maps it to the one parameter set or a
|
// knob_deck hit-test returns; the shell maps it to the one parameter set or a
|
||||||
// processor-side per-instance setter.
|
// processor-side per-instance setter. The id space and the deck's group composition are
|
||||||
enum class ParamControl {
|
// the pure deck_groups module's — this alias keeps the shell's spelling.
|
||||||
kPlayMode = 0, // Gate | Trigger toggle
|
using ParamControl = instrument::ui::DeckParam;
|
||||||
kPitchEngine, // Varispeed | Preserve toggle
|
|
||||||
kAttack, // AHDSR attack (Gate) / —
|
|
||||||
kHold, // AHDSR hold (Gate)
|
|
||||||
kDecay, // AHDSR decay (Gate)
|
|
||||||
kSustain, // AHDSR sustain (Gate)
|
|
||||||
kRelease, // AHDSR release (Gate)
|
|
||||||
kTrigLength, // Trigger %-length
|
|
||||||
kTrigFadeIn, // Trigger fade-in
|
|
||||||
kTrigFadeOut, // Trigger fade-out
|
|
||||||
kPitchEnvEnable, // AD pitch envelope on|off
|
|
||||||
kPitchEnvAttack, // AD pitch attack
|
|
||||||
kPitchEnvDecay, // AD pitch decay
|
|
||||||
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
|
||||||
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
|
|
||||||
// Deck-only controls: processor-side per-instance params — routed to the processor
|
|
||||||
// setters, never through applyParamControl.
|
|
||||||
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
|
||||||
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
|
|
||||||
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
|
|
||||||
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
|
|
||||||
kCount
|
|
||||||
};
|
|
||||||
|
|
||||||
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
|
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
|
||||||
// in draw + hit order.
|
// in draw + hit order.
|
||||||
@@ -188,6 +167,10 @@ private:
|
|||||||
bool mouseDownDeck(const FaceLayout& fl, int x, int y);
|
bool mouseDownDeck(const FaceLayout& fl, int x, int y);
|
||||||
void mouseDownBrowse(int w, int h, int x, int y);
|
void mouseDownBrowse(int w, int h, int x, int y);
|
||||||
|
|
||||||
|
// Whether deck knob `id` belongs to a group whose enable toggle is off. The ONE predicate
|
||||||
|
// behind both the Disabled paint and the inert grab, so they cannot disagree.
|
||||||
|
bool deckKnobDisabled(int id) const;
|
||||||
|
|
||||||
// Live drag resolution, split on the same axis; each handles only its own DragKind
|
// Live drag resolution, split on the same axis; each handles only its own DragKind
|
||||||
// values and is called from onMouseMove's router.
|
// values and is called from onMouseMove's router.
|
||||||
void dragChrome(const FaceLayout& fl, int x, int y); // kRootMarker
|
void dragChrome(const FaceLayout& fl, int x, int y); // kRootMarker
|
||||||
@@ -349,12 +332,6 @@ private:
|
|||||||
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
||||||
double previewVelocity01() const;
|
double previewVelocity01() const;
|
||||||
|
|
||||||
// The deck groups: AMP ENVELOPE (Gate A/H/D/S/R; Trigger Fade In/Length %/Fade Out + two
|
|
||||||
// reserved blanks so a mode flip never reflows neighbours) / PITCH (Key Track) / PITCH
|
|
||||||
// ENV (P.Attack/P.Decay/P.Depth) / VOICE (Voices knob + Poly|Mono + Retrig|Legato) /
|
|
||||||
// MASTER (Gain knob).
|
|
||||||
std::vector<DeckGroupDesc> deckGroupDescs(const PlaySeconds& play) const;
|
|
||||||
|
|
||||||
// The normalized [0,1] value a deck knob shows — parameter-set ids route through
|
// The normalized [0,1] value a deck knob shows — parameter-set ids route through
|
||||||
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
|
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
|
||||||
// via the -2 sentinel) read the processor's live value.
|
// via the -2 sentinel) read the processor's live value.
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ struct Zone {
|
|||||||
std::vector<VelocityPoint> curve; // empty -> the flat endpoints
|
std::vector<VelocityPoint> curve; // empty -> the flat endpoints
|
||||||
};
|
};
|
||||||
|
|
||||||
static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
|
// Everything after a zone's id and key range — which is EXACTLY the whole v8 single record,
|
||||||
strv(out, z.sampleId);
|
// so the two shapes are written from one place here just as the codec writes them from one
|
||||||
u32v(out, static_cast<std::uint32_t>(z.lowNote));
|
// place (putOverrides + the shared play tail).
|
||||||
u32v(out, static_cast<std::uint32_t>(z.highNote));
|
static void putRecordBody(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
|
||||||
u8v(out, z.rootOverride >= 0 ? 1 : 0);
|
u8v(out, z.rootOverride >= 0 ? 1 : 0);
|
||||||
if (z.rootOverride >= 0) u32v(out, static_cast<std::uint32_t>(z.rootOverride));
|
if (z.rootOverride >= 0) u32v(out, static_cast<std::uint32_t>(z.rootOverride));
|
||||||
if (pv >= 2) {
|
if (pv >= 2) {
|
||||||
@@ -133,6 +133,13 @@ static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
|
||||||
|
strv(out, z.sampleId);
|
||||||
|
u32v(out, static_cast<std::uint32_t>(z.lowNote));
|
||||||
|
u32v(out, static_cast<std::uint32_t>(z.highNote));
|
||||||
|
putRecordBody(out, z, pv);
|
||||||
|
}
|
||||||
|
|
||||||
// The envelope fields, in wire order. A builder at version N emits only the prefix fields
|
// The envelope fields, in wire order. A builder at version N emits only the prefix fields
|
||||||
// version N carried, so each lift can be asserted against a blob shaped exactly as that
|
// version N carried, so each lift can be asserted against a blob shaped exactly as that
|
||||||
// version's writer produced.
|
// version's writer produced.
|
||||||
@@ -178,6 +185,33 @@ static std::vector<std::uint8_t> envelopeWithZones(const Envelope& env,
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The CURRENT envelope carrying a payload-v8 SINGLE RECORD — the shape immediately before the
|
||||||
|
// filter tail. The shipping writer only emits v9, so a v8 blob can come from nowhere but
|
||||||
|
// bytes laid out here, which is what makes the off/neutral filter lift provable rather than
|
||||||
|
// assumed.
|
||||||
|
static std::vector<std::uint8_t> envelopeWithV8Record(const std::string& selectionId,
|
||||||
|
const Zone& record) {
|
||||||
|
Envelope env;
|
||||||
|
env.selectionId = selectionId;
|
||||||
|
std::vector<std::uint8_t> out;
|
||||||
|
u32v(out, env.version);
|
||||||
|
u8v(out, env.modeByte);
|
||||||
|
i64v(out, env.assignGeneration);
|
||||||
|
u8v(out, env.previewVelocity);
|
||||||
|
u8v(out, env.voiceCount);
|
||||||
|
u8v(out, env.voiceMode);
|
||||||
|
u8v(out, env.monoTrigger);
|
||||||
|
f64v(out, env.masterGain);
|
||||||
|
u8v(out, env.channelModeExplicit);
|
||||||
|
u32v(out, 0); // sample-refs: empty table
|
||||||
|
strv(out, env.instanceGuid);
|
||||||
|
strv(out, env.selectionId);
|
||||||
|
u32v(out, kParamsFormatMarker);
|
||||||
|
u32v(out, 8);
|
||||||
|
putRecordBody(out, record, 7); // the v7 zone tail IS the v8 single record's body
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Shorthand for the common case: the CURRENT envelope version carrying a zone payload.
|
// Shorthand for the common case: the CURRENT envelope version carrying a zone payload.
|
||||||
static std::vector<std::uint8_t> envelopeWithZones(const std::string& selectionId,
|
static std::vector<std::uint8_t> envelopeWithZones(const std::string& selectionId,
|
||||||
const std::vector<Zone>& zones,
|
const std::vector<Zone>& zones,
|
||||||
@@ -388,7 +422,7 @@ static void testGoldenFullBlobFixture() {
|
|||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
|
||||||
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
|
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
|
||||||
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
|
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
|
||||||
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x08,0x00,0x00,
|
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x09,0x00,0x00,
|
||||||
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
|
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
|
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
|
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
@@ -401,13 +435,39 @@ static void testGoldenFullBlobFixture() {
|
|||||||
0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40,
|
0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40,
|
||||||
0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
|
0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
|
||||||
|
// --- payload v9 filter tail, at its OFF/NEUTRAL default (this fixture sets no
|
||||||
|
// filter field), in the header's documented order ---
|
||||||
|
0x00, // enabled = false
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // cutoffNorm 1.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // resonanceNorm 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // morphNorm 1.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // driveNorm 0.0
|
||||||
|
0x00, // morphLaw = HighBandLow
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // modAmount 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velAmount 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // keyTrack 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env attack 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env hold 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env decay 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // env sustain 1.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env release 0.0
|
||||||
|
0x02,0x00,0x00,0x00, // filter curve: 2 points (linear)
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp 1.0
|
||||||
};
|
};
|
||||||
// clang-format on
|
// clang-format on
|
||||||
CHECK(bytes.size() == sizeof(kGolden));
|
CHECK(bytes.size() == sizeof(kGolden));
|
||||||
if (bytes.size() == sizeof(kGolden)) {
|
if (bytes.size() == sizeof(kGolden)) {
|
||||||
bool same = true;
|
bool same = true;
|
||||||
for (std::size_t i = 0; i < bytes.size(); ++i) {
|
for (std::size_t i = 0; i < bytes.size(); ++i) {
|
||||||
if (bytes[i] != kGolden[i]) { same = false; break; }
|
if (bytes[i] != kGolden[i]) {
|
||||||
|
std::printf(" golden byte %zu: got 0x%02x, want 0x%02x\n", i,
|
||||||
|
bytes[i], kGolden[i]);
|
||||||
|
same = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
CHECK(same);
|
CHECK(same);
|
||||||
}
|
}
|
||||||
@@ -443,8 +503,109 @@ static void testEnvelopePrefixBytesFrozen() {
|
|||||||
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
||||||
}
|
}
|
||||||
CHECK(kComponentStateVersion == 11);
|
CHECK(kComponentStateVersion == 11);
|
||||||
CHECK(kParamsPayloadVersion == 8);
|
CHECK(kParamsPayloadVersion == 9);
|
||||||
|
CHECK(kParamsSingleRecordVersion == 8);
|
||||||
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
||||||
|
// The filter tail rode a PAYLOAD bump, not an envelope one — the two axes stay
|
||||||
|
// independent, so a future envelope field cannot collide with it on one number.
|
||||||
|
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The filter tail (payload v9) --------------------------------------------
|
||||||
|
|
||||||
|
// A v8 blob is a strict prefix of v9, so it must lift to the OFF/NEUTRAL filter — the reason
|
||||||
|
// a project saved before the filter existed reopens sounding identical. Everything the v8
|
||||||
|
// record did carry must survive alongside it.
|
||||||
|
static void testV8RecordLiftsToTheOffNeutralFilter() {
|
||||||
|
legacy::Zone rec;
|
||||||
|
rec.rootOverride = 48;
|
||||||
|
rec.startPoint = 512;
|
||||||
|
rec.holdSeconds = 0.25;
|
||||||
|
rec.attackSeconds = 0.011;
|
||||||
|
rec.releaseSeconds = 0.222;
|
||||||
|
rec.keyTrack = 0.75;
|
||||||
|
rec.preserve = true;
|
||||||
|
const ComponentState out =
|
||||||
|
deserializeComponentState(envelopeWithV8Record("kick", rec), 48000.0);
|
||||||
|
|
||||||
|
CHECK(out.selectionId == "kick");
|
||||||
|
CHECK(out.params.rootOverride && *out.params.rootOverride == 48);
|
||||||
|
CHECK(out.params.startPoint && *out.params.startPoint == 512);
|
||||||
|
CHECK(out.params.keyTrack == 0.75);
|
||||||
|
CHECK(out.params.play.adsr.holdSeconds == 0.25);
|
||||||
|
CHECK(out.params.play.adsr.releaseSeconds == 0.222);
|
||||||
|
CHECK(out.params.play.pitchEngine == PitchEngine::Preserve);
|
||||||
|
|
||||||
|
// The lift, field by field: nothing engaged, nothing modulating, a flat unity envelope.
|
||||||
|
const FilterSeconds& f = out.params.play.filter;
|
||||||
|
const FilterSeconds def;
|
||||||
|
CHECK(!f.enabled);
|
||||||
|
CHECK(f.settings.cutoffNorm == def.settings.cutoffNorm);
|
||||||
|
CHECK(f.settings.resonanceNorm == def.settings.resonanceNorm);
|
||||||
|
CHECK(f.settings.morphNorm == def.settings.morphNorm);
|
||||||
|
CHECK(f.settings.driveNorm == def.settings.driveNorm);
|
||||||
|
CHECK(f.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighBandLow);
|
||||||
|
CHECK(f.modAmount == 0.0);
|
||||||
|
CHECK(f.velAmount == 0.0);
|
||||||
|
CHECK(f.keyTrack == 0.0);
|
||||||
|
CHECK(f.env.sustainLevel == 1.0);
|
||||||
|
CHECK(f.env.attackSeconds == 0.0 && f.env.decaySeconds == 0.0 &&
|
||||||
|
f.env.releaseSeconds == 0.0 && f.env.holdSeconds == 0.0);
|
||||||
|
|
||||||
|
// Re-saving lifts it into the current format, and that blob is what the writer would have
|
||||||
|
// produced for the same state — so the lift is stable, not one-way lossy.
|
||||||
|
ComponentState resaved = out;
|
||||||
|
CHECK(serializeComponentState(resaved) ==
|
||||||
|
serializeComponentState(deserializeComponentState(
|
||||||
|
serializeComponentState(resaved), 48000.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The v9 tail round-trips losslessly, including the morph law's non-default leg and a filter
|
||||||
|
// velocity curve distinct from the amp's.
|
||||||
|
static void testFilterTailRoundTripsLosslessly() {
|
||||||
|
ComponentState in;
|
||||||
|
in.selectionId = "pad";
|
||||||
|
FilterSeconds& f = in.params.play.filter;
|
||||||
|
f.enabled = true;
|
||||||
|
f.settings.cutoffNorm = 0.375f;
|
||||||
|
f.settings.resonanceNorm = 0.8125f;
|
||||||
|
f.settings.morphNorm = 0.25f;
|
||||||
|
f.settings.driveNorm = 0.5f;
|
||||||
|
f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
|
||||||
|
f.modAmount = -0.625;
|
||||||
|
f.velAmount = 0.5;
|
||||||
|
f.keyTrack = 1.5;
|
||||||
|
f.env.attackSeconds = 0.031;
|
||||||
|
f.env.holdSeconds = 0.062;
|
||||||
|
f.env.decaySeconds = 0.125;
|
||||||
|
f.env.sustainLevel = 0.25;
|
||||||
|
f.env.releaseSeconds = 0.5;
|
||||||
|
f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
|
{VelocityPoint{0.0, 0.1}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}});
|
||||||
|
// The amp's own curve stays different, so a codec that read one into the other fails here.
|
||||||
|
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::flat();
|
||||||
|
|
||||||
|
const ComponentState out =
|
||||||
|
deserializeComponentState(serializeComponentState(in), 48000.0);
|
||||||
|
const FilterSeconds& g = out.params.play.filter;
|
||||||
|
CHECK(g.enabled);
|
||||||
|
CHECK(g.settings.cutoffNorm == f.settings.cutoffNorm);
|
||||||
|
CHECK(g.settings.resonanceNorm == f.settings.resonanceNorm);
|
||||||
|
CHECK(g.settings.morphNorm == f.settings.morphNorm);
|
||||||
|
CHECK(g.settings.driveNorm == f.settings.driveNorm);
|
||||||
|
CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
|
||||||
|
CHECK(g.modAmount == f.modAmount);
|
||||||
|
CHECK(g.velAmount == f.velAmount);
|
||||||
|
CHECK(g.keyTrack == f.keyTrack);
|
||||||
|
CHECK(g.env.attackSeconds == f.env.attackSeconds);
|
||||||
|
CHECK(g.env.holdSeconds == f.env.holdSeconds);
|
||||||
|
CHECK(g.env.decaySeconds == f.env.decaySeconds);
|
||||||
|
CHECK(g.env.sustainLevel == f.env.sustainLevel);
|
||||||
|
CHECK(g.env.releaseSeconds == f.env.releaseSeconds);
|
||||||
|
CHECK(g.velocityCurve.size() == 3);
|
||||||
|
CHECK(g.velocityCurve.equals(f.velocityCurve));
|
||||||
|
CHECK(out.params.velocityCurve.equals(
|
||||||
|
reasampler::instrument::engine::VelocityCurve::flat()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
|
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
|
||||||
@@ -643,7 +804,7 @@ static void testEveryOlderPayloadVersionMigrates() {
|
|||||||
(pv >= 5 ? 0.4 : AdsrSeconds{}.releaseSeconds));
|
(pv >= 5 ? 0.4 : AdsrSeconds{}.releaseSeconds));
|
||||||
}
|
}
|
||||||
// And the CURRENT version does NOT take the migration path: it reads its own record.
|
// And the CURRENT version does NOT take the migration path: it reads its own record.
|
||||||
CHECK(kParamsPayloadVersion == 8);
|
CHECK(kParamsPayloadVersion >= kParamsSingleRecordVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The LEGACY v3 payload's wall-clock frame counts convert to seconds at the READ boundary
|
// The LEGACY v3 payload's wall-clock frame counts convert to seconds at the READ boundary
|
||||||
@@ -814,11 +975,12 @@ static void testSampleRefsTruncatedMidEntry() {
|
|||||||
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
||||||
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
|
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
|
||||||
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
|
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
|
||||||
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default)) = 158 bytes;
|
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
|
||||||
// entry two is 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0).
|
// v9 filter tail) = 292 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4, loop
|
||||||
// Cutting 178 bytes keeps the first 27 of entry two's 47 — mid loop.start (offset 23..31).
|
// 1+8+8, channels4, name 4+0). Cutting 312 keeps the first 27 of entry two's 47 — mid
|
||||||
CHECK(bytes.size() > 178);
|
// loop.start (offset 23..31).
|
||||||
bytes.resize(bytes.size() - 178);
|
CHECK(bytes.size() > 312);
|
||||||
|
bytes.resize(bytes.size() - 312);
|
||||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||||
CHECK(back.sampleRefs.size() == 1);
|
CHECK(back.sampleRefs.size() == 1);
|
||||||
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
||||||
@@ -916,6 +1078,8 @@ int main() {
|
|||||||
testUnknownEnvelopeVersionIsEmpty();
|
testUnknownEnvelopeVersionIsEmpty();
|
||||||
testV1SelectionLift();
|
testV1SelectionLift();
|
||||||
testTruncationDegradesCleanly();
|
testTruncationDegradesCleanly();
|
||||||
|
testV8RecordLiftsToTheOffNeutralFilter();
|
||||||
|
testFilterTailRoundTripsLosslessly();
|
||||||
if (failures == 0) {
|
if (failures == 0) {
|
||||||
std::printf("component_state_io_tests: all tests passed\n");
|
std::printf("component_state_io_tests: all tests passed\n");
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
|
||||||
|
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH
|
||||||
|
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
|
||||||
|
// the Filter group's contents, the wrapped deck height at the editor's two pinned widths, the
|
||||||
|
// hit-test reaching the new filter controls, and the bipolar knob law's inverse pair.
|
||||||
|
|
||||||
|
#include "../src/core/instrument/ui/deck_groups.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
using namespace reasampler::instrument::ui;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
// The editor's two pinned client widths (checkSizeConstraint's 560 floor, the 840 default),
|
||||||
|
// less the band allocator's kPad inset on each side.
|
||||||
|
static constexpr int kAvailAtMinWidth = 560 - 16;
|
||||||
|
static constexpr int kAvailAtDefaultWidth = 840 - 16;
|
||||||
|
|
||||||
|
static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
|
||||||
|
for (std::size_t i = 0; i < g.size(); ++i) {
|
||||||
|
if (g[i].id == id) return static_cast<int>(i);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int cell(DeckParam p) { return static_cast<int>(p); }
|
||||||
|
|
||||||
|
static void testDeckReadsPitchThenFilterThenAmpLeftToRight() {
|
||||||
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||||
|
const int pitch = indexOfGroup(g, kGroupPitch);
|
||||||
|
const int penv = indexOfGroup(g, kGroupPitchEnv);
|
||||||
|
const int filt = indexOfGroup(g, kGroupFilter);
|
||||||
|
const int fenv = indexOfGroup(g, kGroupFilterEnv);
|
||||||
|
const int amp = indexOfGroup(g, kGroupAmpEnv);
|
||||||
|
CHECK(pitch >= 0 && penv >= 0 && filt >= 0 && fenv >= 0 && amp >= 0);
|
||||||
|
// The signal flow, left to right. Each envelope group trails its own stage.
|
||||||
|
CHECK(pitch < penv);
|
||||||
|
CHECK(penv < filt);
|
||||||
|
CHECK(filt < fenv);
|
||||||
|
CHECK(fenv < amp);
|
||||||
|
// The two instance-wide groups stay at the end.
|
||||||
|
CHECK(amp < indexOfGroup(g, kGroupVoice));
|
||||||
|
CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() {
|
||||||
|
const std::vector<DeckGroupDesc>& g = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
const DeckGroupDesc& f = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
||||||
|
const std::vector<int> expected = {
|
||||||
|
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
|
||||||
|
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
|
||||||
|
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
|
||||||
|
cell(DeckParam::kFilterKeyTrack)};
|
||||||
|
CHECK(f.cellIds == expected);
|
||||||
|
// Off by default is a state question, but reachability is a layout one: the enable
|
||||||
|
// toggle is in the caption row and the morph law in the knob row.
|
||||||
|
CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable));
|
||||||
|
CHECK(f.rowToggle.id == cell(DeckParam::kFilterLaw));
|
||||||
|
|
||||||
|
const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
|
||||||
|
const std::vector<int> env = {
|
||||||
|
cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold),
|
||||||
|
cell(DeckParam::kFilterEnvDecay), cell(DeckParam::kFilterEnvSustain),
|
||||||
|
cell(DeckParam::kFilterEnvRelease)};
|
||||||
|
CHECK(fe.cellIds == env);
|
||||||
|
// The filter envelope has no enable of its own — the FILTER group's toggle governs both.
|
||||||
|
CHECK(fe.captionToggle.id == -1);
|
||||||
|
CHECK(fe.rowToggle.id == -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
|
||||||
|
// The reserved blanks are what stop a mode flip reflowing the groups beside AMP.
|
||||||
|
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
|
||||||
|
const DeckGroupDesc& a = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupAmpEnv))];
|
||||||
|
const DeckGroupDesc& b = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
|
||||||
|
CHECK(deckGroupWidth(a) == deckGroupWidth(b));
|
||||||
|
CHECK(a.cellIds.size() == b.cellIds.size());
|
||||||
|
CHECK(b.cellIds[3] == -1 && b.cellIds[4] == -1);
|
||||||
|
// Every other group is mode-independent, so the whole deck's height is too.
|
||||||
|
CHECK(deckHeight(gate, kAvailAtDefaultWidth) == deckHeight(trig, kAvailAtDefaultWidth));
|
||||||
|
CHECK(deckHeight(gate, kAvailAtMinWidth) == deckHeight(trig, kAvailAtMinWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testWrappedDeckHeightAtThePinnedEditorWidths() {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
// At the default 840 the deck takes two rows: PITCH + PITCH ENV + FILTER fill the first,
|
||||||
|
// the remaining four fit the second.
|
||||||
|
CHECK(deckRowCount(g, kAvailAtDefaultWidth) == 2);
|
||||||
|
CHECK(deckHeight(g, kAvailAtDefaultWidth) == 2 * kDeckGroupH + kDeckRowGap);
|
||||||
|
// At the 560 floor it takes four; FILTER is wider than the row on its own.
|
||||||
|
CHECK(deckRowCount(g, kAvailAtMinWidth) == 4);
|
||||||
|
CHECK(deckHeight(g, kAvailAtMinWidth) == 4 * kDeckGroupH + 3 * kDeckRowGap);
|
||||||
|
|
||||||
|
// Whole groups only, never split: every group's box lies inside the available width or is
|
||||||
|
// the first of its row.
|
||||||
|
const DeckLayout dl = layoutDeck(g, 8, 0, kAvailAtDefaultWidth);
|
||||||
|
CHECK(dl.groups.size() == g.size());
|
||||||
|
for (const DeckGroupLayout& gl : dl.groups) {
|
||||||
|
CHECK(gl.box.x >= 8);
|
||||||
|
CHECK(gl.box.height == kDeckGroupH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testHitTestResolvesTheNewFilterControls() {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
const DeckLayout dl = layoutDeck(g, 8, 40, kAvailAtDefaultWidth);
|
||||||
|
const DeckGroupLayout& f =
|
||||||
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
||||||
|
|
||||||
|
// Every knob cell resolves to its own id, from the centre of its cell.
|
||||||
|
for (const DeckCellLayout& c : f.cells) {
|
||||||
|
const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2,
|
||||||
|
c.cell.y + c.cell.height / 2);
|
||||||
|
CHECK(hit.kind == DeckHitKind::Knob);
|
||||||
|
CHECK(hit.id == c.id);
|
||||||
|
}
|
||||||
|
CHECK(f.cells.size() == 7);
|
||||||
|
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
|
||||||
|
|
||||||
|
// The enable toggle's two segments and the morph-law row toggle's two.
|
||||||
|
const DeckHit off = hitTestDeck(dl, f.captionToggle.seg0.x + 2,
|
||||||
|
f.captionToggle.seg0.y + 2);
|
||||||
|
CHECK(off.kind == DeckHitKind::CaptionToggle);
|
||||||
|
CHECK(off.id == cell(DeckParam::kFilterEnable) && off.segment == 0);
|
||||||
|
const DeckHit on = hitTestDeck(dl, f.captionToggle.seg1.x + 2,
|
||||||
|
f.captionToggle.seg1.y + 2);
|
||||||
|
CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1);
|
||||||
|
|
||||||
|
const DeckHit band = hitTestDeck(dl, f.rowToggle.seg0.x + 2, f.rowToggle.seg0.y + 2);
|
||||||
|
CHECK(band.kind == DeckHitKind::RowToggle);
|
||||||
|
CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0);
|
||||||
|
const DeckHit notch = hitTestDeck(dl, f.rowToggle.seg1.x + 2, f.rowToggle.seg1.y + 2);
|
||||||
|
CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1);
|
||||||
|
|
||||||
|
// The filter-envelope knobs resolve too, and are distinct ids from the amp's.
|
||||||
|
const DeckGroupLayout& fe =
|
||||||
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
|
||||||
|
const DeckHit attack = hitTestDeck(dl, fe.cells[0].cell.x + 4, fe.cells[0].cell.y + 4);
|
||||||
|
CHECK(attack.kind == DeckHitKind::Knob);
|
||||||
|
CHECK(attack.id == cell(DeckParam::kFilterEnvAttack));
|
||||||
|
CHECK(attack.id != cell(DeckParam::kAttack));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
|
||||||
|
// Centre is EXACT in both directions: a knob parked at 0.5 stores 0, and 0 reads back
|
||||||
|
// 0.5 — no residual modulation from a rounding hair.
|
||||||
|
CHECK(deckBipolarFromNorm(0.5) == 0.0);
|
||||||
|
CHECK(deckNormFromBipolar(0.0) == 0.5);
|
||||||
|
CHECK(deckBipolarFromNorm(0.0) == -1.0);
|
||||||
|
CHECK(deckBipolarFromNorm(1.0) == 1.0);
|
||||||
|
for (int i = 0; i <= 200; ++i) {
|
||||||
|
const double norm = static_cast<double>(i) / 200.0;
|
||||||
|
CHECK(std::fabs(deckNormFromBipolar(deckBipolarFromNorm(norm)) - norm) < 1e-12);
|
||||||
|
const double value = -1.0 + static_cast<double>(i) / 100.0;
|
||||||
|
CHECK(std::fabs(deckBipolarFromNorm(deckNormFromBipolar(value)) - value) < 1e-12);
|
||||||
|
}
|
||||||
|
// Out of range clamps rather than extrapolating.
|
||||||
|
CHECK(deckBipolarFromNorm(-3.0) == -1.0);
|
||||||
|
CHECK(deckBipolarFromNorm(3.0) == 1.0);
|
||||||
|
CHECK(deckNormFromBipolar(-3.0) == 0.0);
|
||||||
|
CHECK(deckNormFromBipolar(3.0) == 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||||
|
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
|
||||||
|
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
||||||
|
testWrappedDeckHeightAtThePinnedEditorWidths();
|
||||||
|
testHitTestResolvesTheNewFilterControls();
|
||||||
|
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
|
||||||
|
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -610,6 +610,57 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
|
|||||||
CHECK(at96.trigger.fadeInFrames == 441); // still unconverted
|
CHECK(at96.trigger.fadeInFrames == 441); // still unconverted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
|
||||||
|
// The filter's control positions are already rate-free, so only its envelope crosses the
|
||||||
|
// seconds->frames boundary. A converted norm would be a bug in the other direction: the
|
||||||
|
// same preset must sound identical at 48k and 96k.
|
||||||
|
PlaySeconds st;
|
||||||
|
st.filter.enabled = true;
|
||||||
|
st.filter.settings.cutoffNorm = 0.25f;
|
||||||
|
st.filter.settings.resonanceNorm = 0.75f;
|
||||||
|
st.filter.settings.morphNorm = 0.5f;
|
||||||
|
st.filter.settings.driveNorm = 0.125f;
|
||||||
|
st.filter.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
|
||||||
|
st.filter.modAmount = -0.5;
|
||||||
|
st.filter.velAmount = 0.25;
|
||||||
|
st.filter.keyTrack = 1.25;
|
||||||
|
st.filter.env.attackSeconds = 0.01;
|
||||||
|
st.filter.env.holdSeconds = 0.02;
|
||||||
|
st.filter.env.decaySeconds = 0.03;
|
||||||
|
st.filter.env.sustainLevel = 0.4;
|
||||||
|
st.filter.env.releaseSeconds = 0.05;
|
||||||
|
|
||||||
|
const PlayParams at48 = resolvePlay(st, 48000);
|
||||||
|
CHECK(at48.filter.enabled);
|
||||||
|
CHECK(at48.filter.settings.cutoffNorm == 0.25f);
|
||||||
|
CHECK(at48.filter.settings.resonanceNorm == 0.75f);
|
||||||
|
CHECK(at48.filter.settings.morphNorm == 0.5f);
|
||||||
|
CHECK(at48.filter.settings.driveNorm == 0.125f);
|
||||||
|
CHECK(at48.filter.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
|
||||||
|
CHECK(at48.filter.modAmount == -0.5);
|
||||||
|
CHECK(at48.filter.velAmount == 0.25);
|
||||||
|
CHECK(at48.filter.keyTrack == 1.25);
|
||||||
|
CHECK(at48.filter.env.attackFrames == 480);
|
||||||
|
CHECK(at48.filter.env.holdFrames == 960);
|
||||||
|
CHECK(at48.filter.env.decayFrames == 1440);
|
||||||
|
CHECK(at48.filter.env.sustainLevel == 0.4); // a level, not a time
|
||||||
|
CHECK(at48.filter.env.releaseFrames == 2400);
|
||||||
|
|
||||||
|
const PlayParams at96 = resolvePlay(st, 96000);
|
||||||
|
CHECK(at96.filter.env.attackFrames == 960);
|
||||||
|
CHECK(at96.filter.env.releaseFrames == 4800);
|
||||||
|
CHECK(at96.filter.settings.cutoffNorm == 0.25f); // rate-free: unchanged
|
||||||
|
|
||||||
|
// Off by default, and the default envelope is a flat unity so a disengaged filter has
|
||||||
|
// nothing to modulate with either.
|
||||||
|
const PlayParams bare = resolvePlay(PlaySeconds{}, 48000);
|
||||||
|
CHECK(!bare.filter.enabled);
|
||||||
|
CHECK(bare.filter.modAmount == 0.0);
|
||||||
|
CHECK(bare.filter.velAmount == 0.0);
|
||||||
|
CHECK(bare.filter.keyTrack == 0.0);
|
||||||
|
CHECK(bare.filter.env.sustainLevel == 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
static void testResolvePlayRoundsAndFloorsNegatives() {
|
static void testResolvePlayRoundsAndFloorsNegatives() {
|
||||||
PlaySeconds st;
|
PlaySeconds st;
|
||||||
st.adsr.attackSeconds = 0.0001; // 4.41 frames at 44.1k -> rounds to 4
|
st.adsr.attackSeconds = 0.0001; // 4.41 frames at 44.1k -> rounds to 4
|
||||||
@@ -845,6 +896,7 @@ int main() {
|
|||||||
testRetainRefsFiltersToPlayedSet();
|
testRetainRefsFiltersToPlayedSet();
|
||||||
testLegacyLiftDecision();
|
testLegacyLiftDecision();
|
||||||
testResolvePlayConvertsWallClockAtTheRate();
|
testResolvePlayConvertsWallClockAtTheRate();
|
||||||
|
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
|
||||||
testResolvePlayRoundsAndFloorsNegatives();
|
testResolvePlayRoundsAndFloorsNegatives();
|
||||||
testResolveCaptureUsesIntrinsicsWhenNoOverride();
|
testResolveCaptureUsesIntrinsicsWhenNoOverride();
|
||||||
testResolveCaptureOverridesBeatIntrinsics();
|
testResolveCaptureOverridesBeatIntrinsics();
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
// Standalone tests for the filter's place in the VOICE PATH — no VST3, no REAPER, no
|
||||||
|
// framework. The filter's own numerical behaviour is filter_tests / filter_state_tests /
|
||||||
|
// filter_morph_tests / filter_params_tests; this file asserts only the integration: that a
|
||||||
|
// disengaged filter is bit-inert, that it sits between the pitch stage and the amp stage,
|
||||||
|
// that each voice runs its own, and that the three cutoff-modulation sources reach it.
|
||||||
|
|
||||||
|
#include "../src/core/instrument/engine/voice.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <type_traits>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
namespace flt = reasampler::instrument::engine::filter;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
constexpr int kRate = 48000;
|
||||||
|
|
||||||
|
// The port's whole point, asserted from the CALL SITE as well as inside the module: neither
|
||||||
|
// the voice nor the envelope it drives may grow a vtable, and the filter stays a plain value
|
||||||
|
// member (trivially copyable => nothing heap-owned, so process() cannot allocate).
|
||||||
|
static_assert(!std::is_polymorphic_v<Voice>, "no vtable on the per-sample path");
|
||||||
|
static_assert(!std::is_polymorphic_v<AdsrEnvelope>, "the filter envelope must inline");
|
||||||
|
static_assert(std::is_trivially_copyable_v<flt::VoiceFilter>, "filter state is plain values");
|
||||||
|
|
||||||
|
// A sine whose period is EXACTLY `period` frames, so two voices started `period` apart read
|
||||||
|
// identical values at every absolute frame — that is what isolates envelope phase from read
|
||||||
|
// position in the per-voice-independence test.
|
||||||
|
static SampleData periodicSine(std::size_t frames, double period, int rootNote = 60) {
|
||||||
|
SampleData s;
|
||||||
|
s.frames.resize(frames);
|
||||||
|
for (std::size_t i = 0; i < frames; ++i) {
|
||||||
|
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / period));
|
||||||
|
}
|
||||||
|
s.sampleRate = kRate;
|
||||||
|
s.rootNote = rootNote;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amp envelope held wide open, so a rendered frame is exactly the (filtered) source.
|
||||||
|
static AdsrParams flatAdsr() {
|
||||||
|
AdsrParams a;
|
||||||
|
a.sustainLevel = 1.0;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FilterParams engagedFilter(float cutoffNorm, float resNorm, float morphNorm) {
|
||||||
|
FilterParams f;
|
||||||
|
f.enabled = true;
|
||||||
|
f.settings.cutoffNorm = cutoffNorm;
|
||||||
|
f.settings.resonanceNorm = resNorm;
|
||||||
|
f.settings.morphNorm = morphNorm;
|
||||||
|
f.env.sustainLevel = 1.0;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::vector<double> render(SampleData& sample, int note, int velocity, int frames) {
|
||||||
|
Voice v;
|
||||||
|
v.start(note, velocity, sample);
|
||||||
|
std::vector<double> out(static_cast<std::size_t>(frames), 0.0);
|
||||||
|
for (int i = 0; i < frames; ++i) out[static_cast<std::size_t>(i)] = v.renderFrame();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double rms(const std::vector<double>& x, std::size_t from, std::size_t to) {
|
||||||
|
double acc = 0.0;
|
||||||
|
for (std::size_t i = from; i < to; ++i) acc += x[i] * x[i];
|
||||||
|
return std::sqrt(acc / static_cast<double>(to - from));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Off by default.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored() {
|
||||||
|
SampleData plain = periodicSine(2000, 64);
|
||||||
|
plain.play.adsr = flatAdsr();
|
||||||
|
|
||||||
|
// Independent reference: with a flat amp envelope, unity velocity curve and the note at
|
||||||
|
// the root, the rendered frame IS the source frame. Asserting against this rather than
|
||||||
|
// against another render of the same code proves the un-filtered path is still correct,
|
||||||
|
// not merely self-consistent.
|
||||||
|
const std::vector<double> bare = render(plain, 60, 100, 1500);
|
||||||
|
for (std::size_t i = 0; i < bare.size(); ++i) {
|
||||||
|
CHECK(static_cast<float>(bare[i]) == plain.frames[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now store the most violent filter settings available and leave `enabled` false. A blob
|
||||||
|
// that carries filter parameters must sound exactly as it did before they existed.
|
||||||
|
SampleData stored = periodicSine(2000, 64);
|
||||||
|
stored.play.adsr = flatAdsr();
|
||||||
|
stored.play.filter = engagedFilter(0.0f, 1.0f, 1.0f);
|
||||||
|
stored.play.filter.enabled = false;
|
||||||
|
stored.play.filter.settings.driveNorm = 1.0f;
|
||||||
|
stored.play.filter.modAmount = 1.0;
|
||||||
|
stored.play.filter.velAmount = -1.0;
|
||||||
|
stored.play.filter.keyTrack = 2.0;
|
||||||
|
const std::vector<double> inert = render(stored, 60, 100, 1500);
|
||||||
|
for (std::size_t i = 0; i < inert.size(); ++i) CHECK(inert[i] == bare[i]);
|
||||||
|
|
||||||
|
// And engaging it must actually do something — otherwise the bit-identity above would be
|
||||||
|
// satisfied by a filter that never runs.
|
||||||
|
SampleData live = periodicSine(2000, 64);
|
||||||
|
live.play.adsr = flatAdsr();
|
||||||
|
live.play.filter = engagedFilter(0.0f, 0.0f, 1.0f); // low-pass, corner at 20 Hz
|
||||||
|
const std::vector<double> filtered = render(live, 60, 100, 1500);
|
||||||
|
CHECK(rms(filtered, 500, 1500) < 0.05 * rms(bare, 500, 1500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pipeline position: pitch -> filter -> amp.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testFilterSeesThePreAmpSignalSoAmpGainScalesTheResultLinearly() {
|
||||||
|
// With a NONLINEAR drive stage engaged, the two orderings are distinguishable: a filter
|
||||||
|
// fed the post-amp signal would see a half-level input and shape it differently, so the
|
||||||
|
// two renders could not be an exact factor of two apart. Filter-before-amp makes them
|
||||||
|
// exactly that, because the amp multiply is the last thing to happen.
|
||||||
|
const auto renderAtSustain = [](double sustain) {
|
||||||
|
SampleData s = periodicSine(2000, 64);
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
s.play.adsr.sustainLevel = sustain;
|
||||||
|
s.play.filter = engagedFilter(0.35f, 0.9f, 1.0f);
|
||||||
|
s.play.filter.settings.driveNorm = 1.0f; // hard nonlinearity in the resonance path
|
||||||
|
return render(s, 60, 100, 1200);
|
||||||
|
};
|
||||||
|
const std::vector<double> half = renderAtSustain(0.5);
|
||||||
|
const std::vector<double> full = renderAtSustain(1.0);
|
||||||
|
|
||||||
|
bool sawSignal = false;
|
||||||
|
// Frame 0 is the AHDSR's attack peak (1.0) in BOTH runs; the sustain level takes over
|
||||||
|
// from frame 1.
|
||||||
|
for (std::size_t i = 1; i < half.size(); ++i) {
|
||||||
|
CHECK(half[i] == 0.5 * full[i]);
|
||||||
|
if (std::fabs(full[i]) > 1e-6) sawSignal = true;
|
||||||
|
}
|
||||||
|
CHECK(sawSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-voice, not instance-wide.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testTwoVoicesAtDifferentEnvelopePhasesFilterIndependently() {
|
||||||
|
// Both voices read the SAME source value at every absolute frame (voice B starts exactly
|
||||||
|
// one sine period late), and both amp envelopes are wide open — so the only thing that
|
||||||
|
// can separate their outputs is their own filter-envelope phase. A single shared filter
|
||||||
|
// would hand both voices one cutoff and one integrator pair, and they would match.
|
||||||
|
constexpr int kPeriod = 64;
|
||||||
|
SampleData s = periodicSine(20000, kPeriod);
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
s.play.filter = engagedFilter(0.0f, 0.2f, 1.0f);
|
||||||
|
s.play.filter.modAmount = 1.0; // envelope sweeps cutoff over the full range
|
||||||
|
s.play.filter.env.attackFrames = 8000; // slow, so the two phases stay far apart
|
||||||
|
s.play.filter.env.sustainLevel = 1.0;
|
||||||
|
|
||||||
|
Voice a;
|
||||||
|
Voice b;
|
||||||
|
a.start(60, 100, s);
|
||||||
|
for (int i = 0; i < kPeriod; ++i) a.renderFrame();
|
||||||
|
b.start(60, 100, s);
|
||||||
|
|
||||||
|
int differing = 0;
|
||||||
|
for (int i = 0; i < 4000; ++i) {
|
||||||
|
const double fa = a.renderFrame();
|
||||||
|
const double fb = b.renderFrame();
|
||||||
|
if (std::fabs(fa - fb) > 1e-6) ++differing;
|
||||||
|
}
|
||||||
|
CHECK(differing > 3000);
|
||||||
|
|
||||||
|
// The same staggered pair with the MODULATION removed converges: each voice still owns
|
||||||
|
// its own integrators, so the stagger costs a short start-up transient, but with no
|
||||||
|
// envelope reaching cutoff the two settle onto the identical steady state. That is what
|
||||||
|
// makes the count above evidence of per-voice ENVELOPE phase rather than of the stagger.
|
||||||
|
SampleData flat = periodicSine(20000, kPeriod);
|
||||||
|
flat.play.adsr = flatAdsr();
|
||||||
|
flat.play.filter = engagedFilter(0.5f, 0.2f, 1.0f);
|
||||||
|
Voice c;
|
||||||
|
Voice d;
|
||||||
|
c.start(60, 100, flat);
|
||||||
|
for (int i = 0; i < kPeriod; ++i) c.renderFrame();
|
||||||
|
d.start(60, 100, flat);
|
||||||
|
int settledDiffering = 0;
|
||||||
|
for (int i = 0; i < 4000; ++i) {
|
||||||
|
const double fc = c.renderFrame();
|
||||||
|
const double fd = d.renderFrame();
|
||||||
|
if (i >= 2000 && fc != fd) ++settledDiffering;
|
||||||
|
}
|
||||||
|
CHECK(settledDiffering == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modulation: envelope depth, velocity, key tracking.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testModAmountPolarityDrivesCutoffFromOppositeEnds() {
|
||||||
|
// A low-pass over a 750 Hz tone: energy tracks cutoff, so the sweep direction is readable
|
||||||
|
// straight off the output's RMS. +100% from a closed filter opens it; -100% from an open
|
||||||
|
// one closes it; 0% leaves it where the knob is.
|
||||||
|
const auto sweep = [](float cutoffNorm, double modAmount) {
|
||||||
|
SampleData s = periodicSine(20000, 64);
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
s.play.filter = engagedFilter(cutoffNorm, 0.0f, 1.0f);
|
||||||
|
s.play.filter.modAmount = modAmount;
|
||||||
|
s.play.filter.env.attackFrames = 12000;
|
||||||
|
s.play.filter.env.sustainLevel = 1.0;
|
||||||
|
return render(s, 60, 100, 12000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::vector<double> rising = sweep(0.0f, 1.0);
|
||||||
|
CHECK(rms(rising, 10000, 12000) > 20.0 * rms(rising, 0, 2000));
|
||||||
|
|
||||||
|
const std::vector<double> falling = sweep(1.0f, -1.0);
|
||||||
|
CHECK(rms(falling, 10000, 12000) < 0.05 * rms(falling, 0, 2000));
|
||||||
|
|
||||||
|
// Zero depth: the envelope is still running, but it must not reach cutoff at all.
|
||||||
|
const std::vector<double> steady = sweep(0.5f, 0.0);
|
||||||
|
const double early = rms(steady, 2000, 4000);
|
||||||
|
const double late = rms(steady, 10000, 12000);
|
||||||
|
CHECK(std::fabs(late - early) < 1e-6 * early + 1e-9);
|
||||||
|
|
||||||
|
// The two poles land at opposite ends of the same range: at full sweep the rising run
|
||||||
|
// ends open and the falling run ends closed.
|
||||||
|
CHECK(rms(rising, 10000, 12000) > rms(falling, 10000, 12000));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
|
||||||
|
// Playback key-tracking off, so both notes read the source at the SAME rate and the only
|
||||||
|
// note-dependent difference left is the filter's own key-tracking.
|
||||||
|
const auto tone = [](double velAmount, double keyTrack) {
|
||||||
|
SampleData s = periodicSine(8000, 64);
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
s.keyTrack = 0.0;
|
||||||
|
s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
|
||||||
|
s.play.filter.velAmount = velAmount;
|
||||||
|
s.play.filter.keyTrack = keyTrack;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Velocity: the default linear curve rises with velocity, so a positive depth opens the
|
||||||
|
// filter for a hard hit. The amp's own velocity curve is flat, so amplitude is unaffected.
|
||||||
|
SampleData vel = tone(1.0, 0.0);
|
||||||
|
const std::vector<double> soft = render(vel, 60, 1, 6000);
|
||||||
|
const std::vector<double> hard = render(vel, 60, 127, 6000);
|
||||||
|
CHECK(rms(hard, 2000, 6000) > 2.0 * rms(soft, 2000, 6000));
|
||||||
|
|
||||||
|
// Key tracking: two octaves up opens it by two octaves of cutoff.
|
||||||
|
SampleData key = tone(0.0, 1.0);
|
||||||
|
const std::vector<double> low = render(key, 60, 100, 6000);
|
||||||
|
const std::vector<double> high = render(key, 84, 100, 6000);
|
||||||
|
CHECK(rms(high, 2000, 6000) > 2.0 * rms(low, 2000, 6000));
|
||||||
|
|
||||||
|
// Both neutral: neither velocity nor note may move the filter.
|
||||||
|
SampleData neutral = tone(0.0, 0.0);
|
||||||
|
const std::vector<double> a = render(neutral, 60, 1, 6000);
|
||||||
|
const std::vector<double> b = render(neutral, 60, 127, 6000);
|
||||||
|
const std::vector<double> c = render(neutral, 84, 100, 6000);
|
||||||
|
for (std::size_t i = 0; i < a.size(); ++i) {
|
||||||
|
CHECK(a[i] == b[i]);
|
||||||
|
CHECK(a[i] == c[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak() {
|
||||||
|
SampleData s = periodicSine(8000, 64);
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
s.play.filter = engagedFilter(0.2f, 0.95f, 1.0f); // high Q: a long, obvious ring
|
||||||
|
|
||||||
|
Voice v;
|
||||||
|
v.start(60, 100, s);
|
||||||
|
for (int i = 0; i < 4000; ++i) v.renderFrame(); // fill the integrators
|
||||||
|
v.start(60, 100, s); // restart: reset() must clear them
|
||||||
|
std::vector<double> restarted(1000);
|
||||||
|
for (int i = 0; i < 1000; ++i) restarted[static_cast<std::size_t>(i)] = v.renderFrame();
|
||||||
|
|
||||||
|
const std::vector<double> fresh = render(s, 60, 100, 1000);
|
||||||
|
for (std::size_t i = 0; i < fresh.size(); ++i) CHECK(restarted[i] == fresh[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored();
|
||||||
|
testFilterSeesThePreAmpSignalSoAmpGainScalesTheResultLinearly();
|
||||||
|
testTwoVoicesAtDifferentEnvelopePhasesFilterIndependently();
|
||||||
|
testModAmountPolarityDrivesCutoffFromOppositeEnds();
|
||||||
|
testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults();
|
||||||
|
testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak();
|
||||||
|
if (g_fail == 0) std::printf("sampler_filter: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user