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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions Source/PluginEditor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
AirPlayPluginEditor::AirPlayPluginEditor(AirPlayPluginProcessor& p)
: AudioProcessorEditor(&p), audioProcessor(p)
{
setSize(400, 500);
setSize(500, 500);

titleLabel.setText("FreeCaster", juce::dontSendNotification);
titleLabel.setFont(juce::Font(24.0f, juce::Font::bold));
Expand Down Expand Up @@ -41,6 +41,20 @@ AirPlayPluginEditor::AirPlayPluginEditor(AirPlayPluginProcessor& p)
bufferHealthLabel.setFont(juce::Font(11.0f));
bufferHealthLabel.setColour(juce::Label::textColourId, juce::Colours::lightgrey);
addAndMakeVisible(bufferHealthLabel);

// Set up level meters
addAndMakeVisible(inputMeter);
addAndMakeVisible(outputMeter);

inputMeterLabel.setText("Input", juce::dontSendNotification);
inputMeterLabel.setJustificationType(juce::Justification::centred);
inputMeterLabel.setFont(juce::Font(11.0f));
addAndMakeVisible(inputMeterLabel);

outputMeterLabel.setText("Output", juce::dontSendNotification);
outputMeterLabel.setJustificationType(juce::Justification::centred);
outputMeterLabel.setFont(juce::Font(11.0f));
addAndMakeVisible(outputMeterLabel);

// Set up error callbacks
audioProcessor.getAirPlayManager().onError = [this](const juce::String& error)
Expand All @@ -56,7 +70,7 @@ AirPlayPluginEditor::AirPlayPluginEditor(AirPlayPluginProcessor& p)
audioProcessor.getDeviceDiscovery().addListener(this);
updateDeviceList();

startTimer(500); // Update more frequently for better responsiveness
startTimer(16); // ~60Hz for smooth meter updates
}

AirPlayPluginEditor::~AirPlayPluginEditor()
Expand All @@ -72,6 +86,10 @@ void AirPlayPluginEditor::paint(juce::Graphics& g)
void AirPlayPluginEditor::resized()
{
auto area = getLocalBounds().reduced(10);

// Reserve space for meters on the right
auto metersArea = area.removeFromRight(100);
area.removeFromRight(10); // Gap

titleLabel.setBounds(area.removeFromTop(40));
area.removeFromTop(5);
Expand All @@ -89,15 +107,32 @@ void AirPlayPluginEditor::resized()
area.removeFromTop(10);

auto buttonArea = area.removeFromTop(40);
connectButton.setBounds(buttonArea.removeFromLeft(180));
connectButton.setBounds(buttonArea.removeFromLeft(165));
buttonArea.removeFromLeft(10);
disconnectButton.setBounds(buttonArea);

// Layout meters vertically on the right side
metersArea.removeFromTop(40); // Align with title

auto inputMeterArea = metersArea.removeFromLeft(40);
metersArea.removeFromLeft(10);
auto outputMeterArea = metersArea.removeFromLeft(40);

inputMeterLabel.setBounds(inputMeterArea.removeFromTop(20));
inputMeter.setBounds(inputMeterArea.removeFromTop(340));

outputMeterLabel.setBounds(outputMeterArea.removeFromTop(20));
outputMeter.setBounds(outputMeterArea.removeFromTop(340));
}

void AirPlayPluginEditor::timerCallback()
{
updateStatusDisplay();
updateBufferHealth();

// Update level meters
inputMeter.setLevel(audioProcessor.getInputLevel());
outputMeter.setLevel(audioProcessor.getOutputLevel());
}

void AirPlayPluginEditor::updateStatusDisplay()
Expand Down Expand Up @@ -208,4 +243,7 @@ void AirPlayPluginEditor::connectButtonClicked()
void AirPlayPluginEditor::disconnectButtonClicked()
{
audioProcessor.getAirPlayManager().disconnectFromDevice();

// Reset meters when disconnected
outputMeter.reset();
}
122 changes: 122 additions & 0 deletions Source/PluginEditor.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,122 @@
#include <JuceHeader.h>
#include "PluginProcessor.h"

