fix(vst): pin output bus to stereo (mode is decode-only) killing the hard-right pan; auto-default channel mode from the loaded capture (state v9)

This commit is contained in:
2026-07-28 06:19:28 -04:00
parent 104a25f390
commit 3e6629a867
7 changed files with 212 additions and 74 deletions
+4
View File
@@ -248,6 +248,10 @@ void ReaSamplerEditor::commitAndReload() {
processor_->setSelectedSampleId(selectedId_); processor_->setSelectedSampleId(selectedId_);
processor_->setPerformanceMap(map_); processor_->setPerformanceMap(map_);
processor_->reloadFromBank(); processor_->reloadFromBank();
// GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's
// channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode
// the engine actually decoded with.
channelMode_ = processor_->channelMode();
#ifdef _WIN32 #ifdef _WIN32
invalidate(); invalidate();
#endif #endif
+51 -47
View File
@@ -11,13 +11,10 @@
#include "pluginterfaces/base/ibstream.h" #include "pluginterfaces/base/ibstream.h"
#include "pluginterfaces/vst/ivstaudioprocessor.h" #include "pluginterfaces/vst/ivstaudioprocessor.h"
#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate)
#include "pluginterfaces/vst/ivstevents.h" #include "pluginterfaces/vst/ivstevents.h"
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) #include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
#include "pluginterfaces/vst/vstspeaker.h" #include "pluginterfaces/vst/vstspeaker.h"
#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr)
#include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) #include "assignment_request.h" // decodeAssignmentRequest (S8 request wire parse)
#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
@@ -121,14 +118,18 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
bridge_.connect(context); bridge_.connect(context);
// Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no
// audio input. The output arrangement follows the instance's channel mode (S7) — mono by // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of
// default (kMono), stereo (kStereo) when the mode is stereo. addAudioOutput needs an initial // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders
// arrangement; seed it at the mode's arrangement so getBusInfo is correct from the first // dual-mono through the stereo bus (both channels equal, centered), which is audibly
// query. (setState may later flip the mode and re-negotiate via setChannelMode.) // identical to a mono bus but never asks the host to re-map a live instance's pins. The
// prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every
// mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The
// in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer
// write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's
// re-routing of the live instance's pins across the arrangement change. A fixed arrangement
// is the maximally-standard VSTi shape and removes that whole negotiation surface.
addEventInput(STR16("MIDI In"), 16); addEventInput(STR16("MIDI In"), 16);
const ChannelMode mode = channelMode(); addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo);
addAudioOutput(STR16("Audio Out"),
mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono);
return kResultOk; return kResultOk;
} }
@@ -209,14 +210,13 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
std::lock_guard<std::mutex> lock(assignMarkerMutex_); std::lock_guard<std::mutex> lock(assignMarkerMutex_);
lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration;
} }
// Restore the S7 channel mode and point the output bus at its arrangement so a reopened // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see
// project comes back in the saved mode. setState runs before the host queries bus info, so // initialize) — the mode only governs how the reload below decodes, so no bus work here.
// seeding the arrangement here (rather than re-negotiating) is enough — no restartComponent.
{ {
std::lock_guard<std::mutex> lock(channelModeMutex_); std::lock_guard<std::mutex> lock(channelModeMutex_);
channelMode_ = cs.channelMode; channelMode_ = cs.channelMode;
channelModeExplicit_ = cs.channelModeExplicit;
} }
applyOutputArrangement(cs.channelMode);
// S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2
// the editor's velocity knob is a concurrent UI-thread writer. // the editor's velocity knob is a concurrent UI-thread writer.
{ {
@@ -251,7 +251,12 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
ComponentState state_out; ComponentState state_out;
state_out.selectionId = selectedSampleId(); state_out.selectionId = selectedSampleId();
state_out.map = performanceMap(); state_out.map = performanceMap();
state_out.channelMode = channelMode(); // S7: persist the per-instance mono/stereo mode {
// S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9).
std::lock_guard<std::mutex> lock(channelModeMutex_);
state_out.channelMode = channelMode_;
state_out.channelModeExplicit = channelModeExplicit_;
}
{ {
std::lock_guard<std::mutex> lock(assignMarkerMutex_); std::lock_guard<std::mutex> lock(assignMarkerMutex_);
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
@@ -397,49 +402,32 @@ void ReaSamplerProcessor::previewNoteOff(int note) {
previewOffRequest_.store(packed, std::memory_order_release); previewOffRequest_.store(packed, std::memory_order_release);
} }
void ReaSamplerProcessor::applyOutputArrangement(ChannelMode mode) {
// Set the single output bus's SpeakerArrangement to the mode's arrangement so getBusInfo /
// getBusArrangement report the right channel count. The default getBusArrangement (from the
// base) reads back exactly what we store here. No re-negotiation — the caller drives that.
BusList* outs = getBusList(kAudio, kOutput);
if (!outs || outs->empty()) return;
if (auto* bus = FCast<AudioBus>(outs->at(0))) {
bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo
: SpeakerArr::kMono);
}
}
void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
{ {
std::lock_guard<std::mutex> lock(channelModeMutex_); std::lock_guard<std::mutex> lock(channelModeMutex_);
if (channelMode_ == mode) return; // no-op: don't churn the bus / re-negotiate // The editor toggle is a DELIBERATE choice either way: latch explicit even on a
// same-mode click (the user confirmed the mode; the GA auto-default stops fighting it).
channelModeExplicit_ = true;
if (channelMode_ == mode) return; // no decode change: don't churn a reload
channelMode_ = mode; channelMode_ = mode;
} }
// The mode changed: repoint the output bus and ask the host to re-negotiate I/O so REAPER's // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no
// routing follows (mono<->stereo). restartComponent is a main/UI-thread call; setChannelMode // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread
// is driven from the editor, so this is safe. Then reload so the next block decodes the new // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering.
// channel count into the LoadedInstrument (off-thread, RT path untouched).
applyOutputArrangement(mode);
if (componentHandler) componentHandler->restartComponent(kIoChanged);
reloadFromBank(); reloadFromBank();
} }
tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
SpeakerArrangement* inputs, int32 numIns, SpeakerArrangement* inputs, int32 numIns,
SpeakerArrangement* outputs, int32 numOuts) { SpeakerArrangement* outputs, int32 numOuts) {
// The instrument has ONE canonical arrangement per its channel mode (S7). We take NO audio // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a
// input, so any inputs are rejected. For the single output bus: accept (kResultTrue) only // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected.
// when the host proposes exactly the mode's arrangement; otherwise reject (kResultFalse) but // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse)
// KEEP the mode's arrangement (per the VST3 contract, a plug-in that can't honor a proposal // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a
// keeps a valid arrangement of its own). getBusArrangement then still reports the mode's // proposal keeps a valid arrangement of its own) — the host adapts its routing to us.
// channel count, so the host adapts its routing to us rather than forcing our channel count.
if (numIns < 0 || numOuts < 0) return kInvalidArgument; if (numIns < 0 || numOuts < 0) return kInvalidArgument;
if (numIns > 0) return kResultFalse; // no audio input bus to arrange if (numIns > 0) return kResultFalse; // no audio input bus to arrange
if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue;
const SpeakerArrangement want =
channelMode() == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono;
applyOutputArrangement(channelMode()); // keep the bus pinned to the mode's arrangement
if (numOuts == 1 && outputs && outputs[0] == want) return kResultTrue;
return kResultFalse; return kResultFalse;
} }
@@ -459,8 +447,9 @@ std::string ReaSamplerProcessor::reloadFromBank() {
bridge_.readReasamplerExtState(kProjExtBanksKey); bridge_.readReasamplerExtState(kProjExtBanksKey);
const std::string projectDir = bridge_.activeProjectDir(); const std::string projectDir = bridge_.activeProjectDir();
// The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
// Read once under its mutex, off the audio thread, before the decode loop. // Read once under its mutex, off the audio thread, before the decode loop. The single-
const ChannelMode mode = channelMode(); // capture branch below may auto-default it (GA) before its decode.
ChannelMode mode = channelMode();
// Phase S: snapshot the voice-system parameters once — they are baked into the built // Phase S: snapshot the voice-system parameters once — they are baked into the built
// engine's construction (the engine's config is immutable; a later change rebuilds). // engine's construction (the engine's config is immutable; a later change rebuilds).
int builtVoiceCount = kDefaultVoiceCount; int builtVoiceCount = kDefaultVoiceCount;
@@ -516,6 +505,18 @@ std::string ReaSamplerProcessor::reloadFromBank() {
std::optional<SelectedSample> sel = std::optional<SelectedSample> sel =
selectSample(*banksJson, selectedSampleId()); selectSample(*banksJson, selectedSampleId());
if (sel) { if (sel) {
// GA auto-default: while the channel mode is IMPLICIT (never user-toggled),
// follow the loaded capture's channel count — a stereo capture decodes (and
// shows) Stereo, a mono one Mono. An unknown count (0, an older bank entry)
// changes nothing; an explicit user choice is never fought. Decode-only: the
// output bus is fixed stereo, so no bus work follows a flip.
if (sel->channelCount > 0) {
const ChannelMode desired = sel->channelCount >= 2 ? ChannelMode::Stereo
: ChannelMode::Mono;
std::lock_guard<std::mutex> lock(channelModeMutex_);
if (!channelModeExplicit_) channelMode_ = desired;
mode = channelMode_;
}
std::optional<DecodedZonePcm> pcm = std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, sel->relativePath, mode); decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) { if (pcm) {
@@ -887,6 +888,9 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here.
float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr;
// The PLUG-IN owns output silenceFlags (VST3 contract). Claim non-silence on every rendered
// block — a stale host-side flag left unwritten could mute a channel downstream (GA).
out.silenceFlags = 0;
if (ch0 && ch1) { if (ch0 && ch1) {
// Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo
// path (both channels equal), so a mono capture in stereo mode is centered, not silent. // path (both channels equal), so a mono capture in stereo mode is centered, not silent.
+16 -15
View File
@@ -102,12 +102,11 @@ public:
Steinberg::tresult PLUGIN_API process( Steinberg::tresult PLUGIN_API process(
Steinberg::Vst::ProcessData& data) override; Steinberg::Vst::ProcessData& data) override;
// S7 channel-mode bus negotiation. The instrument has ONE canonical output arrangement // Output-bus negotiation. The instrument has ONE canonical output arrangement: a FIXED
// determined by its per-instance channel mode (mono -> kMono, stereo -> kStereo). We // stereo bus (GA fix — the channel mode is a decode policy, never a bus fact; mono mode
// accept the host's proposal only when it matches that arrangement; otherwise we reject // renders dual-mono through it). We accept the host's proposal only when it is a single
// (kResultFalse) but keep the mode's arrangement, so getBusArrangement / getBusInfo always // stereo output; otherwise we reject (kResultFalse) but keep our stereo arrangement, so
// report the mode's channel count and REAPER routes accordingly. A runtime mode change // getBusArrangement / getBusInfo always report 2 channels and the host routes accordingly.
// updates the output bus + calls restartComponent(kIoChanged) to trigger re-negotiation.
Steinberg::tresult PLUGIN_API setBusArrangements( Steinberg::tresult PLUGIN_API setBusArrangements(
Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns, Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns,
Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override; Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override;
@@ -184,11 +183,14 @@ public:
// (the editor toggle) and read off-thread by getState/reloadFromBank; guarded by // (the editor toggle) and read off-thread by getState/reloadFromBank; guarded by
// channelModeMutex_. NEVER read on the audio thread — process() renders against the host's // channelModeMutex_. NEVER read on the audio thread — process() renders against the host's
// negotiated output channel count, and reloadFromBank bakes the mode into the decode. // negotiated output channel count, and reloadFromBank bakes the mode into the decode.
// GA fix: the mode is a DECODE policy only (downmix vs L/R split). The output bus is a
// FIXED stereo bus — mono mode renders dual-mono through it (centered) — so a mode change
// never renegotiates host I/O (the mono<->stereo bus flip's live pin remap was the
// hard-right-pan defect).
ChannelMode channelMode(); ChannelMode channelMode();
// Sets the mode. When it CHANGES, updates the output bus arrangement (mono->kMono / // Sets the mode from the EDITOR TOGGLE (a deliberate user choice): latches the mode
// stereo->kStereo) and asks the host to re-negotiate I/O via restartComponent(kIoChanged), // EXPLICIT (the GA auto-default stops fighting it), and on a CHANGE reloads the instrument
// then reloads the instrument so the next block decodes the new channel count. A no-op set // so the next block decodes the new channel count. UI thread only.
// (same mode) does neither. UI thread only.
void setChannelMode(ChannelMode mode); void setChannelMode(ChannelMode mode);
// The per-instance preview-trigger velocity (S-VIEW-4, MIDI 1..127). Read/written on the // The per-instance preview-trigger velocity (S-VIEW-4, MIDI 1..127). Read/written on the
@@ -234,11 +236,6 @@ public:
void previewNoteOff(int note); void previewNoteOff(int note);
private: private:
// Apply `mode` to the output audio bus's SpeakerArrangement (kMono / kStereo). Called from
// initialize (topology) and setChannelMode (runtime change). Does NOT re-negotiate — the
// caller drives restartComponent when appropriate.
void applyOutputArrangement(ChannelMode mode);
// Phase S drain retirement (FA1-review Major #2): if process() has published that the // Phase S drain retirement (FA1-review Major #2): if process() has published that the
// CURRENT drain instrument is fully idle (every engine voice + the preview card silent), // CURRENT drain instrument is fully idle (every engine voice + the preview card silent),
// move it out of the drain slot into the graveyard and prune — so an edited-away snapshot // move it out of the drain slot into the graveyard and prune — so an edited-away snapshot
@@ -330,8 +327,12 @@ private:
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadFromBank); // The per-instance channel mode (S7). Off-thread only (UI + getState + reloadFromBank);
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read // guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
// on the audio thread — process renders against the host's negotiated output channel count. // on the audio thread — process renders against the host's negotiated output channel count.
// channelModeExplicit_ (GA, persisted v9): false = the mode is an un-touched default that
// reloadFromBank may auto-default from the loaded capture's channel count; true = the user
// deliberately toggled the mode (setChannelMode latches it) and it is never fought.
std::mutex channelModeMutex_; std::mutex channelModeMutex_;
ChannelMode channelMode_ = ChannelMode::Mono; ChannelMode channelMode_ = ChannelMode::Mono;
bool channelModeExplicit_ = false;
// The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in // The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in
// component state (v5) so a re-open does not re-apply a request the user already got and // component state (v5) so a re-open does not re-apply a request the user already got and
+18 -3
View File
@@ -36,6 +36,7 @@ SelectedSample distill(const Sample& s) {
out.relativePath = s.relativePath; out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60; out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s); out.loop = loopFromSample(s);
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry)
return out; return out;
} }
@@ -629,6 +630,11 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
if (g > maxLin) g = maxLin; if (g > maxLin) g = maxLin;
putU64le(out, doubleToBits(g)); putU64le(out, doubleToBits(g));
} }
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag,
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's
// channel count); 1 = the user deliberately toggled the mode (never fought).
out.push_back(state.channelModeExplicit ? 1 : 0);
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
// unlike the v1 selection blob where the id ran to end-of-stream). // unlike the v1 selection blob where the id ran to end-of-stream).
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size())); putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
@@ -705,12 +711,13 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4) return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
} }
if (version != kComponentStateVersion && if (version != kComponentStateVersion &&
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
version != kSelectionZonesModeMarkerVelVoiceV7Version && version != kSelectionZonesModeMarkerVelVoiceV7Version &&
version != kSelectionZonesModeMarkerVelV6Version) { version != kSelectionZonesModeMarkerVelV6Version) {
return out; // unknown -> empty return out; // unknown -> empty
} }
// v6/v7/v8 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, // v6/v7/v8/v9 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated // then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated
// as mono (conservative default) rather than rejected — a corrupt mode never silences the // as mono (conservative default) rather than rejected — a corrupt mode never silences the
// instance. // instance.
@@ -741,10 +748,10 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
} }
// v8 (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction // v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or // default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting. // above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting.
if (version == kComponentStateVersion) { if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
const double g = bitsToDouble(r.u64()); const double g = bitsToDouble(r.u64());
if (!r.ok) return out; // truncated inside the gain double — unity holds (out already if (!r.ok) return out; // truncated inside the gain double — unity holds (out already
// carries mode/marker/velocity/voice fields from above) // carries mode/marker/velocity/voice fields from above)
@@ -753,6 +760,14 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
? g ? g
: 1.0; : 1.0;
} }
// v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
// default (false = implicit) holds, so an already-saved instance's mode is treated as the
// un-touched default and the shell may auto-default it from the loaded capture.
if (version >= kComponentStateVersion) {
const std::uint8_t explicitByte = r.u8();
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
out.channelModeExplicit = (explicitByte == 1);
}
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
+24 -9
View File
@@ -36,6 +36,8 @@ struct SelectedSample {
std::string relativePath; // project-relative; the shell resolves it (M4 convention) std::string relativePath; // project-relative; the shell resolves it (M4 convention)
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older
// bank entries) — the GA channel-mode auto-default skips it
}; };
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks" // Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
@@ -447,25 +449,29 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
// state), never auto-playing sample #1. // state), never auto-playing sample #1.
// //
// Format (envelope v8): 4-byte LE version tag (== 8), then a 1-byte channel-mode field (0 = mono, // Format (envelope v9): 4-byte LE version tag (== 9), then a 1-byte channel-mode field (0 = mono,
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a // 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system // 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono // bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 // trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then a 4-byte LE // double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
// mode — see ComponentState::channelModeExplicit), then a 4-byte LE
// selection-id length + id bytes, then the CURRENT zones payload (identical to // selection-id length + id bytes, then the CURRENT zones payload (identical to
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). // serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
// The master-gain double is the ONLY envelope-v8 addition over v7 — the envelope grew a field, // The explicit flag is the ONLY envelope-v9 addition over v8 — the envelope grew a field,
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own // the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload // versioning; the two version numbers are independent axes — do NOT bump the zones-payload
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range // version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing // master-gain double (a corrupt blob) falls back to the field's default rather than silencing
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to // the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = // channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, and unity // kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
// master gain — which reproduce pre-v8 behavior exactly — preserving current behavior for // master gain, and channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
// already-saved instances): // un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones} direct. // deliberately chosen a mode re-toggles once and the choice persists explicit from then on):
// * v9 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, selectionId, zones} direct.
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). // * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). // * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). // * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
@@ -489,7 +495,12 @@ inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
struct ComponentState { struct ComponentState {
std::string selectionId; // the single-capture pick; "" = no pick std::string selectionId; // the single-capture pick; "" = no pick
PerformanceMap map; // the opt-in zones; empty = no zones PerformanceMap map; // the opt-in zones; empty = no zones
ChannelMode channelMode = ChannelMode::Mono; // S7 output mode; default mono (D-E) ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling // S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's // of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
@@ -509,7 +520,11 @@ struct ComponentState {
double masterGainLinear = 1.0; double masterGainLinear = 1.0;
}; };
inline constexpr std::uint32_t kComponentStateVersion = 8; inline constexpr std::uint32_t kComponentStateVersion = 9;
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + // The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can // preview velocity + voice system, no master gain). Retained so deserializeComponentState can
+69
View File
@@ -129,6 +129,23 @@ static void testSelectNoLoopIsAbsent() {
CHECK(sel && !sel->loop.hasLoop); // absent loop -> hasLoop false (not a zero loop) CHECK(sel && !sel->loop.hasLoop); // absent loop -> hasLoop false (not a zero loop)
} }
static void testSelectChannelCountThreaded() {
// GA: the bank's capture channel-count intrinsic rides SelectedSample so the shell can
// auto-default the channel mode (stereo capture -> Stereo). An entry without the
// intrinsic yields 0 (unknown — the auto-default skips it).
Sample st = makeSample("st", "Wide", "reasampler_bank/st.wav", 60);
st.channelCount = 2;
Sample mo = makeSample("mo", "Narrow", "reasampler_bank/mo.wav", 60);
mo.channelCount = 1;
const std::string json = bookJson({st, mo, makeSample("un", "Old", "reasampler_bank/un.wav", 60)}, {});
auto selSt = selectSample(json, "st");
CHECK(selSt && selSt->channelCount == 2);
auto selMo = selectSample(json, "mo");
CHECK(selMo && selMo->channelCount == 1);
auto selUn = selectSample(json, "un");
CHECK(selUn && selUn->channelCount == 0); // unstamped -> unknown, never a guess
}
static void testSelectEmptyBlob() { static void testSelectEmptyBlob() {
CHECK(!selectSample("", "a").has_value()); CHECK(!selectSample("", "a").has_value());
} }
@@ -1320,6 +1337,55 @@ static void testComponentStateV8TruncatedMasterGain() {
CHECK(back.selectionId.empty() && back.map.zones.empty()); CHECK(back.selectionId.empty() && back.map.zones.empty());
} }
// --- v9 component state: the GA channel-mode explicit flag ------------------------------------
static void testComponentStateChannelModeExplicitRoundTrip() {
// The explicit flag survives a round-trip in BOTH states, its envelope neighbours intact.
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.channelModeExplicit = true;
s.masterGainLinear = 0.5;
ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.channelModeExplicit);
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(std::fabs(back.masterGainLinear - 0.5) < 1e-12);
CHECK(back.selectionId == "pick");
s.channelModeExplicit = false;
back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(!back.channelModeExplicit); // implicit round-trips too (not defaulted-true)
CHECK(back.channelMode == ChannelMode::Stereo);
}
static void testComponentStateV8LiftsImplicitChannelMode() {
// A GENUINE v8 blob (version tag 8: mode, marker, velocity, voice bytes, gain, id, zones —
// NO explicit flag) lifts to channelModeExplicit = FALSE: a pre-GA mode byte is treated as
// the un-touched default so the shell's auto-default may follow the loaded capture. Hand-
// built (serializeComponentState now emits v9, so it cannot make a v8 blob).
std::vector<std::uint8_t> v8;
v8.push_back(8); v8.push_back(0); v8.push_back(0); v8.push_back(0); // version 8
v8.push_back(0); // channel mode = mono
for (int i = 0; i < 8; ++i) v8.push_back(0); // marker = 0
v8.push_back(88); // preview velocity
v8.push_back(7); // voice count
v8.push_back(0); // voice mode = poly
v8.push_back(0); // trigger = retrigger
for (int i = 0; i < 8; ++i) v8.push_back(0); // gain double bytes...
v8[17 + 6] = 0xF0; v8[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754)
const std::string id = "saved";
v8.push_back(static_cast<std::uint8_t>(id.size())); v8.push_back(0); v8.push_back(0); v8.push_back(0);
v8.insert(v8.end(), id.begin(), id.end());
v8.push_back(0); v8.push_back(0); v8.push_back(0); v8.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v8, 44100.0);
CHECK(!back.channelModeExplicit); // pre-GA blob -> implicit (auto-default allowed)
CHECK(back.channelMode == ChannelMode::Mono);
CHECK(back.masterGainLinear == 1.0);
CHECK(back.previewVelocity == 88);
CHECK(back.voiceCount == 7);
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ---------------- // --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
// //
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and // The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
@@ -2033,6 +2099,7 @@ int main() {
testSelectRootNoteDefault(); testSelectRootNoteDefault();
testSelectLoopThreaded(); testSelectLoopThreaded();
testSelectNoLoopIsAbsent(); testSelectNoLoopIsAbsent();
testSelectChannelCountThreaded();
testSelectEmptyBlob(); testSelectEmptyBlob();
testSelectMalformedBlob(); testSelectMalformedBlob();
testSelectZeroSamples(); testSelectZeroSamples();
@@ -2136,6 +2203,8 @@ int main() {
testComponentStateV7LiftsUnityMasterGain(); testComponentStateV7LiftsUnityMasterGain();
testComponentStateV8CorruptMasterGainFallsBack(); testComponentStateV8CorruptMasterGainFallsBack();
testComponentStateV8TruncatedMasterGain(); testComponentStateV8TruncatedMasterGain();
testComponentStateChannelModeExplicitRoundTrip();
testComponentStateV8LiftsImplicitChannelMode();
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay(); testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
testReconcileKeepsOnlySelectedFullRangeZone(); testReconcileKeepsOnlySelectedFullRangeZone();
+30
View File
@@ -851,6 +851,35 @@ static void testMonoSamplePlaysDualMonoInStereo() {
} }
} }
static void testDualMonoStereoSampleRendersCentered() {
// GA Bug 1 (pure-layer proof): a STEREO sample whose two channels are IDENTICAL (a
// dual-mono capture) must render EXACTLY equal L and R — bitwise, every frame — under
// BOTH pitch engines. Any asymmetry here (a silent L, a channel offset, divergent
// shifter state) would pan the output; hard-panned output from a dual-mono capture
// therefore cannot originate in the engine.
auto renderBoth = [](PitchEngine engine, int note) {
SampleData s = sineSample(600, 12.0, 60);
s.framesR = s.frames; // dual-mono: identical channels
s.play.pitchEngine = engine;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*preserveWindowFrames=*/128);
eng.noteOn(note, 127);
std::vector<AudioSample> left(256, 0.f), right(256, 0.f);
eng.render(left.data(), right.data(), 256);
bool sound = false, equal = true;
for (std::size_t i = 0; i < left.size(); ++i) {
if (left[i] != 0.0f) sound = true;
if (left[i] != right[i]) equal = false; // EXACT: dual-mono must be centered
}
CHECK(sound); // the render actually produced signal (a 0==0 pass would be vacuous)
CHECK(equal);
};
renderBoth(PitchEngine::Varispeed, 60);
renderBoth(PitchEngine::Varispeed, 67); // off-root: repitch rides both channels equally
renderBoth(PitchEngine::Preserve, 60); // both shifters run (no unity demotion in MIDI)
renderBoth(PitchEngine::Preserve, 67); // off-root Preserve: per-channel shift, same state
}
static void testMonoRenderUnchangedByStereoData() { static void testMonoRenderUnchangedByStereoData() {
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical // Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
// whether or not a second channel is present. A stereo sample rendered mono == its L channel. // whether or not a second channel is present. A stereo sample rendered mono == its L channel.
@@ -1975,6 +2004,7 @@ int main() {
testChannelCount(); testChannelCount();
testStereoRenderKeepsChannelsDistinct(); testStereoRenderKeepsChannelsDistinct();
testMonoSamplePlaysDualMonoInStereo(); testMonoSamplePlaysDualMonoInStereo();
testDualMonoStereoSampleRendersCentered();
testMonoRenderUnchangedByStereoData(); testMonoRenderUnchangedByStereoData();
testStereoRenderAdvancesLikeMonoRepitch(); testStereoRenderAdvancesLikeMonoRepitch();
testStereoRenderSumsVoicesPerChannel(); testStereoRenderSumsVoicesPerChannel();