de5654fb6f
The SDK's own single-component sample drains inputParameterChanges in process() and implements setParamNormalized; automation was reading the GUI channel alone. The audio thread now patches a block it solely owns.
311 lines
17 KiB
C++
311 lines
17 KiB
C++
#pragma once
|
|
// play_params.h — the instrument's one set of playback-parameter value structs plus the
|
|
// per-instance mode enums, shared by the engine, sample_map, the ComponentState codec, and
|
|
// the editor. Split out of the engine headers so a UI/codec TU reading a param struct
|
|
// doesn't recompile when a Voice/VoiceEngine member changes. The per-frame evaluators live
|
|
// in envelopes.h; the engine in voice.h / voice_engine.h.
|
|
|
|
#include <cstdint>
|
|
#include <vector>
|
|
|
|
#include "core/audio/peaks.h"
|
|
#include "core/instrument/engine/filter/voice_filter.h"
|
|
#include "core/instrument/engine/velocity_curve.h"
|
|
#include "core/util/curve_law.h" // the per-segment curve exponent domain + its neutral
|
|
|
|
namespace reasampler {
|
|
|
|
namespace instrument::engine { class LiveParams; } // live_params.h; SampleData holds a pointer
|
|
|
|
using audio::AudioSample;
|
|
using instrument::engine::VelocityCurve;
|
|
|
|
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
|
|
// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
|
|
enum class ChannelMode { Mono, Stereo };
|
|
|
|
// POLY is the fixed-pool engine with bounded stealing; MONO is a single voice with last-note
|
|
// priority over a held-note stack (a new note takes over; releasing the top note falls back to
|
|
// the most-recent still-held one). Never a bank fact. Default Poly.
|
|
enum class VoiceMode { Poly, Mono };
|
|
|
|
// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
|
|
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
|
|
// re-attack). With one loaded capture every takeover is same-sample, so Legato always glides.
|
|
enum class MonoTrigger { Retrigger, Legato };
|
|
|
|
// Shared range so the engine, the component-state codec, and the editor control can't drift.
|
|
inline constexpr int kMinVoiceCount = 1;
|
|
inline constexpr int kMaxVoiceCount = 32;
|
|
inline constexpr int kDefaultVoiceCount = 16;
|
|
|
|
// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
|
|
// The three curve exponents shape the SLOPED stages only — Hold and Sustain are flat by
|
|
// definition and carry none. `curve_law.h` owns what an exponent means.
|
|
struct AdsrParams {
|
|
std::int64_t attackFrames = 0;
|
|
std::int64_t holdFrames = 0;
|
|
std::int64_t decayFrames = 0;
|
|
double sustainLevel = 1.0; // 0..1
|
|
std::int64_t releaseFrames = 0;
|
|
double attackCurve = util::kCurveNeutral;
|
|
double decayCurve = util::kCurveNeutral;
|
|
double releaseCurve = util::kCurveNeutral;
|
|
};
|
|
|
|
// Attack -> Hold -> Decay over a bounded span: the shape every SUSTAIN-LESS envelope takes
|
|
// (the Trigger amp, the Trigger filter envelope, the pitch envelope). Hold is a FRACTION of
|
|
// the span left after attack and decay, never a time of its own — fitAhd (envelopes.h) owns
|
|
// why a fraction, not a time.
|
|
struct AhdParams {
|
|
std::int64_t attackFrames = 0;
|
|
std::int64_t decayFrames = 0;
|
|
double holdFraction = 1.0; // 0..1 of the span remaining after attack + decay
|
|
double attackCurve = util::kCurveNeutral;
|
|
double decayCurve = util::kCurveNeutral;
|
|
};
|
|
|
|
// Which shape an envelope takes: the STAGED knobs, or a free-drawn SPLINE contour. Both states
|
|
// are stored side by side and neither converts into the other, so a mode flip is reversible and
|
|
// lossless — the inactive one is saved but inert, edited only by switching back to it.
|
|
enum class EnvMode { Staged, Spline };
|
|
|
|
// The free-drawn alternative to a staged envelope: a contour over NORMALIZED sample time,
|
|
// covering the full sample length. Normalized is what makes it length-independent — a
|
|
// different-length capture replays the same shape proportionally, with no stored seconds to
|
|
// rescale. The default is the smooth y = 1 - x downward slope.
|
|
struct SplineEnv {
|
|
EnvMode mode = EnvMode::Staged;
|
|
VelocityCurve contour = VelocityCurve::rampDown();
|
|
};
|
|
|
|
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
|
|
// note-off-immune, no sustain loop, plays a % of sample length shaped by the AHD. Both honor
|
|
// the start point. Default Gate so an instrument with no params set plays as before.
|
|
enum class PlayMode { Gate, Trigger };
|
|
|
|
// Trigger's play SPAN: [startFrame, playEnd), playEnd = startFrame +
|
|
// round(lengthFraction*(frames - startFrame)). The voice frees when the head reaches playEnd.
|
|
// The amplitude SHAPE over that span is PlayParams::trigAhd — the fade-in/fade-out pair that
|
|
// used to live here is retired; do not reintroduce a second amplitude mechanism.
|
|
struct TriggerParams {
|
|
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
|
};
|
|
|
|
// VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
|
|
// PRESERVE: the read advances at the source rate while a PitchShifter transposes the output
|
|
// (an octave up keeps its length).
|
|
enum class PitchEngine { Varispeed, Preserve };
|
|
|
|
// Product default is Preserve, but applied at the state boundary (the codec's read path /
|
|
// the editor's default params), NOT here: PlayParams.pitchEngine itself defaults to Varispeed
|
|
// so "no params == the bare engine" holds for the core's own regression tests (an octave up
|
|
// still halves duration with no params set).
|
|
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
|
|
|
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
|
|
// on big transpositions. Onset latency is zero — start() primes the ring with the first window
|
|
// of real source, so output frame 0 is source frame 0 regardless of window size.
|
|
inline constexpr double kPreserveWindowMs = 50.0;
|
|
|
|
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 0 ->
|
|
// bit-identical to the un-modulated engine). At note-on the offset rises to peakSemitones over
|
|
// attack, holds there, then falls to 0 over decay; a zero attack gives a pure percussive pitch
|
|
// drop. The hold fraction defaults to 0 so an instance predating the stage plays exactly as its
|
|
// attack-decay predecessor did.
|
|
struct PitchEnvParams {
|
|
bool enabled = false;
|
|
double peakSemitones = 0.0; // signed depth at the peak
|
|
AhdParams shape{0, 0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
|
|
};
|
|
|
|
// 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 below land in that same normalized cutoff domain and sum
|
|
// before a single clamp; 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], scales velocityCurve's output
|
|
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
|
|
// The filter envelope takes the same shape the amp does under the active play mode:
|
|
// AHDSR in Gate, AHD in Trigger. Both are stored, so a mode flip cannot lose either
|
|
// mode's dialled values (see core/instrument/CLAUDE.md).
|
|
AdsrParams env; // Gate: the same staged AHDSR the amp runs; frames
|
|
AhdParams trigEnv; // Trigger: the same staged AHD the amp runs; frames
|
|
// Velocity -> cutoff, in the normalized cutoff domain. The contribution is
|
|
// velAmount * velocityCurve.eval(velocity): the BIPOLAR curve carries the shape (and its
|
|
// own sign), the depth knob scales it, and BOTH apply. The curve is flat at 0 by default,
|
|
// so no depth setting produces velocity modulation until a curve is drawn.
|
|
VelocityCurve velocityCurve = VelocityCurve::zero();
|
|
};
|
|
|
|
// Full-scale of the velocity->pitch curve: y = +/-1 transposes by this many semitones. Shared
|
|
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
|
|
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
|
|
|
|
// Standard 12-tone-ET tracking, and the ONE home for that number: the capture's own scalar, the
|
|
// instrument's stored scalar and the live block all default from here, so a blob predating the
|
|
// field and a block published before the first note can never disagree about it.
|
|
inline constexpr double kKeyTrackDefault = 1.0;
|
|
|
|
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
|
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
|
|
// on this; the Preserve product default is layered on at (de)serialization, see
|
|
// kDefaultPitchEngine.
|
|
struct PlayParams {
|
|
PlayMode playMode = PlayMode::Gate;
|
|
AdsrParams adsr; // Gate amp
|
|
TriggerParams trigger; // Trigger play span
|
|
AhdParams trigAhd; // Trigger amp
|
|
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
|
// Playback RATE, as source frames consumed per output frame. Under Varispeed it is one more
|
|
// factor of the read increment, so it moves pitch and duration together; under Preserve it
|
|
// drives duration alone and the shifter holds the pitch. Latched at note-on either way (the
|
|
// loop fold and the contour scale it composes with are both note-on folds), and clamped by
|
|
// the stretcher's own clampStretchRate — never here. 1.0 is the bare engine, bit for bit.
|
|
double playRate = 1.0;
|
|
// A baseline pitch offset in semitones, folded into the note's ratio beside key-tracking and
|
|
// the velocity->pitch transpose. Live on a sounding voice under both engines.
|
|
double pitchOffsetSemitones = 0.0;
|
|
PitchEnvParams pitchEnv;
|
|
// Velocity -> pitch offset, scaled by kVelocityPitchRangeSemitones. Bipolar and flat at 0
|
|
// by default, so it transposes nothing until a curve is drawn. Folded into the voice's
|
|
// baseRatio_ at note-on — it is fixed for the note's lifetime, so it costs no per-frame work.
|
|
VelocityCurve pitchVelocityCurve = VelocityCurve::zero();
|
|
FilterParams filter;
|
|
// The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to
|
|
// filter.env/trigEnv respectively. They sit HERE rather than inside the three envelope
|
|
// structs because those are copied whole into the live block, which must stay trivially
|
|
// copyable (live_params.h) — and a contour is not a live control anyway: like the velocity
|
|
// curves it travels by reload.
|
|
SplineEnv ampSpline;
|
|
SplineEnv pitchSpline;
|
|
SplineEnv filterSpline;
|
|
};
|
|
|
|
// Whether ANY of the three envelopes is drawn rather than staged. Templated over the two
|
|
// parameter representations (frames and the editor's seconds mirror) because both spell the
|
|
// three fields identically and the rule must not be written twice — compile-time dispatch,
|
|
// no runtime cost, off every hot path.
|
|
//
|
|
// THE consequence, and its one home: a spline contour is a pure time function over the full
|
|
// sample length, which IS the Trigger/one-shot playback model — so Gate is not available while
|
|
// any spline EG is active. resolvePlay enforces it on the way to the engine; the editor's
|
|
// play-mode toggle refuses the Gate segment so the two agree.
|
|
//
|
|
// The pitch/filter terms are gated on their own `enabled` flag to match Voice::start's binder
|
|
// (voice.cpp only binds pitchSplineCur_/filterSplineCur_ when that flag is set): without this,
|
|
// a Spline mode flip on a disabled pitch/filter envelope would cost Gate for zero modulation,
|
|
// since the binder would never actually engage. Amp has no such flag, so it counts unconditionally.
|
|
template <class Play>
|
|
bool splineActive(const Play& p) {
|
|
return p.ampSpline.mode == EnvMode::Spline ||
|
|
(p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) ||
|
|
(p.filter.enabled && p.filterSpline.mode == EnvMode::Spline);
|
|
}
|
|
|
|
// The mode the engine will actually run, and the one home of splineActive's rule (see its doc
|
|
// above). Header-inline and allocation-free: play_params.h sits on the per-voice-per-sample
|
|
// include path. Every caller — resolvePlay (sample_map.cpp), the editor's applyControl, and
|
|
// the editor's read-only predicates — routes through one of these two, so none of them can
|
|
// drift into a second reading of the fields.
|
|
template <class Play>
|
|
PlayMode effectivePlayMode(const Play& p) {
|
|
return splineActive(p) ? PlayMode::Trigger : p.playMode;
|
|
}
|
|
|
|
template <class Play>
|
|
void enforceGateUnavailableWhileDrawn(Play& p) {
|
|
p.playMode = effectivePlayMode(p);
|
|
}
|
|
|
|
// The %-length the voice ACTUALLY plays. Same rule family, same reason it is templated: a drawn
|
|
// contour is a pure time function over the full sample length, so any active spline EG folds
|
|
// the fraction to 1.0 while the stored knob goes inert — but the stored value survives, so a
|
|
// pre-spline setting is still there to be read. Every consumer of the Trigger span must fold it
|
|
// here or it silently plays/draws/bakes a fraction of the take.
|
|
template <class Play>
|
|
double effectiveLengthFraction(const Play& p) {
|
|
return splineActive(p) ? 1.0 : p.trigger.lengthFraction;
|
|
}
|
|
|
|
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
|
// marker — a held note past the sample end goes silent rather than looping a zero span.
|
|
struct SampleLoop {
|
|
bool hasLoop = false;
|
|
std::int64_t start = 0;
|
|
std::int64_t end = 0;
|
|
};
|
|
|
|
// The one loaded capture the core plays: decoded PCM plus every parameter governing playback.
|
|
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
|
//
|
|
// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
|
|
// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
|
|
// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
|
|
// channels share the read head / rootNote / loop, so repitch and loop stay per-frame identical
|
|
// across channels. `rootNote` is the MIDI note the file was recorded at — unity ratio there.
|
|
struct SampleData {
|
|
std::vector<AudioSample> frames;
|
|
std::vector<AudioSample> framesR; // empty for a mono sample
|
|
int sampleRate = 0; // ratio math is note-relative, so rate cancels for
|
|
// repitch; still, 0 is invalid — every consumer must
|
|
// receive a real rate before use.
|
|
int rootNote = 60;
|
|
SampleLoop loop;
|
|
|
|
// Pre-seam crossfade at the loop reset, in SOURCE frames — a source-timeline quantity
|
|
// like the loop points it belongs to, so no rate resolves it. 0 (the default) is the
|
|
// hard seam every instance predating the field plays. engine/loop/loop_span.h owns what
|
|
// the fade actually does and how it clamps.
|
|
std::int64_t loopCrossfadeFrames = 0;
|
|
|
|
// Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
|
|
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
|
|
std::int64_t startFrame = 0;
|
|
|
|
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
|
|
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
|
|
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
|
|
double keyTrack = kKeyTrackDefault;
|
|
|
|
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
|
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
|
VelocityCurve velocityCurve = VelocityCurve::flat();
|
|
|
|
// The source's own fundamental period in SOURCE frames, which makes Preserve's splices
|
|
// pitch-synchronous (pitch_shift.h). DERIVED from the PCM at load, not authored and never
|
|
// persisted — a cache, not state, so it takes no rung of the payload ladder. 0 means
|
|
// unknown (nothing detected it, or the source has no single period) and restores the
|
|
// fixed-window splice geometry byte for byte, which is why a hand-built SampleData is
|
|
// still exactly the bare engine.
|
|
double sourcePeriodFrames = 0.0;
|
|
|
|
PlayParams play;
|
|
|
|
// The live-parameter block a sounding voice tracks, or null for the bare latched engine
|
|
// (the default — with no block attached the core is byte-identical to the pre-live one).
|
|
// NON-OWNING and deliberately not per-snapshot: the shell owns ONE block that outlives
|
|
// every instrument snapshot, so a voice still ringing out of the drain slot follows the
|
|
// same knob as a live one. That is the desired behaviour — it is the note the user is
|
|
// hearing. Do not "fix" it by moving ownership into the snapshot.
|
|
const instrument::engine::LiveParams* live = nullptr;
|
|
|
|
// A framesR of a different length than frames is treated as absent — a malformed pair
|
|
// never half-plays.
|
|
int channelCount() const {
|
|
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
|
}
|
|
|
|
// Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a
|
|
// voice on an empty read span.
|
|
bool playable() const { return !frames.empty(); }
|
|
};
|
|
|
|
} // namespace reasampler
|