diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..b6f49a99e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,133 @@ +name: Tests + +on: + pull_request: + paths: + - 'JuceLibraryCode/**' + - 'Plugins/**' + - 'Resources/**' + - 'Source/**' + - 'CMakeLists.txt' + - 'HelperFunctions.cmake' + branches: + - 'development' + - 'testing' + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + - name: build + env: + CC: gcc-10 + CXX: g++-10 + run: | + sudo apt update + sudo ./Resources/Scripts/install_linux_dependencies.sh + git apply Resources/Scripts/gha_unit_tests.patch + cd Build && cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=ON .. + make -j8 + - name: run tests + run: | + chmod +x ./Resources/Scripts/run_unit_tests_linux.sh + ./Resources/Scripts/run_unit_tests_linux.sh Build/TestBin + shell: bash + + integration-tests: + name: Integration Tests + runs-on: windows-2022 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Start Windows Audio Engine + run: net start audiosrv + - name: Install Scream + shell: powershell + run: | + Start-Service audio* + Invoke-WebRequest https://github.com/duncanthrax/scream/releases/download/3.6/Scream3.6.zip -OutFile C:\Scream3.6.zip + Expand-7ZipArchive -Path C:\Scream3.6.zip -DestinationPath C:\Scream + $cert = (Get-AuthenticodeSignature C:\Scream\Install\driver\Scream.sys).SignerCertificate + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new("TrustedPublisher", "LocalMachine") + $store.Open("ReadWrite") + $store.Add($cert) + $store.Close() + cd C:\Scream\Install\driver + C:\Scream\Install\helpers\devcon install Scream.inf *Scream + - name: Show audio device + run: Get-CimInstance Win32_SoundDevice | fl * + - name: configure + run: | + cd Build + cmake -G "Visual Studio 17 2022" -A x64 .. + - name: Add msbuild to PATH + uses: microsoft/setup-msbuild@v1.0.2 + - name: build + run: | + cd Build + msbuild ALL_BUILD.vcxproj -p:Configuration=Release -p:Platform=x64 -m + - name: Install open-ephys-data-format + shell: powershell + run: | + New-Item -Path '..\OEPlugins' -ItemType Directory + git clone --branch main https://github.com/open-ephys-plugins/open-ephys-data-format.git ..\OEPlugins\open-ephys-data-format + cd ..\OEPlugins\open-ephys-data-format\Build + cmake -G "Visual Studio 17 2022" -A x64 .. + msbuild INSTALL.vcxproj -p:Configuration=Release -p:Platform=x64 + - name: Install OpenEphysHDF5Lib + shell: powershell + run: | + git clone --branch main https://github.com/open-ephys-plugins/OpenEphysHDF5Lib.git ..\OEPlugins\OpenEphysHDF5Lib + cd ..\OEPlugins\OpenEphysHDF5Lib\Build + cmake -G "Visual Studio 17 2022" -A x64 .. + msbuild INSTALL.vcxproj -p:Configuration=Release -p:Platform=x64 + - name: Install nwb-format + shell: powershell + run: | + git clone --branch main https://github.com/open-ephys-plugins/nwb-format.git ..\OEPlugins\nwb-format + cd ..\OEPlugins\nwb-format\Build + cmake -G "Visual Studio 17 2022" -A x64 .. + msbuild INSTALL.vcxproj -p:Configuration=Release -p:Platform=x64 + - name: Install test-suite + shell: powershell + run: | + git clone --branch main https://github.com/open-ephys/open-ephys-python-tools.git C:\open-ephys-python-tools + cd C:\open-ephys-python-tools + pip install -e . + pip install psutil + - name: Run Tests + shell: powershell + run: | + New-Item -Path 'C:\open-ephys\data' -ItemType Directory + git clone --branch main https://github.com/open-ephys/open-ephys-test-suite.git C:\test-suite + cd C:\test-suite + $process = Start-Process -FilePath "Build\Release\open-ephys.exe" -ArgumentList "Build\Release\configs\file_reader_config.xml" -NoNewWindow -PassThru + Write-Host "Started open-ephys process with ID: $($process.Id)" + Start-Sleep -Seconds 10 + Write-Host "Starting Python script..." + python run_all.py 2>&1 | Tee-Object -FilePath "python_output.log" + Write-Host "Python script completed. Output saved to python_output.log" + Stop-Process -Id $process.Id -Force + env: + OE_WINDOWS_GITHUB_RECORD_PATH: C:\open-ephys\data + - name: Set timestamp + shell: powershell + id: timestamp + run: | + $timestamp = Get-Date -Format 'yyyy_MM_dd_HH_mm_ss' + "timestamp=$timestamp" >> $env:GITHUB_OUTPUT + - name: Upload test results + uses: actions/upload-artifact@v4 + with: + name: windows_${{ steps.timestamp.outputs.timestamp }}.log + path: python_output.log + retention-days: 7 \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 2d6c76605..f579ea4ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ #Open Ephys GUI main build file cmake_minimum_required(VERSION 3.15) -set(GUI_VERSION 1.0.1) +set(GUI_VERSION 1.0.2) string(REGEX MATCHALL "[0-9]+" VERSION_LIST ${GUI_VERSION}) list(LENGTH VERSION_LIST num_version_components) diff --git a/Plugins/LfpViewer/DisplayBuffer.cpp b/Plugins/LfpViewer/DisplayBuffer.cpp index 168ab5027..67b4049d3 100644 --- a/Plugins/LfpViewer/DisplayBuffer.cpp +++ b/Plugins/LfpViewer/DisplayBuffer.cpp @@ -64,19 +64,33 @@ void DisplayBuffer::addChannel ( ContinuousChannel::Type type, bool isRecorded, int group, + float xpos, float ypos, String description, - String structure) + String structure, + float inputRangeMin, + float inputRangeMax, + String units, + bool hasGroupMetadata, + bool hasYposMetadata, + bool hasXposMetadata) { ChannelMetadata metadata = ChannelMetadata(); metadata.name = name; metadata.type = type; metadata.group = group; + metadata.xpos = xpos; metadata.ypos = ypos; metadata.structure = structure; metadata.type = type; metadata.isRecorded = isRecorded; metadata.description = description; + metadata.inputRangeMin = inputRangeMin; + metadata.inputRangeMax = inputRangeMax; + metadata.units = units; + metadata.hasGroupMetadata = hasGroupMetadata; + metadata.hasYposMetadata = hasYposMetadata; + metadata.hasXposMetadata = hasXposMetadata; channelMetadata.add (metadata); channelMap[channelNum] = numChannels; diff --git a/Plugins/LfpViewer/DisplayBuffer.h b/Plugins/LfpViewer/DisplayBuffer.h index 7464590a2..f882ceab9 100644 --- a/Plugins/LfpViewer/DisplayBuffer.h +++ b/Plugins/LfpViewer/DisplayBuffer.h @@ -61,9 +61,16 @@ class TESTABLE DisplayBuffer : public AudioBuffer ContinuousChannel::Type channelType, bool isRecorded, int group = 0, + float xpos = 0.0f, float ypos = 0, String description = "", - String structure = "None"); + String structure = "None", + float inputRangeMin = -5000.0f, + float inputRangeMax = +5000.0f, + String units = "", + bool hasGroupMetadata = false, + bool hasYposMetadata = false, + bool hasXposMetadata = false); /** Initializes the event channel at the start of each buffer */ void initializeEventChannel (int nSamples); @@ -86,11 +93,18 @@ class TESTABLE DisplayBuffer : public AudioBuffer { String name = ""; int group = 0; + float xpos = 0.0f; float ypos = 0; String structure = "None"; ContinuousChannel::Type type; bool isRecorded = false; String description = ""; + float inputRangeMin = -5000.0f; + float inputRangeMax = +5000.0f; + String units = ""; + bool hasGroupMetadata = false; + bool hasYposMetadata = false; + bool hasXposMetadata = false; }; Array channelMetadata; diff --git a/Plugins/LfpViewer/LfpChannelDisplay.cpp b/Plugins/LfpViewer/LfpChannelDisplay.cpp index a1df71d80..085504e36 100644 --- a/Plugins/LfpViewer/LfpChannelDisplay.cpp +++ b/Plugins/LfpViewer/LfpChannelDisplay.cpp @@ -79,6 +79,16 @@ void LfpChannelDisplay::setType (ContinuousChannel::Type type_) typeStr = options->getTypeName (type); } +void LfpChannelDisplay::setUnits (const String& newUnits) +{ + units = newUnits; +} + +const String& LfpChannelDisplay::getUnits() const +{ + return units; +} + void LfpChannelDisplay::setEnabledState (bool state) { /*if (state) @@ -809,6 +819,18 @@ void LfpChannelDisplay::setDepth (float depth_) depth = depth_; } +void LfpChannelDisplay::setXpos (float xpos_) +{ + xpos = xpos_; +} + +void LfpChannelDisplay::setMetadataPresence (bool hasGroupMetadata_, bool hasYposMetadata_, bool hasXposMetadata_) +{ + groupMetadataAvailable = hasGroupMetadata_; + yposMetadataAvailable = hasYposMetadata_; + xposMetadataAvailable = hasXposMetadata_; +} + void LfpChannelDisplay::setRecorded (bool recorded_) { isRecorded = recorded_; diff --git a/Plugins/LfpViewer/LfpChannelDisplay.h b/Plugins/LfpViewer/LfpChannelDisplay.h index f360c1a54..e4abbde6a 100644 --- a/Plugins/LfpViewer/LfpChannelDisplay.h +++ b/Plugins/LfpViewer/LfpChannelDisplay.h @@ -89,6 +89,12 @@ class LfpChannelDisplay : public Component /** Sets the channel depth*/ void setDepth (float); + /** Sets the channel x-position */ + void setXpos (float); + + /** Records which metadata fields were provided for this channel */ + void setMetadataPresence (bool hasGroupMetadata_, bool hasYposMetadata_, bool hasXposMetadata_); + /** Sets whether or not the channel is recorded by an upstream Record Node*/ void setRecorded (bool); @@ -113,6 +119,12 @@ class LfpChannelDisplay : public Component /** Return the assigned channel name */ String getName(); + /** Set the units string used for display */ + void setUnits (const String& newUnits); + + /** Return the units string for this channel */ + const String& getUnits() const; + /** Returns the assigned channel number for this display, relative to the subset of channels being drawn to the canvas */ int getDrawableChannelNumber(); @@ -170,6 +182,10 @@ class LfpChannelDisplay : public Component float getDepth() { return depth; } int getGroup() { return group; } + float getXpos() const { return xpos; } + bool hasGroupMetadata() const { return groupMetadataAvailable; } + bool hasYposMetadata() const { return yposMetadataAvailable; } + bool hasXposMetadata() const { return xposMetadataAvailable; } int ifrom, ito, ito_local, ifrom_local; @@ -189,10 +205,16 @@ class LfpChannelDisplay : public Component int drawableChan; String name; - int group; - float depth; + int group = 0; + float depth = 0.0f; + float xpos = 0.0f; + bool groupMetadataAvailable = false; + bool yposMetadataAvailable = false; + bool xposMetadataAvailable = false; bool isRecorded; + String units; + FontOptions channelFont; Colour lineColour; diff --git a/Plugins/LfpViewer/LfpChannelDisplayInfo.cpp b/Plugins/LfpViewer/LfpChannelDisplayInfo.cpp index e09617469..904ea56c4 100644 --- a/Plugins/LfpViewer/LfpChannelDisplayInfo.cpp +++ b/Plugins/LfpViewer/LfpChannelDisplayInfo.cpp @@ -239,8 +239,31 @@ void LfpChannelDisplayInfo::paint (Graphics& g) if (getChannelTypeStringVisibility()) { + constexpr int textHeight = 14; + constexpr int textStartX = 5; + const int textY = center + 10; + g.setFont (FontOptions (13.0f)); - g.drawText (typeStr, 5, center + 10, 50, 14, Justification::centred, false); + g.setColour (lineColour); + const auto currentFont = g.getCurrentFont(); + + const int typeWidth = currentFont.getStringWidth (typeStr); + const int typeBoundsWidth = typeWidth + 2; + g.drawText (typeStr, textStartX, textY, typeBoundsWidth, textHeight, Justification::centredLeft, false); + + const String& unitsText = getUnits(); + if (unitsText.isNotEmpty()) + { + const int unitsX = textStartX + typeWidth + 5; + const int unitsWidth = getWidth() - unitsX - 4; + + if (unitsWidth > 0) + { + g.setColour (Colours::grey.withAlpha (0.8f)); + g.setFont (FontOptions (12.0f)); + g.drawFittedText (unitsText, unitsX, textY, unitsWidth, textHeight, Justification::centredLeft, 1, 0.8f); + } + } } if (isSingleChannel) @@ -325,8 +348,7 @@ bool LfpChannelDisplayInfo::isChannelNumberHidden() String LfpChannelDisplayInfo::getTooltip() { const bool showChannelNumbers = options->getChannelNameState(); - const String channelString = (isChannelNumberHidden() ? ("--") : showChannelNumbers ? String (getChannelNumber() + 1) - : getName()); + const String channelString = showChannelNumbers ? String (getChannelNumber() + 1) : getName(); return channelString; -} \ No newline at end of file +} diff --git a/Plugins/LfpViewer/LfpDisplay.cpp b/Plugins/LfpViewer/LfpDisplay.cpp index 884c489ff..de1ce60b3 100644 --- a/Plugins/LfpViewer/LfpDisplay.cpp +++ b/Plugins/LfpViewer/LfpDisplay.cpp @@ -47,8 +47,10 @@ #define MS_FROM_START Time::highResolutionTicksToSeconds (Time::getHighResolutionTicks() - start) * 1000 +#include #include #include +#include using namespace LfpViewer; @@ -162,8 +164,32 @@ ChannelColourScheme* LfpDisplay::getColourSchemePtr() void LfpDisplay::updateRange (int i) { - channels[i]->setRange (range[channels[i]->getType()]); - channelInfo[i]->setRange (range[channels[i]->getType()]); + // Check if this is an AUX channel with auto-scaling enabled + if (channels[i]->getType() == ContinuousChannel::Type::AUX && options->isAuxAutoScaleEnabled()) + { + // Apply individual auto-scaling based on this channel's InputRange + float rangeMin = canvasSplit->displayBuffer->channelMetadata[i].inputRangeMin; + float rangeMax = canvasSplit->displayBuffer->channelMetadata[i].inputRangeMax; + float autoRange = rangeMax - rangeMin; + + if (autoRange > 0.0f) + { + channels[i]->setRange (autoRange); + channelInfo[i]->setRange (autoRange); + } + else + { + // Fall back to the default range for this type + channels[i]->setRange (range[channels[i]->getType()]); + channelInfo[i]->setRange (range[channels[i]->getType()]); + } + } + else + { + // Use the standard range for the channel type + channels[i]->setRange (range[channels[i]->getType()]); + channelInfo[i]->setRange (range[channels[i]->getType()]); + } } void LfpDisplay::setNumChannels (int newChannelCount) @@ -234,17 +260,31 @@ void LfpDisplay::setColours() { if (colourGrouping.equalsIgnoreCase ("By Shank")) { - /* - depth ranges of electrodes for multishank configurations: - Shank 1: depth < 10000 - Shank 2: 10000 ≤ depth < 20000 - Shank 3: 20000 ≤ depth < 30000 - Shank 4: depth ≥ 30000 - */ - int depth = drawableChannels[i].channel->getDepth(); - int colourIdx = depth < 10000 ? 0 : depth < 20000 ? 2 : depth < 30000 ? 4 : 6; - drawableChannels[i].channel->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); - drawableChannels[i].channelInfo->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + auto* channel = drawableChannels[i].channel; + auto* info = drawableChannels[i].channelInfo; + + if (channel->hasGroupMetadata()) + { + const int colourIdx = (channel->getGroup() * 2) % 8; + channel->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + info->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + } + else + { + /* + Legacy depth-based shank colouring ranges: + Shank 1: depth < 10000 + Shank 2: 10000 ≤ depth < 20000 + Shank 3: 20000 ≤ depth < 30000 + Shank 4: depth ≥ 30000 + */ + const int depth = channel->getDepth(); + const int colourIdx = depth < 10000 ? 0 : depth < 20000 ? 2 + : depth < 30000 ? 4 + : 6; + channel->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + info->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + } } else { @@ -257,10 +297,24 @@ void LfpDisplay::setColours() { if (colourGrouping.equalsIgnoreCase ("By Shank")) { - int depth = drawableChannels[0].channel->getDepth(); - int colourIdx = depth < 10000 ? 0 : depth < 20000 ? 2 : depth < 30000 ? 4 : 6; - drawableChannels[0].channel->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); - drawableChannels[0].channelInfo->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + auto* channel = drawableChannels[0].channel; + auto* info = drawableChannels[0].channelInfo; + + if (channel->hasGroupMetadata()) + { + const int colourIdx = channel->getGroup(); + channel->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + info->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + } + else + { + const int depth = channel->getDepth(); + const int colourIdx = depth < 10000 ? 0 : depth < 20000 ? 2 + : depth < 30000 ? 4 + : 6; + channel->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + info->setColour (getColourSchemePtr()->getColourForIndex (colourIdx)); + } } else { @@ -602,7 +656,10 @@ void LfpDisplay::setRange (float r, ContinuousChannel::Type type) for (int i = 0; i < numChans; i++) { if (channels[i]->getType() == type) + { channels[i]->setRange (range[type]); + channelInfo[i]->setRange (range[type]); + } } if (displayIsPaused) @@ -615,6 +672,46 @@ void LfpDisplay::setRange (float r, ContinuousChannel::Type type) } } +void LfpDisplay::setAutoRangeForAuxChannels() +{ + if (canvasSplit->displayBuffer == nullptr || channels.size() == 0) + return; + + // Apply individual ranges to each AUX channel based on their InputRange + for (int i = 0; i < numChans; i++) + { + if (channels[i]->getType() == ContinuousChannel::Type::AUX) + { + // Get the InputRange from the channel metadata + if (i < canvasSplit->displayBuffer->channelMetadata.size()) + { + float rangeMin = canvasSplit->displayBuffer->channelMetadata[i].inputRangeMin; + float rangeMax = canvasSplit->displayBuffer->channelMetadata[i].inputRangeMax; + float autoRange = rangeMax - rangeMin; + + if (autoRange > 0.0f) + { + channels[i]->setRange (autoRange); + channelInfo[i]->setRange (autoRange); + } + else + { + // Fall back to the default range for this type + channels[i]->setRange (range[ContinuousChannel::Type::AUX]); + channelInfo[i]->setRange (range[ContinuousChannel::Type::AUX]); + } + } + } + } + + if (displayIsPaused) + { + timeOffsetChanged = true; + canRefresh = true; + refresh(); + } +} + int LfpDisplay::getRange() { return getRange (options->getSelectedType()); @@ -834,20 +931,25 @@ void LfpDisplay::mouseWheelMove (const MouseEvent& e, const MouseWheelDetails& w { if (e.mods.isAltDown()) // ALT + scroll wheel -> change channel range (was SHIFT but that clamps wheel.deltaY to 0 on OSX for some reason..) { + auto chanType = options->getSelectedType(); + + if (chanType == ContinuousChannel::AUX && options->isAuxAutoScaleEnabled()) + return; + int h = getRange(); - int step = options->getRangeStep (options->getSelectedType()); + int step = options->getRangeStep (chanType); // std::cout << wheel.deltaY << std::endl; if (wheel.deltaY > 0) { - setRange (h + step, options->getSelectedType()); + setRange (h + step, chanType); } else { if (h > step + 1) - setRange (h - step, options->getSelectedType()); + setRange (h - step, chanType); } options->setRangeSelection (h); // update combobox @@ -1020,23 +1122,43 @@ void LfpDisplay::rebuildDrawableChannelsList() const int numChannels = channelsToDraw.size(); std::vector depths (numChannels); + std::vector xposValues (numChannels); + std::vector hasYposMetadata (numChannels); + std::vector hasXposMetadata (numChannels); + std::vector groups (numChannels); + std::vector hasGroupMetadata (numChannels); bool allSame = true; + bool anyYposMetadata = false; + bool anyXposMetadata = false; + bool anyGroupMetadata = false; float last = channelsToDraw[0].channelInfo->getDepth(); for (int i = 0; i < channelsToDraw.size(); i++) { - float depth = channelsToDraw[i].channelInfo->getDepth(); + auto* info = channelsToDraw[i].channelInfo; + const float depth = info->getDepth(); if (depth != last) allSame = false; depths[i] = depth; - + xposValues[i] = info->getXpos(); + hasYposMetadata[i] = info->hasYposMetadata(); + hasXposMetadata[i] = info->hasXposMetadata(); + groups[i] = info->getGroup(); + hasGroupMetadata[i] = info->hasGroupMetadata(); + + anyYposMetadata = anyYposMetadata || hasYposMetadata[i]; + anyXposMetadata = anyXposMetadata || hasXposMetadata[i]; + anyGroupMetadata = anyGroupMetadata || hasGroupMetadata[i]; last = depth; } - if (allSame) + const bool positionMetadataAvailable = anyYposMetadata && anyXposMetadata; + const bool groupMetadataAvailable = anyGroupMetadata; + + if (! groupMetadataAvailable && allSame && ! anyYposMetadata) { LOGD ("No depth info found."); } @@ -1046,7 +1168,21 @@ void LfpDisplay::rebuildDrawableChannelsList() std::iota (V.begin(), V.end(), 0); //Initializing sort (V.begin(), V.end(), [&] (int i, int j) - { return depths[i] <= depths[j]; }); + { + const float depthDiff = depths[i] - depths[j]; + const float depthEpsilon = 1.0e-3f; + + if (groupMetadataAvailable && groups[i] != groups[j]) + return groups[i] < groups[j]; + + if (std::abs (depthDiff) >= depthEpsilon) + return depths[i] < depths[j]; + + if (positionMetadataAvailable) + return xposValues[i] < xposValues[j]; + + return i < j; // deterministic fallback + }); Array orderedDrawableChannels; @@ -1174,6 +1310,7 @@ void LfpDisplay::mouseDown (const MouseEvent& event) int dist = 0; int mindist = 10000; int closest = 5; + float chanRange = getRange(); for (int n = 0; n < drawableChannels.size(); n++) // select closest instead of relying on eventComponent { @@ -1188,6 +1325,7 @@ void LfpDisplay::mouseDown (const MouseEvent& event) { mindist = dist - 1; closest = n; + chanRange = drawableChannels[n].channel->getRange(); } } @@ -1213,7 +1351,7 @@ void LfpDisplay::mouseDown (const MouseEvent& event) { drawableChannels[0].channelInfo->updateXY ( float (x) / getWidth() * canvasSplit->timebase, - (-(float (y) - viewport->getViewPositionY()) / viewport->getViewHeight() * float (getRange())) + float (getRange() / 2)); + (-(float (y) - viewport->getViewPositionY()) / viewport->getViewHeight() * float (chanRange)) + float (chanRange / 2)); } } diff --git a/Plugins/LfpViewer/LfpDisplay.h b/Plugins/LfpViewer/LfpDisplay.h index ad9331028..64b2be08f 100644 --- a/Plugins/LfpViewer/LfpDisplay.h +++ b/Plugins/LfpViewer/LfpDisplay.h @@ -92,6 +92,9 @@ class LfpDisplay : public Component, /** Sets the display range for a particular channel type*/ void setRange (float range, ContinuousChannel::Type type); + /** Applies auto-scaling to AUX channels based on their individual InputRange values */ + void setAutoRangeForAuxChannels(); + /** Returns the display range for the current channel type*/ int getRange(); diff --git a/Plugins/LfpViewer/LfpDisplayCanvas.cpp b/Plugins/LfpViewer/LfpDisplayCanvas.cpp index ff1f7f48e..e9d0f9a88 100644 --- a/Plugins/LfpViewer/LfpDisplayCanvas.cpp +++ b/Plugins/LfpViewer/LfpDisplayCanvas.cpp @@ -29,6 +29,8 @@ #include "LfpDisplayNode.h" #include "ShowHideOptionsButton.h" +#include +#include #include #define MS_FROM_START Time::highResolutionTicksToSeconds (Time::getHighResolutionTicks() - start) * 1000 @@ -750,6 +752,27 @@ String LfpDisplaySplitter::getStreamKey() return stream->getKey(); } +void LfpDisplaySplitter::refreshLeftMargin() +{ + int newMargin = minimumLeftMargin; + + if (displayBuffer != nullptr) + { + const auto labelFont = Font (FontOptions (14.0f)); + constexpr int padding = 20; + + for (int i = 0; i < displayBuffer->channelMetadata.size(); ++i) + { + const auto& metadata = displayBuffer->channelMetadata.getReference (i); + const float textWidth = labelFont.getStringWidthFloat (metadata.name); + const int requiredWidth = static_cast (std::ceil (textWidth)) + padding; + newMargin = std::max (newMargin, requiredWidth); + } + } + + leftmargin = newMargin; +} + void LfpDisplaySplitter::resized() { const int timescaleHeight = 30; @@ -928,6 +951,7 @@ void LfpDisplaySplitter::updateSettings() sampleRate = 44100.0f; options->setEnabled (true); + leftmargin = minimumLeftMargin; } else { @@ -942,6 +966,8 @@ void LfpDisplaySplitter::updateSettings() options->setEnabled (true); channelOverlapFactor = options->selectedOverlapValue.getFloatValue(); + + refreshLeftMargin(); } if (eventDisplayBuffer == nullptr) // not yet initialized @@ -966,14 +992,24 @@ void LfpDisplaySplitter::updateSettings() lfpDisplay->channels[i]->setName (displayBuffer->channelMetadata[i].name); lfpDisplay->channels[i]->setGroup (displayBuffer->channelMetadata[i].group); lfpDisplay->channels[i]->setDepth (displayBuffer->channelMetadata[i].ypos); + lfpDisplay->channels[i]->setXpos (displayBuffer->channelMetadata[i].xpos); + lfpDisplay->channels[i]->setMetadataPresence (displayBuffer->channelMetadata[i].hasGroupMetadata, + displayBuffer->channelMetadata[i].hasYposMetadata, + displayBuffer->channelMetadata[i].hasXposMetadata); lfpDisplay->channels[i]->setRecorded (displayBuffer->channelMetadata[i].isRecorded); lfpDisplay->channels[i]->updateType (displayBuffer->channelMetadata[i].type); + lfpDisplay->channels[i]->setUnits (displayBuffer->channelMetadata[i].units); lfpDisplay->channelInfo[i]->setName (displayBuffer->channelMetadata[i].name); lfpDisplay->channelInfo[i]->setGroup (displayBuffer->channelMetadata[i].group); lfpDisplay->channelInfo[i]->setDepth (displayBuffer->channelMetadata[i].ypos); + lfpDisplay->channelInfo[i]->setXpos (displayBuffer->channelMetadata[i].xpos); + lfpDisplay->channelInfo[i]->setMetadataPresence (displayBuffer->channelMetadata[i].hasGroupMetadata, + displayBuffer->channelMetadata[i].hasYposMetadata, + displayBuffer->channelMetadata[i].hasXposMetadata); lfpDisplay->channelInfo[i]->setRecorded (displayBuffer->channelMetadata[i].isRecorded); lfpDisplay->channelInfo[i]->updateType (displayBuffer->channelMetadata[i].type); + lfpDisplay->channelInfo[i]->setUnits (displayBuffer->channelMetadata[i].units); lfpDisplay->updateRange (i); diff --git a/Plugins/LfpViewer/LfpDisplayCanvas.h b/Plugins/LfpViewer/LfpDisplayCanvas.h index adb90583e..2e9631b39 100644 --- a/Plugins/LfpViewer/LfpDisplayCanvas.h +++ b/Plugins/LfpViewer/LfpDisplayCanvas.h @@ -308,8 +308,11 @@ class LfpDisplaySplitter : public Component, */ bool fullredraw; - /** Left margin for lfp plots (so the ch number text doesnt overlap) */ - static const int leftmargin = 60; // + /** Minimum left margin for LFP plots (so the channel label has space) */ + static constexpr int minimumLeftMargin = 60; + + /** Left margin for LFP plots, adjusted to fit the widest channel label */ + int leftmargin = minimumLeftMargin; Array isChannelEnabled; @@ -359,6 +362,8 @@ class LfpDisplaySplitter : public Component, String getStreamKey(); private: + void refreshLeftMargin(); + bool isSelected; bool isUpdating; diff --git a/Plugins/LfpViewer/LfpDisplayNode.cpp b/Plugins/LfpViewer/LfpDisplayNode.cpp index 8e79522f0..a0e2b528c 100644 --- a/Plugins/LfpViewer/LfpDisplayNode.cpp +++ b/Plugins/LfpViewer/LfpDisplayNode.cpp @@ -85,14 +85,40 @@ void LfpDisplayNode::updateSettings() displayBufferMap[streamId]->sampleRate = channel->getSampleRate(); displayBufferMap[streamId]->name = name; } - // + + bool hasGroupMetadata = (! channel->group.name.equalsIgnoreCase ("default")); + + float ypos = channel->position.y; + float xpos = channel->position.x; + bool hasYposMetadata = false; + bool hasXposMetadata = false; + + const int yposMetadataIndex = channel->findMetadata (MetadataDescriptor::MetadataType::FLOAT, 1, "channel.ypos"); + if (yposMetadataIndex >= 0) + { + if (const auto* yposValue = channel->getMetadataValue (yposMetadataIndex)) + { + yposValue->getValue (ypos); + hasYposMetadata = true; + hasXposMetadata = true; + } + } + displayBufferMap[streamId]->addChannel (channel->getName(), // name ch, // index channel->getChannelType(), // type channel->isRecorded, - 0, // group - channel->position.y, // ypos - channel->getDescription()); + channel->group.number, // group + xpos, + ypos, // ypos + channel->getDescription(), + "None", // structure + channel->inputRange.min, // inputRangeMin + channel->inputRange.max, // inputRangeMax + channel->getUnits(), // units + hasGroupMetadata, + hasYposMetadata, + hasXposMetadata); // metadata flags } Array toDelete; diff --git a/Plugins/LfpViewer/LfpDisplayOptions.cpp b/Plugins/LfpViewer/LfpDisplayOptions.cpp index b9d5cc325..d544a1dda 100644 --- a/Plugins/LfpViewer/LfpDisplayOptions.cpp +++ b/Plugins/LfpViewer/LfpDisplayOptions.cpp @@ -154,6 +154,7 @@ LfpDisplayOptions::LfpDisplayOptions (LfpDisplayCanvas* canvas_, LfpDisplaySplit typeButtons.add (tbut); //Ranges for AUX/accelerometer data + voltageRanges[ContinuousChannel::Type::AUX].add ("Auto"); voltageRanges[ContinuousChannel::Type::AUX].add ("25"); voltageRanges[ContinuousChannel::Type::AUX].add ("50"); voltageRanges[ContinuousChannel::Type::AUX].add ("100"); @@ -163,7 +164,7 @@ LfpDisplayOptions::LfpDisplayOptions (LfpDisplayCanvas* canvas_, LfpDisplaySplit voltageRanges[ContinuousChannel::Type::AUX].add ("750"); voltageRanges[ContinuousChannel::Type::AUX].add ("1000"); voltageRanges[ContinuousChannel::Type::AUX].add ("2000"); - selectedVoltageRange[ContinuousChannel::Type::AUX] = 9; + selectedVoltageRange[ContinuousChannel::Type::AUX] = 1; // Default to Auto rangeGain[ContinuousChannel::Type::AUX] = 0.001f; //mV rangeSteps[ContinuousChannel::Type::AUX] = 10; rangeUnits.add ("mV"); @@ -549,11 +550,23 @@ LfpDisplayOptions::LfpDisplayOptions (LfpDisplayCanvas* canvas_, LfpDisplaySplit * rangeGain[ContinuousChannel::Type::ELECTRODE], ContinuousChannel::Type::ELECTRODE); lfpDisplay->setRange (voltageRanges[ContinuousChannel::Type::ADC][selectedVoltageRange[ContinuousChannel::Type::ADC] - 1].getFloatValue() - * rangeGain[ContinuousChannel::Type::AUX], + * rangeGain[ContinuousChannel::Type::ADC], ContinuousChannel::Type::ADC); - lfpDisplay->setRange (voltageRanges[ContinuousChannel::Type::AUX][selectedVoltageRange[ContinuousChannel::Type::AUX] - 1].getFloatValue() - * rangeGain[ContinuousChannel::Type::AUX], - ContinuousChannel::Type::AUX); + + // Handle Auto scaling for AUX channels during initialization + String auxRangeValue = voltageRanges[ContinuousChannel::Type::AUX][selectedVoltageRange[ContinuousChannel::Type::AUX] - 1]; + if (auxRangeValue.equalsIgnoreCase ("Auto")) + { + // Set a default range value for the type (used as fallback) + lfpDisplay->setRange (2000.0f * rangeGain[ContinuousChannel::Type::AUX], ContinuousChannel::Type::AUX); + // Apply individual auto-scaling to each AUX channel based on their InputRange + lfpDisplay->setAutoRangeForAuxChannels(); + } + else + { + lfpDisplay->setRange (auxRangeValue.getFloatValue() * rangeGain[ContinuousChannel::Type::AUX], + ContinuousChannel::Type::AUX); + } } void LfpDisplayOptions::timerCallback() @@ -1198,17 +1211,37 @@ void LfpDisplayOptions::comboBoxChanged (ComboBox* cb) { if (cb->getSelectedId()) { - lfpDisplay->setRange (voltageRanges[selectedChannelType][cb->getSelectedId() - 1].getFloatValue() * rangeGain[selectedChannelType], selectedChannelType); + String selectedText = voltageRanges[selectedChannelType][cb->getSelectedId() - 1]; + + // Check if "Auto" is selected for AUX channels + if (selectedChannelType == ContinuousChannel::Type::AUX && selectedText.equalsIgnoreCase ("Auto")) + { + // Set a default range value for the type (used as fallback) + lfpDisplay->setRange (2000.0f * rangeGain[selectedChannelType], selectedChannelType); + // Apply individual auto-scaling to each AUX channel based on their InputRange + lfpDisplay->setAutoRangeForAuxChannels(); + } + else + { + lfpDisplay->setRange (selectedText.getFloatValue() * rangeGain[selectedChannelType], selectedChannelType); + } } else { float vRange = cb->getText().getFloatValue(); if (vRange) { - if (vRange < voltageRanges[selectedChannelType][0].getFloatValue()) + // Check if we should skip the first item (Auto) when validating range + int firstRangeIndex = 0; + if (selectedChannelType == ContinuousChannel::Type::AUX && voltageRanges[selectedChannelType][0].equalsIgnoreCase ("Auto")) { - cb->setSelectedId (1, dontSendNotification); - vRange = voltageRanges[selectedChannelType][0].getFloatValue(); + firstRangeIndex = 1; + } + + if (firstRangeIndex < voltageRanges[selectedChannelType].size() && vRange < voltageRanges[selectedChannelType][firstRangeIndex].getFloatValue()) + { + cb->setSelectedId (firstRangeIndex + 1, dontSendNotification); + vRange = voltageRanges[selectedChannelType][firstRangeIndex].getFloatValue(); } else if (vRange > voltageRanges[selectedChannelType][voltageRanges[selectedChannelType].size() - 1].getFloatValue()) { @@ -1235,6 +1268,11 @@ void LfpDisplayOptions::comboBoxChanged (ComboBox* cb) selectedVoltageRange[selectedChannelType] = cb->getSelectedId(); selectedVoltageRangeValues[selectedChannelType] = cb->getText(); canvasSplit->redraw(); + + if (selectedChannelType == ContinuousChannel::Type::AUX && isAuxAutoScaleEnabled()) + rangeSelectionLabel->setText ("Range", dontSendNotification); + else + rangeSelectionLabel->setText ("Range (" + rangeUnits[selectedChannelType] + ")", dontSendNotification); } else if (cb == spreadSelection.get()) { @@ -1396,6 +1434,11 @@ ContinuousChannel::Type LfpDisplayOptions::getSelectedType() return selectedChannelType; } +bool LfpDisplayOptions::isAuxAutoScaleEnabled() +{ + return selectedVoltageRangeValues[ContinuousChannel::Type::AUX].equalsIgnoreCase ("Auto"); +} + void LfpDisplayOptions::setSelectedType (ContinuousChannel::Type type, bool toggleButton) { if (selectedChannelType == type) @@ -1412,7 +1455,11 @@ void LfpDisplayOptions::setSelectedType (ContinuousChannel::Type type, bool togg else rangeSelection->setText (selectedVoltageRangeValues[selectedChannelType], dontSendNotification); - rangeSelectionLabel->setText ("Range (" + rangeUnits[type] + ")", dontSendNotification); + // If AUX and 'Auto' is selected, do not show units + if (type == ContinuousChannel::Type::AUX && isAuxAutoScaleEnabled()) + rangeSelectionLabel->setText ("Range", dontSendNotification); + else + rangeSelectionLabel->setText ("Range (" + rangeUnits[type] + ")", dontSendNotification); repaint (5, getHeight() - 55, 300, 100); @@ -1547,7 +1594,20 @@ void LfpDisplayOptions::loadParameters (XmlElement* xml) selectedVoltageRange[2] = voltageRanges[2].indexOf (ranges[2]) + 1; rangeSelection->setText (ranges[selectedChannelType]); lfpDisplay->setRange (ranges[0].getFloatValue() * rangeGain[0], ContinuousChannel::Type::ELECTRODE); - lfpDisplay->setRange (ranges[1].getFloatValue() * rangeGain[1], ContinuousChannel::Type::AUX); + + // Handle Auto scaling for AUX channels + if (ranges[1].equalsIgnoreCase ("Auto")) + { + // Set a default range value for the type (used as fallback) + lfpDisplay->setRange (2000.0f * rangeGain[ContinuousChannel::Type::AUX], ContinuousChannel::Type::AUX); + // Apply individual auto-scaling to each AUX channel based on their InputRange + lfpDisplay->setAutoRangeForAuxChannels(); + } + else + { + lfpDisplay->setRange (ranges[1].getFloatValue() * rangeGain[1], ContinuousChannel::Type::AUX); + } + lfpDisplay->setRange (ranges[2].getFloatValue() * rangeGain[2], ContinuousChannel::Type::ADC); // LOGD(" Set range in ", MS_FROM_START, " milliseconds"); diff --git a/Plugins/LfpViewer/LfpDisplayOptions.h b/Plugins/LfpViewer/LfpDisplayOptions.h index 7a1171a00..ef27f3b80 100644 --- a/Plugins/LfpViewer/LfpDisplayOptions.h +++ b/Plugins/LfpViewer/LfpDisplayOptions.h @@ -103,6 +103,9 @@ class LfpDisplayOptions : public Component, /** Returns the selected channel type for the range editor */ ContinuousChannel::Type getSelectedType(); + /** Returns true if AUX channels are set to auto-scale */ + bool isAuxAutoScaleEnabled(); + /** Returns the name for a given channel type (DATA, AUX, ADC) */ String getTypeName (ContinuousChannel::Type type); diff --git a/Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp b/Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp index e83446778..ca1592e4e 100644 --- a/Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp +++ b/Plugins/LfpViewer/Tests/LfpDisplayNodeTests.cpp @@ -319,7 +319,7 @@ TEST_F (LfpDisplayNodeTests, VisualIntegrityTest) Rectangle canvasSnapshot (x, y, width, height); ExpectedImage expected (numChannels, sampleRate * 2); //2 seconds to match canvas timebase - tester->startAcquisition (false); + processor->startAcquisition (); canvas->beginAnimation(); //Add 5 10Hz waves with +-125uV amplitude @@ -367,16 +367,16 @@ TEST_F (LfpDisplayNodeTests, VisualIntegrityTest) missCount = getImageDifferencePixelCount (expectedImage, canvasImage); EXPECT_LE (float (missCount) / float (width * height), errorThreshold); - tester->stopAcquisition(); + processor->stopAcquisition(); } TEST_F (LfpDisplayNodeTests, DataIntegrityTest) { int numSamples = 100; - tester->startAcquisition (false); + processor->startAcquisition (); auto inputBuffer = createBuffer (1000.0, 20.0, numChannels, numSamples); writeBlock (inputBuffer); - tester->stopAcquisition(); + processor->stopAcquisition(); } diff --git a/README.md b/README.md index 80db39cb5..c5908f5e2 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,11 @@ Our primary user base is scientists performing electrophysiology experiments wit The easiest way to get started is to download the installer for your platform of choice: -- [Windows](https://openephys.jfrog.io/artifactory/GUI-binaries/Release-Installer/windows/Install-Open-Ephys-GUI-v1.0.1.exe) -- [Ubuntu/Debian](https://openephys.jfrog.io/artifactory/GUI-binaries/Release-Installer/linux/open-ephys-gui-v1.0.1.deb) -- [macOS](https://openephys.jfrog.io/artifactory/GUI-binaries/Release-Installer/mac/Open_Ephys_GUI_v1.0.1.dmg) +- [Windows](https://openephys.jfrog.io/artifactory/GUI-binaries/Release-Installer/windows/Install-Open-Ephys-GUI-v1.0.2.exe) +- [Ubuntu/Debian](https://openephys.jfrog.io/artifactory/GUI-binaries/Release-Installer/linux/open-ephys-gui-v1.0.2.deb) +- [macOS](https://openephys.jfrog.io/artifactory/GUI-binaries/Release-Installer/mac/Open_Ephys_GUI_v1.0.2.dmg) -It’s also possible to obtain the binaries as a .zip file for [Windows](https://openephys.jfrog.io/artifactory/GUI-binaries/Release/windows/open-ephys-v1.0.1-windows.zip), [Linux](https://openephys.jfrog.io/artifactory/GUI-binaries/Release/linux/open-ephys-v1.0.1-linux.zip), or [Mac](https://openephys.jfrog.io/artifactory/GUI-binaries/Release/mac/open-ephys-v1.0.1-mac.zip). +It’s also possible to obtain the binaries as a .zip file for [Windows](https://openephys.jfrog.io/artifactory/GUI-binaries/Release/windows/open-ephys-v1.0.2-windows.zip), [Linux](https://openephys.jfrog.io/artifactory/GUI-binaries/Release/linux/open-ephys-v1.0.2-linux.zip), or [Mac](https://openephys.jfrog.io/artifactory/GUI-binaries/Release/mac/open-ephys-v1.0.2-mac.zip). Detailed installation instructions can be found [here](https://open-ephys.github.io/gui-docs/User-Manual/Installing-the-GUI.html). diff --git a/Resources/Installers/Linux/Open-Ephys_Installer/DEBIAN/control b/Resources/Installers/Linux/Open-Ephys_Installer/DEBIAN/control index 4aa44268e..3cf25e17b 100644 --- a/Resources/Installers/Linux/Open-Ephys_Installer/DEBIAN/control +++ b/Resources/Installers/Linux/Open-Ephys_Installer/DEBIAN/control @@ -1,9 +1,9 @@ Package: open-ephys -Version: 1.0.1 +Version: 1.0.2 Architecture: amd64 Installed-Size: 18644 Section: science Priority: optional -Maintainer: Open Ephys +Maintainer: Open Ephys Homepage: https://open-ephys.org/gui Description: Software for processing, recording, and visualizing multichannel electrophysiology data. diff --git a/Resources/Installers/Linux/Open-Ephys_Installer/DEBIAN/copyright b/Resources/Installers/Linux/Open-Ephys_Installer/DEBIAN/copyright index 36ef27be3..9fa74860d 100644 --- a/Resources/Installers/Linux/Open-Ephys_Installer/DEBIAN/copyright +++ b/Resources/Installers/Linux/Open-Ephys_Installer/DEBIAN/copyright @@ -3,7 +3,7 @@ Upstream-Name: Open Ephys GUI Source: https://github.com/open-ephys/plugin-GUI/ Files: * -Copyright: 2025 Open Ephys +Copyright: 2026 Open Ephys License: GPL-3+ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/Resources/Installers/Windows/windows_installer_script.iss b/Resources/Installers/Windows/windows_installer_script.iss index 38e0d8329..2f9167c8c 100644 --- a/Resources/Installers/Windows/windows_installer_script.iss +++ b/Resources/Installers/Windows/windows_installer_script.iss @@ -1,9 +1,9 @@ [Setup] AppId=Open Ephys AppName=Open Ephys GUI -AppVersion=1.0.1 -AppVerName=Open Ephys GUI 1.0.1 -AppCopyright=Copyright (C) 2010-2025, Open Ephys & Contributors +AppVersion=1.0.2 +AppVerName=Open Ephys GUI 1.0.2 +AppCopyright=Copyright (C) 2010-2026, Open Ephys & Contributors AppPublisher=open-ephys.org AppPublisherURL=https://open-ephys.org/gui DefaultDirName={autopf}\Open Ephys diff --git a/Resources/Scripts/gha_unit_tests.patch b/Resources/Scripts/gha_unit_tests.patch new file mode 100644 index 000000000..6b3c7c073 --- /dev/null +++ b/Resources/Scripts/gha_unit_tests.patch @@ -0,0 +1,15 @@ +diff --git a/Tests/Processors/CMakeLists.txt b/Tests/Processors/CMakeLists.txt +index a89fa4da4..ab53e8d89 100644 +--- a/Tests/Processors/CMakeLists.txt ++++ b/Tests/Processors/CMakeLists.txt +@@ -5,8 +5,8 @@ add_sources(${COMPONENT_NAME}_tests + DataBufferTests.cpp + PluginManagerTests.cpp + SourceNodeTests.cpp +- RecordNodeTests.cpp +- ProcessorGraphTests.cpp ++ #RecordNodeTests.cpp ++ #ProcessorGraphTests.cpp + EventTests.cpp + DataThreadTests.cpp + GenericProcessorTests.cpp diff --git a/Resources/Scripts/run_unit_tests_linux.sh b/Resources/Scripts/run_unit_tests_linux.sh new file mode 100644 index 000000000..087fd6900 --- /dev/null +++ b/Resources/Scripts/run_unit_tests_linux.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Use first argument as TEST_DIR if provided, otherwise use default +TEST_DIR="${1:-../../Build/TestBin}" + +# Track overall exit code +EXIT_CODE=0 + +# Find all executable files that are not .so files +for test_exec in $(find "$TEST_DIR" -type f -executable ! -name "*.so"); do + echo "Running test: $test_exec" + "$test_exec" + TEST_RESULT=$? + if [ $TEST_RESULT -ne 0 ]; then + EXIT_CODE=1 + fi + echo "----------------------------------------" +done + +exit $EXIT_CODE \ No newline at end of file diff --git a/Source/MainWindow.cpp b/Source/MainWindow.cpp index 493049641..7aa098152 100644 --- a/Source/MainWindow.cpp +++ b/Source/MainWindow.cpp @@ -142,7 +142,17 @@ MainWindow::MainWindow (const File& fileToLoad, bool isConsoleApp_) : isConsoleA #ifdef JUCE_WINDOWS documentWindow->setUsingNativeTitleBar (false); #ifdef NDEBUG - ShowWindow (GetConsoleWindow(), SW_HIDE); + // Only hide console if launched by double-clicking (not from CLI) + // Check if console is attached to a parent process (CLI) or standalone + DWORD processList[2]; + DWORD processCount = GetConsoleProcessList (processList, 2); + + // If only 1 process (this app), it was launched by double-clicking + // If more than 1, it was launched from an existing console (CLI) + if (processCount == 1) + { + ShowWindow (GetConsoleWindow(), SW_HIDE); + } #endif #else documentWindow->setUsingNativeTitleBar (true); // Use native title bar on Mac and Linux @@ -244,6 +254,13 @@ MainWindow::MainWindow (const File& fileToLoad, bool isConsoleApp_) : isConsoleA LatestVersionCheckerAndUpdater::getInstance()->checkForNewVersion (true, this); #endif + // Check for plugin updates and notify user if any are available + if (! isConsoleApp) + { + UIComponent* ui = (UIComponent*) documentWindow->getContentComponent(); + ui->checkForPluginUpdates(); + } + Process::setPriority (Process::HighPriority); } diff --git a/Source/Processors/AudioMonitor/AudioMonitor.cpp b/Source/Processors/AudioMonitor/AudioMonitor.cpp index 8f9658ed4..68f7a0bd2 100644 --- a/Source/Processors/AudioMonitor/AudioMonitor.cpp +++ b/Source/Processors/AudioMonitor/AudioMonitor.cpp @@ -169,7 +169,8 @@ void AudioMonitor::updateSettings() CategoricalParameter* spikeChanParam = (CategoricalParameter*) stream->getParameter ("spike_channel"); spikeChanParam->setCategories (spikeChannelNames); - parameterValueChanged (stream->getParameter ("spike_channel")); + if (spikeChanParam->getSelectedIndex() > 0) + parameterValueChanged (stream->getParameter ("spike_channel")); } } diff --git a/Source/Processors/FileReader/BinaryFileSource/BinaryFileSource.cpp b/Source/Processors/FileReader/BinaryFileSource/BinaryFileSource.cpp index a095c1e3d..75e633861 100644 --- a/Source/Processors/FileReader/BinaryFileSource/BinaryFileSource.cpp +++ b/Source/Processors/FileReader/BinaryFileSource/BinaryFileSource.cpp @@ -77,7 +77,7 @@ void BinaryFileSource::fillRecordInfo() String sampleNumbersFilename; String channelStatesFilename; - int majorVersion = guiVersion.substring(0, 1).getIntValue(); + int majorVersion = guiVersion.substring (0, 1).getIntValue(); int minorVersion = guiVersion.substring (2, 3).getIntValue(); if (minorVersion < 6 && majorVersion == 0) @@ -125,9 +125,27 @@ void BinaryFileSource::fillRecordInfo() File dataFile = m_rootPath.getChildFile ("continuous").getChildFile (streamName).getChildFile ("continuous.dat"); if (! dataFile.existsAsFile()) + { + LOGE ("Continuous data file not found: ", dataFile.getFullPathName()); continue; + } int numChannels = record[idNumChannels]; + + // if numchannels is not equal to the size of channels var, skip this record + if (numChannels != channels.size()) + { + LOGE ("Number of channels mismatch in stream: ", streamName); + continue; + } + + // if numSamples is not a whole number, skip this record + if (dataFile.getSize() % (numChannels * sizeof (int16)) != 0) + { + LOGE ("File size is not consistent with number of channels in stream: ", streamName); + continue; + } + int64 numSamples = (dataFile.getSize() / numChannels) / sizeof (int16); info.name = streamName; @@ -157,7 +175,7 @@ void BinaryFileSource::fillRecordInfo() cInfo.name = chan[idChannelName]; cInfo.bitVolts = chan[idBitVolts]; - cInfo.type = static_cast(int(chan[idType])); + cInfo.type = static_cast (int (chan[idType])); info.channels.add (cInfo); } @@ -196,6 +214,11 @@ void BinaryFileSource::fillRecordInfo() streamName = streamName.trimCharactersAtEnd ("/"); File sampleNumbersFile = m_rootPath.getChildFile ("events").getChildFile (streamName).getChildFile (sampleNumbersFilename); + if (! sampleNumbersFile.existsAsFile()) + { + LOGE ("Sample numbers file not found: ", sampleNumbersFile.getFullPathName(), ". Unable to load events for this stream."); + continue; + } std::unique_ptr sampleNumbersMap (new MemoryMappedFile (sampleNumbersFile, MemoryMappedFile::readOnly)); if (sampleNumbersFile.getSize() == EVENT_HEADER_SIZE_IN_BYTES) @@ -210,6 +233,12 @@ void BinaryFileSource::fillRecordInfo() LOGD ("TTL found"); File channelStatesFile = m_rootPath.getChildFile ("events").getChildFile (streamName).getChildFile (channelStatesFilename); + + if (! channelStatesFile.existsAsFile()) + { + LOGE ("Channel states file not found: ", channelStatesFile.getFullPathName()); + continue; + } LOGD ("Channel States File: ", channelStatesFile.getFullPathName()); std::unique_ptr channelStatesFileMap (new MemoryMappedFile (channelStatesFile, MemoryMappedFile::readOnly)); jassert (channelStatesFileMap.get() != nullptr); @@ -233,6 +262,11 @@ void BinaryFileSource::fillRecordInfo() LOGD ("Message found"); File textFile = m_rootPath.getChildFile ("events").getChildFile (streamName).getChildFile ("text.npy"); + if (! textFile.existsAsFile()) + { + LOGE ("MessageCenter text file not found: ", textFile.getFullPathName()); + continue; + } juce::FileInputStream inputStream (textFile); inputStream.skipNextBytes (10); // \x93NUMPY \x01 \x00 @@ -338,7 +372,7 @@ int BinaryFileSource::readData (float* buffer, int nSamples) } m_samplePos += samplesToRead; - return int(samplesToRead); + return int (samplesToRead); } /* void BinaryFileSource::processChannelData (int16* inBuffer, float* outBuffer, int channel, int64 numSamples) diff --git a/Source/Processors/FileReader/FileReader.cpp b/Source/Processors/FileReader/FileReader.cpp index 9816b1587..a4a12116d 100644 --- a/Source/Processors/FileReader/FileReader.cpp +++ b/Source/Processors/FileReader/FileReader.cpp @@ -262,12 +262,14 @@ bool FileReader::setFile (String fullpath, bool shouldUpdateSignalChain) if (! input) { LOGE ("Error creating file source for extension ", ext); + showWarningAsync ("Failed to open file", "Error creating file source for extension " + ext); return false; } } else { input = nullptr; + showWarningAsync ("Failed to open file", "File type \"" + ext + "\" not supported"); CoreServices::sendStatusMessage ("File type not supported"); return false; } @@ -275,8 +277,8 @@ bool FileReader::setFile (String fullpath, bool shouldUpdateSignalChain) if (! input->openFile (file)) { input = nullptr; + showWarningAsync ("Invalid file", "The selected file is invalid. Please make sure the file is not corrupted and has valid format."); CoreServices::sendStatusMessage ("Invalid file"); - return false; } @@ -284,6 +286,7 @@ bool FileReader::setFile (String fullpath, bool shouldUpdateSignalChain) if (isEmptyFile) { input = nullptr; + showWarningAsync ("Failed to open file", "Continuous data file is missing, empty, or invalid."); CoreServices::sendStatusMessage ("Empty file. Ignoring open operation"); return false; @@ -318,6 +321,17 @@ bool FileReader::setFile (String fullpath, bool shouldUpdateSignalChain) return true; } +void FileReader::showWarningAsync (const String& title, const String& message) const +{ + if (headlessMode) + return; + + MessageManager::callAsync ([title, message]() + { AlertWindow::showMessageBoxAsync (AlertWindow::WarningIcon, + title, + message); }); +} + void FileReader::setActiveStream (int index, bool reset) { //Resets the stream to the beginning if reset flag is true diff --git a/Source/Processors/FileReader/FileReader.h b/Source/Processors/FileReader/FileReader.h index 3cd8cfe26..4f66f8605 100644 --- a/Source/Processors/FileReader/FileReader.h +++ b/Source/Processors/FileReader/FileReader.h @@ -242,6 +242,9 @@ class FileReader : public GenericProcessor, /** Returns a new FileSource object for a given file source */ FileSource* createBuiltInFileSource (int index) const; + /** Shows a warning message asynchronously */ + void showWarningAsync (const String& title, const String& message) const; + /** Holds a path to the default file */ File defaultFile; diff --git a/Source/Processors/FileReader/FileReaderActions.cpp b/Source/Processors/FileReader/FileReaderActions.cpp index ce59d18a6..971fb543c 100644 --- a/Source/Processors/FileReader/FileReaderActions.cpp +++ b/Source/Processors/FileReader/FileReaderActions.cpp @@ -47,8 +47,14 @@ bool SelectFile::perform() // Set the new path - this will trigger the linked parameter changes pathParam->setNextValue (newPath, false); - // Load the new file - processor->setFile (newPath, false); + // Check if the file was loaded successfully + if (processor->getFile().isEmpty() + || ! processor->getFile().equalsIgnoreCase (newPath.toString())) + { + // If loading the file failed, revert the path parameter to the original path + pathParam->setNextValue (originalPath, false); + return false; + } // Set the active stream to the first stream processor->setActiveStream (0, true); diff --git a/Source/Processors/Parameter/Parameter.cpp b/Source/Processors/Parameter/Parameter.cpp index e50a9d6a1..ceed52f97 100755 --- a/Source/Processors/Parameter/Parameter.cpp +++ b/Source/Processors/Parameter/Parameter.cpp @@ -857,15 +857,14 @@ void SelectedChannelsParameter::setChannelCount (int newCount) currentValue = values; } - else if (channelCount == 0 && currentValue.getArray()->size() == 0) // If the current count is 0, set the selected channels to the first maxSelectableChannels channels + else if (channelCount == 0 + && currentValue.getArray()->size() == 0 + && maxSelectableChannels < std::numeric_limits::max()) // If the current count is 0, set the selected channels to the first maxSelectableChannels channels { - for (int i = 0; i < maxSelectableChannels; i++) - { - if (i < newCount) - { - values.add (i); - } - } + const int limit = jmin (maxSelectableChannels, newCount); + values.ensureStorageAllocated (limit); + for (int i = 0; i < limit; ++i) + values.add (i); currentValue = values; } diff --git a/Source/Processors/Parameter/ParameterCollection.cpp b/Source/Processors/Parameter/ParameterCollection.cpp index 017f70f87..8e5d8e7fe 100644 --- a/Source/Processors/Parameter/ParameterCollection.cpp +++ b/Source/Processors/Parameter/ParameterCollection.cpp @@ -87,7 +87,54 @@ void ParameterCollection::copyParameterValuesTo (ParameterOwner* pOwner) for (auto parameter : parameters) { if (pOwner->hasParameter (parameter->getName())) - pOwner->getParameter (parameter->getName())->currentValue = parameter->getValue(); + { + Parameter* targetParam = pOwner->getParameter (parameter->getName()); + + // For MaskChannelsParameter and SelectedChannelsParameter, filter the values + // to only include valid channel indices for the target parameter's channel count + if (parameter->getType() == Parameter::MASK_CHANNELS_PARAM) + { + MaskChannelsParameter* targetMaskParam = (MaskChannelsParameter*) targetParam; + int targetChannelCount = targetMaskParam->getChannelCount(); + const int savedChannelCount = owner.channel_count; + + Array filteredValues; + for (int i = 0; i < parameter->getValue().getArray()->size(); i++) + { + int channelIndex = (int) parameter->getValue()[i]; + if (channelIndex >= 0 && channelIndex < targetChannelCount) + filteredValues.add (channelIndex); + } + + // Auto-include any channels that exist on the device but were absent in the + // saved configuration (e.g., saved with fewer channels than currently available). + if (targetChannelCount > savedChannelCount) + { + for (int ch = savedChannelCount; ch < targetChannelCount; ++ch) + filteredValues.addIfNotAlreadyThere (ch); + } + + targetParam->currentValue = filteredValues; + } + else if (parameter->getType() == Parameter::SELECTED_CHANNELS_PARAM) + { + SelectedChannelsParameter* targetSelectedParam = (SelectedChannelsParameter*) targetParam; + int targetChannelCount = targetSelectedParam->getChannelCount(); + + Array filteredValues; + for (int i = 0; i < parameter->getValue().getArray()->size(); i++) + { + int channelIndex = (int) parameter->getValue()[i]; + if (channelIndex >= 0 && channelIndex < targetChannelCount) + filteredValues.add (channelIndex); + } + targetParam->currentValue = filteredValues; + } + else + { + targetParam->currentValue = parameter->getValue(); + } + } } } diff --git a/Source/Processors/ProcessorGraph/ProcessorGraph.cpp b/Source/Processors/ProcessorGraph/ProcessorGraph.cpp index 39b55310f..230d5bbc8 100644 --- a/Source/Processors/ProcessorGraph/ProcessorGraph.cpp +++ b/Source/Processors/ProcessorGraph/ProcessorGraph.cpp @@ -1824,6 +1824,14 @@ void ProcessorGraph::setRecordState (bool isRecording) p->startRecording(); else p->stopRecording(); + + if (auto editor = p->getEditor()) + { + if (isRecording) + editor->startRecording(); + else + editor->stopRecording(); + } } } } diff --git a/Source/Processors/RecordNode/BinaryFormat/BinaryRecording.cpp b/Source/Processors/RecordNode/BinaryFormat/BinaryRecording.cpp index 7c6072093..06951f879 100644 --- a/Source/Processors/RecordNode/BinaryFormat/BinaryRecording.cpp +++ b/Source/Processors/RecordNode/BinaryFormat/BinaryRecording.cpp @@ -26,6 +26,7 @@ #include "../../Settings/DataStream.h" #include "../../Settings/InfoObject.h" +#include "../../../CoreServices.h" #include "../../Events/Spike.h" #define TIC std::chrono::high_resolution_clock::now() @@ -95,7 +96,7 @@ void BinaryRecording::openFiles (File rootFolder, int experimentNumber, int reco if (streamId != lastStreamId) { wroteFirstSampleNumber[streamId] = false; - + firstChannels.add (channelInfo); streamIndex++; @@ -120,7 +121,7 @@ void BinaryRecording::openFiles (File rootFolder, int experimentNumber, int reco singleChannelJSON->setProperty ("history", channelInfo->getHistoryString()); singleChannelJSON->setProperty ("bit_volts", channelInfo->getBitVolts()); singleChannelJSON->setProperty ("units", channelInfo->getUnits()); - singleChannelJSON->setProperty ("type", static_cast(channelInfo->getChannelType())); + singleChannelJSON->setProperty ("type", static_cast (channelInfo->getChannelType())); createChannelMetadata (channelInfo, singleChannelJSON); singleStreamJSON.add (var (singleChannelJSON)); @@ -140,11 +141,14 @@ void BinaryRecording::openFiles (File rootFolder, int experimentNumber, int reco String datPath = getProcessorString (ch); String filename = contPath + datPath + "continuous.dat"; - LOGD ("Creating file: ", contPath, datPath, "sample_numbers.npy"); - ScopedPointer tFile = new NpyFile (contPath + datPath + "sample_numbers.npy", NpyType (BaseType::INT64, 1)); + String samplesPath = contPath + datPath + "sample_numbers.npy"; + LOGD ("Creating file: ", samplesPath); + ScopedPointer tFile = new NpyFile (samplesPath, NpyType (BaseType::INT64, 1)); m_dataTimestampFiles.add (tFile.release()); - ScopedPointer syncTimestampFile = new NpyFile (contPath + datPath + "timestamps.npy", NpyType (BaseType::DOUBLE, 1)); + String syncTimestampPath = contPath + datPath + "timestamps.npy"; + LOGD ("Creating file: ", syncTimestampPath); + ScopedPointer syncTimestampFile = new NpyFile (syncTimestampPath, NpyType (BaseType::DOUBLE, 1)); m_dataSyncTimestampFiles.add (syncTimestampFile.release()); DynamicObject::Ptr fileJSON = new DynamicObject(); @@ -332,7 +336,113 @@ void BinaryRecording::openFiles (File rootFolder, int experimentNumber, int reco settingsJSON->writeAsJSON (settingsFileStream, JSON::FormatOptions {}.withIndentLevel (2).withSpacing (JSON::Spacing::multiLine).withMaxDecimalPlaces (10)); - + // Validate that all files were opened successfully + if (! validateOpenFiles()) + { + String errorMsg = "Recording stopped! Failed to open one or more recording files. Please check disk space and write permissions."; + + LOGE ("BinaryRecording::openFiles: ", errorMsg); + + // Stop recording and show error message on the message thread + CoreServices::setRecordingStatus (false); + MessageManager::callAsync ([] + { CoreServices::sendStatusMessage ("Unable to start recording. Please check the console for errors."); }); + } +} + +bool BinaryRecording::validateOpenFiles() const +{ + bool allFilesValid = true; + + // Check continuous data files (SequentialBlockFile) + for (int i = 0; i < m_continuousFiles.size(); i++) + { + if (m_continuousFiles[i] == nullptr) + { + allFilesValid = false; + } + } + + // Check timestamp files + for (int i = 0; i < m_dataTimestampFiles.size(); i++) + { + if (m_dataTimestampFiles[i] == nullptr || ! m_dataTimestampFiles[i]->isOpen()) + { + allFilesValid = false; + } + } + + // Check sync timestamp files + for (int i = 0; i < m_dataSyncTimestampFiles.size(); i++) + { + if (m_dataSyncTimestampFiles[i] == nullptr || ! m_dataSyncTimestampFiles[i]->isOpen()) + { + allFilesValid = false; + } + } + + // Check event files + for (int i = 0; i < m_eventFiles.size(); i++) + { + EventRecording* rec = m_eventFiles[i]; + if (rec != nullptr) + { + if (rec->data == nullptr || ! rec->data->isOpen()) + { + allFilesValid = false; + } + if (rec->samples == nullptr || ! rec->samples->isOpen()) + { + allFilesValid = false; + } + if (rec->timestamps == nullptr || ! rec->timestamps->isOpen()) + { + allFilesValid = false; + } + // extraFile is optional (only for TTL full words) + if (rec->extraFile != nullptr && ! rec->extraFile->isOpen()) + { + allFilesValid = false; + } + } + } + + // Check spike files + for (int i = 0; i < m_spikeFiles.size(); i++) + { + EventRecording* rec = m_spikeFiles[i]; + if (rec != nullptr) + { + if (rec->data == nullptr || ! rec->data->isOpen()) + { + allFilesValid = false; + } + if (rec->samples == nullptr || ! rec->samples->isOpen()) + { + allFilesValid = false; + } + if (rec->timestamps == nullptr || ! rec->timestamps->isOpen()) + { + allFilesValid = false; + } + if (rec->channels == nullptr || ! rec->channels->isOpen()) + { + allFilesValid = false; + } + if (rec->extraFile == nullptr || ! rec->extraFile->isOpen()) + { + allFilesValid = false; + } + } + } + + // Check sync text file + if (m_syncTextFile == nullptr) + { + allFilesValid = false; + } + + return allFilesValid; } std::unique_ptr BinaryRecording::createEventMetadataFile (const MetadataEventObject* channel, String filename, DynamicObject* jsonFile) @@ -549,6 +659,9 @@ void BinaryRecording::writeContinuousData (int writeChannel, /* Get the file index that belongs to the current recording channel */ int fileIndex = m_fileIndexes[writeChannel]; + if (! m_continuousFiles[fileIndex]) + return; + /* Write the data to that file */ m_continuousFiles[fileIndex]->writeChannel ( m_samplesWritten[writeChannel], @@ -565,7 +678,7 @@ void BinaryRecording::writeContinuousData (int writeChannel, uint32 streamId = getContinuousChannel (realChannel)->getStreamId(); - if (! wroteFirstSampleNumber[streamId] ) + if (! wroteFirstSampleNumber[streamId]) { firstSampleNumber[streamId] = baseSampleNumber; wroteFirstSampleNumber[streamId] = true; @@ -682,11 +795,11 @@ void BinaryRecording::writeTimestampSyncText (uint64 streamId, int64 sampleNumbe int64 fsn = firstSampleNumber[streamId]; - if(streamId > 0) + if (streamId > 0) jassert (fsn == sampleNumber); m_syncTextFile->writeText (syncString + "\r\n", false, false, nullptr); - + m_syncTextFile->flush(); } diff --git a/Source/Processors/RecordNode/BinaryFormat/BinaryRecording.h b/Source/Processors/RecordNode/BinaryFormat/BinaryRecording.h index a604dcd73..9932571b0 100644 --- a/Source/Processors/RecordNode/BinaryFormat/BinaryRecording.h +++ b/Source/Processors/RecordNode/BinaryFormat/BinaryRecording.h @@ -89,6 +89,10 @@ class BinaryRecording : public RecordEngine void createChannelMetadata (const MetadataObject* channel, DynamicObject* jsonObject); void writeEventMetadata (const MetadataEvent* event, NpyFile* file); void increaseEventCounts (EventRecording* rec); + + /** Validates that all recording files were opened successfully. + Returns true if all files are valid, false otherwise. */ + bool validateOpenFiles () const; bool m_saveTTLWords { true }; diff --git a/Source/Processors/RecordNode/BinaryFormat/NpyFile.cpp b/Source/Processors/RecordNode/BinaryFormat/NpyFile.cpp index d7226aa79..0bce821bf 100644 --- a/Source/Processors/RecordNode/BinaryFormat/NpyFile.cpp +++ b/Source/Processors/RecordNode/BinaryFormat/NpyFile.cpp @@ -70,7 +70,7 @@ bool NpyFile::openFile (String path) Result res = file.create(); if (res.failed()) { - std::cerr << "Error creating file " << path << ":" << res.getErrorMessage() << std::endl; + LOGD ("Error creating file ", path, ": ", res.getErrorMessage()); file.deleteFile(); Result res = file.create(); LOGD ("Re-creating file: ", path); @@ -108,7 +108,7 @@ void NpyFile::writeHeader (const Array& typeList) String magicStr = "NUMPY"; uint16 ver = 0x0001; // magic = magic number + magic string + magic version - int magicLen = int( sizeof (uint8) + magicStr.getNumBytesAsUTF8() + sizeof (uint16)); + int magicLen = int (sizeof (uint8) + magicStr.getNumBytesAsUTF8() + sizeof (uint16)); int nbytesAlign = 64; // header should use an integer multiple of this many bytes bool multiValue = typeList.size() > 1; @@ -157,7 +157,7 @@ void NpyFile::writeHeader (const Array& typeList) void NpyFile::updateHeader() { - if (true) + if (m_okOpen) // only update if file opened successfully { // overwrite the shape part of the header - even without explicitly calling // m_file->flush(), overwriting seems to trigger a flush to disk, @@ -251,7 +251,7 @@ int NpyType::getTypeLength() const if (type == BaseType::CHAR) return 1; else - return int(length); + return int (length); } String NpyType::getName() const diff --git a/Source/Processors/RecordNode/BinaryFormat/NpyFile.h b/Source/Processors/RecordNode/BinaryFormat/NpyFile.h index 4a5ae79e3..4ea0dbc48 100644 --- a/Source/Processors/RecordNode/BinaryFormat/NpyFile.h +++ b/Source/Processors/RecordNode/BinaryFormat/NpyFile.h @@ -99,6 +99,9 @@ class PLUGIN_API NpyFile /** Increases the count of the number of records in the file (must match the number of samples written) */ void increaseRecordCount (int count = 1); + /** Returns true if the file was opened successfully */ + bool isOpen() const { return m_okOpen; } + private: /** Opens the file at a specified path */ bool openFile (String path); diff --git a/Source/Processors/RecordNode/BinaryFormat/SequentialBlockFile.cpp b/Source/Processors/RecordNode/BinaryFormat/SequentialBlockFile.cpp index d283347ab..d603c0843 100644 --- a/Source/Processors/RecordNode/BinaryFormat/SequentialBlockFile.cpp +++ b/Source/Processors/RecordNode/BinaryFormat/SequentialBlockFile.cpp @@ -44,7 +44,8 @@ SequentialBlockFile::~SequentialBlockFile() } //manually flush the last one to avoid trailing zeroes - m_memBlocks[0]->partialFlush (m_lastBlockFill * m_nChannels); + if (m_memBlocks.size() > 0) + m_memBlocks[0]->partialFlush (m_lastBlockFill * m_nChannels); } bool SequentialBlockFile::openFile (String filename) @@ -53,7 +54,7 @@ bool SequentialBlockFile::openFile (String filename) Result res = file.create(); if (res.failed()) { - std::cerr << "Error creating file " << filename << ":" << res.getErrorMessage() << std::endl; + LOGD ("Error creating file ", filename, ": ", res.getErrorMessage()); file.deleteFile(); Result res = file.create(); LOGD ("Re-creating file: ", filename); @@ -104,7 +105,7 @@ bool SequentialBlockFile::writeChannel (uint64 startPos, int channel, int16* dat while (writtenSamples < nSamples) { int16* blockPtr = m_memBlocks[bIndex]->getData(); - int samplesToWrite = jmin ((nSamples - writtenSamples), (m_samplesPerBlock - int(startIdx))); + int samplesToWrite = jmin ((nSamples - writtenSamples), (m_samplesPerBlock - int (startIdx))); for (int i = 0; i < samplesToWrite; i++) { diff --git a/Source/Processors/RecordNode/RecordNode.cpp b/Source/Processors/RecordNode/RecordNode.cpp index a94e0fcaa..8d0de543e 100755 --- a/Source/Processors/RecordNode/RecordNode.cpp +++ b/Source/Processors/RecordNode/RecordNode.cpp @@ -865,7 +865,24 @@ void RecordNode::startRecording() if (! rootFolder.exists()) { - rootFolder.createDirectory(); + Result res = rootFolder.createDirectory(); + if (res.failed()) + { + LOGE ("Record Node " + String (getNodeId()) + ": Could not create directory: " + rootFolder.getFullPathName(), " -- ", res.getErrorMessage()); + + CoreServices::setRecordingStatus (false); + + if (! headlessMode) + { + AlertWindow::showMessageBoxAsync (AlertWindow::WarningIcon, + "Recording Error", + "Record Node " + String (getNodeId()) + " - Could not create recording directory:\n\n" + + rootFolder.getFullPathName() + "\n\n" + + res.getErrorMessage()); + } + + return; + } } recordThread->setFileComponents (rootFolder, experimentNumber, recordingNumber); @@ -889,6 +906,9 @@ void RecordNode::startRecording() // called by GenericProcessor::setRecording() and CoreServices::setRecordingStatus() void RecordNode::stopRecording() { + if (! isRecording) + return; + isRecording = false; hasRecorded = true; recordingNumber++; // increment recording number within this directory; should be zero for first recording diff --git a/Source/UI/ControlPanel.cpp b/Source/UI/ControlPanel.cpp index 269735889..d9ea87ab6 100755 --- a/Source/UI/ControlPanel.cpp +++ b/Source/UI/ControlPanel.cpp @@ -1048,10 +1048,10 @@ void ControlPanel::startRecording() filenameComponent->setEnabled (false); - graph->setRecordState (true); - LOGC ("Starting recording"); + graph->setRecordState (true); + repaint(); } diff --git a/Source/UI/PluginInstaller.cpp b/Source/UI/PluginInstaller.cpp index 1c41b671a..f5cbbb94f 100644 --- a/Source/UI/PluginInstaller.cpp +++ b/Source/UI/PluginInstaller.cpp @@ -171,6 +171,84 @@ void PluginInstaller::createXmlFile() } } +int PluginInstaller::checkForPluginUpdates() +{ + LOGD ("Checking for plugin updates..."); + + File xmlFile = getPluginsDirectory().getChildFile ("installedPlugins.xml"); + + XmlDocument doc (xmlFile); + std::unique_ptr xml (doc.getDocumentElement()); + + if (xml == 0 || ! xml->hasTagName ("PluginInstaller")) + { + LOGD ("[PluginInstaller] installedPlugins.xml not found."); + return 0; + } + + auto child = xml->getFirstChildElement(); + + String baseUrl = "https://open-ephys-plugin-gateway.herokuapp.com/"; + String response = URL (baseUrl).readEntireTextStream(); + + if (response.isEmpty()) + { + LOGE ("Unable to fetch plugin updates! Please check your internet connection."); + return 0; + } + + var gatewayData; + Result result = JSON::parse (response, gatewayData); + gatewayData = gatewayData.getProperty ("plugins", var()); + + updatablePlugins.clear(); + + for (auto* e : child->getChildIterator()) + { + String pName = e->getTagName(); + String latestVer; + + // Get latest compatible version for this plugin + for (int i = 0; i < gatewayData.size(); i++) + { + if (gatewayData[i].getProperty ("name", "NULL").toString().equalsIgnoreCase (pName)) + { + auto allVersions = gatewayData[i].getProperty ("versions", "NULL").getArray(); + StringArray compatibleVersions; + + for (String depVersion : *allVersions) + { + String apiVer = depVersion.substring (depVersion.indexOf ("I") + 1); + + if (apiVer.equalsIgnoreCase (String (PLUGIN_API_VER))) + compatibleVersions.add (depVersion); + } + + if (! compatibleVersions.isEmpty()) + { + compatibleVersions.sort (false); + latestVer = compatibleVersions[compatibleVersions.size() - 1]; + } + else + { + latestVer = "0.0.0-API" + String (PLUGIN_API_VER); + } + + break; + } + } + + if (latestVer.isNotEmpty() && latestVer.compareNatural (e->getAttributeValue (0)) > 0) + { + updatablePlugins.add (pName); + LOGD ("Plugin update available: ", pName); + } + } + + LOGD ("Found ", updatablePlugins.size(), " plugin(s) with updates available."); + return updatablePlugins.size(); +} + void PluginInstaller::installPluginAndDependency (const String& plugin, String version) { PluginInfoComponent tempInfoComponent; diff --git a/Source/UI/PluginInstaller.h b/Source/UI/PluginInstaller.h index d8d19955a..51a801982 100644 --- a/Source/UI/PluginInstaller.h +++ b/Source/UI/PluginInstaller.h @@ -50,6 +50,10 @@ class PluginInstaller : public DocumentWindow /** Access method to install a plugin directly without interacting with the Plugin Installer interface*/ void installPluginAndDependency (const String& plugin, String version); + /** Checks for plugin updates in the background and populates updatablePlugins array. + * Returns the number of plugins that have updates available. */ + static int checkForPluginUpdates(); + private: WeakReference::Master masterReference; friend class WeakReference; diff --git a/Source/UI/UIComponent.cpp b/Source/UI/UIComponent.cpp index 3dac3400e..b5d992b65 100755 --- a/Source/UI/UIComponent.cpp +++ b/Source/UI/UIComponent.cpp @@ -476,6 +476,32 @@ void UIComponent::setUIBusy (bool busy) repaint(); } +void UIComponent::checkForPluginUpdates() +{ + // Run the check on a background thread to avoid blocking the UI + Thread::launch ([this]() + { + int numUpdates = PluginInstaller::checkForPluginUpdates(); + + if (numUpdates > 0) + { + MessageManager::callAsync ([this, numUpdates]() + { + String message = String (numUpdates) + " plugin update" + + (numUpdates > 1 ? "s" : "") + " available"; + + AttributedString s; + s.setText (message); + s.setColour (findColour (ThemeColours::defaultText)); + s.setJustification (Justification::left); + s.setWordWrap (AttributedString::WordWrap::byWord); + s.setFont (FontOptions ("Inter", "Regular", 16.0f)); + + bubbleMsgComponent->showAt ({5, 5, 195, 32}, s, 4000); + }); + } }); +} + void UIComponent::showBubbleMessage (Component* component, const String& message) { AttributedString s; diff --git a/Source/UI/UIComponent.h b/Source/UI/UIComponent.h index 2b4724398..d05254306 100755 --- a/Source/UI/UIComponent.h +++ b/Source/UI/UIComponent.h @@ -210,6 +210,9 @@ class UIComponent : public Component, /** Sets the busy state of the UIComponent */ void setUIBusy (bool busy); + /** Checks for plugin updates and shows a bubble message if any are available */ + void checkForPluginUpdates(); + private: ScopedPointer dataViewport; ScopedPointer signalChainTabComponent; diff --git a/Tests/Processors/DataThreadTests.cpp b/Tests/Processors/DataThreadTests.cpp index c98c03c84..fde4406a6 100644 --- a/Tests/Processors/DataThreadTests.cpp +++ b/Tests/Processors/DataThreadTests.cpp @@ -115,11 +115,11 @@ class DataThreadTests : public testing::Test TEST_F(DataThreadTests, DataIntegrity) { - tester->startAcquisition(false); + processor->startAcquisition(); int numSamples = 100; auto inputBuffer = createBuffer(1000.0, 20.0, 5, numSamples); writeBlock(inputBuffer); - tester->stopAcquisition(); + processor->stopAcquisition(); } \ No newline at end of file diff --git a/Tests/Processors/RecordNodeTests.cpp b/Tests/Processors/RecordNodeTests.cpp index 786f9a7a5..9bf8d1a4d 100644 --- a/Tests/Processors/RecordNodeTests.cpp +++ b/Tests/Processors/RecordNodeTests.cpp @@ -220,14 +220,14 @@ class RecordNodeTests : public testing::Test { TEST_F(RecordNodeTests, TestInputOutput_Continuous_Single) { int numSamples = 100; - tester->startAcquisition(true); + processor->startAcquisition(); auto inputBuffer = createBuffer(1000.0, 20.0, numChannels, numSamples); writeBlock(inputBuffer); // The record node always flushes its pending writes when stopping acquisition, so we don't need to sleep before // stopping. - tester->stopAcquisition(); + processor->stopAcquisition(); std::vector persistedData; loadContinuousDatFile(&persistedData); @@ -245,7 +245,7 @@ TEST_F(RecordNodeTests, TestInputOutput_Continuous_Single) { } TEST_F(RecordNodeTests, TestInputOutput_Continuous_Multiple) { - tester->startAcquisition(true); + processor->startAcquisition(); int numSamplesPerBlock = 100; int numBlocks = 8; @@ -256,7 +256,7 @@ TEST_F(RecordNodeTests, TestInputOutput_Continuous_Multiple) { inputBuffers.push_back(inputBuffer); } - tester->stopAcquisition(); + processor->stopAcquisition(); std::vector persistedData; loadContinuousDatFile(&persistedData); @@ -277,8 +277,8 @@ TEST_F(RecordNodeTests, TestInputOutput_Continuous_Multiple) { } TEST_F(RecordNodeTests, TestEmpty) { - tester->startAcquisition(true); - tester->stopAcquisition(); + processor->startAcquisition(); + processor->stopAcquisition(); std::vector persistedData; loadContinuousDatFile(&persistedData); @@ -287,7 +287,7 @@ TEST_F(RecordNodeTests, TestEmpty) { TEST_F(RecordNodeTests, TestClipsProperly) { int numSamples = 100; - tester->startAcquisition(true); + processor->startAcquisition(); // The min value is actually -32767, not -32768 like the "true" min std::vector> inputBuffers; @@ -301,7 +301,7 @@ TEST_F(RecordNodeTests, TestClipsProperly) { writeBlock(inputBuffer); inputBuffers.push_back(inputBuffer); - tester->stopAcquisition(); + processor->stopAcquisition(); std::vector persistedData; loadContinuousDatFile(&persistedData); @@ -341,10 +341,10 @@ class CustomBitVolts_RecordNodeTests : public RecordNodeTests { TEST_F(CustomBitVolts_RecordNodeTests, Test_RespectsBitVolts) { int numSamples = 100; - tester->startAcquisition(true); + processor->startAcquisition(); auto inputBuffer = createBuffer(1000.0, 20.0, numChannels, numSamples); writeBlock(inputBuffer); - tester->stopAcquisition(); + processor->stopAcquisition(); std::vector persistedData; loadContinuousDatFile(&persistedData); @@ -370,7 +370,7 @@ TEST_F(CustomBitVolts_RecordNodeTests, Test_RespectsBitVolts) { } TEST_F(RecordNodeTests, Test_PersistsSampleNumbersAndTimestamps) { - tester->startAcquisition(true); + processor->startAcquisition(); int numSamples = 5; for (int i = 0; i < 3; i++) { @@ -417,7 +417,7 @@ TEST_F(RecordNodeTests, Test_PersistsSampleNumbersAndTimestamps) { } TEST_F(RecordNodeTests, Test_PersistsStructureOeBin) { - tester->startAcquisition(true); + processor->startAcquisition(); int numSamples = 5; for (int i = 0; i < 3; i++) { @@ -479,7 +479,7 @@ TEST_F(RecordNodeTests, Test_PersistsEvents) { processor->setRecordEvents(true); processor->updateSettings(); - tester->startAcquisition(true); + processor->startAcquisition(); int numSamples = 5; auto streamId = processor->getDataStreams()[0]->getStreamId(); @@ -492,7 +492,7 @@ TEST_F(RecordNodeTests, Test_PersistsEvents) { true); auto inputBuffer = createBuffer(1000.0, 20.0, numChannels, numSamples); writeBlock(inputBuffer, eventPtr.get()); - tester->stopAcquisition(); + processor->stopAcquisition(); std::filesystem::path sampleNumbersPath; ASSERT_TRUE(eventsPathFor("sample_numbers.npy", &sampleNumbersPath)); diff --git a/Tests/Processors/SourceNodeTests.cpp b/Tests/Processors/SourceNodeTests.cpp index af9a5f99c..67ea9ac57 100644 --- a/Tests/Processors/SourceNodeTests.cpp +++ b/Tests/Processors/SourceNodeTests.cpp @@ -127,11 +127,11 @@ This test verifies that given a Data Thread, the Source Node will perform this w */ TEST_F(SourceNodeTests, DataAcquisition) { - tester->startAcquisition(false); + tester->getSourceNode()->startAcquisition(); int numSamples = 100; auto inputBuffer = createBuffer(1000.0, 20.0, 5, numSamples); writeBlock(inputBuffer); - tester->stopAcquisition(); + tester->getSourceNode()->stopAcquisition(); } \ No newline at end of file diff --git a/Tests/TestHelpers/include/TestFixtures.h b/Tests/TestHelpers/include/TestFixtures.h index b244c24d6..77e1f9f81 100644 --- a/Tests/TestHelpers/include/TestFixtures.h +++ b/Tests/TestHelpers/include/TestFixtures.h @@ -79,9 +79,9 @@ class ProcessorTester LookAndFeel::setDefaultLookAndFeel (customLookAndFeel.get()); // All of these sets the global state in AccessClass in their constructors - audioComponent = std::make_unique(); + //audioComponent = std::make_unique(); processorGraph = std::make_unique (true); - controlPanel = std::make_unique (processorGraph.get(), audioComponent.get(), true); + //controlPanel = std::make_unique (processorGraph.get(), audioComponent.get(), true); SourceNode* snTemp = sourceNodeBuilder.buildSourceNode(); sourceNodeId = nextProcessorId++; @@ -97,12 +97,12 @@ class ProcessorTester sn->initialize (false); sn->setDestNode (nullptr); - controlPanel->updateRecordEngineList(); + //controlPanel->updateRecordEngineList(); // Refresh everything processorGraph->updateSettings (sn); - controlPanel->colourChanged(); + //controlPanel->colourChanged(); } virtual ~ProcessorTester()