diff --git a/Plugins/LfpViewer/DisplayBuffer.cpp b/Plugins/LfpViewer/DisplayBuffer.cpp index 67b4049d3..ac2178c93 100644 --- a/Plugins/LfpViewer/DisplayBuffer.cpp +++ b/Plugins/LfpViewer/DisplayBuffer.cpp @@ -27,6 +27,7 @@ namespace LfpViewer { #define BUFFER_LENGTH_S 1.0f +#define MIN_BUFFER_SAMPLES 1000 DisplayBuffer::DisplayBuffer (int id_, String name_, float sampleRate_) : id (id_), name (name_), sampleRate (sampleRate_), isNeeded (true) { @@ -104,7 +105,7 @@ void DisplayBuffer::addChannel ( void DisplayBuffer::update() { if (numChannels != previousSize) - setSize (numChannels + 1, int (sampleRate * BUFFER_LENGTH_S)); + setSize (numChannels + 1, std::max (int (sampleRate * BUFFER_LENGTH_S), MIN_BUFFER_SAMPLES)); clear(); diff --git a/Plugins/LfpViewer/LfpDisplayCanvas.cpp b/Plugins/LfpViewer/LfpDisplayCanvas.cpp index 7f2d7b424..be86d562c 100644 --- a/Plugins/LfpViewer/LfpDisplayCanvas.cpp +++ b/Plugins/LfpViewer/LfpDisplayCanvas.cpp @@ -1357,6 +1357,11 @@ void LfpDisplaySplitter::updateScreenBuffer() int sampleNumber = 0; + // For ratio < 1: save the display-buffer start index so we can + // compute each pixel's sample position with a direct multiply + // (drift-free) instead of accumulating subSampleOffset in a loop. + const int dbiStart = dbi; + if (pixelsToFill > 0 && pixelsToFill < 1000000) { float i; @@ -1404,39 +1409,41 @@ void LfpDisplaySplitter::updateScreenBuffer() if (ratio < 1.0f) // less than one sample per pixel { + // Drift-free interpolation: compute sample position + // directly from pixel index rather than accumulating + // subSampleOffset, which accumulates rounding errors, + // causing the displayed sample positions to drift from + // the true timeline. + float samplePos = float (i) * ratio; + int sampleStep = int (samplePos); + float alpha = samplePos - float (sampleStep); + + int curDbi = (dbiStart + sampleStep) % displayBufferSize; + int lastIndex = (curDbi - 1 + displayBufferSize) % displayBufferSize; + if (isEventChannel) { - eventWritePtr[sbi] = displayData[dbi]; + eventWritePtr[sbi] = displayData[curDbi]; } else { - const float alpha = subSampleOffset; - const float invAlpha = 1.0f - alpha; - - int lastIndex = dbi - 1; - - if (lastIndex < 0) - { - lastIndex = displayBufferSize - 1; + // Skip the very first pixel if the display buffer has + // never been written (dbiStart == 0 means no previous + // sample exists to interpolate from). + if (dbiStart == 0 && sampleStep == 0) continue; - } const float val0 = displayData[lastIndex]; - const float val1 = displayData[dbi]; - const float val = invAlpha * val0 + alpha * val1; - - meanWritePtr[sbi] += val; - minWritePtr[sbi] += val; - maxWritePtr[sbi] += val; - } + const float val1 = displayData[curDbi]; + const float val = (1.0f - alpha) * val0 + alpha * val1; - subSampleOffset += ratio; + // Span min..max across consecutive pixels so the plotter + // draws connected line segments at low sample rates. + const float prevVal = (sbi > 0) ? meanReadPtr[sbi - 1] : val; - if (subSampleOffset > 1.0f) // go to next pixel - { - subSampleOffset -= 1.0f; - dbi += 1; - dbi %= displayBufferSize; + meanWritePtr[sbi] += val; + minWritePtr[sbi] += jmin (val, prevVal); + maxWritePtr[sbi] += jmax (val, prevVal); } } else @@ -1621,10 +1628,12 @@ void LfpDisplaySplitter::updateScreenBuffer() } // !isPaused } - if (ratio > 1.0f) - leftOverSamples.set (channel, pixelsToFill - i); // +(pixelsToFill - (i - 1)) * ratio); - else - leftOverSamples.set (channel, subSampleOffset - 1.0f); + // Same formula for both ratio branches: after the loop, `i` is + // the first value that failed (i < pixelsToFill), so + // pixelsToFill - i is the signed fractional carry. For ratio < 1 + // the loop runs ceil(pixelsToFill) iterations and the carry is + // <= 0, which subtracts from the next call and prevents drift. + leftOverSamples.set (channel, pixelsToFill - i); //std::cout << "Setting channel " << channel << " sbi to " << sbi << std::endl; screenBufferIndex.set (channel, sbi); diff --git a/Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp b/Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp index ca1592e4e..e1e4aafc5 100644 --- a/Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp +++ b/Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp @@ -380,3 +380,122 @@ TEST_F (LfpDisplayNodeTests, DataIntegrityTest) processor->stopAcquisition(); } + +class LfpDisplayNodeLowRateTests : public LfpDisplayNodeTests +{ +protected: + void SetUp() override + { + sampleRate = 10.0f; // Override before base SetUp reads it + LfpDisplayNodeTests::SetUp(); + } +}; + +// Checks if low sampling rate signals receive a sufficient buffer legnth +TEST_F (LfpDisplayNodeLowRateTests, LowRateDisplayBufferHasMinimumSize) +{ + Array displayBuffers = processor->getDisplayBuffers(); + ASSERT_GT (displayBuffers.size(), 0); + EXPECT_GE (displayBuffers[0]->getNumSamples(), 1000); +} + +// Checks that low sampling rate signals with numSamples / pixels < 1.0 still display. +TEST_F (LfpDisplayNodeLowRateTests, LowRateSignalIsNotFlatLine) +{ + const int canvasX = 600; + const int canvasY = 800; + const int numSamples = 20; + + std::unique_ptr canvas = + std::make_unique (processor, LfpViewer::SplitLayouts::SINGLE, false); + canvas->updateSettings(); + canvas->setSize (canvasX, canvasY); + canvas->resized(); + canvas->setVisible (true); + setExpectedImageParameters (canvas.get()); + + processor->startAcquisition(); + canvas->beginAnimation(); + + auto inputBuffer = createBufferSinusoidal (1, numChannels, numSamples, 100); + writeBlock (inputBuffer); + canvas->refreshState(); + + Rectangle canvasSnapshot (x, y, width, height); + Image canvasImage = canvas->createComponentSnapshot (canvasSnapshot); + + const int firstChannelHeight = height / numChannels; + const int midlineRow = firstChannelHeight / 2; + const int midlineBand = 4; // tolerance for midline so zero-buffer flat line cannot pass + + bool hasOffMidlineSignal = false; + for (int px = 0; px < width && !hasOffMidlineSignal; px++) + { + for (int py = 0; py < firstChannelHeight && !hasOffMidlineSignal; py++) + { + if (std::abs (py - midlineRow) <= midlineBand) + continue; + if (canvasImage.getPixelAt (px, py) == channelColours[0]) + hasOffMidlineSignal = true; + } + } + + EXPECT_TRUE (hasOffMidlineSignal) + << "No off-midline signal found: canvas likely rendered a zero-valued flat line"; + + processor->stopAcquisition(); +} + +// A connected trace produces signal in almost every pixel column; isolated dots produce +// signal in only ~numSamples columns. The midline band is excluded so a zero-buffer +// flat line scores zero columns. +TEST_F (LfpDisplayNodeLowRateTests, LowRateTraceIsConnected) +{ + const int canvasX = 600; + const int canvasY = 800; + const int numSamples = 20; + + std::unique_ptr canvas = + std::make_unique (processor, LfpViewer::SplitLayouts::SINGLE, false); + canvas->updateSettings(); + canvas->setSize (canvasX, canvasY); + canvas->resized(); + canvas->setVisible (true); + setExpectedImageParameters (canvas.get()); + + processor->startAcquisition(); + canvas->beginAnimation(); + + auto inputBuffer = createBufferSinusoidal (1, numChannels, numSamples, 100); + writeBlock (inputBuffer); + canvas->refreshState(); + + Rectangle canvasSnapshot (x, y, width, height); + Image canvasImage = canvas->createComponentSnapshot (canvasSnapshot); + + const int firstChannelHeight = height / numChannels; + const int midlineRow = firstChannelHeight / 2; + const int midlineBand = 4; // tolerance for midline so zero-buffer flat line cannot pass + + int signalColumns = 0; + for (int px = 0; px < width; px++) + { + for (int py = 0; py < firstChannelHeight; py++) + { + if (std::abs (py - midlineRow) <= midlineBand) + continue; + if (canvasImage.getPixelAt (px, py) == channelColours[0]) + { + signalColumns++; + break; + } + } + } + + // With connected rendering almost every column has off-midline signal. + EXPECT_GT (signalColumns, numSamples * 5) + << "Trace appears disjointed: " << signalColumns + << " off-midline signal columns, expected > " << numSamples * 5; + + processor->stopAcquisition(); +}