instrument: deliver continuous playback params live to sounding voices via a seqlock block, holding normalized stage position across time edits

This commit is contained in:
2026-07-30 21:03:05 -04:00
parent 7bd911d58b
commit 1dade0bfcf
25 changed files with 1352 additions and 66 deletions
+118
View File
@@ -0,0 +1,118 @@
#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.
struct LiveValues {
filter::FilterSettings filterSettings{};
double filterModAmount = 0.0;
double filterKeyTrack = 0.0;
AdsrParams filterEnv{};
AdsrParams adsr{};
std::int64_t pitchEnvAttackFrames = 0;
std::int64_t pitchEnvDecayFrames = 0;
double pitchEnvPeakSemitones = 0.0;
};
// 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");
// 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.
LiveValues foldLive(const PlayParams& params);
// 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.
//
// 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);
seq_.store(gen + 2, std::memory_order_release); // even: complete and coherent
}
// 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