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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user