177 lines
9.9 KiB
C++
177 lines
9.9 KiB
C++
#pragma once
|
|
// live_params.h — the live playback-parameter block: the plain value bundle the audio thread
|
|
// observes once per BLOCK, the single-writer seqlock that publishes it 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 <atomic>
|
|
#include <cstdint>
|
|
#include <type_traits>
|
|
|
|
#include "core/instrument/engine/play_params.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<LiveValues>,
|
|
"the live block is copied under a seqlock — it must stay a plain value");
|
|
|
|
// 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);
|
|
|
|
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
|
|
// and an even generation; the reader copies the block and re-checks the generation, retrying
|
|
// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the
|
|
// reader: after the retry budget it reports "nothing new" and the caller keeps its last good
|
|
// snapshot rather than spinning on the audio thread.
|
|
//
|
|
// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the
|
|
// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the
|
|
// block, and a reader then accepts a torn block as coherent. Every publisher must serialize.
|
|
//
|
|
// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock:
|
|
// the fences give correct ordering, but the concurrent read of a non-atomic object is a data
|
|
// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not
|
|
// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain
|
|
// value the audio thread can copy in one shot.
|
|
//
|
|
// The writer interface deliberately assumes NO particular thread beyond single-writer, so a
|
|
// host's own parameter-change queue (delivered on the audio thread with sample offsets) can
|
|
// drive it later without a redesign.
|
|
class LiveParams {
|
|
public:
|
|
// A generation of 0 means "never published"; the first publish lands on 2.
|
|
void publish(const LiveValues& values) {
|
|
const std::uint32_t gen = seq_.load(std::memory_order_relaxed);
|
|
seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress
|
|
std::atomic_thread_fence(std::memory_order_release);
|
|
values_ = values;
|
|
std::atomic_thread_fence(std::memory_order_release);
|
|
// Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and
|
|
// stall every reader until the NEXT publish — a silent mode, unlike a loud one.
|
|
const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2;
|
|
seq_.store(next, std::memory_order_release); // even: complete and coherent
|
|
}
|
|
|
|
// The last generation published, without copying the block — one relaxed load, so a reader
|
|
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
|
|
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
|
|
|
|
// Copies the block into `out` and returns the generation actually observed, or 0 when
|
|
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
|
|
// a torn copy and MUST be discarded — compare the return against 0 before using it).
|
|
std::uint32_t read(LiveValues& out, int maxAttempts = 4) const {
|
|
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
|
|
const std::uint32_t before = seq_.load(std::memory_order_acquire);
|
|
if (before == 0) return 0; // never published
|
|
if ((before & 1u) != 0u) continue; // writer mid-update
|
|
std::atomic_thread_fence(std::memory_order_acquire);
|
|
out = values_;
|
|
std::atomic_thread_fence(std::memory_order_acquire);
|
|
if (seq_.load(std::memory_order_relaxed) == before) return before;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
private:
|
|
std::atomic<std::uint32_t> seq_{0};
|
|
LiveValues values_{};
|
|
};
|
|
|
|
// 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
|