Merge ps-w8-t1-stereo: S7 stereo channel mode

This commit is contained in:
2026-07-26 21:59:14 -04:00
11 changed files with 727 additions and 85 deletions
+22 -19
View File
@@ -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
+47 -2
View File
@@ -146,6 +146,7 @@ void ReaSamplerEditor::refreshFromBank() {
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
selectedId_ = processor_->selectedSampleId();
map_ = processor_->performanceMap();
channelMode_ = processor_->channelMode();
if (selectedZone_ >= static_cast<int>(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);
+1
View File
@@ -113,6 +113,7 @@ private:
std::vector<SampleChoice> 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
+120 -24
View File
@@ -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<std::uint8_t> 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<DecodedZonePcm> 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<std::uint8_t> bytes = readFileBytes(abs);
@@ -76,11 +82,9 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
if (!layout.valid) return std::nullopt;
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
std::vector<AudioSample> mono = downmixToMono(interleaved, layout.channelCount);
if (mono.empty()) return std::nullopt;
DecodedZonePcm out;
out.monoFrames = std::move(mono);
out.sampleRate = static_cast<int>(layout.sampleRate);
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
static_cast<int>(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<std::mutex> 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<std::uint8_t> bytes = serializeComponentState(state_out);
if (!bytes.empty()) {
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
@@ -219,6 +237,57 @@ void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
performanceMap_ = map;
}
ChannelMode ReaSamplerProcessor::channelMode() {
std::lock_guard<std::mutex> 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<AudioBus>(outs->at(0))) {
bus->setArrangement(mode == ChannelMode::Stereo ? SpeakerArr::kStereo
: SpeakerArr::kMono);
}
}
void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
{
std::lock_guard<std::mutex> 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<std::string> 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<LoadedInstrument> built;
@@ -257,7 +329,7 @@ std::string ReaSamplerProcessor::reloadFromBank() {
kept.reserve(resolved.zones.size());
for (const ResolvedZone& rz : resolved.zones) {
std::optional<DecodedZonePcm> 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<DecodedZonePcm> 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<std::size_t>(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<std::size_t>(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];
+32
View File
@@ -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;
+64 -4
View File
@@ -101,10 +101,48 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
return out;
}
Keymap buildTier0Keymap(std::vector<AudioSample> monoFrames, int sampleRate,
int rootNote, const SampleLoop& loop) {
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(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<std::size_t>(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<AudioSample>& 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<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> 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<ResolvedZone>& 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<std::uint8_t>& bytes) {
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
std::vector<std::uint8_t> 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<std::uint32_t>(state.selectionId.size()));
@@ -324,10 +370,24 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& 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
+53 -18
View File
@@ -100,12 +100,23 @@ std::vector<BankChoice> listBanks(const std::string& banksJson);
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& 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<AudioSample> 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<AudioSample> extractChannel(const std::vector<AudioSample>& 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<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> 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<AudioSample> monoFrames;
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 44100;
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
};
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& 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<AudioSample>& 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<std::uint8_t>& 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<std::uint8_t> serializeComponentState(const ComponentState& state);
+59 -8
View File
@@ -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<AudioSample>& pcm = sample_->frames;
const std::int64_t frameCount = static_cast<std::int64_t>(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<AudioSample>& 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<double>(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<double>(pcm[i0]) : 0.0;
const double s1 = (i1 >= 0 && i1 < frameCount) ? static_cast<double>(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<double>(pcm[i0]) : 0.0;
const double l1 = i1ok ? static_cast<double>(pcm[i1]) : 0.0;
const double outL = (l0 + (l1 - l0) * frac) * gain;
if (stereo) {
const double r0 = i0ok ? static_cast<double>(pcmR[i0]) : 0.0;
const double r1 = i1ok ? static_cast<double>(pcmR[i1]) : 0.0;
outR = static_cast<AudioSample>((r0 + (r1 - r0) * frac) * gain);
}
readPos_ += ratio_;
if (env_.finished()) {
active_ = false;
}
return static_cast<AudioSample>(out);
return static_cast<AudioSample>(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<AudioSample>& 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
+52 -10
View File
@@ -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<AudioSample> 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<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
std::vector<AudioSample> 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.
+158
View File
@@ -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<AudioSample> in{0.1f, 0.9f, 0.2f, 0.8f, 0.3f, 0.7f};
const std::vector<AudioSample> l = extractChannel(in, 2, 0);
const std::vector<AudioSample> 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<AudioSample> mono{0.1f, 0.2f, 0.3f};
const std::vector<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<ResolvedZone> zones;
ResolvedZone z; z.lowNote = 0; z.highNote = 127; z.rootNote = 60; zones.push_back(z);
std::vector<DecodedZonePcm> 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<std::uint8_t> 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<std::uint8_t>(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<std::uint8_t> 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;
+119
View File
@@ -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<AudioSample> 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<AudioSample> 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<AudioSample> 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<double>(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<float>(std::sin(2.0 * kPi * cycles *
static_cast<double>(i) / static_cast<double>(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<AudioSample> 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<AudioSample> 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<AudioSample> 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");