From dff39662be252b3f5852f14596cd372aa3c2ed35 Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Tue, 6 Jan 2026 22:45:04 -0500 Subject: [PATCH 01/14] Refactor: share buffer reader between the mono and stereo sample seq nodes --- runtime/elem/builtins/SampleSeq.h | 184 ++----------------- runtime/elem/builtins/helpers/BufferReader.h | 71 +++++++ runtime/elem/builtins/mc/SampleSeq.h | 95 +--------- 3 files changed, 99 insertions(+), 251 deletions(-) create mode 100644 runtime/elem/builtins/helpers/BufferReader.h diff --git a/runtime/elem/builtins/SampleSeq.h b/runtime/elem/builtins/SampleSeq.h index 8027ecca..47edde2c 100644 --- a/runtime/elem/builtins/SampleSeq.h +++ b/runtime/elem/builtins/SampleSeq.h @@ -1,175 +1,24 @@ #pragma once +#include +#include + #include "../GraphNode.h" #include "../SingleWriterSingleReaderQueue.h" #include "../Types.h" - +#include "helpers/BufferReader.h" #include "helpers/RefCountedPool.h" -#include "../third-party/signalsmith-stretch/signalsmith-stretch.h" - -#include -#include +#include "../third-party/signalsmith-stretch/signalsmith-stretch.h" namespace elem { - namespace detail - { - template - FloatType lerp (FloatType alpha, FloatType x, FloatType y) { - return x + alpha * (y - x); - } - - template - FloatType fpEqual (FloatType x, FloatType y) { - return std::abs(x - y) <= FloatType(1e-6); - } - - template - struct GainFade { - GainFade() = default; - - void setTargetGain (FloatType g) { - targetGain = g; - - if (targetGain < currentGain) { - step = FloatType(-1) * std::abs(step); - } else { - step = std::abs(step); - } - } - - FloatType operator() (FloatType x) { - if (currentGain == targetGain) - return (currentGain * x); - - auto y = x * currentGain; - currentGain = std::clamp(currentGain + step, FloatType(0), FloatType(1)); - - return y; - } - - bool on() { - return fpEqual(targetGain, FloatType(1)); - } - - bool silent() { - return fpEqual(targetGain, FloatType(0)) && fpEqual(currentGain, FloatType(0)); - } - - void reset() { - currentGain = FloatType(0); - targetGain = FloatType(0); - } - - FloatType currentGain = 0; - FloatType targetGain = 0; - FloatType step = 0.02; // TODO - }; - - template - struct BufferReader { - BufferReader() = default; - - void engage (double start, double currentTime, FloatType* _buffer, size_t _size) { - startTime = start; - buffer = _buffer; - bufferSize = _size; - fade.setTargetGain(FloatType(1)); - - position = static_cast(((currentTime - startTime) / sampleDuration) * (double) (bufferSize - 1u)); - position = std::clamp(position, 0, bufferSize); - } - - void disengage() { - fade.setTargetGain(FloatType(0)); - } - - // Does the incoming time match what this reader is expecting? - // - // If we're not engaged, we don't have any expectations so we just say sure. - // If we are engaged, we try to map the incoming time onto a position in the - // buffer and see if that's far off from where we currently are. - bool isAlignedWithTime(double t) { - if (!fade.on()) - return true; - - size_t newPos = static_cast(((t - startTime) / sampleDuration) * (double) (bufferSize - 1u)); - int delta = static_cast(position) - static_cast(newPos); - bool aligned = std::abs(delta) < 16; - - return aligned; - } - - template - void readAdding(DestType* outputData, size_t numSamples) { - for (size_t i = 0; (i < numSamples) && (position < bufferSize); ++i) { - outputData[i] += static_cast(fade(buffer[position++])); - } - } - - FloatType read (FloatType const* buffer, size_t size, double t) - { - if (fade.silent() || sampleDuration <= FloatType(0)) - return FloatType(0); - - // An allocated but inactive reader is currently fading out at the point in time - // from which we jumped to allocate a new reader - double const pos = fade.on() - ? (t - startTime) / sampleDuration - : (stepStopTime() - startTime) / sampleDuration; - - // While we're still active, track last position so that we can stop effectively - if (fade.on()) { - dt = t - lastTimeStep; - lastTimeStep = t; - } - - // Deallocate if we've run out of bounds - if (pos < 0.0 || pos >= 1.0) { - disengage(); - return FloatType(0); - } - - // Instead of clamping here, we could accept loop points in the sample and - // mod the playback position within those loop points. Property loop: [start, stop] - auto l = static_cast(pos * (double) (size - 1u)); - auto r = std::min(size, l + 1u); - auto const alpha = FloatType((pos * (double) (size - 1u)) - static_cast(l)); - - return fade(lerp(alpha, buffer[l], buffer[r])); - } - - FloatType stepStopTime() { - lastTimeStep += dt; - return lastTimeStep; - } - - void reset (double sampleDur) { - fade.reset(); - - sampleDuration = sampleDur; - startTime = 0.0; - dt = 0.0; - } - - GainFade fade; - FloatType* buffer = nullptr; - size_t bufferSize = 0; - size_t position = 0; - - double sampleDuration = 0; - double startTime = 0; - double lastTimeStep = 0; - double dt = 0; - }; - } - template struct SampleSeqNode : public GraphNode { SampleSeqNode(NodeId id, FloatType const sr, int const blockSize) : GraphNode::GraphNode(id, sr, blockSize) + , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) { if constexpr (WithStretch) { stretch.presetDefault(1, sr); @@ -273,9 +122,9 @@ namespace elem // Here a value of 1.0 is considered an onset, and anything else // considered an offset. - if (detail::fpEqual(prevEvent->second, FloatType(1.0))) { + if (fpEqual(prevEvent->second, FloatType(1.0))) { auto const bufferView = activeBuffer->getChannelData(0); - readers[activeReader].engage(prevEvent->first, t, const_cast(bufferView.data()), bufferView.size()); + readers[activeReader].engage(prevEvent->first, t, bufferView.size()); } } } @@ -338,10 +187,15 @@ namespace elem || (prevEvent != seqEnd && before(t, prevEvent->first)) || (nextEvent != seqEnd && after(t, nextEvent->first)); + double const timeUnitsPerSample = sampleDur / (double) activeBuffer->numSamples(); + int64_t const sampleTime = t / timeUnitsPerSample; + bool const significantTimeChange = std::abs(sampleTime - nextExpectedBlockStart) > 16; + nextExpectedBlockStart = sampleTime + numSamples; + // TODO: if the input time has changed significantly, need to address the input latency of // the phase vocoder by resetting it and then pushing stretch.inputLatency * stretchFactor // samples ahead of `timeInSamples(t)` - if (shouldUpdateBounds || !readers[activeReader].isAlignedWithTime(t)) { + if (shouldUpdateBounds || significantTimeChange) { updateEventBoundaries(t); } @@ -365,16 +219,16 @@ namespace elem // Clear and read std::fill_n(scratchData, numSourceSamples, FloatType(0)); - readers[0].readAdding(scratchData, numSourceSamples); - readers[1].readAdding(scratchData, numSourceSamples); + readers[0].readAdding(activeBuffer.get(), &scratchData, 1, numSourceSamples); + readers[1].readAdding(activeBuffer.get(), &scratchData, 1, numSourceSamples); stretch.process(&scratchData, numSourceSamples, &outputData, numSamples); } else { // Clear and read std::fill_n(outputData, numSamples, FloatType(0)); - readers[0].readAdding(outputData, numSamples); - readers[1].readAdding(outputData, numSamples); + readers[0].readAdding(activeBuffer.get(), &outputData, 1, numSamples); + readers[1].readAdding(activeBuffer.get(), &outputData, 1, numSamples); } } @@ -390,7 +244,7 @@ namespace elem SingleWriterSingleReaderQueue bufferQueue; SharedResourcePtr activeBuffer; - std::array, 2> readers; + std::array, 2> readers; size_t activeReader = 0; int64_t nextExpectedBlockStart = 0; diff --git a/runtime/elem/builtins/helpers/BufferReader.h b/runtime/elem/builtins/helpers/BufferReader.h new file mode 100644 index 00000000..ff61c27e --- /dev/null +++ b/runtime/elem/builtins/helpers/BufferReader.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +#include "GainFade.h" +#include "elem/SharedResource.h" + +namespace elem +{ + + template + struct BufferReader { + BufferReader(double sampleRate, double fadeTime) + : fade(sampleRate, fadeTime, fadeTime) + { + } + + void engage (double start, double currentTime, size_t _bufferSize) { + startTime = start; + bufferSize = _bufferSize; + fade.fadeIn(); + + position = static_cast(((currentTime - startTime) / sampleDuration) * (double) (bufferSize - 1u)); + position = std::clamp(position, 0, bufferSize); + } + + void disengage() { + fade.fadeOut(); + } + + template + void readAdding(SharedResource* resource, DestType** outputData, size_t numChannels, size_t numSamples) { + elem::GainFade localFade(fade); + + for (size_t j = 0; j < std::min(numChannels, resource->numChannels()); ++j) { + auto bufferView = resource->getChannelData(j); + auto bufferSize = bufferView.size(); + auto* sourceData = bufferView.data(); + + // Reinitialize the local copy to match our member instance + localFade = fade; + + for (size_t i = 0; (i < numSamples) && ((position + i) < bufferSize); ++i) { + outputData[j][i] += static_cast(localFade(sourceData[position + i])); + } + } + + // Here we have a localFade instance that has finished running over a block, which + // represents where our class instance should now be + fade = localFade; + + // And update our position + position += numSamples; + } + + void reset (double sampleDur) { + fade.reset(); + + sampleDuration = sampleDur; + startTime = 0.0; + } + + elem::GainFade fade; + size_t bufferSize = 0; + size_t position = 0; + + double sampleDuration = 0; + double startTime = 0; + }; +} // namespace elem \ No newline at end of file diff --git a/runtime/elem/builtins/mc/SampleSeq.h b/runtime/elem/builtins/mc/SampleSeq.h index c4e151b8..2229b4ba 100644 --- a/runtime/elem/builtins/mc/SampleSeq.h +++ b/runtime/elem/builtins/mc/SampleSeq.h @@ -1,97 +1,20 @@ #pragma once -#include "../helpers/FloatUtils.h" -#include "../helpers/GainFade.h" - +#include "elem/GraphNode.h" +#include "elem/SingleWriterSingleReaderQueue.h" +#include "elem/Types.h" +#include "elem/builtins/helpers/BufferReader.h" +#include "elem/builtins/helpers/RefCountedPool.h" +#include "elem/third-party/signalsmith-stretch/signalsmith-stretch.h" namespace elem { - namespace detail - { - - template - struct MCBufferReader { - MCBufferReader(double sampleRate, double fadeTime) - : fade(sampleRate, fadeTime, fadeTime) - { - } - - void engage (double start, double currentTime, size_t _bufferSize) { - startTime = start; - bufferSize = _bufferSize; - fade.fadeIn(); - - position = static_cast(((currentTime - startTime) / sampleDuration) * (double) (bufferSize - 1u)); - position = std::clamp(position, 0, bufferSize); - } - - void disengage() { - fade.fadeOut(); - } - - // Does the incoming time match what this reader is expecting? - // - // If we're not engaged, we don't have any expectations so we just say sure. - // If we are engaged, we try to map the incoming time onto a position in the - // buffer and see if that's far off from where we currently are. - bool isAlignedWithTime(double t) { - if (!fade.on()) - return true; - - size_t newPos = static_cast(((t - startTime) / sampleDuration) * (double) (bufferSize - 1u)); - int delta = static_cast(position) - static_cast(newPos); - bool aligned = std::abs(delta) < 16; - - return aligned; - } - - template - void readAdding(SharedResource* resource, DestType** outputData, size_t numChannels, size_t numSamples) { - elem::GainFade localFade(fade); - - for (size_t j = 0; j < std::min(numChannels, resource->numChannels()); ++j) { - auto bufferView = resource->getChannelData(j); - auto bufferSize = bufferView.size(); - auto* sourceData = bufferView.data(); - - // Reinitialize the local copy to match our member instance - localFade = fade; - - for (size_t i = 0; (i < numSamples) && ((position + i) < bufferSize); ++i) { - outputData[j][i] += static_cast(localFade(sourceData[position + i])); - } - } - - // Here we have a localFade instance that has finished running over a block, which - // represents where our class instance should now be - fade = localFade; - - // And update our position - position += numSamples; - } - - void reset (double sampleDur) { - fade.reset(); - - sampleDuration = sampleDur; - startTime = 0.0; - } - - elem::GainFade fade; - size_t bufferSize = 0; - size_t position = 0; - - double sampleDuration = 0; - double startTime = 0; - }; - } - template struct StereoSampleSeqNode : public GraphNode { StereoSampleSeqNode(NodeId id, FloatType const sr, int const blockSize) : GraphNode::GraphNode(id, sr, blockSize) - , readers({detail::MCBufferReader(sr, 8.0), detail::MCBufferReader(sr, 8.0)}) + , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) { if constexpr (WithStretch) { stretch.presetDefault(2, sr); @@ -199,7 +122,7 @@ namespace elem // Here a value of 1.0 is considered an onset, and anything else // considered an offset. - if (detail::fpEqual(prevEvent->second, FloatType(1.0))) { + if (fpEqual(prevEvent->second, FloatType(1.0))) { readers[activeReader].engage(prevEvent->first, t, activeBuffer->numSamples()); } } @@ -331,7 +254,7 @@ namespace elem SingleWriterSingleReaderQueue bufferQueue; SharedResourcePtr activeBuffer; - std::array, 2> readers; + std::array, 2> readers; size_t activeReader = 0; int64_t nextExpectedBlockStart = 0; From 07db3cf79c527596703f512d3b221369841dda51 Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Wed, 7 Jan 2026 00:33:04 -0500 Subject: [PATCH 02/14] Add support for start/stop offsets and looping in BufferReader, which will allow it to be used by the remaining sample nodes --- runtime/elem/builtins/SampleSeq.h | 30 +++++++- runtime/elem/builtins/helpers/BufferReader.h | 79 ++++++++++++++++---- runtime/elem/builtins/helpers/GainFade.h | 4 + runtime/elem/builtins/mc/SampleSeq.h | 34 +++++++-- 4 files changed, 121 insertions(+), 26 deletions(-) diff --git a/runtime/elem/builtins/SampleSeq.h b/runtime/elem/builtins/SampleSeq.h index 47edde2c..41f42867 100644 --- a/runtime/elem/builtins/SampleSeq.h +++ b/runtime/elem/builtins/SampleSeq.h @@ -16,6 +16,8 @@ namespace elem template struct SampleSeqNode : public GraphNode { + using ReaderContext = typename BufferReader::template ReadContext; + SampleSeqNode(NodeId id, FloatType const sr, int const blockSize) : GraphNode::GraphNode(id, sr, blockSize) , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) @@ -219,16 +221,36 @@ namespace elem // Clear and read std::fill_n(scratchData, numSourceSamples, FloatType(0)); - readers[0].readAdding(activeBuffer.get(), &scratchData, 1, numSourceSamples); - readers[1].readAdding(activeBuffer.get(), &scratchData, 1, numSourceSamples); + readers[0].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = &scratchData, + .numChannels = 1, + .numSamples = numSourceSamples, + }); + readers[1].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = &scratchData, + .numChannels = 1, + .numSamples = numSourceSamples, + }); stretch.process(&scratchData, numSourceSamples, &outputData, numSamples); } else { // Clear and read std::fill_n(outputData, numSamples, FloatType(0)); - readers[0].readAdding(activeBuffer.get(), &outputData, 1, numSamples); - readers[1].readAdding(activeBuffer.get(), &outputData, 1, numSamples); + readers[0].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = &outputData, + .numChannels = 1, + .numSamples = numSamples, + }); + readers[1].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = &outputData, + .numChannels = 1, + .numSamples = numSamples, + }); } } diff --git a/runtime/elem/builtins/helpers/BufferReader.h b/runtime/elem/builtins/helpers/BufferReader.h index ff61c27e..20c8771a 100644 --- a/runtime/elem/builtins/helpers/BufferReader.h +++ b/runtime/elem/builtins/helpers/BufferReader.h @@ -30,28 +30,75 @@ namespace elem } template - void readAdding(SharedResource* resource, DestType** outputData, size_t numChannels, size_t numSamples) { - elem::GainFade localFade(fade); + struct ReadContext { + SharedResource* source; + DestType** outputData; + size_t numChannels; + size_t numSamples; + int startOffset = -1; + int stopOffset = -1; + bool shouldLoop = false; + }; - for (size_t j = 0; j < std::min(numChannels, resource->numChannels()); ++j) { - auto bufferView = resource->getChannelData(j); - auto bufferSize = bufferView.size(); - auto* sourceData = bufferView.data(); + template + void readAdding(ReadContext const& ctx) { + if (ctx.source == nullptr || position < 0.0 || fade.fadedOut()) { + return; + } - // Reinitialize the local copy to match our member instance - localFade = fade; + auto const numChannels = std::min(ctx.numChannels, ctx.source->numChannels()); + auto const bufferSize = ctx.source->numSamples(); + if (numChannels == 0 || bufferSize == 0) { + return; + } - for (size_t i = 0; (i < numSamples) && ((position + i) < bufferSize); ++i) { - outputData[j][i] += static_cast(localFade(sourceData[position + i])); + auto const startOffset = ctx.startOffset >= 0 ? std::min(ctx.startOffset, static_cast(bufferSize)) : 0; + auto const stopOffset = ctx.stopOffset >= 0 ? std::min(ctx.stopOffset, static_cast(bufferSize)) : 0; + + auto pos = position; + elem::GainFade localFade(fade); + + for (size_t j = 0; j < numChannels; ++j) { + pos = position; + localFade = fade; + auto bufferView = ctx.source->getChannelData(j); + auto* sourceData = bufferView.data(); + size_t const sourceLength = bufferView.size(); + + for (size_t i = 0; i < ctx.numSamples; ++i) { + if (pos >= (double) (sourceLength - stopOffset)) { + if (!ctx.shouldLoop) { + break; + } + pos = (double) startOffset; + } + + // Linear interpolation on the buffer read + auto readLeft = static_cast(pos); + auto readRight = readLeft + 1; + auto const frac = FloatType(pos - (double) readLeft); + + if (readLeft >= sourceLength) + readLeft -= sourceLength; + + if (readRight >= sourceLength) + readRight -= sourceLength; + + auto const left = sourceData[readLeft]; + auto const right = sourceData[readRight]; + + // Now we can read the next sample out of the buffer with linear + // interpolation for sub-sample reads. + auto const out = localFade(left + frac * (right - left)); + ctx.outputData[j][i] += static_cast(out); + ++pos; } } - // Here we have a localFade instance that has finished running over a block, which - // represents where our class instance should now be + // Now update the position member to match the updated local position + position = pos; + // Similarly, update the fade member to have the latest state fade = localFade; - - // And update our position - position += numSamples; } void reset (double sampleDur) { @@ -68,4 +115,4 @@ namespace elem double sampleDuration = 0; double startTime = 0; }; -} // namespace elem \ No newline at end of file +} // namespace elem diff --git a/runtime/elem/builtins/helpers/GainFade.h b/runtime/elem/builtins/helpers/GainFade.h index c79efe98..a9057a51 100644 --- a/runtime/elem/builtins/helpers/GainFade.h +++ b/runtime/elem/builtins/helpers/GainFade.h @@ -99,6 +99,10 @@ namespace elem return (targetGain.load() > FloatType(0.5)); } + bool fadedOut() const { + return targetGain.load() == FloatType(0) && currentGain.load() == FloatType(0); + } + bool settled() { return fpEqual(targetGain.load(), currentGain.load()); } diff --git a/runtime/elem/builtins/mc/SampleSeq.h b/runtime/elem/builtins/mc/SampleSeq.h index 2229b4ba..85d3479c 100644 --- a/runtime/elem/builtins/mc/SampleSeq.h +++ b/runtime/elem/builtins/mc/SampleSeq.h @@ -12,9 +12,11 @@ namespace elem template struct StereoSampleSeqNode : public GraphNode { + using ReaderContext = typename BufferReader::template ReadContext; + StereoSampleSeqNode(NodeId id, FloatType const sr, int const blockSize) : GraphNode::GraphNode(id, sr, blockSize) - , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) + , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) { if constexpr (WithStretch) { stretch.presetDefault(2, sr); @@ -227,8 +229,18 @@ namespace elem std::array ptrs {{scratchData, scratchData + (numSamples * 4)}}; auto** scratchPtrs = ptrs.data(); - readers[0].readAdding(activeBuffer.get(), scratchPtrs, ctx.numOutputChannels, numSourceSamples); - readers[1].readAdding(activeBuffer.get(), scratchPtrs, ctx.numOutputChannels, numSourceSamples); + readers[0].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = scratchPtrs, + .numChannels = ctx.numOutputChannels, + .numSamples = numSourceSamples, + }); + readers[1].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = scratchPtrs, + .numChannels = ctx.numOutputChannels, + .numSamples = numSourceSamples, + }); stretch.process(scratchPtrs, static_cast(numSourceSamples), outputData, static_cast(numSamples)); } else { @@ -237,8 +249,18 @@ namespace elem std::fill_n(outputData[i], numSamples, FloatType(0)); } - readers[0].readAdding(activeBuffer.get(), outputData, ctx.numOutputChannels, numSamples); - readers[1].readAdding(activeBuffer.get(), outputData, ctx.numOutputChannels, numSamples); + readers[0].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = outputData, + .numChannels = ctx.numOutputChannels, + .numSamples = numSamples, + }); + readers[1].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = outputData, + .numChannels = ctx.numOutputChannels, + .numSamples = numSamples, + }); } } @@ -254,7 +276,7 @@ namespace elem SingleWriterSingleReaderQueue bufferQueue; SharedResourcePtr activeBuffer; - std::array, 2> readers; + std::array, 2> readers; size_t activeReader = 0; int64_t nextExpectedBlockStart = 0; From 3a7a52cf4a52068fb22c128dcf31fba8aebc53dc Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Wed, 7 Jan 2026 00:57:27 -0500 Subject: [PATCH 03/14] Refactor the single channel Sample node to use the shared BufferReader --- runtime/elem/builtins/Sample.h | 128 +++++-------------- runtime/elem/builtins/helpers/BufferReader.h | 13 +- 2 files changed, 44 insertions(+), 97 deletions(-) diff --git a/runtime/elem/builtins/Sample.h b/runtime/elem/builtins/Sample.h index 7f86dc90..df0ecbf8 100644 --- a/runtime/elem/builtins/Sample.h +++ b/runtime/elem/builtins/Sample.h @@ -5,22 +5,27 @@ #include "../Types.h" #include "./helpers/Change.h" +#include "elem/builtins/helpers/BufferReader.h" namespace elem { - template - struct VariablePitchLerpReader; - // SampleNode is a core builtin for sample playback. // // The sample file is loaded from disk or from virtual memory with a path set by the `path` property. // The sample is then triggered on the rising edge of an incoming pulse train, so // this node expects a single child node delivering that train. - template > + template struct SampleNode : public GraphNode { using GraphNode::GraphNode; + using ReaderContext = typename BufferReader::template ReadContext; + + SampleNode(NodeId id, double sr, size_t blockSize) + : GraphNode::GraphNode(id, sr, blockSize) + , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) + { + } int setProperty(std::string const& key, js::Value const& val, SharedResourceMap& resources) override { @@ -76,8 +81,8 @@ namespace elem } void reset() override { - readers[0].noteOff(); - readers[1].noteOff(); + readers[0].disengage(); + readers[1].disengage(); } void process (BlockContext const& ctx) override { @@ -93,10 +98,7 @@ namespace elem // while playing the sample will cause a discontinuity. while (bufferQueue.size() > 0) { bufferQueue.pop(activeBuffer); - - readers[0] = ReaderType(sampleRate, activeBuffer); - readers[1] = ReaderType(sampleRate, activeBuffer); - } + } // If we don't have an input trigger or an active buffer, we can just return here if (numChannels < 1 || activeBuffer == nullptr) @@ -119,17 +121,35 @@ namespace elem // Rising edge if (cv > FloatType(0.5)) { - readers[currentReader & 1].noteOff(); - readers[++currentReader & 1].noteOn(ostart); + readers[currentReader & 1].disengage(); + readers[++currentReader & 1].engage(ostart, 0, activeBuffer->numSamples()); } // If we're in trigger mode then we can ignore falling edges if (cv < FloatType(-0.5) && playbackMode != Mode::Trigger) { - readers[currentReader & 1].noteOff(); + readers[currentReader & 1].disengage(); } + auto outputChannels = std::array{&outputData[i]}; // Process both readers for the current sample - outputData[i] = readers[0].tick(ostart, ostop, rate, wantsLoop) + readers[1].tick(ostart, ostop, rate, wantsLoop); + readers[0].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = outputChannels.data(), + .numChannels = 1, + .numSamples = 1, + .startOffset = ostart, + .stopOffset = ostop, + .shouldLoop = wantsLoop, + }); + readers[1].readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = outputChannels.data(), + .numChannels = 1, + .numSamples = 1, + .startOffset = ostart, + .stopOffset = ostop, + .shouldLoop = wantsLoop, + }); } } @@ -137,7 +157,7 @@ namespace elem SharedResourcePtr activeBuffer; Change change; - std::array readers; + std::array, 2> readers; size_t currentReader = 0; enum class Mode @@ -152,82 +172,4 @@ namespace elem std::atomic stopOffset = 0; }; - // A helper struct for reading from sample data with variable rate using - // linear interpolation. - template - struct VariablePitchLerpReader - { - VariablePitchLerpReader() = default; - - VariablePitchLerpReader(FloatType _sampleRate, SharedResourcePtr _sourceBuffer) - : sourceBuffer(_sourceBuffer), sampleRate(_sampleRate), gainSmoothAlpha(1.0 - std::exp(-1.0 / (0.01 * _sampleRate))) {} - - VariablePitchLerpReader(VariablePitchLerpReader& other) - : sourceBuffer(other.sourceBuffer), sampleRate(other.sampleRate), gainSmoothAlpha(1.0 - std::exp(-1.0 / (0.01 * other.sampleRate))) {} - - void noteOn(size_t const startOffset) - { - targetGain = FloatType(1); - pos = FloatType(startOffset); - } - - void noteOff() - { - targetGain = FloatType(0); - } - - FloatType tick (size_t const startOffset, size_t const stopOffset, FloatType const stepSize, bool const wantsLoop) - { - if (sourceBuffer == nullptr || pos < 0.0 || (gain == FloatType(0) && targetGain == FloatType(0))) - return FloatType(0); - - auto bufferView = sourceBuffer->getChannelData(0); - auto* sourceData = bufferView.data(); - size_t const sourceLength = bufferView.size(); - - if (pos >= (double) (sourceLength - stopOffset)) { - if (!wantsLoop) { - return FloatType(0); - } - - pos = (double) startOffset; - } - - // Linear interpolation on the buffer read - auto readLeft = static_cast(pos); - auto readRight = readLeft + 1; - auto const frac = FloatType(pos - (double) readLeft); - - if (readLeft >= sourceLength) - readLeft -= sourceLength; - - if (readRight >= sourceLength) - readRight -= sourceLength; - - auto const left = sourceData[readLeft]; - auto const right = sourceData[readRight]; - - // Now we can read the next sample out of the buffer with linear - // interpolation for sub-sample reads. - auto const out = gain * (left + frac * (right - left)); - auto const gainSettled = std::abs(targetGain - gain) <= std::numeric_limits::epsilon(); - - // Update our state - pos = pos + (double) stepSize; - gain = gainSettled ? targetGain : gain + gainSmoothAlpha * (targetGain - gain); - gain = std::clamp(gain, FloatType(0), FloatType(1)); - - // And return - return out; - } - - SharedResourcePtr sourceBuffer; - - FloatType sampleRate = 0; - FloatType gainSmoothAlpha = 0; - FloatType targetGain = 0; - FloatType gain = 0; - double pos = 0; - }; - } // namespace elem diff --git a/runtime/elem/builtins/helpers/BufferReader.h b/runtime/elem/builtins/helpers/BufferReader.h index 20c8771a..65af6a06 100644 --- a/runtime/elem/builtins/helpers/BufferReader.h +++ b/runtime/elem/builtins/helpers/BufferReader.h @@ -2,6 +2,7 @@ #include #include +#include #include "GainFade.h" #include "elem/SharedResource.h" @@ -35,8 +36,8 @@ namespace elem DestType** outputData; size_t numChannels; size_t numSamples; - int startOffset = -1; - int stopOffset = -1; + std::optional startOffset; + std::optional stopOffset; bool shouldLoop = false; }; @@ -52,8 +53,12 @@ namespace elem return; } - auto const startOffset = ctx.startOffset >= 0 ? std::min(ctx.startOffset, static_cast(bufferSize)) : 0; - auto const stopOffset = ctx.stopOffset >= 0 ? std::min(ctx.stopOffset, static_cast(bufferSize)) : 0; + auto const _startOffset = ctx.startOffset.value_or(0); + auto const _stopOffset = ctx.stopOffset.value_or(0); + auto const startOffset = _startOffset >= 0 ? + std::min(_startOffset, static_cast(bufferSize)) : 0; + auto const stopOffset = _stopOffset >= 0 ? + std::min(_stopOffset, static_cast(bufferSize)) : 0; auto pos = position; elem::GainFade localFade(fade); From 5757de5ecdf373da57256939300cb42684ed5d69 Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Wed, 7 Jan 2026 23:19:33 -0500 Subject: [PATCH 04/14] Simplify BufferReader a bit by tracking normalized position and reading from a buffer view that has already clamped the range to respect start/stop offsets --- runtime/elem/Types.h | 4 + runtime/elem/builtins/Sample.h | 14 ++- runtime/elem/builtins/SampleSeq.h | 12 ++- runtime/elem/builtins/helpers/BufferReader.h | 104 ++++++++++--------- runtime/elem/builtins/mc/SampleSeq.h | 12 ++- 5 files changed, 80 insertions(+), 66 deletions(-) diff --git a/runtime/elem/Types.h b/runtime/elem/Types.h index c0206bf4..eb4a5e41 100644 --- a/runtime/elem/Types.h +++ b/runtime/elem/Types.h @@ -115,6 +115,10 @@ namespace elem : _data(__data), _size(__size) {} + static BufferView subview(FloatType* __data, size_t start, size_t length) { + return BufferView(__data + start, length); + } + FloatType operator[] (size_t i) { return _data[i]; } diff --git a/runtime/elem/builtins/Sample.h b/runtime/elem/builtins/Sample.h index df0ecbf8..649e9ade 100644 --- a/runtime/elem/builtins/Sample.h +++ b/runtime/elem/builtins/Sample.h @@ -122,7 +122,7 @@ namespace elem // Rising edge if (cv > FloatType(0.5)) { readers[currentReader & 1].disengage(); - readers[++currentReader & 1].engage(ostart, 0, activeBuffer->numSamples()); + readers[++currentReader & 1].engage(0); } // If we're in trigger mode then we can ignore falling edges @@ -137,18 +137,20 @@ namespace elem .outputData = outputChannels.data(), .numChannels = 1, .numSamples = 1, - .startOffset = ostart, - .stopOffset = ostop, + .startOffsetSamples = ostart, + .stopOffsetSamples = ostop, .shouldLoop = wantsLoop, + .playbackRate = rate, }); readers[1].readAdding(ReaderContext{ .source = activeBuffer.get(), .outputData = outputChannels.data(), .numChannels = 1, .numSamples = 1, - .startOffset = ostart, - .stopOffset = ostop, + .startOffsetSamples = ostart, + .stopOffsetSamples = ostop, .shouldLoop = wantsLoop, + .playbackRate = rate, }); } } @@ -170,6 +172,8 @@ namespace elem std::atomic mode = Mode::Trigger; std::atomic startOffset = 0; std::atomic stopOffset = 0; + + double normalizedPosition = 0; }; } // namespace elem diff --git a/runtime/elem/builtins/SampleSeq.h b/runtime/elem/builtins/SampleSeq.h index 41f42867..40415ee7 100644 --- a/runtime/elem/builtins/SampleSeq.h +++ b/runtime/elem/builtins/SampleSeq.h @@ -126,7 +126,9 @@ namespace elem // considered an offset. if (fpEqual(prevEvent->second, FloatType(1.0))) { auto const bufferView = activeBuffer->getChannelData(0); - readers[activeReader].engage(prevEvent->first, t, bufferView.size()); + // Map t to a normalized position relative to the sample + double const pos = rtSampleDuration > 0.0 ? prevEvent->first / rtSampleDuration : 0.0; + readers[activeReader].engage(pos); } } } @@ -141,8 +143,8 @@ namespace elem auto const sampleDur = sampleDuration.load(); if (sampleDur != rtSampleDuration) { - readers[0].reset(sampleDur); - readers[1].reset(sampleDur); + readers[0].reset(); + readers[1].reset(); rtSampleDuration = sampleDur; } @@ -150,8 +152,8 @@ namespace elem while (bufferQueue.size() > 0) { bufferQueue.pop(activeBuffer); - readers[0].reset(sampleDur); - readers[1].reset(sampleDur); + readers[0].reset(); + readers[1].reset(); } // Pull newest seq from queue diff --git a/runtime/elem/builtins/helpers/BufferReader.h b/runtime/elem/builtins/helpers/BufferReader.h index 65af6a06..d80e4f42 100644 --- a/runtime/elem/builtins/helpers/BufferReader.h +++ b/runtime/elem/builtins/helpers/BufferReader.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -9,22 +10,16 @@ namespace elem { - template struct BufferReader { BufferReader(double sampleRate, double fadeTime) : fade(sampleRate, fadeTime, fadeTime) - { - } + {} - void engage (double start, double currentTime, size_t _bufferSize) { - startTime = start; - bufferSize = _bufferSize; + void engage (double _position) { fade.fadeIn(); - - position = static_cast(((currentTime - startTime) / sampleDuration) * (double) (bufferSize - 1u)); - position = std::clamp(position, 0, bufferSize); - } + position = _position; + } void disengage() { fade.fadeOut(); @@ -36,14 +31,15 @@ namespace elem DestType** outputData; size_t numChannels; size_t numSamples; - std::optional startOffset; - std::optional stopOffset; + std::optional startOffsetSamples; + std::optional stopOffsetSamples; bool shouldLoop = false; + double playbackRate = 1.0; }; template void readAdding(ReadContext const& ctx) { - if (ctx.source == nullptr || position < 0.0 || fade.fadedOut()) { + if (ctx.source == nullptr || fade.fadedOut()) { return; } @@ -53,71 +49,77 @@ namespace elem return; } - auto const _startOffset = ctx.startOffset.value_or(0); - auto const _stopOffset = ctx.stopOffset.value_or(0); + auto const _startOffset = ctx.startOffsetSamples.value_or(0); + auto const _stopOffset = ctx.stopOffsetSamples.value_or(0); auto const startOffset = _startOffset >= 0 ? std::min(_startOffset, static_cast(bufferSize)) : 0; auto const stopOffset = _stopOffset >= 0 ? std::min(_stopOffset, static_cast(bufferSize)) : 0; + auto const sampleLength = bufferSize - startOffset - stopOffset; - auto pos = position; elem::GainFade localFade(fade); + double pos = position; for (size_t j = 0; j < numChannels; ++j) { pos = position; localFade = fade; - auto bufferView = ctx.source->getChannelData(j); - auto* sourceData = bufferView.data(); - size_t const sourceLength = bufferView.size(); - + + // Here we take a subview of the buffer that ignores samples before the start offset and after the stop offset. + // This view then gets passed into lerpRead() below. This means we can treat a pos of 0 as `startOffset` and a + // pos of 1 as `startOffset + sampleLength`. + auto bufferView = BufferView::subview(ctx.source->getChannelData(j).data(), + startOffset, sampleLength); for (size_t i = 0; i < ctx.numSamples; ++i) { - if (pos >= (double) (sourceLength - stopOffset)) { + if (pos >= 1.0) { if (!ctx.shouldLoop) { break; } - pos = (double) startOffset; + // Restart the loop. Note there is no crossfade happening yet, + // so loops may be discontinuous. + pos = pos - 1.0; } - // Linear interpolation on the buffer read - auto readLeft = static_cast(pos); - auto readRight = readLeft + 1; - auto const frac = FloatType(pos - (double) readLeft); - - if (readLeft >= sourceLength) - readLeft -= sourceLength; - - if (readRight >= sourceLength) - readRight -= sourceLength; + auto const out = static_cast(localFade(lerpRead(bufferView, pos))); + ctx.outputData[j][i] += out; - auto const left = sourceData[readLeft]; - auto const right = sourceData[readRight]; - - // Now we can read the next sample out of the buffer with linear - // interpolation for sub-sample reads. - auto const out = localFade(left + frac * (right - left)); - ctx.outputData[j][i] += static_cast(out); - ++pos; + pos += (ctx.playbackRate / static_cast(sampleLength)); } } - // Now update the position member to match the updated local position - position = pos; - // Similarly, update the fade member to have the latest state + // Update the fade member to have the latest state fade = localFade; + position = pos; } - void reset (double sampleDur) { - fade.reset(); + // Linearly interpolates between the two samples adjacent to the given position. + // @param pos must be a normalized value between 0 and 1. + static FloatType lerpRead(BufferView const& view, double pos) + { + assert(pos >= 0.0 && pos <= 1.0); + + auto* data = view.data(); + auto size = view.size(); + + auto const realPos = pos * view.size(); + auto left = static_cast(realPos); + auto right = std::min(left + 1, size - 1); + auto alpha = realPos - (double) left; - sampleDuration = sampleDur; - startTime = 0.0; + if (left >= size) + return FloatType(0); + + if (right >= size) + return data[left]; + + return lerp(static_cast(alpha), data[left], data[right]); + } + + void reset () { + fade.reset(); } elem::GainFade fade; - size_t bufferSize = 0; - size_t position = 0; - double sampleDuration = 0; - double startTime = 0; + double position = 0; }; } // namespace elem diff --git a/runtime/elem/builtins/mc/SampleSeq.h b/runtime/elem/builtins/mc/SampleSeq.h index 85d3479c..b491ef72 100644 --- a/runtime/elem/builtins/mc/SampleSeq.h +++ b/runtime/elem/builtins/mc/SampleSeq.h @@ -125,7 +125,9 @@ namespace elem // Here a value of 1.0 is considered an onset, and anything else // considered an offset. if (fpEqual(prevEvent->second, FloatType(1.0))) { - readers[activeReader].engage(prevEvent->first, t, activeBuffer->numSamples()); + // Map t to a normalized position relative to the sample + double const pos = rtSampleDuration > 0.0 ? prevEvent->first / rtSampleDuration : 0.0; + readers[activeReader].engage(pos); } } } @@ -139,8 +141,8 @@ namespace elem auto const sampleDur = sampleDuration.load(); if (sampleDur != rtSampleDuration) { - readers[0].reset(sampleDur); - readers[1].reset(sampleDur); + readers[0].reset(); + readers[1].reset(); rtSampleDuration = sampleDur; } @@ -148,8 +150,8 @@ namespace elem while (bufferQueue.size() > 0) { bufferQueue.pop(activeBuffer); - readers[0].reset(sampleDur); - readers[1].reset(sampleDur); + readers[0].reset(); + readers[1].reset(); } // Pull newest seq from queue From 47b284a44fefdfd33d3f9630083180d508a59811 Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Wed, 7 Jan 2026 23:31:07 -0500 Subject: [PATCH 05/14] Fix sample trigger in SampleSeq nodes. They should always restart from 0 --- runtime/elem/builtins/SampleSeq.h | 5 +---- runtime/elem/builtins/mc/SampleSeq.h | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/runtime/elem/builtins/SampleSeq.h b/runtime/elem/builtins/SampleSeq.h index 40415ee7..237ccbc4 100644 --- a/runtime/elem/builtins/SampleSeq.h +++ b/runtime/elem/builtins/SampleSeq.h @@ -125,10 +125,7 @@ namespace elem // Here a value of 1.0 is considered an onset, and anything else // considered an offset. if (fpEqual(prevEvent->second, FloatType(1.0))) { - auto const bufferView = activeBuffer->getChannelData(0); - // Map t to a normalized position relative to the sample - double const pos = rtSampleDuration > 0.0 ? prevEvent->first / rtSampleDuration : 0.0; - readers[activeReader].engage(pos); + readers[activeReader].engage(0); } } } diff --git a/runtime/elem/builtins/mc/SampleSeq.h b/runtime/elem/builtins/mc/SampleSeq.h index b491ef72..b83056e9 100644 --- a/runtime/elem/builtins/mc/SampleSeq.h +++ b/runtime/elem/builtins/mc/SampleSeq.h @@ -125,9 +125,7 @@ namespace elem // Here a value of 1.0 is considered an onset, and anything else // considered an offset. if (fpEqual(prevEvent->second, FloatType(1.0))) { - // Map t to a normalized position relative to the sample - double const pos = rtSampleDuration > 0.0 ? prevEvent->first / rtSampleDuration : 0.0; - readers[activeReader].engage(pos); + readers[activeReader].engage(0); } } } From a2f596f7314eb98239f98ae544726f50e40926ee Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Wed, 7 Jan 2026 23:50:09 -0500 Subject: [PATCH 06/14] Update the mc Sample node to use the shared BufferReader --- runtime/elem/builtins/helpers/BufferReader.h | 3 +- runtime/elem/builtins/mc/Sample.h | 176 ++++++------------- 2 files changed, 58 insertions(+), 121 deletions(-) diff --git a/runtime/elem/builtins/helpers/BufferReader.h b/runtime/elem/builtins/helpers/BufferReader.h index d80e4f42..48667d31 100644 --- a/runtime/elem/builtins/helpers/BufferReader.h +++ b/runtime/elem/builtins/helpers/BufferReader.h @@ -35,6 +35,7 @@ namespace elem std::optional stopOffsetSamples; bool shouldLoop = false; double playbackRate = 1.0; + size_t writeOffset = 0; }; template @@ -80,7 +81,7 @@ namespace elem } auto const out = static_cast(localFade(lerpRead(bufferView, pos))); - ctx.outputData[j][i] += out; + ctx.outputData[j][i + ctx.writeOffset] += out; pos += (ctx.playbackRate / static_cast(sampleLength)); } diff --git a/runtime/elem/builtins/mc/Sample.h b/runtime/elem/builtins/mc/Sample.h index ae7b1c51..5860ba42 100644 --- a/runtime/elem/builtins/mc/Sample.h +++ b/runtime/elem/builtins/mc/Sample.h @@ -5,8 +5,8 @@ #include "../../Types.h" #include "../helpers/Change.h" -#include "../helpers/GainFade.h" -#include "../helpers/FloatUtils.h" +#include "elem/builtins/helpers/BufferReader.h" +#include namespace elem @@ -23,6 +23,13 @@ namespace elem template struct MCSampleNode : public GraphNode { using GraphNode::GraphNode; + using ReaderContext = typename BufferReader::template ReadContext; + + MCSampleNode(NodeId id, double sr, size_t blockSize) + : GraphNode::GraphNode(id, sr, blockSize) + , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) + { + } int setProperty(std::string const& key, js::Value const& val, SharedResourceMap& resources) override { @@ -85,8 +92,8 @@ namespace elem } void reset() override { - readers[0].noteOff(); - readers[1].noteOff(); + readers[0].disengage(); + readers[1].disengage(); } void process (BlockContext const& ctx) override { @@ -96,16 +103,14 @@ namespace elem auto numOuts = ctx.numOutputChannels; auto numSamples = ctx.numSamples; - auto const sampleRate = GraphNode::getSampleRate(); - // First order of business: grab the most recent sample buffer to use if // there's anything in the queue. This behavior means that changing the buffer // while playing the sample will cause a discontinuity. while (bufferQueue.size() > 0) { bufferQueue.pop(activeBuffer); - readers[0] = MCVariablePitchReader(sampleRate, activeBuffer); - readers[1] = MCVariablePitchReader(sampleRate, activeBuffer); + readers[0].reset(); + readers[1].reset(); } // First we clear the output buffers @@ -136,12 +141,23 @@ namespace elem if (cv > FloatType(0.5)) { // Read from [i, j] - readers[0].sumInto(outputData, numOuts, i, j - i, rate); - readers[1].sumInto(outputData, numOuts, i, j - i, rate); + std::for_each(readers.begin(), readers.end(), [&](auto& reader) { + reader.readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = outputData, + .numChannels = numOuts, + .numSamples = j - i, + .startOffsetSamples = ostart, + .stopOffsetSamples = ostop, + .shouldLoop = wantsLoop, + .writeOffset = i, + .playbackRate = rate, + }); + }); // Update voice state - readers[currentReader & 1].noteOff(); - readers[++currentReader & 1].noteOn(ostart, ostop, wantsLoop); + readers[currentReader & 1].disengage(); + readers[++currentReader & 1].engage(0); // Update counters i = j; @@ -150,11 +166,22 @@ namespace elem // If we're in trigger mode then we can ignore falling edges if (cv < FloatType(-0.5) && playbackMode != Mode::Trigger) { // Read from [i, j] - readers[0].sumInto(outputData, numOuts, i, j - i, rate); - readers[1].sumInto(outputData, numOuts, i, j - i, rate); + std::for_each(readers.begin(), readers.end(), [&](auto& reader) { + reader.readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = outputData, + .numChannels = numOuts, + .numSamples = j - i, + .startOffsetSamples = ostart, + .stopOffsetSamples = ostop, + .shouldLoop = wantsLoop, + .writeOffset = i, + .playbackRate = rate, + }); + }); // Update voice state - readers[currentReader & 1].noteOff(); + readers[currentReader & 1].disengage(); // Break so we can update our sample counters // Update counters @@ -162,15 +189,26 @@ namespace elem } } - readers[0].sumInto(outputData, numOuts, i, j - i, rate); - readers[1].sumInto(outputData, numOuts, i, j - i, rate); + std::for_each(readers.begin(), readers.end(), [&](auto& reader) { + reader.readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = outputData, + .numChannels = numOuts, + .numSamples = j - i, + .startOffsetSamples = ostart, + .stopOffsetSamples = ostop, + .shouldLoop = wantsLoop, + .writeOffset = i, + .playbackRate = rate, + }); + }); } SingleWriterSingleReaderQueue bufferQueue; SharedResourcePtr activeBuffer; Change change; - std::array, 2> readers; + std::array, 2> readers; size_t currentReader = 0; enum class Mode @@ -186,106 +224,4 @@ namespace elem std::atomic playbackRate = 1.0; }; - // A helper struct for reading from sample data with variable rate using - // linear interpolation. - template - struct MCVariablePitchReader - { - MCVariablePitchReader() - : sourceBuffer(nullptr), gainFade(44100.0, 4.0, 4.0) - {} - - MCVariablePitchReader(FloatType _sampleRate, SharedResourcePtr _sourceBuffer) - : sourceBuffer(_sourceBuffer), gainFade(_sampleRate, 4.0, 4.0) - {} - - MCVariablePitchReader(MCVariablePitchReader& other) - : sourceBuffer(other.sourceBuffer), gainFade(other.gainFade) - {} - - void noteOn(size_t _startOffset, size_t _stopOffset, bool wantsLoop) - { - gainFade.fadeIn(); - - startOffset = (double) _startOffset; - stopOffset = (double) _stopOffset; - shouldLoop = wantsLoop; - pos = startOffset; - } - - void noteOff() - { - gainFade.fadeOut(); - } - - FloatType lerpRead(BufferView const& view, double pos) - { - auto* data = view.data(); - auto size = view.size(); - - auto left = static_cast(pos); - auto right = left + 1; - auto alpha = pos - (double) left; - - if (left >= size) - return FloatType(0); - - if (right >= size) - return data[left]; - - return lerp(static_cast(alpha), data[left], data[right]); - } - - void sumInto(FloatType** outputData, size_t numOuts, size_t writeOffset, size_t numSamples, double playbackRate) - { - elem::GainFade localFade(gainFade); - - double readStart = startOffset; - double readStop = 0; - - for (size_t i = 0; i < std::min(numOuts, sourceBuffer->numChannels()); ++i) { - auto bufferView = sourceBuffer->getChannelData(i); - size_t const sourceLength = bufferView.size(); - - readStop = static_cast(sourceLength) - stopOffset; - - // Reinitialize the local copy to match our member instance - localFade = gainFade; - - for (size_t j = 0; j < numSamples; ++j) { - double readPos = pos + static_cast(j) * playbackRate; - - if (readPos >= readStop) { - if (shouldLoop) { - readPos = readStart + std::fmod(readPos - readStart, readStop - readStart); - } else { - continue; - } - } - - outputData[i][writeOffset + j] += localFade(lerpRead(bufferView, readPos)); - } - } - - // Here we have a localFade instance that has finished running over a block, which - // represents where our class instance should now be - gainFade = localFade; - - // And update our position - pos += static_cast(numSamples) * playbackRate; - - if (pos >= readStop && shouldLoop) { - pos = readStart + std::fmod(pos - readStart, readStop - readStart); - } - } - - SharedResourcePtr sourceBuffer; - - GainFade gainFade; - bool shouldLoop = false; - double stopOffset = 0; - double startOffset = 0; - double pos = 0; - }; - } // namespace elem From 2f3e003ec6cd9aecc5005b562848cce0ec2b269d Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Thu, 8 Jan 2026 00:04:43 -0500 Subject: [PATCH 07/14] De-duplicate read calls --- runtime/elem/builtins/Sample.h | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/runtime/elem/builtins/Sample.h b/runtime/elem/builtins/Sample.h index 649e9ade..90a80e01 100644 --- a/runtime/elem/builtins/Sample.h +++ b/runtime/elem/builtins/Sample.h @@ -132,25 +132,17 @@ namespace elem auto outputChannels = std::array{&outputData[i]}; // Process both readers for the current sample - readers[0].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = outputChannels.data(), - .numChannels = 1, - .numSamples = 1, - .startOffsetSamples = ostart, - .stopOffsetSamples = ostop, - .shouldLoop = wantsLoop, - .playbackRate = rate, - }); - readers[1].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = outputChannels.data(), - .numChannels = 1, - .numSamples = 1, - .startOffsetSamples = ostart, - .stopOffsetSamples = ostop, - .shouldLoop = wantsLoop, - .playbackRate = rate, + std::for_each(readers.begin(), readers.end(), [&](auto& reader) { + reader.readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = outputChannels.data(), + .numChannels = 1, + .numSamples = 1, + .startOffsetSamples = ostart, + .stopOffsetSamples = ostop, + .shouldLoop = wantsLoop, + .playbackRate = rate, + }); }); } } From eebe93fc6bfa47c9eda8b82354dd715254e7c15a Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Thu, 8 Jan 2026 00:04:58 -0500 Subject: [PATCH 08/14] formatting --- runtime/elem/builtins/Sample.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/elem/builtins/Sample.h b/runtime/elem/builtins/Sample.h index 90a80e01..a1eae18f 100644 --- a/runtime/elem/builtins/Sample.h +++ b/runtime/elem/builtins/Sample.h @@ -98,7 +98,7 @@ namespace elem // while playing the sample will cause a discontinuity. while (bufferQueue.size() > 0) { bufferQueue.pop(activeBuffer); - } + } // If we don't have an input trigger or an active buffer, we can just return here if (numChannels < 1 || activeBuffer == nullptr) From 7b6f397a5393ad1da6d9b07d6ff6b4adc9c72ed5 Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Thu, 8 Jan 2026 00:06:08 -0500 Subject: [PATCH 09/14] Remove unused member --- runtime/elem/builtins/Sample.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/runtime/elem/builtins/Sample.h b/runtime/elem/builtins/Sample.h index a1eae18f..13f37105 100644 --- a/runtime/elem/builtins/Sample.h +++ b/runtime/elem/builtins/Sample.h @@ -164,8 +164,6 @@ namespace elem std::atomic mode = Mode::Trigger; std::atomic startOffset = 0; std::atomic stopOffset = 0; - - double normalizedPosition = 0; }; } // namespace elem From 61c6cb1a505c48b40ce2816deb399b24752866d9 Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Thu, 8 Jan 2026 00:11:32 -0500 Subject: [PATCH 10/14] Reduce duplication --- runtime/elem/builtins/SampleSeq.h | 36 +++++++++++----------------- runtime/elem/builtins/mc/SampleSeq.h | 36 +++++++++++----------------- 2 files changed, 28 insertions(+), 44 deletions(-) diff --git a/runtime/elem/builtins/SampleSeq.h b/runtime/elem/builtins/SampleSeq.h index 237ccbc4..e844ba0c 100644 --- a/runtime/elem/builtins/SampleSeq.h +++ b/runtime/elem/builtins/SampleSeq.h @@ -220,17 +220,13 @@ namespace elem // Clear and read std::fill_n(scratchData, numSourceSamples, FloatType(0)); - readers[0].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = &scratchData, - .numChannels = 1, - .numSamples = numSourceSamples, - }); - readers[1].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = &scratchData, - .numChannels = 1, - .numSamples = numSourceSamples, + std::for_each(readers.begin(), readers.end(), [&](auto& reader) { + reader.readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = &scratchData, + .numChannels = 1, + .numSamples = numSourceSamples, + }); }); stretch.process(&scratchData, numSourceSamples, &outputData, numSamples); @@ -238,17 +234,13 @@ namespace elem // Clear and read std::fill_n(outputData, numSamples, FloatType(0)); - readers[0].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = &outputData, - .numChannels = 1, - .numSamples = numSamples, - }); - readers[1].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = &outputData, - .numChannels = 1, - .numSamples = numSamples, + std::for_each(readers.begin(), readers.end(), [&](auto& reader) { + reader.readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = &outputData, + .numChannels = 1, + .numSamples = numSamples, + }); }); } } diff --git a/runtime/elem/builtins/mc/SampleSeq.h b/runtime/elem/builtins/mc/SampleSeq.h index b83056e9..0093caae 100644 --- a/runtime/elem/builtins/mc/SampleSeq.h +++ b/runtime/elem/builtins/mc/SampleSeq.h @@ -229,17 +229,13 @@ namespace elem std::array ptrs {{scratchData, scratchData + (numSamples * 4)}}; auto** scratchPtrs = ptrs.data(); - readers[0].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = scratchPtrs, - .numChannels = ctx.numOutputChannels, - .numSamples = numSourceSamples, - }); - readers[1].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = scratchPtrs, - .numChannels = ctx.numOutputChannels, - .numSamples = numSourceSamples, + std::for_each(readers.begin(), readers.end(), [&](auto& reader) { + reader.readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = scratchPtrs, + .numChannels = ctx.numOutputChannels, + .numSamples = numSourceSamples, + }); }); stretch.process(scratchPtrs, static_cast(numSourceSamples), outputData, static_cast(numSamples)); @@ -249,17 +245,13 @@ namespace elem std::fill_n(outputData[i], numSamples, FloatType(0)); } - readers[0].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = outputData, - .numChannels = ctx.numOutputChannels, - .numSamples = numSamples, - }); - readers[1].readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = outputData, - .numChannels = ctx.numOutputChannels, - .numSamples = numSamples, + std::for_each(readers.begin(), readers.end(), [&](auto& reader) { + reader.readAdding(ReaderContext{ + .source = activeBuffer.get(), + .outputData = outputData, + .numChannels = ctx.numOutputChannels, + .numSamples = numSamples, + }); }); } } From 207e0155073dc8966492bca6a4c4d5ee0e70df5d Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Thu, 8 Jan 2026 00:14:44 -0500 Subject: [PATCH 11/14] Hide buffer reader's private data members --- runtime/elem/builtins/helpers/BufferReader.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/runtime/elem/builtins/helpers/BufferReader.h b/runtime/elem/builtins/helpers/BufferReader.h index 48667d31..3b777f85 100644 --- a/runtime/elem/builtins/helpers/BufferReader.h +++ b/runtime/elem/builtins/helpers/BufferReader.h @@ -11,7 +11,8 @@ namespace elem { template - struct BufferReader { + class BufferReader { + public: BufferReader(double sampleRate, double fadeTime) : fade(sampleRate, fadeTime, fadeTime) {} @@ -119,6 +120,7 @@ namespace elem fade.reset(); } + private: elem::GainFade fade; double position = 0; From cd9e541ca602ea5305717ccdc2c77e9df88ed695 Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Thu, 8 Jan 2026 21:11:05 -0500 Subject: [PATCH 12/14] Fix initialization order --- runtime/elem/builtins/mc/Sample.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/runtime/elem/builtins/mc/Sample.h b/runtime/elem/builtins/mc/Sample.h index 5860ba42..fedb11a2 100644 --- a/runtime/elem/builtins/mc/Sample.h +++ b/runtime/elem/builtins/mc/Sample.h @@ -150,8 +150,8 @@ namespace elem .startOffsetSamples = ostart, .stopOffsetSamples = ostop, .shouldLoop = wantsLoop, - .writeOffset = i, .playbackRate = rate, + .writeOffset = i, }); }); @@ -175,8 +175,8 @@ namespace elem .startOffsetSamples = ostart, .stopOffsetSamples = ostop, .shouldLoop = wantsLoop, - .writeOffset = i, .playbackRate = rate, + .writeOffset = i, }); }); @@ -198,8 +198,8 @@ namespace elem .startOffsetSamples = ostart, .stopOffsetSamples = ostop, .shouldLoop = wantsLoop, - .writeOffset = i, .playbackRate = rate, + .writeOffset = i, }); }); } From 92810ead9f6683c1293f9b41482e2e027a77a38f Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Thu, 8 Jan 2026 21:49:41 -0500 Subject: [PATCH 13/14] Preserve the gain fade times previously used in each sample node to fix tests --- runtime/elem/builtins/Sample.h | 4 +++- runtime/elem/builtins/SampleSeq.h | 6 +++++- runtime/elem/builtins/mc/Sample.h | 4 +++- runtime/elem/builtins/mc/SampleSeq.h | 4 +++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/runtime/elem/builtins/Sample.h b/runtime/elem/builtins/Sample.h index 13f37105..b8549b68 100644 --- a/runtime/elem/builtins/Sample.h +++ b/runtime/elem/builtins/Sample.h @@ -21,9 +21,11 @@ namespace elem using GraphNode::GraphNode; using ReaderContext = typename BufferReader::template ReadContext; + static constexpr double FadeTime = 8.0; + SampleNode(NodeId id, double sr, size_t blockSize) : GraphNode::GraphNode(id, sr, blockSize) - , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) + , readers({BufferReader(sr, FadeTime), BufferReader(sr, FadeTime)}) { } diff --git a/runtime/elem/builtins/SampleSeq.h b/runtime/elem/builtins/SampleSeq.h index e844ba0c..3a8f8a9a 100644 --- a/runtime/elem/builtins/SampleSeq.h +++ b/runtime/elem/builtins/SampleSeq.h @@ -18,9 +18,13 @@ namespace elem struct SampleSeqNode : public GraphNode { using ReaderContext = typename BufferReader::template ReadContext; + // Note: this is set to 1.1 ms to preserve backwards compatibility. Historically, a gain fade with + // a step size of 0.02 was used for SampleSeqNode, which comes out to roughly 1.1 ms at 44100 Hz. + static constexpr double FadeTime = 1.1; + SampleSeqNode(NodeId id, FloatType const sr, int const blockSize) : GraphNode::GraphNode(id, sr, blockSize) - , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) + , readers({BufferReader(sr, FadeTime), BufferReader(sr, FadeTime)}) { if constexpr (WithStretch) { stretch.presetDefault(1, sr); diff --git a/runtime/elem/builtins/mc/Sample.h b/runtime/elem/builtins/mc/Sample.h index fedb11a2..32d647fd 100644 --- a/runtime/elem/builtins/mc/Sample.h +++ b/runtime/elem/builtins/mc/Sample.h @@ -25,9 +25,11 @@ namespace elem using GraphNode::GraphNode; using ReaderContext = typename BufferReader::template ReadContext; + static constexpr double FadeTime = 4.0; + MCSampleNode(NodeId id, double sr, size_t blockSize) : GraphNode::GraphNode(id, sr, blockSize) - , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) + , readers({BufferReader(sr, FadeTime), BufferReader(sr, FadeTime)}) { } diff --git a/runtime/elem/builtins/mc/SampleSeq.h b/runtime/elem/builtins/mc/SampleSeq.h index 0093caae..f45e79bd 100644 --- a/runtime/elem/builtins/mc/SampleSeq.h +++ b/runtime/elem/builtins/mc/SampleSeq.h @@ -14,9 +14,11 @@ namespace elem struct StereoSampleSeqNode : public GraphNode { using ReaderContext = typename BufferReader::template ReadContext; + static constexpr double FadeTime = 8.0; + StereoSampleSeqNode(NodeId id, FloatType const sr, int const blockSize) : GraphNode::GraphNode(id, sr, blockSize) - , readers({BufferReader(sr, 8.0), BufferReader(sr, 8.0)}) + , readers({BufferReader(sr, FadeTime), BufferReader(sr, FadeTime)}) { if constexpr (WithStretch) { stretch.presetDefault(2, sr); From 4e4fe44a3c0653b44c2ad7e8e0045ff76416ae65 Mon Sep 17 00:00:00 2001 From: Steve MacKinnon Date: Thu, 8 Jan 2026 22:17:34 -0500 Subject: [PATCH 14/14] Remove designated initialization calls which require C++20 for msvc --- runtime/elem/builtins/Sample.h | 20 +++--- runtime/elem/builtins/SampleSeq.h | 24 +++---- runtime/elem/builtins/helpers/BufferReader.h | 28 ++++++++- runtime/elem/builtins/mc/Sample.h | 66 ++++++++++---------- runtime/elem/builtins/mc/SampleSeq.h | 24 +++---- 5 files changed, 92 insertions(+), 70 deletions(-) diff --git a/runtime/elem/builtins/Sample.h b/runtime/elem/builtins/Sample.h index b8549b68..260434fc 100644 --- a/runtime/elem/builtins/Sample.h +++ b/runtime/elem/builtins/Sample.h @@ -135,16 +135,16 @@ namespace elem auto outputChannels = std::array{&outputData[i]}; // Process both readers for the current sample std::for_each(readers.begin(), readers.end(), [&](auto& reader) { - reader.readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = outputChannels.data(), - .numChannels = 1, - .numSamples = 1, - .startOffsetSamples = ostart, - .stopOffsetSamples = ostop, - .shouldLoop = wantsLoop, - .playbackRate = rate, - }); + reader.readAdding(ReaderContext( + activeBuffer.get(), + outputChannels.data(), + 1, + 1, + ostart, + ostop, + wantsLoop, + rate + )); }); } } diff --git a/runtime/elem/builtins/SampleSeq.h b/runtime/elem/builtins/SampleSeq.h index 3a8f8a9a..783b08f1 100644 --- a/runtime/elem/builtins/SampleSeq.h +++ b/runtime/elem/builtins/SampleSeq.h @@ -225,12 +225,12 @@ namespace elem std::fill_n(scratchData, numSourceSamples, FloatType(0)); std::for_each(readers.begin(), readers.end(), [&](auto& reader) { - reader.readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = &scratchData, - .numChannels = 1, - .numSamples = numSourceSamples, - }); + reader.readAdding(ReaderContext( + activeBuffer.get(), + &scratchData, + 1, + numSourceSamples + )); }); stretch.process(&scratchData, numSourceSamples, &outputData, numSamples); @@ -239,12 +239,12 @@ namespace elem std::fill_n(outputData, numSamples, FloatType(0)); std::for_each(readers.begin(), readers.end(), [&](auto& reader) { - reader.readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = &outputData, - .numChannels = 1, - .numSamples = numSamples, - }); + reader.readAdding(ReaderContext( + activeBuffer.get(), + &outputData, + 1, + numSamples + )); }); } } diff --git a/runtime/elem/builtins/helpers/BufferReader.h b/runtime/elem/builtins/helpers/BufferReader.h index 3b777f85..fe30978b 100644 --- a/runtime/elem/builtins/helpers/BufferReader.h +++ b/runtime/elem/builtins/helpers/BufferReader.h @@ -28,15 +28,37 @@ namespace elem template struct ReadContext { + ReadContext( + SharedResource* source, + DestType** outputData, + size_t numChannels, + size_t numSamples, + std::optional startOffsetSamples = std::nullopt, + std::optional stopOffsetSamples = std::nullopt, + bool shouldLoop = false, + double playbackRate = 1.0, + size_t writeOffset = 0 + ) + : source(source) + , outputData(outputData) + , numChannels(numChannels) + , numSamples(numSamples) + , startOffsetSamples(startOffsetSamples) + , stopOffsetSamples(stopOffsetSamples) + , shouldLoop(shouldLoop) + , playbackRate(playbackRate) + , writeOffset(writeOffset) + {} + SharedResource* source; DestType** outputData; size_t numChannels; size_t numSamples; std::optional startOffsetSamples; std::optional stopOffsetSamples; - bool shouldLoop = false; - double playbackRate = 1.0; - size_t writeOffset = 0; + bool shouldLoop; + double playbackRate; + size_t writeOffset; }; template diff --git a/runtime/elem/builtins/mc/Sample.h b/runtime/elem/builtins/mc/Sample.h index 32d647fd..557e7303 100644 --- a/runtime/elem/builtins/mc/Sample.h +++ b/runtime/elem/builtins/mc/Sample.h @@ -144,17 +144,17 @@ namespace elem if (cv > FloatType(0.5)) { // Read from [i, j] std::for_each(readers.begin(), readers.end(), [&](auto& reader) { - reader.readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = outputData, - .numChannels = numOuts, - .numSamples = j - i, - .startOffsetSamples = ostart, - .stopOffsetSamples = ostop, - .shouldLoop = wantsLoop, - .playbackRate = rate, - .writeOffset = i, - }); + reader.readAdding(ReaderContext( + activeBuffer.get(), + outputData, + numOuts, + j - i, + ostart, + ostop, + wantsLoop, + rate, + i + )); }); // Update voice state @@ -169,17 +169,17 @@ namespace elem if (cv < FloatType(-0.5) && playbackMode != Mode::Trigger) { // Read from [i, j] std::for_each(readers.begin(), readers.end(), [&](auto& reader) { - reader.readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = outputData, - .numChannels = numOuts, - .numSamples = j - i, - .startOffsetSamples = ostart, - .stopOffsetSamples = ostop, - .shouldLoop = wantsLoop, - .playbackRate = rate, - .writeOffset = i, - }); + reader.readAdding(ReaderContext( + activeBuffer.get(), + outputData, + numOuts, + j - i, + ostart, + ostop, + wantsLoop, + rate, + i + )); }); // Update voice state @@ -192,17 +192,17 @@ namespace elem } std::for_each(readers.begin(), readers.end(), [&](auto& reader) { - reader.readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = outputData, - .numChannels = numOuts, - .numSamples = j - i, - .startOffsetSamples = ostart, - .stopOffsetSamples = ostop, - .shouldLoop = wantsLoop, - .playbackRate = rate, - .writeOffset = i, - }); + reader.readAdding(ReaderContext( + activeBuffer.get(), + outputData, + numOuts, + j - i, + ostart, + ostop, + wantsLoop, + rate, + i + )); }); } diff --git a/runtime/elem/builtins/mc/SampleSeq.h b/runtime/elem/builtins/mc/SampleSeq.h index f45e79bd..245146c9 100644 --- a/runtime/elem/builtins/mc/SampleSeq.h +++ b/runtime/elem/builtins/mc/SampleSeq.h @@ -232,12 +232,12 @@ namespace elem auto** scratchPtrs = ptrs.data(); std::for_each(readers.begin(), readers.end(), [&](auto& reader) { - reader.readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = scratchPtrs, - .numChannels = ctx.numOutputChannels, - .numSamples = numSourceSamples, - }); + reader.readAdding(ReaderContext( + activeBuffer.get(), + scratchPtrs, + ctx.numOutputChannels, + numSourceSamples + )); }); stretch.process(scratchPtrs, static_cast(numSourceSamples), outputData, static_cast(numSamples)); @@ -248,12 +248,12 @@ namespace elem } std::for_each(readers.begin(), readers.end(), [&](auto& reader) { - reader.readAdding(ReaderContext{ - .source = activeBuffer.get(), - .outputData = outputData, - .numChannels = ctx.numOutputChannels, - .numSamples = numSamples, - }); + reader.readAdding(ReaderContext( + activeBuffer.get(), + outputData, + ctx.numOutputChannels, + numSamples + )); }); } }