instrument: narrow the live-param publish lock to its own mutex, off the reload's decode

Knob-drag publishes no longer block behind a full reload's WAV decode — a dedicated livePublishMutex_ replaces reloadMutex_ for the seqlock's single-writer contract. Also fixes an editor comment overclaim and two doc restatements.
This commit is contained in:
2026-07-30 21:54:20 -04:00
parent bbc7dc70bb
commit efd1e41f46
6 changed files with 26 additions and 21 deletions
+2 -7
View File
@@ -161,13 +161,8 @@ must live compute, latching the parameters at note on is not acceptable. long te
automatable parameters."* It rejects the precedent, not one instance of it.
- **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` and
`liveCommitFor` (`ui/deck_groups`), whose header is the home for why each excluded control is
excluded. Continuous playback controls go live: the six filter tone/modulation knobs, and
every stage time and stage level on all three envelopes. Everything else reloads or drops to
the voice-param rebuild. **FIVE continuous controls are outside the live set** — pitch
key-track, velocity→cutoff depth, and Trigger's %-length, fade-in and fade-out — so a
Trigger-mode instance gets no live delivery on its amplitude controls at all; only the filter
and pitch-envelope knobs move a sounding one-shot.
`liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and
why each exclusion is excluded — see there rather than restating the list here.
- **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one
block the shell owns per instance. The member-ordering constraint that enforces it, and why,
are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice
+7 -3
View File
@@ -219,9 +219,13 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
}
if (self->drag_ != DragKind::kNone) {
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
// voice count / master gain) mutate no parameter, so dragStartParams_ is
// not a rollback target for them — reset drag state only. Every
// parameter-editing drag restores the pre-grab snapshot.
// voice count / master gain) mutate no params_ field, so dragStartParams_
// is not a rollback target for them — reset drag state only. Every
// params_-editing drag restores the pre-grab snapshot. Voice count and
// master gain are pre-existing exceptions to that: both write straight
// through on every move (editor voiceCount_ / processor masterGain_)
// rather than through params_, so an abandoned drag leaves them at the
// abandoned value indefinitely instead of rolling back.
const bool transient = self->drag_ == DragKind::kScrollThumb ||
(self->drag_ == DragKind::kDeckKnob &&
(self->dragParamId_ == -2 ||
+6 -1
View File
@@ -154,7 +154,12 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// frames the build resolved and a note-on with a live block sounds identical
// to one without.
sample.live = &liveParams_;
liveParams_.publish(instrument::engine::foldLive(sample.play));
{
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play));
}
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
}
+6 -9
View File
@@ -153,17 +153,14 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
}
void ReaSamplerProcessor::publishLiveParams() {
// reloadMutex_ enforces the block's SINGLE-WRITER contract (live_params.h), not the
// reload's slot bookkeeping: reloadInstrument publishes the block too, and two concurrent
// seqlock writers can leave the generation even mid-write, which a reader would accept as
// a coherent — but torn — block. The audio thread never takes this mutex, so the cost is
// an off-thread wait behind a reload. Lock order matches reloadInstrument's
// (reloadMutex_ then paramsMutex_, taken by instrumentParams below).
std::lock_guard<std::mutex> lock(reloadMutex_);
const int rate = builtSampleRate_.load(std::memory_order_relaxed);
if (rate <= 0) return;
liveParams_.publish(
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate)));
const instrument::engine::LiveValues block =
instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate));
// livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against
// reloadInstrument's publish — held for the publish call only, not the fold above.
std::lock_guard<std::mutex> lock(livePublishMutex_);
liveParams_.publish(block);
}
SampleRefs ReaSamplerProcessor::sampleRefs() {
@@ -239,6 +239,11 @@ private:
// Both live_ and draining_ observe this same block — a block owned by a snapshot would
// leave the drain's still-sounding voices deaf to the knob under them.
instrument::engine::LiveParams liveParams_;
// Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams)
// only — separate from reloadMutex_ so a knob drag's publish never blocks behind a
// reload's WAV decode. The audio thread never takes this; process() only reads via
// LiveParams::read's lock-free seqlock retry.
std::mutex livePublishMutex_;
// The rate the loaded capture was decoded/built at, so a live republish resolves the
// stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing
// built yet.