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:
2026-07-26 21:26:21 -04:00
parent ee82b50fb7
commit 4a3551b3b9
11 changed files with 727 additions and 85 deletions
+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