From aa5b1209f252a43da2395ccab755f28f924d0d50 Mon Sep 17 00:00:00 2001 From: Joel Meuleman Date: Tue, 3 Feb 2026 11:03:23 -0800 Subject: [PATCH 1/4] Gradients in ambisonic visualizers --- .../AmbisonicsVisualizer.cpp | 332 ++++++------------ .../AmbisonicsVisualizer.h | 38 +- 2 files changed, 121 insertions(+), 249 deletions(-) diff --git a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp index f14cb582..da38fd5b 100644 --- a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp +++ b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp @@ -15,11 +15,9 @@ #include "AmbisonicsVisualizer.h" #include -#include #include "components/src/EclipsaColours.h" #include "components/src/ambisonics_visualizers/ColourLegend.h" -#include "components/src/loudness_meter/LoudnessScale.h" AmbisonicsVisualizer::AmbisonicsVisualizer(AmbisonicsData* ambisonicsData, const VisualizerView& view) @@ -32,7 +30,7 @@ AmbisonicsVisualizer::AmbisonicsVisualizer(AmbisonicsData* ambisonicsData, label_.setColour(juce::Label::backgroundColourId, juce::Colours::transparentBlack); addAndMakeVisible(label_); - startTimerHz(10); + startTimerHz(30); } AmbisonicsVisualizer::~AmbisonicsVisualizer() { setLookAndFeel(nullptr); } @@ -61,11 +59,8 @@ void AmbisonicsVisualizer::paint(juce::Graphics& g) { g.setColour(EclipsaColours::ambisonicsFillGrey); g.fillEllipse(bounds.toFloat()); - if (visualizerElements_.isEmpty()) { - tesselateCircle(g, bounds); - } else { - repaintTesselatedCircle(g); - } + // Draw heatmap + drawHeatmap(g, bounds); if (view_ != VisualizerView::kFront && view_ != VisualizerView::kRear) { if (caratTransform_.isIdentity()) { @@ -204,193 +199,127 @@ AmbisonicsVisualizer::getSpeakerPositions(AmbisonicsData* ambisonicsData) { return speakerPositions; } -void AmbisonicsVisualizer::tesselateCircle(juce::Graphics& g, - const juce::Rectangle& bounds) { +void AmbisonicsVisualizer::timerCallback() { repaint(); } + +void AmbisonicsVisualizer::drawHeatmap(juce::Graphics& g, + const juce::Rectangle& bounds) { std::vector loudnessValues(ambisonicsData_->speakerElevations.size()); - const bool valuesReturned = - ambisonicsData_->speakerLoudnesses.read(loudnessValues); - int colorIndex = 0; - const int radius = bounds.getWidth() / 2; - const int centreX = bounds.getCentreX(); - const int centreY = bounds.getCentreY(); - const int numPoints = 20; - Eigen::VectorXf thetas = Eigen::VectorXf::LinSpaced( - numPoints, 0, 2 * juce::MathConstants::pi); - const Eigen::VectorXf radii = - Eigen::VectorXf::LinSpaced(numPoints, 0, radius); - - // create pie segments for the first two radii - const float pieSegmentRadius = radii[1]; - const float avg_radius = - (pieSegmentRadius / 2) / radius; // ensure the value is normalized - // keep an element index to access memoized elements - int elementIndex = 0; - for (int j = 1; j < numPoints; j++) { // iterate through the thetas - juce::Path tesselatedPatch; - const float theta = thetas[j]; - const float thetaPrev = thetas[j - 1]; - juce::Point arcStart{ - centreX + pieSegmentRadius * std::sin(thetaPrev), - centreY - pieSegmentRadius * std::cos(thetaPrev)}; - tesselatedPatch.startNewSubPath(centreX, centreY); - juce::Point pointA = tesselatedPatch.getCurrentPosition().toInt(); - tesselatedPatch.lineTo(arcStart); - juce::Point pointB = tesselatedPatch.getCurrentPosition().toInt(); - tesselatedPatch.addCentredArc(centreX, centreY, pieSegmentRadius, - pieSegmentRadius, 0, thetaPrev, theta); - juce::Point pointC = tesselatedPatch.getCurrentPosition().toInt(); - tesselatedPatch.lineTo(centreX, centreY); - juce::Point pointD = tesselatedPatch.getCurrentPosition().toInt(); - tesselatedPatch.closeSubPath(); - - // add r & theta to the 3D points - const float avg_theta = (theta + thetaPrev) / 2; - // write to the Visualizer Elements - writeVisualizerElements(tesselatedPatch, - CartesianPoint3D(avg_radius, avg_theta, view_)); - if (valuesReturned) { - float loudness = - gaussianFilter(*visualizerElements_.getLast(), loudnessValues); - juce::Colour colour = ColourLegend::assignColour(loudness); - - visualizerElements_.getLast()->prevColour_ = colour; + ambisonicsData_->speakerLoudnesses.read(loudnessValues); + + const float kRadius = bounds.getWidth() / 2.0f; + const float kCentreX = bounds.getCentreX(); + const float kCentreY = bounds.getCentreY(); + + // Create a clipping region for the circle + juce::Path clipPath; + clipPath.addEllipse(bounds.toFloat()); + g.saveState(); + g.reduceClipRegion(clipPath); + + // Draw a radial gradient for each speaker + for (int i = 0; i < ambisonicsData_->speakerAzimuths.size(); i++) { + float loudness = loudnessValues[i]; + + // Skip silent speakers + const float kSilenceThreshold = -40.0f; + if (loudness < kSilenceThreshold) { + continue; } - g.setColour(visualizerElements_.getLast()->prevColour_); - g.fillPath(tesselatedPatch); - g.strokePath(tesselatedPatch, juce::PathStrokeType(1.f)); - } - // create the rest of the circle - int thetaCount = numPoints; // store the previous theta count - for (int i = 2; i < numPoints; i++) { // iterate through the radii - const float outerRadius = radii[i]; - const float innerRadius = radii[i - 1]; - const float avg_radius = ((outerRadius + innerRadius) / 2) / - radius; // ensure radius is normalized - // try to preserve radial resolution - // scale the number of thetas based on the ratio of the radii - const int newThetaCount = - std::ceil(thetaCount * outerRadius / - innerRadius); // round up to the next integer - thetaCount = newThetaCount; // store the previous theta count - thetas = Eigen::VectorXf::LinSpaced(newThetaCount, 0, - 2 * juce::MathConstants::pi); - for (int j = 1; j < newThetaCount; j++) { // iterate through the thetas - juce::Path tesselatedPatch; - // get the equivalent x and y coordinates of the starting point - // theta is 0 from the circles top centre - juce::Point innerArcStart = { - centreX + innerRadius * std::sin(thetas[j - 1]), - centreY - innerRadius * std::cos(thetas[j - 1])}; - - tesselatedPatch.startNewSubPath(innerArcStart); - - // inner arc is drawn clockwise - tesselatedPatch.addCentredArc(centreX, centreY, innerRadius, innerRadius, - 0, thetas[j - 1], thetas[j], true); - - juce::Point innerArcEnd = tesselatedPatch.getCurrentPosition(); - // theta is 0 from the circles top centre - juce::Point outterArcStart = { - centreX + outerRadius * std::sin(thetas[j]), - centreY - outerRadius * std::cos(thetas[j])}; - - tesselatedPatch.lineTo(outterArcStart); - // outer arc is drawn counter clockwise - tesselatedPatch.addCentredArc(centreX, centreY, outerRadius, outerRadius, - 0, thetas[j], thetas[j - 1]); - - juce::Point outerArcEnd = tesselatedPatch.getCurrentPosition(); - - tesselatedPatch.lineTo(innerArcStart); - tesselatedPatch.closeSubPath(); - - const float avg_theta = (thetas[j] + thetas[j - 1]) / 2; - // write to the Visualizer Elements - writeVisualizerElements(tesselatedPatch, - CartesianPoint3D(avg_radius, avg_theta, view_)); - if (valuesReturned) { - float loudness = - gaussianFilter(*visualizerElements_.getLast(), loudnessValues); - juce::Colour colour = ColourLegend::assignColour(loudness); - - visualizerElements_.getLast()->prevColour_ = colour; - } - g.setColour(visualizerElements_.getLast()->prevColour_); - g.fillPath(tesselatedPatch); - g.strokePath(tesselatedPatch, juce::PathStrokeType(1.f)); - } - } -} + // Get speaker position in 3D + const CartesianPoint3D& kSpeakerPos = speakerPositions_[i]; -void AmbisonicsVisualizer::timerCallback() { repaint(); } + // Project speaker position onto the 2D circle view + const auto kProjectedPoint = + projectSpeakerToView(kSpeakerPos, view_, kCentreX, kCentreY, kRadius); -void AmbisonicsVisualizer::repaintTesselatedCircle(juce::Graphics& g) { - std::vector loudnessValues(ambisonicsData_->speakerElevations.size()); - const bool readValues = - ambisonicsData_->speakerLoudnesses.read(loudnessValues); - // couldn't read the values - // repaint the previous colours - if (!readValues) { - for (auto& element : visualizerElements_) { - g.setColour(element->prevColour_); - g.fillPath(element->tesselationPatch_); - g.strokePath(element->tesselationPatch_, juce::PathStrokeType(1.f)); - } - } else { // able to update the loudness values, paint the new colours - int i = 0; // track element index for debugging - for (auto& element : visualizerElements_) { - float loudness = gaussianFilter(*element, loudnessValues); - juce::Colour colour = ColourLegend::assignColour(loudness); - g.setColour(colour); - g.fillPath(element->tesselationPatch_); - g.strokePath(element->tesselationPatch_, juce::PathStrokeType(1.f)); - element->prevColour_ = colour; - i++; - } - } -} + // Skip if speaker is on the back hemisphere (not visible in this view) + if (!kProjectedPoint.has_value()) continue; + + const auto [kSpeakerX, kSpeakerY] = kProjectedPoint.value(); + + // Map loudness to color + juce::Colour colour = ColourLegend::assignColour(loudness); + + // Create radial gradient from speaker position + // Gradient radius based on loudness (louder = larger influence area) + const float kGradientRadMultiplier = 0.8f; + const float kMinGradientScale = 1.0f; + const float kMaxGradientScale = 1.0f; + const float kGradientRadius = + kRadius * kGradientRadMultiplier * + juce::jmap(loudness, kSilenceThreshold, 0.0f, kMinGradientScale, + kMaxGradientScale); -float AmbisonicsVisualizer::gaussianFilter( - const VisualizerElement& element, - const std::vector& loudnessValues) { - float numerator = 0.f; - float denominator = 0.f; - // create a local copy to iterate over - std::priority_queue> closestSpeakers = - element.closestSpeakers_; - int index = 0; - // closestSpeakers will always have a max size of kNearestSpeakers_ - // element.gaussianFilterWeights_ will always have a size of kNearestSpeakers_ - while (!closestSpeakers.empty() && index < kNearestSpeakers_) { - const auto speaker = closestSpeakers.top(); - const float distance = speaker.first; - const float loudness = loudnessValues[speaker.second]; - const float weight = element.gaussianFilterWeights_[index]; - numerator += loudness * weight; - index++; - closestSpeakers.pop(); + const float kCenterAlpha = 0.8f; + const float kEdgeAlpha = 0.0f; + juce::ColourGradient gradient(colour.withAlpha(kCenterAlpha), // center + kSpeakerX, kSpeakerY, // speaker position + colour.withAlpha(kEdgeAlpha), // edge + kSpeakerX + kGradientRadius, + kSpeakerY, // edge of influence + true); + + g.setGradientFill(gradient); + + // Draw a circle for this speaker's influence area + juce::Rectangle gradientBounds( + kSpeakerX - kGradientRadius, kSpeakerY - kGradientRadius, + kGradientRadius * 2, kGradientRadius * 2); + + g.fillEllipse(gradientBounds); } - return {numerator / element.denominator_}; + + g.restoreState(); } -void AmbisonicsVisualizer::writeVisualizerElements( - const juce::Path& path, const CartesianPoint3D& point) { - // calculate the distance from point i to point j, keeping the k nearest - // speakers - std::priority_queue> closestSpeakers; - for (int j = 0; j < ambisonicsData_->speakerAzimuths.size(); j++) { - float geoDesicDistance = - CartesianPoint3D::geoDesicDistance(point, speakerPositions_[j]); - if (closestSpeakers.size() < kNearestSpeakers_) { - closestSpeakers.push({geoDesicDistance, j}); - } else if (closestSpeakers.top().first > geoDesicDistance) { - closestSpeakers.pop(); - closestSpeakers.push({geoDesicDistance, j}); - } +std::optional> +AmbisonicsVisualizer::projectSpeakerToView(const CartesianPoint3D& speaker3D, + const VisualizerView& view, + float centreX, float centreY, + float radius) { + float x2D, y2D; + + // Coordinate system: +X = front (azimuth 0°), +Y = left (azimuth 90°), +Z = + // up (these come from SAF conventions) + switch (view) { + case VisualizerView::kFront: + if (speaker3D.x < 0) return std::nullopt; // speaker on far side (behind) + x2D = speaker3D.y; + y2D = -speaker3D.z; + break; + case VisualizerView::kRear: + if (speaker3D.x > 0) return std::nullopt; // speaker on far side (front) + x2D = -speaker3D.y; + y2D = -speaker3D.z; + break; + case VisualizerView::kLeft: + if (speaker3D.y < 0) return std::nullopt; // speaker on far side (right) + x2D = speaker3D.x; + y2D = -speaker3D.z; + break; + case VisualizerView::kRight: + if (speaker3D.y > 0) return std::nullopt; // speaker on far side (left) + x2D = -speaker3D.x; + y2D = -speaker3D.z; + break; + case VisualizerView::kTop: + if (speaker3D.z < 0) return std::nullopt; // speaker on far side (below) + x2D = speaker3D.y; + y2D = speaker3D.x; + break; + case VisualizerView::kBottom: + if (speaker3D.z > 0) return std::nullopt; // speaker on far side (above) + x2D = speaker3D.y; + y2D = -speaker3D.x; + break; } - visualizerElements_.add( - std::make_unique(path, point, closestSpeakers)); + + // Convert from [-1, 1] normalized coordinates to pixel coordinates + const float kPixelX = centreX + x2D * radius; + const float kPixelY = centreY + y2D * radius; + + return std::make_pair(kPixelX, kPixelY); } AmbisonicsVisualizer::CartesianPoint3D::CartesianPoint3D(const float& azimuth, @@ -468,36 +397,3 @@ float AmbisonicsVisualizer::CartesianPoint3D::dotProduct( const CartesianPoint3D& vec1, const CartesianPoint3D& vec2) { return vec1.x * vec2.x + vec1.y * vec2.y + vec1.z * vec2.z; } - -AmbisonicsVisualizer::VisualizerElement::VisualizerElement( - const juce::Path& tesselatedPatch, const CartesianPoint3D& position, - const std::priority_queue>& closestSpeakers) - : tesselationPatch_(tesselatedPatch), - position_(position), - closestSpeakers_(closestSpeakers), - gaussianFilterWeights_(calculateWeights()), - denominator_(calculateDenominator()) {} - -std::vector AmbisonicsVisualizer::VisualizerElement::calculateWeights() { - std::vector weights; - std::priority_queue> speakerCopy = - closestSpeakers_; // create a local copy to iterate over - // calculate the weights for the k nearest speakers - while (!speakerCopy.empty()) { - const auto speaker = speakerCopy.top(); - const float distance = speaker.first; - const float weight = - std::exp(-1.f * std::pow(distance, 2) / twoSigmaSquared_); - weights.push_back(weight); - speakerCopy.pop(); - } - return weights; -} - -float AmbisonicsVisualizer::VisualizerElement::calculateDenominator() { - float denominator = 0.f; - for (auto& weight : gaussianFilterWeights_) { - denominator += weight; - } - return denominator; -} diff --git a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h index 75547900..34e9b44c 100644 --- a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h +++ b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h @@ -15,8 +15,9 @@ */ #pragma once + #include -#include +#include #include "../EclipsaColours.h" #include "ColourLegend.h" @@ -72,24 +73,6 @@ class AmbisonicsVisualizer : public juce::Component, public juce::Timer { const CartesianPoint3D& vec2); }; - struct VisualizerElement { - VisualizerElement( - const juce::Path& tesselatedPatch, const CartesianPoint3D& position, - const std::priority_queue>& closestSpeakers); - - const juce::Path tesselationPatch_; - const CartesianPoint3D position_; - const std::priority_queue> closestSpeakers_; - const float twoSigmaSquared_ = 2.f * 0.25f * 0.25f; // sigma = 0.25 - const std::vector gaussianFilterWeights_; - const float denominator_; - juce::Colour prevColour_ = EclipsaColours::inactiveGrey; - - private: - std::vector calculateWeights(); - float calculateDenominator(); - }; - AmbisonicsVisualizer(AmbisonicsData* ambisonicsData, const VisualizerView& view); @@ -117,32 +100,25 @@ class AmbisonicsVisualizer : public juce::Component, public juce::Timer { void adjustDialAspectRatio(juce::Rectangle& dialBounds); void drawCircle(juce::Graphics& g, juce::Rectangle& bounds); void drawCarat(juce::Graphics& g); + void drawHeatmap(juce::Graphics& g, const juce::Rectangle& bounds); + + std::optional> projectSpeakerToView( + const CartesianPoint3D& speaker3D, const VisualizerView& view, + float centreX, float centreY, float radius); // called in initializer list float getCaratRotation(const VisualizerView& view); juce::String getViewText(const VisualizerView& view); - void writeVisualizerElements(const juce::Path& path, - const CartesianPoint3D& point); - void tesselateCircle(juce::Graphics& g, const juce::Rectangle& bounds); - void repaintTesselatedCircle(juce::Graphics& g); - - // returns loudness, using a gaussian filter to smooth the values across the - // kNearestSpeakers_ - float gaussianFilter(const VisualizerElement& element, - const std::vector& loudnessValues); - std::vector getSpeakerPositions( AmbisonicsData* ambisonicsData); AmbisonicsData* ambisonicsData_; VisualizerView view_; - juce::OwnedArray visualizerElements_; juce::AffineTransform caratTransform_; const std::vector speakerPositions_; const juce::Image carat_ = IconStore::getInstance().getCaratIcon(); - const int kNearestSpeakers_ = 8; // number of nearest speakers to display juce::Label label_; // label holds the position text juce::Image image_; // image holds the carat image From 5ee3f374229113fd9d29a9dc8ee77844a0db90ca Mon Sep 17 00:00:00 2001 From: Joel Meuleman Date: Mon, 9 Feb 2026 13:52:05 -0800 Subject: [PATCH 2/4] Add peak decay to ambi viz --- .../AmbisonicsVisualizer.cpp | 40 +++++++++++++++++-- .../AmbisonicsVisualizer.h | 8 ++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp index da38fd5b..cc791733 100644 --- a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp +++ b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp @@ -23,14 +23,16 @@ AmbisonicsVisualizer::AmbisonicsVisualizer(AmbisonicsData* ambisonicsData, const VisualizerView& view) : ambisonicsData_(ambisonicsData), view_(view), - speakerPositions_(getSpeakerPositions(ambisonicsData)) { + speakerPositions_(getSpeakerPositions(ambisonicsData)), + peakLoudnesses_(ambisonicsData->speakerAzimuths.size(), -100.0f), + decayCounters_(ambisonicsData->speakerAzimuths.size(), 0) { label_.setText(getViewText(view), juce::dontSendNotification); label_.setJustificationType(juce::Justification::centred); label_.setColour(juce::Label::textColourId, EclipsaColours::headingGrey); label_.setColour(juce::Label::backgroundColourId, juce::Colours::transparentBlack); addAndMakeVisible(label_); - startTimerHz(30); + startTimerHz(kRefreshRate_); } AmbisonicsVisualizer::~AmbisonicsVisualizer() { setLookAndFeel(nullptr); } @@ -206,6 +208,9 @@ void AmbisonicsVisualizer::drawHeatmap(juce::Graphics& g, std::vector loudnessValues(ambisonicsData_->speakerElevations.size()); ambisonicsData_->speakerLoudnesses.read(loudnessValues); + // Update peak loudnesses with current values + updatePeakLoudnesses(loudnessValues); + const float kRadius = bounds.getWidth() / 2.0f; const float kCentreX = bounds.getCentreX(); const float kCentreY = bounds.getCentreY(); @@ -218,7 +223,8 @@ void AmbisonicsVisualizer::drawHeatmap(juce::Graphics& g, // Draw a radial gradient for each speaker for (int i = 0; i < ambisonicsData_->speakerAzimuths.size(); i++) { - float loudness = loudnessValues[i]; + // Use peak loudness instead of current value + float loudness = peakLoudnesses_[i]; // Skip silent speakers const float kSilenceThreshold = -40.0f; @@ -397,3 +403,31 @@ float AmbisonicsVisualizer::CartesianPoint3D::dotProduct( const CartesianPoint3D& vec1, const CartesianPoint3D& vec2) { return vec1.x * vec2.x + vec1.y * vec2.y + vec1.z * vec2.z; } + +void AmbisonicsVisualizer::updatePeakLoudnesses( + const std::vector& currentLoudnesses) { + for (int i = 0; i < currentLoudnesses.size(); i++) { + float currentLoudness = currentLoudnesses[i]; + + // If current loudness is higher (louder) than peak, update peak and reset + // decay + if (currentLoudness > peakLoudnesses_[i]) { + peakLoudnesses_[i] = currentLoudness; + decayCounters_[i] = static_cast(kRefreshRate_ * kDecayPeriod_); + } else { + // Decrement counter + --decayCounters_[i]; + + // When counter expires, begin decaying the peak + if (decayCounters_[i] <= 0) { + peakLoudnesses_[i] -= kDecayRate_; + + // Don't let peak go below silence threshold + const float kSilenceThreshold = -100.0f; + if (peakLoudnesses_[i] < kSilenceThreshold) { + peakLoudnesses_[i] = kSilenceThreshold; + } + } + } + } +} diff --git a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h index 34e9b44c..90c871c6 100644 --- a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h +++ b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h @@ -101,6 +101,7 @@ class AmbisonicsVisualizer : public juce::Component, public juce::Timer { void drawCircle(juce::Graphics& g, juce::Rectangle& bounds); void drawCarat(juce::Graphics& g); void drawHeatmap(juce::Graphics& g, const juce::Rectangle& bounds); + void updatePeakLoudnesses(const std::vector& currentLoudnesses); std::optional> projectSpeakerToView( const CartesianPoint3D& speaker3D, const VisualizerView& view, @@ -118,6 +119,13 @@ class AmbisonicsVisualizer : public juce::Component, public juce::Timer { juce::AffineTransform caratTransform_; const std::vector speakerPositions_; + + // Peak hold mechanism for speaker loudnesses + std::vector peakLoudnesses_; // Stores peak loudness per speaker + std::vector decayCounters_; // Countdown before decay starts + static constexpr int kRefreshRate_ = 30; // Hz + static constexpr float kDecayPeriod_ = 0.5f; // seconds + static constexpr float kDecayRate_ = 1.0f; // dB per frame after decay period const juce::Image carat_ = IconStore::getInstance().getCaratIcon(); juce::Label label_; // label holds the position text From c97f059f34b2cd5a24e8946f7ab9a2bda6b339d7 Mon Sep 17 00:00:00 2001 From: Joel Meuleman Date: Thu, 12 Feb 2026 13:33:23 -0800 Subject: [PATCH 3/4] Add small integration window to remove flicker --- .../AmbisonicsVisualizer.cpp | 59 ++++++++----------- .../AmbisonicsVisualizer.h | 9 +-- 2 files changed, 29 insertions(+), 39 deletions(-) diff --git a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp index cc791733..4d739158 100644 --- a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp +++ b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp @@ -14,6 +14,7 @@ #include "AmbisonicsVisualizer.h" +#include #include #include "components/src/EclipsaColours.h" @@ -24,8 +25,7 @@ AmbisonicsVisualizer::AmbisonicsVisualizer(AmbisonicsData* ambisonicsData, : ambisonicsData_(ambisonicsData), view_(view), speakerPositions_(getSpeakerPositions(ambisonicsData)), - peakLoudnesses_(ambisonicsData->speakerAzimuths.size(), -100.0f), - decayCounters_(ambisonicsData->speakerAzimuths.size(), 0) { + smoothedLoudnesses_(ambisonicsData->speakerAzimuths.size(), -100.0f) { label_.setText(getViewText(view), juce::dontSendNotification); label_.setJustificationType(juce::Justification::centred); label_.setColour(juce::Label::textColourId, EclipsaColours::headingGrey); @@ -208,8 +208,8 @@ void AmbisonicsVisualizer::drawHeatmap(juce::Graphics& g, std::vector loudnessValues(ambisonicsData_->speakerElevations.size()); ambisonicsData_->speakerLoudnesses.read(loudnessValues); - // Update peak loudnesses with current values - updatePeakLoudnesses(loudnessValues); + // Update smoothed loudnesses with current values + updateSmoothedLoudnesses(loudnessValues); const float kRadius = bounds.getWidth() / 2.0f; const float kCentreX = bounds.getCentreX(); @@ -223,12 +223,12 @@ void AmbisonicsVisualizer::drawHeatmap(juce::Graphics& g, // Draw a radial gradient for each speaker for (int i = 0; i < ambisonicsData_->speakerAzimuths.size(); i++) { - // Use peak loudness instead of current value - float loudness = peakLoudnesses_[i]; + // Use smoothed loudness instead of current value + const float kLoudness = smoothedLoudnesses_[i]; // Skip silent speakers - const float kSilenceThreshold = -40.0f; - if (loudness < kSilenceThreshold) { + const float kSilenceThreshold = -60.0f; + if (kLoudness < kSilenceThreshold) { continue; } @@ -245,7 +245,7 @@ void AmbisonicsVisualizer::drawHeatmap(juce::Graphics& g, const auto [kSpeakerX, kSpeakerY] = kProjectedPoint.value(); // Map loudness to color - juce::Colour colour = ColourLegend::assignColour(loudness); + juce::Colour colour = ColourLegend::assignColour(kLoudness); // Create radial gradient from speaker position // Gradient radius based on loudness (louder = larger influence area) @@ -254,7 +254,7 @@ void AmbisonicsVisualizer::drawHeatmap(juce::Graphics& g, const float kMaxGradientScale = 1.0f; const float kGradientRadius = kRadius * kGradientRadMultiplier * - juce::jmap(loudness, kSilenceThreshold, 0.0f, kMinGradientScale, + juce::jmap(kLoudness, kSilenceThreshold, 0.0f, kMinGradientScale, kMaxGradientScale); const float kCenterAlpha = 0.8f; @@ -404,30 +404,23 @@ float AmbisonicsVisualizer::CartesianPoint3D::dotProduct( return vec1.x * vec2.x + vec1.y * vec2.y + vec1.z * vec2.z; } -void AmbisonicsVisualizer::updatePeakLoudnesses( +void AmbisonicsVisualizer::updateSmoothedLoudnesses( const std::vector& currentLoudnesses) { - for (int i = 0; i < currentLoudnesses.size(); i++) { - float currentLoudness = currentLoudnesses[i]; + const float kWindowSeconds = 0.05f; - // If current loudness is higher (louder) than peak, update peak and reset - // decay - if (currentLoudness > peakLoudnesses_[i]) { - peakLoudnesses_[i] = currentLoudness; - decayCounters_[i] = static_cast(kRefreshRate_ * kDecayPeriod_); - } else { - // Decrement counter - --decayCounters_[i]; - - // When counter expires, begin decaying the peak - if (decayCounters_[i] <= 0) { - peakLoudnesses_[i] -= kDecayRate_; - - // Don't let peak go below silence threshold - const float kSilenceThreshold = -100.0f; - if (peakLoudnesses_[i] < kSilenceThreshold) { - peakLoudnesses_[i] = kSilenceThreshold; - } - } - } + const float dt = 1.0f / static_cast(kRefreshRate_); + const float alpha = 1.0f - std::exp(-dt / kWindowSeconds); + for (int i = 0; i < currentLoudnesses.size(); i++) { + const float kCurrLoudness = + isnan(currentLoudnesses[i]) ? -100.0f : currentLoudnesses[i]; + const float delta = kCurrLoudness - smoothedLoudnesses_[i]; + smoothedLoudnesses_[i] += delta * alpha; } + // Filter out possible NaN and inf values + std::for_each(smoothedLoudnesses_.begin(), smoothedLoudnesses_.end(), + [](float& ldn) { + if (std::isnan(ldn) || std::isinf(ldn)) { + ldn = -100.0f; + } + }); } diff --git a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h index 90c871c6..aa234673 100644 --- a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h +++ b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h @@ -101,7 +101,7 @@ class AmbisonicsVisualizer : public juce::Component, public juce::Timer { void drawCircle(juce::Graphics& g, juce::Rectangle& bounds); void drawCarat(juce::Graphics& g); void drawHeatmap(juce::Graphics& g, const juce::Rectangle& bounds); - void updatePeakLoudnesses(const std::vector& currentLoudnesses); + void updateSmoothedLoudnesses(const std::vector& currentLoudnesses); std::optional> projectSpeakerToView( const CartesianPoint3D& speaker3D, const VisualizerView& view, @@ -120,12 +120,9 @@ class AmbisonicsVisualizer : public juce::Component, public juce::Timer { const std::vector speakerPositions_; - // Peak hold mechanism for speaker loudnesses - std::vector peakLoudnesses_; // Stores peak loudness per speaker - std::vector decayCounters_; // Countdown before decay starts + // Smoothed loudness values for speakers + std::vector smoothedLoudnesses_; // Low-pass filtered loudness static constexpr int kRefreshRate_ = 30; // Hz - static constexpr float kDecayPeriod_ = 0.5f; // seconds - static constexpr float kDecayRate_ = 1.0f; // dB per frame after decay period const juce::Image carat_ = IconStore::getInstance().getCaratIcon(); juce::Label label_; // label holds the position text From 5494e2d135ee2a4d5c3cc2073c7d312ce96a943c Mon Sep 17 00:00:00 2001 From: Joel Meuleman Date: Fri, 13 Feb 2026 13:30:32 -0800 Subject: [PATCH 4/4] Cleanup --- .../ambisonics_visualizers/AmbisonicsVisualizer.cpp | 12 +++++------- .../ambisonics_visualizers/AmbisonicsVisualizer.h | 6 ++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp index 4d739158..2e79df64 100644 --- a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp +++ b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.cpp @@ -14,7 +14,6 @@ #include "AmbisonicsVisualizer.h" -#include #include #include "components/src/EclipsaColours.h" @@ -417,10 +416,9 @@ void AmbisonicsVisualizer::updateSmoothedLoudnesses( smoothedLoudnesses_[i] += delta * alpha; } // Filter out possible NaN and inf values - std::for_each(smoothedLoudnesses_.begin(), smoothedLoudnesses_.end(), - [](float& ldn) { - if (std::isnan(ldn) || std::isinf(ldn)) { - ldn = -100.0f; - } - }); + for (float& loudness : smoothedLoudnesses_) { + if (std::isnan(loudness) || std::isinf(loudness)) { + loudness = -100.0f; + } + } } diff --git a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h index aa234673..cbee9415 100644 --- a/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h +++ b/common/components/src/ambisonics_visualizers/AmbisonicsVisualizer.h @@ -19,8 +19,6 @@ #include #include -#include "../EclipsaColours.h" -#include "ColourLegend.h" #include "data_structures/src/AmbisonicsData.h" class AmbisonicsVisualizer : public juce::Component, public juce::Timer { @@ -121,8 +119,8 @@ class AmbisonicsVisualizer : public juce::Component, public juce::Timer { const std::vector speakerPositions_; // Smoothed loudness values for speakers - std::vector smoothedLoudnesses_; // Low-pass filtered loudness - static constexpr int kRefreshRate_ = 30; // Hz + std::vector smoothedLoudnesses_; + static constexpr int kRefreshRate_ = 30; const juce::Image carat_ = IconStore::getInstance().getCaratIcon(); juce::Label label_; // label holds the position text