//==============================================================================
// Professional vertical level meter component
class LevelMeter : public juce::Component
{
public:
LevelMeter()
{
setOpaque(false);
}

void paint(juce::Graphics& g) override
{
auto bounds = getLocalBounds().toFloat();

// Draw background
g.setColour(juce::Colour(0xFF1a1a1a));
g.fillRoundedRectangle(bounds, 4.0f);

// Draw scale markings
g.setColour(juce::Colour(0xFF404040));
g.setFont(9.0f);

const float dbMarks[] = { 0.0f, -6.0f, -12.0f, -24.0f, -48.0f };
for (auto db : dbMarks)
{
float yPos = dbToY(db, bounds.getHeight());
g.drawLine(bounds.getX(), yPos, bounds.getX() + 3, yPos, 1.0f);
g.drawText(juce::String((int)db), bounds.getX() + bounds.getWidth() + 2, yPos - 6, 30, 12,
juce::Justification::left, false);
}

// Calculate meter height
float meterHeight = bounds.getHeight() * currentLevel;
float peakHeight = bounds.getHeight() * peakLevel;

if (meterHeight > 0.0f)
{
auto meterBounds = bounds.withTop(bounds.getBottom() - meterHeight).reduced(2.0f);

// Color gradient based on level
juce::ColourGradient gradient(
juce::Colour(0xFF00ff00), meterBounds.getX(), meterBounds.getBottom(),
juce::Colour(0xFFffff00), meterBounds.getX(), meterBounds.getY(),
false);

gradient.addColour(0.7, juce::Colour(0xFF00ff00)); // Green
gradient.addColour(0.85, juce::Colour(0xFFffff00)); // Yellow
gradient.addColour(1.0, juce::Colour(0xFFff0000)); // Red

g.setGradientFill(gradient);
g.fillRoundedRectangle(meterBounds, 2.0f);
}

// Draw peak hold indicator
if (peakLevel > 0.0f)
{
float peakY = bounds.getBottom() - peakHeight;
g.setColour(peakLevel > 0.9f ? juce::Colours::red : juce::Colours::white);
g.fillRect(bounds.getX() + 1, peakY - 1, bounds.getWidth() - 2, 2.0f);
}

// Draw border
g.setColour(juce::Colour(0xFF505050));
g.drawRoundedRectangle(bounds, 4.0f, 1.0f);
}

void setLevel(float newLevel)
{
// Convert to normalized 0-1 range (assuming input is in 0-1 linear amplitude)
currentLevel = juce::jlimit(0.0f, 1.0f, newLevel);

// Update peak hold
if (currentLevel > peakLevel)
{
peakLevel = currentLevel;
peakHoldCounter = 60; // Hold for 60 frames (~1 second at 60Hz)
}
else if (peakHoldCounter > 0)
{
peakHoldCounter--;
}
else
{
// Decay peak
peakLevel *= 0.95f;
if (peakLevel < 0.001f)
peakLevel = 0.0f;
}

repaint();
}

void reset()
{
currentLevel = 0.0f;
peakLevel = 0.0f;
peakHoldCounter = 0;
repaint();
}

private:
float dbToY(float db, float height) const
{
// Map dB to Y position (0dB at top, -inf at bottom)
float normalized = (db + 60.0f) / 60.0f; // -60dB to 0dB range
return height * (1.0f - juce::jlimit(0.0f, 1.0f, normalized));
}

float currentLevel = 0.0f;
float peakLevel = 0.0f;
int peakHoldCounter = 0;

JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(LevelMeter)
};

//==============================================================================
class AirPlayPluginEditor : public juce::AudioProcessorEditor,
public juce::Timer,
public DeviceDiscovery::Listener
Expand Down Expand Up @@ -40,6 +156,12 @@ class AirPlayPluginEditor : public juce::AudioProcessorEditor,
juce::Label errorLabel;
juce::Label bufferHealthLabel;

// Level meters
LevelMeter inputMeter;
LevelMeter outputMeter;
juce::Label inputMeterLabel;
juce::Label outputMeterLabel;

class DeviceListModel : public juce::ListBoxModel
{
public:
Expand Down
31 changes: 31 additions & 0 deletions Source/PluginProcessor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,40 @@ void AirPlayPluginProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce
{
juce::ScopedNoDenormals noDenormals;

// Calculate input level (RMS)
float inputRMS = 0.0f;
for (int channel = 0; channel < buffer.getNumChannels(); ++channel)
{
const float* channelData = buffer.getReadPointer(channel);
float channelRMS = 0.0f;

for (int sample = 0; sample < buffer.getNumSamples(); ++sample)
{
channelRMS += channelData[sample] * channelData[sample];
}

channelRMS = std::sqrt(channelRMS / buffer.getNumSamples());
inputRMS = juce::jmax(inputRMS, channelRMS);
}

// Convert RMS to normalized level (0-1) with some headroom
// RMS of 0.7 (~-3dB) maps to full scale
float normalizedInput = juce::jlimit(0.0f, 1.0f, inputRMS / 0.7f);
inputLevel.store(normalizedInput);

// Send to AirPlay if connected
if (airPlayManager.isConnected())
{
airPlayManager.pushAudioData(buffer, buffer.getNumSamples());

// Output level is the same as input for a pass-through plugin
// (In a real scenario with processing, this might differ)
outputLevel.store(normalizedInput);
}
else
{
// Reset output level when not connected
outputLevel.store(0.0f);
}
}

Expand Down
8 changes: 8 additions & 0 deletions Source/PluginProcessor.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ class AirPlayPluginProcessor : public juce::AudioProcessor
AirPlayManager& getAirPlayManager() { return airPlayManager; }
DeviceDiscovery& getDeviceDiscovery() { return deviceDiscovery; }

// Level meter accessors
float getInputLevel() const { return inputLevel.load(); }
float getOutputLevel() const { return outputLevel.load(); }

private:
AirPlayManager airPlayManager;
DeviceDiscovery deviceDiscovery;

// Level tracking for meters (thread-safe)
std::atomic<float> inputLevel{ 0.0f };
std::atomic<float> outputLevel{ 0.0f };

JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AirPlayPluginProcessor)
};