S7: stereo channel mode — core channel dimension, v4 mode state, VST3 bus negotiation, editor toggle
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+52
-17
@@ -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)
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+48
-6
@@ -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
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user