diff --git a/src/instrument_drop.cpp b/src/instrument_drop.cpp index c7e698e..9db7320 100644 --- a/src/instrument_drop.cpp +++ b/src/instrument_drop.cpp @@ -76,10 +76,11 @@ std::vector buildVstPresetBytes( std::vector instrumentDropStateBytes(const std::string& sampleId) { // The ONE fact the drop carries: this capture is the instance's selection. Everything - // else stays at the fresh-instance defaults (no zones, mono, generation 0) — the same - // ComponentState a browser click would produce. serializeComponentState is the - // instrument's own writer (the single source of truth for the byte layout), so this is - // NOT a parallel encoder — it IS the instrument's encoder. + // else stays at the fresh-instance defaults (no zones, implicit channel mode, generation + // 0) — the same ComponentState a browser click would produce. The implicit mode means + // the GA auto-default will follow the loaded capture's channel count on first reload. + // serializeComponentState is the instrument's own writer (the single source of truth for + // the byte layout), so this is NOT a parallel encoder — it IS the instrument's encoder. ComponentState cs; cs.selectionId = sampleId; return serializeComponentState(cs); diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 2df9ed4..1afcb34 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -248,6 +248,10 @@ void ReaSamplerEditor::commitAndReload() { processor_->setSelectedSampleId(selectedId_); processor_->setPerformanceMap(map_); 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 invalidate(); #endif diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 6233378..d443d4a 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -11,13 +11,10 @@ #include "pluginterfaces/base/ibstream.h" #include "pluginterfaces/vst/ivstaudioprocessor.h" -#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate) #include "pluginterfaces/vst/ivstevents.h" #include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) #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 "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) @@ -121,14 +118,18 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { bridge_.connect(context); // 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 - // default (kMono), stereo (kStereo) when the mode is stereo. addAudioOutput needs an initial - // arrangement; seed it at the mode's arrangement so getBusInfo is correct from the first - // query. (setState may later flip the mode and re-negotiate via setChannelMode.) + // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of + // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders + // dual-mono through the stereo bus (both channels equal, centered), which is audibly + // 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); - const ChannelMode mode = channelMode(); - addAudioOutput(STR16("Audio Out"), - mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono); + addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); return kResultOk; } @@ -209,14 +210,13 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { std::lock_guard lock(assignMarkerMutex_); lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; } - // Restore the S7 channel mode and point the output bus at its arrangement so a reopened - // project comes back in the saved mode. setState runs before the host queries bus info, so - // seeding the arrangement here (rather than re-negotiating) is enough — no restartComponent. + // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see + // initialize) — the mode only governs how the reload below decodes, so no bus work here. { std::lock_guard lock(channelModeMutex_); channelMode_ = cs.channelMode; + channelModeExplicit_ = cs.channelModeExplicit; } - applyOutputArrangement(cs.channelMode); // 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. { @@ -251,7 +251,12 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { ComponentState state_out; state_out.selectionId = selectedSampleId(); 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 lock(channelModeMutex_); + state_out.channelMode = channelMode_; + state_out.channelModeExplicit = channelModeExplicit_; + } { std::lock_guard lock(assignMarkerMutex_); state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker @@ -397,49 +402,32 @@ void ReaSamplerProcessor::previewNoteOff(int note) { 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(outs->at(0))) { - bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo - : SpeakerArr::kMono); - } -} - void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { { std::lock_guard 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; } - // The mode changed: repoint the output bus and ask the host to re-negotiate I/O so REAPER's - // routing follows (mono<->stereo). restartComponent is a main/UI-thread call; setChannelMode - // is driven from the editor, so this is safe. Then reload so the next block decodes the new - // channel count into the LoadedInstrument (off-thread, RT path untouched). - applyOutputArrangement(mode); - if (componentHandler) componentHandler->restartComponent(kIoChanged); + // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no + // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread + // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering. reloadFromBank(); } tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( SpeakerArrangement* inputs, int32 numIns, SpeakerArrangement* outputs, int32 numOuts) { - // The instrument has ONE canonical arrangement per its channel mode (S7). We take NO audio - // input, so any inputs are rejected. For the single output bus: accept (kResultTrue) only - // when the host proposes exactly the mode's arrangement; otherwise reject (kResultFalse) but - // KEEP the mode's arrangement (per the VST3 contract, a plug-in that can't honor a proposal - // keeps a valid arrangement of its own). getBusArrangement then still reports the mode's - // channel count, so the host adapts its routing to us rather than forcing our channel count. + // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a + // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected. + // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse) + // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a + // proposal keeps a valid arrangement of its own) — the host adapts its routing to us. if (numIns < 0 || numOuts < 0) return kInvalidArgument; if (numIns > 0) return kResultFalse; // no audio input bus to arrange - - 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; + if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue; return kResultFalse; } @@ -459,8 +447,9 @@ std::string ReaSamplerProcessor::reloadFromBank() { bridge_.readReasamplerExtState(kProjExtBanksKey); const std::string projectDir = bridge_.activeProjectDir(); // 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. - const ChannelMode mode = channelMode(); + // Read once under its mutex, off the audio thread, before the decode loop. The single- + // 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 // engine's construction (the engine's config is immutable; a later change rebuilds). int builtVoiceCount = kDefaultVoiceCount; @@ -516,6 +505,17 @@ std::string ReaSamplerProcessor::reloadFromBank() { std::optional sel = selectSample(*banksJson, selectedSampleId()); if (sel) { + // GA auto-default: channelModeFor computes the mode from the loaded capture's + // REQUESTED channel count (always 2 for extension captures; mono only for + // ingest-imported mono files). An unknown count (0) or explicit user choice + // returns the current mode unchanged. Decode-only: the output bus is fixed + // stereo, so no bus work follows a flip. + { + std::lock_guard lock(channelModeMutex_); + channelMode_ = channelModeFor(sel->channelCount, channelMode_, + channelModeExplicit_); + mode = channelMode_; + } std::optional pcm = decodeRelative(projectDir, sel->relativePath, mode); if (pcm) { diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 14f5f6b..33cc4c7 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -102,12 +102,11 @@ public: Steinberg::tresult PLUGIN_API process( Steinberg::Vst::ProcessData& data) override; - // S7 channel-mode bus negotiation. The instrument has ONE canonical output arrangement - // determined by its per-instance channel mode (mono -> kMono, stereo -> kStereo). We - // accept the host's proposal only when it matches that arrangement; otherwise we reject - // (kResultFalse) but keep the mode's arrangement, so getBusArrangement / getBusInfo always - // report the mode's channel count and REAPER routes accordingly. A runtime mode change - // updates the output bus + calls restartComponent(kIoChanged) to trigger re-negotiation. + // Output-bus negotiation. The instrument has ONE canonical output arrangement: a FIXED + // stereo bus (GA fix — the channel mode is a decode policy, never a bus fact; mono mode + // renders dual-mono through it). We accept the host's proposal only when it is a single + // stereo output; otherwise we reject (kResultFalse) but keep our stereo arrangement, so + // getBusArrangement / getBusInfo always report 2 channels and the host routes accordingly. Steinberg::tresult PLUGIN_API setBusArrangements( Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns, 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 // 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. + // 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(); - // Sets the mode. When it CHANGES, updates the output bus arrangement (mono->kMono / - // stereo->kStereo) and asks the host to re-negotiate I/O via restartComponent(kIoChanged), - // then reloads the instrument so the next block decodes the new channel count. A no-op set - // (same mode) does neither. UI thread only. + // Sets the mode from the EDITOR TOGGLE (a deliberate user choice): latches the mode + // EXPLICIT (the GA auto-default stops fighting it), and on a CHANGE reloads the instrument + // so the next block decodes the new channel count. UI thread only. void setChannelMode(ChannelMode mode); // 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); 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 // 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 @@ -330,8 +327,12 @@ private: // 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 // 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_; ChannelMode channelMode_ = ChannelMode::Mono; + bool channelModeExplicit_ = false; // 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 diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 82ef9a1..e693b09 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -36,6 +36,7 @@ SelectedSample distill(const Sample& s) { out.relativePath = s.relativePath; out.rootNote = s.rootNote ? *s.rootNote : 60; out.loop = loopFromSample(s); + out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry) return out; } @@ -62,6 +63,12 @@ std::optional selectSample(const std::string& banksJson, return std::nullopt; } +ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit) { + if (isExplicit) return current; // user's explicit choice is never fought + if (channelCount <= 0) return current; // unknown (0) or pathological -> no change + return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono; +} + std::vector listSamples(const std::string& banksJson) { std::vector out; if (banksJson.empty()) return out; @@ -629,6 +636,11 @@ std::vector serializeComponentState(const ComponentState& state) { if (g > maxLin) g = maxLin; 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 — // unlike the v1 selection blob where the id ran to end-of-stream). putU32le(out, static_cast(state.selectionId.size())); @@ -705,12 +717,13 @@ ComponentState deserializeComponentState(const std::vector& bytes, return out; // previewVelocity stays at the mid default (pre-S-VIEW-4) } if (version != kComponentStateVersion && + version != kSelectionZonesModeMarkerVelVoiceGainV8Version && version != kSelectionZonesModeMarkerVelVoiceV7Version && version != kSelectionZonesModeMarkerVelV6Version) { 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 // as mono (conservative default) rather than rejected — a corrupt mode never silences the // instance. @@ -741,18 +754,26 @@ ComponentState deserializeComponentState(const std::vector& bytes, out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; 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 // 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()); - if (!r.ok) return out; // truncated inside the gain double — unity holds (out already - // carries mode/marker/velocity/voice fields from above) + if (!r.ok) return out; // truncated inside the gain double — out already carries + // mode/marker/velocity/voice fields from above; unity holds out.masterGainLinear = (std::isfinite(g) && g >= 0.0 && g <= vst::masterGainMaxLinear() * (1.0 + 1e-9)) ? g : 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 >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) { + 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(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 9fd099d..483c44a 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -36,6 +36,8 @@ struct SelectedSample { 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 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" @@ -58,6 +60,16 @@ struct SelectedSample { std::optional selectSample(const std::string& banksJson, const std::string& sampleId); +// GA auto-default rule (pure, tested): given the capture's requested channel count, the +// instance's current mode, and whether the user has explicitly toggled the mode, return +// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0) +// leaves the current mode unchanged. Used by reloadFromBank in the single-capture path. +// * isExplicit == true -> current (user's choice stands) +// * channelCount == 0 -> current (unknown, skip) +// * channelCount >= 2 -> Stereo +// * channelCount == 1 -> Mono +ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit); + // One entry in the capture browser's card list: the stable id + display name plus the S2 // intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge, // filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded @@ -447,25 +459,29 @@ PerformanceMap deserializePerformance(const std::vector& bytes, // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // 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-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 // 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 // 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 // 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 // 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 // channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = -// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, and unity -// master gain — which reproduce pre-v8 behavior exactly — preserving current behavior for -// already-saved instances): -// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones} direct. +// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity +// master gain, and channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the +// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD +// 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). // * 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). @@ -489,7 +505,12 @@ inline constexpr std::uint8_t kPreviewVelocityDefault = 64; struct ComponentState { std::string selectionId; // the single-capture pick; "" = no pick 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 // 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 @@ -509,7 +530,15 @@ struct ComponentState { 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 GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the +// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9; // The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + // preview velocity + voice system, no master gain). Retained so deserializeComponentState can diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index e190367..74ea740 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -129,6 +129,51 @@ static void testSelectNoLoopIsAbsent() { 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 +} + +// --- channelModeFor (GA auto-default rule) ------------------------------------------- + +static void testChannelModeForExplicitIsNeverFought() { + // An explicit user choice is ALWAYS returned unchanged, regardless of channelCount. + CHECK(channelModeFor(2, ChannelMode::Mono, true) == ChannelMode::Mono); + CHECK(channelModeFor(1, ChannelMode::Stereo, true) == ChannelMode::Stereo); + CHECK(channelModeFor(0, ChannelMode::Stereo, true) == ChannelMode::Stereo); +} + +static void testChannelModeForUnknownCountIsNoOp() { + // An unknown channel count (0 — an older bank entry) leaves the current mode unchanged. + CHECK(channelModeFor(0, ChannelMode::Mono, false) == ChannelMode::Mono); + CHECK(channelModeFor(0, ChannelMode::Stereo, false) == ChannelMode::Stereo); +} + +static void testChannelModeForStereoCapture() { + // A capture with channelCount >= 2 selects Stereo (regardless of current mode). + CHECK(channelModeFor(2, ChannelMode::Mono, false) == ChannelMode::Stereo); + CHECK(channelModeFor(2, ChannelMode::Stereo, false) == ChannelMode::Stereo); + CHECK(channelModeFor(6, ChannelMode::Mono, false) == ChannelMode::Stereo); +} + +static void testChannelModeForMonoCapture() { + // A capture with channelCount == 1 selects Mono (ingest-imported mono files only). + CHECK(channelModeFor(1, ChannelMode::Stereo, false) == ChannelMode::Mono); + CHECK(channelModeFor(1, ChannelMode::Mono, false) == ChannelMode::Mono); +} + static void testSelectEmptyBlob() { CHECK(!selectSample("", "a").has_value()); } @@ -1320,6 +1365,55 @@ static void testComponentStateV8TruncatedMasterGain() { 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 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(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) ---------------- // // The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and @@ -2033,6 +2127,11 @@ int main() { testSelectRootNoteDefault(); testSelectLoopThreaded(); testSelectNoLoopIsAbsent(); + testSelectChannelCountThreaded(); + testChannelModeForExplicitIsNeverFought(); + testChannelModeForUnknownCountIsNoOp(); + testChannelModeForStereoCapture(); + testChannelModeForMonoCapture(); testSelectEmptyBlob(); testSelectMalformedBlob(); testSelectZeroSamples(); @@ -2136,6 +2235,8 @@ int main() { testComponentStateV7LiftsUnityMasterGain(); testComponentStateV8CorruptMasterGainFallsBack(); testComponentStateV8TruncatedMasterGain(); + testComponentStateChannelModeExplicitRoundTrip(); + testComponentStateV8LiftsImplicitChannelMode(); testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay(); testReconcileKeepsOnlySelectedFullRangeZone(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 2ea67c7..9ad5ae7 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -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 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() { // 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. @@ -1975,6 +2004,7 @@ int main() { testChannelCount(); testStereoRenderKeepsChannelsDistinct(); testMonoSamplePlaysDualMonoInStereo(); + testDualMonoStereoSampleRendersCentered(); testMonoRenderUnchangedByStereoData(); testStereoRenderAdvancesLikeMonoRepitch(); testStereoRenderSumsVoicesPerChannel();