Skip to content

Commit 43c94b0

Browse files
committed
LFP Viewer support for low sampling rates
1 parent bd8efe3 commit 43c94b0

3 files changed

Lines changed: 157 additions & 28 deletions

File tree

Plugins/LfpViewer/DisplayBuffer.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ namespace LfpViewer
2727
{
2828

2929
#define BUFFER_LENGTH_S 1.0f
30+
#define MIN_BUFFER_SAMPLES 1000
3031

3132
DisplayBuffer::DisplayBuffer (int id_, String name_, float sampleRate_) : id (id_), name (name_), sampleRate (sampleRate_), isNeeded (true)
3233
{
@@ -104,7 +105,7 @@ void DisplayBuffer::addChannel (
104105
void DisplayBuffer::update()
105106
{
106107
if (numChannels != previousSize)
107-
setSize (numChannels + 1, int (sampleRate * BUFFER_LENGTH_S));
108+
setSize (numChannels + 1, std::max (int (sampleRate * BUFFER_LENGTH_S), MIN_BUFFER_SAMPLES));
108109

109110
clear();
110111

Plugins/LfpViewer/LfpDisplayCanvas.cpp

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,11 @@ void LfpDisplaySplitter::updateScreenBuffer()
13571357

13581358
int sampleNumber = 0;
13591359

1360+
// For ratio < 1: save the display-buffer start index so we can
1361+
// compute each pixel's sample position with a direct multiply
1362+
// (drift-free) instead of accumulating subSampleOffset in a loop.
1363+
const int dbiStart = dbi;
1364+
13601365
if (pixelsToFill > 0 && pixelsToFill < 1000000)
13611366
{
13621367
float i;
@@ -1404,39 +1409,41 @@ void LfpDisplaySplitter::updateScreenBuffer()
14041409

14051410
if (ratio < 1.0f) // less than one sample per pixel
14061411
{
1412+
// Drift-free interpolation: compute sample position
1413+
// directly from pixel index rather than accumulating
1414+
// subSampleOffset, which accumulates rounding errors,
1415+
// causing the displayed sample positions to drift from
1416+
// the true timeline.
1417+
float samplePos = float (i) * ratio;
1418+
int sampleStep = int (samplePos);
1419+
float alpha = samplePos - float (sampleStep);
1420+
1421+
int curDbi = (dbiStart + sampleStep) % displayBufferSize;
1422+
int lastIndex = (curDbi - 1 + displayBufferSize) % displayBufferSize;
1423+
14071424
if (isEventChannel)
14081425
{
1409-
eventWritePtr[sbi] = displayData[dbi];
1426+
eventWritePtr[sbi] = displayData[curDbi];
14101427
}
14111428
else
14121429
{
1413-
const float alpha = subSampleOffset;
1414-
const float invAlpha = 1.0f - alpha;
1415-
1416-
int lastIndex = dbi - 1;
1417-
1418-
if (lastIndex < 0)
1419-
{
1420-
lastIndex = displayBufferSize - 1;
1430+
// Skip the very first pixel if the display buffer has
1431+
// never been written (dbiStart == 0 means no previous
1432+
// sample exists to interpolate from).
1433+
if (dbiStart == 0 && sampleStep == 0)
14211434
continue;
1422-
}
14231435

14241436
const float val0 = displayData[lastIndex];
1425-
const float val1 = displayData[dbi];
1426-
const float val = invAlpha * val0 + alpha * val1;
1427-
1428-
meanWritePtr[sbi] += val;
1429-
minWritePtr[sbi] += val;
1430-
maxWritePtr[sbi] += val;
1431-
}
1437+
const float val1 = displayData[curDbi];
1438+
const float val = (1.0f - alpha) * val0 + alpha * val1;
14321439

1433-
subSampleOffset += ratio;
1440+
// Span min..max across consecutive pixels so the plotter
1441+
// draws connected line segments at low sample rates.
1442+
const float prevVal = (sbi > 0) ? meanReadPtr[sbi - 1] : val;
14341443

1435-
if (subSampleOffset > 1.0f) // go to next pixel
1436-
{
1437-
subSampleOffset -= 1.0f;
1438-
dbi += 1;
1439-
dbi %= displayBufferSize;
1444+
meanWritePtr[sbi] += val;
1445+
minWritePtr[sbi] += jmin (val, prevVal);
1446+
maxWritePtr[sbi] += jmax (val, prevVal);
14401447
}
14411448
}
14421449
else
@@ -1621,10 +1628,12 @@ void LfpDisplaySplitter::updateScreenBuffer()
16211628
} // !isPaused
16221629
}
16231630

1624-
if (ratio > 1.0f)
1625-
leftOverSamples.set (channel, pixelsToFill - i); // +(pixelsToFill - (i - 1)) * ratio);
1626-
else
1627-
leftOverSamples.set (channel, subSampleOffset - 1.0f);
1631+
// Same formula for both ratio branches: after the loop, `i` is
1632+
// the first value that failed (i < pixelsToFill), so
1633+
// pixelsToFill - i is the signed fractional carry. For ratio < 1
1634+
// the loop runs ceil(pixelsToFill) iterations and the carry is
1635+
// <= 0, which subtracts from the next call and prevents drift.
1636+
leftOverSamples.set (channel, pixelsToFill - i);
16281637

16291638
//std::cout << "Setting channel " << channel << " sbi to " << sbi << std::endl;
16301639
screenBufferIndex.set (channel, sbi);

Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,122 @@ TEST_F (LfpDisplayNodeTests, DataIntegrityTest)
380380

381381
processor->stopAcquisition();
382382
}
383+
384+
class LfpDisplayNodeLowRateTests : public LfpDisplayNodeTests
385+
{
386+
protected:
387+
void SetUp() override
388+
{
389+
sampleRate = 10.0f; // Override before base SetUp reads it
390+
LfpDisplayNodeTests::SetUp();
391+
}
392+
};
393+
394+
// Checks if low sampling rate signals receive a sufficient buffer legnth
395+
TEST_F (LfpDisplayNodeLowRateTests, LowRateDisplayBufferHasMinimumSize)
396+
{
397+
Array<LfpViewer::DisplayBuffer*> displayBuffers = processor->getDisplayBuffers();
398+
ASSERT_GT (displayBuffers.size(), 0);
399+
EXPECT_GE (displayBuffers[0]->getNumSamples(), 1000);
400+
}
401+
402+
// Checks that low sampling rate signals with numSamples / pixels < 1.0 still display.
403+
TEST_F (LfpDisplayNodeLowRateTests, LowRateSignalIsNotFlatLine)
404+
{
405+
const int canvasX = 600;
406+
const int canvasY = 800;
407+
const int numSamples = 20;
408+
409+
std::unique_ptr<LfpViewer::LfpDisplayCanvas> canvas =
410+
std::make_unique<LfpViewer::LfpDisplayCanvas> (processor, LfpViewer::SplitLayouts::SINGLE, false);
411+
canvas->updateSettings();
412+
canvas->setSize (canvasX, canvasY);
413+
canvas->resized();
414+
canvas->setVisible (true);
415+
setExpectedImageParameters (canvas.get());
416+
417+
processor->startAcquisition();
418+
canvas->beginAnimation();
419+
420+
auto inputBuffer = createBufferSinusoidal (1, numChannels, numSamples, 100);
421+
writeBlock (inputBuffer);
422+
canvas->refreshState();
423+
424+
Rectangle<int> canvasSnapshot (x, y, width, height);
425+
Image canvasImage = canvas->createComponentSnapshot (canvasSnapshot);
426+
427+
const int firstChannelHeight = height / numChannels;
428+
const int midlineRow = firstChannelHeight / 2;
429+
const int midlineBand = 4; // tolerance for midline so zero-buffer flat line cannot pass
430+
431+
bool hasOffMidlineSignal = false;
432+
for (int px = 0; px < width && !hasOffMidlineSignal; px++)
433+
{
434+
for (int py = 0; py < firstChannelHeight && !hasOffMidlineSignal; py++)
435+
{
436+
if (std::abs (py - midlineRow) <= midlineBand)
437+
continue;
438+
if (canvasImage.getPixelAt (px, py) == channelColours[0])
439+
hasOffMidlineSignal = true;
440+
}
441+
}
442+
443+
EXPECT_TRUE (hasOffMidlineSignal)
444+
<< "No off-midline signal found: canvas likely rendered a zero-valued flat line";
445+
446+
processor->stopAcquisition();
447+
}
448+
449+
// A connected trace produces signal in almost every pixel column; isolated dots produce
450+
// signal in only ~numSamples columns. The midline band is excluded so a zero-buffer
451+
// flat line scores zero columns.
452+
TEST_F (LfpDisplayNodeLowRateTests, LowRateTraceIsConnected)
453+
{
454+
const int canvasX = 600;
455+
const int canvasY = 800;
456+
const int numSamples = 20;
457+
458+
std::unique_ptr<LfpViewer::LfpDisplayCanvas> canvas =
459+
std::make_unique<LfpViewer::LfpDisplayCanvas> (processor, LfpViewer::SplitLayouts::SINGLE, false);
460+
canvas->updateSettings();
461+
canvas->setSize (canvasX, canvasY);
462+
canvas->resized();
463+
canvas->setVisible (true);
464+
setExpectedImageParameters (canvas.get());
465+
466+
processor->startAcquisition();
467+
canvas->beginAnimation();
468+
469+
auto inputBuffer = createBufferSinusoidal (1, numChannels, numSamples, 100);
470+
writeBlock (inputBuffer);
471+
canvas->refreshState();
472+
473+
Rectangle<int> canvasSnapshot (x, y, width, height);
474+
Image canvasImage = canvas->createComponentSnapshot (canvasSnapshot);
475+
476+
const int firstChannelHeight = height / numChannels;
477+
const int midlineRow = firstChannelHeight / 2;
478+
const int midlineBand = 4; // tolerance for midline so zero-buffer flat line cannot pass
479+
480+
int signalColumns = 0;
481+
for (int px = 0; px < width; px++)
482+
{
483+
for (int py = 0; py < firstChannelHeight; py++)
484+
{
485+
if (std::abs (py - midlineRow) <= midlineBand)
486+
continue;
487+
if (canvasImage.getPixelAt (px, py) == channelColours[0])
488+
{
489+
signalColumns++;
490+
break;
491+
}
492+
}
493+
}
494+
495+
// With connected rendering almost every column has off-midline signal.
496+
EXPECT_GT (signalColumns, numSamples * 5)
497+
<< "Trace appears disjointed: " << signalColumns
498+
<< " off-midline signal columns, expected > " << numSamples * 5;
499+
500+
processor->stopAcquisition();
501+
}

0 commit comments

Comments
 (0)