Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
processor_hotswap_slot.hpp
Go to the documentation of this file.
1#pragma once
2
38
39#include <pulp/audio/buffer.hpp>
42#include <pulp/midi/buffer.hpp>
43
44#include <algorithm>
46
47#include <atomic>
48#include <cstddef>
49#include <cstdint>
50#include <memory>
51#include <mutex>
52#include <shared_mutex>
53#include <vector>
54
55namespace pulp::format::reload {
56
64
66public:
68 explicit ProcessorHotSwapSlot(std::unique_ptr<Processor> initial)
69 : active_(std::move(initial)) {}
70
79 midi::MidiBuffer& midi_in,
80 midi::MidiBuffer& midi_out,
81 const ProcessContext& ctx) {
82 std::shared_lock<std::shared_mutex> lock(mutex_, std::try_to_lock);
83 if (!lock.owns_lock() || !active_) {
84 passthrough(out, in);
85 contention_blocks_.fetch_add(1, std::memory_order_relaxed);
86 return;
87 }
88 active_->process(out, in, midi_in, midi_out, ctx);
89 mix_fade_out(out, in, ctx);
90 }
91
100 [[nodiscard]] std::unique_ptr<Processor> swap(std::unique_ptr<Processor> next) {
101 std::unique_ptr<Processor> displaced; // returned to caller (instant path)
102 std::unique_ptr<Processor> superseded; // a collapsed prior fade-out
103 {
104 std::unique_lock<std::shared_mutex> lock(mutex_);
105 // DSP-state carry: holding the writer lock proves no audio
106 // reader is inside the old processor, so it is safe to serialize its
107 // DSP state (delay tails / filter history / phase) and restore it into
108 // the incoming one — off the audio thread. Opt-in + cold-start-safe:
109 // an empty blob or a false restore leaves `next` freshly prepared and
110 // never fails the swap. Skipped on the first install (no old to carry
111 // from). The blob is copied while the lock is held, so a plugin that
112 // opts in should keep it bounded (a longer copy → a few more audio
113 // passthrough blocks during the swap, which the crossfade masks).
114 if (active_ && next) {
115 // Best-effort: a THROW (not just a false return) must also degrade
116 // to cold-start, per the contract — otherwise a serialize/restore
117 // bug would reject the whole reload (and a throwing serialize on
118 // the already-live OLD processor would block every future reload).
119 try {
120 const std::vector<std::byte> blob = active_->serialize_dsp_state();
121 if (!blob.empty()) next->restore_dsp_state(blob);
122 } catch (...) {
123 // Swallow: state-carry failed → `next` stays freshly prepared.
124 }
125 }
126 if (!crossfade_ready()) {
127 displaced = std::move(active_);
128 active_ = std::move(next);
129 } else {
130 // Collapse any still-pending fade-out: it is fully superseded.
131 // We hold the writer lock so no audio reader is inside it; hand
132 // it out to be freed AFTER unlock (not under the lock — a slow
133 // ~Processor() would lengthen the audio thread's passthrough
134 // contention window, as the instant path already avoids).
135 // MID-FADE RE-SWAP CONTRACT: with a SINGLE fade slot,
136 // a re-swap arriving WITHIN the ~12ms window can only keep one
137 // fade-out — the just-displaced processor — and must drop the
138 // older fade-out's residual contribution, so the output can STEP
139 // at the re-swap instant (bounded, not click-free). This is
140 // deliberate: click-free chaining of overlapping fades needs a
141 // SECOND fade-out slot + buffer (double the RT cost + memory) to
142 // handle an event that is human-file-save paced (far wider than
143 // the window) — not worth it. What IS guaranteed and tested
144 // (test_hotswap_slot "swap during a fade …"): RT-safe (no
145 // alloc/free/leak — the superseded fade-out is reclaimed on the
146 // control thread), output stays finite + bounded across the
147 // re-swap, and the fade always settles to the NEWEST processor.
148 // Restarting the new fade at g=0 (below) is the right single-slot
149 // choice: keeping the ramp position or seeding a partial gain both
150 // measured WORSE late in the fade (the common case). See 1.7b.
151 superseded = std::move(fading_out_);
152 fading_out_ = std::move(active_); // keep the just-displaced DSP for the fade
153 active_ = std::move(next);
154 fade_mixer_.configure(fade_samples_, fade_mixer_.curve());
155 // Nothing to fade from on the first install → mark done immediately.
156 fade_done_.store(fading_out_ == nullptr, std::memory_order_release);
157 }
158 }
159 return displaced; // nullptr on the crossfade path (slot retains the fade-out)
160 }
161
166 void reclaim() {
167 std::unique_lock<std::shared_mutex> lock(mutex_);
168 if (fading_out_ && fade_done_.load(std::memory_order_acquire)) {
169 auto old = std::move(fading_out_);
170 lock.unlock();
171 old.reset(); // ~Processor() here — control thread
172 }
173 }
174
182 std::unique_lock<std::shared_mutex> lock(mutex_);
183 if (active_) active_->prepare(ctx);
184 }
185
189 void set_crossfade_samples(std::size_t samples) {
190 std::unique_lock<std::shared_mutex> lock(mutex_);
191 fade_samples_ = samples;
192 }
193
199 std::unique_lock<std::shared_mutex> lock(mutex_);
200 fade_mixer_.set_curve(curve); // live: a mid-fade change re-maps gains
201 }
202
207 void prepare_crossfade(std::size_t max_frames, std::size_t max_channels) {
208 std::unique_lock<std::shared_mutex> lock(mutex_);
209 scratch_frames_ = max_frames;
210 scratch_channels_ = max_channels;
211 scratch_storage_.assign(max_frames * max_channels, 0.0f);
212 scratch_ptrs_.resize(max_channels);
213 for (std::size_t c = 0; c < max_channels; ++c)
214 scratch_ptrs_[c] = scratch_storage_.data() + c * max_frames;
215 // Bound the fade-out's MIDI buffers to their reserved capacity so a
216 // MIDI-emitting DSP (arp/MIDI-fx) rendered during the fade drops-and-
217 // counts on overflow instead of ALLOCATING on the audio thread. reserve()
218 // alone does not bound growth; the realtime-capacity-limit flag does.
219 fade_midi_in_.reserve(64, 0);
220 fade_midi_out_.reserve(64, 0);
221 fade_midi_in_.set_realtime_capacity_limit(true);
222 fade_midi_out_.set_realtime_capacity_limit(true);
223 }
224
226 bool has_active() const {
227 std::shared_lock<std::shared_mutex> lock(mutex_);
228 return active_ != nullptr;
229 }
230
253 template <class Fn>
254 auto with_active(Fn&& fn) const -> decltype(fn(std::declval<Processor&>())) {
255 std::shared_lock<std::shared_mutex> lock(mutex_);
256 if (active_) return fn(*active_);
257 return decltype(fn(std::declval<Processor&>())){};
258 }
259
261 bool crossfade_active() const {
262 std::shared_lock<std::shared_mutex> lock(mutex_);
263 return fading_out_ != nullptr && !fade_done_.load(std::memory_order_acquire);
264 }
265
268 std::uint64_t contention_blocks() const {
269 return contention_blocks_.load(std::memory_order_relaxed);
270 }
271
272private:
273 bool crossfade_ready() const { // caller holds the lock
274 return fade_samples_ > 0 && scratch_frames_ > 0 && scratch_channels_ > 0;
275 }
276
277 // Render the fading-out processor into scratch and mix it under a smoothstep
278 // ramp (old→new). Audio thread, under the shared lock. RT-safe: no alloc
279 // (scratch + midi reserved at prepare), no destroy (control thread reclaims).
280 void mix_fade_out(audio::BufferView<float>& out,
282 const ProcessContext& ctx) {
283 if (!fading_out_ || fade_done_.load(std::memory_order_relaxed)) return;
284 const std::size_t frames = out.num_samples();
285 if (fade_mixer_.length() == 0 || frames > scratch_frames_) {
286 // Safety floor: a block larger than the scratch (the host exceeded
287 // its declared max_buffer_size) can't be faded — finish now. This
288 // snaps to full-new mid-ramp (a click), but it only triggers on a
289 // misdeclared block size; prepare_crossfade() is sized to the host's
290 // worst case so the normal path never hits this.
291 fade_done_.store(true, std::memory_order_release);
292 return;
293 }
294 const std::size_t ch =
295 std::min({out.num_channels(), in.num_channels(), scratch_channels_});
296 audio::BufferView<float> sv(scratch_ptrs_.data(), ch, frames);
297 fade_midi_in_.clear();
298 fade_midi_out_.clear();
299 fading_out_->process(sv, in, fade_midi_in_, fade_midi_out_, ctx);
300
301 // Blend the fading-out (old) render into the live output over the shared
302 // TransitionMixer's curve — click-free at both ends. The same primitive
303 // drives the SignalGraph live plugin swap so both share one crossfade law.
304 if (signal::blend_fade_out(fade_mixer_, out, sv))
305 fade_done_.store(true, std::memory_order_release);
306 }
307
308 static void passthrough(audio::BufferView<float>& out,
309 const audio::BufferView<const float>& in) {
310 // Shared with the format adapters' state-restore gate so every
311 // "Processor unavailable this block" path degrades identically.
312 passthrough_block(out, in);
313 }
314
315 mutable std::shared_mutex mutex_;
316 std::unique_ptr<Processor> active_;
317 std::atomic<std::uint64_t> contention_blocks_{0};
318
319 // ── Crossfade state ──────────────────────────────────────────────────────
320 std::unique_ptr<Processor> fading_out_; // retained old DSP during a fade
321 std::size_t fade_samples_ = 0; // configured fade length (0 = instant)
322 signal::TransitionMixer fade_mixer_; // active fade: length + position + curve
323 std::atomic<bool> fade_done_{true}; // audio thread sets; reclaim() reads
324 std::vector<float> scratch_storage_; // [channels * frames], allocated off-RT
325 std::vector<float*> scratch_ptrs_;
326 std::size_t scratch_frames_ = 0;
327 std::size_t scratch_channels_ = 0;
328 midi::MidiBuffer fade_midi_in_; // empty MIDI for the fading-out tail
329 midi::MidiBuffer fade_midi_out_; // discarded
330};
331
332} // namespace pulp::format::reload
Non-owning view over multi-channel audio sample data.
Definition buffer.hpp:30
std::size_t num_samples() const
Definition buffer.hpp:84
std::size_t num_channels() const
Definition buffer.hpp:83
void reprepare_active(const PrepareContext &ctx)
Control-thread re-prepare of the live processor (e.g.
Definition processor_hotswap_slot.hpp:181
bool has_active() const
True when a processor is installed. Control-thread/diagnostic use.
Definition processor_hotswap_slot.hpp:226
auto with_active(Fn &&fn) const -> decltype(fn(std::declval< Processor & >()))
Invoke fn with the ACTIVE processor under the shared lock, returning its result (or a default-constru...
Definition processor_hotswap_slot.hpp:254
void prepare_crossfade(std::size_t max_frames, std::size_t max_channels)
Allocate the scratch the fade-out processor renders into, sized for the worst-case block.
Definition processor_hotswap_slot.hpp:207
void set_crossfade_samples(std::size_t samples)
Set the crossfade length in samples (0 = instantaneous swaps; the default).
Definition processor_hotswap_slot.hpp:189
bool crossfade_active() const
True while a crossfade is in progress (a fade-out is rendering).
Definition processor_hotswap_slot.hpp:261
ProcessorHotSwapSlot(std::unique_ptr< Processor > initial)
Definition processor_hotswap_slot.hpp:68
void reclaim()
Control-thread reclaim of a completed fade-out.
Definition processor_hotswap_slot.hpp:166
void set_crossfade_curve(CrossfadeCurve curve)
Select the crossfade amplitude law (default Smoothstep = equal-gain).
Definition processor_hotswap_slot.hpp:198
std::unique_ptr< Processor > swap(std::unique_ptr< Processor > next)
Control-thread swap.
Definition processor_hotswap_slot.hpp:100
void process(audio::BufferView< float > &out, const audio::BufferView< const float > &in, midi::MidiBuffer &midi_in, midi::MidiBuffer &midi_out, const ProcessContext &ctx)
Audio-thread entry.
Definition processor_hotswap_slot.hpp:77
std::uint64_t contention_blocks() const
Number of blocks that passed through due to swap contention / no active processor (dev metric).
Definition processor_hotswap_slot.hpp:268
Collection of timestamped MIDI events within a single audio buffer period.
Definition buffer.hpp:31
void clear()
Remove all events.
Definition buffer.hpp:118
std::size_t length() const
Definition transition_mixer.hpp:54
Definition build_fingerprint.hpp:23
signal::TransitionCurve CrossfadeCurve
The crossfade amplitude law is the shared signal::TransitionMixer primitive — extracted so the hot-sw...
Definition processor_hotswap_slot.hpp:63
void passthrough_block(audio::BufferView< Sample > &out, const audio::BufferView< const Sample > &in)
Copy input to output for a block the plug-in did not render.
Definition state_restore_gate.hpp:72
TransitionMixerT< float > TransitionMixer
Definition transition_mixer.hpp:83
TransitionCurve
Definition transition_mixer.hpp:34
bool blend_fade_out(Mixer &mixer, OutView &out, const OldView &old_render) noexcept
Blend an already-rendered fading-out buffer into the live output under a TransitionMixer's old->new g...
Definition transition_mixer.hpp:97
STL namespace.
Proving the audio thread is outside process() while plugin state is restored under it.
Definition prepare_resources.hpp:102
Process context — passed every audio callback with transport state.
Definition process_context.hpp:89
Shared click-free crossfade primitive.