diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 89d6dc6..707cb9c 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -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)); @@ -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) @@ -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() @@ -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); @@ -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() @@ -208,4 +243,7 @@ void AirPlayPluginEditor::connectButtonClicked() void AirPlayPluginEditor::disconnectButtonClicked() { audioProcessor.getAirPlayManager().disconnectFromDevice(); + + // Reset meters when disconnected + outputMeter.reset(); } diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index a369fd0..cd5761f 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -3,6 +3,122 @@ #include #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 @@ -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: diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index b15a9b7..8713a0a 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -54,9 +54,40 @@ void AirPlayPluginProcessor::processBlock(juce::AudioBuffer& 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); } } diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index 8bcdedf..e1d412f 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -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 inputLevel{ 0.0f }; + std::atomic outputLevel{ 0.0f }; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AirPlayPluginProcessor) };