#pragma once // live_params.h — the live playback-parameter block: the plain value bundle the audio thread // observes once per BLOCK, its publication (the seqlock.h protocol) without a lock or a torn // read, the ONE fold from PlayParams that keeps the two representations in step, and the // per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every // instrument snapshot (see SampleData::live). #include #include #include "core/instrument/engine/play_params.h" #include "core/instrument/engine/seqlock.h" namespace reasampler::instrument::engine { // Full-scale glide time for a live control move (wall-clock seconds), so every smoothed // control in the program settles on the one time base the post-mixer gain ramp already uses. inline constexpr double kLiveRampSeconds = 0.020; // Every continuously-valued playback control, in the SAME domains the engine latches at // note-on (normalized control positions, envelope times already resolved to frames). What is // deliberately absent is as load-bearing as what is present: velocity and everything derived // from it, the note number and its pitch ratio, and the decoded PCM are facts about the note // event, not controls, and stay latched at note-on. The discrete toggles (play mode, pitch // engine, filter enable/law, pitch-envelope enable, channel mode) travel by reload instead. // // morphLaw rides inside filterSettings only because it is cheaper to carry the whole struct to // the filter's prepare() than to splice it back; it changes only across a reload, which // republishes this block, so the two can never disagree. // Each envelope carries BOTH mode shapes: which one a voice applies is fixed at note-on by // its play mode, so publishing both keeps the block one shape regardless of mode. The pitch // envelope's `enabled` rides along inside its params only because the struct is carried whole; // PitchEnvelope ignores it, since a toggle travels by reload. struct LiveValues { filter::FilterSettings filterSettings{}; double filterModAmount = 0.0; // The DEPTH scaling the velocity curve, not the curve's value: the note's velocity is // latched, its depth is a control, exactly as filterKeyTrack is a control over a latched // note number. double filterVelAmount = 0.0; double filterKeyTrack = 0.0; AdsrParams filterEnv{}; AhdParams filterAhd{}; AdsrParams adsr{}; AhdParams ampAhd{}; PitchEnvParams pitchEnv{}; // The block's THIRD commit class, and the reason this comment is here rather than at the // predicate: playRate is published like any live control but read ONLY at note-on, by // Voice::start via VoiceEngine::startVoice — never by applyLive on a sounding voice. A live // rate would mean re-folding an already-resolved sustain loop and re-mapping a contour // mid-note, both of which are note-on folds. pitchOffsetSemitones has no such tie and is // ordinarily live. double playRate = 1.0; double pitchOffsetSemitones = 0.0; // Two more members of playRate's note-on-latched class, here for the same reason it is: // both resolve a fact the voice fixes at note-on (the pitch ratio, and playEnd_), so live // delivery would retune or re-span a note already struck. Voice::start receives them as // arguments; applyLive never touches either. double keyTrack = kKeyTrackDefault; // ALREADY spline-folded (effectiveLengthFraction) — a drawn contour is a pure time function // over the whole sample, so the stored knob is inert while one is active and the block must // carry what the voice will actually play, not the stored value. double lengthFraction = 1.0; // The drawn-EG state the fold above reads. A mode flip travels by reload like the contours // themselves, so this is not a control; it rides here only so a block-boundary write of // Trigger length (a host automation point) can apply the SAME fold rather than un-doing it. bool splineActive = false; }; // The seqlock copies the block as raw bytes, which is only defensible for a plain value type. static_assert(std::is_trivially_copyable_v, "the live block is copied under a seqlock — it must stay a plain value"); // A SIZE-CHANGING edit only: padding can absorb a member added beside an existing one (a bool // beside splineActive, a fifth FilterSettings float) without moving this literal at all, so this // assert is NOT the guard against a forgotten operator== field — // testEveryFieldOfLiveValuesIsCompared (test_live_params.cpp) is that guard, poisoning one leaf // at a time. This assert only catches an edit that changes sizeof(LiveValues) itself. Confirmed // 352 bytes, MSVC 19.44 x64, Release (`SizeProbe`, an incomplete-template // size probe whose error message reports the value). Bump the literal AND operator== together. static_assert(sizeof(LiveValues) == 352, "a member was added or removed — extend operator== in live_params.cpp to match"); // FIELD-wise equality, and it must never be "simplified" into a memcmp. LiveValues carries // padding, and nothing gives that padding a determinate value across a copy: NRVO is optional // and the implicit copy/move is specified member-wise, so two blocks folded from the same // parameter set are NOT reliably byte-equal. A byte compare therefore reports differences that // do not exist — which is exactly what it did before this existed. Listed member by member, so a // member added to the block above must be added here as well; this sits directly beneath the // struct for that reason. bool operator==(const LiveValues& a, const LiveValues& b); inline bool operator!=(const LiveValues& a, const LiveValues& b) { return !(a == b); } // The ONE derivation of the live block from the parameter set. Every publisher goes through // here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in // because it belongs to the capture/instrument scalar beside the play bundle, not to // PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit. LiveValues foldLive(const PlayParams& params, double keyTrack); // The live-parameter block's publication — seqlock.h owns the protocol and its single-writer // contract. A class rather than an alias so play_params.h can forward-declare it. class LiveParams : public Seqlock {}; // Linear per-frame glide with EXACT termination: once the target is within one step the value // becomes the target itself. An asymptotic smoother would leave the value forever a hair off, // pinning the filter's exact-equality cutoff skip on the always-re-solve path; this returns to // the skip path the moment the move completes. A non-positive step snaps (no rate known yet). struct ValueRamp { double value = 0.0; double target = 0.0; double step = 0.0; bool moving() const { return value != target; } void set(double v) { value = v; target = v; } void aim(double t) { target = t; } // Advances one frame; returns whether the value actually moved. bool tick() { if (value == target) return false; const double delta = target - value; if (step <= 0.0 || (delta <= step && delta >= -step)) value = target; else value += (delta > 0.0) ? step : -step; return true; } }; // Per-frame ramp step for a control whose full travel is 1.0, at `sampleRate`. A non-positive // rate yields 0 — the ramp then snaps rather than inventing a rate. double liveRampStep(double sampleRate); } // namespace reasampler::instrument::engine