From 4a3551b3b9395be7d1418ae4aaaf9ee7f744f43f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 21:26:21 -0400 Subject: [PATCH] =?UTF-8?q?S7:=20stereo=20channel=20mode=20=E2=80=94=20cor?= =?UTF-8?q?e=20channel=20dimension,=20v4=20mode=20state,=20VST3=20bus=20ne?= =?UTF-8?q?gotiation,=20editor=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-instance mono|stereo (default mono, byte-identical). Stereo grows SampleData a 2nd channel + a per-channel VoiceEngine render; decodeChannels applies the cross-mode policy; setBusArrangements pins the mode's arrangement and restartComponent(kIoChanged) re-negotiates. --- PLAN.md | 41 ++++---- src/vst/reasampler_editor.cpp | 49 +++++++++- src/vst/reasampler_editor.h | 1 + src/vst/reasampler_processor.cpp | 144 +++++++++++++++++++++++----- src/vst/reasampler_processor.h | 32 +++++++ src/vst/sample_map.cpp | 68 ++++++++++++- src/vst/sample_map.h | 71 ++++++++++---- src/vst/sampler_core.cpp | 67 +++++++++++-- src/vst/sampler_core.h | 62 ++++++++++-- tests/test_sample_map.cpp | 158 +++++++++++++++++++++++++++++++ tests/test_sampler_core.cpp | 119 +++++++++++++++++++++++ 11 files changed, 727 insertions(+), 85 deletions(-) diff --git a/PLAN.md b/PLAN.md index 08a2366..eff680a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -285,25 +285,28 @@ unchanged (regression). **Depends on:** S3 (extends the core), S4 (extends the process/bus shell). Independent of S8/S9. -- [ ] Core channel dimension (pure, S3 extension): `SampleData` carries N-channel - (1 or 2) decoded PCM; `Voice::renderFrame` and `VoiceEngine::render` produce a - per-channel frame; stereo linear interpolation + loop read per channel. Mono stays the - degenerate case (single channel) — no behavior change for existing mono play. Tests: - stereo render asserted against a known 2-channel signal; mono render unchanged. -- [ ] Channel-mode toggle as per-instance state: `mono | stereo` in the instrument's own - component state (setState/getState, alongside the selected sample); default preserves - current behavior (mono). Cross-mode policy: **mono source + stereo mode → dual-mono** - (same signal both channels, centered); **stereo source + mono mode → downmix** (the - existing decode-side policy). The toggle lives in the instrument, never written to the - bank (a performance choice, not a file fact — D-B). -- [ ] Shell: decode fills 1- or 2-channel `SampleData` per the source's channel count - (the S2/bank channel-count intrinsic already exists); the process path renders the - active mode's channel count into the output bus. -- [ ] VST3 bus negotiation: implement `setBusArrangements` so the output bus reports - mono or stereo per the instance's channel mode, and REAPER's routing follows - automatically (no manual channel wiring). **Must-verify before build:** the - `setBusArrangements` / `getBusArrangement` contract and REAPER's mono/stereo instrument - bus expectations against the vendored Steinberg SDK + `reaper_vst3_interfaces.h`. +- [x] Core channel dimension (pure, S3 extension): `SampleData` carries 1- or 2-channel + decoded PCM (`frames` + optional length-matched `framesR`; `channelCount()`); + `Voice::renderFrameStereo` + a `VoiceEngine::render(left,right,n)` overload produce a + per-channel frame sharing one read head + one envelope tick; stereo linear interpolation + + loop read per channel. Mono stays the degenerate case (`renderFrame` reads channel 0 only, + byte-identical). Tests: stereo render asserted against a known 2-channel signal; dual-mono; + per-channel repitch + additive mix; mono render unchanged (regression) — sampler_core_tests. +- [x] Channel-mode toggle as per-instance state: `ChannelMode {Mono,Stereo}` in the + instrument's own component state (v4 = v3 + a channel-mode byte; setState/getState); + default mono. Cross-mode policy in `decodeChannels`: **mono source + stereo mode → + dual-mono**; **stereo source + mono mode → downmix** (existing decode-side policy). The + toggle lives in the instrument, never written to the bank (D-B). v1/v2/v3 blobs lift to v4 + with mono default; round-trip + lift tests — sample_map_tests. +- [x] Shell: `decodeRelative` fills 1- or 2-channel `DecodedZonePcm` per the active mode + (source channel count from the WAV layout); the process path renders the host's negotiated + output channel count (stereo into ch0/ch1, mono into ch0) — RT discipline unchanged. +- [x] VST3 bus negotiation: `setBusArrangements` accepts only the mode's arrangement + (kMono/kStereo), else rejects (kResultFalse) but keeps a valid mode arrangement so + `getBusArrangement` (base default) reports it; a runtime mode change repoints the output bus + + calls `restartComponent(kIoChanged)` so REAPER re-negotiates. **Verified** against the + vendored Steinberg SDK (`ivstaudioprocessor.h` contract, `vstsinglecomponenteffect.cpp` + base impl, `ivsteditcontroller.h` kIoChanged); see handoff notes. ## S8 — ingest through the bank (one gesture: capture/import into bank + assign to instance) **Goal:** Loading a sample into the sampler is **one gesture** — capture/import-into-bank diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 6bbebf3..b86008e 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -146,6 +146,7 @@ void ReaSamplerEditor::refreshFromBank() { banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; selectedId_ = processor_->selectedSampleId(); map_ = processor_->performanceMap(); + channelMode_ = processor_->channelMode(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; // Drop a filter that names a bank no longer present. if (!activeFilterBankId_.empty()) { @@ -334,6 +335,22 @@ Rect zonesStripArea(const EditorBands& bands) { return Rect{bands.content.left + pad, stripTop, bands.content.right - pad, stripTop + kStripBandHeight}; } + +// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's +// header row (same y as the sample-name header, so it reads as "this capture's output mode"). +// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide, +// kSegH tall, side by side. Kept to a small fenced block (S11 owns the waveform region). +constexpr int kChanSegW = 52; +constexpr int kChanSegH = 18; +struct ChannelToggleRects { Rect mono; Rect stereo; }; +ChannelToggleRects channelToggleRects(const Rect& area) { + constexpr int pad = 8; + const int top = area.top + 4; + const int right = area.right - pad; + const Rect stereo{right - kChanSegW, top, right, top + kChanSegH}; + const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH}; + return {mono, stereo}; +} } // namespace void ReaSamplerEditor::paint(HDC hdc) { @@ -477,10 +494,22 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { } const int pad = 8; - Rect headerR{area.left + pad, area.top + 4, area.right - pad, area.top + 22}; + // The mono/stereo toggle sits at the right of the header row; keep the name text clear of it. + const ChannelToggleRects chan = channelToggleRects(area); + Rect headerR{area.left + pad, area.top + 4, chan.mono.left - 8, area.top + 22}; std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root); drawText(bmp, headerR, header.c_str(), kRgbText); + // S7 mono | stereo output-mode toggle. The active segment highlights (kColTabActiveBg), + // the inactive is kColTabBg — the same visual grammar as the Browser/Zones toggle. + const bool isStereo = (channelMode_ == ChannelMode::Stereo); + LICE_FillRect(bmp, chan.mono.left, chan.mono.top, chan.mono.width(), chan.mono.height(), + isStereo ? kColTabBg : kColTabActiveBg, 1.0f, 0); + LICE_FillRect(bmp, chan.stereo.left, chan.stereo.top, chan.stereo.width(), + chan.stereo.height(), isStereo ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(bmp, chan.mono, "Mono", kRgbText); + drawTextCentered(bmp, chan.stereo, "Stereo", kRgbText); + Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16}; drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim); @@ -597,9 +626,25 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { commitAndReload(); // publishes the pick + reloads; process() plays it repitched return; } - // The setup strip: grab the root marker (drag to set the picked capture's root). + // The setup band: the mono/stereo toggle (header row), then the root-marker strip. if (havePick) { const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + // S7: a click on a channel-mode segment sets the instance mode (setChannelMode + // re-negotiates the bus + reloads; a no-op set for the already-active mode is ignored + // by the processor). Snapshot the new mode locally so the paint reflects it at once. + const ChannelToggleRects chan = channelToggleRects(area); + if (contains(chan.mono, x, y)) { + channelMode_ = ChannelMode::Mono; + processor_->setChannelMode(ChannelMode::Mono); + invalidate(); + return; + } + if (contains(chan.stereo, x, y)) { + channelMode_ = ChannelMode::Stereo; + processor_->setChannelMode(ChannelMode::Stereo); + invalidate(); + return; + } const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index e492869..303ada0 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -113,6 +113,7 @@ private: std::vector visible_; // samples_ narrowed by the active bank filter std::string selectedId_; // the single-capture pick ("" = empty state) PerformanceMap map_; // the opt-in zones (empty = no zones) + ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot // --- Transient UI state (not persisted; component state carries selection + zones) --- View view_ = View::kBrowser; // default face is the browser diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index daca7c7..7d65188 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -11,9 +11,12 @@ #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/vstspeaker.h" +#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr) + #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "ext_keys.h" // kProjExtBanksKey (shared wire contract) #include "reasampler_editor.h" @@ -63,12 +66,15 @@ std::vector readFileBytes(const std::string& path) { } // Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file -// I/O — off-thread only), and downmix to the core's mono contract. Returns nullopt when -// the path fails to resolve, the file is unreadable, the WAV is malformed, or the decode -// yields no frames — the caller drops the zone (zoned map) or plays silence (single capture). -// Shared by the zoned build and the single-capture path so both decode identically. +// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode +// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for +// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path +// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no +// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by +// the zoned build and the single-capture path so both decode identically for the active mode. std::optional decodeRelative(const std::string& projectDir, - const std::string& relativePath) { + const std::string& relativePath, + ChannelMode mode) { const std::string abs = resolveBankFile(projectDir, relativePath); if (abs.empty()) return std::nullopt; const std::vector bytes = readFileBytes(abs); @@ -76,11 +82,9 @@ std::optional decodeRelative(const std::string& projectDir, if (!layout.valid) return std::nullopt; std::vector interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount()); - std::vector mono = downmixToMono(interleaved, layout.channelCount); - if (mono.empty()) return std::nullopt; - DecodedZonePcm out; - out.monoFrames = std::move(mono); - out.sampleRate = static_cast(layout.sampleRate); + DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, + static_cast(layout.sampleRate)); + if (out.monoFrames.empty()) return std::nullopt; return out; } @@ -117,10 +121,15 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { // instrument still loads, it just has no live bank to play. bridge_.connect(context); - // Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio - // output, no audio input. This is the standard VSTi arrangement. + // 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.) addEventInput(STR16("MIDI In"), 16); - addAudioOutput(STR16("Stereo Out"), SpeakerArr::kStereo); + const ChannelMode mode = channelMode(); + addAudioOutput(STR16("Audio Out"), + mode == ChannelMode::Stereo ? SpeakerArr::kStereo : SpeakerArr::kMono); return kResultOk; } @@ -175,6 +184,14 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { const ComponentState cs = deserializeComponentState(bytes); setSelectedSampleId(cs.selectionId); setPerformanceMap(cs.map); + // 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. + { + std::lock_guard lock(channelModeMutex_); + channelMode_ = cs.channelMode; + } + applyOutputArrangement(cs.channelMode); // Rebuild from the restored state (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; @@ -190,6 +207,7 @@ 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 const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), @@ -219,6 +237,57 @@ void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { performanceMap_ = map; } +ChannelMode ReaSamplerProcessor::channelMode() { + std::lock_guard lock(channelModeMutex_); + return channelMode_; +} + +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 + 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); + 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. + 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; + return kResultFalse; +} + std::string ReaSamplerProcessor::reloadFromBank() { // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so // the retired-slot free is single-writer. This mutex is NEVER taken on the audio @@ -234,6 +303,9 @@ std::string ReaSamplerProcessor::reloadFromBank() { std::optional banksJson = 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(); std::string resolvedId; std::unique_ptr built; @@ -257,7 +329,7 @@ std::string ReaSamplerProcessor::reloadFromBank() { kept.reserve(resolved.zones.size()); for (const ResolvedZone& rz : resolved.zones) { std::optional pcm = - decodeRelative(projectDir, rz.relativePath); + decodeRelative(projectDir, rz.relativePath, mode); if (!pcm) continue; // unreadable WAV -> drop this zone kept.push_back(rz); decoded.push_back(std::move(*pcm)); @@ -279,10 +351,11 @@ std::string ReaSamplerProcessor::reloadFromBank() { selectSample(*banksJson, selectedSampleId()); if (sel) { std::optional pcm = - decodeRelative(projectDir, sel->relativePath); + decodeRelative(projectDir, sel->relativePath, mode); if (pcm) { km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, - sel->rootNote, sel->loop); + sel->rootNote, sel->loop, + std::move(pcm->framesR)); haveKeymap = true; resolvedId = selectedSampleId(); // the concrete pick that resolved } @@ -378,25 +451,48 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { return kResultOk; } - // Render mono into channel 0's buffer, then replicate to the other channels (the - // core is mono-per-sample). Clear channel 0 first (render ADDS), then mix. + // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked + // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here + // we simply match the buffers the host handed us: >=2 channels -> true stereo render into + // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the + // 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; - if (ch0) { + float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; + if (ch0 && ch1) { + // 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. + for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } + if (inst) { + inst->engine.render(ch0, ch1, static_cast(frames)); + } + // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). + for (int32 ch = 2; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; + if (a0 > peak) peak = a0; + if (a1 > peak) peak = a1; + } + embedPeak_.store(peak, std::memory_order_relaxed); + } else if (ch0) { + // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; + // the replicate is defensive for a host that still hands >1 channel on a mono bus). for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; if (inst) { inst->engine.render(ch0, static_cast(frames)); } - // Block peak (mono, pre-replicate) for the embedded strip's level indicator. A - // single scan of ch0 + one relaxed atomic store — RT-safe (no alloc/IO/lock). The - // UI thread reads it via embedActivityLevel(); a plain store is sufficient because - // the readout is advisory (no ordering dependency on other state). float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; if (a > peak) peak = a; } embedPeak_.store(peak, std::memory_order_relaxed); - // Duplicate the mono render across the remaining output channels. for (int32 ch = 1; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index d832d2a..a16e283 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -88,6 +88,16 @@ 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. + Steinberg::tresult PLUGIN_API setBusArrangements( + Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns, + Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override; + //--- from IEditController ----------------------------------------------- // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; @@ -128,7 +138,23 @@ public: PerformanceMap performanceMap(); void setPerformanceMap(const PerformanceMap& map); + // The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread + // (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. + 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. + void setChannelMode(ChannelMode mode); + 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); + ReaperBridge bridge_; // --- The audio-thread handoff (S4 real-time discipline) ----------------- @@ -178,6 +204,12 @@ private: std::mutex performanceMutex_; PerformanceMap performanceMap_; + // 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. + std::mutex channelModeMutex_; + ChannelMode channelMode_ = ChannelMode::Mono; + // Latched from setupProcessing so setActive/reload can size against it. Read // off-thread only. double sampleRate_ = 44100.0; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index cb6d8b9..e54e030 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -101,10 +101,48 @@ std::vector downmixToMono(const std::vector& interleav return out; } -Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, - int rootNote, const SampleLoop& loop) { +std::vector extractChannel(const std::vector& interleaved, + int channelCount, int which) { + std::vector out; + if (channelCount <= 0 || interleaved.empty()) return out; + const std::size_t stride = static_cast(channelCount); + // Clamp the requested channel into the source's range: a channel past the last one reads + // the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono). + std::size_t ch = which < 0 ? 0 : static_cast(which); + if (ch >= stride) ch = stride - 1; + const std::size_t frames = interleaved.size() / stride; + out.resize(frames); + for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch]; + return out; +} + +DecodedZonePcm decodeChannels(const std::vector& interleaved, + int sourceChannels, ChannelMode mode, int sampleRate) { + DecodedZonePcm out; + out.sampleRate = sampleRate > 0 ? sampleRate : 44100; + if (mode == ChannelMode::Mono) { + // MONO mode: the existing downmix policy (average all source channels), one channel out. + out.monoFrames = downmixToMono(interleaved, sourceChannels); + return out; // framesR stays empty + } + // STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0 + // duplicated when the source is mono (dual-mono, centered). extractChannel clamps the + // out-of-range channel request to the last channel, so a mono source yields L == R. + out.monoFrames = extractChannel(interleaved, sourceChannels, 0); + out.framesR = extractChannel(interleaved, sourceChannels, 1); + return out; +} + +Keymap buildTier0Keymap(std::vector frames, int sampleRate, + int rootNote, const SampleLoop& loop, + std::vector framesR) { SampleData data; - data.frames = std::move(monoFrames); + data.frames = std::move(frames); + // A second channel only counts when it length-matches channel 0 (else the sample stays + // mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays). + if (!framesR.empty() && framesR.size() == data.frames.size()) { + data.framesR = std::move(framesR); + } data.sampleRate = sampleRate > 0 ? sampleRate : 44100; data.rootNote = rootNote; data.loop = loop; @@ -158,6 +196,12 @@ Keymap buildZonedKeymap(const std::vector& zones, if (decoded[i].monoFrames.empty()) continue; SampleData data; data.frames = decoded[i].monoFrames; + // Carry the second channel only when it length-matches channel 0 (channelCount() + // enforces the same rule; a mismatched pair falls back to mono rather than half-play). + if (!decoded[i].framesR.empty() && + decoded[i].framesR.size() == data.frames.size()) { + data.framesR = decoded[i].framesR; + } data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100; data.rootNote = zones[i].rootNote; data.loop = zones[i].loop; @@ -292,6 +336,8 @@ PerformanceMap deserializePerformance(const std::vector& bytes) { std::vector serializeComponentState(const ComponentState& state) { std::vector out; putU32le(out, kComponentStateVersion); + // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. + out.push_back(state.channelMode == ChannelMode::Stereo ? 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())); @@ -324,10 +370,24 @@ ComponentState deserializeComponentState(const std::vector& bytes) } if (version == kPerformanceStateVersion) { readZonesPayload(r, out.map); // v2 body starts right after the version tag - return out; + return out; // channelMode stays Mono (pre-S7) + } + // BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO — + // the id length + id + zones body starts right after the version tag (no mode byte). + if (version == kSelectionZonesV3Version) { + const std::uint32_t idLen = r.u32(); + out.selectionId = r.str(idLen); + if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty + readZonesPayload(r, out.map); + return out; // channelMode stays Mono (pre-S7) } if (version != kComponentStateVersion) return out; // unknown -> empty + // v4: the channel-mode byte precedes the v3 body. A non-{0,1} byte is treated as mono + // (conservative default) rather than rejected — a corrupt mode never silences the instance. + const std::uint8_t modeByte = r.u8(); + if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) + out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; 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 ad2b696..e280d3c 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -100,12 +100,23 @@ std::vector listBanks(const std::string& banksJson); std::vector downmixToMono(const std::vector& interleaved, int channelCount); -// Build the Tier-0 chromatic keymap for one decoded, mono sample: one zone spanning -// the whole keyboard, repitched from `rootNote`, looped per `loop`. This is the -// single-sample degenerate case (Keymap::singleSampleChromatic) with the S2 intrinsics -// threaded in. `monoFrames` is the downmixed PCM; `sampleRate` is the WAV's rate. -Keymap buildTier0Keymap(std::vector monoFrames, int sampleRate, - int rootNote, const SampleLoop& loop); +// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is +// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the +// source's last channel reads the last channel, so a mono source asked for channel 1 yields +// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure. +std::vector extractChannel(const std::vector& interleaved, + int channelCount, int which); + +// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole +// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case +// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0 +// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default), +// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length +// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad +// pair never half-plays. `sampleRate` is the WAV's rate. +Keymap buildTier0Keymap(std::vector frames, int sampleRate, + int rootNote, const SampleLoop& loop, + std::vector framesR = {}); // --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // @@ -178,12 +189,27 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the // map). Empty zones in -> empty Keymap (silence). struct DecodedZonePcm { - std::vector monoFrames; + std::vector monoFrames; // channel 0 (mono, or L of a stereo decode) int sampleRate = 44100; + std::vector framesR; // channel 1 (R); EMPTY for a mono decode }; Keymap buildZonedKeymap(const std::vector& zones, const std::vector& decoded); +// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding +// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's +// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode. +// * MONO mode -> downmix to one channel (the existing policy: average all source +// channels). framesR EMPTY. A mono or stereo source both collapse. +// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered). +// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels +// takes channels 0 and 1 (documented; the sampler's stereo image is +// the first two channels — no surround fold). +// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone +// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here. +DecodedZonePcm decodeChannels(const std::vector& interleaved, + int sourceChannels, ChannelMode mode, int sampleRate); + // --- Performance-map instance state (VST3 setState/getState) ----------------- // // The performance map is the instrument's OWN state (D-B), serialized to the VST3 @@ -222,20 +248,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 (v3): 4-byte LE version tag (== 3), then a 4-byte LE selection-id length + -// id bytes, then the v2 zones payload (4-byte LE zone count + per-zone records, identical -// to serializePerformance's body). BACK-COMPAT on read: -// * v3 blob -> {selectionId, zones} parsed directly. -// * v2 blob -> {"", zones}: an S5 instance had zones but no separate selection. -// * v1 blob -> {id, one full-keyboard zone}: the S4 single-selection lift, so the -// old pick survives as both the selection AND a one-zone map. -// * empty/unknown -> {"", no zones}: EMPTY (the S10 silent empty state). +// Format (v4): 4-byte LE version tag (== 4), then a 1-byte channel-mode field (0 = mono, +// 1 = stereo), then a 4-byte LE selection-id length + id bytes, then the v2 zones payload +// (4-byte LE zone count + per-zone records, identical to serializePerformance's body). The +// channel-mode field is the ONLY v4 addition over v3 — the envelope grew a field, the zones +// payload is untouched (a PARALLEL track owns zone-record extension under the map's own +// versioning). BACK-COMPAT on read (every older blob lifts to channelMode = MONO, preserving +// current behavior for already-saved instances): +// * v4 blob -> {channelMode, selectionId, zones} parsed directly. +// * v3 blob -> {mono, selectionId, zones}: pre-S7 had no channel mode. +// * v2 blob -> {mono, "", zones}: an S5 instance had zones but no separate selection. +// * v1 blob -> {mono, id, one full-keyboard zone}: the S4 single-selection lift. +// * empty/unknown -> {mono, "", no zones}: EMPTY (the S10 silent empty state). struct ComponentState { - std::string selectionId; // the single-capture pick; "" = no pick (empty state) - PerformanceMap map; // the opt-in zones; empty = no zones + 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) }; -inline constexpr std::uint32_t kComponentStateVersion = 3; +inline constexpr std::uint32_t kComponentStateVersion = 4; + +// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named +// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. +inline constexpr std::uint32_t kSelectionZonesV3Version = 3; // The full instance state serialized to bytes for IBStream (getState). std::vector serializeComponentState(const ComponentState& state); diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 57a7748..f4bdbec 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -163,11 +163,23 @@ void Voice::release() { env_.noteOff(); } -AudioSample Voice::renderFrame() { - if (!active_ || sample_ == nullptr) return 0.0f; +AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { + // Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap, + // bracketing indices, interpolation partner) is computed ONCE and applied identically to + // every channel — only the PCM value read differs. The envelope ticks ONCE per frame and + // scales all channels equally (a voice is one envelope). The head advances by exactly one + // ratio step per call, so mono and stereo consume the sample at the same rate. + if (!active_ || sample_ == nullptr) { + if (stereo) outR = 0.0f; + return 0.0f; + } const std::vector& pcm = sample_->frames; const std::int64_t frameCount = static_cast(pcm.size()); + // Read the second channel only for a genuinely stereo sample; a mono sample plays + // dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case. + const bool haveR = stereo && sample_->channelCount() == 2; + const std::vector& pcmR = haveR ? sample_->framesR : pcm; // Loop-aware sustain: if a valid, non-zero-length loop exists and the read head // has advanced past the loop end, wrap it back into [start, end). A zero-length @@ -188,6 +200,7 @@ AudioSample Voice::renderFrame() { // Ran off the end with no usable loop -> voice is done. if (readPos_ >= static_cast(frameCount)) { active_ = false; + if (stereo) outR = 0.0f; return 0.0f; } @@ -201,20 +214,40 @@ AudioSample Voice::renderFrame() { } // i0 is always in [0, frameCount) after the early-out above; the guard is purely // defensive. i1 (the interpolation partner) can exceed frameCount when no loop - // wraps it — only that partner actually needs the clamp. - const double s0 = (i0 >= 0 && i0 < frameCount) ? static_cast(pcm[i0]) : 0.0; - const double s1 = (i1 >= 0 && i1 < frameCount) ? static_cast(pcm[i1]) : 0.0; - const double interp = s0 + (s1 - s0) * frac; + // wraps it — only that partner actually needs the clamp. framesR is length-matched + // to frames (channelCount() enforces it), so the same indices are valid in both. + const bool i0ok = (i0 >= 0 && i0 < frameCount); + const bool i1ok = (i1 >= 0 && i1 < frameCount); const double amp = env_.tick(); - const double out = interp * amp * velocityGain_; + const double gain = amp * velocityGain_; + + const double l0 = i0ok ? static_cast(pcm[i0]) : 0.0; + const double l1 = i1ok ? static_cast(pcm[i1]) : 0.0; + const double outL = (l0 + (l1 - l0) * frac) * gain; + + if (stereo) { + const double r0 = i0ok ? static_cast(pcmR[i0]) : 0.0; + const double r1 = i1ok ? static_cast(pcmR[i1]) : 0.0; + outR = static_cast((r0 + (r1 - r0) * frac) * gain); + } readPos_ += ratio_; if (env_.finished()) { active_ = false; } - return static_cast(out); + return static_cast(outL); +} + +AudioSample Voice::renderFrame() { + AudioSample discard = 0.0f; + return advanceFrame(/*stereo=*/false, discard); +} + +void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) { + r = 0.0f; + l = advanceFrame(/*stereo=*/true, r); } // --------------------------------------------------------------------------- @@ -303,6 +336,24 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) { } } +void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) { + // Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel + // contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice + // iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle + // discipline; only the per-frame call differs (renderFrameStereo vs renderFrame). + if (left == nullptr || right == nullptr || frameCount == 0) return; + for (Voice& voice : voices_) { + if (!voice.active()) continue; + for (std::size_t f = 0; f < frameCount; ++f) { + if (!voice.active()) break; + AudioSample l = 0.0f, r = 0.0f; + voice.renderFrameStereo(l, r); + left[f] += l; + right[f] += r; + } + } +} + void VoiceEngine::render(std::vector& out, std::size_t frameCount) { // Off-thread / test path: grow the buffer (this allocates — never call under // process), zero-fill the appended span, then delegate to the RT mix loop so both diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 5efa837..3d8b219 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -25,6 +25,14 @@ namespace reasampler { +// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 +// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders +// per-channel. A PERFORMANCE choice the instrument owns (component state), never written +// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain +// value so the shell (bus negotiation, state) and the engine share one spelling; the core +// itself never branches on it — the mode only picks which render overload the shell drives. +enum class ChannelMode { Mono, Stereo }; + // --------------------------------------------------------------------------- // Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that // govern playback. The shell decodes the on-disk WAV and fills this; the core @@ -40,16 +48,26 @@ struct SampleLoop { std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end }; -// One decoded audio sample the engine can voice. `frames` is DEINTERLEAVED-agnostic: -// the core plays a single mono stream per sample (Tier 0-1 scope), so `frames` is one -// channel's PCM at `sampleRate`. `rootNote` is the MIDI note the file was recorded at -// (S2 intrinsic) — the pitch that plays back at unity ratio. +// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is +// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). +// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise +// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both +// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical +// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was +// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. struct SampleData { - std::vector frames; // mono PCM, one value per frame - int sampleRate = 44100; // frames per second (for reference; ratio is - // note-relative, so rate cancels for repitch) - int rootNote = 60; // MIDI note recorded at (plays at unity here) - SampleLoop loop; // sustain loop, if any + std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) + std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample + int sampleRate = 44100; // frames per second (for reference; ratio is + // note-relative, so rate cancels for repitch) + int rootNote = 60; // MIDI note recorded at (plays at unity here) + SampleLoop loop; // sustain loop, if any + + // 2 iff a matching-length second channel exists; else 1. A framesR of a different + // length than frames is treated as absent (mono) — a malformed pair never half-plays. + int channelCount() const { + return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; + } }; // --------------------------------------------------------------------------- @@ -190,10 +208,25 @@ public: // Renders one frame's contribution, advancing the read head and envelope by one // output frame. Returns 0.0 (and goes idle) once the envelope finishes or the // sample runs out with no loop. The value is already velocity- and - // envelope-scaled — the engine sums voices directly. + // envelope-scaled — the engine sums voices directly. This is the MONO path (channel + // 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged. AudioSample renderFrame(); + // STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances + // the read head + envelope by exactly one frame (the same single advance the mono path + // performs — the envelope ticks ONCE per frame, shared across both channels). For a mono + // sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered). + // Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions + // as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both. + void renderFrameStereo(AudioSample& l, AudioSample& r); + private: + // Shared read/advance for both render paths: computes the interpolated per-channel + // value(s) at the current read head, ticks the envelope once, advances the head, and + // latches idle on exhaustion. `stereo` selects whether the second channel is read (and + // returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value. + AudioSample advanceFrame(bool stereo, AudioSample& outR); + bool active_ = false; bool releasing_ = false; int note_ = 0; @@ -245,6 +278,15 @@ public: // at least `frameCount` writable samples; a null `out` or zero count is a no-op. void render(AudioSample* out, std::size_t frameCount); + // REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two + // buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there + // (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no + // resize, no lock. A mono sample plays dual-mono (same value to both channels, centered); + // a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono + // and stereo render paths are independent output shapes over the SAME voice pool; the active + // channel mode (mono vs stereo bus) picks which one the process callback drives per block. + void render(AudioSample* left, AudioSample* right, std::size_t frameCount); + // TEST / off-thread convenience: appends `frameCount` summed frames to `out` // (grows it — DO NOT call on the audio thread; it allocates). Delegates to the // real-time overload after sizing the buffer, so both paths share one mix loop. diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index da645ef..21aa607 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -678,6 +678,149 @@ static void testComponentStateGarbage() { CHECK(deserializeComponentState(t).map.zones.empty()); } +// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ---------- + +static void testExtractChannelStereo() { + // Interleaved stereo [L0,R0,L1,R1,...]; extract channel 0 -> L's, channel 1 -> R's. + const std::vector in{0.1f, 0.9f, 0.2f, 0.8f, 0.3f, 0.7f}; + const std::vector l = extractChannel(in, 2, 0); + const std::vector r = extractChannel(in, 2, 1); + CHECK(l.size() == 3 && approx(l[0], 0.1) && approx(l[1], 0.2) && approx(l[2], 0.3)); + CHECK(r.size() == 3 && approx(r[0], 0.9) && approx(r[1], 0.8) && approx(r[2], 0.7)); +} + +static void testExtractChannelClampsToLast() { + // A mono source asked for channel 1 yields channel 0 (clamp to last) — the dual-mono block. + const std::vector mono{0.1f, 0.2f, 0.3f}; + const std::vector ch1 = extractChannel(mono, 1, 1); + CHECK(ch1.size() == 3 && approx(ch1[0], 0.1) && approx(ch1[2], 0.3)); // == channel 0 + CHECK(extractChannel({}, 2, 0).empty()); // empty in + CHECK(extractChannel({0.1f}, 0, 0).empty()); // zero stride +} + +static void testDecodeChannelsMonoModeDownmixes() { + // MONO mode: a stereo source averages to one channel (the existing policy), framesR empty. + const std::vector stereo{1.0f, 0.0f, 0.4f, 0.6f}; // frames (1,0) and (0.4,0.6) + const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Mono, 48000); + CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.5) && approx(d.monoFrames[1], 0.5)); + CHECK(d.framesR.empty()); // mono mode -> single channel + CHECK(d.sampleRate == 48000); +} + +static void testDecodeChannelsStereoModeStereoSource() { + // STEREO mode + stereo source: channels taken as-is (L/R), both present + distinct. + const std::vector stereo{0.1f, 0.9f, 0.2f, 0.8f}; + const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Stereo, 44100); + CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.1) && approx(d.monoFrames[1], 0.2)); + CHECK(d.framesR.size() == 2 && approx(d.framesR[0], 0.9) && approx(d.framesR[1], 0.8)); +} + +static void testDecodeChannelsStereoModeMonoSourceDualMono() { + // STEREO mode + mono source: dual-mono — framesR duplicates channel 0 (centered, not silent). + const std::vector mono{0.3f, 0.6f, 0.9f}; + const DecodedZonePcm d = decodeChannels(mono, 1, ChannelMode::Stereo, 44100); + CHECK(d.monoFrames.size() == 3); + CHECK(d.framesR.size() == 3); + for (std::size_t i = 0; i < 3; ++i) CHECK(approx(d.monoFrames[i], d.framesR[i])); // R == L +} + +// --- S7: buildTier0Keymap stereo threading ------------------------------------ + +static void testBuildKeymapStereoCarriesSecondChannel() { + const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}, {0.9f, 0.8f}); + CHECK(km.samples.size() == 1); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2); + CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 && + approx(km.samples[0].framesR[0], 0.9) && approx(km.samples[0].framesR[1], 0.8)); +} + +static void testBuildKeymapMonoWhenNoSecondChannel() { + // No framesR passed -> mono SampleData (byte-identical to the pre-S7 build). + const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1); + CHECK(km.samples.size() == 1 && km.samples[0].framesR.empty()); +} + +static void testBuildKeymapDropsMismatchedSecondChannel() { + // A framesR whose length mismatches frames is dropped -> mono (a bad pair never half-plays). + const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 60, SampleLoop{}, {0.9f}); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1); +} + +static void testBuildZonedKeymapCarriesSecondChannel() { + // The zoned build threads each zone's framesR when it length-matches channel 0. + std::vector zones; + ResolvedZone z; z.lowNote = 0; z.highNote = 127; z.rootNote = 60; zones.push_back(z); + std::vector decoded; + DecodedZonePcm d; d.monoFrames = {0.1f, 0.2f}; d.sampleRate = 44100; d.framesR = {0.9f, 0.8f}; + decoded.push_back(d); + const Keymap km = buildZonedKeymap(zones, decoded); + CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2); + CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 && + approx(km.samples[0].framesR[1], 0.8)); +} + +// --- S7: component state v4 (channel mode) ------------------------------------ + +static void testComponentStateV4RoundTripStereo() { + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "pick"); + CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); +} + +static void testComponentStateV4RoundTripMono() { + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Mono; + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "pick"); + CHECK(back.channelMode == ChannelMode::Mono); +} + +static void testComponentStateV4DefaultIsMono() { + // A default-constructed state serializes with mono and restores mono (preserves behavior). + const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{})); + CHECK(back.channelMode == ChannelMode::Mono); + CHECK(back.selectionId.empty() && back.map.zones.empty()); +} + +static void testComponentStateV3LiftsToMono() { + // A pre-S7 v3 blob (selection + zones, no mode byte) lifts to channelMode = mono, with the + // selection and zones intact. Build a v3 blob by hand: tag 3, id length + id, zones payload. + std::vector v3; + v3.push_back(3); v3.push_back(0); v3.push_back(0); v3.push_back(0); // version 3 + const std::string id = "legacy"; + v3.push_back(static_cast(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0); + v3.insert(v3.end(), id.begin(), id.end()); + v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0 + const ComponentState back = deserializeComponentState(v3); + CHECK(back.selectionId == "legacy"); + CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default + CHECK(back.map.zones.empty()); +} + +static void testComponentStateV1V2LiftToMono() { + // The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read. + const ComponentState v1 = deserializeComponentState(serializeSelection("old")); + CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old"); + PerformanceMap m; m.zones.push_back(zone("s", 12, 24)); + const ComponentState v2 = deserializeComponentState(serializePerformance(m)); + CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1); +} + +static void testComponentStateV4TruncatedModeByte() { + // A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds. + std::vector t{4, 0, 0, 0}; // version 4, nothing after + const ComponentState back = deserializeComponentState(t); + CHECK(back.channelMode == ChannelMode::Mono); + CHECK(back.selectionId.empty() && back.map.zones.empty()); +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -726,6 +869,21 @@ int main() { testComponentStateV1BackCompat(); testComponentStateV2BackCompat(); testComponentStateGarbage(); + testExtractChannelStereo(); + testExtractChannelClampsToLast(); + testDecodeChannelsMonoModeDownmixes(); + testDecodeChannelsStereoModeStereoSource(); + testDecodeChannelsStereoModeMonoSourceDualMono(); + testBuildKeymapStereoCarriesSecondChannel(); + testBuildKeymapMonoWhenNoSecondChannel(); + testBuildKeymapDropsMismatchedSecondChannel(); + testBuildZonedKeymapCarriesSecondChannel(); + testComponentStateV4RoundTripStereo(); + testComponentStateV4RoundTripMono(); + testComponentStateV4DefaultIsMono(); + testComponentStateV3LiftsToMono(); + testComponentStateV1V2LiftToMono(); + testComponentStateV4TruncatedModeByte(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 4a3a5a7..89ac901 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -562,6 +562,118 @@ static void testPolyphonyMixesAdditively() { CHECK(approx(out[0], 2.0, 1e-4)); // both voices sum } +// --------------------------------------------------------------------------- +// 7. Stereo channel dimension (S7). +// --------------------------------------------------------------------------- + +// A distinct-per-channel stereo DC sample: L = `l`, R = `r` everywhere. A stereo render +// must keep them distinct; a mono render (channel 0 only) sees L. +static SampleData stereoDcSample(std::size_t frames, float l, float r, int rootNote = 60) { + SampleData s; + s.frames.assign(frames, l); + s.framesR.assign(frames, r); + s.rootNote = rootNote; + return s; +} + +static void testChannelCount() { + // Mono: framesR empty -> 1 channel. Stereo: matching-length framesR -> 2. + CHECK(dcSample(10, 60).channelCount() == 1); + CHECK(stereoDcSample(10, 1.0f, -1.0f).channelCount() == 2); + // A mismatched framesR length is treated as mono (a bad pair never half-plays). + SampleData bad = dcSample(10, 60); + bad.framesR.assign(5, 0.5f); // wrong length + CHECK(bad.channelCount() == 1); +} + +static void testStereoRenderKeepsChannelsDistinct() { + // A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each + // scaled by velocity (full here). If the engine copied L to both channels the R check fails. + Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + + std::vector left(8, 0.f), right(8, 0.f); + eng.render(left.data(), right.data(), 8); + for (std::size_t i = 0; i < 8; ++i) { + CHECK(approx(left[i], 1.0, 1e-4)); // channel 0 + CHECK(approx(right[i], -1.0, 1e-4)); // channel 1 — distinct, NOT a copy of L + } +} + +static void testMonoSamplePlaysDualMonoInStereo() { + // A MONO sample rendered through the stereo path plays dual-mono: both channels equal + // (centered), not silent on the right. The cross-mode "mono source in stereo mode" case. + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0 + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector left(8, 0.f), right(8, 0.f); + eng.render(left.data(), right.data(), 8); + for (std::size_t i = 0; i < 8; ++i) { + CHECK(approx(left[i], 1.0, 1e-4)); + CHECK(approx(right[i], 1.0, 1e-4)); // R == L (dual-mono), not 0 + } +} + +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. + Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60)); + VoiceEngine engS(1, kmS, flatAdsr()); + engS.noteOn(60, 127); + std::vector mono; + engS.render(mono, 8); // the mono overload + for (std::size_t i = 0; i < 8; ++i) CHECK(approx(mono[i], 0.75, 1e-4)); // == L, ignores R +} + +static void testStereoRenderAdvancesLikeMonoRepitch() { + // The stereo path must advance the read head by the SAME per-frame ratio as the mono path, + // so repitch is identical. Play a stereo sine (both channels the same signal) an octave up + // and confirm the observed period halves — the mono repitch assertion, on the stereo path. + const std::size_t frames = 8000; + const double cycles = 20.0; + const double nativePeriod = static_cast(frames) / cycles; // 400 + SampleData s; + s.frames.resize(frames); + s.framesR.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + const float v = static_cast(std::sin(2.0 * kPi * cycles * + static_cast(i) / static_cast(frames))); + s.frames[i] = v; + s.framesR[i] = v; + } + s.rootNote = 60; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(72, 127); // +1 octave + std::vector left(frames / 2, 0.f), right(frames / 2, 0.f); + eng.render(left.data(), right.data(), frames / 2); + CHECK(approx(observedPeriodFrames(left), nativePeriod / 2.0, 2.0)); + CHECK(approx(observedPeriodFrames(right), nativePeriod / 2.0, 2.0)); // R repitches identically +} + +static void testStereoRenderSumsVoicesPerChannel() { + // Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo). + Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(60, 127); + eng.noteOn(60, 127); // second voice, same note + std::vector left(1, 0.f), right(1, 0.f); + eng.render(left.data(), right.data(), 1); + CHECK(approx(left[0], 1.0, 1e-4)); // 0.5 + 0.5 + CHECK(approx(right[0], -1.0, 1e-4)); // -0.5 + -0.5 +} + +static void testStereoRenderNullBufferIsNoOp() { + Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector buf(4, 0.f); + eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash + eng.render(buf.data(), nullptr, 4); // null right -> no-op + for (float v : buf) CHECK(approx(v, 0.0, 1e-9)); // untouched +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -582,6 +694,13 @@ int main() { testAbsentLoopGoesSilent(); testVelocityToVolume(); testPolyphonyMixesAdditively(); + testChannelCount(); + testStereoRenderKeepsChannelsDistinct(); + testMonoSamplePlaysDualMonoInStereo(); + testMonoRenderUnchangedByStereoData(); + testStereoRenderAdvancesLikeMonoRepitch(); + testStereoRenderSumsVoicesPerChannel(); + testStereoRenderNullBufferIsNoOp(); if (g_fail == 0) { std::printf("all sampler_core tests passed\n");