Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Plugins/LfpViewer/DisplayBuffer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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();

Expand Down
63 changes: 36 additions & 27 deletions Plugins/LfpViewer/LfpDisplayCanvas.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
119 changes: 119 additions & 0 deletions Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<LfpViewer::DisplayBuffer*> 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<LfpViewer::LfpDisplayCanvas> canvas =
std::make_unique<LfpViewer::LfpDisplayCanvas> (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<int> 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<LfpViewer::LfpDisplayCanvas> canvas =
std::make_unique<LfpViewer::LfpDisplayCanvas> (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<int> 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();
}
Loading